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