From 034c48f2cb8bd0044d21129b89cc277a63be27bf Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 25 Aug 2026 18:25:07 +0700 Subject: [PATCH] raid tab art is back, set bonuses fit, and 12 voices bound the mix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the mix is bounded the way the reference's is: FMOD gave 1.12 exactly twelve software channels and nothing else limits a priest buffing five people — ported as a 12-voice ceiling (a probe capture read 42 live voices at +13 dBFS, the limiter pumping 13 dB to hold it), plus same-kit copies started in one frame capped at two, since five sample-aligned Fortitudes are one sound +14 dB; `$WOW_SOUND_PROBE` records the mix before and after the limiter with a timeline, F9 stamps the moment - the raid tab draws its window art again — its four corner textures were written with single backslashes, which Lua drops silently, and a sweep now reads every archive path a shipped Lua chunk names; a drag whose release never arrives (pointer left the window, a second press) ends instead of gluing the frame to the cursor for the rest of the session, and `WOW_PROBE_DRAG` drives any UI drag headlessly in a live client - the item tooltip fits its set bonuses again: an emptied line kept its last measured height forever (a pooled cell with no way to say "empty"), and the two blank rows above the set block are the reference's own literal (a space and a newline) — a whole row each, not the empty string - an equipped BoE or BoP piece reads Soulbound: the bind line reads the item's own flags through the client's bound predicate instead of the template's bonding, one predicate shared with the enchant cursor's bind confirm Co-Authored-By: Claude Fable 5 --- crates/benilla-app/assets/ui/FriendsFrame.xml | 8 +- crates/benilla-app/src/capture/fixtures.rs | 2 + crates/benilla-app/src/capture/mod.rs | 4 +- crates/benilla-app/src/capture/probes/act.rs | 202 ++++++++ crates/benilla-app/src/capture/probes/mod.rs | 2 +- crates/benilla-app/src/dev.rs | 6 + crates/benilla-app/src/items.rs | 103 ++++ crates/benilla-app/src/sound/glue.rs | 12 + crates/benilla-app/src/sound/kit.rs | 312 +++++++++++- crates/benilla-app/src/sound/meter.rs | 79 ++- crates/benilla-app/src/sound/mix_tap.rs | 58 ++- crates/benilla-app/src/sound/mixer.rs | 148 +++++- crates/benilla-app/src/sound/mod.rs | 71 ++- crates/benilla-app/src/sound/probe.rs | 472 ++++++++++++++++++ crates/benilla-app/src/sound/zone.rs | 16 + .../src/ui_action/targeting/item.rs | 42 +- crates/benilla-app/src/ui_char.rs | 8 + crates/benilla-app/src/ui_items/feed.rs | 7 +- .../src/ui_script/action_bar_tests.rs | 1 + crates/benilla-app/src/ui_script/bag_tests.rs | 8 + .../src/ui_script/character_tests.rs | 2 + .../benilla-app/src/ui_script/chat_tests.rs | 1 + .../src/ui_script/delete_item_tests.rs | 1 + .../benilla-app/src/ui_script/escape_tests.rs | 2 + .../src/ui_script/game_menu_tests.rs | 1 + crates/benilla-app/src/ui_script/input.rs | 16 +- crates/benilla-app/src/ui_script/mod.rs | 14 + .../benilla-app/src/ui_script/raid_tests.rs | 147 ++++++ .../src/ui_script/shipped_xml_tests.rs | 206 ++++++++ .../src/ui_script/tooltip_compare_tests.rs | 3 + crates/benilla-app/src/ui_text/markup.rs | 4 + crates/benilla-ui/src/script/char_stats.rs | 5 + crates/benilla-ui/src/script/container.rs | 9 + crates/benilla-ui/src/script/cursor.rs | 5 +- crates/benilla-ui/src/script/cursor/doll.rs | 6 + crates/benilla-ui/src/script/cursor/drag.rs | 115 +++++ crates/benilla-ui/src/script/layout.rs | 23 +- crates/benilla-ui/src/script/mod.rs | 18 +- crates/benilla-ui/src/script/pointer.rs | 53 +- crates/benilla-ui/src/script/tests/tooltip.rs | 73 +++ .../src/script/tests/tooltip_item/bindings.rs | 1 + .../src/script/tests/tooltip_item/law.rs | 88 ++++ .../src/script/tests/tooltip_item/set.rs | 9 +- .../benilla-ui/src/script/tooltip_item/mod.rs | 2 + .../src/script/tooltip_item/render.rs | 52 +- crates/benilla-ui/src/script/types.rs | 6 +- 46 files changed, 2300 insertions(+), 123 deletions(-) create mode 100644 crates/benilla-app/src/sound/probe.rs 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