Files
KittyMacro/kittymacro/screen.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

239 lines
8.1 KiB
Python

"""Reading the screen, and knowing which window is in front."""
from __future__ import annotations
import ctypes
import struct
import zlib
from ctypes import wintypes
from .winput import ensure_dpi_aware
ensure_dpi_aware()
user32 = ctypes.WinDLL("user32", use_last_error=True)
gdi32 = ctypes.WinDLL("gdi32", use_last_error=True)
gdi32.GetPixel.argtypes = (wintypes.HDC, ctypes.c_int, ctypes.c_int)
gdi32.GetPixel.restype = wintypes.COLORREF
user32.GetDC.argtypes = (wintypes.HWND,)
user32.GetDC.restype = wintypes.HDC
gdi32.CreateCompatibleDC.argtypes = (wintypes.HDC,)
gdi32.CreateCompatibleDC.restype = wintypes.HDC
gdi32.CreateDIBSection.argtypes = (wintypes.HDC, ctypes.c_void_p, wintypes.UINT,
ctypes.POINTER(ctypes.c_void_p), wintypes.HANDLE,
wintypes.DWORD)
gdi32.CreateDIBSection.restype = wintypes.HBITMAP
gdi32.SelectObject.argtypes = (wintypes.HDC, wintypes.HGDIOBJ)
gdi32.SelectObject.restype = wintypes.HGDIOBJ
gdi32.BitBlt.argtypes = (wintypes.HDC, ctypes.c_int, ctypes.c_int, ctypes.c_int,
ctypes.c_int, wintypes.HDC, ctypes.c_int, ctypes.c_int,
wintypes.DWORD)
gdi32.DeleteObject.argtypes = (wintypes.HGDIOBJ,)
gdi32.DeleteDC.argtypes = (wintypes.HDC,)
SRCCOPY = 0x00CC0020
user32.GetWindowTextW.argtypes = (wintypes.HWND, wintypes.LPWSTR, ctypes.c_int)
user32.GetWindowTextLengthW.argtypes = (wintypes.HWND,)
CLR_INVALID = 0xFFFFFFFF
_dc = None
def _desktop_dc():
global _dc
if _dc is None:
_dc = user32.GetDC(None)
return _dc
def screen_size() -> tuple[int, int]:
return user32.GetSystemMetrics(0), user32.GetSystemMetrics(1)
def pixel(x: int, y: int) -> tuple[int, int, int] | None:
"""(r, g, b) at a screen coordinate, or None if it cannot be read."""
value = gdi32.GetPixel(_desktop_dc(), int(x), int(y))
if value == CLR_INVALID:
return None
# COLORREF is 0x00BBGGRR.
return value & 0xFF, (value >> 8) & 0xFF, (value >> 16) & 0xFF
class BITMAPINFOHEADER(ctypes.Structure):
_fields_ = [
("biSize", wintypes.DWORD), ("biWidth", ctypes.c_long),
("biHeight", ctypes.c_long), ("biPlanes", wintypes.WORD),
("biBitCount", wintypes.WORD), ("biCompression", wintypes.DWORD),
("biSizeImage", wintypes.DWORD), ("biXPelsPerMeter", ctypes.c_long),
("biYPelsPerMeter", ctypes.c_long), ("biClrUsed", wintypes.DWORD),
("biClrImportant", wintypes.DWORD),
]
class Region:
"""A rectangle of the screen, taken in one copy."""
def __init__(self, left: int, top: int, width: int, height: int = 1):
self.left, self.top = int(left), int(top)
self.width, self.height = max(1, int(width)), max(1, int(height))
self._screen = _desktop_dc()
self._dc = gdi32.CreateCompatibleDC(self._screen)
header = BITMAPINFOHEADER()
header.biSize = ctypes.sizeof(BITMAPINFOHEADER)
header.biWidth = self.width
header.biHeight = -self.height # negative: rows top down, like the screen
header.biPlanes = 1
header.biBitCount = 32
header.biCompression = 0 # BI_RGB
self._bits = ctypes.c_void_p()
self._bitmap = gdi32.CreateDIBSection(self._dc, ctypes.byref(header), 0,
ctypes.byref(self._bits), None, 0)
if not self._bitmap:
raise OSError("could not make a bitmap to read the screen into")
gdi32.SelectObject(self._dc, self._bitmap)
self.pixels = (ctypes.c_ubyte * (self.width * self.height * 4)).from_address(
self._bits.value)
def read(self) -> bool:
"""Copy the rectangle as it is right now. False if Windows refused."""
return bool(gdi32.BitBlt(self._dc, 0, 0, self.width, self.height,
self._screen, self.left, self.top, SRCCOPY))
def at(self, x: int, y: int | None = None):
"""(r, g, b) at a screen coordinate, out of the last read."""
column = int(x) - self.left
row = 0 if y is None else int(y) - self.top
if not (0 <= column < self.width and 0 <= row < self.height):
return None
index = (row * self.width + column) * 4
return self.pixels[index + 2], self.pixels[index + 1], self.pixels[index]
def png(self, path) -> str:
"""Write what was last read to a PNG, for a person to look at."""
rows = bytearray()
stride = self.width * 4
for row in range(self.height):
line = bytes(self.pixels[row * stride:(row + 1) * stride])
rgb = bytearray(self.width * 3)
rgb[0::3] = line[2::4] # the DIB is BGRA; PNG wants RGB
rgb[1::3] = line[1::4]
rgb[2::3] = line[0::4]
rows.append(0) # no per-row filter
rows += rgb
def chunk(tag, payload):
return (struct.pack(">I", len(payload)) + tag + payload
+ struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF))
data = (b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", self.width, self.height,
8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(bytes(rows), 6))
+ chunk(b"IEND", b""))
path = str(path)
with open(path, "wb") as handle:
handle.write(data)
return path
def close(self) -> None:
if getattr(self, "_bitmap", None):
gdi32.DeleteObject(self._bitmap)
self._bitmap = None
if getattr(self, "_dc", None):
gdi32.DeleteDC(self._dc)
self._dc = None
def __enter__(self):
return self
def __exit__(self, *_exc):
self.close()
def __del__(self):
try:
self.close()
except Exception:
pass
def around(points, margin: int = 1) -> Region:
"""A Region just big enough to hold every one of these (x, y) points."""
xs = [int(x) for x, _y in points]
ys = [int(y) for _x, y in points]
left, top = min(xs) - margin, min(ys) - margin
return Region(left, top, max(xs) + margin - left + 1, max(ys) + margin - top + 1)
def close_enough(a, b, tolerance: int = 24) -> bool:
"""Per-channel comparison."""
if not a or not b:
return False
return all(abs(int(p) - int(q)) <= tolerance for p, q in zip(a, b))
# --------------------------------------------------------------- windows
WNDENUMPROC = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
def window_title(hwnd) -> str:
length = user32.GetWindowTextLengthW(hwnd)
if length <= 0:
return ""
buffer = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(hwnd, buffer, length + 1)
return buffer.value
def foreground_title() -> str:
return window_title(user32.GetForegroundWindow())
def find_roblox():
"""The Roblox player window, or None."""
found = []
def callback(hwnd, _lparam):
if not user32.IsWindowVisible(hwnd):
return True
title = window_title(hwnd)
if title == "Roblox":
found.append((0, hwnd))
elif title.startswith("Roblox") and "Studio" not in title:
found.append((1, hwnd))
return True
user32.EnumWindows(WNDENUMPROC(callback), 0)
if not found:
return None
found.sort(key=lambda item: item[0])
return found[0][1]
def roblox_focused() -> bool:
hwnd = user32.GetForegroundWindow()
if not hwnd:
return False
title = window_title(hwnd)
return title == "Roblox" or (title.startswith("Roblox") and "Studio" not in title)
def focus_roblox() -> bool:
"""Bring Roblox forward."""
hwnd = find_roblox()
if not hwnd:
return False
if user32.IsIconic(hwnd):
user32.ShowWindow(hwnd, 9) # SW_RESTORE
user32.SetForegroundWindow(hwnd)
return roblox_focused()
def window_rect(hwnd) -> tuple[int, int, int, int] | None:
rect = wintypes.RECT()
if not user32.GetWindowRect(hwnd, ctypes.byref(rect)):
return None
return rect.left, rect.top, rect.right, rect.bottom