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

264 lines
9.3 KiB
Python

#!/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())