the cooldown sweep is pinned, and the shoulder armor calms down

- the GCD system now matches the real client end to end: opening a chest no longer eats the running global cooldown, and a running sweep's start stops wobbling between frames
- the mounted left shoulder's wild swing is fixed at the bone: a bone flagged to ignore its parent's rotation now does, decided before any camera-facing runs
- a gameobject effect with several animation sequences plays the right one: Molten Core's rune flame spreads and dances instead of collapsing into a bright dot
- the targeting cursor asks the thing under it and greys by default like the real client, and the ground-target reticle keeps its own location rules

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-04 18:54:26 +07:00
co-authored by Claude Fable 5
parent b82cc3dd4f
commit 422e1b08e7
50 changed files with 2714 additions and 641 deletions
+32 -8
View File
@@ -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<ModelRibbon> = 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,
+153 -90
View File
@@ -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<benilla_formats::BillboardKind>,
/// 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<benilla_formats::ParentArm>,
}
/// 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<Mat4>) {
pub(crate) fn build_skeleton(skel: &Skeleton) -> (ModelSkeleton, Vec<Mat4>) {
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<T: Animatable + Clone>(
/// - **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<benilla_formats::BoneKeys>) -> 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"
);
}
}
+17 -1
View File
@@ -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<usize> {
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,
}
}
@@ -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<String> = 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<String> = 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::<Vec<_>>()
.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<String> = 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<String> = Vec::new();
let mut animated: Vec<String> = Vec::new();
for (ch, (label, slots)) in views.iter().enumerate() {
let vals: Vec<f32> = 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(())
}
@@ -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<String>,
},
/// 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<String>,
},
/// 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())?,
@@ -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<String> = 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<String> = 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(())
}
+4 -4
View File
@@ -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::{
+9 -7
View File
@@ -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<crate::BillboardKind>,
/// 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<crate::ParentArm>,
}
/// 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<Skeleton> {
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 })
@@ -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<Self> {
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
@@ -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)
);
}
@@ -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,
+29
View File
@@ -553,6 +553,30 @@ fn spell_schema() -> Schema {
pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
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<u32> = {
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<SpellCatalog> {
}),
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),
+211 -41
View File
@@ -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<Entity>,
parents: Vec<i16>,
kinds: Vec<Option<BillboardKind>>,
/// 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<bool>,
/// 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<Option<benilla_formats::ParentArm>>,
/// 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<Vec3>,
}
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<Option<GlobalTransform>> = 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,
+271 -99
View File
@@ -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"
);
}
+126 -47
View File
@@ -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<GlobalTransform>, Vec<bool>) {
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<Entity> = Vec::new();
let mut finalize =
|rig: &mut RigPose,
holder: Entity,
skin: &RigSkin,
globals: &mut Query<(&Transform, &mut GlobalTransform), Without<WorldCamera>>,
cascade: &mut Vec<Entity>| {
cascade: &mut Vec<Entity>,
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]);
@@ -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,
}
}
+2 -1
View File
@@ -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,
+39 -10
View File
@@ -44,13 +44,19 @@ pub(crate) struct RigPose {
pub(crate) locals: Vec<Transform>,
/// 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<Affine3A>,
pub(crate) parents: Vec<i16>,
/// The bone's billboard arm (`0x08/0x10/0x20/0x40`), for the world pass's camera replacement.
pub(crate) kinds: Vec<Option<benilla_formats::BillboardKind>>,
/// Bone flag `0x04`: rotation resets to the model root's frame (the HandArrow helpers).
pub(crate) ignore_rot: Vec<bool>,
/// 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<Option<benilla_formats::ParentArm>>,
/// 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<Vec3>,
/// 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,
@@ -31,6 +31,7 @@ fn clip(anim_id: u16, move_speed: f32) -> AnimClip {
upper_node: None,
frequency: 0,
replay: (0, 0),
poses_bones: true,
}
}
+23 -1
View File
@@ -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
+127 -104
View File
@@ -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<RigBuild> {
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::<crate::creature_anim::AnimParked>();
}
}
// 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::<crate::creature_anim::AnimParked>();
}
}
}
/// 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<RigBuild> = 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) {
@@ -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,
+1 -1
View File
@@ -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()
};
@@ -242,6 +242,7 @@ mod tests {
upper_node: None,
frequency: 0,
replay: (0, 0),
poses_bones: true,
}
}
+1
View File
@@ -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(),
),
+23 -2
View File
@@ -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,
);
+2
View File
@@ -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;
+26
View File
@@ -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<u32> {
super::depthdump::frame(now, &mut self.depth_frames)
}
}
+137
View File
@@ -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=<label-substring>[,<period-seconds>]` — 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<Option<(String, f32)>> = 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::<f32>()))
{
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<Entity>) -> &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<Entity>, 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<usize>,
/// 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,
}
+1 -1
View File
@@ -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
+27 -3
View File
@@ -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<u32>,
// 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 {
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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.
+125
View File
@@ -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() {
+88 -10
View File
@@ -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<AssetServe
commands.init_resource::<ReticleState>();
}
/// 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<SpellTargeting>,
occlusion: Res<PickOcclusion>,
@@ -97,9 +111,12 @@ pub(super) fn update_reticle(
mut state: ResMut<ReticleState>,
) {
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::<ReticleState>();
world.init_resource::<WorldCursor>();
world.init_resource::<SpellTargeting>();
world.insert_resource(PickOcclusion {
distance: 10.0,
point: Some(Vec3::ZERO),
});
world
.resource_mut::<SpellTargeting>()
.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::<ReticleState>().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"
);
}
}
}
+273 -124
View File
@@ -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::<CastErrors>().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::<CastErrors>().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::<CastErrors>().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::<CastErrors>().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::<CastErrors>().0, vec![(HEARTHSTONE, 0x4d)]);
world.resource_mut::<CastErrors>().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::<CastErrors>().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::<crate::cooldowns::Cooldowns>()
.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::<CastErrors>().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`
+1
View File
@@ -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,
+3 -1
View File
@@ -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};
+7 -6
View File
@@ -200,6 +200,7 @@ pub(super) fn feed_action_state(
actions: Res<PlayerActions>,
spells: Option<Res<Spells>>,
mut cooldowns: ResMut<Cooldowns>,
clock: Res<crate::ui_script::UiClock>,
auto_repeat: Res<AutoRepeatActive>,
// One tuple param (Bevy's 16-SystemParam ceiling): our own cast tracking — the in-flight
// guard, the queued on-next-swing strike, 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
+216 -30
View File
@@ -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<SpellTargeting>,
occlusion: Res<PickOcclusion>,
hovered: Res<crate::target::Hovered>,
hovered_object: Res<crate::target::HoveredObject>,
spells: Option<Res<Spells>>,
self_tf: Query<&Transform, With<SelfPlayer>>,
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<WorldCursor>,
) {
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<Vec3>,
) -> 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<Vec3>, go: Option<f32>| {
let mut world = World::new();
world.init_resource::<WorldCursor>();
world.init_resource::<SpellTargeting>();
world.init_resource::<crate::target::Hovered>();
world.init_resource::<crate::target::HoveredObject>();
world.init_resource::<crate::go_templates::GameObjectTemplates>();
world.init_resource::<crate::items::Items>();
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::<SpellTargeting>().enter(
2120,
crate::ui_action::CastCommit::Spell,
word,
);
world
.run_system_once(drive_targeting_cursor)
.expect("the targeting cursor drives");
let cursor = world.resource::<WorldCursor>();
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));
}
}
+29 -7
View File
@@ -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<u32> {
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<u32> {
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,
+32 -20
View File
@@ -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),
+6 -2
View File
@@ -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,
+5 -2
View File
@@ -578,13 +578,16 @@ pub(super) fn feed_containers(
mut pending: ResMut<PendingItemOps>,
mut lock_cleared: ResMut<LockClearedByFailure>,
mut names: ResMut<crate::names::NameCache>,
clock: Res<crate::ui_script::UiClock>,
mut memory: Local<FeedMemory>,
) {
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):
+8
View File
@@ -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,
@@ -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
@@ -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<Time<Real>>,
mut ui_clock: ResMut<super::UiClock>,
mut quads: ResMut<UiQuads>,
world_assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
@@ -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<Real>`'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::<crate::hover_log::UiFrameCost>();
app.init_resource::<Time>();
app.init_resource::<Time<Real>>();
app.init_resource::<crate::ui_script::UiClock>();
// The uiScale dial at its identity Default (1.0) — tests pin the byte-identity base;
// the shipped app inserts the taste default instead (decision 0584).
app.init_resource::<crate::ui_script::UiScaleCvar>();
@@ -837,6 +848,7 @@ mod clip_plumb_tests {
app.init_resource::<crate::hover_log::UiFrameCost>();
app.init_resource::<Time>();
app.init_resource::<Time<Real>>();
app.init_resource::<crate::ui_script::UiClock>();
app.init_resource::<crate::ui_script::UiScaleCvar>();
app.world_mut().spawn((
Window {
@@ -892,6 +904,7 @@ mod extract_gate_tests {
app.init_resource::<crate::hover_log::UiFrameCost>();
app.init_resource::<Time>();
app.init_resource::<Time<Real>>();
app.init_resource::<crate::ui_script::UiClock>();
app.init_resource::<crate::ui_script::UiScaleCvar>();
app.world_mut().spawn((
Window {
+30
View File
@@ -77,6 +77,35 @@ pub(crate) struct CursorPayloadHeld(pub(crate) bool);
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct UiInput;
/// The frame's atomic (`Instant`, `GetTime`) clock pair — the ONE lawful base for mapping a
/// store-side `Instant` onto the VM's `GetTime` clock (`CooldownInfo::ui_triple` and kin).
///
/// Written at the single `script.tick` site ([`extract::drive_script`]): `ui_now` is the value the
/// VM clock just advanced to, `anchor` is `Time<Real>`'s own `last_update()` — the exact instant
/// whose frame-to-frame differences ARE the deltas the VM clock accumulates. Because both legs
/// advance in lockstep by construction, a conversion `ui_now - (anchor - start)` yields the SAME
/// number every frame for one fixed `start` — the frame-stability 0375's absolute-start triples
/// require. Converting through `Instant::now()` sampled inside a feed system instead (the pre-fix
/// shape) re-measures the tick→feed scheduling gap every frame and wobbles the derived start by
/// that jitter (±12 ms observed live), turning every running cooldown into a per-frame "changed"
/// triple — the diff churn 0375 existed to kill.
#[derive(Resource)]
pub(crate) struct UiClock {
/// The `Instant` leg: `Time<Real>::last_update()` at the tick that produced [`Self::ui_now`].
pub(crate) anchor: std::time::Instant,
/// The `GetTime` leg: the VM clock's value after that tick (seconds).
pub(crate) ui_now: f64,
}
impl Default for UiClock {
fn default() -> Self {
Self {
anchor: std::time::Instant::now(),
ui_now: 0.0,
}
}
}
/// Run a Lua chunk, logging (never discarding) a failure. App systems drive the VM with fire-and-
/// forget chunks; `let _ = script.run(…)` swallows the error — the chat header machine died
/// mid-chunk on a missing `EditBox:SetTextColor` every single frame and nothing ever said so
@@ -146,6 +175,7 @@ impl Plugin for UiScriptPlugin {
.init_resource::<UiKeyboardCapture>()
.init_resource::<PlayerUiClickConsumed>()
.init_resource::<CursorPayloadHeld>()
.init_resource::<UiClock>()
// After `AssetSet::Open` so the patch chain exists at boot: the VM's first load is
// the real `GlobalStrings.lua` (the reference's own FrameXML order), which the
// cast-fail display (0427) resolves its messages from.
+7 -3
View File
@@ -94,6 +94,7 @@ fn feed_shapeshift_bar(
reputations: Res<Reputations>,
mut items: ResMut<Items>,
commands: Res<NetCommands>,
clock: Res<crate::ui_script::UiClock>,
mut memory: Local<StanceMemory>,
) {
let Some(mut script) = script else {
@@ -105,7 +106,9 @@ fn feed_shapeshift_bar(
let store = self_q.iter().next();
let form_byte = store.map(|s| s.0.unit_shapeshift_form()).unwrap_or(0);
let now = Instant::now();
let ui_now = script.now();
// The frame's atomic clock pair for the `ui_triple` conversion (`crate::ui_script::UiClock`'s
// own doc: converting through a locally sampled `Instant::now()` wobbles the derived start).
let (anchor, ui_now) = (clock.anchor, clock.ui_now);
// The usable walk's target leg (the Execute family) reads the CURRENT TARGET, like the
// action feed's own ctx.
let target_store = selection
@@ -148,7 +151,6 @@ fn feed_shapeshift_bar(
factions: factions.as_deref(),
reputations: &reputations,
cooldowns: &cooldowns,
now,
};
usable::spell_usable(id, d, &spells, &ctx, &mut items, &commands).0
});
@@ -157,7 +159,9 @@ fn feed_shapeshift_bar(
} else {
d.icon.clone()
};
let cooldown = cooldowns.info(id, 0, Some(d), now).ui_triple(now, ui_now);
let cooldown = cooldowns
.info(id, 0, Some(d), now)
.ui_triple(anchor, ui_now);
ShapeshiftFormView {
spell_id: id,
texture,
+4 -4
View File
@@ -130,6 +130,7 @@ fn feed_spellbook(
icons: Option<Res<ItemDisplays>>,
commands: Res<NetCommands>,
cooldowns: Res<crate::cooldowns::Cooldowns>,
clock: Res<crate::ui_script::UiClock>,
mut memory: Local<FeedMemory>,
) {
let Some(mut script) = script else {
@@ -189,12 +190,11 @@ fn feed_spellbook(
// per arm, so a running cooldown never churns this diff). The XML's SPELL_UPDATE_COOLDOWN
// handler re-reads these through `GetSpellCooldown` — the plugin's `.before(CooldownEvents)`
// guarantees they're pushed before that event fires.
let now = std::time::Instant::now();
let ui_now = script.now();
let (anchor, ui_now) = (clock.anchor, clock.ui_now);
for slot in &mut fresh.slots {
slot.cooldown = cooldowns
.info(slot.spell_id, 0, spells.catalog.get(slot.spell_id), now)
.ui_triple(now, ui_now);
.info(slot.spell_id, 0, spells.catalog.get(slot.spell_id), anchor)
.ui_triple(anchor, ui_now);
}
if fresh != memory.pushed {
// Two event edges off one diff: the ref fires CURRENT_SPELL_CAST_CHANGED for a checked-