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