feat(init): KittyMacro
Roblox macros that play from outside the game: Anime Vanguards, and a falling-notes piano event.
This commit is contained in:
commit
bc09d70516
16 files changed
+5542
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
config/
|
||||||
|
TOMORROW.md
|
||||||
@@ -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" %*)
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""KittyMacro - an outside-the-game macro for Roblox."""
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
@@ -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"))
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Game macros. One module each; add another next to animevanguards."""
|
||||||
@@ -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
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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())
|
||||||
Reference in new issue
Block a user