Quintec writeups and other thoughts

Let’s Play Osu!Mania

Let’s Play Osu!Mania was a programming challenge in SekaiCTF 2022 where you were given an input file that represented an osu!mania beatmap and had to determine how many objects there were in the beatmap. Here’s a sample input file:

13
|-- -|
| #  |
| #- |
| #  |
| - -|
|-   |
| - -|
|    |
|  --|
|- # |
| -# |
|  # |
|  - |

As an avid osu! player and mapper as well as a (former) competitive programmer, this challenge seemed right up my alley. I quickly grasped and appreciated the intricacies of the problem - as a seasoned programmer, I knew the challenge was clearly too difficult to manually compute. I realized that the title, “Let’s PLAY Osu!Mania”, must be a hint - we can use osu! itself to do the calculation for us! I came up with a solution path:

  • Parse the input file and figure out where every object is

  • Write those objects in .osu beatmap format into a .osu beatmap file

  • Package the .osu beatmap file into an importable .osz

  • Open the .osz and have osu! process the beatmap

  • Read off the object count from osu!’s GUI

Let’s get started. Parsing the input file is easy:

n = int(input())
arr = []
for i in range(n):
    arr.append(list(input()))

Easy enough. Next we have to actually write the .osu beatmap file. The osu! wiki was very helpful here, telling us everything we need. With that in mind, I created a beatmap header and functions to create the actual objects.

beatmap = """osu file format v14

[General]
AudioFilename: audio.mp3
AudioLeadIn: 0
PreviewTime: -1
Countdown: 0
SampleSet: Normal
StackLeniency: 0.7
Mode: 3
LetterboxInBreaks: 0
SpecialStyle: 0
WidescreenStoryboard: 0

[Editor]
DistanceSpacing: 0.8
BeatDivisor: 4
GridSize: 8
TimelineZoom: 1

[Metadata]
Title:CTF
TitleUnicode:CTF
Artist:Sekai
ArtistUnicode:Sekai
Creator:QuintecX
Version:a
Source:
Tags:
BeatmapID:0
BeatmapSetID:-1

[Difficulty]
HPDrainRate:5
CircleSize:4
OverallDifficulty:5
ApproachRate:5
SliderMultiplier:1.4
SliderTickRate:1

[Events]
//Background and Video events
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Layer 4 (Overlay)
//Storyboard Sound Samples

[TimingPoints]
0,500,4,1,0,100,1,0

[HitObjects]
"""

def circle(beat, col):
    x = [64, 192, 320, 448][col - 1]
    return f"{x},192,{beat},1,0,0:0:0:0:\n"
    return hstr

def hold(beat, col, duration):
    x = [64, 192, 320, 448][col - 1]
    time = duration * 250
    return f"{x},192,{beat},128,0,{beat+time}:0:0:0:0:\n"

def time_cmp(obj1, obj2):
    t1, t2 = int(obj1.split(',')[2]), int(obj2.split(',')[2])
    if t1 < t2:
        return -1
    elif t1 == t2:
        return 0
    else:
        return 1

Now to actually parse the objects - just loop through the columns. In the end, since we went column by column instead of timestep by timestep, we’ll have to sort the objects in the right order:

def time_cmp(obj1, obj2):
    t1, t2 = int(obj1.split(',')[2]), int(obj2.split(',')[2])
    if t1 < t2:
        return -1
    elif t1 == t2:
        return 0
    else:
        return 1

def column(matrix, i):
    return [row[i] for row in matrix]

notes = []

for i in range(1, 5):
    col = column(arr, i)
    h = False
    c = False
    hold_len = 0
    beat = 0
    for char in col:
        if char == '-':
            if h:
                notes.append(hold(beat - 250 * (hold_len + 1), i, hold_len + 1))
                hold_len = 0
            else:
                c = True
            h = False
        elif char == '#':
            h = True
            c = False
            hold_len += 1
        elif c:
            c = False
            notes.append(circle(beat - 250, i))
        beat += 250
beatmap += ''.join(sorted(notes, key=cmp_to_key(time_cmp)))

Now we just have to package the beatmap with a random audio file lying around on my computer, and then we can open it in osu!

with open('Sekai - CTF (QuintecX) [a].osu', 'w') as f:
    f.write(beatmap)

zipObj = ZipFile('sekaictf.osz', 'w')
zipObj.write('audio.mp3') # every self respecting osu! player has an Freedom Dive audio.mp3 in every single folder
zipObj.write('Sekai - CTF (QuintecX) [a].osu')
zipObj.close()

