diff --git a/crates/benilla-app/assets/ui/FriendsFrame.xml b/crates/benilla-app/assets/ui/FriendsFrame.xml index b31c31d3..b85d8595 100644 --- a/crates/benilla-app/assets/ui/FriendsFrame.xml +++ b/crates/benilla-app/assets/ui/FriendsFrame.xml @@ -343,10 +343,10 @@ -- CHARACTER window's bottom art rather than art of its own — there is no -- RaidFrame-BotLeft in the patch chain, because the pane is a grid on a plain -- parchment rather than a list in a frame. - FriendsFrameTopLeft:SetTexture("Interface\PaperDollInfoFrame\UI-Character-General-TopLeft") - FriendsFrameTopRight:SetTexture("Interface\PaperDollInfoFrame\UI-Character-General-TopRight") - FriendsFrameBottomLeft:SetTexture("Interface\PaperDollInfoFrame\UI-Character-General-BottomLeft") - FriendsFrameBottomRight:SetTexture("Interface\PaperDollInfoFrame\UI-Character-General-BottomRight") + FriendsFrameTopLeft:SetTexture("Interface\\PaperDollInfoFrame\\UI-Character-General-TopLeft") + FriendsFrameTopRight:SetTexture("Interface\\PaperDollInfoFrame\\UI-Character-General-TopRight") + FriendsFrameBottomLeft:SetTexture("Interface\\PaperDollInfoFrame\\UI-Character-General-BottomLeft") + FriendsFrameBottomRight:SetTexture("Interface\\PaperDollInfoFrame\\UI-Character-General-BottomRight") FriendsFrameTitleText:SetText(RAID) FriendsFrame_ShowSubFrame("RaidFrame") end diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index 647d68b3..91f1b0c7 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -1315,6 +1315,7 @@ fn seed_bag_window( readable: false, creator: None, flags: 0, + already_bound: false, enchants: Vec::new(), } }; @@ -1404,6 +1405,7 @@ fn seed_equipped_bags( readable: false, creator: None, flags: 0, + already_bound: false, enchants: Vec::new(), }; diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index 76c3e835..95ead16f 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -122,8 +122,8 @@ pub(crate) use probe_taxi::ProbeTaxiPlugin; pub(crate) use probes::{ fx_draw_census_plugin, DressCensusPlugin, EntityCensusPlugin, GroundCensusPlugin, LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin, ProbeChatPlugin, ProbeClock, - ProbeExitPlugin, ProbeFocusPlugin, ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin, - RevealAuditPlugin, SchedCensusPlugin, UnitVisualsPlugin, + ProbeDragPlugin, ProbeExitPlugin, ProbeFocusPlugin, ProbeKeyPlugin, ProbeLuaPlugin, + ProbeResizePlugin, RevealAuditPlugin, SchedCensusPlugin, UnitVisualsPlugin, }; use scenarios::GlueScreen; use scenarios::{Scenario, SubjectKind, UiFixture, GLUE_SCENARIOS, GROUND_EYE, SCENARIOS}; diff --git a/crates/benilla-app/src/capture/probes/act.rs b/crates/benilla-app/src/capture/probes/act.rs index f7d1a923..c2da87aa 100644 --- a/crates/benilla-app/src/capture/probes/act.rs +++ b/crates/benilla-app/src/capture/probes/act.rs @@ -369,3 +369,205 @@ fn fire_probe_lua( error!("probe-lua: {e}"); } } + +/// The PROBE DRAG driver (`WOW_PROBE_DRAG="A>B[;C>D…]"`, delay via `WOW_PROBE_DRAG_AT` seconds +/// (default 14), one gesture step per `WOW_PROBE_DRAG_STEP` seconds (default 0.1)): drag one named +/// frame onto another **through the real pointer path**, headlessly, in a live session. +/// +/// **Why this exists as an instrument rather than a unit test.** The UI's own harness can already +/// press, move and release ([`UiScript::mouse_button`]/`mouse_move`), and a drag test written +/// there passes while the live client's drag is broken — because the harness supplies the whole +/// world: no per-frame app feed between the press and the release, no real frames elapsing, no +/// resolve pass, no `feed_party` re-push, no OS cursor. That gap cost a session (B310: the raid +/// grid's second drag). This closes it by driving the SAME gesture in a real client: the press, +/// the threshold-crossing move, the path, and the release each land on their own frame, with +/// every app system running in between exactly as it does for a hand on the mouse. +/// +/// It names FRAMES, not coordinates, and reads their centres out of the live layout, so a probe +/// line survives a window moving. `WOW_PROBE_DRAG_LUA=""` runs a chunk after each gesture — +/// the report channel (`ProbeLog` from [`ProbeLuaPlugin`] is installed by that probe; this one +/// prints the chunk's own returned string). +pub(crate) struct ProbeDragPlugin; + +impl Plugin for ProbeDragPlugin { + fn build(&self, app: &mut App) { + let pairs = std::env::var("WOW_PROBE_DRAG") + .unwrap_or_default() + .split(';') + .filter_map(|p| p.split_once('>')) + .map(|(a, b)| (a.trim().to_string(), b.trim().to_string())) + .collect(); + let at = std::env::var("WOW_PROBE_DRAG_AT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(14.0); + let step = std::env::var("WOW_PROBE_DRAG_STEP") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(0.1); + app.insert_resource(ProbeDrag { + pairs, + at, + step, + report: std::env::var("WOW_PROBE_DRAG_LUA").unwrap_or_default(), + pair: 0, + phase: 0, + next: 0.0, + from: (0.0, 0.0), + to: (0.0, 0.0), + }) + .add_systems(Update, fire_probe_drag); + } +} + +/// [`ProbeDragPlugin`]'s state machine: which pair, which step of the gesture, and when the next +/// step is due. +#[derive(Resource)] +struct ProbeDrag { + pairs: Vec<(String, String)>, + at: f32, + step: f32, + report: String, + pair: usize, + /// The step within the current gesture — see [`fire_probe_drag`]'s table. + phase: usize, + next: f32, + from: (f32, f32), + to: (f32, f32), +} + +/// One frame's centre in the VM's own units, read out of the live layout (`GetLeft`/`GetRight`/ +/// `GetTop`/`GetBottom` — the same space [`crate::ui_script::input`] feeds the cursor in). +/// `None` when the frame does not exist or has no resolved rect. +fn frame_centre(script: &benilla_ui::script::UiScript, name: &str) -> Option<(f32, f32)> { + let read = |edge: &str| { + script + .eval::(&format!( + "local f = getglobal(\"{name}\") return f and f:Get{edge}()" + )) + .ok() + }; + Some(( + (read("Left")? + read("Right")?) / 2.0, + (read("Top")? + read("Bottom")?) / 2.0, + )) +} + +/// Advance the scripted drag by at most one step per `probe.step` seconds. +/// +/// The gesture is deliberately spread across real frames rather than run in one call: a press and +/// a release inside a single frame is a *click*, and the whole point here is to exercise what the +/// app does BETWEEN them. +fn fire_probe_drag( + mut probe: ResMut, + mut synthetic: ResMut, + time: ProbeClock, + script: Option>, + self_player: Query<(), With>, +) { + if probe.pairs.is_empty() || probe.pair >= probe.pairs.len() { + if synthetic.0 { + synthetic.0 = false; + } + return; + } + let now = time.elapsed_secs(); + if now < probe.at || self_player.is_empty() { + return; + } + let Some(mut script) = script else { return }; + if now < probe.next { + return; + } + probe.next = now + probe.step; + // The probe owns the pointer from the first step of a gesture to the last, never longer. + synthetic.0 = true; + + let (from_name, to_name) = probe.pairs[probe.pair].clone(); + match probe.phase { + // Read both centres and park the cursor on the source — the hover the player makes + // before they press. + 0 => { + let (Some(from), Some(to)) = ( + frame_centre(&script, &from_name), + frame_centre(&script, &to_name), + ) else { + warn!("probe-drag: {from_name} > {to_name} — no resolved rect, skipping"); + probe.pair += 1; + return; + }; + probe.from = from; + probe.to = to; + info!( + "probe-drag: {from_name} ({:.0},{:.0}) > {to_name} ({:.0},{:.0})", + from.0, from.1, to.0, to.1 + ); + script.mouse_move(from.0, from.1); + } + 1 => { + let (x, y) = probe.from; + script.mouse_button(x, y, "LeftButton", true); + } + // Past the 4-px drag threshold — this is the move that fires `OnDragStart`. + 2 => { + let (x, y) = probe.from; + script.mouse_move(x + 8.0, y + 8.0); + } + // Two waypoints along the path, so anything that only breaks while the drag is IN FLIGHT + // (a repaint that re-seats the row under the cursor, say) gets frames to do it in. + 3 | 4 => { + let t = if probe.phase == 3 { 0.4 } else { 0.8 }; + let (fx, fy) = probe.from; + let (tx, ty) = probe.to; + script.mouse_move(fx + (tx - fx) * t, fy + (ty - fy) * t); + } + 5 => { + let (x, y) = probe.to; + script.mouse_move(x, y); + } + 6 => { + let (x, y) = probe.to; + script.mouse_button(x, y, "LeftButton", false); + } + // The self-check: hover the source where it now DRAWS and ask who the hit test says is + // there. A frame whose hit rect and drawn rect have parted company is invisible to any + // harness that presses the centre it just read — and it is exactly what "I can't grab + // anything any more" looks like from a hand on the mouse. + 7 => { + if let Some((x, y)) = frame_centre(&script, &from_name) { + script.mouse_move(x, y); + let focus = script + .eval::( + "return tostring(GetMouseFocus() and GetMouseFocus():GetName())", + ) + .unwrap_or_else(|_| "".into()); + if focus != from_name && !focus.starts_with(&from_name) { + warn!( + "probe-drag: {from_name} draws at ({x:.0},{y:.0}) but the hit test there \ + says {focus}" + ); + } else { + info!("probe-drag: {from_name} still answers its own rect"); + } + } + } + _ => { + if !probe.report.is_empty() { + match script.eval::(&probe.report) { + Ok(text) => info!("probe-drag: after {from_name} > {to_name}: {text}"), + Err(e) => warn!("probe-drag: report chunk raised: {e}"), + } + } + for err in script.errors().drain(..) { + warn!("probe-drag: UI error during {from_name} > {to_name}: {err}"); + } + probe.pair += 1; + probe.phase = 0; + // Hand the pointer back between gestures: the app's own feed runs for a frame, which + // is what a player's hand does too, and is where a stale hover would show up. + synthetic.0 = false; + return; + } + } + probe.phase += 1; +} diff --git a/crates/benilla-app/src/capture/probes/mod.rs b/crates/benilla-app/src/capture/probes/mod.rs index 5a91fd05..17f4ceb5 100644 --- a/crates/benilla-app/src/capture/probes/mod.rs +++ b/crates/benilla-app/src/capture/probes/mod.rs @@ -13,7 +13,7 @@ use bevy::prelude::*; mod fx_draw_census; pub(crate) use fx_draw_census::plugin as fx_draw_census_plugin; mod act; -pub(crate) use act::{ProbeChatPlugin, ProbeKeyPlugin, ProbeLuaPlugin}; +pub(crate) use act::{ProbeChatPlugin, ProbeDragPlugin, ProbeKeyPlugin, ProbeLuaPlugin}; /// The probe **run** itself — its bounded lifetime, and the window it lives in (level, /// parking, size). Nothing here measures anything; it is the shell every other probe rides. diff --git a/crates/benilla-app/src/dev.rs b/crates/benilla-app/src/dev.rs index 359f990f..1ef10946 100644 --- a/crates/benilla-app/src/dev.rs +++ b/crates/benilla-app/src/dev.rs @@ -133,6 +133,12 @@ impl Plugin for DevProbesPlugin { if std::env::var("WOW_PROBE_LUA").is_ok() { app.add_plugins(crate::capture::ProbeLuaPlugin); } + // The probe-drag driver: `WOW_PROBE_DRAG="RaidGroupButton7>RaidGroup6Slot1"` drags one + // named frame onto another through the real pointer path, one gesture step per frame — + // the "do what the hand on the mouse does" instrument (see `capture::ProbeDragPlugin`). + if std::env::var("WOW_PROBE_DRAG").is_ok() { + app.add_plugins(crate::capture::ProbeDragPlugin); + } // The probe-key taps: `WOW_PROBE_KEY="Space@14"` presses keys once in-world — the "press // space headlessly" instrument for input-gated behavior (see `capture::ProbeKeyPlugin`). if std::env::var("WOW_PROBE_KEY").is_ok() { diff --git a/crates/benilla-app/src/items.rs b/crates/benilla-app/src/items.rs index 1ec87521..fa8be98b 100644 --- a/crates/benilla-app/src/items.rs +++ b/crates/benilla-app/src/items.rs @@ -90,6 +90,44 @@ pub(crate) fn enchant_lines( lines } +/// `0x5da2c0` — **"has this item already been through the bind question?"**: the instance's +/// `ITEM_FIELD_FLAGS & 1` (already soulbound), **or** any of its seven live +/// `ITEM_FIELD_ENCHANTMENT` slots naming a `SpellItemEnchantment` row that binds the item +/// ([`benilla_formats::EnchantCatalog::binds_the_item`], the ref's `5da300`–`5da320` walk). +/// +/// One predicate, two consumers — the enchant cursor's bind question +/// ([`crate::ui_action`]'s `ClickedItem::already_bound`, the `0x495d60` gate) and the item +/// tooltip's §6 **Soulbound** override (B310). They must agree: an item the cursor considers +/// already bound is exactly an item whose tooltip says *Soulbound*. +/// +/// Read off the RAW descriptor, never off the rendered [`enchant_lines`] list. That list is a +/// *display* view: it drops rows the catalog cannot name, and it drops every +/// `Flags & 0x2` row outright (the line the reference refuses to print — decision 0928). The two +/// flag sets **overlap**, so this is not a hypothetical: **Firestone 1-4 and Orb of Fire carry +/// both bits** — they bind the item AND print no line — so an imbued weapon would read back as +/// "not bound" from the lines while the reference calls it bound. +pub(crate) fn already_bound(fields: &ObjectFields, cat: Option<&Enchants>) -> bool { + fields.item_flags().is_some_and(|f| f & 0x1 != 0) + || (0..7).any(|slot| live_enchant(fields, slot, cat).is_some_and(|id| binds(id, cat))) +} + +/// One `ITEM_FIELD_ENCHANTMENT` slot as the bind checks read it (`495eec: +/// movl 0x40(%ecx,%eax,4)` with `eax = 3*slot`): 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". +/// +/// NB this is the *bind-question* reading, not the *line* reading — the line law names its row +/// off `abs(id)` and keeps the sign only for the colour ([`enchant_lines`], wow-re §E3). +pub(crate) fn live_enchant(fields: &ObjectFields, slot: u8, cat: Option<&Enchants>) -> Option { + let id = u32::try_from(fields.item_enchant(slot)?).ok()?; + cat.is_some_and(|c| c.0.has_row(id)).then_some(id) +} + +/// `SpellItemEnchantment.Flags & 1` — this enchant soulbinds the item it lands on. +pub(crate) fn binds(id: u32, cat: Option<&Enchants>) -> bool { + cat.is_some_and(|c| c.0.binds_the_item(id)) +} + /// [`enchant_lines`] without the breadcrumb — the same gate and the same naming, for the one /// caller that is not a live item: the startup resolve of the whole `ItemRandomProperties` table /// ([`random_property_views`]). Routing that through the loud one would fire "the first enchant @@ -605,6 +643,7 @@ mod tests { ITEM_DYNFLAG_UNLOCKED, ITEM_DYNFLAG_WRAPPED, ITEM_FLAG_LOOTABLE, ITEM_FLAG_WRAPPER, }; use crossbeam_channel::TryRecvError; + use std::collections::HashMap; fn commands() -> (NetCommands, crossbeam_channel::Receiver) { let (tx, rx) = crossbeam_channel::unbounded(); @@ -846,4 +885,68 @@ mod tests { // No DBC → no lines, the pre-0915 tooltip. assert!(enchant_lines([(0, 2564, 0, None)], None).is_empty()); } + + /// `SpellItemEnchantment` field 23 for three synthetic rows: one that binds, one that binds + /// AND hides its tooltip line (the Firestone shape), one that does neither. + fn catalog() -> Enchants { + Enchants(benilla_formats::EnchantCatalog::from_rows( + HashMap::new(), + HashMap::new(), + HashMap::from([(11, 0x1), (12, 0x1 | 0x2), (13, 0x0)]), + )) + } + + /// One item object with the given `ITEM_FIELD_FLAGS` and enchant slot 0. + fn item(flags: u32, slot0: u32) -> ObjectFields { + ObjectFields::from_pairs(&[(21, flags), (22, slot0)]) + } + + /// **`0x5da2c0` — the bind question's predicate**, and the reason it reads the raw descriptor. + /// + /// Two halves, `||`: the instance's soulbound bit, or any live enchant slot naming a row with + /// `Flags & 1`. The last case is the one that decides the shape: the Firestone family carries + /// BOTH the binding bit and the tooltip-suppression bit, so the same item is bound *and* + /// prints no enchant line — a predicate read off [`enchant_lines`] would call it unbound. + #[test] + fn the_bind_predicate_reads_the_descriptor_not_the_rendered_lines() { + let cat = catalog(); + let cat = Some(&cat); + assert!( + already_bound(&item(0x1, 0), cat), + "ITEM_FIELD_FLAGS & 1 — already soulbound" + ); + assert!( + !already_bound(&item(0x0, 0), cat), + "no flag, no enchant — the plain BoE" + ); + assert!( + !already_bound(&item(0x8, 0), cat), + "a WRAPPED gift is not a bound item — the bit is 0x1, not any bit" + ); + assert!( + already_bound(&item(0x0, 11), cat), + "a live enchant slot naming a binding row" + ); + assert!( + !already_bound(&item(0x0, 13), cat), + "a non-binding enchant leaves the item unbound" + ); + assert!( + !already_bound(&item(0x0, 99), cat), + "an id that names no row binds nothing (the ref's `testl` after the table load)" + ); + assert!( + !already_bound(&item(0x0, 11), None), + "no catalog loaded — the enchant half cannot answer, and does not guess" + ); + // The one that pins the design: bound, and invisible to the line law. + assert!( + already_bound(&item(0x0, 12), cat), + "the Firestone shape — binds AND hides its line" + ); + assert!( + enchant_lines_quiet([(0u8, 12i32, 0u32, None)], cat).is_empty(), + "…and the rendered lines are empty for it, which is why they are not the source" + ); + } } diff --git a/crates/benilla-app/src/sound/glue.rs b/crates/benilla-app/src/sound/glue.rs index c5d9acce..07598d28 100644 --- a/crates/benilla-app/src/sound/glue.rs +++ b/crates/benilla-app/src/sound/glue.rs @@ -209,6 +209,17 @@ fn watch_glue_music(mut music: NonSendMut, time: Res