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
958 lines
35 KiB
Rust
958 lines
35 KiB
Rust
//! The key-binding table behind `GetBinding`, `SetBinding`, `LoadBindings` and their kin: per
|
|
//! command, a category header and its chords, spelled `[ALT-][CTRL-][SHIFT-]<TOKEN>` and matched by
|
|
//! string equality as the reference does. Host commands carry their 1.12 defaults; an addon's
|
|
//! `Bindings.xml` adds ordinary rows that also carry a Lua body.
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use mlua::{Lua, MultiValue, Value};
|
|
|
|
use crate::bindings_xml::AddonBinding;
|
|
|
|
use super::Model;
|
|
|
|
/// One host-registered command: the 1.12 name, its category header's global string
|
|
/// (`BINDING_HEADER_MOVEMENT`), whether it also runs on release (`runOnUp`, read only by
|
|
/// `RunCommand` at `0x4b7bf1`, never a bindability rule), and the 1.12 default chords.
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct KeybindCommand {
|
|
pub name: &'static str,
|
|
pub category: &'static str,
|
|
pub run_on_up: bool,
|
|
pub default1: Option<&'static str>,
|
|
pub default2: Option<&'static str>,
|
|
}
|
|
|
|
/// An addon binding's runnable half: what the app's dispatch needs to fire it.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct AddonBindingBody {
|
|
/// The command name as registered, the key of a `keybind_snapshot` row.
|
|
pub name: String,
|
|
/// Run the body a second time on the release, with `keystate = "up"` (`runOnUp="true"`).
|
|
pub run_on_up: bool,
|
|
/// The `<Binding>` element's Lua chunk, verbatim.
|
|
pub body: String,
|
|
}
|
|
|
|
/// A host request queued by Lua. `Save(set)` persists set 1 or 2 under `benilla-config/bindings/`;
|
|
/// `Save(1)` also deletes the character set's file.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum KeybindRequest {
|
|
Save(u32),
|
|
/// `RunBinding(command)`: the app runs the command's action as if its chord were pressed.
|
|
Run(String),
|
|
}
|
|
|
|
/// `SetBinding`'s only refusal: the alias step of `CBindings::SetBinding` (`0x4b7490`), then
|
|
/// `IsValidBindingKeyString` (`0x4b7890`); no command is consulted. `Some` is the key to store,
|
|
/// upper-cased because the table's hash and compare fold case (`0x64b3f0`, `0x64a4c0`). The alias
|
|
/// step ignores case but the validator does not (`0x64a480`), so `"mousewheelup"` is refused.
|
|
pub fn normalize_binding_key(key: &str) -> Option<String> {
|
|
// Eleven punctuation names become their character on a whole-string match only, so
|
|
// `SHIFT-LEFTBRACKET` is refused where `SHIFT-[` passes.
|
|
const ALIASES: [(&str, &str); 11] = [
|
|
("LEFTBRACKET", "["),
|
|
("RIGHTBRACKET", "]"),
|
|
("SLASH", "/"),
|
|
("BACKSLASH", "\\"),
|
|
("SEMICOLON", ";"),
|
|
("APOSTROPHE", "'"),
|
|
("COMMA", ","),
|
|
("PERIOD", "."),
|
|
("TILDE", "`"),
|
|
("PLUS", "="),
|
|
("MINUS", "-"),
|
|
];
|
|
let key = ALIASES
|
|
.iter()
|
|
.find(|(name, _)| name.eq_ignore_ascii_case(key))
|
|
.map_or(key, |(_, literal)| literal);
|
|
is_valid_binding_key(key).then(|| key.to_ascii_uppercase())
|
|
}
|
|
|
|
/// `IsValidBindingKeyString 0x4b7890`, arm for arm.
|
|
fn is_valid_binding_key(key: &str) -> bool {
|
|
// 1. Modifier prefixes, in any order and any number of times (`0x4b78a0`-`0x4b78d0`).
|
|
let mut rest = key;
|
|
while let Some(next) = ["SHIFT-", "CTRL-", "ALT-"]
|
|
.iter()
|
|
.find_map(|p| rest.strip_prefix(p))
|
|
{
|
|
rest = next;
|
|
}
|
|
// 2. One character or none passes (`0x4b78e6`): `SHIFT-` alone is a legal key.
|
|
if rest.chars().count() <= 1 {
|
|
return true;
|
|
}
|
|
// 3. `F`, `NUMPAD` or `BUTTON` (`0x846c04`) followed by digits only.
|
|
for prefix in ["F", "NUMPAD", "BUTTON"] {
|
|
if let Some(digits) = rest.strip_prefix(prefix) {
|
|
if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
// 4. The 26 names at `0x846c20`-`0x846c84`: no lone modifier, `SCROLLLOCK` or `PAUSE`.
|
|
const NAMES: [&str; 26] = [
|
|
"SPACE",
|
|
"NUMPADPLUS",
|
|
"NUMPADMINUS",
|
|
"NUMPADMULTIPLY",
|
|
"NUMPADDIVIDE",
|
|
"NUMPADDECIMAL",
|
|
"ESCAPE",
|
|
"ENTER",
|
|
"BACKSPACE",
|
|
"TAB",
|
|
"LEFT",
|
|
"UP",
|
|
"RIGHT",
|
|
"DOWN",
|
|
"INSERT",
|
|
"DELETE",
|
|
"HOME",
|
|
"END",
|
|
"PAGEUP",
|
|
"PAGEDOWN",
|
|
"NUMLOCK",
|
|
"CAPSLOCK",
|
|
"PRINTSCREEN",
|
|
"NUMPADEQUALS",
|
|
"MOUSEWHEELDOWN",
|
|
"MOUSEWHEELUP",
|
|
];
|
|
NAMES.contains(&rest)
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct Entry {
|
|
name: String,
|
|
category: String,
|
|
run_on_up: bool,
|
|
defaults: [Option<String>; 2],
|
|
/// The live chords in bind order; `GetBindingKey` and the window show the first two.
|
|
keys: Vec<String>,
|
|
/// An addon's `hidden="true"`: a full binding, left out of `GetNumBindings`/`GetBinding`.
|
|
hidden: bool,
|
|
/// An addon binding's Lua chunk; `None` for a host command, whose action is engine-side.
|
|
body: Option<String>,
|
|
}
|
|
|
|
/// The table, the stored sets and the queued host requests; lives in [`Model`].
|
|
#[derive(Default)]
|
|
pub(crate) struct KeybindState {
|
|
entries: Vec<Entry>,
|
|
by_name: HashMap<String, usize>,
|
|
/// Stored sets in entry order: `[0]` account (set 1), `[1]` character (set 2, if any).
|
|
stored: [Option<Vec<Vec<String>>>; 2],
|
|
/// The same sets by uppercased name, for addon commands registered after the seed.
|
|
stored_by_name: [HashMap<String, Vec<String>>; 2],
|
|
/// The set the live table edits (`GetCurrentBindingSet`): 1 account, 2 character.
|
|
current_set: u32,
|
|
/// Bumped by every change to the live table: the app's signal to re-derive dispatch.
|
|
generation: u64,
|
|
requests: Vec<KeybindRequest>,
|
|
/// Set by `BenillaBindCapture`: the host swallows raw input and hands the page the chord.
|
|
capture_armed: bool,
|
|
}
|
|
|
|
impl KeybindState {
|
|
/// The rows `GetNumBindings`/`GetBinding` enumerate: all but `hidden="true"`, since the stock
|
|
/// window skips only `HEADER` rows (`Blizzard_BindingUI.lua:87`).
|
|
fn visible(&self) -> impl Iterator<Item = &Entry> {
|
|
self.entries.iter().filter(|e| !e.hidden)
|
|
}
|
|
|
|
/// `SetBinding(key[, command])`: unbind `key` everywhere, then append it to the command's
|
|
/// list. The reference refuses only an invalid key (`0x4b762c`) and never reads the command
|
|
/// (`0x4b7490`), so the wheel binds to a `runOnUp` command despite the stock window's
|
|
/// MOUSEWHEEL_ERROR branch.
|
|
fn set_binding(&mut self, key: &str, command: Option<&str>) -> bool {
|
|
let Some(key) = normalize_binding_key(key) else {
|
|
return false;
|
|
};
|
|
// Deviation: an unregistered command answers nil where the reference stores the string
|
|
// and answers 1, because this table only holds key lists of registered commands.
|
|
let cmd_idx = match command {
|
|
Some(name) => match self.by_name.get(&name.to_ascii_uppercase()) {
|
|
Some(&i) => Some(i),
|
|
None => return false,
|
|
},
|
|
None => None,
|
|
};
|
|
for e in &mut self.entries {
|
|
e.keys.retain(|k| k != &key);
|
|
}
|
|
if let Some(i) = cmd_idx {
|
|
self.entries[i].keys.push(key);
|
|
}
|
|
self.generation += 1;
|
|
true
|
|
}
|
|
|
|
/// The live key lists in entry order, as a stored set holds them.
|
|
fn snapshot(&self) -> Vec<Vec<String>> {
|
|
self.entries.iter().map(|e| e.keys.clone()).collect()
|
|
}
|
|
|
|
/// Replace the live key lists from a snapshot; an entry past the snapshot's end keeps its keys.
|
|
fn apply(&mut self, snap: &[Vec<String>]) {
|
|
for (i, e) in self.entries.iter_mut().enumerate() {
|
|
if let Some(keys) = snap.get(i) {
|
|
e.keys = keys.clone();
|
|
}
|
|
}
|
|
self.generation += 1;
|
|
}
|
|
|
|
fn defaults_snapshot(&self) -> Vec<Vec<String>> {
|
|
self.entries
|
|
.iter()
|
|
.map(|e| e.defaults.iter().flatten().cloned().collect())
|
|
.collect()
|
|
}
|
|
|
|
/// `LoadBindings(set)`: 0 defaults, 1 account, 2 character, which falls back to the account
|
|
/// set (the reference's starts as a copy). Loading defaults keeps the current set, as the
|
|
/// window's Reset To Default does.
|
|
fn load(&mut self, set: u32) {
|
|
match set {
|
|
0 => {
|
|
let d = self.defaults_snapshot();
|
|
self.apply(&d);
|
|
}
|
|
1 => {
|
|
let s = self.stored[0]
|
|
.clone()
|
|
.unwrap_or_else(|| self.defaults_snapshot());
|
|
self.apply(&s);
|
|
self.current_set = 1;
|
|
}
|
|
2 => {
|
|
let s = self.stored[1]
|
|
.clone()
|
|
.or_else(|| self.stored[0].clone())
|
|
.unwrap_or_else(|| self.defaults_snapshot());
|
|
self.apply(&s);
|
|
self.current_set = 2;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// `SaveBindings(which)`: store the live table as set `which` and make it current. Saving the
|
|
/// account set drops the character set, the window's confirmed delete.
|
|
fn save(&mut self, which: u32) {
|
|
if which != 1 && which != 2 {
|
|
return;
|
|
}
|
|
self.stored[(which - 1) as usize] = Some(self.snapshot());
|
|
if which == 1 {
|
|
self.stored[1] = None;
|
|
}
|
|
self.current_set = which;
|
|
self.requests.push(KeybindRequest::Save(which));
|
|
}
|
|
|
|
/// Append one addon's parsed `Bindings.xml`, skipping a name already registered, so a host
|
|
/// command keeps it and no handed-out index moves. A `header` opens a section
|
|
/// (`BINDING_HEADER_<header>`) until the next one, as in the reference's flat list.
|
|
/// Deviation: rows before a file's first header take the addon's name as their section, not
|
|
/// the previous file's, because under a foreign header the player could not find them.
|
|
fn register_addon(&mut self, addon: &str, bindings: &[AddonBinding]) {
|
|
let mut section = addon.to_string();
|
|
for b in bindings {
|
|
// A foreign `platform` skips the whole node, header included (`0x4b70c3`-`0x4b70e5`).
|
|
if b.platform.as_deref().is_some_and(|p| p != THIS_PLATFORM) {
|
|
continue;
|
|
}
|
|
// Before the duplicate skip: a duplicate's header still opens its section.
|
|
if let Some(h) = &b.header {
|
|
section = format!("BINDING_HEADER_{h}");
|
|
}
|
|
let key = b.name.to_ascii_uppercase();
|
|
if self.by_name.contains_key(&key) {
|
|
continue;
|
|
}
|
|
let idx = self.entries.len();
|
|
// The stored chord: the current set first, then the account set, as `load` falls back.
|
|
let stored = {
|
|
let cur = if self.current_set == 2 { 1 } else { 0 };
|
|
self.stored_by_name[cur]
|
|
.get(&key)
|
|
.or_else(|| self.stored_by_name[0].get(&key))
|
|
.cloned()
|
|
};
|
|
self.by_name.insert(key, idx);
|
|
self.entries.push(Entry {
|
|
name: b.name.clone(),
|
|
category: section.clone(),
|
|
run_on_up: b.run_on_up,
|
|
// 1.12's `<Binding>` carries no default chord.
|
|
defaults: [None, None],
|
|
keys: stored.unwrap_or_default(),
|
|
hidden: b.hidden,
|
|
body: Some(b.body.clone()),
|
|
});
|
|
}
|
|
self.generation += 1;
|
|
}
|
|
}
|
|
|
|
/// The `platform="…"` value that registers here: the Windows client compares against `"windows"`
|
|
/// (`0x846f98`), the Mac client against `"mac"` (`0x4e28f8`); every other OS takes `"windows"`.
|
|
const THIS_PLATFORM: &str = if cfg!(target_os = "macos") {
|
|
"mac"
|
|
} else {
|
|
"windows"
|
|
};
|
|
|
|
impl super::UiScript {
|
|
/// Register the host's commands at boot, in 1.12 `Bindings.xml` order, keys from the defaults.
|
|
pub fn register_bindings(&mut self, commands: &[KeybindCommand]) {
|
|
let mut model = self.model_mut();
|
|
for c in commands {
|
|
let key = c.name.to_ascii_uppercase();
|
|
if model.keybinds.by_name.contains_key(&key) {
|
|
continue;
|
|
}
|
|
let defaults = [c.default1.map(str::to_owned), c.default2.map(str::to_owned)];
|
|
let idx = model.keybinds.entries.len();
|
|
model.keybinds.by_name.insert(key, idx);
|
|
model.keybinds.entries.push(Entry {
|
|
name: c.name.to_owned(),
|
|
category: c.category.to_owned(),
|
|
run_on_up: c.run_on_up,
|
|
keys: defaults.iter().flatten().cloned().collect(),
|
|
defaults,
|
|
hidden: false,
|
|
body: None,
|
|
});
|
|
}
|
|
model.keybinds.current_set = 1;
|
|
model.keybinds.generation += 1;
|
|
}
|
|
|
|
/// Register one addon's parsed `Bindings.xml`, at the reference's point in the addon load:
|
|
/// after its `.toc` files, before its saved variables (`0x51f400`).
|
|
pub fn register_addon_bindings(&mut self, addon: &str, bindings: &[AddonBinding]) {
|
|
self.model_mut().keybinds.register_addon(addon, bindings);
|
|
}
|
|
|
|
/// Every addon binding's runnable half, in registration order; host commands have none.
|
|
pub fn addon_binding_bodies(&self) -> Vec<AddonBindingBody> {
|
|
self.model_mut()
|
|
.keybinds
|
|
.entries
|
|
.iter()
|
|
.filter_map(|e| {
|
|
e.body.as_ref().map(|body| AddonBindingBody {
|
|
name: e.name.clone(),
|
|
run_on_up: e.run_on_up,
|
|
body: body.clone(),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Seed stored set 1 (account) or 2 (character) from `benilla-config/bindings/`; `None` clears
|
|
/// it. The live table is untouched until [`Self::load_binding_set`].
|
|
pub fn seed_binding_set(&mut self, set: u32, keys: Option<Vec<(String, Vec<String>)>>) {
|
|
let mut model = self.model_mut();
|
|
let kb = &mut model.keybinds;
|
|
// By name as well: an addon command registered later has no position yet.
|
|
let mut by_name_owned: HashMap<String, Vec<String>> = HashMap::new();
|
|
let snap = keys.map(|pairs| {
|
|
let by_name: HashMap<_, _> = pairs.into_iter().collect();
|
|
by_name_owned = by_name
|
|
.iter()
|
|
.map(|(n, k)| (n.to_ascii_uppercase(), k.clone()))
|
|
.collect();
|
|
kb.entries
|
|
.iter()
|
|
.map(|e| {
|
|
by_name
|
|
.get(&e.name)
|
|
.cloned()
|
|
.unwrap_or_else(|| e.keys.clone())
|
|
})
|
|
.collect()
|
|
});
|
|
match set {
|
|
1 => {
|
|
kb.stored[0] = snap;
|
|
kb.stored_by_name[0] = by_name_owned;
|
|
}
|
|
2 => {
|
|
kb.stored[1] = snap;
|
|
kb.stored_by_name[1] = by_name_owned;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Host-side `LoadBindings` (world entry: character set if it exists, else account).
|
|
pub fn load_binding_set(&mut self, set: u32) {
|
|
self.model_mut().keybinds.load(set);
|
|
}
|
|
|
|
/// The live table as `(command, chords)` in registration order, for dispatch and the save.
|
|
pub fn keybind_snapshot(&self) -> Vec<(String, Vec<String>)> {
|
|
self.model_mut()
|
|
.keybinds
|
|
.entries
|
|
.iter()
|
|
.map(|e| (e.name.clone(), e.keys.clone()))
|
|
.collect()
|
|
}
|
|
|
|
/// Bumped by every table change; re-derive dispatch when it moves.
|
|
pub fn keybinds_generation(&self) -> u64 {
|
|
self.model_mut().keybinds.generation
|
|
}
|
|
|
|
/// Drain the queued host requests.
|
|
pub fn take_keybind_requests(&mut self) -> Vec<KeybindRequest> {
|
|
std::mem::take(&mut self.model_mut().keybinds.requests)
|
|
}
|
|
|
|
/// Which set the live table edits: 1 account, 2 character.
|
|
pub fn current_binding_set(&self) -> u32 {
|
|
self.model_mut().keybinds.current_set
|
|
}
|
|
|
|
/// Whether a character-specific stored set exists (the window's checkbox).
|
|
pub fn character_bindings_exist(&self) -> bool {
|
|
self.model_mut().keybinds.stored[1].is_some()
|
|
}
|
|
|
|
/// While true, the host swallows raw input and calls `KeyBindings_OnHostKey("<chord>")`.
|
|
pub fn bind_capture_armed(&self) -> bool {
|
|
self.model_mut().keybinds.capture_armed
|
|
}
|
|
}
|
|
|
|
/// Register one addon's `Bindings.xml` from a bare `&Lua`: `LoadAddOn` runs inside a Lua binding
|
|
/// with no `UiScript` to reach.
|
|
pub(crate) fn register_addon_bindings(lua: &Lua, addon: &str, bindings: &[AddonBinding]) {
|
|
lua.app_data_mut::<Model>()
|
|
.expect("model app_data")
|
|
.keybinds
|
|
.register_addon(addon, bindings);
|
|
}
|
|
|
|
/// The binding globals. `GetBinding` returns command, category, key1, key2, which is not 1.12's
|
|
/// shape: 1.12 returns command, key1, key2, with each header a `HEADER_*` row
|
|
/// (`Blizzard_BindingUI.lua:84`). Only `GetNumBindings` and `GetBinding` skip hidden rows.
|
|
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
|
lua.globals().set(
|
|
"GetNumBindings",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
Ok(model.keybinds.visible().count())
|
|
})?,
|
|
)?;
|
|
lua.globals().set(
|
|
"GetBinding",
|
|
lua.create_function(|lua, i: usize| {
|
|
let model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
let Some(e) = i
|
|
.checked_sub(1)
|
|
.and_then(|i| model.keybinds.visible().nth(i))
|
|
else {
|
|
return Ok(MultiValue::new());
|
|
};
|
|
let mut out = vec![
|
|
Value::String(lua.create_string(&e.name)?),
|
|
Value::String(lua.create_string(&e.category)?),
|
|
];
|
|
for k in e.keys.iter().take(2) {
|
|
out.push(Value::String(lua.create_string(k)?));
|
|
}
|
|
Ok(MultiValue::from_iter(out))
|
|
})?,
|
|
)?;
|
|
lua.globals().set(
|
|
"SetBinding",
|
|
lua.create_function(|lua, (key, command): (String, Option<String>)| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
let ok = model.keybinds.set_binding(&key, command.as_deref());
|
|
Ok(if ok { Some(1) } else { None })
|
|
})?,
|
|
)?;
|
|
// `RunBinding(command)`, as `CinematicFrame.xml:42` passes SCREENSHOT through: a host
|
|
// command's action is engine-side, so this queues it and the app runs it in the same frame.
|
|
lua.globals().set(
|
|
"RunBinding",
|
|
lua.create_function(|lua, command: String| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
let name = command.to_ascii_uppercase();
|
|
if model.keybinds.by_name.contains_key(&name) {
|
|
model.keybinds.requests.push(KeybindRequest::Run(name));
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
lua.globals().set(
|
|
"GetBindingKey",
|
|
lua.create_function(|lua, command: String| {
|
|
let model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
let kb = &model.keybinds;
|
|
let mut out = Vec::new();
|
|
if let Some(&i) = kb.by_name.get(&command.to_ascii_uppercase()) {
|
|
for k in kb.entries[i].keys.iter().take(2) {
|
|
out.push(Value::String(lua.create_string(k)?));
|
|
}
|
|
}
|
|
Ok(MultiValue::from_iter(out))
|
|
})?,
|
|
)?;
|
|
lua.globals().set(
|
|
"GetBindingAction",
|
|
lua.create_function(|lua, key: String| {
|
|
let model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
let key = key.to_ascii_uppercase();
|
|
let name = model
|
|
.keybinds
|
|
.entries
|
|
.iter()
|
|
.find(|e| e.keys.iter().any(|k| k == &key))
|
|
.map(|e| e.name.as_str())
|
|
.unwrap_or("");
|
|
lua.create_string(name)
|
|
})?,
|
|
)?;
|
|
lua.globals().set(
|
|
"LoadBindings",
|
|
lua.create_function(|lua, set: u32| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
model.keybinds.load(set);
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
lua.globals().set(
|
|
"SaveBindings",
|
|
lua.create_function(|lua, which: u32| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
model.keybinds.save(which);
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
lua.globals().set(
|
|
"GetCurrentBindingSet",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
Ok(model.keybinds.current_set)
|
|
})?,
|
|
)?;
|
|
lua.globals().set(
|
|
"BenillaCharacterBindingsExist",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
Ok(model.keybinds.stored[1].is_some())
|
|
})?,
|
|
)?;
|
|
lua.globals().set(
|
|
"BenillaBindCapture",
|
|
lua.create_function(|lua, armed: Option<bool>| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
model.keybinds.capture_armed = armed.unwrap_or(false);
|
|
Ok(())
|
|
})?,
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::KeybindCommand;
|
|
use crate::script::keybind::KeybindRequest;
|
|
use crate::script::UiScript;
|
|
|
|
fn script() -> UiScript {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.register_bindings(&[
|
|
KeybindCommand {
|
|
name: "MOVEFORWARD",
|
|
category: "BINDING_HEADER_MOVEMENT",
|
|
run_on_up: true,
|
|
default1: Some("W"),
|
|
default2: Some("UP"),
|
|
},
|
|
KeybindCommand {
|
|
name: "JUMP",
|
|
category: "BINDING_HEADER_MOVEMENT",
|
|
run_on_up: false,
|
|
default1: Some("SPACE"),
|
|
default2: Some("NUMPAD0"),
|
|
},
|
|
KeybindCommand {
|
|
name: "CAMERAZOOMIN",
|
|
category: "BINDING_HEADER_CAMERA",
|
|
run_on_up: false,
|
|
default1: Some("MOUSEWHEELUP"),
|
|
default2: None,
|
|
},
|
|
]);
|
|
s
|
|
}
|
|
|
|
#[test]
|
|
fn the_table_reads_like_the_reference() {
|
|
let s = script();
|
|
assert_eq!(s.eval::<usize>("return GetNumBindings()").unwrap(), 3);
|
|
// Command, category, key1, key2 (not 1.12's three values), the defaults seeded live.
|
|
assert!(s
|
|
.eval::<bool>(
|
|
r#"local c, cat, k1, k2 = GetBinding(1)
|
|
return c == "MOVEFORWARD" and cat == "BINDING_HEADER_MOVEMENT"
|
|
and k1 == "W" and k2 == "UP""#
|
|
)
|
|
.unwrap());
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetBindingAction("NUMPAD0")"#)
|
|
.unwrap(),
|
|
"JUMP"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetBindingAction("F")"#).unwrap(),
|
|
"",
|
|
"an unbound key reads as the empty command, like the client"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn set_binding_steals_and_refuses_only_a_key_string_that_is_not_a_key() {
|
|
let s = script();
|
|
let g0 = s.keybinds_generation();
|
|
// W leaves MOVEFORWARD (UP slides up) and joins the end of JUMP's list.
|
|
assert!(s
|
|
.eval::<bool>(r#"return SetBinding("W", "JUMP") == 1"#)
|
|
.unwrap());
|
|
assert!(s
|
|
.eval::<bool>(
|
|
r#"local k1, k2 = GetBindingKey("MOVEFORWARD"); return k1 == "UP" and k2 == nil"#
|
|
)
|
|
.unwrap());
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetBindingAction("W")"#).unwrap(),
|
|
"JUMP"
|
|
);
|
|
s.run(r#"SetBinding("SPACE")"#).unwrap();
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetBindingAction("SPACE")"#)
|
|
.unwrap(),
|
|
""
|
|
);
|
|
// The wheel binds to a `runOnUp` command: `0x4b7490` never reads the command.
|
|
assert!(s
|
|
.eval::<bool>(r#"return SetBinding("SHIFT-MOUSEWHEELUP", "MOVEFORWARD") == 1"#)
|
|
.unwrap());
|
|
assert!(s
|
|
.eval::<bool>(r#"return SetBinding("MOUSEWHEELDOWN", "CAMERAZOOMIN") == 1"#)
|
|
.unwrap());
|
|
// Refused by `IsValidBindingKeyString` (`0x4b7890`), on the pure unbind too.
|
|
for bad in ["SHIFT", "ALT", "UNKNOWN", "SCROLLLOCK", "SHIFT-LEFTBRACKET"] {
|
|
assert!(
|
|
s.eval::<bool>(&format!(r#"return SetBinding("{bad}", "JUMP") == nil"#))
|
|
.unwrap(),
|
|
"{bad} is not a bindable key string"
|
|
);
|
|
assert!(
|
|
s.eval::<bool>(&format!(r#"return SetBinding("{bad}") == nil"#))
|
|
.unwrap(),
|
|
"{bad} fails the same validator on the pure unbind"
|
|
);
|
|
}
|
|
for good in [
|
|
"SHIFT-CTRL-ALT-K",
|
|
"CTRL-SHIFT-K",
|
|
"-",
|
|
"BUTTON5",
|
|
"NUMPAD7",
|
|
"F11",
|
|
"NUMPADEQUALS",
|
|
"PRINTSCREEN",
|
|
] {
|
|
assert!(
|
|
s.eval::<bool>(&format!(r#"return SetBinding("{good}", "JUMP") == 1"#))
|
|
.unwrap(),
|
|
"{good} is a bindable key string"
|
|
);
|
|
}
|
|
assert!(s
|
|
.eval::<bool>(r#"return SetBinding("LEFTBRACKET", "JUMP") == 1"#)
|
|
.unwrap());
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetBindingAction("[")"#).unwrap(),
|
|
"JUMP",
|
|
"the alias is normalized to its literal character before it is stored"
|
|
);
|
|
// The validator is case-sensitive: a lower-case key string is refused.
|
|
assert!(s
|
|
.eval::<bool>(r#"return SetBinding("shift-w", "JUMP") == nil"#)
|
|
.unwrap());
|
|
assert!(
|
|
s.keybinds_generation() > g0,
|
|
"mutations bump the generation"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_three_set_model_loads_saves_and_deletes_like_the_reference() {
|
|
let mut s = script();
|
|
assert_eq!(s.current_binding_set(), 1);
|
|
assert!(!s.character_bindings_exist());
|
|
s.run(r#"SetBinding("F", "JUMP")"#).unwrap();
|
|
s.run("SaveBindings(2)").unwrap();
|
|
assert_eq!(s.current_binding_set(), 2);
|
|
assert!(s.character_bindings_exist());
|
|
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(2)]);
|
|
// Reset To Default keeps the character set current.
|
|
s.run("LoadBindings(0)").unwrap();
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetBindingAction("F")"#).unwrap(),
|
|
""
|
|
);
|
|
assert_eq!(s.current_binding_set(), 2);
|
|
// Cancel reloads the saved character set.
|
|
s.run("LoadBindings(2)").unwrap();
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetBindingAction("F")"#).unwrap(),
|
|
"JUMP"
|
|
);
|
|
// Saving the account set drops the character set, the confirmed delete.
|
|
s.run("SaveBindings(1)").unwrap();
|
|
assert_eq!(s.current_binding_set(), 1);
|
|
assert!(!s.character_bindings_exist());
|
|
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
|
|
}
|
|
|
|
#[test]
|
|
fn an_addons_bindings_xml_registers_as_ordinary_rows() {
|
|
let mut s = script();
|
|
let parsed = crate::bindings_xml::parse(
|
|
r#"<Bindings>
|
|
<Binding name="PROBEHOLD" runOnUp="true" header="PROBE">
|
|
if ( keystate == "down" ) then Down(); else Up(); end
|
|
</Binding>
|
|
<Binding name="PROBEEDGE">Edge();</Binding>
|
|
<Binding name="PROBEHIDDEN" hidden="true">Hidden();</Binding>
|
|
<Binding name="MOVEFORWARD">Hijack();</Binding>
|
|
</Bindings>"#,
|
|
)
|
|
.expect("well-formed");
|
|
let g0 = s.keybinds_generation();
|
|
s.register_addon_bindings("ProbeAddon", &parsed);
|
|
assert!(
|
|
s.keybinds_generation() > g0,
|
|
"registration must move the generation — it is the app's re-derive-dispatch signal"
|
|
);
|
|
|
|
// Three host rows and two addon rows: the addon's `MOVEFORWARD` is skipped and
|
|
// `PROBEHIDDEN` is not listed.
|
|
assert_eq!(s.eval::<usize>("return GetNumBindings()").unwrap(), 5);
|
|
assert!(s
|
|
.eval::<bool>(
|
|
r#"local c, cat, k1 = GetBinding(4)
|
|
return c == "PROBEHOLD" and cat == "BINDING_HEADER_PROBE" and k1 == nil"#
|
|
)
|
|
.unwrap());
|
|
assert!(
|
|
s.eval::<bool>(
|
|
r#"local c, cat = GetBinding(5)
|
|
return c == "PROBEEDGE" and cat == "BINDING_HEADER_PROBE""#
|
|
)
|
|
.unwrap(),
|
|
"a row with no header of its own belongs to the section the last header opened"
|
|
);
|
|
assert!(s
|
|
.eval::<bool>(
|
|
r#"local k1, k2 = GetBindingKey("MOVEFORWARD"); return k1 == "W" and k2 == "UP""#
|
|
)
|
|
.unwrap());
|
|
|
|
// The hidden row is not enumerated, but binds and is found by key.
|
|
assert!(s
|
|
.eval::<bool>(
|
|
r#"for i = 1, GetNumBindings() do
|
|
if GetBinding(i) == "PROBEHIDDEN" then return false end
|
|
end
|
|
return true"#
|
|
)
|
|
.unwrap());
|
|
assert!(s
|
|
.eval::<bool>(r#"return SetBinding("H", "PROBEHIDDEN") == 1"#)
|
|
.unwrap());
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetBindingAction("H")"#).unwrap(),
|
|
"PROBEHIDDEN"
|
|
);
|
|
// `runOnUp` never refuses a wheel chord.
|
|
assert!(s
|
|
.eval::<bool>(r#"return SetBinding("MOUSEWHEELUP", "PROBEHOLD") == 1"#)
|
|
.unwrap());
|
|
assert!(s
|
|
.eval::<bool>(r#"return SetBinding("MOUSEWHEELDOWN", "PROBEEDGE") == 1"#)
|
|
.unwrap());
|
|
|
|
// A file with no header: its rows' section is the addon's name.
|
|
let parsed = crate::bindings_xml::parse(
|
|
r#"<Bindings><Binding name="LIBKEY">Lib();</Binding></Bindings>"#,
|
|
)
|
|
.expect("well-formed");
|
|
s.register_addon_bindings("ProbeLib", &parsed);
|
|
assert!(s
|
|
.eval::<bool>(
|
|
r#"local c, cat = GetBinding(6); return c == "LIBKEY" and cat == "ProbeLib""#
|
|
)
|
|
.unwrap());
|
|
|
|
// Addon rows only, in registration order, hidden included.
|
|
let bodies = s.addon_binding_bodies();
|
|
let names: Vec<&str> = bodies.iter().map(|b| b.name.as_str()).collect();
|
|
assert_eq!(names, ["PROBEHOLD", "PROBEEDGE", "PROBEHIDDEN", "LIBKEY"]);
|
|
assert!(bodies[0].run_on_up && !bodies[1].run_on_up);
|
|
assert!(bodies[0].body.contains(r#"keystate == "down""#));
|
|
}
|
|
|
|
#[test]
|
|
fn a_binding_for_another_platform_does_not_register() {
|
|
let mut s = script();
|
|
let parsed = crate::bindings_xml::parse(
|
|
r#"<Bindings>
|
|
<Binding name="PROBEHERE" platform="THIS">Here();</Binding>
|
|
<Binding name="PROBEELSEWHERE" platform="elsewhere">Elsewhere();</Binding>
|
|
<Binding name="PROBEANYWHERE">Anywhere();</Binding>
|
|
</Bindings>"#
|
|
.replace("THIS", super::THIS_PLATFORM)
|
|
.as_str(),
|
|
)
|
|
.unwrap();
|
|
s.register_addon_bindings("Probe", &parsed);
|
|
|
|
let names: Vec<String> = s.keybind_snapshot().into_iter().map(|(n, _)| n).collect();
|
|
assert!(names.iter().any(|n| n == "PROBEHERE"), "{names:?}");
|
|
assert!(names.iter().any(|n| n == "PROBEANYWHERE"), "{names:?}");
|
|
assert!(
|
|
!names.iter().any(|n| n == "PROBEELSEWHERE"),
|
|
"a foreign-platform row registered: {names:?}"
|
|
);
|
|
assert!(!s
|
|
.addon_binding_bodies()
|
|
.iter()
|
|
.any(|b| b.name == "PROBEELSEWHERE"));
|
|
}
|
|
|
|
#[test]
|
|
fn host_seeding_feeds_load_and_the_capture_arm_reads_back() {
|
|
let mut s = script();
|
|
s.seed_binding_set(1, Some(vec![("JUMP".into(), vec!["F".into()])]));
|
|
s.load_binding_set(1);
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetBindingAction("F")"#).unwrap(),
|
|
"JUMP"
|
|
);
|
|
// A command the seed lacks keeps its live keys.
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetBindingAction("W")"#).unwrap(),
|
|
"MOVEFORWARD"
|
|
);
|
|
assert!(!s.bind_capture_armed());
|
|
s.run("BenillaBindCapture(true)").unwrap();
|
|
assert!(s.bind_capture_armed());
|
|
s.run("BenillaBindCapture(false)").unwrap();
|
|
assert!(!s.bind_capture_armed());
|
|
let snap = s.keybind_snapshot();
|
|
assert_eq!(snap[1].0, "JUMP");
|
|
assert_eq!(snap[1].1, vec!["F".to_string()]);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod addon_persistence_tests {
|
|
use crate::bindings_xml::AddonBinding;
|
|
use crate::script::keybind::KeybindCommand;
|
|
use crate::script::UiScript;
|
|
|
|
fn binding(name: &str) -> AddonBinding {
|
|
AddonBinding {
|
|
name: name.into(),
|
|
header: None,
|
|
run_on_up: false,
|
|
hidden: false,
|
|
platform: None,
|
|
body: "AddonBindingRan = true".into(),
|
|
}
|
|
}
|
|
|
|
/// The stored set is seeded at boot, before the addon's `Bindings.xml` registers at world
|
|
/// entry, so its row has no position to land in.
|
|
#[test]
|
|
fn an_addon_binding_restores_its_stored_chord_registered_after_the_seed() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.register_bindings(&[KeybindCommand {
|
|
name: "JUMP",
|
|
category: "BINDING_HEADER_MOVEMENT",
|
|
run_on_up: false,
|
|
default1: Some("SPACE"),
|
|
default2: None,
|
|
}]);
|
|
s.seed_binding_set(
|
|
1,
|
|
Some(vec![
|
|
("JUMP".into(), vec!["SPACE".into()]),
|
|
("MYADDONTOGGLE".into(), vec!["CTRL-X".into()]),
|
|
]),
|
|
);
|
|
s.load_binding_set(1);
|
|
|
|
s.register_addon_bindings("MyAddon", &[binding("MYADDONTOGGLE")]);
|
|
|
|
let bound = s
|
|
.keybind_snapshot()
|
|
.into_iter()
|
|
.find(|(n, _)| n == "MYADDONTOGGLE")
|
|
.expect("the addon's command is in the table");
|
|
assert_eq!(
|
|
bound.1,
|
|
vec!["CTRL-X".to_string()],
|
|
"the stored chord came back, though the seed ran before the addon registered"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_addon_binding_with_no_stored_chord_registers_unbound() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.seed_binding_set(1, Some(vec![("SOMETHINGELSE".into(), vec!["Q".into()])]));
|
|
s.load_binding_set(1);
|
|
s.register_addon_bindings("MyAddon", &[binding("MYADDONTOGGLE")]);
|
|
let bound = s
|
|
.keybind_snapshot()
|
|
.into_iter()
|
|
.find(|(n, _)| n == "MYADDONTOGGLE")
|
|
.expect("registered");
|
|
assert!(bound.1.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn the_character_set_wins_for_an_addon_command() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.seed_binding_set(
|
|
1,
|
|
Some(vec![("MYADDONTOGGLE".into(), vec!["CTRL-X".into()])]),
|
|
);
|
|
s.seed_binding_set(
|
|
2,
|
|
Some(vec![("MYADDONTOGGLE".into(), vec!["ALT-Z".into()])]),
|
|
);
|
|
s.load_binding_set(2);
|
|
s.register_addon_bindings("MyAddon", &[binding("MYADDONTOGGLE")]);
|
|
let bound = s
|
|
.keybind_snapshot()
|
|
.into_iter()
|
|
.find(|(n, _)| n == "MYADDONTOGGLE")
|
|
.expect("registered");
|
|
assert_eq!(bound.1, vec!["ALT-Z".to_string()]);
|
|
}
|
|
}
|