Files
benilla-octo/crates/benilla-ui/src/script/action.rs
T
gromandClaude Opus 5 81d96af35d Everwood: the HD/OctoWoW line of benilla, imported onto upstream
This checkout had no git history at all, so this one commit carries the whole
of the Everwood work as a single import on top of chairmanpow/benilla-octo's
`main` (706cd71). The two trees have genuinely diverged — each has files the
other does not — so this is a fork point, not a catch-up.

Today's work, which is the reason a baseline exists at all:

- Booth previews no longer cast world shadows (`NO_BOOTH_SHADOW`). Portrait
  characters were entering the sun's shadow cascades, so every skinned-rig
  shadow specialization compiled live mid-frame; the hitch detector logged
  "main thread blocked 1316 ms" beside a `compiled LIVE` warning. This was
  the Fashion/Tmog stutter.
- Per-pane dress-up: each `DressUpModel` pane assembles and stages its own
  tried-on look instead of every pane mirroring the live player.
- Item template cache is append-only JSONL written on the IO pool, replacing
  an 11.6 MB whole-map JSON rewrite on the main thread every 10 s.
- `WOW_SYSTEM_TIMING` rebuilt per-thread: it took 2 mutexes per system enter
  and 4 per exit, costing 15 ms a frame (590 fps -> 60) and corrupting the
  measurements it was taken to produce.
- The visibility-churn sweep moved to its own `WOW_FLASH_CHURN` switch, so a
  frame-cost reading no longer pays 2.7 ms a frame for it.

Local state (`benilla-config/`, `logs/`, `target/`) is excluded by the
existing .gitignore; the account credential it holds is not in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-15 15:01:14 -06:00

545 lines
20 KiB
Rust

