the world only pays for what's on screen, and the leak is gone

- terrain draws per chunk now, and one authority culls everything outside: buildings, billboard cards, particle emitters
- the fps and memory creep on long flights is fixed twice over: material animations only tick what something draws, and far-away art is released by distance (with a 30 s dwell floor so fast cameras don't thrash it)
- the second crash ever reported (a cast racing a despawning unit at flight speed) is fixed, with a regression test that reproduces the exact panic
- every build now prints which commit it is, so a bug report can say exactly what it ran

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-30 19:39:56 +07:00
co-authored by Claude Fable 5
parent 03aa04e54d
commit d3e05f32e5
66 changed files with 3061 additions and 370 deletions
+2 -3
View File
@@ -49,9 +49,8 @@ You need a **1.12.1 (build 5875) client install** for game data, a
WOW_DATA=/path/to/WoW/Data cargo run --release -p benilla
```
The server defaults to `localhost` — point `WOW_HOST` at any IP or hostname, with an optional
auth port: `WOW_HOST=play.example.com:3724`. A bare host uses port 13724 (this project's own dev
deploy); a stock vmangos `realmd` listens on 3724, so you'll usually want the explicit port.
The server defaults to `localhost:3724`, where a stock vmangos `realmd` listens — point `WOW_HOST`
at any IP or hostname, and append an auth port if yours is remapped: `WOW_HOST=play.example.com:5000`.
Credentials go in at the login screen, or set `WOW_USER` / `WOW_PASS` to skip it.
## Contributing
+51 -44
View File
@@ -1,11 +1,12 @@
//! ADT → terrain-tile asset loader.
//!
//! Decodes one vanilla ADT tile into an [`AdtTile`]: the **merged** splat-blended terrain `Mesh`
//! (all 256 MCNK chunks in one mesh, Bevy space) plus its three `texture_2d_array`s — ground layers,
//! per-chunk alpha (blend) maps, per-chunk MCSH shadow maps — and the raw doodad/WMO placement lists
//! (for the spawn system to load as `M2Model`/`WmoModel`). Per-chunk array indices ride the merged
//! mesh's vertex `COLOR` (4 layer indices) and `UV1` (alpha index `.x`, shadow index `.y`, `-1` =
//! none); the app's terrain material reads them.
//! Decodes one vanilla ADT tile into an [`AdtTile`]: **one splat-blended `Mesh` per MCNK chunk**
//! (Bevy space, absolute world coords) plus its three `texture_2d_array`s — ground layers, per-chunk
//! alpha (blend) maps, per-chunk MCSH shadow maps — and the raw doodad/WMO placement lists (for the
//! spawn system to load as `M2Model`/`WmoModel`). Each chunk mesh carries its array indices on every
//! one of its vertices — `COLOR` (4 layer indices) and `UV1` (alpha index `.x`, shadow index `.y`,
//! `-1` = none) — which is why all 256 chunks of a tile still share **one** material; the app's
//! terrain material reads them.
//!
//! Layer textures are read via [`LoadContext::read_asset_bytes`] (raw bytes, dependency-tracked) and
//! packed by [`crate::terrain`] — the BLP's authored mips verbatim (the C2 fidelity invariant).
@@ -30,13 +31,20 @@ use crate::terrain::{
LayerTexture, LAYER_TEX_SIZE,
};
/// A loaded ADT terrain tile: the merged splat mesh + its three texture arrays, plus the raw
/// A loaded ADT terrain tile: one splat mesh per MCNK chunk + its three texture arrays, plus the raw
/// doodad/WMO placements (which the spawn system resolves to `M2Model`/`WmoModel` handles).
#[derive(Asset, TypePath)]
pub struct AdtTile {
/// Merged terrain mesh (Bevy space). Per-chunk layer indices ride vertex `COLOR`; the alpha and
/// shadow array indices ride `UV1.x` / `UV1.y`.
pub mesh: Handle<Mesh>,
/// One terrain mesh per drawn MCNK chunk (Bevy space, absolute world coords) — the tile's 256
/// cells, less any that a hole mask emptied. Layer indices ride vertex `COLOR`; the alpha and
/// shadow array indices ride `UV1.x` / `UV1.y`, uniform across a chunk's own vertices.
///
/// **Per chunk, not per tile, because the cull's unit is the chunk** (decision 0780): the
/// exterior-scene cull tests one AABB per drawn object, and the reference's object there is the
/// 33.333 yd cell (`0x683bf0`), never the 533 yd tile — a slab the camera stands on intersects
/// every portal window, so it could never be hidden from inside a building. Nothing is duplicated
/// by the split: the merged form never shared a vertex across a chunk boundary either.
pub chunk_meshes: Vec<Handle<Mesh>>,
/// `texture_2d_array` of the tile's ground textures (the C2-faithful authored mip chains).
pub layer_array: Handle<Image>,
/// `texture_2d_array` of per-chunk alpha (blend-weight) maps.
@@ -47,8 +55,9 @@ pub struct AdtTile {
pub doodads: Vec<Doodad>,
/// WMO building placements (raw WoW coords).
pub wmos: Vec<WmoInstance>,
/// The tile's decoded per-MCNK chunks — the source the merged `mesh` + arrays were built from,
/// kept resident so the app can derive what those forms drop: the MCLQ liquid surfaces (each
/// The tile's decoded per-MCNK chunks — the source the `chunk_meshes` + arrays were built from,
/// kept resident so the app can derive what those forms drop: the terrain collider, the MCLQ
/// liquid surfaces (each
/// `ChunkMesh.liquid`) and the ground-clutter scatter (per-chunk layers/normals/MCSH + the
/// app-side ground-effect catalog). Bounded by the loaded-tile count; the heaviest field on the
/// asset, carried because clutter/liquid are app concerns (the loader stays catalog-independent).
@@ -87,27 +96,31 @@ impl AssetLoader for AdtLoader {
let mut shadow_buf: Vec<u8> = Vec::new();
let mut shadow_count = 0u32;
// Merged-mesh accumulators: COLOR carries each vertex's 4 layer indices, UV1 the alpha/shadow.
let mut m_pos: Vec<[f32; 3]> = Vec::new();
let mut m_norm: Vec<[f32; 3]> = Vec::new();
let mut m_uv: Vec<[f32; 2]> = Vec::new();
let mut m_col: Vec<[f32; 4]> = Vec::new();
let mut m_uv1: Vec<[f32; 2]> = Vec::new();
let mut m_idx: Vec<u32> = Vec::new();
// One mesh per drawn chunk. COLOR carries the chunk's 4 layer indices, UV1 its alpha/shadow —
// uniform over the chunk's own vertices, which is what keeps ONE material per tile.
let mut chunk_meshes: Vec<Handle<Mesh>> = Vec::with_capacity(tile.chunks.len());
for chunk in &tile.chunks {
let base = m_pos.len() as u32;
for (i, chunk) in tile.chunks.iter().enumerate() {
// A chunk the hole mask emptied draws nothing: no mesh, no entity, and no alpha/shadow
// array slot either (the indices below are running counters, so skipping here is free).
if chunk.indices.len() < 3 {
continue;
}
let positions: Vec<[f32; 3]> = chunk
.positions
.iter()
.map(|p| wow_to_bevy(*p).to_array())
.collect();
// Prefer authored MCNR normals (continuous across MCNK borders — no seams); else flat.
if chunk.normals.len() == chunk.positions.len() {
m_norm.extend(chunk.normals.iter().map(|n| wow_to_bevy(*n).to_array()));
let normals: Vec<[f32; 3]> = if chunk.normals.len() == chunk.positions.len() {
chunk
.normals
.iter()
.map(|n| wow_to_bevy(*n).to_array())
.collect()
} else {
m_norm.extend(computed_normals(&positions, &chunk.indices));
}
computed_normals(&positions, &chunk.indices)
};
// Resolve up to 4 layer textures to array indices (appending new ones). Missing slots
// reuse layer 0 (its alpha weight is 0, so it never shows).
@@ -148,13 +161,18 @@ impl AssetLoader for AdtLoader {
let col = [li[0] as f32, li[1] as f32, li[2] as f32, li[3] as f32];
let uv1 = [ai as f32, si];
for _ in 0..positions.len() {
m_col.push(col);
m_uv1.push(uv1);
}
m_uv.extend_from_slice(&chunk.uvs);
m_idx.extend(chunk.indices.iter().map(|i| base + i));
m_pos.extend(positions);
let n = positions.len();
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, chunk.uvs.clone());
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_1, vec![uv1; n]);
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, vec![col; n]);
mesh.insert_indices(Indices::U32(chunk.indices.clone()));
chunk_meshes.push(ctx.add_labeled_asset(format!("chunk{i}"), mesh));
}
// A `texture_2d_array` needs ≥1 layer even when a tile carries no alpha / shadow maps.
@@ -167,23 +185,12 @@ impl AssetLoader for AdtLoader {
shadow_count = 1;
}
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, m_pos);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, m_norm);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, m_uv);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_1, m_uv1);
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, m_col);
mesh.insert_indices(Indices::U32(m_idx));
let layer_array = layer_array_image(LAYER_TEX_SIZE, layer_count, layer_mips, layer_buf);
let alpha_array = alpha_array_image(ALPHA_MAP_SIZE, alpha_count, alpha_buf);
let shadow_array = shadow_array_image(SHADOW_MAP_SIZE, shadow_count, shadow_buf);
Ok(AdtTile {
mesh: ctx.add_labeled_asset("mesh".to_string(), mesh),
chunk_meshes,
layer_array: ctx.add_labeled_asset("layer_array".to_string(), layer_array),
alpha_array: ctx.add_labeled_asset("alpha_array".to_string(), alpha_array),
shadow_array: ctx.add_labeled_asset("shadow_array".to_string(), shadow_array),
+19 -9
View File
@@ -532,7 +532,7 @@ mod tests {
app.update();
if let Some(t) = app.world().resource::<Assets<AdtTile>>().get(&handle) {
info = Some((
t.mesh.clone(),
t.chunk_meshes.clone(),
t.layer_array.clone(),
t.alpha_array.clone(),
t.shadow_array.clone(),
@@ -542,18 +542,25 @@ mod tests {
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
let (mesh_h, layer_h, alpha_h, shadow_h, n_doodads) =
let (chunk_hs, layer_h, alpha_h, shadow_h, n_doodads) =
info.expect("the ADT tile should load via mpq:// + AdtLoader");
// One drawn mesh per MCNK cell, never one merged slab (decision 0780) — the exterior-scene
// cull's unit is the chunk, and a tile that loads as a single object cannot be culled from
// inside a building. Every cell must be non-empty and a 145-vertex 9×9+8×8 grid.
let meshes = app.world().resource::<Assets<Mesh>>();
let vc = meshes
.get(&mesh_h)
.expect("merged terrain mesh present")
.count_vertices();
assert!(
vc > 0 && vc <= 256 * 145,
"merged tile mesh ≤ 256 chunks × 145 verts, got {vc}"
(1..=256).contains(&chunk_hs.len()),
"a tile draws as its MCNK cells, got {} meshes",
chunk_hs.len()
);
for h in &chunk_hs {
let vc = meshes
.get(h)
.expect("chunk mesh sub-asset present")
.count_vertices();
assert_eq!(vc, 145, "an MCNK cell is 9×9 + 8×8 vertices");
}
let images = app.world().resource::<Assets<Image>>();
let layer = images.get(&layer_h).expect("layer array present");
@@ -568,6 +575,9 @@ mod tests {
assert!(images.get(&alpha_h).is_some(), "alpha array present");
assert!(images.get(&shadow_h).is_some(), "shadow array present");
// Placement lists are carried as data (count is tile-dependent — some tiles are bare).
eprintln!("ADT tile loaded: {vc} verts, {n_doodads} doodad placements");
eprintln!(
"ADT tile loaded: {} MCNK cells, {n_doodads} doodad placements",
chunk_hs.len()
);
}
}
+14 -1
View File
@@ -163,10 +163,23 @@ pub(crate) fn build_submesh_mesh(sub: &RenderSubmesh) -> Mesh {
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, sub.vertex_colors.clone());
}
if sub.normals.len() == sub.positions.len() {
// A billboard CARD is lit on the face it PRESENTS (decision 0788): a card authored
// back-to-front against the law's `+X`-at-the-viewer gets its plane's normals turned round
// here, so its shading stops swinging with the camera (the shape, the reference's own
// behaviour and the 279-batch population are on
// [`RenderSubmesh::billboard_card_faces_away`]).
//
// **Normals only — never the winding.** The 177 away-facing SINGLE-sided cards are
// authored placeholder geometry the reference backface-culls from every angle
// (`bbfacescan`), and our `cull_mode` reproduces that; re-winding them would reveal it.
let flip = sub.billboard_card_faces_away();
let normals: Vec<[f32; 3]> = sub
.normals
.iter()
.map(|n| wow_to_bevy(*n).to_array())
.map(|n| {
let b = wow_to_bevy(*n);
if flip { -b } else { b }.to_array()
})
.collect();
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
} else {
@@ -643,8 +643,17 @@ fn winding(s: &benilla_formats::RenderSubmesh) -> Option<String> {
(Some(f), Some(n)) => format!("{:+.2}", f[0] * n[0] + f[1] * n[1] + f[2] * n[2]),
_ => "-".to_string(),
};
// A billboard card authored back-to-front against the law's `+X`-at-the-viewer: the renderer
// turns its normals round so the card is lit off the face it presents (decision 0788). `vnorm`
// alone cannot answer this — it is the MEAN, so a batch whose normals cancel reads the same as
// one flat plane — hence the shape's own verdict here rather than an eyeball off the numbers.
let lit_face = if s.billboard_card_faces_away() {
" LIT-FACE-FLIP"
} else {
""
};
Some(format!(
"facet {} vnorm {} dot {dot}",
"facet {} vnorm {} dot {dot}{lit_face}",
fmt(facet),
fmt(vnorm)
))
@@ -346,6 +346,44 @@ pub struct GroundQuad {
const GROUND_FLAT_EPS: f32 = 0.01;
impl RenderSubmesh {
/// Is this a **billboard card authored back-to-front** — one flat plane whose normal sits in the
/// −X half-space the billboard law points *away* from the camera?
///
/// The billboard arm aims bone-local **+X** at the viewer (wow-re `billboard-bone-law.md` §2;
/// M2 bones carry no bind rotation, so bone-local == model space here), and 279 of the corpus's
/// 4424 billboard batches are wound and normalled the other way — the camera only ever sees the
/// card's back. The reference skins the normal through the same billboard-replaced palette row
/// as the position (`m2_vertex_skin 0x71a460`) and never flips one per face, so on those cards
/// its `max(N·L, 0)` runs off a normal pointing away from the viewer and the card's shading
/// swings with the CAMERA: bare ambient when the sun is behind you, full sun when you look into
/// it. Consumers light such a card off the side it presents instead (decision 0788).
///
/// The planarity half is load-bearing: 3-D billboard geometry (the questgiver `?`'s 353 verts)
/// carries normals pointing every way, and flipping just its −X ones would gut its shading.
/// A card authored edge-on to the camera axis has no facing to correct, matching
/// `bbfacescan`'s away/edge-on split.
pub fn billboard_card_faces_away(&self) -> bool {
/// Cosine floor for "these two normals are the same plane" — one authored card, allowing
/// for unnormalised//soft-averaged authoring.
const SAME_PLANE_COS: f32 = 0.999;
/// A normal with |x| under this is edge-on to the camera axis: nothing to correct.
const EDGE_ON_X: f32 = 1e-3;
if self.billboard.is_none() {
return false;
}
let unit = |n: &[f32; 3]| {
let l = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
(l > 1e-6).then(|| [n[0] / l, n[1] / l, n[2] / l])
};
let Some(n0) = self.normals.first().and_then(unit) else {
return false;
};
n0[0] < -EDGE_ON_X
&& self.normals.iter().all(|n| {
unit(n).is_some_and(|n| n[0] * n0[0] + n[1] * n0[1] + n[2] * n0[2] > SAME_PLANE_COS)
})
}
/// Detect the flat **ground-plane quad** shape ([`GroundQuad`]) — `None` for everything that
/// isn't exactly it: more than four vertices, any vertex off the z≈0 plane, a multi-bone or
/// partial-weight skin, or corners that don't form an axis-aligned XY rectangle. Billboard
@@ -0,0 +1,168 @@
//! Difftest the **billboard-card lit-face** shape (decision 0788) against real content: which
//! billboard batches are authored back-to-front against the law's `+X`-at-the-viewer, and — the
//! load-bearing half — which must NOT be touched.
//!
//! `RenderSubmesh::billboard_card_faces_away` decides whether a consumer turns a card's normals
//! round so it is lit off the face it presents. Get it wrong in the permissive direction and 3-D
//! billboard geometry loses its shading; get it wrong in the strict direction and the cards go on
//! swinging warm/cool with the camera. Both sides are pinned here on the shipped assets. Skips
//! when the client isn't present.
use std::path::PathBuf;
use benilla_formats::{load_m2_mesh, open_chain, BillboardKind};
fn vanilla_data_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data")
}
/// The hanging shop sign — the report this shape came from. Its two "chains" are not chain geometry
/// at all but a pair of 4-vert lock-Z billboard cards on a tiled chain texture, each authored with
/// its whole plane's normal on the −X side, i.e. facing away from the viewer the law points them at.
/// The sign body is ordinary geometry and must stay untouched.
#[test]
fn the_shop_signs_chain_cards_face_away_and_its_body_is_untouched() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = open_chain(&data).expect("open vanilla patch chain");
let subs = load_m2_mesh(
&mut chain,
"World\\Generic\\Human\\Passive Doodads\\Signs\\CheeseShop01.m2",
)
.expect("load CheeseShop01");
let cards: Vec<_> = subs.iter().filter(|s| s.billboard.is_some()).collect();
assert_eq!(cards.len(), 2, "the sign hangs on two billboard cards");
for c in &cards {
let bb = c.billboard.as_ref().unwrap();
assert_eq!(
bb.kind,
BillboardKind::LockZ,
"a hanging chain card spins about model up (bone flag 0x40)"
);
assert_eq!(c.positions.len(), 4, "a card is one quad");
assert!(
c.two_sided,
"the card is two-sided (material 0x04), so the reference draws the back we see"
);
assert!(
c.billboard_card_faces_away(),
"the card's plane normal is authored on the −X side: lit off the face it presents"
);
}
assert!(
subs.iter()
.any(|s| s.billboard.is_none() && !s.billboard_card_faces_away()),
"the sign body is not a billboard and is never re-normalled"
);
}
/// The negative that matters: the questgiver `?` marker is a lock-Z billboard too, but its geometry
/// is 3-D (hundreds of verts, normals pointing every way round it). A rule that flipped every −X
/// normal on a billboard batch would gut its shading, so the shape demands ONE plane.
#[test]
fn the_questgiver_marker_is_3d_billboard_geometry_not_a_card() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = open_chain(&data).expect("open vanilla patch chain");
let subs = load_m2_mesh(&mut chain, "Interface\\Buttons\\TalkToMeQuestionMark.m2")
.expect("load TalkToMeQuestionMark");
let billboards: Vec<_> = subs.iter().filter(|s| s.billboard.is_some()).collect();
assert!(
!billboards.is_empty(),
"the marker rides a billboard bone (it faces the camera)"
);
for b in &billboards {
assert!(
b.positions.len() > 4,
"3-D geometry, not a quad — got {} verts",
b.positions.len()
);
assert!(
!b.billboard_card_faces_away(),
"3-D billboard geometry is not a flat card: its normals must be left alone"
);
}
}
/// The shape's remaining arms, on synthetic normals (no assets needed): an EDGE-ON card has no
/// facing to correct, the already-correct majority is untouched, degenerate normals never flip,
/// and a non-billboard batch is out of scope however it is normalled.
#[test]
fn the_shape_ignores_edge_on_correct_and_degenerate_batches() {
use benilla_formats::{Billboard, ModelBlend, RenderSubmesh};
let card = |normals: Vec<[f32; 3]>, billboard: bool| RenderSubmesh {
positions: vec![[0.0; 3]; normals.len()],
normals,
uvs: vec![[0.0; 2]; 0],
indices: vec![],
texture: None,
skin_slot: None,
geoset_id: 0,
char_slot: None,
blend: ModelBlend::Opaque,
wrap_x: true,
wrap_y: true,
two_sided: true,
joints: vec![],
weights: vec![],
vertex_colors: vec![],
interior: false,
emissive: false,
sidn: None,
window: false,
additive: false,
no_depth_write: false,
no_depth_test: false,
fog_policy: benilla_formats::FogPolicy::Scene,
billboard: billboard.then(|| Billboard {
bone: 0,
pivot: [0.0; 3],
kind: BillboardKind::LockZ,
scale_anim: None,
seq_translations: vec![],
}),
alpha_anim: None,
uv_anim: None,
rgb_anim: None,
wmo_batch: None,
};
assert!(
card(vec![[-1.0, 0.0, 0.0]; 4], true).billboard_card_faces_away(),
"the away-facing card (the control) flips"
);
// Soft/averaged authoring: unnormalised and a hair off plane — still one card.
assert!(
card(vec![[-0.98, 0.01, -0.02], [-1.02, -0.01, 0.01]], true).billboard_card_faces_away(),
"a soft-normal card is still one plane"
);
assert!(
!card(vec![[1.0, 0.0, 0.0]; 4], true).billboard_card_faces_away(),
"the majority already presents its lit face — untouched"
);
assert!(
!card(vec![[0.0, 0.0, -1.0]; 4], true).billboard_card_faces_away(),
"edge-on to the camera axis: no facing to correct"
);
assert!(
!card(vec![[0.0; 3]; 2], true).billboard_card_faces_away(),
"degenerate zero normals never flip"
);
assert!(
!card(vec![], true).billboard_card_faces_away(),
"no normals, no rule"
);
assert!(
!card(vec![[-1.0, 0.0, 0.0]; 4], false).billboard_card_faces_away(),
"an ordinary batch is out of scope however it is normalled"
);
}
+8 -8
View File
@@ -26,10 +26,9 @@ use std::net::TcpStream;
use anyhow::{anyhow, Context, Result};
use benilla_srp::{NormalizedString, PublicKey, SrpClientChallenge, SESSION_KEY_LENGTH};
/// The realmd (auth/login) server port. Our vmangos deploy maps realmd to host port 13724 —
/// moved off the classic 3724, which the WOA dev server owns on this machine (the compose file at
/// `vmangos-deploy` carries the matching mapping).
pub const AUTH_PORT: u16 = 13724;
/// The realmd (auth/login) server port — the stock one a vmangos `realmd` listens on, which our
/// deploy maps straight through (`3724:3724` in the compose file at `vmangos-deploy`).
pub const AUTH_PORT: u16 = 3724;
/// The 1.12.1 client build we present to the server.
pub const CLIENT_BUILD: u16 = 5875;
@@ -126,19 +125,20 @@ pub fn logon(host: &str, username: &str, password: &str) -> Result<Logon> {
#[cfg(test)]
mod host_port_tests {
use super::host_port;
use super::{host_port, AUTH_PORT};
#[test]
fn explicit_port_splits() {
// Explicit port beats the default — they differ so the assert can tell them apart.
assert_eq!(
host_port("play.example.com:3724", 13724),
("play.example.com", 3724)
host_port("play.example.com:5000", AUTH_PORT),
("play.example.com", 5000)
);
}
#[test]
fn bare_host_gets_default() {
assert_eq!(host_port("localhost", 13724), ("localhost", 13724));
assert_eq!(host_port("localhost", AUTH_PORT), ("localhost", AUTH_PORT));
}
#[test]
@@ -91,6 +91,13 @@ const FIELD_UNIT_DISPLAYID: u16 = 131;
/// on every dismount path (`Unit::Mount`/`Unmount`) — this field, not the aura, IS the mounted
/// state (`Unit::IsMounted` reads it back; no flag or bytes field accompanies it). Decision 0441.
const FIELD_UNIT_MOUNTDISPLAYID: u16 = 133;
/// `UNIT_FIELD_PETNUMBER` (`OBJECT_END(6) + 0x85` = 139, PUBLIC — vmangos
/// `UpdateFields_1_12_1.h:84`; byte-verified client-side as `[unit+0x110]+0x214` = relative index
/// `0x85`, the second gate inside the rank getter `0x605620` — decision 0782). Non-zero ⇒ this
/// unit is somebody's pet or charm, which is exactly how the
/// client decides a creature has **no** classification (rank forced to 0), so no elite dragon, no
/// ELITE/BOSS tooltip word and no world-boss skull on an enslaved mob.
const FIELD_UNIT_PETNUMBER: u16 = 139;
const FIELD_UNIT_DYNAMIC_FLAGS: u16 = 143;
/// `UNIT_CHANNEL_SPELL` (idx 144, `OBJECT_END(6) + 0x8A`, PUBLIC — VERIFIED vmangos
/// `UpdateFields_1_12_1.h:89`) — the spell id a unit is channeling (`0` = not channeling).
@@ -41,6 +41,22 @@ impl ObjectFields {
pub fn unit_created_by(&self) -> Option<u64> {
self.get_guid(FIELD_UNIT_CREATEDBY).filter(|&g| g != 0)
}
/// `UNIT_FIELD_PETNUMBER` — non-zero iff this unit is somebody's **pet or charm**.
///
/// Not a count and not an index anyone displays: the client reads it as a pure boolean, as the
/// **second gate of the rank getter** `0x605620` (decision 0782). A creature with a cached
/// template but a non-zero pet number reports classification rank `0` — so an enslaved elite
/// loses its dragon border, its ELITE tooltip word and (at rank 3) its world-boss skull, all
/// three from this one read.
///
/// What lands here is the server's call, and vmangos is narrower than the name suggests:
/// `CharmInfo::SetPetNumber(n, statWindow)` writes `n` only when `statWindow`, else **0**. So a
/// permanent pet (`Pet::IsPermanentPetFor`), a charm/mind-control apply and a player's summon
/// carry a non-zero number, while **guardians and totems carry 0** and therefore keep whatever
/// rank their template gives them.
pub fn unit_is_pet_or_charm(&self) -> bool {
self.get_u32(FIELD_UNIT_PETNUMBER).unwrap_or(0) != 0
}
/// `UNIT_FIELD_HEALTH` — current hit points (0 ⇒ dead).
pub fn unit_health(&self) -> Option<u32> {
self.get_u32(FIELD_UNIT_HEALTH)
+5 -5
View File
@@ -31,11 +31,11 @@ pub use reader::WorldReader;
pub use session::{WardenRequired, WorldSession};
pub use writer::WorldWriter;
/// Default `mangosd` world-server port. Our vmangos deploy maps mangosd to host port 18085 —
/// moved off the classic 8085, which the WOA dev server owns on this machine. Normally the realm
/// list reply carries the port (the deploy's `VMANGOS_REALMLIST_PORT=18085` matches); this
/// constant is the fallback for probes/examples that dial the world server directly.
pub const WORLD_PORT: u16 = 18085;
/// Default `mangosd` world-server port — the stock one, which our vmangos deploy maps straight
/// through. Normally the realm list reply carries the port (the deploy's
/// `VMANGOS_REALMLIST_PORT=8085` matches); this constant is the fallback for probes/examples that
/// dial the world server directly.
pub const WORLD_PORT: u16 = 8085;
/// Read one server packet from `stream`: decrypt the 4-byte header, read the body, parse by opcode.
/// `decrypter` is `None` for the (single) unencrypted `SMSG_AUTH_CHALLENGE`.
+16 -1
View File
@@ -5,7 +5,9 @@
use mlua::{Lua, Value};
use super::super::Model;
use super::{grey_band, level_reads_unknown, pick_unit_token, power_token, with_unit};
use super::{
classification_word, grey_band, level_reads_unknown, pick_unit_token, power_token, with_unit,
};
/// Register the `Unit*` globals reading the per-token snapshot store (the same style/place the
/// object model and stdlib register their globals — bare globals on `_G`, matching the live API
@@ -406,6 +408,19 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// UnitClassification(unit) → "normal" | "elite" | "rareelite" | "worldboss" | "rare" (decision
// 0782, byte-verified `0x516d90`): a plain table index by the gated rank, and it answers a
// STRING for every input — never nil. An absent snapshot deliberately reports "normal" rather
// than nil because the binary does: its unresolved-token path loads table index 0 and pushes
// that, so a frame reading it gets the plain border art instead of a nil comparison.
g.set(
"UnitClassification",
lua.create_function(|lua, token: Option<String>| {
let rank = with_unit(lua, &token, 0u32, |u| u.rank);
Ok(classification_word(rank).to_string())
})?,
)?;
// UnitPowerType(unit) → (index, token): (0, "MANA"), (1, "RAGE"), … (the live API also returns
// alt-power color components; addons that read those handle nil).
g.set(
+28 -1
View File
@@ -86,7 +86,13 @@ pub struct UnitState {
/// The creature TYPE display word ("Beast", "Humanoid", …) — app-resolved from
/// `CreatureType.dbc`; the level line's class slot for hostile/neutral creatures.
pub creature_type_name: Option<String>,
/// Elite rank 0..4 — the level line's rank word `{"", Elite, Elite, Boss, ""}` (0276).
/// Elite rank 0..4 — the creature classification every rank reader in the client shares, via
/// the one getter `0x605620`: the level line's rank word `{"", Elite, Elite, Boss, ""}` (0276),
/// `UnitLevel`'s world-boss −1 ([`level_reads_unknown`]), and `UnitClassification`
/// ([`classification_word`], decision 0782). **Already gated** by the app when it fills this:
/// the getter answers `0` unless the unit has a cached creature template *and* a zero
/// `UNIT_FIELD_PETNUMBER`, so a player, an un-queried creature and an enslaved elite all read
/// `0` here — the gate belongs at the one write, not in each of the three readers.
pub rank: u32,
/// The creature civilian flag — feeds the tooltip's green CIVILIAN line (shown only for a
/// PvP-flagged HOSTILE unit that is also GREY/trivial to the player — the dishonorable-kill
@@ -198,6 +204,27 @@ pub fn level_reads_unknown(u: &UnitState, player_level: u32) -> bool {
!u.is_player && (u.level == 0 || u.rank == 3 || much_higher_hostile)
}
/// `UnitClassification`'s return — the classification-word table, **byte-verified** at `0x850424`
/// (decision 0782): the binding `0x516d90` is nothing but `TABLE[rank(unit)]`, indexed by the same
/// gated rank [`UnitState::rank`] carries. The table is exactly five entries long (the sixth dword
/// at `0x850438` is an unrelated `"UnitExists"` literal), and rank comes pre-clamped to 0..4 by
/// `CreatureInfo+0x20`, so an out-of-range value can only be our own bug — it reads `"normal"`,
/// which is also what the binding's unresolved-token path pushes (`0x516dc4` loads index 0).
///
/// Note `"rareelite"` is a real return here even though 1.12 has no rare-elite *art* and no
/// rare-elite tooltip *word* — the reference's `TargetFrame_CheckClassification` sends it to the
/// same Elite border as `"elite"`, and the tooltip's `{"", Elite, Elite, Boss, ""}` prints ELITE for
/// it. The distinction exists in the API and nowhere in the pixels.
pub fn classification_word(rank: u32) -> &'static str {
match rank {
1 => "elite",
2 => "rareelite",
3 => "worldboss",
4 => "rare",
_ => "normal",
}
}
/// The Era power-token string for a power-type index (`UnitPowerType`'s second return,
/// `UNIT_POWER_UPDATE`'s arg2). Unknown indices report as mana, the descriptor default.
pub fn power_token(ty: u8) -> &'static str {
@@ -564,3 +564,50 @@ fn faction_group_pair_and_the_pvp_toggle() {
);
assert_eq!(s.take_pvp_toggles(), 0, "the drain empties the queue");
}
/// `UnitClassification` (decision 0782, byte-verified table `0x850424`): five words indexed by the
/// gated rank, a STRING for every input — including an absent token, which the binary answers
/// `"normal"` for (its unresolved path loads index 0), never nil.
#[test]
fn unit_classification_reads_the_five_word_table() {
let mut s = UiScript::new().unwrap();
for (rank, word) in [
(0, "normal"),
(1, "elite"),
(2, "rareelite"),
(3, "worldboss"),
(4, "rare"),
] {
s.set_unit(
"target",
Some(UnitState {
exists: true,
rank,
..UnitState::default()
}),
);
assert_eq!(
s.eval::<String>(r#"return UnitClassification("target")"#)
.unwrap(),
word,
"rank {rank}"
);
}
// No snapshot at all, and a missing argument: still "normal", never nil. The target frame's
// border branch compares this against string literals, so a nil here would be a Lua error the
// reference cannot produce.
s.set_unit("target", None);
assert_eq!(
s.eval::<String>(r#"return UnitClassification("target")"#)
.unwrap(),
"normal",
"an absent unit is normal, not nil"
);
assert_eq!(
s.eval::<String>(r#"return UnitClassification()"#).unwrap(),
"normal",
"a missing token is normal, not nil"
);
}
+21 -5
View File
@@ -61,6 +61,8 @@ struct Vertex {
// world_position, so the big-coordinate cancellation never reaches the matrix product —
// absolute-coordinate verts through `clip_from_world` shear thin geometry apart far from
// the origin (the precip module's empirically-learned law at ~9000 yd).
// EXCEPT under DECAL_WORLD_CLIP (decision 0781): the decal family's verts arrive ABSOLUTE
// (prepare skips their rebase) and take the mesh path's own matrix in the vertex stage.
@location(0) position: vec3<f32>,
@location(1) uv: vec2<f32>,
@location(2) color: vec4<f32>, // raw authored gamma RGBA; α is the blend weight
@@ -70,25 +72,39 @@ struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
// View-space depth (+ in front of the camera) — the fog/farclip planar eye-Z, exact
// because the camera-relative view transform is pure rotation.
// View-space depth (+ in front of the camera) — the fog/farclip planar eye-Z (exact on
// the cam-relative path, where the view transform is pure rotation).
@location(2) view_z: f32,
};
@vertex
fn vertex(v: Vertex) -> VertexOutput {
var out: VertexOutput;
// view_from_world = [R | −R·cam]; for a cam-relative point the translation column is
// exactly the subtraction prepare already did, so only the rotation remains.
#ifdef DECAL_WORLD_CLIP
// The decal family (raster_bias ≠ 0): ABSOLUTE world verts through `clip_from_world` —
// the SAME matrix every world-mesh shader runs (`position_world_to_clip` in terrain.wgsl /
// wow_model.wgsl). A decal's depth must tie against the drawn ground within the small
// rasterizer bias; the cam-relative route below reaches the same plane through different
// arithmetic, and at WoW-scale coordinates the two disagree by more than the bias — the
// flaky/wedge-shaped blob shadow (decision 0781). Same matrix, same jitter: the decal
// wobbles WITH its receiver instead of against it.
out.clip_position = view.clip_from_world * vec4<f32>(v.position, 1.0);
// Fog/farclip eye-Z via the full affine transform (yards-scale — rounding is irrelevant).
out.view_z = -(view.view_from_world * vec4<f32>(v.position, 1.0)).z;
#else
// Free-floating families: cam-relative verts (0733 §2). view_from_world = [R | −R·cam];
// for a cam-relative point the translation column is exactly the subtraction prepare
// already did, so only the rotation remains.
let view_pos = mat3x3<f32>(
view.view_from_world[0].xyz,
view.view_from_world[1].xyz,
view.view_from_world[2].xyz,
) * v.position;
out.clip_position = view.clip_from_view * vec4<f32>(view_pos, 1.0);
out.view_z = -view_pos.z;
#endif
out.uv = v.uv;
out.color = v.color;
out.view_z = -view_pos.z;
return out;
}
+48 -5
View File
@@ -50,10 +50,15 @@
grey (l.176) is still deferred, on the tap wire. The ref gates the player
branch on UnitPlayerControlled (which also covers pets); we resolve its player half via
UnitIsPlayer, so a player-controlled PET takes the NPC reaction branch until that flag lands.
- The classification border swap (elite/rare art, ref-TargetFrame.lua l.205-218) is a named
gap: the creature `rank` DOES surface now (the query's record caches it — names.rs, the
tooltip's rank word and the plate's world-boss skull read it), but UnitClassification is
not yet bound and the elite/rare border art swap is unbuilt.
- The classification border swap (elite/rare art, ref-TargetFrame.lua l.205-218) LANDED with
decision 0782 — UnitClassification is bound and BenillaTargetFrame_CheckClassification
below runs the ref's branch table. Two things worth knowing at this altitude. The rank
`UnitClassification` reads is the client's GATED rank (`0x605620`): a pet or charmed unit
reports rank 0, so an enslaved elite wears the plain border, and because the tooltip's
ELITE/BOSS word and UnitLevel's world-boss skull read the same getter, the gate lives at
the app's single write (ui_unit.rs enrich_unit), not per reader. And UNIT_CLASSIFICATION_
CHANGED is the repaint wire, edge-fired on that rank — without it the border would only be
right on re-target, since Update is otherwise the sole caller.
- The level slot runs the full CheckLevel law (ref-TargetFrame.lua l.119-142, transcribed in
Update below): corpse or "can't tell" (UnitLevel ≤ 0) → the HighLevelTexture skull;
otherwise the number, GetDifficultyColor-tinted when attackable, gold when not.
@@ -157,9 +162,16 @@
-- and nothing else does.
frame:RegisterEvent("UNIT_FACTION")
frame:RegisterEvent("PLAYER_TARGET_CHANGED")
-- The aura rows are target-only (ref TargetFrame_OnLoad registers UNIT_AURA).
-- The aura rows and the classification border are target-only (ref TargetFrame_OnLoad
-- registers UNIT_AURA and UNIT_CLASSIFICATION_CHANGED; PlayerFrame registers neither,
-- and there is no CheckClassification for the player — your own frame never wears a
-- dragon). The ring-art region is the one the border swap re-textures, so only the
-- target caches the handle: its presence is what gates the swap in Update, the same
-- nil-handle idiom highLevel/nameBackground use above.
if unit == "target" then
frame:RegisterEvent("UNIT_AURA")
frame:RegisterEvent("UNIT_CLASSIFICATION_CHANGED")
frame.frameTexture = getglobal(tf .. "Texture")
end
-- The portrait hit indicator (decision 0576): only the player frame registers
-- UNIT_COMBAT in 1.12 (ref PlayerFrame_OnLoad — the target frame has no feedback
@@ -392,6 +404,31 @@
end
-- Pull the unit's current state and repaint. A non-existent unit hides the whole frame.
-- The classification border art (TargetFrame_CheckClassification, ref-TargetFrame.lua
-- l.205-218) — transcribed branch for branch, and the shape is the surprise: 1.12 has FIVE
-- classifications but only THREE border textures, because elite, rareelite and worldboss all
-- take the same Elite art. There is no rare-elite border in this build and no rare-elite
-- tooltip word either (the level line's rank table is {"", ELITE, ELITE, BOSS, ""}), so
-- "rareelite" is a distinction the API makes and the pixels never do. Verified rather than
-- assumed, because later expansions DO ship UI-TargetingFrame-Rare-Elite and it would be the
-- natural thing to write: that path is absent from 1.12's patch chain entirely, and the ref
-- Lua sends rareelite to the Elite art.
--
-- SetTexture is the whole operation — no TexCoords, size or anchor changes. That is only
-- sound because all three BLPs are 256x128 (checked, not assumed), so the region's
-- XML-declared TexCoords (0.09375..1.0 x 0..0.78125 = the 232x100 crop) frame all three
-- identically, and our SetTexture touches only the path (it leaves tex_coords alone).
function BenillaTargetFrame_CheckClassification(frame)
local classification = UnitClassification(frame.unit)
if classification == "worldboss" or classification == "rareelite" or classification == "elite" then
frame.frameTexture:SetTexture("Interface\\TargetingFrame\\UI-TargetingFrame-Elite")
elseif classification == "rare" then
frame.frameTexture:SetTexture("Interface\\TargetingFrame\\UI-TargetingFrame-Rare")
else
frame.frameTexture:SetTexture("Interface\\TargetingFrame\\UI-TargetingFrame")
end
end
function BenillaUnitFrame_Update(frame)
local unit = frame.unit
if not UnitExists(unit) then
@@ -483,6 +520,12 @@
end
end
-- The classification border (target only) — the ref's TargetFrame_Update calls
-- CheckClassification right here, between CheckFaction and CheckDead.
if frame.frameTexture then
BenillaTargetFrame_CheckClassification(frame)
end
-- DEAD text (target only): shown at zero health (ref TargetFrame_CheckDead). Blanked
-- via SetText when alive — a site that predates region Show/Hide (region.rs has the
-- real pair now, the level slot above uses it); equivalent either way.
+106
View File
@@ -0,0 +1,106 @@
//! Stamps the **commit this binary was built from** into the binary, for
//! [`build_id`](../src/build_id.rs) to read back at runtime.
//!
//! Why it exists: people run benilla from a clone of the public snapshot repo, and a report ("this
//! looks wrong", "it crashed here") is only actionable if we know *which* code they ran. A git sha
//! is the only id that can't drift — the crate version is a permanent `0.1.0` and there are no
//! releases to number. For a public clone the stamp is the **snapshot** sha, which maps back to the
//! private commit exactly (`pubsync` tags it `pub/<public-short-sha>`); for our own worktrees it is
//! the private commit itself.
//!
//! It emits four `rustc-env` vars. The three git ones come back **empty** when git can't answer (a
//! source zip with no `.git`, or no `git` on PATH), which the runtime side reports as an unknown
//! build rather than failing to compile:
//!
//! - `BENILLA_GIT_SHA` — the full 40-char sha (what the panel copies to the clipboard).
//! - `BENILLA_GIT_SHORT` — git's own abbreviation, so the string matches the `pub/<sha>` tag names
//! (`pubsync` abbreviates the same way, in the same repo).
//! - `BENILLA_GIT_DATE` — the commit date (`%cs`, `YYYY-MM-DD`). Committer date, not build date:
//! it is a property of the sha, so it can never disagree with it.
//!
//! …plus `BENILLA_PROFILE`, the profile directory name — `debug` / `release` / `ship` (0736's
//! shipping profile). Cargo's own `PROFILE` var collapses every release-like profile to `release`,
//! so `ship` and `release` would be indistinguishable; the profile *directory* inside `OUT_DIR`
//! carries the real name, which is what a "why is this slow" report needs to say.
//!
//! ## Staleness — the only thing that could make this lie
//!
//! A stamp is worthless if it can report a commit the binary isn't. The rerun triggers below are
//! therefore exactly the files that change when HEAD moves: `HEAD` itself (checkout, rebase,
//! detach) and the ref it names (a commit rewrites `refs/heads/<branch>`, not `HEAD`). Both are
//! resolved with `git rev-parse --git-path`, which applies the worktree rules — in a `wt.sh` pool
//! slot `.git` is a *file*, `HEAD` lives in `<primary>/.git/worktrees/<slot>/`, and `refs/heads/*`
//! stay in the common dir. Only paths that exist are emitted: cargo reruns a build script whose
//! watched path is *missing* on every build, and every one of those reruns relinks the app crate.
//!
//! The cost of that honesty is one app-crate recompile once the sha actually changes (measured at
//! ~37 s, 0787) — the previous binary genuinely no longer matches it. A rerun that produces the
//! same output does *not* recompile: cargo compares it, and the build settles for a relink (~7 s).
//!
//! What the stamp deliberately does **not** claim is that the working tree was clean: it names the
//! commit the build came *from*. A dirty flag can only be as fresh as the last build-script run, so
//! in the normal dev loop (edit, run, don't stage) it would read "clean" while the binary carried
//! uncommitted work — a flag that is wrong exactly when it matters. "Which snapshot" is the
//! question a remote report needs answered, and a bare sha answers it without lying.
use std::path::Path;
use std::process::Command;
fn main() {
// Build scripts run with the package root as cwd, but be explicit: `-C` makes the git calls
// independent of that guarantee.
let dir = std::env::var("CARGO_MANIFEST_DIR").expect("cargo sets CARGO_MANIFEST_DIR");
let git = |args: &[&str]| -> Option<String> {
let out = Command::new("git")
.arg("-C")
.arg(&dir)
.args(args)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
(!s.is_empty()).then_some(s)
};
// Rerun triggers first, so they are emitted even if a later call fails.
let watch = |path: Option<String>| {
if let Some(p) = path.filter(|p| Path::new(p).exists()) {
println!("cargo::rerun-if-changed={p}");
}
};
watch(git(&["rev-parse", "--git-path", "HEAD"]));
if let Some(git_ref) = git(&["symbolic-ref", "-q", "HEAD"]) {
// The loose ref file, plus `packed-refs` for the freshly-cloned/gc'd repo where the branch
// has no loose file at all (a public clone is exactly that).
watch(git(&["rev-parse", "--git-path", &git_ref]));
watch(git(&["rev-parse", "--git-path", "packed-refs"]));
}
// A stamp that never changes if this file is edited would be its own kind of stale.
println!("cargo::rerun-if-changed=build.rs");
for (var, args) in [
("BENILLA_GIT_SHA", &["rev-parse", "HEAD"][..]),
("BENILLA_GIT_SHORT", &["rev-parse", "--short", "HEAD"]),
("BENILLA_GIT_DATE", &["log", "-1", "--format=%cs"]),
] {
println!(
"cargo::rustc-env={var}={}",
git(args).unwrap_or_default() // empty ⇒ "unknown build" at runtime
);
}
// `OUT_DIR` is `<target>/[<triple>/]<profile-dir>/build/<pkg>-<hash>/out` — the component
// before `build` is the profile directory. Falls back to cargo's coarse `PROFILE`.
let out_dir = std::env::var("OUT_DIR").unwrap_or_default();
let parts: Vec<_> = Path::new(&out_dir).components().collect();
let profile = parts
.iter()
.position(|c| c.as_os_str() == "build")
.and_then(|i| i.checked_sub(1))
.and_then(|i| parts[i].as_os_str().to_str())
.map(str::to_owned)
.unwrap_or_else(|| std::env::var("PROFILE").unwrap_or_default());
println!("cargo::rustc-env=BENILLA_PROFILE={profile}");
}
+589
View File
@@ -0,0 +1,589 @@
//! **Within-map art residency** — the distance half of decision 0729's map-scope teardown.
//!
//! 0729 gave the world-art dedup caches a *reset point*: a cross-map transition clears them, and the
//! loading screen pays for the rebuild. B131 is the case that argument does not reach — a long flight
//! **inside** one map never fires that transition, so nothing ever evicts and `mats`/`images` ratchet
//! for as long as you stay on a continent (decision 0785's open half: 26.8 k materials / 2.8 k images
//! after ten minutes here; 3.2 → 4.9 GiB of VRAM on the reporter's tour).
//!
//! This module turns clear-on-*transition* into clear-on-*distance*: every cache entry remembers the
//! view focus it was last used from, and a sweep drops what was last wanted more than
//! [`DEFAULT_RADIUS_YD`] away. So residency is bounded by *where you are*, not by how long the process
//! has run, and the map-change clear stays exactly as it was — the hard reset on top of the soft one.
//!
//! **Distance decides what expires; a dwell floor decides how soon.** [`MIN_DWELL_SECS`] exists
//! because the radius alone is sized for gameplay speed — the detached free-fly camera crosses it in
//! five seconds and would lose a city's dedup only to rebuild it on the way back (found on the
//! director's first real run; see the constant).
//!
//! **Distance, not a TTL**, and the difference is safety rather than taste: a time-expired entry can
//! be one the streamer is about to ask for again from ten yards away, and dropping it costs a
//! duplicate; a distance-expired entry is one whose tiles unloaded long ago. The radius is therefore
//! floored at the streamer's own reach ([`radius_floor`]) so eviction can never outrun the thing that
//! placed the art.
//!
//! **Two properties worth knowing before reading the code**, both of which buy the design its
//! smallness:
//!
//! 1. **Stamping needs no plumbing.** A *use* (a [`SpatialCache::fetch`] hit or an insert) clears the
//! stamp to `None` — "used since the last sweep" — and the *sweep* is what writes today's focus
//! onto every `None`. So no call site has to know where the camera is, and a cache nothing sweeps
//! (the `Local<MaterialCache>`s in the glue booth and the portrait bake) simply never expires.
//! 2. **Nothing needs ordering.** The stamp is at worst one sweep interval stale (≈25 yd at flight
//! speed, against a 2.6 km radius) and `due` being read a frame before it is set only moves the
//! sweep one frame. There are no `.before`/`.after` constraints anywhere in this module.
//!
//! The failure mode if the radius were ever too small: evicting the dedup for art that is still
//! drawn. That is not a visual bug and not a leak — the drawn entity holds its own handles, and the
//! next spawn of that art re-creates one duplicate material which becomes the new cache entry. It
//! costs a lost batch, and it self-heals. That asymmetry (too-eager = mild and transient, too-lazy =
//! the bug we are fixing) is why the floor is a clamp and not a warning.
use std::collections::HashMap;
use std::hash::Hash;
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use bevy::time::Real;
use benilla_formats::TILE_SIZE;
/// Wall seconds between sweeps ([`Real`], not the virtual clock — this is a housekeeping cadence,
/// and decision 0789's reading applies: "what time is it" is not "how much world time to advance").
/// One `retain` over ~30 k entries a second is not measurable; the interval exists so it is not run
/// per frame, and it doubles as the stamp's resolution.
const SWEEP_SECS: f32 = 1.0;
/// Default eviction radius in yards — five ADT tiles (2667 yd). Chosen as the smallest round
/// multiple of the tile grid comfortably clear of [`radius_floor`] at the default
/// `$WOW_TILE_RADIUS` (2 ⇒ 2419 yd): far enough that art still on screen is never evicted, close
/// enough that leaving one city for another recovers it (Stormwind → Booty Bay is ~5500 yd,
/// Ironforge → Stormwind ~4250).
const DEFAULT_RADIUS_YD: f32 = 5.0 * TILE_SIZE;
/// The **dwell floor**: nothing is dropped within this many wall seconds of its last use, however far
/// away you have gone. Distance decides *what* expires; this decides how soon it is allowed to.
///
/// Found by the director's first real run, and it is the case the distance rule alone gets wrong. The
/// detached free-fly camera (`F`, `FlyCam::speed` 40–100 yd/s, ×5 on Ctrl) travels at up to 500 yd/s —
/// so it crosses the 2667 yd radius in **five seconds**, and a sweep then drops a whole city's dedup
/// that the same camera re-creates on the way back. Their 48-second leg evicted 25 570 entries and
/// rebuilt `pmat` 3086 → 15354 doing exactly that: the eviction was *correct* by the radius and
/// useless in effect, because nothing had finished being wanted.
///
/// 30 s covers a 15 000 yd round trip at boosted fly speed. Under gameplay speed it never binds at
/// all (a gryphon needs ~107 s to cover the radius), so B131's own case is unchanged — this only
/// stops the thrash at speeds the radius was never sized for. Residency stays bounded either way:
/// what you hold is at most the art of the last 30 seconds of travel, which does not grow with how
/// long the process has run.
const MIN_DWELL_SECS: f32 = 30.0;
/// The smallest radius that cannot evict art the streamer is still holding: the far **corner** of
/// the `(2r+1)²` tile block it keeps resident, plus a tile of margin.
///
/// A stamp records where *the viewer* was when the art was last wanted, not where the art is — so an
/// entry can be up to a streaming radius "behind" the art it belongs to. Flooring the radius at the
/// block's circumscribed radius means you have to leave the art's neighbourhood entirely before its
/// dedup expires.
fn radius_floor(tile_radius: u32) -> f32 {
(tile_radius as f32 + 0.5) * TILE_SIZE * std::f32::consts::SQRT_2 + TILE_SIZE
}
/// Resolve `$WOW_ART_RADIUS` (yards; `0` or negative ⇒ eviction off, the A/B leg that reproduces the
/// unbounded behaviour on a fixed build) against the streamer's reach. An explicitly-requested radius
/// below [`radius_floor`] is raised **and** warned about, rather than silently obeyed: a knob that
/// quietly means something else than it says is how decision 0789 happened.
fn radius_from_env(tile_radius: u32) -> f32 {
let floor = radius_floor(tile_radius);
match std::env::var("WOW_ART_RADIUS")
.ok()
.and_then(|s| s.parse::<f32>().ok())
{
Some(r) if r <= 0.0 => 0.0,
Some(r) if r < floor => {
warn!(
"art-scope: WOW_ART_RADIUS={r} is inside the streamer's own reach ({floor:.0} yd \
at tile radius {tile_radius}) — using {floor:.0}"
);
floor
}
Some(r) => r,
None => DEFAULT_RADIUS_YD.max(floor),
}
}
/// Horizontal (tile-plane) distance between two wow-space points. Height is deliberately ignored:
/// residency is a 2D question — the streamer's window is a tile square, and art 200 yd below a
/// gryphon is in front of you, not away from you.
fn plan_dist(a: [f32; 3], b: [f32; 3]) -> f32 {
let (dx, dy) = (a[0] - b[0], a[1] - b[1]);
(dx * dx + dy * dy).sqrt()
}
/// A dedup cache whose entries remember **where they were last used from**, so a sweep can drop the
/// art of a place you have left. A drop-in for the `HashMap<K, Handle<_>>` these caches used to be:
/// the only difference at a call site is that a lookup is [`Self::fetch`] (it counts as a use).
///
/// `None` in the stamp means "used since the last sweep" — see the module docs for why that inverted
/// spelling is what removes all the plumbing.
pub(crate) struct SpatialCache<K, V> {
map: HashMap<K, (V, Option<Stamp>)>,
}
/// Where and when an entry was last wanted: the view focus at the first sweep after its last use.
/// Both halves are load-bearing — `at` decides whether it is far enough to expire, `t` whether it has
/// been idle long enough to be allowed to ([`MIN_DWELL_SECS`]).
#[derive(Clone, Copy)]
struct Stamp {
at: [f32; 3],
t: f32,
}
impl<K, V> Default for SpatialCache<K, V> {
fn default() -> Self {
Self {
map: HashMap::new(),
}
}
}
impl<K: Eq + Hash, V> SpatialCache<K, V> {
/// Live entry count — what the census reports and the map-change evictors log.
pub(crate) fn len(&self) -> usize {
self.map.len()
}
/// Drop everything (the map-scope teardown, decision 0729 — still the hard reset).
pub(crate) fn clear(&mut self) {
self.map.clear();
}
/// Fetch a deduped value, **counting the hit as a use** so it restarts its distance grace.
/// Clones out rather than lending a reference: every caller wants an owned `Handle` anyway, and
/// ending the borrow at the call is what lets the miss path insert in the same expression.
pub(crate) fn fetch(&mut self, key: &K) -> Option<V>
where
V: Clone,
{
let slot = self.map.get_mut(key)?;
slot.1 = None;
Some(slot.0.clone())
}
/// Install a freshly-built value, counted as a use.
pub(crate) fn insert(&mut self, key: K, value: V) {
self.map.insert(key, (value, None));
}
/// Get-or-build in place, counted as a use — for the caches whose value is too big to clone out
/// ([`crate::clutter::ClutterGeometry`]'s decoded submeshes).
pub(crate) fn or_insert_with(&mut self, key: K, build: impl FnOnce() -> V) -> &mut V {
let slot = self.map.entry(key).or_insert_with(|| (build(), None));
slot.1 = None;
&mut slot.0
}
/// Stamp everything used since the last sweep at `focus`/`now`, then drop every entry that is
/// **both** more than `radius` yards away **and** idle for at least [`MIN_DWELL_SECS`]. Returns
/// how many were dropped.
///
/// The two conditions are not redundant. Distance alone expires art the moment a fast camera
/// pulls away from it, which is how the director's free-fly leg lost a city's dedup and rebuilt
/// it seconds later; the dwell floor is what makes "not immediate" true at any speed, not just at
/// gameplay speed.
pub(crate) fn scope(&mut self, focus: [f32; 3], radius: f32, now: f32) -> usize {
let before = self.map.len();
self.map.retain(|_, (_, stamp)| match *stamp {
None => {
*stamp = Some(Stamp { at: focus, t: now });
true
}
Some(s) => plan_dist(s.at, focus) <= radius || now - s.t < MIN_DWELL_SECS,
});
before - self.map.len()
}
}
/// Which cache a census row is about. Fixed set, in journal-column order — the CSV's columns only
/// ever grow at the end, so this enum's order is part of the file format.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum ArtSlot {
/// `Placements::materials` — streamed doodad/WMO submesh materials.
PlaceMats,
/// `EntityMaterials` — creature/player/GameObject model materials.
EntityMats,
/// `SkinComposites` — composited character body atlases (decision 0044).
Skins,
/// `WorldAssets::model_materials` — the ground-clutter material dedup.
ClutterMats,
/// `WorldAssets::textures` — decoded world BLPs by (path, wrap).
Textures,
/// `ClutterGeometry` — CPU submesh copies of every detail M2 decoded.
ClutterGeo,
}
impl ArtSlot {
/// Every slot, in journal-column order.
pub(crate) const ALL: [ArtSlot; 6] = [
ArtSlot::PlaceMats,
ArtSlot::EntityMats,
ArtSlot::Skins,
ArtSlot::ClutterMats,
ArtSlot::Textures,
ArtSlot::ClutterGeo,
];
/// The journal's column name for this slot.
pub(crate) fn column(self) -> &'static str {
match self {
ArtSlot::PlaceMats => "pmat",
ArtSlot::EntityMats => "emat",
ArtSlot::Skins => "skin",
ArtSlot::ClutterMats => "cmat",
ArtSlot::Textures => "tex",
ArtSlot::ClutterGeo => "cgeo",
}
}
fn idx(self) -> usize {
match self {
ArtSlot::PlaceMats => 0,
ArtSlot::EntityMats => 1,
ArtSlot::Skins => 2,
ArtSlot::ClutterMats => 3,
ArtSlot::Textures => 4,
ArtSlot::ClutterGeo => 5,
}
}
}
/// Per-cache residency, refreshed by every [`ArtScope::apply`] call — the instrument that localizes
/// a residency ratchet to **one cache** in a single journal row, instead of the run-length probe that
/// found B131. `Assets<T>::len()` says "materials are growing"; this says which map is holding them.
#[derive(Resource, Default)]
pub(crate) struct ArtCensus {
live: [usize; ArtSlot::ALL.len()],
dropped: [usize; ArtSlot::ALL.len()],
}
impl ArtCensus {
/// Live entries in one cache as of its last sweep.
pub(crate) fn live(&self, slot: ArtSlot) -> usize {
self.live[slot.idx()]
}
/// Entries dropped by distance across every cache since the run began.
pub(crate) fn dropped_total(&self) -> usize {
self.dropped.iter().sum()
}
}
/// The sweep's shared state: the radius, this tick's focus, and whether this frame is a sweep frame.
#[derive(Resource, Default)]
pub(crate) struct ArtScopeState {
/// Eviction radius in yards; `0` ⇒ never evict. Zero until [`configure_art_scope`] runs, so
/// nothing can be dropped before the streamer's reach is known.
radius: f32,
/// The view focus, wow coords ([`crate::terrain_stream::view_focus`] — the same ladder the
/// streamer and the WDL ring use). `None` when there is no focus at all (no avatar, no camera),
/// which suspends both stamping and sweeping.
focus: Option<[f32; 3]>,
/// Set on the one frame per [`SWEEP_SECS`] that sweeps.
due: bool,
last_sweep: f32,
/// Wall seconds since app start, this frame — the clock the dwell floor is measured on.
now: f32,
}
impl ArtScopeState {
/// The view focus this frame, wow coords — what the sweep measures from. Exposed for the FPS
/// journal: its `x,y,z` are the *avatar*, which stands still through a whole detached free-fly, so
/// on that leg the position columns say nothing about where the art was being asked for. This is
/// the column that does.
pub(crate) fn focus(&self) -> Option<[f32; 3]> {
self.focus
}
}
/// What an owning module needs to scope its caches: the policy, plus the census to report into.
#[derive(SystemParam)]
pub(crate) struct ArtScope<'w> {
state: Res<'w, ArtScopeState>,
census: ResMut<'w, ArtCensus>,
}
impl ArtScope<'_> {
/// Sweep one cache (on sweep frames) and record its residency. Safe to call for a cache that is
/// empty, unconfigured, or in a run with eviction switched off — it degrades to a census read.
pub(crate) fn apply<K: Eq + Hash, V>(&mut self, cache: &mut SpatialCache<K, V>, slot: ArtSlot) {
let dropped = match self.state.focus {
Some(focus) if self.state.due && self.state.radius > 0.0 => {
cache.scope(focus, self.state.radius, self.state.now)
}
_ => 0,
};
let i = slot.idx();
self.census.live[i] = cache.len();
self.census.dropped[i] += dropped;
if dropped > 0 {
debug!(
"art-scope: dropped {dropped} {} entries beyond {:.0} yd ({} live)",
slot.column(),
self.state.radius,
cache.len()
);
}
}
}
/// Resolve the radius once the streamer's configuration exists (it decides the floor).
fn configure_art_scope(
mut state: ResMut<ArtScopeState>,
cfg: Option<Res<crate::assets::RenderConfig>>,
) {
let tile_radius = cfg.map(|c| c.tile_radius).unwrap_or(2);
state.radius = radius_from_env(tile_radius);
if state.radius > 0.0 {
info!(
"art-scope: within-map art evicts beyond {:.0} yd of the view focus",
state.radius
);
} else {
warn!("art-scope: eviction OFF ($WOW_ART_RADIUS=0) — within-map residency is unbounded");
}
}
/// Publish this frame's focus and decide whether it is a sweep frame. Deliberately unordered against
/// every consumer — see the module docs.
fn track_art_scope(
mut state: ResMut<ArtScopeState>,
time: Res<Time<Real>>,
player: Res<crate::player::Player>,
camera: Query<&Transform, With<crate::player::WorldCamera>>,
roster: Option<Res<crate::char_select::Roster>>,
) {
let cam = camera.single().ok().map(|c| c.translation);
state.focus = (player.active || cam.is_some()).then(|| {
crate::terrain_stream::view_focus(
&player,
cam,
roster
.as_deref()
.and_then(crate::char_select::Roster::pending_entry),
)
});
let now = time.elapsed_secs();
state.now = now;
state.due = now - state.last_sweep >= SWEEP_SECS;
if state.due {
state.last_sweep = now;
}
}
/// Owns the sweep policy + the census. The caches themselves stay private to their modules; each
/// registers its own three-line system that hands its caches to [`ArtScope::apply`].
pub(crate) struct ArtScopePlugin;
impl Plugin for ArtScopePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ArtCensus>()
.init_resource::<ArtScopeState>()
.add_systems(
Startup,
configure_art_scope.after(crate::assets::AssetSet::Open),
)
.add_systems(Update, track_art_scope);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// When the art is used and stamped.
const NOW: f32 = 1000.0;
/// A sweep past the dwell floor — the earliest moment distance is *allowed* to expire anything.
/// Every "…is dropped" assertion sweeps here, which is itself the dwell floor's regression test:
/// swap this back to `NOW` and three of them fail (verified).
const LATER: f32 = NOW + MIN_DWELL_SECS + 1.0;
const HOME: [f32; 3] = [0.0, 0.0, 0.0];
const FAR: [f32; 3] = [4000.0, 0.0, 0.0];
const R: f32 = DEFAULT_RADIUS_YD;
/// A fresh entry survives its first sweep — that is the sweep that *stamps* it. Without this the
/// grace period would depend on where in the second the art happened to load.
#[test]
fn the_first_sweep_stamps_rather_than_judges() {
let mut c: SpatialCache<u32, u32> = SpatialCache::default();
c.insert(1, 10);
// Swept from the far side immediately: the entry has no stamp yet, so it cannot be too far.
assert_eq!(c.scope(FAR, R, NOW), 0);
assert_eq!(c.len(), 1);
// Now it is stamped at FAR, so a later sweep from HOME drops it.
assert_eq!(c.scope(HOME, R, LATER), 1);
assert!(c.fetch(&1).is_none());
}
#[test]
fn art_you_walked_away_from_is_dropped() {
let mut c: SpatialCache<u32, u32> = SpatialCache::default();
c.insert(1, 10);
c.scope(HOME, R, NOW); // stamp at home
assert_eq!(
c.scope([R * 0.9, 0.0, 0.0], R, LATER),
0,
"inside the radius"
);
assert_eq!(c.scope(FAR, R, LATER), 1, "beyond it");
}
/// A hit restarts the grace, which is what keeps art in the direction of travel resident: the
/// same tree material is re-fetched by every new placement that streams in ahead of you.
#[test]
fn a_use_restarts_the_grace() {
let mut c: SpatialCache<u32, u32> = SpatialCache::default();
c.insert(1, 10);
c.scope(HOME, R, NOW);
for step in 1..20 {
let at = [step as f32 * R * 0.5, 0.0, 0.0];
assert_eq!(c.fetch(&1), Some(10));
// Swept well past the dwell floor every step, so what keeps it alive is the use.
assert_eq!(
c.scope(at, R, NOW + step as f32 * (MIN_DWELL_SECS + 1.0)),
0,
"re-used at step {step}"
);
}
}
/// `or_insert_with` builds once and counts as a use, like `fetch`.
#[test]
fn or_insert_with_builds_once_and_counts_as_a_use() {
let mut c: SpatialCache<u32, Vec<u32>> = SpatialCache::default();
let mut builds = 0;
for _ in 0..3 {
let v = c.or_insert_with(7, || {
builds += 1;
vec![1, 2, 3]
});
assert_eq!(v.len(), 3);
}
assert_eq!(builds, 1);
assert_eq!(
c.scope(FAR, R, LATER),
0,
"the last use restarted the grace"
);
}
/// **The director's free-fly case.** A camera at boosted fly speed crosses the radius in ~5 s; if
/// distance alone decided, the art it just streamed would be dropped and then rebuilt as the same
/// camera came back. Nothing expires inside the dwell floor however far away the focus goes.
#[test]
fn a_fast_camera_cannot_outrun_the_dwell_floor() {
let mut c: SpatialCache<u32, u32> = SpatialCache::default();
c.insert(1, 10);
c.scope(HOME, R, NOW);
// 500 yd/s (FlyCam 100 × the Ctrl boost 5) for five seconds: far past the radius already.
for s in 1..=5 {
let at = [500.0 * s as f32, 0.0, 0.0];
assert_eq!(
c.scope(at, R, NOW + s as f32),
0,
"dropped {s} s out — the free-fly thrash"
);
}
// Fly all the way back inside the radius and the entry is still the same one: no rebuild.
assert_eq!(c.scope(HOME, R, NOW + 6.0), 0);
assert_eq!(c.fetch(&1), Some(10));
// Stay away past the floor and it does expire — the bound is delayed, not removed.
c.scope(HOME, R, NOW + 7.0);
assert_eq!(c.scope(FAR, R, NOW + 7.0 + MIN_DWELL_SECS), 1);
}
/// Height is not distance: flying over a city must not evict it.
#[test]
fn altitude_is_not_distance() {
let mut c: SpatialCache<u32, u32> = SpatialCache::default();
c.insert(1, 10);
c.scope(HOME, R, NOW);
assert_eq!(c.scope([0.0, 0.0, 10_000.0], R, LATER), 0);
}
/// Eviction must never outrun the streamer: the default radius clears the far corner of the
/// resident tile block at the default `$WOW_TILE_RADIUS`, and the floor rises with it.
#[test]
fn the_radius_clears_the_streamers_own_reach() {
assert!(
DEFAULT_RADIUS_YD > radius_floor(2),
"default {DEFAULT_RADIUS_YD} must clear the floor {}",
radius_floor(2)
);
// The corner of the 5×5 block at tile radius 2 is ~1886 yd; the floor adds a tile.
assert!(
(radius_floor(2) - 2418.7).abs() < 1.0,
"{}",
radius_floor(2)
);
assert!(
radius_floor(4) > DEFAULT_RADIUS_YD,
"a wider window raises it"
);
}
/// With `$WOW_ART_RADIUS` unset (every ordinary run, and the test binary) the radius is the
/// default, floored — never zero, so a shipped build always bounds itself.
#[test]
fn an_unconfigured_run_still_bounds_itself() {
assert_eq!(radius_from_env(2), DEFAULT_RADIUS_YD.max(radius_floor(2)));
assert!(radius_from_env(8) >= radius_floor(8));
}
/// **The mechanism the whole design rests on**: dropping the cache's handle really does free the
/// asset. If `Assets<T>` kept the value alive past its last strong handle, eviction would cost us
/// the dedup and recover no memory — a failure indistinguishable, from the outside, from "the fix
/// did nothing". Checked here rather than assumed, because the *reason* B131's `images` column
/// never fell is precisely that these caches held the last handle.
#[test]
fn dropping_the_last_handle_frees_the_asset() {
let mut app = App::new();
app.add_plugins((
bevy::app::TaskPoolPlugin::default(),
bevy::asset::AssetPlugin::default(),
))
.init_asset::<Image>();
let mut cache: SpatialCache<u32, Handle<Image>> = SpatialCache::default();
let id = {
let mut images = app.world_mut().resource_mut::<Assets<Image>>();
let handle = images.add(Image::default());
let id = handle.id();
cache.insert(1, handle);
id
};
app.update();
assert!(
app.world().resource::<Assets<Image>>().contains(id),
"held by the cache"
);
cache.scope(HOME, R, NOW); // stamp it here…
assert_eq!(cache.scope(FAR, R, LATER), 1, "…then walk away");
// The drop is processed by `Assets::track_assets` on a later frame, not at the drop itself.
for _ in 0..3 {
app.update();
}
assert!(
!app.world().resource::<Assets<Image>>().contains(id),
"the image outlived its last handle — eviction would free nothing"
);
}
/// A cache nobody sweeps (the `Local<MaterialCache>`s in the glue booth / portrait bake) expires
/// nothing: without a `scope` call the stamps stay `None` forever.
#[test]
fn an_unswept_cache_never_expires() {
let mut c: SpatialCache<u32, u32> = SpatialCache::default();
c.insert(1, 10);
assert_eq!(c.fetch(&1), Some(10));
assert_eq!(c.len(), 1);
}
}
+22 -9
View File
@@ -17,6 +17,7 @@ use bevy::prelude::*;
use bevy::render::render_resource::{Buffer, Face};
use bevy::render::renderer::RenderDevice;
use crate::art_scope::{ArtScope, ArtSlot, SpatialCache};
use crate::model_render::VANILLA_ALPHA_KEY_REF;
use crate::terrain::{WowModelExt, WowModelMaterial};
use benilla_formats::{open_chain, read_texture_mip_chain, read_texture_rgba, Chain, ModelBlend};
@@ -45,7 +46,7 @@ pub(crate) struct WorldAssets {
/// address mode lives on the Bevy `Image`'s sampler — so each mode needs its own upload
/// (decision 0763). Before that key existed, whichever model loaded the texture first decided
/// the mode for every later one.
pub(crate) textures: HashMap<(String, bool, bool), Handle<Image>>,
pub(crate) textures: SpatialCache<(String, bool, bool), Handle<Image>>,
/// Decoded UI sprite textures by resolved path (`None` = a miss, cached so a bad path never
/// re-walks the chain per frame). Separate from `textures`: sprites are sRGB/clamped, world
/// art is Unorm/repeat — the same BLP can legitimately live in both.
@@ -64,7 +65,7 @@ pub(crate) struct WorldAssets {
/// the GPU image.
masks: HashMap<String, Option<Handle<Image>>>,
/// Materials deduped by their identity (texture path + blend, or the untextured fallback).
pub(crate) model_materials: HashMap<MaterialKey, Handle<WowModelMaterial>>,
pub(crate) model_materials: SpatialCache<MaterialKey, Handle<WowModelMaterial>>,
/// The shared global-light storage buffer (`lighting::global_light`). Held here so `model_material`
/// can clone it into every deduped model material's `light_buf` without threading a `Buffer` through
/// the many call sites. One buffer for the whole scene, updated in place each frame.
@@ -157,8 +158,8 @@ impl WorldAssets {
images: &mut Assets<Image>,
) -> Option<Handle<Image>> {
let key = (normalize_path(path), wrap.0, wrap.1);
if let Some(handle) = self.textures.get(&key) {
return Some(handle.clone());
if let Some(handle) = self.textures.fetch(&key) {
return Some(handle);
}
let chain = read_texture_mip_chain(&mut self.chain.lock_recover(), &key.0).ok()?;
let handle = images.add(repeat_texture_authored(chain, wrap));
@@ -345,8 +346,8 @@ impl WorldAssets {
),
None => MaterialKey::Fallback(is_wmo),
};
if let Some(handle) = self.model_materials.get(&key) {
return handle.clone();
if let Some(handle) = self.model_materials.fetch(&key) {
return handle;
}
// Per-submesh blend (opaque trunk/wall vs alpha-cut leaves/windows). This builder serves
// ground clutter, which never authors the multiply modes — Mod/Mod2x fall to plain Blend
@@ -430,7 +431,7 @@ pub(crate) struct AssetPlugin;
impl Plugin for AssetPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, open_world_assets.in_set(AssetSet::Open));
app.add_systems(Update, evict_world_art);
app.add_systems(Update, (evict_world_art, scope_world_art));
}
}
@@ -453,6 +454,18 @@ fn evict_world_art(
}
}
/// Expire the world-art dedup by **distance** (decision 0793) — the within-map half of the eviction
/// above. `textures` is the one that matters for VRAM: a decoded BLP is pinned by the material that
/// samples it, and a material by this cache, so nothing here dropping is why `images` never fell on a
/// same-map traverse. The UI sprite caches stay unswept for the same reason they survive a map change
/// (game-global scope, and their negative entries exist to stop per-frame chain re-walks).
fn scope_world_art(mut scope: ArtScope, assets: Option<ResMut<WorldAssets>>) {
if let Some(mut a) = assets {
scope.apply(&mut a.model_materials, ArtSlot::ClutterMats);
scope.apply(&mut a.textures, ArtSlot::Textures);
}
}
/// Open the vanilla patch chain from `$WOW_DATA` (default `WoW/Data`) and insert the shared
/// [`WorldAssets`] (chain + dedup caches) + [`RenderConfig`]. If the client data can't be opened,
/// `WorldAssets` is simply absent and downstream startup falls back to an empty free-fly scene.
@@ -484,12 +497,12 @@ fn open_world_assets(mut commands: Commands, device: Res<RenderDevice>) {
match open_chain(&data) {
Ok(chain) => commands.insert_resource(WorldAssets {
chain: Arc::new(Mutex::new(chain)),
textures: HashMap::new(),
textures: SpatialCache::default(),
sprites: HashMap::new(),
tiled_sprites: HashMap::new(),
portraits: HashMap::new(),
masks: HashMap::new(),
model_materials: HashMap::new(),
model_materials: SpatialCache::default(),
shared_light: shared_light.0.clone(),
}),
Err(e) => error!("failed to open client data: {e:#}"),
+81 -7
View File
@@ -411,6 +411,8 @@ fn face_billboards(
&mut Transform,
&mut GlobalTransform,
&mut Visibility,
// Whether the exterior-scene cull owns this card's `Visibility` — see the mirror below.
Has<crate::exterior_cull::ExteriorScene>,
),
Without<WorldCamera>,
>,
@@ -422,7 +424,7 @@ fn face_billboards(
// for every billboard; never a per-pivot aim).
let (fwd, right, up) = (*cam_tf.forward(), *cam_tf.right(), *cam_tf.up());
let elapsed_ms = time.elapsed().as_millis() as u32;
for (entity, mut card, mut tf, mut global, mut visibility) in &mut cards {
for (entity, mut card, mut tf, mut global, mut visibility, gated) in &mut cards {
if let Some(owner) = card.follow {
match owners.get(owner) {
Ok((gt, vis)) => {
@@ -434,12 +436,24 @@ fn face_billboards(
// the mirror a deck lantern's glow keeps rendering at the other continent's
// coordinates. (The owner's inherited visibility is last propagate's — one
// frame of lag on a minutes-long hide.)
let want = match vis {
Some(v) if !v.get() => Visibility::Hidden,
_ => Visibility::Inherited,
};
if *visibility != want {
*visibility = want;
//
// **Not on a card the exterior-scene cull owns** (decision 0784). One component,
// one authority (0025): a world-placement card is tagged
// [`crate::exterior_cull::ExteriorScene`] with the rest of its model, and both
// systems run in the same unordered post-propagation window — so this write
// silently undid the cull's `Hidden` and the card drew straight through a sealed
// room's wall. Nothing is lost by standing down: such a card's owner is a joint of
// its own placement rig, which is never hidden apart from the model the cull is
// already hiding whole. The transport case above is an entity-lane card, untagged,
// and still mirrors.
if !gated {
let want = match vis {
Some(v) if !v.get() => Visibility::Hidden,
_ => Visibility::Inherited,
};
if *visibility != want {
*visibility = want;
}
}
}
Err(_) => {
@@ -571,6 +585,66 @@ mod tests {
);
}
/// A card the **exterior-scene cull** owns must not have its `Visibility` written here
/// (decision 0784). One component, one authority (0025): a world-placement card is tagged
/// with the rest of its model, both systems run in the same unordered post-propagation
/// window, and this mirror silently undid the cull's `Hidden` — a lamp glow drawing through
/// a sealed room's wall while every other submesh of the same lamp was correctly gone.
///
/// The card must still be *placed* (its transform is this system's job either way), and an
/// untagged card must still mirror — that half is the test above.
#[test]
fn the_exterior_cull_owns_a_tagged_cards_visibility() {
let mut app = App::new();
app.init_resource::<Time>();
app.add_systems(Update, face_billboards);
app.world_mut().spawn((
crate::player::WorldCamera,
GlobalTransform::from_translation(Vec3::new(0.0, 0.0, 10.0)),
));
// A VISIBLE owner — the mirror's "show it" arm, which is the one that did the damage.
let owner = app
.world_mut()
.spawn((
GlobalTransform::from_translation(Vec3::new(5.0, 0.0, 0.0)),
InheritedVisibility::VISIBLE,
))
.id();
let info = BillboardInfo {
bone: 0,
pivot: Vec3::new(0.0, 1.7, 0.0),
normal: Vec3::Z,
kind: BillboardKind::Spherical,
scale_anim: None,
seq_translations: vec![],
};
let card = app
.world_mut()
.spawn((
BillboardCard::following(&info, owner),
Transform::IDENTITY,
// …and the cull has already hidden it: no window admits this model.
Visibility::Hidden,
crate::exterior_cull::ExteriorScene,
))
.id();
app.update();
assert_eq!(
*app.world().entity(card).get::<Visibility>().unwrap(),
Visibility::Hidden,
"the cull's verdict must survive the owner mirror"
);
assert_eq!(
app.world()
.entity(card)
.get::<Transform>()
.unwrap()
.translation,
Vec3::new(5.0, 1.7, 0.0),
"standing down from the visibility write must not stop the card being PLACED"
);
}
/// The palette pass: a lock-Z billboard JOINT gets its propagated world rotation replaced by
/// the camera basis (translation/scale kept — the pivot stays put, the grow-in scale
/// survives), and its CHILD joint is re-composed from the replaced parent — so geometry
+97 -6
View File
@@ -343,14 +343,105 @@ fn update_shadows(
verts.0.clear();
n_no_ground += 1;
}
if census && is_self {
// The census's self row: where the OWN shadow's projection actually went — the
// "shadow missing under me" report's second question (the first is the gate line
// below). Y-extents vs feet split "landed on the surface I stand on" from "fell
// through to a receiver below" in one read.
let (mut y_min, mut y_max) = (f32::MAX, f32::MIN);
let (mut x_min, mut x_max) = (f32::MAX, f32::MIN);
let (mut z_min, mut z_max) = (f32::MAX, f32::MIN);
for v in &verts.0 {
y_min = y_min.min(v.pos[1]);
y_max = y_max.max(v.pos[1]);
x_min = x_min.min(v.pos[0]);
x_max = x_max.max(v.pos[0]);
z_min = z_min.min(v.pos[2]);
z_max = z_max.max(v.pos[2]);
}
// World-XZ extents of what was actually emitted vs the frame rect: if these disagree,
// the projector inflated/displaced the box (the -8844,669 hunt).
debug!(
"self shadow world span: x [{:.3}, {:.3}] ({:.3}), z [{:.3}, {:.3}] ({:.3}); \
feet ({:.3}, {:.3}), yaw {:.1} deg",
x_min,
x_max,
x_max - x_min,
z_min,
z_max,
z_max - z_min,
unit.translation.x,
unit.translation.z,
unit.rotation
.to_euler(bevy::math::EulerRot::YXZ)
.0
.to_degrees(),
);
debug!(
"self shadow: feet y {:.3}, box y [{:.3}, {:.3}], {} verts, vert y [{:.3}, \
{:.3}]",
unit.translation.y,
unit.translation.y + frame.min_y,
unit.translation.y + frame.max_y,
verts.0.len(),
y_min,
y_max
);
// The -8844,669 hunt's uv probe: a span check alone can't see a DEGENERATE mapping
// (all uv.y at the rim still spans 0..1) — print the actual per-vert uv pairs.
let uvs: Vec<String> = verts
.0
.iter()
.map(|v| format!("({:.3},{:.3})", v.uv[0], v.uv[1]))
.collect();
debug!("self shadow uvs: {}", uvs.join(" "));
// And the box/rect numbers: the oracle says HumanFemale's footprint is 0.77x0.74 yd
// nearly centred; a bigger or offset rect indicts the box math, not the projector.
debug!(
"self shadow box: bmin {:?} bmax {:?} rect x [{:.3}, {:.3}] z [{:.3}, {:.3}] \
(extent {:.3}x{:.3}, centre offset ({:.3}, {:.3}))",
bmin,
bmax,
min_x,
max_x,
min_z,
max_z,
max_x - min_x,
max_z - min_z,
(min_x + max_x) * 0.5,
(min_z + max_z) * 0.5,
);
}
}
if census && n_total > 0 {
let tex = shadow_assets.map_or("no-resource", |a| {
if images.contains(&a.texture) {
"loaded"
} else {
"MISSING"
}
// Not just "loaded": the CONTENT. A white-decoded blob multiplies to a no-op — an
// invisible shadow whose every draw-side reading looks healthy (the -8844,669 hunt).
let tex = shadow_assets.map_or("no-resource".into(), |a| {
images.get(&a.texture).map_or("MISSING".into(), |img| {
let (w, h) = (img.width(), img.height());
let center = img
.data
.as_ref()
.and_then(|d| {
let i = ((h / 2) * w + w / 2) as usize * 4;
d.get(i..i + 4).map(|p| format!("{p:?}"))
})
.unwrap_or_else(|| "no-data".into());
// The -8844,669 hunt: the whole centre ROW, not one texel — the ink radius and
// alpha reach decide how much of the box the disc visibly fills.
if let Some(d) = img.data.as_ref() {
let row: Vec<String> = (0..w as usize)
.map(|x| {
let i = ((h / 2) as usize * w as usize + x) * 4;
d.get(i..i + 4)
.map(|p| format!("{}/{}", p[0], p[3]))
.unwrap_or_default()
})
.collect();
debug!("blob row {}: {}", h / 2, row.join(" "));
}
format!("loaded {w}x{h} center {center} id {:?}", a.texture.id())
})
});
debug!(
"blob shadows: {n_total} ({n_shown} shown, {n_no_owner} ownerless, {n_no_clip} \
+49
View File
@@ -0,0 +1,49 @@
//! **Which build is this?** — the commit the running binary was built from, stamped in at compile
//! time by `build.rs` (which owns the *how*, including why the stamp can't go stale).
//!
//! A report from someone else's machine — "the water reads wrong here", "it panicked on login" — is
//! only actionable against known code, and nothing else in the binary identifies it: the crate
//! version is a permanent `0.1.0`, and people build from a clone of the public snapshot repo whose
//! HEAD moves with every sync. So the sha is the version.
//!
//! Two surfaces, deliberately:
//!
//! - **The startup log line** ([`banner`], run from [`crate::preflight`]'s banner family) — this is
//! the one that matters for someone else's run. They launch from a terminal, so the id is already
//! in the output they paste; nobody has to know a hotkey to answer "what are you on?".
//! - **The debug panel's footer** (`` ` ``) — the same line where the reader already is when they
//! are looking at a readout, click-to-copy for the full sha.
//!
//! Mapping a reported sha back: a **public** snapshot sha is tagged `pub/<short-sha>` on the private
//! commit it shipped, so `git show pub/<sha>` (or `git rev-parse pub/<sha>^{commit}`) names the code
//! exactly; a **private** sha is just a commit here.
use bevy::prelude::*;
/// The full 40-char sha of the commit this binary was built from. Empty when the build had no git
/// checkout to read (a downloaded source zip, or no `git` on `PATH`).
pub(crate) const SHA: &str = env!("BENILLA_GIT_SHA");
/// Git's own abbreviation of [`SHA`], from the same repo `pubsync` abbreviates in — so a public
/// build's string is literally the suffix of its `pub/<sha>` tag.
pub(crate) const SHORT: &str = env!("BENILLA_GIT_SHORT");
/// The commit date of [`SHA`] (`YYYY-MM-DD`) — a property of the sha, so it can't disagree with it.
pub(crate) const DATE: &str = env!("BENILLA_GIT_DATE");
/// The cargo profile directory this was built in: `debug`, `release`, or `ship` (0736). Half of
/// every "it runs badly" report is a debug build.
pub(crate) const PROFILE: &str = env!("BENILLA_PROFILE");
/// The one-line build id: `f5fd009 · 2026-07-30 · release`.
pub(crate) fn summary() -> String {
if SHORT.is_empty() {
format!("unknown (built without a git checkout) · {PROFILE}")
} else {
format!("{SHORT} · {DATE} · {PROFILE}")
}
}
/// Log the build id once at startup — the line that answers "what version are you on?" from a
/// pasted terminal log. Not env-gated, for [`crate::preflight`]'s reason: an id nobody knows to
/// switch on is not an id.
pub(crate) fn banner() {
info!("benilla build {}", summary());
}
+2 -1
View File
@@ -68,6 +68,7 @@ use bevy::render::renderer::{RenderContext, RenderDevice};
use bevy::render::view::{ExtractedView, ViewDepthTexture};
use bevy::render::{Render, RenderApp, RenderSystems};
use super::probes::ProbeClock;
use crate::particles::ParticleEmitter;
use crate::player::WorldCamera;
@@ -325,7 +326,7 @@ struct DepthStaging {
/// what the renderer is actually doing, not what the environment asked for.
fn arm(
mut watch: ResMut<DepthWatch>,
time: Res<Time>,
time: ProbeClock,
mut cam: Query<(Entity, &mut Camera3d, &Msaa), With<WorldCamera>>,
mut commands: Commands,
) {
+2 -1
View File
@@ -9,6 +9,7 @@ use bevy::render::view::screenshot::{save_to_disk, Screenshot};
use benilla_protocol::guid;
use super::probes::ProbeClock;
use crate::names::NameCache;
use crate::net::{Guid, NetCommands, NetEntity, ObjectStore, SelfPlayer};
use crate::player::WorldCamera;
@@ -128,7 +129,7 @@ fn burst_path(out: &str, index: u32, count: u32) -> String {
#[allow(clippy::too_many_arguments, clippy::type_complexity)] // one Bevy system's full input set
fn fire_live_shot(
mut shot: ResMut<LiveShot>,
time: Res<Time>,
time: ProbeClock,
mut commands: Commands,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
subjects: Query<(&Guid, &Transform), (With<NetEntity>, Without<SelfPlayer>)>,
+6 -1
View File
@@ -84,7 +84,7 @@ pub(crate) use probe_rig::{rig_char_name_from_env, ProbeRigPlugin};
pub(crate) use probe_taxi::ProbeTaxiPlugin;
pub(crate) use probes::{
EntityCensusPlugin, LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin, ProbeChatPlugin,
ProbeExitPlugin, ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin,
ProbeClock, ProbeExitPlugin, ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin,
};
use scenarios::{Scenario, SubjectKind, UiFixture, GROUND_EYE, SCENARIOS};
@@ -644,6 +644,11 @@ fn drive_capture(
fx_req: Option<Res<FxViewRequest>>,
wfx_req: Option<Res<crate::water_fx::WaterFxView>>,
mut fx_state: Option<ResMut<FxViewState>>,
// **The harness's one deliberate virtual clock** — the allowlisted exception in
// `probes::probe_schedules_read_the_wall_clock`. The fixture age below is an age *on the clock
// the effect animates on* — the same clock this system freezes at save time, one field down —
// not a schedule. Everything schedule-shaped in the harness reads [`probes::ProbeClock`]
// instead; decision 0789 says why, and what learning it cost.
game_time: Res<Time>,
mut clock: ResMut<Time<Virtual>>,
) {
+3 -2
View File
@@ -32,6 +32,7 @@ use bevy::render::sync_world::MainEntity;
use bevy::render::texture::GpuImage;
use bevy::render::{Render, RenderApp, RenderSystems};
use super::probes::ProbeClock;
use crate::interact::WorldObject;
use crate::terrain::WowModelMaterial;
@@ -140,7 +141,7 @@ type WatchedBatch = (
/// watch list itself a moving part of the measurement.
fn collect_batches(
mut watch: ResMut<PhaseWatch>,
time: Res<Time>,
time: ProbeClock,
// `WorldObject` rides on **each submesh entity**, not on a parent that holds them as children —
// the same shape `WOW_PICK` relies on, where a single ray hit carries both its object identity
// and its batch. So the watch list is a filter over those entities, not a child walk.
@@ -206,7 +207,7 @@ fn collect_batches(
/// the subject instead of to a wall-clock guess.
fn collect_emitters(
mut watch: ResMut<PhaseWatch>,
time: Res<Time>,
time: ProbeClock,
emitters: Query<(Entity, &crate::particles::ParticleEmitter)>,
trails: Query<(Entity, &crate::ribbons::RibbonTrail, &GlobalTransform)>,
parts: Query<(Entity, &crate::debug_panel::ModelPart, &GlobalTransform)>,
+2 -1
View File
@@ -42,6 +42,7 @@ use bevy::platform::collections::HashSet;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use super::probes::ProbeClock;
use crate::interact::WorldObject;
use crate::player::WorldCamera;
use crate::terrain::WowModelMaterial;
@@ -168,7 +169,7 @@ struct HitNames<'w, 's> {
fn fire_pick(
mut probe: ResMut<PickProbe>,
time: Res<Time>,
time: ProbeClock,
window: Query<&Window, With<PrimaryWindow>>,
camera: Query<(&Camera, &GlobalTransform), With<WorldCamera>>,
objects: Query<Entity, With<WorldObject>>,
+2 -1
View File
@@ -46,6 +46,7 @@ use benilla_protocol::messages::{BAG_PLAYER_INVENTORY, SLOT_PACK_FIRST};
use benilla_protocol::EntityKind;
use benilla_ui::script::UiScript;
use super::probes::ProbeClock;
use crate::net::{ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectStore, SelfPlayer};
use crate::player::Player;
use crate::ui_bank::{BankOpen, BankPrices};
@@ -203,7 +204,7 @@ fn find_bank_slot(store: &ObjectStore, guid: u64) -> Option<u8> {
#[allow(clippy::too_many_arguments)]
fn bank_probe(
time: Res<Time>,
time: ProbeClock,
mut probe: ResMut<BankProbe>,
open: Res<BankOpen>,
prices: Option<Res<BankPrices>>,
@@ -19,6 +19,7 @@
use bevy::prelude::*;
use super::probes::ProbeClock;
use crate::net::SelfPlayer;
pub(crate) struct ProbeCastCancelPlugin;
@@ -49,7 +50,7 @@ const BAR_PHASE_CHUNK: &str = "return (function()\n\
/// ~5 flash + ~20 fade ticks normalized to `CASTING_BAR_REF_TICK` 30 Hz ≈ 0.83 s). The deltas
/// between the logged `hold` → `burst` → `fade` → `hidden` lines ARE the verdict; no eyeballing.
fn bar_timeline(
time: Res<Time>,
time: ProbeClock,
script: Option<NonSend<benilla_ui::script::UiScript>>,
self_player: Query<(), With<SelfPlayer>>,
mut last: Local<String>,
@@ -100,7 +101,7 @@ const USE_HEARTH_CHUNK: &str = "return (function()\n\
/// into the 10 s cast** press `W`; **+0.3 s** release it. Every phase logs, so a stalled run
/// shows where it stopped.
fn cast_cancel_probe(
time: Res<Time>,
time: ProbeClock,
mut probe: ResMut<ProbeCastCancel>,
self_player: Query<(), With<SelfPlayer>>,
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
+2 -1
View File
@@ -10,6 +10,7 @@
use bevy::prelude::*;
use super::probes::ProbeClock;
use crate::net::{ClientCommand, Guid, SelfPlayer};
use crate::player::Player;
use crate::transport::{Transport, TransportAnchor};
@@ -61,7 +62,7 @@ const DROP_HEIGHT: f32 = 10.0;
const BOARD_DEADLINE: f64 = 15.0;
fn crossing_probe(
time: Res<Time>,
time: ProbeClock,
mut probe: ResMut<CrossingProbe>,
self_player: Query<(), With<SelfPlayer>>,
player: Res<Player>,
+2 -1
View File
@@ -51,6 +51,7 @@ use benilla_protocol::messages::MailListEntry;
use benilla_protocol::EntityKind;
use benilla_ui::script::UiScript;
use super::probes::ProbeClock;
use crate::net::{ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectStore, SelfPlayer};
use crate::player::Player;
use crate::ui_mail::MailOpen;
@@ -223,7 +224,7 @@ fn last_event(script: &UiScript) -> String {
#[allow(clippy::too_many_arguments)]
fn mail_probe(
time: Res<Time>,
time: ProbeClock,
mut probe: ResMut<MailProbe>,
mut mail: ResMut<MailOpen>,
script: Option<NonSendMut<UiScript>>,
+2 -1
View File
@@ -11,6 +11,7 @@
use bevy::prelude::*;
use super::probes::ProbeClock;
use crate::net::SelfPlayer;
use crate::target::AttackNearestRequest;
@@ -26,7 +27,7 @@ impl Plugin for ProbeMeleePlugin {
/// already auto-attacking; covers the auto-acquired-attacker case the acquire core skips); with
/// none, run the acquire core. Re-acquires the next victim after a kill clears the selection.
fn melee_probe(
time: Res<Time>,
time: ProbeClock,
mut last: Local<f64>,
self_player: Query<(), With<SelfPlayer>>,
selection: Res<crate::target::Selection>,
+2 -1
View File
@@ -67,6 +67,7 @@
use benilla_protocol::{messages, CharAction, CharCreateReq};
use bevy::prelude::*;
use super::probes::ProbeClock;
use crate::char_select::{send_pick, Roster};
use crate::net::{
CharActionResultMessage, CharListMessage, CharPick, CharRequest, EnteredWorldMessage,
@@ -267,7 +268,7 @@ fn drive_rig(
mut rig: ResMut<Rig>,
mut roster: ResMut<Roster>,
pick: Res<CharPick>,
time: Res<Time>,
time: ProbeClock,
mut lists: MessageReader<CharListMessage>,
mut results: MessageReader<CharActionResultMessage>,
mut entered: MessageReader<EnteredWorldMessage>,
+2 -1
View File
@@ -17,6 +17,7 @@ use benilla_assets::coords::bevy_to_wow;
use benilla_formats::{load_taxi_nodes, load_taxi_path_nodes};
use bevy::prelude::*;
use super::probes::ProbeClock;
use crate::assets::{LockRecover, WorldAssets};
use crate::net::{ClientCommand, Guid, NetEntity, ObjectStore, SelfPlayer};
use crate::player::Player;
@@ -139,7 +140,7 @@ fn load_expectations(mut probe: ResMut<TaxiProbe>, world_assets: Option<Res<Worl
// One Bevy system's full input set (the crossing-probe shape) + its self query tuple.
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
fn taxi_probe(
time: Res<Time>,
time: ProbeClock,
mut probe: ResMut<TaxiProbe>,
self_player: Query<
(
+307 -17
View File
@@ -6,10 +6,31 @@
//! sibling [`super::live_shot`]. Each is env-gated and registered by `main`; compose them for
//! unattended "park, act, observe" probes.
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use super::PROBE_WARMUP_FRAMES;
/// The clock **every probe schedule reads** — real time, never the virtual clock (decision 0789).
///
/// A probe knob is a wall-clock instruction: "send this at 20 s", "sample 300 frames from 25 s",
/// "resize at 12 s", "exit at 480 s". `Time<Virtual>` cannot honour one — it clamps every frame delta
/// to `max_delta` (250 ms, `bevy_time`'s default), so on any leg that hitches (a streaming burst, an
/// occluded window, a loaded machine) it falls behind real time and drags the whole schedule with it.
/// The knob then means something the operator never asked for, *silently*.
///
/// **Third time this clock has cost us a measurement.** 0615 moved the relayed-move replay off it
/// (and cites the UI script clock as the same lesson before that); then B131's first causal leg was
/// destroyed by it here — the probe-chat hops drifted 40 s → 75 s apart, so windows labelled
/// "parked, ticks off" in fact contained a teleport and live ticks, and an eight-minute leg had to be
/// thrown away (decision 0785's discarded run). A named alias is what makes the next probe get it
/// right without knowing the story: type this, and the mistake is unavailable.
///
/// The one deliberate virtual clock in the harness is the fixture **age** in
/// [`super::drive_capture`] — an age on the clock the effect animates on (and which the capture
/// freezes at save time), which is not a schedule at all.
pub(crate) type ProbeClock<'w> = Res<'w, Time<bevy::time::Real>>;
/// The PROBE CHAT one-shot (`WOW_PROBE_CHAT="<line>[;<line>…]"`, delay via `WOW_PROBE_CHAT_AT`
/// seconds, default 8): send each `;`-separated line as Say once we are in-world — the "park the
/// probe character anywhere" instrument. The probe account (gmlevel 6) makes `.go xyz …`, `.gm on`,
@@ -124,6 +145,12 @@ fn probe_key_by_name(name: &str) -> Option<KeyCode> {
"X" => KeyCode::KeyX,
"Z" => KeyCode::KeyZ,
"Tab" => KeyCode::Tab,
// The detached free-fly toggle (`player.rs`) and its Ctrl speed boost (`camera.rs`, ×5).
// Added because the harness could not reach the leg that broke decision 0793: the director's
// first real run was a boosted free-fly, whose camera crosses the art radius in ~5 s, and
// reproducing it needed a held `Ctrl` + `W` behind an `F`.
"F" => KeyCode::KeyF,
"Ctrl" => KeyCode::ControlLeft,
_ => return None,
})
}
@@ -146,7 +173,7 @@ struct ProbeKeyTap {
/// Press each due tap (in-world gated, like the chat probe) and release it after its hold window.
fn fire_probe_key(
mut probe: ResMut<ProbeKeys>,
time: Res<Time>,
time: ProbeClock,
self_player: Query<(), With<crate::net::SelfPlayer>>,
mut keys: ResMut<ButtonInput<KeyCode>>,
) {
@@ -230,7 +257,7 @@ struct ProbeExit {
fn fire_probe_exit(
mut probe: ResMut<ProbeExit>,
time: Res<Time>,
time: ProbeClock,
mut exit: MessageWriter<AppExit>,
) {
let Some(at) = probe.at else { return };
@@ -287,7 +314,7 @@ struct ProbeResize {
fn fire_probe_resize(
mut probe: ResMut<ProbeResize>,
time: Res<Time>,
time: ProbeClock,
mut windows: Query<&mut Window, With<bevy::window::PrimaryWindow>>,
) {
if probe.fired || time.elapsed_secs() < probe.at {
@@ -306,7 +333,7 @@ fn fire_probe_resize(
/// Run the probe chunk once the delay has elapsed AND the session is in-world.
fn fire_probe_lua(
mut probe: ResMut<ProbeLua>,
time: Res<Time>,
time: ProbeClock,
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
self_player: Query<(), With<crate::net::SelfPlayer>>,
) {
@@ -351,7 +378,7 @@ fn fire_probe_lua(
/// every client-side command silently went out as public chat and did nothing (decision 0637).
fn fire_probe_chat(
mut probe: ResMut<ProbeChat>,
time: Res<Time>,
time: ProbeClock,
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
self_player: Query<(), With<crate::net::SelfPlayer>>,
) {
@@ -567,6 +594,27 @@ impl ResidencyMeter<'_> {
}
}
/// Where — and in what scene state — the sample was taken. Bundled because [`drive_live_fps`] sits
/// at Bevy's 16-param ceiling, and because these four are read together or not at all.
///
/// The `room`/`windows` half is the exterior-scene gate's two terms (decision 0774), the same pair
/// the debug panel's World section shows. Without them a `drawn=` reading taken indoors cannot be
/// read at all: a big number means either "we claimed a room and the cull let everything through" or
/// "we never claimed a room, so nothing was gated" — opposite bugs with identical numbers, and the
/// difference cost a measurement (0780).
#[derive(SystemParam)]
struct SamplePin<'w> {
/// The map the sample landed on (0705's prove-the-run law): a probe number is evidence only once
/// the body is known to be at the pin, and `WOW_PROBE_CHAT`'s `.go` can silently fail (a bad map
/// id, a refused command) leaving the run measuring the login spot.
map: Option<Res<'w, crate::world_map::CurrentMap>>,
body: Option<Res<'w, crate::player::Player>>,
room: Option<Res<'w, crate::wmo_portal::CameraInteriorClaim>>,
windows: Option<Res<'w, crate::wmo_portal::ExteriorWindows>>,
/// What the cull DID, not just what it was told — see [`crate::exterior_cull::ExteriorCullVerdict`].
verdict: Option<Res<'w, crate::exterior_cull::ExteriorCullVerdict>>,
}
/// Wait for in-world + the delay, uncap, warm, sample, print, exit — the live twin of the
/// harness probe's `Phase::ProbeWarmup`/`Probing` arms.
#[allow(clippy::too_many_arguments)]
@@ -576,17 +624,16 @@ fn drive_live_fps(
self_player: Query<(), With<crate::net::SelfPlayer>>,
mut windows: Query<&mut Window, With<bevy::window::PrimaryWindow>>,
particles: Query<&crate::particles::ParticleEmitter>,
parts: Query<&ViewVisibility, With<crate::debug_panel::ModelPart>>,
// Every spawned model submesh, with the two facts that make a visible one accountable: which
// subsystem it belongs to, and whether it is gated by the exterior-scene cull. See
// [`VisCensus`] for why "visible" alone was not enough to diagnose anything.
parts: Query<CensusData>,
streamed: Query<(), With<crate::net::NetEntity>>,
// The animation-LOD gate's effect, machine-readable per probe (decision 0448): how many
// streamed rigs sat parked at sample end.
parked: Query<(), With<crate::creature_anim::AnimParked>>,
entities: Query<()>,
// Where the sample was actually taken (0705's prove-the-run law): a probe number is evidence
// only once the body is known to be at the pin, and `WOW_PROBE_CHAT`'s `.go` can silently
// fail (a bad map id, a refused command) leaving the run measuring the login spot.
map: Option<Res<crate::world_map::CurrentMap>>,
body: Option<Res<crate::player::Player>>,
pin: SamplePin,
// The owned skin-palette occupancy (decision 0720) — `rigs=live/peak bones=live/peak` on the
// probe line proves the palette lane is actually populated (an all-zero table renders
// origin-collapsed rigs, which no other probe number would catch).
@@ -665,8 +712,13 @@ fn drive_live_fps(
.fold((0usize, 0usize, 0usize), |(e, a, l), p| {
(e + 1, a + usize::from(p.live() > 0), l + p.live())
});
let (submeshes, drawn) = parts.iter().fold((0usize, 0usize), |(n, d), v| {
(n + 1, d + usize::from(v.get()))
let mut census = VisCensus {
own_instance: pin.room.as_ref().and_then(|r| r.0).map(|c| c.room.instance),
..Default::default()
};
let (submeshes, drawn) = parts.iter().fold((0usize, 0usize), |(n, d), row| {
census.add(row);
(n + 1, d + usize::from(row.0.get()))
});
let px = windows
.single()
@@ -691,14 +743,42 @@ fn drive_live_fps(
_ => String::new(),
};
// The pin the number belongs to, in the `.go xyz` order, so a probe line can be
// matched against the report's coordinates without a second instrument.
let at_pin = match (map.as_ref(), body.as_ref().filter(|b| b.active)) {
// matched against the report's coordinates without a second instrument — followed by
// the exterior-scene gate's state, which is what makes `drawn=` legible indoors.
let at_pin = match (pin.map.as_ref(), pin.body.as_ref().filter(|b| b.active)) {
(Some(m), Some(b)) => {
let [x, y, z] = benilla_assets::coords::bevy_to_wow(b.pos);
format!(" map={} pos={x:.1},{y:.1},{z:.1}", m.0)
}
_ => String::new(),
};
let gate = {
let room = match pin.room.as_ref().and_then(|r| r.0) {
Some(claim) => format!("g{:02}", claim.room.group),
None => "none".to_string(),
};
match pin.windows.as_deref() {
Some(crate::wmo_portal::ExteriorWindows::Windows(rects)) => {
format!(" room={room} windows={}", rects.len())
}
Some(crate::wmo_portal::ExteriorWindows::Unrestricted) => {
format!(" room={room} windows=unrestricted")
}
None => String::new(),
}
};
let culled = match pin.verdict.as_deref() {
Some(v) => format!(
" cull_windows={} cull_frusta={} cull_tested={} cull_hidden={} cull_unbounded={}",
v.windows
.map_or("unrestricted".to_string(), |n| n.to_string()),
v.frusta,
v.tested,
v.hidden,
v.unbounded
),
None => String::new(),
};
let rigs = palettes
.map(|p| {
let (s, b, ps, pb) = p.occupancy();
@@ -717,7 +797,7 @@ fn drive_live_fps(
residency.tint_reg.0.len(),
);
println!(
"FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={emitters} active={active} particles={live} submeshes={submeshes} drawn={drawn} streamed={} parked={} entities={}{rigs}{residency_line} px={}x{}{cpu}{present} occluded_frames={}{at_pin}",
"FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={emitters} active={active} particles={live} submeshes={submeshes} drawn={drawn} streamed={} parked={} entities={}{rigs}{residency_line} px={}x{}{cpu}{present} occluded_frames={}{at_pin}{gate}{culled}",
v.len(),
at(0.50),
at(0.95),
@@ -731,6 +811,7 @@ fn drive_live_fps(
px.1,
probe.occluded_frames,
);
census.print();
// The window's Modified-event totals per asset type — a type at ~1×/frame here is a
// per-frame re-upload ratchet (see [`ChurnCensus`]); absent means quiet.
if !residency.churn.0.is_empty() {
@@ -755,6 +836,131 @@ fn drive_live_fps(
}
}
/// **What is on the screen, and who is accountable for it** — the `VIS_CENSUS` line beside
/// `FPS_PROBE`, plus a per-model breakdown under `WOW_VIS_DUMP=1`.
///
/// `drawn=` alone cannot answer "why can I still see that from in here?". It is one number over
/// every model submesh, and the interesting split is not visible-vs-not: it is which *subsystem* the
/// visible thing belongs to and whether anything is gating it at all. A tree that draws through a
/// wall is a completely different defect depending on whether it carries
/// [`crate::exterior_cull::ExteriorScene`] (tagged but admitted — the cull or the bound is wrong) or
/// does not (nothing is gating it — the wrong lane spawned it). Naming which took a screenshot, an
/// asset dig and a wrong guess; this line answers it in one run.
///
/// `WOW_VIS_DUMP=1` then names the models: one `VIS_DUMP` line per distinct visible label, ungated
/// first, most-drawn first — which is the "so WHICH trees are they?" question.
#[derive(Default)]
struct VisCensus {
/// Per [`ModelKind`] index: `(visible, visible-and-gated)`.
kinds: [(usize, usize); 4],
/// `label -> (visible count, gated)`, for the dump.
labels: std::collections::HashMap<(String, bool), usize>,
/// Of every `ExteriorScene`-tagged submesh: how many the cull actually wrote `Hidden` on, and
/// how many carry **no `Aabb`** — the cull's fail-open arm, which admits them unconditionally.
/// A tagged-but-drawn object is one of those two, and they are opposite bugs.
gated_total: usize,
gated_hidden: usize,
gated_no_aabb: usize,
/// Tagged, bounded, NOT exempt, and yet NOT `Hidden` — the escapees, by `(label, is a
/// billboard card)`. **Exempt** means the piece belongs to the placement the camera is standing
/// in, which is not exterior scene to itself (decision 0784) and is *supposed* to draw; without
/// that subtraction this list is all room-you-are-in furniture and says nothing.
escaped: std::collections::HashMap<(String, bool), usize>,
/// The placement the camera is inside, for that subtraction.
own_instance: Option<Entity>,
/// How many tagged pieces were exempt — the number that explains the `tagged` vs `hidden` gap.
exempt: usize,
}
/// What [`VisCensus`] reads off every model submesh — the query shape and its fetched row.
type CensusData = (
&'static ViewVisibility,
&'static crate::debug_panel::ModelPart,
Has<crate::exterior_cull::ExteriorScene>,
Option<&'static crate::interact::WorldObject>,
&'static Visibility,
Option<&'static bevy::camera::primitives::Aabb>,
Has<crate::billboard::BillboardCard>,
Option<&'static crate::wmo_portal::WmoGroupVis>,
);
type CensusRow<'a> = (
&'a ViewVisibility,
&'a crate::debug_panel::ModelPart,
bool,
Option<&'a crate::interact::WorldObject>,
&'a Visibility,
Option<&'a bevy::camera::primitives::Aabb>,
bool,
Option<&'a crate::wmo_portal::WmoGroupVis>,
);
impl VisCensus {
fn add(&mut self, (vis, part, gated, object, want, aabb, card, group): CensusRow) {
if gated {
let exempt = group.is_some_and(|g| Some(g.instance) == self.own_instance);
self.gated_total += 1;
self.gated_hidden += usize::from(*want == Visibility::Hidden);
self.gated_no_aabb += usize::from(aabb.is_none());
self.exempt += usize::from(exempt);
if *want != Visibility::Hidden && aabb.is_some() && !exempt {
let label = object.map_or("<unlabelled>", |o| o.label.as_str());
*self.escaped.entry((label.to_string(), card)).or_default() += 1;
}
}
if !vis.get() {
return;
}
let slot = &mut self.kinds[kind_index(part.kind)];
slot.0 += 1;
slot.1 += usize::from(gated);
if let Some(o) = object {
*self.labels.entry((o.label.clone(), gated)).or_default() += 1;
}
}
fn print(&self) {
let line = ["doodad", "wmo", "creature", "gameobject"]
.iter()
.zip(&self.kinds)
.map(|(name, (vis, gated))| format!("{name}={vis}/gated{gated}"))
.collect::<Vec<_>>()
.join(" ");
println!(
"VIS_CENSUS visible-submeshes {line} | tagged={} hidden={} exempt={} no_aabb={}",
self.gated_total, self.gated_hidden, self.exempt, self.gated_no_aabb
);
// The escapees always print: a tagged, bounded object the cull left un-hidden is a defect
// by construction, and burying it behind a flag is how it stays unnoticed.
let mut escapees: Vec<_> = self.escaped.iter().collect();
escapees.sort_by(|a, b| b.1.cmp(a.1));
for ((label, card), n) in escapees {
let c = if *card { " BILLBOARD-CARD" } else { "" };
println!("VIS_ESCAPED {n}{c} {label}");
}
if std::env::var_os("WOW_VIS_DUMP").is_none() {
return;
}
let mut rows: Vec<_> = self.labels.iter().collect();
// Ungated first (the leak candidates), then most-drawn first.
rows.sort_by(|a, b| a.0 .1.cmp(&b.0 .1).then(b.1.cmp(a.1)));
for ((label, gated), n) in rows {
let g = if *gated { "gated" } else { "UNGATED" };
println!("VIS_DUMP {g} {n} {label}");
}
}
}
/// [`ModelKind`] has a private `index`; the census needs its own (and pins the column order).
fn kind_index(kind: crate::debug_panel::ModelKind) -> usize {
match kind {
crate::debug_panel::ModelKind::Doodad => 0,
crate::debug_panel::ModelKind::Wmo => 1,
crate::debug_panel::ModelKind::Creature => 2,
crate::debug_panel::ModelKind::GameObject => 3,
}
}
/// The particle census (`WOW_PARTICLE_CENSUS=<secs>`): once, `t` seconds in, print one line per
/// live emitter (blend, file flags, sampled rate keys, texture, live count) plus a machine-
/// readable total — the like-for-like number to put beside a reference-trace quad count (the
@@ -789,7 +995,7 @@ struct ParticleCensus {
fn fire_particle_census(
mut probe: ResMut<ParticleCensus>,
time: Res<Time>,
time: ProbeClock,
view: Res<crate::view::ViewDistance>,
cam: Query<&GlobalTransform, With<crate::player::WorldCamera>>,
emitters: Query<(
@@ -1123,3 +1329,87 @@ fn fire_entity_census(world: &mut World) {
rows.len().min(ENTITY_CENSUS_ROWS),
);
}
#[cfg(test)]
mod tests {
/// **The invariant, checked instead of remembered** (decision 0789).
///
/// Naming the right clock [`ProbeClock`] makes it easy to reach for; it does not make the wrong
/// one unavailable, and `Res<Time>` is the shorter, prelude-blessed, obvious spelling. That
/// asymmetry is precisely why this same clock has now cost three lanes a correctness bug
/// (0615's replay clock, the UI script clock, and B131's discarded probe leg) — each time it was
/// fixed where it was found and left available everywhere else. So the fix is not another
/// convention: it is this test, in the suite the gates already run.
///
/// Adding a virtual clock to the probe harness now means adding yourself to [`ALLOWED`] with a
/// reason, which is a much better conversation than discovering the drift in a thrown-away leg.
#[test]
fn probe_schedules_read_the_wall_clock() {
/// `(file, system, why it is genuinely an age or a delta on the animating clock)`.
const ALLOWED: &[(&str, &str, &str)] = &[(
"capture/mod.rs",
"drive_capture",
"the fixture AGE runs on the clock the effect animates on, and the capture freezes that \
same clock at save time — an age, not a schedule",
)];
// The needles are assembled at runtime so the checker does not flag **its own source** —
// which is exactly what it did on its first run, and is the cheapest possible proof that it
// has teeth.
let bare = format!(": Res<{}>,", "Time");
let bare_last = format!(": Res<{}>", "Time");
let explicit = format!("Res<{}<{}>>", "Time", "Virtual");
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut offenders = Vec::new();
let mut allowed_seen = 0usize;
// The probe harness: everything under `capture/`, plus the one probe that lives beside the
// player controller because it writes `face_yaw` directly.
let mut stack = vec![src.join("capture")];
let mut files = vec![src.join("player/probe_look.rs")];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).expect("probe harness dir is readable") {
let path = entry.expect("dir entry").path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|e| e == "rs") {
files.push(path);
}
}
}
for path in files {
let rel = path
.strip_prefix(&src)
.expect("under src")
.to_string_lossy()
.replace('\\', "/");
let text = std::fs::read_to_string(&path).expect("source is readable");
for (n, line) in text.lines().enumerate() {
let t = line.trim();
// A system param binding the virtual clock. `Time<Real>` is the whole point, and
// `ResMut<Time<Virtual>>` is the capture's own clock *control*, not a read of it.
let virtual_clock =
(t.ends_with(&bare) || t.ends_with(&bare_last)) || t.contains(&explicit);
if !virtual_clock {
continue;
}
match ALLOWED.iter().find(|(f, _, _)| *f == rel) {
Some(_) => allowed_seen += 1,
None => offenders.push(format!("{rel}:{} {t}", n + 1)),
}
}
}
assert!(
offenders.is_empty(),
"the probe harness must schedule on the wall clock (`ProbeClock`), not the virtual \
clock — it is clamped to max_delta (250 ms), so any hitching leg silently drifts every \
`<secs>` knob out from under the operator (decision 0789). Offenders:\n {}",
offenders.join("\n "),
);
assert!(
allowed_seen > 0,
"the ALLOWED exception list is stale — nothing matched it. If the fixture-age clock \
moved or went away, drop its entry rather than leaving a rule guarding nothing.",
);
}
}
+26 -3
View File
@@ -33,7 +33,14 @@ pub(crate) struct ClutterPlugin;
impl Plugin for ClutterPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, setup_clutter.after(AssetSet::Open))
.add_systems(Update, (stream_chunk_clutter, evict_clutter_geometry));
.add_systems(
Update,
(
stream_chunk_clutter,
evict_clutter_geometry,
scope_clutter_geometry,
),
);
}
}
@@ -53,6 +60,20 @@ fn evict_clutter_geometry(
}
}
/// Expire the decoded clutter geometry by **distance** (decision 0793) — the within-map half of
/// [`evict_clutter_geometry`]. Clutter models are map-*flavoured*: the handful a zone uses is decoded
/// on its first chunk build and then held for the process, so a continent tour accumulates every
/// zone's set. Re-approaching re-decodes one M2 off the chain, inside the same lazy per-chunk build
/// that would rebuild the meshes anyway.
fn scope_clutter_geometry(
mut scope: crate::art_scope::ArtScope,
geometry: Option<ResMut<ClutterGeometry>>,
) {
if let Some(mut g) = geometry {
scope.apply(&mut g.0, crate::art_scope::ArtSlot::ClutterGeo);
}
}
/// Startup: load the ground-effect (clutter) catalog off the shared chain + insert the live config and
/// the per-chunk geometry cache. (Moved out of the terrain streamer's `setup_terrain`.)
fn setup_clutter(mut commands: Commands, world_assets: Option<ResMut<WorldAssets>>) {
@@ -132,7 +153,9 @@ impl Default for ClutterConfig {
/// so the lazy per-chunk build system can reach it independently of `WorldAssets`. Replaces the
/// streamer's old per-tile `clutter_cache`.
#[derive(Resource, Default)]
pub(crate) struct ClutterGeometry(pub(crate) HashMap<String, Vec<RenderSubmesh>>);
pub(crate) struct ClutterGeometry(
pub(crate) crate::art_scope::SpatialCache<String, Vec<RenderSubmesh>>,
);
/// One MCNK chunk's ground clutter as a **lazily-built** unit — the faithful per-chunk `CDetailDoodadInst`
/// lifecycle (`ground-effects.md` §7): scattered + MCSH/normal-baked at tile-load, but its meshes are
@@ -301,7 +324,7 @@ fn build_chunk_clutter(
) -> Vec<Entity> {
let mut out = Vec::new();
for (model_path, placements) in models {
let subs = geometry.0.entry(model_path.clone()).or_insert_with(|| {
let subs = geometry.0.or_insert_with(model_path.clone(), || {
load_m2_mesh(&mut assets.chain.lock_recover(), model_path).unwrap_or_default()
});
if subs.is_empty() {
+4 -1
View File
@@ -164,7 +164,10 @@ pub(crate) fn drive_nock_latch(
for ev in events.read() {
match &ev.ident {
b"$BWP" if nocked.get(ev.entity) == Ok(true) => {
commands.entity(ev.entity).insert(NockLatch);
// `try_insert`, matching the `$BWR` arm's guard below: the tag names a unit that
// may be despawned by the time these commands apply (the fade lane is unordered
// against this chain — B130's second window, in the same defect class).
commands.entity(ev.entity).try_insert(NockLatch);
}
b"$BWR" => {
if let Ok(mut e) = commands.get_entity(ev.entity) {
@@ -493,6 +493,17 @@ pub(super) fn route_cast_visuals(
}
for ev in events.read() {
// **The subject may already be dead** (B130's crash, the second ever reported): every
// despawn of an *indexed* unit runs inside the wire drain — `DESTROY_OBJECT`, the
// out-of-range stream-out, the worldport purge — and those commands are applied at the sync
// point this chain sits behind (`.after(WorldStage::Net)`), so a SPELL_START and its
// subject's death drain from the same batch and the edge outlives the unit. Every arm below
// is *about* that unit (its hold, its sound, its effect models), so a dead subject skips
// whole rather than spraying the downstream lanes with edges for an entity that is gone —
// and rather than warning once per component removed off a corpse.
if commands.get_entity(ev.entity).is_err() {
continue;
}
match ev.kind {
CastEventKind::Start => {
// A replacing cast reaps the prior hold's loop before its own sound starts —
@@ -543,13 +554,19 @@ pub(super) fn route_cast_visuals(
.catalog
.get(ev.spell_id)
.is_some_and(|d| d.ranged_slot());
// Fallible, like every hold write in this system: the skip above sees only
// despawns already *applied*, and `model_fade::apply_despawn_fade` is
// Update-unordered against this chain — its instant path (a stream-out unit with
// no fadeable geometry, which is what a creature streamed in and back out at
// flight speed *is*) can queue the despawn this frame and have it applied before
// our commands. An infallible `insert` panics there; nothing can see it coming.
if ranged {
commands.entity(ev.entity).insert(super::RangedHold);
commands.entity(ev.entity).try_insert(super::RangedHold);
} else {
commands.entity(ev.entity).remove::<super::RangedHold>();
commands.entity(ev.entity).try_remove::<super::RangedHold>();
}
if let Some(anim_id) = kit.anim_id {
commands.entity(ev.entity).insert(CastHold {
commands.entity(ev.entity).try_insert(CastHold {
anim_id,
spell_id: ev.spell_id,
ranged,
@@ -580,7 +597,7 @@ pub(super) fn route_cast_visuals(
// Spell-id-keyed reap (the client's `0x614150(spellId, 0)`) — a proc's GO landing
// mid-cast never drops another spell's hold.
if held_spell(&pending, ev.entity) == Some(ev.spell_id) {
commands.entity(ev.entity).remove::<CastHold>();
commands.entity(ev.entity).try_remove::<CastHold>();
pending.insert(ev.entity, None);
}
// The release reaps the precast's loop unconditionally-of-hold-state: an instant
@@ -628,9 +645,9 @@ pub(super) fn route_cast_visuals(
.get(ev.spell_id)
.is_some_and(|d| d.ranged_slot())
{
commands.entity(ev.entity).insert(super::RangedHold);
commands.entity(ev.entity).try_insert(super::RangedHold);
} else {
commands.entity(ev.entity).remove::<super::RangedHold>();
commands.entity(ev.entity).try_remove::<super::RangedHold>();
}
play_kit(
ev.entity,
@@ -660,7 +677,7 @@ pub(super) fn route_cast_visuals(
}
CastEventKind::Fail => {
if held_spell(&pending, ev.entity) == Some(ev.spell_id) {
commands.entity(ev.entity).remove::<CastHold>();
commands.entity(ev.entity).try_remove::<CastHold>();
pending.insert(ev.entity, None);
}
out.sounds
@@ -776,7 +793,11 @@ pub(super) fn route_cast_visuals(
// channels anyway.
if let Some(kit) = resolve_kit(spells, &visuals.0, cur, |s| s.channel, || None) {
if let Some(anim_id) = kit.anim_id {
commands.entity(entity).insert(CastHold {
// Fallible for the same reason as the wire arms above — and this loop's
// subjects need it *more*: a unit mid-stream-out is un-indexed but still
// carries its `ObjectStore`, so it is in this very query while the fade lane
// is free to despawn it out from under us this frame.
commands.entity(entity).try_insert(CastHold {
anim_id,
spell_id: cur,
ranged: false, // no basic shot channels (the comment above)
@@ -803,7 +824,7 @@ pub(super) fn route_cast_visuals(
if held_spell(&pending, entity) == Some(prev) {
// Only the ending channel's own hold is reaped — a precast for the unit's next
// spell (already in flight when the field clears) survives.
commands.entity(entity).remove::<CastHold>();
commands.entity(entity).try_remove::<CastHold>();
pending.insert(entity, None);
}
out.sounds.write(SpellKitSound::StopHold { entity });
@@ -651,6 +651,55 @@ fn missile_spawn_defers_iff_the_cast_kit_animates() {
);
}
/// **B130's crash** — the second ever reported: a release build panicked on `insert<CastHold>`
/// while flying at high speed through the Wetlands, applying hold commands to a unit that had
/// despawned. Both windows are exercised here, because they fail for different reasons:
///
/// 1. **Already gone when the edge is read.** Every despawn of an indexed unit runs inside the wire
/// drain (`DESTROY_OBJECT`, the out-of-range stream-out, the worldport purge), and those are
/// applied at the sync point this chain sits behind — so a START and its subject's death arrive
/// in one batch and the edge outlives the unit.
/// 2. **Queued this frame, no sync point between.** `model_fade::apply_despawn_fade` is
/// Update-unordered against this chain; its despawn can be queued before ours and applied first,
/// which a queue-time existence check structurally cannot see.
///
/// The pass condition is that the frame completes — and that neither window resurrects the unit.
#[test]
fn a_despawned_subject_never_panics_the_router() {
{
let mut app = app();
let unit = app.world_mut().spawn_empty().id();
app.world_mut().entity_mut(unit).despawn();
app.world_mut()
.write_message(cast_event(unit, SPELL, CastEventKind::Start));
app.update(); // window 1: panicked here before the fix
assert!(
app.world().get_entity(unit).is_err(),
"the hold write must not resurrect a dead subject"
);
}
{
// `before_ignore_deferred` is exactly the fade lane's shape — an ordering edge with no sync
// point on it, so both command queues flush together and the despawn applies first.
let mut app = app();
let unit = app.world_mut().spawn_empty().id();
app.add_systems(
Update,
(move |mut commands: Commands| {
commands.entity(unit).try_despawn();
})
.before_ignore_deferred(route_cast_visuals),
);
app.world_mut()
.write_message(cast_event(unit, SPELL, CastEventKind::Start));
app.update();
assert!(
app.world().get_entity(unit).is_err(),
"the same-frame despawn wins; the hold write is dropped"
);
}
}
/// The `0x400` weapon-visual hold (wow-re `ranged-sheath-exempt-autorepeat.md` §Q4): a RANGED
/// spell's visual play inserts [`RangedHold`] on ANY caster — what keeps a remote shooter in
/// the drawn Load/Hold idle between shots — and a non-ranged visual play clears it (the
+59 -33
View File
@@ -207,35 +207,42 @@ impl Plugin for DebugPanelPlugin {
egui.enable_cursor_icon_updates = false;
}
app.init_resource::<DebugState>()
.init_resource::<EguiPointerOver>()
.add_systems(Startup, spawn_egui_camera)
// Keep Tab out of egui: it's a bound game key, never focus-navigation for our overlays.
// Runs after bevy_egui fills `EguiInput` and before egui's pass consumes it (the seam
// bevy_egui documents for input edits).
.add_systems(
PreUpdate,
strip_egui_tab_focus
.after(EguiPreUpdateSet::ProcessInput)
.before(EguiPreUpdateSet::BeginPass),
)
.add_systems(
EguiPrimaryContextPass,
(debug_panel_ui, track_pointer_over_ui),
)
// `apply_model_visibility` reads the WMO portal PVS, so it runs after the compute that
// fills it (`crate::wmo_portal::WmoPvsSet`).
.add_systems(
Update,
(
// After the UI keyboard feed so a backtick typed into a focused EditBox never
// toggles the panel.
toggle_panel.after(crate::ui_script::UiInput),
apply_model_visibility
.after(crate::wmo_portal::WmoPvsSet)
.in_set(ModelVisSet),
),
);
app.insert_resource(DebugState {
// `$WOW_PANEL=1` — start with the panel **open**, which is how a headless capture run
// gets it into the frame. Without it a panel change (a new footer line, a section that
// grew past the scroll reserve) can only be checked in the director's own window, and
// clipping is exactly the failure a capture catches for free.
open: std::env::var_os("WOW_PANEL").is_some(),
..default()
})
.init_resource::<EguiPointerOver>()
.add_systems(Startup, spawn_egui_camera)
// Keep Tab out of egui: it's a bound game key, never focus-navigation for our overlays.
// Runs after bevy_egui fills `EguiInput` and before egui's pass consumes it (the seam
// bevy_egui documents for input edits).
.add_systems(
PreUpdate,
strip_egui_tab_focus
.after(EguiPreUpdateSet::ProcessInput)
.before(EguiPreUpdateSet::BeginPass),
)
.add_systems(
EguiPrimaryContextPass,
(debug_panel_ui, track_pointer_over_ui),
)
// `apply_model_visibility` reads the WMO portal PVS, so it runs after the compute that
// fills it (`crate::wmo_portal::WmoPvsSet`).
.add_systems(
Update,
(
// After the UI keyboard feed so a backtick typed into a focused EditBox never
// toggles the panel.
toggle_panel.after(crate::ui_script::UiInput),
apply_model_visibility
.after(crate::wmo_portal::WmoPvsSet)
.in_set(ModelVisSet),
),
);
}
}
@@ -350,10 +357,11 @@ fn debug_panel_ui(
egui::ScrollArea::vertical()
.auto_shrink([false, false])
// Leave room under the scroll for the pinned hotkey footer below. Two lines' worth:
// the footer keeps the panel's default wrapping, so a font whose metrics run wider
// than ours reflows it instead of having its second line clipped away.
.max_height(panel_h - 40.0)
// Leave room under the scroll for the pinned footer below (hotkey map + build id).
// Three lines' worth: the footer keeps the panel's default wrapping, so a font
// whose metrics run wider than ours reflows the hotkey map instead of having its
// second line clipped away.
.max_height(panel_h - 56.0)
.show(ui, |ui| {
// Disjoint borrows of the sections so each can drive its own widgets.
let DebugState {
@@ -651,6 +659,24 @@ fn debug_panel_ui(
.small()
.color(OVERLAY_TEXT_DIM),
);
// …and which build this is (`crate::build_id`). Bottom line of the surface a reader is
// already on when something looks wrong: a click copies the full sha, so "it looks like
// this here" can name the code it happened on.
let build = ui.add(
egui::Label::new(
egui::RichText::new(format!("build {}", crate::build_id::summary()))
.small()
.color(OVERLAY_TEXT_DIM),
)
.sense(egui::Sense::click()),
);
if !crate::build_id::SHA.is_empty()
&& build
.on_hover_text(format!("{}\n(click to copy)", crate::build_id::SHA))
.clicked()
{
ui.ctx().copy_text(crate::build_id::SHA.to_string());
}
});
Ok(())
}
+57 -16
View File
@@ -25,14 +25,24 @@ use crate::wmo_portal::{WmoGroupVis, WmoPortalInstance};
/// Runs every frame (the camera moves, so this can't be snapshot-gated like a pure toggle), but only
/// **writes** `Visibility` when a submesh's decision actually flips — so the steady-state cost is one
/// squared-distance compare per submesh and no change-detection churn.
#[allow(clippy::type_complexity)]
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub(super) fn apply_model_visibility(
debug: Res<DebugState>,
view: Res<ViewDistance>,
cam: Query<&GlobalTransform, With<WorldCamera>>,
cam: Query<(&GlobalTransform, &Projection), With<WorldCamera>>,
// The per-frame WMO portal PVS (computed by `crate::wmo_portal`), read here so the cull composes
// with the toggles + far-clip in this single Visibility authority rather than fighting it.
instances: Query<&WmoPortalInstance>,
// …and, for exactly the same reason, the exterior-scene window gate (decision 0784). It used to
// write `Visibility` itself in `PostUpdate`, i.e. AFTER this system — so on every object it
// admitted it silently undid the toggles, the far-clip cull and the portal PVS, including
// outdoors, where it wrote `Inherited` over all of them every single frame.
windows: Res<crate::wmo_portal::ExteriorWindows>,
// The camera's OWN building is not exterior scene to itself: the reference draws the containing
// map object through the interior portal walk, never through the per-window exterior populate
// (`[0xc7b748]` is the branch, not a participant). Without this exemption, standing inside
// Stratholme would window-cull Stratholme's own walls.
claim: Res<crate::wmo_portal::CameraInteriorClaim>,
mut q: Query<(
&ModelPart,
&GlobalTransform,
@@ -43,25 +53,41 @@ pub(super) fn apply_model_visibility(
Option<&Aabb>,
Option<&WmoGroupVis>,
Option<&crate::doodad_anim::MatAnim>,
Has<crate::exterior_cull::ExteriorScene>,
)>,
// The building's own MLIQ surfaces (canals, dungeon pools, Ragefire's lava). They carry a
// `WmoGroupVis` like every other piece of their group but no `ModelPart` — they are not model
// submeshes and take none of the toggle/far-clip/fade rules above. A second QUERY, deliberately
// not a second SYSTEM: decision 0025 wants one `Visibility` authority, and this keeps it
// (decision 0689 — a culled room's water must go with the room, same as its furniture).
mut group_only: Query<(&WmoGroupVis, &mut Visibility), Without<ModelPart>>,
mut group_only: Query<
(
&WmoGroupVis,
&mut Visibility,
&GlobalTransform,
Option<&Aabb>,
Has<crate::exterior_cull::ExteriorScene>,
),
Without<ModelPart>,
>,
) {
let m = &debug.models;
// The (single) world Camera3d; the egui overlay is a Camera2d. None before it spawns → no distance
// cull that frame (toggles still apply).
let cam_t = cam.iter().next();
let cam_view = cam.iter().next();
let cam_t = cam_view.map(|(t, _)| t);
let cam_pos = cam_t.map(|t| t.translation());
let cam_fwd = cam_t.map(|t| Vec3::from(t.forward()));
// The exterior gate, built ONCE for the whole walk (it is a handful of 6-plane frusta) and then
// asked per submesh below — the same value `crate::exterior_cull` asks for the objects it owns.
let gate = crate::exterior_cull::ExteriorGate::build(&windows, cam_view);
// The placement the camera is standing in, exempt from its own window gate (see the param).
let own_instance = claim.0.map(|c| c.room.instance);
// Parallel: this walks EVERY model submesh in residency — ~100k in a city — and at that N a
// serial walk alone blew half the 16.7 ms budget (the Stormwind fps hunt). Every write below
// is change-gated, so the steady state is a pure read fan-out.
q.par_iter_mut().for_each(
|(part, xf, mut vis, fade, tag, mat, aabb, group_vis, mat_anim)| {
|(part, xf, mut vis, fade, tag, mat, aabb, group_vis, mat_anim, exterior)| {
let toggled_on =
m.kind_visible[part.kind.index()] && m.blend_visible[blend_index(part.blend)];
// `farclip` is the *world-doodad/WMO* draw distance only. Creatures/GameObjects come from the
@@ -129,15 +155,27 @@ pub(super) fn apply_model_visibility(
// so a flicker track at 0 hides the batch outright.
let mat_factor = mat_anim.map_or(1.0, |m| m.current);
// The exterior-scene window gate (decision 0774's law, decision 0784's placement):
// standing in a WMO interior, exterior content draws only through a portal window. ANDed
// in here rather than written by a second system, so it composes with everything above
// instead of overwriting it. Untagged content (units, GameObjects) is exempt by the
// carved law; the camera's own building is exempt because it is not exterior to itself.
let own_building = group_vis.is_some_and(|gv| Some(gv.instance) == own_instance);
let exterior_ok = !exterior || own_building || gate.admits(xf, aabb);
// `Inherited` (not `Visible`) so child submeshes still respect a hidden parent root. A fully
// faded doodad (`fade_alpha == 0`) is culled just like an out-of-range one.
let desired =
if toggled_on && in_range && fade_alpha > 0.0 && mat_factor > 0.0 && portal_visible
{
Visibility::Inherited
} else {
Visibility::Hidden
};
let desired = if toggled_on
&& in_range
&& fade_alpha > 0.0
&& mat_factor > 0.0
&& portal_visible
&& exterior_ok
{
Visibility::Inherited
} else {
Visibility::Hidden
};
if *vis != desired {
*vis = desired;
}
@@ -189,13 +227,16 @@ pub(super) fn apply_model_visibility(
// The group-only audience: portal cull and nothing else. Serial — a building has a handful of
// liquid surfaces, not the ~100k submeshes above — and change-gated like every write here.
for (gv, mut vis) in &mut group_only {
let want = if !m.portal_cull
for (gv, mut vis, xf, aabb, exterior) in &mut group_only {
let portal_ok = !m.portal_cull
|| instances
.get(gv.instance)
.ok()
.is_none_or(|inst| gv.drawn_by(inst))
{
.is_none_or(|inst| gv.drawn_by(inst));
// …and the same exterior-window term as the submesh walk: another building's canal is
// exterior content, the canal of the building you are standing in is not.
let exterior_ok = !exterior || Some(gv.instance) == own_instance || gate.admits(xf, aabb);
let want = if portal_ok && exterior_ok {
Visibility::Inherited
} else {
Visibility::Hidden
+5 -1
View File
@@ -4,7 +4,11 @@
//! tiles + WMO faces — **never** doodads/GameObjects) whose BVH overlaps a projection box, clip
//! each to the box ([`clip_to_frame`]), and emit them with planar top-down UVs. Because the
//! emitted triangles are exact sub-pieces of the drawn surfaces, a decal is pixel-coplanar with
//! what's on screen (the draw's rasterizer `depth_bias` settles the depth test) and drapes down
//! what's on screen — **provided it transforms through the same `clip_from_world` matrix as the
//! world-mesh shaders** (the `DECAL_WORLD_CLIP` lane variant; the cam-relative route reaches
//! the same plane through different arithmetic and misses by more than the bias at WoW-scale
//! coordinates — decision 0781), the rasterizer `depth_bias` settling the depth test — and
//! drapes down
//! steps and ledge faces precisely like the reference (a vertical face gets the smeared texel
//! column of its XZ spot: projective texturing, faithfully).
//!
+136 -40
View File
@@ -328,6 +328,7 @@ fn reroll_doodad_variation(
/// both clocks to the shared-clock position — when one is again. Runs before [`AnimationSystems`] so
/// a resume's seek lands the same frame. Steady state (nothing flipped) is one `Visibility` read per
/// mesh, no writes.
#[allow(clippy::too_many_arguments)] // one Bevy system's input set: the gate reads four authorities
fn gate_doodad_anim(
time: Res<Time>,
mut hosts: Query<(
@@ -337,12 +338,20 @@ fn gate_doodad_anim(
)>,
vis: Query<&Visibility>,
cam: Query<
(&GlobalTransform, &bevy::camera::primitives::Frustum),
(
&GlobalTransform,
&bevy::camera::primitives::Frustum,
&bevy::camera::Projection,
),
With<crate::player::WorldCamera>,
>,
// The far-clip wall — the meshless host's draw-set gate needs the same depth bound the
// emitters and the doodad meshes use.
// emitters and the doodad meshes use…
view: Res<crate::view::ViewDistance>,
// …and the same exterior-window term, for the same reason: a meshless prop OUTSIDE, seen from a
// WMO interior, is not in the frame's worklist and must not tick its bones either (0786).
exterior_windows: Res<crate::wmo_portal::ExteriorWindows>,
camera_claim: Res<crate::wmo_portal::CameraInteriorClaim>,
mut logged: Local<bool>,
) {
// One breadcrumb per session, the first frame any host exists — the machine-readable "doodads
@@ -353,6 +362,11 @@ fn gate_doodad_anim(
}
let now = time.elapsed_secs();
let world_cam = cam.single().ok();
let exterior_gate = crate::exterior_cull::ExteriorGate::build(
&exterior_windows,
world_cam.map(|(tf, _, proj)| (tf, proj)),
);
let camera_instance = camera_claim.0.map(|c| c.room.instance);
for (mut host, player, drive) in &mut hosts {
let drawn = if host.meshes.is_empty() {
// Meshless (particles-only) host: the emitters' own draw-set law (see the `fade`
@@ -363,9 +377,16 @@ fn gate_doodad_anim(
// term went missing here as well as in the emitters (decision 0678 / bug B39) — a
// meshless fire prop kept animating its bones at any distance past the wall.
let (radius, center) = host.fade;
world_cam.is_some_and(|(cam_tf, frustum)| {
world_cam.is_some_and(|(cam_tf, frustum, _)| {
let cam_pos = cam_tf.translation();
crate::particles::EmitterFade { radius, center }.in_draw_set(
// A meshless host is a placement's own particles-only model; it carries no building
// instance of its own, so the honest answer here is "test my sphere".
let fade = crate::particles::EmitterFade {
radius,
center,
instance: None,
};
fade.in_draw_set(
cam_pos,
Vec3::from(cam_tf.forward()),
view.farclip,
@@ -376,6 +397,7 @@ fn gate_doodad_anim(
},
false,
),
fade.exterior_admitted(&exterior_gate, camera_instance),
)
})
} else {
@@ -572,7 +594,7 @@ fn sample_mat_anim(
/// The **UV-animated materials** registry (decision 0130 phase 3, wow-re `m2-texanim-uv`): each
/// batch material carrying a texture-transform translation loop, keyed by material asset id.
/// [`tick_uv_anim_materials`] re-samples every entry's offset into the material's `sun_scale.zw`
/// [`tick_anim_materials`] re-samples a *drawn* entry's offset into the material's `sun_scale.zw`
/// each frame — one shared uniform per material, so every instance of a model batch scrolls in
/// phase. Exactly faithful for gseq loops (the reference's free-running shared clock); a recorded,
/// invisible divergence for seq-band loops (the reference phases those per instance at arm time —
@@ -585,20 +607,69 @@ pub(crate) struct UvAnimMaterials(
>,
);
/// Scroll every UV-animated material's offset on the shared clock. Skipped entirely in captures
/// (materials keep their t = 0 seed — constants still show, frames stay deterministic). Mutating
/// the material asset re-uploads its packed uniform; the population is tiny (the corpus holds 113
/// texanim models game-wide, a handful in residency), so this is a few uniform writes per frame.
fn tick_uv_anim_materials(
/// Re-sample the **drawn** animated materials on the shared clock — the UV scroll
/// ([`UvAnimMaterials`]) and the RGB tint ([`TintAnimMaterials`]) together, because they share the
/// draw scan below. Skipped entirely in captures (materials keep their t = 0 seed — constants still
/// show, frames stay deterministic).
///
/// **The draw gate, and why it is the fix for B131.** Mutating a material asset marks it Modified,
/// which on the Metal non-bindless path re-creates its uniform buffers *and its bind group* that
/// frame. 0130 sized this as "a few uniform writes per frame" on the premise that the resident
/// population is tiny (113 texanim models exist game-wide, a handful in view) — true per view,
/// **false per map session**: the dedup caches hold every material they ever built until a
/// `MapChange` (decision 0729), and a registry entry only evicts when its material dies, so on a
/// single-map traverse both registries grow monotonically and every entry is re-uploaded every
/// frame for ever. Measured on a parked same-map leg by square-waving this system:
/// **+9.85 ms of CPU per frame at 174 resident entries (~57 µs each)** — and residency 4 → 248
/// entries inside ten minutes, recoverable only by a restart or a map change. That is B131.
///
/// Gating on the draw is not a workaround for that growth, it is this lane finally obeying the
/// module's own law: [`gate_doodad_anim`] already gates the *pose* on "any submesh actually drawn",
/// on exactly the byte ground that makes it free — sampling is clock-indexed, so a material that
/// re-appears is written from `now` and shows the value the shared clock dictates, with nothing to
/// catch up (the module docs' "pausing costs nothing and drifts nothing"). A material nothing draws
/// is a per-frame GPU rebuild with no pixel to show for it.
///
/// `Visibility != Hidden` is the same spelling [`gate_doodad_anim`] uses — the authority's verdict
/// (far clip + size-bucketed fade + portal cull), written in `Update` by
/// `debug_panel::ModelVisSet`, which this system is ordered after so the verdict is *this* frame's.
/// It over-includes (a part left `Inherited` under a hidden ancestor counts as drawn), which is the
/// safe direction: an extra write costs a frame's uniform upload, a missed one would freeze a
/// visible scroll.
fn tick_anim_materials(
time: Res<Time>,
mut reg: ResMut<UvAnimMaterials>,
real: Res<Time<bevy::time::Real>>,
mut uv_reg: ResMut<UvAnimMaterials>,
mut tint_reg: ResMut<TintAnimMaterials>,
mut materials: ResMut<Assets<crate::terrain::WowModelMaterial>>,
parts: Query<(
&MeshMaterial3d<crate::terrain::WowModelMaterial>,
&Visibility,
)>,
mut drawn: Local<
bevy::platform::collections::HashSet<AssetId<crate::terrain::WowModelMaterial>>,
>,
) {
if reg.0.is_empty() || crate::capture::scenario_active() {
if (uv_reg.0.is_empty() && tint_reg.0.is_empty())
|| crate::capture::scenario_active()
|| matanim_off(&real)
{
return;
}
drawn.clear();
for (mat, vis) in &parts {
if *vis != Visibility::Hidden {
drawn.insert(mat.id());
}
}
let now = time.elapsed_secs();
reg.0.retain(|id, anim| {
// `contains` on the skip path, never `get_mut`: the entry must survive while its material does
// (that is the eviction predicate — an entry dies with its material, not with its visibility),
// and an immutable probe is what keeps a skipped material *un*-Modified.
uv_reg.0.retain(|id, anim| {
if !drawn.contains(id) {
return materials.contains(*id);
}
let Some(mat) = materials.get_mut(*id) else {
return false; // material unloaded with its cache — drop the entry
};
@@ -607,12 +678,52 @@ fn tick_uv_anim_materials(
mat.extension.sun_scale.w = uv[1];
true
});
tint_reg.0.retain(|id, anim| {
if !drawn.contains(id) {
return materials.contains(*id);
}
let Some(mat) = materials.get_mut(*id) else {
return false;
};
let rgb = anim.sample(now);
mat.extension.tint = bevy::math::Vec4::new(rgb[0], rgb[1], rgb[2], 1.0);
true
});
}
/// **The price-this-system knob** (`WOW_MATANIM_DUTY=<start_s>:<period_s>`, decision 0785): alternate
/// [`tick_anim_materials`] off/on every `period` seconds from `start`. Park at one pin, square-wave
/// it, and the difference between the ON and OFF buckets **is** this system's per-frame cost, with
/// residency, scene, entity count and camera all held identical — a measurement, not an argument.
/// It is how B131's ratchet was priced (+9.85 ms/frame over 174 resident entries) and how the draw
/// gate above was then shown to remove it (+0.22 ms). Kept, not deleted: the ~10 ms-floor ledger
/// (0729's residuals) has more per-frame systems queued for exactly this treatment.
///
/// A **square wave** rather than one flip, because this machine's frame cost drifts on its own —
/// a single before/after pair cannot tell the drift from the signal, and both legs of the verifying
/// run climbed ~10 ms while their ON/OFF difference stayed flat.
///
/// **`Time<Real>`, deliberately:** virtual time is clamped to `max_delta` (250 ms), so on a leg that
/// hitches it lags real time badly. That is what smeared the first attempt at this measurement into
/// nonsense — the probe-chat schedule reads virtual time, so once the leg hitched its hops drifted
/// 40 s → 75 s apart and windows labelled "parked, ticks off" in fact held a teleport and live ticks.
fn matanim_off(time: &Time<bevy::time::Real>) -> bool {
static SPEC: std::sync::OnceLock<Option<(f32, f32)>> = std::sync::OnceLock::new();
let spec = SPEC.get_or_init(|| {
let v = std::env::var("WOW_MATANIM_DUTY").ok()?;
let (start, period) = v.split_once(':')?;
Some((start.trim().parse().ok()?, period.trim().parse().ok()?))
});
spec.is_some_and(|(start, period)| {
let t = time.elapsed_secs() - start;
t >= 0.0 && period > 0.0 && ((t / period) as u32) % 2 == 1
})
}
/// The **tint-animated materials** registry — the M2Color-RGB twin of [`UvAnimMaterials`]: each
/// batch material whose colour track animates (the vertex bake is skipped for those —
/// `benilla-formats` `m2_batches`), keyed by material asset id. [`tick_tint_anim_materials`]
/// re-samples the tint into the material's `tint` uniform each frame on the same shared clock
/// `benilla-formats` `m2_batches`), keyed by material asset id. [`tick_anim_materials`]
/// re-samples the tint into the material's `tint` uniform on the same shared clock, for the drawn
/// (the same recorded seq-band phase divergence as the UV scroll — invisible for a placed
/// doodad's ambient loop). Spell-effect instances need real per-instance phase instead (one cast
/// = one 0.9 s pulse), so the effect lane clones its materials and ticks them on the instance
@@ -625,27 +736,6 @@ pub(crate) struct TintAnimMaterials(
>,
);
/// Re-sample every tint-animated material's RGB on the shared clock (see [`TintAnimMaterials`]).
/// Skipped in captures like the UV scroll (materials keep their first-key seed).
fn tick_tint_anim_materials(
time: Res<Time>,
mut reg: ResMut<TintAnimMaterials>,
mut materials: ResMut<Assets<crate::terrain::WowModelMaterial>>,
) {
if reg.0.is_empty() || crate::capture::scenario_active() {
return;
}
let now = time.elapsed_secs();
reg.0.retain(|id, anim| {
let Some(mat) = materials.get_mut(*id) else {
return false; // material unloaded with its cache — drop the entry
};
let rgb = anim.sample(now);
mat.extension.tint = bevy::math::Vec4::new(rgb[0], rgb[1], rgb[2], 1.0);
true
});
}
pub struct DoodadAnimPlugin;
impl Plugin for DoodadAnimPlugin {
@@ -662,14 +752,15 @@ impl Plugin for DoodadAnimPlugin {
.chain()
.before(AnimationSystems),
);
// Before the visibility authority (`ModelVisSet`): it composes `MatAnim::current` into the
// render-alpha tag the same frame.
// `sample_mat_anim` runs before the visibility authority (`ModelVisSet`): it composes
// `MatAnim::current` into the render-alpha tag the same frame. The material tick runs
// *after* it, because its draw gate reads the verdict that authority writes — one frame
// later would freeze a scroll for a frame on every doodad that comes into view.
app.add_systems(
Update,
(
sample_mat_anim.before(crate::debug_panel::ModelVisSet),
tick_uv_anim_materials,
tick_tint_anim_materials,
tick_anim_materials.after(crate::debug_panel::ModelVisSet),
),
);
}
@@ -1105,6 +1196,11 @@ mod tests {
// The gate reads the far-clip wall (0678) — this host is mesh-BACKED, so it takes the
// `Visibility` branch and never consults it, but the system still needs the resource.
app.init_resource::<crate::view::ViewDistance>();
// …and the exterior-window gate's two terms (0786), for the same reason: this host is
// mesh-BACKED so it never consults them, but the system's params must resolve. Their
// defaults are the outdoor case (`Unrestricted`, no room claimed).
app.init_resource::<crate::wmo_portal::ExteriorWindows>();
app.init_resource::<crate::wmo_portal::CameraInteriorClaim>();
app.add_systems(Update, gate_doodad_anim);
let mesh = app.world_mut().spawn(Visibility::Inherited).id();
+17 -2
View File
@@ -265,7 +265,7 @@ struct EntityMaterials(MaterialCache);
/// composite is a fresh `Image` asset per build (unlike an `asset_server.load` path, which dedups by
/// path), so without this cache every player would re-composite and break material dedup downstream.
#[derive(Resource, Default)]
struct SkinComposites(HashMap<SkinKey, Handle<Image>>);
struct SkinComposites(crate::art_scope::SpatialCache<SkinKey, Handle<Image>>);
/// The appearance fields that determine a composited body skin (decision 0044): race/sex pick the
/// CharSections rows; skin/face/facialHair/hairStyle/hairColor pick the base + overlay variations;
@@ -336,6 +336,21 @@ fn evict_display_caches(
}
}
/// Expire the streamed-entity dedups by **distance** (decision 0793) — the within-map half of
/// [`evict_display_caches`]. The composited skins are the notable half: each distinct dressed look is
/// its own 256² `Image` (decision 0044), so a session that meets a lot of players accumulates
/// uploads no map change ever reaches. The display-id → model caches (`Creatures`/`GameObjects`/
/// `ItemDisplays`/`SpellFx`) are deliberately *not* swept: they key on ids, not places, and hold M2
/// assets whose count is bounded by the catalogs rather than by where you have been.
fn scope_entity_art(
mut scope: crate::art_scope::ArtScope,
mut mats: ResMut<EntityMaterials>,
mut composites: ResMut<SkinComposites>,
) {
scope.apply(&mut mats.0, crate::art_scope::ArtSlot::EntityMats);
scope.apply(&mut composites.0, crate::art_scope::ArtSlot::Skins);
}
/// The streamed-entity subsystem: builds the shared cube assets + display catalogs at startup, then
/// each frame resolves/builds display models and attaches a visual to every net entity.
pub(crate) struct EntitiesPlugin;
@@ -353,7 +368,7 @@ impl Plugin for EntitiesPlugin {
.add_systems(Startup, setup_entities.after(AssetSet::Open))
// The map-scope teardown (`world_map::MapChange`): drop every display/material dedup
// so a map's assets actually die with it — the #bugs teleport leak.
.add_systems(Update, evict_display_caches)
.add_systems(Update, (evict_display_caches, scope_entity_art))
// Every streamed unit's collision height, the frame after `apply_net_updates` spawns it
// (that stage's Commands are what create the entity, so this cannot be earlier). Its
// consumers all read `Option<&CollisionHeight>` against the ctor default, so a unit's
@@ -6,8 +6,6 @@
//! ([`WornEquip`], decision 0074), and the per-appearance material quints over the composited body
//! atlas / hair / cape textures ([`build_char_skin_materials`], decisions 0044 / 0045).
use std::collections::HashMap;
use benilla_formats::{CharSkinSlot, ModelBlend};
use benilla_protocol::EntityKind;
use bevy::prelude::*;
@@ -304,7 +302,7 @@ pub(super) fn build_char_skin_materials(
light: Option<&SharedLightBuffer>,
parts: &[EntityPart],
images: &mut Assets<Image>,
skin_cache: &mut HashMap<SkinKey, Handle<Image>>,
skin_cache: &mut crate::art_scope::SpatialCache<SkinKey, Handle<Image>>,
asset_server: &AssetServer,
materials: &mut Assets<WowModelMaterial>,
cache: &mut MaterialCache,
@@ -331,8 +329,8 @@ pub(super) fn build_char_skin_materials(
hair_color: look.hair_color,
equip,
};
match skin_cache.get(&key) {
Some(handle) => Some(handle.clone()),
match skin_cache.fetch(&key) {
Some(handle) => Some(handle),
None => {
// The worn ItemDisplayInfo rows whose region textures dress the atlas
// (decision 0074); an unknown/zero display id contributes nothing.
+178 -42
View File
@@ -49,9 +49,15 @@
//!
//! ## What is gated so far, and what is knowingly not
//!
//! **Gated:** ADT terrain tiles (`0x683bf0`) and ADT doodad placements (`0x683700`) — the two buckets
//! that produce the reported symptom, and the two whose entities have no other `Visibility` writer, so
//! this is their sole authority (decision 0025).
//! **Gated:** ADT terrain (`0x683bf0`), ADT doodad placements (`0x683700`) and the WDL far band
//! (`0x683040`) — the buckets that produce the reported symptom, and the ones whose entities have no
//! other `Visibility` writer, so this is their sole authority (decision 0025).
//!
//! **The unit is the drawn object, and for terrain that is the 33.333 yd MCNK cell, not the 533 yd
//! tile** (decision 0780). This is a property of the *spawner*, not of anything here — but the cull is
//! where it bites: a tile-sized box always contains the camera's own ground, so it intersects every
//! window and is admitted whichever way the doorway faces. The cull was correct and looked broken.
//! The far band keeps its whole-tile box, which is the reference's own far-tier granularity.
//!
//! **NOT yet gated, deliberately:** world **WMO placements** (`0x6856c0`) and open-world **liquid**
//! (`0x683ab0`). Both already have a visibility authority — WMO group submeshes are written by the
@@ -77,7 +83,9 @@ use crate::wmo_portal::{ExteriorWindows, Rect, WmoPvsSet};
const MIN_WINDOW_NDC: f32 = 0.02;
/// Tag for a piece of the **exterior scene** — the content the window worklist gates. Put this on ADT
/// terrain tiles, ADT doodad placements, world WMO placements and open-world liquid.
/// terrain **chunks**, ADT doodad placements, the WDL far band, world WMO placements and open-world
/// liquid. Tag the object that is *drawn*: this is tested per entity, so tagging a container whose box
/// spans far more than any one draw admits the lot (decision 0780).
///
/// **Never on units, players, or anything parented to a WMO group**: the former are exempt by the
/// carved law above, and the latter are already culled by the portal PVS
@@ -89,11 +97,31 @@ pub struct ExteriorScene;
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ExteriorCullSet;
/// What the cull actually did on its last run — the instrument that makes a "why is that still
/// drawn?" report answerable without guessing. `windows` is the worklist it read (`None` =
/// [`ExteriorWindows::Unrestricted`], the stand-down leg), `frusta` how many survived
/// [`MIN_WINDOW_NDC`], and `tested`/`hidden` how many tagged objects it reached and rejected.
///
/// Its whole point is that `tested` is the number no other instrument can see: an object drawn
/// through a wall while `hidden == tested` is an object the cull **never reached**, which is a
/// different defect from one it reached and admitted.
#[derive(Resource, Default, Clone, Copy)]
pub struct ExteriorCullVerdict {
pub windows: Option<usize>,
pub frusta: usize,
pub tested: usize,
pub hidden: usize,
/// Tagged objects with **no `Aabb`**, which the fail-open arm below admits unconditionally.
/// Non-zero is not automatically a defect (a tagged non-drawing entity has no bound and draws
/// nothing), but it is the one number that says how much of the scene the cull is not deciding.
pub unbounded: usize,
}
pub(crate) struct ExteriorCullPlugin;
impl Plugin for ExteriorCullPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
app.init_resource::<ExteriorCullVerdict>().add_systems(
PostUpdate,
apply_exterior_cull
.in_set(ExteriorCullSet)
@@ -131,53 +159,117 @@ fn window_frustum(rect: Rect, clip_from_world: &Mat4) -> Option<Frustum> {
))
}
/// Hide every [`ExteriorScene`] object that no window admits.
/// This frame's exterior gate — the window worklist turned into clip volumes, built once and asked
/// per object. **Two systems ask it**, which is the whole point of it being a value rather than a
/// loop: [`apply_exterior_cull`] owns the exterior objects nothing else writes (terrain cells, the
/// far band), and [`crate::debug_panel`]'s model-visibility authority folds the same answer into its
/// own AND for every model submesh, because those already have an owner (decision 0784).
pub(crate) enum ExteriorGate {
/// Outdoors — the driver's outside leg (`0x6811ca`): the ordinary frustum is the window, so the
/// gate admits everything and each object goes back to whatever else owns it.
Open,
/// Indoors: an object draws only where one of these sub-frusta admits it. **Empty admits
/// nothing** — the sealed-room case (`0x681199`'s skip), and the one branch that must not fail
/// open, because failing open here is the bug this whole module exists to fix.
Windows(Vec<Frustum>),
}
impl ExteriorGate {
/// Build from the worklist + the world camera. No camera yet ⇒ [`Self::Open`]: nothing is on
/// screen to leak, and a verdict taken without a view matrix would be arbitrary.
pub(crate) fn build(
windows: &ExteriorWindows,
cam: Option<(&GlobalTransform, &Projection)>,
) -> Self {
let ExteriorWindows::Windows(rects) = windows else {
return Self::Open;
};
let Some((cam_t, proj)) = cam else {
return Self::Open;
};
let clip_from_world = proj.get_clip_from_view() * cam_t.to_matrix().inverse();
Self::Windows(
rects
.iter()
.filter_map(|r| window_frustum(*r, &clip_from_world))
.collect(),
)
}
/// Whole-AABB, per object — the reference's `0x682f40`. Not per primitive: a doodad straddling
/// the doorway edge draws whole, and the interior geometry cuts its silhouette.
///
/// No `Aabb` (mesh still loading, or an entity that draws nothing) ⇒ admitted. A missing bound
/// is a *timing* gap, not a visibility verdict, and blanking on it would flicker the world as
/// tiles stream in.
pub(crate) fn admits(&self, gt: &GlobalTransform, aabb: Option<&Aabb>) -> bool {
match (self, aabb) {
(Self::Open, _) | (_, None) => true,
(Self::Windows(frusta), Some(aabb)) => {
let world_from_local = gt.affine();
frusta
.iter()
.any(|f| f.intersects_obb(aabb, &world_from_local, true, true))
}
}
}
/// The same test against a WORLD-space bounding **sphere** — the form the particle lane speaks
/// (`EmitterFade`'s `[rec+0x68]` fade sphere is the owner doodad's bound, and an emitter has no
/// mesh of its own to carry an `Aabb`). The sphere's axis-aligned box is what gets tested, which
/// is looser than the sphere and therefore never hides something a sphere test would admit.
pub(crate) fn admits_sphere(&self, center: Vec3, radius: f32) -> bool {
let aabb = Aabb::from_min_max(center - Vec3::splat(radius), center + Vec3::splat(radius));
self.admits(&GlobalTransform::IDENTITY, Some(&aabb))
}
fn frusta(&self) -> usize {
match self {
Self::Open => 0,
Self::Windows(f) => f.len(),
}
}
}
/// What [`apply_exterior_cull`] reads per object.
type UnownedScene = (
&'static GlobalTransform,
Option<&'static Aabb>,
&'static mut Visibility,
);
/// …and the objects it is allowed to write: exterior scene that nothing else owns. `ModelPart` and
/// `WmoGroupVis` both mean "the model-visibility authority writes this one".
type UnownedSceneFilter = (
With<ExteriorScene>,
Without<crate::debug_panel::ModelPart>,
Without<crate::wmo_portal::WmoGroupVis>,
);
/// Hide every [`ExteriorScene`] object that no window admits — **only those with no other
/// `Visibility` owner**. A model submesh (`ModelPart`) and a WMO group piece (`WmoGroupVis`) are
/// written by [`crate::debug_panel`]'s model-visibility authority, which composes the toggles, the
/// far-clip wall, the distance fade and the portal PVS; a second writer here does not "also cull"
/// them, it *overwrites* all of that every frame (decision 0784). So this system now owns exactly
/// what it is the sole authority for: terrain cells and the WDL far band.
fn apply_exterior_cull(
windows: Res<ExteriorWindows>,
cam: Query<(&GlobalTransform, &Projection), With<WorldCamera>>,
mut scene: Query<(&GlobalTransform, Option<&Aabb>, &mut Visibility), With<ExteriorScene>>,
mut scene: Query<UnownedScene, UnownedSceneFilter>,
mut verdict: ResMut<ExteriorCullVerdict>,
) {
let set = |vis: &mut Visibility, target: Visibility| {
if *vis != target {
*vis = target;
}
};
let rects = match &*windows {
// Outside leg: the ordinary frustum is the window, so stand down entirely and let Bevy's own
// frustum cull do its job. Writing `Inherited` (not `Visible`) hands each object back to
// whatever else owns it.
ExteriorWindows::Unrestricted => {
for (_, _, mut vis) in &mut scene {
set(&mut vis, Visibility::Inherited);
}
return;
}
ExteriorWindows::Windows(rects) => rects,
};
let Some((cam_t, proj)) = cam.iter().next() else {
return; // no camera yet — leave last frame's verdict rather than blanking the world
};
let clip_from_world = proj.get_clip_from_view() * cam_t.to_matrix().inverse();
let frusta: Vec<Frustum> = rects
.iter()
.filter_map(|r| window_frustum(*r, &clip_from_world))
.collect();
// Zero surviving windows is the sealed-room case and draws nothing — `0x681199`'s skip. This is
// the one branch that must NOT fail open: failing open here is the bug being fixed.
let gate = ExteriorGate::build(&windows, cam.iter().next());
let (mut tested, mut hidden, mut unbounded) = (0usize, 0usize, 0usize);
for (gt, aabb, mut vis) in &mut scene {
let admitted = match aabb {
// Whole-AABB, per object — the reference's `0x682f40`. Not per primitive: a doodad
// straddling the doorway edge draws whole, and the interior geometry cuts its silhouette.
Some(aabb) => {
let world_from_local = gt.affine();
frusta
.iter()
.any(|f| f.intersects_obb(aabb, &world_from_local, true, true))
}
// No Aabb yet (mesh still loading): admit it. A missing bound is a *timing* gap, not a
// visibility verdict, and blanking on it would flicker the world as tiles stream in.
None => true,
};
tested += 1;
unbounded += usize::from(aabb.is_none());
let admitted = gate.admits(gt, aabb);
hidden += usize::from(!admitted);
set(
&mut vis,
if admitted {
@@ -187,6 +279,16 @@ fn apply_exterior_cull(
},
);
}
*verdict = ExteriorCullVerdict {
windows: match &*windows {
ExteriorWindows::Unrestricted => None,
ExteriorWindows::Windows(rects) => Some(rects.len()),
},
frusta: gate.frusta(),
tested,
hidden,
unbounded,
};
}
#[cfg(test)]
@@ -238,6 +340,40 @@ mod tests {
);
}
/// **The granularity of the tagged object is half the cull** (decision 0780). This test is the
/// same patch of ground twice: once as the 33.333 yd MCNK cell it is drawn as now, once as the
/// 533.333 yd ADT tile it used to be merged into. The cell is rejected; the tile is admitted,
/// because a tile is drawn around the camera and so reaches the doorway's side of the view no
/// matter which side the ground is on. Nothing in `apply_exterior_cull` can fix that — it is
/// decided by what the spawner tags — which is why re-merging terrain into a per-tile mesh
/// would silently restore "I can see the hillside through the wall" with every test still green.
#[test]
fn a_narrow_window_rejects_a_chunk_of_ground_but_never_a_whole_tile_of_it() {
// A doorway on the RIGHT of the view; the ground of interest is 200 yd to the LEFT.
let f = window_frustum([0.6, -1.0, 1.0, 1.0], &clip_from_world()).expect("a right doorway");
let ground = Vec3::new(-200.0, -2.0, -200.0);
const CELL: f32 = 33.333 / 2.0;
let cell = Aabb::from_min_max(
ground - Vec3::new(CELL, 0.5, CELL),
ground + Vec3::new(CELL, 0.5, CELL),
);
assert!(
!f.intersects_obb(&cell, &Affine3A::IDENTITY, true, true),
"an MCNK cell to the left must not come in through a doorway on the right"
);
// The ADT tile that CONTAINS that cell. The camera stands on it, so it spans both sides of
// the view — half a kilometre of ground admitted by a doorway that shows a hundredth of it.
const TILE: f32 = 533.333 / 2.0;
let tile = Aabb::from_min_max(Vec3::new(-TILE, -2.5, -TILE), Vec3::new(TILE, -1.5, TILE));
assert!(
f.intersects_obb(&tile, &Affine3A::IDENTITY, true, true),
"a tile-sized box reaches the doorway's side of the view — it can never be culled, and \
that is exactly why terrain is spawned per chunk"
);
}
/// The reference rejects a window narrower than a hundredth of the screen (`[0x8029d0]`); a
/// collapsed rect must produce no frustum at all rather than a degenerate one whose planes are
/// NaN (which `intersects_obb` would answer arbitrarily).
+6
View File
@@ -22,12 +22,14 @@
mod area;
mod area_trigger;
mod art_scope;
mod asset_churn;
mod assets;
mod bgwin;
mod billboard;
mod blob_shadow;
mod bowstring;
mod build_id;
mod capture;
mod char_create;
mod char_select;
@@ -461,6 +463,10 @@ fn main() -> AppExit {
.add_plugins(PerfPlugin)
.add_plugins(hover_log::HoverLogPlugin)
.add_plugins(asset_churn::AssetChurnPlugin)
// Within-map art residency (decision 0793): the dedup caches expire by DISTANCE, so a
// long flight inside one map stops ratcheting. Registered before AssetPlugin only so the
// census resource exists for anything that reads it at startup; it needs no ordering.
.add_plugins(art_scope::ArtScopePlugin)
.add_plugins(particles::census::plugin)
// Foundation: opens the patch chain + inserts WorldAssets/RenderConfig (AssetSet::Open), which
// every other subsystem's startup runs after.
+8 -7
View File
@@ -3,8 +3,6 @@
//! every M2/WMO instance: doodads, WMO buildings, creatures, and GameObjects. Deduped per owner by
//! `(texture, blend, sidedness, kind, fade-variant)` so instances sharing a look batch into one draw.
use std::collections::HashMap;
use benilla_formats::{ModelBlend, WmoBatchClass};
use bevy::asset::AssetId;
use bevy::pbr::ExtendedMaterial;
@@ -107,8 +105,11 @@ impl ShadeSel {
}
/// A material-dedup cache. Each model-spawning subsystem (terrain doodads/WMOs, streamed entities)
/// keeps its own so its handles drop with it.
pub(crate) type MaterialCache = HashMap<MatKey, Handle<WowModelMaterial>>;
/// keeps its own so its handles drop with it — and, since decision 0793, so its entries expire by
/// **distance** ([`crate::art_scope::SpatialCache`]) instead of living until the next map change. A
/// cache nobody sweeps (the `Local<MaterialCache>`s in the glue booth and the portrait bake) behaves
/// exactly as it did: a plain dedup map.
pub(crate) type MaterialCache = crate::art_scope::SpatialCache<MatKey, Handle<WowModelMaterial>>;
/// Build (or fetch the deduped) [`WowModelMaterial`] for a model batch: a `StandardMaterial` base
/// carrying the texture/alpha/cull, plus the `WowModelExt` shared-light extension. `fade_variant` is
@@ -161,8 +162,8 @@ pub(crate) fn model_material(
sidn,
window,
};
if let Some(h) = cache.get(&key) {
return h.clone();
if let Some(h) = cache.fetch(&key) {
return h;
}
// Additive batches (M2 glow cards) ADD their colour to the framebuffer — so the warm glow isn't
// muted by the (cool, at night) background bleeding through. They go in the transparent pass
@@ -280,7 +281,7 @@ pub(crate) fn model_material(
// zw = the batch's live **UV-animation offset** (decision 0130 phase 3, wow-re
// `m2-texanim-uv`: the real client adds the sampled translation to the stage UVs —
// translation is un-pivoted, and no placed doodad uses rotation/scaling). Seeded at
// t = 0 here; `doodad_anim::tick_uv_anim_materials` re-samples it per frame on the
// t = 0 here; `doodad_anim::tick_anim_materials` re-samples it per drawn frame on the
// shared clock (frozen in captures).
sun_scale: {
let uv0 = uv_anim.map_or([0.0, 0.0], |a| a.sample(0.0));
+32 -2
View File
@@ -18,7 +18,7 @@ use bevy::prelude::*;
use benilla_protocol::guid;
use crate::net::{ClientCommand, NetCommands};
use crate::net::{ClientCommand, NetCommands, ObjectStore};
/// The name cache: players by guid, creatures by template entry, plus the in-flight ask-once sets.
/// Filled by the net bridge; read (and query-triggered) through [`Self::resolve`].
@@ -54,7 +54,8 @@ pub(crate) struct CreatureRecord {
pub(crate) name: String,
pub(crate) subname: Option<String>,
pub(crate) creature_type: u32,
/// Elite rank 0..4 — the tooltip rank word `{"", Elite, Elite, Boss, ""}`.
/// Elite rank 0..4 **as the template declares it** — read it through [`gated_rank`], never
/// directly, unless you specifically want the ungated template value.
pub(crate) rank: u32,
/// Template type flags — bit `0x10` (HIDE_FACTION_TOOLTIP) suppresses the tooltip's
/// faction-name line (the client's `0x612610` gate).
@@ -64,6 +65,35 @@ pub(crate) struct CreatureRecord {
pub(crate) racial_leader: bool,
}
/// The client's **creature-rank getter**, `0x605620` — 33 bytes, and the single source every rank
/// reader in the real client goes through (decision 0782):
///
/// ```text
/// 605620: mov eax,[ecx+0xb30] ; the cached creature record
/// test eax,eax / je → 0 ; not queried yet
/// mov ecx,[ecx+0x110] ; the unit descriptor block
/// mov edx,[ecx+0x214] ; UNIT_FIELD_PETNUMBER
/// test edx,edx / jne → 0 ; somebody's pet or charm
/// mov eax,[eax+0x20] ; record->rank
/// ```
///
/// Two gates, and the pet gate is the one nobody expects: a **charmed or enslaved unit reports rank
/// 0** whatever its template says. Because all three of the client's rank consumers call this one
/// getter — `UnitClassification` (the target frame's elite/rare border), the unit tooltip's
/// ELITE/BOSS word, and `UnitLevel`'s world-boss `−1` (the target frame *and* nameplate skull) — a
/// mind-controlled world boss loses its dragon, its BOSS word and its skull together. Applying the
/// gate per-reader is how those three silently drift apart, so this function is the only place any
/// of them may read a rank from.
///
/// `store: None` (no descriptor streamed) cannot prove a pet number, so it reads as not-a-pet —
/// matching the client, whose descriptor block is zero-initialized.
pub(crate) fn gated_rank(rec: Option<&CreatureRecord>, store: Option<&ObjectStore>) -> u32 {
match rec {
Some(rec) if !store.is_some_and(|s| s.0.unit_is_pet_or_charm()) => rec.rank,
_ => 0,
}
}
impl NameCache {
/// The name for `guid`, if known. On a miss, sends the right query (once per guid/entry per
/// connection) and returns `None` — call again after the answer lands. A guid family that has no
+84 -7
View File
@@ -367,22 +367,35 @@ pub struct EmitterFade {
/// The owner doodad's world bbox centre — the fade measures horizontal distance to this
/// (matching the doodad's own gate in `debug_panel::visibility`), not the emitter position.
pub center: Vec3,
/// The WMO placement whose doodad set this emitter's owner belongs to; `None` for an ADT map
/// doodad. Read only by the exterior-window term: a prop of the building the camera is standing
/// in is not exterior scene to itself, so its candle keeps burning (decision 0784's exemption,
/// which the owner submesh gets through `WmoGroupVis` — an emitter carries no such component,
/// so it carries the instance directly).
pub instance: Option<Entity>,
}
impl EmitterFade {
/// The **draw-set admission rule** in one place: is the owner doodad in the frame's scene
/// worklist, and therefore does its emitter tick and draw this frame?
///
/// Three terms, all against the owner's fade sphere (the reference's `[rec+0x68]`):
/// Four terms, all against the owner's fade sphere (the reference's `[rec+0x68]`):
/// 1. the radius-tiered distance fade hasn't reached zero (`FUN_00683f80`),
/// 2. the sphere is inside the far-clip wall ([`crate::view::within_farclip`]),
/// 3. `lateral_in_frustum` — the caller's frustum sphere test on the side/near planes.
/// 3. `lateral_in_frustum` — the caller's frustum sphere test on the side/near planes,
/// 4. `exterior_admitted` — the caller's exterior-window test (decision 0786).
///
/// Term 2 is the one bug B39 was missing (decision 0678), and it cannot be folded into term 1:
/// [`crate::model_fade::doodad_fade_alpha`] returns a flat `1.0` for any owner bigger than
/// [`crate::model_fade::NEVER_FADE_RADIUS`], so for exactly the props that carry the big
/// effects — braziers, bonfires, portal frames — term 1 admits at *every* distance.
///
/// Term 4 is the same omission one layer out. The reference links a doodad into the worklist
/// through the per-window populate walk `0x683700` and ticks its particles as part of that
/// model's animate step — so an unlinked doodad emits nothing. We gated the doodad's *mesh* and
/// left its emitter running, which is a mushroom's spore cloud hanging in a sealed dungeon (the
/// director's report: `plaguelandmushroom01`, 3 emitters, 59.7 yd through a wall).
///
/// Pure and total so the rule is pinned by tests without an ECS (the `model_fade` pattern).
pub fn in_draw_set(
&self,
@@ -390,12 +403,28 @@ impl EmitterFade {
cam_fwd: Vec3,
farclip: f32,
lateral_in_frustum: bool,
exterior_admitted: bool,
) -> bool {
let (dx, dz) = (self.center.x - cam_pos.x, self.center.z - cam_pos.z);
let horiz = (dx * dx + dz * dz).sqrt();
crate::model_fade::doodad_fade_alpha(self.radius, horiz) > 0.0
&& crate::view::within_farclip(farclip, cam_pos, cam_fwd, self.center, self.radius)
&& lateral_in_frustum
&& exterior_admitted
}
/// Term 4 for this emitter, given the frame's gate and the placement the camera is inside. Kept
/// beside the rule so both callers — the particle sim and the meshless-host anim gate — ask the
/// same question instead of each re-deriving the exemption.
pub fn exterior_admitted(
&self,
gate: &crate::exterior_cull::ExteriorGate,
camera_instance: Option<Entity>,
) -> bool {
if self.instance.is_some() && self.instance == camera_instance {
return true; // a prop of the building the camera stands in — not exterior to itself
}
gate.admits_sphere(self.center, self.radius)
}
}
@@ -682,8 +711,9 @@ pub(crate) mod tests {
EmitterFade {
radius,
center: Vec3::new(0.0, 0.0, -depth),
instance: None,
}
.in_draw_set(Vec3::ZERO, Vec3::NEG_Z, farclip, true)
.in_draw_set(Vec3::ZERO, Vec3::NEG_Z, farclip, true, true)
}
/// **The owner-last rung, pinned** (decisions 0719/0721), through the wrapper the renderer
@@ -741,9 +771,52 @@ pub(crate) mod tests {
let f = EmitterFade {
radius: 2.0,
center: Vec3::new(0.0, 0.0, -60.0),
instance: None,
};
assert!(f.in_draw_set(Vec3::ZERO, Vec3::NEG_Z, 777.0, true));
assert!(!f.in_draw_set(Vec3::ZERO, Vec3::NEG_Z, 777.0, false));
assert!(f.in_draw_set(Vec3::ZERO, Vec3::NEG_Z, 777.0, true, true));
assert!(!f.in_draw_set(Vec3::ZERO, Vec3::NEG_Z, 777.0, false, true));
// …and the exterior-window term is ANDed the same way (0786): a doodad no portal window
// admits is not in the worklist, so its emitter neither ticks nor draws.
assert!(!f.in_draw_set(Vec3::ZERO, Vec3::NEG_Z, 777.0, true, false));
}
/// **The exemption, on the emitter lane.** A sealed room (no windows) admits no exterior scene
/// — but the props of the building the camera is standing IN are not exterior to it, and their
/// flames must keep burning. Getting this wrong does not look like a cull bug; it looks like
/// walking into an inn snuffs its fireplace, which is why it is pinned rather than argued.
///
/// The third case is the reported one: an ADT map doodad carries no building at all, so a
/// sealed room must stop it (`plaguelandmushroom01`'s spore cloud, seen through a wall).
#[test]
fn a_sealed_room_keeps_its_own_props_burning_and_stops_everything_else() {
use crate::exterior_cull::ExteriorGate;
let mut w = World::new();
let (here, elsewhere) = (w.spawn_empty().id(), w.spawn_empty().id());
let sealed = ExteriorGate::Windows(Vec::new());
let fade = |instance| EmitterFade {
radius: 2.0,
center: Vec3::new(0.0, 0.0, -60.0),
instance,
};
assert!(
fade(Some(here)).exterior_admitted(&sealed, Some(here)),
"a prop of the building the camera is in must keep emitting"
);
assert!(
!fade(Some(elsewhere)).exterior_admitted(&sealed, Some(here)),
"another building's prop is exterior scene, and the room is sealed"
);
assert!(
!fade(None).exterior_admitted(&sealed, Some(here)),
"an ADT map doodad belongs to no building — the reported mushroom"
);
// Outdoors the gate stands down and every one of them emits again.
for instance in [Some(here), Some(elsewhere), None] {
assert!(fade(instance).exterior_admitted(&ExteriorGate::Open, Some(here)));
}
// …and with no room claimed at all, `None == None` must NOT read as "my own building".
assert!(!fade(None).exterior_admitted(&sealed, None));
}
/// The emitter gate and the owner mesh's own cull must agree on the boundary — they read the
@@ -768,8 +841,12 @@ pub(crate) mod tests {
"precondition: this owner never size-fades"
);
let mesh_drawn = crate::view::within_farclip(farclip, cam, fwd, center, radius);
let emitter_drawn =
EmitterFade { radius, center }.in_draw_set(cam, fwd, farclip, true);
let emitter_drawn = EmitterFade {
radius,
center,
instance: None,
}
.in_draw_set(cam, fwd, farclip, true, true);
assert_eq!(
mesh_drawn, emitter_drawn,
"wall disagreement at farclip {farclip} depth {depth} radius {radius}"
+3 -1
View File
@@ -201,7 +201,9 @@ pub struct EffectDraw {
/// The rasterizer `DepthBiasState` constant for this draw's pipeline (0733 §4): the
/// coplanar decals keep the depth-offset half their materials carried (projected verts are
/// exact sub-pieces of drawn ground — clip-interpolated vertices land within ULPs of it);
/// everything free-floating passes 0.
/// everything free-floating passes 0. Nonzero ALSO selects the decal transform (0781):
/// the draw's verts skip the cam-relative rebase and run the world-mesh `clip_from_world`,
/// so the depth tie the bias settles is against the same arithmetic.
pub raster_bias: i32,
/// Vertex range in [`EffectQuads::verts`] (a multiple of 4 for quads, 3 for tris).
pub range: Range<u32>,
+141 -6
View File
@@ -13,7 +13,9 @@
//! construction;
//! - **prepare** (after `PhaseSort`) rebases each draw's vertices against its target view's
//! camera position (0733 §2 — the `ViewUniform.world_position` source value, so the shader's
//! reconstruction is exact), uploads them, then walks the sorted items building the frame's
//! reconstruction is exact; decal draws are exempt and stay absolute for the
//! `DECAL_WORLD_CLIP` mesh-matrix transform, 0781), uploads them, then walks the sorted
//! items building the frame's
//! index stream in draw order and **merging sort-adjacent items that share (pipeline,
//! texture, light, fog)** into one draw call. The merge rides bevy's own sorted-phase
//! contract: the phase renderer advances by `batch_range.len()` (`render_phase/mod.rs:1487`),
@@ -178,7 +180,9 @@ pub struct EffectPipelineKey {
blend: EffectBlend,
/// The rasterizer depth-bias constant (the decal family's coplanarity settle; 0 for
/// free-floating geometry). Three values exist ({0, 4096, 8192}), so the key space stays
/// small.
/// small. Nonzero ALSO selects the `DECAL_WORLD_CLIP` transform (0781): a coplanar decal
/// keeps absolute verts and runs the mesh path's `clip_from_world`, so its depth ties
/// against the drawn ground within the bias.
raster_bias: i32,
}
@@ -189,7 +193,8 @@ impl SpecializedRenderPipeline for EffectPipeline {
let vertex_layout = VertexBufferLayout::from_vertex_formats(
VertexStepMode::Vertex,
vec![
// position (camera-relative — prepare rebased it; 0733 §2)
// position (camera-relative — prepare rebased it; 0733 §2. Decal draws are
// the exception: absolute world-space, transformed by `clip_from_world`; 0781)
VertexFormat::Float32x3,
// uv
VertexFormat::Float32x2,
@@ -239,6 +244,13 @@ impl SpecializedRenderPipeline for EffectPipeline {
),
};
let mut shader_defs = vec![blend_def.into()];
// The decal family (nonzero raster bias ⇔ ground-coplanar): absolute verts through
// `clip_from_world` — the same matrix, hence the same rounding, as the world-mesh
// shaders whose depth the bias must settle against. `prepare_effects` skips these
// draws' cam-relative rebase on the same predicate (decision 0781).
if key.raster_bias != 0 {
shader_defs.push("DECAL_WORLD_CLIP".into());
}
// `$WOW_PARTICLE_FLAT` — the fragment-input A/B (B16): solid magenta, no inputs.
if std::env::var_os("WOW_PARTICLE_FLAT").is_some() {
shader_defs.push("WOW_PARTICLE_FLAT".into());
@@ -398,9 +410,9 @@ type RunKey = (
);
/// After `PhaseSort`: rebase each draw's vertices against its target view's camera position
/// (0733 §2), upload them, build the frame's index stream in sorted-item order while merging
/// sort-adjacent compatible items into single draws, and write the canonical fog-params rows
/// once.
/// (0733 §2; decal draws are exempt — 0781), upload them, build the frame's index stream in
/// sorted-item order while merging sort-adjacent compatible items into single draws, and write
/// the canonical fog-params rows once.
#[allow(clippy::too_many_arguments)] // one render system's full input set
fn prepare_effects(
device: Res<RenderDevice>,
@@ -425,6 +437,12 @@ fn prepare_effects(
);
}
for draw in &meta.draws {
// Decal draws stay ABSOLUTE: their pipeline transforms through `clip_from_world`
// (`DECAL_WORLD_CLIP`, the same `raster_bias != 0` predicate — decision 0781), so a
// rebase here would shift them a full camera-position off.
if draw.raster_bias != 0 {
continue;
}
let Some(cam) = cams.get(&MainEntity::from(draw.cam)) else {
continue;
};
@@ -446,6 +464,13 @@ fn prepare_effects(
// `merged_index .. merged_index + run_len` — the phase renderer draws the opener once and
// advances past the absorbed items (`render_phase/mod.rs:1487`).
let effect_fn = draw_functions.read().id::<DrawEffects>();
// `$WOW_EFFECT_TRACE` — the lane's own phase probe (the WOW_PHASE shape, decision 0665,
// asked of effect items): during the merge walk, record each BLOB-SHADOW item (Tris
// topology + the shadow's 4096 raster bias) with its sort distance and appended index
// range, then log the phase totals. Splits "never pushed / never queued / merged away /
// submitted but the GPU state ate it" — the gap no pixel reading can see into.
let trace = std::env::var_os("WOW_EFFECT_TRACE").is_some();
let mut trace_lines: Vec<String> = Vec::new();
meta.indices.clear();
meta.merged.clear();
let mut walked: Vec<bevy::render::view::RetainedViewEntity> = Vec::new();
@@ -503,6 +528,16 @@ fn prepare_effects(
}
}
let index_end = meta.indices.len() as u32;
if trace && draw.topology == EffectTopology::Tris && draw.raster_bias == 4096 {
trace_lines.push(format!(
" item {i}: shadow anchor {:.1?}, dist {:.1}, verts {}, indices \
{index_start}..{index_end}, tex {:?}",
draw.anchor,
item.distance,
draw.range.len(),
draw.texture,
));
}
let key: RunKey = (
pipeline,
draw.texture,
@@ -532,6 +567,15 @@ fn prepare_effects(
}
close(&mut phase.items, &mut open, meta.merged.len());
}
if trace {
info!(
"effect trace: {} draws, {} merged, {} indices; shadow items:\n{}",
meta.draws.len(),
meta.merged.len(),
meta.indices.len(),
trace_lines.join("\n")
);
}
if !meta.indices.is_empty() {
meta.indices.write_buffer(&device, &queue);
}
@@ -593,6 +637,12 @@ 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() {
info!(
"effect bind: new group for tex {:?} ({}x{})",
draw.texture, image.size.width, image.size.height
);
}
let light_buf = draw.light.as_ref().unwrap_or(&light.0);
bind_groups.images.insert(
key,
@@ -668,6 +718,16 @@ impl<P: PhaseItem> RenderCommand<P> for DrawEffectBatch {
pass.set_bind_group(1, image_bind_group, &[offsets[draw.fog.slot() as usize]]);
pass.set_vertex_buffer(0, vertices.slice(..));
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()) {
info!(
"effect draw: merged {} indices {:?}",
item.batch_range().start,
draw.index_range
);
}
pass.draw_indexed(draw.index_range.clone(), 0, 0..1);
RenderCommandResult::Success
}
@@ -702,3 +762,78 @@ impl Plugin for EffectLanePlugin {
);
}
}
#[cfg(test)]
mod tests {
use bevy::math::{Mat3, Mat4, Vec3};
/// One rasterizer constant-bias unit for a `Depth32Float` value: `2^(e−23)`, the
/// next-representable step at that depth's exponent (the Vulkan/Metal float-depth scale
/// the pipeline's `DepthBiasState::constant` is multiplied by).
fn bias_unit(depth: f32) -> f32 {
f32::from_bits(depth.to_bits() + 1) - depth
}
/// The world-mesh route: `clip_from_world × p` — `position_world_to_clip`
/// (terrain.wgsl / wow_model.wgsl), and `DECAL_WORLD_CLIP`'s transform.
fn depth_world(clip_from_world: &Mat4, p: Vec3) -> f32 {
let c = *clip_from_world * p.extend(1.0);
c.z / c.w
}
/// The cam-relative route (0733 §2): prepare's rebase, then rotation + `clip_from_view`.
fn depth_cam_relative(
view_from_world: &Mat4,
clip_from_view: &Mat4,
p: Vec3,
cam: Vec3,
) -> f32 {
let view_pos = Mat3::from_mat4(*view_from_world) * (p - cam);
let c = *clip_from_view * view_pos.extend(1.0);
c.z / c.w
}
/// 0781's premise, demonstrated in the two routes' exact arithmetic shapes: give both the
/// SAME ground vertex at WoW-scale coordinates, and the cam-relative route (0733 §2)
/// disagrees with the world-mesh route by the ORDER OF THE WHOLE 4096-unit rasterizer bias
/// at close camera distances — pure arithmetic divergence spending the margin that exists
/// to settle real geometry deltas (the CPU-baked decal verts vs the GPU-transformed render
/// verts; GPU per-pipeline FMA differences push the route term higher still). The
/// same-matrix route spends zero on it by construction. Coplanarity is a relationship with
/// the receiver's own transform: a decal must ride the receiver's matrix.
#[test]
fn decal_depth_ties_only_through_the_mesh_matrix() {
// A WMO floor vertex at eastern-Kalimdor-scale coordinates (bevy = (−wow.y, wow.z,
// −wow.x); magnitudes of several thousand yards are ordinary).
let ground = Vec3::new(3807.13, 7.42, 7093.87);
let clip_from_view =
Mat4::perspective_infinite_reverse_rh(std::f32::consts::FRAC_PI_4, 16.0 / 9.0, 0.1);
let dir = Vec3::new(0.35, 0.55, 0.76).normalize();
let mut worst_route_over_bias = 0.0_f32;
for step in 0..80 {
// The zoom sweep: camera 1.5..41 yd out along a fixed off-axis orbit offset.
let d = 1.5 + step as f32 * 0.5;
let cam = ground + dir * d;
// Bevy's own construction: camera world matrix, general inverse, premultiplied
// product (`ExtractedView`) — the rounding path the mesh shaders actually see.
let world_from_view = Mat4::look_at_rh(cam, ground, Vec3::Y).inverse();
let view_from_world = world_from_view.inverse();
let clip_from_world = clip_from_view * view_from_world;
let mesh = depth_world(&clip_from_world, ground);
let bias = 4096.0 * bias_unit(mesh);
// The fix: same matrix, same input — bitwise the same depth, zero divergence.
assert_eq!(depth_world(&clip_from_world, ground), mesh);
let route =
(depth_cam_relative(&view_from_world, &clip_from_view, ground, cam) - mesh).abs();
worst_route_over_bias = worst_route_over_bias.max(route / bias);
}
// Measured 0.93× at d=1.5 on this sweep; the assertion keeps headroom for
// platform-to-platform rounding differences while still pinning the order.
assert!(
worst_route_over_bias > 0.5,
"cam-relative route divergence stayed far inside the bias \
(worst {worst_route_over_bias:.2}× across the sweep) — 0781's premise would be \
unfounded"
);
}
}
+14
View File
@@ -170,6 +170,11 @@ pub(super) fn simulate_particles(
tuning: Res<ParticleTuning>,
// The far-clip wall — the emitter draw-set gate's depth bound (see the gate below).
view: Res<crate::view::ViewDistance>,
// …and the gate's exterior-window term (0786): from inside a WMO, a doodad outside is not in
// the frame's worklist, so its emitter must not tick. `camera_claim` carries the one exemption —
// a prop of the building the camera is standing in.
exterior_windows: Res<crate::wmo_portal::ExteriorWindows>,
camera_claim: Res<crate::wmo_portal::CameraInteriorClaim>,
mut commands: Commands,
cam: Query<(Entity, &GlobalTransform, &Frustum, &Camera, &Projection), With<WorldCamera>>,
// Owner reads (joints/units/roots — never emitter or child-draw entities): disjoint from
@@ -241,6 +246,11 @@ pub(super) fn simulate_particles(
// The far-clip wall's axis — the SAME forward `debug_panel::visibility` measures the owner
// doodad's own cull along, so an emitter and its owner cross the wall together.
let cam_fwd = Vec3::from(cam_tf.forward());
// The exterior gate, built once for the whole emitter walk — the same value the model
// visibility authority and `exterior_cull` ask (0784/0786: one spelling of the window test).
let exterior_gate =
crate::exterior_cull::ExteriorGate::build(&exterior_windows, Some((cam_tf, projection)));
let camera_instance = camera_claim.0.map(|c| c.room.instance);
// `$WOW_PARTICLE_DEPTHDUMP` (B16): is this a dump frame? Decided once per run.
let dump_frame = super::depthdump::frame(time.elapsed_secs(), &mut dump_count);
@@ -296,6 +306,10 @@ pub(super) fn simulate_particles(
},
false,
),
// …and the exterior-window term (0786): standing in a WMO interior, a doodad
// outside is not in the worklist at all, so its emitter neither ticks nor draws —
// which the mesh gate already knew and this one did not.
f.exterior_admitted(&exterior_gate, camera_instance),
);
if !in_set {
// Frozen: the pool writes no quads this frame (the shared stream is cleared
+79 -8
View File
@@ -424,20 +424,40 @@ fn frame_graph(ui: &mut egui::Ui, stats: &FrameStats) {
));
}
/// The FPS JOURNAL (`WOW_FPS_JOURNAL=<csv path>`): once a second, append
/// `t, x, y, z, mean_ms, p95_ms, streamed, entities` (player position in raw WoW coords) — the
/// "where does it dip" instrument for a director-driven run. They play normally; the journal
/// turns "it drops in the Dwarven District" into coordinates a headless probe can tele straight
/// back to. Negligible cost: one line of IO a second, samples reused from [`FrameStats`].
/// The FPS JOURNAL (`WOW_FPS_JOURNAL=<csv path>`): once a second, append one row of where the
/// player is, what the frame cost, and **what is resident** (see [`JOURNAL_HEADER`] for the column
/// order, written into every fresh file) — the "where does it dip" instrument for a
/// director-driven run. They play normally; the journal turns "it drops in the Dwarven District"
/// into coordinates a headless probe can tele straight back to. Negligible cost: one line of IO a
/// second, samples reused from [`FrameStats`].
///
/// The residency columns make it the **leak curve** instrument too (B131): `FPS_PROBE`'s residency
/// meter samples once per run, which can only compare two runs at one point each — it cannot tell
/// "grows with distance streamed" from "grows with time elapsed", and cannot show *where* on a
/// route the cost arrives. A per-second row of `cpu_ms` beside `mats/images/uv/tint` plots the
/// per-frame cost directly against residency along one continuous leg, on the same time axis as
/// the position — so a same-map traverse (no `MapChange`, so no map-scoped eviction) shows its
/// accumulation as a slope instead of a before/after pair.
pub(crate) struct FpsJournalPlugin;
/// The journal's column order, written as the first line of a fresh file. Appended-to files keep
/// whatever header they were created with — the columns only ever grow at the end.
const JOURNAL_HEADER: &str = "t,x,y,z,mean_ms,p95_ms,streamed,entities,cpu_ms,mats,meshes,images,\
m2,uv,tint,pmat,emat,skin,cmat,tex,cgeo,evicted,fx,fy,fz\n";
impl Plugin for FpsJournalPlugin {
fn build(&self, app: &mut App) {
let path = std::env::var("WOW_FPS_JOURNAL").unwrap_or_default();
// The header goes in exactly once, at creation: the rows are appended for the life of the
// run (and across runs, deliberately — a journal accumulates legs).
if !path.is_empty() && !std::path::Path::new(&path).exists() {
let _ = std::fs::write(&path, JOURNAL_HEADER);
}
app.insert_resource(FpsJournal {
path,
window: Vec::new(),
last_flush: 0.0,
cpu_at_flush: process_cpu_secs(),
})
.add_systems(Update, journal_fps);
}
@@ -448,6 +468,33 @@ struct FpsJournal {
path: String,
window: Vec<f32>,
last_flush: f32,
/// Process CPU seconds at the previous flush — the row's `cpu_ms` is this second's CPU cost
/// per frame, the load-robust half of the measurement (see [`process_cpu_secs`]).
cpu_at_flush: Option<f64>,
}
/// The journal's residency columns, grouped because `journal_fps` is near Bevy's system-param
/// arity limit.
///
/// The `Assets<T>` counts are the totals — what the process holds. The [`ArtCensus`] half is the
/// same population **broken down by the cache that holds it** (decision 0793), which is what turns
/// "materials are growing" into a named holder in one row instead of a run-length probe. `evicted`
/// is the running total dropped by distance: on a same-map traverse it was structurally zero before
/// 0793, because nothing but a `MapChange` evicted anything (0729).
#[derive(bevy::ecs::system::SystemParam)]
struct JournalResidency<'w> {
mats: Res<'w, Assets<crate::terrain::WowModelMaterial>>,
meshes: Res<'w, Assets<Mesh>>,
images: Res<'w, Assets<bevy::image::Image>>,
m2: Res<'w, Assets<benilla_assets::M2Model>>,
uv_reg: Res<'w, crate::doodad_anim::UvAnimMaterials>,
tint_reg: Res<'w, crate::doodad_anim::TintAnimMaterials>,
art: Res<'w, crate::art_scope::ArtCensus>,
/// The **view focus** — where art is actually being asked for. Distinct from the row's `x,y,z`,
/// which is the avatar: through a detached free-fly the body stands still while the camera covers
/// kilometres, so on that leg the position columns describe nothing that is happening. The
/// director's first run was exactly that leg, and reading it needed this column.
scope: Res<'w, crate::art_scope::ArtScopeState>,
}
fn journal_fps(
@@ -456,6 +503,7 @@ fn journal_fps(
player: Option<Res<crate::player::Player>>,
streamed: Query<(), With<crate::net::NetEntity>>,
entities: Query<()>,
residency: JournalResidency,
) {
let now = time.elapsed_secs();
journal.window.push(time.delta_secs() * 1000.0);
@@ -475,14 +523,37 @@ fn journal_fps(
.filter(|p| p.active)
.map(|p| benilla_assets::coords::bevy_to_wow(p.pos))
.unwrap_or([0.0; 3]);
let line = format!(
"{now:.1},{:.1},{:.1},{:.1},{mean:.2},{p95:.2},{},{}\n",
// CPU per frame over this second — the number the reporter's "CPU %" compares against, and
// the one that does not move with whatever else is compiling on this machine.
let cpu_now = process_cpu_secs();
let cpu_ms = match (journal.cpu_at_flush, cpu_now) {
(Some(t0), Some(t1)) => format!("{:.2}", (t1 - t0) * 1000.0 / v.len() as f64),
_ => String::new(),
};
journal.cpu_at_flush = cpu_now;
let mut line = format!(
"{now:.1},{:.1},{:.1},{:.1},{mean:.2},{p95:.2},{},{},{cpu_ms},{},{},{},{},{},{}",
pos[0],
pos[1],
pos[2],
streamed.iter().len(),
entities.iter().len()
entities.iter().len(),
residency.mats.len(),
residency.meshes.len(),
residency.images.len(),
residency.m2.len(),
residency.uv_reg.0.len(),
residency.tint_reg.0.len(),
);
// The per-cache breakdown, in `ArtSlot::ALL` order — which IS the header's column order.
for slot in crate::art_scope::ArtSlot::ALL {
line.push_str(&format!(",{}", residency.art.live(slot)));
}
line.push_str(&format!(",{}", residency.art.dropped_total()));
match residency.scope.focus() {
Some(f) => line.push_str(&format!(",{:.1},{:.1},{:.1}\n", f[0], f[1], f[2])),
None => line.push_str(",,,\n"),
}
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
+7 -2
View File
@@ -18,8 +18,13 @@
use bevy::prelude::*;
use super::Player;
use crate::capture::ProbeClock;
/// One scripted turn: `rate` (rad/s), when it starts, and how long it runs (seconds, in-world clock).
/// One scripted turn: `rate` (rad/s), when it starts, and how long it runs — **wall-clock seconds**
/// ([`ProbeClock`], decision 0789). Both halves want real time here, for one reason: `90°/s for 6 s`
/// has to produce 540° of facing stream whatever the frame rate did, and the stream it feeds is
/// itself paced on the real clock (0615). On the virtual clock the schedule would drift *and* every
/// hitch would silently under-rotate the turn, since its delta is clamped to 250 ms.
struct Turn {
rate: f32,
at: f32,
@@ -66,7 +71,7 @@ pub(super) fn from_env() -> Option<ProbeLook> {
/// **before** the controller, so the frame that sees the new `face_yaw` is the frame that streams it.
pub(super) fn drive_probe_look(
probe: Res<ProbeLook>,
time: Res<Time>,
time: ProbeClock,
mut player: ResMut<Player>,
self_player: Query<(), With<crate::net::SelfPlayer>>,
) {
+5 -1
View File
@@ -34,6 +34,10 @@
//! one line per world entry when everything is fine, and it re-fires on every re-entry (a relog, a
//! `.character race` forced logout, a reconnect) because the state can have changed.
//!
//! The same audience — the reader of the log — is why [`crate::build_id::banner`] is registered
//! here: **which build produced this log** is the first thing a report from someone else's machine
//! has to establish, and one startup line puts it in the output they already paste.
//!
//! The other half is [`account_guard`] — the pre-connect check that a session running inside a
//! worktree pool slot is logging in as **its own** probe account. A vmangos login kicks whoever
//! holds the account, so the default `one` credentials fire the director out of their live session
@@ -94,7 +98,7 @@ pub(crate) struct PreflightPlugin;
impl Plugin for PreflightPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<Preflight>()
.add_systems(Startup, offline_notice)
.add_systems(Startup, (crate::build_id::banner, offline_notice))
.add_systems(
Update,
report_session.after(crate::schedule::WorldStage::Net),
+1 -1
View File
@@ -114,7 +114,7 @@ pub struct WowModelExt {
/// (shader-unread; `WowModelKey` reads it back for the per-batch depth bias). `zw` = the batch's
/// live **UV-animation offset** (decision 0130 phase 3): added to the stage UVs in
/// `wow_model.wgsl`; `0` for static batches, re-sampled per frame by
/// `doodad_anim::tick_uv_anim_materials` for texanim batches (flowing waterfalls).
/// `doodad_anim::tick_anim_materials` for texanim batches (flowing waterfalls).
#[uniform(100)]
pub sun_scale: Vec4,
/// Per-material **RGB tint** (`xyz`): the M2Color colour multiplied into the albedo exactly
+41 -13
View File
@@ -327,6 +327,11 @@ impl Plugin for TerrainPlugin {
// would leave the whole streamed world resident and unowned — the gate above stops it
// being *maintained*, which is precisely what makes an abandoned world immortal.
.add_systems(OnExit(ClientState::InWorld), release_world)
// Within-map art residency (decision 0793): the placement material dedup expires by
// distance, so a long flight on one continent stops ratcheting. Ungated by
// `world_is_live` on purpose — a cache still holding the last world's art is exactly
// what should be draining while you sit at character select.
.add_systems(Update, scope_placement_art)
.add_systems(
Update,
// After the interior claim so the leaf override reads THIS frame's claim —
@@ -537,19 +542,30 @@ fn stream_terrain(
light_buf: shared_light.0.clone(),
},
});
// Terrain collider (decision 0009): a static trimesh from the SAME merged world-space verts that
// are drawn (so you stand on the visible ground), built off-thread (attached by
// `finish_colliders`) so a tile streaming in never hitches the frame. It rides the tile entity's
// lifecycle — gone when the tile despawns, no separate bookkeeping.
let collider_data = meshes.get(&adt.mesh).and_then(terrain_collider_data);
let mut tile_ent = commands.spawn((
Mesh3d(adt.mesh.clone()),
MeshMaterial3d(material),
Transform::IDENTITY, // the merged mesh is already in absolute world coords
// ADT terrain is exterior scene: from inside a WMO it draws only through a portal window
// (`0x683bf0`, fed solely by the per-window walk `0x682fa0` — see `crate::exterior_cull`).
crate::exterior_cull::ExteriorScene,
));
// Terrain collider (decision 0009): ONE static trimesh per tile, welded from the same decoded
// chunks the drawn cells are built from (so you stand on the visible ground), built off-thread
// (attached by `finish_colliders`) so a tile streaming in never hitches the frame. It rides the
// tile root's lifecycle — gone when the tile despawns, no separate bookkeeping.
let collider_data = terrain_collider_data(&adt.chunks);
// The tile ROOT draws nothing: it carries the collider and the surface roles, and its MCNK
// cells hang off it as children — one drawn object per 33.333 yd chunk. That is the unit the
// exterior-scene cull needs (decision 0780; a 533 yd slab the camera stands on intersects
// every portal window, so it could never be hidden), and it also gives Bevy's own frustum cull
// something smaller than half a kilometre to reject. Despawning the root takes the cells.
let mut tile_ent = commands.spawn((Transform::IDENTITY, Visibility::default()));
tile_ent.with_children(|cells| {
for mesh in &adt.chunk_meshes {
cells.spawn((
Mesh3d(mesh.clone()),
MeshMaterial3d(material.clone()),
Transform::IDENTITY, // chunk meshes are already in absolute world coords
// ADT terrain is exterior scene: from inside a WMO a cell draws only through a
// portal window (`0x683bf0`, fed solely by the per-window walk `0x682fa0` — see
// `crate::exterior_cull`).
crate::exterior_cull::ExteriorScene,
));
}
});
if let Some((verts, tris)) = collider_data {
// `GroundDecalSurface`: terrain receives the selection ring (see `crate::collision`).
// `PickOccluder`: terrain clamps the mouse pick (the reference's world trace).
@@ -788,6 +804,18 @@ fn drop_streamed_world(
placements.materials.clear();
}
/// Expire the placement material dedup by **distance** (decision 0793) — the within-map half of
/// [`drop_streamed_world`]'s clear. This is the big one: `mats` 2603 → 26786 over ten minutes on a
/// same-map tour (decision 0785) is overwhelmingly submesh materials of doodads and buildings whose
/// tiles unloaded thousands of yards ago. The placements themselves are already refcount-released;
/// only the dedup kept their materials — and through them their textures — alive.
fn scope_placement_art(mut scope: crate::art_scope::ArtScope, mut placements: ResMut<Placements>) {
scope.apply(
&mut placements.materials,
crate::art_scope::ArtSlot::PlaceMats,
);
}
/// Leaving `InWorld` (a `/logout` back to character select): release the world.
///
/// `map_dir`/`wdt` are cleared too, so the next entry reads as a fresh map rather than a resumed
+24 -18
View File
@@ -167,24 +167,30 @@ pub(crate) fn placement_collider_data(
Some((verts, tris))
}
/// The `(vertices, triangles)` for a terrain tile's static collider, pulled from its already-merged
/// world-space render mesh (so you stand on the visible ground). `None` if the mesh lacks float
/// positions or indices.
pub(super) fn terrain_collider_data(mesh: &Mesh) -> Option<(Vec<Vec3>, Vec<[u32; 3]>)> {
use bevy::mesh::{Indices, VertexAttributeValues};
let VertexAttributeValues::Float32x3(positions) = mesh.attribute(Mesh::ATTRIBUTE_POSITION)?
else {
return None;
};
let verts: Vec<Vec3> = positions.iter().map(|p| Vec3::from_array(*p)).collect();
let tris: Vec<[u32; 3]> = match mesh.indices()? {
Indices::U32(i) => i.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect(),
Indices::U16(i) => i
.chunks_exact(3)
.map(|c| [c[0] as u32, c[1] as u32, c[2] as u32])
.collect(),
};
Some((verts, tris))
/// The `(vertices, triangles)` for a terrain tile's static collider: **one** trimesh welded from the
/// tile's decoded MCNK chunks, in the same world space they are drawn in (so you stand on the visible
/// ground). `None` for a tile with no triangles at all.
///
/// Built from the decoded chunks rather than the render meshes because the tile no longer *has* one
/// merged mesh to read (decision 0780) — and one collider per 33 yd cell would be 256 parry builds
/// and 256 QBVHs where the ground is a single continuous surface. The mapping is the loader's,
/// verbatim: `wow_to_bevy` per position, hole-masked indices rebased onto the running vertex count.
pub(super) fn terrain_collider_data(
chunks: &[benilla_formats::ChunkMesh],
) -> Option<(Vec<Vec3>, Vec<[u32; 3]>)> {
let mut verts: Vec<Vec3> = Vec::new();
let mut tris: Vec<[u32; 3]> = Vec::new();
for chunk in chunks {
let base = verts.len() as u32;
verts.extend(chunk.positions.iter().map(|p| wow_to_bevy(*p)));
tris.extend(
chunk
.indices
.chunks_exact(3)
.map(|c| [base + c[0], base + c[1], base + c[2]]),
);
}
(!tris.is_empty()).then_some((verts, tris))
}
/// Build a static-trimesh [`Collider`] on the **async compute pool** (parry's QBVH construction is the
@@ -24,6 +24,11 @@ use crate::terrain::WowModelMaterial;
pub(crate) struct PlacementHost {
/// Bone-indexed joint entities — an emitter or ribbon rides its host bone's joint off this.
pub joints: Vec<Entity>,
/// This placement's anim-root entity. It rides the returned entity list so it despawns with the
/// placement, but it is **not geometry** — a caller tagging "everything this placement draws"
/// has to be able to leave it out, or it lands in a cull that can only fail open on a boundless
/// entity (decision 0784). Distinct from `arm`, which is `None` unless a sequence was armed.
pub root: Entity,
/// The anim-root entity IF this placement armed a sequence; `None` on the gseq-only tier, where
/// nothing was armed and slot 0 is the honest answer.
///
@@ -70,10 +75,10 @@ pub(crate) fn spawn_model_entities(
// group geometry.
m2: Option<(&M2Model, f32)>,
// UV-animated material registry (decision 0130 phase 3): a batch with a `uv_anim` loop registers
// its material here so `tick_uv_anim_materials` scrolls the shared offset each frame.
// its material here so `tick_anim_materials` scrolls the shared offset while it is drawn.
uv_reg: &mut crate::doodad_anim::UvAnimMaterials,
// Animated-tint material registry (the M2Color RGB twin of `uv_reg`): a batch with an
// `rgb_anim` loop registers its material here so `tick_tint_anim_materials` re-samples the
// `rgb_anim` loop registers its material here so `tick_anim_materials` re-samples the
// shared tint each frame.
tint_reg: &mut crate::doodad_anim::TintAnimMaterials,
// `Some(anchor)` for a prop spawned ON a streamed entity (the WMO-gameobject path): a boneless
@@ -367,5 +372,9 @@ pub(crate) fn spawn_model_entities(
active: true,
});
}
(out, skin.map(|(joints, _)| PlacementHost { joints, arm }))
(
out,
skin.zip(rig_root)
.map(|((joints, _), root)| PlacementHost { joints, root, arm }),
)
}
@@ -129,6 +129,9 @@ pub(super) fn spawn_emitters_for(
joints: Option<&[Entity]>,
arm: Option<Entity>,
fade: (f32, Vec3),
// The WMO placement this model's doodad set belongs to (`None` for an ADT map doodad) — the
// exterior-window exemption for a prop of the building the camera is standing in (0786).
instance: Option<Entity>,
out: &mut Vec<Entity>,
) {
// The owner doodad's fade sphere (radius, local centre) → the emitter's draw-set gate
@@ -162,6 +165,7 @@ pub(super) fn spawn_emitters_for(
commands.entity(e).insert(particles::EmitterFade {
radius: fade_radius,
center: world_center,
instance,
});
out.push(e);
}
+48 -9
View File
@@ -128,7 +128,14 @@ pub(super) fn spawn_loaded_placements(
// `crate::exterior_cull`). Tagged per submesh because a placement has no root
// entity to carry the bound; the deviation from the reference's whole-object test
// is recorded in `exterior_cull`'s module doc.
for e in &ents {
//
// The anim-host ROOT is skipped: it is a joint hierarchy, not geometry, so it has
// no `Aabb` and lands in the cull's fail-open arm — harmless to draw (it draws
// nothing) but it was 484 of the 6917 "tested" objects at one Stratholme pin,
// which is exactly the kind of noise that makes the instrument stop meaning
// anything. Tag what is drawn (decision 0784).
let anim_root = host.as_ref().map(|h| h.root);
for e in ents.iter().filter(|e| Some(**e) != anim_root) {
commands
.entity(*e)
.insert(crate::exterior_cull::ExteriorScene);
@@ -161,6 +168,7 @@ pub(super) fn spawn_loaded_placements(
host.as_ref().map(|h| h.joints.as_slice()),
host.as_ref().and_then(|h| h.arm),
(radius, center),
None, // an ADT map doodad belongs to no building
&mut ents,
);
spawn_ribbons_for(
@@ -205,6 +213,22 @@ pub(super) fn spawn_loaded_placements(
&mut tint_reg,
None, // world-static placement: cards bake their world pivot
);
// A world WMO placement is exterior scene too (`0x6856c0`, fed by the same
// per-window populate `0x682fa0`): from inside one building, another building
// draws only through a portal window. 0774 left this ungated because these
// entities already had a `Visibility` authority and a second writer would have
// fought it — that is fixed at the root now, with the window term folded INTO
// that authority (decision 0784), so the tag is safe to add.
//
// Tagging is unconditional and the **exemption is dynamic**: the camera's own
// containing placement is not exterior to itself, which the authority decides
// per frame from `CameraInteriorClaim`. A static "is this the player's building"
// could not be right — the player walks in and out of it.
for e in &ents {
commands
.entity(*e)
.insert(crate::exterior_cull::ExteriorScene);
}
// Portal visibility + interior tracking: tie every group submesh entity (the `ents`
// here are exactly the submeshes, in order, before colliders/lights are appended
// below) back to one per-placement instance that holds the placement transform + the
@@ -283,10 +307,17 @@ pub(super) fn spawn_loaded_placements(
if let Some(instance) = p.portal_instance {
let groups: Arc<[u16]> = Arc::from([gi as u16].as_slice());
for &e in &ents[first..] {
commands.entity(e).insert(WmoGroupVis {
instance,
groups: groups.clone(),
});
commands.entity(e).insert((
WmoGroupVis {
instance,
groups: groups.clone(),
},
// Another building's canal is exterior scene like its walls; the
// one you are standing in is exempted by instance (0784). Only
// tagged alongside `WmoGroupVis`, because that component is what
// carries the instance the exemption is keyed on.
crate::exterior_cull::ExteriorScene,
));
}
}
}
@@ -442,10 +473,17 @@ pub(super) fn spawn_loaded_placements(
// is this one prop, so they all share the one key.
if let (Some(instance), false) = (portal_instance, d.groups.is_empty()) {
for &entity in &ents {
commands.entity(entity).insert(WmoGroupVis {
instance,
groups: d.groups.clone(),
});
commands.entity(entity).insert((
WmoGroupVis {
instance,
groups: d.groups.clone(),
},
// Furniture is exterior scene when its building is (0784) — and exempt when
// that building is the one the camera stands in. Keyed on the instance
// `WmoGroupVis` carries, so a prop no group names (no key, no exemption
// possible) is deliberately left untagged rather than gated blind.
crate::exterior_cull::ExteriorScene,
));
}
}
// Prop collider (avian): a static trimesh from the prop's collision hull at its world
@@ -472,6 +510,7 @@ pub(super) fn spawn_loaded_placements(
host.as_ref().map(|h| h.joints.as_slice()),
host.as_ref().and_then(|h| h.arm),
(radius, center),
portal_instance, // a WMO prop rides its building's exemption
&mut ents,
);
spawn_ribbons_for(
@@ -927,3 +927,131 @@ fn flagged_friendly_player_plate_is_green() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The classification border law (decision 0782, ref-TargetFrame.lua l.205-218) end to end: the
/// gated rank on the snapshot → `UnitClassification` → which of the three shipped border textures
/// actually reaches the draw list. Asserting the *extracted quad* rather than a Lua getter is the
/// point — it is the pixels, and it catches a swap that sets the path on the wrong region.
///
/// The two facts worth a test rather than a comment: three of the five classifications share the
/// Elite art (1.12 ships no rare-elite border at all), and the border must repaint on
/// UNIT_CLASSIFICATION_CHANGED alone, with no re-target.
#[test]
fn target_frame_border_follows_the_classification_law() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
// Every TargetingFrame border path in the draw list. The player frame contributes the plain
// art on every frame, so the assertions below are about which *extra* border appears.
let borders = |s: &mut UiScript| -> Vec<String> {
s.resolve();
let mut v: Vec<String> = s
.extract()
.into_iter()
.filter_map(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. }
if p.contains("UI-TargetingFrame") && !p.contains("LevelBackground") =>
{
Some(p.clone())
}
_ => None,
})
.collect();
v.sort();
v.dedup();
v
};
let has = |v: &[String], suffix: &str| v.iter().any(|p| p.ends_with(suffix));
let mob = |rank: u32| UnitState {
exists: true,
name: Some("Ol' Sooty".into()),
health: 400,
max_health: 400,
level: 26,
reaction: 2,
can_attack: true,
rank,
..UnitState::default()
};
// rank 0 — a plain mob: no elite and no rare art anywhere.
s.set_unit("target", Some(mob(0)));
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
let v = borders(&mut s);
assert_eq!(
s.eval::<String>(r#"return UnitClassification("target")"#)
.unwrap(),
"normal"
);
assert!(
!has(&v, "UI-TargetingFrame-Elite") && !has(&v, "UI-TargetingFrame-Rare"),
"rank 0 wears the plain border, got {v:?}"
);
// ranks 1/2/3 — elite, rare-elite and world boss ALL take the one Elite texture. 1.12 ships no
// UI-TargetingFrame-Rare-Elite (absent from the patch chain), which is why 2 lands here.
for (rank, word) in [(1, "elite"), (2, "rareelite"), (3, "worldboss")] {
s.set_unit("target", Some(mob(rank)));
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
let v = borders(&mut s);
assert_eq!(
s.eval::<String>(r#"return UnitClassification("target")"#)
.unwrap(),
word
);
assert!(
has(&v, "UI-TargetingFrame-Elite") && !has(&v, "UI-TargetingFrame-Rare"),
"rank {rank} ({word}) takes the Elite border, got {v:?}"
);
}
// rank 4 — rare (the silver dragon), the only classification with art of its own.
s.set_unit("target", Some(mob(4)));
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
let v = borders(&mut s);
assert_eq!(
s.eval::<String>(r#"return UnitClassification("target")"#)
.unwrap(),
"rare"
);
assert!(
has(&v, "UI-TargetingFrame-Rare") && !has(&v, "UI-TargetingFrame-Elite"),
"rank 4 takes the Rare border, got {v:?}"
);
// The repaint wire: the creature query landing on an already-targeted mob raises its rank, and
// UNIT_CLASSIFICATION_CHANGED alone must swap the border — no PLAYER_TARGET_CHANGED.
s.set_unit("target", Some(mob(0)));
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
assert!(
!has(&borders(&mut s), "UI-TargetingFrame-Elite"),
"precondition: plain border before the query lands"
);
s.set_unit("target", Some(mob(1)));
s.fire_event(
"UNIT_CLASSIFICATION_CHANGED",
vec![ScriptValue::Str("target".into())],
);
assert!(
has(&borders(&mut s), "UI-TargetingFrame-Elite"),
"the event alone repaints the border"
);
// The player frame never reclassifies: its own art stays plain with an elite target up.
let plain_on_player: bool = s
.eval(
r#"
local p = getglobal("BenillaPlayerFrameTextureFrameTexture")
return p ~= nil and BenillaPlayerFrame.frameTexture == nil
"#,
)
.unwrap();
assert!(
plain_on_player,
"the player frame has the region but caches no handle, so nothing can swap it"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+68 -1
View File
@@ -384,7 +384,10 @@ pub(crate) fn enrich_unit(
if let Some(rec) = names.creature_record(entry) {
state.subtitle = rec.subname.clone();
state.creature_type_name = creature_type_word(rec.creature_type).map(str::to_string);
state.rank = rec.rank;
// The client's one rank getter, both gates (`gated_rank`, decision 0782) — never `rec.rank`
// directly: an enslaved elite reads rank 0, so it loses its border dragon, its ELITE
// tooltip word and its world-boss skull together, exactly as in the reference.
state.rank = crate::names::gated_rank(Some(rec), Some(store));
state.civilian = rec.civilian;
state.racial_leader = rec.racial_leader;
// The faction-name line ("Stormwind", between level and PvP) — the unit builder's tail
@@ -521,6 +524,15 @@ pub(crate) fn fire_transitions(
if prev.is_none_or(|p| p.name != cur.name) {
script.fire_event("UNIT_NAME_UPDATE", vec![tok()]);
}
// UNIT_CLASSIFICATION_CHANGED (decision 0782) — the target frame's border-art repaint wire, and
// the only frame that registers it in the reference. Edge-fired on the gated rank, which IS the
// classification (`classification_word` is a pure table index), so this fires exactly when the
// border would change: the creature query landing on a freshly-seen elite, and a mob being
// enslaved or released. Without it the border would only be right on re-target, because the
// ref's own `TargetFrame_Update` is the sole other caller of CheckClassification.
if prev.is_none_or(|p| p.rank != cur.rank) {
script.fire_event("UNIT_CLASSIFICATION_CHANGED", vec![tok()]);
}
// UNIT_FACTION (decision 0646 §2) — the PvP-icon repaint wire, fired on the three fields the
// icon law reads. Exactly the three frames that draw the icon register it in the reference
// (player, target, party member) and nothing else does. Edge-fired, so the player frame's
@@ -711,6 +723,61 @@ fn feed_units(
mod tests {
use super::*;
/// The rank getter's two gates (`0x605620`, decision 0782) as they reach a snapshot. The pet
/// gate is the interesting half: a charmed or enslaved elite reports rank 0, so it loses its
/// dragon border AND its ELITE tooltip word AND (at rank 3) its world-boss skull together.
/// Asserted through `enrich_unit` rather than `gated_rank` directly, because the thing that
/// can regress is the *wiring* — a future edit reaching for `rec.rank` again.
#[test]
fn the_rank_gate_zeroes_a_pet_or_charm() {
use benilla_protocol::messages::ObjectFields;
const ENTRY: u32 = 12397; // Ol' Sooty, a rank-1 elite
const GUID: u64 = (0xF130u64 << 48) | ((ENTRY as u64) << 24) | 0x42;
/// `UNIT_FIELD_PETNUMBER` — absolute descriptor index (`OBJECT_END(6) + 0x85`).
const PETNUMBER: u16 = 139;
let mut names = NameCache::default();
names.insert_creature(
ENTRY,
Some(crate::names::CreatureRecord {
name: "Ol' Sooty".into(),
subname: None,
creature_type: 1,
rank: 1,
type_flags: 0,
civilian: false,
racial_leader: false,
}),
);
let rank_of = |fields: &[(u16, u32)]| {
let store = ObjectStore(ObjectFields::from_pairs(fields));
let mut s = UnitState::default();
enrich_unit(&mut s, GUID, &names, &store, None, None);
s.rank
};
assert_eq!(rank_of(&[]), 1, "a free elite keeps its template rank");
assert_eq!(
rank_of(&[(PETNUMBER, 0)]),
1,
"an explicit zero pet number is not a pet"
);
assert_eq!(
rank_of(&[(PETNUMBER, 7)]),
0,
"a non-zero pet number forces rank 0 — no dragon on an enslaved elite"
);
// The record gate, the getter's other half: no cached template at all → rank 0, and the
// border stays plain until the creature query answers.
let store = ObjectStore(ObjectFields::from_pairs(&[]));
let mut s = UnitState::default();
enrich_unit(&mut s, GUID ^ (1 << 24), &names, &store, None, None);
assert_eq!(s.rank, 0, "an un-queried creature has no classification");
}
/// The PvP-preference announcement law (decision 0652), as the reference's changed-bits handler
/// runs it: silent on first sight, one pair per real edge, and the OFF text is the one that
/// explains the five-minute wait — the whole reason the toggle doesn't read as dead.
+7 -3
View File
@@ -698,9 +698,13 @@ fn drive_vplates(
// classification rank 3, from the ask-once creature record (it lands with the
// name query; a miss just means no skull yet) — unconditionally; or a hostile
// ≥ 10 levels up that isn't trivial-grey (`0x5f0700`, the shared grey check).
let world_boss = benilla_protocol::guid::entry(guid.0)
.and_then(|e| names.creature_record(e))
.is_some_and(|r| r.rank == 3);
// The rank goes through the client's own getter (`gated_rank`, decision 0782), which is
// what makes a MIND-CONTROLLED world boss show its NUMBER here rather than a skull:
// `0x7cbb40` reads rank via `0x605620`, pet gate and all.
let world_boss = crate::names::gated_rank(
benilla_protocol::guid::entry(guid.0).and_then(|e| names.creature_record(e)),
store,
) == 3;
if world_boss || (rank <= 1 && level >= my_level + 10 && !unit_is_grey(my_level, level))
{
let half = gx(SKULL_SIZE) * 0.5;
+6
View File
@@ -227,6 +227,12 @@ fn stream_wdl(
MeshMaterial3d(streamer.material.clone()),
Transform::IDENTITY,
WdlTile,
// The far band is exterior scene like the detailed tiles are: the reference's far
// walk `0x683040` is fed by the SAME per-window populate `0x682fa0`, so from a sealed
// room the horizon is not drawn either (`crate::exterior_cull`). One ring tile is one
// drawn object here, which is already the reference's far-tier granularity — this
// band never needed the chunk split the near tiles did (decision 0780).
crate::exterior_cull::ExteriorScene,
))
.id();
streamer.loaded.insert(coords, e);