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

2482 lines
111 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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("<Enter>", lambda _e: widget["state"] == "normal" and widget.config(bg=hover))
widget.bind("<Leave>", 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("<Configure>", fitted)
canvas.bind("<Configure>",
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("<Enter>", lambda _e: canvas.bind_all(
"<MouseWheel>", lambda event: canvas.yview_scroll(-event.delta // 120, "units")))
canvas.bind("<Leave>", lambda _e: canvas.unbind_all("<MouseWheel>"))
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("<Return>", lambda _e: self._save_tolerance())
box.bind("<FocusOut>", 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("<Return>", lambda _e: self._save_tuning())
box.bind("<FocusOut>", 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("<Return>", lambda _event: save())
box.bind("<FocusOut>", 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())