The 48 movement, camera and targeting functions Bindings.xml calls are Lua globals on their keys' paths: Start/Stop hold the key's command inside a binding body, targeting and AttackTarget go through the call queue (#485)

The 48 functions the stock `Bindings.xml` bodies call are now Lua globals, registered from the reference's three tables (`0x8500b8`, `0x84f7a0`, `0x83de68`), so an addon, an addon's `Bindings.xml` body or a `/script` line that calls `TargetNearestEnemy`, `TargetLastTarget`, `AttackTarget` or the rest no longer raises. Each runs the code its native binding runs.

- **Targeting** goes through the ordered script-call queue, so `/script TargetNearestEnemy()` then `/cast` casts at the unit the cycle picked. `TargetNearestEnemy`, `TargetNearestFriend`, `TargetNearestPartyMember` and `TargetNearestRaidMember` share the Tab cycler (`0x493f60`, modes 1-4; party and raid modes are new: `0x493eed`, `0x493f15`), reading the reverse flag as `0x6f1c10` does. `TargetLastTarget` (`0x489b00`) swaps to the last-target pair `SetSelection` stamps, and deselects when it is empty. `AttackTarget` (`0x489b50`) shares the ATTACKTARGET key's body.
- **Movement:** 23 of the 26 movement-table functions open with the gate `0x494a50`, which lets a call through only while a binding runs; here that is a binding body running, stock or an addon's. Inside one, `MoveForwardStart`/`Stop` and the rest set and clear the same held state the key does (`0x515090`); a Start with no Stop keeps moving, and the window deactivate (`0x514490`) and the loading cover (`0x5144c0`) clear it. `Jump`, `ToggleRun`, `ToggleAutoRun`, `SitOrStand` (`0x48b920`) and `ToggleSheath` (`0x48a070`) fire their key's command. `IsMouselooking` (`0x514270`) answers 1 or nil.
- **Camera:** `CameraZoomIn`/`Out([yards])` (`0x50b400`, `0x50b450`) take a number or numeric string, 1.0 when absent, into the wheel's zoom.
- Not built, so they answer and do nothing: the pitch pair, `TurnOrAction*`, `CameraOrSelectOrMove*`, `MouselookStart`/`Stop`, the `MoveView*` pairs and `ToggleMouseMove`; each is named with its address where it is registered.

Fixes #334

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-28 09:09:45 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 7362a87f95
commit 06bc451bac
23 changed files with 1103 additions and 75 deletions
+157 -16
View File
@@ -35,6 +35,7 @@ use crate::ui_script::{PlayerUiHover, PointerOverUiPanel, UiKeyboardCapture};
pub(crate) mod chord;
pub(crate) mod commands;
mod script_input;
mod store;
use chord::{BindKey, Chord};
@@ -89,6 +90,9 @@ pub(crate) struct BindingsState {
/// Live latches, (base key, binding): a [`Kind::Held`], [`Kind::EdgeUpDown`] or `runOnUp`
/// addon press not yet released.
latched: Vec<(BindKey, Bound)>,
/// The held commands a Lua Start set and no Stop has cleared ([`script_input`]); no key backs
/// them, so no key's release ends them but their own command's.
script_held: Vec<Cmd>,
/// Commands whose first latch began this frame (the press edge).
just: Vec<Cmd>,
/// Host-edge commands fired this frame.
@@ -105,9 +109,10 @@ pub(crate) struct BindingsState {
}
impl BindingsState {
/// Is a registry command latched right now (the reference's held movement bit)?
/// Is a registry command latched right now (the reference's held movement bit), by a key or
/// by a Lua Start?
pub(crate) fn pressed(&self, c: Cmd) -> bool {
self.latched.iter().any(|&(_, l)| l == Bound::Spec(c))
self.latched.iter().any(|&(_, l)| l == Bound::Spec(c)) || self.script_held.contains(&c)
}
/// Did this command's latch begin this frame (the key-down edge)?
pub(crate) fn just_pressed(&self, c: Cmd) -> bool {
@@ -387,7 +392,7 @@ impl WheelNotches {
/// The dispatch pass (see the module doc). Runs after the UI key feed and before
/// `WorldStage::Input`, so a bound key acts this frame, once.
fn latch_and_dispatch(
script: Option<NonSendMut<UiScript>>,
mut script: Option<NonSendMut<UiScript>>,
mut keyboard: MessageReader<KeyboardInput>,
keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
@@ -399,6 +404,8 @@ fn latch_and_dispatch(
dispatch: Res<BindingDispatch>,
mut state: ResMut<BindingsState>,
mut same_vm: Local<crate::ui_script::VmMemo<bool>>,
mut focus_lost: MessageReader<bevy::input::keyboard::KeyboardFocusLost>,
cover: Option<Res<crate::loading_screen::LoadingScreen>>,
) {
state.just.clear();
state.fired.clear();
@@ -407,11 +414,24 @@ fn latch_and_dispatch(
// Deviation: latches die with the VM that made them, because a latch indexes that VM's
// dispatch table and releasing it against a new one would run the wrong addon's up-half. The
// reference keeps a held key running through `ReloadUI`; here it re-latches on its next press.
// A Lua Start dies with it too, as a key would.
if let Some(script) = script.as_ref() {
if same_vm.claim(script) {
state.latched.clear();
state.script_held.clear();
}
}
// The reference's two bulk clears reach a Lua Start as they reach a held key: the window
// deactivate (`0x514490`, direction bits only; autorun is `Player`'s) and the world enter
// behind the loading cover (`0x5144c0`). A key's latch meets them in the stuck-latch sweep.
if focus_lost.read().count() > 0 || cover.is_some_and(|c| c.covering()) {
state.script_held.clear();
}
// Lua's binding calls made since the last pass, before this frame's keys.
if let Some(s) = script.as_mut() {
let inputs = s.take_binding_input();
state.apply_script_input(inputs);
}
let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight);
@@ -422,11 +442,11 @@ fn latch_and_dispatch(
// affordances: a player build keeps the reference's `CTRL-SHIFT-P` fallback to `SHIFT-P`.
let dev_plane = ctrl && shift && !alt && crate::run_mode::dev_affordances();
let mut script = script;
let armed = script.as_ref().is_some_and(|s| s.bind_capture_armed());
// A binding body runs as `ExecuteBinding` runs it, so the movement functions' gate passes.
let run_lua = |script: &mut Option<NonSendMut<UiScript>>, lua: &str, tag: &str| {
if let Some(s) = script.as_mut() {
if let Err(e) = s.run(lua) {
if let Err(e) = s.run_binding(lua) {
warn!("bindings({tag}): {e}");
}
}
@@ -462,11 +482,12 @@ fn latch_and_dispatch(
captured = Some(chord::chord_string(alt, ctrl, shift, token));
}
if let Some(chord_str) = captured {
run_lua(
&mut script,
&format!("KeyBindings_OnHostKey(\"{chord_str}\")"),
"capture",
);
// The Keybindings page's own handler, not a binding body.
if let Some(s) = script.as_mut() {
if let Err(e) = s.run(&format!("KeyBindings_OnHostKey(\"{chord_str}\")")) {
warn!("bindings(capture): {e}");
}
}
}
// Releases still unlatch below so a key held across the arm cannot stick; nothing new
// latches or fires while armed.
@@ -643,6 +664,13 @@ fn latch_and_dispatch(
for k in stuck {
release(&mut state, &mut script, run_lua, &dispatch, k);
}
// The calls this pass's binding bodies made act this frame, as the reference's return from
// `0x515090` has already moved the bit.
if let Some(s) = script.as_mut() {
let inputs = s.take_binding_input();
state.apply_script_input(inputs);
}
}
#[cfg(test)]
@@ -744,11 +772,11 @@ fn release(
while i < state.latched.len() {
if state.latched[i].0 == key {
match state.latched.remove(i).1 {
Bound::Spec(cmd) => {
if let Kind::EdgeUpDown(_, up) = &SPECS[cmd.0 as usize].kind {
run_lua(script, up, SPECS[cmd.0 as usize].name);
}
}
Bound::Spec(cmd) => match &SPECS[cmd.0 as usize].kind {
Kind::EdgeUpDown(_, up) => run_lua(script, up, SPECS[cmd.0 as usize].name),
Kind::Held => state.release_script_held(cmd),
_ => {}
},
// The same body again, with `keystate = "up"`.
Bound::Addon(a) => {
if let Some(a) = dispatch.addons.get(a as usize) {
@@ -778,7 +806,7 @@ fn run_addon(script: &mut Option<NonSendMut<UiScript>>, bind: &AddonBindingBody,
warn!("bindings({}): setting keystate: {e}", bind.name);
return;
}
if let Err(e) = s.run(&bind.body) {
if let Err(e) = s.run_binding(&bind.body) {
warn!("bindings({}): {e}", bind.name);
}
if let Err(e) = s.lua().globals().set("keystate", prior) {
@@ -1235,6 +1263,119 @@ mod tests {
);
}
/// An addon's bindings that call the movement functions, in the reference's shapes: a
/// `runOnUp` pair like stock MOVEFORWARD, and a Start and a Stop alone.
const MOVE_BINDINGS: &str = r#"<Bindings>
<Binding name="PROBEFWD" runOnUp="true">
if ( keystate == "down" ) then MoveForwardStart(); else MoveForwardStop(); end
</Binding>
<Binding name="PROBEGO">MoveForwardStart()</Binding>
<Binding name="PROBESTOP">MoveForwardStop()</Binding>
</Bindings>"#;
/// `MoveForwardStart`/`Stop` set and clear the bit the W key does (`0x515090(0x10, …)`): a
/// binding body's pair moves while held, a Start with no Stop keeps moving, a Stop clears it
/// whoever set it, and outside a binding body the gate (`0x494a50`) refuses the call.
#[test]
fn the_movement_functions_drive_the_held_state_as_the_key_does() {
let mut script = UiScript::new().expect("VM");
script.register_bindings(&registry_commands());
script.register_addon_bindings(
"ProbeAddon",
&benilla_ui::bindings_xml::parse(MOVE_BINDINGS).expect("well-formed"),
);
script
.run(
r#"SetBinding("J", "PROBEFWD"); SetBinding("K", "PROBEGO");
SetBinding("L", "PROBESTOP")"#,
)
.expect("bind");
let mut app = vm_harness(script);
let tap = |app: &mut App, k: KeyCode| {
press_key(app, k);
app.update();
release_key(app, k);
app.update();
};
// The `runOnUp` pair: forward from the press frame, stopped on the release.
press_key(&mut app, KeyCode::KeyJ);
app.update();
assert!(state(&app).pressed(cmd::MOVE_FORWARD));
assert!(
state(&app).just_pressed(cmd::MOVE_FORWARD),
"the press edge"
);
app.update();
assert!(state(&app).pressed(cmd::MOVE_FORWARD), "held across frames");
release_key(&mut app, KeyCode::KeyJ);
app.update();
assert!(!state(&app).pressed(cmd::MOVE_FORWARD));
// A Start with no Stop keeps moving past its key's release; the Stop ends it.
tap(&mut app, KeyCode::KeyK);
app.update();
assert!(
state(&app).pressed(cmd::MOVE_FORWARD),
"no Stop, still moving"
);
tap(&mut app, KeyCode::KeyL);
assert!(!state(&app).pressed(cmd::MOVE_FORWARD));
// W's release runs MOVEFORWARD's Stop, which clears a Lua Start too.
tap(&mut app, KeyCode::KeyK);
tap(&mut app, KeyCode::KeyW);
assert!(!state(&app).pressed(cmd::MOVE_FORWARD));
// And a Stop clears the bit under a held key, which stays stopped until pressed again.
press_key(&mut app, KeyCode::KeyW);
app.update();
tap(&mut app, KeyCode::KeyL);
assert!(!state(&app).pressed(cmd::MOVE_FORWARD));
release_key(&mut app, KeyCode::KeyW);
app.update();
// Outside a binding body (a macro, `RunScript`, an addon's own code) nothing moves.
app.world()
.non_send_resource::<UiScript>()
.run("MoveForwardStart() Jump() ToggleAutoRun()")
.expect("the calls answer");
app.update();
assert!(!state(&app).pressed(cmd::MOVE_FORWARD));
assert!(!state(&app).fired(cmd::JUMP));
assert!(!state(&app).fired(cmd::TOGGLE_AUTORUN));
}
/// The ungated one-shots fire their commands as the keys do: `CameraZoomIn(yards)` carries its
/// amount to the zoom (1.0 when absent, `0x50b42d`), `SitOrStand` and `ToggleSheath` fire.
#[test]
fn the_one_shot_functions_fire_their_commands() {
let mut script = UiScript::new().expect("VM");
script.register_bindings(&registry_commands());
let mut app = vm_harness(script);
let run = |app: &mut App, lua: &str| {
app.world()
.non_send_resource::<UiScript>()
.run(lua)
.expect("the call answers");
app.update();
};
run(&mut app, "CameraZoomIn(3)");
assert!(state(&app).fired(cmd::CAMERA_ZOOM_IN));
assert_eq!(state(&app).amount(cmd::CAMERA_ZOOM_IN), 3.0);
run(&mut app, "CameraZoomOut()");
assert_eq!(state(&app).amount(cmd::CAMERA_ZOOM_OUT), 1.0);
assert_eq!(
state(&app).amount(cmd::CAMERA_ZOOM_IN),
0.0,
"one frame only"
);
run(&mut app, "SitOrStand()");
assert!(state(&app).fired(cmd::SIT_OR_STAND));
run(&mut app, "ToggleSheath()");
assert!(state(&app).fired(cmd::TOGGLE_SHEATH));
assert!(!state(&app).fired(cmd::SIT_OR_STAND));
}
#[test]
fn held_commands_latch_across_frames_and_release_per_base_key() {
let mut app = harness();
@@ -0,0 +1,110 @@
//! 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.
use benilla_ui::script::{BindingInput, FiredInput, HeldInput};
use super::commands::{cmd, Cmd};
use super::{BindingsState, Bound};
/// The held command a Start or Stop names.
fn held_cmd(input: HeldInput) -> Cmd {
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,
}
}
/// 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) {
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),
}
}
impl BindingsState {
/// Apply Lua's binding calls, in call order.
pub(super) fn apply_script_input(&mut self, inputs: Vec<BindingInput>) {
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.
BindingInput::Start(held) => {
let c = held_cmd(held);
if !self.pressed(c) {
self.just.push(c);
self.script_held.push(c);
}
}
// `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.
BindingInput::Stop(held) => {
let c = held_cmd(held);
self.script_held.retain(|&h| h != c);
self.latched.retain(|&(_, b)| b != Bound::Spec(c));
}
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));
}
}
}
}
/// 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);
}
}
}
+6
View File
@@ -655,6 +655,7 @@ pub(super) fn latch_world_mouse(
mut rig: ResMut<CameraControl>,
cameras: Query<&Camera, With<FlyCam>>,
window: Single<&Window, With<PrimaryWindow>>,
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
) {
let Ok(camera) = cameras.single() else {
return;
@@ -662,6 +663,11 @@ pub(super) fn latch_world_mouse(
let over_ui = pointer_over_ui.0;
let world_press = rig.look.is_some() || (cursor_in_viewport(&window, camera) && !over_ui);
rig.world_mouse.update(&buttons, world_press);
// `IsMouselooking` (`0x514270`) reads `[InputControl+4] & 1`, the TurnOrAction channel: the
// world's right button, the press the TURNORACTION binding latches.
if let Some(mut script) = script {
script.set_turn_or_action_held(rig.world_mouse.held(LookButton::Right));
}
}
/// The right button's down edge in the world, before the press is judged a click or a drag:
+91 -2
View File
@@ -38,10 +38,12 @@ pub(crate) fn apply_script_calls(
in_call_order(&mut script, |script, call| match call {
ScriptCall::Select(request) => appliers.p0().select(request),
ScriptCall::TargetByName { name, exact } => appliers.p0().target_by_name(&name, exact),
ScriptCall::TargetNearestFriend { reverse } => {
appliers.p0().target_nearest_friend(reverse);
ScriptCall::TargetNearest { mode, reverse } => {
appliers.p0().target_nearest(mode, reverse);
}
ScriptCall::TargetLastTarget => appliers.p0().target_last_target(),
ScriptCall::ClearTarget => appliers.p0().clear_target(),
ScriptCall::AttackTarget => appliers.p4().attack_target(),
ScriptCall::SpellTargetUnit(token) => appliers.p1().spell_target_unit(&token),
ScriptCall::SpellStopTargeting => appliers.p1().stop_targeting(),
ScriptCall::SpellStopCasting => appliers.p2().stop_casting(),
@@ -160,6 +162,7 @@ mod tests {
*world.resource_mut::<Selection>() = Selection {
target: Some(old),
guid: Some(OLD),
..Default::default()
};
}
let mut script = UiScript::new().expect("a VM");
@@ -258,6 +261,92 @@ mod tests {
}
}
/// `TargetNearestEnemy` (`0x489a80` → `0x493f60(0, 1)`) commits through `SetSelection` before
/// it returns, so the cast after it in the same script goes to the unit the TAB cycle picked.
#[test]
fn a_cast_after_target_nearest_enemy_goes_to_the_picked_unit() {
const MOB: u64 = 0x31;
let mut f = frame(true);
let world = f.app.world_mut();
// With no `FactionTemplate.dbc` a plain unit is attackable; OLD and NEW carry no
// `NetEntity`, so the scan's one candidate is the mob.
let mob = world
.spawn((
Guid(MOB),
crate::net::NetEntity {
kind: benilla_protocol::EntityKind::Unit,
display_id: None,
scale: 1.0,
},
Transform::from_xyz(3.0, 0.0, 0.0),
GlobalTransform::from_translation(Vec3::new(3.0, 0.0, 0.0)),
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(22, 100),
(28, 100),
])),
))
.id();
world.resource_mut::<GuidIndex>().0.insert(MOB, mob);
// We are a player (`OBJECT_FIELD_TYPE` 0x19, `UNIT_FLAG_PVP_ATTACKABLE`), which selects
// `CanAttack`'s player arm.
let me = world.resource::<GuidIndex>().0[&ME];
world
.entity_mut(me)
.insert(ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(2, 0x19),
(22, 100),
(28, 100),
(46, 0x8),
])));
run(&mut f, r#"TargetNearestEnemy() CastSpellByName("Heal")"#);
assert_eq!(casts(&f), vec![(HEAL, Some(MOB))]);
assert_eq!(selected(&f), Some(MOB));
}
/// `TargetLastTarget` (`0x489b00`) reads the pair `SetSelection` stamps with the outgoing
/// selection (`0x49361d`): a held pair re-selects, which swaps the two; an empty one deselects
/// through `0x493540(0,0)` (`0x489b2d`), which stamps what it drops.
#[test]
fn target_last_target_swaps_and_deselects_on_an_empty_pair() {
let mut f = frame(false);
// Selecting from nothing stamps nothing: the pair is empty, so the call deselects.
run(&mut f, r#"TargetUnit("party1") TargetLastTarget()"#);
assert_eq!(selected(&f), None);
// The deselect stamped NEW, so the next call brings it back.
run(&mut f, "TargetLastTarget()");
assert_eq!(selected(&f), Some(NEW));
// A switch stamps the outgoing unit; the call swaps back, and the cast after it in the
// same script goes to the unit it re-selected.
run(
&mut f,
r#"TargetUnit("player") TargetLastTarget() CastSpellByName("Heal")"#,
);
assert_eq!(selected(&f), Some(NEW));
assert_eq!(casts(&f), vec![(HEAL, Some(NEW))]);
run(&mut f, "TargetLastTarget()");
assert_eq!(selected(&f), Some(ME), "and back again");
}
/// `AttackTarget` (`0x489b50` → `0x6131a0`) swings at the selection as the calls before it
/// left it.
#[test]
fn attack_target_swings_at_the_selection_in_call_order() {
let swings = |f: &Frame| -> Vec<u64> {
f.rx.try_iter()
.filter_map(|c| match c {
ClientCommand::AttackSwing { guid } => Some(guid),
_ => None,
})
.collect()
};
let mut f = frame(true);
run(&mut f, r#"AttackTarget() TargetUnit("party1")"#);
assert_eq!(swings(&f), vec![OLD]);
let mut f = frame(true);
run(&mut f, r#"TargetUnit("party1") AttackTarget()"#);
assert_eq!(swings(&f), vec![NEW]);
}
/// A macro's lines run inside `UseAction` (`0x4e6098 call 0x4f1460`), so what they call lands
/// before the calls the pressing script makes after it: the macro's cast goes to the old
/// target, then the script's `TargetUnit` moves the selection.
@@ -542,6 +542,7 @@ mod tests {
world.insert_resource(crate::target::Selection {
target: Some(ally),
guid: Some(ALLY),
..Default::default()
});
let mut script = UiScript::new().expect("a VM");
script.set_spell_targeting(true);
+1 -1
View File
@@ -536,7 +536,7 @@ impl SelectCommit<'_, '_> {
/// Deselect (`0x493540(0,0)`), a no-op with nothing selected; see [`super::click::clear`].
pub(super) fn clear(&mut self) {
let engaged = self.engaged();
super::click::clear(&mut self.selection, &mut self.seam, engaged);
super::click::deselect(&mut self.selection, &mut self.seam, engaged);
}
/// Select a resolved guid, `0x489a40`'s arm 1, through [`scan::commit`].
+19 -3
View File
@@ -132,7 +132,7 @@ pub(super) fn select_on_click(
return;
}
if click_cfg.deselect_on_click && (!payload_held.0 || occlusion.distance.is_finite()) {
clear(&mut selection, &mut seam, engaged);
deselect(&mut selection, &mut seam, engaged);
}
}
}
@@ -1008,8 +1008,23 @@ pub(super) fn clear_target_requests(
#[derive(bevy::ecs::message::Message, Clone, Copy, Debug)]
pub(crate) struct DeselectGuid(pub(crate) u64);
/// Drop the target and send `CMSG_SET_SELECTION` 0 (a no-op with none); when `engaged`, melee
/// stops too, as on the reference's Esc, click-off or target death. Weapons stay drawn.
/// `SetSelection(0,0)` (`0x493540` → `0x4938f3`): the deselect of Esc, a click off and a target's
/// death, which stamps the outgoing target into the last-target pair before [`clear`]. A no-op
/// with nothing selected, the setter's dedup.
pub(super) fn deselect(
selection: &mut Selection,
seam: &mut crate::creature_anim::AttackSeam,
engaged: bool,
) {
if selection.guid.is_some() {
selection.last = selection.guid;
}
clear(selection, seam, engaged);
}
/// Drop the target and send `CMSG_SET_SELECTION` 0 (a no-op with none): the teardown `0x493910`
/// with its send, which a despawn and a dead unit's loot close reach without `SetSelection`, so the
/// last-target pair is left. When `engaged`, melee stops too. Weapons stay drawn.
pub(super) fn clear(
selection: &mut Selection,
seam: &mut crate::creature_anim::AttackSeam,
@@ -1512,6 +1527,7 @@ mod tests {
world.insert_resource(Selection {
target: Some(Entity::PLACEHOLDER),
guid: Some(HELD),
..Default::default()
});
world.insert_resource(PressPick {
hovered: pick,
+6 -2
View File
@@ -81,6 +81,10 @@ pub(crate) use click::DeselectGuid;
pub(crate) struct Selection {
pub(crate) target: Option<Entity>,
pub(crate) guid: Option<u64>,
/// The last-target pair `[0xb4e2e0]` that `TargetLastTarget` reads: `SetSelection` stamps the
/// outgoing selection, none included, on every change it makes, a select or a deselect
/// (`0x49361d`-`0x493628`); a teardown clear (`0x493910` alone) leaves it.
pub(crate) last: Option<u64>,
}
/// This frame's character-model pick, by [`hover::update_hover`]. At most one slot is set: the
@@ -330,8 +334,8 @@ impl Plugin for TargetPlugin {
)
.chain(),
scan::auto_acquire_attacker,
// The TAB cycler (`0x493f60`, mode 1); `TargetNearestFriend`, mode 2, is a
// script call.
// The TAB keys' cycler (`0x493f60`, mode 1); the `TargetNearest*` Lua calls
// run the same cycle as script calls.
scan::tab_target,
scan::acquire_and_attack,
flash::drive_flash,
+4 -2
View File
@@ -13,7 +13,7 @@ use benilla_assets::{LockRecover, WorldAssets};
use benilla_world::decal::{DecalFrame, WorldDecal};
use benilla_world::particles::buffer::EffectVertex;
use super::click::clear;
use super::click::{clear, deselect};
use super::{CombatFlash, Selection, SelectionRadius};
use crate::creature_anim::Engaged;
use benilla_world::view::WorldCamera;
@@ -277,7 +277,8 @@ pub(super) fn update_ring(
.is_some_and(|g| *last_vitals == Some((g, false)));
*last_vitals = selection.guid.map(|g| (g, is_dead));
if died {
clear(&mut selection, &mut seam, !engaged.is_empty());
// `SetSelection(0,0)` at `0x605901`, so the dead target becomes the last one.
deselect(&mut selection, &mut seam, !engaged.is_empty());
*last_vitals = None;
state.shown = false;
state.verts.clear();
@@ -1161,6 +1162,7 @@ mod tests {
*world.resource_mut::<Selection>() = Selection {
target: Some(mob),
guid: Some(MOB),
..Default::default()
};
// Control: a live target keeps its selection through a ring pass.
world
+104 -2
View File
@@ -52,7 +52,7 @@ fn tab_trace_on() -> bool {
/// The reference's scan mode: the `TargetNearest*` shims (`0x489a80` enemy 1, `0x489aa0` friend 2,
/// `0x489ac0`/`0x489ae0` party and raid 3/4) all call one cycler, `0x493f60(reverse, mode)`, and
/// the mode reaches only the per-candidate filter `0x493e40`. Modes 3 and 4 are not built.
/// the mode reaches only the per-candidate filter `0x493e40`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ScanSide {
/// Mode 1 (`0x493e73`): alive by the reads-dead triple `0x605f90`, and `CanAttack 0x606980`.
@@ -61,6 +61,25 @@ pub(crate) enum ScanSide {
/// `IsPvP 0x605ff0` leg refuses a creature without `UNIT_FLAG_PVP`; then health > 0 with no
/// dynflag leg, so a feigning ally counts.
Friend,
/// Mode 3 (`0x493eed`): not us, then `0x4e7f70`, a member of our party slots. No health, dead
/// or reaction leg.
Party,
/// Mode 4 (`0x493f15`): not us, then `0x4918e0`, party (`0x4e7f70`) or raid roster
/// (`0x4baee0`).
Raid,
}
impl ScanSide {
/// The side a `TargetNearest*` call's mode names.
pub(crate) fn of(mode: benilla_ui::script::NearestMode) -> Self {
use benilla_ui::script::NearestMode;
match mode {
NearestMode::Enemy => Self::Enemy,
NearestMode::Friend => Self::Friend,
NearestMode::PartyMember => Self::Party,
NearestMode::RaidMember => Self::Raid,
}
}
}
/// `score`, lower is better, includes the fighting-me bonus; `on_screen` is the tier-1 gate.
@@ -199,6 +218,8 @@ pub(crate) struct TargetScan<'w, 's> {
stores: Query<'w, 's, &'static ObjectStore>,
/// The owner chase's guid → entity map; `Option` because a UI-only harness has no net stack.
index: Option<Res<'w, GuidIndex>>,
/// The party slots and raid roster modes 3 and 4 test; `Option` for the same harnesses.
group: Option<Res<'w, crate::ui_party::GroupState>>,
}
impl TargetScan<'_, '_> {
@@ -206,6 +227,7 @@ impl TargetScan<'_, '_> {
fn is_valid(
&self,
side: ScanSide,
guid: u64,
store: Option<&ObjectStore>,
self_store: Option<&ObjectStore>,
) -> bool {
@@ -232,6 +254,16 @@ impl TargetScan<'_, '_> {
|owner| self.store_of(owner).cloned(),
) && !store.is_some_and(|s| s.0.unit_is_dead())
}
// Modes 3 and 4 open on `cmp esi,ecx`, the candidate is not us, which our own body's
// absence from [`Self::units`] already gives.
ScanSide::Party => self
.group
.as_ref()
.is_some_and(|g| g.party_slots().any(|m| m.guid == guid)),
ScanSide::Raid => self.group.as_ref().is_some_and(|g| {
g.party_slots().any(|m| m.guid == guid)
|| (g.group_type == 1 && g.members.iter().any(|m| m.guid == guid))
}),
}
}
@@ -323,13 +355,15 @@ impl TargetScan<'_, '_> {
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
continue;
}
if !self.is_valid(side, store, self_store) {
if !self.is_valid(side, guid_c.0, store, self_store) {
trace_unit(
guid_c.0,
tf,
match side {
ScanSide::Enemy => "REJECT dead-or-unattackable",
ScanSide::Friend => "REJECT dead-or-unassistable",
ScanSide::Party => "REJECT not-in-party",
ScanSide::Raid => "REJECT not-in-party-or-raid",
},
);
continue;
@@ -457,6 +491,8 @@ pub(super) fn commit(
return CommitOutcome::default(); // the setter's dedup
}
let had_old = selection.guid.is_some();
// The last-target stamp (`0x49361d`-`0x493628`), past the two early outs.
selection.last = selection.guid;
if let Some(old) = selection.guid {
// The old target's teardown (`0x4936cc` → `0x493910`) closes its loot first.
seam.close_loot_on(old);
@@ -1316,6 +1352,72 @@ mod tests {
assert_eq!(pool(&mut world, ScanSide::Friend), [ALLY, ALLY_FEIGN]);
}
/// Modes 3 and 4 (`0x493eed`, `0x493f15`): the party slots, then party or raid roster, with no
/// health or reaction leg, so a dead party member counts and a stranger never does.
#[test]
fn the_party_and_raid_modes_take_only_the_roster() {
use benilla_protocol::messages::GroupMemberEntry;
use bevy::ecs::system::RunSystemOnce;
const PARTY: u64 = 0x11;
const PARTY_DEAD: u64 = 0x12;
const RAID: u64 = 0x13;
const STRANGER: u64 = 0x14;
let mut world = World::new();
world.init_resource::<Reputations>();
world.init_resource::<NameCache>();
world.spawn((
SelfPlayer,
Transform::default(),
Guid(1),
store(&[(F_HEALTH, 100)]),
));
for (guid, x, health) in [
(PARTY, 5.0, 100),
(PARTY_DEAD, 6.0, 0),
(RAID, 7.0, 100),
(STRANGER, 3.0, 100),
] {
world.spawn((
NetEntity {
kind: EntityKind::Player,
display_id: None,
scale: 1.0,
},
Guid(guid),
Transform::from_xyz(x, 0.0, 0.0),
store(&[(F_HEALTH, health), (F_MAXHEALTH, 100)]),
));
}
let member = |guid, flags| GroupMemberEntry {
name: String::new(),
guid,
status: 1,
flags,
};
// A raid: our subgroup 0 holds PARTY and PARTY_DEAD; RAID is in subgroup 1.
world.insert_resource(crate::ui_party::GroupState {
in_group: true,
group_type: 1,
members: vec![member(PARTY, 0), member(PARTY_DEAD, 0), member(RAID, 1)],
..Default::default()
});
let pool = |world: &mut World, side: ScanSide| {
world
.run_system_once(move |scan: TargetScan| {
scan.build(side).iter().map(|c| c.guid).collect::<Vec<_>>()
})
.expect("the scan runs as a one-shot system")
};
assert_eq!(pool(&mut world, ScanSide::Party), [PARTY, PARTY_DEAD]);
assert_eq!(pool(&mut world, ScanSide::Raid), [PARTY, PARTY_DEAD, RAID]);
// Out of a raid, mode 4's raid half has no roster: it is the party test.
world
.resource_mut::<crate::ui_party::GroupState>()
.group_type = 0;
assert_eq!(pool(&mut world, ScanSide::Raid), [PARTY, PARTY_DEAD]);
}
#[test]
fn a_body_the_draw_election_culled_is_still_a_candidate() {
use bevy::ecs::system::RunSystemOnce;
+19 -4
View File
@@ -20,7 +20,7 @@ pub(crate) struct ScriptSelect<'w, 's> {
/// included.
commit: SelectCommit<'w, 's>,
by_name: ByNameScan<'w, 's>,
/// The TAB cycler's inputs, for `TargetNearestFriend`.
/// The TAB cycler's inputs, for the `TargetNearest*` calls.
scan: scan::TargetScan<'w, 's>,
history: ResMut<'w, scan::TabHistory>,
time: Res<'w, Time>,
@@ -73,11 +73,12 @@ impl ScriptSelect<'_, '_> {
by_name::target_named(&self.by_name, &mut self.commit, name, exact);
}
/// `TargetNearestFriend([reverse])` (`0x489aa0` → `0x493f60(reverse, 2)`), one cycle.
pub(crate) fn target_nearest_friend(&mut self, reverse: bool) {
/// `TargetNearest*([reverse])` (`0x489a80`/`0x489aa0`/`0x489ac0`/`0x489ae0` →
/// `0x493f60(reverse, mode)`), one cycle of the cycler the TAB key runs.
pub(crate) fn target_nearest(&mut self, mode: benilla_ui::script::NearestMode, reverse: bool) {
let engaged = self.commit.engaged();
scan::cycle(
scan::ScanSide::Friend,
scan::ScanSide::of(mode),
reverse,
self.time.elapsed_secs_f64(),
&self.scan,
@@ -88,6 +89,20 @@ impl ScriptSelect<'_, '_> {
);
}
/// `TargetLastTarget()` (`0x489b00`): a held last-target pair goes to `0x489a40` as a stack
/// copy, so a streamed unit is committed (which stamps the outgoing target, swapping the two)
/// and an unstreamed one is a no-op (the roster fallback is not built, as for
/// `TargetLastEnemy`); an empty pair deselects through `0x493540(0,0)` (`0x489b2d`).
pub(crate) fn target_last_target(&mut self) {
match self.commit.selection.last {
Some(guid) => match self.tokens.held(guid) {
Some((entity, guid)) => self.commit.commit(entity, guid),
None => info!("TargetLastTarget: {guid:#x} is no longer streamed; no-op"),
},
None => self.commit.clear(),
}
}
/// `ClearTarget()` (`0x489ff0`): deselect whatever the calls before it left selected.
pub(crate) fn clear_target(&mut self) {
self.commit.clear();
+30 -7
View File
@@ -55,8 +55,8 @@ pub(super) fn item_action_route(
}
}
/// The ATTACKTARGET binding (default T): the action bar's attack arm without a slot, as in the
/// reference, where `AttackTarget` and `UseAction`'s Attack both land in `0x612df0`.
/// The ATTACKTARGET binding (default T), whose stock body is `AttackTarget()`: the same toggle as
/// the Lua call ([`ActionPress::attack_target`]).
pub(super) fn attack_target_binding(
binds: Res<crate::bindings::BindingsState>,
targeting: cast_target::CastTargeting,
@@ -64,13 +64,24 @@ pub(super) fn attack_target_binding(
mut ui_errors: ResMut<UiErrorKeys>,
mut ladder: CastLadder,
) {
if !binds.fired(crate::bindings::cmd::ATTACK_TARGET) {
return;
if binds.fired(crate::bindings::cmd::ATTACK_TARGET) {
attack_target(&targeting, &mut acquire, &mut ui_errors, &mut ladder);
}
}
/// `AttackTarget` (`0x489b50` → `0x6131a0(0,0)`): the action bar's attack arm without a slot, as
/// `UseAction`'s Attack also lands in `0x612df0`. A held selection toggles the swing at it; none
/// acquires the nearest enemy.
fn attack_target(
targeting: &cast_target::CastTargeting,
acquire: &mut MessageWriter<crate::target::AttackNearestRequest>,
ui_errors: &mut UiErrorKeys,
ladder: &mut CastLadder,
) {
if attack_actor_refusal(
targeting.self_store.iter().next(),
targeting.context().self_guid,
&mut ui_errors,
ui_errors,
) {
return;
}
@@ -80,7 +91,7 @@ pub(super) fn attack_target_binding(
return;
};
debug!(
"bindings: ATTACKTARGET {} at {guid:#x}",
"AttackTarget {} at {guid:#x}",
if engaged { "toggled off" } else { "swing" }
);
// The action button's own toggle (`0x6131a0`).
@@ -96,7 +107,7 @@ pub(super) fn attack_target_binding(
);
}
None => {
debug!("bindings: ATTACKTARGET with no target — acquiring nearest");
debug!("AttackTarget with no target — acquiring nearest");
acquire.write(crate::target::AttackNearestRequest);
}
}
@@ -176,6 +187,18 @@ pub(crate) struct ActionPress<'w, 's> {
gate: crate::ui_bind_confirm::BindGate<'w>,
}
impl ActionPress<'_, '_> {
/// `AttackTarget()` from the call queue, at the selection the calls before it left.
pub(crate) fn attack_target(&mut self) {
attack_target(
&self.targeting,
&mut self.acquire,
&mut self.ui_errors,
&mut self.ladder,
);
}
}
/// `UseAction` (`0x4e5ee0`) on the pressed slot: a cast, a swing, an item use or a macro run, at
/// the selection as the calls before it left it. A macro's lines run inside it (`0x4e6098 call
/// 0x4f1460`), so the calls they make queue for [`crate::script_calls`] to apply next.
+3
View File
@@ -163,12 +163,14 @@ fn emoting_at_your_own_selection_sends_an_untargeted_emote() {
let sel = Selection {
target: Some(me),
guid: Some(0xdead_beef),
..Default::default()
};
assert_eq!(emote_target(&sel, Some(me)), 0);
let sel = Selection {
target: Some(them),
guid: Some(0xdead_beef),
..Default::default()
};
assert_eq!(emote_target(&sel, Some(me)), 0xdead_beef);
@@ -177,6 +179,7 @@ fn emoting_at_your_own_selection_sends_an_untargeted_emote() {
let sel = Selection {
target: Some(them),
guid: Some(0xdead_beef),
..Default::default()
};
assert_eq!(emote_target(&sel, None), 0xdead_beef);
}
+1
View File
@@ -999,6 +999,7 @@ mod tests {
.insert_resource(Selection {
target: None,
guid: Some(0x7), // high 16 bits 0: a player guid
..Default::default()
});
app.insert_non_send_resource(UiScript::new().unwrap());
+1
View File
@@ -2144,6 +2144,7 @@ mod tests {
app.insert_resource(Selection {
target: Some(target),
guid: Some(guid),
..Default::default()
});
app.world_mut().run_system_once(feed_unit_reach).unwrap();
@@ -44,6 +44,13 @@ const VERDICTS: &[(&str, Verdict)] = &[
"WindowFocused",
Open("alt-tab bookkeeping; `cursor.rs` re-asserts the hardware cursor on the focus edge"),
),
(
"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",
),
),
(
"WindowOccluded",
Open("the present-mode throttle: a load behind another window must still throttle"),
+24 -2
View File
@@ -15,10 +15,18 @@ pub enum ScriptCall {
Select(SelectionRequest),
/// `TargetByName(name, exactMatch)` (`0x489d60`), the stock `/target`.
TargetByName { name: String, exact: bool },
/// `TargetNearestFriend([reverse])` (`0x489aa0` → `0x493f60(reverse, 2)`).
TargetNearestFriend { reverse: bool },
/// `TargetNearestEnemy`, `TargetNearestFriend`, `TargetNearestPartyMember` and
/// `TargetNearestRaidMember` (`0x489a80`/`0x489aa0`/`0x489ac0`/`0x489ae0`): one TAB cycle,
/// `0x493f60(reverse, mode)`.
TargetNearest { mode: NearestMode, reverse: bool },
/// `TargetLastTarget()` (`0x489b00`): re-select the last-target pair, or deselect when it is
/// empty.
TargetLastTarget,
/// `ClearTarget()` (`0x489ff0` → `0x493540(0,0)`), a no-op when nothing is selected by then.
ClearTarget,
/// `AttackTarget()` (`0x489b50` → `0x6131a0(0,0)`): the attack toggle at the selection as it
/// stands then.
AttackTarget,
/// `SpellTargetUnit(unit)` (`0x6e6d90`), made while the cursor was up.
SpellTargetUnit(String),
/// `SpellStopTargeting()` (`0x6e6e30`), made while the cursor was up.
@@ -43,6 +51,20 @@ pub enum ScriptCall {
PetOrder(u32),
}
/// The mode the four `TargetNearest*` shims hand the TAB cycler `0x493f60`, the only byte that
/// differs between them; it reaches only the per-candidate filter `0x493e40`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NearestMode {
/// Mode 1, `TargetNearestEnemy`.
Enemy,
/// Mode 2, `TargetNearestFriend`.
Friend,
/// Mode 3, `TargetNearestPartyMember`: a party member other than us.
PartyMember,
/// Mode 4, `TargetNearestRaidMember`: a party or raid member other than us.
RaidMember,
}
impl UiScript {
/// Take every queued call, in call order.
pub fn take_script_calls(&mut self) -> Vec<ScriptCall> {
+485
View File
@@ -0,0 +1,485 @@
//! 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::run_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,
}
/// 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.
turn_or_action: 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
}
/// Push whether the TurnOrAction channel is held: the world holds the right mouse button.
pub fn set_turn_or_action_held(&mut self, held: bool) {
self.model_mut().input.turn_or_action = held;
}
}
/// 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)`; no argument is read,
// nothing is returned.
let held: [(&str, &str, HeldInput); 6] = [
(
"MoveForwardStart",
"MoveForwardStop",
HeldInput::MoveForward,
),
(
"MoveBackwardStart",
"MoveBackwardStop",
HeldInput::MoveBackward,
),
("TurnLeftStart", "TurnLeftStop", HeldInput::TurnLeft),
("TurnRightStart", "TurnRightStop", HeldInput::TurnRight),
("StrafeLeftStart", "StrafeLeftStop", HeldInput::StrafeLeft),
(
"StrafeRightStart",
"StrafeRightStop",
HeldInput::StrafeRight,
),
];
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))
})?,
)?;
// 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 is keyed to the physical buttons
// (`ABSENT` TURNORACTION, CAMERAORSELECTORMOVE);
// - `MouselookStart`/`Stop` (`0x514210`/`0x514240`, ungated), the same channel with no click
// armed: a session without a held button is not built.
inert(
lua,
&[
"PitchUpStart",
"PitchUpStop",
"PitchDownStart",
"PitchDownStop",
"TurnOrActionStart",
"TurnOrActionStop",
"CameraOrSelectOrMoveStart",
"CameraOrSelectOrMoveStop",
"MouselookStart",
"MouselookStop",
],
)?;
// ── 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)
);
}
/// `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,
]
);
}
}
+4 -1
View File
@@ -69,6 +69,7 @@ pub use handler_prof::HandlerRow;
mod surface;
pub use surface::widget_method_census;
mod input_verbs;
mod inspect;
mod item_stats;
mod item_text;
@@ -151,7 +152,7 @@ pub use battlefield_positions::{BattlefieldFlagView, BattlefieldPositionView};
pub use battlefield_queue::{BattlefieldListView, BattlefieldMapInfo, BattlefieldQueueSlot};
pub use battlefield_score::{BattlefieldScoreRow, BattlefieldScores, BattlefieldStatColumn};
pub use bind_confirm::PendingEquipAnswer;
pub use calls::ScriptCall;
pub use calls::{NearestMode, ScriptCall};
pub use camera_view::{CameraViewRequest, CAMERA_VIEW_COUNT};
pub use channel::{ChannelCommand, ZoneChannelRow};
pub use char_stats::{
@@ -187,6 +188,7 @@ pub use guild::{
GuildMemberInfo, GuildRankEdit, GuildRankInfo, GuildRequest, GuildState, LastOnline, UnitGuild,
MAX_RANKS, MIN_RANKS, RANK_RIGHT_BITS,
};
pub use input_verbs::{BindingInput, FiredInput, HeldInput};
pub use modelframe::ModelPaneFrame;
pub use petition::{
validate_guild_name, PetitionRecordView, PetitionRequest, PetitionState, PETITION_TYPE_CHARTER,
@@ -508,6 +510,7 @@ impl UiScript {
duel::install(&lua)?;
follow::install(&lua)?;
camera_view::install(&lua)?;
input_verbs::install(&lua)?;
session::install(&lua)?;
pvp::install(&lua)?;
worn_display::install(&lua)?;
+3
View File
@@ -234,6 +234,8 @@ pub(crate) struct Model {
pub(crate) tracking: Option<super::aura::TrackingState>,
/// The calls that touch the selection, the cast or the targeting cursor, in call order.
pub(crate) script_calls: Vec<super::calls::ScriptCall>,
/// The binding functions' queue, gate depth and mouselook state.
pub(crate) input: super::input_verbs::InputState,
/// `DropItemOnUnit` tokens (`0x48d960`), gated by the app; a refusal silently keeps the item.
pub(crate) drop_item_on_unit: Vec<String>,
@@ -1035,6 +1037,7 @@ impl Model {
cancel_aura_requests: Vec::new(),
tracking: None,
script_calls: Vec::new(),
input: Default::default(),
drop_item_on_unit: Vec::new(),
joined_channels: Vec::new(),
party: party::PartyState::default(),
@@ -1026,27 +1026,6 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// TargetNearestFriend([reverse]) (`0x489aa0`): the Tab cycler `0x493f60` in mode 2 (enemy is
// 1), whose filter (`0x493eca`) wants `CanAssist` and health above 0. Argument 1 reverses
// (`0x6f1c10`, absent is 0); stock `Bindings.xml` says "1 (or "true")", so a number or a
// boolean reverses.
g.set(
"TargetNearestFriend",
lua.create_function(|lua, reverse: Option<Value>| {
let reverse = match reverse {
None | Some(Value::Nil) | Some(Value::Boolean(false)) => false,
Some(Value::Integer(n)) => n != 0,
Some(Value::Number(n)) => n != 0.0,
Some(_) => true,
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.script_calls
.push(ScriptCall::TargetNearestFriend { reverse });
Ok(())
})?,
)?;
// TargetByName(name [, exactMatch]) (`0x489d60`, resolver `0x493aa0`, shared with `/target`): a
// case-insensitive whole-name match wins, else without `exactMatch` the longest common prefix,
// so "Rag" selects Ragnaros. Any unit (typemask 8), no dead, reaction, range or self gate. A
+5 -5
View File
@@ -4,7 +4,7 @@
use mlua::Lua;
use super::{Model, ScriptCall};
use super::{Model, NearestMode, ScriptCall};
/// A selection ask from Lua, queued in call order for the app to resolve and commit. The reference
/// routes `TargetUnit`, `AssistUnit` and `TargetLastEnemy` through one helper (`0x489a40`: commit,
@@ -394,12 +394,12 @@ impl super::UiScript {
})
}
/// Take the `TargetNearestFriend([reverse])` calls out of the call stream, `true` for reverse.
/// It names no unit: the reference runs the TAB cycler (`0x493f60`, mode 2) straight into
/// Take the `TargetNearest*([reverse])` calls out of the call stream, `true` for reverse.
/// They name no unit: the reference runs the TAB cycler (`0x493f60`) straight into
/// `SetSelection`.
pub fn take_target_nearest_friend_requests(&mut self) -> Vec<bool> {
pub fn take_target_nearest_requests(&mut self) -> Vec<(NearestMode, bool)> {
self.take_calls_where(|c| match c {
ScriptCall::TargetNearestFriend { reverse } => Some(*reverse),
ScriptCall::TargetNearest { mode, reverse } => Some((*mode, *reverse)),
_ => None,
})
}
+22 -7
View File
@@ -349,22 +349,37 @@ fn the_selection_queue_carries_all_three_verbs_in_call_order() {
assert!(s.take_selection_requests().is_empty());
}
/// The reverse flag as `0x6f1c10` reads it: absent, nil and 0 are forward, 1 and `true` reverse
/// (`Bindings.xml:458`).
/// The reverse flag as `0x6f1c10` reads it (default 0): absent, nil, 0 and `"0"` are forward, 1,
/// `true` and `"1"` reverse (`Bindings.xml:458`). The four shims differ only in the mode.
#[test]
fn target_nearest_friend_queues_its_reverse_flag() {
fn target_nearest_queues_its_mode_and_reverse_flag() {
use crate::script::NearestMode::{Enemy, Friend, PartyMember, RaidMember};
let mut s = UiScript::new().unwrap();
assert!(s.take_target_nearest_friend_requests().is_empty());
assert!(s.take_target_nearest_requests().is_empty());
s.eval::<()>("TargetNearestFriend()").unwrap();
s.eval::<()>("TargetNearestFriend(1)").unwrap();
s.eval::<()>("TargetNearestFriend(true)").unwrap();
s.eval::<()>("TargetNearestFriend(0)").unwrap();
s.eval::<()>("TargetNearestFriend(nil)").unwrap();
s.eval::<()>(r#"TargetNearestFriend("0")"#).unwrap();
s.eval::<()>(r#"TargetNearestEnemy("1")"#).unwrap();
s.eval::<()>("TargetNearestPartyMember()").unwrap();
s.eval::<()>("TargetNearestRaidMember(1)").unwrap();
assert_eq!(
s.take_target_nearest_friend_requests(),
vec![false, true, true, false, false]
s.take_target_nearest_requests(),
vec![
(Friend, false),
(Friend, true),
(Friend, true),
(Friend, false),
(Friend, false),
(Friend, false),
(Enemy, true),
(PartyMember, false),
(RaidMember, true),
]
);
assert!(s.take_target_nearest_friend_requests().is_empty());
assert!(s.take_target_nearest_requests().is_empty());
}
#[test]