Skip to content

Rewrite and improve mouse emulation#531

Open
mevouc wants to merge 26 commits intoClassicOldSong:moonlight-noirfrom
mevouc:rewrite/mouse-emulation
Open

Rewrite and improve mouse emulation#531
mevouc wants to merge 26 commits intoClassicOldSong:moonlight-noirfrom
mevouc:rewrite/mouse-emulation

Conversation

@mevouc
Copy link
Copy Markdown

@mevouc mevouc commented Mar 8, 2026

Rewrite and improve mouse emulation

In #452, @ClassicOldSong mentioned that mouse emulation is written badly and that it could be rewritten.

I have looked at the code and I fully agree. This PR is doing just that, in an attempt to make this feature a lot better in my opinion.

I've started this branch from the one for PR #529 . That's why it includes a few commits from it at the start.

#529 should be merged first, or I can merge my two PRs into one, but with the UI screenshots, I think it's better to have #529 separated.

Issues found & fixed

  • Mouse emulation code was entangled in ControllerHandler and GenericControllerContext
  • Code was bad: public methods and fields, unclear hardcoded constants, useless allocations, some dead code, unclear logic and math formulas, maintainability was terrible
  • X/Y sensitivity feature was unusable; can only move cursor diagonally, no UX feedback on Y usage
  • Mouse emulation movement was not fluid at all. Speed was tick-dependent and only 20 Hz (hardcoded 50 ms tick time)
  • No middle click mapping
  • Toggle toast not localized
  • Cursor was going faster diagonally than vertically for controller firwares reporting on "square" deflection
  • Magnitude check discard any speed lower than 1 px/tick + create artificial increased dead zone
  • Holding a button while toggling off mouse emulation was never releasing the button
  • "Remember mouse mode" menu was wrongly depending on mouse emulation being enabled
  • Mouse emulation buttons mapping was never documented to user
  • No unit tests coverage at all

Changes

Behavioral/feature changes

  • X/Y sensitivity tweaking feature removed because 100% buggy, unusable and never documented to the user. Now redundant with the slider introduced by Add sensitivity slider for mouse emulation with controller #529
  • Increased the tick rate from 20 Hz to 120 Hz. Base speed of 1200 px/s is now explicit in the code. Speed is not tick-rate dependant now. Mouse usage is now a lot smoother now
  • Removed the magnitude >= 1 check of mouse movement per tick + introduced fractional pixels accumulation. This fixes the "virtual" dead zone issue and allows for really slow and precise user movement
  • Added support for middle click emulation. Mapped on Y button by default. If an analog stick has been selected by user for scrolling, use this analog stick click as mouse middle click as well

Refactoring & code cleaning

  • New class MouseEmulationHandler, extracting all mouse-emulation logic from ControlerHandler and GenericControllerContext. The only need from MouseEmulationHandler from its parent classes are the stick values, StickValueProvider light interface introduced for this
  • Replaced public fields with private or package-private when relevant
  • Unclear speed and tick constants renamed to real-life physical constants with clear units (Hz, px/s, milliseconds)
  • Simplified all Vector2d uses to avoid unecessary called to new and redundant sqrt calls
  • Removed dead code from Vector2d which was buggy too (mutability + division by zero not handled)

Bug fixes

  • Added releaseHeldMouseButtons method to release held buttons when toggle-off and destroy
  • Clamp stick magnitude below 1 to prevent diagonal movement being 2.83x faster than horizontal/vertical

UI

  • Toggle toast "Mouse emulation is: ON/OFF" now localized. Added French translation too.
  • Removed "Remember mouse mode" checkbox wrongly depending on mouse emulation checkbox being enabled: remembering mouse mode should be available even if mouse emulation is Off
  • Added documentation for the user about mouse emulation buttons mapping in the preference summary

Code documentation

  • Cubic acceleration with stick tilt made explicit in the comment before targetSpeed computation and scalarMultiply formula
  • Added some documentation for the real-life hardcoded constants (raw max stick value, tick rate, base speed)
  • Added a comment to explain why mouse emulation sends many 0 values for controller to the host

