Files
KittyMacro/kittymacro/winput.py
T
capyb2222 bc09d70516 feat(init): KittyMacro
Roblox macros that play from outside the game: Anime Vanguards, and a
falling-notes piano event.
2026-09-05 12:22:28 -07:00

270 lines
7.5 KiB
Python

"""Synthetic input that Roblox actually accepts."""
from __future__ import annotations
import ctypes
import time
from ctypes import wintypes
user32 = ctypes.WinDLL("user32", use_last_error=True)
# ------------------------------------------------------------------ DPI
# Without this, Windows lies about coordinates on any display not at 100% scale.
def ensure_dpi_aware() -> None:
try:
# PER_MONITOR_AWARE_V2. Fails harmlessly if it is already set.
user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4))
return
except Exception:
pass
try:
user32.SetProcessDPIAware()
except Exception:
pass
ensure_dpi_aware()
# --------------------------------------------------------------- structs
ULONG_PTR = wintypes.WPARAM
INPUT_MOUSE = 0
INPUT_KEYBOARD = 1
KEYEVENTF_EXTENDEDKEY = 0x0001
KEYEVENTF_KEYUP = 0x0002
KEYEVENTF_SCANCODE = 0x0008
MOUSEEVENTF_MOVE = 0x0001
MOUSEEVENTF_LEFTDOWN = 0x0002
MOUSEEVENTF_LEFTUP = 0x0004
MOUSEEVENTF_RIGHTDOWN = 0x0008
MOUSEEVENTF_RIGHTUP = 0x0010
MOUSEEVENTF_MIDDLEDOWN = 0x0020
MOUSEEVENTF_MIDDLEUP = 0x0040
MOUSEEVENTF_WHEEL = 0x0800
MOUSEEVENTF_ABSOLUTE = 0x8000
MOUSEEVENTF_VIRTUALDESK = 0x4000
class MOUSEINPUT(ctypes.Structure):
_fields_ = [
("dx", wintypes.LONG),
("dy", wintypes.LONG),
("mouseData", wintypes.DWORD),
("dwFlags", wintypes.DWORD),
("time", wintypes.DWORD),
("dwExtraInfo", ULONG_PTR),
]
class KEYBDINPUT(ctypes.Structure):
_fields_ = [
("wVk", wintypes.WORD),
("wScan", wintypes.WORD),
("dwFlags", wintypes.DWORD),
("time", wintypes.DWORD),
("dwExtraInfo", ULONG_PTR),
]
class HARDWAREINPUT(ctypes.Structure):
_fields_ = [
("uMsg", wintypes.DWORD),
("wParamL", wintypes.WORD),
("wParamH", wintypes.WORD),
]
class _INPUTUNION(ctypes.Union):
_fields_ = [("mi", MOUSEINPUT), ("ki", KEYBDINPUT), ("hi", HARDWAREINPUT)]
class INPUT(ctypes.Structure):
_anonymous_ = ("u",)
_fields_ = [("type", wintypes.DWORD), ("u", _INPUTUNION)]
user32.SendInput.argtypes = (wintypes.UINT, ctypes.POINTER(INPUT), ctypes.c_int)
user32.SendInput.restype = wintypes.UINT
user32.MapVirtualKeyW.argtypes = (wintypes.UINT, wintypes.UINT)
user32.MapVirtualKeyW.restype = wintypes.UINT
user32.GetSystemMetrics.argtypes = (ctypes.c_int,)
user32.GetSystemMetrics.restype = ctypes.c_int
class InputError(RuntimeError):
"""SendInput refused the event."""
def _send(*inputs: INPUT) -> None:
count = len(inputs)
array = (INPUT * count)(*inputs)
sent = user32.SendInput(count, array, ctypes.sizeof(INPUT))
if sent != count:
err = ctypes.get_last_error()
raise InputError(f"SendInput sent {sent}/{count} events (error {err})")
# ------------------------------------------------------------------ keys
KEYS: dict[str, int] = {
"backspace": 0x08, "tab": 0x09, "enter": 0x0D, "return": 0x0D,
"shift": 0xA0, "lshift": 0xA0, "rshift": 0xA1,
"ctrl": 0xA2, "lctrl": 0xA2, "rctrl": 0xA3,
"alt": 0xA4, "lalt": 0xA4, "ralt": 0xA5,
"esc": 0x1B, "escape": 0x1B, "space": 0x20,
"pageup": 0x21, "pagedown": 0x22, "end": 0x23, "home": 0x24,
"left": 0x25, "up": 0x26, "right": 0x27, "down": 0x28,
"insert": 0x2D, "delete": 0x2E,
"-": 0xBD, "=": 0xBB, "[": 0xDB, "]": 0xDD, ";": 0xBA,
"'": 0xDE, ",": 0xBC, ".": 0xBE, "/": 0xBF, "\\": 0xDC, "`": 0xC0,
}
for _i in range(1, 25):
KEYS[f"f{_i}"] = 0x6F + _i
for _c in "abcdefghijklmnopqrstuvwxyz":
KEYS[_c] = ord(_c.upper())
for _c in "0123456789":
KEYS[_c] = ord(_c)
# Keys whose scan code needs the extended flag.
EXTENDED = {
0x25, 0x26, 0x27, 0x28, # arrows
0x21, 0x22, 0x23, 0x24, 0x2D, 0x2E, # navigation cluster
0xA3, 0xA5, 0x5B, 0x5C, 0x90, # right ctrl/alt, windows keys, numlock
}
VK_TO_NAME = {}
for _name, _vk in KEYS.items():
VK_TO_NAME.setdefault(_vk, _name)
held_keys: set[str] = set()
held_buttons: set[str] = set()
def vk_of(key: str) -> int:
try:
return KEYS[key.lower()]
except KeyError:
raise KeyError(f"unknown key name: {key!r}") from None
def _key_event(key: str, up: bool) -> None:
vk = vk_of(key)
scan = user32.MapVirtualKeyW(vk, 0)
flags = KEYEVENTF_KEYUP if up else 0
if scan:
# wVk stays zero on purpose: with the scan-code flag set.
flags |= KEYEVENTF_SCANCODE
if vk in EXTENDED:
flags |= KEYEVENTF_EXTENDEDKEY
ki = KEYBDINPUT(wVk=0, wScan=scan, dwFlags=flags, time=0, dwExtraInfo=0)
else:
ki = KEYBDINPUT(wVk=vk, wScan=0, dwFlags=flags, time=0, dwExtraInfo=0)
_send(INPUT(type=INPUT_KEYBOARD, ki=ki))
def key_down(key: str) -> None:
_key_event(key, up=False)
held_keys.add(key.lower())
def key_up(key: str) -> None:
_key_event(key, up=True)
held_keys.discard(key.lower())
def tap(key: str, hold: float = 0.04) -> None:
key_down(key)
time.sleep(hold)
key_up(key)
# ----------------------------------------------------------------- mouse
_BUTTONS = {
"left": (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP),
"right": (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP),
"middle": (MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP),
}
def _mouse(flags: int, dx: int = 0, dy: int = 0, data: int = 0) -> None:
mi = MOUSEINPUT(dx=dx, dy=dy, mouseData=data, dwFlags=flags, time=0, dwExtraInfo=0)
_send(INPUT(type=INPUT_MOUSE, mi=mi))
def cursor_pos() -> tuple[int, int]:
point = wintypes.POINT()
user32.GetCursorPos(ctypes.byref(point))
return point.x, point.y
def move_to(x: int, y: int) -> None:
"""Absolute move, for clicking something on screen."""
vx = user32.GetSystemMetrics(76)
vy = user32.GetSystemMetrics(77)
vw = max(user32.GetSystemMetrics(78), 1)
vh = max(user32.GetSystemMetrics(79), 1)
nx = int(round((x - vx) * 65535 / max(vw - 1, 1)))
ny = int(round((y - vy) * 65535 / max(vh - 1, 1)))
_mouse(MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK, nx, ny)
def move_rel(dx: int, dy: int) -> None:
"""Relative move - the only kind the camera reacts to."""
_mouse(MOUSEEVENTF_MOVE, int(dx), int(dy))
def mouse_down(button: str = "left") -> None:
down, _ = _BUTTONS[button]
_mouse(down)
held_buttons.add(button)
def mouse_up(button: str = "left") -> None:
_, up = _BUTTONS[button]
_mouse(up)
held_buttons.discard(button)
def click(x: int | None = None, y: int | None = None,
button: str = "left", settle: float = 0.03, hold: float = 0.04) -> None:
"""Move, let the cursor land, click."""
if x is not None and y is not None:
move_to(x, y)
time.sleep(settle)
mouse_down(button)
time.sleep(hold)
mouse_up(button)
def scroll(clicks: int) -> None:
_mouse(MOUSEEVENTF_WHEEL, data=int(clicks) * 120)
def release_all() -> None:
"""Put everything back up. Safe to call twice, and it never raises."""
for key in list(held_keys):
try:
key_up(key)
except Exception:
held_keys.discard(key)
for button in list(held_buttons):
try:
mouse_up(button)
except Exception:
held_buttons.discard(button)
def is_down(key: str) -> bool:
"""Physical state of a key, regardless of which window has focus."""
return bool(user32.GetAsyncKeyState(vk_of(key)) & 0x8000)
MOUSE_VK = {"left": 0x01, "right": 0x02, "middle": 0x04}
def mouse_is_down(button: str) -> bool:
return bool(user32.GetAsyncKeyState(MOUSE_VK[button]) & 0x8000)