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:
co-authored by
Claude Opus 5.5
parent
c574e20031
commit
34b7c30af2
+522
-605
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(); \
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
(
|
||||
|
||||
@@ -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 < 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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user