the loading screen warms every shader, and locked chests say LOCKED

- the loading screen now compiles every shader the world can reach: sky, water, spell shards and model variants stop hitching the first time they appear
- a locked chest's cursor carries the LOCKED word like the real client, and the lock verdict never leaks into the sent packet
- enchanting asks before it acts: a bind-on-enchant item gets its confirm popup, and so does replacing an enchant that's already there
- a long gossip menu scrolls like the reference with rows grown to their wrapped text, and music already playing is not restarted when the server re-sends it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-04 18:54:16 +07:00
co-authored by Claude Fable 5
parent 906e997aa6
commit b82cc3dd4f
48 changed files with 4442 additions and 1259 deletions
+32 -17
View File
@@ -177,6 +177,15 @@ pub struct ModelEmitter {
/// **Bevy model space** (the same frame the instance transform maps), radius model-local
/// yards. `(ZERO, 0)` when the header carries no bounds — the sign test at the anchor.
pub water_bound: (Vec3, f32),
/// The OWNER model's **loader-idle file sequence slot**
/// ([`crate::ModelAnimations::idle_seq`]) — the sequence every M2 instance is playing when no
/// rig has armed anything, and therefore the slot this emitter's per-sequence rate/gate/param
/// bakes sample against by default (decision 0936). Baked onto the emitter, not asked of the
/// spawn site, because the lanes that need it most are exactly the ones with no rig to ask:
/// a content-gated GameObject (`spawn_emitter`'s `EmitClock::Host` whose player armed nothing)
/// and an unrigged placed doodad (`EmitClock::Pinned`). `0` for a model with no sequences —
/// the same degrade as before, now stated once instead of falling out of an `unwrap_or`.
pub idle_seq: usize,
}
/// Whether looping `anim` would look like anything other than the **static mesh** — the doodad
@@ -193,22 +202,13 @@ pub struct ModelEmitter {
/// ≠ 0 (the M2 translation track is a delta on the pivot offset), rotation ≠ identity, scale ≠ 1.
/// Genuinely rest-posed idles — the ~90% of placed doodads decision 0130 measured — still take the
/// static path, so that optimization survives intact.
///
/// The predicate itself is `ModelAnimation::is_rest_pose`, beside the parse, so
/// `benilla-extract idleslotscan`'s corpus census of this gate's reach can't drift from the gate
/// (decision 0936). What it decides is **whether to build a rig**, and nothing else — the sequence
/// the instance is playing is [`ModelAnimations::idle_seq`], which has an answer either way.
fn idle_pose_differs(anim: &benilla_formats::ModelAnimation) -> bool {
const EPS: f32 = 1e-4;
anim.bones.iter().any(|b| {
b.translation.len() > 1
|| b.rotation.len() > 1
|| b.scale.len() > 1
|| b.translation
.iter()
.any(|(_, v)| v.iter().any(|c| c.abs() > EPS))
|| b.rotation
.iter()
.any(|(_, q)| (q[3].abs() - 1.0).abs() > EPS)
|| b.scale
.iter()
.any(|(_, s)| s.iter().any(|c| (c - 1.0).abs() > EPS))
})
!anim.is_rest_pose()
}
/// A model reference inside an M2 record (`.mdx`/`.mdl`, mixed case, backslashes) → its
@@ -355,7 +355,7 @@ impl AssetLoader for M2ModelLoader {
// Particle emitters: resolve each emitter's texture to an `mpq://` image dependency (same
// lowercase normalisation as submesh albedos), and bake its host bone's pivot so the spawn
// site can ride a live joint (see [`ModelEmitter::bone_pivot`]). Absent on most models.
let emitters = parse_m2_particle_emitters(&bytes)
let mut emitters: Vec<ModelEmitter> = parse_m2_particle_emitters(&bytes)
.unwrap_or_default()
.into_iter()
.map(|def| {
@@ -393,6 +393,7 @@ impl AssetLoader for M2ModelLoader {
geometry,
owner_reach,
water_bound,
idle_seq: 0, // stamped below, once the sequences are built
}
})
.collect();
@@ -435,7 +436,11 @@ impl AssetLoader for M2ModelLoader {
def,
})
.collect();
let (skeleton, inverse_bindposes) = build_skeleton(&skeleton_raw);
// 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 inverse_bindposes = ctx.add_labeled_asset(
"inverse_bindposes".to_string(),
SkinnedMeshInverseBindposes::from(inverse_bindposes),
@@ -657,6 +662,16 @@ impl AssetLoader for M2ModelLoader {
})
};
// Stamp every emitter with the model's loader-idle slot (decision 0936) — the sequence a
// rig-less instance is playing, and so the slot its rate/gate/param bakes sample by
// default. Done here rather than in the map above because the selection needs the built
// clips (their `seq_index` + the `playableAnimationLookup` resolve), and only `None` when
// the model builds no clip at all — nothing to name, so the historical slot 0 stands.
let idle_seq = animations.as_ref().and_then(ModelAnimations::idle_seq);
for e in &mut emitters {
e.idle_seq = idle_seq.unwrap_or(0);
}
// The authored portrait camera (raw WoW model space → Bevy, same pure rotation the meshes
// bake through, so eye/target land in exactly the submeshes' frame) — and the camera
// table's first record, the glue screens' `SetCamera(0)` rig.
+107 -2
View File
@@ -254,6 +254,10 @@ pub struct ModelJoint {
/// skinned to them — inherit the camera facing (the frost-armor sheets skin to the scale-in
/// 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).
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
@@ -289,7 +293,23 @@ 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.
pub(crate) fn build_skeleton(skel: &Skeleton) -> (ModelSkeleton, Vec<Mat4>) {
///
/// ## 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>) {
let pivots = skeleton_pivots(skel);
let joints = skel
.bones
@@ -303,7 +323,9 @@ pub(crate) fn build_skeleton(skel: &Skeleton) -> (ModelSkeleton, Vec<Mat4>) {
ModelJoint {
parent: b.parent,
local_translation: pivots[i] - parent_pivot,
billboard: b.billboard,
billboard: b
.billboard
.filter(|_| !welded_billboards.contains(&(i as u16))),
ignore_parent_rotation: b.ignore_parent_rotation,
}
})
@@ -833,4 +855,87 @@ mod tests {
let id = r * Quat::IDENTITY * r.inverse();
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.
///
/// `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).
///
/// 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).
#[test]
fn the_field_marshal_pauldron_reaches_the_palette_with_no_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 path = "Item\\ObjectComponents\\Shoulder\\LShoulder_Plate_PVPAlliance_A_01.m2";
let bytes = chain.read_file(path).expect("read the pauldron");
let raw = benilla_formats::parse_m2_skeleton(&bytes).expect("parse its skeleton");
// The asset really does author two spherical arms — otherwise this test would pass on a
// build that had simply stopped parsing bone flags.
assert_eq!(
raw.bones.iter().filter(|b| b.billboard.is_some()).count(),
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!(
built.joints.iter().all(|j| j.billboard.is_none()),
"no palette lane may camera-face this model — it draws in its authored pose"
);
}
}
+82
View File
@@ -198,6 +198,36 @@ impl ModelAnimations {
.or_else(|| self.clips.first())
}
/// The **FILE sequence slot** the loader-idle seed arms — [`Self::first_seq`]'s selection taken
/// *without* its bind-pose content gate, and expressed as the slot the per-sequence bakes are
/// keyed on rather than as a `clips` index.
///
/// The two fields answer different questions and must not be conflated (decision 0936). The
/// reference arms this sequence on **every** M2 instance the moment it goes LIVE (`0x70ebd0`'s
/// tail — no instance in the client ever exists with nothing armed), so "which sequence is this
/// model playing" always has an answer. `first_seq` answers the narrower *rendering* question
/// "is it worth building a skin + `AnimationPlayer` for it", and says `None` when looping the
/// idle would render identically to the static mesh. That skip is sound for the mesh — and
/// silently wrong for every consumer keyed on the sequence *identity*, which then degraded to
/// file slot 0 (`EmitTiming::idx`/`EmitParams::sample`/`AlphaAnim::seq` all resolve `None` that
/// way). Slot 0 is only accidentally the idle one: on the Spawn/Stand/Despawn GameObjects
/// (`DuelingFlag`-shaped — the battlefield banners, the goobers, `ArenaFlag`) it is the **Spawn**
/// flourish, whose emitters then pour for ever at its opening frame.
///
/// `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> {
let idle_id = self
.playable_animation_lookup
.first()
.map_or(0, |p| p.resolved_id);
self.clips
.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()`,
/// `0..0x7fff`): the byte-verified weighted walk (wow-re `anim-id-resolution.md`, op4 with
/// variationIdx −1) — `roll < frequency` picks the current node, else `roll -= frequency` and
@@ -506,6 +536,58 @@ mod tests {
assert_eq!(anims.preferred_clip(Some(42)).map(|c| c.anim_id), Some(0));
}
// ── idle_seq: the loader-idle FILE slot, split from the render content gate ──────────────
/// `test_anims` in file order, with each clip's `seq_index` set to its slot — the axis the
/// per-sequence bakes (`EmitTiming`/`EmitParams`/`AlphaAnim`) are keyed on.
fn slotted(ids: &[u16], table: Vec<PlayableAnim>) -> ModelAnimations {
let mut a = test_anims(ids, table);
for (i, c) in a.clips.iter_mut().enumerate() {
c.seq_index = i;
}
a
}
/// The Spawn/Stand/Despawn GameObject shape — `DuelingFlag`, the battlefield banners,
/// `ArenaFlag`, the goobers. The idle slot is **1**, not 0: slot 0 is the Spawn flourish, whose
/// emitters pour at its opening frame for ever if a consumer degrades to it.
#[test]
fn idle_seq_is_the_stand_slot_not_file_slot_zero() {
let anims = slotted(&[145, 0, 157], Vec::new());
assert_eq!(anims.idle_seq(), Some(1));
}
/// It is `first_seq`'s selection **without** the bind-pose content gate: the field stays `None`
/// (looping a constant-pose idle renders identically to the static mesh, so the rig tier skips
/// the arm) while the slot is still known. Conflating the two is the whole defect.
#[test]
fn idle_seq_answers_even_when_the_content_gate_skipped_the_arm() {
let anims = slotted(&[145, 0, 157], Vec::new());
assert_eq!(anims.first_seq, None, "the render gate declined to arm");
assert_eq!(
anims.idle_seq(),
Some(1),
"the sequence identity is still known"
);
}
/// The idle id is resolved through the model's own `playableAnimationLookup` — the same
/// `0x711bf0(reqId = 0)` chain `first_seq` takes (0637). 268 models corpus-wide remap requested
/// id 0 (`Cripple_State_Base` → 158 Hold), and slot 0 is not their idle either.
#[test]
fn idle_seq_resolves_id_zero_through_the_playable_lookup() {
let anims = slotted(&[159, 158], vec![playable(158, 0)]);
assert_eq!(anims.idle_seq(), Some(1));
}
/// No sequence for the resolved idle id ⇒ the first record's own slot, the guarded
/// `*(dword*)&sequences[0]` fallback — and `None` only when there is no clip at all to name.
#[test]
fn idle_seq_falls_back_to_the_first_record_then_to_none() {
assert_eq!(slotted(&[158, 159], Vec::new()).idle_seq(), Some(0));
assert_eq!(slotted(&[], Vec::new()).idle_seq(), None);
}
/// The variation pick is the client's weighted walk (wow-re `anim-id-resolution.md`, op4 with
/// variationIdx −1): `roll < frequency` picks, else `roll -= frequency` and advance; chain
/// exhaustion clamps to the last variation; a lone variation always wins.
@@ -293,6 +293,29 @@ enum Command {
/// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted.
prefix: Option<String>,
},
/// Sweep every `.m2` (optionally under a path prefix) and census the **3-D model particles**:
/// emitters carrying a geometry-model reference, whose shard instances render the GEOMETRY
/// model's own submeshes stamped with the OWNER model's owner-last draw-order rung. Per
/// (owner, geometry) pair: the owner's rung and reach plus the geometry's material family
/// tuples (blend, two_sided, additive, no_depth_write, no_depth_test) — the exact keys the
/// shard materials are built from. The ground truth sizing the pipeline-warm menagerie's
/// shard rows: rung histogram, family-tuple set split by transparent-pass membership, and
/// the unresolvable-path bound.
Shardcensus {
/// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted.
prefix: Option<String>,
},
/// Sweep every `.m2` (optionally under a path prefix) and list the models where the
/// **loader-idle sequence is not file slot 0** while benilla's render content gate leaves it
/// unarmed — so every per-sequence bake (emission rate/gate, the nine parameter channels,
/// material alpha) degrades to slot 0, a sequence the instance is not playing. On the
/// `DuelingFlag`-shaped GameObjects (Spawn/Stand/Despawn) slot 0 is the **Spawn flourish**:
/// the population instrument for decision 0936, found on the Stormwind battlefield banner
/// pouring its spawn sparkle-tails for ever.
Idleslotscan {
/// 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 PARTICLE
/// emitters carry any of the given file-flag bits (`M2ParticleEmitter+0x04`) — the
/// population instrument for particle-flag mechanisms (which content actually authors
@@ -585,6 +608,8 @@ fn main() -> Result<()> {
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())?,
Command::Shardcensus { prefix } => scan::shardcensus(&mut chain, prefix.as_deref())?,
Command::Idleslotscan { prefix } => scan::idleslotscan(&mut chain, prefix.as_deref())?,
Command::Partscan { mask, prefix } => {
let mask = parse_u32_maybe_hex(&mask)
.with_context(|| format!("parsing flag mask '{mask}'"))?;
@@ -2720,6 +2720,172 @@ pub fn fxordercensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
Ok(())
}
/// Sweep every `.m2` (optionally under a path prefix) and census the corpus's **3-D model
/// particles**: the emitters whose record carries a geometry-model reference (`+0x18` — a shard
/// instance renders that GEOMETRY model's own submeshes, not a billboard quad). Per OWNER model:
/// the owner-last draw-order rung its shards are stamped with at runtime
/// (`owner_last_rung(m2_owner_reach(owner submeshes))`); per referenced GEOMETRY model: the
/// material family tuples its render submeshes author — (blend, two_sided, additive,
/// no_depth_write, no_depth_test), the exact key the shard's material is built from.
///
/// This is the **ground truth for the pipeline-warm menagerie's shard rows**: warming a pipeline
/// per (rung × family) cell is only worth its table if the corpus's cells are enumerable, and this
/// census is what enumerates them — the rung histogram sizes the rung axis, the family-tuple set
/// (split by transparent-pass membership, the renderer's own test) sizes the material axis, and
/// the unresolved-path count bounds what the table can never cover.
pub fn shardcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
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();
// A submesh's material family: the tuple the shard material is keyed by, rendered compactly
// (`Blend+2s+add`), plus its transparent-pass membership — the renderer's own test, where
// `additive` forces the pass whatever the authored blend says (`model_material`).
let fam = |s: &benilla_formats::RenderSubmesh| {
let transparent = s.additive
|| matches!(
s.blend,
benilla_formats::ModelBlend::Blend
| benilla_formats::ModelBlend::Mod
| benilla_formats::ModelBlend::Mod2x
);
let label = format!(
"{:?}{}{}{}{}",
s.blend,
if s.two_sided { "+2s" } else { "" },
if s.additive { "+add" } else { "" },
if s.no_depth_write { "+ndw" } else { "" },
if s.no_depth_test { "+ndt" } else { "" },
);
(label, transparent)
};
// Each distinct geometry model is read, parsed, and tallied exactly once; the cache keeps its
// (family, transparent) list per submesh for the per-pair lines. `None` = unresolvable.
let mut geo_cache: BTreeMap<String, Option<Vec<(String, bool)>>> = BTreeMap::new();
let mut fam_transparent: BTreeMap<String, u32> = BTreeMap::new();
let mut fam_opaque: BTreeMap<String, u32> = BTreeMap::new();
let mut rungs: BTreeMap<u32, u32> = BTreeMap::new();
let mut unresolved: Vec<String> = Vec::new();
let mut parse_failures: Vec<(String, String)> = Vec::new();
let (mut scanned, mut owners) = (0u32, 0u32);
for name in names {
let Ok(bytes) = chain.read_file(&name) else {
continue;
};
scanned += 1;
if benilla_formats::parse_m2_particle_emitters(&bytes).is_err() {
continue;
}
// Distinct geometry references, normalized the way the chain resolves model paths
// (lowercase, `\` separators, `.mdx`/`.mdl` → `.m2` — `model_key` mirrors the crate's own
// `model_path`).
let geos: BTreeSet<String> = record_extras(&bytes)
.iter()
.filter_map(|e| e.geometry_model.as_deref())
.map(|p| model_key(&p.replace('/', "\\")))
.collect();
if geos.is_empty() {
continue;
}
owners += 1;
// The rung the owner's shards are stamped with: the renderer's own bound and rung, at
// placement scale 1 (a scaled placement multiplies the reach).
let dir = name.rsplit_once('\\').map_or("", |(d, _)| d);
let owner_subs =
benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]).unwrap_or_default();
let reach = benilla_formats::m2_owner_reach(&owner_subs);
let rung = benilla_formats::owner_last_rung(reach);
*rungs.entry(rung as u32).or_default() += 1;
for geo in geos {
if !geo_cache.contains_key(&geo) {
let parsed = match chain.read_file(&geo) {
Ok(gb) => {
let gdir = geo.rsplit_once('\\').map_or("", |(d, _)| d);
match benilla_formats::parse_m2_render_submeshes(&gb, gdir, &[]) {
Ok(gsubs) => {
let fams: Vec<(String, bool)> = gsubs.iter().map(&fam).collect();
for (label, transparent) in &fams {
let tally = if *transparent {
&mut fam_transparent
} else {
&mut fam_opaque
};
*tally.entry(label.clone()).or_default() += 1;
}
Some(fams)
}
Err(e) => {
parse_failures.push((geo.clone(), e.to_string()));
None
}
}
}
Err(_) => {
unresolved.push(geo.clone());
None
}
};
geo_cache.insert(geo.clone(), parsed);
}
let fams = match &geo_cache[&geo] {
Some(fams) if fams.is_empty() => "(no submeshes)".to_string(),
Some(fams) => fams
.iter()
.map(|(l, _)| l.as_str())
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>()
.join(", "),
None => "UNRESOLVED".to_string(),
};
println!("rung {rung:>2.0} reach {reach:>8.2} {name} -> {geo} [{fams}]");
}
}
eprintln!(
"{scanned} models scanned\n \
{owners} own a geometry-model (3-D shard) emitter\n \
{} distinct geometry models referenced\n \
{} geometry paths unresolvable in the chain",
geo_cache.len(),
unresolved.len()
);
eprintln!(" owner rung histogram (placement scale 1):");
for (rung, n) in &rungs {
eprintln!(" rung {rung:>2} {n:>5}");
}
eprintln!(" geometry family tuples — TRANSPARENT pass (submeshes across distinct models):");
for (label, n) in &fam_transparent {
eprintln!(" {label:<24} {n:>5}");
}
eprintln!(" geometry family tuples — opaque/mask pass:");
for (label, n) in &fam_opaque {
eprintln!(" {label:<24} {n:>5}");
}
if !unresolved.is_empty() {
eprintln!(" unresolvable geometry paths:");
for p in &unresolved {
eprintln!(" {p}");
}
}
if !parse_failures.is_empty() {
eprintln!(" geometry parse failures:");
for (p, e) in &parse_failures {
eprintln!(" {p}: {e}");
}
}
Ok(())
}
/// The terrain MCSH shadow bit at a world position + an ASCII texel neighborhood (`#` shadowed,
/// `.` lit, `?` off-tile/no-chunk). One MCSH texel is `TILE_SIZE/1024` ≈ 0.52 yd; the grid spans
/// ±8 texels so a doodad base sitting one texel from a shadow edge — the 2.5-vs-0.5 intensity
@@ -3252,3 +3418,109 @@ pub fn fxlifescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
}
Ok(())
}
/// Sweep every `.m2` (optionally under a path prefix) and list the models where the **loader-idle
/// sequence is not file slot 0** while benilla's render content gate declines to arm it — so every
/// per-sequence bake (`EmitTiming`/`EmitParams`/`AlphaAnim`) degrades to slot 0, a sequence the
/// instance is not playing (decision 0936, found on the Stormwind battlefield banner).
///
/// The reference arms the loader-idle sequence on **every** M2 instance at load (`0x70ebd0`'s tail,
/// wow-re `gameobject-anim-arm.md` §1), so "which sequence is this playing" always has an answer.
/// benilla skips the arm when looping the idle would render identically to the static mesh — sound
/// for the *mesh*, and silently wrong for anything keyed on the sequence *identity*. The two only
/// disagree visibly when the idle is not slot 0, which is the `DuelingFlag` shape:
/// `Spawn(145) / Stand(0) / Despawn(157)`, where slot 0 is the **Spawn flourish**.
///
/// Reports, per model: the emitters whose enabled/rate gate at t = 0 differs between slot 0 and the
/// idle slot (`FX`), and the batches whose combined material-alpha factor differs (`ALPHA`).
pub fn idleslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
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 unarmed, mut window) = (0u32, 0u32, 0u32);
let (mut fx_models, mut alpha_models) = (0u32, 0u32);
for name in names {
let Ok(bytes) = chain.read_file(&name) else {
continue;
};
let anims = benilla_formats::parse_m2_animations(&bytes);
if anims.is_empty() {
continue;
}
scanned += 1;
let idle_id = benilla_formats::parse_m2_playable_animation_lookup(&bytes)
.unwrap_or_default()
.first()
.map_or(0, |p| p.resolved_id);
// benilla's render content gate (`benilla_assets`' `idle_pose_differs`): a bind-pose idle
// is not armed at all, so nothing ever overrides the per-sequence bakes' opening slot.
if anims
.iter()
.any(|a| a.anim_id == idle_id && !a.is_rest_pose())
{
continue;
}
unarmed += 1;
let idle = anims
.iter()
.find(|a| a.anim_id == idle_id)
.map_or(0, |a| a.seq_index);
if idle == 0 {
continue; // degrading to slot 0 happens to BE the idle slot — no divergence
}
window += 1;
let mut lines = Vec::new();
let defs = benilla_formats::parse_m2_particle_emitters(&bytes).unwrap_or_default();
let mut fx = 0u32;
for (i, d) in defs.iter().enumerate() {
let on = |s: usize| {
d.timing.emitting(Some(s), 0.0, 0.0) && d.timing.rate(Some(s), 0.0, 0.0) > 0.0
};
if on(0) != on(idle) {
fx += 1;
lines.push(format!(
" FX emitter {i:>2}: slot 0 {:>3}, idle slot {idle} {:>3} tex {}",
if on(0) { "ON" } else { "off" },
if on(idle) { "ON" } else { "off" },
d.texture.as_deref().unwrap_or("NONE"),
));
}
}
let dir = name.rsplit_once('\\').map_or("", |(d, _)| d);
let subs = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]).unwrap_or_default();
let mut alpha = 0u32;
for (i, s) in subs.iter().enumerate() {
let Some(a) = &s.alpha_anim else { continue };
let (f0, fi) = (a.sample(Some(0), 0.0, 0.0), a.sample(Some(idle), 0.0, 0.0));
if (f0 - fi).abs() > 1e-3 {
alpha += 1;
lines.push(format!(
" ALPHA batch {i:>2}: slot 0 {f0:.2}, idle slot {idle} {fi:.2}"
));
}
}
if !lines.is_empty() {
fx_models += u32::from(fx > 0);
alpha_models += u32::from(alpha > 0);
println!("{name} (idle slot {idle} of {} sequences)", anims.len());
for l in lines {
println!("{l}");
}
}
}
eprintln!(
"{scanned} models with sequences, {unarmed} whose idle the content gate leaves unarmed, \
{window} of those with idle slot != 0 (the divergence window): \
{fx_models} model(s) whose EMITTERS gate differently, \
{alpha_models} whose MATERIAL ALPHA differs"
);
Ok(())
}
+147 -10
View File
@@ -90,19 +90,43 @@ impl ItemVisualCatalog {
}
}
/// `SpellItemEnchantment.dbc`'s two consumer columns, from one load of the one table: the
/// **visual** (field 22) an enchant glows with, and the **name** (field 13, the enUS slot of the
/// 1.12 localized-string block) the item tooltip prints for it. Two lanes read this table — the
/// weapon-glow chain (decision 0805) and the tooltip's enchant line (decision 0915) — and one
/// adapter serves both: two loaders over one DBC is how a schema drifts.
/// `SpellItemEnchantment.dbc`'s consumer columns, from one load of the one table: the **visual**
/// (field 22) an enchant glows with, the **name** (field 13, the enUS slot of the 1.12
/// localized-string block) the item tooltip prints for it, and its **`Flags`** (field 23 —
/// [`EnchantCatalog::binds_the_item`] and [`EnchantCatalog::tooltip_hides_name`]). Three lanes
/// read this table — the weapon-glow chain (decision 0805), the tooltip's enchant line (decision
/// 0915) and the item-bind confirms (decision 0928) — and one adapter serves all three: two
/// loaders over one DBC is how a schema drifts.
///
/// Sparse on both axes: 102 of the 1460 rows carry a visual, and a row without a name simply has
/// Sparse on every axis: 102 of the 1460 rows carry a visual, and a row without a name simply has
/// none. The rest of the table (effects, points, args) belongs to whoever needs it, not here.
pub struct EnchantCatalog {
visuals: HashMap<u32, i32>,
names: HashMap<u32, String>,
/// `Flags` (field 23) for **every** row the table carries, `0` included — so this map's key
/// set is also the reference's `enchantTable[id] != 0` ([`EnchantCatalog::has_row`]).
flags: HashMap<u32, u32>,
}
/// `Flags & 0x1` — applying this enchant **binds the item to you**. Only two sites in the whole
/// binary read this bit, and both ask that one question: the enchant-apply gate `0x495d60` @
/// `495ea3` (`testb $0x1, 0x5c(%eax)` — the sole gate on the "Enchanting this item will bind it to
/// you." confirm, event 402) and `0x5da2c0` @ `5da313`, which walks an item's seven live enchant
/// slots asking whether it already carries one (and so has already been asked). 86 of the 1460
/// shipped rows: the shaman weapon imbues, every rogue poison, Firestone, and the Zul'Gurub /
/// Ahn'Qiraj head-and-leg enchants. The permanent profession enchants are NOT among them, and
/// neither are sharpening stones, weightstones, oils or scopes.
const FLAG_BINDS_THE_ITEM: u32 = 0x1;
/// `Flags & 0x2` — **suppress the tooltip line** for this enchant. The two item-tooltip enchant-line
/// printers both open with it and return outright when it is set (`6290e4` and `62923e`, each
/// `testb $0x2, 0x5c(...)` → `jne` straight to the function's `retl`), *before* they read the name
/// at `+0x34`. 12 shipped rows, and they are one coherent family: the totem-granted weapon imbues
/// (Flametongue Totem 1-4, Windfury Totem 1-3) plus the warlock Firestone 1-4 and Orb of Fire —
/// buffs whose source is already visible elsewhere on screen. The *replace* confirm reads the name
/// with no such gate (`4960d0`), so a suppressed enchant still names itself in that popup.
const FLAG_TOOLTIP_HIDES_NAME: u32 = 0x2;
impl EnchantCatalog {
/// The ItemVisuals id an enchant glows with, or `None` for an enchant with no visual. The
/// value is signed for the same reason as [`ItemVisualCatalog::effects`] — one shipped row
@@ -117,9 +141,49 @@ impl EnchantCatalog {
self.names.get(&enchant_id).map(String::as_str)
}
/// Build from explicit maps — tests and synthetic fixtures.
pub fn from_rows(visuals: HashMap<u32, i32>, names: HashMap<u32, String>) -> Self {
EnchantCatalog { visuals, names }
/// [`FLAG_BINDS_THE_ITEM`] — whether applying this enchant binds the item to you, the whole
/// predicate behind the reference's bind confirm (decision 0928). `false` for an unknown id,
/// which is also the reference's answer: it looks the row up first and skips on a miss.
///
/// `+0x5c` is field 23, and the field-13 name column chain-locks it: the replace confirm reads
/// its two names at `4960d0`/`4960d4` as `0x34(%row,%locale,4)`, and `0x34/4 == 13` is exactly
/// [`Self::name`]'s column — so the eight localized name slots run 13..20, `NameFlags` is 21,
/// `ItemVisual` 22 (the glow lane's, and `5d9be1: movl 0x58(%eax)` confirms it), and `Flags`
/// 23. vmangos's own `SpellItemEnchantmentEntry` agrees field-for-field.
pub fn binds_the_item(&self, enchant_id: u32) -> bool {
self.flag(enchant_id, FLAG_BINDS_THE_ITEM)
}
/// [`FLAG_TOOLTIP_HIDES_NAME`] — whether the item tooltip must print NO line for this enchant.
/// The enchant-line lane gates on it (`crate::items::enchant_lines` in the app).
pub fn tooltip_hides_name(&self, enchant_id: u32) -> bool {
self.flag(enchant_id, FLAG_TOOLTIP_HIDES_NAME)
}
fn flag(&self, enchant_id: u32, bit: u32) -> bool {
self.flags.get(&enchant_id).is_some_and(|f| f & bit != 0)
}
/// Whether the id names a real row at all — the reference's `testl %eax,%eax` after every one
/// of its `enchantTable[id]` loads. Both confirms need it: an id that names nothing raises no
/// popup and blocks no bind.
pub fn has_row(&self, enchant_id: u32) -> bool {
self.flags.contains_key(&enchant_id)
}
/// Build from explicit maps — tests and synthetic fixtures. `flags` is field 23 per row and
/// doubles as the row census, so a fixture that only sets `names`/`visuals` must list its ids
/// there too for [`Self::has_row`] to see them.
pub fn from_rows(
visuals: HashMap<u32, i32>,
names: HashMap<u32, String>,
flags: HashMap<u32, u32>,
) -> Self {
EnchantCatalog {
visuals,
names,
flags,
}
}
/// Iterate `(enchant id, ItemVisuals id)` for the rows that carry one (order unspecified) —
@@ -229,8 +293,11 @@ pub fn load_enchant_catalog(chain: &mut Chain) -> Result<EnchantCatalog> {
)?;
let mut visuals = HashMap::new();
let mut names = HashMap::new();
let mut flags = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
// Every row lands here, `Flags == 0` included — the map's key set IS the row census.
flags.insert(id, u32_at(r, 23).unwrap_or(0));
let visual = u32_at(r, 22).unwrap_or(0) as i32;
if visual != 0 {
visuals.insert(id, visual);
@@ -240,7 +307,11 @@ pub fn load_enchant_catalog(chain: &mut Chain) -> Result<EnchantCatalog> {
names.insert(id, name);
}
}
Ok(EnchantCatalog { visuals, names })
Ok(EnchantCatalog {
visuals,
names,
flags,
})
}
#[cfg(test)]
@@ -429,4 +500,70 @@ mod tests {
"far more enchants print a name than carry a glow"
);
}
/// The `Flags` column (field 23) as it actually ships — the two bits the reference reads, and
/// the shape of each one's set. Written the way the mapping was *settled*: the name column at
/// field 13 is chain-locked by the binary reading it as `0x34(%row,%locale,4)`, `ItemVisual`
/// at 22 by `5d9be1: movl 0x58(%eax)`, so `Flags` is 23 (`+0x5c`) — which is exactly what the
/// two `testb $0x1,0x5c` / `testb $0x2,0x5c` sites read.
///
/// A column slip fails this hard, because both sets are *characterized*, not just counted: bit
/// 0's members are weapon imbues / poisons / raid enchants and bit 1's are exactly the twelve
/// totem-and-Firestone rows. This also pins the negative that surprised us and is worth not
/// re-learning: the permanent profession enchants (Agility +15, Crusader, Fiery Weapon) and
/// every consumable-applied enchant (stones, oils, scopes) have `Flags == 0`, so the bind
/// confirm does NOT fire for them.
#[test]
fn real_enchant_flags_column() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let e = load_enchant_catalog(&mut chain).expect("load SpellItemEnchantment");
// Bit 0 — the bind confirm's gate. 86 rows: the shaman imbues, the rogue poisons, the
// warlock Firestone, and the ZG/AQ head-and-leg enchants.
for (id, name) in [
(1u32, "Rockbiter 3"),
(7, "Deadly Poison"),
(283, "Windfury 1"),
(2488, "+5 All Resistances"),
(2606, "+30 Attack Power"),
] {
assert!(e.binds_the_item(id), "{id} ({name}) must carry Flags & 1");
}
// The negative half — the one that makes this bit counter-intuitive.
for (id, name) in [
(2564u32, "Agility +15"),
(1900, "Crusader"),
(803, "Fiery Weapon"),
(40, "Sharpened +2"),
(2627, "Wizard Oil"),
(33, "Scope (+3 Damage)"),
] {
assert!(
!e.binds_the_item(id),
"{id} ({name}) must NOT carry Flags & 1"
);
}
// Bit 1 — the tooltip's name suppression. Exactly twelve rows, all one family.
let hidden: std::collections::BTreeSet<u32> =
(0..3000).filter(|&id| e.tooltip_hides_name(id)).collect();
assert_eq!(
hidden,
[124, 285, 303, 543, 563, 564, 1683, 1783, 1803, 1823, 1824, 1825]
.into_iter()
.collect(),
"the totem-granted imbues (Flametongue/Windfury Totem), Orb of Fire and Firestone 1-4"
);
// Suppression is about the tooltip LINE, not the row: the names are still there, and the
// replace confirm reads them ungated (`4960d0`).
assert_eq!(e.name(124), Some("Flametongue Totem 1"));
// The row census the confirms gate on — present for a real id, absent past the table.
assert!(e.has_row(2564) && e.has_row(1) && !e.has_row(999_999));
}
}
+14 -13
View File
@@ -154,19 +154,20 @@ pub use models::{
load_m2_bounds, load_m2_collision_hull, load_m2_mesh, load_m2_mesh_skinned, load_object_model,
load_wmo, load_wmo_collision_tris, m2_owner_reach, m2_ribbon_emitter_count,
m2_texture_transform_count, non_separable_billboard_bones, owner_last_rung,
parse_m2_animation_lookup, parse_m2_animation_summary, parse_m2_animations,
parse_m2_attachments, parse_m2_bounds, parse_m2_camera, parse_m2_collision_hull,
parse_m2_event_markers, parse_m2_global_sequence_bones, parse_m2_lights,
parse_m2_playable_animation_lookup, parse_m2_portrait_camera, parse_m2_render_submeshes,
parse_m2_skeleton, parse_m2_string_anchors, parse_wmo_fogs, parse_wmo_lights,
parse_wmo_portals, parse_wmo_root, wmo_group_doodad_refs, wmo_group_footprint_tris,
wmo_group_header, wmo_group_light_refs, wmo_group_liquid_mesh, wmo_group_submeshes,
wmo_root_id, AlphaAnim, AlphaSeq, AnimEvent, Billboard, BillboardKind, BoneKeys, BoneScaleAnim,
CharSkinSlot, CollisionMesh, EmitterBoneLink, EventMarker, FogPolicy, FootprintTris,
GlobalSeqBone, GlobalSeqChannel, GroundQuad, M2AnimSummary, M2Attachment, M2Bounds, M2Light,
M2PortraitCamera, ModelAnimation, ModelBlend, PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim,
Skeleton, SkeletonBone, StringAnchors, UvAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog,
WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot,
owner_last_rung_bucket, parse_m2_animation_lookup, parse_m2_animation_summary,
parse_m2_animations, parse_m2_attachments, parse_m2_bounds, parse_m2_camera,
parse_m2_collision_hull, parse_m2_event_markers, parse_m2_global_sequence_bones,
parse_m2_lights, parse_m2_playable_animation_lookup, parse_m2_portrait_camera,
parse_m2_render_submeshes, parse_m2_skeleton, parse_m2_string_anchors, parse_wmo_fogs,
parse_wmo_lights, parse_wmo_portals, parse_wmo_root, wmo_group_doodad_refs,
wmo_group_footprint_tris, wmo_group_header, wmo_group_light_refs, wmo_group_liquid_mesh,
wmo_group_submeshes, wmo_root_id, AlphaAnim, AlphaSeq, AnimEvent, Billboard, BillboardKind,
BoneKeys, BoneScaleAnim, CharSkinSlot, CollisionMesh, EmitterBoneLink, EventMarker, FogPolicy,
FootprintTris, GlobalSeqBone, GlobalSeqChannel, GroundQuad, M2AnimSummary, M2Attachment,
M2Bounds, M2Light, M2PortraitCamera, ModelAnimation, ModelBlend, PlayableAnim, RenderSubmesh,
RgbAnim, ScalarAnim, Skeleton, SkeletonBone, StringAnchors, UvAnim, WmoBatchClass, WmoDoodad,
WmoDoodadSet, WmoFog, WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef,
WmoPortals, WmoRoot, OWNER_RUNG_BUCKETS,
};
mod terrain;
pub use terrain::{
+34
View File
@@ -376,6 +376,40 @@ pub struct ModelAnimation {
pub events: Vec<AnimEvent>,
}
impl ModelAnimation {
/// Does this sequence leave **every** bone at bind pose for its whole band — one key per
/// channel, and that key the identity (zero translation, unit-w rotation, unit scale)?
///
/// The **render** content gate (decision 0130, consumed by `benilla_assets`' loader as
/// `ModelAnimations::first_seq`): looping such a sequence draws exactly the static mesh, so the
/// placed-doodad tier skips building a skin + `AnimationPlayer` for it — the ~90 % of placed
/// doodads `doodadscan` measured. It lives here, beside the parse, so the `benilla-extract`
/// census of what that gate costs the corpus (`idleslotscan`) asks the renderer's own question
/// instead of a hand-copied twin that can drift from it.
///
/// It answers a question about **pixels only**, and is never a reason to treat the sequence as
/// absent: a consumer keyed on the sequence *identity* — the per-sequence emission, parameter
/// and material-alpha bakes — still needs the slot the instance is playing, and the reference
/// always has one armed (decision 0936, `ModelAnimations::idle_seq`).
pub fn is_rest_pose(&self) -> bool {
const EPS: f32 = 1e-4;
!self.bones.iter().any(|b| {
b.translation.len() > 1
|| b.rotation.len() > 1
|| b.scale.len() > 1
|| b.translation
.iter()
.any(|(_, v)| v.iter().any(|c| c.abs() > EPS))
|| b.rotation
.iter()
.any(|(_, q)| (q[3].abs() - 1.0).abs() > EPS)
|| b.scale
.iter()
.any(|(_, s)| s.iter().any(|c| (c - 1.0).abs() > EPS))
})
}
}
/// One bone-channel `M2Track`, read **once per model** rather than once per sequence: the v256 track
/// (0x1c) is `interp_type`@0, `global_seq`@2, then three `M2Array`s — interpolation_ranges@0x04,
/// **timestamps@0x0c**, **values@0x14** (each `{count u32, offset u32}`). Every sequence carves its
@@ -802,10 +802,61 @@ pub fn owner_last_rung(reach_world: f32) -> f32 {
(reach_world.max(0.0).floor() + 1.0).min(MAX_RUNG)
}
/// The FINITE set of rung values a mesh-shard **material** may carry — the quantization of
/// [`owner_last_rung`] for the one lane where the rung rides `StandardMaterial::depth_bias` and
/// is therefore a *pipeline-key axis* (bevy folds `depth_bias as i32` into the key; benilla
/// decision 0837). The quad-particle lane applies its rung at queue time and keeps the exact
/// value; the 3-D-shard lane materializes it, and 32 integer rungs × every reachable blend state
/// is an open key space no warm pass can pre-compile — each first-seen combination was a
/// synchronous render-thread pipeline compile mid-spell-cast. A closed bucket set is compilable
/// behind the loading cover by construction (`benilla::pipe_warm` iterates exactly this array).
///
/// Bucket choice: rounding UP is the blessed error direction (decision 0721 — over-biasing only
/// costs ordering against other models' transparents inside the delta), and the buckets are
/// spaced so the common case stays tight: spell-kit owners are small (rung ≤ 4 — the corpus
/// census `benilla-extract shardcensus` is the ground truth), so most shards take the first
/// bucket and at worst a few yards of over-bias; the tail rides the coarser rungs.
pub const OWNER_RUNG_BUCKETS: [f32; 3] = [4.0, 12.0, 32.0];
/// Snap an [`owner_last_rung`] value UP to its material bucket (see [`OWNER_RUNG_BUCKETS`]).
/// Total for any input: values past the last bucket (impossible — `owner_last_rung` caps at the
/// same 32) still return it, so the key space stays closed no matter what.
pub fn owner_last_rung_bucket(rung: f32) -> f32 {
for b in OWNER_RUNG_BUCKETS {
if rung <= b {
return b;
}
}
OWNER_RUNG_BUCKETS[OWNER_RUNG_BUCKETS.len() - 1]
}
#[cfg(test)]
mod tests {
use super::*;
/// The bucket law: total, never under the exact rung (a shard may never sort under its
/// sibling quad cloud, which keeps the exact value), monotonic, always a member of the
/// closed set — and the set's ceiling is [`owner_last_rung`]'s own cap, so no reachable
/// rung escapes the warmed key space.
#[test]
fn owner_rung_buckets_cover_every_reachable_rung() {
let max = OWNER_RUNG_BUCKETS[OWNER_RUNG_BUCKETS.len() - 1];
assert_eq!(owner_last_rung(f32::MAX), max, "one shared ceiling");
let mut prev = 0.0f32;
for r in 1..=32 {
let b = owner_last_rung_bucket(r as f32);
assert!(b >= r as f32, "bucket({r}) sorts under the exact rung");
assert!(b >= prev, "bucket must be monotonic");
assert!(
OWNER_RUNG_BUCKETS.contains(&b),
"bucket({r}) not in the set"
);
prev = b;
}
// The set itself is sorted ascending — the snap-up scan relies on it.
assert!(OWNER_RUNG_BUCKETS.windows(2).all(|w| w[0] < w[1]));
}
/// The repo root's `WoW/Data` (gitignored; the real-data test skips when absent).
fn vanilla_data_dir() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data")
@@ -889,6 +889,39 @@ fn real_item_target_family_and_its_gate_columns() {
);
assert_eq!(locked_only, 103, "Targets == 0x4000 — the OPEN_LOCK family");
// The OPEN_LOCK family's **implicit arm**, which is what turns its bare `0x4000` into the word
// both click seams read (decision 0939). The app's `cast_target_mask` ORs `0x800` for arm 23
// and `TF_UNIT` for arm 25, so this census is the data behind "a lock word is `0x4000` or
// `0x4800`, and either way `& 0x4010` and `& 0x4800` are *both* nonzero" — the overlap that
// lets one armed cursor answer the bag click and the world click. Pick Lock 1804 is one of the
// 100 (live-probed: it arms `0x4000` and both seams take it). If this distribution moves, the
// seam that stops being reachable fails here first.
let mut arms = std::collections::BTreeMap::<u32, usize>::new();
for (_, d) in cat.iter().filter(|(_, d)| d.targets == 0x4000) {
*arms.entry(d.implicit_target_a1).or_default() += 1;
}
assert_eq!(
arms.values().sum::<usize>(),
103,
"every OPEN_LOCK row is counted"
);
assert_eq!(
arms.get(&25),
Some(&1),
"one row arms 25 (its overlay is TF_UNIT, not the GameObject bit)"
);
assert!(
arms.get(&23).copied().unwrap_or(0) >= 100,
"the family is overwhelmingly arm 23, whose overlay is TARGET_FLAG_GAMEOBJECT: {arms:?}"
);
// And no row in the whole file reaches the GameObject seam by `Targets` alone — bit 11 is
// something the implicit arm puts on the word, never a column value.
assert_eq!(
cat.iter().filter(|(_, d)| d.targets & 0x800 != 0).count(),
0,
"TARGET_FLAG_GAMEOBJECT never appears in the Targets column"
);
// Bracer enchant: armor (4), any subclass, InventoryType WRIST(9) only.
let bracer = cat.get(7418).unwrap();
assert_eq!(bracer.targets, 0x10);
+8 -11
View File
@@ -564,11 +564,6 @@ pub const SLOT_BAG_FIRST: u8 = 19;
/// `TARGET_FLAG_GAMEOBJECT` — the cast-target bit that carries a GO guid (vmangos
/// `SpellDefines.h`; its `SpellCastTargets::read` is the only bit here that consumes bytes).
const TARGET_FLAG_GAMEOBJECT: u16 = 0x0800;
/// `TARGET_FLAG_LOCKED` — "this cast is opening a lock". Consumes **no** bytes on the read side
/// (vmangos `SpellCastTargets::read` tests only `TARGET_FLAG_GAMEOBJECT` for the GO guid), but the
/// real client ORs it into every OPEN_LOCK cast's mask (`0x6e44d9`/`0x6e44e1`, wow-re
/// `cursor-system.md` §8.4), so the mask we write is the mask it writes.
const TARGET_FLAG_LOCKED: u16 = 0x4000;
/// `TARGET_FLAG_UNIT` — a bound unit target: the same bit, and the same packed guid, a
/// `CMSG_CAST_SPELL` writes. In the real client ONE block builder serves both opcodes, so one bit
@@ -597,11 +592,13 @@ pub enum UseItemTarget {
/// `TARGET_FLAG_UNIT` + the packed guid — an item whose spell binds a unit exactly as a
/// spell's does: a bandage, a soulstone, an offensive trinket.
Unit(u64),
/// `TARGET_FLAG_GAMEOBJECT | TARGET_FLAG_LOCKED` + the packed guid — the **key-in-a-lock**
/// case (decision 0769): opening a locked door or chest with a key is not a spell cast, it is
/// *using the key at the object*. It matters that this is USE_ITEM and not a bare cast:
/// `Spell::CanOpenLock` honours a `Lock.dbc` KEY slot **only** when `m_CastItem` is set
/// (`Spell.cpp:7892`), which only this packet supplies.
/// `TARGET_FLAG_GAMEOBJECT` + the packed guid — the **key-in-a-lock** case (decision 0769):
/// opening a locked door or chest with a key is not a spell cast, it is *using the key at the
/// object*. It matters that this is USE_ITEM and not a bare cast: `Spell::CanOpenLock` honours
/// a `Lock.dbc` KEY slot **only** when `m_CastItem` is set (`Spell.cpp:7892`), which only this
/// packet supplies. The mask is `0x0800` alone — `TARGET_FLAG_LOCKED` is a *targeting-word*
/// bit that `BindTarget 0x6e5b40` consumes and never writes to the wire (decision 0939,
/// correcting 0769; see [`super::spells::cast_spell_gameobject`] for the census).
Object(u64),
/// `TARGET_FLAG_DEST_LOCATION` + three `f32` WoW coords — the targeting-cursor commit for a
/// **thrown** item: dynamite, grenades, bombs, the Goblin Mortar (46 of the 1.12 on-use item
@@ -635,7 +632,7 @@ pub fn use_item(bag_index: u8, slot: u8, spell_slot: u8, target: UseItemTarget)
guid
}
UseItemTarget::Object(guid) => {
body.extend_from_slice(&(TARGET_FLAG_GAMEOBJECT | TARGET_FLAG_LOCKED).to_le_bytes());
body.extend_from_slice(&TARGET_FLAG_GAMEOBJECT.to_le_bytes());
guid
}
UseItemTarget::Item(guid) => {
+20 -16
View File
@@ -321,21 +321,24 @@ pub fn cast_spell(spell_id: u32, target: Option<u64>) -> Vec<u8> {
body
}
/// `TARGET_FLAG_LOCKED` — set alongside `GAMEOBJECT` when the client casts an OPEN_LOCK spell at a
/// lockable object (decision 0239). It carries **no** wire data of its own (VERIFIED vmangos
/// `SpellCastTargets` read/write — `LOCKED` is in no read branch); the real client sends the bit, so
/// benilla mirrors it.
const TARGET_FLAG_LOCKED: u16 = 0x4000;
/// Body of `CMSG_CAST_SPELL` aimed at a **GameObject** (decision 0239): the OPEN_LOCK cast a
/// right-click on a locked chest / mining vein / herb node sends instead of `CMSG_GAMEOBJ_USE`.
/// `spell_id`, then the target mask `GAMEOBJECT | LOCKED` (0x4800), then the GameObject's **packed**
/// guid — the only field the mask reads (the GameObject branch reads one packed guid; `LOCKED` adds
/// nothing). Distinct from [`cast_spell`]'s unit/self target shape (flag `0x2` + packed guid).
/// right-click on a locked chest / mining vein / herb node sends instead of `CMSG_GAMEOBJ_USE`, and
/// the targeting cursor's world commit (decision 0939). `spell_id`, then the target mask
/// `GAMEOBJECT (0x0800)` **alone**, then the GameObject's **packed** guid — the one field that mask
/// reads. Distinct from [`cast_spell`]'s unit/self target shape (flag `0x2` + packed guid).
///
/// **`TARGET_FLAG_LOCKED (0x4000)` is not in this mask** (decision 0939, correcting 0239/0769). The
/// bit lives in the *targeting* word `0xcecac0`: the implicit-target switch ORs it there
/// (`6e44e1: orl $0x4000, %ebx` — `%ebx` is the word that switch is accumulating, not the outgoing
/// mask), and `BindTarget 0x6e5b40`'s GameObject arm then **consumes** it — `6e5f60 testb $0x48, %ch`
/// on the word, `6e5f69 orb $0x8, 0xceac5d` (⇒ `0x0800`) into the wire mask, `6e5f70` clearing the
/// word's own `0x4800`. A whole-image census of every write to the 16-bit mask at `0xceac5c` (one
/// `movw` restore plus eleven `orb` sites) contains no `0x4000` anywhere: the real client never puts
/// `LOCKED` on the wire, in this packet or any other.
pub fn cast_spell_gameobject(spell_id: u32, go_guid: u64) -> Vec<u8> {
let mut body = Vec::with_capacity(16);
body.extend_from_slice(&spell_id.to_le_bytes());
body.extend_from_slice(&(TARGET_FLAG_GAMEOBJECT | TARGET_FLAG_LOCKED).to_le_bytes());
body.extend_from_slice(&TARGET_FLAG_GAMEOBJECT.to_le_bytes());
crate::wire::write_packed_guid(go_guid, &mut body).expect("vec write");
body
}
@@ -384,15 +387,16 @@ mod tests {
#[test]
fn cast_spell_gameobject_body_golden() {
// spell_id (LE) + target mask GAMEOBJECT|LOCKED = 0x4800 (LE `00 48`) + packed guid 0x1234
// (mask 0x03, bytes 34 12). VERIFIED vmangos `SpellCastTargets`: LOCKED reads no bytes; the
// GameObject branch reads one packed guid.
// spell_id (LE) + target mask GAMEOBJECT = 0x0800 (LE `00 08`) + packed guid 0x1234
// (mask 0x03, bytes 34 12). The `0x4000` LOCKED bit is deliberately absent: it is a
// *targeting-word* bit that `BindTarget 0x6e5b40` consumes, never a wire bit (decision
// 0939 — the whole-image census of writes to `0xceac5c` has no `0x4000`).
assert_eq!(
cast_spell_gameobject(1, 0x1234),
[0x01, 0x00, 0x00, 0x00, 0x00, 0x48, 0x03, 0x34, 0x12],
[0x01, 0x00, 0x00, 0x00, 0x00, 0x08, 0x03, 0x34, 0x12],
"CMSG_CAST_SPELL (GameObject/OPEN_LOCK) body"
);
// The unit-target twin still carries flag 0x2, not 0x4800 — the two shapes stay distinct.
// The unit-target twin still carries flag 0x2, not 0x800 — the two shapes stay distinct.
assert_eq!(cast_spell(1, None), [0x01, 0x00, 0x00, 0x00, 0x00, 0x00]);
}
+11 -6
View File
@@ -23,10 +23,15 @@ fn item_query_wire() {
);
// The KEY-IN-A-LOCK form (decision 0769): the same three bytes, then a SpellCastTargets with
// mask TARGET_FLAG_GAMEOBJECT|TARGET_FLAG_LOCKED (0x0800|0x4000 = 0x4800) and the object's
// PACKED guid. This is the packet the real client sends for a key (wow-re cursor-system.md
// §8.4: sender 0x6e54f0's item arm -> 0x6e57d8 push 0xab) and the only one vmangos will open a
// KEY-slot lock for (Spell::CanOpenLock requires m_CastItem, Spell.cpp:7892).
// mask TARGET_FLAG_GAMEOBJECT (0x0800) and the object's PACKED guid. This is the packet the
// real client sends for a key (wow-re cursor-system.md §8.4: sender 0x6e54f0's item arm ->
// 0x6e57d8 push 0xab) and the only one vmangos will open a KEY-slot lock for
// (Spell::CanOpenLock requires m_CastItem, Spell.cpp:7892).
//
// `TARGET_FLAG_LOCKED` (0x4000) is deliberately NOT here (decision 0939, correcting 0769): it
// is a bit of the *targeting word* `0xcecac0` that `BindTarget 0x6e5b40`'s GameObject arm reads
// and clears (`6e5f60`/`6e5f70`) while writing only `0x800` to the outgoing mask (`6e5f69`).
// A whole-image census of writes to `0xceac5c` finds no `0x4000` anywhere.
//
// Keyring slot 1 = bag 255 + player-array slot 81 (0x51). The guid 0xF110000C1F00A3B2 has two
// zero bytes (indices 2 and 5), so it packs to mask 0xDB + the other six, low byte first.
@@ -37,14 +42,14 @@ fn item_query_wire() {
0,
messages::UseItemTarget::Object(0xF110_000C_1F00_A3B2)
),
hx("ff51000048dbb2a31f0c10f1"),
hx("ff51000008dbb2a31f0c10f1"),
);
// The packed form really does drop zero bytes: guid 1 is mask 0x01 + a single byte, and the
// spellSlot ordinal rides in the third byte.
assert_eq!(
messages::use_item(255, 81, 2, messages::UseItemTarget::Object(1)),
hx("ff510200480101")
hx("ff510200080101")
);
// The UNIT form (decision 0914): an item whose spell binds a unit the way a spell's does — a
+38
View File
@@ -363,6 +363,27 @@ impl super::UiScript {
pub fn take_item_picks(&mut self) -> Vec<(i64, u32)> {
std::mem::take(&mut self.model_mut().item_picks)
}
/// Drain the enchant-confirm popups' answers since the last call (decision 0928). Both are
/// answers to the *same* pick the app parked, which is why they share one queue.
pub fn take_enchant_confirms(&mut self) -> Vec<EnchantConfirm> {
std::mem::take(&mut self.model_mut().enchant_confirms)
}
}
/// A Yes on one of the enchant-apply confirms — the two Lua globals `StaticPopup.lua` calls from
/// `BIND_ENCHANT`'s and `REPLACE_ENCHANT`'s `OnAccept` (decision 0928). Plain intents; the app
/// holds the item guid they answer for (the reference's `0xb4e3c0/0xb4e3c4`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EnchantConfirm {
/// `BindEnchant()` — `0x48d2e0`, which re-invokes the enchant-apply gate `0x495d60` over the
/// parked guid with its "already confirmed" parameter set. So this is NOT a send: the gate
/// runs again and may raise the *replace* popup next.
Bind,
/// `ReplaceEnchant()` — `0x48d300`, which re-resolves the parked guid and calls the ordinary
/// target binder `0x6e5b40` **directly**, skipping the gate. There is no
/// `CMSG_REPLACE_ENCHANT` in 5875: the popup is pure client-side gating over the same cast.
Replace,
}
// The paper-doll globals (`PickupInventoryItem` &c.) install from [`doll`]; the action-bar
@@ -372,6 +393,23 @@ impl super::UiScript {
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// BindEnchant() / ReplaceEnchant() — the two enchant-confirm popups' OnAccept (StaticPopup.lua
// `BIND_ENCHANT` l.1240 and `REPLACE_ENCHANT` l.1252). Both queue an intent the app answers
// over its parked item guid; neither sends anything from here.
for (name, answer) in [
("BindEnchant", EnchantConfirm::Bind),
("ReplaceEnchant", EnchantConfirm::Replace),
] {
g.set(
name,
lua.create_function(move |lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.enchant_confirms.push(answer);
Ok(())
})?,
)?;
}
// GetCursorInfo() → per arm: Item ("item", itemID, itemLink); Spell ("spell", book_slot,
// book_type, spell_id) — the Era shape; Action ("action", src_slot). Empty ⇒ all nils. A
// fixed 4-return shape (padded with nil past each arm's own fields) so one function signature
+3 -1
View File
@@ -109,7 +109,9 @@ pub use char_stats::{
};
pub use container::{ContainerMove, ContainerSlot, ContainerState, EnchantView, UiCursorMode};
pub use craft::{CraftReagent, CraftRecipe, CraftState};
pub use cursor::{CursorAction, CursorItem, CursorPayload, CursorSpell, WorldPick, EQUIPMENT_BAG};
pub use cursor::{
CursorAction, CursorItem, CursorPayload, CursorSpell, EnchantConfirm, WorldPick, EQUIPMENT_BAG,
};
pub use death::{DeathAction, DeathUiState};
pub use duel::DuelRequest;
pub use follow::FollowRequest;
+4
View File
@@ -312,6 +312,9 @@ pub(crate) struct Model {
/// reports as [`super::EQUIPMENT_BAG`] + its 1-based inventory slot, so both seams speak the
/// one bag space the drain already resolves.
pub(crate) item_picks: Vec<(i64, u32)>,
/// `BindEnchant()`/`ReplaceEnchant()` — the two enchant-confirm popups' answers to a pick the
/// app already parked, drained by it (decision 0928; [`cursor::EnchantConfirm`]).
pub(crate) enchant_confirms: Vec<cursor::EnchantConfirm>,
/// `(bag, slot, count)` triples queued by `DeleteCursorItem` (`count == 0` = the whole
/// stack) — the popup-confirmed destroy (decision 0216 §3), drained by the app into
/// `CMSG_DESTROYITEM`.
@@ -746,6 +749,7 @@ impl Model {
container_repairs: Vec::new(),
item_pick_armed: false,
item_picks: Vec::new(),
enchant_confirms: Vec::new(),
container_destroys: Vec::new(),
ui_cursor: None,
container_autoequips: Vec::new(),
@@ -0,0 +1,93 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<!--
The two enchant-apply confirms (decision 0928): registry entries on the shared StaticPopup
engine (UiPanels.xml) plus their UIParent event arms, transcribed from the patch-chain
FrameXML (mpqx extraction, decision 0246) — StaticPopup.lua l.1240-1251 (BIND_ENCHANT) and
l.1252-1263 (REPLACE_ENCHANT), UIParent.lua l.110-111 (the registrations), l.437-442 (the
show arms) and l.449-453 (the CURRENT_SPELL_CAST_CHANGED hide). Strings verbatim from
GlobalStrings.lua l.415 / l.3324.
They are NOT the Craft window's, which is why they live in their own file and load with the
shared dialogs rather than with CraftFrame.xml: the gate that raises them (`0x495d60`) is
the generic item-target bind, so a rogue applying a poison and a shaman imbuing a mace
reach them without any profession window open at all.
Both are pure client-side gating. There is no CMSG_REPLACE_ENCHANT in 5875 (wow-re's
tradeskill node: no CDataStore put/Send anywhere in `0x48d2e0`/`0x48d300`/`0x495d60`
/`0x6e5b40` outside the shared SendCast) — the popups withhold the *same* cast and their
Yes re-issues it. `BindEnchant()` re-enters the gate with its confirmed flag set, so a Yes
here can raise the replace popup next; `ReplaceEnchant()` skips the gate entirely.
The third sibling, TRADE_REPLACE_ENCHANT (event 404, fired at `0x496230` with a third
numeric arg), belongs to the trade-window enchant path — a different wire shape
(TARGET_FLAG_TRADE_ITEM + the slot sentinel) we do not build yet, so it is not here.
-->
<Script><![CDATA[
-- ── Strings (GlobalStrings.lua, verbatim) ───────────────────────────────────────────────
local BIND_ENCHANT = "Enchanting this item will bind it to you." -- l.415
local REPLACE_ENCHANT = "Do you want to replace \"%s\" with \"%s\"?" -- l.3324
local OKAY = "Okay"
local CANCEL = "Cancel"
local YES = "Yes"
local NO = "No"
-- ── The bind confirm — StaticPopup.lua l.1240-1251, verbatim ───────────────────────────
-- No OnCancel: declining sends nothing and needs no teardown, because the gate returned
-- before BindTarget and the targeting word is still standing. The cursor simply stays up.
StaticPopupDialogs["BIND_ENCHANT"] = {
text = BIND_ENCHANT,
button1 = OKAY,
button2 = CANCEL,
OnAccept = function()
BindEnchant()
end,
timeout = 0,
exclusive = 1,
showAlert = 1,
hideOnEscape = 1,
}
-- ── The replace confirm — StaticPopup.lua l.1252-1263, verbatim ────────────────────────
-- arg1 = the enchant already on the item, arg2 = the one about to replace it, in that
-- order (the fire site pushes old then new: `0x703f50(0x193, "%s%s", old, new)`).
StaticPopupDialogs["REPLACE_ENCHANT"] = {
text = REPLACE_ENCHANT,
button1 = YES,
button2 = NO,
OnAccept = function()
ReplaceEnchant()
end,
timeout = 0,
exclusive = 1,
showAlert = 1,
hideOnEscape = 1,
}
-- ── The UIParent event arms (UIParent.lua l.437-453, verbatim) ─────────────────────────
function BenillaEnchantConfirmDriver_OnEvent()
if event == "BIND_ENCHANT" then
StaticPopup_Show("BIND_ENCHANT")
elseif event == "REPLACE_ENCHANT" then
StaticPopup_Show("REPLACE_ENCHANT", arg1, arg2)
elseif event == "CURRENT_SPELL_CAST_CHANGED" then
-- The pending cast the popups are asking about is gone (cancelled, replaced, or
-- committed), so the question is moot. This is the ref's ONLY teardown for them.
StaticPopup_Hide("BIND_ENCHANT")
StaticPopup_Hide("REPLACE_ENCHANT")
end
end
]]></Script>
<Frame name="BenillaEnchantConfirmDriver" hidden="true">
<Size><AbsDimension x="1" y="1"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Scripts>
<OnLoad>
self:RegisterEvent("BIND_ENCHANT")
self:RegisterEvent("REPLACE_ENCHANT")
self:RegisterEvent("CURRENT_SPELL_CAST_CHANGED")
</OnLoad>
<OnEvent>BenillaEnchantConfirmDriver_OnEvent()</OnEvent>
</Scripts>
</Frame>
</Ui>
+176 -69
View File
@@ -30,11 +30,11 @@
needs to call the right one of `SelectGossipQuest`/`SelectGossipOption` (ref's three intents
collapse to two on our seam — see gossip.rs's doc comment). Per-row icon: active → the real
`Interface\GossipFrame\ActiveQuestIcon` (ref l.99), available → `Interface\GossipFrame\
AvailableQuestIcon` (ref l.75) — both verified present in the MPQ list. We skip the ref's
per-row `GossipResize` auto-height (ref l.130-132, `SetHeight(GetTextHeight()+2)`) and its
"too many quests" message() (ref l.67-69/91-93/115-117): no word-wrap engine to measure against
yet (same gap the greeting text already lives with), and the shared 8-row cap below silently
drops the overflow exactly like the option rows already did before this change.
AvailableQuestIcon` (ref l.75) — both verified present in the MPQ list. We DO run the ref's
per-row `GossipResize` auto-height (ref l.130-132, `SetHeight(GetTextHeight()+2)`) — see
`BenillaGossipRow_Resize` below — but skip its "too many quests" message()
(ref l.67-69/91-93/115-117): the shared 8-row cap below silently drops the overflow exactly
like the option rows always did.
ENGINE-SLICE CAVEAT (this branch predates the dressing arc's engine slice — named `<Font>`
objects are not implemented here yet, loader.rs "named Font objects are not implemented in v1").
@@ -51,10 +51,10 @@
it lands. The GOODBYE button rides bottom-right (ref GossipFrameGreetingGoodbyeButton, l.206-222),
wired to the same close path as the close button.
Real-window features we OMIT (no feed, or no scroll/wrap engine yet):
the scroll frame itself (ref l.223-436 — our short greeting needs no scrolling),
and the BotLeftPatch material art. We keep the real geometry of everything we DO ship — including
the quest-row section now (see below).
The scroll frame IS here now (ref l.223-436): the greeting + the 32-row pool ride a real
`<ScrollFrame>`, so a menu taller than the parchment scrolls instead of spilling out of the
window. See the ScrollFrame's own comment below. Nothing of the real window is knowingly
omitted any more except the BotLeftPatch material art.
UIPanel slot system (decision 0084 §2): carries NO hardcoded <Anchors> — ShowUIPanel/SetLeftFrame
SetPoints it to the left slot (TOPLEFT UIParent, 0, -104) on open; every show goes through
@@ -62,9 +62,20 @@
-->
<Script><![CDATA[
-- The shared row pool's cap (decision 0088 §3): quest rows fill from slot 1, option rows fill
-- after them, both drawing from the SAME BenillaGossipRow1..8 buttons (ref's single
-- GossipTitleButton pool, l.24-29/63-128) — mirrors the option-only cap this already was.
BENILLA_GOSSIP_MAX = 8
-- after them, both drawing from the SAME BenillaGossipRow1..32 buttons (ref's single
-- GossipTitleButton pool, l.24-29/63-128). This IS the reference's number
-- (`NUMGOSSIPBUTTONS = 32`, ref-GossipFrame.lua l.1); benilla ran a cap of 8 only because an
-- overflowing menu had nowhere to go — with the scroll frame below, it does.
BENILLA_GOSSIP_MAX = 32
-- Every widget that can be the LOWEST visible thing in the scroll child, for the content
-- measure (BenillaScroll_ResizeChild, ScrollTemplates.xml). Built once at load, like the
-- quest window's per-panel lists.
BENILLA_GOSSIP_SCROLL_WIDGETS = (function()
local t = { "BenillaGossipGreetingText" }
for i = 1, BENILLA_GOSSIP_MAX do table.insert(t, "BenillaGossipRow" .. i) end
return t
end)()
-- The Era icon-type string → the real Blizzard gossip-icon texture (resolves from the MPQ at
-- render). Unmapped types fall back to the chat bubble (the app already defaults unknowns to
@@ -89,6 +100,49 @@
button.label = getglobal(button:GetName() .. "Label")
end
-- The ref's GossipResize (ref l.130-132: `SetHeight(GetTextHeight() + 2)`) — a row is as
-- tall as its OWN wrapped label. Load-bearing, not cosmetic: the rows hang off each other
-- in one static chain (each on the previous row's BOTTOMLEFT), so a row left at the
-- template's 16px while its label draws three wrapped lines puts the next row's text
-- straight through this one's — every long option overprinting its neighbour (the
-- director's judgement-menu screenshot). The reference's own LEFT-anchored label is only
-- correct BECAUSE of this resize: centring the text on a row sized to the text is the same
-- as top-aligning it.
--
-- Deviation from the ref, the same one the tab-fit carries (UiPanels.xml
-- `BenillaTabButton_OnUpdate`): the client's GetTextHeight is a synchronous font-engine
-- call, ours is the measure round-trip's answer and lands a frame late — 0 until the host
-- has measured THIS text. A zero keeps the current height (never collapses the row) and
-- the frame's OnUpdate below re-runs the fit until it settles.
function BenillaGossipRow_Resize(button)
local h = button.label:GetStringHeight()
if h <= 0 or button.benillaFitHeight == h then return end
button.benillaFitHeight = h
button:SetHeight(h + 2)
end
-- Size the scroll child to the rows actually on it, so the bar ranges over the real content
-- (the ref grows its child with the 15px GossipSpacerFrame under the last row instead —
-- ref-GossipFrame.lua l.39-44; the shared kit's BENILLA_SCROLL_PAD is that breathing room).
function BenillaGossipFrame_SizeScrollChild()
BenillaScroll_ResizeChild("BenillaGossipGreetingScroll", BENILLA_GOSSIP_SCROLL_WIDGETS)
end
-- The convergence driver (the tab-fit / quest-panel idiom): while the menu is up, re-fit
-- every visible row until its measure lands, after which the guard above makes this a
-- no-op compare per row per frame. OnUpdate only fires on effectively-visible frames, so
-- a closed gossip window costs nothing. The rows fit FIRST and the child is measured after,
-- so the scroll range sees their settled heights in the same pass.
function BenillaGossipFrame_OnUpdate()
for i = 1, BENILLA_GOSSIP_MAX do
local button = getglobal("BenillaGossipRow" .. i)
if button:IsShown() then
BenillaGossipRow_Resize(button)
end
end
BenillaGossipFrame_SizeScrollChild()
end
-- One shared row serves either a quest or an option depending on what
-- BenillaGossipFrame_Update assigned it this repaint (button.kind, set there) — mirrors the
-- ref's titleButton.type dispatch (ref l.53-61), collapsed onto our seam's two intents
@@ -117,6 +171,7 @@
button.kind = "quest"
button.itemIndex = i
button.label:SetText(title)
BenillaGossipRow_Resize(button)
button.icon:SetTexture(active and BENILLA_GOSSIP_QUEST_ICON_ACTIVE or BENILLA_GOSSIP_QUEST_ICON_AVAILABLE)
button:Enable()
button:Show()
@@ -129,6 +184,9 @@
button.kind = "option"
button.itemIndex = optIndex
button.label:SetText(label)
-- A label whose measure is already cached (a revisit to this NPC) fits on this
-- very repaint; a fresh string fits on the next OnUpdate tick.
BenillaGossipRow_Resize(button)
button.icon:SetTexture(BENILLA_GOSSIP_ICONS[otype] or BENILLA_GOSSIP_ICONS.gossip)
-- Coded (password) options are greyed and unselectable in v1 (decision 0081).
if IsGossipOptionCoded(optIndex) then
@@ -141,6 +199,10 @@
button:Hide()
end
end
-- The ref's GossipFrameUpdate tail (ref-GossipFrame.lua l.46-48): a repaint always lands
-- back at the TOP of the menu, then re-ranges the bar off the new content.
getglobal("BenillaGossipGreetingScroll"):SetVerticalScroll(0)
BenillaGossipFrame_SizeScrollChild()
end
function BenillaGossipFrame_OnLoad(frame)
@@ -265,66 +327,107 @@
<Anchor point="TOP" relativePoint="TOP"><Offset><AbsDimension x="0" y="-23"/></Offset></Anchor>
</Anchors>
</FontString>
<!-- Greeting body: the real GossipGreetingText (ref l.241-252) — QuestFont, LEFT, its
width pinned to the real 270 so word-wrap engages once that engine lands (line
breaks today come from the wire's $B macro → \n). Positioned at the real scroll
frame's text origin: window TOPLEFT +(23,-81) (scroll frame, ref l.230) then
+(10,-10) inside the scroll child (ref l.248) = (33,-91). -->
<FontString name="BenillaGossipGreetingText" inherits="QuestFont" justifyH="LEFT">
<Size><AbsDimension x="270" y="0"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="33" y="-91"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- The shared row pool: the real GossipTitleButtonTemplate stack (ref l.256-309), now
serving quest rows AND option rows off the SAME 8 buttons (decision 0088 §3 — the
ref's own shape, ref l.24-29/63-128: quest rows fill first, options after). Row 1
anchors to the GREETING FONTSTRING's BOTTOMLEFT +(-10,-20) — the real anchor verbatim
(ref l.258-261): the greeting's height is its measured wrapped text (the engine's
measure round-trip), so a 3-line greeting pushes the rows down exactly as the real
client does (the fixed-offset bridge used before overprinted multi-line greetings —
caught by the ui-gossip capture fixture). The rest stack on the prior row's BOTTOMLEFT
(ref l.267 — no gap; the real 16px rows touch) — since it's one static anchor chain
over one shared pool, however many quest rows BenillaGossipFrame_Update shows this
repaint, the option rows that follow land right below them with no runtime
repositioning (decision 0088 §2 flags runtime SetPoint as NOT propagating to
dependents on this engine; a static XML chain has no such issue — verified by the
layout assertions in the engine test below). -->
<Button name="BenillaGossipRow1" inherits="BenillaGossipRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipGreetingText" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="-10" y="-20"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts>
</Button>
<Button name="BenillaGossipRow2" inherits="BenillaGossipRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow1" relativePoint="BOTTOMLEFT"/></Anchors>
<Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts>
</Button>
<Button name="BenillaGossipRow3" inherits="BenillaGossipRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow2" relativePoint="BOTTOMLEFT"/></Anchors>
<Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts>
</Button>
<Button name="BenillaGossipRow4" inherits="BenillaGossipRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow3" relativePoint="BOTTOMLEFT"/></Anchors>
<Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts>
</Button>
<Button name="BenillaGossipRow5" inherits="BenillaGossipRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow4" relativePoint="BOTTOMLEFT"/></Anchors>
<Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts>
</Button>
<Button name="BenillaGossipRow6" inherits="BenillaGossipRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow5" relativePoint="BOTTOMLEFT"/></Anchors>
<Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts>
</Button>
<Button name="BenillaGossipRow7" inherits="BenillaGossipRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow6" relativePoint="BOTTOMLEFT"/></Anchors>
<Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts>
</Button>
<Button name="BenillaGossipRow8" inherits="BenillaGossipRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow7" relativePoint="BOTTOMLEFT"/></Anchors>
<Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts>
</Button>
<!--
The greeting + row list live inside a REAL ScrollFrame — the reference's own structure
(ref-GossipFrame.xml l.223-436, extracted via mpqx this session), which benilla omitted
on the grounds that "our short greeting needs no scrolling". A gossip menu of wrapped
roleplay options is exactly the case that breaks: once each row grew to its text
(BenillaGossipRow_Resize), four long options ran off the parchment and out of the
window (the director's second screenshot). The ref's answer is this scroll frame, and
it is now ours: 300x334 at the window's TOPLEFT +(23,-81) (ref l.223-233) over a child
the same size (ref l.235-239), the greeting at the child's +(10,-10) (ref l.241-252),
and the row chain hanging off the greeting exactly as before (ref l.256-269).
Content taller than the frame is CLIPPED to it and panned (engine mechanism, decision
0112) instead of overflowing; BenillaGossipFrame_SizeScrollChild sizes the child to the
measured content each repaint, which re-ranges the bar through OnScrollRangeChanged.
The ref grows its child with a 15px GossipSpacerFrame under the last row (ref l.421-432,
re-anchored per update by GossipFrame.lua l.39-44); ours is the shared kit's
BENILLA_SCROLL_PAD, the same breathing room by a plainer mechanism.
-->
<ScrollFrame name="BenillaGossipGreetingScroll">
<Size><AbsDimension x="300" y="334"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="23" y="-81"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>self:SetScrollChild(getglobal(self:GetName() .. "Child"))</OnLoad>
<OnMouseWheel>BenillaScroll_OnMouseWheel(this, arg1)</OnMouseWheel>
<OnVerticalScroll>BenillaScroll_SyncBar(this)</OnVerticalScroll>
<OnScrollRangeChanged>BenillaScroll_SyncBar(this)</OnScrollRangeChanged>
</Scripts>
<Frames>
<!-- The scrollbar rides the ScrollFrame as a child, the ref's UIPanelScrollFrameTemplate
shape (ref-UIPanelTemplates.xml l.166-181: $parentScrollBar, TOPLEFT->TOPRIGHT
+(6,-16) / BOTTOMLEFT->BOTTOMRIGHT +(6,16)) — so the bar's PARENT is the frame it
scrolls, with no name derivation. It wears the shared Slider bar (drag + arrows,
decision 0250); only OnValueChanged is overridden, off the faux kit's row-offset
handler onto the real-scroll one. Hidden until the content overflows (SyncBar). -->
<Slider name="$parentBar" inherits="BenillaScrollBarTemplate" hidden="true">
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="6" y="-16"/></Offset></Anchor>
<Anchor point="BOTTOMLEFT" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="6" y="16"/></Offset></Anchor>
</Anchors>
<Scripts><OnValueChanged>BenillaScrollBar_OnValueChanged(self, arg1)</OnValueChanged></Scripts>
</Slider>
<Frame name="$parentChild">
<Size><AbsDimension x="300" y="334"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<Layers>
<Layer level="ARTWORK">
<!-- Greeting body: the real GossipGreetingText (ref l.241-252) — QuestFont,
LEFT, 270 wide so the engine wraps it, at the child's +(10,-10). -->
<FontString name="BenillaGossipGreetingText" inherits="QuestFont" justifyH="LEFT">
<Size><AbsDimension x="270" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="10" y="-10"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- The shared row pool, now the reference's full NUMGOSSIPBUTTONS = 32
(ref-GossipFrame.lua l.1; ours capped at 8 only because an overflowing
menu had nowhere to go). Quest rows fill it first, option rows after
(decision 0088 sec.3). Row 1 hangs off the GREETING's BOTTOMLEFT +(-10,-20),
the rest off the previous row's BOTTOMLEFT (ref l.256-269) — one static
chain, so rows re-flow off each other's measured heights with no runtime
repositioning. -->
<Button name="BenillaGossipRow1" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipGreetingText" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="-10" y="-20"/></Offset></Anchor></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow2" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow1" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow3" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow2" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow4" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow3" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow5" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow4" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow6" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow5" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow7" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow6" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow8" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow7" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow9" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow8" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow10" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow9" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow11" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow10" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow12" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow11" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow13" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow12" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow14" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow13" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow15" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow14" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow16" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow15" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow17" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow16" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow18" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow17" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow19" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow18" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow20" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow19" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow21" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow20" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow22" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow21" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow23" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow22" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow24" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow23" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow25" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow24" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow26" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow25" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow27" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow26" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow28" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow27" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow29" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow28" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow30" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow29" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow31" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow30" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="BenillaGossipRow32" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaGossipRow31" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
</Frames>
</Frame>
</Frames>
</ScrollFrame>
<!-- Close: the real GossipFrameCloseButton position (CENTER on the window's TOPRIGHT,
-42,-31; ref l.179-187), on the minimize-button art our close already used (the real
UIPanelCloseButton art rides the tooltip/dialog arc). -->
@@ -386,6 +489,10 @@
<Scripts>
<OnLoad>BenillaGossipFrame_OnLoad(self)</OnLoad>
<OnEvent>BenillaGossipFrame_OnEvent(self)</OnEvent>
<!-- The row auto-height's settle pass (see BenillaGossipRow_Resize): our text measure
is a frame late, so the fit re-runs here until every visible row's label measure
lands. Fires only while the window is effectively visible. -->
<OnUpdate>BenillaGossipFrame_OnUpdate()</OnUpdate>
<!-- OnShow/OnHide, quoted from the real GossipFrame.xml frame Scripts (l.445/454): the
kits igQuestListOpen/Close, and — load-bearing — CloseGossip() on hide (the real
GossipFrame_OnHide). CloseGossip ends the gossip session whenever the frame hides,
+32 -37
View File
@@ -121,7 +121,6 @@
BENILLA_QUEST_SCROLL_STEP = 20
BENILLA_QUEST_SCROLL_TRACK_H = 302 -- 334-tall scroll, minus the two 16px arrows
BENILLA_QUEST_SCROLL_THUMB_H = 16
BENILLA_QUEST_CHILD_PAD = 20 -- parchment breathing room under the last element
function BenillaQuestScroll_Step(sf, delta)
sf:SetVerticalScroll(sf:GetVerticalScroll() + delta) -- engine clamps to [0, range]
@@ -153,43 +152,13 @@
"TOPLEFT", getglobal(n .. "BarTrack"), "TOPLEFT", 0, -(value / range * travel))
end
-- A widget's resolved bottom edge for the resize sum — nil when it isn't contributing
-- (hidden frame / blank FontString / not yet resolved this frame).
local function BenillaQuest_WidgetBottom(w)
if not w then return nil end
if w.IsShown then
if not w:IsShown() then return nil end
elseif w.GetText then
local t = w:GetText()
if not t or t == "" then return nil end
end
return w:GetBottom()
end
-- Size a panel's scroll child to its MEASURED content (the resolved bottom of the lowest
-- visible widget + padding) instead of a fixed budget — a fixed 700 left a huge dead
-- scrollable span under short quests (the director's jank report). Frame-late like the
-- log's resize (rects resolve after Lua runs), so the panel OnUpdate below re-runs this
-- until it stops changing — the log's own convergence pattern.
-- Size the ACTIVE panel's scroll child to its measured content — the shared real-scroll
-- kit's `BenillaScroll_ResizeChild` (ScrollTemplates.xml), which owns the widget-bottom sum,
-- the change-guard, and the UpdateScrollChildRect. Only the bar sync stays here: this window
-- wears the minimal no-drag bar, not the kit's Slider (see the scrollbar markup below).
function BenillaQuestPanel_Resize(scrollName, widgets)
local sf = getglobal(scrollName)
local child = getglobal(scrollName .. "Child")
local top = child:GetTop()
if not top then return end
local bottom = nil
for i = 1, table.getn(widgets) do
local b = BenillaQuest_WidgetBottom(getglobal(widgets[i]))
if b and (not bottom or b < bottom) then bottom = b end
end
local height = 1
if bottom then height = (top - bottom) + BENILLA_QUEST_CHILD_PAD end
if height < 1 then height = 1 end
-- Only a real change re-sizes (SetHeight/UpdateScrollChildRect invalidate layout —
-- an every-frame write would fight the resolver).
if math.abs(child:GetHeight() - height) >= 1 then
child:SetHeight(height)
sf:UpdateScrollChildRect()
BenillaQuestScroll_SyncBar(sf)
if BenillaScroll_ResizeChild(scrollName, widgets) then
BenillaQuestScroll_SyncBar(getglobal(scrollName))
end
end
@@ -233,9 +202,19 @@
-- The convergence driver: while the window shows, keep re-measuring the ACTIVE panel
-- (cheap — the resize itself no-ops once the sum stabilizes; the log's own OnUpdate idiom).
-- The greeting panel's title rows fit FIRST, so the panel sum below already sees their
-- final bottoms in this same pass.
function BenillaQuestFrame_OnUpdate()
local frame = getglobal("BenillaQuestFrame")
if not frame:IsVisible() or not frame.panel then return end
if frame.panel == "greeting" then
for i = 1, BENILLA_QUEST_MAX_TITLES do
local button = getglobal("BenillaQuestTitleButton" .. i)
if button:IsShown() then
BenillaQuestTitleButton_Resize(button)
end
end
end
local scroll = BENILLA_QUEST_PANEL_SCROLLS[frame.panel]
if scroll then
BenillaQuestPanel_Resize(scroll, BENILLA_QUEST_PANEL_WIDGETS[scroll])
@@ -248,6 +227,20 @@
PlaySound("igQuestListSelect") -- ref QuestTitleButton_OnClick, QuestFrame.lua:303
end
-- A title row is as tall as its own wrapped title, +2 (ref QuestFrameGreetingPanel_OnShow's
-- `SetHeight(GetTextHeight() + 2)`, QuestFrame.lua:243/272 — the same law GossipFrame.xml's
-- `BenillaGossipRow_Resize` runs on the gossip rows). Titles wrap at the ButtonText's 275 px,
-- and the rows hang off each other, so a row left at the template's 16px while its title
-- draws two lines prints the next title through it. Our GetStringHeight lands a frame after
-- the SetText (the measure round-trip), hence the guard + the OnUpdate settle above.
function BenillaQuestTitleButton_Resize(button)
local text = button:GetFontString()
local h = text and text:GetStringHeight() or 0
if h <= 0 or button.benillaFitHeight == h then return end
button.benillaFitHeight = h
button:SetHeight(h + 2)
end
function BenillaQuestGreetingPanel_OnShow()
getglobal("BenillaQuestDetailPanel"):Hide()
getglobal("BenillaQuestProgressPanel"):Hide()
@@ -272,6 +265,7 @@
button.isActive = true
button.titleIndex = i
button:SetText(GetActiveTitle(i))
BenillaQuestTitleButton_Resize(button)
button:Show()
if i == 1 then
button:SetPoint("TOPLEFT", activeHeader, "BOTTOMLEFT", -10, -5)
@@ -303,6 +297,7 @@
button.isActive = false
button.titleIndex = i - numActive
button:SetText(GetAvailableTitle(i - numActive))
BenillaQuestTitleButton_Resize(button)
button:Show()
if i == numActive + 1 then
button:SetPoint("TOPLEFT", availableHeader, "BOTTOMLEFT", -10, -5)
+103 -8
View File
@@ -1,6 +1,14 @@
<Ui>
<!--
benilla's reusable **faux-scroll** kit (decisions 0247/0250/0251): a draggable scroll bar over
benilla's reusable scroll kits — TWO of them, sharing one scrollbar and one pair of arrow
helpers. The **faux-scroll** kit (this header) re-binds a fixed set of rows to a moving data
offset; the **real-scroll** kit (second half of the script block) pans and CLIPS a
variable-height child through the engine's `<ScrollFrame>` (decision 0112) — the one a gossip
menu of wrapped options needs, since its content height isn't known until it is laid out.
Only their OnValueChanged differs, and an instance overrides the template's (the loader
SetScripts every `<Scripts>` block in order, so the instance's handler wins).
The faux kit (decisions 0247/0250/0251): a draggable scroll bar over
the `Slider` primitive (0250) plus the fixed-row "faux scroll" Lua layer — the reference's
`FauxScrollFrame`, which never really scrolls a child: it re-binds a fixed set of row buttons to
a moving data offset. First customer: the class trainer's skill-line list (TrainerFrame.xml).
@@ -31,7 +39,7 @@
-- design: the ref plays UChatScrollButton only on the arrow *buttons'* OnClick, never on the
-- wheel (ScrollFrameTemplate_OnMouseWheel is soundless). Both the wheel and the arrows call
-- this, so the click lives on the arrow OnClick, keeping wheel-scroll silent.
function BenillaFauxScrollBar_Step(scrollBar, dir)
function BenillaScrollBar_Step(scrollBar, dir)
local step = scrollBar:GetValueStep()
if step <= 0 then step = 1 end
scrollBar:SetValue(scrollBar:GetValue() + dir * step)
@@ -39,7 +47,7 @@
-- Grey the up arrow at the top and the down arrow at the bottom (ref
-- ScrollFrame_OnScrollRangeChanged's arrow handling), from the slider's own value/range.
function BenillaFauxScrollBar_UpdateArrows(scrollBar)
function BenillaScrollBar_UpdateArrows(scrollBar)
local minV, maxV = scrollBar:GetMinMaxValues()
local val = scrollBar:GetValue()
local up = getglobal(scrollBar:GetName() .. "ScrollUpButton")
@@ -62,7 +70,7 @@
frame.offset = newOffset
if frame.updateFunc then frame.updateFunc() end
end
BenillaFauxScrollBar_UpdateArrows(scrollBar)
BenillaScrollBar_UpdateArrows(scrollBar)
end
-- OnLoad: a fresh faux frame is at the top with an empty range (ref ScrollFrame_OnLoad).
@@ -100,7 +108,7 @@
scrollBar:SetValueStep(itemHeight)
scrollBar:SetMinMaxValues(0, maxOffset * itemHeight)
scrollBar:SetValue(frame.offset * itemHeight)
BenillaFauxScrollBar_UpdateArrows(scrollBar)
BenillaScrollBar_UpdateArrows(scrollBar)
if numItems > numToDisplay then
scrollBar:Show()
@@ -114,7 +122,94 @@
-- lives on the window, not a child — TrainerFrame.xml's own note).
function BenillaFauxScrollFrame_OnMouseWheel(frame, delta)
local scrollBar = getglobal(frame:GetName() .. "ScrollBar")
BenillaFauxScrollBar_Step(scrollBar, -delta)
BenillaScrollBar_Step(scrollBar, -delta)
end
-- ══════════════════════════ The REAL-scroll kit ══════════════════════════════════════════
--
-- The other half of the file: a true `<ScrollFrame>` (engine mechanism, decision 0112) that
-- pans and CLIPS a variable-height child, as against the faux kit above (fixed rows re-bound
-- to a data offset). Windows whose content height is unknown until it is laid out — a gossip
-- menu of wrapped options, a quest's body text — need this one; a uniform list wants the faux.
--
-- SHAPE, from the reference `UIPanelScrollFrameTemplate` (Interface\FrameXML\
-- UIPanelTemplates.xml l.166-211, extracted via mpqx): the scrollbar is a SLIDER that rides
-- its ScrollFrame as a child, so `bar:GetParent()` IS the frame it scrolls — no name
-- derivation, and `$parentBar` gives SyncBar its handle. The ref's OnVerticalScroll drives
-- the bar's value and greys the two arrows at the ends; ours does the same through the
-- shared arrow helper above.
BENILLA_SCROLL_STEP = 20 -- px per arrow click / wheel notch
BENILLA_SCROLL_PAD = 20 -- breathing room under the last element in the child
-- The bar moved (drag, arrow, or SyncBar): pan the frame it rides. The loop this could form
-- (bar → scroll → OnVerticalScroll → SyncBar → bar) dead-ends in one hop — SyncBar writes the
-- value the bar already holds, and the Slider's SetValue change-gate (decision 0250) drops it.
function BenillaScrollBar_OnValueChanged(bar, value)
bar:GetParent():SetVerticalScroll(value)
BenillaScrollBar_UpdateArrows(bar)
end
-- Reconcile a ScrollFrame's bar to its live range (ref ScrollFrame_OnScrollRangeChanged):
-- the bar spans [0, range] in pixels, seats at the current scroll, and hides entirely when
-- the content fits. Wired to both OnScrollRangeChanged and OnVerticalScroll by the owner.
function BenillaScroll_SyncBar(sf)
local bar = getglobal(sf:GetName() .. "Bar")
if not bar then return end
local range = sf:GetVerticalScrollRange()
bar:SetValueStep(BENILLA_SCROLL_STEP)
bar:SetMinMaxValues(0, range)
bar:SetValue(sf:GetVerticalScroll())
BenillaScrollBar_UpdateArrows(bar)
if range > 0 then bar:Show() else bar:Hide() end
end
-- Pan by `delta` px; the engine clamps to [0, range].
function BenillaScroll_Step(sf, delta)
sf:SetVerticalScroll(sf:GetVerticalScroll() + delta)
BenillaScroll_SyncBar(sf)
end
-- WoW wheel convention: +1 per notch up, -1 down — up lifts the content.
function BenillaScroll_OnMouseWheel(sf, delta)
BenillaScroll_Step(sf, -delta * BENILLA_SCROLL_STEP)
end
-- A widget's resolved bottom edge for the content sum — nil when it isn't contributing
-- (hidden frame / blank FontString / not yet resolved this frame).
function BenillaScroll_WidgetBottom(w)
if not w then return nil end
if w.IsShown then
if not w:IsShown() then return nil end
elseif w.GetText then
local t = w:GetText()
if not t or t == "" then return nil end
end
return w:GetBottom()
end
-- Size a scroll child to its MEASURED content — the resolved bottom of the lowest visible
-- widget, plus padding — instead of a fixed budget, and tell the frame (which fires
-- OnScrollRangeChanged → the owner's SyncBar). Frame-late by nature: rects resolve after Lua
-- runs, so owners re-run this from an OnUpdate until it stops changing. Returns true when it
-- actually resized (only a real change writes: SetHeight invalidates layout, and an
-- every-frame write would fight the resolver).
function BenillaScroll_ResizeChild(scrollName, widgets)
local sf = getglobal(scrollName)
local child = getglobal(scrollName .. "Child")
local top = child:GetTop()
if not top then return false end
local bottom = nil
for i = 1, table.getn(widgets) do
local b = BenillaScroll_WidgetBottom(getglobal(widgets[i]))
if b and (not bottom or b < bottom) then bottom = b end
end
local height = 1
if bottom then height = (top - bottom) + BENILLA_SCROLL_PAD end
if height < 1 then height = 1 end
if math.abs(child:GetHeight() - height) < 1 then return false end
child:SetHeight(height)
sf:UpdateScrollChildRect()
return true
end
]]></Script>
@@ -135,7 +230,7 @@
<PushedTexture file="Interface\Buttons\UI-ScrollBar-ScrollUpButton-Down"><TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/></PushedTexture>
<DisabledTexture file="Interface\Buttons\UI-ScrollBar-ScrollUpButton-Disabled"><TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/></DisabledTexture>
<HighlightTexture file="Interface\Buttons\UI-ScrollBar-ScrollUpButton-Highlight" alphaMode="ADD"><TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/></HighlightTexture>
<Scripts><OnClick>BenillaFauxScrollBar_Step(this:GetParent(), 1); PlaySound("UChatScrollButton")</OnClick></Scripts>
<Scripts><OnClick>BenillaScrollBar_Step(this:GetParent(), -1); PlaySound("UChatScrollButton")</OnClick></Scripts>
</Button>
<Button name="$parentScrollDownButton">
<Size><AbsDimension x="16" y="16"/></Size>
@@ -144,7 +239,7 @@
<PushedTexture file="Interface\Buttons\UI-ScrollBar-ScrollDownButton-Down"><TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/></PushedTexture>
<DisabledTexture file="Interface\Buttons\UI-ScrollBar-ScrollDownButton-Disabled"><TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/></DisabledTexture>
<HighlightTexture file="Interface\Buttons\UI-ScrollBar-ScrollDownButton-Highlight" alphaMode="ADD"><TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/></HighlightTexture>
<Scripts><OnClick>BenillaFauxScrollBar_Step(this:GetParent(), -1); PlaySound("UChatScrollButton")</OnClick></Scripts>
<Scripts><OnClick>BenillaScrollBar_Step(this:GetParent(), 1); PlaySound("UChatScrollButton")</OnClick></Scripts>
</Button>
</Frames>
<Scripts>
+31 -2
View File
@@ -209,21 +209,50 @@ pub(super) fn seed_ui_fixture(
);
gossip.npc = Some(NPC_GUID);
gossip.text_id = 1;
// The greeting of the live menu the overflow was reported on: long enough that the
// wrapped options below it run past the parchment, so this capture stands on the
// scrolling path rather than beside it.
gossip.greeting = Some(
"Hey, citizen! You look like a stout one. We guards are spread a little thin \
out here, and I could use your help..."
"A man has been caught stealing corn from the fields of a noble, a lord known \
for his harsh taxes throughout the land.$B$BMake your choice!"
.into(),
);
// Quest rows ride above the options (decision 0088) — an available quest (its own
// AvailableQuestIcon dot) so the capture covers the quest-row icon/text seating, not
// just the option rows.
gossip.quests = vec![(783, 0, "Eagan Peltskinner".into())];
// One short option AND four that WRAP — the live shape both gossip bugs came in as
// (the director's screenshots), and a menu deliberately TALLER than the parchment so
// the capture covers the whole chain: the per-row auto-height
// (`BenillaGossipRow_Resize`), the scroll frame that contains the overflow, and the
// scrollbar that appears with it. A fixture of one-line labels showed none of this —
// every row fit the template's 16 px and nothing ever overflowed.
let judgement = [
"I slay the man on the spot as my liege would expect me to, as he has broken the \
law of the land and it is my sworn duty to enforce it.",
"I turn over the man to my liege for punishment, as he has stolen, and I am not \
the arbiter of his fate.",
"I confiscate the corn he has stolen, warn him that stealing is a path towards \
doom and destruction, but I let him go to return to his family.",
"I allow the man to take enough corn to feed his family for a couple of days, \
encouraging him to leave the land.",
];
gossip.options = vec![benilla_protocol::messages::GossipOption {
index: 0,
icon: 1,
coded: false,
message: "Let me browse your goods.".into(),
}];
gossip
.options
.extend(judgement.iter().enumerate().map(|(i, message)| {
benilla_protocol::messages::GossipOption {
index: i as u32 + 1,
icon: 0,
coded: false,
message: (*message).into(),
}
}));
}
UiFixture::Bank => {
// The banker (a pure banker — the Ironforge vault's own name) + the vault fed the REAL
@@ -156,6 +156,14 @@ impl MatAnim {
/// base clips are live and the heavier one wins; the reference instead blends the two sampled
/// scalars by λ (wow-re `eval.md` FN 0x71af20's blend leg), a sub-blend-time difference on tracks
/// the corpus authors as 0/1 steps — recorded, not modelled.
///
/// A player with **nothing armed** is not "no sequence" — the reference arms the loader-idle clip on
/// every M2 instance at load, so the answer is that sequence at its opening frame
/// ([`ModelAnimations::idle_seq`], decision 0936). benilla's rig tier skips the arm whenever looping
/// the idle would render identically to the static mesh, and that skip used to leak out of the mesh
/// question into this one: both callers read the returned `None` as "keep the pinned slot", which
/// starts life at file slot 0. On a Spawn/Stand/Despawn GameObject slot 0 is the Spawn flourish, so
/// the batch read a sequence the instance was never playing.
pub(crate) fn playing_seq(
player: &AnimationPlayer,
anims: &ModelAnimations,
@@ -168,6 +176,7 @@ pub(crate) fn playing_seq(
})
.max_by(|a, b| a.2.total_cmp(&b.2))
.map(|(seq, t, _)| (seq, t))
.or_else(|| anims.idle_seq().map(|seq| (seq, 0.0)))
}
/// Sample every instance's material-alpha loops: a hosted instance on its host's playing sequence
+12
View File
@@ -179,6 +179,18 @@ pub(crate) struct CubeAssets {
npc_mat: Handle<StandardMaterial>,
}
impl CubeAssets {
/// The pipeline-warm rig parts ([`crate::pipe_warm`], decision 0938): the production cube
/// mesh + both materials, so the fallback-cube pipeline compiles behind the cover instead of
/// on the first model-less spawn in view.
pub(crate) fn warm_parts(&self) -> (Handle<Mesh>, [Handle<StandardMaterial>; 2]) {
(
self.mesh.clone(),
[self.player_mat.clone(), self.npc_mat.clone()],
)
}
}
/// Creature rendering: the display→model catalog + a per-display [`DisplayModel`] cache. Optional — if
/// the DBCs fail to load, NPCs stay cubes.
#[derive(Resource)]
@@ -972,6 +972,7 @@ mod tests {
geometry: None,
owner_reach: 0.0,
water_bound: (Vec3::ZERO, 0.0),
idle_seq: 0,
}];
displays.models.insert((7, SHOULDER_KIND), dm);
app.insert_resource(displays);
@@ -1235,6 +1236,7 @@ mod tests {
geometry: None,
owner_reach: 0.0,
water_bound: (Vec3::ZERO, 0.0),
idle_seq: 0,
}];
}
displays.models.insert((display, kind), dm);
+5 -1
View File
@@ -349,7 +349,11 @@ mod tests {
.collect();
(
ItemGlows::new(ItemVisualCatalog::from_visuals(visuals)),
EnchantCatalog::from_rows(enchant_rows.iter().copied().collect(), HashMap::new()),
EnchantCatalog::from_rows(
enchant_rows.iter().copied().collect(),
HashMap::new(),
Default::default(),
),
)
}
+9
View File
@@ -81,6 +81,14 @@ pub(crate) fn enchant_lines(
let lines: Vec<benilla_ui::script::EnchantView> = slots
.into_iter()
.filter(|&(_, id, _, _)| id != 0)
// `SpellItemEnchantment.Flags & 0x2` — the row prints NO line at all. Both of the
// reference's enchant-line printers open with it and return before they ever read the
// name (`6290e4` / `62923e`, each `testb $0x2, 0x5c(...)` → `jne <retl>`). Twelve shipped
// rows, one family: the totem-granted weapon imbues, Firestone, Orb of Fire — buffs whose
// source already shows elsewhere on screen, so the weapon does not repeat them. Found and
// closed while transcribing the *other* bit of that column (decision 0928); 0915 read the
// name column alone and printed all twelve.
.filter(|&(_, id, _, _)| !enchants.0.tooltip_hides_name(id.unsigned_abs()))
.filter_map(|(slot, id, charges, remaining_ms)| {
let name = enchants.0.name(id.unsigned_abs())?.to_string();
Some(benilla_ui::script::EnchantView {
@@ -495,6 +503,7 @@ mod tests {
]
.into_iter()
.collect(),
Default::default(),
));
let named = |v: Vec<benilla_ui::script::EnchantView>| -> Vec<String> {
v.into_iter().map(|e| e.name).collect()
+81 -12
View File
@@ -32,10 +32,20 @@ pub const VANILLA_ALPHA_KEY_REF: f32 = 224.0 / 255.0;
/// `m2-blend-promotion-zfill.md` §4/§6; MOBA file order for WMO). This epsilon reproduces that
/// order through bevy's sort. Sized well under [`benilla_formats::owner_last_rung`]'s 1-yd floor
/// (an effect still draws after every batch of its owner) and far over f32 noise on a ~500 yd
/// sort distance (~4e-5). Not a pipeline axis: `depth_bias` participates in sorting only
/// (`WowModelKey` deliberately excludes it — decision 0837).
/// sort distance (~4e-5).
///
/// It IS a pipeline-key axis when it reaches 1.0: the base `StandardMaterialKey` packs
/// `depth_bias as i32` (only the extension `WowModelKey` excludes it — 0837's comment
/// overclaimed), so the product is capped at [`BATCH_ORDER_SORT_CAP`] and applied to
/// transparent-pass materials only (`model_material`).
pub(crate) const BATCH_ORDER_SORT_EPS: f32 = 1e-3;
/// The eps product's ceiling: strictly under 1.0 so no batch index can mint a pipeline key
/// (`as i32` == 0 for the whole band), and strictly under `FAR_KEY_PULL` so a capped batch's
/// far-side twin still truncates onto the far band's single key integer. Batches past index
/// 899 tie here — see the cap rationale at the one application site.
pub(crate) const BATCH_ORDER_SORT_CAP: f32 = 0.9;
/// Material-dedup key: same texture + blend + sidedness + kind + fade-variant → one shared material.
#[derive(PartialEq, Eq, Hash)]
pub(crate) struct MatKey {
@@ -241,7 +251,24 @@ pub(crate) fn model_material(
let cull_mode = if two_sided { None } else { Some(Face::Back) };
// The authored batch order as a transparent sort bias ([`BATCH_ORDER_SORT_EPS`]): one model's
// coplanar batches draw in file order instead of re-flipping a sort tie every frame.
let depth_bias = f32::from(batch_order) * BATCH_ORDER_SORT_EPS;
//
// TRANSPARENT-PASS ONLY, and CAPPED below 1.0 (decision 0938's tail — the Stormwind evening
// log). The bias is also the base `StandardMaterialKey`'s `depth_bias as i32` axis, which
// 0837's original comment believed excluded (only the EXTENSION key excludes it) — invisible
// while every product truncated to 0, until a city-scale WMO's root-scoped batch index
// (`assemble.rs`, u16 — Stormwind roots run past 2000) pushed the product to 1.0+ and each
// integer became a live-compiled pipeline. Opaque/mask batches take no bias at all: their
// pass never sorts by it, and the coplanar-stack job belongs to the vertex-stage depth nudge
// (0837's `sun_scale.y`), so a nonzero value was only a key leak. Transparent batches keep
// total order through index 899 and tie at the cap beyond it — safe, because the eps is
// load-bearing only for SAME-CENTRE stacks (M2 item overlays, always low-index); WMO batches
// sort by their own distinct mesh centres. The cap stays under `FAR_KEY_PULL` so a far twin
// of a capped batch still lands on the far band's one key integer.
let depth_bias = if matches!(alpha_mode, AlphaMode::Blend) {
(f32::from(batch_order) * BATCH_ORDER_SORT_EPS).min(BATCH_ORDER_SORT_CAP)
} else {
0.0
};
let base = match texture {
Some(image) => StandardMaterial {
base_color_texture: Some(image),
@@ -517,17 +544,30 @@ pub(crate) const FAR_SIDE_MARKER: u16 = 1 << 11;
/// over it, plus the [`FAR_SIDE_MARKER`] pipeline bit that keeps the rung sort-only. Pure, so the
/// twin's shape is testable without an `Assets` store; the identity holds for every variant the
/// swap can meet (steady blend, fade twin, zfill twin — their own marker bits ride along).
fn far_twin_of(near: &WowModelMaterial) -> WowModelMaterial {
/// `pub(crate)`: the pipeline warm pass ([`crate::pipe_warm`]) builds its far-side warm variants
/// through this same builder, so the twin's key encoding can never drift from the swap's.
pub(crate) fn far_twin_of(near: &WowModelMaterial) -> WowModelMaterial {
let mut far = near.clone();
far.base.depth_bias = far_sort_bias(far.base.depth_bias);
far.extension.clutter_fade.z = far_markers(far.extension.clutter_fade.z);
far
}
/// Fractional pull on every far-side sort slot, so the whole batch-eps band shares ONE pipeline
/// key. Bevy folds `depth_bias as i32` into the material's pipeline key (0837's law): unpulled,
/// a batch-order-0 twin lands exactly on `FAR_SIDE_BIAS` while its eps-nudged siblings land a
/// fraction above — truncating to TWO key integers, i.e. two live pipeline compiles where the
/// descriptors are identical (`specialize` zeroes the raster constant for far twins either way).
/// Pulled by just under 1, every `near ∈ [0, 0.99]` twin truncates to the same integer. Sort-side
/// it is a uniform shift of the whole band, so nothing reorders (the effect lane's far draws sit
/// `rung ≥ 1` above, and the margin only grows).
const FAR_KEY_PULL: f32 = 0.99;
/// The twin's sort slot: one water rung under wherever the source sat (a zfill twin keeps its −8
/// under the far batches it primes; a colour batch keeps its batch eps).
/// under the far batches it primes; a colour batch keeps its batch eps), less [`FAR_KEY_PULL`]
/// so the eps band collapses onto one pipeline-key integer.
fn far_sort_bias(near: f32) -> f32 {
near + crate::sky_order::FAR_SIDE_BIAS
near + crate::sky_order::FAR_SIDE_BIAS - FAR_KEY_PULL
}
/// The twin's `clutter_fade.z` marker word: [`FAR_SIDE_MARKER`] added, every other pipeline
@@ -928,8 +968,11 @@ mod tests {
/// (effects interleaving into their owner's batches).
#[test]
fn batch_order_sort_eps_sits_between_f32_noise_and_the_effect_rung() {
// 512 batches is comfortably past any shipped model's batch table.
let deepest = super::BATCH_ORDER_SORT_EPS * 512.0;
// The CAPPED product is the deepest any batch can bias — "512 is past any shipped
// model" was this test's original premise, and Stormwind's root batch tables disproved
// it (indices past 2000; the 0938-tail live compiles at biases 1 and 2).
let deepest =
(super::BATCH_ORDER_SORT_EPS * f32::from(u16::MAX)).min(super::BATCH_ORDER_SORT_CAP);
assert!(
deepest < benilla_formats::owner_last_rung(0.0),
"a model's last batch must still sort before its own effects' rung"
@@ -939,9 +982,19 @@ mod tests {
super::BATCH_ORDER_SORT_EPS > 8.0 * far_noise,
"one order step must dominate f32 noise on a far sort distance"
);
// And bevy's pipeline key truncates it to 0 — the bias may never mint pipelines (0837):
// sub-1.0 biases all cast to the same i32 constant.
// Bevy's pipeline key truncates the WHOLE band to 0 — no batch index may mint a
// pipeline (0837, re-learned in 0938's tail): the cap holds for any u16.
assert_eq!(deepest as i32, 0);
const {
assert!(super::BATCH_ORDER_SORT_CAP < 1.0);
// A capped batch's far twin still truncates onto the far band's ONE key integer.
assert!(super::BATCH_ORDER_SORT_CAP < super::FAR_KEY_PULL);
}
assert_eq!(
super::far_sort_bias(super::BATCH_ORDER_SORT_CAP) as i32,
super::far_sort_bias(0.0) as i32,
"the far band stays one pipeline key across the whole capped eps range"
);
}
/// The far-side twin is its source shifted exactly one water rung with the marker bit added —
@@ -953,14 +1006,30 @@ mod tests {
fn far_twin_keeps_the_source_identity_one_rung_down() {
assert_eq!(
super::far_sort_bias(super::ZFILL_SORT_BIAS),
crate::sky_order::FAR_SIDE_BIAS + super::ZFILL_SORT_BIAS,
"one rung down from wherever the source sat"
crate::sky_order::FAR_SIDE_BIAS + super::ZFILL_SORT_BIAS - super::FAR_KEY_PULL,
"one rung down from wherever the source sat (less the key-collapse pull)"
);
assert!(
super::far_sort_bias(super::ZFILL_SORT_BIAS)
< super::far_sort_bias(super::BATCH_ORDER_SORT_EPS * 512.0),
"a far zfill twin still primes before its model's far colour batches"
);
// The pull's whole point: the entire batch-eps band truncates to ONE pipeline-key
// integer (0837's law — `depth_bias as i32` is a key axis), and the zfill twin keeps
// its own single key one bracket down.
assert_eq!(
super::far_sort_bias(0.0) as i32,
super::far_sort_bias(super::BATCH_ORDER_SORT_EPS * 512.0) as i32,
"batch-order far twins may never mint a second pipeline"
);
const {
assert!(super::FAR_KEY_PULL > super::BATCH_ORDER_SORT_EPS * 512.0);
}
assert_eq!(
super::far_sort_bias(super::ZFILL_SORT_BIAS) as i32,
crate::sky_order::FAR_SIDE_BIAS as i32 + super::ZFILL_SORT_BIAS as i32,
"the zfill far key stays put — the pull is sub-integer"
);
let src = super::ZFILL_MARKER | super::TWIN_CUTOUT_MARKER | (3 << 4);
let z = super::far_markers(f32::from(src)) as u16;
assert_ne!(z & super::FAR_SIDE_MARKER, 0, "the sort-only pipeline bit");
+69 -3
View File
@@ -554,10 +554,18 @@ pub fn spawn_emitter(
if def.params.peak_lifespan() <= 0.0 || def.timing.peak_rate() <= 0.0 {
return None; // emits nothing
}
// The starting slot is the model's **loader-idle** sequence, never "unknown" (decision 0936).
// The reference arms that sequence on every M2 instance at load, so an emitter always has a
// sequence to sample; leaving it `None` handed the slot resolution to `EmitTiming::idx`'s
// `unwrap_or(0)` degrade, which is the idle slot only by accident. On the Spawn/Stand/Despawn
// GameObjects slot 0 is the *Spawn* flourish, so a content-gated instance (nothing armed, so
// `playing_seq` never overrides this seed) sat at that slot's opening frame for ever — the
// Stormwind battlefield banner firing its spawn sparkle-tails on a loop.
let idle = Some(emitter.idle_seq);
let (host, seq) = match clock {
EmitClock::Pinned => (None, None),
EmitClock::Effect(s) => (None, s),
EmitClock::Host(h) => (Some(h), None),
EmitClock::Pinned => (None, idle),
EmitClock::Effect(s) => (None, s.or(idle)),
EmitClock::Host(h) => (Some(h), idle),
};
// The owner's reach is model-local; the rung is a view-space distance, so it takes the
// placement scale with it (a scaled-up creature's batches spread proportionally).
@@ -802,6 +810,64 @@ pub(crate) mod tests {
super::emit::tests::def(ParticleShape::Plane)
}
/// A spawnable emitter whose owner model's loader-idle sequence is file slot `idle_seq`.
fn model_emitter(idle_seq: usize) -> ModelEmitter {
ModelEmitter {
def: plain_def(),
texture: Some(Handle::default()),
bone_pivot: [0.0; 3],
billboard: None,
recursion: None,
geometry: None,
owner_reach: 0.0,
water_bound: (Vec3::ZERO, 0.0),
idle_seq,
}
}
/// Spawn one emitter through [`spawn_emitter`] and report the sequence slot it was seeded with.
fn seeded_slot(emitter: ModelEmitter, clock: EmitClock) -> Option<usize> {
use bevy::ecs::system::RunSystemOnce;
let mut app = App::new();
let e = app
.world_mut()
.run_system_once(move |mut c: Commands| {
spawn_emitter(&mut c, &emitter, Transform::IDENTITY, default(), clock)
})
.unwrap()?;
app.world().get::<ParticleEmitter>(e).unwrap().seq
}
/// **The emitter's opening slot is its model's loader-idle sequence, not file slot 0.**
///
/// A Spawn/Stand/Despawn GameObject (`DuelingFlag`-shaped — the battlefield banners, the
/// goobers, `ArenaFlag`) keeps its idle in slot 1; slot 0 is the Spawn flourish. Seeding `None`
/// handed the resolution to `EmitTiming::idx`'s `unwrap_or(0)` degrade, so a rig-less instance —
/// one whose constant-pose idle made the render gate skip the arm, so nothing ever overrode the
/// seed — sampled the Spawn gate at its opening frame for ever. That is the Stormwind
/// battlefield banner firing its spawn sparkle-tails on a permanent loop.
///
/// Every clock takes the seed: `Host` because its player may arm nothing, `Pinned` because there
/// is no rig to ask at all, `Effect(None)` because an unarmed effect rig rests on the same idle.
#[test]
fn an_emitter_opens_on_its_models_idle_slot_not_slot_zero() {
let host = Entity::from_raw_u32(1).unwrap();
for clock in [
EmitClock::Pinned,
EmitClock::Effect(None),
EmitClock::Host(host),
] {
assert_eq!(seeded_slot(model_emitter(1), clock), Some(1));
}
// An effect rig that DID arm a slot still names it — the seed is a default, not an override.
assert_eq!(
seeded_slot(model_emitter(1), EmitClock::Effect(Some(2))),
Some(2)
);
// The ordinary model, whose idle IS slot 0, is unchanged.
assert_eq!(seeded_slot(model_emitter(0), EmitClock::Pinned), Some(0));
}
/// Camera at the origin looking down −Z (Bevy's convention), and an owner sphere `depth` yd
/// straight ahead — the geometry every draw-set test below uses.
fn gate(radius: f32, depth: f32, farclip: f32) -> bool {
+16 -2
View File
@@ -117,14 +117,28 @@ pub(super) fn update_model_particles(
// The owner-last draw-order rung, stamped on after the fact: a 3-D model
// particle is one of its owner's emitters exactly like the quad cloud beside
// it, and the reference draws them in one bracket after that model's batches
// — so both must sit on the SAME rung or the cloud paints over the shards
// (`ParticleEmitter::owner_rung`). It goes here rather than through
// `model_material`, which is the shared M2-batch recipe: every batch in the
// world would have to carry a particle-only argument and a cache-key field to
// 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
// 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
// synchronous render-thread pipeline compile mid-spell-cast.
// `owner_last_rung_bucket` snaps UP (0721's blessed direction) to the closed
// set `pipe_warm`'s menagerie compiles behind the loading cover; the bucket
// is ≥ the exact rung, so a shard never sorts under its sibling quad cloud
// (which keeps the exact value — its rung is queue-side, never a material).
// Opaque/mask shard batches take no rung at all: sort bias is a
// transparent-pass concept, and on an opaque material it would only mint an
// unwarmable pipeline variant.
if let Some(m) = materials.get_mut(&material) {
m.base.depth_bias = rung;
if m.base.alpha_mode == AlphaMode::Blend {
m.base.depth_bias = benilla_formats::owner_last_rung_bucket(rung);
}
}
let entity = commands
.spawn((
+708
View File
@@ -0,0 +1,708 @@
//! The menagerie — `pipe_warm`'s warm-set builder: one tiny rig per reachable pipeline variant,
//! spawned behind the entry cover by [`super::run_warm_pass`]. The watch/tripwire half (and the
//! why of the whole pass) lives in the parent module; this file owns WHAT gets warmed — the
//! model lane and its derived keys, the sky/water lanes, the plain-`StandardMaterial` lanes,
//! and the portrait-booth samples=1 twins — plus the lane-coverage gate test that keeps a new
//! material lane from shipping unwarmed (decisions 0837 / 0937 / 0938).
use benilla_formats::{FogPolicy, ModelBlend, RenderSubmesh};
use bevy::asset::RenderAssetUsages;
use bevy::camera::primitives::MeshAabb;
use bevy::ecs::system::SystemParam;
use bevy::mesh::{Indices, MeshTag, PrimitiveTopology};
use bevy::prelude::*;
use bevy::render::render_resource::Buffer;
use crate::clouds::CloudMaterial;
use crate::model_render::{far_twin_of, model_material, zfill_material, MaterialCache, ShadeSel};
use crate::sky::SkyMaterial;
use crate::sun::{CelestialMaterial, StarMaterial};
use crate::terrain::{LiquidMaterial, WowModelMaterial};
use crate::wmo_sky::{WmoSkyboxExt, WmoSkyboxMaterial};
use super::WarmRig;
// --------------------------------------------------------------------------------------------
// The warm pass — the fix half of 0837, widened by 0937.
//
// The 0837 inventory (pipes1.log) showed the model lane's REACHABLE pipeline space is small once
// the batch-order axis left the key: 4 vertex layouts × the blend/depth-flag families, ~28
// observed in a wilderness-to-Stormwind leg. 0937 added the spaces 0837 left out — the
// shard-rung buckets and far-side-of-water twins of that same model lane, and the sky/water
// lanes (celestial discs/glares, stars, cloud dome, gradient dome, WMO skybox, liquid), each of
// which had fired the tripwire as a director-felt live stall. The menagerie below compiles the
// whole space behind the entry loading cover — one 1 cm rig per variant, parented to the world
// camera (the camera renders under the cover, 0540, so every rig's draw queues its pipeline
// through the production specialize path), the cover held (via `WarmPass::satisfied` in the
// loading screen's clear condition) until the cache drains, then the rigs despawn. A variant
// this misses shows up as the tripwire's "compiled LIVE" warn — extend the loops, don't guess.
/// The sky/water lanes' material stores, grouped so [`run_warm_pass`] stays under Bevy's
/// system-param arity. Every store here is populated at `Startup` by its own subsystem (the
/// celestial rig, the star dome, the cloud dome, the gradient dome, the liquid frame sets), so
/// by the time the menagerie spawns behind the entry cover, iterating the store IS the complete
/// reachable set — warming can never drift from what's spawned. The one exception is the WMO
/// skybox, whose materials are built on first need: its pipeline key is texture-independent, so
/// one representative material covers the lane.
#[derive(SystemParam)]
pub(super) struct WarmLanes<'w> {
celestial: ResMut<'w, Assets<CelestialMaterial>>,
stars: ResMut<'w, Assets<StarMaterial>>,
clouds: ResMut<'w, Assets<CloudMaterial>>,
sky: ResMut<'w, Assets<SkyMaterial>>,
liquid: ResMut<'w, Assets<LiquidMaterial>>,
skybox: ResMut<'w, Assets<WmoSkyboxMaterial>>,
/// The plain-`StandardMaterial` lanes (0938): representatives for the on-demand nameplate
/// and raid-mark materials go through this store the way their builders do.
standard: ResMut<'w, Assets<StandardMaterial>>,
/// The fallback cube (0938): production mesh + materials, drawn while a model streams.
cubes: Option<Res<'w, crate::entities::CubeAssets>>,
}
/// One portrait/paperdoll booth camera + its layer ([`crate::portrait`], 0938): the booths run
/// `Msaa::Off`, so every model-lane pipeline has a samples=1 twin that otherwise compiles live
/// on the first in-world portrait (the first click-target). The booth cameras exist from
/// `Startup` and render every frame, so menagerie rigs duplicated onto ONE booth's layer compile
/// that whole twin space behind the cover — the view shape (HDR, no tonemap, the glow node) is
/// otherwise the world camera's, so samples is the only twin axis.
pub(super) type BoothCamQuery<'w, 's> = Query<
'w,
's,
(Entity, &'static bevy::camera::visibility::RenderLayers),
With<crate::portrait::BoothCam>,
>;
/// 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
/// 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
/// variant encoding can never drift from the real spawn paths; meshes from the production
/// submesh builders (or their attribute-exact stand-ins) so the vertex layouts can't either.
/// Returns the entity count.
#[allow(clippy::too_many_arguments)] // one arg per store/anchor, the file's builder convention
pub(super) fn spawn_menagerie(
commands: &mut Commands,
cam: Entity,
booth: Option<(Entity, &bevy::camera::visibility::RenderLayers)>,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<WowModelMaterial>,
lanes: &mut WarmLanes,
cache: &mut MaterialCache,
light: &Buffer,
) -> usize {
// The four vertex layouts the model lane ships (0837 dump: strides 32/48/56/72): static ×
// {plain, vertex-colours} and their skinned twins. Statics are RENDER_WORLD-only, so their
// Aabb is computed here and inserted explicitly (0832's rule); skinned twins keep main-world
// data and `calculate_bounds` covers them.
let mut layouts: Vec<(Handle<Mesh>, Option<bevy::camera::primitives::Aabb>, bool)> = Vec::new();
for colors in [false, true] {
let stat = benilla_assets::submesh_to_static_mesh(&warm_quad(colors, false));
let aabb = stat.compute_aabb();
layouts.push((meshes.add(stat), aabb, false));
let skin = benilla_assets::submesh_to_skinned_mesh(&warm_quad(colors, true));
layouts.push((meshes.add(skin), None, true));
}
// The material families. The full cross is deliberate: every branch here is authorable in an
// M2/WMO (blends, the 0x10/0x08 depth flags, sidedness), and an over-warmed variant costs
// milliseconds behind a loading bar once per run, while a missed one is a director-felt live
// stall. The observed set (28) is the floor, not the target.
let mut mats: Vec<Handle<WowModelMaterial>> = Vec::new();
for two_sided in [false, true] {
for blend in [
ModelBlend::Opaque,
ModelBlend::AlphaTest,
ModelBlend::Blend,
ModelBlend::Mod,
ModelBlend::Mod2x,
] {
for no_depth_write in [false, true] {
for no_depth_test in [false, true] {
mats.push(model_material(
cache,
materials,
None,
blend,
two_sided,
false,
false,
false,
false,
false,
no_depth_write,
no_depth_test,
FogPolicy::Scene,
ShadeSel::Lit,
0,
None,
None,
None,
None,
false,
light,
));
}
}
}
// The additive glow-card blend state (specialize's pure ONE/ONE add), both depth-write
// flavours, and the doodad/entity distance-fade blend twin (depth-write forced on).
for no_depth_write in [false, true] {
mats.push(model_material(
cache,
materials,
None,
ModelBlend::Blend,
two_sided,
false,
false,
false,
true,
false,
no_depth_write,
false,
FogPolicy::Scene,
ShadeSel::Lit,
0,
None,
None,
None,
None,
false,
light,
));
}
mats.push(model_material(
cache,
materials,
None,
ModelBlend::Blend,
two_sided,
false,
false,
false,
false,
true,
false,
false,
FogPolicy::Scene,
ShadeSel::Lit,
0,
None,
None,
None,
None,
false,
light,
));
// The depth-prime twin (colour writes masked off), plain and cutout.
for cutout in [false, true] {
mats.push(zfill_material(
cache, materials, None, two_sided, cutout, light,
));
}
}
// The ground-clutter lane (Mask + specialize's over-blend), both sidednesses — the first
// verification leg caught the two-sided one compiling live. Its material is built by
// `WorldAssets::model_material` (image machinery this pass doesn't need) — the pipeline only
// sees the KEY bits, so arm `clutter_fade` on a COPY of the plain Mask material (a fresh
// asset; the dedup cache's own entry stays untouched).
for two_sided in [false, true] {
let mask = model_material(
cache,
materials,
None,
ModelBlend::AlphaTest,
two_sided,
false,
false,
false,
false,
false,
false,
false,
FogPolicy::Scene,
ShadeSel::Lit,
0,
None,
None,
None,
None,
false,
light,
);
if let Some(m) = materials.get(&mask) {
let mut m = m.clone();
m.extension.clutter_fade = Vec4::new(52.5, 70.0, 0.0, 1.0);
let clutter = materials.add(m);
mats.push(clutter);
}
}
// The shard-rung rows (decision 0937). 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
// (`benilla-extract shardcensus`: every 1.12.1 shard batch is Blend, ± two-sided,
// ± additive, all no-depth-write — over-warming the write axis costs milliseconds). Built as
// bias-stamped COPIES exactly like the runtime does it, so the dedup cache stays untouched.
let mut shard_mats: Vec<Handle<WowModelMaterial>> = Vec::new();
for &bucket in benilla_formats::OWNER_RUNG_BUCKETS.iter() {
for two_sided in [false, true] {
for additive in [false, true] {
for no_depth_write in [false, true] {
let h = model_material(
cache,
materials,
None,
ModelBlend::Blend,
two_sided,
false,
false,
false,
additive,
false,
no_depth_write,
false,
FogPolicy::Scene,
ShadeSel::Lit,
0,
None,
None,
None,
None,
false,
light,
);
if let Some(m) = materials.get(&h) {
let mut m = m.clone();
m.base.depth_bias = bucket;
shard_mats.push(materials.add(m));
}
}
}
}
}
// The far-side-of-water twins (decision 0937). `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
// straddle of a water plane is a live compile. Twin everything transparent above, through the
// swap's own builder, with the swap's own predicate.
let far_mats = far_twins_of(materials, &mats);
let far_shard_mats = far_twins_of(materials, &shard_mats);
let mut count = 0;
// The main cross and its far twins ride every layout (any family can appear static or
// skinned, plain or vertex-coloured — a submerged character's skinned gear classifies far
// too); the shard rows and their far twins ride the static layouts only (shard geometry
// models are static meshes by construction — `particles::model`). Every main-cross rig is
// ALSO duplicated onto one portrait booth's layer (0938): the booths render at `Msaa::Off`,
// so the samples=1 twin of each model pipeline otherwise compiles live on the first in-world
// portrait — a unit's gear (any family, any layout, far-swapped included when submerged)
// can reach a booth pane. Shard rows can't (particle instances never ride booth layers).
for (mesh, aabb, skinned) in &layouts {
for mat in mats.iter().chain(far_mats.iter()) {
spawn_model_rig(commands, cam, None, mesh, aabb, *skinned, mat);
count += 1;
if let Some((booth_cam, layers)) = booth {
spawn_model_rig(
commands,
booth_cam,
Some(layers.clone()),
mesh,
aabb,
*skinned,
mat,
);
count += 1;
}
}
if !*skinned {
for mat in shard_mats.iter().chain(far_shard_mats.iter()) {
spawn_model_rig(commands, cam, None, mesh, aabb, false, mat);
count += 1;
}
}
}
// The sky and water lanes (decision 0937 — 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
// what's spawned. Layout indices: the loop above pushed [static plain, skinned plain,
// static colours, skinned colours].
let (plain_mesh, plain_aabb, _) = layouts[0].clone();
let (colours_mesh, colours_aabb, _) = layouts[2].clone();
let posuv = meshes.add(warm_pos_uv_mesh());
let liquid_mesh = meshes.add(warm_liquid_mesh());
// Celestial discs + glares (`sun::setup` quads: position+normal+UV).
for mat in lane_handles(&mut lanes.celestial) {
spawn_lane_rig(
commands,
cam,
None,
&plain_mesh,
plain_aabb.as_ref(),
mat,
&mut count,
);
}
// Stars: the real `Stars.m2` patches carry position+UV only; the assetless fallback dome
// carries normals too — two distinct pipeline keys, warm both.
for mat in lane_handles(&mut lanes.stars) {
spawn_lane_rig(commands, cam, None, &posuv, None, mat.clone(), &mut count);
spawn_lane_rig(
commands,
cam,
None,
&plain_mesh,
plain_aabb.as_ref(),
mat,
&mut count,
);
}
// The cloud dome (position+normal+UV+colour) and the gradient dome (position+normal+UV).
for mat in lane_handles(&mut lanes.clouds) {
spawn_lane_rig(
commands,
cam,
None,
&colours_mesh,
colours_aabb.as_ref(),
mat,
&mut count,
);
}
for mat in lane_handles(&mut lanes.sky) {
spawn_lane_rig(
commands,
cam,
None,
&plain_mesh,
plain_aabb.as_ref(),
mat,
&mut count,
);
}
// Liquid: every kind × fog-block material `setup_liquid` built, on the liquid grid layout.
for mat in lane_handles(&mut lanes.liquid) {
spawn_lane_rig(commands, cam, None, &liquid_mesh, None, mat, &mut count);
}
// WMO skybox: the one on-demand lane — its materials are built at first need, but the
// pipeline key is texture-independent, so one representative built the way
// `wmo_sky::build_skybox` builds them covers the lane.
let skybox = lanes.skybox.add(WmoSkyboxMaterial {
base: StandardMaterial {
unlit: true,
cull_mode: None,
..default()
},
extension: WmoSkyboxExt::default(),
});
spawn_lane_rig(commands, cam, None, &posuv, None, skybox, &mut count);
// The plain-`StandardMaterial` lanes (0938 — the director's evening log). The fallback cube
// (`entities::CubeAssets`, drawn while any entity's model streams) uses the production mesh
// + materials, on the world camera AND a booth layer (a cube-bodied target can reach a
// portrait pane). The nameplate and raid-mark materials are built on first need, so the lane
// warms REPRESENTATIVES with the builders' exact key fields (`nameplates::spawn_nameplates`,
// `raid_marks::place_marks` — texture presence is not a key axis); the plate/mark quads
// share the static-plain attribute set.
if let Some(cubes) = lanes.cubes.as_ref() {
let (cube_mesh, cube_mats) = cubes.warm_parts();
for mat in cube_mats {
spawn_lane_rig(
commands,
cam,
None,
&cube_mesh,
None,
mat.clone(),
&mut count,
);
if let Some((booth_cam, layers)) = booth {
spawn_lane_rig(
commands,
booth_cam,
Some(layers.clone()),
&cube_mesh,
None,
mat,
&mut count,
);
}
}
}
let plate = lanes.standard.add(StandardMaterial {
unlit: true,
alpha_mode: AlphaMode::Blend,
cull_mode: None,
depth_bias: crate::nameplates::NAMEPLATE_DEPTH_BIAS,
..default()
});
spawn_lane_rig(
commands,
cam,
None,
&plain_mesh,
plain_aabb.as_ref(),
plate,
&mut count,
);
let mark = lanes.standard.add(StandardMaterial {
unlit: true,
alpha_mode: AlphaMode::Blend,
cull_mode: None,
..default()
});
spawn_lane_rig(
commands,
cam,
None,
&plain_mesh,
plain_aabb.as_ref(),
mark,
&mut count,
);
count
}
/// The far twins of every TRANSPARENT material in `src` — the same `alpha_mode == Blend`
/// predicate `classify_water_side` swaps on, through the swap's own `far_twin_of` builder.
fn far_twins_of(
materials: &mut Assets<WowModelMaterial>,
src: &[Handle<WowModelMaterial>],
) -> Vec<Handle<WowModelMaterial>> {
let twins: Vec<WowModelMaterial> = src
.iter()
.filter_map(|h| materials.get(h))
.filter(|m| matches!(m.base.alpha_mode, AlphaMode::Blend))
.map(far_twin_of)
.collect();
twins.into_iter().map(|m| materials.add(m)).collect()
}
/// Strong handles to every material currently in a lane's store (the iterate-the-store warm:
/// what exists is what gets compiled).
fn lane_handles<M: Material>(assets: &mut Assets<M>) -> Vec<Handle<M>> {
let ids: Vec<AssetId<M>> = assets.iter().map(|(id, _)| id).collect();
ids.into_iter()
.filter_map(|id| assets.get_strong_handle(id))
.collect()
}
/// One model-lane menagerie entity: a 1 cm rig in front of the camera (world or booth — `layers`
/// puts the rig on the booth camera's layer for the samples=1 twin space), drawn under the cover.
fn spawn_model_rig(
commands: &mut Commands,
cam: Entity,
layers: Option<bevy::camera::visibility::RenderLayers>,
mesh: &Handle<Mesh>,
aabb: &Option<bevy::camera::primitives::Aabb>,
skinned: bool,
mat: &Handle<WowModelMaterial>,
) {
let tag = if skinned {
MeshTag(crate::mesh_tag::rig_bits(0) | crate::mesh_tag::alpha_bits(1.0))
} else {
MeshTag(crate::mesh_tag::alpha_bits(1.0))
};
let mut e = commands.spawn((
Mesh3d(mesh.clone()),
MeshMaterial3d(mat.clone()),
Transform::from_xyz(0.0, 0.0, -0.5).with_scale(Vec3::splat(0.01)),
tag,
WarmRig,
ChildOf(cam),
));
if let Some(aabb) = aabb {
e.insert(*aabb);
}
if let Some(layers) = layers {
e.insert(layers);
}
}
/// One sky/water/standard-lane menagerie entity — same rig, no `MeshTag` (those lanes don't
/// carry one in production, and the tag is instance data, never a pipeline axis).
fn spawn_lane_rig<M: Material>(
commands: &mut Commands,
cam: Entity,
layers: Option<bevy::camera::visibility::RenderLayers>,
mesh: &Handle<Mesh>,
aabb: Option<&bevy::camera::primitives::Aabb>,
mat: Handle<M>,
count: &mut usize,
) {
let mut e = commands.spawn((
Mesh3d(mesh.clone()),
MeshMaterial3d(mat),
Transform::from_xyz(0.0, 0.0, -0.5).with_scale(Vec3::splat(0.01)),
WarmRig,
ChildOf(cam),
));
if let Some(aabb) = aabb {
e.insert(*aabb);
}
if let Some(layers) = layers {
e.insert(layers);
}
*count += 1;
}
/// A tiny triangle carrying POSITION + UV_0 only — the layout the real `Stars.m2` patches and
/// the WMO skybox batches ship (`sun::setup` / `wmo_sky::build_skybox` insert exactly these
/// two attributes). Main-world-resident (`RenderAssetUsages::default()`), so `calculate_bounds`
/// covers it — no explicit Aabb needed.
fn warm_pos_uv_mesh() -> Mesh {
let mut m = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
m.insert_attribute(
Mesh::ATTRIBUTE_POSITION,
vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]],
);
m.insert_attribute(
Mesh::ATTRIBUTE_UV_0,
vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]],
);
m.insert_indices(Indices::U32(vec![0, 1, 2]));
m
}
/// A tiny quad in the liquid grid's layout — POSITION + NORMAL + UV_0 + UV_1
/// (`liquid::surface` inserts exactly these four attributes).
fn warm_liquid_mesh() -> Mesh {
let mut m = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
let uvs = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
m.insert_attribute(
Mesh::ATTRIBUTE_POSITION,
vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[1.0, 1.0, 0.0],
[0.0, 1.0, 0.0],
],
);
m.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 1.0, 0.0]; 4]);
m.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs.clone());
m.insert_attribute(Mesh::ATTRIBUTE_UV_1, uvs);
m.insert_indices(Indices::U32(vec![0, 1, 2, 0, 2, 3]));
m
}
/// A unit quad in each attribute combination the model lane ships. Every `RenderSubmesh` field
/// is spelled out on purpose: a new field breaks THIS build, which is the drift alarm that keeps
/// the menagerie honest against the format.
fn warm_quad(colors: bool, skinned: bool) -> RenderSubmesh {
let n = 4usize;
RenderSubmesh {
positions: vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[1.0, 1.0, 0.0],
[0.0, 1.0, 0.0],
],
normals: vec![[0.0, 0.0, 1.0]; n],
uvs: vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]],
indices: vec![0, 1, 2, 0, 2, 3],
texture: None,
skin_slot: None,
geoset_id: 0,
char_slot: None,
blend: ModelBlend::Opaque,
wrap_x: true,
wrap_y: true,
two_sided: false,
joints: if skinned { vec![[0; 4]; n] } else { Vec::new() },
weights: if skinned {
vec![[1.0, 0.0, 0.0, 0.0]; n]
} else {
Vec::new()
},
vertex_colors: if colors {
vec![[1.0, 1.0, 1.0, 1.0]; n]
} else {
Vec::new()
},
interior: false,
emissive: false,
sidn: None,
window: false,
additive: false,
no_depth_write: false,
no_depth_test: false,
fog_policy: FogPolicy::Scene,
billboard: None,
welded_billboard: false,
alpha_anim: None,
uv_anim: None,
rgb_anim: None,
wmo_batch: None,
}
}
#[cfg(test)]
mod tests {
/// The lane-coverage gate (decision 0938): every 3-D material lane registered anywhere in
/// this crate (`MaterialPlugin::<X>`) must be NAMED in this file — the warm pass is the one
/// place that compiles a lane's pipelines behind the loading cover, so a lane nobody
/// considered for warming is a future director-felt live stall on its first sight.
/// Exemptions carry their reason beside them; anything else red-bars the build until
/// `spawn_menagerie` (or an exemption with a reason) handles it. The runtime half of the
/// contract stays `watch_pipelines`' "compiled LIVE" tripwire, which catches per-VARIANT
/// drift inside a covered lane; this test catches whole lanes.
#[test]
fn every_material_lane_has_a_warm_contributor() {
// Lanes that never need the menagerie, each with the reason it is safe:
// - TerrainMaterial / WdlMaterial: the ground the player spawns on and its horizon
// ring — always drawn under the entry cover by construction, no per-variant key axis.
let exempt = ["TerrainMaterial", "WdlMaterial"];
let src_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
// "Named in this file" = anywhere in the pipe_warm module folder.
let warm_src = std::fs::read_to_string(src_root.join("pipe_warm/mod.rs")).unwrap()
+ &std::fs::read_to_string(src_root.join("pipe_warm/menagerie.rs")).unwrap();
let mut stack = vec![src_root];
let mut missing = Vec::new();
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
stack.push(path);
continue;
}
if path.extension().is_none_or(|e| e != "rs") {
continue;
}
let text = std::fs::read_to_string(&path).unwrap();
for (i, _) in text.match_indices("MaterialPlugin::<") {
// Skip `UiMaterialPlugin::<…>` and friends: a preceding ident char means
// this is a different plugin family (UI/2D pipelines compile pre-world,
// where every frame counts as covered).
if i > 0 && (text.as_bytes()[i - 1].is_ascii_alphanumeric()) {
continue;
}
let rest = &text[i + "MaterialPlugin::<".len()..];
let Some(end) = rest.find('>') else { continue };
let ty = rest[..end].rsplit("::").next().unwrap().trim();
if exempt.contains(&ty) || warm_src.contains(ty) {
continue;
}
missing.push(format!("{ty} (registered in {})", path.display()));
}
}
}
assert!(
missing.is_empty(),
"material lanes with no pipe_warm contributor: {missing:?} — every registered \
lane's pipelines compile behind the loading cover, or its first sight is a live \
render-thread stall (decisions 0837/0937/0938)"
);
}
}
@@ -13,8 +13,10 @@
//!
//! The pieces:
//!
//! - [`WarmPass`] + `spawn_menagerie` — the warm pass: one tiny quad per reachable model-lane
//! pipeline variant, parented to the world camera, spawned when the entry cover rises; the
//! - [`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
//! 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.
//! - [`PipeWatch`] — an `Arc` shared by the main and render worlds: how many pipelines the cache
@@ -33,20 +35,18 @@
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use benilla_formats::{FogPolicy, ModelBlend, RenderSubmesh};
use bevy::camera::primitives::MeshAabb;
use bevy::mesh::MeshTag;
use bevy::prelude::*;
use bevy::render::render_resource::{
Buffer, CachedPipelineState, PipelineCache, PipelineDescriptor,
};
use bevy::render::render_resource::{CachedPipelineState, PipelineCache, PipelineDescriptor};
use bevy::render::{Render, RenderApp, RenderSystems};
use crate::char_select::ClientState;
use crate::loading_screen::LoadingScreen;
use crate::model_render::{model_material, zfill_material, MaterialCache, ShadeSel};
use crate::model_render::MaterialCache;
use crate::terrain::WowModelMaterial;
mod menagerie;
use menagerie::{spawn_menagerie, BoothCamQuery, WarmLanes};
/// The cross-world channel: cloned into the render app at plugin build. Frame alignment between
/// the two worlds is ±1 frame under pipelined rendering — fine for counters and a tripwire.
#[derive(Resource, Clone)]
@@ -215,18 +215,6 @@ fn describe(desc: &PipelineDescriptor) -> String {
}
}
// --------------------------------------------------------------------------------------------
// The warm pass — the fix half of 0837.
//
// The 0837 inventory (pipes1.log) showed the model lane's REACHABLE pipeline space is small once
// the batch-order axis left the key: 4 vertex layouts × the blend/depth-flag families, ~28
// observed in a wilderness-to-Stormwind leg. The menagerie below compiles that whole space
// behind the entry loading cover — one 1 cm quad per variant, parented to the world camera (the
// camera renders under the cover, 0540, so every quad's draw queues its pipeline through the
// production specialize path), the cover held (via `WarmPass::satisfied` in the loading screen's
// clear condition) until the cache drains, then the quads despawn. A variant this misses shows
// up as the tripwire's "compiled LIVE" warn — extend the loops, don't guess.
/// Marker on every menagerie entity.
#[derive(Component)]
struct WarmRig;
@@ -268,6 +256,9 @@ fn run_warm_pass(
rigs: Query<Entity, With<WarmRig>>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<WowModelMaterial>>,
mut lanes: WarmLanes,
booth: BoothCamQuery,
mut gizmos: Gizmos,
mut cache: Local<MaterialCache>,
shared_light: Option<Res<crate::lighting::SharedLightBuffer>>,
) {
@@ -301,8 +292,10 @@ fn run_warm_pass(
let count = spawn_menagerie(
&mut commands,
cam,
booth.iter().next(),
&mut meshes,
&mut materials,
&mut lanes,
&mut cache,
&light.0,
);
@@ -312,6 +305,15 @@ fn run_warm_pass(
if warm.done {
return;
}
// The gizmo-line lane (0938): gizmos are immediate-mode, so the warm draw happens per frame
// while the pass runs — one tiny line through the DEFAULT config group, exactly the config
// the bowstring draws with, compiles the `LineGizmo` pipeline that otherwise waits for the
// first bow-wielder in view.
gizmos.line(
Vec3::new(0.0, 0.0, -0.5),
Vec3::new(0.001, 0.0, -0.5),
Color::WHITE,
);
let pending = watch
.0
.created
@@ -331,239 +333,3 @@ fn run_warm_pass(
warn!("pipeline warm: TIMED OUT with {pending} pipelines pending — cover released");
}
}
/// Spawn one tiny quad per reachable model-lane pipeline variant, parented to the world camera.
/// Materials come from the PRODUCTION builders (`model_material` / `zfill_material`) so the
/// variant encoding can never drift from the real spawn paths; meshes from the production
/// submesh builders so the vertex layouts can't either. Returns the entity count.
fn spawn_menagerie(
commands: &mut Commands,
cam: Entity,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<WowModelMaterial>,
cache: &mut MaterialCache,
light: &Buffer,
) -> usize {
// The four vertex layouts the model lane ships (0837 dump: strides 32/48/56/72): static ×
// {plain, vertex-colours} and their skinned twins. Statics are RENDER_WORLD-only, so their
// Aabb is computed here and inserted explicitly (0832's rule); skinned twins keep main-world
// data and `calculate_bounds` covers them.
let mut layouts: Vec<(Handle<Mesh>, Option<bevy::camera::primitives::Aabb>, bool)> = Vec::new();
for colors in [false, true] {
let stat = benilla_assets::submesh_to_static_mesh(&warm_quad(colors, false));
let aabb = stat.compute_aabb();
layouts.push((meshes.add(stat), aabb, false));
let skin = benilla_assets::submesh_to_skinned_mesh(&warm_quad(colors, true));
layouts.push((meshes.add(skin), None, true));
}
// The material families. The full cross is deliberate: every branch here is authorable in an
// M2/WMO (blends, the 0x10/0x08 depth flags, sidedness), and an over-warmed variant costs
// milliseconds behind a loading bar once per run, while a missed one is a director-felt live
// stall. The observed set (28) is the floor, not the target.
let mut mats: Vec<Handle<WowModelMaterial>> = Vec::new();
for two_sided in [false, true] {
for blend in [
ModelBlend::Opaque,
ModelBlend::AlphaTest,
ModelBlend::Blend,
ModelBlend::Mod,
ModelBlend::Mod2x,
] {
for no_depth_write in [false, true] {
for no_depth_test in [false, true] {
mats.push(model_material(
cache,
materials,
None,
blend,
two_sided,
false,
false,
false,
false,
false,
no_depth_write,
no_depth_test,
FogPolicy::Scene,
ShadeSel::Lit,
0,
None,
None,
None,
None,
false,
light,
));
}
}
}
// The additive glow-card blend state (specialize's pure ONE/ONE add), both depth-write
// flavours, and the doodad/entity distance-fade blend twin (depth-write forced on).
for no_depth_write in [false, true] {
mats.push(model_material(
cache,
materials,
None,
ModelBlend::Blend,
two_sided,
false,
false,
false,
true,
false,
no_depth_write,
false,
FogPolicy::Scene,
ShadeSel::Lit,
0,
None,
None,
None,
None,
false,
light,
));
}
mats.push(model_material(
cache,
materials,
None,
ModelBlend::Blend,
two_sided,
false,
false,
false,
false,
true,
false,
false,
FogPolicy::Scene,
ShadeSel::Lit,
0,
None,
None,
None,
None,
false,
light,
));
// The depth-prime twin (colour writes masked off), plain and cutout.
for cutout in [false, true] {
mats.push(zfill_material(
cache, materials, None, two_sided, cutout, light,
));
}
}
// The ground-clutter lane (Mask + specialize's over-blend), both sidednesses — the first
// verification leg caught the two-sided one compiling live. Its material is built by
// `WorldAssets::model_material` (image machinery this pass doesn't need) — the pipeline only
// sees the KEY bits, so arm `clutter_fade` on a COPY of the plain Mask material (a fresh
// asset; the dedup cache's own entry stays untouched).
for two_sided in [false, true] {
let mask = model_material(
cache,
materials,
None,
ModelBlend::AlphaTest,
two_sided,
false,
false,
false,
false,
false,
false,
false,
FogPolicy::Scene,
ShadeSel::Lit,
0,
None,
None,
None,
None,
false,
light,
);
if let Some(m) = materials.get(&mask) {
let mut m = m.clone();
m.extension.clutter_fade = Vec4::new(52.5, 70.0, 0.0, 1.0);
let clutter = materials.add(m);
mats.push(clutter);
}
}
let mut count = 0;
for (mesh, aabb, skinned) in &layouts {
for mat in &mats {
let tag = if *skinned {
MeshTag(crate::mesh_tag::rig_bits(0) | crate::mesh_tag::alpha_bits(1.0))
} else {
MeshTag(crate::mesh_tag::alpha_bits(1.0))
};
let mut e = commands.spawn((
Mesh3d(mesh.clone()),
MeshMaterial3d(mat.clone()),
Transform::from_xyz(0.0, 0.0, -0.5).with_scale(Vec3::splat(0.01)),
tag,
WarmRig,
ChildOf(cam),
));
if let Some(aabb) = aabb {
e.insert(*aabb);
}
count += 1;
}
}
count
}
/// A unit quad in each attribute combination the model lane ships. Every `RenderSubmesh` field
/// is spelled out on purpose: a new field breaks THIS build, which is the drift alarm that keeps
/// the menagerie honest against the format.
fn warm_quad(colors: bool, skinned: bool) -> RenderSubmesh {
let n = 4usize;
RenderSubmesh {
positions: vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[1.0, 1.0, 0.0],
[0.0, 1.0, 0.0],
],
normals: vec![[0.0, 0.0, 1.0]; n],
uvs: vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]],
indices: vec![0, 1, 2, 0, 2, 3],
texture: None,
skin_slot: None,
geoset_id: 0,
char_slot: None,
blend: ModelBlend::Opaque,
wrap_x: true,
wrap_y: true,
two_sided: false,
joints: if skinned { vec![[0; 4]; n] } else { Vec::new() },
weights: if skinned {
vec![[1.0, 0.0, 0.0, 0.0]; n]
} else {
Vec::new()
},
vertex_colors: if colors {
vec![[1.0, 1.0, 1.0, 1.0]; n]
} else {
Vec::new()
},
interior: false,
emissive: false,
sidn: None,
window: false,
additive: false,
no_depth_write: false,
no_depth_test: false,
fog_policy: FogPolicy::Scene,
billboard: None,
welded_billboard: false,
alpha_anim: None,
uv_anim: None,
rgb_anim: None,
wmo_batch: None,
}
}
+14 -2
View File
@@ -1315,15 +1315,22 @@ fn sync_body_booth(
/// skips its whole pass (clear + model + FFXGlow chain); its target keeps the last render — exactly
/// right for a still (the 0105 bake, frozen at Stand).
/// With `WOW_PORTRAIT_TEST` set the gate stands down (the eyeball harness wants live cameras).
/// The pipeline warm pass is demand too (decision 0938): its menagerie duplicates rigs onto a
/// booth layer so the booths' `Msaa::Off` pipeline twins compile behind the entry cover — which
/// only works if the booth cameras render during the warm window.
fn gate_booth_cameras(
mut booths: ResMut<Booths>,
preview: Res<GluePreview>,
images: Res<Assets<Image>>,
warm: Res<crate::pipe_warm::WarmPass>,
time: Res<Time<bevy::time::Real>>,
mut cams: Query<(&BoothCam, &mut Camera)>,
mut env_cache: Local<Option<bool>>,
) {
let test = test_mode(&mut env_cache);
// `satisfied()` is false exactly while the covered warm window runs (the loading screen
// holds on it), so this term costs nothing outside that window.
let warming = !warm.satisfied();
for (BoothCam(token), mut cam) in &mut cams {
let Some(booth) = booths.0.get_mut(token.as_str()) else {
continue;
@@ -1335,8 +1342,12 @@ fn gate_booth_cameras(
booth.wake = booth.wake.max(1);
}
let live_scene = token.as_str() == GLUE_SLOT && preview.scene.is_some();
let active =
test || live_scene || booth.live || booth.wake > 0 || !booth.pending.is_empty();
let active = test
|| warming
|| live_scene
|| booth.live
|| booth.wake > 0
|| !booth.pending.is_empty();
// `WOW_BOOTH_LOG=1`: the gate's timeline — every activity flip and every armed frame,
// wall-stamped (the first-login black-pane hunt).
static LOG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
@@ -1441,6 +1452,7 @@ mod tests {
geometry: None,
owner_reach: 0.0,
water_bound: (Vec3::ZERO, 0.0),
idle_seq: 0,
})
.collect(),
}
+42 -3
View File
@@ -15,7 +15,9 @@
//! FactionTemplate.dbc row 35 (VERIFIED from the extracted DBC): faction 31 "Friendly", own group
//! mask 0, enemy mask 0 — it is hostile to nothing and nothing is hostile to it. Any hostility,
//! reaction-colour, nameplate, aggro, threat, damage or drowning measurement taken with GM on is
//! simply wrong, quietly. It is now **off** by default: [`crate::probe_shield`] keeps the body
//! simply wrong, quietly. It also silently suspends the **indoor dismount**: the outdoor-only
//! aura sweep is `!IsGameMaster()`-gated, so a GM rides into the Goldshire inn and stays mounted
//! (decision 0934). It is now **off** by default: [`crate::probe_shield`] keeps the body
//! alive without poisoning any of that (decision 0677), and this banner reports which it is.
//! 3. **Movement is server-blocked** — rooted, stunned, confused, fleeing, or mid-taxi-flight. The
//! controller ignores input and the honest report is "the mover is broken".
@@ -276,7 +278,9 @@ fn findings(
(\"Friendly\", enemy mask 0) and freezes the mirror timers, so NOTHING is hostile, \
nothing aggros, fall/environmental damage is skipped and breath/fatigue never tick. \
Any hostility, reaction-colour, nameplate, threat, aggro, damage or drowning reading \
taken now is wrong. {}",
taken now is wrong. It ALSO suspends the INDOOR DISMOUNT: \
`CheckAreaExploreAndOutdoor` drops outdoor-only auras only `if (… && \
!IsGameMaster())`, so a GM rides into a building and stays mounted (decision 0934). {}",
match shield {
// GM mode is the DEFAULT (0679) — it is what stops a parked body being mobbed, and
// the shield is what makes dropping it safe. So this warning is expected on most
@@ -284,6 +288,15 @@ fn findings(
ShieldReport::Arming | ShieldReport::Armed =>
"This is the default. Re-run with WOW_GM=off for those readings — safe, \
because the probe shield (decision 0677) keeps the body alive without it.",
// Not a probe body — the director's own account, or a bystander. `WOW_GM` would be
// inert here (the shield only ever commands `probe<N>`, 0677), and saying otherwise
// sends the reader after a switch that does nothing. The state is also PERSISTED:
// vmangos saves it in `characters.extra_flags` bit 0 and `GM.LoginState = 2`
// restores it, so it stays on across logins until somebody turns it off.
ShieldReport::NotOurs =>
"WOW_GM does not reach this body — the shield only ever commands probe accounts \
(0677) — so the way out is typing `.gm off` yourself. It persists across \
logins (vmangos GM.LoginState = 2), which is why it is on now.",
_ =>
"Re-run with WOW_GM=off for those readings. Note the probe shield is NOT up on \
this run, so an unshielded body with GM mode off can be killed.",
@@ -484,13 +497,39 @@ mod tests {
ShieldReport::Armed,
ShieldReport::Arming,
ShieldReport::Disabled,
ShieldReport::NotOurs,
ShieldReport::Unconfirmed,
] {
let out = findings(&gm, report);
let line = out.iter().find(|l| l.contains("GM MODE IS ON")).unwrap();
assert!(line.contains("WOW_GM=off"), "{report:?}: {line}");
assert!(!line.contains("put it back on"), "{report:?}: {line}");
}
// …but the way out has to be one that WORKS on this body. `WOW_GM` only ever commands a
// probe account (0677), so on anyone else's — the director's own, which is exactly who
// rides into an inn and asks why they are still mounted — the switch is `.gm off`, typed.
let not_ours = findings(&gm, ShieldReport::NotOurs);
let line = not_ours
.iter()
.find(|l| l.contains("GM MODE IS ON"))
.unwrap();
assert!(
line.contains(".gm off") && !line.contains("WOW_GM=off"),
"{line}"
);
}
#[test]
fn the_gm_warning_names_the_indoor_dismount() {
// The consequence that cost a session (decision 0934): the outdoor-only aura sweep is
// `!IsGameMaster()`-gated server-side, so a GM never dismounts riding into a building — and
// the client, whose mount lane is nothing but the MOUNTDISPLAYID watcher, has no say in it.
let gm = player(&[
(HEALTH, 60),
(MAXHEALTH, 60),
(PLAYER_FLAGS, PLAYER_FLAGS_GM),
]);
let line = findings(&gm, ShieldReport::Armed).remove(0);
assert!(line.contains("INDOOR DISMOUNT"), "{line}");
}
#[test]
+74 -5
View File
@@ -22,6 +22,22 @@
//! entry, throttled by its per-entry minute delay;
//! higher `Priority` wins when nested areas compete (we resolve one row).
//!
//! **The music slot holds exactly one stream**, and every start goes through
//! [`start_music_stream`], which fades the outgoing one out as it opens the incoming. Dropping a
//! kira handle does *not* stop its sound (that is precisely what the fade-stop rides on), so a
//! start that merely overwrote the handle would leave the old track playing forever, unreapable
//! and deaf to the volume slider.
//!
//! **Server-pushed music** (`SMSG_PLAY_MUSIC`) is a SoundEntries kit for that same slot, and the
//! server **re-pushes the same id on a timer** to keep an event track looping: the Darkmoon Faire's
//! emitter sends it every **5 s** (vmangos `go_darkmoon_faire_music`, commented as a sniffed retail
//! value). So a push for the kit already playing is a **no-op** ([`slot_holds`]) — the client's own
//! already-playing early-out (`0x4602e0` @`0x460342`: `cmp [0xb06cbc],eax; je` → desired == playing
//! → return); when the track *does* end, the next push restarts it, which is what makes the loop.
//! Any other push takes the slot the way a zone-music change does (outgoing 4.0 s fade, incoming at
//! full) — inferred from the slot's one transition idiom, as the `SMSG_PLAY_MUSIC` handler itself
//! is not pinned in wow-re.
//!
//! **Ambience transport** (`0x460b00`, the separate ambience updater; §5 B16). An area / interior /
//! day↔night / ghost ambience-id change is a **5.0 s crossfade**: the old bed fades out over 5.0 s
//! then stops (`0x7a5a10(0x40a00000 = 5.0f)`) while the new bed starts at volume 0 and fades in over
@@ -132,8 +148,11 @@ fn fade_in_gain(fade: &mut Option<FadeIn>, now: f64) -> f32 {
pub(super) struct ZoneAudio {
/// The `ZoneMusic` row currently scheduled (0 = none).
zone_music: u32,
/// The playing music stream (a zone track or an intro).
/// The playing music stream (a zone track, an intro, or a server-pushed event track).
music: Option<StreamingSoundHandle<kira::sound::FromFileError>>,
/// The SoundEntries kit on the music slot (0 = never started one) — the "what is playing" a
/// repeat `SMSG_PLAY_MUSIC` is compared against ([`slot_holds`]).
music_kit: u32,
/// The playing track's kit base volume (multiplied under the category slider + fade).
music_kit_vol: f32,
/// When the next zone track starts (Time::elapsed_secs_f64; None = a track is playing or
@@ -167,6 +186,7 @@ impl Default for ZoneAudio {
Self {
zone_music: 0,
music: None,
music_kit: 0,
music_kit_vol: 1.0,
next_track_at: None,
have_played_music: false,
@@ -458,9 +478,19 @@ fn next_track_time(
})
}
/// Open a music kit as a stream on the music slot (a zone track or an intro). The stream starts at
/// **full** kit × category volume for every start — the client has no music fade-in (§5 B16);
/// the slot's only fade is the outgoing 4.0 s fade-stop. Returns whether a stream actually started.
/// Whether the music slot is *already* running `kit_id` — the client's already-playing early-out
/// (`0x4602e0` @`0x460342`: `cmp [0xb06cbc],eax; je` → desired == playing → return), which is what
/// makes the server's every-5-s re-push of an event track a no-op instead of a restart. A handle
/// the end-of-track reap hasn't collected yet (`Stopped`) does **not** hold the slot: the track is
/// over, and a push is exactly what should start it again.
fn slot_holds(music_kit: u32, kit_id: u32, slot: Option<kira::sound::PlaybackState>) -> bool {
music_kit == kit_id && slot.is_some_and(|s| s != kira::sound::PlaybackState::Stopped)
}
/// Open a music kit as a stream on the music slot (a zone track, an intro, or a server-pushed
/// track), replacing whatever is on it. The stream starts at **full** kit × category volume for
/// every start — the client has no music fade-in (§5 B16); the slot's only fade is the outgoing
/// 4.0 s fade-stop. Returns whether a stream actually started.
fn start_music_stream(
zone: &mut ZoneAudio,
out: &mut SoundOutput,
@@ -490,7 +520,14 @@ fn start_music_stream(
match mixer_ref.play_stream(data.volume(mixer::amp_to_db(start_amp))) {
Ok(h) => {
info!("zone music: {path}");
zone.music = Some(h);
// One stream per slot: hand the outgoing track the 4.0 s music fade as the incoming
// takes over (the same overlap `0x4602e0` arms on a zone-music change). It cannot just
// be dropped — a dropped kira handle keeps playing, so an overwritten stream would
// stay in the mix at full volume until its file ran out.
if let Some(mut outgoing) = zone.music.replace(h) {
outgoing.stop(mixer::fade(MUSIC_FADE_OUT_MS));
}
zone.music_kit = kit_id;
zone.music_kit_vol = kit_vol;
zone.have_played_music = true;
true
@@ -590,6 +627,17 @@ fn server_sounds(
for m in msgs.read() {
match m.kind {
ServerSoundKind::Music => {
// A push for the track already on the slot is a NO-OP (module docs): the server
// re-pushes an event track on a timer to loop it — the Darkmoon Faire emitter
// every 5 s — so restarting on each push stacked a fresh full-volume copy of the
// faire theme onto the mix every 5 s, a dozen at a time.
if slot_holds(
zone.music_kit,
m.sound_id,
zone.music.as_ref().map(|h| h.state()),
) {
continue;
}
start_music_stream(&mut zone, &mut out, &mut kits, &assets, &config, m.sound_id);
}
ServerSoundKind::Sound2d | ServerSoundKind::ObjectSound => {
@@ -634,6 +682,7 @@ fn leave_world(mut zone: NonSendMut<ZoneAudio>) {
h.stop(mixer::fade(WORLD_TEARDOWN_FADE_MS));
}
zone.zone_music = 0;
zone.music_kit = 0;
zone.music_kit_vol = 1.0;
zone.next_track_at = None;
zone.ambience_kit = 0;
@@ -663,3 +712,23 @@ pub(super) fn plugin(app: &mut App) {
leave_world,
);
}
#[cfg(test)]
mod tests {
use super::slot_holds;
use kira::sound::PlaybackState;
/// The repeat-push guard. The server keeps an event track looping by re-pushing its id every
/// few seconds (the Darkmoon Faire emitter: `SMSG_PLAY_MUSIC` 8440 every 5 s), so a push for
/// the track *currently playing* must do nothing — without this the faire stacked a new
/// full-volume copy of its theme into the mix every 5 s. Once the track ends (or never
/// started), the same id must start it again: that repeat is what loops the music.
#[test]
fn repeat_music_push_only_no_ops_while_the_track_plays() {
assert!(slot_holds(8440, 8440, Some(PlaybackState::Playing)));
assert!(!slot_holds(8440, 8440, Some(PlaybackState::Stopped)));
assert!(!slot_holds(8440, 8440, None));
// A different track always takes the slot (an intro, a scripted track, a zone change).
assert!(!slot_holds(8440, 4123, Some(PlaybackState::Playing)));
}
}
+5 -1
View File
@@ -212,8 +212,12 @@ impl Plugin for TargetPlugin {
click::world_right_click_payload,
click::select_on_click,
// AFTER the gated select (which holds while the mode is active): the commit
// may clear the mode, and the selection gate must have read it first.
// may clear the mode, and the selection gate must have read it first. The two
// world legs are siblings, not a fallback chain — the pending spell's word
// decides which of them a click can even feed (decision 0939), so their order
// relative to each other never matters.
crate::ui_action::targeting::commit_ground_cast_on_click,
crate::ui_action::targeting::commit_object_cast_on_click,
click::act_on_right_click,
click::clear_target_requests,
click::target_unit_requests,
+57 -36
View File
@@ -101,7 +101,7 @@ pub(crate) struct CastLadder<'w, 's> {
pub(crate) ground: ResMut<'w, super::targeting::SpellTargeting>,
}
/// What a targeting-cursor click bound — the two things `BindLocation 0x6e60f0` /
/// What a targeting-cursor click bound — the three things `BindLocation 0x6e60f0` /
/// `BindTarget 0x6e5b40` can fill into a standing flag_word once the ladder has already run.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum TargetedBind {
@@ -109,17 +109,19 @@ pub(crate) enum TargetedBind {
Dest([f32; 3]),
/// The bag / paper-doll click's item guid (decision 0923).
Item(u64),
/// The world click's GameObject guid (decision 0939) — a chest, a door, a vein, a herb.
Object(u64),
}
impl CastLadder<'_, '_> {
/// **The targeting cursor's commit tail**, shared by both halves. The ladder itself already
/// **The targeting cursor's commit tail**, shared by all three seams. The ladder itself already
/// ran when the cursor went up — the click owes only what `SendCast 0x6e54f0`'s tail owes:
/// the packet (same block, two opcodes, [`CastCommit`] picking which), the pending arm, and
/// the GCD. Then the word clears.
///
/// One function because there is one tail: the ground click and the item click differ in
/// exactly one field of one packet, and a tail written twice is a tail that drifts (the
/// lesson decision 0914 wrote up one layer above this).
/// One function because there is one tail: the three clicks differ in exactly one field of one
/// packet, and a tail written three times is a tail that drifts (the lesson decision 0914 wrote
/// up one layer above this).
pub(crate) fn commit_targeted(
&mut self,
spell_id: u32,
@@ -135,6 +137,12 @@ impl CastLadder<'_, '_> {
spell_id,
item_guid,
},
// The same packet the right-click OPEN_LOCK path sends (decision 0239) — one
// builder, because `BindTarget`'s GameObject arm is the one that fills the block
// on both routes.
TargetedBind::Object(go_guid) => {
ClientCommand::CastSpellGameObject { spell_id, go_guid }
}
},
CastCommit::Item {
bag_index,
@@ -148,6 +156,7 @@ impl CastLadder<'_, '_> {
target: match bound {
TargetedBind::Dest(dest) => UseItemTarget::Dest(dest),
TargetedBind::Item(guid) => UseItemTarget::Item(guid),
TargetedBind::Object(guid) => UseItemTarget::Object(guid),
},
},
};
@@ -400,25 +409,20 @@ fn send_spell_cast(
) {
cast_target::CastWireTarget::SelfImplicit => None,
cast_target::CastWireTarget::Unit(guid) => Some(guid),
cast_target::CastWireTarget::GroundTargeting => {
// Enter the targeting-cursor mode's location half (decision 0792) — nothing is
// sent, nothing armed: the ref's cursor entry (`6e50c8`) runs none of the commit
// tail; the world click's commit 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 world click's `0x6e54f0` still
// emits USE_ITEM for a thrown grenade or a stick of dynamite.
debug!("ui_action: cast {spell_id} awaits its ground click — targeting cursor up");
ground.enter(spell_id, commit, super::targeting::TargetingWants::Location);
return;
}
cast_target::CastWireTarget::ItemTargeting => {
// The same cursor's item half (decision 0923) — the bag / paper-doll click seam
// binds, and its `0x495d60` owes the commit tail. A poison, a sharpening stone,
// an enchanting scroll, a Craft-window enchant: same entry, same word, same
// pending-cast block carried across the cursor, so the click still emits
// USE_ITEM for an item's own ON_USE and CAST_SPELL for a known enchant.
debug!("ui_action: cast {spell_id} awaits its item click — targeting cursor up");
ground.enter(spell_id, commit, super::targeting::TargetingWants::Item);
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) => {
@@ -750,27 +754,25 @@ mod tests {
));
}
/// The targeting cursor's ONE commit tail (decision 0923), all four cells of its
/// commit × bind grid. Both halves of the cursor share it precisely so this table can't grow
/// a fifth, divergent copy: a thrown grenade and a poison bottle are the same `CMSG_USE_ITEM`
/// with a different bit set, and a Flamestrike and a Craft-window enchant are the same
/// pending-cast block under two `CMSG_CAST_SPELL` builders. Every cell also arms the pending
/// guard and clears the word — asserted once, since the tail is one function.
/// 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`
/// with a different bit set, and a Flamestrike, a Craft-window enchant and a lockpick are the
/// same pending-cast block under three `CMSG_CAST_SPELL` builders. Every cell also arms the
/// pending guard and clears the word — asserted once, since the tail is one function.
#[test]
fn the_targeting_commit_tail_covers_both_halves() {
fn the_targeting_commit_tail_covers_every_seam() {
use benilla_protocol::messages::UseItemTarget;
const DEST: [f32; 3] = [1.0, 2.0, 3.0];
const ITEM: u64 = 0xF150_0000_0000_ABCD;
const GO: u64 = 0xF110_000C_1F00_A3B2;
let (mut world, rx) = world();
let commit = |world: &mut World, commit: CastCommit, bound: TargetedBind| {
world.insert_resource(crate::ui_cast::PendingCast::default());
world
.resource_mut::<super::super::targeting::SpellTargeting>()
.enter(
HEARTHSTONE,
commit,
super::super::targeting::TargetingWants::Item,
);
// The lock word — the one that answers both the bag and the world seam.
.enter(HEARTHSTONE, commit, 0x4800);
world
.run_system_once(move |mut ladder: CastLadder| {
ladder.commit_targeted(HEARTHSTONE, commit, bound);
@@ -826,5 +828,24 @@ mod tests {
..
})
));
// The world seam (decision 0939): the same two opcodes, the GameObject block. The spell
// arm is byte-identical to the right-click OPEN_LOCK send — one builder, deliberately.
commit(&mut world, CastCommit::Spell, TargetedBind::Object(GO));
assert!(matches!(
rx.try_recv(),
Ok(ClientCommand::CastSpellGameObject { go_guid: GO, .. })
));
commit(&mut world, HEARTH_COMMIT, TargetedBind::Object(GO));
assert!(matches!(
rx.try_recv(),
Ok(ClientCommand::UseItem {
bag_index: 255,
slot: 24,
target: UseItemTarget::Object(GO),
..
})
));
}
}
+105 -55
View File
@@ -20,14 +20,17 @@
//! is unmodeled here (INTERIM): we refuse locally with the client's own error strings instead —
//! `0x09` "You have no target." / `0x0A` "Invalid target" — and never ship an unbindable cast.
//!
//! **Both halves of the targeting cursor are modeled.** The *location* half (decision 0792):
//! switch enum 16 and the bare DEST word (`Targets = 0x40`) resolve to
//! [`CastWireTarget::GroundTargeting`], which [`super::targeting`] turns into the Cast/UnableCast
//! cursor + the world-click commit. The *item* half (decision 0923): the bare ITEM word
//! (`Targets = 0x10`) resolves to [`CastWireTarget::ItemTargeting`], the same module's bag /
//! paper-doll click commit. Still deferred, refused-not-guessed: the pure SOURCE word (0x20 —
//! NPC-cast data only), the LOCKED word (`0x4000`, whose lock machinery the world-click OPEN_LOCK
//! path owns), gameobject bit 11, STRING bit 13, and the *unit* hand-cursor mode (the
//! **All three seams of the targeting cursor are modeled** — and the cursor carries the *word*
//! ([`CastWireTarget::Targeting`]), not a verdict about it, because the reference's three "wants"
//! predicates are three independent mask tests on the one word `0xcecac0` and **more than one can
//! be true at once**: `TargetingWantsLocation 0x6e6320` (`& 0x60`) → the terrain click (decision
//! 0792); `TargetingWantsItem 0x6e6330` (`& 0x4010`) → the bag / paper-doll click (0923/0928);
//! `TargetingWantsGameObject 0x6e62d0` (`ch & 0x48`, i.e. `& 0x4800`) → the world click on a
//! GameObject (decision 0939). A LOCKED spell — Opening, Pick Lock, Mining, Herb Gathering —
//! satisfies the last **two**, and the click decides which leg it was, exactly as `BindTarget
//! 0x6e5b40` decides: by the clicked object's typemask (`6e5f17` item bit 1, `6e5f52` GameObject
//! bit 5), each arm then re-testing the word. Still deferred, refused-not-guessed: the pure SOURCE
//! word (0x20 — NPC-cast data only), STRING bit 13, and the *unit* hand-cursor mode (the
//! residual-unit-word machine behind the autoSelfCast stand-in above).
use benilla_formats::SpellDisplay;
@@ -67,9 +70,24 @@ pub(crate) const ERR_INVALID_TARGET: u8 = 0x0A;
const TF_DEST_LOCATION: u16 = 0x0040;
/// The item bit — the *other* half of the targeting cursor (decision 0923). Its predicate twin is
/// `TargetingWantsItem 0x6e6330` (`flag_word & 0x4010`), which the bag and paper-doll click seams
/// consult before binding the clicked item ([`super::targeting`]). Only the bare bit resolves here:
/// see [`resolve_cast_target`] for why `0x4000` stays refused.
/// consult before binding the clicked item ([`super::targeting`]).
const TF_ITEM: u16 = 0x0010;
/// `TARGET_FLAG_LOCKED` — the **shared** bit: it is in `TargetingWantsItem`'s `0x4010` *and* in
/// `TargetingWantsGameObject`'s `0x4800`, which is precisely how one armed lock spell serves both
/// legs (decisions 0928 / 0939). Mining, Herb Gathering, Opening, Pick Lock, Disarm Trap: one
/// spell, two clicks that can end it — a lockbox in your bag, or a lockable GameObject in the
/// world. Neither leg is a new wire shape; `BindTarget 0x6e5b40` picks its arm by the clicked
/// object's typemask and then **hardcodes** the outgoing bit — item arm `6e5f1e: testl $0x4010,
/// 0xcecac0` → `6e5f2e: orb $0x10, 0xceac5c` (ITEM); GameObject arm `6e5f60: testb $0x48, %ch` →
/// `6e5f69: orb $0x8, 0xceac5d` (GAMEOBJECT). `LOCKED` itself is never written to the wire mask
/// by either (decision 0939's census). vmangos agrees from the other side: `Spell::CheckCast`
/// accepts a LOCKED spell whose mask carries `TARGET_FLAG_ITEM | TARGET_FLAG_GAMEOBJECT`
/// (`Spell.cpp:6755`).
const TF_LOCKED: u16 = 0x4000;
/// `TARGET_FLAG_GAMEOBJECT` — the other bit of `TargetingWantsGameObject 0x6e62d0`'s `0x4800`, and
/// the one implicit arm 23 ORs in ([`cast_target_mask`]). A word carrying it arms the world click's
/// GameObject leg (decision 0939).
const TF_GAMEOBJECT: u16 = 0x0800;
/// What the wire's target block should carry for this cast.
#[derive(Debug, PartialEq, Eq)]
@@ -79,13 +97,14 @@ pub(crate) enum CastWireTarget {
/// A bound unit — mask `TARGET_FLAG_UNIT (0x2)` + this guid (possibly the player's own,
/// via the autoSelfCast fallback).
Unit(u64),
/// A ground-targeted cast (decision 0792) — no send yet: enter the targeting-cursor mode's
/// location half ([`super::targeting`]); the world click commits mask `0x40` + the point.
GroundTargeting,
/// An **item**-targeted cast (decision 0923) — no send yet either: the same cursor mode's item
/// half; the next bag or paper-doll click commits mask `0x10` + the clicked item's packed guid.
/// Poisons, sharpening/weightstones, weapon oils, scopes, every enchant, Disenchant.
ItemTargeting,
/// The cast awaits a click — no send yet: enter the targeting-cursor mode with **this standing
/// flag_word** ([`super::targeting`]), which is the reference's own state (`0xcecac0` != 0 *is*
/// `IsTargeting 0x6e48a0`). Each click seam then asks the word its own question, so one word
/// can serve more than one seam: terrain (`& 0x60` → mask `0x40` + the point), bag / paper doll
/// (`& 0x4010` → mask `0x10` + the item guid), world GameObject (`& 0x4800` → mask `0x800` +
/// the GO guid). Blizzard and Flamestrike arm the first; poisons, oils and enchants the second;
/// Opening and Pick Lock arm the second **and** the third at once.
Targeting(u16),
/// Nothing bindable — do NOT send; surface this client error instead.
Refused(u8),
}
@@ -310,7 +329,11 @@ pub(crate) fn resolve_cast_target(
// after the word==0 immediate-commit above, whose order the ref fixes (`6e5338` precedes the
// bl test).
if def.implicit_target_a1 == 16 {
return CastWireTarget::GroundTargeting;
// The ref's arm-16 defer is a cursor-mode flag (`bl`), not a word bit — but our seams read
// only the word, so the DEST bit is what says "the terrain click owns this one". The two
// agree on shipped data (arm-16 rows carry `Targets & 0x40` anyway); the OR is what keeps
// them agreeing if one ever doesn't.
return CastWireTarget::Targeting(word | TF_DEST_LOCATION);
}
// Bits outside the unit family (item/gameobject/location/string) have no candidate here.
// The DEST-location word (Blizzard's bare `Targets = 0x40`, default switch arm) is the
@@ -321,24 +344,22 @@ pub(crate) fn resolve_cast_target(
// is NPC-cast data (Aura of Fear kin), unreachable from a player's book, and keeps the
// refusal with the item/GO/string machines.
if word & !UNIT_BITS != 0 {
if word == TF_DEST_LOCATION {
return CastWireTarget::GroundTargeting;
}
// The item half of the same cursor (decision 0923). The reference never decides this at
// arm time — it lets the bind walk fail, leaves the word standing, and asks
// `TargetingWantsItem 0x6e6330` (`word & 0x4010`) at *click* time. Forking here is
// byte-equivalent on shipped data because the two item bits are never mixed with a unit
// bit: a whole-`Spell.dbc` sweep finds every `Targets & 0x4010` row is exactly `0x10`
// (363 rows — enchants, poisons, stones, oils, scopes, Disenchant) or exactly `0x4000`
// (103 rows — the OPEN_LOCK family), and that pin is a test
// (`benilla_formats::spells::catalog_tests::real_item_target_family_and_its_gate_columns`).
// Hand the whole word to the cursor when any seam can serve it — written as the
// reference's own three predicates rather than as equalities on bare words. The reference
// never decides this at arm time at all: it lets the bind walk fail, leaves the word
// standing, and asks at *click* time. Forking here is byte-equivalent for a structural
// reason, not a data one — **no unit candidate can ever clear any of these bits**
// ([`clear_satisfied_bits`] only clears the unit family), so a word carrying one always
// survives the walk and always ends in cursor mode.
//
// Only the bare `0x10` resolves. `0x4000` is `TARGET_FLAG_LOCKED`, whose spells are the
// Mining/Herb Gathering/Opening/Pick Lock family: implicit arm 23 also sets the GAMEOBJECT
// bit, and their *item* leg (a lockbox in your bag) needs the lock machinery the world
// click already owns — the GO gap decision 0914 named. It stays refused, deliberately.
if word == TF_ITEM {
return CastWireTarget::ItemTargeting;
// Which is exactly why the word travels instead of a verdict about it. The LOCKED family's
// word is not bare: 100 of its 103 rows carry implicit arm 23, whose overlay ORs in
// `TF_GAMEOBJECT`, and one carries arm 25 (`TF_UNIT`). An equality test refused every one
// of them (0928's live probe: a Dull Iron Key drew "Invalid target" instead of the
// cursor) — and a single-verdict enum then had to *choose* item-or-world for a word the
// reference lets satisfy both. Both bits ride along; the click picks the leg.
if word == TF_DEST_LOCATION || word & (TF_ITEM | TF_LOCKED | TF_GAMEOBJECT) != 0 {
return CastWireTarget::Targeting(word);
}
return CastWireTarget::Refused(ERR_INVALID_TARGET);
}
@@ -475,12 +496,12 @@ mod tests {
let flamestrike = spell(0x40, 16);
assert_eq!(
resolve_cast_target(Some(&flamestrike), Some(42), Some(1), true, &rel_none()),
CastWireTarget::GroundTargeting
CastWireTarget::Targeting(TF_DEST_LOCATION)
);
let blizzard = spell(0x40, 28);
assert_eq!(
resolve_cast_target(Some(&blizzard), None, Some(1), true, &rel_none()),
CastWireTarget::GroundTargeting
CastWireTarget::Targeting(TF_DEST_LOCATION)
);
let self_commit_with_ground_arm = spell(0, 16);
assert_eq!(
@@ -496,11 +517,13 @@ mod tests {
);
}
/// The still-deferred non-unit masks (source-location, LOCKED, string, the 0x60 pair) refuse
/// instead of shipping a guess — the machines named in the module docs.
/// The still-deferred non-unit masks (source-location, string, the 0x60 pair) refuse instead
/// of shipping a guess — the machines named in the module docs. `0x60` is the *pair*: the
/// reference binds source then dest across two clicks (`BindLocation 0x6e60f0`'s bit-5 arm
/// takes priority and only the second click sends), and our terrain seam binds dest only.
#[test]
fn non_unit_masks_refuse() {
for targets in [0x20u32, 0x4000, 0x2000, 0x60] {
for targets in [0x20u32, 0x2000, 0x60] {
let s = spell(targets, 0);
assert_eq!(
resolve_cast_target(Some(&s), Some(42), Some(1), true, &rel_none()),
@@ -510,22 +533,49 @@ mod tests {
}
}
/// The bare ITEM word enters the cursor's item half (decision 0923) — and it does so whether
/// or not a unit is selected, because there is no unit leg to try: the word carries no unit
/// bit (pinned across the whole shipped table by the formats-side family test). Its LOCKED
/// sibling `0x4000` stays refused above; the word==0 immediate commit still precedes both.
/// The seam predicates are MASKS — `word & 0x4010` for the bag click, `word & 0x4800` for the
/// world click — so every word carrying one of their bits raises the cursor, not just the bare
/// ones. That distinction is the whole of the LOCKED family: 100 of its 103 rows carry implicit
/// arm 23, whose overlay ORs the GAMEOBJECT bit into the word (`0x4800`), and one carries arm
/// 25 (`0x4002`). An equality test refused all of them, which is what a live key-on-lockbox
/// probe caught (0928).
///
/// And the **word travels**: the resolver hands the cursor the whole thing rather than a
/// verdict, so a `0x4800` word can answer the bag click *and* the world click. Deciding here
/// would have to pick one, and the reference picks neither until the click lands.
///
/// A selected unit changes nothing in any case: none of these is a bit a unit candidate can
/// clear, so the walk can never discharge the word. The word==0 immediate commit still precedes
/// all of it.
#[test]
fn the_item_word_enters_the_cursors_item_half() {
let poison = spell(0x10, 0);
assert_eq!(
resolve_cast_target(Some(&poison), Some(42), Some(1), true, &rel_none()),
CastWireTarget::ItemTargeting
);
assert_eq!(
resolve_cast_target(Some(&poison), None, Some(1), false, &rel_none()),
CastWireTarget::ItemTargeting,
"no selection and no autoSelfCast change nothing — the item bit is the whole word"
);
fn the_seam_predicates_are_masks_and_the_word_travels() {
// Raw `Targets`, then the shapes `cast_target_mask`'s implicit overlay actually produces on
// the shipped LOCKED rows (arm 23 → `|0x800`, arm 25 → `|TF_UNIT`), then the bare
// GAMEOBJECT word arm 23 puts on a spell whose `Targets` carries nothing else.
for (targets, implicit) in [
(0x10u32, 0),
(0x4000, 0),
(0x4000, 23),
(0x4000, 25),
(0x0, 23),
] {
let s = spell(targets, implicit);
let word = cast_target_mask(&s);
assert_eq!(
resolve_cast_target(Some(&s), Some(42), Some(1), true, &rel_none()),
CastWireTarget::Targeting(word),
"Targets {targets:#x} + implicit arm {implicit} must raise the cursor with its word"
);
assert_eq!(
resolve_cast_target(Some(&s), None, Some(1), false, &rel_none()),
CastWireTarget::Targeting(word),
"no selection and no autoSelfCast change nothing — no unit clears these bits"
);
}
// The shipped lock word: one cursor, and it carries both seams' bits at once.
let opening = cast_target_mask(&spell(0x4000, 23));
assert_eq!(opening & (TF_ITEM | TF_LOCKED), TF_LOCKED);
assert_eq!(opening & (TF_GAMEOBJECT | TF_LOCKED), opening);
}
fn rel_none() -> TargetRelations<'static> {
+1
View File
@@ -244,6 +244,7 @@ impl Plugin for UiActionPlugin {
.init_resource::<AutoRepeatActive>()
.init_resource::<cast_target::AutoSelfCast>()
.init_resource::<targeting::SpellTargeting>()
.init_resource::<targeting::EnchantConfirmItem>()
.add_systems(Startup, load_spells.after(AssetSet::Open))
.add_systems(
Update,
-684
View File
@@ -1,684 +0,0 @@
//! **The targeting cursor** — the client's one "this cast is waiting for a click" machine, both
//! halves: the **location** half (decision 0792, closing B132: "ground-targeted AOE all Invalid
//! target") and the **item** half (decision 0923: poisons, stones, oils, scopes, enchants).
//!
//! The reference's targeting mode IS a nonzero flag_word (`IsTargeting 0x6e48a0`); this module
//! holds benilla's mirror of that state and the systems around it, each transcribing a
//! byte-verified piece (wow-re `wave-cast.md` + `cursor-system.md` §5 + the world-click path in
//! `world-click-targeting.md`, plus 0923's own read of the two pickup seams):
//!
//! - **Cursor** ([`drive_targeting_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,
//! computed by `CheckGroundPointInRange 0x6e6810` over `GetMinMaxRange`). The item half takes
//! plain Cast: that range gate is a *location* predicate.
//! - **Location commit** ([`commit_ground_cast_on_click`]): the terrain leg's action-1 arm tries
//! the ground commit first, gated only on the word (`0x492580` → `BindLocation 0x6e60f0`).
//! - **Item commit** ([`commit_item_cast_on_pick`]): the bag click (`PickupContainerItem
//! 0x4f9b30` @ `4f9c54`) and the paper-doll click (`0x4c7300` @ `4c76df`) each carry the same
//! three-instruction rung — IsTargeting, `TargetingWantsItem 0x6e6330`, then
//! `0x495d60(itemGuid)` and return — and `0x495d60` is [`item_target_refusal`] plus the bind.
//! - Both end in **one commit tail** (`CastLadder::commit_targeted`): the packet (`SendCast
//! 0x6e54f0`'s same block, two opcodes), the pending arm, the GCD, and the word cleared.
//! - **The ESC chain** ([`feed_targeting_to_vm`] / [`drain_stop_targeting`]): the real
//! `UIParent.lua:1490` rung (`elseif ( SpellStopTargeting() ) then`) runs in our live VM; the
//! feed pushes the state its `SpellIsTargeting`/`SpellStopTargeting` bindings read (and the
//! item half's arm, which gates the VM's click reroute), the drain commits the cancel.
//! AbortCast in targeting mode clears the word and sends **nothing**.
//!
//! Entry and the two press-cancel shapes live in the cast path itself: the resolver yields
//! [`super::cast_target::CastWireTarget::GroundTargeting`] (arm 16 / the bare DEST word) or
//! `ItemTargeting` (the bare ITEM word), the one cast-send path enters the mode here, a NEW
//! spell's press aborts-and-proceeds (`TryCast 6e4d62`), and the action bar's re-press of the
//! SAME spell toggles the mode off (`UseAction 0x4e5ee0`'s
//! `GetTargetingSpellId`+`StopTargeting` — [`super::drain`]).
//!
//! The click path is byte-pinned by wow-re's `world-click-targeting.md` (the 0792 dispatch's
//! answers): the terrain-leg commit `0x492580` has **no range gate and no error path** — it
//! binds and sends regardless, and the server judges range (`CheckGroundPointInRange 0x6e6810`
//! has exactly ONE caller binary-wide, the hover classifier `0x4820f0`: its verdict colours the
//! cursor and nothing else). While targeting, the pick flags come from the pending spell's mask
//! alone — for a dest-only word a unit is not pickable, so a click over one commits on the
//! ground behind it ([`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.
use bevy::prelude::*;
use benilla_assets::coords::bevy_to_wow;
use benilla_formats::SpellRange;
use crate::interact::{WorldClick, WorldRightPress};
use crate::net::SelfPlayer;
use crate::target::{CursorKind, PickOcclusion, WorldCursor};
use super::Spells;
/// Which click the standing flag_word is waiting for — the reference's two *wants* predicates,
/// each a one-instruction mask test on the same word `0xcecac0`, each consulted by exactly one
/// click seam:
///
/// - `TargetingWantsLocation 0x6e6320` (`word & 0x60`) → the terrain click's commit.
/// - `TargetingWantsItem 0x6e6330` (`word & 0x4010`) → the **bag** click's bind
/// (`PickupContainerItem 0x4f9b30` @ `4f9c5d`) and the **paper-doll** click's
/// (`0x4c7300` @ `4c76e8`) — the identical three-instruction rung in both.
///
/// The word itself is one state, which is why this is a mode of one resource and not two
/// resources: every cancel (ESC, right-press, a new cast's abort-and-proceed) clears the one word,
/// and the reference has exactly one `IsTargeting`.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum TargetingWants {
/// Decision 0792 — the location half.
Location,
/// Decision 0923 — the item half.
Item,
}
/// The targeting-cursor mode — benilla's `flag_word != 0` mirror: `Some` while a cast awaits the
/// click that binds its target. Entered by the one cast-send path ([`super::cast_send`]'s
/// `GroundTargeting`/`ItemTargeting` arms), cleared by whichever commit fires, the two press
/// cancels, and the ESC drain.
#[derive(Resource, Default)]
pub(crate) struct SpellTargeting(Option<Targeting>);
struct Targeting {
spell_id: u32,
/// What the click will commit. The ref keeps the whole pending-cast block across the cursor —
/// the cast **item's** guid at `0xceac48` included — so `0x6e54f0`'s discriminator still picks
/// `CMSG_USE_ITEM` when the click lands: a thrown grenade for the location half (decision
/// 0914), a poison bottle for the item half (decision 0923).
commit: super::cast_send::CastCommit,
wants: TargetingWants,
}
impl SpellTargeting {
/// `IsTargeting 0x6e48a0` — the canonical predicate.
pub(crate) fn active(&self) -> bool {
self.0.is_some()
}
/// `GetTargetingSpellId 0x6e48e0` — the spell awaiting its click, for the action bar's
/// press-again toggle.
pub(crate) fn spell(&self) -> Option<u32> {
self.0.as_ref().map(|t| t.spell_id)
}
/// Which click seam owns the standing word — `None` when nothing is targeting.
pub(crate) fn wants(&self) -> Option<TargetingWants> {
self.0.as_ref().map(|t| t.wants)
}
pub(crate) fn enter(
&mut self,
spell_id: u32,
commit: super::cast_send::CastCommit,
wants: TargetingWants,
) {
self.0 = Some(Targeting {
spell_id,
commit,
wants,
});
}
/// The pending cast's `(spell, commit)` when the word wants `wants` — the shape both commit
/// systems open with, so neither can fire on the other half's word.
fn pending_for(&self, wants: TargetingWants) -> Option<(u32, super::cast_send::CastCommit)> {
self.0
.as_ref()
.filter(|t| t.wants == wants)
.map(|t| (t.spell_id, t.commit))
}
pub(crate) fn clear(&mut self) {
self.0 = None;
}
}
/// `CheckGroundPointInRange 0x6e6810` — min²/max² from the spell's `SpellRange` row against the
/// squared caster↔point distance. Its ONE caller binary-wide is the hover-cursor classifier
/// (`0x4820f0` — wow-re `world-click-targeting.md` Q1's caller census): the verdict colours
/// Cast/UnableCast and nothing else. The click never consults it, so neither does ours. No row
/// (a failed DBC, an unknown spell) is permissive — the server validates every send anyway.
fn ground_point_in_range(row: Option<&SpellRange>, self_pos: Vec3, point: Vec3) -> bool {
let Some(row) = row else {
return true;
};
let dist_sq = self_pos.distance_squared(point);
if row.min > 0.0 && dist_sq < row.min * row.min {
return false;
}
dist_sq <= row.max * row.max
}
/// The targeting spell's `SpellRange` row, through the catalogs.
fn range_row(spells: Option<&Spells>, spell_id: u32) -> Option<&SpellRange> {
let spells = spells?;
spells.ranges.get(spells.catalog.get(spell_id)?.range_index)
}
/// `GetCurrentCastRadius 0x6e6350` (wow-re `ground-target-reticle.md` B2) — the reticle's
/// radius: per-effect `radius + casterLevel × perLevel` over **EffectRadiusIndex[0] and [1]
/// only** (slot 2 is never read by the client), the max with candidate 1 winning ties/NaN,
/// clamped to 20.0 (`0x4820f0`'s `[0x804478]` literal — `min`, NaN → 20). `0.0` = no radius
/// rows; the reticle then draws at its literal default size. Class-6 spell modifiers are
/// unmodelled (the 0792 residual, same as the range gate).
pub(crate) fn ground_cast_radius(spells: Option<&Spells>, spell_id: u32, level: u32) -> f32 {
let Some(spells) = spells else { return 0.0 };
let Some(d) = spells.catalog.get(spell_id) else {
return 0.0;
};
let candidate = |slot: usize| -> f32 {
let idx = d.effect_radius_index[slot];
if idx == 0 {
return 0.0;
}
spells
.radii
.get(idx)
.map_or(0.0, |r| r.radius + level as f32 * r.per_level)
};
let (c0, c1) = (candidate(0), candidate(1));
// Strict > for candidate 0; a tie or a NaN c0 falls to candidate 1 — the byte order.
let r = if c0 > c1 { c0 } else { c1 };
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.
///
/// The **item** half takes 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 an item-targeting word has no
/// ground point to judge. Whether the reference grays the item cursor per hovered slot is
/// unpinned — the honest read is that its validity gate runs at BIND time (`0x495d60`, whose
/// mismatch is error `0x0a`), not at hover time. Named INTERIM, decision 0923.
pub(crate) fn drive_targeting_cursor(
targeting: Res<SpellTargeting>,
occlusion: Res<PickOcclusion>,
spells: Option<Res<Spells>>,
self_tf: Query<&Transform, With<SelfPlayer>>,
mut cursor: ResMut<WorldCursor>,
) {
let Some(spell_id) = targeting.spell() else {
return;
};
let unable = match targeting.wants() {
Some(TargetingWants::Item) | None => false,
Some(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,
),
_ => false,
},
};
*cursor = WorldCursor {
kind: CursorKind::Cast,
unable,
};
}
/// The world click's ground commit — the terrain leg's action-1 arm (`0x492580`, tried before
/// anything else the click could mean; [`crate::target::click::select_on_click`] holds its gate
/// while this mode is active, so the click neither selects nor deselects). Binds the frame's
/// pick-occlusion point and sends **unconditionally** — the leg's complete callee set has no
/// range check and no error path (wow-re `world-click-targeting.md` Q1; C2 REFUTED: the click
/// never gates on range, the server judges it, and its refusing `SMSG_CAST_RESULT` is the red
/// line) — `CMSG_CAST_SPELL` mask `0x40` + the point (WoW coords), arming the pending cast +
/// the GCD (the `SendCast 0x6e54f0` tail's two live pieces for a ground cast); the mode ends
/// with the send. No world hit (sky) → the nothing leg: no commit, mode kept.
///
/// Runs AFTER `select_on_click` in the target chain: the selection gate reads the mode's state,
/// so the commit that clears it must come later in the same frame.
pub(crate) fn commit_ground_cast_on_click(
mut clicks: MessageReader<WorldClick>,
occlusion: Res<PickOcclusion>,
mut ladder: super::CastLadder,
) {
if !ladder.ground.active() {
// Keep the reader current so a click buffered while idle can never replay as a commit
// the frame the mode turns on.
clicks.clear();
return;
}
if clicks.read().last().is_none() {
return;
}
// `TargetingWantsLocation 0x6e6320` — an item-targeting word has no location leg, so the
// terrain click's `BindLocation` binds nothing and the mode simply stays up.
let Some((spell_id, commit)) = ladder.ground.pending_for(TargetingWants::Location) else {
return;
};
let Some(point) = occlusion.point else {
// The ray hit nothing (sky) — the ref's nothing-leg has no ground commit; the mode
// stays, exactly like the UnableCast cursor said it would.
return;
};
let dest = bevy_to_wow(point);
debug!(
"ui_action: ground cast {spell_id} committed at wow ({:.2}, {:.2}, {:.2})",
dest[0], dest[1], dest[2]
);
// The shared commit tail — same block, two opcodes (`SendCast 0x6e54f0`'s one discriminator
// survives the cursor, decision 0914: a thrown grenade commits as `CMSG_USE_ITEM` with the
// DEST block), then the pending arm, the GCD, and the word cleared.
ladder.commit_targeted(spell_id, commit, super::cast_send::TargetedBind::Dest(dest));
}
/// `0x495d60`'s equipped-item gate — the whole local validity law for an item target, run at
/// BIND time (the click), not at hover time. For each of the spell's three effects that is
/// `ENCHANT_ITEM` (53) / `ENCHANT_ITEM_TEMPORARY` (54) the reference tests, in this order:
///
/// - `EquippedItemSubClassMask [+0xec] != 0` ⇒ the item's class must equal `EquippedItemClass
/// [+0xe8]` **and** `(1 << subclass)` must be in the mask (`495e10`–`495e28`);
/// - `EquippedItemInventoryTypeMask [+0xf0] != 0` ⇒ `(1 << InventoryType)` must be in it
/// (`495e4d`–`495e70`).
///
/// Either miss lands on `0x496068`: `0x6e1a00(spell, 0x0a)` — the client's own **"Invalid
/// target"** red line, no packet, and the targeting word is *not* cleared (the ref returns before
/// `BindTarget`), so the cursor stays up for another try. That last detail is the one that makes
/// this feel right: a mis-click on the wrong weapon doesn't eat your poison press.
///
/// `Some(reason)` = refuse with that code; `None` = bind. A spell with no enchant effect (the
/// bare-`Targets 0x10` rows that are Disenchant and kin) has no leg to fail — the reference walks
/// its loop and falls straight through to `496056: call 0x6e5b40`, and so do we.
///
/// One narrowing, measured rather than assumed: the reference tests **all three** effect slots,
/// [`benilla_formats::SpellDisplay`] carries only slot 0, and across the whole 363-row item-target
/// family not one row hides its enchant effect in slot 1 or 2 — pinned by the formats-side family
/// test, which fails if that ever stops being true.
pub(crate) fn item_target_refusal(
def: &benilla_formats::SpellDisplay,
item_class: u32,
item_subclass: u32,
item_inventory_type: u32,
) -> Option<u8> {
let enchants = def.effect_1 == benilla_formats::SPELL_EFFECT_ENCHANT_ITEM
|| def.effect_1 == benilla_formats::SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY;
if !enchants {
return None;
}
if def.equipped_item_subclass_mask != 0 {
let class_ok = i64::from(item_class) == i64::from(def.equipped_item_class);
let sub_ok =
item_subclass < 32 && def.equipped_item_subclass_mask & (1u32 << item_subclass) != 0;
if !(class_ok && sub_ok) {
return Some(super::cast_target::ERR_INVALID_TARGET);
}
}
if def.equipped_item_inventory_type_mask != 0
&& !(item_inventory_type < 32
&& def.equipped_item_inventory_type_mask & (1u32 << item_inventory_type) != 0)
{
return Some(super::cast_target::ERR_INVALID_TARGET);
}
None
}
/// The item half's commit — the bag and paper-doll click seams (`PickupContainerItem 0x4f9b30`
/// @ `4f9c54`–`4f9c6d` and its byte-identical doll twin `0x4c7300` @ `4c76df`–`4c76fb`: *if
/// IsTargeting and TargetingWantsItem, then `0x495d60(itemGuidLo, itemGuidHi)` and return —
/// nothing is picked up*). The VM half of that reroute lives in `benilla_ui`'s cursor seam; this
/// drain is `0x495d60` itself: resolve the clicked slot's live item, run [`item_target_refusal`],
/// and on a pass do what `496056` does — hand the item to the ONE binder, which fills the word's
/// item bit and lets `SendCast 0x6e54f0` commit. Same block, two opcodes: `CMSG_CAST_SPELL` for
/// an enchant off the Craft window, `CMSG_USE_ITEM` for a poison bottle's own ON_USE.
///
/// The post-send tail is the ground commit's (decision 0792): arm the pending cast + the GCD, and
/// clear the word. A click on an EMPTY slot binds nothing and keeps the mode — the ref's
/// `0x495d60` returns at its own null-item guard (`495da1`).
pub(crate) fn commit_item_cast_on_pick(
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
self_q: Query<&crate::net::ObjectStore, With<SelfPlayer>>,
mut ladder: super::CastLadder,
) {
let Some(mut script) = script else {
return;
};
for (bag, slot) in script.take_item_picks() {
let Some((spell_id, commit)) = ladder.ground.pending_for(TargetingWants::Item) else {
continue; // a click raced a cancel — the word is gone, so there is nothing to bind
};
let slot0 = u8::try_from(slot.saturating_sub(1)).unwrap_or(0);
let Some(item_guid) = self_q
.iter()
.next()
.and_then(|store| crate::ui_items::slot_guid(&store.0, bag, slot0, &ladder.items))
else {
debug!("ui_action: item pick on an empty slot (bag {bag} slot {slot}) — mode kept");
continue;
};
// The gate needs the clicked item's template; an unresolved one (never seen in practice —
// the bag needed it for the icon) binds ungated and lets the server judge, the same
// permissive shape the rest of the click law uses.
let entry = ladder
.items
.object(item_guid)
.and_then(|o| o.object_entry());
let clicked = match entry {
Some(entry) => ladder
.items
.template(entry, item_guid, &ladder.commands)
.map(|t| (t.class, t.subclass, t.inventory_type)),
None => None,
};
let def = ladder
.spells
.as_deref()
.and_then(|s| s.catalog.get(spell_id));
if let (Some(d), Some((class, subclass, inv))) = (def, clicked) {
if let Some(reason) = item_target_refusal(d, class, subclass, inv) {
debug!(
"ui_action: cast {spell_id} refused at the item bind ({reason:#x}) — \
the cursor stays up"
);
ladder.cast_errors.0.push((spell_id, reason));
continue;
}
}
debug!("ui_action: item pick — cast {spell_id} at item {item_guid:#x}");
ladder.commit_targeted(
spell_id,
commit,
super::cast_send::TargetedBind::Item(item_guid),
);
}
}
/// Right-click cancels targeting — on the **DOWN edge**, the reference's WorldFrame
/// `OnMouseDown 0x483c40` → `0x492c20`: right button ∧ `IsTargeting` → `StopTargeting
/// 0x6e4900`, no packet — and the handler returns 0, so the press keeps doing everything else
/// it did (the turn-drag, the release's context click; we consume nothing either). Byte-pinned
/// by wow-re `world-click-targeting.md` Q3, whose caller census is complete: this and the
/// ESC/UseAction/TryCast paths are the ONLY input-band cancels — no keyboard caller exists.
///
/// Two qualifications, transcribed: a held cursor payload pre-empts the cancel (`0x492b50`
/// clears the payload and returns before the WorldFrame virtuals dispatch — our payload keeps
/// its own clean-click clear in [`crate::target::click::world_right_click_payload`]); and a
/// press over a UI frame never reaches the WorldFrame — [`WorldRightPress`]'s world gate
/// transcribes the certain half of wow-re's one DEFERRED (whether a UI-frame right-click also
/// cancels is unpinned there). The `0x51`-effect placement-rotate skip (`[0xceca90]`) is
/// unmodelled along with the flag itself (named residual, 0792).
pub(crate) fn cancel_targeting_on_right_press(
mut presses: MessageReader<WorldRightPress>,
payload_held: Res<crate::ui_script::CursorPayloadHeld>,
mut targeting: ResMut<SpellTargeting>,
) {
if !targeting.active() {
// Reader hygiene, like the commit's: a press buffered while idle never replays as a
// cancel the frame the mode turns on.
presses.clear();
return;
}
if presses.read().last().is_none() || payload_held.0 {
return;
}
debug!("ui_action: targeting cancelled (right-click)");
targeting.clear();
}
/// Push the targeting state into the live VM each frame, **before** the input pass — so a word
/// armed last frame is already standing when this frame's clicks run. Two consumers, one push:
/// `SpellIsTargeting()` / `SpellStopTargeting()`'s ESC chain reads the word itself, and the
/// engine's bag / doll pickup reroute reads the item half (`TargetingWantsItem`'s mirror).
pub(crate) fn feed_targeting_to_vm(
targeting: Res<SpellTargeting>,
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
) {
if let Some(mut script) = script {
script.set_spell_targeting(targeting.active());
script.set_item_pick_armed(targeting.wants() == Some(TargetingWants::Item));
}
}
/// Drain the ESC chain's `SpellStopTargeting()` trigger (**after** the input pass) and clear
/// the mode — the ref's `StopTargeting 0x6e4900` → AbortCast-in-targeting: word cleared, no
/// packet.
pub(crate) fn drain_stop_targeting(
mut targeting: ResMut<SpellTargeting>,
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
) {
let Some(mut script) = script else {
return;
};
if script.take_stop_targeting() {
debug!("ui_action: targeting cancelled (ESC chain)");
targeting.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `0x495d60`'s equipped-item gate, on the shapes the real rows have (the columns themselves
/// are pinned against the shipped `Spell.dbc` in `benilla_formats`). Three legs, in the
/// reference's order, and one refusal code for all of them — `0x0a` "Invalid target", the
/// same red line a wrong unit target draws.
#[test]
fn the_item_bind_gate_mirrors_0x495d60() {
use benilla_formats::SpellDisplay;
// Item classes/subclasses/inventory types, 1.12 values.
const CLASS_WEAPON: u32 = 2;
const CLASS_ARMOR: u32 = 4;
const SUB_DAGGER: u32 = 15;
const SUB_SHIELD: u32 = 6;
const INVTYPE_WRIST: u32 = 9;
const INVTYPE_CHEST: u32 = 5;
const ENCHANT: u32 = benilla_formats::SPELL_EFFECT_ENCHANT_ITEM;
// Enchant Bracer - Minor Health (7418): armor, any subclass, WRIST only.
let bracer = SpellDisplay {
effect_1: ENCHANT,
equipped_item_class: CLASS_ARMOR as i32,
equipped_item_subclass_mask: 0x1f,
equipped_item_inventory_type_mask: 1 << INVTYPE_WRIST,
..Default::default()
};
assert_eq!(
item_target_refusal(&bracer, CLASS_ARMOR, 1, INVTYPE_WRIST),
None,
"a bracer takes the bracer enchant"
);
assert_eq!(
item_target_refusal(&bracer, CLASS_ARMOR, 1, INVTYPE_CHEST),
Some(super::super::cast_target::ERR_INVALID_TARGET),
"the inventory-type leg (495e4d) refuses a chestpiece"
);
assert_eq!(
item_target_refusal(&bracer, CLASS_WEAPON, 1, INVTYPE_WRIST),
Some(super::super::cast_target::ERR_INVALID_TARGET),
"the class leg (495e10) refuses a weapon"
);
// Instant Poison (8679): weapon class, a subclass mask, NO inventory-type requirement.
let poison = SpellDisplay {
effect_1: benilla_formats::SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY,
equipped_item_class: CLASS_WEAPON as i32,
equipped_item_subclass_mask: 0x2a5f3,
equipped_item_inventory_type_mask: 0,
..Default::default()
};
// 8679's real mask carries dagger (15), so a rogue's own weapon passes the subclass leg.
assert_eq!(
item_target_refusal(&poison, CLASS_WEAPON, SUB_DAGGER, 13),
None
);
assert_eq!(
item_target_refusal(&poison, CLASS_WEAPON, 1, 13),
None,
"and two-handed axe (1) is in it too — poisons are broad, the class leg is the fence"
);
assert_eq!(
item_target_refusal(&poison, CLASS_ARMOR, SUB_SHIELD, 14),
Some(super::super::cast_target::ERR_INVALID_TARGET),
"a shield is armor — the class leg alone stops it"
);
// Disenchant (13262): an item-targeted spell with NO enchant effect. The reference walks
// its loop, finds no 53/54 arm, and falls straight through to the bind — anything goes,
// and the server judges.
let disenchant = SpellDisplay {
effect_1: 99,
equipped_item_class: -1,
..Default::default()
};
assert_eq!(
item_target_refusal(&disenchant, CLASS_ARMOR, 1, INVTYPE_CHEST),
None
);
assert_eq!(
item_target_refusal(&disenchant, CLASS_WEAPON, SUB_DAGGER, 13),
None
);
// A subclass past the mask's 32 bits can never be in it — shifted, that would be UB-ish
// nonsense, so the gate refuses instead of wrapping.
assert_eq!(
item_target_refusal(&poison, CLASS_WEAPON, 40, 13),
Some(super::super::cast_target::ERR_INVALID_TARGET)
);
}
/// The two halves of the ONE word never answer each other's click (the reference asks
/// `TargetingWantsLocation`/`TargetingWantsItem` at each seam, decision 0923). Without this,
/// a terrain click while a poison is armed would ship a DEST block for an item spell.
#[test]
fn each_click_seam_only_sees_its_own_half() {
let commit = super::super::cast_send::CastCommit::Spell;
let mut t = SpellTargeting::default();
assert_eq!(
t.pending_for(TargetingWants::Location),
None,
"idle binds nothing"
);
t.enter(2120, commit, TargetingWants::Location);
assert!(t.active());
assert_eq!(
t.pending_for(TargetingWants::Location),
Some((2120, commit))
);
assert_eq!(
t.pending_for(TargetingWants::Item),
None,
"a bag click cannot commit a Blizzard"
);
t.enter(8679, commit, TargetingWants::Item);
assert_eq!(t.pending_for(TargetingWants::Item), Some((8679, commit)));
assert_eq!(
t.pending_for(TargetingWants::Location),
None,
"a terrain click cannot commit a poison"
);
// The spell id is the word's, either half — what the action bar's re-press toggle reads.
assert_eq!(t.spell(), Some(8679));
t.clear();
assert!(!t.active());
}
/// The `0x6e6810` mirror: min²/max² against the squared caster↔point distance — the
/// CURSOR's verdict and nothing else (its one caller binary-wide is the hover classifier;
/// the click never asks). Permissive with no row (Blizzard's row 4 is 0–30 yd; a synthetic
/// min exercises the too-close arm the real row can't).
#[test]
fn ground_point_in_range_mirrors_check_ground_point_in_range() {
let row = |min: f32, max: f32| SpellRange { min, max, flags: 0 };
let origin = Vec3::ZERO;
let at = |d: f32| Vec3::new(d, 0.0, 0.0);
let blizzard = row(0.0, 30.0);
assert!(ground_point_in_range(Some(&blizzard), origin, at(29.9)));
assert!(!ground_point_in_range(Some(&blizzard), origin, at(30.1)));
let banded = row(8.0, 35.0);
assert!(!ground_point_in_range(Some(&banded), origin, at(5.0)));
assert!(ground_point_in_range(Some(&banded), origin, at(20.0)));
// No row → permissive (the server still validates).
assert!(ground_point_in_range(None, origin, at(500.0)));
}
/// `GetCurrentCastRadius 0x6e6350` + the `0x4820f0` clamp: slots 0/1 only (slot 2 is never
/// read), max with candidate-1 winning ties, per-level scaling, min(r, 20). Fixture rows
/// mirror the real table (row 14 = 8.0 Blizzard, row 8 = 5.0 Flamestrike).
#[test]
fn ground_cast_radius_mirrors_get_current_cast_radius() {
use benilla_formats::{SpellDisplay, SpellRadius};
use std::collections::HashMap;
let mut spells = super::super::Spells::empty_for_tests();
let display = |idx: [u32; 3]| SpellDisplay {
effect_radius_index: idx,
..SpellDisplay::default()
};
spells.catalog = benilla_formats::SpellCatalog::from_displays(HashMap::from([
(10, display([14, 0, 0])),
(2120, display([8, 8, 0])),
(777, display([0, 0, 13])), // slot 2 only — the client never reads it
(778, display([90, 8, 0])), // per-level row in slot 0
(779, display([10, 0, 0])), // row 10 = 30.0 — the 20.0 clamp
]));
spells.radii = benilla_formats::SpellRadiusCatalog::from_rows(HashMap::from([
(
14,
SpellRadius {
radius: 8.0,
per_level: 0.0,
max: 0.0,
},
),
(
8,
SpellRadius {
radius: 5.0,
per_level: 0.0,
max: 0.0,
},
),
(
13,
SpellRadius {
radius: 10.0,
per_level: 0.0,
max: 0.0,
},
),
(
10,
SpellRadius {
radius: 30.0,
per_level: 0.0,
max: 0.0,
},
),
(
90,
SpellRadius {
radius: 2.0,
per_level: 0.1,
max: 0.0,
},
),
]));
let s = Some(&spells);
assert_eq!(ground_cast_radius(s, 10, 60), 8.0);
assert_eq!(ground_cast_radius(s, 2120, 60), 5.0);
// Slot 2 is invisible to the reticle — no rows in 0/1 reads 0 (→ the default size).
assert_eq!(ground_cast_radius(s, 777, 60), 0.0);
// Per-level: 2.0 + 60 × 0.1 = 8.0 beats slot 1's 5.0.
assert_eq!(ground_cast_radius(s, 778, 60), 8.0);
// The 20.0 clamp (`[0x804478]`).
assert_eq!(ground_cast_radius(s, 779, 60), 20.0);
// Unknown spell / no data at all → 0 (default size).
assert_eq!(ground_cast_radius(s, 9999, 60), 0.0);
assert_eq!(ground_cast_radius(None, 10, 60), 0.0);
}
}
@@ -0,0 +1,206 @@
//! 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`).
//!
//! 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.
use bevy::prelude::*;
use benilla_formats::SpellRange;
use crate::net::SelfPlayer;
use crate::target::{CursorKind, PickOcclusion, WorldCursor};
use crate::ui_action::Spells;
use super::{SpellTargeting, TargetingWants};
/// `CheckGroundPointInRange 0x6e6810` — min²/max² from the spell's `SpellRange` row against the
/// squared caster↔point distance. Its ONE caller binary-wide is the hover-cursor classifier
/// (`0x4820f0` — wow-re `world-click-targeting.md` Q1's caller census): the verdict colours
/// Cast/UnableCast and nothing else. The click never consults it, so neither does ours. No row
/// (a failed DBC, an unknown spell) is permissive — the server validates every send anyway.
fn ground_point_in_range(row: Option<&SpellRange>, self_pos: Vec3, point: Vec3) -> bool {
let Some(row) = row else {
return true;
};
let dist_sq = self_pos.distance_squared(point);
if row.min > 0.0 && dist_sq < row.min * row.min {
return false;
}
dist_sq <= row.max * row.max
}
/// The targeting spell's `SpellRange` row, through the catalogs.
fn range_row(spells: Option<&Spells>, spell_id: u32) -> Option<&SpellRange> {
let spells = spells?;
spells.ranges.get(spells.catalog.get(spell_id)?.range_index)
}
/// `GetCurrentCastRadius 0x6e6350` (wow-re `ground-target-reticle.md` B2) — the reticle's
/// radius: per-effect `radius + casterLevel × perLevel` over **EffectRadiusIndex[0] and [1]
/// only** (slot 2 is never read by the client), the max with candidate 1 winning ties/NaN,
/// clamped to 20.0 (`0x4820f0`'s `[0x804478]` literal — `min`, NaN → 20). `0.0` = no radius
/// rows; the reticle then draws at its literal default size. Class-6 spell modifiers are
/// unmodelled (the 0792 residual, same as the range gate).
pub(crate) fn ground_cast_radius(spells: Option<&Spells>, spell_id: u32, level: u32) -> f32 {
let Some(spells) = spells else { return 0.0 };
let Some(d) = spells.catalog.get(spell_id) else {
return 0.0;
};
let candidate = |slot: usize| -> f32 {
let idx = d.effect_radius_index[slot];
if idx == 0 {
return 0.0;
}
spells
.radii
.get(idx)
.map_or(0.0, |r| r.radius + level as f32 * r.per_level)
};
let (c0, c1) = (candidate(0), candidate(1));
// Strict > for candidate 0; a tie or a NaN c0 falls to candidate 1 — the byte order.
let r = if c0 > c1 { c0 } else { c1 };
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.
///
/// 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.
pub(crate) fn drive_targeting_cursor(
targeting: Res<SpellTargeting>,
occlusion: Res<PickOcclusion>,
spells: Option<Res<Spells>>,
self_tf: Query<&Transform, With<SelfPlayer>>,
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,
),
_ => false,
};
*cursor = WorldCursor {
kind: CursorKind::Cast,
unable,
};
}
#[cfg(test)]
mod tests {
use super::*;
/// The `0x6e6810` mirror: min²/max² against the squared caster↔point distance — the
/// CURSOR's verdict and nothing else (its one caller binary-wide is the hover classifier;
/// the click never asks). Permissive with no row (Blizzard's row 4 is 0–30 yd; a synthetic
/// min exercises the too-close arm the real row can't).
#[test]
fn ground_point_in_range_mirrors_check_ground_point_in_range() {
let row = |min: f32, max: f32| SpellRange { min, max, flags: 0 };
let origin = Vec3::ZERO;
let at = |d: f32| Vec3::new(d, 0.0, 0.0);
let blizzard = row(0.0, 30.0);
assert!(ground_point_in_range(Some(&blizzard), origin, at(29.9)));
assert!(!ground_point_in_range(Some(&blizzard), origin, at(30.1)));
let banded = row(8.0, 35.0);
assert!(!ground_point_in_range(Some(&banded), origin, at(5.0)));
assert!(ground_point_in_range(Some(&banded), origin, at(20.0)));
// No row → permissive (the server still validates).
assert!(ground_point_in_range(None, origin, at(500.0)));
}
/// `GetCurrentCastRadius 0x6e6350` + the `0x4820f0` clamp: slots 0/1 only (slot 2 is never
/// read), max with candidate-1 winning ties, per-level scaling, min(r, 20). Fixture rows
/// mirror the real table (row 14 = 8.0 Blizzard, row 8 = 5.0 Flamestrike).
#[test]
fn ground_cast_radius_mirrors_get_current_cast_radius() {
use benilla_formats::{SpellDisplay, SpellRadius};
use std::collections::HashMap;
let mut spells = crate::ui_action::Spells::empty_for_tests();
let display = |idx: [u32; 3]| SpellDisplay {
effect_radius_index: idx,
..SpellDisplay::default()
};
spells.catalog = benilla_formats::SpellCatalog::from_displays(HashMap::from([
(10, display([14, 0, 0])),
(2120, display([8, 8, 0])),
(777, display([0, 0, 13])), // slot 2 only — the client never reads it
(778, display([90, 8, 0])), // per-level row in slot 0
(779, display([10, 0, 0])), // row 10 = 30.0 — the 20.0 clamp
]));
spells.radii = benilla_formats::SpellRadiusCatalog::from_rows(HashMap::from([
(
14,
SpellRadius {
radius: 8.0,
per_level: 0.0,
max: 0.0,
},
),
(
8,
SpellRadius {
radius: 5.0,
per_level: 0.0,
max: 0.0,
},
),
(
13,
SpellRadius {
radius: 10.0,
per_level: 0.0,
max: 0.0,
},
),
(
10,
SpellRadius {
radius: 30.0,
per_level: 0.0,
max: 0.0,
},
),
(
90,
SpellRadius {
radius: 2.0,
per_level: 0.1,
max: 0.0,
},
),
]));
let s = Some(&spells);
assert_eq!(ground_cast_radius(s, 10, 60), 8.0);
assert_eq!(ground_cast_radius(s, 2120, 60), 5.0);
// Slot 2 is invisible to the reticle — no rows in 0/1 reads 0 (→ the default size).
assert_eq!(ground_cast_radius(s, 777, 60), 0.0);
// Per-level: 2.0 + 60 × 0.1 = 8.0 beats slot 1's 5.0.
assert_eq!(ground_cast_radius(s, 778, 60), 8.0);
// The 20.0 clamp (`[0x804478]`).
assert_eq!(ground_cast_radius(s, 779, 60), 20.0);
// Unknown spell / no data at all → 0 (default size).
assert_eq!(ground_cast_radius(s, 9999, 60), 0.0);
assert_eq!(ground_cast_radius(None, 10, 60), 0.0);
}
}
@@ -0,0 +1,569 @@
//! The **item** seam — `0x495d60`, the whole local law for an item target, plus the two confirm
//! popups that hang off it (decisions 0923 / 0928).
//!
//! Reached from two byte-identical rungs: the bag click (`PickupContainerItem 0x4f9b30` @ `4f9c54`)
//! and the paper-doll click (`0x4c7300` @ `4c76df`) — *if IsTargeting and `TargetingWantsItem
//! 0x6e6330`, then `0x495d60(itemGuidLo, itemGuidHi)` and return; nothing is picked up*. The VM
//! half of that reroute lives in `benilla_ui`'s cursor seam; this module is `0x495d60` itself.
use bevy::prelude::*;
use crate::net::SelfPlayer;
use super::TargetingWants;
/// The item guid a confirm popup is standing over — the reference's `0xb4e3c0`/`0xb4e3c4` pair,
/// written by BOTH confirm exits of `0x495d60` (`49608f`/`4960bb`) and read back by whichever Lua
/// answer arrives: `BindEnchant 0x48d2e0` re-invokes the gate with it, `ReplaceEnchant 0x48d300`
/// re-resolves it and calls the binder outright.
///
/// It is a *separate* global from the targeting word in the reference, and it stays separate here,
/// because it holds no cancel logic of its own: the reference never clears it, and never needs to,
/// since `BindTarget 0x6e5b40` re-checks `test $0x4010, [0xcecac0]` before it binds anything
/// (`6e5f1e`). Ours is inert the same way — every reader goes through
/// [`SpellTargeting::pending_for`], so once the word is gone a stale guid can do nothing.
#[derive(Resource, Default)]
pub(crate) struct EnchantConfirmItem(Option<u64>);
/// What the clicked item is, as `0x495d60` reads it off the live object — everything past the
/// template's three type fields comes from the item OBJECT's descriptor, which for our own bags is
/// fully streamed.
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct ClickedItem {
/// `0x5d9f30` / `0x5d9f90` / `0x5d9ff0` — the item template's Class, SubClass and
/// InventoryType (each resolved through the item cache `0x55ba30`).
pub(crate) class: u32,
pub(crate) subclass: u32,
pub(crate) inventory_type: u32,
/// `0x5da2c0` — "has this item already been through the bind question?": `ITEM_FIELD_FLAGS &
/// 1` (already soulbound), **or** any of its seven live `ITEM_FIELD_ENCHANTMENT` slots naming
/// a row with [`benilla_formats::EnchantCatalog::binds_the_item`] (`5da300`–`5da320`).
pub(crate) already_bound: bool,
/// `ITEM_FIELD_ENCHANTMENT[slot]` for PERM (0) and TEMP (1), as the replace check reads them
/// (`495eec: movl 0x40(%ecx,%eax,4)` with `eax = 3*slot`). `None` for an empty slot, a
/// negative id (the ref's `jl`), or an id that names no `SpellItemEnchantment` row.
pub(crate) existing_enchant: [Option<u32>; 2],
}
/// The enchant an ENCHANT_ITEM effect would apply — `SpellItemEnchantment[EffectMiscValue[i]]`,
/// resolved once at `495e93` and read by BOTH confirms.
#[derive(Clone, Copy, Debug)]
pub(crate) struct NewEnchant {
pub(crate) id: u32,
/// [`benilla_formats::EnchantCatalog::binds_the_item`] — `Flags & 1`.
pub(crate) binds: bool,
}
/// What `0x495d60` decides for one clicked item. Four exits, and the three non-`Bind` ones all
/// return **before** `BindTarget`, so the targeting word survives every one of them — that is what
/// keeps the cursor up through a mis-click *and* through both confirm popups.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ItemBind {
/// `496056: call 0x6e5b40` — bind the item and send.
Bind,
/// `496068` — `0x6e1a00(spell, 0x0a)`, no packet.
Refuse(u8),
/// `496087` — park the guid at `0xb4e3c0/0xb4e3c4` and fire event **402 `BIND_ENCHANT`**
/// (`4960a3`, no args): *"Enchanting this item will bind it to you."* Its Yes is
/// `BindEnchant 0x48d2e0`, which re-invokes this very gate with the confirmed flag set.
ConfirmBind,
/// `4960b3` — park the guid and fire event **403 `REPLACE_ENCHANT`** (`4960e4`, format
/// `"%s%s"` = the OLD then the NEW enchant name): *"Do you want to replace "%s" with "%s"?"*.
/// Its Yes is `ReplaceEnchant 0x48d300`, which skips the gate entirely and calls the binder.
ConfirmReplace { existing: u32, new: u32 },
}
/// `0x495d60` — the whole local law for an item target, run at BIND time (the click), not at hover
/// time. The reference walks the spell's three effects; for each `ENCHANT_ITEM` (53) /
/// `ENCHANT_ITEM_TEMPORARY` (54) one it runs, **in this order**:
///
/// 1. **The equipped-item gate** (decision 0923). `EquippedItemSubClassMask [+0xec] != 0` ⇒ the
/// item's class must equal `EquippedItemClass [+0xe8]` **and** `(1 << subclass)` must be in the
/// mask (`495e10`–`495e28`); `EquippedItemInventoryTypeMask [+0xf0] != 0` ⇒ `(1 <<
/// InventoryType)` must be in it (`495e4d`–`495e70`). Either miss is
/// [`ItemBind::Refuse`] — the client's own "Invalid target" red line, no packet, cursor kept.
/// 2. **The bind confirm** (`495e93`–`495ec6`, decision 0928): the enchant this effect would apply
/// must name a real `SpellItemEnchantment` row whose `Flags & 1` is set, the item must not be
/// [`ClickedItem::already_bound`], the confirmed flag must be clear, and the item's
/// InventoryType must be nonzero (`495ebc` — an *equippable* item; a lockbox or a reagent is
/// never asked). All four ⇒ [`ItemBind::ConfirmBind`].
/// 3. **The replace confirm** (`495ecc`–`495f1c`): the slot is PERM for effect 53 and TEMP for 54
/// (`495ed3: cmpl $0x35 / setne` — the ONE place the two effect types diverge), and if that slot
/// already holds an enchant that names a row, **and** the new enchant names one too, ⇒
/// [`ItemBind::ConfirmReplace`]. Note this leg does NOT consult the confirmed flag: answering
/// the bind popup Yes re-enters here and can raise the replace popup next, which is exactly the
/// reference's two-popup chain.
///
/// Otherwise [`ItemBind::Bind`]. A spell with no enchant effect (the bare-`Targets 0x10` rows that
/// are Disenchant and kin, and every `0x4000` lockbox opener) has no leg to fail at all — the
/// reference walks its loop and falls straight through to `496056: call 0x6e5b40`, and so do we.
///
/// One narrowing, measured rather than assumed: the reference tests **all three** effect slots,
/// [`benilla_formats::SpellDisplay`] carries only slot 0, and across the whole 363-row item-target
/// family not one row hides its enchant effect in slot 1 or 2 — pinned by the formats-side family
/// test, which fails if that ever stops being true.
pub(crate) fn item_bind_verdict(
def: &benilla_formats::SpellDisplay,
item: &ClickedItem,
new: Option<NewEnchant>,
confirmed: bool,
) -> ItemBind {
let perm = def.effect_1 == benilla_formats::SPELL_EFFECT_ENCHANT_ITEM;
if !perm && def.effect_1 != benilla_formats::SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY {
return ItemBind::Bind;
}
if def.equipped_item_subclass_mask != 0 {
let class_ok = i64::from(item.class) == i64::from(def.equipped_item_class);
let sub_ok =
item.subclass < 32 && def.equipped_item_subclass_mask & (1u32 << item.subclass) != 0;
if !(class_ok && sub_ok) {
return ItemBind::Refuse(crate::ui_action::cast_target::ERR_INVALID_TARGET);
}
}
if def.equipped_item_inventory_type_mask != 0
&& !(item.inventory_type < 32
&& def.equipped_item_inventory_type_mask & (1u32 << item.inventory_type) != 0)
{
return ItemBind::Refuse(crate::ui_action::cast_target::ERR_INVALID_TARGET);
}
if let Some(new) = new {
if new.binds && !item.already_bound && !confirmed && item.inventory_type != 0 {
return ItemBind::ConfirmBind;
}
if let Some(existing) = item.existing_enchant[usize::from(!perm)] {
return ItemBind::ConfirmReplace {
existing,
new: new.id,
};
}
}
ItemBind::Bind
}
/// The item half's commit — the bag and paper-doll click seams (`PickupContainerItem 0x4f9b30`
/// @ `4f9c54`–`4f9c6d` and its byte-identical doll twin `0x4c7300` @ `4c76df`–`4c76fb`: *if
/// IsTargeting and TargetingWantsItem, then `0x495d60(itemGuidLo, itemGuidHi)` and return —
/// nothing is picked up*). The VM half of that reroute lives in `benilla_ui`'s cursor seam; this
/// drain is `0x495d60` itself: resolve the clicked slot's live item, run [`item_bind_verdict`],
/// and on a pass do what `496056` does — hand the item to the ONE binder, which fills the word's
/// item bit and lets `SendCast 0x6e54f0` commit. Same block, two opcodes: `CMSG_CAST_SPELL` for
/// an enchant off the Craft window, `CMSG_USE_ITEM` for a poison bottle's own ON_USE.
///
/// It also drains the **two confirm popups' answers**, because the reference routes both back
/// through this same gate rather than to a second machine (decision 0928):
/// `BindEnchant 0x48d2e0` re-invokes `0x495d60` with its third parameter 1, and
/// `ReplaceEnchant 0x48d300` skips the gate and calls `0x6e5b40` directly. So a Yes is just
/// another item ask over the parked guid, and there is one code path for all three entries.
///
/// The post-send tail is the ground commit's (decision 0792): arm the pending cast + the GCD, and
/// clear the word. A click on an EMPTY slot binds nothing and keeps the mode — the ref's
/// `0x495d60` returns at its own null-item guard (`495da1`) — and so does a refusal or either
/// confirm, all three of which return before `BindTarget`.
pub(crate) fn commit_item_cast_on_pick(
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
self_q: Query<&crate::net::ObjectStore, With<SelfPlayer>>,
enchants: Option<Res<crate::items::Enchants>>,
mut parked: ResMut<EnchantConfirmItem>,
mut ladder: crate::ui_action::CastLadder,
) {
let Some(mut script) = script else {
return;
};
// The three entries into the ONE gate, in the order the reference reaches them: a fresh click
// (unconfirmed), then the bind popup's Yes over the parked guid (confirmed). The replace
// popup's Yes bypasses the gate entirely, so it collects separately.
let mut asks: Vec<(u64, bool)> = Vec::new();
let mut bind_outright: Vec<u64> = Vec::new();
for (bag, slot) in script.take_item_picks() {
let slot0 = u8::try_from(slot.saturating_sub(1)).unwrap_or(0);
match self_q
.iter()
.next()
.and_then(|store| crate::ui_items::slot_guid(&store.0, bag, slot0, &ladder.items))
{
Some(guid) => asks.push((guid, false)),
None => {
debug!("ui_action: item pick on an empty slot (bag {bag} slot {slot}) — mode kept")
}
}
}
for answer in script.take_enchant_confirms() {
let Some(guid) = parked.0 else { continue };
match answer {
benilla_ui::script::EnchantConfirm::Bind => asks.push((guid, true)),
benilla_ui::script::EnchantConfirm::Replace => bind_outright.push(guid),
}
}
for (item_guid, confirmed) in asks {
let Some((spell_id, commit)) = ladder.ground.pending_for(TargetingWants::Item) else {
continue; // a click raced a cancel — the word is gone, so there is nothing to bind
};
// The gate needs the clicked item's template; an unresolved one (never seen in practice —
// the bag needed it for the icon) binds ungated and lets the server judge, the same
// permissive shape the rest of the click law uses.
let entry = ladder
.items
.object(item_guid)
.and_then(|o| o.object_entry());
let clicked = match entry {
Some(entry) => ladder
.items
.template(entry, item_guid, &ladder.commands)
.map(|t| (t.class, t.subclass, t.inventory_type)),
None => None,
};
let def = ladder
.spells
.as_deref()
.and_then(|s| s.catalog.get(spell_id));
let (Some(def), Some((class, subclass, inventory_type))) = (def, clicked) else {
debug!("ui_action: item pick — cast {spell_id} at item {item_guid:#x} (ungated)");
ladder.commit_targeted(
spell_id,
commit,
crate::ui_action::cast_send::TargetedBind::Item(item_guid),
);
continue;
};
let cat = enchants.as_deref().map(|e| &e.0);
let fields = ladder.items.object(item_guid);
let item = ClickedItem {
class,
subclass,
inventory_type,
// `0x5da2c0`: already soulbound, or already carrying an enchant that binds.
already_bound: fields.is_some_and(|f| {
f.item_flags().is_some_and(|flags| flags & 0x1 != 0)
|| (0..7).any(|slot| {
live_enchant(f, slot, cat).is_some_and(|id| {
cat.is_some_and(|c: &benilla_formats::EnchantCatalog| {
c.binds_the_item(id)
})
})
})
}),
existing_enchant: [0u8, 1].map(|slot| fields.and_then(|f| live_enchant(f, slot, cat))),
};
// `495e93` — the enchant this cast would apply, resolved once and read by both confirms.
// `EffectMiscValue` is signed and the ref tests `jl` before its range compare.
let new = u32::try_from(def.effect_misc_value[0])
.ok()
.filter(|&id| cat.is_some_and(|c| c.has_row(id)))
.map(|id| NewEnchant {
id,
binds: cat.is_some_and(|c| c.binds_the_item(id)),
});
match item_bind_verdict(def, &item, new, confirmed) {
ItemBind::Refuse(reason) => {
debug!(
"ui_action: cast {spell_id} refused at the item bind ({reason:#x}) — \
the cursor stays up"
);
ladder.cast_errors.0.push((spell_id, reason));
}
ItemBind::ConfirmBind => {
debug!("ui_action: item bind confirm for {spell_id} on item {item_guid:#x}");
parked.0 = Some(item_guid);
script.fire_event("BIND_ENCHANT", vec![]);
}
ItemBind::ConfirmReplace { existing, new } => {
// `4960d0`/`4960d4` read both names off the rows with no suppression gate — the
// OLD one first, then the NEW, matching `"Do you want to replace %s with %s?"`.
let name = |id: u32| cat.and_then(|c| c.name(id)).unwrap_or_default().to_string();
debug!("ui_action: replace-enchant confirm {existing} -> {new} on {item_guid:#x}");
parked.0 = Some(item_guid);
script.fire_event(
"REPLACE_ENCHANT",
vec![
benilla_ui::script::ScriptValue::Str(name(existing)),
benilla_ui::script::ScriptValue::Str(name(new)),
],
);
}
ItemBind::Bind => {
debug!("ui_action: item pick — cast {spell_id} at item {item_guid:#x}");
ladder.commit_targeted(
spell_id,
commit,
crate::ui_action::cast_send::TargetedBind::Item(item_guid),
);
}
}
}
// `ReplaceEnchant 0x48d300` — re-resolve the parked guid and call `0x6e5b40` outright. No gate
// re-run at all: the popup's Yes IS the answer to every question the gate would ask again.
for item_guid in bind_outright {
let Some((spell_id, commit)) = ladder.ground.pending_for(TargetingWants::Item) else {
continue;
};
debug!("ui_action: replace-enchant accepted — cast {spell_id} at item {item_guid:#x}");
ladder.commit_targeted(
spell_id,
commit,
crate::ui_action::cast_send::TargetedBind::Item(item_guid),
);
}
}
/// One `ITEM_FIELD_ENCHANTMENT` slot as the confirms read it: the raw id must be **positive** (the
/// ref's `jl` skip at `495ef4`/`5da306`) and must name a real `SpellItemEnchantment` row (its
/// `testl %eax,%eax` after the table load). Anything else is "no enchant here".
fn live_enchant(
fields: &benilla_protocol::messages::ObjectFields,
slot: u8,
cat: Option<&benilla_formats::EnchantCatalog>,
) -> Option<u32> {
let id = u32::try_from(fields.item_enchant(slot)?).ok()?;
cat.is_some_and(|c| c.has_row(id)).then_some(id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_item_bind_gate_mirrors_0x495d60() {
use benilla_formats::SpellDisplay;
fn item_target_refusal(
def: &SpellDisplay,
class: u32,
subclass: u32,
inventory_type: u32,
) -> Option<u8> {
let item = ClickedItem {
class,
subclass,
inventory_type,
..Default::default()
};
match item_bind_verdict(def, &item, None, false) {
ItemBind::Refuse(code) => Some(code),
ItemBind::Bind => None,
other => panic!("no enchant row resolved — {other:?} is unreachable"),
}
}
// Item classes/subclasses/inventory types, 1.12 values.
const CLASS_WEAPON: u32 = 2;
const CLASS_ARMOR: u32 = 4;
const SUB_DAGGER: u32 = 15;
const SUB_SHIELD: u32 = 6;
const INVTYPE_WRIST: u32 = 9;
const INVTYPE_CHEST: u32 = 5;
const ENCHANT: u32 = benilla_formats::SPELL_EFFECT_ENCHANT_ITEM;
// Enchant Bracer - Minor Health (7418): armor, any subclass, WRIST only.
let bracer = SpellDisplay {
effect_1: ENCHANT,
equipped_item_class: CLASS_ARMOR as i32,
equipped_item_subclass_mask: 0x1f,
equipped_item_inventory_type_mask: 1 << INVTYPE_WRIST,
..Default::default()
};
assert_eq!(
item_target_refusal(&bracer, CLASS_ARMOR, 1, INVTYPE_WRIST),
None,
"a bracer takes the bracer enchant"
);
assert_eq!(
item_target_refusal(&bracer, CLASS_ARMOR, 1, INVTYPE_CHEST),
Some(crate::ui_action::cast_target::ERR_INVALID_TARGET),
"the inventory-type leg (495e4d) refuses a chestpiece"
);
assert_eq!(
item_target_refusal(&bracer, CLASS_WEAPON, 1, INVTYPE_WRIST),
Some(crate::ui_action::cast_target::ERR_INVALID_TARGET),
"the class leg (495e10) refuses a weapon"
);
// Instant Poison (8679): weapon class, a subclass mask, NO inventory-type requirement.
let poison = SpellDisplay {
effect_1: benilla_formats::SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY,
equipped_item_class: CLASS_WEAPON as i32,
equipped_item_subclass_mask: 0x2a5f3,
equipped_item_inventory_type_mask: 0,
..Default::default()
};
// 8679's real mask carries dagger (15), so a rogue's own weapon passes the subclass leg.
assert_eq!(
item_target_refusal(&poison, CLASS_WEAPON, SUB_DAGGER, 13),
None
);
assert_eq!(
item_target_refusal(&poison, CLASS_WEAPON, 1, 13),
None,
"and two-handed axe (1) is in it too — poisons are broad, the class leg is the fence"
);
assert_eq!(
item_target_refusal(&poison, CLASS_ARMOR, SUB_SHIELD, 14),
Some(crate::ui_action::cast_target::ERR_INVALID_TARGET),
"a shield is armor — the class leg alone stops it"
);
// Disenchant (13262): an item-targeted spell with NO enchant effect. The reference walks
// its loop, finds no 53/54 arm, and falls straight through to the bind — anything goes,
// and the server judges.
let disenchant = SpellDisplay {
effect_1: 99,
equipped_item_class: -1,
..Default::default()
};
assert_eq!(
item_target_refusal(&disenchant, CLASS_ARMOR, 1, INVTYPE_CHEST),
None
);
assert_eq!(
item_target_refusal(&disenchant, CLASS_WEAPON, SUB_DAGGER, 13),
None
);
// A subclass past the mask's 32 bits can never be in it — shifted, that would be UB-ish
// nonsense, so the gate refuses instead of wrapping.
assert_eq!(
item_target_refusal(&poison, CLASS_WEAPON, 40, 13),
Some(crate::ui_action::cast_target::ERR_INVALID_TARGET)
);
}
/// `0x495d60`'s two confirm branches and, more importantly, the ORDER they sit in — the equip
/// gate first (`495e10`), then the bind confirm (`495e93`), then the replace confirm
/// (`495ecc`), each returning before `BindTarget`. Decision 0928.
///
/// The chain this pins is the one that is easy to get subtly wrong: the bind confirm consults
/// the confirmed flag and the replace confirm does **not**, so answering the bind popup Yes
/// re-enters the gate and can raise the replace popup next. Getting that backwards would
/// either swallow the second question or loop on the first.
#[test]
fn the_two_confirms_chain_in_the_references_order() {
use benilla_formats::SpellDisplay;
const ENCHANT: u32 = benilla_formats::SPELL_EFFECT_ENCHANT_ITEM;
const TEMP: u32 = benilla_formats::SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY;
const INVTYPE_WEAPON: u32 = 13;
// A permanent weapon enchant whose row binds the item (the ZG/imbue family's `Flags & 1`).
let perm = SpellDisplay {
effect_1: ENCHANT,
effect_misc_value: [1900, 0, 0],
..Default::default()
};
let binder = Some(NewEnchant {
id: 1900,
binds: true,
});
let plain = Some(NewEnchant {
id: 1900,
binds: false,
});
let bare = ClickedItem {
inventory_type: INVTYPE_WEAPON,
..Default::default()
};
// 1 · The bind confirm, and each of its four legs turned off in turn (`495ea3`-`495ec6`).
assert_eq!(
item_bind_verdict(&perm, &bare, binder, false),
ItemBind::ConfirmBind
);
assert_eq!(
item_bind_verdict(&perm, &bare, plain, false),
ItemBind::Bind,
"a row without the flag never asks (495ea3)"
);
assert_eq!(
item_bind_verdict(&perm, &bare, None, false),
ItemBind::Bind,
"an EffectMiscValue naming no row never asks (495e9e)"
);
assert_eq!(
item_bind_verdict(
&perm,
&ClickedItem {
already_bound: true,
..bare
},
binder,
false
),
ItemBind::Bind,
"0x5da2c0 says it is already bound (495eb3)"
);
assert_eq!(
item_bind_verdict(
&perm,
&ClickedItem {
inventory_type: 0,
..bare
},
binder,
false
),
ItemBind::Bind,
"a non-equippable item — a lockbox, a reagent — is never asked (495ec6)"
);
// 2 · Yes on the bind popup re-enters with the flag set (`BindEnchant 0x48d2e0`), and the
// gate falls through to the REPLACE question for the same click.
let enchanted = ClickedItem {
existing_enchant: [Some(2564), None],
..bare
};
assert_eq!(
item_bind_verdict(&perm, &enchanted, binder, false),
ItemBind::ConfirmBind,
"the bind question comes first"
);
assert_eq!(
item_bind_verdict(&perm, &enchanted, binder, true),
ItemBind::ConfirmReplace {
existing: 2564,
new: 1900
},
"and its Yes lands on the replace question — the ref's two-popup chain"
);
// 3 · The slot fork (`495ed3: cmpl $0x35 / setne`) — the ONE place effect 53 and 54
// diverge. A permanent enchant asks about PERM; a poison asks about TEMP.
let temp_spell = SpellDisplay {
effect_1: TEMP,
effect_misc_value: [1900, 0, 0],
..Default::default()
};
assert_eq!(
item_bind_verdict(&temp_spell, &enchanted, plain, false),
ItemBind::Bind,
"a poison over a permanently-enchanted weapon replaces nothing — different slot"
);
let poisoned = ClickedItem {
existing_enchant: [None, Some(2564)],
..bare
};
assert_eq!(
item_bind_verdict(&temp_spell, &poisoned, plain, false),
ItemBind::ConfirmReplace {
existing: 2564,
new: 1900
},
"but over an already-poisoned one it does"
);
assert_eq!(
item_bind_verdict(&perm, &poisoned, plain, false),
ItemBind::Bind,
"and the mirror: a permanent enchant ignores the temp slot"
);
// 4 · The equip gate still runs FIRST — a refusal beats both confirms (495e10 < 495e93).
let bracer_only = SpellDisplay {
effect_1: ENCHANT,
effect_misc_value: [1900, 0, 0],
equipped_item_class: 4,
equipped_item_subclass_mask: 0x1f,
equipped_item_inventory_type_mask: 1 << 9,
..Default::default()
};
assert_eq!(
item_bind_verdict(&bracer_only, &enchanted, binder, false),
ItemBind::Refuse(crate::ui_action::cast_target::ERR_INVALID_TARGET)
);
}
}
@@ -0,0 +1,325 @@
//! **The targeting cursor** — the client's one "this cast is waiting for a click" machine, and the
//! three seams that can end it: the **terrain** click (decision 0792, closing B132:
//! "ground-targeted AOE all Invalid target"), the **bag / paper-doll** click (decision 0923:
//! poisons, stones, oils, scopes, enchants; 0928: its lockbox word and the two confirm popups), and
//! the **world GameObject** click (decision 0939: Opening / Pick Lock / Mining / Herb Gathering
//! armed from the book or a bar, then clicked at a chest, door or node).
//!
//! The reference's targeting mode IS a nonzero flag_word (`IsTargeting 0x6e48a0`), and that word —
//! not a verdict about it — is what this module holds ([`SpellTargeting`]). Each seam asks it the
//! seam's own one-instruction question ([`TargetingWants`]), which is why a lock spell can arm the
//! bag click and the world click at once.
//!
//! This file is the **state and the input plumbing**; the three seams and the cursor live beside
//! it, each transcribing a byte-verified piece (wow-re `wave-cast.md` + `cursor-system.md` §5 +
//! `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`]).
//! - [`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
//! a click can even take is a function of the pending spell's word, not of the scene.
//! - [`item`] — the bag click (`PickupContainerItem 0x4f9b30` @ `4f9c54`) and the paper-doll click
//! (`0x4c7300` @ `4c76df`) each carry the same three-instruction rung — IsTargeting,
//! `TargetingWantsItem 0x6e6330`, then `0x495d60(itemGuid)` and return — and `0x495d60` is
//! [`item_bind_verdict`] plus the bind. Two of its four exits are the **confirm popups**
//! (`BIND_ENCHANT` / `REPLACE_ENCHANT`, decision 0928), which park the clicked guid
//! ([`EnchantConfirmItem`], the ref's `0xb4e3c0`) and return with the word still standing; their
//! Yes re-enters that same drain.
//!
//! All three end in **one commit tail** ([`crate::ui_action::CastLadder::commit_targeted`]): the
//! packet (`SendCast 0x6e54f0`'s same block, two opcodes), the pending arm, the GCD, and the word
//! cleared.
//!
//! **The ESC chain** ([`feed_targeting_to_vm`] / [`drain_stop_targeting`]): the real
//! `UIParent.lua:1490` rung (`elseif ( SpellStopTargeting() ) then`) runs in our live VM; the feed
//! pushes the state its `SpellIsTargeting`/`SpellStopTargeting` bindings read (and the item seam's
//! arm, which gates the VM's click reroute), the drain commits the cancel. AbortCast in targeting
//! mode clears the word and sends **nothing**.
//!
//! Entry and the two press-cancel shapes live in the cast path itself: the resolver yields
//! [`crate::ui_action::cast_target::CastWireTarget::Targeting`] carrying the word, the one
//! cast-send path enters the mode here, a NEW spell's press aborts-and-proceeds (`TryCast
//! 6e4d62`), and the action bar's re-press of the SAME spell toggles the mode off (`UseAction
//! 0x4e5ee0`'s `GetTargetingSpellId`+`StopTargeting` — [`crate::ui_action::drain`]).
//!
//! The click path is byte-pinned by wow-re's `world-click-targeting.md`: the terrain-leg commit
//! `0x492580` has **no range gate and no error path** — it binds and sends regardless, and the
//! server judges range (`CheckGroundPointInRange 0x6e6810` has exactly ONE caller binary-wide, the
//! hover classifier `0x4820f0`: its verdict colours the cursor and nothing else). The object leg
//! has no gate either. While targeting, the pick flags come from the pending spell's mask alone —
//! for a dest-only word a unit is not pickable, so a click over one commits on the ground behind it
//! ([`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.
mod cursor;
mod item;
mod world;
pub(crate) use cursor::{drive_targeting_cursor, ground_cast_radius};
pub(crate) use item::{commit_item_cast_on_pick, EnchantConfirmItem};
pub(crate) use world::{commit_ground_cast_on_click, commit_object_cast_on_click};
use bevy::prelude::*;
use crate::interact::WorldRightPress;
/// Which click seam is asking the standing flag_word whether it may bind — the reference's three
/// *wants* predicates, each a one-instruction mask test on the same word `0xcecac0`, each consulted
/// by exactly one seam. Read off the three (six-instruction) functions themselves:
///
/// - `TargetingWantsLocation 0x6e6320` (`6e6328: testb $0x60, %cl`) → the terrain click's commit.
/// - `TargetingWantsItem 0x6e6330` (`6e6338: testl $0x4010, %ecx`) → the **bag** click's bind
/// (`PickupContainerItem 0x4f9b30` @ `4f9c5d`) and the **paper-doll** click's
/// (`0x4c7300` @ `4c76e8`) — the identical three-instruction rung in both.
/// - `TargetingWantsGameObject 0x6e62d0` (`6e62d8: testb $0x48, %ch`, i.e. `word & 0x4800`) → the
/// **world** click on a GameObject (decision 0939).
///
/// **This is a question asked of a word, not a partition of words.** `0x4010` and `0x4800` overlap
/// on `TARGET_FLAG_LOCKED`, so every lock spell answers *yes* to both the item and the GameObject
/// seam — one armed cursor, two clicks that could end it — and the reference resolves that nowhere
/// but at the click, where `BindTarget 0x6e5b40` picks its arm by the clicked object's typemask
/// (`6e5f17` item bit 1 / `6e5f52` GameObject bit 5) and *then* re-tests the word. Modelling the
/// mode as one-of-N is what kept a Dull Iron Key from opening the door it was standing in front of.
///
/// The word itself is one state, which is why this is a mode of one resource and not three
/// resources: every cancel (ESC, right-press, a new cast's abort-and-proceed) clears the one word,
/// and the reference has exactly one `IsTargeting`.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum TargetingWants {
/// Decision 0792 — the terrain click.
Location,
/// Decision 0923 — the bag / paper-doll click.
Item,
/// Decision 0939 — the world click on a GameObject.
GameObject,
}
impl TargetingWants {
/// The seam's own mask test, verbatim.
fn matches(self, word: u16) -> bool {
let mask = match self {
Self::Location => 0x0060,
Self::Item => 0x4010,
Self::GameObject => 0x4800,
};
word & mask != 0
}
}
/// The targeting-cursor mode — benilla's `flag_word != 0` mirror: `Some` while a cast awaits the
/// click that binds its target. Entered by the one cast-send path
/// ([`super::cast_target::CastWireTarget::Targeting`]), cleared by whichever commit fires, the two
/// press cancels, and the ESC drain.
#[derive(Resource, Default)]
pub(crate) struct SpellTargeting(Option<Targeting>);
struct Targeting {
spell_id: u32,
/// What the click will commit. The ref keeps the whole pending-cast block across the cursor —
/// the cast **item's** guid at `0xceac48` included — so `0x6e54f0`'s discriminator still picks
/// `CMSG_USE_ITEM` when the click lands: a thrown grenade for the terrain seam (decision
/// 0914), a poison bottle for the item seam (0923), a key for the GameObject seam (0939).
commit: super::cast_send::CastCommit,
/// The standing flag_word `0xcecac0` itself — not a verdict about it. Every seam asks it its
/// own question; more than one can answer yes.
word: u16,
}
impl SpellTargeting {
/// `IsTargeting 0x6e48a0` — the canonical predicate.
pub(crate) fn active(&self) -> bool {
self.0.is_some()
}
/// `GetTargetingSpellId 0x6e48e0` — the spell awaiting its click, for the action bar's
/// press-again toggle.
pub(crate) fn spell(&self) -> Option<u32> {
self.0.as_ref().map(|t| t.spell_id)
}
/// Whether the standing word answers `wants`' mask test — `false` when nothing is targeting.
pub(crate) fn wants(&self, wants: TargetingWants) -> bool {
self.0.as_ref().is_some_and(|t| wants.matches(t.word))
}
pub(crate) fn enter(&mut self, spell_id: u32, commit: super::cast_send::CastCommit, word: u16) {
self.0 = Some(Targeting {
spell_id,
commit,
word,
});
}
/// The pending cast's `(spell, commit)` when the standing word answers `wants` — the shape
/// every commit system opens with, so none can fire on a word its seam cannot bind.
fn pending_for(&self, wants: TargetingWants) -> Option<(u32, super::cast_send::CastCommit)> {
self.0
.as_ref()
.filter(|t| wants.matches(t.word))
.map(|t| (t.spell_id, t.commit))
}
pub(crate) fn clear(&mut self) {
self.0 = None;
}
}
/// Right-click cancels targeting — on the **DOWN edge**, the reference's WorldFrame
/// `OnMouseDown 0x483c40` → `0x492c20`: right button ∧ `IsTargeting` → `StopTargeting
/// 0x6e4900`, no packet — and the handler returns 0, so the press keeps doing everything else
/// it did (the turn-drag, the release's context click; we consume nothing either). Byte-pinned
/// by wow-re `world-click-targeting.md` Q3, whose caller census is complete: this and the
/// ESC/UseAction/TryCast paths are the ONLY input-band cancels — no keyboard caller exists.
///
/// Two qualifications, transcribed: a held cursor payload pre-empts the cancel (`0x492b50`
/// clears the payload and returns before the WorldFrame virtuals dispatch — our payload keeps
/// its own clean-click clear in [`crate::target::click::world_right_click_payload`]); and a
/// press over a UI frame never reaches the WorldFrame — [`WorldRightPress`]'s world gate
/// transcribes the certain half of wow-re's one DEFERRED (whether a UI-frame right-click also
/// cancels is unpinned there). The `0x51`-effect placement-rotate skip (`[0xceca90]`) is
/// unmodelled along with the flag itself (named residual, 0792).
pub(crate) fn cancel_targeting_on_right_press(
mut presses: MessageReader<WorldRightPress>,
payload_held: Res<crate::ui_script::CursorPayloadHeld>,
mut targeting: ResMut<SpellTargeting>,
) {
if !targeting.active() {
// Reader hygiene, like the commit's: a press buffered while idle never replays as a
// cancel the frame the mode turns on.
presses.clear();
return;
}
if presses.read().last().is_none() || payload_held.0 {
return;
}
debug!("ui_action: targeting cancelled (right-click)");
targeting.clear();
}
/// Push the targeting state into the live VM each frame, **before** the input pass — so a word
/// armed last frame is already standing when this frame's clicks run. Three consumers, one push:
/// `SpellIsTargeting()` / `SpellStopTargeting()`'s ESC chain reads the word itself, the engine's
/// bag / doll pickup reroute reads the item half (`TargetingWantsItem`'s mirror), and the word's
/// **edges** fire `CURRENT_SPELL_CAST_CHANGED`.
///
/// That last one is the confirm popups' whole teardown (decision 0928): `UIParent.lua` hides both
/// on it, and without it a popup outlives the cast it is asking about — its Yes would then bind a
/// *stale* item guid into whatever cast armed next. The reference fires the event from exactly
/// these edges: `CURRENT_SPELL_CAST_CHANGED` (261) has ONE emitter binary-wide, the two-line
/// `0x4b3250`, and its twelve callers are the `Spell_C` arm/abort/bind sites — **`StopTargeting
/// 0x6e4900` @ `6e495c` among them**. We fire on the spell changing rather than on the raw flag so
/// a cancel-and-rearm in one frame still counts as a change.
pub(crate) fn feed_targeting_to_vm(
targeting: Res<SpellTargeting>,
mut last: Local<Option<u32>>,
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
) {
if let Some(mut script) = script {
script.set_spell_targeting(targeting.active());
script.set_item_pick_armed(targeting.wants(TargetingWants::Item));
if *last != targeting.spell() {
*last = targeting.spell();
script.fire_event("CURRENT_SPELL_CAST_CHANGED", vec![]);
}
}
}
/// Drain the ESC chain's `SpellStopTargeting()` trigger (**after** the input pass) and clear
/// the mode — the ref's `StopTargeting 0x6e4900` → AbortCast-in-targeting: word cleared, no
/// packet.
pub(crate) fn drain_stop_targeting(
mut targeting: ResMut<SpellTargeting>,
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
) {
let Some(mut script) = script else {
return;
};
if script.take_stop_targeting() {
debug!("ui_action: targeting cancelled (ESC chain)");
targeting.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A seam only answers a word its own mask test accepts (decisions 0923 / 0939). Without this,
/// a terrain click while a poison is armed would ship a DEST block for an item spell.
#[test]
fn each_click_seam_only_sees_a_word_it_can_bind() {
let commit = super::super::cast_send::CastCommit::Spell;
let mut t = SpellTargeting::default();
assert_eq!(
t.pending_for(TargetingWants::Location),
None,
"idle binds nothing"
);
assert!(!t.wants(TargetingWants::Item), "idle wants nothing");
// Blizzard — the bare DEST word.
t.enter(2120, commit, 0x0040);
assert!(t.active());
assert_eq!(
t.pending_for(TargetingWants::Location),
Some((2120, commit))
);
for seam in [TargetingWants::Item, TargetingWants::GameObject] {
assert_eq!(
t.pending_for(seam),
None,
"{seam:?} cannot commit a Blizzard"
);
}
// Instant Poison — the bare ITEM word.
t.enter(8679, commit, 0x0010);
assert_eq!(t.pending_for(TargetingWants::Item), Some((8679, commit)));
for seam in [TargetingWants::Location, TargetingWants::GameObject] {
assert_eq!(t.pending_for(seam), None, "{seam:?} cannot commit a poison");
}
// The spell id is the word's, whichever seam — what the action bar's re-press toggle reads.
assert_eq!(t.spell(), Some(8679));
t.clear();
assert!(!t.active());
}
/// **The overlap is the point** (decision 0939). A lock spell's word — `LOCKED`, plus arm 23's
/// `GAMEOBJECT` overlay — is in `TargetingWantsItem`'s `0x4010` *and* in
/// `TargetingWantsGameObject`'s `0x4800`, so one armed cursor genuinely answers two seams and
/// whichever click lands first is the one that binds. An enum of halves could not say this, and
/// saying it wrong is what left a lockpick with no way to reach a chest.
#[test]
fn a_lock_word_answers_both_the_bag_and_the_world_seam() {
let commit = super::super::cast_send::CastCommit::Spell;
let mut t = SpellTargeting::default();
// Opening (3365): `Targets 0x4000` + implicit arm 23 ⇒ `0x4800`.
t.enter(3365, commit, 0x4800);
assert_eq!(t.pending_for(TargetingWants::Item), Some((3365, commit)));
assert_eq!(
t.pending_for(TargetingWants::GameObject),
Some((3365, commit))
);
assert!(t.wants(TargetingWants::Item));
assert!(t.wants(TargetingWants::GameObject));
assert_eq!(
t.pending_for(TargetingWants::Location),
None,
"a lock word still has no terrain leg"
);
// A bare GAMEOBJECT word (arm 23 over a `Targets`-less row) is the world seam's alone —
// `0x800 & 0x4010 == 0`.
t.enter(3365, commit, 0x0800);
assert!(t.wants(TargetingWants::GameObject));
assert!(!t.wants(TargetingWants::Item));
}
}
@@ -0,0 +1,285 @@
//! The **world** click's two commits — the two legs of the reference's click dispatcher
//! `0x492ce0`, which while targeting are chosen by the *pending spell's word*, not by the scene
//! (wow-re `world-click-targeting.md` §2: the pick flags `0x481050` builds come only from
//! `0xcecac0` when `IsTargeting`).
//!
//! - **terrain leg** (`0x492c90` → `0x492580` → `BindLocation 0x6e60f0`) — [`commit_ground_cast_on_click`]
//! - **object leg** (`0x492ce0` → `SELECT 0x4925d0` → `SetSelection 0x493540` @ `4935d5` →
//! `BindTarget 0x6e5b40`) — [`commit_object_cast_on_click`]
//!
//! They cannot both fire for one word, and not because we sequence them: the predicates are
//! disjoint on real data (`& 0x60` vs `& 0x4800`), and in the reference the *pick* enforces it —
//! a location-only word yields pick flags `3`, whose `& 0x7c == 0` disables the object pick
//! entirely, which is why an AoE reticle clicks straight through a chest.
//!
//! Neither leg gates on range, validity or the lock. `0x492580`'s complete callee set contains no
//! range call and no error emitter, and the object leg's `BindTarget` arm reads nothing but the
//! clicked object's typemask and the word (wow-re C2 REFUTED). The server judges; its refusing
//! `SMSG_CAST_RESULT` is the red line.
use bevy::prelude::*;
use benilla_assets::coords::bevy_to_wow;
use crate::interact::WorldClick;
use crate::target::{go_is_nearest, Hovered, HoveredObject, PickOcclusion};
use crate::ui_action::cast_send::TargetedBind;
use super::TargetingWants;
/// The world click's ground commit — the terrain leg's action-1 arm (`0x492580`, tried before
/// anything else the click could mean; [`crate::target::click::select_on_click`] holds its gate
/// while this mode is active, so the click neither selects nor deselects). Binds the frame's
/// pick-occlusion point and sends **unconditionally** — the leg's complete callee set has no
/// range check and no error path (wow-re `world-click-targeting.md` Q1; C2 REFUTED: the click
/// never gates on range, the server judges it, and its refusing `SMSG_CAST_RESULT` is the red
/// line) — `CMSG_CAST_SPELL` mask `0x40` + the point (WoW coords), arming the pending cast +
/// the GCD (the `SendCast 0x6e54f0` tail's two live pieces for a ground cast); the mode ends
/// with the send. No world hit (sky) → the nothing leg: no commit, mode kept.
///
/// Runs AFTER `select_on_click` in the target chain: the selection gate reads the mode's state,
/// so the commit that clears it must come later in the same frame.
pub(crate) fn commit_ground_cast_on_click(
mut clicks: MessageReader<WorldClick>,
occlusion: Res<PickOcclusion>,
mut ladder: crate::ui_action::CastLadder,
) {
if !ladder.ground.active() {
// Keep the reader current so a click buffered while idle can never replay as a commit
// the frame the mode turns on.
clicks.clear();
return;
}
if clicks.read().last().is_none() {
return;
}
// `TargetingWantsLocation 0x6e6320` — an item-targeting word has no location leg, so the
// terrain click's `BindLocation` binds nothing and the mode simply stays up.
let Some((spell_id, commit)) = ladder.ground.pending_for(TargetingWants::Location) else {
return;
};
let Some(point) = occlusion.point else {
// The ray hit nothing (sky) — the ref's nothing-leg has no ground commit; the mode
// stays, exactly like the UnableCast cursor said it would.
return;
};
let dest = bevy_to_wow(point);
debug!(
"ui_action: ground cast {spell_id} committed at wow ({:.2}, {:.2}, {:.2})",
dest[0], dest[1], dest[2]
);
// The shared commit tail — same block, two opcodes (`SendCast 0x6e54f0`'s one discriminator
// survives the cursor, decision 0914: a thrown grenade commits as `CMSG_USE_ITEM` with the
// DEST block), then the pending arm, the GCD, and the word cleared.
ladder.commit_targeted(spell_id, commit, TargetedBind::Dest(dest));
}
/// The world click's **GameObject** commit — the object leg (decision 0939). While targeting, a
/// left-click that resolves to an object goes `0x492ce0` → `SELECT 0x4925d0` → `SetSelection
/// 0x493540`, and `0x493540`'s *first* act is the targeting intercept: `4935ca call 0x6e48a0;
/// je 0x4935ec` — targeting ⇒ `4935d5 call 0x6e5b40` `BindTarget(this = the clicked object)` and
/// `ret 8` immediately, never reaching the selection write. So a click that feeds a pending cast
/// **does not change the player's target**, which is why
/// [`crate::target::click::select_on_click`]'s mode gate — already there for the terrain leg —
/// is the whole selection story here too. (It also never reaches the `[vtbl+0x58]`
/// "can be selected" stub that makes a GameObject unselectable: the intercept is upstream of it.)
///
/// `BindTarget` picks its arm from the clicked object's **typemask** — `6e5f52: shrl $0x5, %ecx;
/// testb $0x1, %cl` selects the GameObject arm — and that arm then asks the *word*:
/// `6e5f60: testb $0x48, %ch`, which is `TargetingWantsGameObject 0x6e62d0`'s own `0x4800`. On a
/// match it writes the wire bit `6e5f69: orb $0x8, 0xceac5d` = `TARGET_FLAG_GAMEOBJECT (0x800)`
/// — **not** `LOCKED`, which is a word bit this arm *consumes* and clears (`6e5f70: andb $0xb7,
/// 0xcecac1`) — parks the guid at `0xceac60/64`, and the now-zero word lets the tail fire
/// `SendCast 0x6e54f0` (`6e60c1: cmpw $0, 0xcecac0; 6e60d7: call 0x6e54f0`).
///
/// A click on a **unit** while a lock word stands binds nothing: every unit arm of `0x6e5b40`
/// tests a bit (`0x2/0x4/0x8/0x80/0x100/0x200/0x8000`) the word does not carry, `bl` stays 0, and
/// the function returns having written nothing and left the word alone — the cursor simply stays
/// up. [`go_is_nearest`] is that same discrimination on our side.
///
/// **No gate of any kind before the send** — not range, not the lock, not "is this object even
/// openable". The right-click path ([`crate::target::click`]) resolves the lock itself and can
/// refuse locally with a toast; this path is the reference's blunt one, and its refusal arrives as
/// the server's `SMSG_CAST_RESULT`. That asymmetry is the reference's, not ours: `0x5f33e0` is a
/// lock-routing sender, `0x6e5b40` is a target binder.
///
/// Runs AFTER `select_on_click`, like the terrain commit and for the same reason.
pub(crate) fn commit_object_cast_on_click(
mut clicks: MessageReader<WorldClick>,
hovered: Res<Hovered>,
hovered_object: Res<HoveredObject>,
mut ladder: crate::ui_action::CastLadder,
) {
if !ladder.ground.active() {
// Reader hygiene, as in the terrain leg: a click buffered while idle must not replay as a
// commit the frame the mode turns on.
clicks.clear();
return;
}
if clicks.read().last().is_none() {
return;
}
// `TargetingWantsGameObject 0x6e62d0` — a poison's bare `0x10` word has no GameObject leg, so
// clicking a chest with one armed binds nothing and the mode stays up.
let Some((spell_id, commit)) = ladder.ground.pending_for(TargetingWants::GameObject) else {
return;
};
// The pick's object leg only wins when a GameObject is what the ray actually hit nearest.
if !go_is_nearest(&hovered, &hovered_object) {
return;
}
let Some(guid) = hovered_object.guid else {
return;
};
debug!("ui_action: cast {spell_id} committed at gameobject {guid:#x}");
// The shared commit tail — `CMSG_CAST_SPELL` mask `0x800` + the packed guid for a known
// opener, `CMSG_USE_ITEM` with the same block for a key's own ON_USE (the pending-cast block
// survives the cursor, decision 0914) — then the pending arm, the GCD, and the word cleared.
ladder.commit_targeted(spell_id, commit, TargetedBind::Object(guid));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::net::{ClientCommand, NetCommands};
use bevy::ecs::system::SystemId;
use crossbeam_channel::Receiver;
const OPENING: u32 = 3365;
const CHEST: u64 = 0xF110_000C_1F00_A3B2;
/// The lock word a shipped opener arms: `Targets 0x4000` + implicit arm 23's `|0x800`.
const LOCK_WORD: u16 = 0x4800;
/// The ladder's resource set, plus the two hover verdicts and the click message this seam
/// reads. Kept minimal on purpose: what this test is for is the WIRING — that the system reads
/// the hover the pick wrote and the word the arm left, and that a click turns them into the
/// one packet. The packet's own shape is pinned in `benilla-protocol`.
fn fixture() -> (World, Receiver<ClientCommand>, SystemId) {
let (tx, rx) = crossbeam_channel::unbounded();
let mut world = World::new();
world.insert_resource(NetCommands(tx));
world.init_resource::<crate::items::Items>();
world.init_resource::<crate::ui_cast::PendingCast>();
world.init_resource::<crate::ui_cast::QueuedMeleeSpell>();
world.init_resource::<crate::cooldowns::Cooldowns>();
world.init_resource::<crate::ui_action::CastErrors>();
world.init_resource::<crate::ui_action::AutoRepeatActive>();
world.init_resource::<crate::ui_tradeskill::TradeSkillOpens>();
world.init_resource::<super::super::SpellTargeting>();
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
world.init_resource::<Messages<WorldClick>>();
world.init_resource::<Hovered>();
world.init_resource::<HoveredObject>();
world.init_resource::<PickOcclusion>();
// REGISTERED, not `run_system_once`: this seam's reader hygiene is a property of state
// that survives between frames, and a fresh system per call would start every read at
// cursor 0 and hide exactly the bug the drain exists to prevent.
let id = world.register_system(commit_object_cast_on_click);
(world, rx, id)
}
/// Put a GameObject under the cursor at `distance`, nearer than any unit.
fn hover_go(world: &mut World, distance: f32) {
world.insert_resource(HoveredObject {
target: Some(Entity::from_raw_u32(1).unwrap()),
guid: Some(CHEST),
distance,
});
}
fn click(world: &mut World, id: SystemId) {
world
.resource_mut::<Messages<WorldClick>>()
.write(WorldClick);
world.run_system(id).expect("the object commit runs");
}
/// The whole gesture, end to end: a lock word standing, a chest under the cursor, one
/// left-click ⇒ the OPEN_LOCK cast at that GameObject, and the cursor is down.
#[test]
fn a_click_on_a_hovered_gameobject_commits_the_lock_cast() {
let (mut world, rx, id) = fixture();
world.resource_mut::<super::super::SpellTargeting>().enter(
OPENING,
crate::ui_action::cast_send::CastCommit::Spell,
LOCK_WORD,
);
hover_go(&mut world, 5.0);
click(&mut world, id);
assert!(matches!(
rx.try_recv(),
Ok(ClientCommand::CastSpellGameObject {
spell_id: OPENING,
go_guid: CHEST,
})
));
assert!(
!world.resource::<super::super::SpellTargeting>().active(),
"the commit clears the one word"
);
}
/// Every way the click must NOT commit — each one a rung the reference has, and each one the
/// difference between a dead click and a wrong packet.
#[test]
fn the_object_commit_holds_its_fire() {
let commit = crate::ui_action::cast_send::CastCommit::Spell;
// (a) Nothing armed: the click is not ours at all, and the reader is drained so it cannot
// replay as a commit the frame the mode turns on.
let (mut world, rx, id) = fixture();
hover_go(&mut world, 5.0);
click(&mut world, id);
assert!(rx.try_recv().is_err(), "idle binds nothing");
world
.resource_mut::<super::super::SpellTargeting>()
.enter(OPENING, commit, LOCK_WORD);
world.run_system(id).expect("the next frame, no new click");
assert!(
rx.try_recv().is_err(),
"a click buffered while idle must not replay once the word stands"
);
// (b) A word with no GameObject leg — a poison's bare ITEM word. `0x10 & 0x4800 == 0`, so
// clicking a chest with one armed binds nothing and the cursor stays up.
let (mut world, rx, id) = fixture();
world
.resource_mut::<super::super::SpellTargeting>()
.enter(8679, commit, 0x0010);
hover_go(&mut world, 5.0);
click(&mut world, id);
assert!(rx.try_recv().is_err(), "a poison has no world leg");
assert!(
world.resource::<super::super::SpellTargeting>().active(),
"and the cursor survives the click it cannot consume"
);
// (c) Nothing hovered — bare ground or sky. `BindTarget` is never reached; the reference's
// nothing-leg writes no guid at all.
let (mut world, rx, id) = fixture();
world
.resource_mut::<super::super::SpellTargeting>()
.enter(OPENING, commit, LOCK_WORD);
click(&mut world, id);
assert!(rx.try_recv().is_err(), "no object, no bind");
assert!(world.resource::<super::super::SpellTargeting>().active());
// (d) A UNIT is nearer than the GameObject. `0x6e5b40` picks its arm by the clicked
// object's typemask, and every unit arm tests a bit this word does not carry — `bl` stays
// 0 and nothing is written.
let (mut world, rx, id) = fixture();
world
.resource_mut::<super::super::SpellTargeting>()
.enter(OPENING, commit, LOCK_WORD);
hover_go(&mut world, 9.0);
world.insert_resource(Hovered {
target: Some(Entity::from_raw_u32(2).unwrap()),
guid: Some(0x1234),
distance: 4.0,
});
click(&mut world, id);
assert!(rx.try_recv().is_err(), "a unit in front of the chest wins");
assert!(world.resource::<super::super::SpellTargeting>().active());
}
}
@@ -0,0 +1,116 @@
//! The two enchant-apply confirms (decision 0928, EnchantConfirm.xml): the Lua wiring between the
//! events `ui_action::targeting`'s item-bind gate fires and the shared StaticPopup engine, driven
//! exactly as that gate drives it.
//!
//! What these pin is the seam, not the gate: that `BIND_ENCHANT` and `REPLACE_ENCHANT` reach a
//! popup at all, that the replace text takes its two enchant names in the reference's order (old
//! then new), and that each Yes lands on the right one of the two Lua globals — because
//! `BindEnchant()` and `ReplaceEnchant()` mean opposite things on the app side (re-run the gate vs
//! bind outright), and swapping them would silently skip a question.
use benilla_ui::script::{EnchantConfirm, ScriptValue, UiScript};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error.
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "EnchantConfirm.xml");
s
}
/// `BIND_ENCHANT` (event 402, no args) raises the bind warning, and its Okay calls `BindEnchant()`
/// — which on the app side re-enters `0x495d60` with the confirmed flag, not a send.
#[test]
fn the_bind_confirm_shows_and_its_okay_queues_bind_enchant() {
let mut s = setup();
s.fire_event("BIND_ENCHANT", vec![]);
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"BIND_ENCHANT shows the bind warning"
);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Enchanting this item will bind it to you."
);
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert_eq!(s.take_enchant_confirms(), vec![EnchantConfirm::Bind]);
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
}
/// `REPLACE_ENCHANT(old, new)` fills the GlobalStrings template in the reference's argument order
/// — the fire site pushes the enchant already on the item first, then the one about to land.
#[test]
fn the_replace_confirm_names_the_old_enchant_first() {
let mut s = setup();
s.fire_event(
"REPLACE_ENCHANT",
vec![
ScriptValue::Str("Crusader".into()),
ScriptValue::Str("Agility +15".into()),
],
);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Do you want to replace \"Crusader\" with \"Agility +15\"?"
);
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert_eq!(s.take_enchant_confirms(), vec![EnchantConfirm::Replace]);
}
/// No is silent, and so is ESC — declining sends nothing and tears nothing down, because the gate
/// returned before `BindTarget` and the targeting word is still standing.
#[test]
fn declining_either_confirm_queues_nothing() {
let mut s = setup();
s.fire_event("BIND_ENCHANT", vec![]);
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
assert_eq!(s.take_enchant_confirms(), vec![]);
s.fire_event(
"REPLACE_ENCHANT",
vec![ScriptValue::Str("a".into()), ScriptValue::Str("b".into())],
);
s.run("ToggleGameMenu()").unwrap();
assert_eq!(s.take_enchant_confirms(), vec![]);
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
}
/// `CURRENT_SPELL_CAST_CHANGED` takes both away — the reference's only teardown for them, and the
/// one that matters: the pending cast the popup is asking about is what makes the question mean
/// anything, so a cancelled or replaced cast must not leave a live Yes button behind.
#[test]
fn a_changed_pending_cast_dismisses_both() {
let mut s = setup();
s.fire_event("BIND_ENCHANT", vec![]);
s.fire_event("CURRENT_SPELL_CAST_CHANGED", vec![]);
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
assert_eq!(s.take_enchant_confirms(), vec![]);
s.fire_event(
"REPLACE_ENCHANT",
vec![ScriptValue::Str("a".into()), ScriptValue::Str("b".into())],
);
s.fire_event("CURRENT_SPELL_CAST_CHANGED", vec![]);
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
assert_eq!(s.take_enchant_confirms(), vec![]);
}
+7
View File
@@ -404,6 +404,10 @@ fn load_default_ui(script: &UiScript) -> Vec<String> {
// The two duel dialogs + their event driver (decision 0633): registry entries on the same
// StaticPopup engine, and DUEL_OUTOFBOUNDS leans on its per-tick countdown branch.
"DuelFrame.xml",
// The two enchant-apply confirms + their event driver (decision 0928): the same StaticPopup
// registry again. Not CraftFrame's — the gate that raises them is the generic item-target
// bind, reached by a poison or a sharpening stone with no profession window open.
"EnchantConfirm.xml",
// The shared CooldownFrame_SetTimer (the ref's Cooldown.lua file split) — before its
// consumers (ActionBar's buttons, the multibars, the stance bar, BagFrame's slots).
"Cooldown.xml",
@@ -881,6 +885,9 @@ mod death_tests;
#[cfg(test)]
mod duel_tests;
#[cfg(test)]
mod enchant_confirm_tests;
#[cfg(test)]
mod friends_tests;
+263 -6
View File
@@ -53,14 +53,15 @@ fn shipped_gossip_frame_drives_end_to_end() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "UiPanels.xml");
// The window + the 8-row shared pool (quest rows and option rows both draw from it, decision
// 0088 §3) + the close button + the GOODBYE button. The re-skinned greeting and the NPC-name
// banner are FontString layers on the window (the real GossipGreetingText ref l.241 /
// GossipFrameNpcNameText ref l.170) — not their own frames.
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// The window + its scroll frame (bar + child) + the 32-row shared pool (quest rows and option
// rows both draw from it, decision 0088 §3 — the reference's own NUMGOSSIPBUTTONS) + the close
// button + the GOODBYE button. The greeting and the NPC-name banner are FontString layers (the
// real GossipGreetingText ref l.241 / GossipFrameNpcNameText ref l.170) — not their own frames.
assert_eq!(
load_xml(&s, "GossipFrame.xml"),
11,
"window + 8 shared rows + close + goodbye"
40,
"window + scroll + bar (+2 arrows) + child + 32 rows + close + goodbye"
);
// Hidden by default: no gossip icon on screen.
@@ -233,6 +234,7 @@ fn shipped_gossip_frame_renders_quest_rows_above_options() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "GossipFrame.xml");
s.set_gossip(Some(GossipMenu {
@@ -332,6 +334,115 @@ fn shipped_gossip_frame_renders_quest_rows_above_options() {
);
}
/// A gossip option whose label WRAPS gets a row as tall as its wrapped text (the reference's
/// `GossipResize` — `SetHeight(GetTextHeight() + 2)`, ref-GossipFrame.lua l.130-132), so the static
/// row chain (each row on the previous row's BOTTOMLEFT) still stacks them clear of one another.
/// Without the resize every row stayed the template's 16 px while its label drew 2-3 wrapped lines,
/// and the labels printed on top of each other — the director's screenshot of a four-option
/// judgement menu, every option overlapping the next.
///
/// The engine-only harness has no font atlas, so the measure round-trip is answered with a
/// deterministic 6 px/char × 14 px/line fake (the app answers from the real atlas in-game). The
/// assertions pin the LAW — row height = its label's measured height + 2, and consecutive rows share
/// an edge — not any glyph metric.
#[test]
fn shipped_gossip_rows_grow_to_their_wrapped_labels() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "GossipFrame.xml");
// Three long options — the shape of a real judgement/roleplay menu, every one of them wrapping
// at the row label's 275 px width.
let long = |t: &str| GossipOptionView {
label: t.into(),
icon_type: "gossip".into(),
coded: false,
};
s.set_gossip(Some(GossipMenu {
greeting: Some("Make your choice!".into()),
quests: Vec::new(),
options: vec![
long(
"I slay the man on the spot as my liege would expect me to, as he has broken the \
law of the land and it is my sworn duty to enforce it.",
),
long(
"I turn over the man to my liege for punishment, as the man has stolen, and I am \
not the arbiter of his fate.",
),
long(
"I allow the man to take enough corn to feed his family for a couple of days, \
encouraging him to leave the land.",
),
],
}));
s.fire_event("GOSSIP_SHOW", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The host's job: measure every FontString the frame asks about. 6 px/char, wrapped at the
// request's own wrap width, 14 px per resulting line.
let answer_measures = |s: &mut UiScript| {
let answers: Vec<(u32, f32, f32, u64)> = s
.fontstrings_needing_measure()
.into_iter()
.map(|r| {
let ink = r.text.chars().count() as f32 * 6.0;
match r.wrap_width {
Some(w) => {
let lines = (ink / w).ceil().max(1.0);
(r.id, ink.min(w), lines * 14.0, r.key)
}
None => (r.id, ink, 14.0, r.key),
}
})
.collect();
s.set_measured_text(&answers);
};
// Frame 1: the labels are measured (their heights land for the NEXT tick — the round-trip is a
// frame late, exactly as the tab-fit and quest-panel resizes already live with).
answer_measures(&mut s);
s.resolve();
// Frame 2: the frame's own settle pass reads those measures and sizes each row.
s.tick(0.016);
answer_measures(&mut s);
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let row = |i: u32| -> (f32, f32, f32) {
s.eval::<(f32, f32, f32)>(&format!(
"return BenillaGossipRow{i}:GetTop(), BenillaGossipRow{i}:GetBottom(), \
BenillaGossipRow{i}Label:GetStringHeight()"
))
.unwrap()
};
let (t1, b1, h1) = row(1);
let (t2, b2, h2) = row(2);
let (t3, b3, h3) = row(3);
// Every label wrapped (the fake measure gives ≥ 2 lines of 14) …
for (i, h) in [(1, h1), (2, h2), (3, h3)] {
assert!(h >= 28.0, "row {i}'s label wraps: measured height {h}");
}
// … and each row is exactly its label + the reference's 2 px.
for (i, (top, bottom, h)) in [(1, (t1, b1, h1)), (2, (t2, b2, h2)), (3, (t3, b3, h3))] {
assert!(
(top - bottom - (h + 2.0)).abs() < 0.5,
"row {i} height is its wrapped label + 2: got {}, label {h}",
top - bottom
);
}
// The chain stacks them edge to edge — no row overprints the next (rects are y-up).
assert!(
(t2 - b1).abs() < 0.5,
"row 2 starts where row 1 ends: row1 bottom {b1}, row2 top {t2}"
);
assert!(
(t3 - b2).abs() < 0.5,
"row 3 starts where row 2 ends: row2 bottom {b2}, row3 top {t3}"
);
}
/// Pin §4's headline scenario, and §2's whole justification: with both windows loaded, opening
/// gossip then merchant closes gossip purely through panel replacement (both register
/// pushable=0, so the second `ShowUIPanel` replaces the left occupant — UIParent.lua l.729-732) —
@@ -345,6 +456,7 @@ fn gossip_show_hide_plays_open_and_close_kits() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "GossipFrame.xml");
// Hidden at load: no open sound (never transitions on startup).
@@ -380,6 +492,7 @@ fn shipped_panel_slot_replaces_gossip_with_merchant() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "GossipFrame.xml");
load_xml(&s, "GameTooltip.xml"); // app load order: tooltip before merchant
load_xml(&s, "MerchantFrame.xml");
@@ -461,6 +574,7 @@ fn displacing_an_npc_window_ends_the_displaced_session() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "GossipFrame.xml");
load_xml(&s, "GameTooltip.xml"); // app load order: tooltip before merchant
load_xml(&s, "MerchantFrame.xml");
@@ -599,6 +713,7 @@ fn gossip_bank_option_hands_the_left_slot_to_the_bank() {
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "BankFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "GossipFrame.xml");
// The gossip menu is open on the banker (its bank option showing).
@@ -652,3 +767,145 @@ fn gossip_bank_option_hands_the_left_slot_to_the_bank() {
order_first(true);
order_first(false);
}
/// A menu too tall for the parchment SCROLLS instead of spilling out of the window — the reference's
/// own answer (`GossipGreetingScrollFrame`, ref-GossipFrame.xml l.223-436), which benilla had omitted
/// on the grounds that "our short greeting needs no scrolling". Once the rows grew to their wrapped
/// text, a menu of long options ran straight off the bottom of the window and over the world (the
/// director's second screenshot).
///
/// Pins the whole mechanism: the content is measured into the scroll child, the range is the overflow,
/// the bar appears only when there IS overflow, every row is CLIPPED to the frame's rect, and
/// scrolling moves the content up under that clip. Same deterministic measure fake as the row test.
#[test]
fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "GossipFrame.xml");
// Eight wrapping options: ~4 lines each, far past the 334 px scroll frame.
let long = |n: usize| GossipOptionView {
label: format!(
"Option {n}: I slay the man on the spot as my liege would expect me to, as he has \
broken the law of the land and it is my sworn duty to enforce it, whatever the cost."
),
icon_type: "gossip".into(),
coded: false,
};
s.set_gossip(Some(GossipMenu {
greeting: Some("Make your choice!".into()),
quests: Vec::new(),
options: (1..=8).map(long).collect(),
}));
s.fire_event("GOSSIP_SHOW", vec![]);
let answer_measures = |s: &mut UiScript| {
let answers: Vec<(u32, f32, f32, u64)> = s
.fontstrings_needing_measure()
.into_iter()
.map(|r| {
let ink = r.text.chars().count() as f32 * 6.0;
match r.wrap_width {
Some(w) => (r.id, ink.min(w), (ink / w).ceil().max(1.0) * 14.0, r.key),
None => (r.id, ink, 14.0, r.key),
}
})
.collect();
s.set_measured_text(&answers);
};
// Settle: measures land, rows fit, the child is sized to them, the bar re-ranges.
for _ in 0..3 {
answer_measures(&mut s);
s.resolve();
s.tick(0.016);
}
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The child grew past the 334 px frame, so there is a range to scroll …
let (child_h, range): (f32, f32) = s
.eval(
"return BenillaGossipGreetingScrollChild:GetHeight(), \
BenillaGossipGreetingScroll:GetVerticalScrollRange()",
)
.unwrap();
assert!(
child_h > 334.0 && range > 0.0,
"content overflows the 334px frame: child {child_h}, range {range}"
);
// … and the bar is up for it (it stays hidden when everything fits — the fit case is covered by
// `shipped_gossip_frame_drives_end_to_end`'s two-option menu).
assert!(
s.eval::<bool>("return BenillaGossipGreetingScrollBar:IsVisible()")
.unwrap(),
"the scrollbar shows once the menu overflows"
);
// Every row is CLIPPED to the scroll frame — the mechanism that replaces "spilling out of the
// window". The engine clips by carrying a clip rect on the quad (decision 0112 §4/§5, applied in
// `ui_pass`), not by shrinking its rect, so the check is on the clip each row's text quad rides
// out with: it must be the scroll frame's own rect, and rows past the bottom must be entirely
// outside it (nothing of them survives the clip).
let (frame_top, frame_bottom): (f32, f32) = s
.eval(
"return BenillaGossipGreetingScroll:GetTop(), BenillaGossipGreetingScroll:GetBottom()",
)
.unwrap();
let painted_rows =
|s: &mut UiScript| -> Vec<(benilla_ui::layout::Rect, benilla_ui::layout::Rect)> {
s.extract()
.into_iter()
.filter_map(|q| match &q.content {
QuadContent::Text { text: Some(t), .. } if t.starts_with("Option ") => {
Some((q.rect?, q.clip?))
}
_ => None,
})
.collect()
};
let rows = painted_rows(&mut s);
assert_eq!(rows.len(), 8, "all eight option labels extract");
for (rect, clip) in &rows {
assert!(
(clip.top - frame_top).abs() < 0.5 && (clip.bottom - frame_bottom).abs() < 0.5,
"row clipped to the scroll frame [{frame_bottom}, {frame_top}], got {clip:?}"
);
let _ = rect;
}
// And the menu really is longer than the window: at least one row sits entirely below the
// frame's bottom edge (drawn nowhere, because the clip discards it) rather than over the world.
assert!(
rows.iter().any(|(rect, _)| rect.top < frame_bottom),
"the menu overflows: some row is entirely below the frame"
);
// Scrolling pans the content up under that clip by exactly the scroll amount. Rects are y-up
// (`ExtractedQuad::rect`), so "up" means row 1's top EDGE VALUE grows as it slides off the top.
let first_top =
|s: &mut UiScript| -> f32 { s.eval::<f32>("return BenillaGossipRow1:GetTop()").unwrap() };
let before = first_top(&mut s);
s.run("BenillaScroll_Step(BenillaGossipGreetingScroll, 100)")
.unwrap();
s.resolve();
let after = first_top(&mut s);
assert!(
(after - before - 100.0).abs() < 0.5,
"scrolling 100px lifts the content 100px: {before} → {after}"
);
// The bar followed the scroll (SyncBar off OnVerticalScroll), and the rows are still clipped.
assert_eq!(
s.eval::<f32>("return BenillaGossipGreetingScrollBar:GetValue()")
.unwrap(),
100.0,
"the bar seats at the scroll offset"
);
for (_, clip) in painted_rows(&mut s) {
assert!(
(clip.top - frame_top).abs() < 0.5 && (clip.bottom - frame_bottom).abs() < 0.5,
"a scrolled row still clips to the frame, got {clip:?}"
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -38,6 +38,7 @@ fn questgiver_show_hide_plays_open_and_close_kits() {
// The BenillaMoney_* purse helpers the quest reward/progress panels repaint through live in
// MerchantFrame.xml (the same documented cross-window dep the bag tests load).
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
// Hidden at load: no open sound (never transitions on startup).
@@ -95,6 +96,7 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
let panels = [
@@ -167,6 +169,7 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
let choice = |name: &str, quality: u32| benilla_ui::script::QuestItemView {
@@ -250,6 +253,7 @@ fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
let choice = |name: &str| benilla_ui::script::QuestItemView {
@@ -310,6 +314,7 @@ fn greeting_goodbye_button_closes_the_window() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
s.set_quest(Some(QuestState {
@@ -346,6 +351,7 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
s.set_quest(Some(QuestState {
panel: QuestPanel::Detail,
@@ -453,6 +459,7 @@ fn write_on_still_fades_when_instant_text_is_off() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
s.eval::<()>(r#"QUEST_FADING_DISABLE = "0""#).unwrap();
s.set_quest(Some(QuestState {
@@ -512,6 +519,7 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
s.set_quest(Some(QuestState {
@@ -561,3 +569,80 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
"the QUEST_ITEM_UPDATE refresh also carries the name"
);
}
/// The gossip window's twin law, on the greeting panel's quest-title rows: a title long enough to
/// WRAP at the row label's 275 px must grow its row, or the static/Lua-chained rows below print
/// through it (the shape of the gossip overlap the director reported). Same deterministic
/// 6 px/char × 14 px/line measure fake as the gossip row test.
#[test]
fn greeting_panel_title_rows_grow_to_their_wrapped_titles() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
s.set_quest(Some(QuestState {
panel: QuestPanel::Greeting,
greeting: "What can I do for you?".into(),
active_titles: vec![
"Deliver Thomas' Report to Marshal Dughan in Goldshire before the Defias move again"
.into(),
"Investigate the Echo Ridge Mine and report back to Marshal McBride at once".into(),
],
..QuestState::default()
}));
s.fire_event(
"QUEST_GREETING",
vec![ScriptValue::Str("Deputy Willem".into())],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let answer_measures = |s: &mut UiScript| {
let answers: Vec<(u32, f32, f32, u64)> = s
.fontstrings_needing_measure()
.into_iter()
.map(|r| {
let ink = r.text.chars().count() as f32 * 6.0;
match r.wrap_width {
Some(w) => (r.id, ink.min(w), (ink / w).ceil().max(1.0) * 14.0, r.key),
None => (r.id, ink, 14.0, r.key),
}
})
.collect();
s.set_measured_text(&answers);
};
answer_measures(&mut s);
s.resolve();
s.tick(0.016);
answer_measures(&mut s);
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let row = |i: u32| -> (f32, f32, f32) {
s.eval::<(f32, f32, f32)>(&format!(
"local b = getglobal('BenillaQuestTitleButton{i}')\n\
return b:GetTop(), b:GetBottom(), b:GetFontString():GetStringHeight()"
))
.unwrap()
};
let (t1, b1, h1) = row(1);
let (t2, b2, h2) = row(2);
assert!(h1 >= 28.0 && h2 >= 28.0, "both titles wrap: {h1}, {h2}");
assert!(
(t1 - b1 - (h1 + 2.0)).abs() < 0.5,
"row 1 is its wrapped title + 2: got {}, title {h1}",
t1 - b1
);
assert!(
(t2 - b2 - (h2 + 2.0)).abs() < 0.5,
"row 2 is its wrapped title + 2: got {}, title {h2}",
t2 - b2
);
assert!(
t2 <= b1 + 0.5,
"row 2 starts at/below row 1's bottom: row1 bottom {b1}, row2 top {t2}"
);
}
@@ -488,3 +488,41 @@ fn wheel_scroll_is_silent_but_the_arrows_click() {
"the arrow button clicks (UChatScrollButton)"
);
}
/// The scrollbar ARROWS move the list the way they point — the direction half the sound test
/// above never checked. They were inverted in the shared kit (ScrollTemplates.xml): at the top the
/// up arrow correctly greys out, but the *enabled* down arrow called `Step(bar, -1)`, decrementing
/// a value already clamped at its minimum — so both arrows were dead in every faux-scroll window
/// (the wheel and the thumb drag masked it, and the sound test passed because OnClick still fired).
#[test]
fn the_scrollbar_arrows_step_the_list_the_way_they_point() {
let mut s = trainer_script();
s.set_money(50);
s.set_trainer(Some(long_menu())); // 16 rows > 11 visible → the bar shows
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Sana".into())]);
let offset = |s: &mut UiScript| {
s.eval::<i64>("return BenillaTrainerListScrollFrame.offset or -1")
.unwrap()
};
let click = |s: &mut UiScript, which: &str| {
s.run(&format!(
"BenillaTrainerListScrollFrameScrollBarScroll{which}Button:Click()"
))
.unwrap();
};
assert_eq!(offset(&mut s), 0, "opens at the top");
click(&mut s, "Down");
assert_eq!(
offset(&mut s),
1,
"the down arrow advances the list one row"
);
click(&mut s, "Down");
assert_eq!(offset(&mut s), 2);
click(&mut s, "Up");
assert_eq!(offset(&mut s), 1, "the up arrow walks it back");
click(&mut s, "Up");
click(&mut s, "Up");
assert_eq!(offset(&mut s), 0, "and stops at the top, never past it");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}