From d41ebdd64257cd59416bba2ac13f475d0aee10d8 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 15 Sep 2026 16:53:24 +0700 Subject: [PATCH] eighteen no-op writes gated, and the dead-gate finder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - eighteen per-frame systems now write only when their inputs moved: the equipment resolve, the skills, language and bind-location feeds, quest markers, shadows, the ambience seat and the weapon trail (2197) - a still remote mover writes no transform, so a city's idle crowd no longer re-propagates every model subtree each frame, and the WMO liquid latch no longer holds a city's visibility walk open (2197) - the bags are walked once a frame for every reagent and item slot instead of once per slot, and fourteen per-frame environment reads become one read each (2197) - the ffx blur chain is skipped where the combine multiplies it by zero, the audio pump compares before set_volume, and the QoS latch sits beside bevy's own exclusive system (2197) - WOW_RES_CENSUS counts on how many frames each resource read as changed, noisiest first — the finder that named these gates (2197) Co-Authored-By: Claude Fable 5.1 --- crates/benilla-app/src/blob_shadow.rs | 15 ++ .../src/capture/probes/live_fps.rs | 10 +- crates/benilla-app/src/entities.rs | 103 +++++++++----- crates/benilla-app/src/minimap/mod.rs | 15 +- crates/benilla-app/src/net/motion/remote.rs | 21 ++- crates/benilla-app/src/perf/census.rs | 129 ++++++++++++++++++ crates/benilla-app/src/perf/mod.rs | 7 + crates/benilla-app/src/player/camera.rs | 9 +- crates/benilla-app/src/player/controller.rs | 2 +- crates/benilla-app/src/quest_markers/query.rs | 23 +++- crates/benilla-app/src/sound/emitter_pool.rs | 8 +- crates/benilla-app/src/sound/kit.rs | 29 ++-- crates/benilla-app/src/ui_action/state.rs | 23 ++-- crates/benilla-app/src/ui_action/usable.rs | 19 ++- crates/benilla-app/src/ui_char.rs | 9 +- crates/benilla-app/src/ui_chat/language.rs | 14 +- crates/benilla-app/src/ui_items/feed.rs | 26 ++-- crates/benilla-app/src/ui_items/mod.rs | 20 +++ crates/benilla-app/src/ui_shapeshift.rs | 5 + crates/benilla-app/src/ui_unit.rs | 14 +- crates/benilla-app/src/vplates.rs | 3 +- crates/benilla-app/src/weapon_trail.rs | 6 + crates/benilla-world/src/clouds/mod.rs | 6 +- crates/benilla-world/src/dev_state.rs | 20 ++- crates/benilla-world/src/ffx_glow.rs | 124 +++++++++-------- .../src/lighting/global_light.rs | 19 ++- crates/benilla-world/src/lighting/resolve.rs | 10 +- crates/benilla-world/src/liquid/query.rs | 3 +- crates/benilla-world/src/model_render.rs | 18 ++- crates/benilla-world/src/particles/render.rs | 15 +- crates/benilla-world/src/terrain_stream.rs | 71 +++++++--- crates/benilla-world/src/thread_qos.rs | 11 +- crates/benilla-world/src/wmo_portal/mod.rs | 20 ++- 33 files changed, 630 insertions(+), 197 deletions(-) diff --git a/crates/benilla-app/src/blob_shadow.rs b/crates/benilla-app/src/blob_shadow.rs index f6ac814d..082fbccb 100644 --- a/crates/benilla-app/src/blob_shadow.rs +++ b/crates/benilla-app/src/blob_shadow.rs @@ -188,7 +188,22 @@ fn sync_shadows( ), >, shadows: Query<(Entity, &BlobShadow)>, + // The reconciler's inputs move only when a unit's animated model arrives or leaves, or a + // unit becomes or stops being a mount child (the `units` filter's two terms); a frame with + // none of those has the same answer it had last frame, and used to pay a set build plus a + // walk of every unit for it. + grew: Query<(), Added>, + mut shrank: RemovedComponents, + mounted: Query<(), Added>, + mut unmounted: RemovedComponents, ) { + if grew.is_empty() + && mounted.is_empty() + && shrank.read().next().is_none() + && unmounted.read().next().is_none() + { + return; + } let mut shadowed = EntityHashSet::default(); for (entity, shadow) in &shadows { // Owner gone or no longer eligible (model torn down) → the decal goes with it. diff --git a/crates/benilla-app/src/capture/probes/live_fps.rs b/crates/benilla-app/src/capture/probes/live_fps.rs index cb570ee5..ae892e24 100644 --- a/crates/benilla-app/src/capture/probes/live_fps.rs +++ b/crates/benilla-app/src/capture/probes/live_fps.rs @@ -59,7 +59,7 @@ impl Plugin for LiveFpsPlugin { paced_samples: Vec::new(), threads_at_start: None, faults_at_start: None, - still_at_start: [0; 5], + still_at_start: [0; 7], cpu_at_start: None, sys_at_start: None, occluded_now: false, @@ -83,7 +83,7 @@ enum LiveFpsPhase { /// [`LiveFpsPlugin`] state. /// The still-frame input counters (`benilla_world::dev_state::STILL_INPUTS_CHANGED`), now. -fn still_inputs_now() -> [u32; 5] { +fn still_inputs_now() -> [u32; 7] { let c = &benilla_world::dev_state::STILL_INPUTS_CHANGED; std::array::from_fn(|i| c[i].load(std::sync::atomic::Ordering::Relaxed)) } @@ -123,7 +123,7 @@ struct LiveFps { /// The page-fault counters at the window's first frame (`perf::process_faults`). faults_at_start: Option<(u64, u64)>, /// `dev_state::STILL_INPUTS_CHANGED` at the window's first frame — `noisy=` is the delta. - still_at_start: [u32; 5], + still_at_start: [u32; 7], /// Process CPU seconds at the first sampled frame ([`crate::perf::process_cpu_secs`]) — the /// baseline for the window's `cpu_ms`/`cpu_pct`. cpu_at_start: Option, @@ -545,7 +545,7 @@ fn drive_live_fps( let faults = match (probe.faults_at_start, crate::perf::process_faults()) { (Some((mi0, ma0)), Some((mi1, ma1))) => format!( - " faults={:.0}/{:.1} noisy=[cam:{},dbg:{},view:{},win:{},claim:{}]", + " faults={:.0}/{:.1} noisy=[cam:{},dbg:{},view:{},win:{},claim:{},portals:{},flips:{}]", (mi1 - mi0) as f64 / v_len as f64, (ma1 - ma0) as f64 / v_len as f64, noisy[0], @@ -553,6 +553,8 @@ fn drive_live_fps( noisy[2], noisy[3], noisy[4], + noisy[5], + noisy[6], ), _ => String::new(), }; diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs index ebd9343c..5485a23d 100644 --- a/crates/benilla-app/src/entities.rs +++ b/crates/benilla-app/src/entities.rs @@ -1283,6 +1283,17 @@ fn update_display_models( // `CORPSE_FIELD_DISPLAY_ID` is the dead player's own body display, which is the // reference's own lookup at `0x5d6759`. EntityKind::Unit | EntityKind::Player | EntityKind::Corpse => { + // Peek through `&` first: `Option>::as_deref_mut` marks the resource + // changed whether or not a byte is written, and `resolve_equipment`'s skip gate + // reads `Creatures::is_changed()` — so an every-frame `&mut` here re-resolved + // every rigged unit's equipment every frame (1697 item 1). A display that is + // present and built asks nothing of the cache. + if creatures + .as_deref() + .is_some_and(|cr| cr.models.get(&disp).is_some_and(|dm| dm.parts.is_some())) + { + continue; + } let Some(cr) = creatures.as_deref_mut() else { continue; }; @@ -1305,6 +1316,14 @@ fn update_display_models( } } EntityKind::GameObject => { + // The same peek, for the same reason (no gate reads this tick today; the census + // that finds the next one should not have to look past it). + if gameobjects + .as_deref() + .is_some_and(|go| go.models.get(&disp).is_some_and(|dm| dm.parts.is_some())) + { + continue; + } let Some(go) = gameobjects.as_deref_mut() else { continue; }; @@ -1355,54 +1374,66 @@ fn update_display_models( // Held-item displays (decision 0072): entries are created by `resolve_equipment`; build each // one's parts once its M2 loads. Items are static meshes — no collider, unit lighting. - if let Some(held) = held.as_deref_mut() { - for dm in held.models.values_mut() { - if dm.parts.is_none() { - build_parts( - dm, - m2s, - wmos, - &mut forms, - &asset_server, - &mut mats, - false, // gameobject: held items — unit lighting, no collider - ); + // Each of the three path-keyed caches below is `&mut`-borrowed only when it holds an + // unbuilt entry — the read-side scan is a few hundred `Option` tests, the write-side tick + // would have read as "changed" on every frame of every run. + fn unbuilt(models: &HashMap) -> bool { + models.values().any(|dm| dm.parts.is_none()) + } + if held.as_deref().is_some_and(|h| unbuilt(&h.models)) { + if let Some(held) = held.as_deref_mut() { + for dm in held.models.values_mut() { + if dm.parts.is_none() { + build_parts( + dm, + m2s, + wmos, + &mut forms, + &asset_server, + &mut mats, + false, // gameobject: held items — unit lighting, no collider + ); + } } } } // Spell-effect displays (decision 0099 phase 3): entries are created by // `spell_fx::resolve_spell_fx`; the same build, keyed by model path instead of display id. - if let Some(fx) = spell_fx.as_deref_mut() { - for dm in fx.models.values_mut() { - if dm.parts.is_none() { - build_parts( - dm, - m2s, - wmos, - &mut forms, - &asset_server, - &mut mats, - false, // gameobject: effects — unit lighting, no collider - ); + if spell_fx.as_deref().is_some_and(|f| unbuilt(&f.models)) { + if let Some(fx) = spell_fx.as_deref_mut() { + for dm in fx.models.values_mut() { + if dm.parts.is_none() { + build_parts( + dm, + m2s, + wmos, + &mut forms, + &asset_server, + &mut mats, + false, // gameobject: effects — unit lighting, no collider + ); + } } } } // Item/enchant glow models (decision 0805): path-keyed like the effect cache above, entries // created by `resolve_equipment`'s glow resolve. - if let Some(glows) = glows.as_deref_mut() { - for dm in glows.models.values_mut() { - if dm.parts.is_none() { - build_parts( - dm, - m2s, - wmos, - &mut forms, - &asset_server, - &mut mats, - false, // gameobject: effects — unit lighting, no collider - ); + if glows.as_deref().is_some_and(|g| unbuilt(&g.models)) { + if let Some(glows) = glows.as_deref_mut() { + for dm in glows.models.values_mut() { + if dm.parts.is_none() { + build_parts( + dm, + m2s, + wmos, + &mut forms, + &asset_server, + &mut mats, + false, // gameobject: effects — unit lighting, no collider + ); + } } } } diff --git a/crates/benilla-app/src/minimap/mod.rs b/crates/benilla-app/src/minimap/mod.rs index 941cb60b..fcb06177 100644 --- a/crates/benilla-app/src/minimap/mod.rs +++ b/crates/benilla-app/src/minimap/mod.rs @@ -462,9 +462,12 @@ fn emit_minimap( // `WOW_MM_ZOOM=0..5` forces the zoom level of whichever map is showing — a capture instrument // (pairs with the `WOW_MM_PROBE` interior probe). Indoors and outdoors each carry their own // persisted index, so the override stands in for both. - let zoom_override = std::env::var("WOW_MM_ZOOM") - .ok() - .and_then(|s| s.parse::().ok()); + static ZOOM_OVERRIDE: std::sync::OnceLock> = std::sync::OnceLock::new(); + let zoom_override = *ZOOM_OVERRIDE.get_or_init(|| { + std::env::var("WOW_MM_ZOOM") + .ok() + .and_then(|s| s.parse::().ok()) + }); let zoom = zoom_override.unwrap_or(slot.zoom); let inside_zoom = zoom_override.unwrap_or(slot.inside_zoom); let center = (slot.rect.min + slot.rect.max) * 0.5; @@ -634,7 +637,8 @@ fn emit_minimap( // how many groups the flood-fill kept out of how many, and how many tiles that came to. The // reference's own Stormwind capture emitted 57 tiles at indoor zoom 3, which is the number // this is here to be compared against (wow-re `wmo-interior-no-adt-underlay.md`). - if std::env::var("WOW_MM_STATS").is_ok() { + static MM_STATS: std::sync::OnceLock = std::sync::OnceLock::new(); + if *MM_STATS.get_or_init(|| std::env::var_os("WOW_MM_STATS").is_some()) { eprintln!( "MM-STATS: radius {radius} yd, groups {}/{} selected, {} tiles composited", drawable.iter().filter(|d| **d).count(), @@ -735,7 +739,8 @@ fn emit_minimap( }) .unwrap_or_default(); let blip_ctx = (blip_px_per_yd > 0.0).then(|| { - if std::env::var("WOW_MM_BLIP_PROBE").is_ok() { + static BLIP_PROBE: std::sync::OnceLock = std::sync::OnceLock::new(); + if *BLIP_PROBE.get_or_init(|| std::env::var_os("WOW_MM_BLIP_PROBE").is_some()) { eprintln!( "BLIP-PROBE: arrow_art={} pois={} map={} wx={wx:.0} wy={wy:.0} px_per_yd={blip_px_per_yd:.3} track_c={:#x} track_r={:#x} track_s={}", blips::RimArrow::ALL diff --git a/crates/benilla-app/src/net/motion/remote.rs b/crates/benilla-app/src/net/motion/remote.rs index 5cb585f2..64f9bdaa 100644 --- a/crates/benilla-app/src/net/motion/remote.rs +++ b/crates/benilla-app/src/net/motion/remote.rs @@ -772,7 +772,16 @@ pub(in crate::net) fn extrapolate_remote_units( now_ms, ); } - t.translation = wow_to_bevy(pos); + // Compare-then-write, all three (1362's no-op-write law, as `spline.rs`'s clamp and the + // camera seat already do): the loop visits every remote mover, and a flag-still one — + // the whole idle population of a city — reproduces last frame's pose bit for bit. Writing + // it anyway marked every such body's `Transform` changed on every frame: its whole model + // subtree re-propagated, the visibility walk's per-part skip (1979) missed on every one + // of its parts, and the swim-mark and splash gates re-asked the water for it. + let translation = wow_to_bevy(pos); + if t.translation != translation { + t.translation = translation; + } // The strafe body pose, same as our own avatar's (the client's display-facing blend): a // strafing remote player renders its body at `orientation ± 90°/45°`, eased in aim-relative // offset space (a left↔right flip swings around the front, never the 180°-tie back path), @@ -794,9 +803,15 @@ pub(in crate::net) fn extrapolate_remote_units( // The swim body pitch — [`crate::creature_anim::swim_body_rotation`], the same law our own // avatar's pose owner calls, on the pitch this mover reported. TU-A is explicit that the // observed mover takes the *reported* pitch here, exactly as the local one takes its own. - t.rotation = crate::creature_anim::swim_body_rotation(yaw, rm.flags, rm.pitch); + let rotation = crate::creature_anim::swim_body_rotation(yaw, rm.flags, rm.pitch); + if t.rotation != rotation { + t.rotation = rotation; + } if let Some(mut twist) = twist { - twist.yaw_gap = wrap_pi(orientation - yaw); + let gap = wrap_pi(orientation - yaw); + if twist.yaw_gap != gap { + twist.yaw_gap = gap; + } } } } diff --git a/crates/benilla-app/src/perf/census.rs b/crates/benilla-app/src/perf/census.rs index 0a62ca50..f1ffc3c0 100644 --- a/crates/benilla-app/src/perf/census.rs +++ b/crates/benilla-app/src/perf/census.rs @@ -549,3 +549,132 @@ pub(super) fn mesh_touch( /// When [`mesh_touch`] starts touching (seconds of `Time`). #[derive(Resource)] pub(super) struct MeshTouchAt(pub f32); + +/// `WOW_RES_CENSUS=:` — the resource change census: over a window opening at `at` +/// seconds and `secs` long, on how many of its frames each resource read as CHANGED, printed +/// once at the window's end, noisiest first. +/// +/// The premise-checker behind every still-frame gate, generalised from 1982's five hand-picked +/// `noisy=` counters to every registered resource. Bevy marks a resource changed on every `&mut` +/// borrow, not on every write: one system that takes `ResMut` — or `as_deref_mut()`s an +/// `Option>` — each frame to *test* something makes `T.is_changed()` true on every +/// frame of every run, and silently disarms every gate that asks it. 1982 found `DebugState` +/// that way (299 of 300 frames); 1697's item 1 (`Creatures`, every frame, the equipment gate) +/// sat unfound for a fortnight after being named because nothing walked the rest. On a parked +/// leg the honest list is short — the clocks, the input state, the probe's own bookkeeping — +/// and anything else near 100 % is a writer to find with `grep -rn 'ResMut'`. +/// +/// Exclusive (`&mut World`) in `Last`; dev-only and env-gated, so it costs nothing unarmed and +/// a few microseconds a frame armed. `NonSend` resources (the UI script VM) are not walked: the +/// world exposes no iterator over that storage. +pub(super) mod res_census { + use std::collections::HashMap; + + use bevy::ecs::change_detection::Tick; + use bevy::ecs::component::ComponentId; + use bevy::prelude::*; + use bevy::time::Real; + + #[derive(Resource)] + pub(in crate::perf) struct ResCensus { + at: f32, + window: f32, + /// The world tick this census last counted at — `is_changed(last_run, this_run)`'s + /// "when the system last ran", so a touch counts once per frame, never twice. + last_run: Option, + frames: u32, + counts: HashMap, + done: bool, + } + + impl ResCensus { + /// Parse `WOW_RES_CENSUS=:`. + pub(in crate::perf) fn from_env() -> Option { + let v = std::env::var("WOW_RES_CENSUS").ok()?; + let (at, window) = v.split_once(':')?; + Some(Self { + at: at.parse().ok()?, + window: window.parse().ok()?, + last_run: None, + frames: 0, + counts: HashMap::new(), + done: false, + }) + } + } + + pub(in crate::perf) fn res_census(world: &mut World) { + let now = world.resource::>().elapsed_secs(); + let this_run = world.read_change_tick(); + // `resource_scope` lifts the census out of the world for the walk, so it never counts + // its own borrow. + world.resource_scope(|world, mut census: Mut| { + if census.done || now < census.at { + return; + } + let Some(last_run) = census.last_run.replace(this_run) else { + return; // the window's first frame: arm the tick, count from the next + }; + if now >= census.at + census.window { + census.done = true; + report(world, &census); + return; + } + census.frames += 1; + let changed: Vec = world + .iter_resources() + .map(|(info, _)| info.id()) + .filter(|&id| { + world + .get_resource_change_ticks_by_id(id) + .is_some_and(|t| t.is_changed(last_run, this_run)) + }) + .collect(); + for id in changed { + *census.counts.entry(id).or_insert(0) += 1; + } + }); + } + + fn report(world: &World, census: &ResCensus) { + let total = world.iter_resources().count(); + let mut rows: Vec<(u32, String)> = census + .counts + .iter() + .map(|(&id, &n)| { + let name = world + .components() + .get_info(id) + .map(|i| i.name().shortname().to_string()) + .unwrap_or_else(|| format!("{id:?}")); + (n, name) + }) + .collect(); + rows.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); + let frames = census.frames.max(1); + let hot = |n: u32| n * 10 >= frames * 9; + // bevy's own asset stores are `ResMut` in its per-frame asset systems by design (that is + // why `AssetChanged` exists) — every `Assets` reads hot on every run, so they are one + // count here and never a row, or they would bury the app's own writers. + let bevy_store = + |name: &str| name.starts_with("Assets<") || name.starts_with("AssetChanges<"); + let every_frame = rows.iter().filter(|(n, _)| hot(*n)).count(); + let bevy_hot = rows + .iter() + .filter(|(n, name)| hot(*n) && bevy_store(name)) + .count(); + let mut out = format!( + "RES_CENSUS frames={frames} resources={total} touched={} at_90pct_or_more={every_frame} (of which bevy asset stores: {bevy_hot})\n", + rows.len() + ); + // Every row at half the window or more: the whole list is the point (the first run + // printed 48 of 302 and stopped in the B's). + for (n, name) in rows + .iter() + .filter(|(n, name)| *n * 2 >= frames && !bevy_store(name)) + { + out.push_str(&format!(" {n:>5}/{frames} {name}\n")); + } + eprint!("{out}"); + } +} diff --git a/crates/benilla-app/src/perf/mod.rs b/crates/benilla-app/src/perf/mod.rs index f5b27d87..5d63ce8c 100644 --- a/crates/benilla-app/src/perf/mod.rs +++ b/crates/benilla-app/src/perf/mod.rs @@ -187,6 +187,13 @@ impl Plugin for PerfPlugin { app.insert_resource(c); app.add_systems(Update, census::cpu_census::cpu_census); } + // `WOW_RES_CENSUS=:` — the resource change census (see its module doc): on how + // many of the window's frames each resource read as changed, noisiest first — the + // finder for the dead-gate class 1982's `noisy=` counted five of by hand. + if let Some(c) = census::res_census::ResCensus::from_env() { + app.insert_resource(c); + app.add_systems(Last, census::res_census::res_census); + } #[cfg(target_os = "macos")] stall::plugin(app); // `WOW_FRAME_PHASES=` — which PHASE of a slow frame spent it (see the module doc). diff --git a/crates/benilla-app/src/player/camera.rs b/crates/benilla-app/src/player/camera.rs index 507d6d5c..7b0b6a58 100644 --- a/crates/benilla-app/src/player/camera.rs +++ b/crates/benilla-app/src/player/camera.rs @@ -1531,7 +1531,7 @@ pub(super) fn seat_camera( // camera-derived can be the cause" (0671) rests entirely on that being untrue, and it was never // measured. `open` is printed beside the eased arm so a hit/miss alternation in the CAST is // visible even on a frame where the ease has not yet moved the camera far enough to see. - if std::env::var_os("WOW_CAM_DUMP").is_some() { + if cam_dump_enabled() { // `follow=` is the auto-follow's own reading (1502): the offset the return is animating, // the state the input word classifies to, and — once armed — how far through the // transition this frame is. `off` moving while `arm` reads `-` means something other than @@ -1897,6 +1897,13 @@ pub(super) fn fly_free( } } +/// `$WOW_CAM_DUMP` — the per-frame camera/turn dump (this file's seat and the controller's turn +/// line share it). One read for the process: both sites sit on the every-frame path. +pub(crate) fn cam_dump_enabled() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var_os("WOW_CAM_DUMP").is_some()) +} + #[cfg(test)] mod tests { use super::super::camera_channel::CHANNEL_EPS; diff --git a/crates/benilla-app/src/player/controller.rs b/crates/benilla-app/src/player/controller.rs index 12878b02..c2aa29d0 100644 --- a/crates/benilla-app/src/player/controller.rs +++ b/crates/benilla-app/src/player/controller.rs @@ -1076,7 +1076,7 @@ pub(super) fn control( // turn jittery") needs the input cadence and the output cadence on the same timeline: a // bursty `dx` under a steady `dt` convicts event delivery; a steady `dx` with an uneven // realized pose convicts everything downstream. - if std::env::var_os("WOW_CAM_DUMP").is_some() { + if crate::player::camera::cam_dump_enabled() { eprintln!( "[turn] t={:.6} dt={:.6} dx={:.3} dy={:.3} look={} face={:.6} model={:.6} \ pos [{:.4},{:.4},{:.4}] pivot={:.4}->{:.4}", diff --git a/crates/benilla-app/src/quest_markers/query.rs b/crates/benilla-app/src/quest_markers/query.rs index fc65ecd3..c34eaa54 100644 --- a/crates/benilla-app/src/quest_markers/query.rs +++ b/crates/benilla-app/src/quest_markers/query.rs @@ -140,7 +140,7 @@ pub(super) fn query_statuses( Entity, &crate::net::Guid, &NetEntity, - &ObjectStore, + Ref, Option<&crate::ui_taxi::FlightMasterStatus>, ), Without, @@ -193,9 +193,24 @@ pub(super) fn query_statuses( // The map is NOT cleared by a sweep — it holds each unit's last ask key, and a sweep re-asking // everyone must stay distinguishable from that unit's own key actually moving, because only // the second is a `0x607380` and only a `0x607380` tears the marker down. - state.asked.retain(|guid, _| index.0.contains_key(guid)); - quest.retain_statuses(|npc| index.0.contains_key(&npc)); + // …and it can only shrink on a frame the object index moved (a guid left the world): the + // two retains used to walk both maps every frame, the second through `ResMut` — which + // marked `QuestGiver` changed on every frame whether or not a status left. + if index.is_changed() { + state.asked.retain(|guid, _| index.0.contains_key(guid)); + quest.retain_statuses(|npc| index.0.contains_key(&npc)); + } + // On a frame that is not a sweep, a unit's verdict below is a function of its own + // descriptor and of the two reaction catalogs, and nothing else: with all three still, the + // arm is a no-op (same reaction → same teardown already done, same key → no `0x607380`), so + // the unit is not visited. A city's whole idle population used to take the reaction + // resolve and the ask-key insert every frame for it. + let catalogs_moved = + reputations.is_changed() || factions.as_ref().is_some_and(|f| f.is_changed()); for (entity, guid, net, obj, fm) in &objects { + if !swept && !full && !catalogs_moved && !obj.is_changed() { + continue; + } // `0x6073f0` zeroes `+0xb2c` (the marker instance) and `+0xcb8` (the questgiver status) // **together**, and the flight master's green `!` lives in that same `+0xb2c` — so every // teardown below takes both. Only removed when there is something to remove: a `Commands` @@ -220,7 +235,7 @@ pub(super) fn query_statuses( let reaction = crate::target::ring_reaction( factions.as_deref(), &reputations, - Some(obj), + Some(&*obj), Some(&store), ); if reaction <= 1 { diff --git a/crates/benilla-app/src/sound/emitter_pool.rs b/crates/benilla-app/src/sound/emitter_pool.rs index f004b21c..0446b7ab 100644 --- a/crates/benilla-app/src/sound/emitter_pool.rs +++ b/crates/benilla-app/src/sound/emitter_pool.rs @@ -463,7 +463,13 @@ fn pump_emitters( let emitter = match pool.entries[e].voice { Some(em) => { match emitters.get_mut(em) { - Ok(mut tf) => tf.translation = nearest, + // Compare-then-write (1362): a parked listener's nearest record is the same + // point every frame, and a no-op write still re-propagates the transform. + Ok(mut tf) => { + if tf.translation != nearest { + tf.translation = nearest; + } + } // Ours, spawned on an earlier frame, so this cannot happen — except through a // query-filter mistake, which would silently freeze every ambience at the // first emitter it ever found and leave the whole nearest-follow inert. That diff --git a/crates/benilla-app/src/sound/kit.rs b/crates/benilla-app/src/sound/kit.rs index c4767c3c..7ebe974d 100644 --- a/crates/benilla-app/src/sound/kit.rs +++ b/crates/benilla-app/src/sound/kit.rs @@ -1157,11 +1157,19 @@ pub(super) fn pump_channels( } } } + // Compare before the write, on both arms (1362's no-op-write law, the audio edition): + // `set_volume` is a command into the audio thread's queue whether or not the value + // moved, and on a still frame every live channel's amp is bit-identical to last + // frame's — the glide an earlier write started completes on its own. The handle's + // volume is `amp_to_db(ch.amp)` from `play` onward, so the field IS the handle's state. let Some(p) = ch.pos else { // 2D: only the category slider can move under a live channel. - ch.amp = config.category_amp(ch.category) * ch.v * ch.gain; - ch.handle - .set_volume(mixer::amp_to_db(ch.amp), mixer::glide()); + let amp = config.category_amp(ch.category) * ch.v * ch.gain; + if amp != ch.amp { + ch.amp = amp; + ch.handle + .set_volume(mixer::amp_to_db(ch.amp), mixer::glide()); + } return true; }; let d_sq = math::dist_sq(listener, p); @@ -1170,16 +1178,19 @@ pub(super) fn pump_channels( ch.handle.stop(mixer::declick()); return false; } - ch.amp = config.category_amp(ch.category) + let amp = config.category_amp(ch.category) * ch.v * ch.gain * math::fmod_rolloff(d_sq, ch.min_dist) * near_field(d_sq, ch.cutoff); - // Glides, not snaps (decision 1026): this is the per-frame gain feed, and a step here is a - // click. It is also the one that scales — every live channel steps together when a frame - // hitches, which is what a "crack fest" under OBS actually was. - ch.handle - .set_volume(mixer::amp_to_db(ch.amp), mixer::glide()); + if amp != ch.amp { + ch.amp = amp; + // Glides, not snaps (decision 1026): this is the per-frame gain feed, and a step here + // is a click. It is also the one that scales — every live channel steps together when + // a frame hitches, which is what a "crack fest" under OBS actually was. + ch.handle + .set_volume(mixer::amp_to_db(ch.amp), mixer::glide()); + } true }); } diff --git a/crates/benilla-app/src/ui_action/state.rs b/crates/benilla-app/src/ui_action/state.rs index e145192a..e5245eed 100644 --- a/crates/benilla-app/src/ui_action/state.rs +++ b/crates/benilla-app/src/ui_action/state.rs @@ -23,7 +23,7 @@ //! §2a fold-back: reagents, forms, stealth, aura states (the Execute-family target dependence), //! the works. -use crate::ui_items::{count_of, InventoryScope}; +use crate::ui_items::carried_counts; use std::collections::HashMap; use std::time::Instant; @@ -436,6 +436,12 @@ pub(super) fn feed_action_state( let (pending, queued_melee, channel, targeting, bound) = &cast_state; let me = self_q.iter().next(); + // The bags, walked ONCE for the frame: every reagent, totem and item-count question below + // reads this table. It used to be one whole walk per question — per reagent per spell slot, + // per item slot — for the same bags each time (1697 item 13). + let carried = me + .map(|(s, _, _, _)| carried_counts(&s.0, &items)) + .unwrap_or_default(); let engaged = me.is_some_and(|(_, _, e, _)| e); let form_byte = me .map(|(s, _, _, _)| s.0.unit_shapeshift_form()) @@ -524,6 +530,7 @@ pub(super) fn feed_action_state( factions: factions.as_deref(), reputations: &reputations, cooldowns: &cooldowns, + carried: &carried, }; let (u, oom) = usable::spell_usable(button.action, d, sp, &ctx, &mut items, &commands); @@ -558,15 +565,15 @@ pub(super) fn feed_action_state( } } ACTION_KIND_ITEM => { - let template = items.template(button.action, 0, &commands).cloned(); + // Only the on-use spell leaves the template (`ItemUseSpell` is `Copy`) — not a + // clone of the whole `ItemInfo` (its Strings and Vecs) per item slot per frame. + let use_spell = items + .template(button.action, 0, &commands) + .and_then(|t| t.use_spell); // `IsConsumableAction` is NOT fed from here. It reads nothing but this template // (`0x4e5250`), so it is slot IDENTITY, and it rides the identity feed's push // beside the count it gates — `super::feed`'s ITEM arm, decision 1301. - let count = me - .map(|(s, _, _, _)| { - count_of(&s.0, &items, button.action, InventoryScope::CARRIED) - }) - .unwrap_or(0); + let count = carried.get(&button.action).copied().unwrap_or(0); // Worn on any equipment slot (0..18) — the green border's IsEquippedAction. st.equipped = me.is_some_and(|(s, _, _, _)| { (0..19).any(|i| { @@ -579,7 +586,6 @@ pub(super) fn feed_action_state( // The rest of `0x4e5050`'s ITEM arm — the count gate, `IsItemOnCooldown`, and // the item's on-use spell run through the SAME `0x6e3d60` walk a spell slot // takes ([`super::usable::item_usable`]). Food greys in combat from leg 8 there. - let use_spell = template.as_ref().and_then(|t| t.use_spell); // No active player and the reference answers (0,0) before resolving anything // (§2a P0) — which is `ActionState::default()`'s `usable`. if let Some((store, _, _, _)) = me { @@ -589,6 +595,7 @@ pub(super) fn feed_action_state( factions: factions.as_deref(), reputations: &reputations, cooldowns: &cooldowns, + carried: &carried, }; let (u, oom) = usable::item_usable( button.action, diff --git a/crates/benilla-app/src/ui_action/usable.rs b/crates/benilla-app/src/ui_action/usable.rs index e45e12b2..17b246fa 100644 --- a/crates/benilla-app/src/ui_action/usable.rs +++ b/crates/benilla-app/src/ui_action/usable.rs @@ -21,7 +21,6 @@ use benilla_protocol::messages::ItemUseSpell; -use crate::ui_items::{count_of, InventoryScope}; use benilla_formats::{ SpellDisplay, ATTR_CASTABLE_WHILE_DEAD, ATTR_NOT_IN_COMBAT, ATTR_ONLY_STEALTHED, SPELL_EFFECT_TRADE_SKILL, @@ -50,6 +49,10 @@ pub(crate) struct UsableCtx<'a> { pub(crate) factions: Option<&'a Factions>, pub(crate) reputations: &'a Reputations, pub(crate) cooldowns: &'a Cooldowns, + /// Every carried entry's count, walked ONCE by the caller for the frame + /// ([`crate::ui_items::carried_counts`]) — leg 3 reads reagents and totems off it instead + /// of walking the bags per reagent per slot. + pub(crate) carried: &'a std::collections::HashMap, } /// How many equipment indices the search covers — `0..=22` (`0x5f0c50`'s `cmp ebx,0x17; jl`): the @@ -263,7 +266,7 @@ fn equipped_slots_match( /// combat; the reference runs three more gates, and the third is the whole spell walk. /// /// 1. **the count cache** `[0xbc6390+slot*4] == 0` ⇒ `(false, false)` (`4e50ab`). `held` is our -/// stand-in: carried copies ([`InventoryScope::CARRIED`], decision 1158's documented narrowing) +/// stand-in: carried copies ([`crate::ui_items::InventoryScope::CARRIED`], decision 1158's documented narrowing) /// or a copy worn on an equipment slot. The reference fills that cache from `0x4e6d20`, whose /// mask `0x4e6d20`→`0x622439` rewrites to **`0x47`** — equipment `0..0x12`, bag slots, the /// backpack, container contents and **the keyring**, bank excluded. So the `|| equipped` half @@ -351,13 +354,14 @@ pub(crate) fn spell_usable( return (false, false); } // Leg 3 (`0x6e4000`): every reagent pair in bag counts; every totem tool present. + let carried = |entry: u32| ctx.carried.get(&entry).copied().unwrap_or(0); for &(entry, count) in &d.reagents { - if entry != 0 && count_of(&ctx.store.0, items, entry, InventoryScope::CARRIED) < count { + if entry != 0 && carried(entry) < count { return (false, false); } } for &totem in &d.totems { - if totem != 0 && count_of(&ctx.store.0, items, totem, InventoryScope::CARRIED) == 0 { + if totem != 0 && carried(totem) == 0 { return (false, false); } } @@ -711,6 +715,7 @@ mod tests { store: &'a ObjectStore, cooldowns: &'a Cooldowns, reputations: &'a Reputations, + carried: &'a std::collections::HashMap, ) -> UsableCtx<'a> { UsableCtx { store, @@ -718,6 +723,7 @@ mod tests { factions: None, reputations, cooldowns, + carried, } } @@ -728,11 +734,12 @@ mod tests { let mut items = Items::default(); let (tx, _rx) = crossbeam_channel::unbounded(); let commands = NetCommands(tx); + let carried = crate::ui_items::carried_counts(&store.0, &items); spell_usable( 1, d, &spells, - &ctx(store, &cooldowns, &reputations), + &ctx(store, &cooldowns, &reputations, &carried), &mut items, &commands, ) @@ -864,6 +871,7 @@ mod tests { let healthy = ObjectStore(ObjectFields::from_pairs(&[(22, 100), (125, 0)])); let low = ObjectStore(ObjectFields::from_pairs(&[(22, 10), (125, 0x2)])); + let carried = crate::ui_items::carried_counts(&me.0, &items); for (target, expect) in [(&healthy, false), (&low, true)] { let ctx = UsableCtx { store: &me, @@ -871,6 +879,7 @@ mod tests { factions: None, reputations: &reputations, cooldowns: &cooldowns, + carried: &carried, }; assert_eq!( spell_usable(5308, &execute, &spells, &ctx, &mut items, &commands), diff --git a/crates/benilla-app/src/ui_char.rs b/crates/benilla-app/src/ui_char.rs index dfc890b0..7ef82f2d 100644 --- a/crates/benilla-app/src/ui_char.rs +++ b/crates/benilla-app/src/ui_char.rs @@ -324,7 +324,7 @@ fn watch_skill_ups( /// without `0x2` under its own header (decision 1091). fn feed_skills( script: Option>, - self_store: Query<&ObjectStore, With>, + self_store: Query, With>, skill_lines: Option>, mut last: Local>>, ) { @@ -332,9 +332,16 @@ fn feed_skills( return; }; let last = last.get(&script); + let lines_moved = skill_lines.as_ref().is_some_and(|l| l.is_changed()); let (Ok(store), Some(skill_lines)) = (self_store.single(), skill_lines.as_deref()) else { return; }; + // The list is a pure function of our descriptor and the catalog: with both still and a + // push already made on this VM, nothing below can differ from what the memo holds. It used + // to build ~40 rows with a cloned name each, every frame, to compare and drop them. + if last.is_some() && !store.is_changed() && !lines_moved { + return; + } // The display predicate reads the player's own race/class (the SkillRaceClassInfo row-match — // the spellbook's own General-collapse inputs, `ui_spellbook::build_book`) and level (the // untrained gate). diff --git a/crates/benilla-app/src/ui_chat/language.rs b/crates/benilla-app/src/ui_chat/language.rs index c7a3b838..fa90bf71 100644 --- a/crates/benilla-app/src/ui_chat/language.rs +++ b/crates/benilla-app/src/ui_chat/language.rs @@ -145,7 +145,7 @@ pub(super) fn feed_language_skills( actions: Option>, spells: Option>, skill_lines: Option>, - self_q: Query<&ObjectStore, With>, + self_q: Query, With>, ) { let Ok(store) = self_q.single() else { // No body — the reference's "no local player" edge, which copies verbatim. @@ -156,6 +156,16 @@ pub(super) fn feed_language_skills( } return; }; + // The gate the doc above promises: the map is a function of the spell book, the two + // catalogs and our own descriptor, so a frame with all four still — and a player already + // folded in — has nothing to rebuild. + let inputs_moved = store.is_changed() + || actions.as_ref().is_some_and(|a| a.is_changed()) + || spells.as_ref().is_some_and(|s| s.is_changed()) + || skill_lines.as_ref().is_some_and(|l| l.is_changed()); + if !inputs_moved && langs.have_player { + return; + } let gm = store.0.player_flags() & PLAYER_FLAGS_GM != 0; let mut skill = HashMap::new(); @@ -172,7 +182,7 @@ pub(super) fn feed_language_skills( }; // Ascending spell id, and the later write wins — the reference's `[0xb700ac][lang] = // spellId` is a plain store, and the initial spell batch arrives sorted. - skill.insert(language, skill_value(store, line)); + skill.insert(language, skill_value(&store, line)); } } diff --git a/crates/benilla-app/src/ui_items/feed.rs b/crates/benilla-app/src/ui_items/feed.rs index a1ee62b5..0de1d04c 100644 --- a/crates/benilla-app/src/ui_items/feed.rs +++ b/crates/benilla-app/src/ui_items/feed.rs @@ -421,12 +421,22 @@ pub(super) fn feed_item_stats( // player is bound. But the item feed idles whenever nothing is pending, and the bind point // arrives long after world entry (`SMSG_BINDPOINTUPDATE` at login and on every re-bind), so the // push cannot sit behind that gate. - let home_area: Option = home_bind + let home_area: Option<&str> = home_bind .as_deref() .and_then(|b| b.0) - .and_then(|id| area_names.as_deref()?.0.resolve(id as i32)) - .map(str::to_string); - script.set_bind_location(home_area.as_deref().unwrap_or_default()); + .and_then(|id| area_names.as_deref()?.0.resolve(id as i32)); + // …pushed on CHANGE, per VM: the memo resets with the VM, so a rebuilt one is fed again + // the frame it appears. It used to push every frame — with a fresh String each time — + // because it cannot sit behind the pending gate below; the memo compare is the gate it can + // sit behind, and the re-substitute rides the same edge. + if last_home.as_deref() != home_area { + script.set_bind_location(home_area.unwrap_or_default()); + *last_home = home_area.map(str::to_string); + // A bind-point change re-substitutes every held view: templates pushed before the + // login's SMSG_BINDPOINTUPDATE landed carry a raw $z otherwise (the hearthstone's login + // race). + pending.extend(items.cached_template_ids()); + } pending.extend(items.take_fresh()); pending.extend(script.take_item_stat_asks()); @@ -435,12 +445,6 @@ pub(super) fn feed_item_stats( } let spell_res = spells.as_deref(); let skill_catalog = skill_lines.as_deref().map(|s| &s.catalog); - // A bind-point change re-substitutes every held view: templates pushed before the login's - // SMSG_BINDPOINTUPDATE landed carry a raw $z otherwise (the hearthstone's login race). - if *last_home != home_area { - *last_home = home_area.clone(); - pending.extend(items.cached_template_ids()); - } let ready: Vec = pending .iter() .copied() @@ -468,7 +472,7 @@ pub(super) fn feed_item_stats( &t, spell_res, skill_catalog, - home_area.as_deref(), + home_area, factions.as_deref().map(|f| f.catalog()), sub_classes.as_deref().map(|s| &s.0), classes.as_deref().map(|c| &c.0), diff --git a/crates/benilla-app/src/ui_items/mod.rs b/crates/benilla-app/src/ui_items/mod.rs index 516ddae9..4e5f0022 100644 --- a/crates/benilla-app/src/ui_items/mod.rs +++ b/crates/benilla-app/src/ui_items/mod.rs @@ -473,6 +473,26 @@ pub(crate) fn count_of( total } +/// Every carried entry's count in ONE walk — [`count_of`] for a caller that asks about many +/// entries on one frame. The action bar asked `count_of` once per reagent, per totem and per +/// item slot, each a whole walk of the bags to sum one entry; one walk answers all of them, and +/// the per-entry question becomes a lookup. Same scope, same per-copy stack sum. +pub(crate) fn carried_counts( + store: &ObjectFields, + items: &Items, +) -> std::collections::HashMap { + let mut counts = std::collections::HashMap::new(); + walk_inventory::<()>(store, items, InventoryScope::CARRIED, |_, _, guid| { + if let Some(fields) = items.object(guid) { + if let Some(entry) = fields.object_entry() { + *counts.entry(entry).or_insert(0) += fields.item_stack_count().unwrap_or(1); + } + } + None + }); + counts +} + /// How far [`find_item`] looks, and which copies count — the two mode bits the reference's own /// callers pass into the inventory walker `0x622420` (wow-re `action-item-slot.md` §8.2). #[derive(Clone, Copy, Default)] diff --git a/crates/benilla-app/src/ui_shapeshift.rs b/crates/benilla-app/src/ui_shapeshift.rs index fd9ce5ca..291cf943 100644 --- a/crates/benilla-app/src/ui_shapeshift.rs +++ b/crates/benilla-app/src/ui_shapeshift.rs @@ -283,6 +283,10 @@ fn feed_shapeshift_bar( (order, id) }); + // The bags walked once for every form's reagent leg (see `feed_action_state`). + let carried = store + .map(|s| crate::ui_items::carried_counts(&s.0, &items)) + .unwrap_or_default(); let fresh: Vec = rows .into_iter() .map(|(id, d)| { @@ -297,6 +301,7 @@ fn feed_shapeshift_bar( factions: factions.as_deref(), reputations: &reputations, cooldowns: &cooldowns, + carried: &carried, }; usable::spell_usable(id, d, &spells, &ctx, &mut items, &commands).0 }); diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 420445b8..6547b661 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -295,7 +295,7 @@ fn feed_known_languages( spells: Option>, skill_lines: Option>, languages: Option>, - self_q: Query<&ObjectStore, With>, + self_q: Query, With>, mut pushed: Local>>>, ) { let Some(mut script) = script else { @@ -306,6 +306,18 @@ fn feed_known_languages( }; let pushed = pushed.get(&script); let store = self_q.iter().next(); + // A pure function of the spell book, the three catalogs and our descriptor — with all still + // and a push already made on this VM, the rebuild (a skill-slot scan per language, a + // `Vec`) can only reproduce the memo. + let inputs_moved = store.as_ref().is_some_and(|s| s.is_changed()) + || actions.is_changed() + || spells.is_changed() + || languages.is_changed() + || skill_lines.as_ref().is_some_and(|l| l.is_changed()); + if pushed.is_some() && !inputs_moved { + return; + } + let store: Option<&ObjectStore> = store.as_deref(); let has_skill_line = |line: u32| { store.is_some_and(|s| { (0..benilla_protocol::messages::PLAYER_SKILL_SLOTS) diff --git a/crates/benilla-app/src/vplates.rs b/crates/benilla-app/src/vplates.rs index a756ce06..f4d2296c 100644 --- a/crates/benilla-app/src/vplates.rs +++ b/crates/benilla-app/src/vplates.rs @@ -684,7 +684,8 @@ fn drive_vplates( // Geometry trace for the vplates capture (`WOW_VPLATE_TRACE=1`): the exact plate rects // this frame, in logical px — the machine-side check the capture PNG can't give // (fill/border/text hues overlap under zoom). - let trace = std::env::var("WOW_VPLATE_TRACE").as_deref() == Ok("1"); + static TRACE: std::sync::OnceLock = std::sync::OnceLock::new(); + let trace = *TRACE.get_or_init(|| std::env::var("WOW_VPLATE_TRACE").as_deref() == Ok("1")); // The full seat (`0x509ec0`): the desired rect TOP-anchored on the raw projected point // (the plate hangs below head + 2/3 yd), then the bucket-0 seat law — normalize, SOLVE // off the plates already claimed this frame, clamp the resolved center-X/top half a diff --git a/crates/benilla-app/src/weapon_trail.rs b/crates/benilla-app/src/weapon_trail.rs index 9eb5d278..b7e67b79 100644 --- a/crates/benilla-app/src/weapon_trail.rs +++ b/crates/benilla-app/src/weapon_trail.rs @@ -430,6 +430,12 @@ fn draw_weapon_trails( let scene = lighting.as_deref().map_or([1.0; 3], |l| l.ambient); let now_ms = time.elapsed().as_millis() as u32; for (entity, mut trail, prop, vis, wearer) in &mut trails { + // No swing, no work — asked through `&` first: every drawn weapon carries a trail, and + // reaching the swing through `as_mut()` below marked each one changed every frame while + // paying the ambient lookup and two transforms for a strip that was never built. + if trail.swing.is_none() { + continue; + } // Indoors the wearer's node carries a committed ambient word of its own — the ramped chase // toward `cap96(MOCV)`, the room's own light rather than the sky's. Its ABSENCE is the // exterior lane. A Goldshire-inn character's word is ≈ (0.30, 0.22, 0.14) warm against a diff --git a/crates/benilla-world/src/clouds/mod.rs b/crates/benilla-world/src/clouds/mod.rs index 91c8a04c..89a6d8f3 100644 --- a/crates/benilla-world/src/clouds/mod.rs +++ b/crates/benilla-world/src/clouds/mod.rs @@ -136,10 +136,12 @@ fn tick_clouds( glow_dir: light.cloud_glow_dir, glow_track: light.cloud_glow_track, }; - if surfaced && std::env::var_os("WOW_CLOUD_DUMP").is_some() { + static CLOUD_DUMP: std::sync::OnceLock = std::sync::OnceLock::new(); + let cloud_dump = *CLOUD_DUMP.get_or_init(|| std::env::var_os("WOW_CLOUD_DUMP").is_some()); + if surfaced && cloud_dump { eprintln!("[cloud] surfaced -> full rebuild (C {density:.3})"); } - if std::env::var_os("WOW_CLOUD_DUMP").is_some() && cov.last_frame != Some(frame) { + if cloud_dump && cov.last_frame != Some(frame) { eprintln!( "[cloud] C {density:.3} sun {:?} slope {:?} gbase {:?} bcc {:.2} glow_dir {:?} track {:.2}", frame.sun, frame.slope, frame.gbase, frame.bcc, frame.glow_dir, frame.glow_track diff --git a/crates/benilla-world/src/dev_state.rs b/crates/benilla-world/src/dev_state.rs index b047ee81..3c75b082 100644 --- a/crates/benilla-world/src/dev_state.rs +++ b/crates/benilla-world/src/dev_state.rs @@ -30,11 +30,17 @@ pub fn deterministic_run() -> bool { } /// How many frames each still-frame input has read as changed since startup — `[camera -/// transform, DebugState, ViewDistance, ExteriorWindows, CameraInteriorClaim]`. The still-frame -/// skips (decision 1979: the visibility walk, billboards, emitter gates, doodad hosts) engage -/// only when all five are still; an input rewritten every frame silently disarms every one of -/// them, and `FPS_PROBE`'s `noisy=` prints these as per-window deltas so that input is named. -pub static STILL_INPUTS_CHANGED: [std::sync::atomic::AtomicU32; 5] = [ +/// transform, DebugState, ViewDistance, ExteriorWindows, CameraInteriorClaim, any +/// WmoPortalInstance, any InheritedVisibility]`. The still-frame skips (decision 1979: the +/// visibility walk, billboards, emitter gates, doodad hosts) engage only when all of these are +/// still; an input rewritten every frame silently disarms every one of them, and `FPS_PROBE`'s +/// `noisy=` prints these as per-window deltas so that input is named. The two population +/// terms are the visibility walk's own last two (`new_portals`, `owner_flips`): they were +/// added the day the walk's `portals` term read 300 of 300 on a parked city frame for a +/// latch written through `Mut` — a gate the five resource counters could not see. +pub static STILL_INPUTS_CHANGED: [std::sync::atomic::AtomicU32; 7] = [ + std::sync::atomic::AtomicU32::new(0), + std::sync::atomic::AtomicU32::new(0), std::sync::atomic::AtomicU32::new(0), std::sync::atomic::AtomicU32::new(0), std::sync::atomic::AtomicU32::new(0), @@ -49,6 +55,8 @@ pub(crate) fn count_still_inputs( view: Res, windows: Res, claim: Res, + portals: Query<(), Changed>, + flips: Query<(), Changed>, ) { let changed = [ cam.iter().any(|t| t.is_changed()), @@ -56,6 +64,8 @@ pub(crate) fn count_still_inputs( view.is_changed(), windows.is_changed(), claim.is_changed(), + !portals.is_empty(), + !flips.is_empty(), ]; for (slot, c) in STILL_INPUTS_CHANGED.iter().zip(changed) { if c { diff --git a/crates/benilla-world/src/ffx_glow.rs b/crates/benilla-world/src/ffx_glow.rs index 8c6dbbcb..9a912ee3 100644 --- a/crates/benilla-world/src/ffx_glow.rs +++ b/crates/benilla-world/src/ffx_glow.rs @@ -428,7 +428,8 @@ fn ensure_ffx_glow( ) { // Perf-bisect kill-switch: $WOW_NO_FFX strips the pass from every camera (the frame then shows // undecoded gamma — ~2.2× bright — which is fine: this is a measurement mode, not a look mode). - if std::env::var_os("WOW_NO_FFX").is_some() { + static NO_FFX: std::sync::OnceLock = std::sync::OnceLock::new(); + if *NO_FFX.get_or_init(|| std::env::var_os("WOW_NO_FFX").is_some()) { for e in &with { commands.entity(e).remove::(); } @@ -835,63 +836,76 @@ impl ViewNode for FfxGlowNode { bytemuck::cast_slice(&uniform), ); let post = view_target.post_process_write(); - let layout_filter = pipeline_cache.get_bind_group_layout(&pipelines.layout_filter); let layout_combine = pipeline_cache.get_bind_group_layout(&pipelines.layout_combine); - - // The downsample binds `post.source`, which `post_process_write` flips per call — this - // bind group cannot be cached (it would sample last frame's texture); the two Gauss ones - // bind only the stable ¼-res ping-pong and ride prepared on [`FfxGlowTextures`]. - let down_bind = render_device.create_bind_group( - "ffx_glow_down_quarter", - &layout_filter, - &BindGroupEntries::sequential((post.source, &pipelines.sampler)), - ); - - // The filter passes (byte-pinned chain): source→¼ (one Box4), ¼a→¼b (H), ¼b→¼a (V). - // Each pass opens its own diagnostic span (`render/ffx_glow_*/elapsed_gpu` on a device - // that times passes): the journal's `gpu_glow` column is their sum (2008). let diagnostics = render_context.diagnostic_recorder(); - let filter_passes: [(&'static str, &RenderPipeline, &BindGroup, &TextureView); 3] = [ - ( + + // The combine reads the blur through exactly two terms — `w·blur²` (`lane.x`) and the + // haze cross-fade (`lane.z`; the ghost combine has no haze and reads the blur through + // the same `x`). With both exactly zero the three filter passes would compute a texture + // the combine multiplies by nothing, so they are skipped and the combine samples what + // the ¼-res target holds — finite bytes from an earlier frame, or wgpu's zero-init — × 0. + // Every UI-pane view (`gain_scale` 0, no haze lane) skips on every frame; the world view + // skips in a zone whose `LightParams.glow` is 0 while the eye is dry and sober. The same + // shader then runs with the same uniform: look-neutral by construction, and the + // journal's `gpu_glow` column reads 0 for a view that skipped (2008). + let blur_read = uniform[0] != 0.0 || uniform[2] != 0.0; + if blur_read { + let layout_filter = pipeline_cache.get_bind_group_layout(&pipelines.layout_filter); + // The downsample binds `post.source`, which `post_process_write` flips per call — + // this bind group cannot be cached (it would sample last frame's texture); the two + // Gauss ones bind only the stable ¼-res ping-pong and ride prepared on + // [`FfxGlowTextures`]. + let down_bind = render_device.create_bind_group( "ffx_glow_down_quarter", - downsample, - &down_bind, - &textures.quarter_a.default_view, - ), - ( - "ffx_glow_gauss_h", - gauss_h, - &textures.gauss_h_bind, - &textures.quarter_b.default_view, - ), - ( - "ffx_glow_gauss_v", - gauss_v, - &textures.gauss_v_bind, - &textures.quarter_a.default_view, - ), - ]; - for (label, pipeline, bind, dst) in filter_passes { - let mut pass = - render_context - .command_encoder() - .begin_render_pass(&RenderPassDescriptor { - label: Some(label), - color_attachments: &[Some(RenderPassColorAttachment { - view: dst, - depth_slice: None, - resolve_target: None, - ops: Operations::default(), - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - }); - let span = diagnostics.pass_span(&mut pass, label); - pass.set_pipeline(pipeline); - pass.set_bind_group(0, bind, &[]); - pass.draw(0..3, 0..1); - span.end(&mut pass); + &layout_filter, + &BindGroupEntries::sequential((post.source, &pipelines.sampler)), + ); + + // The filter passes (byte-pinned chain): source→¼ (one Box4), ¼a→¼b (H), ¼b→¼a (V). + // Each pass opens its own diagnostic span (`render/ffx_glow_*/elapsed_gpu` on a + // device that times passes): the journal's `gpu_glow` column is their sum (2008). + let filter_passes: [(&'static str, &RenderPipeline, &BindGroup, &TextureView); 3] = [ + ( + "ffx_glow_down_quarter", + downsample, + &down_bind, + &textures.quarter_a.default_view, + ), + ( + "ffx_glow_gauss_h", + gauss_h, + &textures.gauss_h_bind, + &textures.quarter_b.default_view, + ), + ( + "ffx_glow_gauss_v", + gauss_v, + &textures.gauss_v_bind, + &textures.quarter_a.default_view, + ), + ]; + for (label, pipeline, bind, dst) in filter_passes { + let mut pass = + render_context + .command_encoder() + .begin_render_pass(&RenderPassDescriptor { + label: Some(label), + color_attachments: &[Some(RenderPassColorAttachment { + view: dst, + depth_slice: None, + resolve_target: None, + ops: Operations::default(), + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + let span = diagnostics.pass_span(&mut pass, label); + pass.set_pipeline(pipeline); + pass.set_bind_group(0, bind, &[]); + pass.draw(0..3, 0..1); + span.end(&mut pass); + } } // Combine: screen + w·blur² (gamma-space byte math in the shader) → the post destination. diff --git a/crates/benilla-world/src/lighting/global_light.rs b/crates/benilla-world/src/lighting/global_light.rs index 2f1f0fa6..702762ea 100644 --- a/crates/benilla-world/src/lighting/global_light.rs +++ b/crates/benilla-world/src/lighting/global_light.rs @@ -387,8 +387,14 @@ fn build_light_data( // alternate frame to frame, which a 1 Hz sample cannot see at all. Reading a per-second dump as // evidence of per-frame stability is how that light was cleared once already (0665's parked // culling test made the same mistake with a different instrument). - if let Some(mode) = std::env::var_os("WOW_POINTS_DUMP") { - let every = if mode == *"frame" { 0.0 } else { 1.0 }; + static POINTS_DUMP: std::sync::OnceLock> = + std::sync::OnceLock::new(); + if let Some(mode) = POINTS_DUMP.get_or_init(|| std::env::var_os("WOW_POINTS_DUMP")) { + let every = if mode.as_os_str() == "frame" { + 0.0 + } else { + 1.0 + }; let now = time.elapsed_secs_f64(); if now - *last_dump >= every { *last_dump = now; @@ -432,8 +438,13 @@ fn build_light_data( // can still be moving. A dump of selected rows would answer "did ambient move?"; only the full // set answers "did ANY shading input move?", and that is the question worth a run. Rows are // printed as raw f32 bits, so a change far below a printed decimal cannot hide. - if let Some(mode) = std::env::var_os("WOW_LIGHT_DUMP") { - let every = if mode == *"frame" { 0.0 } else { 1.0 }; + static LIGHT_DUMP: std::sync::OnceLock> = std::sync::OnceLock::new(); + if let Some(mode) = LIGHT_DUMP.get_or_init(|| std::env::var_os("WOW_LIGHT_DUMP")) { + let every = if mode.as_os_str() == "frame" { + 0.0 + } else { + 1.0 + }; let now = time.elapsed_secs_f64(); if now - *last_rows_dump >= every { *last_rows_dump = now; diff --git a/crates/benilla-world/src/lighting/resolve.rs b/crates/benilla-world/src/lighting/resolve.rs index e3aa28fc..5072fbb5 100644 --- a/crates/benilla-world/src/lighting/resolve.rs +++ b/crates/benilla-world/src/lighting/resolve.rs @@ -427,8 +427,9 @@ pub(super) fn update_time_lighting( // the committed fog frame to frame, and a once-a-second sample simply lands on whichever side it // lands on. Rows 18/19 are read by WMO materials *alone*, which is exactly the footprint B38 shows // (decision 0670: one WMO re-lit, terrain and sky untouched). - if let Some(mode) = std::env::var_os("WOW_FOG_DUMP") { - let sec = (mode != *"frame").then(|| time.elapsed_secs() as u32); + static FOG_DUMP: std::sync::OnceLock> = std::sync::OnceLock::new(); + if let Some(mode) = FOG_DUMP.get_or_init(|| std::env::var_os("WOW_FOG_DUMP")) { + let sec = (mode.as_os_str() != "frame").then(|| time.elapsed_secs() as u32); if sec.is_none() || *last_fog_dump != sec { *last_fog_dump = sec; let b = |c: [f32; 3]| c.map(|v| (v * 255.0).round() as i32); @@ -462,8 +463,9 @@ pub(super) fn update_time_lighting( // them land in the first moments after world entry — before the submersion verdict has settled, // so the line reports `Dry` at a position that is plainly underwater (1829, chasing exactly // that contradiction). - if let Some(mode) = std::env::var_os("WOW_LIGHT_DUMP") { - let key = (mode != *"frame").then_some(minute); + static LIGHT_DUMP: std::sync::OnceLock> = std::sync::OnceLock::new(); + if let Some(mode) = LIGHT_DUMP.get_or_init(|| std::env::var_os("WOW_LIGHT_DUMP")) { + let key = (mode.as_os_str() != "frame").then_some(minute); if key.is_some() && *last_dump == key { return; } diff --git a/crates/benilla-world/src/liquid/query.rs b/crates/benilla-world/src/liquid/query.rs index de4937be..2a2f5916 100644 --- a/crates/benilla-world/src/liquid/query.rs +++ b/crates/benilla-world/src/liquid/query.rs @@ -1006,7 +1006,8 @@ pub(super) fn detect_submersion( // committed fog is whichever submerged atmosphere the verdict names, so a fog line alone cannot // say whether an atmosphere that reads wrong is the wrong record or the right record never // selected. Reports the eye, the verdict, and every candidate surface over the eye's XY. - if std::env::var_os("WOW_FOG_DUMP").is_some() { + static FOG_DUMP: std::sync::OnceLock = std::sync::OnceLock::new(); + if *FOG_DUMP.get_or_init(|| std::env::var_os("WOW_FOG_DUMP").is_some()) { let sec = time.elapsed_secs() as u32; if last_dump.replace(sec) != Some(sec) { // Every surface over the eye's XY, whether or not the eye's claim admits it — a diff --git a/crates/benilla-world/src/model_render.rs b/crates/benilla-world/src/model_render.rs index bfef3fee..fd6ed5e2 100644 --- a/crates/benilla-world/src/model_render.rs +++ b/crates/benilla-world/src/model_render.rs @@ -52,6 +52,13 @@ pub(crate) fn alphatest_disabled() -> bool { *ON.get_or_init(|| std::env::var_os("WOW_NO_ALPHATEST").is_some()) } +/// `WOW_WMO_BIAS=0` — the WMO batch's clip-z nudge off (a bisect lever; see its use site). Read +/// once: the use site runs per WMO batch on stream-in, thousands of times through a city's load. +fn wmo_bias_off() -> bool { + static OFF: std::sync::OnceLock = std::sync::OnceLock::new(); + *OFF.get_or_init(|| matches!(std::env::var("WOW_WMO_BIAS").as_deref(), Ok("0"))) +} + /// Material-dedup key: same texture + blend + sidedness + kind + fade-variant → one shared material. #[derive(PartialEq, Eq, Hash)] pub struct MatKey { @@ -459,12 +466,11 @@ pub fn model_material( // The clip-z nudge stays WMO-only: M2 coplanar layers pass GreaterEqual at exactly // equal depth (same mesh, same transform, same vertex path), and their ORDER is // the sort bias above — nudging their depth would be an unverified extra. - let order = - if !is_wmo || matches!(std::env::var("WOW_WMO_BIAS").as_deref(), Ok("0")) { - 0.0 - } else { - f32::from(batch_order) - }; + let order = if !is_wmo || wmo_bias_off() { + 0.0 + } else { + f32::from(batch_order) + }; Vec4::new(shade.selector(), order, uv0[0], uv0[1]) }, // The animated M2Color tint's first key (identity white for static batches — their diff --git a/crates/benilla-world/src/particles/render.rs b/crates/benilla-world/src/particles/render.rs index 0febbfc3..7b31db4d 100644 --- a/crates/benilla-world/src/particles/render.rs +++ b/crates/benilla-world/src/particles/render.rs @@ -628,7 +628,7 @@ fn prepare_effects( // The lane is keyed on the SORT rung because that is the one field unique per lane: the raster // bias is shared (every ground decal rides `DECAL_RASTER`, and prints were being labelled // shadows here for exactly that reason). - let trace = std::env::var_os("WOW_EFFECT_TRACE").is_some(); + let trace = effect_trace(); let mut trace_lines: Vec = Vec::new(); // The per-draw uniform rows, THIS frame: the six canonical fog policies plus one row per // distinct (fog, clip) a clipped draw asks for. It is rebuilt every frame rather than once @@ -801,7 +801,7 @@ fn prepare_effect_bind_groups( let Some(image) = gpu_images.get(draw.texture) else { continue; }; - if std::env::var_os("WOW_EFFECT_TRACE").is_some() { + if effect_trace() { info!( "effect bind: new group for tex {:?} ({}x{})", draw.texture, image.size.width, image.size.height @@ -881,8 +881,7 @@ impl RenderCommand

