feat(init): KittyMacro

Roblox macros that play from outside the game: Anime Vanguards, and a
falling-notes piano event.
This commit is contained in:
capyb2222 committed 2026-09-05 12:22:28 -07:00
commit bc09d70516
16 files changed
+5542

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
config/
TOMORROW.md
+6
View File
@@ -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" %*)
+466
View File
@@ -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.
+2481
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -0,0 +1,3 @@
"""KittyMacro - an outside-the-game macro for Roblox."""
__version__ = "1.0.0"
+69
View File
@@ -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"))
+52
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Game macros. One module each; add another next to animevanguards."""
+200
View File
@@ -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.")
File diff suppressed because it is too large. Load diff
+150
View File
@@ -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()
+113
View File
@@ -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()
+238
View File
@@ -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
+35
View File
@@ -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)
+269
View File
@@ -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)
+263
View File
@@ -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())