World clicks, attack starts and /target follow 1.12.1's gates (#2117)

Clicks and attack starts passed gates 1.12.1 refuses at:

- A right press with a cursor payload went on to the world.
- StartAttack ran without control or while pacified.
- A charmed player's right-click selected and interacted.
- Talk ignored shapeshift forms.
- A plate right-click while targeting skipped the interact.
- A `/target` miss said nothing.
- `/follow` read the cast slot.

Reference: `0x7662c0` runs the press pre-hook `0x492b50` before any hit-test, which drops a payload and consumes the press. StartAttack returns on `0x20000` or lost control (`0x5ecbaa`, `0x5ecbc0`) for every caller. The router tests `CHARMEDBY` first (`0x481f98`), which plates skip (`0x4949f0`). Talk tests form flag `0x8` (`0x5f0150`). `0x489dbd` picks the miss message, and `/follow` reads `UNIT_CHANNEL_SPELL` (`0x60ffb9`).

Changed: each gate follows these, and tests pin each.

Fixes #321
Fixes #381
Fixes #785
Fixes #1305
Fixes #1397
Fixes #52
Fixes #1326

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-20 23:31:37 +07:00
committed by GitHub
parent 70432f0566
commit b3fbaa86d4
43 changed files with 1593 additions and 278 deletions
+63 -9
View File
@@ -196,21 +196,60 @@ pub(crate) fn stop_attack_local(engaged: bool, core: &mut crate::spell::CastCore
core.cancel_queued();
}
/// The local StartAttack, reference `0x5ecb70`: the swing send (`0x5eccfd`) is skipped while
/// attacking unless a stop is in flight (`[+0xc54]`, which the target switch passes as
/// `stop_in_flight`); the tail, melee sheath snap then auto-repeat cancel (`0x5ecd78`), always
/// runs, so melee and auto-shot are exclusive. Callers own the entry checks: no call from a cast
/// press while engaged (`0x6e51cb`), the attackability walk (`0x606980`) and `[0xb4b3e4]`.
/// What StartAttack (`0x5ecb70`) reads of the active player before it does anything: the pacified
/// bit of `UNIT_FIELD_FLAGS` (`0x5ecbaa`) and the player-control flag `[0xb4b3e4]` (`0x5ecbc0`)
/// each end it silently, with no stand (`0x5ecc9b`), select, swing, sheath or auto-repeat
/// cancel. Every caller reads it from its own state ([`StartGate::of`]) and hands it to
/// [`start_attack_local`]; a caller that selects first asks [`StartGate::refuses`] ahead of the
/// select (`0x5eccaa`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct StartGate {
pacified: bool,
control_lost: bool,
}
impl StartGate {
/// The gate from the player's descriptor and `OwnControlLost`.
pub(crate) fn of(own: Option<&crate::net::ObjectStore>, control_lost: bool) -> Self {
Self {
pacified: own.is_some_and(|me| {
me.0.unit_flags() & crate::target::cursor_mode::UNIT_FLAG_PACIFIED != 0
}),
control_lost,
}
}
/// StartAttack returns before anything.
pub(crate) fn refuses(self) -> bool {
self.pacified || self.control_lost
}
/// `[0xb4b3e4]` is set: the Attack toggle's stop arm needs it too (`0x6131ce`).
pub(crate) fn has_control(self) -> bool {
!self.control_lost
}
}
/// The local StartAttack, reference `0x5ecb70`: it returns first when [`StartGate::refuses`];
/// the swing send (`0x5eccfd`) is skipped while attacking unless a stop is in flight (`[+0xc54]`,
/// which the target switch passes as `stop_in_flight`); the tail, melee sheath snap then
/// auto-repeat cancel (`0x5ecd78`), always runs, so melee and auto-shot are exclusive. Callers own
/// the other entry checks: no call from a cast press while engaged (`0x6e51cb`) and the
/// attackability walk (`0x606980`).
pub(crate) fn start_attack_local(
entity: Entity,
target: u64,
engaged: bool,
stop_in_flight: bool,
gate: StartGate,
auto_repeat: &mut crate::spell::AutoRepeatActive,
sheath: &mut MessageWriter<SheathRequest>,
commands: &mut Commands,
net: &crate::net::NetCommands,
) {
if gate.refuses() {
return;
}
// Starting an attack stands you up (`0x5ecc9b`), after the attackability check but before
// the range logic and the already-attacking skip, so a re-issued or out-of-reach attack
// stands you too.
@@ -230,19 +269,20 @@ pub(crate) fn start_attack_local(
}
/// The Attack toggle, reference `0x6131a0` (the Attack pseudo-spell, `0x6e4c7a`): stop when
/// attacking, else start. Only the start arm cancels auto-repeat, so toggling melee off leaves
/// Auto Shot running. The stop arm's `[0xb4b3e4]` world gate is not modelled.
/// attacking with control (`0x6131ce`), else start, which returns at a clear `[0xb4b3e4]` too. Only
/// the start arm cancels auto-repeat, so toggling melee off leaves Auto Shot running.
pub(crate) fn toggle_attack_local(
entity: Entity,
target: u64,
engaged: bool,
gate: StartGate,
slots: &mut crate::spell::CastSlots,
auto_repeat: &mut crate::spell::AutoRepeatActive,
sheath: &mut MessageWriter<SheathRequest>,
commands: &mut Commands,
net: &crate::net::NetCommands,
) {
if engaged {
if engaged && gate.has_control() {
stop_attack_local(engaged, &mut slots.core(net));
} else {
start_attack_local(
@@ -250,6 +290,7 @@ pub(crate) fn toggle_attack_local(
target,
engaged,
false,
gate,
auto_repeat,
sheath,
commands,
@@ -271,8 +312,10 @@ pub(crate) struct AttackSeam<'w, 's> {
pub(crate) ecs: Commands<'w, 's>,
/// Our own entity.
pub(crate) me: Query<'w, 's, Entity, With<crate::net::SelfPlayer>>,
/// Our own descriptor, for [`Self::player_is_charmed`].
/// Our own descriptor, for [`Self::player_is_charmed`] and [`Self::start_gate`].
self_store: Query<'w, 's, &'static crate::net::ObjectStore, With<crate::net::SelfPlayer>>,
/// `[0xb4b3e4]`, for [`Self::start_gate`].
own_control: Option<Res<'w, crate::player::OwnControlLost>>,
/// The open loot, which the selection teardown closes when it loots the outgoing target.
pub(crate) loot: ResMut<'w, crate::ui_loot::LootState>,
/// The loot latch: the teardown's close drops it, and the right-click loot legs arm it.
@@ -290,6 +333,14 @@ impl AttackSeam<'_, '_> {
.is_some_and(|me| me.0.unit_charmed_by().is_some())
}
/// StartAttack's two silent returns about the player (`0x5ecbaa`, `0x5ecbc0`).
pub(crate) fn start_gate(&self) -> StartGate {
StartGate::of(
self.self_store.iter().next(),
self.own_control.as_deref().is_some_and(|c| c.0),
)
}
/// The selection teardown's loot close (`0x493910`, `0x493959`–`0x493974`): a window looting
/// the outgoing selection closes as `CloseLoot` does, its release ahead of anything the new
/// selection sends. The close's dead-unit deselect (`0x48f369`) targets the selection already
@@ -319,11 +370,13 @@ impl AttackSeam<'_, '_> {
/// StartAttack (`0x5ecb70`); a no-op before our own entity streams in.
pub(crate) fn start(&mut self, target: u64, engaged: bool, stop_in_flight: bool) {
let Ok(e) = self.me.single() else { return };
let gate = self.start_gate();
start_attack_local(
e,
target,
engaged,
stop_in_flight,
gate,
&mut self.auto_repeat,
&mut self.sheath,
&mut self.ecs,
@@ -950,6 +1003,7 @@ mod attack_stand_tests {
0xdead_beef,
true, // already engaged: the skip leg
false,
StartGate::default(),
&mut auto_repeat,
&mut sheath,
&mut commands,
+56 -5
View File
@@ -790,7 +790,8 @@ pub(crate) mod schedule_tests {
/// - the area-spirit-healer poll over `Transform` and `NetEntity`: it is level-triggered, so a
/// unit seen a frame early or late is decided again next frame;
/// - the cursor classifier against `world_focus`'s focus publish and settle release over
/// `Player`: it reads `own_control_lost`, and they write only `settling` and `world_stale`;
/// `Player`: it reads `control_lost` and `foreign_mover`, and they write only `settling` and
/// `world_stale`;
/// - `ui_quest::lines::feed_quest_lines` and the reader's begin and feed
/// (`ui_item_text::begin_item_text`, `feed_item_text`) against `ui_items::feed_item_stats`
/// and `ui_tooltip::feed_spell_tooltips` over `Items` and the VM: they only read templates,
@@ -833,9 +834,6 @@ pub(crate) mod schedule_tests {
/// `normalize_action_ranks` over `PlayerActions` and the VM: it reads only the book's spell
/// ids, which those two never rewrite, and writes the lock state alone; its gate watches
/// `PlayerActions`, so a book change seen a frame late is pushed the next;
/// - the item gate's control-bypass feed (`ui_unit::control_gate::feed_control_bypass`)
/// against `player::camera_saved::load_camera_pose` over `Player`: the feed reads
/// `own_control_lost` only, and the loader writes the saved camera pose;
/// - the terrain stream's spawn half (`terrain_stream::spawn_streamed_tiles`) against the
/// readers of `TerrainStreamer`, `Placements` and the asset stores its request half
/// (`stream_terrain`) already pairs with: the inner-window wait sits between the two, so a
@@ -854,7 +852,7 @@ pub(crate) mod schedule_tests {
/// Raising the ceiling is a claim that a new undeclared order is acceptable: make it with the
/// reason read off the dump, or declare the order (`.after`, a set, a `chain`). A resource
/// that commutes by construction belongs in [`Classes`].
const UPDATE_ACTIONABLE_CEILING: usize = 3_090;
const UPDATE_ACTIONABLE_CEILING: usize = 3_079;
const UPDATE_ACTIONABLE_SLACK: usize = 40;
fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) {
@@ -1289,6 +1287,59 @@ pub(crate) mod schedule_tests {
assert!(stale.is_empty(), "stale rows:\n {}", stale.join("\n "));
}
/// A nameplate's right-click is `SelectTarget` (`0x492870`), which with a targeting cursor up
/// binds the cast and returns, and then the unit's interact (`0x492881`), so the cast goes out
/// ahead of what the interact does. The click's select parks the unit; the script calls'
/// pass binds it at its head; the interact runs after that, or its attack leg would swing
/// before the cast and select nothing.
#[test]
fn a_plates_interact_runs_after_the_bind_of_the_unit_its_click_parked() {
let mut app = headless_client();
let c = census(&mut app, Update, SyncPoints::Declared);
if !type_names_available(&c) {
eprintln!(
"skipped: this build carries no type names (bevy/debug rides with the dev feature)"
);
return;
}
let one = |suffix: &str| {
let mut hits = c.systems.iter().filter(|(_, s)| s.name.ends_with(suffix));
match (hits.next(), hits.next()) {
(Some((k, _)), None) => *k,
_ => panic!("exactly one system named `…{suffix}` in Update"),
}
};
let (select, bind, interact) = (
one("::target::click::select_on_plate_click"),
one("::script_calls::apply_script_calls"),
one("::target::click::act_on_interact"),
);
let mut succ: HashMap<SystemKey, Vec<SystemKey>> = HashMap::new();
for (a, b) in &c.dependencies {
succ.entry(*a).or_default().push(*b);
}
let runs_before = |from: SystemKey, to: SystemKey| {
let mut seen = HashSet::new();
let mut stack = vec![from];
while let Some(n) = stack.pop() {
for m in succ.get(&n).into_iter().flatten() {
if seen.insert(*m) {
stack.push(*m);
}
}
}
seen.contains(&to)
};
assert!(
runs_before(select, bind),
"the click parks before the pass binds"
);
assert!(
runs_before(bind, interact),
"the interact runs after the pass's bind"
);
}
/// The `Startup` VM, the stock glue's, is built after the glyph cache:
/// its first screen sizes frames by measured text in the show (`TOSFrameHeader` off
/// `TOSFrameTitle:GetWidth()`, `AccountLogin.lua`), which with no cache measures 0.
+2 -1
View File
@@ -101,7 +101,7 @@ use state::{
WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES,
};
pub(crate) use state::{
Player, PlayerCapsule, CAPSULE_HEIGHT, CAPSULE_RADIUS, CREATURE_STEP_UP_HEIGHT,
OwnControlLost, Player, PlayerCapsule, CAPSULE_HEIGHT, CAPSULE_RADIUS, CREATURE_STEP_UP_HEIGHT,
DEFAULT_COLLISION_HEIGHT, FEATHER_TERMINAL_VELOCITY, GRAVITY, HOVER_CLIMB_RATE, HOVER_HEIGHT,
SETTLE_TIMEOUT, STEP_UP_HEIGHT, TERMINAL_VELOCITY,
};
@@ -244,6 +244,7 @@ impl Plugin for PlayerPlugin {
app.init_resource::<camera::LookConfig>();
app.init_resource::<camera_zoom::ZoomLimit>();
app.init_resource::<camera::FollowConfig>();
app.init_resource::<OwnControlLost>();
app.init_resource::<camera_dynamics::CameraOptions>();
// Far sight resolves `PLAYER_FARSIGHT` to a pose before `control` seats the camera: a
// separate system, as `control` holds our `Transform` mutably and cannot read another's.
+2 -2
View File
@@ -694,8 +694,8 @@ pub(super) fn latch_world_mouse(
// ground-targeting veto (`+0x3c`, `0x7cba30`) arrives through `PointerOverUi`
// (`UiScript::set_nameplate_hit_test_veto`). The wheel still zooms over a plate.
pointer_over_ui: Res<crate::ui_script::PointerOverUi>,
// A press the UI consumed with nothing hovered, the layout drag's: no frame saw it, the
// world's included (`0x766353`).
// A press the UI consumed with nothing hovered, the layout drag's or a right press that
// dropped a payload: no frame saw it, the world's included (`0x766353`, `0x766370`).
press_consumed: Option<Res<crate::ui_script::PlayerUiPressConsumed>>,
mut rig: ResMut<CameraControl>,
cameras: Query<&Camera, With<FlyCam>>,
+4 -1
View File
@@ -49,7 +49,7 @@ pub(super) fn control(
// `SMSG_STANDSTATE_UPDATE`, applied locally and ungated, with nothing sent back.
MessageReader<super::ServerStandState>,
),
speed_capsule: (
mut speed_capsule: (
Res<MoveSpeed>,
Res<PlayerCapsule>,
Res<InspectMode>,
@@ -64,6 +64,8 @@ pub(super) fn control(
Res<scoped_view::ScopedView>,
// The loading cover blanks input at the source; a gesture in flight is unwound below.
Res<crate::loading_screen::LoadingScreen>,
// Our own `[0xb4b3e4]`, which a control verdict on our character writes.
ResMut<super::OwnControlLost>,
),
mut commands: Commands,
mut player: ResMut<Player>,
@@ -281,6 +283,7 @@ pub(super) fn control(
&time,
&mut commands,
&mut player,
&mut speed_capsule.10,
&mut cam,
&net.0,
&mut net.1,
+8 -5
View File
@@ -285,6 +285,14 @@ pub(crate) fn incapacitated_flags(flags: u32, translate_gated: bool, turn_gated:
out
}
/// The local player's own control, the reference's `[0xb4b3e4]` cleared (`0x4958e0`): written by
/// `0x5fa600` only when the named unit is our own character (`0x5fa6d0`), so a possessed
/// creature's loss ([`Player::control_lost`] on a released guid) never reaches it. The item and
/// cursor gates, StartAttack, `HasFullControl` and `PLAYER_CONTROL_LOST` read this one. A resource
/// of its own, so its many readers do not order against every other writer of [`Player`].
#[derive(Resource, Default, Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct OwnControlLost(pub(crate) bool);
/// Our avatar: until `active` the camera free-flies, then we drive the body the server placed.
/// `PartialEq` serves the test that the session end resets the whole of it to `Player::default()`.
#[derive(Resource, Default, PartialEq)]
@@ -312,11 +320,6 @@ pub(crate) struct Player {
/// standing. The reference zeroes its mover; vmangos neither roots a charmed player nor refuses
/// its movement, so nothing else holds the body. Unlike a root, it stops turning too.
pub(crate) control_lost: bool,
/// The local player's own control, the reference's `[0xb4b3e4]` cleared (`0x4958e0`): written
/// by `0x5fa600` only when the named unit is our own character (`0x5fa6d0`), so a possessed
/// creature's loss ([`Self::control_lost`] on a released guid) never reaches it. The item and
/// cursor gates, `HasFullControl` and `PLAYER_CONTROL_LOST` read this one.
pub(crate) own_control_lost: bool,
/// The unit we drive in place of our body (a mind-controlled creature, Eye of Kilrogg), `None`
/// for our own. Our body must then send nothing: outbound `MSG_MOVE_*` carry no guid, so the
/// server applies them to whatever we last claimed.
+56 -26
View File
@@ -19,7 +19,7 @@ use crate::transport::Transport;
use benilla_world::world_map::CurrentMap;
use super::camera::FlyCam;
use super::{movement_net, Player, SETTLE_TIMEOUT};
use super::{movement_net, OwnControlLost, Player, SETTLE_TIMEOUT};
/// What one `SMSG_CLIENT_CONTROL_UPDATE` means for us. It says whether a unit may move, not who the
/// mover is: the server revokes a mind-controlled victim's control by naming the victim.
@@ -66,17 +66,17 @@ fn yield_own_body(net_cmds: &NetCommands, player: &mut Player, self_guid: Option
/// The control flags a verdict sets. `control_lost` is the mover we drive being unable to move:
/// our body revoked, or our possessed creature released while feared or confused (the possession
/// standing). `own_control_lost` is only `[0xb4b3e4]` (`0x4958e0`), which `0x5fa600` writes for our
/// own character alone (`0x5fa6d0`): a grant or a release naming another unit never touches it.
fn track_control(player: &mut Player, verdict: ControlVerdict) {
/// standing). `own` is only `[0xb4b3e4]` (`0x4958e0`), which `0x5fa600` writes for our own
/// character alone (`0x5fa6d0`): a grant or a release naming another unit never touches it.
fn track_control(player: &mut Player, own: &mut OwnControlLost, verdict: ControlVerdict) {
match verdict {
ControlVerdict::Revoked => {
player.control_lost = true;
player.own_control_lost = true;
own.0 = true;
}
ControlVerdict::Restored => {
player.control_lost = false;
player.own_control_lost = false;
own.0 = false;
}
ControlVerdict::Granted(_) => player.control_lost = false,
ControlVerdict::Released(guid) => {
@@ -106,6 +106,7 @@ pub(super) fn apply_server_moves(
time: &Time,
commands: &mut Commands,
player: &mut Player,
own_control: &mut OwnControlLost,
cam: &mut FlyCam,
net_cmds: &NetCommands,
teleports: &mut MessageReader<TeleportMessage>,
@@ -126,7 +127,7 @@ pub(super) fn apply_server_moves(
// (`MovementHandler.cpp:291-293`, `Player.cpp:20257-20272`).
for c in control_msgs.read() {
let verdict = control_verdict(c.mover, c.allow_move, self_guid);
track_control(player, verdict);
track_control(player, own_control, verdict);
match verdict {
// Park, which stops the server extrapolating a run, and say nothing else: the mover has
// not changed hands, and `CMSG_MOVE_NOT_ACTIVE_MOVER` would strand us.
@@ -507,12 +508,13 @@ fn apply_self_move(
pub(crate) fn regain_control_on_arrival(
mut arrivals: MessageReader<crate::net::WorldEnterCascadeMessage>,
mut player: ResMut<Player>,
mut own_control: ResMut<OwnControlLost>,
) {
if arrivals.read().next().is_none() {
return;
}
if player.own_control_lost || player.control_lost || player.foreign_mover.is_some() {
player.own_control_lost = false;
if own_control.0 || player.control_lost || player.foreign_mover.is_some() {
own_control.0 = false;
player.control_lost = false;
// The mover is ours again, as `ControlVerdict::Restored` seats it.
player.foreign_mover = None;
@@ -528,11 +530,13 @@ pub(super) fn release_on_session_end(
mut logouts: MessageReader<crate::net::LoggedOutMessage>,
mut lost: MessageReader<crate::net::DisconnectedMessage>,
mut player: ResMut<Player>,
mut own_control: ResMut<OwnControlLost>,
) {
// `|`, not `||`: both readers must drain, or the unread one carries into the next frame.
if logouts.read().next().is_some() | lost.read().any(|m| m.session_over) {
// vmangos re-sends the modes the new session holds (`Player.cpp:19297-19311`).
*player = Player::default();
*own_control = OwnControlLost::default();
}
}
@@ -546,6 +550,7 @@ mod session_end_tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<Player>()
.init_resource::<OwnControlLost>()
.add_message::<LoggedOutMessage>()
.add_message::<DisconnectedMessage>()
.add_systems(Update, release_on_session_end);
@@ -554,6 +559,7 @@ mod session_end_tests {
/// A session that ran: in world, moving, every mode granted, the reins in other hands.
fn a_session_that_ran(app: &mut App) {
app.world_mut().resource_mut::<OwnControlLost>().0 = true;
let mut p = app.world_mut().resource_mut::<Player>();
p.active = true;
p.modes = super::super::state::MoveModes {
@@ -596,6 +602,10 @@ mod session_end_tests {
"and nothing else survives either: the session boundary returns the resource to the \
state `player::setup` inserts at boot"
);
assert!(
!app.world().resource::<OwnControlLost>().0,
"`[0xb4b3e4]` is back at its boot value, in control (`0x48f626`)"
);
}
/// A lost session is a different message on a different reader; the `|` draining both matters.
@@ -611,6 +621,7 @@ mod session_end_tests {
app.update();
assert!(*app.world().resource::<Player>() == Player::default());
assert!(!app.world().resource::<OwnControlLost>().0);
}
/// `session_over: false` (the logout's own teardown, a seamless reconnect) keeps the body ours.
@@ -763,17 +774,25 @@ mod control_tests {
/// cursor gates still see control.
#[test]
fn releasing_a_possessed_creature_leaves_our_own_control_alone() {
let mut player = Player::default();
track_control(&mut player, control_verdict(CREATURE, true, Some(ME)));
let (mut player, mut own) = (Player::default(), OwnControlLost::default());
track_control(
&mut player,
&mut own,
control_verdict(CREATURE, true, Some(ME)),
);
assert!(!player.control_lost);
player.foreign_mover = Some(CREATURE);
track_control(&mut player, control_verdict(CREATURE, false, Some(ME)));
track_control(
&mut player,
&mut own,
control_verdict(CREATURE, false, Some(ME)),
);
assert!(player.control_lost, "the creature we drive is stuck");
assert!(!player.own_control_lost, "our own control stands");
track_control(&mut player, control_verdict(ME, false, Some(ME)));
assert!(player.own_control_lost);
track_control(&mut player, control_verdict(ME, true, Some(ME)));
assert!(!player.own_control_lost && !player.control_lost);
assert!(!own.0, "our own control stands");
track_control(&mut player, &mut own, control_verdict(ME, false, Some(ME)));
assert!(own.0);
track_control(&mut player, &mut own, control_verdict(ME, true, Some(ME)));
assert!(!own.0 && !player.control_lost);
}
#[test]
@@ -826,10 +845,19 @@ mod arrival_tests {
const ME: u64 = 0x45;
/// The control-0 packet naming us.
fn lose_control(app: &mut App) {
let mut own = std::mem::take(&mut *app.world_mut().resource_mut::<OwnControlLost>());
let mut player = app.world_mut().resource_mut::<Player>();
track_control(&mut player, &mut own, control_verdict(ME, false, Some(ME)));
*app.world_mut().resource_mut::<OwnControlLost>() = own;
}
fn harness() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<Player>()
.init_resource::<OwnControlLost>()
.add_message::<WorldEnterCascadeMessage>()
.add_systems(Update, regain_control_on_arrival);
app
@@ -841,19 +869,22 @@ mod arrival_tests {
#[test]
fn a_control_loss_does_not_survive_the_next_self_create() {
let mut app = harness();
let mut player = app.world_mut().resource_mut::<Player>();
track_control(&mut player, control_verdict(ME, false, Some(ME)));
assert!(player.control_lost && player.own_control_lost);
lose_control(&mut app);
assert!(app.world().resource::<Player>().control_lost);
assert!(app.world().resource::<OwnControlLost>().0);
app.update();
assert!(
app.world().resource::<Player>().own_control_lost,
app.world().resource::<OwnControlLost>().0,
"nothing restores it without a create"
);
app.world_mut().write_message(WorldEnterCascadeMessage);
app.update();
assert!(
!app.world().resource::<OwnControlLost>().0,
"the bar's flag is back"
);
let player = app.world().resource::<Player>();
assert!(!player.own_control_lost, "the bar's flag is back");
assert!(!player.control_lost, "and the controller's movement lock");
assert_eq!(player.foreign_mover, None);
}
@@ -869,16 +900,15 @@ mod arrival_tests {
Update,
crate::net::enter_world_on_self_create.before(regain_control_on_arrival),
);
let mut player = app.world_mut().resource_mut::<Player>();
track_control(&mut player, control_verdict(ME, false, Some(ME)));
lose_control(&mut app);
app.update();
assert!(app.world().resource::<Player>().own_control_lost);
assert!(app.world().resource::<OwnControlLost>().0);
app.world_mut()
.spawn((crate::net::Guid(ME), crate::net::SelfPlayer));
app.update();
let player = app.world().resource::<Player>();
assert!(!player.own_control_lost && !player.control_lost);
assert!(!app.world().resource::<OwnControlLost>().0 && !player.control_lost);
}
/// A possession does not outlive the world either: the create claims our own mover.
+80
View File
@@ -794,6 +794,25 @@ mod tests {
);
}
/// `TargetByName` (`0x489d60`) on a miss leaves the target alone and shows message `0x127`
/// (`ERR_UNIT_NOT_FOUND`) for a name given (`0x489dbd`, tested on the raw string's first byte,
/// so blanks count) or `0xb8` (`ERR_GENERIC_NO_TARGET`) for an empty one (`0x489dcf`), inside
/// the call.
#[test]
fn target_by_name_that_finds_nobody_shows_the_references_line() {
for (name, line) in [
("Nobody", "ERR_UNIT_NOT_FOUND"),
(" ", "ERR_UNIT_NOT_FOUND"),
("", "ERR_GENERIC_NO_TARGET"),
] {
let mut f = frame(true);
run(&mut f, &format!("TargetByName(\"{name}\")"));
assert_eq!(errors(&f), vec![line], "{name:?}");
assert_eq!(selected(&f), Some(OLD), "{name:?}: the target stays");
assert_eq!(f.rx.try_iter().count(), 0, "{name:?}: nothing is sent");
}
}
/// Two namesakes: the ranking decides, and the chunk shows the one the commit then selects --
/// the nearer, and of two at one distance the lower guid, whatever order either side walks.
#[test]
@@ -1109,6 +1128,67 @@ mod tests {
}
}
/// `0x6131a0` stops a running attack only with `[0xb4b3e4]` set (`0x6131ce`); without control
/// it falls to the start (`0x5ecb70`), which returns at the same flag (`0x5ecbc0`) before the
/// stand, the swing and the sheath: the press neither stops nor swings.
#[test]
fn attack_without_control_neither_stops_nor_swings() {
for engaged in [true, false] {
let mut f = frame(true);
with_factions(&mut f, FOE, &[(HEALTH, 100)]);
let world = f.app.world_mut();
world.init_resource::<crate::player::Player>();
world.resource_mut::<crate::player::OwnControlLost>().0 = true;
if engaged {
let me = world.resource::<GuidIndex>().0[&ME];
world
.entity_mut(me)
.insert(crate::creature_anim::Engaged(OLD));
}
run(&mut f, "AttackTarget()");
assert_eq!(swings(&f), (vec![], false), "engaged {engaged}");
}
}
/// TryCast's tail starts the swing of a cast that initiates combat through `0x6131a0`
/// (`0x6e51b5`), whose start returns at a clear `[0xb4b3e4]`: the cast goes out, the swing
/// does not.
#[test]
fn an_on_next_swing_cast_without_control_sends_no_swing() {
const STRIKE: u32 = 78;
for lost in [false, true] {
let mut f = frame(true);
with_factions(&mut f, FOE, &[(HEALTH, 100)]);
with_book_spell(
&mut f,
STRIKE,
"Strike",
benilla_formats::SpellDisplay {
targets: 0x2,
attributes: 0x404,
..Default::default()
},
);
let world = f.app.world_mut();
world.init_resource::<crate::player::Player>();
world.resource_mut::<crate::player::OwnControlLost>().0 = lost;
run(&mut f, r#"CastSpellByName("Strike")"#);
let sent: Vec<_> = f.rx.try_iter().collect();
assert!(
sent.iter().any(
|c| matches!(c, ClientCommand::CastSpell { spell_id, .. } if *spell_id == STRIKE)
),
"lost {lost}: the cast goes out: {sent:?}"
);
assert_eq!(
sent.iter()
.any(|c| matches!(c, ClientCommand::AttackSwing { guid } if *guid == OLD)),
!lost,
"lost {lost}: {sent:?}"
);
}
}
/// 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.
@@ -959,6 +959,10 @@ fn send_spell_cast(
guid,
engaged,
false,
crate::creature_anim::StartGate::of(
ctx.rel.self_store,
ctx.self_control_lost,
),
auto_repeat,
sheath,
ecs,
@@ -1012,6 +1016,7 @@ mod tests {
range: cast_target::RangeInputs::default(),
main_hand_item: None,
self_move_flags: 0,
self_control_lost: false,
}
}
@@ -220,6 +220,9 @@ pub(crate) struct CastContext<'a> {
/// The caster's movement flags (the client's `[unit+0x9e8]`), read by the requirement
/// validator's moving gate.
pub(crate) self_move_flags: u32,
/// `[0xb4b3e4]` is clear: the start of melee the cast's tail makes returns at it
/// (`0x5ecbc0`).
pub(crate) self_control_lost: bool,
}
impl CastContext<'_> {
@@ -302,6 +305,8 @@ pub(crate) struct CastTargeting<'w, 's> {
/// `Option`: the body exists only in world, and the cast-result handler must fetch this param
/// anywhere. With no body the moving gate passes and the server decides.
player: Option<Res<'w, crate::player::Player>>,
/// `[0xb4b3e4]`, for the cast tail's start of melee.
own_control: Option<Res<'w, crate::player::OwnControlLost>>,
/// The caster's and the selection's reach and motion, for the range gate.
range_units: super::RangeUnits<'w, 's>,
}
@@ -363,6 +368,7 @@ impl CastTargeting<'_, '_> {
.and_then(|s| s.0.player_inv_slot(EQUIPMENT_SLOT_MAINHAND))
.filter(|&g| g != 0),
self_move_flags: self.player.as_deref().map_or(0, |p| p.move_flags()),
self_control_lost: self.own_control.as_deref().is_some_and(|c| c.0),
}
}
}
+1
View File
@@ -61,6 +61,7 @@ struct PendingCastState {
impl PendingCast {
/// A cast is outstanding: the next press meets `0x6e4d97`'s refusal.
#[cfg(test)]
pub(crate) fn in_flight(&self) -> bool {
self.current().is_some()
}
+50 -11
View File
@@ -87,6 +87,8 @@ pub(crate) struct Scene<'w, 's> {
go_targets: MessageWriter<'w, SpellGoTargets>,
go_lid: MessageWriter<'w, crate::go_anim::GoLidOpen>,
sheaths: MessageWriter<'w, crate::creature_anim::SheathRequest>,
/// `[0xb4b3e4]`, for the GO-deferred swing's gate.
own_control: Option<Res<'w, crate::player::OwnControlLost>>,
}
/// `SMSG_INITIAL_SPELLS`, once at login, into the action store the UI feed reads.
@@ -253,6 +255,15 @@ fn on_spell_go(In(ev): In<SessionEvent>, mut l: Lifecycle, mut sc: Scene) {
} = ev
{
let engaged = !sc.engaged_self.is_empty();
let own = l
.self_guid
.0
.and_then(|guid| l.index.0.get(&guid))
.and_then(|&entity| sc.stores.get(entity).ok());
let start_gate = crate::creature_anim::StartGate::of(
own,
sc.own_control.as_deref().is_some_and(|c| c.0),
);
spell_go(
caster,
spell_id,
@@ -281,7 +292,7 @@ fn on_spell_go(In(ev): In<SessionEvent>, mut l: Lifecycle, mut sc: Scene) {
&mut l.pet_bar,
&l.spell_mods,
),
(&mut l.auto_repeat, &mut sc.sheaths, engaged),
(&mut l.auto_repeat, &mut sc.sheaths, engaged, start_gate),
l.play_seq.next(),
);
}
@@ -842,12 +853,14 @@ fn spell_go(
&mut crate::ui_pet::PetBar,
&crate::spell::SpellModifiers,
),
// The GO-deferred auto-attack start's writes (`0x6e83c0`) and the attack lock it gates on
// (`Engaged`, the reference's `[player+0xc48]`).
// The GO-deferred auto-attack start's writes (`0x6e83c0`), the attack lock it gates on
// (`Engaged`, the reference's `[player+0xc48]`) and StartAttack's own gate (`0x5ecbaa`,
// `0x5ecbc0`).
attack_ctx: (
&mut crate::spell::AutoRepeatActive,
&mut MessageWriter<crate::creature_anim::SheathRequest>,
bool,
crate::creature_anim::StartGate,
),
seq: u64,
) {
@@ -878,7 +891,7 @@ fn spell_go(
go_lid.write(crate::go_anim::GoLidOpen { go_guid, spell_id });
}
let (cooldowns, spells, items, net_commands, pet_bar, spell_mods) = cooldown_ctx;
let (auto_repeat, sheath, engaged) = attack_ctx;
let (auto_repeat, sheath, engaged, start_gate) = attack_ctx;
let now = Instant::now();
let display = spells.and_then(|s| s.catalog.get(spell_id));
// A chest becomes the loot target at this packet, not at the loot response: `HandleSpellGo
@@ -930,6 +943,7 @@ fn spell_go(
guid,
engaged,
false,
start_gate,
auto_repeat,
sheath,
commands,
@@ -1844,6 +1858,7 @@ mod tests {
&mut crate::spell::AutoRepeatActive::default(),
&mut sheath,
false,
crate::creature_anim::StartGate::default(),
),
1,
);
@@ -2511,6 +2526,7 @@ mod tests {
&mut crate::spell::AutoRepeatActive::default(),
&mut sheath,
false,
crate::creature_anim::StartGate::default(),
),
1,
);
@@ -2710,6 +2726,7 @@ mod tests {
&mut crate::spell::AutoRepeatActive::default(),
&mut sheath,
false,
crate::creature_anim::StartGate::default(),
),
1,
);
@@ -2996,9 +3013,13 @@ mod tests {
radii: Default::default(),
};
// `caster` casts `spell_id`, landing on 20; `engaged` is our mirror of `[+0xc48]`.
// Returns every command the seam put on the wire.
let fire = |caster: u64, spell_id: u32, engaged: bool| -> Vec<ClientCommand> {
// `caster` casts `spell_id`, landing on 20; `engaged` is our mirror of `[+0xc48]`, `gate`
// StartAttack's own. Returns every command the seam put on the wire.
let fire = |caster: u64,
spell_id: u32,
engaged: bool,
gate: crate::creature_anim::StartGate|
-> Vec<ClientCommand> {
let mut app = App::new();
app.add_message::<CastEvent>()
.add_message::<SpellGoTargets>()
@@ -3083,6 +3104,7 @@ mod tests {
&mut crate::spell::AutoRepeatActive::default(),
&mut sheath,
engaged,
gate,
),
1,
);
@@ -3101,23 +3123,40 @@ mod tests {
.collect::<Vec<_>>()
};
use crate::creature_anim::StartGate;
let open = StartGate::default();
assert_eq!(
swings(&fire(10, BACKSTAB, false)),
swings(&fire(10, BACKSTAB, false, open)),
vec![20],
"a bit20 spell's own GO starts the swing at its first hit target"
);
assert!(
swings(&fire(10, SERPENT_STING, false)).is_empty(),
swings(&fire(10, SERPENT_STING, false, open)).is_empty(),
"Serpent Sting carries Ex2 bit 17, not bit 20 — it starts nothing"
);
assert!(
swings(&fire(10, BACKSTAB, true)).is_empty(),
swings(&fire(10, BACKSTAB, true, open)).is_empty(),
"already swinging: `0x6e83e7`'s attack-lock gate refuses"
);
assert!(
swings(&fire(11, BACKSTAB, false)).is_empty(),
swings(&fire(11, BACKSTAB, false, open)).is_empty(),
"somebody else's Backstab is not our attack-start"
);
let pacified = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(
46, 0x2_0000,
)]));
for (gate, why) in [
(StartGate::of(None, true), "without control (`0x5ecbc0`)"),
(
StartGate::of(Some(&pacified), false),
"pacified (`0x5ecbaa`)",
),
] {
assert!(
swings(&fire(10, BACKSTAB, false, gate)).is_empty(),
"StartAttack returns {why}"
);
}
}
/// The `modalNextSpell` chain (`HandleCastResult 0x6e7330`, `0x6e7408`-`0x6e74aa`):
/// - a successful sting chains Auto Shot (`0x6e735a jne` past the report to `0x6e73eb`);
+70 -13
View File
@@ -100,7 +100,7 @@ pub(crate) struct AssistRequest {
/// | `CanCooperate` with the followee (`0x606ba0`) | `0x60ff6a` | `0x128`, the same line |
/// | we are alive | `0x60ff7c` | `0x7e` `ERR_PLAYER_DEAD` |
/// | we are not stunned (`UNIT_FIELD_FLAGS & 0x40000`) | `0x60ff95` | `0x191` `ERR_GENERIC_STUNNED` |
/// | we are not casting | `0x60ffb9` | `0x134` `ERR_TOOBUSYTOFOLLOW` |
/// | we are not channelling (`UNIT_CHANNEL_SPELL`, the unit block's `+0x228`) | `0x60ffb9` | `0x134` `ERR_TOOBUSYTOFOLLOW` |
///
/// There is no followee-alive check (the per-tick death test ends a follow) and no distance check:
/// follow's row in `0x860a58` has a `0.0` threshold, so `ERR_AUTOFOLLOW_TOO_FAR` (`0x6110a0`)
@@ -110,7 +110,7 @@ fn follow_refusal_key(
we_cooperate: bool,
we_are_dead: bool,
we_are_stunned: bool,
we_are_casting: bool,
we_are_channelling: bool,
) -> Option<&'static str> {
if !followee_is_player || !we_cooperate {
return Some("ERR_INVALID_FOLLOW_TARGET");
@@ -121,7 +121,7 @@ fn follow_refusal_key(
if we_are_stunned {
return Some("ERR_GENERIC_STUNNED");
}
if we_are_casting {
if we_are_channelling {
return Some("ERR_TOOBUSYTOFOLLOW");
}
None
@@ -191,13 +191,14 @@ impl ByNameScan<'_, '_> {
///
/// The gate's two other silent arms, a `this` that is not the active mover (`0x60fef7`) and a
/// followee with no object (`0x60ff25`), never fire from these callers: both found those
/// objects first.
/// objects first. Its channel test reads the mover's own `UNIT_CHANNEL_SPELL` (`0x60ffb9`),
/// not the cast slot: a hard cast in flight does not refuse, a channel does for as long as it
/// lasts.
fn follow_refusal(
&self,
mover: Option<(u64, &ObjectStore)>,
followee: Option<(u64, &ObjectStore)>,
missing: &'static str,
casting: bool,
) -> Option<FollowRefusal> {
let Some((guid, store)) = followee else {
return Some(FollowRefusal::Line(missing));
@@ -219,7 +220,7 @@ impl ByNameScan<'_, '_> {
self.relations.get().can_cooperate(&mover, &unit),
mover_store.0.unit_is_dead(),
mover_store.0.unit_flags() & crate::player::UNIT_FLAG_STUNNED != 0,
casting,
mover_store.0.unit_channel_spell() != 0,
)
.map(FollowRefusal::Line)
}
@@ -422,12 +423,13 @@ impl PlayerLookup<'_, '_> {
/// `/target <name>` and the Lua `TargetByName(name, exactMatch)` through [`scan::commit`], the
/// stop, select, re-swing path a click takes. `exact` is the Lua second argument, which `0x489d8e`
/// reads with default 0 as the resolver's exact-only flag (consumed at `0x493cab`). A miss leaves
/// the target alone, as neither failure edge in `0x489db4` calls `SetSelection`, and prints
/// nothing; the reference prints message `0x127` `ERR_UNIT_NOT_FOUND` there, or `0xb8`
/// `ERR_GENERIC_NO_TARGET` for an empty name.
/// the target alone, as neither failure edge in `0x489db4` calls `SetSelection`, and shows
/// message `0x127` `ERR_UNIT_NOT_FOUND` for a name given (`0x489dbd`, tested on the raw string's
/// first byte, so blanks count) or `0xb8` `ERR_GENERIC_NO_TARGET` for an empty one (`0x489dcf`).
pub(super) fn target_named(
scan_params: &ByNameScan,
commit: &mut SelectCommit,
errors: &mut crate::ui_action::UiErrorKeys,
name: &str,
exact: bool,
) {
@@ -441,6 +443,12 @@ pub(super) fn target_named(
Match::PrefixOk
},
) else {
let key = if name.is_empty() {
"ERR_GENERIC_NO_TARGET"
} else {
"ERR_UNIT_NOT_FOUND"
};
errors.0.push(crate::ui_action::UiError::key(key));
return;
};
commit.commit(entity, guid);
@@ -479,9 +487,10 @@ pub(super) fn target_by_name_requests(
mut requests: MessageReader<TargetByNameRequest>,
scan_params: ByNameScan,
mut commit: SelectCommit,
mut errors: ResMut<crate::ui_action::UiErrorKeys>,
) {
for request in requests.read() {
target_named(&scan_params, &mut commit, &request.name, false);
target_named(&scan_params, &mut commit, &mut errors, &request.name, false);
}
}
@@ -510,7 +519,6 @@ pub(super) fn follow_requests(
selection: Res<Selection>,
tokens: crate::ui_unit::UnitTokens,
mover: Query<(&Guid, &ObjectStore), With<crate::net::ActiveMover>>,
cast: Res<crate::spell::PendingCast>,
mut errors: ResMut<crate::ui_action::UiErrorKeys>,
mut follow: ResMut<crate::player::FollowState>,
mut approach: ResMut<crate::player::Approach>,
@@ -550,8 +558,7 @@ pub(super) fn follow_requests(
Some((guid, store.filter(|s| s.is_unit())?))
});
let mover = mover.iter().next().map(|(guid, store)| (guid.0, store));
let casting = cast.in_flight();
match scan_params.follow_refusal(mover, followee, missing, casting) {
match scan_params.follow_refusal(mover, followee, missing) {
// A refusal leaves a follow in progress alone: the gate returns before `0x611130`.
Some(FollowRefusal::Line(key)) => {
info!("follow: refused — {key}");
@@ -1118,6 +1125,56 @@ mod tests {
assert_eq!(drain(&mut world, unit("target")), no_target, "no mover");
}
/// The start gate refuses a follow with `ERR_TOOBUSYTOFOLLOW` while the mover's
/// `UNIT_CHANNEL_SPELL` is set (`0x60ffb9`, the unit block's word at `+0x228`): a channel
/// refuses it for as long as it lasts, a hard cast in flight does not.
#[test]
fn a_channel_refuses_a_follow_and_a_cast_in_flight_does_not() {
use crate::target::relations::fixture::{player, HUMAN};
/// `UNIT_CHANNEL_SPELL`.
const CHANNEL_SPELL: u16 = 144;
const DRAIN_LIFE: u32 = 689;
// (channelling, a cast in flight, followed)
for (channel, cast, followed) in [
(false, false, true),
(true, false, false),
(false, true, true),
] {
let (mut world, t) = follow_world(player(ME, HUMAN), player(THEM, HUMAN), false);
select(&mut world, Some((t, THEM)));
if channel {
let me = world.resource::<GuidIndex>().0[&ME];
world
.entity_mut(me)
.get_mut::<ObjectStore>()
.expect("our descriptor")
.0
.merge(benilla_protocol::ObjectFields::from_pairs(&[(
CHANNEL_SPELL,
DRAIN_LIFE,
)]));
}
if cast {
world.resource_mut::<crate::spell::PendingCast>().push(
DRAIN_LIFE,
None,
std::time::Instant::now(),
);
}
let want: (Vec<&str>, Option<u64>) = if followed {
(Vec::new(), Some(THEM))
} else {
(vec!["ERR_TOOBUSYTOFOLLOW"], None)
};
assert_eq!(
drain(&mut world, unit("target")),
want,
"channel {channel}, cast in flight {cast}"
);
}
}
/// `DoEmote`'s search (`0x49fde9`, typemask 0x10, mode 0) runs the roster passes: a groupmate
/// with no object answers to a prefix, and a streamed player nearer than the roster's
/// placeholder distance with an equal prefix answers instead.
+179 -86
View File
@@ -6,13 +6,17 @@ use super::*;
use crate::query_cache::Ask;
/// The right-click payload leg of the reference's click router (`0x481f60`, clean clicks only,
/// `0x514ae0`): over terrain (`0x492c90`) or nothing (`0x492d30`) any payload clears, silently.
/// Over a world object this keeps every payload; the reference's object leg `0x492ce0` there
/// clears one that sets `[0xb4b41c]` (mode 5 vendor row, mode 7 bar item, mode 9 ammo).
/// `0x514ae0`), for a payload still held at the release: the hook that clears one at the press
/// (`benilla_ui::script::UiScript::mouse_button`, `0x492b50`) has already run unless a pickup came
/// after it. Over terrain (`0x492c90`) or nothing (`0x492d30`) any payload clears, silently. Over
/// an object (a unit, a corpse, a GameObject: `0x492ce0`) only a preview one clears, the vendor
/// row or bar item that sets `[0xb4b41c]`. A charmed player's click is the nothing leg
/// (`0x481f98`). A nameplate's right-click does not come through here (`0x4949f0`).
pub(super) fn world_right_click_payload(
mut right_clicks: MessageReader<WorldRightClick>,
// The press pick: this leg and [`act_on_right_click`] must classify the same pick.
press: Res<PressPick>,
me: Query<&ObjectStore, With<SelfPlayer>>,
script: Option<NonSendMut<UiScript>>,
) {
if right_clicks.read().last().is_none() {
@@ -21,21 +25,35 @@ pub(super) fn world_right_click_payload(
let Some(mut script) = script else {
return;
};
if press.hovered.target.is_some() || press.object.target.is_some() {
return;
let charmed = me.iter().any(|me| me.0.unit_charmed_by().is_some());
if press.over_object() && !charmed {
script.clear_preview_payload();
} else {
script.clear_cursor_payload();
}
script.clear_cursor_payload();
}
/// A right-click begun on a nameplate, replayed for [`act_on_interact`]: the plate's click slot
/// (`0x7cb910`) goes to `0x4949f0` and so to `InteractWithObjectByClick` (`0x492820`), not
/// through the world click router (`0x481f60`), whose charmed return and payload legs it skips.
/// It carries what the click's `SelectTarget` (`0x492870`) did, which ran at the click.
#[derive(Message, Clone, Copy)]
pub(super) struct PlateRightClick {
pub(super) selected: scan::CommitOutcome,
}
/// A plate click is a click on its unit (the click slot `0x7cb910`). The left button carries the
/// plate's unit, as a scripted `plate:Click()` (pfUI, `nameplates.lua:1228`) has no cursor; the
/// right replays [`WorldRightClick`] only when the press pick names it, so a scripted one does
/// right replays [`PlateRightClick`] only when the press pick names it, so a scripted one does
/// nothing yet. A physical right-click arrives only here: `0x7cb910` → `0x4949f0` → `0x492820`.
///
/// The left click is the body click's select (`0x4949f0` action 1 → `0x4925d0` → `SelectTarget`
/// `0x49280c`), so with the targeting cursor up the unit goes to the cast's bind and the target
/// stays ([`super::front`]). The right is dropped while the cursor is up: its `0x492820` also binds
/// (`0x492870`) and then interacts, which is not built.
/// stays ([`super::front`]). The right is the interact (`0x492820`): its `SelectTarget`
/// (`0x492870`) runs here and binds the same way, and the unit's interact (`0x492881`) follows
/// either way, in [`act_on_interact`], after the bind has emptied the cursor's word; the plate's
/// press never reached the world frame's right-press targeting cancel (`0x492c20`), so the cursor
/// stands up to here.
pub(super) fn select_on_plate_click(
mut plate: ResMut<crate::vplates::PlateClicks>,
press: Res<PressPick>,
@@ -44,7 +62,7 @@ pub(super) fn select_on_plate_click(
mut front: super::front::SelectFront,
self_q: Query<(&Guid, Has<Engaged>), With<SelfPlayer>>,
mut greeting: MessageWriter<crate::sound::NpcGreetingRequest>,
mut right_clicks: MessageWriter<WorldRightClick>,
mut right_clicks: MessageWriter<PlateRightClick>,
units: Query<(&Guid, Option<&ObjectStore>)>,
) {
let (left, right) = (
@@ -78,10 +96,29 @@ pub(super) fn select_on_plate_click(
attack,
);
}
if !seam.slots.targeting().active()
&& right.into_iter().any(|e| press.hovered.target == Some(e))
{
right_clicks.write(WorldRightClick);
for entity in right {
// The press pick must name it: a scripted click has no cursor to interact by.
if press.hovered.target != Some(entity) {
continue;
}
let Ok((guid, store)) = units.get(entity) else {
continue;
};
let selected = if front.takes(entity, guid.0, seam.slots.targeting()) {
scan::CommitOutcome::default()
} else {
scan::commit(
&mut selection,
&mut seam,
entity,
guid.0,
store,
engaged,
self_guid,
press.attack(),
)
};
right_clicks.write(PlateRightClick { selected });
}
}
@@ -313,6 +350,28 @@ impl Dispatch<'_, '_> {
}
}
// The unit legs measure their own reach, as the reference's dispatchers do, never the
// press's latched cursor: with a spell cursor up at the press that gray is the spell's
// verdict (`spell::targeting::cursor`), not the interaction's.
/// `0x5f0130`'s range test (`0x5f01b9`-`0x5f01fd`, `0x5f0238`): centre to centre against
/// 5.5556 yd squared, boundary-inclusive, a NaN passing.
fn in_service_reach(&self, target: Entity) -> bool {
let d2 = self.dist_sq(target).unwrap_or(f32::INFINITY);
d2.is_nan() || d2 <= SERVICE_RANGE_SQ
}
/// Inside the melee reach, which the skin cast's range rides (`0x6e3480`).
fn in_melee_reach(&self, target: Entity) -> bool {
let reach = self.melee_reach(target);
self.dist_sq(target).is_some_and(|d2| d2 <= reach * reach)
}
/// `CanLootNow 0x5ec110`: the melee reach, skipped under `CanAutoInteract` (`0x5ec11b`).
fn can_loot_unit(&self, target: Entity) -> bool {
self.auto.can_auto_interact() || self.in_melee_reach(target)
}
fn approach(
&mut self,
verb: crate::player::ApproachVerb,
@@ -596,10 +655,30 @@ impl Dispatch<'_, '_> {
});
}
/// `0x5f0130`: the NPC-service ladder. Out of the 5.5556 yd reach it walks there when `walk`,
/// returning before the ladder and the gesture (`0x5f022f`), and otherwise sends nothing.
fn talk(&mut self, entity: Entity, guid: u64, in_reach: bool, walk: bool) {
if !in_reach {
/// Whether the player's form bars NPCs (`IsShapeShifted`, `0x60e9f0`, and the form row's flag
/// `0x8`, `0x5f017f`).
fn shapeshifted_from_npcs(&self) -> bool {
let form = self.self_store().map_or(0, |s| s.0.unit_shapeshift_form());
self.go_inputs
.spells
.as_deref()
.is_some_and(|spells| crate::ui_session::form_bars_interaction(&spells.forms, form))
}
/// `0x5f0130`: the NPC-service ladder. It opens with the form test (`0x5f0150`): a player in a
/// form that bars NPCs gets `ERR_CANT_INTERACT_SHAPESHIFTED` (`0x5f0185`, message 351) and
/// nothing else, no walk and no gesture, in reach or not. Out of the 5.5556 yd reach it then
/// walks there when `walk`, returning before the ladder and the gesture (`0x5f022f`), and
/// otherwise sends nothing.
fn talk(&mut self, entity: Entity, guid: u64, walk: bool) {
if self.shapeshifted_from_npcs() {
debug!("right-click interact: {guid:#x} — refused, shapeshifted");
self.feedback.errors.0.push(crate::ui_action::UiError::key(
"ERR_CANT_INTERACT_SHAPESHIFTED",
));
return;
}
if !self.in_service_reach(entity) {
if walk {
self.approach(
crate::player::ApproachVerb::Talk,
@@ -699,8 +778,11 @@ impl Dispatch<'_, '_> {
}
}
/// On a clean right-click, the INTERACT leg `0x492820` on the press pick: select it, then
/// [`interact`].
/// The world click router (`0x481f60`) on the press pick, for a right-click. A charmed player
/// takes the nothing leg with zero points (`0x481f98`, `0x48204e`), so nothing is selected,
/// interacted with or walked to; otherwise the ground and the sky walk (`0x5e0378`, `0x492d50`)
/// and an object takes the INTERACT leg `0x492820`: select it, then [`interact`]. A nameplate's
/// click does not come through here ([`PlateRightClick`]).
pub(super) fn act_on_right_click(
mut clicks: MessageReader<WorldRightClick>,
// The press pick: the reference picks once, on the down edge (`0x481f00`).
@@ -709,12 +791,12 @@ pub(super) fn act_on_right_click(
mut dispatch: Dispatch,
payload_held: Res<crate::ui_script::CursorPayloadHeld>,
) {
if clicks.read().last().is_none() {
if clicks.read().last().is_none() || dispatch.seam.player_is_charmed() {
return;
}
// The router's other legs (`0x481f60`): the world hit walks there (`0x5e0378`), and nothing
// walks along the ray when the cursor is empty (`0x492d50`).
if press.hovered.any().is_none() && !press.hovered.refused && press.object.target.is_none() {
// The router's other legs: the world hit walks there (`0x5e0378`), and nothing walks along the
// ray when the cursor is empty (`0x492d50`).
if !press.over_object() {
match (press.occlusion.point, press.occlusion.ray) {
(Some(at), _) => {
dispatch.auto.walk_to_ground(at);
@@ -730,19 +812,34 @@ pub(super) fn act_on_right_click(
}
/// Where [`interact`] selects its unit, if it does: the world click's entry (`0x492820`) selects
/// first, whatever leg follows; a crate's [`Interact`] runs the dispatcher alone, and only its
/// attack leg selects, through StartAttack (`0x5ecb70`, `SetSelection` at `0x5eccaa`).
/// first, whatever leg follows; a nameplate's click made that select already, at the click
/// ([`select_on_plate_click`]), and the dispatcher runs after the cast it bound; a crate's
/// [`Interact`] runs the dispatcher alone. The attack leg selects again through StartAttack
/// (`0x5ecb70`, `SetSelection` at `0x5eccaa`), which finds the unit selected or, after a bind
/// emptied the cursor's word, selects it.
enum Select<'a, 'w> {
First(&'a mut ResMut<'w, Selection>),
Done(&'a mut ResMut<'w, Selection>, scan::CommitOutcome),
OnAttack(&'a mut ResMut<'w, Selection>),
}
impl<'a, 'w> Select<'a, 'w> {
fn selection(&mut self) -> &mut ResMut<'w, Selection> {
match self {
Select::First(selection) | Select::Done(selection, _) | Select::OnAttack(selection) => {
selection
}
}
}
}
/// Act on a pick by the cursor's classification of it: a GameObject, a corpse, an attack, loot,
/// skin, or the `UNIT_NPC_FLAGS` ladder ([`service_arm`]); only the click selects first
/// (`0x493540`), and the attack leg of an [`Interact`] selects through StartAttack. The range gray
/// (`unable`) suppresses every send but attack, which the server holds until in reach; with Click
/// to Move on, each dispatcher walks there instead (`CanAutoInteract` is their approach flag,
/// `0x60c170`, `0x5d6c6f`).
/// (`0x493540`), and the attack leg selects through StartAttack. The unit legs measure their own
/// reach ([`Dispatch::in_service_reach`]); a GameObject's and a corpse's still ride the range gray
/// the press latched (`unable`). Out of reach every send but attack is suppressed, the server
/// holds attack until in reach; with Click to Move on, each dispatcher walks there instead
/// (`CanAutoInteract` is their approach flag, `0x60c170`, `0x5d6c6f`).
fn interact(dispatch: &mut Dispatch, press: &PressPick, mut select: Select) {
let (hovered, hovered_object, cursor) = (&press.hovered, &press.object, &press.cursor);
let walk = dispatch.auto.can_auto_interact();
@@ -915,6 +1012,7 @@ fn interact(dispatch: &mut Dispatch, press: &PressPick, mut select: Select) {
};
let mut outcome = match &mut select {
Select::First(selection) => commit(&mut dispatch.seam, &mut dispatch.front, selection),
Select::Done(_, done) => *done,
Select::OnAttack(_) => scan::CommitOutcome::default(),
};
match unit_branch(attack, dead_fork, leg) {
@@ -936,20 +1034,25 @@ fn interact(dispatch: &mut Dispatch, press: &PressPick, mut select: Select) {
}
// Silent after the select: the click's `0x60c247 call 0x5ecb70` has no `DisplayError`,
// and the red `ERR_ATTACK_*` lines are `0x612df0`'s (the Attack action, pet attack,
// TryCast). The predicate is `0x612df0`'s; `0x5ecb70`'s own set is not transcribed.
if crate::ui_action::attack_actor_blocked(dispatch.self_store(), me.map(|(_, g, _)| g))
.is_some()
{
// TryCast), which this path never reaches. The arm's own returns are the dead and
// mounted player (`0x60c1a1`, `0x60c1bc`), and StartAttack's the pacified flag and a
// clear `[0xb4b3e4]` (`0x5ecbaa`, `0x5ecbc0`).
let gate = dispatch.seam.start_gate();
if refused || gate.refuses() {
// refused: the selection stands, no swing, nothing said
} else {
// StartAttack's own select (`0x5eccaa`), past its gates and before the swing
// (`0x5eccfd`): the click's commit, with its stop and re-swing mid-combat.
if let Select::OnAttack(selection) = &mut select {
outcome = commit(&mut dispatch.seam, &mut dispatch.front, selection);
}
// (`0x5eccfd`): the click's commit, with its stop and re-swing mid-combat. A
// unit already selected is its dedupe, and a cursor's bind has emptied the word
// by now, so a plate's unit is selected here, ahead of the swing.
let second = commit(&mut dispatch.seam, &mut dispatch.front, select.selection());
outcome = scan::CommitOutcome {
changed: outcome.changed || second.changed,
swung: outcome.swung || second.swung,
};
debug!("right-click attack: {guid:#x}");
// `0x5ecb70`'s body through the seam; `swung` means the commit's re-swing already
// went out, which `0x5eccda` keeps from sending twice.
// `0x5ecb70`'s body through the seam; `swung` means the commit already asked it
// for its re-swing, which `0x5eccda` keeps from sending twice.
let engaged = me.is_some_and(|(_, _, e)| e);
dispatch.seam.start(guid, engaged || outcome.swung, false);
}
@@ -967,19 +1070,21 @@ fn interact(dispatch: &mut Dispatch, press: &PressPick, mut select: Select) {
.0
.push(crate::ui_action::UiError::key("ERR_LOOT_NOTSTANDING"));
} else {
dispatch.loot_unit(entity, guid, !cursor.unable, walk);
let can_loot = dispatch.can_loot_unit(entity);
dispatch.loot_unit(entity, guid, can_loot, walk);
}
}
UnitBranch::Dead(DeadUnitLeg::Skin) => {
// The skin leg (`0x60c01f`): the known Skinning spell (`[0xb700e4]`) at a corpse the
// loot leg declined, lootable ones included while mounted.
if let Some(spell_id) = dispatch.learned.skinning {
let in_reach = dispatch.in_melee_reach(entity);
dispatch.skin(
entity,
guid,
spell_id,
crate::player::Subject::Unit { dead: true },
!cursor.unable,
in_reach,
walk,
);
}
@@ -989,7 +1094,7 @@ fn interact(dispatch: &mut Dispatch, press: &PressPick, mut select: Select) {
UnitBranch::Dead(DeadUnitLeg::Nothing) => {
debug!("right-click unit {guid:#x}: dead fork took no leg — nothing sent");
}
UnitBranch::Service => dispatch.talk(entity, guid, !cursor.unable, walk),
UnitBranch::Service => dispatch.talk(entity, guid, walk),
}
}
@@ -1000,14 +1105,27 @@ fn interact(dispatch: &mut Dispatch, press: &PressPick, mut select: Select) {
#[derive(Message, Clone, Copy, PartialEq, Eq, Debug)]
pub struct Interact(pub Entity);
/// Each [`Interact`], its object classified as a press over it would be.
/// Each nameplate right-click's unit interact (`0x492881`), then each [`Interact`], its object
/// classified as a press over it would be. Runs after the head of the script calls' pass, which
/// binds a standing cursor's parked picks: a plate's `SelectTarget` gave its unit to the cast, and
/// the interact that follows finds the cursor's word empty, so its attack leg selects the unit
/// and its service leg talks, after the cast on the wire.
pub(super) fn act_on_interact(
mut requests: MessageReader<Interact>,
mut plate_clicks: MessageReader<PlateRightClick>,
press: Res<PressPick>,
mut selection: ResMut<Selection>,
kinds: Query<(&Guid, &crate::net::NetEntity)>,
mut set: ParamSet<(cursor_mode::CursorInputs, Dispatch)>,
) {
use benilla_protocol::EntityKind;
for &PlateRightClick { selected } in plate_clicks.read() {
interact(
&mut set.p1(),
&press,
Select::Done(&mut selection, selected),
);
}
for &Interact(entity) in requests.read() {
let Ok((guid, net)) = kinds.get(entity) else {
continue;
@@ -1063,7 +1181,7 @@ pub(super) fn act_on_arrival(
d2.sqrt()
);
match verb {
ApproachVerb::Talk if unit => dispatch.talk(entity, guid, d2 <= SERVICE_RANGE_SQ, false),
ApproachVerb::Talk if unit => dispatch.talk(entity, guid, false),
// `0x5df2a0`'s own lootable test (`0x6003a0`), which the click's dead fork made earlier.
ApproachVerb::Loot if unit => {
if dispatch
@@ -1071,8 +1189,7 @@ pub(super) fn act_on_arrival(
.get(entity)
.is_ok_and(|(s, _)| s.0.unit_lootable())
{
let reach = dispatch.melee_reach(entity);
let can_loot = dispatch.auto.can_auto_interact() || d2 <= reach * reach;
let can_loot = dispatch.can_loot_unit(entity);
dispatch.loot_unit(entity, guid, can_loot, false);
}
}
@@ -2136,6 +2253,7 @@ mod tests {
let mut world = World::new();
world.insert_resource(NetCommands(tx));
world.init_resource::<Messages<WorldRightClick>>();
world.init_resource::<Messages<PlateRightClick>>();
world.init_resource::<PressPick>();
world.init_resource::<Selection>();
world.init_resource::<crate::spell::SpellTargeting>();
@@ -2167,6 +2285,7 @@ mod tests {
world.init_resource::<crate::player::Approach>();
world.init_resource::<crate::player::FollowState>();
world.init_resource::<crate::player::Player>();
world.init_resource::<crate::player::OwnControlLost>();
world.init_resource::<crate::ui_script::CursorPayloadHeld>();
world.init_resource::<Messages<Interact>>();
world.init_resource::<crate::net::Reputations>();
@@ -2202,14 +2321,19 @@ mod tests {
.query_filtered::<Entity, With<SelfPlayer>>()
.single(&world)
.unwrap();
world
.entity_mut(me)
.insert(store(&[(F_HEALTH, 100), (F_MAXHEALTH, 100)]));
world.entity_mut(boar).insert(store(&[
(F_HEALTH, 0),
(F_MAXHEALTH, 100),
(F_UNIT_FLAGS, cursor_mode::UNIT_FLAG_SKINNABLE),
]));
world.entity_mut(me).insert((
store(&[(F_HEALTH, 100), (F_MAXHEALTH, 100)]),
Transform::default(),
));
// The click measures the melee reach itself: 5 yd for these two.
world.entity_mut(boar).insert((
store(&[
(F_HEALTH, 0),
(F_MAXHEALTH, 100),
(F_UNIT_FLAGS, cursor_mode::UNIT_FLAG_SKINNABLE),
]),
Transform::from_xyz(if in_reach { 2.0 } else { 20.0 }, 0.0, 0.0),
));
world
.resource_mut::<crate::ui_action::LearnedAbilities>()
.skinning = Some(SKINNING);
@@ -2381,37 +2505,6 @@ mod tests {
}
}
/// A right-click begun on a plate acts on the plate's unit: [`act_on_right_click`] reads the
/// [`PressPick`], and a plate leaves no body under the cursor for a live hover to find.
#[test]
fn a_right_click_begun_on_a_plate_acts_on_the_plates_unit() {
let (mut world, boar) = right_click_world();
// The press latch as `latch_press_pick` writes it: the plate's unit, distance 0.0
// (topmost UI), classified Attack.
*world.resource_mut::<PressPick>() = PressPick {
hovered: Hovered {
target: Some(boar),
guid: Some(BOAR),
distance: 0.0,
..Hovered::default()
},
cursor: WorldCursor {
kind: cursor_mode::CursorKind::Attack,
unable: false,
},
..PressPick::default()
};
world
.resource_mut::<Messages<WorldRightClick>>()
.write(WorldRightClick);
world.run_system_once(act_on_right_click).unwrap();
assert_eq!(
world.resource::<Selection>().guid,
Some(BOAR),
"the release must act on the unit whose plate the press was over"
);
}
/// The dispatcher's attack arm (`0x60c18c`) is taken on the fork, not the sword: a ghost over
/// a hostile NPC sees the pointer, and its right-click is refused silently inside the arm
/// (`0x60c1a1`), with no swing and no service packet; a live player on the same fork swings.
+5 -3
View File
@@ -127,7 +127,7 @@ pub(crate) mod npc_flags {
}
pub(super) const UNIT_FLAG_SKINNABLE: u32 = 0x0400_0000;
const UNIT_FLAG_PACIFIED: u32 = 0x0002_0000;
pub(crate) const UNIT_FLAG_PACIFIED: u32 = 0x0002_0000;
/// The hovered unit is one the unit dispatcher `0x60bea0` forks to its attack arm for (`0x60c192`):
/// alive or feigning, refused by `CanInteract` and passed by `CanAttack`. The sword also needs the
@@ -584,8 +584,9 @@ pub(super) fn classify_cursor(
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct CursorInputs<'w, 's> {
relations: super::RelationWorld<'w, 's>,
// `own_control_lost` is the attack leg's `[0xb4b3e4]` clear.
player: Res<'w, crate::player::Player>,
// The attack leg's `[0xb4b3e4]` clear.
own_control: Res<'w, crate::player::OwnControlLost>,
approach: Res<'w, crate::player::Approach>,
#[allow(clippy::type_complexity)]
units: Query<
@@ -620,6 +621,7 @@ pub(super) fn classify(
let CursorInputs {
relations,
player,
own_control,
approach,
units,
self_q,
@@ -747,7 +749,7 @@ pub(super) fn classify(
return None;
}
attack_fork.set(true);
player_attack_legs(self_store, player.own_control_lost)
player_attack_legs(self_store, own_control.0)
.then_some((CursorKind::Attack, dist_sq > ATTACK_RANGE_SQ))
};
// The corpse classifier `0x482740`.
+19 -19
View File
@@ -11,23 +11,23 @@ use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfG
use crate::vplates::PlateClicks;
use benilla_world::interact::WorldClick;
const ME: u64 = 0x10;
pub(super) const ME: u64 = 0x10;
/// Selected before each writer runs.
const OLD: u64 = 0x21;
pub(super) const OLD: u64 = 0x21;
/// The unit each writer finds: the plate, the click, the attacker.
const MOB: u64 = 0x31;
pub(super) const MOB: u64 = 0x31;
/// A spell the library does not know, so any unit binds under its `TARGET_FLAG_UNIT` word.
const HEAL: u32 = 2050;
pub(super) const HEAL: u32 = 2050;
struct Fix {
app: App,
rx: Receiver<ClientCommand>,
mob: Entity,
pub(super) struct Fix {
pub(super) app: App,
pub(super) rx: Receiver<ClientCommand>,
pub(super) mob: Entity,
}
/// The whole client, headless, with us, `OLD` selected and `MOB` beside; `charmed` has someone
/// charm us (`UNIT_FIELD_CHARMEDBY`, a guid of two fields).
fn fix(charmed: bool) -> Fix {
pub(super) fn fix(charmed: bool) -> Fix {
let mut app = crate::game_plugins::schedule_tests::headless_client();
let (tx, rx) = crossbeam_channel::unbounded();
app.insert_resource(NetCommands(tx));
@@ -70,25 +70,25 @@ fn fix(charmed: bool) -> Fix {
}
impl Fix {
fn cursor_up(&mut self) {
pub(super) fn cursor_up(&mut self) {
self.app
.world_mut()
.resource_mut::<crate::spell::SpellTargeting>()
.enter(HEAL, crate::spell::CastCommit::Spell, 0x0002);
}
fn cursor_is_up(&self) -> bool {
pub(super) fn cursor_is_up(&self) -> bool {
self.app
.world()
.resource::<crate::spell::SpellTargeting>()
.active()
}
fn selected(&self) -> Option<u64> {
pub(super) fn selected(&self) -> Option<u64> {
self.app.world().resource::<Selection>().guid
}
fn click(&mut self, pick: Hovered) {
pub(super) fn click(&mut self, pick: Hovered) {
let world = self.app.world_mut();
world.resource_mut::<PressPick>().hovered = pick;
world
@@ -97,7 +97,7 @@ impl Fix {
}
/// A unit the TAB scan can pick, `x` yards along.
fn scannable_at(&mut self, guid: u64, x: f32) -> Entity {
pub(super) fn scannable_at(&mut self, guid: u64, x: f32) -> Entity {
let world = self.app.world_mut();
let e = world
.spawn((
@@ -120,7 +120,7 @@ impl Fix {
}
/// One TAB from a key's body, applied.
fn tab(&mut self) {
pub(super) fn tab(&mut self) {
self.app
.world_mut()
.non_send_resource_mut::<benilla_ui::script::UiScript>()
@@ -130,7 +130,7 @@ impl Fix {
}
/// The `NpcGreetingRequest`s written since the last look.
fn greetings(&mut self) -> Vec<Entity> {
pub(super) fn greetings(&mut self) -> Vec<Entity> {
self.app
.world_mut()
.resource_mut::<Messages<crate::sound::NpcGreetingRequest>>()
@@ -139,7 +139,7 @@ impl Fix {
.collect()
}
fn over_the_mob(&self) -> Hovered {
pub(super) fn over_the_mob(&self) -> Hovered {
Hovered {
target: Some(self.mob),
guid: Some(MOB),
@@ -149,7 +149,7 @@ impl Fix {
}
/// The calls pass, whose head binds what the writers parked.
fn bind(&mut self) {
pub(super) fn bind(&mut self) {
self.app
.world_mut()
.run_system_once(crate::script_calls::apply_script_calls)
@@ -157,7 +157,7 @@ impl Fix {
}
/// What went on the wire: the casts' targets and the selections.
fn wire(&self) -> (Vec<Option<u64>>, Vec<u64>) {
pub(super) fn wire(&self) -> (Vec<Option<u64>>, Vec<u64>) {
let sent: Vec<_> = self.rx.try_iter().collect();
let casts = sent
.iter()
+682
View File
@@ -0,0 +1,682 @@
//! The gates a click, a target command and a hand-held payload answer to, on the whole client
//! headless: what 1.12.1 lets each of them do, read from the entry the case comes in by.
use std::collections::HashMap;
use benilla_protocol::field::{
FIELD_UNIT_BYTES_1, FIELD_UNIT_DYNAMIC_FLAGS, FIELD_UNIT_FLAGS, FIELD_UNIT_HEALTH,
FIELD_UNIT_NPC_FLAGS,
};
use benilla_protocol::ObjectFields;
use benilla_ui::script::{ContainerSlot, ContainerState, MerchantItem, MerchantState, UiScript};
use benilla_world::interact::WorldRightClick;
use bevy::ecs::system::RunSystemOnce;
use bevy::prelude::*;
use super::front_tests::{fix, Fix, ME, MOB, OLD};
use super::{by_name, click, cursor_mode, Hovered, PickOcclusion, PressPick};
use crate::net::{ClientCommand, GuidIndex, ObjectStore};
use crate::target::TargetByNameRequest;
use crate::ui_action::UiErrorKeys;
use crate::vplates::PlateClicks;
/// A gossip NPC, and a hostile unit.
const NPC: u64 = 0x51;
const FOE: u64 = 0x52;
/// `UNIT_NPC_FLAG_GOSSIP`.
const GOSSIP: u32 = 0x1;
/// What the cursor holds before the click.
#[derive(Clone, Copy, Debug)]
enum Held {
/// A bag item (payload mode 1).
Item,
/// A vendor row (payload mode 5), a preview payload (`[0xb4b41c]` set).
Row,
}
/// What the press was over.
#[derive(Clone, Copy, Debug)]
enum Under {
Nothing,
Unit,
Corpse,
}
impl Fix {
fn merge_mine(&mut self, pairs: &[(u16, u32)]) {
let me = self.app.world().resource::<GuidIndex>().0[&ME];
self.app
.world_mut()
.entity_mut(me)
.get_mut::<ObjectStore>()
.expect("our descriptor")
.0
.merge(ObjectFields::from_pairs(pairs));
}
/// A unit `x` yards along, with `fields` over a living unit's.
fn unit_with(&mut self, guid: u64, x: f32, fields: &[(u16, u32)]) -> Entity {
let unit = self.scannable_at(guid, x);
self.app
.world_mut()
.entity_mut(unit)
.get_mut::<ObjectStore>()
.expect("its descriptor")
.0
.merge(ObjectFields::from_pairs(fields));
unit
}
/// The press `latch_press_pick` leaves for a click begun on `unit`, the classifier's cursor.
fn press_on(&mut self, unit: Entity, guid: u64, attack: bool) {
self.latch(
unit,
guid,
attack,
cursor_mode::WorldCursor {
kind: if attack {
cursor_mode::CursorKind::Attack
} else {
cursor_mode::CursorKind::Speak
},
unable: false,
},
);
}
/// The same press with a spell cursor up: the cursor the press latched is the spell's, and
/// `unable` is its verdict on the unit, whatever the interaction's own range says.
fn press_under_spell(&mut self, unit: Entity, guid: u64, attack: bool, unable: bool) {
self.latch(
unit,
guid,
attack,
cursor_mode::WorldCursor {
kind: cursor_mode::CursorKind::Cast,
unable,
},
);
}
fn latch(&mut self, unit: Entity, guid: u64, attack: bool, cursor: cursor_mode::WorldCursor) {
*self.app.world_mut().resource_mut::<PressPick>() = PressPick {
hovered: Hovered {
target: Some(unit),
guid: Some(guid),
distance: 3.0,
..Hovered::default()
},
cursor,
attack_fork: cursor_mode::AttackFork(attack),
..PressPick::default()
};
}
/// The world's right-click on what `press_on` latched.
fn right_click(&mut self) {
self.app
.world_mut()
.resource_mut::<Messages<WorldRightClick>>()
.write(WorldRightClick);
self.app
.world_mut()
.run_system_once(click::act_on_right_click)
.expect("the right-click runs");
}
/// A right-click on `unit`'s nameplate, begun on what the press latched, through the systems
/// in the schedule's order (`plate_right_click_runs_after_the_script_calls_head` pins it):
/// the click's `SelectTarget`, the head of the script calls' pass that binds what it parked,
/// then the unit's interact.
fn plate_right_click(&mut self, unit: Entity) {
self.app
.world_mut()
.resource_mut::<PlateClicks>()
.right
.push(unit);
self.app
.world_mut()
.run_system_once(click::select_on_plate_click)
.expect("the plate click runs");
self.bind();
self.app
.world_mut()
.run_system_once(click::act_on_interact)
.expect("the unit's interact runs");
}
/// Everything sent since the last look.
fn sent(&self) -> Vec<ClientCommand> {
self.rx.try_iter().collect()
}
fn errors(&self) -> Vec<&'static str> {
self.app
.world()
.resource::<UiErrorKeys>()
.0
.iter()
.map(|e| e.key)
.collect()
}
/// The player's control, `[0xb4b3e4]`.
fn lose_control(&mut self) {
self.app
.world_mut()
.resource_mut::<crate::player::OwnControlLost>()
.0 = true;
}
/// A VM holding `held` on its cursor.
fn hold(&mut self, held: Held) {
let mut script = UiScript::new().expect("a VM");
match held {
Held::Item => {
script.set_container(
0,
Some(ContainerState {
num_slots: 16,
slots: HashMap::from([(
1,
ContainerSlot {
guid: 0x77,
item_id: 117,
count: 1,
..Default::default()
},
)]),
..Default::default()
}),
);
script
.run("PickupContainerItem(0, 1)")
.expect("the pickup runs");
}
Held::Row => {
script.set_merchant(Some(MerchantState {
items: vec![MerchantItem {
name: None,
texture: None,
price: 25,
quantity: 1,
num_available: -1,
item_id: 159,
stats: None,
link: None,
max_stack: None,
}],
..Default::default()
}));
script.run("PickupMerchantItem(1)").expect("the grab runs");
}
}
assert!(
script.cursor_payload().is_some(),
"{held:?} is on the cursor"
);
self.app.world_mut().insert_non_send_resource(script);
}
fn held(&self) -> bool {
self.app
.world()
.non_send_resource::<UiScript>()
.cursor_payload()
.is_some()
}
}
fn swung_at(sent: &[ClientCommand], guid: u64) -> bool {
sent.iter()
.any(|c| matches!(c, ClientCommand::AttackSwing { guid: g } if *g == guid))
}
fn greeted(sent: &[ClientCommand], guid: u64) -> bool {
sent.iter()
.any(|c| matches!(c, ClientCommand::GossipHello { guid: g } if *g == guid))
}
/// `CGPlayer_C::InteractWithNpc` (`0x5f0130`) opens with `IsShapeShifted` (`0x60e9f0`, at
/// `0x5f0150`): a form whose `SpellShapeshiftForm` row lacks flag `0x8` shows message 351 and
/// returns (`0x5f017f`, `0x5f0185`), ahead of the range test and the walk. A form with `0x8`, a
/// stance (flag `0x1`, so not shapeshifted) and no form talk as ever.
#[test]
fn a_shapeshifted_player_cannot_talk_to_an_npc() {
// (form byte, its row's flags1 when it has a row, refused)
for (form, flags, refused) in [
(0u32, None, false),
(1, Some(0), true),
(2, Some(0x8), false),
(3, Some(0x1), false),
] {
for far in [false, true] {
let mut f = fix(false);
f.app.world_mut().init_resource::<crate::player::Player>();
let mut spells = crate::ui_action::Spells::empty_for_tests();
if let Some(row_flags) = flags {
spells.forms.insert(
form,
benilla_formats::ShapeshiftForm {
flags: row_flags,
..Default::default()
},
);
}
f.app.world_mut().insert_resource(spells);
f.merge_mine(&[(FIELD_UNIT_BYTES_1, form << 16)]);
let npc = f.unit_with(
NPC,
if far { 20.0 } else { 3.0 },
&[(FIELD_UNIT_NPC_FLAGS, GOSSIP)],
);
f.press_on(npc, NPC, false);
f.right_click();
let what = format!("form {form}, row {flags:?}, out of reach {far}");
if refused {
assert_eq!(f.errors(), ["ERR_CANT_INTERACT_SHAPESHIFTED"], "{what}");
assert!(!greeted(&f.sent(), NPC), "{what}");
} else {
assert!(f.errors().is_empty(), "{what}: {:?}", f.errors());
assert_eq!(greeted(&f.sent(), NPC), !far, "{what}");
}
}
}
}
/// `StartAttack` (`0x5ecb70`) returns before anything when the unit flags carry `0x20000`
/// (`0x5ecbaa`) or `[0xb4b3e4]` is clear (`0x5ecbc0`): no stand, no swing, no sheath. Its set is
/// those two and its own dead and mounted returns; the stunned, fleeing, confused and charmed
/// refusals are the Attack action's (`0x612df0`), which a right-click never reaches (`0x60c247`).
#[test]
fn a_right_click_attack_is_refused_by_start_attacks_own_gates() {
// (what, own unit flags over PVP, control lost, swings)
for (what, flags, lost, swings) in [
("in control", 0, false, true),
("control lost", 0, true, false),
("pacified", 0x2_0000, false, false),
("stunned, in control", 0x4_0000, false, true),
("fleeing, in control", 0x80_0000, false, true),
] {
let mut f = fix(false);
f.app.world_mut().init_resource::<crate::player::Player>();
if lost {
f.lose_control();
}
f.merge_mine(&[(FIELD_UNIT_FLAGS, 0x8 | flags)]);
let foe = f.unit_with(FOE, 3.0, &[]);
f.press_on(foe, FOE, true);
f.right_click();
let sent = f.sent();
assert_eq!(swung_at(&sent, FOE), swings, "{what}: {sent:?}");
assert!(
sent.iter()
.any(|c| matches!(c, ClientCommand::SetSelection { guid: FOE })),
"{what}: the click's select comes first: {sent:?}"
);
let world = f.app.world_mut();
let started = !world
.resource_mut::<Messages<crate::player::StandStateRequest>>()
.drain()
.collect::<Vec<_>>()
.is_empty()
|| !world
.resource_mut::<Messages<crate::creature_anim::SheathRequest>>()
.drain()
.collect::<Vec<_>>()
.is_empty();
assert_eq!(
started, swings,
"{what}: the stand and the sheath go with the swing"
);
}
}
/// The world click router (`0x481f60`) reads the player's `UNIT_FIELD_CHARMEDBY` first
/// (`0x481f98`) and sends every click to the nothing leg (`0x492d30`) with zero points: nothing
/// is selected, interacted with or walked to. A nameplate's click enters at `0x4949f0` instead,
/// past that test, so its `SelectTarget` (`0x492870`) returns and the unit's interact (`0x492881`)
/// still runs.
#[test]
fn a_charmed_players_world_right_click_does_nothing_and_a_plates_still_interacts() {
for (charmed, plate) in [(false, false), (true, false), (true, true)] {
let mut f = fix(charmed);
f.app.world_mut().init_resource::<crate::player::Player>();
let npc = f.unit_with(NPC, 3.0, &[(FIELD_UNIT_NPC_FLAGS, GOSSIP)]);
f.press_on(npc, NPC, false);
if plate {
f.plate_right_click(npc);
} else {
f.right_click();
}
let what = format!("charmed {charmed}, plate {plate}");
assert_eq!(greeted(&f.sent(), NPC), !charmed || plate, "{what}");
assert_eq!(
f.selected(),
if charmed { Some(OLD) } else { Some(NPC) },
"{what}"
);
}
}
/// The router's nothing leg for a charmed player has no points to walk along (`0x492d3f` on a
/// zero ray), and the ground leg's walk is the router's too.
#[test]
fn a_charmed_players_ground_right_click_walks_nowhere() {
for charmed in [false, true] {
let mut f = fix(charmed);
f.app.world_mut().init_resource::<crate::player::Player>();
f.app
.world_mut()
.resource_mut::<crate::player::Approach>()
.enabled = true;
*f.app.world_mut().resource_mut::<PressPick>() = PressPick {
occlusion: PickOcclusion {
distance: 10.0,
point: Some(Vec3::new(0.0, 0.0, -10.0)),
..PickOcclusion::default()
},
..PressPick::default()
};
f.right_click();
assert_eq!(
f.app.world().resource::<crate::player::Approach>().active(),
!charmed,
"charmed {charmed}"
);
}
}
/// The wire's commands that mark a plate right-click's steps, in the order they went out.
fn steps(sent: &[ClientCommand]) -> Vec<&'static str> {
sent.iter()
.filter_map(|c| match c {
ClientCommand::CastSpell {
target: Some(_), ..
} => Some("cast"),
ClientCommand::SetSelection { .. } => Some("select"),
ClientCommand::AttackSwing { .. } => Some("swing"),
ClientCommand::GossipHello { .. } => Some("gossip"),
_ => None,
})
.collect()
}
/// A plate's right-click (`0x7cb910` → `0x4949f0` action 4 → `0x492820`) runs `SelectTarget`
/// (`0x492870`), which with a targeting cursor up binds its cast to the unit (`0x6e5b40`, which
/// empties the word and sends) and returns; then the unit's interact (`0x492881`) runs, and its
/// attack leg's own `SelectTarget` (`0x5eccaa`) finds no cursor, so it selects, and swings. The
/// wire reads cast, select, swing; and cast, gossip on the service leg, which selects nothing.
/// With no cursor the select comes first. No world-frame press came first to cancel the cursor
/// (`0x492c20`).
#[test]
fn a_plate_right_click_binds_then_selects_then_acts_in_the_references_order() {
// (the leg, the cursor is up, the wire's steps)
for (attack, cursor, want) in [
(true, true, vec!["cast", "select", "swing"]),
(true, false, vec!["select", "swing"]),
(false, true, vec!["cast", "gossip"]),
(false, false, vec!["select", "gossip"]),
] {
let mut f = fix(false);
f.app.world_mut().init_resource::<crate::player::Player>();
if cursor {
f.cursor_up();
}
let (guid, fields): (u64, &[(u16, u32)]) = if attack {
(FOE, &[])
} else {
(NPC, &[(FIELD_UNIT_NPC_FLAGS, GOSSIP)])
};
let unit = f.unit_with(guid, 3.0, fields);
f.press_on(unit, guid, attack);
f.plate_right_click(unit);
let sent = f.sent();
let what = format!("attack leg {attack}, cursor up {cursor}: {sent:?}");
assert_eq!(steps(&sent), want, "{what}");
if cursor {
assert!(
sent.iter().any(|c| matches!(
c,
ClientCommand::CastSpell { spell_id, target: Some(t) }
if *spell_id == super::front_tests::HEAL && *t == guid
)),
"the heal is bound to the plate's unit: {what}"
);
assert!(!f.cursor_is_up(), "{what}");
}
let selected = if attack || !cursor {
Some(guid)
} else {
Some(OLD)
};
assert_eq!(f.selected(), selected, "{what}");
}
}
/// A unit `x` yd off under a press that latched a spell cursor with `spell_unable` as its
/// verdict, against the interact's own range: (the unit's distance, the verdict, in reach). The
/// verdict says the opposite of the range in both rows.
const REACH_CASES: [(f32, bool, bool); 2] = [(3.0, true, true), (20.0, false, false)];
const CORPSE: u64 = 0x53;
/// `0x5f0130` takes the player-to-unit distance (`0x5f01b9`-`0x5f01fd`) against 5.5556 yd, not
/// the spell cursor's verdict the press latched.
#[test]
fn the_talk_leg_measures_its_own_reach_not_the_spell_cursors() {
for (x, spell_unable, in_reach) in REACH_CASES {
let mut f = fix(false);
f.app.world_mut().init_resource::<crate::player::Player>();
let npc = f.unit_with(NPC, x, &[(FIELD_UNIT_NPC_FLAGS, GOSSIP)]);
f.press_under_spell(npc, NPC, false, spell_unable);
f.right_click();
assert_eq!(
greeted(&f.sent(), NPC),
in_reach,
"at {x} yd, the spell cursor unable {spell_unable}"
);
}
}
/// Loot rides `CanLootNow` (`0x5ec110`), the melee reach.
#[test]
fn the_loot_leg_measures_its_own_reach_not_the_spell_cursors() {
for (x, spell_unable, in_reach) in REACH_CASES {
let mut f = fix(false);
f.app.world_mut().init_resource::<crate::player::Player>();
let body = f.unit_with(
CORPSE,
x,
&[(FIELD_UNIT_HEALTH, 0), (FIELD_UNIT_DYNAMIC_FLAGS, 0x1)],
);
f.press_under_spell(body, CORPSE, false, spell_unable);
f.right_click();
let looted = f
.sent()
.iter()
.any(|c| matches!(c, ClientCommand::Loot { guid: CORPSE }));
assert_eq!(
looted, in_reach,
"at {x} yd, the spell cursor unable {spell_unable}"
);
}
}
/// Skin rides the melee reach: out of it the click walks there (`0x5f06a1`) and casts nothing, in
/// it the click casts.
#[test]
fn the_skin_leg_measures_its_own_reach_not_the_spell_cursors() {
const SKINNING: u32 = 8613;
for (x, spell_unable, in_reach) in REACH_CASES {
let mut f = fix(false);
f.app.world_mut().init_resource::<crate::player::Player>();
f.app
.world_mut()
.resource_mut::<crate::player::Approach>()
.enabled = true;
f.app
.world_mut()
.resource_mut::<crate::ui_action::LearnedAbilities>()
.skinning = Some(SKINNING);
let body = f.unit_with(
CORPSE,
x,
&[
(FIELD_UNIT_HEALTH, 0),
(FIELD_UNIT_FLAGS, cursor_mode::UNIT_FLAG_SKINNABLE),
],
);
f.press_under_spell(body, CORPSE, false, spell_unable);
f.right_click();
let cast = !f
.app
.world()
.resource::<crate::ui_action::ClickCasts>()
.0
.is_empty();
let walking = f.app.world().resource::<crate::player::Approach>().active();
assert_eq!(
(cast, walking),
(in_reach, !in_reach),
"at {x} yd, the spell cursor unable {spell_unable}"
);
}
}
/// The router's legs for a payload still held at the release (`0x481f60`): the ground and nothing
/// legs clear any payload on a right-click (`0x492c90`, `0x492d30`), the object leg a preview one
/// only, `[0xb4b41c]` set (`0x492ce0`), a charmed player's click is the nothing leg, and a corpse
/// is an object like a unit.
#[test]
fn the_router_legs_clear_the_payload_as_the_reference_does() {
// (held, under the press, charmed, cleared)
for (held, under, charmed, cleared) in [
(Held::Item, Under::Nothing, false, true),
(Held::Row, Under::Nothing, false, true),
(Held::Item, Under::Unit, false, false),
(Held::Row, Under::Unit, false, true),
(Held::Item, Under::Corpse, false, false),
(Held::Row, Under::Corpse, false, true),
(Held::Item, Under::Unit, true, true),
] {
let mut f = fix(charmed);
f.hold(held);
let mob = f.mob;
let hovered = match under {
Under::Nothing => Hovered::default(),
Under::Unit => Hovered {
target: Some(mob),
guid: Some(MOB),
..Hovered::default()
},
Under::Corpse => Hovered {
corpse: Some(mob),
corpse_guid: Some(MOB),
..Hovered::default()
},
};
f.app.world_mut().resource_mut::<PressPick>().hovered = hovered;
f.app
.world_mut()
.resource_mut::<Messages<WorldRightClick>>()
.write(WorldRightClick);
f.app
.world_mut()
.run_system_once(click::world_right_click_payload)
.expect("the payload leg runs");
assert_eq!(
!f.held(),
cleared,
"{held:?} under {under:?}, charmed {charmed}"
);
}
}
/// The UI root's press handler runs the world input hook (`0x492b50`) before any frame, when no
/// frame holds a press: a right press with anything on the cursor clears it and is consumed
/// (`0x766370`), so the world never sees it and neither the turn nor the release's interact
/// starts. Over a unit too, which the router's own legs (`0x492ce0`) would have kept.
#[test]
fn a_right_press_with_a_payload_is_consumed_before_the_world_sees_it() {
// (held, button, consumed)
for (held, button, consumed) in [
(Some(Held::Item), MouseButton::Right, true),
(Some(Held::Row), MouseButton::Right, true),
(None, MouseButton::Right, false),
(Some(Held::Item), MouseButton::Left, false),
] {
let mut f = fix(false);
match held {
Some(held) => f.hold(held),
None => f
.app
.world_mut()
.insert_non_send_resource(UiScript::new().expect("a VM")),
}
let world = f.app.world_mut();
let mut window = Window {
resolution: UVec2::new(1024, 768).into(),
..default()
};
window.set_cursor_position(Some(Vec2::new(100.0, 100.0)));
world.spawn((window, bevy::window::PrimaryWindow));
let mut buttons = world.resource_mut::<ButtonInput<MouseButton>>();
buttons.clear();
buttons.press(button);
crate::ui_script::feed_input_once(world);
let what = format!("{held:?}, {button:?}");
assert_eq!(
world
.resource::<crate::ui_script::PlayerUiPressConsumed>()
.0,
consumed,
"{what}"
);
if consumed {
assert!(!f.held(), "{what}: the press dropped the payload");
} else if held.is_some() {
assert!(f.held(), "{what}: the payload stays");
}
}
}
/// `TargetByName` (`0x489d60`) leaves the target alone on a miss and shows message `0x127`
/// (`ERR_UNIT_NOT_FOUND`) for a name given (`0x489dbd`, tested on the raw string's first byte, so
/// blanks count) or `0xb8` (`ERR_GENERIC_NO_TARGET`) for an empty one (`0x489dcf`); the chat
/// layer's `/target` request is the binding's body. A hit shows nothing. The Lua binding's miss is
/// pinned in `script_calls`, whose call host fires the lines inside the call.
#[test]
fn a_target_name_that_finds_nobody_shows_the_references_line() {
let request = |f: &mut Fix, name: &str| {
f.app
.world_mut()
.resource_mut::<Messages<TargetByNameRequest>>()
.write(TargetByNameRequest { name: name.into() });
f.app
.world_mut()
.run_system_once(by_name::target_by_name_requests)
.expect("the drain runs");
};
for (name, line) in [
("Nobody", "ERR_UNIT_NOT_FOUND"),
(" ", "ERR_UNIT_NOT_FOUND"),
("", "ERR_GENERIC_NO_TARGET"),
] {
let mut f = fix(false);
request(&mut f, name);
assert_eq!(f.errors(), [line], "{name:?}");
assert_eq!(f.selected(), Some(OLD), "{name:?}: the target stays");
assert!(f.sent().is_empty(), "{name:?}");
}
let mut f = fix(false);
f.app
.world_mut()
.resource_mut::<crate::names::NameCache>()
.insert_player(MOB, "Mob".into(), None);
request(&mut f, "Mob");
assert!(f.errors().is_empty(), "{:?}", f.errors());
assert_eq!(f.selected(), Some(MOB));
}
+16 -7
View File
@@ -23,6 +23,8 @@ mod flash;
mod front;
#[cfg(test)]
mod front_tests;
#[cfg(test)]
mod gate_tests;
mod highlight;
pub(crate) mod hover;
mod hover_probe;
@@ -185,6 +187,13 @@ pub(crate) struct PressPick {
}
impl PressPick {
/// The router's pick state is an object (`[this+0x350]` 2, `0x481fb8`): a unit, a corpse, a
/// unit the `IsSelectable` grader refused (an object hit that selects nothing) or a
/// GameObject. Anything else is the ground or the sky.
pub(crate) fn over_object(&self) -> bool {
self.hovered.any().is_some() || self.hovered.refused || self.object.target.is_some()
}
/// Whether the press was an Attack-cursor press, for `0x5ecb70`'s new-target validation.
pub(crate) fn attack(&self) -> bool {
self.cursor.kind == cursor_mode::CursorKind::Attack
@@ -298,6 +307,7 @@ impl Plugin for TargetPlugin {
.add_message::<TargetByNameRequest>()
.add_message::<AssistRequest>()
.add_message::<click::DeselectGuid>()
.add_message::<click::PlateRightClick>()
.add_message::<Interact>()
.add_systems(
Startup,
@@ -340,19 +350,18 @@ impl Plugin for TargetPlugin {
crate::spell::targeting::commit_ground_cast_on_click,
crate::spell::targeting::commit_object_cast_on_click,
// The verb a walk owes runs at its stop, ahead of this frame's click.
(
click::act_on_arrival,
click::act_on_right_click,
click::act_on_interact,
)
.chain(),
(click::act_on_arrival, click::act_on_right_click).chain(),
// The loot close's guid-scoped deselect.
click::clear_target_requests,
// The script calls that touch the selection, the cast or the targeting cursor
// (`TargetUnit`, `/target`, `ClearTarget`, `/cast`, `UseAction`, …), applied
// in the order the script made them; then `DropItemOnUnit`'s pet leg.
// in the order the script made them; then the unit interact of a nameplate's
// right-click and a crate's `Interact`, after the pass's head bound the
// cursor's parked picks (the cast goes out ahead of the interact it came
// with); then `DropItemOnUnit`'s pet leg.
(
crate::script_calls::apply_script_calls,
click::act_on_interact,
crate::ui_action::drop_item::drop_item_on_unit,
)
.chain(),
+5 -2
View File
@@ -442,7 +442,9 @@ impl TabHistory {
}
}
/// What one [`commit`] did; `swung` means it already sent `CMSG_ATTACKSWING`, so a caller must not.
/// What one [`commit`] did; `swung` means it ran the re-swing (`0x4938c8`), so a caller must not ask
/// again. StartAttack's own gate may have refused it ([`crate::creature_anim::StartGate`]), and the
/// caller's would refuse alike: `swung` says it was asked, not that `CMSG_ATTACKSWING` went out.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) struct CommitOutcome {
pub(super) changed: bool,
@@ -497,7 +499,8 @@ pub(super) fn commit(
let _ = seam.net.0.send(ClientCommand::SetSelection { guid });
let swung = stop_and_repoint && new_attackable && self_guid != Some(guid);
if swung {
// `0x4938c8`: the swing goes out despite the lock, and cancels auto-repeat (`0x6ea080`).
// `0x4938c8`: the swing goes out despite the lock, and cancels auto-repeat (`0x6ea080`),
// unless StartAttack's gate returns first.
seam.start(guid, engaged, true);
}
CommitOutcome {
+10 -1
View File
@@ -21,6 +21,8 @@ pub(crate) struct ScriptSelect<'w, 's> {
/// included.
commit: SelectCommit<'w, 's>,
by_name: ByNameScan<'w, 's>,
/// Where a name that finds nobody shows its line.
errors: ResMut<'w, crate::ui_action::UiErrorKeys>,
/// The TAB cycler's inputs, for the `TargetNearest*` calls.
scan: scan::TargetScan<'w, 's>,
history: ResMut<'w, scan::TabHistory>,
@@ -76,7 +78,13 @@ impl ScriptSelect<'_, '_> {
/// `TargetByName(name, exactMatch)` (`0x489d60`), the stock `/target`.
pub(crate) fn target_by_name(&mut self, name: &str, exact: bool) {
by_name::target_named(&self.by_name, &mut self.commit, name, exact);
by_name::target_named(
&self.by_name,
&mut self.commit,
&mut self.errors,
name,
exact,
);
}
/// `TargetNearest*([reverse])` (`0x489a80`/`0x489aa0`/`0x489ac0`/`0x489ae0` →
@@ -131,6 +139,7 @@ mod tests {
world.init_resource::<crate::spell::QueuedMeleeSpell>();
world.init_resource::<crate::spell::PendingCast>();
world.init_resource::<crate::ui_cast::CastReports>();
world.init_resource::<crate::ui_action::UiErrorKeys>();
world.init_resource::<crate::spell::AutoRepeatActive>();
world.init_resource::<crate::ui_loot::LootState>();
world.init_resource::<crate::ui_loot::LootLatch>();
@@ -107,10 +107,12 @@ impl AttackPress<'_, '_> {
"attack {} at {guid:#x}",
if engaged { "toggled off" } else { "swing" }
);
let gate = seam.start_gate();
crate::creature_anim::toggle_attack_local(
e,
guid,
engaged,
gate,
&mut seam.slots,
&mut seam.auto_repeat,
&mut seam.sheath,
+4 -5
View File
@@ -372,11 +372,10 @@ pub(crate) fn attack_actor_refusal(
true
}
/// The same ladder without the message, in the reference's gate order. The world right-click
/// asks this: its path (`0x60bea0` to `0x5ecb70`) shows no error text and never reaches
/// `0x612df0`, whose callers are the pet bar, Attack and TryCast (`0x6e4efb`). That path's own
/// gate set overlaps this one but is not transcribed.
pub(crate) fn attack_actor_blocked(
/// The same ladder without the message, in the reference's gate order. Its callers are the Attack
/// action, the pet bar and TryCast (`0x6e4efb`); the world right-click's path (`0x60bea0` to
/// `0x5ecb70`) never reaches `0x612df0` and has its own gates (`creature_anim::StartGate`).
fn attack_actor_blocked(
actor: Option<&ObjectStore>,
self_guid: Option<u64>,
) -> Option<&'static str> {
+3 -3
View File
@@ -53,9 +53,9 @@ pub(crate) use cast_fail::report_reaches_pop;
#[cfg(test)]
pub(crate) use cast_fail::CAST_FAIL_KEYS;
pub(crate) use errors::{
attack_actor_blocked, attack_actor_refusal, keyed_line, keyed_line_s, reagent_totem_refusal,
show_messages, ui_error_text, CastFail, Caster, FillArg, MessageSink, MountErrors,
PetTameFailures, Shown, UiError, UiErrorKeys, UiErrorTexts,
attack_actor_refusal, keyed_line, keyed_line_s, reagent_totem_refusal, show_messages,
ui_error_text, CastFail, Caster, FillArg, MessageSink, MountErrors, PetTameFailures, Shown,
UiError, UiErrorKeys, UiErrorTexts,
};
pub(crate) use weapon_icon::{melee_auto_attack_icon, ranged_weapon_icon, substitutes_weapon_icon};
+3 -3
View File
@@ -230,7 +230,7 @@ pub(super) fn feed_action_state(
// The caster's and the target's reach and motion, which the range compare reads.
crate::spell::RangeUnits,
// `[0xb4b3e4]`, which the usable walk reads.
Option<Res<crate::player::Player>>,
Option<Res<crate::player::OwnControlLost>>,
// A profession opener's line (`0x6de040`), which the checked test reads, and the skill
// lines the walk's cost finds its level through.
Option<Res<crate::ui_spellbook::SkillLines>>,
@@ -281,10 +281,10 @@ pub(super) fn feed_action_state(
memory.last_cd_trace = Some(now);
}
let (bound, spell_mods, range_units, player, skill_lines, created) = &cast_state;
let (bound, spell_mods, range_units, own_control, skill_lines, created) = &cast_state;
let skill_catalog = skill_lines.as_deref().map(|s| &s.catalog);
let me = self_q.iter().next();
let control_lost = player.as_ref().is_some_and(|p| p.own_control_lost);
let control_lost = own_control.as_ref().is_some_and(|c| c.0);
// The bags, walked once per frame for every reagent and totem count below.
let stacks = me
.map(|(s, _, _)| stack_counts(&s.0, &objects))
+14 -12
View File
@@ -781,6 +781,7 @@ fn lost_control_greys_the_bar_but_the_immunity_spell() {
..Spells::empty_for_tests()
})
.init_resource::<crate::player::Player>()
.init_resource::<crate::player::OwnControlLost>()
.init_resource::<Cooldowns>()
.init_resource::<crate::spell::SpellModifiers>()
.init_resource::<crate::ui_script::UiClock>()
@@ -846,8 +847,8 @@ fn lost_control_greys_the_bar_but_the_immunity_spell() {
events(&mut app); // the first push's own signal
app.world_mut()
.resource_mut::<crate::player::Player>()
.own_control_lost = true;
.resource_mut::<crate::player::OwnControlLost>()
.0 = true;
app.update();
assert_eq!(
lit(&app),
@@ -857,8 +858,8 @@ fn lost_control_greys_the_bar_but_the_immunity_spell() {
assert_eq!(events(&mut app), 1, "the cache change signals the bar once");
app.world_mut()
.resource_mut::<crate::player::Player>()
.own_control_lost = false;
.resource_mut::<crate::player::OwnControlLost>()
.0 = false;
app.update();
assert_eq!(lit(&app), [true, true, true], "control back, all lit again");
assert_eq!(events(&mut app), 1);
@@ -975,6 +976,7 @@ fn feed_app(buttons: &[(u8, u32, u8)], spells: Spells, x: f32) -> App {
.init_resource::<crate::spell::SpellTargeting>()
.init_resource::<crate::spell::HeldForPick>()
.init_resource::<crate::player::Player>()
.init_resource::<crate::player::OwnControlLost>()
.init_resource::<Items>()
.insert_resource(NetCommands(tx));
feed_inputs(&mut app);
@@ -1299,8 +1301,8 @@ fn spell_update_usable_fires_from_every_usable_refresh_and_once_a_frame() {
assert_eq!(frame(&mut app), USABLE, "the clear");
app.world_mut()
.resource_mut::<crate::player::Player>()
.own_control_lost = true;
.resource_mut::<crate::player::OwnControlLost>()
.0 = true;
assert_eq!(frame(&mut app), USABLE, "control lost");
let now = Instant::now();
@@ -1333,8 +1335,8 @@ fn spell_update_usable_fires_from_every_usable_refresh_and_once_a_frame() {
edge(&mut app, ME, Player, 23, 480, 470);
select(&mut app, Some(OTHER));
app.world_mut()
.resource_mut::<crate::player::Player>()
.own_control_lost = false;
.resource_mut::<crate::player::OwnControlLost>()
.0 = false;
assert_eq!(
frame(&mut app),
USABLE,
@@ -1357,8 +1359,8 @@ fn a_refresh_that_changes_a_slot_signals_the_bar_then_the_spells() {
);
frame(&mut app);
app.world_mut()
.resource_mut::<crate::player::Player>()
.own_control_lost = true;
.resource_mut::<crate::player::OwnControlLost>()
.0 = true;
assert_eq!(
frame(&mut app),
["ACTIONBAR_UPDATE_USABLE", "SPELL_UPDATE_USABLE"]
@@ -1402,8 +1404,8 @@ fn usable_reader_app() -> App {
/// Sets whether the player has lost control, as `SMSG_CLIENT_CONTROL_UPDATE` reports it.
fn set_control_lost(app: &mut App, lost: bool) {
app.world_mut()
.resource_mut::<crate::player::Player>()
.own_control_lost = lost;
.resource_mut::<crate::player::OwnControlLost>()
.0 = lost;
}
/// `SetPlayerControl` (`0x4958e0`) stores the flag (`0x4958ea`) and signals
@@ -1311,18 +1311,18 @@ fn shipped_bag_frame_drives_end_to_end() {
"a pickup alone queues no move"
);
// Right-click while holding: the right arm has no cursor test (ContainerFrame.lua:583-599),
// and `UseContainerItem` clears the cursor before it reads the slot (`0x4fa198`).
// Right-click while holding: the UI root's press handler runs the world hook first (`0x492b50`,
// `0x766370`), which puts the held item back and consumes the press, so the slot's right arm
// (ContainerFrame.lua:583-599) never sees it.
s.mouse_button(bx, by, "RightButton", true);
s.mouse_button(bx, by, "RightButton", false);
assert_eq!(
s.take_container_uses(),
vec![(0, 1)],
"the right arm uses the slot even with a full cursor"
assert!(
s.take_container_uses().is_empty(),
"no frame saw the press with a full cursor"
);
assert!(
s.cursor_item().is_none(),
"…after putting the held item back"
"the press put the held item back"
);
s.mouse_button(bx, by, "RightButton", true);
@@ -276,6 +276,12 @@ fn shipped_merchant_frame_drives_end_to_end() {
s.take_merchant_buys().is_empty(),
"left-click does not buy (pickup pending the cursor arc)"
);
// The row is on the cursor, so a right press drops it and buys nothing: the world hook
// (`0x492b50`) takes the press ahead of every frame.
s.mouse_button(cx, cy, "RightButton", true);
s.mouse_button(cx, cy, "RightButton", false);
assert!(s.take_merchant_buys().is_empty(), "a held row buys nothing");
assert!(s.cursor_payload().is_none(), "the press dropped the row");
s.mouse_button(cx, cy, "RightButton", true);
s.mouse_button(cx, cy, "RightButton", false);
assert_eq!(s.take_merchant_buys(), vec![(1, 1)]);
+3 -2
View File
@@ -143,8 +143,9 @@ pub(crate) struct UiKeyboardCapture {
pub(crate) struct PlayerUiClickConsumed(pub(crate) bool);
/// A press the UI consumed this frame, whatever is under the pointer: the layout drag's press
/// returns at `0x766353`–`0x76635b`, before any frame, the world frame included, sees it, so the
/// world latch takes no press this frame even with no UI frame hovered.
/// returns at `0x766353`–`0x76635b`, and a right press with a payload held at the world input
/// hook (`0x492b50`, `0x766370`), each before any frame, the world frame included, sees it, so
/// the world latch takes no press this frame even with no UI frame hovered.
#[derive(Resource, Default)]
pub(crate) struct PlayerUiPressConsumed(pub(crate) bool);
+1
View File
@@ -25,6 +25,7 @@ pub(crate) use latch::{
dispatch, fire, note_closed, queue, register, release, with_script, CoreCloses, InteractLatch,
LatchApp, LatchVocal, LatchedWindow, NpcWindow,
};
pub(crate) use release::form_bars_interaction;
/// Owns [`InteractLatch`], its watchdog and [`InteractNpc`], read by the `"npc"` portrait and the
/// interaction face-me.
+6 -5
View File
@@ -27,10 +27,11 @@ fn form_of(bytes_1: u32) -> u8 {
(bytes_1 >> 16) as u8
}
/// Whether taking `form` closes the open window (`0x5de010`): a form other than none whose row is
/// not a stance (`0x60e9f0`) and lacks the interact flag (`0x5de072`). A form with no row reads
/// nothing here, where the reference faults.
pub(super) fn form_releases(forms: &HashMap<u32, ShapeshiftForm>, form: u8) -> bool {
/// Whether `form` bars the player from NPCs: a form other than none whose row is not a stance
/// (`IsShapeShifted`, `0x60e9f0`) and lacks the interact flag (`0x5f017f`, `0x5de072`).
/// `InteractWithNpc` (`0x5f0130`) refuses a talk with it, and taking it closes the open window
/// (`0x5de010`). A form with no row reads nothing here, where the reference faults.
pub(crate) fn form_bars_interaction(forms: &HashMap<u32, ShapeshiftForm>, form: u8) -> bool {
form != 0
&& forms
.get(&u32::from(form))
@@ -60,7 +61,7 @@ pub(super) fn release_on_field_edges(
&& form_of(e.old) != form_of(e.new)
&& spells
.as_ref()
.is_some_and(|s| form_releases(&s.forms, form_of(e.new)))
.is_some_and(|s| form_bars_interaction(&s.forms, form_of(e.new)))
{
commands.queue(|world: &mut World| release_latch(world, true));
}
@@ -8,7 +8,7 @@ use benilla_protocol::field::{FIELD_UNIT_BYTES_1, FIELD_UNIT_HEALTH};
use benilla_protocol::messages::ObjectType;
use super::latch_tests::{harness, latch, open, vocals, BANKER, VENDOR};
use super::release::{form_releases, release_on_field_edges};
use super::release::{form_bars_interaction, release_on_field_edges};
use super::{LatchVocal, NpcWindow};
use crate::net::{FieldChanged, SelfPlayer};
use crate::ui_action::Spells;
@@ -211,7 +211,7 @@ fn the_shipped_forms_split_into_those_that_close_the_window_and_those_that_do_no
let Some(spells) = crate::ui_action::real_spells() else {
return;
};
let closes = |id: u8| form_releases(&spells.forms, id);
let closes = |id: u8| form_bars_interaction(&spells.forms, id);
for (id, name) in [
(1, "Cat"),
(2, "Tree"),
+7 -6
View File
@@ -143,7 +143,7 @@ fn feed_shapeshift_bar(
items: Res<Items>,
clock: Res<crate::ui_script::UiClock>,
spell_mods: Res<crate::spell::SpellModifiers>,
player: Option<Res<crate::player::Player>>,
own_control: Option<Res<crate::player::OwnControlLost>>,
// The skill lines the walk's cost finds its level through.
skill_lines: Option<Res<crate::ui_spellbook::SkillLines>>,
mut memory: Local<crate::ui_script::VmMemo<StanceMemory>>,
@@ -206,7 +206,7 @@ fn feed_shapeshift_bar(
cooldowns: &cooldowns,
stacks: &stacks,
spell_mods: &spell_mods,
control_lost: player.as_ref().is_some_and(|p| p.own_control_lost),
control_lost: own_control.as_ref().is_some_and(|c| c.0),
skill_lines: skill_lines.as_deref().map(|s| &s.catalog),
};
usable::spell_usable(id, d, &spells, &ctx, &objects, &items).0
@@ -445,6 +445,7 @@ mod tests {
..Spells::empty_for_tests()
})
.init_resource::<crate::player::Player>()
.init_resource::<crate::player::OwnControlLost>()
.init_resource::<Cooldowns>()
.init_resource::<crate::spell::SpellModifiers>()
.init_resource::<crate::ui_script::UiClock>()
@@ -485,15 +486,15 @@ mod tests {
assert!(castable(&app, 1) && castable(&app, 2), "in control");
app.world_mut()
.resource_mut::<crate::player::Player>()
.own_control_lost = true;
.resource_mut::<crate::player::OwnControlLost>()
.0 = true;
app.update();
assert!(!castable(&app, 1), "the plain form greys");
assert!(castable(&app, 2), "the fear immunity passes the scan");
app.world_mut()
.resource_mut::<crate::player::Player>()
.own_control_lost = false;
.resource_mut::<crate::player::OwnControlLost>()
.0 = false;
app.update();
assert!(castable(&app, 1) && castable(&app, 2), "control back");
}
+5 -8
View File
@@ -511,7 +511,7 @@ fn answer_trade_request(
mut errors: ResMut<crate::ui_action::UiErrorKeys>,
social: Res<crate::ui_social::SocialState>,
cinematic: Res<crate::cinematic::Cinematic>,
player: Res<crate::player::Player>,
own_control: Res<crate::player::OwnControlLost>,
auction: Res<crate::ui_auction::AuctionOpen>,
block_trades: Res<BlockTrades>,
index: Res<GuidIndex>,
@@ -551,11 +551,7 @@ fn answer_trade_request(
// player control (`[0xb4b3e4]`, which `0x4958e0` writes, not an in-world flag) or an open
// auction house (`[0xb725f8]`, its auctioneer, not a pending trade).
let dead_or_ghost = initiator_store.is_some_and(|s| s.0.is_dead_or_ghost());
if dead_or_ghost
|| cinematic.is_playing()
|| player.own_control_lost
|| auction.auctioneer.is_some()
{
if dead_or_ghost || cinematic.is_playing() || own_control.0 || auction.auctioneer.is_some() {
info!(target: "trade", "BEGIN_TRADE from {initiator:#x} refused silently; sending CMSG_BUSY_TRADE");
let _ = commands.0.send(ClientCommand::BusyTrade);
trade.refuse_request();
@@ -2174,6 +2170,7 @@ mod tests {
.init_resource::<crate::ui_auction::AuctionOpen>()
.init_resource::<GuidIndex>()
.init_resource::<crate::player::Player>()
.init_resource::<crate::player::OwnControlLost>()
.insert_resource(names)
.insert_resource(NetCommands(tx))
.insert_resource(BlockTrades(block_trades))
@@ -2183,8 +2180,8 @@ mod tests {
crate::cinematic::Cinematic::default()
});
app.world_mut()
.resource_mut::<crate::player::Player>()
.own_control_lost = !controlled;
.resource_mut::<crate::player::OwnControlLost>()
.0 = !controlled;
// Streamed, since leg 3 drops a request from a guid that resolves to nothing.
for guid in [0x7u64, 0x8] {
let e = app.world_mut().spawn(ObjectStore::default()).id();
+11 -10
View File
@@ -818,18 +818,18 @@ fn fire_faction_on_forced_table(
/// (`0x4958e0`), for our own character only (`0x5fa6d0`); the boot value, and a fresh VM's memo, is in control (`0x48f626`).
fn feed_player_control(
script: Option<NonSendMut<UiScript>>,
player: Option<Res<crate::player::Player>>,
own_control: Option<Res<crate::player::OwnControlLost>>,
mut lost: Local<crate::ui_script::VmMemo<bool>>,
) {
let (Some(mut script), Some(player)) = (script, player) else {
let (Some(mut script), Some(own_control)) = (script, own_control) else {
return;
};
let lost = lost.get(&script);
if *lost != player.own_control_lost {
*lost = player.own_control_lost;
if *lost != own_control.0 {
*lost = own_control.0;
// `HasFullControl`'s flag rides the same edge.
script.set_player_control(!player.own_control_lost);
let event = if player.own_control_lost {
script.set_player_control(!own_control.0);
let event = if own_control.0 {
"PLAYER_CONTROL_LOST"
} else {
"PLAYER_CONTROL_GAINED"
@@ -2776,6 +2776,7 @@ mod tests {
const FIELD_PLAYER_FARSIGHT: u16 = 712;
let mut app = App::new();
app.init_resource::<crate::player::Player>()
.init_resource::<crate::player::OwnControlLost>()
.add_systems(Update, (feed_player_control, feed_farsight_focus));
let script = UiScript::new().unwrap();
script
@@ -2813,13 +2814,13 @@ mod tests {
"in control, no far sight: quiet"
);
app.world_mut()
.resource_mut::<crate::player::Player>()
.own_control_lost = true;
.resource_mut::<crate::player::OwnControlLost>()
.0 = true;
assert_eq!(seen(&mut app), vec!["PLAYER_CONTROL_LOST".to_string()]);
assert_eq!(seen(&mut app), Vec::<String>::new(), "held, not repeated");
app.world_mut()
.resource_mut::<crate::player::Player>()
.own_control_lost = false;
.resource_mut::<crate::player::OwnControlLost>()
.0 = false;
assert_eq!(seen(&mut app), vec!["PLAYER_CONTROL_GAINED".to_string()]);
// A possessed creature stuck under fear or confusion is not our character losing control:
@@ -71,19 +71,19 @@ pub(crate) fn bypass_candidates(catalog: &SpellCatalog) -> Vec<u32> {
/// back; only a change is pushed, and a fresh VM gets it again.
pub(super) fn feed_control_bypass(
script: Option<NonSendMut<UiScript>>,
player: Option<Res<crate::player::Player>>,
own_control: Option<Res<crate::player::OwnControlLost>>,
spells: Option<Res<crate::ui_action::Spells>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
mut pushed: Local<VmMemo<HashSet<u32>>>,
mut candidates: Local<Option<Vec<u32>>>,
) {
let (Some(mut script), Some(player)) = (script, player) else {
let (Some(mut script), Some(own_control)) = (script, own_control) else {
return;
};
if spells.as_ref().is_some_and(|s| s.is_changed()) {
*candidates = None;
}
let want = match (player.own_control_lost, spells.as_deref(), self_q.single()) {
let want = match (own_control.0, spells.as_deref(), self_q.single()) {
(true, Some(spells), Ok(store)) => {
let list = candidates.get_or_insert_with(|| bypass_candidates(&spells.catalog));
control_bypass_spells(&spells.catalog, list, &store.0)
@@ -225,6 +225,7 @@ mod tests {
use benilla_ui::script::{ContainerSlot, ContainerState};
let mut app = App::new();
app.init_resource::<crate::player::Player>()
.init_resource::<crate::player::OwnControlLost>()
.insert_resource(crate::ui_action::Spells {
catalog: catalog(),
..crate::ui_action::Spells::empty_for_tests()
@@ -260,8 +261,8 @@ mod tests {
))
.id();
app.world_mut()
.resource_mut::<crate::player::Player>()
.own_control_lost = true;
.resource_mut::<crate::player::OwnControlLost>()
.0 = true;
app.update();
let calls = |app: &mut App| {
+13
View File
@@ -804,6 +804,19 @@ impl super::UiScript {
clear_cursor(&mut self.model_mut());
}
/// The world click router's object leg (`0x492ce0`): `ClearCursor(1, 1)` only while a preview
/// payload is held, `[0xb4b41c]` set, which is a vendor row (mode 5) or an item action lifted
/// off the bar (mode 7); any other payload stays held.
pub fn clear_preview_payload(&mut self) {
let preview = matches!(
self.model_ref().cursor,
Some(CursorPayload::Merchant(_) | CursorPayload::Action(_))
);
if preview {
clear_cursor(&mut self.model_mut());
}
}
/// Test-only: set the payload directly, bypassing the Lua bindings.
#[cfg(test)]
pub(crate) fn set_cursor_for_test(&mut self, payload: CursorPayload) {
+2 -2
View File
@@ -652,8 +652,8 @@ mod tests {
}
/// A press whose frame hid (the removal cleared the capture, `0x764cad`) reaches no one on the
/// release: `0x766420` dispatches to the capture alone, and the world hook `0x492b50` runs
/// only in freelook, so the world click `0x495300` never runs.
/// release: `0x766420` dispatches to the capture alone, and the world hook `0x492b50` sends
/// a release to the world frame only in freelook, so the world click `0x495300` never runs.
#[test]
fn a_press_whose_frame_hid_is_not_a_world_drop() {
let mut s = drag_script();
+18 -3
View File
@@ -16,6 +16,7 @@ use crate::order;
use crate::widget::{ButtonState, FrameHandle};
use super::clip::{hit_clip, hit_rect};
use super::tick::verb;
use super::{button, cursor, editbox, event, protected, Model, UiScript};
/// The double-click interval, 300 ms, a constant in the Button mouse-up dispatcher `0x7792d0`
@@ -353,7 +354,7 @@ impl UiScript {
// it and returns before its sweep (`0x766164`), and the hover stays the frame it was when
// the drag began.
let mut release_lost = false;
let press_target: Option<crate::widget::FrameHandle> = if down {
let (press_target, hooked): (Option<crate::widget::FrameHandle>, bool) = if down {
let mut model = self.model_mut();
// A press for a button still held means its release never came (a focus loss); the
// reference's capture never loses one. That frame gets the release edge so it does not
@@ -376,10 +377,24 @@ impl UiScript {
} else {
hit_id.and_then(|id| model.id_to_frame.get(&id).copied())
};
held.or(hover)
// The world input hook `0x492b50`, which the handler runs on the event record before
// it picks a frame, and only with no capture held (`0x76635c`-`0x766370`): a right
// press with a payload on the cursor or a gift wrap pending clears the cursor and is
// consumed.
let hooked = held.is_none()
&& button == "RightButton"
&& (model.cursor.is_some() || model.pending_wrap.is_some());
(held.or(hover), hooked)
} else {
None
(None, false)
};
if hooked {
// `ClearCursor(1, 1)` at `0x492bb5`; the handler returns without a capture, a raise or
// a press slot, so no frame sees this press and its release finds none to go to.
self.model_mut().cursor_pos = (x, y);
verb(&self.lua, cursor::clear_cursor);
return true;
}
let press_id: Option<u32> =
press_target.and_then(|h| self.model_ref().frame_to_id.get(&h).copied());
// ── The raise, first ────────────────────────────────────────────────────────────────────
@@ -102,6 +102,7 @@ mod visibility;
mod wheel;
mod widget_args;
mod widget_surface;
mod world_press_hook;
mod worldframe;
mod worldmap;
@@ -0,0 +1,145 @@
//! The world input hook `0x492b50`, which the UI root's press handler `0x7662c0` runs ahead of
//! every frame when no frame holds a press (`0x76635c`-`0x766376`): a right press with a payload
//! on the cursor or a gift wrap pending clears the cursor with `ClearCursor(1, 1)` and is
//! consumed, so no frame sees it, and the world does not either.
use super::common::script;
use crate::script::cursor::{two_item_bag, CursorMacro, CursorPayload, CursorSpell};
use crate::script::tick::heard;
use crate::script::UiScript;
/// A button at (100..300, 100..200) counting what reaches it.
fn button_script() -> UiScript {
let mut s = script();
s.run(
r#"
DOWNS, UPS, CLICKS = 0, 0, 0
local b = CreateFrame("Button", "B")
b:SetPoint("BOTTOMLEFT", 100, 100); b:SetWidth(200); b:SetHeight(100)
b:EnableMouse(true)
b:RegisterForClicks("LeftButtonUp", "RightButtonDown", "RightButtonUp")
b:SetScript("OnMouseDown", function() DOWNS = DOWNS + 1 end)
b:SetScript("OnMouseUp", function() UPS = UPS + 1 end)
b:SetScript("OnClick", function() CLICKS = CLICKS + 1 end)
"#,
)
.unwrap();
s.resolve();
s
}
fn counts(s: &UiScript) -> (i64, i64, i64) {
s.eval::<(i64, i64, i64)>("return DOWNS, UPS, CLICKS")
.unwrap()
}
fn spell() -> CursorPayload {
CursorPayload::Spell(CursorSpell {
passive: false,
spell_id: 133,
texture: None,
})
}
/// Over a frame or over nothing, a right press with any payload drops it and goes no further: the
/// frame gets no `OnMouseDown`, no `OnClick` and, at the release, no `OnMouseUp`; the events of
/// the clear have run by the time the press returns.
#[test]
fn a_right_press_with_a_payload_drops_it_and_reaches_no_frame() {
let mut s = button_script();
heard::listen(&s, &["CURSOR_UPDATE"], None);
s.set_cursor_for_test(spell());
assert!(
s.mouse_button(150.0, 150.0, "RightButton", true),
"consumed"
);
assert!(s.cursor_payload().is_none(), "the payload dropped");
assert_eq!(heard::take(&s), ["CURSOR_UPDATE"], "fired in the press");
assert_eq!(counts(&s), (0, 0, 0), "no frame saw the press");
s.mouse_button(150.0, 150.0, "RightButton", false);
assert_eq!(counts(&s), (0, 0, 0), "nor its release");
// Over nothing there is no frame to consume it: the hook does.
s.set_cursor_for_test(spell());
assert!(s.mouse_button(900.0, 700.0, "RightButton", true));
assert!(s.cursor_payload().is_none());
assert!(
!s.mouse_button(900.0, 700.0, "RightButton", true),
"with nothing held, the same press is nobody's"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `0x492b50` tests each payload word in turn (`0x492b62`-`0x492ba3`): an item, coins, a spell,
/// a pet action, a macro, a stabled pet and a preview all count.
#[test]
fn every_payload_kind_is_the_hooks() {
let mut s = button_script();
s.set_container(0, Some(two_item_bag()));
for held in ["item", "macro", "spell"] {
match held {
"item" => s.run("PickupContainerItem(0, 1)").unwrap(),
"macro" => s.set_cursor_for_test(CursorPayload::Macro(CursorMacro {
index: 1,
texture: None,
})),
_ => s.set_cursor_for_test(spell()),
}
assert!(s.cursor_payload().is_some(), "{held} is held");
assert!(s.mouse_button(150.0, 150.0, "RightButton", true), "{held}");
assert!(s.cursor_payload().is_none(), "{held} dropped");
}
assert_eq!(counts(&s), (0, 0, 0));
assert!(
!s.item_locked(117) && !s.item_locked(118),
"a held item is unlocked by the clear"
);
}
/// A pending gift wrap is held cursor state too (`0x492ba5`, `0x5edf10`): the press cancels it.
#[test]
fn a_right_press_cancels_a_pending_gift_wrap() {
let mut s = button_script();
s.set_container(0, Some(two_item_bag()));
s.arm_gift_wrap(0, 1);
assert!(s.gift_wrap_armed().is_some());
assert!(s.mouse_button(150.0, 150.0, "RightButton", true));
assert_eq!(s.gift_wrap_armed(), None);
assert_eq!(counts(&s), (0, 0, 0));
}
/// With nothing held the press is an ordinary one: the frame under it gets it.
#[test]
fn a_right_press_with_nothing_held_reaches_the_frame() {
let mut s = button_script();
assert!(s.mouse_button(150.0, 150.0, "RightButton", true));
s.mouse_button(150.0, 150.0, "RightButton", false);
assert_eq!(
counts(&s),
(1, 1, 2),
"down, up, the down click and the up click"
);
}
/// The hook runs only when no frame holds a press (`0x766361`-`0x766369`): a right press chorded
/// onto a held left press goes to the frame that holds it, and the payload stays. And only the
/// right button (`0x492b5c`) is the hook's.
#[test]
fn the_hook_waits_for_a_free_mouse_and_a_right_press() {
let mut s = button_script();
s.mouse_button(150.0, 150.0, "LeftButton", true);
s.set_cursor_for_test(spell());
s.mouse_button(150.0, 150.0, "RightButton", true);
assert!(
s.cursor_payload().is_some(),
"the chord's press is the frame's"
);
assert_eq!(counts(&s).0, 2, "both presses reached the held frame");
s.mouse_button(150.0, 150.0, "RightButton", false);
s.mouse_button(150.0, 150.0, "LeftButton", false);
s.set_cursor_for_test(spell());
s.mouse_button(150.0, 150.0, "LeftButton", true);
assert!(s.cursor_payload().is_some(), "a left press never drops it");
assert_eq!(counts(&s).0, 3);
}
@@ -977,11 +977,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// so "Rag" selects Ragnaros. Any unit (typemask 8), no dead, reaction, range or self gate. A
// missing name, or one neither string nor number, raises `Usage:` (`0x489d69`, `0x6f4940`); a
// number is taken as its string. Deviation: among whole-name matches ours picks the nearest,
// because the reference's first-walked pick is order-dependent and reads as a bug. A miss is
// silent here, where the reference shows `0x127` ERR_UNIT_NOT_FOUND, or `0xb8`
// ERR_GENERIC_NO_TARGET for an empty name. The pick is shown as `"target"` at once, from the
// app's candidates (`by_name`), so `UnitName("target")` on the next line names it, unless it
// is not selectable and `SetSelection` returns (`0x4935ee`); the queued call still commits it.
// because the reference's first-walked pick is order-dependent and reads as a bug. A miss
// shows `0x127` ERR_UNIT_NOT_FOUND, or `0xb8` ERR_GENERIC_NO_TARGET for an empty name
// (`0x489dbd`, `0x489dcf`), from the app's commit. The pick is shown as `"target"` at once,
// from the app's candidates (`by_name`), so `UnitName("target")` on the next line names it,
// unless it is not selectable and `SetSelection` returns (`0x4935ee`); the queued call still
// commits it.
g.set(
"TargetByName",
lua.create_function(|lua, (name, exact): (Value, Option<Value>)| {
+2 -2
View File
@@ -218,8 +218,8 @@ fn leave_mouse_index(model: &mut Model, h: FrameHandle) -> Option<Option<u32>> {
/// The removal tail's capture clear (`0x764ca5`–`0x764cad`): a frame leaving the mouse index
/// that holds the capture slot (`root+0x80`) empties it. `0x766420` delivers a release to the
/// capture alone, so the press this frame took reaches no one: no `OnMouseUp`, no click, and no
/// world click, since the world hook `0x492b50` runs only in freelook. The button stays held
/// until its release, which then finds nothing.
/// world click, since the world hook `0x492b50` sends a release to the world only in freelook.
/// The button stays held until its release, which then finds nothing.
fn drop_capture(model: &mut Model, h: FrameHandle) {
if model.mouse_capture == Some(h) {
model.mouse_capture = None;
+7 -7
View File
@@ -205,7 +205,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
- A settle hold is summed and sent once on release, without the push, because it stands in for the reference's blocking load (`0x694920`), not a refused substep, and lasts seconds. ([`src/player/movement_net.rs:303`](../crates/benilla-app/src/player/movement_net.rs#L303))
- The reference's blocking load (`0x694920`) waits out its reads before the frame goes on and has no such release; without one, a model that never loads would freeze our body for good. ([`src/player/state.rs:120`](../crates/benilla-app/src/player/state.rs#L120))
- Root is not merged, because a bare pose unrooting us while the server still roots us streams motion into `CHEAT_TYPE_ROOT_MOVE`; vmangos keeps a rooted mover's root bit (`MovementHandler.cpp:1064`), so the reference's merge of it is a no-op anyway. ([`src/player/state.rs:202`](../crates/benilla-app/src/player/state.rs#L202))
- Applied at arrival, because our own avatar has no motion to de-jitter; the reference routes it through the replay chain remotes use, which paces to the sender's cadence and can hold an early packet (up to 1000 ms in `net::motion`). ([`src/player/wire_in.rs:413`](../crates/benilla-app/src/player/wire_in.rs#L413))
- Applied at arrival, because our own avatar has no motion to de-jitter; the reference routes it through the replay chain remotes use, which paces to the sender's cadence and can hold an early packet (up to 1000 ms in `net::motion`). ([`src/player/wire_in.rs:414`](../crates/benilla-app/src/player/wire_in.rs#L414))
#### portrait
@@ -273,18 +273,18 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
#### spell
- An out-of-range byte drops the packet, because the reference's unchecked write overruns the table (`(64, 19)` lands on the class family at `0xcecaac`). ([`src/spell/mods.rs:106`](../crates/benilla-app/src/spell/mods.rs#L106))
- The fallback is the packet's target guid, never the nearest-hostile acquire, because that would attack something the player never named. ([`src/spell/net.rs:920`](../crates/benilla-app/src/spell/net.rs#L920))
- The fallback is the packet's target guid, never the nearest-hostile acquire, because that would attack something the player never named. ([`src/spell/net.rs:933`](../crates/benilla-app/src/spell/net.rs#L933))
- A range failure leaves targeting armed and working. The reference's report cancels the cast (`0x6e2218` → `0x6e4940`): its line, `SPELLCAST_FAILED` and the tradeskill disarm, which benilla keeps; with the word the bind emptied it also sends `CMSG_CANCEL_CAST` for the unsent spell (`0x6e4970`) and clears the caster, then restores the pre-click word (`0x6e6096`), so every later click fails the caster test (`0x6e5b93`) and the cursor is stranded until a cancel. ([`src/spell/targeting/mod.rs:516`](../crates/benilla-app/src/spell/targeting/mod.rs#L516))
#### target
- The reference's arm reads the last object's stop (`0x611336` before `0x610383` stores this one's), so its first walk has no stop. ([`src/target/click.rs:352`](../crates/benilla-app/src/target/click.rs#L352))
- It runs on the first frame with none, so an arrival that sends no stop (mid-air, rooted, under a held turn key) acts at once or on landing or release, where the reference waits for the next ground stop and can act long after, wherever the player then is. ([`src/target/click.rs:1046`](../crates/benilla-app/src/target/click.rs#L1046))
- The reference's arm reads the last object's stop (`0x611336` before `0x610383` stores this one's), so its first walk has no stop. ([`src/target/click.rs:411`](../crates/benilla-app/src/target/click.rs#L411))
- It runs on the first frame with none, so an arrival that sends no stop (mid-air, rooted, under a held turn key) acts at once or on landing or release, where the reference waits for the next ground stop and can act long after, wherever the player then is. ([`src/target/click.rs:1164`](../crates/benilla-app/src/target/click.rs#L1164))
- The rank is held to 7, the top of the ring scale's eight entries (`0..=7`), where the reference keeps any value; a server rank past 7 would index outside our palette. ([`src/target/relations.rs:491`](../crates/benilla-app/src/target/relations.rs#L491))
- TAB uses Classic Era's screen-space priority, not 1.12's ±30° facing cone and snapshot cursor (`0x493f60`), because the cone skips a close mob in front of the camera, which sits behind the character. ([`src/target/scan.rs:3`](../crates/benilla-app/src/target/scan.rs#L3))
- Our own body is never a candidate. The reference's friendly scan picks the player at some facings (`CanAssist(P, P)` is true, `0x6061e0`; the cone test `0x47f220`), but our scoring always puts the player dead centre at distance 0, so CTRL-TAB would pick us on every press. ([`src/target/scan.rs:193`](../crates/benilla-app/src/target/scan.rs#L193))
- No scene-attach gate (the reference's `0x6704c0`), because the draw election hides every body outside the frustum, which would empty the off-screen tier. ([`src/target/scan.rs:367`](../crates/benilla-app/src/target/scan.rs#L367))
- Sampled each frame, not stamped in `SetSelection`, so every selection writer is covered; a selected unit that turns hostile is remembered, where the reference's is not. ([`src/target/scan.rs:762`](../crates/benilla-app/src/target/scan.rs#L762))
- Sampled each frame, not stamped in `SetSelection`, so every selection writer is covered; a selected unit that turns hostile is remembered, where the reference's is not. ([`src/target/scan.rs:765`](../crates/benilla-app/src/target/scan.rs#L765))
#### textinput
@@ -432,7 +432,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
- The files are under `benilla-config/Logs/`, since the install is read-only. ([`src/ui_script/load_log.rs:5`](../crates/benilla-app/src/ui_script/load_log.rs#L5))
- `FULLSCREEN_QUADS_RESEAT` re-sizes the full-screen quads, which the stock files size only in their OnLoads, because a resize here keeps the loaded UI and the blackout would stop short of the new screen. ([`src/ui_script/manifest.rs:64`](../crates/benilla-app/src/ui_script/manifest.rs#L64))
- No `FrameXML.toc` signature check, where `UI_Init` loads on a failed one and then quits (`0x401560(0xa)` at `0x48ff90`), because the check verifies Blizzard's signature with a key carried in the client binary, which benilla does not ship, and a modified interface file is loaded rather than refused. ([`src/ui_script/manifest.rs:161`](../crates/benilla-app/src/ui_script/manifest.rs#L161))
- A flat 0.9, because 1.0 reads oversized. With `useUiScale` off, its default (`0x8430c0`), the reference sets `max(768/H, 0.9)` above 768 px tall and 1.0 at or below (`0x492f70`, on a mode set and from `0x4908ad`), so the two agree from ~853 px up. benilla never reads `useUiScale`: this CVar always applies to `UIParent` alone, as with it on. ([`src/ui_script/mod.rs:238`](../crates/benilla-app/src/ui_script/mod.rs#L238))
- A flat 0.9, because 1.0 reads oversized. With `useUiScale` off, its default (`0x8430c0`), the reference sets `max(768/H, 0.9)` above 768 px tall and 1.0 at or below (`0x492f70`, on a mode set and from `0x4908ad`), so the two agree from ~853 px up. benilla never reads `useUiScale`: this CVar always applies to `UIParent` alone, as with it on. ([`src/ui_script/mod.rs:239`](../crates/benilla-app/src/ui_script/mod.rs#L239))
- The body is read with 0x4000-byte reads filled to the end of the body, so what a read returns does not depend on how the network cut the packets; and the reading stops once the buffer is full, since no later byte can change it. ([`src/ui_script/server_alert.rs:23`](../crates/benilla-app/src/ui_script/server_alert.rs#L23))
- The connection is direct. The reference opens its session with access type 0 (`InternetOpenA`, `0x7a6d97`), the system's proxy settings, which benilla does not read. ([`src/ui_script/server_alert.rs:26`](../crates/benilla-app/src/ui_script/server_alert.rs#L26))
- On a worldport it fires with our descriptor present, so `UnitExists("player")` answers true where the reference's (`0x515970`) misses, because matching it breaks `UnitExists` for no stock reader. ([`src/ui_script/sweep.rs:150`](../crates/benilla-app/src/ui_script/sweep.rs#L150))
@@ -700,7 +700,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
- A last `spell_id` key breaks the remaining ties (Charge's six ranks all have `spellLevel` 0), because the reference leaves them to its qsort over an array benilla does not reproduce; ascending id is ascending rank in every 1.12 pet-ability family. ([`src/script/craft.rs:103`](../crates/benilla-ui/src/script/craft.rs#L103))
- On `Terrain` the reference keeps a spell, action, macro or pet action on the left button; we clear them, because otherwise a spell has no left-click way off the cursor. ([`src/script/cursor.rs:528`](../crates/benilla-ui/src/script/cursor.rs#L528))
- Every lock lifts here, bag contents included. The reference's walk of a bag's contents indexes the bag by its own player-array index (`0x495528`, `0x495535`), at least 19, past a bag's slots in every case but the biggest bags, so it normally unlocks none of them; a lock it strands lasts until the item's seat or stack moves, which this does not copy. ([`src/script/cursor.rs:700`](../crates/benilla-ui/src/script/cursor.rs#L700))
- Returns nothing. The handler ends `mov eax,1` with nothing pushed (`0x4895bc`), so Lua 5.0 hands back the stack top, the last argument or the function itself: an accident of the handler, not an API, and copying a stack-top leak would copy a bug. ([`src/script/cursor.rs:914`](../crates/benilla-ui/src/script/cursor.rs#L914))
- Returns nothing. The handler ends `mov eax,1` with nothing pushed (`0x4895bc`), so Lua 5.0 hands back the stack top, the last argument or the function itself: an accident of the handler, not an API, and copying a stack-top leak would copy a bug. ([`src/script/cursor.rs:927`](../crates/benilla-ui/src/script/cursor.rs#L927))
- An auto-store into a bank item or keyring slot sends nothing, because no container id names it and no stock caller passes one; the reference sends it and the server refuses. ([`src/script/cursor/bag_verbs.rs:25`](../crates/benilla-ui/src/script/cursor/bag_verbs.rs#L25))
- Slot 11 is refused, because the reference's gate (`cmp esi,0xa; jbe`) admits it and neither `0x4bce00` nor `0x4bc9a0` bounds-checks again, so it writes one dword past the array, and the autocast toggle (`0x4bcbb0`) reads that word (`[0xb71438+0x28]`) with no bounds check. ([`src/script/cursor/pet.rs:19`](../crates/benilla-ui/src/script/cursor/pet.rs#L19))
- The list is the window sizes this client can present, not the device's display modes, because benilla makes no exclusive mode-set. `"WxH"` is a contract: `OptionsFrame.lua:281-284` splits on the `x` and `CT_Viewport.lua:107` matches `"(%d+)x(%d+)"`, both failing silently. ([`src/script/cvars.rs:81`](../crates/benilla-ui/src/script/cvars.rs#L81))