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:
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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(..) {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user