Files
benilla-octo/crates/benilla-ui/src/script/input_verbs.rs
T
Sam SungandClaude Opus 5.5 34b7c30af2 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>
2026-09-29 10:51:21 +07:00

558 lines
22 KiB
Rust

//! The functions the stock `Bindings.xml` bodies call, from the reference's three registrar
//! tables: the movement table `0x8500b8` (loop `0x514349`), the camera table `0x84f7a0` (loop
//! `0x50c089`) and the targeting and posture verbs of the main table `0x83de68` (loop
//! `0x49027c`). A key and its Lua function share one path: a held command's Start and Stop are the
//! key's press and release ([`HeldInput`]), a one-shot command fires as its key does
//! ([`FiredInput`]), and the targeting and attack calls go through the call queue
//! ([`ScriptCall`]), so a macro's `TargetNearestEnemy()` then `/cast` casts at the new target.
//!
//! Every movement function but the mouselook three opens on the hardware-event gate `0x494a50`
//! with tag 0. Untainted, it passes only inside a real input event, which for a binding body is
//! `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::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.
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::{bool_or_default, flag};
use super::calls::{NearestMode, ScriptCall};
use super::{Model, UiScript};
/// A held movement command: one bit of the reference's `[InputControl+4]` word, set by its Start
/// and cleared by its Stop through `0x515090`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HeldInput {
/// `0x10`, `MoveForwardStart`/`Stop` (`0x513e20`/`0x513e50`).
MoveForward,
/// `0x20`, `MoveBackwardStart`/`Stop` (`0x513e80`/`0x513eb0`).
MoveBackward,
/// `0x100`, `TurnLeftStart`/`Stop` (`0x513ee0`/`0x513f10`).
TurnLeft,
/// `0x200`, `TurnRightStart`/`Stop` (`0x513f40`/`0x513f70`).
TurnRight,
/// `0x40`, `StrafeLeftStart`/`Stop` (`0x513fa0`/`0x513fd0`).
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.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum FiredInput {
/// `Jump()` (`0x513bd0`).
Jump,
/// `ToggleRun()` (`0x513d50` → `0x60e080`), the walk toggle.
ToggleRun,
/// `ToggleAutoRun()` (`0x513de0`), which toggles `0x1000`.
ToggleAutoRun,
/// `SitOrStand()` (`0x48b920`): sit when standing, else stand (`0x5ed430(1)` or `(0)`).
SitOrStand,
/// `ToggleSheath()` (`0x48a070` → `0x5eb480`).
ToggleSheath,
/// `CameraZoomIn([yards])` (`0x50b400` → `0x50fc60`), 1.0 when absent.
CameraZoomIn(f32),
/// `CameraZoomOut([yards])` (`0x50b450` → `0x50fcd0`), 1.0 when absent.
CameraZoomOut(f32),
}
/// One call for the app's binding state, in call order.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum BindingInput {
Start(HeldInput),
Stop(HeldInput),
Fire(FiredInput),
}
/// The VM's half of the binding functions.
#[derive(Default)]
pub(crate) struct InputState {
/// The calls made since the app last drained them.
queue: Vec<BindingInput>,
/// How many binding bodies are running: the hardware-event gate's `[[0xb71290]+0xd8]`.
binding_depth: u32,
/// `[InputControl+4] & 1`, the TurnOrAction channel `IsMouselooking` tests (`0x514278`), as
/// the app pushed it or a `MouselookStart`/`Stop` since moved it.
turn_or_action: bool,
/// The `MouselookStart` (`true`) and `MouselookStop` calls since the app last drained them.
mouselook: Vec<bool>,
}
impl UiScript {
/// Take the binding calls queued since the last call, in call order.
pub fn take_binding_input(&mut self) -> Vec<BindingInput> {
std::mem::take(&mut self.model_mut().input.queue)
}
/// Run a binding body as `CBindings::RunCommand` (`0x4b7b50`) does from inside
/// `ExecuteBinding`: the movement functions' hardware-event gate passes while it runs.
pub fn run_binding(&self, chunk: &str) -> mlua::Result<()> {
self.model_mut().input.binding_depth += 1;
let result = self.run(chunk);
self.model_mut().input.binding_depth -= 1;
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) {
self.model_mut().input.turn_or_action = held;
}
/// Take the `MouselookStart` (`true`) and `MouselookStop` calls made since the last call, in
/// call order, for the app's look session.
pub fn take_mouselook_calls(&mut self) -> Vec<bool> {
std::mem::take(&mut self.model_mut().input.mouselook)
}
}
/// The hardware-event gate `0x494a50(0)`: inside a binding body.
fn in_binding(lua: &Lua) -> bool {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.input.binding_depth > 0
}
fn push(lua: &Lua, input: BindingInput) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.input.queue.push(input);
}
fn push_call(lua: &Lua, call: ScriptCall) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.script_calls.push(call);
}
/// A zoom's optional amount: `0x6f34d0` (is-number, a numeric string passes) then `0x6f3620`,
/// else the stored `1.0f` (`0x50b42d`). A NaN or infinite amount is dropped: what `0x50fc60`'s
/// tick conversion makes of it is not established.
fn zoom_amount(lua: &Lua, v: Option<Value>) -> Option<f32> {
let n = match v {
Some(v) => lua.coerce_number(v).ok().flatten().unwrap_or(1.0),
None => 1.0,
} as f32;
n.is_finite().then_some(n)
}
/// Register a function that takes any arguments and does nothing: the mechanism behind it is not
/// built, so the call is answered with the reference's zero results and no effect.
fn inert(lua: &Lua, names: &[&str]) -> mlua::Result<()> {
let g = lua.globals();
for name in names {
g.set(*name, lua.create_function(|_, _: MultiValue| Ok(()))?)?;
}
Ok(())
}
/// Register the 49 functions: the three tables' 48 and `TargetNearestFriend`.
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)`; 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",
HeldInput::MoveForward,
),
(
"MoveBackwardStart",
"MoveBackwardStop",
HeldInput::MoveBackward,
),
("TurnLeftStart", "TurnLeftStop", HeldInput::TurnLeft),
("TurnRightStart", "TurnRightStop", HeldInput::TurnRight),
("StrafeLeftStart", "StrafeLeftStop", HeldInput::StrafeLeft),
(
"StrafeRightStart",
"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(
start,
lua.create_function(move |lua, _: MultiValue| {
if in_binding(lua) {
push(lua, BindingInput::Start(input));
}
Ok(())
})?,
)?;
g.set(
stop,
lua.create_function(move |lua, _: MultiValue| {
if in_binding(lua) {
push(lua, BindingInput::Stop(input));
}
Ok(())
})?,
)?;
}
// `Jump` (`0x513bd0`), `ToggleRun` (`0x513d50`) and `ToggleAutoRun` (`0x513de0`) open on the
// same gate; their further refusals (a dead or rooted player) are the engine's, as for the key.
let fired: [(&str, FiredInput); 3] = [
("Jump", FiredInput::Jump),
("ToggleRun", FiredInput::ToggleRun),
("ToggleAutoRun", FiredInput::ToggleAutoRun),
];
for (name, input) in fired {
g.set(
name,
lua.create_function(move |lua, _: MultiValue| {
if in_binding(lua) {
push(lua, BindingInput::Fire(input));
}
Ok(())
})?,
)?;
}
// `IsMouselooking()` (`0x514270`): `[InputControl+4] & 1`, the TurnOrAction channel, not the
// camera's freelook bit; 1 or nil (`0x514280`/`0x514293`).
g.set(
"IsMouselooking",
lua.create_function(|lua, _: MultiValue| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(flag(model.input.turn_or_action))
})?,
)?;
// `MouselookStart`/`MouselookStop` (`0x514210`/`0x514240`) pass no gate: each disarms the
// pending world click (`0x514810(0)`) and sets or clears the TurnOrAction channel
// (`0x515090(1, set, now, 0)`), the bit the held right button drives. The bit moves at the
// call, so `IsMouselooking` answers it at once; the app's look session applies the call.
for (name, on) in [("MouselookStart", true), ("MouselookStop", false)] {
g.set(
name,
lua.create_function(move |lua, _: MultiValue| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.input.turn_or_action = on;
model.input.mouselook.push(on);
Ok(())
})?,
)?;
}
// 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,
&[
"PitchUpStart",
"PitchUpStop",
"PitchDownStart",
"PitchDownStop",
],
)?;
// ── The camera table `0x84f7a0` ────────────────────────────────────────────────────────
g.set(
"CameraZoomIn",
lua.create_function(|lua, v: Option<Value>| {
if let Some(yards) = zoom_amount(lua, v) {
push(lua, BindingInput::Fire(FiredInput::CameraZoomIn(yards)));
}
Ok(())
})?,
)?;
g.set(
"CameraZoomOut",
lua.create_function(|lua, v: Option<Value>| {
if let Some(yards) = zoom_amount(lua, v) {
push(lua, BindingInput::Fire(FiredInput::CameraZoomOut(yards)));
}
Ok(())
})?,
)?;
// Not built: the `MoveView*` pairs (`0x50b4a0`-`0x50b590`, `0x50fd40(direction, time, 0)` and
// `0x50fd90(direction, time)` for In, Out, Right, Left, Up, Down = 0..5), whose effect on the
// camera is not established; 1.12's own `MOVEVIEW*` bindings are commented out. And
// `ToggleMouseMove` (`0x50b5a0` → `0x50f910`), which toggles freelook with no button held.
inert(
lua,
&[
"MoveViewInStart",
"MoveViewInStop",
"MoveViewOutStart",
"MoveViewOutStop",
"MoveViewRightStart",
"MoveViewRightStop",
"MoveViewLeftStart",
"MoveViewLeftStop",
"MoveViewUpStart",
"MoveViewUpStop",
"MoveViewDownStart",
"MoveViewDownStop",
"ToggleMouseMove",
],
)?;
// ── The main table `0x83de68` ──────────────────────────────────────────────────────────
// The four `TargetNearest*` shims are byte-identical but for the mode: argument 1 through
// `0x6f1c10` with default 0 is the reverse flag, then `0x493f60(reverse, mode)`.
let nearest: [(&str, NearestMode); 4] = [
("TargetNearestEnemy", NearestMode::Enemy),
("TargetNearestFriend", NearestMode::Friend),
("TargetNearestPartyMember", NearestMode::PartyMember),
("TargetNearestRaidMember", NearestMode::RaidMember),
];
for (name, mode) in nearest {
g.set(
name,
lua.create_function(move |lua, reverse: Option<Value>| {
let reverse = bool_or_default(reverse.as_ref(), false);
push_call(lua, ScriptCall::TargetNearest { mode, reverse });
Ok(())
})?,
)?;
}
// `TargetLastTarget()` (`0x489b00`) and `AttackTarget()` (`0x489b50`) read no argument.
g.set(
"TargetLastTarget",
lua.create_function(|lua, _: MultiValue| {
push_call(lua, ScriptCall::TargetLastTarget);
Ok(())
})?,
)?;
g.set(
"AttackTarget",
lua.create_function(|lua, _: MultiValue| {
push_call(lua, ScriptCall::AttackTarget);
Ok(())
})?,
)?;
// `ToggleSheath()` and `SitOrStand()` resolve the active player and act on it, ungated.
let posture: [(&str, FiredInput); 2] = [
("ToggleSheath", FiredInput::ToggleSheath),
("SitOrStand", FiredInput::SitOrStand),
];
for (name, input) in posture {
g.set(
name,
lua.create_function(move |lua, _: MultiValue| {
push(lua, BindingInput::Fire(input));
Ok(())
})?,
)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// The three reference tables' 48 names (`0x8500b8`, `0x84f7a0`, and the main table's seven
/// at `0x83de68`), each a callable global that answers without raising.
const NAMES: [&str; 48] = [
"Jump",
"ToggleRun",
"ToggleAutoRun",
"MoveForwardStart",
"MoveForwardStop",
"MoveBackwardStart",
"MoveBackwardStop",
"TurnLeftStart",
"TurnLeftStop",
"TurnRightStart",
"TurnRightStop",
"StrafeLeftStart",
"StrafeLeftStop",
"StrafeRightStart",
"StrafeRightStop",
"PitchUpStart",
"PitchUpStop",
"PitchDownStart",
"PitchDownStop",
"TurnOrActionStart",
"TurnOrActionStop",
"CameraOrSelectOrMoveStart",
"CameraOrSelectOrMoveStop",
"MouselookStart",
"MouselookStop",
"IsMouselooking",
"CameraZoomIn",
"CameraZoomOut",
"MoveViewInStart",
"MoveViewInStop",
"MoveViewOutStart",
"MoveViewOutStop",
"MoveViewRightStart",
"MoveViewRightStop",
"MoveViewLeftStart",
"MoveViewLeftStop",
"MoveViewUpStart",
"MoveViewUpStop",
"MoveViewDownStart",
"MoveViewDownStop",
"ToggleMouseMove",
"TargetNearestEnemy",
"TargetNearestPartyMember",
"TargetNearestRaidMember",
"TargetLastTarget",
"AttackTarget",
"ToggleSheath",
"SitOrStand",
];
#[test]
fn every_binding_function_is_a_global_that_answers() {
let s = UiScript::new().unwrap();
for name in NAMES {
let kind: String = s.eval(&format!("return type({name})")).unwrap();
assert_eq!(kind, "function", "{name}");
s.run(&format!("{name}()"))
.unwrap_or_else(|e| panic!("{name}() raised: {e}"));
s.run_binding(&format!("{name}(1)"))
.unwrap_or_else(|e| panic!("{name}(1) in a binding raised: {e}"));
}
}
/// The gate `0x494a50(0)`: a movement call queues only from inside a binding body, while the
/// camera and posture verbs, which have no gate, queue from anywhere.
#[test]
fn the_movement_calls_act_only_inside_a_binding_body() {
let mut s = UiScript::new().unwrap();
s.run("MoveForwardStart() StrafeLeftStop() Jump() ToggleRun()")
.unwrap();
assert!(s.take_binding_input().is_empty());
s.run_binding("MoveForwardStart() StrafeLeftStop() Jump() ToggleRun()")
.unwrap();
assert_eq!(
s.take_binding_input(),
vec![
BindingInput::Start(HeldInput::MoveForward),
BindingInput::Stop(HeldInput::StrafeLeft),
BindingInput::Fire(FiredInput::Jump),
BindingInput::Fire(FiredInput::ToggleRun),
]
);
// The gate closes again after the body, and after a body that raised.
assert!(s.run_binding("error('boom')").is_err());
s.run("TurnLeftStart()").unwrap();
assert!(s.take_binding_input().is_empty());
s.run("SitOrStand() ToggleSheath() CameraZoomIn()").unwrap();
assert_eq!(
s.take_binding_input(),
vec![
BindingInput::Fire(FiredInput::SitOrStand),
BindingInput::Fire(FiredInput::ToggleSheath),
BindingInput::Fire(FiredInput::CameraZoomIn(1.0)),
]
);
}
/// The zoom's amount: a number or numeric string (`0x6f34d0`), else 1.0 (`0x50b42d`); a
/// non-finite amount is dropped.
#[test]
fn a_zoom_takes_its_yards_or_one() {
let mut s = UiScript::new().unwrap();
s.run(r#"CameraZoomIn(2.5) CameraZoomOut("4") CameraZoomIn("far") CameraZoomOut(0/0)"#)
.unwrap();
assert_eq!(
s.take_binding_input(),
vec![
BindingInput::Fire(FiredInput::CameraZoomIn(2.5)),
BindingInput::Fire(FiredInput::CameraZoomOut(4.0)),
BindingInput::Fire(FiredInput::CameraZoomIn(1.0)),
]
);
}
/// `IsMouselooking` answers the pushed TurnOrAction channel as 1 or nil.
#[test]
fn is_mouselooking_answers_one_or_nil() {
let mut s = UiScript::new().unwrap();
assert_eq!(s.arity("IsMouselooking()").unwrap(), 1);
assert!(s
.eval::<Option<i64>>("return IsMouselooking()")
.unwrap()
.is_none());
s.set_turn_or_action_held(true);
assert_eq!(
s.eval::<Option<i64>>("return IsMouselooking()").unwrap(),
Some(1)
);
}
/// `MouselookStart`/`Stop` pass no gate (`0x514210`/`0x514240`), queue for the look session in
/// call order, and move the channel at the call, so `IsMouselooking` answers them at once.
#[test]
fn mouselook_start_and_stop_move_the_channel_from_any_caller() {
let mut s = UiScript::new().unwrap();
assert_eq!(s.arity("MouselookStart()").unwrap(), 0);
s.run("MouselookStop()").unwrap();
s.take_mouselook_calls();
let looking = |s: &UiScript| s.eval::<Option<i64>>("return IsMouselooking()").unwrap();
s.run("MouselookStart()").unwrap();
assert_eq!(
looking(&s),
Some(1),
"an addon's own call, outside any binding"
);
s.run("MouselookStop() MouselookStart() MouselookStop()")
.unwrap();
assert_eq!(looking(&s), None);
assert_eq!(s.take_mouselook_calls(), vec![true, false, true, false]);
assert!(s.take_mouselook_calls().is_empty(), "drained");
assert!(
s.take_binding_input().is_empty(),
"not a movement-binding call"
);
// The app's push is the channel's truth from then on: a right release ended it.
s.run("MouselookStart()").unwrap();
s.set_turn_or_action_held(false);
assert_eq!(looking(&s), None);
}
/// `TargetLastTarget` and `AttackTarget` join the call queue, in call order with the rest.
#[test]
fn the_targeting_calls_join_the_call_queue_in_order() {
let mut s = UiScript::new().unwrap();
s.run(r#"TargetNearestEnemy(1) TargetLastTarget() AttackTarget() ClearTarget()"#)
.unwrap();
assert_eq!(
s.take_script_calls(),
vec![
ScriptCall::TargetNearest {
mode: NearestMode::Enemy,
reverse: true
},
ScriptCall::TargetLastTarget,
ScriptCall::AttackTarget,
ScriptCall::ClearTarget,
]
);
}
}