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