├── .gitignore ├── README.md ├── beatmap.py ├── calc.py ├── diff_calc.py └── pp_calc.py /.gitignore: -------------------------------------------------------------------------------- 1 | keys.cfg 2 | *.pyc 3 | .DS_Store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # osu-calc 2 | osu! calc is a performance point(PP) calculator for the game osu! This program only works on standard beatmaps. Most of this code is based on the C++ version located [here](https://github.com/Francesco149/oppai). 3 | 4 | # Usage 5 | 6 | This program is written in python and requires version 3. There are several supported arguments. 7 | 8 | * -l `link` (Link to a .osu beatmap, for example https://osu.ppy.sh/osu/1262832) 9 | * -acc `% acc` 10 | * -c100 `# of 100s` 11 | * -c50 `# of 50s` 12 | * -m `# of misses` 13 | * -sv `score version 1 or 2` 14 | * -mods `string of mods` 15 | 16 | Examples: 17 | ```python 18 | python calc.py map.osu 19 | python calc.py map.osu -mods HDDTHR 20 | python calc.py -l https://osu.ppy.sh/b/994495 21 | ``` 22 | -------------------------------------------------------------------------------- /beatmap.py: -------------------------------------------------------------------------------- 1 | import math 2 | import sys, traceback 3 | class Beatmap: 4 | def __init__(self, file): 5 | self.searchfile = file 6 | self.main() 7 | def main(self): 8 | 9 | ## Setting init beatmap values 10 | # Metadata 11 | self.title = None 12 | self.artist = None 13 | self.creator = None 14 | self.version = None 15 | 16 | # difficulty 17 | self.hp = 0 18 | self.cs = 0 19 | self.od = 0 20 | self.ar = 0 21 | self.sv = 0 22 | self.tick_rate = 1 23 | self.speed = 1 24 | 25 | # Combo 26 | self.num_circles = 0 27 | self.num_sliders = 0 28 | self.num_spinners = 0 29 | self.max_combo = 0 30 | self.num_objects = 0 31 | 32 | # Slider data 33 | class slider_data: 34 | def __init__(self, s_type, points, repeats, length): 35 | self.s_type = s_type 36 | self.points = points 37 | self.repeats = repeats 38 | self.length = length 39 | 40 | # Hit Object 41 | # 1 = Circle 42 | # 2 = Slider 43 | # 3 = Spinner 44 | self.objects = [] 45 | ho_num = 0 46 | class hit_object: 47 | def __init__(self,pos,time,h_type,end_time,slider): 48 | self.pos = pos 49 | self.time = time 50 | self.h_type = h_type 51 | self.end_time = end_time 52 | self.slider = slider 53 | 54 | 55 | # Timing points 56 | self.timing_points = [] 57 | tp_num = 0 58 | class timing_point: 59 | def __init__(self,time,ms_per_beat,inherit): 60 | self.time = time 61 | self.ms_per_beat = ms_per_beat 62 | self.inherit = inherit 63 | 64 | # Some init variables 65 | tp_sec = False 66 | ho_time = False 67 | valid = False 68 | 69 | # Gathering Metadata 70 | def metadata(self,line): 71 | if "Title:" in line: 72 | self.title = line.split("Title:")[1].split("\r")[0].split("\n")[0] 73 | #print "Title: "+self.title 74 | elif "Artist:" in line: 75 | self.artist = line.split("Artist:")[1].split("\r")[0].split("\n")[0] 76 | #print "Artist: "+self.artist 77 | elif "Creator:" in line: 78 | self.creator = line.split("Creator:")[1].split("\r")[0].split("\n")[0] 79 | #print "Mapper: "+self.creator 80 | elif "Version:" in line: 81 | self.version = line.split("Version:")[1].split("\r")[0].split("\n")[0] 82 | #print "Dfifficulty: "+self.version 83 | # Gather difficulty -> remember to check for exceptions 84 | def difficulty(self,line): 85 | if "HPDrainRate:" in line: 86 | self.hp = float(line.split(":")[1].split("\n")[0]) 87 | #print "HP: "+str(self.hp) 88 | elif "CircleSize:" in line: 89 | self.cs = float(line.split(":")[1].split("\n")[0]) 90 | #print "CS: "+str(self.cs) 91 | elif "OverallDifficulty:" in line: 92 | self.ar = float(line.split(":")[1].split("\n")[0]) 93 | self.od = float(line.split(":")[1].split("\n")[0]) 94 | #print "OD: "+str(self.od) 95 | elif "ApproachRate:" in line: 96 | self.ar = float(line.split(":")[1].split("\n")[0]) 97 | #print "AR: "+str(self.ar) 98 | elif "SliderMultiplier:" in line: 99 | self.sv = float(line.split(":")[1].split("\n")[0]) 100 | #print "SV: "+str(self.sv) 101 | elif "SliderTickRate:" in line: 102 | self.tick_rate = float(line.split(":")[1].split("\n")[0]) 103 | #print "TR: "+str(self.tick_rate) 104 | 105 | # Parse the tp object 106 | def tp_ptr(self,line): 107 | temp_tp = line.split("\r")[0].split("\n")[0].split(",") 108 | 109 | if temp_tp[0] != '': 110 | if len(temp_tp) < 3: 111 | self.timing_points.append(timing_point(temp_tp[0],temp_tp[1],0)) 112 | else: 113 | self.timing_points.append(timing_point(temp_tp[0],temp_tp[1],temp_tp[6])) 114 | #print timing_points[tp_num].ms_per_beat 115 | 116 | # Parse the HO. This may take a while 117 | def ho_ptr(self,line): 118 | # Start to global stuff. Need to learn more about this 119 | # Split commas for each line which should be a hit object 120 | temp_tp = line.split("\r")[0].split("\n")[0].split(",") 121 | # Only if the line is not null do something 122 | if temp_tp[0] != '': 123 | # Set variables to send to hit object 124 | pos = [temp_tp[0],temp_tp[1]] 125 | time = temp_tp[2] 126 | h_type = temp_tp[3] 127 | end_time = 0 128 | slider = 0 129 | slider_true = 0 130 | if len(line.split("|")) > 1: 131 | slider_true = 1 132 | 133 | #Circle type 134 | if h_type == "1" or h_type == "5" or (slider_true == 0 and int(h_type) > 12): 135 | self.num_circles += 1 136 | h_type = 1 137 | #Slider type. Need to do some more math on sliders 138 | elif h_type == "2" or h_type == "6" or slider_true: 139 | #print "Found slider beginning analysis..." 140 | self.num_sliders += 1 141 | h_type = 2 142 | pos_s = [] 143 | # split into pipeline for slider logic 144 | sl_line = line.split("\r")[0].split("\n")[0].split("|") 145 | sl_type = sl_line[0][len(sl_line[0])-1] 146 | sl_line = sl_line[1:] 147 | counter = 0 148 | # add first slider point 149 | pos_s.append(pos) 150 | # iterate line for the rest of the slider points 151 | l_pos = None 152 | for l_pos in sl_line: 153 | pos_s.append([l_pos.split(":")[0],l_pos.split(":")[1].split(",")[0]]) 154 | if len(l_pos.split(",")) > 2: 155 | break 156 | if l_pos: 157 | repeats = float(l_pos.split(",")[1]) 158 | length = float(l_pos.split(",")[2]) 159 | else: 160 | self.num_circles += 1 161 | h_type = 1 162 | self.num_objects += 1 163 | self.max_combo += 1 164 | self.objects.append(hit_object(pos,time,h_type,end_time,slider)) 165 | return 166 | #print "Repeats: "+repeats+" Length: "+length+" Points: " 167 | #print pos_s 168 | time_p = self.timing_points[0] 169 | parent = self.timing_points[0] 170 | # Get timing point 171 | for tp in self.timing_points: 172 | if float(tp.time) > float(time): 173 | break 174 | time_p = tp 175 | # Get the parent point 176 | for tp in self.timing_points: 177 | if int(tp.inherit) == 1: 178 | parent = tp 179 | if tp == time_p: 180 | break 181 | # Begin to calculte the amount of ticks for max combo 182 | sv_mult = 1 183 | if time_p.inherit == "0" and float(tp.ms_per_beat) < 0: 184 | sv_mult = (-100.0 / float(time_p.ms_per_beat)) 185 | px_per_beat = self.sv * 100.0 * sv_mult 186 | num_beats = (length * repeats) / px_per_beat 187 | duration = math.ceil(num_beats * float(parent.ms_per_beat)) 188 | end_time = float(time) + duration 189 | slider = slider_data(sl_type,pos_s,repeats,length) 190 | ticks = math.ceil((num_beats - 0.1) / repeats * self.tick_rate) 191 | ticks -= 1 192 | raw_ticks = ticks 193 | ticks *= repeats 194 | ticks += repeats + 1 195 | self.max_combo += ticks - 1 196 | 197 | 198 | 199 | 200 | #Spinner type. 201 | elif h_type == "8" or h_type == "12": 202 | self.num_spinners += 1 203 | h_type = 3 204 | else: 205 | print("HELP "+h_type) 206 | self.num_objects += 1 207 | self.max_combo += 1 208 | self.objects.append(hit_object(pos,time,h_type,end_time,slider)) 209 | 210 | # Begin to parse beatmap 211 | try: 212 | for line in self.searchfile: 213 | # Gather metadata 214 | metadata(self,line) 215 | # Gather Difficulty information 216 | difficulty(self,line) 217 | #print "AR: "+str(self.ar) 218 | if ho_time: 219 | ho_ptr(self,line) 220 | ho_num += 1 221 | if "[HitObjects]" in line: 222 | ho_time = True 223 | if "osu file format v" in line: 224 | valid = True 225 | if "Mode: 1" in line or "Mode: 2" in line or "Mode: 3" in line: 226 | valid = False 227 | # Section for timing points 228 | if tp_sec: 229 | tp_ptr(self,line) 230 | tp_num += 1 231 | if "[TimingPoints]" in line: 232 | tp_sec = True 233 | if tp_sec and (line == "\n" or line == "\r\n" or line == ""): 234 | tp_sec = False 235 | #print "Circles: "+str(self.num_circles)+" Sliders: "+str(self.num_sliders)+" Spinners: "+str(self.num_spinners) 236 | #print "Max combo: "+str(self.max_combo) 237 | if valid != True: 238 | print("ERROR: Unsupported gamemode") 239 | raise() 240 | except: 241 | print("ERROR: Processing beatmap failed") 242 | sys.exit(1) 243 | def apply_mods(self,mods): 244 | # Ugly shouldput somewhere else 245 | od0_ms = 79.5 246 | od10_ms = 19.5 247 | ar0_ms = 1800 248 | ar5_ms = 1200 249 | ar10_ms = 450 250 | 251 | od_ms_step = 6.0 252 | ar_ms_step1 = 120.0 253 | ar_ms_step2 = 150.0 254 | 255 | if mods.map_changing == 0: 256 | return 257 | 258 | speed = 1 259 | 260 | if mods.dt or mods.nc: 261 | speed *= 1.5 262 | 263 | if mods.ht: 264 | speed *= 0.75 265 | 266 | self.speed = speed 267 | 268 | od_multiplier = 1 269 | 270 | if mods.hr: 271 | od_multiplier *= 1.4 272 | 273 | if mods.ez: 274 | od_multiplier *= 0.5 275 | 276 | self.od *= od_multiplier 277 | odms = od0_ms - math.ceil(od_ms_step * self.od) 278 | 279 | ar_multiplier = 1 280 | 281 | if mods.hr: 282 | ar_multiplier = 1.4 283 | 284 | if mods.ez: 285 | ar_multiplier = 0.5 286 | 287 | self.ar *= ar_multiplier 288 | 289 | arms = (ar0_ms - ar_ms_step1 * self.ar) if self.ar <= 5 else (ar5_ms - ar_ms_step2 * (self.ar-5)) 290 | 291 | cs_multipier = 1 292 | 293 | if mods.hr: 294 | cs_multipier = 1.3 295 | 296 | if mods.ez: 297 | cs_multipier = 0.5 298 | 299 | odms = min(od0_ms, max(od10_ms,odms)) 300 | arms = min(ar0_ms,max(ar10_ms,arms)) 301 | 302 | odms /= speed 303 | arms /= speed 304 | 305 | self.od = (od0_ms - odms) / od_ms_step 306 | 307 | self.ar = ((ar0_ms - arms) / ar_ms_step1) if self.ar<= 5.0 else (5.0 + (ar5_ms - arms) / ar_ms_step2) 308 | self.cs *= cs_multipier 309 | self.cs = max(0.0,min(10.0,self.cs)) 310 | 311 | if mods.speed_changing == 0: 312 | return 313 | 314 | for tp in self.timing_points: 315 | tp.time = float(tp.time) 316 | if int(tp.inherit) == 0: 317 | tp.ms_per_beat = float(tp.ms_per_beat) 318 | 319 | 320 | for obj in self.objects: 321 | obj.time = float(obj.time) 322 | obj.end_time = obj.end_time 323 | 324 | -------------------------------------------------------------------------------- /calc.py: -------------------------------------------------------------------------------- 1 | import diff_calc 2 | import requests 3 | import pp_calc 4 | import sys 5 | import argparse 6 | import configparser 7 | from beatmap import Beatmap 8 | parser = argparse.ArgumentParser() 9 | feature = False 10 | mod_s = None 11 | c100 = 0 12 | c50 = 0 13 | misses = 0 14 | sv = 1 15 | acc = 0 16 | combo = 0 17 | parser.add_argument('file', help='File or url. If url provided use -l flag') 18 | parser.add_argument('-l', help='Flag if url provided', action='store_true') 19 | parser.add_argument('-acc', help='Accuracy', metavar="acc%", default=0) 20 | parser.add_argument('-c100', help='Number of 100s', metavar="100s", default=0) 21 | parser.add_argument('-c50', help='Number of 50s', metavar="50s", default=0) 22 | parser.add_argument('-m', help='Number of misses', metavar="miss", default=0, dest='misses') 23 | parser.add_argument('-c', help='Max combo', metavar="combo", default=0, dest='combo') 24 | parser.add_argument('-sv', help='Score version 1 or 2', metavar="sv", default=1) 25 | parser.add_argument('-mods', help='Mod string eg. HDDT', metavar="mods", default="") 26 | args = parser.parse_args() 27 | c100 = int(args.c100) 28 | c50 = int(args.c50) 29 | misses = int(args.misses) 30 | combo = int(args.combo) 31 | acc = float(args.acc) 32 | sv = int(args.sv) 33 | mod_s = args.mods 34 | feature = args.l 35 | file_name = None 36 | 37 | 38 | try: 39 | file_name = args.file 40 | if feature: 41 | file = requests.get(file_name).text.splitlines() 42 | else: 43 | file = open(file_name) 44 | except: 45 | print("ERROR: "+file_name + " not a valid beatmap file or URL") 46 | sys.exit(1) 47 | map = Beatmap(file) 48 | if combo == 0 or combo > map.max_combo: 49 | combo = map.max_combo 50 | 51 | 52 | def mod_str(mod): 53 | string = "" 54 | if mod.nf: 55 | string += "NF" 56 | if mod.ez: 57 | string += "EZ" 58 | if mod.hd: 59 | string += "HD" 60 | if mod.hr: 61 | string += "HR" 62 | if mod.dt: 63 | string += "DT" 64 | if mod.ht: 65 | string += "HT" 66 | if mod.nc: 67 | string += "NC" 68 | if mod.fl: 69 | string += "FL" 70 | if mod.so: 71 | string += "SO" 72 | if mod.td: 73 | string += "TD" 74 | return string 75 | 76 | class mods: 77 | def __init__(self): 78 | self.nomod = 0, 79 | self.nf = 0 80 | self.ez = 0 81 | self.hd = 0 82 | self.hr = 0 83 | self.dt = 0 84 | self.ht = 0 85 | self.nc = 0 86 | self.fl = 0 87 | self.so = 0 88 | self.td = 0 89 | self.speed_changing = self.dt | self.ht | self.nc 90 | self.map_changing = self.hr | self.ez | self.speed_changing 91 | def update(self): 92 | self.speed_changing = self.dt | self.ht | self.nc 93 | self.map_changing = self.hr | self.ez | self.speed_changing 94 | mod = mods() 95 | 96 | def set_mods(mod, m): 97 | if m == "NF": 98 | mod.nf = 1 99 | if m == "EZ": 100 | mod.ez = 1 101 | if m == "HD": 102 | mod.hd = 1 103 | if m == "HR": 104 | mod.hr = 1 105 | if m == "DT": 106 | mod.dt = 1 107 | if m == "HT": 108 | mod.ht = 1 109 | if m == "NC": 110 | mod.nc = 1 111 | if m == "FL": 112 | mod.fl = 1 113 | if m == "SO": 114 | mod.so = 1 115 | if m == "TD": 116 | mod.td = 1 117 | 118 | if mod_s != "": 119 | mod_s = mod_s.upper() 120 | mod_s = [mod_s[i:i+2] for i in range(0, len(mod_s), 2)] 121 | for m in mod_s: 122 | set_mods(mod, m) 123 | mod.update() 124 | 125 | mod_string = mod_str(mod) 126 | map.apply_mods(mod) 127 | diff = diff_calc.main(map) 128 | if acc == 0: 129 | pp = pp_calc.pp_calc(diff[0], diff[1], diff[3], misses, c100, c50, mod, combo,sv) 130 | else: 131 | pp = pp_calc.pp_calc_acc(diff[0], diff[1], diff[3], acc, mod, combo, misses,sv) 132 | title = map.artist + " - "+map.title + "["+map.version+"]" 133 | if mod_string != "": 134 | title += "+" + mod_string 135 | title += " (" + map.creator + ")" 136 | print("Map: " + title) 137 | print("AR: " + str(round(map.ar, 2)) + " CS: " + str(round(map.cs,2)) + " OD: " + str(round(map.od,2))) 138 | print("Aim:", round(diff[0],2), "Speed:",round(diff[1],2)) 139 | print("Stars: "+str(round(diff[2], 2))) 140 | print("Acc: "+str(round(pp.acc_percent, 2)) + "%") 141 | comb_s = "Combo: "+str(int(combo)) + "/" + str(int(map.max_combo)) 142 | if misses != 0: 143 | comb_s += " with " + str(misses) + " misses" 144 | print(comb_s) 145 | print("Performance: "+str(round(pp.pp, 2)) + "PP") 146 | -------------------------------------------------------------------------------- /diff_calc.py: -------------------------------------------------------------------------------- 1 | import math 2 | def main(file): 3 | map = file 4 | objects = [] 5 | radius = (512 / 16) * (1. - 0.7 * (map.cs - 5) / 5); 6 | class consts: 7 | decay_base = [0.3,0.15] 8 | 9 | almost_diameter = 90 10 | 11 | aim_angle_bonus_begin = math.pi / 3; 12 | speed_angle_bonus_begin = 5 * math.pi / 6; 13 | timing_threshold = 107; 14 | 15 | stream_spacing = 110 16 | single_spacing = 125 17 | 18 | min_speed_bonus = 75 # 200bpm 19 | max_speed_bonus = 45 # 330bpm 20 | speed_balancing_factor = 40 21 | 22 | weight_scaling = [1400,26.25] 23 | 24 | circlesize_buff_threshhold = 30 25 | 26 | class d_obj: 27 | def __init__(self,base_object, radius,prev): 28 | self.radius = float(radius) 29 | self.ho = base_object 30 | self.strains = [0, 0] 31 | self.norm_start = 0 32 | self.norm_end = 0 33 | self.prev = prev 34 | self.delta_time = 0 35 | # We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps. 36 | self.scaling_factor = 52.0 / self.radius 37 | if self.radius < consts.circlesize_buff_threshhold: 38 | self.scaling_factor *= 1 + min((consts.circlesize_buff_threshhold - self.radius), 5) / 50.0 39 | self.norm_start = [float(self.ho.pos[0]) * self.scaling_factor,float(self.ho.pos[1])*self.scaling_factor] 40 | self.norm_end = self.norm_start 41 | self.jump_distance = 0 42 | self.angle = None 43 | self.travel_distance = 0 44 | # Calculate jump distance for objects 45 | if((self.ho.h_type == 1 or self.ho.h_type == 2) and prev != None ): 46 | self.jump_distance = math.sqrt(math.pow(self.norm_start[0] - prev.norm_end[0],2) + math.pow(self.norm_start[1] - prev.norm_end[1],2)) 47 | # Not working, need to figure out how sliders work 48 | if(self.ho.h_type == 2): 49 | self.comp_slider_pos() 50 | if(prev != None and prev.prev != None): 51 | # Calculate angle with lastlast last and base object 52 | v1 = [prev.prev.norm_start[0] - prev.norm_start[0], prev.prev.norm_start[1] - prev.norm_start[1]] 53 | v2 = [self.norm_start[0] - prev.norm_start[0], self.norm_start[1] - prev.norm_start[1]] 54 | dot = v1[0]*v2[0] + v1[1]*v2[1] 55 | det = v1[0]*v2[1] - v1[1]*v2[0] 56 | self.angle = abs(math.atan2(det,dot)) 57 | if(prev != None): 58 | self.delta_time = (int(self.ho.time) - int(prev.ho.time)) / map.speed 59 | if(self.ho.h_type != 3): 60 | # Calculate speed 61 | self.strains[0] = prev.strains[0]*math.pow(consts.decay_base[0],self.delta_time / 1000.0) + self.calculate_speed(prev)*consts.weight_scaling[0] 62 | # Calculate aim 63 | self.strains[1] = prev.strains[1]*math.pow(consts.decay_base[1],self.delta_time / 1000.0) + self.calculate_aim(prev)*consts.weight_scaling[1] 64 | 65 | # needs work. Do not understand how sliders work 66 | def comp_slider_pos(self): 67 | approx_rad = self.radius * 3 68 | if self.ho.slider.length > approx_rad: 69 | self.travel_distance = self.ho.slider.length 70 | 71 | 72 | # Calculate aim strain 73 | def calculate_aim(self,prev): 74 | result = 0 75 | strain_time = max(50,self.delta_time) 76 | prev_strain_time = max(50,prev.delta_time) 77 | if(prev != None): 78 | if(self.angle != None and self.angle > consts.aim_angle_bonus_begin): 79 | scale = 90 80 | angle_bonus = math.sqrt(max(prev.jump_distance - scale,0) * math.pow(math.sin(self.angle - consts.aim_angle_bonus_begin),2) * max(self.jump_distance - scale,0)) 81 | result = 1.5 * math.pow(max(0,angle_bonus),0.99) / max(consts.timing_threshold, prev_strain_time) 82 | jump_dist_exp = math.pow(self.jump_distance,0.99) 83 | travel_dist_exp = 0 84 | return max(result + jump_dist_exp / max(strain_time, consts.timing_threshold), jump_dist_exp / strain_time) 85 | 86 | # Calculate speed strain 87 | def calculate_speed(self,prev): 88 | distance = min(consts.single_spacing, self.jump_distance) 89 | strain_time = max(50,self.delta_time) 90 | delta_time = max(consts.max_speed_bonus,self.delta_time) 91 | speed_bonus = 1.0 92 | if(delta_time < consts.min_speed_bonus): 93 | speed_bonus = 1 + math.pow((consts.min_speed_bonus - delta_time) / consts.speed_balancing_factor,2) 94 | angle_bonus = 1.0 95 | if(self.angle != None and self.angle < consts.speed_angle_bonus_begin): 96 | angle_bonus = 1 + math.pow(math.sin(1.5 * (self.angle - consts.speed_angle_bonus_begin)),2) / 3.57 97 | if(self.angle < math.pi / 2): 98 | angle_bonus = 1.28 99 | if(distance < 90 and self.angle < math.pi / 4): 100 | angle_bonus += (1 - angle_bonus)*min((90 - distance) / 10, 1) 101 | elif (distance < 90): 102 | angle_bonus += (1 - angle_bonus)*min((90 - distance) / 10,1) * math.sin(((math.pi / 2 )- self.angle) / (math.pi / 4)) 103 | return (1 + (speed_bonus - 1)*0.75) * angle_bonus * (0.95 + speed_bonus*math.pow(distance / consts.single_spacing,3.5)) / strain_time 104 | 105 | def calculate_difficulty(type, objects): 106 | strain_step = 400 * map.speed 107 | prev = None 108 | max_strain = 0 109 | decay_weight = 0.9 110 | highest_strains = [] 111 | interval_end = math.ceil(float(map.objects[0].time) / strain_step) * strain_step 112 | for obj in objects: 113 | while int(obj.ho.time) > interval_end: 114 | highest_strains.append(max_strain) 115 | if prev == None: 116 | max_strain = 0 117 | else: 118 | decay = math.pow(consts.decay_base[type],(interval_end - int(prev.ho.time)) / 1000.0) 119 | max_strain = prev.strains[type] * decay 120 | interval_end += strain_step 121 | prev = obj 122 | max_strain = max(obj.strains[type],max_strain) 123 | highest_strains.append(max_strain) 124 | difficulty = 0 125 | weight = 1.0 126 | highest_strains = sorted(highest_strains, reverse = True) 127 | for strain in highest_strains: 128 | difficulty += weight * strain 129 | weight *= decay_weight 130 | return difficulty 131 | star_scaling_factor = 0.0675 132 | extreme_scaling_factor = 0.5 133 | prev = None 134 | for obj in map.objects: 135 | new = d_obj(obj, radius,prev) 136 | objects.append(new) 137 | prev = new 138 | aim = calculate_difficulty(1, objects) 139 | speed = calculate_difficulty(0, objects) 140 | aim = math.sqrt(aim) * star_scaling_factor 141 | speed = math.sqrt(speed) * star_scaling_factor 142 | stars = aim + speed + abs(speed-aim) * extreme_scaling_factor 143 | return [aim,speed,stars, map] -------------------------------------------------------------------------------- /pp_calc.py: -------------------------------------------------------------------------------- 1 | import math 2 | import diff_calc 3 | class mods: 4 | def __init__(self): 5 | self.nomod = 1, 6 | self.nf = 0 7 | self.ez = 0 8 | self.hd = 0 9 | self.hr = 0 10 | self.dt = 0 11 | self.ht = 0 12 | self.nc = 0 13 | self.fl = 0 14 | self.so = 0 15 | speed_changing = self.dt | self.ht | self.nc 16 | map_changing = self.hr | self.ez | speed_changing 17 | 18 | def base_strain(strain): 19 | return math.pow(5.0 * max(1.0, strain / 0.0675) - 4.0, 3.0) / 100000.0 20 | 21 | def acc_calc(c300, c100, c50, misses): 22 | total_hits = c300 + c100 + c50 + misses 23 | acc = 0.0 24 | if total_hits > 0: 25 | acc = (c50 * 50.0 + c100 * 100.0 + c300 * 300.0) / (total_hits * 300.0) 26 | return acc 27 | 28 | class pp_calc_result: 29 | def __init__(self): 30 | self.acc_percent = 0 31 | self.pp = 0 32 | self.aim_pp = 0 33 | self.speed_pp = 0 34 | self.acc_pp = 0 35 | 36 | def pp_calc(aim, speed, b, misses, c100, c50, used_mods = mods() ,combo = 0xFFFF, score_version = 1, c300 = 0xFFFF): 37 | res = pp_calc_result() 38 | od = b.od 39 | ar = b.ar 40 | circles = b.num_circles 41 | 42 | if c100 > b.num_objects or c50 > b.num_objects or misses > b.num_objects: 43 | print("Invalid accuracy number") 44 | return res 45 | 46 | if c300 == 0xFFFF: 47 | c300 = b.num_objects - c100 - c50 - misses 48 | 49 | if combo == 0xFFFF: 50 | combo = b.max_combo 51 | elif combo == 0: 52 | print("Invalid combo count") 53 | return res 54 | 55 | total_hits = c300 + c100 + c50 + misses 56 | if total_hits != b.num_objects: 57 | print("warning hits != objects") 58 | 59 | if score_version != 1 and score_version != 2: 60 | print("Score version not found") 61 | return res 62 | 63 | acc = acc_calc(c300,c100,c50,misses) 64 | res.acc_percent = acc * 100.0 65 | 66 | if used_mods.td: 67 | aim = math.pow(aim, 0.8) 68 | 69 | aim_value = base_strain(aim) 70 | 71 | total_hits_over_2k = total_hits / 2000.0 72 | length_bonus = 0.95 + 0.4 * min(1.0, total_hits_over_2k) + (math.log10(total_hits_over_2k) * 0.5 if total_hits > 2000 else 0.0) 73 | 74 | miss_penalty = math.pow(0.97,misses) 75 | 76 | combo_break = math.pow(combo, 0.8) / math.pow(b.max_combo,0.8) 77 | 78 | aim_value *= length_bonus 79 | aim_value *= miss_penalty 80 | aim_value *= combo_break 81 | ar_bonus = 1.0 82 | 83 | if ar > 10.33: 84 | ar_bonus += 0.3 * (ar - 10.33) 85 | elif ar < 8: 86 | ar_bonus += 0.01*(8.0 - ar) 87 | 88 | 89 | 90 | aim_value *= ar_bonus 91 | hd_bonus = 1.0 92 | if used_mods.hd: 93 | hd_bonus = 1.0 + 0.04*(12 - ar) 94 | aim_value *= hd_bonus 95 | 96 | if used_mods.fl: 97 | aim_value *= 1.0 + 0.35 * min(1.0,total_hits / 200.0) + ((0.3 * min(1,(total_hits - 200) / 300.0) + ((total_hits - 500) / 1200.0 if total_hits > 500 else 0)) if total_hits > 200 else 0) 98 | 99 | acc_bonus = 0.5 + acc / 2.0 100 | 101 | od_bonus = 0.98 + math.pow(od,2) / 2500.0 102 | 103 | aim_value *= acc_bonus 104 | aim_value *= od_bonus 105 | 106 | res.aim_pp = aim_value 107 | 108 | speed_value = base_strain(speed) 109 | 110 | speed_value *= length_bonus 111 | speed_value *= miss_penalty 112 | speed_value *= combo_break 113 | if(ar > 10.33): 114 | speed_value *= ar_bonus 115 | speed_value *= hd_bonus 116 | speed_value *= 0.02 + acc 117 | speed_value *= 0.96 + (math.pow(od, 2) / 1600) 118 | 119 | res.speed_pp = speed_value 120 | 121 | real_acc = 0.0 122 | 123 | if score_version == 2: 124 | circles = total_hits 125 | real_acc = acc 126 | else: 127 | if circles: 128 | real_acc = ((c300 - (total_hits - circles)) * 300.0 + c100 * 100.0 + c50 * 50.0) / (circles * 300) 129 | real_acc = max(0.0,real_acc) 130 | 131 | acc_value = math.pow(1.52163, od) * math.pow(real_acc, 24.0) * 2.83 132 | 133 | acc_value *= min(1.15, math.pow(circles / 1000.0, 0.3)) 134 | 135 | if used_mods.hd: 136 | acc_value *= 1.08 137 | 138 | if used_mods.fl: 139 | acc_value *= 1.02 140 | 141 | res.acc_pp = acc_value 142 | 143 | final_multiplier = 1.12 144 | 145 | if used_mods.nf: 146 | final_multiplier *= 0.90 147 | 148 | if used_mods.so: 149 | final_multiplier *= 0.95 150 | res.pp = math.pow(math.pow(aim_value,1.1) + math.pow(speed_value,1.1) + math.pow(acc_value, 1.1), 1.0 / 1.1) * final_multiplier 151 | return res; 152 | 153 | def pp_calc_acc(aim, speed, b, acc_percent, used_mods = mods(), combo = 0xFFFF, misses = 0,score_version = 1): 154 | misses = min(b.num_objects,misses) 155 | 156 | max300 = (b.num_objects - misses) 157 | 158 | acc_percent = max(0.0, min(acc_calc(max300, 0, 0, misses) * 100.0, acc_percent)) 159 | 160 | c50 = 0 161 | 162 | c100 = round(-3.0 * ((acc_percent * 0.01 - 1.0) * b.num_objects + misses) * 0.5) 163 | 164 | if c100 > b.num_objects - misses: 165 | c100 = 0 166 | c50 = round(-6.0 * ((acc_percent * 0.01 - 1.0) * b.num_objects + misses) * 0.2); 167 | 168 | c50 = min(max300, c50) 169 | else: 170 | c100 = min(max300, c100) 171 | 172 | c300 = b.num_objects - c100 - c50 - misses 173 | 174 | return pp_calc(aim,speed,b,misses,c100,c50,used_mods, combo, score_version,c300) 175 | --------------------------------------------------------------------------------