portals work, GameObjects animate, and the tent stops flickering

- Area triggers exist now, so portals work: every teleport pad, dungeon entrance
  and enter-a-place quest volume reports the reference's own way — one exit latch,
  first containing volume on the map wins, a sphere or a yawed box. Verified live:
  Rut'theran to Darnassus, the Deadmines worldport, and a level-gated entrance
  refusing with the server's own "You must be at least level 10 to enter."
- GameObjects animate. Every animated object arms its idle the moment it goes live
  (doors and chests used to flash bind pose for a frame), a missing rest pose is
  remapped the way the reference remaps it instead of playing nothing — the
  Ahn'Qiraj gate's root tangle stood frozen for want of a Closed clip — and the
  state machine covers the verified twenty of thirty-one object types instead of
  three: fourteen hundred spawns' worth of traps, goobers, books and questgivers
  now change pose off the wire.
- The flickering tent is still. Cloth you can see from both sides is authored as
  two reversed copies of the same sheet, and the real client backface-culls each
  copy from its wrong side so exactly one covers any pixel; we drew every building
  batch two-sided, which left the winner to floating-point noise, flickering while
  the camera crept. WMO materials now honor their cull flag.
- A sweep of long-standing visual bugs: effects respect the far-clip wall — a
  ferry's deck lanterns were drawing from 7,000 yards away; the night-elf eye glow
  goes out in first person instead of leaving two orbs burning in mid-air; the
  goblins' stray white hair tuft is gone (a duplicated table key resolved last-wins
  where the client scans first-wins); lava and slime finally have their own
  submerged view — the dense fiery orange and dense green of the real thing, off
  two fixed lighting rows nothing in the zone data points at; and the far-horizon
  band overlaps the detailed terrain instead of leaving a strip of sky at lowered
  view distance.
- Two caught at the keyboard: the backpack icon greys instead of vanishing while
  the escape menu is up, and the world map is a real panel now — it can no longer
  open over the menu, and ESC closes it first like everything else.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-26 19:20:51 +07:00
