commit bc09d705163b0e843041947a1f90e61475c4027f Author: capyb2222 Date: Sat Sep 5 12:22:28 2026 -0700 feat(init): KittyMacro Roblox macros that play from outside the game: Anime Vanguards, and a falling-notes piano event. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c696ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +config/ +TOMORROW.md diff --git a/KittyMacro.bat b/KittyMacro.bat new file mode 100644 index 0000000..4d506e5 --- /dev/null +++ b/KittyMacro.bat @@ -0,0 +1,6 @@ +@echo off +rem KittyMacro - double-click to open the window. +rem pythonw so no console sits behind it; python.exe if that is all there is. +cd /d "%~dp0" +where pythonw >nul 2>nul +if %errorlevel%==0 (start "" pythonw "%~dp0gui.py" %*) else (python "%~dp0gui.py" %*) diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed2f18c --- /dev/null +++ b/README.md @@ -0,0 +1,466 @@ +# KittyMacro + +Macros for **Anime Vanguards** and for **falling-notes events** — outside the +game, like Salmon. Nothing is injected and nothing is executed inside Roblox: it +moves the real mouse, presses real keys, and reads the screen the way you do. +From the game's side there is nothing to find, because there is nothing there — +a keyboard and a mouse doing what a keyboard and a mouse do. + +Windows only. Python 3.9+. Nothing to install — it is the standard library and +`ctypes`. + +## The window + +```powershell +python macro.py gui # or double-click KittyMacro.bat +``` + +Six pages, and a log along the bottom that tells you what the macro is doing +while Roblox is sitting in front of it: + +| | | +|---|---| +| **Home** | whether this is going to work — Roblox open, build order recorded, the two points that matter calibrated — and the button that starts it | +| **Routines** | what you have recorded, which of them the macro uses, and the button that records another | +| **Calibrate** | the live cursor readout, and the five points, each showing where it is and the colour it expects to find there | +| **Piano** | the lanes of a falling-notes event, and what each one is reading right now | +| **Event** | the way into the event, when a song counts as over, and the forty runs | +| **Settings** | every number in the config file, with the line that says why you would touch it | + +**Playing** at the top of the sidebar picks which macro START runs. + +Every button in it sends the press the F-key sends, so the two can never +disagree about what is running: START is F7, Stop is F8, Pick waits for F6. + +That matters more than it sounds, because the window is behind Roblox exactly +when you are using it. Start a run or a recording from the window and it brings +Roblox forward and counts down three seconds first — otherwise the click that +focused the game would be the first step of your build order. + +The rest of this page is the command line, which does all of the same things. + +## Why it can actually drive Roblox + +Every "send keys to the window" recipe fails here, because Roblox ignores +`PostMessage` and `SendMessage`: a window message that never came up through +the input stack is not input as far as it is concerned. This uses **SendInput**, +which goes in at the same place the keyboard driver puts a keystroke, so Roblox +cannot tell the difference. Two details past that matter, and both are handled: + +- **scan codes, not virtual keys.** A real keyboard puts a scan code on the + wire; injectors that send only a virtual-key code are what games learn to + distrust. `winput.py` sends the scan code with `wVk` left at zero. +- **relative mouse moves for the camera.** The camera reads raw deltas, not + where the cursor is, so `SetCursorPos` turns nothing. Clicking a button is + the opposite — that *is* cursor position — so clicks move the cursor and + press, and camera work is a relative move. + +`python macro.py check --live` proves it end to end: it focuses Roblox and makes +your character jump. + +## Five minutes to a working farm + +```powershell +cd C:\Users\bachk\roblox\KittyMacro + +python macro.py check --live # does input land in Roblox? (jumps twice) +python macro.py record build # F7, play one round by hand, F7 +python macro.py play build # watch it do that round back +python macro.py calibrate # hover each button, F6 to capture +python macro.py run # farm — F7 to start, F7 pause, F8 stop +``` + +### 1. Record your build order + +`record build` watches the physical mouse and keyboard. Press **F7**, then play +one round exactly as you would: pick a unit, click the tile, upgrade, all of it. +Press **F7** again to finish. Every click and key press is saved with the gap +before it, so the timing of your own run is the timing of the macro's. + +The recording is plain JSON in `config/routines/build.json`. Editing it by hand +is expected — nudge a coordinate, stretch a wait, delete a misclick. + +Two routines, if you want them: + +| Routine | When it runs | What belongs in it | +|---|---|---| +| `build` | once, at the start of a match | placing units, opening upgrades | +| `loop` | over and over until the match ends | upgrade clicks, ability keys | + +Record the second one as its own short routine and point `routines.loop` at it +in `config/animevanguards.json`. + +### 2. Calibrate the buttons around the match + +`calibrate` asks for up to five screen points. Hover each one and press **F6**, +or **F8** to skip anything your setup does not have: + +| Point | What to hover | +|---|---| +| `close` | the X on whatever popup greets you. Skip if none | +| `start` | the button that starts a match | +| `end_probe` | **a pixel that is only that colour when the results screen is up** | +| `replay` | Replay / Play Again on the results screen | +| `confirm` | a second confirmation click, if there is one | + +`end_probe` is the one that matters. Put it on something loud — the coloured +header of the rewards panel, a button's fill — never on grey background that +looks the same mid-match. Get it right and the loop runs for hours; leave it +unset and the macro falls back to a fixed timer, which cuts off slow runs and +wastes the tail of fast ones. + +Capture the results-screen points at the end of an actual run, while the panel +is on screen. `python macro.py mouse` prints the cursor position and the colour +under it live, if you would rather read them off and type them into the config. + +### 3. Run it + +```powershell +python macro.py run # loop matches until you stop it +python macro.py run --once # one match, then stop +``` + +It starts **paused**. Focus Roblox, press **F7**, and it goes. + +## Playing a falling-notes event + +The other macro plays the piano events — notes falling down a few lanes towards +a line, and you press that lane's key as one crosses it. Pick **Piano** in the +sidebar, then open the Piano tab. + +1. **Type the keys**, one per lane, left to right: `asdfg` for a five-key + event, or whatever yours uses. Any number of lanes works. +2. **Pick any one point on the track** — anywhere on it, roughly. It only needs + to know which part of the screen to search. +3. **Press Find the lanes.** It watches a band of the screen around that point + for five seconds and asks which columns changed. A lane changes as notes go + through it; a divider, a key and the background do not. It tries dozens of + rows and keeps the **lowest one where every lane is visible at once** — + lowest because that is nearest the line you are hitting, and every lane + because a keyboard hides some lanes behind its taller keys well before it + hides the rest. Then it learns their colours and picks a threshold without + being asked. +4. **Press F7.** You are already in the game. + +It shows you what it saw, which is worth reading: + +``` +lowest row where all 5 lanes are visible: y=818 + ████ ████ ████ ████ ████ + ^ ^ ^ ^ ^ + 670–730 centre 700 moved 175 + ... + a: track (6, 19, 43), note (36, 57, 150) at 107 away, covered 25% +threshold 54 — noise reaches 0, the faintest note is 107 away +``` + +Five clusters for five keys, all reporting a similar track colour, is what +right looks like. Five different track colours — whites and greys among them — +means the points are on dividers or keys rather than on the lanes. + +If you would rather place them yourself, pick both ends and the lanes in +between are spaced out evenly, or use the **pick** button under each lane to +place one at a time. Put them **on the track where the notes fall**, above any +key or pad that lights up when pressed — see the troubleshooting below for why +that one matters more than it sounds. + +The Δ numbers on the Piano page are for checking its work afterwards, or for +setting the threshold by hand. **Trace 5s** prints what each lane actually +showed, run by run — track, note, or something else — which is how you find out +why a lane misbehaves instead of guessing. + +### What it does with that + +One `BitBlt` of the strip across every lane, about 180 times a second, and each +lane's pixel compared to its empty track. Reading the lanes one at a time with +`GetPixel` would cost five milliseconds each — forty for eight lanes, longer +than the game takes to draw two frames, with the eight answers coming from +eight different moments. For something falling that is not a detail, it is the +whole problem. + +The key goes **down when the lane stops looking empty and up when it looks empty +again**. That taps the short notes and holds the long ones without either being +a special case. + +### When it plays badly + +**Hits early** — raise `offset` (milliseconds between seeing a note and pressing +it). This is the usual one: the top edge of a note reaches the sampler before +its middle does. + +**Hits late** — move the sampling points a little further up the lane by +re-picking the two ends. Offset cannot go backwards in time. + +**Misses notes in fast runs** — lower `gap`. It is the shortest allowed spacing +between two notes in one lane, and it is there to stop a flicker counting twice. + +**Some lanes work and others do not** — the sampling points are not all on the +lanes. This is the common one, and **Find the lanes** exists to end it: it puts +every point where the picture actually moves, so no lane can end up on a +divider. The tell, if you placed them by hand, is five lanes reporting five +different track colours after a watch. + +**Put the points on the falling-note track, not on the keys.** A key or pad that +lights up when pressed is the macro watching its own hand: it presses, the pad +lights, the pad never goes back to looking empty, and the key is never let go. +Somewhere up the lane where notes are still travelling has none of that +problem. + +**A hold note never lets go** — the lane is not going back to looking like +track. Press **Trace 5s** while it plays: if the lane shows a third colour that +is neither track nor note, that is what it is stuck on. Move the point up the +lane, or turn **Match note** on to go by the note colour instead. `Max hold` is +the backstop — it lets go after that many milliseconds whatever the pixel says, +so one confused lane cannot sit on a key for the rest of the song. + +**One lane never fires, and the log said it was "covered 60% of the time"** — +that lane spent most of the watch under a note, so there was no majority to +learn from. Watch again over a stretch of the song with gaps in it. If the +other lanes are fine, it usually fixes itself: a lane whose note colour is +everyone else's background gets turned back round automatically. + +**Fires with nothing there** — raise `threshold` first. If the lane background +is animated rather than flat, matching a note colour works better than measuring +distance from an empty one: put the note's colour in `config/piano.json` as +`"note": [r, g, b]` on that lane and it will look for that instead. + +**Holds when it should tap** — turn `hold notes` off, and every note becomes a +tap of `min hold` milliseconds. + +**A long note stutters, or comes out short** — a hold is let go `offset` after +the note leaves the sampler, the same delay its press got, so the length is +right rather than short by an offset. `Let go` is the patience on top of that: +a seam, a beat marker or a sparkle crossing the sampling point reads as the +note ending, and letting go for one scan and pressing again breaks the hold. +Anything that comes back inside that window never happened. Raise it if holds +still stutter; lower it if the note *after* a hold gets swallowed. + +**The lanes are not evenly spaced** — edit the `at` values in +`config/piano.json` directly. Two ends and even spacing is the quick path, not +the only one. A lane with `"do": "click"` clicks its own point instead of +pressing a key, for events played with the mouse. + +## Farming the whole event + +Playing one song well is half of it. The other half is that nobody wants to sit +there clicking Play forty times. The **Event** page is that other half, and +every part of it is a point you pick once — same as the lanes, for the same +reason: your resolution, your UI scale, your menus. + +### When is a song over? + +Three signals. The best of them is the one the game already draws for you. + +**The song timer stopped counting.** If there is a clock on screen, point at +it, and the question is settled: numbers count while a song runs and stop when +it ends. Nothing to match, no threshold, no colour to reason about — the macro +only asks whether the pixels changed. **Watch it** on the Event page says +whether it is ticking right now, which is the whole check. Where a timer is +picked it is the only signal consulted, because a quiet stretch of chart stops +the track moving but does not stop the numbers counting. + +The other two are for events without a clock. + +**The track stops changing.** Notes falling is the only thing moving on that +strip, so when it goes still for a few seconds the song is done. The trap this +replaced was asking whether the lanes looked like *empty track*: when a song +ends, the panel that comes up is not empty track either, so "nothing looks like +the background" stayed true forever and every lane sat on its key. What +actually stops at the end of a song is change, so that is what is measured, and +a track that has held still for a second gets its keys let go of straight away +rather than waiting for the verdict. + +**A pixel that only the results screen paints.** Pick one on the Event page — a +coloured header or button on the panel, not grey background — and a song ends +the moment that panel appears. Optional, and fiddlier than the timer. + +Neither counts until the song has actually **started**, which is the other half +of the same idea: a menu, a loading screen and the song picker are all +perfectly still, so a macro armed before the music would otherwise decide the +song was over before it began — and then stop. A song has started when the hit +line has moved a dozen times inside three seconds; one cursor going past does +not do that, and a chart cannot help doing it. `not before` is counted from +there, so arming the macro and then taking your time in the menus is fine. + +### The way in + +A route is a list of steps, each one a point you picked: + +``` +Events → Special Events → scroll to the bottom → the Piano event → Play +``` + +Pick a step, press **Walk it**, watch where it lands, pick the next. A step +with nothing picked is skipped and said so in the log, so a half-built route +still walks as far as it goes — which is how you build one. + +**Every click in a route is checked.** Roblox drops clicks — one that lands +while the UI is still animating never happened as far as the game is concerned, +and then the rest of the route clicks into empty space and the run is wasted. +So each one takes a coarse picture of the screen after the cursor arrives and +another after the button comes up, and presses again if nothing moved. Three +goes, then it carries on and says so. + +It waits for the reaction rather than glancing once: a button that opens a menu +changes the screen immediately, but one that loads something takes a moment, +and deciding *there* that the click was dropped is worse than not checking at +all — the clicks that follow land wherever the game has got to by then. + +How much movement counts is measured, not assumed. Two pictures before touching +anything say what the screen does on its own — almost nothing in a menu, a good +deal in a lobby full of other people's characters — and the click has to beat +that by a distance. Where the screen is so busy that no click could be told +apart from it, it says so and clicks once. A retry also takes the cursor off +the button and back before pressing, because some UI wants a fresh hover and +ignores everything else. +The clicks that cycle a difficulty are exempt — the change they make is far too +small to see, and one counted twice would pick the wrong difficulty. `+ click`, +`+ scroll`, `+ key` and `+ wait` add steps if your menus need a different +shape, `✕` drops one, and the seconds after each step are yours to set. + +The second route, **back to the list**, is how it gets off the results screen +and onto the songs again. Both are also walkable from the command line: + +```powershell +python macro.py walk into +python macro.py walk back +``` + +### Forty runs + +Ten songs at four difficulties. **One point per row of the song list** — pick +them, `+ song` for another, or pick the first and the last and press **Space +them out** to fill the middle evenly. Your own click on a row beats arithmetic, +so picked rows are never overwritten. + +Then the **difficulties**, which are a point and a number of clicks each. That +covers both shapes these events come in: + +| | clicks | +|---|---| +| four separate buttons | one click each, on four different points | +| one button that cycles easy → medium → hard → expert | 0, 1, 2, 3 clicks on the same point | +| ditto, but the panel remembers where you left it | 0, 1, 1, 1 | + +**One button that cycles** sets the middle row of that table up in one press. +Pick **Play** as well if the song panel has its own. + +If all ten do not fit on screen, say how many do and it winds the list back to +the top and down by whole screenfuls — always from the top, because a list +already scrolled to somewhere unknown cannot be moved by a relative amount and +land anywhere knowable. + +Last, **where a finished song leaves you**. Some events drop you back on the +song list; others throw you out of the event altogether, and then the way in +has to be walked again before every single run. That is slower and it is simply +what some events do, so it is a choice on the page rather than an assumption. + +Then **Full session** on the Home page, and START: + +``` +song 3 hard — run 11 of 40 + song 3 at 587,288 + hard: 2 clicks at 1315,700 +song 3 hard is under way +song 3 hard: the results screen came up after 2.4 min — 486 notes, 181 scans a second + back to the list — + 1. leave the results screen +``` + +The tally lives in `config/piano_progress.json`, written after every run, so +stopping for dinner, closing the window or a crash all resume at song 7 expert +rather than song 1 easy. **Forget them** on the Event page starts the grid +over. Runs that ended on the clock rather than on an ending are listed at the +end, because those are the ones worth replaying by hand. + +The same thing from the command line: + +```powershell +python macro.py run --game piano --session +``` + +**Song after song** is the lighter version: it plays whatever is in front of +it, notices the song ending, waits for the next one to start, and plays that +too. No picking required, and you drive the menus. + +## Keys + +| Key | | +|---|---| +| `F7` | start / pause | +| `F8` | stop (and, while recording, discard) | +| `F6` | capture the point under the cursor, during `calibrate` | + +They are polled globally, so they work while Roblox has focus. Change them in +`config/animevanguards.json` under `hotkeys` if a game binds them. + +## It never types into the wrong window + +The loop only sends input while the Roblox window is in front. Alt-tab away and +it lets go of every key and button it was holding and waits; alt-tab back and it +carries on. Stopping does the same. That is the one rule the whole thing is +built around — a macro that keeps clicking into your browser is worse than none. + +## Settings + +`config/animevanguards.json`, written on first run: + +| | | +|---|---| +| `routines.build` / `.loop` | which recordings to use | +| `routines.loop_every` | seconds between replays of the loop routine | +| `routines.speed` | scales every recorded gap. Above 1.0 outruns your income | +| `match.start_delay` | after pressing start, before the build order | +| `match.max_seconds` | give up on a match that never ends | +| `match.load_seconds` | after replay, before the next match starts | +| `tolerance` | per-channel colour slack on `end_probe`, 0–255 | +| `antiafk` | a jump every few minutes so Roblox does not idle you out | + +## When it does not work + +**`sendinput REFUSED`** — Roblox is running as administrator and this is not. +Windows blocks input from a lower integrity level. Run the macro elevated too. + +**Clicks land in the wrong place** — the coordinates are absolute screen pixels, +so they are tied to the resolution, the window position and the UI scale you +recorded at. Change any of those and you re-record. Run Roblox windowed or in +its own borderless fullscreen and leave the window where it is. + +**`end_probe` never fires** — the colour under that pixel is not what it was. +Re-capture it with the results screen up, or raise `tolerance`. If pixel reads +come back black, something is drawing over the desktop; a game in exclusive +fullscreen cannot be read this way (Roblox does not use it, but overlays can). + +**`roblox NOT FOUND`** — it matches the window titled `Roblox`. Roblox Studio is +deliberately excluded. + +**Nothing happens at all and no error** — the macro is paused, or Roblox is not +the front window. Both say so, in the console and in the window's header. + +**`No module named tkinter`** — the window needs Tk, which comes with the +installer from python.org and is left out of some slimmed-down builds. The +command line does not need it. + +## Layout + +``` +macro.py the command line +gui.py the window +KittyMacro.bat double-click, and the window opens +kittymacro/winput.py SendInput: keys, clicks, relative mouse +kittymacro/screen.py one-pixel reads, whole-strip reads, and + finding the Roblox window +kittymacro/hotkeys.py F6/F7/F8 while the game has focus — and the + window's buttons, arriving the same way +kittymacro/runner.py pause, stop, focus gate, release-everything +kittymacro/routine.py recording and playback +kittymacro/config.py settings and routines on disk +kittymacro/macros/ one module per game + animevanguards.py run a stage, place units, take the reward, again + piano.py read the hit line, press what crosses it +config/ your settings and recordings + piano_progress.json which of the forty runs are done +``` + +Adding another game is one module in `macros/` with `DEFAULTS`, `calibrate()` +and `run()` — everything above it is game-agnostic. diff --git a/gui.py b/gui.py new file mode 100644 index 0000000..49dd213 --- /dev/null +++ b/gui.py @@ -0,0 +1,2481 @@ +#!/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()) diff --git a/kittymacro/__init__.py b/kittymacro/__init__.py new file mode 100644 index 0000000..db38b93 --- /dev/null +++ b/kittymacro/__init__.py @@ -0,0 +1,3 @@ +"""KittyMacro - an outside-the-game macro for Roblox.""" + +__version__ = "1.0.0" diff --git a/kittymacro/config.py b/kittymacro/config.py new file mode 100644 index 0000000..d952dde --- /dev/null +++ b/kittymacro/config.py @@ -0,0 +1,69 @@ +"""Config and recorded routines on disk.""" + +from __future__ import annotations + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +CONFIG_DIR = ROOT / "config" +ROUTINE_DIR = CONFIG_DIR / "routines" + + +def _merge(defaults, saved): + """Saved values win; keys only in defaults are filled in.""" + if not isinstance(defaults, dict) or not isinstance(saved, dict): + return saved if saved is not None else defaults + out = dict(defaults) + for key, value in saved.items(): + out[key] = _merge(defaults.get(key), value) if key in defaults else value + return out + + +def config_path(macro: str) -> Path: + return CONFIG_DIR / f"{macro}.json" + + +def load(macro: str, defaults: dict) -> dict: + path = config_path(macro) + if not path.exists(): + return json.loads(json.dumps(defaults)) + with path.open(encoding="utf-8") as handle: + return _merge(defaults, json.load(handle)) + + +def save(macro: str, data: dict) -> Path: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + path = config_path(macro) + with path.open("w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2) + handle.write("\n") + return path + + +def routine_path(name: str) -> Path: + return ROUTINE_DIR / f"{name}.json" + + +def load_routine(name: str) -> list[dict]: + path = routine_path(name) + if not path.exists(): + raise FileNotFoundError(f"no routine called {name!r} — record one first") + with path.open(encoding="utf-8") as handle: + data = json.load(handle) + return data.get("steps", []) if isinstance(data, dict) else data + + +def save_routine(name: str, steps: list[dict], note: str = "") -> Path: + ROUTINE_DIR.mkdir(parents=True, exist_ok=True) + path = routine_path(name) + with path.open("w", encoding="utf-8") as handle: + json.dump({"note": note, "steps": steps}, handle, indent=2) + handle.write("\n") + return path + + +def list_routines() -> list[str]: + if not ROUTINE_DIR.exists(): + return [] + return sorted(path.stem for path in ROUTINE_DIR.glob("*.json")) diff --git a/kittymacro/hotkeys.py b/kittymacro/hotkeys.py new file mode 100644 index 0000000..2a5f450 --- /dev/null +++ b/kittymacro/hotkeys.py @@ -0,0 +1,52 @@ +"""Hotkeys that work while Roblox has focus.""" + +from __future__ import annotations + +import threading + +from .winput import vk_of, user32 + + +class Hotkeys: + """Edge-triggered key state, for whoever is polling.""" + + def __init__(self, catch_taps: bool = False, **binds: str): + self._vk = {name: vk_of(key) for name, key in binds.items()} + self._names = dict(binds) + self._was = {name: True for name in binds} # ignore a key already held + self._catch_taps = catch_taps + + def describe(self) -> str: + return " ".join(f"[{key.upper()}] {name}" for name, key in self._names.items()) + + def pressed(self, name: str) -> bool: + """True once per physical press.""" + vk = self._vk[name] + state = user32.GetAsyncKeyState(vk) + now = bool(state & 0x8000) + # The low bit is Windows saying the key went down at some point since the last look. + tapped = self._catch_taps and bool(state & 0x0001) + fired = (now and not self._was[name]) or tapped + self._was[name] = now + return fired + + def poll(self) -> list[str]: + """Every binding that went down since the last call.""" + return [name for name in self._vk if self.pressed(name)] + + +class Signals: + """The same presses, arriving from something that is not a keyboard.""" + + def __init__(self): + self._pending: list[str] = [] + self._lock = threading.Lock() + + def send(self, name: str) -> None: + with self._lock: + self._pending.append(name) + + def poll(self) -> list[str]: + with self._lock: + pending, self._pending = self._pending, [] + return pending diff --git a/kittymacro/macros/__init__.py b/kittymacro/macros/__init__.py new file mode 100644 index 0000000..a1fe6f3 --- /dev/null +++ b/kittymacro/macros/__init__.py @@ -0,0 +1 @@ +"""Game macros. One module each; add another next to animevanguards.""" diff --git a/kittymacro/macros/animevanguards.py b/kittymacro/macros/animevanguards.py new file mode 100644 index 0000000..83c403a --- /dev/null +++ b/kittymacro/macros/animevanguards.py @@ -0,0 +1,200 @@ +"""Anime Vanguards - run a stage, place your units, take the reward, again.""" + +from __future__ import annotations + +import time + +from .. import config, routine, screen, winput +from ..runner import Runner + +NAME = "animevanguards" + +DEFAULTS = { + "hotkeys": {"pause": "f7", "stop": "f8", "capture": "f6"}, + "routines": { + # Recorded with `record`. + "build": "build", + "loop": None, + "loop_every": 6.0, + "speed": 1.0, + }, + "match": { + "start_delay": 4.0, # after pressing start, before the build order + "max_seconds": 900.0, # give up on a match that never ends + "check_every": 0.75, # how often to look at the results probe + "load_seconds": 12.0, # after replay, before the next match begins + "between": 1.0, + }, + "points": { + # Each is {"at": [x, y], "color": [r, g, b]} or null. + "close": None, # X on a popup, clicked before each match + "start": None, # the button that starts / replays a match + "end_probe": None, # a pixel that is only this colour on results + "replay": None, # results-screen replay button + "confirm": None, # a second click, if replay asks to confirm + }, + "tolerance": 26, # per-channel colour slack on the probe + "antiafk": {"enabled": True, "period": 240.0, "key": "space"}, +} + +POINTS = [ + ("close", "Popup close button (the X). Skip if nothing pops up."), + ("start", "The button that STARTS a match from the lobby or results screen."), + ("end_probe", "A pixel that is only this colour when the RESULTS screen is up " + "— a coloured header or button on the panel, not grey background."), + ("replay", "The REPLAY / PLAY AGAIN button on the results screen."), + ("confirm", "A confirmation click after replay, if there is one. Skip if not."), +] + + +# ------------------------------------------------------------- calibrate +def calibrate(cfg: dict) -> dict: + keys = cfg["hotkeys"] + print("\ncalibrating Anime Vanguards") + print("Put Roblox where you can see each thing before you capture it —") + print("the results-screen points are easiest to capture at the end of a run.") + for key, prompt in POINTS: + current = cfg["points"].get(key) + if current: + print(f"\n{key}: currently {current['at']} colour {current['color']}") + captured = routine.capture_point(f"{key} — {prompt}", keys) + if captured: + cfg["points"][key] = captured + return cfg + + +# ------------------------------------------------------------------ bits +def _click_point(point: dict | None, runner: Runner, label: str) -> bool: + if not point or not point.get("at"): + return False + x, y = point["at"] + runner.log(f"click {label} at {x},{y}") + winput.click(x, y) + return True + + +def _probe_matches(point: dict | None, tolerance: int) -> bool: + if not point or not point.get("at") or not point.get("color"): + return False + x, y = point["at"] + return screen.close_enough(screen.pixel(x, y), point["color"], tolerance) + + +class AnimeVanguards: + def __init__(self, cfg: dict, runner: Runner): + self.cfg = cfg + self.runner = runner + self.matches = 0 + self.started = time.monotonic() + + names = cfg["routines"] + self.build = self._routine(names.get("build"), runner) + self.loop = self._routine(names.get("loop"), runner) + self.speed = float(names.get("speed", 1.0) or 1.0) + self._afk_at = time.monotonic() + runner.stats.update(matches=0, started=self.started) + + @staticmethod + def _routine(name, runner: Runner) -> list[dict]: + if not name: + return [] + try: + return config.load_routine(name) + except FileNotFoundError: + runner.log(f"no routine called {name!r} — carrying on without it " + f"(record one with: python macro.py record {name})") + return [] + + # ------------------------------------------------------------ pieces + def antiafk(self) -> None: + settings = self.cfg["antiafk"] + if not settings.get("enabled"): + return + if time.monotonic() - self._afk_at < float(settings.get("period", 240)): + return + self._afk_at = time.monotonic() + # A jump, not a mouse jiggle: Roblox's idle timer watches for input the game itself. + winput.tap(settings.get("key", "space")) + + def wait_for_end(self) -> str: + """Wait out the match, upgrading if there is a loop routine.""" + match = self.cfg["match"] + probe = self.cfg["points"].get("end_probe") + tolerance = int(self.cfg.get("tolerance", 26)) + deadline = time.monotonic() + float(match.get("max_seconds", 900)) + next_loop = time.monotonic() + + if not probe: + self.runner.log("no end_probe calibrated — waiting on the timer instead") + + while time.monotonic() < deadline: + self.runner.wait(float(match.get("check_every", 0.75))) + self.antiafk() + + if _probe_matches(probe, tolerance): + # Twice, a beat apart: a matching pixel can be a coincidence of an effect passing over. + self.runner.wait(0.5) + if _probe_matches(probe, tolerance): + return "results" + + if self.loop and time.monotonic() >= next_loop: + next_loop = time.monotonic() + float(self.cfg["routines"].get("loop_every", 6)) + routine.play(self.runner, self.loop, self.speed) + + return "timeout" + + # -------------------------------------------------------------- match + def one_match(self) -> None: + cfg = self.cfg + points = cfg["points"] + match = cfg["match"] + + if _click_point(points.get("close"), self.runner, "close"): + self.runner.wait(0.6) + + if _click_point(points.get("start"), self.runner, "start"): + self.runner.wait(float(match.get("start_delay", 4))) + else: + self.runner.log("no start point calibrated — assuming the match is already running") + + if self.build: + self.runner.log(f"placing — {len(self.build)} recorded steps") + routine.play(self.runner, self.build, self.speed) + else: + self.runner.log("no build routine recorded — nothing to place") + + how = self.wait_for_end() + self.matches += 1 + self.runner.stats["matches"] = self.matches + minutes = (time.monotonic() - self.started) / 60 + self.runner.log(f"match {self.matches} ended ({how}) — {minutes:.1f} min elapsed") + + if _click_point(points.get("replay"), self.runner, "replay"): + self.runner.wait(1.0) + if _click_point(points.get("confirm"), self.runner, "confirm"): + self.runner.wait(1.0) + + self.runner.wait(float(match.get("load_seconds", 12))) + self.runner.wait(float(match.get("between", 1))) + + +def run(once: bool = False, runner: Runner | None = None) -> None: + """Farm until stopped.""" + cfg = config.load(NAME, DEFAULTS) + # Written back on every run, so the file always exists to edit and gains any setting. + config.save(NAME, cfg) + runner = runner or Runner("Anime Vanguards", cfg["hotkeys"]) + macro = AnimeVanguards(cfg, runner) + + if once: + runner.log("single match — press pause to begin") + + def tick(): + macro.one_match() + runner.stopped = True + else: + def tick(): + macro.one_match() + + runner.loop(tick) + runner.log(f"{macro.matches} matches this session.") diff --git a/kittymacro/macros/piano.py b/kittymacro/macros/piano.py new file mode 100644 index 0000000..82ed5aa --- /dev/null +++ b/kittymacro/macros/piano.py @@ -0,0 +1,1192 @@ +"""A falling-notes event - the lane lights up, the key goes down.""" + +from __future__ import annotations + +import time +from collections import Counter, deque + +from .. import config, screen, winput +from ..runner import Runner + +NAME = "piano" + +DEFAULTS = { + "hotkeys": {"pause": "f7", "stop": "f8", "capture": "f6"}, + # One per lane, left to right: its key, its point, and the colours it learnt. + "lanes": [], + "detect": { + "threshold": 45, # how far from empty counts as a note, per channel + "offset_ms": 0, # wait this long after seeing one before pressing + "min_hold_ms": 35, # a tap still has to be long enough to register + "gap_ms": 45, # ignore a second note in a lane sooner than this + "let_go_ms": 12, # a held key waits this long for the note to come back + "hold_notes": True, # keep the key down while the note is still there + "max_hold_ms": 4000, # let go anyway after this, so a lane cannot jam + }, + "finish": { + # When a song counts as over. + "quiet_seconds": 4.0, # a track that stops changing for this long is done + "gone_seconds": 1.5, # no lane on its own track for this long: not the event + "track_tolerance": 24, # and how near its own colour a lane has to be + "min_seconds": 20.0, # never call it done sooner than this into a song + "max_seconds": 420.0, # and never sit through more than this + "movement": 8, # per-channel change that counts as the track moving + "clock": None, # {"at": [x, y]} on the song timer — see `Timer` + "probe": None, # {"at": [x, y], "color": [r, g, b]} on the results panel + "tolerance": 26, # colour slack on that pixel + "check_every": 0.4, # how often to look at it + }, + # Picked once each, then it drives itself in and back out again. + "routes": { + "into": [ + {"do": "click", "at": None, "after": 1.5, "note": "Events"}, + {"do": "click", "at": None, "after": 1.5, "note": "Special Events"}, + {"do": "scroll", "at": None, "amount": -12, "after": 1.0, + "note": "scroll the list to the bottom"}, + {"do": "click", "at": None, "after": 2.0, "note": "the Piano event"}, + {"do": "click", "at": None, "after": 4.0, "note": "Play"}, + ], + "back": [ + {"do": "click", "at": None, "after": 2.5, "note": "leave the results screen"}, + ], + }, + "session": { + # Ten songs at four difficulties: 40 runs, resumed where it left off. + "songs": { + # One [x, y] per row of the list. + "points": [], + "first": None, + "last": None, + "count": 0, # how many songs there are; 0 means "the rows picked" + "visible": 0, # how many fit on screen; 0 means all of them + "scroll_at": None, # where to put the cursor to scroll the list + "top_clicks": 15, # wheel clicks that get it back to the top + "page_clicks": -3, # and how many move it on one screenful + }, + # `clicks` is how many times to press this one's point. + "difficulties": [{"name": "easy", "at": None, "clicks": 0}, + {"name": "medium", "at": None, "clicks": 1}, + {"name": "hard", "at": None, "clicks": 2}, + {"name": "expert", "at": None, "clicks": 3}], + "play": None, # [x, y] on the button that starts the song + # Where a finished song leaves you: back on the song "list". + "after_song": "list", + "pick_seconds": 1.2, # after each menu click + "settle_seconds": 4.0, # after pressing play, before watching for notes + "between_seconds": 2.5, # a breath between runs + }, +} + + +# ------------------------------------------------------------------ lanes +def spread(keys: str, left, right) -> list[dict]: + """Lane points evenly spaced between the two ends of the hit line.""" + keys = list(keys) + if not keys: + return [] + return [{"key": key, "at": at, "empty": None} + for key, at in zip(keys, between(left, right, len(keys)))] + + +def between(first, last, count: int) -> list[list[int]]: + """`count` points evenly spaced from one to the other, both ends included.""" + count = max(1, int(count)) + if count == 1 or not last: + return [[int(first[0]), int(first[1])]] + return [[round(first[0] + (last[0] - first[0]) * index / (count - 1)), + round(first[1] + (last[1] - first[1]) * index / (count - 1))] + for index in range(count)] + + +def _median(values): + ordered = sorted(values) + return ordered[len(ordered) // 2] + + +def _apart(a, b) -> int: + """How far two colours are, on their furthest channel.""" + return max(abs(int(p) - int(q)) for p, q in zip(a, b)) + + +def study(watched: list[list[tuple]], noise_gate: int = 10) -> dict: + """Work out what each lane looks like empty, from a lane that is in use.""" + lanes = [] + for colours in watched: + colours = [colour for colour in colours if colour] + if not colours: + lanes.append({"empty": None, "note": None, "covered": 0.0, + "noise": 0, "distance": 0}) + continue + + # Quantised, so noise cannot split one background into a hundred near colours. + common = Counter((r >> 3, g >> 3, b >> 3) for r, g, b in colours) + bucket = common.most_common(1)[0][0] + empty_like = [c for c in colours if (c[0] >> 3, c[1] >> 3, c[2] >> 3) == bucket] + empty = tuple(_median([c[channel] for c in empty_like]) for channel in range(3)) + + deviations = [_apart(colour, empty) for colour in colours] + noise = max((d for d in deviations if d <= noise_gate), default=0) + notes = [colour for colour, drift in zip(colours, deviations) + if drift > max(noise_gate, noise * 2)] + note = (tuple(_median([c[channel] for c in notes]) for channel in range(3)) + if len(notes) >= max(3, len(colours) // 100) else None) + covered = len(notes) / len(colours) + lanes.append({ + "empty": empty, + "note": note, + "covered": covered, + "noise": noise, + "distance": _apart(note, empty) if note else 0, + # More than half of a watch under a note means the majority colour was the note. + "suspect": covered > 0.45, + "repaired": False, + }) + + _turn_suspects_around(lanes) + seen = [lane for lane in lanes if lane["note"] and not lane["suspect"]] + noise = max((lane["noise"] for lane in lanes), default=0) + nearest = min((lane["distance"] for lane in seen), default=0) + separable = bool(seen) and nearest > noise * 1.5 + 6 + return { + "lanes": lanes, + "noise": noise, + "nearest": nearest, + "separable": separable, + # Halfway between the worst noise and the faintest note. + "threshold": max(8, min(200, round((noise + nearest) / 2))) if separable else None, + } + + +def _turn_suspects_around(lanes: list[dict]) -> None: + """A lane read backwards, put right by what the other lanes agree on.""" + settled = [lane["empty"] for lane in lanes + if lane["empty"] and not lane["suspect"]] + if not settled: + return + for lane in lanes: + if not (lane["suspect"] and lane["note"]): + continue + agree = sum(1 for background in settled if _apart(lane["note"], background) <= 12) + if agree * 2 >= len(settled): + lane["empty"], lane["note"] = lane["note"], lane["empty"] + lane["covered"] = 1.0 - lane["covered"] + lane["distance"] = _apart(lane["note"], lane["empty"]) + lane["suspect"] = False + lane["repaired"] = True + + +def timeline(colours, empty, note, threshold: int, min_run: int = 2): + """What a lane did, as runs of track / note / something else.""" + runs = [] + for colour in colours: + if not colour: + what = "unreadable" + elif empty and screen.close_enough(colour, empty, threshold): + what = "track" + elif note and screen.close_enough(colour, note, threshold): + what = "note" + else: + what = "other" + if runs and runs[-1][0] == what: + runs[-1][1] += 1 + runs[-1][2].append(colour) + else: + runs.append([what, 1, [colour]]) + + kept = [] + for what, count, seen in runs: + if count < min_run and kept: # a single odd scan is noise, not a run + kept[-1][1] += count + continue + seen = [colour for colour in seen if colour] + middle = (tuple(_median([c[channel] for c in seen]) for channel in range(3)) + if seen else None) + kept.append([what, count, middle]) + return [(what, count, middle) for what, count, middle in kept] + + +def find_lanes(columns, movement, want: int = 0, gate: float = 0.35, + min_width: int = 6) -> list[dict]: + """Where along a row did the picture actually change?""" + if not movement: + return [] + top = max(movement) + if top <= 0: + return [] + step = (columns[1] - columns[0]) if len(columns) > 1 else 1 + floor = max(top * gate, 12) + + groups, current = [], None + for x, value in zip(columns, movement): + if value < floor: + continue + # A gap of a couple of samples is the same lane, not two of them. + if current is not None and x - current[-1][0] <= step * 3: + current.append((x, value)) + else: + if current: + groups.append(current) + current = [(x, value)] + if current: + groups.append(current) + + found = [] + for group in groups: + left, right = group[0][0], group[-1][0] + if right - left + step < min_width: + continue + found.append({ + "centre": (left + right) // 2, + "left": left, + "right": right, + "score": round(sum(value for _x, value in group) / len(group)), + }) + if want and len(found) > want: + found.sort(key=lambda lane: lane["score"], reverse=True) + found = found[:want] + found.sort(key=lambda lane: lane["centre"]) + return found + + +def pick_row(rows, columns, movements, want: int, gate: float = 0.35, + min_width: int = 6): + """Of every row watched, the lowest one that shows all the lanes at once.""" + qualifying = [] + for row, movement in zip(rows, movements): + found = find_lanes(columns, movement, want=want, gate=gate, min_width=min_width) + if len(found) != want: + continue + qualifying.append((row, found, min(lane["score"] for lane in found))) + if not qualifying: + return None + strongest = max(strength for _row, _found, strength in qualifying) + solid = [item for item in qualifying if item[2] >= strongest * 0.5] + row, found, _strength = max(solid, key=lambda item: item[0]) + return row, found + + +def profile(columns, movement, width: int = 96) -> str: + """The same numbers as a picture, because a picture is read at a glance.""" + if not columns: + return "" + span = max(columns[-1] - columns[0], 1) + buckets = [0] * width + for x, value in zip(columns, movement): + index = min(width - 1, (x - columns[0]) * width // (span + 1)) + buckets[index] = max(buckets[index], value) + top = max(buckets) or 1 + blocks = " \u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588" + return "".join(blocks[min(8, value * 8 // top)] for value in buckets) + + +def marks(columns, found, width: int = 96) -> str: + """A row of carets under the profile, one per lane it settled on.""" + if not columns: + return "" + span = max(columns[-1] - columns[0], 1) + row = [" "] * width + for lane in found: + index = min(width - 1, (lane["centre"] - columns[0]) * width // (span + 1)) + row[index] = "^" + return "".join(row) + + +class Lane: + """One lane's key, and whether it is currently down.""" + + def __init__(self, spec: dict): + self.key = spec.get("key") or "" + self.at = (int(spec["at"][0]), int(spec["at"][1])) + self.empty = tuple(spec.get("empty") or ()) + self.note = tuple(spec.get("note") or ()) + self.clicks = spec.get("do") == "click" + self.hits = 0 + self.stuck = 0 + self.lit = False + self.down = False + self.down_at = 0.0 + self.armed_at = None + self.free_at = None + # Long ago, not zero, so the first note is never "too soon after the last". + self.freed_at = float("-inf") + + @property + def ready(self) -> bool: + return bool(self.empty or self.note) + + def on_track(self, colour, tolerance: int) -> bool: + """Is this the empty track - the thing this lane is *usually* looking at?""" + return bool(colour and self.empty + and screen.close_enough(colour, self.empty, tolerance)) + + def looks_lit(self, colour, threshold: int) -> bool: + if not colour: + return False + if self.note: + return screen.close_enough(colour, self.note, threshold) + if self.empty: + return not screen.close_enough(colour, self.empty, threshold) + return False + + # ------------------------------------------------------------ the key + def press(self, now: float) -> None: + self.armed_at = None + self.down = True + self.down_at = now + self.hits += 1 + if self.clicks: + winput.click(*self.at) + else: + winput.key_down(self.key) + + def release(self, now: float) -> None: + self.down = False + self.free_at = None + self.freed_at = now + if not self.clicks: + winput.key_up(self.key) + + def forget(self) -> None: + """Whatever we thought we were holding, we are not holding it now.""" + self.down = False + self.armed_at = None + self.free_at = None + self.lit = False + + def rest(self, now: float) -> None: + """Nothing out there is moving - let go, and do not press again.""" + if self.down: + self.release(now) + self.armed_at = None + self.free_at = None + self.lit = False + + def update(self, colour, now: float, tuning: dict) -> None: + self.lit = self.looks_lit(colour, tuning["threshold"]) + + if self.down: + held = now - self.down_at + if self.lit and tuning["hold_notes"]: + self.free_at = None # still under the note + elif self.free_at is None: + # The note has left the sampler - which is above the line. + self.free_at = now + tuning["offset"] + tuning["let_go"] + if tuning["max_hold"] and held >= tuning["max_hold"]: + # The lane never went back to looking empty. + self.stuck += 1 + self.release(now) + elif (self.free_at is not None and now >= self.free_at + and held >= tuning["min_hold"]): + self.release(now) + return + + if self.armed_at is not None: + if now >= self.armed_at: + self.press(now) + return + + if self.lit and now - self.freed_at >= tuning["gap"]: + self.armed_at = now + tuning["offset"] + if tuning["offset"] <= 0: + self.press(now) + + +# -------------------------------------------------------------- calibrate +def calibrate(cfg: dict) -> dict: + """Set the lanes up from a terminal. The window does this better.""" + from .. import routine + + keys = cfg["hotkeys"] + print("\ncalibrating a falling-notes event") + print("The window (python macro.py gui) shows you what it is reading while") + print("you do this, which is worth a lot here. Carrying on anyway:\n") + + order = input(" the key for each lane, left to right, no spaces (e.g. asdfg): ").strip() + if not order: + print(" nothing to do without lanes.") + return cfg + + print("\nPut the sampling points ON the hit line — where you would press.") + left = routine.capture_point("the LEFTMOST lane, on the hit line", keys) + right = routine.capture_point("the RIGHTMOST lane, on the hit line", keys) + if not left or not right: + print(" need both ends to space the lanes out. Nothing changed.") + return cfg + + cfg["lanes"] = spread(order, left["at"], right["at"]) + for lane in cfg["lanes"]: + print(f" {lane['key']} at {lane['at']}") + + print("\nNow let the track run empty — no notes crossing the line.") + input(" press Enter when it is clear: ") + with screen.around([lane["at"] for lane in cfg["lanes"]]) as region: + region.read() + for lane in cfg["lanes"]: + colour = region.at(*lane["at"]) + lane["empty"] = list(colour or ()) + print(f" {lane['key']} empty track {colour}") + return cfg + + +# -------------------------------------------------------------- the end +def probe_matches(point: dict | None, tolerance: int) -> bool: + """Is the pixel that only the results screen paints showing it right now?""" + if not point or not point.get("at") or not point.get("color"): + return False + x, y = point["at"] + return screen.close_enough(screen.pixel(x, y), point["color"], tolerance) + + +class Timer: + """The song's own clock, watched only for whether it is still counting.""" + + WIDE, TALL = 110, 34 # enough to cover a clock, small enough to be cheap + + def __init__(self, at, every: float = 0.2): + x, y = int(at[0]), int(at[1]) + self.region = screen.Region(x - self.WIDE // 2, y - self.TALL // 2, + self.WIDE, self.TALL) + self.every = float(every) + self.was = None + self.looked_at = 0.0 + self.ticks = 0 + + def look(self, now: float) -> bool: + """Did the clock change since the last look?""" + if now - self.looked_at < self.every: + return False + self.looked_at = now + if not self.region.read(): + return False + picture = bytes(self.region.pixels) + changed = self.was is not None and picture != self.was + self.was = picture + self.ticks += bool(changed) + return changed + + def close(self) -> None: + self.region.close() + + +class Ending: + """Whether the song is over, and which of three signals says so.""" + + def __init__(self, settings: dict | None = None): + settings = settings or {} + self.quiet = float(settings.get("quiet_seconds", 4.0)) + self.gone = float(settings.get("gone_seconds", 1.5)) + self.track_tolerance = int(settings.get("track_tolerance", 24)) + self.least = float(settings.get("min_seconds", 20.0)) + self.most = float(settings.get("max_seconds", 420.0)) + self.movement = int(settings.get("movement", 8)) + self.probe = settings.get("probe") + self.clock = settings.get("clock") or {} + self.tolerance = int(settings.get("tolerance", 26)) + self.check_every = float(settings.get("check_every", 0.4)) + self.began = self.moved_at = self.looked_at = 0.0 + self.was = None + self.probe_since = None + # When the music actually started, and the recent movements that say so. + self.started = None + self.moves = deque() + # And the same question asked of the clock, if there is one. + self.clock_at = None + self.clock_ticks = 0 + # When a lane last looked at its own empty track, when half of them last did. + self.on_track_at = None + self.most_track_at = None + self.can_see_track = False + + # movements inside `WINDOW` seconds that mean a song is under way. + BUSY = 12 + WINDOW = 3.0 + + @property + def has_clock(self) -> bool: + return bool(self.clock.get("at")) + + @property + def has_probe(self) -> bool: + return bool(self.probe and self.probe.get("at") and self.probe.get("color")) + + def begin(self, now: float) -> None: + self.began = self.moved_at = self.looked_at = now + self.was = None + self.probe_since = None + self.started = None + self.moves.clear() + self.clock_at = None + self.clock_ticks = 0 + self.on_track_at = None + self.most_track_at = None + + def skipped(self, now: float, gap: float) -> None: + """Time the macro was not watching - paused, or behind another window.""" + self.began += gap + if self.started is not None: + self.started += gap + self.moved_at = self.looked_at = now + self.was = None + self.probe_since = None + self.moves.clear() + + def tracked(self, on: int, lanes: int, now: float) -> None: + """How many lanes are looking at their own empty track right now?""" + if on: + self.on_track_at = now + if lanes and on * 2 >= lanes: + self.most_track_at = now + + def on_the_event(self, now: float) -> bool: + """Is the track on screen right now?""" + if not self.can_see_track: + return True + return self.on_track_at is not None and now - self.on_track_at < self.gone + + def clocked(self, ticked: bool, now: float) -> None: + """The song timer, as of this look.""" + if not ticked: + return + self.clock_ticks += 1 + self.clock_at = now + if self.started is None and self.clock_ticks >= 2 and self.on_the_event(now): + self.started = now + + def saw(self, colours, now: float) -> None: + """The strip as of this scan.""" + if self.was is not None and len(self.was) == len(colours): + for before, after in zip(self.was, colours): + if before and after and _apart(before, after) > self.movement: + self.moved_at = now + self.moves.append(now) + break + self.was = list(colours) + while self.moves and now - self.moves[0] > self.WINDOW: + self.moves.popleft() + if self.started is None and len(self.moves) >= self.BUSY \ + and self.on_the_event(now): + self.started = self.moves[0] + + def still_for(self, now: float) -> float: + return now - self.moved_at + + def verdict(self, now: float) -> str | None: + """Why the song is over - or None, meaning it is not.""" + if self.started is None: + return None + if now - self.started >= self.most: + return "timeout" + if now - self.started < self.least: + return None + if self.has_probe and now - self.looked_at >= self.check_every: + self.looked_at = now + if probe_matches(self.probe, self.tolerance): + # Twice, a beat apart: one frame of an effect crossing that pixel is not a results screen. + if self.probe_since is None: + self.probe_since = now + elif now - self.probe_since >= 0.5: + return "results" + else: + self.probe_since = None + if self.on_track_at is not None and now - self.on_track_at >= self.gone: + return "gone" + if self.most_track_at is not None \ + and now - self.most_track_at >= self.gone * 3: + return "gone" + if self.has_clock and self.clock_at is not None \ + and now - self.clock_at >= self.quiet: + return "clock" + # A quiet stretch of chart stops the track moving without stopping the numbers counting. + patience = self.quiet * (3 if self.has_clock else 1) + if now - self.moved_at >= patience: + return "quiet" + return None + + +# --------------------------------------------------------------- routes +class Look: + """The screen, coarsely, for asking whether a click did anything.""" + + STEP = 8 + REACTED = 0.06 # the least a click has to move, however still it is + HOPELESS = 0.25 # and the point past which nothing can be told apart + + def __init__(self): + width, height = screen.screen_size() + self.region = screen.Region(0, 0, width, height) + self.stride = width * 4 + self.rows = range(0, height, self.STEP) + + def take(self): + if not self.region.read(): + return None + picture = bytes(self.region.pixels) + return b"".join(picture[row * self.stride:(row + 1) * self.stride:self.STEP * 4] + for row in self.rows) + + @staticmethod + def apart(before, after) -> float: + """How much of it changed, 0 to 1. An unreadable screen counts as all.""" + if not before or not after or len(before) != len(after): + return 1.0 + return sum(1 for a, b in zip(before, after) if a != b) / len(before) + + def close(self) -> None: + self.region.close() + + +def sure_click(runner: Runner, at, label: str = "", tries: int = 3, + settle: float = 0.15, patience: float = 1.6, + look: "Look | None" = None) -> bool: + """Click, and click again if the screen did not react.""" + x, y = int(at[0]), int(at[1]) + mine = look is None + look = look or Look() + try: + winput.move_to(x, y) + runner.wait(settle) + # What the screen does with nobody touching it. + steady = look.take() + runner.wait(0.4) # the same sort of span the click is judged over + steady, ambient = look.take(), look.apart(steady, look.take()) + if ambient > look.HOPELESS: + runner.log(f" {label or 'that click'}: this screen moves " + f"{ambient:.0%} on its own, so a click cannot be told apart " + f"— clicking once and hoping") + winput.mouse_down() + runner.wait(0.05) + winput.mouse_up() + runner.wait(settle) + return True + bar = max(look.REACTED, ambient * 2.5) + + for attempt in range(1, max(1, tries) + 1): + if attempt > 1: + # Off the button and back on, so the game gets a fresh hover before the press - some UI. + winput.move_to(x + 4, y + 4) + runner.wait(0.05) + winput.move_to(x, y) + runner.wait(0.08) + winput.mouse_down() + runner.wait(0.05) + winput.mouse_up() + waited, moved, after = 0.0, 0.0, steady + while waited < patience: + runner.wait(settle) + waited += settle + after = look.take() + moved = look.apart(steady, after) + if moved >= bar: + if attempt > 1: + runner.log(f" (that one needed {attempt} goes)") + return True + steady = after + runner.log(f" {label or 'that click'}: the screen moved {moved:.0%} after " + f"{tries} goes, against {bar:.0%} needed — carrying on anyway") + return False + finally: + if mine: + look.close() + + +def wheel(runner: Runner, clicks: int) -> None: + """Wheel clicks one at a time, because a list scrolled in one jump does not.""" + step = 1 if clicks > 0 else -1 + for _ in range(abs(int(clicks))): + winput.scroll(step) + runner.wait(0.03) + + +def walk(runner: Runner, steps: list[dict], label: str = "") -> int: + """Do a route: the picked steps, in order.""" + if label: + runner.log(label) + done = 0 + # A beat before the first step. + runner.wait(0.4) + look = Look() + for index, step in enumerate(steps or [], start=1): + kind = step.get("do", "click") + note = step.get("note") or kind + at = step.get("at") + if kind in ("click", "scroll") and not at: + runner.log(f" {index}. {note} — not picked yet, skipped") + continue + runner.gate() + if kind == "click": + if step.get("sure") is False or step.get("button", "left") != "left": + winput.click(int(at[0]), int(at[1]), button=step.get("button", "left")) + else: + sure_click(runner, at, note, look=look) + elif kind == "scroll": + winput.move_to(int(at[0]), int(at[1])) + runner.wait(0.12) + wheel(runner, int(step.get("amount", -5))) + elif kind == "key": + winput.tap(step.get("key", "e")) + elif kind != "wait": + runner.log(f" {index}. {note}: no idea what {kind!r} means, skipped") + continue + runner.log(f" {index}. {note}") + runner.wait(float(step.get("after", 0.8))) + done += 1 + look.close() + return done + + +def picked(steps: list[dict] | None) -> bool: + """Is there anything in this route worth walking?""" + return any(step.get("at") or step.get("do") in ("key", "wait") + for step in steps or []) + + +# ---------------------------------------------------------------- chart +def tuning_of(cfg: dict) -> dict: + """Milliseconds are what a person thinks in; seconds are what a loop uses.""" + detect = cfg["detect"] + return { + "threshold": int(detect.get("threshold", 45)), + "offset": float(detect.get("offset_ms", 0)) / 1000.0, + "min_hold": float(detect.get("min_hold_ms", 35)) / 1000.0, + "gap": float(detect.get("gap_ms", 45)) / 1000.0, + "let_go": float(detect.get("let_go_ms", 12)) / 1000.0, + "hold_notes": bool(detect.get("hold_notes", True)), + # Zero means never give up, which is a real answer for a game that genuinely holds a note. + "max_hold": float(detect.get("max_hold_ms", 2500)) / 1000.0, + } + + +class Chart: + """The lanes, the strip they live in, and one song at a time.""" + + REST_AFTER = 1.2 # a picture this still is one to let go of and watch + WATCH_EVERY = 8 # px between the columns watched for "is it moving?" + + def __init__(self, cfg: dict, runner: Runner): + self.cfg = cfg + self.runner = runner + self.lanes = [Lane(spec) for spec in cfg.get("lanes") or []] + self.tuning = tuning_of(cfg) + self.ending = Ending(cfg.get("finish")) + self.region = screen.around([lane.at for lane in self.lanes]) if self.lanes else None + # A row of the strip watched only for movement. + if self.region is not None: + self.watch_row = self.region.top + self.region.height // 2 + self.watch_at = list(range(self.region.left, + self.region.left + self.region.width, + self.WATCH_EVERY)) + clock = (cfg.get("finish") or {}).get("clock") or {} + self.timer = Timer(clock["at"]) if clock.get("at") else None + self.ending.can_see_track = any(lane.empty for lane in self.lanes) + self.songs = 0 + self.notes = 0 + self.scans = 0 + self.last = {"notes": 0, "seconds": 0.0, "why": "none"} + + def close(self) -> None: + if self.region is not None: + self.region.close() + self.region = None + if self.timer is not None: + self.timer.close() + self.timer = None + + def look(self): + """One read: a colour per lane, and a row across the whole strip.""" + if not self.region.read(): + return None, None + return ([self.region.at(*lane.at) for lane in self.lanes], + [self.region.at(x, self.watch_row) for x in self.watch_at]) + + # ----------------------------------------------------------- playing + def play(self, label: str = "") -> str: + """Play until the song ends.""" + runner = self.runner + for lane in self.lanes: + lane.hits = lane.stuck = 0 + lane.forget() + + started = time.perf_counter() + self.ending.begin(started) + last_scan = started + told = started + scans = 0 + waiting = True + why = "stopped" + runner.stats.update(lit=[False] * len(self.lanes), hits=[0] * len(self.lanes), + song=label or "a song") + try: + while True: + runner.gate() + colours, strip = self.look() + if colours is None: + runner.wait(0.05) + continue + + now = time.perf_counter() + if now - last_scan > 0.25: + # We were paused or behind another window, and the runner has already dropped every key. + for lane in self.lanes: + lane.forget() + self.ending.skipped(now, now - last_scan) + last_scan = now + scans += 1 + self.scans += 1 + + near = self.ending.track_tolerance + self.ending.tracked( + sum(1 for lane, colour in zip(self.lanes, colours) + if lane.on_track(colour, near)), len(self.lanes), now) + if self.timer is not None: + self.ending.clocked(self.timer.look(now), now) + self.ending.saw(strip, now) + if self.ending.started is None: + runner.status(f"waiting for {label or 'a song'} to start") + elif waiting: + waiting = False + runner.log(f"{label or 'a song'} is under way") + # Nothing is pressed at a picture that has stopped. + resting = (self.ending.still_for(now) >= self.REST_AFTER + or not self.ending.on_the_event(now)) + for lane, colour in zip(self.lanes, colours): + if resting: + lane.rest(now) + else: + lane.update(colour, now, self.tuning) + + if now - told >= 0.5: + elapsed = now - started + runner.stats.update( + notes=self.notes + sum(lane.hits for lane in self.lanes), + scans=self.scans, rate=scans / elapsed if elapsed else 0.0, + lit=[lane.lit for lane in self.lanes], + hits=[lane.hits for lane in self.lanes]) + told = now + + verdict = self.ending.verdict(now) + if verdict: + why = verdict + break + finally: + for lane in self.lanes: + lane.rest(time.perf_counter()) + winput.release_all() + + played = sum(lane.hits for lane in self.lanes) + elapsed = time.perf_counter() - (self.ending.started or started) + self.notes += played + self.songs += 1 + self.last = {"notes": played, "seconds": elapsed, "why": why} + runner.stats.update(notes=self.notes) + runner.log(f"{label or 'song'}: {SAID.get(why, why)} after " + f"{elapsed / 60:.1f} min — {played} notes, " + f"{scans / elapsed if elapsed else 0:.0f} scans a second") + for lane in self.lanes: + if lane.stuck: + runner.log(f" {lane.key}: {lane.stuck} let go on the time limit — " + f"that lane is not seeing its track come back") + return why + + def report(self) -> None: + runner = self.runner + runner.log(f"{self.songs} songs, {self.notes} notes, {self.scans} scans") + for lane in self.lanes: + runner.log(f" {lane.key or 'click'}: {lane.hits} in the last song") + + +SAID = { + "gone": "the track went off screen", + "clock": "the timer stopped counting", + "results": "the results screen came up", + "quiet": "the track went still", + "timeout": "gave up on the clock", + "stopped": "stopped", +} + + +# -------------------------------------------------------------- session +PROGRESS = "piano_progress" +PROGRESS_DEFAULTS = {"done": {}, "runs": 0, "notes": 0} + + +def load_progress() -> dict: + return config.load(PROGRESS, PROGRESS_DEFAULTS) + + +def save_progress(data: dict) -> None: + config.save(PROGRESS, data) + + +def reset_progress() -> None: + save_progress({"done": {}, "runs": 0, "notes": 0}) + + +def song_rows(songs: dict) -> list: + """Every row of one screenful of the song list, top to bottom.""" + points = [list(point) for point in (songs.get("points") or []) if point] + if points: + return points + if songs.get("first"): + count = max(1, int(songs.get("visible") or 0) or int(songs.get("count") or 1)) + return between(songs.get("first"), songs.get("last"), count) + return [] + + +def song_count(songs: dict) -> int: + """How many songs there are: the rows picked, unless the list scrolls.""" + rows = song_rows(songs) + if not int(songs.get("visible") or 0): + return len(rows) + return max(len(rows), int(songs.get("count") or 0)) + + +def song_point(songs: dict, index: int): + """Where song `index` is, and which screenful of the list it is on.""" + rows = song_rows(songs) + if not rows: + return None, 0 + visible = int(songs.get("visible") or 0) or len(rows) + visible = max(1, min(visible, len(rows))) + return rows[index % visible], index // visible + + +class Session: + """Ten songs at four difficulties, and the memory to pick up where it left off.""" + + def __init__(self, cfg: dict, runner: Runner, chart: "Chart"): + self.cfg = cfg + self.runner = runner + self.settings = cfg.get("session") or {} + self.routes = cfg.get("routes") or {} + self.chart = chart + self.progress = load_progress() + self.announced = False + # Some events drop you back on the song list when a song ends and some throw you out. + self.exits = (self.settings.get("after_song") or "list") == "out" + self.needs_way_in = True + self.played = 0 + + # ------------------------------------------------------------- plan + def jobs(self) -> list[tuple]: + """Every run there is to do, song by song, easiest difficulty first.""" + songs = song_count(self.settings.get("songs") or {}) + levels = self.settings.get("difficulties") or [{"name": "as it is", "at": None}] + return [(song, index, level) + for song in range(songs) for index, level in enumerate(levels)] + + @staticmethod + def key_of(song: int, index: int) -> str: + # By position, not by name: renaming "expert" should not lose a night's progress. + return f"{song}:{index}" + + def pending(self) -> list[tuple]: + done = self.progress.get("done") or {} + return [job for job in self.jobs() if self.key_of(job[0], job[1]) not in done] + + @staticmethod + def name_of(song: int, index: int, level: dict) -> str: + return f"song {song + 1} {level.get('name') or f'difficulty {index + 1}'}" + + # ------------------------------------------------------------ menus + CYCLE_GAP = 0.35 # between clicks on a difficulty button that cycles + + def choose(self, song: int, index: int, level: dict) -> bool: + """Click the song, set the difficulty, press play. False if it cannot.""" + songs = self.settings.get("songs") or {} + point, page = song_point(songs, song) + if point is None: + self.runner.log("no song rows picked — Event tab, pick one point per row " + "of the song list") + return False + + pause = float(self.settings.get("pick_seconds", 1.2)) + if page: + self.scroll_to(page, songs) + self.runner.log(f" song {song + 1} at {point[0]},{point[1]}") + sure_click(self.runner, point, f"song {song + 1}") + self.runner.wait(pause) + + name = level.get("name") or f"difficulty {index + 1}" + at = level.get("at") + clicks = 1 if level.get("clicks") is None else int(level["clicks"]) + if not at: + self.runner.log(f" {name}: no point picked — playing whatever difficulty " + f"is already chosen") + elif clicks <= 0: + self.runner.log(f" {name}: nothing to click, it is where the panel starts") + else: + for click in range(clicks): + winput.click(*at) + self.runner.wait(self.CYCLE_GAP if click + 1 < clicks else 0) + self.runner.log(f" {name}: {clicks} clicks at {at[0]},{at[1]}" + if clicks > 1 else + f" {name}: one click at {at[0]},{at[1]}") + self.runner.wait(pause) + + if self.settings.get("play"): + # Not the difficulty clicks above: those cycle a label through four values. + sure_click(self.runner, self.settings["play"], "play") + self.runner.wait(float(self.settings.get("settle_seconds", 4.0))) + return True + + def scroll_to(self, page: int, songs: dict) -> None: + at = songs.get("scroll_at") or songs.get("first") + if not at: + return + winput.move_to(int(at[0]), int(at[1])) + self.runner.wait(0.15) + wheel(self.runner, int(songs.get("top_clicks", 15))) + self.runner.wait(0.4) + wheel(self.runner, page * int(songs.get("page_clicks", -3))) + self.runner.wait(0.4) + self.runner.log(f" wound the list down to screenful {page + 1}") + + # ------------------------------------------------------------- runs + def remember(self, song: int, index: int, level: dict, why: str) -> None: + run = self.chart.last + done = self.progress.setdefault("done", {}) + done[self.key_of(song, index)] = { + "song": song + 1, + "difficulty": level.get("name") or index + 1, + "notes": run["notes"], + "seconds": round(run["seconds"], 1), + "why": why, + "when": time.strftime("%Y-%m-%d %H:%M"), + } + self.progress["runs"] = len(done) + self.progress["notes"] = sum(int(item.get("notes", 0)) for item in done.values()) + save_progress(self.progress) + + def announce(self) -> None: + total = len(self.jobs()) + done = len(self.progress.get("done") or {}) + if done: + self.runner.log(f"picking up where it left off — {done} of {total} runs done") + else: + self.runner.log(f"{total} runs to do") + if self.exits: + self.runner.log("this event drops you out after a song, so the way in " + "gets walked again before every run") + + def way_in(self) -> None: + if picked(self.routes.get("into")): + walk(self.runner, self.routes["into"], "walking the way in —") + else: + self.runner.log("no way in picked — carrying on from wherever you are, " + "which wants to be the song list") + + def tick(self) -> None: + if not self.announced: + self.announced = True + self.announce() + if self.needs_way_in: + self.needs_way_in = False + self.way_in() + + left = self.pending() + total = len(self.jobs()) + self.runner.stats.update(runs=total - len(left), total=total) + if not left: + self.report() + self.runner.stopped = True + return + + song, index, level = left[0] + label = self.name_of(song, index, level) + self.runner.log(f"{label} — run {total - len(left) + 1} of {total}") + if not self.choose(song, index, level): + self.runner.stopped = True + return + + why = self.chart.play(label=label) + self.remember(song, index, level, why) + self.played += 1 + self.runner.stats.update(runs=len(self.progress["done"]), total=total) + + if picked(self.routes.get("back")): + walk(self.runner, self.routes["back"], " back to the list —") + if self.exits: + self.needs_way_in = True + self.runner.wait(float(self.settings.get("between_seconds", 2.5))) + + def report(self) -> None: + done = self.progress.get("done") or {} + notes = sum(int(run.get("notes", 0)) for run in done.values()) + minutes = sum(float(run.get("seconds", 0)) for run in done.values()) / 60 + self.runner.log(f"all {len(done)} runs done — {notes} notes over " + f"{minutes:.0f} minutes of song, {self.played} of them today") + odd = sorted(f"{run['song']} {run['difficulty']}" + for run in done.values() if run.get("why") == "timeout") + if odd: + self.runner.log(f" ended on the clock rather than on an ending: " + f"{', '.join(odd)} — worth replaying by hand") + + +# -------------------------------------------------------------------- run +def migrate(cfg: dict) -> dict: + """An older config kept the end of a song at the top level.""" + cfg.pop("quiet_seconds", None) + return cfg + + +def _lanes_ready(cfg: dict, runner: Runner) -> bool: + lanes = cfg.get("lanes") or [] + if not lanes: + runner.log("no lanes set up — calibrate first (the window makes this easy)") + return False + unknown = sorted({lane.get("key") for lane in lanes + if lane.get("do") != "click" and lane.get("key") not in winput.KEYS}) + if unknown: + runner.log(f"lanes named {', '.join(repr(key) for key in unknown)} are not keys " + f"this can press — fix them before starting") + return False + unset = [lane.get("key") or "?" for lane in lanes + if not (lane.get("empty") or lane.get("note"))] + if unset: + runner.log(f"lanes {''.join(unset)!r} have no empty-track colour — " + f"they will never fire until you sample one") + return True + + +def run(once: bool = False, runner: Runner | None = None, mode: str | None = None) -> None: + """Play: mode is "loop", "once" or "session". `once` is the older spelling.""" + mode = mode or ("once" if once else "loop") + cfg = migrate(config.load(NAME, DEFAULTS)) + config.save(NAME, cfg) + runner = runner or Runner("Piano", cfg["hotkeys"]) + + if not _lanes_ready(cfg, runner): + return + + chart = Chart(cfg, runner) + ending = chart.ending + runner.log(f"{len(chart.lanes)} lanes, reading " + f"{chart.region.width}x{chart.region.height} px at " + f"{[lane.at for lane in chart.lanes]}") + knows = ["the track goes off screen"] + if chart.timer is not None: + knows.insert(0, "the timer stops") + if ending.has_probe: + knows.insert(0, "the results pixel shows") + runner.log(f"threshold {chart.tuning['threshold']}, offset " + f"{cfg['detect']['offset_ms']}ms — a song ends when " + f"{' or '.join(knows)} for {ending.quiet:.0f}s") + runner.stats.update(notes=0, scans=0, rate=0.0, started=time.monotonic(), + lit=[False] * len(chart.lanes), hits=[0] * len(chart.lanes)) + + if mode == "session": + tick = Session(cfg, runner, chart).tick + elif mode == "once": + def tick(): + chart.play(label="the song") + runner.stopped = True + else: + def tick(): + # play() waits for the next song by itself. + chart.play() + + try: + runner.loop(tick) + finally: + chart.close() + chart.report() diff --git a/kittymacro/routine.py b/kittymacro/routine.py new file mode 100644 index 0000000..991d1a2 --- /dev/null +++ b/kittymacro/routine.py @@ -0,0 +1,150 @@ +"""Recording what you do, and doing it again.""" + +from __future__ import annotations + +import time + +from . import screen, sound, winput +from .hotkeys import Hotkeys +from .runner import Runner + +# Everything worth catching in a Roblox game. +WATCH_KEYS = [ + *"abcdefghijklmnopqrstuvwxyz", + *"0123456789", + "space", "shift", "ctrl", "tab", "esc", "enter", + "-", "=", "[", "]", ",", ".", "/", +] + + +def record(name: str, keys: dict | None = None, watch_keys=None, extra=None) -> list[dict]: + """Watch the physical mouse and keyboard until you stop, and write it down.""" + keys = keys or {} + control = Hotkeys( + toggle=keys.get("pause", "f7"), + quit=keys.get("stop", "f8"), + ) + watch = list(watch_keys or WATCH_KEYS) + + print(f"recording into {name!r}") + print(f" [{keys.get('pause', 'f7').upper()}] start / finish" + f" [{keys.get('stop', 'f8').upper()}] discard and quit") + print(" clicks and key presses are captured while recording is on.") + print(" focus Roblox, press start, play your build order, press finish.") + + steps: list[dict] = [] + recording = False + last_at = None + + # Seeded as held, so a button already down is not recorded when it comes up. + button_was = {button: winput.mouse_is_down(button) for button in ("left", "right", "middle")} + key_was = {key: winput.is_down(key) for key in watch} + + try: + while True: + for pressed in control.poll() + (extra.poll() if extra else []): + if pressed == "quit": + sound.alarm() + print("discarded.") + return [] + if pressed == "toggle": + recording = not recording + if recording: + # After the beep, not before: the beep is blocking. + sound.started() + last_at = time.monotonic() + print("recording…") + else: + sound.stopped() + print(f"finished — {len(steps)} steps.") + return steps + + now = time.monotonic() + + for button in ("left", "right", "middle"): + down = winput.mouse_is_down(button) + fired = down and not button_was[button] + button_was[button] = down + if fired and recording: + x, y = winput.cursor_pos() + steps.append({ + "do": "click", + "at": [x, y], + "button": button, + "after": round(now - last_at, 3), + "color": list(screen.pixel(x, y) or ()), + }) + last_at = now + print(f" click {button} at {x},{y}") + + for key in watch: + down = winput.is_down(key) + fired = down and not key_was[key] + key_was[key] = down + if fired and recording: + steps.append({"do": "key", "key": key, "after": round(now - last_at, 3)}) + last_at = now + print(f" key {key}") + + time.sleep(0.005) + except KeyboardInterrupt: + print("interrupted.") + return steps + + +def play(runner: Runner, steps: list[dict], speed: float = 1.0) -> None: + """Replay a recorded routine once.""" + speed = max(float(speed), 0.05) + for index, step in enumerate(steps, start=1): + runner.wait(float(step.get("after", 0)) / speed) + kind = step.get("do") + if kind == "click": + x, y = step["at"] + winput.click(x, y, button=step.get("button", "left")) + elif kind == "key": + winput.tap(step["key"]) + elif kind == "wait": + runner.wait(float(step.get("seconds", 0)) / speed) + elif kind == "hold": + winput.key_down(step["key"]) + runner.wait(float(step.get("seconds", 0.2))) + winput.key_up(step["key"]) + else: + runner.log(f"step {index}: unknown action {kind!r}, skipped") + + +def capture_point(prompt: str, keys: dict | None = None) -> dict | None: + """Point the cursor at something and press the capture key.""" + keys = keys or {} + control = Hotkeys( + take=keys.get("capture", "f6"), + skip=keys.get("stop", "f8"), + ) + print(f"\n{prompt}") + print(f" hover it, then [{keys.get('capture', 'f6').upper()}] to capture" + f" [{keys.get('stop', 'f8').upper()}] to skip") + while True: + for pressed in control.poll(): + if pressed == "skip": + print(" skipped.") + return None + if pressed == "take": + x, y = winput.cursor_pos() + colour = screen.pixel(x, y) + sound.tick() + print(f" captured {x},{y} colour {colour}") + return {"at": [x, y], "color": list(colour or ())} + time.sleep(0.01) + + +def watch_cursor() -> None: + """Live cursor position and colour, for calibrating by hand. Ctrl+C quits.""" + print("cursor readout — Ctrl+C to stop") + try: + while True: + x, y = winput.cursor_pos() + colour = screen.pixel(x, y) + print(f"\r {x:>5},{y:<5} rgb{colour} ", end="", flush=True) + time.sleep(0.08) + except KeyboardInterrupt: + print() diff --git a/kittymacro/runner.py b/kittymacro/runner.py new file mode 100644 index 0000000..1143182 --- /dev/null +++ b/kittymacro/runner.py @@ -0,0 +1,113 @@ +"""The loop every macro runs inside: pause, stop, focus, cleanup.""" + +from __future__ import annotations + +import time + +from . import screen, sound, winput +from .hotkeys import Hotkeys, Signals + + +class Stopped(Exception): + """Raised out of wait() when the stop key is pressed.""" + + +class Runner: + TICK = 0.02 + + def __init__(self, name: str, keys: dict | None = None, require_focus: bool = True, + signals: Signals | None = None): + keys = keys or {} + self.name = name + self.require_focus = require_focus + self.hotkeys = Hotkeys( + pause=keys.get("pause", "f7"), + stop=keys.get("stop", "f8"), + ) + # A window's buttons press the same two things the F-keys do, and they arrive here. + self.signals = signals or Signals() + # Whatever the macro wants a front end to be able to show. + self.stats: dict = {} + self.paused = True # armed but not running until you say so + self.stopped = False + self._said = None + + # ------------------------------------------------------------ output + def log(self, message: str) -> None: + print(f"[{time.strftime('%H:%M:%S')}] {message}", flush=True) + + def status(self, message: str) -> None: + """Log only when it changes - a loop would otherwise print it forever.""" + if message != self._said: + self._said = message + self.log(message) + + # ------------------------------------------------------------- state + def _handle_controls(self) -> None: + for pressed in self.hotkeys.poll() + self.signals.poll(): + if pressed == "stop": + self.stopped = True + elif pressed == "pause": + self.paused = not self.paused + if self.paused: + winput.release_all() + sound.stopped() + else: + sound.started() + self.log("paused" if self.paused else "running") + + def ready(self) -> bool: + """Running, not paused, and Roblox is in front.""" + if self.paused: + return False + if self.require_focus and not screen.roblox_focused(): + return False + return True + + def wait(self, seconds: float) -> None: + """Sleep, but stay responsive and never sleep through a stop.""" + remaining = max(float(seconds), 0.0) + while True: + self._handle_controls() + if self.stopped: + raise Stopped() + + if self.paused: + self.status(f"paused — {self.hotkeys.describe()}") + time.sleep(self.TICK) + continue + + if self.require_focus and not screen.roblox_focused(): + if winput.held_keys or winput.held_buttons: + winput.release_all() + self.status("waiting for Roblox to be the front window") + time.sleep(self.TICK) + continue + + if remaining <= 0: + return + slice_ = min(self.TICK, remaining) + time.sleep(slice_) + remaining -= slice_ + + def gate(self) -> None: + """Block until running, paused-off and focused. A wait of nothing.""" + self.wait(0) + + # -------------------------------------------------------------- main + def loop(self, tick) -> None: + """Drive a macro until it stops or the stop key is pressed.""" + self.log(f"{self.name} armed. {self.hotkeys.describe()}") + if not screen.find_roblox(): + self.log("Roblox is not open — start it and this will pick it up.") + try: + while not self.stopped: + self.gate() + tick() + except Stopped: + sound.alarm() + self.log("stopped.") + except KeyboardInterrupt: + self.log("interrupted.") + finally: + winput.release_all() diff --git a/kittymacro/screen.py b/kittymacro/screen.py new file mode 100644 index 0000000..d99ef20 --- /dev/null +++ b/kittymacro/screen.py @@ -0,0 +1,238 @@ +"""Reading the screen, and knowing which window is in front.""" + +from __future__ import annotations + +import ctypes +import struct +import zlib +from ctypes import wintypes + +from .winput import ensure_dpi_aware + +ensure_dpi_aware() + +user32 = ctypes.WinDLL("user32", use_last_error=True) +gdi32 = ctypes.WinDLL("gdi32", use_last_error=True) + +gdi32.GetPixel.argtypes = (wintypes.HDC, ctypes.c_int, ctypes.c_int) +gdi32.GetPixel.restype = wintypes.COLORREF +user32.GetDC.argtypes = (wintypes.HWND,) +user32.GetDC.restype = wintypes.HDC +gdi32.CreateCompatibleDC.argtypes = (wintypes.HDC,) +gdi32.CreateCompatibleDC.restype = wintypes.HDC +gdi32.CreateDIBSection.argtypes = (wintypes.HDC, ctypes.c_void_p, wintypes.UINT, + ctypes.POINTER(ctypes.c_void_p), wintypes.HANDLE, + wintypes.DWORD) +gdi32.CreateDIBSection.restype = wintypes.HBITMAP +gdi32.SelectObject.argtypes = (wintypes.HDC, wintypes.HGDIOBJ) +gdi32.SelectObject.restype = wintypes.HGDIOBJ +gdi32.BitBlt.argtypes = (wintypes.HDC, ctypes.c_int, ctypes.c_int, ctypes.c_int, + ctypes.c_int, wintypes.HDC, ctypes.c_int, ctypes.c_int, + wintypes.DWORD) +gdi32.DeleteObject.argtypes = (wintypes.HGDIOBJ,) +gdi32.DeleteDC.argtypes = (wintypes.HDC,) + +SRCCOPY = 0x00CC0020 +user32.GetWindowTextW.argtypes = (wintypes.HWND, wintypes.LPWSTR, ctypes.c_int) +user32.GetWindowTextLengthW.argtypes = (wintypes.HWND,) + +CLR_INVALID = 0xFFFFFFFF + +_dc = None + + +def _desktop_dc(): + global _dc + if _dc is None: + _dc = user32.GetDC(None) + return _dc + + +def screen_size() -> tuple[int, int]: + return user32.GetSystemMetrics(0), user32.GetSystemMetrics(1) + + +def pixel(x: int, y: int) -> tuple[int, int, int] | None: + """(r, g, b) at a screen coordinate, or None if it cannot be read.""" + value = gdi32.GetPixel(_desktop_dc(), int(x), int(y)) + if value == CLR_INVALID: + return None + # COLORREF is 0x00BBGGRR. + return value & 0xFF, (value >> 8) & 0xFF, (value >> 16) & 0xFF + + +class BITMAPINFOHEADER(ctypes.Structure): + _fields_ = [ + ("biSize", wintypes.DWORD), ("biWidth", ctypes.c_long), + ("biHeight", ctypes.c_long), ("biPlanes", wintypes.WORD), + ("biBitCount", wintypes.WORD), ("biCompression", wintypes.DWORD), + ("biSizeImage", wintypes.DWORD), ("biXPelsPerMeter", ctypes.c_long), + ("biYPelsPerMeter", ctypes.c_long), ("biClrUsed", wintypes.DWORD), + ("biClrImportant", wintypes.DWORD), + ] + + +class Region: + """A rectangle of the screen, taken in one copy.""" + + def __init__(self, left: int, top: int, width: int, height: int = 1): + self.left, self.top = int(left), int(top) + self.width, self.height = max(1, int(width)), max(1, int(height)) + self._screen = _desktop_dc() + self._dc = gdi32.CreateCompatibleDC(self._screen) + + header = BITMAPINFOHEADER() + header.biSize = ctypes.sizeof(BITMAPINFOHEADER) + header.biWidth = self.width + header.biHeight = -self.height # negative: rows top down, like the screen + header.biPlanes = 1 + header.biBitCount = 32 + header.biCompression = 0 # BI_RGB + self._bits = ctypes.c_void_p() + self._bitmap = gdi32.CreateDIBSection(self._dc, ctypes.byref(header), 0, + ctypes.byref(self._bits), None, 0) + if not self._bitmap: + raise OSError("could not make a bitmap to read the screen into") + gdi32.SelectObject(self._dc, self._bitmap) + self.pixels = (ctypes.c_ubyte * (self.width * self.height * 4)).from_address( + self._bits.value) + + def read(self) -> bool: + """Copy the rectangle as it is right now. False if Windows refused.""" + return bool(gdi32.BitBlt(self._dc, 0, 0, self.width, self.height, + self._screen, self.left, self.top, SRCCOPY)) + + def at(self, x: int, y: int | None = None): + """(r, g, b) at a screen coordinate, out of the last read.""" + column = int(x) - self.left + row = 0 if y is None else int(y) - self.top + if not (0 <= column < self.width and 0 <= row < self.height): + return None + index = (row * self.width + column) * 4 + return self.pixels[index + 2], self.pixels[index + 1], self.pixels[index] + + def png(self, path) -> str: + """Write what was last read to a PNG, for a person to look at.""" + rows = bytearray() + stride = self.width * 4 + for row in range(self.height): + line = bytes(self.pixels[row * stride:(row + 1) * stride]) + rgb = bytearray(self.width * 3) + rgb[0::3] = line[2::4] # the DIB is BGRA; PNG wants RGB + rgb[1::3] = line[1::4] + rgb[2::3] = line[0::4] + rows.append(0) # no per-row filter + rows += rgb + + def chunk(tag, payload): + return (struct.pack(">I", len(payload)) + tag + payload + + struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF)) + + data = (b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", self.width, self.height, + 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(bytes(rows), 6)) + + chunk(b"IEND", b"")) + path = str(path) + with open(path, "wb") as handle: + handle.write(data) + return path + + def close(self) -> None: + if getattr(self, "_bitmap", None): + gdi32.DeleteObject(self._bitmap) + self._bitmap = None + if getattr(self, "_dc", None): + gdi32.DeleteDC(self._dc) + self._dc = None + + def __enter__(self): + return self + + def __exit__(self, *_exc): + self.close() + + def __del__(self): + try: + self.close() + except Exception: + pass + + +def around(points, margin: int = 1) -> Region: + """A Region just big enough to hold every one of these (x, y) points.""" + xs = [int(x) for x, _y in points] + ys = [int(y) for _x, y in points] + left, top = min(xs) - margin, min(ys) - margin + return Region(left, top, max(xs) + margin - left + 1, max(ys) + margin - top + 1) + + +def close_enough(a, b, tolerance: int = 24) -> bool: + """Per-channel comparison.""" + if not a or not b: + return False + return all(abs(int(p) - int(q)) <= tolerance for p, q in zip(a, b)) + + +# --------------------------------------------------------------- windows +WNDENUMPROC = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + + +def window_title(hwnd) -> str: + length = user32.GetWindowTextLengthW(hwnd) + if length <= 0: + return "" + buffer = ctypes.create_unicode_buffer(length + 1) + user32.GetWindowTextW(hwnd, buffer, length + 1) + return buffer.value + + +def foreground_title() -> str: + return window_title(user32.GetForegroundWindow()) + + +def find_roblox(): + """The Roblox player window, or None.""" + found = [] + + def callback(hwnd, _lparam): + if not user32.IsWindowVisible(hwnd): + return True + title = window_title(hwnd) + if title == "Roblox": + found.append((0, hwnd)) + elif title.startswith("Roblox") and "Studio" not in title: + found.append((1, hwnd)) + return True + + user32.EnumWindows(WNDENUMPROC(callback), 0) + if not found: + return None + found.sort(key=lambda item: item[0]) + return found[0][1] + + +def roblox_focused() -> bool: + hwnd = user32.GetForegroundWindow() + if not hwnd: + return False + title = window_title(hwnd) + return title == "Roblox" or (title.startswith("Roblox") and "Studio" not in title) + + +def focus_roblox() -> bool: + """Bring Roblox forward.""" + hwnd = find_roblox() + if not hwnd: + return False + if user32.IsIconic(hwnd): + user32.ShowWindow(hwnd, 9) # SW_RESTORE + user32.SetForegroundWindow(hwnd) + return roblox_focused() + + +def window_rect(hwnd) -> tuple[int, int, int, int] | None: + rect = wintypes.RECT() + if not user32.GetWindowRect(hwnd, ctypes.byref(rect)): + return None + return rect.left, rect.top, rect.right, rect.bottom diff --git a/kittymacro/sound.py b/kittymacro/sound.py new file mode 100644 index 0000000..dfae79b --- /dev/null +++ b/kittymacro/sound.py @@ -0,0 +1,35 @@ +"""Beeps, because the console is behind Roblox when it matters.""" + +from __future__ import annotations + +try: + import winsound +except ImportError: # not Windows; every call below becomes a no-op + winsound = None + + +def _beep(frequency: int, milliseconds: int) -> None: + if not winsound: + return + try: + winsound.Beep(frequency, milliseconds) + except Exception: + pass + + +def started() -> None: + _beep(880, 60) + _beep(1320, 60) + + +def stopped() -> None: + _beep(880, 60) + _beep(560, 60) + + +def tick() -> None: + _beep(1500, 45) + + +def alarm() -> None: + _beep(400, 120) diff --git a/kittymacro/winput.py b/kittymacro/winput.py new file mode 100644 index 0000000..6ee4eee --- /dev/null +++ b/kittymacro/winput.py @@ -0,0 +1,269 @@ +"""Synthetic input that Roblox actually accepts.""" + +from __future__ import annotations + +import ctypes +import time +from ctypes import wintypes + +user32 = ctypes.WinDLL("user32", use_last_error=True) + +# ------------------------------------------------------------------ DPI +# Without this, Windows lies about coordinates on any display not at 100% scale. + + +def ensure_dpi_aware() -> None: + try: + # PER_MONITOR_AWARE_V2. Fails harmlessly if it is already set. + user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4)) + return + except Exception: + pass + try: + user32.SetProcessDPIAware() + except Exception: + pass + + +ensure_dpi_aware() + +# --------------------------------------------------------------- structs +ULONG_PTR = wintypes.WPARAM + +INPUT_MOUSE = 0 +INPUT_KEYBOARD = 1 + +KEYEVENTF_EXTENDEDKEY = 0x0001 +KEYEVENTF_KEYUP = 0x0002 +KEYEVENTF_SCANCODE = 0x0008 + +MOUSEEVENTF_MOVE = 0x0001 +MOUSEEVENTF_LEFTDOWN = 0x0002 +MOUSEEVENTF_LEFTUP = 0x0004 +MOUSEEVENTF_RIGHTDOWN = 0x0008 +MOUSEEVENTF_RIGHTUP = 0x0010 +MOUSEEVENTF_MIDDLEDOWN = 0x0020 +MOUSEEVENTF_MIDDLEUP = 0x0040 +MOUSEEVENTF_WHEEL = 0x0800 +MOUSEEVENTF_ABSOLUTE = 0x8000 +MOUSEEVENTF_VIRTUALDESK = 0x4000 + + +class MOUSEINPUT(ctypes.Structure): + _fields_ = [ + ("dx", wintypes.LONG), + ("dy", wintypes.LONG), + ("mouseData", wintypes.DWORD), + ("dwFlags", wintypes.DWORD), + ("time", wintypes.DWORD), + ("dwExtraInfo", ULONG_PTR), + ] + + +class KEYBDINPUT(ctypes.Structure): + _fields_ = [ + ("wVk", wintypes.WORD), + ("wScan", wintypes.WORD), + ("dwFlags", wintypes.DWORD), + ("time", wintypes.DWORD), + ("dwExtraInfo", ULONG_PTR), + ] + + +class HARDWAREINPUT(ctypes.Structure): + _fields_ = [ + ("uMsg", wintypes.DWORD), + ("wParamL", wintypes.WORD), + ("wParamH", wintypes.WORD), + ] + + +class _INPUTUNION(ctypes.Union): + _fields_ = [("mi", MOUSEINPUT), ("ki", KEYBDINPUT), ("hi", HARDWAREINPUT)] + + +class INPUT(ctypes.Structure): + _anonymous_ = ("u",) + _fields_ = [("type", wintypes.DWORD), ("u", _INPUTUNION)] + + +user32.SendInput.argtypes = (wintypes.UINT, ctypes.POINTER(INPUT), ctypes.c_int) +user32.SendInput.restype = wintypes.UINT +user32.MapVirtualKeyW.argtypes = (wintypes.UINT, wintypes.UINT) +user32.MapVirtualKeyW.restype = wintypes.UINT +user32.GetSystemMetrics.argtypes = (ctypes.c_int,) +user32.GetSystemMetrics.restype = ctypes.c_int + + +class InputError(RuntimeError): + """SendInput refused the event.""" + + +def _send(*inputs: INPUT) -> None: + count = len(inputs) + array = (INPUT * count)(*inputs) + sent = user32.SendInput(count, array, ctypes.sizeof(INPUT)) + if sent != count: + err = ctypes.get_last_error() + raise InputError(f"SendInput sent {sent}/{count} events (error {err})") + + +# ------------------------------------------------------------------ keys +KEYS: dict[str, int] = { + "backspace": 0x08, "tab": 0x09, "enter": 0x0D, "return": 0x0D, + "shift": 0xA0, "lshift": 0xA0, "rshift": 0xA1, + "ctrl": 0xA2, "lctrl": 0xA2, "rctrl": 0xA3, + "alt": 0xA4, "lalt": 0xA4, "ralt": 0xA5, + "esc": 0x1B, "escape": 0x1B, "space": 0x20, + "pageup": 0x21, "pagedown": 0x22, "end": 0x23, "home": 0x24, + "left": 0x25, "up": 0x26, "right": 0x27, "down": 0x28, + "insert": 0x2D, "delete": 0x2E, + "-": 0xBD, "=": 0xBB, "[": 0xDB, "]": 0xDD, ";": 0xBA, + "'": 0xDE, ",": 0xBC, ".": 0xBE, "/": 0xBF, "\\": 0xDC, "`": 0xC0, +} +for _i in range(1, 25): + KEYS[f"f{_i}"] = 0x6F + _i +for _c in "abcdefghijklmnopqrstuvwxyz": + KEYS[_c] = ord(_c.upper()) +for _c in "0123456789": + KEYS[_c] = ord(_c) + +# Keys whose scan code needs the extended flag. +EXTENDED = { + 0x25, 0x26, 0x27, 0x28, # arrows + 0x21, 0x22, 0x23, 0x24, 0x2D, 0x2E, # navigation cluster + 0xA3, 0xA5, 0x5B, 0x5C, 0x90, # right ctrl/alt, windows keys, numlock +} + +VK_TO_NAME = {} +for _name, _vk in KEYS.items(): + VK_TO_NAME.setdefault(_vk, _name) + +held_keys: set[str] = set() +held_buttons: set[str] = set() + + +def vk_of(key: str) -> int: + try: + return KEYS[key.lower()] + except KeyError: + raise KeyError(f"unknown key name: {key!r}") from None + + +def _key_event(key: str, up: bool) -> None: + vk = vk_of(key) + scan = user32.MapVirtualKeyW(vk, 0) + flags = KEYEVENTF_KEYUP if up else 0 + if scan: + # wVk stays zero on purpose: with the scan-code flag set. + flags |= KEYEVENTF_SCANCODE + if vk in EXTENDED: + flags |= KEYEVENTF_EXTENDEDKEY + ki = KEYBDINPUT(wVk=0, wScan=scan, dwFlags=flags, time=0, dwExtraInfo=0) + else: + ki = KEYBDINPUT(wVk=vk, wScan=0, dwFlags=flags, time=0, dwExtraInfo=0) + _send(INPUT(type=INPUT_KEYBOARD, ki=ki)) + + +def key_down(key: str) -> None: + _key_event(key, up=False) + held_keys.add(key.lower()) + + +def key_up(key: str) -> None: + _key_event(key, up=True) + held_keys.discard(key.lower()) + + +def tap(key: str, hold: float = 0.04) -> None: + key_down(key) + time.sleep(hold) + key_up(key) + + +# ----------------------------------------------------------------- mouse +_BUTTONS = { + "left": (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP), + "right": (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP), + "middle": (MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP), +} + + +def _mouse(flags: int, dx: int = 0, dy: int = 0, data: int = 0) -> None: + mi = MOUSEINPUT(dx=dx, dy=dy, mouseData=data, dwFlags=flags, time=0, dwExtraInfo=0) + _send(INPUT(type=INPUT_MOUSE, mi=mi)) + + +def cursor_pos() -> tuple[int, int]: + point = wintypes.POINT() + user32.GetCursorPos(ctypes.byref(point)) + return point.x, point.y + + +def move_to(x: int, y: int) -> None: + """Absolute move, for clicking something on screen.""" + vx = user32.GetSystemMetrics(76) + vy = user32.GetSystemMetrics(77) + vw = max(user32.GetSystemMetrics(78), 1) + vh = max(user32.GetSystemMetrics(79), 1) + nx = int(round((x - vx) * 65535 / max(vw - 1, 1))) + ny = int(round((y - vy) * 65535 / max(vh - 1, 1))) + _mouse(MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK, nx, ny) + + +def move_rel(dx: int, dy: int) -> None: + """Relative move - the only kind the camera reacts to.""" + _mouse(MOUSEEVENTF_MOVE, int(dx), int(dy)) + + +def mouse_down(button: str = "left") -> None: + down, _ = _BUTTONS[button] + _mouse(down) + held_buttons.add(button) + + +def mouse_up(button: str = "left") -> None: + _, up = _BUTTONS[button] + _mouse(up) + held_buttons.discard(button) + + +def click(x: int | None = None, y: int | None = None, + button: str = "left", settle: float = 0.03, hold: float = 0.04) -> None: + """Move, let the cursor land, click.""" + if x is not None and y is not None: + move_to(x, y) + time.sleep(settle) + mouse_down(button) + time.sleep(hold) + mouse_up(button) + + +def scroll(clicks: int) -> None: + _mouse(MOUSEEVENTF_WHEEL, data=int(clicks) * 120) + + +def release_all() -> None: + """Put everything back up. Safe to call twice, and it never raises.""" + for key in list(held_keys): + try: + key_up(key) + except Exception: + held_keys.discard(key) + for button in list(held_buttons): + try: + mouse_up(button) + except Exception: + held_buttons.discard(button) + + +def is_down(key: str) -> bool: + """Physical state of a key, regardless of which window has focus.""" + return bool(user32.GetAsyncKeyState(vk_of(key)) & 0x8000) + + +MOUSE_VK = {"left": 0x01, "right": 0x02, "middle": 0x04} + + +def mouse_is_down(button: str) -> bool: + return bool(user32.GetAsyncKeyState(MOUSE_VK[button]) & 0x8000) diff --git a/macro.py b/macro.py new file mode 100644 index 0000000..0f7bc13 --- /dev/null +++ b/macro.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""KittyMacro — command line. + + python macro.py gui all of the below, in a window + python macro.py check is any of this going to work here? + python macro.py mouse live cursor position and colour + python macro.py record build record a build order + python macro.py play build replay it once, to check it + python macro.py calibrate teach it your buttons + python macro.py run farm Anime Vanguards + python macro.py routines what has been recorded + + python macro.py calibrate --game piano set up a falling-notes event + python macro.py run --game piano and play it + python macro.py run --game piano --session the whole event, menus and all + python macro.py walk into walk the route into the event, once + +Nothing needs installing: it is the standard library and ctypes. +""" + +from __future__ import annotations + +import argparse +import ctypes +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +# Some Windows consoles are still on a code page that cannot spell "-". +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, winput # noqa: E402 +from kittymacro.macros import animevanguards, piano # noqa: E402 +from kittymacro.runner import Runner # noqa: E402 + +# Recording and playing routines belong to the tower defence macro; the rest takes --game. +MACRO = animevanguards +GAMES = {"animevanguards": animevanguards, "piano": piano} + + +def game_of(args): + return GAMES[getattr(args, "game", "animevanguards")] + + +# ------------------------------------------------------------------ check +def cmd_check(args) -> int: + print(f"KittyMacro {__version__}") + print(f"python {sys.version.split()[0]} ({'64' if sys.maxsize > 2**32 else '32'}-bit)") + + width, height = screen.screen_size() + print(f"screen {width} x {height}") + + x, y = winput.cursor_pos() + print(f"cursor {x},{y} rgb{screen.pixel(x, y)}") + + hwnd = screen.find_roblox() + if hwnd: + rect = screen.window_rect(hwnd) + print(f"roblox found — window {rect}") + print(f" in front: {'yes' if screen.roblox_focused() else 'no'}") + else: + print("roblox NOT FOUND — open the game, then run this again") + + admin = False + try: + admin = bool(ctypes.windll.shell32.IsUserAnAdmin()) + except Exception: + pass + print(f"elevated {'yes' if admin else 'no'}") + + # A relative move of nothing. + try: + winput.move_rel(0, 0) + print("sendinput accepted") + except winput.InputError as error: + print(f"sendinput REFUSED — {error}") + print(" if Roblox is running as administrator, run this the same way") + return 1 + + if not args.live: + print("\nlooks fine. `check --live` will make your character jump in game " + "to prove input really lands.") + return 0 + + if not hwnd: + print("\ncannot run the live test without Roblox open.") + return 1 + + print("\nlive test: focusing Roblox and pressing space in 3 seconds…") + screen.focus_roblox() + for remaining in (3, 2, 1): + print(f" {remaining}…", flush=True) + time.sleep(1) + if not screen.roblox_focused(): + print(" Roblox is still not in front — click it and try again.") + return 1 + winput.tap("space", hold=0.08) + time.sleep(0.4) + winput.tap("space", hold=0.08) + print(" sent two jumps. If your character jumped, everything below works.") + return 0 + + +# ------------------------------------------------------------------ tools +def cmd_mouse(args) -> int: + routine.watch_cursor() + return 0 + + +def cmd_record(args) -> int: + cfg = config.load(MACRO.NAME, MACRO.DEFAULTS) + steps = routine.record(args.name, cfg["hotkeys"]) + if not steps: + return 1 + path = config.save_routine(args.name, steps, args.note or "") + print(f"saved {len(steps)} steps to {path}") + if args.name not in (cfg["routines"].get("build"), cfg["routines"].get("loop")): + print(f"point the macro at it with: routines.build = \"{args.name}\" " + f"in {config.config_path(MACRO.NAME)}") + return 0 + + +def cmd_play(args) -> int: + steps = config.load_routine(args.name) + cfg = config.load(MACRO.NAME, MACRO.DEFAULTS) + runner = Runner(f"routine {args.name}", cfg["hotkeys"]) + + def tick(): + routine.play(runner, steps, args.speed) + runner.stopped = True + + runner.log(f"{len(steps)} steps — focus Roblox and press " + f"{cfg['hotkeys'].get('pause', 'f7').upper()} to play") + runner.loop(tick) + return 0 + + +def cmd_calibrate(args) -> int: + macro = game_of(args) + cfg = config.load(macro.NAME, macro.DEFAULTS) + cfg = macro.calibrate(cfg) + path = config.save(macro.NAME, cfg) + print(f"\nsaved to {path}") + return 0 + + +def cmd_run(args) -> int: + macro = game_of(args) + try: + if macro is piano: + macro.run(mode="session" if args.session else + ("once" if args.once else "loop")) + else: + macro.run(once=args.once) + except FileNotFoundError as error: + print(f"{error}") + print("record one first: python macro.py record build") + return 1 + return 0 + + +def cmd_walk(args) -> int: + """Walk one of the piano routes once, without playing anything.""" + cfg = config.load(piano.NAME, piano.DEFAULTS) + steps = (cfg.get("routes") or {}).get(args.which) or [] + if not piano.picked(steps): + print(f"nothing picked in the {args.which!r} route — the window " + f"(python macro.py gui) has a Pick button per step") + return 1 + runner = Runner(f"route {args.which}", cfg["hotkeys"]) + + def tick(): + piano.walk(runner, steps, f"walking the {args.which} route —") + runner.stopped = True + + runner.log(f"{len(steps)} steps — focus Roblox and press " + f"{cfg['hotkeys'].get('pause', 'f7').upper()} to walk it") + runner.loop(tick) + return 0 + + +def cmd_gui(args) -> int: + import gui + return gui.main() + + +def cmd_routines(args) -> int: + names = config.list_routines() + if not names: + print("nothing recorded yet — python macro.py record build") + return 0 + for name in names: + steps = config.load_routine(name) + 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) + print(f" {name:<20} {len(steps):>3} steps ({clicks} clicks, {keys} keys, {seconds:.0f}s)") + return 0 + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(prog="macro.py", description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + subs = parser.add_subparsers(dest="command", required=True) + + window = subs.add_parser("gui", help="open the window") + window.set_defaults(func=cmd_gui) + + check = subs.add_parser("check", help="is any of this going to work here?") + check.add_argument("--live", action="store_true", + help="also press space in game to prove input lands") + check.set_defaults(func=cmd_check) + + mouse = subs.add_parser("mouse", help="live cursor position and colour") + mouse.set_defaults(func=cmd_mouse) + + record = subs.add_parser("record", help="record clicks and keys into a routine") + record.add_argument("name") + record.add_argument("--note", help="what this routine is for") + record.set_defaults(func=cmd_record) + + play = subs.add_parser("play", help="replay a recorded routine once") + play.add_argument("name") + play.add_argument("--speed", type=float, default=1.0, + help="scale the gaps between steps (default 1.0)") + play.set_defaults(func=cmd_play) + + calibrate = subs.add_parser("calibrate", help="capture the points a macro watches") + calibrate.add_argument("--game", choices=sorted(GAMES), default="animevanguards") + calibrate.set_defaults(func=cmd_calibrate) + + run = subs.add_parser("run", help="play the game") + run.add_argument("--once", action="store_true", + help="one match, or one song, then stop") + run.add_argument("--session", action="store_true", + help="piano: the whole event — in through the menus, then every " + "song at every difficulty, resuming where it left off") + run.add_argument("--game", choices=sorted(GAMES), default="animevanguards") + run.set_defaults(func=cmd_run) + + walk = subs.add_parser("walk", help="walk a piano route once, to check it") + walk.add_argument("which", nargs="?", default="into", choices=["into", "back"], + help="into the event, or back to the song list") + walk.set_defaults(func=cmd_walk) + + routines = subs.add_parser("routines", help="list recorded routines") + routines.set_defaults(func=cmd_routines) + + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + if sys.platform != "win32": + print("KittyMacro drives Windows input directly — it only runs on Windows.") + raise SystemExit(2) + raise SystemExit(main())