for DrawEffectBatch { pass.set_index_buffer(indices.slice(..), IndexFormat::Uint32); // The `$WOW_EFFECT_TRACE` tail: what draw_indexed ACTUALLY ran — read against the // prepare-side item lines to see which appended ranges never reached the GPU. - static TRACE: std::sync::OnceLock = std::sync::OnceLock::new(); - if *TRACE.get_or_init(|| std::env::var_os("WOW_EFFECT_TRACE").is_some()) { + if effect_trace() { info!( "effect draw: merged {} indices {:?}", item.batch_range().start, @@ -924,6 +923,14 @@ impl Plugin for EffectLanePlugin { } } +/// `$WOW_EFFECT_TRACE` — the effect lane's per-item / per-bind / per-draw trace, one read for +/// the process: the three sites that asked the environment per frame (and per new bind group) +/// now ask this. +fn effect_trace() -> bool { + static TRACE: std::sync::OnceLock = std::sync::OnceLock::new(); + *TRACE.get_or_init(|| std::env::var_os("WOW_EFFECT_TRACE").is_some()) +} + #[cfg(test)] mod tests { use bevy::math::{Mat3, Mat4, Vec3}; diff --git a/crates/benilla-world/src/terrain_stream.rs b/crates/benilla-world/src/terrain_stream.rs index dac4e0cd..9ebb2867 100644 --- a/crates/benilla-world/src/terrain_stream.rs +++ b/crates/benilla-world/src/terrain_stream.rs @@ -1495,30 +1495,65 @@ fn handoff_straddlers( if !merge_on { return; } - let mut handed = 0u32; - for &uid in uids { - let Some(p) = placements.by_id.get_mut(&uid) else { - continue; // refs hit zero — released outright, nothing survives to hand off - }; - if p.owner != dead || !matches!(p.model, ModelHandle::M2(_)) { + // The straddlers first — the dead tile's M2 placements that survived the release with refs + // left (a handful to ~90 of its ~1k uids) — then ONE pass over each 8-neighbour's list. The + // old shape asked `Vec::contains` of every neighbour's whole list per straddler: 8 × ~1k + // compares × every straddler, on every tile drop (1697 item 2). + let mut straddlers: HashMap> = uids + .iter() + .copied() + .filter(|uid| { + placements + .by_id + .get(uid) + .is_some_and(|p| p.owner == dead && matches!(p.model, ModelHandle::M2(_))) + }) + .map(|uid| (uid, None)) + .collect(); + if straddlers.is_empty() { + return; // refs hit zero — released outright, nothing survives to hand off + } + // A straddler's other referrer shares its MDDF row across a tile seam, so it is an + // 8-neighbour of the dead owner; the full scan is a fallback for data that defies that. + let neighbours = [-1i32, 0, 1] + .iter() + .flat_map(|dx| [-1i32, 0, 1].map(|dy| (dead.0 + dx, dead.1 + dy))) + .filter(|c| *c != dead); + for c in neighbours { + let Some(t) = tiles.get(&c) else { continue; + }; + for uid in &t.placements { + if let Some(slot) = straddlers.get_mut(uid) { + if slot.is_none() { + *slot = Some(c); + } + } } - // A straddler's other referrer shares its MDDF row across a tile seam, so it is an - // 8-neighbour of the dead owner; the full scan is a fallback for data that defies that. - let new_owner = [-1i32, 0, 1] - .iter() - .flat_map(|dx| [-1i32, 0, 1].map(|dy| (dead.0 + dx, dead.1 + dy))) - .find(|c| *c != dead && tiles.get(c).is_some_and(|t| t.placements.contains(&uid))) - .or_else(|| { - tiles - .iter() - .find(|(_, t)| t.placements.contains(&uid)) - .map(|(c, _)| *c) - }); + } + if straddlers.values().any(Option::is_none) { + for (c, t) in tiles { + if *c == dead { + continue; + } + for uid in &t.placements { + if let Some(slot) = straddlers.get_mut(uid) { + if slot.is_none() { + *slot = Some(*c); + } + } + } + } + } + let mut handed = 0u32; + for (uid, new_owner) in straddlers { let Some(new_owner) = new_owner else { warn!("straddler handoff: uid {uid} holds refs but no loaded tile references it"); continue; }; + let Some(p) = placements.by_id.get_mut(&uid) else { + continue; + }; p.owner = new_owner; if p.spawned { for e in p.entities.drain(..) { diff --git a/crates/benilla-world/src/thread_qos.rs b/crates/benilla-world/src/thread_qos.rs index a6d1c97b..15f7c118 100644 --- a/crates/benilla-world/src/thread_qos.rs +++ b/crates/benilla-world/src/thread_qos.rs @@ -61,6 +61,12 @@ pub fn promote_current_thread(class: QosClass) { /// bare `std::thread::spawn` and no hook. An exclusive system runs on whichever thread drives the /// render schedule — the main thread during startup, the render thread once pipelined rendering /// takes over — so it re-runs each frame behind a thread-local latch instead of `run_once`. +/// +/// It sits in `ExtractCommands`, the schedule's first set, beside bevy's own exclusive +/// `apply_extract_commands`: an exclusive system is a barrier (the executor drains every running +/// system before it and restarts the fan-out after), and at that point nothing is in flight to +/// drain. Until 1697's scan named it, it lived in `Prepare` and cut that set's parallel +/// prepare fan-out in two on every frame of every run — for a latch that fires once. pub struct ThreadQosPlugin; impl Plugin for ThreadQosPlugin { @@ -68,7 +74,10 @@ impl Plugin for ThreadQosPlugin { let Some(render_app) = app.get_sub_app_mut(RenderApp) else { return; }; - render_app.add_systems(Render, promote_render_thread.in_set(RenderSystems::Prepare)); + render_app.add_systems( + Render, + promote_render_thread.in_set(RenderSystems::ExtractCommands), + ); } } diff --git a/crates/benilla-world/src/wmo_portal/mod.rs b/crates/benilla-world/src/wmo_portal/mod.rs index e8e0dc3a..b7d92a15 100644 --- a/crates/benilla-world/src/wmo_portal/mod.rs +++ b/crates/benilla-world/src/wmo_portal/mod.rs @@ -563,14 +563,26 @@ fn compute_wmo_pvs( // visit records): a group entering the PVS this frame stays "visited" for the rest of this // placement's residency, and the visibility authority draws its MLIQ surface off THIS latch, // never off the per-frame `visible`. - if inst.liquid_visited.len() != groups { - inst.liquid_visited = vec![false; groups]; + // Through the change gate like the flood's own answer above: this latch used to be + // written through `Mut` for every visible building on every frame, which marked the + // whole resident population changed and held `Changed` open — the + // one term of the visibility walk's still-frame skip (1979/1982) that a city can never + // satisfy. A slot that actually flips is a PVS change and marks, as `visible` does. + 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 inst.visible.get(g).copied().unwrap_or(false) { - inst.liquid_visited[g] = true; + 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;