co-authored by Claude Fable 5
parent c887170b37
commit 2c55790ec1
68 changed files with 4050 additions and 241 deletions
+5
View File
@@ -460,6 +460,10 @@ impl AssetLoader for M2ModelLoader {
// loader-idle seed below resolves through it.
let playable_animation_lookup =
parse_m2_playable_animation_lookup(&bytes).unwrap_or_default();
// The model's own "do I author id X" table (the reference's `0x711960`) — the
// GameObject arm's missing-sequence remap branches on it (`ModelAnimations::owns`).
let animation_lookup =
benilla_formats::parse_m2_animation_lookup(&bytes).unwrap_or_default();
let mut clips = Vec::new();
// The loader-idle seed (decision 0637, wow-re `gameobject-anim-arm.md` §1 — the
// corrected `0x71019b` read): the loader arms **animation id 0 ("Stand")** resolved
@@ -557,6 +561,7 @@ impl AssetLoader for M2ModelLoader {
graph,
clips,
playable_animation_lookup,
animation_lookup,
hand_close,
global_bones,
first_seq,
+18
View File
@@ -96,6 +96,12 @@ pub struct ModelAnimations {
/// `AnimationData.dbc` id `i`. Empty for a model whose header carries no table (a boneless
/// doodad-shaped cube fallback, a malformed M2) — [`Self::resolve`] then degrades to identity.
pub playable_animation_lookup: Vec<PlayableAnim>,
/// The model's **AnimationLookup** (M2 header `+0x24`/`+0x28`): `AnimationData.dbc` id → the
/// model's first sequence slot for it, `0xffff` where it authors none. Kept beside the playable
/// table because it is a *different question* — "does this model own id X at all", the
/// reference's `0x711960` — and the GameObject arm's missing-sequence remap branches on it four
/// times (wow-re `gameobject-anim-arm.md` §2c). See [`Self::owns`].
pub animation_lookup: Vec<u16>,
/// The model's **global-sequence bone channels** baked to Bevy space (the eye-blink eyelid scale and
/// kin): free-clock loops the runtime samples independently of the playing animation and composes
/// over the sequence pose (see [`super::GlobalBone`]). Empty for a model with no global-sequence
@@ -139,6 +145,17 @@ impl ModelAnimations {
self.clips.iter().find(|c| c.anim_id == anim_id)
}
/// **Does the model author `anim_id`?** — the reference's `0x711960`: a bounds-checked read of
/// [`Self::animation_lookup`] against the `0xffff` sentinel. Deliberately NOT `find(..).is_some()`:
/// `clips` drops sequences we can't build (zero-duration ones among them), so a clip scan can
/// answer "no" where the reference answers "yes" and takes a different branch. Use this to decide
/// what the reference would *request*; use [`Self::find`] to get something we can actually play.
pub fn owns(&self, anim_id: u16) -> bool {
self.animation_lookup
.get(anim_id as usize)
.is_some_and(|&slot| slot != 0xffff)
}
/// The clip a free/effect model instance runs: the caller's `preferred` id when the model has
/// it (the thrown-weapon missile asks for InFlight), else the file-order-first clip — the
/// existing default. One selector so the rig ([`crate::…`]'s `arm_effect_rig`) and its ribbon
@@ -265,6 +282,7 @@ mod tests {
graph: Handle::default(),
clips: has_clips.iter().map(|&id| test_clip(id)).collect(),
playable_animation_lookup: table,
animation_lookup: Vec::new(),
hand_close: [None, None],
global_bones: Vec::new(),
first_seq: None,
@@ -0,0 +1,219 @@
//! Which faces of a WMO are **authored twice** — two triangles on the same three positions:
//! `cargo run -p benilla-formats --example wmo_doubled -- <wmo-path-or-substring>`
//! e.g. `wmo_doubled orctower`.
//!
//! The falsifier `wmo_coplanar` structurally cannot be. That tool skips same-batch face pairs that
//! share a vertex ("two halves of a quad") — but a thin sheet whose two *sides* are authored by
//! reusing the same vertices with **reversed winding** shares all three, so a doubled canvas awning
//! is invisible to it by construction (it reported the B38 tent's nearest partner at 1.91 yd while
//! the tent's own two sides sat at 0.000). This tool asks the opposite question: which triangles
//! occupy the SAME three positions?
//!
//! Doubled-with-opposite-winding is not a defect — it is how 1.12 authors visible-from-both-sides
//! cloth when the material is single-sided: the real client backface-culls each copy from the wrong
//! side, so exactly one covers any pixel. It *becomes* a defect the moment the renderer draws WMO
//! batches two-sided (ours does, `group.rs` "keep two-sided for now"): both copies then rasterize at
//! depths equal to the ulp, and the winner per pixel is floating-point noise — latched while the
//! camera is bit-still, flickering while anything creeps. So each doubled pair is printed with its
//! batches' MOMT flags: `0x04` (UNCULLED) set means the file *asked* for two-sided and the double
//! draw is authored intent; clear means the renderer's shortcut is manufacturing a z-tie the file
//! never contained.
//!
//! Output is Blizzard data — pipe it to the scratchpad, never into the repo.
use std::collections::HashMap;
use benilla_wmo::{parse_wmo, ParsedWmo};
/// Positions are matched to a tenth of a millimetre — far under authoring precision, far over f32
/// noise at building scale.
const QUANT: f32 = 10_000.0;
fn key(p: [f32; 3]) -> [i64; 3] {
p.map(|c| (c * QUANT).round() as i64)
}
/// A triangle's identity (order-free) plus the data needed to classify its partner.
struct Tri {
batch: usize,
/// Geometric normal in original winding order — two copies with `dot < 0` are opposite-wound.
normal: [f32; 3],
/// Mean authored (MONR) normal, to name which side of the sheet this copy is.
authored: [f32; 3],
}
fn main() -> anyhow::Result<()> {
let pat = std::env::args()
.nth(1)
.ok_or_else(|| anyhow::anyhow!("usage: wmo_doubled <wmo-path-or-substring>"))?;
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
let mut chain = benilla_formats::open_chain(&data)?;
let path = if pat.to_lowercase().ends_with(".wmo") {
pat.clone()
} else {
let lower = pat.to_lowercase();
chain
.list()?
.into_iter()
.map(|e| e.name)
.find(|n| {
let l = n.to_lowercase();
l.ends_with(".wmo") && l.contains(&lower) && !l.contains("_00")
})
.ok_or_else(|| anyhow::anyhow!("no root .wmo matching {pat:?}"))?
};
println!("{path}\n");
// The root's MOMT table, raw — the submeshes don't carry material flags, and the flags decide
// whether a doubled pair is authored intent (0x04 UNCULLED) or a renderer-manufactured tie.
let root_bytes = chain.read_file(&path.to_ascii_lowercase())?;
let (materials, textures, tex_map) = match parse_wmo(&mut std::io::Cursor::new(&root_bytes))? {
ParsedWmo::Root(r) => (r.materials, r.textures, r.texture_offset_index_map),
ParsedWmo::Group(_) => anyhow::bail!("{path} is a group file, want the root"),
};
println!("=== MOMT ({} materials) ===", materials.len());
for (i, m) in materials.iter().enumerate() {
let tex = textures
.get(m.get_texture1_index(&tex_map) as usize)
.map(String::as_str)
.unwrap_or("(none)");
println!(
"[{i:3}] flags {:#06x}{} blend {} {tex}",
m.flags,
if m.flags & 0x04 != 0 { " UNCULLED" } else { "" },
m.blend_mode,
);
}
let subs = benilla_formats::load_wmo(&mut chain, &path)?;
// One global bucket per geometric triangle, across every batch: any bucket holding two entries
// is a doubled face, wherever its copies live.
let mut buckets: HashMap<[[i64; 3]; 3], Vec<Tri>> = HashMap::new();
for (bi, s) in subs.iter().enumerate() {
for t in s.indices.chunks_exact(3) {
let p: Vec<[f32; 3]> = t.iter().map(|&i| s.positions[i as usize]).collect();
let (u, v) = (sub3(p[1], p[0]), sub3(p[2], p[0]));
let n = cross(u, v);
if len(n) < 1e-9 {
continue; // degenerate: no facing to double
}
let mut authored = [0.0f32; 3];
for &i in t {
let a = s.normals.get(i as usize).copied().unwrap_or([0.0; 3]);
for c in 0..3 {
authored[c] += a[c];
}
}
let mut k = [key(p[0]), key(p[1]), key(p[2])];
k.sort_unstable();
buckets.entry(k).or_default().push(Tri {
batch: bi,
normal: n,
authored,
});
}
}
// Doubled-face census per batch pair, split by winding — opposite is the two-sided-sheet
// authoring; same-winding true duplicates would be a different (and stranger) finding.
/// Per batch pair: doubled-face count + each side's summed authored normal.
type OppositeCensus = HashMap<(usize, usize), (usize, [f32; 3], [f32; 3])>;
let mut opposite: OppositeCensus = HashMap::new();
let mut same: HashMap<(usize, usize), usize> = HashMap::new();
for tris in buckets.values() {
for i in 0..tris.len() {
for j in (i + 1)..tris.len() {
let (a, b) = (&tris[i], &tris[j]);
let pair = (a.batch.min(b.batch), a.batch.max(b.batch));
if dot(a.normal, b.normal) < 0.0 {
let e = opposite.entry(pair).or_insert((0, [0.0; 3], [0.0; 3]));
e.0 += 1;
// Accumulate each side's authored normal by batch order, so the report can say
// "batch A faces up, batch B faces down" rather than just "they differ".
let (first, second) = if a.batch <= b.batch { (a, b) } else { (b, a) };
for c in 0..3 {
e.1[c] += first.authored[c];
e.2[c] += second.authored[c];
}
} else {
*same.entry(pair).or_default() += 1;
}
}
}
}
let describe = |bi: usize| {
let s = &subs[bi];
format!(
"[{bi:3}] bias {:3} {:?} {}",
bi + 1,
s.blend,
s.texture.as_deref().unwrap_or("(none)"),
)
};
println!("\n=== doubled faces, OPPOSITE winding (the two-sided-sheet authoring) ===");
let mut pairs: Vec<_> = opposite.iter().collect();
pairs.sort_by_key(|(k, _)| **k);
if pairs.is_empty() {
println!("(none)");
}
for (&(a, b), &(count, na, nb)) in pairs {
println!(
"{count:5} tri {} <-> {}",
describe(a),
if a == b {
"ITSELF".to_string()
} else {
describe(b)
}
);
println!(
" side A authored normal ~[{:+.2},{:+.2},{:+.2}] side B ~[{:+.2},{:+.2},{:+.2}]",
na[0] / count as f32 / 3.0,
na[1] / count as f32 / 3.0,
na[2] / count as f32 / 3.0,
nb[0] / count as f32 / 3.0,
nb[1] / count as f32 / 3.0,
nb[2] / count as f32 / 3.0,
);
}
println!(
"\n=== doubled faces, SAME winding (true duplicates — would double-draw even culled) ==="
);
if same.is_empty() {
println!("(none)");
}
let mut pairs: Vec<_> = same.iter().collect();
pairs.sort_by_key(|(k, _)| **k);
for (&(a, b), &count) in pairs {
println!(
"{count:5} tri {} <-> {}",
describe(a),
if a == b {
"ITSELF".to_string()
} else {
describe(b)
}
);
}
Ok(())
}
fn sub3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn cross(u: [f32; 3], v: [f32; 3]) -> [f32; 3] {
[
u[1] * v[2] - u[2] * v[1],
u[2] * v[0] - u[0] * v[2],
u[0] * v[1] - u[1] * v[0],
]
}
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn len(a: [f32; 3]) -> f32 {
dot(a, a).sqrt()
}
+280
View File
@@ -0,0 +1,280 @@
//! `AreaTrigger.dbc` — the world's invisible trip-wires, and the containment law that fires them.
//!
//! A trigger is a volume the *client* watches: when the player walks into one, the client sends
//! `CMSG_AREATRIGGER` naming its id and the **server** decides what that means — a teleport (every
//! instance entrance, the Darnassus/Rut'theran portals), a quest's "explore here" objective, the
//! inn's rested-XP state, a battleground's entrance list. The client owns only the geometry; it
//! never knows what any trigger does.
//!
//! Layout — 10 × 4-byte columns (record stride `0x28`, which is what the reference's own scan
//! strides by): `ID(0), MapID(1), X(2), Y(3), Z(4), Radius(5), BoxLength(6), BoxWidth(7),
//! BoxHeight(8), BoxYaw(9)`. Positions are WoW world space (the space `bevy_to_wow` produces),
//! yaw in radians.
//!
//! The containment law is [`AreaTriggerRow::contains`] — read from the reference's `0x5e22d0`
//! (wow-5875-re `object-layer/scratch/w2b1-decomp.c`), which is the only place this math lives.
use std::collections::HashMap;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::chain::Chain;
use crate::dbc::{f32_at, parse, u32_at};
const AREA_TRIGGER: &str = "DBFilesClient\\AreaTrigger.dbc";
/// One `AreaTrigger.dbc` row: a sphere (`radius != 0`) or an oriented box (`radius == 0`) sitting
/// on one map, in WoW world space.
#[derive(Clone, Copy, Debug)]
pub struct AreaTriggerRow {
/// The id that goes on the wire in `CMSG_AREATRIGGER`.
pub id: u32,
/// `Map.dbc` id the volume lives on.
pub map_id: u32,
/// Centre, WoW world space.
pub position: [f32; 3],
/// Sphere radius (yd). `0` ⇒ this row is a box; see [`Self::contains`].
pub radius: f32,
/// **Full** box extents along the box's own local X/Y/Z (yd) — halved by the containment test,
/// which is how the reference reads them.
pub box_size: [f32; 3],
/// The box's yaw about world Z (radians, CCW from +X), i.e. where its local +X points.
pub box_yaw: f32,
}
impl AreaTriggerRow {
/// Is `p` (WoW world space) inside this volume?
///
/// **VERIFIED** against the reference's containment predicate `0x5e22d0` (wow-5875-re
/// `object-layer/scratch/w2b1-decomp.c`; the ledger classes it ORCHESTRATION over gx's matrix
/// ops, and the comparison it owns is what this reproduces):
///
/// - **`radius != 0` ⇒ sphere**, tested in **3D** and **inclusively**: the reference computes
/// `Σ(centre − p)²` and returns "inside" on `radius² >= dist²`.
/// - **`radius == 0` ⇒ oriented box**: the point is carried into the box's own frame (translate
/// to the centre, rotate by **−`box_yaw`** about Z) and compared against ±half of each
/// `box_size` component, **strictly** on all six faces (`-h < l < h`). The reference does the
/// transform through gx (`0x7bdc40` load / `0x7bdd60` rotate-about-Z / `0x7bd700` apply) and
/// owns only the comparison; vmangos's `IsPointInAreaTriggerZone` reaches the same law from
/// the other side (it rotates the player by `2π − box_orientation`, which is this rotation).
///
/// The server re-runs the same test with **5 yd of slop** before acting
/// (`MiscHandler.cpp:641`), so a marginally tight answer here still opens the door; a
/// marginally *loose* one is simply ignored.
pub fn contains(&self, p: [f32; 3]) -> bool {
if self.radius != 0.0 {
let d = [
self.position[0] - p[0],
self.position[1] - p[1],
self.position[2] - p[2],
];
let dist_sq = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
return self.radius * self.radius >= dist_sq;
}
// World → box-local: the box's local +X points along `box_yaw`, so the inverse rotation is
// by −yaw about Z. Z is unrotated (the volume never pitches or rolls).
let (sin, cos) = (-self.box_yaw).sin_cos();
let dx = p[0] - self.position[0];
let dy = p[1] - self.position[1];
let local = [
dx * cos - dy * sin,
dx * sin + dy * cos,
p[2] - self.position[2],
];
(0..3).all(|i| {
let half = self.box_size[i] * 0.5;
local[i] > -half && local[i] < half
})
}
}
/// Every `AreaTrigger.dbc` row, bucketed by map — the per-map window the reference narrows to
/// before each check (`0x5e2080` walks the map-sorted table for a `[first, end)` index range;
/// bucketing answers the same question without depending on the file staying sorted).
///
/// **File order is preserved inside each bucket**, because the check takes the *first* containing
/// row and volumes do overlap (an instance entrance sits inside its building's larger trigger).
pub struct AreaTriggerCatalog {
by_map: HashMap<u32, Vec<AreaTriggerRow>>,
len: usize,
}
impl AreaTriggerCatalog {
/// The triggers on `map_id`, in file order (empty for a map with none).
pub fn on_map(&self, map_id: u32) -> &[AreaTriggerRow] {
self.by_map.get(&map_id).map_or(&[], |v| v.as_slice())
}
/// The **first** trigger on `map_id` containing `p` — the reference's own pick
/// (`0x5e2110` breaks its scan on the first hit).
pub fn first_containing(&self, map_id: u32, p: [f32; 3]) -> Option<&AreaTriggerRow> {
self.on_map(map_id).iter().find(|t| t.contains(p))
}
/// A row by id (linear over its map's bucket is not available here — ids are unique table-wide,
/// so this walks every bucket; used by tests and diagnostics, never per frame).
pub fn get(&self, id: u32) -> Option<&AreaTriggerRow> {
self.by_map.values().flatten().find(|t| t.id == id)
}
pub fn len(&self) -> usize {
self.len
}
pub fn is_empty(&self) -> bool {
self.len == 0
}
}
/// 10 u32/f32-wide columns (see module doc).
pub fn area_trigger_schema() -> Schema {
let mut s = Schema::new("AreaTrigger");
s.add_field(SchemaField::new("ID", FieldType::UInt32));
s.add_field(SchemaField::new("MapID", FieldType::UInt32));
s.add_field(SchemaField::new("X", FieldType::Float32));
s.add_field(SchemaField::new("Y", FieldType::Float32));
s.add_field(SchemaField::new("Z", FieldType::Float32));
s.add_field(SchemaField::new("Radius", FieldType::Float32));
s.add_field(SchemaField::new("BoxLength", FieldType::Float32));
s.add_field(SchemaField::new("BoxWidth", FieldType::Float32));
s.add_field(SchemaField::new("BoxHeight", FieldType::Float32));
s.add_field(SchemaField::new("BoxYaw", FieldType::Float32));
s.set_key_field("ID");
s
}
/// Read `AreaTrigger.dbc` off the patch chain into an [`AreaTriggerCatalog`].
pub fn load_area_trigger_catalog(chain: &mut Chain) -> Result<AreaTriggerCatalog> {
let bytes = chain
.read_file(AREA_TRIGGER)
.context("reading AreaTrigger.dbc")?;
let rs = parse(&bytes, area_trigger_schema(), "AreaTrigger")?;
let mut by_map: HashMap<u32, Vec<AreaTriggerRow>> = HashMap::new();
let mut len = 0;
for r in rs.records() {
let (Some(id), Some(map_id)) = (u32_at(r, 0), u32_at(r, 1)) else {
continue;
};
let f = |i| f32_at(r, i).unwrap_or(0.0);
by_map.entry(map_id).or_default().push(AreaTriggerRow {
id,
map_id,
position: [f(2), f(3), f(4)],
radius: f(5),
box_size: [f(6), f(7), f(8)],
box_yaw: f(9),
});
len += 1;
}
Ok(AreaTriggerCatalog { by_map, len })
}
#[cfg(test)]
mod tests {
use super::*;
fn sphere(radius: f32) -> AreaTriggerRow {
AreaTriggerRow {
id: 1,
map_id: 0,
position: [10.0, 20.0, 30.0],
radius,
box_size: [0.0; 3],
box_yaw: 0.0,
}
}
/// The sphere leg: 3D, and inclusive exactly on the surface (the reference's `radius² >= d²`).
#[test]
fn sphere_is_3d_and_inclusive() {
let t = sphere(5.0);
assert!(t.contains([10.0, 20.0, 30.0]));
assert!(
t.contains([15.0, 20.0, 30.0]),
"exactly on the surface is in"
);
assert!(!t.contains([15.1, 20.0, 30.0]));
// Z counts: straight up out of the sphere is out, which a 2D test would miss.
assert!(!t.contains([10.0, 20.0, 36.0]));
assert!(t.contains([10.0, 20.0, 34.0]));
}
/// The box leg: half-extents, strict faces, and the yaw actually rotating the volume.
#[test]
fn box_is_half_extents_strict_and_yawed() {
let axis_aligned = AreaTriggerRow {
id: 2,
map_id: 0,
position: [0.0, 0.0, 0.0],
radius: 0.0,
box_size: [10.0, 4.0, 2.0],
box_yaw: 0.0,
};
assert!(axis_aligned.contains([4.9, 1.9, 0.9]));
// Half, not full: 6 yd along a 10-yd box is outside.
assert!(!axis_aligned.contains([6.0, 0.0, 0.0]));
// Strict on the face itself.
assert!(!axis_aligned.contains([5.0, 0.0, 0.0]));
assert!(!axis_aligned.contains([0.0, 0.0, 1.0]));
// Rotate the same box a quarter turn: the long axis now runs along world +Y, so a point
// that was inside along X is outside, and its mirror along Y is inside.
let turned = AreaTriggerRow {
box_yaw: std::f32::consts::FRAC_PI_2,
..axis_aligned
};
assert!(!turned.contains([4.0, 0.0, 0.0]));
assert!(turned.contains([0.0, 4.0, 0.0]));
}
/// The real 5875 table: the Darnassus portal pair the reports name (B70), a box row and a
/// sphere row both present, and the per-map bucketing. Skips without client data.
#[test]
fn real_area_triggers_load_and_contain_their_own_centres() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_area_trigger_catalog(&mut chain).expect("load AreaTrigger");
assert_eq!(cat.len(), 432, "5875 ships 432 area triggers");
// The two the Darnassus report rides on (B70), both on Kalimdor, both 10-yd spheres. They
// pin the coordinate columns from *outside* the client: vmangos's `areatrigger_teleport`
// sends 527 ("Darnassus - Exit") to (8785.79, 966.98, 30.20) — which is trigger **542**'s
// own position here, and vice versa. Two independent sources, agreeing to a couple of yards,
// on a pair of doorways that teleport to each other.
let exit = *cat.get(527).expect("trigger 527 (Darnassus - Exit)");
let entrance = *cat.get(542).expect("trigger 542 (Darnassus - Entrance)");
assert_eq!((exit.map_id, entrance.map_id), (1, 1));
assert!(exit.contains([9947.0, 2630.0, 1318.0]), "{exit:?}");
assert!(entrance.contains([8799.0, 970.0, 30.0]), "{entrance:?}");
// …and 25 yd away is outside a 10-yd sphere.
assert!(!entrance.contains([8824.0, 970.0, 30.0]));
// Every volume contains its own centre — the one invariant that catches a column shift
// (a size read as a coordinate, or a radius read from the wrong slot, breaks it).
for t in cat.on_map(1) {
assert!(
t.contains(t.position),
"trigger {} does not contain its own centre: {t:?}",
t.id
);
}
// Both shapes are represented in the shipped table.
// Both shapes ship: 80 boxes, the rest spheres.
let boxes = cat.on_map(0).iter().filter(|t| t.radius == 0.0).count();
assert!(
boxes > 0 && boxes < cat.on_map(0).len(),
"map 0 has both shapes"
);
// Buckets are per map, and the two continents carry most of the table.
assert!(cat.on_map(1).iter().all(|t| t.map_id == 1));
assert_eq!((cat.on_map(0).len(), cat.on_map(1).len()), (133, 121));
}
}
@@ -180,6 +180,17 @@ enum Command {
/// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted.
prefix: Option<String>,
},
/// Sweep every model named by **GameObjectDisplayInfo.dbc** and resolve what the reference's
/// GameObject animation arm plays in each reachable `GAMEOBJECT_STATE` × `GAMEOBJECT_ANIMPROGRESS`
/// substate (wow-re `gameobject-anim-arm.md` §2b/§2c: the substate table, the `0x8607e4` LUT, the
/// four-way missing-sequence remap) — beside the generic loader seed the same model gets at build
/// (§1, animation id 0 through `playableAnimationLookup`). The population instrument for "which
/// GameObjects can the wire state actually be SEEN on": a model whose every substate lands on the
/// seed's own sequence is state-blind, so skipping the arm on its GO type costs nothing, while a
/// STATE-SENSITIVE model renders in the wrong pose the moment its type is left off the machine.
/// Also counts the models whose pose depends on the §2c remap (benilla plays nothing there today,
/// i.e. bind pose) and the ones reaching a rate-0 freeze leg.
Goanimscan,
/// Sweep every `.m2` (optionally under a path prefix) and census the models whose batch
/// visibility is PER SEQUENCE — geometry the reference draws in one animation and skips in
/// another (the verified `A <= 0` alpha cull). The population instrument for "a single-sequence
@@ -254,6 +265,36 @@ enum Command {
/// Game minute-of-day (0..1440; 720 = noon).
minute: u32,
},
/// Dump every `LightIntBand`/`LightFloatBand` row of an explicitly named `LightParams` id — the
/// instrument for rows **no position can reach**, because nothing in `Light.dbc` references them:
/// magma submersion reads the fixed global row 7 and slime row 6 (VERIFIED `0x6d2371`), not the
/// zone's underwater slot.
Lightparam {
/// `LightParams.dbc` id (1-based).
id: u32,
/// Game minute-of-day (0..1440; 720 = noon).
minute: u32,
},
/// Dump the **per-weather-slot** light resolve at a world position: which `LightParams` id each
/// of the five `Light.dbc` slots names (clear / clear-underwater / storm / storm-underwater /
/// death), which of them are UNSET (and so fall back to clear), and the fog/ambient/diffuse the
/// blend resolves for each. `lightbands` only ever shows the clear slot, so an underwater or
/// ghost atmosphere that reads wrong could not be told from data that simply has no such record.
Lightslots {
/// Map id (0 = Eastern Kingdoms, 1 = Kalimdor).
map: u32,
/// World-space X (raw WoW yards; may be negative).
#[arg(allow_hyphen_values = true)]
x: f32,
/// World-space Y.
#[arg(allow_hyphen_values = true)]
y: f32,
/// World-space Z.
#[arg(allow_hyphen_values = true)]
z: f32,
/// Game minute-of-day (0..1440; 720 = noon).
minute: u32,
},
/// Bulk-scan placed doodads (MDDF) and WMO doodad-set-0 props (MODF → MODS/MODD) across a
/// `(2·tile_radius+1)²` block of ADT tiles around a world position, and report how much of that
/// content animates (see `m2anim`) — the doodad-animation perf/scope instrument.
@@ -405,6 +446,7 @@ fn main() -> Result<()> {
Command::Groundscan { prefix } => scan::groundscan(&mut chain, prefix.as_deref())?,
Command::Geosetscan { prefix } => scan::geosetscan(&mut chain, prefix.as_deref())?,
Command::Alphascan { prefix } => scan::alphascan(&mut chain, prefix.as_deref())?,
Command::Goanimscan => scan::goanimscan(&mut chain)?,
Command::Bonescan { prefix } => scan::bonescan(&mut chain, prefix.as_deref())?,
Command::Partcensus { prefix } => scan::partcensus(&mut chain, prefix.as_deref())?,
Command::Partscan { mask, prefix } => {
@@ -424,6 +466,20 @@ fn main() -> Result<()> {
// `debug_bands` takes the client's half-minute clock (0..2880).
catalog.debug_bands(map, [x, y, z], minute * 2);
}
Command::Lightparam { id, minute } => {
let catalog = benilla_formats::LightCatalog::load(&mut chain)?;
catalog.debug_param(id, minute * 2);
}
Command::Lightslots {
map,
x,
y,
z,
minute,
} => {
let catalog = benilla_formats::LightCatalog::load(&mut chain)?;
catalog.debug_slots(map, [x, y, z], minute * 2);
}
Command::Placescan {
map,
center_x,
@@ -1936,3 +1936,191 @@ pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
}
Ok(())
}
/// The GameObject animation arm's LUT (wow-re `gameobject-anim-arm.md` §2c, `.data 0x8607e4`):
/// internal **substate** → the `AnimationData.dbc` id the object layer arms.
const SUBSTATE_ANIM: [u16; 13] = [
145, // 0 Spawn — NO client path produces this substate (§2c census)
147, // 1 Closed (rest)
148, // 2 Open (motion)
149, // 3 Opened (rest)
146, // 4 Close (motion)
150, // 5 Destroy (motion)
151, // 6 Destroyed (rest)
152, // 7 Rebuild (motion)
153, 154, 155, 156, // 8..11 Custom0-3 — reachable only via SMSG_GAMEOBJECT_CUSTOM_ANIM
157, // 12 Despawn
];
/// The six substates a `GAMEOBJECT_STATE` × `GAMEOBJECT_ANIMPROGRESS` pair can actually produce
/// (§2b). Substate 0 (Spawn) has no producer at all, and 8..12 come from other opcodes entirely.
const REACHABLE: [(usize, &str); 6] = [
(1, "READY settled"),
(4, "READY mid "),
(3, "ACTIVE settled"),
(2, "ACTIVE mid "),
(6, "ALT settled"),
(5, "ALT mid "),
];
/// The §2c four-way remap: what the arm actually requests when the model doesn't author the
/// substate's LUT id. Returns `(id, rate0)` — `rate0` marks the two legs that freeze a *motion*
/// clip at frame 0 to stand in for a missing *rest* pose.
fn go_remap(m: &benilla_m2::M2Model, id: u16) -> (u16, bool) {
if m.owns_animation(id) {
return (id, false);
}
match id {
// Close missing: keep it (op4 resolves onward) if Open exists, else fall to Closed.
146 => (if m.owns_animation(148) { 146 } else { 147 }, false),
// Closed missing: keep it if Close exists; else freeze Open at frame 0; else Stand.
147 if m.owns_animation(146) => (147, false),
147 if m.owns_animation(148) => (148, true),
147 => (0, false),
// Open missing: keep it if Close exists; else Destroy if present; else Opened.
148 => (
if m.owns_animation(146) {
148
} else if m.owns_animation(150) {
150
} else {
149
},
false,
),
// Opened missing: keep it if Open exists; else freeze Close at frame 0; else Destroyed.
149 if m.owns_animation(148) => (149, false),
149 if m.owns_animation(146) => (146, true),
149 => (151, false),
// Outside the door group there is no remap — the id goes to op4 as-is.
other => (other, false),
}
}
/// op4's own id → played sequence resolve (`0x7121a0` via `0x711bf0`): the model's
/// `playableAnimationLookup` row (when the id is in range), then `animationLookup` to a file slot.
/// `None` when nothing playable comes out — the reference arms nothing and the pose simply stands.
fn go_resolve_slot(m: &benilla_m2::M2Model, id: u16) -> Option<(u16, u16)> {
let played = m
.playable_animation_lookup
.get(id as usize)
.map_or(id, |p| p.resolved_id);
let slot = *m.animation_lookup.get(played as usize)?;
(slot != 0xffff).then_some((played, slot))
}
/// The generic loader seed (§1, `0x71019b`): resolve id 0, and arm **id 0** when the model owns what
/// that resolves to — only the degenerate leg (owning nothing reachable) falls back to the raw
/// `animations[0]` dword.
fn go_loader_seed(
m: &benilla_m2::M2Model,
seqs: &[benilla_formats::ModelAnimation],
) -> Option<(u16, u16)> {
let resolved = m
.playable_animation_lookup
.first()
.map_or(0, |p| p.resolved_id);
if m.owns_animation(resolved) {
go_resolve_slot(m, 0)
} else {
// The degenerate leg: `animations[0]`'s low16 — the file-order-first sequence's own id.
go_resolve_slot(m, seqs.first()?.anim_id)
}
}
/// Sweep every model named by **GameObjectDisplayInfo.dbc** and resolve, per model, what the
/// reference's GameObject animation arm plays in each reachable `GAMEOBJECT_STATE` ×
/// `GAMEOBJECT_ANIMPROGRESS` substate — see the `Goanimscan` command doc.
pub fn goanimscan(chain: &mut Chain) -> Result<()> {
let catalog =
benilla_formats::load_gameobject_catalog(chain).context("GameObjectDisplayInfo.dbc")?;
// displayId → path, deduped to one entry per model (many displays share a model).
let mut models: BTreeMap<String, Vec<u32>> = BTreeMap::new();
for (id, path) in catalog.iter() {
let key = model_key(path);
if key.ends_with(".m2") {
models.entry(key).or_default().push(id);
}
}
for ids in models.values_mut() {
ids.sort_unstable();
}
let (mut parsed, mut no_seq, mut blind, mut sensitive, mut needs_remap, mut rate0) =
(0u32, 0u32, 0u32, 0u32, 0u32, 0u32);
for (path, displays) in &models {
let Ok(bytes) = chain.read_file(path) else {
continue;
};
let Ok(fmt) = benilla_m2::parse_m2(&mut std::io::Cursor::new(&bytes)) else {
continue;
};
let m = fmt.model();
parsed += 1;
let seqs = benilla_formats::parse_m2_animations(&bytes);
if seqs.is_empty() {
no_seq += 1;
continue;
}
let seed = go_loader_seed(m, &seqs);
let mut lines = Vec::new();
let (mut differs, mut remapped, mut froze) = (false, false, false);
for (sub, label) in REACHABLE {
let lut = SUBSTATE_ANIM[sub];
let (req, r0) = go_remap(m, lut);
let armed = go_resolve_slot(m, req);
remapped |= req != lut;
froze |= r0;
differs |= armed.map(|(_, s)| s) != seed.map(|(_, s)| s);
lines.push(format!(
" {label} sub{sub} lut {lut}{} -> {}{}",
if req == lut {
String::new()
} else {
format!(" (remap {req})")
},
match armed {
Some((id, slot)) => format!("id {id} slot {slot}"),
None => "NOTHING".to_string(),
},
if r0 { " [rate 0 — frozen]" } else { "" },
));
}
if differs {
sensitive += 1;
} else {
blind += 1;
}
remapped.then(|| needs_remap += 1);
froze.then(|| rate0 += 1);
// Only the state-SENSITIVE models are worth printing: on every other one the arm lands on
// the same sequence the loader seed already holds, so `GAMEOBJECT_STATE` is unobservable.
if differs {
println!("{path} ({} sequences, displays {displays:?})", seqs.len());
println!(
" loader seed -> {}",
match seed {
Some((id, slot)) => format!("id {id} slot {slot}"),
None => "NOTHING".to_string(),
}
);
for l in lines {
println!("{l}");
}
}
}
println!(
"\n{} GameObjectDisplayInfo M2 models, {parsed} parsed, {no_seq} with no sequences",
models.len()
);
println!(
" STATE-BLIND {blind} — every reachable substate lands on the loader seed's own \
sequence, so GAMEOBJECT_STATE cannot be seen on this model at all"
);
println!(
" STATE-SENSITIVE {sensitive} — at least one substate plays something else: exactly the \
models a GO type that skips the arm renders in the wrong pose"
);
println!(" needing the §2c remap on some substate: {needs_remap}");
println!(" hitting a rate-0 freeze leg (a motion clip standing in for a missing rest pose): {rate0}");
Ok(())
}
@@ -150,6 +150,22 @@ impl CharacterGeosets {
}
/// Load both customization DBCs from the patch chain.
///
/// **A duplicated `(race, sex, variation)` key resolves to the FIRST row** (decision 0682). Both
/// consumers are *linear scans* of the loaded table — `0x478540` for the hair geoset, `0x478740`
/// for the facial-hair record (wow-re RF-0073; `object-layer/scratch/r1.md:358` calls `0x478540`
/// "a loaded-DBC linear scan, NOT a pool") — so the earliest matching record is the one the
/// reference returns, where a last-write `HashMap::insert` would take the latest.
///
/// It matters exactly once in the shipped data, and it was B11: `CharHairGeosets` carries **four**
/// rows for **(race 9 goblin, sex 0, variation 0)** — ids 241–244, geosets 1 / 2 / 1 / 2, the only
/// duplicated key in the file (243/244 read as the goblin-*female* pair with a zeroed sex column,
/// which is why goblin females, having no row at all, were always right). Geoset 1 is the bare
/// scalp; geoset 2 is that scalp **plus** a 38-vertex topknot whose texture slot is `Monster1` —
/// a slot no character-model goblin display fills, so it drew flat white (decision 0157). All 314
/// character-model goblin displays select variation 0, so last-write put a white tuft on every one
/// of them. `CharacterFacialHairStyles` has no duplicate keys today; it takes the same rule
/// because the same scan law governs it.
pub fn load(chain: &mut Chain) -> Result<Self> {
let hair = {
let bytes = chain
@@ -162,7 +178,9 @@ impl CharacterGeosets {
if let (Some(race), Some(sex), Some(var), Some(geoset)) =
(u32_at(r, 1), u32_at(r, 2), u32_at(r, 3), u32_at(r, 4))
{
m.insert((race as u8, sex as u8, var as u8), geoset);
// FIRST row wins — see the duplicate-key note on [`CharacterGeosets::load`].
m.entry((race as u8, sex as u8, var as u8))
.or_insert(geoset);
}
}
m
@@ -187,7 +205,8 @@ impl CharacterGeosets {
u32_at(r, 7).unwrap_or(0),
u32_at(r, 8).unwrap_or(0),
];
m.insert((race as u8, sex as u8, var as u8), g);
// FIRST row wins — see the duplicate-key note on [`CharacterGeosets::load`].
m.entry((race as u8, sex as u8, var as u8)).or_insert(g);
}
}
m
@@ -216,7 +235,7 @@ impl CharacterGeosets {
}
/// CharHairGeosets.dbc — 6 fields in build 5875 (verified against the file header).
fn char_hair_geosets_schema() -> Schema {
pub(crate) fn char_hair_geosets_schema() -> Schema {
let mut s = Schema::new("CharHairGeosets");
for (name, ty) in [
("ID", FieldType::UInt32),
@@ -233,7 +252,7 @@ fn char_hair_geosets_schema() -> Schema {
/// HelmetGeosetVisData.dbc — 6 fields in build 5875 (verified: the loader `0x546f00` asserts
/// fieldCount 6 / recordSize 0x18; the 5 mask columns are **race** bitmasks, RF-0083).
fn helmet_vis_schema() -> Schema {
pub(crate) fn helmet_vis_schema() -> Schema {
let mut s = Schema::new("HelmetGeosetVisData");
for (name, ty) in [
("ID", FieldType::UInt32),
@@ -249,7 +268,7 @@ fn helmet_vis_schema() -> Schema {
}
/// CharacterFacialHairStyles.dbc — 9 fields in build 5875 (verified); no ID column (keyed race/sex/var).
fn char_facial_hair_schema() -> Schema {
pub(crate) fn char_facial_hair_schema() -> Schema {
let mut s = Schema::new("CharacterFacialHairStyles");
for (name, ty) in [
("RaceID", FieldType::UInt32),
@@ -420,4 +439,67 @@ mod tests {
let set = cg.visible_geosets(1, 0, 0, 0, &EquipGeosets::default());
assert!(set.contains(&1), "max(1, 0) = scalp geoset 1");
}
/// The repo root's `WoW/Data` (gitignored; the real-data tests skip when absent).
fn vanilla_data_dir() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data")
}
/// **B11, against the shipped DBC** (decision 0682): `CharHairGeosets` carries four rows for the
/// goblin male's only hairstyle, and the reference's linear scan takes the first — geoset **1**,
/// the bare scalp. Last-write took id 244's geoset **2**, which adds an untextured topknot.
///
/// Three things are pinned here because each on its own could rot: that the shipped file really
/// does duplicate that key (the defect only exists if it does), that we resolve it to 1, and that
/// **no other key in the table is duplicated** — which is what bounds this change to goblins.
#[test]
fn goblin_male_hair_takes_the_first_of_four_duplicate_rows() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
// The file's own rows, read independently of the loader, so the test states the premise it
// rests on rather than assuming it.
let bytes = chain.read_file(CHAR_HAIR_GEOSETS).expect("CharHairGeosets");
let rs = parse(&bytes, char_hair_geosets_schema(), "CharHairGeosets").expect("parse");
let mut keys: HashMap<(u32, u32, u32), Vec<u32>> = HashMap::new();
for r in rs.records() {
if let (Some(race), Some(sex), Some(var), Some(geoset)) =
(u32_at(r, 1), u32_at(r, 2), u32_at(r, 3), u32_at(r, 4))
{
keys.entry((race, sex, var)).or_default().push(geoset);
}
}
let dups: Vec<_> = keys.iter().filter(|(_, v)| v.len() > 1).collect();
assert_eq!(
dups.len(),
1,
"exactly one duplicated (race,sex,variation) key ships; got {dups:?}"
);
assert_eq!(
keys.get(&(9, 0, 0)).map(Vec::as_slice),
Some([1u32, 2, 1, 2].as_slice()),
"the goblin-male block is the duplicate, in file order"
);
// And the loader resolves it the reference's way.
let cg = CharacterGeosets::load(&mut chain).expect("load customization tables");
let set = cg.visible_geosets(9, 0, 0, 0, &EquipGeosets::default());
assert!(
set.contains(&1),
"goblin male shows the bare scalp (geoset 1)"
);
assert!(
!set.contains(&2),
"goblin male does NOT show geoset 2 — the white topknot of B11"
);
// The goblin female has no row at all (race 9 ships only sex-0 rows), so she falls to the
// region base, which is the same scalp. She was never affected; assert it so a future
// "fix" to the sex column has to notice her.
let f = cg.visible_geosets(9, 1, 0, 0, &EquipGeosets::default());
assert!(f.contains(&1) && !f.contains(&2), "goblin female unchanged");
}
}
@@ -26,3 +26,8 @@ mod sections;
pub use customization::{CharCreateCatalog, DialRanges, StartOutfitItem};
pub use geosets::{CharacterGeosets, EquipGeosets};
pub use sections::CharSections;
// The appearance tables' schemas, for the `benilla-extract dbc` CSV dump (`crate::schema_for`) —
// the same constructors the loaders above use, never a second transcription of the same layout.
pub(crate) use geosets::{char_facial_hair_schema, char_hair_geosets_schema, helmet_vis_schema};
pub(crate) use sections::char_sections_schema;
@@ -347,7 +347,7 @@ fn blit_over(dst: &mut BlpMipChain, src: &BlpMipChain, tile: Tile) {
}
/// CharSections.dbc — 10 fields in build 5875 (verified); 3 string columns are texture-name offsets.
fn char_sections_schema() -> Schema {
pub(crate) fn char_sections_schema() -> Schema {
let mut s = Schema::new("CharSections");
for (name, ty) in [
("ID", FieldType::UInt32),
+3 -3
View File
@@ -189,7 +189,7 @@ impl CreatureCatalog {
}
/// CreatureModelData.dbc — 16 fields in build 5875 (no `mountHeight`). We read ID, ModelName, scale.
fn creature_model_data_schema() -> Schema {
pub(crate) fn creature_model_data_schema() -> Schema {
let mut s = Schema::new("CreatureModelData");
for (name, ty) in [
("ID", FieldType::UInt32),
@@ -215,7 +215,7 @@ fn creature_model_data_schema() -> Schema {
}
/// CreatureDisplayInfo.dbc — 12 fields in build 5875. We read ID, ModelID, scale, 3 skin textures.
fn creature_display_info_schema() -> Schema {
pub(crate) fn creature_display_info_schema() -> Schema {
let mut s = Schema::new("CreatureDisplayInfo");
for (name, ty) in [
("ID", FieldType::UInt32),
@@ -242,7 +242,7 @@ fn creature_display_info_schema() -> Schema {
/// cross-checked with vmangos `DBCStructure.h`). We read the appearance selectors, the 10 equipment
/// columns (8..17 — `ItemDisplayInfo` display ids for the worn armor geosets + helm/shoulder attach),
/// and the bake name.
fn creature_display_info_extra_schema() -> Schema {
pub(crate) fn creature_display_info_extra_schema() -> Schema {
let mut s = Schema::new("CreatureDisplayInfoExtra");
for (name, ty) in [
("ID", FieldType::UInt32),
@@ -37,6 +37,12 @@ impl GameObjectCatalog {
pub fn is_empty(&self) -> bool {
self.models.is_empty()
}
/// Every `(displayId, model path)` pair, unordered — the corpus-sweep entry point for "which
/// models can a GameObject ever be, and which display ids reach each one".
pub fn iter(&self) -> impl Iterator<Item = (u32, &str)> {
self.models.iter().map(|(&id, p)| (id, p.as_str()))
}
}
/// GameObjectDisplayInfo.dbc — 12 fields in build 5875: ID, modelName, then 10 sound refs.
+1 -1
View File
@@ -112,7 +112,7 @@ impl ItemDisplayCatalog {
/// column pins). Unpinned integer columns are still typed `UInt32` (matching the file's own record
/// stride) even though nothing reads them, so the schema's field-count check against the real header
/// stays exact.
fn item_display_info_schema() -> Schema {
pub(crate) fn item_display_info_schema() -> Schema {
let mut s = Schema::new("ItemDisplayInfo");
for (name, ty) in [
("ID", FieldType::UInt32),
+94 -16
View File
@@ -63,7 +63,7 @@ pub use ground_effects::{
GroundEffectCatalog,
};
mod light;
pub use light::{Atmosphere, LightCatalog};
pub use light::{Atmosphere, LightCatalog, Submersion};
mod loading_screen;
pub use loading_screen::{load_loading_screens, LoadingScreenCatalog};
mod liquid;
@@ -74,6 +74,8 @@ mod anim_data;
pub use anim_data::{load_anim_data_catalog, AnimDataCatalog, AnimEntry};
mod quest_headers;
pub use quest_headers::{load_quest_header_names, QuestHeaderNames};
mod area_trigger;
pub use area_trigger::{load_area_trigger_catalog, AreaTriggerCatalog, AreaTriggerRow};
mod area_sound;
pub use area_sound::{
load_area_sound_catalog, AreaAudio, AreaEntry, AreaSoundCatalog, SoundAmbienceEntry,
@@ -141,20 +143,20 @@ pub use models::{
accumulate_wmo_group_camera_collision, accumulate_wmo_group_collision, hand_grip_finger_poses,
load_m2_animation_summary, load_m2_bounds, load_m2_collision_hull, load_m2_mesh,
load_m2_mesh_skinned, load_object_model, load_wmo, load_wmo_collision_tris,
m2_ribbon_emitter_count, m2_texture_transform_count, parse_m2_animation_summary,
parse_m2_animations, parse_m2_attachments, parse_m2_bounds, parse_m2_camera,
parse_m2_collision_hull, parse_m2_event_markers, parse_m2_global_sequence_bones,
parse_m2_lights, parse_m2_playable_animation_lookup, parse_m2_portrait_camera,
parse_m2_render_submeshes, parse_m2_skeleton, parse_m2_string_anchors, parse_wmo_fogs,
parse_wmo_lights, parse_wmo_portals, parse_wmo_root, wmo_group_doodad_refs,
wmo_group_footprint_tris, wmo_group_header, wmo_group_light_refs, wmo_group_liquid_mesh,
wmo_group_submeshes, wmo_root_id, AlphaAnim, AlphaSeq, AnimEvent, Billboard, BillboardKind,
BoneKeys, BoneScaleAnim, CharSkinSlot, CollisionMesh, EmitterBoneLink, EventMarker, FogPolicy,
FootprintTris, GlobalSeqBone, GlobalSeqChannel, GroundQuad, M2AnimSummary, M2Attachment,
M2Bounds, M2Light, M2PortraitCamera, ModelAnimation, ModelBlend, PlayableAnim, RenderSubmesh,
RgbAnim, ScalarAnim, Skeleton, SkeletonBone, StringAnchors, UvAnim, WmoBatchClass, WmoDoodad,
WmoDoodadSet, WmoFog, WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef,
WmoPortals, WmoRoot,
m2_ribbon_emitter_count, m2_texture_transform_count, parse_m2_animation_lookup,
parse_m2_animation_summary, parse_m2_animations, parse_m2_attachments, parse_m2_bounds,
parse_m2_camera, parse_m2_collision_hull, parse_m2_event_markers,
parse_m2_global_sequence_bones, parse_m2_lights, parse_m2_playable_animation_lookup,
parse_m2_portrait_camera, parse_m2_render_submeshes, parse_m2_skeleton,
parse_m2_string_anchors, parse_wmo_fogs, parse_wmo_lights, parse_wmo_portals, parse_wmo_root,
wmo_group_doodad_refs, wmo_group_footprint_tris, wmo_group_header, wmo_group_light_refs,
wmo_group_liquid_mesh, wmo_group_submeshes, wmo_root_id, AlphaAnim, AlphaSeq, AnimEvent,
Billboard, BillboardKind, BoneKeys, BoneScaleAnim, CharSkinSlot, CollisionMesh,
EmitterBoneLink, EventMarker, FogPolicy, FootprintTris, GlobalSeqBone, GlobalSeqChannel,
GroundQuad, M2AnimSummary, M2Attachment, M2Bounds, M2Light, M2PortraitCamera, ModelAnimation,
ModelBlend, PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim, Skeleton, SkeletonBone,
StringAnchors, UvAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog, WmoGroupHeader,
WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot,
};
mod terrain;
pub use terrain::{
@@ -351,6 +353,40 @@ pub fn blp_bytes_to_mip_chain(bytes: &[u8]) -> Result<BlpMipChain> {
fn schema_for(dbc_name: &str) -> Option<Schema> {
let base = dbc_name.rsplit(['/', '\\']).next().unwrap_or(dbc_name);
// Tables whose schema a typed loader in this crate already declares: reuse *that* constructor
// rather than re-transcribing the layout here, so a dump can never disagree with what the client
// actually reads (the six hand-written schemas below predate any loader for their table).
// The appearance family is registered because "which row does this NPC render from?" is a
// recurring question; the crate has ~30 more loader schemas that could join this list one line
// at a time as they are needed.
for (name, ctor) in [
(
"CreatureDisplayInfo.dbc",
creatures::creature_display_info_schema as fn() -> Schema,
),
(
"CreatureDisplayInfoExtra.dbc",
creatures::creature_display_info_extra_schema,
),
(
"CreatureModelData.dbc",
creatures::creature_model_data_schema,
),
("CharHairGeosets.dbc", characters::char_hair_geosets_schema),
(
"CharacterFacialHairStyles.dbc",
characters::char_facial_hair_schema,
),
("HelmetGeosetVisData.dbc", characters::helmet_vis_schema),
("CharSections.dbc", characters::char_sections_schema),
("ItemDisplayInfo.dbc", items::item_display_info_schema),
("AreaTrigger.dbc", area_trigger::area_trigger_schema),
] {
if base.eq_ignore_ascii_case(name) {
return Some(ctor());
}
}
if base.eq_ignore_ascii_case("TaxiNodes.dbc") {
// 16 fields (record_size 64), verified against build 5875.
let mut s = Schema::new("TaxiNodes");
@@ -477,7 +513,9 @@ pub fn dbc_to_csv(dbc_bytes: &[u8], dbc_name: &str, out: &Path) -> Result<(u32,
anyhow::anyhow!(
"no schema defined for '{dbc_name}' ({fields} fields); known: TaxiNodes.dbc, \
AreaTable.dbc, GameObjectDisplayInfo.dbc, TaxiPath.dbc, TaxiPathNode.dbc, \
TransportAnimation.dbc"
TransportAnimation.dbc, CreatureDisplayInfo.dbc, CreatureDisplayInfoExtra.dbc, \
CreatureModelData.dbc, CharHairGeosets.dbc, CharacterFacialHairStyles.dbc, \
HelmetGeosetVisData.dbc, CharSections.dbc, ItemDisplayInfo.dbc"
)
})?;
@@ -567,4 +605,44 @@ mod tests {
);
assert_eq!(chain.mips[0].len(), 2 * 2 * 4);
}
/// Every table the CSV dumper claims in its error hint really has a schema, and each dumps
/// against the **shipped** file — which is the only check that matters, because `with_schema`
/// rejects a field-count mismatch and `dbc_to_csv` would otherwise fail only when a session
/// reached for it mid-investigation. Skips without the client data.
#[test]
fn registered_dbc_schemas_dump_the_shipped_tables() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = open_chain(&data).expect("open chain");
let out = std::env::temp_dir().join("benilla-schema-registry-test.csv");
for table in [
"CreatureDisplayInfo",
"CreatureDisplayInfoExtra",
"CreatureModelData",
"CharHairGeosets",
"CharacterFacialHairStyles",
"HelmetGeosetVisData",
"CharSections",
"ItemDisplayInfo",
"TaxiNodes",
"AreaTable",
"GameObjectDisplayInfo",
"TaxiPath",
"TaxiPathNode",
"TransportAnimation",
] {
let path = format!("DBFilesClient\\{table}.dbc");
let bytes = chain
.read_file(&path)
.unwrap_or_else(|e| panic!("{path}: {e}"));
let (rows, _) = dbc_to_csv(&bytes, &path, &out)
.unwrap_or_else(|e| panic!("{table} schema does not fit the shipped file: {e}"));
assert!(rows > 0, "{table} has rows");
}
let _ = std::fs::remove_file(&out);
}
}
+181 -7
View File
@@ -48,9 +48,11 @@ const POS_SCALE: f32 = 36.0;
/// Light.dbc weather param slots: [0]=clear, [1]=clear-underwater, [2]=storm, [3]=storm-underwater,
/// [4]=death.
const SLOT_CLEAR: usize = 0;
/// `[1]` clear-underwater: the denser/greener fog + cooler light the client switches to when the eye
/// is submerged (VERIFIED apitrace WoW.18: ~38 yd teal fog + blue ambient/diffuse vs the ~1284 yd
/// surface fog). Same band-row meanings as the clear slot, just a different `LightParams` id.
/// `[1]` clear-underwater: the denser fog + cooler light the client switches to when the eye is
/// submerged in **water** (VERIFIED apitrace WoW.18: ~38 yd teal fog + blue ambient/diffuse vs the
/// ~1284 yd surface fog). Same band-row meanings as the clear slot, just a different `LightParams` id.
/// Reached only by [`Submersion::Water`] — magma and slime never consult a zone slot at all
/// (see [`PARAM_MAGMA`]).
const SLOT_CLEAR_UNDERWATER: usize = 1;
const SLOT_STORM: usize = 2;
const SLOT_STORM_UNDERWATER: usize = 3;
@@ -60,9 +62,68 @@ const SLOT_STORM_UNDERWATER: usize = 3;
/// priority over weather/underwater exactly as the single global slot does.
const SLOT_DEATH: usize = 4;
/// The **fixed global `LightParams` rows** the reference uses for submersion in the two fullbright
/// liquids — byte-VERIFIED (`0x6d2371` routes magma and slime to their own leg *before* the slot
/// selection: magma at `0x6d23e1` reads row 7, slime at `0x6d239e` reads row 6, each guarded on the
/// table's `maxId`). These are **zone-independent** and nothing in `Light.dbc` references them, which
/// is why no position-keyed dump ever surfaced them: submersion in lava does not read the zone's
/// underwater slot at all.
///
/// The shipped rows are exactly the vanilla look, which is the cross-check that earned this:
/// magma fog `[200,52,0]` at **27 yd** with start fraction **−2.0** (67 % fiery orange at the eye),
/// slime fog `[0,255,0]` at **50 yd** with **−1.0** (50 % green). There is no separate magma/slime
/// fog constant anywhere in the binary (VERIFIED negative) — these two rows *are* the distinct look.
const PARAM_SLIME: u32 = 6;
const PARAM_MAGMA: u32 = 7;
/// What the camera eye is submerged in, if anything — the atmosphere selector. Water and ocean pick
/// the zone's *underwater slot*; magma and slime replace the whole area blend with a fixed global row
/// (see [`PARAM_MAGMA`]/[`PARAM_SLIME`]).
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum Submersion {
/// Dry — the ordinary clear/storm slot.
#[default]
Dry,
/// Under water, ocean or rapids: the zone's own underwater `LightParams`, area-blended as usual.
Water,
/// Under magma: the fixed global row, verbatim.
Magma,
/// Under slime: the fixed global row, verbatim.
Slime,
}
impl Submersion {
/// Submerged in **any** liquid.
pub fn any(self) -> bool {
self != Submersion::Dry
}
/// Submerged specifically in a WATER kind (water / ocean / rapids) — the predicate for the
/// consumers that are about water rather than about liquid: the `UnderWaterLoop` ambience bed and
/// the "Underwater" reverb-preference column.
///
/// Deliberately NOT [`Self::any`]: whether the reference swaps the submerged ambience bed and the
/// reverb preset for **lava and slime** as well is unverified, and widening it silently on the way
/// past would be inventing a behaviour. The atmosphere is verified per kind; the audio is not.
pub fn is_water(self) -> bool {
self == Submersion::Water
}
/// The fixed global `LightParams` row this submersion replaces the whole blend with, if any.
/// `None` for [`Submersion::Dry`] and [`Submersion::Water`], which go through the slot selection.
fn fixed_param(self) -> Option<u32> {
match self {
Submersion::Magma => Some(PARAM_MAGMA),
Submersion::Slime => Some(PARAM_SLIME),
Submersion::Dry | Submersion::Water => None,
}
}
}
/// Pick the `LightParams` slot for the current ghost/weather/submersion state (see `SLOT_*`).
/// Ghost wins outright — the client keeps ONE active slot, and the ghost watcher owns it while
/// the flag is up.
/// the flag is up. Only [`Submersion::Water`] reaches the underwater slots; the fullbright liquids
/// never get here (their fixed row short-circuits the blend).
fn weather_slot(ghost: bool, stormy: bool, underwater: bool) -> usize {
if ghost {
return SLOT_DEATH;
@@ -304,6 +365,19 @@ impl LightCatalog {
}
x => x,
};
self.debug_param(p, time);
}
/// Debug: [`Self::debug_bands`] for an explicitly named `LightParams` id, with no position and no
/// slot selection. The instrument for the rows **nothing in `Light.dbc` references**: magma and
/// slime submersion do not read a zone's underwater slot at all, they read the fixed global rows
/// 7 and 6 (VERIFIED `0x6d2371`, guards `maxId >= 7`/`>= 6`), which no position-keyed dump can
/// ever reach.
pub fn debug_param(&self, p: u32, time: u32) {
if p < 1 {
println!("(LightParams ids are 1-based)");
return;
}
println!("LightParams {p} @ time {time} half-min — all int rows (sRGB 0..255):");
for b in 0..18u32 {
let key = (p - 1) * 18 + b + 1;
@@ -348,10 +422,20 @@ impl LightCatalog {
pos: [f32; 3],
time: u32,
stormy: bool,
underwater: bool,
submersion: Submersion,
ghost: bool,
) -> Atmosphere {
let slot = weather_slot(ghost, stormy, underwater);
// Magma/slime submersion replaces the WHOLE area blend with one fixed global row — the
// reference branches out at `0x6d2371`, before the slot selection, so there is no zone
// sphere blend and no storm lerp on this leg (see [`PARAM_MAGMA`]). Guarded on the row
// actually existing, matching the binary's own `maxId` guard: a chain without it falls
// through to the ordinary atmosphere rather than inventing one.
if let Some(p) = submersion.fixed_param() {
if self.has_param(p) {
return self.sample_param(p, time);
}
}
let slot = weather_slot(ghost, stormy, submersion == Submersion::Water);
let atmo_of = |l: &Light| -> Option<Atmosphere> {
// Fall back to the clear slot if the requested slot is unset for this light (e.g. a zone
// with no dedicated underwater param degrades to the above-water atmosphere).
@@ -453,7 +537,7 @@ impl LightCatalog {
);
}
let picked = self.sample(map, pos, time, false);
let blended = self.sample_blended(map, pos, time, false, false, false);
let blended = self.sample_blended(map, pos, time, false, Submersion::Dry, false);
println!(
" => pick_light : amb {} sun {}",
c(picked.ambient),
@@ -466,6 +550,87 @@ impl LightCatalog {
);
}
/// Debug: the **per-weather-slot** resolve at `pos` — for each of the five `Light.dbc` param
/// slots (clear / clear-underwater / storm / storm-underwater / death), which `LightParams` id
/// the picked sphere and the continent global each name, whether the slot is UNSET (and so falls
/// back to clear), and the fog/ambient/diffuse the blend actually resolves.
///
/// The companion to [`Self::debug_bands`], which only ever dumps the clear slot: an underwater or
/// ghost atmosphere that reads wrong is either a slot the data leaves unset (→ we correctly show
/// the surface atmosphere) or a slot whose values we mis-consume, and those two look identical
/// from inside the game. This tells them apart in one command.
pub fn debug_slots(&self, map: u32, pos: [f32; 3], time: u32) {
const NAMES: [&str; 5] = [
"clear",
"clear-underwater",
"storm",
"storm-underwater",
"death",
];
let c = |x: [f32; 3]| {
format!(
"[{:3} {:3} {:3}]",
(x[0] * 255.0) as u8,
(x[1] * 255.0) as u8,
(x[2] * 255.0) as u8
)
};
let picked = self.pick_light(map, pos);
let global = self.lights.iter().find(|l| l.map == map && l.global);
println!(
"Light slots on map {map} at [{:.1} {:.1} {:.1}], time {time} half-min:",
pos[0], pos[1], pos[2]
);
match picked {
Some(l) if l.global => {
println!(" picked sphere : (none local — the continent global)")
}
Some(l) => println!(
" picked sphere : local, falloff {:.0}->{:.0} yd, params {:?}",
l.falloff_start, l.falloff_end, l.params
),
None => println!(" picked sphere : (no light covers this position)"),
}
if let Some(g) = global {
println!(" continent glob: params {:?}", g.params);
}
println!(
" {:<17} {:>6} {:>6} {:>9} {:>6} {:<15} {:<15} {:<15}",
"slot", "picked", "global", "fog_end", "frac", "fog_color", "ambient", "diffuse"
);
for (slot, name) in NAMES.iter().enumerate() {
// The same UNSET→clear fallback both sample paths apply, reported per source so an
// "unset everywhere" slot is visibly distinct from one carrying real values.
let show = |l: Option<&Light>| match l.map(|l| l.params[slot]) {
None => " -".to_string(),
Some(0) => " unset".to_string(),
Some(p) => format!("{p:>6}"),
};
let a = self.sample_blended(
map,
pos,
time,
slot == SLOT_STORM || slot == SLOT_STORM_UNDERWATER,
if slot == SLOT_CLEAR_UNDERWATER || slot == SLOT_STORM_UNDERWATER {
Submersion::Water
} else {
Submersion::Dry
},
slot == SLOT_DEATH,
);
println!(
" {name:<17} {} {} {:>9.1} {:>6.2} {:<15} {:<15} {:<15}",
show(picked),
show(global),
a.fog_end,
a.fog_start_frac,
c(a.fog_color),
c(a.ambient),
c(a.sun_diffuse)
);
}
}
fn pick_light(&self, map: u32, pos: [f32; 3]) -> Option<&Light> {
let mut local: Option<&Light> = None;
let mut global: Option<&Light> = None;
@@ -484,6 +649,15 @@ impl LightCatalog {
local.or(global)
}
/// Whether `LightParams` `p` actually has band rows in this chain — the client's `maxId` guard on
/// the fixed magma/slime rows. Checks the fog-colour row, which every real record carries.
fn has_param(&self, p: u32) -> bool {
p >= 1
&& self
.int_bands
.contains_key(&((p - 1) * 18 + IB_FOG_COLOR + 1))
}
fn sample_param(&self, p: u32, t: u32) -> Atmosphere {
let ib = |b: u32| self.int_bands.get(&((p - 1) * 18 + b + 1));
let fb = |b: u32| self.float_bands.get(&((p - 1) * 6 + b + 1));
+10
View File
@@ -200,6 +200,16 @@ pub fn parse_m2_playable_animation_lookup(bytes: &[u8]) -> Result<Vec<PlayableAn
.collect())
}
/// Parse the M2's **AnimationLookup** table (header `count@+0x24`/`ofs@+0x28`) — `AnimationData.dbc`
/// id → this model's first sequence slot for it, `0xffff` where it authors none. The table behind the
/// reference's ownership predicate `0x711960`; see [`benilla_m2::M2Model::owns_animation`], which is
/// what callers should normally use rather than indexing this by hand.
pub fn parse_m2_animation_lookup(bytes: &[u8]) -> Result<Vec<u16>> {
let format =
parse_m2(&mut Cursor::new(bytes)).map_err(|e| anyhow::anyhow!("parsing M2: {e}"))?;
Ok(format.model().animation_lookup.clone())
}
/// One bone's keyframe tracks for an animation sequence (decision 0019), in **raw WoW model space**:
/// translation/scale are `[f32;3]`, rotation is an uncompressed quaternion `[x,y,z,w]` (vanilla v256).
/// Times are **seconds**, rebased to the sequence start so each clip runs `0..duration`. Only the
+10 -3
View File
@@ -424,15 +424,22 @@ pub fn wmo_group_submeshes(group_bytes: &[u8], root: &WmoRoot) -> Result<Vec<Ren
// warm pane seen from inside — MM_ELWYNN_WND_INT, MM_STAINED_01).
let flags = material.map_or(0, |m| m.flags);
let emissive = !interior && flags & 0x01 != 0;
// WMO: keep two-sided for now (the MOMT two-sided bit is ambiguous between sources, and
// single-siding a wall wrong makes it vanish). Buildings aren't the canopy issue.
// UNCULLED (0x04): the real client backface-culls every WMO batch unless this MOMT
// flag is set — byte-verified at the render-state write (wow-re models.md: bit 0x04
// inverted → EGxRs 0x14 GL_CULL_FACE; default cull ON, GL_BACK/CCW) — the same law the
// M2 path already honours. This used to be hardcoded two-sided ("buildings aren't the
// canopy issue"), and a building IS the canopy issue: 1.12 authors both-sides-visible
// cloth as the SAME faces twice with reversed winding (one copy per side — B38's tent
// awning, `wmo_doubled`), each meant to be culled from its wrong side. Drawing them
// two-sided rasterises both copies into an ulp-level depth tie, and the per-pixel
// winner is floating-point noise — the B38 flicker/latch (decision 0677).
// WMO has no skeleton, so the per-vertex skin (`_globals`) is unused.
let (mut submesh, _globals) = remap_submesh(
global_indices.into_iter(),
vertex,
texture,
blend,
true,
flags & 0x04 != 0,
interior,
emissive,
);
+16 -14
View File
@@ -88,24 +88,26 @@ impl WdlFile {
(0..4096u32).filter_map(move |i| self.tiles[i as usize].as_ref().map(|_| (i % 64, i / 64)))
}
/// Present WDL tiles forming a Chebyshev **ring** around a world `(x, y)`: every present tile
/// within `radius` tiles, **excluding** those within `skip_inner` tiles (the detailed-ADT ring the
/// renderer already draws — WDL must not double-draw under it; see terrain.md WDL "depth/blend").
pub fn tiles_in_ring(
&self,
world_x: f32,
world_y: f32,
radius: u32,
skip_inner: u32,
) -> Vec<(u32, u32)> {
/// Present WDL tiles forming a Chebyshev **window** around a world `(x, y)`: every present tile
/// within `radius` tiles, **the centre one included** — the reference's far walk (`0x683040`)
/// visits a camera-centred ±3-tile window of the distant grid with no exclusion, and so must ours.
///
/// There used to be a `skip_inner` parameter, and the renderer passed 0 — dropping the camera's
/// OWN tile, on the reasoning that a 533 yd tile sits entirely inside the far-clip wall and would
/// be discarded anyway. That holds only near the default `farclip` of 777. **Drop the view
/// distance below a tile — the vanilla range reaches down to 177 — and most of the camera's own
/// tile lies BEYOND the wall, where it is the only thing that can draw the near horizon.** Its
/// absence is then a gap between the detailed terrain and the distant hills, right above the
/// horizon line, through which the sky pours (decision 0684 — the director's Weazel's Crater
/// report at view distance 320). The parameter is gone rather than defaulted so the hole cannot
/// come back: what bounds the band on the near side is the far band's near plane in `wdl.wgsl`,
/// and nothing else.
pub fn tiles_in_ring(&self, world_x: f32, world_y: f32, radius: u32) -> Vec<(u32, u32)> {
let (cx, cy) = benilla_wdt::world_to_tile(world_x, world_y);
let (r, inner) = (radius as i32, skip_inner as i32);
let r = radius as i32;
let mut out = Vec::new();
for dy in -r..=r {
for dx in -r..=r {
if dx.abs() <= inner && dy.abs() <= inner {
continue; // the detailed ring — drawn at full detail, so skip WDL here
}
let (tx, ty) = (cx as i32 + dx, cy as i32 + dy);
if (0..64).contains(&tx)
&& (0..64).contains(&ty)
+175
View File
@@ -961,3 +961,178 @@ fn decodes_azeroth_wdl_against_apitrace_ground_truth() {
assert!((xmax - (-8533.3)).abs() < 0.5, "tile origin X, got {xmax}");
assert!((ymax - (-1066.7)).abs() < 0.5, "tile origin Y, got {ymax}");
}
/// The two **fixed global `LightParams` rows** magma and slime submersion read (`light::PARAM_MAGMA`
/// / `PARAM_SLIME`, byte-VERIFIED `0x6d2371`), pinned against the shipped client.
///
/// This is the cross-check that earned the finding rather than merely repeating it: nothing in
/// `Light.dbc` references rows 6 or 7, so no position-keyed sample can reach them and no zone can
/// vouch for them. What vouches for them is that they decode to *exactly* the vanilla lava and slime
/// view — dense fiery orange and dense pure green — which a wrong pair of row numbers would not.
#[test]
fn magma_and_slime_submersion_read_the_fixed_global_light_params() {
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 cat = benilla_formats::LightCatalog::load(&mut chain).expect("load Light DBCs");
let to255 = |c: [f32; 3]| {
[
(c[0] * 255.0).round() as i32,
(c[1] * 255.0).round() as i32,
(c[2] * 255.0).round() as i32,
]
};
// Two positions on different continents whose CLEAR atmospheres differ — the fixed rows must be
// identical at both, because they are zone-independent. This is the property that separates "a
// fixed global row" from "a zone underwater slot that happens to look right at one pin".
let pins = [
(0u32, [-7531.21f32, -1123.64, 172.58]), // Blackrock Mountain (the B24/B68 lava pin)
(1u32, [-5075.53f32, -2063.09, -50.10]), // Thousand Needles (the B68 water pin)
];
for kind in [
benilla_formats::Submersion::Magma,
benilla_formats::Submersion::Slime,
] {
let sampled: Vec<_> = pins
.iter()
.map(|(map, pos)| cat.sample_blended(*map, *pos, 1440, false, kind, false))
.collect();
assert_eq!(
to255(sampled[0].fog_color),
to255(sampled[1].fog_color),
"{kind:?} is zone-INDEPENDENT: the fixed row must not vary by position"
);
assert_eq!(
sampled[0].fog_end, sampled[1].fog_end,
"{kind:?} fog end must not vary by position"
);
}
// Magma → row 7. Dense fiery orange, and the shortest fog in the client: 972/36 = 27 yd, with a
// −2.0 start fraction putting `1 − 27/(27+54)` = 67 % of it at the eye itself.
let magma = cat.sample_blended(
0,
pins[0].1,
1440,
false,
benilla_formats::Submersion::Magma,
false,
);
assert_eq!(
to255(magma.fog_color),
[200, 52, 0],
"magma fog (row 7 int 7)"
);
assert_eq!(to255(magma.ambient), [255, 55, 0], "magma ambient");
assert!(
(magma.fog_end - 27.0).abs() < 0.01,
"magma fog end 972/36 = 27 yd, got {}",
magma.fog_end
);
assert!(
(magma.fog_start_frac + 2.0).abs() < 1e-6,
"magma start fraction −2.0, got {}",
magma.fog_start_frac
);
// Slime → row 6. Pure green at 1800/36 = 50 yd, −1.0 start = 50 % at the eye.
let slime = cat.sample_blended(
0,
pins[0].1,
1440,
false,
benilla_formats::Submersion::Slime,
false,
);
assert_eq!(
to255(slime.fog_color),
[0, 255, 0],
"slime fog (row 6 int 7)"
);
assert_eq!(to255(slime.ambient), [0, 60, 0], "slime ambient");
assert!(
(slime.fog_end - 50.0).abs() < 0.01,
"slime fog end 1800/36 = 50 yd, got {}",
slime.fog_end
);
assert!(
(slime.fog_start_frac + 1.0).abs() < 1e-6,
"slime start fraction −1.0, got {}",
slime.fog_start_frac
);
// And the whole point: neither is the zone's own underwater atmosphere. At the Thousand Needles
// pin the water murk is the olive-brown LP 203 — being in lava there must not show it.
let water = cat.sample_blended(
1,
pins[1].1,
1440,
false,
benilla_formats::Submersion::Water,
false,
);
assert_ne!(
to255(water.fog_color),
to255(magma.fog_color),
"lava must not inherit the zone's water murk"
);
assert_ne!(
to255(water.fog_color),
to255(slime.fog_color),
"slime must not inherit the zone's water murk"
);
// Dry is different again — the guard against "everything resolved to one fallback".
let dry = cat.sample_blended(
1,
pins[1].1,
1440,
false,
benilla_formats::Submersion::Dry,
false,
);
assert_ne!(
to255(dry.fog_color),
to255(water.fog_color),
"the water pin's underwater slot must differ from its clear slot"
);
}
/// The far band's tile window **contains the camera's own tile** (decision 0684). Dropping it was
/// invisible at the default view distance — a 533 yd tile sits inside a 777 yd wall, so it would
/// have been discarded anyway — and a hole above the horizon at any lower one, where the own tile is
/// the only thing that draws the near horizon. The director found it at view distance 320 in
/// Weazel's Crater; this pins the window at the coordinates they reported.
#[test]
fn the_wdl_window_contains_the_cameras_own_tile() {
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 wdl = benilla_formats::WdlFile::load(&mut chain, "Kalimdor").expect("load Kalimdor.wdl");
// Weazel's Crater, Thousand Needles — the report's spot. The client's debug panel read
// `tile 39,42` standing there, which is what `world_to_tile` must agree on.
let (x, y) = (-5841.9_f32, -3802.4);
let own = benilla_wdt::world_to_tile(x, y);
assert_eq!(own, (39, 42), "the reported tile for Weazel's Crater");
assert!(wdl.is_present(own.0, own.1), "the own tile is authored");
let window = wdl.tiles_in_ring(x, y, 5);
assert!(
window.contains(&own),
"the camera's own WDL tile must be in the window — without it the near horizon is a hole"
);
// And it is a full (2r+1)² window, not a ring: every present tile within the radius.
let expected = (-5i32..=5)
.flat_map(|dy| (-5i32..=5).map(move |dx| (own.0 as i32 + dx, own.1 as i32 + dy)))
.filter(|&(tx, ty)| (0..64).contains(&tx) && (0..64).contains(&ty))
.filter(|&(tx, ty)| wdl.is_present(tx as u32, ty as u32))
.count();
assert_eq!(window.len(), expected, "every present tile in the window");
}
@@ -0,0 +1,146 @@
//! Every book in the world opened and closed constantly (`#bugs` B36) — because the loader-idle
//! seed armed the model's **file-order-first** sequence, and on a book that sequence is the *Close
//! motion*, not the closed pose.
//!
//! `G_BookOpenMediumBrown.m2` is the model that makes this unmissable. Its four sequences are the
//! whole door family — Close(146) / Closed(147) / Open(148) / Opened(149) — authored in **that**
//! order, so file-order-0 is a 0.333 s motion that sweeps bone 0 from the open angle to the closed
//! one. Every one of the four carries `flags` bit 0 clear, i.e. the kernel loops it forever (wow-re
//! `gameobject-anim-arm.md` §3, `0x714585`). Looping the Close motion is therefore a book that snaps
//! open and swings shut three times a second, for ever — exactly what was reported.
//!
//! The reference arms **animation id 0 (Stand)** resolved through the model's own
//! `playableAnimationLookup` (§1, byte-verified `0x71019b`), and this book's table sends id 0 to
//! **147 Closed** — a two-key band whose keys are both identity, i.e. a still, shut book.
//!
//! The DuelingFlag test next door pins the same seed law on a model whose first sequence is a
//! *Spawn*; this one pins it where the wrong pick is a **motion**, which is the visually loudest
//! failure and the one a whole class of GameObjects (every `World\Goober\` door-family prop) shares.
//! Skips (passes) when the client isn't present at `<repo>/WoW/Data`.
use std::path::PathBuf;
use benilla_formats::{
open_chain, parse_m2_animation_lookup, parse_m2_animations, parse_m2_playable_animation_lookup,
};
fn vanilla_data_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data")
}
const BOOK: &str = "World\\Goober\\G_BookOpenMediumBrown.m2";
/// `AnimationData.dbc`: 146 Close (motion), 147 Closed (rest), 148 Open (motion), 149 Opened (rest).
const CLOSE: u16 = 146;
const CLOSED: u16 = 147;
const OPEN: u16 = 148;
#[test]
fn the_book_idle_resolves_to_closed_not_the_close_motion() {
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 bytes = chain.read_file(BOOK).expect("the book M2 is in the chain");
let anims = parse_m2_animations(&bytes);
// 1. File order really does lead with the Close MOTION — without this the test proves nothing.
assert_eq!(
anims.first().map(|a| a.anim_id),
Some(CLOSE),
"the model's FIRST sequence is the Close motion, not a rest pose — the whole point"
);
// 2. That first sequence loops (bit 0 clear), so arming it is a permanent flap, not a one-shot.
assert!(
anims[0].looping,
"Close is a looping band — arming it is what made the book cycle for ever"
);
// 3. The loader's seed resolves to Closed through the model's own table.
let playable = parse_m2_playable_animation_lookup(&bytes).expect("playable lookup");
let idle_id = playable.first().map_or(0, |p| p.resolved_id);
assert_eq!(
idle_id, CLOSED,
"playableAnimationLookup[0] resolves to Closed — the seed the reference arms"
);
// 4. The pose each band actually holds, as the viewer would see it. This is the assertion that
// catches the bug coming back even if the id plumbing is rewritten. Bone 0's rotation is a
// pure x-axis quaternion: x ≈ 0 is the shut book, x ≈ 0.69 (≈ 87°) the open one.
//
// Closed's band (533..633 ms) has NO keys of its own — the authored keys sit at 500 and 667,
// both identity — so the window rule (decision 0643) resolves it to a single constant key.
// One key here is the *correct* answer, not a missing one: the band genuinely doesn't move.
let root_rot = |id: u16| -> Vec<[f32; 4]> {
anims
.iter()
.find(|a| a.anim_id == id)
.and_then(|a| a.bones.iter().find(|b| b.bone == 0))
.map(|b| b.rotation.iter().map(|(_, q)| *q).collect())
.unwrap_or_default()
};
let closed = root_rot(CLOSED);
assert!(!closed.is_empty(), "Closed must resolve to a pose");
for q in &closed {
assert!(
q[0].abs() < 1e-3,
"Closed must hold the SHUT pose (bone 0 x ≈ 0), got {}",
q[0]
);
}
// Close, by contrast, sweeps: it starts at the open angle and lands shut. Looping *that* is
// the flapping book — so both halves of the claim are pinned, not just the one we fixed.
let close_motion = root_rot(CLOSE);
let (lo, hi) = close_motion
.iter()
.fold((f32::MAX, f32::MIN), |(lo, hi), q| {
(lo.min(q[0]), hi.max(q[0]))
});
assert!(
hi - lo > 0.5 && lo.abs() < 1e-3 && hi > 0.5,
"Close must swing from open ({hi}) to shut ({lo}) — that sweep, looped, IS the reported bug"
);
// 5. Closed is a non-BIND pose, which is what keeps the book on the skinned path at all: its
// two cover bones sit at ±2.5° while bind has them square. If the idle ever stopped counting
// as "differs from bind" the book would drop to the static mesh — silently, because the
// difference is small enough to pass an eyeball and large enough to be the wrong book.
let closed_seq = anims
.iter()
.find(|a| a.anim_id == CLOSED)
.expect("the book authors Closed");
let tilted = closed_seq
.bones
.iter()
.filter(|b| {
b.rotation
.iter()
.any(|(_, q)| (q[3].abs() - 1.0).abs() > 1e-4)
})
.count();
assert!(
tilted >= 2,
"Closed must pose bones off bind (the covers), got {tilted}"
);
// 6. The ownership table the GameObject arm's missing-sequence remap branches on (the
// reference's `0x711960`): this model owns the whole door family and nothing beyond it, so
// no remap leg is reachable here and the arm plays the LUT id directly.
let lookup = parse_m2_animation_lookup(&bytes).expect("animation lookup");
let owns = |id: u16| lookup.get(id as usize).is_some_and(|&s| s != 0xffff);
for id in [CLOSE, CLOSED, OPEN, 149] {
assert!(owns(id), "the book authors animation id {id}");
}
assert!(
!owns(0),
"the book authors NO Stand — the seed reaches Closed only through the playable table, \
which is exactly why a naive `find(Stand)` would arm nothing"
);
assert!(
!owns(150),
"ids past the table's end read as the out-of-bounds sentinel, i.e. not owned"
);
}
+18 -1
View File
@@ -82,7 +82,11 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result<M2Format> {
p += 8;
let _anim = arr(p)?;
p += 8;
let _anim_lookup = arr(p)?;
// AnimationLookup (header `+0x24`/`+0x28`): `AnimationData.dbc` id → this model's first sequence
// slot for it, `0xffff` where the model doesn't author it. The reference's "does this model own
// animation id X" predicate (`0x711960`) is exactly a bounds-checked read of this table, so it is
// parsed rather than inferred from the sequence list — see [`M2Model::owns_animation`].
let animation_lookup_arr = arr(p)?;
p += 8;
// PlayableAnimationLookup (decision 0082, header `+0x2c`/`+0x30`, pre-Wrath only — dropped past
// version 263, which `parse_m2`'s own top guard already excludes, so this is unconditional on any
@@ -311,6 +315,18 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result<M2Format> {
});
}
// AnimationLookup: one u16 each — the id → sequence-slot table `owns_animation` reads.
let al_avail = b.len().saturating_sub(animation_lookup_arr.1 as usize);
let mut animation_lookup =
Vec::with_capacity(capped(animation_lookup_arr.0 as usize, 2, al_avail));
for i in 0..animation_lookup_arr.0 as usize {
animation_lookup.push(
get(animation_lookup_arr.1 as usize + i * 2, 2)?
.u16_at(0)
.ok_or(Error::Truncated)?,
);
}
// PlayableAnimationLookup (decision 0082): one dword each, low16 resolved id / high16 dir flags.
let pal_avail = b
.len()
@@ -457,6 +473,7 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result<M2Format> {
pivot_attach_z,
attachments: attachment_list,
event_markers,
animation_lookup,
playable_animation_lookup,
views,
version,
+22
View File
@@ -268,6 +268,12 @@ pub struct M2Model {
/// (`$CSL`/`$CSR`/`$CST`), the ranged release (`$BWR`), the bow anchors, the sound/impact tags'
/// anchor points.
pub event_markers: Vec<M2EventMarker>,
/// The model's **AnimationLookup** (header `+0x24`/`+0x28`): row `i` is the file slot of this
/// model's first sequence for `AnimationData.dbc` id `i`, or `0xffff` where it authors none.
/// This is the table behind the reference's ownership predicate — see [`M2Model::owns_animation`].
/// Note the table is *short*: a model's `nAnimationLookup` stops just past the highest id it
/// authors, so an id beyond the end reads as the out-of-bounds sentinel, i.e. **not owned**.
pub animation_lookup: Vec<u16>,
/// The model's baked **PlayableAnimationLookup** (decision 0082 — see [`M2PlayableAnim`]): row `i`
/// is the model's own precomputed substitute for requested `AnimationData.dbc` id `i`. Empty for a
/// version past 263 (the array is dropped) or a model whose header couldn't be read that far — the
@@ -278,6 +284,22 @@ pub struct M2Model {
pub(crate) version: u32,
}
impl M2Model {
/// **Does this model author `AnimationData.dbc` id `anim_id`?** — the reference's `0x711960`,
/// byte-for-byte: a bounds-checked read of [`Self::animation_lookup`] (`0x710310` returns the
/// `0xffff` sentinel for an out-of-range index) compared against `0xffff`.
///
/// This is the predicate the GameObject animation arm branches on when the substate's animation
/// id is missing from the model (wow-re `gameobject-anim-arm.md` §2c's four-way remap), so it
/// must answer exactly as the reference does — including for an id past the table's end, which
/// is **not owned** even if some later mechanism could reach a sequence carrying it.
pub fn owns_animation(&self, anim_id: u16) -> bool {
self.animation_lookup
.get(anim_id as usize)
.is_some_and(|&slot| slot != 0xffff)
}
}
/// Holds the parsed model (mirrors the reference's `parse_m2(..).model()` shape).
pub struct M2Format {
pub(crate) model: M2Model,
+5
View File
@@ -443,6 +443,11 @@ pub enum SessionEvent {
/// A server notice (`SMSG_NOTIFICATION`) — pre-formatted text the real client flashes in the
/// red UIErrorsFrame ("You do not know that language", trade refusals, and kin).
Notification { text: String },
/// An area trigger refused (`SMSG_AREA_TRIGGER_MESSAGE`) — pre-formatted text explaining why a
/// portal or instance entrance did nothing ("You must be at least level 58 to enter…", "You
/// cannot enter … while in ghost form."). The reference sends it to the same system-message
/// sink as [`Self::Notification`].
AreaTriggerMessage { text: String },
/// Answers `/played` (`SMSG_PLAYED_TIME`, our `CMSG_PLAYED_TIME`): total played time + time
/// since the last level-up, both in seconds.
PlayedTime { total: u32, level: u32 },
@@ -98,6 +98,9 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
}
ServerPacket::ChatWrongFaction => vec![SessionEvent::ChatWrongFaction],
ServerPacket::Notification { text } => vec![SessionEvent::Notification { text }],
ServerPacket::AreaTriggerMessage { text } => {
vec![SessionEvent::AreaTriggerMessage { text }]
}
ServerPacket::PlayedTime { total, level } => {
vec![SessionEvent::PlayedTime { total, level }]
}
@@ -0,0 +1,61 @@
//! The area-trigger pair: the client's "I walked into trigger N" report, and the server's refusal
//! text that sometimes comes back.
//!
//! An `AreaTrigger.dbc` volume means nothing to the client — it owns the geometry and nothing else.
//! Walking into one sends [`area_trigger`] and the **server** decides what happens: a teleport
//! (every instance entrance, the Darnassus/Rut'theran portals), a quest's explore objective, the
//! inn's rested state, a battleground's entrance list. A teleport comes back as the ordinary
//! `SMSG_TRANSFER_PENDING` + `SMSG_NEW_WORLD` pair (decision 0455) or a same-map
//! `MSG_MOVE_TELEPORT_ACK`; a refusal comes back as [`read_area_trigger_message`]'s text.
use std::io;
use crate::wire::{read_cstring, read_u32_le};
/// Body of `CMSG_AREATRIGGER` (opcode `0xB4`/180 — VERIFIED vmangos `Opcodes_1_12_1.h:183`, and the
/// reference builds exactly this: `0x5e2110` writes `0xb4` then the record's first field, its id).
/// One `u32`: the `AreaTrigger.dbc` id.
///
/// The server re-checks the claim before acting — `HandleAreaTriggerOpcode`
/// (`Handlers/MiscHandler.cpp:622`) drops it unless the id is a real DBC row and the player is
/// inside the volume with 5 yd of slop — so a stale or invented id costs nothing but is never
/// obeyed. It is also dropped outright while taxi-flying.
pub fn area_trigger(trigger_id: u32) -> Vec<u8> {
trigger_id.to_le_bytes().to_vec()
}
/// Read `SMSG_AREA_TRIGGER_MESSAGE` (VERIFIED vmangos `WorldSession::SendAreaTriggerMessage`,
/// `Server/WorldSession.cpp:882-898`: `u32 length` then the cstring): server-composed text
/// explaining why a trigger did *not* fire — "You must be at least level 58 to enter…", "You
/// cannot enter %s while in ghost form.", a battleground's level/faction refusal.
///
/// The length prefix counts the cstring's bytes **including** its terminator; it is redundant with
/// the string itself, and is read and discarded (the reference does the same — its `0x2b8` arm
/// reads a u32, then a string, then displays the string).
pub(super) fn read_area_trigger_message(r: &mut &[u8]) -> io::Result<String> {
let _length = read_u32_le(r)?;
read_cstring(r)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn body_is_the_bare_id() {
assert_eq!(area_trigger(542), 542u32.to_le_bytes().to_vec());
}
/// The length prefix is skipped and the text read whole — built the way the server builds it.
#[test]
fn message_reads_past_its_length_prefix() {
let text = "You must be at least level 58 to enter.";
let mut body = Vec::new();
body.extend_from_slice(&(text.len() as u32 + 1).to_le_bytes());
body.extend_from_slice(text.as_bytes());
body.push(0);
let mut r = body.as_slice();
assert_eq!(read_area_trigger_message(&mut r).unwrap(), text);
assert!(r.is_empty(), "the whole body is consumed");
}
}
@@ -16,6 +16,7 @@
//! `tests/common`).
mod action_bar;
mod area_trigger;
mod attack;
mod bank;
mod channel;
@@ -53,6 +54,7 @@ mod world_state;
pub use action_bar::{
set_action_button, ActionButton, ACTION_KIND_ITEM, ACTION_KIND_MACRO, ACTION_KIND_SPELL,
};
pub use area_trigger::area_trigger;
pub use attack::{attack_swing, AttackerState};
pub use bank::{
autobank_item, autostore_bank_item, bank_slot_result, banker_activate, buy_bank_slot,
@@ -219,6 +219,15 @@ pub const CMSG_USE_ITEM: u16 = 0x00AB; // 171
/// GameObject to use (decision 0236). Not interchangeable with `CMSG_LOOT`: the server rejects a
/// GameObject guid on `CMSG_LOOT`, so a chest opens its loot through this opcode.
pub const CMSG_GAMEOBJ_USE: u16 = 0x00B1; // 177
/// VERIFIED vmangos `Opcodes_1_12_1.h:183`: 180 — and the reference writes this literal
/// (`0x5e2110`, its area-trigger check, builds `0xb4` + the trigger id). Body in
/// [`super::area_trigger`]: the `AreaTrigger.dbc` id the player just walked into.
pub const CMSG_AREATRIGGER: u16 = 0x00B4; // 180
/// VERIFIED vmangos `Opcodes_1_12_1.h:697`: 696. Body in
/// [`super::area_trigger::read_area_trigger_message`] — why a trigger refused (level, ghost form,
/// battleground faction). The reference displays it through the same system-message sink as
/// `SMSG_NOTIFICATION` (`0x4945b0`).
pub const SMSG_AREA_TRIGGER_MESSAGE: u16 = 0x02B8; // 696
pub const CMSG_MESSAGECHAT: u16 = 0x0095;
pub const SMSG_MESSAGECHAT: u16 = 0x0096;
// The channel family (VERIFIED vmangos `Server/Protocol/Opcodes_1_12_1.h:154-171`, decimal
@@ -329,6 +329,12 @@ pub enum ServerPacket {
Notification {
text: String,
},
/// `SMSG_AREA_TRIGGER_MESSAGE` — why an area trigger refused us ("You must be at least level 58
/// to enter…"); `u32 length` + one cstring (vmangos `WorldSession::SendAreaTriggerMessage`,
/// `Server/WorldSession.cpp:882-898`).
AreaTriggerMessage {
text: String,
},
/// `SMSG_PLAYED_TIME` — answers our `CMSG_PLAYED_TIME` (`/played`): total played time + time
/// since the last level-up, both in seconds (layout in [`super::chat::read_played_time`]).
PlayedTime {
@@ -1053,6 +1059,7 @@ impl ServerPacket {
ServerPacket::ChatPlayerNotFound { .. } => "SMSG_CHAT_PLAYER_NOT_FOUND".into(),
ServerPacket::ChatWrongFaction => "SMSG_CHAT_WRONG_FACTION".into(),
ServerPacket::Notification { .. } => "SMSG_NOTIFICATION".into(),
ServerPacket::AreaTriggerMessage { .. } => "SMSG_AREA_TRIGGER_MESSAGE".into(),
ServerPacket::PlayedTime { .. } => "SMSG_PLAYED_TIME".into(),
ServerPacket::RandomRoll { .. } => "MSG_RANDOM_ROLL".into(),
ServerPacket::InitialSpells { .. } => "SMSG_INITIAL_SPELLS".into(),
@@ -11,10 +11,10 @@ use crate::wire::{
};
use super::{
action_bar, attack, bank, channel, chat, combat_log, death, duel, gameobject, gossip, group,
items, loot, mail, monster_move, movement, opcode, progression, quest, social, spellbook,
spells, taxi, trade, trainer, update_object, vendor, world_state, Character, CreatureQueryInfo,
ServerPacket, SpeedKind,
action_bar, area_trigger, attack, bank, channel, chat, combat_log, death, duel, gameobject,
gossip, group, items, loot, mail, monster_move, movement, opcode, progression, quest, social,
spellbook, spells, taxi, trade, trainer, update_object, vendor, world_state, Character,
CreatureQueryInfo, ServerPacket, SpeedKind,
};
/// Read one `SMSG_FORCE_*_SPEED_CHANGE` body — `[packed mover guid][u32 counter][f32 speed]`,
@@ -343,6 +343,9 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
opcode::SMSG_NOTIFICATION => ServerPacket::Notification {
text: chat::read_notification(&mut r)?,
},
opcode::SMSG_AREA_TRIGGER_MESSAGE => ServerPacket::AreaTriggerMessage {
text: area_trigger::read_area_trigger_message(&mut r)?,
},
opcode::SMSG_PLAYED_TIME => {
let (total, level) = chat::read_played_time(&mut r)?;
ServerPacket::PlayedTime { total, level }
@@ -0,0 +1,24 @@
//! The one area-trigger send. Body in [`crate::messages::area_trigger`], whose scope this mirrors.
use anyhow::Result;
use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// Report that we just walked into an `AreaTrigger.dbc` volume (`CMSG_AREATRIGGER`, layout in
/// [`messages::area_trigger`]) — the client's whole part in the trigger system: it owns the
/// geometry and names the id, and the **server** decides what that trigger means (a teleport,
/// a quest's explore objective, the inn's rested state, a battleground's entrance list).
///
/// There is no dedicated success reply: a teleport arrives as the ordinary
/// `SMSG_TRANSFER_PENDING` + `SMSG_NEW_WORLD` pair or a same-map `MSG_MOVE_TELEPORT_ACK`, a
/// refusal as `SMSG_AREA_TRIGGER_MESSAGE`, and most triggers answer with nothing at all.
pub fn area_trigger(&mut self, trigger_id: u32) -> Result<()> {
self.send(
opcode::CMSG_AREATRIGGER,
&messages::area_trigger(trigger_id),
)
}
}
@@ -40,6 +40,7 @@
//! | [`mail`] | the mailbox |
//! | [`pvp`] | the one PvP-flag toggle |
//! | [`social`] | friends, ignores, and `/who` |
//! | [`area_trigger`] | the one "I walked into trigger N" report |
use std::net::TcpStream;
@@ -49,6 +50,7 @@ use benilla_srp::vanilla_header::EncrypterHalf;
use super::send_packet;
mod action_bar;
mod area_trigger;
mod attack;
mod bank;
mod channel;
+68 -13
View File
@@ -7,17 +7,57 @@
// is fog-coloured hill silhouettes occluding the (un-fogged) sky. Fog math + gamma handling mirror
// terrain.wgsl exactly: planar eye-Z, GL_LINEAR, gamma space; the output is raw gamma — the buffer
// holds bytes and the frame's one decode lives in the FFXGlow combine (GAMMA LANE, 0161).
//
// ## The far band is a BACKDROP, not the far half of a plane partition (decision 0684)
//
// The reference's far-band pass (`CWorldScene` drain `0x6841a0`, wow-re `terrain.md` + `rf35-render-list`)
// does NOT share a clip plane with the detailed world. It draws the coarse hulls under its own
// projection — `SetPerspective(fov, aspect, near = farclip − 33.0, far = horizonfarclip)`, byte-read at
// the two call sites `0x6842f2` (render) / `0x68317f` (the far walk's cull frustum), the `33.0` at
// `[0x8101b0]`, the horizon far from the runtime box `[0xc7b49c]` — into a **compressed depth range
// `[0.955, 0.96]`** (`[0x80febc]=0.96`, `[0x80fec4]=0.005`), i.e. squashed into the back half-percent of
// the depth buffer so it can never occlude anything the detailed world drew. Two properties fall out,
// and BOTH are load-bearing:
//
// * the band **overlaps** the detailed world by 33 yd (≈ one WDL outer cell, spacing 33.333) — so a
// coarse hull that crosses back inside the wall is still drawn, and no hole can open at the seam;
// * it is **depth-pushed**, never near-clipped at the wall — so that overlap cannot poke through the
// fine terrain, and nothing coarse near the camera can paint over the sky (its near plane stops it).
//
// We used to partition instead: terrain discarded past `farclip`, WDL discarded before it, sharing one
// plane with zero overlap. Two different surfaces cannot partition cleanly at one plane — wherever a ray
// grazed the coarse hull entirely INSIDE the wall while the fine mesh it approximates lay beyond it (a
// ridge crest, exactly), both passes discarded and the sky showed through as a band at the horizon
// (the director's "in-between area at the clip barrier", Weazel's Crater, 2026-07-26).
//
// Our depth buffer is Bevy's infinite REVERSE-Z (`perspective_infinite_reverse_rh`: depth = near/eye_z,
// 0 = infinitely far, and never exactly 0 for finite geometry), so the reference's `[0.955, 0.96]`
// window becomes a clamp rather than a `glDepthRange`: every detailed-world fragment lives at
// `depth ≥ near/farclip` (it is discarded past the wall), so clamping WDL just below that value puts the
// whole band behind the world by construction, while leaving it in front of the sky's forced `0.0`
// (`sky_order.rs`, "The depth law"). Order of the three backdrop tiers, all decided by depth and nothing
// else: sky (0.0) → WDL band → detailed world.
#import bevy_pbr::{
mesh_functions,
forward_io::Vertex,
view_transformations::position_world_to_clip,
view_transformations::{position_world_to_clip, view_z_to_depth_ndc},
mesh_view_bindings::view,
}
/// How far the far band reaches back INSIDE the detailed world's far-clip wall — the reference's
/// far-band near plane `farclip − 33.0` (`[0x8101b0]`, one WDL outer cell). This overlap is what
/// closes the coarse-vs-fine seam; the depth clamp below is what keeps it harmless.
const WDL_OVERLAP: f32 = 33.0;
/// Nudge off the wall depth so the band is strictly BEHIND a detailed-world fragment sitting exactly
/// at the wall, instead of tying it (reverse-Z `GreaterEqual` lets a tie through, and the winner would
/// then be draw order). Multiplicative so it can never cross zero into the sky.
const WDL_DEPTH_PUSH: f32 = 0.999;
struct WdlParams {
fog_color: vec4<f32>, // rgb = Light.dbc row 7 (gamma 0..1); w = enable (>0.5 ⇒ blend)
fog_params: vec4<f32>, // x = fog_start yd; y = fog_end yd; zw reserved
fog_params: vec4<f32>, // x = fog_start yd; y = fog_end yd; z reserved; w = farclip wall (0 ⇒ off)
};
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var<uniform> w: WdlParams;
@@ -26,6 +66,11 @@ struct WdlVsOut {
@location(0) world_position: vec4<f32>,
}
struct WdlFsOut {
@location(0) color: vec4<f32>,
@builtin(frag_depth) depth: f32,
}
@vertex
fn vertex(in: Vertex) -> WdlVsOut {
var out: WdlVsOut;
@@ -37,26 +82,36 @@ fn vertex(in: Vertex) -> WdlVsOut {
}
@fragment
fn fragment(in: WdlVsOut) -> @location(0) vec4<f32> {
fn fragment(in: WdlVsOut) -> WdlFsOut {
// PLANAR eye-Z (view-space depth), NOT radial — same as terrain.wgsl (apitrace-verified). Used for
// both the far-clip partition (below) and the fog.
// the band's near plane (below) and the fog.
let eye_z = -(view.view_from_world * vec4<f32>(in.world_position.xyz, 1.0)).z;
// INVERSE of the detailed-world far-clip wall: WDL draws ONLY beyond the wall (`fog_params.w` =
// farclip), while terrain/wow_model/liquid discard *beyond* it — so the detailed world and the WDL
// backdrop partition cleanly at the SAME plane (no overlap, no gap). WDL can therefore be drawn as a
// full ring (a constant backdrop) without toggling against the detailed streaming radius, which is
// what made the horizon pop in/out. The coarse-vs-fine height seam at the plane sits at ~farclip,
// deep in the fog, so it's hidden (the reference relies on the same). (0 ⇒ disabled.)
if (w.fog_params.w > 0.0 && eye_z < w.fog_params.w) {
let farclip = w.fog_params.w;
// The far band's NEAR PLANE: `farclip − 33` (the reference's, see the header). Deliberately NOT the
// wall — the 33 yd overlap is what stops a hole opening at the coarse-vs-fine seam. (0 ⇒ disabled.)
if (farclip > 0.0 && eye_z < farclip - WDL_OVERLAP) {
discard;
}
// White vertex diffuse (the reference's WDL colour); fog does all the colouring.
// White vertex diffuse (the reference's WDL colour); fog does all the colouring. Past `fog_end`
// — and `fog_end = min(zone end, farclip)`, so that is the whole band — this is pure fog colour.
var rgb = vec3<f32>(1.0);
if (w.fog_color.w > 0.5) {
let denom = max(w.fog_params.y - w.fog_params.x, 0.001);
let factor = clamp((w.fog_params.y - eye_z) / denom, 0.0, 1.0);
rgb = mix(w.fog_color.xyz, rgb, factor);
}
var out: WdlFsOut;
// GAMMA LANE (0161): raw gamma out; the frame decodes once in the FFXGlow combine.
return vec4<f32>(rgb, 1.0);
out.color = vec4<f32>(rgb, 1.0);
// The depth push (the reference's compressed far-band depth range). `view_z_to_depth_ndc` takes a
// view-space z — negative in front of the camera — so the wall is at `-farclip`.
var depth = in.clip_position.z;
if (farclip > 0.0) {
depth = min(depth, view_z_to_depth_ndc(-farclip) * WDL_DEPTH_PUSH);
}
out.depth = depth;
return out;
}
@@ -58,6 +58,24 @@ const RAIN_FOG_GREY: vec3<f32> = vec3<f32>(0.50196078, 0.50196078, 0.50196078);
@fragment
fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> FragmentOutput {
// HARD FAR-CLIP WALL (faithful `farclip` ~777 yd) — see terrain.wgsl for the law. The reference
// has ONE projection far plane and it clips the whole detailed world: terrain, models, liquid AND
// the effect family, which is drawn by the same scene pass through the same matrix. benilla
// emulates that plane per-pixel (our projection far sits at ~3000 yd so the coarse WDL horizon can
// draw behind the wall) — and every world shader took the emulation except this one. That gap IS
// bug B39: campfires, braziers and portal effects kept drawing at any distance, over a horizon
// where the terrain beneath them had already been discarded by the very same wall.
//
// Ribbons ride this shader too (`ribbons.rs` shares `WowParticleMaterial`), so trails are walled
// by the same discard. `fog_params.w` = farclip (0 ⇒ disabled). No pop: the scene fog end is
// `min(fog_end, farclip)`, so a quad reaching the wall has already faded to the fog colour —
// toward BLACK for an Add-blend emitter (`wow_ext_params.x`), i.e. to nothing under `One` blend.
if (wow_light.fog_params.w > 0.0) {
let clip_z = -(view.view_from_world * vec4<f32>(in.world_position.xyz, 1.0)).z;
if (clip_z > wow_light.fog_params.w) {
discard;
}
}
var pbr_input = pbr_input_from_standard_material(in, is_front);
// base_color = white base × tex (gamma bytes, Unorm) × vertex colour (raw authored track
// values) — the gamma-space product, exactly what the reference multiplies.
+90 -19
View File
@@ -699,6 +699,12 @@
end
end
-- The bag BAR's five buttons (the backpack toggle + the four equipped-bag slots) — the row
-- Disable_/Enable_BagButtons greys as a unit. Distinct from UiPanels.xml's BAG_WINDOWS,
-- which is the five bag *windows* those buttons open.
BENILLA_BAG_BUTTONS = { "BenillaBagToggle", "BenillaBagBarSlot1", "BenillaBagBarSlot2",
"BenillaBagBarSlot3", "BenillaBagBarSlot4" }
-- Disable_BagButtons / Enable_BagButtons — ref MainMenuBarBagButtons.lua l.102-128, called
-- by GameMenuFrame's OnShow/OnHide (decision 0674) and by nothing else: while the game menu
-- holds the screen, the whole bag row greys out. That is the *visible* half of a rule the
@@ -706,29 +712,34 @@
-- while the menu is up — UiPanels.xml `CanOpenPanels`); without it the bags would look
-- clickable and simply do nothing.
--
-- Mapping onto benilla's row: the ref's MainMenuBarBackpackButton is BenillaBagToggle —
-- whose icon IS its NormalTexture (it carries no named $parentIcon region, so the greying
-- goes through GetNormalTexture) — and CharacterBag0..3Slot are BenillaBagBarSlot1..4, each
-- with the $parentIcon the Update path above also writes.
-- Mapping onto benilla's row: the ref's MainMenuBarBackpackButton is BenillaBagToggle and
-- CharacterBag0..3Slot are BenillaBagBarSlot1..4 — and in BOTH cases the greying goes onto
-- the button's `$parentIcon` LAYER texture, never onto a state texture. That is the
-- reference's own structure and it is load-bearing here: a disabled button draws its
-- DisabledTexture, and with none set it draws no state texture at all
-- (`ButtonState::region_visible`), so greying a NormalTexture would grey something already
-- gone. The four slots' UI-Quickslot2 rings ARE state textures and do vanish while
-- disabled unless the slot states one explicitly — so what you should see is the
-- ring stay put and the bag icon grey — which is why each slot repeats its ring as
-- DisabledTexture (see the slot instances below).
function Disable_BagButtons()
BenillaBagToggle:Disable()
SetDesaturation(BenillaBagToggle:GetNormalTexture(), 1)
for i = 1, 4 do
local slot = getglobal("BenillaBagBarSlot" .. i)
slot:Disable()
SetDesaturation(slot.icon, 1)
for _, button in ipairs(BENILLA_BAG_BUTTONS) do
local b = getglobal(button)
b:Disable()
SetDesaturation(b.icon, 1)
end
end
function Enable_BagButtons()
BenillaBagToggle:Enable()
SetDesaturation(BenillaBagToggle:GetNormalTexture(), nil)
for _, button in ipairs(BENILLA_BAG_BUTTONS) do
local b = getglobal(button)
b:Enable()
SetDesaturation(b.icon, nil)
end
-- The four slots' own paint decides their restored colour: an item locked on the cursor
-- wants 0.4 grey, not the flat white SetDesaturation(nil) just left.
for i = 1, 4 do
local slot = getglobal("BenillaBagBarSlot" .. i)
slot:Enable()
-- The slot's own paint decides the restored colour: an item locked on the cursor
-- wants 0.4 grey, not the flat white a bare SetDesaturation(nil) would leave.
BenillaBagBarSlot_Update(slot)
BenillaBagBarSlot_Update(getglobal("BenillaBagBarSlot" .. i))
end
end
]]></Script>
@@ -1234,7 +1245,20 @@
<Offset><AbsDimension x="-6" y="2"/></Offset>
</Anchor>
</Anchors>
<NormalTexture file="Interface\Buttons\Button-Backpack-Up"/>
<!-- The backpack art is a LAYER texture, not the NormalTexture — the reference's own
structure (MainMenuBarBackpackButton inherits ItemButtonTemplate and sets
`MainMenuBarBackpackButtonIconTexture` in its OnLoad; the state textures are the
template's ring). It matters because `Disable_BagButtons` (decision 0674) disables this
button while the game menu is up, and a *state* texture with no DisabledTexture beside
it is not drawn in the disabled state (`ButtonState::region_visible` — the byte rule at
`SetState 0x779790`), so the backpack icon vanished outright instead of greying
(director-reported). As a layer it is untouched by button state, exactly like the four
bag-bar slots' own `$parentIcon`, and the greying is the vertex tint on it. -->
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentIcon" file="Interface\Buttons\Button-Backpack-Up"/>
</Layer>
</Layers>
<HighlightTexture file="Interface\Buttons\ButtonHilight-Square" alphaMode="ADD"/>
<!-- The lit ring while the backpack window is open (ref MainMenuBarBackpackButton
l.104) — a CheckButton like the ref's; OnShow/OnHide of the window drive it. -->
@@ -1242,7 +1266,10 @@
<Scripts>
<!-- Seat the backpack icon above the bar art too (same reason as the bag-bar slots —
BenillaActionBarArt_SeatAbove's comment). -->
<OnLoad>if BenillaActionBarArt_SeatAbove then BenillaActionBarArt_SeatAbove(self) end</OnLoad>
<OnLoad>
self.icon = getglobal("BenillaBagToggleIcon")
if BenillaActionBarArt_SeatAbove then BenillaActionBarArt_SeatAbove(self) end
</OnLoad>
<OnClick>BenillaBagToggle_OnClick()</OnClick>
<OnEnter>BenillaBagToggle_OnEnter(self)</OnEnter>
<OnLeave>BenillaBagBarButton_OnLeave()</OnLeave>
@@ -1268,6 +1295,17 @@
<Size><AbsDimension x="66" y="66"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="CENTER"/></Anchors>
</NormalTexture>
<!-- The same ring again as the DISABLED art, so the slot keeps its frame while
Disable_BagButtons has the row greyed under the game menu (decision 0674). A button
with no DisabledTexture draws NO state texture when disabled
(`ButtonState::region_visible`) — the ring would blink out of the bar entirely. The
reference needs no such line because its widget keeps the previously shown texture up
when the new state's slot is empty (`SetState 0x779790`'s gated hide); ours states it
instead of relying on that. -->
<DisabledTexture file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="66" y="66"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="CENTER"/></Anchors>
</DisabledTexture>
<HighlightTexture file="Interface\Buttons\ButtonHilight-Square" alphaMode="ADD"/>
<!-- The lit ring while this bag's window is open (ref BagSlotButtonTemplate l.52). -->
<CheckedTexture file="Interface\Buttons\CheckButtonHilight" alphaMode="ADD"/>
@@ -1294,6 +1332,17 @@
<Size><AbsDimension x="66" y="66"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="CENTER"/></Anchors>
</NormalTexture>
<!-- The same ring again as the DISABLED art, so the slot keeps its frame while
Disable_BagButtons has the row greyed under the game menu (decision 0674). A button
with no DisabledTexture draws NO state texture when disabled
(`ButtonState::region_visible`) — the ring would blink out of the bar entirely. The
reference needs no such line because its widget keeps the previously shown texture up
when the new state's slot is empty (`SetState 0x779790`'s gated hide); ours states it
instead of relying on that. -->
<DisabledTexture file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="66" y="66"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="CENTER"/></Anchors>
</DisabledTexture>
<HighlightTexture file="Interface\Buttons\ButtonHilight-Square" alphaMode="ADD"/>
<CheckedTexture file="Interface\Buttons\CheckButtonHilight" alphaMode="ADD"/>
<Scripts>
@@ -1319,6 +1368,17 @@
<Size><AbsDimension x="66" y="66"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="CENTER"/></Anchors>
</NormalTexture>
<!-- The same ring again as the DISABLED art, so the slot keeps its frame while
Disable_BagButtons has the row greyed under the game menu (decision 0674). A button
with no DisabledTexture draws NO state texture when disabled
(`ButtonState::region_visible`) — the ring would blink out of the bar entirely. The
reference needs no such line because its widget keeps the previously shown texture up
when the new state's slot is empty (`SetState 0x779790`'s gated hide); ours states it
instead of relying on that. -->
<DisabledTexture file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="66" y="66"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="CENTER"/></Anchors>
</DisabledTexture>
<HighlightTexture file="Interface\Buttons\ButtonHilight-Square" alphaMode="ADD"/>
<CheckedTexture file="Interface\Buttons\CheckButtonHilight" alphaMode="ADD"/>
<Scripts>
@@ -1344,6 +1404,17 @@
<Size><AbsDimension x="66" y="66"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="CENTER"/></Anchors>
</NormalTexture>
<!-- The same ring again as the DISABLED art, so the slot keeps its frame while
Disable_BagButtons has the row greyed under the game menu (decision 0674). A button
with no DisabledTexture draws NO state texture when disabled
(`ButtonState::region_visible`) — the ring would blink out of the bar entirely. The
reference needs no such line because its widget keeps the previously shown texture up
when the new state's slot is empty (`SetState 0x779790`'s gated hide); ours states it
instead of relying on that. -->
<DisabledTexture file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="66" y="66"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="CENTER"/></Anchors>
</DisabledTexture>
<HighlightTexture file="Interface\Buttons\ButtonHilight-Square" alphaMode="ADD"/>
<CheckedTexture file="Interface\Buttons\CheckButtonHilight" alphaMode="ADD"/>
<Scripts>
+59 -3
View File
@@ -89,6 +89,12 @@
-- every non-center panel (`CanOpenPanels` below). That is the rule behind "you can't open
-- anything with the ESC menu up". whileDead: dying is exactly when you want to log out.
UIPanelWindows["GameMenuFrame"] = { area = "center", pushable = 0, whileDead = 1 }
-- The world map (decision 0203 phase 2) — the ref's own line verbatim (UIParent.lua l.30)
-- and the first **full** row. It was a plain toplevel until now, showing itself with a bare
-- `WorldMapFrame:Show()` and so bypassing this manager entirely: with the game menu up it
-- was the ONE window that still opened (director-reported). Routing its toggle through
-- ShowUIPanel is what subjects it to the same rules as everything else.
UIPanelWindows["WorldMapFrame"] = { area = "full", pushable = 0, whileDead = 1 }
-- The real client stores `.left`/`.center`/`.doublewide`/`.fullscreen` as fields directly on
-- the `UIParent` frame table (UIParent.lua l.789-885). benilla has no `UIParent` frame object
@@ -96,7 +102,7 @@
-- table instead of squatting the `UIParent` global name; a future `CreateFrame("Frame",
-- "UIParent")` then lands cleanly on an empty global rather than silently coexisting with our
-- state. Every function below is defined in this same chunk, so they all close over it.
local slots = { left = nil, center = nil }
local slots = { left = nil, center = nil, fullscreen = nil }
-- SetLeftFrame(frame) — UIParent.lua l.809-824. Hides the old left occupant, SetPoints +
-- Shows the new one at the left slot's screen anchor (verified: TOPLEFT UIParent, 0, -104).
@@ -167,6 +173,26 @@
return slots.center
end
-- SetFullScreenFrame(frame) — UIParent.lua l.852-866. ONE line dropped: the reference also
-- does `UIParent:Hide()`/`:Show()` around the swap, hiding the whole game UI behind the
-- fullscreen frame. benilla has no UIParent frame (the note on `slots` above), and the only
-- full frame we ship — the world map — already paints its own full-screen blackout
-- (WorldMapFrame.xml), which is what that Hide buys the reference.
function SetFullScreenFrame(frame)
local oldFrame = slots.fullscreen
slots.fullscreen = frame
if oldFrame then
oldFrame:Hide()
end
if frame then
frame:Show()
end
end
function GetFullScreenFrame()
return slots.fullscreen
end
-- CanOpenPanels() — UIParent.lua l.887-910. Its dead-player arms are commented out in the
-- reference itself (both of them, with the note that the fear/out-of-control gate was
-- "previously"); what survives is one rule: **a native-center frame open ⇒ nothing else
@@ -209,6 +235,17 @@
return
end
-- l.668-675: a full-screen frame open ignores every non-full open request (`force`
-- evicts it instead). The world map is the only `full` row, so this reads: nothing opens
-- behind the map.
if GetFullScreenFrame() and info.area ~= "full" then
if force then
SetFullScreenFrame(nil)
else
return
end
end
-- l.677-689: with a native-center frame open, only another center request gets through
-- — `force` evicts it instead of refusing. Redundant with the CanOpenPanels gate above
-- for every caller that doesn't force (the reference double-checks it too); kept because
@@ -226,6 +263,14 @@
end
end
-- l.692-696: full-screen frames just replace each other, and they take everything with
-- them on the way in.
if info.area == "full" then
CloseAllWindows()
SetFullScreenFrame(frame)
return
end
-- l.697-702: native-center frames just replace each other and take the screen —
-- everything else closes first. `skipSetPoint` is the load-bearing argument: the menu
-- keeps its own CENTER anchor instead of being dropped at the left-area seat.
@@ -284,6 +329,12 @@
return
end
-- l.759-763.
if frame == GetFullScreenFrame() then
SetFullScreenFrame(nil)
return
end
if frame == GetCenterFrame() then
SetCenterFrame(nil)
return
@@ -311,9 +362,11 @@
function CloseWindows(ignoreCenter)
local leftFrame = GetLeftFrame()
local centerFrame = GetCenterFrame()
local found = leftFrame or centerFrame
local fullScreenFrame = GetFullScreenFrame()
local found = leftFrame or centerFrame or fullScreenFrame
HideUIPanel(leftFrame)
HideUIPanel(fullScreenFrame)
if centerFrame then
local info = UIPanelWindows[centerFrame:GetName()]
if not info or info.area ~= "center" or not ignoreCenter then
@@ -497,7 +550,10 @@
-- l.628; ours routes through this chain since frames get no raw key events).
local worldmap = getglobal("WorldMapFrame")
if worldmap and worldmap:IsVisible() then
worldmap:Hide()
-- HideUIPanel, not a bare Hide: the map holds the full-screen slot (decision 0203's
-- `full` row, wired 0674), and hiding it behind the manager's back would leave the
-- slot pointing at an invisible frame — which then refuses every future panel.
HideUIPanel(worldmap)
return
end
-- The open-letter toplevel (decision 0544): the ref registers OpenMailFrame in
+8 -3
View File
@@ -33,11 +33,16 @@
NUM_WORLDMAP_DETAIL_TILES = 12;
NUM_WORLDMAP_OVERLAYS = 0;
-- ref WorldMapFrame.lua l.509-515, verbatim. Through the PANEL MANAGER, not a bare
-- Show/Hide: the map is registered `area = "full"` (UiPanels.xml), which is what makes it
-- take the screen from the other windows on the way in and — the reason this changed —
-- what stops it opening from behind the game menu, the one window that still could
-- (director-reported, decision 0674).
function ToggleWorldMap()
if ( WorldMapFrame:IsVisible() ) then
WorldMapFrame:Hide();
HideUIPanel(WorldMapFrame);
else
WorldMapFrame:Show();
ShowUIPanel(WorldMapFrame);
end
end
@@ -454,7 +459,7 @@
<PushedTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Down"/>
<HighlightTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Highlight" alphaMode="ADD"/>
<Scripts>
<OnClick>WorldMapFrame:Hide();</OnClick>
<OnClick>HideUIPanel(WorldMapFrame);</OnClick>
</Scripts>
</Button>
<Frame name="WorldMapDetailFrame">
+227
View File
@@ -0,0 +1,227 @@
//! Area triggers — the client's half of every portal, instance entrance and "explore here" quest.
//!
//! `AreaTrigger.dbc` is a table of invisible volumes. The client's *entire* job is geometry: notice
//! that the player has walked into one, and send `CMSG_AREATRIGGER` naming its id. The **server**
//! decides what the trigger means — a teleport (`areatrigger_teleport`: the Darnassus/Rut'theran
//! portals, every dungeon and raid entrance), a quest's explore objective
//! (`areatrigger_involvedrelation`), the inn's rested state, a battleground's entrance list. The
//! client never knows and never needs to.
//!
//! Until this module existed, benilla sent that opcode from nowhere: a portal did nothing, and an
//! instance entrance did nothing, because the server was never told we were standing in one (ledger
//! **B70**, **N02**). The teleport that answers it has worked since decision 0455.
//!
//! ## The law, from the reference
//!
//! **VERIFIED** against `0x5e2110` (the per-frame check) and `0x5e22d0` (containment), wow-5875-re
//! `object-layer/scratch/w2b1-decomp.c`; the geometry itself is
//! [`AreaTriggerRow::contains`](benilla_formats::AreaTriggerRow::contains).
//!
//! - The map's rows are the only candidates (`0x5e2080` narrows the map-sorted table to a
//! `[first, end)` window before any test).
//! - **One latch, and it is an *exit* latch.** The check holds the trigger it is currently inside;
//! while the player is still inside *that* volume it returns immediately and sends nothing. Only
//! once they have left does it scan for a new one — so standing in a portal fires it exactly once,
//! and overlapping volumes never fight.
//! - The **first** containing row in file order wins.
//! - A map change clears the latch.
//!
//! Two things the reference's shape buys us, worth stating because they are load-bearing: a trigger
//! cannot re-fire while you stand in it (a portal would otherwise re-teleport you every frame), and
//! the *destination* of a portal pair is authored to land outside the return trigger (Darnassus's
//! exit lands 13.9 yd from the entrance's 10-yd sphere), so a round trip is not a loop.
//!
//! The server re-checks our claim with 5 yd of slop and ignores it while taxi-flying
//! (`HandleAreaTriggerOpcode`, vmangos `Handlers/MiscHandler.cpp:622`), so a wrong id is refused
//! rather than obeyed.
//!
//! ## ⚠ Probing this with `.go` is not the same as walking in
//!
//! A GM teleport that drops you *inside* a volume sends this message in the same millisecond as the
//! teleport ack, and the server re-checks the claim against the position it has stored — which is
//! sometimes still the pre-teleport one. Measured: five such teleport-ins were obeyed and one
//! (Darnassus's trigger 527) was silently ignored, while drifting into the same trigger under
//! movement fired it and teleported 40 ms later. So an ignored `.go` probe is the harness racing
//! the server, not this check failing — and the latch, faithfully, does not retry (it latches on
//! the *send*; the client never learns whether the server obeyed). Step out and back in, or better,
//! park outside and move in.
use benilla_assets::coords::bevy_to_wow;
use benilla_formats::AreaTriggerCatalog;
use bevy::prelude::*;
use crate::assets::{AssetSet, LockRecover, WorldAssets};
use crate::net::{ClientCommand, NetCommands};
use crate::player::Player;
use crate::schedule::WorldStage;
use crate::world_map::CurrentMap;
/// The `AreaTrigger.dbc` catalog, bucketed by map. Absent when the client data didn't load — the
/// check then does nothing, like every other data-driven system here.
#[derive(Resource)]
pub(crate) struct AreaTriggers(pub(crate) AreaTriggerCatalog);
/// The trigger we are currently standing **inside**, with the map it is on — the reference's single
/// "current trigger" pointer (`DAT_00c4d73c`).
///
/// Keyed by map so it can never leak across a worldport (the reference clears it on the map change;
/// carrying the map id makes the same guarantee without a hook). It deliberately survives a
/// reconnect on the same map, which is also what the reference does — its clear runs off the map
/// change, not the socket — and it is the safer half of that coin: reconnecting inside a portal
/// does not teleport you.
#[derive(Resource, Default)]
pub(crate) struct InsideTrigger(Option<(u32, u32)>);
impl InsideTrigger {
/// One check, as the reference orders it (`0x5e2110`): if we are still inside the latched
/// volume, report nothing; otherwise unlatch, and latch + report the **first** trigger on this
/// map containing `p`, if any. `Some(id)` means "send `CMSG_AREATRIGGER` for this one".
///
/// Pure, and separate from the system, because the property that matters is a state machine
/// rather than a query: it must fire **once** per entry. Firing per frame would be a packet
/// flood at best and, on a teleport trigger, an infinite loop — arrive, fire, arrive.
fn step(&mut self, triggers: &AreaTriggerCatalog, map_id: u32, p: [f32; 3]) -> Option<u32> {
if let Some((latched_map, id)) = self.0 {
let still_in = latched_map == map_id
&& triggers
.on_map(map_id)
.iter()
.find(|t| t.id == id)
.is_some_and(|t| t.contains(p));
if still_in {
return None;
}
self.0 = None;
}
let entered = triggers.first_containing(map_id, p)?;
self.0 = Some((map_id, entered.id));
Some(entered.id)
}
}
pub(crate) struct AreaTriggerPlugin;
impl Plugin for AreaTriggerPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<InsideTrigger>()
.add_systems(Startup, load_area_triggers.after(AssetSet::Open))
// In the Stream band: after Input has moved the avatar (and applied any teleport
// snap), so the position tested is the one this frame ends at — the same one the
// movement stream reports, which is what the server re-checks us against.
.add_systems(
Update,
check_area_triggers
.in_set(WorldStage::Stream)
.run_if(in_state(crate::char_select::ClientState::InWorld)),
);
}
}
fn load_area_triggers(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
let Some(assets) = assets else { return };
let mut chain = assets.chain.lock_recover();
match benilla_formats::load_area_trigger_catalog(&mut chain) {
Ok(cat) => {
info!("area_trigger: {} triggers", cat.len());
commands.insert_resource(AreaTriggers(cat));
}
Err(e) => warn!("area_trigger: AreaTrigger.dbc failed to load: {e:#}"),
}
}
/// The per-frame check — the reference's `0x5e2110`, in the same order it runs.
fn check_area_triggers(
triggers: Option<Res<AreaTriggers>>,
map: Option<Res<CurrentMap>>,
player: Res<Player>,
mut inside: ResMut<InsideTrigger>,
net: Res<NetCommands>,
) {
let (Some(triggers), Some(map)) = (triggers, map) else {
return;
};
// Before the server has placed us, `pos` is wherever the free-flying camera left the avatar —
// not a position the server would recognise, and not one worth reporting.
if !player.active {
return;
}
let here = bevy_to_wow(player.pos);
let Some(trigger_id) = inside.step(&triggers.0, map.0, here) else {
return;
};
// Fire-and-forget, like every other send: a down write thread drops it.
let _ = net.0.send(ClientCommand::AreaTrigger { trigger_id });
// At `info`, deliberately: entering a trigger is rare (you must cross a volume boundary), and
// when a portal or instance entrance "does nothing", the first question is whether the client
// saw the volume at all. One line answers it.
info!(
"area_trigger: entered {trigger_id} on map {} at [{:.2}, {:.2}, {:.2}]",
map.0, here[0], here[1], here[2]
);
}
#[cfg(test)]
mod tests {
use super::*;
fn real_catalog() -> Option<AreaTriggerCatalog> {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return None;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
Some(benilla_formats::load_area_trigger_catalog(&mut chain).expect("AreaTrigger.dbc"))
}
/// The whole state machine over the **real** table, walking the exact route the live probe
/// walked: the Darnassus portal pair (B70) and the Southshore inn's box (the shape the sphere
/// tests can't reach). Skips without client data.
///
/// The second assertion is the load-bearing one — a portal that reported every frame would
/// teleport you the instant you arrived, forever.
#[test]
fn a_trigger_fires_once_per_entry_and_re_arms_on_leaving() {
let Some(cat) = real_catalog() else { return };
let mut inside = InsideTrigger::default();
// Rut'theran Village's portal (542, a 10-yd sphere): step in, and it reports once.
let ruttheran = [8799.41, 969.787, 30.2409];
assert_eq!(inside.step(&cat, 1, ruttheran), Some(542));
assert_eq!(
inside.step(&cat, 1, ruttheran),
None,
"standing in a portal must not re-report it — that is the teleport loop"
);
// The server answers by putting us at the Darnassus end. That landing spot is deliberately
// OUTSIDE the return trigger (527, also 10 yd) — 17.2 yd from its centre, and 13.9 yd the
// other direction — which is why a round trip is not a loop. Nothing fires, and the latch
// re-arms.
let darnassus_arrival = [9946.25, 2612.97, 1316.49];
assert_eq!(inside.step(&cat, 1, darnassus_arrival), None);
// Walk into the return portal proper: it reports, having re-armed.
assert_eq!(inside.step(&cat, 1, [9947.48, 2630.04, 1318.6]), Some(527));
// A map change cannot leak the latch: the Southshore inn's BOX trigger (708) is on map 0
// and fires immediately, even though a trigger is still latched from map 1.
assert_eq!(
inside.step(&cat, 0, [-854.547, -576.314, 18.4659]),
Some(708)
);
assert_eq!(inside.step(&cat, 0, [-854.593, -576.207, 18.5563]), None);
// Deadmines' entrance (78) — the instance-portal case (N02), a 7-yd sphere on map 0.
assert_eq!(inside.step(&cat, 0, [-11208.5, 1685.34, 25.7612]), Some(78));
}
/// Empty air reports nothing, and an unknown map is not an error.
#[test]
fn open_ground_and_unknown_maps_are_silent() {
let Some(cat) = real_catalog() else { return };
let mut inside = InsideTrigger::default();
assert_eq!(inside.step(&cat, 0, [0.0, 0.0, 0.0]), None);
assert_eq!(inside.step(&cat, 9999, [8799.41, 969.787, 30.2409]), None);
}
}
+17 -1
View File
@@ -23,6 +23,7 @@
use benilla_assets::BillboardInfo;
use benilla_formats::{BillboardKind, BoneScaleAnim};
use bevy::mesh::MeshTag;
use bevy::prelude::*;
use crate::player::WorldCamera;
@@ -32,8 +33,15 @@ use crate::player::WorldCamera;
/// per-frame system rewrites the entity transform from these — including `Visibility` (the
/// hidden-owner mirror), so a card requires it rather than trusting every spawn site's `Mesh3d`
/// to bring it along.
///
/// It requires a [`MeshTag`] for the same reason: a card is a world ROOT, so every per-model alpha
/// that reaches an ordinary submesh by descending the model's tree has to reach a card through its
/// own tag instead (`player::apply_self_model_fade` — the zoom-to-first-person feather). Only the
/// alpha-animated spawn sites used to bring one, which left the channel *incidentally* present;
/// the default `MeshTag(0)` is the shader's untagged-⇒-opaque sentinel, so requiring it changes
/// nothing about how a card draws.
#[derive(Component)]
#[require(Transform, Visibility)]
#[require(Transform, Visibility, MeshTag)]
pub struct BillboardCard {
world_pivot: Vec3,
scale: f32,
@@ -124,6 +132,14 @@ impl BillboardCard {
self.seq_translation = anim;
}
/// The entity this card follows, if any — the anchor/joint that decides both where it sits and
/// which model it BELONGS to. A card is a world root, so a system that walks a model's tree
/// (the self-avatar fade) can only recognise the model's own cards by testing this against the
/// entities it walked.
pub(crate) fn follows(&self) -> Option<Entity> {
self.follow
}
/// Re-seat a card that FOLLOWS something (the questgiver `!`/`?` markers over a unit that can
/// move) — recompute the world pivot/scale/rotation from a fresh `placement`, keeping the card's
/// orientation kind, rest normal, and animation phase. Doodad cards never need this (their
+50
View File
@@ -141,6 +141,11 @@ pub(crate) struct FxViewState {
/// the ground scenario's streamed tiles, high enough that terrain never intersects the model.
pub(crate) const FXVIEW_POS: [f32; 3] = [-8960.0, -145.0, 90.0];
/// How far the `vista` fixture seats its eye above the position it is given (yd) — a standing human's
/// camera pivot, so pasting a `.go xyz` straight off the debug panel frames what the director saw
/// rather than a worm's-eye view from inside the ground.
const VISTA_EYE_HEIGHT: f32 = 2.0;
/// Print the BASELINE scenario names, one per line — the single source of truth
/// `scripts/visual.sh` reads so the driver never drifts from the code. Invoked by `main` for
/// `WOW_CAPTURE=list`. On-demand fixtures (the UI look-pass windows, sun/moon/sky, house-compass)
@@ -298,6 +303,51 @@ impl Plugin for CapturePlugin {
minute: 720,
ui: None,
}
} else if name == "vista" {
// The **arbitrary-viewpoint** instrument: stand anywhere on the map, face any heading,
// at any clock — the world half of what `fxview` is for effects. A director report that
// arrives as "look at this horizon, here" (position, facing and time are all on the debug
// panel, and `copy .go xyz` puts the position on the clipboard) becomes a reproducible
// headless capture instead of a round-trip. Pair it with `WOW_FARCLIP` to match their
// slider — horizon and fog artifacts live and die by the far-clip wall. Not a golden
// scenario (its output depends on the knobs, not the name).
//
// WOW_CAPTURE=vista WOW_VISTA_AT=-5841.9,-3802.4,-59.7 WOW_VISTA_FACE=24 \
// WOW_VISTA_MIN=1052 WOW_FARCLIP=320 WOW_CAPTURE_OUT=/tmp/v.png cargo run -q -p benilla
//
// Knobs: `WOW_VISTA_AT` (required, raw WoW `x,y,z` — the PLAYER position; the eye seats
// `VISTA_EYE_HEIGHT` above it), `WOW_VISTA_FACE` (heading in degrees — the panel's
// "facing" in its `(24°)` form; 0 = +X, counter-clockwise), `WOW_VISTA_PITCH` (degrees,
// + = up, default 0 = level), `WOW_VISTA_MIN` (game minute of day, default 720 = noon).
let knob = |k: &str, d: f32| {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(d)
};
let Some(at) = std::env::var("WOW_VISTA_AT").ok().and_then(|v| {
let c: Vec<f32> = v.split(',').filter_map(|p| p.trim().parse().ok()).collect();
(c.len() == 3).then(|| [c[0], c[1], c[2]])
}) else {
eprintln!("WOW_CAPTURE=vista needs WOW_VISTA_AT=<x,y,z> (raw WoW coords)");
std::process::exit(2);
};
let face = knob("WOW_VISTA_FACE", 0.0).to_radians();
let pitch = knob("WOW_VISTA_PITCH", 0.0).to_radians();
let eye = [at[0], at[1], at[2] + VISTA_EYE_HEIGHT];
// A look point far enough out that the framing is the heading, not the distance.
let d = 500.0_f32;
Scenario {
name: "vista",
eye,
look: [
eye[0] + d * pitch.cos() * face.cos(),
eye[1] + d * pitch.cos() * face.sin(),
eye[2] + d * pitch.sin(),
],
minute: knob("WOW_VISTA_MIN", 720.0) as u32,
ui: None,
}
// By name, EITHER table: the blessed six or an on-demand fixture. Only the sweep is
// narrowed — every old viewpoint is still capturable by name (decision 0632).
} else if let Some(&s) = SCENARIOS
+100 -4
View File
@@ -661,6 +661,12 @@ fn drive_live_fps(
/// login whirlpool investigation: the real client draws 793 particle quads across 23 draws in
/// one `UI_MainMenu` frame). Works at any state — the glue screens included, unlike the
/// in-world-gated FPS probe.
///
/// It also measures **draw distance** (decision 0678): each emitter's planar depth along
/// camera-forward — the coordinate the far-clip wall uses — and the draw-set gate's verdict, with
/// `drawn_beyond_wall` on the summary line. That is the numeric form of "effects render at
/// unlimited distance" (bug B39): emitters still ticking and drawing past the wall that has already
/// discarded the terrain beneath them. **It must read 0**; a non-zero value is the bug, live.
pub(crate) struct ParticleCensusPlugin;
impl Plugin for ParticleCensusPlugin {
@@ -684,7 +690,14 @@ struct ParticleCensus {
fn fire_particle_census(
mut probe: ResMut<ParticleCensus>,
time: Res<Time>,
emitters: Query<&crate::particles::ParticleEmitter>,
view: Res<crate::view::ViewDistance>,
cam: Query<&GlobalTransform, With<crate::player::WorldCamera>>,
emitters: Query<(
&crate::particles::ParticleEmitter,
Option<&crate::particles::EmitterFade>,
&Visibility,
Option<&bevy::camera::visibility::RenderLayers>,
)>,
) {
if probe.fired || time.elapsed_secs() < probe.at {
return;
@@ -692,7 +705,71 @@ fn fire_particle_census(
probe.fired = true;
let mut total = 0usize;
let mut n = 0usize;
for e in &emitters {
// The B39 columns (decision 0678). Per emitter: its planar depth along camera-forward (the
// coordinate the far-clip wall is measured in) and the draw-set gate's live verdict.
//
// **`drawn_beyond_wall` is the number that names the bug.** It counts emitters the gate is
// still ticking and drawing at a depth where the detailed world — the terrain under them
// included — has already been discarded by the wall. Before 0678 it was routinely non-zero,
// because `doodad_fade_alpha` admits any owner over `NEVER_FADE_RADIUS` at *every* distance
// and nothing else bounded depth; that is precisely "all effects render at unlimited
// distance", and precisely the reporter's "the terrain is not even rendered that far".
// It must now be **0**: past the wall the gate hides the emitter and freezes its pool.
//
// `beyond_wall` (verdict ignored) stays as the denominator — emitters *exist* out there and
// should, they are simply frozen. A fix that despawned them would be the wrong fix.
//
// **Booth-layered emitters are excluded from the distance accounting** — the same layer filter
// `simulate_particles` uses to pick a booth's camera. The portrait/glue scenes are parked
// thousands of yards from the world and drawn by their OWN camera, so the world camera's wall
// says nothing about them; counting them read as 28 phantom "effects past the wall" (all of
// them Karazahn braziers and night-elf glows at ~7080 yd) on a build where the world was
// already clean. Measuring the right subject is the instrument's job, not the reader's.
let cam_tf = cam.iter().next();
let mut beyond_wall = 0usize;
let mut drawn_beyond_wall = 0usize;
let mut drawn_beyond_wall_live = 0usize;
let mut booth = 0usize;
let mut max_drawn_depth = f32::NEG_INFINITY;
for (e, fade, vis, layers) in &emitters {
let world_layer =
layers.is_none_or(|l| l.intersects(&bevy::camera::visibility::RenderLayers::default()));
// Depth to the OWNER's fade sphere where there is one (the gate's own subject), else the
// emitter's live anchor — so the number always names what the gate actually tests.
let depth = cam_tf.map(|t| {
let center = fade.map_or_else(|| e.anchor_world(), |f| f.center);
let radius = fade.map_or(0.0, |f| f.radius);
(center - t.translation()).dot(Vec3::from(t.forward())) - radius
});
let drawn = *vis != Visibility::Hidden;
if !world_layer {
booth += 1;
}
if let Some(d) = depth.filter(|_| world_layer) {
if drawn {
max_drawn_depth = max_drawn_depth.max(d);
}
if d > view.farclip {
beyond_wall += 1;
if drawn {
drawn_beyond_wall += 1;
drawn_beyond_wall_live += e.live();
}
}
}
let dist = depth
.map(|d| {
let lane = if world_layer { "world" } else { "booth" };
let c = fade.map_or_else(|| e.anchor_world(), |f| f.center);
format!(
" depth={d:.1} drawn={drawn} lane={lane} gated={} at=({:.0},{:.0},{:.0})",
fade.is_some(),
c.x,
c.y,
c.z
)
})
.unwrap_or_default();
let d = e.def();
let rate_keys: Vec<String> = d
.emission_rate
@@ -713,7 +790,7 @@ fn fire_particle_census(
})
.unwrap_or_default();
println!(
"PARTICLE_CENSUS_EMITTER blend={:?} flags={:#06x} rate=[{}] life={:.2} tex={} live={}{plane}",
"PARTICLE_CENSUS_EMITTER blend={:?} flags={:#06x} rate=[{}] life={:.2} tex={} live={}{dist}{plane}",
d.blend,
d.flags,
rate_keys.join(","),
@@ -724,7 +801,26 @@ fn fire_particle_census(
total += e.live();
n += 1;
}
println!("PARTICLE_CENSUS emitters={n} live_total={total}");
let max_drawn_depth = if max_drawn_depth.is_finite() {
max_drawn_depth
} else {
0.0
};
// The camera pose goes on the line so a census is self-describing: every distance number here
// is measured from it, and a probe whose `.go` silently failed otherwise reports crisp numbers
// about the wrong place.
let where_ = cam_tf
.map(|t| {
let p = t.translation();
format!(" cam=({:.1},{:.1},{:.1})", p.x, p.y, p.z)
})
.unwrap_or_else(|| " cam=none".into());
println!(
"PARTICLE_CENSUS emitters={n} booth={booth} live_total={total} farclip={:.0} \
beyond_wall={beyond_wall} drawn_beyond_wall={drawn_beyond_wall} \
drawn_beyond_wall_live={drawn_beyond_wall_live} max_drawn_depth={max_drawn_depth:.1}{where_}",
view.farclip,
);
}
/// The bevy_ui node census (`WOW_NODE_PROBE=<secs>`): once, `t` seconds in, print one line per
@@ -56,6 +56,7 @@ fn caster_model() -> ModelAnimations {
],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
}
@@ -251,6 +252,7 @@ fn fidget_model() -> ModelAnimations {
clips: vec![head, look, clip(11, 7, true) /* ShuffleLeft */],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
}
@@ -359,6 +361,7 @@ fn cast_hold_stows_even_when_the_model_lacks_the_spell_anims() {
clips: vec![clip(0, 1, true), clip(26, 2, true)],
hand_close: [None, None],
playable_animation_lookup: lookup,
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
};
@@ -425,6 +428,7 @@ fn spell_impact_wound_rides_the_secondary_slot() {
],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
};
@@ -477,6 +481,7 @@ fn whiff_slowdown_spares_a_non_swing_oneshot() {
],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
};
@@ -561,6 +566,7 @@ fn same_frame_collision_fast_paths_the_second_combat_clip() {
],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
};
@@ -655,6 +661,7 @@ fn deferred_combat_clip_plays_once_the_body_frees() {
],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
};
@@ -710,6 +717,7 @@ fn a_movement_flag_change_cuts_a_full_body_oneshot_immediately() {
],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
};
@@ -863,6 +871,7 @@ fn the_swim_relatch_holds_the_kick_but_a_ground_cut_freezes_it() {
],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
};
@@ -955,6 +964,7 @@ fn unit_flag_looting_kneels_stationary_units_only() {
clips: vec![clip(0, 1, true), clip(50, 2, false), clip(5, 3, true)],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
};
@@ -1010,6 +1020,7 @@ fn the_self_kneel_rides_the_loot_latch_not_the_flag() {
clips: vec![clip(0, 1, true), clip(50, 2, false), clip(5, 3, true)],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
};
@@ -1097,6 +1108,7 @@ fn a_looping_arm_advances_through_its_variations_at_window_end() {
clips: vec![variation(nodes[0]), variation(nodes[1])],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
};
+1
View File
@@ -217,6 +217,7 @@ mod tests {
}],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
};
+5 -4
View File
@@ -430,11 +430,12 @@ fn debug_panel_ui(
}
ui.add_space(6.0);
// Faithful `farclip`: doodads/WMOs hard-pop out past this (no fade). Vanilla
// range [177, 777]; the slider goes a little past 777 only for A/B against the
// old "draw everything in the tile window" look.
// Faithful `farclip`: doodads/WMOs hard-pop out past this (no fade). The
// range (and why it runs past vanilla's 777) is `view::FARCLIP_RANGE`,
// shared with the `$WOW_FARCLIP` capture knob so a setting seen here can
// be reproduced headlessly.
ui.add(
egui::Slider::new(&mut view.farclip, 177.0..=1200.0)
egui::Slider::new(&mut view.farclip, crate::view::FARCLIP_RANGE)
.text("view distance / farclip (yd)"),
);
});
+4 -2
View File
@@ -82,8 +82,10 @@ pub(super) fn apply_model_visibility(
};
// Planar depth along the camera-forward axis (the SAME coordinate the per-pixel wall
// uses) of the bound's nearest point — so the cull and the wall agree and the object
// dissolves through the boundary with no pop, even off-centre.
(center - c).dot(fwd) - radius <= view.farclip
// dissolves through the boundary with no pop, even off-centre. The rule itself lives
// in `view::within_farclip`; the particle draw-set gate reads the same one, because
// an emitter outliving its own doodad past the wall is exactly what B39 reported.
crate::view::within_farclip(view.farclip, c, fwd, center, radius)
}
};
+29 -10
View File
@@ -37,9 +37,12 @@ pub(crate) enum DoodadAnimTier<'a> {
/// Only free-running global-sequence channels (candelabra glow pulses): joints +
/// [`GlobalSeqDrive`], **no** `AnimationPlayer` — the cheapest animated tier.
GlobalSeqOnly,
/// The file-order-first sequence moves bones (flags, windmills, torch flames): joints + an
/// `AnimationPlayer` looping this clip — the client's one-time load arm. Global-sequence
/// channels ride along if the model also has them.
/// The **loader-idle seed** moves bones (flags, windmills, torch flames): joints + an
/// `AnimationPlayer` looping this clip — the client's one-time load arm. That seed is animation
/// id 0 (Stand) resolved through the model's own `playableAnimationLookup`, NOT the file-order
/// first sequence (decision 0637; the variant name predates the correction and is kept because
/// it is the tier's identity, not a claim about which sequence). Global-sequence channels ride
/// along if the model also has them.
FirstSeq(&'a AnimClip),
}
@@ -75,9 +78,11 @@ pub(crate) struct AnimHostSpawn {
/// Spawn the animation host for one placed M2, if [`classify`] says it animates: an anim-root entity
/// at the placement transform, the joint hierarchy as its children (so despawning the root cascades),
/// and — per tier — an `AnimationPlayer` looping the file-order-first sequence's clip (the client's
/// one-time load arm, wow-re `doodad-anim-host.md`: `0x7121a0(bone 0, animations[0].id, linkFlag=1)`
/// once, at `0x70ebd0`) and/or the free-running [`GlobalSeqDrive`]. `None` ⇒ the model is static and
/// and — per tier — an `AnimationPlayer` looping the **loader-idle seed's** clip (the client's
/// one-time load arm at `0x70ebd0`: `0x7121a0(bone 0, animation id 0 resolved through the model's
/// own `playableAnimationLookup`, linkFlag=1)` once — wow-re `gameobject-anim-arm.md` §1, which
/// CORRECTED `doodad-anim-host.md` §1's prose reading of `animations[0].id`; decision 0637) and/or
/// the free-running [`GlobalSeqDrive`]. `None` ⇒ the model is static and
/// the caller keeps today's path untouched.
pub(crate) fn spawn_anim_host(
commands: &mut Commands,
@@ -182,6 +187,9 @@ fn gate_doodad_anim(
(&GlobalTransform, &bevy::camera::primitives::Frustum),
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.
view: Res<crate::view::ViewDistance>,
mut logged: Local<bool>,
) {
// One breadcrumb per session, the first frame any host exists — the machine-readable "doodads
@@ -197,18 +205,25 @@ fn gate_doodad_anim(
// Meshless (particles-only) host: the emitters' own draw-set law (see the `fade`
// field doc) — the reference ticks animation for any model in the draw set, and a
// 0-batch model is admitted on its fade sphere exactly like its emitters are.
// "Exactly like" is now literal: this calls `EmitterFade::in_draw_set`, the single
// spelling of the rule, rather than a second copy of it. The copy is how the far-clip
// 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)| {
let cam_pos = cam_tf.translation();
let (dx, dz) = (center.x - cam_pos.x, center.z - cam_pos.z);
crate::model_fade::doodad_fade_alpha(radius, (dx * dx + dz * dz).sqrt()) > 0.0
&& frustum.intersects_sphere(
crate::particles::EmitterFade { radius, center }.in_draw_set(
cam_pos,
Vec3::from(cam_tf.forward()),
view.farclip,
frustum.intersects_sphere(
&bevy::camera::primitives::Sphere {
center: center.into(),
radius,
},
false,
)
),
)
})
} else {
host.meshes
@@ -522,6 +537,7 @@ mod tests {
graph: Handle::default(),
clips,
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
hand_close: [None, None],
global_bones: if gseq {
vec![GlobalBone {
@@ -701,6 +717,9 @@ mod tests {
fn gate_pauses_hidden_and_resumes_on_the_shared_clock() {
let mut app = App::new();
app.add_plugins(MinimalPlugins); // Time
// 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>();
app.add_systems(Update, gate_doodad_anim);
let mesh = app.world_mut().spawn(Visibility::Inherited).id();
+25 -11
View File
@@ -61,9 +61,15 @@ pub(super) use glue_preview::build_glue_preview;
/// like the creature path (GOs are few — the doodad host's draw-gate isn't warranted for them).
///
/// The reference *also* runs a second, GameObject-specific arm off `GAMEOBJECT_STATE` +
/// `GAMEOBJECT_ANIMPROGRESS` (same note, §1). Not modelled here: for every GO benilla renders
/// today it resolves to the same Stand this seed already arms — a named deferral, not a claim
/// that the overlay doesn't exist.
/// `GAMEOBJECT_ANIMPROGRESS` (same note, §2), and it lands AFTER this seed, so where both apply
/// the state arm is what is seen. benilla runs that arm only for the door/button/chest types
/// ([`crate::go_anim::go_animates`]). **That gate is a known narrowing, and it is not free:**
/// `benilla-extract goanimscan` measures 251 of 1582 GameObjectDisplayInfo models as
/// STATE-SENSITIVE (some reachable substate plays a different sequence from this seed), which on
/// the live world data is **1979 spawns** sitting on such a model with a GO type left off the
/// machine — books, traps, goobers, generic props. Widening it waits on the wow-re type census
/// (which of the 30 types allocate the `[GO+0x210]` handler at the `0x5f708c` dispatch); the
/// seed below is what those types render in the meantime.
///
/// Global-sequence channels ride along regardless of flavour. Returns the joints + inverse-bindposes
/// for the caller to bind each submesh's skinned twin, or `None` when the model has no inverse
@@ -87,17 +93,24 @@ fn setup_skinned_instance(
commands.entity(entity).insert(bb);
}
if let Some(anims) = d.animations.as_ref() {
// A non-state GameObject loops its first sequence directly on the player (the loader-idle
// seed); a creature/door leaves the player idle here for its driver to arm.
let idle_loop = kind == EntityKind::GameObject && !go_state_machine;
// **Every** GameObject instance gets the loader-idle seed, state machine or not — the
// reference's `0x70ebd0` tail arms bone 0 the moment the M2 goes LIVE and has exactly two
// callers, so no M2 instance in the client ever exists with nothing armed (wow-re
// `gameobject-anim-arm.md` §1/§2e). For a door/chest the object-layer arm lands *after* it
// and overrides it (§2, "because it lands after the loader seed, it is the effective arm");
// seeding first is what stops the one-frame BIND POSE our state GOs used to render on their
// first displayed frame, before `go_anim` had a chance to run — the "explodes for a split
// second" report. The seed is played THROUGH the transitions object so that first arm
// cleanly fades out of it; playing it bare on the player would leave two clips live at once.
let mut player = AnimationPlayer::default();
if idle_loop {
let mut transitions = AnimationTransitions::new();
if kind == EntityKind::GameObject {
if let Some(clip) = anims.first_seq.and_then(|i| anims.clips.get(i)) {
// Loop iff the sequence says so (`M2Sequence.flags & 1 == 0`) — the kernel's own
// end-of-band law (wow-re `gameobject-anim-arm.md` §2, byte-verified at
// `0x714585`): bit0 clear loops on the modulo wrap, bit0 set plays the window and
// then FREEZES at `end_ms`. An unconditional repeat replayed one-shot idles.
let active = player.play(clip.node);
let active = transitions.play(&mut player, clip.node, std::time::Duration::ZERO);
if clip.looping {
active.repeat();
}
@@ -111,13 +124,14 @@ fn setup_skinned_instance(
match kind {
EntityKind::GameObject if go_state_machine => {
commands.entity(entity).insert((
// Cross-fades the open/close transition over the clip's blend-in time (0242/0049).
AnimationTransitions::new(),
// Cross-fades the open/close transition over the clip's blend-in time (0242/0049),
// and carries the seed above as the pose the first arm transitions out of.
transitions,
crate::go_anim::GoAnim::default(),
));
}
// A loader-idle GameObject needs no driver and no transitions — the looping player IS the
// whole animation.
// whole animation, and nothing will ever arm over it.
EntityKind::GameObject => {}
_ => {
commands
+9 -3
View File
@@ -30,9 +30,15 @@
//! refreshes the placement from the owner's propagated `GlobalTransform` every frame and the
//! risen cloud re-anchors to the prop's live position (the reference's per-frame
//! `translate(−emitterPos)` draw-matrix rebuild: a moving model carries its flame). No
//! [`crate::particles::EmitterFade`] — that gate's centre is a baked world point; like every
//! entity-owned emitter, the population is bounded by server visibility instead. The emitter
//! entity parents under the gameobject, so the off-map hide reaches it and despawn cascades.
//! [`crate::particles::EmitterFade`] — that gate's centre is a baked world point, and a mover has
//! none. The emitter entity parents under the gameobject, so the off-map hide reaches it and
//! despawn cascades.
//! ⚠ **This used to say the population is "bounded by server visibility instead". That was
//! wrong, and a transport is the exact counter-example** (decision 0678, bug B39): vmangos
//! streams transports **map-wide**, so a ship's deck lanterns are resident from anywhere on the
//! map. Measured from Durotar: 56 emitters ticking and drawing at 4853–7080 yd, this boat's
//! lanterns among them. The bound is now the far-clip wall, applied in
//! [`crate::particles`]' sim to every world-lane emitter regardless of `EmitterFade`.
//! - **M2 point lights** (the lantern's glow source) spawn as CHILDREN at the prop-local
//! position — propagation carries the source with the hull, and the hide/despawn follow.
//! - **Interior cabin props** (an INDOOR-group MODD — ~118 of the ship's 133) take the interior
+231 -26
View File
@@ -41,10 +41,16 @@
//! [`ModelAnimations::clips`], exactly as `creature_anim` does. A one-shot motion held at its end frame
//! *is* the destination rest pose, so playing the motion and holding needs no explicit settle step.
//!
//! Deferred (noted, not this slice): the §243 **fallback chain** for a model that authors only the motion
//! clips (rest pose = the neighbour motion frozen at frame 0) — folded once a real door M2 dump shows it's
//! needed; the **ANIMPROGRESS one-time seek** (resync a door streamed in mid-swing — we play from frame 0);
//! and the mid-flight **reverse blend** (interrupting a half-open door). None change the common case.
//! The §243 **missing-sequence fallback** is no longer deferred: [`remap_missing`] implements the
//! four-way remap, including the two legs that freeze a *motion* clip at frame 0 to stand in for an
//! absent rest pose. It is not a corner case — the Ahn'Qiraj gate's roots (`AHN_QIRAJ_DOORROOTS`,
//! Stand + Open only) took the "play nothing" path and rendered its 42-bone tangle at bind pose.
//!
//! Still deferred (noted, not this slice): the **ANIMPROGRESS** half — the field selects the *motion*
//! substate rather than the rest one at spawn, and seeks the clip to `duration × progress / 100`
//! (wow-re `gameobject-anim-arm.md` §2b, `go-anim-state-machine.md`'s seek section); benilla reads
//! only `GAMEOBJECT_STATE` and always plays from frame 0. And the mid-flight **reverse blend**
//! (interrupting a half-open door).
use avian3d::prelude::{Collider, ColliderDisabled};
use benilla_assets::ModelAnimations;
@@ -82,14 +88,40 @@ pub(crate) struct GoAnim {
last_wire: Option<u32>,
}
/// Which GameObject types get the state-driven **animated** instance (skinned lid/door + §243 sequences):
/// the door/button state machine (**DOOR(0) / BUTTON(1)**, server-driven over the wire) plus the
/// **CHEST(3)**, whose lid runs the identical machine but fed by loot events (decision 0250). A model that
/// doesn't author a skeleton/sequences still falls back to a static mesh (the attach gate also checks the
/// joints/animations exist), so a lid-less chest simply stays static. A **GOOBER(10)** flips state too but
/// also fires custom-anim + spells, so its path is unverified and left out until checked.
/// Which GameObject types get the state-driven **animated** instance (skinned lid/door + §243
/// sequences) — **the byte-verified type census**, not a guess (wow-re `gameobject-anim-arm.md` §2f).
///
/// `CGGameObject::LoadBaseObject` dispatches on the wire TYPE_ID through the 31-entry jump table at
/// `0x5f76cc` (`cmp ecx,0x1e` + *unsigned* `ja`, so 0..30) and allocates a per-type strategy handler
/// into `[GO+0x210]`. The handlers fall into exactly two families, and the split is legible in the
/// **allocation size**: every type whose handler is `0x1c` bytes gets a 36-slot vtable carrying the
/// real `0x5f3c30`/`0x5f3b50` arm; every other size gets a 34-slot vtable whose corresponding slots
/// are the abstract base's do-nothing bodies — and whose `+0x88` doesn't even exist (reading it walks
/// into the next vtable, which is what made an earlier census report plausible nonsense).
///
/// So the machine is **20 of the 31 types**, not the three we had:
///
/// | on the machine | off it |
/// |----------------|--------|
/// | 0 DOOR · 1 BUTTON · 2 QUESTGIVER · 3 CHEST · 6 TRAP · 8 SPELL_FOCUS · 9 TEXT · 10 GOOBER · 12 AREADAMAGE · 16 DUELFLAG · 17 FISHINGNODE · 18 SUMMONING_RITUAL · 19 MAILBOX · 23 MEETINGSTONE · 24 FLAGSTAND · 26 FLAGDROP · 27 MINI_GAME · 28 LOTTERY_KIOSK · 29 CAPTURE_POINT · 30 | 4 BINDER · 5 GENERIC · 7 CHAIR · 11 TRANSPORT · 13 CAMERA · 14 MAP_OBJECT · 15 MO_TRANSPORT · 20 AUCTIONHOUSE · 21 GUARDPOST · 22 SPELLCASTER · 25 FISHINGHOLE |
///
/// Type **30** is past vmangos's enum but has a real family-A arm in the table, so it is included for
/// completeness; type **21 GUARDPOST** has no `case` in the source at all and shares the out-of-range
/// default, which points `[GO+0x210]` at a static placeholder and logs `"BADBASEGAMEOBJECT|%d"`.
///
/// The old `0 | 1 | 3` was wrong in both directions — far too narrow, and its stated reason for
/// excluding GOOBER(10) ("also fires custom-anim + spells, so its path is unverified") was not a
/// reason the binary recognises: the custom-anim opcode drives a *disjoint* substate family (8..11),
/// never the lid. Measured against live world data, the narrowing left **1427 spawns** — books,
/// traps, goobers, questgivers — rendering the loader seed's pose instead of their state's.
///
/// A model that doesn't author a skeleton/sequences still falls back to a static mesh (the attach
/// gate also checks the joints/animations exist), so a lid-less chest simply stays static.
pub(crate) fn go_animates(type_id: i32) -> bool {
matches!(type_id, 0 | 1 | 3)
matches!(
type_id,
0 | 1 | 2 | 3 | 6 | 8 | 9 | 10 | 12 | 16 | 17 | 18 | 19 | 23 | 24 | 26 | 27 | 28 | 29 | 30
)
}
/// Which types drop their collision when open (decision 0249): **DOOR(0) / BUTTON(1)** only. A door's
@@ -128,6 +160,56 @@ impl Play {
}
}
/// The §2c **missing-sequence remap** — what the arm actually requests when the model doesn't author
/// the state's animation id. Byte-verified twice over (wow-re `gameobject-anim-arm.md` §2c's
/// `0x5f3972` jump table `0x5f3b40`, and `go-anim-state-machine.md`'s independent read of the same
/// switch), it is a four-way table keyed on the id, consulted only after the ownership test
/// [`ModelAnimations::owns`] (the reference's `0x711960`) says no:
///
/// | missing | condition | requests instead |
/// |------------|-------------------|----------------------------|
/// | 146 Close | owns Open | 146 (op4 resolves onward) |
/// | | else | 147 Closed |
/// | 147 Closed | owns Close | 147 (op4 resolves onward) |
/// | | else owns Open | **148 Open, frozen** |
/// | | else | 0 Stand |
/// | 148 Open | owns Close | 148 (op4 resolves onward) |
/// | | else owns Destroy | 150 Destroy |
/// | | else | 149 Opened |
/// | 149 Opened | owns Open | 149 (op4 resolves onward) |
/// | | else owns Close | **146 Close, frozen** |
/// | | else | 151 Destroyed |
///
/// The returned flag marks the two **frozen** legs, where the reference stands a *motion* clip in for
/// a missing *rest* pose by arming it at playback rate `0`. That reads correctly because a motion's
/// frame 0 IS the pose it departs from: Open's first frame is closed, Close's first frame is open —
/// confirmed in the asset, `G_BookOpenMediumBrown`'s bone-0 rotation being identity at Open's first
/// key and the full open angle at Close's. Ids outside the door group get no remap at all
/// (`lea eax,[esi-0x92]; cmp eax,3; ja`).
///
/// Before this existed an unowned id simply played **nothing**: the `AnimationPlayer` was never armed
/// and the skinned mesh sat at bind pose. `AHN_QIRAJ_DOORROOTS` (the Ahn'Qiraj gate's roots, GO type
/// 1 BUTTON) authors only Stand and Open — no Closed — so it took exactly that path.
fn remap_missing(anims: &ModelAnimations, id: u16) -> (u16, bool) {
if anims.owns(id) {
return (id, false);
}
match id {
146 if anims.owns(148) => (146, false),
146 => (147, false),
147 if anims.owns(146) => (147, false),
147 if anims.owns(148) => (148, true),
147 => (0, false),
148 if anims.owns(146) => (148, false),
148 if anims.owns(150) => (150, false),
148 => (149, false),
149 if anims.owns(148) => (149, false),
149 if anims.owns(146) => (146, true),
149 => (151, false),
other => (other, false),
}
}
/// The held rest-pose animation-id for a wire `GAMEOBJECT_STATE` (§243). `None` for an unmapped state.
fn rest_anim(state: u32) -> Option<u16> {
match state {
@@ -138,14 +220,25 @@ fn rest_anim(state: u32) -> Option<u16> {
}
}
/// The transition-motion animation-id for a `prev → cur` state change (§243), i.e. the swing. `None` when
/// the pair has no distinct motion (falls back to snapping the rest pose).
/// The transition-motion animation-id for a `prev → cur` state change (§243), i.e. the swing. `None`
/// when the pair has no distinct motion (falls back to snapping the rest pose).
///
/// The reference (`0x5f3cb0`, byte-verified in wow-re `go-anim-state-machine.md`) dispatches on the
/// **NEW** state and lets OLD pick transient-vs-rest: NEW 0 takes the Open motion from OLD 1 (else
/// rests Opened), NEW 1 takes Close from OLD 0 / Rebuild from OLD 2 (else rests Closed), NEW 2 takes
/// Destroy from OLD 1 (else rests Destroyed). Our `(_, 2)` wildcard got that last row wrong: an
/// **open** object going destructible (`0 → 2`) rests straight on Destroyed rather than playing the
/// Destroy swing, because the reference's condition is `OLD == 1`, not "any OLD".
///
/// (The reference's conditions are each `OLD == x` **or** `ANIMPROGRESS < 100`. benilla does not read
/// ANIMPROGRESS yet — see the module doc's deferral — so this is the settled column only, which is
/// what every state-change we can observe today resolves to.)
fn motion_anim(prev: u32, cur: u32) -> Option<u16> {
match (prev, cur) {
(1, 0) => Some(0x94), // closed → open : Open motion
(0, 1) => Some(0x92), // open → closed: Close motion
(_, 2) => Some(0x96), // → destroyed : Destroy
(2, 1) => Some(0x98), // rebuild
(1, 0) => Some(0x94), // closed → open : Open motion (else NEW 0 rests Opened)
(0, 1) => Some(0x92), // open → closed : Close motion
(2, 1) => Some(0x98), // rebuilt → closed : Rebuild
(1, 2) => Some(0x96), // closed → destroyed : Destroy (else NEW 2 rests Destroyed)
_ => None,
}
}
@@ -262,11 +355,12 @@ fn drive_go_anim(
let Some(play) = resolve(prev, state) else {
continue;
};
// Resolve the id to this model's clip (keyed by AnimationData.dbc id, as `creature_anim` does). A
// model that doesn't author the resolved id plays nothing (the §243 freeze-fallback is a follow-on)
// — it holds its current pose rather than snapping to bind.
let Some(clip) = anims.clips.iter().find(|c| c.anim_id == play.anim_id()) else {
continue;
// Resolve the id to this model's clip (keyed by AnimationData.dbc id, as `creature_anim` does),
// through the §2c remap for a model that doesn't author it — that is what keeps a lidless
// model on a real pose instead of bind.
let (want, frozen) = remap_missing(anims, play.anim_id());
let Some(clip) = anims.find(want) else {
continue; // nothing playable even after the remap: hold whatever the loader seed armed
};
// Snap a rest pose (blend 0 — a stream-in must not swing); ease a motion over its authored blend.
let blend = match play {
@@ -274,11 +368,23 @@ fn drive_go_anim(
Play::Motion(_) => clip.blend_time.max(0.0),
};
let active = tr.play(&mut player, clip.node, Duration::from_secs_f32(blend));
active.set_repeat(if clip.looping {
RepeatAnimation::Forever
if frozen {
// A motion standing in for a missing rest pose: the reference arms it at playback rate 0,
// so it holds frame 0 forever — the pose that motion departs from.
active.seek_to(0.0);
active.set_speed(0.0);
active.set_repeat(RepeatAnimation::Never);
} else {
RepeatAnimation::Never
});
// Explicit, not defaulted: `AnimationTransitions::play` → `AnimationPlayer::start` only
// `replay()`s the node, which rewinds the clock but keeps `speed` — so re-arming a node a
// frozen leg previously parked at rate 0 (the same Open clip serves both) would stay stuck.
active.set_speed(1.0);
active.set_repeat(if clip.looping {
RepeatAnimation::Forever
} else {
RepeatAnimation::Never
});
}
}
}
@@ -351,6 +457,84 @@ mod tests {
assert!(matches!(resolve(None, 1), Some(Play::Rest(0x93))));
// A change with no distinct motion snaps the destination rest pose.
assert!(matches!(resolve(Some(2), 0), Some(Play::Rest(0x95))));
// The destructible row dispatches on OLD too: only a CLOSED object swings Destroy; an open
// one rests straight on Destroyed (`0x5f3cb0`'s condition is `OLD == 1`, not any OLD).
assert!(matches!(resolve(Some(1), 2), Some(Play::Motion(0x96))));
assert!(matches!(resolve(Some(0), 2), Some(Play::Rest(0x97))));
// …and the rebuild leg stays the motion it always was.
assert!(matches!(resolve(Some(2), 1), Some(Play::Motion(0x98))));
}
/// A `ModelAnimations` that owns exactly `ids` — only the lookup table matters to the remap.
fn owning(ids: &[u16]) -> ModelAnimations {
let hi = ids.iter().copied().max().unwrap_or(0) as usize;
let mut lookup = vec![0xffffu16; hi + 1];
for (slot, &id) in ids.iter().enumerate() {
lookup[id as usize] = slot as u16;
}
ModelAnimations {
graph: Handle::default(),
clips: Vec::new(),
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: lookup,
global_bones: Vec::new(),
first_seq: None,
}
}
#[test]
fn an_owned_id_is_never_remapped() {
let full = owning(&[146, 147, 148, 149]);
for id in [146, 147, 148, 149] {
assert_eq!(remap_missing(&full, id), (id, false));
}
}
#[test]
fn a_missing_rest_pose_freezes_the_neighbouring_motion() {
// The Ahn'Qiraj gate roots: Stand + Open only, no Closed. The reference stands the Open
// motion in at rate 0 — its frame 0 IS the closed pose. Before this, benilla played nothing
// and the 42-bone root tangle rendered at bind pose.
let roots = owning(&[0, 148]);
assert_eq!(remap_missing(&roots, 147), (148, true));
// The mirror leg: no Opened, no Open, but a Close motion → freeze Close at frame 0 (open).
let no_open = owning(&[146]);
assert_eq!(remap_missing(&no_open, 149), (146, true));
}
#[test]
fn the_remap_falls_through_the_door_family_in_the_verified_order() {
// 146 Close missing: kept when Open exists (op4 resolves onward), else Closed.
assert_eq!(remap_missing(&owning(&[148]), 146), (146, false));
assert_eq!(remap_missing(&owning(&[147]), 146), (147, false));
// 147 Closed missing: kept when Close exists; else the frozen leg; else Stand.
assert_eq!(remap_missing(&owning(&[146]), 147), (147, false));
assert_eq!(remap_missing(&owning(&[0]), 147), (0, false));
// 148 Open missing: kept when Close exists; else Destroy if present; else Opened.
assert_eq!(remap_missing(&owning(&[146]), 148), (148, false));
assert_eq!(remap_missing(&owning(&[150]), 148), (150, false));
assert_eq!(remap_missing(&owning(&[0]), 148), (149, false));
// 149 Opened missing with neither motion: Destroyed.
assert_eq!(remap_missing(&owning(&[0]), 149), (151, false));
}
#[test]
fn ids_outside_the_door_group_get_no_remap() {
// `lea eax,[esi-0x92]; cmp eax,3; ja` — only 146..149 index the jump table.
let none = owning(&[0]);
for id in [145, 150, 151, 152, 157] {
assert_eq!(remap_missing(&none, id), (id, false));
}
}
#[test]
fn ownership_reads_the_lookup_table_not_the_clip_list() {
// The table is short — a model's `nAnimationLookup` stops just past its highest id, so an id
// beyond the end is the out-of-bounds sentinel. `clips` is deliberately empty here: a clip
// scan would answer "owns nothing" and take every remap's last leg.
let book = owning(&[146, 147, 148, 149]);
assert!(book.owns(147) && !book.owns(0) && !book.owns(150));
}
#[test]
@@ -363,4 +547,25 @@ mod tests {
assert!(go_animates(0) && collision_follows_state(0));
assert!(go_animates(1) && collision_follows_state(1));
}
#[test]
fn the_type_gate_is_the_verified_census() {
// The 20 family-A types (0x1c-byte handler, 36-slot vtable with the real arm).
for t in [
0, 1, 2, 3, 6, 8, 9, 10, 12, 16, 17, 18, 19, 23, 24, 26, 27, 28, 29, 30,
] {
assert!(go_animates(t), "type {t} runs the machine");
}
// The 11 family-B types, whose vtable slots are the abstract base's do-nothing bodies.
for t in [4, 5, 7, 11, 13, 14, 15, 20, 21, 22, 25] {
assert!(!go_animates(t), "type {t} keeps the loader seed");
}
// Past the table: the unsigned `ja` default, no handler.
assert!(!go_animates(31) && !go_animates(99));
// The three that used to be the whole gate are still on it; the ones it wrongly excluded —
// TEXT (every book) and GOOBER — are the reported regressions this widening closes.
assert!(go_animates(9) && go_animates(10));
// Type 0 is also what an ABSENT type id reads as (decision 0248), so the default animates.
assert!(go_animates(0));
}
}
+3 -1
View File
@@ -77,7 +77,9 @@ pub(super) fn apply_wow_lighting(
// fog off via `fog_color.w`. (The sky-dome horizon is unaffected — it reads the fog *colour*.)
let mut tu = l.terrain_uniforms(t.disable_fog);
// Live hard far-clip wall: terrain.wgsl + wow_model.wgsl read fog_params.w (from the shared buffer)
// and discard fragments beyond it. WDL/liquid ignore .w (they render to the horizon behind the wall).
// and discard fragments beyond it; liquid.wgsl does the same. `wdl.wgsl` reads the SAME value from
// the other side — its band starts at `farclip − 33` and its depth is clamped behind the wall, so
// the horizon backdrop tracks the slider without a seam (decision 0684).
tu.fog_params.w = farclip;
// WDL distant terrain shares the terrain fog exactly (it's unlit white × this fog — the only
// lever it has). One shared material, so this is a single write on each light change.
+21 -5
View File
@@ -124,7 +124,16 @@ pub(super) fn setup_lighting(
// SPAWN_XY (no local sphere covers it, α=0 → blended ≡ global) but right anywhere spheres overlap.
let atmo = catalog
.as_ref()
.map(|c| c.sample_blended(0, [SPAWN_XY.0, SPAWN_XY.1, 83.5], 1440, false, false, false))
.map(|c| {
c.sample_blended(
0,
[SPAWN_XY.0, SPAWN_XY.1, 83.5],
1440,
false,
benilla_formats::Submersion::Dry,
false,
)
})
.unwrap_or(Atmosphere::DEFAULT);
if let Some(cat) = catalog {
commands.insert_resource(LightSampler(cat));
@@ -253,7 +262,10 @@ pub(super) fn update_time_lighting(
// fog + cool light. Feeding it through the normal atmosphere → `WowLighting` path means every
// existing consumer (terrain/model/water/WDL fog + the clear colour) becomes underwater for free
// (VERIFIED apitrace WoW.18: the reference just switches the active param; no overlay quad).
let submerged = underwater.as_ref().is_some_and(|u| u.0);
let submerged = underwater
.as_ref()
.map(|u| u.0)
.unwrap_or(benilla_formats::Submersion::Dry);
// The ghost-world atmosphere (decision 0308 §7, byte-VERIFIED death-light.md): while
// PLAYER_FLAGS_GHOST is up the active LightParams slot is 4 — the death profile — applied
// instantly (the client rebuilds its color tables per frame off the single active slot).
@@ -312,9 +324,13 @@ pub(super) fn update_time_lighting(
// The camera-in-WMO interior-fog crossfade (see [`WmoFogRamp`]) — computed as its OWN triple;
// the scene fog above stays untouched (the round-5 global-overwrite washed the outside view
// golden through the door — director-caught, corrected per the round-6 Q-I lane map).
// Submerged, the underwater Light param owns everything — the MFOG record's own underwater
// block (`+0x24`, gated record flags 0x100/0x10) is a recorded, deferred lane.
let (wmo_fog_color, wmo_fog_start, wmo_fog_end) = if submerged {
// Submerged, the submerged Light param owns everything — the MFOG record's own underwater
// block (`+0x24`, gated record flags 0x100/0x10) is a recorded, deferred lane. Bypassing the ramp
// rather than blending through it is also what the reference does for the fullbright liquids
// specifically: submerged in magma or slime *inside* an MFOG interior it **snaps** the fog block
// instantly (VERIFIED `0x6cef6f-85`) instead of taking the 4 s crossfade — which is the case that
// matters here, since Undercity's slime is WMO liquid.
let (wmo_fog_color, wmo_fog_start, wmo_fog_end) = if submerged.any() {
(atmo.fog_color, fog_start, fog_end)
} else {
wmo_ramp.blend(
+100 -22
View File
@@ -70,13 +70,20 @@ impl Plugin for LiquidPlugin {
}
}
/// Whether the camera eye is currently below a water surface. Set by [`detect_submersion`]; read by
/// `lighting::update_time_lighting`, which (when true) samples the **underwater** Light param so the
/// whole scene gets the dense teal fog + cool tint + teal clear colour (VERIFIED apitrace WoW.18 —
/// the murk is fog + light-tint, no overlay quad). Two clocks aside, this is the one cross-feed from
/// the water subsystem into lighting.
/// What the camera eye is currently submerged in — **which liquid, not merely whether**. Set by
/// [`detect_submersion`]; read by `lighting::update_time_lighting`, which selects the atmosphere from
/// it so the whole scene (fog colour + distances, ambient, diffuse, clear colour) becomes the
/// submerged one (VERIFIED apitrace WoW.18 — the murk is fog + light-tint, no overlay quad). Two
/// clocks aside, this is the one cross-feed from the liquid subsystem into lighting.
///
/// **The kind is load-bearing, not decoration.** Water and ocean read the *zone's* underwater
/// `LightParams` slot; magma and slime read fixed global rows instead, zone-independent
/// (byte-VERIFIED `0x6d2371` — see `benilla_formats::Submersion`). Carrying a bare `bool` here is
/// what left lava and slime with no submerged view at all: they were excluded from the flag outright,
/// because one bool could only mean "the water murk", and turning the Great Forge teal was worse than
/// showing nothing.
#[derive(Resource, Default)]
pub(crate) struct Underwater(pub(crate) bool);
pub(crate) struct Underwater(pub(crate) benilla_formats::Submersion);
/// Which file the liquid came from — the **delegation key** for [`liquid_at`].
///
@@ -102,8 +109,10 @@ pub(crate) enum LiquidSource {
///
/// Named `Water*` from when only water carried one. It now rides **every** kind — magma and slime
/// included, which is what makes Blackrock's lava and Undercity's slime swimmable at all (decision
/// 0634). Consumers that are specifically about *water* (the teal murk, foam, the splash) filter on
/// [`Self::kind`]; the swim mode does not, because you swim in lava too.
/// 0634). Consumers that are specifically about *water* (the surface swatch, foam, the wade splash and
/// footstep depth) filter on [`Self::kind`]; the swim mode does not, because you swim in lava too —
/// and neither does the **submerged atmosphere**, which is per-kind rather than water-only
/// ([`Underwater`]).
///
/// **A liquid is a grid — not a plane, not a triangle soup.** Both of its questions, *is this XY
/// wet* and *how high is the surface here*, are answered by locating the containing cell
@@ -484,8 +493,13 @@ pub(crate) fn describe_at<'a>(
}
/// [`liquid_at`] restricted to **water** kinds — the query for the consumers that are about water
/// specifically (the teal murk, foam, the wade splash), which must not fire in the Great Forge's
/// lava or Undercity's slime. Swim mode deliberately does NOT use this one.
/// specifically (the wade splash, footstep depth, the remote-motion spline's depth), which must not
/// fire in the Great Forge's lava or Undercity's slime. Swim mode deliberately does NOT use this one.
///
/// The **submerged atmosphere no longer routes through here either**: it is per-kind (magma and slime
/// have their own fixed `LightParams` rows), so it reads [`Underwater`] instead. This wrapper's docs
/// used to name "the teal murk" as its headline consumer, and that was exactly the assumption that
/// left lava and slime with no submerged view at all.
pub(crate) fn water_surface_at<'a>(
water: impl Iterator<Item = &'a WaterChunkInfo>,
wow: [f32; 3],
@@ -501,29 +515,92 @@ pub(crate) fn water_surface_at<'a>(
// swimming*, one number, and its one spelling is `player::swim_enter_depth(h)`. A human's line
// moved 2.0 → 1.52 yd, a murloc's far shallower.
/// Eye-submersion accept margin (VERIFIED `FUN_0069b6d0`: `eye.z < surface + 0.01`).
/// Eye-submersion accept margin for the **water** kinds (VERIFIED `FUN_0069b6d0`: `eye.z < surface +
/// 0.01`, the f32 `0x3c23d70a` at `0x8029d0`, strict `<`). The WMO magma/slime compare carries no
/// epsilon at all, so [`detect_submersion`] applies this per kind.
const SUBMERSION_EPS: f32 = 0.01;
/// Set [`Underwater`] from the camera vs the water surfaces: the eye is submerged if it's over a wet
/// Which submerged atmosphere a liquid kind selects. Water/ocean/rapids take the zone's own
/// underwater `LightParams` slot; magma and slime take fixed global rows instead, zone-independent
/// (see [`benilla_formats::Submersion`]).
fn submersion_of(kind: LiquidKind) -> benilla_formats::Submersion {
use benilla_formats::Submersion;
match kind {
LiquidKind::Still | LiquidKind::Rapids | LiquidKind::Ocean => Submersion::Water,
LiquidKind::Magma => Submersion::Magma,
LiquidKind::Slime => Submersion::Slime,
}
}
/// Set [`Underwater`] from the camera vs the liquid surfaces: the eye is submerged if it's over a wet
/// cell and below that cell's surface (`FUN_0069b6d0` — its 9×9 bilinear sample is now what
/// [`WaterChunkInfo::surface_z_at`] does, so this is the binary's own rule and no longer a per-chunk
/// flat approximation of it). One pass over the loaded water surfaces (a few hundred, cheap).
/// flat approximation of it). One pass over the loaded surfaces (a few hundred, cheap).
///
/// **Every** liquid counts, including magma and slime — they are not the water murk, they are their
/// own atmospheres (see [`Underwater`]). The eye's own **`+0.01` accept margin is water's alone**:
/// byte-VERIFIED (`0x8029d0` = `0x3c23d70a` = 0.01, strict `<`, at `0x69ba23`) that the WMO
/// magma/slime compare is a bare `z < h` with **no epsilon**, so the margin is applied per kind
/// rather than uniformly.
///
/// Where surfaces stack, the **deepest submerging** one wins: standing in the Great Forge's lava
/// under an unrelated water footprint should read as lava, and a `.any()` over an unordered query
/// would otherwise answer with whichever entity the ECS happened to yield first.
fn detect_submersion(
mut underwater: ResMut<Underwater>,
camera: Query<&Transform, With<WorldCamera>>,
water: Query<&WaterChunkInfo>,
time: Res<Time>,
mut last_dump: Local<Option<u32>>,
) {
let Ok(cam) = camera.single() else {
return;
};
let eye = bevy_to_wow(cam.translation); // [x, y, z] WoW yards
// WATER only: the murk this drives is the teal underwater fog/tint. Magma and slime now carry a
// footprint too (they became swimmable in 0634), and dunking the camera in the Great Forge would
// otherwise turn the lava teal.
underwater.0 = water.iter().filter(|w| !w.kind.is_fullbright()).any(|w| {
w.surface_z_at(eye[0], eye[1])
.is_some_and(|z| eye[2] < z + SUBMERSION_EPS)
});
underwater.0 = water
.iter()
.filter_map(|w| {
let z = w.surface_z_at(eye[0], eye[1])?;
let eps = if w.kind.is_fullbright() {
0.0
} else {
SUBMERSION_EPS
};
(eye[2] < z + eps).then_some((z, submersion_of(w.kind)))
})
// Lowest surface first: `total_cmp` on the surface z, so a tie is still deterministic.
.min_by(|a, b| a.0.total_cmp(&b.0))
.map(|(_, s)| s)
.unwrap_or_default();
// `WOW_FOG_DUMP` also explains *this* decision (`frame` drops the 1 Hz throttle, as there). The
// committed fog is whichever submerged atmosphere the verdict names, so a fog line alone cannot
// say whether an atmosphere that reads wrong is the wrong record or the right record never
// selected. Reports the eye, the verdict, and every candidate surface over the eye's XY.
if std::env::var_os("WOW_FOG_DUMP").is_some() {
let sec = time.elapsed_secs() as u32;
if last_dump.replace(sec) != Some(sec) {
let mut cands: Vec<String> = water
.iter()
.filter_map(|w| {
w.surface_z_at(eye[0], eye[1])
.map(|z| format!("{:?} z {z:.2}", w.kind))
})
.collect();
cands.sort();
eprintln!(
"[submerged] {:?} eye [{:.1} {:.1} {:.2}] over-xy {}",
underwater.0,
eye[0],
eye[1],
eye[2],
if cands.is_empty() {
"(no surface covers the eye's XY)".to_string()
} else {
cands.join(", ")
}
);
}
}
}
/// The shared liquid materials, one per [`LiquidKind`], plus each one's animated frame count (for
@@ -1095,8 +1172,9 @@ mod tests {
}
/// Lava and slime ARE swimmable (`liquid_at`) but must never drive the water-flavoured
/// consumers (`water_surface_at` → the teal murk, foam, the splash). B24/B25 vs the teal-lava
/// regression the old fullbright exclusion was guarding against — both, at once.
/// consumers (`water_surface_at` → the wade splash, footstep depth, the spline depth). B24/B25 vs
/// the teal-lava regression the old fullbright exclusion was guarding against — both, at once.
/// The submerged atmosphere is deliberately NOT in that list any more: it is per-kind.
#[test]
fn fullbright_kinds_swim_but_are_not_water() {
let lava = flat_info(LiquidSource::WmoGroup, LiquidKind::Magma, 6.0);
+7
View File
@@ -116,6 +116,13 @@ impl LoginIntent {
self.creds = None;
self.retry_at = None;
}
/// The account this session authenticated as, however it got there — the env fast path or the
/// login screen. The one honest answer to "whose body is this?", which is what decides whether
/// the probe shield has any business touching it (decision 0677).
pub(crate) fn account(&self) -> Option<&str> {
self.creds.as_ref().map(|(user, _)| user.as_str())
}
}
/// Send one login attempt to the parked IO thread, stamped with the current abandon generation.
+10
View File
@@ -18,6 +18,7 @@
//! the camera with Space up / C down, Ctrl boost).
mod area;
mod area_trigger;
mod assets;
mod billboard;
mod blob_shadow;
@@ -69,6 +70,7 @@ mod perf;
mod player;
mod portrait;
mod preflight;
mod probe_shield;
mod quest_markers;
mod raid_marks;
mod ribbons;
@@ -463,6 +465,10 @@ fn main() {
// into, and loud warnings for the states — dead/ghost, GM mode, server-blocked movement — that
// silently invalidate a session's readings. Never env-gated; a warning nobody switches on isn't one.
.add_plugins(preflight::PreflightPlugin)
// The probe shield (decision 0677): a body on a probe account is put into vmangos's `.cheat
// god` on every world entry — damage clamps at 1 hp instead of killing — and GM mode is turned
// OFF, because the shield replaces the only reason it was ever on. Inert on any other account.
.add_plugins(probe_shield::ProbeShieldPlugin)
// Audio: the delegated mixer + WoW's owned selection layer (decision 0070).
.add_plugins(SoundPlugin)
// Targeting: left-click a unit to select it (→ CMSG_SET_SELECTION) + draw its ground ring.
@@ -480,6 +486,10 @@ fn main() {
// The shared AreaTable catalog + the ZONE_CHANGED event family / zone-text host globals
// behind GetZoneText & co. (the zone-entry splash arc, decision 0287).
.add_plugins(area::AreaPlugin)
// The `AreaTrigger.dbc` volumes + the per-frame containment check that reports walking into
// one (`CMSG_AREATRIGGER`) — the client's whole part in portals, instance entrances and
// explore objectives; the server owns what each trigger means.
.add_plugins(area_trigger::AreaTriggerPlugin)
.add_plugins(ui_world_map::WorldMapUiPlugin)
// The glyph atlas (client TTFs -> baked bitmap) `ui_script`'s extraction draws `FontString`
// regions through. Loads at Startup, after the asset chain opens (decision 0068 §2).
+18
View File
@@ -82,6 +82,7 @@ impl Plugin for NetPlugin {
.add_message::<CharListMessage>()
.add_message::<CharActionResultMessage>()
.add_message::<EnteredWorldMessage>()
.add_message::<ServerSaidMessage>()
.add_message::<LoggedOutMessage>()
.add_message::<LoginStageMessage>()
.add_message::<LoginFailedMessage>()
@@ -754,6 +755,12 @@ pub(crate) enum ClientCommand {
/// it out by GO type — a chest answers with `SMSG_LOOT_RESPONSE` (the loot window), a questgiver
/// GO with the gossip/quest packets, a door with a `GAMEOBJECT_STATE` flip — or refuses silently.
GameObjUse { guid: u64 },
/// Report walking into an `AreaTrigger.dbc` volume (`CMSG_AREATRIGGER`): the trigger's id.
/// Sent by [`crate::area_trigger`]'s per-frame containment check — the client's whole part in
/// the system. The server answers a teleport trigger with the ordinary
/// `SMSG_TRANSFER_PENDING`/`SMSG_NEW_WORLD` pair (or a same-map `MSG_MOVE_TELEPORT_ACK`), a
/// refused one with `SMSG_AREA_TRIGGER_MESSAGE`, and most with nothing at all.
AreaTrigger { trigger_id: u32 },
/// Ask a GameObject's template (`CMSG_GAMEOBJECT_QUERY`, decision 0239): `entry` + the asking
/// `guid`. Sent ask-once when a GameObject streams in ([`crate::go_templates`]); the answer's
/// `lockId` decides whether a right-click uses it or casts an OPEN_LOCK spell.
@@ -1074,6 +1081,17 @@ pub(crate) struct CharActionResultMessage {
#[derive(Message)]
pub(crate) struct EnteredWorldMessage;
/// One `CHAT_MSG_SYSTEM` line — the server's own answer to a GM dot-command ("You set god mode to
/// on for …", "There is no such command", "Player not found!"). It has always been *logged*
/// (`net: server says — …`, decision 0651); this makes it *readable*, which is what lets a sender
/// know whether its command landed. That matters most for server state the descriptor does not
/// carry: vmangos's god mode is a runtime-only `Unit::m_invincibilityHpThreshold` with no field and
/// no flag on the wire, so this line is the only ground truth there is (decision 0677).
#[derive(Message, Clone)]
pub(crate) struct ServerSaidMessage {
pub(crate) text: String,
}
/// The server confirmed our logout (`SMSG_LOGOUT_COMPLETE`): back to character select. The Net
/// drain does the self-teardown (the part 0065's disconnect teardown deliberately keeps); this
/// message flips [`crate::char_select::ClientState`] and clears the pick policy's pending state.
+12
View File
@@ -84,6 +84,9 @@ pub(super) fn apply_net_updates(
MessageWriter<super::LoginStageMessage>,
MessageWriter<super::LoginFailedMessage>,
MessageWriter<super::DisconnectedMessage>,
// The server's own answer to a GM dot-command, readable rather than only logged — the
// probe shield's confirmation channel (decision 0677).
MessageWriter<super::ServerSaidMessage>,
),
// One tuple param (the 16-SystemParam ceiling): the ask-once query caches + the gossip/merchant
// state the net drain fills for the NPC-interaction windows (decision 0081).
@@ -272,6 +275,7 @@ pub(super) fn apply_net_updates(
mut login_stages,
mut login_failures,
mut disconnects,
mut server_said,
) = session_msgs;
// Descriptor seeds/deltas for objects created *earlier in this same drain* can't land on their
// entities yet (the spawn `Command` hasn't run), so they accumulate here and flush once at the end.
@@ -697,6 +701,11 @@ pub(super) fn apply_net_updates(
// Ordinary chat stays at `debug!`: conversation, not diagnosis, and high volume.
if m.chat_type == 0x0A {
info!("net: server says — {}", m.text);
// …and as a message, for the senders that must know whether their command
// landed. Server state with no descriptor field (god mode) has no other tell.
server_said.write(super::ServerSaidMessage {
text: m.text.clone(),
});
} else {
debug!("net: chat [{:#04x}] {}", m.chat_type, m.text);
}
@@ -743,6 +752,9 @@ pub(super) fn apply_net_updates(
}
SessionEvent::ChatWrongFaction => chat::chat_wrong_faction(&mut chat_log),
SessionEvent::Notification { text } => chat::notification(text, &mut chat_log),
SessionEvent::AreaTriggerMessage { text } => {
chat::area_trigger_message(text, &mut chat_log)
}
SessionEvent::PlayedTime { total, level } => {
chat::played_time(total, level, &mut chat_log)
}
+14
View File
@@ -3,6 +3,8 @@
//! refusals, `SMSG_NOTIFICATION`, `/played`). Each `pub(super)` fn here is exactly one arm's
//! body; the match at the call site stays the dispatcher, one call per arm.
use bevy::prelude::info;
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
/// The `/chatlist` roster (`SMSG_CHANNEL_LIST`) — CHAT_CHANNEL_LIST_GET "[%s] " + the roster.
@@ -41,6 +43,18 @@ pub(super) fn notification(text: String, chat_log: &mut ChatLog) {
chat_log.push_event(ChatEvent::text_only(ChatEventKind::System, text));
}
/// An area trigger's refusal (`SMSG_AREA_TRIGGER_MESSAGE`) — "You must be at least level 58 to
/// enter…", "You cannot enter … while in ghost form." The reference sends it to the **same** sink
/// as [`notification`] (its `0x2b8` arm and the system-message table's kinds 1/2 both end at
/// `0x4945b0`), so it goes wherever that one goes. Without it, a refused portal is silent, which
/// reads exactly like a portal that is still broken.
pub(super) fn area_trigger_message(text: String, chat_log: &mut ChatLog) {
// Logged for the same reason 0651 logs the server's dot-command answers: a trigger that
// refused and a trigger the client never noticed look identical from outside.
info!("net: area-trigger message — {text}");
chat_log.push_event(ChatEvent::text_only(ChatEventKind::System, text));
}
/// The `/played` answer (`SMSG_PLAYED_TIME`) — TIME_PLAYED_TOTAL/LEVEL over
/// TIME_DAYHOURMINUTESECOND (GlobalStrings:4243-4247; the ref's
/// ChatFrame_DisplayTimePlayed breakdown).
+1
View File
@@ -734,6 +734,7 @@ fn writer_loop(
}
ClientCommand::UnlearnSkill { skill_id } => w.unlearn_skill(skill_id),
ClientCommand::GameObjUse { guid } => w.gameobj_use(guid),
ClientCommand::AreaTrigger { trigger_id } => w.area_trigger(trigger_id),
ClientCommand::GameObjectQuery { entry, guid } => w.gameobject_query(entry, guid),
ClientCommand::CastSpellGameObject { spell_id, go_guid } => {
w.cast_spell_gameobject(spell_id, go_guid)
+122
View File
@@ -388,6 +388,14 @@ impl ParticleEmitter {
&self.def
}
/// The cloud's live world anchor — the census probe's fallback distance subject for an emitter
/// with no [`EmitterFade`] (entity-owned: creatures, GameObjects, spell kits). A faded
/// emitter's distance is measured to its OWNER's sphere instead, because that sphere is what
/// the draw-set gate actually tests.
pub(crate) fn anchor_world(&self) -> Vec3 {
self.anchor_pos
}
/// The cloud-orientation fingerprint — read by the particle census probe: the live cloud's
/// WORLD centroid, best-fit plane normal (unit), RMS thickness along it, and RMS in-plane
/// radius, from the same composition the quad expansion draws with. The numeric "which way
@@ -468,6 +476,36 @@ pub struct EmitterFade {
pub center: Vec3,
}
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]`):
/// 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.
///
/// 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.
///
/// Pure and total so the rule is pinned by tests without an ECS (the `model_fade` pattern).
pub fn in_draw_set(
&self,
cam_pos: Vec3,
cam_fwd: Vec3,
farclip: f32,
lateral_in_frustum: 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
}
}
#[allow(clippy::too_many_arguments)] // the spawn's full wiring: assets + owner/attach frames
pub fn spawn_emitter(
commands: &mut Commands,
@@ -741,6 +779,90 @@ pub(crate) mod tests {
def(ParticleShape::Plane)
}
/// Camera at the origin looking down −Z (Bevy's convention), and an owner sphere `depth` yd
/// straight ahead — the geometry every draw-set test below uses.
fn gate(radius: f32, depth: f32, farclip: f32) -> bool {
EmitterFade {
radius,
center: Vec3::new(0.0, 0.0, -depth),
}
.in_draw_set(Vec3::ZERO, Vec3::NEG_Z, farclip, true)
}
/// **The B39 defect, pinned** (decision 0678). An owner bigger than `NEVER_FADE_RADIUS` never
/// distance-fades, so the fade term admits it at every distance — the far-clip term is the only
/// thing that stops its emitter. Reverting that term makes this test fail, which is the point:
/// a big fire prop's flames used to draw a kilometre away, over terrain the same wall had
/// already discarded.
#[test]
fn a_never_fading_owner_is_still_bounded_by_the_wall() {
let big = crate::model_fade::NEVER_FADE_RADIUS + 5.0;
// The fade term alone says "draw" at any distance — that is what made this invisible.
assert_eq!(crate::model_fade::doodad_fade_alpha(big, 5000.0), 1.0);
assert!(gate(big, 500.0, 777.0), "inside the wall: draws");
assert!(!gate(big, 1000.0, 777.0), "past the wall: must NOT draw");
// ...and it tracks the live farclip, not a constant: the same emitter at the vanilla
// minimum view distance is out, at the panel's maximum it is back in.
assert!(!gate(big, 500.0, 177.0));
assert!(gate(big, 1000.0, 1200.0));
}
/// The wall never *replaces* the fade cutoff — a small prop still pops at its own band end
/// (40→50 yd) far inside the wall. Both terms are live; neither subsumes the other.
#[test]
fn the_wall_does_not_replace_the_size_fade() {
assert!(gate(0.3, 45.0, 777.0), "mid-band: still drawing");
assert!(
!gate(0.3, 60.0, 777.0),
"past the 50-yd band end, nowhere near the wall"
);
}
/// The lateral frustum term is ANDed, not implied: an owner dead ahead and well inside the
/// wall is still out when the caller's frustum test rejects it (off-screen).
#[test]
fn the_lateral_frustum_term_is_anded() {
let f = EmitterFade {
radius: 2.0,
center: Vec3::new(0.0, 0.0, -60.0),
};
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));
}
/// The emitter gate and the owner mesh's own cull must agree on the boundary — they read the
/// same `within_farclip` now, and this pins that they cannot drift apart again. An emitter
/// admitted where its owner's mesh is culled IS bug B39.
///
/// Scoped to owners **above** `NEVER_FADE_RADIUS` on purpose: there the size-fade term is a
/// constant `1.0`, so the gate reduces to the wall alone and the comparison is exact. Below it
/// the two rules legitimately differ (the mesh keeps drawing while the fade feathers it out),
/// so an equality there would be asserting something false — and these are the owners that
/// carry the big effects the report is about anyway.
#[test]
fn the_emitter_gate_agrees_with_the_owner_meshs_cull() {
let (cam, fwd) = (Vec3::ZERO, Vec3::NEG_Z);
for farclip in [177.0f32, 777.0, 1200.0] {
for depth in [100.0f32, 700.0, 776.0, 777.0, 778.0, 900.0, 2000.0] {
for radius in [crate::model_fade::NEVER_FADE_RADIUS + 0.01, 12.0, 40.0] {
let center = Vec3::new(0.0, 0.0, -depth);
assert_eq!(
crate::model_fade::doodad_fade_alpha(radius, depth),
1.0,
"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);
assert_eq!(
mesh_drawn, emitter_drawn,
"wall disagreement at farclip {farclip} depth {depth} radius {radius}"
);
}
}
}
}
/// A minimal def for kernel tests — only the fields [`emit_local`] reads matter.
fn def(shape: ParticleShape) -> ParticleEmitterDef {
ParticleEmitterDef {
+85 -13
View File
@@ -165,6 +165,8 @@ fn drive_child(
pub(super) fn simulate_particles(
time: Res<Time>,
tuning: Res<ParticleTuning>,
// The far-clip wall — the emitter draw-set gate's depth bound (see the gate below).
view: Res<crate::view::ViewDistance>,
mut commands: Commands,
cam: Query<(&GlobalTransform, &Frustum), With<WorldCamera>>,
// Owner reads (joints/units/roots — never emitter or child-draw entities): disjoint from
@@ -228,6 +230,9 @@ pub(super) fn simulate_particles(
let cam_right = cam_rot * Vec3::X;
let cam_up = cam_rot * Vec3::Y;
let face_normal = cam_up.cross(cam_right).normalize_or_zero(); // toward the camera
// 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());
for (entity, mut emitter, mut entity_tf, mut entity_global, fade, mut vis, layers) in
&mut emitters
@@ -236,10 +241,11 @@ pub(super) fn simulate_particles(
// emitter uses its booth's camera (decision 0539 §5 — the glue scenes' braziers, which the
// WORLD camera would billboard sideways and LOD from a nonsense distance); everything else
// the world camera.
let (e_cam_pos, e_right, e_up, e_normal) = match layers
let booth = layers
.filter(|l| !l.intersects(&RenderLayers::default()))
.and_then(|l| booth_cams.iter().find(|(_, cl)| cl.intersects(l)))
{
.and_then(|l| booth_cams.iter().find(|(_, cl)| cl.intersects(l)));
let is_booth = booth.is_some();
let (e_cam_pos, e_right, e_up, e_normal) = match booth {
Some((tf, _)) => {
let (_, rot, _) = tf.to_scale_rotation_translation();
let right = rot * Vec3::X;
@@ -254,24 +260,39 @@ pub(super) fn simulate_particles(
None => (cam_pos, cam_right, cam_up, face_normal),
};
// Draw-set gate (byte-verified, decision 0171): the reference ticks an emitter only when
// its owner doodad is in the frame's scene worklist — admission = the 6-plane frustum
// sphere test + the radius-tiered distance-fade cutoff (`FUN_00683f80`; alpha ≤ 0 is never
// inserted). A culled owner's emitter neither simulates nor draws: pool + age FROZEN
// its owner doodad is in the frame's scene worklist — admission = the frustum sphere test
// + the radius-tiered distance-fade cutoff (`FUN_00683f80`; alpha ≤ 0 is never inserted).
// A culled owner's emitter neither simulates nor draws: pool + age FROZEN
// (`[obj+0x88]` accumulates nothing while absent), and on re-entry it resumes from frozen
// state with one frame's dt — no catch-up, no refill. Both tests use the owner's fade
// state with one frame's dt — no catch-up, no refill. All three tests use the owner's fade
// sphere, matching the reference's `[rec+0x68]`.
//
// The **far-clip** term is the third test and it is load-bearing (decision 0678, bug B39):
// the reference's frustum is bounded by its projection far plane at `farclip`, ours is not
// — our projection far is ~3000 yd so the WDL horizon can draw behind the wall, and
// `intersects_sphere`'s far-plane term would therefore bound at 3000, not 777. Worse, the
// fade cutoff alone bounds NOTHING for a big owner: `doodad_fade_alpha` returns a flat 1.0
// above `NEVER_FADE_RADIUS` (7 yd) — trees, buildings, the large fire props. So every
// big-doodad emitter used to simulate and draw at ANY distance out to the streaming
// residency, long past the wall its own owner's mesh had already been culled by. That is
// "all effects render at unlimited distance", and it is why the terrain under them was
// already gone. `within_farclip` is the same rule the owner mesh uses in
// `debug_panel::visibility` — shared, so the two can no longer drift apart.
if let Some(f) = fade {
let dx = f.center.x - cam_pos.x;
let dz = f.center.z - cam_pos.z;
let horiz = (dx * dx + dz * dz).sqrt();
let in_set = crate::model_fade::doodad_fade_alpha(f.radius, horiz) > 0.0
&& frustum.intersects_sphere(
let in_set = f.in_draw_set(
cam_pos,
cam_fwd,
view.farclip,
// Lateral planes only (`intersect_far = false`) — the depth bound is the farclip
// term inside `in_draw_set`, deliberately; see `view::within_farclip`.
frustum.intersects_sphere(
&CullSphere {
center: f.center.into(),
radius: f.radius,
},
false,
);
),
);
if !in_set {
if *vis != Visibility::Hidden {
*vis = Visibility::Hidden;
@@ -295,6 +316,57 @@ pub(super) fn simulate_particles(
if *vis != Visibility::Inherited {
*vis = Visibility::Inherited;
}
} else if !is_booth && !emitter.draining {
// ENTITY-owned emitters (creatures, GameObjects, spell kits, WMO-prop deck lanterns)
// carry no `EmitterFade`, and the premise that excused them — "the population is
// bounded by server visibility instead" (`entities::wmo_props`) — is FALSE for
// transports, which vmangos streams map-wide. Measured (decision 0678): parked in
// Durotar, **56 emitters** were ticking and drawing at 4853–7080 yd — the deck
// lanterns of the Teldrassil↔Auberdine boat (`transports` 176244 "Moonspray") and its
// Menethil↔Auberdine neighbour, five to seven kilometres away. So the wall is applied
// to EVERY world-lane emitter, not just the faded ones. That is also the faithful
// shape: the reference ticks particles inside the owning model's animate step, which
// runs only for models the frame actually draws — and past `farclip` nothing is drawn.
//
// Three deliberate exclusions, each of which would be a bug the other way:
// - **booth-layered** emitters (glue/portrait scenes) — parked thousands of yards away
// and drawn by their OWN camera, so the world wall says nothing about them;
// `glue_booth` states the contract outright ("a glue scene always ticks").
// - **draining** emitters — freezing one strands it: it can never empty its pool, so it
// never reaches the self-despawn below and leaks for the session.
// - an emitter whose **owner has vanished** — `subject` is `None`, so it falls through
// to the drain path below rather than freezing before that can be noticed.
//
// The subject is read LIVE from the owner's transform, never from the stored anchor: a
// frozen emitter stops refreshing its anchor, so gating on the stored one would latch a
// moving owner out of existence the first time it crossed the wall.
let subject = match emitter.owner {
Some(o) => transforms.get(o).ok().map(|gt| gt.translation()),
None => Some(emitter.anchor_pos),
};
if let Some(at) = subject {
if !crate::view::within_farclip(view.farclip, cam_pos, cam_fwd, at, 0.0) {
if *vis != Visibility::Hidden {
*vis = Visibility::Hidden;
for c in &emitter.children {
if let Ok((_, _, mut cv)) = child_draws.get_mut(c.entity) {
*cv = Visibility::Hidden;
}
}
for slot in &emitter.model_instances {
for (e, _) in &slot.meshes {
if let Ok((_, _, mut cv)) = child_draws.get_mut(*e) {
*cv = Visibility::Hidden;
}
}
}
}
continue;
}
if *vis != Visibility::Inherited {
*vis = Visibility::Inherited;
}
}
}
let ParticleEmitter {
def,
+152 -5
View File
@@ -4,6 +4,7 @@
//! person. Split out of the controller — this owns the camera's pose and input session, not the
//! avatar/movement/networking [`super::control`] drives with it.
use bevy::ecs::entity::EntityHashSet;
use bevy::input::mouse::{AccumulatedMouseMotion, AccumulatedMouseScroll, MouseScrollUnit};
use bevy::mesh::MeshTag;
use bevy::prelude::*;
@@ -482,6 +483,19 @@ pub(super) fn seat_camera(
/// entity (itself a descendant of the root, at varying depth) — an earlier direct-`Children`-only version
/// silently skipped every attach model. The self-player entity is singular, so a per-frame tree walk over
/// its handful of joints + submeshes is nil cost.
///
/// **The tree is not the whole model.** An M2's BILLBOARD batches can't be tree children — their mesh is
/// centred on the bone pivot and their transform belongs to the billboard system, so every one of them is
/// a world ROOT entity that merely *follows* an anchor inside the tree (decision 0153). The descendant
/// walk therefore cannot see them, and the night-elf eye glow — two additive `…EYEGLOW.BLP` billboard
/// quads at head height — went on burning in mid-air after the body it belongs to had gone (reported
/// first-hand; ledger B71). Cards are picked up here by testing their follow-anchor against the walked
/// set, and folded into the same α — the idiom [`crate::blob_shadow`] already uses for the other
/// world-root follower of the self avatar ("the self first-person fade rides the same model-fade slot in
/// the reference"). One multiply covers both halves: it feathers with the body, and at α 0 the additive
/// compose (`wow_model.wgsl`: `out_rgb *= faded_alpha`) takes the card to black, which for an ADD blend
/// is gone. That deliberately avoids `Visibility`, which the card's own hidden-owner mirror authors every
/// frame in a different system.
#[allow(clippy::type_complexity)]
pub(crate) fn apply_self_model_fade(
rig: Res<CameraControl>,
@@ -495,8 +509,19 @@ pub(crate) fn apply_self_model_fade(
&mut Visibility,
Option<&mut crate::interior::InteriorLit>,
),
(Without<RenderFade>, Without<PendingAppearFade>),
(
Without<RenderFade>,
Without<PendingAppearFade>,
// Disjointness for the card query below (both want `&mut MeshTag`); a card never
// carries `FadeMaterials`, so this excludes nothing.
Without<crate::billboard::BillboardCard>,
),
>,
mut cards: Query<(
&crate::billboard::BillboardCard,
&mut MeshTag,
Option<&crate::doodad_anim::MatAnim>,
)>,
mut was_fading: Local<bool>,
) {
let fading = rig.self_fade_alpha < 1.0;
@@ -508,14 +533,42 @@ pub(crate) fn apply_self_model_fade(
*was_fading = false;
return;
};
apply_self_fade_to_descendants(root, rig.self_fade_alpha, &children_of, &mut parts);
// The walked set doubles as "which anchors belong to this model" for the card pass — built only
// while fading, so the steady state (the early return above) never pays for it.
let mut walked = EntityHashSet::default();
apply_self_fade_to_descendants(
root,
rig.self_fade_alpha,
&children_of,
&mut parts,
&mut walked,
);
let alpha = rig.self_fade_alpha.clamp(0.0, 1.0);
for (card, mut tag, anim) in &mut cards {
if !card
.follows()
.is_some_and(|anchor| walked.contains(&anchor))
{
continue;
}
// The card's steady author is `entities::apply_unit_mat_alpha`, ordered before this system,
// which writes the batch's per-sequence factor every frame — so composing from `current`
// (not from the tag we'd read back) keeps that animation alive under the fade, and the
// release frame's `α = 1` write lands exactly on the value it would have had.
let authored = anim.map_or(1.0, |a| a.current);
let bits = crate::mesh_tag::with_alpha(tag.0, authored * alpha);
if tag.0 != bits {
tag.0 = bits;
}
}
*was_fading = fading;
}
/// Depth-first helper for [`apply_self_model_fade`]: apply the fade (or, at `α ≥ 1`, the release) to
/// `entity` if it's a fadeable part, then recurse into its children regardless (a joint or an attach-model
/// root carries no `FadeMaterials` itself but must still be descended through to reach the mesh leaves
/// under it).
/// under it). Every entity visited — parts, joints, attach roots, billboard anchors alike — is recorded
/// in `walked`, which the caller uses to recognise the world-root billboard cards that follow this model.
#[allow(clippy::type_complexity)]
fn apply_self_fade_to_descendants(
entity: Entity,
@@ -529,9 +582,15 @@ fn apply_self_fade_to_descendants(
&mut Visibility,
Option<&mut crate::interior::InteriorLit>,
),
(Without<RenderFade>, Without<PendingAppearFade>),
(
Without<RenderFade>,
Without<PendingAppearFade>,
Without<crate::billboard::BillboardCard>,
),
>,
walked: &mut EntityHashSet,
) {
walked.insert(entity);
if let Ok((fm, mut tag, mut mat, mut vis, lit)) = parts.get_mut(entity) {
if alpha >= 1.0 {
// The release edge (runs once, on the frame the fade ends — decision 0213): un-hide,
@@ -584,7 +643,7 @@ fn apply_self_fade_to_descendants(
}
if let Ok(children) = children_of.get(entity) {
for &child in children {
apply_self_fade_to_descendants(child, alpha, children_of, parts);
apply_self_fade_to_descendants(child, alpha, children_of, parts, walked);
}
}
}
@@ -652,3 +711,91 @@ pub(super) fn fly_free(
cam_t.translation += dir.normalize() * cam.speed * boost * dt;
}
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_assets::BillboardInfo;
use benilla_formats::BillboardKind;
use crate::billboard::BillboardCard;
use crate::mesh_tag::alpha_bits;
/// The self-avatar's zoom-to-first-person fade reaches its BILLBOARD cards — the night-elf eye
/// glow (ledger B71: two additive quads left burning in mid-air after the body was hidden).
/// A card is a world ROOT following an anchor inside the model (decision 0153), so the fade's
/// descendant walk can only claim it through that anchor — and must claim ONLY its own: every
/// brazier and lamppost in the zone is a card too, and dimming those with the player's zoom
/// would be a far worse bug than the one being fixed.
#[test]
fn self_fade_reaches_the_avatars_billboard_cards_and_no_others() {
let info = BillboardInfo {
bone: 0,
pivot: Vec3::new(0.0, 2.14, 0.0), // the eye-glow bone, head height
normal: Vec3::Z,
kind: BillboardKind::Spherical,
scale_anim: None,
seq_translations: vec![],
};
let mut app = App::new();
app.init_resource::<CameraControl>();
app.add_systems(Update, apply_self_model_fade);
// The avatar: root -> joint (the eye-glow bone). Its card follows the joint.
let avatar = app.world_mut().spawn(SelfPlayer).id();
let joint = app.world_mut().spawn(Transform::default()).id();
app.world_mut().entity_mut(avatar).add_child(joint);
let eye_glow = app
.world_mut()
.spawn((
BillboardCard::following_joint(&info, joint),
MeshTag(alpha_bits(1.0)),
))
.id();
// A brazier across the square: same mechanism, another model entirely.
let brazier_anchor = app.world_mut().spawn(Transform::default()).id();
let brazier = app
.world_mut()
.spawn((
BillboardCard::following(&info, brazier_anchor),
MeshTag(alpha_bits(1.0)),
))
.id();
let tag_of = |app: &App, e: Entity| app.world().entity(e).get::<MeshTag>().unwrap().0;
// Mid-feather: the glow rides the body's alpha down.
app.world_mut()
.resource_mut::<CameraControl>()
.self_fade_alpha = 0.5;
app.update();
assert_eq!(
tag_of(&app, eye_glow),
alpha_bits(0.5),
"the avatar's card feathers with the body"
);
assert_eq!(
tag_of(&app, brazier),
alpha_bits(1.0),
"another model's card is untouched by the player's zoom"
);
// First person: the additive compose (`out_rgb *= faded_alpha`) takes it to black.
app.world_mut()
.resource_mut::<CameraControl>()
.self_fade_alpha = 0.0;
app.update();
assert_eq!(tag_of(&app, eye_glow), alpha_bits(0.0));
// Back out to third person — the release frame hands the authored value back.
app.world_mut()
.resource_mut::<CameraControl>()
.self_fade_alpha = 1.0;
app.update();
assert_eq!(
tag_of(&app, eye_glow),
alpha_bits(1.0),
"the release edge restores the card, like the body parts"
);
}
}
+108 -22
View File
@@ -8,13 +8,15 @@
//! interacted with, attacked, looted or talked to; the world renders through the death filter;
//! movement is rooted (unreleased) or ghost-speed (released). Every symptom reads like a client
//! bug, and the client is fine.
//! 2. **GM mode is on** — which every probe character carries by default (method.md's guard against
//! unattended aggro killing it). vmangos's `Player::SetGameMaster` re-templates the player to
//! **faction template 35** and freezes the mirror timers, so nothing is hostile, nothing aggros,
//! fall/environmental damage is skipped and breath/fatigue never tick. FactionTemplate.dbc row 35
//! (VERIFIED from the extracted DBC): faction 31 "Friendly", own group mask 0, enemy mask 0 — it
//! is hostile to nothing and nothing is hostile to it. Any hostility, reaction-colour, nameplate,
//! aggro, threat, damage or drowning measurement taken with GM on is simply wrong, quietly.
//! 2. **GM mode is on** — which every probe character used to carry by default, as the only guard
//! against unattended aggro killing it. vmangos's `Player::SetGameMaster` re-templates the player
//! to **faction template 35** and freezes the mirror timers, so nothing is hostile, nothing
//! aggros, fall/environmental damage is skipped and breath/fatigue never tick.
//! FactionTemplate.dbc row 35 (VERIFIED from the extracted DBC): faction 31 "Friendly", own group
//! mask 0, enemy mask 0 — it is hostile to nothing and nothing is hostile to it. Any hostility,
//! reaction-colour, nameplate, aggro, threat, damage or drowning measurement taken with GM on is
//! simply wrong, quietly. It is now **off** by default: [`crate::probe_shield`] keeps the body
//! alive without poisoning any of that (decision 0677), and this banner reports which it is.
//! 3. **Movement is server-blocked** — rooted, stunned, confused, fleeing, or mid-taxi-flight. The
//! controller ignores input and the honest report is "the mover is broken".
//!
@@ -34,6 +36,7 @@ use bevy::prelude::*;
use crate::area::AreaTableRes;
use crate::names::NameCache;
use crate::net::{EnteredWorldMessage, ObjectStore, SelfGuid, SelfPlayer};
use crate::probe_shield::{ProbeShield, ShieldReport};
use crate::terrain_stream::CurrentArea;
use crate::world_map::CurrentMap;
@@ -107,6 +110,7 @@ fn report_session(
map: Option<Res<CurrentMap>>,
area: Option<Res<CurrentArea>>,
area_table: Option<Res<AreaTableRes>>,
shield: Res<ProbeShield>,
) {
if entered.read().next().is_some() {
state.armed_at = Some(time.elapsed_secs());
@@ -166,22 +170,34 @@ fn report_session(
// point of putting the position in the banner is that it can be pasted back.
let [wx, wy, wz] = benilla_assets::coords::bevy_to_wow(transform.translation);
info!(
"preflight: {name} — level {lvl} {race} {class}, {hp}/{maxhp} hp, map {map}{zone} @ [{wx:.1}, {wy:.1}, {wz:.1}], faction template {faction}",
"preflight: {name} — level {lvl} {race} {class}, {hp}/{maxhp} hp, map {map}{zone} @ [{wx:.1}, {wy:.1}, {wz:.1}], faction template {faction}{shielded}",
lvl = store.0.unit_level().unwrap_or(0),
hp = store.0.unit_health().unwrap_or(0),
maxhp = store.0.unit_max_health().unwrap_or(0),
map = map.map_or(-1, |m| m.0 as i64),
faction = store.0.unit_faction_template().unwrap_or(0),
// A shielded body is good news, so it rides the banner rather than the warning ladder —
// but it is still *stated*, because "can this thing die?" is the first question an
// unattended run needs answered (decision 0677).
shielded = match shield.report() {
ShieldReport::Armed => ", SHIELDED (cannot die)",
ShieldReport::Arming => ", shield arming",
_ => "",
},
);
for line in findings(&store.0) {
for line in findings(&store.0, shield.report()) {
warn!("preflight: {line}");
}
}
/// Everything about this avatar that will quietly invalidate a session's work, worst first. Pure
/// over the descriptor so the whole ladder is unit-testable.
fn findings(fields: &benilla_protocol::messages::ObjectFields) -> Vec<String> {
/// over the descriptor (plus the one piece of state that rides no descriptor field — the probe
/// shield, decision 0677) so the whole ladder is unit-testable.
fn findings(
fields: &benilla_protocol::messages::ObjectFields,
shield: ShieldReport,
) -> Vec<String> {
let mut out = Vec::new();
let unit_flags = fields.unit_flags();
@@ -203,14 +219,40 @@ fn findings(fields: &benilla_protocol::messages::ObjectFields) -> Vec<String> {
);
}
// The shield's bad states are findings; being armed is good news and rides the banner line.
match shield {
ShieldReport::Disabled => out.push(
"THE PROBE SHIELD IS OFF (WOW_GOD=off) — this character CAN die, so an unattended run \
can leave a corpse for the next session. Deliberate for a death-arc test; unset \
WOW_GOD for anything else (decision 0677)."
.into(),
),
ShieldReport::Unconfirmed => out.push(
"THE PROBE SHIELD DID NOT CONFIRM — `.cheat god on` went out and the server never \
answered. Treat this character as mortal and do not leave it parked anywhere hostile."
.into(),
),
ShieldReport::NotOurs | ShieldReport::Arming | ShieldReport::Armed => {}
}
if fields.player_flags() & PLAYER_FLAGS_GM != 0 {
out.push(format!(
"GM MODE IS ON — vmangos re-templates the player to faction {GM_FACTION_TEMPLATE} \
(\"Friendly\", enemy mask 0) and freezes the mirror timers, so NOTHING is hostile, \
nothing aggros, fall/environmental damage is skipped and breath/fatigue never tick. \
Any hostility, reaction-colour, nameplate, threat, aggro, damage or drowning reading \
taken now is wrong. Send WOW_PROBE_CHAT=\".gm off\" first — and put it back on when \
you are done, so an unwatched probe can't be killed."
taken now is wrong. {}",
match shield {
// GM mode is the DEFAULT (0679) — it is what stops a parked body being mobbed, and
// the shield is what makes dropping it safe. So this warning is expected on most
// runs, and its job is to make sure nobody measures hostility through faction 35.
ShieldReport::Arming | ShieldReport::Armed =>
"This is the default. Re-run with WOW_GM=off for those readings — safe, \
because the probe shield (decision 0677) keeps the body alive without it.",
_ =>
"Re-run with WOW_GM=off for those readings. Note the probe shield is NOT up on \
this run, so an unshielded body with GM mode off can be killed.",
}
));
}
@@ -341,21 +383,20 @@ mod tests {
#[test]
fn a_healthy_avatar_reports_nothing() {
let f = player(&[(HEALTH, 60), (MAXHEALTH, 60)]);
assert!(findings(&f).is_empty());
assert!(findings(&f, ShieldReport::Armed).is_empty());
}
#[test]
fn dead_and_ghost_never_report_together() {
// Dead: health 0 with a real MAXHEALTH.
let dead = findings(&player(&[(MAXHEALTH, 60)]));
let dead = findings(&player(&[(MAXHEALTH, 60)]), ShieldReport::Armed);
assert_eq!(dead.len(), 1);
assert!(dead[0].contains("IS DEAD"));
// Ghost: health 1 and PLAYER_FLAGS_GHOST — the wire never shows both (decision 0308 §1).
let ghost = findings(&player(&[
(HEALTH, 1),
(MAXHEALTH, 60),
(PLAYER_FLAGS, 0x10),
]));
let ghost = findings(
&player(&[(HEALTH, 1), (MAXHEALTH, 60), (PLAYER_FLAGS, 0x10)]),
ShieldReport::Armed,
);
assert_eq!(ghost.len(), 1);
assert!(ghost[0].contains("IS A GHOST"));
}
@@ -367,11 +408,56 @@ mod tests {
(MAXHEALTH, 60),
(PLAYER_FLAGS, PLAYER_FLAGS_GM),
]);
let out = findings(&f);
let out = findings(&f, ShieldReport::Armed);
assert_eq!(out.len(), 1);
assert!(out[0].contains("GM MODE IS ON"));
}
#[test]
fn a_shielded_body_says_nothing_but_an_unshielded_one_does() {
let healthy = player(&[(HEALTH, 60), (MAXHEALTH, 60)]);
// Armed is good news: it rides the banner line, never the warning ladder. Neither does a
// body the shield has no opinion about (the director's own account).
for quiet in [
ShieldReport::Armed,
ShieldReport::Arming,
ShieldReport::NotOurs,
] {
assert!(findings(&healthy, quiet).is_empty(), "{quiet:?}");
}
// Both failure shapes are findings, because both mean "this body can die".
let off = findings(&healthy, ShieldReport::Disabled);
assert_eq!(off.len(), 1);
assert!(off[0].contains("SHIELD IS OFF") && off[0].contains("WOW_GOD"));
let unconfirmed = findings(&healthy, ShieldReport::Unconfirmed);
assert_eq!(unconfirmed.len(), 1);
assert!(unconfirmed[0].contains("DID NOT CONFIRM"));
}
#[test]
fn the_gm_warning_always_names_the_way_out() {
// GM mode is the default (0679), so this warning fires on nearly every probe run. Its whole
// job is that nobody measures hostility through faction 35 without being told how to stop —
// and the old advice ("put it back on when you are done") must not come back, because a
// session that complies re-poisons every faction reading (0657).
let gm = player(&[
(HEALTH, 60),
(MAXHEALTH, 60),
(PLAYER_FLAGS, PLAYER_FLAGS_GM),
]);
for report in [
ShieldReport::Armed,
ShieldReport::Arming,
ShieldReport::Disabled,
ShieldReport::NotOurs,
] {
let out = findings(&gm, report);
let line = out.iter().find(|l| l.contains("GM MODE IS ON")).unwrap();
assert!(line.contains("WOW_GM=off"), "{report:?}: {line}");
assert!(!line.contains("put it back on"), "{report:?}: {line}");
}
}
#[test]
fn every_move_blocker_is_named_in_one_line() {
let f = player(&[
@@ -379,7 +465,7 @@ mod tests {
(MAXHEALTH, 60),
(UNIT_FLAGS, 0x0004_0000 | 0x0010_0000), // stunned + taxi
]);
let out = findings(&f);
let out = findings(&f, ShieldReport::Armed);
assert_eq!(out.len(), 1);
assert!(out[0].contains("STUNNED") && out[0].contains("TAXI FLIGHT"));
}
+453
View File
@@ -0,0 +1,453 @@
//! The probe shield (decision 0677) — **a probe body cannot die, and does not need GM mode to stay
//! alive.**
//!
//! ## The failure it removes
//!
//! Probe characters kept dying, and the reason was structural: the only protection we had was **GM
//! mode**, and GM mode is exactly what a session has to switch off to measure anything real.
//! `Player::SetGameMaster(true)` re-templates the player to faction 35 ("Friendly", enemy mask 0)
//! and freezes the mirror timers, so with it on nothing is hostile, nothing aggros, environmental
//! damage is skipped and breath/fatigue never tick (0649, 0657). Every session doing hostility,
//! reaction-colour, nameplate, threat, damage or drowning work therefore sends `.gm off` — and the
//! moment it does, the body is a level-1 standing wherever the last session parked it. Measured on
//! this project's own transcripts: `.gm off` **257×**, `.revive` **121×** (0651). Measured live
//! while writing this: `.gm off` on a probe parked in Valley of Spears killed it in **~2 seconds**,
//! inside the gap between two probe-chat lines.
//!
//! Telling sessions to "put GM mode back when you are done" was the old advice and it cannot work:
//! it asks every session to remember a manual step at the exact moment it is thinking about
//! something else, and it trades one silent wrongness (a dead probe) for another (faction 35).
//!
//! ## What it does instead
//!
//! vmangos has a second, *invisible* protection: **`.cheat god`**
//! (`Player::SetCheatGod` → `SetInvincibilityHpThreshold(1)`). `Unit::DealDamage` then clamps every
//! hit at 1 hp instead of calling `Kill` — and `Player::EnvironmentalDamage` (fall, lava, slime,
//! drowning, fatigue) routes through the same `DealDamage`, so it is covered too. Unlike GM mode it
//! touches **nothing** a client can see: no `UNIT_FIELD_FLAGS` bit, no `PLAYER_FLAGS` bit, no
//! faction re-template, no frozen timers. Damage still lands, combat still happens, hostility is
//! still faithful — the body simply cannot reach 0.
//!
//! **The shield is therefore always on** — and, since it does not stop *aggro*, GM mode **stays on
//! by default too** (decision 0679). The two do different jobs: GM mode stops the town attacking a
//! parked body, the shield stops anything killing it. Without the shield, `.gm off` was a two-second
//! death sentence; with it, `.gm off` (`WOW_GM=off`) is a free, safe switch a session flips whenever
//! it needs faithful factions. That — making the drop safe, not making it the default — is the whole
//! win. A shielded body with GM off in a hostile camp survives, but survives *pinned*: 1 hp, stunned,
//! permanently in combat, never regenerating, which is its own kind of useless.
//!
//! ## The three things about it that are not obvious
//!
//! 1. **It is not persisted**, and that leaves a window nothing here can close.
//! `m_invincibilityHpThreshold` is a runtime `Unit` member, zeroed in the constructor and never
//! saved, so it must be re-sent on **every world entry** — which is why this is a client module
//! and not a one-off DB fix. But a command cannot be sent before we are *in* the world, so a body
//! parked somewhere hostile is unshielded for the few hundred ms it takes to get the first line
//! out. MEASURED: a probe left at 1 hp in a centaur camp entered the world at `29.41`, had the
//! shield confirmed at `29.75` (+337 ms) and was already **dead** when the banner printed at
//! `29.97`. This is the "it was alive at 1 hp when I closed the window, it was dead next login"
//! report, and it is why **GM mode stays on by default** (0679): the fix is to never let the body
//! be whittled down and mobbed in the first place, not to arm faster.
//! 2. **It is target-sensitive.** `.cheat god on` re-targets to the current selection
//! (`ChatHandler::GetSelectedPlayer` → `sObjectMgr.GetPlayer(guid)`), which is `nullptr` for a
//! creature — the server answers *"Player not found!"* and the body is left mortal. VERIFIED
//! live: with a centaur Tab-selected the bare form was refused, and the same command with the
//! name appended succeeded before and after. So we **always name the character**.
//! 3. **`.die` clears it silently.** `HandleDieHelper` calls `SetCheatGod(false)` with
//! `notify = false` before killing, so deliberate death-testing still works out of the box —
//! but the shield is down afterwards and nothing says so. Hence the death watch below.
//!
//! ## What it never touches
//!
//! Only a body on a **probe account** (`probe<N>`). The director's own account is never given a
//! command it did not ask for, whether they typed the login or an env var did.
//!
//! ## Knobs
//!
//! - `WOW_GM=off` — drop GM mode, for anything about hostility, reaction colour, nameplates,
//! threat, aggro, damage or breath/fatigue. Safe now, which is the point. `WOW_RIG="… gm:off"` is
//! honoured the same way, so the rig's explicit token wins over the default.
//! - `WOW_GOD=off` — run unshielded (a death-arc test that wants a real corpse). Reported as a
//! `WARN` finding on the preflight banner, because the body can now die.
use bevy::prelude::*;
use crate::login::LoginIntent;
use crate::names::NameCache;
use crate::net::{EnteredWorldMessage, ObjectStore, SelfGuid, SelfPlayer, ServerSaidMessage};
/// `PLAYER_FLAGS_GM` — the bit that says the body is carrying the faction-35 re-template.
const PLAYER_FLAGS_GM: u32 = 0x0000_0008;
/// Spacing between the shield's lines. Two server-side flips inside one net drain merge to a no-op
/// (0441's lesson, which the rig also spaces for), and `.gm off` must land *after* the shield is up
/// — never before, or the body is naked for exactly as long as the gap.
const STEP_SECS: f32 = 0.8;
/// How long we wait for our own name to resolve before arming anyway. The name makes the command
/// selection-proof (§2 above), so it is worth a short wait; going without it is still better than
/// leaving the body mortal.
const NAME_WAIT_SECS: f32 = 3.0;
/// How long the server gets to confirm before we stop claiming the body is shielded. The live
/// round trip measured ~100–150 ms; this is a wide margin whose only job is to stop a silent
/// refusal from reading as success (0651's lesson).
const CONFIRM_SECS: f32 = 5.0;
pub(crate) struct ProbeShieldPlugin;
impl Plugin for ProbeShieldPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ProbeShield>()
.add_systems(Update, drive_shield.after(crate::schedule::WorldStage::Net));
}
}
/// What the preflight banner says about the shield. Copy and tiny on purpose: the banner is the
/// reader's one orienting line and it must not have to reason about the state machine.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub(crate) enum ShieldReport {
/// Not a probe account — the shield has no opinion about this body and never sends anything.
#[default]
NotOurs,
/// `WOW_GOD=off`: deliberately unshielded. This body CAN die.
Disabled,
/// The command is out, the server has not answered yet.
Arming,
/// The server confirmed it. This body cannot die.
Armed,
/// The server did not confirm inside [`CONFIRM_SECS`], or refused. Treat the body as mortal.
Unconfirmed,
}
/// The shield's state for this session.
#[derive(Resource)]
pub(crate) struct ProbeShield {
/// What the banner reports.
report: ShieldReport,
/// Queued lines and when the next one may go out — the rig's cadence, for the rig's reason.
steps: Vec<String>,
next_at: f32,
/// `Time::elapsed_secs` after which an unanswered `.cheat god on` is called unconfirmed.
confirm_by: f32,
/// Set at world entry, cleared once the batch is built — the "we owe this body a shield" latch.
arm_wanted: Option<f32>,
/// Our own death state last frame, so a death *edge* is distinguishable from arriving dead.
/// `None` until the first descriptor read of this world entry.
was_dead: Option<bool>,
}
impl Default for ProbeShield {
fn default() -> Self {
Self {
report: ShieldReport::NotOurs, // decided once we know whose account this is
steps: Vec::new(),
next_at: 0.0,
confirm_by: 0.0,
arm_wanted: None,
was_dead: None,
}
}
}
impl ProbeShield {
/// What the preflight banner should say ([`crate::preflight`]).
pub(crate) fn report(&self) -> ShieldReport {
self.report
}
}
/// Whether `user` is a probe account — `probe` followed by digits, the slot-keyed identity every
/// unattended session logs in with (method.md "The local vmangos server"). Nothing else is ours to
/// modify: `one` is the director's, and a bystander test account is not a probe.
fn is_probe_account(user: &str) -> bool {
let user = user.to_ascii_lowercase();
user.strip_prefix("probe")
.is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()))
}
/// Whether this run wants GM mode **off** — `WOW_GM=off`, or an explicit `gm:off` in `WOW_RIG`.
/// The default is on (decision 0679), so this is the flag a session sets when it needs faithful
/// factions, hostility, colours, threat, damage or timers. An explicit ask always beats the
/// default, and the rig's token is an explicit ask: two modules must not send `.gm` at each other,
/// so the one with the spec in front of it wins (0651's rig owns its own `gm:` step).
fn wants_gm_off() -> bool {
std::env::var("WOW_GM").is_ok_and(|v| v.eq_ignore_ascii_case("off"))
|| std::env::var("WOW_RIG").is_ok_and(|spec| rig_asks_for_gm_off(&spec))
}
/// Whether a `WOW_RIG` spec carries an explicit `gm:off` — the rig's own token
/// ([`crate::capture::probe_rig`]), matched the way the rig matches it (case-insensitive key,
/// anything but `on`/`1` falsy).
fn rig_asks_for_gm_off(spec: &str) -> bool {
spec.split_whitespace().any(|tok| {
matches!(
tok.to_ascii_lowercase().split_once(':'),
Some(("gm", v)) if !matches!(v, "on" | "1")
)
})
}
/// Whether `WOW_GOD=off` asked for an unshielded run.
fn disabled_by_env() -> bool {
std::env::var("WOW_GOD").is_ok_and(|v| v.eq_ignore_ascii_case("off"))
}
/// Classify one `CHAT_MSG_SYSTEM` line as an answer about god mode. `None` = not about it.
///
/// The strings are vmangos `mangos_string` 368/369 (`LANG_YOU_SET_GOD`, `LANG_YOUR_GOD_SET`) — the
/// only ones that reach us as *chat*. `LANG_CHEAT_GOD_ON/OFF` (349/350) travel as
/// `SMSG_NOTIFICATION` instead and never appear here, which is why matching on "GOD mode is ON"
/// would silently never fire.
fn god_verdict(text: &str) -> Option<bool> {
let lower = text.to_ascii_lowercase();
if !lower.contains("god mode") {
return None;
}
// "You set god mode to on for X." / "Your god mode has been turned off by X."
if lower.contains(" to on") || lower.contains("turned on") {
Some(true)
} else if lower.contains(" to off") || lower.contains("turned off") {
Some(false)
} else {
None
}
}
/// Arm the shield on every world entry, confirm it against the server's own answer, and put it
/// back up whenever something drops it.
#[allow(clippy::too_many_arguments)]
fn drive_shield(
mut shield: ResMut<ProbeShield>,
mut entered: MessageReader<EnteredWorldMessage>,
mut said: MessageReader<ServerSaidMessage>,
time: Res<Time>,
intent: Res<LoginIntent>,
names: Res<NameCache>,
self_guid: Res<SelfGuid>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
) {
let entered_world = entered.read().next().is_some();
let ours = intent.account().is_some_and(is_probe_account);
if !ours || disabled_by_env() {
// Still drain, so a later frame never sees a stale backlog.
said.read().for_each(drop);
shield.report = if ours {
ShieldReport::Disabled
} else {
ShieldReport::NotOurs
};
if entered_world && ours {
warn!(
"probe-shield: DISABLED by WOW_GOD=off — this probe character CAN die. That is the \
right setting for a death-arc test; unset it for everything else."
);
}
return;
}
if entered_world {
shield.arm_wanted = Some(time.elapsed_secs());
shield.was_dead = None;
shield.report = ShieldReport::Arming;
}
// The server's verdict — the only ground truth about a flag that rides no descriptor field.
for msg in said.read() {
match god_verdict(&msg.text) {
Some(true) => {
if shield.report != ShieldReport::Armed {
info!(
"probe-shield: ARMED — this character cannot die (vmangos `.cheat god`: \
damage clamps at 1 hp instead of killing). Hostility, factions, aggro and \
environmental damage all stay faithful, unlike GM mode. It is NOT \
persisted, so it is re-armed on every world entry."
);
}
shield.report = ShieldReport::Armed;
}
Some(false) => {
warn!(
"probe-shield: the shield was turned OFF — putting it back up. If you meant to \
run unshielded, use WOW_GOD=off (which also stops this re-arm) rather than \
`.cheat god off`."
);
rearm(&mut shield, &time, &names, &self_guid);
}
None => {}
}
}
// A death is proof the shield was down, whatever we believed. `.die` is the usual cause: it
// clears god mode silently (`notify = false`) before killing, by design, so this is the normal
// path for a session that wanted a corpse — not necessarily a fault.
if let Ok(store) = self_q.single() {
let dead = store.0.unit_is_dead() || store.0.player_is_ghost();
match shield.was_dead {
None => shield.was_dead = Some(dead), // first read of this entry: a state, not an edge
Some(false) if dead => {
shield.was_dead = Some(true);
warn!(
"probe-shield: THE CHARACTER DIED — so the shield was down. `.die` clears it \
silently by design; anything else means it never landed. Re-arming now (it \
does not revive: send `.revive`)."
);
rearm(&mut shield, &time, &names, &self_guid);
}
Some(_) => shield.was_dead = Some(dead),
}
}
// Build the batch once the body — and ideally our own name — is there to address.
if let Some(wanted_at) = shield.arm_wanted {
let Ok(store) = self_q.single() else { return };
let name = self_guid.0.and_then(|g| names.peek(g));
let waited = time.elapsed_secs() - wanted_at;
if name.is_none() && waited < NAME_WAIT_SECS {
return; // the name makes the command selection-proof — it is worth a short wait
}
if name.is_none() {
warn!(
"probe-shield: our own name has not resolved after {NAME_WAIT_SECS:.0}s — arming \
without it. `.cheat god` re-targets to the current selection, so this can be \
refused with \"Player not found!\" if anything is targeted."
);
}
shield.arm_wanted = None;
shield.steps.push(god_line(name));
shield.confirm_by = time.elapsed_secs() + CONFIRM_SECS;
let gm_is_on = store.0.player_flags() & PLAYER_FLAGS_GM != 0;
match (gm_is_on, wants_gm_off()) {
// The default (decision 0679): GM mode stays on, so nothing aggros a parked body. The
// shield is what makes it SAFE to drop, not a reason to drop it by default.
(false, false) => {
info!(
"probe-shield: GM mode is off — turning it back ON, so a parked body is not \
permanently mobbed. Readings taken now are faction-35 readings and are wrong \
for anything about hostility, colour, threat, aggro, damage or timers \
(0649/0657) — set WOW_GM=off for those, which is safe: the shield holds."
);
shield.steps.push(".gm on".into());
}
(true, true) => {
info!(
"probe-shield: WOW_GM=off — turning GM mode OFF so factions, hostility and \
damage are faithful. Safe: the shield is what keeps this body alive now."
);
shield.steps.push(".gm off".into());
}
_ => {} // already as asked for
}
shield.next_at = time.elapsed_secs();
}
// Send the queued lines, one per STEP_SECS — god first, always.
if !shield.steps.is_empty() {
let Some(mut script) = script else { return };
while !shield.steps.is_empty() && time.elapsed_secs() >= shield.next_at {
let line = shield.steps.remove(0);
debug!("probe-shield: sending {line:?}");
script.push_chat_input(line);
shield.next_at = time.elapsed_secs() + STEP_SECS;
}
return;
}
// Nothing outstanding: a `.cheat god on` that was never answered did not land.
if shield.report == ShieldReport::Arming && time.elapsed_secs() > shield.confirm_by {
shield.report = ShieldReport::Unconfirmed;
warn!(
"probe-shield: NOT CONFIRMED — `.cheat god on` went out and the server never answered \
it. Treat this character as mortal. Check the account's GM level (`.cheat` needs \
SEC_GAMEMASTER 3; probe accounts are 6): \
SELECT gmlevel FROM realmd.account_access WHERE id = <account>."
);
}
}
/// The command, always naming the character when we know it — `.cheat god` re-targets to the
/// current selection, and a creature selection makes the bare form fail.
fn god_line(name: Option<&str>) -> String {
match name {
Some(n) => format!(".cheat god on {n}"),
None => ".cheat god on".into(),
}
}
/// Queue another `.cheat god on` and re-open the confirmation window.
fn rearm(shield: &mut ProbeShield, time: &Time, names: &NameCache, self_guid: &SelfGuid) {
let name = self_guid.0.and_then(|g| names.peek(g));
shield.steps.push(god_line(name));
shield.next_at = time.elapsed_secs();
shield.confirm_by = time.elapsed_secs() + CONFIRM_SECS;
shield.report = ShieldReport::Arming;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_probe_accounts_are_ours() {
for user in ["probe0", "probe7", "PROBE12"] {
assert!(is_probe_account(user), "{user} is a probe account");
}
// The director's account, and bystanders: never touched.
for user in ["one", "two", "probe", "probeone", "aprobe1", "probe1x"] {
assert!(!is_probe_account(user), "{user} is NOT a probe account");
}
}
#[test]
fn the_servers_own_words_are_what_confirm_it() {
// vmangos 368, what `.cheat god on <name>` actually answers (captured live).
assert_eq!(
god_verdict("You set god mode to on for |cffffffff|Hplayer:Probetwo|h[Probetwo]|h|r."),
Some(true)
);
assert_eq!(
god_verdict("You set god mode to off for [Probetwo]."),
Some(false)
);
// vmangos 369, when another GM does it to us.
assert_eq!(
god_verdict("Your god mode has been turned off by [Someone]."),
Some(false)
);
// Everything else on the same channel is not an answer about the shield.
for other in [
"Player not found!",
"GM mode is OFF",
"There is no such command",
"Welcome to World of Warcraft!",
] {
assert_eq!(god_verdict(other), None, "{other}");
}
}
#[test]
fn the_rigs_own_gm_token_wins_over_the_default() {
// GM mode is ON by default (0679), so the rig's token only matters when it asks for OFF —
// and then the shield must not fight it back on.
assert!(rig_asks_for_gm_off("gnome mage 39 gm:off"));
assert!(rig_asks_for_gm_off("GM:0"));
// An explicit gm:on, and every spec that says nothing, leave the default alone.
for spec in [
"tauren druid 60 gm:on at:ThunderBluff",
"GM:1",
"60 gear:dps-preraid-bis",
"",
] {
assert!(!rig_asks_for_gm_off(spec), "{spec:?}");
}
}
#[test]
fn the_command_always_names_its_target() {
// The bare form re-targets to the selection and is refused with a creature targeted
// (verified live), so the name is the whole point.
assert_eq!(god_line(Some("Probetwo")), ".cheat god on Probetwo");
assert_eq!(god_line(None), ".cheat god on");
}
}
+1 -1
View File
@@ -113,7 +113,7 @@ fn drive_liquid_loops(
};
// The submerge HARD stop (no fade) + the resurface instant-restart edge.
if underwater.0 {
if underwater.0.is_water() {
for slot in &mut state.classes {
if let Some(cl) = slot.take() {
stop_source_kit(&mut out, cl.emitter, cl.kit);
+1 -1
View File
@@ -67,7 +67,7 @@ fn zone_reverb(
let (Some(areas), Some(providers)) = (areas, providers) else {
return;
};
let column = usize::from(underwater.0);
let column = usize::from(underwater.0.is_water());
let pref = if config.enabled {
interior
.0
+3 -3
View File
@@ -321,7 +321,7 @@ fn zone_audio(
.is_ok_and(|store| store.0.player_is_ghost());
let desired = if ghost {
kits.id_by_name("Ghost").unwrap_or(0)
} else if underwater.0 {
} else if underwater.0.is_water() {
UNDERWATER_LOOP_KIT
} else if weather.0 != 0 && area_interior.0.is_none() {
// The selector's weather branch (`0x460bd0`, 0x460bf7–0x460c17; wow-re
@@ -350,7 +350,7 @@ fn zone_audio(
// branch (`0x458650` → `0x460af0(param=1)`, wow-re §5 B16, correcting the old "5 s
// underwater" of B6). Every other ambience swap — interior, area, day↔night — is the one
// 5.0 s crossfade (`0x460b00`'s fade branch).
let fade_ms = if underwater.0 != zone.was_underwater {
let fade_ms = if underwater.0.is_water() != zone.was_underwater {
0
} else {
AMBIENCE_TRANSITION_FADE_MS
@@ -359,7 +359,7 @@ fn zone_audio(
zone, &mut out, &mut kits, &assets, &config, desired, fade_ms, now,
);
}
zone.was_underwater = underwater.0;
zone.was_underwater = underwater.0.is_water();
// ---- music transport ----
// Reap a finished track → schedule the next after a fresh silence interval.
@@ -473,6 +473,193 @@ fn escape_during_a_countdown_cancels_it_and_does_not_open_the_menu() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The world map is subject to the menu like everything else (director-reported: it was "the only
/// one not blocked"). It was a plain toplevel showing itself with a bare `WorldMapFrame:Show()`,
/// so the panel manager never saw it and `CanOpenPanels()` never got a say; it is now the ref's own
/// `area = "full"` row and its toggle goes through ShowUIPanel.
///
/// Both directions matter, and the second is the one a bare `IsVisible()` check would miss: the map
/// must also VACATE the full-screen slot when it closes, or the stale slot refuses every panel
/// afterwards.
#[test]
fn the_world_map_cannot_open_behind_the_menu_and_gives_its_slot_back() {
let s = harness_with(&[
"GameTooltip.xml",
"UIDropDownMenu.xml", // the map's continent/zone pickers initialize into it at OnLoad
"ScrollTemplates.xml",
"WorldMapFrame.xml",
]);
// Opens normally, and takes the full-screen slot.
s.run("ToggleWorldMap()").unwrap();
assert!(s.eval::<bool>("return WorldMapFrame:IsVisible()").unwrap());
assert!(s
.eval::<bool>("return GetFullScreenFrame():GetName() == \"WorldMapFrame\"")
.unwrap());
// Closing gives the slot back — a stale full-screen frame would block every later panel.
s.run("ToggleWorldMap()").unwrap();
assert!(!s.eval::<bool>("return WorldMapFrame:IsVisible()").unwrap());
assert!(
s.eval::<bool>("return GetFullScreenFrame() == nil")
.unwrap(),
"the map vacated the full-screen slot"
);
// With the menu up, it must not open — from the M binding or from the micro button, both of
// which are this same ToggleWorldMap.
s.run("ToggleGameMenu(1)").unwrap();
assert!(s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap());
s.run("ToggleWorldMap()").unwrap();
assert!(
!s.eval::<bool>("return WorldMapFrame:IsVisible()").unwrap(),
"the map must not open behind the game menu"
);
assert!(s
.eval::<bool>("return GetFullScreenFrame() == nil")
.unwrap());
// And it opens again the moment the menu is gone.
s.run("ToggleGameMenu(1)").unwrap();
s.run("ToggleWorldMap()").unwrap();
assert!(s.eval::<bool>("return WorldMapFrame:IsVisible()").unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The other direction of the same rule (ref `ShowUIPanel` l.668-675): with the map holding the
/// full screen, nothing opens behind IT either — and ESC closes the map first, so the press that
/// would have opened the menu goes to the map instead (one eater per press).
#[test]
fn nothing_opens_behind_the_world_map_and_escape_closes_it_first() {
let s = harness_with(&[
"GameTooltip.xml",
"UIDropDownMenu.xml",
"ScrollTemplates.xml",
"WorldMapFrame.xml",
"MerchantFrame.xml",
"LootFrame.xml",
]);
s.run("ToggleWorldMap()").unwrap();
s.run("ShowUIPanel(BenillaLootFrame)").unwrap();
assert!(
!s.eval::<bool>("return BenillaLootFrame:IsVisible()")
.unwrap(),
"a left-area panel must not open behind the full-screen map"
);
// ESC: the map's own rung eats the press (it stands in for the reference frame's OnKeyDown).
s.run("ToggleGameMenu()").unwrap();
assert!(!s.eval::<bool>("return WorldMapFrame:IsVisible()").unwrap());
assert!(
!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
"…and did not also open the menu"
);
assert!(s
.eval::<bool>("return GetFullScreenFrame() == nil")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The bag row under an open menu, pinned at the QUAD level — because "greyed out" and "gone" are
/// the same thing to an `IsEnabled()` assertion, and the difference is what the director saw: the
/// first cut of `Disable_BagButtons` disabled buttons whose art was their NormalTexture, and a
/// disabled button with no DisabledTexture draws no state texture at all
/// (`ButtonState::region_visible`, the byte rule at `SetState 0x779790`) — so the backpack icon
/// vanished off the bar instead of dimming.
///
/// What must hold with the menu up: every one of the five buttons still DRAWS its art, and every
/// one is tinted grey. And on the way back out, tinted white again.
#[test]
fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
let mut s = harness_with(&[
"MerchantFrame.xml",
"Cooldown.xml",
"ActionBar.xml",
"BagFrame.xml",
]);
s.set_money(0);
s.set_container(0, Some(backpack()));
s.resolve();
// The backpack icon + the four slot rings, by name.
let art = |s: &UiScript, owner: &str| -> Vec<(String, Option<[f32; 4]>)> {
s.extract()
.into_iter()
.filter(|eq| s.quad_owner_name(eq.target).as_deref() == Some(owner))
.filter_map(|eq| match &eq.content {
benilla_ui::script::QuadContent::Texture {
path: Some(p),
color,
..
} => Some((p.clone(), *color)),
_ => None,
})
.collect()
};
for owner in [
"BenillaBagToggle",
"BenillaBagBarSlot1",
"BenillaBagBarSlot2",
"BenillaBagBarSlot3",
"BenillaBagBarSlot4",
] {
assert!(
!art(&s, owner).is_empty(),
"{owner} draws art before the menu opens"
);
}
s.run("ToggleGameMenu(1)").unwrap();
s.resolve();
for owner in [
"BenillaBagToggle",
"BenillaBagBarSlot1",
"BenillaBagBarSlot2",
"BenillaBagBarSlot3",
"BenillaBagBarSlot4",
] {
let drawn = art(&s, owner);
assert!(
!drawn.is_empty(),
"{owner} must still DRAW under the open menu — greyed is not gone"
);
}
// The backpack icon specifically: still its own art, and tinted to SetDesaturation's grey.
let toggle = art(&s, "BenillaBagToggle");
assert!(
toggle
.iter()
.any(|(p, _)| p == "Interface\\Buttons\\Button-Backpack-Up"),
"the backpack image is still on the bar: {toggle:?}"
);
assert!(
toggle
.iter()
.any(|(p, c)| p == "Interface\\Buttons\\Button-Backpack-Up"
&& c.is_some_and(|c| (c[0] - 0.5).abs() < 0.01)),
"…and it is greyed, not full-bright: {toggle:?}"
);
s.run("ToggleGameMenu(1)").unwrap();
s.resolve();
let toggle = art(&s, "BenillaBagToggle");
assert!(
toggle
.iter()
.any(|(p, c)| p == "Interface\\Buttons\\Button-Backpack-Up"
&& c.is_none_or(|c| (c[0] - 1.0).abs() < 0.01)),
"closing the menu restores full colour: {toggle:?}"
);
assert!(
s.eval::<bool>("return BenillaBagToggle:IsEnabled()")
.unwrap(),
"…and the button works again"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A one-item backpack (the escape/bag tests' fixture, duplicated so this file is self-contained).
fn backpack() -> ContainerState {
let mut slots = std::collections::HashMap::new();
+99 -3
View File
@@ -3,8 +3,10 @@
//! double duty) so it's *config*, not a debug knob, and so subsystems read one value instead of
//! several uncoordinated ones.
//!
//! Read by: the hard far-clip **wall** (terrain/model/liquid/WDL shaders, pushed as `fog_params.w` by
//! `lighting::apply_wow_lighting`), the per-object **cull** (`debug_panel::apply_model_visibility`), and
//! Read by: the hard far-clip **wall** (terrain/model/liquid/WDL/particle shaders, pushed as
//! `fog_params.w` by `lighting::apply_wow_lighting`), the per-object **cull**
//! (`debug_panel::apply_model_visibility`), the particle **draw-set gate**
//! (`particles::sim::simulate_particles`) — both through [`within_farclip`] — and
//! — once `terrain_stream` is split — the tile
//! **stream radius** (which should derive its coverage from `farclip`). Live-editable via the debug
//! panel slider (the same A/B lever as before, now writing this resource).
@@ -19,8 +21,102 @@ pub struct ViewDistance {
pub farclip: f32,
}
/// The settable range of [`ViewDistance::farclip`], shared by the debug-panel slider and the
/// `$WOW_FARCLIP` env knob so the two can't drift. The vanilla `farclip` CVar clamp is `[177, 777]`
/// (validate callback `0x688d40`, wow-re `terrain.md` "Camera-distance CVars"; its default is 350, ours
/// is the max) — the upper end here runs past that ONLY as an A/B lever against the old
/// "draw everything in the tile window" look.
pub const FARCLIP_RANGE: std::ops::RangeInclusive<f32> = 177.0..=1200.0;
impl Default for ViewDistance {
/// `$WOW_FARCLIP` (yd, clamped to [`FARCLIP_RANGE`]) overrides the 777 default. The panel slider is
/// the live lever, but a headless capture has no hands — and a horizon or fog report almost always
/// arrives with the director's slider somewhere other than the default (the 0684 gap was invisible
/// at 777 and glaring at 320), so reproducing one must not need a human. Read once at startup, like
/// the other capture-side knobs.
fn default() -> Self {
Self { farclip: 777.0 }
let farclip = std::env::var("WOW_FARCLIP")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.map_or(777.0, |v| {
v.clamp(*FARCLIP_RANGE.start(), *FARCLIP_RANGE.end())
});
Self { farclip }
}
}
/// Is a world bounding sphere inside the far-clip wall — i.e. does the detailed world still draw it?
///
/// **The one spelling of "is it nearer than `farclip`", shared by every CPU-side consumer.** The test is
/// planar depth along the camera-forward axis (`(center − eye)·fwd`) of the sphere's NEAREST point, which
/// is deliberately the *same coordinate* the per-pixel wall uses in the shaders (`terrain.wgsl` /
/// `wow_model.wgsl` / `wow_particle.wgsl` all discard on eye-Z past `fog_params.w`). Agreeing on the
/// coordinate is what makes an object straddling the boundary **dissolve** through it instead of popping
/// when its origin crosses.
///
/// Radial distance would be the obvious alternative and it is wrong: it disagrees with the wall off-axis,
/// so a wide object at the edge of the frame pops while its pixels were still being drawn.
///
/// ## Why this is not the camera's far plane
/// The world camera's projection far is ~3000 yd — far *beyond* `farclip` on purpose, so the coarse WDL
/// horizon can draw behind the wall. So the frustum's own far plane is **not** the reference's far plane,
/// and a `Frustum::intersects_sphere(.., intersect_far = true)` is not a substitute for this test. In the
/// reference there is one projection far plane at `farclip` and it bounds the detailed world; here that
/// bound is this function plus the shaders' per-pixel discard, and nothing else.
pub fn within_farclip(
farclip: f32,
cam_pos: Vec3,
cam_fwd: Vec3,
center: Vec3,
radius: f32,
) -> bool {
(center - cam_pos).dot(cam_fwd) - radius <= farclip
}
#[cfg(test)]
mod tests {
use super::*;
/// The wall is planar depth along camera-forward, measured to the sphere's nearest point.
#[test]
fn planar_depth_of_the_nearest_point() {
let eye = Vec3::ZERO;
let fwd = Vec3::NEG_Z; // Bevy's camera looks down −Z
let at = |d: f32, r: f32| within_farclip(777.0, eye, fwd, Vec3::new(0.0, 0.0, -d), r);
assert!(at(700.0, 0.0));
assert!(at(777.0, 0.0)); // exactly at the wall still draws (the shader discards past it)
assert!(!at(778.0, 0.0));
// A big object straddling the wall stays in: its near side is still inside, and the
// per-pixel wall dissolves the far half. This is the no-pop property.
assert!(at(800.0, 30.0));
assert!(!at(900.0, 30.0));
}
/// Off-axis is where radial distance and the shader's eye-Z part company — the wall is a PLANE,
/// so a point 700 yd forward and 700 yd sideways (radially ~990) is still inside it.
#[test]
fn the_wall_is_a_plane_not_a_sphere() {
let eye = Vec3::ZERO;
let fwd = Vec3::NEG_Z;
let off = Vec3::new(700.0, 0.0, -700.0);
assert!(off.length() > 777.0, "radially outside");
assert!(
within_farclip(777.0, eye, fwd, off, 0.0),
"but inside the planar wall — must match the shader, which discards on eye-Z"
);
}
/// Behind the camera is trivially inside the wall (negative depth); the lateral frustum planes,
/// not this test, are what reject it. Pinned so nobody "fixes" this into an abs().
#[test]
fn behind_the_camera_is_not_this_tests_job() {
let eye = Vec3::ZERO;
assert!(within_farclip(
777.0,
eye,
Vec3::NEG_Z,
Vec3::new(0.0, 0.0, 5000.0),
0.0
));
}
}
+29 -10
View File
@@ -4,6 +4,8 @@
//! is just the Bevy streaming + render glue (one shared [`WdlMaterial`], one mesh per ring tile).
//!
//! Mechanism + RE (geometry/shading/fog/depth all VERIFIED from apitrace WoW.8 + the real `.wdl`).
//! How it partitions against the detailed world — the reference's far-band **backdrop** law, and why a
//! shared clip plane could not work — is `wdl.wgsl`'s header (decision 0684).
use std::collections::HashMap;
@@ -25,8 +27,9 @@ use benilla_formats::WdlFile;
/// Chebyshev tile radius the WDL ring covers around the view. Past ~`fog_end` (~481 yd ≈ <1 tile) WDL
/// is pure haze, but hills 2–4 tiles out still rise above the horizon as fogged silhouettes, so we
/// extend toward the reference's `horizonfarclip` (~2112 yd ≈ 4 tiles). Drawn as a full ring; the
/// shader near-clips it to beyond the far-clip wall so it never overlaps the detailed terrain (which
/// is clipped *at* the wall) — the two partition cleanly at the wall plane.
/// shader makes it a **backdrop** — near plane at `farclip − 33`, depth clamped behind everything the
/// detailed world can draw (`wdl.wgsl`'s header, decision 0684) — so it fills whatever the detailed
/// world leaves empty and can never overlap it. (The reference's own far walk is a ±3-tile window.)
const WDL_RADIUS: u32 = 5;
/// New WDL tiles built per frame — coarse meshes are cheap (545 verts), but a whole ring at once
@@ -175,14 +178,11 @@ fn stream_wdl(
} else {
[SPAWN_XY.0, SPAWN_XY.1, 0.0]
};
// Draw the WDL as a FULL ring (skip only the player's own tile, which is entirely inside the wall
// and so fully discarded by the shader's far-clip partition anyway). The shader near-clips WDL to
// beyond the far-clip wall, so it never overlaps the detailed terrain and never toggles with the
// detailed streaming radius — it's a constant backdrop. (Previously skipped `config.tile_radius`,
// which left a gap between the wall and where WDL started → the horizon popped in/out.)
let desired = streamer
.wdl
.tiles_in_ring(center[0], center[1], WDL_RADIUS, 0);
// The FULL window, the camera's own tile INCLUDED (`tiles_in_ring`'s doc is the why — at a
// lowered view distance the own tile *is* the near horizon, and dropping it leaves a gap the sky
// pours through). What bounds the band on the near side is the shader's near plane, never the
// streamed set, so this cannot toggle against the detailed streaming radius — a constant backdrop.
let desired = streamer.wdl.tiles_in_ring(center[0], center[1], WDL_RADIUS);
// Unload ring tiles no longer wanted.
let stale: Vec<(u32, u32)> = streamer
@@ -234,3 +234,22 @@ fn stream_wdl(
streamer.loaded.insert(coords, e);
}
}
/// The backdrop law (`wdl.wgsl`'s header, decision 0684) is a property of the **shader**, so it is
/// checked there — the same shape as `sky_order.rs`'s depth-law test, and for the same reason: the
/// failure it guards is invisible except at a ridge crest on a fogged horizon, and the obvious "tidy-up"
/// (go back to one shared clip plane, drop the frag-depth write) silently reintroduces it.
#[test]
fn the_far_band_stays_a_depth_pushed_backdrop() {
let src = include_str!("../assets/shaders/wdl.wgsl");
assert!(
src.contains("const WDL_OVERLAP: f32 = 33.0;"),
"wdl.wgsl: the far band's 33 yd overlap into the wall is gone — the coarse-vs-fine seam \
reopens as a hole at the horizon (the reference's far-band near plane, [0x8101b0])"
);
assert!(
src.contains("out.depth = depth;") && src.contains("view_z_to_depth_ndc(-farclip)"),
"wdl.wgsl: the far band no longer clamps its depth behind the far-clip wall — its overlap \
now pokes THROUGH the detailed terrain (the reference's compressed far-band depth range)"
);
}
+10 -1
View File
@@ -55,7 +55,16 @@ fn load_world_map(mut commands: Commands, world_assets: Option<Res<WorldAssets>>
Ok(c) => {
info!("Map.dbc: {} maps catalogued", c.len());
commands.insert_resource(MapCatalogRes(c));
commands.insert_resource(CurrentMap(DEFAULT_MAP_ID));
// `$WOW_MAP` seeds a different continent for a SERVER-LESS run (a `WOW_CAPTURE=vista`
// shot in Kalimdor, say — a `Map.dbc` id; 1 = Kalimdor). Live sessions are unaffected:
// `player::wire_in` overwrites this from the server's world-verify the moment it says
// where we are. Without it every headless capture is stuck in Eastern Kingdoms, and a
// horizon report from anywhere else cannot be reproduced.
let map = std::env::var("WOW_MAP")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_MAP_ID);
commands.insert_resource(CurrentMap(map));
}
Err(e) => error!("failed to load Map.dbc, cross-map teleport disabled: {e:#}"),
}