From 4d19090259720d305c98a43f4481ca1913021062 Mon Sep 17 00:00:00 2001 From: grom Date: Sun, 1 Nov 2026 18:43:34 -0700 Subject: [PATCH] compat: the cast-info pair as bare globals, and IsMouseButtonDown over a real feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cinematic's last two session errors. With these it loads clean, survives a session start, survives a UI probe and **draws** — all four of the harness's runtime columns. **`UnitCastingInfo`/`UnitChannelInfo` as bare globals** is an export, not a feature: the namespaced pair was already built with the whole contract in `cast_info` (the two return shapes, the millisecond unit, every position this client leaves nil and why), and these hand out the **same function values** the `C_Spell` table holds, so the two spellings cannot drift. Six calls of each in `Cinematic/Camera.lua` and its siblings, unguarded. `CastingInfo`/ `ChannelInfo` stay namespace-only: nothing on this install calls them bare, and pfUI reaches the one it wants through the namespace (`libdebuff.lua:887`). **`IsMouseButtonDown` needed a feed before it could be built honestly, and the reason it was declined is why.** `compat/classic_api/input.rs` had said no on the grounds that the only press state here is `Model::mouse_down_on`, which the pointer layer fills *only* for a press that landed on a frame — and `Cinematic/Camera.lua:612` asks in order to spot a right-drag **on the world**, which is exactly what that map cannot see. Answering from it would have been wrong for the one caller. So `Model::mouse_buttons` is new: `[Left, Right, Middle, Button4, Button5]`, pushed each frame from `ButtonInput` beside the modifiers. Placed there deliberately rather than with the pointer feed below it, because that feed is skipped for a cursor off the window or a hidden UI, and "is the right button still held" has to stay true through both — which is the camera's question. Both spellings (`RightButton` and `Button2`), no argument means any button as upstream's does, and a button no device has answers not-down rather than raising. The reference capture of the real client's `_G` has no mouse-button getter at all, and `ClassicAPI.dll` carries the name, so it is compat's rather than a 1.12 gap. Corpus: 100 addons, 85 ok / 1 fail, roster identical. benilla-ui 2082 passed. Chain green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-app/src/ui_script/input.rs | 10 ++ .../src/script/compat/classic_api/input.rs | 91 ++++++++++++++++++- .../src/script/compat/classic_api/spell.rs | 13 +++ crates/benilla-ui/src/script/mod.rs | 15 ++- crates/benilla-ui/src/script/model.rs | 10 ++ .../src/script/tests/reference_surface.rs | 19 ++++ 6 files changed, 153 insertions(+), 5 deletions(-) diff --git a/crates/benilla-app/src/ui_script/input.rs b/crates/benilla-app/src/ui_script/input.rs index 9e37c5ff..4b39665d 100644 --- a/crates/benilla-app/src/ui_script/input.rs +++ b/crates/benilla-app/src/ui_script/input.rs @@ -143,6 +143,16 @@ pub(super) fn feed_ui_input( let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight); let alt = keys.pressed(KeyCode::AltLeft) || keys.pressed(KeyCode::AltRight); script.set_modifiers(shift, ctrl, alt); + // The device's own button state, for `IsMouseButtonDown`. Pushed here rather than from the + // pointer feed below because that one is skipped for a cursor off the window or a hidden UI, + // and "is the right button held" stays true through both. + script.set_mouse_buttons([ + buttons.pressed(MouseButton::Left), + buttons.pressed(MouseButton::Right), + buttons.pressed(MouseButton::Middle), + buttons.pressed(MouseButton::Other(4)), + buttons.pressed(MouseButton::Other(5)), + ]); // ── Mouse ── A cursor off the window, or a UI hidden by TOGGLEUI, skips only the mouse feed. // The headless hover probe's aim stands in for a missing cursor, so `PointerOverUi` rises and // falls over a panel in an automated run as it does for a person; a real pointer always wins. diff --git a/crates/benilla-ui/src/script/compat/classic_api/input.rs b/crates/benilla-ui/src/script/compat/classic_api/input.rs index 6c9c28a5..56d4ba28 100644 --- a/crates/benilla-ui/src/script/compat/classic_api/input.rs +++ b/crates/benilla-ui/src/script/compat/classic_api/input.rs @@ -8,11 +8,16 @@ //! caller asking "is left shift down" gets "is shift down", never a fabricated `false` for the //! half the engine does not model. //! +//! **`IsMouseButtonDown([button])` is now built, and the old reason for leaving it out is why it +//! needed a feed first.** That reason stands: `Model::mouse_down_on` is a button-to-frame map the +//! pointer layer fills *only* for a press that landed on a frame, so a press on the world reads as +//! not-down there and answering from it would be wrong more often than right — and wrong for the +//! very caller that wanted it, since `Cinematic/Camera.lua:612` asks in order to spot a right-drag +//! **on the world**. So the verb reads `Model::mouse_buttons` instead, the host's own button state +//! pushed per frame beside the modifiers (`ui_script/input.rs`), which is the device rather than +//! the UI. +//! //! Not built, for want of state rather than by choice: -//! - `IsMouseButtonDown([button])`: the only mouse-press state here is `Model::mouse_down_on`, a -//! button-to-frame map the pointer layer fills *only* for a press that landed on a frame and -//! clears otherwise (`script/pointer.rs`). A press on the world would read as not-down, so -//! answering from it would be wrong more often than right. //! - `GetMouseButtonClicked()`: wants the button that triggered the handler currently running. //! Handler dispatch (`script/event.rs`) passes the button to `OnClick` as an argument and keeps //! no ambient "current event's button", which this getter would read. @@ -22,6 +27,20 @@ use mlua::Lua; use crate::script::binding_abi::flag; use crate::script::Model; +/// The index into [`Model::mouse_buttons`] a button name reads, or `None` for a name no device +/// has. Both spellings: `LeftButton` is what this client's own click path passes a handler, and +/// `Button1`..`Button5` is the numbered form later clients accept. +fn button_index(name: &str) -> Option { + match name { + "LeftButton" | "Button1" => Some(0), + "RightButton" | "Button2" => Some(1), + "MiddleButton" | "Button3" => Some(2), + "Button4" => Some(3), + "Button5" => Some(4), + _ => None, + } +} + /// Which of the `(shift, ctrl, alt)` triple a name reads. fn held(lua: &Lua, pick: usize) -> mlua::Value { let model = lua.app_data_ref::().expect("model app_data"); @@ -57,6 +76,23 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; + // `IsMouseButtonDown([button])`: the device's own state, `Model::mouse_buttons`. With no + // argument, any button, as upstream's does. The `1`/nil shape its siblings use. + // + // A name no device has answers nil rather than raising: an addon probing `"Button6"` on a + // three-button mouse is asking a fair question and the answer is "not down". + g.set( + "IsMouseButtonDown", + lua.create_function(|lua, name: Option| { + let model = lua.app_data_ref::().expect("model app_data"); + let down = match name.as_deref() { + None => model.mouse_buttons.iter().any(|b| *b), + Some(n) => button_index(n).is_some_and(|i| model.mouse_buttons[i]), + }; + Ok(flag(down)) + })?, + )?; + Ok(()) } @@ -64,6 +100,53 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { mod tests { use crate::script::UiScript; + /// The device's buttons, not the UI's presses: a right-drag begun on the world is what + /// `Cinematic/Camera.lua:612` asks about, and `Model::mouse_down_on` — the map the pointer + /// layer fills only for a press that landed on a frame — cannot see it. Nothing here touches + /// a frame at all. + #[test] + fn is_mouse_button_down_reads_the_device_and_takes_both_spellings() { + let mut s = UiScript::new().unwrap(); + assert!(s + .eval::(r#"return IsMouseButtonDown("RightButton") == nil"#) + .unwrap()); + assert!( + s.eval::("return IsMouseButtonDown() == nil").unwrap(), + "no argument is any button, and none is down" + ); + + // Right and Button5 held, nothing else. + s.set_mouse_buttons([false, true, false, false, true]); + assert_eq!( + s.eval::(r#"return IsMouseButtonDown("RightButton")"#) + .unwrap(), + 1 + ); + assert_eq!( + s.eval::(r#"return IsMouseButtonDown("Button2")"#) + .unwrap(), + 1, + "the numbered spelling reads the same button" + ); + assert_eq!( + s.eval::(r#"return IsMouseButtonDown("Button5")"#) + .unwrap(), + 1 + ); + assert!(s + .eval::(r#"return IsMouseButtonDown("LeftButton") == nil"#) + .unwrap()); + assert_eq!( + s.eval::("return IsMouseButtonDown()").unwrap(), + 1, + "no argument is any button" + ); + // A button no device has is not down, rather than a raise. + assert!(s + .eval::(r#"return IsMouseButtonDown("Button9") == nil"#) + .unwrap()); + } + #[test] fn each_side_reads_the_combined_bit_the_engine_actually_tracks() { let mut s = UiScript::new().unwrap(); diff --git a/crates/benilla-ui/src/script/compat/classic_api/spell.rs b/crates/benilla-ui/src/script/compat/classic_api/spell.rs index 91a47c99..c6340f9c 100644 --- a/crates/benilla-ui/src/script/compat/classic_api/spell.rs +++ b/crates/benilla-ui/src/script/compat/classic_api/spell.rs @@ -626,6 +626,19 @@ pub(super) fn install_globals(lua: &Lua, t: &Table) -> mlua::Result<()> { // pfUI's `modules/bags.lua:703` calls it bare, keyed by spell id, to decide which extract / // disenchant button its bag frame may show. g.set("IsSpellKnown", t.get::("IsSpellKnown")?)?; + // `UnitCastingInfo`/`UnitChannelInfo` as bare globals, which is how an addon written for a + // later client calls them — `Cinematic/Camera.lua:632` and five siblings, six calls of each, + // and the nil global was its last session error. The **same function values** the table + // holds, as every row here does, so the namespaced and bare spellings cannot drift: the whole + // contract (the two return shapes, milliseconds, every position this client leaves nil and + // why) stays in `cast_info` alone. + // + // Not exported bare: `CastingInfo`/`ChannelInfo`, the no-argument pair. They are `C_Spell`'s + // legacy spelling here and nothing on this install calls them bare — pfUI reaches the one it + // wants through the namespace (`libdebuff.lua:887`). + for name in ["UnitCastingInfo", "UnitChannelInfo"] { + g.set(name, t.get::(name)?)?; + } // `GetSpellInfo` as a **bare global**, in both of the forms upstream lists // (`docs/classic-api-functions.md`'s Spell section says so outright: "GetSpellInfo (both diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index dfe7f6fe..b8464de3 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -26,10 +26,12 @@ mod bind_confirm; mod binder; mod binding_abi; mod dialog_verbs; +/// Numeric texture ids, which later clients address art by and 1.12 does not. +mod file_ids; mod tutorial; mod worldmap_arrow; // `camera_view`: the five camera views and `FlipCameraYaw`, the reference's `UIUtil\Camera.cpp`. -mod button; +pub(in crate::script) mod button; mod camera_view; mod cast_events; mod channel; @@ -823,6 +825,17 @@ impl UiScript { model.modifiers = (shift, ctrl, alt); } + /// Push which mouse buttons are physically down, `[Left, Right, Middle, Button4, Button5]`, + /// for `IsMouseButtonDown`. Call it beside [`Self::set_modifiers`], before the mouse feed, so + /// a handler asking during a click sees that click's own state. + /// + /// The host's button state, not the UI's: a press on the world counts here and never reaches + /// `Model::mouse_down_on`, which is the distinction the verb exists for. + pub fn set_mouse_buttons(&mut self, down: [bool; 5]) { + let mut model = self.model_mut(); + model.mouse_buttons = down; + } + /// Push the player's WMO-containment state (the client's `0xceaa60`) onto every Minimap: it /// picks which persisted zoom index `GetZoom`/`SetZoom` act on. Call it before the script /// tick, on each inside/outside edge and whenever [`Self::minimap_widgets_created`] moves. diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 0ec9e55c..d75de623 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -1673,6 +1673,15 @@ pub(crate) struct Model { pub(crate) framerate: f64, /// `(shift, ctrl, alt)` for `Is*KeyDown`, pushed before the frame's mouse events. pub(crate) modifiers: (bool, bool, bool), + /// Which mouse buttons are physically down, in the order + /// `[Left, Right, Middle, Button4, Button5]`, fed per frame by + /// [`crate::script::UiScript::set_mouse_buttons`] from the host's own button state. + /// + /// Distinct from [`Self::mouse_down_on`], which is a *press that landed on a frame* and is + /// what the pointer layer routes clicks with. A press on the world is in this array and not + /// in that map, which is the whole reason this exists: `IsMouseButtonDown` is asked about the + /// device, not about the UI. + pub(crate) mouse_buttons: [bool; 5], } impl Model { @@ -2349,6 +2358,7 @@ impl Model { summon_confirms: 0, framerate: 0.0, modifiers: (false, false, false), + mouse_buttons: [false; 5], money: 0, net_latency_ms: 0, player_xp: 0, diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs index ff55692f..47583e03 100644 --- a/crates/benilla-ui/src/script/tests/reference_surface.rs +++ b/crates/benilla-ui/src/script/tests/reference_surface.rs @@ -202,6 +202,25 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> { // "spellIconID")`. 1.12 reaches an icon only through a spellbook slot, so a spell the // player does not know has no texture an addon can draw. "GetSpellIconTexture", + // ClassicAPI's `UnitCastingInfo`/`UnitChannelInfo` as bare globals + // (script/compat/classic_api/spell.rs's `install_globals`). 1.12 has no casting-info API + // at all, which is why SuperWoW and nampower each added their own; the namespaced pair was + // already built and these hand out the same function values, so the two spellings cannot + // drift. Six calls of each in `Cinematic/Camera.lua` and its siblings, unguarded. + "UnitCastingInfo", + "UnitChannelInfo", + // ClassicAPI's `IsMouseButtonDown` (script/compat/classic_api/input.rs): absent from the + // reference capture of the client's own `_G` — it has no mouse-button getter at all — and + // in `ClassicAPI.dll`'s string pool. Reads `Model::mouse_buttons`, the host's own button + // state pushed per frame, which is the *device* rather than the UI: the press state the + // pointer layer keeps only covers presses that landed on a frame, and the caller that + // wanted this (`Cinematic/Camera.lua:612`) is asking about a right-drag on the world. + "IsMouseButtonDown", + // `GetFileIDFromPath` (script/compat/classic_api/cvar.rs's globals installer, over + // `script/file_ids.rs`): later clients address art by numeric id, which 1.12 has no + // notion of, and an addon that holds one wants the path back. Answers only the ids the + // installed addons annotate at their own call sites, nil otherwise. OpenMapClassic asks. + "GetFileIDFromPath", // ClassicAPI's `GetCVarBool` (script/compat/classic_api/cvar.rs). Absent from 1.12 on this // file's own evidence — `reference/1.12-globals.tsv` is a capture of the real client's // `_G` and carries `GetCVar` and `GetCVarDefault` and nothing else of the family — and