aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-06 13:35:35 -0400
committerhistoria <historiavg@proton.me>2026-09-06 13:35:35 -0400
commit40f108547126a3402d2285fe7e3353f0afddb24d (patch)
tree399662d854cba73d49d4ae2cd065603d8e661764
downloadvrising-camera-40f108547126a3402d2285fe7e3353f0afddb24d.tar.gz
initial commit
-rw-r--r--.gitignore3
-rw-r--r--README.md95
-rwxr-xr-xvrising-cam.py528
3 files changed, 626 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..77ac754
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+.venv/
+__pycache__/
+*.pyc
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0094eb1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,95 @@
+# vrising-cam
+
+Hold **Q / E** to rotate the camera in V Rising on Linux, by emulating the
+game's "Rotate camera" gesture: holding the right mouse button while streaming
+relative mouse motion. The emulation happens at the kernel level (uinput), so
+it works the same on X11 and Wayland — verified on KDE Plasma Wayland with the
+game running through plain Steam/Proton.
+
+Single-file, no services: the first run creates a `.venv` next to the script,
+installs `evdev`, and re-executes itself inside it. The script only acts while
+its process is running; `Ctrl+C` to stop, and the emulated right mouse button
+is always released on exit (including on SIGTERM and crashes, via `atexit`).
+
+## Usage
+
+```
+./vrising-cam.py # hold Q = rotate left, hold E = rotate right
+```
+
+While running, the process watches your keyboard's `/dev/input/event*` nodes
+for Q / E, presses the emulated right mouse button for as long as either key
+is held, and emits relative X motion at a continuous rate.
+
+### Options
+
+| Flag | Meaning |
+| --- | --- |
+| `--speed N` | rotation speed in relative X units per second (default `2000`) |
+| `--tick-ms N` | milliseconds between motion ticks (default `1.0`; lower = smoother, higher = lighter) |
+| `--ease SECONDS` | ramp speed up over `SECONDS` on press and decay on release (`0` = off; try `0.15`) |
+| `--invert` | swap the rotation direction of Q and E |
+| `-v`, `--verbose` | log key presses/releases, RMB injection, measured motion rate |
+| `--test-rmb [SECONDS]` | self-test: hold the injected RMB for `SECONDS` (default 3) and read events back from the kernel; no keyboard needed |
+| `--check` | show detected keyboards, uinput and grab status, then exit |
+
+### Tuning the feel
+
+- **Smoothness** comes from the fractional accumulator: ticks deliver 0–1 unit
+ each at a fine cadence, so motion is progress-continuous instead of pulsing.
+ The default 1 ms tick keeps bursts at 0–1 unit practically throughout; bulge
+ the interval (e.g. `--tick-ms 50`) for a lighter pipeline if you ever need to.
+- **Speed**: `--speed 1000` is a slow orbit; `3000+` is a fast spin. Because
+ motion is rate-continuous, `--speed` directly maps to how fast the camera
+ rotates.
+- **Ease**: `--ease 0.15` gives short smoothstep accel after pressing and a
+ linear decay on key release (the emulated RMB stays held during the decay,
+ then releases). `--ease 0` (default) is instant — some people prefer that.
+ The previous caveat applies while tap-spinning: easing shapes hold-and-drag
+ feel, not button clicks.
+- Q held together with E cancels out (no motion) but keeps the RMB hold.
+
+### CPU cost
+
+Idle the loop wakes 4×/s via `select`. While rotating, it wakes once per tick
+(1000/s at the default 1 ms) plus one kernel write per emitted event — well
+under 1% of one core on any modern machine.
+
+## Setup on Arch (KDE Wayland / X11 alike)
+
+1. Make `/dev/uinput` writable:
+
+ ```
+ echo 'KERNEL=="uinput", MODE="0660", GROUP="input", TAG+="uaccess"' | sudo tee /etc/udev/rules.d/70-vrising-cam.rules
+ sudo udevadm control --reload-rules && sudo udevadm trigger
+ sudo usermod -aG input $USER # log out and back in
+ ```
+2. Ensure the `uinput` module loads at boot:
+
+ ```
+ sudo modprobe uinput
+ echo uinput | sudo tee /etc/modules-load.d/uinput.conf
+ ```
+3. Run `./vrising-cam.py --check` — it should print `READY`:
+ `/dev/uinput` openable, your keyboards listed exposing Q+E, and no
+ "exclusively grabbed" warnings.
+
+## Troubleshooting
+
+- **No camera rotation at all** → run with `-v`: hold Q and watch for
+ `key: Q press` lines. No lines while rotating means some other process
+ exclusively grabbed your keyboard (`EVIOCGRAB`; remappers like
+ keyd/kanata/kmonad do this) — the script warns about that with `--check`
+ and `-v`.
+- **Keys detected but desktop/game shows nothing** → run the self-test
+ `./vrising-cam.py --test-rmb`: if `kernel read-back: OK` still shows no
+ reaction on the desktop, get `sudo libinput debug-events`
+ (`sudo pacman -S libinput-tools` on current Arch) and watch for
+ `BTN_RIGHT` lines while the test runs.
+- **Stop quickly** → `Ctrl+C` (or `kill`, also emulated) — the emulated RMB
+ is released in the `finally`/`atexit` path.
+
+## Files
+
+- `vrising-cam.py` — the whole program (single file by design)
+- `.venv/` — auto-created next to the script on first run (git-ignored)
diff --git a/vrising-cam.py b/vrising-cam.py
new file mode 100755
index 0000000..c2a6666
--- /dev/null
+++ b/vrising-cam.py
@@ -0,0 +1,528 @@
+#!/usr/bin/env python3
+"""V Rising camera helper.
+
+Hold Q / E to rotate the camera: emulates holding the in-game "Rotate camera"
+key (right mouse button) while streaming relative mouse motion, so it behaves
+the same on X11 and Wayland. Single file, no services: the first run creates a
+venv next to this script and installs evdev; the script only acts while this
+process is running (Ctrl+C to stop, RMB is always released on exit).
+"""
+
+import argparse
+import atexit
+import errno
+import fcntl
+import glob
+import os
+import select
+import signal
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+SCRIPT = Path(__file__).resolve()
+VENV_DIR = SCRIPT.parent / ".venv"
+VENV_PY = VENV_DIR / "bin" / "python"
+
+VENV_HINT = """\
+error: could not create the virtualenv: python3 -m venv {dir}
+Debian/Ubuntu: sudo apt install python3-venv
+"""
+
+BUILD_HINT = """\
+error: pip failed inside the venv (see stderr above).
+If the error says "No module named pip": delete {dir} and rerun; you are
+likely missing the venv/ensurepip package (Debian/Ubuntu: python3-venv).
+Otherwise the evdev source build needs a toolchain, Python headers AND the
+kernel input headers (linux/input.h):
+ Debian/Ubuntu: sudo apt install build-essential python3-dev linux-headers-amd64
+ Fedora: sudo dnf install gcc python3-devel kernel-headers
+ Arch: sudo pacman -S base-devel linux-headers
+ Alpine: apk add build-base python3-dev linux-headers
+"""
+
+UINPUT_HINT = """\
+uinput access is missing. Fix one of two ways:
+ a) quick: rerun with sudo: sudo {script}
+ b) one-time setup to run as your normal user (recommended):
+ echo 'KERNEL=="uinput", MODE="0660", GROUP="input", TAG+="uaccess"' | sudo tee /etc/udev/rules.d/70-vrising-cam.rules
+ sudo udevadm control --reload-rules && sudo udevadm trigger
+ sudo usermod -aG input $USER
+ (log out and back in so the input group applies)
+"""
+
+DEV_PERM_HINT = """\
+some /dev/input/event* devices are not readable by your user. Fix one of:
+ a) quick: rerun with sudo: sudo {script}
+ b) one-time: sudo usermod -aG input $USER (then log out and back in)
+"""
+
+MODULE_HINT = """\
+the uinput kernel module is not loaded. Fix:
+ sudo modprobe uinput
+ echo uinput | sudo tee /etc/modules-load.d/uinput.conf (load at every boot)
+If modprobe says the module is unknown, your kernel was built without
+CONFIG_INPUT_UINPUT and you would need a kernel that includes it.
+"""
+
+
+def _bootstrap():
+ if Path(sys.prefix).resolve() != VENV_DIR:
+ if not VENV_PY.exists():
+ print(f"vrising-cam: creating venv at {VENV_DIR} ...", flush=True)
+ r = subprocess.run([sys.executable, "-m", "venv", str(VENV_DIR)])
+ if r.returncode != 0 or not VENV_PY.exists():
+ sys.stderr.write(VENV_HINT.format(dir=VENV_DIR))
+ sys.exit(1)
+ os.execv(str(VENV_PY), [str(VENV_PY), str(SCRIPT), *sys.argv[1:]])
+ print("vrising-cam: installing evdev into the venv ...", flush=True)
+ r = subprocess.run(
+ [str(VENV_PY), "-m", "pip", "install", "evdev-binary"],
+ capture_output=True,
+ text=True,
+ )
+ if r.returncode != 0:
+ print("vrising-cam: no prebuilt wheel for this platform, building evdev from source ...", flush=True)
+ r = subprocess.run(
+ [str(VENV_PY), "-m", "pip", "install", "evdev"],
+ capture_output=True,
+ text=True,
+ )
+ if r.returncode != 0:
+ sys.stderr.write((r.stderr or "")[-2000:])
+ sys.stderr.write("\n" + BUILD_HINT.format(dir=VENV_DIR))
+ sys.exit(1)
+ print("vrising-cam: evdev installed.", flush=True)
+
+
+try:
+ from evdev import InputDevice, UInput, ecodes, list_devices
+except ImportError:
+ _bootstrap()
+ from evdev import InputDevice, UInput, ecodes, list_devices
+
+
+def _on_sigterm(signum, frame):
+ raise SystemExit(130)
+
+
+def _say(msg):
+ print(msg, flush=True)
+
+
+def _list_event_devices():
+ try:
+ return sorted(glob.glob("/dev/input/event*"))
+ except OSError:
+ return []
+
+
+def scan_keyboards():
+ keyboards, unreadable = [], []
+ for path in _list_event_devices():
+ try:
+ dev = InputDevice(path)
+ except (OSError, PermissionError):
+ unreadable.append(path)
+ continue
+ try:
+ keys = set(dev.capabilities(absinfo=False).get(ecodes.EV_KEY, ()))
+ except (OSError, PermissionError):
+ unreadable.append(path)
+ continue
+ if {ecodes.KEY_Q, ecodes.KEY_E} <= keys:
+ keyboards.append(dev)
+ else:
+ dev.close()
+ return keyboards, unreadable
+
+
+def check_grabs(keyboards, log):
+ """Detect devices that another process has grabbed exclusively (EVIOCGRAB).
+
+ If someone else holds an exclusive grab (e.g. keyd/kanata/kmonad/remappers),
+ our reads on that device return nothing and Q/E will never be detected.
+ We try to grab for a microsecond and release immediately; EBUSY means taken.
+ """
+ grabbed = getattr(ecodes, "EVIOCGRAB", None)
+ if grabbed is None:
+ grabbed = 0x40044592 # _IOW('E', 0x92, int)
+ for dev in keyboards:
+ try:
+ fcntl.ioctl(dev.fd, grabbed, 1)
+ except OSError as e:
+ if e.errno == errno.EBUSY:
+ log(f"WARNING: {dev.name!r} ({dev.path}) is exclusively grabbed by "
+ "another process (keyd/kanata/kmonad/solaar ...); its key events "
+ "cannot be seen here and Q/E on it will be missed.")
+ else:
+ try:
+ fcntl.ioctl(dev.fd, grabbed, 0)
+ except OSError as e:
+ log(f"warning: could not release grab probe on {dev.path}: {e}")
+
+
+def uinput_status():
+ p = Path("/dev/uinput")
+ if not p.exists():
+ return False, "/dev/uinput does not exist", MODULE_HINT
+ try:
+ fd = os.open(p, os.O_RDWR | os.O_NONBLOCK)
+ except OSError as e:
+ if e.errno in (errno.ENXIO, errno.ENODEV):
+ return False, (
+ f"/dev/uinput cannot be opened ({os.strerror(e.errno)}); "
+ "the uinput kernel module is probably not loaded"
+ ), MODULE_HINT
+ return False, f"/dev/uinput cannot be opened ({e})", UINPUT_HINT
+ os.close(fd)
+ return True, None, None
+
+
+class Injector:
+ def __init__(self, verbose=False, log=None):
+ self.ui = UInput(
+ {
+ ecodes.EV_KEY: [
+ ecodes.BTN_LEFT,
+ ecodes.BTN_RIGHT,
+ ecodes.BTN_MIDDLE,
+ ],
+ ecodes.EV_REL: [ecodes.REL_X, ecodes.REL_Y],
+ },
+ name="vrising-cam virtual mouse",
+ bustype=ecodes.BUS_USB,
+ vendor=0x1209,
+ product=0x5356,
+ version=1,
+ )
+ self.rmb = False
+ self.verbose = verbose
+ self.log = log or _say
+ self._closed = False
+
+ def press(self):
+ self.ui.write(ecodes.EV_KEY, ecodes.BTN_RIGHT, 1)
+ self.ui.syn()
+ self.rmb = True
+ if self.verbose:
+ self.log("inject: RMB press")
+
+ def release(self):
+ if self.rmb:
+ self.ui.write(ecodes.EV_KEY, ecodes.BTN_RIGHT, 0)
+ self.ui.syn()
+ self.rmb = False
+ if self.verbose:
+ self.log("inject: RMB release")
+
+ def move(self, dx):
+ self.ui.write(ecodes.EV_REL, ecodes.REL_X, dx)
+ self.ui.syn()
+
+ def close(self):
+ if self._closed:
+ return
+ self._closed = True
+ self.release()
+ self.ui.close()
+
+
+def run_check():
+ ok_ui, ui_msg, ui_hint = uinput_status()
+ keyboards, unreadable = scan_keyboards()
+ if ok_ui:
+ print("/dev/uinput: present and openable for writing")
+ else:
+ print("/dev/uinput: NOT USABLE")
+ if ui_msg:
+ print(f" {ui_msg}")
+ if ui_hint:
+ sys.stderr.write(ui_hint)
+ print(f"keyboards exposing Q+E: {len(keyboards)}")
+ for dev in keyboards:
+ print(f" {dev.path} {dev.name}")
+ if keyboards:
+ check_grabs(keyboards, print)
+ if unreadable:
+ print(f"unreadable event devices: {len(unreadable)}")
+ for path in unreadable[:8]:
+ print(f" {path}")
+ if len(unreadable) > 8:
+ print(f" ... and {len(unreadable) - 8} more")
+ sys.stderr.write(DEV_PERM_HINT.format(script=SCRIPT))
+ if not Path("/dev/input").is_dir():
+ print(" note: /dev/input does not exist (running in a container?)")
+ ready = ok_ui and bool(keyboards)
+ print("READY" if ready else "NOT READY")
+ return 0 if ready else 1
+
+
+def parse_args():
+ p = argparse.ArgumentParser(
+ prog="vrising-cam",
+ description=(
+ "Hold Q / E to rotate the V Rising camera: emulates holding the in-game "
+ "'Rotate camera' key (right mouse button) while dragging the mouse. "
+ "Runs only while this process is alive; the first run auto-creates "
+ ".venv next to the script and installs evdev."
+ ),
+ )
+ p.add_argument("--speed", type=int, default=2000, metavar="UNITS/SEC",
+ help="rotation speed in relative X units per second "
+ "(default: 2000)")
+ p.add_argument("--tick-ms", type=float, default=1.0, metavar="MS",
+ help="milliseconds between motion ticks (default: 1.0; "
+ "lower = smoother, higher = lighter)")
+ p.add_argument("--ease", type=float, default=0.0, metavar="SECONDS",
+ help="ramp the rotation speed up over SECONDS on press and "
+ "decay it on release (0 = off, try 0.15)")
+ p.add_argument("--invert", action="store_true",
+ help="swap the rotation direction of Q and E")
+ p.add_argument("--verbose", "-v", action="store_true",
+ help="log key presses/releases, RMB injection and motion ticks")
+ p.add_argument("--test-rmb", type=float, nargs="?", const=3.0, default=0, metavar="SECONDS",
+ help="hold the emulated right mouse button for SECONDS "
+ "(default 3, or 0 to disable), then exit; ignores Q/E "
+ "and is used to check that the desktop reacts to the "
+ "injected button at all")
+ p.add_argument("--check", action="store_true",
+ help="show detected keyboards and permission status, then exit")
+ return p.parse_args()
+
+
+def run_test_rmb(hold_s, lead_s=3.0):
+ ok_ui, ui_msg, ui_hint = uinput_status()
+ if not ok_ui:
+ print(f"error: {ui_msg}", flush=True)
+ if ui_hint:
+ sys.stderr.write(ui_hint)
+ return 1
+ try:
+ inj = Injector()
+ except (OSError, PermissionError) as e:
+ print(f"error: could not create the uinput device: {e}", flush=True)
+ sys.stderr.write(UINPUT_HINT.format(script=SCRIPT))
+ return 1
+ atexit.register(inj.close)
+ reader = inj.ui.device
+ if reader is None:
+ print("note: could not open a read-back handle for the virtual device; "
+ "kernel self-check will be skipped", flush=True)
+ print(f"move the mouse over something useful NOW; injected RMB press in "
+ f"{lead_s:.0f} s ...", flush=True)
+ for i in range(int(lead_s), 0, -1):
+ print(f" {i} s ...", flush=True)
+ time.sleep(1)
+
+ def drain():
+ read = []
+ if reader is None:
+ return read
+ while True:
+ ready, _, _ = select.select([reader.fd], [], [], 0)
+ if not ready:
+ break
+ try:
+ events = list(reader.read())
+ except (OSError, BlockingIOError):
+ break
+ if not events:
+ break
+ read.extend(events)
+ return read
+
+ print(f"holding injected RIGHT mouse button for {hold_s:.1f} s ...", flush=True)
+ print("watch the CURSOR: it should twitch left/right during the hold (if "
+ "it does not move at all, the session is not receiving this "
+ "device's motion either).", flush=True)
+ try:
+ inj.press()
+ steps = max(2, int(hold_s / 0.25))
+ for i in range(steps):
+ inj.move(1 if i % 2 else -1)
+ time.sleep(0.25 if i < steps - 1 else 0.05)
+ except KeyboardInterrupt:
+ pass
+ seen = drain()
+ inj.release()
+ seen += drain()
+ inj.close()
+ buttons = [ev for ev in seen if ev.type == ecodes.EV_KEY and ev.code == ecodes.BTN_RIGHT]
+ rels = [ev for ev in seen if ev.type == ecodes.EV_REL and ev.code == ecodes.REL_X]
+ values = sorted(ev.value for ev in buttons)
+ if reader is not None and values and {0, 1} <= set(values):
+ print(f"kernel read-back: OK - {len(buttons)} BTN_RIGHT events "
+ f"(down+up) and {len(rels)} REL_X events came back from the "
+ "kernel. Injection works at kernel level; if the desktop still "
+ "showed nothing, the problem is session-side delivery "
+ "(KWin/libinput), not this script.", flush=True)
+ elif reader is not None:
+ print(f"kernel read-back: FAILED - kernel delivered "
+ f"{len(buttons)} BTN_RIGHT / {len(rels)} REL_X events back "
+ "despite injection; the writes never made it into the input "
+ "stack. Report this output.", flush=True)
+ else:
+ print(f"test-rmb done, RMB released. ({len(rels)} REL_X events were "
+ "not self-verified: read-back handle unavailable.)", flush=True)
+ print("if the desktop showed NO reaction to the hold/release, install the "
+ "debug tool on the PC (it is in the 'libinput-tools' package on Arch:\n"
+ " sudo pacman -S libinput-tools\nthen keep this running in another "
+ "terminal\n sudo libinput debug-events\nwhile rerunning "
+ "python vrising-cam.py --test-rmb and check whether a 'BTN_RIGHT' "
+ "line appears there. Also useful:\n udevadm info /sys/devices/virtual/input/inputNN "
+ "(the 'vrising-cam virtual mouse' sysfs node from /proc/bus/input/devices)",
+ flush=True)
+ if reader is not None:
+ print(f"(virtual device node for read-back: {reader.path})", flush=True)
+ return 0
+
+
+def main():
+ args = parse_args()
+ if args.check:
+ sys.exit(run_check())
+ if args.test_rmb:
+ sys.exit(run_test_rmb(args.test_rmb))
+ ok_ui, ui_msg, ui_hint = uinput_status()
+ if not ok_ui:
+ if ui_msg:
+ print(f"error: {ui_msg}", flush=True)
+ if ui_hint:
+ sys.stderr.write(ui_hint)
+ sys.exit(1)
+ keyboards, unreadable = scan_keyboards()
+ if not keyboards:
+ print("error: no readable keyboard device exposing Q and E was found.", flush=True)
+ if unreadable:
+ sys.stderr.write(DEV_PERM_HINT.format(script=SCRIPT))
+ sys.exit(1)
+ keymap = {ecodes.KEY_Q: -1, ecodes.KEY_E: 1}
+ if args.invert:
+ keymap = {code: -sign for code, sign in keymap.items()}
+ try:
+ inj = Injector(verbose=args.verbose)
+ except (OSError, PermissionError) as e:
+ print(f"error: could not create the uinput device: {e}", flush=True)
+ sys.stderr.write(UINPUT_HINT.format(script=SCRIPT))
+ sys.exit(1)
+ atexit.register(inj.close)
+ signal.signal(signal.SIGTERM, _on_sigterm)
+ if args.verbose:
+ check_grabs(keyboards, _say)
+ watched = ", ".join(f"{dev.name!r} ({dev.path})" for dev in keyboards)
+ print("vrising-cam running - hold Q to rotate left, E to rotate right. Ctrl+C to stop.", flush=True)
+ print(f"watching {len(keyboards)} keyboard(s): {watched}", flush=True)
+ print(f"emulation: RMB hold + {args.speed} units/s over {args.tick_ms} ms ticks"
+ + (f", {args.ease:.2f} s ease" if args.ease > 0 else ""), flush=True)
+ fds = {dev.fd: dev for dev in keyboards}
+ state = {code: False for code in keymap}
+ tick_s = min(0.05, max(0.0005, args.tick_ms / 1000.0))
+ max_dt = max(0.05, 4.0 * tick_s) # clamp late wake-ups, never throttle ticks
+ last = 0.0 # last emission timestamp while active
+ acc = 0.0 # fractional unit accumulator
+ idle = 0.25
+ verbose = args.verbose
+ lit = {ecodes.KEY_Q: "Q", ecodes.KEY_E: "E"}
+ win_total = 0 # units emitted in the verbose log window
+ window_t0 = 0.0
+ ease_s = args.ease
+ press_t = 0.0 # ramp anchor for the current ease-in
+ mult = 1.0 # current speed multiplier (0..1, ease only)
+ decel = False # keys released: ease-out until RMB unpresses
+ last_dir = 0 # direction to keep decaying after release
+ try:
+ while True:
+ active = state[ecodes.KEY_Q] or state[ecodes.KEY_E]
+ emitting = active or decel
+ now = time.monotonic()
+ timeout = idle if not emitting else max(0.0, last + tick_s - now)
+ ready, _, _ = select.select(list(fds), [], [], timeout)
+ for fd in ready:
+ dev = fds.get(fd)
+ if dev is None:
+ continue
+ try:
+ events = dev.read()
+ except OSError:
+ print(f"warning: lost device {dev.name} ({dev.path})", flush=True)
+ fds.pop(fd, None)
+ continue
+ for ev in events:
+ if ev.type == ecodes.EV_KEY and ev.code in keymap and ev.value in (0, 1):
+ state[ev.code] = ev.value == 1
+ if verbose:
+ _say(f"key: {lit[ev.code]} {'press' if ev.value else 'release'} "
+ f"({dev.name!r}, {dev.path})")
+ if not fds:
+ print("error: all watched devices are gone, exiting.", flush=True)
+ sys.exit(1)
+ active = state[ecodes.KEY_Q] or state[ecodes.KEY_E]
+ if active:
+ now = time.monotonic()
+ if not inj.rmb:
+ inj.press()
+ last = now
+ acc = 0.0
+ win_total = 0
+ window_t0 = now
+ press_t = now
+ decel = False
+ elif decel:
+ decel = False
+ press_t = now - mult * ease_s # continue ramp seamless
+ dt = min(max(0.0, now - last), max_dt)
+ last = now
+ direction = sum(state[c] * s for c, s in keymap.items())
+ if direction:
+ last_dir = direction
+ else:
+ now = time.monotonic()
+ if inj.rmb and not decel:
+ if ease_s > 0:
+ decel = True # keys gone: ease-out, keep last_dir
+ else:
+ inj.release()
+ dt = min(max(0.0, now - last), max_dt)
+ last = now
+ direction = last_dir if decel else 0
+ if ease_s > 0:
+ if decel:
+ mult = max(0.0, mult - dt / ease_s)
+ elif active:
+ t = (now - press_t) / ease_s
+ mult = min(1.0, t * t * (3.0 - 2.0 * t)) if t < 1.0 else 1.0
+ else:
+ mult = 1.0
+ else:
+ mult = 1.0
+ if decel and mult <= 0.0:
+ inj.release()
+ decel = False
+ direction = 0
+ if direction and mult > 0.0:
+ acc += args.speed * mult * dt
+ n = int(acc)
+ if n:
+ acc -= n
+ dx = n * direction
+ inj.move(dx)
+ win_total += n
+ if verbose and now - window_t0 >= 2.0:
+ measured = win_total / (now - window_t0)
+ _say(f"motion: {win_total:+d} units in "
+ f"{now - window_t0:.1f} s "
+ f"(~{measured:+.0f} actual vs {args.speed:+d} "
+ "requested)")
+ win_total = 0
+ window_t0 = now
+ except KeyboardInterrupt:
+ pass
+ finally:
+ inj.close()
+ print("vrising-cam stopped, RMB released.", flush=True)
+
+
+if __name__ == "__main__":
+ main()