This time we'll be taking a look at osu! and more specifically the Relax modifier in the Standard gamemode. First off, we'll build a beatmap parser and then we'll use it to implement basic input automation externally. I'll be using C++ but any language that can read files and use Windows APIs should work just fine.

Getting started

I've been writing a lot of internal game hacking posts so I thought it was about time I did something external.

For those unaware, the Relax modifier in osu! allows you to play through a beatmap without ever pressing any keys or clicking any mouse buttons, you only need to hover over objects and the rest is performed for you.

Any scores achieved while this modifier is active aren't saved - not even in the local ranking. That's where our program will come in, we'll be able to reap the benefits of the modifier but the game will be none the wiser.

Let's think about the requirements. We really only need to know what inputs to send and when to send them. Parsing a beatmap file provides us the inputs so let's start there, we can concern ourselves with the timing later.

Basic beatmap parsing

So then, how are we going to parse a beatmap? Luckily for us, this format is not only plain text, but it's well documented on the osu! wiki. We can parse everything from the song name, difficulty settings to the combo colors. We'll keep things simple and only parse the timing points, the hit objects and some slider related values.

For the Standard gamemode we're dealing with three types of objects: the hit circle, the slider and the spinner. The documentation for the .osu file format states that all objects have x, y, time and type values which we'll include in our structure.

I don't want to dwell on this section for too long as it's basically just reading each line, splitting it and storing the results. As such, I've included my extremely rough beatmap parser here. Feel free to send me any improvements, they'd be much appreciated. Many thanks to this project which helped with the slider length calculations.

Getting the game time

There are a few different ways to do this but the easiest is with Cheat Engine. If you're paranoid like me you might want to do this part offline, after all, there have been many known instances of automatic bans related to Cheat Engine usage. At the very least, make sure you've signed out of your osu! account before continuing.

Start off by opening Cheat Engine. If osu! isn't running yet you can start it now. Click the top-left icon to open the process list, from here select osu!.exe and click 'Attach debugger to process'. Switch back to osu! now and make sure that nothing is playing. You can do this from the main menu by clicking the stop icon in the top right.

osu! with the music stopped. Feels like it's trying to tell me something.

Now go back to Cheat Engine and enter 0 in the 'Value' field and perform the first scan. Once it's finished you should be left with millions of results. This is expected, we're going to narrow this down to just a few. Switch back to osu! and start playing the music again. Now go back to Cheat Engine, set the scan type to 'Increased value' and press 'Next scan'. This should've narrowed things down by quite a lot, keep pressing the 'Next scan' button until you're left with only a few.

Time addresses in Cheat Engine The selected addresses contain the game time.

We've almost got it, all that's left now is to get this value dynamically. This is why we attached the debugger with Cheat Engine earlier, right click each address and select 'Find out what writes to this address' from the drop-down menu. Some of these are no good but you should find one that, when disassembled, looks similar to this.

13654FA8 - DB 5D E8  - fistp dword ptr [ebp-18]
13654FAB - 8B 45 E8  - mov eax,[ebp-18]
13654FAE - A3 BC5D7705 - mov [05775DBC],eax
13654FB3 - 8B 35 94382104  - mov esi,[04213894]
13654FB9 - 85 F6  - test esi,esi

I've only covered signature scanning from within the process before on this blog but the end result is the same. I've uploaded a basic external signature scanner here which we'll be using later on in our implementation.

DB 5D E8 8B 45 E8 A3
— Regular or 'IDA-style' signature.
\xDB\x5D\xE8\x8B\x45\xE8\xA3
— Code-style signature.
xxxxxxx
— Code-style mask.

Note that the above signature is only for the Stable (Latest) release channel. The signatures are likely to differ on the Stable (Fallback), Beta and Cutting Edge (Experimental) channels but the process for finding it will be the same as above.

Implementation

In the one day that I allocated myself to write this post I wasn't able to find the 'currently loaded song' anywhere in memory. If you find it I'd love to know how you did it. Until then, we can load the beatmap from the command line arguments.

This gives us the added bonus of being able to drop the beatmap file on our executable or register our program as the handler for the .osu file format. Of course, it still sucks that it's not fully automated.

beatmap active_beatmap;
  
if (argc < 2 || !active_beatmap.Parse(argv[1])) {
  return EXIT_FAILURE;
}

Now we need to find the process ID of the osu! process. There's many different ways to do this but it's probably easiest to use CreateToolhelp32Snapshot and Process32Next to iterate through the process list.

inline const DWORD get_process_id() {
  DWORD process_id = NULL;
  HANDLE process_list = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, NULL);

  PROCESSENTRY32 entry = {0};
  entry.dwSize = sizeof PROCESSENTRY32;

  if (Process32First(process_list, &entry)) {
    while (Process32Next(process_list, &entry)) {
      if (_wcsicmp(entry.szExeFile, L"osu!.exe") == 0) {
        process_id = entry.th32ProcessID;
      }
    }
  }

  CloseHandle(process_list);

  return process_id;
};
game_process_id = get_process_id();

