eighteen no-op writes gated, and the dead-gate finder

- 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 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-15 16:53:24 +07:00
co-authored by Claude Fable 5.1
parent 46673c3f08
commit d41ebdd642
33 changed files with 630 additions and 197 deletions
+15
View File
@@ -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<ModelAnimations>>,
mut shrank: RemovedComponents<ModelAnimations>,
mounted: Query<(), Added<crate::entities::mount::MountBody>>,
mut unmounted: RemovedComponents<crate::entities::mount::MountBody>,
) {
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.
@@ -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<f64>,
@@ -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(),
};
+67 -36
View File
@@ -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<ResMut<T>>::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<K>(models: &HashMap<K, DisplayModel>) -> 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
);
}
}
}
}
+10 -5
View File
@@ -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::<u8>().ok());
static ZOOM_OVERRIDE: std::sync::OnceLock<Option<u8>> = std::sync::OnceLock::new();
let zoom_override = *ZOOM_OVERRIDE.get_or_init(|| {
std::env::var("WOW_MM_ZOOM")
.ok()
.and_then(|s| s.parse::<u8>().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<bool> = 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<bool> = 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
+18 -3
View File
@@ -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;
}
}
}
}
+129
View File
@@ -549,3 +549,132 @@ pub(super) fn mesh_touch(
/// When [`mesh_touch`] starts touching (seconds of `Time<Real>`).
#[derive(Resource)]
pub(super) struct MeshTouchAt(pub f32);
/// `WOW_RES_CENSUS=<at>:<secs>` — 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<T>` — or `as_deref_mut()`s an
/// `Option<ResMut<T>>` — 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<Name>'`.
///
/// 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<Tick>,
frames: u32,
counts: HashMap<ComponentId, u32>,
done: bool,
}
impl ResCensus {
/// Parse `WOW_RES_CENSUS=<at>:<secs>`.
pub(in crate::perf) fn from_env() -> Option<Self> {
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::<Time<Real>>().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<ResCensus>| {
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<ComponentId> = 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<T>` 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}");
}
}
+7
View File
@@ -187,6 +187,13 @@ impl Plugin for PerfPlugin {
app.insert_resource(c);
app.add_systems(Update, census::cpu_census::cpu_census);
}
// `WOW_RES_CENSUS=<at>:<secs>` — 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=<ms>` — which PHASE of a slow frame spent it (see the module doc).
+8 -1
View File
@@ -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<bool> = 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;
+1 -1
View File
@@ -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}",
+19 -4
View File
@@ -140,7 +140,7 @@ pub(super) fn query_statuses(
Entity,
&crate::net::Guid,
&NetEntity,
&ObjectStore,
Ref<ObjectStore>,
Option<&crate::ui_taxi::FlightMasterStatus>,
),
Without<SelfPlayer>,
@@ -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 {
+7 -1
View File
@@ -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
+20 -9
View File
@@ -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
});
}
+15 -8
View File
@@ -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,
+14 -5
View File
@@ -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<u32, u32>,
}
/// 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<u32, u32>,
) -> 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),
+8 -1
View File
@@ -324,7 +324,7 @@ fn watch_skill_ups(
/// without `0x2` under its own header (decision 1091).
fn feed_skills(
script: Option<NonSendMut<UiScript>>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
self_store: Query<Ref<ObjectStore>, With<SelfPlayer>>,
skill_lines: Option<Res<crate::ui_spellbook::SkillLines>>,
mut last: Local<crate::ui_script::VmMemo<Option<SkillsState>>>,
) {
@@ -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).
+12 -2
View File
@@ -145,7 +145,7 @@ pub(super) fn feed_language_skills(
actions: Option<Res<PlayerActions>>,
spells: Option<Res<Spells>>,
skill_lines: Option<Res<SkillLines>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
self_q: Query<Ref<ObjectStore>, With<SelfPlayer>>,
) {
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));
}
}
+15 -11
View File
@@ -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<String> = 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<u32> = 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),
+20
View File
@@ -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<u32, u32> {
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)]
+5
View File
@@ -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<ShapeshiftFormView> = 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
});
+13 -1
View File
@@ -295,7 +295,7 @@ fn feed_known_languages(
spells: Option<Res<crate::ui_action::Spells>>,
skill_lines: Option<Res<crate::ui_spellbook::SkillLines>>,
languages: Option<Res<LanguagesRes>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
self_q: Query<Ref<ObjectStore>, With<SelfPlayer>>,
mut pushed: Local<crate::ui_script::VmMemo<Option<Vec<String>>>>,
) {
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<String>`) 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)
+2 -1
View File
@@ -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<bool> = 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
+6
View File
@@ -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
+4 -2
View File
@@ -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<bool> = 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
+15 -5
View File
@@ -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<crate::view::ViewDistance>,
windows: Res<crate::wmo_portal::ExteriorWindows>,
claim: Res<crate::wmo_portal::CameraInteriorClaim>,
portals: Query<(), Changed<crate::wmo_portal::WmoPortalInstance>>,
flips: Query<(), Changed<InheritedVisibility>>,
) {
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 {
+69 -55
View File
@@ -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<bool> = 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::<FfxGlow>();
}
@@ -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.
@@ -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<Option<std::ffi::OsString>> =
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<Option<std::ffi::OsString>> = 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;
+6 -4
View File
@@ -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<Option<std::ffi::OsString>> = 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<Option<std::ffi::OsString>> = 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;
}
+2 -1
View File
@@ -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<bool> = 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
+12 -6
View File
@@ -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<bool> = 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
+11 -4
View File
@@ -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<String> = 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<P: PhaseItem> RenderCommand<P> 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<bool> = 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<bool> = 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};
+53 -18
View File
@@ -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<u32, Option<(i32, i32)>> = 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(..) {
+10 -1
View File
@@ -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),
);
}
}
+16 -4
View File
@@ -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<WmoPortalInstance>` 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;