birds stop blinking out, and the version check is answered

- an animated doodad is culled by its authored box, not its bind pose — the one-yard bird flying a 64-yard circuit stops vanishing with the camera angle — and the fix survives the engine's own bound recompute
- the logon proof answers the server's version challenge instead of sending zeros, so a strict realmd stops rejecting benilla as a modified client
- two instruments land: a frame-by-frame visibility trace for one model, and a probe that classifies whether a realmd enforces the version check

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-14 19:09:40 +07:00
co-authored by Claude Fable 5
parent ea1f0d2bb5
commit 6e7867147d
19 changed files with 848 additions and 20 deletions
+1
View File
@@ -16,6 +16,7 @@ authors = ["sam <samuv.eth@gmail.com>"]
anyhow = "1.0.102"
bevy = "0.18.1"
clap = { version = "4.6.1", features = ["derive"] }
sha1 = "0.10.6"
# The WoW format + protocol stack is fully in-repo (decision 0021), so there are no warcraft-rs /
# wow_messages crates (or forks of them) anywhere in the tree. That is still true and is why this
+4 -2
View File
@@ -60,8 +60,8 @@ use benilla_world::lighting::{PropProbeSlot, PropProbes};
use benilla_world::model_render::ShadeSel;
use benilla_world::particles;
use benilla_world::terrain_stream::{
build_collider_task, fold_interior_probe, m2_fade, placement_collider_data, point_light,
spawn_model_entities, PendingCollider, PropLobeLight,
build_collider_task, fold_interior_probe, m2_anim_bound, m2_fade, placement_collider_data,
point_light, spawn_model_entities, PendingCollider, PropLobeLight,
};
use super::{GameObjects, ModelHandle, VisualAttached};
@@ -228,6 +228,7 @@ pub(super) fn spawn_wmo_gameobject_props(
anchor
});
let (radius, center) = m2_fade(&m.bounds, prop.local.scale.x);
let anim_bound = m2_anim_bound(&m.bounds);
// The interior lane (0474): fold the cabin prop's committed light ONCE, composed
// through the host's current pose. Exact on a mover — see the module doc's
// invariance argument.
@@ -273,6 +274,7 @@ pub(super) fn spawn_wmo_gameobject_props(
interior_slot,
radius,
center,
anim_bound,
Some((m, now)),
&mut uv_reg,
&mut tint_reg,
@@ -212,6 +212,19 @@ enum Command {
/// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted.
prefix: Option<String>,
},
/// Sweep every `.m2` (optionally under a path prefix) and measure how far its authored header
/// bounding box — the model's **all-animation** vertex extent, and the box the reference
/// derives its doodad cull sphere from (`rec+0x5c`/`rec+0x68`) — reaches past its **bind-pose**
/// vertex extent. The population instrument for decision 1259: an animated placement's submesh
/// keeps its transform at the placement origin while the joint palette moves its vertices, so a
/// bind-pose bound culls an object whose geometry is still on screen. The ambient critters are
/// the extreme — `World\critter\birds\Bird01.m2` authors a 67 yd box around a 1.2 yd body,
/// because its root bone flies it 64 yd along a circuit. Also reports the reverse (`SHORT`:
/// bind-pose geometry OUTSIDE the authored box), which is why the fix unions the two boxes.
Animboundscan {
/// Internal-path prefix filter (e.g. `world\critter`), case-insensitive; all if omitted.
prefix: Option<String>,
},
/// Sweep every `.m2` (optionally under a path prefix) and census the geometry a
/// **non-character** spawn draws that the reference may not: MULTI-GEOSET models (more than
/// one `skinSectionId` — only the character compositor selects among them, so every other
@@ -658,6 +671,7 @@ fn main() -> Result<()> {
Command::Bbscan { prefix } => scan::bbscan(&mut chain, prefix.as_deref())?,
Command::Bbfacescan { prefix } => scan::bbfacescan(&mut chain, prefix.as_deref())?,
Command::Groundscan { prefix } => scan::groundscan(&mut chain, prefix.as_deref())?,
Command::Animboundscan { prefix } => scan::animboundscan(&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::Fxlifescan { prefix } => scan::fxlifescan(&mut chain, prefix.as_deref())?,
@@ -507,3 +507,110 @@ pub fn groundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
}
Ok(())
}
/// Sweep every `.m2` (under `prefix`, if given) and measure how far its authored header bounding
/// box — the model's **all-animation** vertex extent, and the box the reference derives its doodad
/// cull sphere from — reaches past its **bind-pose** vertex extent.
///
/// The population instrument for decision 1259. A placed model's submesh entity keeps its transform
/// at the placement origin while the joint palette moves its vertices, so a bind-pose mesh bound
/// stops describing what is drawn the moment the model animates: cull with it and the object blinks
/// out while its geometry is still on screen. `SLACK` is how many yards the authored box reaches
/// past the bind-pose box on its worst face — for the ambient critters (birds, bats, butterflies,
/// wasps) that is tens of yards against a sub-yard body, which is the whole bug.
///
/// `SHORT` is the same measure with the signs reversed: yards of bind-pose geometry sticking out of
/// the *authored* box. It is not zero in the shipped corpus, which is why the fix unions the two
/// boxes instead of swapping one for the other — the reference never trips over it because it tests
/// the box's circumsphere, which swallows the overhang.
///
/// `ANIM` marks the models the widened bound actually applies to: any bone track with more than one
/// key, or any global-sequence bone channel. A static model keeps its tighter per-batch bound.
pub fn animboundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
let names = super::m2_names(chain, prefix)?;
// (slack, short, animates, bind-pose half-diagonal, name)
let mut rows: Vec<(f32, f32, bool, f32, String)> = Vec::new();
let mut scanned = 0u32;
for name in names {
let Ok(bytes) = chain.read_file(&name) else {
continue;
};
let Ok(bounds) = benilla_formats::parse_m2_bounds(&bytes) else {
continue;
};
let Ok(fmt) = benilla_m2::parse_m2(&mut std::io::Cursor::new(bytes.as_slice())) else {
continue;
};
let verts = &fmt.model().vertices;
if verts.is_empty() {
continue;
}
scanned += 1;
let mut lo = [f32::MAX; 3];
let mut hi = [f32::MIN; 3];
for v in verts {
for (a, c) in [v.position.x, v.position.y, v.position.z]
.into_iter()
.enumerate()
{
lo[a] = lo[a].min(c);
hi[a] = hi[a].max(c);
}
}
let (mut slack, mut short) = (0.0f32, 0.0f32);
for a in 0..3 {
slack = slack
.max(lo[a] - bounds.bbox_min[a])
.max(bounds.bbox_max[a] - hi[a]);
short = short
.max(bounds.bbox_min[a] - lo[a])
.max(hi[a] - bounds.bbox_max[a]);
}
let half_diag =
((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2) + (hi[2] - lo[2]).powi(2)).sqrt()
* 0.5;
// "Animates" the way `doodad_anim::classify` means it: geometry that MOVES relative to the
// placement transform — a keyed bone track, or a free-running global-sequence channel.
let keyed = benilla_formats::parse_m2_animations(&bytes)
.iter()
.any(|a| {
a.bones
.iter()
.any(|b| b.translation.len() > 1 || b.rotation.len() > 1 || b.scale.len() > 1)
});
let gseq = !benilla_formats::parse_m2_global_sequence_bones(&bytes).is_empty();
rows.push((slack, short, keyed || gseq, half_diag, name));
}
rows.sort_by(|a, b| b.0.total_cmp(&a.0));
let over = |t: f32| rows.iter().filter(|r| r.0 > t && r.2).count();
println!("{scanned} models with geometry");
println!(
" animated: {}",
rows.iter().filter(|r| r.2).count()
);
for t in [1.0f32, 5.0, 10.0, 20.0] {
println!(" animated with SLACK > {t:>5.1} yd: {}", over(t));
}
println!(
" models whose authored box does NOT contain their bind pose: {}",
rows.iter().filter(|r| r.1 > 0.01).count()
);
println!();
println!(
"{:>9} {:>8} {:>9} {:>4} MODEL",
"SLACK", "SHORT", "BIND-R", "ANIM"
);
// The class the bug is about: the authored box dwarfs the body, so the animation carries the
// model clean out of any bind-pose bound. Ranked by that ratio, not by raw slack — a 200 yd
// waterfall with 200 yd of slack is never culled either way.
let mut ranked: Vec<&(f32, f32, bool, f32, String)> =
rows.iter().filter(|r| r.2 && r.0 > 1.0).collect();
ranked.sort_by(|a, b| (b.0 / b.3.max(0.05)).total_cmp(&(a.0 / a.3.max(0.05))));
for (slack, short, _, half_diag, name) in ranked.iter().take(40) {
println!(
"{slack:9.2} {short:8.2} {half_diag:9.2} {:>4} {name}",
"yes"
);
}
Ok(())
}
@@ -27,7 +27,7 @@ mod sequence;
mod skeleton;
mod world;
pub use geometry::{bbfacescan, bbscan, geosetscan, groundscan};
pub use geometry::{animboundscan, bbfacescan, bbscan, geosetscan, groundscan};
pub use lighting::{darkpropscan, m2lightscan, shadeat};
pub use material::{alphascan, blendscan, envmapscan, texmodescan, uvwrapscan};
pub use particles::{
@@ -0,0 +1,90 @@
//! The birds that blink out when you turn the camera (decision 1259).
//!
//! `World\critter\birds\Bird01.m2` is the model that separates a **bind-pose** bound from an
//! **all-animation** one. Its geometry is a single 1.2 × 1.8 × 0.23 yd bird, modelled once at the
//! origin — and its only sequence keys bone 0 with a translation track that flies that bird 64 yd
//! along X and 17 yd along Y, a slow circuit over the treetops. A placed doodad's submesh entity
//! keeps its transform at the placement origin and lets the joint palette move the vertices, so a
//! bound derived from the bind-pose mesh describes a 1-yd box the bird has long since left: turn the
//! camera until that box exits the frustum and the bird vanishes while still on screen.
//!
//! The authored header box is the fix's source, and this pins that it really is the animated extent
//! — off the shipped file, so the premise can't rot. What consumes it (`m2_anim_bound` → the
//! per-submesh `Aabb` of an animated placement) is pinned by `benilla-world`'s own unit tests.
//!
//! Skips (passes) when the client isn't present at `<repo>/WoW/Data`.
use benilla_formats::{open_chain, parse_m2_animations, parse_m2_bounds};
const BIRD: &str = "World\\critter\\birds\\Bird01.m2";
#[test]
fn the_birds_authored_box_covers_a_flight_path_its_bind_pose_never_hints_at() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = open_chain(&data).expect("open vanilla patch chain");
let bytes = chain.read_file(BIRD).expect("Bird01.m2 in the chain");
// 1. The mechanism: bone 0 — the root every other bone hangs off — is keyed with a translation
// track tens of yards wide. Without this the model would need no special bound at all.
let anims = parse_m2_animations(&bytes);
let root = anims
.iter()
.flat_map(|a| a.bones.iter())
.find(|b| b.bone == 0)
.expect("Bird01 keys bone 0");
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
for (_, t) in &root.translation {
for a in 0..3 {
lo[a] = lo[a].min(t[a]);
hi[a] = hi[a].max(t[a]);
}
}
assert!(
hi[0] - lo[0] > 60.0,
"bone 0 should fly the bird >60 yd along X, got {}",
hi[0] - lo[0]
);
// 2. The premise: the AUTHORED header box is that flight path, not the bird. Its half-diagonal
// is the reference's own cull radius (`rec+0x68` = `bounding_sphere_radius × scale`,
// wow-5875-re `terrain/scratch/doodad-emitter-drawset-gate.md` §1c) — so a ~35 yd sphere
// around a 0.6 yd body is what the real client tests, and it never blinks.
let b = parse_m2_bounds(&bytes).expect("Bird01 bounds");
let box_x = b.bbox_max[0] - b.bbox_min[0];
assert!(
box_x > 60.0,
"the authored box should span the whole circuit, got {box_x} yd on X"
);
assert!(
b.sphere_radius > 30.0,
"the authored sphere radius should be the circuit's, got {}",
b.sphere_radius
);
// 3. …and the bind pose is not remotely it: the vertex extent — the box Bevy's
// `calculate_bounds` derives, and what a placed submesh was culled with — is one small bird.
let model = benilla_m2::parse_m2(&mut std::io::Cursor::new(&bytes[..])).expect("parse Bird01");
let (mut vlo, mut vhi) = ([f32::MAX; 3], [f32::MIN; 3]);
for v in &model.model().vertices {
for (a, c) in [v.position.x, v.position.y, v.position.z]
.into_iter()
.enumerate()
{
vlo[a] = vlo[a].min(c);
vhi[a] = vhi[a].max(c);
}
}
let widest = (0..3).fold(0.0f32, |w, a| w.max(vhi[a] - vlo[a]));
assert!(
widest < 3.0,
"the bind-pose geometry is one small bird, widest axis {widest} yd"
);
// The gap IS the bug: the authored box reaches this far past the bind-pose box.
let slack = (0..3).fold(0.0f32, |s, a| {
s.max(vlo[a] - b.bbox_min[a]).max(b.bbox_max[a] - vhi[a])
});
assert!(
slack > 30.0,
"the authored box should reach tens of yards past the bind pose, got {slack} yd"
);
}
+1 -1
View File
@@ -10,9 +10,9 @@ anyhow.workspace = true
clap.workspace = true
benilla-srp = { path = "../benilla-srp" }
flate2 = "1.0"
sha1.workspace = true
[dev-dependencies]
# The SRP probes re-derive the handshake by hand (to vary what `benilla-srp` fixes) — examples only.
num-bigint = "0.4.6"
rand = "0.8.6"
sha1 = "0.10.6"
@@ -285,7 +285,8 @@ fn handshake(
let attempt = compute(&reply, user, pass, enc, want, tries)
.ok_or_else(|| anyhow!("ephemeral search did not reach the wanted shape"))?;
auth::write_logon_proof(&mut s, &attempt.a_pub, &attempt.m1).context("sending logon proof")?;
auth::write_logon_proof(&mut s, &attempt.a_pub, &attempt.m1, &reply.crc_salt)
.context("sending logon proof")?;
let outcome = match auth::read_proof_reply(&mut s) {
Ok(m2) => Outcome::Accepted {
m2_fixed: m2 == attempt.expected_m2(Enc::Fixed),
@@ -0,0 +1,137 @@
//! `version_check_probe` — the B241 instrument (decision 1263): does this realmd enforce
//! `StrictVersionCheck`, and does our logon proof satisfy it?
//!
//! Runs the same handshake **twice against the same server**, differing only in the proof packet's
//! `crc_hash`:
//!
//! - **zeros** — what benilla sent for its whole life before 1263. A strict realmd answers this with
//! `WOW_FAIL_VERSION_INVALID` (0x09) and logs *"tried to login with modified client!"*; 0x09 is the
//! code our login screen renders as `AUTH_VERSION_MISMATCH` / "Wrong client version", which is
//! exactly how B241 was reported.
//! - **computed** — `SHA1(A ‖ H)` with the integrity digest for our build and OS
//! ([`auth::version_proof`]).
//!
//! A non-strict server accepts both arms (it never looks), so the probe reports *what the server
//! enforces* as much as what we send. That is the point: it is the A/B that closes B241 and the one
//! to run against any server a strict-mode rejection is reported from.
//!
//! Usage: `cargo run -p benilla-protocol --example version_check_probe -- <host[:port]> <user> <pass>`
//! (the local strict-mode realmd of decision 1263 is `127.0.0.1:3725`; the ordinary one is 3724.)
use std::net::TcpStream;
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use benilla_protocol::{auth, host_port, AuthReject, AUTH_PORT, CLIENT_BUILD};
use benilla_srp::{NormalizedString, PublicKey, SrpClientChallenge};
/// Which `crc_hash` the arm puts in the proof packet.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Arm {
/// Twenty zeros — pre-1263 benilla. Forced by handing the writer a `crc_salt` we hold no digest
/// for, which is precisely the "no answer" path.
Zeros,
/// `SHA1(A ‖ H)` for the salt the server actually sent.
Computed,
}
impl Arm {
fn label(self) -> &'static str {
match self {
Arm::Zeros => "zeros (pre-1263)",
Arm::Computed => "computed (1263) ",
}
}
}
/// One full handshake, sending `arm`'s `crc_hash`. `Ok(None)` = accepted, `Ok(Some(code))` = the
/// server's `WOW_FAIL_*` byte.
fn attempt(host: &str, port: u16, user: &str, pass: &str, arm: Arm) -> Result<Option<u8>> {
let user_n = NormalizedString::new(user).map_err(|e| anyhow!("invalid username: {e}"))?;
let pass_n = NormalizedString::new(pass).map_err(|e| anyhow!("invalid password: {e}"))?;
// Redial past an ambiguously-serialized `B` exactly as `logon` does, so a 0x04 here means a bad
// password and never an encoding coin-flip (see `benilla-srp`, "Encoding-unambiguous handshakes").
for _ in 0..8 {
let mut stream = TcpStream::connect((host, port))
.with_context(|| format!("connecting to {host}:{port}"))?;
stream.set_read_timeout(Some(Duration::from_secs(10)))?;
auth::write_logon_challenge(&mut stream, &user.to_uppercase(), CLIENT_BUILD)
.context("sending logon challenge")?;
let reply = auth::read_challenge_reply(&mut stream).context("reading challenge reply")?;
let b = PublicKey::from_le_bytes(reply.server_public_key)
.map_err(|e| anyhow!("invalid server public key: {e}"))?;
if !b.is_width_stable() {
continue;
}
let challenge = SrpClientChallenge::new(
user_n,
pass_n,
reply.generator,
reply.large_safe_prime,
b,
reply.salt,
);
let salt = match arm {
Arm::Computed => reply.crc_salt,
Arm::Zeros => [0u8; 16],
};
auth::write_logon_proof(
&mut stream,
challenge.client_public_key(),
challenge.client_proof(),
&salt,
)
.context("sending logon proof")?;
return match auth::read_proof_reply(&mut stream) {
Ok(_) => Ok(None),
Err(e) => match e.downcast_ref::<AuthReject>() {
Some(r) => Ok(Some(r.code)),
None => Err(e),
},
};
}
Err(anyhow!("eight dials, every `B` ambiguous — try again"))
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let [host_arg, user, pass] = match args.as_slice() {
[h, u, p] => [h.clone(), u.clone(), p.clone()],
_ => {
eprintln!("usage: version_check_probe <host[:port]> <user> <pass>");
std::process::exit(2);
}
};
let (host, port) = host_port(&host_arg, AUTH_PORT);
// Report the challenge itself first: our digest is only valid for the one salt every mangos-family
// realmd sends, so a server issuing anything else explains a `zeros` result on the computed arm.
let mut stream =
TcpStream::connect((host, port)).with_context(|| format!("connecting to {host}:{port}"))?;
stream.set_read_timeout(Some(Duration::from_secs(10)))?;
auth::write_logon_challenge(&mut stream, &user.to_uppercase(), CLIENT_BUILD)?;
let crc_salt = auth::read_challenge_reply(&mut stream)?.crc_salt;
drop(stream); // no proof follows, so realmd records nothing for this dial
let hex: String = crc_salt.iter().map(|b| format!("{b:02x}")).collect();
let known = auth::version_proof(&crc_salt, &[0u8; 32]) != [0u8; 20];
println!("{host}:{port} — build {CLIENT_BUILD}, crc_salt {hex}");
println!(
" we {} an integrity digest for that challenge\n",
if known { "HOLD" } else { "hold NO" }
);
for arm in [Arm::Zeros, Arm::Computed] {
let verdict = match attempt(host, port, &user, &pass, arm)? {
None => "ACCEPTED".to_string(),
Some(0x09) => {
"REJECTED 0x09 WOW_FAIL_VERSION_INVALID (\"Wrong client version\")".into()
}
Some(c) => format!("REJECTED {c:#04x}"),
};
println!(" crc_hash {} → {verdict}", arm.label());
}
Ok(())
}
+135 -6
View File
@@ -9,6 +9,7 @@ use std::io::{Read, Write};
use std::net::Ipv4Addr;
use anyhow::{bail, Result};
use sha1::{Digest, Sha1};
use crate::wire::{read_array, read_cstring, read_f32_le, read_u16_le, read_u32_le, read_u8};
use crate::RealmInfo;
@@ -66,6 +67,9 @@ pub struct ChallengeReply {
pub generator: u8,
pub large_safe_prime: [u8; 32],
pub salt: [u8; 32],
/// The 16-byte version challenge the client answers with `crc_hash` — see [`version_proof`].
/// Not an SRP6 input; it seeds the client's binary-integrity digest.
pub crc_salt: [u8; 16],
}
/// Send `CMD_AUTH_LOGON_CHALLENGE_Client`. `account_name` must already be uppercased (it is what the
@@ -126,9 +130,10 @@ pub fn read_challenge_reply(r: &mut impl Read) -> Result<ChallengeReply> {
.map_err(|_| anyhow::anyhow!("large safe prime was {prime_len} bytes, expected 32"))?;
let salt = read_array::<32>(r)?;
// Consume the rest of the packet so the stream stays aligned for the next message — login packets
// are NOT length-framed, so leftover bytes desync the very next read. crc_salt[16] + security_flag
// (+ the PIN block if set) are unused for the SRP6 calculation but must still be read off.
let _crc_salt = read_array::<16>(r)?;
// are NOT length-framed, so leftover bytes desync the very next read. crc_salt[16] feeds the
// integrity proof (not SRP6); security_flag (+ the PIN block if set) is unused but must still be
// read off.
let crc_salt = read_array::<16>(r)?;
let security_flag = read_u8(r)?;
if security_flag & 0x01 != 0 {
// PIN: pin_grid_seed (u32) + pin_salt[16]. (vmangos sends security_flag = 0; handled anyway.)
@@ -140,21 +145,94 @@ pub fn read_challenge_reply(r: &mut impl Read) -> Result<ChallengeReply> {
generator,
large_safe_prime,
salt,
crc_salt,
})
}
/// Send `CMD_AUTH_LOGON_PROOF_Client`. `crc_hash` is the client-binary checksum (vmangos accepts
/// zeros); no telemetry keys, no security flag.
// --- the version (client-integrity) proof ---------------------------------------------------------
//
// The proof packet's `crc_hash` answers the challenge's `crc_salt`: the real client scans its own
// binaries under that salt into a 20-byte digest `H`, and sends `SHA1(A ‖ H)`. realmd recomputes the
// same expression from a stored `H` and rejects a mismatch as a *modified client*
// (vmangos `AuthSocket::VerifyVersion`, cmangos ditto — byte-identical implementations). We sent
// twenty zeros here for the project's whole life, which is invisible until a server turns the check
// on: `StrictVersionCheck = 1` then answers a correct password with `WOW_FAIL_VERSION_INVALID` (0x09,
// our login screen's "Wrong client version") — B241. Note vmangos's own shipped
// `realmd.conf.example` sets 1, so this is a *default* on a fresh repack, not exotic hardening.
/// The 16-byte `crc_salt` every mangos-family realmd sends — a **constant, not a nonce** (vmangos
/// `realmd/AuthSocket.cpp:65` and cmangos `realmd/AuthSocket.cpp:187`, two independent copies of the
/// same bytes). The whole scheme rests on that: because every server issues the same challenge, the
/// client's answer is a per-build constant a server can store without owning the client.
const MANGOS_VERSION_CHALLENGE: [u8; 16] = [
0xba, 0xa3, 0x1e, 0x99, 0xa0, 0x0b, 0x21, 0x57, 0xfc, 0x37, 0x3f, 0xb3, 0x69, 0xcd, 0xd2, 0xf1,
];
/// `H` for build 5875 under [`MANGOS_VERSION_CHALLENGE`], Windows client. Cross-checked between two
/// independent emulator lineages: vmangos ships it as DB data (`sql/migrations/20221117065844_logon.sql`,
/// `allowed_clients` row `1,12,1,'',5875,'Win','x86'`), cmangos as a hardcoded table
/// (`realmd/RealmList.cpp`, `ExpectedRealmdClientBuilds`) — byte-identical.
const INTEGRITY_HASH_5875_WINDOWS: [u8; 20] = [
0x95, 0xed, 0xb2, 0x7c, 0x78, 0x23, 0xb3, 0x63, 0xcb, 0xdd, 0xab, 0x56, 0xa3, 0x92, 0xe7, 0xcb,
0x73, 0xfc, 0xca, 0x20,
];
/// `H` for build 5875 under [`MANGOS_VERSION_CHALLENGE`], Mac client — same two sources, and the one
/// we must answer with on macOS, where [`client_os`] says `OSX` (vmangos keys its lookup on the os
/// field, cmangos picks `MacHash` off the same field).
const INTEGRITY_HASH_5875_MACOS: [u8; 20] = [
0x8d, 0x17, 0x3c, 0xc3, 0x81, 0x96, 0x1e, 0xeb, 0xab, 0xf3, 0x36, 0xf5, 0xe6, 0x67, 0x5b, 0x10,
0x1b, 0xb5, 0x13, 0xe5,
];
/// The 20-byte integrity digest to answer `crc_salt` with, or `None` when we have no answer for that
/// salt.
///
/// We hold `H` only for the one salt above, so a server that issues a *different* one gets zeros
/// rather than a stale constant. That is not defensive padding: replaying a constant computed for
/// another challenge is exactly the tell a randomised challenge would be looking for, and it cannot
/// pass anyway. (No server in the mangos family randomises it — a per-connection salt would make
/// their own stored hashes useless — so this branch is the honest answer to a hypothetical, and free.)
fn integrity_hash(crc_salt: &[u8; 16]) -> Option<[u8; 20]> {
if *crc_salt != MANGOS_VERSION_CHALLENGE {
return None;
}
Some(if client_os() == OS_MACOS {
INTEGRITY_HASH_5875_MACOS
} else {
INTEGRITY_HASH_5875_WINDOWS
})
}
/// The proof packet's `crc_hash`: `SHA1(A ‖ H)` over the wire bytes of `A` (realmd hashes `lp->A`
/// as received, so this is the same 32 bytes we put in the packet). Twenty zeros when we have no
/// `H` for this salt — the pre-B241 behaviour, which a non-strict server ignores.
pub fn version_proof(crc_salt: &[u8; 16], client_public_key: &[u8; 32]) -> [u8; 20] {
match integrity_hash(crc_salt) {
Some(h) => {
let mut sha = Sha1::new();
sha.update(client_public_key);
sha.update(h);
sha.finalize().into()
}
None => [0u8; 20],
}
}
/// Send `CMD_AUTH_LOGON_PROOF_Client`. `crc_salt` is the challenge's — the packet's `crc_hash` is
/// computed here ([`version_proof`]) rather than passed in, so no caller can forget it. No telemetry
/// keys, no security flag.
pub fn write_logon_proof(
w: &mut impl Write,
client_public_key: &[u8; 32],
client_proof: &[u8; 20],
crc_salt: &[u8; 16],
) -> std::io::Result<()> {
let mut packet = Vec::with_capacity(1 + 32 + 20 + 20 + 1 + 1);
packet.push(CMD_AUTH_LOGON_PROOF);
packet.extend_from_slice(client_public_key);
packet.extend_from_slice(client_proof);
packet.extend_from_slice(&[0u8; 20]); // crc_hash
packet.extend_from_slice(&version_proof(crc_salt, client_public_key));
packet.push(0); // number_of_telemetry_keys
packet.push(0); // security_flag = None
w.write_all(&packet)
@@ -242,4 +320,55 @@ mod tests {
assert_eq!(client_os(), OS_WINDOWS);
}
}
/// An arbitrary but fixed `A` for the version-proof vectors below.
fn test_public_key() -> [u8; 32] {
std::array::from_fn(|i| (i as u8).wrapping_mul(7).wrapping_add(9))
}
/// Known answers, computed *outside* this crate (Python `hashlib`) from realmd's own expression
/// `SHA1(A ‖ H)` and the emulator-sourced `H`. A regression here means either constant moved or
/// the hash grew an extra input — both of which read as "modified client" to a strict server and
/// are otherwise invisible until someone tries to log in.
#[test]
fn version_proof_matches_the_realmd_expression() {
let expected = if cfg!(target_os = "macos") {
"dccd0e67946fae221451513b1a59724090ff6096"
} else {
"9b95cd41edd719fddf237294b8aca17010e71703"
};
let got = version_proof(&MANGOS_VERSION_CHALLENGE, &test_public_key());
assert_eq!(
got.iter().map(|b| format!("{b:02x}")).collect::<String>(),
expected
);
}
/// A salt we hold no `H` for is answered with zeros, not with the constant for another salt.
#[test]
fn an_unknown_version_challenge_is_answered_with_zeros() {
let mut salt = MANGOS_VERSION_CHALLENGE;
salt[0] ^= 0xff;
assert_eq!(version_proof(&salt, &test_public_key()), [0u8; 20]);
}
/// The proof packet carries the answer at the offset realmd reads it from: `opcode · A[32] ·
/// M1[20] · crc_hash[20] · num_keys · security_flag`, 75 bytes total.
#[test]
fn the_proof_packet_carries_the_version_proof() {
let a = test_public_key();
let m1: [u8; 20] = std::array::from_fn(|i| (i as u8).wrapping_mul(13).wrapping_add(4));
let mut packet = Vec::new();
write_logon_proof(&mut packet, &a, &m1, &MANGOS_VERSION_CHALLENGE).unwrap();
assert_eq!(packet.len(), 1 + 32 + 20 + 20 + 1 + 1);
assert_eq!(packet[0], CMD_AUTH_LOGON_PROOF);
assert_eq!(&packet[1..33], &a);
assert_eq!(&packet[33..53], &m1);
assert_eq!(
&packet[53..73],
&version_proof(&MANGOS_VERSION_CHALLENGE, &a)
);
assert_eq!(&packet[73..], &[0, 0]);
}
}
+1
View File
@@ -128,6 +128,7 @@ pub fn logon(host: &str, username: &str, password: &str) -> Result<Logon> {
&mut stream,
challenge.client_public_key(),
challenge.client_proof(),
&reply.crc_salt,
)
.context("sending logon proof")?;
+9 -1
View File
@@ -8,6 +8,13 @@ use benilla_protocol::auth;
const N: [u8; 32] = benilla_srp::LARGE_SAFE_PRIME_LITTLE_ENDIAN;
/// The `crc_salt` a real mangos-family realmd sends (its fixed `VersionChallenge`). The fixture uses
/// the live bytes rather than zeros, so the reader meets what it actually meets — and so the `got
/// 0xba` in the proof-read comment below names a real byte of this array.
const CRC_SALT: [u8; 16] = [
0xba, 0xa3, 0x1e, 0x99, 0xa0, 0x0b, 0x21, 0x57, 0xfc, 0x37, 0x3f, 0xb3, 0x69, 0xcd, 0xd2, 0xf1,
];
/// A successful `CMD_AUTH_LOGON_CHALLENGE_Server` body, including the `crc_salt` + `security_flag`
/// tail that must be consumed.
fn challenge_packet(server_public_key: &[u8; 32], salt: &[u8; 32]) -> Vec<u8> {
@@ -18,7 +25,7 @@ fn challenge_packet(server_public_key: &[u8; 32], salt: &[u8; 32]) -> Vec<u8> {
p.push(32); // large-safe-prime length
p.extend_from_slice(&N);
p.extend_from_slice(salt);
p.extend_from_slice(&[0u8; 16]); // crc_salt
p.extend_from_slice(&CRC_SALT);
p.push(0); // security_flag = None
p
}
@@ -72,6 +79,7 @@ fn logon_read_sequence_stays_aligned() {
assert_eq!(reply.generator, 7);
assert_eq!(reply.large_safe_prime, N);
assert_eq!(reply.salt, salt);
assert_eq!(reply.crc_salt, CRC_SALT); // the proof's `crc_hash` is computed from this
// 2. Proof — this is exactly where a challenge-reply under-read used to surface (`got 0xba`).
let proof =
+1 -1
View File
@@ -7,5 +7,5 @@ description = "WoW 1.12.1 (build 5875) SRP6 client + vanilla world-header crypto
[dependencies]
num-bigint = "0.4.6"
sha1 = "0.10.6"
sha1.workspace = true
rand = "0.8.6"
@@ -447,3 +447,88 @@ mod tests {
assert_eq!(app.world().resource::<RigPalettes>().occupancy().0, 0);
}
}
/// The Bevy contract this lane's `Aabb` depends on (decision 1261), pinned against the real
/// `calculate_bounds` system rather than a reading of it.
///
/// `promote_lazy_rig`/`demote_lazy_rig` write `Mesh3d`. Bevy's `calculate_bounds` runs two
/// queries — an inserting one filtered `Without<Aabb>`, and an **updating** one filtered
/// `Or<(AssetChanged<Mesh3d>, Changed<Mesh3d>)>` that overwrites a bound the app authored. So the
/// authored all-animation bound decision 1259 puts on an animated placement survives exactly as
/// long as nothing swaps that placement's mesh — i.e. until its first draw-gate wake, at which
/// point it was silently recomputed from the skinned twin's bind-pose geometry and the birds
/// resumed blinking. `NoAutoAabb` (which both queries exclude) is the fix; this is the guard that
/// a Bevy upgrade cannot quietly take it back.
#[cfg(test)]
mod bound_survives_the_twin_swap {
use super::*;
use bevy::camera::primitives::Aabb;
use bevy::camera::visibility::NoAutoAabb;
/// A 1 yd cube — what the skinned twin's bind-pose geometry computes to.
fn tiny_mesh() -> Mesh {
Mesh::from(bevy::math::primitives::Cuboid::new(1.0, 1.0, 1.0))
}
/// The authored all-animation bound: a bird's 67 yd circuit around that 1 yd body.
fn authored() -> Aabb {
Aabb::from_min_max(Vec3::new(-4.2, 8.8, -30.5), Vec3::new(13.6, 16.0, 36.6))
}
/// `(app, entity)` with Bevy's own bounds system on the schedule and the authored bound in
/// place, mid-swap: `Mesh3d` has just been rewritten the way `promote_lazy_rig` rewrites it.
fn swapped(guard: bool) -> (App, Entity) {
let mut app = App::new();
app.add_plugins((bevy::MinimalPlugins, AssetPlugin::default()));
app.init_asset::<Mesh>();
app.add_systems(Update, bevy::camera::visibility::calculate_bounds);
let stat = app
.world_mut()
.resource_mut::<Assets<Mesh>>()
.add(tiny_mesh());
let skinned = app
.world_mut()
.resource_mut::<Assets<Mesh>>()
.add(tiny_mesh());
let e = app.world_mut().spawn((Mesh3d(stat), authored())).id();
if guard {
app.world_mut().entity_mut(e).insert(NoAutoAabb);
}
app.update();
// The swap the lazy rig performs at the placement's first wake.
app.world_mut().entity_mut(e).get_mut::<Mesh3d>().unwrap().0 = skinned;
app.update();
(app, e)
}
fn bound(app: &App, e: Entity) -> Aabb {
*app.world().entity(e).get::<Aabb>().expect("a bound")
}
/// The bug: unguarded, the twin swap hands the authored bound back to `compute_aabb`.
#[test]
fn an_unguarded_bound_is_clobbered_by_the_mesh_swap() {
let (app, e) = swapped(false);
let b = bound(&app, e);
assert!(
b.half_extents.max_element() < 1.0,
"expected the 1 yd cube's own bound, got half-extents {:?} — if this now keeps the \
authored box, Bevy changed `calculate_bounds` and `NoAutoAabb` may be unnecessary",
b.half_extents
);
}
/// The fix: guarded, the authored bound is still the authored bound after the swap.
#[test]
fn a_guarded_bound_survives_the_mesh_swap() {
let (app, e) = swapped(true);
let b = bound(&app, e);
assert!(
(Vec3::from(b.min()) - Vec3::from(authored().min())).length() < 1e-3
&& (Vec3::from(b.max()) - Vec3::from(authored().max())).length() < 1e-3,
"the authored all-animation bound must survive the promote, got {:?}..{:?}",
b.min(),
b.max()
);
}
}
+10
View File
@@ -976,6 +976,16 @@ pub fn plugin(app: &mut App) {
.after(crate::wmo_portal::WmoPvsSet)
.in_set(ModelVisSet),
);
// `WOW_VIS_TRACE=<label>`: watch one model's placements decide (see `visibility::VisTrace`).
// The resource is absent unless the env var names something, and the system's first line is
// the `None` check — so an ordinary run pays one `Option` read per frame.
if let Some(trace) = visibility::VisTrace::from_env() {
app.insert_resource(trace);
}
app.add_systems(
Update,
visibility::trace_model_visibility.after(ModelVisSet),
);
// The material dedup cache and its two evictors — the soft one (distance, decision 0785) and
// the hard one (a cross-map transition, decision 0729). Both used to live in `entities`, which
// owned the cache because the entity spawner was the first lane to want one; the glue booth
@@ -271,3 +271,113 @@ pub(super) fn apply_model_visibility(
}
}
}
/// `WOW_VIS_TRACE=<label-substring>` — **watch one model's placements decide, frame by frame.**
///
/// The `VIS_CENSUS` line counts what is drawn; it cannot answer "that thing vanished when I turned
/// — who dropped it?". Two different systems can hide a model submesh and they fail in opposite
/// directions:
///
/// - **`vis=Hidden`** — [`apply_model_visibility`] above said no: a toggle, the far-clip wall, a
/// fully-faded doodad, an `A ≤ 0` material track, the portal PVS or the exterior window gate.
/// - **`vis=Inherited view=false`** — *we* admitted it and **Bevy's frustum cull** dropped it,
/// which means the entity's `Aabb` does not describe what it draws.
///
/// That second line is the whole of decisions 1259 and 1261 — an animated placement whose bound was
/// its bind pose, and then the same bound silently recomputed at the twin swap — and both were
/// diagnosed by reading code because nothing could be asked. `bound=` is printed in **world space**
/// beside the camera, so "the box is nowhere near the bird" is visible directly rather than inferred.
///
/// Off (the env var unset) this system is one `Option` check per frame and no query iteration.
/// Throttled to [`VIS_TRACE_HZ`]; matching is a case-insensitive substring of the `WorldObject`
/// label (a model path), so `WOW_VIS_TRACE=bird01` follows every bird in residency.
#[derive(Resource)]
pub struct VisTrace {
needle: String,
next_at: f32,
}
/// Trace lines per second — enough to watch a verdict flip under a camera swing, sparse enough that
/// a dozen placements don't bury the log.
const VIS_TRACE_HZ: f32 = 4.0;
impl VisTrace {
/// `None` unless `WOW_VIS_TRACE` names a substring — the whole cost of the instrument when off.
pub(crate) fn from_env() -> Option<Self> {
std::env::var("WOW_VIS_TRACE")
.ok()
.filter(|s| !s.is_empty())
.map(|needle| Self {
needle: needle.to_ascii_lowercase(),
next_at: 0.0,
})
}
}
/// What the trace reads per submesh: its identity, where it is, both verdicts, and the bound the
/// second of them tested.
type TracedModels<'w, 's> = Query<
'w,
's,
(
Entity,
&'static crate::interact::WorldObject,
&'static GlobalTransform,
&'static Visibility,
&'static ViewVisibility,
Option<&'static Aabb>,
),
>;
/// The trace's printer — see [`VisTrace`]. Runs after the authority so `vis` is this frame's
/// verdict, and reads `ViewVisibility` (last frame's frustum result, which is the freshest a
/// same-frame reader can have) to separate our gates from the cull.
pub(super) fn trace_model_visibility(
time: Res<Time>,
trace: Option<ResMut<VisTrace>>,
cam: Query<&GlobalTransform, With<WorldCamera>>,
q: TracedModels,
) {
let Some(mut trace) = trace else { return };
let now = time.elapsed_secs();
if now < trace.next_at {
return;
}
trace.next_at = now + 1.0 / VIS_TRACE_HZ;
let cam_t = cam.iter().next();
let (cam_pos, cam_fwd) = cam_t.map_or((Vec3::ZERO, Vec3::Z), |t| {
(t.translation(), Vec3::from(t.forward()))
});
for (e, object, xf, vis, view, aabb) in &q {
if !object.label.to_ascii_lowercase().contains(&trace.needle) {
continue;
}
// World-space bound: the entity transform applied to the local box, which is exactly what
// Bevy's cull tests — so a bound that has parted company with the geometry shows up here as
// a centre nowhere near what the eye sees.
let (centre, radius) = match aabb {
Some(a) => (
xf.transform_point(Vec3::from(a.center)),
Vec3::from(a.half_extents).length() * xf.affine().matrix3.x_axis.length(),
),
None => (xf.translation(), f32::NAN),
};
let depth = (centre - cam_pos).dot(cam_fwd);
println!(
"VIS_TRACE {e} {label} vis={vis:?} view={view} origin=[{ox:.1},{oy:.1},{oz:.1}] \
bound=[{cx:.1},{cy:.1},{cz:.1}] r={radius:.1} depth={depth:.1} \
cam=[{px:.1},{py:.1},{pz:.1}]",
label = object.label,
view = view.get(),
ox = xf.translation().x,
oy = xf.translation().y,
oz = xf.translation().z,
cx = centre.x,
cy = centre.y,
cz = centre.z,
px = cam_pos.x,
py = cam_pos.y,
pz = cam_pos.z,
);
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ pub use spawn::prop_light::{fold_interior_probe, PropLobeLight};
// doodad-prop path's spawner (`crate::entities`' `wmo_props`: the ship's sails ride the streamed
// gameobject entity, and its cargo hulls ride the boat's kinematic body).
pub use collider::{build_collider_task, placement_collider_data, PendingCollider};
pub use spawn::{m2_fade, point_light, spawn_model_entities};
pub use spawn::{m2_anim_bound, m2_fade, point_light, spawn_model_entities};
// The position queries + area authority (their home is `queries`; paths stay `terrain_stream::X`).
use queries::update_current_area;
pub use queries::{
@@ -6,6 +6,8 @@
use benilla_assets::{M2Model, ModelSubmesh};
use benilla_formats::ModelBlend;
use bevy::camera::primitives::Aabb;
use bevy::camera::visibility::NoAutoAabb;
use bevy::mesh::MeshTag;
use bevy::prelude::*;
use bevy::render::render_resource::Buffer;
@@ -73,6 +75,12 @@ pub fn spawn_model_entities(
interior_slot: Option<u16>,
radius: f32,
local_center: Vec3,
// The model's authored **all-animation** bound in Bevy model-local space
// ([`super::m2_anim_bound`]) — what an ANIMATED placement's submeshes are culled with instead of
// their bind-pose mesh bound, which the joint palette has left behind (decision 1259). `None` for
// WMO group geometry (no M2 header) and for a model with no authored box; a static placement
// ignores it and keeps the tighter per-batch bound.
anim_bound: Option<Aabb>,
// `Some((model, now))` for an M2 placement: the doodad-animation gate (decision 0130) — an
// animated model spawns joints + the skinned twins; `now` is the clock origin. `None` for WMO
// group geometry.
@@ -300,9 +308,11 @@ pub fn spawn_model_entities(
));
// The build-time Aabb, inserted explicitly: the static form is `RENDER_WORLD`-only,
// so Bevy's `calculate_bounds` can race extraction — and the exterior cull fails
// OPEN on a missing bound (0832's rule, extended to the model lane).
// OPEN on a missing bound (0832's rule, extended to the model lane). `NoAutoAabb`
// says the bound is OURS: see the ordinary-part insert below for what derives it
// otherwise, and what that cost.
if let Some(aabb) = stat_aabb {
card_entity.insert(*aabb);
card_entity.insert((*aabb, NoAutoAabb));
}
(card_entity.id(), Vec3::ZERO)
} else {
@@ -329,10 +339,28 @@ pub fn spawn_model_entities(
mesh_tag,
));
// Every part now spawns static, so every part carries the build-time Aabb (the
// RENDER_WORLD rule above). It stays through a promote: the skinned twin shares the
// bind-pose geometry, so the bound is the same one `calculate_bounds` used to derive.
if let Some(aabb) = stat_aabb {
part_entity.insert(*aabb);
// RENDER_WORLD rule above).
//
// For an ANIMATED placement that bind-pose bound is a lie, because the joints move the
// vertices while this entity's transform stays at the placement origin. Widen to the
// model's authored all-animation box (decision 1259) — the union, never the replacement,
// so the 152 corpus models whose authored box does not fully contain their own bind pose
// still bound their geometry. A billboard card is exempt: its entity transform FOLLOWS
// its joint every frame, so its own small bound travels with what it draws.
//
// **`NoAutoAabb` is what makes that bound survive** (decision 1261). Bevy's
// `calculate_bounds` runs TWO queries: one that inserts a bound where there is none,
// and one that **overwrites an existing bound** on `Changed<Mesh3d>`. The lazy rig
// swaps `Mesh3d` static → skinned at this placement's first draw-gate wake
// (`doodad_anim::lazy`), and the skinned twin shares the BIND-POSE geometry — so the
// second query recomputed the very box 1259 had just widened, and the birds went back
// to blinking a second after they streamed in. The bound here is authored, not derived;
// this component is how that is said.
if let Some(aabb) = cull_bound(
stat_aabb.as_ref(),
skin.is_some().then_some(anim_bound).flatten(),
) {
part_entity.insert((aabb, NoAutoAabb));
}
if let Some(sm) = skinned_mesh {
part_entity.insert(crate::doodad_anim::SkinnedTwin {
@@ -426,3 +454,73 @@ pub fn spawn_model_entities(
.map(|((joints, _), root)| PlacementHost { joints, root, arm }),
)
}
/// The `Aabb` a placed submesh is culled with: its build-time bind-pose bound, widened by the
/// model's authored all-animation box when this placement animates (decision 1259).
///
/// It is a **union**, not a swap, for one measured reason: 152 of the 9315 M2s that carry geometry
/// author a header box that does not fully contain their own bind-pose vertices (worst case
/// `Spells\Teleport.m2`, 19.9 yd short). The reference never notices — it tests the box's
/// *circumsphere*, which swallows the shortfall — so a bare swap would be the one way this change
/// could cull geometry the reference draws.
fn cull_bound(stat: Option<&Aabb>, anim: Option<Aabb>) -> Option<Aabb> {
match (stat, anim) {
(Some(s), Some(a)) => Some(Aabb::from_min_max(
Vec3::from(s.min().min(a.min())),
Vec3::from(s.max().max(a.max())),
)),
(Some(s), None) => Some(*s),
(None, a) => a,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The bug's own numbers (`benilla-extract animboundscan`, `World\critter\birds\Bird01.m2`):
/// a 1.2 × 1.8 × 0.23 yd bind-pose box, and an authored all-animation box 67 × 18 × 7 yd that
/// reaches ~37 yd past it. Culled by the bind pose, the bird is dropped while its drawn
/// geometry is still on screen.
#[test]
fn an_animated_placements_bound_covers_the_whole_flight_path() {
// Bevy-space conversions of Bird01's two boxes (WoW (x,y,z) -> Bevy (-y, z, -x)).
let bind = Aabb::from_min_max(
Vec3::new(-0.863, 9.359, -3.536),
Vec3::new(0.915, 9.586, -2.366),
);
let authored = Aabb::from_min_max(
Vec3::new(-4.219, 8.816, -30.547),
Vec3::new(13.571, 16.019, 36.605),
);
let widened = cull_bound(Some(&bind), Some(authored)).expect("a bound");
// Every corner of the authored box is inside the widened bound. `Aabb` stores
// centre/half-extents, so the reconstructed corners carry a float epsilon — hence the
// tolerance, which is ~7 orders of magnitude below anything a cull can see.
const EPS: f32 = 1e-4;
assert!((widened.min() - EPS).cmple(authored.min()).all());
assert!((widened.max() + EPS).cmpge(authored.max()).all());
// …and the bind-pose bound alone was ~37 yd short of it along the flight axis.
assert!(authored.max().z - bind.max().z > 36.0);
}
/// The union arm, not a swap: an authored box that fails to contain the bind pose (152 corpus
/// models do) must not shrink the bound.
#[test]
fn a_short_authored_box_never_shrinks_the_bound() {
let bind = Aabb::from_min_max(Vec3::splat(-5.0), Vec3::splat(5.0));
let authored = Aabb::from_min_max(Vec3::new(-40.0, -1.0, -1.0), Vec3::new(40.0, 1.0, 1.0));
let b = cull_bound(Some(&bind), Some(authored)).expect("a bound");
assert!(Vec3::from(b.min()).abs_diff_eq(Vec3::new(-40.0, -5.0, -5.0), 1e-4));
assert!(Vec3::from(b.max()).abs_diff_eq(Vec3::new(40.0, 5.0, 5.0), 1e-4));
}
/// A static placement is untouched — it keeps the tighter per-batch bind-pose bound.
#[test]
fn a_static_placement_keeps_its_per_batch_bound() {
let bind = Aabb::from_min_max(Vec3::splat(-1.0), Vec3::splat(1.0));
let b = cull_bound(Some(&bind), None).expect("a bound");
assert!(Vec3::from(b.min()).abs_diff_eq(Vec3::splat(-1.0), 1e-4));
assert!(Vec3::from(b.max()).abs_diff_eq(Vec3::splat(1.0), 1e-4));
}
}
@@ -17,6 +17,7 @@ use std::time::Instant;
use benilla_assets::coords::{wmo_doodad_local, wow_to_bevy};
use benilla_assets::{AdtTile, DoodadBase, M2Model, WmoModel};
use benilla_formats::{world_to_tile, M2Bounds};
use bevy::camera::primitives::Aabb;
use bevy::prelude::*;
use crate::collision::{camera_layers, walk_layers, GroundDecalSurface, PickOccluder};
@@ -152,6 +153,7 @@ pub(super) fn spawn_loaded_placements(
ShadeResolve::Pending => continue,
};
let (radius, center) = m2_fade(&m.bounds, p.transform.scale.x);
let anim_bound = m2_anim_bound(&m.bounds);
let (mut ents, host) = spawn_model_entities(
&mut commands,
mat_cache,
@@ -168,6 +170,7 @@ pub(super) fn spawn_loaded_placements(
None, // map doodad: exterior sky lighting (no interior probe)
radius,
center,
anim_bound,
Some((m, now)),
&mut uv_reg,
&mut tint_reg,
@@ -270,6 +273,7 @@ pub(super) fn spawn_loaded_placements(
None, // WMO groups carry their own per-submesh interior flag + batch class
f32::INFINITY,
Vec3::ZERO,
None, // …and no authored M2 box: group geometry never animates
None, // WMO group geometry is not an M2 — its doodad props animate below
&mut uv_reg,
&mut tint_reg,
@@ -509,6 +513,7 @@ pub(super) fn spawn_loaded_placements(
}
};
let (radius, center) = m2_fade(&m.bounds, d.transform.scale.x);
let anim_bound = m2_anim_bound(&m.bounds);
// An interior prop's committed light, folded ONCE into its SH probe (the reference folds
// at doodad create + per-frame light commit; everything here is static, so once): the
// MODD-colour ambient + the diffuse lobe on the fixed interior axis + the owning group's
@@ -552,6 +557,7 @@ pub(super) fn spawn_loaded_placements(
interior_slot, // interior props light off their folded probe, not the sky
radius,
center,
anim_bound,
Some((m, now)),
&mut uv_reg,
&mut tint_reg,
@@ -757,6 +763,35 @@ fn tag_world_object(
}
}
/// The **animated cull bound** for an M2 placement: the model's authored header bounding box
/// (`M2Header.bounding_box_min/max`), in Bevy model-local space. `None` when the model carries no
/// authored bounds.
///
/// This box is the model's **all-animation vertex extent**, not its bind pose — which is exactly why
/// it is the bound an *animated* placement must be culled with. A bind-pose mesh bound is the extent
/// of geometry the entity transform no longer describes: the joint palette moves the vertices while
/// the entity stays parked at the placement origin, so the cull tests an empty box. `World\critter\
/// birds\Bird01.m2` is the extreme: a 1.2 × 1.8 × 0.23 yd bind-pose box, and a root-bone translation
/// track that flies the bird 64 yd along X and 17 yd along Y away from it. Culled by the bind pose,
/// the bird blinks out whenever that 1-yd box leaves the frustum while the bird itself is still on
/// screen — the director's "birds in the sky often dis/appear based on the cam angle" (decision 1259).
///
/// The reference tests one volume per doodad *object* and derives it from these same header fields:
/// `0x683700` calls `0x682ef0(ecx = &[rec+0x5c] centre, [rec+0x68] radius)` → `0x686b80`, a 6-plane
/// frustum **sphere** test, where `rec+0x5c` is the transformed `(min+max)/2` and `rec+0x68` is
/// `bounding_sphere_radius × scale` (wow-5875-re `terrain/scratch/doodad-emitter-drawset-gate.md`
/// §1c/§2a, VERIFIED; the same two fields [`m2_fade`] already reads). We keep the **box** rather than
/// its circumsphere: it bounds the same geometry more tightly, and Bevy's cull is an OBB test anyway.
pub fn m2_anim_bound(bounds: &Option<M2Bounds>) -> Option<Aabb> {
let b = bounds.as_ref()?;
// The basis swap permutes and negates axes, so min/max have to be re-derived, not mapped.
let (a, c) = (wow_to_bevy(b.bbox_min), wow_to_bevy(b.bbox_max));
let (lo, hi) = (a.min(c), a.max(c));
// A model that authors a degenerate (all-zero) box would otherwise pin every submesh of an
// animated placement to a point at the origin — strictly worse than the bind pose it replaces.
(hi.cmpgt(lo).any()).then(|| Aabb::from_min_max(lo, hi))
}
/// The distance-fade size for an M2: world radius = authored bounding-sphere radius × placement scale;
/// centre = authored bbox centre in Bevy model-local space. Absent bounds ⇒ never size-fade.
pub fn m2_fade(bounds: &Option<M2Bounds>, scale: f32) -> (f32, Vec3) {