diff --git a/crates/benilla-app/src/bindings.rs b/crates/benilla-app/src/bindings.rs index 4281c87c..f2743f6d 100644 --- a/crates/benilla-app/src/bindings.rs +++ b/crates/benilla-app/src/bindings.rs @@ -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, /// Commands whose first latch began this frame (the press edge). just: Vec, /// 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>, + mut script: Option>, mut keyboard: MessageReader, keys: Res>, buttons: Res>, @@ -399,6 +404,8 @@ fn latch_and_dispatch( dispatch: Res, mut state: ResMut, mut same_vm: Local>, + mut focus_lost: MessageReader, + cover: Option>, ) { 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>, 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>, 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#" + + if ( keystate == "down" ) then MoveForwardStart(); else MoveForwardStop(); end + + MoveForwardStart() + MoveForwardStop() + "#; + + /// `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::() + .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::() + .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(); diff --git a/crates/benilla-app/src/bindings/script_input.rs b/crates/benilla-app/src/bindings/script_input.rs new file mode 100644 index 00000000..985229ed --- /dev/null +++ b/crates/benilla-app/src/bindings/script_input.rs @@ -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) { + 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); + } + } +} diff --git a/crates/benilla-app/src/player/camera.rs b/crates/benilla-app/src/player/camera.rs index df52a110..f48deaa8 100644 --- a/crates/benilla-app/src/player/camera.rs +++ b/crates/benilla-app/src/player/camera.rs @@ -655,6 +655,7 @@ pub(super) fn latch_world_mouse( mut rig: ResMut, cameras: Query<&Camera, With>, window: Single<&Window, With>, + script: Option>, ) { 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: diff --git a/crates/benilla-app/src/script_calls.rs b/crates/benilla-app/src/script_calls.rs index 6a0876dd..b35ba2a8 100644 --- a/crates/benilla-app/src/script_calls.rs +++ b/crates/benilla-app/src/script_calls.rs @@ -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 { 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::().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::().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 { + 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. diff --git a/crates/benilla-app/src/spell/targeting/mod.rs b/crates/benilla-app/src/spell/targeting/mod.rs index d1fb5209..839f1fe5 100644 --- a/crates/benilla-app/src/spell/targeting/mod.rs +++ b/crates/benilla-app/src/spell/targeting/mod.rs @@ -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); diff --git a/crates/benilla-app/src/target/by_name.rs b/crates/benilla-app/src/target/by_name.rs index d183bfd5..e5d4bde6 100644 --- a/crates/benilla-app/src/target/by_name.rs +++ b/crates/benilla-app/src/target/by_name.rs @@ -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`]. diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs index 4a23f232..ffe98c02 100644 --- a/crates/benilla-app/src/target/click.rs +++ b/crates/benilla-app/src/target/click.rs @@ -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, diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index f3608f1c..632f1405 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -81,6 +81,10 @@ pub(crate) use click::DeselectGuid; pub(crate) struct Selection { pub(crate) target: Option, pub(crate) guid: Option, + /// 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, } /// 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, diff --git a/crates/benilla-app/src/target/ring.rs b/crates/benilla-app/src/target/ring.rs index 1bbebd10..7265865a 100644 --- a/crates/benilla-app/src/target/ring.rs +++ b/crates/benilla-app/src/target/ring.rs @@ -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 { target: Some(mob), guid: Some(MOB), + ..Default::default() }; // Control: a live target keeps its selection through a ring pass. world diff --git a/crates/benilla-app/src/target/scan.rs b/crates/benilla-app/src/target/scan.rs index be3330cd..005fadfb 100644 --- a/crates/benilla-app/src/target/scan.rs +++ b/crates/benilla-app/src/target/scan.rs @@ -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>, + /// The party slots and raid roster modes 3 and 4 test; `Option` for the same harnesses. + group: Option>, } 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::(); + world.init_resource::(); + 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::>() + }) + .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::() + .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; diff --git a/crates/benilla-app/src/target/script.rs b/crates/benilla-app/src/target/script.rs index 106c4fbd..82b0d554 100644 --- a/crates/benilla-app/src/target/script.rs +++ b/crates/benilla-app/src/target/script.rs @@ -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(); diff --git a/crates/benilla-app/src/ui_action/drain.rs b/crates/benilla-app/src/ui_action/drain.rs index 7b82d635..64ff885a 100644 --- a/crates/benilla-app/src/ui_action/drain.rs +++ b/crates/benilla-app/src/ui_action/drain.rs @@ -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, targeting: cast_target::CastTargeting, @@ -64,13 +64,24 @@ pub(super) fn attack_target_binding( mut ui_errors: ResMut, 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, + 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. diff --git a/crates/benilla-app/src/ui_chat/tests.rs b/crates/benilla-app/src/ui_chat/tests.rs index 9eaa0046..6c53135f 100644 --- a/crates/benilla-app/src/ui_chat/tests.rs +++ b/crates/benilla-app/src/ui_chat/tests.rs @@ -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); } diff --git a/crates/benilla-app/src/ui_trade/mod.rs b/crates/benilla-app/src/ui_trade/mod.rs index 3950e8e0..fc67658c 100644 --- a/crates/benilla-app/src/ui_trade/mod.rs +++ b/crates/benilla-app/src/ui_trade/mod.rs @@ -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()); diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index fa844a85..2b5cca5c 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -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(); diff --git a/crates/benilla-app/tests/cover_input_wall.rs b/crates/benilla-app/tests/cover_input_wall.rs index abfbebb4..0bc4bbfc 100644 --- a/crates/benilla-app/tests/cover_input_wall.rs +++ b/crates/benilla-app/tests/cover_input_wall.rs @@ -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"), diff --git a/crates/benilla-ui/src/script/calls.rs b/crates/benilla-ui/src/script/calls.rs index 80b01dca..9bca09de 100644 --- a/crates/benilla-ui/src/script/calls.rs +++ b/crates/benilla-ui/src/script/calls.rs @@ -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 { diff --git a/crates/benilla-ui/src/script/input_verbs.rs b/crates/benilla-ui/src/script/input_verbs.rs new file mode 100644 index 00000000..3f09c658 --- /dev/null +++ b/crates/benilla-ui/src/script/input_verbs.rs @@ -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, + /// 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 { + 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::().expect("model app_data"); + model.input.binding_depth > 0 +} + +fn push(lua: &Lua, input: BindingInput) { + let mut model = lua.app_data_mut::().expect("model app_data"); + model.input.queue.push(input); +} + +fn push_call(lua: &Lua, call: ScriptCall) { + let mut model = lua.app_data_mut::().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) -> Option { + 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::().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| { + 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| { + 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| { + 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::>("return IsMouselooking()") + .unwrap() + .is_none()); + s.set_turn_or_action_held(true); + assert_eq!( + s.eval::>("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, + ] + ); + } +} diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 0330685c..4d6e471b 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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)?; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 421443fd..18f9fe4e 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -234,6 +234,8 @@ pub(crate) struct Model { pub(crate) tracking: Option, /// The calls that touch the selection, the cast or the targeting cursor, in call order. pub(crate) script_calls: Vec, + /// 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, @@ -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(), diff --git a/crates/benilla-ui/src/script/unit/bindings.rs b/crates/benilla-ui/src/script/unit/bindings.rs index ddc6d0b8..77988093 100644 --- a/crates/benilla-ui/src/script/unit/bindings.rs +++ b/crates/benilla-ui/src/script/unit/bindings.rs @@ -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| { - 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::().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 diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index 9a39361b..b0ade537 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -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 { + 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, }) } diff --git a/crates/benilla-ui/src/script/unit/tests.rs b/crates/benilla-ui/src/script/unit/tests.rs index 70ff364e..8efbb850 100644 --- a/crates/benilla-ui/src/script/unit/tests.rs +++ b/crates/benilla-ui/src/script/unit/tests.rs @@ -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]