compat: the cast-info pair as bare globals, and IsMouseButtonDown over a real feed

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<MouseButton>` 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-11-01 18:43:34 -07:00
co-authored by Claude Opus 5
parent 482ad0bc5c
commit 4d19090259
6 changed files with 153 additions and 5 deletions
+10
View File
@@ -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.
@@ -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<usize> {
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::<Model>().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<String>| {
let model = lua.app_data_ref::<Model>().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::<bool>(r#"return IsMouseButtonDown("RightButton") == nil"#)
.unwrap());
assert!(
s.eval::<bool>("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::<u32>(r#"return IsMouseButtonDown("RightButton")"#)
.unwrap(),
1
);
assert_eq!(
s.eval::<u32>(r#"return IsMouseButtonDown("Button2")"#)
.unwrap(),
1,
"the numbered spelling reads the same button"
);
assert_eq!(
s.eval::<u32>(r#"return IsMouseButtonDown("Button5")"#)
.unwrap(),
1
);
assert!(s
.eval::<bool>(r#"return IsMouseButtonDown("LeftButton") == nil"#)
.unwrap());
assert_eq!(
s.eval::<u32>("return IsMouseButtonDown()").unwrap(),
1,
"no argument is any button"
);
// A button no device has is not down, rather than a raise.
assert!(s
.eval::<bool>(r#"return IsMouseButtonDown("Button9") == nil"#)
.unwrap());
}
#[test]
fn each_side_reads_the_combined_bit_the_engine_actually_tracks() {
let mut s = UiScript::new().unwrap();
@@ -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::<Value>("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::<Value>(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
+14 -1
View File
@@ -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.
+10
View File
@@ -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,
@@ -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