Files
benilla-octo/crates/benilla-ui/src/script/action.rs
T
SamandClaude Fable 5.1 2f7cfcabf5 the action bars are the reference's own, and a gift can be wrapped
- the action-bar cluster is the reference's own: `MainMenuBar.xml`, `ActionBarFrame.xml`, `MultiActionBars.xml` and `BonusActionBarFrame.xml` run off the player's own chain, and our `ActionBar.xml` (2047 lines), `MultiBars.xml` and `StanceBar.xml` are deleted — three, since the stock bonus-bar file declares the shapeshift bar too; one engine verb, `ChangeActionBarPage`, fires `ACTIONBAR_PAGE_CHANGED` and nothing else; one load blocker: stock `MultiActionBars.lua` writes the options window's table from its OnLoad, the reference's own load-order hack, so our options window moves to the reference's toc slot; and ten things the reference does that ours did not: an empty button is HIDDEN while showgrid is 0, the bonus overlay sits one pixel right of the main buttons, every slide takes one frame more than its time, and `ActionButton_ShowGrid` ghosts EVERY ring, occupied included (1938)
- a gift can be wrapped: right-clicking wrapping paper fell through to plain use and sent `CMSG_USE_ITEM`, so nothing happened — you could receive a gift and never give one; the wrap is a displayed-cursor MODE plus a stashed slot, never a payload, so `CursorHasItem()` stays false and every stock gate on it stays shut: right-click arms it, a left-click on a bag slot sends `CMSG_WRAP_ITEM`, and the client applies no eligibility test — the server's `ERR_CANT_WRAP_*` reasons are the error line (1934); a duration-limited item counts down: `SMSG_ITEM_TIME_UPDATE` parks a deadline and the tooltip's `ITEM_DURATION` line runs the enchant countdown's bucket ladder, so "Duration: 1 days" is what the reference renders; an update for an item we do not hold is DROPPED, no queue (1933); and a renamed crowd-control refusal names its mechanic — "Can't do that while stunned" — off `SpellMechanic.dbc` (1943)
- a creature paths on a boat: `SMSG_MONSTER_MOVE_TRANSPORT` carries the transport's guid and deck-local coordinates, and is the attach/detach edge; a deck sample lands on the rider or nowhere, never as a world write a few yards from the map origin; and "there is no terrain under a boat" turned out wrong: the ground probe admits GameObject meshes — a boat's `.wmo` is a GameObject, so the probe hits the deck (1936); `MSG_MOVE_TIME_SKIPPED` is read, not dropped — the reference adds the lag to the observed unit's last-wire stamp, so dropping it scheduled their next packet `lag` ms late, a hitch for every player who stalled zoning in near us; and our own settle hold reports once (1935); and the tradeskill tool order was backwards — focus FIRST, so every two-tool recipe had read "Blacksmith Hammer, Anvil" for the reference's "Anvil, Blacksmith Hammer"; the focus's `hasTool` is 1.0 in the reference too (1942)
- the crowd's second sitting, instruments first: the probe line carries the tail — cpu p95/p99 and the six worst frames — because a mean under budget hides the one frame in twenty over it (raid legs railed at 16.67 with p95 at 18–20 ms) and whether that frame was work or a wait is the first question about a stutter, and the visible meshes are counted by lane; then two cuts — a terrain tile furnishes as 4×4 cells of 4×4 chunks, one mesh, one entity, one draw per 133-yd cell instead of per 33-yd chunk (a city horizon is over a thousand chunks a frame, and a cell changes nothing a pixel can see; Stormwind 10.55 → 10.32 `cpu_ms`), and an authored point light is our own `WorldPointLight`, not Bevy's `PointLight`, which was only a data carrier while Bevy ran its whole light lane over every lamp, ~2 % of the alone frame; the sitting's record is 1945, and the code says so (1945)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:54:27 +07:00

