MouselookStart and MouselookStop start and end mouse look with no button held, and a look session outlives losing focus with the OS cursor handed back (#534)

`MouselookStart()` and `MouselookStop()` now start and end mouse look with no button held, as in 1.12.1, so an addon such as CustomNameplates can steer with the mouse; `IsMouselooking()` reads the same state. Before, both did nothing.

- `MouselookStart` (`0x514210`, ungated) disarms a pending world click (`0x514810(0)`) and sets the turn channel the held right button drives (`0x515090(1, 1, …)`), entering the right button's look session: the same cursor lock and hide, the camera and the character turning with the mouse. `MouselookStop` (`0x514240`) clears it; `IsMouselooking` (`0x514270`) tests it. A right-button release ends a script-started look, as its `TurnOrActionStop` clears the same bit; the loading cover clears it (`0x5144c0`); a window deactivate does not (`0x5144a3 and eax,0xfffff00f` keeps the bit).
- A look session, right-drag or script-started, outlives losing window focus, as 1.12.1's does, and the OS cursor is handed back while the window is unfocused and taken again on focus, with no turn from the pointer's travel away. On macOS winit's cursor lock is the global `CGAssociateMouseAndMouseCursorPosition(false)`, which a deactivate leaves in place, so a held lock would freeze the pointer in other apps; 1.12.1's Windows client gives the cursor back while inactive.
- While focused, the plain right- and left-button paths are unchanged.

Fixes #487

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-28 16:07:37 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 0e39273f13
commit ae6d9f3500
7 changed files with 654 additions and 61 deletions
+1 -1
View File
@@ -483,7 +483,7 @@ mod macos {
}
// A cinematic hides the pointer too (`0x58b590`); `CursorOptions.visible` is inert here, so
// it joins the look's hide/unhide pair, keeping AppKit's hide counter balanced.
let looking = rig.is_looking() || cinematic.is_some_and(|c| c.is_playing());
let looking = rig.holds_cursor() || cinematic.is_some_and(|c| c.is_playing());
let stem = mode.0.stem();
// The key names the chosen cursor, so `NSCursor::set`, a WindowServer round-trip that can
// stall the main thread for milliseconds, fires only on a change or a drift.
+201 -42
View File
@@ -545,6 +545,8 @@ pub(crate) struct CameraControl {
pub(super) world_mouse: WorldMouse,
/// Logical cursor position captured when look began, to restore on release.
pub(super) cursor_stash: Option<Vec2>,
/// A look session is running with the OS cursor handed back: the window is unfocused.
pub(super) cursor_released: bool,
/// The self-avatar's alpha from the camera-to-pivot distance, 1 in third person to 0 in first
/// (`self_model_fade_alpha`); applied by [`apply_self_model_fade`].
pub(super) self_fade_alpha: f32,
@@ -573,11 +575,18 @@ impl CameraControl {
self.target_distance = d;
}
/// True while a mouse-look drag is active (right- or left-button). The cursor is hidden then.
/// True while a mouse-look drag is active (right- or left-button).
pub(crate) fn is_looking(&self) -> bool {
self.look.is_some()
}
/// True while a look session holds the OS cursor, hidden and locked: looking, and focused.
/// Read by the macOS cursor hide, the one platform that hides the cursor itself.
#[cfg(any(target_os = "macos", test))]
pub(crate) fn holds_cursor(&self) -> bool {
self.look.is_some() && !self.cursor_released
}
/// The self-avatar's render alpha this frame. The blob shadow multiplies it in, as the
/// reference's shadow diffuse rides the body's model fade slot (`[model+0x180]`).
pub(crate) fn self_fade(&self) -> f32 {
@@ -601,16 +610,23 @@ impl LookButton {
}
}
/// The mouse buttons the world owns, latched at the press. 1.12 reads the `TurnOrAction` and
/// `CameraOrSelectOrMove` bindings, which a press a UI frame captured never dispatches: no mouse
/// bit in `[InputControl+0x4]`, no look session, no [`FollowState`]. The world's down sets the bit
/// and that button's up clears it, so a frame appearing under the locked cursor cannot steal it.
/// The mouse buttons the world owns, latched at the press, and the two channels they drive. 1.12
/// reads the `TurnOrAction` and `CameraOrSelectOrMove` bindings, which a press a UI frame captured
/// never dispatches: no mouse bit in `[InputControl+0x4]`, no look session, no [`FollowState`].
/// The world's down sets the bit and that button's up clears it, so a frame appearing under the
/// locked cursor cannot steal it. `MouselookStart` sets the TurnOrAction bit with no button.
#[derive(Default)]
pub(super) struct WorldMouse {
/// Held by the world right now, indexed by [`LookButton`].
held: [bool; 2],
/// Took its down edge from the world this frame, same index.
down: [bool; 2],
/// `MouselookStart` holds the TurnOrAction channel (`0x515090(1, 1, …)` at `0x514231`).
scripted: bool,
/// This frame's `MouselookStart` raised the channel.
scripted_rise: bool,
/// A `MouselookStart`/`Stop` ran this frame; each disarms the pending click (`0x514810(0)`).
disarmed: bool,
}
impl WorldMouse {
@@ -622,20 +638,60 @@ impl WorldMouse {
self.down[b as usize]
}
/// Both primaries in the world's hand: the both-button run.
/// The TurnOrAction channel, `[InputControl+4] & 1`: the world's right button, or a
/// `MouselookStart` with none.
pub(super) fn turn(&self) -> bool {
self.held(LookButton::Right) || self.scripted
}
/// The channel `b`'s look session runs on: TurnOrAction for right, the left button's own.
pub(super) fn channel(&self, b: LookButton) -> bool {
match b {
LookButton::Right => self.turn(),
LookButton::Left => self.held(LookButton::Left),
}
}
/// Both channels held: the both-button run (`0x514da0` nets `0x1` and `0x2` as forward).
pub(super) fn both(&self) -> bool {
self.held(LookButton::Right) && self.held(LookButton::Left)
self.turn() && self.held(LookButton::Left)
}
/// A channel rose this frame, by a press or a `MouselookStart`: with [`Self::both`], the
/// transition into the both-button run (`0x514a73`, in the set helper both reach).
pub(super) fn rose(&self) -> bool {
self.down(LookButton::Left) || self.down(LookButton::Right) || self.scripted_rise
}
/// A `MouselookStart`/`Stop` this frame: no click may settle from the gesture in flight.
pub(super) fn disarmed(&self) -> bool {
self.disarmed
}
/// Latch this frame; `world_press` says whether a down edge belongs to the world. A held bit
/// rides to its release, including a cover's emptying of the button planes.
fn update(&mut self, buttons: &ButtonInput<MouseButton>, world_press: bool) {
self.scripted_rise = false;
self.disarmed = false;
for b in [LookButton::Right, LookButton::Left] {
let i = b as usize;
self.down[i] = world_press && buttons.just_pressed(b.button());
self.held[i] = (self.held[i] || self.down[i]) && buttons.pressed(b.button());
}
}
/// `MouselookStart` (`on`) or `MouselookStop`: one bit, whoever set it, so a stop also ends
/// a right drag in flight, whose release then clears nothing.
fn mouselook(&mut self, on: bool) {
self.disarmed = true;
if on {
self.scripted_rise |= !self.turn();
self.scripted = true;
} else {
self.scripted = false;
self.held[LookButton::Right as usize] = false;
}
}
}
/// Decide once per frame which mouse buttons the world owns: a press in the viewport off the UI,
@@ -655,18 +711,39 @@ pub(super) fn latch_world_mouse(
mut rig: ResMut<CameraControl>,
cameras: Query<&Camera, With<FlyCam>>,
window: Single<&Window, With<PrimaryWindow>>,
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
mut script: Option<NonSendMut<benilla_ui::script::UiScript>>,
cover: Option<Res<crate::loading_screen::LoadingScreen>>,
) {
let calls = script
.as_mut()
.map(|s| s.take_mouselook_calls())
.unwrap_or_default();
let Ok(camera) = cameras.single() else {
return;
};
let over_ui = pointer_over_ui.0;
let world_press = rig.look.is_some() || (cursor_in_viewport(&window, camera) && !over_ui);
rig.world_mouse.update(&buttons, world_press);
// `IsMouselooking` (`0x514270`) reads `[InputControl+4] & 1`, the TurnOrAction channel: the
// world's right button, the press the TURNORACTION binding latches.
let wm = &mut rig.world_mouse;
wm.update(&buttons, world_press);
// The script's calls, made since the last latch, before this frame's release.
for on in calls {
wm.mouselook(on);
}
// The right button's release ends a scripted channel too, wherever its press went: in
// freelook every button event goes to the `WorldFrame` (`0x492b50`), whose TURNORACTION up
// runs `TurnOrActionStop` (`0x514160`), `0x515090(1, 0, …)`.
if buttons.just_released(MouseButton::Right) {
wm.scripted = false;
}
// The world enter behind the cover clears the whole word (`0x5144c0` → `0x514b70(-1, …)`).
// A window deactivate clears none of it (`0x5144a3 and eax,0xfffff00f` keeps the low bits):
// the session outlives the focus, and only the OS cursor goes back ([`sync_look_focus`]).
if cover.is_some_and(|c| c.covering()) {
wm.scripted = false;
}
// `IsMouselooking` (`0x514270`) reads `[InputControl+4] & 1`, the TurnOrAction channel.
if let Some(mut script) = script {
script.set_turn_or_action_held(rig.world_mouse.held(LookButton::Right));
script.set_turn_or_action_held(rig.world_mouse.turn());
}
}
@@ -714,9 +791,9 @@ pub(crate) struct CameraPivot {
/// The mouse-look session: start, stop and hand-off between the buttons, cursor grab and restore,
/// the look rotation, and the click tests behind [`WorldClick`]/[`WorldRightClick`]. Orbit and
/// select are independent: each primary press engages its session at once and arms a click test,
/// and the release decides on [`PressGesture::is_click`] alone.
/// and the release decides on [`PressGesture::is_click`] alone. A session lasts while its
/// [`WorldMouse::channel`] is held, so a `MouselookStart` runs the right button's session.
pub(super) fn run_look_session(
buttons: &ButtonInput<MouseButton>,
mouse_motion: &AccumulatedMouseMotion,
both_buttons: bool,
rig: &mut CameraControl,
@@ -737,18 +814,30 @@ pub(super) fn run_look_session(
dynamics: &DynamicsInput,
// Seconds on the app clock, for the press predicate's two time gates.
now: f32,
// The primary window has the OS focus.
focused: bool,
) {
// The mouse moves the view only through a held cursor: motion while unfocused, or on the frame
// that takes the cursor back, is the pointer travelling in another app.
let motion = if sync_look_focus(rig, focused, window, cursor_opts) {
mouse_motion.delta
} else {
Vec2::ZERO
};
// A chord is a both-button run, never a select: the reference kills the pending click and arms
// none while another primary's binding is held (`0x514ac1`, `0x51481a`).
if rig.world_mouse.both() {
// none while another primary's binding is held (`0x514ac1`, `0x51481a`). A `MouselookStart` or
// `Stop` disarms it too, ahead of the channel's move (`0x514810(0)` stores mode 0 unguarded,
// then `0x515090`), so a stop's release, or the button's after it, selects nothing.
let disarmed = rig.world_mouse.disarmed();
if rig.world_mouse.both() || disarmed {
*left_click = None;
*right_click = None;
}
// Both tests accumulate the rotation the press asked for, charged before the pitch clamp: the
// reference accumulates raw motion, so a drag pinned at the pitch limit still spends budget.
let (yaw_rate, pitch_rate) = (look_cfg.yaw_rate(), look_cfg.pitch_rate());
let dyaw = (mouse_motion.delta.x * yaw_rate).abs();
let dpitch = (mouse_motion.delta.y * pitch_rate).abs();
let dyaw = (motion.x * yaw_rate).abs();
let dpitch = (motion.y * pitch_rate).abs();
for test in [&mut *left_click, &mut *right_click].into_iter().flatten() {
test.yaw_travel += dyaw;
test.pitch_travel += dpitch;
@@ -757,8 +846,8 @@ pub(super) fn run_look_session(
// Both buttons engage their session on the down edge, with no threshold (`0x51491f`); the click
// test rides along to the release. Looking hides and locks the cursor until the release.
if let Some(active) = rig.look {
if !buttons.pressed(active.button()) {
// The button went up: settle its click test (a chord already cancelled both).
if !rig.world_mouse.channel(active) {
// The channel went down: settle its click test (a chord already cancelled both).
let test = match active {
LookButton::Left => left_click.take(),
LookButton::Right => right_click.take(),
@@ -775,51 +864,43 @@ pub(super) fn run_look_session(
}
}
}
// Hand off to the other button if the world holds it, as the reference keeps turning;
// a button the UI holds never fired its binding.
// Hand off to the other channel if it is held, as the reference keeps turning; a
// button the UI holds never fired its binding.
let other = match active {
LookButton::Right => LookButton::Left,
LookButton::Left => LookButton::Right,
};
if rig.world_mouse.held(other) {
if rig.world_mouse.channel(other) {
rig.look = Some(other);
} else {
rig.look = None;
cursor_opts.grab_mode = CursorGrabMode::None;
// Show the cursor again; on macOS hiding is the cursor subsystem's job.
cursor_opts.visible = true;
if let Some(pos) = rig.cursor_stash.take() {
window.set_cursor_position(Some(pos));
}
disengage_look(rig, window, cursor_opts);
}
}
} else {
// `WorldMouse` has already dropped a press over the UI, the dev overlay or outside the
// viewport. Right-drag turns, and arms its click test unless left is held.
if rig.world_mouse.down(LookButton::Right) {
rig.look = Some(LookButton::Right);
rig.cursor_stash = window.cursor_position();
cursor_opts.grab_mode = CursorGrabMode::Locked;
cursor_opts.visible = false;
*right_click =
(!rig.world_mouse.held(LookButton::Left)).then(|| PressGesture::new(now));
engage_look(rig, LookButton::Right, focused, window, cursor_opts);
*right_click = (!rig.world_mouse.held(LookButton::Left) && !disarmed)
.then(|| PressGesture::new(now));
} else if rig.world_mouse.turn() {
// `MouselookStart` with no button: the channel's rise enters the same freelook
// (`0x514840` → `0x51491f`), and no click is armed (`0x514810(0)`).
engage_look(rig, LookButton::Right, focused, window, cursor_opts);
} else if rig.world_mouse.down(LookButton::Left) && !inspect_enabled {
// Left-drag orbits, engaged on the press like right (`0x51491f`); the select settles
// at the release. While the inspector is armed, left belongs to it.
rig.look = Some(LookButton::Left);
rig.cursor_stash = window.cursor_position();
cursor_opts.grab_mode = CursorGrabMode::Locked;
cursor_opts.visible = false;
engage_look(rig, LookButton::Left, focused, window, cursor_opts);
// A cursor-payload world drop still orbits (`0x51491f`) but must not also select.
*right_click = None;
*left_click = (!click_consumed && !rig.world_mouse.held(LookButton::Right))
*left_click = (!click_consumed && !rig.world_mouse.turn() && !disarmed)
.then(|| PressGesture::new(now));
}
}
// Look rotation; a right-drag also turns the character.
if let Some(active) = rig.look {
let delta = mouse_motion.delta;
let delta = motion;
let d_yaw = -delta.x * yaw_rate;
cam.yaw += d_yaw;
// `mouseInvertPitch` flips only the pitch axis.
@@ -850,6 +931,81 @@ pub(super) fn run_look_session(
rig.freelook = rig.look == Some(LookButton::Right) || (rig.look.is_some() && both_buttons);
}
/// Start `button`'s look session: stash the cursor, then lock and hide it until the session ends,
/// or leave it free while the window is unfocused ([`sync_look_focus`]).
fn engage_look(
rig: &mut CameraControl,
button: LookButton,
focused: bool,
window: &Window,
cursor_opts: &mut CursorOptions,
) {
rig.look = Some(button);
if focused {
take_cursor(rig, window, cursor_opts);
} else {
rig.cursor_released = true;
}
}
/// End the look session: free and show the cursor, and put it back where the look began unless
/// it was already free, when it is wherever the player left it.
fn disengage_look(rig: &mut CameraControl, window: &mut Window, cursor_opts: &mut CursorOptions) {
rig.look = None;
let stash = rig.cursor_stash.take();
if !std::mem::take(&mut rig.cursor_released) {
release_cursor(cursor_opts);
if let Some(pos) = stash {
window.set_cursor_position(Some(pos));
}
}
}
/// Stash the cursor, then lock and hide it for the session.
fn take_cursor(rig: &mut CameraControl, window: &Window, cursor_opts: &mut CursorOptions) {
rig.cursor_stash = window.cursor_position();
cursor_opts.grab_mode = CursorGrabMode::Locked;
cursor_opts.visible = false;
}
/// Unlock and show the cursor; on macOS hiding is the cursor subsystem's job.
fn release_cursor(cursor_opts: &mut CursorOptions) {
cursor_opts.grab_mode = CursorGrabMode::None;
cursor_opts.visible = true;
}
/// Hand the OS cursor back while the window is unfocused and take it again on focus, without
/// touching the session: the look state outlives a deactivate (`0x514490` keeps the mouse bits).
/// Platform behaviour, not a reference fact. On macOS winit's `Locked` is the global
/// `CGAssociateMouseAndMouseCursorPosition(false)`, and losing key status (`windowDidResignKey`)
/// leaves it, so a lock held while unfocused freezes the pointer in every other app; 1.12.1's
/// Windows client gives the cursor back to other applications while inactive. Returns whether the
/// session holds the cursor through this whole frame, so its motion may turn the view.
fn sync_look_focus(
rig: &mut CameraControl,
focused: bool,
window: &Window,
cursor_opts: &mut CursorOptions,
) -> bool {
if rig.look.is_none() {
return focused;
}
match (focused, rig.cursor_released) {
(false, false) => {
release_cursor(cursor_opts);
rig.cursor_released = true;
false
}
(true, true) => {
// The pointer is where the player brought it back: the release restores it there.
take_cursor(rig, window, cursor_opts);
rig.cursor_released = false;
false
}
(focused, _) => focused,
}
}
/// Seat the camera on whatever it orbits: our own body, or the far-sight subject `PLAYER_FARSIGHT`
/// names (Mind Vision, Sentry Totem, Mind Control), substituting the orbit centre, sweep origin and
/// pivot target before [`seat_camera`]. Both of the controller's seat paths call it, as far sight
@@ -1366,6 +1522,9 @@ pub(crate) fn cam_dump_enabled() -> bool {
*ON.get_or_init(|| std::env::var_os("WOW_CAM_DUMP").is_some())
}
#[cfg(test)]
mod mouselook_tests;
#[cfg(test)]
mod tests {
use super::super::camera_channel::CHANNEL_EPS;
@@ -0,0 +1,389 @@
//! `MouselookStart`/`MouselookStop` against the look session: the latch and the session run as the
//! frame runs them, with the VM's calls in between.
use benilla_ui::script::UiScript;
use bevy::ecs::system::RunSystemOnce;
use bevy::math::DVec2;
use super::super::camera_dynamics::{CameraOptions, SubjectState};
use super::*;
/// What the session's caller keeps between frames: the facing and the two click tests.
#[derive(Resource, Default)]
struct Hand {
face_yaw: f32,
left: Option<PressGesture>,
right: Option<PressGesture>,
}
/// `control`'s call of [`run_look_session`], with the both-button run read off the latch.
fn look_frame(
motion: Res<AccumulatedMouseMotion>,
mut rig: ResMut<CameraControl>,
cam: Single<&mut FlyCam>,
window: Single<(&mut Window, &mut CursorOptions), With<PrimaryWindow>>,
mut clicks: (MessageWriter<WorldClick>, MessageWriter<WorldRightClick>),
mut hand: ResMut<Hand>,
) {
let (mut window, mut opts) = window.into_inner();
let focused = window.focused;
let mut cam = cam.into_inner();
let hand = &mut *hand;
let both = rig.world_mouse.both();
let dynamics = DynamicsInput {
options: CameraOptions::default(),
smooth_style: FollowStyle::default(),
tracking_style: FollowStyle::default(),
subject: SubjectState::default(),
nearclip: 0.2,
surface_y: None,
};
run_look_session(
&motion,
both,
&mut rig,
&mut cam,
&mut hand.face_yaw,
&mut window,
&mut opts,
false,
false,
&mut clicks.0,
&mut clicks.1,
&mut hand.left,
&mut hand.right,
LookConfig::default(),
&dynamics,
0.0,
focused,
);
}
/// A world with the camera, the window, a VM and nothing held; `over_ui` says where the cursor is.
fn world(over_ui: bool) -> World {
let mut world = World::new();
world.init_resource::<ButtonInput<MouseButton>>();
world.init_resource::<AccumulatedMouseMotion>();
world.insert_resource(crate::ui_script::PointerOverUi(over_ui));
world.init_resource::<CameraControl>();
world.init_resource::<Hand>();
world.init_resource::<Messages<WorldClick>>();
world.init_resource::<Messages<WorldRightClick>>();
world.spawn((
Camera::default(),
FlyCam {
yaw: 0.0,
pitch: 0.0,
speed: 0.0,
},
));
let mut window = Window::default();
window.set_physical_cursor_position(Some(DVec2::new(100.0, 100.0)));
world.spawn((window, CursorOptions::default(), PrimaryWindow));
world.insert_non_send_resource(UiScript::new().unwrap());
world
}
/// One frame with the mouse moved by `dx` pixels: the latch, then the session, then the input
/// planes age as bevy's do.
fn frame(world: &mut World, dx: f32) {
world.resource_mut::<AccumulatedMouseMotion>().delta = Vec2::new(dx, 0.0);
world.run_system_once(latch_world_mouse).unwrap();
world.run_system_once(look_frame).unwrap();
world.resource_mut::<ButtonInput<MouseButton>>().clear();
}
/// The OS gives the window focus or takes it.
fn focus(world: &mut World, focused: bool) {
world
.query::<&mut Window>()
.single_mut(world)
.unwrap()
.focused = focused;
}
/// Where the OS pointer is, in logical window pixels.
fn pointer(world: &mut World) -> Option<Vec2> {
world
.query::<&Window>()
.single(world)
.unwrap()
.cursor_position()
}
fn lua(world: &mut World, chunk: &str) {
world.non_send_resource::<UiScript>().run(chunk).unwrap();
}
fn is_mouselooking(world: &World) -> bool {
world
.non_send_resource::<UiScript>()
.eval::<Option<i64>>("return IsMouselooking()")
.unwrap()
== Some(1)
}
fn yaw(world: &mut World) -> f32 {
world.query::<&FlyCam>().single(world).unwrap().yaw
}
fn cursor(world: &mut World) -> CursorOptions {
world
.query::<&CursorOptions>()
.single(world)
.unwrap()
.clone()
}
fn press(world: &mut World, b: MouseButton) {
world.resource_mut::<ButtonInput<MouseButton>>().press(b);
}
fn release(world: &mut World, b: MouseButton) {
world.resource_mut::<ButtonInput<MouseButton>>().release(b);
}
/// The right button's session, entered with no button: the mouse turns the camera and the body
/// together, the cursor is locked and hidden, and `IsMouselooking` answers 1 (`0x514210`).
#[test]
fn mouselook_start_turns_the_camera_and_the_character_with_no_button_held() {
let mut w = world(false);
frame(&mut w, 0.0);
assert!(!is_mouselooking(&w));
lua(&mut w, "MouselookStart()");
frame(&mut w, 40.0);
let rig = w.resource::<CameraControl>();
assert_eq!(
rig.look,
Some(LookButton::Right),
"the right button's session"
);
assert!(rig.freelook);
let turned = yaw(&mut w);
assert!(turned < 0.0, "the mouse turns the camera: {turned}");
assert_eq!(
w.resource::<Hand>().face_yaw,
turned,
"and the character with it"
);
let c = cursor(&mut w);
assert_eq!(c.grab_mode, CursorGrabMode::Locked);
assert!(!c.visible);
assert!(is_mouselooking(&w));
// It holds with no button while the mouse keeps moving.
frame(&mut w, 40.0);
assert!(yaw(&mut w) < turned);
assert!(w.resource::<CameraControl>().is_looking());
}
/// `MouselookStop` (`0x514240`) ends the session: the cursor comes back and the mouse turns
/// nothing, and `IsMouselooking` answers nil.
#[test]
fn mouselook_stop_ends_the_session() {
let mut w = world(false);
lua(&mut w, "MouselookStart()");
frame(&mut w, 40.0);
let turned = yaw(&mut w);
lua(&mut w, "MouselookStop()");
frame(&mut w, 40.0);
assert_eq!(w.resource::<CameraControl>().look, None);
assert_eq!(yaw(&mut w), turned, "the mouse turns nothing");
let c = cursor(&mut w);
assert_eq!(c.grab_mode, CursorGrabMode::None);
assert!(c.visible);
assert!(!is_mouselooking(&w));
}
/// CustomNameplates' steer: a right press a plate took calls `MouselookStart`, and the button's
/// release ends it (`0x492b50` routes it to `TurnOrActionStop`) without a world click.
#[test]
fn the_right_release_ends_a_mouselook_its_press_started_on_the_ui() {
let mut w = world(true);
press(&mut w, MouseButton::Right);
frame(&mut w, 0.0);
assert_eq!(
w.resource::<CameraControl>().look,
None,
"the plate's press alone starts no look"
);
lua(&mut w, "MouselookStart()");
frame(&mut w, 40.0);
assert_eq!(w.resource::<CameraControl>().look, Some(LookButton::Right));
assert!(is_mouselooking(&w));
release(&mut w, MouseButton::Right);
frame(&mut w, 0.0);
assert_eq!(w.resource::<CameraControl>().look, None);
assert!(!is_mouselooking(&w));
assert!(
w.resource::<Messages<WorldRightClick>>().is_empty(),
"no click was armed"
);
}
/// The loading cover's world enter clears the whole word (`0x5144c0`).
#[test]
fn the_cover_ends_a_scripted_mouselook() {
let mut w = world(false);
lua(&mut w, "MouselookStart()");
frame(&mut w, 0.0);
w.insert_resource(crate::loading_screen::LoadingScreen::test_covering());
frame(&mut w, 0.0);
assert_eq!(w.resource::<CameraControl>().look, None);
assert!(!is_mouselooking(&w));
}
/// A session across a focus loss: the look state stays as the deactivate leaves it (`0x514490`
/// keeps the mouse bits), the OS cursor is free and shown meanwhile and the mouse turns nothing,
/// and the focus takes it back locked and hidden, with no jolt on the frame it returns.
fn a_session_survives_focus_loss(start: impl Fn(&mut World)) {
let mut w = world(false);
start(&mut w);
frame(&mut w, 40.0);
assert_eq!(w.resource::<CameraControl>().look, Some(LookButton::Right));
let before = yaw(&mut w);
focus(&mut w, false);
frame(&mut w, 40.0);
assert_eq!(
w.resource::<CameraControl>().look,
Some(LookButton::Right),
"the session outlives the focus"
);
assert!(is_mouselooking(&w));
let c = cursor(&mut w);
assert_eq!(c.grab_mode, CursorGrabMode::None, "the OS pointer is free");
assert!(c.visible);
assert!(!w.resource::<CameraControl>().holds_cursor());
assert_eq!(
yaw(&mut w),
before,
"the pointer in another app turns nothing"
);
focus(&mut w, true);
frame(&mut w, 40.0);
let c = cursor(&mut w);
assert_eq!(c.grab_mode, CursorGrabMode::Locked);
assert!(!c.visible);
assert!(w.resource::<CameraControl>().holds_cursor());
assert_eq!(
yaw(&mut w),
before,
"the returning frame's travel is not a turn"
);
frame(&mut w, 40.0);
assert!(yaw(&mut w) < before, "and the mouse turns the view again");
assert!(is_mouselooking(&w));
}
#[test]
fn a_scripted_mouselook_survives_focus_loss() {
a_session_survives_focus_loss(|w| lua(w, "MouselookStart()"));
}
#[test]
fn a_right_drag_survives_focus_loss() {
a_session_survives_focus_loss(|w| press(w, MouseButton::Right));
}
/// `MouselookStop` while unfocused ends the session; the pointer stays where the player left it
/// in the other app, and the focus coming back takes nothing.
#[test]
fn mouselook_stop_while_unfocused_ends_it_and_the_focus_takes_nothing_back() {
let mut w = world(false);
lua(&mut w, "MouselookStart()");
frame(&mut w, 0.0);
focus(&mut w, false);
frame(&mut w, 0.0);
let away = Vec2::new(300.0, 250.0);
w.query::<&mut Window>()
.single_mut(&mut w)
.unwrap()
.set_cursor_position(Some(away));
lua(&mut w, "MouselookStop()");
frame(&mut w, 0.0);
assert_eq!(w.resource::<CameraControl>().look, None);
assert!(!is_mouselooking(&w));
assert_eq!(pointer(&mut w), Some(away), "no warp back to the stash");
focus(&mut w, true);
frame(&mut w, 0.0);
let c = cursor(&mut w);
assert_eq!(c.grab_mode, CursorGrabMode::None);
assert!(c.visible);
assert!(!w.resource::<CameraControl>().is_looking());
}
/// One bit, whoever set it: `MouselookStop` ends a right drag in flight and disarms its click, so
/// the release after it selects nothing (`0x514810(0)`, then `0x515090(1, 0, …)`).
#[test]
fn mouselook_stop_ends_a_right_drag_and_its_release_clicks_nothing() {
let mut w = world(false);
press(&mut w, MouseButton::Right);
frame(&mut w, 0.0);
assert!(
w.resource::<Hand>().right.is_some(),
"the press armed a click"
);
lua(&mut w, "MouselookStop()");
frame(&mut w, 0.0);
assert_eq!(w.resource::<CameraControl>().look, None);
assert!(!is_mouselooking(&w));
release(&mut w, MouseButton::Right);
frame(&mut w, 0.0);
assert!(w.resource::<Messages<WorldRightClick>>().is_empty());
assert!(!is_mouselooking(&w));
// The control: the same press and release with no stop is a click.
let mut w = world(false);
press(&mut w, MouseButton::Right);
frame(&mut w, 0.0);
release(&mut w, MouseButton::Right);
frame(&mut w, 0.0);
assert_eq!(w.resource::<Messages<WorldRightClick>>().len(), 1);
}
/// `MouselookStart` under a left drag sets the other primary channel: the both-button run, its
/// rise, and the left click disarmed; the left release hands the session to the mouselook.
#[test]
fn mouselook_start_under_a_left_drag_is_the_both_button_run() {
let mut w = world(false);
press(&mut w, MouseButton::Left);
frame(&mut w, 0.0);
assert_eq!(w.resource::<CameraControl>().look, Some(LookButton::Left));
assert!(w.resource::<Hand>().left.is_some());
lua(&mut w, "MouselookStart()");
w.resource_mut::<AccumulatedMouseMotion>().delta = Vec2::new(40.0, 0.0);
w.run_system_once(latch_world_mouse).unwrap();
{
let wm = &w.resource::<CameraControl>().world_mouse;
assert!(wm.both(), "both channels held");
assert!(wm.rose(), "the transition into the run (0x514a73)");
}
w.run_system_once(look_frame).unwrap();
w.resource_mut::<ButtonInput<MouseButton>>().clear();
assert!(
w.resource::<Hand>().left.is_none(),
"the left click disarmed"
);
let cam_yaw = yaw(&mut w);
assert!(cam_yaw < 0.0);
assert_eq!(
w.resource::<Hand>().face_yaw,
cam_yaw,
"the body turns with the camera"
);
release(&mut w, MouseButton::Left);
frame(&mut w, 0.0);
assert_eq!(w.resource::<CameraControl>().look, Some(LookButton::Right));
assert!(w.resource::<Messages<WorldClick>>().is_empty());
}
+2 -1
View File
@@ -227,8 +227,8 @@ pub(super) fn control(
*left_click = None;
*right_click = None;
}
let focused = window.focused;
run_look_session(
&buttons,
mouse_motion,
both_buttons,
&mut rig,
@@ -245,6 +245,7 @@ pub(super) fn control(
look_cfg,
&dynamics,
time.elapsed_secs(),
focused,
);
// The mouse turns the view, but the body hand-off (`0x514474`) skips a body that is stunned,
// dead or seated (`0x5145e0`) or not ours to drive (`control_lost`, `reseat`), while the
+1 -3
View File
@@ -212,9 +212,7 @@ pub(super) fn steer_follow(
// ── The cancel set ──
// The world's buttons, not the device's: the both-button run is two bindings held, and a
// press a UI frame captured dispatches neither.
let both_engaged = input.rig.world_mouse.both()
&& (input.rig.world_mouse.down(LookButton::Left)
|| input.rig.world_mouse.down(LookButton::Right));
let both_engaged = input.rig.world_mouse.both() && input.rig.world_mouse.rose();
if follow_cancelled(
input.move_start(),
// The on edge: `control` toggles `autorun` after this, so the flag is the pre-toggle value.
+2 -6
View File
@@ -36,10 +36,7 @@ pub(super) fn look_input(
w |= b;
}
};
set(
rig.world_mouse.held(LookButton::Right) || steer_held,
bit::RIGHT_MOUSE,
);
set(rig.world_mouse.turn() || steer_held, bit::RIGHT_MOUSE);
set(
rig.world_mouse.held(LookButton::Left) || steer_held,
bit::LEFT_MOUSE,
@@ -143,8 +140,7 @@ pub(super) fn move_axes(
// emit; ours is `state::may_translate`. The server-ride term is benilla's own.
// A jump, a chat EditBox taking focus and a zone change leave it set; mounting is untraced and
// leaves it set here. A focused chat box releases nothing, so W held through ENTER still runs.
let both_buttons_engaged = (both_buttons
&& (rig.world_mouse.down(LookButton::Left) || rig.world_mouse.down(LookButton::Right)))
let both_buttons_engaged = (both_buttons && rig.world_mouse.rose())
|| binds.just_pressed(crate::bindings::cmd::MOVE_AND_STEER);
if state::autorun_cancelled(
binds.just_pressed(crate::bindings::cmd::MOVE_FORWARD),
+58 -8
View File
@@ -74,8 +74,10 @@ pub(crate) struct InputState {
/// How many binding bodies are running: the hardware-event gate's `[[0xb71290]+0xd8]`.
binding_depth: u32,
/// `[InputControl+4] & 1`, the TurnOrAction channel `IsMouselooking` tests (`0x514278`), as
/// the app pushed it.
/// the app pushed it or a `MouselookStart`/`Stop` since moved it.
turn_or_action: bool,
/// The `MouselookStart` (`true`) and `MouselookStop` calls since the app last drained them.
mouselook: Vec<bool>,
}
impl UiScript {
@@ -93,10 +95,17 @@ impl UiScript {
result
}
/// Push whether the TurnOrAction channel is held: the world holds the right mouse button.
/// Push whether the TurnOrAction channel is held: the world holds the right mouse button, or
/// a `MouselookStart` holds it with none.
pub fn set_turn_or_action_held(&mut self, held: bool) {
self.model_mut().input.turn_or_action = held;
}
/// Take the `MouselookStart` (`true`) and `MouselookStop` calls made since the last call, in
/// call order, for the app's look session.
pub fn take_mouselook_calls(&mut self) -> Vec<bool> {
std::mem::take(&mut self.model_mut().input.mouselook)
}
}
/// The hardware-event gate `0x494a50(0)`: inside a binding body.
@@ -210,13 +219,26 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(flag(model.input.turn_or_action))
})?,
)?;
// `MouselookStart`/`MouselookStop` (`0x514210`/`0x514240`) pass no gate: each disarms the
// pending world click (`0x514810(0)`) and sets or clears the TurnOrAction channel
// (`0x515090(1, set, now, 0)`), the bit the held right button drives. The bit moves at the
// call, so `IsMouselooking` answers it at once; the app's look session applies the call.
for (name, on) in [("MouselookStart", true), ("MouselookStop", false)] {
g.set(
name,
lua.create_function(move |lua, _: MultiValue| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.input.turn_or_action = on;
model.input.mouselook.push(on);
Ok(())
})?,
)?;
}
// Not built, so these answer and do nothing:
// - the pitch pair (`0x400`/`0x800`): the mover has no keyboard pitch axis (`ABSENT` PITCHUP);
// - TurnOrAction and CameraOrSelectOrMove (`0x514120`-`0x5141d0`), which arm the world click
// (`0x514810`) and set the mouse bits: the look session is keyed to the physical buttons
// (`ABSENT` TURNORACTION, CAMERAORSELECTORMOVE);
// - `MouselookStart`/`Stop` (`0x514210`/`0x514240`, ungated), the same channel with no click
// armed: a session without a held button is not built.
// (`0x514810`) and set the mouse bits: the look session reads the physical buttons for
// these (`ABSENT` TURNORACTION, CAMERAORSELECTORMOVE).
inert(
lua,
&[
@@ -228,8 +250,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"TurnOrActionStop",
"CameraOrSelectOrMoveStart",
"CameraOrSelectOrMoveStop",
"MouselookStart",
"MouselookStop",
],
)?;
@@ -463,6 +483,36 @@ mod tests {
);
}
/// `MouselookStart`/`Stop` pass no gate (`0x514210`/`0x514240`), queue for the look session in
/// call order, and move the channel at the call, so `IsMouselooking` answers them at once.
#[test]
fn mouselook_start_and_stop_move_the_channel_from_any_caller() {
let mut s = UiScript::new().unwrap();
assert_eq!(s.arity("MouselookStart()").unwrap(), 0);
s.run("MouselookStop()").unwrap();
s.take_mouselook_calls();
let looking = |s: &UiScript| s.eval::<Option<i64>>("return IsMouselooking()").unwrap();
s.run("MouselookStart()").unwrap();
assert_eq!(
looking(&s),
Some(1),
"an addon's own call, outside any binding"
);
s.run("MouselookStop() MouselookStart() MouselookStop()")
.unwrap();
assert_eq!(looking(&s), None);
assert_eq!(s.take_mouselook_calls(), vec![true, false, true, false]);
assert!(s.take_mouselook_calls().is_empty(), "drained");
assert!(
s.take_binding_input().is_empty(),
"not a movement-binding call"
);
// The app's push is the channel's truth from then on: a right release ended it.
s.run("MouselookStart()").unwrap();
s.set_turn_or_action_held(false);
assert_eq!(looking(&s), None);
}
/// `TargetLastTarget` and `AttackTarget` join the call queue, in call order with the rest.
#[test]
fn the_targeting_calls_join_the_call_queue_in_order() {