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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
})?,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 /
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()]) {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)| {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user