andrew kim / projects / osu bot

a bot that plays osu! on its own. it follows the song by reading the game's memory, parses the map to know where every note is, and presses the keys to hit them in time. it works for standard, as a relax bot that taps while you still aim, and for mania at every key count.

osu! can catch this. the keys go through sendinput and the timing it produces is far more even than a person's, so a score it sets online gets flagged and takes the account with it. don't run it signed in.

the bot playing a 7k mania map, 2771 notes. no audio
$ python bot.py --help
usage: bot.py [-h] [--offset OFFSET] [--tap-ms TAP_MS]
              [--mania-keys LAYOUT [LAYOUT ...]]

key bot for osu! stable (standard relax and mania)

options:
  -h, --help            show this help message and exit
  --offset OFFSET       press this many ms early (negative for late)
  --tap-ms TAP_MS       how long a tap holds the key
  --mania-keys LAYOUT [LAYOUT ...]
                        mania layouts, one per key count, e.g. "dfjk" "sdf
                        jkl"
the help text. run it, start a map in osu!, and it plays

finding the game in memory

the bot needs the song's clock, the current map, and whether a map is even playing, and osu! doesn't expose any of that. it has to read it out of the game's memory. the addresses move whenever osu! updates, though, so hardcoded ones would break on the next update.

so it does what a memory scanner does. for each value, it searches the running process for a short, stable pattern of bytes from the machine code near where osu! touches that value, then reads the pointer sitting at a fixed offset from the match. the ?? bytes are wildcards, for the parts that do change between builds.

PATTERNS = {
    "base": "F8 01 74 04 83 65",
    "play_time": "5E 5F 5D C3 A1 ?? ?? ?? ?? 89 ?? 04",
    "status": "48 83 F8 04 73 1E",
    ...
}

class Osu:
    def __init__(self, process):
        found = process.scan(PATTERNS)
        missing = sorted(set(PATTERNS) - set(found))
        if missing: raise LookupError("signatures not found: " + ", ".join(missing))

        self.beatmap_slot = process.uint32(found["base"] - 0xC)
        self.time_address = process.uint32(found["play_time"] + 0x5)
        self.status_address = process.uint32(found["status"] - 0x4)

after that, reading the clock is one memory read. audio_time() returns the milliseconds osu! is into the song.

reading the map

.osu files are plain text split into sections. the parser pulls out the hit objects and converts each one into a timestamp. for standard it walks the timing points to turn a slider's length into a real duration. for mania it maps each note's horizontal position into a column with x * keys / 512, since the format stores columns as x coordinates across a 512-wide playfield.

def parse_mania_objects(lines, key_count):
    objects = []
    for line in lines:
        fields = line.split(",")
        time = int(float(fields[2]))
        column = min(int(float(fields[0]) * key_count / 512), key_count - 1)
        end_time = int(float(fields[5].split(":")[0])) if int(fields[3]) & HOLD else time
        objects.append(HitObject(time, max(end_time, time), column))
    objects.sort(key=lambda hit: hit.time)
    return objects

playing in time

the rest is staying in sync. the notes are sorted by time, and the bot holds an index into them. each tick it reads the song's current time from memory and fires every note whose time has passed. if you skip ahead or retry, the clock jumps backwards, so it finds its place again with a binary search and lets go of anything it was holding.

def play(osu, objects, presser, offset):
    starts = [hit.time for hit in objects]
    previous = osu.audio_time() + offset
    index = bisect.bisect_left(starts, previous)
    while osu.status() == PLAYING:
        if stop_reason(): return
        now = osu.audio_time() + offset
        if now < previous - SEEK_THRESHOLD_MS:
            presser.release_all()
            index = bisect.bisect_left(starts, now)
        previous = now
        presser.update(now)
        while index < len(objects) and objects[index].time <= now:
            presser.hit(objects[index])
            index += 1

pressing the keys

keypresses go through the windows SendInput api as scan codes. in standard the bot alternates between two keys. in mania each column gets its own key, and long notes stay held until their end time. pressing end stops everything.

class Hands(KeyPresser):
    def __init__(self, codes, tap_ms):
        super().__init__(codes, tap_ms)
        self.turn = 0

    def hit(self, hit):
        preferred = [self.turn, 1 - self.turn]
        free = [index for index in preferred if self.release_at[index] is None]
        index = free[0] if free else preferred[0]
        self.press(index, hit)
        self.turn = 1 - index

download the code (13 kb zip)