At this point, if you open the sekaictf.osz file, you should get something that looks like this:

As you can see, osu! has done all the hard work of computing the answer, and we merely have to grab it from the top left corner of the screen. That’s easy enough - we just take a screen grab and use PyTesseract!

os.startfile('sekaictf.osz')  
time.sleep(15) #my computer is a potato

mon = {'top': 0, 'left': 0, 'width': 414, 'height': 180}
with mss.mss() as sct:
    im = numpy.asarray(sct.grab(mon))
    text = pytesseract.image_to_string(im)
    print(text.split("Objects: ")[1].split()[0])

Putting it all together with the test input file:

PS C:\Users\[redacted]\Downloads> cat .\1.in | py .\osumania.py
12

Beautiful. Unfortunately, for some odd reason, this solution did not work on the Online Judge server. Apparently it didn’t have osu! installed? I could not believe this large oversight on behalf of the organizers. My hard-earned CTF points vanished before my eyes, and all I have left is this writeup. You can find the final code below:

from functools import cmp_to_key
from zipfile import ZipFile
import os, cv2, pytesseract, mss, numpy, subprocess, time

pytesseract.pytesseract.tesseract_cmd = "C:\\Program Files\\Tesseract-OCR\\tesseract.exe"

beatmap = """osu file format v14

[General]
AudioFilename: audio.mp3
AudioLeadIn: 0
PreviewTime: -1
Countdown: 0
SampleSet: Normal
StackLeniency: 0.7
Mode: 3
LetterboxInBreaks: 0
SpecialStyle: 0
WidescreenStoryboard: 0

[Editor]
DistanceSpacing: 0.8
BeatDivisor: 4
GridSize: 8
TimelineZoom: 1

[Metadata]
Title:CTF
TitleUnicode:CTF
Artist:Sekai
ArtistUnicode:Sekai
Creator:QuintecX
Version:a
Source:
Tags:
BeatmapID:0
BeatmapSetID:-1

[Difficulty]
HPDrainRate:5
CircleSize:4
OverallDifficulty:5
ApproachRate:5
SliderMultiplier:1.4
SliderTickRate:1

[Events]
//Background and Video events
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Layer 4 (Overlay)
//Storyboard Sound Samples

[TimingPoints]
0,500,4,1,0,100,1,0

[HitObjects]
"""

def circle(beat, col):
    x = [64, 192, 320, 448][col - 1]
    return f"{x},192,{beat},1,0,0:0:0:0:\n"
    return hstr

def hold(beat, col, duration):
    x = [64, 192, 320, 448][col - 1]
    time = duration * 250
    return f"{x},192,{beat},128,0,{beat+time}:0:0:0:0:\n"

def time_cmp(obj1, obj2):
    t1, t2 = int(obj1.split(',')[2]), int(obj2.split(',')[2])
    if t1 < t2:
        return -1
    elif t1 == t2:
        return 0
    else:
        return 1


n = int(input())
arr = []
for i in range(n):
    arr.append(list(input()))

def column(matrix, i):
    return [row[i] for row in matrix]

notes = []

for i in range(1, 5):
    col = column(arr, i)
    h = False
    c = False
    hold_len = 0
    beat = 0
    for char in col:
        if char == '-':
            if h:
                notes.append(hold(beat - 250 * (hold_len + 1), i, hold_len + 1))
                hold_len = 0
            else:
                c = True
            h = False
        elif char == '#':
            h = True
            c = False
            hold_len += 1
        elif c:
            c = False
            notes.append(circle(beat - 250, i))
        beat += 250
beatmap += ''.join(sorted(notes, key=cmp_to_key(time_cmp)))

with open('Sekai - CTF (QuintecX) [a].osu', 'w') as f:
    f.write(beatmap)

zipObj = ZipFile('sekaictf.osz', 'w')
zipObj.write('audio.mp3') # every self respecting osu! player has an Freedom Dive audio.mp3 in every single folder
zipObj.write('Sekai - CTF (QuintecX) [a].osu')
zipObj.close()

os.startfile('sekaictf.osz')  
time.sleep(15) #my computer is a potato

mon = {'top': 0, 'left': 0, 'width': 414, 'height': 180}
with mss.mss() as sct:
    im = numpy.asarray(sct.grab(mon))
    text = pytesseract.image_to_string(im)
    print(text.split("Objects: ")[1].split()[0])