The core loads the chain's Bindings.xml and DefaultBindings.wtf, and every command runs its stock body (#587)

The core now loads the chain's `Interface\FrameXML\Bindings.xml` after the `FrameXML.toc` rows, as the reference's UI load does. The load checks the file at `0x48fff9` and loads it through the addons' loader `0x4b6f70` at `0x490018`. The default keys come from the chain's `WTF\DefaultBindings.wtf` (`0x4b62b0`). Every command now runs the stock file's own body, and the registry compiled into benilla, with its 91 retyped bodies, is deleted.

- **The loader's rules (`0x4b6f70`).** It reads the root's children only and skips empty, `debug="true"` and non-Windows rows. A second definition of a name is skipped, and duplicate headers are refused.
- **Keys and commands are separate tables, as in 1.12.** `SetBinding` never reads the command (`0x4b7490`). `RunBinding` runs the body in the VM. `RunCommand` sets `keystate` around the body and clears it after (`0x4b7c42`).
- **Engine behaviour sits behind the Lua globals the bodies call.** These are the movement, turn and mouse-steer bits. Focus loss clears the direction bits and keeps the mouse pair (`0x514490`). One key's stop clears its bit, as `0x514b70` does, so releasing W while UP is held stops the character.
- **Behaviour changes that follow the stock bodies.**
  - The nameplate keys (V, Shift-V, Ctrl-V) now run the stock bodies.
  - ALT-Z runs `CloseAllWindows(); UIParent:Hide()`.
  - The minimap, sound, stance and bonus-bar keys call their stock functions.
  - The wheel zooms one `CameraZoomIn(1.0)` per notch.
- **Platform.** benilla answers as the PC build, so a `platform="mac"` row does not register on any OS.

Verified:
- New tests: a fixture `Bindings.xml` (reordered, edited, and with debug, empty, nested and Mac rows) drives the command list, defaults and bodies. The V keys follow the stock bodies. No stock body's text remains in the tree. Every stock body's calls resolve after a production load. Each test fails with its change undone.
- `scripts/gates.sh` and `scripts/smoke.sh` are green.
- A live run with `WOW_GM=off`:
  - W and Space moved and jumped.
  - B toggled the backpack.
  - 1 reached `UseAction`.
  - V, Shift-V and Ctrl-V behaved as the stock bodies do.

Built on macOS; no platform seam touched.

