diff --git a/crates/benilla-app/src/creature_anim.rs b/crates/benilla-app/src/creature_anim.rs index e9380a273..360bade7d 100644 --- a/crates/benilla-app/src/creature_anim.rs +++ b/crates/benilla-app/src/creature_anim.rs @@ -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, 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, 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>, - /// 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>, + /// `[0xb4b3e4]`, for [`Self::start_gate`]. + own_control: Option>, /// 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, diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 1df489e6c..919e2d2d7 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -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> = 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. diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs index 6ba54c477..68badd981 100644 --- a/crates/benilla-app/src/player.rs +++ b/crates/benilla-app/src/player.rs @@ -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::(); app.init_resource::(); app.init_resource::(); + app.init_resource::(); app.init_resource::(); // 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. diff --git a/crates/benilla-app/src/player/camera.rs b/crates/benilla-app/src/player/camera.rs index d1fc9262d..49e86f605 100644 --- a/crates/benilla-app/src/player/camera.rs +++ b/crates/benilla-app/src/player/camera.rs @@ -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, - // 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>, mut rig: ResMut, cameras: Query<&Camera, With>, diff --git a/crates/benilla-app/src/player/controller.rs b/crates/benilla-app/src/player/controller.rs index 8e0a7f78e..e1aec8838 100644 --- a/crates/benilla-app/src/player/controller.rs +++ b/crates/benilla-app/src/player/controller.rs @@ -49,7 +49,7 @@ pub(super) fn control( // `SMSG_STANDSTATE_UPDATE`, applied locally and ungated, with nothing sent back. MessageReader, ), - speed_capsule: ( + mut speed_capsule: ( Res, Res, Res, @@ -64,6 +64,8 @@ pub(super) fn control( Res, // The loading cover blanks input at the source; a gesture in flight is unwound below. Res, + // Our own `[0xb4b3e4]`, which a control verdict on our character writes. + ResMut, ), mut commands: Commands, mut player: ResMut, @@ -281,6 +283,7 @@ pub(super) fn control( &time, &mut commands, &mut player, + &mut speed_capsule.10, &mut cam, &net.0, &mut net.1, diff --git a/crates/benilla-app/src/player/state.rs b/crates/benilla-app/src/player/state.rs index 6187324bb..e23ef1895 100644 --- a/crates/benilla-app/src/player/state.rs +++ b/crates/benilla-app/src/player/state.rs @@ -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. diff --git a/crates/benilla-app/src/player/wire_in.rs b/crates/benilla-app/src/player/wire_in.rs index ac7a1b56f..2cafaaea7 100644 --- a/crates/benilla-app/src/player/wire_in.rs +++ b/crates/benilla-app/src/player/wire_in.rs @@ -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, @@ -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, mut player: ResMut, + mut own_control: ResMut, ) { 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, mut lost: MessageReader, mut player: ResMut, + mut own_control: ResMut, ) { // `|`, 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::() + .init_resource::() .add_message::() .add_message::() .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::().0 = true; let mut p = app.world_mut().resource_mut::(); 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::().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::default()); + assert!(!app.world().resource::().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::()); + let mut player = app.world_mut().resource_mut::(); + track_control(&mut player, &mut own, control_verdict(ME, false, Some(ME))); + *app.world_mut().resource_mut::() = own; + } + fn harness() -> App { let mut app = App::new(); app.add_plugins(MinimalPlugins) .init_resource::() + .init_resource::() .add_message::() .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::(); - 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::().control_lost); + assert!(app.world().resource::().0); app.update(); assert!( - app.world().resource::().own_control_lost, + app.world().resource::().0, "nothing restores it without a create" ); app.world_mut().write_message(WorldEnterCascadeMessage); app.update(); + assert!( + !app.world().resource::().0, + "the bar's flag is back" + ); let player = app.world().resource::(); - 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::(); - track_control(&mut player, control_verdict(ME, false, Some(ME))); + lose_control(&mut app); app.update(); - assert!(app.world().resource::().own_control_lost); + assert!(app.world().resource::().0); app.world_mut() .spawn((crate::net::Guid(ME), crate::net::SelfPlayer)); app.update(); let player = app.world().resource::(); - assert!(!player.own_control_lost && !player.control_lost); + assert!(!app.world().resource::().0 && !player.control_lost); } /// A possession does not outlive the world either: the create claims our own mover. diff --git a/crates/benilla-app/src/script_calls.rs b/crates/benilla-app/src/script_calls.rs index 8f74eed6d..c20b5307b 100644 --- a/crates/benilla-app/src/script_calls.rs +++ b/crates/benilla-app/src/script_calls.rs @@ -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::(); + world.resource_mut::().0 = true; + if engaged { + let me = world.resource::().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::(); + world.resource_mut::().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. diff --git a/crates/benilla-app/src/spell/cast_send.rs b/crates/benilla-app/src/spell/cast_send.rs index f5dee0fa3..6ac13c836 100644 --- a/crates/benilla-app/src/spell/cast_send.rs +++ b/crates/benilla-app/src/spell/cast_send.rs @@ -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, } } diff --git a/crates/benilla-app/src/spell/cast_target.rs b/crates/benilla-app/src/spell/cast_target.rs index abada88cb..243812355 100644 --- a/crates/benilla-app/src/spell/cast_target.rs +++ b/crates/benilla-app/src/spell/cast_target.rs @@ -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>, + /// `[0xb4b3e4]`, for the cast tail's start of melee. + own_control: Option>, /// 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), } } } diff --git a/crates/benilla-app/src/spell/inflight.rs b/crates/benilla-app/src/spell/inflight.rs index fd406ceea..203dbf8e3 100644 --- a/crates/benilla-app/src/spell/inflight.rs +++ b/crates/benilla-app/src/spell/inflight.rs @@ -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() } diff --git a/crates/benilla-app/src/spell/net.rs b/crates/benilla-app/src/spell/net.rs index 42c032ea9..5d97676ff 100644 --- a/crates/benilla-app/src/spell/net.rs +++ b/crates/benilla-app/src/spell/net.rs @@ -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>, } /// `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, 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, 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, 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 { + // `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 { let mut app = App::new(); app.add_message::() .add_message::() @@ -3083,6 +3104,7 @@ mod tests { &mut crate::spell::AutoRepeatActive::default(), &mut sheath, engaged, + gate, ), 1, ); @@ -3101,23 +3123,40 @@ mod tests { .collect::>() }; + 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`); diff --git a/crates/benilla-app/src/target/by_name.rs b/crates/benilla-app/src/target/by_name.rs index f01529fe6..23052f14d 100644 --- a/crates/benilla-app/src/target/by_name.rs +++ b/crates/benilla-app/src/target/by_name.rs @@ -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 { 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 ` 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, scan_params: ByNameScan, mut commit: SelectCommit, + mut errors: ResMut, ) { 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, tokens: crate::ui_unit::UnitTokens, mover: Query<(&Guid, &ObjectStore), With>, - cast: Res, mut errors: ResMut, mut follow: ResMut, mut approach: ResMut, @@ -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::().0[&ME]; + world + .entity_mut(me) + .get_mut::() + .expect("our descriptor") + .0 + .merge(benilla_protocol::ObjectFields::from_pairs(&[( + CHANNEL_SPELL, + DRAIN_LIFE, + )])); + } + if cast { + world.resource_mut::().push( + DRAIN_LIFE, + None, + std::time::Instant::now(), + ); + } + let want: (Vec<&str>, Option) = 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. diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs index 67de3dc58..b52aec9b6 100644 --- a/crates/benilla-app/src/target/click.rs +++ b/crates/benilla-app/src/target/click.rs @@ -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, // The press pick: this leg and [`act_on_right_click`] must classify the same pick. press: Res, + me: Query<&ObjectStore, With>, script: Option>, ) { 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, press: Res, @@ -44,7 +62,7 @@ pub(super) fn select_on_plate_click( mut front: super::front::SelectFront, self_q: Query<(&Guid, Has), With>, mut greeting: MessageWriter, - mut right_clicks: MessageWriter, + mut right_clicks: MessageWriter, 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, // 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, ) { - 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, + mut plate_clicks: MessageReader, + press: Res, mut selection: ResMut, 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::>(); + world.init_resource::>(); world.init_resource::(); world.init_resource::(); world.init_resource::(); @@ -2167,6 +2285,7 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world.init_resource::(); world.init_resource::>(); world.init_resource::(); @@ -2202,14 +2321,19 @@ mod tests { .query_filtered::>() .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::() .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 { - 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::>() - .write(WorldRightClick); - world.run_system_once(act_on_right_click).unwrap(); - assert_eq!( - world.resource::().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. diff --git a/crates/benilla-app/src/target/cursor_mode.rs b/crates/benilla-app/src/target/cursor_mode.rs index 11bba42a8..bd8e09c62 100644 --- a/crates/benilla-app/src/target/cursor_mode.rs +++ b/crates/benilla-app/src/target/cursor_mode.rs @@ -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`. diff --git a/crates/benilla-app/src/target/front_tests.rs b/crates/benilla-app/src/target/front_tests.rs index b88d7dae5..445bd69ce 100644 --- a/crates/benilla-app/src/target/front_tests.rs +++ b/crates/benilla-app/src/target/front_tests.rs @@ -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, - mob: Entity, +pub(super) struct Fix { + pub(super) app: App, + pub(super) rx: Receiver, + 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::() .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::() .active() } - fn selected(&self) -> Option { + pub(super) fn selected(&self) -> Option { self.app.world().resource::().guid } - fn click(&mut self, pick: Hovered) { + pub(super) fn click(&mut self, pick: Hovered) { let world = self.app.world_mut(); world.resource_mut::().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::() @@ -130,7 +130,7 @@ impl Fix { } /// The `NpcGreetingRequest`s written since the last look. - fn greetings(&mut self) -> Vec { + pub(super) fn greetings(&mut self) -> Vec { self.app .world_mut() .resource_mut::>() @@ -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>, Vec) { + pub(super) fn wire(&self) -> (Vec>, Vec) { let sent: Vec<_> = self.rx.try_iter().collect(); let casts = sent .iter() diff --git a/crates/benilla-app/src/target/gate_tests.rs b/crates/benilla-app/src/target/gate_tests.rs new file mode 100644 index 000000000..c17618198 --- /dev/null +++ b/crates/benilla-app/src/target/gate_tests.rs @@ -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::().0[&ME]; + self.app + .world_mut() + .entity_mut(me) + .get_mut::() + .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::() + .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 { + 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::>() + .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::() + .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 { + self.rx.try_iter().collect() + } + + fn errors(&self) -> Vec<&'static str> { + self.app + .world() + .resource::() + .0 + .iter() + .map(|e| e.key) + .collect() + } + + /// The player's control, `[0xb4b3e4]`. + fn lose_control(&mut self) { + self.app + .world_mut() + .resource_mut::() + .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::() + .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::(); + 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::(); + 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::>() + .drain() + .collect::>() + .is_empty() + || !world + .resource_mut::>() + .drain() + .collect::>() + .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::(); + 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::(); + f.app + .world_mut() + .resource_mut::() + .enabled = true; + *f.app.world_mut().resource_mut::() = 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::().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::(); + 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::(); + 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::(); + 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::(); + f.app + .world_mut() + .resource_mut::() + .enabled = true; + f.app + .world_mut() + .resource_mut::() + .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::() + .0 + .is_empty(); + let walking = f.app.world().resource::().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::().hovered = hovered; + f.app + .world_mut() + .resource_mut::>() + .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::>(); + buttons.clear(); + buttons.press(button); + crate::ui_script::feed_input_once(world); + let what = format!("{held:?}, {button:?}"); + assert_eq!( + world + .resource::() + .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::>() + .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::() + .insert_player(MOB, "Mob".into(), None); + request(&mut f, "Mob"); + assert!(f.errors().is_empty(), "{:?}", f.errors()); + assert_eq!(f.selected(), Some(MOB)); +} diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index a63d27112..ce14b215f 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -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::() .add_message::() .add_message::() + .add_message::() .add_message::() .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(), diff --git a/crates/benilla-app/src/target/scan.rs b/crates/benilla-app/src/target/scan.rs index 3854dbf79..899f504f0 100644 --- a/crates/benilla-app/src/target/scan.rs +++ b/crates/benilla-app/src/target/scan.rs @@ -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 { diff --git a/crates/benilla-app/src/target/script.rs b/crates/benilla-app/src/target/script.rs index 1bc841bd2..dce983adc 100644 --- a/crates/benilla-app/src/target/script.rs +++ b/crates/benilla-app/src/target/script.rs @@ -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::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); diff --git a/crates/benilla-app/src/ui_action/drain.rs b/crates/benilla-app/src/ui_action/drain.rs index f1cd34e9f..46e4c6ded 100644 --- a/crates/benilla-app/src/ui_action/drain.rs +++ b/crates/benilla-app/src/ui_action/drain.rs @@ -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, diff --git a/crates/benilla-app/src/ui_action/errors.rs b/crates/benilla-app/src/ui_action/errors.rs index bfea9de0f..d1969b10b 100644 --- a/crates/benilla-app/src/ui_action/errors.rs +++ b/crates/benilla-app/src/ui_action/errors.rs @@ -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, ) -> Option<&'static str> { diff --git a/crates/benilla-app/src/ui_action/mod.rs b/crates/benilla-app/src/ui_action/mod.rs index 13971dd7c..ecb603ec6 100644 --- a/crates/benilla-app/src/ui_action/mod.rs +++ b/crates/benilla-app/src/ui_action/mod.rs @@ -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}; diff --git a/crates/benilla-app/src/ui_action/state.rs b/crates/benilla-app/src/ui_action/state.rs index 064f99a67..3ff3397e9 100644 --- a/crates/benilla-app/src/ui_action/state.rs +++ b/crates/benilla-app/src/ui_action/state.rs @@ -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>, + Option>, // A profession opener's line (`0x6de040`), which the checked test reads, and the skill // lines the walk's cost finds its level through. Option>, @@ -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)) diff --git a/crates/benilla-app/src/ui_action/state_tests.rs b/crates/benilla-app/src/ui_action/state_tests.rs index e0993aa3f..937a9b981 100644 --- a/crates/benilla-app/src/ui_action/state_tests.rs +++ b/crates/benilla-app/src/ui_action/state_tests.rs @@ -781,6 +781,7 @@ fn lost_control_greys_the_bar_but_the_immunity_spell() { ..Spells::empty_for_tests() }) .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -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::() - .own_control_lost = true; + .resource_mut::() + .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::() - .own_control_lost = false; + .resource_mut::() + .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::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .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::() - .own_control_lost = true; + .resource_mut::() + .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::() - .own_control_lost = false; + .resource_mut::() + .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::() - .own_control_lost = true; + .resource_mut::() + .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::() - .own_control_lost = lost; + .resource_mut::() + .0 = lost; } /// `SetPlayerControl` (`0x4958e0`) stores the flag (`0x4958ea`) and signals diff --git a/crates/benilla-app/src/ui_script/action_bar_tests.rs b/crates/benilla-app/src/ui_script/action_bar_tests.rs index b793adbd5..5c9fd0115 100644 --- a/crates/benilla-app/src/ui_script/action_bar_tests.rs +++ b/crates/benilla-app/src/ui_script/action_bar_tests.rs @@ -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); diff --git a/crates/benilla-app/src/ui_script/merchant_tests.rs b/crates/benilla-app/src/ui_script/merchant_tests.rs index 2947aaa90..58fae525c 100644 --- a/crates/benilla-app/src/ui_script/merchant_tests.rs +++ b/crates/benilla-app/src/ui_script/merchant_tests.rs @@ -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)]); diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index b7de3136b..0f620ff75 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -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); diff --git a/crates/benilla-app/src/ui_session.rs b/crates/benilla-app/src/ui_session.rs index 7ecc0f671..777ec4ad1 100644 --- a/crates/benilla-app/src/ui_session.rs +++ b/crates/benilla-app/src/ui_session.rs @@ -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. diff --git a/crates/benilla-app/src/ui_session/release.rs b/crates/benilla-app/src/ui_session/release.rs index b43fd0cb3..a00b4e49b 100644 --- a/crates/benilla-app/src/ui_session/release.rs +++ b/crates/benilla-app/src/ui_session/release.rs @@ -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, 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, 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)); } diff --git a/crates/benilla-app/src/ui_session/release_tests.rs b/crates/benilla-app/src/ui_session/release_tests.rs index c8c421ee9..25c47618b 100644 --- a/crates/benilla-app/src/ui_session/release_tests.rs +++ b/crates/benilla-app/src/ui_session/release_tests.rs @@ -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"), diff --git a/crates/benilla-app/src/ui_shapeshift.rs b/crates/benilla-app/src/ui_shapeshift.rs index ae380f28f..aa46c8c32 100644 --- a/crates/benilla-app/src/ui_shapeshift.rs +++ b/crates/benilla-app/src/ui_shapeshift.rs @@ -143,7 +143,7 @@ fn feed_shapeshift_bar( items: Res, clock: Res, spell_mods: Res, - player: Option>, + own_control: Option>, // The skill lines the walk's cost finds its level through. skill_lines: Option>, mut memory: Local>, @@ -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::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -485,15 +486,15 @@ mod tests { assert!(castable(&app, 1) && castable(&app, 2), "in control"); app.world_mut() - .resource_mut::() - .own_control_lost = true; + .resource_mut::() + .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::() - .own_control_lost = false; + .resource_mut::() + .0 = false; app.update(); assert!(castable(&app, 1) && castable(&app, 2), "control back"); } diff --git a/crates/benilla-app/src/ui_trade/mod.rs b/crates/benilla-app/src/ui_trade/mod.rs index fd6b7331c..261899d72 100644 --- a/crates/benilla-app/src/ui_trade/mod.rs +++ b/crates/benilla-app/src/ui_trade/mod.rs @@ -511,7 +511,7 @@ fn answer_trade_request( mut errors: ResMut, social: Res, cinematic: Res, - player: Res, + own_control: Res, auction: Res, block_trades: Res, index: Res, @@ -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::() .init_resource::() .init_resource::() + .init_resource::() .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::() - .own_control_lost = !controlled; + .resource_mut::() + .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(); diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 4e39252b8..1bbb7db2d 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -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>, - player: Option>, + own_control: Option>, mut lost: Local>, ) { - 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::() + .init_resource::() .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::() - .own_control_lost = true; + .resource_mut::() + .0 = true; assert_eq!(seen(&mut app), vec!["PLAYER_CONTROL_LOST".to_string()]); assert_eq!(seen(&mut app), Vec::::new(), "held, not repeated"); app.world_mut() - .resource_mut::() - .own_control_lost = false; + .resource_mut::() + .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: diff --git a/crates/benilla-app/src/ui_unit/control_gate.rs b/crates/benilla-app/src/ui_unit/control_gate.rs index a06ab4f5e..0b2b8cec8 100644 --- a/crates/benilla-app/src/ui_unit/control_gate.rs +++ b/crates/benilla-app/src/ui_unit/control_gate.rs @@ -71,19 +71,19 @@ pub(crate) fn bypass_candidates(catalog: &SpellCatalog) -> Vec { /// back; only a change is pushed, and a fresh VM gets it again. pub(super) fn feed_control_bypass( script: Option>, - player: Option>, + own_control: Option>, spells: Option>, self_q: Query<&ObjectStore, With>, mut pushed: Local>>, mut candidates: Local>>, ) { - 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::() + .init_resource::() .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::() - .own_control_lost = true; + .resource_mut::() + .0 = true; app.update(); let calls = |app: &mut App| { diff --git a/crates/benilla-ui/src/script/cursor.rs b/crates/benilla-ui/src/script/cursor.rs index b6125e226..f0c8f97de 100644 --- a/crates/benilla-ui/src/script/cursor.rs +++ b/crates/benilla-ui/src/script/cursor.rs @@ -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) { diff --git a/crates/benilla-ui/src/script/cursor/drag.rs b/crates/benilla-ui/src/script/cursor/drag.rs index e78f2b219..10036490e 100644 --- a/crates/benilla-ui/src/script/cursor/drag.rs +++ b/crates/benilla-ui/src/script/cursor/drag.rs @@ -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(); diff --git a/crates/benilla-ui/src/script/pointer.rs b/crates/benilla-ui/src/script/pointer.rs index f3544ab88..186822908 100644 --- a/crates/benilla-ui/src/script/pointer.rs +++ b/crates/benilla-ui/src/script/pointer.rs @@ -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 = if down { + let (press_target, hooked): (Option, 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 = press_target.and_then(|h| self.model_ref().frame_to_id.get(&h).copied()); // ── The raise, first ──────────────────────────────────────────────────────────────────── diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index 5f49f1ea2..e3e3bcbdc 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -102,6 +102,7 @@ mod visibility; mod wheel; mod widget_args; mod widget_surface; +mod world_press_hook; mod worldframe; mod worldmap; diff --git a/crates/benilla-ui/src/script/tests/world_press_hook.rs b/crates/benilla-ui/src/script/tests/world_press_hook.rs new file mode 100644 index 000000000..2625b89ca --- /dev/null +++ b/crates/benilla-ui/src/script/tests/world_press_hook.rs @@ -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); +} diff --git a/crates/benilla-ui/src/script/unit/bindings.rs b/crates/benilla-ui/src/script/unit/bindings.rs index 27e8cd1e6..37c84d7b4 100644 --- a/crates/benilla-ui/src/script/unit/bindings.rs +++ b/crates/benilla-ui/src/script/unit/bindings.rs @@ -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)| { diff --git a/crates/benilla-ui/src/script/visibility.rs b/crates/benilla-ui/src/script/visibility.rs index e82d8b3b0..9b0b9f329 100644 --- a/crates/benilla-ui/src/script/visibility.rs +++ b/crates/benilla-ui/src/script/visibility.rs @@ -218,8 +218,8 @@ fn leave_mouse_index(model: &mut Model, h: FrameHandle) -> Option> { /// 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; diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index afa740247..cb06e7f56 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -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))