From b82cc3dd4faf7ba21304cec4d219ed8d7764f88e Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 4 Aug 2026 18:54:16 +0700 Subject: [PATCH] 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 --- crates/benilla-assets/src/m2.rs | 49 +- crates/benilla-assets/src/model.rs | 109 ++- crates/benilla-assets/src/model/anims.rs | 82 ++ .../src/bin/benilla-extract/main.rs | 25 + .../src/bin/benilla-extract/scan.rs | 272 +++++++ crates/benilla-formats/src/item_visuals.rs | 157 +++- crates/benilla-formats/src/lib.rs | 27 +- crates/benilla-formats/src/models/anim.rs | 34 + .../benilla-formats/src/models/m2_batches.rs | 51 ++ .../src/spells/catalog_tests.rs | 33 + crates/benilla-protocol/src/messages/items.rs | 19 +- .../benilla-protocol/src/messages/spells.rs | 36 +- crates/benilla-protocol/tests/items.rs | 17 +- crates/benilla-ui/src/script/cursor.rs | 38 + crates/benilla-ui/src/script/mod.rs | 4 +- crates/benilla-ui/src/script/model.rs | 4 + crates/benilla/assets/ui/EnchantConfirm.xml | 93 +++ crates/benilla/assets/ui/GossipFrame.xml | 245 ++++-- crates/benilla/assets/ui/QuestFrame.xml | 69 +- crates/benilla/assets/ui/ScrollTemplates.xml | 111 ++- crates/benilla/src/capture/fixtures.rs | 33 +- crates/benilla/src/doodad_anim/mat_anim.rs | 9 + crates/benilla/src/entities.rs | 12 + .../benilla/src/entities/equipment/spawn.rs | 2 + crates/benilla/src/entities/item_glow.rs | 6 +- crates/benilla/src/items.rs | 9 + crates/benilla/src/model_render.rs | 93 ++- crates/benilla/src/particles.rs | 72 +- crates/benilla/src/particles/model.rs | 18 +- crates/benilla/src/pipe_warm/menagerie.rs | 708 ++++++++++++++++++ .../src/{pipe_warm.rs => pipe_warm/mod.rs} | 280 +------ crates/benilla/src/portrait/mod.rs | 16 +- crates/benilla/src/preflight.rs | 45 +- crates/benilla/src/sound/zone.rs | 79 +- crates/benilla/src/target/mod.rs | 6 +- crates/benilla/src/ui_action/cast_send.rs | 93 ++- crates/benilla/src/ui_action/cast_target.rs | 160 ++-- crates/benilla/src/ui_action/mod.rs | 1 + crates/benilla/src/ui_action/targeting.rs | 684 ----------------- .../benilla/src/ui_action/targeting/cursor.rs | 206 +++++ .../benilla/src/ui_action/targeting/item.rs | 569 ++++++++++++++ crates/benilla/src/ui_action/targeting/mod.rs | 325 ++++++++ .../benilla/src/ui_action/targeting/world.rs | 285 +++++++ .../src/ui_script/enchant_confirm_tests.rs | 116 +++ crates/benilla/src/ui_script/mod.rs | 7 + crates/benilla/src/ui_script/panel_tests.rs | 269 ++++++- crates/benilla/src/ui_script/quest_tests.rs | 85 +++ crates/benilla/src/ui_script/trainer_tests.rs | 38 + 48 files changed, 4442 insertions(+), 1259 deletions(-) create mode 100644 crates/benilla/assets/ui/EnchantConfirm.xml create mode 100644 crates/benilla/src/pipe_warm/menagerie.rs rename crates/benilla/src/{pipe_warm.rs => pipe_warm/mod.rs} (59%) delete mode 100644 crates/benilla/src/ui_action/targeting.rs create mode 100644 crates/benilla/src/ui_action/targeting/cursor.rs create mode 100644 crates/benilla/src/ui_action/targeting/item.rs create mode 100644 crates/benilla/src/ui_action/targeting/mod.rs create mode 100644 crates/benilla/src/ui_action/targeting/world.rs create mode 100644 crates/benilla/src/ui_script/enchant_confirm_tests.rs diff --git a/crates/benilla-assets/src/m2.rs b/crates/benilla-assets/src/m2.rs index 498c2091..917f3097 100644 --- a/crates/benilla-assets/src/m2.rs +++ b/crates/benilla-assets/src/m2.rs @@ -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 = 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. diff --git a/crates/benilla-assets/src/model.rs b/crates/benilla-assets/src/model.rs index 56b1c98b..27d894cb 100644 --- a/crates/benilla-assets/src/model.rs +++ b/crates/benilla-assets/src/model.rs @@ -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, /// 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) { +/// +/// ## 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) { let pivots = skeleton_pivots(skel); let joints = skel .bones @@ -303,7 +323,9 @@ pub(crate) fn build_skeleton(skel: &Skeleton) -> (ModelSkeleton, Vec) { 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" + ); + } } diff --git a/crates/benilla-assets/src/model/anims.rs b/crates/benilla-assets/src/model/anims.rs index 4ff53276..b977cecd 100644 --- a/crates/benilla-assets/src/model/anims.rs +++ b/crates/benilla-assets/src/model/anims.rs @@ -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 { + 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) -> 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. diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs index 40f9743e..518c1c6b 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/main.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -293,6 +293,29 @@ enum Command { /// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted. prefix: Option, }, + /// 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, + }, + /// 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, + }, /// 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}'"))?; diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan.rs b/crates/benilla-formats/src/bin/benilla-extract/scan.rs index 4163d04b..88357891 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan.rs @@ -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 = 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>> = BTreeMap::new(); + let mut fam_transparent: BTreeMap = BTreeMap::new(); + let mut fam_opaque: BTreeMap = BTreeMap::new(); + let mut rungs: BTreeMap = BTreeMap::new(); + let mut unresolved: Vec = 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 = 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::>() + .into_iter() + .collect::>() + .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 = 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(()) +} diff --git a/crates/benilla-formats/src/item_visuals.rs b/crates/benilla-formats/src/item_visuals.rs index 0f9cc48a..581b67fc 100644 --- a/crates/benilla-formats/src/item_visuals.rs +++ b/crates/benilla-formats/src/item_visuals.rs @@ -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, names: HashMap, + /// `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, } +/// `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, names: HashMap) -> 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, + names: HashMap, + flags: HashMap, + ) -> 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 { )?; 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 { 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 = + (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)); + } } diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 2268195a..cce93e51 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -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::{ diff --git a/crates/benilla-formats/src/models/anim.rs b/crates/benilla-formats/src/models/anim.rs index b16e4608..f9d2e8ae 100644 --- a/crates/benilla-formats/src/models/anim.rs +++ b/crates/benilla-formats/src/models/anim.rs @@ -376,6 +376,40 @@ pub struct ModelAnimation { pub events: Vec, } +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 diff --git a/crates/benilla-formats/src/models/m2_batches.rs b/crates/benilla-formats/src/models/m2_batches.rs index 18336fed..bf338130 100644 --- a/crates/benilla-formats/src/models/m2_batches.rs +++ b/crates/benilla-formats/src/models/m2_batches.rs @@ -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") diff --git a/crates/benilla-formats/src/spells/catalog_tests.rs b/crates/benilla-formats/src/spells/catalog_tests.rs index 9a772887..3ba30743 100644 --- a/crates/benilla-formats/src/spells/catalog_tests.rs +++ b/crates/benilla-formats/src/spells/catalog_tests.rs @@ -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::::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::(), + 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); diff --git a/crates/benilla-protocol/src/messages/items.rs b/crates/benilla-protocol/src/messages/items.rs index 16b8bb52..d6421d03 100644 --- a/crates/benilla-protocol/src/messages/items.rs +++ b/crates/benilla-protocol/src/messages/items.rs @@ -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) => { diff --git a/crates/benilla-protocol/src/messages/spells.rs b/crates/benilla-protocol/src/messages/spells.rs index 95f1e4f8..45944fb7 100644 --- a/crates/benilla-protocol/src/messages/spells.rs +++ b/crates/benilla-protocol/src/messages/spells.rs @@ -321,21 +321,24 @@ pub fn cast_spell(spell_id: u32, target: Option) -> Vec { 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 { 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]); } diff --git a/crates/benilla-protocol/tests/items.rs b/crates/benilla-protocol/tests/items.rs index 2c22fcb9..646aa0f3 100644 --- a/crates/benilla-protocol/tests/items.rs +++ b/crates/benilla-protocol/tests/items.rs @@ -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 diff --git a/crates/benilla-ui/src/script/cursor.rs b/crates/benilla-ui/src/script/cursor.rs index e0efd7e9..8d3e90d9 100644 --- a/crates/benilla-ui/src/script/cursor.rs +++ b/crates/benilla-ui/src/script/cursor.rs @@ -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 { + 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::().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 diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 2fd336e4..d19caa49 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 145c52b8..4dd3a0c4 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -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, /// `(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(), diff --git a/crates/benilla/assets/ui/EnchantConfirm.xml b/crates/benilla/assets/ui/EnchantConfirm.xml new file mode 100644 index 00000000..0cdcfaa1 --- /dev/null +++ b/crates/benilla/assets/ui/EnchantConfirm.xml @@ -0,0 +1,93 @@ + + + + + + + + + + self:RegisterEvent("BIND_ENCHANT") + self:RegisterEvent("REPLACE_ENCHANT") + self:RegisterEvent("CURRENT_SPELL_CAST_CHANGED") + + BenillaEnchantConfirmDriver_OnEvent() + + + diff --git a/crates/benilla/assets/ui/GossipFrame.xml b/crates/benilla/assets/ui/GossipFrame.xml index b826eede..ffd800d6 100644 --- a/crates/benilla/assets/ui/GossipFrame.xml +++ b/crates/benilla/assets/ui/GossipFrame.xml @@ -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 `` 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 + ``, 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 — ShowUIPanel/SetLeftFrame SetPoints it to the left slot (TOPLEFT UIParent, 0, -104) on open; every show goes through @@ -62,9 +62,20 @@ --> @@ -135,7 +230,7 @@ - BenillaFauxScrollBar_Step(this:GetParent(), 1); PlaySound("UChatScrollButton") + BenillaScrollBar_Step(this:GetParent(), -1); PlaySound("UChatScrollButton") diff --git a/crates/benilla/src/capture/fixtures.rs b/crates/benilla/src/capture/fixtures.rs index 5f9a9bf6..422668c4 100644 --- a/crates/benilla/src/capture/fixtures.rs +++ b/crates/benilla/src/capture/fixtures.rs @@ -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 diff --git a/crates/benilla/src/doodad_anim/mat_anim.rs b/crates/benilla/src/doodad_anim/mat_anim.rs index 8eb228fb..fabd01cd 100644 --- a/crates/benilla/src/doodad_anim/mat_anim.rs +++ b/crates/benilla/src/doodad_anim/mat_anim.rs @@ -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 diff --git a/crates/benilla/src/entities.rs b/crates/benilla/src/entities.rs index 5e0bfc15..8277a086 100644 --- a/crates/benilla/src/entities.rs +++ b/crates/benilla/src/entities.rs @@ -179,6 +179,18 @@ pub(crate) struct CubeAssets { npc_mat: Handle, } +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, [Handle; 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)] diff --git a/crates/benilla/src/entities/equipment/spawn.rs b/crates/benilla/src/entities/equipment/spawn.rs index 337d645e..43f9ef0e 100644 --- a/crates/benilla/src/entities/equipment/spawn.rs +++ b/crates/benilla/src/entities/equipment/spawn.rs @@ -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); diff --git a/crates/benilla/src/entities/item_glow.rs b/crates/benilla/src/entities/item_glow.rs index 0e4c9354..88ba96a9 100644 --- a/crates/benilla/src/entities/item_glow.rs +++ b/crates/benilla/src/entities/item_glow.rs @@ -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(), + ), ) } diff --git a/crates/benilla/src/items.rs b/crates/benilla/src/items.rs index 76bd52b9..d448c295 100644 --- a/crates/benilla/src/items.rs +++ b/crates/benilla/src/items.rs @@ -81,6 +81,14 @@ pub(crate) fn enchant_lines( let lines: Vec = 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 `). 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| -> Vec { v.into_iter().map(|e| e.name).collect() diff --git a/crates/benilla/src/model_render.rs b/crates/benilla/src/model_render.rs index 02a1c0bf..847d70da 100644 --- a/crates/benilla/src/model_render.rs +++ b/crates/benilla/src/model_render.rs @@ -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"); diff --git a/crates/benilla/src/particles.rs b/crates/benilla/src/particles.rs index ad48a961..9e726163 100644 --- a/crates/benilla/src/particles.rs +++ b/crates/benilla/src/particles.rs @@ -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 { + 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::(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 { diff --git a/crates/benilla/src/particles/model.rs b/crates/benilla/src/particles/model.rs index 35fd28e9..2e9913d8 100644 --- a/crates/benilla/src/particles/model.rs +++ b/crates/benilla/src/particles/model.rs @@ -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(( diff --git a/crates/benilla/src/pipe_warm/menagerie.rs b/crates/benilla/src/pipe_warm/menagerie.rs new file mode 100644 index 00000000..99741a77 --- /dev/null +++ b/crates/benilla/src/pipe_warm/menagerie.rs @@ -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>, + stars: ResMut<'w, Assets>, + clouds: ResMut<'w, Assets>, + sky: ResMut<'w, Assets>, + liquid: ResMut<'w, Assets>, + skybox: ResMut<'w, Assets>, + /// 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>, + /// The fallback cube (0938): production mesh + materials, drawn while a model streams. + cubes: Option>, +} + +/// 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, +>; + +/// 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, + materials: &mut Assets, + 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, Option, 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> = 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> = 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, + src: &[Handle], +) -> Vec> { + let twins: Vec = 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(assets: &mut Assets) -> Vec> { + let ids: Vec> = 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, + mesh: &Handle, + aabb: &Option, + skinned: bool, + mat: &Handle, +) { + 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( + commands: &mut Commands, + cam: Entity, + layers: Option, + mesh: &Handle, + aabb: Option<&bevy::camera::primitives::Aabb>, + mat: Handle, + 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::`) 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)" + ); + } +} diff --git a/crates/benilla/src/pipe_warm.rs b/crates/benilla/src/pipe_warm/mod.rs similarity index 59% rename from crates/benilla/src/pipe_warm.rs rename to crates/benilla/src/pipe_warm/mod.rs index b1f1341a..a05d0820 100644 --- a/crates/benilla/src/pipe_warm.rs +++ b/crates/benilla/src/pipe_warm/mod.rs @@ -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>, mut meshes: ResMut>, mut materials: ResMut>, + mut lanes: WarmLanes, + booth: BoothCamQuery, + mut gizmos: Gizmos, mut cache: Local, shared_light: Option>, ) { @@ -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, - materials: &mut Assets, - 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, Option, 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> = 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, - } -} diff --git a/crates/benilla/src/portrait/mod.rs b/crates/benilla/src/portrait/mod.rs index 7b74414d..08f99117 100644 --- a/crates/benilla/src/portrait/mod.rs +++ b/crates/benilla/src/portrait/mod.rs @@ -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, preview: Res, images: Res>, + warm: Res, time: Res>, mut cams: Query<(&BoothCam, &mut Camera)>, mut env_cache: Local>, ) { 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 = 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(), } diff --git a/crates/benilla/src/preflight.rs b/crates/benilla/src/preflight.rs index 88da6458..d0119aed 100644 --- a/crates/benilla/src/preflight.rs +++ b/crates/benilla/src/preflight.rs @@ -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`, 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] diff --git a/crates/benilla/src/sound/zone.rs b/crates/benilla/src/sound/zone.rs index 3959a7e1..2cb1b2a3 100644 --- a/crates/benilla/src/sound/zone.rs +++ b/crates/benilla/src/sound/zone.rs @@ -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, 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>, + /// 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) -> 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) { 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))); + } +} diff --git a/crates/benilla/src/target/mod.rs b/crates/benilla/src/target/mod.rs index 1f1ec071..1d7546f3 100644 --- a/crates/benilla/src/target/mod.rs +++ b/crates/benilla/src/target/mod.rs @@ -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, diff --git a/crates/benilla/src/ui_action/cast_send.rs b/crates/benilla/src/ui_action/cast_send.rs index c33bf4c3..ed32e0cd 100644 --- a/crates/benilla/src/ui_action/cast_send.rs +++ b/crates/benilla/src/ui_action/cast_send.rs @@ -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::() - .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), + .. + }) + )); } } diff --git a/crates/benilla/src/ui_action/cast_target.rs b/crates/benilla/src/ui_action/cast_target.rs index 9f1d8e0c..88387a44 100644 --- a/crates/benilla/src/ui_action/cast_target.rs +++ b/crates/benilla/src/ui_action/cast_target.rs @@ -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> { diff --git a/crates/benilla/src/ui_action/mod.rs b/crates/benilla/src/ui_action/mod.rs index 81b2b53b..0aa5504c 100644 --- a/crates/benilla/src/ui_action/mod.rs +++ b/crates/benilla/src/ui_action/mod.rs @@ -244,6 +244,7 @@ impl Plugin for UiActionPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_systems(Startup, load_spells.after(AssetSet::Open)) .add_systems( Update, diff --git a/crates/benilla/src/ui_action/targeting.rs b/crates/benilla/src/ui_action/targeting.rs deleted file mode 100644 index 00724e24..00000000 --- a/crates/benilla/src/ui_action/targeting.rs +++ /dev/null @@ -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); - -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 { - 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 { - 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, - occlusion: Res, - spells: Option>, - self_tf: Query<&Transform, With>, - mut cursor: ResMut, -) { - 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, - occlusion: Res, - 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 { - 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>, - self_q: Query<&crate::net::ObjectStore, With>, - 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, - payload_held: Res, - mut targeting: ResMut, -) { - 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, - script: Option>, -) { - 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, - script: Option>, -) { - 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); - } -} diff --git a/crates/benilla/src/ui_action/targeting/cursor.rs b/crates/benilla/src/ui_action/targeting/cursor.rs new file mode 100644 index 00000000..3c1d3540 --- /dev/null +++ b/crates/benilla/src/ui_action/targeting/cursor.rs @@ -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, + occlusion: Res, + spells: Option>, + self_tf: Query<&Transform, With>, + mut cursor: ResMut, +) { + let Some(spell_id) = targeting.spell() else { + return; + }; + let unable = targeting.wants(TargetingWants::Location) + && !match (occlusion.point, self_tf.single()) { + (Some(point), Ok(tf)) => ground_point_in_range( + range_row(spells.as_deref(), spell_id), + tf.translation, + point, + ), + _ => 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); + } +} diff --git a/crates/benilla/src/ui_action/targeting/item.rs b/crates/benilla/src/ui_action/targeting/item.rs new file mode 100644 index 00000000..b45c582b --- /dev/null +++ b/crates/benilla/src/ui_action/targeting/item.rs @@ -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); + +/// 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; 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, + 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>, + self_q: Query<&crate::net::ObjectStore, With>, + enchants: Option>, + mut parked: ResMut, + 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 = 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 { + 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 { + 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) + ); + } +} diff --git a/crates/benilla/src/ui_action/targeting/mod.rs b/crates/benilla/src/ui_action/targeting/mod.rs new file mode 100644 index 00000000..96c54be6 --- /dev/null +++ b/crates/benilla/src/ui_action/targeting/mod.rs @@ -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); + +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 { + 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, + payload_held: Res, + mut targeting: ResMut, +) { + 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, + mut last: Local>, + script: Option>, +) { + 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, + script: Option>, +) { + 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)); + } +} diff --git a/crates/benilla/src/ui_action/targeting/world.rs b/crates/benilla/src/ui_action/targeting/world.rs new file mode 100644 index 00000000..c6027bc8 --- /dev/null +++ b/crates/benilla/src/ui_action/targeting/world.rs @@ -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, + occlusion: Res, + 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, + hovered: Res, + hovered_object: Res, + 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, SystemId) { + let (tx, rx) = crossbeam_channel::unbounded(); + let mut world = World::new(); + world.insert_resource(NetCommands(tx)); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::>(); + world.init_resource::>(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + // 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::>() + .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::().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::().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::() + .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::() + .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::().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::() + .enter(OPENING, commit, LOCK_WORD); + click(&mut world, id); + assert!(rx.try_recv().is_err(), "no object, no bind"); + assert!(world.resource::().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::() + .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::().active()); + } +} diff --git a/crates/benilla/src/ui_script/enchant_confirm_tests.rs b/crates/benilla/src/ui_script/enchant_confirm_tests.rs new file mode 100644 index 00000000..00ba794a --- /dev/null +++ b/crates/benilla/src/ui_script/enchant_confirm_tests.rs @@ -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/` 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::("return StaticPopup1:IsVisible()").unwrap(), + "BIND_ENCHANT shows the bind warning" + ); + assert_eq!( + s.eval::("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::("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::("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::("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::("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::("return StaticPopup1:IsVisible()").unwrap()); + assert_eq!(s.take_enchant_confirms(), vec![]); +} diff --git a/crates/benilla/src/ui_script/mod.rs b/crates/benilla/src/ui_script/mod.rs index 2d49d8ae..439f8497 100644 --- a/crates/benilla/src/ui_script/mod.rs +++ b/crates/benilla/src/ui_script/mod.rs @@ -404,6 +404,10 @@ fn load_default_ui(script: &UiScript) -> Vec { // 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; diff --git a/crates/benilla/src/ui_script/panel_tests.rs b/crates/benilla/src/ui_script/panel_tests.rs index a8d235b2..4688365a 100644 --- a/crates/benilla/src/ui_script/panel_tests.rs +++ b/crates/benilla/src/ui_script/panel_tests.rs @@ -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::("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::("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::("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()); +} diff --git a/crates/benilla/src/ui_script/quest_tests.rs b/crates/benilla/src/ui_script/quest_tests.rs index f78ca8cf..5cf8cb07 100644 --- a/crates/benilla/src/ui_script/quest_tests.rs +++ b/crates/benilla/src/ui_script/quest_tests.rs @@ -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}" + ); +} diff --git a/crates/benilla/src/ui_script/trainer_tests.rs b/crates/benilla/src/ui_script/trainer_tests.rs index 7fc47579..a1c8ee23 100644 --- a/crates/benilla/src/ui_script/trainer_tests.rs +++ b/crates/benilla/src/ui_script/trainer_tests.rs @@ -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::("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()); +}