Tests

New unit tests for:

  • Vector2d methods
  • All mouse buttons emulation logic

Note on AI usage

  • All new source code has been fully written by me
  • AI generated the new unit tests
  • AI helped identify some of the bugs, and organize my work, write commit messages, but I heavily checked and validated everything myself
  • I've thoroughly tested everything manually: with Android Studio virtual devices but also by running the dev APK on my own AndroidTV

The PR is big because there were many issues and because I worked on it almost full-time during 3-4 days; not because AI generated slop.

mevouc added 26 commits March 2, 2026 12:03
…stant

- Also make the Milliseconds units explicit for mouse emulation report
  period
- Explicitly name hardcoded values with constants
- Update comment to clarify the use of cubic acceleration
Delete use of X and Y buttons when using mouse emulation with a
controller due to:
- Feature was unusable because it does not allow to move cursor
  horizontally nor vertically. When pressing X, cursor can only go in
  45° diagonals
- Sensitivity control by pressing Y gives no clear UX feedback and is
  now redundant with the "Mouse emulation sensitivity" slider in the
  User settings
- Hardcoded default cursor speed is in pixel/tick
- Mouse emulation report period is what is also called "tick" period
- Max stick axis is a raw controller value
…tionHandler

Pure structural refactor, zero behavorial change

New file MouseEmulationHandler.java:
  - 3 constants (tick period, max stick axis, base speed) moved from ControllerHandler
  - StickValueProvider interface for gettitng live stick values from GenericControllerContext
  - State variables moved from GenericControllerContext: active, lastInputMap
  - Logic scattered accross ControllerHandler and GenericControllerContext:
    convertRawStickAxisToPixelMovement, sendEmulatedMouseMove, sendEmulatedMouseScroll, tick runnable, toggle(), destroy(), getMenuOptions(), handleButtonInput()

Changes to ControllerHandler.java:
  - Removed 3 constants, 3 methods, unused MouseButtonPacket import
  - Moved defaultContext = new InputDeviceContext() from field initializer to constructor body (so outer-class fields like conn are non-null when MouseEmulationHandler is constructed)
  - GenericControllerContext now implements MouseEmulationHandler.StickValueProvider with 4 getter methods
  - Replaced all mouse emulation inline code with delegation to MouseEmulationHandler
  - Controller fusion, sendControllerInputPacket, and handleButtonUp all updated
…ick rate

Problem:
- 50 ms tick period (20 Hz rate) was not fluid at all
- Cursor & scroll speed was tick-rate dependent (each tick produces a
  fixed pixel delta regardless of elapsed time)

Changes:
- Increased tick rate to 120 Hz for fluidity & responsivness
- Define base speed in pixel/s = 1200 px/s, matches the old tick-dependent speed
- Compute real delta time elapsed between two ticks to use real-life speed math
At 120 Hz, each tick lasts ~8ms. With the previous magnitude >= 1 threshold,
any speed vector shorter than 1 px per tick was silently discarded.
This corresponds to a minimum speed of 120 px/s: the
bottom 10% of the speed range, and roughly the bottom 47% of the stick range
due to the cubic response curve.

At the old 20 Hz rate this was only 20 px/s, so the issue was minor. Moving
to 120 Hz made it six times worse, turning a large portion of the stick range
into a dead zone on top of the hardware dead zone.

Fix: instead of discarding sub-pixel deltas, accumulate them across ticks and
only send once the integer part reaches 1. The fractional remainder is carried
over, so arbitrarily slow speeds are now represented accurately.
…rt calls

Reuse two Vector2d instances (moveVector, scrollVector) across ticks instead
of allocating on every tick. scalarMultiply() now updates magnitude directly
(magnitude *= |factor|) instead of calling initialize(), which avoids a sqrt
recomputation on every scale. initialize() also replaces Math.pow(x, 2) with
x * x as a minor cleanup.
No behavorial change.

