Files
benilla-octo/crates/benilla-app/src/script_calls.rs
T
Sam SungandClaude Opus 5.5 06bc451bac 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>
2026-09-28 09:09:45 +07:00

387 lines
16 KiB
Rust

//! The one point in the frame where the script calls that touch the selection, the player's cast
//! or the targeting cursor take effect, in the order the script made them. In the reference each
//! such call is done when it returns (the macro runner `0x4f14e0` fires every line in one pass;
//! `TargetByName` `0x489d60` and `TargetUnit` `0x4899d0` commit through `SetSelection 0x493540`;
//! `CastSpellByName` `0x4b4ab0` casts at the selection as it stands then), so `/target Bob` then
//! `/cast Flash Heal` heals Bob, and `/cast` then `/target` casts at the old target. A fixed order
//! between per-kind drains cannot give both, so the VM keeps one queue ([`ScriptCall`]) and this
//! system applies it front to back, each call through its owner's applier.
use std::collections::VecDeque;
use bevy::prelude::*;
use benilla_ui::script::{ScriptCall, UiScript};
/// How deep calls made while applying a call may nest in one frame: a macro whose line presses a
/// macro, and so on. Past it (a macro that presses itself), what is left waits for the next frame,
/// so a frame always ends.
const MAX_NESTING: u8 = 8;
/// Apply this frame's script calls in call order, in the target chain after the world clicks.
#[allow(clippy::type_complexity)] // the appliers, one per owner of the state a call touches
pub(crate) fn apply_script_calls(
script: Option<NonSendMut<UiScript>>,
mut appliers: ParamSet<(
crate::target::ScriptSelect,
crate::spell::ScriptCursor,
crate::spell::SelfCancel,
crate::spell::ScriptCast,
crate::ui_action::ActionPress,
crate::ui_items::ScriptItemUse,
crate::ui_pet::PetPress,
)>,
) {
let Some(mut script) = script else {
return;
};
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::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(),
ScriptCall::CastSpell(spell_id) => {
crate::ui_spellbook::cast_spell(&mut appliers.p3(), spell_id);
}
ScriptCall::CastShapeshiftForm(spell_id) => {
crate::ui_shapeshift::cast_form(&mut appliers.p3(), spell_id);
}
ScriptCall::UseAction(press) => {
crate::ui_action::use_action(&mut appliers.p4(), script, press);
}
ScriptCall::UseContainerItem { bag, slot } => {
appliers.p5().use_container_item(script, bag, slot);
}
ScriptCall::UseInventoryItem(id) => appliers.p5().use_inventory_item(script, id),
ScriptCall::CastPetSpell(spell_id) => {
crate::ui_pet_book::cast_pet_spell(&mut appliers.p6(), spell_id);
}
ScriptCall::PetAction(slot) => appliers.p6().press_slot(slot),
ScriptCall::PetOrder(packed) => appliers.p6().order(packed),
});
}
/// Apply every queued call front to back. Applying one may queue more: a macro's lines run
/// inside `UseAction` (`0x4e6098 call 0x4f1460`), so the calls they make go before the next one
/// queued.
pub(crate) fn in_call_order(
script: &mut UiScript,
mut apply: impl FnMut(&mut UiScript, ScriptCall),
) {
let mut queue: VecDeque<(ScriptCall, u8)> = script
.take_script_calls()
.into_iter()
.map(|call| (call, 0))
.collect();
let mut next_frame = Vec::new();
while let Some((call, depth)) = queue.pop_front() {
apply(script, call);
let nested = script.take_script_calls();
if depth >= MAX_NESTING {
next_frame.extend(nested);
continue;
}
for call in nested.into_iter().rev() {
queue.push_front((call, depth + 1));
}
}
if !next_frame.is_empty() {
script.requeue_script_calls(next_frame);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::net::{
ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfGuid, SelfPlayer,
};
use crate::target::Selection;
use benilla_protocol::messages::{ActionButton, GroupMemberEntry, ACTION_KIND_MACRO};
use benilla_ui::script::{SpellBookState, SpellSlotView, SpellTabView};
use bevy::ecs::system::RunSystemOnce;
use crossbeam_channel::Receiver;
const ME: u64 = 0x10;
/// The unit selected before the script runs.
const OLD: u64 = 0x21;
/// `party1`, the unit the script selects.
const NEW: u64 = 0x22;
/// Not in the (absent) spell catalog, so the cast's wire target is the selection as it stands.
const HEAL: u32 = 2050;
struct Frame {
app: App,
rx: Receiver<ClientCommand>,
}
/// The whole client, headless, with us, `OLD` (selected when `selected`) and `NEW` as
/// `party1`, and a VM whose book holds Heal.
fn frame(selected: bool) -> Frame {
let mut app = crate::game_plugins::schedule_tests::headless_client();
let (tx, rx) = crossbeam_channel::unbounded();
app.insert_resource(NetCommands(tx));
let world = app.world_mut();
// Fields 22 health and 28 max health: every unit is alive.
let unit = |guid: u64, x: f32| {
(
Guid(guid),
Transform::from_xyz(x, 0.0, 0.0),
GlobalTransform::from_translation(Vec3::new(x, 0.0, 0.0)),
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(22, 100),
(28, 100),
])),
)
};
let me = world.spawn((SelfPlayer, unit(ME, 0.0))).id();
let old = world.spawn(unit(OLD, 5.0)).id();
let new = world.spawn(unit(NEW, 6.0)).id();
let index = &mut world.resource_mut::<GuidIndex>().0;
index.insert(ME, me);
index.insert(OLD, old);
index.insert(NEW, new);
world.resource_mut::<SelfGuid>().0 = Some(ME);
world
.resource_mut::<crate::ui_party::GroupState>()
.members
.push(GroupMemberEntry {
name: "Bob".into(),
guid: NEW,
status: 1,
flags: 0,
});
if selected {
*world.resource_mut::<Selection>() = Selection {
target: Some(old),
guid: Some(OLD),
..Default::default()
};
}
let mut script = UiScript::new().expect("a VM");
script.set_spellbook(SpellBookState {
tabs: vec![SpellTabView {
name: "Holy".into(),
texture: None,
offset: 0,
num_spells: 1,
}],
slots: vec![SpellSlotView {
spell_id: HEAL,
name: "Heal".into(),
..Default::default()
}],
});
if selected {
// The frame's unit snapshot, as the feed pushed it before the input pass.
script.set_unit(
"target",
Some(benilla_ui::script::UnitState {
exists: true,
..Default::default()
}),
);
}
world.insert_non_send_resource(script);
Frame { app, rx }
}
/// Run `lua` in the VM, then this frame's one application of what it queued.
fn run(f: &mut Frame, lua: &str) {
f.app
.world_mut()
.non_send_resource_mut::<UiScript>()
.run(lua)
.expect("the script runs");
f.app
.world_mut()
.run_system_once(apply_script_calls)
.expect("the calls apply as a one-shot system");
}
/// The `CMSG_CAST_SPELL`s sent, as `(spell, target)`.
fn casts(f: &Frame) -> Vec<(u32, Option<u64>)> {
f.rx.try_iter()
.filter_map(|c| match c {
ClientCommand::CastSpell { spell_id, target } => Some((spell_id, target)),
_ => None,
})
.collect()
}
fn selected(f: &Frame) -> Option<u64> {
f.app.world().resource::<Selection>().guid
}
/// `/target` then `/cast`: `TargetUnit` commits through `SetSelection 0x493540` before it
/// returns, and `CastSpellByName` (`0x4b4ab0`) casts at the selection as it stands then.
#[test]
fn a_cast_after_a_target_goes_to_the_new_target() {
let mut f = frame(true);
run(&mut f, r#"TargetUnit("party1") CastSpellByName("Heal")"#);
assert_eq!(casts(&f), vec![(HEAL, Some(NEW))]);
assert_eq!(selected(&f), Some(NEW));
}
/// `/cast` then `/target`: the cast is done before the selection moves.
#[test]
fn a_cast_before_a_target_goes_to_the_old_target() {
let mut f = frame(true);
run(&mut f, r#"CastSpellByName("Heal") TargetUnit("party1")"#);
assert_eq!(casts(&f), vec![(HEAL, Some(OLD))]);
assert_eq!(selected(&f), Some(NEW));
}
/// `ClearTarget` (`0x489ff0`) reads the selection at call time and deselects through
/// `0x493540(0,0)`: after `TargetUnit("player")` there is one to clear, with or without a
/// target before the script ran.
#[test]
fn a_clear_after_a_target_ends_with_nothing_selected() {
for selected_before in [true, false] {
let mut f = frame(selected_before);
run(&mut f, r#"TargetUnit("player") ClearTarget()"#);
assert_eq!(selected(&f), None, "selected before: {selected_before}");
let world = f.app.world();
assert_eq!(world.resource::<Selection>().target, None);
let sends: Vec<u64> =
f.rx.try_iter()
.filter_map(|c| match c {
ClientCommand::SetSelection { guid } => Some(guid),
_ => None,
})
.collect();
assert_eq!(sends, vec![ME, 0], "selected before: {selected_before}");
}
}
/// `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.
#[test]
fn a_macros_calls_land_inside_the_use_action_that_ran_it() {
let mut f = frame(true);
{
let world = f.app.world_mut();
let script = world.non_send_resource::<UiScript>();
// `ChatFrame1`'s `EXECUTE_CHAT_LINE` handler (`ChatFrame.lua:1343`) sends the line
// through the edit box; this one runs it as Lua, which is all the order needs.
script
.run(
r#"local f = CreateFrame("Frame")
f:RegisterEvent("EXECUTE_CHAT_LINE")
f:SetScript("OnEvent", function() RunScript(arg1) end)
MACRO_INDEX = CreateMacro("heal", 1, 'CastSpellByName("Heal")', 1, nil)"#,
)
.expect("the macro is made");
let index: u32 = script.eval("return MACRO_INDEX").expect("an index");
world
.resource_mut::<crate::ui_action::PlayerActions>()
.buttons
.insert(
0,
ActionButton {
slot: 0,
action: index,
kind: ACTION_KIND_MACRO,
},
);
}
run(&mut f, r#"UseAction(1) TargetUnit("party1")"#);
assert_eq!(casts(&f), vec![(HEAL, Some(OLD))]);
assert_eq!(selected(&f), Some(NEW));
}
}