665 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The action-bar bindings (decision 0068 slice 1, extended by decision 0216 §7/slice 4) — both
//! directions of the engine seam in one place: the app pushes an **action snapshot** (what each
//! of the 120 action slots displays, [`UiScript::set_action`], same inward shape as
//! [`super::unit`]'s snapshots), and `UseAction` queues an outbound **intent** the app drains
//! into the wire ([`UiScript::take_action_uses`], same outward shape as [`super::sound`]'s
//! queue). The engine holds no spell/item/macro KNOWLEDGE — a slot is "a texture, a count, and
//! the packed `(kind, action)` halves" — but slice 4 hands it the packed halves themselves so the
//! cursor seam ([`super::cursor::bar`]) can pick a slot up and place it without round-tripping the
//! app: the ENGINE owns the payload + the local slot mutation against its own optimistic mirror
//! of `model.actions`, the APP owns the authoritative 120-table and drains the queued
//! `action_sets` onto the wire (`CMSG_SET_ACTION_BUTTON`).
//!
//! Actions are keyed by the **Lua action id** (1..120, the live API's space; the 1.12 wire's
//! 120-slot array is this minus one). `GetBonusBarOffset` reports the app-pushed stance/form
//! page offset — the vanilla main bar shows actions `(6 + offset − 1)·12 + i` when an offset is
//! active (warrior stances, druid forms), which the FrameXML side computes exactly like
//! Blizzard's own bar code.
use mlua::{Lua, MultiValue, Value};
use super::Model;
/// Action-kind bytes — bits 24–31 of the wire's packed slot word (VERIFIED vmangos `Player.h`
/// `ActionButtonType`; decision 0216 §1, `CMSG_SET_ACTION_BUTTON`'s own packing). The natural
/// home for the constant every `ActionSlot` producer/consumer needs (`super::cursor::bar`'s
/// 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,
/// the item template chain for an item) before pushing.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ActionSlot {
/// The icon texture path (`Interface\Icons\…`); `None` shows the slot's fallback.
pub texture: Option<String>,
/// The wire's kind byte (bits 24–31 of the packed slot word — SPELL 0x00/MACRO 0x40/ITEM
/// 0x80, decision 0216 §1). Opaque to the engine beyond the cursor seam's own pack/unpack
/// (`super::cursor::bar`); ids, targets, and the cast are still the app's.
pub kind: u8,
/// The spell/macro/item id (bits 0–23 of the packed slot word).
pub action: u32,
/// Bag count for an ITEM-kind slot (`GetActionCount`), `0` for every other kind or an empty
/// bag — the app-resolved value the Count fontstring reads (decision 0216 §7).
pub count: u32,
/// `IsConsumableAction`: the gate the ref's `UpdateCount` puts in front of [`Self::count`]
/// (decision 0926 §3). **Identity, not state** — `0x4e5250` reads nothing but the slot's own
/// item template, so it changes exactly when the icon does and rides the same push (decision
/// 1301; it lived in [`ActionState`] until the login race that split the pair).
pub consumable: bool,
}
/// One action's **dynamic** state — the per-frame half the app's feed pushes beside the slot's
/// identity ([`ActionSlot`]): what the reference's `ActionButton_Update*` family reads through
/// `IsUsableAction`/`IsActionInRange`/`IsCurrentAction`/`GetActionCooldown` and kin (decision
/// 0137 phase 4). Split from the identity map so a state churn (range, cooldown) never disturbs
/// the identity diff that fires `ACTIONBAR_SLOT_CHANGED`.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ActionState {
/// `IsUsableAction`'s first return: castable right now (power etc.).
pub usable: bool,
/// `IsUsableAction`'s second return: unusable **specifically** for insufficient power (the
/// 0.5/0.5/1.0 blue tint); other unusability reads `(false, false)` (the 0.4 grey).
pub not_enough_mana: bool,
/// `IsActionInRange`: `None` = rangeless action or no valid target (Lua `nil`);
/// `Some(true/false)` = the 1/0 verdict.
pub in_range: Option<bool>,
/// `ActionHasRange`: the action's spell has a range row to test at all.
pub has_range: bool,
/// `IsCurrentAction`: the Attack action while auto-attack is engaged, or the action's spell
/// while it is our in-flight cast (the checked ring).
pub current: bool,
/// `IsAutoRepeatAction`: the action's spell is the live autorepeat key (`0xceac30`).
pub auto_repeat: bool,
/// `IsAttackAction`: the action is the melee auto-attack.
pub is_attack: bool,
/// `IsEquippedAction`: an ITEM action currently worn (the green border).
pub equipped: bool,
/// The action's cooldown as pushed: `(start_ms, duration_ms, enabled)` with `start_ms` the
/// cooldown's **absolute start on the `GetTime` clock** (the app converts from its own clock
/// at feed time — the app-side `CooldownInfo::ui_triple`'s job). Absolute so the value is
/// identity-preserving: one running cooldown reads the same triple every frame (no diff
/// churn), while a re-arm always reads a new one (the sweep restarts — the reference's own
/// convention, whose `GetCooldownInfo 0x6e13e0` returns the record's start and therefore
/// cannot alias two arms the way a `(remaining, duration)` pair did).
pub cooldown: Option<(i64, u32, bool)>,
}
/// [`ActionState`] as stored: the cooldown converted to the `GetTime` clock at push time.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct StoredActionState {
pub(crate) state: ActionState,
/// `(start_s, duration_s, enabled)` in `GetTime` seconds; `None` = no cooldown.
pub(crate) cooldown: Option<(f64, f64, bool)>,
}
/// One queued `UseAction` press — the action id, and whether it carried the **self-cast
/// modifier**.
///
/// A struct rather than a `(u32, bool)` because a bare bool in a tuple is exactly the argument
/// that gets swapped silently; the neighbouring `take_action_sets` pairs two `u32`s, where the
/// positions are self-describing.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ActionUse {
/// The 1-based Lua action id, as `UseAction` was given it.
pub action: u32,
/// `UseAction`'s third argument — 1.12's `SELFACTIONBUTTON1`-`12` (`ALT-1`…`ALT-=`) reach the
/// bar through `ActionButtonUp(id, 1)`, and every other caller passes nothing.
pub on_self: bool,
}
impl super::UiScript {
/// Push (or clear) one action slot, keyed by Lua action id (1..120).
pub fn set_action(&mut self, action: u32, slot: Option<ActionSlot>) {
let mut model = self.model_mut();
match slot {
Some(s) => {
model.actions.insert(action, s);
}
None => {
model.actions.remove(&action);
}
}
}
/// Push (or clear) one action's dynamic state. The cooldown arrives with its **absolute
/// start already on the `GetTime` clock** (ms), so storing is a pure unit conversion — no
/// anchor derivation, no prev-comparison. A re-push of a running cooldown carries the same
/// start (the sweep is undisturbed — the reset-on-kill invariant holds by construction); a
/// re-armed one carries a new start (the sweep restarts, as the reference's does).
pub fn set_action_state(&mut self, action: u32, state: Option<ActionState>) {
let mut model = self.model_mut();
match state {
Some(s) => {
let cooldown = s.cooldown.map(|(start_ms, duration_ms, enabled)| {
(
start_ms as f64 / 1000.0,
f64::from(duration_ms) / 1000.0,
enabled,
)
});
model
.action_states
.insert(action, StoredActionState { state: s, cooldown });
}
None => {
model.action_states.remove(&action);
}
}
}
/// Push the current bonus-bar page offset (0 = the plain main bar; warrior stances 1..3).
pub fn set_bonus_bar_offset(&mut self, offset: u8) {
self.model_mut().bonus_bar_offset = offset;
}
/// Drain the presses queued by `UseAction` since the last call.
pub fn take_action_uses(&mut self) -> Vec<ActionUse> {
std::mem::take(&mut self.model_mut().action_uses)
}
/// Drain the `(lua action id, packed)` pairs `PickupAction`/`PlaceAction` queued since the
/// last call (decision 0216 §7) — the app sends one `CMSG_SET_ACTION_BUTTON` per entry
/// (`packed == 0` clears the slot) and updates its own authoritative `PlayerActions` store.
pub fn take_action_sets(&mut self) -> Vec<(u32, u32)> {
std::mem::take(&mut self.model_mut().action_sets)
}
/// Drain the GlobalStrings keys queued by engine-side client-local refusals since the last
/// call — the app resolves each against the VM's own GlobalStrings and fires
/// `UI_ERROR_MESSAGE`, standing in for the reference's inline `CGGameUI::DisplayError` (whose
/// engine can call it directly; ours is on the far side of the crate boundary). See
/// [`crate::script::model::Model::ui_errors`].
pub fn take_ui_errors(&mut self) -> Vec<&'static str> {
std::mem::take(&mut self.model_mut().ui_errors)
}
}
/// `checkCursor`'s truthiness (`UseAction`'s second argument): the reference's own numeric
/// convention — `nil`/`false`/`0` all read falsy, anything else (including Lua's own `true`)
/// 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.
pub(super) fn truthy_nonzero(v: &Value) -> bool {
match v {
Value::Nil => false,
Value::Boolean(b) => *b,
Value::Integer(i) => *i != 0,
Value::Number(n) => *n != 0.0,
_ => true,
}
}
/// Register the action globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
g.set(
"HasAction",
lua.create_function(|lua, action: u32| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.actions.contains_key(&action))
})?,
)?;
g.set(
"GetActionTexture",
lua.create_function(|lua, action: u32| {
let tex = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.actions.get(&action).and_then(|s| s.texture.clone())
};
match tex {
Some(t) => Ok(Value::String(lua.create_string(&t)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// GetActionText(slot) → the MACRO's own name, or nil. 1-based (`0x4e7072 dec eax`).
//
// **The classification is binary, not four-way** (`0x4e7050`, wow-re
// `bag-language-combat-action-bindings.md` §4, §5-cross-checked). The binding tests exactly
// one thing — top nibble `raw & 0xf0000000 == 0x40000000` — and **spell, item and empty all
// collapse to the identical nil arm**. It settles nothing about how spells or items are
// tagged, because it never asks.
//
// | slot contents | returns |
// |---|---|
// | macro, id resolves | the macro's own name (record `+0x24`, an inline buffer) |
// | macro, id does not resolve | `nil` |
// | spell · item · empty | `nil` |
// | slot missing / not a number | **raises** `Usage: GetActionText(slot)` (`0x84bff4`) |
//
// A macro whose name is empty answers `""`, not nil: `+0x24` is an inline buffer and can never
// be the null pointer `0x6f3890`'s guard tests for, so that guard is dead at this site. Our
// `MacroView::name` is a `String` and reproduces that for free.
//
// **The reference's trap does not exist here, and the reason is worth stating.** There the
// slot's macro payload (`raw & 0x3fffffff`) is an **opaque hash key** into `[0xbdcc54]`, NOT
// the 1..36 index — `GetMacroInfo` translates an index through `0xbdcc60` first and
// `GetActionText` never does, so a client that treats the payload as an index reads the wrong
// macro for every macro whose id ≠ its slot. benilla's macro table has no id space at all
// (decision 0983: two dense lists addressed by the 1..36 Lua index), so the payload our wire
// carries IS that index — and this is the same lookup `ui_action::feed`'s MACRO icon arm
// makes, through the same `MacroState::get`, so a slot's text and its icon cannot disagree
// about which macro it holds.
//
// One deliberate deviation: the reference has **no bounds check** (read contiguously from the
// `dec` to `mov eax,[4*eax + 0xbc6980]` — no `cmp`, no clamp), so slot 0 reads the dword below
// a 120-dword array and slot 121 reads into the next global. Our slot store is a map, so an
// out-of-range slot is simply absent and answers nil. Safer than the binary, and recorded as a
// deviation rather than passed off as a match.
g.set(
"GetActionText",
lua.create_function(|lua, slot: Value| {
let slot = super::binding_abi::number_arg(lua, slot, "Usage: GetActionText(slot)")?;
let name = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
u32::try_from(slot)
.ok()
.and_then(|s| model.actions.get(&s))
.filter(|s| s.kind & 0xf0 == ACTION_KIND_MACRO)
.and_then(|s| model.macros.get(s.action as usize))
.map(|m| m.name.clone())
};
match name {
Some(n) => Ok(Value::String(lua.create_string(&n)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// UseAction(action [, checkCursor [, onSelf]]). **`onSelf` is the self-cast modifier** — the
// third argument stock `ActionButtonUp(id, onSelf)` forwards on its main-bar branch and this
// host used to drop on the floor, which is why 1.12's twelve `SELFACTIONBUTTON` bindings
// (`ALT-1`…`ALT-=`) had no home. It rides out on [`ActionUse::on_self`] and the app's cast
// resolver reads it. `checkCursor` TRUTHY (numeric nonzero — the
// reference's own convention, not Lua's: `0` reads falsy here even though Lua truthiness
// would call it true) AND a payload is held routes to [`cursor::place_action`] instead of
// queuing the use (decision 0216 §7's INTERIM: the reference passes 1 from a mouse click and
// 0 from a keybind — the 0216 §5 dispatch never pinned checkCursor's byte semantics
// explicitly, and place-on-click is the only reading consistent with that click/keybind
// split). Any of the three payload arms counts as "held" — Item/Spell/Action are all
// placeable onto a bar slot.
g.set(
"UseAction",
lua.create_function(|lua, (action, rest): (u32, MultiValue)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let mut rest = rest.iter();
let check_cursor = rest.next().is_some_and(truthy_nonzero);
// `onSelf` reads with the SAME truthiness as `checkCursor` — numeric-nonzero, the
// reference's convention rather than Lua's — because it arrives from the same
// callers by the same route. `ActionButtonUp(id, 1)` is the only shipped caller
// that passes it.
let on_self = rest.next().is_some_and(truthy_nonzero);
if check_cursor && model.cursor.is_some() {
super::cursor::place_action(&mut model, action);
} else {
model.action_uses.push(ActionUse { action, on_self });
}
Ok(())
})?,
)?;
// Bag count for an ITEM-kind action, 0 for every other kind (the ref's IsConsumableAction
// gate simplified — 0216 §7's App-section note) — the check lives HERE, not in every XML
// caller, so a slot fed a stray count on the wrong kind (a bug elsewhere) can't leak through.
g.set(
"GetActionCount",
lua.create_function(|lua, action: u32| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model
.actions
.get(&action)
.filter(|s| s.kind == ACTION_KIND_ITEM)
.map_or(0, |s| s.count))
})?,
)?;
g.set(
"GetBonusBarOffset",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(i64::from(model.bonus_bar_offset))
})?,
)?;
// `ChangeActionBarPage()` — four instructions in the reference (`0x4e7650`: `mov ecx,0xd4;
// call FrameScript_SignalEvent; xor eax,eax; ret` — wow-re `action-bar-toggles.md` §6, `ui.md`):
// it fires `ACTIONBAR_PAGE_CHANGED` and does NOTHING else. The page is FrameXML state
// (`CURRENT_ACTIONBAR_PAGE`, which `ActionBar_PageUp/Down` and the SHIFT-n bindings write before
// calling this), the buttons repaint from `ActionButton_OnEvent`, and the up-arrow's OnEvent
// restamps the numeral. Synchronous, like the reference's `SignalEvent` (1938; the pattern is
// `UpdateSpells` → `SPELLS_CHANGED`, 1924). Zero return values on every path.
g.set(
"ChangeActionBarPage",
lua.create_function(|lua, ()| {
super::tick::fire_event_into(lua, "ACTIONBAR_PAGE_CHANGED", Vec::new());
Ok(())
})?,
)?;
// ── The dynamic-state read API (decision 0137 phase 4): the reference ActionButton.lua's
// whole input surface, answered from the app-pushed per-action state. Return conventions are
// the 1.12 API's own (1/nil booleans; IsActionInRange's nil/0/1 tri-state) — the transcribed
// Lua tests them with plain `if`, so nil-vs-0 matters exactly where the ref makes it matter.
// A 1/nil boolean read over the state map. An absent action answers nil.
fn state_flag(lua: &Lua, action: u32, pick: impl Fn(&ActionState) -> bool) -> Value {
let model = lua.app_data_ref::<Model>().expect("model app_data");
match model.action_states.get(&action) {
Some(s) if pick(&s.state) => Value::Integer(1),
_ => Value::Nil,
}
}
// IsUsableAction(action) → isUsable (1/nil), notEnoughMana (1/nil).
g.set(
"IsUsableAction",
lua.create_function(|lua, action: u32| {
Ok((
state_flag(lua, action, |s| s.usable),
state_flag(lua, action, |s| s.not_enough_mana),
))
})?,
)?;
// IsActionInRange(action) → nil (no range / no target) | 0 | 1.
g.set(
"IsActionInRange",
lua.create_function(|lua, action: u32| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(
match model
.action_states
.get(&action)
.and_then(|s| s.state.in_range)
{
Some(true) => Value::Integer(1),
Some(false) => Value::Integer(0),
None => Value::Nil,
},
)
})?,
)?;
g.set(
"ActionHasRange",
lua.create_function(|lua, action: u32| Ok(state_flag(lua, action, |s| s.has_range)))?,
)?;
g.set(
"IsCurrentAction",
lua.create_function(|lua, action: u32| Ok(state_flag(lua, action, |s| s.current)))?,
)?;
g.set(
"IsAutoRepeatAction",
lua.create_function(|lua, action: u32| Ok(state_flag(lua, action, |s| s.auto_repeat)))?,
)?;
g.set(
"IsAttackAction",
lua.create_function(|lua, action: u32| Ok(state_flag(lua, action, |s| s.is_attack)))?,
)?;
// The Count gate reads the SLOT, not the state map: `IsConsumableAction 0x4e5250` is a pure
// query over the item template the icon already came from, so it has to arrive on the same
// push the icon does. Split across the two feeds it answered `nil` for the whole session on a
// freshly logged-in character (decision 1301).
g.set(
"IsConsumableAction",
lua.create_function(|lua, action: u32| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
match model.actions.get(&action) {
Some(s) if s.consumable => Ok(Value::Integer(1)),
_ => Ok(Value::Nil),
}
})?,
)?;
g.set(
"IsEquippedAction",
lua.create_function(|lua, action: u32| Ok(state_flag(lua, action, |s| s.equipped)))?,
)?;
// GetActionCooldown(action) → start, duration, enable — the reference's `(GetTime-clock
// seconds, seconds, 0/1)` triple. An elapsed (or absent) cooldown answers `(0, 0, 1)`: the
// read must go cold once `start + duration` passes, or a later event-driven
// `CooldownFrame_SetTimer` re-feed would re-show the sweep and replay the finish flash.
g.set(
"GetActionCooldown",
lua.create_function(|lua, action: 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 model.action_states.get(&action).and_then(|s| s.cooldown) {
Some((start, duration, enabled)) if start + duration > now || !enabled => {
(start, duration, i32::from(enabled))
}
_ => (0.0, 0.0, 1),
},
)
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::ActionSlot;
use crate::script::UiScript;
/// `(action, on_self)` pairs, which is what these assertions are actually about.
fn uses(s: &mut UiScript) -> Vec<(u32, bool)> {
s.take_action_uses()
.into_iter()
.map(|u| (u.action, u.on_self))
.collect()
}
/// **`UseAction`'s third argument is the self-cast modifier** (1745) — 1.12's twelve
/// `SELFACTIONBUTTON` bindings (`ALT-1`…`ALT-=`) reach the bar as `ActionButtonUp(id, 1)`,
/// and this host dropped it on the floor until the day those bindings needed a home.
///
/// It reads with `checkCursor`'s truthiness, not Lua's — **numeric-nonzero** — because it
/// arrives from the same callers by the same route. That is the assertion worth having: Lua
/// would call `0` true, and a bar where `ActionButtonUp(id, 0)` self-cast would be worse than
/// one where nothing did.
#[test]
fn use_actions_third_argument_is_the_self_cast_modifier() {
let mut s = UiScript::new().unwrap();
s.run("UseAction(1)").unwrap();
s.run("UseAction(2, 0)").unwrap();
s.run("UseAction(3, 0, 0)").unwrap();
s.run("UseAction(4, 0, 1)").unwrap();
s.run("UseAction(5, nil, 1)").unwrap();
assert_eq!(
uses(&mut s),
vec![(1, false), (2, false), (3, false), (4, true), (5, true)],
"only a numeric-nonzero third argument is the modifier"
);
}
#[test]
fn change_action_bar_page_fires_the_event_and_nothing_else() {
let s = UiScript::new().unwrap();
s.run(
"CURRENT_ACTIONBAR_PAGE = 3 \
local f = CreateFrame('Frame') f:RegisterEvent('ACTIONBAR_PAGE_CHANGED') \
f:SetScript('OnEvent', function() SEEN = event .. ':' .. CURRENT_ACTIONBAR_PAGE end) \
N = table.getn({ChangeActionBarPage()})",
)
.unwrap();
assert_eq!(
s.eval::<String>("return SEEN").unwrap(),
"ACTIONBAR_PAGE_CHANGED:3",
"the event fires synchronously, and the page is whatever FrameXML wrote"
);
assert_eq!(s.eval::<i64>("return N").unwrap(), 0, "zero return values");
}
#[test]
fn action_snapshot_reads_and_use_queues() {
let mut s = UiScript::new().unwrap();
assert!(!s.eval::<bool>("return HasAction(73)").unwrap());
assert!(s
.eval::<bool>("return GetActionTexture(73) == nil")
.unwrap());
assert_eq!(s.eval::<i64>("return GetBonusBarOffset()").unwrap(), 0);
s.set_action(
73,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Ability_Rogue_Ambush".into()),
kind: 0x00,
action: 133,
count: 0,
consumable: false,
}),
);
s.set_bonus_bar_offset(1);
assert!(s.eval::<bool>("return HasAction(73)").unwrap());
assert_eq!(
s.eval::<String>("return GetActionTexture(73)").unwrap(),
"Interface\\Icons\\Ability_Rogue_Ambush"
);
assert_eq!(s.eval::<i64>("return GetBonusBarOffset()").unwrap(), 1);
s.run("UseAction(73)").unwrap();
s.run("UseAction(74, 0, 1)").unwrap(); // the self-cast modifier rides out
assert_eq!(uses(&mut s), vec![(73, false), (74, true)]);
assert!(s.take_action_uses().is_empty());
s.set_action(73, None);
assert!(!s.eval::<bool>("return HasAction(73)").unwrap());
}
#[test]
fn get_action_count_reads_the_pushed_count() {
let mut s = UiScript::new().unwrap();
assert_eq!(s.eval::<i64>("return GetActionCount(5)").unwrap(), 0);
s.set_action(
5,
Some(ActionSlot {
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
kind: 0x80,
action: 117,
count: 4,
consumable: false,
}),
);
assert_eq!(s.eval::<i64>("return GetActionCount(5)").unwrap(), 4);
}
/// `UseAction`'s `checkCursor` routes to a place ONLY when truthy (numeric-nonzero, not Lua
/// truthiness) AND a payload is held; a keybind's bare `UseAction(id)` and an explicit `0`
/// both queue the ordinary use even while holding.
#[test]
fn use_action_check_cursor_routes_to_place_or_use() {
use crate::script::cursor::{CursorAction, CursorPayload};
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Action(CursorAction {
src_slot: 1,
kind: 0x00,
action: 111,
texture: Some("Interface\\Icons\\Spell_A".into()),
}));
// checkCursor 0 (a keybind's own convention): queues the use, cursor untouched.
s.run("UseAction(9, 0)").unwrap();
assert_eq!(uses(&mut s), vec![(9, false)]);
assert!(s.cursor_payload().is_some(), "checkCursor 0 never places");
// checkCursor 1 (a mouse click) with a payload held: places instead of queuing.
s.run("UseAction(9, 1)").unwrap();
assert!(s.take_action_uses().is_empty(), "routed to place, not use");
assert!(s.cursor_payload().is_none(), "empty destination clears");
assert_eq!(s.take_action_sets(), vec![(9, 111)]);
// checkCursor 1 with an EMPTY cursor: the ordinary use (nothing to place).
s.run("UseAction(10, 1)").unwrap();
assert_eq!(uses(&mut s), vec![(10, false)]);
}
// ── `GetActionText` (wow-re `bag-language-combat-action-bindings.md` §4) ────────────────────
/// The classification is **binary**: macro or not. A SPELL slot and an ITEM slot answer the
/// identical `nil` an empty slot does — the binding tests one nibble and asks nothing else.
#[test]
fn get_action_text_is_the_macro_name_and_nil_for_everything_else() {
use crate::script::{MacroState, MacroView};
let mut s = UiScript::new().unwrap();
s.set_macros(MacroState {
account: vec![
MacroView {
name: "Pull".into(),
..Default::default()
},
MacroView {
name: String::new(),
..Default::default()
},
],
character: Vec::new(),
});
let slot = |kind: u8, action: u32| {
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_A".into()),
kind,
action,
count: 0,
consumable: false,
})
};
s.set_action(1, slot(0x40, 1)); // macro 1 — "Pull"
s.set_action(2, slot(0x40, 2)); // macro 2 — an empty NAME, not an empty slot
s.set_action(3, slot(0x40, 30)); // macro id that does not resolve
s.set_action(4, slot(0x00, 133)); // a SPELL (Fireball)
s.set_action(5, slot(0x80, 117)); // an ITEM
assert_eq!(s.eval::<String>("return GetActionText(1)").unwrap(), "Pull");
assert_eq!(
s.eval::<String>("return GetActionText(2)").unwrap(),
"",
"an empty macro NAME is the empty string, not nil — `+0x24` is an inline buffer"
);
for (slot, why) in [
(3, "a macro id that resolves to nothing"),
(4, "a SPELL — the same nil arm"),
(5, "an ITEM — the same nil arm"),
(6, "an empty slot"),
(121, "past the 120-slot array"),
(0, "slot 0 (1-based)"),
] {
assert!(
s.eval::<bool>(&format!("return GetActionText({slot}) == nil"))
.unwrap(),
"{why} must be nil"
);
}
// One value on every non-raising path — never zero values.
assert_eq!(
s.eval::<i64>("return select('#', GetActionText(4))")
.unwrap(),
1
);
}
/// A missing or non-number slot **raises** (`0x4e70be` → `0x6f4940`, which never returns).
#[test]
fn get_action_text_raises_on_a_bad_slot() {
let s = UiScript::new().unwrap();
for call in ["GetActionText()", "GetActionText({})"] {
let err = s
.eval::<mlua::Value>(&format!("return {call}"))
.unwrap_err();
assert!(
format!("{err}").contains("Usage: GetActionText(slot)"),
"{call} must raise, got {err}"
);
}
}
}