"""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