pets get their action bar, macros arrive, and hunters draw the bow

- a hunter's or warlock's pet has its action bar now: attack, follow, stay, the pet's own spells with cooldowns and autocast sparkles
- the macro system lands: the macro window, the icon chooser, /cast and /macro, and macros dragged onto the action bar
- hunter shots look right at last: the bow draws and releases on the shot, and the arrow lays a spark trail its whole flight
- the options window re-dresses in 1.12's own art with the classic dialog look, and gets a working search box and clickable steppers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-05 19:03:13 +07:00
co-authored by Claude Fable 5
parent 994be22f5c
commit 77dc8f8ead
98 changed files with 8922 additions and 1089 deletions
+2 -1
View File
@@ -26,6 +26,7 @@ use super::Model;
/// pack/unpack included) — this crate is deliberately engine-free (no protocol dependency, see
/// the crate doc), so it can't just import `benilla_protocol::messages::ACTION_KIND_*`.
pub(crate) const ACTION_KIND_SPELL: u8 = 0x00;
pub(crate) const ACTION_KIND_MACRO: u8 = 0x40;
pub(crate) const ACTION_KIND_ITEM: u8 = 0x80;
/// What one action slot displays. The app resolves icons (Spell.dbc × SpellIcon.dbc for a spell,
@@ -163,7 +164,7 @@ impl super::UiScript {
/// reads truthy. Deliberately NOT Lua's plain truthiness (where `0` is truthy) — the reference
/// passes literal `0` from a keybind meaning "never place", which only a numeric-zero check
/// reproduces.
fn truthy_nonzero(v: &Value) -> bool {
pub(super) fn truthy_nonzero(v: &Value) -> bool {
match v {
Value::Nil => false,
Value::Boolean(b) => *b,
+3 -1
View File
@@ -289,7 +289,9 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) -> bool
}
}
}
Some(other @ (CursorPayload::Spell(_) | CursorPayload::Action(_))) => {
Some(
other @ (CursorPayload::Spell(_) | CursorPayload::Action(_) | CursorPayload::Macro(_)),
) => {
model.cursor = Some(other);
false
}
+24 -4
View File
@@ -40,8 +40,8 @@ pub const EQUIPMENT_BAG: i64 = -100;
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// What the cursor carries — the client's payload-mode global [0xb4d900] as a typed enum
/// (wow-re cursor-dragdrop-payload.md §1: 1 = live item, 3 = spell; our Action arm is the
/// client's bar-slot pickup; the money/macro/preview arms stay unbuilt). One transition seam for
/// (wow-re cursor-dragdrop-payload.md §1: 1 = live item, 3 = spell, **8 = macro**; our Action arm
/// is the client's bar-slot pickup; the money/preview arms stay unbuilt). One transition seam for
/// every surface, so sounds, CURSOR_UPDATE, and lock display can't drift apart per window
/// (decision 0216).
#[derive(Clone, Debug, PartialEq)]
@@ -49,6 +49,7 @@ pub enum CursorPayload {
Item(CursorItem),
Spell(CursorSpell),
Action(CursorAction),
Macro(CursorMacro),
}
/// The item currently held on the cursor (`PickupContainerItem`/`SplitContainerItem`/
@@ -110,6 +111,18 @@ pub struct CursorAction {
pub texture: Option<String>,
}
/// A macro payload ([`super::macros`]'s `PickupMacro` produces it) — the client's cursor mode
/// **8**, whose payload global is the bare macro id (`[0xb4e2fc]`, wow-re `action-item-slot.md`'s
/// payload table, where mode 8 is the ONE non-item/non-spell payload `PlaceAction` accepts).
/// Unlike every other arm this one carries no source slot to swap back to: a macro lives in the
/// macro table, not in the surface it was dragged from, so a refused place just leaves it held.
#[derive(Clone, Debug, PartialEq)]
pub struct CursorMacro {
/// The 1-based macro index (1..=18 account, 19..=36 character — [`super::macros`]'s space).
pub index: u32,
pub texture: Option<String>,
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The one transition seam: CURSOR_UPDATE / ITEM_LOCK_CHANGED / DELETE_ITEM_CONFIRM
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -181,7 +194,7 @@ pub(crate) fn clear_cursor(model: &mut Model) {
queue_cursor_update(model);
queue_lock_changed(model, item.bag, item.slot);
}
Some(CursorPayload::Spell(_) | CursorPayload::Action(_)) => {
Some(CursorPayload::Spell(_) | CursorPayload::Action(_) | CursorPayload::Macro(_)) => {
queue_cursor_update(model);
}
None => {}
@@ -237,7 +250,7 @@ pub(crate) fn world_drop_click(model: &mut Model) -> bool {
true
}
(
Some(CursorPayload::Spell(_) | CursorPayload::Action(_)),
Some(CursorPayload::Spell(_) | CursorPayload::Action(_) | CursorPayload::Macro(_)),
WorldPick::Terrain | WorldPick::Nothing,
) => {
clear_cursor(model);
@@ -443,6 +456,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Value::Nil,
Value::Nil,
)),
// The Era `GetCursorInfo` shape for a macro: the kind word + the macro index.
Some(CursorPayload::Macro(m)) => Ok((
Value::String(lua.create_string("macro")?),
Value::Integer(i64::from(m.index)),
Value::Nil,
Value::Nil,
)),
None => Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil)),
}
})?,
+6 -1
View File
@@ -16,7 +16,7 @@
use mlua::Lua;
use crate::script::action::{ACTION_KIND_ITEM, ACTION_KIND_SPELL};
use crate::script::action::{ACTION_KIND_ITEM, ACTION_KIND_MACRO, ACTION_KIND_SPELL};
use crate::script::{ActionSlot, Model};
use super::{queue_cursor_update, CursorAction, CursorPayload};
@@ -114,6 +114,11 @@ pub(crate) fn place_action(model: &mut Model, id: u32) -> bool {
CursorPayload::Action(a) => (a.kind, a.action, a.texture.clone()),
CursorPayload::Item(i) => (ACTION_KIND_ITEM, i.item_id, i.texture.clone()),
CursorPayload::Spell(s) => (ACTION_KIND_SPELL, s.spell_id, s.texture.clone()),
// Mode 8 — the one non-item/non-spell payload the reference's `PlaceAction` accepts
// (`action-item-slot.md` §5's payload table: pet actions and class abilities are refused,
// macros are not). It packs the bare macro id under the MACRO tag, exactly as the SPELL
// and ITEM arms pack theirs.
CursorPayload::Macro(m) => (ACTION_KIND_MACRO, m.index, m.texture.clone()),
};
let displaced = model.actions.insert(
id,
+3 -1
View File
@@ -101,7 +101,9 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
queue_lock_changed(model, held.bag, held.slot);
true
}
Some(other @ (CursorPayload::Spell(_) | CursorPayload::Action(_))) => {
Some(
other @ (CursorPayload::Spell(_) | CursorPayload::Action(_) | CursorPayload::Macro(_)),
) => {
model.cursor = Some(other);
false
}
@@ -1,127 +0,0 @@
//! The Era atlas seam (decision 0950): `atlas=` in XML, `SetAtlas`/`GetAtlas` at runtime, and the
//! warn-once miss drain — against a hand-pushed table. Disk and manifest.json belong to the app;
//! these tests prove the script/loader side alone.
use crate::framexml;
use crate::loader;
use crate::script::{EraAtlasEntry, UiScript};
/// The real `options_innerframe` numbers (fdid 1318750, rect [1,150,887,768] in 1024×1024) so a
/// regression here reads like the manifest it mirrors.
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.set_era_atlases([
(
"options_innerframe".to_string(),
EraAtlasEntry {
file: "era:textures/1318750.blp".to_string(),
uv: [1.0 / 1024.0, 887.0 / 1024.0, 150.0 / 1024.0, 768.0 / 1024.0],
size: [886.0, 618.0],
},
),
(
"checkbox-minimal".to_string(),
EraAtlasEntry {
file: "era:textures/4614134.blp".to_string(),
uv: [1.0 / 64.0, 31.0 / 64.0, 1.0 / 64.0, 30.0 / 64.0],
size: [30.0, 29.0],
},
),
]);
s
}
#[test]
fn xml_atlas_attribute_resolves_texture_uv_and_size() {
let mut s = harness();
let doc = framexml::parse(
r#"<Ui>
<Frame name="T">
<Size><AbsDimension x="920" y="724"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers><Layer level="OVERLAY">
<Texture name="$parentInner" atlas="Options_InnerFrame" useAtlasSize="true"/>
</Layer></Layers>
</Frame>
</Ui>"#,
)
.unwrap();
let report = loader::load(&s, &doc, &|_| None);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
// Case-insensitive resolve; the canonical (lowercase) name is what GetAtlas reports.
assert_eq!(
s.eval::<String>("return TInner:GetAtlas()").unwrap(),
"options_innerframe"
);
let (l, r, t, b): (f32, f32, f32, f32) = s.eval("return TInner:GetTexCoord()").unwrap();
assert!((l - 1.0 / 1024.0).abs() < 1e-6 && (r - 887.0 / 1024.0).abs() < 1e-6);
assert!((t - 150.0 / 1024.0).abs() < 1e-6 && (b - 768.0 / 1024.0).abs() < 1e-6);
// useAtlasSize applied the member's nominal size.
assert_eq!(s.eval::<f32>("return TInner:GetWidth()").unwrap(), 886.0);
assert_eq!(s.eval::<f32>("return TInner:GetHeight()").unwrap(), 618.0);
// Nothing missed.
assert!(s.take_era_atlas_misses().is_empty());
}
#[test]
fn runtime_setatlas_swaps_and_settexture_clears() {
let s = harness();
let doc = framexml::parse(
r#"<Ui>
<Frame name="T">
<Size><AbsDimension x="100" y="100"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers><Layer>
<Texture name="$parentTex" atlas="options_innerframe"/>
</Layer></Layers>
</Frame>
</Ui>"#,
)
.unwrap();
let report = loader::load(&s, &doc, &|_| None);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
// The category-row idiom: swap atlas at runtime (hover/selected), size riding along.
s.run("TTex:SetAtlas(\"checkbox-minimal\", true)").unwrap();
assert_eq!(
s.eval::<String>("return TTex:GetAtlas()").unwrap(),
"checkbox-minimal"
);
assert_eq!(s.eval::<f32>("return TTex:GetWidth()").unwrap(), 30.0);
// A plain SetTexture makes the region ordinary again — GetAtlas goes nil.
s.run("TTex:SetTexture(\"Interface\\\\Buttons\\\\UI-Panel-Button-Up\")")
.unwrap();
assert!(s.eval::<bool>("return TTex:GetAtlas() == nil").unwrap());
}
#[test]
fn unknown_atlas_is_a_warn_once_miss_not_an_error() {
let mut s = harness();
let doc = framexml::parse(
r#"<Ui>
<Frame name="T">
<Size><AbsDimension x="100" y="100"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers><Layer>
<Texture name="$parentTex" atlas="not-extracted-yet"/>
</Layer></Layers>
</Frame>
</Ui>"#,
)
.unwrap();
// The load survives (a stale extraction must not kill the whole XML pass) …
let report = loader::load(&s, &doc, &|_| None);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
// … the region draws nothing …
assert!(s.eval::<bool>("return TTex:GetAtlas() == nil").unwrap());
// … and the miss names itself exactly once, then the drain clears.
s.run("TTex:SetAtlas(\"not-extracted-yet\")").unwrap();
assert_eq!(
s.take_era_atlas_misses(),
vec!["not-extracted-yet".to_string()]
);
assert!(s.take_era_atlas_misses().is_empty());
}
+742
View File
@@ -0,0 +1,742 @@
//! Player **macros** (decision 0983) — the one seam in this crate that owns its game state
//! outright, because the 1.12 macro system has no server side at all: there is no macro opcode on
//! the wire (vmangos has none, and the client's own `UIMacros.cpp` persists to
//! `WTF/…/macros-cache.txt`), so the macro table *is* client state. Contrast [`super::action`],
//! whose 120-slot table the app owns because the server hands it back at login.
//!
//! That ownership is forced by the reference's own API shape, not chosen for convenience:
//! `MacroPopupOkayButton_OnClick` does `index = CreateMacro(…)` and immediately selects `index`,
//! so the mutation must be synchronous and must return the new slot — a queue-an-intent-and-wait
//! seam cannot answer it. The app therefore **seeds** the table once ([`UiScript::set_macros`],
//! from `benilla/macros/…`), **reads** it back to persist ([`UiScript::macros`]) whenever
//! [`UiScript::take_macros_dirty`] says something moved, and pushes the icon-chooser list
//! ([`UiScript::set_macro_icons`]) it builds off `SpellIcon.dbc`.
//!
//! ## The index space (VERIFIED, the shipped `Blizzard_MacroUI`)
//!
//! `MAX_MACROS = 18` per tab; `MacroFrame.macroBase` is `0` for the account tab and `18` for the
//! character tab, and every binding takes `macroBase + i`. So **1..=18 are the account macros and
//! 19..=36 the character macros**, each list dense from its base — `MacroFrame_Update` draws
//! `i <= numMacros` and disables the rest, so a gap is not representable. [`MacroIndex`] is that
//! split, made once at the boundary.
//!
//! ## What the engine does NOT know
//!
//! A macro's **bound spell** — the spell whose cooldown/usability/range a macro action-bar slot
//! reports (byte-verified: `0x4e5a50`'s macro arm returns `[rec+0x564]`, wow-re
//! `action-spell-icon-apis.md` §2) — is the app's derivation, because resolving a name to a spell
//! id needs the catalog and the player's book. The engine stores the body; `benilla::ui_macro`
//! parses it. Same split as everywhere else in this crate: data and layout here, game knowledge
//! there.
use mlua::{Lua, MultiValue, Value};
use super::cursor::{queue_cursor_update, CursorMacro, CursorPayload};
use super::Model;
/// Macros per tab — the shipped `Blizzard_MacroUI.lua`'s own `MAX_MACROS = 18`, and the account
/// tab's size *and* the character tab's base (`MacroFrame_SetCharacterMacros` sets
/// `macroBase = MAX_MACROS`).
pub const MAX_MACROS: usize = 18;
/// The macro **name** length cap — `MacroPopupEditBox`'s `letters="16"` and its label
/// `MACRO_POPUP_TEXT = "Enter Macro Name (Max 16 Characters):"`. Enforced here as well as in the
/// box, because `CreateMacro`/`EditMacro` are callable from any script.
pub const MAX_MACRO_NAME: usize = 16;
/// The macro **body** length cap — `MacroFrameText`'s `letters="255"` and its counter
/// `MACROFRAME_CHAR_LIMIT = "%d/255 Characters Used"`.
pub const MAX_MACRO_BODY: usize = 255;
/// One macro, as `GetMacroInfo` reports it.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MacroView {
/// The player's name for it (≤ [`MAX_MACRO_NAME`] characters).
pub name: String,
/// The chosen icon's **texture path** (`Interface\Icons\Ability_Ambush`). Stored resolved
/// rather than as an index into the chooser list: the reference's own save format writes the
/// icon by NAME (`MACRO %d "%s" %s`, byte-verified at `0x44cb60`), so a name is what survives
/// a restart — an index would silently re-point if the list ever changed.
pub texture: Option<String>,
/// The macro body: the lines run when the macro is used, `\n`-separated, ≤ [`MAX_MACRO_BODY`].
pub body: String,
/// `GetMacroInfo`'s fourth return (`isLocal`) and `CreateMacro`/`EditMacro`'s `local`
/// argument. The 1.12 client keeps a second file for these (`macros-local.txt` beside
/// `macros-cache.txt`, both byte-verified strings at `0x45de74`/`0x45de88`); the shipped macro
/// UI never passes the flag, so it is carried faithfully and is otherwise inert.
pub local_only: bool,
}
/// The whole macro table: the two dense lists behind the frame's two tabs (module docs).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MacroState {
/// Indices 1..=18.
pub account: Vec<MacroView>,
/// Indices 19..=36.
pub character: Vec<MacroView>,
}
/// A 1-based Lua macro index resolved into `(which list, position in it)` — the one place the
/// 1..36 space is split, so no binding re-derives the base.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct MacroIndex {
/// True for 19..=36 (the character tab).
per_character: bool,
/// 0-based position within that tab's list.
pos: usize,
}
impl MacroIndex {
/// Split a 1-based Lua index; `None` outside 1..=36. Deliberately NOT range-checked against
/// the live lists — callers that need an occupied slot go through [`MacroState::get`].
fn split(index: usize) -> Option<Self> {
let zero = index.checked_sub(1)?;
match zero {
0..MAX_MACROS => Some(Self {
per_character: false,
pos: zero,
}),
_ if zero < MAX_MACROS * 2 => Some(Self {
per_character: true,
pos: zero - MAX_MACROS,
}),
_ => None,
}
}
/// The 1-based Lua index this position occupies.
fn lua_index(self) -> usize {
self.pos + 1 + if self.per_character { MAX_MACROS } else { 0 }
}
}
impl MacroState {
/// The macro at a 1-based Lua index, or `None` for an empty/out-of-range slot.
pub fn get(&self, index: usize) -> Option<&MacroView> {
let at = MacroIndex::split(index)?;
self.list(at.per_character).get(at.pos)
}
fn list(&self, per_character: bool) -> &Vec<MacroView> {
if per_character {
&self.character
} else {
&self.account
}
}
fn list_mut(&mut self, per_character: bool) -> &mut Vec<MacroView> {
if per_character {
&mut self.character
} else {
&mut self.account
}
}
/// `GetMacroIndexByName`'s search — case-insensitive, account list first (the reference walks
/// the one flat 1..36 space, and the account half is its low end). `0` when nothing matches,
/// which is the reference's own miss value (a number, never nil — the shipped
/// `Usage: GetMacroIndexByName(name)` binding pushes a number).
pub fn index_by_name(&self, name: &str) -> usize {
for per_character in [false, true] {
for (i, m) in self.list(per_character).iter().enumerate() {
if m.name.eq_ignore_ascii_case(name) {
return MacroIndex {
per_character,
pos: i,
}
.lua_index();
}
}
}
0
}
}
/// Clamp a player-supplied string to a character (not byte) cap — the EditBox's own
/// `max_letters` rule, applied again at the API so a script cannot store what the box refuses.
fn clamp_chars(s: &str, max: usize) -> String {
s.chars().take(max).collect()
}
impl super::UiScript {
/// Seed the whole macro table, replacing whatever was there — the app's load path
/// (`benilla/macros/…`). Does **not** mark the table dirty: the app already has what it just
/// handed over, and a save triggered by its own load would be a write-back loop.
pub fn set_macros(&mut self, state: MacroState) {
let mut model = self.model_mut();
model.macros = state;
model.macros_dirty = false;
model.macros_generation += 1;
}
/// Read the live macro table — the app's save path, taken when [`Self::take_macros_dirty`]
/// reports a change.
pub fn macros(&self) -> MacroState {
self.model_mut().macros.clone()
}
/// Did a script mutate the table since the last call (`CreateMacro`/`EditMacro`/`DeleteMacro`)?
/// The app persists on a `true` and fires `UPDATE_MACROS` — the reference's own event for
/// exactly this transition (byte-verified string at `0x452460`).
pub fn take_macros_dirty(&mut self) -> bool {
std::mem::take(&mut self.model_mut().macros_dirty)
}
/// Push the icon-chooser list — the full texture paths `GetMacroIconInfo` serves, in the order
/// the popup's grid shows them. Built by the app off `SpellIcon.dbc` (`benilla::ui_macro`).
pub fn set_macro_icons(&mut self, icons: Vec<String>) {
self.model_mut().macro_icons = icons;
}
/// The macro table's **generation** — bumped by every seed and every mutation, so a per-frame
/// consumer can gate an expensive re-resolve on a `u64` compare instead of cloning the table
/// to diff it. The action bar's identity feed reads it as a third input beside its `dirty`
/// flag and the item-template epoch (the same shape decision 0660 gave that one): a macro's
/// icon changes when the macro is edited, which touches neither of the other two.
///
/// Distinct from [`Self::take_macros_dirty`] on purpose — that one is a *drained* edge with
/// exactly one owner (the save), and a second consumer draining it would silently eat the
/// other's save.
pub fn macros_generation(&self) -> u64 {
self.model_mut().macros_generation
}
}
/// `CreateMacro(name, iconIndex, body, local, perCharacter)` — the shipped binding's own argument
/// list (byte-verified usage string at `0x44cb74`). Returns the new macro's 1-based index, or
/// `None` on the two failures the client names in its own log lines (`0x44cbb4`/`0x44cbdc`):
/// an empty name, and a full tab.
fn create_macro(
model: &mut Model,
name: &str,
texture: Option<String>,
body: &str,
local_only: bool,
per_character: bool,
) -> Option<usize> {
let name = clamp_chars(name.trim(), MAX_MACRO_NAME);
if name.is_empty() {
model
.warnings
.push("CreateMacro() failed, no name specified".into());
return None;
}
let list = model.macros.list_mut(per_character);
if list.len() >= MAX_MACROS {
model.warnings.push(format!(
"CreateMacro() failed, already have {MAX_MACROS} macros"
));
return None;
}
list.push(MacroView {
name,
texture,
body: clamp_chars(body, MAX_MACRO_BODY),
local_only,
});
let pos = list.len() - 1;
model.macros_dirty = true;
model.macros_generation += 1;
Some(MacroIndex { per_character, pos }.lua_index())
}
/// `EditMacro(index, name, icon, body, local)` — every argument is optional past the index, and an
/// omitted one leaves that field alone. That is load-bearing, not defensive: the shipped UI calls
/// it **twice with disjoint halves** — `EditMacro(sel, name, icon)` from the rename popup and
/// `EditMacro(sel, nil, nil, text)` from `MacroFrame_SaveMacro` — so a nil that overwrote would
/// blank the body every time the name changed.
fn edit_macro(
model: &mut Model,
index: usize,
name: Option<String>,
texture: Option<Option<String>>,
body: Option<String>,
local_only: Option<bool>,
) -> Option<usize> {
let at = MacroIndex::split(index)?;
let entry = model.macros.list_mut(at.per_character).get_mut(at.pos)?;
if let Some(name) = name {
let name = clamp_chars(name.trim(), MAX_MACRO_NAME);
// An explicit blank is refused rather than stored: `MacroPopupOkayButton_Update` already
// keeps OKAY disabled on an empty box, so reaching here means a script did it.
if !name.is_empty() {
entry.name = name;
}
}
if let Some(texture) = texture {
entry.texture = texture;
}
if let Some(body) = body {
entry.body = clamp_chars(&body, MAX_MACRO_BODY);
}
if let Some(local_only) = local_only {
entry.local_only = local_only;
}
model.macros_dirty = true;
model.macros_generation += 1;
Some(index)
}
/// `DeleteMacro(index)` — removes the slot and **closes the gap**, which is why every action-bar
/// slot holding a higher macro index would now point at the wrong macro. The reference has the
/// same property (its own list is dense, and `MacroFrame_Update` draws it densely); the app's
/// action feed re-resolves against the shifted table, so a bar button follows the slot, not the
/// macro. Named in decision 0983 as faithful-and-surprising rather than fixed.
fn delete_macro(model: &mut Model, index: usize) -> bool {
let Some(at) = MacroIndex::split(index) else {
return false;
};
let list = model.macros.list_mut(at.per_character);
if at.pos >= list.len() {
return false;
}
list.remove(at.pos);
model.macros_dirty = true;
model.macros_generation += 1;
true
}
/// `PickupMacro(index)` — the macro button's `OnDragStart` and the selected-macro button's
/// `OnClick` (both in the shipped `Blizzard_MacroUI.xml`). Loads the cursor with the macro
/// payload (the client's mode **8**, `[0xb4e2fc]` — wow-re `cursor-dragdrop-payload.md` §1), which
/// `PlaceAction` then packs as `macroId | 0x40000000` (`action-item-slot.md`'s payload table).
///
/// Refuses while the cursor already holds something, matching `PickupSpell`'s precedent: a macro
/// button is a SOURCE, never a fit-checked drop target, so silently discarding the held payload
/// would be worse than doing nothing.
fn pickup_macro(model: &mut Model, index: usize) -> bool {
if model.cursor.is_some() {
return false;
}
let Some(entry) = model.macros.get(index) else {
return false;
};
let payload = CursorMacro {
index: index as u32,
texture: entry.texture.clone(),
};
model.cursor = Some(CursorPayload::Macro(payload));
queue_cursor_update(model);
true
}
/// Coerce a Lua argument that the shipped UI passes as either a real value or `nil`/`false`.
fn opt_string(v: Option<&Value>) -> Option<String> {
match v {
Some(Value::String(s)) => Some(s.to_string_lossy()),
_ => None,
}
}
/// The reference's own truthiness for the `local`/`perCharacter` flags: `MacroPopupOkayButton_OnClick`
/// passes the Lua boolean `(MacroFrame.macroBase > 0)`, and the shipped `local` argument is always
/// `nil`. `0` reads falsy here (the numeric convention `UseAction`'s `checkCursor` established —
/// `super::action::truthy_nonzero`'s law, shared so the two cannot drift).
fn flag(v: Option<&Value>) -> bool {
v.is_some_and(super::action::truthy_nonzero)
}
/// Register the macro globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// GetNumMacros() -> numAccountMacros, numCharacterMacros.
g.set(
"GetNumMacros",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok((
model.macros.account.len() as i64,
model.macros.character.len() as i64,
))
})?,
)?;
// GetMacroInfo(index) -> name, texture, body, isLocal. An empty/out-of-range slot answers a
// single nil (the out-of-range shape every list binding in this crate uses).
g.set(
"GetMacroInfo",
lua.create_function(|lua, index: usize| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(m) = model.macros.get(index) else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
let texture = match &m.texture {
Some(t) => Value::String(lua.create_string(t)?),
None => Value::Nil,
};
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&m.name)?),
texture,
Value::String(lua.create_string(&m.body)?),
if m.local_only {
Value::Integer(1)
} else {
Value::Nil
},
]))
})?,
)?;
// GetMacroIndexByName(name) -> index (0 = no match).
g.set(
"GetMacroIndexByName",
lua.create_function(|lua, name: String| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.macros.index_by_name(&name) as i64)
})?,
)?;
// GetNumMacroIcons() -> the chooser list's length.
g.set(
"GetNumMacroIcons",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.macro_icons.len() as i64)
})?,
)?;
// GetMacroIconInfo(index) -> texture path; 1-based, out of range -> nil.
g.set(
"GetMacroIconInfo",
lua.create_function(|lua, index: usize| {
let path = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
index
.checked_sub(1)
.and_then(|i| model.macro_icons.get(i))
.cloned()
};
match path {
Some(p) => Ok(Value::String(lua.create_string(&p)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// CreateMacro(name, iconIndex, body, local, perCharacter) -> index | nil.
// `iconIndex` is an index into the chooser list (the shipped popup passes
// `MacroPopupFrame.selectedIcon`), resolved to a path HERE so the stored macro keeps a name
// that survives a restart.
g.set(
"CreateMacro",
lua.create_function(|lua, args: MultiValue| {
let a: Vec<Value> = args.into_iter().collect();
let name = opt_string(a.first()).unwrap_or_default();
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let texture = icon_path(&model, a.get(1));
let body = opt_string(a.get(2)).unwrap_or_default();
let (local_only, per_character) = (flag(a.get(3)), flag(a.get(4)));
Ok(
match create_macro(&mut model, &name, texture, &body, local_only, per_character) {
Some(i) => Value::Integer(i as i64),
None => Value::Nil,
},
)
})?,
)?;
// EditMacro(index, name, icon, body, local) -> index | nil. Absent arguments leave their
// field alone (see `edit_macro` — the shipped UI relies on it).
g.set(
"EditMacro",
lua.create_function(|lua, args: MultiValue| {
let a: Vec<Value> = args.into_iter().collect();
let Some(index) = a.first().and_then(|v| v.as_integer()).map(|i| i as usize) else {
return Ok(Value::Nil);
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// An icon argument that is present-and-resolvable replaces; an absent/nil one leaves
// the icon alone. (`MacroFrame_SaveMacro` passes nil here on every body-only save.)
let texture = match a.get(2) {
None | Some(Value::Nil) => None,
other => Some(icon_path(&model, other)),
};
let name = opt_string(a.get(1));
let body = opt_string(a.get(3));
let local_only = match a.get(4) {
None | Some(Value::Nil) => None,
other => Some(flag(other)),
};
Ok(
match edit_macro(&mut model, index, name, texture, body, local_only) {
Some(i) => Value::Integer(i as i64),
None => Value::Nil,
},
)
})?,
)?;
g.set(
"DeleteMacro",
lua.create_function(|lua, index: usize| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(delete_macro(&mut model, index))
})?,
)?;
g.set(
"PickupMacro",
lua.create_function(|lua, index: usize| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(pickup_macro(&mut model, index))
})?,
)?;
Ok(())
}
/// Resolve a chooser-list **index** argument to a texture path. A string is taken as a path
/// already (nothing shipped does that, but a script may, and the store holds paths anyway).
fn icon_path(model: &Model, v: Option<&Value>) -> Option<String> {
match v {
Some(Value::String(s)) => Some(s.to_string_lossy()),
Some(v) => {
let i = v.as_integer()?;
let i = usize::try_from(i).ok()?.checked_sub(1)?;
model.macro_icons.get(i).cloned()
}
None => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::script::UiScript;
fn seeded() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_macro_icons(vec![
"Interface\\Icons\\Ability_Ambush".into(),
"Interface\\Icons\\Ability_BackStab".into(),
"Interface\\Icons\\Spell_Fire_FlameBolt".into(),
]);
s
}
/// The whole create → read → edit → delete round trip through the reference's own signatures,
/// including the index space's account/character split.
#[test]
fn the_macro_api_round_trip_over_the_two_tabs() {
let s = seeded();
assert_eq!(
s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c")
.unwrap(),
(0, 0)
);
// Account tab: perCharacter falsy -> index 1.
let i = s
.eval::<i64>(r#"return CreateMacro("Ambush", 1, "/cast Ambush", nil, nil)"#)
.unwrap();
assert_eq!(i, 1);
// Character tab: perCharacter truthy -> the 19 base.
let j = s
.eval::<i64>(r#"return CreateMacro("Bolt", 3, "/cast Fireball", nil, true)"#)
.unwrap();
assert_eq!(j, 19, "the character tab starts at MAX_MACROS + 1");
assert_eq!(
s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c")
.unwrap(),
(1, 1)
);
// GetMacroInfo's four returns, with the icon index resolved to a path at create time.
let (name, tex, body, is_local) = s
.eval::<(String, String, String, Value)>(
"local n, t, b, l = GetMacroInfo(1) return n, t, b, l",
)
.unwrap();
assert_eq!(
(name.as_str(), tex.as_str(), body.as_str()),
("Ambush", "Interface\\Icons\\Ability_Ambush", "/cast Ambush")
);
assert_eq!(is_local, Value::Nil);
// By-name is case-insensitive and searches the account list first; a miss is 0.
assert_eq!(
s.eval::<i64>(r#"return GetMacroIndexByName("ambush")"#)
.unwrap(),
1
);
assert_eq!(
s.eval::<i64>(r#"return GetMacroIndexByName("bolt")"#)
.unwrap(),
19
);
assert_eq!(
s.eval::<i64>(r#"return GetMacroIndexByName("nope")"#)
.unwrap(),
0
);
// Delete closes the gap: the character macro keeps its own base.
assert!(s.eval::<bool>("return DeleteMacro(1)").unwrap());
assert_eq!(
s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c")
.unwrap(),
(0, 1)
);
assert!(s.eval::<bool>("return GetMacroInfo(1) == nil").unwrap());
assert_eq!(s.eval::<String>("return GetMacroInfo(19)").unwrap(), "Bolt");
}
/// `EditMacro`'s partial-update law — the shipped UI's two disjoint calls. A rename must not
/// blank the body, and a body save must not blank the name or icon.
#[test]
fn edit_macro_leaves_omitted_fields_alone() {
let s = seeded();
s.run(r#"CreateMacro("Old", 1, "/cast Ambush")"#).unwrap();
// MacroPopupOkayButton_OnClick's edit form: name + icon, no body.
s.run(r#"EditMacro(1, "New", 2)"#).unwrap();
let (name, tex, body) = s
.eval::<(String, String, String)>("local n, t, b = GetMacroInfo(1) return n, t, b")
.unwrap();
assert_eq!(name, "New");
assert_eq!(tex, "Interface\\Icons\\Ability_BackStab");
assert_eq!(body, "/cast Ambush", "the body survives a rename");
// MacroFrame_SaveMacro's form: body only, name and icon nil.
s.run(r#"EditMacro(1, nil, nil, "/cast Backstab")"#)
.unwrap();
let (name, tex, body) = s
.eval::<(String, String, String)>("local n, t, b = GetMacroInfo(1) return n, t, b")
.unwrap();
assert_eq!(
(name.as_str(), tex.as_str(), body.as_str()),
(
"New",
"Interface\\Icons\\Ability_BackStab",
"/cast Backstab"
),
"a body save touches neither the name nor the icon"
);
}
/// The two refusals the client names in its own log lines, and the caps the edit boxes carry.
#[test]
fn create_refuses_a_blank_name_and_a_full_tab_and_clamps_the_caps() {
let s = seeded();
assert!(s
.eval::<bool>(r#"return CreateMacro("", 1, "x") == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return CreateMacro(" ", 1, "x") == nil"#)
.unwrap());
for i in 1..=MAX_MACROS {
let made = s
.eval::<i64>(&format!(r#"return CreateMacro("m{i}", 1, "")"#))
.unwrap();
assert_eq!(made as usize, i);
}
assert!(
s.eval::<bool>(r#"return CreateMacro("one too many", 1, "") == nil"#)
.unwrap(),
"the 19th account macro is refused"
);
// …and the character tab is a separate 18.
assert_eq!(
s.eval::<i64>(r#"return CreateMacro("c", 1, "", nil, 1)"#)
.unwrap(),
19
);
// The caps are the shipped boxes' own letters= values, enforced at the API too.
let s = seeded();
s.run(&format!(
r#"CreateMacro("{}", 1, "{}")"#,
"N".repeat(40),
"b".repeat(400)
))
.unwrap();
let (name, body) = s
.eval::<(String, String)>("local n, _, b = GetMacroInfo(1) return n, b")
.unwrap();
assert_eq!(name.chars().count(), MAX_MACRO_NAME);
assert_eq!(body.chars().count(), MAX_MACRO_BODY);
}
/// The dirty flag is the app's save trigger: every mutation raises it, a seed never does, and
/// the drain clears it.
#[test]
fn mutations_raise_the_dirty_flag_and_a_seed_does_not() {
let mut s = seeded();
assert!(!s.take_macros_dirty());
s.run(r#"CreateMacro("a", 1, "")"#).unwrap();
assert!(s.take_macros_dirty());
assert!(!s.take_macros_dirty(), "the drain clears it");
s.run(r#"EditMacro(1, nil, nil, "/say hi")"#).unwrap();
assert!(s.take_macros_dirty());
s.run("DeleteMacro(1)").unwrap();
assert!(s.take_macros_dirty());
// A no-op delete changes nothing and raises nothing.
s.run("DeleteMacro(7)").unwrap();
assert!(!s.take_macros_dirty());
// The app's own seed must not trigger a save (a write-back loop).
s.set_macros(MacroState {
account: vec![MacroView {
name: "loaded".into(),
..Default::default()
}],
character: Vec::new(),
});
assert!(!s.take_macros_dirty());
assert_eq!(s.macros().account.len(), 1);
}
/// `PickupMacro` loads the cursor with the macro payload, and refuses while holding.
#[test]
fn pickup_macro_loads_the_cursor_and_refuses_while_holding() {
use crate::script::cursor::CursorPayload;
let s = seeded();
s.run(r#"CreateMacro("Ambush", 1, "/cast Ambush")"#)
.unwrap();
assert!(
!s.eval::<bool>("return PickupMacro(5)").unwrap(),
"empty slot"
);
assert!(s.eval::<bool>("return PickupMacro(1)").unwrap());
assert_eq!(
s.cursor_payload(),
Some(CursorPayload::Macro(CursorMacro {
index: 1,
texture: Some("Interface\\Icons\\Ability_Ambush".into()),
}))
);
// GetCursorInfo's Era shape for a macro: the kind word + the index.
assert_eq!(
s.eval::<(String, i64)>("local k, i = GetCursorInfo() return k, i")
.unwrap(),
("macro".to_string(), 1)
);
assert!(
!s.eval::<bool>("return PickupMacro(1)").unwrap(),
"a macro button is a source, never a drop target"
);
}
/// The chooser list is the app's, served 1-based, and an out-of-range index is nil rather
/// than an error (`MacroPopupFrame_Update` indexes past the end on the last row every time).
#[test]
fn the_icon_chooser_list_is_one_based_with_a_nil_tail() {
let s = seeded();
assert_eq!(s.eval::<i64>("return GetNumMacroIcons()").unwrap(), 3);
assert_eq!(
s.eval::<String>("return GetMacroIconInfo(2)").unwrap(),
"Interface\\Icons\\Ability_BackStab"
);
assert!(s.eval::<bool>("return GetMacroIconInfo(4) == nil").unwrap());
assert!(s.eval::<bool>("return GetMacroIconInfo(0) == nil").unwrap());
}
}
+12 -27
View File
@@ -52,8 +52,6 @@ mod editbox;
pub(crate) use editbox::adopt_text_region;
mod cvars;
mod duel;
#[cfg(test)]
mod era_atlas_tests;
mod event;
mod extract;
mod follow;
@@ -64,6 +62,7 @@ mod item_text;
mod layout;
mod loot;
mod loot_roll;
mod macros;
mod mail;
mod merchant;
mod messageframe;
@@ -72,6 +71,7 @@ mod model;
mod net_stats;
mod object;
mod party;
mod pet;
mod pointer;
mod pvp;
mod quest;
@@ -113,7 +113,8 @@ 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, EnchantConfirm, WorldPick, EQUIPMENT_BAG,
CursorAction, CursorItem, CursorMacro, CursorPayload, CursorSpell, EnchantConfirm, WorldPick,
EQUIPMENT_BAG,
};
pub use death::{DeathAction, DeathUiState};
pub use duel::DuelRequest;
@@ -124,10 +125,12 @@ pub use item_stats::{item_usable, ItemSetView, ItemTemplateView, PlayerReqState}
pub use item_text::ItemTextState;
pub use loot::{LootRow, LootState};
pub use loot_roll::{LootRollEntry, LootRollsState};
pub use macros::{MacroState, MacroView, MAX_MACROS, MAX_MACRO_BODY, MAX_MACRO_NAME};
pub use mail::{MailInboxRow, MailSendRequest, MailState};
pub use merchant::{ItemStatsHead, MerchantItem, MerchantState};
pub(crate) use model::Model;
pub use party::{PartyMemberInfo, PartyRequest, PartyState};
pub use pet::PetActionView;
pub use quest::{QuestAction, QuestItemView, QuestPanel, QuestSelect, QuestState};
pub use quest_log::{QuestLogDetail, QuestLogEntryView, QuestLogObjectiveView, QuestLogState};
pub use session::SessionRequest;
@@ -135,7 +138,7 @@ pub use shapeshift::ShapeshiftFormView;
pub use skills::{SkillEntry, SkillsState};
pub use social::{FriendInfo, SocialRequest, SocialState, WhoInfo};
pub use sound::SoundRequest;
pub use spellbook::{SpellBookState, SpellSlotView, SpellTabView};
pub use spellbook::{resolve_spell_by_name, SpellBookState, SpellSlotView, SpellTabView};
pub use talent::{TalentPrereqView, TalentTabView, TalentUiState, TalentView};
pub use taxi::{TaxiNodeType, TaxiUiNode, TaxiUiState};
pub use tooltip_spell::SpellTooltipView;
@@ -146,9 +149,9 @@ pub use trainer::{
TrainerState,
};
pub use types::{
EditAction, EditBoxTextUi, EditOutcome, EditUnit, EraAtlasEntry, ExtractedQuad, FontObject,
FontShadow, JustifyH, JustifyV, LineMeasureRequest, MeasureRequest, Outline, QuadContent,
ScriptValue, TexCoords,
EditAction, EditBoxTextUi, EditOutcome, EditUnit, ExtractedQuad, FontObject, FontShadow,
JustifyH, JustifyV, LineMeasureRequest, MeasureRequest, Outline, QuadContent, ScriptValue,
TexCoords,
};
pub(crate) use types::{MeasuredText, RegionData};
pub use unit::{grey_band, level_reads_unknown, power_token, unit_is_grey, UnitState};
@@ -276,8 +279,10 @@ impl UiScript {
container::install(&lua)?;
cursor::install(&lua)?;
spellbook::install(&lua)?;
macros::install(&lua)?;
talent::install(&lua)?;
shapeshift::install(&lua)?;
pet::install(&lua)?;
gossip::install(&lua)?;
merchant::install(&lua)?;
bank::install(&lua)?;
@@ -333,26 +338,6 @@ impl UiScript {
}
/// Replace the Era atlas table (decision 0950) — pushed once at boot, before the XML loads.
/// Keys are stored lowercased (the DB2 dump's own convention); `SetAtlas`/`atlas=` match
/// case-insensitively. The app builds the entries from `WoW-era/_extracted_ui/manifest.json`;
/// the script side only stores resolved paint, never touching disk.
pub fn set_era_atlases(&mut self, entries: impl IntoIterator<Item = (String, EraAtlasEntry)>) {
let mut model = self.model_mut();
model.era_atlas = entries
.into_iter()
.map(|(k, v)| (k.to_ascii_lowercase(), v))
.collect();
}
/// Drain the atlas names `SetAtlas` failed to resolve (each reported once, sorted) — the app
/// logs them as WARNs pointing at `scripts/era-extract.py`: a stale extraction must name
/// itself, never silently draw blank.
pub fn take_era_atlas_misses(&mut self) -> Vec<String> {
let mut misses: Vec<String> = self.model_mut().era_atlas_missing.drain().collect();
misses.sort();
misses
}
/// Push the modifier-key state (shift, ctrl, alt) behind `IsShiftKeyDown`/`IsControlKeyDown`/
/// `IsAltKeyDown`. The app's input pass calls this BEFORE feeding the frame's mouse events, so
/// a click handler's modifier fork (the reference's shift-split / ctrl-dressup /
+38 -12
View File
@@ -5,9 +5,9 @@ use crate::widget::{FrameHandle, RegionHandle, WidgetArena};
use super::{
backdrop, bank, char_stats, container, craft, cursor, death, duel, follow, gossip, inspect,
item_text, loot, loot_roll, mail, merchant, party, quest, quest_log, session, skills, slider,
social, spellbook, taxi, trade, tradeskill, trainer, ActionSlot, AuraState, EraAtlasEntry,
FontObject, ItemTemplateView, PlayerReqState, RegionData, ScriptValue, SoundRequest, UnitState,
item_text, loot, loot_roll, macros, mail, merchant, party, quest, quest_log, session, skills,
slider, social, spellbook, taxi, trade, tradeskill, trainer, ActionSlot, AuraState, FontObject,
ItemTemplateView, PlayerReqState, RegionData, ScriptValue, SoundRequest, UnitState,
};
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -71,13 +71,6 @@ pub(crate) struct Model {
pub(crate) chat_tab: bool,
/// Region visuals (texture path/color/text) + region layout (anchors/size/justify).
pub(crate) region_data: HashMap<RegionHandle, RegionData>,
/// The Era atlas table (decision 0950): lowercased member name → resolved paint. Pushed once
/// by [`super::UiScript::set_era_atlases`] before the XML loads; read by `SetAtlas` and the
/// loader's `atlas=` attribute.
pub(crate) era_atlas: HashMap<String, EraAtlasEntry>,
/// Atlas names `SetAtlas` could not resolve — warn-once, drained by
/// [`super::UiScript::take_era_atlas_misses`] for app-side WARNs.
pub(crate) era_atlas_missing: HashSet<String>,
/// Per-frame installed [`Backdrop`] (the tooltip/dialog/panel plate — `<Backdrop>` or
/// `SetBackdrop`). Absent ⇒ the frame draws no plate. Stored beside the arena like `region_data`
/// (the arena models structure, not paint). The client's `frame+0x1ac` pointer.
@@ -242,6 +235,20 @@ pub(crate) struct Model {
/// `GetSpellTabInfo`/`GetSpellName`/… bindings read ([`spellbook`]). Durable player state like
/// `actions` above, never `Option` — "no known spells yet" is simply empty vectors.
pub(crate) spellbook: spellbook::SpellBookState,
/// The player's **macros** (decision 0983) — the one game-state table this crate owns
/// outright, because 1.12 macros have no server side at all ([`macros`]'s module docs). The
/// app seeds it from `benilla/macros/…` and reads it back to persist.
pub(crate) macros: macros::MacroState,
/// A script mutated [`Self::macros`] since the app's last
/// [`super::UiScript::take_macros_dirty`] drain — the save + `UPDATE_MACROS` trigger.
pub(crate) macros_dirty: bool,
/// Bumped by every seed and every mutation — the *undrained* change signal per-frame
/// consumers gate on ([`super::UiScript::macros_generation`]), so the drained
/// [`Self::macros_dirty`] edge keeps its single owner.
pub(crate) macros_generation: u64,
/// The macro-icon chooser list (full texture paths, app-built off `SpellIcon.dbc`) —
/// `GetNumMacroIcons`/`GetMacroIconInfo`.
pub(crate) macro_icons: Vec<String>,
/// Spell ids `CastSpell` queued since the app's last [`super::UiScript::take_spell_casts`]
/// drain.
pub(crate) spell_casts: Vec<u32>,
@@ -281,6 +288,19 @@ pub(crate) struct Model {
/// [`super::UiScript::take_shapeshift_casts`] drain.
pub(crate) shapeshift_casts: Vec<u32>,
/// The pet action bar the app pushes — the ten slots plus the two bar-wide bits the
/// `PetHasActionBar`/`GetPetActionsUsable`/`GetPetActionInfo` family reads ([`super::pet`],
/// decision 0982). Unlike the stance bar's, this state is **server-authoritative**: it is
/// replaced wholesale on every `SMSG_PET_SPELLS`, and is empty whenever there is no pet.
pub(crate) pet_bar: super::pet::PetBarState,
/// 1-based slot indices `CastPetAction` queued since the app's last
/// [`super::UiScript::take_pet_actions`] drain.
pub(crate) pet_actions_pressed: Vec<u32>,
/// 1-based slot indices `TogglePetAutocast` queued.
pub(crate) pet_autocast_toggles: Vec<u32>,
/// `PetStopAttack()` calls queued — a count, since the verb takes no argument.
pub(crate) pet_stop_attacks: u32,
/// The per-bag container snapshot (keyed by live-API bag id, 0 = backpack) the app pushes,
/// and the `UseContainerItem` intents it drains — the container seam ([`container`]).
pub(crate) containers: HashMap<i64, container::ContainerState>,
@@ -702,8 +722,6 @@ impl Model {
link_spans: HashMap::new(),
chat_tab: false,
region_data: HashMap::new(),
era_atlas: HashMap::new(),
era_atlas_missing: HashSet::new(),
backdrops: HashMap::new(),
font_objects: HashMap::new(),
region_resolved: HashMap::new(),
@@ -750,6 +768,10 @@ impl Model {
action_sets: Vec::new(),
ui_errors: Vec::new(),
spellbook: spellbook::SpellBookState::default(),
macros: macros::MacroState::default(),
macros_dirty: false,
macros_generation: 0,
macro_icons: Vec::new(),
spell_casts: Vec::new(),
casting: false,
spell_stop: false,
@@ -759,6 +781,10 @@ impl Model {
talent_learns: Vec::new(),
shapeshift_forms: Vec::new(),
shapeshift_casts: Vec::new(),
pet_bar: super::pet::PetBarState::default(),
pet_actions_pressed: Vec::new(),
pet_autocast_toggles: Vec::new(),
pet_stop_attacks: 0,
containers: HashMap::new(),
container_uses: Vec::new(),
container_cooldowns: HashMap::new(),
+432
View File
@@ -0,0 +1,432 @@
//! The pet action bar seam (decision 0982) — the eight bindings `PetActionBarFrame.lua` consumes
//! (`GetPetActionInfo`/`GetPetActionsUsable`/`GetPetActionCooldown`/`PetHasActionBar`/
//! `CastPetAction`/`TogglePetAutocast`/`IsPetAttackActive`/`PetStopAttack`) over an app-pushed
//! slot snapshot, in [`super::shapeshift`]'s two-way shape: the app resolves everything (which
//! slot is a command, a reaction or a spell; its icon, name, checked and autocast bits; its
//! cooldown) and pushes it ([`super::UiScript::set_pet_actions`]); the engine drains the click
//! intents ([`super::UiScript::take_pet_actions`] and kin) back out.
//!
//! **The engine holds no pet knowledge**: a slot here is "a name, a subtext, a texture, four bits
//! and a cooldown triple". In particular it does not know that a token slot's `name`/`texture` are
//! *the names of globals* rather than values — that convention belongs to the reference's own
//! `GetPetActionInfo` and is reproduced faithfully by the app, which sets `is_token` and lets the
//! Lua do the `getglobal` (the shipped `PetActionBarFrame.lua:98-104` fork).
//!
//! Return conventions are the 1.12 API's own, matching [`super::action`]: 1/nil booleans, and the
//! cooldown as `(start_s on the GetTime clock, duration_s, enable)` with the same
//! elapsed-goes-cold rule `GetActionCooldown` uses.
use mlua::{Lua, MultiValue, Value};
use super::Model;
/// One pet bar slot, fully resolved by the app before pushing.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PetActionView {
/// `GetPetActionInfo`'s first return, and the slot's OCCUPANCY test — `None` hides the button
/// (`PetActionBarFrame.lua:122-128`). For a spell slot this is the spell's name; for a token
/// slot it is **the name of a global** (`"PET_ACTION_ATTACK"`), which the Lua resolves.
pub name: Option<String>,
/// The second return — the spell's rank line, or `None` for a token.
pub subtext: Option<String>,
/// The third return: an icon path for a spell, **the name of a global**
/// (`"PET_ATTACK_TEXTURE"`) for a token. `None` leaves the button art empty and swaps its
/// NormalTexture to the unfilled `UI-Quickslot`.
pub texture: Option<String>,
/// Is this a command/reaction token (so `name`/`texture` are global names)?
pub is_token: bool,
/// The slot's spell, when it has one — what `GameTooltip:SetPetAction` renders. `None` for a
/// token and for an empty slot. Not a `GetPetActionInfo` return: the reference's tooltip
/// channel reaches the pet spellbook itself, and this is that reach.
pub spell_id: Option<u32>,
/// The checked ring.
pub active: bool,
/// This slot CAN autocast — the static `UI-AutoCastableOverlay` ring.
pub autocast_allowed: bool,
/// …and it currently does — the sparkle trail.
pub autocast_enabled: bool,
/// Whether a left click on this slot means "call the pet off" rather than "do this"
/// (`IsPetAttackActive`, the Attack button's second press).
pub attack_active: bool,
/// `(start_ms on the GetTime clock, duration_ms, enabled)` — [`super::action::ActionState`]'s
/// exact shape; `None` = no cooldown.
pub cooldown: Option<(i64, u32, bool)>,
}
/// [`PetActionView`] as stored: the cooldown converted to the `GetTime` clock at push time (the
/// [`super::shapeshift::StoredShapeshiftForm`] pattern).
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct StoredPetAction {
pub(crate) view: PetActionView,
/// `(start_s, duration_s, enabled)` in `GetTime` seconds; `None` = no cooldown.
pub(crate) cooldown: Option<(f64, f64, bool)>,
}
/// The pet bar's pushed state: the slots plus the two bar-wide bits the reference exposes
/// separately from them.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct PetBarState {
/// `PetHasActionBar()` — is there a bar at all. Distinct from "the slot list is empty": a
/// possessed minion has a bar of pure commands, and a bar of ten empty slots is still a bar.
pub(crate) has_bar: bool,
/// `GetPetActionsUsable()` — false desaturates every icon on the bar at once.
pub(crate) actions_usable: bool,
pub(crate) slots: Vec<StoredPetAction>,
}
impl super::UiScript {
/// Push the whole pet bar, replacing whatever was there. A bare setter — firing
/// `PET_BAR_UPDATE` is the app's diff-and-fire job, mirroring `set_shapeshift_forms`.
pub fn set_pet_actions(
&mut self,
has_bar: bool,
actions_usable: bool,
slots: Vec<PetActionView>,
) {
let mut model = self.model_mut();
model.pet_bar = PetBarState {
has_bar,
actions_usable,
slots: slots
.into_iter()
.map(|view| {
// The cooldown arrives with its absolute start already on the `GetTime` clock
// (ms) — storing is a pure unit conversion, `set_shapeshift_forms`' seam.
let cooldown = view.cooldown.map(|(start_ms, duration_ms, enabled)| {
(
start_ms as f64 / 1000.0,
f64::from(duration_ms) / 1000.0,
enabled,
)
});
StoredPetAction { view, cooldown }
})
.collect(),
};
}
/// Drain the 1-based slot indices `CastPetAction` queued since the last call. What each index
/// *means* on the wire (a command, a reaction, a cast) is the app's to decide at drain time
/// from the slot it still owns.
pub fn take_pet_actions(&mut self) -> Vec<u32> {
std::mem::take(&mut self.model_mut().pet_actions_pressed)
}
/// Drain the 1-based slot indices `TogglePetAutocast` queued.
pub fn take_pet_autocast_toggles(&mut self) -> Vec<u32> {
std::mem::take(&mut self.model_mut().pet_autocast_toggles)
}
/// Drain the `PetStopAttack()` calls queued (a count — the verb carries no argument).
pub fn take_pet_stop_attacks(&mut self) -> u32 {
std::mem::replace(&mut self.model_mut().pet_stop_attacks, 0)
}
}
/// The 1-based button index → stored slot, the reference's own indexing.
fn slot_at(model: &Model, i: u32) -> Option<&StoredPetAction> {
usize::try_from(i.checked_sub(1)?)
.ok()
.and_then(|n| model.pet_bar.slots.get(n))
}
/// Register the pet-bar globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
let flag = |b: bool| if b { Value::Integer(1) } else { Value::Nil };
// PetHasActionBar() → 1/nil. The bar frame's whole show/hide gate.
g.set(
"PetHasActionBar",
lua.create_function(move |lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(flag(model.pet_bar.has_bar))
})?,
)?;
// GetPetActionsUsable() → 1/nil — one answer for the whole bar (the SetDesaturation sweep).
g.set(
"GetPetActionsUsable",
lua.create_function(move |lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(flag(model.pet_bar.actions_usable))
})?,
)?;
// GetPetActionInfo(i) → name, subtext, texture, isToken, isActive, autoCastAllowed,
// autoCastEnabled. An out-of-range index answers a single nil, which the Lua's `if (name)`
// occupancy test reads exactly as an empty slot (the spellbook bindings' shape).
g.set(
"GetPetActionInfo",
lua.create_function(move |lua, i: u32| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(slot) = slot_at(&model, i) else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
let v = &slot.view;
let text = |s: &Option<String>| match s {
Some(s) => Ok(Value::String(lua.create_string(s)?)),
None => Ok::<_, mlua::Error>(Value::Nil),
};
Ok(MultiValue::from_vec(vec![
text(&v.name)?,
text(&v.subtext)?,
text(&v.texture)?,
flag(v.is_token),
flag(v.active),
flag(v.autocast_allowed),
flag(v.autocast_enabled),
]))
})?,
)?;
// GetPetActionCooldown(i) → start, duration, enable — GetActionCooldown's triple and its
// elapsed-goes-cold rule (an elapsed/absent cooldown answers (0, 0, 1) so a re-feed never
// replays the sweep).
g.set(
"GetPetActionCooldown",
lua.create_function(|lua, i: u32| {
let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0);
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(match slot_at(&model, i).and_then(|s| s.cooldown) {
Some((start, duration, enabled)) if start + duration > now || !enabled => {
(start, duration, i32::from(enabled))
}
_ => (0.0, 0.0, 1),
})
})?,
)?;
// IsPetAttackActive(i) → a BOOLEAN — the left-click fork: true means the press should call
// the pet OFF (`PetStopAttack`) instead of running the slot.
//
// The odd one out of this file's returns, and deliberately so: it is the single pet binding
// that pushes a real Lua boolean (`0x6f39f0`) rather than the 1/nil the rest use, so it
// answers `false`, never nil, even out of range. Consumers only ever test it for truth, so
// the difference is invisible in use — but a seam that quietly upgraded it to the house
// convention would be lying about the API.
g.set(
"IsPetAttackActive",
lua.create_function(move |lua, i: u32| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(slot_at(&model, i).is_some_and(|s| s.view.attack_active))
})?,
)?;
// CastPetAction(i) — queue the press. An EMPTY slot queues nothing: the reference's bar hides
// an unnamed button, so a press on one can only come from the show-grid state, where it must
// be inert.
g.set(
"CastPetAction",
lua.create_function(|lua, i: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if slot_at(&model, i).is_some_and(|s| s.view.name.is_some()) {
model.pet_actions_pressed.push(i);
}
Ok(())
})?,
)?;
// TogglePetAutocast(i) — queue the right-click. Only a slot that CAN autocast queues: the
// wire verb names a spell id, and a command token has none.
g.set(
"TogglePetAutocast",
lua.create_function(|lua, i: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if slot_at(&model, i).is_some_and(|s| s.view.autocast_allowed) {
model.pet_autocast_toggles.push(i);
}
Ok(())
})?,
)?;
// PetStopAttack() — queue the call-off. No argument: the wire carries only the pet's guid.
g.set(
"PetStopAttack",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.pet_stop_attacks += 1;
Ok(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::PetActionView;
use crate::script::UiScript;
/// A hunter's bar, cut down to the three slot classes that matter: the Attack command (a
/// token, currently attacking), Claw (a spell with autocast ON and a running cooldown), and an
/// empty middle slot.
fn slots() -> Vec<PetActionView> {
vec![
PetActionView {
name: Some("PET_ACTION_ATTACK".into()),
texture: Some("PET_ATTACK_TEXTURE".into()),
is_token: true,
active: true,
attack_active: true,
..Default::default()
},
PetActionView {
name: Some("Claw".into()),
subtext: Some("Rank 3".into()),
texture: Some("Interface\\Icons\\Ability_Druid_Rake".into()),
autocast_allowed: true,
autocast_enabled: true,
cooldown: Some((9400, 1500, true)),
..Default::default()
},
PetActionView::default(),
]
}
#[test]
fn slot_info_reads_and_out_of_range_is_one_nil() {
let mut s = UiScript::new().unwrap();
assert!(s.eval::<bool>("return PetHasActionBar() == nil").unwrap());
assert!(s.eval::<bool>("return GetPetActionInfo(1) == nil").unwrap());
s.set_pet_actions(true, true, slots());
assert_eq!(s.eval::<i64>("return PetHasActionBar()").unwrap(), 1);
assert_eq!(s.eval::<i64>("return GetPetActionsUsable()").unwrap(), 1);
// The token slot returns GLOBAL NAMES, and says so with isToken.
let (name, subtext, texture, is_token, active, allowed, enabled) = s
.eval::<(
String,
Option<String>,
String,
Option<i64>,
Option<i64>,
Option<i64>,
Option<i64>,
)>("return GetPetActionInfo(1)")
.unwrap();
assert_eq!(
(
name.as_str(),
subtext,
texture.as_str(),
is_token,
active,
allowed,
enabled
),
(
"PET_ACTION_ATTACK",
None,
"PET_ATTACK_TEXTURE",
Some(1),
Some(1),
None,
None
)
);
// The spell slot returns a real name, a rank line and an icon PATH, and is not a token.
assert!(s
.eval::<bool>(
"local n, sub, tex, tok, act, allow, on = GetPetActionInfo(2) \
return n == 'Claw' and sub == 'Rank 3' and tok == nil and act == nil \
and allow == 1 and on == 1 and string.find(tex, 'Icons') ~= nil"
)
.unwrap());
// The empty slot exists (so it is not the out-of-range single nil) but has no name — the
// reference's own "hide this button" test.
assert!(s
.eval::<bool>("local n, _, tex = GetPetActionInfo(3) return n == nil and tex == nil")
.unwrap());
assert!(s.eval::<bool>("return GetPetActionInfo(4) == nil").unwrap());
}
#[test]
fn cooldown_triple_stamps_to_the_vm_clock_and_goes_cold() {
let mut s = UiScript::new().unwrap();
s.tick(10.0); // GetTime == 10
s.set_pet_actions(true, true, slots());
assert_eq!(
s.eval::<(f64, f64, i32)>("return GetPetActionCooldown(1)")
.unwrap(),
(0.0, 0.0, 1),
"no cooldown reads cold"
);
let (start, duration, enable) = s
.eval::<(f64, f64, i32)>("return GetPetActionCooldown(2)")
.unwrap();
assert!((start - 9.4).abs() < 1e-9, "start {start}");
assert!((duration - 1.5).abs() < 1e-9);
assert_eq!(enable, 1);
s.tick(2.0); // now == 12 > 9.4 + 1.5
assert_eq!(
s.eval::<(f64, f64, i32)>("return GetPetActionCooldown(2)")
.unwrap(),
(0.0, 0.0, 1)
);
}
/// The three intent queues, and the two gates that keep a meaningless intent off the wire: an
/// empty slot cannot be pressed, and a slot with no autocast cannot be toggled (its wire verb
/// names a spell id, which a command token has not got).
#[test]
fn intents_queue_and_the_meaningless_ones_are_dropped() {
let mut s = UiScript::new().unwrap();
s.set_pet_actions(true, true, slots());
s.run("CastPetAction(1) CastPetAction(3) CastPetAction(9)")
.unwrap();
assert_eq!(
s.take_pet_actions(),
vec![1],
"empty + out-of-range dropped"
);
assert!(s.take_pet_actions().is_empty(), "drain empties");
s.run("TogglePetAutocast(1) TogglePetAutocast(2)").unwrap();
assert_eq!(
s.take_pet_autocast_toggles(),
vec![2],
"only the autocastable slot"
);
assert_eq!(s.take_pet_stop_attacks(), 0);
s.run("PetStopAttack() PetStopAttack()").unwrap();
assert_eq!(s.take_pet_stop_attacks(), 2);
assert_eq!(s.take_pet_stop_attacks(), 0, "drain empties");
}
/// `IsPetAttackActive` is per-slot, and it is what turns the Attack button's second press into
/// a call-off — the reference's `PetActionButton_OnClick` fork.
///
/// It answers a **boolean** on every path, including out of range — the one binding here that
/// does not use the 1/nil convention.
#[test]
fn attack_active_is_a_per_slot_boolean() {
let mut s = UiScript::new().unwrap();
s.set_pet_actions(true, true, slots());
assert!(s.eval::<bool>("return IsPetAttackActive(1)").unwrap());
assert!(!s.eval::<bool>("return IsPetAttackActive(2)").unwrap());
assert!(s
.eval::<bool>("return IsPetAttackActive(9) == false")
.unwrap());
}
/// A disabled bar still EXISTS — `PetHasActionBar` stays true while `GetPetActionsUsable`
/// goes false. The pair is what greys every icon without taking the bar off screen.
#[test]
fn a_disabled_bar_is_still_a_bar() {
let mut s = UiScript::new().unwrap();
s.set_pet_actions(true, false, slots());
assert_eq!(s.eval::<i64>("return PetHasActionBar()").unwrap(), 1);
assert!(s
.eval::<bool>("return GetPetActionsUsable() == nil")
.unwrap());
}
}
+26 -8
View File
@@ -236,10 +236,12 @@ impl UiScript {
// drop — all under one short borrow. A press fires OnClick when the hit frame registered
// "<Button>ButtonDown"; a release fires it when press+release landed on the same frame AND
// it registered "<Button>ButtonUp" — UNLESS a started drag is being resolved instead.
let (click_id, drag_release, world_dropped): (
#[allow(clippy::type_complexity)]
let (click_id, drag_release, world_dropped, slider_jump): (
Option<u32>,
Option<cursor::DragRelease>,
bool,
Option<(u32, f32)>,
) = {
let mut model = self.model_mut();
let hit_handle = hit_id.and_then(|id| model.id_to_frame.get(&id).copied());
@@ -253,16 +255,20 @@ impl UiScript {
}
}
cursor::arm_drag(&mut model, hit_handle, button, (x, y));
// A left press on a Slider's thumb begins the engine thumb drag (decision 0250 §5),
// independent of the click/drag-trio path above (a Slider isn't drag_registered).
if button == "LeftButton" {
super::slider::begin_drag(&mut model, hit_handle, x, y);
}
// A left press on a Slider begins the engine drag (0250 §5): a thumb press grabs
// it in place; a track press seats the thumb under the cursor first (the returned
// value jump — 0989), independent of the click/drag-trio path above (a Slider
// isn't drag_registered).
let jump = if button == "LeftButton" {
super::slider::begin_drag(&mut model, hit_handle, x, y)
} else {
None
};
let wants = format!("{button}Down");
let click = hit_handle
.filter(|&h| button::wants_click(&model, h, &wants))
.and(hit_id);
(click, None, false)
(click, None, false, jump)
} else {
let pressed = model.mouse_down_on.remove(button);
// A left release ends any in-flight thumb drag (decision 0250 §5).
@@ -294,9 +300,21 @@ impl UiScript {
.filter(|&h| same_frame && button::wants_click(&model, h, &wants))
.and(hit_id)
};
(click, release, dropped)
(click, release, dropped, None)
}
};
// A track press's value jump fires OnValueChanged first (outside the borrow), the same
// seam as `mouse_move`'s in-drag changes — the press IS the first move of the gesture.
if let Some((id, value)) = slider_jump {
if let Err(e) = event::fire_widget_handler(
&self.lua,
id,
"OnValueChanged",
vec![Value::Number(f64::from(value))],
) {
self.push_error(e);
}
}
let script = if down { "OnMouseDown" } else { "OnMouseUp" };
if let Some(id) = hit_id {
if let Err(e) = event::fire_widget_handler(&self.lua, id, script, vec![btn.clone()]) {
-52
View File
@@ -231,11 +231,9 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
Value::String(s) if s.to_str()?.is_empty() => {
data.texture = None;
data.color = None;
data.atlas = None;
}
Value::String(s) => {
data.texture = Some(s.to_str()?.to_string());
data.atlas = None;
}
Value::Number(_) | Value::Integer(_) => {
data.color = Some([
@@ -250,7 +248,6 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
Value::Nil => {
data.texture = None;
data.color = None;
data.atlas = None;
}
_ => {}
}
@@ -274,7 +271,6 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
data.texture = Some(path);
data.circular = true;
data.portrait_unit = None;
data.atlas = None;
Ok(())
})?,
)?;
@@ -640,54 +636,6 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
Ok((l, r, t, b))
})?,
)?;
// SetAtlas(name[, useAtlasSize]) — the Era atlas resolve (decision 0950): one call sets the
// sheet texture + UV sub-rect from the app-pushed table ([`super::UiScript::set_era_atlases`],
// baked offline from the UiTextureAtlas DB2s by scripts/era-extract.py), and with
// useAtlasSize the member's nominal size too. The reference's SetAtlas hard-errors on an
// unknown name; ours blanks the region and records a warn-once miss instead
// ([`super::UiScript::take_era_atlas_misses`]) — a stale extraction must not kill the whole
// XML load, but it must name itself.
m.set(
"SetAtlas",
lua.create_function(
|lua, (this, name, use_size): (Table, String, Option<bool>)| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let key = name.to_ascii_lowercase();
let Some(entry) = model.era_atlas.get(&key).cloned() else {
model.era_atlas_missing.insert(key);
let d = model.region_data.entry(rh).or_default();
d.texture = None;
d.atlas = None;
return Ok(());
};
let d = model.region_data.entry(rh).or_default();
d.circular = false;
d.portrait_unit = None;
d.texture = Some(entry.file);
d.tex_coords = Some(TexCoords::Rect(entry.uv));
d.atlas = Some(key);
if use_size.unwrap_or(false) {
let new = Some((entry.size[0], entry.size[1]));
let changed = !size_bits_eq(d.size, new);
d.size = new;
if changed {
model.touch_layout();
}
}
Ok(())
},
)?,
)?;
// GetAtlas() → the member name the last SetAtlas applied (nil for an ordinary texture).
m.set(
"GetAtlas",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.region_data.get(&rh).and_then(|d| d.atlas.clone()))
})?,
)?;
// SetFontObject("GameFontNormal") — re-point this FontString at a named virtual Font object: its
// resolved paint (face/height/color/outline) is copied into the region as the new default. An
// explicit SetFont/SetTextColor afterward overrides. An unknown name errors (never silently no-op).
+38 -18
View File
@@ -310,39 +310,59 @@ pub(crate) struct SliderDrag {
}
/// On a LeftButton press at `(x, y)` whose hit frame is `hit`: if that frame is an **enabled**
/// Slider with a resolved rect and the cursor lies on its thumb, begin a drag capture (records
/// [`Model::slider_drag`]). Returns whether a capture began — the caller needs nothing from it (the
/// press still fires OnMouseDown normally). A press off the thumb (on the track) captures nothing:
/// only the thumb drags, matching the vanilla scrollbar where the arrows do the stepping.
pub(super) fn begin_drag(model: &mut Model, hit: Option<FrameHandle>, x: f32, y: f32) -> bool {
let Some(h) = hit else { return false };
let Some(r) = model.resolved.get(&h).copied() else {
return false;
};
/// Slider with a resolved rect, begin a drag capture (records [`Model::slider_drag`]). A press ON
/// the thumb grabs it **offset-preserving** (the grabbed point stays under the cursor — 0250 §5);
/// a press on the TRACK seats the thumb's **center** under the cursor — the value jumps there and
/// the same capture begins, one continuous press-drag gesture (decision 0989: "the only way to
/// set the bar was the arrows"). Returns `Some((frame id, new value))` when the press itself
/// changed the value (the track jump) — the caller fires `OnValueChanged` outside the model
/// borrow, exactly like [`drag_move`]'s changes — else `None`.
pub(super) fn begin_drag(
model: &mut Model,
hit: Option<FrameHandle>,
x: f32,
y: f32,
) -> Option<(u32, f32)> {
let h = hit?;
let r = model.resolved.get(&h).copied()?;
let (enabled, vertical, fraction, thumb) = match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Slider(s)) => (s.enabled, s.vertical, s.fraction(), s.thumb),
_ => return false,
_ => return None,
};
if !enabled {
return false;
return None;
}
let size = thumb
.and_then(|rh| model.region_data.get(&rh))
.and_then(|d| d.size);
let trect = thumb_rect(r, size, vertical, fraction);
if !point_in_rect(trect, x, y) {
return false;
}
let grab_offset = if vertical {
y - trect.top
let on_thumb = point_in_rect(trect, x, y);
let grab_offset = if on_thumb {
// The grabbed thumb point rides under the cursor for the whole drag.
if vertical {
y - trect.top
} else {
x - trect.left
}
} else {
x - trect.left
// Track press: grab the thumb by its center, so the seat-under-cursor jump below and
// every subsequent move share one formula ([`drag_move`]). The offset is
// cursor − leading edge at grab: +tw/2 horizontal; −th/2 vertical (top is the leading
// edge and sits ABOVE the center in y-up, hence bottom − top, which is −th).
if vertical {
(trect.bottom - trect.top) * 0.5
} else {
(trect.right - trect.left) * 0.5
}
};
model.slider_drag = Some(SliderDrag {
slider: h,
grab_offset,
});
true
if on_thumb {
return None; // no value change from the grab itself
}
drag_move(model, x, y)
}
/// On a pointer move at `(x, y)` while a thumb is captured: recompute the value from the cursor
+104
View File
@@ -106,6 +106,15 @@ impl super::UiScript {
self.model_mut().spellbook = state;
}
/// Read the book back — for the ONE app-side consumer that must resolve a spell name by the
/// same law `CastSpellByName` does: a macro's bound spell (`benilla::ui_macro`, decision
/// 0983). Going through the pushed book rather than re-deriving from the catalog is what
/// stops the bar's cooldown swirl and the macro's own cast disagreeing about which rank a
/// bare `/cast Fireball` means.
pub fn spellbook(&self) -> SpellBookState {
self.model_mut().spellbook.clone()
}
/// Drain the spell ids `CastSpell` queued since the last call.
pub fn take_spell_casts(&mut self) -> Vec<u32> {
std::mem::take(&mut self.model_mut().spell_casts)
@@ -151,6 +160,81 @@ fn slot_index(id: u32, book_type: &str) -> Option<usize> {
usize::try_from(id.checked_sub(1)?).ok()
}
/// Resolve a spell **by name** against the player's book — the law behind `CastSpellByName` and,
/// through it, `/cast` and every macro's `/cast` line (decision 0983).
///
/// The grammar is the one the client documents in its own help text:
/// `MACRO_HELP_TEXT_LINE4 = "- To cast a spell from a macro use the following syntax:
/// /cast <name> (<subtext>)"`. So:
///
/// - **`Fireball`** — the highest-ranked *known* Fireball. Rank order is the book's own
/// `NameSubtext` number (`"Rank 8"` → 8) through the reference's own leading-number parse
/// ([`super::super::…`]'s twin lives in `benilla::ui_spellbook`); an unranked subtext sorts as 0,
/// and ties fall to the later book slot, which is the order the book itself lists ranks in.
/// - **`Fireball(Rank 1)`** / **`Fireball (Rank 1)`** — that exact subtext, case-insensitively.
/// Both spacings, because vanilla macros in the wild are written both ways and the reference's
/// own help text prints the spaced form while its FrameXML never re-spaces the argument.
///
/// Name matching is case-insensitive and whole-name — a *prefix* rule would silently cast
/// "Frostbolt" for "Frost" and there is nothing in the reference suggesting one. A spell the
/// player does not know resolves to `None` and the cast simply does not happen: the reference has
/// no error line for it either (`SlashCmdList["CAST"]` discards the binding's result).
///
/// Passives are skipped, matching [`pickup_spell`]'s sibling rule in `CastSpell`: a passive is
/// permanent player state, never something a macro casts.
pub fn resolve_spell_by_name<'a>(
book: &'a SpellBookState,
query: &str,
) -> Option<&'a SpellSlotView> {
let (name, subtext) = split_subtext(query);
if name.is_empty() {
return None;
}
book.slots
.iter()
.filter(|s| !s.passive && s.name.eq_ignore_ascii_case(name))
.filter(|s| match subtext {
Some(want) => s
.rank
.as_deref()
.is_some_and(|r| r.eq_ignore_ascii_case(want)),
None => true,
})
// Highest rank wins when no subtext pinned one; the book's own later slot breaks a tie.
.enumerate()
.max_by_key(|(i, s)| (rank_number(s.rank.as_deref()), *i))
.map(|(_, s)| s)
}
/// `Name(Subtext)` / `Name (Subtext)` → `("Name", Some("Subtext"))`; a bare name → `(name, None)`.
/// An unclosed parenthesis is not a subtext — the whole string stays the name, so a spell whose
/// own name holds a `(` cannot be silently truncated.
fn split_subtext(query: &str) -> (&str, Option<&str>) {
let q = query.trim();
let Some(open) = q.find('(') else {
return (q, None);
};
let Some(close) = q.rfind(')') else {
return (q, None);
};
if close < open {
return (q, None);
}
(q[..open].trim(), Some(q[open + 1..close].trim()))
}
/// The rank number inside a `NameSubtext` (`"Rank 8"` → 8), by the reference's own leading-number
/// parse: skip to the first digit, fold the digit run. A subtext with no digits (`"Racial"`,
/// `"Passive"`) and an absent one both read 0.
fn rank_number(subtext: Option<&str>) -> u32 {
subtext
.unwrap_or("")
.chars()
.skip_while(|c| !c.is_ascii_digit())
.take_while(|c| c.is_ascii_digit())
.fold(0u32, |acc, c| acc * 10 + c.to_digit(10).unwrap_or(0))
}
/// `PickupSpell(id, bookType)` — the drag/shift-click entry point (ref `SpellButton_OnClick`'s
/// other two forks, `SpellBookFrame.lua:263-290`). The book is a SOURCE, never a placement
/// target — the ref's plain click always casts unconditionally, never checking `GetCursorInfo`
@@ -325,6 +409,26 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// CastSpellByName(name [, onSelf]) — the reference binding `0x4b4ab0`, whose only two callers
// share the `0x4b3300` dispatcher with `CastSpell` above (wow-re ledger), so this queues onto
// the same `spell_casts` list and the app's one cast tail handles both. `SlashCmdList["CAST"]`
// is literally `CastSpellByName(msg)`, which is why `/cast` needs nothing else, and it is the
// command the whole macro system is built to run.
//
// `onSelf` is accepted and carried no further (benilla has no self-cast modifier yet — the
// same named gap `UseAction`'s third argument already has).
g.set(
"CastSpellByName",
lua.create_function(|lua, (name, _on_self): (String, MultiValue)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Some(slot) = resolve_spell_by_name(&model.spellbook, &name) {
let spell_id = slot.spell_id;
model.spell_casts.push(spell_id);
}
Ok(())
})?,
)?;
g.set(
"PickupSpell",
lua.create_function(|lua, (id, book_type): (u32, String)| {
+33 -14
View File
@@ -104,37 +104,56 @@ fn slider_thumb_drag_maps_cursor_to_value() {
}
#[test]
fn slider_track_click_and_disabled_thumb_do_not_drag() {
fn slider_track_press_seats_the_thumb_and_a_disabled_slider_ignores_it() {
let mut s = script();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
bar = CreateFrame("Slider", "SlNoDrag")
bar = CreateFrame("Slider", "SlTrack")
bar:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100)
bar:SetSize(16, 100)
bar:SetThumbTexture("Interface\\Buttons\\UI-ScrollBar-Knob")
bar:GetThumbTexture():SetSize(16, 16)
bar:SetMinMaxValues(0, 100)
bar:SetValue(0)
fired = {}
bar:SetScript("OnValueChanged", function() table.insert(fired, arg1) end)
"#,
)
.unwrap();
s.resolve();
// A press on the TRACK below the thumb (108, 120) — inside the slider but not on the thumb
// [184,200] — captures nothing: only the thumb drags (the arrows step, decision 0250).
// A press on the TRACK below the thumb (108, 120) — inside the slider, not on the thumb
// [184,200] — SEATS the thumb's center under the cursor and fires OnValueChanged from the
// press itself (0989's track-press law; 0250 §5 covered only the thumb grab). Track
// y in [100, 200], travel 84: cursor 120 → thumb top 128 → fraction (200−128)/84 = 72/84.
s.mouse_button(108.0, 120.0, "LeftButton", true);
s.mouse_move(108.0, 150.0);
let v: f32 = s.eval("return SlNoDrag:GetValue()").unwrap();
assert_eq!(v, 0.0, "track click does not drag");
s.mouse_button(108.0, 150.0, "LeftButton", false);
let v: f32 = s.eval("return SlTrack:GetValue()").unwrap();
assert!(
(v - 100.0 * (72.0 / 84.0)).abs() < 1e-3,
"the press seats the thumb center at the cursor (got {v})"
);
let n: usize = s.eval("return table.getn(fired)").unwrap();
assert_eq!(n, 1, "the jump fired OnValueChanged once, from the press");
// A disabled slider ignores a press even on the thumb.
s.run(r#"SlNoDrag:Disable()"#).unwrap();
s.mouse_button(108.0, 192.0, "LeftButton", true);
s.mouse_move(108.0, 150.0);
let v: f32 = s.eval("return SlNoDrag:GetValue()").unwrap();
assert_eq!(v, 0.0, "disabled slider does not drag");
// The SAME press keeps dragging — the capture began at the track press.
s.mouse_move(108.0, 108.0);
let v: f32 = s.eval("return SlTrack:GetValue()").unwrap();
assert_eq!(v, 100.0, "the gesture drags on without re-grabbing");
s.mouse_button(108.0, 108.0, "LeftButton", false);
// A disabled slider ignores a press anywhere — thumb and track alike.
s.run(r#"SlTrack:SetValue(0); SlTrack:Disable(); fired = {}"#)
.unwrap();
for y in [192.0, 150.0] {
s.mouse_button(108.0, y, "LeftButton", true);
s.mouse_move(108.0, 130.0);
let v: f32 = s.eval("return SlTrack:GetValue()").unwrap();
assert_eq!(v, 0.0, "disabled slider does not move (press at y={y})");
s.mouse_button(108.0, 130.0, "LeftButton", false);
}
let n: usize = s.eval("return table.getn(fired)").unwrap();
assert_eq!(n, 0, "disabled: no OnValueChanged at all");
}
#[test]
@@ -336,6 +336,32 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
set_spell_by_id(lua, &this, spell_id, name, false, false, None)
})?,
)?;
// GameTooltip:SetPetAction(index) — the pet-bar hover (decision 0982). Only ever reached for a
// SPELL slot: the reference's `PetActionButton_OnEnter` builds a token slot's tooltip inline
// from `tooltipName`/`tooltipSubtext` and never calls this. A slot with no spell (a token, an
// empty slot, an out-of-range index) is a no-op, leaving whatever was shown — the same shape
// as SetSpell's out-of-range.
m.set(
"SetPetAction",
lua.create_function(|lua, (this, index): (Table, usize)| {
let (spell_id, name) = {
let model = lua.app_data_mut::<Model>().expect("model app_data");
match model
.pet_bar
.slots
.get(index.saturating_sub(1))
.filter(|s| !s.view.is_token)
{
Some(s) => match s.view.spell_id {
Some(id) => (id, s.view.name.clone()),
None => return Ok(()),
},
None => return Ok(()),
}
};
set_spell_by_id(lua, &this, spell_id, name, false, false, None)
})?,
)?;
// GameTooltip:SetPlayerBuff(index [, filter]) — the buff-bar hover: the aura variant (white
// AuraDescription) + the duration-remaining line only this entry point appends
// (byte-verified; remaining computed live off the aura's GetTime expiry — the TEXT shape is
-16
View File
@@ -296,18 +296,6 @@ pub struct ExtractedQuad {
pub scale: f32,
}
/// One Era atlas member (decision 0950): the resolved paint `SetAtlas`/`atlas=` apply. The app
/// builds these from `WoW-era/_extracted_ui/manifest.json` — `file` is an app-resolvable texture
/// path (the `era:` scheme), `uv` the normalized `[left, right, top, bottom]` sub-rect
/// ([`TexCoords::Rect`] order), `size` the member's nominal draw size in UI units (what
/// `useAtlasSize` applies).
#[derive(Clone, Debug, PartialEq)]
pub struct EraAtlasEntry {
pub file: String,
pub uv: [f32; 4],
pub size: [f32; 2],
}
/// The visual state of a region leaf (`Texture`/`FontString`), stored beside the arena because
/// [`crate::widget::Region`] models only structure (kind/owner/layer), not paint. See [`object`]'s
/// `SetTexture`/`SetVertexColor`/`SetText`.
@@ -334,10 +322,6 @@ pub(crate) struct RegionData {
pub(crate) alpha: Option<f32>,
/// A texture path (`SetTexture("Interface\\...")`).
pub(crate) texture: Option<String>,
/// The Era atlas member this texture was last set from (`SetAtlas`, decision 0950) — what
/// `GetAtlas` reports. Bookkeeping only: `texture`/`tex_coords`/`size` carry the resolved
/// paint; any later `SetTexture` clears it (an ordinary region again).
pub(crate) atlas: Option<String>,
/// A color: a solid fill (`SetTexture(r,g,b,a)`) or a vertex tint (`SetVertexColor`).
pub(crate) color: Option<[f32; 4]>,
/// This region is a **portrait**: draw its texture masked to the inscribed circle (set by