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:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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((
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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![]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user