Roblox macros that play from outside the game: Anime Vanguards, and a falling-notes piano event.
239 lines
8.1 KiB
Python
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
|