//! The action-bar bindings. The app pushes what each of the 120 slots shows and its per-frame
//! state, and drains the queued `UseAction` presses and the slot writes the cursor made
//! (`CMSG_SET_ACTION_BUTTON`); the engine keeps each slot's packed `(kind, action)` so the cursor
//! can move a slot without the app. Actions are keyed by Lua action id, 1..120, one more than the
//! wire's slot index. FrameXML's bonus bar shows actions `(6 + offset - 1) * 12 + i` for the
//! app-pushed `GetBonusBarOffset`.
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::flag;
use super::Model;
/// The kind byte, bits 24-31 of the packed slot word (vmangos `Player.h:133`). Must match
/// `benilla_protocol`'s `ACTION_KIND_*`; this crate has no protocol dependency.
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 shows; the app resolves the icon 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 kind byte, one of the `ACTION_KIND_*` above.
pub kind: u8,
/// The spell, macro or item id (bits 0-23 of the packed slot word).
pub action: u32,
/// `GetActionCount`'s bag count for an item slot, 0 otherwise.
pub count: u32,
/// `IsConsumableAction`: `0x4e5250` reads only the slot's item template, so this rides the
/// icon's push, not [`ActionState`]'s.
pub consumable: bool,
}
/// One action's per-frame state, read by `IsUsableAction`, `IsActionInRange`, `GetActionCooldown`
/// and kin; kept apart from [`ActionSlot`] so its churn never fires `ACTIONBAR_SLOT_CHANGED`.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ActionState {
/// `IsUsableAction`'s first return: castable now.
pub usable: bool,
/// `IsUsableAction`'s second return: unusable only for lack of power (FrameXML's blue tint).
pub not_enough_mana: bool,
/// `IsActionInRange`: `None` (nil) for a rangeless action or no target, else 1 or 0.
pub in_range: Option<bool>,
/// `ActionHasRange`: the action's spell has a range to test.
pub has_range: bool,
/// `IsCurrentAction`: the Attack action while auto-attack is on, or the spell being cast.
pub current: bool,
/// `IsAutoRepeatAction`: the action's spell is the live autorepeat spell (`0xceac30`).
pub auto_repeat: bool,
/// `IsAttackAction`: the action is the melee auto-attack.
pub is_attack: bool,
/// `IsEquippedAction`: an item action currently worn.
pub equipped: bool,
/// `(start_ms, duration_ms, enabled)`, the start absolute on the `GetTime` clock, so a running
/// cooldown re-pushes the same triple and a re-arm a new one (`0x6e13e0` returns the start).
pub cooldown: Option<(i64, u32, bool)>,
}
/// [`ActionState`] with its cooldown in `GetTime` seconds.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct StoredActionState {
pub(crate) state: ActionState,
pub(crate) cooldown: Option<(f64, f64, bool)>,
}
/// One queued `UseAction` press.
#[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, which only the self-cast bindings (`SELFACTIONBUTTON1`-`12`)
/// pass, through `ActionButtonUp(id, 1)`.
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 state; the cooldown start is already on the `GetTime` clock,
/// so storing only converts milliseconds to seconds.
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; the app
/// sends each as `CMSG_SET_ACTION_BUTTON` (`packed == 0` clears) and updates its own store.
pub fn take_action_sets(&mut self) -> Vec<(u32, u32)> {
std::mem::take(&mut self.model_mut().action_sets)
}
/// Drain the GlobalStrings keys of engine-side refusals; the app fires `UI_ERROR_MESSAGE` for
/// each, where the reference calls `CGGameUI::DisplayError` inline.
pub fn take_ui_errors(&mut self) -> Vec<&'static str> {
std::mem::take(&mut self.model_mut().ui_errors)
}
}
/// `UseAction`'s flag test: `nil`, `false` and `0` are false, unlike Lua, where `0` is true.
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(flag(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 name, else nil. `0x4e7050` tests only
// `raw & 0xf0000000 == 0x40000000`, so spell, item and empty slots all answer nil; a missing or
// non-number slot raises `Usage: GetActionText(slot)` (`0x84bff4`); the slot is 1-based
// (`0x4e7072` `dec eax`). An empty name answers "": it is an inline buffer at `+0x24`, so
// `0x6f3890`'s null guard never fires.
// The reference's macro payload (`raw & 0x3fffffff`) is a hash key into `[0xbdcc54]`; ours is
// the 1..36 index, the lookup the app's macro icon makes too, so text and icon agree.
// Deviation: an out-of-range slot answers nil, because the reference indexes `0xbc6980` with
// no bounds check and reads the memory beside its 120 slots.
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]]): a nonzero `checkCursor` while the cursor holds
// a payload places it, as `PlaceAction` does; otherwise the press is queued with `onSelf`, the
// self-cast flag. FrameXML passes 1 from a click and 0 from a keybind; the binding's own test
// of it is untraced, and place-on-click is the reading that split implies.
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);
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(())
})?,
)?;
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(): fires `ACTIONBAR_PAGE_CHANGED` synchronously and returns nothing
// (`0x4e7650`); the page itself is FrameXML's `CURRENT_ACTIONBAR_PAGE`.
g.set(
"ChangeActionBarPage",
lua.create_function(|lua, ()| {
super::tick::fire_event_into(lua, "ACTIONBAR_PAGE_CHANGED", Vec::new());
Ok(())
})?,
)?;
// ── Per-action state ──
// 1/nil booleans and `IsActionInRange`'s nil/0/1, which FrameXML tests with a plain `if`.
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,
}
}
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),
))
})?,
)?;
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)))?,
)?;
// From the slot, not the state map: `0x4e5250` depends only on the item template.
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, in `GetTime` seconds. An absent or
// elapsed enabled cooldown answers `(0, 0, 1)`, or a later `CooldownFrame_SetTimer` would
// replay the sweep and its 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;
fn uses(s: &mut UiScript) -> Vec<(u32, bool)> {
s.take_action_uses()
.into_iter()
.map(|u| (u.action, u.on_self))
.collect()
}
/// The numeric test: `0` is not the modifier, though Lua would call it true.
#[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();
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);
}
/// A place needs a nonzero `checkCursor` and a held payload; a keybind's `0` queues the use.
#[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()),
}));
s.run("UseAction(9, 0)").unwrap();
assert_eq!(uses(&mut s), vec![(9, false)]);
assert!(s.cursor_payload().is_some(), "checkCursor 0 never places");
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)]);
s.run("UseAction(10, 1)").unwrap();
assert_eq!(uses(&mut s), vec![(10, false)]);
}
// ── `GetActionText` (`0x4e7050`) ──
#[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 "Pull"
s.set_action(2, slot(0x40, 2)); // a macro with an empty name
s.set_action(3, slot(0x40, 30)); // no such macro
s.set_action(4, slot(0x00, 133)); // a spell
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.
assert_eq!(s.arity("GetActionText(4)").unwrap(), 1);
}
/// The raise is `0x4e70be` into `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}"
);
}
}
}