diff --git a/crates/benilla-assets/src/m2.rs b/crates/benilla-assets/src/m2.rs index 917f3097..9ac6f78b 100644 --- a/crates/benilla-assets/src/m2.rs +++ b/crates/benilla-assets/src/m2.rs @@ -400,7 +400,7 @@ impl AssetLoader for M2ModelLoader { // Ribbon emitters (trails): same texture resolution, bone-pivot bake and owner-reach bake // as the particles — a trail is one of the model's emitters and draws under the same law. - let ribbons = benilla_formats::parse_m2_ribbon_emitters(&bytes) + let ribbons: Vec = benilla_formats::parse_m2_ribbon_emitters(&bytes) .unwrap_or_default() .into_iter() .map(|def| { @@ -436,11 +436,7 @@ impl AssetLoader for M2ModelLoader { def, }) .collect(); - // The welded-billboard gate (decision 0935): a billboard bone whose geometry is stitched - // into the surrounding mesh keeps its authored pose, because facing it drags the mesh. - // Same predicate the per-batch card split already refuses on, so the two lanes agree. - let welded_billboards = benilla_formats::non_separable_billboard_bones(&bytes); - let (skeleton, inverse_bindposes) = build_skeleton(&skeleton_raw, &welded_billboards); + let (skeleton, inverse_bindposes) = build_skeleton(&skeleton_raw); let inverse_bindposes = ctx.add_labeled_asset( "inverse_bindposes".to_string(), SkinnedMeshInverseBindposes::from(inverse_bindposes), @@ -554,7 +550,16 @@ impl AssetLoader for M2ModelLoader { .map_or(0, |p: &benilla_formats::PlayableAnim| p.resolved_id); let mut first_seq = None; for (i, anim) in sequences.iter().enumerate() { - if let Some((clip, pose_clip)) = build_animation_clip(anim, &skeleton) { + // EVERY sequence becomes a clip — the clip carries the instance's sequence CLOCK, + // not just a bone pose (decision 0941, [`build_animation_clip`]). `poses_bones` + // separates the two meanings: the clock is free, the rig is not. + { + let (clip, pose_clip, poses_bones) = build_animation_clip(anim, &skeleton); + // `first_seq` stays the RENDERING question — "is this seed worth a skin + + // player" (0130's content gate, and decision 0936's split). The identity + // question, "which sequence does the loader arm", is + // [`ModelAnimations::idle_seq`]/`idle_clip`, which asks the same selection + // without the gate. if first_seq.is_none() && anim.anim_id == idle_id && idle_pose_differs(anim) { first_seq = Some(clips.len()); } @@ -602,6 +607,7 @@ impl AssetLoader for M2ModelLoader { upper_node, frequency: anim.frequency, replay: (anim.min_replay, anim.max_replay), + poses_bones, }); } } @@ -647,7 +653,25 @@ impl AssetLoader for M2ModelLoader { // empty would silently freeze it. let global_bones = build_global_bones(&parse_m2_global_sequence_bones(&bytes), &skeleton); - (!clips.is_empty() || !global_bones.is_empty()).then(|| { + // The gate is "does ANYTHING in this model vary with the sequence clock" — and its + // consumer list was incomplete (decision 0941). A bone-posing clip and a global + // sequence were counted; the **per-sequence samplers** were not: the emitters' + // rate/enable/params tracks, a ribbon's, and the material alpha/colour/UV loops all + // resolve through the instance's playing sequence. 807 corpus models animate ONLY + // through those, and denying them `ModelAnimations` denied them the clock itself — + // no player, no slot, no time — so every one of those tracks read file slot 0 at + // t=0 for ever. Models with none of the three keep the static path: the optimization + // decision 0130 measured (~90% of placed doodads) is untouched, and 6342 boneless + // models with nothing to sample stay out of the animation lane entirely. + let samples_sequence = !emitters.is_empty() + || !ribbons.is_empty() + || submeshes + .iter() + .any(|s| s.alpha_anim.is_some() || s.uv_anim.is_some() || s.rgb_anim.is_some()); + (clips.iter().any(|c| c.poses_bones) + || !global_bones.is_empty() + || (samples_sequence && !clips.is_empty())) + .then(|| { let graph = ctx.add_labeled_asset("anim_graph".to_string(), graph); ModelAnimations { graph, diff --git a/crates/benilla-assets/src/model.rs b/crates/benilla-assets/src/model.rs index 27d894cb..8550d664 100644 --- a/crates/benilla-assets/src/model.rs +++ b/crates/benilla-assets/src/model.rs @@ -255,14 +255,15 @@ pub struct ModelJoint { /// CHILD of a lock-Z bone). Rigged hosts feed this to the billboard joint pass; the per-batch /// card split only covers geometry skinned to the billboard bone itself. /// - /// **`None` for a WELDED billboard bone**, even though the M2 authors the flag — see - /// [`build_skeleton`]. A bone whose geometry is stitched into the surrounding mesh cannot be - /// re-oriented without dragging that mesh with it, so no palette lane faces it (decision 0935). + /// Authored on every bone that carries the flag, welded seam or not: `m2_animate` reads no + /// vertex, weight or triangle data at all, so there is no topology gate on the arm (decision + /// 0945 superseding 0935). A welded seam stays welded because the replacement is built about + /// the bone's own pivot, which is what makes the blended seam ring move by millimetres. pub billboard: Option, - /// Bone flag `0x04` (see [`benilla_formats::SkeletonBone::ignore_parent_rotation`]): the bone - /// keeps the MODEL ROOT's orientation — its pivot rides the parent's full matrix, its rotation - /// doesn't. Same palette pass as `billboard`, different replacement rotation. - pub ignore_parent_rotation: bool, + /// Bone flags `0x1/0x2/0x4` (see [`benilla_formats::SkeletonBone::parent_arm`]): how the + /// bone's effective PARENT matrix is rebuilt from the model's own root frame before it + /// composes. Applied by the same palette passes as `billboard`, and *before* them. + pub parent_arm: Option, } /// A model's rest skeleton in Bevy space (decision 0019). Built once per M2 asset; the creature path @@ -293,23 +294,7 @@ pub struct ModelSkeleton { /// transform (the two pivot translations cancel before scale acts). /// /// Pivots map WoW→Bevy via [`wow_to_bevy`] (a pure rotation), so the whole skeleton lives in mesh space. -/// -/// ## The welded-billboard gate (decision 0935) -/// -/// `welded_billboards` is [`benilla_formats::non_separable_billboard_bones`] for the same model: -/// billboard bones whose geometry is stitched into the surrounding mesh — partial-weight seam -/// vertices, or triangles straddling the bone and a static neighbour. **Their arm is dropped -/// here**, so no palette lane camera-faces them, and the whole model draws in its authored pose. -/// -/// This is the same predicate the per-batch card split already uses to refuse a welded bone -/// (`RenderSubmesh::welded_billboard`, 0839), applied one level up. Reading it from that one -/// function is the point: the card lane's "is this separable" and the palette lane's can never -/// disagree, which is exactly how the left pauldron ended up camera-faced by one lane while the -/// other left it alone. -pub(crate) fn build_skeleton( - skel: &Skeleton, - welded_billboards: &[u16], -) -> (ModelSkeleton, Vec) { +pub(crate) fn build_skeleton(skel: &Skeleton) -> (ModelSkeleton, Vec) { let pivots = skeleton_pivots(skel); let joints = skel .bones @@ -323,10 +308,8 @@ pub(crate) fn build_skeleton( ModelJoint { parent: b.parent, local_translation: pivots[i] - parent_pivot, - billboard: b - .billboard - .filter(|_| !welded_billboards.contains(&(i as u16))), - ignore_parent_rotation: b.ignore_parent_rotation, + billboard: b.billboard, + parent_arm: b.parent_arm, } }) .collect(); @@ -542,11 +525,19 @@ fn keyframe_curve( /// - **rotation** = the WoW quat conjugated into Bevy space (`r·q·r⁻¹`); /// - **scale** = the WoW scale with axes permuted to Bevy's (magnitudes; near-always uniform anyway). /// -/// `None` if no channel produced a curve (every bone holds bind pose → nothing worth playing). +/// Always returns a clip, and a flag for whether any channel produced a **curve**. A sequence +/// whose bones all hold bind pose still gets one — an empty clip carrying the sequence's own +/// duration — because the clip is not only a bone pose: it is this model's carrier for the +/// instance's **sequence clock** (which slot is playing, and how far into it), which the emitter +/// rate/params tracks, the material-alpha loops and the GameObject state arm all resolve through. +/// Dropping the track-less ones left 807 corpus models — the ones whose animation is authored +/// entirely in the *emitter* tracks — with no clock at all, frozen on slot 0 at t=0 for ever +/// (decision 0941). The flag is what the doodad content gate reads instead (`poses_bones`): a clip +/// with no curves can only ever render the bind-pose mesh, so it must not spawn a rig. pub(crate) fn build_animation_clip( anim: &ModelAnimation, skeleton: &ModelSkeleton, -) -> Option<(AnimationClip, PoseClip)> { +) -> (AnimationClip, PoseClip, bool) { let r = wow_to_bevy_quat(); let mut clip = AnimationClip::default(); let mut pose = PoseClip::default(); @@ -608,7 +599,13 @@ pub(crate) fn build_animation_clip( scale: pose_scale, }); } - any.then_some((clip, pose)) + if !any { + // Bevy derives a clip's duration from its curves; with none, the sequence's own band + // length is the clock's period — without it the player would wrap on a 0-second loop + // (a modulo by zero) and the clock would never leave t=0, which is the whole defect. + clip.set_duration(anim.duration.max(1e-3)); + } + (clip, pose, any) } /// Build the weapon-grip **overlay clip** from clamped finger poses ([`benilla_formats::hand_grip_finger_poses`]): @@ -778,6 +775,62 @@ pub(crate) fn build_global_bones( mod tests { use super::*; + /// One sequence record, `bones` supplied by the caller — everything else at a neutral value. + fn sequence(duration: f32, bones: Vec) -> ModelAnimation { + ModelAnimation { + anim_id: 0, + seq_index: 0, + start_ms: 0, + end_ms: (duration * 1000.0) as u32, + duration, + looping: true, + move_speed: 0.0, + blend_time: 0.0, + bounds_center: [0.0; 3], + bounds_radius: 0.0, + bounds_min: [0.0; 3], + bounds_max: [0.0; 3], + frequency: 0, + min_replay: 0, + max_replay: 0, + bones, + events: Vec::new(), + } + } + + /// A sequence whose bones hold bind pose still becomes a clip — an EMPTY one carrying the + /// sequence's own duration — because the clip is this model's carrier for the instance's + /// **sequence clock** (decision 0941). Dropping it left 807 corpus models whose animation is + /// authored purely in their emitter tracks with no clock at all: no player, so no slot and no + /// time, so every per-sequence track read file slot 0 at t = 0 for ever. The duration is the + /// load-bearing half — Bevy derives a clip's period from its curves, and a 0-second period + /// would wrap the player's `seek_time` by a modulo-zero and never leave t = 0. + #[test] + fn a_boneless_sequence_still_yields_a_clock_clip() { + let skeleton = ModelSkeleton { + joints: Vec::new(), + spine_bone: None, + head_bone: None, + }; + let (clip, _pose, poses_bones) = + build_animation_clip(&sequence(1.333, Vec::new()), &skeleton); + assert!(!poses_bones, "no bone track ⇒ nothing to skin to"); + assert!( + (clip.duration() - 1.333).abs() < 1e-4, + "the clock's period is the sequence's own band, not 0: {}", + clip.duration() + ); + // The flag is what the doodad content gate reads: a clock-only clip must never spawn a rig. + let moving = benilla_formats::BoneKeys { + bone: 0, + translation: vec![(0.0, [0.0, 0.0, 0.0]), (0.5, [0.0, 0.0, 1.0])], + rotation: Vec::new(), + scale: Vec::new(), + }; + let (_, _, poses_bones) = build_animation_clip(&sequence(1.0, vec![moving]), &skeleton); + assert!(poses_bones, "a keyed track ⇒ a real pose"); + } + /// A [`GlobalSeqChannel`] samples with linear interpolation, clamps at the endpoint keys, and wraps /// on its period — the eye-blink shape: `0` (open) held, a fast ramp to `1` (shut), back to `0`, and /// the whole thing repeating every `period`. Models the real eyelid keys (ms → seconds). @@ -856,64 +909,23 @@ mod tests { assert!(id.dot(Quat::IDENTITY).abs() > 0.9999, "got {id:?}"); } - /// **A welded billboard bone reaches the palette lanes with no arm** (decision 0935). - /// - /// The shape is the Field Marshal pauldron's: a plain root plus two spherical-billboard spikes, - /// both non-separable because their seam rings are stitched into the body. Bone 1 stands for - /// them; bone 2 is a separable card bone on the same model, and must keep its arm — the gate is - /// per bone, not per model, or every glow card on a model that also carries one welded spike - /// would freeze with it. - /// - /// `ModelJoint::billboard` is the single field both lanes read (`BillboardJointRig::new` and - /// `RigPose`), so clearing it here is the whole gate. - #[test] - fn a_welded_billboard_bone_loses_its_arm_and_a_separable_one_keeps_it() { - let bone = |billboard| benilla_formats::SkeletonBone { - parent: -1, - pivot: [0.0, 0.0, 0.0], - key_bone: -1, - billboard, - ignore_parent_rotation: false, - }; - let skel = Skeleton { - bones: vec![ - bone(None), - bone(Some(benilla_formats::BillboardKind::Spherical)), - bone(Some(benilla_formats::BillboardKind::Spherical)), - ], - }; - let (built, _) = build_skeleton(&skel, &[1]); - assert!(built.joints[0].billboard.is_none(), "a plain bone is plain"); - assert!( - built.joints[1].billboard.is_none(), - "the welded spike is never camera-faced — facing it drags the mesh it is stitched to" - ); - assert_eq!( - built.joints[2].billboard, - Some(benilla_formats::BillboardKind::Spherical), - "a separable card bone on the same model still faces the camera" - ); - // The empty list is the overwhelmingly common case (no welded bones): nothing is dropped. - let (ungated, _) = build_skeleton(&skel, &[]); - assert!( - ungated.joints[1].billboard.is_some() && ungated.joints[2].billboard.is_some(), - "with nothing welded, every authored arm survives" - ); - } - - /// The same gate on the **real shipped asset** — the director's own pauldron. + /// **A welded billboard bone still reaches the palette with its arm** (decision 0945, + /// superseding 0935's gate) — on the real shipped asset, the director's own pauldron. /// /// `LShoulder_Plate_PVPAlliance_A_01.m2` authors 3 bones: a plain root and two spherical - /// billboard spikes whose 8-vertex 50/50 seam rings stitch them to the body. Measured live, the - /// arm displaced those spikes **18° standing on foot and 54° (44°–68°) mounted at a run**, while - /// the right pauldron — `RShoulder_…`, 1 bone, no billboard — sat at a flat 0°. That L/R split - /// is the whole of the director's report, and it comes from the artwork, not the skeleton (both - /// shoulder *bones* measured within 1.5° of each other). + /// billboard spikes whose 8-vertex 50/50 seam rings stitch them to the body. 0935 dropped + /// their arm on exactly that welding. `m2_animate` reads no vertex, weight or triangle data at + /// all — over its whole extent there is a single `movzx byte ptr`, the arm-selector map — so + /// no such gate exists in the reference (wow-re `billboard-bone-law.md` §9.4 SQ1). The seam + /// survives because the replacement is built about the bone's own pivot, not because the arm + /// is skipped. /// - /// The synthetic test above pins the wiring; this pins that the wiring fires on the file that - /// actually caused the bug. Skips when the client isn't installed (the repo ships no assets). + /// The predicate itself is still live and still correct where it belongs: the per-batch **card + /// split** refuses to lift a welded bone's geometry into its own draw (0839). This test pins + /// that the two lanes now disagree *on purpose* — welded for the card split, faced by the + /// palette. Skips when the client isn't installed (the repo ships no assets). #[test] - fn the_field_marshal_pauldron_reaches_the_palette_with_no_arm() { + fn a_welded_billboard_bone_still_reaches_the_palette_with_its_arm() { let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); @@ -930,12 +942,63 @@ mod tests { 2, "the M2 authors two billboard bones" ); - let welded = benilla_formats::non_separable_billboard_bones(&bytes); - assert_eq!(welded, vec![1, 2], "both of them are welded to the body"); - let (built, _) = build_skeleton(&raw, &welded); + assert_eq!( + benilla_formats::non_separable_billboard_bones(&bytes), + vec![1, 2], + "both are welded to the body — the card split still refuses to lift them" + ); + let (built, _) = build_skeleton(&raw); + assert_eq!( + built + .joints + .iter() + .filter(|j| j.billboard == Some(benilla_formats::BillboardKind::Spherical)) + .count(), + 2, + "…and the palette faces them anyway: welding is not a gate on the arm" + ); + } + + /// **The mount seat's parent arm survives the bake** — the load-bearing case, on the asset + /// that decides it. + /// + /// `RidingHorse.m2` attachment 0 (the rider seat) rides bone 30, `flags = 0x6` = ignore parent + /// rotation + scale. That is why a galloping horse never rocks its rider: the seat's basis is + /// replaced by the mount's own root basis before the rider's frame is built from it, so the + /// spine's ~21° stride swing is discarded at the saddle while the seat still *translates* with + /// it (wow-re §9.1/§9.2, byte-verified). Every vanilla player mount is `0x4`, `0x6`, or has a + /// seat whose ancestors don't rotate. + #[test] + fn the_riding_horse_seat_bone_carries_the_root_basis_arm() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let bytes = chain + .read_file("Creature\\RidingHorse\\RidingHorse.m2") + .expect("read the horse"); + let raw = benilla_formats::parse_m2_skeleton(&bytes).expect("parse its skeleton"); + let seat = benilla_formats::parse_m2_attachments(&bytes) + .expect("parse attachments") + .into_iter() + .find(|a| a.id == 0) + .expect("the horse authors a rider seat"); + assert_eq!(seat.bone, 30, "the seat rides bone 30"); + let (built, _) = build_skeleton(&raw); + assert_eq!( + built.joints[usize::from(seat.bone)].parent_arm, + Some(benilla_formats::ParentArm { + ignore_translate: false, + basis: benilla_formats::ParentBasis::RootBasis, + }), + "the seat takes the mount's own root basis — the gallop reaches it as translation only" + ); + // The spine bones it hangs off carry no arm: they are what supplies the discarded swing. assert!( - built.joints.iter().all(|j| j.billboard.is_none()), - "no palette lane may camera-face this model — it draws in its authored pose" + built.joints[23].parent_arm.is_none() && built.joints[25].parent_arm.is_none(), + "the swinging spine bones are ordinary" ); } } diff --git a/crates/benilla-assets/src/model/anims.rs b/crates/benilla-assets/src/model/anims.rs index b977cecd..a8e1c1ef 100644 --- a/crates/benilla-assets/src/model/anims.rs +++ b/crates/benilla-assets/src/model/anims.rs @@ -87,6 +87,13 @@ pub struct AnimClip { /// variation — `(0, 0)` ⇒ `R = 1` ⇒ a re-roll every single loop. That is the whole mechanism /// behind the Blasted Lands lightning wandering instead of strobing from one fixed spot. pub replay: (u32, u32), + /// Whether this clip actually **poses bones** — some bone track produced a curve. `false` for a + /// sequence whose bones all hold bind pose: it still exists as a clip (playing it is how the + /// instance's sequence clock names a slot and advances a time, which the emitter tracks, the + /// material loops and the GameObject arm all read), but skinning to it can only ever reproduce + /// the static mesh. So a rig is spawned for a *posing* clip, never for a clock-only one — + /// decision 0941's split between the clock (free) and the rig (a palette slot, a skinned twin). + pub poses_bones: bool, } /// A model's animations as a playable bundle (decision 0019): one shared `AnimationGraph` holding every @@ -217,6 +224,15 @@ impl ModelAnimations { /// `None` only for a model with no buildable clip at all (the `GlobalSeqOnly` tier), where there /// is no sequence to name. pub fn idle_seq(&self) -> Option { + self.idle_clip().map(|c| c.seq_index) + } + + /// The loader-idle seed's **clip** — [`Self::idle_seq`]'s selection, before it is reduced to a + /// slot number. What a spawn site plays to put an instance on that sequence: since every + /// sequence builds a clip (decision 0941), a model whose bones never move still has one to arm, + /// and arming it is what gives its emitters/material loops a running clock instead of a frozen + /// slot-0 read. Distinct from [`Self::first_seq`], which stays the *rendering* question. + pub fn idle_clip(&self) -> Option<&AnimClip> { let idle_id = self .playable_animation_lookup .first() @@ -225,7 +241,6 @@ impl ModelAnimations { .iter() .find(|c| c.anim_id == idle_id) .or_else(|| self.clips.first()) - .map(|c| c.seq_index) } /// Pick a **variation** of `anim_id` for one play, given a fresh `roll` (the client's `_rand()`, @@ -336,6 +351,7 @@ mod tests { upper_node: None, frequency: 0, replay: (0, 0), + poses_bones: true, } } diff --git a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs index 74c40e19..c1801c61 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs @@ -553,8 +553,26 @@ pub fn m2bones(chain: &mut Chain, internal_path: &str) -> Result<()> { .with_context(|| format!("parsing M2 '{name}'"))?; let m = fmt.model(); let seqs = benilla_formats::parse_m2_animations(&data); - println!("idx keybone flags bb parent pivot keyed (seq[idx] T/R/S counts)"); + // `ign` is `flags & 0x7` spelled out — which of the parent's Translate/Scale/Rotate this bone + // REFUSES, taking the model root's instead (decision 0945). The raw flags hex could always be + // read for it and never was: three billboard rounds and a mount round each looked at + // `RidingHorse` bone 30's `0x00000006` and none read it as "the saddle discards the gallop", + // which decision 0932 then measured as a 21° rider swing and called faithful. A bone-table + // dump exists to answer "what does this bone actually inherit"; now it says so in words. + println!( + "idx keybone flags bb ign parent pivot \ + keyed (seq[idx] T/R/S counts)" + ); for (i, b) in m.bones.iter().enumerate() { + let ignore: String = match b.flags.bits() & 0x7 { + 0 => "-".into(), + bits => ["T", "S", "R"] + .iter() + .enumerate() + .filter(|(k, _)| bits & (1 << k) != 0) + .map(|(_, n)| *n) + .collect(), + }; let keyed: Vec = seqs .iter() .enumerate() @@ -569,7 +587,7 @@ pub fn m2bones(chain: &mut Chain, internal_path: &str) -> Result<()> { }) .collect(); println!( - "{i:>3} {:>7} {:#010x} {:>2} {:>6} ({:>7.3}, {:>7.3}, {:>7.3}) {}", + "{i:>3} {:>7} {:#010x} {:>2} {ignore:>3} {:>6} ({:>7.3}, {:>7.3}, {:>7.3}) {}", b.key_bone, b.flags.bits(), yn(b.is_billboard()), @@ -1197,3 +1215,258 @@ pub fn m2alpha(chain: &mut Chain, internal_path: &str) -> Result<()> { } Ok(()) } + +/// Decode a particle emitter's file-flag word into the mechanism names the runtime keys off +/// (each is a [`benilla_formats::ParticleEmitterDef`] predicate), plus any bit the loader maps +/// to nothing — an unmapped bit on a model that looks wrong is a lead, not noise. +fn part_flags(flags: u32) -> String { + const NAMED: [(u32, &str); 11] = [ + (0x0010, "modelSpace"), + (0x0020, "sizeByInstanceScale"), + (0x0040, "inheritEmitterMotion"), + (0x0080, "killOutbound(sphere)"), + (0x0100, "sphereUp(sphere)"), + (0x0200, "tumbleRandomSign"), + (0x0400, "tailClampsToAge"), + (0x1000, "xyQuad"), + (0x2000, "groundSnap"), + (0x4000, "followEmitterDelta"), + (0x8000, "burst"), + ]; + let mut out: Vec = NAMED + .iter() + .filter(|(bit, _)| flags & bit != 0) + .map(|(_, n)| (*n).to_string()) + .collect(); + let residue = flags & !NAMED.iter().fold(0, |a, (bit, _)| a | bit); + if residue != 0 { + out.push(format!("unmapped 0x{residue:x}")); + } + if out.is_empty() { + "none".into() + } else { + out.join(" ") + } +} + +/// One baked track's keys as `t=v` pairs (seconds from the slot's band start). +fn keys_str(keys: &[(f32, f32)]) -> String { + keys.iter() + .map(|&(t, v)| format!("{t:.3}={v:.3}")) + .collect::>() + .join(" ") +} + +/// Dump one M2's particle emitters in full — see the `M2part` command doc. +pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> { + let name = normalize(internal_path); + let data = chain + .read_file(&name) + .with_context(|| format!("reading '{name}' from chain"))?; + let emitters = benilla_formats::parse_m2_particle_emitters(&data) + .map_err(|e| anyhow::anyhow!("parsing particle emitters of '{name}': {e}"))?; + if emitters.is_empty() { + println!("{name}: no particle emitters"); + return Ok(()); + } + println!("{name} — {} particle emitter(s)", emitters.len()); + + let d = benilla_formats::ParamsNow::default(); + let defaults = [ + d.emission_speed, + d.speed_variation, + d.vertical_range, + d.horizontal_range, + d.gravity, + d.lifespan, + d.area_length, + d.area_width, + d.z_source, + ]; + + for (i, e) in emitters.iter().enumerate() { + let [px, py, pz] = e.position; + println!( + "\n#{i:<3} bone {:<4} pos ({px:.3}, {py:.3}, {pz:.3}) {:?} · {:?} · {}", + e.bone, + e.shape, + e.blend, + match e.head_tail { + 0 => "head", + 1 => "tail", + _ => "head+tail", + } + ); + println!( + " texture {} atlas {}x{} flags 0x{:04x} [{}]", + e.texture.as_deref().unwrap_or("(none)"), + e.tile_cols, + e.tile_rows, + e.flags, + part_flags(e.flags) + ); + if let Some(g) = &e.geometry_model { + println!(" geometry model (3-D particles): {g}"); + } + if let Some(r) = &e.recursion_model { + println!(" recursion model (child emitters): {r}"); + } + if let Some(s) = &e.spline { + let pt = |p: &[f32; 3]| format!("({:.2},{:.2},{:.2})", p[0], p[1], p[2]); + println!( + " spline: {} control points, {} .. {}", + s.points.len(), + s.points.first().map(pt).unwrap_or_default(), + s.points.last().map(pt).unwrap_or_default(), + ); + } + + // Emission timing: the rate/gate pair, per file sequence slot. + match e.timing.constant_rate() { + Some(r) => println!(" rate {r:.2}/s (same in every sequence)"), + None => { + println!( + " rate ANIMATED (peak {:.2}/s), per slot:", + e.timing.peak_rate() + ); + for (slot, (looping, rate, _)) in e.timing.slot_views().iter().enumerate() { + if let Some(keys) = rate { + println!( + " slot {slot} {} {}", + if *looping { "loop" } else { "once" }, + keys_str(keys) + ); + } + } + } + } + let gates: Vec = e + .timing + .slot_views() + .iter() + .enumerate() + .filter_map(|(slot, (_, _, gate))| { + gate.map(|keys| format!("slot {slot}: {}", keys_str(keys))) + }) + .collect(); + println!( + " gate {}", + if gates.is_empty() { + "always on (keyless)".to_string() + } else { + gates.join(" · ") + } + ); + + // The nine emission parameter tracks: the flat ones on one line, the moving ones spelled + // out per slot (decision 0844 — a flattened track is exactly how a spread-out effect + // collapses to a point). + let views = e.params.channel_views(); + let mut flat: Vec = Vec::new(); + let mut animated: Vec = Vec::new(); + for (ch, (label, slots)) in views.iter().enumerate() { + let vals: Vec = slots + .iter() + .flatten() + .flat_map(|k| k.iter().map(|&(_, v)| v)) + .collect(); + let (lo, hi) = vals + .iter() + .fold((f32::MAX, f32::MIN), |(lo, hi), &v| (lo.min(v), hi.max(v))); + if vals.is_empty() { + flat.push(format!("{label} {:.3}*", defaults[ch])); + } else if (hi - lo).abs() < 1e-6 { + flat.push(format!("{label} {lo:.3}")); + } else { + flat.push(format!("{label} {lo:.3}..{hi:.3} ANIM")); + for (slot, keys) in slots.iter().enumerate() { + if let Some(keys) = keys { + animated.push(format!(" {label} slot {slot}: {}", keys_str(keys))); + } + } + } + } + println!( + " params: {} (* = keyless, loader default)", + flat.join(" ") + ); + for line in &animated { + println!("{line}"); + } + + println!( + " drag {:.3} spin {:.3} tailTime {:.3} inheritScale {:.3}", + e.drag, e.spin, e.tail_time, e.inherit_scale + ); + println!( + " twinkle speed {:.3} pct {:.3} range {:.3}..{:.3} [{}]", + e.twinkle_speed, + e.twinkle_percent, + e.twinkle_min, + e.twinkle_max, + if (e.twinkle_max - e.twinkle_min).abs() < 1e-6 { + "degenerate — steady at ramp size" + } else { + "active" + } + ); + + let ol = &e.over_life; + println!(" over-life (mid {:.3}):", ol.mid); + for (k, c) in ol.color.iter().enumerate() { + println!( + " colour k{k} r {:.3} g {:.3} b {:.3} a {:.3}", + c[0], c[1], c[2], c[3] + ); + } + println!( + " size {:.3} -> {:.3} -> {:.3} yd (half-extent)", + ol.scale[0], ol.scale[1], ol.scale[2] + ); + println!( + " cells head A {}->{} B {}->{} · tail A {}->{} B {}->{} · repeat {:.2}/{:.2}", + ol.head_cells[0].begin, + ol.head_cells[0].end, + ol.head_cells[1].begin, + ol.head_cells[1].end, + ol.tail_cells[0].begin, + ol.tail_cells[0].end, + ol.tail_cells[1].begin, + ol.tail_cells[1].end, + ol.repeat[0], + ol.repeat[1] + ); + + // The derived read: what this record actually puts on screen. + let rate = e.timing.peak_rate(); + let life = e.params.peak_lifespan(); + let speed = views[0] + .1 + .iter() + .flatten() + .flat_map(|k| k.iter().map(|&(_, v)| v.abs())) + .fold(0.0f32, f32::max); + // Drag caps total travel near `speed/drag` (the integrator's exponential decay); with no + // drag a particle simply coasts for its lifetime. + let reach = if e.drag > 1e-6 { + (speed / e.drag).min(speed * life) + } else { + speed * life + }; + let size_lo = ol.scale.iter().copied().fold(f32::MAX, f32::min); + let size_hi = ol.scale.iter().copied().fold(f32::MIN, f32::max); + println!( + " derived: {} · reach ~{reach:.2} yd · size {size_lo:.3}..{size_hi:.3} yd · peak alpha {:.2}", + if e.burst() { + format!("burst of ~{rate:.0} particles, life {life:.2}s") + } else { + format!( + "steady ~{:.0} live (rate {rate:.1}/s × life {life:.2}s)", + rate * life + ) + }, + ol.color.iter().map(|c| c[3]).fold(0.0f32, f32::max) + ); + } + Ok(()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs index 518c1c6b..6b34ee4a 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/main.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -110,9 +110,12 @@ enum Command { /// Internal path to the `.m2` (forward or back slashes accepted). internal_path: String, }, - /// Dump an M2's bone table: KeyBoneID, flags (billboard bits tagged), parent, pivot, and which - /// sequences key each bone (T/R/S key counts) — the bone-attach / billboard-geometry instrument - /// (decisions 0028/0122: which bone a rider actually rides, and whether it faces the camera). + /// Dump an M2's bone table: KeyBoneID, flags (billboard bits tagged), the `flags & 0x7` + /// parent-ignore bits spelled out (`ign` — which of the parent's Translate/Scale/Rotate the + /// bone refuses), parent, pivot, and which sequences key each bone (T/R/S key counts) — the + /// bone-attach / billboard-geometry instrument (decisions 0028/0122: which bone a rider + /// actually rides and whether it faces the camera; 0945: what it actually inherits from its + /// parent, which is how a saddle travels with the gallop without rotating with it). M2bones { /// Internal path to the `.m2` (forward or back slashes accepted). internal_path: String, @@ -135,6 +138,18 @@ enum Command { /// Internal path to the `.m2` (forward or back slashes accepted). internal_path: String, }, + /// Dump ONE M2's particle emitters in full — every authored field of every record, as the + /// runtime parses it: bone/position/shape/blend/head-tail, texture + atlas, the decoded file + /// flags, the rate/gate timing and the nine emission tracks (flagged `ANIM` with their keys + /// when they actually move), drag/spin/tail/twinkle, spline and geometry/recursion models, + /// and the over-life colour/size/flipbook ramps. Closes with a derived read per emitter — + /// steady-state live count, ballistic reach, size range — so "this flame is a bright blob, + /// not a spread-out fire" is answered from the asset before anything is blamed on the + /// renderer. The per-model counterpart to the corpus sweeps (`partcensus`, `partscan`). + M2part { + /// Internal path to the `.m2` (forward or back slashes accepted). + internal_path: String, + }, /// Sweep every `.m2` and report where **addressing an attachment by id** through the model's /// AttachLookup (the reference's `0x710310`) disagrees with scanning its record table — the /// duplicate-id census behind decision 0805 (item glows hang on ids 0..4 of the item model). @@ -264,6 +279,18 @@ enum Command { /// Internal-path prefix filter (e.g. `world`), case-insensitive; all models if omitted. prefix: Option, }, + /// Sweep every `.m2` (optionally under a path prefix) and list the models whose animation is + /// authored **entirely outside the bone tracks**: sequences exist, per-sequence consumers exist + /// (particle emitters, ribbons, material alpha/colour, UV transforms), and not one sequence + /// keys a bone. A sequence clock that rides a bone-animation clip has nothing to ride on such a + /// model — no clip, no player, no "which sequence, how far in" — so every per-sequence consumer + /// degrades to file slot 0 at t = 0, for ever. The population instrument for that silent class + /// (decision 0941; found on the Molten Core rune + flame ring). `[GO]` marks a + /// `GameObjectDisplayInfo` model — the hosted-clock lane, where the freeze is total. + Seqclockscan { + /// Internal-path prefix filter (e.g. `world`), case-insensitive; all models if omitted. + prefix: Option, + }, /// Sweep every `.m2` (optionally under a path prefix) and list batches whose texture is authored /// **CLAMP** (`M2Texture.flags` bit 0/1 clear) while the batch's UVs run **outside `0..1`** — the /// population a repeat-sampling renderer draws wrong. That margin is deliberate: clamped it @@ -591,6 +618,7 @@ fn main() -> Result<()> { Command::M2bones { internal_path } => m2dump::m2bones(&mut chain, &internal_path)?, Command::M2batch { internal_path } => m2dump::m2batch(&mut chain, &internal_path)?, Command::M2alpha { internal_path } => m2dump::m2alpha(&mut chain, &internal_path)?, + Command::M2part { internal_path } => m2dump::m2part(&mut chain, &internal_path)?, Command::Attachscan { prefix } => scan::attachscan(&mut chain, prefix.as_deref())?, Command::Ribbonscan => scan::ribbonscan(&mut chain)?, Command::Cellscan => scan::cellscan(&mut chain)?, @@ -605,6 +633,7 @@ fn main() -> Result<()> { Command::Bonescan { prefix } => scan::bonescan(&mut chain, prefix.as_deref())?, Command::Partcensus { prefix } => scan::partcensus(&mut chain, prefix.as_deref())?, Command::Partslotscan { prefix } => scan::partslotscan(&mut chain, prefix.as_deref())?, + Command::Seqclockscan { prefix } => scan::seqclockscan(&mut chain, prefix.as_deref())?, Command::Uvwrapscan { prefix } => scan::uvwrapscan(&mut chain, prefix.as_deref())?, Command::Texmodescan { prefix } => scan::texmodescan(&mut chain, prefix.as_deref())?, Command::Fxordercensus { prefix } => scan::fxordercensus(&mut chain, prefix.as_deref())?, diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan.rs b/crates/benilla-formats/src/bin/benilla-extract/scan.rs index 88357891..76ace6ce 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan.rs @@ -3524,3 +3524,119 @@ pub fn idleslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { ); Ok(()) } + +/// Sweep every `.m2` (optionally under a path prefix) and list the models whose animation is +/// authored **entirely outside the bone tracks** — sequences exist, per-sequence consumers exist +/// (particle emitters, ribbons, material alpha/colour, UV transforms), and **not one sequence +/// keys a bone**. +/// +/// That combination is the blind spot of a renderer whose sequence clock rides a *bone* animation +/// clip: with no keyed bone there is no clip, so there is no player, so nothing ever says which +/// sequence is playing or how far into it — and every per-sequence consumer degrades to "file slot +/// 0, at t = 0", permanently. The emitters still build, pool and tick; they just read the wrong +/// column of a table that never advances. Found on the Molten Core rune + flame ring (decision +/// 0941): the rune's slot 0 is its *Closed* band, where every emission rate key is 0 (no flames at +/// all), and the ring's spline spawn-window opens `0 → 1` over its first second, so frozen at t=0 +/// all 180 particles are born at one point of a 2.8-yd circle — one bright blob. +/// +/// The `[GO]` mark is the cross-tab that matters: a `GameObjectDisplayInfo` model reaches the +/// world through the **hosted** clock (`EmitClock::Host`), which is the lane that freezes at +/// `t = 0`. A placed doodad of the same shape still runs its spawn-age clock (only its *slot* is +/// pinned — decision 0760's axis), so it is wrong in a different, milder way. +/// +/// PARTIAL models — some sequences key bones, some don't — are tallied, not listed: there the +/// clock exists, but the unkeyed sequences are unreachable (no clip to arm), so a GameObject +/// substate or a creature animation id that resolves to one plays nothing. +pub fn seqclockscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + // Which models a GameObject can display — the hosted-clock lane. + let go_models: HashSet = benilla_formats::load_gameobject_catalog(chain) + .map(|c| c.iter().map(|(_, p)| model_key(p)).collect()) + .unwrap_or_default(); + let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); + let names: Vec = chain + .list() + .context("listing chain contents")? + .into_iter() + .map(|e| e.name) + .filter(|n| { + let l = n.to_ascii_lowercase(); + l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) + }) + .collect(); + let (mut scanned, mut with_seqs, mut frozen, mut frozen_go, mut partial, mut inert) = + (0u32, 0u32, 0u32, 0u32, 0u32, 0u32); + let mut frozen_emitters = 0usize; + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let seqs = benilla_formats::parse_m2_animations(&bytes); + if seqs.is_empty() { + continue; + } + with_seqs += 1; + // Exactly the runtime's own test (`build_animation_clip`): a sequence becomes a clip iff + // some bone track produced a key inside its band. + let keyed = seqs + .iter() + .filter(|a| { + a.bones.iter().any(|b| { + !b.translation.is_empty() || !b.rotation.is_empty() || !b.scale.is_empty() + }) + }) + .count(); + // Counted over EVERY model with sequences, consumers or not: it measures clip + // REACHABILITY (can an arm find this sequence at all), which is a property of the model. + if keyed > 0 && keyed < seqs.len() { + partial += 1; + } + let Ok(s) = benilla_formats::parse_m2_animation_summary(&bytes) else { + continue; + }; + // The per-sequence consumers: everything that samples a track on the playing sequence's + // clock. A constant (≤1 key) colour/alpha track is excluded — it reads the same in every + // slot, so a frozen clock costs it nothing. + let consumers = s.particle_emitter_count + + s.ribbon_emitter_count + + s.color_alpha_tracks.1 + + s.color_rgb_tracks.1 + + s.transparency_tracks.1 + + s.texture_transform_count; + if keyed == 0 && consumers == 0 { + // The same shape with nothing to sample: a clock costs it nothing and buys it + // nothing. Counted because it IS the cost side of giving every sequenced model a + // clock — these are the models that gain an inert one. + inert += 1; + } + if consumers > 0 && keyed == 0 { + frozen += 1; + frozen_emitters += s.particle_emitter_count; + let is_go = go_models.contains(&name.to_ascii_lowercase()); + if is_go { + frozen_go += 1; + } + println!( + "{}{name}\n seqs {:>3} (0 keyed) · emitters {:>2} ribbons {} · alpha {} rgb {} \ + transp {} · uvanim {} · gseq {}", + if is_go { "[GO] " } else { " " }, + seqs.len(), + s.particle_emitter_count, + s.ribbon_emitter_count, + s.color_alpha_tracks.1, + s.color_rgb_tracks.1, + s.transparency_tracks.1, + s.texture_transform_count, + s.global_seq_channels.len(), + ); + } + } + eprintln!( + "{scanned} models scanned, {with_seqs} with sequences: {frozen} CLOCKLESS \ + ({frozen_emitters} emitters) — sequences + per-sequence consumers, not one keyed bone; \ + {frozen_go} of them GameObject display models (the hosted lane, frozen at slot 0 t=0). \ + {partial} PARTIAL models have unkeyed sequences a clip lookup can never reach; \ + {inert} more are boneless with nothing to sample (a clock there is inert)." + ); + Ok(()) +} diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index cce93e51..51665038 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -164,10 +164,10 @@ pub use models::{ 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, OWNER_RUNG_BUCKETS, + M2Bounds, M2Light, M2PortraitCamera, ModelAnimation, ModelBlend, ParentArm, ParentBasis, + PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim, Skeleton, SkeletonBone, StringAnchors, + UvAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog, WmoGroupHeader, WmoGroupInfo, WmoLight, + WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot, OWNER_RUNG_BUCKETS, }; mod terrain; pub use terrain::{ diff --git a/crates/benilla-formats/src/models/anim.rs b/crates/benilla-formats/src/models/anim.rs index f9d2e8ae..01eaeb71 100644 --- a/crates/benilla-formats/src/models/anim.rs +++ b/crates/benilla-formats/src/models/anim.rs @@ -23,12 +23,14 @@ pub struct SkeletonBone { /// The bone's billboard arm (flags `0x08/0x10/0x20/0x40`), `None` for an ordinary bone — the /// palette-level camera facing a rigged host applies per frame (children inherit it). pub billboard: Option, - /// Bone flag `0x04` — the bone keeps the MODEL ROOT's orientation instead of inheriting its - /// parent's rotation (its pivot still rides the parent's full matrix). Carried by the unskinned - /// attach-helper bones (HandArrow/Bullet 126/127 — the nocked arrow lies flat along the facing - /// instead of twisting with the draw hand). The client's bone-palette build honors the flag; - /// the attach-child transform then inherits it (wow-re `nocked-ammo-cancel.md` §E4). - pub ignore_parent_rotation: bool, + /// Bone flags `0x1/0x2/0x4` — how this bone's effective **parent matrix** is rebuilt from the + /// model's own root matrix before it composes (`None` for the ordinary bone). See + /// [`crate::ParentArm`]: the byte-verified `ignore parent translate / scale / rotate` trio. + /// Carried by 862 bones across 456 shipped models — the unskinned attach helpers + /// (HandArrow/Bullet: the nocked arrow lies flat along the facing instead of twisting with the + /// draw hand, wow-re `nocked-ammo-cancel.md` §E4) and, load-bearingly, **every vanilla player + /// mount's rider seat**, which is why a galloping horse's spine never rocks its rider. + pub parent_arm: Option, } /// A model's bone hierarchy — the rest skeleton the skinned-entity path turns into a joint-entity @@ -86,7 +88,7 @@ pub fn parse_m2_skeleton(bytes: &[u8]) -> Result { pivot: [b.pivot.x, b.pivot.y, b.pivot.z], key_bone: b.key_bone, billboard: crate::BillboardKind::from_bone_flags(b.flags.bits()), - ignore_parent_rotation: b.flags.bits() & 0x4 != 0, + parent_arm: crate::ParentArm::from_bone_flags(b.flags.bits()), }) .collect(); Ok(Skeleton { bones }) diff --git a/crates/benilla-formats/src/models/types.rs b/crates/benilla-formats/src/models/types.rs index 8dde96a9..96010032 100644 --- a/crates/benilla-formats/src/models/types.rs +++ b/crates/benilla-formats/src/models/types.rs @@ -86,6 +86,63 @@ impl BillboardKind { } } +/// How a bone's **effective parent matrix** is rewritten before it composes — M2 bone flag bits +/// `0x1/0x2/0x4`, the standard `ignore parent translate / scale / rotate` trio. +/// +/// The reference tests `flags & 7` on every non-root bone (`m2_animate` `0x714961`) and, when any +/// of the three is set, takes a ~950-byte arm (`0x71496d`–`0x714d0c`) that rebuilds that bone's +/// parent matrix out of the **model's own root matrix** — pivot-preserved — *and then falls into +/// the billboard selector unchanged*. It is not an escape hatch from anything: it changes the +/// INPUT the billboard law is applied to (wow-re `billboard-bone-law.md` §9.1, byte-verified). +/// +/// The three legs are proven from the binary, matching the standard names: `0x1` at `0x714c92`, +/// `0x2` at `0x714bdb`, `0x4` at `0x714a6e` alone / `0x714a18` combined with `0x2`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ParentArm { + /// `0x1` — the bone's frame is placed at the model root's origin instead of at the pivot its + /// animated parent carried it to. + pub ignore_translate: bool, + /// What `flags & 0x6` does to the parent's basis. + pub basis: ParentBasis, +} + +/// The `flags & 0x6` leg of [`ParentArm`] — what happens to the parent matrix's three basis +/// vectors. (The reference works in row-major/row-vector form, so its "row K" is our column K: +/// both name the image of model basis vector K, which is what every leg operates on.) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ParentBasis { + /// `flags & 6 == 0` (so `flags & 7 == 1` alone): the parent's basis is kept as-is. + Keep, + /// `flags & 6 == 2` — ignore parent scale: each basis vector is normalized, direction kept. + UnitNormalize, + /// `flags & 6 == 4` — ignore parent rotation: the ROOT's direction with the parent's + /// magnitude (`0x714a6e`'s per-axis `|P_k| / |R_k|` ratio). + RootDirection, + /// `flags & 6 == 6` — ignore parent rotation AND scale: the root's basis outright + /// (`0x714a18`). Every vanilla player mount's rider seat is this or [`Self::RootDirection`], + /// which is why the saddle translates with the gallop and never rotates. + RootBasis, +} + +impl ParentArm { + /// The arm an M2 bone's flag word authors, `None` when `flags & 7 == 0` (the ordinary bone, + /// which goes straight to the billboard selector — `je 0x714d0f`). + pub fn from_bone_flags(bits: u32) -> Option { + if bits & 0x7 == 0 { + return None; + } + Some(Self { + ignore_translate: bits & 0x1 != 0, + basis: match bits & 0x6 { + 0x2 => ParentBasis::UnitNormalize, + 0x4 => ParentBasis::RootDirection, + 0x6 => ParentBasis::RootBasis, + _ => ParentBasis::Keep, + }, + }) + } +} + /// A bone **scale track driven by a global sequence** — the looping "breathe" pulse a glow card rides. /// VERIFIED mechanism: the Lamppost glow card sits on a spherical-billboard bone whose scale track /// (`interp=1`, `gseq=0`) oscillates `0.86 … 1.04` over the model's 1333 ms global sequence, so the diff --git a/crates/benilla-formats/src/spells/catalog_tests.rs b/crates/benilla-formats/src/spells/catalog_tests.rs index 3ba30743..82175e3c 100644 --- a/crates/benilla-formats/src/spells/catalog_tests.rs +++ b/crates/benilla-formats/src/spells/catalog_tests.rs @@ -969,3 +969,47 @@ fn real_item_target_family_and_its_gate_columns() { ); } } + +/// The 0948 §5's flagged data questions, pinned on the real 5875 data (`gcd-power-gate.md` +/// §2.1): the SpellCategory flags-bit-0x2 wildcard set is EXACTLY {351} — wand Shoot's category +/// (the whole-bar swing sweep the store's wildcard leg implements) — and the `{cat=0, time≠0}` +/// GCD-source shape (which would arm a category-0 GCD node matching every category-0 press) has +/// NO player-castable carrier: every such row is an NPC/internal spell. A data change here means +/// the store's predicates need re-judging, loudly. +#[test] +fn gcd_wildcard_and_shape_corners_hold_on_the_real_data() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_spell_catalog(&mut chain).expect("catalog"); + + // The wildcard set: exactly wand Shoot's 351, resolved onto the display at load. + let shoot = cat.get(5019).expect("wand Shoot"); + assert_eq!(shoot.category, 351); + assert!( + shoot.category_wildcard, + "Shoot's category row carries flags&2" + ); + for &(id, name) in &[ + (133u32, "Fireball"), + (100, "Charge"), + (6673, "Battle Shout"), + ] { + let d = cat.get(id).expect(name); + assert!( + !d.category_wildcard, + "{name}'s category must not read wildcard" + ); + } + + // Scroll of Armor's spell: the {cat≠0, time=0} shape the corrected refusal predicate now + // locks during a GCD (the pressed spell's own time is never consulted). + let scroll = cat.get(8091).expect("Scroll of Armor's spell"); + assert_eq!( + (scroll.start_recovery_category, scroll.start_recovery_ms), + (133, 0) + ); +} diff --git a/crates/benilla-formats/src/spells/display.rs b/crates/benilla-formats/src/spells/display.rs index 2b8a3d13..f078ce6f 100644 --- a/crates/benilla-formats/src/spells/display.rs +++ b/crates/benilla-formats/src/spells/display.rs @@ -74,6 +74,11 @@ pub struct SpellDisplay { pub dispel: u32, /// `Category` (column 2, [`COL_CATEGORY`]) — the shared-cooldown category; `0` = none. pub category: u32, + /// Whether [`Self::category`]'s `SpellCategory.dbc` row carries the flags-bit-`0x2` + /// "matches every query" wildcard (`GetCooldownInfo 0x6e13e0`'s category leg, + /// `gcd-power-gate.md` §2) — resolved at catalog load. Only wand Shoot's category 351 in + /// the 5875 data: its running swing cooldown sweeps EVERY button. + pub category_wildcard: bool, /// `RecoveryTime` ms (column 19) — the spell's own cooldown. pub recovery_ms: u32, /// `InterruptFlags` ([`COL_INTERRUPT_FLAGS`]) — what breaks this spell's cast. Ordinary @@ -247,6 +252,7 @@ impl Default for SpellDisplay { spell_level: 0, dispel: 0, category: 0, + category_wildcard: false, recovery_ms: 0, interrupt_flags: 0, aura_interrupt_flags: 0, @@ -436,6 +442,17 @@ impl SpellDisplay { ) } + /// The cooldown getter's HEAD exclusion (`GetCooldownInfo 0x6e13e0` @ `6e1439`/`6e1442`, + /// wow-re `gcd-power-gate.md` §2, §5-verified): `Effect[0] ∈ {0x4e ATTACK, 0x2f TRADE_SKILL}` + /// returns "no cooldown" unconditionally — the reason the Attack and profession buttons never + /// show a pie AND their presses can never be cooldown-refused. + pub fn cooldown_query_excluded(&self) -> bool { + matches!( + self.effect_1, + SPELL_EFFECT_TRADE_SKILL | SPELL_EFFECT_ATTACK + ) + } + // The equipped-item requirement's NAME used to be decided here, by a // `single_equipped_subclass` that answered only for a one-bit mask — a placeholder standing in // for the uncarved `0x6e2380`. `0x6e2380` is carved now, and it reads only ItemSubClass.dbc, diff --git a/crates/benilla-formats/src/spells/mod.rs b/crates/benilla-formats/src/spells/mod.rs index 89a9ae01..23d2bade 100644 --- a/crates/benilla-formats/src/spells/mod.rs +++ b/crates/benilla-formats/src/spells/mod.rs @@ -553,6 +553,30 @@ fn spell_schema() -> Schema { pub fn load_spell_catalog(chain: &mut Chain) -> Result { let icons = crate::dbc::load_spell_icon_map(chain)?; let dispel_types = load_spell_dispel_types(chain)?; + // The SpellCategory "matches every query" wildcard set (`GetCooldownInfo 0x6e13e0`'s category + // leg, `6e1563`/`6e1567`: a category row whose Flags carry bit `0x2` contributes to ANY + // queried spell — wow-re `gcd-power-gate.md` §2). In the 5875 data exactly one row carries + // it: category 351, wand Shoot's — the whole-bar wand-swing sweep (pinned in catalog_tests). + let wildcard_categories: std::collections::HashSet = { + let bytes = chain + .read_file("DBFilesClient\\SpellCategory.dbc") + .context("reading SpellCategory.dbc")?; + let mut s = benilla_dbc::Schema::new("SpellCategory"); + s.add_field(benilla_dbc::SchemaField::new( + "ID", + benilla_dbc::FieldType::UInt32, + )); + s.add_field(benilla_dbc::SchemaField::new( + "Flags", + benilla_dbc::FieldType::UInt32, + )); + let rs = parse(&bytes, s, "SpellCategory.dbc")?; + rs.records() + .iter() + .filter(|r| u32_at(r, 1).unwrap_or(0) & 0x2 != 0) + .filter_map(|r| u32_at(r, 0)) + .collect() + }; let spell_bytes = chain.read_file(SPELL).context("reading Spell.dbc")?; let spells_set = parse(&spell_bytes, spell_schema(), "Spell.dbc")?; @@ -613,6 +637,11 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result { }), dispel: u32_at(r, COL_DISPEL).unwrap_or(0), category: u32_at(r, COL_CATEGORY).unwrap_or(0), + // The category row's flags-bit-0x2 "matches every query" mark (only wand Shoot's + // 351 in the 5875 data) — resolved at load so the cooldown store's category leg + // reads it off the record it armed (`gcd-power-gate.md` §2). + category_wildcard: u32_at(r, COL_CATEGORY) + .is_some_and(|c| wildcard_categories.contains(&c)), recovery_ms: u32_at(r, COL_RECOVERY_TIME).unwrap_or(0), interrupt_flags: u32_at(r, COL_INTERRUPT_FLAGS).unwrap_or(0), aura_interrupt_flags: u32_at(r, COL_AURA_INTERRUPT_FLAGS).unwrap_or(0), diff --git a/crates/benilla/src/billboard.rs b/crates/benilla/src/billboard.rs index 5201011a..26dfdd4b 100644 --- a/crates/benilla/src/billboard.rs +++ b/crates/benilla/src/billboard.rs @@ -23,6 +23,7 @@ use benilla_assets::BillboardInfo; use benilla_formats::{BillboardKind, BoneScaleAnim}; +use bevy::math::{Affine3A, Mat3A, Vec3A}; use bevy::mesh::MeshTag; use bevy::prelude::*; @@ -196,6 +197,75 @@ impl BillboardCard { } } +/// A bone's rewritten **effective parent matrix** — the `flags & 0x7` arm the reference takes at +/// `m2_animate` `0x71496d`–`0x714d0c`, before the billboard selector and before the bone's own TRS +/// composes onto it (wow-re `billboard-bone-law.md` §9.1/§9.5, byte-verified). This is not an +/// escape hatch from the billboard: it changes the input the billboard law is applied to, and the +/// `&0x78` switch runs afterwards exactly as before. +/// +/// - `parent` — the bone's ANIMATED parent world matrix (`palette[parent_bone]` before the rewrite). +/// - `root` — the model's own root frame, `world_from_model` (`[model+0xfc]`): for a rigged host +/// that is its `joints_root`, and for a mounted rider that is its seat anchor, which is exactly +/// what the reference composes at `0x714389`. +/// - `pivot` — the bone's BIND local translation (`pivot_i − pivot_parent`). Our joint chain is +/// pivot-relative, so this plays the role of the byte law's model-space `piv` in the +/// pivot-preserving tail `T' = pivotWorld − piv·newBasis`: the bone keeps the POSITION its +/// animated parent carried it to and loses only the orientation. That is the whole reason a +/// galloping mount still carries its rider up and down while never rocking them. +/// +/// The reference is row-major with row vectors, so its "row K" is our column K — both name the +/// image of model basis vector K, and the WoW→Bevy bake is a signed permutation of those axes, so +/// the per-axis legs below pair the same axes the bytes do. +pub(crate) fn parent_arm_matrix( + arm: benilla_formats::ParentArm, + parent: Affine3A, + root: Affine3A, + pivot: Vec3, +) -> Affine3A { + use benilla_formats::ParentBasis; + // `0x714bdb`'s unit guard: a degenerate axis is left alone rather than exploding. + const UNIT_EPS: f32 = 1.0 / (1 << 22) as f32; + // `0x80c5c8`, the ratio leg's own constant — deliberately NOT the unit eps above. + const RATIO_EPS: f32 = 1e-5; + let (p, r) = (parent.matrix3, root.matrix3); + let per_axis = |f: &dyn Fn(usize) -> Vec3A| Mat3A::from_cols(f(0), f(1), f(2)); + let matrix3 = match arm.basis { + ParentBasis::Keep => p, + // `flags & 6 == 2` — ignore parent scale: unit-length axes, directions kept. + ParentBasis::UnitNormalize => per_axis(&|k| { + let len = p.col(k).length(); + if len > UNIT_EPS { + p.col(k) / len + } else { + p.col(k) + } + }), + // `flags & 6 == 4` — ignore parent rotation: the ROOT's direction at the PARENT's + // magnitude, per axis. + ParentBasis::RootDirection => per_axis(&|k| { + let rl2 = r.col(k).length_squared(); + let ratio = if rl2 <= RATIO_EPS { + 1.0 + } else { + p.col(k).length() / rl2.sqrt() + }; + r.col(k) * ratio + }), + // `flags & 6 == 6` — ignore parent rotation AND scale: the root basis outright. + ParentBasis::RootBasis => r, + }; + Affine3A { + matrix3, + // `flags & 1` (`0x714c92`) takes the root's own origin; otherwise the pivot-preserving + // tail at `0x714caf`. + translation: if arm.ignore_translate { + root.translation + } else { + parent.transform_point3a(pivot.into()) - matrix3 * Vec3A::from(pivot) + }, + } +} + /// The rebuilt orientation for a billboard of `kind` — the byte law (module doc), one function /// for both consumers: the CARD path (`kept_rot` = the placement/owner rotation) and the JOINT /// palette pass below (`kept_rot` = the joint's fully-composed pre-billboard world rotation, the @@ -257,16 +327,20 @@ pub(crate) fn billboard_basis( /// lock-Z bone, which is exactly why they rendered glued to the character. #[derive(Component)] pub struct BillboardJointRig { - /// The host root entity — the frame an `ignore_parent_rotation` joint's rotation snaps back - /// to (bone flag `0x04` keeps the MODEL's orientation, not the parent bone's). + /// The host root entity — the model's own root frame `[model+0xfc]`, which the `flags & 0x7` + /// arm rebuilds a bone's parent matrix out of. root: Entity, joints: Vec, parents: Vec, kinds: Vec>, - /// Bone flag `0x04` per joint (the HandArrow/Bullet attach helpers): pivot rides the parent's - /// full matrix, rotation resets to the model root's — the nocked arrow lies flat along the - /// facing instead of twisting with the draw hand (wow-re `nocked-ammo-cancel.md` §E4). - ignore_rot: Vec, + /// Bone flags `0x1/0x2/0x4` per joint ([`parent_arm_matrix`]) — the HandArrow/Bullet attach + /// helpers (the nocked arrow lies flat along the facing instead of twisting with the draw + /// hand, wow-re `nocked-ammo-cancel.md` §E4) and every vanilla mount's rider seat. + arms: Vec>, + /// Each bone's BIND local translation — the pivot the arm preserves. `locals` carry the + /// ANIMATED translation, which the byte law rotates by the *new* basis, so the two are not + /// interchangeable here. + binds: Vec, } impl BillboardJointRig { @@ -276,9 +350,9 @@ impl BillboardJointRig { self.root } - /// Build for a spawned rig — `None` when the skeleton authors no billboard bone and no - /// ignore-parent-rotation bone (the common case: ordinary rigs cost nothing). `root` is the - /// host entity the joints hang under (the model-space frame). + /// Build for a spawned rig — `None` when the skeleton authors neither a billboard bone nor a + /// `flags & 0x7` bone (the common case: ordinary rigs cost nothing). `root` is the host entity + /// the joints hang under (the model-space frame). pub fn new( skeleton: &benilla_assets::ModelSkeleton, joints: &[Entity], @@ -287,7 +361,7 @@ impl BillboardJointRig { if skeleton .joints .iter() - .all(|j| j.billboard.is_none() && !j.ignore_parent_rotation) + .all(|j| j.billboard.is_none() && j.parent_arm.is_none()) { return None; } @@ -296,10 +370,11 @@ impl BillboardJointRig { joints: joints.to_vec(), parents: skeleton.joints.iter().map(|j| j.parent).collect(), kinds: skeleton.joints.iter().map(|j| j.billboard).collect(), - ignore_rot: skeleton + arms: skeleton.joints.iter().map(|j| j.parent_arm).collect(), + binds: skeleton .joints .iter() - .map(|j| j.ignore_parent_rotation) + .map(|j| j.local_translation) .collect(), }) } @@ -349,40 +424,48 @@ pub(crate) fn billboard_joint_palette( .iter() .filter(|r| !parked.get(r.root).unwrap_or(false)) { - // The model-space frame an ignore-parent-rotation joint (bone flag 0x04) snaps back to: - // the host root's world rotation. Read before the joint loop (the root is never a joint). - let root_rot = joints + // The model's own root frame `[model+0xfc]` — what the `flags & 0x7` arm rebuilds a + // parent matrix out of. Read before the joint loop (the root is never a joint). + let root_affine = joints .get(rig.root) - .map(|(_, g)| g.rotation()) + .map(|(_, g)| g.affine()) .unwrap_or_default(); let n = rig.joints.len(); let mut replaced: Vec> = vec![None; n]; for i in 0..n { - let parent_new = usize::try_from(rig.parents[i]) - .ok() - .filter(|&p| p < i) - .and_then(|p| replaced[p]); - if parent_new.is_none() && rig.kinds[i].is_none() && !rig.ignore_rot[i] { + let pidx = usize::try_from(rig.parents[i]).ok().filter(|&p| p < i); + let parent_new = pidx.and_then(|p| replaced[p]); + let arm = rig.arms[i]; + if parent_new.is_none() && rig.kinds[i].is_none() && arm.is_none() { continue; // untouched subtree — the propagated pose stands } + // The arm needs the PARENT's matrix in hand; propagation only left us the child's, so + // an armed bone whose parent was untouched reads the parent's propagated frame here. + let parent_world = if arm.is_some() { + parent_new.or_else(|| { + let e = pidx.map_or(rig.root, |p| rig.joints[p]); + joints.get(e).ok().map(|(_, g)| *g) + }) + } else { + parent_new + }; let Ok((local, mut global)) = joints.get_mut(rig.joints[i]) else { continue; }; - let mut g = match parent_new { - Some(pg) => pg.mul_transform(*local), - None => *global, + let mut g = match (arm, parent_world) { + // `flags & 0x7`: rewrite the parent matrix, then compose this bone's own TRS onto + // it — and fall through to the billboard switch, which still runs (§9.1). + (Some(a), Some(pw)) => GlobalTransform::from(parent_arm_matrix( + a, + pw.affine(), + root_affine, + rig.binds[i], + )) + .mul_transform(*local), + (_, Some(pw)) => pw.mul_transform(*local), + (_, None) => *global, }; - if rig.ignore_rot[i] { - // Bone flag 0x04: keep the parent-composed pivot (the hand carries the point), - // reset the rotation to the model root's frame — children (the nocked arrow) - // inherit the flat model-space orientation, not the hand twist. - let (scale, _, translation) = g.to_scale_rotation_translation(); - g = GlobalTransform::from(Transform { - translation, - rotation: root_rot, - scale, - }); - } else if let Some(kind) = rig.kinds[i] { + if let Some(kind) = rig.kinds[i] { let (scale, rot, translation) = g.to_scale_rotation_translation(); g = GlobalTransform::from(Transform { translation, @@ -951,13 +1034,13 @@ mod tests { parent: -1, local_translation: Vec3::ZERO, billboard: Some(BillboardKind::LockZ), - ignore_parent_rotation: false, + parent_arm: None, }, benilla_assets::ModelJoint { parent: 0, local_translation: Vec3::Y, billboard: None, - ignore_parent_rotation: false, + parent_arm: None, }, ], spine_bone: None, @@ -1012,6 +1095,87 @@ mod tests { ); } + /// The four `flags & 0x6` legs of [`parent_arm_matrix`], each against its byte definition + /// (wow-re `billboard-bone-law.md` §9.5), plus the pivot-preserving tail that is the whole + /// reason a galloping mount carries its rider without rocking them. + /// + /// The parent here is rotated 90° about X and scaled non-uniformly; the root is a plain 90° + /// yaw at scale 3. Each leg is asserted on the quantity it is defined by — axis LENGTHS for + /// the scale legs, axis DIRECTIONS for the rotation legs — so a leg that accidentally did the + /// other one's job cannot pass. + #[test] + fn the_parent_arm_legs_match_their_byte_definitions() { + use benilla_formats::{ParentArm, ParentBasis}; + let pivot = Vec3::new(0.0, 2.0, 0.0); + let parent = Affine3A::from_scale_rotation_translation( + Vec3::new(1.0, 2.0, 4.0), + Quat::from_rotation_x(std::f32::consts::FRAC_PI_2), + Vec3::new(7.0, 0.0, 0.0), + ); + let root = Affine3A::from_scale_rotation_translation( + Vec3::splat(3.0), + Quat::from_rotation_y(std::f32::consts::FRAC_PI_2), + Vec3::new(0.0, 5.0, 0.0), + ); + let arm = |basis| ParentArm { + ignore_translate: false, + basis, + }; + let axis_len = |m: Affine3A, k: usize| m.matrix3.col(k).length(); + let axis_dir = |m: Affine3A, k: usize| Vec3::from(m.matrix3.col(k).normalize()); + + // `flags & 6 == 0` — the basis is the parent's, untouched. + let keep = parent_arm_matrix(arm(ParentBasis::Keep), parent, root, pivot); + assert!(keep.matrix3.abs_diff_eq(parent.matrix3, 1e-5)); + + // `flags & 6 == 2` — unit axes, parent's directions. + let unit = parent_arm_matrix(arm(ParentBasis::UnitNormalize), parent, root, pivot); + for k in 0..3 { + assert!((axis_len(unit, k) - 1.0).abs() < 1e-5, "axis {k} unit"); + assert!( + axis_dir(unit, k).dot(axis_dir(parent, k)) > 0.9999, + "axis {k} keeps the parent's direction" + ); + } + + // `flags & 6 == 4` — root's direction, parent's magnitude, per axis. + let ratio = parent_arm_matrix(arm(ParentBasis::RootDirection), parent, root, pivot); + for k in 0..3 { + assert!( + (axis_len(ratio, k) - axis_len(parent, k)).abs() < 1e-4, + "axis {k} keeps the parent's magnitude" + ); + assert!( + axis_dir(ratio, k).dot(axis_dir(root, k)) > 0.9999, + "axis {k} takes the root's direction" + ); + } + + // `flags & 6 == 6` — the root basis outright, scale included. + let full = parent_arm_matrix(arm(ParentBasis::RootBasis), parent, root, pivot); + assert!(full.matrix3.abs_diff_eq(root.matrix3, 1e-5)); + + // The pivot-preserving tail: whichever leg ran, composing the bone's own bind translation + // onto the rewritten matrix lands it exactly where the ANIMATED parent carried it. + let want = parent.transform_point3a(pivot.into()); + for m in [keep, unit, ratio, full] { + let landed = m.transform_point3a(pivot.into()); + assert!((landed - want).length() < 1e-4, "pivot preserved: {landed}"); + } + + // `flags & 1` instead places the bone at the model root's own origin. + let moved = parent_arm_matrix( + ParentArm { + ignore_translate: true, + basis: ParentBasis::RootBasis, + }, + parent, + root, + pivot, + ); + assert!((Vec3::from(moved.translation) - Vec3::new(0.0, 5.0, 0.0)).length() < 1e-5); + } + /// The ignore-parent-rotation joint (bone flag 0x04 — the HandArrow/Bullet attach helpers, /// wow-re `nocked-ammo-cancel.md` §E4): its pivot rides the parent's full matrix, its /// ROTATION resets to the host root's frame — and a rigid child (the nocked arrow) hanging @@ -1065,13 +1229,16 @@ mod tests { parent: -1, local_translation: Vec3::ZERO, billboard: None, - ignore_parent_rotation: false, + parent_arm: None, }, benilla_assets::ModelJoint { parent: 0, local_translation: Vec3::Y, billboard: None, - ignore_parent_rotation: true, + parent_arm: Some(benilla_formats::ParentArm { + ignore_translate: false, + basis: benilla_formats::ParentBasis::RootDirection, + }), }, ], spine_bone: None, @@ -1153,7 +1320,10 @@ mod tests { parent: -1, local_translation: Vec3::ZERO, billboard: None, - ignore_parent_rotation: true, + parent_arm: Some(benilla_formats::ParentArm { + ignore_translate: false, + basis: benilla_formats::ParentBasis::RootDirection, + }), }], spine_bone: None, head_bone: None, @@ -1177,7 +1347,7 @@ mod tests { parent: -1, local_translation: Vec3::ZERO, billboard: Some(BillboardKind::Spherical), - ignore_parent_rotation: false, + parent_arm: None, }], spine_bone: None, head_bone: None, diff --git a/crates/benilla/src/cooldowns.rs b/crates/benilla/src/cooldowns.rs index 47313cf2..de044901 100644 --- a/crates/benilla/src/cooldowns.rs +++ b/crates/benilla/src/cooldowns.rs @@ -63,6 +63,10 @@ struct Record { item_id: u32, recovery: Timer, category: u32, + /// Whether [`Self::category`]'s SpellCategory row carries the flags-bit-`0x2` wildcard — + /// the category leg then contributes to EVERY query (`0x6e13e0` @ `6e1563`; wand Shoot's + /// 351 is the only 5875 carrier). Resolved at catalog load, copied here at insert. + category_wildcard: bool, category_recovery: Timer, /// Parked until `SMSG_COOLDOWN_EVENT` (`SPELL_ATTR_COOLDOWN_ON_EVENT`): the recovery pairs /// hold their *durations* but their clocks haven't started. @@ -89,15 +93,26 @@ pub(crate) struct CooldownInfo { impl CooldownInfo { /// The pushable UI triple `(start_ms on the GetTime clock, duration_ms, enabled)`, or `None` - /// when cold. `now`/`ui_now` are the same frame's readings of the two clocks (`Instant` vs - /// the VM's `GetTime`), so the subtraction maps the start across without either side knowing - /// the other's epoch. Rounded to whole ms so the value is frame-stable: a re-push of the same - /// running cooldown re-derives the same number, while a RE-ARM always derives a new one — the - /// property the old `(remaining, duration)` shape lacked (two arms of the same cooldown read - /// byte-identical and the seam kept the first, elapsed, anchor: the vanished-GCD-pie bug). - pub(crate) fn ui_triple(&self, now: Instant, ui_now: f64) -> Option<(i64, u32, bool)> { + /// when cold. `anchor`/`ui_now` are the frame's ATOMIC clock pair + /// ([`crate::ui_script::UiClock`]) — the `Instant` whose deltas the VM clock accumulates, + /// beside the value it accumulated to — so the subtraction maps the start across without + /// either side knowing the other's epoch, and derives the SAME whole-ms number every frame + /// for one arm (both legs advance in lockstep by construction). A RE-ARM always derives a + /// new one — the property the old `(remaining, duration)` shape lacked (two arms of the same + /// cooldown read byte-identical and the seam kept the first, elapsed, anchor: the + /// vanished-GCD-pie bug). Never convert through a locally sampled `Instant::now()`: that + /// re-measures the tick→caller scheduling gap every frame and wobbles the derived start by + /// the jitter (±12 ms observed live), turning a running cooldown into a per-frame "changed" + /// triple. + pub(crate) fn ui_triple(&self, anchor: Instant, ui_now: f64) -> Option<(i64, u32, bool)> { (self.remaining_ms > 0).then(|| { - let start = ui_now - now.saturating_duration_since(self.start).as_secs_f64(); + // Signed both ways: a timer armed AFTER the anchor sample (mid-frame, before the + // next tick) projects forward, so its first-frame derivation already equals every + // later frame's. + let start = match self.start.checked_duration_since(anchor) { + Some(ahead) => ui_now + ahead.as_secs_f64(), + None => ui_now - anchor.duration_since(self.start).as_secs_f64(), + }; #[allow(clippy::cast_possible_truncation)] // session-clock ms fit i64 ( (start * 1000.0).round() as i64, @@ -123,8 +138,16 @@ impl Cooldowns { self.generation = self.generation.wrapping_add(1); } - /// The insert/refresh primitive (`AddCooldown 0x6e12c0`): nothing to track → no-op; an - /// existing `(spell, item)` record is refreshed in place, else a fresh one is appended. + /// The insert primitive (`AddCooldown 0x6e12c0`): nothing to track → no-op; else **append a + /// new record**. The client never matches-by-id here — its "reuse" scan is free-list node + /// recycling, an allocator detail (no `[node+8]==spellId` compare anywhere in `0x6e12c0`'s + /// body, wow-re `wave-cooldown.md`), where every other op in the family explicitly walks + /// "each node matching id". So one spell can hold SEVERAL records at once — and must: the + /// cast-send GCD arm (`StartGlobalCooldown`, a gcd-only node) and the GO self-insert + /// (`StartCooldown`, which passes `gcd=0,0`) are separate nodes. The find-and-replace this + /// used to do made the GO insert of any cooldown-carrying spell (Frost Nova) overwrite its + /// own running GCD node — the whole bar's pie flashed and died ~100 ms after the press, + /// while cooldown-less spells (Arcane Explosion) kept theirs (decision 0947). #[allow(clippy::too_many_arguments)] // the SPELLHISTORY node's own field list fn add( &mut self, @@ -132,6 +155,7 @@ impl Cooldowns { item_id: u32, recovery: Timer, category: u32, + category_wildcard: bool, category_recovery: Timer, on_hold: bool, gcd_category: u32, @@ -146,24 +170,17 @@ impl Cooldowns { { return; } - let node = Record { + self.records.push(Record { spell_id, item_id, recovery, category, + category_wildcard, category_recovery, on_hold, gcd_category, gcd, - }; - match self - .records - .iter_mut() - .find(|r| r.spell_id == spell_id && r.item_id == item_id) - { - Some(existing) => *existing = node, - None => self.records.push(node), - } + }); self.bump(); } @@ -205,6 +222,7 @@ impl Cooldowns { duration: Duration::from_millis(u64::from(spell.recovery_ms)), }, spell.category, + spell.category_wildcard, Timer { start: now, duration: Duration::from_millis(u64::from( @@ -245,6 +263,10 @@ impl Cooldowns { duration: Duration::from_millis(u64::from(recovery_ms)), }, use_spell.category, + // The wildcard mark rides the CATEGORY; the wire's per-slot category resolves it + // through the spell only when the two agree (no 5875 item carries the one wildcard + // category, wand Shoot's 351 — a named corner, not a live one). + spell.is_some_and(|s| s.category == use_spell.category && s.category_wildcard), Timer { start: now, duration: Duration::from_millis(u64::from(category_ms)), @@ -256,19 +278,32 @@ impl Cooldowns { } /// Arm the global cooldown at cast-send (`StartGlobalCooldown 0x6e2de0` ← the cast-send arm - /// `0x6e58fb`): the spell's `startRecoveryCategory`/`startRecoveryTime` pair; both zero → - /// nothing (Attack, Auto Shot, wand Shoot carry no GCD). + /// `0x6e58fb`, shared by all three commit wires — CAST_SPELL, USE_ITEM, PET_CAST — keyed on + /// the cast SPELL): arms iff `startRecoveryTime != 0` (the byte predicate `6e2e0f–6e2e3d`: + /// enter on either pair field nonzero, then bail on a zero post-mod time — so a + /// `{cat≠0, time=0}` spell arms nothing; Attack, Auto Shot and wand Shoot carry (0,0)). + /// `onHold` rides Attributes bit 25 exactly as the ref passes it (`([rec+0x18]>>0x19)&1`). pub(crate) fn start_gcd(&mut self, spell_id: u32, spell: &SpellDisplay, now: Instant) { - if spell.start_recovery_category == 0 && spell.start_recovery_ms == 0 { + if spell.start_recovery_ms == 0 { return; } + if crate::dbg_trace::enabled() { + crate::dbg_trace::line( + "cd", + &format!( + "arm-gcd spell={spell_id} gcdcat={} dur={}ms (cast-send)", + spell.start_recovery_category, spell.start_recovery_ms + ), + ); + } self.add( spell_id, 0, Timer::none(now), 0, - Timer::none(now), false, + Timer::none(now), + spell.cooldown_on_event(), spell.start_recovery_category, Timer { start: now, @@ -283,11 +318,16 @@ impl Cooldowns { let mut touched = false; for r in &mut self.records { if r.spell_id == spell_id && !r.gcd.duration.is_zero() { + // `0x6e1630` zeroes BOTH fields (+0x28 startRecoveryCategory, +0x2c time). + r.gcd_category = 0; r.gcd = Timer::none(now); touched = true; } } if touched { + if crate::dbg_trace::enabled() { + crate::dbg_trace::line("cd", &format!("clear-gcd spell={spell_id} (cast-fail)")); + } self.prune(now); self.bump(); } @@ -364,6 +404,7 @@ impl Cooldowns { duration: Duration::from_millis(u64::from(recovery_ms)), }, category, + spell.is_some_and(|s| s.category == category && s.category_wildcard), Timer { start: now, duration: Duration::from_millis(u64::from(category_ms)), @@ -393,6 +434,7 @@ impl Cooldowns { duration: Duration::from_millis(30_000), }, 0, + false, Timer::none(now), false, 0, @@ -418,6 +460,9 @@ impl Cooldowns { duration: Duration::from_millis(u64::from(cd.spell_cd_ms)), }, u32::from(cd.category & 0x7FFF), + // No display in reach here; the one wildcard category (wand Shoot's 351) never + // arrives via the initial-cooldowns list on 1.12 data — a named, dead corner. + false, Timer { start: now, duration: Duration::from_millis(u64::from(cd.category_cd_ms)), @@ -428,56 +473,59 @@ impl Cooldowns { ); } - /// Whether the spell (or its category) is on a *tracked* cooldown — the client's - /// `IsSpellOnCooldown 0x6e1690`, the local not-ready refusal at cast-send. Faithfully - /// **excludes** the GCD fields (the byte law tests only `duration != 0 || onHold` on the - /// spell/category pairs): a pure-GCD lock does not read as "not ready". - pub(crate) fn is_on_cooldown( - &self, - spell_id: u32, - spell: Option<&SpellDisplay>, - now: Instant, - ) -> bool { + /// The client's `IsSpellOnCooldown 0x6e1690` — an **"has an on-hold (not-yet-started) + /// record"** predicate, NOT a general on-cooldown test (the corrected decode, wow-re + /// `gcd-power-gate.md` §3: both legs return 1 only when the matched node's onHold byte is + /// set; `+0x28`/`+0x2c` are never referenced and no time source is called). Its reference + /// consumers are all bit25/cooldown-on-event gates: the usable walk's grey-while-parked leg + /// (`0x6e3fb1`) — ours — and the cast-fail on-hold revert. + pub(crate) fn has_on_hold_record(&self, spell_id: u32, spell: Option<&SpellDisplay>) -> bool { let category = spell.map_or(0, |s| s.category); self.records.iter().any(|r| { - // The id match reads the spell timer + hold only; a record's CATEGORY timer is - // reachable solely through the category resolve below — the client's own shape - // (`GetCooldownInfo 0x6e13e0` surfaces the category pair only on the category - // compare at `0x6e155e`, and `SpellCategory[0]` is NULL). Concretely: Auto Shot's - // ranged-speed pad (decision 0378) sits on its category-0 record, invisible — a - // re-press is never locally refused, matching the ref. - (r.spell_id == spell_id && (!r.recovery.remaining(now).is_zero() || r.on_hold)) - || (category != 0 - && r.category == category - && !r.category_recovery.remaining(now).is_zero() - && !r.on_hold) + r.on_hold + && ((r.spell_id == spell_id && r.item_id == 0) + || (category != 0 + && r.category == category + && !r.category_recovery.duration.is_zero())) }) } - /// Whether a **GCD-carrying** spell is locked by a running global cooldown — its - /// `startRecoveryCategory` has remaining time on some record's GCD pair. The send seam's - /// second refusal leg (after [`Self::is_on_cooldown`], which byte-faithfully EXCLUDES the - /// GCD): a locked press refuses locally with "not ready" and never wires a cast — sending - /// instead draws the server's NOT_READY fail (vmangos `Spell.cpp:5387` enforces the GCD), - /// whose faithful revert [`Self::clear_gcd`] then wipes the RUNNING GCD — the spam-press - /// vanished-pie bug. GCD-free spells (`startRecoveryTime == 0` — Attack, Auto Shot, Heroic - /// Strike) are never locked: they queue/fire during the GCD, the vanilla feel. INTERIM: the - /// ref's exact refusal site for a GCD-locked press is unpinned in wow-re (`0x6e1690` skips - /// the GCD fields, yet the ref demonstrably refuses locally and never casts during the GCD); - /// the observable this reproduces is the reference's. - pub(crate) fn gcd_locked(&self, spell: &SpellDisplay, now: Instant) -> bool { - spell.start_recovery_ms > 0 - && spell.start_recovery_category != 0 - && self.records.iter().any(|r| { - r.gcd_category == spell.start_recovery_category && !r.gcd.remaining(now).is_zero() - }) + /// The cast validator's FIRST rung (`0x6094f0` @ `0x609565` → `0x6e2ea0` → the getter): a + /// press is refused "not ready" iff [`Self::info`] reads ANY remaining — the spell's own + /// pair, a category match, or the GCD leg, one query (wow-re `gcd-power-gate.md` §1.3/§2, + /// the §5 that closed 0379's INTERIM). The GCD refusal predicate is therefore the GETTER's: + /// `pressed.startRecoveryCategory == node.startRecoveryCategory && node.time != 0` — the + /// pressed spell's own `startRecoveryTime` is never consulted (a `{cat≠0, time=0}` press — + /// the scroll spells — IS refused during the GCD), and Attack / profession presses can never + /// be refused (the getter's head exclusion). The item fork (`0x60952b`) is the CALLER's: + /// an item press queries `(use_spell, item_entry)` and refuses 0x28; a spell press queries + /// `(spell, 0)` and refuses 0x3c. Refusing locally is what keeps the server's NOT_READY fail + /// — whose faithful revert [`Self::clear_gcd`] wipes the RUNNING GCD — off the wire (the + /// 0379 spam-press vanished-pie loop). + pub(crate) fn not_ready( + &self, + spell_id: u32, + item_entry: u32, + spell: Option<&SpellDisplay>, + now: Instant, + ) -> bool { + self.info(spell_id, item_entry, spell, now).remaining_ms > 0 } - /// The per-spell read (`GetCooldownInfo 0x6e13e0`): resolve `spell_id` (as cast from - /// `item_entry`, `0` for a plain spell) against every record — id (+item) match takes the - /// spell pair, category match the category pair, and a GCD-category match the GCD pair (how - /// one cast's GCD reaches every button sharing `startRecoveryCategory`). Longest remaining - /// wins; an on-hold winner reads `enabled == false` (the parked "cooldown hasn't begun"). + /// The per-spell read (`GetCooldownInfo 0x6e13e0`, the complete §5 match law — wow-re + /// `gcd-power-gate.md` §2): resolve `spell_id` (as cast from `item_entry`, `0` for a plain + /// spell) against EVERY record, three legs each, **longest remaining wins**: + /// + /// - **head exclusion**: `Effect[0] ∈ {ATTACK, TRADE_SKILL}` reads cold unconditionally + /// (`6e1439`/`6e1442`) — no pie and no refusal for the Attack/profession buttons, ever; + /// - **spell-id leg**: id+item match; a parked record reads full duration with + /// `enabled == false` (the `CooldownFrame_SetTimer` law hides it); + /// - **category leg**: node-category equality — or ANY query when the node's category is a + /// flags-bit-`0x2` wildcard row (wand Shoot's 351: the whole-bar swing sweep). A parked + /// record contributes its full duration, disabled (ref: start=now while onHold); + /// - **GCD leg** (`6e15cc`): plain equality `node.gcd_category == queried + /// startRecoveryCategory` with the NODE's time nonzero — the queried spell's own + /// `startRecoveryTime` plays no role, and the leg ignores onHold and never disables. pub(crate) fn info( &self, spell_id: u32, @@ -485,14 +533,17 @@ impl Cooldowns { spell: Option<&SpellDisplay>, now: Instant, ) -> CooldownInfo { - let category = spell.map_or(0, |s| s.category); - let start_recovery_category = spell.map_or(0, |s| s.start_recovery_category); let mut best = CooldownInfo { start: now, remaining_ms: 0, duration_ms: 0, enabled: true, }; + if spell.is_some_and(|s| s.cooldown_query_excluded()) { + return best; + } + let category = spell.map_or(0, |s| s.category); + let start_recovery_category = spell.map_or(0, |s| s.start_recovery_category); let mut consider = |timer: &Timer, remaining: Duration, enabled: bool| { let remaining_ms = remaining.as_millis().min(u128::from(u32::MAX)) as u32; if remaining_ms > best.remaining_ms { @@ -513,14 +564,18 @@ impl Cooldowns { consider(&r.recovery, r.recovery.remaining(now), true); } } - if category != 0 && r.category == category && !r.on_hold { - consider( - &r.category_recovery, - r.category_recovery.remaining(now), - true, - ); + if (r.category != 0 && r.category == category) || r.category_wildcard { + if r.on_hold { + consider(&r.category_recovery, r.category_recovery.duration, false); + } else { + consider( + &r.category_recovery, + r.category_recovery.remaining(now), + true, + ); + } } - if start_recovery_category != 0 && r.gcd_category == start_recovery_category { + if r.gcd_category == start_recovery_category && !r.gcd.duration.is_zero() { consider(&r.gcd, r.gcd.remaining(now), true); } } @@ -580,8 +635,42 @@ mod tests { let ch = cds.info(100, 0, Some(&charge()), mid); assert_eq!(ch.remaining_ms, 0, "no startRecoveryCategory — no GCD read"); - // …and the GCD is NOT a "not ready" lock (0x6e1690 skips the GCD fields). - assert!(!cds.is_on_cooldown(133, Some(&fireball()), mid)); + // …and the press gate reads it — the ONE getter is the refusal (0948's §5 closed + // 0379's INTERIM: there is no separate GCD site); the corrected `0x6e1690` on-hold + // predicate stays false (no parked record). + assert!(cds.not_ready(133, 0, Some(&fireball()), mid)); + assert!(!cds.has_on_hold_record(133, Some(&fireball()))); + } + + /// The director's Frost Nova report (decision 0947): a cooldown-carrying spell's GO + /// self-insert (`StartCooldown` passes `gcd=0,0`) lands on its OWN node and must never eat + /// the cast-send GCD node — the bar's pie flashed and died ~100 ms after every Frost Nova + /// press while cooldown-less spells (Arcane Explosion) kept theirs, because `add` used to + /// find-and-replace by `(spell, item)`. + #[test] + fn a_go_self_insert_never_wipes_the_running_gcd() { + let t0 = Instant::now(); + let mut cds = Cooldowns::default(); + // Frost-Nova-shaped: own 25 s recovery, category 35, the ordinary 133/1500 GCD pair. + let frost_nova = spell(35, 25_000, 0, (133, 1500), 0); + cds.start_gcd(122, &frost_nova, t0); // the cast-send arm + cds.start_spell(122, &frost_nova, 0, t0 + Duration::from_millis(100)); // the GO insert + + let mid = t0 + Duration::from_millis(200); + // Every GCD-carrying sibling still reads the RUNNING GCD… + let fb = cds.info(133, 0, Some(&fireball()), mid); + assert_eq!( + (fb.remaining_ms, fb.duration_ms), + (1300, 1500), + "the GO self-insert must not eat the GCD node" + ); + assert!( + cds.not_ready(133, 0, Some(&fireball()), mid), + "the local lock holds too" + ); + // …and Frost Nova's own button reads its own cooldown (longest remaining wins). + let own = cds.info(122, 0, Some(&frost_nova), mid); + assert_eq!((own.remaining_ms, own.duration_ms), (24_900, 25_000)); } #[test] @@ -592,13 +681,12 @@ mod tests { cds.start_gcd(5384, &fd, t0); cds.start_spell(5384, &fd, 0, t0); let mid = t0 + Duration::from_millis(100); - assert!(cds.is_on_cooldown(5384, Some(&fd), mid)); - assert!(cds.info(5384, 0, Some(&fd), mid).remaining_ms > 0); + assert!(cds.not_ready(5384, 0, Some(&fd), mid)); // The 0x6e1a00 fail path: GCD cleared (0x6e1630) + the record force-removed (0x6e3050). cds.clear_gcd(5384, mid); cds.clear_spell(5384); - assert!(!cds.is_on_cooldown(5384, Some(&fd), mid)); + assert!(!cds.not_ready(5384, 0, Some(&fd), mid)); assert_eq!(cds.info(5384, 0, Some(&fd), mid).remaining_ms, 0); } @@ -614,7 +702,7 @@ mod tests { let s = cds.info(999, 0, Some(&sibling), mid); assert_eq!((s.remaining_ms, s.duration_ms), (10_000, 15_000)); assert!( - cds.is_on_cooldown(999, Some(&sibling), mid), + cds.not_ready(999, 0, Some(&sibling), mid), "category lock is a not-ready" ); // …an unrelated spell reads nothing. @@ -636,7 +724,11 @@ mod tests { (30_000, false), "an on-hold record never elapses on its own" ); - assert!(cds.is_on_cooldown(5384, Some(&fd), t0 + Duration::from_secs(60))); + assert!(cds.not_ready(5384, 0, Some(&fd), t0 + Duration::from_secs(60))); + assert!( + cds.has_on_hold_record(5384, Some(&fd)), + "the corrected 0x6e1690: an on-hold record — the usable walk's grey-while-parked" + ); // SMSG_COOLDOWN_EVENT starts the clocks NOW. let event_at = t0 + Duration::from_secs(60); @@ -740,32 +832,112 @@ mod tests { ); } - /// The spam-press vanished-pie bug, leg 2 (the send gate): a press during the running GCD - /// must be refused LOCALLY — if it were sent, the server's NOT_READY fail would `clear_gcd` - /// the running GCD and kill the sweep. GCD-free spells (Heroic Strike's 0 ms pair) are never - /// locked, and the lock lifts when the GCD elapses. + /// The conversion is signed both ways: a timer armed AFTER the frame's clock-pair sample + /// (mid-frame — the anchor predates the arm until the next tick) must project the SAME + /// start the next frame's pair re-derives, so frame one never pushes a value frame two + /// then "corrects" (a phantom re-arm at every cast). #[test] - fn a_running_gcd_locks_gcd_carrying_presses_locally() { + fn a_mid_frame_arm_derives_the_same_start_as_the_next_frames_pair() { + let anchor0 = Instant::now(); + let mut cds = Cooldowns::default(); + // Armed 4 ms after this frame's anchor sample. + cds.start_gcd(133, &fireball(), anchor0 + Duration::from_millis(4)); + + // Frame 1 converts through the pre-arm pair; frame 2 through the next tick's pair + // (both legs advanced by the same 16 ms). One arm — one start, both frames. + let read1 = cds + .info( + 133, + 0, + Some(&fireball()), + anchor0 + Duration::from_millis(4), + ) + .ui_triple(anchor0, 10.000); + let read2 = cds + .info( + 133, + 0, + Some(&fireball()), + anchor0 + Duration::from_millis(20), + ) + .ui_triple(anchor0 + Duration::from_millis(16), 10.016); + assert_eq!(read1, Some((10_004, 1500, true))); + assert_eq!(read1, read2, "the projected start IS the settled start"); + } + + /// The GCD refusal predicate, corrected by the 0948 §5 (`gcd-power-gate.md` §2.1): a press + /// is refused iff its `startRecoveryCategory` EQUALS the armed node's (node time ≠ 0) — the + /// pressed spell's own `startRecoveryTime` is never consulted. So a `{133, 0}` press (the + /// scroll spells) IS refused during the GCD (the pre-§5 predicate passed it), a `{0, 0}` + /// press (Attack shape, Charge) flows, and the lock lifts at expiry. + #[test] + fn a_running_gcd_locks_presses_by_category_equality_alone() { let t0 = Instant::now(); let mut cds = Cooldowns::default(); assert!( - !cds.gcd_locked(&fireball(), t0), + !cds.not_ready(133, 0, Some(&fireball()), t0), "no GCD running — nothing locks" ); - cds.start_gcd(772, &fireball(), t0); // the successful Rend arms the 133/1500 GCD + cds.start_gcd(772, &fireball(), t0); // the successful cast arms the 133/1500 GCD let mid = t0 + Duration::from_millis(200); assert!( - cds.gcd_locked(&fireball(), mid), + cds.not_ready(133, 0, Some(&fireball()), mid), "the spam press 200 ms later is locked — refused, never sent, the GCD lives" ); - // A GCD-free spell (Heroic Strike shape: category 133, 0 ms) queues through freely. - let heroic = spell(0, 0, 0, (133, 0), 0x10000); - assert!(!cds.gcd_locked(&heroic, mid), "0 ms GCD pair never locks"); + // A {133, 0} press — Scroll of Armor's shape — is REFUSED: the node's category matches + // and only the NODE's time matters (the §5's corrected divergence). + let scroll = spell(0, 0, 0, (133, 0), 0x10000); + assert!( + cds.not_ready(8091, 0, Some(&scroll), mid), + "a zero-GCD press in the shared category is locked on the reference" + ); // Charge (no GCD pair at all) is untouched. - assert!(!cds.gcd_locked(&charge(), mid)); + assert!(!cds.not_ready(100, 0, Some(&charge()), mid)); // The lock lifts exactly when the GCD elapses. - assert!(!cds.gcd_locked(&fireball(), t0 + Duration::from_millis(1_501))); + assert!(!cds.not_ready(133, 0, Some(&fireball()), t0 + Duration::from_millis(1_501))); + } + + /// The getter's HEAD exclusion (`6e1439`/`6e1442`): Attack / profession presses can never + /// be cooldown-refused and their buttons never read a pie — whatever is running. + #[test] + fn attack_and_tradeskill_reads_are_always_cold() { + let t0 = Instant::now(); + let mut cds = Cooldowns::default(); + cds.start_gcd(772, &fireball(), t0); + let mid = t0 + Duration::from_millis(200); + let attack = SpellDisplay { + effect_1: 78, // SPELL_EFFECT_ATTACK + start_recovery_category: 133, + ..Default::default() + }; + assert_eq!(cds.info(6603, 0, Some(&attack), mid).remaining_ms, 0); + assert!(!cds.not_ready(6603, 0, Some(&attack), mid)); + } + + /// The category-wildcard leg (`6e1563`: a SpellCategory row with flags bit 0x2 matches EVERY + /// query — wand Shoot's 351, the only 5875 carrier): a running wildcard-category cooldown + /// sweeps and not-readies every button, the whole-bar wand-swing feel. + #[test] + fn a_wildcard_category_record_reaches_every_query() { + let t0 = Instant::now(); + let mut cds = Cooldowns::default(); + // Shoot-shaped: category 351 (wildcard), all-zero DBC recovery, the ranged pad supplies + // the category duration (decision 0378's insert shape). + let shoot = SpellDisplay { + category: 351, + category_wildcard: true, + attributes: 0x50012, + ..Default::default() + }; + cds.start_spell(5019, &shoot, 1500, t0); + let mid = t0 + Duration::from_millis(500); + // Fireball (category 0, unrelated) reads the wand swing… + let fb = cds.info(133, 0, Some(&fireball()), mid); + assert_eq!((fb.remaining_ms, fb.duration_ms), (1000, 1500)); + // …and is locally not-ready for its duration. + assert!(cds.not_ready(133, 0, Some(&fireball()), mid)); + assert!(!cds.not_ready(133, 0, Some(&fireball()), t0 + Duration::from_millis(1_501))); } #[test] @@ -799,9 +971,9 @@ mod tests { assert_eq!(info.duration_ms, 2200, "the sweep is the weapon speed"); assert_eq!(info.remaining_ms, 1200); assert!(info.enabled, "running, not parked"); - assert!(cds.is_on_cooldown(2764, Some(&throw), mid)); + assert!(cds.not_ready(2764, 0, Some(&throw), mid)); assert!( - !cds.is_on_cooldown(2764, Some(&throw), t0 + Duration::from_millis(2300)), + !cds.not_ready(2764, 0, Some(&throw), t0 + Duration::from_millis(2300)), "free again after the weapon speed elapses" ); } @@ -821,7 +993,7 @@ mod tests { "no sweep for a category-0 record" ); assert!( - !cds.is_on_cooldown(75, Some(&auto_shot), mid), + !cds.not_ready(75, 0, Some(&auto_shot), mid), "no local refusal either" ); } diff --git a/crates/benilla/src/creature_anim/compose.rs b/crates/benilla/src/creature_anim/compose.rs index 2abbe3ba..e73d065e 100644 --- a/crates/benilla/src/creature_anim/compose.rs +++ b/crates/benilla/src/creature_anim/compose.rs @@ -3,29 +3,36 @@ //! arrays instead. //! //! **Model pass** ([`compose_rig_models`], pre-propagation): a pose-dirty rig forward-folds its -//! `locals` into model-space affines and re-seats its consumer **anchors** (children of the rig's -//! `joints_root`) from them — so ordinary propagation carries every consumer subtree (held items, -//! nested effect rigs, the mount seat) at this frame's pose, exactly as the joint hierarchy did. -//! Runs after [`PosePost`] (body twist, global sequences — the writers that follow the evaluator). +//! `locals` into model-space affines — **including the `flags & 0x7` parent-matrix arm**, which +//! needs nothing but model space (`RigPose::compose`) — and re-seats its consumer **anchors** +//! (children of the rig's `joints_root`) from them, so ordinary propagation carries every consumer +//! subtree (held items, nested effect rigs, the mount seat) at this frame's pose, exactly as the +//! joint hierarchy did. Runs after [`PosePost`] (body twist, global sequences — the writers that +//! follow the evaluator). //! //! **World pass** ([`finalize_rig_worlds`], post-propagation, inside //! [`crate::billboard::BillboardPlace`]): per rig needing it — pose-dirty, `joints_root` moved, //! or camera-faced — compose the world chain `world_from_model × local…`, apply the byte-law -//! bone replacements (`billboard_joint_palette`'s math verbatim: billboard kinds take the camera -//! basis, bone flag `0x04` resets to the holder's rotation, descendants chain onto the replaced +//! bone replacements (`billboard_joint_palette`'s math verbatim: the arm again, in its world-space +//! form, then billboard kinds take the camera basis, and descendants chain onto the replaced //! frames), write the palette rows (`world × inverse_bindpose`, decision 0720), and re-seat the //! anchors sitting on replaced subtrees — including the same rigid-child re-walk and the same //! nested-rig do-not-enter rule as the entity pass. A re-seated subtree that carries another //! rig's model frame (the mounted rider's seat anchor, [`RigFrame`]) cascades: that rig //! re-finalizes in the same pass, so its palette never lags its seat. //! +//! The arm appearing in BOTH passes is not a double application — each pass builds its own chain +//! once, in its own space, and the two agree (see `RigPose::compose`). What it is NOT allowed to +//! be is world-pass-only: the anchors are seated from the model pass, so an arm that ran only +//! afterwards left every attached subtree riding the un-armed frame (decision 0945). +//! //! A parked, stationary rig runs neither pass and uploads zero bytes (decision 0448's park //! observables carry over unchanged). use bevy::mesh::skinning::SkinnedMeshInverseBindposes; use bevy::prelude::*; -use crate::billboard::{billboard_basis, BillboardJointRig}; +use crate::billboard::{billboard_basis, parent_arm_matrix, BillboardJointRig}; use crate::player::WorldCamera; use crate::rig_palette::{RigPalettes, RigSkin}; @@ -65,15 +72,14 @@ fn compose_rig_models(mut rigs: Query<&mut RigPose>, mut anchors: Query<&mut Tra } /// One rig's world chain + which bones sit in a replaced subtree. `root_g` is the rig's -/// `joints_root` propagated world, `root_rot` the HOLDER's world rotation (the frame a bone-flag -/// `0x04` joint snaps to — the entity pass read `BillboardJointRig::root`, which spawn sites fed -/// the holder), `cam` the camera basis (`None` = no camera, replacements limited to `0x04`). -/// The math mirrors `billboard_joint_palette` operation for operation — `mul_transform`, then -/// decompose/replace/recompose — so the collapsed lane is bit-compatible with the entity lane. +/// `joints_root` propagated world — the model's own root frame `[model+0xfc]`, which is both what +/// the `flags & 0x7` arm rebuilds a parent matrix out of and, for a mounted rider, its seat anchor +/// (`0x714389`). `cam` is the camera basis (`None` = no camera, so only the arm applies). +/// The math mirrors `billboard_joint_palette` operation for operation — rewrite the parent matrix, +/// compose the local, then face — so the collapsed lane is bit-compatible with the entity lane. fn rig_worlds( rig: &RigPose, root_g: GlobalTransform, - root_rot: Quat, cam: Option<(Vec3, Vec3, Vec3)>, ) -> (Vec, Vec) { let n = rig.locals.len(); @@ -81,20 +87,26 @@ fn rig_worlds( let mut touched = vec![false; n]; for i in 0..n { let parent = usize::try_from(rig.parents[i]).ok().filter(|&p| p < i); - let mut g = match parent { + let parent_world = match parent { Some(p) => worlds[p], None => root_g, + }; + // `flags & 0x7` first: it changes the INPUT the billboard law is applied to, and the + // billboard switch below still runs (wow-re `billboard-bone-law.md` §9.1). + let mut g = match rig.arms[i] { + Some(arm) => { + touched[i] = true; + GlobalTransform::from(parent_arm_matrix( + arm, + parent_world.affine(), + root_g.affine(), + rig.binds[i], + )) + } + None => parent_world, } .mul_transform(rig.locals[i]); - if rig.ignore_rot[i] { - let (scale, _, translation) = g.to_scale_rotation_translation(); - g = GlobalTransform::from(Transform { - translation, - rotation: root_rot, - scale, - }); - touched[i] = true; - } else if let (Some(kind), Some((fwd, right, up))) = (rig.kinds[i], cam) { + if let (Some(kind), Some((fwd, right, up))) = (rig.kinds[i], cam) { let (scale, rot, translation) = g.to_scale_rotation_translation(); g = GlobalTransform::from(Transform { translation, @@ -102,8 +114,8 @@ fn rig_worlds( scale, }); touched[i] = true; - } else if let Some(p) = parent { - touched[i] = touched[p]; + } else if !touched[i] { + touched[i] = parent.is_some_and(|p| touched[p]); } worlds.push(g); } @@ -167,40 +179,47 @@ pub(crate) fn finalize_rig_worlds( let mut cascade: Vec = Vec::new(); let mut finalize = |rig: &mut RigPose, - holder: Entity, skin: &RigSkin, globals: &mut Query<(&Transform, &mut GlobalTransform), Without>, - cascade: &mut Vec| { + cascade: &mut Vec, + root_moved: bool| { let Ok(root_g) = globals.get(rig.joints_root).map(|(_, g)| *g) else { return; }; - let root_rot = globals - .get(holder) - .map(|(_, g)| g.rotation()) - .unwrap_or_default(); - let (worlds, touched) = rig_worlds(rig, root_g, root_rot, cam_basis); + let (worlds, touched) = rig_worlds(rig, root_g, cam_basis); if let Some(ibp) = ibps.get(&skin.ibp) { palettes.write_rig_worlds(skin, &worlds, ibp); } - if !rig.has_special { + if !rig.has_special && !root_moved { return; } // Anchors in replaced subtrees: re-seat their globals and re-compose their rigid // children from the replaced frames — the entity pass's child walk, anchor-rooted. + // + // `root_moved` widens that to EVERY anchor. The touched-only rule assumes propagation + // already placed an untouched anchor correctly, and that assumption dies the moment + // this rig's own model frame is re-seated after propagation ran: a mounted rider's + // seat is patched here, so every one of its anchors is standing on the pre-patch + // frame. Its palette rows are rebuilt from `root_g` either way, which is why the BODY + // looked right while its attached items rode the stale seat — the mounted pauldron + // swinging on the gallop while the shoulder under it did not (decision 0945). let mut stack: Vec<(Entity, GlobalTransform)> = Vec::new(); for &(bone, anchor) in &rig.anchors { let b = bone as usize; - if !touched.get(b).copied().unwrap_or(false) { + if !root_moved && !touched.get(b).copied().unwrap_or(false) { continue; } + let Some(&world) = worlds.get(b) else { + continue; + }; if let Ok((_, mut g)) = globals.get_mut(anchor) { - *g = worlds[b]; + *g = world; } if let Ok(cs) = children.get(anchor) { stack.extend( cs.iter() .filter(|c| !fx_roots.contains(c)) - .map(|c| (c, worlds[b])), + .map(|c| (c, world)), ); } } @@ -223,7 +242,7 @@ pub(crate) fn finalize_rig_worlds( continue; }; let rig = rig.into_inner(); - finalize(rig, holder, skin, &mut globals, &mut cascade); + finalize(rig, skin, &mut globals, &mut cascade, false); rig.pose_dirty = false; } // The cascade: a patch walk above moved some rig's model frame after it (or before it) ran — @@ -236,7 +255,7 @@ pub(crate) fn finalize_rig_worlds( } let rig = rig.into_inner(); let mut ignore = Vec::new(); - finalize(rig, holder, skin, &mut globals, &mut ignore); + finalize(rig, skin, &mut globals, &mut ignore, true); rig.pose_dirty = false; } } @@ -286,10 +305,68 @@ mod tests { parent, local_translation: t, billboard: None, - ignore_parent_rotation: false, + parent_arm: None, } } + /// **The mount seat, in the pass that decides it** — `compose`, pre-propagation, which is what + /// consumer anchors (the rider's seat, and every item hanging off the rider) are re-seated + /// from. The shape is `RidingHorse`'s: a spine bone that swings with the gallop, and the seat + /// bone under it carrying `flags = 0x6`. + /// + /// The seat must come out with the MODEL's orientation whatever the spine does, while still + /// being carried to the position the spine put it at — that is the byte law's pivot-preserving + /// tail (wow-re `billboard-bone-law.md` §9.1), and it is the whole reason a galloping horse + /// bobs its rider without rocking them. Asserted across a swept spine angle, because a single + /// sample cannot tell a discarded rotation from a lucky one. + /// + /// Pinning it HERE and not only in the world pass is the point of the regression: the arm was + /// applied post-propagation first, the anchors were seated from the un-armed matrix, and the + /// mounted pauldron swung 53° over the stride while the shoulder it hung off stood still. + #[test] + fn a_flags_0x6_seat_bone_discards_the_gallop_before_the_anchors_are_seated() { + let sk = skeleton(vec![ + joint(-1, Vec3::ZERO), + joint(0, Vec3::Y), // the spine, which swings + ModelJoint { + parent: 1, + local_translation: Vec3::new(0.0, 0.5, 0.25), + billboard: None, + parent_arm: Some(benilla_formats::ParentArm { + ignore_translate: false, + basis: benilla_formats::ParentBasis::RootBasis, + }), + }, + ]); + let mut rig = RigPose::new(Entity::PLACEHOLDER, &sk); + for step in 0..16 { + let swing = (step as f32 - 7.5) * 0.05; // ±21°, the horse's measured stride + rig.locals[1].rotation = Quat::from_rotation_x(swing); + rig.compose(); + let (_, seat_rot, seat_pos) = rig.model[2].to_scale_rotation_translation(); + assert!( + seat_rot.angle_between(Quat::IDENTITY) < 1e-4, + "the seat keeps the model's orientation at spine swing {swing}: got {seat_rot:?}" + ); + // …and it still RIDES the spine: the pivot is where the animated parent put it. + let want = rig.model[1].transform_point3(Vec3::new(0.0, 0.5, 0.25)); + assert!( + (seat_pos - want).length() < 1e-4, + "the seat travels with the gallop at swing {swing}: {seat_pos} vs {want}" + ); + } + // The sweep must actually have moved the seat, or the assertions above are vacuous. + rig.locals[1].rotation = Quat::from_rotation_x(0.4); + rig.compose(); + let far = rig.model[2].translation; + rig.locals[1].rotation = Quat::from_rotation_x(-0.4); + rig.compose(); + assert!( + (Vec3::from(far) - Vec3::from(rig.model[2].translation)).length() > 0.1, + "the spine sweep really does carry the seat" + ); + } + /// The model compose is the exact affine product entity propagation computed: a three-bone /// chain with rotation + non-uniform scale folds to the same matrices as chained /// `GlobalTransform::mul_transform`. @@ -322,7 +399,7 @@ mod tests { /// The world pass reproduces `billboard_joint_palette`'s replacements: a lock-Z billboard /// bone takes the camera basis (pivot/scale kept), its child chains onto the replaced frame, - /// and a bone-flag-0x04 helper resets to the holder's rotation while its pivot rides the + /// and a `flags & 0x4` helper takes the model root's basis while its pivot still rides the /// animated parent — the entity pass's three verified behaviours, computed from the arrays. #[test] fn world_pass_matches_the_entity_billboard_law() { @@ -331,14 +408,17 @@ mod tests { parent: -1, local_translation: Vec3::new(5.0, 1.0, 0.0), billboard: Some(BillboardKind::LockZ), - ignore_parent_rotation: false, + parent_arm: None, }, joint(0, Vec3::Y), ModelJoint { parent: 1, local_translation: Vec3::Y, billboard: None, - ignore_parent_rotation: true, + parent_arm: Some(benilla_formats::ParentArm { + ignore_translate: false, + basis: benilla_formats::ParentBasis::RootDirection, + }), }, ]); let mut rig = RigPose::new(Entity::PLACEHOLDER, &sk); @@ -352,7 +432,6 @@ mod tests { let (worlds, touched) = rig_worlds( &rig, GlobalTransform::from(Transform::from_rotation(root_rot)), - root_rot, Some(cam), ); assert_eq!(touched, vec![true; 3], "the whole chain is replaced"); @@ -367,14 +446,14 @@ mod tests { let (s1, _, t1) = worlds[1].to_scale_rotation_translation(); assert!((t1 - (t0 + r0 * (Vec3::Y * 2.0))).length() < 1e-4, "{t1}"); assert!((s1 - Vec3::ONE).length() < 1e-5, "2 × 0.5 scale chain"); - // Bone 2 (flag 0x04): pivot carried by the animated parent's frame, rotation snapped to - // the holder's. + // Bone 2 (flags & 0x4): pivot carried by the animated parent's frame, basis taken from + // the model root — the parent here is unit-scaled, so the ratio leg reproduces it exactly. let (_, r2, t2) = worlds[2].to_scale_rotation_translation(); let expect_t = worlds[1].transform_point(Vec3::Y); assert!((t2 - expect_t).length() < 1e-4, "pivot rides the parent"); assert!( r2.angle_between(root_rot) < 1e-3, - "rotation resets to the holder" + "basis comes from the model root, not the parent bone" ); } @@ -387,7 +466,7 @@ mod tests { rig.locals[0].rotation = Quat::from_rotation_z(0.4); rig.compose(); let root = GlobalTransform::from_translation(Vec3::new(0.0, 0.0, 7.0)); - let (worlds, touched) = rig_worlds(&rig, root, Quat::IDENTITY, None); + let (worlds, touched) = rig_worlds(&rig, root, None); assert_eq!(touched, vec![false; 2]); for (i, w) in worlds.iter().enumerate() { let expect = GlobalTransform::from(root.affine() * rig.model[i]); diff --git a/crates/benilla/src/creature_anim/driver/tests.rs b/crates/benilla/src/creature_anim/driver/tests.rs index e3c003a5..ed5c0636 100644 --- a/crates/benilla/src/creature_anim/driver/tests.rs +++ b/crates/benilla/src/creature_anim/driver/tests.rs @@ -38,6 +38,7 @@ fn clip(anim_id: u16, node: u32, looping: bool) -> AnimClip { upper_node: None, frequency: 0, replay: (0, 0), + poses_bones: true, } } diff --git a/crates/benilla/src/creature_anim/lod.rs b/crates/benilla/src/creature_anim/lod.rs index 76f7ffa7..bce30051 100644 --- a/crates/benilla/src/creature_anim/lod.rs +++ b/crates/benilla/src/creature_anim/lod.rs @@ -309,6 +309,7 @@ mod tests { upper_node: None, frequency: 0, replay: (0, 0), + poses_bones: true, }], hand_close: [None, None], playable_animation_lookup: Vec::new(), @@ -341,7 +342,7 @@ mod tests { parent: -1, local_translation: Vec3::ZERO, billboard: None, - ignore_parent_rotation: false, + parent_arm: None, }], spine_bone: None, head_bone: None, diff --git a/crates/benilla/src/creature_anim/pose.rs b/crates/benilla/src/creature_anim/pose.rs index f919331e..d5b0d669 100644 --- a/crates/benilla/src/creature_anim/pose.rs +++ b/crates/benilla/src/creature_anim/pose.rs @@ -44,13 +44,19 @@ pub(crate) struct RigPose { pub(crate) locals: Vec, /// Per-bone **animated** model-space affine (`parent_model × local`), the product transform /// propagation used to compute through the joint entities. Rebuilt by [`Self::compose`] for a - /// dirty rig; billboard/ignore-rot replacements happen in the world pass, never here. + /// dirty rig, WITH the `flags & 0x7` arm folded in — it is model-space-computable and the + /// anchors are seated from these. Only the camera-dependent billboard replacement waits for + /// the world pass. pub(crate) model: Vec, pub(crate) parents: Vec, /// The bone's billboard arm (`0x08/0x10/0x20/0x40`), for the world pass's camera replacement. pub(crate) kinds: Vec>, - /// Bone flag `0x04`: rotation resets to the model root's frame (the HandArrow helpers). - pub(crate) ignore_rot: Vec, + /// Bone flags `0x1/0x2/0x4`: how the bone's effective PARENT matrix is rebuilt from the model + /// root before it composes (`crate::billboard::parent_arm_matrix`). + pub(crate) arms: Vec>, + /// Each bone's BIND local translation — the pivot the arm preserves. `locals` carry the + /// ANIMATED translation, which the byte law rotates by the *new* basis; not interchangeable. + pub(crate) binds: Vec, /// Any bone billboards — the world pass re-faces this rig whenever it isn't parked. pub(crate) has_billboard: bool, /// Any bone billboards or resets — the world pass's override walk runs at all. @@ -80,16 +86,17 @@ impl RigPose { locals, parents: skeleton.joints.iter().map(|j| j.parent).collect(), kinds: skeleton.joints.iter().map(|j| j.billboard).collect(), - ignore_rot: skeleton + arms: skeleton.joints.iter().map(|j| j.parent_arm).collect(), + binds: skeleton .joints .iter() - .map(|j| j.ignore_parent_rotation) + .map(|j| j.local_translation) .collect(), has_billboard: skeleton.joints.iter().any(|j| j.billboard.is_some()), has_special: skeleton .joints .iter() - .any(|j| j.billboard.is_some() || j.ignore_parent_rotation), + .any(|j| j.billboard.is_some() || j.parent_arm.is_some()), anchors: Vec::new(), pose_dirty: true, }; @@ -101,13 +108,35 @@ impl RigPose { /// computed through the joint entities (`parent_global.mul_transform(local)`, model-rooted). /// M2 bones are parent-sorted (the format guarantees parent < child); a malformed child whose /// parent follows it composes from the model root, matching the old hierarchy's `unwrap_or(root)`. + /// + /// The **`flags & 0x7` arm runs here**, in model space, with the model root as the identity — + /// which is what it *is*, relative to this rig. That is not a shortcut around the world-space + /// form the world pass uses: a model frame is a similarity (uniform scale), so `root_world × + /// arm(P_model, I)` and `arm(root_world × P_model, root_world)` agree axis for axis, including + /// the ratio leg's per-axis magnitudes (the common factor cancels). + /// + /// Doing it here rather than only in the world pass is load-bearing, not a tidy-up. `model` is + /// what the pre-propagation pass re-seats consumer anchors from, so an arm applied only + /// afterwards leaves ordinary propagation to carry every attached subtree on the UN-armed + /// frame — and the world pass's patch walk deliberately does not enter a nested rig. That is + /// how a mounted rider's pauldron ended up riding the horse's gallop while the shoulder it + /// hangs off did not (decision 0945). pub(crate) fn compose(&mut self) { for i in 0..self.locals.len() { let local = self.locals[i].compute_affine(); - self.model[i] = match usize::try_from(self.parents[i]).ok().filter(|&p| p < i) { - Some(p) => self.model[p] * local, - None => local, + let parent = match usize::try_from(self.parents[i]).ok().filter(|&p| p < i) { + Some(p) => self.model[p], + None => Affine3A::IDENTITY, }; + self.model[i] = match self.arms[i] { + Some(arm) => crate::billboard::parent_arm_matrix( + arm, + parent, + Affine3A::IDENTITY, + self.binds[i], + ), + None => parent, + } * local; } } } @@ -451,7 +480,7 @@ mod tests { parent: -1, local_translation: Vec3::ZERO, billboard: None, - ignore_parent_rotation: false, + parent_arm: None, }) .collect(), spine_bone: None, diff --git a/crates/benilla/src/creature_anim/select/tests.rs b/crates/benilla/src/creature_anim/select/tests.rs index 08ea3015..c71f5d52 100644 --- a/crates/benilla/src/creature_anim/select/tests.rs +++ b/crates/benilla/src/creature_anim/select/tests.rs @@ -31,6 +31,7 @@ fn clip(anim_id: u16, move_speed: f32) -> AnimClip { upper_node: None, frequency: 0, replay: (0, 0), + poses_bones: true, } } diff --git a/crates/benilla/src/doodad_anim/mod.rs b/crates/benilla/src/doodad_anim/mod.rs index 461a693f..8eba8fd0 100644 --- a/crates/benilla/src/doodad_anim/mod.rs +++ b/crates/benilla/src/doodad_anim/mod.rs @@ -110,6 +110,9 @@ pub(crate) fn classify<'a>( if skeleton.joints.is_empty() { return DoodadAnimTier::Static; } + // `first_seq` — 0130's content gate, and only that (decision 0936's split; the loader arm's + // *identity* answer is `idle_seq`/`idle_clip`, which the entity lane arms from). It stays the + // rig question here: a seed that holds bind pose renders identically to the static mesh. match anims.first_seq.and_then(|i| anims.clips.get(i)) { Some(clip) => DoodadAnimTier::FirstSeq(clip), None if !anims.global_bones.is_empty() => DoodadAnimTier::GlobalSeqOnly, @@ -543,6 +546,7 @@ mod tests { upper_node: None, frequency: 0, replay: (0, 0), + poses_bones: true, } } @@ -684,7 +688,7 @@ mod tests { parent: -1, local_translation: Vec3::ZERO, billboard: None, - ignore_parent_rotation: false, + parent_arm: None, }) .collect(), spine_bone: None, @@ -725,6 +729,24 @@ mod tests { classify(&skeleton(3), Some(&a)), DoodadAnimTier::FirstSeq(c) if c.anim_id == 0 )); + // A CLOCK-ONLY model (decision 0941): every sequence now builds a clip, so a model whose + // bones never move — the Molten Core rune, the flame ring, 807 corpus models whose + // animation lives entirely in their emitter tracks — arrives here with clips and NO + // `first_seq` (the content gate declined it). It must still classify Static: its clock is + // armed by the entity lane off `idle_clip`, and rigging it could only reproduce the + // bind-pose mesh. This is the case that used to reach `first_seq: None` by never having + // built a clip at all. + let a = anims(vec![clip(0)], None, false); + assert!(matches!( + classify(&skeleton(3), Some(&a)), + DoodadAnimTier::Static + )); + // ...and one that also pulses on a global sequence takes the player-less tier, not a rig. + let a = anims(vec![clip(0)], None, true); + assert!(matches!( + classify(&skeleton(3), Some(&a)), + DoodadAnimTier::GlobalSeqOnly + )); } /// The client's `rand()` is the MSVC LCG, and its seed-1 stream is the textbook one. Pinning the diff --git a/crates/benilla/src/entities/attach/mod.rs b/crates/benilla/src/entities/attach/mod.rs index f7d25041..5caab0fc 100644 --- a/crates/benilla/src/entities/attach/mod.rs +++ b/crates/benilla/src/entities/attach/mod.rs @@ -8,7 +8,7 @@ //! reaches the shared types + caches in the parent via `super::`. use avian3d::prelude::RigidBody; -use benilla_assets::ModelSkeleton; +use benilla_assets::{ModelAnimations, ModelSkeleton}; use benilla_protocol::EntityKind; use bevy::prelude::*; @@ -40,33 +40,17 @@ pub(super) use glue_preview::build_glue_preview; mod redress; pub(super) use redress::redress_player_looks; -/// Set up the skinned + animated instance shared by creatures/players (decision 0019) and animated -/// GameObjects (decision 0242): the per-instance joints, the billboard glow rig, and — when the model -/// authors sequences — the `AnimationPlayer` + graph + `ModelAnimations` plus the base-track driver. -/// Three flavours share the rig, disjoint so one never touches the other's entities: -/// * a **creature/player** — the movement-driven gait picker (`AnimDriver`); -/// * a **state GameObject** (door/button/chest — `go_state_machine`) — the open/close state machine -/// (`GoAnim`), which cross-fades transitions through `AnimationTransitions`; -/// * **any other animated GameObject** (a mailbox's wind-swung flags, a banner) — *no* controller: -/// the reference arms the model's **Stand** sequence (animation id 0, resolved through the -/// model's own `playableAnimationLookup`) as the universal "loader-idle seed", and loops it iff -/// `M2Sequence.flags & 1 == 0` (wow-re `gameobject-anim-arm.md` §1/§2, byte-verified `0x71019b` -/// / `0x714585` — decision 0637, which corrected the earlier "arms the FIRST sequence" reading -/// this comment used to carry). We arm it here and let the kernel's modulo-wrap run it, ungated -/// like the creature path (GOs are few — the doodad host's draw-gate isn't warranted for them). +/// Set up the skinned instance shared by creatures/players (decision 0019) and animated +/// GameObjects (decision 0242): the per-instance consumer-bone anchors, the pose buffer, the +/// palette rig slot, and the global-sequence drive. /// -/// The reference *also* runs a second, GameObject-specific arm off `GAMEOBJECT_STATE` + -/// `GAMEOBJECT_ANIMPROGRESS` (same note, §2), and it lands AFTER this seed, so where both apply -/// the state arm is what is seen. benilla runs that arm only for the door/button/chest types -/// ([`crate::go_anim::go_animates`]). **That gate is a known narrowing, and it is not free:** -/// `benilla-extract goanimscan` measures 251 of 1582 GameObjectDisplayInfo models as -/// STATE-SENSITIVE (some reachable substate plays a different sequence from this seed), which on -/// the live world data is **1979 spawns** sitting on such a model with a GO type left off the -/// machine — books, traps, goobers, generic props. Widening it waits on the wow-re type census -/// (which of the 30 types allocate the `[GO+0x210]` handler at the `0x5f708c` dispatch); the -/// seed below is what those types render in the meantime. +/// The **sequence clock** — the `AnimationPlayer` + graph + [`ModelAnimations`] + the driver that +/// owns the choice — is NOT set up here; it is [`arm_sequence_clock`], called by the spawn site +/// whether or not this rig was built (decision 0941). Everything in this function costs a palette +/// slot and a skinned twin, and is worth spending only on a model whose bones actually move; the +/// clock is worth arming on every instance, because a sequence drives far more than a pose. /// -/// Global-sequence channels ride along regardless of flavour. This lane spawns **no joint +/// This lane spawns **no joint /// entities** (decision 0724): the pose lives in a [`crate::creature_anim::RigPose`] array on /// `entity`, and only the CONSUMER bones — attachment points, event markers, emitter/ribbon/ /// light hosts, billboard-card bones — get an anchor entity under `joints_root`, re-seated from @@ -81,8 +65,6 @@ fn setup_skinned_instance( entity: Entity, joints_root: Entity, d: &DisplayModel, - kind: EntityKind, - go_state_machine: bool, ) -> Option { let ibp = d.inverse_bindposes.as_ref()?; let nbones = d.skeleton.joints.len(); @@ -159,62 +141,10 @@ fn setup_skinned_instance( } }; if let Some(anims) = d.animations.as_ref() { - // **Every** GameObject instance gets the loader-idle seed, state machine or not — the - // reference's `0x70ebd0` tail arms bone 0 the moment the M2 goes LIVE and has exactly two - // callers, so no M2 instance in the client ever exists with nothing armed (wow-re - // `gameobject-anim-arm.md` §1/§2e). For a door/chest the object-layer arm lands *after* it - // and overrides it (§2, "because it lands after the loader seed, it is the effective arm"); - // seeding first is what stops the one-frame BIND POSE our state GOs used to render on their - // first displayed frame, before `go_anim` had a chance to run — the "explodes for a split - // second" report. The seed is played THROUGH the transitions object so that first arm - // cleanly fades out of it; playing it bare on the player would leave two clips live at once. - let mut player = AnimationPlayer::default(); - let mut transitions = AnimationTransitions::new(); - if kind == EntityKind::GameObject { - if let Some(clip) = anims.first_seq.and_then(|i| anims.clips.get(i)) { - // Loop iff the sequence says so (`M2Sequence.flags & 1 == 0`) — the kernel's own - // end-of-band law (wow-re `gameobject-anim-arm.md` §2, byte-verified at - // `0x714585`): bit0 clear loops on the modulo wrap, bit0 set plays the window and - // then FREEZES at `end_ms`. An unconditional repeat replayed one-shot idles. - let active = transitions.play(&mut player, clip.node, std::time::Duration::ZERO); - if clip.looping { - active.repeat(); - } - } - } - commands.entity(entity).insert(( - player, - AnimationGraphHandle(anims.graph.clone()), - anims.clone(), - )); - match kind { - EntityKind::GameObject if go_state_machine => { - commands.entity(entity).insert(( - // Cross-fades the open/close transition over the clip's blend-in time (0242/0049), - // and carries the seed above as the pose the first arm transitions out of. - transitions, - crate::go_anim::GoAnim::default(), - )); - } - // A loader-idle GameObject needs no driver and no transitions — the looping player IS the - // whole animation, and nothing will ever arm over it. - EntityKind::GameObject => {} - _ => { - commands - .entity(entity) - .insert(( - // Cross-fades over each clip's blend-in time, so a gait change eases (0049). - AnimationTransitions::new(), - AnimDriver::default(), - )) - // A (re)built rig is born live: fresh joints spawn pointing at the root, so a - // stale park marker from the torn-down visual would desync the LOD gate's - // edge-triggered bookkeeping (decision 0448). It re-parks on its own merits. - .remove::(); - } - } // Global-sequence bone channels (the eye-blink eyelid scale, resting fidget pulses; a GO's // free-running flicker): free-clock loops the per-sequence reader drops, driven on their own clock. + // Rig-bound (it writes bone slots), unlike the sequence clock — which is armed by + // [`arm_sequence_clock`] whether or not this instance ended up with a rig. if let Some(drive) = crate::creature_anim::GlobalSeqDrive::new_rig(&anims.global_bones, nbones) { @@ -228,6 +158,87 @@ fn setup_skinned_instance( Some(RigBuild { anchors, slot }) } +/// Arm this instance's **sequence clock**: the `AnimationPlayer` + graph + [`ModelAnimations`] that +/// say which sequence is playing and how far into it, plus the driver that owns the choice +/// ([`crate::go_anim::GoAnim`] for a state GameObject, `AnimDriver` for a unit). +/// +/// Separate from the rig on purpose (decision 0941). The two used to be one call — the clock was +/// armed inside [`setup_skinned_instance`], so an instance that wasn't worth skinning got no clock +/// either. But a skinned pose is only ONE of the things a sequence drives: the particle emitters' +/// rate/enable/params tracks read it (`EmitClock::Host`), so do the material alpha/colour/UV loops +/// and the GameObject state arm. 147 GameObject display models animate **only** through those — +/// no keyed bone anywhere — and for them "no rig" silently meant "no clock", i.e. file slot 0 at +/// t = 0 for ever: the Molten Core rune's flames read its *Closed* band, where every emission rate +/// key is 0, and the flame ring's spawn window never opened past its first frame. The clock is a +/// player and a handle; the rig is a palette slot and a skinned twin. Only the second is worth +/// gating on whether bones actually move. +fn arm_sequence_clock( + commands: &mut Commands, + entity: Entity, + anims: &ModelAnimations, + kind: EntityKind, + go_state_machine: bool, +) { + // **Every** GameObject instance gets the loader-idle seed, state machine or not — the + // reference's `0x70ebd0` tail arms bone 0 the moment the M2 goes LIVE and has exactly two + // callers, so no M2 instance in the client ever exists with nothing armed (wow-re + // `gameobject-anim-arm.md` §1/§2e). For a door/chest the object-layer arm lands *after* it + // and overrides it (§2, "because it lands after the loader seed, it is the effective arm"); + // seeding first is what stops the one-frame BIND POSE our state GOs used to render on their + // first displayed frame, before `go_anim` had a chance to run — the "explodes for a split + // second" report. The seed is played THROUGH the transitions object so that first arm + // cleanly fades out of it; playing it bare on the player would leave two clips live at once. + let mut player = AnimationPlayer::default(); + let mut transitions = AnimationTransitions::new(); + if kind == EntityKind::GameObject { + // `idle_clip`, NOT `first_seq`: the loader arm's identity answer, taken without the + // rendering content gate (decision 0936's split). A GameObject whose sequences pose no + // bone has no `first_seq` at all and used to arm nothing — which is precisely how its + // emitters ended up reading file slot 0 at t = 0 for ever (0941). + if let Some(clip) = anims.idle_clip() { + // Loop iff the sequence says so (`M2Sequence.flags & 1 == 0`) — the kernel's own + // end-of-band law (wow-re `gameobject-anim-arm.md` §2, byte-verified at + // `0x714585`): bit0 clear loops on the modulo wrap, bit0 set plays the window and + // then FREEZES at `end_ms`. An unconditional repeat replayed one-shot idles. + let active = transitions.play(&mut player, clip.node, std::time::Duration::ZERO); + if clip.looping { + active.repeat(); + } + } + } + commands.entity(entity).insert(( + player, + AnimationGraphHandle(anims.graph.clone()), + anims.clone(), + )); + match kind { + EntityKind::GameObject if go_state_machine => { + commands.entity(entity).insert(( + // Cross-fades the open/close transition over the clip's blend-in time (0242/0049), + // and carries the seed above as the pose the first arm transitions out of. + transitions, + crate::go_anim::GoAnim::default(), + )); + } + // A loader-idle GameObject needs no driver and no transitions — the looping player IS the + // whole animation, and nothing will ever arm over it. + EntityKind::GameObject => {} + _ => { + commands + .entity(entity) + .insert(( + // Cross-fades over each clip's blend-in time, so a gait change eases (0049). + AnimationTransitions::new(), + AnimDriver::default(), + )) + // A (re)built rig is born live: fresh joints spawn pointing at the root, so a + // stale park marker from the torn-down visual would desync the LOD gate's + // edge-triggered bookkeeping (decision 0448). It re-parks on its own merits. + .remove::(); + } + } +} + /// A collapsed rig's build result (decision 0724): the consumer anchors by bone + the palette /// slot each skinned part tags. struct RigBuild { @@ -444,6 +455,13 @@ pub(super) fn attach_entity_visuals( // mesh did). Truly static props keep the static mesh. `skin` is `Some((joints, // inverse_bindposes, palette_slot))` when instanced (decision 0720; slot 0 = palette // full, parts fall back to the static mesh). + // Does this entity run the GameObject open/close state machine (the byte-verified + // TYPE_ID census)? Read once — it picks both the rig flavour below and the clock's + // driver, and those two must never disagree about the same object. + let go_state_machine = net.kind == EntityKind::GameObject + && stores + .get(entity) + .is_ok_and(|s| crate::go_anim::go_animates(s.0.gameobject_type_id())); let skin: Option = match (net.kind, dm) { // A creature (or player body — decision 0041) with a real skeleton. The `!is_empty` // guard keeps a degenerate boneless model on the static mesh (its skinned twin would @@ -479,15 +497,7 @@ pub(super) fn attach_entity_visuals( commands.entity(entity).add_child(node); joints_root = node; } - setup_skinned_instance( - &mut commands, - &mut palettes, - entity, - joints_root, - d, - net.kind, - false, - ) + setup_skinned_instance(&mut commands, &mut palettes, entity, joints_root, d) } // A GameObject whose model authors a real skeleton + animation draws through the // skinned twin like a creature. Two flavours share the rig: a door/button/chest @@ -501,29 +511,42 @@ pub(super) fn attach_entity_visuals( (EntityKind::GameObject, Some(d)) if !d.skeleton.joints.is_empty() && d.animations.is_some() => { - let state_machine = stores - .get(entity) - .is_ok_and(|s| crate::go_anim::go_animates(s.0.gameobject_type_id())); + let state_machine = go_state_machine; let ambient = !matches!( crate::doodad_anim::classify(&d.skeleton, d.animations.as_ref()), crate::doodad_anim::DoodadAnimTier::Static ); - (state_machine || ambient) + // A state GO whose model poses NO bone in any sequence gets no rig: skinning + // it could only reproduce the bind-pose mesh it already renders, at the cost + // of a scarce palette slot (decision 0941 — 147 display models are this + // shape). It still gets the clock below, which is the half it was missing. + let poses = d + .animations + .as_ref() + .is_some_and(|a| a.clips.iter().any(|c| c.poses_bones)); + ((state_machine && poses) || ambient) .then(|| { - setup_skinned_instance( - &mut commands, - &mut palettes, - entity, - entity, - d, - net.kind, - state_machine, - ) + setup_skinned_instance(&mut commands, &mut palettes, entity, entity, d) }) .flatten() } _ => None, }; + // The instance's SEQUENCE CLOCK — armed for every M2 instance whose model has one, + // rigged or not (decision 0941). The rig above is a palette slot and a skinned twin, + // and is worth spending only when bones actually move; the clock is a player and a + // handle, and everything per-sequence reads it — the emitters' rate/enable/params + // tracks, the material alpha/colour/UV loops, the GameObject state arm. Bundling the + // two is what left the boneless-but-animated content (the Molten Core rune's flames, + // the flame ring's spreading spawn window) reading file slot 0 at t = 0 for ever. + if matches!( + net.kind, + EntityKind::Unit | EntityKind::Player | EntityKind::GameObject + ) { + if let Some(anims) = dm.and_then(|d| d.animations.as_ref()) { + arm_sequence_clock(&mut commands, entity, anims, net.kind, go_state_machine); + } + } // The bone-riding surface (decision 0072): the instance's joints + the model's attachment // points, so held items (and future bone riders) can hang from the hand/hip/back joints. if let (Some(rb), Some(d)) = (&skin, dm) { diff --git a/crates/benilla/src/entities/equipment/spawn.rs b/crates/benilla/src/entities/equipment/spawn.rs index 43f9ef0e..f7f8c4cc 100644 --- a/crates/benilla/src/entities/equipment/spawn.rs +++ b/crates/benilla/src/entities/equipment/spawn.rs @@ -722,13 +722,13 @@ mod tests { parent: -1, local_translation: Vec3::ZERO, billboard: None, - ignore_parent_rotation: false, + parent_arm: None, }, benilla_assets::ModelJoint { parent: 0, local_translation: Vec3::Y, billboard: Some(benilla_formats::BillboardKind::Spherical), - ignore_parent_rotation: false, + parent_arm: None, }, ], spine_bone: None, diff --git a/crates/benilla/src/entities/mount/tests.rs b/crates/benilla/src/entities/mount/tests.rs index ca8f51e8..1b97a780 100644 --- a/crates/benilla/src/entities/mount/tests.rs +++ b/crates/benilla/src/entities/mount/tests.rs @@ -40,7 +40,7 @@ fn rig_at(frame: Entity, anchor: Entity) -> RigPose { parent: -1, local_translation: Vec3::ZERO, billboard: None, - ignore_parent_rotation: false, + parent_arm: None, }], ..Default::default() }; diff --git a/crates/benilla/src/entities/spell_fx/lifecycle.rs b/crates/benilla/src/entities/spell_fx/lifecycle.rs index 216d1de2..466d5d5c 100644 --- a/crates/benilla/src/entities/spell_fx/lifecycle.rs +++ b/crates/benilla/src/entities/spell_fx/lifecycle.rs @@ -242,6 +242,7 @@ mod tests { upper_node: None, frequency: 0, replay: (0, 0), + poses_bones: true, } } diff --git a/crates/benilla/src/net/apply.rs b/crates/benilla/src/net/apply.rs index 18e5681d..035857b3 100644 --- a/crates/benilla/src/net/apply.rs +++ b/crates/benilla/src/net/apply.rs @@ -645,6 +645,7 @@ pub(super) fn apply_net_updates( &mut ui_actions.15, &mut ui_actions.10, &mut ui_actions.12, + ui_actions.11.as_deref(), &net_commands, play_seq.next(), ), diff --git a/crates/benilla/src/net/apply/spells.rs b/crates/benilla/src/net/apply/spells.rs index 5e9b420f..cf3ec871 100644 --- a/crates/benilla/src/net/apply/spells.rs +++ b/crates/benilla/src/net/apply/spells.rs @@ -88,6 +88,7 @@ pub(super) fn cast_result( queued_melee: &mut QueuedMeleeSpell, cooldowns: &mut Cooldowns, auto_repeat: &mut AutoRepeatActive, + spells: Option<&Spells>, net: &crate::net::NetCommands, seq: u64, ) { @@ -107,7 +108,24 @@ pub(super) fn cast_result( // deselect/interrupt surfaces solely as this CAST_RESULT failure // (`HandleSetSelectionOpcode` → `Spell::cancel` → `SendCastResult(INTERRUPTED)`). let now = Instant::now(); - cooldowns.clear_gcd(spell_id, now); + // Reason 0x17 DONT_REPORT exits the ref's handler at a bare epilogue BEFORE the GCD + // clear and the display (`6e1ce1`/`6e1cf7 → 0x6e224f` — 0948's C4): a silent server + // abort neither reopens the GCD nor prints. Our in-flight/bar bookkeeping below still + // runs (vmangos uses DONT_REPORT for real aborts whose guard must open). + let dont_report = reason == Some(0x17); + if !dont_report { + cooldowns.clear_gcd(spell_id, now); + // The handler tail's bit25 full revert (`6e73cc–6e73e6`): a non-0x3c failure of a + // cooldown-on-event spell force-removes its records — the parked insert a failed + // Feign Death / Stealth would otherwise leave behind forever. + if reason != Some(0x3c) + && spells + .and_then(|s| s.catalog.get(spell_id)) + .is_some_and(|d| d.cooldown_on_event()) + { + cooldowns.clear_spell(spell_id); + } + } let self_e = self_guid.0.and_then(|g| index.0.get(&g)).copied(); if auto_repeat.0 == Some(spell_id) && reason != Some(0x17) { crate::creature_anim::cancel_auto_repeat_local(self_e, auto_repeat, commands, net); @@ -130,7 +148,9 @@ pub(super) fn cast_result( // The red error line rides `CastErrors` independently of the bar — a pre-start failure // still shows "out of range"/"line of sight" even though no bar is (or should be) up. if let Some(reason) = reason { - cast_errors.0.push((spell_id, reason)); + if !dont_report { + cast_errors.0.push((spell_id, reason)); + } } // The cast bar's red "Failed" — only the showing cast's own failure turns it red. if fails_our_cast { @@ -909,6 +929,7 @@ mod tests { &mut queued_melee, &mut cooldowns, &mut auto_repeat, + None, &net, 1, ); diff --git a/crates/benilla/src/particles.rs b/crates/benilla/src/particles.rs index 9e726163..c31cd570 100644 --- a/crates/benilla/src/particles.rs +++ b/crates/benilla/src/particles.rs @@ -30,7 +30,9 @@ use bevy::prelude::*; pub mod buffer; pub(crate) mod census; mod depthdump; +mod dumps; mod emit; +mod emitdump; mod model; mod quads; pub mod render; diff --git a/crates/benilla/src/particles/dumps.rs b/crates/benilla/src/particles/dumps.rs new file mode 100644 index 00000000..8a8ee7b3 --- /dev/null +++ b/crates/benilla/src/particles/dumps.rs @@ -0,0 +1,26 @@ +//! The particle lane's debug-instrument system plumbing, bundled into one parameter. +//! +//! `simulate_particles` sits exactly at Bevy's 16-system-parameter ceiling, and the lane carries +//! two optional instruments — [`super::depthdump`] (`$WOW_PARTICLE_DEPTHDUMP`, the depth numbers +//! a pool brings to the compare) and [`super::emitdump`] (`$WOW_EMIT_DUMP`, what the emission +//! front end decided) — each of which would otherwise spend a parameter or two of its own. That +//! ceiling should never be the reason an instrument doesn't get built (0022), so the +//! pair shares one: both stay inert without their env, and the sim reads them through here. + +use bevy::ecs::system::SystemParam; +use bevy::prelude::*; + +#[derive(SystemParam)] +pub(super) struct Dumps<'w, 's> { + /// `$WOW_PARTICLE_DEPTHDUMP`'s frame counter. + depth_frames: Local<'s, u32>, + /// `$WOW_EMIT_DUMP`'s label lookup and period clock. + pub(super) emit: super::emitdump::EmitDump<'w, 's>, +} + +impl Dumps<'_, '_> { + /// `$WOW_PARTICLE_DEPTHDUMP`: this frame's dump index, if it is a dump frame at all. + pub(super) fn depth_frame(&mut self, now: f32) -> Option { + super::depthdump::frame(now, &mut self.depth_frames) + } +} diff --git a/crates/benilla/src/particles/emitdump.rs b/crates/benilla/src/particles/emitdump.rs new file mode 100644 index 00000000..ea947798 --- /dev/null +++ b/crates/benilla/src/particles/emitdump.rs @@ -0,0 +1,137 @@ +//! `$WOW_EMIT_DUMP` — what the emission front end **decided**, per emitter, for one model. +//! +//! The particle lane already has an asset-side dump (`benilla-extract m2part` — every authored +//! field of every record) and a draw-side one ([`super::depthdump`] — the depth numbers a pool +//! brings to the compare). The gap between them is where "this effect looks wrong" reports +//! actually live: *which* sequence slot the emitter resolved, what the ten per-frame tracks +//! sampled at that slot, and how many particles that produced. A model whose rate track keys +//! `0` in one sequence and `526/s` in another looks identical in the asset dump and in the draw +//! dump — only the resolved slot says which of the two is on screen. +//! +//! `WOW_EMIT_DUMP=[,]` — every `period` seconds (default 2), +//! print one line per live emitter whose owning model's [`crate::interact::WorldObject`] label +//! contains the substring, case-insensitively. That label is the model path for doodads, WMO +//! props and GameObjects and the unit name for creatures — the same string the hover inspector +//! shows, so the filter is copied straight off the panel that prompted the question. +//! +//! Scoping by label is what keeps it usable in a populated scene: a Molten Core frame ticks +//! hundreds of emitters, and an unfiltered per-emitter dump both floods the log and costs the +//! framerate of the run it is measuring. + +use bevy::ecs::system::SystemParam; +use bevy::prelude::*; + +use benilla_formats::{ParamsNow, ParticleEmitterDef}; + +/// Parsed `$WOW_EMIT_DUMP`: `(lowercased label substring, period seconds)`. `None` = off, and +/// every entry point below then does nothing at all. +static FILTER: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + let v = std::env::var("WOW_EMIT_DUMP").ok()?; + // Only a trailing NUMBER is a period — a model path may itself contain a comma. + let (label, period) = match v + .rsplit_once(',') + .map(|(l, p)| (l, p.trim().parse::())) + { + Some((l, Ok(p))) => (l, p), + _ => (v.as_str(), 2.0), + }; + Some((label.trim().to_ascii_lowercase(), period.max(0.1))) +}); + +/// The instrument's own system plumbing: the label lookup it filters by and its period clock. +/// One [`SystemParam`] rather than two loose arguments — `simulate_particles` sits at Bevy's +/// 16-parameter ceiling, and a debug affordance has no business spending two of them. +#[derive(SystemParam)] +pub(super) struct EmitDump<'w, 's> { + /// The owning model's inspector identity. It rides the **drawn parts**, not the entity the + /// emitter hosts on (the picker hovers a mesh, so that is where `attach::dress` puts it) — + /// hence the child walk in [`EmitDump::label`]. Looking only at the owner is what made this + /// instrument's first outing print 10822 unlabelled lines. + labels: Query<'w, 's, &'static crate::interact::WorldObject>, + parts: Query<'w, 's, &'static Children>, + last: Local<'s, f32>, +} + +impl EmitDump<'_, '_> { + /// Is this frame a dump tick? Advances the period clock when it is. Always `false` without + /// the env, so the per-emitter call site costs one atomic load per frame. + pub(super) fn due(&mut self, now: f32) -> bool { + let Some((_, period)) = FILTER.as_ref() else { + return false; + }; + if now - *self.last < *period { + return false; + } + *self.last = now; + true + } + + /// This emitter's model identity: the owner's own [`crate::interact::WorldObject`] if it has + /// one, else the first drawn part under it that does — a GameObject/unit carries its label on + /// the submesh entities, and the emitter hosts on the root above them. + fn label(&self, owner: Option) -> &str { + let Some(e) = owner else { return "" }; + if let Ok(o) = self.labels.get(e) { + return o.label.as_str(); + } + self.parts + .get(e) + .ok() + .and_then(|kids| kids.iter().find_map(|k| self.labels.get(k).ok())) + .map_or("", |o| o.label.as_str()) + } + + /// Print one emitter's decision line, if its owning model matches the filter. + pub(super) fn dump(&self, owner: Option, d: &Decision<'_>) { + let Some((want, _)) = FILTER.as_ref() else { + return; + }; + let label = self.label(owner); + if !label.to_ascii_lowercase().contains(want.as_str()) { + return; + } + let n = d.now; + println!( + "emit {label} bone {bone:<3} seq {seq} t={t:.3}s · rate {rate:.2}/s {gate} · live \ + {live:<4} · life {life:.2} speed {speed:.3}±{var:.2} lat {lat:.3} lon {lon:.3} \ + grav {grav:.2} area {al:.3}x{aw:.3} · size {s0:.3}/{s1:.3}/{s2:.3} · at \ + [{x:.2},{y:.2},{z:.2}]", + bone = d.def.bone, + seq = d.seq.map_or("-".to_string(), |s| s.to_string()), + t = d.elapsed, + rate = d.rate, + gate = if d.emitting { "ON " } else { "off" }, + live = d.live, + life = n.lifespan, + speed = n.emission_speed, + var = n.speed_variation, + lat = n.vertical_range, + lon = n.horizontal_range, + grav = n.gravity, + al = n.area_length, + aw = n.area_width, + s0 = d.def.over_life.scale[0], + s1 = d.def.over_life.scale[1], + s2 = d.def.over_life.scale[2], + x = d.at.x, + y = d.at.y, + z = d.at.z, + ); + } +} + +/// One emitter's decision this frame — the sim's live values, not the authored ones. +pub(super) struct Decision<'a> { + pub(super) def: &'a ParticleEmitterDef, + /// The sequence FILE slot the rate/gate/params tracks resolved to (`None` = degraded to 0). + pub(super) seq: Option, + /// Seconds into that slot's baked loop. + pub(super) elapsed: f32, + pub(super) rate: f32, + pub(super) emitting: bool, + pub(super) live: usize, + pub(super) now: &'a ParamsNow, + /// The emitter origin's live world position — the number that says whether a model's + /// emitters are spread across it or collapsed onto one point. + pub(super) at: Vec3, +} diff --git a/crates/benilla/src/particles/model.rs b/crates/benilla/src/particles/model.rs index 2e9913d8..464e1d25 100644 --- a/crates/benilla/src/particles/model.rs +++ b/crates/benilla/src/particles/model.rs @@ -123,7 +123,7 @@ pub(super) fn update_model_particles( // move one number. These instance materials are per-instance throwaways // (their `tint` is already mutated every frame), so nothing else sees it. // - // BUCKETED, and transparent-pass only (decision 0937). On a material the rung + // BUCKETED, and transparent-pass only (decision 0945). On a material the rung // is also a *pipeline-key axis* (bevy folds `depth_bias as i32` into the key // — 0837's law), and 32 integer rungs × every blend state is an open key // space no warm pass can pre-compile: each first-seen combination was a diff --git a/crates/benilla/src/particles/sim.rs b/crates/benilla/src/particles/sim.rs index 5bcc5d4f..c2ec80b8 100644 --- a/crates/benilla/src/particles/sim.rs +++ b/crates/benilla/src/particles/sim.rs @@ -375,8 +375,9 @@ pub(super) fn simulate_particles( // `AnimationPlayer` names the playing sequence + clip time, same resolution as the // material-alpha sampler (`doodad_anim::playing_seq`). hosts: Query<(&AnimationPlayer, &benilla_assets::ModelAnimations)>, - // `$WOW_PARTICLE_DEPTHDUMP`'s frame counter (see [`super::depthdump`]); inert without the env. - mut dump_count: Local, + // The lane's two debug instruments' plumbing, sharing one parameter (see [`super::dumps`]): + // `$WOW_PARTICLE_DEPTHDUMP` and `$WOW_EMIT_DUMP`. Both inert without their env. + mut dumps: super::dumps::Dumps, ) { let Ok((world_cam, cam_tf, frustum, camera, projection)) = cam.single() else { return; @@ -403,7 +404,9 @@ pub(super) fn simulate_particles( ); let camera_instance = gates.camera_claim.0.map(|c| c.room.instance); // `$WOW_PARTICLE_DEPTHDUMP` (B16): is this a dump frame? Decided once per run. - let dump_frame = super::depthdump::frame(time.elapsed_secs(), &mut dump_count); + let dump_frame = dumps.depth_frame(time.elapsed_secs()); + // `$WOW_EMIT_DUMP`: is this a dump tick? Decided once per frame, for the whole walk. + let emit_dump = dumps.emit.due(time.elapsed_secs()); for (entity, mut emitter, mut entity_tf, mut entity_global, fade, layers, light_override) in &mut emitters @@ -653,6 +656,9 @@ pub(super) fn simulate_particles( None if owner.is_none() => *anchor_pos = placement.translation, None => {} // joint-owned, unanchored (placed doodads): the spawn placement stands } + // `$WOW_EMIT_DUMP`'s subject: the model this cloud belongs to, captured here because + // `anchor` is shadowed by the draw anchor further down. + let dump_owner = (*host).or(*anchor); // 0b. The EMITTER-MOTION terms (wow-re `part-emitter-motion.md`, byte-verified): both // feed off the emitter origin's one-frame live world Δ. prevPos refreshes EVERY @@ -1041,6 +1047,24 @@ pub(super) fn simulate_particles( ); } } + // `$WOW_EMIT_DUMP`: what this emitter's front end decided this frame — the resolved + // sequence slot and what the ten tracks sampled at it. Placed here so `live` is the + // count AFTER this frame's births, i.e. what the draw below actually consumes. + if emit_dump { + dumps.emit.dump( + dump_owner, + &super::emitdump::Decision { + def, + seq: clock_seq, + elapsed: elapsed_s, + rate, + emitting, + live: particles.len(), + now: &now, + at: emitter_world, + }, + ); + } quads.commit_quads( start, EffectDrawSpec { diff --git a/crates/benilla/src/pipe_warm/menagerie.rs b/crates/benilla/src/pipe_warm/menagerie.rs index 99741a77..6062339b 100644 --- a/crates/benilla/src/pipe_warm/menagerie.rs +++ b/crates/benilla/src/pipe_warm/menagerie.rs @@ -73,7 +73,7 @@ pub(super) type BoothCamQuery<'w, 's> = Query< >; /// Spawn one tiny quad per reachable pipeline variant — the model lane, its shard-rung and -/// far-side-of-water twins, and the sky/water lanes (decision 0937 widened 0837's model-only +/// far-side-of-water twins, and the sky/water lanes (decision 0945 widened 0837's model-only /// scope) — parented to the world camera. Materials come from the PRODUCTION builders /// (`model_material` / `zfill_material` / `far_twin_of`) or the PRODUCTION live asset stores /// (celestial, stars, clouds, gradient dome, liquid — all populated at `Startup`), so the @@ -239,7 +239,7 @@ pub(super) fn spawn_menagerie( } } - // The shard-rung rows (decision 0937). A 3-D model particle's instance material carries its + // The shard-rung rows (decision 0945). A 3-D model particle's instance material carries its // owner-last rung in `depth_bias`, which is ALSO a pipeline-key axis (0837's law) — so the // runtime stamps only the closed bucket set (`owner_last_rung_bucket`), and this table // compiles that set. Families are the corpus census's floor plus the depth-write axis @@ -284,7 +284,7 @@ pub(super) fn spawn_menagerie( } } - // The far-side-of-water twins (decision 0937). `classify_water_side` swaps every transparent + // The far-side-of-water twins (decision 0945). `classify_water_side` swaps every transparent // model material for its `far_twin_of` — a DISTINCT pipeline key (the far marker bit + the // shifted bias integer) that the cache never dedups against the near one even though // `specialize` makes the descriptors byte-identical. Unwarmed, the first eye-and-model @@ -327,7 +327,7 @@ pub(super) fn spawn_menagerie( } } - // The sky and water lanes (decision 0937 — 0837's scope was model-lane-only, and every hole + // The sky and water lanes (decision 0945 — 0837's scope was model-lane-only, and every hole // was a director-felt stall: the sun disc first drawn on stepping outdoors, the first water // in view, a spell's shards mid-cast). Every material that EXISTS in these Startup-populated // stores gets a rig with its production mesh layout — iterating the store can't drift from diff --git a/crates/benilla/src/pipe_warm/mod.rs b/crates/benilla/src/pipe_warm/mod.rs index a05d0820..27479683 100644 --- a/crates/benilla/src/pipe_warm/mod.rs +++ b/crates/benilla/src/pipe_warm/mod.rs @@ -15,7 +15,7 @@ //! //! - [`WarmPass`] + `spawn_menagerie` — the warm pass: one tiny rig per reachable pipeline //! variant — the model lane with its shard-rung and far-side twins, and the sky/water lanes -//! (celestial, stars, clouds, gradient dome, WMO skybox, liquid; decision 0937 widened 0837's +//! (celestial, stars, clouds, gradient dome, WMO skybox, liquid; decision 0945 widened 0837's //! model-only scope) — parented to the world camera, spawned when the entry cover rises; the //! loading screen's clear condition holds on [`WarmPass::satisfied`] until the pipeline cache //! drains (10 s backstop, 0737's rule), then the menagerie despawns. Captures skip it. diff --git a/crates/benilla/src/target/lock.rs b/crates/benilla/src/target/lock.rs index f6546d16..ad8e6651 100644 --- a/crates/benilla/src/target/lock.rs +++ b/crates/benilla/src/target/lock.rs @@ -187,6 +187,50 @@ pub(crate) fn resolve_lock( } } +/// **`0x5f8260`** — the *targeting cursor's* lock question, and this chain's **third** consumer +/// (decision 0949). Reached from the object dispatcher `0x4828d0`'s targeting step +/// (`0x482910 → 0x6e6460`, whose GameObject arm calls it at `6e670f` with the GO, the cast-item +/// guid `0xceac48` and the pending spell id `[0xceac58]`). +/// +/// Where [`resolve_lock`] (`0x5f83d0`) asks *"can I open this with anything I have?"*, this asks +/// the narrower *"does **this pending spell** open **this lock**?"*. Three differences, all in the +/// bytes: +/// +/// - It walks the **spell's** three effect slots, not the player's known spells — `5f82c0`'s outer +/// loop over `SpellRec+0xf4/f8/fc` with `cmp …, 0x21` (`SPELL_EFFECT_OPEN_LOCK`), matching +/// `EffectMiscValue[k]` (`[ebx+0xb4]`) against the slot's `Index[i]`. +/// - **It never compares skill values.** `0x5f83d0`'s satisfaction test (`0x5f850f`, +/// [`required_skill`]) has no counterpart here. A matched `LockType` that passes the Action gate +/// is the whole predicate — so the cursor lights on a chest your skill cannot yet open, the +/// click sends, and the *server* refuses. That is the same "no gate on the object leg" 0939 +/// found at `BindTarget`, and it is why this is not simply [`resolve_lock`] reused. +/// - A GameObject with **no `Lock.dbc` row is `false`**, not "open" (`0x5f8180` null → `5f8273` +/// returns 0) — the opposite of [`resolve_lock`]'s lockless arm, and correct: you cannot Pick +/// Lock a mailbox, and the cursor greys over one. +/// +/// The Action gate ([`LockSlot::available`]) is shared with both siblings — same `0x5f81d0`, so +/// the cursor, the tooltip and the click cannot disagree about whether a slot applies. +/// +/// **Not transcribed:** the `SPELL_EFFECT_OPEN_LOCK_ITEM` (59) arm at `5f831c`, which matches the +/// **cast item's** entry against a `LOCK_KEY_ITEM` slot (`5f8360`: `Type[i] == 1`, `5f836c`: the +/// item's entry `== Index[i]`, then the same gate). It needs a per-effect id the catalog does not +/// carry yet, and it only fires for a key's own ON_USE spell armed at a GameObject — a seam 0939 +/// built but no 5875 key is known to use. Named residual, decision 0949. +pub(crate) fn spell_opens_lock( + slots: &[LockSlot], + spell: &benilla_formats::SpellDisplay, + go: GoFacts, +) -> bool { + let Some(lock_type) = spell.open_lock_type() else { + return false; + }; + slots.iter().any(|slot| { + slot.key_type == LOCK_KEY_SKILL + && slot.index == lock_type + && slot.available(go.state, go.flag_locked) + }) +} + /// The slot's required skill: `Skill[i]`, or **`GAMEOBJECT_LEVEL × 5`** when it is zero /// (`0x5f84be..0x5f84ca` — the resolver; `0x5f3490..0x5f349f` recomputes the same value for the /// `0xe0` toast's `%d`). A zero `Skill` is *not* "no requirement": every gathering node in the game @@ -228,6 +272,87 @@ mod tests { } } + /// **`0x5f8260` is not `0x5f83d0`** (decision 0949). The cursor's question differs from the + /// right-click resolver's in three ways that all show on screen, and getting any of them + /// wrong greys the wrong chest. + #[test] + fn the_cursor_lock_predicate_ignores_skill_and_refuses_the_lockless() { + use benilla_formats::SpellDisplay; + // Pick Lock: LockType 1 (Lockpicking), providing 300 at level 60. + let pick_lock = SpellDisplay { + open_lock: Some(benilla_formats::OpenLock { + effect: 0, + lock_type: 1, + }), + ..SpellDisplay::default() + }; + let unlocked = GoFacts { + state: GO_STATE_READY, + flag_locked: true, + level: 60, + }; + + // A Lockpicking slot demanding 300 skill, Action 1 (applies while flagged locked). + let matching = [skill_slot(1, 300, 1), LockSlot::default()]; + assert!( + spell_opens_lock(&matching, &pick_lock, unlocked), + "the right LockType through an applicable slot lights the cursor" + ); + + // **The skill value is never read** — `0x5f850f` has no counterpart in `0x5f8260`. A slot + // demanding far more than Pick Lock provides STILL lights: the click sends and the server + // refuses. This is the single biggest difference from `resolve_lock`. + let brutal = [skill_slot(1, 9999, 1), LockSlot::default()]; + assert!( + spell_opens_lock(&brutal, &pick_lock, unlocked), + "0x5f8260 compares no skill values — an out-of-reach lock still lights" + ); + + // Wrong LockType (3 = Mining) — a lockpick greys over an ore vein. + let mining = [skill_slot(3, 0, 1), LockSlot::default()]; + assert!(!spell_opens_lock(&mining, &pick_lock, unlocked)); + + // The shared Action gate still applies: Action 0 means "only when NOT flagged locked". + assert!(!spell_opens_lock( + &[skill_slot(1, 0, 0)], + &pick_lock, + unlocked + )); + assert!(spell_opens_lock( + &[skill_slot(1, 0, 0)], + &pick_lock, + GoFacts { + flag_locked: false, + ..unlocked + } + )); + + // **No lock row at all is FALSE, not "open"** (`0x5f8180` null → `5f8273`) — the exact + // opposite of `resolve_lock`'s lockless arm. You cannot Pick Lock a mailbox. + assert!(!spell_opens_lock(&[], &pick_lock, unlocked)); + assert!(!spell_opens_lock( + &[LockSlot::default()], + &pick_lock, + unlocked + )); + + // A spell with no OPEN_LOCK effect opens nothing, whatever the lock says. + assert!(!spell_opens_lock( + &matching, + &SpellDisplay::default(), + unlocked + )); + + // A KEY slot is not the SKILL arm's business (the effect-59 arm is a named residual). + let key = [LockSlot { + key_type: LOCK_KEY_ITEM, + index: 1, + skill: 0, + action: 1, + }]; + assert!(!spell_opens_lock(&key, &pick_lock, unlocked)); + } + /// The requirement fallback (`0x5f84be`): a zero `Skill[i]` means GO-level × 5, not "free". #[test] fn zero_skill_falls_back_to_go_level_times_five() { diff --git a/crates/benilla/src/target/reticle.rs b/crates/benilla/src/target/reticle.rs index e0a2930e..5b7de6ae 100644 --- a/crates/benilla/src/target/reticle.rs +++ b/crates/benilla/src/target/reticle.rs @@ -1,5 +1,8 @@ -//! The **ground-targeting AoE reticle** — the terrain-projected decal the targeting cursor -//! drags across the world (decision 0797; byte-pinned in wow-re `ground-target-reticle.md`). +//! The **ground-targeting AoE reticle** — the terrain-projected decal a **location** cast's +//! cursor drags across the world (decisions 0797 / 0943; byte-pinned in wow-re +//! `ground-target-reticle.md`). Only a word that passes `TargetingWantsLocation`'s `& 0x60` has +//! one: the other two seams (a bag click, a world GameObject click) arm the same cursor and draw +//! no decal at all — see [`update_reticle`]'s guard. //! //! For every ordinary area spell (Blizzard, Flamestrike — no object-placement effect), the //! reference draws a **projected decal**, not a model: box = the picked ground point ± r in the @@ -37,7 +40,7 @@ use crate::net::{ObjectStore, SelfPlayer}; use crate::particles::buffer::{EffectBlend, EffectDrawSpec, EffectFog, EffectQuads, EffectVertex}; use crate::player::WorldCamera; use crate::target::{PickOcclusion, WorldCursor}; -use crate::ui_action::{ground_cast_radius, SpellTargeting, Spells}; +use crate::ui_action::{ground_cast_radius, SpellTargeting, Spells, TargetingWants}; /// The default footprint when the radius is 0 — out of range, or a rowless spell /// (`[0xb4b3b0] == 0.0 → 1.3888889`, the ref's literal). @@ -83,10 +86,21 @@ pub(super) fn setup_reticle(mut commands: Commands, asset_server: Res(); } -/// Place/size/state the reticle each frame while the targeting cursor is up. Runs after the -/// targeting cursor drive in the target chain: `WorldCursor.unable` IS the frame's range -/// verdict (the one `CheckGroundPointInRange` caller — the cursor and the decal state are the -/// same read in the ref). +/// Place/size/state the reticle each frame while a **location** cast awaits its click. Runs +/// after the targeting cursor drive in the target chain: `WorldCursor.unable` IS the frame's +/// range verdict (the one `CheckGroundPointInRange` caller — the cursor and the decal state are +/// the same read in the ref). +/// +/// **Both of `0x4820f0`'s guards, in its order** (decision 0943): `4820f9 IsTargeting 0x6e48a0` +/// **and** `482106 TargetingWantsLocation 0x6e6320` — either false and it returns having never +/// touched the draw state, which the hover handler already reset to `3` = *do not draw* +/// (`481840`, every pass). The *word's* mask decides, not the mere fact of targeting: a lock or +/// an enchant word (`0x4000` / `0x0010`) fails `& 0x60`, so no decal is drawn for one. The +/// reference does not even reach here for such a word — `0x481050`'s targeting arm builds the +/// pick flags from the word alone, and one without `0x60` gets no bit `0x1`, so the pick reports +/// state 2 (object → `0x4828d0`) or 0, never state 1. Our picks are not word-gated, so this +/// consumer carries the guard; asking [`SpellTargeting::spell_for`] rather than `spell()` is what +/// keeps the next seam from forgetting it. pub(super) fn update_reticle( targeting: Res, occlusion: Res, @@ -97,9 +111,12 @@ pub(super) fn update_reticle( mut state: ResMut, ) { let state = &mut *state; - let (Some(spell_id), Some(point)) = (targeting.spell(), occlusion.point) else { - // Not targeting, or the pick hit nothing (sky): nothing is drawn — the ref resets the - // draw state on every hover pass before the pick. + let (Some(spell_id), Some(point)) = ( + targeting.spell_for(TargetingWants::Location), + occlusion.point, + ) else { + // Not targeting, targeting a word that wants no location, or the pick hit nothing (sky): + // nothing is drawn — the ref resets the draw state on every hover pass before the pick. state.shown = false; return; }; @@ -197,3 +214,64 @@ pub(super) fn push_reticle( }, ); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui_action::CastCommit; + use bevy::ecs::system::RunSystemOnce; + + /// **`0x4820f0`'s second guard** (decision 0943). One armed cursor, three seams, one decal: + /// only a word that answers `TargetingWantsLocation`'s `& 0x60` may draw. Before this, the + /// reticle asked `IsTargeting` alone, so arming Pick Lock (`0x4000`), Opening (`0x4800`) or an + /// enchant (`0x0010`) dropped a green AoE circle on the ground under a cursor whose click is a + /// hover-pick — director-reported, and true of the item seam since 0928. + /// + /// The ground is a real trimesh so "nothing drawn" is a verdict and not an empty scene: the + /// same fixture with a DEST word must draw, or this test proves nothing. + #[test] + fn only_a_location_word_draws_a_decal() { + let drawn_for = |word: u16| { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.insert_resource(PickOcclusion { + distance: 10.0, + point: Some(Vec3::ZERO), + }); + world + .resource_mut::() + .enter(2120, CastCommit::Spell, word); + // A flat 100×100 yd ground quad at y = 0, world-space verts (the marked colliders' + // identity-pose contract) — inside the ±2.0 slab and wide enough for any radius. + let q = 50.0; + world.spawn(( + GroundDecalSurface, + Collider::trimesh( + vec![ + Vec3::new(-q, 0.0, -q), + Vec3::new(q, 0.0, -q), + Vec3::new(q, 0.0, q), + Vec3::new(-q, 0.0, q), + ], + vec![[0, 1, 2], [0, 2, 3]], + ), + )); + world.run_system_once(update_reticle).unwrap(); + world.resource::().shown + }; + + // Blizzard's bare DEST word — the seam the decal belongs to. + assert!(drawn_for(0x0040), "a DEST word draws its reticle"); + // SOURCE|DEST is still `& 0x60`. + assert!(drawn_for(0x0060), "a SOURCE|DEST word draws its reticle"); + // The three words the director saw a grenade circle under. + for word in [0x4000, 0x4800, 0x0010, 0x0800] { + assert!( + !drawn_for(word), + "word {word:#06x} wants no location — no decal" + ); + } + } +} diff --git a/crates/benilla/src/ui_action/cast_send.rs b/crates/benilla/src/ui_action/cast_send.rs index ed32e0cd..ce243ffd 100644 --- a/crates/benilla/src/ui_action/cast_send.rs +++ b/crates/benilla/src/ui_action/cast_send.rs @@ -4,11 +4,12 @@ //! the trade-skill window and the craft window all funnel here too. That is the point: the client's //! `TryCast 0x6e4b60` → commit `0x6e54f0` is *one* function with a long ladder of local gates and a //! post-send tail, and duplicating any part of it per caller is how the two paths drift. The ladder -//! below is that function's order, gate for gate — profession intercept, auto-repeat toggle, -//! cooldown, GCD, in-flight guard, mounted, moving, form, reagents/totems, target binding, -//! range — followed by -//! the commit's own tail (ranged stance, auto-repeat arm, the send, the auto-attack start, the GCD -//! arm). +//! below is that function's order, gate for gate (re-pinned end to end by the 0948 §5, +//! `gcd-power-gate.md`) — profession intercept, auto-repeat toggle, targeting abort, in-flight, +//! reagents/totems, target binding + range, then the validator `0x6094f0`'s opening rungs +//! (not-ready/GCD, power), mounted, moving, form, the deferred targeting-cursor entry — followed +//! by the commit's own tail (ranged stance, auto-repeat arm, the send, the auto-attack start, the +//! GCD arm). //! //! A refusal here is **local and pre-commit**, exactly like the reference's: no packet, no GCD, no //! pending arm, no autorepeat key — just the red error line's reason code. @@ -20,10 +21,12 @@ //! computes a display flag and does *not* skip the IsCasting gate below it, and at `6e4f33`, where //! it is forwarded to the requirement validator `0x6094f0`. Every rung between entry and the //! commit is therefore the same code for a spell and for an item, which is why [`CastCommit`] is a -//! *parameter* of this function and not a second path. Two rungs fork on it, and only two: the -//! validator's first rung (`60952b`: item → the item cooldown query `0x6e2ed0` and error **0x28** -//! "Item is not ready yet."; no item → `0x6e2ea0` and **0x3c** "Spell is not ready yet.") and the -//! commit's opcode (`0x6e57d8 push 0xab` vs `push 0x12e`). +//! *parameter* of this function and not a second path. Three rungs fork on it, and only three +//! (`gcd-power-gate.md` §1): the validator's first rung (`60952b`: item → the item cooldown query +//! `0x6e2ed0` on the (use-spell, ENTRY) pair and error **0x28** "Item is not ready yet."; no item +//! → `0x6e2ea0` and **0x3c** "Spell is not ready yet."), the power gate (`0x60962c` — an item +//! press's clear-query jump lands PAST it: items are never power-gated), and the commit's opcode +//! (`0x6e57d8 push 0xab` vs `push 0x12e`). use std::time::Instant; @@ -50,6 +53,9 @@ pub(crate) enum CastCommit { Item { bag_index: u8, slot: u8, + /// The item's template ENTRY — the not-ready rung's item-leg query key (the store's + /// `(use_spell, itemID)` match; decision 0948, closing the entry gap 0914 named). + entry: u32, spell_index: u8, /// The key-in-a-lock arm (decision 0769) — `CGItem::Use`'s own target argument, non-zero /// only for the lock chain. A caller-bound guid short-circuits the binder below, exactly @@ -264,30 +270,6 @@ fn send_spell_cast( debug!("ui_action: cast {spell_id} supersedes the targeting cursor"); ground.clear(); } - // The local not-ready refusal (the client's `IsSpellOnCooldown 0x6e1690` gate in the cast - // path): a spell/category cooldown refuses at the source with the client's own reason 0x3c - // ("Spell is not ready yet.") — never sent, exactly like the real client. The GCD is - // faithfully NOT part of this test (0x6e1690 skips the GCD fields). - // …and the leg that forks on item-present, verbatim: the validator branches at `60952b` on - // its item argument — item → the item cooldown query `0x6e2ed0` and reason **0x28** "Item is - // not ready yet." (`609549`), no item → `0x6e2ea0` and **0x3c** (`609616`). (Ours keys one - // cooldown store by spell id for both; the ref's item query keys by the item ENTRY as well — - // a named gap, decision 0914.) - if cooldowns.is_on_cooldown(spell_id, def, now) { - debug!("ui_action: cast {spell_id} refused locally — on cooldown"); - cast_errors.0.push((spell_id, commit.not_ready_reason())); - return; - } - // The GCD leg of the local not-ready ladder ([`Cooldowns::gcd_locked`], decision 0379): a - // GCD-carrying spell pressed while its startRecoveryCategory still runs refuses at the - // source — same reason 0x3c, NEVER sent. Sending would draw the server's NOT_READY fail - // (vmangos enforces the GCD), whose faithful revert clears the RUNNING GCD — the spam-press - // vanished-pie bug. GCD-free presses (Heroic Strike's queue, Attack, Shoot) pass. - if def.is_some_and(|d| cooldowns.gcd_locked(d, now)) { - debug!("ui_action: cast {spell_id} refused locally — the GCD is running"); - cast_errors.0.push((spell_id, commit.not_ready_reason())); - return; - } // The cast classes at this seam. A ranged/auto-repeat shot (Auto Shot, wand Shoot, Throw) is // not a cast-bar cast — it runs the ranged-stance / `AutoRepeatArmed` path, outside the // in-flight guard. An on-next-swing spell (`Attributes & 0x404` — Heroic Strike, Cleave) @@ -313,16 +295,155 @@ fn send_spell_cast( debug!("ui_action: cast {spell_id} suppressed — a cast is already in flight"); return; } + // The pre-send totem/reagent possession check (`CheckReagentsAndTotems 0x6e4000`, TryCast's + // `0x6e4ded` — decision 0552): a missing tool (Mining Pick) or a short reagent refuses HERE + // with the client's own 0x78/0x5c red line and NEVER sends. The gate must be local: vmangos + // answers a sent pickless cast with the wrong code (`ITEM_GONE` "Item is gone"), so without + // it the real message can't appear. Position pinned by the 0948 §5: TryCast runs it BEFORE + // the validator (`0x6e4ded` precedes the `0x6e4f3b` call), so an on-cooldown press with + // missing reagents shows the reagent error, never "not ready". + if reagent_totem_refusal(spell_id, def, ctx.rel.self_store, items, cast_errors) { + return; + } + // ArmCast (`0x6e5250`): resolve the wire target from the spell's targeting constraints — + // never the raw selection ([`cast_target`] module docs). A refusal is local and pre-commit, + // like the ref's residual flag_word: no send, no GCD, no pending arm, no autorepeat key. + // A caller-bound GameObject short-circuits the walk, exactly as TryCast's target resolve + // (`6e4ef4` → `0x612df0` over the guid pair it was PASSED) takes an explicit target: the key + // chain calls `CGItem::Use` with the lock's guid, the bag click with zero (decision 0769). + let mut pending_word = None; + let explicit_object = match commit { + CastCommit::Item { on_object, .. } => on_object, + CastCommit::Spell => None, + }; + let target = match explicit_object { + Some(_) => None, + None => match cast_target::resolve_cast_target( + def, + ctx.selection_guid, + ctx.self_guid, + ctx.auto_self_cast, + &ctx.rel, + ) { + cast_target::CastWireTarget::SelfImplicit => None, + cast_target::CastWireTarget::Unit(guid) => Some(guid), + cast_target::CastWireTarget::Targeting(word) => { + // The cursor ENTRY is deferred below the validator rungs (decision 0948: the + // ref enters targeting at cast-arm `6e50c8`, AFTER the validator `0x6094f0` — + // an on-cooldown or unaffordable press refuses before the cursor ever comes + // up). The word parks here; nothing is sent, nothing armed either way. The + // COMMIT rides the word: the ref keeps the whole pending-cast block (the item + // guid at `0xceac48` included) across the cursor, so the click's `0x6e54f0` + // still emits USE_ITEM for a grenade / poison / key and CAST_SPELL for an + // enchant or opener. ONE arm, not one per seam (decisions 0792 / 0923 / 0939). + pending_word = Some(word); + None + } + cast_target::CastWireTarget::Refused(reason) => { + debug!( + "ui_action: cast {spell_id} refused locally — unbindable target ({reason:#x})" + ); + cast_errors.0.push((spell_id, reason)); + return; + } + }, + }; + // The local range gate (the client's TryCast runs `CanTargetUnit 0x6e4440` → + // `IsTargetInRange 0x6e47b0` BEFORE the commit `0x6e54f0`): an out-of-range / too-close + // press on a bound unit target refuses here — before the ranged-stance arm below, so a + // too-close Throw/Auto Shot never draws the bow and never stows the melee weapons (the + // sheath snap `0x6e5930` lives in the commit tail this refusal never reaches). The bound + // target is only ever the selection or ourselves; a self-bind (autoSelfCast) is distance 0 + // with a min-0 range in practice, so only the selection leg is tested. + if let Some(d) = def { + if target.is_some() && target == ctx.selection_guid && target != ctx.self_guid { + let row = spells.and_then(|s| s.ranges.get(d.range_index)); + let dist_sq = ctx + .range + .self_pos + .zip(ctx.range.target_pos) + .map(|(a, b)| a.distance_squared(b)); + if let Some(reason) = state::cast_range_refusal( + d, + row, + ctx.range.self_reach, + ctx.range.target_reach, + dist_sq, + ) { + debug!("ui_action: cast {spell_id} refused locally — range ({reason:#x})"); + cast_errors.0.push((spell_id, reason)); + return; + } + } + } + // ── The validator `0x6094f0`'s opening rungs (wow-re `gcd-power-gate.md`, the §5 that + // closed 0379's INTERIM; decision 0948) — after IsCasting, reagents and the range test, + // exactly where the ref calls the validator. ── + // + // Rung 1 — not-ready: ONE getter query ([`crate::cooldowns::Cooldowns::not_ready`] = + // `GetCooldownInfo != 0`), forked by the commit at `0x60952b`: an item press queries the + // (use-spell, item ENTRY) pair and refuses **0x28** — and per the byte law is never + // power-gated; a spell press queries (spell, 0) and refuses **0x3c**. The GCD lock rides + // the same query (the getter's GCD leg — `node.startRecoveryCategory == pressed's`, the + // pressed spell's own time never consulted): refusing locally keeps the server's NOT_READY + // fail, whose faithful revert clears the RUNNING GCD, off the wire. + let queried_item = match commit { + CastCommit::Item { entry, .. } => entry, + CastCommit::Spell => 0, + }; + if cooldowns.not_ready(spell_id, queried_item, def, now) { + debug!("ui_action: cast {spell_id} refused locally — not ready (the validator's rung 1)"); + // The one packet a local refusal ships (`0x609576–0x60960f`, the SPELL leg only): a + // running autorepeat whose record carries AttributesEx3 0x400000 (wand Shoot alone in + // the 1.12 data) is stopped — CMSG_CANCEL_CAST naming it, then the local cancel. The + // wand-stop feel: a press mid-swing reads "not ready" AND stops the wanding. + if !commit.is_item() { + if let Some(cached) = auto_repeat.0 { + if spells + .and_then(|s| s.catalog.get(cached)) + .is_some_and(|d| d.casting_cancels_autorepeat()) + { + debug!("ui_action: the not-ready refusal cancels the wand repeat {cached}"); + let _ = commands + .0 + .send(ClientCommand::CancelCast { spell_id: cached }); + let self_e = self_player.single().ok().map(|(e, _)| e); + crate::creature_anim::cancel_auto_repeat_local( + self_e, + auto_repeat, + ecs, + commands, + ); + } + } + } + cast_errors.0.push((spell_id, commit.not_ready_reason())); + return; + } + // Rung 2 — the power gate (`0x60962c`, SPELL presses only: the item fork's clear-query jump + // `je 0x6096b3` lands past it): raw `UNIT_FIELD_POWER[type]` — any negative PowerType reads + // HEALTH — signed-compared against the computed cost; reason **0x4d** ("Not enough + // mana/rage/…", the per-power errorId family). The gate must be local: vmangos ACCEPTS the + // doomed cast and its NO_POWER fail clears a running GCD — the phantom pie-blink on every + // rage-starved spam press (the 0946 campaign's live capture of the loop). + if !commit.is_item() { + if let (Some(d), Some(store)) = (def, ctx.rel.self_store) { + if !super::usable::can_afford(d, store) { + debug!("ui_action: cast {spell_id} refused locally — not enough power (0x4d)"); + cast_errors.0.push((spell_id, 0x4d)); + return; + } + } + } // The client-side mounted gate (decision 0481; wow-re `mounted-action-gate.md` §5: // TryCast's requirement validator `0x6094f0`, mounted block `0x609c6c` — a live // `UNIT_FIELD_MOUNTDISPLAYID` refuses a non-exempt cast with reason 0x39 "You are // mounted" BEFORE the cast-arm's target binding, which is why a targetless mounted click // never reads "You have no target"). Exemption: Attributes bit 24 (`0x01000000`, // castable-while-mounted). The gate must be LOCAL: vmangos silently dismounts a mounted - // caster instead of erroring, so without this check the message can never appear. Named - // micro-divergence: the ref range-tests a RESOLVED target (its step 6) before this gate; - // ours range-tests after binding, so the mounted∧out-of-range double fault shows "You are - // mounted" where the ref shows "Out of range." — unobservable outside that corner. + // caster instead of erroring, so without this check the message can never appear. (0948 + // resolved 0481's named micro-divergence: the range gate now runs before this one, the + // ref's own order — mounted∧out-of-range reads "Out of range." on both.) if state::cast_mounted_refusal( ctx.rel .self_store @@ -378,90 +499,13 @@ fn send_spell_cast( return; } } - // The pre-send totem/reagent possession check (`CheckReagentsAndTotems 0x6e4000`, TryCast's - // `0x6e4ded` — decision 0552): a missing tool (Mining Pick) or a short reagent refuses HERE - // with the client's own 0x78/0x5c red line and NEVER sends. The gate must be local: vmangos - // answers a sent pickless cast with the wrong code (`ITEM_GONE` "Item is gone"), so without - // it the real message can't appear. Placement after the mounted gate mirrors the ref only - // approximately (the `0x6094f0`-vs-`0x6e4ded` call order isn't pinned — double-fault - // corners only). - if reagent_totem_refusal(spell_id, def, ctx.rel.self_store, items, cast_errors) { + // The deferred targeting-cursor entry (the ref's cast-arm position `6e50c8`): every + // validator rung above has passed — NOW the cursor comes up and waits for its click. + if let Some(word) = pending_word { + debug!("ui_action: cast {spell_id} awaits its click — targeting cursor up ({word:#06x})"); + ground.enter(spell_id, commit, word); return; } - // ArmCast (`0x6e5250`): resolve the wire target from the spell's targeting constraints — - // never the raw selection ([`cast_target`] module docs). A refusal is local and pre-commit, - // like the ref's residual flag_word: no send, no GCD, no pending arm, no autorepeat key. - // A caller-bound GameObject short-circuits the walk, exactly as TryCast's target resolve - // (`6e4ef4` → `0x612df0` over the guid pair it was PASSED) takes an explicit target: the key - // chain calls `CGItem::Use` with the lock's guid, the bag click with zero (decision 0769). - let explicit_object = match commit { - CastCommit::Item { on_object, .. } => on_object, - CastCommit::Spell => None, - }; - let target = match explicit_object { - Some(_) => None, - None => match cast_target::resolve_cast_target( - def, - ctx.selection_guid, - ctx.self_guid, - ctx.auto_self_cast, - &ctx.rel, - ) { - cast_target::CastWireTarget::SelfImplicit => None, - cast_target::CastWireTarget::Unit(guid) => Some(guid), - cast_target::CastWireTarget::Targeting(word) => { - // Enter the targeting-cursor mode with the standing word (decisions 0792 / 0923 / - // 0939) — nothing is sent, nothing armed: the ref's cursor entry (`6e50c8`) runs - // none of the commit tail; whichever click seam binds owes the GCD and the pending - // arm. The COMMIT rides along: the ref keeps the whole pending-cast block (the - // item guid at `0xceac48` included) across the cursor, so the click's `0x6e54f0` - // still emits USE_ITEM for a thrown grenade, a poison bottle or a key, and - // CAST_SPELL for a known enchant or opener. - // - // ONE arm, not one per seam, because the word is the state: a lock spell's word - // arms the bag click and the world click at the same time and only the click - // decides which it was. - debug!("ui_action: cast {spell_id} awaits its click — targeting cursor up ({word:#06x})"); - ground.enter(spell_id, commit, word); - return; - } - cast_target::CastWireTarget::Refused(reason) => { - debug!( - "ui_action: cast {spell_id} refused locally — unbindable target ({reason:#x})" - ); - cast_errors.0.push((spell_id, reason)); - return; - } - }, - }; - // The local range gate (the client's TryCast runs `CanTargetUnit 0x6e4440` → - // `IsTargetInRange 0x6e47b0` BEFORE the commit `0x6e54f0`): an out-of-range / too-close - // press on a bound unit target refuses here — before the ranged-stance arm below, so a - // too-close Throw/Auto Shot never draws the bow and never stows the melee weapons (the - // sheath snap `0x6e5930` lives in the commit tail this refusal never reaches). The bound - // target is only ever the selection or ourselves; a self-bind (autoSelfCast) is distance 0 - // with a min-0 range in practice, so only the selection leg is tested. - if let Some(d) = def { - if target.is_some() && target == ctx.selection_guid && target != ctx.self_guid { - let row = spells.and_then(|s| s.ranges.get(d.range_index)); - let dist_sq = ctx - .range - .self_pos - .zip(ctx.range.target_pos) - .map(|(a, b)| a.distance_squared(b)); - if let Some(reason) = state::cast_range_refusal( - d, - row, - ctx.range.self_reach, - ctx.range.target_reach, - dist_sq, - ) { - debug!("ui_action: cast {spell_id} refused locally — range ({reason:#x})"); - cast_errors.0.push((spell_id, reason)); - return; - } - } - } // The wand-only auto-repeat handoff (the client's `0x60959e` inside TryCast's `0x6094f0` // step, wow-re `nocked-ammo-cancel.md` §Q-B-5): a NEW cast cancels the running auto-repeat // iff the CACHED spell carries `AttributesEx3 & 0x400000` — wand Shoot 5019 alone in the @@ -507,6 +551,7 @@ fn send_spell_cast( slot, spell_index, on_object, + .. } => ClientCommand::UseItem { bag_index, slot, @@ -584,6 +629,7 @@ mod tests { const HEARTH_COMMIT: CastCommit = CastCommit::Item { bag_index: 255, slot: 24, + entry: 6948, spell_index: 0, on_object: None, }; @@ -691,14 +737,18 @@ mod tests { assert!(rx.try_recv().is_err(), "an item on cooldown never sends"); assert_eq!(world.resource::().0, vec![(HEARTHSTONE, 0x28)]); + // The byte law's other half (0948, correcting this test's pre-§5 shape): the record is + // keyed (use-spell, item ENTRY), and a bare SPELL press queries (spell, 0) — the item + // record does NOT match it, so the press passes the rung and commits. (One store, two + // KEYS — no longer "one store keyed by spell id for both".) world.resource_mut::().0.clear(); + world.insert_resource(crate::ui_cast::PendingCast::default()); send(&mut world, HEARTHSTONE, CastCommit::Spell); - assert!(rx.try_recv().is_err()); - assert_eq!( - world.resource::().0, - vec![(HEARTHSTONE, 0x3c)], - "the very same cooldown reads \"Spell is not ready yet.\" without an item" + assert!( + matches!(rx.try_recv(), Ok(ClientCommand::CastSpell { .. })), + "the item-keyed record never not-readies a bare spell press" ); + assert!(world.resource::().0.is_empty()); } /// The commit's own branch (`SendCast 0x6e54f0`): one ladder, one targets block, two opcodes. @@ -740,6 +790,7 @@ mod tests { CastCommit::Item { bag_index: 255, slot: 81, + entry: 6948, spell_index: 0, on_object: Some(0xF110_000C_1F00_A3B2), }, @@ -754,6 +805,104 @@ mod tests { )); } + /// The validator's power gate (0948, `gcd-power-gate.md` §1.4): a SPELL press the caster + /// cannot afford refuses locally with 0x4d and never wires — the gate vmangos cannot supply + /// (it ACCEPTS the doomed cast, and its NO_POWER fail would clear a running GCD: the + /// phantom pie-blink on rage-starved spam). An ITEM press skips the gate entirely (the item + /// fork's clear-query jump lands past it). + #[test] + fn an_unaffordable_press_refuses_locally_and_items_skip_the_gate() { + use crate::net::ObjectStore; + use benilla_formats::SpellDisplay; + use benilla_protocol::ObjectFields; + + let (mut world, rx) = world(); + let mut displays = std::collections::HashMap::new(); + displays.insert( + HEARTHSTONE, + SpellDisplay { + power_type: 0, + mana_cost: 500, + ..Default::default() + }, + ); + let mut spells = super::super::Spells::empty_for_tests(); + spells.catalog = benilla_formats::SpellCatalog::from_displays(displays); + world.insert_resource(spells); + // A caster holding 100 mana (field 23 = UNIT_FIELD_POWER1). Leaked: the one-shot + // system closure must be 'static, and this is a test. + let store: &'static ObjectStore = + Box::leak(Box::new(ObjectStore(ObjectFields::from_pairs(&[ + (22u16, 100u32), + (23, 100), + ])))); + + let base = ctx(); + let with_store = cast_target::CastContext { + rel: cast_target::TargetRelations { + self_store: Some(store), + ..base.rel + }, + ..base + }; + world + .run_system_once(move |mut ladder: CastLadder| { + ladder.send(HEARTHSTONE, &with_store, CastCommit::Spell); + }) + .expect("one-shot"); + assert!(rx.try_recv().is_err(), "an unaffordable press never sends"); + assert_eq!(world.resource::().0, vec![(HEARTHSTONE, 0x4d)]); + + world.resource_mut::().0.clear(); + let base = ctx(); + let with_store = cast_target::CastContext { + rel: cast_target::TargetRelations { + self_store: Some(store), + ..base.rel + }, + ..base + }; + world + .run_system_once(move |mut ladder: CastLadder| { + ladder.send(HEARTHSTONE, &with_store, HEARTH_COMMIT); + }) + .expect("one-shot"); + assert!( + matches!(rx.try_recv(), Ok(ClientCommand::UseItem { .. })), + "an item press is never power-gated" + ); + assert!(world.resource::().0.is_empty()); + } + + /// The rung ORDER (0948's C6): the in-flight refusal (`0x61`, TryCast's IsCasting) precedes + /// the validator's not-ready rung — an on-cooldown press made mid-cast reads "Another + /// action is in progress", never "not ready". + #[test] + fn in_flight_outranks_not_ready() { + let (mut world, rx) = world(); + // Spell 100 on a long cooldown… + let use_spell = benilla_protocol::messages::ItemUseSpell { + spell_id: MOUNT, + cooldown_ms: 60_000, + category: 0, + category_cooldown_ms: 0, + }; + world + .resource_mut::() + .start_item(0, &use_spell, None, Instant::now()); + // …and a DIFFERENT cast in flight. + send(&mut world, HEARTHSTONE, CastCommit::Spell); + assert!(matches!(rx.try_recv(), Ok(ClientCommand::CastSpell { .. }))); + + send(&mut world, MOUNT, CastCommit::Spell); + assert!(rx.try_recv().is_err()); + assert_eq!( + world.resource::().0, + vec![(MOUNT, 0x61)], + "mid-cast outranks the cooldown rung (the ref's IsCasting precedes the validator)" + ); + } + /// The targeting cursor's ONE commit tail (decisions 0923 / 0939), all six cells of its /// commit × bind grid. All three seams share it precisely so this table can't grow a seventh, /// divergent copy: a thrown grenade, a poison bottle and a key are the same `CMSG_USE_ITEM` diff --git a/crates/benilla/src/ui_action/drain.rs b/crates/benilla/src/ui_action/drain.rs index 8b3ad65a..03d245cb 100644 --- a/crates/benilla/src/ui_action/drain.rs +++ b/crates/benilla/src/ui_action/drain.rs @@ -242,6 +242,7 @@ pub(super) fn drain_action_uses( start_quest: template.start_quest, bag_index, slot: slot0, + entry: b.action, spell_index, use_spell: template.use_spell.map(|u| u.spell_id), on_object: None, diff --git a/crates/benilla/src/ui_action/mod.rs b/crates/benilla/src/ui_action/mod.rs index 0aa5504c..fdef4017 100644 --- a/crates/benilla/src/ui_action/mod.rs +++ b/crates/benilla/src/ui_action/mod.rs @@ -69,7 +69,9 @@ pub(crate) use errors::{ }; // `pub(crate)`: the target chain registers the cursor pre-empt + the click commit, and the // spellbook/stance/craft drains thread the mode through the one cast-send path (decision 0792). -pub(crate) use targeting::{ground_cast_radius, SpellTargeting}; +// `TargetingWants` travels with it because the chain also holds a *seam-specific* consumer — the +// ground reticle, which draws for the location word alone (decision 0943). +pub(crate) use targeting::{ground_cast_radius, SpellTargeting, TargetingWants}; // `pub(crate)`: the spellbook shows the same borrowed weapon icon its bar buttons do, pre-resolved // once per page (decisions 0230/0231). pub(crate) use weapon_icon::{melee_auto_attack_icon, ranged_weapon_icon}; diff --git a/crates/benilla/src/ui_action/state.rs b/crates/benilla/src/ui_action/state.rs index 13acf82c..e1274ecb 100644 --- a/crates/benilla/src/ui_action/state.rs +++ b/crates/benilla/src/ui_action/state.rs @@ -200,6 +200,7 @@ pub(super) fn feed_action_state( actions: Res, spells: Option>, mut cooldowns: ResMut, + clock: Res, auto_repeat: Res, // One tuple param (Bevy's 16-SystemParam ceiling): our own cast tracking — the in-flight // guard, the queued on-next-swing strike, the running channel, and the awaiting-click @@ -224,9 +225,10 @@ pub(super) fn feed_action_state( return; }; let now = Instant::now(); - // This frame's reading of the VM's GetTime clock — `ui_triple`'s conversion base: every - // cooldown is pushed as its absolute start on that clock. - let ui_now = script.now(); + // The frame's atomic clock pair — `ui_triple`'s conversion base: every cooldown is pushed as + // its absolute start on the GetTime clock, derived through the ONE lawful pair + // ([`crate::ui_script::UiClock`]) so a running cooldown re-derives the same start every frame. + let (anchor, ui_now) = (clock.anchor, clock.ui_now); cooldowns.prune(now); let gen_changed = memory.last_generation != Some(cooldowns.generation); memory.last_generation = Some(cooldowns.generation); @@ -306,7 +308,6 @@ pub(super) fn feed_action_state( factions: factions.as_deref(), reputations: &reputations, cooldowns: &cooldowns, - now, }; let (u, oom) = usable::spell_usable(button.action, d, sp, &ctx, &mut items, &commands); @@ -327,7 +328,7 @@ pub(super) fn feed_action_state( _ => None, }; let info = cooldowns.info(button.action, 0, Some(d), now); - st.cooldown = info.ui_triple(now, ui_now); + st.cooldown = info.ui_triple(anchor, ui_now); if st.cooldown.is_some() && trace_cd { // The store (Instant clock) vs the widget (GetTime clock) — the sink // stamps the wall time, so drift between the two clocks reads directly. @@ -363,7 +364,7 @@ pub(super) fn feed_action_state( if let Some(u) = template.as_ref().and_then(|t| t.use_spell) { let d = spells.as_ref().and_then(|s| s.catalog.get(u.spell_id)); let info = cooldowns.info(u.spell_id, button.action, d, now); - st.cooldown = info.ui_triple(now, ui_now); + st.cooldown = info.ui_triple(anchor, ui_now); } } _ => {} // macros: everything cold until a macro window exists diff --git a/crates/benilla/src/ui_action/targeting/cursor.rs b/crates/benilla/src/ui_action/targeting/cursor.rs index 3c1d3540..517026f4 100644 --- a/crates/benilla/src/ui_action/targeting/cursor.rs +++ b/crates/benilla/src/ui_action/targeting/cursor.rs @@ -1,10 +1,13 @@ -//! What the player *sees* while a cast waits for its click — the classifier pre-empt `0x4820f0` -//! (wow-re `cursor-system.md` §5) and the two numbers it computes: the ground point's range verdict -//! (`CheckGroundPointInRange 0x6e6810`) and the reticle's radius (`GetCurrentCastRadius 0x6e6350`). +//! What the player *sees* while a cast waits for its click — the two classifier pre-empts and the +//! numbers they compute: the ground point's range verdict (`CheckGroundPointInRange 0x6e6810`, +//! inside `0x4820f0` — wow-re `cursor-system.md` §5), the hovered object's validity +//! (`0x6e6460`, inside `0x4828d0` — decision 0949), and the reticle's radius +//! (`GetCurrentCastRadius 0x6e6350`). //! -//! Both are *location* quantities, and that is the whole reason this is one small module rather -//! than a branch inside each seam: only the terrain seam has a point to judge, so the item and -//! GameObject seams take plain `Cast` and nothing here runs for them. +//! One module rather than a branch inside each seam, because in the reference this is one +//! decision made in one place: the **pick** picks the handler, the pick flags come from the word, +//! and every path that is not a handler ends at UnableCast. See [`drive_targeting_cursor`] for the +//! table. use bevy::prelude::*; @@ -65,47 +68,154 @@ pub(crate) fn ground_cast_radius(spells: Option<&Spells>, spell_id: u32, level: r.min(20.0) } -/// While targeting, the world cursor is the classifier's pre-empt (`0x4820f0`, cursor-system -/// §5): **Cast** over an in-range ground point, **UnableCast** out of range / too close / no -/// ground hit at all (sky, mouselook). Runs right after [`crate::target`]'s classifier in the -/// target chain and overwrites its verdict — the ref runs this branch before the object -/// classifier ever executes, and the visible result is identical. Because it writes the *base* -/// [`WorldCursor`], it also pre-empts every UI overlay downstream ([`crate::cursor`]'s -/// repair/sell latches only arm while the base is Point) — which is the same total pre-emption -/// the reference's step 2 has. +/// While targeting, the world cursor is the classifier's pre-empt (cursor-system §5). Runs right +/// after [`crate::target`]'s classifier in the target chain and overwrites its verdict. Because it +/// writes the *base* [`WorldCursor`], it also pre-empts every UI overlay downstream +/// ([`crate::cursor`]'s repair/sell latches only arm while the base is Point) — the same total +/// pre-emption the reference's step 2 has. /// -/// The **item** and **GameObject** seams take plain `Cast`, never the grayed twin: the range -/// verdict below comes from `CheckGroundPointInRange 0x6e6810`, which is a *location* predicate -/// (its one caller binary-wide is this classifier, over the ground point), and neither an -/// item-targeting nor a lock word has a ground point to judge. Whether the reference grays either -/// per hovered slot / object is unpinned — the honest read is that their validity gate runs at BIND -/// time (`0x495d60`'s `0x0a` for an item; nothing at all for a GameObject, whose refusal is the -/// server's), not at hover time. Named INTERIM, decisions 0923 / 0939. +/// **The verdict is per-seam, and the default is grey** (decision 0949). The reference reaches a +/// cursor through the pick, and while targeting the pick flags come from the word alone +/// (`0x481050`'s targeting arm), so the *word* chooses which of three handlers runs — and the +/// third one is the reason this function is not just a range check: +/// +/// | pick state | handler | verdict | +/// |---|---|---| +/// | 1 — terrain | `0x4820f0` @ `48214b` | `CheckGroundPointInRange 0x6e6810` over the ground point | +/// | 2 — object | `0x4828d0` @ `482910` → `0x6e6460` | the hovered object's own validity | +/// | 0 — nothing hit | `0x481790`'s tail | **`CursorSetMode(0x16)` = UnableCast** | +/// +/// That last row is the one that makes an armed lockpick read right: a word without `& 0x60` sets +/// no PF bit `0x1`, so a terrain-only hit is suppressed to state 0 and the cursor is **grey +/// everywhere except over a GameObject it can actually open**. An item-only word (a poison, an +/// enchant — `0x0010`) yields PF `0` outright, the pick bails before building a ray +/// (`0x4812c8`), and the world cursor is grey for the whole time it is armed — correct, because +/// that word's click lives in the bag, not the world. +/// +/// The object arm is `0x6e6460`'s GameObject leg (`6e66f3`: the picked object's typemask bit 5): +/// `word & 0x4800` (`6e6702 testb $0x48, %ah`), then +/// [`crate::target::lock::spell_opens_lock`] (`6e670f call 0x5f8260`), then — because the call +/// site passes `dl = 1` — the same min²/max² range test the ground arm uses (`6e677c..6e6801`, +/// through the very `GetMinMaxRange 0x6e3480` that `0x6e6810` calls, so the two arms carry the +/// identical [`range_row`] simplification and no new residual). +/// +/// `0x6e6460`'s other legs are unreachable from our targeting mode and deliberately not +/// transcribed: the **unit** leg (`6e6519`) — a unit-target spell never enters targeting mode at +/// all, it resolves to `CastWireTarget::Unit` — the world-CGItem leg (`6e66de`), and the corpse +/// leg (`6e6719`). Named in decision 0949. +/// +/// The cursor is still a **whole-word** surface in one respect — every seam shows the `Cast` +/// *kind*, only `unable` differs — which is why it reads [`SpellTargeting::spell`]. The reticle is +/// per-seam and reads [`SpellTargeting::spell_for`] (decision 0943). +#[allow(clippy::too_many_arguments)] pub(crate) fn drive_targeting_cursor( targeting: Res, occlusion: Res, + hovered: Res, + hovered_object: Res, spells: Option>, self_tf: Query<&Transform, With>, + go_tf: Query<&Transform>, + stores: Query<( + &crate::net::ObjectStore, + Option<&crate::go_anim::GoAnim>, + &Transform, + )>, + // Read-only here: the ask-once template request is made at object stream-in + // (`net::apply::objects`), never by this hover path, so a cold cache is a one-frame transient + // and not a permanent grey. + lock_inputs: crate::target::lock::GoLockInputs, mut cursor: ResMut, ) { let Some(spell_id) = targeting.spell() else { return; }; - let unable = targeting.wants(TargetingWants::Location) - && !match (occlusion.point, self_tf.single()) { - (Some(point), Ok(tf)) => ground_point_in_range( - range_row(spells.as_deref(), spell_id), - tf.translation, - point, - ), + let row = range_row(spells.as_deref(), spell_id); + let me = self_tf.single().ok().map(|tf| tf.translation); + // The pick's own arbitration decides which handler runs, and a GameObject hit is already + // occlusion-filtered — so "a GO is the nearest pick" is exactly the condition the click uses + // ([`super::world::commit_object_cast_on_click`]). Cursor and click ask one question. + let able = if targeting.wants(TargetingWants::GameObject) + && crate::target::go_is_nearest(&hovered, &hovered_object) + { + object_arm( + &hovered_object, + &stores, + &go_tf, + &lock_inputs, + spell_id, + row, + me, + ) + } else if targeting.wants(TargetingWants::Location) { + // `0x4820f0` — the ground point's range verdict. No hit (sky, mouselook) is state 0 for + // this word too, and state 0 while targeting is already UnableCast. + match (occlusion.point, me) { + (Some(point), Some(me)) => ground_point_in_range(row, me, point), _ => false, - }; + } + } else { + // Pick state 0 — nothing this word can bind is under the cursor. + false + }; *cursor = WorldCursor { kind: CursorKind::Cast, - unable, + unable: !able, }; } +/// `0x6e6460`'s GameObject leg: the word's `& 0x4800`, the spell-vs-lock predicate `0x5f8260`, and +/// the `dl = 1` range test. Split out only so the dispatch above reads like the reference's table. +fn object_arm( + hovered_object: &crate::target::HoveredObject, + stores: &Query<( + &crate::net::ObjectStore, + Option<&crate::go_anim::GoAnim>, + &Transform, + )>, + go_tf: &Query<&Transform>, + lock_inputs: &crate::target::lock::GoLockInputs, + spell_id: u32, + row: Option<&SpellRange>, + me: Option, +) -> bool { + let (Some(entity), Some(guid)) = (hovered_object.target, hovered_object.guid) else { + return false; + }; + // `0x5f8260`'s two data lookups: the GO's template → its `Lock.dbc` row. A template still in + // flight is not yet openable — grey, not lit; the stream-in query makes that a rare frame. + let Some(tmpl) = lock_inputs.templates.get(guid) else { + return false; + }; + let lock_id = tmpl.lock_id; + let Some(locks) = lock_inputs.locks.as_deref() else { + return false; + }; + let Some(slots) = locks.0.slots(lock_id).filter(|_| lock_id != 0) else { + return false; + }; + let Some(spells) = lock_inputs.spells.as_deref() else { + return false; + }; + let Some(spell) = spells.catalog.get(spell_id) else { + return false; + }; + let facts = crate::target::lock::go_facts( + stores + .get(entity) + .ok() + .map(|(s, anim, _)| (s, crate::go_anim::go_state(anim, s))), + ); + if !crate::target::lock::spell_opens_lock(slots, spell, facts) { + return false; + } + // The `dl = 1` tail (`6e677c`): the caster↔target distance against the spell's own min/max. + match (me, go_tf.get(entity)) { + (Some(me), Ok(tf)) => ground_point_in_range(row, me, tf.translation), + _ => false, + } +} + #[cfg(test)] mod tests { use super::*; @@ -203,4 +313,80 @@ mod tests { assert_eq!(ground_cast_radius(s, 9999, 60), 0.0); assert_eq!(ground_cast_radius(None, 10, 60), 0.0); } + + /// **The dispatch table** (decision 0949) — the reference's three pick states, and the fact + /// that only two of them are handlers. Before this, every seam took plain `Cast`, so an armed + /// poison or lockpick showed a lit cast cursor over open ground it could do nothing with. + /// + /// The load-bearing row is the last one: *state 0 is UnableCast*. A word that wants no + /// location sets no PF bit `0x1`, so bare ground can never be a handler for it. + #[test] + fn the_cursor_is_grey_wherever_the_word_has_no_handler() { + use bevy::ecs::system::RunSystemOnce; + + let verdict = |word: u16, point: Option, go: Option| { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.insert_resource(PickOcclusion { + distance: 10.0, + point, + }); + if let Some(distance) = go { + let chest = world.spawn(Transform::default()).id(); + world.insert_resource(crate::target::HoveredObject { + target: Some(chest), + guid: Some(0x1234), + distance, + }); + } + world.spawn((SelfPlayer, Transform::default())); + world.resource_mut::().enter( + 2120, + crate::ui_action::CastCommit::Spell, + word, + ); + world + .run_system_once(drive_targeting_cursor) + .expect("the targeting cursor drives"); + let cursor = world.resource::(); + assert_eq!(cursor.kind, CursorKind::Cast, "the KIND is always Cast"); + !cursor.unable + }; + + // Pick state 1 — Blizzard's DEST word over a ground point. No `Spells` resource means no + // range row, and a rowless spell is permissive (the server still judges). + assert!( + verdict(0x0040, Some(Vec3::ZERO), None), + "ground point → Cast" + ); + // …and over sky / mouselook there is no point: state 0. + assert!(!verdict(0x0040, None, None), "no ground hit → UnableCast"); + + // An **item-only** word (a poison, an enchant, `0x0010`): PF is 0, the pick bails at + // `0x4812c8` before it builds a ray, so the world cursor is grey the whole time it is + // armed — with or without ground under the mouse. + assert!(!verdict(0x0010, Some(Vec3::ZERO), None)); + assert!(!verdict(0x0010, None, None)); + // Even with a GameObject under the cursor: `& 0x4800` is 0, so the object arm is not its + // handler either. + assert!(!verdict(0x0010, Some(Vec3::ZERO), Some(3.0))); + + // A **GameObject** word (Opening, `0x4800`) over bare ground — no bit `0x1`, state 0. + assert!( + !verdict(0x4800, Some(Vec3::ZERO), None), + "lockpick over dirt is grey" + ); + // Over a GameObject whose template has not streamed in yet: the object arm bails, and a + // bail is grey rather than lit — the click is what the server judges, not the cursor. + assert!(!verdict(0x4800, Some(Vec3::ZERO), Some(3.0))); + + // A lock word that ALSO carries DEST (`0x4840`) still has its terrain handler when no + // GameObject is the nearest pick — the seams are questions, not a partition (0939). + assert!(verdict(0x4840, Some(Vec3::ZERO), None)); + } } diff --git a/crates/benilla/src/ui_action/targeting/mod.rs b/crates/benilla/src/ui_action/targeting/mod.rs index 96c54be6..78b79d3f 100644 --- a/crates/benilla/src/ui_action/targeting/mod.rs +++ b/crates/benilla/src/ui_action/targeting/mod.rs @@ -15,10 +15,12 @@ //! `world-click-targeting.md`, plus 0923's own read of the two pickup seams): //! //! - [`cursor`] — while targeting, the world classifier is pre-empted (the ref's dispatcher step 2 -//! runs before any object resolve): **Cast** when the hovered ground point passes the range gate, -//! **UnableCast** otherwise (`0x4820f0`'s split, `CheckGroundPointInRange 0x6e6810` over -//! `GetMinMaxRange`). The other two seams take plain Cast — that range gate is a *location* -//! predicate. Also the ground reticle's radius ([`ground_cast_radius`]). +//! runs before any object resolve), and the verdict is **per-seam** (decision 0949): the pick +//! flags come from the word, so the word chooses the handler — terrain → `0x4820f0`'s +//! `CheckGroundPointInRange 0x6e6810`, a GameObject → `0x4828d0`'s `0x6e6460` (the word's +//! `& 0x4800`, the spell-vs-lock predicate `0x5f8260`, then the range test), and **no handler +//! at all → UnableCast**. That default is what greys an armed lockpick over open ground and +//! greys a poison everywhere in the world. Also the reticle's radius ([`ground_cast_radius`]). //! - [`world`] — the two legs of the world-click dispatcher `0x492ce0`: the **terrain** leg's //! ground commit (`0x492580` → `BindLocation 0x6e60f0`) and the **object** leg's GameObject bind //! (`0x4925d0` → `SetSelection 0x493540` @ `4935d5` → `BindTarget 0x6e5b40`'s GO arm). Which leg @@ -56,7 +58,10 @@ //! ([`crate::target::click::select_on_click`]'s gate transcribes the unreachable select). //! Right-click cancels on the DOWN edge ([`cancel_targeting_on_right_press`]); movement never //! cancels (`0x515090`'s explicit IsTargeting-skip). The ground reticle draws in [`crate::target`]'s -//! `reticle` module (decision 0797) off [`ground_cast_radius`] + the cursor's range verdict. +//! `reticle` module (decision 0797) off [`ground_cast_radius`] + the cursor's range verdict — for +//! the **terrain seam alone**, through [`SpellTargeting::spell_for`] (decision 0943): it is a +//! per-seam surface, and reading the seam-agnostic [`SpellTargeting::spell`] there is what put a +//! green AoE circle under every armed lockpick and enchant. mod cursor; mod item; @@ -138,8 +143,9 @@ impl SpellTargeting { self.0.is_some() } - /// `GetTargetingSpellId 0x6e48e0` — the spell awaiting its click, for the action bar's - /// press-again toggle. + /// `GetTargetingSpellId 0x6e48e0` — the spell awaiting its click, whatever its word wants. + /// For the whole-word consumers only: the bar's checked state, the press-again toggle, the + /// `CURRENT_SPELL_CAST_CHANGED` edge. A seam-specific one wants [`Self::spell_for`]. pub(crate) fn spell(&self) -> Option { self.0.as_ref().map(|t| t.spell_id) } @@ -149,6 +155,22 @@ impl SpellTargeting { self.0.as_ref().is_some_and(|t| wants.matches(t.word)) } + /// The pending spell **when the standing word answers `wants`** — `IsTargeting` and the seam's + /// own mask test as one read, which is the pair every seam-specific consumer needs and + /// [`Self::spell`] deliberately is not. + /// + /// Any surface that draws or binds for *one* seam must ask through this or through + /// [`Self::pending_for`]; `spell()` is `GetTargetingSpellId 0x6e48e0` and answers for the word + /// as a whole (the bar's checked state, the re-press toggle, the + /// `CURRENT_SPELL_CAST_CHANGED` edge). Reading `spell()` where a seam was meant is what put an + /// AoE reticle under a lockpick (decision 0943). + pub(crate) fn spell_for(&self, wants: TargetingWants) -> Option { + self.0 + .as_ref() + .filter(|t| wants.matches(t.word)) + .map(|t| t.spell_id) + } + pub(crate) fn enter(&mut self, spell_id: u32, commit: super::cast_send::CastCommit, word: u16) { self.0 = Some(Targeting { spell_id, diff --git a/crates/benilla/src/ui_action/usable.rs b/crates/benilla/src/ui_action/usable.rs index 8a81d512..2bdbbffb 100644 --- a/crates/benilla/src/ui_action/usable.rs +++ b/crates/benilla/src/ui_action/usable.rs @@ -19,8 +19,6 @@ //! durability model), and the ghost state beyond plain death. CanAssist inside 10b is the //! reaction-rank stand-in the ring/`can_attack` share, pending the true `0x6066f0` walk. -use std::time::Instant; - use benilla_formats::{ SpellDisplay, ATTR_CASTABLE_WHILE_DEAD, ATTR_NOT_IN_COMBAT, ATTR_ONLY_STEALTHED, SPELL_EFFECT_TRADE_SKILL, @@ -49,7 +47,6 @@ pub(crate) struct UsableCtx<'a> { pub(crate) factions: Option<&'a Factions>, pub(crate) reputations: &'a Reputations, pub(crate) cooldowns: &'a Cooldowns, - pub(crate) now: Instant, } /// Leg 4's own test (`0x6e40e0`), shared with the spell tooltip's requirement line: does some @@ -187,29 +184,46 @@ pub(crate) fn spell_usable( _ => {} } } - // Leg 11: ONLY a cooldown-on-event spell folds its cooldown into usable (B3) — Stealth - // greys while its effect runs; Fireball never greys mid-cooldown. - if d.cooldown_on_event() && ctx.cooldowns.is_on_cooldown(spell_id, Some(d), ctx.now) { + // Leg 11: ONLY a cooldown-on-event spell folds its cooldown into usable (B3) — and the + // predicate is the corrected `0x6e1690` (an on-hold-record test, wow-re `gcd-power-gate.md` + // §3): Stealth greys while its record is PARKED; once the event starts the clocks — and for + // every ordinary cooldown — the button never greys from here. + if d.cooldown_on_event() && ctx.cooldowns.has_on_hold_record(spell_id, Some(d)) { return (false, false); } // Leg 12 (`0x6e3fba`–`0x6e3feb`): the power gate — the SOLE notEnoughMana writer (B2). - // Percent costs scale from base mana (mana spells) / max power (the vmangos - // CalculatePowerCost basis). - let ty = d.power_type as u8; - let base = if d.mana_cost_pct == 0 { - 0 - } else if d.power_type == 0 { - ctx.store.0.unit_base_mana().unwrap_or(0) - } else { - ctx.store.0.unit_max_power(ty).unwrap_or(0) - }; - let cost = d.mana_cost + base * d.mana_cost_pct / 100; - if ctx.store.0.unit_power(ty).unwrap_or(0) < cost { + if !can_afford(d, ctx.store) { return (false, true); } (true, false) } +/// Whether the caster can afford `d`'s power cost — the shared availability-vs-cost compare +/// behind BOTH the usable walk's leg 12 (`0x6e3fba`) and the press-path power gate +/// (`0x6094f0` @ `0x60962c`, decision 0948): raw `UNIT_FIELD_POWER[type]` — ANY negative +/// PowerType reads `UNIT_FIELD_HEALTH` instead (the ref's `jl` at `0x609631`; Bloodrage's −2) — +/// signed-compared against the computed cost. Percent costs scale from base mana (mana spells) +/// / max power (the vmangos CalculatePowerCost basis). +pub(crate) fn can_afford(d: &SpellDisplay, store: &ObjectStore) -> bool { + let power_type = d.power_type as i32; + let avail = if power_type < 0 { + store.0.unit_health().unwrap_or(0) + } else { + store.0.unit_power(power_type as u8).unwrap_or(0) + }; + let base = if d.mana_cost_pct == 0 { + 0 + } else if d.power_type == 0 { + store.0.unit_base_mana().unwrap_or(0) + } else if power_type < 0 { + store.0.unit_max_health().unwrap_or(0) + } else { + store.0.unit_max_power(power_type as u8).unwrap_or(0) + }; + let cost = d.mana_cost + base * d.mana_cost_pct / 100; + avail >= cost +} + #[cfg(test)] mod tests { use super::*; @@ -234,7 +248,6 @@ mod tests { factions: None, reputations, cooldowns, - now: Instant::now(), } } @@ -388,7 +401,6 @@ mod tests { factions: None, reputations: &reputations, cooldowns: &cooldowns, - now: Instant::now(), }; assert_eq!( spell_usable(5308, &execute, &spells, &ctx, &mut items, &commands), diff --git a/crates/benilla/src/ui_items/drain.rs b/crates/benilla/src/ui_items/drain.rs index 569f6ada..74df12db 100644 --- a/crates/benilla/src/ui_items/drain.rs +++ b/crates/benilla/src/ui_items/drain.rs @@ -95,7 +95,7 @@ pub(super) fn drain_inventory_uses( let slot = (id - 1) as u8; // The doll's own slot ids ARE the wire slots (`wire_pos`'s EQUIPMENT_BAG law), so the // equipped instance resolves off the player's INV array directly. - let (guid, start_quest, spell_index, use_spell) = self_q + let (guid, start_quest, spell_index, use_spell, entry) = self_q .iter() .next() .and_then(|store| slot_guid(&store.0, EQUIPMENT_BAG, slot, &ladder.items)) @@ -109,9 +109,10 @@ pub(super) fn drain_inventory_uses( t.start_quest, t.use_spell_index().unwrap_or(0), t.use_spell.map(|u| u.spell_id), + Some(entry), )) }) - .unwrap_or((None, 0, 0, None)); + .unwrap_or((None, 0, 0, None, None)); debug!("ui_items: use equipped item, lua slot {id} (wire 255/{slot})"); super::send_item_use( super::ItemUse { @@ -119,6 +120,7 @@ pub(super) fn drain_inventory_uses( start_quest, bag_index: BAG_PLAYER_INVENTORY, slot, + entry: entry.unwrap_or(0), spell_index, use_spell, on_object: None, @@ -322,6 +324,7 @@ pub(super) fn drain_container_uses( start_quest: c.start_quest, bag_index, slot: wire_slot, + entry: c.entry, spell_index: c.spell_index, use_spell: c.use_spell, on_object: None, @@ -403,6 +406,7 @@ pub(super) fn drain_container_uses( start_quest: 0, bag_index, slot: wire_slot, + entry: clicked.map_or(0, |c| c.entry), spell_index: clicked.map_or(0, |c| c.spell_index), use_spell: clicked.and_then(|c| c.use_spell), on_object: None, diff --git a/crates/benilla/src/ui_items/feed.rs b/crates/benilla/src/ui_items/feed.rs index 0a801c24..75173724 100644 --- a/crates/benilla/src/ui_items/feed.rs +++ b/crates/benilla/src/ui_items/feed.rs @@ -578,13 +578,16 @@ pub(super) fn feed_containers( mut pending: ResMut, mut lock_cleared: ResMut, mut names: ResMut, + clock: Res, mut memory: Local, ) { let Some(mut script) = script else { return; }; - let now = std::time::Instant::now(); - let ui_now = script.now(); + // The frame's atomic clock pair (`crate::ui_script::UiClock`): the slot resolves read the + // cooldown store and convert triples through ONE coherent instant, so a running cooldown's + // pushed start is frame-stable (the resource's own doc). + let (now, ui_now) = (clock.anchor, clock.ui_now); let spell_catalog = spells.as_deref().map(|s| &s.catalog); let enchant_rows = enchants.as_deref(); // Inventory refusals surface as the client's red error line (the cast path's exact shape): diff --git a/crates/benilla/src/ui_items/mod.rs b/crates/benilla/src/ui_items/mod.rs index 762be053..799329fc 100644 --- a/crates/benilla/src/ui_items/mod.rs +++ b/crates/benilla/src/ui_items/mod.rs @@ -477,6 +477,11 @@ pub(crate) struct ItemUse { pub(crate) bag_index: u8, /// The wire slot, 0-based. pub(crate) slot: u8, + /// The item's template ENTRY — the cooldown store keys item records on `(use_spell, entry)` + /// (the client's `[eax+8]==spellId && [eax+0xc]==itemID` match), and the ladder's not-ready + /// rung queries exactly that pair for the item leg's 0x28 (decision 0948; closes the + /// item-entry gap 0914 named). + pub(crate) entry: u32, /// The template spell BLOCK ordinal the server should cast (decision 0666). pub(crate) spell_index: u8, /// The template's ON_USE spell id — `0x5d8c80`'s answer: the first template block whose @@ -638,6 +643,7 @@ pub(crate) fn send_item_use( crate::ui_action::CastCommit::Item { bag_index: it.bag_index, slot: it.slot, + entry: it.entry, spell_index: it.spell_index, on_object: it.on_object, }, @@ -938,6 +944,7 @@ mod tests { // (0 of the 215 live ones), but the quest arm wins even if one did. let letter = 0x4000_0000_0000_0BAD_u64; let it = |guid, start_quest, use_spell| ItemUse { + entry: 0, guid, start_quest, bag_index: 255, @@ -982,6 +989,7 @@ mod tests { fn a_live_aura_makes_the_item_click_cancel_instead_of_cast() { const SUMMON_HORSE: u32 = 17462; let it = |start_quest, use_spell| ItemUse { + entry: 0, guid: Some(0x4000_0000_0000_0BAD), start_quest, bag_index: 255, diff --git a/crates/benilla/src/ui_script/action_bar_tests.rs b/crates/benilla/src/ui_script/action_bar_tests.rs index b429bb91..ed0ef6dd 100644 --- a/crates/benilla/src/ui_script/action_bar_tests.rs +++ b/crates/benilla/src/ui_script/action_bar_tests.rs @@ -246,6 +246,61 @@ fn state_feedback_drives_cooldown_checked_and_usable_through_the_xml() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } +/// The pie paints OVER its button's art. The Cooldown child is born at button-level+1, and the +/// draw key's LEVEL term outranks 0884's bucket-wide layer term — so the sweep quad must sort +/// after the icon (BACKGROUND) and after the button's own special textures. A regression here is +/// invisible to every store/feed instrument (the triple still pushes; only the pixels vanish +/// under the icon), which is exactly why the order is pinned end-to-end through the real XML. +#[test] +fn the_cooldown_sweep_paints_over_the_buttons_icon_and_ring() { + use benilla_ui::script::ActionState; + + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + load_action_bar(&s); + s.set_action( + 1, + Some(ActionSlot { + texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()), + kind: 0x00, + action: 133, + count: 0, + }), + ); + s.fire_event("PLAYER_ENTERING_WORLD", vec![]); + s.tick(10.0); + s.set_action_state( + 1, + Some(ActionState { + usable: true, + cooldown: Some((6_000, 10_000, true)), + ..Default::default() + }), + ); + s.fire_event("ACTIONBAR_UPDATE_COOLDOWN", vec![]); + s.resolve(); + + let quads = s.extract(); + let pos = |pred: &dyn Fn(&QuadContent) -> bool| quads.iter().position(|q| pred(&q.content)); + let icon = pos(&|c| { + matches!(c, QuadContent::Texture { path: Some(p), .. } if p.contains("Spell_Fire_FlameBolt")) + }) + .expect("the icon texture quad"); + let ring = pos( + &|c| matches!(c, QuadContent::Texture { path: Some(p), .. } if p.contains("UI-Quickslot2")), + ) + .expect("the NormalTexture ring quad"); + let sweep = pos(&|c| matches!(c, QuadContent::Cooldown { .. })).expect("the sweep quad"); + assert!( + icon < sweep, + "the sweep (index {sweep}) must paint over the icon (index {icon})" + ); + assert!( + ring < sweep, + "the sweep (index {sweep}) must paint over the button ring (index {ring})" + ); +} + /// An action button is a TWO-button button (decision 0908; director's report B200: "I can't right /// click food on my bar to eat it or right click spells"). The ref's `ActionButton_OnLoad` /// registers `("LeftButtonUp", "RightButtonUp")` (ActionButton.lua:109) and its OnClick body reads diff --git a/crates/benilla/src/ui_script/extract/mod.rs b/crates/benilla/src/ui_script/extract/mod.rs index 6e9b4148..56604c00 100644 --- a/crates/benilla/src/ui_script/extract/mod.rs +++ b/crates/benilla/src/ui_script/extract/mod.rs @@ -53,6 +53,7 @@ pub(super) fn drive_script( // cooldown store and the server, which is exactly "I can charge before the sweep ends" // (verified live: an occluded run's UI clock fell 27 s behind in 43 s of wall time). time: Res>, + mut ui_clock: ResMut, mut quads: ResMut, world_assets: Option>, mut images: ResMut>, @@ -126,6 +127,15 @@ pub(super) fn drive_script( script.set_screen_size(w / s, if h > 0.0 { h / s } else { 768.0 }); script.tick(time.delta_secs()); } + // The frame's clock pair ([`super::UiClock`]): the VM value the tick just produced, anchored + // at the exact `Instant` whose frame-to-frame deltas that clock accumulates — `Time`'s + // own last-update. Every Instant→GetTime conversion goes through this pair; sampling + // `Instant::now()` at a conversion site instead re-measures the tick→feed scheduling gap + // every frame and wobbles the derived start by that jitter (the resource's own doc). + *ui_clock = super::UiClock { + anchor: time.last_update().unwrap_or_else(std::time::Instant::now), + ui_now: script.now(), + }; let us_tick = lap(); // ── The FontString measure round-trip, BEFORE the resolve ──────────────────────────────── // `fontstrings_needing_measure` reads only `region_data` — each FontString's text, font, and @@ -755,6 +765,7 @@ mod clip_plumb_tests { app.init_resource::(); app.init_resource::