Rewrite convertRawStickAxisToSpeedPxPerSec to make the cubic response curve
explicit: speed = MAX_SPEED * (deflection/max)^3.
Previous formula (normalize then multiply by magnitude^2) produced the same
result but hid the cubic nature behind two opaque scalarMultiply calls
Fix a bug where toggling off (or destroying) while A or B was held
left the corresponding mouse button stuck down on the host
Tests for Vector2d: initialize sets x/y/magnitude correctly, scalar
multiply scales components and preserves magnitude sign rule, zero vector
has magnitude zero

Tests for MouseEmulationHandler: A/B flags send LMB/RMB down+up edges,
handleButtonInput is a no-op when inactive, zeroed controller packet is
always sent to suppress unmapped buttons, toggle-off and destroy both
release any held mouse buttons (covers the Step 1 fix)
The previous summary only mentioned the toggle gesture. Users had no way
to discover the button mapping without trial and error
…shoot

Some controller firmwares calibrate each axis independently, so a full
diagonal deflection can report both axes near max (32766, 32766). The
resulting raw magnitude (~46340 instead of 32766) caused the cubic
response curve to produce diagonal speeds up to 2.83× faster than
cardinal speeds

Fix by clamping the normalized magnitude to 1.0 before applying the
curve. This is a no-op for sticks that already report circular values
and has no effect on direction
…tion checkbox

"Remember mouse mode" is about touchscreen mode, not mouse emulation
with a controller. It must not be greyed-out when mouse emulation is
disabled
- Y is used for middle click by default
- If an analog stick (left or right) has been selected by user for
  scrolling. Emulate the analog stick click as mouse middle click as
  well
- ZERO is a mutable shared instance: any caller passing it to initialize()
  or scalarMultiply() corrupt it for others + it's unused
- getNormalized() is broken because there's no division by zero handling
  + it's unused
Y flag sends MMB down+up edges, RS_CLK_FLAG/LS_CLK_FLAG send MMB only
when the matching scrolling mode is active, stick clicks send no MMB
when the wrong mode is active or when scrolling is none, toggle-off and
destroy release any held middle click buttons
Replace hardcoded "Mouse emulation is: ON/OFF" Toast string with
string resources for localization

Include French translation
All fields were public but are only accessed from within ControllerHandler
and its inner classes, which are all in the same package
Include French translation as well
@wtfolivr
Copy link
Copy Markdown

wtfolivr commented Mar 8, 2026

derflacco's Artemide fork already fixed mouse emulation stuttering. It's smooth as butter, feel free to review their changes on that - clone or improve the code for this fork if needed.

@mevouc
Copy link
Copy Markdown
Author

mevouc commented Mar 8, 2026

I've just had a look at the code there, and I don't understand how it can be smoother. What change were you referring to?

The report period is still 50 ms (https://github.com/derflacco/moonlight-android/blob/8157555264bf1b77eb76261755805cdbba26c3c2/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L3107).

This means the tick rate is still 20 Hz, the same as the one in this repo.

20 frame per second is not smooth. That's why I went up to 120 Hz.

The only change I see on derflacco's fork in ControllerHandler is about "snappy input", but I don't see how it can impact smoothness.

@mevouc
Copy link
Copy Markdown
Author

mevouc commented Mar 8, 2026

Nevermind, it was on a separate experimental branch. Here is the commit: derflacco@670d8d4

They indeed have changed the mouseEmulationReportPeriod to 10 ms, which means 100 Hz.

However, they did it very very dirty in my opinion. Since they kept the tick-rate dependent speed computation, by lowering the tick length to 10 ms they ended-up with a need to compensate the the base speed too.
And instead of rewritting it for the speed to not be tick-rate dependent, they added yet one more scaling method to compensate with yet-another hardcoded & undocumented constant.

They fixed the shutterness, but they did not understand the core issue behind it (hardcoded tick-rate dependent speed). So they kept the issue and just built around it: it's just a quick fix.

My PR goal is to introduce a "clean" rewrite of the all feature.

I don't want to take that code into a "clean" rewrite. Even though the intent of increasing smoothness to 100 Hz is the same intent as mine and is a good one, it has made the code even less maintainable on their fork by doing so.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants