#!/usr/bin/env python3 """KittyMacro - the window.""" from __future__ import annotations import ctypes import os import queue import sys import threading import time import traceback from pathlib import Path import tkinter as tk from tkinter import messagebox HERE = Path(__file__).resolve().parent sys.path.insert(0, str(HERE)) # Started from a console, the log goes there too. for _stream in (sys.stdout, sys.stderr): try: _stream.reconfigure(encoding="utf-8", errors="replace") except Exception: pass from kittymacro import __version__, config, routine, screen, sound, winput # noqa: E402 from kittymacro.hotkeys import Hotkeys, Signals # noqa: E402 from kittymacro.macros import animevanguards, piano # noqa: E402 from kittymacro.runner import Runner # noqa: E402 MACRO = animevanguards # the pages that are about a tower defence game GAMES = [("animevanguards", "Vanguards", animevanguards), ("piano", "Piano", piano)] # ------------------------------------------------------------------ paint BG = "#12121a" # the window itself PANEL = "#191922" # a card on it FIELD = "#20202c" # something you can type in or press LINE = "#2c2c3c" # a border TEXT = "#e9e9f2" DIM = "#8c8ca4" FAINT = "#5b5b74" ACCENT = "#ff5fa2" # kitty pink ACCENT_INK = "#1a0d14" # text that sits on the accent GOOD = "#57d9a3" WARN = "#ffbf5f" BAD = "#ff6b6b" UI = ("Segoe UI", 10) UI_SMALL = ("Segoe UI", 9) UI_BOLD = ("Segoe UI Semibold", 10) UI_TINY = ("Segoe UI Semibold", 8) UI_TITLE = ("Segoe UI Semibold", 15) UI_BIG = ("Segoe UI Semibold", 19) MONO = ("Consolas", 9) MONO_BIG = ("Consolas", 15) PAGES = [("home", "Home"), ("routines", "Routines"), ("calibrate", "Calibrate"), ("piano", "Piano"), ("event", "Event"), ("settings", "Settings")] # What START does, per game. MODES = { "animevanguards": (("loop", "Loop matches"), ("once", "One match")), "piano": (("loop", "Song after song"), ("once", "One song"), ("session", "Full session")), } # the four numbers you actually turn while watching the lane monitor TUNING = [ ("threshold", "Threshold", "how far from the empty track counts as a note"), ("offset_ms", "Offset", "ms to wait before pressing. Raise it if it hits early"), ("min_hold_ms", "Min hold", "ms — a tap has to last long enough to register"), ("gap_ms", "Gap", "ms — ignore a second note in a lane sooner than this"), ("let_go_ms", "Let go", "ms a held key waits for the note to come back before " "letting go. Raise it if long notes stutter"), ("max_hold_ms", "Max hold", "ms — let go anyway, so a lane cannot jam. 0 never does"), ] # path in the config -> label, and the one line that says why you would touch it SETTINGS = [ ("Match", ( ("match.start_delay", "Start delay", "after pressing start, before the build order"), ("match.max_seconds", "Match limit", "give up on a match that never ends"), ("match.check_every", "Probe every", "how often to look at the results pixel"), ("match.load_seconds", "Load wait", "after replay, before the next match begins"), ("match.between", "Between", "a breath between rounds"), )), ("Routines", ( ("routines.speed", "Speed", "scales every recorded gap. Above 1.0 outruns your income"), ("routines.loop_every", "Loop every", "seconds between replays of the loop routine"), )), ("Anti-AFK", ( ("antiafk.enabled", "Enabled", "a jump every few minutes so Roblox does not idle you out"), ("antiafk.period", "Every", "seconds between jumps"), ("antiafk.key", "Key", "what to press. space is a jump"), )), ("Hotkeys", ( ("hotkeys.pause", "Start / pause", "polled globally, so it works while Roblox has focus"), ("hotkeys.stop", "Stop", "and, while recording, discard"), ("hotkeys.capture", "Capture point", "picks up the pixel under the cursor"), )), ] def _get(tree: dict, path: str): node = tree for part in path.split("."): node = node[part] return node def _set(tree: dict, path: str, value) -> None: parts = path.split(".") node = tree for part in parts[:-1]: node = node[part] node[parts[-1]] = value def _short(value) -> str: """A number as you would write it down: 4 rather than 4.0.""" try: number = float(value) except (TypeError, ValueError): return str(value) return str(int(number)) if number == int(number) else f"{number:g}" def _hex(colour) -> str: if not colour: return "#000000" return "#%02x%02x%02x" % tuple(int(channel) for channel in colour[:3]) # ------------------------------------------------------------- small parts def label(parent, text="", font=UI, fg=TEXT, bg=PANEL, anchor="w", **kw): return tk.Label(parent, text=text, font=font, fg=fg, bg=bg, anchor=anchor, justify="left", **kw) def button(parent, text, command, kind="normal", width=None): face, ink, hover = { "normal": (FIELD, TEXT, LINE), "accent": (ACCENT, ACCENT_INK, "#ff7db5"), "ghost": (PANEL, DIM, FIELD), "danger": (FIELD, BAD, "#39222e"), }[kind] widget = tk.Button( parent, text=text, command=command, font=UI_BOLD if kind == "accent" else UI, bg=face, fg=ink, activebackground=hover, activeforeground=ink, disabledforeground=FAINT, relief="flat", bd=0, highlightthickness=0, cursor="hand2", padx=13, pady=6, width=width, ) widget.bind("", lambda _e: widget["state"] == "normal" and widget.config(bg=hover)) widget.bind("", lambda _e: widget.config(bg=face)) return widget def entry(parent, variable, width=8): return tk.Entry(parent, textvariable=variable, font=UI, width=width, bg=FIELD, fg=TEXT, insertbackground=ACCENT, relief="flat", highlightthickness=1, highlightbackground=LINE, highlightcolor=ACCENT, bd=4) def card(parent, title=None, fill="x", expand=False): outer = tk.Frame(parent, bg=PANEL, highlightthickness=1, highlightbackground=LINE, highlightcolor=LINE) outer.pack(fill=fill, expand=expand, pady=(0, 12)) if title: label(outer, title.upper(), font=UI_TINY, fg=FAINT).pack( fill="x", padx=15, pady=(11, 0)) body = tk.Frame(outer, bg=PANEL) body.pack(fill="both", expand=True, padx=15, pady=(7, 13)) body.outer = outer return body class Segmented(tk.Frame): """Two or three choices where only one can be true. A radio, but legible.""" def __init__(self, parent, options, value, command=None): super().__init__(parent, bg=LINE, highlightthickness=0) self.command = command self.buttons = {} self.options(options, value) def options(self, options, value=None): """Different choices in the same place - the modes change with the game.""" for widget in self.buttons.values(): widget.destroy() self.buttons = {} for key, text in options: widget = tk.Button(self, text=text, font=UI, relief="flat", bd=0, padx=13, pady=5, highlightthickness=0, cursor="hand2", command=lambda k=key: self.set(k)) widget.pack(side="left", padx=1, pady=1) self.buttons[key] = widget keys = list(self.buttons) self.value = value if value in keys else (keys[0] if keys else None) self._paint() def set(self, key): self.value = key self._paint() if self.command: self.command(key) def _paint(self): for key, widget in self.buttons.items(): on = key == self.value widget.config(bg=ACCENT if on else FIELD, fg=ACCENT_INK if on else DIM, activebackground=ACCENT if on else LINE, activeforeground=ACCENT_INK if on else TEXT) class Relay: """Everything printed in this process, on its way to the log pane.""" def __init__(self, stream, sink: queue.Queue): self.stream = stream self.sink = sink self.partial = "" def write(self, text: str) -> int: if self.stream is not None: try: self.stream.write(text) except Exception: pass self.partial += text.replace("\r", "\n") while "\n" in self.partial: line, self.partial = self.partial.split("\n", 1) if line.strip(): self.sink.put(line) return len(text) def flush(self) -> None: if self.stream is not None: try: self.stream.flush() except Exception: pass def isatty(self) -> bool: return False # ------------------------------------------------------------------- app class App(tk.Tk): def __init__(self): super().__init__() self.title("KittyMacro") self.configure(bg=BG) self.geometry("1040x740") self.minsize(920, 620) self._scale_for_display() self._set_icon() self._dark_title_bar() self.cfg = config.load(MACRO.NAME, MACRO.DEFAULTS) self.piano = piano.migrate(config.load(piano.NAME, piano.DEFAULTS)) self.playing = "animevanguards" # which macro START runs self.ends = {} # the two hit-line ends being picked self.lane_region = None self.lane_cells = [] self.lane_picks = [] self.logs: queue.Queue[str] = queue.Queue() sys.stdout = Relay(sys.stdout, self.logs) sys.stderr = Relay(sys.stderr, self.logs) # what the macro thread is doing, if anything self.task: threading.Thread | None = None self.kind: str | None = None # "run" | "play" | "record" self.runner: Runner | None = None self.signals: Signals | None = None self.recording = False # the recorder is actually taking steps self.countdown = None self.page = "home" self.pick: str | None = None # the point Capture is waiting for # Watched every 15ms, not every tick: a tap of a key lasts about 80ms. self.watch = Hotkeys(catch_taps=True, take=self.cfg["hotkeys"].get("capture", "f6"), skip=self.cfg["hotkeys"].get("stop", "f8")) self.roblox = None self._roblox_at = 0.0 self._ready_shown = None self._build() self.subtitle.config(text=self.SUBTITLE[self.playing]) self._show("home") self._log(f"KittyMacro {__version__} — {sys.platform}, python {sys.version.split()[0]}") self._check_input() self._refresh_routines() self._refresh_points() self._load_fields() self.protocol("WM_DELETE_WINDOW", self._close) self._tick() self._watch_keys() # ---------------------------------------------------------- chrome def _scale_for_display(self): """The macro makes the process DPI aware before Tk starts.""" try: dpi = ctypes.windll.user32.GetDpiForSystem() except Exception: dpi = 96 self.tk.call("tk", "scaling", max(dpi, 96) / 72.0) def _dark_title_bar(self): """The frame is Windows' to draw, and it draws it white unless told.""" try: self.update_idletasks() hwnd = ctypes.windll.user32.GetParent(self.winfo_id()) ctypes.windll.dwmapi.DwmSetWindowAttribute( hwnd, 20, ctypes.byref(ctypes.c_int(1)), ctypes.sizeof(ctypes.c_int)) except Exception: pass def _set_icon(self): self.logo = None for candidate in (HERE / "logo.png", HERE.parent / "FullLogo_NoBuffer.png"): if not candidate.exists(): continue try: image = tk.PhotoImage(file=str(candidate)) self.iconphoto(True, image) self.logo = image.subsample(max(1, round(image.height() / 34))) self._icon_keep = image except Exception: pass break def _build(self): self._build_header() body = tk.Frame(self, bg=BG) body.pack(fill="both", expand=True) self._build_sidebar(body) split = tk.PanedWindow(body, orient="vertical", bg=BG, bd=0, sashwidth=7, sashrelief="flat", showhandle=False, sashpad=0) split.pack(side="left", fill="both", expand=True) stack = tk.Frame(split, bg=BG) split.add(stack, minsize=240, stretch="always") stack.rowconfigure(0, weight=1) stack.columnconfigure(0, weight=1) self.pages, self.sheets = {}, {} for name, _title in PAGES: self.pages[name], self.sheets[name] = self._scroller(stack) self._page_home(self.sheets["home"]) self._page_routines(self.sheets["routines"]) self._page_calibrate(self.sheets["calibrate"]) self._page_piano(self.sheets["piano"]) self._page_event(self.sheets["event"]) self._page_settings(self.sheets["settings"]) self._build_log(split) # The log is a sidecar, not half the window; leave it where it starts. self.after(120, lambda: split.sash_place(0, 0, 470)) def _scroller(self, stack): """A page taller than the window is one you can still reach the bottom of.""" outer = tk.Frame(stack, bg=BG) outer.grid(row=0, column=0, sticky="nsew") bar = tk.Scrollbar(outer, bg=PANEL, troughcolor=BG, activebackground=LINE, bd=0, highlightthickness=0, relief="flat", width=11) canvas = tk.Canvas(outer, bg=BG, highlightthickness=0, bd=0, yscrollcommand=bar.set) canvas.pack(side="left", fill="both", expand=True) bar.config(command=canvas.yview) sheet = tk.Frame(canvas, bg=BG, padx=18, pady=16) window = canvas.create_window((0, 0), window=sheet, anchor="nw") def fitted(_event=None): canvas.configure(scrollregion=canvas.bbox("all")) over = sheet.winfo_reqheight() > canvas.winfo_height() if over and not bar.winfo_ismapped(): bar.pack(side="right", fill="y") elif not over and bar.winfo_ismapped(): bar.pack_forget() sheet.bind("", fitted) canvas.bind("", lambda event: (canvas.itemconfigure(window, width=event.width), fitted())) # Only while the pointer is over this page - the log has its own wheel. canvas.bind("", lambda _e: canvas.bind_all( "", lambda event: canvas.yview_scroll(-event.delta // 120, "units"))) canvas.bind("", lambda _e: canvas.unbind_all("")) outer.canvas = canvas return outer, sheet def _build_header(self): head = tk.Frame(self, bg=PANEL, height=62) head.pack(fill="x") head.pack_propagate(False) tk.Frame(self, bg=LINE, height=1).pack(fill="x") left = tk.Frame(head, bg=PANEL) left.pack(side="left", padx=18) if self.logo is not None: tk.Label(left, image=self.logo, bg=PANEL).pack(side="left", padx=(0, 11)) text = tk.Frame(left, bg=PANEL) text.pack(side="left") label(text, "KittyMacro", font=UI_TITLE).pack(anchor="w") self.subtitle = label(text, "", font=UI_SMALL, fg=FAINT) self.subtitle.pack(anchor="w") right = tk.Frame(head, bg=PANEL) right.pack(side="right", padx=18) self.pill_stats = label(right, "", font=UI_SMALL, fg=DIM) self.pill_stats.pack(side="right", padx=(18, 0)) self.pill_state = self._pill(right, "idle") self.pill_roblox = self._pill(right, "Roblox") def _pill(self, parent, text): holder = tk.Frame(parent, bg=PANEL) holder.pack(side="right", padx=(16, 0)) dot = tk.Label(holder, text="●", font=("Segoe UI", 11), fg=FAINT, bg=PANEL) dot.pack(side="left", padx=(0, 5)) name = label(holder, text, font=UI_SMALL, fg=DIM) name.pack(side="left") holder.dot, holder.text = dot, name return holder def _build_sidebar(self, parent): side = tk.Frame(parent, bg=PANEL, width=176) side.pack(side="left", fill="y") side.pack_propagate(False) tk.Frame(parent, bg=LINE, width=1).pack(side="left", fill="y") choose = tk.Frame(side, bg=PANEL) choose.pack(fill="x", padx=14, pady=(14, 10)) label(choose, "PLAYING", font=UI_TINY, fg=FAINT).pack(anchor="w", pady=(0, 5)) self.game = Segmented(choose, [(name, title) for name, title, _m in GAMES], "animevanguards", command=self._choose_game) self.game.pack(fill="x") self.nav = {} for name, title in PAGES: row = tk.Frame(side, bg=PANEL) row.pack(fill="x") bar = tk.Frame(row, bg=PANEL, width=3) bar.pack(side="left", fill="y") item = tk.Button(row, text=title, font=UI, anchor="w", bg=PANEL, fg=DIM, activebackground=FIELD, activeforeground=TEXT, relief="flat", bd=0, highlightthickness=0, cursor="hand2", padx=14, pady=9, command=lambda n=name: self._show(n)) item.pack(side="left", fill="x", expand=True) self.nav[name] = (bar, item) foot = tk.Frame(side, bg=PANEL) foot.pack(side="bottom", fill="x", padx=14, pady=14) self.hotkey_hint = label(foot, "", font=UI_SMALL, fg=FAINT, bg=PANEL) self.hotkey_hint.pack(fill="x", pady=(0, 10)) self.btn_go = button(foot, "START", self._go, kind="accent") self.btn_go.pack(fill="x", pady=(0, 6)) self.btn_stop = button(foot, "Stop", self._stop, kind="danger") self.btn_stop.pack(fill="x") self.btn_stop.config(state="disabled") SUBTITLE = { "animevanguards": "Anime Vanguards — outside the game", "piano": "a falling-notes event — outside the game", } def _mode_changed(self, _key): self._ready_shown = None def _choose_game(self, name): self.playing = name self.subtitle.config(text=self.SUBTITLE[name]) self.mode.options(MODES[name], self.mode.value) self._ready_shown = None self._log(f"START now runs: {dict((n, t) for n, t, _m in GAMES)[name]}") if name == "piano": self._show("piano") def _module(self): return dict((name, module) for name, _t, module in GAMES)[self.playing] def _build_log(self, split): pane = tk.Frame(split, bg=PANEL, highlightthickness=1, highlightbackground=LINE) split.add(pane, minsize=96) bar = tk.Frame(pane, bg=PANEL) bar.pack(fill="x", padx=12, pady=(8, 4)) label(bar, "LOG", font=UI_TINY, fg=FAINT).pack(side="left") button(bar, "Clear", self._clear_log, kind="ghost").pack(side="right") self.follow = tk.BooleanVar(value=True) tk.Checkbutton(bar, text="follow", variable=self.follow, font=UI_SMALL, bg=PANEL, fg=DIM, activebackground=PANEL, activeforeground=TEXT, selectcolor=FIELD, relief="flat", bd=0, highlightthickness=0 ).pack(side="right", padx=8) holder = tk.Frame(pane, bg=PANEL) holder.pack(fill="both", expand=True, padx=12, pady=(0, 10)) bars = tk.Scrollbar(holder, bg=PANEL, troughcolor=BG, activebackground=LINE, bd=0, highlightthickness=0, relief="flat", width=11) bars.pack(side="right", fill="y") self.log = tk.Text(holder, height=9, font=MONO, bg=BG, fg=DIM, relief="flat", bd=0, highlightthickness=0, wrap="word", padx=10, pady=8, yscrollcommand=bars.set, state="disabled") self.log.pack(side="left", fill="both", expand=True) bars.config(command=self.log.yview) for tag, colour in (("good", GOOD), ("warn", WARN), ("bad", BAD), ("dim", FAINT), ("plain", TEXT)): self.log.tag_config(tag, foreground=colour) # ------------------------------------------------------------ pages def _show(self, name): self.page = name self.pages[name].tkraise() self.pages[name].canvas.yview_moveto(0) for key, (bar, item) in self.nav.items(): on = key == name bar.config(bg=ACCENT if on else PANEL) item.config(fg=TEXT if on else DIM, bg=FIELD if on else PANEL) if name == "routines": self._refresh_routines() if name == "settings": self._load_fields() if name == "piano": self._rebuild_region() if name == "event": self._refresh_event() # -- home --------------------------------------------------------- def _page_home(self, page): body = card(page, "session") top = tk.Frame(body, bg=PANEL) top.pack(fill="x") self.state_big = label(top, "idle", font=UI_BIG) self.state_big.pack(side="left") self.mode = Segmented(top, MODES["animevanguards"], "loop", command=self._mode_changed) self.mode.pack(side="right") self.state_note = label(body, "press START, then let it have the window", font=UI_SMALL, fg=DIM) self.state_note.pack(fill="x", pady=(6, 12)) stats = tk.Frame(body, bg=PANEL) stats.pack(fill="x") self.stat_widgets = {} for key in ("a", "b", "c", "d"): cell = tk.Frame(stats, bg=PANEL) cell.pack(side="left", expand=True, fill="x") value = label(cell, "—", font=UI_BOLD) value.pack(anchor="w") caption = label(cell, "", font=UI_SMALL, fg=FAINT) caption.pack(anchor="w") self.stat_widgets[key] = (value, caption) body = card(page, "before you start") self.ready_rows = tk.Frame(body, bg=PANEL) self.ready_rows.pack(fill="x") row = tk.Frame(body, bg=PANEL) row.pack(fill="x", pady=(12, 0)) button(row, "Re-check", self._check_input).pack(side="left") button(row, "Test input — jumps in game", self._test_input).pack(side="left", padx=8) body = card(page, "what start does") self.explain = label(body, "", font=UI_SMALL, fg=DIM, wraplength=640) self.explain.pack(fill="x") EXPLAIN = { "animevanguards": "Brings Roblox forward, waits three seconds, then: close the popup, press " "start, replay your build order, upgrade while the waves run, watch the " "results pixel, press replay. Again.\n\n" "It only ever sends input while Roblox is the front window. Alt-tab away " "and it lets go of everything and waits.", "piano": "Brings Roblox forward, waits three seconds, then reads the strip across " "your hit line about 180 times a second. A lane that stops looking like " "empty track gets its key held down until it looks empty again — which " "taps the short notes and holds the long ones, without either being a " "special case.\n\n" "A song is over when the track stops changing, or the moment the results " "pixel shows if you picked one. Song after song waits for the next one to " "start; Full session walks in through the menus and plays every song at " "every difficulty, remembering across restarts which ones it has done.\n\n" "Nothing is predicted and nothing is read out of the game. If you can see " "the note, so can it, at the same moment you can.", } def _ready_rows(self): if self.playing == "piano": lanes = self.piano.get("lanes") or [] sampled = [lane for lane in lanes if lane.get("empty") or lane.get("note")] finish = self.piano.get("finish") or {} probe = (finish.get("clock") or finish.get("probe") or {}).get("at") rows = [ (bool(self.roblox), "Roblox window", "found" if self.roblox else "not open — start the game"), (bool(lanes), "Lanes", f"{len(lanes)} across the hit line: " f"{''.join(lane.get('key') or '·' for lane in lanes)}" if lanes else "not laid out — Piano tab, pick both ends of the hit line"), (bool(lanes) and len(sampled) == len(lanes), "Empty track", f"sampled for all {len(lanes)}" if lanes and len(sampled) == len(lanes) else f"{len(sampled)} of {len(lanes)} sampled — nothing fires without it"), (bool(probe), "End of a song", f"watching {tuple(probe)}" if probe else "on stillness alone — Event tab, point at the song timer"), ] if self.mode.value == "session": rows += self._session_rows() return rows routines = config.list_routines() build = self.cfg["routines"].get("build") points = self.cfg["points"] return [ (bool(self.roblox), "Roblox window", "found" if self.roblox else "not open — start the game"), (bool(build and build in routines), "Build routine", f"{build!r} recorded" if build in routines else f"nothing recorded — Routines tab, record {build or 'one'}"), (bool(points.get("start")), "Start button", "calibrated" if points.get("start") else "not set — the macro will assume a match is already running"), (bool(points.get("end_probe")), "Results pixel", "calibrated" if points.get("end_probe") else "not set — matches end on a timer instead, which wastes the fast ones"), ] def _session_rows(self): """The three things a session needs that playing one song does not.""" routes = self.piano.get("routes") or {} session = self.piano.get("session") or {} songs = session.get("songs") or {} levels = session.get("difficulties") or [] set_levels = [level for level in levels if level.get("at")] into = [step for step in routes.get("into") or [] if step.get("at")] done = len(piano.load_progress().get("done") or {}) rows = piano.song_rows(songs) total = piano.song_count(songs) * max(1, len(levels)) return [ (bool(into), "Way in", f"{len(into)} steps picked" if into else "not picked — Event tab. Without it, start on the song list yourself"), (bool(rows), "Song list", f"{len(rows)} rows picked" if rows else "not picked — Event tab, one point per row of the song list"), (bool(levels) and len(set_levels) == len(levels), "Difficulties", f"all {len(levels)} picked" if levels and len(set_levels) == len(levels) else f"{len(set_levels)} of {len(levels)} picked — the rest play whatever " f"is already chosen"), (done < total, "Progress", f"{done} of {total} runs done" if done < total else f"all {total} done — Forget them on the Event tab to go again"), ] def _refresh_ready(self): rows = self._ready_rows() self.explain.config(text=self.EXPLAIN[self.playing]) shown = [(ok, text) for ok, _name, text in rows] + [self.playing] if shown == self._ready_shown: return self._ready_shown = shown for child in self.ready_rows.winfo_children(): child.destroy() for ok, name, text in rows: row = tk.Frame(self.ready_rows, bg=PANEL) row.pack(fill="x", pady=2) tk.Label(row, text="✓" if ok else "✕", font=UI_BOLD, width=2, fg=GOOD if ok else WARN, bg=PANEL).pack(side="left") label(row, name, font=UI, width=15).pack(side="left") label(row, text, font=UI_SMALL, fg=DIM).pack(side="left") def _check_input(self): width, height = screen.screen_size() try: elevated = bool(ctypes.windll.shell32.IsUserAnAdmin()) except Exception: elevated = False try: winput.move_rel(0, 0) self._log(f"screen {width}x{height} · sendinput accepted · " f"{'elevated' if elevated else 'not elevated'}") except winput.InputError as error: self._log(f"sendinput REFUSED — {error}") self._log("if Roblox is running as administrator, run KittyMacro the same way") self._ready_shown = None self._refresh_ready() def _test_input(self): if self.task: self._log("stop the macro first") return screen.focus_roblox() self._log("live test — two jumps in three seconds") self._count(3, self._jump_twice) def _jump_twice(self): if not screen.roblox_focused(): self._log("Roblox is not the front window — click it and try again") return winput.tap("space", hold=0.08) self.after(400, lambda: winput.tap("space", hold=0.08)) self._log("sent two jumps. If your character jumped, everything below works.") # -- routines ----------------------------------------------------- def _page_routines(self, page): body = card(page, "recorded") holder = tk.Frame(body, bg=PANEL) holder.pack(fill="x") bars = tk.Scrollbar(holder, bg=PANEL, troughcolor=BG, activebackground=LINE, bd=0, highlightthickness=0, width=11) bars.pack(side="right", fill="y") self.routine_list = tk.Listbox( holder, height=6, font=MONO, bg=FIELD, fg=TEXT, relief="flat", bd=0, highlightthickness=0, activestyle="none", selectbackground=ACCENT, selectforeground=ACCENT_INK, yscrollcommand=bars.set) self.routine_list.pack(side="left", fill="x", expand=True) bars.config(command=self.routine_list.yview) row = tk.Frame(body, bg=PANEL) row.pack(fill="x", pady=(10, 0)) self.play_speed = tk.StringVar(value="1.0") button(row, "Play once", self._play).pack(side="left") label(row, "speed", font=UI_SMALL, fg=DIM).pack(side="left", padx=(12, 5)) entry(row, self.play_speed, width=5).pack(side="left") button(row, "Delete", self._delete_routine, kind="danger").pack(side="right") button(row, "Open folder", lambda: os.startfile(config.ROUTINE_DIR) if config.ROUTINE_DIR.exists() else self._log("nothing recorded yet"), kind="ghost").pack(side="right", padx=8) body = card(page, "record a new one") label(body, "Press record, then play one round by hand exactly as you would — " "pick a unit, click the tile, upgrade. Every click and key press is " "saved with the gap before it, so your timing becomes the macro's.", font=UI_SMALL, fg=DIM, wraplength=640).pack(fill="x", pady=(0, 10)) row = tk.Frame(body, bg=PANEL) row.pack(fill="x") label(row, "name", font=UI_SMALL, fg=DIM).pack(side="left", padx=(0, 6)) self.record_name = tk.StringVar(value="build") entry(row, self.record_name, width=16).pack(side="left") self.btn_record = button(row, "Record", self._record, kind="accent") self.btn_record.pack(side="left", padx=10) self.record_note = label(row, "", font=UI_SMALL, fg=DIM) self.record_note.pack(side="left") body = card(page, "what the macro uses") grid = tk.Frame(body, bg=PANEL) grid.pack(fill="x") self.use_build = tk.StringVar() self.use_loop = tk.StringVar() self.menu_build = self._picker(grid, "build — once, at the start of a match", self.use_build, 0) self.menu_loop = self._picker(grid, "loop — over and over until the match ends", self.use_loop, 1) label(body, "Upgrade clicks and ability keys belong in the loop routine. " "Leave it as (none) if your build order is the whole round.", font=UI_SMALL, fg=FAINT, wraplength=640).pack(fill="x", pady=(10, 0)) def _picker(self, parent, text, variable, row): label(parent, text, font=UI_SMALL, fg=DIM).grid(row=row, column=1, sticky="w", padx=12, pady=4) menu = tk.OptionMenu(parent, variable, "") menu.config(bg=FIELD, fg=TEXT, activebackground=LINE, activeforeground=TEXT, font=UI, relief="flat", bd=0, highlightthickness=0, width=14, anchor="w", direction="below", cursor="hand2") menu["menu"].config(bg=FIELD, fg=TEXT, activebackground=ACCENT, activeforeground=ACCENT_INK, font=UI, bd=0, relief="flat") menu.grid(row=row, column=0, sticky="w", pady=4) variable.trace_add("write", lambda *_: self._save_use()) return menu def _refresh_routines(self): names = config.list_routines() selected = self._selected_routine() self.routine_list.delete(0, "end") for name in names: try: steps = config.load_routine(name) except Exception: continue clicks = sum(1 for step in steps if step.get("do") == "click") keys = sum(1 for step in steps if step.get("do") == "key") seconds = sum(float(step.get("after", 0)) for step in steps) self.routine_list.insert( "end", f" {name:<16} {len(steps):>3} steps {clicks} clicks, " f"{keys} keys, {seconds:.0f}s") if not names: self.routine_list.insert("end", " nothing recorded yet") elif selected in names: self.routine_list.selection_set(names.index(selected)) self._routines = names for menu, variable, allow_none in ((self.menu_build, self.use_build, False), (self.menu_loop, self.use_loop, True)): inner = menu["menu"] inner.delete(0, "end") options = (["(none)"] if allow_none else []) + names for option in options or ["(none)"]: inner.add_command(label=option, command=lambda v=variable, o=option: v.set(o)) self._quiet_use = True self.use_build.set(self.cfg["routines"].get("build") or "(none)") self.use_loop.set(self.cfg["routines"].get("loop") or "(none)") self._quiet_use = False self._ready_shown = None def _selected_routine(self): picked = self.routine_list.curselection() if hasattr(self, "routine_list") else () names = getattr(self, "_routines", []) if not picked or picked[0] >= len(names): return None return names[picked[0]] def _save_use(self): if getattr(self, "_quiet_use", False): return build = self.use_build.get() loop = self.use_loop.get() self.cfg["routines"]["build"] = None if build == "(none)" else build self.cfg["routines"]["loop"] = None if loop == "(none)" else loop config.save(MACRO.NAME, self.cfg) self._log(f"build = {self.cfg['routines']['build']!r}, " f"loop = {self.cfg['routines']['loop']!r}") self._ready_shown = None def _delete_routine(self): name = self._selected_routine() if not name: self._log("pick a routine in the list first") return if not messagebox.askyesno("Delete routine", f"Delete {name!r}?", parent=self): return config.routine_path(name).unlink(missing_ok=True) self._log(f"deleted {name!r}") self._refresh_routines() # -- calibrate ---------------------------------------------------- def _page_calibrate(self, page): body = card(page, "under the cursor") row = tk.Frame(body, bg=PANEL) row.pack(fill="x") self.cursor_at = label(row, "0, 0", font=MONO_BIG) self.cursor_at.pack(side="left") self.cursor_swatch = tk.Frame(row, bg="#000000", width=34, height=34, highlightthickness=1, highlightbackground=LINE) self.cursor_swatch.pack(side="left", padx=14) self.cursor_swatch.pack_propagate(False) self.cursor_rgb = label(row, "", font=MONO, fg=DIM) self.cursor_rgb.pack(side="left") button(row, "Capture in 3s", self._pick_after_countdown, kind="ghost").pack(side="right") self.pick_note = label(body, "Pick a point below, hover it in Roblox, " "then press the capture key.", font=UI_SMALL, fg=DIM) self.pick_note.pack(fill="x", pady=(8, 0)) body = card(page, "points") self.point_rows = {} for key, prompt in MACRO.POINTS: row = tk.Frame(body, bg=PANEL) row.pack(fill="x", pady=4) head = tk.Frame(row, bg=PANEL, width=110) head.pack(side="left", fill="y") head.pack_propagate(False) label(head, key, font=UI_BOLD).pack(anchor="w") middle = tk.Frame(row, bg=PANEL) middle.pack(side="left", fill="x", expand=True) label(middle, prompt, font=UI_SMALL, fg=FAINT, wraplength=390).pack(anchor="w") where = label(row, "not set", font=MONO, fg=WARN, width=16) where.pack(side="left", padx=8) swatch = tk.Frame(row, bg=PANEL, width=20, height=20, highlightthickness=0, highlightbackground=LINE) swatch.pack(side="left", padx=(0, 10)) swatch.pack_propagate(False) button(row, "Clear", lambda k=key: self._clear_point(k), kind="ghost").pack( side="right", padx=(6, 0)) pick = button(row, "Pick", lambda k=key: self._arm_pick(k)) pick.pack(side="right") self.point_rows[key] = (where, swatch, pick) body = card(page, "the results pixel") row = tk.Frame(body, bg=PANEL) row.pack(fill="x") label(row, "tolerance", font=UI_SMALL, fg=DIM).pack(side="left", padx=(0, 10)) self.tolerance = tk.StringVar(value=str(int(self.cfg.get("tolerance", 26)))) button(row, "−", lambda: self._nudge_tolerance(-2), width=2).pack(side="left") box = entry(row, self.tolerance, width=4) box.pack(side="left", padx=6) box.bind("", lambda _e: self._save_tolerance()) box.bind("", lambda _e: self._save_tolerance()) button(row, "+", lambda: self._nudge_tolerance(2), width=2).pack(side="left") button(row, "Test now", self._test_probe).pack(side="left", padx=14) label(body, "How far each of red, green and blue may drift before that pixel " "counts as a different colour. Put the probe on something loud — a " "coloured header, a button's fill — never on grey background.", font=UI_SMALL, fg=FAINT, wraplength=640).pack(fill="x", pady=(8, 0)) def _page_piano(self, page): body = card(page, "the hit line") label(body, "Put one sampling point on each lane, right where the note is when " "you would press it. Lanes are evenly spaced, so capture the two " "ends and the rest are worked out.\n\n" "Then Watch the track: it brings Roblox forward and watches your " "lanes for six seconds of the song. The colour a lane shows most of " "the time is the empty track, the rest are notes, and the threshold " "goes between them. You do not have to find a quiet moment.", font=UI_SMALL, fg=DIM, wraplength=640).pack(fill="x", pady=(0, 10)) row = tk.Frame(body, bg=PANEL) row.pack(fill="x") label(row, "keys", font=UI_SMALL, fg=DIM).pack(side="left", padx=(0, 6)) self.lane_keys = tk.StringVar(value="".join( lane.get("key") or "" for lane in self.piano.get("lanes") or []) or "asdfg") entry(row, self.lane_keys, width=14).pack(side="left") label(row, "one per lane, left to right", font=UI_SMALL, fg=FAINT).pack( side="left", padx=10) row = tk.Frame(body, bg=PANEL) row.pack(fill="x", pady=(10, 0)) self.end_picks = {} for end, text in (("left", "Pick left end"), ("right", "Pick right end")): pick = button(row, text, lambda e=end: self._arm_pick(f"lane:{e}")) pick.plain = text pick.pack(side="left", padx=(0, 8)) self.end_picks[end] = pick button(row, "Capture in 3s", self._pick_after_countdown, kind="ghost").pack( side="left") self.btn_watch = button(row, "Watch the track", self._sample_empty, kind="accent") self.btn_watch.pack(side="right") button(row, "Trace 5s", self._trace).pack(side="right", padx=8) button(row, "Find the lanes", self._find_lanes).pack(side="right") button(row, "Snapshot", self._snapshot, kind="ghost").pack(side="right", padx=8) self.lane_note = label(body, "", font=UI_SMALL, fg=DIM) self.lane_note.pack(fill="x", pady=(10, 0)) body = card(page, "what it is reading") self.lane_strip = tk.Frame(body, bg=PANEL) self.lane_strip.pack(fill="x") label(body, "\u0394 is how far that pixel is from the empty track right now. " "Watch it with the track clear, then with notes crossing: the " "threshold belongs between the two.", font=UI_SMALL, fg=FAINT, wraplength=640).pack(fill="x", pady=(10, 0)) body = card(page, "tuning") self.tune = {} for name, title, hint in TUNING: row = tk.Frame(body, bg=PANEL) row.pack(fill="x", pady=3) label(row, title, font=UI, width=11).pack(side="left") variable = tk.StringVar(value=str(self.piano["detect"].get(name))) box = entry(row, variable, width=7) box.pack(side="left") box.bind("", lambda _e: self._save_tuning()) box.bind("", lambda _e: self._save_tuning()) label(row, hint, font=UI_SMALL, fg=FAINT, wraplength=430).pack( side="left", padx=12) self.tune[name] = variable row = tk.Frame(body, bg=PANEL) row.pack(fill="x", pady=(6, 0)) label(row, "Hold notes", font=UI, width=11).pack(side="left") self.hold_notes = tk.BooleanVar(value=bool(self.piano["detect"].get("hold_notes"))) tk.Checkbutton(row, variable=self.hold_notes, bg=PANEL, fg=ACCENT, activebackground=PANEL, activeforeground=ACCENT, selectcolor=FIELD, relief="flat", bd=0, highlightthickness=0, command=self._save_tuning).pack(side="left") label(row, "keep the key down while the note is still under the sampler. " "Off means every note is a tap", font=UI_SMALL, fg=FAINT, wraplength=430).pack(side="left", padx=12) row = tk.Frame(body, bg=PANEL) row.pack(fill="x", pady=(6, 0)) label(row, "Match note", font=UI, width=11).pack(side="left") self.match_note = tk.BooleanVar( value=any(lane.get("note") for lane in self._lanes())) tk.Checkbutton(row, variable=self.match_note, bg=PANEL, fg=ACCENT, activebackground=PANEL, activeforeground=ACCENT, selectcolor=FIELD, relief="flat", bd=0, highlightthickness=0, command=self._apply_match).pack(side="left") label(row, "go by the note's own colour rather than by anything unlike the " "track. Try this if a lane lights up while its key is held and " "never lets go", font=UI_SMALL, fg=FAINT, wraplength=430).pack(side="left", padx=12) def _apply_match(self): on = self.match_note.get() lanes = self._lanes() missing = [lane["key"] for lane in lanes if not lane.get("note_seen")] for lane in lanes: lane["note"] = list(lane["note_seen"]) if (on and lane.get("note_seen")) else None config.save(piano.NAME, self.piano) if on and missing: self._log(f"no note colour known for {''.join(missing)} — watch the track " f"during the song first, then turn this back on") self._log("going by the note colour" if on else "going by anything unlike the track") # -- event -------------------------------------------------------- def _page_event(self, page): """The way into the event, and the forty runs once you are in.""" label(page, "Getting to the event and back out of a song is clicking, and " "clicking is a point you pick once. Nothing here is guessed and " "nothing is hardcoded — a step with nothing picked is skipped and " "said out loud, so a half-built route still walks as far as it " "goes.", font=UI_SMALL, fg=DIM, wraplength=660, bg=BG).pack(fill="x", pady=(0, 12)) self.event_note = label(page, "", font=UI_SMALL, fg=DIM, bg=BG) self.event_note.pack(fill="x", pady=(0, 8)) self.event_holder = tk.Frame(page, bg=BG) self.event_holder.pack(fill="both", expand=True) self.event_picks = {} self._refresh_event() def _refresh_event(self): """Draw the routes and the run grid from the config as it stands.""" for child in self.event_holder.winfo_children(): child.destroy() self.event_picks = {} self.piano.setdefault("routes", {}) self.piano.setdefault("session", {}) self.piano.setdefault("finish", {}) self._route_card(self.event_holder) self._ending_card(self.event_holder) self._runs_card(self.event_holder) # ---------------------------------------------------------- pieces def _save_piano(self): config.save(piano.NAME, self.piano) self._ready_shown = None def _edited(self, box, save): box.bind("", lambda _event: save()) box.bind("", lambda _event: save()) return box def _number(self, parent, read, write, width=5, whole=False): """A number you can type over. Saved when you leave it or press Enter.""" variable = tk.StringVar(value=_short(read())) def save(): try: write(int(float(variable.get())) if whole else float(variable.get())) self._save_piano() except ValueError: self._log(f"{variable.get()!r} is not a number") variable.set(_short(read())) return self._edited(entry(parent, variable, width=width), save) def _text(self, parent, read, write, width=18): variable = tk.StringVar(value=str(read() or "")) def save(): write(variable.get().strip()) self._save_piano() return self._edited(entry(parent, variable, width=width), save) def _point_row(self, parent, title, key, at, hint="", extra=None): """One picked point: what it is, where it is, and a button to set it.""" row = tk.Frame(parent, bg=PANEL) row.pack(fill="x", pady=2) label(row, title, font=UI, width=16).pack(side="left") label(row, f"{at[0]}, {at[1]}" if at else "not picked", font=MONO, fg=TEXT if at else WARN, width=12).pack(side="left") pick = button(row, "Pick", lambda: self._arm_pick(f"event:{key}"), kind="ghost") pick.plain = "Pick" pick.pack(side="left") self.event_picks[f"event:{key}"] = pick if extra: extra(row) if hint: label(row, hint, font=UI_SMALL, fg=FAINT).pack(side="left", padx=10) return row # ----------------------------------------------------------- routes ROUTES = [("into", "the way in", "Events → Special Events → scroll → the event → Play"), ("back", "back to the list", "off the results screen and onto the songs")] def _route_card(self, page): body = card(page, "routes") label(body, "Pick a step, watch it land, then pick the next. Walk it runs the " "route once without playing anything, which is the whole point of " "having it separate.", font=UI_SMALL, fg=DIM, wraplength=640).pack(fill="x", pady=(0, 4)) for which, title, hint in self.ROUTES: steps = self.piano["routes"].setdefault(which, []) head = tk.Frame(body, bg=PANEL) head.pack(fill="x", pady=(12, 4)) label(head, title, font=UI_BOLD).pack(side="left") label(head, hint, font=UI_SMALL, fg=FAINT).pack(side="left", padx=9) button(head, "Walk it", lambda w=which: self._walk_route(w)).pack(side="right") if not steps: label(body, "no steps — add one below", font=UI_SMALL, fg=FAINT).pack(fill="x") for index, step in enumerate(steps): self._step_row(body, which, index, step) add = tk.Frame(body, bg=PANEL) add.pack(fill="x", pady=(5, 0)) for kind in ("click", "scroll", "key", "wait"): button(add, f"+ {kind}", lambda w=which, k=kind: self._add_step(w, k), kind="ghost").pack(side="left", padx=(0, 5)) def _step_row(self, parent, which, index, step): row = tk.Frame(parent, bg=PANEL) row.pack(fill="x", pady=2) kind = step.get("do", "click") label(row, f"{index + 1}.", font=UI_SMALL, fg=FAINT, width=3).pack(side="left") label(row, kind, font=UI_SMALL, fg=DIM, width=6).pack(side="left") self._text(row, lambda: step.get("note"), lambda value: step.__setitem__("note", value), width=20).pack(side="left") if kind in ("click", "scroll"): at = step.get("at") label(row, f"{at[0]}, {at[1]}" if at else "not picked", font=MONO, fg=TEXT if at else WARN, width=12).pack(side="left", padx=(8, 4)) key = f"event:route:{which}:{index}" pick = button(row, "Pick", lambda k=key: self._arm_pick(k), kind="ghost") pick.plain = "Pick" pick.pack(side="left") self.event_picks[key] = pick elif kind == "key": self._text(row, lambda: step.get("key") or "e", lambda value: step.__setitem__("key", value or "e"), width=4).pack(side="left", padx=(8, 4)) else: label(row, "", width=12).pack(side="left", padx=(8, 4)) if kind == "scroll": label(row, "clicks", font=UI_SMALL, fg=FAINT).pack(side="left", padx=(10, 3)) self._number(row, lambda: step.get("amount", -5), lambda value: step.__setitem__("amount", value), width=4, whole=True).pack(side="left") label(row, "then wait", font=UI_SMALL, fg=FAINT).pack(side="left", padx=(10, 3)) self._number(row, lambda: step.get("after", 0.8), lambda value: step.__setitem__("after", value), width=4).pack(side="left") button(row, "✕", lambda w=which, i=index: self._drop_step(w, i), kind="ghost").pack(side="right") def _add_step(self, which, kind): step = {"do": kind, "after": 1.0, "note": ""} if kind in ("click", "scroll"): step["at"] = None if kind == "scroll": step["amount"] = -5 if kind == "key": step["key"] = "e" self.piano["routes"].setdefault(which, []).append(step) self._save_piano() self._refresh_event() self._log(f"{which}: added a {kind} step — pick where it goes") def _drop_step(self, which, index): steps = self.piano["routes"].get(which) or [] if index < len(steps): gone = steps.pop(index) self._save_piano() self._refresh_event() self._log(f"{which}: dropped step {index + 1} " f"({gone.get('note') or gone.get('do')})") def _walk_route(self, which): """Run one route once, so it can be checked without playing a song.""" if self._busy(): return steps = (self.piano.get("routes") or {}).get(which) or [] if not piano.picked(steps): self._log(f"nothing picked in the {which} route yet") return signals = Signals() runner = Runner(f"route {which}", self.piano["hotkeys"], signals=signals) def once(): piano.walk(runner, steps, f"walking the {which} route —") runner.stopped = True self._spawn("play", lambda: runner.loop(once), runner=runner, signals=signals) self._launch(signals, "pause") # ------------------------------------------------------- the ending def _ending_card(self, page): body = card(page, "the end of a song") finish = self.piano["finish"] label(body, "A song is over when the track stops changing — which is also what " "a long rest in the middle of a chart looks like, so the window is " "a few seconds rather than half of one.\n\n" "If the event has a timer on screen, point at it and that question " "is settled: a clock counts while the song runs and stops when it " "ends, and there is no colour to match or threshold to set. Watch " "it says whether it is ticking right now.", font=UI_SMALL, fg=DIM, wraplength=640).pack(fill="x", pady=(0, 8)) clock = finish.get("clock") or {} def watching(row): button(row, "Watch it", self._test_timer, kind="ghost").pack(side="left") button(row, "Clear", self._clear_timer, kind="ghost").pack(side="left", padx=4) self._point_row(body, "Song timer", "clock", clock.get("at"), extra=watching, hint="the counting numbers, not the bar around them") probe = finish.get("probe") or {} def swatch(row): patch = tk.Frame(row, bg=_hex(probe.get("color")), width=22, height=18, highlightthickness=1 if probe.get("color") else 0, highlightbackground=LINE) patch.pack(side="left", padx=8) patch.pack_propagate(False) button(row, "Test", self._test_end_probe, kind="ghost").pack(side="left") button(row, "Clear", self._clear_end_probe, kind="ghost").pack(side="left", padx=4) self._point_row(body, "Results pixel", "probe", probe.get("at"), extra=swatch, hint="optional, and the timer is easier") row = tk.Frame(body, bg=PANEL) row.pack(fill="x", pady=(8, 0)) for name, title, hint in ( ("quiet_seconds", "still for", "s of an unchanging track ends a song"), ("min_seconds", "not before", "s in — the count-in is still and quiet too"), ("max_seconds", "give up after", "s, whatever the screen says")): cell = tk.Frame(row, bg=PANEL) cell.pack(fill="x", pady=2) label(cell, title, font=UI, width=14).pack(side="left") self._number(cell, lambda n=name: self.piano["finish"].get(n, 0), lambda value, n=name: self.piano["finish"].__setitem__(n, value) ).pack(side="left") label(cell, hint, font=UI_SMALL, fg=FAINT).pack(side="left", padx=10) def _test_timer(self): """Two seconds of looking at the clock, to see whether it is counting.""" clock = (self.piano.get("finish") or {}).get("clock") or {} if not clock.get("at"): self._log("no timer picked yet") return screen.focus_roblox() self._log("watching the timer for 2s —") self._count(3, self._time_the_timer) def _time_the_timer(self): if not screen.roblox_focused(): self._log("Roblox is not the front window — that would have watched this " "window instead of the game") return clock = self.piano["finish"]["clock"] timer = piano.Timer(clock["at"]) started = time.perf_counter() while time.perf_counter() - started < 2.0: timer.look(time.perf_counter()) timer.close() if timer.ticks: self._log(f"the timer ticked {timer.ticks} times in 2s — a song ends " f"{self.piano['finish'].get('quiet_seconds', 4):.0f}s after it " f"stops") else: self._log("nothing changed there in 2s. If a song is playing, that is the " "wrong spot — point at the numbers themselves") def _clear_timer(self): self.piano["finish"]["clock"] = None self._save_piano() self._refresh_event() self._log("timer cleared — songs will end on stillness alone") def _test_end_probe(self): probe = (self.piano.get("finish") or {}).get("probe") if not probe or not probe.get("at"): self._log("no results pixel picked yet") return x, y = probe["at"] now = screen.pixel(x, y) tolerance = int(self.piano["finish"].get("tolerance", 26)) hit = screen.close_enough(now, probe.get("color"), tolerance) self._log(f"results pixel at {x},{y}: saved rgb{tuple(probe.get('color') or ())}, " f"now rgb{now} — {'MATCH' if hit else 'no match'}") def _clear_end_probe(self): self.piano["finish"]["probe"] = None self._save_piano() self._refresh_event() self._log("results pixel cleared — songs will end on stillness alone") # --------------------------------------------------------- the runs def _runs_card(self, page): body = card(page, "forty runs") session = self.piano["session"] songs = session.setdefault("songs", {}) points = songs.setdefault("points", []) levels = session.setdefault("difficulties", []) scrolls = int(songs.get("visible") or 0) total = piano.song_count(songs) * max(1, len(levels)) label(body, "Every song at every difficulty, in order, remembering what it has " "already played.", font=UI_SMALL, fg=DIM, wraplength=640).pack(fill="x", pady=(0, 8)) head = tk.Frame(body, bg=PANEL) head.pack(fill="x", pady=(4, 3)) label(head, "the song list", font=UI_BOLD).pack(side="left") label(head, f"{len(points)} rows picked · {total} runs", font=UI_SMALL, fg=FAINT).pack(side="left", padx=9) if not points: label(body, "no rows yet — add one per song below, or add two and space " "the rest out between them", font=UI_SMALL, fg=FAINT).pack(fill="x") for index, at in enumerate(points): row = tk.Frame(body, bg=PANEL) row.pack(fill="x", pady=1) label(row, f"{index + 1}.", font=UI_SMALL, fg=FAINT, width=3).pack(side="left") label(row, f"{at[0]}, {at[1]}" if at else "not picked", font=MONO, fg=TEXT if at else WARN, width=12).pack(side="left") key = f"event:songrow:{index}" pick = button(row, "Pick", lambda k=key: self._arm_pick(k), kind="ghost") pick.plain = "Pick" pick.pack(side="left") self.event_picks[key] = pick button(row, "✕", lambda i=index: self._drop_song(i), kind="ghost").pack(side="right") row = tk.Frame(body, bg=PANEL) row.pack(fill="x", pady=(6, 0)) button(row, "+ song", self._add_song, kind="ghost").pack(side="left", padx=(0, 5)) button(row, "Space them out", self._space_songs, kind="ghost").pack(side="left") label(row, "fills the unpicked rows evenly between the first and the last", font=UI_SMALL, fg=FAINT).pack(side="left", padx=9) row = tk.Frame(body, bg=PANEL) row.pack(fill="x", pady=(10, 2)) label(row, "of which", font=UI, width=9).pack(side="left") self._number(row, lambda: songs.get("visible", 0), lambda value: songs.__setitem__("visible", value), width=4, whole=True).pack(side="left") label(row, "fit on screen — 0 if they all do, and then there is no scrolling", font=UI_SMALL, fg=FAINT).pack(side="left", padx=8) if scrolls: self._point_row(body, "Scroll the list at", "song:scroll_at", songs.get("scroll_at"), hint="where the cursor goes to use the wheel") row = tk.Frame(body, bg=PANEL) row.pack(fill="x", pady=2) label(row, "songs", font=UI, width=16).pack(side="left") self._number(row, lambda: songs.get("count", 0), lambda value: songs.__setitem__("count", value), width=4, whole=True).pack(side="left") label(row, "altogether, over every screenful", font=UI_SMALL, fg=FAINT).pack(side="left", padx=8) row = tk.Frame(body, bg=PANEL) row.pack(fill="x", pady=2) label(row, "wheel clicks", font=UI, width=16).pack(side="left") self._number(row, lambda: songs.get("top_clicks", 15), lambda value: songs.__setitem__("top_clicks", value), width=4, whole=True).pack(side="left") label(row, "back to the top,", font=UI_SMALL, fg=FAINT).pack(side="left", padx=6) self._number(row, lambda: songs.get("page_clicks", -3), lambda value: songs.__setitem__("page_clicks", value), width=4, whole=True).pack(side="left") label(row, "for one screenful down", font=UI_SMALL, fg=FAINT).pack(side="left", padx=6) head = tk.Frame(body, bg=PANEL) head.pack(fill="x", pady=(14, 3)) label(head, "difficulties", font=UI_BOLD).pack(side="left") label(head, "clicked once the song is chosen", font=UI_SMALL, fg=FAINT).pack(side="left", padx=9) for index, level in enumerate(levels): row = tk.Frame(body, bg=PANEL) row.pack(fill="x", pady=2) label(row, f"{index + 1}.", font=UI_SMALL, fg=FAINT, width=3).pack(side="left") self._text(row, lambda l=level: l.get("name"), lambda value, l=level: l.__setitem__("name", value), width=10).pack(side="left") self._number(row, lambda l=level: 1 if l.get("clicks") is None else l["clicks"], lambda value, l=level: l.__setitem__("clicks", value), width=3, whole=True).pack(side="left", padx=(8, 4)) label(row, "clicks at", font=UI_SMALL, fg=FAINT).pack(side="left") at = level.get("at") label(row, f"{at[0]}, {at[1]}" if at else "not picked", font=MONO, fg=TEXT if at else WARN, width=12).pack(side="left", padx=6) key = f"event:diff:{index}" pick = button(row, "Pick", lambda k=key: self._arm_pick(k), kind="ghost") pick.plain = "Pick" pick.pack(side="left") self.event_picks[key] = pick row = tk.Frame(body, bg=PANEL) row.pack(fill="x", pady=(6, 0)) button(row, "One button that cycles", self._cycle_difficulty, kind="ghost").pack(side="left") label(row, "same point for all four, clicked 0, 1, 2, 3 times. If the panel " "remembers the last difficulty instead of starting at the first, " "make those 0, 1, 1, 1", font=UI_SMALL, fg=FAINT, wraplength=430).pack(side="left", padx=9) self._point_row(body, "Play button", "play", session.get("play"), hint="on the song panel, if it has its own") row = tk.Frame(body, bg=PANEL) row.pack(fill="x", pady=(12, 2)) label(row, "when a song ends", font=UI, width=16).pack(side="left") self.after_song = Segmented(row, (("list", "you are on the song list"), ("out", "you are outside the event")), session.get("after_song") or "list", command=self._save_after_song) self.after_song.pack(side="left") label(body, "Outside means the way in gets walked again before every run, " "which is slower and is simply what some events do.", font=UI_SMALL, fg=FAINT, wraplength=640).pack(fill="x", pady=(2, 0)) row = tk.Frame(body, bg=PANEL) row.pack(fill="x", pady=(12, 0)) done = piano.load_progress().get("done") or {} label(row, f"{len(done)} of {total} runs done", font=UI_BOLD, fg=ACCENT if done else DIM).pack(side="left") button(row, "Forget them", self._reset_progress, kind="ghost").pack(side="right") label(body, "Home page → Full session → START. Stopping is safe: it " "picks up at the run it was on.", font=UI_SMALL, fg=FAINT, wraplength=640).pack(fill="x", pady=(6, 0)) def _save_after_song(self, where): self.piano["session"]["after_song"] = where self._save_piano() self._log("after a song: back on the song list" if where == "list" else "after a song: outside the event, so the way in is walked again") def _song_points(self): return self.piano["session"].setdefault("songs", {}).setdefault("points", []) def _add_song(self): self._song_points().append(None) self._save_piano() self._refresh_event() def _drop_song(self, index): points = self._song_points() if index < len(points): points.pop(index) self._save_piano() self._refresh_event() self._log(f"dropped song row {index + 1}") def _space_songs(self): """Fill the unpicked rows evenly between the first picked and the last.""" points = self._song_points() placed = [(index, point) for index, point in enumerate(points) if point] if len(placed) < 2: self._log("pick the first and last rows first — then this fills the middle") return (top, first), (bottom, last) = placed[0], placed[-1] spread = piano.between(first, last, bottom - top + 1) filled = 0 for offset, point in enumerate(spread): if not points[top + offset]: points[top + offset] = point filled += 1 self._save_piano() self._refresh_event() self._log(f"spaced {filled} rows out between {tuple(first)} and {tuple(last)}" if filled else "every row between those two was already picked") def _cycle_difficulty(self): """One button that goes easy → medium → hard → expert as you click it.""" levels = self.piano["session"].setdefault("difficulties", []) at = next((level["at"] for level in levels if level.get("at")), None) if not at: self._log("pick the difficulty button on one of those rows first") return for index, level in enumerate(levels): level["at"] = list(at) level["clicks"] = index self._save_piano() self._refresh_event() self._log(f"all four difficulties go through {tuple(at)} — " f"{', '.join(str(index) for index in range(len(levels)))} clicks") def _reset_progress(self): if not messagebox.askyesno( "KittyMacro", "Forget which songs have been played?\n\n" "The next session starts again at the first song."): return piano.reset_progress() self._log("progress forgotten — the next session starts at song 1") self._refresh_event() # -------------------------------------------------------- capturing def _capture_event(self, what, x, y, colour): """A picked point, filed where the piano config keeps that kind.""" session = self.piano.setdefault("session", {}) parts = what.split(":") if parts[0] == "route": which, index = parts[1], int(parts[2]) steps = self.piano["routes"].get(which) or [] if index < len(steps): steps[index]["at"] = [x, y] self._log(f"{which} step {index + 1} " f"({steps[index].get('note') or steps[index].get('do')}): {x},{y}") elif parts[0] == "songrow": points = session.setdefault("songs", {}).setdefault("points", []) index = int(parts[1]) if index < len(points): points[index] = [x, y] self._log(f"song {index + 1}: {x},{y}") elif parts[0] == "song": session.setdefault("songs", {})[parts[1]] = [x, y] self._log(f"song list {parts[1].replace('_', ' ')}: {x},{y}") elif parts[0] == "diff": levels = session.setdefault("difficulties", []) index = int(parts[1]) if index < len(levels): levels[index]["at"] = [x, y] self._log(f"{levels[index].get('name') or index + 1}: {x},{y}") elif parts[0] == "play": session["play"] = [x, y] self._log(f"play button: {x},{y}") elif parts[0] == "clock": self.piano["finish"]["clock"] = {"at": [x, y]} self._log(f"song timer: {x},{y} — press Watch it during a song to check") elif parts[0] == "probe": self.piano["finish"]["probe"] = {"at": [x, y], "color": list(colour or ())} self._log(f"results pixel: {x},{y} rgb{colour}") self._save_piano() self._finish_pick() self._refresh_event() # ------------------------------------------------------------- lanes def _lanes(self): return self.piano.get("lanes") or [] def _lay_out_lanes(self): keys = self.lane_keys.get().strip().lower() if not keys: self._log("type the key for each lane first, left to right") return unknown = sorted({key for key in keys if key not in winput.KEYS}) if unknown: self._log(f"not keys this can press: {', '.join(repr(k) for k in unknown)} " f"— one character per lane, no spaces") return left, right = self.ends.get("left"), self.ends.get("right") if not (left and right): self._log("pick both ends of the hit line") return self.piano["lanes"] = piano.spread(keys, left, right) config.save(piano.NAME, self.piano) self._rebuild_region() self._log(f"{len(self.piano['lanes'])} lanes: " + ", ".join(f"{lane['key']}@{lane['at'][0]},{lane['at'][1]}" for lane in self.piano["lanes"])) self._log("now press Watch the track, with the song playing") self.ends.clear() self._ready_shown = None WATCHING = 6.0 TRACING = 5.0 SCANNING = 5.0 def _snapshot(self): """Save a picture of the track, because looking beats deducing.""" if not self._lanes(): self._log("no lanes yet — pick one point on the hit line first") return screen.focus_roblox() self._log("snapshot of the track in —") self._count(3, self._snapshot_now) def _snapshot_now(self): if not screen.roblox_focused(): self._log("Roblox is not the front window — click it and try again") return lanes = self._lanes() xs = [lane["at"][0] for lane in lanes] row = sorted(lane["at"][1] for lane in lanes)[len(lanes) // 2] width, height = screen.screen_size() left = max(0, min(xs) - 220) right = min(width - 1, max(xs) + 220) top = max(0, row - 320) bottom = min(height - 1, row + 90) shot = screen.Region(left, top, right - left + 1, bottom - top + 1) try: if not shot.read(): self._log("could not read the screen there") return path = shot.png(config.CONFIG_DIR / "snapshot.png") finally: shot.close() self._log(f"saved {path}") self._log(f" it covers x {left}–{right}, y {top}–{bottom}; " f"the hit row y={row} is {row - top}px down it") ROWS = 12 # px between the rows it tries COLUMNS = 4 # px between the columns it watches ABOVE = 460 # how far up the track to look BELOW = 60 # and how far below the row you pointed at def _scan_band(self): """The patch of screen to search: around the lanes, and well above them.""" width, height = screen.screen_size() lanes = self._lanes() points = ([tuple(lane["at"]) for lane in lanes] or list(self.ends.values())) if not points: return None xs = [x for x, _y in points] row = sorted(y for _x, y in points)[len(points) // 2] left = max(0, min(xs) - 260) right = min(width - 1, max(xs) + 260) top = max(0, row - self.ABOVE) bottom = min(height - 1, row + self.BELOW) return left, top, right - left + 1, bottom - top + 1 def _find_lanes(self): """Let the picture say where the lanes are.""" keys = self.lane_keys.get().strip().lower() if not keys: self._log("type the key for each lane first, left to right") return unknown = sorted({key for key in keys if key not in winput.KEYS}) if unknown: self._log(f"not keys this can press: {', '.join(repr(k) for k in unknown)}") return band = self._scan_band() if band is None: self._log("pick one point on a lane first, anywhere on it, so it knows " "roughly where to look") return if getattr(self, "watching", False): return self.scan_keys = keys self.band = band left, top, width, height = band screen.focus_roblox() self._log(f"watching x {left}–{left + width - 1}, y {top}–{top + height - 1} " f"for {self.SCANNING:.0f}s — let the song play") self._count(3, self._scan_now) def _scan_now(self): if not screen.roblox_focused(): self._log("Roblox is not the front window — click it and try again") return left, top, width, height = self.band self.scan_region = screen.Region(left, top, width, height) self.scan_columns = list(range(0, width, self.COLUMNS)) self.scan_rows = list(range(0, height, self.ROWS)) self.scan_low = [[10 ** 9] * len(self.scan_columns) for _ in self.scan_rows] self.scan_high = [[-1] * len(self.scan_columns) for _ in self.scan_rows] self.watching = True self.btn_watch.config(state="disabled") self.scan_until = time.monotonic() + self.SCANNING self.scans = 0 self._scan() def _scan(self): if not screen.roblox_focused(): self._stop_watching() self._log("Roblox went behind — nothing found. Try again with the game up.") return _left, _top, width, _height = self.band if self.scan_region.read(): self.scans += 1 # One copy out of the bitmap, then plain bytes indexing: reaching into the ctypes buffer. picture = bytes(self.scan_region.pixels) for index, row in enumerate(self.scan_rows): start = row * width * 4 low, high = self.scan_low[index], self.scan_high[index] for column, x in enumerate(self.scan_columns): base = start + x * 4 value = picture[base] + picture[base + 1] + picture[base + 2] if value < low[column]: low[column] = value if value > high[column]: high[column] = value left = self.scan_until - time.monotonic() if left > 0: self.lane_note.config(text=f"looking for the lanes… {left:.1f}s to go", fg=ACCENT) self.after(1, self._scan) else: self._report_find() def _report_find(self): self._stop_watching() self.scan_region.close() keys = self.scan_keys left, top, _width, _height = self.band columns = [left + x for x in self.scan_columns] rows = [top + y for y in self.scan_rows] movements = [[high - low for low, high in zip(low_row, high_row)] for low_row, high_row in zip(self.scan_low, self.scan_high)] self._log(f"{self.scans} looks at {len(rows)} rows") best = piano.pick_row(rows, columns, movements, want=len(keys)) if best is None: # Nothing worked; show the busiest row so there is something to read. busiest = max(range(len(rows)), key=lambda i: sum(movements[i])) found = piano.find_lanes(columns, movements[busiest], want=len(keys)) self._log(f"no row showed all {len(keys)} lanes at once. The busiest was " f"y={rows[busiest]}, where it found {len(found)}:") self._log(" " + piano.profile(columns, movements[busiest])) self._log(" " + piano.marks(columns, found)) self._log("Try again during a busier part of the song, or press Snapshot " "and look at where the lanes really are.") return row, found = best self._log(f"lowest row where all {len(keys)} lanes are visible: y={row}") self._log(" " + piano.profile(columns, movements[rows.index(row)])) self._log(" " + piano.marks(columns, found)) for lane in found: self._log(f" {lane['left']}–{lane['right']} centre {lane['centre']} " f"moved {lane['score']}") self.piano["lanes"] = [{"key": key, "at": [lane["centre"], row], "empty": None, "note": None} for key, lane in zip(keys, found)] config.save(piano.NAME, self.piano) self._rebuild_region() self._ready_shown = None self._log("lanes: " + ", ".join( f"{lane['key']}@{lane['at'][0]}" for lane in self.piano["lanes"])) self._log("now learning their colours —") self.after(700, self._sample_empty) def _trace(self): """Watch, but report instead of learning.""" if not self._lanes(): self._log("no lanes yet") return if getattr(self, "watching", False): return self.collect_then = self._report_trace self.watch_for = self.TRACING screen.focus_roblox() self._log(f"tracing the lanes for {self.TRACING:.0f}s — play as you would, " f"or let the macro play") self._count(3, self._watch_now) def _report_trace(self): self._stop_watching() threshold = int(self.piano["detect"]["threshold"]) scans = len(self.seen[0]) if self.seen else 0 each = self.watch_for / scans if scans else 0 self._log(f"traced {scans} scans, {each * 1000:.1f}ms apart") for lane, colours in zip(self._lanes(), self.seen): runs = piano.timeline(colours, tuple(lane.get("empty") or ()), tuple(lane.get("note_seen") or ()), threshold) share = {} for what, count, _middle in runs: share[what] = share.get(what, 0) + count summary = " ".join(f"{what} {count / max(scans, 1):.0%}" for what, count in sorted(share.items())) self._log(f" {lane['key']}: {summary}") for what, count, middle in runs[:8]: self._log(f" {what:<10} {count * each:5.2f}s " f"{middle if what != 'track' else ''}") if len(runs) > 8: self._log(f" … and {len(runs) - 8} more") def _sample_empty(self): """Watch a few seconds of the song, and work the colours out from it.""" if not self._lanes(): self._log("no lanes yet — pick both ends of the hit line first") return if getattr(self, "watching", False): return self.collect_then = self._learn self.watch_for = self.WATCHING screen.focus_roblox() self._log(f"watching the track for {self.WATCHING:.0f}s — let the song play") self._count(3, self._watch_now) def _watch_now(self): if not screen.roblox_focused(): self._log("Roblox is not the front window — that would have watched this " "window instead of the game. Click Roblox, then press it again.") return self._rebuild_region() self.watching = True self.btn_watch.config(text="watching…", state="disabled") self.seen = [[] for _ in self._lanes()] self.watch_until = time.monotonic() + self.watch_for self._collect() def _collect(self): lanes = self._lanes() if not screen.roblox_focused(): self._stop_watching() self._log("Roblox went behind — nothing learnt. Try again with the game up.") return for _ in range(4): if self.lane_region.read(): for index, lane in enumerate(lanes): self.seen[index].append(self.lane_region.at(*lane["at"])) left = self.watch_until - time.monotonic() if left > 0: self.lane_note.config(text=f"reading the lanes… {left:.1f}s to go", fg=ACCENT) self.after(5, self._collect) else: self.collect_then() def _stop_watching(self): self.watching = False self.btn_watch.config(text="Watch the track", state="normal") self.lane_note.config(text=f"{len(self._lanes())} lanes on the hit line", fg=DIM) def _learn(self): self._stop_watching() lanes = self._lanes() scans = len(self.seen[0]) if self.seen else 0 found = piano.study(self.seen) self._log(f"watched {scans} scans of each lane") for lane, learnt in zip(lanes, found["lanes"]): if learnt["suspect"] or not learnt["empty"]: self._log(f" {lane['key']}: under something {learnt['covered']:.0%} of " f"the time — cannot tell track from note here, left alone") continue lane["empty"] = list(learnt["empty"]) lane["note_seen"] = list(learnt["note"]) if learnt["note"] else None if lane.get("note") and lane["note_seen"]: lane["note"] = list(lane["note_seen"]) note = f"note {tuple(learnt['note'])} at {learnt['distance']} away" \ if learnt["note"] else "no notes crossed it" self._log(f" {lane['key']}: track {tuple(learnt['empty'])}, {note}" f"{' (turned back round)' if learnt['repaired'] else ''}, " f"covered {learnt['covered']:.0%}") if found["threshold"]: self.piano["detect"]["threshold"] = found["threshold"] self.tune["threshold"].set(str(found["threshold"])) self._log(f"threshold {found['threshold']} — noise reaches " f"{found['noise']}, the faintest note is {found['nearest']} away") elif any(lane["suspect"] for lane in found["lanes"]): self._log("watch it again over a stretch with gaps in it, or set the " "threshold by hand from the \u0394 numbers") else: self._log("no notes crossed the line while watching — the track colours are " "saved, but watch again during the song to set a threshold") config.save(piano.NAME, self.piano) self._ready_shown = None self.lane_note.config(text=f"{len(lanes)} lanes on the hit line", fg=DIM) def _rebuild_region(self): if self.lane_region is not None: self.lane_region.close() self.lane_region = None lanes = self._lanes() if lanes: self.lane_region = screen.around([lane["at"] for lane in lanes]) self.lane_cells = [] self.lane_picks = [] for child in self.lane_strip.winfo_children(): child.destroy() for index, lane in enumerate(lanes): cell = tk.Frame(self.lane_strip, bg=FIELD, highlightthickness=1, highlightbackground=LINE) cell.pack(side="left", expand=True, fill="both", padx=2) key = label(cell, (lane.get("key") or "click").upper(), font=UI_BOLD, bg=FIELD, anchor="center") key.pack(fill="x", pady=(6, 2)) swatch = tk.Frame(cell, bg=PANEL, height=16) swatch.pack(fill="x", padx=6) delta = label(cell, "\u0394 —", font=MONO, fg=DIM, bg=FIELD, anchor="center") delta.pack(fill="x", pady=(3, 4)) pick = button(cell, "pick", lambda i=index: self._arm_pick(f"lane:{i}"), kind="ghost") pick.plain = "pick" pick.config(bg=FIELD, padx=6, pady=2, font=UI_SMALL) pick.pack(pady=(0, 7)) self.lane_picks.append(pick) self.lane_cells.append((cell, key, swatch, delta)) self.lane_note.config( text=(f"{len(lanes)} lanes on the hit line" if lanes else "no lanes yet — type the keys, then pick both ends")) def _in_the_way(self, points) -> bool: """Is this window sitting over the lanes, with the game behind it?""" if screen.roblox_focused(): return False left, top = self.winfo_rootx(), self.winfo_rooty() right, bottom = left + self.winfo_width(), top + self.winfo_height() return any(left <= x <= right and top <= y <= bottom for x, y in points) def _show_lanes(self): lanes = self._lanes() if len(lanes) != len(self.lane_cells): self._rebuild_region() return if not lanes: return live = bool(self.task and self.task.is_alive() and self.playing == "piano") stats = self.runner.stats if (live and self.runner) else {} blocked = not live and self._in_the_way([lane["at"] for lane in lanes]) self.lane_note.config( text="this window is covering the lanes — move it off them, or read these " "with the game in front" if blocked else f"{len(lanes)} lanes on the hit line", fg=WARN if blocked else DIM) colours = [] if not (live or blocked) and self.lane_region is not None and self.lane_region.read(): colours = [self.lane_region.at(*lane["at"]) for lane in lanes] for index, lane in enumerate(lanes): cell, key, swatch, delta = self.lane_cells[index] empty = lane.get("empty") or () if colours: colour = colours[index] gap = (max(abs(int(a) - int(b)) for a, b in zip(colour, empty)) if colour and empty else None) lit = gap is not None and gap > int(self.piano["detect"]["threshold"]) swatch.config(bg=_hex(colour)) delta.config(text=f"\u0394 {gap}" if gap is not None else "\u0394 —", fg=ACCENT if lit else DIM) elif blocked: lit = False swatch.config(bg=PANEL) delta.config(text="\u0394 ?", fg=WARN) else: lit = bool(stats.get("lit", [False] * len(lanes))[index]) if stats else False hits = (stats.get("hits") or [0] * len(lanes))[index] swatch.config(bg=ACCENT if lit else PANEL) delta.config(text=str(hits), fg=TEXT if lit else DIM) cell.config(highlightbackground=ACCENT if lit else LINE) key.config(fg=ACCENT if lit else TEXT) def _save_tuning(self): detect = self.piano["detect"] for name, variable in self.tune.items(): try: detect[name] = type(piano.DEFAULTS["detect"][name])(float(variable.get())) except ValueError: self._log(f"{name}: {variable.get()!r} is not a number") variable.set(str(detect[name])) detect["hold_notes"] = bool(self.hold_notes.get()) config.save(piano.NAME, self.piano) def _refresh_points(self): for key, (where, swatch, _pick) in self.point_rows.items(): point = self.cfg["points"].get(key) if point and point.get("at"): x, y = point["at"] where.config(text=f"{x}, {y}", fg=TEXT) swatch.config(bg=_hex(point.get("color")), highlightthickness=1) else: where.config(text="not set", fg=WARN) swatch.config(bg=PANEL, highlightthickness=0) self._ready_shown = None def _watch_keys(self): """The capture key, polled fast enough to catch an ordinary tap.""" for pressed in self.watch.poll(): if pressed == "take": if self.pick: self._capture_point() else: key = self.cfg["hotkeys"].get("capture", "f6").upper() self._log(f"{key} pressed, but nothing is waiting for it — " f"press Pick on the point you want first") elif pressed == "skip" and self.pick: self._finish_pick(captured=False) self.after(15, self._watch_keys) def _rewatch(self): self.watch = Hotkeys(catch_taps=True, take=self.cfg["hotkeys"].get("capture", "f6"), skip=self.cfg["hotkeys"].get("stop", "f8")) def _arm_pick(self, key): self.pick = key capture = self.cfg["hotkeys"].get("capture", "f6").upper() skip = self.cfg["hotkeys"].get("stop", "f8").upper() self.pick_note.config( text=f"picking {key} — hover it in Roblox and press [{capture}] " f"[{skip}] to cancel", fg=ACCENT) for name, (_w, _s, pick) in self.point_rows.items(): pick.config(text="Pick…" if name == key else "Pick") for end, pick in self.end_picks.items(): pick.config(text="waiting…" if key == f"lane:{end}" else pick.plain) for index, pick in enumerate(self.lane_picks): pick.config(text="…" if key == f"lane:{index}" else pick.plain) for name, pick in self.event_picks.items(): pick.config(text="…" if key == name else pick.plain) if key.startswith("event:"): self.event_note.config( text=f"hover it in Roblox and press [{capture}] [{skip}] to cancel", fg=ACCENT) if key.startswith("lane:"): self.lane_note.config( text=f"hover the {key.split(':')[1]} lane where the note is when you " f"would press it, then {capture}", fg=ACCENT) self._log(f"pick {key}: hover the target and press {capture}, " f"or use the 3 second countdown") def _pick_after_countdown(self): """The same capture, for anyone whose keyboard is busy in the game.""" if not self.pick: self._log("press Pick on a point first, then this") return screen.focus_roblox() self._log("hold the mouse where you want it —") self._count(3, lambda: self.pick and self._capture_point()) def _finish_pick(self, captured=True): key, self.pick = self.pick, None for _name, (_w, _s, pick) in self.point_rows.items(): pick.config(text="Pick") for _end, pick in self.end_picks.items(): pick.config(text=pick.plain) for pick in self.lane_picks: pick.config(text=pick.plain) for pick in self.event_picks.values(): pick.config(text=pick.plain) self.event_note.config(text="") self.pick_note.config(text="Pick a point below, hover it in Roblox, " "then press the capture key.", fg=DIM) if not captured: self._log(f"{key}: cancelled") def _capture_point(self): key = self.pick x, y = winput.cursor_pos() colour = screen.pixel(x, y) sound.tick() if not screen.roblox_focused(): self._log("careful — Roblox is not the front window, so that colour is " "whatever is drawn on top of it") if key.startswith("event:"): self._capture_event(key.split(":", 1)[1], x, y, colour) return if key.startswith("lane:") and key.split(":", 1)[1].isdigit(): index = int(key.split(":", 1)[1]) lanes = self._lanes() if index < len(lanes): lanes[index]["at"] = [x, y] # A colour learnt for a pixel means nothing once the pixel moves. lanes[index]["empty"] = None lanes[index]["note"] = None config.save(piano.NAME, self.piano) self._log(f"lane {lanes[index]['key']}: {x},{y} rgb{colour} — " f"watch the track again to relearn its colours") self._finish_pick() self._rebuild_region() return if key.startswith("lane:"): end = key.split(":", 1)[1] self.ends[end] = (x, y) self._log(f"{end} end of the hit line: {x},{y} rgb{colour}") self._finish_pick() if "left" in self.ends and "right" in self.ends: self._lay_out_lanes() return self.cfg["points"][key] = {"at": [x, y], "color": list(colour or ())} config.save(MACRO.NAME, self.cfg) self._log(f"{key}: {x},{y} rgb{colour}") self._finish_pick() self._refresh_points() def _clear_point(self, key): self.cfg["points"][key] = None config.save(MACRO.NAME, self.cfg) self._log(f"{key}: cleared") self._refresh_points() def _tolerance(self) -> int: try: return max(0, min(255, int(float(self.tolerance.get())))) except ValueError: return int(self.cfg.get("tolerance", 26)) def _save_tolerance(self): value = self._tolerance() self.tolerance.set(str(value)) if value != self.cfg.get("tolerance"): self.cfg["tolerance"] = value config.save(MACRO.NAME, self.cfg) def _nudge_tolerance(self, by): self.tolerance.set(str(max(0, min(255, self._tolerance() + by)))) self._save_tolerance() def _test_probe(self): point = self.cfg["points"].get("end_probe") if not point or not point.get("at"): self._log("no end_probe set — pick one first") return x, y = point["at"] now = screen.pixel(x, y) hit = screen.close_enough(now, point["color"], self._tolerance()) self._log(f"end_probe at {x},{y}: saved rgb{tuple(point['color'])}, " f"now rgb{now} — {'MATCH' if hit else 'no match'}") # -- settings ----------------------------------------------------- def _page_settings(self, page): wrap = tk.Frame(page, bg=BG) wrap.pack(fill="both", expand=True) self.fields = {} for title, rows in SETTINGS: body = card(wrap, title) for path, name, hint in rows: row = tk.Frame(body, bg=PANEL) row.pack(fill="x", pady=3) label(row, name, font=UI, width=14).pack(side="left") default = _get(MACRO.DEFAULTS, path) if isinstance(default, bool): variable = tk.BooleanVar() tk.Checkbutton(row, variable=variable, bg=PANEL, fg=ACCENT, activebackground=PANEL, activeforeground=ACCENT, selectcolor=FIELD, relief="flat", bd=0, highlightthickness=0).pack(side="left") else: variable = tk.StringVar() entry(row, variable, width=9).pack(side="left") label(row, hint, font=UI_SMALL, fg=FAINT, wraplength=420).pack( side="left", padx=12) self.fields[path] = variable row = tk.Frame(wrap, bg=BG) row.pack(fill="x") button(row, "Save settings", self._save_fields, kind="accent").pack(side="left") button(row, "Reload", self._load_fields, kind="ghost").pack(side="left", padx=8) button(row, "Open config", lambda: os.startfile(config.CONFIG_DIR), kind="ghost").pack(side="left") label(row, str(config.config_path(MACRO.NAME)), font=UI_SMALL, fg=FAINT, bg=BG).pack(side="left", padx=12) def _load_fields(self): for path, variable in self.fields.items(): value = _get(self.cfg, path) if isinstance(variable, tk.BooleanVar): variable.set(bool(value)) else: variable.set("" if value is None else str(value)) self._hotkey_hint() def _save_fields(self): problems = [] for path, variable in self.fields.items(): default = _get(MACRO.DEFAULTS, path) raw = variable.get() if isinstance(default, bool): _set(self.cfg, path, bool(raw)) elif isinstance(default, (int, float)): try: _set(self.cfg, path, type(default)(float(raw))) except ValueError: problems.append(f"{path}: {raw!r} is not a number") else: text = str(raw).strip().lower() if path.startswith("hotkeys.") or path == "antiafk.key": if text not in winput.KEYS: problems.append(f"{path}: {text!r} is not a key this can press") continue _set(self.cfg, path, text) for problem in problems: self._log(problem) config.save(MACRO.NAME, self.cfg) self._load_fields() self._log("settings saved" + (" (with the problems above left alone)" if problems else "")) def _hotkey_hint(self): keys = self.cfg["hotkeys"] self.hotkey_hint.config( text=f"[{keys.get('pause', 'f7').upper()}] start / pause\n" f"[{keys.get('stop', 'f8').upper()}] stop\n" f"[{keys.get('capture', 'f6').upper()}] capture a point") # ------------------------------------------------------------ tasks def _busy(self) -> bool: if self.task and self.task.is_alive(): self._log("something is already running — stop it first") return True return False def _spawn(self, kind, target, runner=None, signals=None): self.kind = kind self.runner = runner self.signals = signals self.recording = False def wrapped(): try: target() except Exception: traceback.print_exc() self.task = threading.Thread(target=wrapped, name=f"kittymacro-{kind}", daemon=True) self.task.start() self.btn_stop.config(state="normal") def _go(self): """START, RESUME or PAUSE, depending on what is happening.""" if self.task and self.task.is_alive(): self._toggle() return if self.countdown is not None: return self._start_run(self.mode.value) DOING = { "loop": "playing every song that comes up, until you stop it", "once": "one song, then stop", "session": "the whole event — in through the menus, then every song at " "every difficulty, picking up where it left off", } def _start_run(self, mode="loop"): if self._busy(): return module = self._module() self.cfg = config.load(MACRO.NAME, MACRO.DEFAULTS) self.piano = piano.migrate(config.load(piano.NAME, piano.DEFAULTS)) cfg = self.piano if module is piano else self.cfg signals = Signals() title = dict((n, t) for n, t, _m in GAMES)[self.playing] runner = Runner(title, cfg["hotkeys"], signals=signals) if module is piano: self._spawn("run", lambda: piano.run(mode=mode, runner=runner), runner=runner, signals=signals) self._log(self.DOING[mode]) else: self._spawn("run", lambda: module.run(once=mode == "once", runner=runner), runner=runner, signals=signals) self._log("one match, then stop" if mode == "once" else "looping matches until you stop it") self._launch(signals, "pause") def _play(self): if self._busy(): return name = self._selected_routine() if not name: self._log("pick a routine in the list first") return try: steps = config.load_routine(name) speed = float(self.play_speed.get()) except (FileNotFoundError, ValueError) as error: self._log(str(error)) return signals = Signals() runner = Runner(f"routine {name}", self.cfg["hotkeys"], signals=signals) def once(): routine.play(runner, steps, speed) runner.stopped = True self._spawn("play", lambda: runner.loop(once), runner=runner, signals=signals) self._log(f"playing {name!r} — {len(steps)} steps at {speed}x") self._launch(signals, "pause") def _record(self): if self.task and self.task.is_alive() and self.kind == "record": self._toggle() return if self._busy() or self.countdown is not None: return name = self.record_name.get().strip() if not name: self._log("give the routine a name first") return signals = Signals() result: list = [] def capture(): steps = routine.record(name, self.cfg["hotkeys"], extra=signals) result.append(steps) if steps: path = config.save_routine(name, steps) print(f"saved {len(steps)} steps to {path}") self._spawn("record", capture, signals=signals) self._launch(signals, "toggle") def _launch(self, signals, press): """Give Roblox the window, count down, then press start for real.""" screen.focus_roblox() self._count(3, lambda: signals.send(press)) def _count(self, remaining, then): if remaining <= 0: self.countdown = None then() return self.countdown = remaining self._log(f" {remaining}…") self.after(1000, lambda: self._count(remaining - 1, then)) def _toggle(self): if not self.signals: return self.signals.send("toggle" if self.kind == "record" else "pause") def _stop(self): if not (self.task and self.task.is_alive()): return self.signals.send("quit" if self.kind == "record" else "stop") def _task_ended(self): kind = self.kind self.task = self.kind = self.runner = self.signals = None self.recording = False self.btn_stop.config(state="disabled") self.btn_record.config(text="Record") self.record_note.config(text="") if kind == "record": self._refresh_routines() winput.release_all() # ------------------------------------------------------------- tick def _tick(self): self._drain() now = time.monotonic() if now - self._roblox_at > 1.0: self._roblox_at = now self.roblox = screen.find_roblox() self._refresh_ready() focused = screen.roblox_focused() self._paint_pills(focused) if self.page == "calibrate": x, y = winput.cursor_pos() colour = screen.pixel(x, y) self.cursor_at.config(text=f"{x}, {y}") self.cursor_swatch.config(bg=_hex(colour)) self.cursor_rgb.config(text=f"rgb{colour} {_hex(colour)}" if colour else "unreadable") if self.page == "piano": self._show_lanes() if self.task and not self.task.is_alive(): self._task_ended() self.after(120, self._tick) def _paint_pills(self, focused): live = bool(self.task and self.task.is_alive()) if self.roblox is None: self.pill_roblox.dot.config(fg=BAD) self.pill_roblox.text.config(text="Roblox not open") elif focused: self.pill_roblox.dot.config(fg=GOOD) self.pill_roblox.text.config(text="Roblox in front") else: self.pill_roblox.dot.config(fg=WARN) self.pill_roblox.text.config(text="Roblox behind") if self.countdown is not None: state, colour, note = f"starting in {self.countdown}", ACCENT, "give Roblox the window" elif not live: state, colour, note = "idle", DIM, "press START, then let it have the window" elif self.kind == "record": if self.recording: state, colour, note = "recording", ACCENT, "play your round; press Finish when done" else: state, colour, note = "armed", WARN, "press Record again, or the pause key, to begin" elif self.runner and self.runner.stopped: state, colour, note = "stopping", WARN, "letting go of everything" elif self.runner and self.runner.paused: state, colour, note = "paused", WARN, "nothing is being sent" elif not focused: state, colour, note = "waiting", WARN, "Roblox is not the front window" else: state, colour, note = "running", GOOD, "hands off the mouse" self.pill_state.dot.config(fg=colour) self.pill_state.text.config(text=state, fg=colour) self.state_big.config(text=state, fg=colour if state != "idle" else TEXT) self.state_note.config(text=note) if not live: go = "START" elif self.kind == "record": go = "FINISH" if self.recording else "BEGIN" else: go = "PAUSE" if state == "running" else "RESUME" self.btn_go.config(text=go) if self.kind == "record": self.btn_record.config(text="Finish" if self.recording else "Begin") self.record_note.config(text=note, fg=colour) stats = self.runner.stats if self.runner else {} started = stats.get("started") minutes = (time.monotonic() - started) / 60 if started else 0 if self.playing == "piano": lanes = self._lanes() notes = stats.get("notes") total = stats.get("total") cells = [(str(notes) if notes is not None else "—", "notes played"), ("—" if not started else f"{minutes:.1f} min", "elapsed"), ((f"{stats.get('runs', 0)}/{total}", "runs done") if total else (str(len(lanes)) if lanes else "—", "lanes")), (f"{stats['rate']:.0f}/s" if stats.get("rate") else "—", "scan rate")] summary = "" if notes is None else ( f"{stats.get('runs', 0)}/{total} runs · {notes} notes" if total else f"{notes} notes · {stats.get('rate', 0):.0f}/s") else: matches = stats.get("matches") cells = [("—" if matches is None else str(matches), "matches"), ("—" if not started else f"{minutes:.1f} min", "elapsed"), (self.cfg["routines"].get("build") or "—", "build routine"), (self.cfg["routines"].get("loop") or "—", "loop routine")] summary = "" if matches is None else f"{matches} matches · {minutes:.0f} min" for key, (value, caption) in zip("abcd", cells): widget, title = self.stat_widgets[key] widget.config(text=value) title.config(text=caption) self.pill_stats.config(text=summary) # -------------------------------------------------------------- log def _log(self, message): print(f"[{time.strftime('%H:%M:%S')}] {message}", flush=True) def _drain(self): lines = [] while True: try: lines.append(self.logs.get_nowait()) except queue.Empty: break if not lines: return self.log.config(state="normal") for line in lines: self.log.insert("end", line + "\n", self._tag(line)) # A day of farming is a lot of lines; the last thousand is plenty. if int(self.log.index("end-1c").split(".")[0]) > 1000: self.log.delete("1.0", "300.0") self.log.config(state="disabled") if self.follow.get(): self.log.see("end") for line in lines: if line.endswith("recording…"): self.recording = True elif "finished —" in line or "discarded." in line: self.recording = False @staticmethod def _tag(line): low = line.lower() if any(word in low for word in ("refused", "traceback", "error", "not a")): return "bad" if any(word in low for word in ("not found", "not open", "no ", "paused", "waiting", "stopped", "cancelled", "timeout")): return "warn" if any(word in low for word in ("running", "recording", "captured", "saved", "accepted", "match", "deleted", "found")): return "good" if line.startswith(" "): return "dim" return "plain" def _clear_log(self): self.log.config(state="normal") self.log.delete("1.0", "end") self.log.config(state="disabled") # ------------------------------------------------------------ close def _close(self): if self.task and self.task.is_alive(): self._stop() self.task.join(timeout=2.0) winput.release_all() if self.lane_region is not None: self.lane_region.close() sys.stdout = getattr(sys.stdout, "stream", sys.stdout) sys.stderr = getattr(sys.stderr, "stream", sys.stderr) self.destroy() def main(): if sys.platform != "win32": print("KittyMacro drives Windows input directly — it only runs on Windows.") return 2 App().mainloop() return 0 if __name__ == "__main__": raise SystemExit(main())