From ab65981ebf7a9123c6b4241f2c2f46a6f8012952 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 31 Jul 2026 17:01:30 +0700 Subject: [PATCH] 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 --- Cargo.lock | 3 + .../src/bin/benilla-extract/m2dump.rs | 95 +++ .../src/bin/benilla-extract/scan.rs | 39 +- crates/benilla-formats/src/lib.rs | 28 +- .../benilla-formats/src/models/m2_batches.rs | 192 +++++- crates/benilla-formats/src/models/types.rs | 34 +- crates/benilla-protocol/Cargo.toml | 6 + .../examples/srp_encoding_probe.rs | 467 ++++++++++++++ crates/benilla-protocol/src/lib.rs | 48 +- crates/benilla-srp/src/lib.rs | 221 +++++-- crates/benilla-ui/src/script/spellbook.rs | 145 +++++ crates/benilla/assets/shaders/ffx_glow.wgsl | 29 +- crates/benilla/assets/shaders/wow_model.wgsl | 9 + crates/benilla/assets/ui/SpellBookFrame.xml | 87 ++- crates/benilla/src/capture/pick_probe.rs | 43 +- crates/benilla/src/doodad_anim.rs | 29 +- crates/benilla/src/entities/attach/dress.rs | 187 ++++-- .../benilla/src/entities/equipment/spawn.rs | 213 +++++-- crates/benilla/src/loading_screen.rs | 7 +- crates/benilla/src/main.rs | 4 + crates/benilla/src/model_fade.rs | 253 +++++++- crates/benilla/src/model_render.rs | 15 +- crates/benilla/src/particles/quads.rs | 13 +- crates/benilla/src/perf.rs | 17 +- crates/benilla/src/pipe_warm.rs | 568 ++++++++++++++++++ crates/benilla/src/player/camera.rs | 23 +- crates/benilla/src/terrain.rs | 46 +- crates/benilla/src/ui_action/mod.rs | 11 + crates/benilla/src/ui_action/state.rs | 9 +- crates/benilla/src/ui_items/mod.rs | 9 +- .../benilla/src/ui_script/spellbook_tests.rs | 91 ++- crates/benilla/src/ui_spellbook.rs | 69 ++- crates/benilla/src/ui_unit.rs | 44 +- 33 files changed, 2707 insertions(+), 347 deletions(-) create mode 100644 crates/benilla-protocol/examples/srp_encoding_probe.rs create mode 100644 crates/benilla/src/pipe_warm.rs diff --git a/Cargo.lock b/Cargo.lock index 2cd44b95..32aab707 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -675,6 +675,9 @@ dependencies = [ "benilla-srp", "clap", "flate2", + "num-bigint", + "rand 0.8.6", + "sha1", ] [[package]] diff --git a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs index 022ce291..2108a42f 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs @@ -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 = 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 { )) } +/// 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 { + 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 = 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::>() + .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 = 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::>() + .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::>() + .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(()) } diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan.rs b/crates/benilla-formats/src/bin/benilla-extract/scan.rs index 229c0765..859f2914 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan.rs @@ -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 = 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 = 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::>() + .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) ); diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 05b60ccd..efc51cf5 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -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::{ diff --git a/crates/benilla-formats/src/models/m2_batches.rs b/crates/benilla-formats/src/models/m2_batches.rs index cb228df2..3b23a44f 100644 --- a/crates/benilla-formats/src/models/m2_batches.rs +++ b/crates/benilla-formats/src/models/m2_batches.rs @@ -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 { + 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 { + 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 { + 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 = 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 { 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, Vec)> = 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 diff --git a/crates/benilla-formats/src/models/types.rs b/crates/benilla-formats/src/models/types.rs index 10b7b2d4..dd045219 100644 --- a/crates/benilla-formats/src/models/types.rs +++ b/crates/benilla-formats/src/models/types.rs @@ -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 diff --git a/crates/benilla-protocol/Cargo.toml b/crates/benilla-protocol/Cargo.toml index 07df0a19..52739981 100644 --- a/crates/benilla-protocol/Cargo.toml +++ b/crates/benilla-protocol/Cargo.toml @@ -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" diff --git a/crates/benilla-protocol/examples/srp_encoding_probe.rs b/crates/benilla-protocol/examples/srp_encoding_probe.rs new file mode 100644 index 00000000..b8908695 --- /dev/null +++ b/crates/benilla-protocol/examples/srp_encoding_probe.rs @@ -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 { + 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::() { + 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 = 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(()) +} diff --git a/crates/benilla-protocol/src/lib.rs b/crates/benilla-protocol/src/lib.rs index c5279011..a2809d6e 100644 --- a/crates/benilla-protocol/src/lib.rs +++ b/crates/benilla-protocol/src/lib.rs @@ -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 { // `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, diff --git a/crates/benilla-srp/src/lib.rs b/crates/benilla-srp/src/lib.rs index 52c6b5db..49127923 100644 --- a/crates/benilla-srp/src/lib.rs +++ b/crates/benilla-srp/src/lib.rs @@ -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)); diff --git a/crates/benilla-ui/src/script/spellbook.rs b/crates/benilla-ui/src/script/spellbook.rs index 4895c8a1..9537ab6f 100644 --- a/crates/benilla-ui/src/script/spellbook.rs +++ b/crates/benilla-ui/src/script/spellbook.rs @@ -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::().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::().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::("return IsCurrentCast(1, BOOKTYPE_SPELL) == nil") + .unwrap()); + let mut b = book(); + b.slots[0].current = true; + s.set_spellbook(b); + assert_eq!( + s.eval::("return IsCurrentCast(1, BOOKTYPE_SPELL)") + .unwrap(), + 1 + ); + assert!(s + .eval::("return IsCurrentCast(2, BOOKTYPE_SPELL) == nil") + .unwrap()); + assert!(s + .eval::("return IsCurrentCast(1, BOOKTYPE_PET) == nil") + .unwrap()); + assert!(s + .eval::("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(); diff --git a/crates/benilla/assets/shaders/ffx_glow.wgsl b/crates/benilla/assets/shaders/ffx_glow.wgsl index 80ec82d0..cf5a5def 100644 --- a/crates/benilla/assets/shaders/ffx_glow.wgsl +++ b/crates/benilla/assets/shaders/ffx_glow.wgsl @@ -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 { let texel = 1.0 / vec2(textureDimensions(in_tex)); - return (textureSample(in_tex, in_samp, in.uv + vec2(-1.5, -1.5) * texel) - + textureSample(in_tex, in_samp, in.uv + vec2(0.5, -1.5) * texel) - + textureSample(in_tex, in_samp, in.uv + vec2(0.5, 0.5) * texel) - + textureSample(in_tex, in_samp, in.uv + vec2(-1.5, 0.5) * texel)) + let one = vec4(1.0); + return (min(textureSample(in_tex, in_samp, in.uv + vec2(-1.5, -1.5) * texel), one) + + min(textureSample(in_tex, in_samp, in.uv + vec2(0.5, -1.5) * texel), one) + + min(textureSample(in_tex, in_samp, in.uv + vec2(0.5, 0.5) * texel), one) + + min(textureSample(in_tex, in_samp, in.uv + vec2(-1.5, 0.5) * texel), one)) * 0.25; } @@ -81,7 +91,10 @@ fn fs_combine(in: FullscreenVertexOutput) -> @location(0) vec4 { // 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(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(0.0), vec3(1.0)); let bg = max(textureSample(blur_tex, in_samp, in.uv).rgb, vec3(0.0)); let glowed = sg + glow.x * bg * bg; // The scene alpha rides through untouched: the window surface ignores it and the opaque-clear diff --git a/crates/benilla/assets/shaders/wow_model.wgsl b/crates/benilla/assets/shaders/wow_model.wgsl index 39a46a18..f853ed73 100644 --- a/crates/benilla/assets/shaders/wow_model.wgsl +++ b/crates/benilla/assets/shaders/wow_model.wgsl @@ -377,6 +377,15 @@ fn vertex(vertex: WowVertex) -> WowVsOut { out.world_position = mesh_functions::mesh_position_local_to_world(world_from_local, vec4(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 diff --git a/crates/benilla/assets/ui/SpellBookFrame.xml b/crates/benilla/assets/ui/SpellBookFrame.xml index 2881804a..32971f6d 100644 --- a/crates/benilla/assets/ui/SpellBookFrame.xml +++ b/crates/benilla/assets/ui/SpellBookFrame.xml @@ -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). --> @@ -459,6 +494,14 @@ + + + BenillaSpellButton_OnLoad(self) BenillaSpellButton_OnEvent(self) diff --git a/crates/benilla/src/capture/pick_probe.rs b/crates/benilla/src/capture/pick_probe.rs index b317dd04..5feb93b3 100644 --- a/crates/benilla/src/capture/pick_probe.rs +++ b/crates/benilla/src/capture/pick_probe.rs @@ -104,14 +104,22 @@ fn parse_pixels(spec: &str) -> Vec { .collect() } +/// The filter deciding what a cast may hit — see [`fire_pick`]'s `objects` for why it is both. +type Pickable = Or<(With, With)>; + /// 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>, 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, ); /// 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>, camera: Query<(&Camera, &GlobalTransform), With>, - objects: Query>, + // 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, 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 {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( + || ("".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 diff --git a/crates/benilla/src/doodad_anim.rs b/crates/benilla/src/doodad_anim.rs index 2bd1b544..e30a00a4 100644 --- a/crates/benilla/src/doodad_anim.rs +++ b/crates/benilla/src/doodad_anim.rs @@ -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) -> 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 } } diff --git a/crates/benilla/src/entities/attach/dress.rs b/crates/benilla/src/entities/attach/dress.rs index 5d715272..b44e4886 100644 --- a/crates/benilla/src/entities/attach/dress.rs +++ b/crates/benilla/src/entities/attach/dress.rs @@ -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::(); + 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, + &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. diff --git a/crates/benilla/src/entities/equipment/spawn.rs b/crates/benilla/src/entities/equipment/spawn.rs index ab5a9542..d500f51d 100644 --- a/crates/benilla/src/entities/equipment/spawn.rs +++ b/crates/benilla/src/entities/equipment/spawn.rs @@ -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 = Handle::Uuid( + bevy::asset::uuid::Uuid::from_u128(99), + std::marker::PhantomData, + ); + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + app.init_resource::(); + 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, + 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) { diff --git a/crates/benilla/src/loading_screen.rs b/crates/benilla/src/loading_screen.rs index 56856737..120e5ec2 100644 --- a/crates/benilla/src/loading_screen.rs +++ b/crates/benilla/src/loading_screen.rs @@ -352,6 +352,9 @@ fn drive_loading_screen( >, player: Option>, time: Res