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:
co-authored by
Claude Opus 5.5
parent
7362a87f95
commit
06bc451bac
@@ -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(®istry_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(®istry_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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`].
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user