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