cooldown pies, warmed shaders, and a tamer Frost Nova glow

- the spellbook draws the cooldown pie on a spell that's cooling down, and the active form's slot lights its checked ring
- shader variants warm at startup (with a tripwire if one ever compiles live), so a first cast no longer hitches on shader compile
- additive glow stacks clamp the way the original scene does, taming Frost Nova's blown-out mist, and shoulder cards split only at rigidly separable bones
- the login handshake now reads the same under both SRP encodings, and cooldown events no longer race the net step

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-31 17:01:30 +07:00
co-authored by Claude Fable 5
parent 167f80d92d
commit ab65981ebf
33 changed files with 2707 additions and 347 deletions
Generated
+3
View File
@@ -675,6 +675,9 @@ dependencies = [
"benilla-srp",
"clap",
"flate2",
"num-bigint",
"rand 0.8.6",
"sha1",
]
[[package]]
@@ -473,6 +473,24 @@ pub fn m2anim(chain: &mut Chain, internal_path: &str) -> Result<()> {
println!(" color {i}: interp {}, keys {:?}", t.interp, t.keys);
}
}
// The RGB half of the same M2Colors: the per-batch tint the client multiplies into the vertex
// colour. An effect that reads white where the reference reads coloured is usually visible
// right here (the Frost Nova purple-mist diagnosis).
if m.color_rgb_tracks.iter().any(|t| t.keys.len() > 1) {
println!("=== color rgb tracks (per M2Color) ===");
for (i, t) in m.color_rgb_tracks.iter().enumerate() {
let keys: Vec<String> = t
.keys
.iter()
.map(|(ms, v)| format!("{ms} ms [{:.3} {:.3} {:.3}]", v[0], v[1], v[2]))
.collect();
println!(
" color {i}: interp {}, keys [{}]",
t.interp,
keys.join(", ")
);
}
}
if !m.transparency_tracks.is_empty() {
println!("=== transparency (texture-weight) tracks ===");
for (i, t) in m.transparency_tracks.iter().enumerate() {
@@ -663,6 +681,78 @@ fn winding(s: &benilla_formats::RenderSubmesh) -> Option<String> {
))
}
/// Which BONES a batch's vertices actually ride, and — for a billboard batch — whether the batch is
/// a rigid card at all.
///
/// The reference skins **per vertex**: every vertex is placed by its own (up to four) bone matrices
/// and weights, so a batch that straddles a billboard bone and a static one *deforms* — the static
/// end stays welded to the body while the billboard end swings to the camera. benilla instead splits
/// a batch into per-billboard-bone submeshes keyed on each **triangle's first vertex** and rotates
/// each group rigidly about that bone's pivot. The two agree only when a group's vertices are all
/// 100% on the one billboard bone; where they aren't, we tear geometry off the model that the
/// reference keeps attached. `MIXED` is exactly that condition, and `SPLIT-W` the softer form (a
/// vertex blended across bones, which a rigid group cannot express at all).
fn skin_census(s: &benilla_formats::RenderSubmesh) -> Option<String> {
if s.joints.is_empty() {
return None;
}
// Vertices per PRIMARY bone (`bone_indices[0]` — the key our batch split groups on), in
// ascending bone order so two models' lines compare by eye.
let mut per_bone: std::collections::BTreeMap<u16, usize> = std::collections::BTreeMap::new();
for j in &s.joints {
*per_bone.entry(j[0]).or_default() += 1;
}
let split = s
.weights
.iter()
.filter(|w| w[0] < 0.999 && w[0] > 0.0)
.count();
let bones = per_bone
.iter()
.map(|(b, n)| format!("bone {b}×{n}"))
.collect::<Vec<_>>()
.join(" ");
let mut verdict = String::new();
if let Some(bb) = &s.billboard {
let off = s.joints.len() - per_bone.get(&bb.bone).copied().unwrap_or(0);
if off > 0 {
verdict.push_str(&format!(
" MIXED: {off}/{} verts off billboard bone {}",
s.joints.len(),
bb.bone
));
}
}
if split > 0 {
verdict.push_str(&format!(" SPLIT-W: {split} verts blended across bones"));
}
// The distinct skin TUPLES with their vertex counts — a rigid group is one tuple at weight 1;
// anything else names the seam the split has to cut along. Capped so a 300-vert creature batch
// can't drown the dump (the question only ever has a handful of answers on a card-sized batch).
let mut tuples: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
for (j, w) in s.joints.iter().zip(&s.weights) {
let key = (0..4)
.filter(|&i| w[i] > 0.0)
.map(|i| format!("{}@{:.2}", j[i], w[i]))
.collect::<Vec<_>>()
.join("+");
*tuples.entry(key).or_default() += 1;
}
let detail = if tuples.len() <= 8 {
format!(
"\n skin tuples: {}",
tuples
.iter()
.map(|(k, n)| format!("[{k}]×{n}"))
.collect::<Vec<_>>()
.join(" ")
)
} else {
format!("\n skin tuples: {} distinct (elided)", tuples.len())
};
Some(format!("skin: {bones}{verdict}{detail}"))
}
pub fn m2batch(chain: &mut Chain, internal_path: &str) -> Result<()> {
let name = normalize(internal_path);
let data = chain
@@ -842,6 +932,11 @@ pub fn m2batch(chain: &mut Chain, internal_path: &str) -> Result<()> {
if let Some(w) = winding(s) {
println!(" {w}");
}
// Which bones the batch's vertices ride — and whether a billboard group is a rigid card or
// a strip we tore off a static neighbour (see [`skin_census`]).
if let Some(k) = skin_census(s) {
println!(" {k}");
}
}
Ok(())
}
@@ -173,9 +173,13 @@ pub fn bbfacescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
})
.collect();
let (mut scanned, mut cards) = (0u32, 0u32);
// The four populations. `away_single` is the one the renderer's forced-two-sided override
// The four card populations. `away_single` is the one the renderer's forced-two-sided override
// changes: those and only those become visible when a card is not allowed to be culled.
let (mut toward, mut away_single, mut away_two, mut edge_on) = (0u32, 0u32, 0u32, 0u32);
// …and the batches that are not cards at all. A facing only exists for a batch that IS one
// plane; a closed solid has faces every way round, backface culling never hides it, and
// sampling its first triangle answers a question it doesn't have (see `plane_normal`).
let mut solid = 0u32;
for name in names {
let Ok(bytes) = chain.read_file(&name) else {
continue;
@@ -189,6 +193,11 @@ pub fn bbfacescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
for (i, s) in subs.iter().enumerate() {
let Some(bb) = &s.billboard else { continue };
cards += 1;
// Not one plane ⇒ not a card. Counted apart rather than classified by a triangle.
if s.plane_normal().is_none() {
solid += 1;
continue;
}
// The winding normal's X component: +1 faces the viewer, −1 faces away.
let Some(fx) = facet_x(s) else {
edge_on += 1;
@@ -223,7 +232,7 @@ pub fn bbfacescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
eprintln!(
"{scanned} models scanned, {cards} billboard batch(es): {toward} toward, \
{away_single} away+single-sided (reference culls these), {away_two} away+two-sided, \
{edge_on} edge-on/degenerate"
{edge_on} edge-on/degenerate, {solid} NOT PLANAR (3-D geometry — no facing to decide)"
);
Ok(())
}
@@ -487,6 +496,8 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
// …and the effect riders (particles/ribbons on a billboard chain).
let mut fx_models = 0u32;
let mut fx_total: HashMap<String, u32> = HashMap::new();
// …and the seam population (see the classification below).
let (mut seam_models, mut seam_bones) = (0u32, 0u32);
for name in names {
let Ok(bytes) = chain.read_file(&name) else {
continue;
@@ -531,6 +542,13 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
None => {}
}
}
// SEAM bones: billboard bones whose geometry is welded to the rest of the model — by a
// partial vertex weight, or by a triangle it shares with a static neighbour. The reference
// skins per vertex, so such geometry BENDS (a flap's root stays on the body while its tip
// swings to the camera); a rigid card cannot express that at all, and moving the group
// rigidly tears the flap in two. These are the bones the split declines (decision 0839),
// read through the renderer's own predicate so this census cannot drift from it.
let seam = benilla_formats::non_separable_billboard_bones(&bytes);
let fmt_counts = |m: &HashMap<&str, u32>| {
let mut v: Vec<String> = m.iter().map(|(k, n)| format!("{k}:{n}")).collect();
v.sort();
@@ -565,8 +583,21 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
v.join(" ")
};
let bones: String = kinds.iter().flatten().map(|&k| arm(k)).collect();
let seam_col = if seam.is_empty() {
String::new()
} else {
seam_models += 1;
seam_bones += seam.len() as u32;
format!(
" SEAM[{}]",
seam.iter()
.map(u16::to_string)
.collect::<Vec<_>>()
.join(",")
)
};
println!(
"{bones:>4} direct[{}] inherited[{}] fx[{fx_counts}] {name}",
"{bones:>4} direct[{}] inherited[{}] fx[{fx_counts}]{seam_col} {name}",
fmt_counts(&direct),
fmt_counts(&inherited)
);
@@ -608,7 +639,7 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
v.join(" ")
};
eprintln!(
"{scanned} models scanned, {hits} with billboard bones; models by arm — direct(card) [{}] inherited(palette) [{}]; {fx_models} with EFFECTS on a billboard chain [{fx_tot}]",
"{scanned} models scanned, {hits} with billboard bones; models by arm — direct(card) [{}] inherited(palette) [{}]; {fx_models} with EFFECTS on a billboard chain [{fx_tot}]; {seam_models} with SEAM billboard bones ({seam_bones} total) — geometry welded to the model, which a rigid card tears",
tot(&direct_models),
tot(&inherited_models)
);
+14 -14
View File
@@ -151,20 +151,20 @@ pub use models::{
accumulate_wmo_group_collision, hand_grip_finger_poses, load_m2_animation_summary,
load_m2_bounds, load_m2_collision_hull, load_m2_mesh, load_m2_mesh_skinned, load_object_model,
load_wmo, load_wmo_collision_tris, m2_owner_reach, m2_ribbon_emitter_count,
m2_texture_transform_count, owner_last_rung, parse_m2_animation_lookup,
parse_m2_animation_summary, parse_m2_animations, parse_m2_attachments, parse_m2_bounds,
parse_m2_camera, parse_m2_collision_hull, parse_m2_event_markers,
parse_m2_global_sequence_bones, parse_m2_lights, parse_m2_playable_animation_lookup,
parse_m2_portrait_camera, parse_m2_render_submeshes, parse_m2_skeleton,
parse_m2_string_anchors, parse_wmo_fogs, parse_wmo_lights, parse_wmo_portals, parse_wmo_root,
wmo_group_doodad_refs, wmo_group_footprint_tris, wmo_group_header, wmo_group_light_refs,
wmo_group_liquid_mesh, wmo_group_submeshes, wmo_root_id, AlphaAnim, AlphaSeq, AnimEvent,
Billboard, BillboardKind, BoneKeys, BoneScaleAnim, CharSkinSlot, CollisionMesh,
EmitterBoneLink, EventMarker, FogPolicy, FootprintTris, GlobalSeqBone, GlobalSeqChannel,
GroundQuad, M2AnimSummary, M2Attachment, M2Bounds, M2Light, M2PortraitCamera, ModelAnimation,
ModelBlend, PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim, Skeleton, SkeletonBone,
StringAnchors, UvAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog, WmoGroupHeader,
WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot,
m2_texture_transform_count, non_separable_billboard_bones, owner_last_rung,
parse_m2_animation_lookup, parse_m2_animation_summary, parse_m2_animations,
parse_m2_attachments, parse_m2_bounds, parse_m2_camera, parse_m2_collision_hull,
parse_m2_event_markers, parse_m2_global_sequence_bones, parse_m2_lights,
parse_m2_playable_animation_lookup, parse_m2_portrait_camera, parse_m2_render_submeshes,
parse_m2_skeleton, parse_m2_string_anchors, parse_wmo_fogs, parse_wmo_lights,
parse_wmo_portals, parse_wmo_root, wmo_group_doodad_refs, wmo_group_footprint_tris,
wmo_group_header, wmo_group_light_refs, wmo_group_liquid_mesh, wmo_group_submeshes,
wmo_root_id, AlphaAnim, AlphaSeq, AnimEvent, Billboard, BillboardKind, BoneKeys, BoneScaleAnim,
CharSkinSlot, CollisionMesh, EmitterBoneLink, EventMarker, FogPolicy, FootprintTris,
GlobalSeqBone, GlobalSeqChannel, GroundQuad, M2AnimSummary, M2Attachment, M2Bounds, M2Light,
M2PortraitCamera, ModelAnimation, ModelBlend, PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim,
Skeleton, SkeletonBone, StringAnchors, UvAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog,
WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot,
};
mod terrain;
pub use terrain::{
+187 -5
View File
@@ -228,6 +228,96 @@ pub fn load_m2_mesh_skinned(
parse_m2_render_submeshes(&bytes, &dir, skins).with_context(|| format!("parsing M2 {path}"))
}
/// The model's billboard bones that are **not** rigidly separable — the population the card split
/// declines to claim, read through the renderer's own predicate so the census can't drift from it
/// (`benilla-extract bbscan`'s `SEAM` column). Empty for a model with no billboard bones, and for
/// one whose every card is an ordinary detached quad.
pub fn non_separable_billboard_bones(bytes: &[u8]) -> Vec<u16> {
let Ok(format) = parse_m2(&mut Cursor::new(bytes)) else {
return Vec::new();
};
let model = format.model();
let Ok(skin) = model.parse_embedded_skin(bytes, 0) else {
return Vec::new();
};
let separable = separable_billboard_bones(model, skin.triangles(), skin.indices());
model
.bones
.iter()
.enumerate()
.filter(|(i, b)| b.is_billboard() && !separable.get(*i).copied().unwrap_or(false))
.map(|(i, _)| i as u16)
.collect()
}
/// Per bone: may the per-batch card split claim it? `true` for every non-billboard bone (the split
/// never looks at those) and for a billboard bone whose geometry is rigidly **separable** — see the
/// call site for why that condition is the whole difference between a card and a torn flap.
///
/// Separable means both halves of "nothing else moves with it":
///
/// 1. **No partial weight.** Every vertex with any influence from the bone is influenced *only* by
/// it (weight 255/255). A 50/50 vertex is half a rigid body's worth of motion, which no rigid
/// group can produce.
/// 2. **No shared triangle.** Every triangle touching such a vertex has all three fully on the same
/// bone — otherwise the triangle spans the bone and its static neighbour, and rigidly moving it
/// rips it off whatever it was welded to. Checked over the whole skin, not per batch: a flap's
/// seam ring routinely lands in the *body's* batch while its tip sits in the billboard one
/// (`LShoulder_Plate_PVPAlliance_A_01`, bone 1 — 5 pure vertices in one batch, 8 seam vertices
/// in another), so a batch-local test would call it separable and tear it anyway.
fn separable_billboard_bones(
model: &benilla_m2::M2Model,
tris: &[u16],
lookup: &[u16],
) -> Vec<bool> {
let mut separable = vec![true; model.bones.len()];
let mut deny = |b: usize| {
if let Some(s) = separable.get_mut(b) {
*s = false;
}
};
// (1) any partial weight on a bone disqualifies it outright.
for v in &model.vertices {
for i in 0..4 {
let w = v.bone_weights[i];
if w != 0 && w != u8::MAX {
deny(v.bone_indices[i] as usize);
}
}
}
// The bone a vertex is *wholly* on, if any — the only vertex a rigid group can own.
let sole_bone = |g: usize| -> Option<usize> {
let v = model.vertices.get(g)?;
(0..4)
.find(|&i| v.bone_weights[i] == u8::MAX)
.map(|i| v.bone_indices[i] as usize)
};
// (2) a triangle whose vertices don't all sit wholly on one bone disqualifies every bone it
// touches — including the hard (weight-1 vs weight-1) straddle that check (1) can't see.
for t in tris.chunks_exact(3) {
let g: Vec<usize> = t
.iter()
.filter_map(|&i| lookup.get(i as usize).map(|&x| x as usize))
.collect();
let [a, b, c] = g[..] else { continue };
let (sa, sb, sc) = (sole_bone(a), sole_bone(b), sole_bone(c));
if sa.is_some() && sa == sb && sb == sc {
continue; // wholly one bone: a rigid group can own this triangle
}
for &v in &[a, b, c] {
let Some(vert) = model.vertices.get(v) else {
continue;
};
for i in 0..4 {
if vert.bone_weights[i] != 0 {
deny(vert.bone_indices[i] as usize);
}
}
}
}
separable
}
/// Parse an **in-memory** M2 into render submeshes. `dir` is the model's directory (to resolve its
/// relative texture references); `skins` fill the `Monster1/2/3` texture slots (creature variations).
/// The bytes-in entry point used by the Bevy `AssetLoader`; [`load_m2_mesh_skinned`] is the
@@ -266,6 +356,31 @@ pub fn parse_m2_render_submeshes(
.collect()
};
// **Which billboard bones the per-batch card split is allowed to claim.**
//
// A card is a RIGID body: the split below pulls a billboard bone's triangles out as their own
// submesh and the renderer rotates that whole group about the bone's pivot. The reference has
// no card concept at all — it skins every vertex through the bone palette (`m2_vertex_skin`
// `0x71a460`), where a billboard bone's camera-replaced matrix (wow-re `billboard-bone-law.md`
// §5) is simply one more matrix in a per-vertex weighted blend. The two agree exactly when — and
// only when — the bone's geometry is rigidly **separable**: nothing shares a vertex or a
// triangle with the rest of the model.
//
// Where it isn't, a rigid group cannot express the answer *at all*, and the failure is not a
// small error: a vertex weighted half to the flap and half to the body has to be placed wholly
// on one side, so a flap authored welded to its model is **torn in two** — the free half
// sweeping with the camera, the welded half frozen, a detached triangle between them and a
// z-fight where the swung half grazes the body. That is the Field Marshal pauldron the director
// reported (decision 0839): its two spikes each ride a spherical bone through a 50/50 seam ring.
//
// So the split takes only the separable bones, and everything else stays ordinary geometry —
// whole, in one submesh. On a **rigged** spawn that submesh is the skinned form, so the joint
// palette (which already applies the same billboard replacement, `billboard_joint_palette`)
// reproduces the reference exactly; on a rig-less spawn the flap holds still instead of bending.
// Corpus-wide this claims 16 models of 9691 (236 vertices) — `benilla-extract bbscan`'s SEAM
// column — so every ordinary glow card is untouched.
let separable = separable_billboard_bones(model, tris, lookup);
// Every sequence's (anim id, absolute time band, loops): entry stride 0x44, anim id u16 at
// +0x00, band start/end u32 at +0x04/+0x08, flags u32 at +0x10 (bit 0 CLEAR = the band loops —
// the same read `particles` makes for the emission-timing bake). The first entry's band is the
@@ -473,11 +588,9 @@ pub fn parse_m2_render_submeshes(
// appearance order and preserving triangle order within each group.
let primary_billboard_bone = |g: u32| -> Option<usize> {
let b = model.vertices.get(g as usize)?.bone_indices[0] as usize;
model
.bones
.get(b)
.is_some_and(|bone| bone.is_billboard())
.then_some(b)
(model.bones.get(b).is_some_and(|bone| bone.is_billboard())
&& separable.get(b).copied().unwrap_or(false))
.then_some(b)
};
let mut groups: Vec<(Option<usize>, Vec<u32>)> = Vec::new();
for tri in global_indices.chunks_exact(3) {
@@ -768,6 +881,75 @@ mod tests {
assert!(reflect.no_depth_write, "render flag 0x10");
}
/// The Field Marshal pauldron (`LShoulder_Plate_PVPAlliance_A_01.m2`), the director's report:
/// its two little spikes each ride a spherical-billboard bone through a **50/50 seam ring**, so
/// neither bone is rigidly separable and neither may be split into a card. The whole model must
/// come out as ONE welded batch — the spikes' own vertices, the seam ring, and the body.
///
/// The seam ring is the point. Bone 1's five fully-weighted vertices sat in their own batch
/// while its eight 50/50 vertices sat in the *body's*, so the split saw a clean card, tore the
/// spike off the shoulder and swung it with the camera (decision 0839). A batch-local
/// separability test would still see a clean card here; only the model-wide one catches it.
#[test]
fn a_welded_billboard_spike_is_never_split_into_a_card() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let path = "Item\\ObjectComponents\\Shoulder\\LShoulder_Plate_PVPAlliance_A_01.m2";
let subs = load_m2_mesh(&mut chain, path).expect("parse LShoulder_Plate_PVPAlliance_A_01");
assert_eq!(subs.len(), 1, "body + both spikes stay one welded batch");
assert!(
subs[0].billboard.is_none(),
"a welded bone is never claimed by the card split"
);
assert_eq!(subs[0].positions.len(), 152, "every vertex is present");
// …and the welded batch really does carry the seam — otherwise this test would still pass
// on a build that simply dropped the spikes.
let seam = subs[0]
.weights
.iter()
.filter(|w| w.iter().any(|&x| x > 0.0 && x < 0.999))
.count();
assert_eq!(
seam, 16,
"the two 8-vertex 50/50 seam rings are in the mesh"
);
// The renderer's predicate, read directly: both billboard bones are the denied ones.
let bytes = chain.read_file(path).expect("read the m2");
assert_eq!(non_separable_billboard_bones(&bytes), vec![1, 2]);
}
/// The counter-anchor: an ordinary **detached** glow card is untouched by that rule. The PVP
/// two-hander's two hilt glows are 4-vertex quads wholly on their own spherical bone, sharing
/// no triangle with the blade — exactly the shape the card split exists for, and 4372 of the
/// corpus's billboard batches are like this. If separability ever over-denies, this is what
/// goes dark first.
#[test]
fn a_detached_glow_card_is_still_split_out() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let path = "Item\\ObjectComponents\\Weapon\\Sword_2H_PVPAlliance_A_01.m2";
let subs = load_m2_mesh(&mut chain, path).expect("parse Sword_2H_PVPAlliance_A_01");
let cards: Vec<_> = subs.iter().filter(|s| s.billboard.is_some()).collect();
assert_eq!(cards.len(), 2, "both hilt glows stay cards");
assert!(
cards.iter().all(|c| c.positions.len() == 4),
"each is its own 4-vertex quad"
);
let bytes = chain.read_file(path).expect("read the m2");
assert!(
non_separable_billboard_bones(&bytes).is_empty(),
"nothing on this model is welded"
);
}
/// The questgiver `?` marker, straight off the real client data: ONE cylindrical-billboard
/// bone with a 3-key translation bob per sequence — anim **0** (the load arm's low bob) and
/// anim **190**, the authored **raised** bob the client arms while the unit shows an overhead
+24 -10
View File
@@ -363,25 +363,39 @@ impl RenderSubmesh {
/// A card authored edge-on to the camera axis has no facing to correct, matching
/// `bbfacescan`'s away/edge-on split.
pub fn billboard_card_faces_away(&self) -> bool {
self.billboard.is_some() && self.plane_normal().is_some_and(|n| n[0] < -Self::EDGE_ON_X)
}
/// A normal whose |x| is under this is edge-on to the billboard law's camera axis: it faces
/// neither toward the viewer nor away, so there is nothing to decide.
const EDGE_ON_X: f32 = 1e-3;
/// The single plane every authored vertex normal of this batch shares — `None` when they do
/// not, i.e. the batch is **3-D geometry**, not a card.
///
/// This is the gate that separates the two things a "billboard batch" can be, and getting it
/// wrong is expensive in both directions. A flat card has one facing, so which way it points
/// decides whether the reference ever shows it (decision 0629) and which side to light
/// (decision 0788). A closed solid — the questgiver `?`'s 353 verts, a pauldron's little
/// spike — has normals pointing every way: no single facing exists, backface culling can never
/// hide it, and a per-batch normal flip would gut its shading. Sampling *one* triangle of such
/// a batch and reporting the answer as the batch's facing is how decision 0836 concluded the
/// reference culls a shoulder flap it in fact draws.
pub fn plane_normal(&self) -> Option<[f32; 3]> {
/// Cosine floor for "these two normals are the same plane" — one authored card, allowing
/// for unnormalised//soft-averaged authoring.
const SAME_PLANE_COS: f32 = 0.999;
/// A normal with |x| under this is edge-on to the camera axis: nothing to correct.
const EDGE_ON_X: f32 = 1e-3;
if self.billboard.is_none() {
return false;
}
let unit = |n: &[f32; 3]| {
let l = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
(l > 1e-6).then(|| [n[0] / l, n[1] / l, n[2] / l])
};
let Some(n0) = self.normals.first().and_then(unit) else {
return false;
};
n0[0] < -EDGE_ON_X
&& self.normals.iter().all(|n| {
let n0 = self.normals.first().and_then(unit)?;
self.normals
.iter()
.all(|n| {
unit(n).is_some_and(|n| n[0] * n0[0] + n[1] * n0[1] + n[2] * n0[2] > SAME_PLANE_COS)
})
.then_some(n0)
}
/// Detect the flat **ground-plane quad** shape ([`GroundQuad`]) — `None` for everything that
+6
View File
@@ -10,3 +10,9 @@ anyhow.workspace = true
clap.workspace = true
benilla-srp = { path = "../benilla-srp" }
flate2 = "1.0"
[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"
@@ -0,0 +1,467 @@
//! Probe: **how does realmd feed the SRP6 big numbers to SHA-1** — fixed width, or minimal length?
//!
//! The 1.12.1 client hashes `A`, `B`, `K`, the salt, the `H(N)⊕H(g)` constant and `M1` at their
//! *declared* widths, zero-padded in the high bytes (`wow-5875-re` `srp6_client_session`, byte-exact
//! from `WoW.exe` `0x5d3650`). vmangos feeds the same values as `BigNumber`s
//! (`SHA1::Generator::UpdateData(BigNumber const&)` → `AsByteArray()` with `minSize = 0`), which
//! **drops high-order zero bytes**. The two agree only while no value happens to have one — and
//! disagree, silently, when one does. cmangos is identical (`Sha1Hash::UpdateBigNumbers`).
//!
//! This probe forces each case against a live realmd so the disagreement is observed, not argued:
//!
//! ```text
//! cargo run --release -p benilla-protocol --example srp_encoding_probe -- --user one --pass pone
//! ```
//!
//! `--stress N` instead runs N ordinary handshakes at the client-faithful (fixed-width) encoding and
//! reports the observed failure rate. realmd locks an IP out for 60 s after `WrongPass.MaxAttempts`
//! (default 10) failures inside that window and then answers `0x08 WOW_FAIL_DB_BUSY`; the probe
//! drains the window rather than counting the lockout as a proof failure.
use std::net::TcpStream;
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use benilla_protocol::auth::{self, AuthReject};
use num_bigint::BigUint;
use rand::{thread_rng, RngCore};
use sha1::{Digest, Sha1};
const BUILD: u16 = 5875;
const AUTH_PORT: u16 = 3724;
/// Failures allowed inside realmd's throttle window before it answers `WOW_FAIL_DB_BUSY`.
const THROTTLE_MAX: u32 = 10;
/// How a big number is serialized into the SHA-1 stream.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Enc {
/// The client's law: the value's full declared width, zero-padded in the high bytes.
Fixed,
/// vmangos' law: `BigNumber::AsByteArray()` — high-order zero bytes dropped.
Minimal,
}
impl Enc {
/// `bytes` is little-endian, so the high-order end is the tail.
fn apply(self, bytes: &[u8]) -> &[u8] {
match self {
Enc::Fixed => bytes,
Enc::Minimal => {
let mut end = bytes.len();
while end > 0 && bytes[end - 1] == 0 {
end -= 1;
}
&bytes[..end]
}
}
}
}
fn sha1(parts: &[&[u8]]) -> [u8; 20] {
let mut h = Sha1::new();
for p in parts {
h.update(p);
}
h.finalize().into()
}
fn to_fixed_le_32(v: &BigUint) -> [u8; 32] {
let raw = v.to_bytes_le();
let mut out = [0u8; 32];
let n = raw.len().min(32);
out[..n].copy_from_slice(&raw[..n]);
out
}
/// WoW's `SHA1_Interleave` **as vmangos computes it** — all 32 bytes of `S`, no trimming
/// (`SRP6::HashSessionKey`, `S.AsByteArray(32)`). benilla matches this today; the real client instead
/// strips low-order zero bytes of `S` first, which is its own (separately recorded) divergence.
fn interleave(s: &[u8; 32]) -> [u8; 40] {
let mut even = [0u8; 16];
let mut odd = [0u8; 16];
for i in 0..16 {
even[i] = s[i * 2];
odd[i] = s[i * 2 + 1];
}
let g = sha1(&[&even]);
let h = sha1(&[&odd]);
let mut out = [0u8; 40];
for i in 0..20 {
out[i * 2] = g[i];
out[i * 2 + 1] = h[i];
}
out
}
/// The SRP6 values one handshake produced, plus the proof to send.
struct Attempt {
a_pub: [u8; 32],
b_pub: [u8; 32],
k: [u8; 40],
m1: [u8; 20],
}
impl Attempt {
/// Which hashed values carry a high-order zero byte — i.e. where the two encodings differ.
fn short_values(&self) -> Vec<&'static str> {
let mut v = Vec::new();
if self.a_pub[31] == 0 {
v.push("A");
}
if self.b_pub[31] == 0 {
v.push("B");
}
if self.k[39] == 0 {
v.push("K");
}
if self.m1[19] == 0 {
v.push("M1");
}
v
}
/// `M2 = SHA1(A ‖ M1 ‖ K)`, under `enc`.
fn expected_m2(&self, enc: Enc) -> [u8; 20] {
sha1(&[
enc.apply(&self.a_pub),
enc.apply(&self.m1),
enc.apply(&self.k),
])
}
}
/// What realmd said, and — when it accepted — whose `M2` encoding its reply matches.
enum Outcome {
Accepted { m2_fixed: bool, m2_minimal: bool },
Refused(u8),
Error(String),
}
impl Outcome {
fn line(&self) -> String {
match self {
Outcome::Accepted {
m2_fixed,
m2_minimal,
} => {
let m2 = match (m2_fixed, m2_minimal) {
(true, true) => "M2 ok (both)",
(true, false) => "M2 ok (fixed)",
(false, true) => "M2 ok (minimal only — a fixed-width client rejects it)",
(false, false) => "M2 MATCHES NEITHER",
};
format!("ACCEPTED, {m2}")
}
Outcome::Refused(c) => format!("REFUSED {c:#04x}"),
Outcome::Error(e) => format!("ERROR {e}"),
}
}
}
/// What the ephemeral search is aiming for.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Want {
/// Whatever the first draw gives — an ordinary handshake.
Any,
/// Nothing short — the case both encodings agree on.
Clean,
/// `A` with a zero high byte.
ShortA,
/// `K` with a zero high byte.
ShortK,
/// `M1` with a zero high byte — realmd accepts, but hashes a 19-byte `M1` into its `M2`.
ShortM1,
/// `B` with a zero high byte. Not searchable: `B` is the server's, so the caller redials until
/// one turns up, and the ephemeral is then drawn clean so `B` is the only short value.
ShortB,
}
impl Want {
/// Does a draw of this shape satisfy the search? Every case wants exactly its own value short
/// and the others long, so each verdict isolates one divergence.
fn accepts(self, a_short: bool, k_short: bool, m1_short: bool) -> bool {
match self {
Want::Any => true,
Want::Clean | Want::ShortB => !a_short && !k_short && !m1_short,
Want::ShortA => a_short && !k_short && !m1_short,
Want::ShortK => !a_short && k_short && !m1_short,
Want::ShortM1 => !a_short && !k_short && m1_short,
}
}
}
/// Run the client half of SRP6 for the given challenge, drawing ephemerals until `want` is met.
fn compute(
reply: &auth::ChallengeReply,
user: &str,
pass: &str,
enc: Enc,
want: Want,
tries: u32,
) -> Option<Attempt> {
let n = BigUint::from_bytes_le(&reply.large_safe_prime);
let g = BigUint::from_bytes_le(&[reply.generator]);
let b_pub = reply.server_public_key;
let b_bn = BigUint::from_bytes_le(&b_pub);
// x = SHA1(salt ‖ SHA1(USER:PASS)), v = g^x mod N
let inner = sha1(&[user.as_bytes(), b":", pass.as_bytes()]);
let x = BigUint::from_bytes_le(&sha1(&[&reply.salt, &inner]));
let v = g.modpow(&x, &n);
let base = (&b_bn + 3u32 * (&n - &v)) % &n;
let xor_hash = xor_hash(reply.generator, &reply.large_safe_prime);
let user_hash = sha1(&[user.as_bytes()]);
for _ in 0..tries {
let mut priv_key = [0u8; 32];
thread_rng().fill_bytes(&mut priv_key);
let a = BigUint::from_bytes_le(&priv_key) + BigUint::from(n.bits());
let a_pub = to_fixed_le_32(&g.modpow(&a, &n));
// Reject on the A-shape we are not after before paying for the second modpow.
let a_short = a_pub[31] == 0;
if want != Want::Any && a_short != (want == Want::ShortA) {
continue;
}
let u = BigUint::from_bytes_le(&sha1(&[enc.apply(&a_pub), enc.apply(&b_pub)]));
let s = to_fixed_le_32(&base.modpow(&(&a + &u * &x), &n));
let k = interleave(&s);
let m1 = sha1(&[
enc.apply(&xor_hash),
&user_hash,
enc.apply(&reply.salt),
enc.apply(&a_pub),
enc.apply(&b_pub),
enc.apply(&k),
]);
if want.accepts(a_short, k[39] == 0, m1[19] == 0) {
return Some(Attempt {
a_pub,
b_pub,
k,
m1,
});
}
}
None
}
/// `H( SHA1(N) XOR SHA1(g) )`, little endian — a constant for WoW's fixed `N`/`g`.
fn xor_hash(generator: u8, large_safe_prime: &[u8; 32]) -> [u8; 20] {
let hn = sha1(&[large_safe_prime]);
let hg = sha1(&[&[generator]]);
let mut out = [0u8; 20];
for i in 0..20 {
out[i] = hn[i] ^ hg[i];
}
out
}
/// One full realmd exchange.
fn handshake(
host: &str,
user: &str,
pass: &str,
enc: Enc,
want: Want,
tries: u32,
) -> Result<(Attempt, Outcome)> {
let mut s = TcpStream::connect((host, AUTH_PORT))
.with_context(|| format!("connecting to {host}:{AUTH_PORT}"))?;
s.set_read_timeout(Some(Duration::from_secs(10)))?;
auth::write_logon_challenge(&mut s, user, BUILD).context("sending logon challenge")?;
let reply = auth::read_challenge_reply(&mut s).context("reading logon challenge reply")?;
// `B` is not ours to choose: drop the connection — sending no proof, so realmd records nothing —
// and let the caller redial until the server hands one of the shape we want.
if want != Want::Any && (reply.server_public_key[31] == 0) != (want == Want::ShortB) {
return Err(anyhow!("this connection's B is not the wanted shape"));
}
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")?;
let outcome = match auth::read_proof_reply(&mut s) {
Ok(m2) => Outcome::Accepted {
m2_fixed: m2 == attempt.expected_m2(Enc::Fixed),
m2_minimal: m2 == attempt.expected_m2(Enc::Minimal),
},
Err(e) => match e.downcast_ref::<AuthReject>() {
Some(r) => Outcome::Refused(r.code),
None => Outcome::Error(format!("{e:#}")),
},
};
Ok((attempt, outcome))
}
/// Redial until the search finds the wanted shape (`B` is the server's draw, so `ShortB` needs many).
fn until(
host: &str,
user: &str,
pass: &str,
enc: Enc,
want: Want,
dials: u32,
) -> Result<(Attempt, Outcome)> {
let mut last = None;
for _ in 0..dials {
match handshake(host, user, pass, enc, want, 8192) {
Ok(r) => return Ok(r),
Err(e) => last = Some(e),
}
}
Err(last.unwrap_or_else(|| anyhow!("no dial succeeded")))
}
fn report(label: &str, r: &Result<(Attempt, Outcome)>) {
match r {
Ok((a, v)) => {
let short = a.short_values();
let short = if short.is_empty() {
"none".to_string()
} else {
short.join(",")
};
println!(" {label:<44} high-zero: {short:<5} → {}", v.line());
}
Err(e) => println!(" {label:<44} → probe error: {e:#}"),
}
}
fn matrix(host: &str, user: &str, pass: &str, enc: Enc, title: &str) {
println!("\n{title}");
for (label, want, dials) in [
("control: no value has a high zero byte", Want::Clean, 12),
("A has a high zero byte", Want::ShortA, 12),
("K has a high zero byte", Want::ShortK, 12),
("M1 has a high zero byte", Want::ShortM1, 12),
(
"B has a high zero byte (the server's draw)",
Want::ShortB,
900,
),
] {
report(label, &until(host, user, pass, enc, want, dials));
}
}
fn main() -> Result<()> {
let mut host = "localhost".to_string();
let mut user = "one".to_string();
let mut pass = "pone".to_string();
let mut stress = 0u32;
let mut logons = 0u32;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--host" => host = args.next().unwrap_or(host),
"--user" => user = args.next().unwrap_or(user),
"--pass" => pass = args.next().unwrap_or(pass),
"--stress" => stress = args.next().and_then(|v| v.parse().ok()).unwrap_or(0),
"--logon" => logons = args.next().and_then(|v| v.parse().ok()).unwrap_or(0),
other => return Err(anyhow!("unknown argument {other}")),
}
}
// The end-to-end gate: run the shipped `logon` — the code the client actually uses — and count
// what comes back. Anything but a clean sweep means the ephemeral guarantee is not holding.
if logons > 0 {
println!("logon: {logons} full handshakes through benilla_protocol::logon\n");
let (mut ok, mut failed) = (0u32, 0u32);
for i in 0..logons {
match benilla_protocol::logon(&host, &user, &pass) {
Ok(_) => ok += 1,
Err(e) => {
failed += 1;
println!(" #{i}: {e:#}");
// Do not walk into realmd's lockout while reporting a real regression.
std::thread::sleep(Duration::from_secs(7));
}
}
}
println!("\nsucceeded {ok}, failed {failed}");
return Ok(());
}
let user = user.to_uppercase();
let pass = pass.to_uppercase();
if stress > 0 {
println!("stress: {stress} handshakes, client-faithful (fixed-width) encoding\n");
let (mut ok, mut failed, mut throttled) = (0u32, 0u32, 0u32);
let mut blame: Vec<String> = Vec::new();
let mut window_failures = 0u32;
for i in 0..stress {
if window_failures >= THROTTLE_MAX - 1 {
std::thread::sleep(Duration::from_secs(61)); // drain realmd's lockout window
window_failures = 0;
}
// `Want::Any` with one try: the first ephemeral is used whatever its shape, and whatever
// `B` the server sends is kept — an ordinary handshake.
match handshake(&host, &user, &pass, Enc::Fixed, Want::Any, 1) {
Ok((a, v)) => {
let short = a.short_values();
let short = if short.is_empty() {
"nothing short!".to_string()
} else {
format!("high-zero in {}", short.join(","))
};
match v {
Outcome::Refused(0x08) => {
throttled += 1;
std::thread::sleep(Duration::from_secs(61));
window_failures = 0;
}
Outcome::Accepted { m2_fixed: true, .. } => {
ok += 1;
window_failures = 0;
}
other => {
failed += 1;
window_failures += 1;
blame.push(format!("#{i}: {} — {short}", other.line()));
}
}
}
Err(e) => println!(" #{i}: probe error: {e:#}"),
}
}
println!("\naccepted {ok}, failed {failed}, throttled {throttled}");
if !blame.is_empty() {
println!("failures:");
for b in &blame {
println!(" {b}");
}
}
return Ok(());
}
println!("realmd at {host}:{AUTH_PORT}, account {user}");
// The `H(N)⊕H(g)` constant is hashed as a BigNumber too. It is fixed for WoW's N/g — if its high
// byte were zero, *every* fixed-width login would fail, not one in sixty.
let xh = xor_hash(7, &benilla_srp::LARGE_SAFE_PRIME_LITTLE_ENDIAN);
println!(
"H(N)^H(g) high byte = {:#04x} (non-zero, so the constant is unambiguous)",
xh[19]
);
matrix(
&host,
&user,
&pass,
Enc::Fixed,
"client-faithful encoding (fixed width — what benilla sends today):",
);
matrix(
&host,
&user,
&pass,
Enc::Minimal,
"vmangos encoding (AsByteArray() — high-order zero bytes dropped):",
);
Ok(())
}
+36 -12
View File
@@ -31,6 +31,10 @@ use benilla_srp::{NormalizedString, PublicKey, SrpClientChallenge, SESSION_KEY_L
pub const AUTH_PORT: u16 = 3724;
/// The 1.12.1 client build we present to the server.
pub const CLIENT_BUILD: u16 = 5875;
/// How many logon challenges [`logon`] will ask for while looking for a `B` both serialization
/// conventions read the same way (see the redial comment there). One dial in ~137 comes back
/// ambiguous, so eight is already a probability of about 10⁻¹⁷ of running out.
const MAX_CHALLENGE_DIALS: u32 = 8;
/// Split an optional `:port` off a host string (`play.example.com:3724`). A bare host — or one
/// whose suffix isn't a port, e.g. a raw IPv6 address like `::1` — comes back intact with
@@ -72,23 +76,43 @@ pub fn logon(host: &str, username: &str, password: &str) -> Result<Logon> {
// `host` may carry an explicit `:port` (`WOW_HOST=play.example.com:3724`); bare hosts get
// [`AUTH_PORT`].
let (host, port) = host_port(host, AUTH_PORT);
let mut stream =
TcpStream::connect((host, port)).with_context(|| format!("connecting to {host}:{port}"))?;
// 1. Logon challenge — the account name is sent uppercased (matching the SRP6 calculation).
auth::write_logon_challenge(&mut stream, &username.to_uppercase(), CLIENT_BUILD)
.context("sending logon challenge")?;
// 2. Server challenge → SRP6 inputs.
let reply = auth::read_challenge_reply(&mut stream).context("reading logon challenge reply")?;
// 3. SRP6 (WoW flavor) — compute A, M1, and the session key.
let username_n =
NormalizedString::new(username).map_err(|e| anyhow!("invalid username: {e}"))?;
let password_n =
NormalizedString::new(password).map_err(|e| anyhow!("invalid password: {e}"))?;
let server_public_key = PublicKey::from_le_bytes(reply.server_public_key)
.map_err(|e| anyhow!("invalid server public key: {e}"))?;
// 1-2. Logon challenge → the server's SRP6 inputs. The account name is sent uppercased (matching
// the SRP6 calculation).
//
// `B` is the one hashed value benilla does not draw, and ~1 in 137 of them is one the client and
// the mangos family serialize differently — a correct password answered with 0x04 (see
// `benilla-srp`, "Encoding-unambiguous handshakes"). Redial for a fresh `B` when that lands: the
// challenge is abandoned before any proof goes out, and realmd counts nothing at this stage for a
// known account (`AuthSocket::_HandleLogonChallenge`), so a redial is free and invisible.
let (mut stream, reply, server_public_key) = {
let mut dialed = None;
for _ in 0..MAX_CHALLENGE_DIALS {
let mut stream = TcpStream::connect((host, port))
.with_context(|| format!("connecting to {host}:{port}"))?;
auth::write_logon_challenge(&mut stream, &username.to_uppercase(), CLIENT_BUILD)
.context("sending logon challenge")?;
let reply =
auth::read_challenge_reply(&mut stream).context("reading logon challenge reply")?;
let server_public_key = PublicKey::from_le_bytes(reply.server_public_key)
.map_err(|e| anyhow!("invalid server public key: {e}"))?;
let stable = server_public_key.is_width_stable();
dialed = Some((stream, reply, server_public_key));
if stable {
break;
}
}
// Every dial ran out ambiguous (p ≈ 10⁻¹⁷): send the last anyway rather than inventing a
// failure the server never gave us.
dialed.expect("MAX_CHALLENGE_DIALS is non-zero")
};
// 3. SRP6 (WoW flavor) — compute A, M1, and the session key.
let challenge = SrpClientChallenge::new(
username_n,
password_n,
+183 -38
View File
@@ -11,6 +11,30 @@
//! Proven by a full SRP6 round-trip against `wow_srp`'s server + a byte-exact header-cipher diff
//! during the decision-0021 migration (oracle test in git history); ongoing regression coverage is
//! the oracle-free known-answer + golden tests in this crate.
//!
//! # Encoding-unambiguous handshakes
//!
//! The values the handshake feeds to SHA-1 (`A`, `B`, `K`, the salt, `M1`) are *numbers*, and the two
//! implementations in the wild serialize a number differently:
//!
//! - The **1.12.1 client** writes each at its declared width, zero-padded in the high bytes — `A`/`B`
//! 32, `K` 40, `M1` 20 (`wow-5875-re` `srp6_client_session`, byte-exact from `WoW.exe` `0x5d3650`).
//! - The **mangos family** (vmangos `SHA1::Generator::UpdateData(BigNumber const&)`, cmangos
//! `Sha1Hash::UpdateBigNumbers`) writes `BigNumber::AsByteArray()` with no minimum — **high-order
//! zero bytes dropped**.
//!
//! They agree only while no value happens to have a high-order zero byte, and disagree silently when
//! one does: realmd answers `WOW_FAIL_UNKNOWN_ACCOUNT` (0x04) to a perfectly correct password. There
//! is a matching disagreement at the other end of `S` (see [`calculate_interleaved`]). Measured
//! against a live vmangos, a client that picks a side loses ~1 handshake in 45 — see
//! `benilla-protocol`'s `srp_encoding_probe` example, which forces each case and prints the verdict.
//!
//! benilla picks **neither side**. `a` is ours to draw, and every ambiguous value is downstream of
//! it, so [`SrpClientChallenge::new`] simply redraws until the handshake it is about to send is one
//! both conventions serialize identically — see [`is_width_stable`]. What we send is then bit-for-bit
//! the real client's arithmetic *and* accepted by a mangos-family server, with no branch on which
//! kind of server we are talking to. The one value we cannot draw is the server's `B`; `benilla-
//! protocol`'s `logon` redials for a fresh challenge when that one lands ambiguous.
use num_bigint::BigInt;
use rand::{thread_rng, RngCore};
@@ -60,6 +84,14 @@ fn sha1(parts: &[&[u8]]) -> [u8; 20] {
h.finalize().into()
}
/// Does this little-endian value hash the same whichever serialization the peer uses — its declared
/// width (the 1.12.1 client) or its minimal length (the mangos family)? True exactly when the
/// high-order byte is non-zero, since minimal encoding differs from padded only by dropping those.
/// See the crate docs, "Encoding-unambiguous handshakes".
fn is_width_stable(little_endian: &[u8]) -> bool {
matches!(little_endian.last(), Some(&b) if b != 0)
}
// --- normalized string ----------------------------------------------------------------------------
/// A username/password normalised the way the 1.12 client does it: ASCII only (no control chars),
@@ -165,6 +197,13 @@ impl PublicKey {
&self.key
}
/// Whether this key hashes identically under both serialization conventions (crate docs,
/// "Encoding-unambiguous handshakes"). [`SrpClientChallenge::new`] guarantees it for the `A` it
/// draws; for the server's `B` it is luck, and the caller's cue to redial for a fresh challenge.
pub const fn is_width_stable(&self) -> bool {
self.key[31] != 0
}
fn as_bigint(&self) -> BigInt {
from_le(&self.key)
}
@@ -209,11 +248,17 @@ fn calculate_u(client_public_key: &PublicKey, server_public_key: &PublicKey) ->
/// Fold the shared secret `S` (32 LE bytes) into the 40-byte session key: split the even / odd bytes,
/// SHA-1 each half, then interleave the two digests. This is WoW's specific `SHA1_Interleave`.
///
/// The SRP-6 RFC strips low-order zero bytes of `S` before the split; WoW's does **not** — it hashes
/// all 32 bytes unconditionally (`SRP6::HashSessionKey`, vmangos `src/shared/Crypto/Authentication/
/// SRP6.cpp:127`). Trimming derives a different `K` — and so a different `M1` — for the ~1-in-256
/// handshake whose `S` happens to end in a zero low byte, which the server answers with
/// `WOW_FAIL_UNKNOWN_ACCOUNT` (0x04): an intermittent "wrong password" on a correct one.
/// This is the crate-doc encoding split again, at the *low* end of `S`. The SRP-6 RFC strips leading
/// zero bytes before the split and the real client does too (`wow-5875-re` `srp6_interleave`, from
/// `WoW.exe` `0x5d3360`, 406 cases bit-exact) — leading in its little-endian `S` meaning the **low**
/// bytes. vmangos does not: it hashes all 32 unconditionally (`SRP6::HashSessionKey`,
/// `S.AsByteArray(32)`). A trim would derive a different `K`, hence a different `M1`, for the
/// ~1-in-256 `S` ending in a zero low byte — an intermittent `WOW_FAIL_UNKNOWN_ACCOUNT` (0x04) on a
/// correct password.
///
/// We hash all 32 bytes, and [`SrpClientChallenge::new`] only keeps an `S` whose low byte is
/// non-zero — which makes the strip a no-op, so this `K` is simultaneously the real client's and
/// vmangos'. Neither convention is chosen; the ambiguous inputs are simply never presented.
fn calculate_interleaved(s: &[u8; 32]) -> [u8; 40] {
let mut e = [0u8; 16];
for (i, b) in s.iter().step_by(2).enumerate() {
@@ -255,10 +300,22 @@ pub struct SrpClientChallenge {
session_key: [u8; 40],
}
/// How many ephemerals [`SrpClientChallenge::new`] will draw looking for an encoding-unambiguous
/// handshake. Each draw succeeds ~97.4% of the time, so the loop all but always ends on the first;
/// the bound only exists so a degenerate `N`/`g` from a hostile server cannot spin forever. On
/// exhaustion we send the last draw anyway — the same handshake we would have sent before this
/// guarantee existed, i.e. it can fail the logon but cannot corrupt one.
const MAX_EPHEMERAL_DRAWS: u32 = 512;
impl SrpClientChallenge {
/// Compute `A`, `M1`, and the session key from the server challenge. Mirrors the real client:
/// random 32-byte private key `a`, `A = g^a mod N`, `S = (B - k·g^x)^(a + u·x) mod N`, session
/// key = interleave(S), `M1 = SHA1( H(N)^H(g) | SHA1(user) | salt | A | B | K )`.
///
/// `a` is redrawn until every value the handshake serializes is one both conventions in the wild
/// encode identically (crate docs, "Encoding-unambiguous handshakes"): `A`, `K` and `M1` with no
/// high-order zero byte, and `S` with no low-order one. The arithmetic is untouched — this only
/// declines to *use* an ephemeral whose handshake would be read two ways.
pub fn new(
username: NormalizedString,
password: NormalizedString,
@@ -271,40 +328,65 @@ impl SrpClientChallenge {
let g = BigInt::from(generator);
let k = BigInt::from(K_VALUE);
let mut private_key = [0u8; 32];
thread_rng().fill_bytes(&mut private_key);
let a = from_le(&private_key);
// A = g^a mod N
let client_public_key = to_padded_32_le(&g.modpow(&a, &n));
// Everything the draw does not move, hoisted out of the loop.
let x = from_le(&calculate_x(&username, &password, &salt));
let client_pk = PublicKey::from_le_bytes(client_public_key)
.expect("generated client public key is valid");
let u = from_le(&calculate_u(&client_pk, &server_public_key));
// S = (B - k·(g^x mod N))^(a + u·x) mod N
let s_int =
(server_public_key.as_bigint() - &k * g.modpow(&x, &n)).modpow(&(&a + &u * &x), &n);
let session_key = calculate_interleaved(&to_padded_32_le(&s_int));
// M1 = SHA1( xor_hash | SHA1(username) | salt | A | B | K )
let s_base = server_public_key.as_bigint() - &k * g.modpow(&x, &n);
let xor = xor_hash(generator, &large_safe_prime);
let username_hash = sha1(&[username.as_ref().as_bytes()]);
let client_proof = sha1(&[
&xor_hash(generator, &large_safe_prime),
&username_hash,
&salt,
&client_public_key,
server_public_key.as_le_bytes(),
&session_key,
]);
SrpClientChallenge {
username,
client_proof,
client_public_key,
session_key,
for draw in 1..=MAX_EPHEMERAL_DRAWS {
let last = draw == MAX_EPHEMERAL_DRAWS;
let mut private_key = [0u8; 32];
thread_rng().fill_bytes(&mut private_key);
let a = from_le(&private_key);
// A = g^a mod N
let client_public_key = to_padded_32_le(&g.modpow(&a, &n));
if !is_width_stable(&client_public_key) && !last {
continue;
}
let client_pk = PublicKey::from_le_bytes(client_public_key)
.expect("generated client public key is valid");
let u = from_le(&calculate_u(&client_pk, &server_public_key));
// S = (B - k·(g^x mod N))^(a + u·x) mod N
let s = to_padded_32_le(&s_base.modpow(&(&a + &u * &x), &n));
// A zero low byte is the one the real client's interleave would strip (see
// `calculate_interleaved`) — decline it so our K is both implementations' K.
if s[0] == 0 && !last {
continue;
}
let session_key = calculate_interleaved(&s);
if !is_width_stable(&session_key) && !last {
continue;
}
// M1 = SHA1( xor_hash | SHA1(username) | salt | A | B | K )
let client_proof = sha1(&[
&xor,
&username_hash,
&salt,
&client_public_key,
server_public_key.as_le_bytes(),
&session_key,
]);
// M1 is itself hashed back into the server's M2, so it needs the guarantee too — this is
// the case realmd *accepts* while the M2 it replies with fails our check.
if !is_width_stable(&client_proof) && !last {
continue;
}
return SrpClientChallenge {
username,
client_proof,
client_public_key,
session_key,
};
}
unreachable!("the final draw is accepted unconditionally")
}
/// Our proof `M1` (little endian) — send in `CMD_AUTH_LOGON_PROOF_Client`.
@@ -390,12 +472,19 @@ pub fn password_verifier(
/// Generate a fresh random salt and the matching [`password_verifier`] for a new account. Returns
/// `(salt, verifier)`, both little endian.
///
/// The salt's top bit is forced, exactly as vmangos' own account creation does it
/// (`BigNumber::SetRand(256)` → `BN_rand(_bn, 256, 0, 1)`, whose `top = 0` sets the MSB). The salt is
/// the one hashed value fixed for the life of the account rather than redrawn per handshake, so a
/// high-order zero byte there is not a 1-in-256 *login* failure but a permanently unloggable account
/// (crate docs, "Encoding-unambiguous handshakes").
pub fn generate_account(
username: &NormalizedString,
password: &NormalizedString,
) -> ([u8; 32], [u8; 32]) {
let mut salt = [0u8; 32];
thread_rng().fill_bytes(&mut salt);
salt[31] |= 0x80;
let verifier = password_verifier(username, password, &salt);
(salt, verifier)
}
@@ -438,9 +527,11 @@ mod tests {
}
}
/// A low-order zero byte in `S` must *not* shorten the split: WoW hashes all 32 bytes, so `S`
/// and `S` with its low byte zeroed differ only in that byte, never in length. (Trimming — the
/// SRP-6 RFC behaviour — is what made ~1 login in 256 come back as "wrong password".)
/// A low-order zero byte in `S` must *not* shorten the split: we hash all 32 bytes, so `S` and
/// `S` with its low byte zeroed differ only in that byte, never in length. (Trimming is the
/// SRP-6 RFC's — and the real client's — behaviour, and against vmangos it is what made ~1 login
/// in 256 come back as "wrong password"; `SrpClientChallenge::new` keeps the two identical by
/// never drawing an `S` with a zero low byte at all.)
#[test]
fn interleave_does_not_trim_low_order_zero_bytes() {
let mut s = [0u8; 32];
@@ -502,6 +593,60 @@ mod tests {
}
}
#[test]
fn width_stability_is_the_high_order_byte() {
assert!(is_width_stable(&[0, 0, 1]));
assert!(!is_width_stable(&[1, 1, 0]));
assert!(!is_width_stable(&[]));
}
/// The crate's guarantee: every handshake we hand out is one both serialization conventions read
/// identically, so no value carries a high-order zero byte. `A` lands ambiguous ~1 draw in 137
/// and `K`/`M1` ~1 in 256 each, so over this many draws a regression that dropped any one guard
/// is caught with ~97% probability. (The live gate is `benilla-protocol`'s `srp_encoding_probe`,
/// which forces each case against a real realmd.)
#[test]
fn every_drawn_handshake_is_encoding_unambiguous() {
let user = NormalizedString::new("alice").unwrap();
let pass = NormalizedString::new("password1").unwrap();
for i in 0..128u32 {
let mut b = [0u8; 32];
thread_rng().fill_bytes(&mut b);
b[31] |= 0x80; // the shape of server key `logon` keeps
let salt = std::array::from_fn(|j| (j as u8).wrapping_mul(11).wrapping_add(i as u8));
let c = SrpClientChallenge::new(
user.clone(),
pass.clone(),
GENERATOR,
LARGE_SAFE_PRIME_LITTLE_ENDIAN,
PublicKey::from_le_bytes(b).unwrap(),
salt,
);
assert!(is_width_stable(c.client_public_key()), "A, draw {i}");
assert!(is_width_stable(&c.session_key), "K, draw {i}");
assert!(is_width_stable(c.client_proof()), "M1, draw {i}");
// The M2 we would check against is built from those same bytes, so it round-trips.
let m2 = calculate_server_proof(
&PublicKey::from_le_bytes(*c.client_public_key()).unwrap(),
c.client_proof(),
&c.session_key,
);
assert!(c.verify_server_proof(m2).is_ok(), "M2, draw {i}");
}
}
/// A salt is fixed for the life of an account rather than redrawn per handshake, so an ambiguous
/// one is not a flaky login but an account that can never log in.
#[test]
fn generated_salts_are_encoding_unambiguous() {
let user = NormalizedString::new("alice").unwrap();
let pass = NormalizedString::new("password1").unwrap();
for _ in 0..2048 {
let (salt, _) = generate_account(&user, &pass);
assert!(is_width_stable(&salt));
}
}
#[test]
fn password_verifier_golden() {
let salt: [u8; 32] = std::array::from_fn(|i| (i as u8).wrapping_mul(11).wrapping_add(2));
+145
View File
@@ -73,6 +73,20 @@ pub struct SpellSlotView {
/// transcribed XML and refuses both [`CastSpell`]-family casts (this module) and, faithfully,
/// nothing else: a passive can still be picked up/placed on a bar (the ref never blocks that).
pub passive: bool,
/// The `IsCurrentCast` verdict for this slot — the checked ring (`SpellButton_UpdateSelection`'s
/// gold glow). The delegate `0x4b3600` has exactly two arms (wow-re
/// `spellbook-checked-predicate.md`): a shapeshift spell whose form is the player's current
/// form byte, or the open trade-skill window's own spell — never an ordinary in-flight cast.
/// App-resolved (`benilla::ui_spellbook`), pushed with the book; the app fires
/// `CURRENT_SPELL_CAST_CHANGED` on its edges.
pub current: bool,
/// The spell's running cooldown as `(start_ms on the GetTime clock, duration_ms, enabled)` —
/// the same app-computed triple [`super::ActionState::cooldown`] and the container slots
/// carry, resolved by the ONE cooldown store (`benilla::cooldowns::Cooldowns::info` — id,
/// category and GCD reads alike); `GetSpellCooldown` answers the reference's
/// `(start, duration, enable)`. `None` = cold. Frame-stable per arm (the absolute start), so
/// a running cooldown never churns the book diff.
pub cooldown: Option<(i64, u32, bool)>,
}
/// The player's known-spell book: tabs (skill lines) + the flat slot list every tab indexes into
@@ -235,6 +249,53 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// IsCurrentCast(id, bookType) — the spellbook button's checked ring (binding `0x4b4370` →
// delegate `0x4b3600`; ref
// `SpellButton_UpdateSelection` SetChecks on it). The verdict itself is app-resolved per slot
// ([`SpellSlotView::current`]); this only reads it back.
g.set(
"IsCurrentCast",
lua.create_function(|lua, (id, book_type): (u32, String)| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let current = slot_index(id, &book_type)
.and_then(|i| model.spellbook.slots.get(i))
.is_some_and(|s| s.current);
// The ref's binding convention: 1 or nil, never false.
match current {
true => Ok(Value::Integer(1)),
false => Ok(Value::Nil),
}
})?,
)?;
// GetSpellCooldown(id, bookType) → start, duration, enable — the book twin of
// `GetActionCooldown`/`GetContainerItemCooldown`, identical conventions: `GetTime`-clock
// `(seconds, seconds, 0/1)`, enable 0 = an on-hold record (parked, full duration), and the
// cold-at-expiry guard so an event-driven re-feed can never replay the finish flash. Pet or
// out-of-range answers the cold `(0, 0, 1)` — the ref's own no-cooldown shape.
g.set(
"GetSpellCooldown",
lua.create_function(|lua, (id, book_type): (u32, String)| {
let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0);
let model = lua.app_data_ref::<Model>().expect("model app_data");
let cooldown = slot_index(id, &book_type)
.and_then(|i| model.spellbook.slots.get(i))
.and_then(|s| s.cooldown);
Ok(match cooldown {
Some((start_ms, duration_ms, enabled)) => {
let (start, duration) =
(start_ms as f64 / 1000.0, f64::from(duration_ms) / 1000.0);
if start + duration > now || !enabled {
(start, duration, i32::from(enabled))
} else {
(0.0, 0.0, 1)
}
}
None => (0.0, 0.0, 1),
})
})?,
)?;
g.set(
"IsSpellPassive",
lua.create_function(|lua, (id, book_type): (u32, String)| {
@@ -365,6 +426,8 @@ mod tests {
rank: Some("Rank 1".into()),
texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()),
passive: false,
current: false,
cooldown: None,
},
SpellSlotView {
spell_id: 2136,
@@ -372,6 +435,8 @@ mod tests {
rank: Some("Rank 1".into()),
texture: Some("Interface\\Icons\\Spell_Fire_FireBolt02".into()),
passive: true, // artificial: exercises the refusal gate
current: false,
cooldown: None,
},
SpellSlotView {
spell_id: 168,
@@ -379,11 +444,91 @@ mod tests {
rank: Some("Rank 1".into()),
texture: Some("Interface\\Icons\\Spell_Frost_FrostArmor02".into()),
passive: false,
current: false,
cooldown: None,
},
],
}
}
/// `IsCurrentCast` reads the app-resolved per-slot verdict back as the ref's 1-or-nil.
#[test]
fn is_current_cast_reads_the_slot_verdict() {
let mut s = UiScript::new().unwrap();
assert!(s
.eval::<bool>("return IsCurrentCast(1, BOOKTYPE_SPELL) == nil")
.unwrap());
let mut b = book();
b.slots[0].current = true;
s.set_spellbook(b);
assert_eq!(
s.eval::<i64>("return IsCurrentCast(1, BOOKTYPE_SPELL)")
.unwrap(),
1
);
assert!(s
.eval::<bool>("return IsCurrentCast(2, BOOKTYPE_SPELL) == nil")
.unwrap());
assert!(s
.eval::<bool>("return IsCurrentCast(1, BOOKTYPE_PET) == nil")
.unwrap());
assert!(s
.eval::<bool>("return IsCurrentCast(99, BOOKTYPE_SPELL) == nil")
.unwrap());
}
/// `GetSpellCooldown` reads the app-pushed per-slot triple back as the ref's GetTime-clock
/// `(start, duration, enable)` — cold `(0, 0, 1)` for absent/pet/out-of-range, enable 0 for
/// an on-hold record regardless of expiry, and the cold-at-expiry guard once
/// `start + duration` passes (`GetActionCooldown`'s own conventions, the book twin).
#[test]
fn get_spell_cooldown_reads_the_slot_triple() {
let mut s = UiScript::new().unwrap();
s.set_spellbook(book());
s.tick(20.0); // GetTime = 20
// Cold slot: the ref's no-cooldown shape.
assert_eq!(
s.eval::<(f64, f64, i64)>("return GetSpellCooldown(1, BOOKTYPE_SPELL)")
.unwrap(),
(0.0, 0.0, 1)
);
let mut b = book();
b.slots[0].cooldown = Some((14_000, 10_000, true)); // running: 4 s elapsed of 10
b.slots[1].cooldown = Some((2_000, 8_000, false)); // on hold: parked since t=2
b.slots[2].cooldown = Some((5_000, 10_000, true)); // elapsed at t=15 — cold
s.set_spellbook(b);
assert_eq!(
s.eval::<(f64, f64, i64)>("return GetSpellCooldown(1, BOOKTYPE_SPELL)")
.unwrap(),
(14.0, 10.0, 1)
);
// On hold survives the expiry guard (enable 0 = the parked "hasn't begun").
assert_eq!(
s.eval::<(f64, f64, i64)>("return GetSpellCooldown(2, BOOKTYPE_SPELL)")
.unwrap(),
(2.0, 8.0, 0)
);
// Elapsed goes cold — an event-driven re-feed can never replay the finish flash.
assert_eq!(
s.eval::<(f64, f64, i64)>("return GetSpellCooldown(3, BOOKTYPE_SPELL)")
.unwrap(),
(0.0, 0.0, 1)
);
// The pet deferral and out-of-range answer cold too.
assert_eq!(
s.eval::<(f64, f64, i64)>("return GetSpellCooldown(1, BOOKTYPE_PET)")
.unwrap(),
(0.0, 0.0, 1)
);
assert_eq!(
s.eval::<(f64, f64, i64)>("return GetSpellCooldown(99, BOOKTYPE_SPELL)")
.unwrap(),
(0.0, 0.0, 1)
);
}
#[test]
fn tab_info_shapes_and_book_id_offsets() {
let mut s = UiScript::new().unwrap();
+21 -8
View File
@@ -6,9 +6,11 @@
// out = lerp(screen, blur, z) + w · blur² (FFXGlow.bls, exact)
//
// `w` is the per-zone LightParams glow weight (authored data — ≈0.647 in Elwynn), `z` is the haze
// mix (0 in the standard glow pass). The reference runs this in GAMMA bytes; our buffer is linear
// HDR, so the combine encodes both terms, does the byte math, clamps (the reference's saturating
// byte add), and decodes once — the present-encode then lands the reference's exact byte.
// mix (0 in the standard glow pass). The reference runs this in GAMMA bytes, and since 0161 the
// whole frame composites in gamma too — but in a FLOAT buffer, which unlike the reference's byte
// buffer does not saturate at 1.0 per draw. Every read of the scene RT below therefore clamps to
// 1.0 first (the byte semantics restored at the read), does the byte math, and decodes once — the
// present-encode then lands the reference's exact byte.
// The blur² is the whole character of the vanilla glow: mid-tones (0.5 → 0.25) barely bloom,
// highlights bloom fully — a square-law a linear-composite bloom cannot express (decision 0158).
//
@@ -29,13 +31,21 @@
// The reference's ONE downsample: full → ¼ directly, via Box4 — 4 bilinear taps at source-texel
// offsets {−1.5, +0.5}² (a 4×4 footprint; byte-pinned table 0xce89cc, wow-re blur-geometry.md).
//
// Each tap clamps to 1.0 BEFORE the average: the reference reads a BYTE scene RT, where an
// additive stack saturated at 255 draw by draw — our float buffer keeps summing past 1.0, and an
// unclamped read here feeds that super-white into `w·blur²`, which squares it into a huge hard
// glow disc (the Frost Nova mist stack — ~400 additive puffs — reaches ~5–10 in-buffer). The
// clamp is the byte buffer's saturating-add semantics applied at the lane's read (0161's own
// premise: the framebuffer holds bytes).
@fragment
fn fs_downsample(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
let texel = 1.0 / vec2<f32>(textureDimensions(in_tex));
return (textureSample(in_tex, in_samp, in.uv + vec2<f32>(-1.5, -1.5) * texel)
+ textureSample(in_tex, in_samp, in.uv + vec2<f32>(0.5, -1.5) * texel)
+ textureSample(in_tex, in_samp, in.uv + vec2<f32>(0.5, 0.5) * texel)
+ textureSample(in_tex, in_samp, in.uv + vec2<f32>(-1.5, 0.5) * texel))
let one = vec4<f32>(1.0);
return (min(textureSample(in_tex, in_samp, in.uv + vec2<f32>(-1.5, -1.5) * texel), one)
+ min(textureSample(in_tex, in_samp, in.uv + vec2<f32>(0.5, -1.5) * texel), one)
+ min(textureSample(in_tex, in_samp, in.uv + vec2<f32>(0.5, 0.5) * texel), one)
+ min(textureSample(in_tex, in_samp, in.uv + vec2<f32>(-1.5, 0.5) * texel), one))
* 0.25;
}
@@ -81,7 +91,10 @@ fn fs_combine(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
// the reference's byte RTs) and the combine is raw byte math. The single srgb_to_linear here
// is THE frame's one decode: the sRGB present-encode then restores every byte exactly.
let scene = textureSample(in_tex, in_samp, in.uv);
let sg = max(scene.rgb, vec3<f32>(0.0));
// Clamped to [0, 1] like the downsample's taps: the byte framebuffer this term stands for
// could never exceed 255, and an unclamped super-white here keeps the whole additive stack
// hard-white after the min() below — the screen term must saturate BEFORE the glow add.
let sg = clamp(scene.rgb, vec3<f32>(0.0), vec3<f32>(1.0));
let bg = max(textureSample(blur_tex, in_samp, in.uv).rgb, vec3<f32>(0.0));
let glowed = sg + glow.x * bg * bg;
// The scene alpha rides through untouched: the window surface ignores it and the opaque-clear
@@ -377,6 +377,15 @@ fn vertex(vertex: WowVertex) -> WowVsOut {
out.world_position =
mesh_functions::mesh_position_local_to_world(world_from_local, vec4<f32>(vertex.position, 1.0));
out.position = position_world_to_clip(out.world_position.xyz);
// WMO authored batch order (`m.sun_scale.y`; 0 = non-WMO ⇒ exact no-op): the client resolves
// coplanar batches (wall + decal/trim) by strict MOBA draw order under depth-write + LEQUAL
// (wow-5875-re `wmo-batch-blend-depth-state.md`, byte-verified); Bevy orders draws for
// batching, so a later batch must instead WIN the reverse-Z GreaterEqual test. Scaling clip z
// by (1 + n·2⁻²³) raises the interpolated depth z/w by exactly n ULP-steps per fragment —
// the same one-unit-per-index nudge the old fixed-function `DepthBiasState` constant applied,
// but as uniform DATA: as pipeline state it made every batch index its own pipeline, and a
// first city sight synchronously compiled ~3000 of them on the render thread (decision 0837).
out.position.z *= 1.0 + m.sun_scale.y * 1.1920929e-7;
#endif
#ifdef VERTEX_UVS_A
+65 -22
View File
@@ -39,16 +39,21 @@
flash (`SpellBookTabFlashFrame`, `CheckButtonGlow`) is dropped with it — no such event
exists yet (no "just learned a spell" signal split out of the general `SPELLS_CHANGED`
diff).
- Cooldowns (`GetSpellCooldown`/`CooldownFrameTemplate`) and pet autocast
(`GetSpellAutocast`/`ToggleSpellAutocast`/`UI-AutoCastableOverlay`/the `AutoCast`
model): no cooldown feed exists (the same named gap as CharacterFrame's own item
cooldowns), and autocast is pet-only. Right-click pet-autocast toggle
- Pet autocast (`GetSpellAutocast`/`ToggleSpellAutocast`/`UI-AutoCastableOverlay`/the
`AutoCast` model): pet-only, no pet book. Right-click pet-autocast toggle
(`SpellButton_OnClick`'s `arg1 ~= "LeftButton"` branch) is out of scope this slice
per the same brief — every click routes straight to cast/pickup.
- The "currently casting" checked ring (`SpellButton_UpdateSelection`/`IsCurrentCast`,
`CURRENT_SPELL_CAST_CHANGED`): no cast-selection tracking exists for the spellbook
yet — the `CheckedTexture` art ships (harmless, matches the ref) but nothing ever
calls `SetChecked(1)` on a spell button this slice.
per the same brief — every click routes straight to cast/pickup. (Cooldowns —
originally deferred here alongside autocast for want of a feed — are LIVE:
`GetSpellCooldown` + the native Cooldown child + `SPELL_UPDATE_COOLDOWN`, ref
`SpellButton_UpdateButton` l.359-365; the app feed stamps each slot's triple from
the one cooldown store, `benilla::ui_spellbook`. CharacterFrame's item cooldowns
remain that file's own named gap.)
- The CRAFT_SHOW/_CLOSE/TRADE_SKILL_SHOW/_CLOSE legs of the checked ring: the ring
itself is LIVE (`BenillaSpellButton_UpdateSelection`/`IsCurrentCast` over the
app-resolved slot verdict — the shapeshift arm makes Ghost Wolf's/a druid form's
slot glow while its form is up), but the trade-skill-window arm of the engine
predicate is unmodeled (the same named gap `ui_action`'s IsCurrentAction carries),
so those four events would re-read an arm that never fires — omitted with it.
- The MacroFrame branch (`SpellButton_OnClick`'s shift-click "already in a macro editor"
fork): no macro window exists — shift-click always falls to the ref's own "else"
(`PickupSpell`), exactly what the ref itself does whenever no MacroFrame is visible.
@@ -193,23 +198,38 @@
end
-- ── The spell button (ref SpellButton_*, l.206-404) ──────────────────────────────────────
-- CRAFT_SHOW/_CLOSE/TRADE_SKILL_SHOW/_CLOSE/PET_BAR_UPDATE/CURRENT_SPELL_CAST_CHANGED/
-- SPELL_UPDATE_COOLDOWN are all OMITTED (header comment: no craft/trade-skill window, no
-- pet bar, no cast-selection tracking, no cooldown feed) — SPELLS_CHANGED alone drives a
-- refresh.
-- CRAFT_SHOW/_CLOSE/TRADE_SKILL_SHOW/_CLOSE/PET_BAR_UPDATE are OMITTED (header comment:
-- no craft/trade-skill window arm, no pet bar); SPELLS_CHANGED and SPELL_UPDATE_COOLDOWN
-- drive the full refresh (the ref's own shared branch, l.221) and
-- CURRENT_SPELL_CAST_CHANGED the checked ring (ref SpellButton_OnEvent's own split).
function BenillaSpellButton_OnLoad(button)
button:RegisterEvent("SPELLS_CHANGED")
button:RegisterEvent("CURRENT_SPELL_CAST_CHANGED")
button:RegisterEvent("SPELL_UPDATE_COOLDOWN")
button:RegisterForDrag("LeftButton")
button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
BenillaSpellButton_UpdateButton(button)
end
function BenillaSpellButton_OnEvent(button)
if event == "SPELLS_CHANGED" then
if event == "SPELLS_CHANGED" or event == "SPELL_UPDATE_COOLDOWN" then
BenillaSpellButton_UpdateButton(button)
elseif event == "CURRENT_SPELL_CAST_CHANGED" then
BenillaSpellButton_UpdateSelection(button)
end
end
-- ref SpellButton_UpdateSelection (l.292-306): the checked ring — `IsCurrentCast` over
-- the app-resolved verdict (the ACTIVE shapeshift form's own spell — Ghost Wolf glows
-- while the shaman is the wolf, a druid form likewise; the engine predicate `0x4b3600`
-- deliberately has NO casting-now arm, so an ordinary cast never lights a book slot).
-- The ref's out-of-tab bounds fork collapses into the one SetChecked: IsCurrentCast
-- answers nil past the book's end already.
function BenillaSpellButton_UpdateSelection(button)
local id = BenillaSpellBook_GetSpellID(button:GetID())
button:SetChecked(IsCurrentCast(id, BenillaSpellBookFrame.bookType))
end
-- ref SpellButton_OnEnter (l.235-244): the engine's SetSpell — the byte-verified spell
-- line law (decision 0276: cost|range one line, casttime|cooldown one line, passives
-- omit it, the $-substituted gold description; no rank column from the book — the
@@ -237,13 +257,14 @@
PickupSpell(id, BenillaSpellBookFrame.bookType)
else
CastSpell(id, BenillaSpellBookFrame.bookType)
-- SpellButton_UpdateSelection() OMITTED — header comment, no cast-selection ring.
BenillaSpellButton_UpdateSelection(button) -- ref l.288: the ring re-reads at once
end
end
-- ref SpellButton_UpdateButton (l.307-404): the cooldown sweep, autocast overlay/model,
-- and the "currently casting" checked-ring call are all OMITTED (header comment). Passive
-- gates CastSpell engine-side (spellbook.rs), not here — this only grays the presentation.
-- ref SpellButton_UpdateButton (l.307-404): the autocast overlay/model is OMITTED
-- (header comment); the cooldown sweep (l.359-365) and the checked-ring tail call are
-- LIVE. Passive gates CastSpell engine-side (spellbook.rs), not here — this only grays
-- the presentation.
function BenillaSpellButton_UpdateButton(button)
if not button:IsVisible() then
return
@@ -263,11 +284,13 @@
local iconTexture = getglobal(name .. "IconTexture")
local spellString = getglobal(name .. "SpellName")
local subSpellString = getglobal(name .. "SubSpellName")
local cooldown = getglobal(name .. "Cooldown")
if id > (offset + numSpells) then
button:Disable()
iconTexture:Hide()
spellString:Hide()
subSpellString:Hide()
cooldown:Hide()
button:SetChecked(0)
getglobal(name .. "NormalTexture"):SetVertexColor(1.0, 1.0, 1.0)
return
@@ -281,11 +304,22 @@
iconTexture:Hide()
spellString:Hide()
subSpellString:Hide()
cooldown:Hide()
highlightTexture:SetTexture("Interface\\Buttons\\ButtonHilight-Square")
button:SetChecked(0)
normalTexture:SetVertexColor(1.0, 1.0, 1.0)
return
end
-- The cooldown sweep + the enable-keyed icon dim (ref l.359-365): enable == 0 is an
-- on-hold cooldown (Stealth/Feign Death parked until SMSG_COOLDOWN_EVENT) — the
-- widget stays hidden (CooldownFrame_SetTimer's own gate) and the icon reads 40%.
local start, duration, enable = GetSpellCooldown(id, BenillaSpellBookFrame.bookType)
CooldownFrame_SetTimer(cooldown, start, duration, enable)
if enable == 1 then
iconTexture:SetVertexColor(1.0, 1.0, 1.0)
else
iconTexture:SetVertexColor(0.4, 0.4, 0.4)
end
local spellName, subSpellName = GetSpellName(id, BenillaSpellBookFrame.bookType)
local isPassive = IsSpellPassive(id, BenillaSpellBookFrame.bookType)
if isPassive then
@@ -298,7 +332,6 @@
spellString:SetTextColor(NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b)
end
iconTexture:SetTexture(texture)
iconTexture:SetVertexColor(1.0, 1.0, 1.0) -- no cooldown/enable feed: always full bright
spellString:SetText(spellName)
subSpellString:SetText(subSpellName)
if subSpellName and subSpellName ~= "" then
@@ -309,6 +342,7 @@
iconTexture:Show()
spellString:Show()
subSpellString:Show()
BenillaSpellButton_UpdateSelection(button) -- ref l.403: every full refresh re-reads the ring
end
-- ── Page turn (ref PrevPageButton_OnClick/NextPageButton_OnClick, l.406-434) ─────────────
@@ -425,9 +459,10 @@
The spell button (ref SpellButtonTemplate, l.74-201): 37x37, the UI-Spellbook-SpellBackground
64x64 backdrop offset (-3,3), the icon (setAllPoints, hidden until fed), the name/rank
FontStrings anchored off the button's own RIGHT edge, the UI-Quickslot2 ring + Depress push +
ADD highlight + the (currently-unused, header comment) CheckedTexture ring. Cooldown
($parentCooldown, CooldownFrameTemplate) and the AutoCast overlay/model are OMITTED (this
file's header comment — no cooldown feed, pet-only autocast).
ADD highlight + the CheckedTexture ring (`IsCurrentCast`'s glow), and the Cooldown child
(ref l.136: $parentCooldown inherits CooldownFrameTemplate — ours is the native Cooldown
widget at the template's own 36x36 CENTER (0,-1), the ActionBar/BagFrame transcription).
The AutoCast overlay/model is OMITTED (this file's header comment — pet-only autocast).
-->
<CheckButton name="BenillaSpellButtonTemplate" virtual="true">
<Size><AbsDimension x="37" y="37"/></Size>
@@ -459,6 +494,14 @@
</FontString>
</Layer>
</Layers>
<Frames>
<Cooldown name="$parentCooldown" hidden="true">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
</Anchors>
</Cooldown>
</Frames>
<Scripts>
<OnLoad>BenillaSpellButton_OnLoad(self)</OnLoad>
<OnEvent>BenillaSpellButton_OnEvent(self)</OnEvent>
+35 -8
View File
@@ -104,14 +104,22 @@ fn parse_pixels(spec: &str) -> Vec<Vec2> {
.collect()
}
/// The filter deciding what a cast may hit — see [`fire_pick`]'s `objects` for why it is both.
type Pickable = Or<(With<WorldObject>, With<crate::debug_panel::ModelPart>)>;
/// What a hit entity is asked for: its identity, the batch class it draws as, the material
/// carrying the WMO batch order, and its per-instance `MeshTag` (`Option` because a hit need not be a
/// model batch at all).
/// model batch at all — and an equipped item's batch carries no [`WorldObject`]).
type HitIdentity = (
&'static WorldObject,
Option<&'static WorldObject>,
Option<&'static crate::debug_panel::ModelPart>,
Option<&'static MeshMaterial3d<WowModelMaterial>>,
Option<&'static MeshTag>,
// Is this hit a camera-FACING batch (decision 0153's world-root card) or the model's own
// geometry? The two are indistinguishable in the pixels and lead to completely different
// diagnoses — a card that should be culled vs a submesh drawn with the wrong texture — so the
// cast says which. (`card` in the line below.)
Has<crate::billboard::BillboardCard>,
);
/// Every shading input the batch actually has, as text — the five packed uniform rows and the base
@@ -172,7 +180,15 @@ fn fire_pick(
time: ProbeClock,
window: Query<&Window, With<PrimaryWindow>>,
camera: Query<(&Camera, &GlobalTransform), With<WorldCamera>>,
objects: Query<Entity, With<WorldObject>>,
// What the ray may hit. `WorldObject` is the world's identity tag (doodads, WMOs, unit BODY
// parts) — but an EQUIPPED item carries none: the item lane spawns its parts and cards without
// one, because that component also feeds the mouseover/targeting fallback and a weapon is not a
// target. So the probe would answer "what is at this pixel" for a character's skin and silently
// skip its helm, its pauldrons, its weapon and every camera-facing card hanging off them —
// exactly the geometry a "this bit of my gear looks wrong" report is about (found chasing the
// Field Marshal pauldron, decision 0836). `ModelPart` rides every drawn batch in the client, so
// the probe keys on it too and stays a pure instrument: nothing outside this file reads it.
objects: Query<Entity, Pickable>,
names: HitNames,
mut ray_cast: MeshRayCast,
) {
@@ -239,7 +255,7 @@ fn fire_pick(
)
});
previous = Some((hit.distance, hit.point, hit.normal));
let Ok((obj, part, mat, tag)) = names.identity.get(*entity) else {
let Ok((obj, part, mat, tag, card)) = names.identity.get(*entity) else {
info!(" {i:2} {:9.4} yd <untagged>{gap}", hit.distance);
continue;
};
@@ -262,15 +278,26 @@ fn fire_pick(
// behind wins a pixel is decided by sub-pixel coverage rather than by depth — a
// completely different defect from a depth fight, and indistinguishable in the pixels.
let incidence = ray.direction.dot(hit.normal).abs().clamp(0.0, 1.0).asin();
// An untagged batch — an equipped item's part or its billboard card — has no world
// identity of its own; it is named by its blend + material + texture, which is what
// separates "the pauldron's plate" from "the pauldron's camera-facing trim".
let (kind, id, label) = obj.map_or_else(
|| ("<worn>".to_string(), String::new(), String::new()),
|o| {
(
format!("{:?}", o.kind),
format!("#{}", o.id),
o.label.clone(),
)
},
);
info!(
" {i:2} {:9.4} yd {:?} #{:<10} bias {batch:3} {:?} {:5.1}° to the ray mat {:?} tex {tex} {}{gap}",
" {i:2} {:9.4} yd {kind} {id:<10} bias {batch:3} {:?}{} {:5.1}° to the ray mat {:?} tex {tex} {label}{gap}",
hit.distance,
obj.kind,
obj.id,
part.map(|p| p.blend),
if card { " CARD" } else { "" },
incidence.to_degrees(),
mat.map(|m| m.0.id()),
obj.label,
);
// The contents, not the identity — the whole point (see [`shading_of`]). Printed for
// every hit on every cast: with `WOW_PICK_EVERY=0` that is a per-frame record of every
+27 -2
View File
@@ -485,6 +485,14 @@ pub(crate) struct MatAnim {
/// bits 0..=15 since the 0355 re-lane) — and on the unit lane, whose own compose is
/// [`crate::entities::apply_unit_mat_alpha`].
pub(crate) drives_tag: bool,
/// This instance belongs to the **unit lane's** tag compose
/// ([`crate::entities::apply_unit_mat_alpha`]) even though it has no [`Self::host`] to read a
/// sequence from: the ATTACH-MODEL case (a held weapon, a helm, a pauldron). Such a model
/// spawns no rig — it rests in its file's first sequence, so its loops are pinned like a
/// placed doodad's — but it hangs off a unit, so the compose has to be the one ordered against
/// the wearer's appear-fade and interior classifier rather than the world-model visibility
/// authority's. See [`Self::resting`].
unit_lane: bool,
/// The last sampled combined factor (colour-alpha × weight), read by the visibility authority.
pub current: f32,
}
@@ -503,6 +511,7 @@ impl MatAnim {
spawned_at: now,
frozen,
drives_tag: false,
unit_lane: false,
current,
}
}
@@ -537,10 +546,26 @@ impl MatAnim {
}
}
/// The **attach-model** constructor (a held weapon, a helm, a pauldron): the loops are pinned
/// to the file's first sequence — an item model spawns no rig and rests there, the same reason
/// its emitters and ribbons read Stand — while the tag compose stays the unit lane's, ordered
/// against the wearer's appear-fade and interior classifier.
///
/// Its clock is irrelevant in practice (a rest-pose model's tracks are the constants the file
/// authors) so it takes no `now`; sampling still runs every frame like every other lane, so a
/// keyed track on a rest sequence animates rather than latching at its first key.
pub(crate) fn resting(anim: std::sync::Arc<benilla_formats::AlphaAnim>) -> Self {
Self {
unit_lane: true,
..Self::new(anim, 0.0, false)
}
}
/// Whether this instance's tag alpha is the unit lane's to compose (see
/// [`crate::entities::apply_unit_mat_alpha`]).
/// [`crate::entities::apply_unit_mat_alpha`]) — a hosted creature/player batch, or an
/// attach model's batch ([`Self::resting`]), and never a self-driving effect part.
pub(crate) fn composes_unit_tag(&self) -> bool {
self.host.is_some() && !self.drives_tag
(self.host.is_some() || self.unit_lane) && !self.drives_tag
}
}
+123 -64
View File
@@ -20,9 +20,7 @@ use crate::billboard::BillboardCard;
use crate::debug_panel::{ModelKind, ModelPart};
use crate::interact::WorldObject;
use crate::interior::part_interior_lit;
use crate::model_fade::{
fade_alpha, FadeMaterials, JoinedFade, PendingAppearFade, RenderFade, APPEAR_FADE_SECS,
};
use crate::model_fade::{FadeSet, JoinedFade, PartFade};
use crate::terrain::WowModelMaterial;
use super::super::EntityPart;
@@ -105,6 +103,19 @@ pub(super) fn part_materials<'a>(
}
}
impl<'a> PartMaterials<'a> {
/// The three handles the appear/despawn ramps feather through — this batch's slice of
/// [`FadeSet`], so the spawn and the fade can never disagree about which twin it wears.
pub(super) fn fade_set(&self) -> FadeSet<'a> {
FadeSet {
steady: self.steady,
blend: self.fade_blend,
bake_blend: self.bake_blend,
zfill: self.zfill,
}
}
}
/// Everything one part's spawn reads from its UNIT — gathered once per visual so the per-part body
/// isn't a twenty-argument call, and so the spawn path and the re-dress path provably feed it the
/// same things.
@@ -140,15 +151,6 @@ pub(super) struct PartDress<'a> {
pub(super) fade: JoinedFade,
}
/// One part's resolved appear-fade for this spawn: [`JoinedFade`] folded with whether the part is
/// fade-capable at all (a WMO-display batch has no blend twin).
#[derive(Clone, Copy)]
enum PartFade {
Steady,
Pending(f32),
Live(f32),
}
/// Spawn one part of `dress.unit`'s model as a child of it, dressed by [`part_materials`]. Returns
/// whether it armed an appear-fade — the attach path mirrors that onto the unit root so a held item
/// resolving later can join the same ramp.
@@ -164,29 +166,16 @@ pub(super) fn spawn_part(
dress: &PartDress,
) -> bool {
if let Some(info) = &part.billboard {
spawn_billboard_part(commands, part, index, info, dress);
return false;
return spawn_billboard_part(commands, part, index, info, dress);
}
let mats = part_materials(part, dress.char_mats);
// Per-part join decision: the unit's clock combined with whether this part is fade-capable.
let effective = match (dress.fade, mats.fade_blend) {
(_, None) => PartFade::Steady,
(JoinedFade::Steady, _) => PartFade::Steady,
(JoinedFade::Pending { since }, _) => PartFade::Pending(since),
(JoinedFade::Live { started }, _) => PartFade::Live(started),
};
let set = mats.fade_set();
// A freshly-streamed CGObject appear-fades in (decision 0032): spawn already on the blend twin
// with a ≈0 `MeshTag`, so it doesn't flash opaque for a frame before `apply_render_fade` ramps
// `α = t³`. A joiner computes the ramp's *current* alpha rather than 0, so it doesn't flash
// invisible for a frame either.
let (init_mat, tag_alpha) = match effective {
PartFade::Steady => (mats.steady.clone(), 1.0),
PartFade::Pending(_) => (mats.fade_blend.cloned().unwrap_or_default(), 0.0),
PartFade::Live(started) => (
mats.fade_blend.cloned().unwrap_or_default(),
fade_alpha(0.0, 1.0, (dress.now - started) / APPEAR_FADE_SECS),
),
};
let effective = PartFade::resolve(dress.fade, &set);
let (init_mat, tag_alpha) = effective.seed(&set, dress.now);
// A skinned creature part draws its skinned-mesh twin (the WOW joint attributes → the
// owned-palette `WOW_RIG_SKIN` shader path, decision 0720); everything else — and the
// palette-full fallback (slot 0) — the static mesh. Keyed on the part having a twin, which the
@@ -280,50 +269,21 @@ pub(super) fn spawn_part(
dress.unit,
));
}
// `FadeMaterials` is persistent bookkeeping — the self-avatar zoom feather and decision 0032's
// despawn fade-out both re-arm from it — not a record of whether *this* spawn happened to join
// an in-flight ramp. Attach it whenever the part is fade-capable at all, steady or not; the item
// lane (`equipment::spawn`) has always done it this way, and gating it on the arm is what left a
// player who had ever swapped a shirt unable to fade out on stream-out.
if let Some(blend) = mats.fade_blend {
child.insert(FadeMaterials {
cutout: mats.steady.clone(),
blend: blend.clone(),
bake_blend: mats.bake_blend.cloned(),
zfill: mats.zfill.cloned(),
});
}
match effective {
PartFade::Steady => false,
PartFade::Pending(since) => {
// `arm_appear_fade` starts the ramp once the world is on-screen (not behind the loading
// screen), so it plays where the player can see it.
child.insert(PendingAppearFade { since });
true
}
PartFade::Live(started) => {
child.insert(RenderFade {
started,
duration: APPEAR_FADE_SECS,
from: 0.0,
to: 1.0,
});
true
}
}
effective.dress(&mut child, &set)
}
/// The camera-facing half of [`spawn_part`]: a mirror anchor under the unit (so the portrait /
/// paper-doll booths can rebuild the batch — the visible card is a world ROOT and can never be
/// mirrored) plus the card itself, following the batch's live joint when the unit is rigged and the
/// anchor otherwise.
/// anchor otherwise. Returns whether the card armed the unit's appear-fade, exactly as a mesh part
/// does: the batch is the model's, so it fades with the model (decision 0836).
fn spawn_billboard_part(
commands: &mut Commands,
part: &EntityPart,
index: usize,
info: &benilla_assets::BillboardInfo,
dress: &PartDress,
) {
) -> bool {
let anchor = commands
.spawn((
Transform::default(),
@@ -350,9 +310,22 @@ fn spawn_billboard_part(
} else {
BillboardCard::following(info, owner)
};
// A card is a batch of the unit's own model, so it joins the unit's appear-fade like any other
// batch of it — the reference has ONE instance alpha per model and every batch draws through it
// (decision 0836). Splitting the batch into a world-root entity is benilla's parenting detail;
// it is not a reason for the glow on a weapon's gems to blaze at full strength over a body that
// has not appeared yet.
let set = FadeSet {
steady: &part.material,
blend: part.fade_blend.as_ref(),
bake_blend: part.material_interior_bake_blend.as_ref(),
zfill: part.zfill.as_ref(),
};
let effective = PartFade::resolve(dress.fade, &set);
let (init_mat, tag_alpha) = effective.seed(&set, dress.now);
let mut card = commands.spawn((
Mesh3d(part.mesh.clone()),
MeshMaterial3d(part.material.clone()),
MeshMaterial3d(init_mat),
Transform::default(),
ModelPart {
kind: dress.kind,
@@ -366,7 +339,9 @@ fn spawn_billboard_part(
// even though it is never skinned by it — it is a batch of the unit's own model, so a tinted
// unit tints its eye-glow and torch cards too (decision 0812). No char-slot variants are
// consulted: a body/hair/cape/skin-extra batch is never a billboard batch.
card.insert(MeshTag(dress.rig_tag | crate::mesh_tag::alpha_bits(1.0)));
card.insert(MeshTag(
dress.rig_tag | crate::mesh_tag::alpha_bits(tag_alpha),
));
// The card's build-time bound (decision 0834) — `calculate_bounds` can no longer derive one
// from the `RENDER_WORLD`-only static form's data.
if let Some(aabb) = part.aabb {
@@ -389,6 +364,7 @@ fn spawn_billboard_part(
dress.unit,
));
}
let armed = effective.dress(&mut card, &set);
let card = card.id();
// The card follows the JOINT when the unit is rigged, so it does not cascade with the anchor's
// despawn — the re-dress has to name it to reap it (see [`DressedPart::card`]).
@@ -396,11 +372,13 @@ fn spawn_billboard_part(
index: index as u32,
card: Some(card),
});
armed
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model_fade::{FadeMaterials, PendingAppearFade};
/// One synthetic batch at `char_slot`/`two_sided`, carrying its own (distinguishable) built
/// material set so the fallback arm is visible in the assertions.
@@ -534,6 +512,87 @@ mod tests {
assert!(!found[0].1, "…without arming an appear fade");
}
/// **A camera-facing batch fades with its model** (decision 0836): the split into a world-ROOT
/// card is benilla's parenting detail (0153), not a law — the reference has one instance alpha
/// per `CM2Model` and every batch draws through it, billboard batches included. The card used
/// to open at a flat opaque with no arm and no record, which is a night-elf's eye glow and an
/// undead's shoulder wisps burning at full strength over a body still at α 0.
///
/// Asserted on the CARD, not the anchor: the anchor is a bare mirror carrier with no geometry.
#[test]
fn a_billboard_card_joins_the_units_appear_fade() {
const SINCE: f32 = 5.0;
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default()))
.init_asset::<Mesh>();
let unit = app.world_mut().spawn(Transform::default()).id();
let mut card = part(None, false);
card.fade_blend = Some(mat(99));
card.billboard = Some(benilla_assets::BillboardInfo {
pivot: Vec3::ZERO,
bone: 1,
kind: benilla_formats::BillboardKind::Spherical,
scale_anim: None,
seq_translations: Vec::new(),
});
let anchors = std::collections::HashMap::new();
let object = WorldObject {
kind: ModelKind::Creature,
label: String::new(),
id: 0,
detail: String::new(),
};
let empty: CharSkinMaterials = (None, None, None, (None, None));
let dress = PartDress {
unit,
kind: ModelKind::Creature,
char_mats: &empty,
object: &object,
rig_tag: 0,
inst_slot: 0,
rigged: false,
anchors: &anchors,
bake_center: Vec3::ZERO,
idle_aabb: None,
now: 0.0,
fade: JoinedFade::Pending { since: SINCE },
};
let mut queue = bevy::ecs::world::CommandQueue::default();
let armed = {
let world = app.world();
let mut commands = Commands::new(&mut queue, world);
spawn_part(&mut commands, &card, 0, &dress)
};
queue.apply(app.world_mut());
assert!(
armed,
"a card arms the ramp like a mesh part — the attach path mirrors that onto the root",
);
let mut q = app.world_mut().query::<(
&BillboardCard,
&MeshTag,
&MeshMaterial3d<WowModelMaterial>,
&FadeMaterials,
&PendingAppearFade,
)>();
let found: Vec<_> = q
.iter(app.world())
.map(|(_, t, m, fm, p)| (t.0, m.0.clone(), fm.blend.clone(), p.since))
.collect();
assert_eq!(found.len(), 1, "one billboard batch, one card");
assert_eq!(found[0].3, SINCE, "joined the unit's own pending clock");
assert_eq!(
found[0].2,
mat(99),
"the record names the batch's blend twin"
);
assert_eq!(found[0].1, mat(99), "…and the card OPENS on it");
assert!(
crate::mesh_tag::alpha_of(found[0].0) <= 1.0 / 63.0,
"…at the encoder's ≈0 floor",
);
}
/// A batch with no character slot — and a slot whose row the appearance didn't resolve (a bald
/// style has no hair texture, only tauren author an extra skin) — keeps the shared model's own
/// built materials. That is the arm a creature, a GameObject and an unresolvable look all take.
+153 -60
View File
@@ -9,23 +9,23 @@ use bevy::prelude::*;
use crate::billboard::BillboardCard;
use crate::debug_panel::{ModelKind, ModelPart};
use crate::interior::part_interior_lit;
use crate::model_fade::{
fade_alpha, join_unit_appear_fade, FadeMaterials, JoinedFade, PendingAppearFade, RenderFade,
UnitAppearFade, APPEAR_FADE_SECS,
};
use crate::model_fade::{join_unit_appear_fade, FadeSet, JoinedFade, PartFade, UnitAppearFade};
use super::super::{item_glow::ItemGlow, spawn_carried_lights};
use super::{
attach_id, BoneAttach, HeldAttached, HeldItems, HeldSlot, ItemDisplays, ATTACH_SLOTS, NO_GLOW,
};
/// A held-item part's resolved appear-fade join state for this spawn, folding [`JoinedFade`] with
/// whether the part itself is fade-capable ([`super::EntityPart::fade_blend`]).
#[derive(Clone, Copy)]
enum PartFade {
Steady,
Pending(f32),
Live(f32),
/// The three material handles one attach-model batch fades through — the item lane's
/// [`FadeSet`], read straight off the built part (an item batch never carries a character
/// runtime slot the way a body batch does).
fn item_fade_set(part: &super::super::EntityPart) -> FadeSet<'_> {
FadeSet {
steady: &part.material,
blend: part.fade_blend.as_ref(),
bake_blend: part.material_interior_bake_blend.as_ref(),
zfill: part.zfill.as_ref(),
}
}
/// Everything one slot's spawn needs from its WEARER, read once per unit — the context
@@ -312,24 +312,11 @@ fn spawn_slot(
continue;
}
// Per-part join decision: `joined` (the unit's clock) combined with whether this
// part is fade-capable at all (`fade_blend` — WMO-display parts have none, though
// held items are always M2). Steady = spawn opaque now, exactly as before this fix.
let effective = match (joined, part.fade_blend.is_some()) {
(_, false) => PartFade::Steady,
(JoinedFade::Steady, true) => PartFade::Steady,
(JoinedFade::Pending { since }, true) => PartFade::Pending(since),
(JoinedFade::Live { started }, true) => PartFade::Live(started),
};
let (init_mat, tag_alpha) = match effective {
PartFade::Steady => (part.material.clone(), 1.0),
PartFade::Pending(_) => (part.fade_blend.clone().unwrap(), 0.0),
// Compute the *current* alpha (not 0) so a late joiner doesn't flash invisible
// for a frame before `apply_render_fade` catches up — it's already mid-ramp.
PartFade::Live(started) => (
part.fade_blend.clone().unwrap(),
fade_alpha(0.0, 1.0, (now - started) / APPEAR_FADE_SECS),
),
};
// part is fade-capable at all. A pending part opens on the blend twin at ≈0 and a
// joiner at the ramp's *current* alpha, so neither flashes for a frame.
let set = item_fade_set(part);
let effective = PartFade::resolve(joined, &set);
let (init_mat, tag_alpha) = effective.seed(&set, now);
let mut child = parent.spawn((
Mesh3d(part.mesh.clone()),
MeshMaterial3d(init_mat),
@@ -382,31 +369,18 @@ fn spawn_slot(
) {
child.insert(lit);
}
// `FadeMaterials` is persistent bookkeeping (self-avatar zoom fade, decision 0032's
// despawn-fade-out), not tied to whether *this* spawn happens to join an in-flight
// unit fade — attach it whenever the part is fade-capable at all, steady or not.
if let Some(blend) = &part.fade_blend {
child.insert(FadeMaterials {
cutout: part.material.clone(),
blend: blend.clone(),
bake_blend: part.material_interior_bake_blend.clone(),
zfill: part.zfill.clone(),
});
}
match effective {
PartFade::Steady => {}
PartFade::Pending(since) => {
child.insert(PendingAppearFade { since });
}
PartFade::Live(started) => {
child.insert(RenderFade {
started,
duration: APPEAR_FADE_SECS,
from: 0.0,
to: 1.0,
});
}
// The batch's authored **material alpha** — the verified combine's `colourAlpha ×
// weight` (wow-re `m2-alpha-combine-cull.md`). An attach model spawns no rig of its
// own and rests in its file's first sequence, so its loops are PINNED there (the
// doodad lane's clock) while the tag compose stays the unit lane's, ordered against
// the appear-fade and the interior classifier exactly like the wearer's own batches.
// Skipping this drew every one of the 321 item models that dim a batch at full
// strength — the Hungering Cold's five glow cards blaze at 1.0 where the file says
// 0.30 (decision 0836).
if let Some(anim) = &part.alpha_anim {
child.insert(crate::doodad_anim::MatAnim::resting(anim.clone()));
}
effective.dress(&mut child, &set);
}
});
// The billboard cards (decision 0153): world-root entities FOLLOWING `root` — it sits
@@ -431,9 +405,17 @@ fn spawn_slot(
kind: info.kind,
},
));
// A card is a batch of the item's model and joins the wearer's appear-fade exactly like
// its mesh siblings above — same [`PartFade`], same seed, same arm. It used to spawn at
// a flat opaque with neither `RenderFade` nor `FadeMaterials`, which is why a weapon's
// glowing gems were already blazing before the character carrying them had faded in
// (director-reported on the Hungering Cold; decision 0836).
let set = item_fade_set(part);
let effective = PartFade::resolve(joined, &set);
let (init_mat, tag_alpha) = effective.seed(&set, now);
let mut card = commands.spawn((
Mesh3d(part.mesh.clone()),
MeshMaterial3d(part.material.clone()),
MeshMaterial3d(init_mat),
Transform::default(),
ModelPart {
kind: ModelKind::Creature,
@@ -447,12 +429,9 @@ fn spawn_slot(
}
// Same interior-light membership the item's mesh parts get above, through the same
// constructor and anchored at the same WEARER (decision 0778) — so a held torch's
// glow card can never split from the arm holding it. Spawned at a steady alpha: a
// card joins no appear-fade today (it carries neither `RenderFade` nor
// `FadeMaterials`), and the classifier preserves the alpha field regardless.
// …and the wearer's instance slot, like its mesh siblings: a tinted body colours the
// torch's glow card too (decision 0812).
card.insert(MeshTag(rig_tag | crate::mesh_tag::alpha_bits(1.0)));
// glow card can never split from the arm holding it. …and the wearer's instance slot,
// like its mesh siblings: a tinted body colours the torch's glow card too (0812).
card.insert(MeshTag(rig_tag | crate::mesh_tag::alpha_bits(tag_alpha)));
if let Some(lit) = part_interior_lit(
&part.material,
part.material_interior.as_ref(),
@@ -462,6 +441,12 @@ fn spawn_slot(
) {
card.insert(lit);
}
// The card's own share of the authored material alpha (the Hungering Cold's gems are
// keyed 0.30) — same pinned lane as the mesh parts.
if let Some(anim) = &part.alpha_anim {
card.insert(crate::doodad_anim::MatAnim::resting(anim.clone()));
}
effective.dress(&mut card, &set);
}
// The item's own particle emitters — the held torch's flame (0130 phase 4: the same
// owner-follow rider as doodad emitters). `root` sits at the attach offset under the
@@ -802,6 +787,114 @@ mod tests {
);
}
/// **The director's report on the Hungering Cold** (decision 0836): a weapon's glowing gems
/// were already blazing at full strength before the character carrying them had faded in.
/// The sword authors five camera-facing `GENERICGLOW1` batches, and a card used to spawn with
/// neither `RenderFade` nor `FadeMaterials` — a batch that pops. It is a batch of the item's
/// model like any other, so it joins the wearer's ramp with the mesh parts.
///
/// The same spawn also pins the batch's **authored** alpha: the sword's cards are keyed
/// `weight 0.30` and every item batch drew at 1.0, because no attach-model part ever carried a
/// `MatAnim` at all (321 of the 2681 shipped item models dim a batch this way).
#[test]
fn an_items_card_joins_the_wearers_fade_and_keeps_its_authored_alpha() {
const KIND: ItemModelKind = ItemModelKind::Weapon;
const SINCE: f32 = 3.0;
let blend: Handle<crate::terrain::WowModelMaterial> = Handle::Uuid(
bevy::asset::uuid::Uuid::from_u128(99),
std::marker::PhantomData,
);
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<crate::rig_palette::RigPalettes>();
let mut displays = ItemDisplays::icons_for_tests(
benilla_formats::ItemDisplayCatalog::from_displays(HashMap::new()),
);
let mut dm = empty_display();
let mut card = part(false);
card.fade_blend = Some(blend.clone());
card.alpha_anim = benilla_formats::AlphaAnim::new(vec![benilla_formats::AlphaSeq {
color: None,
weight: Some(benilla_formats::ScalarAnim {
period: 0.0,
step: false,
wrap: true,
keys: vec![(0.0, 0.3)],
}),
}])
.map(std::sync::Arc::new);
card.billboard = Some(benilla_assets::BillboardInfo {
pivot: Vec3::ZERO,
bone: 1,
kind: benilla_formats::BillboardKind::Spherical,
scale_anim: None,
seq_translations: Vec::new(),
});
dm.parts = Some(vec![card]);
displays.models.insert((7, KIND), dm);
app.insert_resource(displays);
let joint = app.world_mut().spawn(Transform::default()).id();
let bones = BoneAttach {
anchors: HashMap::from([(3u16, joint)]),
points: HashMap::from([(attach_id::HAND_RIGHT, (3u16, Vec3::ZERO))]),
markers: HashMap::new(),
};
let mut items = HeldItems::default();
items.slots[0] = Some(HeldSlot {
display: 7,
kind: KIND,
attach: attach_id::HAND_RIGHT,
visual: NO_GLOW,
});
// The wearer is mid-login: its own ramp is still pending, so everything attaching to it
// must join that ramp rather than open opaque beside it.
app.world_mut().spawn((
items,
bones,
Transform::default(),
UnitAppearFade::Pending { since: SINCE },
));
app.add_systems(Update, attach_held_items);
app.update();
let mut q = app.world_mut().query::<(
&BillboardCard,
&MeshTag,
&MeshMaterial3d<crate::terrain::WowModelMaterial>,
Option<&crate::model_fade::FadeMaterials>,
Option<&crate::model_fade::PendingAppearFade>,
Option<&crate::doodad_anim::MatAnim>,
)>();
let found: Vec<_> = q.iter(app.world()).collect();
assert_eq!(found.len(), 1, "the one camera-facing batch spawned a card");
let (_, tag, mat, fm, pending, anim) = found[0];
assert_eq!(
pending.map(|p| p.since),
Some(SINCE),
"the card joined the wearer's pending ramp",
);
assert_eq!(
fm.map(|f| f.blend.clone()),
Some(blend.clone()),
"…carrying the material record the ramp (and the zoom feather) re-arm from",
);
assert_eq!(mat.0, blend, "…and opens ON the blend twin, not the cutout");
assert!(
crate::mesh_tag::alpha_of(tag.0) <= 1.0 / 63.0,
"…at the encoder's ≈0 floor, so it never flashes opaque for a frame",
);
let anim = anim.expect("the batch's authored alpha rides the card");
assert!(
(anim.current - 0.3).abs() < 1e-6,
"the file's 0.30 weight, not 1.0",
);
assert!(
anim.composes_unit_tag(),
"an attach model's compose is the UNIT lane's — ordered against the wearer's fade",
);
}
/// A wearer holding a **weapon** (mainhand, drawn in the hand) and wearing a **shoulder** that
/// carries emitters — the bench for the sheath-swap tests below. Returns the app and the wearer.
fn dress_a_wearer() -> (App, Entity) {
+6 -1
View File
@@ -352,6 +352,9 @@ fn drive_loading_screen(
>,
player: Option<Res<crate::player::Player>>,
time: Res<Time>,
// The 0837 warm pass: the cover holds until the menagerie's pipelines have compiled — the
// point of the cover is that NOTHING first-sight-compiles after it lifts.
warm: Res<crate::pipe_warm::WarmPass>,
// The lifecycle edges (decisions 0737/0738), all observed from what the net bridge already
// publishes — this module owns the whole state machine, nothing else is instrumented for it.
edges: (
@@ -451,6 +454,7 @@ fn drive_loading_screen(
&& progress.presentable()
&& !screen.awaiting_snap
&& !player_settling
&& warm.satisfied()
{
screen.ready_frames += 1;
if screen.ready_frames >= CLEAR_AFTER_READY_FRAMES {
@@ -471,7 +475,7 @@ fn drive_loading_screen(
screen.last_wait_log = now;
info!(
"loading screen: waiting {:.1}s — {}/{} resident, {} placements pending, \
{} colliders pending, awaiting_snap={}, settling={}",
{} colliders pending, awaiting_snap={}, settling={}, warm={}",
now - screen.active_since,
progress.ready,
progress.total,
@@ -479,6 +483,7 @@ fn drive_loading_screen(
progress.colliders_pending,
screen.awaiting_snap,
player_settling,
warm.satisfied(),
);
}
}
+4
View File
@@ -79,6 +79,7 @@ mod npc_text;
mod particles;
mod pending_item_ops;
mod perf;
mod pipe_warm;
mod player;
mod portrait;
mod preflight;
@@ -468,6 +469,9 @@ fn main() -> AppExit {
// Frame-time HUD + diagnostics — the performance standard.
// After DebugPanelPlugin so the egui plugin/context it sets up already exists. Toggle: P.
.add_plugins(PerfPlugin)
// Pipeline-compile counters + the live-compile tripwire (decision 0837: macOS builds every
// pipeline synchronously on the render thread, so a live compile is a felt stall).
.add_plugins(pipe_warm::plugin)
.add_plugins(hover_log::HoverLogPlugin)
.add_plugins(asset_churn::AssetChurnPlugin)
// Within-map art residency (decision 0793): the dedup caches expire by DISTANCE, so a
+232 -21
View File
@@ -476,15 +476,25 @@ pub(crate) fn arm_appear_fade(
time: Res<Time>,
screen: Res<crate::loading_screen::LoadingScreen>,
mut commands: Commands,
q: Query<(Entity, &PendingAppearFade)>,
q: Query<(
Entity,
&PendingAppearFade,
Has<crate::billboard::BillboardCard>,
)>,
mut units: Query<&mut UnitAppearFade>,
) {
let now = time.elapsed_secs();
let shown = !screen.covering();
let mut armed = 0usize;
for (entity, pending) in &q {
// Of those, the world-ROOT billboard cards (decision 0836). Called out separately because a
// card reaches this system through nothing but its own spawn — no tree walk can find it — so
// "how many cards armed" is the one readout that separates "the cards fade" from "the cards
// are simply absent from the ramp", which is what they were.
let mut armed_cards = 0usize;
for (entity, pending, is_card) in &q {
if shown || now - pending.since > PENDING_TIMEOUT_SECS {
armed += 1;
armed_cards += usize::from(is_card);
// `try_*`, like every fade command here: these entities are **wire-owned** — a net
// destroy can apply at any sync point between this system's query and its own
// commands, so fade bookkeeping on an already-dead entity is a no-op, never an error.
@@ -508,7 +518,10 @@ pub(crate) fn arm_appear_fade(
// indoor entity's light law used to land only AFTER the ramp latched (2 s of exterior light,
// then a pop); a run where the two instants coincide is the observable that closes it.
if armed > 0 && std::env::var_os("WOW_INTERIOR_LOG").is_some() {
eprintln!("[fade-arm] t {now:.2} armed {armed} parts (world shown: {shown})");
eprintln!(
"[fade-arm] t {now:.2} armed {armed} parts ({armed_cards} billboard cards) \
(world shown: {shown})"
);
}
}
@@ -583,6 +596,101 @@ impl FadeMaterials {
}
}
/// The material handles one batch fades *through*, as a spawn site borrows them — the inputs
/// [`FadeMaterials`] is built from. `blend: None` means the batch cannot feather at all (a MULTIPLY
/// batch's blend equation reads no alpha — decision 0528 — and a WMO-display batch builds no twin),
/// which is the one legitimate reason for a part to spawn opaque while its unit is still ramping.
pub(crate) struct FadeSet<'a> {
pub(crate) steady: &'a Handle<WowModelMaterial>,
pub(crate) blend: Option<&'a Handle<WowModelMaterial>>,
pub(crate) bake_blend: Option<&'a Handle<WowModelMaterial>>,
/// The depth-prime twin (decision 0831). Carried here for the same reason as the rest of the
/// set: it is needed exactly while the batch is feathering — which a card now does too.
pub(crate) zfill: Option<&'a Handle<WowModelMaterial>>,
}
/// One batch's appear-fade membership for a spawn: the unit's clock ([`JoinedFade`]) folded with
/// whether the batch is fade-capable at all.
///
/// **Every** batch spawn resolves it here and dresses through [`Self::dress`] — the body's mesh
/// parts and its billboard cards, a held item's mesh parts and its cards. The law is a property of
/// the *model*, not of how benilla happens to parent a batch: the reference has one instance alpha
/// (`CM2Model+0x19c`) that every batch of the model draws through, billboard batches included, so a
/// lane that skips this is a batch that pops opaque over a body still fading in. Both card lanes did
/// exactly that until decision 0836.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum PartFade {
/// No ramp to join (unit already appeared, or the batch cannot feather): open opaque.
Steady,
/// Join the unit's still-pending ramp — arm together with it once the world is on screen.
Pending(f32),
/// Join the ramp already running, at its current position.
Live(f32),
}
impl PartFade {
/// Fold the unit's join decision with the batch's own fade-capability.
pub(crate) fn resolve(joined: JoinedFade, set: &FadeSet<'_>) -> Self {
match (joined, set.blend) {
(_, None) | (JoinedFade::Steady, _) => Self::Steady,
(JoinedFade::Pending { since }, _) => Self::Pending(since),
(JoinedFade::Live { started }, _) => Self::Live(started),
}
}
/// The material and `MeshTag` alpha the spawn should OPEN on. A pending part opens on the blend
/// twin at ≈0 so it never flashes opaque for a frame before [`apply_render_fade`] takes over; a
/// joiner opens at the ramp's *current* alpha so it doesn't flash invisible either.
pub(crate) fn seed(self, set: &FadeSet<'_>, now: f32) -> (Handle<WowModelMaterial>, f32) {
match self {
Self::Steady => (set.steady.clone(), 1.0),
Self::Pending(_) => (set.blend.cloned().unwrap_or_default(), 0.0),
Self::Live(started) => (
set.blend.cloned().unwrap_or_default(),
fade_alpha(0.0, 1.0, (now - started) / APPEAR_FADE_SECS),
),
}
}
/// Dress the spawned batch: the persistent [`FadeMaterials`] record whenever it is fade-capable
/// at all — that is a *material record*, not a record of having armed a fade, and the stream-out
/// fade and the self-avatar zoom feather both re-arm from it — plus the arm itself when this
/// spawn is joining the unit's ramp. Returns whether it armed, which the attach path mirrors
/// onto the unit root so a later-resolving attachment can join the same ramp.
pub(crate) fn dress(
self,
child: &mut bevy::ecs::system::EntityCommands,
set: &FadeSet<'_>,
) -> bool {
if let Some(blend) = set.blend {
child.insert(FadeMaterials {
cutout: set.steady.clone(),
blend: blend.clone(),
bake_blend: set.bake_blend.cloned(),
zfill: set.zfill.cloned(),
});
}
match self {
Self::Steady => false,
// `arm_appear_fade` starts the ramp once the world is on-screen (not behind the loading
// screen), so it plays where the player can actually see it.
Self::Pending(since) => {
child.insert(PendingAppearFade { since });
true
}
Self::Live(started) => {
child.insert(RenderFade {
started,
duration: APPEAR_FADE_SECS,
from: 0.0,
to: 1.0,
});
true
}
}
}
}
/// Marks a streamed entity (the parent) that went out of range: instead of popping it out,
/// [`apply_despawn_fade`] fades it out then despawns it. **Our** stream-out look, not a verified
/// mechanism — the wow-re teardown RE found no fade-out in the binary (a *destroyed* object pops
@@ -608,18 +716,39 @@ impl Default for DespawnFade {
/// weapon / helm / shoulder is a child of a **joint** entity several levels down
/// ([`crate::entities::BoneAttach`]) — the fade is a per-*unit* property (decision 0032's shape), so
/// the whole tree fades as one instead of the body thinning around a still-opaque weapon.
///
/// **And the tree is not the whole model.** A BILLBOARD batch is a world ROOT that merely *follows*
/// an anchor inside the tree (decision 0153), so the walk cannot reach it — the cards are picked up
/// by testing their follow-anchor against the walked set, the same idiom
/// [`crate::player::apply_self_model_fade`] uses (decision 0836).
pub(crate) fn apply_despawn_fade(
time: Res<Time>,
mut commands: Commands,
mut q: Query<(Entity, &mut DespawnFade)>,
children_of: Query<&Children>,
fm: Query<(), With<FadeMaterials>>,
cards: Query<(Entity, &crate::billboard::BillboardCard), With<FadeMaterials>>,
) {
let now = time.elapsed_secs();
for (parent, mut df) in &mut q {
if df.started < 0.0 {
let mut any = false;
arm_despawn_descendants(parent, now, &mut commands, &children_of, &fm, &mut any);
let mut walked = bevy::ecs::entity::EntityHashSet::default();
arm_despawn_descendants(
parent,
now,
&mut commands,
&children_of,
&fm,
&mut any,
&mut walked,
);
for (card, follow) in &cards {
if follow.follows().is_some_and(|a| walked.contains(&a)) {
any = true;
arm_fade_out(card, now, &mut commands);
}
}
if any {
df.started = now;
} else {
@@ -631,9 +760,30 @@ pub(crate) fn apply_despawn_fade(
}
}
/// Arm one fadeable entity's `{from: 1, to: 0}` ramp. No material is chosen here:
/// [`apply_render_fade`] resolves it per frame from the part's live law (0755), so a
/// bake-classified part fades out probe-lit — and keeps doing so if it re-classifies mid-ramp,
/// which a pair latched at this instant could not express. `try_*`: a child (a held item
/// mid-re-resolve, a gear swap) can be despawned by its own owner in the same frame — the
/// lifetime contract in [`arm_appear_fade`].
fn arm_fade_out(entity: Entity, now: f32, commands: &mut Commands) {
commands
.entity(entity)
.try_insert(RenderFade {
started: now,
duration: APPEAR_FADE_SECS,
from: 1.0,
to: 0.0,
})
.try_remove::<PendingAppearFade>();
}
/// Depth-first helper for [`apply_despawn_fade`]: arm the fade-out on `entity` if it carries
/// [`FadeMaterials`], and recurse into its children either way (a joint / held-item root carries none
/// itself but has fadeable meshes beneath it).
/// itself but has fadeable meshes beneath it). Every entity visited — parts, joints, attach roots,
/// billboard anchors alike — is recorded in `walked`, which the caller uses to recognise the
/// world-root billboard cards that follow this model.
#[allow(clippy::too_many_arguments)]
fn arm_despawn_descendants(
entity: Entity,
now: f32,
@@ -641,27 +791,16 @@ fn arm_despawn_descendants(
children_of: &Query<&Children>,
fm: &Query<(), With<FadeMaterials>>,
any: &mut bool,
walked: &mut bevy::ecs::entity::EntityHashSet,
) {
walked.insert(entity);
if fm.contains(entity) {
*any = true;
// No material is chosen here: `apply_render_fade` resolves it per frame from the part's
// live law (0755), so a bake-classified part fades out probe-lit — and keeps doing so if
// it re-classifies mid-ramp, which a pair latched at this instant could not express.
// `try_*`: a child (a held item mid-re-resolve, a gear swap) can be despawned by its own
// owner in the same frame — the lifetime contract in `arm_appear_fade`.
commands
.entity(entity)
.try_insert(RenderFade {
started: now,
duration: APPEAR_FADE_SECS,
from: 1.0,
to: 0.0,
})
.try_remove::<PendingAppearFade>();
arm_fade_out(entity, now, commands);
}
if let Ok(children) = children_of.get(entity) {
for &child in children {
arm_despawn_descendants(child, now, commands, children_of, fm, any);
arm_despawn_descendants(child, now, commands, children_of, fm, any, walked);
}
}
}
@@ -740,7 +879,11 @@ mod tests {
Res<Time>,
Res<crate::loading_screen::LoadingScreen>,
Commands,
Query<(Entity, &PendingAppearFade)>,
Query<(
Entity,
&PendingAppearFade,
Has<crate::billboard::BillboardCard>,
)>,
Query<&mut UnitAppearFade>,
)> = SystemState::new(&mut world);
let (time, screen, commands, q, units) = state.get_mut(&mut world);
@@ -1050,4 +1193,72 @@ mod tests {
assert!(near < far);
assert!((0.0..=1.0).contains(&near) && (0.0..=1.0).contains(&far));
}
/// **The stream-out half of decision 0836.** The arm walk descends `Children`, and a billboard
/// card is a world ROOT that only *follows* an anchor inside the tree — so a unit fading out
/// left its eye glow / gem cards at full strength for the whole 2 s and then blinked them out
/// with the despawn. They're picked up by their follow-anchor, exactly as the self-avatar
/// feather picks them up.
///
/// The second card, following nothing of this unit's, is the negative half: the sweep is by
/// membership, not "every card on screen".
#[test]
fn a_despawn_fade_reaches_the_models_billboard_cards() {
let fm = || FadeMaterials {
cutout: Handle::default(),
blend: Handle::default(),
bake_blend: None,
zfill: None,
};
let mut app = App::new();
app.add_plugins(MinimalPlugins);
let unit = app.world_mut().spawn((Transform::default(), fm())).id();
// A joint under the unit, an attach root under it, and the card following that root —
// the real held-item shape, three levels deep.
let joint = app.world_mut().spawn(Transform::default()).id();
let root = app.world_mut().spawn(Transform::default()).id();
app.world_mut().entity_mut(unit).add_child(joint);
app.world_mut().entity_mut(joint).add_child(root);
let info = benilla_assets::BillboardInfo {
pivot: Vec3::ZERO,
bone: 1,
kind: benilla_formats::BillboardKind::Spherical,
scale_anim: None,
seq_translations: Vec::new(),
};
let mine = app
.world_mut()
.spawn((
crate::billboard::BillboardCard::following(&info, root),
fm(),
))
.id();
let stranger = app.world_mut().spawn(Transform::default()).id();
let theirs = app
.world_mut()
.spawn((
crate::billboard::BillboardCard::following(&info, stranger),
fm(),
))
.id();
app.world_mut()
.entity_mut(unit)
.insert(DespawnFade::default());
app.add_systems(Update, apply_despawn_fade);
app.update();
let out = |e: Entity| {
app.world()
.entity(e)
.get::<RenderFade>()
.map(|f| (f.from, f.to))
};
assert_eq!(
out(unit),
Some((1.0, 0.0)),
"the body arms, as it always did"
);
assert_eq!(out(mine), Some((1.0, 0.0)), "…and so does its own card");
assert_eq!(out(theirs), None, "a stranger's card is left alone");
}
}
+12 -3
View File
@@ -291,8 +291,12 @@ pub(crate) fn model_material(
// 0.2 shaded; shader thresholds at 0.85 and 0.5) that chooses which live sun LEVEL scales the
// FFP matte's diffuse term. Static per material (a doodad doesn't move), so it dedups the
// variants; moving entities are Lit here and mix per instance via the `MeshTag` shade byte.
// y = the WMO authored batch order (shader-unread; read back by `WowModelKey` to drive the
// per-batch pipeline depth bias — the byte-verified MOBA draw-order determinism).
// y = the WMO authored batch order, read by `wow_model.wgsl`'s VERTEX stage: it scales
// clip z by (1 + y·2⁻²³) so a later coplanar batch wins the reverse-Z depth test in any
// draw order — the byte-verified MOBA draw-order determinism. Uniform data by design:
// as a `WowModelKey` axis driving a fixed-function depth bias it made every batch index
// its own PIPELINE, and a first city sight compiled ~3000 of them synchronously on the
// render thread (decision 0837). `WOW_WMO_BIAS=0` (B38's A/B diagnostic) zeroes it here.
// zw = the batch's live **UV-animation offset** (decision 0130 phase 3, wow-re
// `m2-texanim-uv`: the real client adds the sampled translation to the stage UVs —
// translation is un-pivoted, and no placed doodad uses rotation/scaling). Seeded at
@@ -300,7 +304,12 @@ pub(crate) fn model_material(
// shared clock (frozen in captures).
sun_scale: {
let uv0 = uv_anim.map_or([0.0, 0.0], |a| a.sample(0.0));
Vec4::new(shade.selector(), f32::from(wmo_batch_order), uv0[0], uv0[1])
let order = if matches!(std::env::var("WOW_WMO_BIAS").as_deref(), Ok("0")) {
0.0
} else {
f32::from(wmo_batch_order)
};
Vec4::new(shade.selector(), order, uv0[0], uv0[1])
},
// The animated M2Color tint's first key (identity white for static batches — their
// constant tint rides the vertex colours instead). A lane that never re-samples this
+6 -7
View File
@@ -175,13 +175,12 @@ pub(super) fn expand_quads(
// fading in fades its effects in with it, and a first-person avatar's torch stops burning
// in your face.
rgba[3] *= frame.alpha;
// RAW authored RGB — the gamma-space decode happens ONCE, in the lane's fragment
// (`wow_effect.wgsl`, decision 0152), where it also covers the texture term that a
// CPU-side vertex linearisation (0150) could not reach. Alpha is a blend weight — raw
// everywhere. The remaining bonfire-CORE brightness gap vs the reference is the
// additive-composite-space question (0148 open item: the reference sums gamma bytes in
// an LDR framebuffer; we sum linear and encode once), NOT a colour-space bug — do not
// "fix" it here again.
// RAW authored RGB — the gamma-space decode happens ONCE, at the FFX combine
// (decision 0161's gamma lane; 0152/0150 are its shader-placement ancestry). Alpha is
// a blend weight — raw everywhere. Additive stacks sum in gamma like the reference's
// bytes; the byte buffer's SATURATION (a stack can never exceed 255) is restored at
// the FFX chain's scene reads (`ffx_glow.wgsl`, the Frost Nova mist diagnosis) — a
// too-bright additive stack is never this ramp's bug, do not "fix" it here.
// Anchored positions ride the emitter's live translation — through the CURRENT attach
// rotation on an attached model (`DrawMx = A·Translate·V`, the heading-since-birth
// fan); model mode folds the whole live placement transform (the reference's rt-0x100
+14 -3
View File
@@ -86,6 +86,7 @@ impl Plugin for PerfPlugin {
frame: 0,
log_until: 0,
prev_cpu_secs: None,
prev_pipes_created: 0,
})
.add_systems(
Update,
@@ -176,13 +177,16 @@ struct StreamTrace {
/// on this machine wall frame time moves with whoever else is compiling (0711; a parallel
/// build polluted this instrument's first A/B).
prev_cpu_secs: Option<f64>,
/// `PipeWatch::created` at the previous frame, so `pipes_new` is per-frame like every other
/// counter (the render world bumps the shared atomic; ±1 frame skew is inherent and fine).
prev_pipes_created: usize,
}
const STREAM_TRACE_HEADER: &str = "frame,t,delta_ms,cpu_ms,ents,stream_ms,furnish_ms,mfurnish_ms,\
spawn_ms,collider_ms,tiles_dropped,pl_dropped,pl_ents_dropped,\
tiles_req,tiles_spawned,cells_spawned,mmeshes_built,pl_spawned,\
attached,adt_freed,meshes_freed,images_freed,adt_added,\
meshes_added,images_added\n";
meshes_added,images_added,pipes_new,pipes_pending\n";
#[allow(clippy::too_many_arguments)]
fn trace_stream(
@@ -193,9 +197,15 @@ fn trace_stream(
mut adt_events: MessageReader<bevy::asset::AssetEvent<benilla_assets::AdtTile>>,
mut mesh_events: MessageReader<bevy::asset::AssetEvent<Mesh>>,
mut image_events: MessageReader<bevy::asset::AssetEvent<bevy::image::Image>>,
pipes: Res<crate::pipe_warm::PipeWatch>,
) {
// Taken every frame — the counters are per-frame by contract, tracing or not.
let a = std::mem::take(&mut *activity);
let pipes_created = pipes.0.created.load(std::sync::atomic::Ordering::Relaxed);
let pipes_settled = pipes.0.settled.load(std::sync::atomic::Ordering::Relaxed);
let pipes_new = pipes_created.saturating_sub(trace.prev_pipes_created);
let pipes_pending = pipes_created.saturating_sub(pipes_settled);
trace.prev_pipes_created = pipes_created;
trace.frame += 1;
if trace.path.is_empty() {
return;
@@ -232,12 +242,13 @@ fn trace_stream(
let (added_mesh, removed_mesh) = count_events(&mut mesh_events);
let (added_img, removed_img) = count_events(&mut image_events);
let delta_ms = time.delta_secs() * 1000.0;
if a.any_event() || removed_adt > 0 || added_adt > 0 {
if a.any_event() || removed_adt > 0 || added_adt > 0 || pipes_new > 0 {
trace.log_until = trace.frame + TRACE_TAIL_FRAMES;
}
if !(a.any_event()
|| removed_adt > 0
|| added_adt > 0
|| pipes_new > 0
|| delta_ms > TRACE_FRAME_MS
|| trace.frame <= trace.log_until)
{
@@ -247,7 +258,7 @@ fn trace_stream(
let _ = std::fs::write(&trace.path, STREAM_TRACE_HEADER);
}
let line = format!(
"{},{:.2},{delta_ms:.2},{cpu_ms},{},{:.2},{:.2},{:.2},{:.2},{:.2},{},{},{},{},{},{},{},{},{},{removed_adt},{removed_mesh},{removed_img},{added_adt},{added_mesh},{added_img}\n",
"{},{:.2},{delta_ms:.2},{cpu_ms},{},{:.2},{:.2},{:.2},{:.2},{:.2},{},{},{},{},{},{},{},{},{},{removed_adt},{removed_mesh},{removed_img},{added_adt},{added_mesh},{added_img},{pipes_new},{pipes_pending}\n",
trace.frame,
time.elapsed_secs(),
entities.iter().len(),
+568
View File
@@ -0,0 +1,568 @@
//! The pipeline warm pass + its instrument — decision 0837 (the B181 city-approach stall).
//!
//! **Why this exists:** on macOS, Bevy compiles every GPU pipeline **synchronously on the render
//! thread** — `bevy_render`'s `create_pipeline_task` has a `target_os = "macos"` carve-out (0.18
//! and 0.19 both) that `block_on`s the build regardless of `synchronous_pipeline_compilation`,
//! and the Metal half of that build runs out-of-process in `MTLCompilerService` (near-zero
//! process CPU while the frame is blocked). So any pipeline variant first drawn *live* is a
//! frame-long stall the app cannot pace; the only fix is compiling everything where a stall is
//! invisible — behind the loading cover, where 0540 put the warm-up. (The worst offender — the
//! per-batch-index depth bias that made every WMO batch its own pipeline, ~3000 variants at
//! Stormwind — left the pipeline key in this same decision: the nudge now rides `sun_scale.y`
//! into `wow_model.wgsl`'s vertex stage as uniform data.)
//!
//! The pieces:
//!
//! - [`WarmPass`] + `spawn_menagerie` — the warm pass: one tiny quad per reachable model-lane
//! pipeline variant, parented to the world camera, spawned when the entry cover rises; the
//! loading screen's clear condition holds on [`WarmPass::satisfied`] until the pipeline cache
//! drains (10 s backstop, 0737's rule), then the menagerie despawns. Captures skip it.
//! - [`PipeWatch`] — an `Arc` shared by the main and render worlds: how many pipelines the cache
//! has ever queued, how many have settled (Ok/Err), and whether a cover currently hides the
//! frame (loading screen up, or not in world — the glue scene is its own cover, 0540).
//! - [`watch_pipelines`] (render world, after the cache's own process step): maintains the
//! counters and — the permanent tripwire — logs a `warn!` for every pipeline compiled while
//! **uncovered**. That line firing in a session log IS the regression signal: it means the
//! menagerie has a coverage hole (extend its loops, don't guess).
//! - `WOW_PIPE_TRACE=<path>` — the inventory dump: one line per pipeline creation (covered or
//! not) with the full variant identity (shaders, defs, depth bias, blend, write mask, vertex
//! buffers, cull), the ground truth the menagerie was built from.
//! - Two stream-trace columns (`pipes_new`, `pipes_pending` — see `perf::trace_stream`) so a
//! compile burst is attributable on the same row as the frame that paid for it.
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use benilla_formats::{FogPolicy, ModelBlend, RenderSubmesh};
use bevy::camera::primitives::MeshAabb;
use bevy::mesh::MeshTag;
use bevy::prelude::*;
use bevy::render::render_resource::{
Buffer, CachedPipelineState, PipelineCache, PipelineDescriptor,
};
use bevy::render::{Render, RenderApp, RenderSystems};
use crate::char_select::ClientState;
use crate::loading_screen::LoadingScreen;
use crate::model_render::{model_material, zfill_material, MaterialCache, ShadeSel};
use crate::terrain::WowModelMaterial;
/// The cross-world channel: cloned into the render app at plugin build. Frame alignment between
/// the two worlds is ±1 frame under pipelined rendering — fine for counters and a tripwire.
#[derive(Resource, Clone)]
pub(crate) struct PipeWatch(pub(crate) Arc<PipeShared>);
pub(crate) struct PipeShared {
/// Pipelines the cache has ever queued (its vec only grows; ids are indices).
pub(crate) created: AtomicUsize,
/// Of those, how many have settled — `Ok` or a non-retryable `Err`. A retryable error
/// (shader not loaded yet) flips back to `Queued` and correctly reads as pending.
pub(crate) settled: AtomicUsize,
/// Main-world truth: an opaque cover hides the frame (loading screen, or not `InWorld`).
pub(crate) covered: AtomicBool,
}
pub(crate) fn plugin(app: &mut App) {
let shared = Arc::new(PipeShared {
created: AtomicUsize::new(0),
settled: AtomicUsize::new(0),
covered: AtomicBool::new(true),
});
app.insert_resource(PipeWatch(shared.clone()));
app.init_resource::<WarmPass>();
app.add_systems(Last, publish_cover);
// Before the Present stage so the loading screen reads this frame's gate, not last frame's.
app.add_systems(
Update,
run_warm_pass.before(crate::schedule::WorldStage::Present),
);
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
return;
};
render_app.insert_resource(PipeWatch(shared));
render_app.add_systems(Render, watch_pipelines.in_set(RenderSystems::Cleanup));
}
/// Main world → render world: is the frame covered right now?
fn publish_cover(
watch: Res<PipeWatch>,
loading: Res<LoadingScreen>,
state: Res<State<ClientState>>,
) {
let covered = loading.covering() || *state.get() != ClientState::InWorld;
watch.0.covered.store(covered, Ordering::Relaxed);
}
/// Render world, after `PipelineCache::process_pipeline_queue_system` has merged this frame's new
/// pipelines and started (= on macOS: finished) their builds. `seen` is how many cache entries the
/// previous frame had — everything past it is new this frame.
fn watch_pipelines(cache: Res<PipelineCache>, watch: Res<PipeWatch>, mut seen: Local<usize>) {
let covered = watch.0.covered.load(Ordering::Relaxed);
let mut total = 0usize;
let mut settled = 0usize;
for (id, pipe) in cache.pipelines().enumerate() {
total += 1;
if matches!(
pipe.state,
CachedPipelineState::Ok(_) | CachedPipelineState::Err(_)
) {
settled += 1;
}
if id >= *seen {
let line = describe(&pipe.descriptor);
if covered {
debug!("pipeline compiled (covered) [{id}] {line}");
} else {
// THE TRIPWIRE: after 0837, a live compile is a stall the director can feel —
// this line in a session log means the warm pass has a coverage hole.
warn!("pipeline compiled LIVE [{id}] {line}");
}
if let Ok(path) = std::env::var("WOW_PIPE_TRACE") {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
let cov = if covered { "covered" } else { "LIVE" };
let _ = writeln!(f, "[{id}] {cov} {line}");
}
}
}
}
*seen = total;
watch.0.created.store(total, Ordering::Relaxed);
watch.0.settled.store(settled, Ordering::Relaxed);
}
/// One line of variant identity: everything that distinguishes this pipeline from its neighbours
/// (label + shaders + defs + the raster/depth/blend states), compact enough to grep and diff.
fn describe(desc: &PipelineDescriptor) -> String {
fn defs(d: &[bevy::shader::ShaderDefVal]) -> String {
let mut v: Vec<String> = d
.iter()
.map(|d| match d {
bevy::shader::ShaderDefVal::Bool(k, true) => k.clone(),
bevy::shader::ShaderDefVal::Bool(k, false) => format!("!{k}"),
bevy::shader::ShaderDefVal::Int(k, i) => format!("{k}={i}"),
bevy::shader::ShaderDefVal::UInt(k, u) => format!("{k}={u}"),
})
.collect();
v.sort();
v.join("+")
}
match desc {
PipelineDescriptor::RenderPipelineDescriptor(d) => {
let label = d.label.as_deref().unwrap_or("?");
let vs = d
.vertex
.shader
.path()
.map_or_else(|| format!("{:?}", d.vertex.shader.id()), |p| p.to_string());
let vbufs: Vec<String> = d
.vertex
.buffers
.iter()
.map(|b| {
let locs: Vec<String> = b
.attributes
.iter()
.map(|a| a.shader_location.to_string())
.collect();
format!("stride{}@[{}]", b.array_stride, locs.join(","))
})
.collect();
let (bias, dw, cmp) = d.depth_stencil.as_ref().map_or_else(
|| (0, false, String::from("none")),
|ds| {
(
ds.bias.constant,
ds.depth_write_enabled,
format!("{:?}", ds.depth_compare),
)
},
);
let frag = d.fragment.as_ref().map_or_else(
|| String::from("frag=none"),
|f| {
let fs = f
.shader
.path()
.map_or_else(|| format!("{:?}", f.shader.id()), |p| p.to_string());
let tgt = f.targets.iter().flatten().next().map_or_else(
|| String::from("none"),
|t| format!("blend={:?} mask={:?}", t.blend, t.write_mask),
);
format!("fs={fs} fs_defs=[{}] {tgt}", defs(&f.shader_defs))
},
);
format!(
"label={label} vs={vs} vs_defs=[{}] bufs=[{}] cull={:?} bias={bias} depth_write={dw} cmp={cmp} {frag} samples={}",
defs(&d.vertex.shader_defs),
vbufs.join(";"),
d.primitive.cull_mode,
d.multisample.count,
)
}
PipelineDescriptor::ComputePipelineDescriptor(d) => {
let label = d.label.as_deref().unwrap_or("?");
let cs = d
.shader
.path()
.map_or_else(|| format!("{:?}", d.shader.id()), |p| p.to_string());
format!("label={label} compute={cs} defs=[{}]", defs(&d.shader_defs))
}
}
}
// --------------------------------------------------------------------------------------------
// The warm pass — the fix half of 0837.
//
// The 0837 inventory (pipes1.log) showed the model lane's REACHABLE pipeline space is small once
// the batch-order axis left the key: 4 vertex layouts × the blend/depth-flag families, ~28
// observed in a wilderness-to-Stormwind leg. The menagerie below compiles that whole space
// behind the entry loading cover — one 1 cm quad per variant, parented to the world camera (the
// camera renders under the cover, 0540, so every quad's draw queues its pipeline through the
// production specialize path), the cover held (via `WarmPass::satisfied` in the loading screen's
// clear condition) until the cache drains, then the quads despawn. A variant this misses shows
// up as the tripwire's "compiled LIVE" warn — extend the loops, don't guess.
/// Marker on every menagerie entity.
#[derive(Component)]
struct WarmRig;
/// Main-world warm-pass state. The loading screen folds [`Self::satisfied`] into its clear
/// condition, so the cover holds while menagerie pipelines are still compiling.
#[derive(Resource, Default)]
pub(crate) struct WarmPass {
/// `Time::elapsed_secs` when the menagerie spawned under the current cover; `None` = idle
/// (no cover, or the pass already finished for this cover).
spawned_at: Option<f32>,
/// This cover's warm work is done (drained, timed out, or not applicable).
done: bool,
}
impl WarmPass {
/// Cover-lift gate: false while the menagerie still has pipelines in flight.
pub(crate) fn satisfied(&self) -> bool {
self.done
}
}
/// The menagerie must have been extracted + drawn + its pipelines queued before `pending == 0`
/// means anything — under a second even on the entry frame's load.
const WARM_SETTLE_SECS: f32 = 0.25;
/// 0737's rule: never hold a cover unbounded. A timeout fires the tripwire-adjacent warn and
/// releases; the remaining compiles land live (the pre-0837 world, once, with a named cause).
const WARM_TIMEOUT_SECS: f32 = 10.0;
#[allow(clippy::too_many_arguments)] // a Bevy system: each param is one resource, the app's convention
fn run_warm_pass(
mut commands: Commands,
mut warm: ResMut<WarmPass>,
watch: Res<PipeWatch>,
loading: Res<LoadingScreen>,
state: Res<State<ClientState>>,
time: Res<Time>,
camera: Query<Entity, With<crate::player::WorldCamera>>,
rigs: Query<Entity, With<WarmRig>>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<WowModelMaterial>>,
mut cache: Local<MaterialCache>,
shared_light: Option<Res<crate::lighting::SharedLightBuffer>>,
) {
let covering = loading.covering() && *state.get() == ClientState::InWorld;
if !covering {
// No world cover → nothing to hold; a leftover menagerie (timeout, teleport race)
// despawns. `done` stays true so the gate never blocks an uncovered frame.
warm.done = true;
warm.spawned_at = None;
for e in &rigs {
commands.entity(e).despawn();
}
return;
}
// Captures boot straight in-world, deterministic by construction — no menagerie in a shot.
if crate::capture::scenario_active() {
warm.done = true;
return;
}
let now = time.elapsed_secs();
let Some(spawned) = warm.spawned_at else {
// The cover just rose (or the world just became live under one): raise the gate and
// spawn the menagerie once the camera + shared light exist (both are entry-frame-early;
// until they do, the gate holds the cover, which is exactly right).
warm.done = false;
let Ok(cam) = camera.single() else { return };
let Some(light) = shared_light.as_ref() else {
return;
};
warm.spawned_at = Some(now);
let count = spawn_menagerie(
&mut commands,
cam,
&mut meshes,
&mut materials,
&mut cache,
&light.0,
);
info!("pipeline warm: menagerie up ({count} variants)");
return;
};
if warm.done {
return;
}
let pending = watch
.0
.created
.load(Ordering::Relaxed)
.saturating_sub(watch.0.settled.load(Ordering::Relaxed));
if now - spawned >= WARM_SETTLE_SECS && pending == 0 {
warm.done = true;
for e in &rigs {
commands.entity(e).despawn();
}
info!("pipeline warm: drained in {:.2}s", now - spawned);
} else if now - spawned >= WARM_TIMEOUT_SECS {
warm.done = true;
for e in &rigs {
commands.entity(e).despawn();
}
warn!("pipeline warm: TIMED OUT with {pending} pipelines pending — cover released");
}
}
/// Spawn one tiny quad per reachable model-lane pipeline variant, parented to the world camera.
/// Materials come from the PRODUCTION builders (`model_material` / `zfill_material`) so the
/// variant encoding can never drift from the real spawn paths; meshes from the production
/// submesh builders so the vertex layouts can't either. Returns the entity count.
fn spawn_menagerie(
commands: &mut Commands,
cam: Entity,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<WowModelMaterial>,
cache: &mut MaterialCache,
light: &Buffer,
) -> usize {
// The four vertex layouts the model lane ships (0837 dump: strides 32/48/56/72): static ×
// {plain, vertex-colours} and their skinned twins. Statics are RENDER_WORLD-only, so their
// Aabb is computed here and inserted explicitly (0832's rule); skinned twins keep main-world
// data and `calculate_bounds` covers them.
let mut layouts: Vec<(Handle<Mesh>, Option<bevy::camera::primitives::Aabb>, bool)> = Vec::new();
for colors in [false, true] {
let stat = benilla_assets::submesh_to_static_mesh(&warm_quad(colors, false));
let aabb = stat.compute_aabb();
layouts.push((meshes.add(stat), aabb, false));
let skin = benilla_assets::submesh_to_skinned_mesh(&warm_quad(colors, true));
layouts.push((meshes.add(skin), None, true));
}
// The material families. The full cross is deliberate: every branch here is authorable in an
// M2/WMO (blends, the 0x10/0x08 depth flags, sidedness), and an over-warmed variant costs
// milliseconds behind a loading bar once per run, while a missed one is a director-felt live
// stall. The observed set (28) is the floor, not the target.
let mut mats: Vec<Handle<WowModelMaterial>> = Vec::new();
for two_sided in [false, true] {
for blend in [
ModelBlend::Opaque,
ModelBlend::AlphaTest,
ModelBlend::Blend,
ModelBlend::Mod,
ModelBlend::Mod2x,
] {
for no_depth_write in [false, true] {
for no_depth_test in [false, true] {
mats.push(model_material(
cache,
materials,
None,
blend,
two_sided,
false,
false,
false,
false,
false,
no_depth_write,
no_depth_test,
FogPolicy::Scene,
ShadeSel::Lit,
0,
None,
None,
None,
None,
false,
light,
));
}
}
}
// The additive glow-card blend state (specialize's pure ONE/ONE add), both depth-write
// flavours, and the doodad/entity distance-fade blend twin (depth-write forced on).
for no_depth_write in [false, true] {
mats.push(model_material(
cache,
materials,
None,
ModelBlend::Blend,
two_sided,
false,
false,
false,
true,
false,
no_depth_write,
false,
FogPolicy::Scene,
ShadeSel::Lit,
0,
None,
None,
None,
None,
false,
light,
));
}
mats.push(model_material(
cache,
materials,
None,
ModelBlend::Blend,
two_sided,
false,
false,
false,
false,
true,
false,
false,
FogPolicy::Scene,
ShadeSel::Lit,
0,
None,
None,
None,
None,
false,
light,
));
// The depth-prime twin (colour writes masked off), plain and cutout.
for cutout in [false, true] {
mats.push(zfill_material(
cache, materials, None, two_sided, cutout, light,
));
}
}
// The ground-clutter lane (Mask + specialize's over-blend), both sidednesses — the first
// verification leg caught the two-sided one compiling live. Its material is built by
// `WorldAssets::model_material` (image machinery this pass doesn't need) — the pipeline only
// sees the KEY bits, so arm `clutter_fade` on a COPY of the plain Mask material (a fresh
// asset; the dedup cache's own entry stays untouched).
for two_sided in [false, true] {
let mask = model_material(
cache,
materials,
None,
ModelBlend::AlphaTest,
two_sided,
false,
false,
false,
false,
false,
false,
false,
FogPolicy::Scene,
ShadeSel::Lit,
0,
None,
None,
None,
None,
false,
light,
);
if let Some(m) = materials.get(&mask) {
let mut m = m.clone();
m.extension.clutter_fade = Vec4::new(52.5, 70.0, 0.0, 1.0);
let clutter = materials.add(m);
mats.push(clutter);
}
}
let mut count = 0;
for (mesh, aabb, skinned) in &layouts {
for mat in &mats {
let tag = if *skinned {
MeshTag(crate::mesh_tag::rig_bits(0) | crate::mesh_tag::alpha_bits(1.0))
} else {
MeshTag(crate::mesh_tag::alpha_bits(1.0))
};
let mut e = commands.spawn((
Mesh3d(mesh.clone()),
MeshMaterial3d(mat.clone()),
Transform::from_xyz(0.0, 0.0, -0.5).with_scale(Vec3::splat(0.01)),
tag,
WarmRig,
ChildOf(cam),
));
if let Some(aabb) = aabb {
e.insert(*aabb);
}
count += 1;
}
}
count
}
/// A unit quad in each attribute combination the model lane ships. Every `RenderSubmesh` field
/// is spelled out on purpose: a new field breaks THIS build, which is the drift alarm that keeps
/// the menagerie honest against the format.
fn warm_quad(colors: bool, skinned: bool) -> RenderSubmesh {
let n = 4usize;
RenderSubmesh {
positions: vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[1.0, 1.0, 0.0],
[0.0, 1.0, 0.0],
],
normals: vec![[0.0, 0.0, 1.0]; n],
uvs: vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]],
indices: vec![0, 1, 2, 0, 2, 3],
texture: None,
skin_slot: None,
geoset_id: 0,
char_slot: None,
blend: ModelBlend::Opaque,
wrap_x: true,
wrap_y: true,
two_sided: false,
joints: if skinned { vec![[0; 4]; n] } else { Vec::new() },
weights: if skinned {
vec![[1.0, 0.0, 0.0, 0.0]; n]
} else {
Vec::new()
},
vertex_colors: if colors {
vec![[1.0, 1.0, 1.0, 1.0]; n]
} else {
Vec::new()
},
interior: false,
emissive: false,
sidn: None,
window: false,
additive: false,
no_depth_write: false,
no_depth_test: false,
fog_policy: FogPolicy::Scene,
billboard: None,
alpha_anim: None,
uv_anim: None,
rgb_anim: None,
wmo_batch: None,
}
}
+20 -3
View File
@@ -523,8 +523,10 @@ pub(crate) fn apply_self_model_fade(
(
Without<RenderFade>,
Without<PendingAppearFade>,
// Disjointness for the card query below (both want `&mut MeshTag`); a card never
// carries `FadeMaterials`, so this excludes nothing.
// Disjointness for the card query below (both want `&mut MeshTag`). A card carries
// `FadeMaterials` too since 0836, so this now genuinely diverts them — into the loop
// at the end, which applies the same law without touching `Visibility` (a card's own
// hidden-owner mirror authors that in a different system).
Without<crate::billboard::BillboardCard>,
),
>,
@@ -532,6 +534,9 @@ pub(crate) fn apply_self_model_fade(
&crate::billboard::BillboardCard,
&mut MeshTag,
Option<&crate::doodad_anim::MatAnim>,
Option<&FadeMaterials>,
Option<&mut MeshMaterial3d<WowModelMaterial>>,
Option<&crate::interior::InteriorLit>,
)>,
mut reauthor: ResMut<crate::interior::InteriorReauthor>,
mut was_fading: Local<bool>,
@@ -564,7 +569,7 @@ pub(crate) fn apply_self_model_fade(
&mut walked,
);
let alpha = feather.clamp(0.0, 1.0);
for (card, mut tag, anim) in &mut cards {
for (card, mut tag, anim, fm, mat, lit) in &mut cards {
if !card
.follows()
.is_some_and(|anchor| walked.contains(&anchor))
@@ -580,6 +585,18 @@ pub(crate) fn apply_self_model_fade(
if tag.0 != bits {
tag.0 = bits;
}
// …and the blend twin while feathering, exactly as a mesh part does. The alpha alone is
// enough for an ADDITIVE card (`wow_model.wgsl` folds it into the colour, so α 0 is black
// is gone), but an OPAQUE one — a pauldron's camera-facing trim, a chain link — ignores it
// entirely and stayed solid in first person until the card carried a twin to swap to
// (decision 0836). No `Visibility` here: that channel belongs to the card's hidden-owner
// mirror in another system.
if let (Some(fm), Some(mut mat)) = (fm, mat) {
let want = fm.material_for(lit, alpha < 1.0);
if mat.0 != *want {
mat.0 = want.clone();
}
}
}
*was_fading = fading;
}
+12 -34
View File
@@ -58,17 +58,15 @@ pub struct WowModelKey {
/// values, so the hardware multiply IS the reference's byte multiply.
modulate: bool,
modulate2x: bool,
/// WMO authored batch index + 1 (0 = not a WMO batch), packed into `sun_scale.y` (shader-unread).
/// Drives a per-batch pipeline depth bias in `specialize`: the client draws WMO batches in strict
/// MOBA file order with depth-write ON + LEQUAL, so a later coplanar batch (wall decal/trim) wins
/// by ORDER (wow-5875-re `models/scratch/wmo-batch-blend-depth-state.md`, byte-verified). Bevy
/// orders draws for batching, not authorship, so coplanar ties were unstable (the run-to-run
/// "pale film" on buildings); the bias reproduces the authored layering deterministically.
wmo_batch_order: u16,
/// Depth-prime twin (`clutter_fade.z` bit 9 — `model_render::zfill_material`, the reference's
/// `M2UseZFill` clone command, wow-re `m2-blend-promotion-zfill.md` §4): `specialize` masks the
/// colour writes off, turns blend off, and forces depth-write ON.
zfill: bool,
// NB: the WMO authored batch order is deliberately NOT a key axis. It used to be (a
// per-batch-index `DepthBiasState` constant), which made every batch index its own pipeline —
// the city first-sight compile stall (decision 0837). The coplanar-layering nudge now rides
// `sun_scale.y` into `wow_model.wgsl`'s vertex stage as uniform data; `model_render::MatKey`
// still dedups materials per order, so per-batch identity is intact.
}
impl From<&WowModelExt> for WowModelKey {
@@ -82,7 +80,6 @@ impl From<&WowModelExt> for WowModelKey {
no_depth_test: markers & 2 != 0,
modulate: markers & 0x80 != 0,
modulate2x: markers & 0x100 != 0,
wmo_batch_order: e.sun_scale.y as u16,
zfill: markers & 0x200 != 0,
}
}
@@ -210,33 +207,14 @@ impl MaterialExtension for WowModelExt {
if key.bind_group_data.no_depth_test {
ds.depth_compare = CompareFunction::Always;
}
// WMO authored batch order (see `WowModelKey::wmo_batch_order`): a constant pipeline depth
// bias per batch index. The client resolves coplanar batches (wall + decal/trim layers) by
// strict MOBA draw order under depth-write+LEQUAL; Bevy's draw order is batching-defined,
// so we bias later batches to win the depth test instead. Bevy renders reverse-Z
// (GreaterEqual): a POSITIVE constant raises the depth value = nearer = a later batch beats
// an earlier coplanar one, in any draw order. One unit per index is the minimum resolvable
// depth step — decisive for coplanar ties, negligible (sub-mm) against real separation.
let order = key.bind_group_data.wmo_batch_order;
if order != 0 {
// DIAGNOSTIC (B38): `WOW_WMO_BIAS=0` drops the bias entirely, so the "both batches
// saturate to depth 1.0" theory can be tested. The log proves the branch was taken.
// `WOW_WMO_BIAS=0` drops the per-batch bias, the A/B that says whether a given
// flicker is this bias resolving (or failing to resolve) a tie. It answered B38:
// the flip is bit-identical with the bias gone, which also settles what the Metal
// backend does with the constant — `wgpu-hal` hands `bias.constant` to
// `setDepthBias` raw, and were that raw value ADDED to a reverse-Z depth (~0.02
// here) every WMO batch would clamp to 1.0 and tie, so removing it could not
// possibly leave the picture unchanged. It does, so the constant is being scaled by
// the depth format's ULP and the comment above means what it says.
let on = !matches!(std::env::var("WOW_WMO_BIAS").as_deref(), Ok("0"));
ds.bias.constant = if on { i32::from(order) } else { 0 };
debug!(
"wmo batch order {order}: bias {} compare {:?} depth_write {}",
ds.bias.constant, ds.depth_compare, ds.depth_write_enabled,
);
}
}
// The WMO authored-batch-order depth nudge (the coplanar MOBA layering determinism) is NOT
// here any more: as a fixed-function `DepthBiasState` constant it made every batch index its
// own PIPELINE — Stormwind alone queued ~3000 variants of this one shader, each a
// synchronous render-thread compile on macOS (the city first-sight stall, decision 0837).
// The nudge now lives in `wow_model.wgsl`'s vertex stage, an exact relative scale of clip z
// driven by `sun_scale.y` (uniform DATA, no pipeline axis) — same one-ULP-per-index
// semantics, byte-verified intent unchanged (wow-5875-re wmo-batch-blend-depth-state.md).
if key.bind_group_data.fade {
// The distance-fade blend twin needs depth-write ON so near geometry occludes far within the
// same fading model — force it regardless of the per-flag rule above.
+11
View File
@@ -48,6 +48,16 @@ pub(crate) mod targeting;
pub(crate) mod toggle;
mod weapon_icon;
/// The cooldown-event cut: [`state::feed_action_state`] fires the store-change flush trio
/// (`ACTIONBAR_UPDATE_COOLDOWN`/`SPELL_UPDATE_COOLDOWN`/`BAG_UPDATE_COOLDOWN`) **synchronously**
/// (`UiScript::fire_event` walks the handlers inline), so every feed that pushes cooldown
/// triples the handlers re-read (the container feed's slot cooldowns, the spellbook feed's) must
/// run `.before(CooldownEvents)` — or a handler reads last frame's triples and the pie stays
/// missing until the next store change. The action states themselves are safe by construction
/// (pushed by the same system, before it fires).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct CooldownEvents;
pub(crate) use cast_send::send_spell_cast;
pub(crate) use errors::{
attack_mounted_refusal, reagent_totem_refusal, ui_error_text, CastErrors, MountErrors, UiError,
@@ -223,6 +233,7 @@ impl Plugin for UiActionPlugin {
feed::feed_actions.in_set(UnitFeed).before(UiInput),
state::feed_action_state
.in_set(UnitFeed)
.in_set(CooldownEvents)
.after(feed::feed_actions)
.before(UiInput),
drain::drain_action_sets.after(UiInput),
+7 -2
View File
@@ -156,11 +156,13 @@ pub(super) fn feed_action_state(
mut cooldowns: ResMut<Cooldowns>,
auto_repeat: Res<AutoRepeatActive>,
// One tuple param (Bevy's 16-SystemParam ceiling): our own cast tracking — the in-flight
// guard, the queued on-next-swing strike, and the running channel.
// guard, the queued on-next-swing strike, the running channel, and the awaiting-click
// ground targeting.
cast_state: (
Res<crate::ui_cast::PendingCast>,
Res<crate::ui_cast::QueuedMeleeSpell>,
Res<crate::ui_cast::ActiveChannel>,
Res<super::SpellTargeting>,
),
self_q: Query<(&ObjectStore, &Transform, Has<Engaged>, Option<&Casting>), With<SelfPlayer>>,
selection: Res<Selection>,
@@ -191,7 +193,7 @@ pub(super) fn feed_action_state(
memory.last_cd_trace = Some(now);
}
let (pending, queued_melee, channel) = &cast_state;
let (pending, queued_melee, channel, targeting) = &cast_state;
let me = self_q.iter().next();
let engaged = me.is_some_and(|(_, _, e, _)| e);
let form_byte = me
@@ -241,6 +243,9 @@ pub(super) fn feed_action_state(
current_cast == Some(button.action)
|| queued_melee.current() == Some(button.action)
|| channel.current(now) == Some(button.action)
// The awaiting-target arm (`0x4e53a0` @ `0x4e54d0`: the `0x6e48e0`
// targeting-spell read) — checked while the ground click is pending.
|| targeting.spell() == Some(button.action)
|| (form_byte != 0 && d.shapeshift_form == Some(u32::from(form_byte)))
};
st.auto_repeat = auto_repeat.0 == Some(button.action);
+8 -1
View File
@@ -646,7 +646,14 @@ impl Plugin for UiItemsPlugin {
.add_systems(
Update,
(
feed_containers.in_set(UnitFeed).before(UiInput),
// `.before(CooldownEvents)`: the slot cooldown triples must be in the VM
// before `feed_action_state`'s synchronous `BAG_UPDATE_COOLDOWN` makes the
// bag handlers re-read them (the set's own doc — else a fresh cooldown's
// pie waits for the NEXT store change).
feed_containers
.in_set(UnitFeed)
.before(crate::ui_action::CooldownEvents)
.before(UiInput),
// The shared item-tooltip store: answer stat asks before the input pass so a
// re-hover the very next frame already sees them.
feed_item_stats.in_set(UnitFeed).before(UiInput),
@@ -50,6 +50,8 @@ fn book() -> SpellBookState {
rank: Some("Rank 1".into()),
texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()),
passive: false,
current: false,
cooldown: None,
},
SpellSlotView {
spell_id: 2136,
@@ -57,6 +59,8 @@ fn book() -> SpellBookState {
rank: Some("Rank 1".into()),
texture: Some("Interface\\Icons\\Spell_Fire_FireBolt02".into()),
passive: false,
current: false,
cooldown: None,
},
SpellSlotView {
spell_id: 168,
@@ -64,13 +68,16 @@ fn book() -> SpellBookState {
rank: Some("Rank 1".into()),
texture: Some("Interface\\Icons\\Spell_Frost_FrostArmor02".into()),
passive: false,
current: false,
cooldown: None,
},
],
}
}
/// The loader itself: every file the window depends on parses and materializes with no errors —
/// the window + close + prev/next page buttons + 12 spell buttons + 8 skill-line tabs = 24 frames.
/// the window + close + prev/next page buttons + 12 spell buttons (each with a Cooldown child)
/// + 8 skill-line tabs = 36 frames.
#[test]
fn shipped_spellbook_loads_clean() {
let s = UiScript::new().unwrap();
@@ -89,8 +96,9 @@ fn shipped_spellbook_loads_clean() {
report.errors
);
assert_eq!(
report.frames, 24,
"window + close + prev/next + 12 spell buttons + 8 skill-line tabs"
report.frames, 36,
"window + close + prev/next + 12 spell buttons (each with a Cooldown child) + 8 \
skill-line tabs"
);
}
@@ -195,6 +203,83 @@ fn shipped_spellbook_drives_end_to_end() {
assert_eq!(s.take_action_sets(), vec![(1, 133)]); // 0x00<<24 | 133
}
/// The cooldown pie through the REAL shipped XML (the once-deferred half of slice 5): a pushed
/// per-slot triple + `SPELL_UPDATE_COOLDOWN` arms the button's Cooldown widget mid-sweep (ref
/// SpellButton_UpdateButton l.359-360), and an on-hold triple (enable 0 — Stealth/Feign Death
/// parked until SMSG_COOLDOWN_EVENT) keeps the widget hidden and dims the icon to 40% (l.361-365).
#[test]
fn shipped_spellbook_shows_the_cooldown_pie() {
use benilla_ui::script::QuadContent;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in [
"Fonts.xml",
"UiPanels.xml",
"GameTooltip.xml",
"Cooldown.xml",
"SpellBookFrame.xml",
] {
load_xml(&s, f);
}
s.set_spellbook(book());
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
s.tick(10.0); // GetTime = 10
// A running 10 s cooldown started at t=6 (6 s left): the event re-read arms the sweep.
let mut b = book();
b.slots[0].cooldown = Some((6_000, 10_000, true));
s.set_spellbook(b);
s.fire_event("SPELL_UPDATE_COOLDOWN", vec![]);
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let sweep = s.extract().into_iter().find_map(|q| {
if s.quad_owner_name(q.target).as_deref() != Some("BenillaSpellButton1Cooldown") {
return None;
}
match q.content {
QuadContent::Cooldown { fraction, flash } => Some((fraction, flash)),
_ => None,
}
});
let (fraction, flash) = sweep.expect("BenillaSpellButton1's Cooldown widget is showing");
assert!(
(fraction - 0.4).abs() < 1e-3,
"4 s elapsed of 10 ⇒ the sweep sits at 40%, got {fraction}"
);
assert_eq!(flash, None);
// An on-hold triple: no sweep (CooldownFrame_SetTimer's enable gate), the icon dims to 40%.
let mut b = book();
b.slots[0].cooldown = Some((6_000, 10_000, false));
s.set_spellbook(b);
s.fire_event("SPELL_UPDATE_COOLDOWN", vec![]);
s.resolve();
let quads = s.extract();
assert!(
!quads.iter().any(|q| {
s.quad_owner_name(q.target).as_deref() == Some("BenillaSpellButton1Cooldown")
}),
"an on-hold cooldown draws no sweep"
);
let icon_color = quads.iter().find_map(|q| {
if s.quad_owner_name(q.target).as_deref() != Some("BenillaSpellButton1") {
return None;
}
match &q.content {
QuadContent::Texture {
path: Some(p),
color,
..
} if p.contains("Spell_Fire_FlameBolt") => Some(*color),
_ => None,
}
});
let c = icon_color.expect("icon quad").expect("vertex color set");
assert_eq!((c[0], c[1], c[2]), (0.4, 0.4, 0.4), "the on-hold 40% dim");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The empty-slot LOOK, pinned at the quad level (the regression that shipped with slice 5): a
/// slot past the book's length is a **disabled** SpellButton with no DisabledTexture and an
/// unchecked ring — so it draws its UI-Spellbook-SpellBackground square and **nothing else**. The
+65 -4
View File
@@ -78,7 +78,13 @@ impl Plugin for UiSpellbookPlugin {
// Feed rides with the unit feed like ui_action's own (before UiInput, so
// P-key-open-this-frame already sees a populated book); the cast drain runs
// after UiInput so a click's CastSpell goes out the same frame.
feed_spellbook.in_set(UnitFeed).before(UiInput),
// `.before(CooldownEvents)`: the per-slot cooldown triples must be in the VM
// before `feed_action_state`'s synchronous `SPELL_UPDATE_COOLDOWN` makes the
// book buttons re-read them (that set's own doc).
feed_spellbook
.in_set(UnitFeed)
.before(crate::ui_action::CooldownEvents)
.before(UiInput),
drain_spell_casts.after(UiInput),
),
);
@@ -122,6 +128,7 @@ fn feed_spellbook(
mut items: ResMut<Items>,
icons: Option<Res<ItemDisplays>>,
commands: Res<NetCommands>,
cooldowns: Res<crate::cooldowns::Cooldowns>,
mut memory: Local<FeedMemory>,
) {
let Some(mut script) = script else {
@@ -148,7 +155,7 @@ fn feed_spellbook(
// serves the page.
let ranged_icon =
store.and_then(|s| ranged_weapon_icon(s, &mut items, icons.as_deref(), &commands));
let fresh = build_book(
let mut fresh = build_book(
&actions.spells,
&spells.catalog,
skill_lines.as_deref().map(|s| &s.catalog),
@@ -157,15 +164,65 @@ fn feed_spellbook(
attack_icon,
ranged_icon,
);
// The `IsCurrentCast` verdict per slot — the delegate `0x4b3600` (wow-re
// `spellbook-checked-predicate.md`, §5-verified): its OWN function, NOT the action bar's
// `0x4e53a0`, and deliberately narrower — TWO arms only. The shapeshift arm is built here: a
// MOD_SHAPESHIFT spell reads current exactly while the player's form byte equals its form id
// (keyed on the FORM, so any spell/rank granting it reads true — Ghost Wolf's slot glows
// while shifted, a druid form's likewise). The other arm (the open trade-skill window) stays
// unmodeled with the trade-skill session itself. The action predicate's casting-now /
// awaiting-target / item / attack arms are ABSENT in the binary — a book slot must NOT light
// during an ordinary cast (the verdict's load-bearing negative).
let form_byte = store.map(|s| s.0.unit_shapeshift_form()).unwrap_or(0);
if form_byte != 0 {
for slot in &mut fresh.slots {
slot.current = spells
.catalog
.get(slot.spell_id)
.is_some_and(|d| d.shapeshift_form == Some(u32::from(form_byte)));
}
}
// Each slot's cooldown triple — the ONE store's per-spell read (`Cooldowns::info`, the
// `GetCooldownInfo 0x6e13e0` resolve: id, category, and GCD spread alike), converted to the
// GetTime clock exactly like the action/bag feeds (`CooldownInfo::ui_triple` is frame-stable
// per arm, so a running cooldown never churns this diff). The XML's SPELL_UPDATE_COOLDOWN
// handler re-reads these through `GetSpellCooldown` — the plugin's `.before(CooldownEvents)`
// guarantees they're pushed before that event fires.
let now = std::time::Instant::now();
let ui_now = script.now();
for slot in &mut fresh.slots {
slot.cooldown = cooldowns
.info(slot.spell_id, 0, spells.catalog.get(slot.spell_id), now)
.ui_triple(now, ui_now);
}
if fresh != memory.pushed {
// Two event edges off one diff: the ref fires CURRENT_SPELL_CAST_CHANGED for a checked-
// ring move and SPELLS_CHANGED for the book itself; a same-frame both fires both.
let ring_moved = fresh.slots.len() != memory.pushed.slots.len()
|| fresh
.slots
.iter()
.zip(&memory.pushed.slots)
.any(|(a, b)| a.current != b.current);
let book_changed = fresh.tabs != memory.pushed.tabs
|| fresh.slots.len() != memory.pushed.slots.len()
|| fresh.slots.iter().zip(&memory.pushed.slots).any(|(a, b)| {
(a.spell_id, &a.name, &a.rank, &a.texture, a.passive)
!= (b.spell_id, &b.name, &b.rank, &b.texture, b.passive)
});
debug!(
"ui_spellbook: fed {} tab(s), {} spell(s)",
"ui_spellbook: fed {} tab(s), {} spell(s) (book {book_changed}, ring {ring_moved})",
fresh.tabs.len(),
fresh.slots.len()
);
script.set_spellbook(fresh.clone());
memory.pushed = fresh;
script.fire_event("SPELLS_CHANGED", vec![]);
if book_changed {
script.fire_event("SPELLS_CHANGED", vec![]);
}
if ring_moved {
script.fire_event("CURRENT_SPELL_CAST_CHANGED", vec![]);
}
}
}
@@ -248,6 +305,10 @@ fn build_book(
rank: d.and_then(|d| d.rank.clone()),
texture,
passive: d.is_some_and(|d| d.passive),
// The IsCurrentCast verdict and the cooldown triple are stamped by the feed
// after the build (they need the live form/cooldown state, not the catalog).
current: false,
cooldown: None,
});
}
tabs.push(SpellTabView {
+26 -18
View File
@@ -32,9 +32,13 @@ use crate::target::{ring_reaction, Factions, Selection};
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
use crate::ui_script::UiInput;
/// The feed pass — runs before [`UiInput`] so the snapshot + events it produces are in place when the
/// VM ticks and dispatches this frame. A named set so the demo override ([`crate::ui_script`]) can
/// order itself after it.
/// The feed pass — runs **after [`crate::schedule::WorldStage::Net`]** (the feeds snapshot state
/// the net apply writes; unordered, `apply_net_updates` could land BETWEEN two feeds, and a
/// synchronous event fired by the later one then re-read the earlier one's pre-mutation push —
/// the spellbook's cooldown pie stayed cold until a manual reopen, reproduced live 2026-07-31)
/// and before [`UiInput`], so the snapshot + events it produces are in place when the VM ticks
/// and dispatches this frame. A named set so the demo override ([`crate::ui_script`]) can order
/// itself after it. Configured in [`UiUnitPlugin`] — the set's home.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct UnitFeed;
@@ -112,22 +116,26 @@ pub(crate) struct UiUnitPlugin;
impl Plugin for UiUnitPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<UnitFeedState>()
.add_message::<UnitCombatFeedback>()
.add_message::<CombatTextEvent>()
.add_systems(
Update,
(
feed_units,
melee_unit_combat,
fire_unit_combat,
fire_combat_text,
)
.chain()
.in_set(UnitFeed)
.before(UiInput),
app.configure_sets(
Update,
UnitFeed.after(crate::schedule::WorldStage::Net), // the set's own doc: the why
)
.init_resource::<UnitFeedState>()
.add_message::<UnitCombatFeedback>()
.add_message::<CombatTextEvent>()
.add_systems(
Update,
(
feed_units,
melee_unit_combat,
fire_unit_combat,
fire_combat_text,
)
.add_systems(Update, drain_pvp_toggles.after(UiInput));
.chain()
.in_set(UnitFeed)
.before(UiInput),
)
.add_systems(Update, drain_pvp_toggles.after(UiInput));
}
}