Fixes #570
Fixes #563

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-29 10:51:21 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c574e20031
commit 34b7c30af2
41 changed files with 1643 additions and 3855 deletions
File diff suppressed because it is too large Load Diff
+21 -11
View File
@@ -372,20 +372,30 @@ fn token_key(t: &str) -> Option<BindKey> {
mod tests {
use super::*;
/// Ties this namer to the engine's `IsValidBindingKeyString`: every default chord, and one key
/// of each token shape, since `KeyCode` cannot be enumerated.
/// Ties this namer to the engine's `IsValidBindingKeyString`: every chord the install's
/// `WTF\DefaultBindings.wtf` ships is a key string it accepts and a chord the dispatcher can
/// press, and one key of each token shape, since `KeyCode` cannot be enumerated.
#[test]
fn every_token_the_codec_names_is_one_setbinding_accepts() {
use benilla_ui::script::keybind::normalize_binding_key;
for spec in super::super::commands::SPECS {
for default in [spec.d1, spec.d2].into_iter().flatten() {
assert_eq!(
normalize_binding_key(default).as_deref(),
Some(default),
"the default chord '{default}' ({}) is not a bindable key string",
spec.name
);
}
let defaults = crate::ui_script::default_bindings();
if benilla_formats::wow_data().is_some() {
assert!(
defaults.len() > 100,
"the install's defaults: {}",
defaults.len()
);
}
for (default, command) in &defaults {
assert_eq!(
normalize_binding_key(default).as_deref(),
Some(default.as_str()),
"the default chord '{default}' ({command}) is not a bindable key string"
);
assert!(
Chord::parse(default).is_some(),
"{command}: default '{default}' does not parse"
);
}
for k in [
KeyCode::KeyW,
File diff suppressed because it is too large Load Diff
+75 -81
View File
@@ -1,36 +1,73 @@
//! The binding functions called from Lua ([`BindingInput`]), applied to the same state their keys
//! drive. In the reference a movement key's body is its Lua function (`MOVEFORWARD` runs
//! `MoveForwardStart()` on the press and `MoveForwardStop()` on the release), and both halves set
//! and clear one bit of `[InputControl+4]` through `0x515090`; so a Start with no Stop keeps
//! moving, a Stop clears the bit whichever input set it, and a one-shot fires as its key does.
//! What the binding functions called from Lua ([`BindingInput`]) leave for the engine. Every key
//! runs its command's `Bindings.xml` body, so this is the one way a key moves the player: in the
//! reference `MOVEFORWARD` runs `MoveForwardStart()` on the press and `MoveForwardStop()` on the
//! release, and both halves set and clear one bit of `[InputControl+4]` through `0x515090`; so a
//! Start with no Stop keeps moving, a Stop clears the bit whichever input set it, and a one-shot
//! fires once per call.
use benilla_ui::script::{BindingInput, FiredInput, HeldInput};
use super::commands::{cmd, Cmd};
use super::{BindingsState, Bound};
use super::BindingsState;
/// The held command a Start or Stop names.
fn held_cmd(input: HeldInput) -> Cmd {
/// One engine input a binding function drives: a held bit of `[InputControl+4]` or a one-shot.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Input {
/// `MoveForwardStart`/`Stop`, bit `0x10`.
MoveForward,
/// `MoveBackwardStart`/`Stop`, bit `0x20`.
MoveBackward,
/// `TurnLeftStart`/`Stop`, bit `0x100`.
TurnLeft,
/// `TurnRightStart`/`Stop`, bit `0x200`.
TurnRight,
/// `StrafeLeftStart`/`Stop`, bit `0x40`.
StrafeLeft,
/// `StrafeRightStart`/`Stop`, bit `0x80`.
StrafeRight,
/// `TurnOrActionStart`/`Stop`, bit `0x1`.
TurnOrAction,
/// `CameraOrSelectOrMoveStart`/`Stop`, bit `0x2`.
CameraOrSelectOrMove,
/// `Jump()`.
Jump,
/// `ToggleRun()`, the walk toggle.
ToggleRun,
/// `ToggleAutoRun()`.
ToggleAutoRun,
/// `SitOrStand()`.
SitOrStand,
/// `ToggleSheath()`.
ToggleSheath,
/// `CameraZoomIn([yards])`, its yards in [`BindingsState::amount`].
CameraZoomIn,
/// `CameraZoomOut([yards])`, its yards in [`BindingsState::amount`].
CameraZoomOut,
}
/// The bit a Start or Stop names.
fn held_input(input: HeldInput) -> Input {
match input {
HeldInput::MoveForward => cmd::MOVE_FORWARD,
HeldInput::MoveBackward => cmd::MOVE_BACKWARD,
HeldInput::TurnLeft => cmd::TURN_LEFT,
HeldInput::TurnRight => cmd::TURN_RIGHT,
HeldInput::StrafeLeft => cmd::STRAFE_LEFT,
HeldInput::StrafeRight => cmd::STRAFE_RIGHT,
HeldInput::MoveForward => Input::MoveForward,
HeldInput::MoveBackward => Input::MoveBackward,
HeldInput::TurnLeft => Input::TurnLeft,
HeldInput::TurnRight => Input::TurnRight,
HeldInput::StrafeLeft => Input::StrafeLeft,
HeldInput::StrafeRight => Input::StrafeRight,
HeldInput::TurnOrAction => Input::TurnOrAction,
HeldInput::CameraOrSelectOrMove => Input::CameraOrSelectOrMove,
}
}
/// The one-shot command a fire names, and its amount: a zoom's yards, else the key's 1.0 step.
fn fired_cmd(input: FiredInput) -> (Cmd, f32) {
/// The one-shot a fire names, and its amount: a zoom's yards, else 1.0.
fn fired_input(input: FiredInput) -> (Input, f32) {
match input {
FiredInput::Jump => (cmd::JUMP, 1.0),
FiredInput::ToggleRun => (cmd::TOGGLE_RUN, 1.0),
FiredInput::ToggleAutoRun => (cmd::TOGGLE_AUTORUN, 1.0),
FiredInput::SitOrStand => (cmd::SIT_OR_STAND, 1.0),
FiredInput::ToggleSheath => (cmd::TOGGLE_SHEATH, 1.0),
FiredInput::CameraZoomIn(yards) => (cmd::CAMERA_ZOOM_IN, yards),
FiredInput::CameraZoomOut(yards) => (cmd::CAMERA_ZOOM_OUT, yards),
FiredInput::Jump => (Input::Jump, 1.0),
FiredInput::ToggleRun => (Input::ToggleRun, 1.0),
FiredInput::ToggleAutoRun => (Input::ToggleAutoRun, 1.0),
FiredInput::SitOrStand => (Input::SitOrStand, 1.0),
FiredInput::ToggleSheath => (Input::ToggleSheath, 1.0),
FiredInput::CameraZoomIn(yards) => (Input::CameraZoomIn, yards),
FiredInput::CameraZoomOut(yards) => (Input::CameraZoomOut, yards),
}
}
@@ -40,71 +77,28 @@ impl BindingsState {
for input in inputs {
match input {
// The set helper `0x514840` early-outs on a bit already set, so a Start on a held
// command is no new edge.
// bit is no new edge.
BindingInput::Start(held) => {
let c = held_cmd(held);
if !self.pressed(c) {
self.just.push(c);
self.script_held.push(c);
let i = held_input(held);
if !self.pressed(i) {
self.just.push(i);
self.held.push(i);
}
}
// `0x514b70` clears the bit whoever set it: a key still down stays latched to
// nothing, so the release that follows is a no-op, as the reference's second Stop.
// `0x514b70` clears the bit whoever set it, so a second key still down on the
// same command stops too, and its release is a no-op second Stop.
BindingInput::Stop(held) => {
let c = held_cmd(held);
self.script_held.retain(|&h| h != c);
self.latched.retain(|&(_, b)| b != Bound::Spec(c));
let i = held_input(held);
self.held.retain(|&h| h != i);
}
// A one-shot is its own press edge.
BindingInput::Fire(fired) => {
let (c, amount) = fired_cmd(fired);
self.fired.push(c);
if !self.pressed(c) {
self.just.push(c);
}
self.amounts.push((c, amount));
let (i, amount) = fired_input(fired);
self.fired.push(i);
self.just.push(i);
self.amounts.push((i, amount));
}
}
}
}
/// A key's release of a held command runs its Stop, which clears a Lua Start's bit too.
pub(super) fn release_script_held(&mut self, c: Cmd) {
self.script_held.retain(|&h| h != c);
}
}
#[cfg(test)]
mod tests {
use super::super::commands::{Kind, SPECS};
use super::*;
/// Every name the VM can send is a command of the kind it drives: a Start and Stop a held one,
/// a fire a host one.
#[test]
fn every_script_input_names_a_command_of_its_kind() {
use HeldInput::*;
for held in [
MoveForward,
MoveBackward,
TurnLeft,
TurnRight,
StrafeLeft,
StrafeRight,
] {
let spec = &SPECS[held_cmd(held).0 as usize];
assert!(matches!(spec.kind, Kind::Held), "{held:?} → {}", spec.name);
}
for fired in [
FiredInput::Jump,
FiredInput::ToggleRun,
FiredInput::ToggleAutoRun,
FiredInput::SitOrStand,
FiredInput::ToggleSheath,
FiredInput::CameraZoomIn(1.0),
FiredInput::CameraZoomOut(1.0),
] {
let spec = &SPECS[fired_cmd(fired).0 .0 as usize];
assert!(matches!(spec.kind, Kind::Host), "{fired:?} → {}", spec.name);
}
}
}
+86 -82
View File
@@ -13,29 +13,48 @@
//! Deviation: the reference saves a full snapshot (`bindings-cache.wtf`); a diff is stored
//! because a full snapshot would leave every command added later unbound for a saved file.
use std::collections::HashMap;
use benilla_ui::script::keybind::KeyBinding;
use super::commands::SPECS;
/// A key list grouped by command, in first-appearance order; names compare case-insensitively.
fn by_command(list: &[KeyBinding]) -> Vec<(String, Vec<String>)> {
let mut out: Vec<(String, Vec<String>)> = Vec::new();
for (key, command) in list {
match out
.iter_mut()
.find(|(c, _)| c.eq_ignore_ascii_case(command))
{
Some((_, keys)) => keys.push(key.clone()),
None => out.push((command.clone(), vec![key.clone()])),
}
}
out
}
/// Serialize the live `(command, keys)` table, in registry order, as the diff file.
pub(crate) fn to_diff(snapshot: &[(String, Vec<String>)]) -> String {
let defaults: HashMap<&str, Vec<&str>> = SPECS
.iter()
.map(|s| (s.name, [s.d1, s.d2].into_iter().flatten().collect()))
.collect();
/// Serialize the live `(command, keys)` table (`keybind_snapshot`) as the diff file against the
/// defaults (`WTF\DefaultBindings.wtf`): a line for each command whose keys differ, including a
/// defaulted command the live table left unbound.
pub(crate) fn to_diff(snapshot: &[(String, Vec<String>)], defaults: &[KeyBinding]) -> String {
let defaults = by_command(defaults);
let default_of = |name: &str| {
defaults
.iter()
.find(|(c, _)| c.eq_ignore_ascii_case(name))
.map_or(&[][..], |(_, k)| k.as_slice())
};
let mut out = String::from("# benilla key bindings — diff vs defaults\n");
for (name, keys) in snapshot {
let is_default = defaults
.get(name.as_str())
.is_some_and(|d| d.iter().copied().eq(keys.iter().map(String::as_str)));
if is_default {
let unlisted = defaults
.iter()
.filter(|(c, _)| !snapshot.iter().any(|(n, _)| n.eq_ignore_ascii_case(c)))
.map(|(c, _)| (c.clone(), Vec::new()));
for (name, keys) in snapshot.iter().cloned().chain(unlisted) {
if default_of(&name) == keys.as_slice() {
continue;
}
out.push_str("bind ");
out.push_str(name);
out.push_str(&name);
for k in keys {
out.push(' ');
out.push_str(k);
out.push_str(&k);
}
out.push('\n');
}
@@ -64,35 +83,14 @@ pub(crate) fn from_diff(text: &str) -> Vec<(String, Vec<String>)> {
out
}
/// Resolve a diff into the full key table: defaults, then each override in file order stealing
/// its keys from every other command, so one key always maps to one command.
pub(crate) fn resolve(diff: &[(String, Vec<String>)]) -> Vec<(String, Vec<String>)> {
let mut table: Vec<(String, Vec<String>)> = SPECS
.iter()
.map(|s| {
(
s.name.to_string(),
[s.d1, s.d2]
.into_iter()
.flatten()
.map(str::to_owned)
.collect(),
)
})
.collect();
/// Resolve a diff into the full key table: the defaults, then each override in file order
/// replacing its command's keys and stealing each key from every other command, so one key always
/// maps to one command.
pub(crate) fn resolve(diff: &[(String, Vec<String>)], defaults: &[KeyBinding]) -> Vec<KeyBinding> {
let mut table = defaults.to_vec();
for (name, keys) in diff {
// Steal each key from wherever it currently sits, then install the command's list.
for k in keys {
for (_, held) in table.iter_mut() {
held.retain(|h| h != k);
}
}
match table.iter_mut().find(|(n, _)| n == name) {
Some((_, slot)) => *slot = keys.clone(),
// A name not in SPECS is an addon's command, registered later at world entry: keep
// its row so the binding has its keys to restore.
None => table.push((name.clone(), keys.clone())),
}
table.retain(|(k, c)| !c.eq_ignore_ascii_case(name) && !keys.contains(k));
table.extend(keys.iter().map(|k| (k.clone(), name.clone())));
}
table
}
@@ -101,65 +99,73 @@ pub(crate) fn resolve(diff: &[(String, Vec<String>)]) -> Vec<(String, Vec<String
mod tests {
use super::*;
fn defaults() -> Vec<KeyBinding> {
[
("W", "MOVEFORWARD"),
("UP", "MOVEFORWARD"),
("Q", "STRAFELEFT"),
("SPACE", "JUMP"),
("NUMPAD0", "JUMP"),
("Z", "TOGGLESHEATH"),
]
.into_iter()
.map(|(k, c)| (k.to_string(), c.to_string()))
.collect()
}
fn keys(table: &[KeyBinding], command: &str) -> Vec<String> {
table
.iter()
.filter(|(_, c)| c == command)
.map(|(k, _)| k.clone())
.collect()
}
#[test]
fn the_diff_round_trips_and_defaults_stay_silent() {
// All defaults: a header-only file.
let snapshot: Vec<(String, Vec<String>)> = SPECS
.iter()
.map(|s| {
(
s.name.to_string(),
[s.d1, s.d2]
.into_iter()
.flatten()
.map(str::to_owned)
.collect(),
)
})
.collect();
let text = to_diff(&snapshot);
let snapshot = by_command(&defaults());
let text = to_diff(&snapshot, &defaults());
assert_eq!(text.lines().count(), 1, "defaults produce no bind lines");
// Move JUMP to F (unbinding SPACE/NUMPAD0), unbind TOGGLESHEATH entirely.
let mut moved = snapshot.clone();
// Move JUMP to F (unbinding SPACE/NUMPAD0), unbind TOGGLESHEATH entirely, and leave
// STRAFELEFT out of the snapshot with no keys (a defaulted command no file declared).
let mut moved: Vec<(String, Vec<String>)> = snapshot
.into_iter()
.filter(|(n, _)| n != "STRAFELEFT")
.collect();
moved.iter_mut().find(|(n, _)| n == "JUMP").unwrap().1 = vec!["F".into()];
moved
.iter_mut()
.find(|(n, _)| n == "TOGGLESHEATH")
.unwrap()
.1 = vec![];
let text = to_diff(&moved);
let text = to_diff(&moved, &defaults());
assert!(text.contains("bind JUMP F\n"));
assert!(
text.contains("bind TOGGLESHEATH\n"),
"unbound = a bare line"
);
assert!(
text.contains("bind STRAFELEFT\n"),
"a defaulted command the snapshot lacks is unbound too"
);
assert!(
!text.contains("MOVEFORWARD"),
"untouched commands stay absent"
);
let parsed = from_diff(&text);
let resolved = resolve(&parsed);
let get = |n: &str| {
resolved
.iter()
.find(|(name, _)| name == n)
.map(|(_, k)| k.clone())
.unwrap()
};
assert_eq!(get("JUMP"), vec!["F".to_string()]);
assert!(get("TOGGLESHEATH").is_empty());
assert_eq!(get("MOVEFORWARD"), vec!["W".to_string(), "UP".to_string()]);
let resolved = resolve(&from_diff(&text), &defaults());
assert_eq!(keys(&resolved, "JUMP"), ["F"]);
assert!(keys(&resolved, "TOGGLESHEATH").is_empty());
assert_eq!(keys(&resolved, "MOVEFORWARD"), ["W", "UP"]);
}
#[test]
fn resolve_steals_across_commands_even_without_a_line_for_the_victim() {
let resolved = resolve(&[("JUMP".to_string(), vec!["W".to_string()])]);
let fwd = &resolved.iter().find(|(n, _)| n == "MOVEFORWARD").unwrap().1;
assert_eq!(fwd, &vec!["UP".to_string()]);
let jump = &resolved.iter().find(|(n, _)| n == "JUMP").unwrap().1;
assert_eq!(jump, &vec!["W".to_string()]);
let resolved = resolve(&[("JUMP".to_string(), vec!["W".to_string()])], &defaults());
assert_eq!(keys(&resolved, "MOVEFORWARD"), ["UP"]);
assert_eq!(keys(&resolved, "JUMP"), ["W"]);
}
#[test]
@@ -173,12 +179,10 @@ mod tests {
]
);
// An unknown command (an addon's, registered later) is kept, and still steals its key.
let resolved = resolve(&parsed);
let bogus = &resolved.iter().find(|(n, _)| n == "BOGUSCMD").unwrap().1;
assert_eq!(bogus, &vec!["Q".to_string()]);
let strafe = &resolved.iter().find(|(n, _)| n == "STRAFELEFT").unwrap().1;
let resolved = resolve(&parsed, &defaults());
assert_eq!(keys(&resolved, "BOGUSCMD"), ["Q"]);
assert!(
strafe.is_empty(),
keys(&resolved, "STRAFELEFT").is_empty(),
"Q was stolen by the unknown command's line"
);
}
+4 -4
View File
@@ -1026,13 +1026,13 @@ pub(super) fn seed_ui_fixture(
}
}
UiFixture::KeyBindings => {
let Some(mut script) = script else {
let Some(script) = script else {
return;
};
// The Keybindings page: the real command registry and CVar set first, then the live
// open path, with Movement expanded to show a header row and the default bindings.
// The Keybindings page over the commands the load registered: the real CVar set
// first, then the live open path, with Movement expanded to show a header row and the
// default bindings.
script.register_cvars(crate::cvars::registered_pairs());
script.register_bindings(&crate::bindings::registry_commands());
if let Err(e) = script.run(
"ShowUIPanel(BenillaOptionsFrame); \
BenillaOptionsFrameCategoryListRowKeybindings:Click(); \
+5 -1
View File
@@ -141,8 +141,12 @@ fn probe_key_by_name(name: &str) -> Option<KeyCode> {
"Tab" => KeyCode::Tab,
// TOGGLEAUTORUN's 1.12 default; with `WOW_PROBE_LOOK` it scripts a drive.
"NumLock" => KeyCode::NumLock,
// The nameplate toggle: bare `V` shows enemy plates, `Shift`+`V` friendly ones.
// The nameplate toggles: `V`, `Shift`+`V`, `Ctrl`+`V`.
"V" => KeyCode::KeyV,
// A window toggle (TOGGLEBACKPACK) and an action button (ACTIONBUTTON1), by their 1.12
// defaults.
"B" => KeyCode::KeyB,
"1" => KeyCode::Digit1,
// Free-fly is the dev chord (`Ctrl`+`Shift`) + `F` (`player.rs`); a held `Ctrl` is its ×5
// speed boost (`camera.rs`).
"F" => KeyCode::KeyF,
+2 -2
View File
@@ -104,8 +104,8 @@ pub(super) enum UiFixture {
OptionsDropdownList,
/// The Options window mid-search: "volume" lists the four volume sliders under the Audio head.
OptionsSearch,
/// The Options window's Keybindings page, Movement expanded, over the 1.12 default bindings and
/// GlobalStrings; the command registry registers in-fixture.
/// The Options window's Keybindings page, Movement expanded, over the stock commands and
/// default bindings the load read off the install, and GlobalStrings.
KeyBindings,
/// An overhead name with the river behind it: a named unit 25 yd out in the Elwynn river (the
/// `water-noon` camera). Deep water is opaque (`WATER_DEEP_ALPHA` 1.0), so a name sorted before
+1 -3
View File
@@ -226,8 +226,6 @@ impl PluginGroup for GamePlugins {
.add(crate::raid_marks::RaidMarksPlugin)
.add(crate::vplates::VPlatesPlugin)
.add(crate::chat_bubble::ChatBubblePlugin)
// TOGGLEUI (`ALT-Z`): the whole quad layer goes dark, leaving the world and the cursor.
.add(crate::ui_hide::UiHidePlugin)
.add(UiItemsPlugin)
.add(UiGossipPlugin)
.add(UiMerchantPlugin)
@@ -674,7 +672,7 @@ pub(crate) mod schedule_tests {
/// Raising the ceiling is a claim that a new undeclared order is acceptable: make it with the
/// reason read off the dump, or declare the order (`.after`, a set, a `chain`). A resource
/// that commutes by construction belongs in [`Classes`].
const UPDATE_ACTIONABLE_CEILING: usize = 4_890;
const UPDATE_ACTIONABLE_CEILING: usize = 4_849;
const UPDATE_ACTIONABLE_SLACK: usize = 40;
fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) {
-1
View File
@@ -135,7 +135,6 @@ mod ui_gamma;
mod ui_gm_ticket;
mod ui_gossip;
mod ui_guild;
mod ui_hide;
mod ui_honor;
mod ui_inspect;
mod ui_instance;
+3 -3
View File
@@ -266,8 +266,8 @@ pub(super) fn control(
}
// A rebound zoom key steps 1.0 per press, the stock `CameraZoomIn(1.0)` (`Bindings.xml:707`).
let zoom = binds.amount(crate::bindings::cmd::CAMERA_ZOOM_IN)
- binds.amount(crate::bindings::cmd::CAMERA_ZOOM_OUT);
let zoom = binds.amount(crate::bindings::Input::CameraZoomIn)
- binds.amount(crate::bindings::Input::CameraZoomOut);
apply_zoom_scroll(zoom, dt, &mut rig, zoom_max);
// Free fly is a dev tool on the dev chord: a bare `F` is the player's to bind.
@@ -493,7 +493,7 @@ pub(super) fn control(
// `Jump` (`0x513bd0`) inlines `0x5144e0` and `0x514560`, which is `may_translate` term for
// term: health, root and stand state 7. Hover's refusal is the movement handler's
// (`0x7c623a`), which keeps the mounted flourish reachable while hovering.
let mut want_jump = binds.fired(crate::bindings::cmd::JUMP) && may_translate;
let mut want_jump = binds.fired(crate::bindings::Input::Jump) && may_translate;
// Swim or walk, latched with hysteresis at the `0x6030c0` boundary against flicker.
let surface_y = swim::surface_over_feet(world, player.pos);
+7 -7
View File
@@ -163,14 +163,14 @@ pub(super) struct FollowInput<'w, 's> {
impl FollowInput<'_, '_> {
/// The press edge of any of the six movement commands; turn and strafe both cancel.
fn move_start(&self) -> bool {
use crate::bindings::cmd;
use crate::bindings::Input;
[
cmd::MOVE_FORWARD,
cmd::MOVE_BACKWARD,
cmd::TURN_LEFT,
cmd::TURN_RIGHT,
cmd::STRAFE_LEFT,
cmd::STRAFE_RIGHT,
Input::MoveForward,
Input::MoveBackward,
Input::TurnLeft,
Input::TurnRight,
Input::StrafeLeft,
Input::StrafeRight,
]
.iter()
.any(|&c| self.binds.just_pressed(c))
+17 -18
View File
@@ -24,7 +24,7 @@ pub(super) fn look_input(
) -> LookInput {
// Both buttons held run forward and steer with the mouse, whichever went down first.
// MOVEANDSTEER is the same state: its 1.12 body runs CameraOrSelectOrMove and TurnOrAction.
let steer_held = binds.pressed(crate::bindings::cmd::MOVE_AND_STEER);
let steer_held = binds.steering();
let both_buttons = rig.world_mouse.both() || steer_held;
// 1.12's `[InputControl+0x4]`, bit for bit: the camera's auto-follow arms on its edges.
@@ -43,27 +43,27 @@ pub(super) fn look_input(
);
// `/follow` sets the forward bit through W's own setter, so it arms the camera like W.
set(
binds.pressed(crate::bindings::cmd::MOVE_FORWARD) || player.follow_forward,
binds.pressed(crate::bindings::Input::MoveForward) || player.follow_forward,
bit::FORWARD,
);
set(
binds.pressed(crate::bindings::cmd::MOVE_BACKWARD),
binds.pressed(crate::bindings::Input::MoveBackward),
bit::BACKWARD,
);
set(
binds.pressed(crate::bindings::cmd::STRAFE_LEFT),
binds.pressed(crate::bindings::Input::StrafeLeft),
bit::STRAFE_LEFT,
);
set(
binds.pressed(crate::bindings::cmd::STRAFE_RIGHT),
binds.pressed(crate::bindings::Input::StrafeRight),
bit::STRAFE_RIGHT,
);
set(
binds.pressed(crate::bindings::cmd::TURN_LEFT),
binds.pressed(crate::bindings::Input::TurnLeft),
bit::TURN_LEFT,
);
set(
binds.pressed(crate::bindings::cmd::TURN_RIGHT),
binds.pressed(crate::bindings::Input::TurnRight),
bit::TURN_RIGHT,
);
set(player.autorun, bit::AUTORUN);
@@ -118,7 +118,7 @@ pub(super) fn move_axes(
// `Forward`). The reference's window-deactivate handler (`0x514490`, called only from the
// WM_ACTIVATE slot at `0x493058`) releases every direction bit and keeps `0x1000`.
let mut autorun_armed = false;
if binds.fired(crate::bindings::cmd::TOGGLE_AUTORUN) {
if binds.fired(crate::bindings::Input::ToggleAutoRun) {
player.autorun = !player.autorun;
autorun_armed = player.autorun;
}
@@ -140,11 +140,10 @@ pub(super) fn move_axes(
// emit; ours is `state::may_translate`. The server-ride term is benilla's own.
// A jump, a chat EditBox taking focus and a zone change leave it set; mounting is untraced and
// leaves it set here. A focused chat box releases nothing, so W held through ENTER still runs.
let both_buttons_engaged = (both_buttons && rig.world_mouse.rose())
|| binds.just_pressed(crate::bindings::cmd::MOVE_AND_STEER);
let both_buttons_engaged = (both_buttons && rig.world_mouse.rose()) || binds.steering_began();
if state::autorun_cancelled(
binds.just_pressed(crate::bindings::cmd::MOVE_FORWARD),
binds.just_pressed(crate::bindings::cmd::MOVE_BACKWARD),
binds.just_pressed(crate::bindings::Input::MoveForward),
binds.just_pressed(crate::bindings::Input::MoveBackward),
both_buttons_engaged,
!may_translate || player.server_riding,
) {
@@ -157,8 +156,8 @@ pub(super) fn move_axes(
// walks you backward. `/follow` is a held forward, W's own bit `0x100000` through W's setter,
// so it nets against S like W and never trips the cancel set, which fires on key-down edges.
let fwd_axis = state::forward_axis(
binds.pressed(crate::bindings::cmd::MOVE_FORWARD) || player.follow_forward,
binds.pressed(crate::bindings::cmd::MOVE_BACKWARD),
binds.pressed(crate::bindings::Input::MoveForward) || player.follow_forward,
binds.pressed(crate::bindings::Input::MoveBackward),
both_buttons,
autorun,
);
@@ -168,10 +167,10 @@ pub(super) fn move_axes(
let mouselook = both_buttons || rig.look == Some(LookButton::Right);
// The strafe axis nets like `fwd_axis`: vmangos relays no packet carrying both strafe bits,
// and the reference's 1.12.1 capture sends none.
let strafe_left = binds.pressed(crate::bindings::cmd::STRAFE_LEFT);
let strafe_right = binds.pressed(crate::bindings::cmd::STRAFE_RIGHT);
let turn_left = binds.pressed(crate::bindings::cmd::TURN_LEFT);
let turn_right = binds.pressed(crate::bindings::cmd::TURN_RIGHT);
let strafe_left = binds.pressed(crate::bindings::Input::StrafeLeft);
let strafe_right = binds.pressed(crate::bindings::Input::StrafeRight);
let turn_left = binds.pressed(crate::bindings::Input::TurnLeft);
let turn_right = binds.pressed(crate::bindings::Input::TurnRight);
let side_axis = i32::from(strafe_right) - i32::from(strafe_left)
+ if mouselook {
i32::from(turn_right) - i32::from(turn_left)
+3 -3
View File
@@ -51,7 +51,7 @@ pub(super) fn update(
// The queued asks (the `/sit` family) ran in the reference's message pass, before the X key
// is read; the last writer wins, and all land on the single commit below.
let mut request_stand = asks.read().last().map(|r| r.state);
if binds.fired(crate::bindings::cmd::SIT_OR_STAND) {
if binds.fired(crate::bindings::Input::SitOrStand) {
request_stand = Some(u8::from(stand_state == 0));
}
// Movement stands us up, volunteered, as the server never does: translation, a keyboard turn
@@ -61,7 +61,7 @@ pub(super) fn update(
// event stands us too (`0x60e139`: `GetStandState` `0x60be50`, then `0x60be30(0)`), read off
// the latch [`super::wire_in::apply_server_moves`] armed this frame, as the mover runs later.
let knocked_out_of_it = player.knockback.is_some();
if (moving || turned || knocked_out_of_it || binds.fired(crate::bindings::cmd::JUMP))
if (moving || turned || knocked_out_of_it || binds.fired(crate::bindings::Input::Jump))
&& stand_state != 0
&& request_stand.is_none()
{
@@ -94,7 +94,7 @@ pub(super) fn update(
// The sheath toggle (Z) cycles the anim layer's committed sheath state through its one setter
// ([`crate::creature_anim::SheathRequest`]), which sends `CMSG_SETSHEATHED`; only it plays the
// ceremony (`bInstant = 0` in `ToggleSheath` `0x5eb480`). No body model yet drops the press.
if binds.fired(crate::bindings::cmd::TOGGLE_SHEATH) {
if binds.fired(crate::bindings::Input::ToggleSheath) {
if let Ok((e, _, _, _, Some(drv), store, engaged, _, _, wielded, _)) = body.single() {
// Of `ToggleSheath`'s 12 silent guards: dead, in combat, any nonzero stand state,
// mid-ceremony (clip 89/90) and mounted; its stunned and channeling guards are not
+1 -1
View File
@@ -31,7 +31,7 @@ pub(super) fn update(
body: &BodyQuery,
binds: &crate::bindings::BindingsState,
) {
if !binds.fired(crate::bindings::cmd::TOGGLE_RUN) {
if !binds.fired(crate::bindings::Input::ToggleRun) {
return;
}
let dead = body
+3 -5
View File
@@ -344,11 +344,9 @@ impl Plugin for TargetPlugin {
)
.chain(),
scan::auto_acquire_attacker,
// The TAB keys' cycler (`0x493f60`, mode 1); the `TargetNearest*` Lua calls
// run the same cycle as script calls.
scan::tab_target,
// The ATTACKTARGET binding and the melee probe's press, the player's Attack.
crate::ui_action::attack_target_binding,
// The melee probe's press, the player's Attack; the TAB keys and ATTACKTARGET
// run their bodies' `TargetNearestEnemy` and `AttackTarget` as script calls.
crate::ui_action::attack_nearest_probe,
flash::drive_flash,
// The last-enemy stamp before the ring's death-clear, so a hostile that dies
// selected is still remembered (the reference's `TargetLastEnemy` has no
-27
View File
@@ -583,33 +583,6 @@ pub(super) fn cycle(
}
}
/// The `TARGETNEARESTENEMY` and `TARGETPREVIOUSENEMY` bindings (TAB, SHIFT-TAB); the dispatch has
/// already applied the typing gate and the modifier match.
pub(super) fn tab_target(
binds: Res<crate::bindings::BindingsState>,
time: Res<Time>,
scan: TargetScan,
mut history: ResMut<TabHistory>,
mut selection: ResMut<Selection>,
mut seam: crate::creature_anim::AttackSeam,
engaged: Query<(), (With<Engaged>, With<SelfPlayer>)>,
) {
let reverse = binds.fired(crate::bindings::cmd::TARGET_PREVIOUS_ENEMY);
if !reverse && !binds.fired(crate::bindings::cmd::TARGET_NEAREST_ENEMY) {
return;
}
cycle(
ScanSide::Enemy,
reverse,
time.elapsed_secs_f64(),
&scan,
&mut history,
&mut selection,
&mut seam,
!engaged.is_empty(),
);
}
/// The target half of the attack validator `0x612df0` (`0x61305d`–`0x613198`), shared by the
/// player's Attack (`0x6131aa`) and the pet bar's (`0x4bd40d`): what it reads beyond the
/// selection, the cycler it runs as `TargetNearestEnemy` (`0x6130b5`, `0x493f60(0, 1)`), and
+4 -6
View File
@@ -55,15 +55,13 @@ pub(super) fn item_action_route(
}
}
/// The ATTACKTARGET binding (default T), whose stock body is `AttackTarget()`, and the melee
/// probe's press with nothing selected: both the player's Attack ([`AttackPress::attack_target`]).
pub(crate) fn attack_target_binding(
binds: Res<crate::bindings::BindingsState>,
/// The melee probe's press with nothing selected, the player's Attack
/// ([`AttackPress::attack_target`]), as the ATTACKTARGET binding's `AttackTarget()` is.
pub(crate) fn attack_nearest_probe(
mut probe: MessageReader<crate::target::AttackNearestRequest>,
mut press: AttackPress,
) {
let probed = probe.read().last().is_some() && press.selection.guid.is_none();
if binds.fired(crate::bindings::cmd::ATTACK_TARGET) || probed {
if probe.read().last().is_some() && press.selection.guid.is_none() {
press.attack_target();
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ use benilla_assets::{AssetSet, LockRecover, WorldAssets};
mod cast_fail;
mod drain;
// A `UseAction` press, applied in call order by `crate::script_calls`.
pub(crate) use drain::{attack_target_binding, use_action, ActionPress, AttackPress, UseOutcome};
pub(crate) use drain::{attack_nearest_probe, use_action, ActionPress, AttackPress, UseOutcome};
#[cfg(test)]
mod drain_tests;
pub(crate) mod drop_item;
+8 -8
View File
@@ -187,18 +187,18 @@ pub(crate) fn movement_clears_afk(
mut chat: ResMut<ChatLog>,
mut mirror: ResMut<AfkMirror>,
) {
use crate::bindings::cmd;
use crate::bindings::Input;
if !mirror.is_afk() || cvars.flag("autoClearAFK") == Some(false) {
return;
}
let moved = [
cmd::JUMP,
cmd::MOVE_FORWARD,
cmd::MOVE_BACKWARD,
cmd::STRAFE_LEFT,
cmd::STRAFE_RIGHT,
cmd::TURN_LEFT,
cmd::TURN_RIGHT,
Input::Jump,
Input::MoveForward,
Input::MoveBackward,
Input::StrafeLeft,
Input::StrafeRight,
Input::TurnLeft,
Input::TurnRight,
]
.iter()
.any(|&c| binds.just_pressed(c));
-68
View File
@@ -1,68 +0,0 @@
//! TOGGLEUI, the hide-the-interface binding (`ALT-Z`), and its state: while it is set the quad
//! pass draws nothing and the UI's pointer feed stops hit-testing.
use bevy::prelude::*;
use crate::char_select::{ClientState, InWorldGated};
use crate::ui_script::UiInput;
/// Whether the player UI is hidden: nothing in `ui_pass::UiQuads` is drawn and the UI takes no
/// mouse, while widgets keep ticking. The keyboard stays live, as in the reference. Open windows
/// stay open, where the stock binding runs `CloseAllWindows()` first (`Bindings.xml:655-661`).
#[derive(Resource, Default)]
pub(crate) struct UiHidden(pub bool);
pub(crate) struct UiHidePlugin;
impl Plugin for UiHidePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<UiHidden>()
.add_systems(
Update,
// After `UiInput`: an EditBox that took this frame's keys has published its
// capture flag by then.
toggle_ui_hidden.after(UiInput).in_set(InWorldGated),
)
.add_systems(OnExit(ClientState::InWorld), show_ui);
}
}
fn toggle_ui_hidden(binds: Res<crate::bindings::BindingsState>, mut hidden: ResMut<UiHidden>) {
if !binds.fired(crate::bindings::cmd::TOGGLE_UI) {
return;
}
hidden.0 = !hidden.0;
info!(
"ui: {} (TOGGLEUI)",
if hidden.0 { "HIDDEN" } else { "shown" }
);
}
/// Leaving the world clears the hide: the binding is in-world only, so a UI hidden at logout would
/// come back invisible with no way to see why.
fn show_ui(mut hidden: ResMut<UiHidden>) {
hidden.0 = false;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_fired_toggleui_flips_the_flag_both_ways() {
let mut app = App::new();
app.add_systems(Update, toggle_ui_hidden)
.init_resource::<UiHidden>()
.insert_resource(crate::bindings::BindingsState::test_fired(&[
crate::bindings::cmd::TOGGLE_UI,
]));
app.update();
assert!(app.world().resource::<UiHidden>().0, "fired → hidden");
app.update();
assert!(!app.world().resource::<UiHidden>().0, "fired again → shown");
app.world_mut()
.insert_resource(crate::bindings::BindingsState::default());
app.update();
assert!(!app.world().resource::<UiHidden>().0, "no fire → no change");
}
}
+7 -88
View File
@@ -734,12 +734,8 @@ fn rebuild_ui_mesh(
mut pools: Local<BatchPools>,
white: Option<Res<UiWhiteTexture>>,
windows: Query<&Window, With<PrimaryWindow>>,
// The hide binding and the cost meter's pair, bundled under clippy's argument ceiling.
mut hide_and_meter: (
Res<crate::ui_hide::UiHidden>,
ResMut<UiMeshCost>,
Res<crate::ui_script::UiCostWanted>,
),
mut mesh_cost: ResMut<UiMeshCost>,
cost_wanted: Res<crate::ui_script::UiCostWanted>,
) {
// Forget the materials of removed images first, before any early return, since a missed read
// leaks: a cached material holds a strong image handle and, prepared, its GPU texture.
@@ -756,11 +752,9 @@ fn rebuild_ui_mesh(
if !retired.is_empty() {
pools.materials.retain(|key, _| !retired.contains(&key.0));
}
let (hidden, mesh_cost, cost_wanted) =
(&hide_and_meter.0, &mut hide_and_meter.1, &hide_and_meter.2);
// The meter is off unless asked (the hover recorder, `WOW_UI_COST=1`): one bool test.
let cost_on = cost_wanted.0 || crate::ui_script::extract::ui_cost_enabled();
**mesh_cost = UiMeshCost::default();
*mesh_cost = UiMeshCost::default();
let t_rebuild = cost_on.then(std::time::Instant::now);
let mut t_mark = t_rebuild;
let mut lap = move || -> u128 {
@@ -773,22 +767,7 @@ fn rebuild_ui_mesh(
};
let (meshes, materials) = (&mut stores.0, &mut stores.1);
let q = quads.as_mut();
// TOGGLEUI hides at the draw, not the producers: both lanes keep filling, so the UI returns as
// it was ([`crate::ui_hide::UiHidden`]). The world is in neither lane (it is this camera's
// first draw), so retiring every batch leaves the world. While dark, the change flag is still
// swallowed and the append-lane mirror kept current, so neither lane reports a stale
// "unchanged" on return. The edge is `UiHidden`'s own change tick.
if hidden.is_changed() {
q.dirty = true;
}
let lanes_hidden = hidden.0;
if lanes_hidden {
q.last_overlays.clone_from(&q.overlays);
// Nothing moves a pixel while dark, so only the toggle edge rebuilds.
if !q.dirty {
return;
}
} else if !q.dirty && q.overlays == q.last_overlays {
if !q.dirty && q.overlays == q.last_overlays {
return;
}
// `WOW_UI_DIFF=1`: names what re-triggered the rebuild, the base lane's dirty flag or the
@@ -832,7 +811,7 @@ fn rebuild_ui_mesh(
retire_batches(&mut pools, &mut commands);
return;
};
let lanes_empty = lanes_hidden || (q.quads.is_empty() && q.overlays.is_empty());
let lanes_empty = q.quads.is_empty() && q.overlays.is_empty();
if lanes_empty {
retire_batches(&mut pools, &mut commands);
q.dirty = false;
@@ -846,11 +825,7 @@ fn rebuild_ui_mesh(
let to_world = move |p: Vec2| Vec2::new(p.x - half_w, half_h - p.y);
// Stable, so equal keys keep producer order, the base lane before the append lane.
let mut sorted: Vec<&UiQuad> = if lanes_hidden {
Vec::new()
} else {
q.quads.iter().chain(q.overlays.iter()).collect()
};
let mut sorted: Vec<&UiQuad> = q.quads.iter().chain(q.overlays.iter()).collect();
sorted.sort_by_key(|q| q.z_key);
let n_sorted = sorted.len();
let us_sort = lap();
@@ -1158,7 +1133,7 @@ fn rebuild_ui_mesh(
pools.offsets.truncate(used);
if cost_on {
let us_write = lap();
**mesh_cost = UiMeshCost {
*mesh_cost = UiMeshCost {
rebuilt: true,
total: t_rebuild.map_or(0, |t| t.elapsed().as_micros()),
sort: us_sort,
@@ -1186,7 +1161,6 @@ mod tests {
.init_resource::<UiQuads>()
.init_resource::<UiMeshCost>()
.init_resource::<crate::ui_script::UiCostWanted>()
.init_resource::<crate::ui_hide::UiHidden>()
.insert_resource(UiWhiteTexture(Handle::default()))
.add_systems(Update, rebuild_ui_mesh);
app.world_mut().spawn((Window::default(), PrimaryWindow));
@@ -1225,35 +1199,6 @@ mod tests {
.count()
}
fn set_hidden(app: &mut App, hidden: bool) {
app.world_mut().resource_mut::<crate::ui_hide::UiHidden>().0 = hidden;
}
/// Un-hiding brings back the same content, though neither lane changed while dark and the
/// rebuild would otherwise take its early-out.
#[test]
fn toggleui_retires_the_batches_and_restores_them() {
let mut app = rebuild_app();
app.update();
assert_eq!(batches(&mut app), 1, "one batch drawn to begin with");
set_hidden(&mut app, true);
app.update();
assert_eq!(batches(&mut app), 0, "hidden ⇒ nothing drawn");
// A flagged frame while dark still takes the dark path.
app.world_mut().resource_mut::<UiQuads>().dirty = true;
app.update();
assert_eq!(batches(&mut app), 0, "hidden stays hidden");
set_hidden(&mut app, false);
app.update();
assert_eq!(
batches(&mut app),
1,
"the UI comes back on the same content"
);
}
/// The cache's strong handle would otherwise pin the image and its GPU texture for good.
#[test]
fn a_removed_image_does_not_keep_its_material_alive() {
@@ -1320,32 +1265,6 @@ mod tests {
.any(|(_, m)| m.texture.as_ref().is_some_and(|t| t.id() == id))
}
/// The world is the UI camera's first draw (`benilla_world::ffx_glow::FfxBackdrop`), not a
/// batch, so dark leaves the world and nothing else; a batch alive while dark is a bug.
#[test]
fn toggleui_retires_every_batch_and_the_world_is_none_of_them() {
let mut app = rebuild_app();
app.update();
let lit = batches(&mut app);
assert!(lit >= 1, "content drawn to begin with");
set_hidden(&mut app, true);
app.update();
assert_eq!(
batches(&mut app),
0,
"dark ⇒ nothing drawn: the world is this camera's own pass, not a batch"
);
set_hidden(&mut app, false);
app.update();
assert_eq!(
batches(&mut app),
lit,
"the UI comes back over the same world"
);
}
/// The greyscale is a per-material uniform: merged, the first quad would decide both (a talent
/// tree greying in blocks).
#[test]
@@ -1362,7 +1362,7 @@ fn the_bar_bag_buttons_name_themselves_on_hover() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
// `GetBindingKey` reads the registered command set, seeded as the app seeds it.
s.register_bindings(&crate::bindings::registry_commands());
crate::ui_script::load_stock_bindings(&mut s);
s.set_screen_size(1024.0, 768.0);
for file in crate::ui_script::test_ui::production_order(&[
BAG_UI,
@@ -13,6 +13,8 @@ fn ui_with_the_cinematic_frame() -> UiScript {
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
// `TakeScreenshot` (`WorldFrame.lua:46`), which the SCREENSHOT binding's body calls.
load_xml(&s, r"Interface\FrameXML\WorldFrame.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua"); // StaticPopup's money row
@@ -107,9 +109,7 @@ fn the_screenshot_key_is_handed_back_to_its_binding() {
benilla_formats::wow_data_or_skip!();
let mut s = ui_with_the_cinematic_frame();
// `GetBindingKey("SCREENSHOT")` must answer, or the handler's screenshot arm is skipped.
s.register_bindings(&crate::bindings::registry_commands());
s.seed_binding_set(1, None);
s.load_binding_set(1);
crate::ui_script::load_stock_bindings(&mut s);
let key = s
.keybind_snapshot()
.into_iter()
@@ -118,23 +118,22 @@ fn the_screenshot_key_is_handed_back_to_its_binding() {
.expect("SCREENSHOT ships a default chord");
start_cinematic(&mut s);
let _ = s.take_keybind_requests();
let _ = s.take_screenshot_asks();
assert!(
s.frame_key_input(&key),
"the frame consumes {key} like any other key"
);
assert_eq!(
s.take_keybind_requests(),
vec![benilla_ui::script::keybind::KeybindRequest::Run(
"SCREENSHOT".into()
)],
"the screenshot key must reach its binding through RunBinding"
s.take_screenshot_asks(),
1,
"the screenshot key must reach its binding's body through RunBinding"
);
// ESCAPE still skips, and queues nothing on the binding channel.
// ESCAPE still skips, and asks for no screenshot.
assert!(s.frame_key_input("ESCAPE"));
assert!(s.take_keybind_requests().is_empty());
assert_eq!(s.take_screenshot_asks(), 0);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `ShowUIPanel`'s `area = "full"` row (`UIParent.lua:31`) routes to `SetFullScreenFrame`, which
+2 -5
View File
@@ -24,8 +24,6 @@ pub(super) struct PointerFeed<'w> {
hovered_object: Res<'w, crate::target::HoveredObject>,
occlusion: Res<'w, crate::target::PickOcclusion>,
payload_held: ResMut<'w, CursorPayloadHeld>,
/// TOGGLEUI ([`crate::ui_hide::UiHidden`]): a hidden UI takes no mouse at all.
hidden: Res<'w, crate::ui_hide::UiHidden>,
/// A headless probe drives the pointer ([`super::SyntheticPointer`]), not the real cursor.
synthetic: Res<'w, super::SyntheticPointer>,
/// A capture owns the pointer ([`super::CapturePointerPinned`]): no OS cursor in the shot.
@@ -99,7 +97,6 @@ pub(super) fn feed_ui_input(
) {
let (keyboard, keys, capture, clipboard) = (&mut kbd.0, &kbd.1, &mut kbd.2, &mut kbd.3);
let world_pick = pointer.world_pick();
let ui_hidden = pointer.hidden.0;
// The OS pointer is not ours while a probe drives a gesture through the real pointer path or a
// capture pins it: skip the mouse half whole, else-arm included, whose `pointer_left_window`
// would disarm the probe's gesture between its press and release.
@@ -132,13 +129,13 @@ pub(super) fn feed_ui_input(
let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight);
let alt = keys.pressed(KeyCode::AltLeft) || keys.pressed(KeyCode::AltRight);
script.set_modifiers(shift, ctrl, alt);
// ── Mouse ── A cursor off the window, or a UI hidden by TOGGLEUI, skips only the mouse feed.
// ── Mouse ── A cursor off the window skips only the mouse feed.
// The headless hover probe's aim stands in for a missing cursor, so `PointerOverUi` rises and
// falls over a panel in an automated run as it does for a person; a real pointer always wins.
if let Some(cursor) = window
.cursor_position()
.or_else(crate::target::hover_probe_point)
.filter(|_| !ui_hidden && !synthetic)
.filter(|_| !synthetic)
{
// The window cursor is logical px, y-down from the top left; the UI is y-up in 768-high
// units under uiScale: flip through the window height, then undo the extract seam's scale.
@@ -2,16 +2,13 @@
//! over the engine's binding table. Deviation: it is the era Settings panel's page, because 1.12's
//! settings screens are much worse to use; capture and set follow 1.12's `Blizzard_BindingUI`.
use benilla_ui::script::keybind::{KeybindCommand, KeybindRequest};
use benilla_ui::script::keybind::KeybindRequest;
use benilla_ui::script::{QuadContent, UiScript};
use crate::bindings::commands::SPECS;
/// The page's files in the production order, the registry seeded first, as `seed_bindings_for_vm`
/// does.
/// The page's files in the production order over the stock commands and the install's defaults.
pub(crate) fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
register_specs(&mut s);
crate::ui_script::load_stock_bindings(&mut s);
s.set_screen_size(1024.0, 768.0);
for file in [
"Interface\\FrameXML\\GlobalStrings.lua",
@@ -44,19 +41,11 @@ pub(crate) fn harness() -> UiScript {
s
}
/// The host's command registry, as `seed_bindings_for_vm` registers it.
pub(crate) fn register_specs(s: &mut UiScript) {
let cmds: Vec<KeybindCommand> = SPECS
.iter()
.map(|spec| KeybindCommand {
name: spec.name,
category: spec.category,
run_on_up: spec.run_on_up(),
default1: spec.d1,
default2: spec.d2,
})
.collect();
s.register_bindings(&cmds);
/// The install's defaults live, as a first login's account set is, on a VM whose load registered
/// the stock commands.
pub(crate) fn seed_defaults(s: &mut UiScript) {
s.set_default_bindings(crate::ui_script::default_bindings());
s.load_binding_set(1);
}
/// A label as the page resolves it: the GlobalStrings global named `token`, else `raw`.
@@ -146,11 +135,16 @@ fn the_page_is_an_options_category_with_the_collapsed_honest_tree() {
.unwrap());
// The categories in `Bindings.xml` order, each a header collapsed by default, as in the era;
// the spacer headers over multibars 2-4 continue the section above (the page's deviation).
let mut expected: Vec<&str> = Vec::new();
for spec in SPECS {
if !expected.contains(&spec.category) && !spec.category.starts_with("BINDING_HEADER_BLANK")
{
expected.push(spec.category);
let stock = benilla_ui::bindings_xml::parse(
&crate::ui_script::stock_bindings_file().expect("the install's Bindings.xml"),
)
.unwrap();
let mut expected: Vec<String> = Vec::new();
for b in &stock {
let Some(h) = &b.header else { continue };
let token = format!("BINDING_HEADER_{h}");
if b.platform.is_none() && !token.starts_with("BINDING_HEADER_BLANK") {
expected.push(token);
}
}
for (i, token) in expected.iter().enumerate() {
@@ -164,7 +158,9 @@ fn the_page_is_an_options_category_with_the_collapsed_honest_tree() {
.unwrap(),
"all sections collapsed: nothing past the headers"
);
assert!(expected.contains(&"BINDING_HEADER_MULTIACTIONBAR"));
assert!(expected
.iter()
.any(|h| h == "BINDING_HEADER_MULTIACTIONBAR"));
s.run(&format!("{ROW}1Header:Click()")).unwrap();
assert_eq!(
s.eval::<String>(&format!("return {ROW}2Description:GetText()"))
@@ -385,7 +381,7 @@ fn the_page_works_after_the_stock_binding_ui_loads_and_the_stock_window_reads_th
let (mut s, failures) =
super::layer_tests::production_load_with("bindingui", false, "", |_| {});
assert!(failures.is_empty(), "load failures: {failures:#?}");
register_specs(&mut s);
seed_defaults(&mut s);
s.run(r#"assert(LoadAddOn("Blizzard_BindingUI") == 1)"#)
.unwrap();
assert!(s
@@ -885,29 +881,114 @@ fn the_pet_lane_is_registered_under_the_action_bar_header() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `loadstring` on each command body: running one would need FrameXML this test does not load.
/// `loadstring` on each stock command body: the reference compiles each at load (`0x704c70`),
/// and running one would need FrameXML this test does not load.
#[test]
fn every_command_body_compiles() {
use crate::bindings::commands::Kind;
benilla_formats::wow_data_or_skip!();
let stock = benilla_ui::bindings_xml::parse(
&crate::ui_script::stock_bindings_file().expect("the install's Bindings.xml"),
)
.unwrap();
let s = UiScript::new().unwrap();
for spec in SPECS {
let bodies: Vec<&str> = match &spec.kind {
Kind::Held | Kind::Host => Vec::new(),
Kind::Edge(body) => vec![*body],
Kind::EdgeUpDown(down, up) => vec![*down, *up],
};
for body in bodies {
s.run(&format!(
"local f, err = loadstring({body:?}); if not f then BenillaBodyError = {name:?} .. \": \" .. err end",
body = body,
name = spec.name
))
.unwrap_or_else(|e| panic!("{}: {e}", spec.name));
}
for b in &stock {
s.run(&format!(
"local f, err = loadstring({body:?}); \
if not f then BenillaBodyError = {name:?} .. \": \" .. err end",
body = b.body,
name = b.name
))
.unwrap_or_else(|e| panic!("{}: {e}", b.name));
}
let bad: Vec<String> = s.errors();
assert!(bad.is_empty(), "script errors: {bad:?}");
s.run("if BenillaBodyError then error(BenillaBodyError) end")
.unwrap_or_else(|e| panic!("a command body is not valid Lua: {e}"));
}
/// Every function and object a stock command body names exists once the in-game UI has loaded:
/// the engine's behaviour sits behind the Lua globals the bodies call. Read off each body: a
/// `Name(` call is a function, an `Object:` or `Object.` receiver a frame or table.
#[test]
fn every_stock_body_calls_what_the_load_defines() {
benilla_formats::wow_data_or_skip!();
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (s, failures) = super::layer_tests::production_load_with("bindcalls", false, "", |_| {});
assert!(failures.is_empty(), "load failures: {failures:#?}");
let stock = benilla_ui::bindings_xml::parse(
&crate::ui_script::stock_bindings_file().expect("the install's Bindings.xml"),
)
.unwrap();
const KEYWORDS: [&str; 14] = [
"if", "then", "else", "elseif", "end", "not", "and", "or", "local", "function", "return",
"do", "while", "for",
];
let mut missing = Vec::new();
for b in stock.iter().filter(|b| b.platform.is_none()) {
let body = b.body.as_bytes();
let mut i = 0;
while i < body.len() {
let c = body[i];
if !(c.is_ascii_alphabetic() || c == b'_') {
i += 1;
continue;
}
let start = i;
while i < body.len() && (body[i].is_ascii_alphanumeric() || body[i] == b'_') {
i += 1;
}
// A field or method name is its receiver's, not a global.
if start > 0 && matches!(body[start - 1], b'.' | b':') {
continue;
}
let name = &b.body[start..i];
let next = b.body[i..].trim_start().chars().next();
let want = match next {
Some('(') if !KEYWORDS.contains(&name) => "function",
Some(':') | Some('.') => "table",
_ => continue,
};
let kind: String = s.eval(&format!("return type({name})")).unwrap();
if kind != want {
missing.push(format!("{}: {name} is {kind}", b.name));
}
}
}
assert!(missing.is_empty(), "unresolved: {missing:#?}");
}
/// ALT-Z runs the stock TOGGLEUI body (`Bindings.xml:655-661`): `CloseAllWindows()` and
/// `UIParent:Hide()`, then `UIParent:Show()`, so the interface under `UIParent` stops drawing and
/// an open window is closed, as in the reference.
#[test]
fn toggleui_hides_uiparent_and_closes_the_windows() {
benilla_formats::wow_data_or_skip!();
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (mut s, failures) = super::layer_tests::production_load_with("toggleui", false, "", |_| {});
assert!(failures.is_empty(), "load failures: {failures:#?}");
s.run("ShowUIPanel(GameMenuFrame)").unwrap();
assert!(s
.eval::<bool>("return GameMenuFrame:IsVisible() == 1")
.unwrap());
s.resolve();
let lit = s.extract().len();
assert!(s.execute_binding("TOGGLEUI", true).unwrap());
s.resolve();
assert!(s
.eval::<bool>("return UIParent:IsVisible() == nil")
.unwrap());
assert!(s
.eval::<bool>("return GameMenuFrame:IsShown() == nil")
.unwrap());
assert!(
s.extract().len() < lit,
"the frames under UIParent stop drawing"
);
assert!(s.execute_binding("TOGGLEUI", true).unwrap());
assert!(s.eval::<bool>("return UIParent:IsVisible() == 1").unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
+194 -9
View File
@@ -1,6 +1,7 @@
//! The in-game interface: the core, the player's own stock FrameXML as their chain's
//! `FrameXML.toc` lists it ([`reference_ui::core`]), then benilla's layer, [`LAYER_MANIFEST`],
//! every entry of which is ours and loads after the whole core.
//! `FrameXML.toc` lists it ([`reference_ui::core`]) and then its `Bindings.xml`
//! ([`load_core_bindings`]), then benilla's layer, [`LAYER_MANIFEST`], every entry of which is
//! ours and loads after the whole core.
use bevy::prelude::*;
@@ -158,11 +159,11 @@ pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
failures
}
/// The core: every row of the chain's `FrameXML.toc`, in its order, each file in document order.
/// A chain without the toc builds no stock interface: the runner reports `"Couldn't open %s"`
/// (`0x846ff4`) at severity 2 (`0x6edc3f`) and returns 0, which `UI_Init` never reads
/// (`0x48fff2`), going on to `Bindings.xml` and the addons. benilla goes on the same way and
/// reports it as a load failure, an `ERROR` line and a row in `/errors`.
/// The core: every row of the chain's `FrameXML.toc`, in its order, each file in document order,
/// then the chain's `Bindings.xml`. A chain without the toc builds no stock interface: the runner
/// reports `"Couldn't open %s"` (`0x846ff4`) at severity 2 (`0x6edc3f`) and returns 0, which
/// `UI_Init` never reads (`0x48fff2`), going on to `Bindings.xml` and the addons. benilla goes on
/// the same way and reports it as a load failure, an `ERROR` line and a row in `/errors`.
pub(super) fn load_core(script: &UiScript) -> Vec<String> {
let Some(core) = reference_ui::core() else {
let e = format!(
@@ -178,17 +179,88 @@ pub(super) fn load_core(script: &UiScript) -> Vec<String> {
benilla_ui::status::missing(reference_ui::TOC, false),
);
script.report_load_status(log);
return vec![e];
let mut failures = vec![e];
failures.extend(load_core_bindings(script));
return failures;
};
let mut toc = benilla_ui::status::Status::default();
let failures = core.load_files_into(script, &core.toc.files, &mut toc);
let mut failures = core.load_files_into(script, &core.toc.files, &mut toc);
let mut log = benilla_ui::status::Status::default();
let banner = benilla_ui::status::toc_banner(reference_ui::TOC);
toc.close_into(&mut log, script.framexml_debug(), banner);
script.report_load_status(log);
failures.extend(load_core_bindings(script));
failures
}
/// The stock key-binding commands, `UI_Init`'s next step after the toc walk: the chain's
/// `Interface\FrameXML\Bindings.xml` (`0x842f9c`), when the chain has it (`0x48fff9`), through
/// the loader addons' files go through (`0x4b6f70`, called at `0x490018` here and `0x51f443` for
/// an addon). Every command benilla has is a row of this file, its body the file's own Lua.
pub(super) fn load_core_bindings(script: &UiScript) -> Vec<String> {
let Some(bytes) = reference_ui::read(reference_ui::BINDINGS) else {
warn!(
"ui_script: {} is not in the patch chain — no key binding has a command",
reference_ui::BINDINGS
);
return Vec::new();
};
match benilla_ui::bindings_xml::parse(&benilla_ui::source::decode(&bytes)) {
Ok(bindings) => {
script.register_bindings(&bindings);
info!(
"bindings: {} commands registered from {}",
bindings.len(),
reference_ui::BINDINGS
);
Vec::new()
}
// `"Couldn't parse XML in %s"` (`0x846fd8`) at severity 2 (`0x4b700c`).
Err(e) => {
let e = format!("{}: {e}", reference_ui::BINDINGS);
error!("ui_script: {e}");
script.report_load_failure(&e);
vec![e]
}
}
}
/// The defaults, set 0, off the player's chain: `WTF\DefaultBindings.wtf` (`0x846e44`), which
/// `0x4b62b0` reads line by line (`0x4b6140`); empty when the chain lacks it.
pub(crate) fn default_bindings() -> Vec<benilla_ui::script::keybind::KeyBinding> {
match reference_ui::read(reference_ui::DEFAULT_BINDINGS) {
Some(bytes) => {
benilla_ui::script::keybind::parse_bindings_wtf(&String::from_utf8_lossy(&bytes))
}
None => {
warn!(
"ui_script: {} is not in the patch chain — every command ships unbound",
reference_ui::DEFAULT_BINDINGS
);
Vec::new()
}
}
}
/// The chain's `Bindings.xml` text, for the tests that hold benilla against it.
#[cfg(test)]
pub(crate) fn stock_bindings_file() -> Option<String> {
reference_ui::read(reference_ui::BINDINGS)
.map(|bytes| benilla_ui::source::decode(&bytes).into_owned())
}
/// The stock bindings on a VM a test built by hand: the defaults loaded live, as a first login's
/// account set is, and the core's commands.
#[cfg(test)]
pub(crate) fn load_stock_bindings(script: &mut UiScript) {
script.set_default_bindings(default_bindings());
script.load_binding_set(1);
assert!(
load_core_bindings(script).is_empty(),
"the stock Bindings.xml loads"
);
}
/// Every [`LAYER_MANIFEST`] entry, in order, after the whole core: the layer loads as more of the
/// stock load, with no `ADDON_LOADED` and no registry row, as FrameXML does (`0x48ffed`).
fn load_layer(script: &UiScript) -> Vec<String> {
@@ -434,4 +506,117 @@ mod tests {
);
}
}
/// A patched chain's `Bindings.xml`, rows reordered, a body edited, and the loader's skips.
const PATCHED_BINDINGS: &[u8] = br#"<Bindings>
<Binding name="TOGGLEBACKPACK" header="INTERFACE">Probe("bag")</Binding>
<Binding name="JUMP" header="MOVEMENT">Probe("edited jump")</Binding>
<Binding name="TOGGLESTATS" hidden="true" debug="true">Probe("debug")</Binding>
<Binding name="EMPTYROW"></Binding>
<Bindings><Binding name="NESTEDROW">Probe("nested")</Binding></Bindings>
<Binding name="ITUNES_PLAYPAUSE" header="ITUNES_REMOTE" platform="mac">Probe("mac")</Binding>
</Bindings>"#;
/// The core's commands are the chain's `Bindings.xml`, loaded after the toc's files (which
/// see none) as `UI_Init` does (`0x48ffed`, then `0x490018`): its order, its headers and its
/// bodies are what registers and runs, the defaults are the chain's `DefaultBindings.wtf`, and
/// a debug, an empty, a nested and a Mac row are no commands.
#[test]
fn the_cores_commands_are_the_chain_bindings_xml() {
const TOC_LUA: &[u8] =
b"TOC_SAW = GetNumBindings() function Probe(n) PROBED = (PROBED or '') .. n end";
let _chain = fixture::lay(&[
(reference_ui::TOC, Some(b"a.lua\r\n")),
(r"Interface\FrameXML\a.lua", Some(TOC_LUA)),
(reference_ui::BINDINGS, Some(PATCHED_BINDINGS)),
(
reference_ui::DEFAULT_BINDINGS,
Some(b"bind J JUMP\r\nbind B TOGGLEBACKPACK\r\nbind CTRL-Y TOGGLESTATS\r\n"),
),
]);
let mut s = UiScript::new().unwrap();
s.set_default_bindings(default_bindings());
s.load_binding_set(1);
let failures = load_core(&s);
assert!(failures.is_empty(), "{failures:#?}");
assert_eq!(s.eval::<i64>("return TOC_SAW").unwrap(), 0);
let rows: Vec<Vec<String>> = s
.eval(
"local out = {} \
for i = 1, GetNumBindings() do out[i] = { GetBinding(i) } end \
return out",
)
.unwrap();
let row = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
assert_eq!(
rows,
[
row(&["HEADER_INTERFACE"]),
row(&["TOGGLEBACKPACK", "B"]),
row(&["HEADER_MOVEMENT"]),
row(&["JUMP", "J"]),
]
);
assert!(s.execute_binding("JUMP", true).unwrap());
assert_eq!(s.eval::<String>("return PROBED").unwrap(), "edited jump");
for skipped in ["TOGGLESTATS", "EMPTYROW", "NESTEDROW", "ITUNES_PLAYPAUSE"] {
assert!(
!s.execute_binding(skipped, true).unwrap(),
"{skipped} is no command"
);
}
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("CTRL-Y")"#)
.unwrap(),
"TOGGLESTATS",
"a default key on a command the file never declared is still the key table's"
);
}
/// A chain without `Bindings.xml` loads no command and fails nothing (`0x48fff9` skips it).
#[test]
fn a_chain_without_bindings_xml_declares_no_command() {
let _chain = fixture::lay(&[
(reference_ui::TOC, Some(b"")),
(reference_ui::BINDINGS, None),
]);
let s = UiScript::new().unwrap();
assert!(load_core(&s).is_empty());
assert_eq!(s.eval::<i64>("return GetNumBindings()").unwrap(), 0);
}
/// The install's own file read as the loader reads it: 219 commands once the nine debug rows
/// are skipped, the Mac rows among them for the Mac build alone; the six `MOVEVIEW*` rows sit
/// in an XML comment.
#[test]
fn the_installs_bindings_xml_reads_as_the_loader_reads_it() {
benilla_formats::wow_data_or_skip!();
let binds = benilla_ui::bindings_xml::parse(&stock_bindings_file().unwrap()).unwrap();
assert_eq!(binds.len(), 219);
assert_eq!(binds.iter().filter(|b| b.run_on_up).count(), 94);
assert_eq!(binds.iter().filter(|b| b.header.is_some()).count(), 13);
assert_eq!(binds.iter().filter(|b| b.hidden).count(), 3);
assert_eq!(binds.iter().filter(|b| b.platform.is_some()).count(), 5);
let names: Vec<&str> = binds.iter().map(|b| b.name.as_str()).collect();
for gone in ["TOGGLESTATS", "RESETPERFORMANCEVALUES", "MOVEVIEWIN"] {
assert!(!names.contains(&gone), "{gone}");
}
for kept in [
"MOVEANDSTEER",
"PITCHUP",
"TURNORACTION",
"TOGGLEUI",
"RAIDTARGETNONE",
] {
assert!(names.contains(&kept), "{kept}");
}
assert_eq!(binds[0].header.as_deref(), Some("MOVEMENT"));
// Registered on this client, the PC build: every row but the five Mac ones, so the list
// walks 211 visible commands and 12 headers, `ITUNES_REMOTE` riding on a Mac row.
let mut s = UiScript::new().unwrap();
load_stock_bindings(&mut s);
assert_eq!(s.eval::<i64>("return GetNumBindings()").unwrap(), 223);
assert!(!s.execute_binding("ITUNES_PLAYPAUSE", true).unwrap());
}
}
@@ -216,7 +216,7 @@ fn every_micro_button_hovers_with_its_reference_explanation() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.run("SHOW_NEWBIE_TIPS = \"1\"").unwrap();
s.register_bindings(&crate::bindings::registry_commands());
crate::ui_script::load_stock_bindings(&mut s);
assert_eq!(
s.eval::<Option<i64>>("return SetBinding(\"C\", \"TOGGLECHARACTER0\")")
.unwrap(),
+3
View File
@@ -33,8 +33,11 @@ pub(crate) mod gate;
pub(crate) use session::VmMemo;
// Not test-only: the addon harness loads the whole shipped interface under each addon.
pub(crate) use manifest::default_bindings;
pub(crate) use manifest::load_default_ui;
pub(crate) use manifest::load_ingame_ui;
#[cfg(test)]
pub(crate) use manifest::{load_stock_bindings, stock_bindings_file};
/// Whether the pointer is over any UI (the egui dev overlay or a player-UI frame), combined by
/// [`arbitrate_pointer_over_ui`]; gameplay reads it, so it is not the dev plugin's.
@@ -1204,7 +1204,7 @@ fn an_extra_bars_empty_well_keeps_its_bound_hotkey_label() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The real command set, so `MULTIACTIONBAR1BUTTONn` is bindable; it ships unbound.
s.register_bindings(&crate::bindings::registry_commands());
crate::ui_script::load_stock_bindings(&mut s);
load_action_bar(&s, &[]);
show_bars(&s, &[1]);
s.set_action(
@@ -590,7 +590,7 @@ fn hovered_pet_bar() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The app's registries: `BONUSACTIONBUTTON1` binds CTRL-1 and `UberTooltips` reads "1".
s.register_bindings(&crate::bindings::registry_commands());
crate::ui_script::load_stock_bindings(&mut s);
s.register_cvars(crate::cvars::registered_pairs());
load_pet_bar(&s);
declare_token_strings(&s);
@@ -31,6 +31,12 @@ pub(super) fn is_chain_entry(entry: &str) -> bool {
/// runner (`0x6edb90`) at `0x48ffed`, once per UI build, `ReloadUI` included.
pub(super) const TOC: &str = r"Interface\FrameXML\FrameXML.toc";
/// The stock key-binding commands, which `UI_Init` loads after the [`TOC`] walk (`0x490018`).
pub(super) const BINDINGS: &str = r"Interface\FrameXML\Bindings.xml";
/// The default key set, which `UI_Init` reads through `0x4b62b0` (`0x4900c7`).
pub(super) const DEFAULT_BINDINGS: &str = r"WTF\DefaultBindings.wtf";
/// The core interface: the chain's own [`TOC`], patched or not, as an [`Addon`] over the chain, or
/// `None` when the chain has no such file.
pub(super) fn core() -> Option<Addon> {
@@ -176,6 +176,8 @@ mod tests {
("cursor.rs", "decode_failed"),
("cursor.rs", "last_set"),
("cursor.rs", "last_ptr"),
// The wheel's fraction toward its next notch, a fact about the device, not the VM.
("bindings.rs", "notches"),
];
use crate::test_support::{fn_parameter_lists, rust_files};
@@ -1322,8 +1322,8 @@ MapProbeZones = table.getn({ GetMapZones(1) })
let _ = std::fs::remove_dir_all(&tmp);
}
/// Stock `ActionButton_OnLoad` paints its hotkey from `GetBindingKey` at load, and an addon that
/// rebinds a stock command at file scope needs the command to exist.
/// Stock `ActionButton_OnLoad` paints its hotkey from `GetBindingKey` at load, so the key table,
/// the install's defaults, is seeded before any file runs; an addon's rebind takes at file scope.
#[test]
fn an_addon_reads_the_keybinding_table_at_file_scope() {
const TOC: &str = "\
@@ -1346,13 +1346,14 @@ BindProbeSet = SetBinding(\"J\", \"TOGGLEWORLDMAP\")
let script = world
.get_non_send_resource::<benilla_ui::script::UiScript>()
.expect("VM");
// The defaults are the install's `WTF\DefaultBindings.wtf`: none without one.
assert_eq!(
script
.eval::<Option<String>>("BindProbeKey")
.ok()
.flatten()
.as_deref(),
Some("M"),
benilla_formats::wow_data().map(|_| "M"),
"an addon's file scope must read the stock binding — `M` is TOGGLEWORLDMAP's own default"
);
assert_eq!(
+50 -18
View File
@@ -784,16 +784,28 @@ mod tests {
);
}
/// The V, Shift-V and Ctrl-V bodies: each flips the verb and FrameXML's global together, so
/// the saved variable carries what the key did, and none writes a CVar.
/// The V, Shift-V and Ctrl-V commands run the stock bodies (`Bindings.xml:516-550`): V and
/// Shift-V each show their own kind and hide the other, and turn their own kind off only
/// when it is the one showing; Ctrl-V shows both unless both show. Each flips the verb and
/// FrameXML's global together, so the saved variable carries what the key did, and none
/// writes a CVar.
#[test]
fn the_v_keys_move_the_bits_and_the_saved_globals_together() {
use crate::bindings::commands::{Kind, SPECS};
let body = |name: &str| match SPECS.iter().find(|s| s.name == name).map(|s| &s.kind) {
Some(Kind::Edge(lua)) => *lua,
_ => panic!("{name} runs a Lua body"),
};
fn the_v_keys_run_the_stock_bodies() {
benilla_formats::wow_data_or_skip!();
let mut app = verb_app();
crate::ui_script::load_stock_bindings(
&mut app
.world_mut()
.non_send_resource_mut::<benilla_ui::script::UiScript>(),
);
let key = |app: &mut App, command: &str| {
assert!(app
.world()
.non_send_resource::<benilla_ui::script::UiScript>()
.execute_binding(command, true)
.unwrap());
app.update();
};
let state = |app: &App| {
(
mode(app),
@@ -802,27 +814,47 @@ mod tests {
)
};
run(&mut app, body("NAMEPLATES"));
assert_eq!(state(&app), ((true, false), Some(1), None), "V turns on");
run(&mut app, body("FRIENDNAMEPLATES"));
key(&mut app, "NAMEPLATES");
assert_eq!(state(&app), ((true, false), Some(1), None), "V: enemies on");
key(&mut app, "FRIENDNAMEPLATES");
assert_eq!(
state(&app),
((true, true), Some(1), Some(1)),
"Shift-V moves only the other kind"
((false, true), None, Some(1)),
"Shift-V: friends on, enemies off"
);
run(&mut app, body("NAMEPLATES"));
assert_eq!(state(&app), ((false, true), None, Some(1)), "V turns off");
run(&mut app, body("ALLNAMEPLATES"));
key(&mut app, "NAMEPLATES");
assert_eq!(
state(&app),
((true, false), Some(1), None),
"V with friends showing: enemies on, friends off"
);
key(&mut app, "NAMEPLATES");
assert_eq!(state(&app), ((false, false), None, None), "V again: off");
key(&mut app, "ALLNAMEPLATES");
assert_eq!(
state(&app),
((true, true), Some(1), Some(1)),
"Ctrl-V: both on"
);
run(&mut app, body("ALLNAMEPLATES"));
key(&mut app, "NAMEPLATES");
assert_eq!(
state(&app),
((true, false), Some(1), None),
"V with both on turns friends off"
);
key(&mut app, "ALLNAMEPLATES");
key(&mut app, "FRIENDNAMEPLATES");
assert_eq!(
state(&app),
((false, true), None, Some(1)),
"Shift-V with both on turns enemies off"
);
key(&mut app, "ALLNAMEPLATES");
key(&mut app, "ALLNAMEPLATES");
assert_eq!(
state(&app),
((false, false), None, None),
"...then both off"
"Ctrl-V with both on: both off"
);
assert!(
app.world_mut()
+2 -2
View File
@@ -47,8 +47,8 @@ const VERDICTS: &[(&str, Verdict)] = &[
(
"KeyboardFocusLost",
Open(
"the window deactivate, not player input: `bindings.rs` drops a Lua-held movement \
command on it, the reference's `0x514490`, which a load must not hide",
"the window deactivate, not player input: `bindings.rs` clears the held direction \
bits on it, the reference's `0x514490`, which a load must not hide",
),
),
(
+57 -24
View File
@@ -1,21 +1,24 @@
//! An addon's `Bindings.xml`, its key-binding declarations. The reference loads one per addon in
//! `AddOn_Load 0x51f240`, after the addon's `.toc` files (`0x51f3fa`) and before its saved
//! variables (`0x51f400` to `0x51f4b5`). The stock file has 228 live bindings (94 `runOnUp`, 13
//! `header`, 12 `hidden`, 9 `debug`, 5 `platform`); the six `MOVEVIEW*` ones sit in an XML comment.
//! A `Bindings.xml`, its key-binding declarations, as the one loader both uses (`0x4b6f70`) reads
//! it: the core's `Interface\FrameXML\Bindings.xml` off the player's chain, which `UI_Init`
//! (`0x48fbf0`) loads at `0x490018` after the `FrameXML.toc` walk (`0x48ffed`), and each addon's,
//! which `AddOn_Load` (`0x51f240`) loads at `0x51f443` after the addon's `.toc` files and before
//! its saved variables. The stock file has 219 commands (94 `runOnUp`, 13 `header`, 3 `hidden`, 5
//! `platform`) once its nine `debug` rows are skipped; the six `MOVEVIEW*` ones sit in an XML
//! comment.
//!
//! A body is one Lua chunk; with `runOnUp="true"` it runs on press and again on release, the
//! global `keystate` set to `"down"` or `"up"`. `header="X"` opens the section
//! `BINDING_HEADER_X` that following header-less bindings join, in a flat list with `HEADER`
//! pseudo-entries (`Blizzard_BindingUI.lua:87`). `hidden="true"` keeps a binding bindable but out
//! of the Key Bindings window. `platform="mac"` marks the `ITUNES_REMOTE` rows; the reference
//! skips another platform's row at load (`0x4b70c3`–`0x4b70e5`), benilla at registration.
//! of the Key Bindings window. `platform="mac"` marks the `ITUNES_REMOTE` rows; the loader skips
//! another platform's row (`0x4b70c3`-`0x4b70e5`), here at registration.
use std::fmt;
/// One `<Binding>` element as the file states it; the section carry-forward and the
/// `BINDING_HEADER_` prefix are applied by [`crate::script::UiScript::register_addon_bindings`].
/// `BINDING_HEADER_` prefix are applied at registration ([`crate::script::UiScript::register_bindings`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AddonBinding {
pub struct Binding {
/// The command name `SetBinding` and `GetBindingKey` speak in.
pub name: String,
/// The raw `header` suffix (`MOVEMENT` of `BINDING_HEADER_MOVEMENT`), opening a section.
@@ -53,11 +56,13 @@ impl std::error::Error for Error {
}
}
/// Parse `Bindings.xml` text into its bindings, in document order: every `<Binding>` at any
/// depth, the tag matched case-insensitively (`0x64a4c0`), skipping one with no `name`. The
/// reference reads only the root's children (`0x4b701f`) and also skips a row with an empty body
/// (`0x4b7093`–`0x4b709e`) or `debug="true"` (`0x4b70a4`–`0x4b70bd`), which this keeps.
pub fn parse(text: &str) -> Result<Vec<AddonBinding>, Error> {
/// Parse `Bindings.xml` text into its bindings, in document order, with the loader's own skips.
/// It walks the root's children only (`0x4b701f`, then the sibling link at `0x4b745b`), so a
/// nested `<Binding>` is never read; a child that is not a `<Binding>` (the tag matched
/// case-insensitively, `0x64a4c0`) is skipped (`0x4b704c`). A row with no `name`
/// (`0x4b7084`-`0x4b708d`), an empty body (`0x4b7093`-`0x4b709e`) or `debug="true"`
/// (`0x4b70a4`-`0x4b70bd`) is skipped too, so the stock file's nine debug rows are not commands.
pub fn parse(text: &str) -> Result<Vec<Binding>, Error> {
// The reference's expat has no namespace processing, so an undeclared prefix is bound and the
// text re-read rather than costing the file, as in `framexml::parse`.
let repaired;
@@ -70,14 +75,18 @@ pub fn parse(text: &str) -> Result<Vec<AddonBinding>, Error> {
Err(e) => return Err(Error::Xml(e)),
};
let mut out = Vec::new();
for node in doc.root_element().descendants() {
for node in doc.root_element().children() {
if !node.is_element() || !node.tag_name().name().eq_ignore_ascii_case("Binding") {
continue;
}
let Some(name) = attr_ci(node, "name").filter(|n| !n.is_empty()) else {
continue;
};
out.push(AddonBinding {
let body = direct_text(node);
if body.is_empty() || attr_bool(node, "debug") {
continue;
}
out.push(Binding {
name,
header: attr_ci(node, "header").filter(|h| !h.is_empty()),
run_on_up: attr_bool(node, "runOnUp"),
@@ -85,7 +94,7 @@ pub fn parse(text: &str) -> Result<Vec<AddonBinding>, Error> {
platform: attr_ci(node, "platform")
.filter(|p| !p.is_empty())
.map(|p| p.to_ascii_lowercase()),
body: direct_text(node),
body,
});
}
Ok(out)
@@ -132,8 +141,8 @@ mod tests {
<Binding name="JUMP">
Jump();
</Binding>
<Binding name="TOGGLESTATS" hidden="true" debug="true">
ToggleStats();
<Binding name="PROBEHIDDEN" hidden="true">
Probe();
</Binding>
<Binding name="PROBECOMPARE" RUNONUP="TRUE">
if ( a &lt; b ) then Probe(); end
@@ -196,21 +205,45 @@ mod tests {
}
/// Registered under `""`, a nameless binding would shadow `GetBindingAction`'s empty answer
/// for an unbound key.
/// for an unbound key; the loader skips it (`0x4b7084`-`0x4b708d`).
#[test]
fn nameless_bindings_are_skipped_and_nesting_is_tolerated() {
fn nameless_bindings_are_skipped() {
let binds = parse(
r#"<Ui>
<Binding>Orphan();</Binding>
<Binding name="">AlsoOrphan();</Binding>
<Bindings>
<Binding name="PROBEWRAPPED">Probe();</Binding>
</Bindings>
<Binding name="PROBENAMED">Probe();</Binding>
</Ui>"#,
)
.expect("well-formed");
assert_eq!(binds.len(), 1);
assert_eq!(binds[0].name, "PROBEWRAPPED");
assert_eq!(binds[0].name, "PROBENAMED");
}
/// The loader's three skips beyond a missing name: a `<Binding>` below the root's children is
/// never walked (`0x4b701f`, the sibling link `0x4b745b`), an empty body is refused
/// (`0x4b7093`-`0x4b709e`), and a truthy `debug` skips the row (`0x4b70a4`-`0x4b70bd`), as
/// the stock file's nine debug rows are skipped. A whitespace body is not empty.
#[test]
fn nested_empty_and_debug_rows_are_not_bindings() {
let binds = parse(
r#"<Bindings>
<Binding name="PROBEKEPT">Probe();</Binding>
<Bindings>
<Binding name="PROBENESTED">Probe();</Binding>
</Bindings>
<Frame><Binding name="PROBEINFRAME">Probe();</Binding></Frame>
<Binding name="PROBEEMPTY"></Binding>
<Binding name="PROBESELFCLOSED"/>
<Binding name="PROBEDEBUG" hidden="true" debug="true">ToggleStats();</Binding>
<Binding name="PROBEDEBUGCASE" DEBUG="TRUE">Probe();</Binding>
<Binding name="PROBENOTDEBUG" debug="1">Probe();</Binding>
<Binding name="PROBEBLANK"> </Binding>
</Bindings>"#,
)
.expect("well-formed");
let names: Vec<&str> = binds.iter().map(|b| b.name.as_str()).collect();
assert_eq!(names, ["PROBEKEPT", "PROBENOTDEBUG", "PROBEBLANK"]);
}
#[test]
+35 -13
View File
@@ -11,7 +11,7 @@
//! `CBindings::ExecuteBinding 0x4b7990` on the stack (`[[0xb71290]+0xd8]`, raised at `0x4b7b02`);
//! tainted code (a macro, `RunScript`, an addon's own) is refused. So a key's body moves and a
//! `/script MoveForwardStart()` does not. benilla has no taint model, so here the gate is "a
//! binding body is running" ([`UiScript::run_binding`]), which refuses the same callers, silently
//! binding body is running" ([`UiScript::execute_binding`]), which refuses the same callers, silently
//! where the reference fires `MACRO_ACTION_FORBIDDEN` (431) or `ADDON_ACTION_FORBIDDEN` (432).
//! The camera table and the main-table verbs have no gate.
@@ -37,6 +37,10 @@ pub enum HeldInput {
StrafeLeft,
/// `0x80`, `StrafeRightStart`/`Stop` (`0x514000`/`0x514030`).
StrafeRight,
/// `0x1`, `TurnOrActionStart`/`Stop` (`0x514120`/`0x514160`), the right mouse button's bit.
TurnOrAction,
/// `0x2`, `CameraOrSelectOrMoveStart`/`Stop` (`0x514190`/`0x5141d0`), the left button's.
CameraOrSelectOrMove,
}
/// A one-shot command, fired as its key fires it.
@@ -95,6 +99,17 @@ impl UiScript {
result
}
/// `CBindings::ExecuteBinding` (`0x4b7990`) once the chord has resolved to `command`: its
/// `RunCommand` (`0x4b7b50`) with the hardware-event gate raised (`0x4b7b02`), a press when
/// `down`, else the release, which runs only a `runOnUp` body. `Ok(false)` when nothing ran:
/// no such command, or a release of one that is not `runOnUp`.
pub fn execute_binding(&self, command: &str, down: bool) -> mlua::Result<bool> {
self.model_mut().input.binding_depth += 1;
let result = super::keybind::run_command(&self.lua, command, down);
self.model_mut().input.binding_depth -= 1;
result
}
/// Push whether the TurnOrAction channel is held: the world holds the right mouse button, or
/// a `MouselookStart` holds it with none.
pub fn set_turn_or_action_held(&mut self, held: bool) {
@@ -150,9 +165,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// ── The movement table `0x8500b8` ──────────────────────────────────────────────────────
// Each Start/Stop is `0x494a50(0)`, then `0x515090(bit, set, time, 0)`; no argument is read,
// nothing is returned.
let held: [(&str, &str, HeldInput); 6] = [
// Each Start/Stop is `0x494a50(0)`, then `0x515090(bit, set, time, 0)`; nothing is returned,
// and no argument is read but `CameraOrSelectOrMoveStop`'s sticky flag (`0x5141f6`), which
// nothing here acts on.
let held: [(&str, &str, HeldInput); 8] = [
(
"MoveForwardStart",
"MoveForwardStop",
@@ -171,6 +187,19 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"StrafeRightStop",
HeldInput::StrafeRight,
),
// The mouse pair also arms the world click (`0x514810`); that, and each bit alone, the
// look session reads off the physical buttons. The two held together are the both-button
// run, which MOVEANDSTEER's body reaches through them.
(
"TurnOrActionStart",
"TurnOrActionStop",
HeldInput::TurnOrAction,
),
(
"CameraOrSelectOrMoveStart",
"CameraOrSelectOrMoveStop",
HeldInput::CameraOrSelectOrMove,
),
];
for (start, stop, input) in held {
g.set(
@@ -234,11 +263,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
}
// Not built, so these answer and do nothing:
// - the pitch pair (`0x400`/`0x800`): the mover has no keyboard pitch axis (`ABSENT` PITCHUP);
// - TurnOrAction and CameraOrSelectOrMove (`0x514120`-`0x5141d0`), which arm the world click
// (`0x514810`) and set the mouse bits: the look session reads the physical buttons for
// these (`ABSENT` TURNORACTION, CAMERAORSELECTORMOVE).
// Not built, so these answer and do nothing: the pitch pair (`0x400`/`0x800`), since the
// mover has no keyboard pitch axis, so PITCHUP and PITCHDOWN (INSERT, DELETE) do nothing.
inert(
lua,
&[
@@ -246,10 +272,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"PitchUpStop",
"PitchDownStart",
"PitchDownStop",
"TurnOrActionStart",
"TurnOrActionStop",
"CameraOrSelectOrMoveStart",
"CameraOrSelectOrMoveStop",
],
)?;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -102,7 +102,7 @@
## Modules (top-level, per crate)
- **benilla-adt** (lib.rs): combined_alpha
- **benilla-app** (lib.rs): addon_harness area area_poi area_trigger asset_churn aura_visual bindings blob_shadow bowstring camera_shake capture char_create char_select chat_bubble chr_classes cinematic combat_log combat_text console crash creature_anim cursor cvars death debug_panel dev doodad_events entities fishing_line footprints game_plugins game_tip glue glue_strings go_anim go_templates hover_log items loading_screen local_state login minimap name_persist nameplates names net npc_text opaque2d pending_item_ops perf pipe_warm player poi_marker portrait preflight probe_shield query_cache quest_markers raid_marks ranged_flex realm_select realmlist run_mode screen_fade screenshot script_calls shaders shutdown smart_rect sound spell swing_refusal target test_support text_filter text_reshape textinput transport tutorial ui_action ui_auction ui_aura ui_bank ui_battlefield ui_battlefield_positions ui_battlefield_score ui_bind_confirm ui_binder ui_cast ui_char ui_chat ui_craft ui_dialog_verbs ui_dressup ui_duel ui_follow ui_gamma ui_gm_ticket ui_gossip ui_guild ui_hide ui_honor ui_inspect ui_instance ui_item_text ui_items ui_layout ui_logout ui_loot ui_loot_roll ui_macro ui_mail ui_merchant ui_mirror ui_models ui_net ui_party ui_pass ui_pet ui_pet_book ui_pet_doll ui_pet_stats ui_petition ui_quest ui_quest_log ui_quest_share ui_reputation ui_saved ui_script ui_session ui_shapeshift ui_social ui_spellbook ui_stable ui_summon ui_tabard ui_talent ui_talent_wipe ui_taxi ui_text ui_tooltip ui_trade ui_tradeskill ui_trainer ui_unit ui_world_map video vplates weapon_trail world_backdrop world_state world_state_ui
- **benilla-app** (lib.rs): addon_harness area area_poi area_trigger asset_churn aura_visual bindings blob_shadow bowstring camera_shake capture char_create char_select chat_bubble chr_classes cinematic combat_log combat_text console crash creature_anim cursor cvars death debug_panel dev doodad_events entities fishing_line footprints game_plugins game_tip glue glue_strings go_anim go_templates hover_log items loading_screen local_state login minimap name_persist nameplates names net npc_text opaque2d pending_item_ops perf pipe_warm player poi_marker portrait preflight probe_shield query_cache quest_markers raid_marks ranged_flex realm_select realmlist run_mode screen_fade screenshot script_calls shaders shutdown smart_rect sound spell swing_refusal target test_support text_filter text_reshape textinput transport tutorial ui_action ui_auction ui_aura ui_bank ui_battlefield ui_battlefield_positions ui_battlefield_score ui_bind_confirm ui_binder ui_cast ui_char ui_chat ui_craft ui_dialog_verbs ui_dressup ui_duel ui_follow ui_gamma ui_gm_ticket ui_gossip ui_guild ui_honor ui_inspect ui_instance ui_item_text ui_items ui_layout ui_logout ui_loot ui_loot_roll ui_macro ui_mail ui_merchant ui_mirror ui_models ui_net ui_party ui_pass ui_pet ui_pet_book ui_pet_doll ui_pet_stats ui_petition ui_quest ui_quest_log ui_quest_share ui_reputation ui_saved ui_script ui_session ui_shapeshift ui_social ui_spellbook ui_stable ui_summon ui_tabard ui_talent ui_talent_wipe ui_taxi ui_text ui_tooltip ui_trade ui_tradeskill ui_trainer ui_unit ui_world_map video vplates weapon_trail world_backdrop world_state world_state_ui
- **benilla-assets** (lib.rs): adt anim_rng blp column_grid coords gpu_blp m2 materials minimap_grid model spatial_cache terrain tex_filter trace wdt wmo world_assets
- **benilla-formats** (lib.rs): anim_data area_poi area_sound area_table area_trigger auction_house bank_bag_slot_prices camera_shakes cfg_categories chain characters chat_channels chr_classes cinematics creature_families creature_sound creature_types creatures dbc death_thud durability elevators emit_timing emote_text emotes environmental_damage exhaustion factions footsteps game_tips gameobjects garble gm_ticket_category ground_effects install item_random_properties item_sounds item_visuals itembagfamily itemclass items itemsets itemsubclass languages light liquid loading_screen lock lock_type macro_icons maps material minimap_translate models npc_greeting packages page_text_material particles pet_stats quest_headers quest_info race_pvp_team race_sound ribbons server_messages sheathe skill_lines sound_entries sound_provider sound_water spell_focus spell_mechanic spell_visual spells stable_slot_prices stationery talents taxi taxi_nodes taxi_path terrain text_filter_lists tga transport_period transports unit_blood value_track vocal_ui_sounds wdl weapon_impact weapon_swing wmo_area world_map_area world_map_continent world_map_overlay world_state_ui wow_ini zone_map
- **benilla-m2** (lib.rs): error model skin tests track
+4 -4
View File
@@ -137,14 +137,14 @@ for marker in "new VM, session"; do
fail "'$marker' happened $n time(s), expected $vms — one per VM built, two per login; \
$sessions would mean the entry adopted the character screen's VM and inherited its spent VmMemos"
done
# The in-game UI, `FrameXML.toc`'s `Fonts.xml` included, and the keybinding table
# (`seed_bindings_for_vm`, run on the world-entry edge) build once per login; one count for two
# logins means the second reused the first's frame tree.
# The in-game UI, `FrameXML.toc`'s `Fonts.xml` included, and the key-binding commands (the core's
# `Bindings.xml`, loaded after the toc) build once per login; one count for two logins means the
# second reused the first's frame tree.
for marker in "Fonts.xml loaded" "UIParent.xml loaded" "commands registered"; do
n="$(printf '%s\n' "$plain" | grep -cF "$marker")"
printf ' %-24s %s\n' "$marker" "$n"
[ "$n" -eq "$sessions" ] ||
fail "'$marker' happened $n time(s), expected $sessions — once per login: the UI rebuilt per login and the keybinding table seeded on the entry edge"
fail "'$marker' happened $n time(s), expected $sessions — once per login: the UI and its binding commands rebuilt per login"
done
# The shutdown tail runs once per session: at the /logout, and at the exit, which closes the window