diff --git a/README.md b/README.md index fce90e1b..68e2b95f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/benilla-assets/src/adt.rs b/crates/benilla-assets/src/adt.rs index cc0c19c5..84d9cff7 100644 --- a/crates/benilla-assets/src/adt.rs +++ b/crates/benilla-assets/src/adt.rs @@ -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, + /// 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>, /// `texture_2d_array` of the tile's ground textures (the C2-faithful authored mip chains). pub layer_array: Handle, /// `texture_2d_array` of per-chunk alpha (blend-weight) maps. @@ -47,8 +55,9 @@ pub struct AdtTile { pub doodads: Vec, /// WMO building placements (raw WoW coords). pub wmos: Vec, - /// 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 = 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 = 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> = 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), diff --git a/crates/benilla-assets/src/lib.rs b/crates/benilla-assets/src/lib.rs index e67fdd23..0eb3f044 100644 --- a/crates/benilla-assets/src/lib.rs +++ b/crates/benilla-assets/src/lib.rs @@ -532,7 +532,7 @@ mod tests { app.update(); if let Some(t) = app.world().resource::>().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::>(); - 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::>(); 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() + ); } } diff --git a/crates/benilla-assets/src/model.rs b/crates/benilla-assets/src/model.rs index 3fe7dfd1..7f50206f 100644 --- a/crates/benilla-assets/src/model.rs +++ b/crates/benilla-assets/src/model.rs @@ -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 { diff --git a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs index 11e780ef..a7ae5231 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs @@ -643,8 +643,17 @@ fn winding(s: &benilla_formats::RenderSubmesh) -> Option { (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) )) diff --git a/crates/benilla-formats/src/models/types.rs b/crates/benilla-formats/src/models/types.rs index 3efaa87a..10b7b2d4 100644 --- a/crates/benilla-formats/src/models/types.rs +++ b/crates/benilla-formats/src/models/types.rs @@ -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 diff --git a/crates/benilla-formats/tests/m2_billboard_card_face.rs b/crates/benilla-formats/tests/m2_billboard_card_face.rs new file mode 100644 index 00000000..eea031f7 --- /dev/null +++ b/crates/benilla-formats/tests/m2_billboard_card_face.rs @@ -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" + ); +} diff --git a/crates/benilla-protocol/src/lib.rs b/crates/benilla-protocol/src/lib.rs index 53f25569..c5279011 100644 --- a/crates/benilla-protocol/src/lib.rs +++ b/crates/benilla-protocol/src/lib.rs @@ -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 { #[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] diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs index 113ed4dd..d6ce206c 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs @@ -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). diff --git a/crates/benilla-protocol/src/messages/update_object/fields/unit.rs b/crates/benilla-protocol/src/messages/update_object/fields/unit.rs index 9f3d0f02..164ccbb5 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/unit.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/unit.rs @@ -41,6 +41,22 @@ impl ObjectFields { pub fn unit_created_by(&self) -> Option { 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 { self.get_u32(FIELD_UNIT_HEALTH) diff --git a/crates/benilla-protocol/src/world/mod.rs b/crates/benilla-protocol/src/world/mod.rs index 0d8347f4..6d2ea02e 100644 --- a/crates/benilla-protocol/src/world/mod.rs +++ b/crates/benilla-protocol/src/world/mod.rs @@ -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`. diff --git a/crates/benilla-ui/src/script/unit/bindings.rs b/crates/benilla-ui/src/script/unit/bindings.rs index 39edff82..666672db 100644 --- a/crates/benilla-ui/src/script/unit/bindings.rs +++ b/crates/benilla-ui/src/script/unit/bindings.rs @@ -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| { + 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( diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index 998d85ba..86277df8 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -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, - /// 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 { diff --git a/crates/benilla-ui/src/script/unit/tests.rs b/crates/benilla-ui/src/script/unit/tests.rs index 7b8f3eb5..f49141eb 100644 --- a/crates/benilla-ui/src/script/unit/tests.rs +++ b/crates/benilla-ui/src/script/unit/tests.rs @@ -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::(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::(r#"return UnitClassification("target")"#) + .unwrap(), + "normal", + "an absent unit is normal, not nil" + ); + assert_eq!( + s.eval::(r#"return UnitClassification()"#).unwrap(), + "normal", + "a missing token is normal, not nil" + ); +} diff --git a/crates/benilla/assets/shaders/wow_effect.wgsl b/crates/benilla/assets/shaders/wow_effect.wgsl index e9cf66fb..aaf9b0db 100644 --- a/crates/benilla/assets/shaders/wow_effect.wgsl +++ b/crates/benilla/assets/shaders/wow_effect.wgsl @@ -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, @location(1) uv: vec2, @location(2) color: vec4, // raw authored gamma RGBA; α is the blend weight @@ -70,25 +72,39 @@ struct VertexOutput { @builtin(position) clip_position: vec4, @location(0) uv: vec2, @location(1) color: vec4, - // 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(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(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( 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(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; } diff --git a/crates/benilla/assets/ui/UnitFrames.xml b/crates/benilla/assets/ui/UnitFrames.xml index 2157af77..6a9a631d 100644 --- a/crates/benilla/assets/ui/UnitFrames.xml +++ b/crates/benilla/assets/ui/UnitFrames.xml @@ -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. diff --git a/crates/benilla/build.rs b/crates/benilla/build.rs new file mode 100644 index 00000000..47bdc7a0 --- /dev/null +++ b/crates/benilla/build.rs @@ -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/`); 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/` 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/`, 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 `/.git/worktrees//`, 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 { + 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| { + 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 `/[/]/build/-/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}"); +} diff --git a/crates/benilla/src/art_scope.rs b/crates/benilla/src/art_scope.rs new file mode 100644 index 00000000..61c7b8ac --- /dev/null +++ b/crates/benilla/src/art_scope.rs @@ -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`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::().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>` 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 { + map: HashMap)>, +} + +/// 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 Default for SpatialCache { + fn default() -> Self { + Self { + map: HashMap::new(), + } + } +} + +impl SpatialCache { + /// 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 + 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::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(&mut self, cache: &mut SpatialCache, 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, + cfg: Option>, +) { + 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, + time: Res>, + player: Res, + camera: Query<&Transform, With>, + roster: Option>, +) { + 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::() + .init_resource::() + .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 = 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 = 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 = 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> = 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 = 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 = 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` 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::(); + let mut cache: SpatialCache> = SpatialCache::default(); + let id = { + let mut images = app.world_mut().resource_mut::>(); + let handle = images.add(Image::default()); + let id = handle.id(); + cache.insert(1, handle); + id + }; + app.update(); + assert!( + app.world().resource::>().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::>().contains(id), + "the image outlived its last handle — eviction would free nothing" + ); + } + + /// A cache nobody sweeps (the `Local`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 = SpatialCache::default(); + c.insert(1, 10); + assert_eq!(c.fetch(&1), Some(10)); + assert_eq!(c.len(), 1); + } +} diff --git a/crates/benilla/src/assets/mod.rs b/crates/benilla/src/assets/mod.rs index d6d0a4e2..3a73ff4f 100644 --- a/crates/benilla/src/assets/mod.rs +++ b/crates/benilla/src/assets/mod.rs @@ -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>, + pub(crate) textures: SpatialCache<(String, bool, bool), Handle>, /// 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>>, /// Materials deduped by their identity (texture path + blend, or the untextured fallback). - pub(crate) model_materials: HashMap>, + pub(crate) model_materials: SpatialCache>, /// 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, ) -> Option> { 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>) { + 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) { 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:#}"), diff --git a/crates/benilla/src/billboard.rs b/crates/benilla/src/billboard.rs index 0edf0c4c..343f2bf9 100644 --- a/crates/benilla/src/billboard.rs +++ b/crates/benilla/src/billboard.rs @@ -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, ), Without, >, @@ -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::