if (!game_process_id) {
  return EXIT_FAILURE;
}

Good, now we have the process ID we can open a handle to the process.

Since we only need to read it's memory we'll use the PROCESS_VM_READ desired access flag.

game_process = OpenProcess(PROCESS_VM_READ, false, game_process_id);

if (!game_process) {
  return false;
}

That's most of the boring stuff out of the way. Now we only need the game time address and a way to send key inputs before we can continue. For the first of these, the signature we made earlier will come in handy.

inline const DWORD find_time_address() {
  // scan process memory for array of bytes.
  DWORD time_ptr = FindPattern(game_process, PBYTE(TIME_SIGNATURE)) + 7;
  DWORD time_address = NULL;

  if (!ReadProcessMemory(game_process, LPCVOID(time_ptr), &time_address, sizeof DWORD, nullptr)) {
    return false;
  }

  return time_address;
};
inline const int32_t get_elapsed_time() {
  // read and return the elapsed time in the current beatmap.
  int32_t current_time = NULL;

  if (!ReadProcessMemory(game_process, LPCVOID(time_address), &current_time, sizeof int32_t, nullptr)) {
    return false;
  }

  return current_time;
};

For the last of these helper functions, we'll need something that will press a key when we call it. Again, a few ways to implement this but I found keybd_event and SendInput to be the easiest. Since keybd_event is deprecated we'll use SendInput.

inline void set_key_pressed(char key, bool pressed) {
  INPUT key_press = {0};
  key_press.type = INPUT_KEYBOARD;
  key_press.ki.wVk = VkKeyScanEx(key, GetKeyboardLayout(NULL)) & 0xFF;
  key_press.ki.wScan = 0;
  key_press.ki.dwExtraInfo = 0;
  key_press.ki.dwFlags = (pressed ? 0 : KEYEVENTF_KEYUP);
  SendInput(1, &key_press, sizeof INPUT);
}

All that's left now is to iterate the hit objects and send inputs as we go along. Start off by assuming we're at the start of the beatmap. We can now read the time to find out how far in we really are.

size_t current_object = 0;
int32_t time = get_elapsed_time();

for (size_t i = 0; i < active_beatmap.hitobjects.size(); i++) {
  if (active_beatmap.hitobjects.at(i).start_time > time) {
    current_object = i;
    break;
  }
}

Make sure to add a check for maps with AudioLeadIn time.

while (current_object == 0 && get_elapsed_time() < active_beatmap.hitobjects.begin()->start_time) {
  std::this_thread::sleep_for(std::chrono::milliseconds(1));
}

Here's where the real fun begins. Maybe you expected this part to be difficult but the logic here is actually rather straight forward. We wait for the 'start time' of the current object, we hold the key, we wait for the 'end time' and then we release the key. After we've released the key we advance to the next object and that continues until we reach the end of the beatmap.

hitobject& object = active_beatmap.hitobjects.at(current_object);
  
while (current_object < active_beatmap.hitobjects.size()) {
  static bool key_down = false;
  time = get_elapsed_time();

  // hold key
  if (time >= (object.start_time - 5) && !key_down) {
    set_key_pressed('z', true);
    key_down = true;

    continue;
  }
  
  // release key
  if (time > object.end_time && key_down) {
    set_key_pressed('z', false);
    key_down = false;

    current_object++;
    object = active_beatmap.hitobjects.at(current_object);
  }

  std::this_thread::sleep_for(std::chrono::milliseconds(1));
}

Note that I've subtracted five milliseconds from the start time, this is sort of a magic number and your mileage may vary with it. I wasn't able to hit everything consistently without adding this. I also add two milliseconds to the hit circle 'end time' in the beatmap class. Since circles don't need to be held we want to release them as soon as possible, if we release it too quickly the input might be ignored so the additional 2ms is there as a product of my limited testing.

Well, with that out of the way we're ready to compile and test!

Demonstration

I found it was easiest to test with the AutoPilot modifier. Unlike Relax, this modifier moves the cursor but you still have to press the keys. It's sort of a match made in heaven for our program. You might need to tweak the game offset for optimal results.

Live demonstration on deltaMAX.

I've also uploaded a second demonstration video on YouTube. As always, the final product can be found over here. There's a lot to expand on here, right now it will always hit as perfectly as it possibly can, so if you're slightly late you're probably going to end up missing. Consider adding some kind of hit detection based on your cursor position.

If you want to get really ambitious try implementing cursor movement too. I would've given it a shot but time was short and the math behind those beizer slider curves isn't something I want to subject myself to any time soon.

Last updated Thursday, 24 February 2022 at 10:44 AM.