From ed4ca02a88a58f4f2180edbaae2dd0dfd486e496 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:12:21 +0700 Subject: [PATCH] A building's pool draws only when its room is visited; type from row one (#2937) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A building's pool drew forever once its room had been visited, and a portal-less building with an area id never drew its pool, Northshire Abbey's wall fountain among them. In 1.12.1 the flood's visit `0x685d70` links a visited group into the liquid list (`0x685f6b`, the only link), and the liquid pass `0x684cd0` unlinks each record as it draws (`0x684e1d`). So a pool draws exactly in the frames its group is visited. The latch is gone, and the fountain draws. Across 184,624 camera poses around the Great Forge, benilla's flood keeps the lava wherever the reference's portal graph does. A pool's liquid type read every tile row and the group's override. `0x6ba970` scans only the first row and takes the first wet nibble, and its caller never reads the override. benilla now does the same. `WOW_LIQUID_TRACE=1` prints each pool's per-frame verdict. Fixes #119 Fixes #65 Fixes #2492 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/creature_anim/lod.rs | 1 - crates/benilla-app/src/player/world_focus.rs | 1 - .../benilla-formats/src/models/wmo/group.rs | 72 +++-- crates/benilla-world/src/doodad_anim/mod.rs | 1 - crates/benilla-world/src/exterior_cull.rs | 1 - crates/benilla-world/src/interior.rs | 1 - crates/benilla-world/src/model_render.rs | 6 + .../src/model_render/visibility.rs | 298 +++++++++++++++++- crates/benilla-world/src/particles.rs | 1 - .../src/terrain_stream/spawn/mod.rs | 1 - .../benilla-world/src/wmo_portal/interior.rs | 1 - crates/benilla-world/src/wmo_portal/mod.rs | 25 +- docs/DEVIATIONS.md | 2 +- docs/MAP.md | 1 + 14 files changed, 347 insertions(+), 65 deletions(-) diff --git a/crates/benilla-app/src/creature_anim/lod.rs b/crates/benilla-app/src/creature_anim/lod.rs index 749d7a1a4..25360404c 100644 --- a/crates/benilla-app/src/creature_anim/lod.rs +++ b/crates/benilla-app/src/creature_anim/lod.rs @@ -514,7 +514,6 @@ mod tests { name_set: 0, visible: vec![false, false], interior_fog: vec![false, false], - liquid_visited: vec![false, false], flooded: vec![None, None], }) .id(); diff --git a/crates/benilla-app/src/player/world_focus.rs b/crates/benilla-app/src/player/world_focus.rs index b30593a44..278c85f1e 100644 --- a/crates/benilla-app/src/player/world_focus.rs +++ b/crates/benilla-app/src/player/world_focus.rs @@ -295,7 +295,6 @@ pub(crate) fn insert_test_room(world: &mut World, flags: u32) { name_set: 0, visible: vec![true], interior_fog: vec![false], - liquid_visited: vec![false], flooded: vec![None], }); } diff --git a/crates/benilla-formats/src/models/wmo/group.rs b/crates/benilla-formats/src/models/wmo/group.rs index c021d972d..05f0fef65 100644 --- a/crates/benilla-formats/src/models/wmo/group.rs +++ b/crates/benilla-formats/src/models/wmo/group.rs @@ -196,11 +196,11 @@ pub fn wmo_group_liquid_mesh(group_bytes: &[u8]) -> Option { return None; }; let lq = group.liquid?; - build_wmo_liquid_mesh(&lq, group.group_liquid) + build_wmo_liquid_mesh(&lq) } -/// The [`LiquidMesh`] for a parsed [`WmoLiquid`] grid and its group's `groupLiquid`. -fn build_wmo_liquid_mesh(lq: &WmoLiquid, group_liquid: u32) -> Option { +/// The [`LiquidMesh`] for a parsed [`WmoLiquid`] grid. +fn build_wmo_liquid_mesh(lq: &WmoLiquid) -> Option { let (xv, yv, xt, yt) = ( lq.xverts as usize, lq.yverts as usize, @@ -214,13 +214,13 @@ fn build_wmo_liquid_mesh(lq: &WmoLiquid, group_liquid: u32) -> Option() .add_systems(Update, (scope_model_materials, evict_model_materials)); diff --git a/crates/benilla-world/src/model_render/visibility.rs b/crates/benilla-world/src/model_render/visibility.rs index 3038a6424..1e850552a 100644 --- a/crates/benilla-world/src/model_render/visibility.rs +++ b/crates/benilla-world/src/model_render/visibility.rs @@ -257,16 +257,15 @@ pub(super) fn apply_model_visibility( }, ); - // A building's MLIQ surfaces, gated by the ever-visited latch, not this frame's PVS: a group's - // liquid draws from its first visit for the rest of the placement's residency. An index past - // the latch fails open. + // A building's MLIQ surfaces, on this frame's PVS: the flood's visit callback links a visited + // group into the liquid list `0xc7cafc` (`0x685f66`), and the liquid pass unlinks each one it + // draws (`0x684e21`), so a pool draws only in a frame its group is visited. for (gv, mut vis, xf, aabb, exterior, tag) in &mut group_only { let portal_ok = !m.portal_cull - || instances.get(gv.instance).ok().is_none_or(|inst| { - gv.groups - .iter() - .any(|&g| inst.liquid_visited.get(g as usize).copied().unwrap_or(true)) - }); + || instances + .get(gv.instance) + .ok() + .is_none_or(|inst| gv.drawn_by(inst)); // The same exterior-window term as the submesh walk. let exterior_ok = !exterior || Some(gv.instance) == own_instance || gate.admits(xf, aabb); // A building's water rides the building's toggle: the reference's WMO liquid drain @@ -281,9 +280,8 @@ pub(super) fn apply_model_visibility( if *vis != want { *vis = want; } - // The pool's fog follows this frame's flood, not the latch, as the reference's WMO liquid - // pass pushes interior fog under the same `[0xca7f00]` as the geometry; a pool drawn by - // the latch alone wears scene fog. `liquid.wgsl` ANDs it with the surface's own class. + // The reference's WMO liquid pass pushes interior fog under the same `[0xca7f00]` as the + // geometry. `liquid.wgsl` ANDs it with the surface's own class. if let Some(mut tag) = tag { let on = instances .get(gv.instance) @@ -395,11 +393,52 @@ pub(super) fn trace_model_visibility( } } +/// `WOW_LIQUID_TRACE=1` prints, every frame, each building pool's group, that group's PVS bit and +/// the pool's verdict: `vis` is this frame's, `view` (Bevy's frustum cull) last frame's. +#[derive(Resource, Default)] +pub struct LiquidTrace { + frame: u64, +} + +impl LiquidTrace { + /// `None` unless `WOW_LIQUID_TRACE` is set to anything but empty. + pub(crate) fn from_env() -> Option { + std::env::var_os("WOW_LIQUID_TRACE") + .filter(|v| !v.is_empty()) + .map(|_| Self::default()) + } +} + +/// Prints [`LiquidTrace`]; runs after the authority. +#[allow(clippy::type_complexity)] +pub(super) fn trace_wmo_liquid( + mut trace: ResMut, + instances: Query<&WmoPortalInstance>, + pools: Query<(Entity, &WmoGroupVis, &Visibility, Option<&ViewVisibility>), Without>, +) { + trace.frame += 1; + for (e, gv, vis, view) in &pools { + let pvs = instances + .get(gv.instance) + .ok() + .map(|inst| gv.drawn_by(inst)); + println!( + "LIQUID_TRACE frame={} {e} inst={} groups={:?} pvs={} vis={vis:?} view={}", + trace.frame, + gv.instance, + gv.groups, + pvs.map_or("-".into(), |p| p.to_string()), + view.map_or("-".into(), |v| v.get().to_string()), + ); + } +} + #[cfg(test)] mod tests { use super::*; use crate::billboard::BillboardCard; use benilla_assets::BillboardInfo; + use benilla_assets::{WmoGroupNav, WmoModel, WmoPortalInfo, WmoPortalRef}; use benilla_formats::{BillboardKind, ModelBlend}; #[test] @@ -476,8 +515,8 @@ mod tests { .init_resource::() .init_resource::() .add_systems(Update, apply_model_visibility); - // A portal-less prop's pool: its instance has no latch, so `portal_ok` fails open and the - // toggle is the only term in play. + // An instance entity with no `WmoPortalInstance`: `portal_ok` fails open and the toggle is + // the only term in play. let instance = app.world_mut().spawn(()).id(); let pool = app .world_mut() @@ -524,4 +563,237 @@ mod tests { "the doodad toggle is a different bit and must not touch a building's water" ); } + + /// The flood and the authority over one placement of `model` at the origin, with a pool in + /// `group` keyed as the spawn site keys it, and a world camera to aim with [`aim`]. + fn pool_app(model: WmoModel, group: u16) -> (App, Entity) { + let mut app = App::new(); + app.init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::>() + .init_resource::>() + .add_systems( + Update, + (crate::wmo_portal::compute_wmo_pvs, apply_model_visibility).chain(), + ); + let groups = model.group_nav.len(); + let handle = app + .world_mut() + .resource_mut::>() + .add(model); + let instance = app + .world_mut() + .spawn(WmoPortalInstance { + handle, + world_from_local: bevy::math::Affine3A::IDENTITY, + name_set: 0, + visible: vec![true; groups], + interior_fog: vec![false; groups], + flooded: vec![None; groups], + }) + .id(); + let pool = app + .world_mut() + .spawn(( + WmoGroupVis { + instance, + groups: std::sync::Arc::from([group].as_slice()), + }, + Visibility::Inherited, + GlobalTransform::IDENTITY, + )) + .id(); + app.world_mut().spawn(( + WorldCamera, + Transform::IDENTITY, + GlobalTransform::IDENTITY, + Projection::Perspective(PerspectiveProjection { + fov: 0.9, + aspect_ratio: 16.0 / 9.0, + near: 0.1, + far: 1000.0, + ..default() + }), + )); + (app, pool) + } + + /// Puts the world camera at WoW model-space `eye`, looking at `at`, and runs a frame. + fn aim(app: &mut App, eye: [f32; 3], at: [f32; 3]) { + use benilla_assets::coords::wow_to_bevy; + let pose = + Transform::from_translation(wow_to_bevy(eye)).looking_at(wow_to_bevy(at), Vec3::Y); + let mut q = app + .world_mut() + .query_filtered::<(&mut Transform, &mut GlobalTransform), With>(); + let (mut t, mut g) = q.single_mut(app.world_mut()).expect("one world camera"); + *t = pose; + *g = GlobalTransform::from(pose); + app.update(); + } + + fn drawn(app: &App, pool: Entity) -> bool { + *app.world().entity(pool).get::().unwrap() == Visibility::Inherited + } + + /// Two interior rooms joined by a doorway in the WoW `x = 0` plane (y in [-0.5, 0.5], z in + /// [0, 2]): `g0`, floored at `z = 0`, holds the camera, and `g1` beyond holds the pool. + fn two_rooms() -> WmoModel { + let room = |min: [f32; 3], max: [f32; 3], ref_start: u16| WmoGroupNav { + flags: 0x2000, + bbox_min: min, + bbox_max: max, + ref_start, + ref_count: 1, + area_table_id: 0, + fog_indices: [0; 4], + group_liquid: benilla_formats::NO_GROUP_LIQUID, + }; + WmoModel { + portal_vertices: vec![ + [0.0, -0.5, 0.0], + [0.0, 0.5, 0.0], + [0.0, 0.5, 2.0], + [0.0, -0.5, 2.0], + ], + portal_infos: vec![WmoPortalInfo { + start_vertex: 0, + count: 4, + plane: [1.0, 0.0, 0.0, 0.0], + }], + // `side = -1` puts an eye at x < 0 on g0's entering half-space. + portal_refs: vec![ + WmoPortalRef { + portal: 0, + group: 1, + side: -1, + }, + WmoPortalRef { + portal: 0, + group: 0, + side: 1, + }, + ], + group_nav: vec![ + room([-10.0, -10.0, 0.0], [0.0, 10.0, 3.0], 0), + room([0.0, -10.0, 0.0], [10.0, 10.0, 3.0], 1), + ], + group_collision_tris: vec![ + vec![ + [[-10.0, -10.0, 0.0], [0.0, -10.0, 0.0], [0.0, 10.0, 0.0]], + [[-10.0, -10.0, 0.0], [0.0, 10.0, 0.0], [-10.0, 10.0, 0.0]], + ], + Vec::new(), + ], + ..WmoModel::default() + } + } + + /// A building's pool draws off this frame's flood: the reference links a visited group into + /// the liquid list `0xc7cafc` (`0x685f66`) and unlinks it as it draws (`0x684e21`), so a + /// room the flood stops reaching loses its pool that frame, and gets it back with the room. + #[test] + fn a_pool_follows_this_frames_pvs() { + let (mut app, pool) = pool_app(two_rooms(), 1); + let eye = [-3.0, 0.0, 1.0]; + let room_visible = |app: &mut App| { + let mut q = app.world_mut().query::<&WmoPortalInstance>(); + q.single(app.world()).expect("one instance").visible[1] + }; + + aim(&mut app, eye, [10.0, 0.0, 1.0]); + assert!( + room_visible(&mut app), + "the flood crosses the doorway ahead" + ); + assert!(drawn(&app, pool), "the far room's pool draws with its room"); + + aim(&mut app, eye, [-10.0, 0.0, 1.0]); + assert!(!room_visible(&mut app), "the doorway is behind the camera"); + assert!( + !drawn(&app, pool), + "a pool whose room the flood no longer visits does not draw, though it drew last frame" + ); + + aim(&mut app, eye, [10.0, 0.0, 1.0]); + assert!(drawn(&app, pool), "the room is back, and its pool with it"); + } + + /// A building without a portal graph keeps every group visible, so its pool draws though an + /// area id gives it an instance. + #[test] + fn a_portal_less_buildings_pool_draws() { + let model = WmoModel { + wmo_id: 63, + group_nav: vec![WmoGroupNav { + flags: 0x1000, + bbox_min: [-5.0; 3], + bbox_max: [5.0; 3], + ref_start: 0, + ref_count: 0, + area_table_id: 0, + fog_indices: [0; 4], + group_liquid: benilla_formats::NO_GROUP_LIQUID, + }], + ..WmoModel::default() + }; + let (mut app, pool) = pool_app(model, 0); + for at in [[10.0, 0.0, 0.0], [-10.0, 0.0, 0.0]] { + aim(&mut app, [0.0, 0.0, 20.0], at); + assert!(drawn(&app, pool), "the fountain's pool draws from any view"); + } + } + + /// Northshire Abbey's wall fountain, `abbeygate01.wmo`: no portal graph and WMO id 63, so its + /// placement takes an instance (the spawn site's `has_portals || wmo_id != 0`), and group 0 + /// holds still water, which draws. + #[test] + fn the_abbey_wall_fountain_draws_its_pool() { + let data = benilla_formats::wow_data_or_skip!(); + let chain = benilla_formats::open_chain(&data).expect("open the MPQ chain"); + let path = r"World\wmo\Azeroth\Collidable Doodads\Elwynn\AbbeyGate\abbeygate01.wmo"; + let root_bytes = chain.read(path).expect("abbeygate01.wmo ships"); + let root = benilla_formats::parse_wmo_root(&root_bytes).expect("parse the root"); + let group = chain + .read(&path.replace(".wmo", "_000.wmo")) + .expect("its one group ships"); + let portals = root.portals(); + assert!( + portals.refs.is_empty() && portals.infos.is_empty(), + "the fountain has no portal graph" + ); + let wmo_id = benilla_formats::wmo_root_id(&root_bytes); + assert_eq!(wmo_id, 63, "an area id: the placement spawns an instance"); + assert_eq!(root.group_count(), 1); + let water = benilla_formats::wmo_group_liquid_mesh(&group).expect("group 0 holds a pool"); + assert_eq!(water.kind, benilla_formats::LiquidKind::Still); + let header = benilla_formats::wmo_group_header(&group).expect("group header"); + let info = &root.group_infos()[0]; + let model = WmoModel { + wmo_id, + group_nav: vec![WmoGroupNav { + flags: header.flags, + bbox_min: info.bbox_min, + bbox_max: info.bbox_max, + ref_start: header.portal_ref_start, + ref_count: header.portal_ref_count, + area_table_id: header.area_table_id, + fog_indices: header.fog_indices, + group_liquid: header.group_liquid, + }], + group_liquids: vec![Some(water)], + ..WmoModel::default() + }; + let (mut app, pool) = pool_app(model, 0); + for at in [[10.0, 0.0, 0.0], [-10.0, 0.0, 0.0]] { + aim(&mut app, [0.0, -18.0, 6.0], at); + assert!(drawn(&app, pool), "the basin holds its water from any view"); + } + } } diff --git a/crates/benilla-world/src/particles.rs b/crates/benilla-world/src/particles.rs index a8c3bca9c..4ee9a43d0 100644 --- a/crates/benilla-world/src/particles.rs +++ b/crates/benilla-world/src/particles.rs @@ -918,7 +918,6 @@ pub(crate) mod tests { handle: Handle::default(), world_from_local: bevy::math::Affine3A::IDENTITY, name_set: 0, - liquid_visited: vec![false; 3], flooded: vec![None; 3], visible: vec![true, false, false], interior_fog: vec![false; 3], diff --git a/crates/benilla-world/src/terrain_stream/spawn/mod.rs b/crates/benilla-world/src/terrain_stream/spawn/mod.rs index 304b31c8f..e380f22b6 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/mod.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/mod.rs @@ -320,7 +320,6 @@ pub(super) fn spawn_loaded_placements( visible: vec![true; m.group_nav.len()], // Fogged only once a flood puts the group on the chain. interior_fog: vec![false; m.group_nav.len()], - liquid_visited: vec![false; m.group_nav.len()], // MOGP `groupLiquid`: a room wholly submerged, with no liquid grid. flooded: m .group_nav diff --git a/crates/benilla-world/src/wmo_portal/interior.rs b/crates/benilla-world/src/wmo_portal/interior.rs index f31f61ccb..f1996696d 100644 --- a/crates/benilla-world/src/wmo_portal/interior.rs +++ b/crates/benilla-world/src/wmo_portal/interior.rs @@ -978,7 +978,6 @@ mod tests { name_set: 0, visible: vec![true], interior_fog: vec![false], - liquid_visited: vec![false], flooded: vec![None], }) .id() diff --git a/crates/benilla-world/src/wmo_portal/mod.rs b/crates/benilla-world/src/wmo_portal/mod.rs index 9862812de..0f36c78de 100644 --- a/crates/benilla-world/src/wmo_portal/mod.rs +++ b/crates/benilla-world/src/wmo_portal/mod.rs @@ -180,10 +180,6 @@ pub struct WmoPortalInstance { /// draws the group and its doodads with the building's MFOG triple, `false` with the scene fog. /// Seeded all-false, which a portal-less prop keeps. pub interior_fog: Vec, - /// Per-group ever-visited latch: once the flood visits a group, its MLIQ surface draws for the - /// rest of the placement's residency, whatever this frame's PVS. Latched by - /// [`compute_wmo_pvs`]; starts all-false. - pub liquid_visited: Vec, /// Per-group whole-group submersion from MOGP `groupLiquid`: `Some(kind)` means anywhere in the /// group is under liquid, at every z. Baked at spawn; empty on a placement spawned before its /// model resolved. @@ -273,7 +269,7 @@ const MAX_FLOOR_DROP: f32 = 1760.0; /// Recompute each resident WMO's per-group visible set from the camera; a portal-less prop stays /// all-visible. -fn compute_wmo_pvs( +pub(crate) fn compute_wmo_pvs( wmos: Res>, cam: Query<(&GlobalTransform, &Projection), With>, mut instances: Query<(Entity, &mut WmoPortalInstance)>, @@ -426,23 +422,6 @@ fn compute_wmo_pvs( } } } - // The `liquid_visited` latch, through the change gate: only a slot that flips marks the - // instance changed. - let held = inst.bypass_change_detection(); - let mut latched = false; - if held.liquid_visited.len() != groups { - held.liquid_visited = vec![false; groups]; - latched = true; - } - for g in 0..groups { - if held.visible.get(g).copied().unwrap_or(false) && !held.liquid_visited[g] { - held.liquid_visited[g] = true; - latched = true; - } - } - if latched { - inst.set_changed(); - } } if camera_fog.0 != fog_target { camera_fog.0 = fog_target; @@ -1017,7 +996,6 @@ mod tests { name_set: 0, visible: vec![false, true, false], interior_fog: vec![false; 3], - liquid_visited: vec![false; 3], flooded: vec![None; 3], }; let key = |groups: &[u16]| WmoGroupVis { @@ -1047,7 +1025,6 @@ mod tests { name_set: 0, visible: vec![true; 3], interior_fog: vec![false, true, false], - liquid_visited: vec![false; 3], flooded: vec![None; 3], }; let key = |groups: &[u16]| WmoGroupVis { diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 3d3693b72..cca9ce104 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -935,7 +935,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - A read still loading after `INNER_WAIT_CAP` is given up on with a warning, once per request, and its tile then streams on unwaited like the outer ring, where the reference waits for ever, because that way a hung read costs one capped stall, not one every frame. ([`src/terrain_stream.rs:870`](../crates/benilla-world/src/terrain_stream.rs#L870)) - Benilla gates on terrain alone, which is more permissive; the settle hold covers a building or doodad arriving after a teleport. And an authored tile whose ADT failed to load counts as resident, where the reference would have loaded it, because a permanent wall over a bad file is worse for a player than walking on without its ground. ([`src/terrain_stream/residency.rs:9`](../crates/benilla-world/src/terrain_stream/residency.rs#L9)) - Tagged per submesh, where the reference tests the whole object, because a placement has no root entity to carry the bound. The anim-host root is skipped: with no geometry it has no `Aabb`, and would only pad the counts. ([`src/terrain_stream/spawn/mod.rs:229`](../crates/benilla-world/src/terrain_stream/spawn/mod.rs#L229)) -- The box, where the reference tests its circumsphere (`0x683700` → `0x682ef0` → `0x686b80` on the `[rec+0x5c]` centre and `[rec+0x68]` radius × scale), because Bevy culls by box and the box is tighter. ([`src/terrain_stream/spawn/mod.rs:889`](../crates/benilla-world/src/terrain_stream/spawn/mod.rs#L889)) +- The box, where the reference tests its circumsphere (`0x683700` → `0x682ef0` → `0x686b80` on the `[rec+0x5c]` centre and `[rec+0x68]` radius × scale), because Bevy culls by box and the box is tighter. ([`src/terrain_stream/spawn/mod.rs:888`](../crates/benilla-world/src/terrain_stream/spawn/mod.rs#L888)) - Whole tiles, because benilla loads, spawns and releases ADT tiles, not chunks: a tile is wanted when any of its chunks lies in the outer window, and waited for when any lies in the inner one, so each edge is a tile line where the reference's is a chunk line. ([`src/terrain_stream/window.rs:7`](../crates/benilla-world/src/terrain_stream/window.rs#L7)) - A one-chunk keep band (`KEEP_BAND_CHUNKS`), because a tile reload re-decodes and re-spawns where the reference's is a cheap read: a tile releases only past `outer + 1`. ([`src/terrain_stream/window.rs:10`](../crates/benilla-world/src/terrain_stream/window.rs#L10)) diff --git a/docs/MAP.md b/docs/MAP.md index 82fdc009b..c59dc839e 100644 --- a/docs/MAP.md +++ b/docs/MAP.md @@ -289,6 +289,7 @@ - `WOW_LIGHT_DUMP` — benilla-world/src/lighting/global_light.rs, benilla-world/src/lighting/resolve.rs - `WOW_LIGHT_GRID` — benilla-world/src/wmo_portal/audit/light_probe.rs - `WOW_LIQUID_DC` — benilla-assets/src/world_assets/images.rs +- `WOW_LIQUID_TRACE` — benilla-world/src/model_render/visibility.rs - `WOW_LIVE_` — benilla-world/src/bgwin.rs - `WOW_LIVE_FPS` — benilla-app/src/capture/probes/live_fps.rs, benilla-app/src/dev.rs, benilla-world/src/bgwin.rs - `WOW_LIVE_FPS_AT` — benilla-app/src/capture/probes/live_fps.rs