the walking tax banded, and the minimap stops rewriting the world

- a panning UI batch rides its Transform instead of rewriting its mesh, minimap tiles emit unclipped (the mask shader already zeroes the corners), and the blip layer's origin snaps to half a logical pixel — steady UI mesh writes while moving drop from ~183/s to 10–14/s, and three minimap quads stop arming change detection over ~40k world meshes
- the classify twin GC comes off the despawn edge: a part despawn schedules the full walk behind a 2 s deadline instead of running it that frame — 12–22 full walks a second while walking, gone
- the walking tax measured and banded at Goldshire: parked 10.7 → moving 14.1 ms/frame of process CPU, ~+2.5 after the two fixes above
- WOW_PART_CHURN and WOW_MESH_HOLDERS count part spawn/despawn frames and name which entities hold a churning mesh; the capture rig's `at:` pin learns a facing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-20 19:09:00 +07:00
co-authored by Claude Fable 5
parent a51f29b9d3
commit 8a82cebae6
6 changed files with 289 additions and 23 deletions
+21 -6
View File
@@ -54,7 +54,7 @@
//! | `gear:<t>` | `.character premade gear <t>` | SEC_BASIC_ADMIN 4 | name or entry; levels up + equips |
//! | `spec:<t>` | `.character premade spec <t>` | SEC_BASIC_ADMIN 4 | resets talents, learns the tree |
//! | `at:<name>` | `.tele <name>` | SEC_TICKETMASTER 2 | 997 named rows in `game_tele` |
//! | `at:m,x,y,z` | `.go xyz x y z m` | SEC_TICKETMASTER 2 | |
//! | `at:m,x,y,z[,o]` | `.go xyz x y z m` (`.go xyzo` with `o`) | SEC_TICKETMASTER 2 | pin the facing when a motion probe walks |
//! | `gm:on\|off` | `.gm on\|off` | SEC_GAMEMASTER 3 | see 0649 on why `off` matters |
//!
//! Probe accounts are gmlevel **6**, so every one of these lands. Pass `gear:?` (or `spec:?`) to
@@ -506,15 +506,21 @@ fn build_steps(spec: &RigSpec, dead: bool) -> Vec<String> {
}
if let Some(at) = &spec.at {
steps.push(match parse_point(at) {
Some((map, x, y, z)) => format!(".go xyz {x} {y} {z} {map}"),
Some((map, x, y, z, Some(o))) => format!(".go xyzo {x} {y} {z} {o} {map}"),
Some((map, x, y, z, None)) => format!(".go xyz {x} {y} {z} {map}"),
None => format!(".tele {at}"),
});
}
steps
}
/// `map,x,y,z` → the `.go xyz` argument order (`x y z map`). Anything else is a `game_tele` name.
fn parse_point(at: &str) -> Option<(i32, f32, f32, f32)> {
/// `map,x,y,z[,o]` → `.go xyz x y z map`, or `.go xyzo x y z o map` when a facing is given
/// (VERIFIED vmangos `Chat.cpp:408`: `xyzo`, SEC_TICKETMASTER like `xyz`). Anything else is a
/// `game_tele` name. The facing is load-bearing exactly when a motion probe walks from the
/// pin: `W` follows the BODY's facing, and each probe character keeps whatever facing it last
/// had — an unpinned facing sent probe0 and probe4 down different routes from the same point,
/// which invalidated a cross-slot A/B before 1462's sitting caught it.
fn parse_point(at: &str) -> Option<(i32, f32, f32, f32, Option<f32>)> {
let mut parts = at.split(',').map(str::trim);
let map = parts.next()?.parse().ok()?;
let (x, y, z) = (
@@ -522,7 +528,11 @@ fn parse_point(at: &str) -> Option<(i32, f32, f32, f32)> {
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
);
parts.next().is_none().then_some((map, x, y, z))
let o = match parts.next() {
Some(o) => Some(o.parse().ok()?),
None => None,
};
parts.next().is_none().then_some((map, x, y, z, o))
}
fn rig_char_name(spec: &RigSpec) -> Option<String> {
@@ -743,9 +753,14 @@ mod tests {
build_steps(&point, false),
vec![".go xyz -1277.5 124 131.2 1"]
);
let faced = RigSpec::parse("at:0,-9399.03,10.41,59.83,0.0").unwrap();
assert_eq!(
build_steps(&faced, false),
vec![".go xyzo -9399.03 10.41 59.83 0 0"]
);
assert_eq!(parse_point("ThunderBluff"), None);
assert_eq!(parse_point("1,2,3"), None); // three numbers is not a map + point
assert_eq!(parse_point("1,2,3,4,5"), None);
assert_eq!(parse_point("1,2,3,4,5,6"), None); // and six is past even a faced point
}
#[test]
+15 -3
View File
@@ -517,7 +517,11 @@ fn emit_minimap(
z_key: slot.z,
texture: Some(handle.clone()),
color: [tint[0], tint[1], tint[2], slot.alpha],
clip: Some(slot.rect),
// No CPU clip (1463): the mask shader already zeroes everything outside
// `mask_rect` (`ui_quad.wgsl`'s `inside` test), and clipping a PANNING tile
// re-cut its quad every frame — constant positions, churning UVs — which is
// exactly the shape the batcher's pan gate cannot ride on a `Transform`.
// Unclipped, the tile is a pure translation and never rewrites its mesh.
mask: mask.clone(),
..default()
});
@@ -552,6 +556,14 @@ fn emit_minimap(
}
let win = window.iter().next();
let cursor = win.and_then(|w| w.cursor_position());
// The player's pan term, quantized to a half-logical-pixel grid (one device px at 2×):
// every blip offset — and the rim arrows' bearing — derives from `wx`/`wy`, so the whole
// blip layer steps together a few times a second instead of re-emitting sub-pixel-shifted
// quads every frame while walking (1463; a 0.07 px/frame slide on a 16 px icon is not a
// visible motion, but each slide rewrote the batch mesh and armed the world's
// `AssetChanged` scans). The blips' world positions stay exact — only the shared pan
// origin snaps.
let q = 0.5 / blip_px_per_yd;
blips::BlipCtx {
center,
side,
@@ -559,8 +571,8 @@ fn emit_minimap(
radius_yd: (side * 0.5) / blip_px_per_yd,
z: slot.z,
alpha: slot.alpha,
wx,
wy,
wx: (wx / q).round() * q,
wy: (wy / q).round() * q,
wz: wow[2],
cursor,
// The same point in UI space (y-up, ÷s through the 0582/0584 seam — the tooltip's
+95
View File
@@ -295,6 +295,101 @@ pub(super) fn count_mesh_events(
}
}
/// `WOW_PART_CHURN=1`: per-second M2-part churn. `rm_frames` is the count of frames with ≥1
/// `MeshMaterial3d<WowModelMaterial>` removal — exactly the predicate that promotes
/// `classify_water_side` to its full walk (0930's twin-GC mark), so a regime reading ~60 here
/// full-walks every frame. `mat_mod` counts `WowModelMaterial` asset Modified events — the
/// global asset-changed tick that wakes every `AssetChanged` scan.
pub(super) fn count_part_churn(
added: Query<(), Added<MeshMaterial3d<benilla_assets::materials::WowModelMaterial>>>,
mut removed: RemovedComponents<MeshMaterial3d<benilla_assets::materials::WowModelMaterial>>,
mut mat_events: MessageReader<
bevy::asset::AssetEvent<benilla_assets::materials::WowModelMaterial>,
>,
time: Res<Time>,
mut acc: Local<(f32, u32, u32, u32, u32)>,
) {
let (last, add_n, rm_n, rm_frames, mat_mod) = &mut *acc;
*add_n += added.iter().count() as u32;
let rm = removed.read().count() as u32;
*rm_n += rm;
if rm > 0 {
*rm_frames += 1;
}
*mat_mod += mat_events
.read()
.filter(|e| matches!(e, bevy::asset::AssetEvent::Modified { .. }))
.count() as u32;
if time.elapsed_secs() - *last >= 1.0 {
eprintln!(
"[part-churn] parts +{add_n} -{rm_n} rm_frames={rm_frames}/s mat_modified={mat_mod}/s"
);
(*add_n, *rm_n, *rm_frames, *mat_mod) = (0, 0, 0, 0);
*last = time.elapsed_secs();
}
}
/// `WOW_MESH_HOLDERS=1`: once a second, the set of mesh asset ids Modified in the last second
/// and the component signature of every entity holding one — [`count_mesh_events`] counts and
/// samples the ids, this names the writer by its holder's archetype (an id alone says nothing).
/// Exclusive, like [`arch_census`], for the same reason: the signature needs the live archetype.
pub(super) fn mesh_holders(
world: &mut World,
mut cursor: Local<Option<bevy::ecs::message::MessageCursor<bevy::asset::AssetEvent<Mesh>>>>,
mut acc: Local<(f32, std::collections::HashSet<bevy::asset::AssetId<Mesh>>)>,
) {
let messages = world.resource::<bevy::ecs::message::Messages<bevy::asset::AssetEvent<Mesh>>>();
let cursor = cursor.get_or_insert_with(|| messages.get_cursor());
let (last, ids) = &mut *acc;
ids.extend(cursor.read(messages).filter_map(|e| match e {
bevy::asset::AssetEvent::Modified { id } => Some(*id),
_ => None,
}));
let now = world.resource::<Time>().elapsed_secs();
if now - *last < 1.0 {
return;
}
*last = now;
if ids.is_empty() {
return;
}
let short = |full: &str| -> String {
let base = full.split('<').next().unwrap_or(full);
let segs: Vec<&str> = base.split("::").collect();
segs[segs.len().saturating_sub(2)..].join("::")
};
let mut found = 0usize;
let mut holders = world.query::<(Entity, &Mesh3d)>();
let matches: Vec<Entity> = holders
.iter(world)
.filter(|(_, m)| ids.contains(&m.0.id()))
.map(|(e, _)| e)
.take(6)
.collect();
for e in matches {
found += 1;
let sig: Vec<String> = world
.entity(e)
.archetype()
.components()
.iter()
.filter_map(|&c| world.components().get_info(c))
.map(|i| short(&i.name().to_string()))
.collect();
eprintln!(
"[mesh-holders] {e} holds a modified mesh: {}",
sig.join("+")
);
}
if found == 0 {
eprintln!(
"[mesh-holders] {} modified id(s), no Mesh3d holder (2d or unowned)",
ids.len()
);
}
ids.clear();
}
/// When the one-shot archetype census fires (seconds of `Time` elapsed; `f32::MAX` = spent).
#[derive(Resource)]
pub(super) struct ArchCensusAt(pub(super) f32);
+11
View File
@@ -99,6 +99,17 @@ impl Plugin for PerfPlugin {
if std::env::var_os("WOW_MESH_EVENTS").is_some() {
app.add_systems(Update, census::count_mesh_events);
}
// `WOW_PART_CHURN=1` — the moving-regime premise counters (1461): how many frames per
// second despawn an M2 part (= `classify_water_side` full-walk promotions), and how
// often model materials fire Modified (= the `AssetChanged` scan wake-ups).
if std::env::var_os("WOW_PART_CHURN").is_some() {
app.add_systems(Update, census::count_part_churn);
}
// `WOW_MESH_HOLDERS=1` — who OWNS the meshes `WOW_MESH_EVENTS` counts? Prints each
// holder's archetype signature once a second (1461's writer hunt).
if std::env::var_os("WOW_MESH_HOLDERS").is_some() {
app.add_systems(Last, census::mesh_holders);
}
// `WOW_CAM_CHANGED=1` — is the world camera's transform bit-stable at a parked pin?
// The premise counter for gating the per-submesh visibility sweep on frame-stable
// camera inputs: if the controller rewrites an equal transform every frame, change
+116 -5
View File
@@ -656,10 +656,18 @@ impl Run {
struct BatchPools {
entities: Vec<Entity>,
meshes: Vec<Handle<Mesh>>,
/// Each slot's last-written content — the per-run skip gate (decision 1361): a slot whose
/// run is bit-identical to what its pooled mesh already holds is not rewritten, so one
/// animating quad no longer drags every batch through the GPU mesh allocator.
/// Each slot's last-written MESH content (base positions, before [`Self::offsets`]) — the
/// per-run skip gate (decision 1361): a slot whose run is bit-identical to what its pooled
/// mesh already holds is not rewritten, so one animating quad no longer drags every batch
/// through the GPU mesh allocator.
stored: Vec<StoredRun>,
/// Each slot's current XY translation from its stored base, carried by the batch entity's
/// `Transform` instead of the mesh (decision 1463): a run that only *panned* — the minimap
/// tile was ~74% of all moving-regime rebuild triggers — moves without an `Assets<Mesh>`
/// write, because ONE Modified event per frame arms `AssetChanged` probes over every
/// `Mesh3d` row in the scene (1370's all-or-nothing fast path, ~0.8 ms/frame at 1461's
/// Goldshire pin).
offsets: Vec<Vec2>,
materials: std::collections::HashMap<MatKey, Handle<UiQuadMaterial>>,
}
@@ -676,6 +684,55 @@ struct StoredRun {
key: MatKey,
}
impl StoredRun {
/// The candidate's uniform XY offset from this base: `Some(d)` when every vertex is this
/// run's vertex plus one constant delta and everything else is bit-equal — `Some(ZERO)` is
/// exactly the old bit-identical skip case, so this subsumes 1361's gate. `None` means the
/// run genuinely changed shape and must be rebaked. The comparison is exact float
/// arithmetic on purpose: a miss only falls back to the rewrite path, so a widget whose
/// pan doesn't survive `b + d == n` bit-exactly loses the optimization, never correctness.
/// Why [`Self::translation_from`] missed — the pan-gate diagnostic (`WOW_UI_DIFF=1`).
fn translation_miss_reason(&self, new: &StoredRun) -> &'static str {
if self.key != new.key {
"key"
} else if self.z_bits != new.z_bits {
"z"
} else if self.positions.len() != new.positions.len() {
"len"
} else if self.indices != new.indices {
"indices"
} else if self.uvs != new.uvs {
"uvs"
} else if self.colors != new.colors {
"colors"
} else {
"delta-nonuniform"
}
}
fn translation_from(&self, new: &StoredRun) -> Option<Vec2> {
if self.key != new.key
|| self.z_bits != new.z_bits
|| self.positions.len() != new.positions.len()
|| self.positions.is_empty()
|| self.indices != new.indices
|| self.uvs != new.uvs
|| self.colors != new.colors
{
return None;
}
let d = Vec2::new(
new.positions[0][0] - self.positions[0][0],
new.positions[0][1] - self.positions[0][1],
);
self.positions
.iter()
.zip(&new.positions)
.all(|(b, n)| n[0] == b[0] + d.x && n[1] == b[1] + d.y && n[2] == b[2])
.then_some(d)
}
}
/// A material's full identity: texture, blend/shape flags, mask texture + mask rect (as bits, so
/// NaN/-0.0 can't split cache entries byte-equal materials would share).
type MatKey = (
@@ -696,8 +753,10 @@ fn retire_batches(pools: &mut BatchPools, commands: &mut Commands) {
commands.entity(entity).despawn();
}
// The skip gate must forget with them (decision 1361): a retired slot's entity is gone, so
// "content unchanged" must not skip the respawn when the UI returns.
// "content unchanged" must not skip the respawn when the UI returns. The pan gate's
// offsets ride the same lifecycle (they index the same slots).
pools.stored.clear();
pools.offsets.clear();
}
/// Per frame: when the BASE lane flagged a change ([`UiQuads::dirty`]) or the APPEND lane's
@@ -962,10 +1021,54 @@ fn rebuild_ui_mesh(
z_bits: z.to_bits(),
key,
};
if pools.stored.get(used) == Some(&stored) {
// The pan gate (1463): a run that matches its slot's base up to one constant XY delta
// moves on the batch entity's `Transform` — no mesh write, no `AssetChanged` arming.
// `Some(ZERO)` is the bit-identical case (1361's old skip gate), where even the
// `Transform` write is skipped unless a previous pan is being undone.
let pan = pools
.stored
.get(used)
.and_then(|prev| prev.translation_from(&stored).map(|d| (d, prev.z_bits)));
if let Some((d, z_bits)) = pan {
if pools.offsets[used] != d {
pools.offsets[used] = d;
if let Some(&entity) = pools.entities.get(used) {
commands.entity(entity).insert(Transform::from_xyz(
d.x,
d.y,
f32::from_bits(z_bits),
));
}
}
used += 1;
continue;
}
// Pan-gate miss diagnostic (`WOW_UI_DIFF=1`, ≤3 lines/s so a startup burst can't
// exhaust it): names the check that sent this slot to the rewrite path.
if *UI_DIFF {
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
static SHOWN: AtomicU32 = AtomicU32::new(0);
static LAST_SEC: AtomicU64 = AtomicU64::new(0);
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
let sec = START
.get_or_init(std::time::Instant::now)
.elapsed()
.as_secs();
if LAST_SEC.swap(sec, Ordering::Relaxed) != sec {
SHOWN.store(0, Ordering::Relaxed);
}
if SHOWN.fetch_add(1, Ordering::Relaxed) < 3 {
let why = pools
.stored
.get(used)
.map_or("no-prev", |p| p.translation_miss_reason(&stored));
eprintln!(
"[ui-pan] slot {used} rewrite: {why} (quads={}, key.tex={:?})",
stored.positions.len() / 4,
stored.key.0
);
}
}
// MAIN_WORLD too (not RENDER_WORLD-only): the pool rewrites this asset in place next
// rebuild, so the main-world copy must survive extraction.
let mut mesh = Mesh::new(
@@ -981,6 +1084,13 @@ fn rebuild_ui_mesh(
} else {
pools.stored.push(stored);
}
// A rebaked slot's mesh holds absolute positions again — its pan resets with it (the
// rewrite arm below writes `Transform::from_xyz(0, 0, z)`).
if pools.offsets.len() > used {
pools.offsets[used] = Vec2::ZERO;
} else {
pools.offsets.push(Vec2::ZERO);
}
let mesh_handle = match pools.meshes.get(used) {
Some(handle) => {
// `WOW_FREEZE_UI_MESH=1` — never rewrite a pooled batch mesh in place (the UI
@@ -1048,6 +1158,7 @@ fn rebuild_ui_mesh(
}
pools.meshes.truncate(used);
pools.stored.truncate(used);
pools.offsets.truncate(used);
}
/// Deliberately-overlapping synthetic content proving the sort: 5 z strata × 40 quads each, offset both
+31 -9
View File
@@ -787,12 +787,22 @@ pub struct FarSideOfWater;
/// surfaces, and the eye's side — so a *clean* batch is skipped, and only the changed set
/// re-classifies (~2.6k of ~40k parts on a measured Stormwind frame; the full sweep cost 2.2 ms).
/// The frame promotes to the full walk — byte-identical to the old per-frame behaviour — whenever
/// a global term moves: the eye crosses the surface (every verdict inverts), surfaces stream
/// in/out, or any part despawned (the twin GC below needs its full mark; between full frames a
/// pair orphaned by a handle overwrite — a fade settling to its cutout — lingers harmlessly, kept
/// fresh by the mirror above, until the next edge sweeps it). The room-claim trigger fans DOWN:
/// the claim lives on the unit root, the batches are its descendants, and a claim can change with
/// the unit standing still (a building streaming in under it re-claims at the same spot).
/// a global term that changes VERDICTS moves: the eye crosses the surface (every verdict
/// inverts) or surfaces stream in/out. A part *despawn* changes no live verdict — it only
/// obligates the twin GC's exact mark eventually — so it schedules a full walk behind
/// [`GC_DEADLINE_SECS`] instead of promoting the same frame (decision 1462: walking streams
/// parts out on 12–22 frames/s, and each promotion was a full re-classify of every part —
/// lane B of 1461's walking tax; between full frames a pair orphaned by a handle overwrite —
/// a fade settling to its cutout — lingers harmlessly, kept fresh by the mirror above, until
/// the next edge sweeps it, and a GC-due twin lingers the same way). The room-claim trigger
/// fans DOWN: the claim lives on the unit root, the batches are its descendants, and a claim
/// can change with the unit standing still (a building streaming in under it re-claims at the
/// same spot).
/// How long a despawn-obligated twin GC may wait for its full walk (1462). Bounds how long an
/// orphaned twin pair can outlive its last user — a handful of small material clones — while a
/// walking regime's continuous despawn stream costs ~0.5 full walks/s instead of 12–22.
const GC_DEADLINE_SECS: f32 = 2.0;
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub(crate) fn classify_water_side(
interleave: crate::particles::WaterInterleave,
@@ -834,7 +844,9 @@ pub(crate) fn classify_water_side(
reclaimed: Query<Entity, Changed<crate::wmo_portal::UnitWmoRoom>>,
children: Query<&Children>,
mut removed: RemovedComponents<MeshMaterial3d<WowModelMaterial>>,
time: Res<bevy::time::Time>,
mut eye_was_submerged: Local<Option<bool>>,
mut gc_due: Local<Option<f32>>,
) {
// Mirror any near-asset edit into the live twin. Since decision 1381 the texanim/tint lane
// animates through the shared table (the twin's clone carries the same slot and seed, so it
@@ -858,12 +870,22 @@ pub(crate) fn classify_water_side(
}
}
let eye = interleave.eye_submerged();
// `last()` drains the reader — `next()` would leave the tail readable and promote NEXT frame
// to a spurious second full walk.
let now = time.elapsed_secs();
// `last()` drains the reader — `next()` would leave the tail readable and re-arm NEXT frame
// as a spurious second deadline. The earliest despawn holds the deadline (`get_or_insert`):
// a steady despawn stream must not push the GC out forever.
if removed.read().last().is_some() {
gc_due.get_or_insert(now + GC_DEADLINE_SECS);
}
let full = *eye_was_submerged != Some(eye)
|| interleave.surfaces_changed()
|| removed.read().last().is_some();
|| gc_due.is_some_and(|due| now >= due);
*eye_was_submerged = Some(eye);
if full {
// Any full walk runs the GC's exact mark, whatever triggered it — a pending deadline
// is satisfied, not merely postponed.
*gc_due = None;
}
if full {
// The full walk — every part, plus the twin GC's exact mark-and-sweep.