diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 00000000..b6045568 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,5 @@ +disallowed-methods = [ + { path = "f64::round", reason = "C# uses f64::round_ties_even"}, + { path = "std::cmp::Ord::min", reason = "too easy to misread `x.min(y)`; use `cmp::min` instead" }, + { path = "std::cmp::Ord::max", reason = "too easy to misread `x.max(y)`; use `cmp::max` instead" }, +] diff --git a/resources/1638954.osu b/resources/1638954.osu index f58c08d7..24df55fd 100644 --- a/resources/1638954.osu +++ b/resources/1638954.osu @@ -1,649 +1,649 @@ -osu file format v14 - -[General] -AudioFilename: audio.mp3 -AudioLeadIn: 0 -PreviewTime: 9356 -Countdown: 0 -SampleSet: Soft -StackLeniency: 0.7 -Mode: 3 -LetterboxInBreaks: 0 -SpecialStyle: 0 -WidescreenStoryboard: 1 - -[Editor] -DistanceSpacing: 0.9 -BeatDivisor: 12 -GridSize: 4 -TimelineZoom: 2.899999 - -[Metadata] -Title:Future Son -TitleUnicode:Future Son -Artist:ARCIEN -ArtistUnicode:ARCIEN -Creator:AncuL -Version:Hard -Source: -Tags:edm electronic dance music bass -BeatmapID:1638954 -BeatmapSetID:777881 - -[Difficulty] -HPDrainRate:8 -CircleSize:4 -OverallDifficulty:8 -ApproachRate:5 -SliderMultiplier:1.4 -SliderTickRate:1 - -[Events] -//Background and Video events -0,0,"Konachan.com_-_198887_animal_barefoot_blonde_hair_bloodborne_doll_fish_flowers_goth-loli_hat_headdre (2).jpg",0,0 -//Break Periods -//Storyboard Layer 0 (Background) -//Storyboard Layer 1 (Fail) -//Storyboard Layer 2 (Pass) -//Storyboard Layer 3 (Foreground) -//Storyboard Sound Samples - -[TimingPoints] -23,400,4,2,1,75,1,0 - - -[HitObjects] -320,192,23,5,4,0:0:0:95: -64,192,23,1,0,0:0:0:0: -448,192,23,1,0,0:0:0:0: -192,192,148,1,0,0:0:0:0: -448,192,198,128,0,1223:0:0:0:0: -192,192,1223,128,0,3223:0:0:0:0: -320,192,3223,1,0,0:0:0:0: -64,192,3289,128,0,4423:0:0:0:0: -320,192,4423,128,0,6423:0:0:0:0: -192,192,6423,1,0,0:0:0:0: -64,192,6548,1,0,0:0:0:0: -448,192,6598,128,0,7623:0:0:0:0: -320,192,7623,128,0,9623:0:0:0:0: -448,192,9623,1,0,0:0:0:0: -64,192,9689,128,0,10823:0:0:0:0: -192,192,10823,128,0,11623:0:0:0:0: -320,192,11623,128,0,11823:0:0:0:0: -192,192,11823,128,0,12023:0:0:0:0: -448,192,12023,128,0,12223:0:0:0:0: -64,192,12223,128,0,12423:0:0:0:0: -320,192,12423,128,0,12623:0:0:0:0: -192,192,12623,128,0,12823:0:0:0:0: -64,192,12823,1,0,0:0:0:95:Kick.wav -320,192,12823,1,4,0:0:0:95: -448,192,12823,1,0,0:0:0:0: -192,192,12923,1,0,0:0:0:0: -320,192,13023,1,0,0:0:0:0: -64,192,13023,1,0,0:0:0:0: -448,192,13123,1,0,0:0:0:0: -192,192,13223,1,0,0:0:0:0: -64,192,13223,1,0,0:0:0:0: -448,192,13323,1,0,0:0:0:0: -192,192,13423,1,0,0:0:0:0: -320,192,13423,1,0,0:0:0:0: -64,192,13523,1,0,0:0:0:0: -320,192,13623,1,0,0:0:0:85:Snare.wav -192,192,13623,1,0,0:0:0:0: -448,192,13623,128,0,14023:0:0:0:0: -192,192,14023,1,4,0:0:0:95: -64,192,14023,1,0,0:0:0:95:Kick.wav -320,192,14023,1,0,0:0:0:0: -448,192,14123,1,0,0:0:0:0: -320,192,14223,1,0,0:0:0:0: -64,192,14223,1,0,0:0:0:0: -448,192,14323,1,0,0:0:0:0: -192,192,14423,1,0,0:0:0:0: -64,192,14423,1,0,0:0:0:0: -320,192,14523,1,0,0:0:0:0: -448,192,14623,1,0,0:0:0:0: -192,192,14623,1,0,0:0:0:0: -64,192,14723,1,0,0:0:0:0: -320,192,14823,1,0,0:0:0:0: -448,192,14823,1,0,0:0:0:0: -64,192,14923,1,0,0:0:0:0: -192,192,15023,1,0,0:0:0:0: -320,192,15023,1,0,0:0:0:0: -448,192,15123,1,0,0:0:0:0: -320,192,15223,1,0,0:0:0:85:Snare.wav -64,192,15223,1,0,0:0:0:0: -192,192,15223,128,0,15623:0:0:0:0: -64,192,15623,128,0,15723:0:0:0:0: -320,192,15623,128,0,15723:0:0:0:0: -64,192,15823,128,0,15923:0:0:0:0: -448,192,15823,128,0,15923:0:0:0:0: -448,192,16023,1,4,0:0:0:95: -192,192,16023,1,0,0:0:0:95:Kick.wav -320,192,16023,1,0,0:0:0:0: -64,192,16123,1,0,0:0:0:0: -192,192,16223,1,0,0:0:0:0: -448,192,16223,1,0,0:0:0:0: -320,192,16323,1,0,0:0:0:0: -192,192,16423,1,0,0:0:0:0: -64,192,16423,1,0,0:0:0:0: -448,192,16523,1,0,0:0:0:0: -320,192,16623,1,0,0:0:0:0: -64,192,16623,1,0,0:0:0:0: -192,192,16723,1,0,0:0:0:0: -320,192,16823,1,0,0:0:0:85:Snare.wav -448,192,16823,1,0,0:0:0:0: -64,192,16823,1,0,0:0:0:0: -320,192,17223,1,4,0:0:0:95: -192,192,17223,1,0,0:0:0:95:Kick.wav -64,192,17223,1,0,0:0:0:0: -448,192,17323,1,0,0:0:0:0: -320,192,17423,1,0,0:0:0:0: -64,192,17423,1,0,0:0:0:0: -448,192,17523,1,0,0:0:0:0: -192,192,17623,1,0,0:0:0:0: -64,192,17623,1,0,0:0:0:0: -320,192,17723,1,0,0:0:0:0: -448,192,17823,1,0,0:0:0:0: -192,192,17823,1,0,0:0:0:0: -64,192,17923,1,0,0:0:0:0: -448,192,18023,1,0,0:0:0:0: -320,192,18023,1,0,0:0:0:0: -64,192,18123,1,0,0:0:0:0: -192,192,18223,1,0,0:0:0:0: -448,192,18223,1,0,0:0:0:0: -64,192,18323,1,0,0:0:0:0: -448,192,18423,1,0,0:0:0:85:Snare.wav -192,192,18423,1,0,0:0:0:0: -320,192,18423,128,0,18623:0:0:0:0: -192,192,18623,128,0,18823:0:0:0:0: -448,192,18823,128,0,18923:0:0:0:0: -64,192,18823,128,0,18923:0:0:0:0: -64,192,19023,128,0,19123:0:0:0:0: -192,192,19023,128,0,19123:0:0:0:0: -448,192,19223,1,4,0:0:0:95: -320,192,19223,1,0,0:0:0:95:Kick.wav -64,192,19223,1,0,0:0:0:0: -192,192,19323,1,0,0:0:0:0: -448,192,19423,1,0,0:0:0:0: -64,192,19423,1,0,0:0:0:0: -320,192,19523,1,0,0:0:0:0: -192,192,19623,1,0,0:0:0:0: -64,192,19623,1,0,0:0:0:0: -448,192,19723,1,0,0:0:0:0: -320,192,19823,1,0,0:0:0:0: -192,192,19823,1,0,0:0:0:0: -448,192,19923,1,0,0:0:0:0: -320,192,20023,1,0,0:0:0:85:Snare.wav -192,192,20023,1,0,0:0:0:0: -64,192,20023,1,0,0:0:0:0: -448,192,20223,1,0,0:0:0:95:Kick.wav -192,192,20223,1,0,0:0:0:0: -448,192,20423,1,4,0:0:0:95: -320,192,20423,1,0,0:0:0:95:Kick.wav -64,192,20423,1,0,0:0:0:0: -192,192,20523,1,0,0:0:0:0: -64,192,20623,1,0,0:0:0:0: -320,192,20623,1,0,0:0:0:0: -448,192,20723,1,0,0:0:0:0: -320,192,20823,1,0,0:0:0:0: -192,192,20823,1,0,0:0:0:0: -448,192,20923,1,0,0:0:0:0: -320,192,21023,1,0,0:0:0:0: -64,192,21023,1,0,0:0:0:0: -192,192,21123,1,0,0:0:0:0: -448,192,21223,1,0,0:0:0:0: -64,192,21223,128,0,21623:0:0:0:0: -320,192,21423,1,0,0:0:0:0: -320,192,21623,1,0,0:0:0:85:Snare.wav -448,192,21623,1,0,0:0:0:0: -192,192,21623,1,0,0:0:0:0: -192,192,22023,128,0,22123:0:0:0:0: -64,192,22023,128,0,22123:0:0:0:0: -192,192,22223,128,0,22323:0:0:0:0: -448,192,22223,128,0,22323:0:0:0:0: -448,192,22423,1,4,0:0:0:95: -320,192,22423,128,0,23023:0:0:0:95:Kick.wav -64,192,22423,1,0,0:0:0:0: -192,192,22623,1,0,0:0:0:0: -64,192,22823,1,0,0:0:0:0: -448,192,23023,128,0,23623:0:0:0:0: -192,192,23023,1,0,0:0:0:0: -192,192,23223,1,0,0:0:0:85:Snare.wav -64,192,23223,1,0,0:0:0:0: -320,192,23223,1,0,0:0:0:0: -64,192,23623,128,4,24823:0:0:0:95: -192,192,23623,1,0,0:0:0:95:Kick.wav -320,192,23623,1,0,0:0:0:0: -448,192,23823,1,0,0:0:0:0: -320,192,24023,1,0,0:0:0:0: -448,192,24223,1,0,0:0:0:0: -192,192,24423,1,0,0:0:0:0: -320,192,24623,1,0,0:0:0:0: -320,192,24823,1,0,0:0:0:85:Snare.wav -192,192,24823,1,0,0:0:0:0: -448,192,24823,128,0,25023:0:0:0:0: -320,192,25023,128,0,25223:0:0:0:0: -192,192,25223,128,0,25323:0:0:0:0: -448,192,25223,128,0,25323:0:0:0:0: -448,192,25423,128,0,25523:0:0:0:0: -64,192,25423,128,0,25523:0:0:0:0: -64,192,25623,1,0,0:0:0:95:Kick.wav -192,192,25623,1,4,0:0:0:95: -448,192,25623,1,0,0:0:0:0: -320,192,25723,1,0,0:0:0:0: -448,192,25823,1,0,0:0:0:0: -192,192,25823,1,0,0:0:0:0: -64,192,25923,1,0,0:0:0:0: -320,192,26023,1,0,0:0:0:0: -192,192,26023,1,0,0:0:0:0: -448,192,26123,1,0,0:0:0:0: -192,192,26223,1,0,0:0:0:0: -64,192,26223,1,0,0:0:0:0: -320,192,26323,1,0,0:0:0:0: -448,192,26423,1,0,0:0:0:85:Snare.wav -64,192,26423,1,0,0:0:0:0: -192,192,26423,128,0,26823:0:0:0:0: -320,192,26823,1,4,0:0:0:95: -64,192,26823,1,0,0:0:0:95:Kick.wav -448,192,26823,1,0,0:0:0:0: -192,192,26923,1,0,0:0:0:0: -448,192,27023,1,0,0:0:0:0: -320,192,27023,1,0,0:0:0:0: -64,192,27123,1,0,0:0:0:0: -192,192,27223,1,0,0:0:0:0: -320,192,27223,1,0,0:0:0:0: -448,192,27323,1,0,0:0:0:0: -320,192,27423,1,0,0:0:0:0: -192,192,27423,1,0,0:0:0:0: -64,192,27523,1,0,0:0:0:0: -192,192,27623,1,0,0:0:0:0: -448,192,27623,1,0,0:0:0:0: -320,192,27723,1,0,0:0:0:0: -192,192,27823,1,0,0:0:0:0: -64,192,27823,1,0,0:0:0:0: -320,192,27923,1,0,0:0:0:0: -192,192,28023,1,0,0:0:0:85:Snare.wav -64,192,28023,1,0,0:0:0:0: -448,192,28023,128,0,28423:0:0:0:0: -64,192,28423,128,0,28523:0:0:0:0: -320,192,28423,128,0,28523:0:0:0:0: -448,192,28623,128,0,28723:0:0:0:0: -64,192,28623,128,0,28723:0:0:0:0: -448,192,28823,1,4,0:0:0:95: -64,192,28823,1,0,0:0:0:95:Kick.wav -192,192,28823,1,0,0:0:0:0: -320,192,28923,1,0,0:0:0:0: -192,192,29023,1,0,0:0:0:0: -64,192,29023,1,0,0:0:0:0: -448,192,29123,1,0,0:0:0:0: -320,192,29223,1,0,0:0:0:0: -64,192,29223,1,0,0:0:0:0: -192,192,29323,1,0,0:0:0:0: -320,192,29423,1,0,0:0:0:0: -448,192,29423,1,0,0:0:0:0: -64,192,29523,1,0,0:0:0:0: -192,192,29623,1,0,0:0:0:85:Snare.wav -448,192,29623,1,0,0:0:0:0: -320,192,29623,1,0,0:0:0:0: -320,192,30023,1,4,0:0:0:95: -448,192,30023,1,0,0:0:0:95:Kick.wav -64,192,30023,1,0,0:0:0:0: -192,192,30123,1,0,0:0:0:0: -448,192,30223,1,0,0:0:0:0: -64,192,30223,1,0,0:0:0:0: -192,192,30323,1,0,0:0:0:0: -320,192,30423,1,0,0:0:0:0: -448,192,30423,1,0,0:0:0:0: -64,192,30523,1,0,0:0:0:0: -448,192,30623,1,0,0:0:0:0: -192,192,30623,1,0,0:0:0:0: -320,192,30723,1,0,0:0:0:0: -192,192,30823,1,0,0:0:0:0: -64,192,30823,1,0,0:0:0:0: -320,192,30923,1,0,0:0:0:0: -192,192,31023,1,0,0:0:0:0: -448,192,31023,1,0,0:0:0:0: -64,192,31123,1,0,0:0:0:0: -448,192,31223,1,0,0:0:0:85:Snare.wav -192,192,31223,1,0,0:0:0:0: -320,192,31223,128,0,31423:0:0:0:0: -192,192,31423,128,0,31623:0:0:0:0: -64,192,31623,128,0,31723:0:0:0:0: -320,192,31623,128,0,31723:0:0:0:0: -320,192,31823,128,0,31923:0:0:0:0: -192,192,31823,128,0,31923:0:0:0:0: -448,192,32023,1,4,0:0:0:95: -320,192,32023,1,0,0:0:0:95:Kick.wav -64,192,32023,1,0,0:0:0:0: -192,192,32123,1,0,0:0:0:0: -448,192,32223,1,0,0:0:0:0: -64,192,32223,1,0,0:0:0:0: -320,192,32323,1,0,0:0:0:0: -192,192,32423,1,0,0:0:0:0: -64,192,32423,1,0,0:0:0:0: -448,192,32523,1,0,0:0:0:0: -320,192,32623,1,0,0:0:0:0: -192,192,32623,1,0,0:0:0:0: -64,192,32723,1,0,0:0:0:0: -320,192,32823,1,0,0:0:0:85:Snare.wav -448,192,32823,1,0,0:0:0:0: -192,192,32823,1,0,0:0:0:0: -192,192,33023,1,0,0:0:0:95:Kick.wav -64,192,33023,1,0,0:0:0:0: -448,192,33223,1,4,0:0:0:95: -320,192,33223,1,0,0:0:0:95:Kick.wav -64,192,33223,1,0,0:0:0:0: -192,192,33323,1,0,0:0:0:0: -320,192,33423,1,0,0:0:0:0: -64,192,33423,1,0,0:0:0:0: -448,192,33523,1,0,0:0:0:0: -64,192,33623,1,0,0:0:0:0: -320,192,33623,1,0,0:0:0:0: -192,192,33723,1,0,0:0:0:0: -320,192,33823,1,0,0:0:0:0: -448,192,33823,1,0,0:0:0:0: -64,192,33923,1,0,0:0:0:0: -320,192,34023,128,0,34423:0:0:0:0: -192,192,34023,1,0,0:0:0:0: -448,192,34223,1,0,0:0:0:0: -448,192,34423,1,0,0:0:0:85:Snare.wav -192,192,34423,1,0,0:0:0:0: -64,192,34423,1,0,0:0:0:0: -192,192,34823,128,0,34923:0:0:0:0: -64,192,34823,128,0,34923:0:0:0:0: -448,192,34923,1,0,0:0:0:0: -64,192,35023,128,0,35123:0:0:0:0: -320,192,35023,128,0,35123:0:0:0:0: -192,192,35223,128,4,35823:0:0:0:95: -320,192,35223,1,0,0:0:0:95:Kick.wav -64,192,35223,1,0,0:0:0:0: -448,192,35423,1,0,0:0:0:0: -320,192,35623,1,0,0:0:0:0: -448,192,35823,128,0,36423:0:0:0:0: -64,192,35823,1,0,0:0:0:0: -64,192,36023,1,0,0:0:0:85:Snare.wav -192,192,36023,1,0,0:0:0:0: -320,192,36023,1,0,0:0:0:0: -64,192,36423,128,4,37623:0:0:0:95: -192,192,36423,1,0,0:0:0:95:Kick.wav -320,192,36423,1,0,0:0:0:0: -448,192,36623,1,0,0:0:0:0: -192,192,36823,1,0,0:0:0:0: -320,192,37023,1,0,0:0:0:0: -192,192,37223,1,0,0:0:0:0: -448,192,37423,1,0,0:0:0:0: -192,192,37623,1,0,0:0:0:85:Snare.wav -320,192,37623,1,0,0:0:0:0: -448,192,37623,128,0,37823:0:0:0:0: -320,192,37823,128,0,38023:0:0:0:0: -192,192,38023,128,0,38123:0:0:0:0: -448,192,38023,128,0,38123:0:0:0:0: -192,192,38223,128,0,38323:0:0:0:0: -64,192,38223,128,0,38323:0:0:0:0: -64,192,38423,1,0,0:0:0:95:Kick.wav -320,192,38423,1,4,0:0:0:95: -448,192,38423,1,0,0:0:0:0: -192,192,38523,1,0,0:0:0:0: -320,192,38623,1,0,0:0:0:0: -64,192,38623,1,0,0:0:0:0: -64,192,38823,1,0,0:0:0:0: -320,192,38823,1,0,0:0:0:0: -64,192,39023,1,0,0:0:0:0: -320,192,39023,1,0,0:0:0:0: -64,192,39223,1,0,0:0:0:85:Snare.wav -192,192,39223,1,0,0:0:0:0: -448,192,39223,128,0,39623:0:0:0:0: -320,192,39623,1,4,0:0:0:95: -192,192,39623,1,0,0:0:0:95:Kick.wav -64,192,39623,1,0,0:0:0:0: -448,192,39723,1,0,0:0:0:0: -320,192,39823,1,0,0:0:0:0: -192,192,39823,1,0,0:0:0:0: -192,192,40023,1,0,0:0:0:0: -320,192,40023,1,0,0:0:0:0: -192,192,40223,1,0,0:0:0:0: -320,192,40223,1,0,0:0:0:0: -192,192,40423,1,0,0:0:0:0: -320,192,40423,1,0,0:0:0:0: -192,192,40623,1,0,0:0:0:0: -320,192,40623,1,0,0:0:0:0: -448,192,40823,1,0,0:0:0:85:Snare.wav -192,192,40823,1,0,0:0:0:0: -64,192,40823,128,0,41223:0:0:0:0: -192,192,41223,128,0,41323:0:0:0:0: -320,192,41223,128,0,41323:0:0:0:0: -320,192,41423,128,0,41523:0:0:0:0: -448,192,41423,128,0,41523:0:0:0:0: -192,192,41623,1,4,0:0:0:95: -448,192,41623,1,0,0:0:0:95:Kick.wav -320,192,41623,1,0,0:0:0:0: -64,192,41723,1,0,0:0:0:0: -192,192,41823,1,0,0:0:0:0: -448,192,41823,1,0,0:0:0:0: -192,192,42023,1,0,0:0:0:0: -448,192,42023,1,0,0:0:0:0: -192,192,42223,1,0,0:0:0:0: -448,192,42223,1,0,0:0:0:0: -448,192,42423,1,0,0:0:0:85:Snare.wav -64,192,42423,1,0,0:0:0:0: -320,192,42423,1,0,0:0:0:0: -448,192,42823,1,4,0:0:0:95: -192,192,42823,1,0,0:0:0:95:Kick.wav -64,192,42823,1,0,0:0:0:0: -320,192,42923,1,0,0:0:0:0: -64,192,43023,1,0,0:0:0:0: -448,192,43023,1,0,0:0:0:0: -448,192,43223,1,0,0:0:0:0: -64,192,43223,1,0,0:0:0:0: -64,192,43423,1,0,0:0:0:0: -448,192,43423,1,0,0:0:0:0: -448,192,43623,1,0,0:0:0:0: -64,192,43623,1,0,0:0:0:0: -64,192,43823,1,0,0:0:0:0: -448,192,43823,1,0,0:0:0:0: -64,192,44023,1,0,0:0:0:85:Snare.wav -192,192,44023,1,0,0:0:0:0: -320,192,44023,128,0,44223:0:0:0:0: -192,192,44223,128,0,44423:0:0:0:0: -64,192,44423,128,0,44523:0:0:0:0: -320,192,44423,128,0,44523:0:0:0:0: -448,192,44623,128,0,44723:0:0:0:0: -320,192,44623,128,0,44723:0:0:0:0: -448,192,44823,1,4,0:0:0:95: -64,192,44823,1,0,0:0:0:95:Kick.wav -320,192,44823,1,0,0:0:0:0: -192,192,44923,1,0,0:0:0:0: -448,192,45023,1,0,0:0:0:0: -64,192,45023,1,0,0:0:0:0: -64,192,45223,1,0,0:0:0:0: -448,192,45223,1,0,0:0:0:0: -448,192,45423,1,0,0:0:0:0: -64,192,45423,1,0,0:0:0:0: -64,192,45623,1,0,0:0:0:85:Snare.wav -320,192,45623,1,0,0:0:0:0: -192,192,45623,1,0,0:0:0:0: -448,192,45823,1,0,0:0:0:95:Kick.wav -192,192,45823,1,0,0:0:0:0: -448,192,46023,1,4,0:0:0:95: -320,192,46023,1,0,0:0:0:95:Kick.wav -64,192,46023,1,0,0:0:0:0: -192,192,46123,1,0,0:0:0:0: -320,192,46223,1,0,0:0:0:0: -64,192,46223,1,0,0:0:0:0: -64,192,46423,1,0,0:0:0:0: -320,192,46423,1,0,0:0:0:0: -64,192,46623,1,0,0:0:0:0: -320,192,46623,1,0,0:0:0:0: -320,192,46823,1,0,0:0:0:0: -64,192,46823,1,0,0:0:0:0: -64,192,47023,1,0,0:0:0:0: -320,192,47023,1,0,0:0:0:0: -320,192,47223,1,0,0:0:0:85:Snare.wav -192,192,47223,1,0,0:0:0:0: -448,192,47223,1,0,0:0:0:0: -192,192,47623,128,0,47723:0:0:0:0: -448,192,47623,128,0,47723:0:0:0:0: -192,192,47823,128,0,47923:0:0:0:0: -320,192,47823,128,0,47923:0:0:0:0: -320,192,48023,1,4,0:0:0:95: -448,192,48023,1,0,0:0:0:95:Kick.wav -64,192,48023,128,0,48623:0:0:0:0: -192,192,48223,1,0,0:0:0:0: -192,192,48423,1,0,0:0:0:0: -448,192,48623,128,0,49223:0:0:0:0: -320,192,48623,1,0,0:0:0:0: -320,192,48823,1,0,0:0:0:85:Snare.wav -192,192,48823,1,0,0:0:0:0: -64,192,48823,1,0,0:0:0:0: -192,192,49223,128,4,50423:0:0:0:95: -320,192,49223,1,0,0:0:0:95:Kick.wav -64,192,49223,1,0,0:0:0:0: -448,192,49423,1,0,0:0:0:0: -448,192,49623,1,0,0:0:0:0: -320,192,49823,1,0,0:0:0:0: -320,192,50023,1,0,0:0:0:0: -64,192,50223,1,0,0:0:0:0: -64,192,50423,1,0,0:0:0:85:Snare.wav -320,192,50423,1,0,0:0:0:0: -448,192,50423,128,0,50623:0:0:0:0: -320,192,50623,128,0,50823:0:0:0:0: -192,192,50823,128,0,50923:0:0:0:0: -448,192,50823,128,0,50923:0:0:0:0: -64,192,51023,128,0,51123:0:0:0:0: -192,192,51023,128,0,51123:0:0:0:0: -64,192,51223,1,0,0:0:0:95:Kick.wav -320,192,51223,1,4,0:0:0:95: -448,192,51223,1,0,0:0:0:0: -192,192,51323,1,0,0:0:0:0: -448,192,51423,1,0,0:0:0:0: -64,192,51423,1,0,0:0:0:0: -320,192,51623,1,0,0:0:0:0: -64,192,51623,128,0,51823:0:0:0:0: -320,192,51823,1,0,0:0:0:0: -448,192,51823,1,0,0:0:0:0: -192,192,52023,1,0,0:0:0:85:Snare.wav -64,192,52023,1,0,0:0:0:0: -448,192,52023,128,0,52423:0:0:0:0: -320,192,52423,1,4,0:0:0:95: -192,192,52423,1,0,0:0:0:95:Kick.wav -64,192,52423,1,0,0:0:0:0: -448,192,52523,1,0,0:0:0:0: -320,192,52623,1,0,0:0:0:0: -64,192,52623,1,0,0:0:0:0: -64,192,52823,1,0,0:0:0:0: -448,192,52823,1,0,0:0:0:0: -448,192,53023,1,0,0:0:0:0: -192,192,53023,1,0,0:0:0:0: -320,192,53223,1,0,0:0:0:0: -192,192,53223,128,0,53423:0:0:0:0: -320,192,53423,1,0,0:0:0:0: -448,192,53423,1,0,0:0:0:0: -448,192,53623,1,0,0:0:0:85:Snare.wav -192,192,53623,1,0,0:0:0:0: -64,192,53623,1,0,0:0:0:0: -320,192,53756,1,0,0:0:0:0: -192,192,53889,1,0,0:0:0:0: -320,192,54023,128,0,54123:0:0:0:0: -448,192,54023,128,0,54123:0:0:0:0: -448,192,54223,128,0,54323:0:0:0:0: -64,192,54223,128,0,54323:0:0:0:0: -448,192,54423,1,4,0:0:0:95: -320,192,54423,1,0,0:0:0:95:Kick.wav -64,192,54423,1,0,0:0:0:0: -192,192,54523,1,0,0:0:0:0: -448,192,54623,1,0,0:0:0:0: -320,192,54623,1,0,0:0:0:0: -64,192,54823,1,0,0:0:0:0: -448,192,54823,128,0,55023:0:0:0:0: -64,192,55023,1,0,0:0:0:0: -320,192,55023,1,0,0:0:0:0: -192,192,55223,1,0,0:0:0:85:Snare.wav -320,192,55223,1,0,0:0:0:0: -448,192,55223,1,0,0:0:0:0: -448,192,55623,1,4,0:0:0:95: -192,192,55623,1,0,0:0:0:95:Kick.wav -64,192,55623,1,0,0:0:0:0: -320,192,55723,1,0,0:0:0:0: -64,192,55823,1,0,0:0:0:0: -448,192,55823,1,0,0:0:0:0: -448,192,56023,1,0,0:0:0:0: -192,192,56023,1,0,0:0:0:0: -192,192,56223,1,0,0:0:0:0: -64,192,56223,1,0,0:0:0:0: -320,192,56423,1,0,0:0:0:0: -64,192,56423,128,0,56623:0:0:0:0: -320,192,56623,1,0,0:0:0:0: -448,192,56623,1,0,0:0:0:0: -192,192,56823,1,0,0:0:0:85:Snare.wav -64,192,56823,1,0,0:0:0:0: -448,192,56823,128,0,57023:0:0:0:0: -320,192,57023,128,0,57223:0:0:0:0: -192,192,57223,128,0,57323:0:0:0:0: -64,192,57223,128,0,57323:0:0:0:0: -64,192,57423,128,0,57523:0:0:0:0: -448,192,57423,128,0,57523:0:0:0:0: -64,192,57623,1,4,0:0:0:95: -320,192,57623,1,0,0:0:0:95:Kick.wav -192,192,57623,1,0,0:0:0:0: -448,192,57723,1,0,0:0:0:0: -64,192,57823,1,0,0:0:0:0: -192,192,57823,1,0,0:0:0:0: -192,192,58023,1,0,0:0:0:0: -320,192,58023,128,0,58223:0:0:0:0: -64,192,58223,1,0,0:0:0:0: -448,192,58223,1,0,0:0:0:0: -320,192,58423,1,0,0:0:0:85:Snare.wav -192,192,58423,1,0,0:0:0:0: -448,192,58423,1,0,0:0:0:0: -192,192,58623,1,0,0:0:0:95:Kick.wav -64,192,58623,1,0,0:0:0:0: -64,192,58823,1,4,0:0:0:95: -448,192,58823,1,0,0:0:0:95:Kick.wav -320,192,58823,1,0,0:0:0:0: -192,192,58923,1,0,0:0:0:0: -448,192,59023,1,0,0:0:0:0: -64,192,59023,1,0,0:0:0:0: -320,192,59223,1,0,0:0:0:0: -448,192,59223,1,0,0:0:0:0: -320,192,59423,1,0,0:0:0:0: -64,192,59423,1,0,0:0:0:0: -448,192,59623,1,0,0:0:0:0: -64,192,59623,128,0,59823:0:0:0:0: -448,192,59823,1,0,0:0:0:0: -320,192,59823,1,0,0:0:0:0: -320,192,60023,1,0,0:0:0:85:Snare.wav -192,192,60023,1,0,0:0:0:0: -64,192,60023,1,0,0:0:0:0: -448,192,60423,128,0,60523:0:0:0:0: -192,192,60423,128,0,60523:0:0:0:0: -64,192,60623,128,0,60723:0:0:0:0: -192,192,60623,128,0,60723:0:0:0:0: -320,192,60823,128,4,61423:0:0:0:95: -448,192,60823,1,0,0:0:0:95:Kick.wav -64,192,60823,1,0,0:0:0:0: -192,192,61023,1,0,0:0:0:0: -192,192,61223,1,0,0:0:0:0: -448,192,61423,128,0,62023:0:0:0:0: -64,192,61423,1,0,0:0:0:0: -64,192,61623,1,0,0:0:0:85:Snare.wav -192,192,61623,1,0,0:0:0:0: -320,192,61623,1,0,0:0:0:0: -64,192,62023,128,4,63223:0:0:0:95: -192,192,62023,1,0,0:0:0:95:Kick.wav -320,192,62023,1,0,0:0:0:0: -448,192,62223,1,0,0:0:0:0: -448,192,62423,1,0,0:0:0:0: -192,192,62623,1,0,0:0:0:0: -192,192,62823,1,0,0:0:0:0: -320,192,63023,1,0,0:0:0:0: -192,192,63223,1,0,0:0:0:85:Snare.wav -448,192,63223,1,0,0:0:0:0: -320,192,63223,128,0,63423:0:0:0:0: -192,192,63423,128,0,63623:0:0:0:0: -64,192,63623,128,0,63723:0:0:0:0: -448,192,63623,128,0,63723:0:0:0:0: -320,192,63823,128,0,63923:0:0:0:0: -192,192,63823,128,0,63923:0:0:0:0: -448,192,64023,1,4,0:0:0:95: -320,192,64023,1,0,0:0:0:95:Kick.wav -64,192,64023,1,0,0:0:0:0: +osu file format v14 + +[General] +AudioFilename: audio.mp3 +AudioLeadIn: 0 +PreviewTime: 9356 +Countdown: 0 +SampleSet: Soft +StackLeniency: 0.7 +Mode: 3 +LetterboxInBreaks: 0 +SpecialStyle: 0 +WidescreenStoryboard: 1 + +[Editor] +DistanceSpacing: 0.9 +BeatDivisor: 12 +GridSize: 4 +TimelineZoom: 2.899999 + +[Metadata] +Title:Future Son +TitleUnicode:Future Son +Artist:ARCIEN +ArtistUnicode:ARCIEN +Creator:AncuL +Version:Hard +Source: +Tags:edm electronic dance music bass +BeatmapID:1638954 +BeatmapSetID:777881 + +[Difficulty] +HPDrainRate:8 +CircleSize:4 +OverallDifficulty:8 +ApproachRate:5 +SliderMultiplier:1.4 +SliderTickRate:1 + +[Events] +//Background and Video events +0,0,"Konachan.com_-_198887_animal_barefoot_blonde_hair_bloodborne_doll_fish_flowers_goth-loli_hat_headdre (2).jpg",0,0 +//Break Periods +//Storyboard Layer 0 (Background) +//Storyboard Layer 1 (Fail) +//Storyboard Layer 2 (Pass) +//Storyboard Layer 3 (Foreground) +//Storyboard Sound Samples + +[TimingPoints] +23,400,4,2,1,75,1,0 + + +[HitObjects] +320,192,23,5,4,0:0:0:95: +64,192,23,1,0,0:0:0:0: +448,192,23,1,0,0:0:0:0: +192,192,148,1,0,0:0:0:0: +448,192,198,128,0,1223:0:0:0:0: +192,192,1223,128,0,3223:0:0:0:0: +320,192,3223,1,0,0:0:0:0: +64,192,3289,128,0,4423:0:0:0:0: +320,192,4423,128,0,6423:0:0:0:0: +192,192,6423,1,0,0:0:0:0: +64,192,6548,1,0,0:0:0:0: +448,192,6598,128,0,7623:0:0:0:0: +320,192,7623,128,0,9623:0:0:0:0: +448,192,9623,1,0,0:0:0:0: +64,192,9689,128,0,10823:0:0:0:0: +192,192,10823,128,0,11623:0:0:0:0: +320,192,11623,128,0,11823:0:0:0:0: +192,192,11823,128,0,12023:0:0:0:0: +448,192,12023,128,0,12223:0:0:0:0: +64,192,12223,128,0,12423:0:0:0:0: +320,192,12423,128,0,12623:0:0:0:0: +192,192,12623,128,0,12823:0:0:0:0: +64,192,12823,1,0,0:0:0:95:Kick.wav +320,192,12823,1,4,0:0:0:95: +448,192,12823,1,0,0:0:0:0: +192,192,12923,1,0,0:0:0:0: +320,192,13023,1,0,0:0:0:0: +64,192,13023,1,0,0:0:0:0: +448,192,13123,1,0,0:0:0:0: +192,192,13223,1,0,0:0:0:0: +64,192,13223,1,0,0:0:0:0: +448,192,13323,1,0,0:0:0:0: +192,192,13423,1,0,0:0:0:0: +320,192,13423,1,0,0:0:0:0: +64,192,13523,1,0,0:0:0:0: +320,192,13623,1,0,0:0:0:85:Snare.wav +192,192,13623,1,0,0:0:0:0: +448,192,13623,128,0,14023:0:0:0:0: +192,192,14023,1,4,0:0:0:95: +64,192,14023,1,0,0:0:0:95:Kick.wav +320,192,14023,1,0,0:0:0:0: +448,192,14123,1,0,0:0:0:0: +320,192,14223,1,0,0:0:0:0: +64,192,14223,1,0,0:0:0:0: +448,192,14323,1,0,0:0:0:0: +192,192,14423,1,0,0:0:0:0: +64,192,14423,1,0,0:0:0:0: +320,192,14523,1,0,0:0:0:0: +448,192,14623,1,0,0:0:0:0: +192,192,14623,1,0,0:0:0:0: +64,192,14723,1,0,0:0:0:0: +320,192,14823,1,0,0:0:0:0: +448,192,14823,1,0,0:0:0:0: +64,192,14923,1,0,0:0:0:0: +192,192,15023,1,0,0:0:0:0: +320,192,15023,1,0,0:0:0:0: +448,192,15123,1,0,0:0:0:0: +320,192,15223,1,0,0:0:0:85:Snare.wav +64,192,15223,1,0,0:0:0:0: +192,192,15223,128,0,15623:0:0:0:0: +64,192,15623,128,0,15723:0:0:0:0: +320,192,15623,128,0,15723:0:0:0:0: +64,192,15823,128,0,15923:0:0:0:0: +448,192,15823,128,0,15923:0:0:0:0: +448,192,16023,1,4,0:0:0:95: +192,192,16023,1,0,0:0:0:95:Kick.wav +320,192,16023,1,0,0:0:0:0: +64,192,16123,1,0,0:0:0:0: +192,192,16223,1,0,0:0:0:0: +448,192,16223,1,0,0:0:0:0: +320,192,16323,1,0,0:0:0:0: +192,192,16423,1,0,0:0:0:0: +64,192,16423,1,0,0:0:0:0: +448,192,16523,1,0,0:0:0:0: +320,192,16623,1,0,0:0:0:0: +64,192,16623,1,0,0:0:0:0: +192,192,16723,1,0,0:0:0:0: +320,192,16823,1,0,0:0:0:85:Snare.wav +448,192,16823,1,0,0:0:0:0: +64,192,16823,1,0,0:0:0:0: +320,192,17223,1,4,0:0:0:95: +192,192,17223,1,0,0:0:0:95:Kick.wav +64,192,17223,1,0,0:0:0:0: +448,192,17323,1,0,0:0:0:0: +320,192,17423,1,0,0:0:0:0: +64,192,17423,1,0,0:0:0:0: +448,192,17523,1,0,0:0:0:0: +192,192,17623,1,0,0:0:0:0: +64,192,17623,1,0,0:0:0:0: +320,192,17723,1,0,0:0:0:0: +448,192,17823,1,0,0:0:0:0: +192,192,17823,1,0,0:0:0:0: +64,192,17923,1,0,0:0:0:0: +448,192,18023,1,0,0:0:0:0: +320,192,18023,1,0,0:0:0:0: +64,192,18123,1,0,0:0:0:0: +192,192,18223,1,0,0:0:0:0: +448,192,18223,1,0,0:0:0:0: +64,192,18323,1,0,0:0:0:0: +448,192,18423,1,0,0:0:0:85:Snare.wav +192,192,18423,1,0,0:0:0:0: +320,192,18423,128,0,18623:0:0:0:0: +192,192,18623,128,0,18823:0:0:0:0: +448,192,18823,128,0,18923:0:0:0:0: +64,192,18823,128,0,18923:0:0:0:0: +64,192,19023,128,0,19123:0:0:0:0: +192,192,19023,128,0,19123:0:0:0:0: +448,192,19223,1,4,0:0:0:95: +320,192,19223,1,0,0:0:0:95:Kick.wav +64,192,19223,1,0,0:0:0:0: +192,192,19323,1,0,0:0:0:0: +448,192,19423,1,0,0:0:0:0: +64,192,19423,1,0,0:0:0:0: +320,192,19523,1,0,0:0:0:0: +192,192,19623,1,0,0:0:0:0: +64,192,19623,1,0,0:0:0:0: +448,192,19723,1,0,0:0:0:0: +320,192,19823,1,0,0:0:0:0: +192,192,19823,1,0,0:0:0:0: +448,192,19923,1,0,0:0:0:0: +320,192,20023,1,0,0:0:0:85:Snare.wav +192,192,20023,1,0,0:0:0:0: +64,192,20023,1,0,0:0:0:0: +448,192,20223,1,0,0:0:0:95:Kick.wav +192,192,20223,1,0,0:0:0:0: +448,192,20423,1,4,0:0:0:95: +320,192,20423,1,0,0:0:0:95:Kick.wav +64,192,20423,1,0,0:0:0:0: +192,192,20523,1,0,0:0:0:0: +64,192,20623,1,0,0:0:0:0: +320,192,20623,1,0,0:0:0:0: +448,192,20723,1,0,0:0:0:0: +320,192,20823,1,0,0:0:0:0: +192,192,20823,1,0,0:0:0:0: +448,192,20923,1,0,0:0:0:0: +320,192,21023,1,0,0:0:0:0: +64,192,21023,1,0,0:0:0:0: +192,192,21123,1,0,0:0:0:0: +448,192,21223,1,0,0:0:0:0: +64,192,21223,128,0,21623:0:0:0:0: +320,192,21423,1,0,0:0:0:0: +320,192,21623,1,0,0:0:0:85:Snare.wav +448,192,21623,1,0,0:0:0:0: +192,192,21623,1,0,0:0:0:0: +192,192,22023,128,0,22123:0:0:0:0: +64,192,22023,128,0,22123:0:0:0:0: +192,192,22223,128,0,22323:0:0:0:0: +448,192,22223,128,0,22323:0:0:0:0: +448,192,22423,1,4,0:0:0:95: +320,192,22423,128,0,23023:0:0:0:95:Kick.wav +64,192,22423,1,0,0:0:0:0: +192,192,22623,1,0,0:0:0:0: +64,192,22823,1,0,0:0:0:0: +448,192,23023,128,0,23623:0:0:0:0: +192,192,23023,1,0,0:0:0:0: +192,192,23223,1,0,0:0:0:85:Snare.wav +64,192,23223,1,0,0:0:0:0: +320,192,23223,1,0,0:0:0:0: +64,192,23623,128,4,24823:0:0:0:95: +192,192,23623,1,0,0:0:0:95:Kick.wav +320,192,23623,1,0,0:0:0:0: +448,192,23823,1,0,0:0:0:0: +320,192,24023,1,0,0:0:0:0: +448,192,24223,1,0,0:0:0:0: +192,192,24423,1,0,0:0:0:0: +320,192,24623,1,0,0:0:0:0: +320,192,24823,1,0,0:0:0:85:Snare.wav +192,192,24823,1,0,0:0:0:0: +448,192,24823,128,0,25023:0:0:0:0: +320,192,25023,128,0,25223:0:0:0:0: +192,192,25223,128,0,25323:0:0:0:0: +448,192,25223,128,0,25323:0:0:0:0: +448,192,25423,128,0,25523:0:0:0:0: +64,192,25423,128,0,25523:0:0:0:0: +64,192,25623,1,0,0:0:0:95:Kick.wav +192,192,25623,1,4,0:0:0:95: +448,192,25623,1,0,0:0:0:0: +320,192,25723,1,0,0:0:0:0: +448,192,25823,1,0,0:0:0:0: +192,192,25823,1,0,0:0:0:0: +64,192,25923,1,0,0:0:0:0: +320,192,26023,1,0,0:0:0:0: +192,192,26023,1,0,0:0:0:0: +448,192,26123,1,0,0:0:0:0: +192,192,26223,1,0,0:0:0:0: +64,192,26223,1,0,0:0:0:0: +320,192,26323,1,0,0:0:0:0: +448,192,26423,1,0,0:0:0:85:Snare.wav +64,192,26423,1,0,0:0:0:0: +192,192,26423,128,0,26823:0:0:0:0: +320,192,26823,1,4,0:0:0:95: +64,192,26823,1,0,0:0:0:95:Kick.wav +448,192,26823,1,0,0:0:0:0: +192,192,26923,1,0,0:0:0:0: +448,192,27023,1,0,0:0:0:0: +320,192,27023,1,0,0:0:0:0: +64,192,27123,1,0,0:0:0:0: +192,192,27223,1,0,0:0:0:0: +320,192,27223,1,0,0:0:0:0: +448,192,27323,1,0,0:0:0:0: +320,192,27423,1,0,0:0:0:0: +192,192,27423,1,0,0:0:0:0: +64,192,27523,1,0,0:0:0:0: +192,192,27623,1,0,0:0:0:0: +448,192,27623,1,0,0:0:0:0: +320,192,27723,1,0,0:0:0:0: +192,192,27823,1,0,0:0:0:0: +64,192,27823,1,0,0:0:0:0: +320,192,27923,1,0,0:0:0:0: +192,192,28023,1,0,0:0:0:85:Snare.wav +64,192,28023,1,0,0:0:0:0: +448,192,28023,128,0,28423:0:0:0:0: +64,192,28423,128,0,28523:0:0:0:0: +320,192,28423,128,0,28523:0:0:0:0: +448,192,28623,128,0,28723:0:0:0:0: +64,192,28623,128,0,28723:0:0:0:0: +448,192,28823,1,4,0:0:0:95: +64,192,28823,1,0,0:0:0:95:Kick.wav +192,192,28823,1,0,0:0:0:0: +320,192,28923,1,0,0:0:0:0: +192,192,29023,1,0,0:0:0:0: +64,192,29023,1,0,0:0:0:0: +448,192,29123,1,0,0:0:0:0: +320,192,29223,1,0,0:0:0:0: +64,192,29223,1,0,0:0:0:0: +192,192,29323,1,0,0:0:0:0: +320,192,29423,1,0,0:0:0:0: +448,192,29423,1,0,0:0:0:0: +64,192,29523,1,0,0:0:0:0: +192,192,29623,1,0,0:0:0:85:Snare.wav +448,192,29623,1,0,0:0:0:0: +320,192,29623,1,0,0:0:0:0: +320,192,30023,1,4,0:0:0:95: +448,192,30023,1,0,0:0:0:95:Kick.wav +64,192,30023,1,0,0:0:0:0: +192,192,30123,1,0,0:0:0:0: +448,192,30223,1,0,0:0:0:0: +64,192,30223,1,0,0:0:0:0: +192,192,30323,1,0,0:0:0:0: +320,192,30423,1,0,0:0:0:0: +448,192,30423,1,0,0:0:0:0: +64,192,30523,1,0,0:0:0:0: +448,192,30623,1,0,0:0:0:0: +192,192,30623,1,0,0:0:0:0: +320,192,30723,1,0,0:0:0:0: +192,192,30823,1,0,0:0:0:0: +64,192,30823,1,0,0:0:0:0: +320,192,30923,1,0,0:0:0:0: +192,192,31023,1,0,0:0:0:0: +448,192,31023,1,0,0:0:0:0: +64,192,31123,1,0,0:0:0:0: +448,192,31223,1,0,0:0:0:85:Snare.wav +192,192,31223,1,0,0:0:0:0: +320,192,31223,128,0,31423:0:0:0:0: +192,192,31423,128,0,31623:0:0:0:0: +64,192,31623,128,0,31723:0:0:0:0: +320,192,31623,128,0,31723:0:0:0:0: +320,192,31823,128,0,31923:0:0:0:0: +192,192,31823,128,0,31923:0:0:0:0: +448,192,32023,1,4,0:0:0:95: +320,192,32023,1,0,0:0:0:95:Kick.wav +64,192,32023,1,0,0:0:0:0: +192,192,32123,1,0,0:0:0:0: +448,192,32223,1,0,0:0:0:0: +64,192,32223,1,0,0:0:0:0: +320,192,32323,1,0,0:0:0:0: +192,192,32423,1,0,0:0:0:0: +64,192,32423,1,0,0:0:0:0: +448,192,32523,1,0,0:0:0:0: +320,192,32623,1,0,0:0:0:0: +192,192,32623,1,0,0:0:0:0: +64,192,32723,1,0,0:0:0:0: +320,192,32823,1,0,0:0:0:85:Snare.wav +448,192,32823,1,0,0:0:0:0: +192,192,32823,1,0,0:0:0:0: +192,192,33023,1,0,0:0:0:95:Kick.wav +64,192,33023,1,0,0:0:0:0: +448,192,33223,1,4,0:0:0:95: +320,192,33223,1,0,0:0:0:95:Kick.wav +64,192,33223,1,0,0:0:0:0: +192,192,33323,1,0,0:0:0:0: +320,192,33423,1,0,0:0:0:0: +64,192,33423,1,0,0:0:0:0: +448,192,33523,1,0,0:0:0:0: +64,192,33623,1,0,0:0:0:0: +320,192,33623,1,0,0:0:0:0: +192,192,33723,1,0,0:0:0:0: +320,192,33823,1,0,0:0:0:0: +448,192,33823,1,0,0:0:0:0: +64,192,33923,1,0,0:0:0:0: +320,192,34023,128,0,34423:0:0:0:0: +192,192,34023,1,0,0:0:0:0: +448,192,34223,1,0,0:0:0:0: +448,192,34423,1,0,0:0:0:85:Snare.wav +192,192,34423,1,0,0:0:0:0: +64,192,34423,1,0,0:0:0:0: +192,192,34823,128,0,34923:0:0:0:0: +64,192,34823,128,0,34923:0:0:0:0: +448,192,34923,1,0,0:0:0:0: +64,192,35023,128,0,35123:0:0:0:0: +320,192,35023,128,0,35123:0:0:0:0: +192,192,35223,128,4,35823:0:0:0:95: +320,192,35223,1,0,0:0:0:95:Kick.wav +64,192,35223,1,0,0:0:0:0: +448,192,35423,1,0,0:0:0:0: +320,192,35623,1,0,0:0:0:0: +448,192,35823,128,0,36423:0:0:0:0: +64,192,35823,1,0,0:0:0:0: +64,192,36023,1,0,0:0:0:85:Snare.wav +192,192,36023,1,0,0:0:0:0: +320,192,36023,1,0,0:0:0:0: +64,192,36423,128,4,37623:0:0:0:95: +192,192,36423,1,0,0:0:0:95:Kick.wav +320,192,36423,1,0,0:0:0:0: +448,192,36623,1,0,0:0:0:0: +192,192,36823,1,0,0:0:0:0: +320,192,37023,1,0,0:0:0:0: +192,192,37223,1,0,0:0:0:0: +448,192,37423,1,0,0:0:0:0: +192,192,37623,1,0,0:0:0:85:Snare.wav +320,192,37623,1,0,0:0:0:0: +448,192,37623,128,0,37823:0:0:0:0: +320,192,37823,128,0,38023:0:0:0:0: +192,192,38023,128,0,38123:0:0:0:0: +448,192,38023,128,0,38123:0:0:0:0: +192,192,38223,128,0,38323:0:0:0:0: +64,192,38223,128,0,38323:0:0:0:0: +64,192,38423,1,0,0:0:0:95:Kick.wav +320,192,38423,1,4,0:0:0:95: +448,192,38423,1,0,0:0:0:0: +192,192,38523,1,0,0:0:0:0: +320,192,38623,1,0,0:0:0:0: +64,192,38623,1,0,0:0:0:0: +64,192,38823,1,0,0:0:0:0: +320,192,38823,1,0,0:0:0:0: +64,192,39023,1,0,0:0:0:0: +320,192,39023,1,0,0:0:0:0: +64,192,39223,1,0,0:0:0:85:Snare.wav +192,192,39223,1,0,0:0:0:0: +448,192,39223,128,0,39623:0:0:0:0: +320,192,39623,1,4,0:0:0:95: +192,192,39623,1,0,0:0:0:95:Kick.wav +64,192,39623,1,0,0:0:0:0: +448,192,39723,1,0,0:0:0:0: +320,192,39823,1,0,0:0:0:0: +192,192,39823,1,0,0:0:0:0: +192,192,40023,1,0,0:0:0:0: +320,192,40023,1,0,0:0:0:0: +192,192,40223,1,0,0:0:0:0: +320,192,40223,1,0,0:0:0:0: +192,192,40423,1,0,0:0:0:0: +320,192,40423,1,0,0:0:0:0: +192,192,40623,1,0,0:0:0:0: +320,192,40623,1,0,0:0:0:0: +448,192,40823,1,0,0:0:0:85:Snare.wav +192,192,40823,1,0,0:0:0:0: +64,192,40823,128,0,41223:0:0:0:0: +192,192,41223,128,0,41323:0:0:0:0: +320,192,41223,128,0,41323:0:0:0:0: +320,192,41423,128,0,41523:0:0:0:0: +448,192,41423,128,0,41523:0:0:0:0: +192,192,41623,1,4,0:0:0:95: +448,192,41623,1,0,0:0:0:95:Kick.wav +320,192,41623,1,0,0:0:0:0: +64,192,41723,1,0,0:0:0:0: +192,192,41823,1,0,0:0:0:0: +448,192,41823,1,0,0:0:0:0: +192,192,42023,1,0,0:0:0:0: +448,192,42023,1,0,0:0:0:0: +192,192,42223,1,0,0:0:0:0: +448,192,42223,1,0,0:0:0:0: +448,192,42423,1,0,0:0:0:85:Snare.wav +64,192,42423,1,0,0:0:0:0: +320,192,42423,1,0,0:0:0:0: +448,192,42823,1,4,0:0:0:95: +192,192,42823,1,0,0:0:0:95:Kick.wav +64,192,42823,1,0,0:0:0:0: +320,192,42923,1,0,0:0:0:0: +64,192,43023,1,0,0:0:0:0: +448,192,43023,1,0,0:0:0:0: +448,192,43223,1,0,0:0:0:0: +64,192,43223,1,0,0:0:0:0: +64,192,43423,1,0,0:0:0:0: +448,192,43423,1,0,0:0:0:0: +448,192,43623,1,0,0:0:0:0: +64,192,43623,1,0,0:0:0:0: +64,192,43823,1,0,0:0:0:0: +448,192,43823,1,0,0:0:0:0: +64,192,44023,1,0,0:0:0:85:Snare.wav +192,192,44023,1,0,0:0:0:0: +320,192,44023,128,0,44223:0:0:0:0: +192,192,44223,128,0,44423:0:0:0:0: +64,192,44423,128,0,44523:0:0:0:0: +320,192,44423,128,0,44523:0:0:0:0: +448,192,44623,128,0,44723:0:0:0:0: +320,192,44623,128,0,44723:0:0:0:0: +448,192,44823,1,4,0:0:0:95: +64,192,44823,1,0,0:0:0:95:Kick.wav +320,192,44823,1,0,0:0:0:0: +192,192,44923,1,0,0:0:0:0: +448,192,45023,1,0,0:0:0:0: +64,192,45023,1,0,0:0:0:0: +64,192,45223,1,0,0:0:0:0: +448,192,45223,1,0,0:0:0:0: +448,192,45423,1,0,0:0:0:0: +64,192,45423,1,0,0:0:0:0: +64,192,45623,1,0,0:0:0:85:Snare.wav +320,192,45623,1,0,0:0:0:0: +192,192,45623,1,0,0:0:0:0: +448,192,45823,1,0,0:0:0:95:Kick.wav +192,192,45823,1,0,0:0:0:0: +448,192,46023,1,4,0:0:0:95: +320,192,46023,1,0,0:0:0:95:Kick.wav +64,192,46023,1,0,0:0:0:0: +192,192,46123,1,0,0:0:0:0: +320,192,46223,1,0,0:0:0:0: +64,192,46223,1,0,0:0:0:0: +64,192,46423,1,0,0:0:0:0: +320,192,46423,1,0,0:0:0:0: +64,192,46623,1,0,0:0:0:0: +320,192,46623,1,0,0:0:0:0: +320,192,46823,1,0,0:0:0:0: +64,192,46823,1,0,0:0:0:0: +64,192,47023,1,0,0:0:0:0: +320,192,47023,1,0,0:0:0:0: +320,192,47223,1,0,0:0:0:85:Snare.wav +192,192,47223,1,0,0:0:0:0: +448,192,47223,1,0,0:0:0:0: +192,192,47623,128,0,47723:0:0:0:0: +448,192,47623,128,0,47723:0:0:0:0: +192,192,47823,128,0,47923:0:0:0:0: +320,192,47823,128,0,47923:0:0:0:0: +320,192,48023,1,4,0:0:0:95: +448,192,48023,1,0,0:0:0:95:Kick.wav +64,192,48023,128,0,48623:0:0:0:0: +192,192,48223,1,0,0:0:0:0: +192,192,48423,1,0,0:0:0:0: +448,192,48623,128,0,49223:0:0:0:0: +320,192,48623,1,0,0:0:0:0: +320,192,48823,1,0,0:0:0:85:Snare.wav +192,192,48823,1,0,0:0:0:0: +64,192,48823,1,0,0:0:0:0: +192,192,49223,128,4,50423:0:0:0:95: +320,192,49223,1,0,0:0:0:95:Kick.wav +64,192,49223,1,0,0:0:0:0: +448,192,49423,1,0,0:0:0:0: +448,192,49623,1,0,0:0:0:0: +320,192,49823,1,0,0:0:0:0: +320,192,50023,1,0,0:0:0:0: +64,192,50223,1,0,0:0:0:0: +64,192,50423,1,0,0:0:0:85:Snare.wav +320,192,50423,1,0,0:0:0:0: +448,192,50423,128,0,50623:0:0:0:0: +320,192,50623,128,0,50823:0:0:0:0: +192,192,50823,128,0,50923:0:0:0:0: +448,192,50823,128,0,50923:0:0:0:0: +64,192,51023,128,0,51123:0:0:0:0: +192,192,51023,128,0,51123:0:0:0:0: +64,192,51223,1,0,0:0:0:95:Kick.wav +320,192,51223,1,4,0:0:0:95: +448,192,51223,1,0,0:0:0:0: +192,192,51323,1,0,0:0:0:0: +448,192,51423,1,0,0:0:0:0: +64,192,51423,1,0,0:0:0:0: +320,192,51623,1,0,0:0:0:0: +64,192,51623,128,0,51823:0:0:0:0: +320,192,51823,1,0,0:0:0:0: +448,192,51823,1,0,0:0:0:0: +192,192,52023,1,0,0:0:0:85:Snare.wav +64,192,52023,1,0,0:0:0:0: +448,192,52023,128,0,52423:0:0:0:0: +320,192,52423,1,4,0:0:0:95: +192,192,52423,1,0,0:0:0:95:Kick.wav +64,192,52423,1,0,0:0:0:0: +448,192,52523,1,0,0:0:0:0: +320,192,52623,1,0,0:0:0:0: +64,192,52623,1,0,0:0:0:0: +64,192,52823,1,0,0:0:0:0: +448,192,52823,1,0,0:0:0:0: +448,192,53023,1,0,0:0:0:0: +192,192,53023,1,0,0:0:0:0: +320,192,53223,1,0,0:0:0:0: +192,192,53223,128,0,53423:0:0:0:0: +320,192,53423,1,0,0:0:0:0: +448,192,53423,1,0,0:0:0:0: +448,192,53623,1,0,0:0:0:85:Snare.wav +192,192,53623,1,0,0:0:0:0: +64,192,53623,1,0,0:0:0:0: +320,192,53756,1,0,0:0:0:0: +192,192,53889,1,0,0:0:0:0: +320,192,54023,128,0,54123:0:0:0:0: +448,192,54023,128,0,54123:0:0:0:0: +448,192,54223,128,0,54323:0:0:0:0: +64,192,54223,128,0,54323:0:0:0:0: +448,192,54423,1,4,0:0:0:95: +320,192,54423,1,0,0:0:0:95:Kick.wav +64,192,54423,1,0,0:0:0:0: +192,192,54523,1,0,0:0:0:0: +448,192,54623,1,0,0:0:0:0: +320,192,54623,1,0,0:0:0:0: +64,192,54823,1,0,0:0:0:0: +448,192,54823,128,0,55023:0:0:0:0: +64,192,55023,1,0,0:0:0:0: +320,192,55023,1,0,0:0:0:0: +192,192,55223,1,0,0:0:0:85:Snare.wav +320,192,55223,1,0,0:0:0:0: +448,192,55223,1,0,0:0:0:0: +448,192,55623,1,4,0:0:0:95: +192,192,55623,1,0,0:0:0:95:Kick.wav +64,192,55623,1,0,0:0:0:0: +320,192,55723,1,0,0:0:0:0: +64,192,55823,1,0,0:0:0:0: +448,192,55823,1,0,0:0:0:0: +448,192,56023,1,0,0:0:0:0: +192,192,56023,1,0,0:0:0:0: +192,192,56223,1,0,0:0:0:0: +64,192,56223,1,0,0:0:0:0: +320,192,56423,1,0,0:0:0:0: +64,192,56423,128,0,56623:0:0:0:0: +320,192,56623,1,0,0:0:0:0: +448,192,56623,1,0,0:0:0:0: +192,192,56823,1,0,0:0:0:85:Snare.wav +64,192,56823,1,0,0:0:0:0: +448,192,56823,128,0,57023:0:0:0:0: +320,192,57023,128,0,57223:0:0:0:0: +192,192,57223,128,0,57323:0:0:0:0: +64,192,57223,128,0,57323:0:0:0:0: +64,192,57423,128,0,57523:0:0:0:0: +448,192,57423,128,0,57523:0:0:0:0: +64,192,57623,1,4,0:0:0:95: +320,192,57623,1,0,0:0:0:95:Kick.wav +192,192,57623,1,0,0:0:0:0: +448,192,57723,1,0,0:0:0:0: +64,192,57823,1,0,0:0:0:0: +192,192,57823,1,0,0:0:0:0: +192,192,58023,1,0,0:0:0:0: +320,192,58023,128,0,58223:0:0:0:0: +64,192,58223,1,0,0:0:0:0: +448,192,58223,1,0,0:0:0:0: +320,192,58423,1,0,0:0:0:85:Snare.wav +192,192,58423,1,0,0:0:0:0: +448,192,58423,1,0,0:0:0:0: +192,192,58623,1,0,0:0:0:95:Kick.wav +64,192,58623,1,0,0:0:0:0: +64,192,58823,1,4,0:0:0:95: +448,192,58823,1,0,0:0:0:95:Kick.wav +320,192,58823,1,0,0:0:0:0: +192,192,58923,1,0,0:0:0:0: +448,192,59023,1,0,0:0:0:0: +64,192,59023,1,0,0:0:0:0: +320,192,59223,1,0,0:0:0:0: +448,192,59223,1,0,0:0:0:0: +320,192,59423,1,0,0:0:0:0: +64,192,59423,1,0,0:0:0:0: +448,192,59623,1,0,0:0:0:0: +64,192,59623,128,0,59823:0:0:0:0: +448,192,59823,1,0,0:0:0:0: +320,192,59823,1,0,0:0:0:0: +320,192,60023,1,0,0:0:0:85:Snare.wav +192,192,60023,1,0,0:0:0:0: +64,192,60023,1,0,0:0:0:0: +448,192,60423,128,0,60523:0:0:0:0: +192,192,60423,128,0,60523:0:0:0:0: +64,192,60623,128,0,60723:0:0:0:0: +192,192,60623,128,0,60723:0:0:0:0: +320,192,60823,128,4,61423:0:0:0:95: +448,192,60823,1,0,0:0:0:95:Kick.wav +64,192,60823,1,0,0:0:0:0: +192,192,61023,1,0,0:0:0:0: +192,192,61223,1,0,0:0:0:0: +448,192,61423,128,0,62023:0:0:0:0: +64,192,61423,1,0,0:0:0:0: +64,192,61623,1,0,0:0:0:85:Snare.wav +192,192,61623,1,0,0:0:0:0: +320,192,61623,1,0,0:0:0:0: +64,192,62023,128,4,63223:0:0:0:95: +192,192,62023,1,0,0:0:0:95:Kick.wav +320,192,62023,1,0,0:0:0:0: +448,192,62223,1,0,0:0:0:0: +448,192,62423,1,0,0:0:0:0: +192,192,62623,1,0,0:0:0:0: +192,192,62823,1,0,0:0:0:0: +320,192,63023,1,0,0:0:0:0: +192,192,63223,1,0,0:0:0:85:Snare.wav +448,192,63223,1,0,0:0:0:0: +320,192,63223,128,0,63423:0:0:0:0: +192,192,63423,128,0,63623:0:0:0:0: +64,192,63623,128,0,63723:0:0:0:0: +448,192,63623,128,0,63723:0:0:0:0: +320,192,63823,128,0,63923:0:0:0:0: +192,192,63823,128,0,63923:0:0:0:0: +448,192,64023,1,4,0:0:0:95: +320,192,64023,1,0,0:0:0:95:Kick.wav +64,192,64023,1,0,0:0:0:0: diff --git a/src/any/difficulty/gradual.rs b/src/any/difficulty/gradual.rs index 9fbe38af..14792658 100644 --- a/src/any/difficulty/gradual.rs +++ b/src/any/difficulty/gradual.rs @@ -39,9 +39,6 @@ use crate::{ /// ``` /// /// [`GradualPerformance`]: crate::GradualPerformance -// 504 vs 184 bytes is an acceptable difference and the Osu variant (424 bytes) -// is likely the most used one anyway. -#[allow(clippy::large_enum_variant)] pub enum GradualDifficulty { Osu(OsuGradualDifficulty), Taiko(TaikoGradualDifficulty), @@ -51,7 +48,7 @@ pub enum GradualDifficulty { impl GradualDifficulty { /// Create a [`GradualDifficulty`] for a map of any mode. - #[allow(clippy::missing_panics_doc)] + #[expect(clippy::missing_panics_doc, reason = "unreachable")] pub fn new(difficulty: Difficulty, map: &Beatmap) -> Self { Self::new_with_mode(difficulty, map, map.mode).expect("no conversion required") } diff --git a/src/any/difficulty/inspect.rs b/src/any/difficulty/inspect.rs index 63397dbf..0d15cd68 100644 --- a/src/any/difficulty/inspect.rs +++ b/src/any/difficulty/inspect.rs @@ -1,6 +1,7 @@ -use crate::{Difficulty, model::mods::GameMods}; - -use super::ModsDependent; +use crate::{ + Difficulty, + model::{beatmap::BeatmapAttribute, mods::GameMods}, +}; /// [`Difficulty`] but all fields are public for inspection. #[derive(Clone, Debug, Default, PartialEq)] @@ -14,15 +15,15 @@ pub struct InspectDifficulty { /// Override a beatmap's set AR. /// /// Only relevant for osu! and osu!catch. - pub ar: Option, + pub ar: BeatmapAttribute, /// Override a beatmap's set CS. /// /// Only relevant for osu! and osu!catch. - pub cs: Option, + pub cs: BeatmapAttribute, /// Override a beatmap's set HP. - pub hp: Option, + pub hp: BeatmapAttribute, /// Override a beatmap's set OD. - pub od: Option, + pub od: BeatmapAttribute, /// Adjust patterns as if the HR mod is enabled. /// /// Only relevant for osu!catch. @@ -59,21 +60,20 @@ impl InspectDifficulty { difficulty = difficulty.clock_rate(clock_rate); } - if let Some(ar) = ar { - difficulty = difficulty.ar(ar.value, ar.with_mods); - } - - if let Some(cs) = cs { - difficulty = difficulty.cs(cs.value, cs.with_mods); + macro_rules! set_attr { + ( $attr:ident ) => { + match $attr { + BeatmapAttribute::None | BeatmapAttribute::Value(_) => {} + BeatmapAttribute::Given(value) => difficulty = difficulty.$attr(value, false), + BeatmapAttribute::Fixed(value) => difficulty = difficulty.$attr(value, true), + }; + }; } - if let Some(hp) = hp { - difficulty = difficulty.hp(hp.value, hp.with_mods); - } - - if let Some(od) = od { - difficulty = difficulty.od(od.value, od.with_mods); - } + set_attr!(ar); + set_attr!(cs); + set_attr!(hp); + set_attr!(od); if let Some(hardrock_offsets) = hardrock_offsets { difficulty = difficulty.hardrock_offsets(hardrock_offsets); diff --git a/src/any/difficulty/mod.rs b/src/any/difficulty/mod.rs index d4881e28..2dca7049 100644 --- a/src/any/difficulty/mod.rs +++ b/src/any/difficulty/mod.rs @@ -9,7 +9,11 @@ use crate::{ GradualDifficulty, GradualPerformance, catch::Catch, mania::Mania, - model::{beatmap::Beatmap, mode::ConvertError, mods::GameMods}, + model::{ + beatmap::{Beatmap, BeatmapAttribute, attributes::BeatmapDifficulty}, + mode::ConvertError, + mods::GameMods, + }, osu::Osu, taiko::Taiko, }; @@ -48,35 +52,15 @@ pub struct Difficulty { /// This allows for an optimization to reduce the struct size by storing its /// bits as a [`NonZeroU64`]. clock_rate: Option, - ar: Option, - cs: Option, - hp: Option, - od: Option, + #[expect( + clippy::struct_field_names, + reason = "it's a different kind of difficulty" + )] + map_difficulty: BeatmapDifficulty, hardrock_offsets: Option, lazer: Option, } -/// Wrapper for beatmap attributes in [`Difficulty`]. -#[derive(Copy, Clone, Debug, Default, PartialEq)] -pub struct ModsDependent { - /// Value of the beatmap attribute. - pub value: f32, - /// Whether `value` should be used as is or modified based on mods. - /// - /// `true` means "value already considers mods" i.e. use as is; - /// `false` means modify with mods. - pub with_mods: bool, -} - -impl ModsDependent { - pub const fn new(value: f32) -> Self { - Self { - value, - with_mods: false, - } - } -} - impl Difficulty { /// Create a new difficulty calculator. pub const fn new() -> Self { @@ -84,10 +68,7 @@ impl Difficulty { mods: GameMods::DEFAULT, passed_objects: None, clock_rate: None, - ar: None, - cs: None, - hp: None, - od: None, + map_difficulty: BeatmapDifficulty::DEFAULT, hardrock_offsets: None, lazer: None, } @@ -100,10 +81,7 @@ impl Difficulty { mods, passed_objects, clock_rate, - ar, - cs, - hp, - od, + map_difficulty, hardrock_offsets, lazer, } = self; @@ -112,10 +90,10 @@ impl Difficulty { mods, passed_objects, clock_rate: clock_rate.map(non_zero_u64_to_f64), - ar, - cs, - hp, - od, + ar: map_difficulty.ar, + cs: map_difficulty.cs, + hp: map_difficulty.hp, + od: map_difficulty.od, hardrock_offsets, lazer, } @@ -170,80 +148,88 @@ impl Difficulty { /// /// Only relevant for osu! and osu!catch. /// - /// `with_mods` determines if the given value should be used before - /// or after accounting for mods, e.g. on `true` the value will be - /// used as is and on `false` it will be modified based on the mods. + /// `fixed` determines if the given value should be used before or after + /// accounting for mods, e.g. on `true` the value will be used as is and on + /// `false` it will be modified based on the mods. /// /// | Minimum | Maximum | /// | :-----: | :-----: | /// | -20 | 20 | - pub fn ar(self, ar: f32, with_mods: bool) -> Self { - Self { - ar: Some(ModsDependent { - value: ar.clamp(-20.0, 20.0), - with_mods, - }), - ..self - } + pub const fn ar(mut self, ar: f32, fixed: bool) -> Self { + let ar = f32::clamp(ar, -20.0, 20.0); + + self.map_difficulty.ar = if fixed { + BeatmapAttribute::Fixed(ar) + } else { + BeatmapAttribute::Given(ar) + }; + + self } /// Override a beatmap's set CS. /// /// Only relevant for osu! and osu!catch. /// - /// `with_mods` determines if the given value should be used before - /// or after accounting for mods, e.g. on `true` the value will be - /// used as is and on `false` it will be modified based on the mods. + /// `fixed` determines if the given value should be used before or after + /// accounting for mods, e.g. on `true` the value will be used as is and on + /// `false` it will be modified based on the mods. /// /// | Minimum | Maximum | /// | :-----: | :-----: | /// | -20 | 20 | - pub fn cs(self, cs: f32, with_mods: bool) -> Self { - Self { - cs: Some(ModsDependent { - value: cs.clamp(-20.0, 20.0), - with_mods, - }), - ..self - } + pub const fn cs(mut self, cs: f32, fixed: bool) -> Self { + let cs = f32::clamp(cs, -20.0, 20.0); + + self.map_difficulty.cs = if fixed { + BeatmapAttribute::Fixed(cs) + } else { + BeatmapAttribute::Given(cs) + }; + + self } /// Override a beatmap's set HP. /// - /// `with_mods` determines if the given value should be used before - /// or after accounting for mods, e.g. on `true` the value will be - /// used as is and on `false` it will be modified based on the mods. + /// `fixed` determines if the given value should be used before or after + /// accounting for mods, e.g. on `true` the value will be used as is and on + /// `false` it will be modified based on the mods. /// /// | Minimum | Maximum | /// | :-----: | :-----: | /// | -20 | 20 | - pub fn hp(self, hp: f32, with_mods: bool) -> Self { - Self { - hp: Some(ModsDependent { - value: hp.clamp(-20.0, 20.0), - with_mods, - }), - ..self - } + pub const fn hp(mut self, hp: f32, fixed: bool) -> Self { + let hp = f32::clamp(hp, -20.0, 20.0); + + self.map_difficulty.hp = if fixed { + BeatmapAttribute::Fixed(hp) + } else { + BeatmapAttribute::Given(hp) + }; + + self } /// Override a beatmap's set OD. /// - /// `with_mods` determines if the given value should be used before - /// or after accounting for mods, e.g. on `true` the value will be - /// used as is and on `false` it will be modified based on the mods. + /// `fixed` determines if the given value should be used before or after + /// accounting for mods, e.g. on `true` the value will be used as is and on + /// `false` it will be modified based on the mods. /// /// | Minimum | Maximum | /// | :-----: | :-----: | /// | -20 | 20 | - pub fn od(self, od: f32, with_mods: bool) -> Self { - Self { - od: Some(ModsDependent { - value: od.clamp(-20.0, 20.0), - with_mods, - }), - ..self - } + pub const fn od(mut self, od: f32, fixed: bool) -> Self { + let od = f32::clamp(od, -20.0, 20.0); + + self.map_difficulty.od = if fixed { + BeatmapAttribute::Fixed(od) + } else { + BeatmapAttribute::Given(od) + }; + + self } /// Adjust patterns as if the HR mod is enabled. @@ -266,7 +252,7 @@ impl Difficulty { } /// Perform the difficulty calculation. - #[allow(clippy::missing_panics_doc)] + #[expect(clippy::missing_panics_doc, reason = "unreachable")] pub fn calculate(&self, map: &Beatmap) -> DifficultyAttributes { match map.mode { GameMode::Osu => DifficultyAttributes::Osu( @@ -296,7 +282,7 @@ impl Difficulty { /// strains, return them as is. /// /// Suitable to plot the difficulty of a map over time. - #[allow(clippy::missing_panics_doc)] + #[expect(clippy::missing_panics_doc, reason = "unreachable")] pub fn strains(&self, map: &Beatmap) -> Strains { match map.mode { GameMode::Osu => Strains::Osu(Osu::strains(self, map).expect("no conversion required")), @@ -359,20 +345,8 @@ impl Difficulty { self.passed_objects.map_or(usize::MAX, |n| n as usize) } - pub(crate) const fn get_ar(&self) -> Option { - self.ar - } - - pub(crate) const fn get_cs(&self) -> Option { - self.cs - } - - pub(crate) const fn get_hp(&self) -> Option { - self.hp - } - - pub(crate) const fn get_od(&self) -> Option { - self.od + pub(crate) const fn get_map_difficulty(&self) -> &BeatmapDifficulty { + &self.map_difficulty } pub(crate) fn get_hardrock_offsets(&self) -> bool { @@ -395,10 +369,7 @@ impl Debug for Difficulty { mods, passed_objects, clock_rate, - ar, - cs, - hp, - od, + map_difficulty, hardrock_offsets, lazer, } = self; @@ -407,10 +378,10 @@ impl Debug for Difficulty { .field("mods", mods) .field("passed_objects", passed_objects) .field("clock_rate", &clock_rate.map(non_zero_u64_to_f64)) - .field("ar", ar) - .field("cs", cs) - .field("hp", hp) - .field("od", od) + .field("ar", &map_difficulty.ar) + .field("cs", &map_difficulty.cs) + .field("hp", &map_difficulty.hp) + .field("od", &map_difficulty.od) .field("hardrock_offsets", hardrock_offsets) .field("lazer", lazer) .finish() diff --git a/src/any/difficulty/skills.rs b/src/any/difficulty/skills.rs index ad620ecf..932c4d2a 100644 --- a/src/any/difficulty/skills.rs +++ b/src/any/difficulty/skills.rs @@ -13,6 +13,8 @@ pub trait StrainSkill: Sized { objects: &Self::DifficultyObjects<'a>, ); + fn object_strains(&self) -> &[f64]; + fn count_top_weighted_strains(&self, difficulty_value: f64) -> f64; fn save_current_peak(&mut self); diff --git a/src/any/hit_result.rs b/src/any/hit_result.rs new file mode 100644 index 00000000..22f43d59 --- /dev/null +++ b/src/any/hit_result.rs @@ -0,0 +1,49 @@ +use rosu_map::section::general::GameMode; + +#[derive(Copy, Clone)] +#[expect(unused, reason = "staying in-sync with lazer")] +pub enum HitResult { + None, + Miss, + Meh, + Ok, + Good, + Great, + Perfect, + SmallTickMiss, + SmallTickHit, + LargeTickMiss, + LargeTickHit, + SmallBonus, + LargeBonus, + IgnoreMiss, + IgnoreHit, + ComboBreak, + SliderTailHit, + LegacyComboIncrease, +} + +impl HitResult { + pub const fn base_score(self, mode: GameMode) -> i32 { + match mode { + GameMode::Osu => { + match self { + Self::SmallTickHit => 10, + Self::LargeTickHit => 30, + Self::SliderTailHit => 150, + Self::Meh => 50, + Self::Ok => 100, + Self::Good => 200, + // * Perfect doesn't actually give more score / accuracy directly + Self::Great | Self::Perfect => 300, + Self::SmallBonus => 10, + Self::LargeBonus => 50, + _ => 0, + } + } + GameMode::Taiko => unimplemented!(), + GameMode::Catch => unimplemented!(), + GameMode::Mania => unimplemented!(), + } + } +} diff --git a/src/any/mod.rs b/src/any/mod.rs index dee4b680..86199c57 100644 --- a/src/any/mod.rs +++ b/src/any/mod.rs @@ -1,8 +1,6 @@ pub use self::{ attributes::{DifficultyAttributes, PerformanceAttributes}, - difficulty::{ - Difficulty, ModsDependent, gradual::GradualDifficulty, inspect::InspectDifficulty, - }, + difficulty::{Difficulty, gradual::GradualDifficulty, inspect::InspectDifficulty}, hitresult_generator::HitResultGenerator, performance::{ HitResultPriority, Performance, @@ -21,6 +19,7 @@ pub mod hitresult_generator; mod attributes; pub(crate) mod difficulty; +pub(crate) mod hit_result; mod performance; mod score_state; mod strains; diff --git a/src/any/performance/gradual.rs b/src/any/performance/gradual.rs index a2e78dfe..b4297dc4 100644 --- a/src/any/performance/gradual.rs +++ b/src/any/performance/gradual.rs @@ -88,9 +88,6 @@ use crate::{ /// [`next`]: GradualPerformance::next /// [`nth`]: GradualPerformance::nth /// [`GradualDifficulty`]: crate::GradualDifficulty -// 504 vs 184 bytes is an acceptable difference and the Osu variant (424 bytes) -// is likely the most used one anyway. -#[allow(clippy::large_enum_variant)] pub enum GradualPerformance { Osu(OsuGradualPerformance), Taiko(TaikoGradualPerformance), @@ -100,7 +97,7 @@ pub enum GradualPerformance { impl GradualPerformance { /// Create a [`GradualPerformance`] for a map of any mode. - #[allow(clippy::missing_panics_doc)] + #[expect(clippy::missing_panics_doc, reason = "unreachable")] pub fn new(difficulty: Difficulty, map: &Beatmap) -> Self { Self::new_with_mode(difficulty, map, map.mode).expect("no conversion required") } @@ -154,7 +151,7 @@ impl GradualPerformance { } /// Returns the amount of remaining objects. - #[allow(clippy::len_without_is_empty)] + #[expect(clippy::len_without_is_empty, reason = "TODO")] pub fn len(&self) -> usize { match self { GradualPerformance::Osu(gradual) => gradual.len(), diff --git a/src/any/performance/mod.rs b/src/any/performance/mod.rs index 642ab266..af2384da 100644 --- a/src/any/performance/mod.rs +++ b/src/any/performance/mod.rs @@ -54,7 +54,7 @@ impl<'map> Performance<'map> { /// Consume the performance calculator and calculate /// performance attributes for the given parameters. - #[allow(clippy::missing_panics_doc)] + #[expect(clippy::missing_panics_doc, reason = "unreachable")] pub fn calculate(self) -> PerformanceAttributes { match self { Self::Osu(o) => { @@ -82,8 +82,7 @@ impl<'map> Performance<'map> { /// [`mode_or_ignore`] instead. /// /// [`mode_or_ignore`]: Self::mode_or_ignore - // Both variants have the same size - #[allow(clippy::result_large_err)] + #[expect(clippy::result_large_err, reason = "both variants have the same size")] pub fn try_mode(self, mode: GameMode) -> Result { match (self, mode) { (Self::Osu(o), _) => o.try_mode(mode).map_err(Self::Osu), @@ -451,8 +450,18 @@ impl<'map> Performance<'map> { } } + /// Specify the legacy total score. + /// + /// Only relevant for osu!standard. + pub fn legacy_total_score(self, legacy_total_score: u32) -> Self { + match self { + Self::Osu(o) => Self::Osu(o.legacy_total_score(legacy_total_score)), + _ => self, + } + } + /// Create the [`ScoreState`] that will be used for performance calculation. - #[allow(clippy::missing_panics_doc)] + #[expect(clippy::missing_panics_doc, reason = "unreachable")] pub fn generate_state(&mut self) -> ScoreState { match self { Self::Osu(o) => o.generate_state().expect("no conversion required").into(), @@ -465,7 +474,6 @@ impl<'map> Performance<'map> { /// While generating remaining hitresults, decide how they should be distributed. #[derive(Copy, Clone, Debug, Eq, PartialEq)] -#[non_exhaustive] pub enum HitResultPriority { /// Prioritize good hitresults over bad ones BestCase, diff --git a/src/any/score_state.rs b/src/any/score_state.rs index 49dab5c5..ffa162ec 100644 --- a/src/any/score_state.rs +++ b/src/any/score_state.rs @@ -53,6 +53,10 @@ pub struct ScoreState { pub n50: u32, /// Amount of current misses (fruits + droplets for osu!catch). pub misses: u32, + /// Legacy total score. + /// + /// Only relevant for osu!standard in stable. + pub legacy_total_score: Option, } impl ScoreState { @@ -69,6 +73,7 @@ impl ScoreState { n100: 0, n50: 0, misses: 0, + legacy_total_score: None, } } @@ -103,6 +108,7 @@ impl From for OsuScoreState { n50: state.n50, misses: state.misses, }, + legacy_total_score: state.legacy_total_score, } } } @@ -161,6 +167,7 @@ impl From for ScoreState { n100: state.hitresults.n100, n50: state.hitresults.n50, misses: state.hitresults.misses, + legacy_total_score: state.legacy_total_score, } } } @@ -178,6 +185,7 @@ impl From for ScoreState { n100: state.hitresults.n100, n50: 0, misses: state.hitresults.misses, + legacy_total_score: None, } } } @@ -195,6 +203,7 @@ impl From for ScoreState { n100: state.hitresults.droplets, n50: state.hitresults.tiny_droplets, misses: state.hitresults.misses, + legacy_total_score: None, } } } @@ -212,6 +221,7 @@ impl From for ScoreState { n100: state.n100, n50: state.n50, misses: state.misses, + legacy_total_score: None, } } } diff --git a/src/catch/attributes.rs b/src/catch/attributes.rs index 9720d736..09b93837 100644 --- a/src/catch/attributes.rs +++ b/src/catch/attributes.rs @@ -7,8 +7,8 @@ use crate::catch::performance::CatchPerformance; pub struct CatchDifficultyAttributes { /// The final star rating pub stars: f64, - /// The approach rate. - pub ar: f64, + /// Time preempt (AR time window). + pub preempt: f64, /// The amount of fruits. pub n_fruits: u32, /// The amount of droplets. diff --git a/src/catch/difficulty/evaluators/mod.rs b/src/catch/difficulty/evaluators/mod.rs new file mode 100644 index 00000000..f4074a2e --- /dev/null +++ b/src/catch/difficulty/evaluators/mod.rs @@ -0,0 +1,3 @@ +pub use self::movement::MovementEvaluator; + +mod movement; diff --git a/src/catch/difficulty/evaluators/movement.rs b/src/catch/difficulty/evaluators/movement.rs new file mode 100644 index 00000000..3fae370f --- /dev/null +++ b/src/catch/difficulty/evaluators/movement.rs @@ -0,0 +1,88 @@ +use crate::{ + any::difficulty::object::IDifficultyObject, catch::difficulty::object::CatchDifficultyObject, + util::float_ext::FloatExt, +}; + +pub struct MovementEvaluator; + +impl MovementEvaluator { + const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0; + const DIRECTION_CHANGE_BONUS: f64 = 21.0; + + pub fn evaluate_diff_of( + curr: &CatchDifficultyObject, + diff_objects: &[CatchDifficultyObject], + clock_rate: f64, + ) -> f64 { + let catch_last_obj = curr.previous(0, diff_objects); + let catch_last_last_obj = curr.previous(1, diff_objects); + + let weighted_strain_time = curr.strain_time + 13.0 + (3.0 / clock_rate); + + let mut dist_addition = f64::from(curr.dist_moved.abs()).powf(1.3) / 510.0; + let sqrt_strain = weighted_strain_time.sqrt(); + + let mut edge_dash_bonus: f64 = 0.0; + + let last_strain_time = catch_last_obj.map_or(0.0, |obj| obj.strain_time); + + if curr.dist_moved.abs() > 0.1 { + let last_dist_moved = catch_last_obj.map_or(0.0, |obj| obj.dist_moved); + + if curr.idx >= 1 + && last_dist_moved.abs() > 0.1 + && curr.dist_moved.signum() != last_dist_moved.signum() + { + let bonus_factor = f64::from(curr.dist_moved.abs().min(50.0) / 50.0); + let anti_flow_factor = f64::from(last_dist_moved.abs().min(70.0) / 70.0).max(0.38); + + dist_addition += Self::DIRECTION_CHANGE_BONUS / (last_strain_time + 16.0).sqrt() + * bonus_factor + * anti_flow_factor + * (1.0 - (weighted_strain_time / 1000.0).powf(3.0)).max(0.0); + } + + // * Base bonus for every movement, giving some weight to streams. + dist_addition += 12.5 + * f64::from(f32::abs(curr.dist_moved).min(Self::NORMALIZED_HITOBJECT_RADIUS * 2.0)) + / f64::from(Self::NORMALIZED_HITOBJECT_RADIUS * 6.0) + / sqrt_strain; + } + + // * Bonus for edge dashes. + if curr.last_object.dist_to_hyper_dash <= 20.0 { + if !curr.last_object.hyper_dash { + edge_dash_bonus += 5.7; + } + + // * Edge Dashes are easier at lower ms values + dist_addition *= 1.0 + + edge_dash_bonus + * f64::from((20.0 - curr.last_object.dist_to_hyper_dash) / 20.0) + * ((curr.strain_time * clock_rate).min(265.0) / 265.0).powf(1.5); + } + + let last_exact_dist_moved = catch_last_obj.map_or(0.0, |obj| obj.exact_dist_moved); + let last_last_exact_dist_moved = + catch_last_last_obj.map_or(0.0, |obj| obj.exact_dist_moved); + + let last_last_strain_time = catch_last_last_obj.map_or(0.0, |obj| obj.strain_time); + + // * There is an edge case where horizontal back and forth sliders create "buzz" patterns which are repeated "movements" with a distance lower than + // * the platter's width but high enough to be considered a movement due to the absolute_player_positioning_error and NORMALIZED_HALF_CATCHER_WIDTH offsets + // * We are detecting this exact scenario. The first back and forth is counted but all subsequent ones are nullified. + // * To achieve that, we need to store the exact distances (distance ignoring absolute_player_positioning_error and NORMALIZED_HALF_CATCHER_WIDTH) + if curr.idx >= 2 + && curr.exact_dist_moved.abs() + <= CatchDifficultyObject::NORMALIZED_HALF_CATCHER_WIDTH * 2.0 + && ::eq(curr.exact_dist_moved, -last_exact_dist_moved) + && ::eq(last_exact_dist_moved, -last_last_exact_dist_moved) + && ::eq(curr.strain_time, last_strain_time) + && ::eq(last_strain_time, last_last_strain_time) + { + dist_addition = 0.0; + } + + dist_addition / weighted_strain_time + } +} diff --git a/src/catch/difficulty/gradual.rs b/src/catch/difficulty/gradual.rs index 5b6e8ee8..e692471d 100644 --- a/src/catch/difficulty/gradual.rs +++ b/src/catch/difficulty/gradual.rs @@ -79,16 +79,11 @@ impl CatchGradualDifficulty { let reflection = difficulty.get_mods().reflection(); let mut count = ObjectCountBuilder::new_gradual(); - let palpable_objects = convert_objects( - &map, - &mut count, - reflection, - hr_offsets, - map_attrs.cs as f32, - ); + let palpable_objects = + convert_objects(&map, &mut count, reflection, hr_offsets, map_attrs.cs()); - let mut half_catcher_width = Catcher::calculate_catch_width(map_attrs.cs as f32) * 0.5; - half_catcher_width *= 1.0 - ((map_attrs.cs as f32 - 5.5).max(0.0) * 0.0625); + let mut half_catcher_width = Catcher::calculate_catch_width(map_attrs.cs()) * 0.5; + half_catcher_width *= 1.0 - ((map_attrs.cs() - 5.5).max(0.0) * 0.0625); let diff_objects = DifficultyValues::create_difficulty_objects( clock_rate, @@ -97,7 +92,7 @@ impl CatchGradualDifficulty { ); let count = count.into_gradual(); - let movement = Movement::new(half_catcher_width, clock_rate); + let movement = Movement::new(clock_rate); Ok(Self { idx: 0, diff --git a/src/catch/difficulty/mod.rs b/src/catch/difficulty/mod.rs index e4882bd8..5ba71a4e 100644 --- a/src/catch/difficulty/mod.rs +++ b/src/catch/difficulty/mod.rs @@ -16,6 +16,7 @@ use super::{ object::palpable::PalpableObject, }; +mod evaluators; pub mod gradual; mod object; mod skills; @@ -48,7 +49,7 @@ impl CatchDifficultySetup { let map_attrs = map.attributes().difficulty(difficulty).build(); let attrs = CatchDifficultyAttributes { - ar: map_attrs.ar, + preempt: map_attrs.hit_windows().ar.unwrap_or(0.0), is_convert: map.is_convert, ..Default::default() }; @@ -77,10 +78,10 @@ impl DifficultyValues { let mut count = ObjectCountBuilder::new_regular(take); let palpable_objects = - convert_objects(map, &mut count, reflection, hr_offsets, map_attrs.cs as f32); + convert_objects(map, &mut count, reflection, hr_offsets, map_attrs.cs()); - let mut half_catcher_width = Catcher::calculate_catch_width(map_attrs.cs as f32) * 0.5; - half_catcher_width *= 1.0 - ((map_attrs.cs as f32 - 5.5).max(0.0) * 0.0625); + let mut half_catcher_width = Catcher::calculate_catch_width(map_attrs.cs()) * 0.5; + half_catcher_width *= 1.0 - ((map_attrs.cs() - 5.5).max(0.0) * 0.0625); let diff_objects = Self::create_difficulty_objects( clock_rate, @@ -88,7 +89,7 @@ impl DifficultyValues { palpable_objects.iter().take(take), ); - let mut movement = Movement::new(half_catcher_width, clock_rate); + let mut movement = Movement::new(clock_rate); for curr in diff_objects.iter() { movement.process(curr, &diff_objects); @@ -113,7 +114,9 @@ impl DifficultyValues { }; let scaling_factor = - CatchDifficultyObject::NORMALIZED_HITOBJECT_RADIUS / half_catcher_width; + CatchDifficultyObject::NORMALIZED_HALF_CATCHER_WIDTH / half_catcher_width; + + let mut last_player_pos = None; palpable_objects .enumerate() @@ -123,9 +126,11 @@ impl DifficultyValues { last_object, clock_rate, scaling_factor, + last_player_pos, i, ); last_object = hit_object; + last_player_pos = Some(diff_object.player_pos); diff_object }) diff --git a/src/catch/difficulty/object.rs b/src/catch/difficulty/object.rs index d0335271..eae2b9a7 100644 --- a/src/catch/difficulty/object.rs +++ b/src/catch/difficulty/object.rs @@ -9,18 +9,25 @@ pub struct CatchDifficultyObject { pub delta_time: f64, pub normalized_pos: f32, pub last_normalized_pos: f32, + pub player_pos: f32, + pub last_player_pos: f32, + pub dist_moved: f32, + pub exact_dist_moved: f32, pub strain_time: f64, pub last_object: LastObject, } impl CatchDifficultyObject { - pub const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0; + pub const NORMALIZED_HALF_CATCHER_WIDTH: f32 = 41.0; + + const ABSOLUTE_PLAYER_POSITIONING_ERROR: f32 = 16.0; pub fn new( hit_object: &PalpableObject, last_object: &PalpableObject, clock_rate: f64, scaling_factor: f32, + last_player_pos: Option, idx: usize, ) -> Self { let normalized_pos = hit_object.effective_x() * scaling_factor; @@ -33,16 +40,48 @@ impl CatchDifficultyObject { let last_object = LastObject { hyper_dash: last_object.hyper_dash, dist_to_hyper_dash: last_object.dist_to_hyper_dash, + player_pos: last_player_pos, }; - Self { + let mut this = Self { idx, start_time, delta_time, normalized_pos, last_normalized_pos, + player_pos: 0.0, + last_player_pos: 0.0, + dist_moved: 0.0, + exact_dist_moved: 0.0, strain_time, last_object, + }; + + this.set_movement_state(); + + this + } + + fn set_movement_state(&mut self) { + self.last_player_pos = self + .last_object + .player_pos + .unwrap_or(self.last_normalized_pos); + + let term = Self::NORMALIZED_HALF_CATCHER_WIDTH - Self::ABSOLUTE_PLAYER_POSITIONING_ERROR; + + self.player_pos = self + .last_player_pos + .clamp(self.normalized_pos - term, self.normalized_pos + term); + + self.dist_moved = self.player_pos - self.last_player_pos; + + // * For the exact position we consider that the catcher is in the correct position for both objects + self.exact_dist_moved = self.normalized_pos - self.last_player_pos; + + // * After a hyperdash we ARE in the correct position. Always! + if self.last_object.hyper_dash { + self.player_pos = self.normalized_pos; } } } @@ -50,6 +89,7 @@ impl CatchDifficultyObject { pub struct LastObject { pub hyper_dash: bool, pub dist_to_hyper_dash: f32, + pub player_pos: Option, } impl IDifficultyObject for CatchDifficultyObject { diff --git a/src/catch/difficulty/skills/movement.rs b/src/catch/difficulty/skills/movement.rs index 2a6bc764..63288123 100644 --- a/src/catch/difficulty/skills/movement.rs +++ b/src/catch/difficulty/skills/movement.rs @@ -1,22 +1,12 @@ -use crate::{catch::difficulty::object::CatchDifficultyObject, util::float_ext::FloatExt}; +use crate::catch::difficulty::{evaluators::MovementEvaluator, object::CatchDifficultyObject}; define_skill! { pub struct Movement: StrainDecaySkill => [CatchDifficultyObject][CatchDifficultyObject] { - half_catcher_width: f32, clock_rate: f64, - last_player_pos: Option = None, - last_dist_moved: f32 = 0.0, - last_exact_dist_moved: f32 = 0.0, - last_strain_time: f64 = 0.0, - is_in_buzz_section: bool = false, } } impl Movement { - const ABSOLUTE_PLAYER_POSITIONING_ERROR: f32 = 16.0; - const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0; - const DIRECTION_CHANGE_BONUS: f64 = 21.0; - const SKILL_MULTIPLIER: f64 = 1.0; const STRAIN_DECAY_BASE: f64 = 0.2; @@ -27,82 +17,8 @@ impl Movement { fn strain_value_of( &mut self, curr: &CatchDifficultyObject, - _: &[CatchDifficultyObject], + diff_objects: &[CatchDifficultyObject], ) -> f64 { - let last_player_pos = self.last_player_pos.unwrap_or(curr.last_normalized_pos); - - let term = Self::NORMALIZED_HITOBJECT_RADIUS - Self::ABSOLUTE_PLAYER_POSITIONING_ERROR; - let mut player_pos = - last_player_pos.clamp(curr.normalized_pos - term, curr.normalized_pos + term); - - let dist_moved = player_pos - last_player_pos; - - // * For the exact position we consider that the catcher is in the correct position for both objects - let exact_dist_moved = curr.normalized_pos - last_player_pos; - - let weighted_strain_time = curr.strain_time + 13.0 + (3.0 / self.clock_rate); - - let mut dist_addition = f64::from(dist_moved.abs()).powf(1.3) / 510.0; - let sqrt_strain = weighted_strain_time.sqrt(); - - let mut edge_dash_bonus: f64 = 0.0; - - if dist_moved.abs() > 0.1 { - if self.last_dist_moved.abs() > 0.1 - && dist_moved.signum() != self.last_dist_moved.signum() - { - let bonus_factor = f64::from(dist_moved.abs().min(50.0) / 50.0); - let anti_flow_factor = - f64::from(self.last_dist_moved.abs().min(70.0) / 70.0).max(0.38); - - dist_addition += Self::DIRECTION_CHANGE_BONUS - / (self.last_strain_time + 16.0).sqrt() - * bonus_factor - * anti_flow_factor - * (1.0 - (weighted_strain_time / 1000.0).powf(3.0)).max(0.0); - } - - dist_addition += 12.5 - * f64::from(f32::abs(dist_moved).min(Self::NORMALIZED_HITOBJECT_RADIUS * 2.0)) - / f64::from(Self::NORMALIZED_HITOBJECT_RADIUS * 6.0) - / sqrt_strain; - } - - if curr.last_object.dist_to_hyper_dash <= 20.0 { - if curr.last_object.hyper_dash { - player_pos = curr.normalized_pos; - } else { - edge_dash_bonus += 5.7; - } - - dist_addition *= 1.0 - + edge_dash_bonus - * f64::from((20.0 - curr.last_object.dist_to_hyper_dash) / 20.0) - * ((curr.strain_time * self.clock_rate).min(265.0) / 265.0).powf(1.5); - } - - // * There is an edge case where horizontal back and forth sliders create "buzz" patterns which are repeated "movements" with a distance lower than - // * the platter's width but high enough to be considered a movement due to the absolute_player_positioning_error and normalized_hitobject_radius offsets - // * We are detecting this exact scenario. The first back and forth is counted but all subsequent ones are nullified. - // * To achieve that, we need to store the exact distances (distance ignoring absolute_player_positioning_error and normalized_hitobject_radius) - if exact_dist_moved.abs() <= self.half_catcher_width * 2.0 - && ::eq(exact_dist_moved, -self.last_exact_dist_moved) - && ::eq(curr.strain_time, self.last_strain_time) - { - if self.is_in_buzz_section { - dist_addition = 0.0; - } else { - self.is_in_buzz_section = true; - } - } else { - self.is_in_buzz_section = false; - } - - self.last_player_pos = Some(player_pos); - self.last_dist_moved = dist_moved; - self.last_strain_time = curr.strain_time; - self.last_exact_dist_moved = exact_dist_moved; - - dist_addition / weighted_strain_time + MovementEvaluator::evaluate_diff_of(curr, diff_objects, self.clock_rate) } } diff --git a/src/catch/object/juice_stream.rs b/src/catch/object/juice_stream.rs index 19e51487..62492436 100644 --- a/src/catch/object/juice_stream.rs +++ b/src/catch/object/juice_stream.rs @@ -44,7 +44,7 @@ impl<'a> JuiceStream<'a> { point.slider_velocity }); - let path = slider.curve(GameMode::Catch, &mut bufs.curve); + let path = slider.borrowed_curve(GameMode::Catch, &mut bufs.curve); let velocity = JuiceStream::BASE_SCORING_DIST * slider_multiplier / get_precision_adjusted_beat_len(slider_velocity, beat_len); diff --git a/src/catch/performance/calculator.rs b/src/catch/performance/calculator.rs index 81beb782..582d9a9a 100644 --- a/src/catch/performance/calculator.rs +++ b/src/catch/performance/calculator.rs @@ -21,9 +21,9 @@ impl<'a> CatchPerformanceCalculator<'a> { impl CatchPerformanceCalculator<'_> { pub fn calculate(self) -> CatchPerformanceAttributes { - let attributes = &self.attrs; - let stars = attributes.stars; - let max_combo = attributes.max_combo(); + let attrs = &self.attrs; + let stars = attrs.stars; + let max_combo = attrs.max_combo(); // Relying heavily on aim let mut pp = (5.0 * (stars / 0.0049).max(1.0) - 4.0).powf(2.0) / 100_000.0; @@ -48,12 +48,17 @@ impl CatchPerformanceCalculator<'_> { // Combo scaling if self.state.max_combo > 0 { - pp *= (f64::from(self.state.max_combo).powf(0.8) / f64::from(max_combo).powf(0.8)) + pp *= (f64::from(self.state.max_combo).powf(0.35) / f64::from(max_combo).powf(0.35)) .min(1.0); } // AR scaling - let ar = attributes.ar; + let ar = if attrs.preempt > 1200.0 { + -(attrs.preempt - 1800.0) / 120.0 + } else { + -(attrs.preempt - 1200.0) / 150.0 + 5.0 + }; + let mut ar_factor = 1.0; if ar > 9.0 { ar_factor += 0.1 * (ar - 9.0) + f64::from(u8::from(ar > 10.0)) * 0.1 * (ar - 10.0); diff --git a/src/catch/performance/gradual.rs b/src/catch/performance/gradual.rs index affb3f54..2c4f719d 100644 --- a/src/catch/performance/gradual.rs +++ b/src/catch/performance/gradual.rs @@ -115,7 +115,7 @@ impl CatchGradualPerformance { /// /// Note that the count is zero-indexed, so `n=0` will process 1 object, /// `n=1` will process 2, and so on. - #[allow(clippy::missing_panics_doc)] + #[expect(clippy::missing_panics_doc, reason = "unreachable")] pub fn nth(&mut self, state: CatchScoreState, n: usize) -> Option { let performance = self .difficulty @@ -131,7 +131,7 @@ impl CatchGradualPerformance { } /// Returns the amount of remaining objects. - #[allow(clippy::len_without_is_empty)] + #[expect(clippy::len_without_is_empty, reason = "TODO")] pub fn len(&self) -> usize { self.difficulty.len() } diff --git a/src/catch/performance/hitresult_generator/fast.rs b/src/catch/performance/hitresult_generator/fast.rs index 2c1c6d23..2ee963ae 100644 --- a/src/catch/performance/hitresult_generator/fast.rs +++ b/src/catch/performance/hitresult_generator/fast.rs @@ -42,7 +42,7 @@ impl HitResultGenerator for Fast { // Calculate how many successful catches we need for target accuracy // acc = catches / total_objects // catches = acc * total_objects - let catches_needed = (acc * f64::from(total_objects)).round() as u32; + let catches_needed = (acc * f64::from(total_objects)).round_ties_even() as u32; // Maximum possible catches considering misses let max_fruit_droplet_catches = (n_fruits + n_droplets).saturating_sub(misses); diff --git a/src/catch/performance/mod.rs b/src/catch/performance/mod.rs index 82729921..d8596ad1 100644 --- a/src/catch/performance/mod.rs +++ b/src/catch/performance/mod.rs @@ -281,7 +281,7 @@ impl<'map> CatchPerformance<'map> { } /// Provide parameters through an [`CatchScoreState`]. - #[expect(clippy::needless_pass_by_value)] + #[expect(clippy::needless_pass_by_value, reason = "more sensible")] pub const fn state(mut self, state: CatchScoreState) -> Self { let CatchScoreState { max_combo, @@ -420,6 +420,7 @@ impl<'map> TryFrom> for CatchPerformance<'map> { misses, hitresult_priority: _, hitresult_generator: _, + legacy_total_score: _, } = osu; Ok(Self { diff --git a/src/catch/score_state.rs b/src/catch/score_state.rs index 26d9f455..8a49cb4c 100644 --- a/src/catch/score_state.rs +++ b/src/catch/score_state.rs @@ -30,6 +30,12 @@ impl CatchHitResults { self.fruits + self.droplets + self.tiny_droplets + self.tiny_droplet_misses + self.misses } + /// Return the total amount of successful hits by adding up fruits, + /// droplets, and tiny droplets. + pub const fn total_successful_hits(&self) -> u32 { + self.fruits + self.droplets + self.tiny_droplets + } + /// Calculate the accuracy between `0.0` and `1.0` for this state. pub fn accuracy(&self) -> f64 { let total_hits = self.total_hits(); @@ -38,7 +44,7 @@ impl CatchHitResults { return 0.0; } - let numerator = self.fruits + self.droplets + self.tiny_droplets; + let numerator = self.total_successful_hits(); let denominator = total_hits; f64::from(numerator) / f64::from(denominator) diff --git a/src/mania/convert/mod.rs b/src/mania/convert/mod.rs index d4179133..b66b4119 100644 --- a/src/mania/convert/mod.rs +++ b/src/mania/convert/mod.rs @@ -1,4 +1,4 @@ -use std::cmp::Ordering; +use std::cmp::{self, Ordering}; use rosu_map::{section::general::GameMode, util::Pos}; @@ -13,7 +13,6 @@ use crate::{ util::{ limited_queue::LimitedQueue, random::{csharp::Random as CsharpRandom, osu::Random as OsuRandom}, - sort, }, }; @@ -143,7 +142,6 @@ pub fn convert(map: &mut Beatmap, mods: &GameMods) { map.hit_sounds.clear(); map.hit_objects = new_hit_objects; map.hit_objects.sort_by(cmp_by_start_time); - sort::osu_legacy(&mut map.hit_objects); map.mode = GameMode::Mania; map.is_convert = true; @@ -197,9 +195,9 @@ fn target_columns(map: &Beatmap, mods: &GameMods) -> f32 { } } - #[allow(clippy::manual_clamp, reason = "keeping it in-sync with lazer")] + #[expect(clippy::manual_clamp, reason = "staying in-sync with lazer")] { - ((rounded_od as i32) + 1).min(7).max(4) as f32 + cmp::max(cmp::min((rounded_od as i32) + 1, 7), 4) as f32 } } diff --git a/src/mania/convert/pattern_generator/path_object.rs b/src/mania/convert/pattern_generator/path_object.rs index 3e40be90..38804f80 100644 --- a/src/mania/convert/pattern_generator/path_object.rs +++ b/src/mania/convert/pattern_generator/path_object.rs @@ -30,7 +30,7 @@ pub struct PathObjectPatternGenerator<'h> { } impl<'h> PathObjectPatternGenerator<'h> { - #[allow(clippy::too_many_arguments)] + #[expect(clippy::too_many_arguments, reason = "it is what it is /shrug")] pub fn new( random: &'h mut Random, hit_object: &'h HitObject, @@ -107,8 +107,7 @@ impl<'h> PathObjectPatternGenerator<'h> { for obj in orig_pattern.hit_objects { let col = ManiaObject::column(obj.pos.x, self.inner.total_columns as f32) as u8; - // Keeping it in-sync with lazer - #[allow(clippy::if_not_else)] + #[expect(clippy::if_not_else, reason = "staying in-sync with lazer")] if self.end_time != obj.end_time().round_ties_even() as i32 { intermediate_pattern.add_object(obj, col); } else { diff --git a/src/mania/difficulty/evaluators/individual.rs b/src/mania/difficulty/evaluators/individual.rs new file mode 100644 index 00000000..d581b1f1 --- /dev/null +++ b/src/mania/difficulty/evaluators/individual.rs @@ -0,0 +1,28 @@ +use crate::{mania::difficulty::object::ManiaDifficultyObject, util::sync::Weak}; + +pub struct IndividualStrainEvaluator; + +impl IndividualStrainEvaluator { + pub fn evaluate_diff_of(curr: &ManiaDifficultyObject) -> f64 { + let mania_curr = curr; + let start_time = curr.start_time; + let end_time = curr.end_time; + + // * We award a bonus if this note starts and ends before the end of another hold note. + let with_bonus = mania_curr + .prev_hit_objects + .iter() + .flatten() + .filter_map(Weak::upgrade) + .any(|rc| { + let mania_prev = rc.get(); + + mania_prev.end_time > end_time + 1.0 && start_time > mania_prev.start_time + 1.0 + }); + + // * Factor to all additional strains in case something else is held + let hold_factor = if with_bonus { 1.25 } else { 1.0 }; + + 2.0 * hold_factor + } +} diff --git a/src/mania/difficulty/evaluators/mod.rs b/src/mania/difficulty/evaluators/mod.rs new file mode 100644 index 00000000..736a365f --- /dev/null +++ b/src/mania/difficulty/evaluators/mod.rs @@ -0,0 +1,4 @@ +pub use self::{individual::IndividualStrainEvaluator, overall::OverallStrainEvaluator}; + +mod individual; +mod overall; diff --git a/src/mania/difficulty/evaluators/overall.rs b/src/mania/difficulty/evaluators/overall.rs new file mode 100644 index 00000000..1c75635b --- /dev/null +++ b/src/mania/difficulty/evaluators/overall.rs @@ -0,0 +1,64 @@ +use crate::{ + mania::difficulty::object::ManiaDifficultyObject, + util::{difficulty::logistic, sync::Weak}, +}; + +pub struct OverallStrainEvaluator; + +impl OverallStrainEvaluator { + const RELEASE_THRESHOLD: f64 = 30.0; + + pub fn evaluate_diff_of(curr: &ManiaDifficultyObject) -> f64 { + let mania_curr = curr; + let start_time = curr.start_time; + let end_time = curr.end_time; + let mut is_overlapping = false; + + // * Lowest value we can assume with the current information + let mut closest_end_time = (end_time - start_time).abs(); + // * Factor to all additional strains in case something else is held + let mut hold_factor = 1.0; + // * Addition to the current note in case it's a hold and has to be released awkwardly + let mut hold_addition = 0.0; + + for mania_prev in mania_curr + .prev_hit_objects + .iter() + .flatten() + .filter_map(Weak::upgrade) + { + let mania_prev_ref = mania_prev.get(); + + // * The current note is overlapped if a previous note or end is + // * overlapping the current note body + is_overlapping |= mania_prev_ref.end_time > start_time + 1.0 + && end_time > mania_prev_ref.end_time + 1.0 + && start_time > mania_prev_ref.start_time + 1.0; + + // * We give a slight bonus to everything if something is held meanwhile + if mania_prev_ref.end_time > end_time + 1.0 + && start_time > mania_prev_ref.start_time + 1.0 + { + hold_factor = 1.25; + } + + closest_end_time = closest_end_time.min((end_time - mania_prev_ref.end_time).abs()); + } + + // * The hold addition is given if there was an overlap, however it is only valid if there are no other note with a similar ending. + // * Releasing multiple notes is just as easy as releasing 1. Nerfs the hold addition by half if the closest release is release_threshold away. + // * holdAddition + // * ^ + // * 1.0 + - - - - - -+----------- + // * | / + // * 0.5 + - - - - -/ Sigmoid Curve + // * | /| + // * 0.0 +--------+-+---------------> Release Difference / ms + // * release_threshold + if is_overlapping { + hold_addition = logistic(closest_end_time, Self::RELEASE_THRESHOLD, 0.27, None); + } + + (1.0 + hold_addition) * hold_factor + } +} diff --git a/src/mania/difficulty/gradual.rs b/src/mania/difficulty/gradual.rs index 4200f21a..7971e4ae 100644 --- a/src/mania/difficulty/gradual.rs +++ b/src/mania/difficulty/gradual.rs @@ -7,6 +7,7 @@ use crate::{ any::difficulty::skills::StrainSkill, mania::{convert, object::ObjectParams}, model::{hit_object::HitObject, mode::ConvertError}, + util::sync::RefCount, }; use super::{ @@ -52,7 +53,7 @@ pub struct ManiaGradualDifficulty { objects_is_circle: Box<[bool]>, is_convert: bool, strain: Strain, - diff_objects: Box<[ManiaDifficultyObject]>, + diff_objects: Box<[RefCount]>, note_state: NoteState, } @@ -79,10 +80,13 @@ impl ManiaGradualDifficulty { convert::apply_random_to_beatmap(map.to_mut(), seed); } + let map = map.to_mut(); + map.mania_hitobjects_legacy_sort(); + let take = difficulty.get_passed_objects(); let total_columns = map.cs.round_ties_even().max(1.0); let clock_rate = difficulty.get_clock_rate(); - let mut params = ObjectParams::new(&map); + let mut params = ObjectParams::new(map); let mania_objects = map .hit_objects @@ -90,7 +94,11 @@ impl ManiaGradualDifficulty { .map(|h| ManiaObject::new(h, total_columns, &mut params)) .take(take); - let diff_objects = DifficultyValues::create_difficulty_objects(clock_rate, mania_objects); + let diff_objects = DifficultyValues::create_difficulty_objects( + clock_rate, + total_columns as usize, + mania_objects, + ); let strain = Strain::new(total_columns as usize); @@ -132,7 +140,7 @@ impl Iterator for ManiaGradualDifficulty { // yet and just skip processing. if self.idx > 0 { let curr = self.diff_objects.get(self.idx - 1)?; - self.strain.process(curr, &self.diff_objects); + self.strain.process(&curr.get(), &self.diff_objects); let is_circle = self.objects_is_circle[self.idx]; increment_combo( @@ -181,7 +189,7 @@ impl Iterator for ManiaGradualDifficulty { for (curr, is_circle) in skip_iter.take(take) { increment_combo(*is_circle, curr, &mut self.note_state, clock_rate); - self.strain.process(curr, &self.diff_objects); + self.strain.process(&curr.get(), &self.diff_objects); self.idx += 1; } @@ -197,14 +205,16 @@ impl ExactSizeIterator for ManiaGradualDifficulty { fn increment_combo( is_circle: bool, - diff_obj: &ManiaDifficultyObject, + diff_obj: &RefCount, state: &mut NoteState, clock_rate: f64, ) { + let h = diff_obj.get(); + increment_combo_raw( is_circle, - diff_obj.start_time * clock_rate, - diff_obj.end_time * clock_rate, + h.start_time * clock_rate, + h.end_time * clock_rate, state, ); } diff --git a/src/mania/difficulty/mod.rs b/src/mania/difficulty/mod.rs index 4686931e..35d03ac4 100644 --- a/src/mania/difficulty/mod.rs +++ b/src/mania/difficulty/mod.rs @@ -10,10 +10,12 @@ use crate::{ object::{ManiaObject, ObjectParams}, }, model::mode::ConvertError, + util::sync::RefCount, }; use super::{attributes::ManiaDifficultyAttributes, convert}; +mod evaluators; pub mod gradual; mod object; mod skills; @@ -38,9 +40,12 @@ pub fn difficulty( convert::apply_random_to_beatmap(map.to_mut(), seed); } + let map = map.to_mut(); + map.mania_hitobjects_legacy_sort(); + let n_objects = cmp::min(difficulty.get_passed_objects(), map.hit_objects.len()) as u32; - let values = DifficultyValues::calculate(difficulty, &map); + let values = DifficultyValues::calculate(difficulty, map); Ok(ManiaDifficultyAttributes { stars: values.strain.into_difficulty_value() * DIFFICULTY_MULTIPLIER, @@ -70,12 +75,13 @@ impl DifficultyValues { .map(|h| ManiaObject::new(h, total_columns, &mut params)) .take(take); - let diff_objects = Self::create_difficulty_objects(clock_rate, mania_objects); + let diff_objects = + Self::create_difficulty_objects(clock_rate, total_columns as usize, mania_objects); let mut strain = Strain::new(total_columns as usize); for curr in diff_objects.iter() { - strain.process(curr, &diff_objects); + strain.process(&curr.get(), &diff_objects); } Self { @@ -87,26 +93,32 @@ impl DifficultyValues { pub fn create_difficulty_objects( clock_rate: f64, + total_columns: usize, mut mania_objects: impl ExactSizeIterator, - ) -> Box<[ManiaDifficultyObject]> { - let Some(first) = mania_objects.next() else { + ) -> Box<[RefCount]> { + let Some(mut prev) = mania_objects.next() else { return Box::default(); }; let n_diff_objects = mania_objects.len(); - let diff_objects_iter = mania_objects.enumerate().scan(first, |last, (i, base)| { - let diff_object = ManiaDifficultyObject::new(&base, last, clock_rate, i); - *last = base; + let mut objects = Vec::with_capacity(n_diff_objects); + let mut per_column_objects = vec![Vec::new(); total_columns].into_boxed_slice(); + + for curr in mania_objects { + let curr_obj = + ManiaDifficultyObject::new(&curr, &prev, clock_rate, &objects, &per_column_objects); - Some(diff_object) - }); + let column = curr_obj.column; + let curr_obj = RefCount::new(curr_obj); + per_column_objects[column].push(curr_obj.downgrade()); + objects.push(curr_obj); - let mut diff_objects = Vec::with_capacity(n_diff_objects); - diff_objects.extend(diff_objects_iter); + prev = curr; + } - debug_assert_eq!(n_diff_objects, diff_objects.len()); + debug_assert_eq!(n_diff_objects, objects.len()); - diff_objects.into_boxed_slice() + objects.into_boxed_slice() } } diff --git a/src/mania/difficulty/object.rs b/src/mania/difficulty/object.rs index cd3b74bf..27935268 100644 --- a/src/mania/difficulty/object.rs +++ b/src/mania/difficulty/object.rs @@ -1,38 +1,86 @@ use crate::{ any::difficulty::object::{HasStartTime, IDifficultyObject}, mania::object::ManiaObject, + util::sync::{RefCount, Weak}, }; pub struct ManiaDifficultyObject { pub idx: usize, - pub base_column: usize, pub delta_time: f64, pub start_time: f64, pub end_time: f64, + pub column: usize, + pub prev_hit_objects: Box<[Option>]>, + pub column_strain_time: f64, } impl ManiaDifficultyObject { - pub fn new(base: &ManiaObject, last: &ManiaObject, clock_rate: f64, idx: usize) -> Self { + pub fn new( + base: &ManiaObject, + last_object: &ManiaObject, + clock_rate: f64, + objects: &[RefCount], + per_column_objects: &[Vec>], + ) -> Self { + let idx = objects.len(); + + let start_time = base.start_time / clock_rate; + let delta_time = (base.start_time - last_object.start_time) / clock_rate; + let end_time = base.end_time / clock_rate; + + let total_columns = per_column_objects.len(); + let column = base.column; + let column_idx = per_column_objects[column].len(); + let mut prev_hit_objects = vec![None; total_columns].into_boxed_slice(); + + let column_strain_time = start_time + - Self::prev_in_column(0, column_idx, column, per_column_objects) + .and_then(Weak::upgrade) + .map_or(start_time, |h| h.get().start_time); + + if idx > 0 { + let prev_note = &objects[idx - 1]; + let prev_note_ref = prev_note.get(); + + prev_hit_objects.clone_from(&prev_note_ref.prev_hit_objects); + + // * intentionally depends on processing order to match live. + prev_hit_objects[prev_note_ref.column] = Some(prev_note.downgrade()); + } + Self { idx, - base_column: base.column, - delta_time: (base.start_time - last.start_time) / clock_rate, - start_time: base.start_time / clock_rate, - end_time: base.end_time / clock_rate, + delta_time, + start_time, + end_time, + column, + prev_hit_objects, + column_strain_time, } } + + fn prev_in_column( + backwards_idx: usize, + column_idx: usize, + column: usize, + per_column_objects: &[Vec>], + ) -> Option<&Weak> { + column_idx + .checked_sub(backwards_idx + 1) + .and_then(|idx| per_column_objects[column].get(idx)) + } } impl IDifficultyObject for ManiaDifficultyObject { - type DifficultyObjects = [Self]; + type DifficultyObjects = [RefCount]; fn idx(&self) -> usize { self.idx } } -impl HasStartTime for ManiaDifficultyObject { +impl HasStartTime for RefCount { fn start_time(&self) -> f64 { - self.start_time + self.get().start_time } } diff --git a/src/mania/difficulty/skills/strain.rs b/src/mania/difficulty/skills/strain.rs index d2d71fec..c3b4f53f 100644 --- a/src/mania/difficulty/skills/strain.rs +++ b/src/mania/difficulty/skills/strain.rs @@ -1,25 +1,24 @@ use crate::{ any::difficulty::object::{HasStartTime, IDifficultyObject}, - mania::difficulty::object::ManiaDifficultyObject, - util::difficulty::logistic, + mania::difficulty::{ + evaluators::{IndividualStrainEvaluator, OverallStrainEvaluator}, + object::ManiaDifficultyObject, + }, + util::sync::RefCount, }; define_skill! { - #[allow(clippy::struct_field_names)] - pub struct Strain: StrainDecaySkill => [ManiaDifficultyObject][ManiaDifficultyObject] { - start_times: Box<[f64]>, - end_times: Box<[f64]>, + #[expect(clippy::struct_field_names, reason = "using lazer's names")] + pub struct Strain: StrainDecaySkill => [RefCount][ManiaDifficultyObject] { individual_strains: Box<[f64]>, - individual_strain: f64 = 0.0, + highest_individual_strain: f64 = 0.0, overall_strain: f64 = 1.0, } pub fn new(total_columns: usize) -> Self { Self { - start_times: vec![0.0; total_columns].into_boxed_slice(), - end_times: vec![0.0; total_columns].into_boxed_slice(), individual_strains: vec![0.0; total_columns].into_boxed_slice(), - individual_strain: 0.0, + highest_individual_strain: 0.0, overall_strain: 1.0, } } @@ -28,7 +27,6 @@ define_skill! { impl Strain { const INDIVIDUAL_DECAY_BASE: f64 = 0.125; const OVERALL_DECAY_BASE: f64 = 0.3; - const RELEASE_THRESHOLD: f64 = 30.0; const SKILL_MULTIPLIER: f64 = 1.0; const STRAIN_DECAY_BASE: f64 = 1.0; @@ -37,14 +35,14 @@ impl Strain { &self, offset: f64, curr: &ManiaDifficultyObject, - objects: &[ManiaDifficultyObject], + objects: &[RefCount], ) -> f64 { let prev_start_time = curr .previous(0, objects) .map_or(0.0, HasStartTime::start_time); apply_decay( - self.individual_strain, + self.highest_individual_strain, offset - prev_start_time, Self::INDIVIDUAL_DECAY_BASE, ) + apply_decay( @@ -57,78 +55,38 @@ impl Strain { fn strain_value_of( &mut self, curr: &ManiaDifficultyObject, - _: &[ManiaDifficultyObject], + _: &[RefCount], ) -> f64 { let mania_curr = curr; - let start_time = mania_curr.start_time; - let end_time = mania_curr.end_time; - let column = mania_curr.base_column; - let mut is_overlapping = false; - // * Lowest value we can assume with the current information - let mut closest_end_time = (end_time - start_time).abs(); - // * Factor to all additional strains in case something else is held - let mut hold_factor = 1.0; - // * Addition to the current note in case it's a hold and has to be released awkwardly - let mut hold_addition = 0.0; - - for i in 0..self.end_times.len() { - // * The current note is overlapped if a previous note or end is overlapping the current note body - is_overlapping |= self.end_times[i] > start_time + 1.0 - && end_time > self.end_times[i] + 1.0 - && start_time > self.start_times[i] + 1.0; - - // * We give a slight bonus to everything if something is held meanwhile - if self.end_times[i] > end_time + 1.0 && start_time > self.start_times[i] + 1.0 { - hold_factor = 1.25; - } - - closest_end_time = (end_time - self.end_times[i]).abs().min(closest_end_time); - } - - // * The hold addition is given if there was an overlap, however it is only valid if there are no other note with a similar ending. - // * Releasing multiple notes is just as easy as releasing 1. Nerfs the hold addition by half if the closest release is release_threshold away. - // * holdAddition - // * ^ - // * 1.0 + - - - - - -+----------- - // * | / - // * 0.5 + - - - - -/ Sigmoid Curve - // * | /| - // * 0.0 +--------+-+---------------> Release Difference / ms - // * release_threshold - if is_overlapping { - hold_addition = logistic(closest_end_time, Self::RELEASE_THRESHOLD, 0.27, None); - } - - // * Decay and increase individualStrains in own column - self.individual_strains[column] = apply_decay( - self.individual_strains[column], - start_time - self.start_times[column], + self.individual_strains[mania_curr.column] = apply_decay( + self.individual_strains[mania_curr.column], + mania_curr.column_strain_time, Self::INDIVIDUAL_DECAY_BASE, ); - self.individual_strains[column] += 2.0 * hold_factor; - // * For notes at the same time (in a chord), the individualStrain should be the hardest individualStrain out of those columns - self.individual_strain = if mania_curr.delta_time <= 1.0 { - self.individual_strain.max(self.individual_strains[column]) + self.individual_strains[mania_curr.column] += + IndividualStrainEvaluator::evaluate_diff_of(curr); + + // * Take the hardest individualStrain for notes that happen at the same time (in a chord). + // * This is to ensure the order in which the notes are processed does not affect the resultant total strain. + self.highest_individual_strain = if mania_curr.delta_time <= 1.0 { + self.highest_individual_strain + .max(self.individual_strains[mania_curr.column]) } else { - self.individual_strains[column] + self.individual_strains[mania_curr.column] }; - // * Decay and increase overallStrain self.overall_strain = apply_decay( self.overall_strain, - curr.delta_time, + mania_curr.delta_time, Self::OVERALL_DECAY_BASE, ); - self.overall_strain += (1.0 + hold_addition) * hold_factor; - - // * Update startTimes and endTimes arrays - self.start_times[column] = start_time; - self.end_times[column] = end_time; + self.overall_strain += OverallStrainEvaluator::evaluate_diff_of(curr); // * By subtracting CurrentStrain, this skill effectively only considers the maximum strain of any one hitobject within each strain section. - self.individual_strain + self.overall_strain - self.strain_decay_skill_current_strain + self.highest_individual_strain + self.overall_strain + - self.strain_decay_skill_current_strain } } diff --git a/src/mania/object.rs b/src/mania/object.rs index 06f1c2c4..c52e8502 100644 --- a/src/mania/object.rs +++ b/src/mania/object.rs @@ -26,7 +26,9 @@ impl ManiaObject { HitObjectKind::Slider(ref slider) => { const BASE_SCORING_DIST: f32 = 100.0; - let dist = slider.curve(GameMode::Mania, &mut params.curve_bufs).dist(); + let dist = slider + .borrowed_curve(GameMode::Mania, &mut params.curve_bufs) + .dist(); let beat_len = params .map diff --git a/src/mania/performance/gradual.rs b/src/mania/performance/gradual.rs index 3dadde4b..bc58eeb4 100644 --- a/src/mania/performance/gradual.rs +++ b/src/mania/performance/gradual.rs @@ -93,7 +93,7 @@ impl ManiaGradualPerformance { /// /// Note that the count is zero-indexed, so `n=0` will process 1 object, /// `n=1` will process 2, and so on. - #[allow(clippy::missing_panics_doc)] + #[expect(clippy::missing_panics_doc, reason = "unreachable")] pub fn nth(&mut self, state: ManiaScoreState, n: usize) -> Option { let performance = self .difficulty @@ -109,7 +109,7 @@ impl ManiaGradualPerformance { } /// Returns the amount of remaining objects. - #[allow(clippy::len_without_is_empty)] + #[expect(clippy::len_without_is_empty, reason = "TODO")] pub fn len(&self) -> usize { self.difficulty.len() } @@ -125,6 +125,9 @@ mod tests { fn next_and_nth() { let map = Beatmap::from_path("./resources/1638954.osu").unwrap(); + let mut cloned = map.clone(); + cloned.mania_hitobjects_legacy_sort(); + let difficulty = Difficulty::new().mods(88); // HDHRDT let mut gradual = ManiaGradualPerformance::new(difficulty.clone(), &map).unwrap(); @@ -139,7 +142,7 @@ mod tests { state.misses += 1; // Hold notes award two hitresults in lazer - if let Some(h) = map.hit_objects.get(i - 1) { + if let Some(h) = cloned.hit_objects.get(i - 1) { if !h.is_circle() { state.n320 += 1; } diff --git a/src/mania/performance/hitresult_generator/closest.rs b/src/mania/performance/hitresult_generator/closest.rs index 8228033d..14d84e73 100644 --- a/src/mania/performance/hitresult_generator/closest.rs +++ b/src/mania/performance/hitresult_generator/closest.rs @@ -34,7 +34,7 @@ impl HitResultGenerator for Closest { let w320: u32 = if is_classic { 60 } else { 61 }; // Target numerator for the accuracy formula - let target_numerator = (acc * f64::from(w320 * total_hits)).round() as i64; + let target_numerator = (acc * f64::from(w320 * total_hits)).round_ties_even() as i64; // Handle cases where some hitresults are provided match ( @@ -490,7 +490,7 @@ fn optimize_last_three( // Solve: 20*n100 + 10*n50 = remaining_score, n100 + n50 = remaining let raw100 = (remaining_score - 10 * i64::from(remaining)) as f64 / 10.0; - let n100 = cmp::min(remaining, raw100.round().max(0.0) as u32); + let n100 = cmp::min(remaining, raw100.round_ties_even().max(0.0) as u32); let n50 = remaining.saturating_sub(n100); (n2, n100, n50) @@ -501,7 +501,7 @@ fn optimize_last_three( // Solve: 40*n200 + 10*n50 = remaining_score, n200 + n50 = remaining let raw200 = (remaining_score - 10 * i64::from(remaining)) as f64 / 30.0; - let n200 = cmp::min(remaining, raw200.round().max(0.0) as u32); + let n200 = cmp::min(remaining, raw200.round_ties_even().max(0.0) as u32); let n50 = remaining.saturating_sub(n200); (n200, n1, n50) @@ -512,7 +512,7 @@ fn optimize_last_three( // Solve: 40*n200 + 20*n100 = remaining_score, n200 + n100 = remaining let raw200 = (remaining_score - 20 * i64::from(remaining)) as f64 / 20.0; - let n200 = cmp::min(remaining, raw200.round().max(0.0) as u32); + let n200 = cmp::min(remaining, raw200.round_ties_even().max(0.0) as u32); let n100 = remaining.saturating_sub(n200); (n200, n100, n5) @@ -535,7 +535,7 @@ fn optimize_last_three( // Solve for n100 and n50 let raw100 = (remaining_score - 10 * i64::from(remaining)) as f64 / 10.0; - let n100 = cmp::min(remaining, raw100.round().max(0.0) as u32); + let n100 = cmp::min(remaining, raw100.round_ties_even().max(0.0) as u32); let n50 = remaining.saturating_sub(n100); let actual_score = 40 * n200 + 20 * n100 + 10 * n50; diff --git a/src/mania/performance/hitresult_generator/fast.rs b/src/mania/performance/hitresult_generator/fast.rs index 6f68129f..ae74330c 100644 --- a/src/mania/performance/hitresult_generator/fast.rs +++ b/src/mania/performance/hitresult_generator/fast.rs @@ -132,7 +132,8 @@ impl HitResultGenerator for Fast { let denominator = perfect_weight * total_hits; let target_total = - f64::round((acc * f64::from(denominator) - f64::from(numerator)).max(0.0)) as u32; + f64::round_ties_even((acc * f64::from(denominator) - f64::from(numerator)).max(0.0)) + as u32; // Start by assuming every non-miss is an n50 (baseline) let baseline = diff --git a/src/mania/performance/mod.rs b/src/mania/performance/mod.rs index 2a2805bf..2b52063b 100644 --- a/src/mania/performance/mod.rs +++ b/src/mania/performance/mod.rs @@ -281,7 +281,7 @@ impl<'map> ManiaPerformance<'map> { } /// Provide parameters through an [`ManiaScoreState`]. - #[allow(clippy::needless_pass_by_value)] + #[expect(clippy::needless_pass_by_value, reason = "more sensible")] pub const fn state(mut self, state: ManiaScoreState) -> Self { let ManiaScoreState { n320, @@ -303,7 +303,6 @@ impl<'map> ManiaPerformance<'map> { } /// Create the [`ManiaScoreState`] that will be used for performance calculation. - #[allow(clippy::too_many_lines, clippy::similar_names)] pub fn generate_state(&mut self) -> Result { self.map_or_attrs.insert_attrs(&self.difficulty)?; @@ -425,6 +424,7 @@ impl<'map> TryFrom> for ManiaPerformance<'map> { misses, hitresult_priority, hitresult_generator: _, + legacy_total_score: _, } = osu; Ok(Self { diff --git a/src/model/beatmap/attributes.rs b/src/model/beatmap/attributes.rs deleted file mode 100644 index 543b1404..00000000 --- a/src/model/beatmap/attributes.rs +++ /dev/null @@ -1,521 +0,0 @@ -use rosu_map::section::general::GameMode; - -use crate::{Difficulty, any::difficulty::ModsDependent, model::mods::GameMods}; - -use super::Beatmap; - -/// Summary struct for a [`Beatmap`]'s attributes. -#[derive(Clone, Debug, PartialEq)] -pub struct BeatmapAttributes { - /// The approach rate. - pub ar: f64, - /// The overall difficulty. - pub od: f64, - /// The circle size. - pub cs: f64, - /// The health drain rate - pub hp: f64, - /// The clock rate with respect to mods. - pub clock_rate: f64, - /// The hit windows for approach rate and overall difficulty. - pub hit_windows: HitWindows, -} - -/// AR and OD hit windows -#[derive(Copy, Clone, Debug, PartialEq)] -pub struct HitWindows { - /// Hit window for approach rate i.e. `TimePreempt` in milliseconds. - pub ar: f64, - /// Great hit window for overall difficulty i.e. time to hit a 300 ("Great") in milliseconds. - pub od_great: f64, - /// Ok hit window for overall difficulty i.e. time to hit a 100 ("Ok") in milliseconds. - /// - /// `None` for osu!mania. - pub od_ok: Option, - /// Meh hit window for overall difficulty i.e. time to hit a 50 ("Meh") in milliseconds. - /// - /// Only `Some` for osu!standard. - pub od_meh: Option, -} - -/// A builder for [`BeatmapAttributes`] and [`HitWindows`]. -#[derive(Clone, Debug, PartialEq)] -#[must_use] -pub struct BeatmapAttributesBuilder { - mode: GameMode, - is_convert: bool, - ar: ModsDependentKind, - od: ModsDependentKind, - cs: ModsDependentKind, - hp: ModsDependentKind, - mods: GameMods, - clock_rate: Option, -} - -struct GameModeHitWindows { - min: f64, - avg: f64, - max: f64, -} - -const OSU_GREAT: GameModeHitWindows = GameModeHitWindows { - min: 80.0, - avg: 50.0, - max: 20.0, -}; - -const OSU_OK: GameModeHitWindows = GameModeHitWindows { - min: 140.0, - avg: 100.0, - max: 60.0, -}; - -const OSU_MEH: GameModeHitWindows = GameModeHitWindows { - min: 200.0, - avg: 150.0, - max: 100.0, -}; - -const TAIKO_GREAT: GameModeHitWindows = GameModeHitWindows { - min: 50.0, - avg: 35.0, - max: 20.0, -}; - -const TAIKO_OK: GameModeHitWindows = GameModeHitWindows { - min: 120.0, - avg: 80.0, - max: 50.0, -}; - -const AR_WINDOWS: GameModeHitWindows = GameModeHitWindows { - min: 1800.0, - avg: 1200.0, - max: 450.0, -}; - -impl BeatmapAttributesBuilder { - /// Create a new [`BeatmapAttributesBuilder`]. - /// - /// The mode will be `GameMode::Osu` and attributes are set to `5.0`. - pub const fn new() -> Self { - Self { - mode: GameMode::Osu, - is_convert: false, - ar: ModsDependentKind::DEFAULT, - od: ModsDependentKind::DEFAULT, - cs: ModsDependentKind::DEFAULT, - hp: ModsDependentKind::DEFAULT, - mods: GameMods::DEFAULT, - clock_rate: None, - } - } - - /// Use the given [`Beatmap`]'s attributes, mode, and convert status. - pub fn map(self, map: &Beatmap) -> Self { - Self { - mode: map.mode, - // Clamping necessary to match lazer on maps like /b/4243836. - ar: ModsDependentKind::Default(ModsDependent::new(map.ar.clamp(0.0, 10.0))), - od: ModsDependentKind::Default(ModsDependent::new(map.od.clamp(0.0, 10.0))), - cs: ModsDependentKind::Default(ModsDependent::new(map.cs)), - hp: ModsDependentKind::Default(ModsDependent::new(map.hp)), - is_convert: map.is_convert, - ..self - } - } - - /// Specify the approach rate. - /// - /// `with_mods` determines if the given value should be used before - /// or after accounting for mods, e.g. on `true` the value will be - /// used as is and on `false` it will be modified based on the mods. - pub const fn ar(mut self, ar: f32, with_mods: bool) -> Self { - self.ar = ModsDependentKind::Custom(ModsDependent { - value: ar, - with_mods, - }); - - self - } - - /// Specify the overall difficulty. - /// - /// `with_mods` determines if the given value should be used before - /// or after accounting for mods, e.g. on `true` the value will be - /// used as is and on `false` it will be modified based on the mods. - pub const fn od(mut self, od: f32, with_mods: bool) -> Self { - self.od = ModsDependentKind::Custom(ModsDependent { - value: od, - with_mods, - }); - - self - } - - /// Specify the circle size. - /// - /// `with_mods` determines if the given value should be used before - /// or after accounting for mods, e.g. on `true` the value will be - /// used as is and on `false` it will be modified based on the mods. - pub const fn cs(mut self, cs: f32, with_mods: bool) -> Self { - self.cs = ModsDependentKind::Custom(ModsDependent { - value: cs, - with_mods, - }); - - self - } - - /// Specify the drain rate. - /// - /// `with_mods` determines if the given value should be used before - /// or after accounting for mods, e.g. on `true` the value will be - /// used as is and on `false` it will be modified based on the mods. - pub const fn hp(mut self, hp: f32, with_mods: bool) -> Self { - self.hp = ModsDependentKind::Custom(ModsDependent { - value: hp, - with_mods, - }); - - self - } - - /// Specify mods. - /// - /// Accepted types are - /// - `u32` - /// - [`rosu_mods::GameModsLegacy`] - /// - [`rosu_mods::GameMods`] - /// - [`rosu_mods::GameModsIntermode`] - /// - [`&rosu_mods::GameModsIntermode`](rosu_mods::GameModsIntermode) - /// - /// See - pub fn mods(mut self, mods: impl Into) -> Self { - self.mods = mods.into(); - - self - } - - /// Specify a custom clock rate. - pub const fn clock_rate(mut self, clock_rate: f64) -> Self { - self.clock_rate = Some(clock_rate); - - self - } - - /// Specify a [`GameMode`] and whether it's a converted map. - pub const fn mode(mut self, mode: GameMode, is_convert: bool) -> Self { - self.mode = mode; - self.is_convert = is_convert; - - self - } - - /// Specify all settings through [`Difficulty`]. - pub fn difficulty(self, difficulty: &Difficulty) -> Self { - Self { - mode: self.mode, - is_convert: self.is_convert, - ar: difficulty - .get_ar() - .map_or(self.ar, ModsDependentKind::Custom), - od: difficulty - .get_od() - .map_or(self.od, ModsDependentKind::Custom), - cs: difficulty - .get_cs() - .map_or(self.cs, ModsDependentKind::Custom), - hp: difficulty - .get_hp() - .map_or(self.hp, ModsDependentKind::Custom), - mods: difficulty.get_mods().clone(), - clock_rate: Some(difficulty.get_clock_rate()), - } - } - - /// Calculate the AR and OD hit windows. - pub fn hit_windows(&self) -> HitWindows { - let mods = &self.mods; - let clock_rate = self.clock_rate.unwrap_or_else(|| mods.clock_rate()); - - let ar_clock_rate = if self.ar.with_mods() { 1.0 } else { clock_rate }; - let od_clock_rate = if self.od.with_mods() { 1.0 } else { clock_rate }; - - let mod_mult = |val: f32| { - if mods.hr() { - (val * 1.4).min(10.0) - } else if mods.ez() { - val * 0.5 - } else { - val - } - }; - - let raw_ar = if self.ar.with_mods() { - self.ar.value(mods, GameMods::ar) - } else { - mod_mult(self.ar.value(mods, GameMods::ar)) - }; - - let preempt = difficulty_range(f64::from(raw_ar), AR_WINDOWS) / ar_clock_rate; - - // OD - let (great, ok, meh) = match self.mode { - GameMode::Osu | GameMode::Catch => { - let raw_od = if self.od.with_mods() { - self.od.value(mods, GameMods::od) - } else { - mod_mult(self.od.value(mods, GameMods::od)) - }; - - let great = difficulty_range(f64::from(raw_od), OSU_GREAT) / od_clock_rate; - let ok = difficulty_range(f64::from(raw_od), OSU_OK) / od_clock_rate; - let meh = difficulty_range(f64::from(raw_od), OSU_MEH) / od_clock_rate; - - (great, Some(ok), Some(meh)) - } - GameMode::Taiko => { - let raw_od = if self.od.with_mods() { - self.od.value(mods, GameMods::od) - } else { - mod_mult(self.od.value(mods, GameMods::od)) - }; - - let great = difficulty_range(f64::from(raw_od), TAIKO_GREAT) / od_clock_rate; - let ok = difficulty_range(f64::from(raw_od), TAIKO_OK) / od_clock_rate; - - (great, Some(ok), None) - } - GameMode::Mania => { - let mut value = if !self.is_convert { - 34.0 + 3.0 * (10.0 - self.od.value(mods, GameMods::od)).clamp(0.0, 10.0) - } else if self.od.value(mods, GameMods::od).round_ties_even() > 4.0 { - 34.0 - } else { - 47.0 - }; - - if !self.od.with_mods() { - if mods.hr() { - value /= 1.4; - } else if mods.ez() { - value *= 1.4; - } - } - - let great = ((f64::from(value) * od_clock_rate).floor() / od_clock_rate).ceil(); - - (great, None, None) - } - }; - - HitWindows { - ar: preempt, - od_great: great, - od_ok: ok, - od_meh: meh, - } - } - - /// Calculate the [`BeatmapAttributes`]. - pub fn build(&self) -> BeatmapAttributes { - let mods = &self.mods; - let clock_rate = self.clock_rate.unwrap_or_else(|| mods.clock_rate()); - - // HP - let mut hp = self.hp.value(mods, GameMods::hp); - - if !self.hp.with_mods() { - hp *= mods.od_ar_hp_multiplier() as f32; - } - - hp = hp.min(10.0); - - // CS - let mut cs = self.cs.value(mods, GameMods::cs); - - if !self.cs.with_mods() { - if mods.hr() { - cs = (cs * 1.3).min(10.0); - } else if mods.ez() { - cs *= 0.5; - } - } - - let hit_windows = self.hit_windows(); - let HitWindows { - ar, - od_great, - od_ok: _, - od_meh: _, - } = hit_windows; - - // AR - let ar = if ar > 1200.0 { - (1800.0 - ar) / 120.0 - } else { - (1200.0 - ar) / 150.0 + 5.0 - }; - - // OD - let od = match self.mode { - GameMode::Osu => Self::osu_great_hit_window_to_od(od_great), - GameMode::Taiko => { - (TAIKO_GREAT.min - od_great) / (TAIKO_GREAT.min - TAIKO_GREAT.avg) * 5.0 - } - GameMode::Catch | GameMode::Mania => f64::from(self.od.value(mods, GameMods::od)), - }; - - BeatmapAttributes { - ar, - od, - cs: f64::from(cs), - hp: f64::from(hp), - clock_rate, - hit_windows, - } - } - - pub(crate) const fn osu_great_hit_window_to_od(hit_window: f64) -> f64 { - (OSU_GREAT.min - hit_window) / 6.0 - } -} - -impl From<&Beatmap> for BeatmapAttributesBuilder { - fn from(map: &Beatmap) -> Self { - Self::new().map(map) - } -} - -// False positive? Value looks consumed to me... -#[allow(clippy::needless_pass_by_value)] -fn difficulty_range(difficulty: f64, windows: GameModeHitWindows) -> f64 { - let GameModeHitWindows { min, avg: mid, max } = windows; - - if difficulty > 5.0 { - mid + (max - mid) * (difficulty - 5.0) / 5.0 - } else if difficulty < 5.0 { - mid - (mid - min) * (5.0 - difficulty) / 5.0 - } else { - mid - } -} - -impl Default for BeatmapAttributesBuilder { - fn default() -> Self { - Self::new() - } -} - -#[derive(Clone, Debug, PartialEq)] -enum ModsDependentKind { - Default(ModsDependent), - Custom(ModsDependent), -} - -impl ModsDependentKind { - const DEFAULT: Self = Self::Default(ModsDependent::new(5.0)); - - const fn with_mods(&self) -> bool { - match self { - ModsDependentKind::Default(inner) | ModsDependentKind::Custom(inner) => inner.with_mods, - } - } - - fn value(&self, mods: &GameMods, mods_fn: impl Fn(&GameMods) -> Option) -> f32 { - match self { - ModsDependentKind::Default(inner) => mods_fn(mods).map_or(inner.value, |n| n as f32), - ModsDependentKind::Custom(inner) => inner.value, - } - } -} - -#[cfg(test)] -mod tests { - #![allow(clippy::float_cmp)] - - use rosu_mods::{ - GameMod, GameMods, - generated_mods::{DifficultyAdjustOsu, DoubleTimeCatch, DoubleTimeOsu, HiddenOsu}, - }; - - use super::*; - - #[test] - fn default_ar() { - let gamemod = GameMod::HiddenOsu(HiddenOsu::default()); - let diff = Difficulty::new().mods(GameMods::from(gamemod)); - let attrs = BeatmapAttributesBuilder::new().difficulty(&diff).build(); - - assert_eq!(attrs.ar, 5.0); - } - - #[test] - fn custom_ar_without_mods() { - let gamemod = GameMod::DoubleTimeOsu(DoubleTimeOsu::default()); - let diff = Difficulty::new().mods(GameMods::from(gamemod)); - let attrs = BeatmapAttributesBuilder::new() - .ar(8.5, false) - .difficulty(&diff) - .build(); - - assert_eq!(attrs.ar, 10.0); - } - - #[test] - fn custom_ar_with_mods() { - let gamemod = GameMod::DoubleTimeOsu(DoubleTimeOsu::default()); - let diff = Difficulty::new().mods(GameMods::from(gamemod)); - let attrs = BeatmapAttributesBuilder::new() - .ar(8.5, true) - .difficulty(&diff) - .build(); - - assert_eq!(attrs.ar, 8.5); - } - - #[test] - fn custom_mods_ar() { - let mut mods = GameMods::new(); - mods.insert(GameMod::DoubleTimeCatch(DoubleTimeCatch::default())); - mods.insert(GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu { - approach_rate: Some(7.0), - ..DifficultyAdjustOsu::default() - })); - let diff = Difficulty::new().mods(mods); - let attrs = BeatmapAttributesBuilder::new().difficulty(&diff).build(); - - assert_eq!(attrs.ar, 9.0); - } - - #[test] - fn custom_ar_custom_mods_ar_without_mods() { - let mut mods = GameMods::new(); - mods.insert(GameMod::DoubleTimeCatch(DoubleTimeCatch::default())); - mods.insert(GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu { - approach_rate: Some(9.0), - ..DifficultyAdjustOsu::default() - })); - - let diff = Difficulty::new().mods(mods).ar(8.5, false); - let attrs = BeatmapAttributesBuilder::new().difficulty(&diff).build(); - - assert_eq!(attrs.ar, 10.0); - } - - #[test] - fn custom_ar_custom_mods_ar_with_mods() { - let mut mods = GameMods::new(); - mods.insert(GameMod::DoubleTimeCatch(DoubleTimeCatch::default())); - mods.insert(GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu { - approach_rate: Some(9.0), - ..DifficultyAdjustOsu::default() - })); - - let diff = Difficulty::new().mods(mods).ar(8.5, true); - let attrs = BeatmapAttributesBuilder::new().difficulty(&diff).build(); - - assert_eq!(attrs.ar, 8.5); - } -} diff --git a/src/model/beatmap/attributes/attribute.rs b/src/model/beatmap/attributes/attribute.rs new file mode 100644 index 00000000..505cbbd2 --- /dev/null +++ b/src/model/beatmap/attributes/attribute.rs @@ -0,0 +1,86 @@ +/// A beatmap attribute. +/// +/// It contains either: +/// - the default value (5.0) +/// - a value taken from a [`Beatmap`] or mutated default value +/// - a user-given value that may be adjusted +/// - a user-given *fixed* value that will stay as-is +/// +/// [`Beatmap`]: crate::Beatmap +#[derive(Copy, Clone, Debug, Default, PartialEq)] +pub enum BeatmapAttribute { + /// No value has been set. + /// + /// Will be treated as the default value (5.0). + #[default] + None, + /// Variable value taken from a [`Beatmap`] or mutated default value that + /// may be overriden and adjusted based on mods and clock rate. + /// + /// [`Beatmap`]: crate::Beatmap + Value(f32), + /// Given by the user and may not be overriden by custom mod values. + /// + /// Mods and clock rate may *adjust* the value, though. + /// + /// # Example + /// Mods include `DifficultyAdjust` which sets AR to 9.5 but the user + /// specified AR to be 9.7. In this case, the user's value is should take + /// precedence. + Given(f32), + /// Represents a final value that should not be adjusted based on mods or + /// clock rate. + Fixed(f32), +} + +impl BeatmapAttribute { + /// The default value for a `BeatmapAttribute`. + pub const DEFAULT: f32 = 5.0; + + /// Overwrites `self` with `other` if `other` is not `None`. + #[must_use] + pub const fn overwrite(self, other: Self) -> Self { + if let Self::None = other { self } else { other } + } + + /// Mutates the `Value` and `Given` variants. + pub fn try_mutate(&mut self, f: impl Fn(&mut f32)) { + if let Self::None = self { + *self = Self::Value(Self::DEFAULT); + } + + if let Self::Value(value) | Self::Given(value) = self { + f(value); + } + } + + /// Sets the `Value` variant only. + pub const fn try_set(&mut self, value: f32) { + match self { + Self::None => *self = Self::Value(value), + Self::Value(old) => *old = value, + _ => {} + } + } + + /// Applies `f` onto the `Value` and `Given` variants and `default` onto the + /// `Fixed` variant. + pub fn map_or_else(self, default: D, f: F) -> U + where + D: FnOnce(f32) -> U, + F: FnOnce(f32) -> U, + { + match self { + Self::None => f(Self::DEFAULT), + Self::Value(value) | Self::Given(value) => f(value), + Self::Fixed(fixed) => default(fixed), + } + } + + pub const fn get_raw(self) -> f32 { + match self { + Self::None => Self::DEFAULT, + Self::Value(value) | Self::Given(value) | Self::Fixed(value) => value, + } + } +} diff --git a/src/model/beatmap/attributes/builder.rs b/src/model/beatmap/attributes/builder.rs new file mode 100644 index 00000000..8427a50f --- /dev/null +++ b/src/model/beatmap/attributes/builder.rs @@ -0,0 +1,168 @@ +use rosu_map::section::general::GameMode; + +use crate::{ + Beatmap, Difficulty, GameMods, + model::beatmap::{ + BeatmapAttributes, + attributes::{ModStatus, attribute::BeatmapAttribute, difficulty::BeatmapDifficulty}, + }, +}; + +/// A builder for [`BeatmapAttributes`]. +#[derive(Clone, Debug, PartialEq)] +#[must_use] +pub struct BeatmapAttributesBuilder { + mode: GameMode, + is_convert: bool, + difficulty: BeatmapDifficulty, + mods: GameMods, + clock_rate: Option, +} + +#[rustfmt::skip] +macro_rules! set_attr { + ( $short:ident = $long:literal ) => { + #[doc = concat!( + "Specify the ", $long, + ". \n\n`fixed` determines if the given value should be used before \ + or after accounting for mods, e.g. on `true` the value will be used \ + as-is and on `false` it will be modified based on the mods." + )] + pub const fn $short(&mut self, $short: f32, fixed: bool) -> &mut Self { + self.difficulty.$short = if fixed { + BeatmapAttribute::Fixed($short) + } else { + BeatmapAttribute::Given($short) + }; + + self + } + }; +} + +impl BeatmapAttributesBuilder { + /// Create a new [`BeatmapAttributesBuilder`]. + /// + /// The mode will be `GameMode::Osu` and attributes are set to `5.0`. + pub const fn new() -> Self { + Self { + mode: GameMode::Osu, + is_convert: false, + difficulty: BeatmapDifficulty::DEFAULT, + mods: GameMods::DEFAULT, + clock_rate: None, + } + } + + /// Use the given [`Beatmap`]'s attributes, mode, and convert status. + pub const fn map(&mut self, map: &Beatmap) -> &mut Self { + self.mode = map.mode; + self.is_convert = map.is_convert; + + self.difficulty = BeatmapDifficulty { + // Clamping necessary to match lazer on maps like /b/4243836. + ar: BeatmapAttribute::Value(map.ar.clamp(0.0, 10.0)), + od: BeatmapAttribute::Value(map.od.clamp(0.0, 10.0)), + cs: BeatmapAttribute::Value(map.cs), + hp: BeatmapAttribute::Value(map.hp), + }; + + self + } + + set_attr!(ar = "approach rate"); + + set_attr!(od = "overall difficulty"); + + set_attr!(cs = "circle size"); + + set_attr!(hp = "drain rate"); + + /// Specify mods. + /// + /// Accepted types are + /// - `u32` + /// - [`rosu_mods::GameModsLegacy`] + /// - [`rosu_mods::GameMods`] + /// - [`rosu_mods::GameModsIntermode`] + /// - [`&rosu_mods::GameModsIntermode`](rosu_mods::GameModsIntermode) + /// + /// See + pub fn mods(&mut self, mods: impl Into) -> &mut Self { + self.mods = mods.into(); + + self + } + + /// Specify a custom clock rate. + pub const fn clock_rate(&mut self, clock_rate: f64) -> &mut Self { + self.clock_rate = Some(clock_rate); + + self + } + + /// Specify a [`GameMode`] and whether it's a converted map. + pub const fn mode(&mut self, mode: GameMode, is_convert: bool) -> &mut Self { + self.mode = mode; + self.is_convert = is_convert; + + self + } + + /// Specify all settings through [`Difficulty`]. + pub fn difficulty(&mut self, difficulty: &Difficulty) -> &mut Self { + let map_diff = difficulty.get_map_difficulty(); + + self.difficulty = BeatmapDifficulty { + ar: self.difficulty.ar.overwrite(map_diff.ar), + cs: self.difficulty.cs.overwrite(map_diff.cs), + hp: self.difficulty.hp.overwrite(map_diff.hp), + od: self.difficulty.od.overwrite(map_diff.od), + }; + self.mods = difficulty.get_mods().clone(); + self.clock_rate = Some(difficulty.get_clock_rate()); + + self + } + + /// Calculate the [`BeatmapAttributes`]. + pub fn build(&self) -> BeatmapAttributes { + let mods = &self.mods; + + let mut difficulty = self.difficulty.clone(); + difficulty.apply_mods(mods, self.mode); + + BeatmapAttributes { + difficulty, + clock_rate: self.clock_rate.unwrap_or_else(|| mods.clock_rate()), + mod_status: ModStatus::new(mods), + mode: self.mode, + is_convert: self.is_convert, + classic_and_not_v2: mods.cl() && !mods.sv2(), + } + } +} + +impl Default for BeatmapAttributesBuilder { + fn default() -> Self { + Self::new() + } +} + +impl From<&Beatmap> for BeatmapAttributesBuilder { + fn from(map: &Beatmap) -> Self { + let mut this = Self::new(); + this.map(map); + + this + } +} + +impl From<&Difficulty> for BeatmapAttributesBuilder { + fn from(difficulty: &Difficulty) -> Self { + let mut this = Self::new(); + this.difficulty(difficulty); + + this + } +} diff --git a/src/model/beatmap/attributes/difficulty.rs b/src/model/beatmap/attributes/difficulty.rs new file mode 100644 index 00000000..082c3e40 --- /dev/null +++ b/src/model/beatmap/attributes/difficulty.rs @@ -0,0 +1,107 @@ +use rosu_map::section::general::GameMode; +use rosu_mods::GameMod; + +use crate::{GameMods, model::beatmap::attributes::attribute::BeatmapAttribute}; + +#[derive(Clone, Debug, PartialEq)] +pub struct BeatmapDifficulty { + pub ar: BeatmapAttribute, + pub cs: BeatmapAttribute, + pub hp: BeatmapAttribute, + pub od: BeatmapAttribute, +} + +impl BeatmapDifficulty { + pub const DEFAULT: Self = Self { + ar: BeatmapAttribute::None, + cs: BeatmapAttribute::None, + hp: BeatmapAttribute::None, + od: BeatmapAttribute::None, + }; + + pub fn apply_mods(&mut self, mods: &GameMods, mode: GameMode) { + // First we *set* values + if let GameMods::Lazer(mods) = mods { + macro_rules! set_if_some { + ( $attr:ident = $opt:expr ) => { + if let Some(value) = $opt { + self.$attr.try_set(value as f32); + } + }; + } + + for m in mods.iter() { + let (hp, od) = match m { + GameMod::DifficultyAdjustCatch(da) => { + set_if_some!(ar = da.approach_rate); + set_if_some!(cs = da.circle_size); + + (da.drain_rate, da.overall_difficulty) + } + GameMod::DifficultyAdjustMania(da) => (da.drain_rate, da.overall_difficulty), + GameMod::DifficultyAdjustOsu(da) => { + set_if_some!(ar = da.approach_rate); + set_if_some!(cs = da.circle_size); + + (da.drain_rate, da.overall_difficulty) + } + GameMod::DifficultyAdjustTaiko(da) => { + // Ignoring slider multiplier + + (da.drain_rate, da.overall_difficulty) + } + _ => continue, + }; + + set_if_some!(hp = hp); + set_if_some!(od = od); + } + } + + // Then we *adjust* values + if mods.ez() { + const ADJUST_RATIO: f32 = 0.5; + + self.ar.try_mutate(|ar| *ar *= ADJUST_RATIO); + self.cs.try_mutate(|cs| *cs *= ADJUST_RATIO); + self.hp.try_mutate(|hp| *hp *= ADJUST_RATIO); + + match mode { + GameMode::Osu => self.od.try_mutate(|od| *od *= ADJUST_RATIO), + // Ignoring slider multiplier + GameMode::Taiko => self.od.try_mutate(|od| *od *= ADJUST_RATIO), + GameMode::Catch => self.od.try_mutate(|od| *od *= ADJUST_RATIO), + GameMode::Mania => {} + } + } else if mods.hr() { + const ADJUST_RATIO: f32 = 1.4; + + self.hp + .try_mutate(|hp| *hp = f32::min(*hp * ADJUST_RATIO, 10.0)); + + match mode { + GameMode::Osu => { + self.od + .try_mutate(|od| *od = f32::min(*od * ADJUST_RATIO, 10.0)); + // * CS uses a custom 1.3 ratio. + self.cs.try_mutate(|cs| *cs = f32::min(*cs * 1.3, 10.0)); + self.ar + .try_mutate(|ar| *ar = f32::min(*ar * ADJUST_RATIO, 10.0)); + } + // Ignoring slider multiplier + GameMode::Taiko => self + .od + .try_mutate(|od| *od = f32::min(*od * ADJUST_RATIO, 10.0)), + GameMode::Catch => { + self.od + .try_mutate(|od| *od = f32::min(*od * ADJUST_RATIO, 10.0)); + // * CS uses a custom 1.3 ratio. + self.cs.try_mutate(|cs| *cs = f32::min(*cs * 1.3, 10.0)); + self.ar + .try_mutate(|ar| *ar = f32::min(*ar * ADJUST_RATIO, 10.0)); + } + GameMode::Mania => {} + } + } + } +} diff --git a/src/model/beatmap/attributes/ext.rs b/src/model/beatmap/attributes/ext.rs new file mode 100644 index 00000000..a11db698 --- /dev/null +++ b/src/model/beatmap/attributes/ext.rs @@ -0,0 +1,39 @@ +use crate::util::float_ext::FloatExt; + +pub(crate) struct BeatmapAttributesExt; + +impl BeatmapAttributesExt { + pub(crate) fn difficulty_range(difficulty: f64, min: f64, mid: f64, max: f64) -> f64 { + if difficulty > 5.0 { + mid + (max - mid) * Self::difficulty_range_value(difficulty) + } else if difficulty < 5.0 { + mid + (mid - min) * Self::difficulty_range_value(difficulty) + } else { + mid + } + } + + pub(crate) fn difficulty_range_value(difficulty: f64) -> f64 { + (difficulty - 5.0) / 5.0 + } + + pub(crate) fn inverse_difficulty_range( + difficulty_value: f64, + diff0: f64, + diff5: f64, + diff10: f64, + ) -> f64 { + if FloatExt::eq( + f64::signum(difficulty_value - diff5), + f64::signum(diff10 - diff5), + ) { + (difficulty_value - diff5) / (diff10 - diff5) * 5.0 + 5.0 + } else { + (difficulty_value - diff5) / (diff5 - diff0) * 5.0 + 5.0 + } + } + + pub(crate) const fn osu_great_hit_window_to_od(hit_window: f64) -> f64 { + (79.5 - hit_window) / 6.0 + } +} diff --git a/src/model/beatmap/attributes/hit_windows.rs b/src/model/beatmap/attributes/hit_windows.rs new file mode 100644 index 00000000..c3272ff5 --- /dev/null +++ b/src/model/beatmap/attributes/hit_windows.rs @@ -0,0 +1,134 @@ +use crate::model::beatmap::attributes::ext::BeatmapAttributesExt; + +/// AR and OD hit windows +#[derive(Copy, Clone, Debug, Default, PartialEq)] +pub struct HitWindows { + /// Hit window for approach rate i.e. `TimePreempt` in milliseconds. + /// + /// Only available for osu!standard and osu!catch. + pub ar: Option, + /// Perfect hit window for overall difficulty i.e. time to hit a "Perfect" + /// in milliseconds. + /// + /// Only available for osu!mania. + pub od_perfect: Option, + /// Great hit window for overall difficulty i.e. time to hit a 300 ("Great") + /// in milliseconds. + /// + /// Only available for osu!standard, osu!taiko, and osu!mania. + pub od_great: Option, + /// Good hit window for overall difficulty i.e. time to hit a "Good" in + /// milliseconds. + /// + /// Only available for osu!mania. + pub od_good: Option, + /// Ok hit window for overall difficulty i.e. time to hit a 100 ("Ok") in + /// milliseconds. + /// + /// Only available for osu!standard, osu!taiko, and osu!mania. + pub od_ok: Option, + /// Meh hit window for overall difficulty i.e. time to hit a 50 ("Meh") in + /// milliseconds. + /// + /// Only available for osu!standard and osu!mania. + pub od_meh: Option, +} + +pub(super) struct GameModeHitWindows { + pub min: f64, + pub mid: f64, + pub max: f64, +} + +impl GameModeHitWindows { + pub fn difficulty_range(&self, difficulty: f64) -> f64 { + let Self { min, mid, max } = *self; + + BeatmapAttributesExt::difficulty_range(difficulty, min, mid, max) + } + + pub fn inverse_difficulty_range(&self, difficulty_value: f64) -> f64 { + let Self { min, mid, max } = *self; + + BeatmapAttributesExt::inverse_difficulty_range(difficulty_value, min, mid, max) + } +} + +pub mod osu { + use super::GameModeHitWindows; + + pub const GREAT: GameModeHitWindows = GameModeHitWindows { + min: 80.0, + mid: 50.0, + max: 20.0, + }; + + pub const OK: GameModeHitWindows = GameModeHitWindows { + min: 140.0, + mid: 100.0, + max: 60.0, + }; + + pub const MEH: GameModeHitWindows = GameModeHitWindows { + min: 200.0, + mid: 150.0, + max: 100.0, + }; +} + +pub mod taiko { + use super::GameModeHitWindows; + + pub const GREAT: GameModeHitWindows = GameModeHitWindows { + min: 50.0, + mid: 35.0, + max: 20.0, + }; + + pub const OK: GameModeHitWindows = GameModeHitWindows { + min: 120.0, + mid: 80.0, + max: 50.0, + }; +} + +pub mod mania { + use super::GameModeHitWindows; + + pub const PERFECT: GameModeHitWindows = GameModeHitWindows { + min: 22.4, + mid: 19.4, + max: 13.9, + }; + + pub const GREAT: GameModeHitWindows = GameModeHitWindows { + min: 64.0, + mid: 49.0, + max: 34.0, + }; + + pub const GOOD: GameModeHitWindows = GameModeHitWindows { + min: 97.0, + mid: 82.0, + max: 67.0, + }; + + pub const OK: GameModeHitWindows = GameModeHitWindows { + min: 127.0, + mid: 112.0, + max: 97.0, + }; + + pub const MEH: GameModeHitWindows = GameModeHitWindows { + min: 151.0, + mid: 136.0, + max: 121.0, + }; +} + +// Same in both Osu and Catch +pub const AR: GameModeHitWindows = GameModeHitWindows { + min: 1800.0, + mid: 1200.0, + max: 450.0, +}; diff --git a/src/model/beatmap/attributes/mod.rs b/src/model/beatmap/attributes/mod.rs new file mode 100644 index 00000000..4c8ce8b1 --- /dev/null +++ b/src/model/beatmap/attributes/mod.rs @@ -0,0 +1,511 @@ +use rosu_map::section::general::GameMode; + +pub use self::{ + attribute::BeatmapAttribute, builder::BeatmapAttributesBuilder, hit_windows::HitWindows, +}; + +pub(crate) use self::{difficulty::BeatmapDifficulty, ext::BeatmapAttributesExt}; + +use crate::{GameMods, model::beatmap::attributes::hit_windows::GameModeHitWindows}; + +mod attribute; +mod builder; +mod difficulty; +mod ext; +mod hit_windows; + +/// Summary struct for a [`Beatmap`]'s attributes. +/// +/// The difference between this and [`BeatmapAttributes`] is that this struct +/// considers the clock rate in its attribute values. +/// +/// [`Beatmap`]: crate::Beatmap +#[derive(Clone, Debug, PartialEq)] +pub struct AdjustedBeatmapAttributes { + /// The approach rate. + pub ar: f64, + /// The circle size. + pub cs: f32, + /// The health drain rate. + pub hp: f32, + /// The overall difficulty. + pub od: f64, +} + +/// Summary struct for a [`Beatmap`]'s attributes. +/// +/// Clock rate is *not* considered in attribute values. +/// +/// [`Beatmap`]: crate::Beatmap +#[derive(Clone, Debug, PartialEq)] +pub struct BeatmapAttributes { + difficulty: BeatmapDifficulty, + mode: GameMode, + clock_rate: f64, + is_convert: bool, + classic_and_not_v2: bool, + mod_status: ModStatus, +} + +#[derive(Copy, Clone, Debug, PartialEq)] +enum ModStatus { + Neither, + Easy, + HardRock, +} + +impl ModStatus { + fn new(mods: &GameMods) -> Self { + if mods.hr() { + Self::HardRock + } else if mods.ez() { + Self::Easy + } else { + Self::Neither + } + } +} + +impl BeatmapAttributes { + /// Create a new [`BeatmapAttributesBuilder`]. + pub const fn builder() -> BeatmapAttributesBuilder { + BeatmapAttributesBuilder::new() + } + + /// The approach rate. + pub fn ar(&self) -> f32 { + match self.difficulty.ar { + BeatmapAttribute::None => BeatmapAttribute::DEFAULT, + BeatmapAttribute::Given(value) | BeatmapAttribute::Value(value) => value, + BeatmapAttribute::Fixed(fixed) => match self.mode { + GameMode::Osu | GameMode::Catch => hit_windows::AR.inverse_difficulty_range( + hit_windows::AR.difficulty_range(f64::from(fixed)) * self.clock_rate, + ) as f32, + GameMode::Taiko | GameMode::Mania => fixed, + }, + } + } + + /// The overall difficulty. + pub fn od(&self) -> f32 { + match self.difficulty.od { + BeatmapAttribute::None => BeatmapAttribute::DEFAULT, + BeatmapAttribute::Given(value) | BeatmapAttribute::Value(value) => value, + BeatmapAttribute::Fixed(fixed) => match self.mode { + GameMode::Osu => hit_windows::osu::GREAT.inverse_difficulty_range( + hit_windows::osu::GREAT.difficulty_range(f64::from(fixed)) * self.clock_rate, + ) as f32, + GameMode::Taiko => hit_windows::taiko::GREAT.inverse_difficulty_range( + hit_windows::taiko::GREAT.difficulty_range(f64::from(fixed)) * self.clock_rate, + ) as f32, + GameMode::Mania => { + let factor = match self.mod_status { + ModStatus::Neither => 1.0, + ModStatus::Easy => 1.0 / 1.4, + ModStatus::HardRock => 1.4, + }; + + hit_windows::mania::PERFECT.inverse_difficulty_range( + hit_windows::mania::PERFECT.difficulty_range(f64::from(fixed)) * factor, + ) as f32 + } + GameMode::Catch => fixed, + }, + } + } + + /// The circle size. + pub const fn cs(&self) -> f32 { + self.difficulty.cs.get_raw() + } + + /// The health drain rate. + pub const fn hp(&self) -> f32 { + self.difficulty.hp.get_raw() + } + + /// The clock rate. + pub const fn clock_rate(&self) -> f64 { + self.clock_rate + } + + /// Calculate the AR and OD hit windows. + pub fn hit_windows(&self) -> HitWindows { + let clock_rate = self.clock_rate; + + // Same for osu! and osu!catch (?) + let ar = || { + let value = match self.difficulty.ar { + BeatmapAttribute::None => BeatmapAttribute::DEFAULT, + BeatmapAttribute::Value(value) | BeatmapAttribute::Given(value) => value, + BeatmapAttribute::Fixed(fixed) => { + return hit_windows::AR.difficulty_range(f64::from(fixed)); + } + }; + + hit_windows::AR.difficulty_range(f64::from(value)) / clock_rate + }; + + // See `{OsuHitWindows,TaikoHitWindows}.SetDifficulty` + let set_difficulty = |hit_windows: &GameModeHitWindows| { + let value = match self.difficulty.od { + BeatmapAttribute::None => BeatmapAttribute::DEFAULT, + BeatmapAttribute::Value(value) | BeatmapAttribute::Given(value) => value, + BeatmapAttribute::Fixed(fixed) => { + // Fixed = f^-1(f(Value) / C) + // <=> f(Fixed) = f(Value) / C + // <=> f(Fixed) * C = f(Value) + let f_value = hit_windows.difficulty_range(f64::from(fixed)) * clock_rate; + + return (f64::floor(f_value) - 0.5) / clock_rate; + } + }; + + (f64::floor(hit_windows.difficulty_range(f64::from(value))) - 0.5) / clock_rate + }; + + match self.mode { + GameMode::Osu => HitWindows { + ar: Some(ar()), + od_great: Some(set_difficulty(&hit_windows::osu::GREAT)), + od_ok: Some(set_difficulty(&hit_windows::osu::OK)), + od_meh: Some(set_difficulty(&hit_windows::osu::MEH)), + ..Default::default() + }, + GameMode::Taiko => HitWindows { + od_great: Some(set_difficulty(&hit_windows::taiko::GREAT)), + od_ok: Some(set_difficulty(&hit_windows::taiko::OK)), + ..Default::default() + }, + GameMode::Catch => HitWindows { + ar: Some(ar()), + ..Default::default() + }, + GameMode::Mania => { + let speed_multiplier: f64 = 1.0; + let difficulty_multiplier: f64 = 1.0; + let total_multiplier = speed_multiplier / difficulty_multiplier; + + // Clock rate is irrelevant, apparently + let od = f64::from(self.difficulty.od.get_raw()); + + let (perfect, great, good, ok, meh) = if self.classic_and_not_v2 { + if self.is_convert { + ( + f64::floor(16.0 * total_multiplier) + 0.5, + f64::floor( + (if f64::round_ties_even(od) > 4.0 { + 34.0 + } else { + 47.0 + }) * total_multiplier, + ) + 0.5, + f64::floor( + (if f64::round_ties_even(od) > 4.0 { + 67.0 + } else { + 77.0 + }) * total_multiplier, + ) + 0.5, + f64::floor(97.0 * total_multiplier) + 0.5, + f64::floor(121.0 * total_multiplier) + 0.5, + ) + } else { + let inverted_od = f64::clamp(10.0 - od, 0.0, 10.0); + + let hit_window = |add: f64| { + f64::floor((add + 3.0 * inverted_od) * total_multiplier) + 0.5 + }; + + ( + f64::floor(16.0 * total_multiplier) + 0.5, + hit_window(34.0), + hit_window(67.0), + hit_window(97.0), + hit_window(121.0), + ) + } + } else { + let hit_window = |hit_windows: &GameModeHitWindows| { + f64::floor(hit_windows.difficulty_range(od) * total_multiplier) + 0.5 + }; + + ( + hit_window(&hit_windows::mania::PERFECT), + hit_window(&hit_windows::mania::GREAT), + hit_window(&hit_windows::mania::GOOD), + hit_window(&hit_windows::mania::OK), + hit_window(&hit_windows::mania::MEH), + ) + }; + + HitWindows { + ar: None, + od_perfect: Some(perfect), + od_great: Some(great), + od_good: Some(good), + od_ok: Some(ok), + od_meh: Some(meh), + } + } + } + } + + /// Convert [`BeatmapAttributes`] into [`AdjustedBeatmapAttributes`] by + /// applying the clock rate to the attribute values. + pub fn apply_clock_rate(&self) -> AdjustedBeatmapAttributes { + let clock_rate = self.clock_rate; + + let (ar, od) = match self.mode { + GameMode::Osu => { + let ar = self.difficulty.ar.map_or_else(f64::from, |ar| { + let mut preempt = hit_windows::AR.difficulty_range(f64::from(ar)); + preempt /= clock_rate; + + hit_windows::AR.inverse_difficulty_range(preempt) + }); + + let od = self.difficulty.od.map_or_else(f64::from, |od| { + let mut great_hit_window = + hit_windows::osu::GREAT.difficulty_range(f64::from(od)); + great_hit_window /= clock_rate; + + hit_windows::osu::GREAT.inverse_difficulty_range(great_hit_window) + }); + + (ar, od) + } + GameMode::Taiko => { + let od = self.difficulty.od.map_or_else(f64::from, |od| { + let mut great_hit_window = + hit_windows::taiko::GREAT.difficulty_range(f64::from(od)); + great_hit_window /= clock_rate; + + hit_windows::taiko::GREAT.inverse_difficulty_range(great_hit_window) + }); + + (f64::from(self.difficulty.ar.get_raw()), od) + } + GameMode::Catch => { + let ar = self.difficulty.ar.map_or_else(f64::from, |ar| { + let mut preempt = hit_windows::AR.difficulty_range(f64::from(ar)); + preempt /= clock_rate; + + hit_windows::AR.inverse_difficulty_range(preempt) + }); + + (ar, f64::from(self.difficulty.od.get_raw())) + } + GameMode::Mania => { + let od = self.difficulty.od.map_or_else(f64::from, |od| { + let mut perfect_hit_window = + hit_windows::mania::PERFECT.difficulty_range(f64::from(od)); + + match self.mod_status { + ModStatus::Neither => {} + ModStatus::Easy => perfect_hit_window /= 1.0 / 1.4, + ModStatus::HardRock => perfect_hit_window /= 1.4, + } + + hit_windows::mania::PERFECT.inverse_difficulty_range(perfect_hit_window) + }); + + // Ignoring CS + + (f64::from(self.difficulty.ar.get_raw()), od) + } + }; + + AdjustedBeatmapAttributes { + ar, + cs: self.difficulty.cs.get_raw(), + hp: self.difficulty.hp.get_raw(), + od, + } + } +} + +#[cfg(test)] +mod tests { + #![expect(clippy::float_cmp, reason = "we're just testing here")] + + use rosu_mods::{ + GameMod, GameMods, + generated_mods::{DifficultyAdjustOsu, DoubleTimeCatch, DoubleTimeOsu, HiddenOsu}, + }; + + use crate::Difficulty; + + use super::*; + + #[test] + fn default_ar() { + let gamemod = GameMod::HiddenOsu(HiddenOsu::default()); + let diff = Difficulty::new().mods(GameMods::from(gamemod)); + let attrs = BeatmapAttributes::builder().difficulty(&diff).build(); + + assert_eq!(attrs.ar(), 5.0); + } + + #[test] + fn ar_without_mods() { + let gamemod = GameMod::DoubleTimeOsu(DoubleTimeOsu::default()); + let diff = Difficulty::new().mods(GameMods::from(gamemod)); + let attrs = BeatmapAttributes::builder() + .ar(8.5, false) + .difficulty(&diff) + .build() + .apply_clock_rate(); + + assert_eq!(attrs.ar, 10.0); + } + + #[test] + fn ar_with_mods() { + let gamemod = GameMod::DoubleTimeOsu(DoubleTimeOsu::default()); + let diff = Difficulty::new().mods(GameMods::from(gamemod)); + let attrs = BeatmapAttributes::builder() + .ar(8.5, true) + .difficulty(&diff) + .build() + .apply_clock_rate(); + + assert_eq!(attrs.ar, 8.5); + } + + #[test] + fn mods_ar() { + let mut mods = GameMods::new(); + mods.insert(GameMod::DoubleTimeCatch(DoubleTimeCatch::default())); + mods.insert(GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu { + approach_rate: Some(7.0), + ..DifficultyAdjustOsu::default() + })); + let diff = Difficulty::new().mods(mods); + + let attrs = BeatmapAttributes::builder() + .difficulty(&diff) + .build() + .apply_clock_rate(); + + assert_eq!(attrs.ar, 9.0); + } + + #[test] + fn ar_mods_ar_without_mods() { + let mut mods = GameMods::new(); + mods.insert(GameMod::DoubleTimeCatch(DoubleTimeCatch::default())); + mods.insert(GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu { + approach_rate: Some(9.0), + ..DifficultyAdjustOsu::default() + })); + + let diff = Difficulty::new().mods(mods).ar(8.5, false); + + let attrs = BeatmapAttributes::builder() + .difficulty(&diff) + .build() + .apply_clock_rate(); + + assert_eq!(attrs.ar, 10.0); + } + + #[test] + fn ar_mods_ar_with_mods() { + let mut mods = GameMods::new(); + mods.insert(GameMod::DoubleTimeCatch(DoubleTimeCatch::default())); + mods.insert(GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu { + approach_rate: Some(9.0), + ..DifficultyAdjustOsu::default() + })); + + let diff = Difficulty::new().mods(mods).ar(8.5, true); + + let attrs = BeatmapAttributes::builder() + .difficulty(&diff) + .build() + .apply_clock_rate(); + + assert_eq!(attrs.ar, 8.5); + } + + #[test] + fn set_od_before_applying_hr() { + let mut hr = GameMods::new(); + hr.insert(GameMod::HardRockOsu(Default::default())); + + let attrs = BeatmapAttributes::builder() + .ar(5.0, false) + .mods(hr) + .build() + .apply_clock_rate(); + + assert_eq!(attrs.od, 7.0); + + let mut hrda = GameMods::new(); + hrda.insert(GameMod::HardRockOsu(Default::default())); + hrda.insert(GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu { + overall_difficulty: Some(7.0), + ..Default::default() + })); + + let attrs = BeatmapAttributes::builder() + .ar(5.0, false) + .mods(hrda) + .build() + .apply_clock_rate(); + + assert_eq!(attrs.od, 9.800000190734863); + } + + #[test] + fn same_hit_windows_fixed_vs_given() { + for mode in [ + GameMode::Osu, + GameMode::Taiko, + GameMode::Catch, + GameMode::Mania, + ] { + let fixed = BeatmapAttributes::builder() + .mode(mode, false) + .ar(6.0, true) + .od(6.0, true) + .build() + .hit_windows(); + + let given = BeatmapAttributes::builder() + .mode(mode, false) + .ar(6.0, false) + .od(6.0, false) + .build() + .hit_windows(); + + assert_eq!(fixed, given, "{mode:?}"); + } + } + + #[test] + fn getter_fixed_vs_given() { + for mode in [ + GameMode::Osu, + GameMode::Taiko, + GameMode::Catch, + GameMode::Mania, + ] { + let fixed = BeatmapAttributes::builder() + .mode(mode, false) + .ar(7.1, true) + .od(7.1, true) + .build(); + + let given = BeatmapAttributes::builder() + .mode(mode, false) + .ar(7.1, false) + .od(7.1, false) + .build(); + + assert_eq!(fixed.ar(), given.ar(), "{mode:?}"); + assert_eq!(fixed.od(), given.od(), "{mode:?}"); + } + } +} diff --git a/src/model/beatmap/bpm.rs b/src/model/beatmap/bpm.rs index d7616ce1..99b55a51 100644 --- a/src/model/beatmap/bpm.rs +++ b/src/model/beatmap/bpm.rs @@ -56,7 +56,7 @@ impl BeatLenDuration { } fn add(&mut self, beat_len: f64, curr_time: f64, next_time: f64) { - let beat_len = (1000.0 * beat_len).round() / 1000.0; + let beat_len = (1000.0 * beat_len).round_ties_even() / 1000.0; let entry = self.map.entry(beat_len.to_bits()).or_default(); if curr_time <= self.last_time { diff --git a/src/model/beatmap/decode.rs b/src/model/beatmap/decode.rs index c56931ea..9a3be3c5 100644 --- a/src/model/beatmap/decode.rs +++ b/src/model/beatmap/decode.rs @@ -309,10 +309,6 @@ impl From for Beatmap { sorter.sort(&mut state.hit_objects); sorter.sort(&mut state.hit_sounds); - if state.mode == GameMode::Mania { - sort::osu_legacy(&mut state.hit_objects); - } - Beatmap { version: state.version, is_convert: false, @@ -596,7 +592,7 @@ impl DecodeBeatmap for Beatmap { Ok(()) } - #[allow(clippy::too_many_lines)] + #[expect(clippy::too_many_lines, reason = "staying in-sync with lazer")] fn parse_hit_objects(state: &mut Self::State, line: &str) -> Result<(), Self::Error> { let mut split = line.trim_comment().split(','); diff --git a/src/model/beatmap/mod.rs b/src/model/beatmap/mod.rs index d4e020c5..94507dda 100644 --- a/src/model/beatmap/mod.rs +++ b/src/model/beatmap/mod.rs @@ -9,15 +9,20 @@ pub use rosu_map::section::events::BreakPeriod; use crate::{ Difficulty, GameMods, GradualDifficulty, GradualPerformance, Performance, catch::Catch, - mania::Mania, taiko::Taiko, + mania::Mania, taiko::Taiko, util::sort, }; pub use self::{ - attributes::{BeatmapAttributes, BeatmapAttributesBuilder, HitWindows}, + attributes::{ + AdjustedBeatmapAttributes, BeatmapAttribute, BeatmapAttributes, BeatmapAttributesBuilder, + HitWindows, + }, decode::{BeatmapState, ParseBeatmapError}, suspicious::TooSuspicious, }; +pub(crate) use self::attributes::BeatmapAttributesExt; + use super::{ control_point::{ DifficultyPoint, EffectPoint, TimingPoint, difficulty_point_at, effect_point_at, @@ -27,7 +32,7 @@ use super::{ mode::ConvertError, }; -mod attributes; +pub(crate) mod attributes; mod bpm; mod decode; mod suspicious; @@ -79,7 +84,7 @@ impl Beatmap { /// Returns a [`BeatmapAttributesBuilder`] to calculate modified beatmap /// attributes. pub fn attributes(&self) -> BeatmapAttributesBuilder { - BeatmapAttributesBuilder::new().map(self) + BeatmapAttributesBuilder::from(self) } /// The beats per minute of the map. @@ -185,6 +190,19 @@ impl Beatmap { Ok(()) } + /// Sort hitobjects via [`sort::osu_legacy`] with a specific comparator. + /// + /// Mania's difficulty calculation applies this sorting *after* all mod + /// conversions have been applied to the hitobjects. + /// + /// + pub(crate) fn mania_hitobjects_legacy_sort(&mut self) { + sort::osu_legacy(&mut self.hit_objects, |a, b| { + (f64::round_ties_even(a.start_time) as i32) + .cmp(&(f64::round_ties_even(b.start_time) as i32)) + }); + } + /// Check whether hitobjects appear too suspicious for further calculation. /// /// Sometimes a [`Beatmap`] isn't created for gameplay but rather to test diff --git a/src/model/beatmap/suspicious.rs b/src/model/beatmap/suspicious.rs index 4991a728..9076f8f7 100644 --- a/src/model/beatmap/suspicious.rs +++ b/src/model/beatmap/suspicious.rs @@ -55,7 +55,8 @@ impl TooSuspicious { const fn too_many_objects(map: &Beatmap) -> bool { const THRESHOLD: usize = 500_000; /// Taiko calculation is especially expensive for high object counts - const THRESHOLD_TAIKO: usize = 20_000; + // Note that the ranked map /b/3824509 has 28866 objects. + const THRESHOLD_TAIKO: usize = 30_000; match map.mode { GameMode::Taiko => map.hit_objects.len() > THRESHOLD_TAIKO, diff --git a/src/model/hit_object.rs b/src/model/hit_object.rs index 8472c34b..d443eadc 100644 --- a/src/model/hit_object.rs +++ b/src/model/hit_object.rs @@ -1,8 +1,8 @@ -use std::cmp::Ordering; +use std::{borrow::Cow, cmp::Ordering}; use rosu_map::section::{ general::GameMode, - hit_objects::{BorrowedCurve, CurveBuffers}, + hit_objects::{BorrowedCurve, Curve, CurveBuffers}, }; pub use rosu_map::{ @@ -10,6 +10,8 @@ pub use rosu_map::{ util::Pos, }; +use crate::model::mods::Reflection; + /// All hitobject related data required for difficulty and performance /// calculation except for the [`HitSoundType`]. #[derive(Clone, Debug, PartialEq)] @@ -84,11 +86,41 @@ impl Slider { self.repeats + 1 } - pub(crate) fn curve<'a>( + /// Creates the [`Curve`] of a [`Slider`]. + /// + /// Applies the [`Reflection`] onto control points before creating the + /// curve. + pub(crate) fn curve( + &self, + mode: GameMode, + reflection: Reflection, + bufs: &mut CurveBuffers, + ) -> Curve { + fn reflect Pos>(points: &mut Cow<'_, [PathControlPoint]>, f: F) { + points + .to_mut() + .iter_mut() + .for_each(|point| point.pos = f(point.pos)); + } + + let mut points = Cow::Borrowed(self.control_points.as_ref()); + + match reflection { + Reflection::None => {} + Reflection::Vertical => reflect(&mut points, |pos| Pos::new(pos.x, -pos.y)), + Reflection::Horizontal => reflect(&mut points, |pos| Pos::new(-pos.x, pos.y)), + Reflection::Both => reflect(&mut points, |pos| Pos::new(-pos.x, -pos.y)), + } + + Curve::new(mode, points.as_ref(), self.expected_dist, bufs) + } + + /// Creates the [`BorrowedCurve`] of a [`Slider`]. + pub(crate) fn borrowed_curve<'bufs>( &self, mode: GameMode, - bufs: &'a mut CurveBuffers, - ) -> BorrowedCurve<'a> { + bufs: &'bufs mut CurveBuffers, + ) -> BorrowedCurve<'bufs> { BorrowedCurve::new(mode, &self.control_points, self.expected_dist, bufs) } } diff --git a/src/model/mods.rs b/src/model/mods.rs index a7b49282..32d0c450 100644 --- a/src/model/mods.rs +++ b/src/model/mods.rs @@ -2,9 +2,7 @@ use std::fmt::{Debug, Formatter, Result as FmtResult}; use rosu_mods::{ GameMod, GameModIntermode, GameMods as GameModsLazer, GameModsIntermode, GameModsLegacy, - generated_mods::{ - DifficultyAdjustCatch, DifficultyAdjustMania, DifficultyAdjustOsu, DifficultyAdjustTaiko, - }, + generated_mods::DifficultyAdjustCatch, }; /// Re-exported [`rosu_mods`]. @@ -79,16 +77,6 @@ impl GameMods { } } - pub(crate) fn od_ar_hp_multiplier(&self) -> f64 { - if self.hr() { - 1.4 - } else if self.ez() { - 0.5 - } else { - 1.0 - } - } - /// Check whether the mods enable `hardrock_offsets`. pub(crate) fn hardrock_offsets(&self) -> bool { fn custom_hardrock_offsets(mods: &GameMods) -> Option { @@ -256,39 +244,39 @@ impl GameMods { }) .map(|seed| seed as i32) } -} -macro_rules! impl_map_attr { - ( $( $fn:ident: $field:ident [ $( $mode:ident ),* ] [$s:literal] ;)* ) => { - impl GameMods { - $( - #[doc = "Check whether the mods specify a custom "] - #[doc = $s] - #[doc = "value."] - pub(crate) fn $fn(&self) -> Option { - match self { - Self::Lazer(mods) => mods.iter().find_map(|gamemod| match gamemod { - $( impl_map_attr!( @ $mode $field) => *$field, )* - _ => None, - }), - Self::Intermode(_) | Self::Legacy(_) => None, - } - } - )* - } - }; + pub(crate) fn attraction_strength(&self) -> Option { + let Self::Lazer(mods) = self else { return None }; - ( @ Osu $field:ident ) => { GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu { $field, .. }) }; - ( @ Taiko $field:ident ) => { GameMod::DifficultyAdjustTaiko(DifficultyAdjustTaiko { $field, .. }) }; - ( @ Catch $field:ident ) => { GameMod::DifficultyAdjustCatch(DifficultyAdjustCatch { $field, .. }) }; - ( @ Mania $field:ident ) => { GameMod::DifficultyAdjustMania(DifficultyAdjustMania { $field, .. }) }; -} + mods.iter() + .find_map(|m| match m { + GameMod::MagnetisedOsu(mg) => Some(mg.attraction_strength), + _ => None, + }) + .flatten() + } -impl_map_attr! { - ar: approach_rate [Osu, Catch] ["ar"]; - cs: circle_size [Osu, Catch] ["cs"]; - hp: drain_rate [Osu, Taiko, Catch, Mania] ["hp"]; - od: overall_difficulty [Osu, Taiko, Catch, Mania] ["od"]; + pub(crate) fn deflate_start_scale(&self) -> Option { + let Self::Lazer(mods) = self else { return None }; + + mods.iter() + .find_map(|m| match m { + GameMod::DeflateOsu(df) => Some(df.start_scale), + _ => None, + }) + .flatten() + } + + pub(crate) fn hd_only_fade_approach_circles(&self) -> Option { + let Self::Lazer(mods) = self else { return None }; + + mods.iter() + .find_map(|m| match m { + GameMod::HiddenOsu(hd) => Some(hd.only_fade_approach_circles), + _ => None, + }) + .flatten() + } } macro_rules! impl_has_mod { @@ -335,6 +323,7 @@ impl_has_mod! { fl: + Flashlight ["Flashlight"], so: + SpunOut ["SpunOut"], ap: + Autopilot ["Autopilot"], + sv2: + ScoreV2 ["ScoreV2"], bl: - Blinds ["Blinds"], cl: - Classic ["Classic"], invert: - Invert ["Invert"], diff --git a/src/osu/attributes.rs b/src/osu/attributes.rs index 2d357c51..34c735b0 100644 --- a/src/osu/attributes.rs +++ b/src/osu/attributes.rs @@ -1,4 +1,4 @@ -use crate::{model::beatmap::BeatmapAttributesBuilder, osu::performance::OsuPerformance}; +use crate::{model::beatmap::BeatmapAttributesExt, osu::performance::OsuPerformance}; /// The result of a difficulty calculation on an osu!standard map. #[derive(Clone, Debug, Default, PartialEq)] @@ -13,12 +13,22 @@ pub struct OsuDifficultyAttributes { pub flashlight: f64, /// The ratio of the aim strain with and without considering sliders pub slider_factor: f64, + /// Describes how much of aim's difficult strain count is contributed to by sliders + pub aim_top_weighted_slider_factor: f64, + /// Describes how much of speed's difficult strain count is contributed to by sliders + pub speed_top_weighted_slider_factor: f64, /// The number of clickable objects weighted by difficulty. pub speed_note_count: f64, /// Weighted sum of aim strains. pub aim_difficult_strain_count: f64, /// Weighted sum of speed strains. pub speed_difficult_strain_count: f64, + /// The amount of nested score per object. + pub nested_score_per_object: f64, + /// The legacy score base multiplier. + pub legacy_score_base_multiplier: f64, + /// The maximum legacy combo score. + pub maximum_legacy_combo_score: f64, /// The approach rate. pub ar: f64, /// The great hit window. @@ -63,7 +73,7 @@ impl OsuDifficultyAttributes { /// The overall difficulty pub const fn od(&self) -> f64 { - BeatmapAttributesBuilder::osu_great_hit_window_to_od(self.great_hit_window) + BeatmapAttributesExt::osu_great_hit_window_to_od(self.great_hit_window) } /// Returns a builder for performance calculation. @@ -91,6 +101,10 @@ pub struct OsuPerformanceAttributes { pub effective_miss_count: f64, /// Approximated unstable-rate pub speed_deviation: Option, + pub combo_based_estimated_miss_count: f64, + pub score_based_estimated_miss_count: Option, + pub aim_estimated_slider_breaks: f64, + pub speed_estimated_slider_breaks: f64, } impl OsuPerformanceAttributes { diff --git a/src/osu/convert.rs b/src/osu/convert.rs index c4044467..742a7f0a 100644 --- a/src/osu/convert.rs +++ b/src/osu/convert.rs @@ -23,7 +23,7 @@ pub fn convert_objects( let mut osu_objects: Box<[_]> = map .hit_objects .iter() - .map(|h| OsuObject::new(h, map, &mut curve_bufs, &mut ticks_buf)) + .map(|h| OsuObject::new(h, map, reflection, &mut curve_bufs, &mut ticks_buf)) .inspect(|h| { if take == 0 { return; @@ -67,10 +67,6 @@ pub fn convert_objects( for h in osu_objects.iter_mut() { h.stack_offset = scaling_factor.stack_offset(h.stack_height); - - if let OsuObjectKind::Slider(ref mut slider) = h.kind { - slider.lazy_end_pos += h.pos + h.stack_offset; - } } osu_objects diff --git a/src/osu/difficulty/evaluators/aim.rs b/src/osu/difficulty/evaluators/aim.rs new file mode 100644 index 00000000..e306d400 --- /dev/null +++ b/src/osu/difficulty/evaluators/aim.rs @@ -0,0 +1,228 @@ +use crate::{ + any::difficulty::object::IDifficultyObject, + osu::difficulty::object::OsuDifficultyObject, + util::{ + difficulty::{milliseconds_to_bpm, reverse_lerp, smootherstep, smoothstep}, + float_ext::FloatExt, + }, +}; + +pub struct AimEvaluator; + +impl AimEvaluator { + const WIDE_ANGLE_MULTIPLIER: f64 = 1.5; + const ACUTE_ANGLE_MULTIPLIER: f64 = 2.55; + const SLIDER_MULTIPLIER: f64 = 1.35; + const VELOCITY_CHANGE_MULTIPLIER: f64 = 0.75; + const WIGGLE_MULTIPLIER: f64 = 1.02; + + #[expect(clippy::too_many_lines, reason = "staying in-sync with lazer")] + pub fn evaluate_diff_of<'a>( + curr: &'a OsuDifficultyObject<'a>, + diff_objects: &'a [OsuDifficultyObject<'a>], + with_slider_travel_dist: bool, + ) -> f64 { + let osu_curr_obj = curr; + + let Some((osu_last_last_obj, osu_last_obj)) = curr + .previous(1, diff_objects) + .zip(curr.previous(0, diff_objects)) + .filter(|(_, last)| !(curr.base.is_spinner() || last.base.is_spinner())) + else { + return 0.0; + }; + + #[expect(clippy::items_after_statements, reason = "staying in-sync with lazer")] + const RADIUS: i32 = OsuDifficultyObject::NORMALIZED_RADIUS; + #[expect(clippy::items_after_statements, reason = "staying in-sync with lazer")] + const DIAMETER: i32 = OsuDifficultyObject::NORMALIZED_DIAMETER; + + // * Calculate the velocity to the current hitobject, which starts + // * with a base distance / time assuming the last object is a hitcircle. + let mut curr_vel = osu_curr_obj.lazy_jump_dist / osu_curr_obj.adjusted_delta_time; + + // * But if the last object is a slider, then we extend the travel + // * velocity through the slider into the current object. + if osu_last_obj.base.is_slider() && with_slider_travel_dist { + // * calculate the slider velocity from slider head to slider end. + let travel_vel = osu_last_obj.travel_dist / osu_last_obj.travel_time; + // * calculate the movement velocity from slider end to current object + let movement_vel = osu_curr_obj.min_jump_dist / osu_curr_obj.min_jump_time; + + // * take the larger total combined velocity. + curr_vel = curr_vel.max(movement_vel + travel_vel); + } + + // * As above, do the same for the previous hitobject. + let mut prev_vel = osu_last_obj.lazy_jump_dist / osu_last_obj.adjusted_delta_time; + + if osu_last_last_obj.base.is_slider() && with_slider_travel_dist { + let travel_vel = osu_last_last_obj.travel_dist / osu_last_last_obj.travel_time; + let movement_vel = osu_last_obj.min_jump_dist / osu_last_obj.min_jump_time; + + prev_vel = prev_vel.max(movement_vel + travel_vel); + } + + let mut wide_angle_bonus = 0.0; + let mut acute_angle_bonus = 0.0; + let mut slider_bonus = 0.0; + let mut vel_change_bonus = 0.0; + let mut wiggle_bonus = 0.0; + + // * Start strain with regular velocity. + let mut aim_strain = curr_vel; + + if let Some((curr_angle, last_angle)) = osu_curr_obj.angle.zip(osu_last_obj.angle) { + // * Rewarding angles, take the smaller velocity as base. + let angle_bonus = curr_vel.min(prev_vel); + + // * If rhythms are the same. + if osu_curr_obj + .adjusted_delta_time + .max(osu_last_obj.adjusted_delta_time) + < 1.25 + * osu_curr_obj + .adjusted_delta_time + .min(osu_last_obj.adjusted_delta_time) + { + acute_angle_bonus = Self::calc_acute_angle_bonus(curr_angle); + + // * Penalize angle repetition. + acute_angle_bonus *= 0.08 + + 0.92 + * (1.0 + - f64::min( + acute_angle_bonus, + f64::powf(Self::calc_acute_angle_bonus(last_angle), 3.0), + )); + + // * Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter + acute_angle_bonus *= angle_bonus + * smootherstep( + milliseconds_to_bpm(osu_curr_obj.adjusted_delta_time, Some(2)), + 300.0, + 400.0, + ) + * smootherstep( + osu_curr_obj.lazy_jump_dist, + f64::from(DIAMETER), + f64::from(DIAMETER * 2), + ); + } + + wide_angle_bonus = Self::calc_wide_angle_bonus(curr_angle); + + // * Penalize angle repetition. + wide_angle_bonus *= 1.0 + - f64::min( + wide_angle_bonus, + f64::powf(Self::calc_wide_angle_bonus(last_angle), 3.0), + ); + + // * Apply full wide angle bonus for distance more than one diameter + wide_angle_bonus *= + angle_bonus * smootherstep(osu_curr_obj.lazy_jump_dist, 0.0, f64::from(DIAMETER)); + + // * Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle + // * https://www.desmos.com/calculator/dp0v0nvowc + wiggle_bonus = angle_bonus + * smootherstep( + osu_curr_obj.lazy_jump_dist, + f64::from(RADIUS), + f64::from(DIAMETER), + ) + * f64::powf( + reverse_lerp( + osu_curr_obj.lazy_jump_dist, + f64::from(DIAMETER * 3), + f64::from(DIAMETER), + ), + 1.8, + ) + * smootherstep(curr_angle, f64::to_radians(110.0), f64::to_radians(60.0)) + * smootherstep( + osu_last_obj.lazy_jump_dist, + f64::from(RADIUS), + f64::from(DIAMETER), + ) + * f64::powf( + reverse_lerp( + osu_last_obj.lazy_jump_dist, + f64::from(DIAMETER * 3), + f64::from(DIAMETER), + ), + 1.8, + ) + * smootherstep(last_angle, f64::to_radians(110.0), f64::to_radians(60.0)); + + if let Some(osu_last_2_obj) = curr.previous(2, diff_objects) { + let distance = + (osu_last_2_obj.base.stacked_pos() - osu_last_obj.base.stacked_pos()).length(); + + if distance < 1.0 { + wide_angle_bonus *= 1.0 - 0.35 * f64::from(1.0 - distance); + } + } + } + + if prev_vel.max(curr_vel).not_eq(0.0) { + // * We want to use the average velocity over the whole object when awarding + // * differences, not the individual jump and slider path velocities. + prev_vel = (osu_last_obj.lazy_jump_dist + osu_last_last_obj.travel_dist) + / osu_last_obj.adjusted_delta_time; + curr_vel = (osu_curr_obj.lazy_jump_dist + osu_last_obj.travel_dist) + / osu_curr_obj.adjusted_delta_time; + + // * Scale with ratio of difference compared to 0.5 * max dist. + let dist_ratio = smoothstep( + (prev_vel - curr_vel).abs() / prev_vel.max(curr_vel), + 0.0, + 1.0, + ); + + // * Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing. + let overlap_vel_buff = (f64::from(DIAMETER) * 1.25 + / osu_curr_obj + .adjusted_delta_time + .min(osu_last_obj.adjusted_delta_time)) + .min((prev_vel - curr_vel).abs()); + + vel_change_bonus = overlap_vel_buff * dist_ratio; + + // * Penalize for rhythm changes. + let bonus_base = (osu_curr_obj.adjusted_delta_time) + .min(osu_last_obj.adjusted_delta_time) + / (osu_curr_obj.adjusted_delta_time).max(osu_last_obj.adjusted_delta_time); + vel_change_bonus *= bonus_base.powf(2.0); + } + + if osu_last_obj.base.is_slider() { + // * Reward sliders based on velocity. + slider_bonus = osu_last_obj.travel_dist / osu_last_obj.travel_time; + } + + aim_strain += wiggle_bonus * Self::WIGGLE_MULTIPLIER; + aim_strain += vel_change_bonus * Self::VELOCITY_CHANGE_MULTIPLIER; + + // * Add in acute angle bonus or wide angle bonus, whichever is larger. + aim_strain += (acute_angle_bonus * Self::ACUTE_ANGLE_MULTIPLIER) + .max(wide_angle_bonus * Self::WIDE_ANGLE_MULTIPLIER); + + aim_strain *= osu_curr_obj.small_circle_bonus; + + // * Add in additional slider velocity bonus. + if with_slider_travel_dist { + aim_strain += slider_bonus * Self::SLIDER_MULTIPLIER; + } + + aim_strain + } + + const fn calc_wide_angle_bonus(angle: f64) -> f64 { + smoothstep(angle, f64::to_radians(40.0), f64::to_radians(140.0)) + } + + const fn calc_acute_angle_bonus(angle: f64) -> f64 { + smoothstep(angle, f64::to_radians(140.0), f64::to_radians(40.0)) + } +} diff --git a/src/osu/difficulty/evaluators/flashlight.rs b/src/osu/difficulty/evaluators/flashlight.rs new file mode 100644 index 00000000..92308f05 --- /dev/null +++ b/src/osu/difficulty/evaluators/flashlight.rs @@ -0,0 +1,138 @@ +use std::cmp; + +use crate::{ + any::difficulty::object::IDifficultyObject, + osu::{difficulty::object::OsuDifficultyObject, object::OsuObjectKind}, +}; + +pub struct FlashlightEvaluator { + scaling_factor: f64, + time_preempt: f64, + time_fade_in: f64, +} + +impl FlashlightEvaluator { + const MAX_OPACITY_BONUS: f64 = 0.4; + const HIDDEN_BONUS: f64 = 0.2; + + const MIN_VELOCITY: f64 = 0.5; + const SLIDER_MULTIPLIER: f64 = 1.3; + + const MIN_ANGLE_MULTIPLIER: f64 = 0.2; + + pub const fn new(scaling_factor: f64, time_preempt: f64, time_fade_in: f64) -> Self { + Self { + scaling_factor, + time_preempt, + time_fade_in, + } + } + + pub fn evaluate_diff_of<'a>( + &self, + curr: &'a OsuDifficultyObject<'a>, + diff_objects: &'a [OsuDifficultyObject<'a>], + hidden: bool, + ) -> f64 { + if curr.base.is_spinner() { + return 0.0; + } + + let osu_curr = curr; + let osu_hit_obj = curr.base; + + let mut small_dist_nerf = 1.0; + let mut cumulative_strain_time = 0.0; + + let mut result = 0.0; + + let mut last_obj = osu_curr; + + let mut angle_repeat_count = 0.0; + + // * This is iterating backwards in time from the current object. + for i in 0..cmp::min(curr.idx, 10) { + let Some(curr_obj) = curr.previous(i, diff_objects) else { + break; + }; + + cumulative_strain_time += last_obj.adjusted_delta_time; + + let curr_hit_obj = curr_obj.base; + + if !curr_obj.base.is_spinner() { + let jump_dist = f64::from( + (osu_hit_obj.stacked_pos() - curr_hit_obj.stacked_end_pos()).length(), + ); + + // * We want to nerf objects that can be easily seen within the Flashlight circle radius. + if i == 0 { + small_dist_nerf = (jump_dist / 75.0).min(1.0); + } + + // * We also want to nerf stacks so that only the first object of the stack is accounted for. + let stack_nerf = ((curr_obj.lazy_jump_dist / self.scaling_factor) / 25.0).min(1.0); + + // * Bonus based on how visible the object is. + let opacity_bonus = 1.0 + + Self::MAX_OPACITY_BONUS + * (1.0 + - osu_curr.opacity_at( + curr_hit_obj.start_time, + hidden, + self.time_preempt, + self.time_fade_in, + )); + + result += stack_nerf * opacity_bonus * self.scaling_factor * jump_dist + / cumulative_strain_time; + + if let Some((curr_obj_angle, osu_curr_angle)) = curr_obj.angle.zip(osu_curr.angle) { + // * Objects further back in time should count less for the nerf. + if (curr_obj_angle - osu_curr_angle).abs() < 0.02 { + angle_repeat_count += (1.0 - 0.1 * i as f64).max(0.0); + } + } + } + + last_obj = curr_obj; + } + + result = (small_dist_nerf * result).powf(2.0); + + // * Additional bonus for Hidden due to there being no approach circles. + if hidden { + result *= 1.0 + Self::HIDDEN_BONUS; + } + + // * Nerf patterns with repeated angles. + result *= Self::MIN_ANGLE_MULTIPLIER + + (1.0 - Self::MIN_ANGLE_MULTIPLIER) / (angle_repeat_count + 1.0); + + let mut slider_bonus = 0.0; + + if let OsuObjectKind::Slider(slider) = &osu_curr.base.kind { + // * Invert the scaling factor to determine the true travel distance independent of circle size. + let pixel_travel_dist = osu_curr.lazy_travel_dist / self.scaling_factor; + + // * Reward sliders based on velocity. + slider_bonus = ((pixel_travel_dist / osu_curr.travel_time - Self::MIN_VELOCITY) + .max(0.0)) + .powf(0.5); + + // * Longer sliders require more memorisation. + slider_bonus *= pixel_travel_dist; + + // * Nerf sliders with repeats, as less memorisation is required. + let repeat_count = slider.repeat_count(); + + if repeat_count > 0 { + slider_bonus /= (repeat_count + 1) as f64; + } + } + + result += slider_bonus * Self::SLIDER_MULTIPLIER; + + result + } +} diff --git a/src/osu/difficulty/evaluators/mod.rs b/src/osu/difficulty/evaluators/mod.rs new file mode 100644 index 00000000..3a6d1f51 --- /dev/null +++ b/src/osu/difficulty/evaluators/mod.rs @@ -0,0 +1,9 @@ +pub use self::{ + aim::AimEvaluator, flashlight::FlashlightEvaluator, rhythm::RhythmEvaluator, + speed::SpeedEvaluator, +}; + +mod aim; +mod flashlight; +mod rhythm; +mod speed; diff --git a/src/osu/difficulty/evaluators/rhythm.rs b/src/osu/difficulty/evaluators/rhythm.rs new file mode 100644 index 00000000..101d4c9e --- /dev/null +++ b/src/osu/difficulty/evaluators/rhythm.rs @@ -0,0 +1,272 @@ +use std::cmp; + +use crate::{ + any::difficulty::object::IDifficultyObject, + osu::difficulty::object::OsuDifficultyObject, + util::difficulty::{logistic, smoothstep_bell_curve}, +}; + +pub struct RhythmEvaluator; + +impl RhythmEvaluator { + const HISTORY_TIME_MAX: u32 = 5 * 1000; // 5 seconds + const HISTORY_OBJECTS_MAX: usize = 32; + const RHYTHM_OVERALL_MULTIPLIER: f64 = 1.0; + const RHYTHM_RATIO_MULTIPLIER: f64 = 15.0; + + #[expect(clippy::too_many_lines, reason = "staying in-sync with lazer")] + pub fn evaluate_diff_of<'a>( + curr: &'a OsuDifficultyObject<'a>, + diff_objects: &'a [OsuDifficultyObject<'a>], + hit_window: f64, + ) -> f64 { + if curr.base.is_spinner() { + return 0.0; + } + + let mut rhythm_complexity_sum = 0.0; + + let delta_difference_eps = hit_window * 0.3; + + let mut island = RhythmIsland::new(delta_difference_eps); + let mut prev_island = RhythmIsland::new(delta_difference_eps); + + // * we can't use dictionary here because we need to compare island with a tolerance + // * which is impossible to pass into the hash comparer + let mut island_counts = Vec::::new(); + + // * store the ratio of the current start of an island to buff for tighter rhythms + let mut start_ratio = 0.0; + + let mut first_delta_switch = false; + + let historical_note_count = cmp::min(curr.idx, Self::HISTORY_OBJECTS_MAX); + + let mut rhythm_start = 0; + + while curr + .previous(rhythm_start, diff_objects) + .filter(|prev| { + rhythm_start + 2 < historical_note_count + && curr.start_time - prev.start_time < f64::from(Self::HISTORY_TIME_MAX) + }) + .is_some() + { + rhythm_start += 1; + } + + if let Some((mut prev_obj, mut last_obj)) = curr + .previous(rhythm_start, diff_objects) + .zip(curr.previous(rhythm_start + 1, diff_objects)) + { + // * we go from the furthest object back to the current one + for i in (1..=rhythm_start).rev() { + let Some(curr_obj) = curr.previous(i - 1, diff_objects) else { + break; + }; + + // * scales note 0 to 1 from history to now + let time_decay = (f64::from(Self::HISTORY_TIME_MAX) + - (curr.start_time - curr_obj.start_time)) + / f64::from(Self::HISTORY_TIME_MAX); + let note_decay = (historical_note_count - i) as f64 / historical_note_count as f64; + + // * either we're limited by time or limited by object count. + let curr_historical_decay = note_decay.min(time_decay); + + // * Use custom cap value to ensure that at this point delta time is actually zero + let curr_delta = curr_obj.delta_time.max(1e-7); + let prev_delta = prev_obj.delta_time.max(1e-7); + let last_delta = last_obj.delta_time.max(1e-7); + + // * calculate how much current delta difference deserves a rhythm bonus + // * this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200) + let delta_difference = prev_delta.max(curr_delta) / prev_delta.min(curr_delta); + + // * Take only the fractional part of the value since we're only interested in punishing multiples + let delta_difference_fraction = delta_difference - delta_difference.trunc(); + + let curr_ratio = 1.0 + + Self::RHYTHM_RATIO_MULTIPLIER + * smoothstep_bell_curve(delta_difference_fraction, 0.5, 0.5).min(0.5); + + // * reduce ratio bonus if delta difference is too big + let difference_multiplier = (2.0 - delta_difference / 8.0).clamp(0.0, 1.0); + + let window_penalty = (((prev_delta - curr_delta).abs() - delta_difference_eps) + .max(0.0) + / delta_difference_eps) + .min(1.0); + + let mut effective_ratio = window_penalty * curr_ratio * difference_multiplier; + + if first_delta_switch { + if (prev_delta - curr_delta).abs() < delta_difference_eps { + // * island is still progressing + island.add_delta(curr_delta as i32); + } else { + // * bpm change is into slider, this is easy acc window + if curr_obj.base.is_slider() { + effective_ratio *= 0.125; + } + + // * bpm change was from a slider, this is easier typically than circle -> circle + // * unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders + if prev_obj.base.is_slider() { + effective_ratio *= 0.3; + } + + // * repeated island polarity (2 -> 4, 3 -> 5) + if island.is_similar_polarity(&prev_island) { + effective_ratio *= 0.5; + } + + // * previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this. + if last_delta > prev_delta + delta_difference_eps + && prev_delta > curr_delta + delta_difference_eps + { + effective_ratio *= 0.125; + } + + // * repeated island size (ex: triplet -> triplet) + // * TODO: remove this nerf since its staying here only for balancing purposes because of the flawed ratio calculation + if prev_island.delta_count == island.delta_count { + effective_ratio *= 0.5; + } + + if let Some(island_count) = island_counts + .iter_mut() + .find(|entry| entry.island == island) + .filter(|entry| !entry.island.is_default()) + { + // * only add island to island counts if they're going one after another + if prev_island == island { + island_count.count += 1; + } + + // * repeated island (ex: triplet -> triplet) + let power = logistic(f64::from(island.delta), 58.33, 0.24, Some(2.75)); + effective_ratio *= (3.0 / island_count.count as f64) + .min((island_count.count as f64).recip().powf(power)); + } else { + island_counts.push(IslandCount { island, count: 1 }); + } + + // * scale down the difficulty if the object is doubletappable + let doubletapness = prev_obj.get_doubletapness(Some(curr_obj), hit_window); + effective_ratio *= 1.0 - doubletapness * 0.75; + + rhythm_complexity_sum += + (effective_ratio * start_ratio).sqrt() * curr_historical_decay; + + start_ratio = effective_ratio; + + prev_island = island; + + // * we're slowing down, stop counting + if prev_delta + delta_difference_eps < curr_delta { + // * if we're speeding up, this stays true and we keep counting island size. + first_delta_switch = false; + } + + island = + RhythmIsland::new_with_delta(curr_delta as i32, delta_difference_eps); + } + } else if prev_delta > curr_delta + delta_difference_eps { + // * we're speeding up. + // * Begin counting island until we change speed again. + first_delta_switch = true; + + // * bpm change is into slider, this is easy acc window + if curr_obj.base.is_slider() { + effective_ratio *= 0.6; + } + + // * bpm change was from a slider, this is easier typically than circle -> circle + // * unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders + if prev_obj.base.is_slider() { + effective_ratio *= 0.6; + } + + start_ratio = effective_ratio; + + island = RhythmIsland::new_with_delta(curr_delta as i32, delta_difference_eps); + } + + last_obj = prev_obj; + prev_obj = curr_obj; + } + } + + // * produces multiplier that can be applied to strain. range [1, infinity) (not really though) + let mut rhythm_difficulty = + (4.0 + rhythm_complexity_sum * Self::RHYTHM_OVERALL_MULTIPLIER).sqrt() / 2.0; + rhythm_difficulty *= 1.0 - curr.get_doubletapness(curr.next(0, diff_objects), hit_window); + + rhythm_difficulty + } +} + +#[derive(Copy, Clone)] +struct RhythmIsland { + delta_difference_eps: f64, + delta: i32, + delta_count: i32, +} + +const MIN_DELTA_TIME: i32 = 25; + +// Compile-time check in case `OsuDifficultyObject::MIN_DELTA_TIME` changes +// but we forget to update this value. +const _: [(); 0 - !{ MIN_DELTA_TIME - OsuDifficultyObject::MIN_DELTA_TIME as i32 == 0 } as usize] = + []; + +impl RhythmIsland { + const fn new(delta_difference_eps: f64) -> Self { + Self { + delta_difference_eps, + delta: 0, + delta_count: 0, + } + } + + fn new_with_delta(delta: i32, delta_difference_eps: f64) -> Self { + Self { + delta_difference_eps, + delta: cmp::max(delta, MIN_DELTA_TIME), + delta_count: 1, + } + } + + fn add_delta(&mut self, delta: i32) { + if self.delta == i32::MAX { + self.delta = cmp::max(delta, MIN_DELTA_TIME); + } + + self.delta_count += 1; + } + + const fn is_similar_polarity(&self, other: &Self) -> bool { + // * TODO: consider islands to be of similar polarity only if they're having the same average delta (we don't want to consider 3 singletaps similar to a triple) + // * naively adding delta check here breaks _a lot_ of maps because of the flawed ratio calculation + self.delta_count % 2 == other.delta_count % 2 + } + + fn is_default(&self) -> bool { + self.delta_difference_eps.abs() < f64::EPSILON + && self.delta == i32::MAX + && self.delta_count == 0 + } +} + +impl PartialEq for RhythmIsland { + fn eq(&self, other: &Self) -> bool { + f64::from((self.delta - other.delta).abs()) < self.delta_difference_eps + && self.delta_count == other.delta_count + } +} + +struct IslandCount { + island: RhythmIsland, + count: usize, +} diff --git a/src/osu/difficulty/evaluators/speed.rs b/src/osu/difficulty/evaluators/speed.rs new file mode 100644 index 00000000..961d2dd5 --- /dev/null +++ b/src/osu/difficulty/evaluators/speed.rs @@ -0,0 +1,73 @@ +use crate::{ + any::difficulty::object::IDifficultyObject, + osu::difficulty::object::OsuDifficultyObject, + util::difficulty::{bpm_to_milliseconds, milliseconds_to_bpm}, +}; + +pub struct SpeedEvaluator; + +impl SpeedEvaluator { + const SINGLE_SPACING_THRESHOLD: f64 = OsuDifficultyObject::NORMALIZED_DIAMETER as f64 * 1.25; // 1.25 circlers distance between centers + const MIN_SPEED_BONUS: f64 = 200.0; // 200 BPM 1/4th + const SPEED_BALANCING_FACTOR: f64 = 40.0; + const DIST_MULTIPLIER: f64 = 0.8; + + pub fn evaluate_diff_of<'a>( + curr: &'a OsuDifficultyObject<'a>, + diff_objects: &'a [OsuDifficultyObject<'a>], + hit_window: f64, + autopilot: bool, + ) -> f64 { + if curr.base.is_spinner() { + return 0.0; + } + + // * derive strainTime for calculation + let osu_curr_obj = curr; + let osu_prev_obj = curr.previous(0, diff_objects); + let osu_next_obj = curr.next(0, diff_objects); + + let mut strain_time = curr.adjusted_delta_time; + // Note: Technically `osu_next_obj` is never `None` but instead the + // default value. This could maybe invalidate the `get_doubletapness` + // result. + let doubletapness = 1.0 - osu_curr_obj.get_doubletapness(osu_next_obj, hit_window); + + // * Cap deltatime to the OD 300 hitwindow. + // * 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap. + strain_time /= ((strain_time / hit_window) / 0.93).clamp(0.92, 1.0); + + let speed_bonus = if milliseconds_to_bpm(strain_time, None) > Self::MIN_SPEED_BONUS { + // * Add additional scaling bonus for streams/bursts higher than 200bpm + let base = (bpm_to_milliseconds(Self::MIN_SPEED_BONUS, None) - strain_time) + / Self::SPEED_BALANCING_FACTOR; + + 0.75 * base.powf(2.0) + } else { + // * speedBonus will be 0.0 for BPM < 200 + 0.0 + }; + + let travel_dist = osu_prev_obj.map_or(0.0, |obj| obj.travel_dist); + let mut dist = travel_dist + osu_curr_obj.min_jump_dist; + + // * Cap distance at single_spacing_threshold + dist = Self::SINGLE_SPACING_THRESHOLD.min(dist); + + // * Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold + let mut dist_bonus = + (dist / Self::SINGLE_SPACING_THRESHOLD).powf(3.95) * Self::DIST_MULTIPLIER; + + dist_bonus *= osu_curr_obj.small_circle_bonus.sqrt(); + + if autopilot { + dist_bonus = 0.0; + } + + // * Base difficulty with all bonuses + let difficulty = (1.0 + speed_bonus + dist_bonus) * 1000.0 / strain_time; + + // * Apply penalty if there's doubletappable doubles + difficulty * doubletapness + } +} diff --git a/src/osu/difficulty/gradual.rs b/src/osu/difficulty/gradual.rs index ccee824f..6c6e25d1 100644 --- a/src/osu/difficulty/gradual.rs +++ b/src/osu/difficulty/gradual.rs @@ -8,7 +8,9 @@ use crate::{ model::mode::ConvertError, osu::{ convert::convert_objects, + legacy_score_simulator::gradual::GradualLegacyScoreSimulator, object::{OsuObject, OsuObjectKind}, + utils::legacy_score::GradualNestedScorePerObject, }, }; @@ -62,6 +64,9 @@ pub struct OsuGradualDifficulty { // `osu_objects` will immediately invalidate `diff_objects`. diff_objects: Box<[OsuDifficultyObject<'static>]>, osu_objects: OsuObjects, + // Boxed to reduce the field's size + score_simulator: Box, + nested_score: GradualNestedScorePerObject, // Additional safety measure that this type can't be cloned as it would // invalidate `diff_objects`. _not_clonable: NotClonable, @@ -109,9 +114,14 @@ impl OsuGradualDifficulty { osu_objects.iter_mut(), ); - let skills = OsuSkills::new(mods, &scaling_factor, &map_attrs, time_preempt); + let great_hit_window = map_attrs.hit_windows().od_great.unwrap_or(0.0); + + let skills = OsuSkills::new(mods, &scaling_factor, great_hit_window, time_preempt); let diff_objects = extend_lifetime(diff_objects.into_boxed_slice()); + let score_simulator = GradualLegacyScoreSimulator::new(&map, map_attrs); + let nested_score = GradualNestedScorePerObject::default(); + Ok(Self { idx: 0, difficulty, @@ -119,6 +129,8 @@ impl OsuGradualDifficulty { skills, diff_objects, osu_objects, + score_simulator: Box::new(score_simulator), + nested_score, _not_clonable: NotClonable, }) } @@ -151,6 +163,20 @@ impl Iterator for OsuGradualDifficulty { type Item = OsuDifficultyAttributes; fn next(&mut self) -> Option { + if let Some(h) = self.osu_objects.get(self.idx) { + let score_attrs = self.score_simulator.simulate_next(h); + self.attrs.maximum_legacy_combo_score = score_attrs.combo_score as f64; + + // Importantly, the `score_multipler` method is called *after* + // `simulate_next` so that the simulator's internal fields have + // been adjusted to the current object. + self.attrs.legacy_score_base_multiplier = + self.score_simulator.score_multiplier(h, false); + + let slider_nested_score_per_object = self.nested_score.calculate_next(h); + self.attrs.nested_score_per_object = slider_nested_score_per_object; + } + // The first difficulty object belongs to the second note since each // difficulty object requires the current and the last note. Hence, if // we're still on the first object, we don't have a difficulty object @@ -190,11 +216,31 @@ impl Iterator for OsuGradualDifficulty { // The first note has no difficulty object if self.idx == 0 && take > 0 { + if let Some(h) = self.osu_objects.get(self.idx) { + let score_attrs = self.score_simulator.simulate_next(h); + self.attrs.maximum_legacy_combo_score = score_attrs.combo_score as f64; + self.attrs.legacy_score_base_multiplier = + self.score_simulator.score_multiplier(h, false); + + let slider_nested_score_per_object = self.nested_score.calculate_next(h); + self.attrs.nested_score_per_object = slider_nested_score_per_object; + } + take -= 1; self.idx += 1; } for curr in skip_iter.take(take) { + if let Some(h) = self.osu_objects.get(self.idx) { + let score_attrs = self.score_simulator.simulate_next(h); + self.attrs.maximum_legacy_combo_score = score_attrs.combo_score as f64; + self.attrs.legacy_score_base_multiplier = + self.score_simulator.score_multiplier(h, false); + + let slider_nested_score_per_object = self.nested_score.calculate_next(h); + self.attrs.nested_score_per_object = slider_nested_score_per_object; + } + self.skills.process(curr, &self.diff_objects); Self::increment_combo(curr.base, &mut self.attrs); self.idx += 1; @@ -225,6 +271,10 @@ mod osu_objects { Self { objects } } + pub(super) fn get(&self, idx: usize) -> Option<&OsuObject> { + self.objects.get(idx) + } + pub(super) const fn is_empty(&self) -> bool { self.objects.is_empty() } diff --git a/src/osu/difficulty/mod.rs b/src/osu/difficulty/mod.rs index d9e83b83..f476bf4a 100644 --- a/src/osu/difficulty/mod.rs +++ b/src/osu/difficulty/mod.rs @@ -9,9 +9,14 @@ use crate::{ model::{beatmap::BeatmapAttributes, mode::ConvertError, mods::GameMods}, osu::{ convert::convert_objects, - difficulty::{object::OsuDifficultyObject, scaling_factor::ScalingFactor}, + difficulty::{ + object::OsuDifficultyObject, rating::OsuRatingCalculator, + scaling_factor::ScalingFactor, skills::strain::count_top_weighted_sliders, + }, + legacy_score_simulator::OsuLegacyScoreSimulator, object::OsuObject, performance::PERFORMANCE_BASE_MULTIPLIER, + utils::legacy_score::NestedScorePerObject, }, }; @@ -19,12 +24,14 @@ use self::skills::OsuSkills; use super::attributes::OsuDifficultyAttributes; +mod evaluators; pub mod gradual; mod object; +pub mod rating; pub mod scaling_factor; pub mod skills; -const DIFFICULTY_MULTIPLIER: f64 = 0.0675; +const STAR_RATING_MULTIPLIER: f64 = 0.0265; const HD_FADE_IN_DURATION_MULTIPLIER: f64 = 0.4; const HD_FADE_OUT_DURATION_MULTIPLIER: f64 = 0.3; @@ -35,12 +42,34 @@ pub fn difficulty( ) -> Result { let map = map.convert_ref(GameMode::Osu, difficulty.get_mods())?; - let DifficultyValues { skills, mut attrs } = DifficultyValues::calculate(difficulty, &map); + let DifficultyValues { + osu_objects, + skills, + mut attrs, + } = DifficultyValues::calculate(difficulty, &map); let mods = difficulty.get_mods(); + let passed_objects = difficulty.get_passed_objects(); DifficultyValues::eval(&mut attrs, mods, &skills); + let mut simulator = OsuLegacyScoreSimulator::new(&osu_objects, &map, passed_objects); + + let score_attrs = simulator.simulate(); + attrs.maximum_legacy_combo_score = score_attrs.combo_score as f64; + + let map_attrs = map.attributes().difficulty(difficulty).build(); + + attrs.legacy_score_base_multiplier = f64::from(OsuLegacyScoreSimulator::score_multiplier( + &map, + &map_attrs, + passed_objects, + )); + + let slider_nested_score_per_object = + NestedScorePerObject::calculate(&osu_objects, passed_objects); + attrs.nested_score_per_object = slider_nested_score_per_object; + Ok(attrs) } @@ -55,18 +84,19 @@ impl OsuDifficultySetup { pub fn new(difficulty: &Difficulty, map: &Beatmap) -> Self { let clock_rate = difficulty.get_clock_rate(); let map_attrs = map.attributes().difficulty(difficulty).build(); - let scaling_factor = ScalingFactor::new(map_attrs.cs); + let hit_windows = map_attrs.hit_windows(); + let scaling_factor = ScalingFactor::new(map_attrs.cs()); let attrs = OsuDifficultyAttributes { - ar: map_attrs.ar, - hp: map_attrs.hp, - great_hit_window: map_attrs.hit_windows.od_great, - ok_hit_window: map_attrs.hit_windows.od_ok.unwrap_or(0.0), - meh_hit_window: map_attrs.hit_windows.od_meh.unwrap_or(0.0), + ar: map_attrs.apply_clock_rate().ar, + hp: f64::from(map_attrs.hp()), + great_hit_window: hit_windows.od_great.unwrap_or(0.0), + ok_hit_window: hit_windows.od_ok.unwrap_or(0.0), + meh_hit_window: hit_windows.od_meh.unwrap_or(0.0), ..Default::default() }; - let time_preempt = f64::from((map_attrs.hit_windows.ar * clock_rate) as f32); + let time_preempt = f64::from((hit_windows.ar.unwrap_or(0.0) * clock_rate) as f32); Self { scaling_factor, @@ -78,6 +108,7 @@ impl OsuDifficultySetup { } pub struct DifficultyValues { + pub osu_objects: Box<[OsuObject]>, pub skills: OsuSkills, pub attrs: OsuDifficultyAttributes, } @@ -108,7 +139,9 @@ impl DifficultyValues { let diff_objects = Self::create_difficulty_objects(difficulty, &scaling_factor, osu_object_iter); - let mut skills = OsuSkills::new(mods, &scaling_factor, &map_attrs, time_preempt); + let great_hit_window = map_attrs.hit_windows().od_great.unwrap_or(0.0); + + let mut skills = OsuSkills::new(mods, &scaling_factor, great_hit_window, time_preempt); // The first hit object has no difficulty object let take_diff_objects = cmp::min(map.hit_objects.len(), take).saturating_sub(1); @@ -117,7 +150,11 @@ impl DifficultyValues { skills.process(hit_object, &diff_objects); } - Self { skills, attrs } + Self { + osu_objects, + skills, + attrs, + } } /// Process the difficulty values and store the results in `attrs`. @@ -131,69 +168,81 @@ impl DifficultyValues { let aim_difficulty_value = aim.cloned_difficulty_value(); - let mut aim_rating = aim_difficulty_value.sqrt() * DIFFICULTY_MULTIPLIER; let aim_difficult_strain_count = aim.count_top_weighted_strains(aim_difficulty_value); let difficult_sliders = aim.get_difficult_sliders(); - let aim_rating_no_sliders = - f64::sqrt(aim_no_sliders.cloned_difficulty_value()) * DIFFICULTY_MULTIPLIER; + let aim_no_sliders_difficulty_value = aim_no_sliders.cloned_difficulty_value(); - let slider_factor = if aim_rating > 0.0 { - aim_rating_no_sliders / aim_rating + let aim_no_sliders_top_weighted_slider_count = count_top_weighted_sliders( + aim_no_sliders.slider_strains(), + aim_no_sliders_difficulty_value, + ); + + let aim_no_sliders_difficult_strain_count = + aim_no_sliders.count_top_weighted_strains(aim_no_sliders_difficulty_value); + + let aim_top_weighted_slider_factor = aim_no_sliders_top_weighted_slider_count + / (aim_no_sliders_difficult_strain_count - aim_no_sliders_top_weighted_slider_count) + .max(1.0); + + let slider_factor = if aim_difficulty_value > 0.0 { + OsuRatingCalculator::calculate_difficulty_rating(aim_no_sliders_difficulty_value) + / OsuRatingCalculator::calculate_difficulty_rating(aim_difficulty_value) } else { 1.0 }; let speed_difficulty_value = speed.cloned_difficulty_value(); - let mut speed_rating = f64::sqrt(speed_difficulty_value) * DIFFICULTY_MULTIPLIER; + let speed_top_weighted_slider_count = + count_top_weighted_sliders(speed.slider_strains(), speed_difficulty_value); + let speed_difficult_strain_count = speed.count_top_weighted_strains(speed_difficulty_value); - let mut flashlight_rating = - f64::sqrt(flashlight.cloned_difficulty_value()) * DIFFICULTY_MULTIPLIER; + let speed_top_weighted_slider_factor = speed_top_weighted_slider_count + / (speed_difficult_strain_count - speed_top_weighted_slider_count).max(1.0); - if mods.td() { - aim_rating = aim_rating.powf(0.8); - flashlight_rating = flashlight_rating.powf(0.8); - } + let mechanical_difficulty_rating = + calculate_mechanical_difficulty_rating(aim_difficulty_value, speed_difficulty_value); - if mods.rx() { - aim_rating *= 0.9; - speed_rating = 0.0; - flashlight_rating *= 0.7; - } else if mods.ap() { - speed_rating *= 0.5; - aim_rating = 0.0; - flashlight_rating *= 0.4; - } + let osu_rating_calculator = OsuRatingCalculator::new( + mods, + attrs.n_objects(), + attrs.ar, + attrs.od(), + mechanical_difficulty_rating, + slider_factor, + ); - let base_aim_performance = Aim::difficulty_to_performance(aim_rating); - let base_speed_performance = Speed::difficulty_to_performance(speed_rating); + let aim_rating = osu_rating_calculator.compute_aim_rating(aim_difficulty_value); + let speed_rating = osu_rating_calculator.compute_speed_rating(speed_difficulty_value); + + let flashlight_rating = if mods.fl() { + let flashlight_difficulty_value = flashlight.cloned_difficulty_value(); - let base_flashlight_performance = if mods.fl() { - Flashlight::difficulty_to_performance(flashlight_rating) + osu_rating_calculator.compute_flashlight_rating(flashlight_difficulty_value) } else { 0.0 }; + let base_aim_performance = Aim::difficulty_to_performance(aim_rating); + let base_speed_performance = Speed::difficulty_to_performance(speed_rating); + let base_flashlight_performance = Flashlight::difficulty_to_performance(flashlight_rating); + let base_performance = ((base_aim_performance).powf(1.1) + (base_speed_performance).powf(1.1) + (base_flashlight_performance).powf(1.1)) .powf(1.0 / 1.1); - let star_rating = if base_performance > 0.00001 { - PERFORMANCE_BASE_MULTIPLIER.cbrt() - * 0.027 - * ((100_000.0 / 2.0_f64.powf(1.0 / 1.1) * base_performance).cbrt() + 4.0) - } else { - 0.0 - }; + let star_rating = calculate_star_rating(base_performance); attrs.aim = aim_rating; attrs.aim_difficult_slider_count = difficult_sliders; attrs.speed = speed_rating; attrs.flashlight = flashlight_rating; attrs.slider_factor = slider_factor; + attrs.aim_top_weighted_slider_factor = aim_top_weighted_slider_factor; + attrs.speed_top_weighted_slider_factor = speed_top_weighted_slider_factor; attrs.aim_difficult_strain_count = aim_difficult_strain_count; attrs.speed_difficult_strain_count = speed_difficult_strain_count; attrs.stars = star_rating; @@ -208,33 +257,68 @@ impl DifficultyValues { let take = difficulty.get_passed_objects(); let clock_rate = difficulty.get_clock_rate(); - let mut osu_objects_iter = osu_objects - .map(|h| OsuDifficultyObject::compute_slider_cursor_pos(h, scaling_factor.radius)) - .map(Pin::into_ref); + let mut osu_objects_iter = osu_objects.map(Pin::into_ref); let Some(mut last) = osu_objects_iter.next().filter(|_| take > 0) else { return Vec::new(); }; - let mut last_last = None; - - osu_objects_iter - .enumerate() - .map(|(idx, h)| { - let diff_object = OsuDifficultyObject::new( - h.get_ref(), - last.get_ref(), - last_last.as_deref(), - clock_rate, - idx, - scaling_factor, - ); - - last_last = Some(last); - last = h; - - diff_object - }) - .collect() + let mut diff_objects = Vec::with_capacity(osu_objects_iter.len()); + + for (idx, h) in osu_objects_iter.enumerate() { + let last_diff = if idx > 0 { + diff_objects.get(idx - 1) + } else { + None + }; + + let last_last_diff = if idx > 1 { + diff_objects.get(idx - 2) + } else { + None + }; + + let diff_object = OsuDifficultyObject::new( + h.get_ref(), + last.get_ref(), + last_diff, + last_last_diff, + clock_rate, + idx, + scaling_factor, + ); + + last = h; + + diff_objects.push(diff_object); + } + + diff_objects } } + +fn calculate_mechanical_difficulty_rating( + aim_difficulty_value: f64, + speed_difficulty_value: f64, +) -> f64 { + let aim_value = Aim::difficulty_to_performance( + OsuRatingCalculator::calculate_difficulty_rating(aim_difficulty_value), + ); + let speed_value = Speed::difficulty_to_performance( + OsuRatingCalculator::calculate_difficulty_rating(speed_difficulty_value), + ); + + let total_value = (aim_value.powf(1.1) + speed_value.powf(1.1)).powf(1.0 / 1.1); + + calculate_star_rating(total_value) +} + +fn calculate_star_rating(base_performance: f64) -> f64 { + if base_performance <= 0.00001 { + return 0.0; + } + + PERFORMANCE_BASE_MULTIPLIER.cbrt() + * STAR_RATING_MULTIPLIER + * ((100_000.0 / 2.0_f64.powf(1.0 / 1.1) * base_performance).cbrt() + 4.0) +} diff --git a/src/osu/difficulty/object.rs b/src/osu/difficulty/object.rs index b4864a88..7cddf0de 100644 --- a/src/osu/difficulty/object.rs +++ b/src/osu/difficulty/object.rs @@ -1,10 +1,10 @@ -use std::{borrow::Cow, pin::Pin}; +use std::borrow::Cow; use rosu_map::util::Pos; use crate::{ any::difficulty::object::{HasStartTime, IDifficultyObject}, - osu::object::{OsuObject, OsuObjectKind, OsuSlider}, + osu::object::{OsuObject, OsuObjectKind}, }; use super::{HD_FADE_OUT_DURATION_MULTIPLIER, scaling_factor::ScalingFactor}; @@ -15,13 +15,18 @@ pub struct OsuDifficultyObject<'a> { pub start_time: f64, pub delta_time: f64, - pub strain_time: f64, + pub adjusted_delta_time: f64, pub lazy_jump_dist: f64, pub min_jump_dist: f64, pub min_jump_time: f64, pub travel_dist: f64, pub travel_time: f64, + pub lazy_end_pos: Option, + pub lazy_travel_dist: f64, + pub lazy_travel_time: f64, pub angle: Option, + + pub small_circle_bonus: f64, } impl<'a> OsuDifficultyObject<'a> { @@ -35,7 +40,8 @@ impl<'a> OsuDifficultyObject<'a> { pub fn new( hit_object: &'a OsuObject, last_object: &'a OsuObject, - last_last_object: Option<&OsuObject>, + last_diff_obj: Option<&OsuDifficultyObject>, + last_last_diff_obj: Option<&OsuDifficultyObject>, clock_rate: f64, idx: usize, scaling_factor: &ScalingFactor, @@ -44,22 +50,34 @@ impl<'a> OsuDifficultyObject<'a> { let start_time = hit_object.start_time / clock_rate; let strain_time = delta_time.max(Self::MIN_DELTA_TIME); + let small_circle_bonus = (1.0 + (30.0 - scaling_factor.radius) / 40.0).max(1.0); let mut this = Self { idx, base: hit_object, start_time, delta_time, - strain_time, + adjusted_delta_time: strain_time, lazy_jump_dist: 0.0, min_jump_dist: 0.0, min_jump_time: 0.0, travel_dist: 0.0, travel_time: 0.0, + lazy_end_pos: None, + lazy_travel_dist: 0.0, + lazy_travel_time: 0.0, angle: None, + small_circle_bonus, }; - this.set_distances(last_object, last_last_object, clock_rate, scaling_factor); + this.compute_slider_cursor_pos(scaling_factor.radius); + this.set_distances( + last_object, + last_diff_obj, + last_last_diff_obj, + clock_rate, + scaling_factor, + ); this } @@ -108,18 +126,17 @@ impl<'a> OsuDifficultyObject<'a> { fn set_distances( &mut self, last_object: &OsuObject, - last_last_object: Option<&OsuObject>, + last_diff_obj: Option<&OsuDifficultyObject>, + last_last_diff_obj: Option<&OsuDifficultyObject>, clock_rate: f64, scaling_factor: &ScalingFactor, ) { if let OsuObjectKind::Slider(ref slider) = self.base.kind { - self.travel_dist = f64::from( - slider.lazy_travel_dist - * ((1.0 + slider.repeat_count() as f64 / 2.5).powf(1.0 / 2.5)) as f32, - ); + self.travel_dist = self.lazy_travel_dist + * ((1.0 + slider.repeat_count() as f64 / 2.5).powf(1.0 / 2.5)); - self.travel_time = (self.base.lazy_travel_time() / clock_rate) - .max(OsuDifficultyObject::MIN_DELTA_TIME); + self.travel_time = + (self.lazy_travel_time / clock_rate).max(OsuDifficultyObject::MIN_DELTA_TIME); } if self.base.is_spinner() || last_object.is_spinner() { @@ -128,19 +145,27 @@ impl<'a> OsuDifficultyObject<'a> { let scaling_factor = scaling_factor.factor; - let last_cursor_pos = Self::get_end_cursor_pos(last_object); + let last_cursor_pos = if let Some(last_diff_obj) = last_diff_obj { + Self::get_end_cursor_pos(last_diff_obj) + } else { + last_object.stacked_pos() + }; self.lazy_jump_dist = f64::from( (self.base.stacked_pos() * scaling_factor - last_cursor_pos * scaling_factor).length(), ); - self.min_jump_time = self.strain_time; + self.min_jump_time = self.adjusted_delta_time; self.min_jump_dist = self.lazy_jump_dist; + let Some(last_diff_obj) = last_diff_obj else { + return; + }; + if let OsuObjectKind::Slider(ref last_slider) = last_object.kind { - let last_travel_time = (last_object.lazy_travel_time() / clock_rate) + let last_travel_time = (last_diff_obj.lazy_travel_time / clock_rate) + .max(OsuDifficultyObject::MIN_DELTA_TIME); + self.min_jump_time = (self.adjusted_delta_time - last_travel_time) .max(OsuDifficultyObject::MIN_DELTA_TIME); - self.min_jump_time = - (self.strain_time - last_travel_time).max(OsuDifficultyObject::MIN_DELTA_TIME); let tail_pos = last_slider.tail().map_or(last_object.pos, |tail| tail.pos); let stacked_tail_pos = tail_pos + last_object.stack_offset; @@ -156,8 +181,12 @@ impl<'a> OsuDifficultyObject<'a> { self.min_jump_dist = ((self.lazy_jump_dist - diff).min(min)).max(0.0); } - if let Some(last_last_object) = last_last_object.filter(|h| !h.is_spinner()) { - let last_last_cursor_pos = Self::get_end_cursor_pos(last_last_object); + let Some(last_last_diff_obj) = last_last_diff_obj else { + return; + }; + + if !last_last_diff_obj.base.is_spinner() { + let last_last_cursor_pos = Self::get_end_cursor_pos(last_last_diff_obj); let v1 = last_last_cursor_pos - last_object.stacked_pos(); let v2 = self.base.stacked_pos() - last_cursor_pos; @@ -169,35 +198,73 @@ impl<'a> OsuDifficultyObject<'a> { } } - /// The [`Pin<&mut OsuObject>`](std::pin::Pin) denotes that the object will - /// be mutated but not moved. - pub fn compute_slider_cursor_pos( - mut h: Pin<&mut OsuObject>, - radius: f64, - ) -> Pin<&mut OsuObject> { - let pos = h.pos; - let stack_offset = h.stack_offset; - let start_time = h.start_time; - - let OsuObjectKind::Slider(ref mut slider) = h.kind else { - return h; + pub fn compute_slider_cursor_pos(&mut self, radius: f64) { + const TAIL_LENIENCY: f64 = -36.0; + + let OsuObjectKind::Slider(ref slider) = self.base.kind else { + return; }; - let mut nested = Cow::Borrowed(slider.nested_objects.as_slice()); + if self.lazy_end_pos.is_some() { + return; + } + + let pos = self.base.pos; + let stack_offset = self.base.stack_offset; + let start_time = self.base.start_time; let duration = slider.end_time - start_time; - OsuSlider::lazy_travel_time(start_time, duration, &mut nested); - let nested = nested.as_ref(); + + let mut nested_objects = Cow::Borrowed(slider.nested_objects.as_slice()); + + let mut tracking_end_time = + (start_time + duration + TAIL_LENIENCY).max(start_time + duration / 2.0); + + let last_real_tick = nested_objects + .iter() + .enumerate() + .rfind(|(_, nested)| nested.is_tick()); + + if let Some((idx, last_real_tick)) = + last_real_tick.filter(|(_, tick)| tick.start_time > tracking_end_time) + { + tracking_end_time = last_real_tick.start_time; + + // * When the last tick falls after the tracking end time, we need to re-sort the nested objects + // * based on time. This creates a somewhat weird ordering which is counter to how a user would + // * understand the slider, but allows a zero-diff with known diffcalc output. + // * + // * To reiterate, this is definitely not correct from a difficulty calculation perspective + // * and should be revisited at a later date (likely by replacing this whole code with the commented + // * version above). + nested_objects.to_mut()[idx..].rotate_left(1); + } + + self.lazy_travel_time = tracking_end_time - start_time; + + let nested_objects = nested_objects.as_ref(); + + let span_duration = duration / slider.span_count; + + let mut end_time_min = self.lazy_travel_time / span_duration; + + if end_time_min % 2.0 >= 1.0 { + end_time_min = 1.0 - end_time_min % 1.0; + } else { + end_time_min %= 1.0; + } + + let mut lazy_end_pos = pos + stack_offset + slider.path.position_at(end_time_min); let mut curr_cursor_pos = pos + stack_offset; let scaling_factor = f64::from(OsuDifficultyObject::NORMALIZED_RADIUS) / radius; - for (curr_movement_obj, i) in nested.iter().zip(1..) { + for (curr_movement_obj, i) in nested_objects.iter().zip(1..) { let mut curr_movement = curr_movement_obj.pos + stack_offset - curr_cursor_pos; let mut curr_movement_len = scaling_factor * f64::from(curr_movement.length()); let mut required_movement = f64::from(OsuDifficultyObject::ASSUMED_SLIDER_RADIUS); - if i == nested.len() { - let lazy_movement = slider.lazy_end_pos - curr_cursor_pos; + if i == nested_objects.len() { + let lazy_movement = lazy_end_pos - curr_cursor_pos; if lazy_movement.length() < curr_movement.length() { curr_movement = lazy_movement; @@ -212,24 +279,22 @@ impl<'a> OsuDifficultyObject<'a> { curr_cursor_pos += curr_movement * ((curr_movement_len - required_movement) / curr_movement_len) as f32; curr_movement_len *= (curr_movement_len - required_movement) / curr_movement_len; - slider.lazy_travel_dist += curr_movement_len as f32; + self.lazy_travel_dist += curr_movement_len; } - if i == nested.len() { - slider.lazy_end_pos = curr_cursor_pos; + if i == nested_objects.len() { + lazy_end_pos = curr_cursor_pos; } } - h + self.lazy_end_pos = Some(lazy_end_pos); } - const fn get_end_cursor_pos(hit_object: &OsuObject) -> Pos { - if let OsuObjectKind::Slider(ref slider) = hit_object.kind { - // We don't have access to the slider's curve at this point so we - // take the pre-computed value. - slider.lazy_end_pos + const fn get_end_cursor_pos(hit_object: &OsuDifficultyObject) -> Pos { + if let Some(lazy_end_pos) = hit_object.lazy_end_pos { + lazy_end_pos } else { - hit_object.stacked_pos() + hit_object.base.stacked_pos() } } } diff --git a/src/osu/difficulty/rating.rs b/src/osu/difficulty/rating.rs new file mode 100644 index 00000000..da9bdd4a --- /dev/null +++ b/src/osu/difficulty/rating.rs @@ -0,0 +1,265 @@ +use std::convert::identity; + +use crate::{ + GameMods, + util::{difficulty::reverse_lerp, float_ext::FloatExt}, +}; + +pub struct OsuRatingCalculator<'mods> { + mods: &'mods GameMods, + total_hits: u32, + approach_rate: f64, + overall_difficulty: f64, + mechanical_difficulty_rating: f64, + slider_factor: f64, +} + +const DIFFICULTY_MULTIPLIER: f64 = 0.0675; + +impl<'mods> OsuRatingCalculator<'mods> { + pub const fn new( + mods: &'mods GameMods, + total_hits: u32, + approach_rate: f64, + overall_difficulty: f64, + mechanical_difficulty_rating: f64, + slider_factor: f64, + ) -> Self { + Self { + mods, + total_hits, + approach_rate, + overall_difficulty, + mechanical_difficulty_rating, + slider_factor, + } + } +} + +impl OsuRatingCalculator<'_> { + pub fn compute_aim_rating(&self, aim_difficulty_value: f64) -> f64 { + if self.mods.ap() { + return 0.0; + } + + let mut aim_rating = Self::calculate_difficulty_rating(aim_difficulty_value); + + if self.mods.td() { + aim_rating = aim_rating.powf(0.8); + } + + if self.mods.rx() { + aim_rating *= 0.9; + } + + if let Some(magnetised_strength) = self.mods.attraction_strength() { + aim_rating *= 1.0 - magnetised_strength; + } + + let mut rating_multiplier = 1.0; + + let ar_length_bonus = 0.95 + + 0.4 * (f64::from(self.total_hits) / 2000.0).min(1.0) + + f64::from(u8::from(self.total_hits > 2000)) + * (f64::from(self.total_hits) / 2000.0).log10() + * 0.5; + + let ar_factor = if self.mods.rx() { + 0.0 + } else if self.approach_rate > 10.33 { + 0.3 * (self.approach_rate - 10.33) + } else if self.approach_rate < 8.0 { + 0.05 * (8.0 - self.approach_rate) + } else { + 0.0 + }; + + // * Buff for longer maps with high AR. + rating_multiplier += ar_factor * ar_length_bonus; + + if self.mods.hd() { + let visibility_factor = Self::calculate_aim_visibility_factor( + self.mechanical_difficulty_rating, + self.approach_rate, + ); + + rating_multiplier += Self::calculate_visibility_bonus( + self.mods, + self.approach_rate, + Some(visibility_factor), + Some(self.slider_factor), + ); + } + + // * It is important to consider accuracy difficulty when scaling with accuracy. + rating_multiplier *= 0.98 + self.overall_difficulty.max(0.0).powf(2.0) / 2500.0; + + aim_rating * rating_multiplier.cbrt() + } + + pub fn compute_speed_rating(&self, speed_difficulty_value: f64) -> f64 { + if self.mods.rx() { + return 0.0; + } + + let mut speed_rating = Self::calculate_difficulty_rating(speed_difficulty_value); + + if self.mods.ap() { + speed_rating *= 0.5; + } + + if let Some(magnetised_strength) = self.mods.attraction_strength() { + // * reduce speed rating because of the speed distance scaling, with maximum reduction being 0.7x + speed_rating *= 1.0 - magnetised_strength * 0.3; + } + + let mut rating_multiplier = 1.0; + + let ar_length_bonus = 0.95 + + 0.4 * (f64::from(self.total_hits) / 2000.0).min(1.0) + + f64::from(u8::from(self.total_hits > 2000)) + * (f64::from(self.total_hits) / 2000.0).log10() + * 0.5; + + let ar_factor = if self.mods.ap() { + 0.0 + } else if self.approach_rate > 10.33 { + 0.3 * (self.approach_rate - 10.33) + } else { + 0.0 + }; + + // * Buff for longer maps with high AR. + rating_multiplier += ar_factor * ar_length_bonus; + + if self.mods.hd() { + let visibility_factor = Self::calculate_speed_visibility_factor( + self.mechanical_difficulty_rating, + self.approach_rate, + ); + + rating_multiplier += Self::calculate_visibility_bonus( + self.mods, + self.approach_rate, + Some(visibility_factor), + None, + ); + } + + rating_multiplier *= 0.95 + self.overall_difficulty.max(0.0).powf(2.0) / 750.0; + + speed_rating * rating_multiplier.cbrt() + } + + pub fn compute_flashlight_rating(&self, flashlight_difficulty_value: f64) -> f64 { + if !self.mods.fl() { + return 0.0; + } + + let mut flashlight_rating = Self::calculate_difficulty_rating(flashlight_difficulty_value); + + if self.mods.td() { + flashlight_rating = flashlight_rating.powf(0.8); + } + + if self.mods.rx() { + flashlight_rating *= 0.7; + } else if self.mods.ap() { + flashlight_rating *= 0.4; + } + + if let Some(magnetised_strength) = self.mods.attraction_strength() { + flashlight_rating *= 1.0 - magnetised_strength; + } + + if let Some(deflate_initial_scale) = self.mods.deflate_start_scale() { + flashlight_rating *= reverse_lerp(deflate_initial_scale, 11.0, 1.0).clamp(0.1, 1.0); + } + + let mut rating_multiplier = 1.0; + + // * Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius. + rating_multiplier *= 0.7 + + 0.1 * (f64::from(self.total_hits) / 200.0).min(1.0) + + f64::from(u8::from(self.total_hits > 200)) + * 0.2 + * (f64::from(self.total_hits.saturating_sub(200)) / 200.0).min(1.0); + + // * It is important to consider accuracy difficulty when scaling with accuracy. + rating_multiplier *= 0.98 + self.overall_difficulty.max(0.0).powf(2.0) / 2500.0; + + flashlight_rating * rating_multiplier.sqrt() + } + + pub fn calculate_visibility_bonus( + mods: &GameMods, + approach_rate: f64, + visibility_factor: Option, + slider_factor: Option, + ) -> f64 { + // * NOTE: TC's effect is only noticeable in performance calculations until lazer mods are accounted for server-side. + let is_always_partially_visible = + mods.hd_only_fade_approach_circles().is_some_and(identity) || mods.tc(); + + // * Start from normal curve, rewarding lower AR up to AR7 + let mut reading_bonus = 0.04 * (12.0 - approach_rate.max(7.0)); + + reading_bonus *= visibility_factor.unwrap_or(1.0); + + // * We want to reward slideraim on low AR less + let slider_visibility_factor = slider_factor.unwrap_or(1.0).powf(3.0); + + // * For AR up to 0 - reduce reward for very low ARs when object is visible + if approach_rate < 7.0 { + let factor = if is_always_partially_visible { + 0.03 + } else { + 0.045 + }; + + reading_bonus += factor * (7.0 - approach_rate.max(0.0)) * slider_visibility_factor; + } + + // * Starting from AR0 - cap values so they won't grow to infinity + if approach_rate < 0.0 { + let factor = if is_always_partially_visible { + 0.075 + } else { + 0.1 + }; + + reading_bonus += + factor * (1.0 - 1.5_f64.powf(approach_rate)) * slider_visibility_factor; + } + + reading_bonus + } + + pub fn calculate_difficulty_rating(difficulty_value: f64) -> f64 { + difficulty_value.sqrt() * DIFFICULTY_MULTIPLIER + } + + fn calculate_aim_visibility_factor( + mechanical_difficulty_rating: f64, + approach_rate: f64, + ) -> f64 { + const AR_FACTOR_END_POINT: f64 = 11.5; + + let mechanical_difficulty_factor = reverse_lerp(mechanical_difficulty_rating, 5.0, 10.0); + let ar_factor_starting_point = FloatExt::lerp(9.0, 10.33, mechanical_difficulty_factor); + + reverse_lerp(approach_rate, AR_FACTOR_END_POINT, ar_factor_starting_point) + } + + fn calculate_speed_visibility_factor( + mechanical_difficulty_rating: f64, + approach_rate: f64, + ) -> f64 { + const AR_FACTOR_END_POINT: f64 = 11.5; + + let mechanical_difficulty_factor = reverse_lerp(mechanical_difficulty_rating, 5.0, 10.0); + let ar_factor_starting_point = FloatExt::lerp(10.0, 10.33, mechanical_difficulty_factor); + + reverse_lerp(approach_rate, AR_FACTOR_END_POINT, ar_factor_starting_point) + } +} diff --git a/src/osu/difficulty/scaling_factor.rs b/src/osu/difficulty/scaling_factor.rs index e2252e51..2f5da0aa 100644 --- a/src/osu/difficulty/scaling_factor.rs +++ b/src/osu/difficulty/scaling_factor.rs @@ -1,6 +1,6 @@ use rosu_map::util::Pos; -use crate::osu::object::OsuObject; +use crate::{model::beatmap::BeatmapAttributesExt, osu::object::OsuObject}; use super::object::OsuDifficultyObject; @@ -11,28 +11,25 @@ const BROKEN_GAMEFIELD_ROUNDING_ALLOWANCE: f32 = 1.00041; /// osu!lazer stores these in each hit object but since all objects share the /// same scaling (w.r.t. difficulty & performance), we store them only once. pub struct ScalingFactor { - /// `NORMALIZED_RADIUS / Radius` and then adjusted if `Radius < 30` + /// `NORMALIZED_RADIUS / Radius` pub factor: f32, pub radius: f64, pub scale: f32, } impl ScalingFactor { - pub fn new(cs: f64) -> Self { - let scale = (f64::from(1.0_f32) - f64::from(0.7_f32) * ((cs - 5.0) / 5.0)) as f32 / 2.0 + pub fn new(cs: f32) -> Self { + let scale = (f64::from(1.0_f32) + - f64::from(0.7_f32) * BeatmapAttributesExt::difficulty_range_value(f64::from(cs))) + as f32 + / 2.0 * BROKEN_GAMEFIELD_ROUNDING_ALLOWANCE; let radius = f64::from(OsuObject::OBJECT_RADIUS * scale); let factor = OsuDifficultyObject::NORMALIZED_RADIUS as f32 / radius as f32; - let factor_with_small_circle_bonus = if radius < 30.0 { - factor * (1.0 + (30.0 - radius as f32).min(5.0) / 50.0) - } else { - factor - }; - Self { - factor: factor_with_small_circle_bonus, + factor, radius, scale, } diff --git a/src/osu/difficulty/skills/aim.rs b/src/osu/difficulty/skills/aim.rs index 23a61c60..9a0ef413 100644 --- a/src/osu/difficulty/skills/aim.rs +++ b/src/osu/difficulty/skills/aim.rs @@ -1,16 +1,10 @@ -use std::f64::consts::FRAC_PI_2; - use crate::{ any::difficulty::{ object::{HasStartTime, IDifficultyObject}, skills::{StrainSkill, strain_decay}, }, - osu::difficulty::object::OsuDifficultyObject, - util::{ - difficulty::{milliseconds_to_bpm, reverse_lerp, smootherstep, smoothstep}, - float_ext::FloatExt, - strains_vec::StrainsVec, - }, + osu::difficulty::{evaluators::AimEvaluator, object::OsuDifficultyObject}, + util::{float_ext::FloatExt, strains_vec::StrainsVec}, }; use super::strain::OsuStrainSkill; @@ -25,7 +19,7 @@ define_skill! { } impl Aim { - const SKILL_MULTIPLIER: f64 = 25.6; + const SKILL_MULTIPLIER: f64 = 26.0; const STRAIN_DECAY_BASE: f64 = 0.15; fn calculate_initial_strain( @@ -75,6 +69,10 @@ impl Aim { .sum() } + pub fn slider_strains(&self) -> &[f64] { + &self.slider_strains + } + // From `OsuStrainSkill`; native rather than trait function so that it has // priority over `StrainSkill::difficulty_value` fn difficulty_value(current_strain_peaks: StrainsVec) -> f64 { @@ -88,199 +86,3 @@ impl Aim { } impl OsuStrainSkill for Aim {} - -struct AimEvaluator; - -impl AimEvaluator { - const WIDE_ANGLE_MULTIPLIER: f64 = 1.5; - const ACUTE_ANGLE_MULTIPLIER: f64 = 2.6; - const SLIDER_MULTIPLIER: f64 = 1.35; - const VELOCITY_CHANGE_MULTIPLIER: f64 = 0.75; - const WIGGLE_MULTIPLIER: f64 = 1.02; - - #[allow(clippy::too_many_lines)] - fn evaluate_diff_of<'a>( - curr: &'a OsuDifficultyObject<'a>, - diff_objects: &'a [OsuDifficultyObject<'a>], - with_slider_travel_dist: bool, - ) -> f64 { - let osu_curr_obj = curr; - - let Some((osu_last_last_obj, osu_last_obj)) = curr - .previous(1, diff_objects) - .zip(curr.previous(0, diff_objects)) - .filter(|(_, last)| !(curr.base.is_spinner() || last.base.is_spinner())) - else { - return 0.0; - }; - - #[allow(clippy::items_after_statements)] - const RADIUS: i32 = OsuDifficultyObject::NORMALIZED_RADIUS; - #[allow(clippy::items_after_statements)] - const DIAMETER: i32 = OsuDifficultyObject::NORMALIZED_DIAMETER; - - // * Calculate the velocity to the current hitobject, which starts - // * with a base distance / time assuming the last object is a hitcircle. - let mut curr_vel = osu_curr_obj.lazy_jump_dist / osu_curr_obj.strain_time; - - // * But if the last object is a slider, then we extend the travel - // * velocity through the slider into the current object. - if osu_last_obj.base.is_slider() && with_slider_travel_dist { - // * calculate the slider velocity from slider head to slider end. - let travel_vel = osu_last_obj.travel_dist / osu_last_obj.travel_time; - // * calculate the movement velocity from slider end to current object - let movement_vel = osu_curr_obj.min_jump_dist / osu_curr_obj.min_jump_time; - - // * take the larger total combined velocity. - curr_vel = curr_vel.max(movement_vel + travel_vel); - } - - // * As above, do the same for the previous hitobject. - let mut prev_vel = osu_last_obj.lazy_jump_dist / osu_last_obj.strain_time; - - if osu_last_last_obj.base.is_slider() && with_slider_travel_dist { - let travel_vel = osu_last_last_obj.travel_dist / osu_last_last_obj.travel_time; - let movement_vel = osu_last_obj.min_jump_dist / osu_last_obj.min_jump_time; - - prev_vel = prev_vel.max(movement_vel + travel_vel); - } - - let mut wide_angle_bonus = 0.0; - let mut acute_angle_bonus = 0.0; - let mut slider_bonus = 0.0; - let mut vel_change_bonus = 0.0; - let mut wiggle_bonus = 0.0; - - // * Start strain with regular velocity. - let mut aim_strain = curr_vel; - - // * If rhythms are the same. - if osu_curr_obj.strain_time.max(osu_last_obj.strain_time) - < 1.25 * osu_curr_obj.strain_time.min(osu_last_obj.strain_time) - && let Some((curr_angle, last_angle)) = osu_curr_obj.angle.zip(osu_last_obj.angle) - { - // * Rewarding angles, take the smaller velocity as base. - let angle_bonus = curr_vel.min(prev_vel); - - wide_angle_bonus = Self::calc_wide_angle_bonus(curr_angle); - acute_angle_bonus = Self::calc_acute_angle_bonus(curr_angle); - - // * Penalize angle repetition. - wide_angle_bonus *= 1.0 - - f64::min( - wide_angle_bonus, - f64::powf(Self::calc_wide_angle_bonus(last_angle), 3.0), - ); - acute_angle_bonus *= 0.08 - + 0.92 - * (1.0 - - f64::min( - acute_angle_bonus, - f64::powf(Self::calc_acute_angle_bonus(last_angle), 3.0), - )); - - // * Apply full wide angle bonus for distance more than one diameter - wide_angle_bonus *= - angle_bonus * smootherstep(osu_curr_obj.lazy_jump_dist, 0.0, f64::from(DIAMETER)); - - // * Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter - acute_angle_bonus *= angle_bonus - * smootherstep( - milliseconds_to_bpm(osu_curr_obj.strain_time, Some(2)), - 300.0, - 400.0, - ) - * smootherstep( - osu_curr_obj.lazy_jump_dist, - f64::from(DIAMETER), - f64::from(DIAMETER * 2), - ); - - // * Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle - // * https://www.desmos.com/calculator/dp0v0nvowc - wiggle_bonus = angle_bonus - * smootherstep( - osu_curr_obj.lazy_jump_dist, - f64::from(RADIUS), - f64::from(DIAMETER), - ) - * f64::powf( - reverse_lerp( - osu_curr_obj.lazy_jump_dist, - f64::from(DIAMETER * 3), - f64::from(DIAMETER), - ), - 1.8, - ) - * smootherstep(curr_angle, f64::to_radians(110.0), f64::to_radians(60.0)) - * smootherstep( - osu_last_obj.lazy_jump_dist, - f64::from(RADIUS), - f64::from(DIAMETER), - ) - * f64::powf( - reverse_lerp( - osu_last_obj.lazy_jump_dist, - f64::from(DIAMETER * 3), - f64::from(DIAMETER), - ), - 1.8, - ) - * smootherstep(last_angle, f64::to_radians(110.0), f64::to_radians(60.0)); - } - - if prev_vel.max(curr_vel).not_eq(0.0) { - // * We want to use the average velocity over the whole object when awarding - // * differences, not the individual jump and slider path velocities. - prev_vel = (osu_last_obj.lazy_jump_dist + osu_last_last_obj.travel_dist) - / osu_last_obj.strain_time; - curr_vel = - (osu_curr_obj.lazy_jump_dist + osu_last_obj.travel_dist) / osu_curr_obj.strain_time; - - // * Scale with ratio of difference compared to 0.5 * max dist. - let dist_ratio_base = - (FRAC_PI_2 * (prev_vel - curr_vel).abs() / prev_vel.max(curr_vel)).sin(); - let dist_ratio = dist_ratio_base.powf(2.0); - - // * Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing. - let overlap_vel_buff = (f64::from(DIAMETER) * 1.25 - / osu_curr_obj.strain_time.min(osu_last_obj.strain_time)) - .min((prev_vel - curr_vel).abs()); - - vel_change_bonus = overlap_vel_buff * dist_ratio; - - // * Penalize for rhythm changes. - let bonus_base = (osu_curr_obj.strain_time).min(osu_last_obj.strain_time) - / (osu_curr_obj.strain_time).max(osu_last_obj.strain_time); - vel_change_bonus *= bonus_base.powf(2.0); - } - - if osu_last_obj.base.is_slider() { - // * Reward sliders based on velocity. - slider_bonus = osu_last_obj.travel_dist / osu_last_obj.travel_time; - } - - aim_strain += wiggle_bonus * Self::WIGGLE_MULTIPLIER; - - // * Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger. - aim_strain += (acute_angle_bonus * Self::ACUTE_ANGLE_MULTIPLIER).max( - wide_angle_bonus * Self::WIDE_ANGLE_MULTIPLIER - + vel_change_bonus * Self::VELOCITY_CHANGE_MULTIPLIER, - ); - - // * Add in additional slider velocity bonus. - if with_slider_travel_dist { - aim_strain += slider_bonus * Self::SLIDER_MULTIPLIER; - } - - aim_strain - } - - const fn calc_wide_angle_bonus(angle: f64) -> f64 { - smoothstep(angle, f64::to_radians(40.0), f64::to_radians(140.0)) - } - - const fn calc_acute_angle_bonus(angle: f64) -> f64 { - smoothstep(angle, f64::to_radians(140.0), f64::to_radians(40.0)) - } -} diff --git a/src/osu/difficulty/skills/flashlight.rs b/src/osu/difficulty/skills/flashlight.rs index 1f577683..86282277 100644 --- a/src/osu/difficulty/skills/flashlight.rs +++ b/src/osu/difficulty/skills/flashlight.rs @@ -1,12 +1,10 @@ -use std::cmp; - use crate::{ GameMods, any::difficulty::{ object::{HasStartTime, IDifficultyObject}, skills::strain_decay, }, - osu::{difficulty::object::OsuDifficultyObject, object::OsuObjectKind}, + osu::difficulty::{evaluators::FlashlightEvaluator, object::OsuDifficultyObject}, util::strains_vec::StrainsVec, }; @@ -14,13 +12,11 @@ define_skill! { pub struct Flashlight: StrainSkill => [OsuDifficultyObject<'a>][OsuDifficultyObject<'a>] { current_strain: f64, has_hidden_mod: bool, - evaluator: FlashlightEvaluator = todo!(), + evaluator: FlashlightEvaluator, } pub fn new(mods: &GameMods, radius: f64, time_preempt: f64, time_fade_in: f64) -> Self { - { - let scaling_factor = 52.0 / radius; - } + let scaling_factor = 52.0 / radius; Self { current_strain: 0.0, @@ -61,7 +57,7 @@ impl Flashlight { self.current_strain } - #[allow( + #[expect( clippy::needless_pass_by_value, reason = "function definition needs to stay in-sync with `StrainSkill::difficulty_value`" )] @@ -73,135 +69,3 @@ impl Flashlight { 25.0 * f64::powf(difficulty, 2.0) } } - -struct FlashlightEvaluator { - scaling_factor: f64, - time_preempt: f64, - time_fade_in: f64, -} - -impl FlashlightEvaluator { - const MAX_OPACITY_BONUS: f64 = 0.4; - const HIDDEN_BONUS: f64 = 0.2; - - const MIN_VELOCITY: f64 = 0.5; - const SLIDER_MULTIPLIER: f64 = 1.3; - - const MIN_ANGLE_MULTIPLIER: f64 = 0.2; - - const fn new(scaling_factor: f64, time_preempt: f64, time_fade_in: f64) -> Self { - Self { - scaling_factor, - time_preempt, - time_fade_in, - } - } - - fn evaluate_diff_of<'a>( - &self, - curr: &'a OsuDifficultyObject<'a>, - diff_objects: &'a [OsuDifficultyObject<'a>], - hidden: bool, - ) -> f64 { - if curr.base.is_spinner() { - return 0.0; - } - - let osu_curr = curr; - let osu_hit_obj = curr.base; - - let mut small_dist_nerf = 1.0; - let mut cumulative_strain_time = 0.0; - - let mut result = 0.0; - - let mut last_obj = osu_curr; - - let mut angle_repeat_count = 0.0; - - // * This is iterating backwards in time from the current object. - for i in 0..cmp::min(curr.idx, 10) { - let Some(curr_obj) = curr.previous(i, diff_objects) else { - break; - }; - - cumulative_strain_time += last_obj.strain_time; - - let curr_hit_obj = curr_obj.base; - - if !curr_obj.base.is_spinner() { - let jump_dist = f64::from( - (osu_hit_obj.stacked_pos() - curr_hit_obj.stacked_end_pos()).length(), - ); - - // * We want to nerf objects that can be easily seen within the Flashlight circle radius. - if i == 0 { - small_dist_nerf = (jump_dist / 75.0).min(1.0); - } - - // * We also want to nerf stacks so that only the first object of the stack is accounted for. - let stack_nerf = ((curr_obj.lazy_jump_dist / self.scaling_factor) / 25.0).min(1.0); - - // * Bonus based on how visible the object is. - let opacity_bonus = 1.0 - + Self::MAX_OPACITY_BONUS - * (1.0 - - osu_curr.opacity_at( - curr_hit_obj.start_time, - hidden, - self.time_preempt, - self.time_fade_in, - )); - - result += stack_nerf * opacity_bonus * self.scaling_factor * jump_dist - / cumulative_strain_time; - - if let Some((curr_obj_angle, osu_curr_angle)) = curr_obj.angle.zip(osu_curr.angle) { - // * Objects further back in time should count less for the nerf. - if (curr_obj_angle - osu_curr_angle).abs() < 0.02 { - angle_repeat_count += (1.0 - 0.1 * i as f64).max(0.0); - } - } - } - - last_obj = curr_obj; - } - - result = (small_dist_nerf * result).powf(2.0); - - // * Additional bonus for Hidden due to there being no approach circles. - if hidden { - result *= 1.0 + Self::HIDDEN_BONUS; - } - - // * Nerf patterns with repeated angles. - result *= Self::MIN_ANGLE_MULTIPLIER - + (1.0 - Self::MIN_ANGLE_MULTIPLIER) / (angle_repeat_count + 1.0); - - let mut slider_bonus = 0.0; - - if let OsuObjectKind::Slider(slider) = &osu_curr.base.kind { - // * Invert the scaling factor to determine the true travel distance independent of circle size. - let pixel_travel_dist = f64::from(slider.lazy_travel_dist) / self.scaling_factor; - - // * Reward sliders based on velocity. - slider_bonus = ((pixel_travel_dist / osu_curr.travel_time - Self::MIN_VELOCITY) - .max(0.0)) - .powf(0.5); - - // * Longer sliders require more memorisation. - slider_bonus *= pixel_travel_dist; - - // * Nerf sliders with repeats, as less memorisation is required. - let repeat_count = slider.repeat_count(); - - if repeat_count > 0 { - slider_bonus /= (repeat_count + 1) as f64; - } - } - - result += slider_bonus * Self::SLIDER_MULTIPLIER; - - result - } -} diff --git a/src/osu/difficulty/skills/mod.rs b/src/osu/difficulty/skills/mod.rs index 860ba992..c338b4d2 100644 --- a/src/osu/difficulty/skills/mod.rs +++ b/src/osu/difficulty/skills/mod.rs @@ -1,8 +1,4 @@ -use crate::{ - any::difficulty::skills::StrainSkill, - model::{beatmap::BeatmapAttributes, mods::GameMods}, - osu::object::OsuObject, -}; +use crate::{any::difficulty::skills::StrainSkill, model::mods::GameMods, osu::object::OsuObject}; use self::{aim::Aim, flashlight::Flashlight, speed::Speed}; @@ -26,10 +22,10 @@ impl OsuSkills { pub fn new( mods: &GameMods, scaling_factor: &ScalingFactor, - map_attrs: &BeatmapAttributes, + great_hit_window: f64, time_preempt: f64, ) -> Self { - let hit_window = 2.0 * map_attrs.hit_windows.od_great; + let hit_window = 2.0 * great_hit_window; // * Preempt time can go below 450ms. Normally, this is achieved via the DT mod // * which uniformly speeds up all animations game wide regardless of AR. diff --git a/src/osu/difficulty/skills/speed.rs b/src/osu/difficulty/skills/speed.rs index f09081e6..5312b3d1 100644 --- a/src/osu/difficulty/skills/speed.rs +++ b/src/osu/difficulty/skills/speed.rs @@ -1,15 +1,13 @@ -use std::{cmp, f64::consts::PI}; - use crate::{ any::difficulty::{ object::{HasStartTime, IDifficultyObject}, skills::{StrainSkill, strain_decay}, }, - osu::difficulty::object::OsuDifficultyObject, - util::{ - difficulty::{bpm_to_milliseconds, logistic, milliseconds_to_bpm}, - strains_vec::StrainsVec, + osu::difficulty::{ + evaluators::{RhythmEvaluator, SpeedEvaluator}, + object::OsuDifficultyObject, }, + util::strains_vec::StrainsVec, }; use super::strain::OsuStrainSkill; @@ -21,11 +19,12 @@ define_skill! { current_rhythm: f64 = 0.0, hit_window: f64, has_autopilot_mod: bool, + slider_strains: Vec = Vec::with_capacity(64), } } impl Speed { - const SKILL_MULTIPLIER: f64 = 1.46; + const SKILL_MULTIPLIER: f64 = 1.47; const STRAIN_DECAY_BASE: f64 = 0.3; const REDUCED_SECTION_COUNT: usize = 5; @@ -48,7 +47,7 @@ impl Speed { curr: &OsuDifficultyObject<'_>, objects: &[OsuDifficultyObject<'_>], ) -> f64 { - self.current_strain *= strain_decay(curr.strain_time, Self::STRAIN_DECAY_BASE); + self.current_strain *= strain_decay(curr.adjusted_delta_time, Self::STRAIN_DECAY_BASE); self.current_strain += SpeedEvaluator::evaluate_diff_of( curr, objects, @@ -57,7 +56,13 @@ impl Speed { ) * Self::SKILL_MULTIPLIER; self.current_rhythm = RhythmEvaluator::evaluate_diff_of(curr, objects, self.hit_window); - self.current_strain * self.current_rhythm + let total_strain = self.current_strain * self.current_rhythm; + + if curr.base.is_slider() { + self.slider_strains.push(total_strain); + } + + total_strain } pub fn relevant_note_count(&self) -> f64 { @@ -75,6 +80,10 @@ impl Speed { }) } + pub fn slider_strains(&self) -> &[f64] { + &self.slider_strains + } + // From `OsuStrainSkill`; native rather than trait function so that it has // priority over `StrainSkill::difficulty_value` fn difficulty_value(current_strain_peaks: StrainsVec) -> f64 { @@ -88,329 +97,3 @@ impl Speed { } impl OsuStrainSkill for Speed {} - -struct SpeedEvaluator; - -impl SpeedEvaluator { - const SINGLE_SPACING_THRESHOLD: f64 = OsuDifficultyObject::NORMALIZED_DIAMETER as f64 * 1.25; // 1.25 circlers distance between centers - const MIN_SPEED_BONUS: f64 = 200.0; // 200 BPM 1/4th - const SPEED_BALANCING_FACTOR: f64 = 40.0; - const DIST_MULTIPLIER: f64 = 0.9; - - fn evaluate_diff_of<'a>( - curr: &'a OsuDifficultyObject<'a>, - diff_objects: &'a [OsuDifficultyObject<'a>], - hit_window: f64, - autopilot: bool, - ) -> f64 { - if curr.base.is_spinner() { - return 0.0; - } - - // * derive strainTime for calculation - let osu_curr_obj = curr; - let osu_prev_obj = curr.previous(0, diff_objects); - let osu_next_obj = curr.next(0, diff_objects); - - let mut strain_time = curr.strain_time; - // Note: Technically `osu_next_obj` is never `None` but instead the - // default value. This could maybe invalidate the `get_doubletapness` - // result. - let doubletapness = 1.0 - osu_curr_obj.get_doubletapness(osu_next_obj, hit_window); - - // * Cap deltatime to the OD 300 hitwindow. - // * 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap. - strain_time /= ((strain_time / hit_window) / 0.93).clamp(0.92, 1.0); - - let speed_bonus = if milliseconds_to_bpm(strain_time, None) > Self::MIN_SPEED_BONUS { - // * Add additional scaling bonus for streams/bursts higher than 200bpm - let base = (bpm_to_milliseconds(Self::MIN_SPEED_BONUS, None) - strain_time) - / Self::SPEED_BALANCING_FACTOR; - - 0.75 * base.powf(2.0) - } else { - // * speedBonus will be 0.0 for BPM < 200 - 0.0 - }; - - let travel_dist = osu_prev_obj.map_or(0.0, |obj| obj.travel_dist); - let mut dist = travel_dist + osu_curr_obj.min_jump_dist; - - // * Cap distance at single_spacing_threshold - dist = Self::SINGLE_SPACING_THRESHOLD.min(dist); - - // * Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold - let mut dist_bonus = - (dist / Self::SINGLE_SPACING_THRESHOLD).powf(3.95) * Self::DIST_MULTIPLIER; - - if autopilot { - dist_bonus = 0.0; - } - - // * Base difficulty with all bonuses - let difficulty = (1.0 + speed_bonus + dist_bonus) * 1000.0 / strain_time; - - // * Apply penalty if there's doubletappable doubles - difficulty * doubletapness - } -} - -struct RhythmEvaluator; - -impl RhythmEvaluator { - const HISTORY_TIME_MAX: u32 = 5 * 1000; // 5 seconds - const HISTORY_OBJECTS_MAX: usize = 32; - const RHYTHM_OVERALL_MULTIPLIER: f64 = 0.95; - const RHYTHM_RATIO_MULTIPLIER: f64 = 12.0; - - #[allow(clippy::too_many_lines)] - fn evaluate_diff_of<'a>( - curr: &'a OsuDifficultyObject<'a>, - diff_objects: &'a [OsuDifficultyObject<'a>], - hit_window: f64, - ) -> f64 { - if curr.base.is_spinner() { - return 0.0; - } - - let mut rhythm_complexity_sum = 0.0; - - let delta_difference_eps = hit_window * 0.3; - - let mut island = RhythmIsland::new(delta_difference_eps); - let mut prev_island = RhythmIsland::new(delta_difference_eps); - - // * we can't use dictionary here because we need to compare island with a tolerance - // * which is impossible to pass into the hash comparer - let mut island_counts = Vec::::new(); - - // * store the ratio of the current start of an island to buff for tighter rhythms - let mut start_ratio = 0.0; - - let mut first_delta_switch = false; - - let historical_note_count = cmp::min(curr.idx, Self::HISTORY_OBJECTS_MAX); - - let mut rhythm_start = 0; - - while curr - .previous(rhythm_start, diff_objects) - .filter(|prev| { - rhythm_start + 2 < historical_note_count - && curr.start_time - prev.start_time < f64::from(Self::HISTORY_TIME_MAX) - }) - .is_some() - { - rhythm_start += 1; - } - - if let Some((mut prev_obj, mut last_obj)) = curr - .previous(rhythm_start, diff_objects) - .zip(curr.previous(rhythm_start + 1, diff_objects)) - { - // * we go from the furthest object back to the current one - for i in (1..=rhythm_start).rev() { - let Some(curr_obj) = curr.previous(i - 1, diff_objects) else { - break; - }; - - // * scales note 0 to 1 from history to now - let time_decay = (f64::from(Self::HISTORY_TIME_MAX) - - (curr.start_time - curr_obj.start_time)) - / f64::from(Self::HISTORY_TIME_MAX); - let note_decay = (historical_note_count - i) as f64 / historical_note_count as f64; - - // * either we're limited by time or limited by object count. - let curr_historical_decay = note_decay.min(time_decay); - - let curr_delta = curr_obj.strain_time; - let prev_delta = prev_obj.strain_time; - let last_delta = last_obj.strain_time; - - // * calculate how much current delta difference deserves a rhythm bonus - // * this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200) - let delta_difference_ratio = - prev_delta.min(curr_delta) / prev_delta.max(curr_delta); - let curr_ratio = 1.0 - + Self::RHYTHM_RATIO_MULTIPLIER - * (PI / delta_difference_ratio).sin().powf(2.0).min(0.5); - - // reduce ratio bonus if delta difference is too big - let fraction = (prev_delta / curr_delta).max(curr_delta / prev_delta); - let fraction_multiplier = (2.0 - fraction / 8.0).clamp(0.0, 1.0); - - let window_penalty = (((prev_delta - curr_delta).abs() - delta_difference_eps) - .max(0.0) - / delta_difference_eps) - .min(1.0); - - let mut effective_ratio = window_penalty * curr_ratio * fraction_multiplier; - - if first_delta_switch { - // Keep in-sync with lazer - #[allow(clippy::if_not_else)] - if (prev_delta - curr_delta).abs() < delta_difference_eps { - // * island is still progressing - island.add_delta(curr_delta as i32); - } else { - // * bpm change is into slider, this is easy acc window - if curr_obj.base.is_slider() { - effective_ratio *= 0.125; - } - - // * bpm change was from a slider, this is easier typically than circle -> circle - // * unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders - if prev_obj.base.is_slider() { - effective_ratio *= 0.3; - } - - // * repeated island polarity (2 -> 4, 3 -> 5) - if island.is_similar_polarity(&prev_island) { - effective_ratio *= 0.5; - } - - // * previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this. - if last_delta > prev_delta + delta_difference_eps - && prev_delta > curr_delta + delta_difference_eps - { - effective_ratio *= 0.125; - } - - // * repeated island size (ex: triplet -> triplet) - // * TODO: remove this nerf since its staying here only for balancing purposes because of the flawed ratio calculation - if prev_island.delta_count == island.delta_count { - effective_ratio *= 0.5; - } - - if let Some(island_count) = island_counts - .iter_mut() - .find(|entry| entry.island == island) - .filter(|entry| !entry.island.is_default()) - { - // * only add island to island counts if they're going one after another - if prev_island == island { - island_count.count += 1; - } - - // * repeated island (ex: triplet -> triplet) - let power = logistic(f64::from(island.delta), 58.33, 0.24, Some(2.75)); - effective_ratio *= (3.0 / island_count.count as f64) - .min((island_count.count as f64).recip().powf(power)); - } else { - island_counts.push(IslandCount { island, count: 1 }); - } - - // * scale down the difficulty if the object is doubletappable - let doubletapness = prev_obj.get_doubletapness(Some(curr_obj), hit_window); - effective_ratio *= 1.0 - doubletapness * 0.75; - - rhythm_complexity_sum += - (effective_ratio * start_ratio).sqrt() * curr_historical_decay; - - start_ratio = effective_ratio; - - prev_island = island; - - // * we're slowing down, stop counting - if prev_delta + delta_difference_eps < curr_delta { - // * if we're speeding up, this stays true and we keep counting island size. - first_delta_switch = false; - } - - island = - RhythmIsland::new_with_delta(curr_delta as i32, delta_difference_eps); - } - } else if prev_delta > curr_delta + delta_difference_eps { - // * we're speeding up. - // * Begin counting island until we change speed again. - first_delta_switch = true; - - // * bpm change is into slider, this is easy acc window - if curr_obj.base.is_slider() { - effective_ratio *= 0.6; - } - - // * bpm change was from a slider, this is easier typically than circle -> circle - // * unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders - if prev_obj.base.is_slider() { - effective_ratio *= 0.6; - } - - start_ratio = effective_ratio; - - island = RhythmIsland::new_with_delta(curr_delta as i32, delta_difference_eps); - } - - last_obj = prev_obj; - prev_obj = curr_obj; - } - } - - // * produces multiplier that can be applied to strain. range [1, infinity) (not really though) - (4.0 + rhythm_complexity_sum * Self::RHYTHM_OVERALL_MULTIPLIER).sqrt() / 2.0 - } -} - -#[derive(Copy, Clone)] -struct RhythmIsland { - delta_difference_eps: f64, - delta: i32, - delta_count: i32, -} - -const MIN_DELTA_TIME: i32 = 25; - -// Compile-time check in case `OsuDifficultyObject::MIN_DELTA_TIME` changes -// but we forget to update this value. -const _: [(); 0 - !{ MIN_DELTA_TIME - OsuDifficultyObject::MIN_DELTA_TIME as i32 == 0 } as usize] = - []; - -impl RhythmIsland { - const fn new(delta_difference_eps: f64) -> Self { - Self { - delta_difference_eps, - delta: 0, - delta_count: 0, - } - } - - fn new_with_delta(delta: i32, delta_difference_eps: f64) -> Self { - Self { - delta_difference_eps, - delta: delta.max(MIN_DELTA_TIME), - delta_count: 1, - } - } - - fn add_delta(&mut self, delta: i32) { - if self.delta == i32::MAX { - self.delta = delta.max(MIN_DELTA_TIME); - } - - self.delta_count += 1; - } - - const fn is_similar_polarity(&self, other: &Self) -> bool { - // * TODO: consider islands to be of similar polarity only if they're having the same average delta (we don't want to consider 3 singletaps similar to a triple) - // * naively adding delta check here breaks _a lot_ of maps because of the flawed ratio calculation - self.delta_count % 2 == other.delta_count % 2 - } - - fn is_default(&self) -> bool { - self.delta_difference_eps.abs() < f64::EPSILON - && self.delta == i32::MAX - && self.delta_count == 0 - } -} - -impl PartialEq for RhythmIsland { - fn eq(&self, other: &Self) -> bool { - f64::from((self.delta - other.delta).abs()) < self.delta_difference_eps - && self.delta_count == other.delta_count - } -} - -struct IslandCount { - island: RhythmIsland, - count: usize, -} diff --git a/src/osu/difficulty/skills/strain.rs b/src/osu/difficulty/skills/strain.rs index 5d79a415..c2256e9b 100644 --- a/src/osu/difficulty/skills/strain.rs +++ b/src/osu/difficulty/skills/strain.rs @@ -1,4 +1,4 @@ -use crate::util::strains_vec::StrainsVec; +use crate::util::{difficulty::logistic, float_ext::FloatExt, strains_vec::StrainsVec}; pub trait OsuStrainSkill { const REDUCED_SECTION_COUNT: usize = 10; @@ -50,6 +50,24 @@ pub fn difficulty_value( difficulty } +pub fn count_top_weighted_sliders(slider_strains: &[f64], difficulty_value: f64) -> f64 { + if slider_strains.is_empty() { + return 0.0; + } + + // * What would the top strain be if all strain values were identical + let consistent_top_strain = difficulty_value / 10.0; + + if FloatExt::eq(consistent_top_strain, 0.0) { + return 0.0; + } + + slider_strains + .iter() + .map(|s| logistic(*s / consistent_top_strain, 0.88, 10.0, Some(1.1))) + .sum() +} + pub fn difficulty_to_performance(difficulty: f64) -> f64 { f64::powf(5.0 * f64::max(1.0, difficulty / 0.0675) - 4.0, 3.0) / 100_000.0 } diff --git a/src/osu/legacy_score_miss_calc.rs b/src/osu/legacy_score_miss_calc.rs new file mode 100644 index 00000000..cfe1add7 --- /dev/null +++ b/src/osu/legacy_score_miss_calc.rs @@ -0,0 +1,210 @@ +use std::cmp; + +use crate::{ + GameMods, + osu::{OsuDifficultyAttributes, OsuScoreState}, +}; + +pub struct OsuLegacyScoreMissCalculator<'a> { + state: &'a OsuScoreState, + acc: f64, + mods: &'a GameMods, + attrs: &'a OsuDifficultyAttributes, +} + +impl<'a> OsuLegacyScoreMissCalculator<'a> { + pub const fn new( + state: &'a OsuScoreState, + acc: f64, + mods: &'a GameMods, + attrs: &'a OsuDifficultyAttributes, + ) -> Self { + Self { + state, + acc, + mods, + attrs, + } + } + + pub fn calculate(self) -> f64 { + let Self { state, attrs, .. } = self; + + if attrs.max_combo == 0 { + return 0.0; + } + + let Some(legacy_total_score) = state.legacy_total_score else { + return 0.0; + }; + + let score_v1_multiplier = + self.attrs.legacy_score_base_multiplier * self.get_legacy_score_multiplier(); + let relevant_combo_per_object = self.calculate_relevant_score_combo_per_object(); + + let maximum_miss_count = self.calculate_maximum_combo_based_miss_count(); + + let score_obtained_during_max_combo = self.calculate_score_at_combo( + state.max_combo, + relevant_combo_per_object, + score_v1_multiplier, + ); + let remaining_score = f64::from(legacy_total_score) - score_obtained_during_max_combo; + + if remaining_score <= 0.0 { + return maximum_miss_count; + } + + let remaining_combo = attrs.max_combo - state.max_combo; + let expected_remaining_score = self.calculate_score_at_combo( + remaining_combo, + relevant_combo_per_object, + score_v1_multiplier, + ); + + let mut score_based_miss_count = expected_remaining_score / remaining_score; + + // * If there's less than one miss detected - let combo-based miss count decide if this is FC or not + score_based_miss_count = score_based_miss_count.max(1.0); + + // * Cap result by very harsh version of combo-based miss count + score_based_miss_count.min(maximum_miss_count) + } + + fn calculate_score_at_combo( + &self, + combo: u32, + relevant_combo_per_object: f64, + score_v1_multiplier: f64, + ) -> f64 { + let Self { + state, acc, attrs, .. + } = self; + + let total_hits = state.hitresults.total_hits(); + + let estimated_objects = f64::from(combo) / relevant_combo_per_object - 1.0; + + // * The combo portion of ScoreV1 follows arithmetic progression + // * Therefore, we calculate the combo portion of score using the combo per object and our current combo. + let mut combo_score = if relevant_combo_per_object > 0.0 { + (2.0 * (relevant_combo_per_object - 1.0) + + (estimated_objects - 1.0) * relevant_combo_per_object) + * estimated_objects + / 2.0 + } else { + 0.0 + }; + + // * We then apply the accuracy and ScoreV1 multipliers to the resulting score. + combo_score *= acc * 300.0 / 25.0 * score_v1_multiplier; + + let objects_hit = f64::from(total_hits - state.hitresults.misses) * f64::from(combo) + / f64::from(attrs.max_combo); + + // * Score also has a non-combo portion we need to create the final score value. + let non_combo_score = (300.0 + attrs.nested_score_per_object) * acc * objects_hit; + + combo_score + non_combo_score + } + + // * Calculates the relevant combo per object for legacy score. + // * This assumes a uniform distribution for circles and sliders. + // * This handles cases where objects (such as buzz sliders) do not fit a normal arithmetic progression model. + fn calculate_relevant_score_combo_per_object(&self) -> f64 { + let attrs = self.attrs; + let mut combo_score = attrs.maximum_legacy_combo_score; + + // * We then reverse apply the ScoreV1 multipliers to get the raw value. + combo_score /= 300.0 / 25.0 * attrs.legacy_score_base_multiplier; + + // * Reverse the arithmetic progression to work out the amount of combo per object based on the score. + let mut result = f64::from((attrs.max_combo as i32 - 2) * attrs.max_combo as i32); + result /= (f64::from(attrs.max_combo) + 2.0 * (combo_score - 1.0)).max(1.0); + + result + } + + fn calculate_maximum_combo_based_miss_count(&self) -> f64 { + let Self { state, attrs, .. } = self; + + if attrs.n_sliders == 0 { + return f64::from(state.hitresults.misses); + } + + let total_imperfect_hits = + state.hitresults.n100 + state.hitresults.n50 + state.hitresults.misses; + + let mut miss_count = 0.0; + + // * Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it + // * In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map + let full_combo_threshold = f64::from(attrs.max_combo) - 0.1 * f64::from(attrs.n_circles); + + if f64::from(state.max_combo) < full_combo_threshold { + miss_count = (full_combo_threshold / f64::from(state.max_combo).max(1.0)).powf(2.5); + } + + // * In classic scores there can't be more misses than a sum of all non-perfect judgements + miss_count = miss_count.min(f64::from(total_imperfect_hits)); + + // * Every slider has *at least* 2 combo attributed in classic mechanics. + // * If they broke on a slider with a tick, then this still works since they would have lost at least 2 combo (the tick and the end) + // * Using this as a max means a score that loses 1 combo on a map can't possibly have been a slider break. + // * It must have been a slider end. + let max_possible_slider_breaks = + cmp::min((attrs.max_combo - state.max_combo) / 2, attrs.n_sliders); + + let slider_breaks = miss_count - f64::from(state.hitresults.misses); + + if slider_breaks > f64::from(max_possible_slider_breaks) { + miss_count = f64::from(state.hitresults.misses + max_possible_slider_breaks); + } + + miss_count + } + + fn get_legacy_score_multiplier(&self) -> f64 { + let mods = self.mods; + let score_v2 = mods.sv2(); + let mut multiplier = 1.0; + + if mods.nf() { + multiplier *= if score_v2 { 1.0 } else { 0.5 }; + } + + if mods.ez() { + multiplier *= 0.5; + } + + if mods.clock_rate() < 1.0 { + multiplier *= 0.3; + } + + if mods.hd() { + multiplier *= 1.06; + } + + if mods.hr() { + multiplier *= if score_v2 { 1.10 } else { 1.06 }; + } + + if mods.clock_rate() > 1.0 { + multiplier *= if score_v2 { 1.20 } else { 1.12 }; + } + + if mods.fl() { + multiplier *= 1.12; + } + + if mods.so() { + multiplier *= 0.9; + } + + if mods.rx() || mods.ap() { + multiplier *= 0.0; + } + + multiplier + } +} diff --git a/src/osu/legacy_score_simulator/gradual.rs b/src/osu/legacy_score_simulator/gradual.rs new file mode 100644 index 00000000..d7f4b806 --- /dev/null +++ b/src/osu/legacy_score_simulator/gradual.rs @@ -0,0 +1,257 @@ +use std::{cmp, iter::Peekable, vec}; + +use rosu_map::section::events::BreakPeriod; + +use crate::{ + Beatmap, + any::hit_result::HitResult, + model::beatmap::BeatmapAttributes, + osu::{ + legacy_score_simulator::{ + AddScoreComboMultiplier, IncreaseCombo, IsBonus, LegacyScoreAttributes, + LegacyScoreSimulatorInner, + }, + object::{NestedSliderObjectKind, OsuObject, OsuObjectKind}, + }, + util::ruleset_ext::calculate_difficulty_peppy_stars, +}; + +pub struct GradualLegacyScoreSimulator { + map_attrs: BeatmapAttributes, + map_attrs_nomod: BeatmapAttributes, + attrs: LegacyScoreAttributes, + inner: super::LegacyScoreSimulatorInner, + combo_score_factors: Vec, + + breaks: Peekable>, + elapsed_curr_break: Option, + break_len_prelim: i32, + object_count: i32, + start_time: Option, +} + +impl GradualLegacyScoreSimulator { + pub fn new(map: &Beatmap, map_attrs: BeatmapAttributes) -> Self { + Self { + map_attrs, + map_attrs_nomod: map.attributes().build(), + attrs: LegacyScoreAttributes::default(), + inner: LegacyScoreSimulatorInner::default(), + combo_score_factors: Vec::new(), + breaks: map.breaks.clone().into_iter().peekable(), + elapsed_curr_break: None, + break_len_prelim: 0, + object_count: 0, + start_time: None, + } + } + + const fn break_len(&self) -> i32 { + // Manual `.unwrap_or(0)` for const-ness + self.break_len_prelim + + if let Some(elapsed) = self.elapsed_curr_break { + elapsed + } else { + 0 + } + } + + pub fn score_multiplier(&self, obj: &OsuObject, nomod: bool) -> f64 { + let start_time = self.start_time.unwrap_or(round_time(obj.start_time)); + let end_time = round_time(obj.start_time); + let drain_len = (end_time - start_time - self.break_len()) / 1000; + + let map_attrs = if nomod { + &self.map_attrs_nomod + } else { + &self.map_attrs + }; + + f64::from(calculate_difficulty_peppy_stars( + map_attrs, + self.object_count, + drain_len, + )) + } + + fn prepare_score_multiplier(&mut self, obj: &OsuObject) { + // Note that this logic does not handle the case properly when + // breaks are overlapping but that seems like a pathological / + // malicious case anyway. + // Another unhandled case are breaks *after* the last object. + while let Some(b) = self.breaks.peek() { + if b.start_time >= obj.start_time { + break; + } + + if b.end_time < obj.start_time { + self.break_len_prelim += round_time(b.end_time) - round_time(b.start_time); + self.elapsed_curr_break.take(); + self.breaks.next(); + } else { + // Do we even need to handle the case of objects appearing + // during a break? Probably yes because those pesky mappers + // will find a way... + + let period_end = cmp::min(round_time(b.end_time), round_time(obj.start_time)); + self.elapsed_curr_break = Some(period_end - round_time(b.start_time)); + } + } + + self.object_count += 1; + self.start_time.get_or_insert(round_time(obj.start_time)); + } + + pub fn simulate_next(&mut self, obj: &OsuObject) -> LegacyScoreAttributes { + self.prepare_score_multiplier(obj); + let score_multiplier = self.score_multiplier(obj, true); + self.simulate_hit(obj); + + let combo_score = self + .combo_score_factors + .iter() + .fold(0.0, |combo_score, &factor| { + combo_score + factor * score_multiplier + }); + + self.attrs.combo_score = i64::from(combo_score as i32); + + self.inner.finalize(&mut self.attrs); + + self.attrs.clone() + } + + fn simulate_hit(&mut self, hit_object: &OsuObject) { + const DEFAULT_BONUS_RESULT: HitResult = HitResult::None; + + match hit_object.kind { + OsuObjectKind::Circle => { + self.unrolled_recursion( + AddScoreComboMultiplier::Yes, + IsBonus::default(), + IncreaseCombo::default(), + 300, + DEFAULT_BONUS_RESULT, + ); + } + OsuObjectKind::Slider(ref slider) => { + // Slider head + self.unrolled_recursion( + AddScoreComboMultiplier::default(), + IsBonus::default(), + IncreaseCombo::default(), + 30, + DEFAULT_BONUS_RESULT, + ); + + for nested in slider.nested_objects.iter() { + match nested.kind { + NestedSliderObjectKind::Repeat | NestedSliderObjectKind::Tail => { + self.unrolled_recursion( + AddScoreComboMultiplier::default(), + IsBonus::default(), + IncreaseCombo::default(), + 30, + DEFAULT_BONUS_RESULT, + ); + } + NestedSliderObjectKind::Tick => { + self.unrolled_recursion( + AddScoreComboMultiplier::default(), + IsBonus::default(), + IncreaseCombo::default(), + 10, + DEFAULT_BONUS_RESULT, + ); + } + } + } + + self.unrolled_recursion( + AddScoreComboMultiplier::Yes, + IsBonus::default(), + IncreaseCombo::No, + 300, + DEFAULT_BONUS_RESULT, + ); + } + OsuObjectKind::Spinner(ref spinner) => { + // * The spinner object applies a lenience because gameplay mechanics differ from osu-stable. + // * We'll redo the calculations to match osu-stable here... + const MAXIMUM_ROTATIONS_PER_SECOND: f64 = 477.0 / 60.0; + + // * Normally, this value depends on the final overall difficulty. For simplicity, we'll only consider the worst case that maximises bonus score. + // * As we're primarily concerned with computing the maximum theoretical final score, + // * this will have the final effect of slightly underestimating bonus score achieved on stable when converting from score V1. + const MINIMUM_ROTATIONS_PER_SECOND: f64 = 3.0; + + let seconds_duration = spinner.duration / 1000.0; + + // * The total amount of half spins possible for the entire spinner. + let total_half_spins_possible = + (seconds_duration * MAXIMUM_ROTATIONS_PER_SECOND * 2.0) as i32; + // * The amount of half spins that are required to successfully complete the spinner (i.e. get a 300). + let half_spins_required_for_completion = + (seconds_duration * MINIMUM_ROTATIONS_PER_SECOND) as i32; + // * To be able to receive bonus points, the spinner must be rotated another 1.5 times. + let half_spins_required_before_bonus = half_spins_required_for_completion + 3; + + for i in 0..=total_half_spins_possible { + if i > half_spins_required_before_bonus + && (i - half_spins_required_before_bonus) % 2 == 0 + { + self.unrolled_recursion( + AddScoreComboMultiplier::default(), + IsBonus::Yes, + IncreaseCombo::No, + 1100, + HitResult::LargeBonus, + ); + } else if i > 1 && i % 2 == 0 { + self.unrolled_recursion( + AddScoreComboMultiplier::default(), + IsBonus::Yes, + IncreaseCombo::No, + 100, + HitResult::SmallBonus, + ); + } + } + + self.unrolled_recursion( + AddScoreComboMultiplier::Yes, + IsBonus::default(), + IncreaseCombo::default(), + 300, + DEFAULT_BONUS_RESULT, + ); + } + } + } + + fn unrolled_recursion( + &mut self, + add_score_combo_multiplier: AddScoreComboMultiplier, + is_bonus: IsBonus, + increase_combo: IncreaseCombo, + score_increase: i32, + bonus_result: HitResult, + ) { + let factor = self.inner.unrolled_recursion( + &mut self.attrs, + add_score_combo_multiplier, + is_bonus, + increase_combo, + score_increase, + bonus_result, + ); + + if let Some(factor) = factor { + self.combo_score_factors.push(factor); + } + } +} + +const fn round_time(time: f64) -> i32 { + time.round_ties_even() as i32 +} diff --git a/src/osu/legacy_score_simulator/mod.rs b/src/osu/legacy_score_simulator/mod.rs new file mode 100644 index 00000000..0063ca75 --- /dev/null +++ b/src/osu/legacy_score_simulator/mod.rs @@ -0,0 +1,302 @@ +use rosu_map::section::general::GameMode; + +use crate::{ + Beatmap, + any::hit_result::HitResult, + model::beatmap::BeatmapAttributes, + osu::object::{NestedSliderObjectKind, OsuObject, OsuObjectKind}, + util::ruleset_ext::calculate_difficulty_peppy_stars, +}; + +pub mod gradual; + +pub struct OsuLegacyScoreSimulator<'a> { + osu_objects: &'a [OsuObject], + passed_objects: usize, + inner: LegacyScoreSimulatorInner, + score_multiplier: f64, +} + +impl<'a> OsuLegacyScoreSimulator<'a> { + pub fn new(osu_objects: &'a [OsuObject], map: &Beatmap, passed_objects: usize) -> Self { + // Note that no mods are being applied here. Apparently, this is how + // lazer wants to it /shrug + let map_attrs = map.attributes().build(); + let score_multiplier = Self::score_multiplier(map, &map_attrs, passed_objects); + + Self { + osu_objects, + passed_objects, + inner: LegacyScoreSimulatorInner::default(), + score_multiplier: f64::from(score_multiplier), + } + } + + pub fn score_multiplier( + map: &Beatmap, + map_attrs: &BeatmapAttributes, + passed_objects: usize, + ) -> i32 { + let hit_objects = &map.hit_objects; + + let object_count = hit_objects.iter().take(passed_objects).count() as i32; + + let mut drain_len = 0; + + let first = hit_objects.first(); + + let last = hit_objects + .get(passed_objects.saturating_sub(1)) + .or(hit_objects.last()); + + if let Some((first, last)) = first.zip(last) { + let break_len: i32 = map + .breaks + .iter() + // Note that this does not account for notes appearing during + // breaks or overlapping breaks. + .take_while(|b| b.end_time < last.start_time) + .map(|b| { + b.end_time.round_ties_even() as i32 - b.start_time.round_ties_even() as i32 + }) + .sum(); + + let full_len = last.start_time.round_ties_even() as i32 + - first.start_time.round_ties_even() as i32; + + drain_len = (full_len - break_len) / 1000; + } + + calculate_difficulty_peppy_stars(map_attrs, object_count, drain_len) + } + + pub fn simulate(&mut self) -> LegacyScoreAttributes { + let mut attrs = LegacyScoreAttributes::default(); + + for obj in self.osu_objects.iter().take(self.passed_objects) { + self.simulate_hit(obj, &mut attrs); + } + + self.inner.finalize(&mut attrs); + + attrs + } + + fn simulate_hit(&mut self, hit_object: &OsuObject, attrs: &mut LegacyScoreAttributes) { + const DEFAULT_BONUS_RESULT: HitResult = HitResult::None; + + match hit_object.kind { + OsuObjectKind::Circle => { + self.unrolled_recursion( + attrs, + AddScoreComboMultiplier::Yes, + IsBonus::default(), + IncreaseCombo::default(), + 300, + DEFAULT_BONUS_RESULT, + ); + } + OsuObjectKind::Slider(ref slider) => { + // Slider head + self.unrolled_recursion( + attrs, + AddScoreComboMultiplier::default(), + IsBonus::default(), + IncreaseCombo::default(), + 30, + DEFAULT_BONUS_RESULT, + ); + + for nested in slider.nested_objects.iter() { + match nested.kind { + NestedSliderObjectKind::Repeat | NestedSliderObjectKind::Tail => { + self.unrolled_recursion( + attrs, + AddScoreComboMultiplier::default(), + IsBonus::default(), + IncreaseCombo::default(), + 30, + DEFAULT_BONUS_RESULT, + ); + } + NestedSliderObjectKind::Tick => { + self.unrolled_recursion( + attrs, + AddScoreComboMultiplier::default(), + IsBonus::default(), + IncreaseCombo::default(), + 10, + DEFAULT_BONUS_RESULT, + ); + } + } + } + + self.unrolled_recursion( + attrs, + AddScoreComboMultiplier::Yes, + IsBonus::default(), + IncreaseCombo::No, + 300, + DEFAULT_BONUS_RESULT, + ); + } + OsuObjectKind::Spinner(ref spinner) => { + // * The spinner object applies a lenience because gameplay mechanics differ from osu-stable. + // * We'll redo the calculations to match osu-stable here... + const MAXIMUM_ROTATIONS_PER_SECOND: f64 = 477.0 / 60.0; + + // * Normally, this value depends on the final overall difficulty. For simplicity, we'll only consider the worst case that maximises bonus score. + // * As we're primarily concerned with computing the maximum theoretical final score, + // * this will have the final effect of slightly underestimating bonus score achieved on stable when converting from score V1. + const MINIMUM_ROTATIONS_PER_SECOND: f64 = 3.0; + + let seconds_duration = spinner.duration / 1000.0; + + // * The total amount of half spins possible for the entire spinner. + let total_half_spins_possible = + (seconds_duration * MAXIMUM_ROTATIONS_PER_SECOND * 2.0) as i32; + // * The amount of half spins that are required to successfully complete the spinner (i.e. get a 300). + let half_spins_required_for_completion = + (seconds_duration * MINIMUM_ROTATIONS_PER_SECOND) as i32; + // * To be able to receive bonus points, the spinner must be rotated another 1.5 times. + let half_spins_required_before_bonus = half_spins_required_for_completion + 3; + + for i in 0..=total_half_spins_possible { + if i > half_spins_required_before_bonus + && (i - half_spins_required_before_bonus) % 2 == 0 + { + self.unrolled_recursion( + attrs, + AddScoreComboMultiplier::default(), + IsBonus::Yes, + IncreaseCombo::No, + 1100, + HitResult::LargeBonus, + ); + } else if i > 1 && i % 2 == 0 { + self.unrolled_recursion( + attrs, + AddScoreComboMultiplier::default(), + IsBonus::Yes, + IncreaseCombo::No, + 100, + HitResult::SmallBonus, + ); + } + } + + self.unrolled_recursion( + attrs, + AddScoreComboMultiplier::Yes, + IsBonus::default(), + IncreaseCombo::default(), + 300, + DEFAULT_BONUS_RESULT, + ); + } + } + } + + fn unrolled_recursion( + &mut self, + attrs: &mut LegacyScoreAttributes, + add_score_combo_multiplier: AddScoreComboMultiplier, + is_bonus: IsBonus, + increase_combo: IncreaseCombo, + score_increase: i32, + bonus_result: HitResult, + ) { + let factor = self.inner.unrolled_recursion( + attrs, + add_score_combo_multiplier, + is_bonus, + increase_combo, + score_increase, + bonus_result, + ); + + if let Some(factor) = factor { + attrs.combo_score += i64::from((factor * self.score_multiplier) as i32); + } + } +} + +#[derive(Default)] +struct LegacyScoreSimulatorInner { + legacy_bonus_score: i32, + standardised_bonus_score: i32, + combo: u32, +} + +#[derive(Copy, Clone, Default)] +enum AddScoreComboMultiplier { + Yes, + #[default] + No, +} + +#[derive(Copy, Clone, Default)] +enum IsBonus { + Yes, + #[default] + No, +} + +#[derive(Copy, Clone, Default)] +enum IncreaseCombo { + #[default] + Yes, + No, +} + +impl LegacyScoreSimulatorInner { + fn unrolled_recursion( + &mut self, + attrs: &mut LegacyScoreAttributes, + add_score_combo_multiplier: AddScoreComboMultiplier, + is_bonus: IsBonus, + increase_combo: IncreaseCombo, + score_increase: i32, + bonus_result: HitResult, + ) -> Option { + let mut factor = None; + + if let AddScoreComboMultiplier::Yes = add_score_combo_multiplier { + factor = Some(f64::from(self.combo.saturating_sub(1)) * f64::from(score_increase / 25)); + } + + if let IsBonus::Yes = is_bonus { + self.legacy_bonus_score += score_increase; + self.standardised_bonus_score += bonus_result.base_score(GameMode::Osu); + } else { + attrs.accuracy_score += score_increase; + } + + if let IncreaseCombo::Yes = increase_combo { + self.combo += 1; + } + + factor + } + + fn finalize(&self, attrs: &mut LegacyScoreAttributes) { + attrs.bonus_score_ratio = if self.legacy_bonus_score == 0 { + 0.0 + } else { + f64::from(self.standardised_bonus_score) / f64::from(self.legacy_bonus_score) + }; + + attrs.bonus_score = self.legacy_bonus_score; + attrs.max_combo = self.combo; + } +} + +#[derive(Clone, Default)] +pub struct LegacyScoreAttributes { + pub accuracy_score: i32, + pub combo_score: i64, + pub bonus_score_ratio: f64, + pub bonus_score: i32, + pub max_combo: u32, +} diff --git a/src/osu/mod.rs b/src/osu/mod.rs index 52a24cc1..5fce2a8a 100644 --- a/src/osu/mod.rs +++ b/src/osu/mod.rs @@ -19,10 +19,13 @@ pub use self::{ mod attributes; mod convert; mod difficulty; +mod legacy_score_miss_calc; +mod legacy_score_simulator; mod object; mod performance; mod score_state; mod strains; +mod utils; const PLAYFIELD_BASE_SIZE: Pos = Pos::new(512.0, 384.0); diff --git a/src/osu/object.rs b/src/osu/object.rs index 18468cb6..0ed98ee8 100644 --- a/src/osu/object.rs +++ b/src/osu/object.rs @@ -1,9 +1,7 @@ -use std::borrow::Cow; - use rosu_map::{ section::{ general::GameMode, - hit_objects::{CurveBuffers, SliderEvent, SliderEventType, SliderEventsIter}, + hit_objects::{Curve, CurveBuffers, SliderEvent, SliderEventType, SliderEventsIter}, }, util::Pos, }; @@ -13,6 +11,7 @@ use crate::{ model::{ control_point::{DifficultyPoint, TimingPoint}, hit_object::{HitObject, HitObjectKind, HoldNote, Slider, Spinner}, + mods::Reflection, }, util::{get_precision_adjusted_beat_len, sort}, }; @@ -36,14 +35,15 @@ impl OsuObject { pub fn new( h: &HitObject, map: &Beatmap, + reflection: Reflection, curve_bufs: &mut CurveBuffers, ticks_buf: &mut Vec, ) -> Self { let kind = match h.kind { HitObjectKind::Circle => OsuObjectKind::Circle, - HitObjectKind::Slider(ref slider) => { - OsuObjectKind::Slider(OsuSlider::new(h, slider, map, curve_bufs, ticks_buf)) - } + HitObjectKind::Slider(ref slider) => OsuObjectKind::Slider(OsuSlider::new( + h, slider, map, reflection, curve_bufs, ticks_buf, + )), HitObjectKind::Spinner(spinner) => OsuObjectKind::Spinner(spinner), HitObjectKind::Hold(HoldNote { duration }) => { OsuObjectKind::Spinner(Spinner { duration }) @@ -65,17 +65,7 @@ impl OsuObject { } reflect_y(&mut self.pos.y); - - if let OsuObjectKind::Slider(ref mut slider) = self.kind { - // Requires `stack_offset` so we can't add `h.pos` just yet - slider.lazy_end_pos.y = -slider.lazy_end_pos.y; - - for nested in slider.nested_objects.iter_mut() { - let mut nested_pos = self.pos; // already reflected at this point - nested_pos += Pos::new(nested.pos.x, -nested.pos.y); - nested.pos = nested_pos; - } - } + self.finalize_nested(); } pub fn reflect_horizontally(&mut self) { @@ -84,17 +74,7 @@ impl OsuObject { } reflect_x(&mut self.pos.x); - - if let OsuObjectKind::Slider(ref mut slider) = self.kind { - // Requires `stack_offset` so we can't add `h.pos` just yet - slider.lazy_end_pos.x = -slider.lazy_end_pos.x; - - for nested in slider.nested_objects.iter_mut() { - let mut nested_pos = self.pos; // already reflected at this point - nested_pos += Pos::new(-nested.pos.x, nested.pos.y); - nested.pos = nested_pos; - } - } + self.finalize_nested(); } pub fn reflect_both_axes(&mut self) { @@ -104,24 +84,13 @@ impl OsuObject { } reflect(&mut self.pos); - - if let OsuObjectKind::Slider(ref mut slider) = self.kind { - // Requires `stack_offset` so we can't add `h.pos` just yet - slider.lazy_end_pos.x = -slider.lazy_end_pos.x; - slider.lazy_end_pos.y = -slider.lazy_end_pos.y; - - for nested in slider.nested_objects.iter_mut() { - let mut nested_pos = self.pos; // already reflected at this point - nested_pos += Pos::new(-nested.pos.x, -nested.pos.y); - nested.pos = nested_pos; - } - } + self.finalize_nested(); } pub fn finalize_nested(&mut self) { if let OsuObjectKind::Slider(ref mut slider) = self.kind { for nested in slider.nested_objects.iter_mut() { - nested.pos += self.pos; + nested.pos = self.pos + nested.pos; } } } @@ -157,13 +126,6 @@ impl OsuObject { self.end_pos() + self.stack_offset } - pub const fn lazy_travel_time(&self) -> f64 { - match self.kind { - OsuObjectKind::Circle | OsuObjectKind::Spinner(_) => 0.0, - OsuObjectKind::Slider(ref slider) => slider.lazy_travel_time, - } - } - pub const fn is_circle(&self) -> bool { matches!(self.kind, OsuObjectKind::Circle) } @@ -185,9 +147,8 @@ pub enum OsuObjectKind { pub struct OsuSlider { pub end_time: f64, - pub lazy_end_pos: Pos, - pub lazy_travel_dist: f32, - pub lazy_travel_time: f64, + pub span_count: f64, + pub path: Curve, pub nested_objects: Vec, } @@ -196,6 +157,7 @@ impl OsuSlider { h: &HitObject, slider: &Slider, map: &Beatmap, + reflection: Reflection, curve_bufs: &mut CurveBuffers, ticks_buf: &mut Vec, ) -> Self { @@ -215,7 +177,7 @@ impl OsuSlider { |point| (point.slider_velocity, point.generate_ticks), ); - let path = slider.curve(GameMode::Osu, curve_bufs); + let path = slider.curve(GameMode::Osu, reflection, curve_bufs); let span_count = slider.span_count() as f64; @@ -293,65 +255,25 @@ impl OsuSlider { a.start_time.total_cmp(&b.start_time) }); - let mut nested = Cow::Borrowed(nested_objects.as_slice()); - let lazy_travel_time = OsuSlider::lazy_travel_time(start_time, duration, &mut nested); - - let mut end_time_min = lazy_travel_time / span_duration; - - if end_time_min % 2.0 >= 1.0 { - end_time_min = 1.0 - end_time_min % 1.0; - } else { - end_time_min %= 1.0; - } - - let lazy_end_pos = path.position_at(end_time_min); - Self { end_time, - lazy_end_pos, - lazy_travel_dist: 0.0, - lazy_travel_time, + span_count, + path, nested_objects, } } - pub fn lazy_travel_time( - start_time: f64, - duration: f64, - nested_objects: &mut Cow<'_, [NestedSliderObject]>, - ) -> f64 { - const TAIL_LENIENCY: f64 = -36.0; - - let mut tracking_end_time = - (start_time + duration + TAIL_LENIENCY).max(start_time + duration / 2.0); - - let last_real_tick = nested_objects + pub fn repeat_count(&self) -> usize { + self.nested_objects .iter() - .enumerate() - .rfind(|(_, nested)| nested.is_tick()); - - if let Some((idx, last_real_tick)) = - last_real_tick.filter(|(_, tick)| tick.start_time > tracking_end_time) - { - tracking_end_time = last_real_tick.start_time; - - // * When the last tick falls after the tracking end time, we need to re-sort the nested objects - // * based on time. This creates a somewhat weird ordering which is counter to how a user would - // * understand the slider, but allows a zero-diff with known diffcalc output. - // * - // * To reiterate, this is definitely not correct from a difficulty calculation perspective - // * and should be revisited at a later date (likely by replacing this whole code with the commented - // * version above). - nested_objects.to_mut()[idx..].rotate_left(1); - } - - tracking_end_time - start_time + .filter(|nested| matches!(nested.kind, NestedSliderObjectKind::Repeat)) + .count() } - pub fn repeat_count(&self) -> usize { + pub fn tick_count(&self) -> usize { self.nested_objects .iter() - .filter(|nested| matches!(nested.kind, NestedSliderObjectKind::Repeat)) + .filter(|nested| matches!(nested.kind, NestedSliderObjectKind::Tick)) .count() } diff --git a/src/osu/performance/calculator.rs b/src/osu/performance/calculator.rs index 83832bb1..e0d05946 100644 --- a/src/osu/performance/calculator.rs +++ b/src/osu/performance/calculator.rs @@ -1,31 +1,29 @@ -use std::f64::consts::PI; +use std::{cmp, f64::consts::PI}; use crate::{ GameMods, osu::{ - OsuDifficultyAttributes, OsuHitResults, OsuPerformanceAttributes, OsuScoreState, - difficulty::skills::{ - aim::Aim, flashlight::Flashlight, speed::Speed, strain::OsuStrainSkill, + OsuDifficultyAttributes, OsuPerformanceAttributes, OsuScoreState, + difficulty::{ + rating::OsuRatingCalculator, + skills::{aim::Aim, flashlight::Flashlight, speed::Speed, strain::OsuStrainSkill}, }, + legacy_score_miss_calc::OsuLegacyScoreMissCalculator, }, util::{ - difficulty::reverse_lerp, + difficulty::{erf, erf_inv, logistic, reverse_lerp, smoothstep}, float_ext::FloatExt, - special_functions::{erf, erf_inv}, }, }; -use super::{n_large_tick_miss, n_slider_ends_dropped, total_imperfect_hits}; - // * This is being adjusted to keep the final pp value scaled around what it used to be when changing things. -pub const PERFORMANCE_BASE_MULTIPLIER: f64 = 1.15; +pub const PERFORMANCE_BASE_MULTIPLIER: f64 = 1.14; pub(super) struct OsuPerformanceCalculator<'mods> { attrs: OsuDifficultyAttributes, mods: &'mods GameMods, acc: f64, state: OsuScoreState, - effective_miss_count: f64, using_classic_slider_acc: bool, } @@ -35,7 +33,6 @@ impl<'a> OsuPerformanceCalculator<'a> { mods: &'a GameMods, acc: f64, state: OsuScoreState, - effective_miss_count: f64, using_classic_slider_acc: bool, ) -> Self { Self { @@ -43,14 +40,13 @@ impl<'a> OsuPerformanceCalculator<'a> { mods, acc, state, - effective_miss_count, using_classic_slider_acc, } } } impl OsuPerformanceCalculator<'_> { - pub fn calculate(mut self) -> OsuPerformanceAttributes { + pub fn calculate(self) -> OsuPerformanceAttributes { let total_hits = self.state.hitresults.total_hits(); if total_hits == 0 { @@ -60,12 +56,35 @@ impl OsuPerformanceCalculator<'_> { }; } + let acc = self.acc; + let state = &self.state; + let attrs = &self.attrs; + let mods = self.mods; + let using_classic_slider_acc = self.using_classic_slider_acc; + + let combo_based_estimated_miss_count = self.calculate_combo_based_estimated_miss_count(); + let mut score_based_estimated_miss_count = None; + + let mut effective_miss_count = if using_classic_slider_acc + && state.legacy_total_score.is_some() + { + let legacy_score_miss_calc = OsuLegacyScoreMissCalculator::new(state, acc, mods, attrs); + + *score_based_estimated_miss_count.insert(legacy_score_miss_calc.calculate()) + } else { + // * Use combo-based miss count if this isn't a legacy score + combo_based_estimated_miss_count + }; + + effective_miss_count = effective_miss_count.max(f64::from(state.hitresults.misses)); + effective_miss_count = effective_miss_count.min(f64::from(state.hitresults.total_hits())); + let total_hits = f64::from(total_hits); let mut multiplier = PERFORMANCE_BASE_MULTIPLIER; if self.mods.nf() { - multiplier *= (1.0 - 0.02 * self.effective_miss_count).max(0.9); + multiplier *= (1.0 - 0.02 * effective_miss_count).max(0.9); } if self.mods.so() && total_hits > 0.0 { @@ -75,12 +94,12 @@ impl OsuPerformanceCalculator<'_> { if self.mods.rx() { let od = self.attrs.od(); - // * https://www.desmos.com/calculator/bc9eybdthb + // * https://www.desmos.com/calculator/vspzsop6td // * we use OD13.3 as maximum since it's the value at which great hitwidow becomes 0 // * this is well beyond currently maximum achievable OD which is 12.17 (DTx2 + DA with OD11) let (n100_mult, n50_mult) = if od > 0.0 { ( - (1.0 - (od / 13.33).powf(1.8)).max(0.0), + 0.75 * (1.0 - od / 13.33).max(0.0), (1.0 - (od / 13.33).powf(5.0)).max(0.0), ) } else { @@ -89,7 +108,7 @@ impl OsuPerformanceCalculator<'_> { // * As we're adding Oks and Mehs to an approximated number of combo breaks the result can be // * higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it. - self.effective_miss_count = (self.effective_miss_count + effective_miss_count = (effective_miss_count + f64::from(self.state.hitresults.n100) * n100_mult + f64::from(self.state.hitresults.n50) * n50_mult) .min(total_hits); @@ -97,10 +116,18 @@ impl OsuPerformanceCalculator<'_> { let speed_deviation = self.calculate_speed_deviation(); - let aim_value = self.compute_aim_value(); - let speed_value = self.compute_speed_value(speed_deviation); + let mut aim_estimated_slider_breaks = 0.0; + let mut speed_estimated_slider_breaks = 0.0; + + let aim_value = + self.compute_aim_value(effective_miss_count, &mut aim_estimated_slider_breaks); + let speed_value = self.compute_speed_value( + speed_deviation, + effective_miss_count, + &mut speed_estimated_slider_breaks, + ); let acc_value = self.compute_accuracy_value(); - let flashlight_value = self.compute_flashlight_value(); + let flashlight_value = self.compute_flashlight_value(effective_miss_count); let pp = (aim_value.powf(1.1) + speed_value.powf(1.1) @@ -116,12 +143,20 @@ impl OsuPerformanceCalculator<'_> { pp_flashlight: flashlight_value, pp_speed: speed_value, pp, - effective_miss_count: self.effective_miss_count, + effective_miss_count, speed_deviation, + combo_based_estimated_miss_count, + score_based_estimated_miss_count, + aim_estimated_slider_breaks, + speed_estimated_slider_breaks, } } - fn compute_aim_value(&self) -> f64 { + fn compute_aim_value( + &self, + effective_miss_count: f64, + aim_estimated_slider_breaks: &mut f64, + ) -> f64 { if self.mods.ap() { return 0.0; } @@ -132,7 +167,7 @@ impl OsuPerformanceCalculator<'_> { let estimate_improperly_followed_difficult_sliders = if self.using_classic_slider_acc { // * When the score is considered classic (regardless if it was made on old client or not) // * we consider all missing combo to be dropped difficult sliders - let maximum_possible_dropped_sliders = total_imperfect_hits(&self.state.hitresults); + let maximum_possible_dropped_sliders = self.total_imperfect_hits(); f64::clamp( f64::min( @@ -147,10 +182,7 @@ impl OsuPerformanceCalculator<'_> { // * We however aren't adding misses here because missing slider heads has a harsh penalty // * by itself and doesn't mean that the rest of the slider wasn't followed properly f64::clamp( - f64::from( - n_slider_ends_dropped(&self.attrs, &self.state) - + n_large_tick_miss(&self.attrs, &self.state), - ), + f64::from(self.n_slider_ends_dropped() + self.n_large_tick_miss()), 0.0, self.attrs.aim_difficult_slider_count, ) @@ -176,45 +208,49 @@ impl OsuPerformanceCalculator<'_> { aim_value *= len_bonus; - if self.effective_miss_count > 0.0 { + if effective_miss_count > 0.0 { + *aim_estimated_slider_breaks = self.calculate_estimated_slider_breaks( + self.attrs.aim_top_weighted_slider_factor, + effective_miss_count, + ); + + let relevant_miss_count = (effective_miss_count + *aim_estimated_slider_breaks) + .min(self.total_imperfect_hits() + f64::from(self.n_large_tick_miss())); + aim_value *= Self::calculate_miss_penalty( - self.effective_miss_count, + relevant_miss_count, self.attrs.aim_difficult_strain_count, ); } - let ar_factor = if self.mods.rx() { - 0.0 - } else if self.attrs.ar > 10.33 { - 0.3 * (self.attrs.ar - 10.33) - } else if self.attrs.ar < 8.0 { - 0.05 * (8.0 - self.attrs.ar) - } else { - 0.0 - }; - - // * Buff for longer maps with high AR. - aim_value *= 1.0 + ar_factor * len_bonus; - + // * TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen. if self.mods.bl() { aim_value *= 1.3 + (total_hits - * (0.0016 / (1.0 + 2.0 * self.effective_miss_count)) + * (0.0016 / (1.0 + 2.0 * effective_miss_count)) * self.acc.powf(16.0)) * (1.0 - 0.003 * self.attrs.hp * self.attrs.hp); - } else if self.mods.hd() || self.mods.tc() { - // * We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. - aim_value *= 1.0 + 0.04 * (12.0 - self.attrs.ar); + } else if self.mods.tc() { + aim_value *= 1.0 + + OsuRatingCalculator::calculate_visibility_bonus( + self.mods, + self.attrs.ar, + Some(self.attrs.slider_factor), + None, + ); } aim_value *= self.acc; - // * It is important to consider accuracy difficulty when scaling with accuracy. - aim_value *= 0.98 + f64::powf(f64::max(0.0, self.attrs.od()), 2.0) / 2500.0; aim_value } - fn compute_speed_value(&self, speed_deviation: Option) -> f64 { + fn compute_speed_value( + &self, + speed_deviation: Option, + effective_miss_count: f64, + speed_estimated_slider_breaks: &mut f64, + ) -> f64 { let Some(speed_deviation) = speed_deviation.filter(|_| !self.mods.rx()) else { return 0.0; }; @@ -229,32 +265,34 @@ impl OsuPerformanceCalculator<'_> { speed_value *= len_bonus; - if self.effective_miss_count > 0.0 { + if effective_miss_count > 0.0 { + *speed_estimated_slider_breaks = self.calculate_estimated_slider_breaks( + self.attrs.speed_top_weighted_slider_factor, + effective_miss_count, + ); + + let relevant_miss_count = (effective_miss_count + *speed_estimated_slider_breaks) + .min(self.total_imperfect_hits() + f64::from(self.n_large_tick_miss())); + speed_value *= Self::calculate_miss_penalty( - self.effective_miss_count, + relevant_miss_count, self.attrs.speed_difficult_strain_count, ); } - let ar_factor = if self.mods.ap() { - 0.0 - } else if self.attrs.ar > 10.33 { - 0.3 * (self.attrs.ar - 10.33) - } else { - 0.0 - }; - - // * Buff for longer maps with high AR. - speed_value *= 1.0 + ar_factor * len_bonus; - + // * TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen. if self.mods.bl() { // * Increasing the speed value by object count for Blinds isn't // * ideal, so the minimum buff is given. speed_value *= 1.12; - } else if self.mods.hd() || self.mods.tc() { - // * We want to give more reward for lower AR when it comes to aim and HD. - // * This nerfs high AR and buffs lower AR. - speed_value *= 1.0 + 0.04 * (12.0 - self.attrs.ar); + } else if self.mods.tc() { + speed_value *= 1.0 + + OsuRatingCalculator::calculate_visibility_bonus( + self.mods, + self.attrs.ar, + None, + None, + ); } let speed_high_deviation_mult = self.calculate_speed_high_deviation_nerf(speed_deviation); @@ -281,8 +319,7 @@ impl OsuPerformanceCalculator<'_> { let od = self.attrs.od(); // * Scale the speed value with accuracy and OD. - speed_value *= (0.95 + f64::powf(f64::max(0.0, od), 2.0) / 750.0) - * f64::powf((self.acc + relevant_acc) / 2.0, (14.5 - od) / 2.0); + speed_value *= f64::powf((self.acc + relevant_acc) / 2.0, (14.5 - od) / 2.0); speed_value } @@ -305,7 +342,7 @@ impl OsuPerformanceCalculator<'_> { let mut better_acc_percentage = if amount_hit_objects_with_acc > 0 { f64::from( (hitresults.n300 as i32 - - (i32::max( + - (cmp::max( hitresults.total_hits() as i32 - amount_hit_objects_with_acc as i32, 0, ))) @@ -337,7 +374,8 @@ impl OsuPerformanceCalculator<'_> { if self.mods.bl() { acc_value *= 1.14; } else if self.mods.hd() || self.mods.tc() { - acc_value *= 1.08; + // * Decrease bonus for AR > 10 + acc_value *= 1.0 + 0.08 * reverse_lerp(self.attrs.ar, 11.5, 10.0); } if self.mods.fl() { @@ -347,7 +385,7 @@ impl OsuPerformanceCalculator<'_> { acc_value } - fn compute_flashlight_value(&self) -> f64 { + fn compute_flashlight_value(&self, effective_miss_count: f64) -> f64 { if !self.mods.fl() { return 0.0; } @@ -357,31 +395,109 @@ impl OsuPerformanceCalculator<'_> { let total_hits = self.total_hits(); // * Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses. - if self.effective_miss_count > 0.0 { + if effective_miss_count > 0.0 { flashlight_value *= 0.97 - * (1.0 - (self.effective_miss_count / total_hits).powf(0.775)) - .powf(self.effective_miss_count.powf(0.875)); + * (1.0 - (effective_miss_count / total_hits).powf(0.775)) + .powf(effective_miss_count.powf(0.875)); } flashlight_value *= self.get_combo_scaling_factor(); - // * Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius. - flashlight_value *= 0.7 - + 0.1 * (total_hits / 200.0).min(1.0) - + f64::from(u8::from(total_hits > 200.0)) - * 0.2 - * ((total_hits - 200.0) / 200.0).min(1.0); - // * Scale the flashlight value with accuracy _slightly_. flashlight_value *= 0.5 + self.acc / 2.0; - // * It is important to also consider accuracy difficulty when doing that. - flashlight_value *= 0.98 + f64::powf(f64::max(0.0, self.attrs.od()), 2.0) / 2500.0; flashlight_value } + fn calculate_combo_based_estimated_miss_count(&self) -> f64 { + let Self { + state, + attrs, + using_classic_slider_acc, + .. + } = self; + + if attrs.n_sliders == 0 { + return f64::from(state.hitresults.misses); + } + + let mut miss_count = f64::from(state.hitresults.misses); + + if *using_classic_slider_acc { + // * Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it + // * In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map + let full_combo_threshold = + f64::from(attrs.max_combo) - 0.1 * f64::from(attrs.n_sliders); + + if f64::from(state.max_combo) < full_combo_threshold { + miss_count = full_combo_threshold / f64::from(state.max_combo).max(1.0); + } + + // * In classic scores there can't be more misses than a sum of all non-perfect judgements + miss_count = miss_count.min(self.total_imperfect_hits()); + + // * Every slider has *at least* 2 combo attributed in classic mechanics. + // * If they broke on a slider with a tick, then this still works since they would have lost at least 2 combo (the tick and the end) + // * Using this as a max means a score that loses 1 combo on a map can't possibly have been a slider break. + // * It must have been a slider end. + let max_possible_slider_breaks = cmp::min( + attrs.n_sliders, + (attrs.max_combo.saturating_sub(state.max_combo)) / 2, + ); + + let slider_breaks = miss_count - f64::from(state.hitresults.misses); + + if slider_breaks > f64::from(max_possible_slider_breaks) { + miss_count = f64::from(state.hitresults.misses + max_possible_slider_breaks); + } + } else { + let full_combo_threshold = f64::from(attrs.max_combo - self.n_slider_ends_dropped()); + + if f64::from(state.max_combo) < full_combo_threshold { + miss_count = full_combo_threshold / f64::from(state.max_combo).max(1.0); + } + + // * Combine regular misses with tick misses since tick misses break combo as well + miss_count = miss_count.min(f64::from( + self.n_large_tick_miss() + state.hitresults.misses, + )); + } + + miss_count + } + + fn calculate_estimated_slider_breaks( + &self, + top_weighted_slider_factor: f64, + effective_miss_count: f64, + ) -> f64 { + let Self { + attrs, + state, + using_classic_slider_acc, + .. + } = self; + + if !using_classic_slider_acc || state.hitresults.n100 == 0 { + return 0.0; + } + + let missed_combo_percent = 1.0 - f64::from(state.max_combo) / f64::from(attrs.max_combo); + let mut estimated_slider_breaks = (effective_miss_count * top_weighted_slider_factor) + .min(f64::from(state.hitresults.n100)); + + // * Scores with more Oks are more likely to have slider breaks. + let ok_adjustment = ((f64::from(state.hitresults.n100) - estimated_slider_breaks) + 0.5) + / f64::from(state.hitresults.n100); + + // * There is a low probability of extra slider breaks on effective miss counts close to 1, as score based calculations are good at indicating if only a single break occurred. + estimated_slider_breaks *= smoothstep(effective_miss_count, 1.0, 2.0); + + estimated_slider_breaks * ok_adjustment * logistic(missed_combo_percent, 0.33, 15.0, None) + } + fn calculate_speed_deviation(&self) -> Option { - if total_successful_hits(&self.state.hitresults) == 0 { + if self.total_successful_hits() == 0 { return None; } @@ -407,12 +523,7 @@ impl OsuPerformanceCalculator<'_> { speed_note_count - relevant_count_miss - relevant_count_meh - relevant_count_ok, ); - self.calculate_deviation( - relevant_count_great, - relevant_count_ok, - relevant_count_meh, - relevant_count_miss, - ) + self.calculate_deviation(relevant_count_great, relevant_count_ok, relevant_count_meh) } fn calculate_deviation( @@ -420,61 +531,56 @@ impl OsuPerformanceCalculator<'_> { relevant_count_great: f64, relevant_count_ok: f64, relevant_count_meh: f64, - relevant_count_miss: f64, ) -> Option { if relevant_count_great + relevant_count_ok + relevant_count_meh <= 0.0 { return None; } - let object_count = - relevant_count_great + relevant_count_ok + relevant_count_meh + relevant_count_miss; - - // * The probability that a player hits a circle is unknown, but we can estimate it to be - // * the number of greats on circles divided by the number of circles, and then add one - // * to the number of circles as a bias correction. - - let n = f64::max(1.0, object_count - relevant_count_miss - relevant_count_meh); - - #[allow(clippy::items_after_statements, clippy::unreadable_literal)] - const Z: f64 = 2.32634787404; // * 99% critical value for the normal distribution (one-tailed). - - // * Proportion of greats hit on circles, ignoring misses and 50s. + // * The sample proportion of successful hits. + let n = f64::max(1.0, relevant_count_great + relevant_count_ok); let p = relevant_count_great / n; - // * We can be 99% confident that p is at least this value. - let p_lower_bound = (n * p + Z * Z / 2.0) / (n + Z * Z) - - Z / (n + Z * Z) * f64::sqrt(n * p * (1.0 - p) + Z * Z / 4.0); + #[expect( + clippy::items_after_statements, + clippy::unreadable_literal, + reason = "staying in-sync with lazer" + )] + // * 99% critical value for the normal distribution (one-tailed). + const Z: f64 = 2.32634787404; + + // * We can be 99% confident that the population proportion is at least this value. + let p_lower_bound = ((n * p + Z * Z / 2.0) / (n + Z * Z) + - Z / (n + Z * Z) * f64::sqrt(n * p * (1.0 - p) + Z * Z / 4.0)) + .min(p); let great_hit_window: f64 = self.attrs.great_hit_window; let ok_hit_window: f64 = self.attrs.ok_hit_window; let meh_hit_window: f64 = self.attrs.meh_hit_window; - // * Compute the deviation assuming greats and oks are normally distributed, and mehs are uniformly distributed. - // * Begin with greats and oks first. Ignoring mehs, we can be 99% confident that the deviation is not higher than: - let mut deviation = great_hit_window / (f64::sqrt(2.0) * erf_inv(p_lower_bound)); - - let random_value = f64::sqrt(2.0 / PI) - * ok_hit_window - * f64::exp(-0.5 * f64::powf(ok_hit_window / deviation, 2.0)) - / (deviation * erf(ok_hit_window / (f64::sqrt(2.0) * deviation))); + let mut deviation; - deviation *= f64::sqrt(1.0 - random_value); + if p_lower_bound > 0.01 { + deviation = great_hit_window / (f64::sqrt(2.0) * erf_inv(p_lower_bound)); - // * Value deviation approach as greatCount approaches 0 - let limit_value = ok_hit_window / f64::sqrt(3.0); + // * Subtract the deviation provided by tails that land outside the ok hit window from the deviation computed above. + // * This is equivalent to calculating the deviation of a normal distribution truncated at +-okHitWindow. + let ok_hit_window_tail_amount = f64::sqrt(2.0 / PI) + * ok_hit_window + * f64::exp(-0.5 * f64::powf(ok_hit_window / deviation, 2.0)) + / (deviation * erf(ok_hit_window / (f64::sqrt(2.0) * deviation))); - // * If precision is not enough to compute true deviation - use limit value - if p_lower_bound == 0.0 || random_value >= 1.0 || deviation > limit_value { - deviation = limit_value; + deviation *= f64::sqrt(1.0 - ok_hit_window_tail_amount); + } else { + // * A tested limit value for the case of a score only containing oks. + deviation = ok_hit_window / f64::sqrt(3.0); } - // * Then compute the variance for mehs. + // * Compute and add the variance for mehs, assuming that they are uniformly distributed. let meh_variance = (meh_hit_window * meh_hit_window + ok_hit_window * meh_hit_window + ok_hit_window * ok_hit_window) / 3.0; - // * Find the total deviation. let deviation = f64::sqrt( ((relevant_count_great + relevant_count_ok) * f64::powf(deviation, 2.0) + relevant_count_meh * meh_variance) @@ -495,7 +601,7 @@ impl OsuPerformanceCalculator<'_> { return 1.0; } - #[allow(clippy::items_after_statements)] + #[expect(clippy::items_after_statements, reason = "staying in-sync with lazer")] const SCALE: f64 = 50.0; let mut adjusted_speed_value = SCALE @@ -528,8 +634,31 @@ impl OsuPerformanceCalculator<'_> { const fn total_hits(&self) -> f64 { self.state.hitresults.total_hits() as f64 } -} -const fn total_successful_hits(state: &OsuHitResults) -> u32 { - state.n300 + state.n100 + state.n50 + const fn total_successful_hits(&self) -> u32 { + self.state.hitresults.n300 + self.state.hitresults.n100 + self.state.hitresults.n50 + } + + fn total_imperfect_hits(&self) -> f64 { + f64::from( + self.state.hitresults.n100 + self.state.hitresults.n50 + self.state.hitresults.misses, + ) + } + + const fn n_slider_ends_dropped(&self) -> u32 { + self.attrs.n_sliders - self.state.hitresults.slider_end_hits + } + + const fn n_large_tick_miss(&self) -> u32 { + // Lazer unconditionally uses the "large tick miss" hitresult and + // relies on this value being correctly provided by the caller / user. + // On stable, this value should always be 0. + // + // This is a best-effort workaround to achieve the same behavior. + if self.using_classic_slider_acc { + 0 + } else { + self.attrs.n_large_ticks - self.state.hitresults.large_tick_hits + } + } } diff --git a/src/osu/performance/gradual.rs b/src/osu/performance/gradual.rs index 26b65afd..46e57923 100644 --- a/src/osu/performance/gradual.rs +++ b/src/osu/performance/gradual.rs @@ -105,7 +105,7 @@ impl OsuGradualPerformance { /// /// Note that the count is zero-indexed, so `n=0` will process 1 object, /// `n=1` will process 2, and so on. - #[allow(clippy::missing_panics_doc)] + #[expect(clippy::missing_panics_doc, reason = "unreachable")] pub fn nth(&mut self, state: OsuScoreState, n: usize) -> Option { let performance = self .difficulty @@ -122,7 +122,7 @@ impl OsuGradualPerformance { } /// Returns the amount of remaining objects. - #[allow(clippy::len_without_is_empty)] + #[expect(clippy::len_without_is_empty, reason = "TODO")] pub fn len(&self) -> usize { self.difficulty.len() } diff --git a/src/osu/performance/hitresult_generator/closest.rs b/src/osu/performance/hitresult_generator/closest.rs index e0bc0053..624229b0 100644 --- a/src/osu/performance/hitresult_generator/closest.rs +++ b/src/osu/performance/hitresult_generator/closest.rs @@ -15,9 +15,7 @@ impl HitResultGenerator for Closest { return >::generate_hitresults(inspect); }; - let large_tick_hits = inspect.large_tick_hits.unwrap_or(0); - let small_tick_hits = inspect.small_tick_hits.unwrap_or(0); - let slider_end_hits = inspect.slider_end_hits.unwrap_or(0); + let (slider_end_hits, large_tick_hits, small_tick_hits) = inspect.tick_hits(); let total_hits = inspect.total_hits(); let misses = inspect.misses(); @@ -224,7 +222,7 @@ impl HitResultGenerator for Closest { for new300 in min300..=max300 { let (new300, new100, new50) = compute_n100_n50(new300); - let state = OsuHitResults { + let hitresults = OsuHitResults { large_tick_hits, small_tick_hits, slider_end_hits, @@ -234,7 +232,7 @@ impl HitResultGenerator for Closest { misses, }; - let dist = f64::abs(acc - state.accuracy(origin)); + let dist = f64::abs(acc - hitresults.accuracy(origin)); if dist < best_dist { best_dist = dist; @@ -617,12 +615,13 @@ mod tests { fn test_without_slider_acc_two_missing() { const N_CIRCLES: u32 = 70; const N_SLIDERS: u32 = 15; + const N_LARGE_TICKS: u32 = 10; let inspect = InspectOsuPerformance { attrs: &OsuDifficultyAttributes { n_circles: N_CIRCLES, n_sliders: N_SLIDERS, - n_large_ticks: 10, + n_large_ticks: N_LARGE_TICKS, ..Default::default() }, difficulty: &Difficulty::new().mods( @@ -638,8 +637,8 @@ mod tests { n100: None, n50: None, misses: Some(5), - large_tick_hits: Some(15), - small_tick_hits: Some(25), + large_tick_hits: Some(N_SLIDERS + N_LARGE_TICKS - 1), + small_tick_hits: Some(N_SLIDERS + 1), // should be clamped to N_SLIDERS slider_end_hits: None, combo: None, hitresult_priority: HitResultPriority::BestCase, @@ -652,6 +651,8 @@ mod tests { result.n300 + result.n100 + result.n50 + result.misses, N_CIRCLES + N_SLIDERS ); + assert_eq!(result.large_tick_hits, N_SLIDERS + N_LARGE_TICKS - 1); + assert_eq!(result.small_tick_hits, N_SLIDERS); verify_is_closest(&inspect, &result); } diff --git a/src/osu/performance/hitresult_generator/fast.rs b/src/osu/performance/hitresult_generator/fast.rs index df15f5d3..217fa4df 100644 --- a/src/osu/performance/hitresult_generator/fast.rs +++ b/src/osu/performance/hitresult_generator/fast.rs @@ -14,9 +14,7 @@ impl HitResultGenerator for Fast { return >::generate_hitresults(inspect); }; - let large_tick_hits = inspect.large_tick_hits.unwrap_or(0); - let small_tick_hits = inspect.small_tick_hits.unwrap_or(0); - let slider_end_hits = inspect.slider_end_hits.unwrap_or(0); + let (slider_end_hits, large_tick_hits, small_tick_hits) = inspect.tick_hits(); let total_hits = inspect.total_hits(); let misses = inspect.misses(); @@ -64,7 +62,8 @@ impl HitResultGenerator for Fast { let denominator = f64::from(6 * total_hits) + f64::from(tick_max) / 50.0; - let target_total = f64::round((acc * denominator - numerator).max(0.0)) as u32; + let target_total = + f64::round_ties_even((acc * denominator - numerator).max(0.0)) as u32; // Start by assuming every non-miss is an n50 // delta is how much we need to increase from the baseline (all n50s) @@ -316,8 +315,8 @@ mod tests { n100: None, n50: None, misses: Some(5), - large_tick_hits: Some(N_SLIDERS), - small_tick_hits: Some(N_SLIDERS + N_LARGE_TICKS), + large_tick_hits: Some(N_SLIDERS + N_LARGE_TICKS), + small_tick_hits: Some(N_SLIDERS), slider_end_hits: None, combo: None, hitresult_priority: HitResultPriority::BestCase, @@ -332,8 +331,8 @@ mod tests { N_CIRCLES + N_SLIDERS ); assert_eq!(result.misses, inspect.misses.unwrap().clone()); - assert_eq!(result.large_tick_hits, N_SLIDERS); - assert_eq!(result.small_tick_hits, N_SLIDERS + N_LARGE_TICKS); + assert_eq!(result.large_tick_hits, N_SLIDERS + N_LARGE_TICKS); + assert_eq!(result.small_tick_hits, N_SLIDERS); let actual_acc = result.accuracy(origin); assert!( diff --git a/src/osu/performance/mod.rs b/src/osu/performance/mod.rs index d5d789a3..29624ce0 100644 --- a/src/osu/performance/mod.rs +++ b/src/osu/performance/mod.rs @@ -15,14 +15,14 @@ use crate::{ catch::CatchPerformance, mania::ManiaPerformance, model::{mode::ConvertError, mods::GameMods}, - osu::score_state::OsuHitResults, + osu::OsuHitResults, taiko::TaikoPerformance, util::map_or_attrs::MapOrAttrs, }; use super::{ Osu, - attributes::{OsuDifficultyAttributes, OsuPerformanceAttributes}, + attributes::OsuPerformanceAttributes, score_state::{OsuScoreOrigin, OsuScoreState}, }; @@ -46,6 +46,7 @@ pub struct OsuPerformance<'map> { pub(crate) n100: Option, pub(crate) n50: Option, pub(crate) misses: Option, + pub(crate) legacy_total_score: Option, pub(crate) hitresult_priority: HitResultPriority, pub(crate) hitresult_generator: Option) -> OsuHitResults>, } @@ -67,6 +68,7 @@ impl PartialEq for OsuPerformance<'_> { misses, hitresult_priority, hitresult_generator: _, + legacy_total_score, } = self; map_or_attrs == &other.map_or_attrs @@ -81,6 +83,7 @@ impl PartialEq for OsuPerformance<'_> { && n50 == &other.n50 && misses == &other.misses && hitresult_priority == &other.hitresult_priority + && legacy_total_score == &other.legacy_total_score } } @@ -99,6 +102,8 @@ impl<'map> OsuPerformance<'map> { /// However, when passing previously calculated attributes, make sure they /// have been calculated for the same map and [`Difficulty`] settings. /// Otherwise, the final attributes will be incorrect. + /// + /// [`OsuDifficultyAttributes`]: crate::osu::OsuDifficultyAttributes pub fn new(map_or_attrs: impl IntoModePerformance<'map, Osu>) -> Self { map_or_attrs.into_performance() } @@ -131,8 +136,10 @@ impl<'map> OsuPerformance<'map> { /// [`mode_or_ignore`] instead. /// /// [`mode_or_ignore`]: Self::mode_or_ignore - // The `Ok`-variant is larger in size - #[allow(clippy::result_large_err)] + #[expect( + clippy::result_large_err, + reason = "Ok-variant is still larger in size" + )] pub fn try_mode(self, mode: GameMode) -> Result, Self> { match mode { GameMode::Osu => Ok(Performance::Osu(self)), @@ -215,6 +222,7 @@ impl<'map> OsuPerformance<'map> { misses: self.misses, hitresult_priority: self.hitresult_priority, hitresult_generator: Some(H::generate_hitresults), + legacy_total_score: self.legacy_total_score, } } @@ -386,14 +394,23 @@ impl<'map> OsuPerformance<'map> { self } + /// Specify the legacy total score. + pub const fn legacy_total_score(mut self, legacy_total_score: u32) -> Self { + self.legacy_total_score = Some(legacy_total_score); + + self + } + /// Provide parameters through an [`OsuScoreState`]. pub const fn state(mut self, state: OsuScoreState) -> Self { let OsuScoreState { max_combo, hitresults, + legacy_total_score, } = state; self.combo = Some(max_combo); + self.legacy_total_score = legacy_total_score; self.hitresults(hitresults) } @@ -431,7 +448,6 @@ impl<'map> OsuPerformance<'map> { } /// Create the [`OsuScoreState`] that will be used for performance calculation. - #[allow(clippy::too_many_lines)] pub fn generate_state(&mut self) -> Result { self.map_or_attrs.insert_attrs(&self.difficulty)?; @@ -494,6 +510,7 @@ impl<'map> OsuPerformance<'map> { Ok(OsuScoreState { max_combo, hitresults, + legacy_total_score: self.legacy_total_score, }) } @@ -510,42 +527,6 @@ impl<'map> OsuPerformance<'map> { let lazer = self.difficulty.get_lazer(); let using_classic_slider_acc = mods.no_slider_head_acc(lazer); - let mut effective_miss_count = f64::from(state.hitresults.misses); - - if attrs.n_sliders > 0 { - if using_classic_slider_acc { - // * Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it - // * In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map - let full_combo_threshold = - f64::from(attrs.max_combo) - 0.1 * f64::from(attrs.n_sliders); - - if f64::from(state.max_combo) < full_combo_threshold { - effective_miss_count = - full_combo_threshold / f64::from(state.max_combo).max(1.0); - } - - // * In classic scores there can't be more misses than a sum of all non-perfect judgements - effective_miss_count = - effective_miss_count.min(total_imperfect_hits(&state.hitresults)); - } else { - let full_combo_threshold = - f64::from(attrs.max_combo - n_slider_ends_dropped(&attrs, &state)); - - if f64::from(state.max_combo) < full_combo_threshold { - effective_miss_count = - full_combo_threshold / f64::from(state.max_combo).max(1.0); - } - - // * Combine regular misses with tick misses since tick misses break combo as well - effective_miss_count = effective_miss_count.min(f64::from( - n_large_tick_miss(&attrs, &state) + state.hitresults.misses, - )); - } - } - - effective_miss_count = effective_miss_count.max(f64::from(state.hitresults.misses)); - effective_miss_count = effective_miss_count.min(f64::from(state.hitresults.total_hits())); - let origin = match (lazer, using_classic_slider_acc) { (false, _) => OsuScoreOrigin::Stable, (true, false) => OsuScoreOrigin::WithSliderAcc { @@ -560,14 +541,8 @@ impl<'map> OsuPerformance<'map> { let acc = state.hitresults.accuracy(origin); - let inner = OsuPerformanceCalculator::new( - attrs, - mods, - acc, - state, - effective_miss_count, - using_classic_slider_acc, - ); + let inner = + OsuPerformanceCalculator::new(attrs, mods, acc, state, using_classic_slider_acc); Ok(inner.calculate()) } @@ -587,10 +562,14 @@ impl<'map> OsuPerformance<'map> { misses: None, hitresult_priority: HitResultPriority::DEFAULT, hitresult_generator: None, + legacy_total_score: None, } } - #[allow(clippy::result_large_err)] + #[expect( + clippy::result_large_err, + reason = "Result type serves more as 'Either'" + )] pub(crate) fn try_convert_map( map_or_attrs: MapOrAttrs<'map, Osu>, mode: GameMode, @@ -622,18 +601,6 @@ impl<'map, T: IntoModePerformance<'map, Osu>> From for OsuPerformance<'map> { } } -fn total_imperfect_hits(hitresults: &OsuHitResults) -> f64 { - f64::from(hitresults.n100 + hitresults.n50 + hitresults.misses) -} - -const fn n_slider_ends_dropped(attrs: &OsuDifficultyAttributes, state: &OsuScoreState) -> u32 { - attrs.n_sliders - state.hitresults.slider_end_hits -} - -const fn n_large_tick_miss(attrs: &OsuDifficultyAttributes, state: &OsuScoreState) -> u32 { - attrs.n_large_ticks - state.hitresults.large_tick_hits -} - #[cfg(test)] mod test { use std::sync::OnceLock; @@ -641,6 +608,7 @@ mod test { use crate::{ Beatmap, any::{DifficultyAttributes, PerformanceAttributes}, + osu::OsuDifficultyAttributes, taiko::{TaikoDifficultyAttributes, TaikoPerformanceAttributes}, }; diff --git a/src/osu/score_state.rs b/src/osu/score_state.rs index b704fdcc..ba9fc117 100644 --- a/src/osu/score_state.rs +++ b/src/osu/score_state.rs @@ -1,3 +1,5 @@ +use std::cmp; + use crate::util::{float_ext::FloatExt, hint::unlikely}; /// osu!standard hitresults. @@ -65,8 +67,8 @@ impl OsuHitResults { max_large_ticks, max_slider_ends, } => { - let slider_end_hits = self.slider_end_hits.min(max_slider_ends); - let large_tick_hits = self.large_tick_hits.min(max_large_ticks); + let slider_end_hits = cmp::min(self.slider_end_hits, max_slider_ends); + let large_tick_hits = cmp::min(self.large_tick_hits, max_large_ticks); numerator += f64::from(3 * slider_end_hits) + 0.6 * f64::from(large_tick_hits); denominator += f64::from(3 * max_slider_ends) + 0.6 * f64::from(max_large_ticks); @@ -75,8 +77,8 @@ impl OsuHitResults { max_large_ticks, max_small_ticks, } => { - let large_tick_hits = self.large_tick_hits.min(max_large_ticks); - let small_tick_hits = self.small_tick_hits.min(max_small_ticks); + let large_tick_hits = cmp::min(self.large_tick_hits, max_large_ticks); + let small_tick_hits = cmp::min(self.small_tick_hits, max_small_ticks); numerator += 0.6 * f64::from(large_tick_hits) + 0.2 * f64::from(small_tick_hits); denominator += 0.6 * f64::from(max_large_ticks) + 0.2 * f64::from(max_small_ticks); @@ -105,6 +107,10 @@ pub struct OsuScoreState { pub max_combo: u32, /// Hitresults of a score. pub hitresults: OsuHitResults, + /// Legacy total score. + /// + /// Only relevant for osu!stable + pub legacy_total_score: Option, } impl OsuScoreState { @@ -113,6 +119,7 @@ impl OsuScoreState { Self { max_combo: 0, hitresults: OsuHitResults::new(), + legacy_total_score: None, } } } diff --git a/src/osu/strains.rs b/src/osu/strains.rs index 2b70db7e..c185e6b5 100644 --- a/src/osu/strains.rs +++ b/src/osu/strains.rs @@ -28,6 +28,7 @@ pub fn strains(difficulty: &Difficulty, map: &Beatmap) -> Result f64 { + let mut inner = InnerNestedScorePerObject::default(); + + for h in objects.iter().take(passed_objects) { + inner.process_next(h); + } + + inner.calculate() + } +} + +fn calculate_spinner_score(spinner: Spinner) -> f64 { + const SPIN_SCORE: i32 = 100; + const BONUS_SPIN_SCORE: i32 = 1000; + + // * The spinner object applies a lenience because gameplay mechanics differ from osu-stable. + // * We'll redo the calculations to match osu-stable here... + const MAXIMUM_ROTATIONS_PER_SECOND: f64 = 477.0 / 60.0; + + // * Normally, this value depends on the final overall difficulty. For simplicity, we'll only consider the worst case that maximises bonus score. + // * As we're primarily concerned with computing the maximum theoretical final score, + // * this will have the final effect of slightly underestimating bonus score achieved on stable when converting from score V1. + const MINIMUM_ROTATIONS_PER_SECOND: f64 = 3.0; + + let seconds_duration = spinner.duration / 1000.0; + + // * The total amount of half spins possible for the entire spinner. + let total_half_spins_possible = (seconds_duration * MAXIMUM_ROTATIONS_PER_SECOND * 2.0) as i32; + // * The amount of half spins that are required to successfully complete the spinner (i.e. get a 300). + let half_spins_required_for_completion = + (seconds_duration * MINIMUM_ROTATIONS_PER_SECOND) as i32; + // * To be able to receive bonus points, the spinner must be rotated another 1.5 times. + let half_spins_required_before_bonus = half_spins_required_for_completion + 3; + + let mut score: i64 = 0; + + let full_spins = total_half_spins_possible / 2; + + // * Normal spin score + score += i64::from(SPIN_SCORE * full_spins); + + let mut bonus_spins = (total_half_spins_possible - half_spins_required_before_bonus) / 2; + + // * Reduce amount of bonus spins because we want to represent the more average case, rather than the best one. + bonus_spins = cmp::max(bonus_spins - full_spins / 2, 0); + + score += i64::from(BONUS_SPIN_SCORE * bonus_spins); + + score as f64 +} + +#[derive(Default)] +struct InnerNestedScorePerObject { + n_sliders: usize, + n_repeats: usize, + amount_of_small_ticks: usize, + spinner_score: f64, + object_count: usize, +} + +impl InnerNestedScorePerObject { + fn process_next(&mut self, h: &OsuObject) { + self.object_count += 1; + + match h.kind { + OsuObjectKind::Circle => {} + OsuObjectKind::Slider(ref slider) => { + self.n_sliders += 1; + self.n_repeats += slider.repeat_count(); + self.amount_of_small_ticks += slider.tick_count(); + } + OsuObjectKind::Spinner(spinner) => { + self.spinner_score += calculate_spinner_score(spinner); + } + } + } + + fn calculate(&self) -> f64 { + const BIG_TICK_SCORE: f64 = 30.0; + const SMALL_TICK_SCORE: f64 = 10.0; + + // * 1 for head, 1 for tail + let mut amount_of_big_ticks = self.n_sliders * 2; + + // * Add slider repeats + amount_of_big_ticks += self.n_repeats; + + let slider_score = amount_of_big_ticks as f64 * BIG_TICK_SCORE + + self.amount_of_small_ticks as f64 * SMALL_TICK_SCORE; + + (slider_score + self.spinner_score) / self.object_count as f64 + } +} + +#[derive(Default)] +pub struct GradualNestedScorePerObject(InnerNestedScorePerObject); + +impl GradualNestedScorePerObject { + pub fn calculate_next(&mut self, h: &OsuObject) -> f64 { + let Self(inner) = self; + inner.process_next(h); + + inner.calculate() + } +} diff --git a/src/osu/utils/mod.rs b/src/osu/utils/mod.rs new file mode 100644 index 00000000..075d7b19 --- /dev/null +++ b/src/osu/utils/mod.rs @@ -0,0 +1 @@ +pub mod legacy_score; diff --git a/src/taiko/attributes.rs b/src/taiko/attributes.rs index 8a24ca67..a0c0bd44 100644 --- a/src/taiko/attributes.rs +++ b/src/taiko/attributes.rs @@ -18,6 +18,12 @@ pub struct TaikoDifficultyAttributes { /// The ratio of stamina difficulty from mono-color (single color) streams to total /// stamina difficulty. pub mono_stamina_factor: f64, + /// The difficulty corresponding to the mechanical skills. + /// + /// This includes colour and stamina combined. + pub mechanical_difficulty: f64, + /// The factor corresponding to the consistency of a map. + pub consistency_factor: f64, /// The final star rating. pub stars: f64, /// The maximum combo. @@ -58,8 +64,6 @@ pub struct TaikoPerformanceAttributes { pub pp_acc: f64, /// The strain portion of the final pp. pub pp_difficulty: f64, - /// Scaled miss count based on total hits. - pub effective_miss_count: f64, /// Upper bound on the player's tap deviation. pub estimated_unstable_rate: Option, } @@ -98,3 +102,52 @@ impl From for TaikoDifficultyAttributes { attributes.difficulty } } + +#[cfg(test)] +pub(super) mod tests { + use super::TaikoDifficultyAttributes; + + /// Asserts equality for two [`TaikoDifficultyAttributes`] instances. + /// + /// `NaN` values are considered to be equal. + #[track_caller] + pub fn assert_eq_attrs(a: &TaikoDifficultyAttributes, b: &TaikoDifficultyAttributes) { + let TaikoDifficultyAttributes { + stamina, + rhythm, + color, + reading, + great_hit_window, + ok_hit_window, + mono_stamina_factor, + mechanical_difficulty, + consistency_factor, + stars, + max_combo, + is_convert, + } = a; + + macro_rules! assert_eq_nan { + ( $field:ident ) => { + if *$field != b.$field { + if $field.is_nan() != b.$field.is_nan() { + assert_eq!(*$field, b.$field); + } + } + }; + } + + assert_eq_nan!(stamina); + assert_eq_nan!(rhythm); + assert_eq_nan!(color); + assert_eq_nan!(reading); + assert_eq_nan!(great_hit_window); + assert_eq_nan!(ok_hit_window); + assert_eq_nan!(mono_stamina_factor); + assert_eq_nan!(mechanical_difficulty); + assert_eq_nan!(consistency_factor); + assert_eq_nan!(stars); + assert_eq!(*max_combo, b.max_combo); + assert_eq!(*is_convert, b.is_convert); + } +} diff --git a/src/taiko/difficulty/evaluators/color.rs b/src/taiko/difficulty/evaluators/color.rs new file mode 100644 index 00000000..2e2a9028 --- /dev/null +++ b/src/taiko/difficulty/evaluators/color.rs @@ -0,0 +1,184 @@ +use std::{f64::consts::E, ops::ControlFlow}; + +use crate::{ + any::difficulty::object::IDifficultyObject, + taiko::difficulty::{ + color::data::{ + alternating_mono_pattern::AlternatingMonoPattern, mono_streak::MonoStreak, + repeating_hit_patterns::RepeatingHitPatterns, + }, + object::{TaikoDifficultyObject, TaikoDifficultyObjects}, + }, + util::{ + difficulty::{logistic_exp, smootherstep}, + sync::{RefCount, Weak}, + }, +}; + +pub struct ColorEvaluator; + +impl ColorEvaluator { + fn consistent_ratio_penalty( + hit_object: &TaikoDifficultyObject, + objects: &TaikoDifficultyObjects, + threshold: Option, + max_objects_to_check: Option, + ) -> f64 { + let threshold = threshold.unwrap_or(0.01); + let max_objects_to_check = max_objects_to_check.unwrap_or(64); + + let mut consistent_ratio_count = 0; + let mut total_ratio_count = 0.0; + + let mut recent_ratios = Vec::new(); + let current = hit_object; + + #[expect(clippy::items_after_statements, reason = "same order as lazer")] + fn iteration( + current: &TaikoDifficultyObject, + previous_hit_object: &TaikoDifficultyObject, + recent_ratios: &mut Vec, + consistent_ratio_count: &mut i32, + total_ratio_count: &mut f64, + threshold: f64, + ) -> ControlFlow<()> { + if current.idx <= 1 { + return ControlFlow::Break(()); + } + + let current_ratio = current.rhythm_data.ratio; + let previous_ratio = previous_hit_object.rhythm_data.ratio; + + recent_ratios.push(current_ratio); + + if f64::abs(1.0 - current_ratio / previous_ratio) <= threshold { + *consistent_ratio_count += 1; + *total_ratio_count += current_ratio; + + return ControlFlow::Break(()); + } + + ControlFlow::Continue(()) + } + + // The lazer implementation is buggy and we want to translate this same + // buggy behavior. + if let Some(previous_hit_object) = current + .previous(1, objects) + .filter(|_| max_objects_to_check > 0) + { + let previous_hit_object = previous_hit_object.get(); + + let flow = iteration( + current, + &previous_hit_object, + &mut recent_ratios, + &mut consistent_ratio_count, + &mut total_ratio_count, + threshold, + ); + + if flow.is_continue() && max_objects_to_check > 1 { + let _ = iteration( + &previous_hit_object, + &previous_hit_object, + &mut recent_ratios, + &mut consistent_ratio_count, + &mut total_ratio_count, + threshold, + ); + } + } + + if consistent_ratio_count > 0 { + return 1.0 - total_ratio_count / f64::from(consistent_ratio_count + 1) * 0.8; + } + + if recent_ratios.len() <= 1 { + return 1.0; + } + + // * As a fallback, calculate the maximum deviation from the average of + // * the recent ratios to ensure slightly off-snapped objects don't + // * bypass the penalty. + let avg = recent_ratios.iter().sum::() / recent_ratios.len() as f64; + let max_ratio_deviation = recent_ratios + .iter() + .fold(f64::MIN, |max, r| f64::max(max, f64::abs(r - avg))); + + 0.7 + 0.3 * smootherstep(max_ratio_deviation, 0.0, 1.0) + } + + pub fn evaluate_difficulty_of( + hit_object: &TaikoDifficultyObject, + objects: &TaikoDifficultyObjects, + ) -> f64 { + let color_data = &hit_object.color_data; + let mut difficulty = 0.0; + + if let Some(mono_streak) = color_data.mono_streak.as_ref().and_then(Weak::upgrade) + && let Some(first_hit_object) = mono_streak.get().first_hit_object() + && &*first_hit_object.get() == hit_object + { + difficulty += Self::eval_mono_streak_diff(&mono_streak); + } + + if let Some(alternating_mono_pattern) = color_data + .alternating_mono_pattern + .as_ref() + .and_then(Weak::upgrade) + && let Some(first_hit_object) = alternating_mono_pattern.get().first_hit_object() + && &*first_hit_object.get() == hit_object + { + difficulty += Self::eval_alternating_mono_pattern_diff(&alternating_mono_pattern); + } + + if let Some(repeating_hit_patterns) = color_data.repeating_hit_patterns.as_ref() + && let Some(first_hit_object) = repeating_hit_patterns.get().first_hit_object() + && &*first_hit_object.get() == hit_object + { + difficulty += Self::eval_repeating_hit_patterns_diff(repeating_hit_patterns); + } + + let consistency_penalty = Self::consistent_ratio_penalty(hit_object, objects, None, None); + difficulty *= consistency_penalty; + + difficulty + } + + fn eval_mono_streak_diff(mono_streak: &RefCount) -> f64 { + let mono_streak = mono_streak.get(); + + let parent_eval = mono_streak + .parent + .as_ref() + .and_then(Weak::upgrade) + .as_ref() + .map_or(1.0, Self::eval_alternating_mono_pattern_diff); + + logistic_exp(E * mono_streak.idx as f64 - 2.0 * E, None) * parent_eval * 0.5 + } + + fn eval_alternating_mono_pattern_diff( + alternating_mono_pattern: &RefCount, + ) -> f64 { + let alternating_mono_pattern = alternating_mono_pattern.get(); + + let parent_eval = alternating_mono_pattern + .parent + .as_ref() + .and_then(Weak::upgrade) + .as_ref() + .map_or(1.0, Self::eval_repeating_hit_patterns_diff); + + logistic_exp(E * alternating_mono_pattern.idx as f64 - 2.0 * E, None) * parent_eval + } + + fn eval_repeating_hit_patterns_diff( + repeating_hit_patterns: &RefCount, + ) -> f64 { + let repetition_interval = repeating_hit_patterns.get().repetition_interval as f64; + + 2.0 * (1.0 - logistic_exp(E * repetition_interval - 2.0 * E, None)) + } +} diff --git a/src/taiko/difficulty/evaluators/mod.rs b/src/taiko/difficulty/evaluators/mod.rs new file mode 100644 index 00000000..9aacc838 --- /dev/null +++ b/src/taiko/difficulty/evaluators/mod.rs @@ -0,0 +1,9 @@ +pub use self::{ + color::ColorEvaluator, reading::ReadingEvaluator, rhythm::RhythmEvaluator, + stamina::StaminaEvaluator, +}; + +mod color; +mod reading; +mod rhythm; +mod stamina; diff --git a/src/taiko/difficulty/evaluators/reading.rs b/src/taiko/difficulty/evaluators/reading.rs new file mode 100644 index 00000000..2b0f9d02 --- /dev/null +++ b/src/taiko/difficulty/evaluators/reading.rs @@ -0,0 +1,58 @@ +use crate::{taiko::difficulty::object::TaikoDifficultyObject, util::difficulty::logistic}; + +pub struct ReadingEvaluator; + +impl ReadingEvaluator { + pub fn evaluate_diff_of(note_object: &TaikoDifficultyObject) -> f64 { + let high_velocity = VelocityRange::new(480.0, 640.0); + let mid_velocity = VelocityRange::new(360.0, 480.0); + + // * Apply a cap to prevent outlier values on maps that exceed the editor's parameters. + let effective_bpm = f64::max(1.0, note_object.effective_bpm); + + let mid_velocity_diff = 0.5 + * logistic( + effective_bpm, + mid_velocity.center(), + 1.0 / (mid_velocity.range() / 10.0), + None, + ); + + // * Expected DeltaTime is the DeltaTime this note would need to be spaced equally to a base slider velocity 1/4 note. + let expected_delta_time = 21_000.0 / effective_bpm; + let object_density = expected_delta_time / f64::max(1.0, note_object.delta_time); + + // * High density is penalised at high velocity as it is generally considered easier to read. + // * See https://www.desmos.com/calculator/u63f3ntdsi + let density_penalty = logistic(object_density, 0.925, 15.0, None); + + let high_velocity_diff = (1.0 - 0.33 * density_penalty) + * logistic( + effective_bpm, + high_velocity.center() + 8.0 * density_penalty, + (1.0 + 0.5 * density_penalty) / (high_velocity.range() / 10.0), + None, + ); + + mid_velocity_diff + high_velocity_diff + } +} + +struct VelocityRange { + min: f64, + max: f64, +} + +impl VelocityRange { + const fn new(min: f64, max: f64) -> Self { + Self { min, max } + } + + const fn center(&self) -> f64 { + (self.max + self.min) / 2.0 + } + + const fn range(&self) -> f64 { + self.max - self.min + } +} diff --git a/src/taiko/difficulty/evaluators/rhythm.rs b/src/taiko/difficulty/evaluators/rhythm.rs new file mode 100644 index 00000000..04b33899 --- /dev/null +++ b/src/taiko/difficulty/evaluators/rhythm.rs @@ -0,0 +1,188 @@ +use std::f64::consts::PI; + +use crate::{ + taiko::difficulty::{ + object::TaikoDifficultyObject, + rhythm::data::same_rhythm_hit_object_grouping::SameRhythmHitObjectGrouping, + }, + util::{ + difficulty::{bell_curve, logistic}, + sync::RefCount, + }, +}; + +pub struct RhythmEvaluator; + +impl RhythmEvaluator { + pub fn evaluate_diff_of(hit_object: &TaikoDifficultyObject, hit_window: f64) -> f64 { + let rhythm_data = &hit_object.rhythm_data; + let mut difficulty = 0.0; + + let mut same_rhythm = 0.0; + let mut same_pattern = 0.0; + let mut interval_penalty = 0.0; + + // * Difficulty for SameRhythmGroupedHitObjects + if let Some(ref same_rhythm_grouped) = rhythm_data.same_rhythm_grouped_hit_objects + && same_rhythm_grouped + .get() + .first_hit_object() + .is_some_and(|h| &*h.get() == hit_object) + { + same_rhythm += 10.0 * Self::evaluate_diff_of_(same_rhythm_grouped, hit_window); + interval_penalty = + Self::repeated_interval_penalty(same_rhythm_grouped, hit_window, None); + } + + // * Difficulty for SamePatternsGroupedHitObjects + if let Some(ref same_pattern_grouped) = rhythm_data.same_patterns_grouped_hit_objects + && same_pattern_grouped + .get() + .first_hit_object() + .is_some_and(|h| &*h.get() == hit_object) + { + same_pattern += + 1.15 * Self::ratio_difficulty(same_pattern_grouped.get().interval_ratio(), None); + } + + difficulty += f64::max(same_rhythm, same_pattern) * interval_penalty; + + difficulty + } + + fn evaluate_diff_of_( + same_rhythm_grouped_hit_objects: &RefCount, + hit_window: f64, + ) -> f64 { + let mut interval_diff = Self::ratio_difficulty( + same_rhythm_grouped_hit_objects + .get() + .hit_object_interval_ratio, + None, + ); + let prev_interval = same_rhythm_grouped_hit_objects + .get() + .upgraded_previous() + .and_then(|h| h.get().hit_object_interval); + + interval_diff *= + Self::repeated_interval_penalty(same_rhythm_grouped_hit_objects, hit_window, None); + + let borrowed = same_rhythm_grouped_hit_objects.get(); + let duration = borrowed.duration(); + + // * If a previous interval exists and there are multiple hit objects in the sequence: + if let Some(prev_interval) = prev_interval.filter(|_| borrowed.hit_objects.len() > 1) + && let Some(duration) = duration + { + let expected_duration_from_prev = prev_interval * borrowed.hit_objects.len() as f64; + let duration_diff = duration - expected_duration_from_prev; + + if duration_diff > 0.0 { + interval_diff *= logistic(duration_diff / hit_window, 0.7, 1.0, Some(1.0)); + } + } + + // Penalise patterns that can be hit within a single hit window. + if let Some(duration) = duration { + interval_diff *= logistic(duration / hit_window, 0.6, 1.0, Some(1.0)); + } + + f64::powf(interval_diff, 0.75) + } + + fn repeated_interval_penalty( + same_rhythm_grouped_hit_objects: &RefCount, + hit_window: f64, + threshold: Option, + ) -> f64 { + let threshold = threshold.unwrap_or(0.1); + + let same_interval = + |start_object: RefCount, interval_count: usize| -> f64 { + let mut intervals = Vec::new(); + let mut curr_object = Some(start_object); + + let mut i = 0; + + while let Some(curr) = curr_object.filter(|_| i < interval_count) { + let curr = curr.get(); + + if let Some(interval) = curr.hit_object_interval { + intervals.push(interval); + } + + curr_object = curr.upgraded_previous(); + i += 1; + } + + if intervals.len() < interval_count { + return 1.0; // * No penalty if there aren't enough valid intervals. + } + + for i in 0..intervals.len() { + for j in i + 1..intervals.len() { + let ratio = intervals[i] / intervals[j]; + + // * If any two intervals are similar, apply a penalty. + if f64::abs(1.0 - ratio) <= threshold { + return 0.8; + } + } + } + + // * No penalty if all intervals are different. + 1.0 + }; + + let long_interval_penalty = + same_interval(RefCount::clone(same_rhythm_grouped_hit_objects), 3); + + let short_interval_penalty = if same_rhythm_grouped_hit_objects.get().hit_objects.len() < 6 + { + same_interval(RefCount::clone(same_rhythm_grouped_hit_objects), 4) + } else { + // * Returns a non-penalty if there are 6 or more notes within an interval. + 1.0 + }; + + // * The duration penalty is based on hit object duration relative to hitWindow. + let duration_penalty = same_rhythm_grouped_hit_objects + .get() + .duration() + .map_or(0.5, |duration| { + f64::max(1.0 - duration * 2.0 / hit_window, 0.5) + }); + + f64::min(long_interval_penalty, short_interval_penalty) * duration_penalty + } + + fn ratio_difficulty(mut ratio: f64, terms: Option) -> f64 { + let terms = terms.unwrap_or(8); + let mut difficulty = 0.0; + + // * Validate the ratio by ensuring it is a normal number in cases where maps breach regular mapping conditions. + ratio = if ratio.is_normal() { ratio } else { 0.0 }; + + for i in 1..=terms { + difficulty += Self::term_penalty(ratio, i, 4.0, 1.0); + } + + difficulty += f64::from(terms) / (1.0 + ratio); + + // * Give bonus to near-1 ratios + difficulty += bell_curve(ratio, 1.0, 0.5, None); + + // * Penalize ratios that are VERY near 1 + difficulty -= bell_curve(ratio, 1.0, 0.3, None); + + difficulty = f64::max(difficulty, 0.0); + difficulty /= f64::sqrt(8.0); + + difficulty + } + + fn term_penalty(ratio: f64, denominator: i32, power: f64, multiplier: f64) -> f64 { + -multiplier * f64::powf(f64::cos(f64::from(denominator) * PI * ratio), power) + } +} diff --git a/src/taiko/difficulty/evaluators/stamina.rs b/src/taiko/difficulty/evaluators/stamina.rs new file mode 100644 index 00000000..ea8d6871 --- /dev/null +++ b/src/taiko/difficulty/evaluators/stamina.rs @@ -0,0 +1,63 @@ +use crate::{ + any::difficulty::object::IDifficultyObject, + taiko::difficulty::object::{TaikoDifficultyObject, TaikoDifficultyObjects}, +}; + +pub struct StaminaEvaluator; + +impl StaminaEvaluator { + pub fn evaluate_diff_of(curr: &TaikoDifficultyObject, objects: &TaikoDifficultyObjects) -> f64 { + if !curr.base_hit_type.is_hit() { + return 0.0; + } + + // * Find the previous hit object hit by the current finger, which is n notes prior, n being the number of + // * available fingers. + let prev = curr.previous(1, objects); + let prev_mono = objects.previous_mono(curr, Self::available_fingers_for(curr, objects) - 1); + + // * Add a base strain to all objects + let mut object_strain = 0.5; + + let Some(prev) = prev else { + return object_strain; + }; + + if let Some(prev_mono) = prev_mono { + object_strain += Self::speed_bonus(curr.start_time - prev_mono.get().start_time) + + 0.5 * Self::speed_bonus(curr.start_time - prev.get().start_time); + } + + object_strain + } + + fn available_fingers_for( + hit_object: &TaikoDifficultyObject, + hit_objects: &TaikoDifficultyObjects, + ) -> usize { + let prev_color_change = hit_object.color_data.previous_color_change(hit_objects); + + if prev_color_change + .is_some_and(|change| hit_object.start_time - change.get().start_time < 300.0) + { + return 2; + } + + let next_color_change = hit_object.color_data.next_color_change(hit_objects); + + if next_color_change + .is_some_and(|change| change.get().start_time - hit_object.start_time < 300.0) + { + return 2; + } + + 8 + } + + fn speed_bonus(mut interval: f64) -> f64 { + // * Interval is capped at a very small value to prevent infinite values. + interval = f64::max(interval, 1.0); + + 20.0 / interval + } +} diff --git a/src/taiko/difficulty/gradual.rs b/src/taiko/difficulty/gradual.rs index 1a53eb12..54299951 100644 --- a/src/taiko/difficulty/gradual.rs +++ b/src/taiko/difficulty/gradual.rs @@ -5,7 +5,7 @@ use rosu_map::section::general::GameMode; use crate::{ Beatmap, Difficulty, any::difficulty::skills::StrainSkill, - model::{beatmap::HitWindows, hit_object::HitObject, mode::ConvertError}, + model::{hit_object::HitObject, mode::ConvertError}, taiko::convert, util::sync::RefCount, }; @@ -89,12 +89,14 @@ impl TaikoGradualDifficulty { (Some(true), Some(true)) => FirstTwoCombos::Both, }; - let HitWindows { - od_great, - od_ok, - od_meh: _, - ar: _, - } = map.attributes().difficulty(&difficulty).hit_windows(); + let hit_windows = map + .attributes() + .difficulty(&difficulty) + .build() + .hit_windows(); + + let great_hit_window = hit_windows.od_great.unwrap_or(0.0); + let ok_hit_window = hit_windows.od_ok.unwrap_or(0.0); let mut n_diff_objects = 0; let mut max_combo = 0; @@ -108,11 +110,11 @@ impl TaikoGradualDifficulty { difficulty.get_mods(), ); - let skills = TaikoSkills::new(od_great, map.is_convert); + let skills = TaikoSkills::new(great_hit_window, map.is_convert); let attrs = TaikoDifficultyAttributes { - great_hit_window: od_great, - ok_hit_window: od_ok.unwrap_or(0.0), + great_hit_window, + ok_hit_window, is_convert: map.is_convert, ..Default::default() }; @@ -269,8 +271,8 @@ impl ExactSizeIterator for TaikoGradualDifficulty { } #[cfg(test)] -mod tests { - use crate::{Beatmap, taiko::Taiko}; +pub(super) mod tests { + use crate::{Beatmap, taiko::Taiko, taiko::attributes::tests::assert_eq_attrs}; use super::*; @@ -305,12 +307,12 @@ mod tests { if i % 2 == 0 { let next_gradual_2nd = gradual_2nd.nth(1).unwrap(); - assert_eq!(next_gradual, next_gradual_2nd); + assert_eq_attrs(&next_gradual, &next_gradual_2nd); } if i % 3 == 0 { let next_gradual_3rd = gradual_3rd.nth(2).unwrap(); - assert_eq!(next_gradual, next_gradual_3rd); + assert_eq_attrs(&next_gradual, &next_gradual_3rd); } let expected = difficulty @@ -319,7 +321,7 @@ mod tests { .calculate_for_mode::(&map) .unwrap(); - assert_eq!(next_gradual, expected); + assert_eq_attrs(&next_gradual, &expected); } } } diff --git a/src/taiko/difficulty/mod.rs b/src/taiko/difficulty/mod.rs index 12956d81..551f8f51 100644 --- a/src/taiko/difficulty/mod.rs +++ b/src/taiko/difficulty/mod.rs @@ -7,7 +7,7 @@ use skills::{color::Color, reading::Reading, rhythm::Rhythm, stamina::Stamina}; use crate::{ Beatmap, Difficulty, GameMods, any::difficulty::skills::StrainSkill, - model::{beatmap::HitWindows, mode::ConvertError}, + model::mode::ConvertError, taiko::{ difficulty::{ color::preprocessor::ColorDifficultyPreprocessor, @@ -15,7 +15,7 @@ use crate::{ }, object::TaikoObject, }, - util::difficulty::norm, + util::difficulty::{norm, reverse_lerp}, }; pub(crate) use self::skills::TaikoSkills; @@ -23,14 +23,16 @@ pub(crate) use self::skills::TaikoSkills; use super::{attributes::TaikoDifficultyAttributes, convert}; mod color; +mod evaluators; pub mod gradual; mod object; mod rhythm; mod skills; +mod utils; -#[allow(clippy::unreadable_literal)] +#[expect(clippy::unreadable_literal, reason = "staying in-sync with lazer")] const DIFFICULTY_MULTIPLIER: f64 = 0.084375; -const RHYTHM_SKILL_MULTIPLIER: f64 = 0.65 * DIFFICULTY_MULTIPLIER; +const RHYTHM_SKILL_MULTIPLIER: f64 = 0.75 * DIFFICULTY_MULTIPLIER; const READING_SKILL_MULTIPLIER: f64 = 0.100 * DIFFICULTY_MULTIPLIER; const COLOR_SKILL_MULTIPLIER: f64 = 0.375 * DIFFICULTY_MULTIPLIER; const STAMINA_SKILL_MULTIPLIER: f64 = 0.445 * DIFFICULTY_MULTIPLIER; @@ -45,19 +47,21 @@ pub fn difficulty( convert::apply_random_to_beatmap(map.to_mut(), seed); } - let HitWindows { - od_great, - od_ok, - od_meh: _, - ar: _, - } = map.attributes().difficulty(difficulty).hit_windows(); + let hit_windows = map + .attributes() + .difficulty(difficulty) + .build() + .hit_windows(); + + let great_hit_window = hit_windows.od_great.unwrap_or(0.0); + let ok_hit_window = hit_windows.od_ok.unwrap_or(0.0); let DifficultyValues { skills, max_combo } = - DifficultyValues::calculate(difficulty, &map, od_great); + DifficultyValues::calculate(difficulty, &map, great_hit_window); let mut attrs = TaikoDifficultyAttributes { - great_hit_window: od_great, - ok_hit_window: od_ok.unwrap_or(0.0), + great_hit_window, + ok_hit_window, max_combo, is_convert: map.is_convert, ..Default::default() @@ -70,7 +74,8 @@ pub fn difficulty( Ok(attrs) } -#[allow(clippy::too_many_arguments)] +/// Returns the combined rating and the consistency factor +#[expect(clippy::too_many_arguments, reason = "it is what it is /shrug")] fn combined_difficulty_value( rhythm: Rhythm, reading: Reading, @@ -80,72 +85,173 @@ fn combined_difficulty_value( is_convert: bool, pattern_multiplier: f64, strain_length_bonus: f64, -) -> f64 { +) -> (f64, f64) { + let mut hit_object_strain_peaks = combine_peaks( + rhythm.object_strains().iter().copied(), + reading.object_strains().iter().copied(), + color.object_strains().iter().copied(), + stamina.object_strains().iter().copied(), + rhythm.object_strains().len(), + is_relax, + is_convert, + pattern_multiplier, + strain_length_bonus, + ); + let rhythm_peaks = rhythm.into_current_strain_peaks(); let reading_peaks = reading.into_current_strain_peaks(); let color_peaks = color.into_current_strain_peaks(); let stamina_peaks = stamina.into_current_strain_peaks(); - let cap = cmp::min( + let len = cmp::min( rhythm_peaks.len(), cmp::min( reading_peaks.len(), cmp::min(color_peaks.len(), stamina_peaks.len()), ), ); - let mut peaks = Vec::with_capacity(cap); - let iter = rhythm_peaks - .iter() - .zip(reading_peaks.iter()) - .zip(color_peaks.iter()) - .zip(stamina_peaks.iter()); + let mut peaks = combine_peaks( + rhythm_peaks.iter(), + reading_peaks.iter(), + color_peaks.iter(), + stamina_peaks.iter(), + len, + is_relax, + is_convert, + pattern_multiplier, + strain_length_bonus, + ); - for (((mut rhythm_peak, mut reading_peak), mut color_peak), mut stamina_peak) in iter { - rhythm_peak *= RHYTHM_SKILL_MULTIPLIER; - rhythm_peak *= pattern_multiplier; + if peaks.is_empty() { + return (0.0, 0.0); + } + + let mut difficulty = 0.0; + let mut weight = 1.0; + + peaks.sort_by(|a, b| b.total_cmp(a)); + + for strain in peaks { + difficulty += strain * weight; + weight *= 0.9; + } + + if hit_object_strain_peaks.is_empty() { + return (0.0, 0.0); + } + + let hit_object_strain_peaks_sum = hit_object_strain_peaks.iter().sum::(); + + // * The average of the top 5% of strain peaks from hit objects. + let take = cmp::min( + 1 + hit_object_strain_peaks.len() / 20, + hit_object_strain_peaks.len(), + ); - reading_peak *= READING_SKILL_MULTIPLIER; + // Put the `take` many largest strains to front (not necessarily sorted). + hit_object_strain_peaks.select_nth_unstable_by(take - 1, |a, b| b.total_cmp(a)); - color_peak *= if is_relax { + // Sort the `take` many largest strains. Technically not necessary but + // affects the order in which they are added together resulting in very + // small differences. + let largest_strains = &mut hit_object_strain_peaks[..take]; + largest_strains.sort_unstable_by(|a, b| b.total_cmp(a)); + + let top_average_hit_object_strain = (largest_strains.iter().sum::()) / take as f64; + + // * Calculates a consistency factor as the sum of difficulty from hit objects compared to if every object were as hard as the hardest. + // * The top average strain is used instead of the very hardest to prevent exceptionally hard objects lowering the factor. + let consistency_factor = hit_object_strain_peaks_sum + / (top_average_hit_object_strain * hit_object_strain_peaks.len() as f64); + + (difficulty, consistency_factor) +} + +#[expect(clippy::too_many_arguments, reason = "it is what it is /shrug")] +fn combine_peaks( + rhythm_peaks: I, + reading_peaks: I, + color_peaks: I, + stamina_peaks: I, + len: usize, + is_relax: bool, + is_convert: bool, + pattern_multiplier: f64, + strain_length_bonus: f64, +) -> Vec +where + I: IntoIterator, +{ + // Combatting size bloat by monomorphization due to generics + #[expect(clippy::too_many_arguments, reason = "it is what it is /shrug")] + fn iteration( + rhythm_peak: &mut f64, + reading_peak: &mut f64, + color_peak: &mut f64, + stamina_peak: &mut f64, + is_relax: bool, + is_convert: bool, + pattern_multiplier: f64, + strain_length_bonus: f64, + combined_peaks: &mut Vec, + ) { + *rhythm_peak *= RHYTHM_SKILL_MULTIPLIER; + *rhythm_peak *= pattern_multiplier; + + *color_peak *= if is_relax { 0.0 // * There is no colour difficulty in relax. } else { COLOR_SKILL_MULTIPLIER }; - stamina_peak *= STAMINA_SKILL_MULTIPLIER; - stamina_peak *= strain_length_bonus; + *reading_peak *= READING_SKILL_MULTIPLIER; + + *stamina_peak *= STAMINA_SKILL_MULTIPLIER; + *stamina_peak *= strain_length_bonus; // * Available finger count is increased by 150%, thus we adjust accordingly. - stamina_peak /= if is_convert || is_relax { 1.5 } else { 1.0 }; + *stamina_peak /= if is_convert || is_relax { 1.5 } else { 1.0 }; let peak = norm( 2.0, [ - norm(1.5, [color_peak, stamina_peak]), - rhythm_peak, - reading_peak, + norm(1.5, [*color_peak, *stamina_peak]), + *rhythm_peak, + *reading_peak, ], ); // * Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). // * These sections will not contribute to the difficulty. if peak > 0.0 { - peaks.push(peak); + combined_peaks.push(peak); } } - let mut difficulty = 0.0; - let mut weight = 1.0; + let mut combined_peaks = Vec::with_capacity(len); - peaks.sort_by(|a, b| b.total_cmp(a)); + let iter = rhythm_peaks + .into_iter() + .zip(reading_peaks) + .zip(color_peaks) + .zip(stamina_peaks); - for strain in peaks { - difficulty += strain * weight; - weight *= 0.9; + for (((mut rhythm_peak, mut reading_peak), mut color_peak), mut stamina_peak) in iter { + iteration( + &mut rhythm_peak, + &mut reading_peak, + &mut color_peak, + &mut stamina_peak, + is_relax, + is_convert, + pattern_multiplier, + strain_length_bonus, + &mut combined_peaks, + ); } - difficulty + combined_peaks } fn rescale(stars: f64) -> f64 { @@ -210,14 +316,14 @@ impl DifficultyValues { let color_difficulty_value = color.cloned_difficulty_value(); let stamina_difficulty_value = stamina.cloned_difficulty_value(); - let rhythm_rating = rhythm_difficulty_value * RHYTHM_SKILL_MULTIPLIER; - let reading_rating = reading_difficulty_value * READING_SKILL_MULTIPLIER; - let color_rating = color_difficulty_value * COLOR_SKILL_MULTIPLIER; - let stamina_rating = stamina_difficulty_value * STAMINA_SKILL_MULTIPLIER; + let rhythm_skill = rhythm_difficulty_value * RHYTHM_SKILL_MULTIPLIER; + let reading_skill = reading_difficulty_value * READING_SKILL_MULTIPLIER; + let color_skill = color_difficulty_value * COLOR_SKILL_MULTIPLIER; + let stamina_skill = stamina_difficulty_value * STAMINA_SKILL_MULTIPLIER; let mono_stamina_rating = single_color_stamina.into_difficulty_value() * STAMINA_SKILL_MULTIPLIER; - let mono_stamina_factor = if stamina_rating.abs() >= f64::EPSILON { - (mono_stamina_rating / stamina_rating).powf(5.0) + let mono_stamina_factor = if stamina_skill.abs() >= f64::EPSILON { + (mono_stamina_rating / stamina_skill).powf(5.0) } else { 1.0 }; @@ -226,16 +332,12 @@ impl DifficultyValues { stamina.count_top_weighted_strains(stamina_difficulty_value); // * As we don't have pattern integration in osu!taiko, we apply the other two skills relative to rhythm. - let pattern_multiplier = f64::powf(stamina_rating * color_rating, 0.10); + let pattern_multiplier = f64::powf(stamina_skill * color_skill, 0.10); - #[allow(clippy::manual_clamp)] let strain_length_bonus = - 1.0 + f64::min( - f64::max((stamina_difficult_strains - 1000.0) / 3700.0, 0.0), - 0.15, - ) + f64::min(f64::max((stamina_rating - 7.0) / 1.0, 0.0), 0.05); + 1.0 + 0.15 * reverse_lerp(stamina_difficult_strains, 1000.0, 1555.0); - let combined_rating = combined_difficulty_value( + let (combined_rating, consistency_factor) = combined_difficulty_value( rhythm, reading, color, @@ -247,11 +349,24 @@ impl DifficultyValues { ); let star_rating = rescale(combined_rating * 1.4); - attrs.rhythm = rhythm_rating; - attrs.reading = reading_rating; - attrs.color = color_rating; - attrs.stamina = stamina_rating; + // * Calculate proportional contribution of each skill to the combinedRating. + let skill_rating = + star_rating / (rhythm_skill + reading_skill + color_skill + stamina_skill); + let rhythm_difficulty = rhythm_skill * skill_rating; + let reading_difficulty = reading_skill * skill_rating; + let color_difficulty = color_skill * skill_rating; + let stamina_difficulty = stamina_skill * skill_rating; + + // * Mechanical difficulty is the sum of colour and stamina difficulties. + let mechanical_difficulty = color_difficulty + stamina_difficulty; + + attrs.rhythm = rhythm_difficulty; + attrs.reading = reading_difficulty; + attrs.color = color_difficulty; + attrs.stamina = stamina_difficulty; attrs.mono_stamina_factor = mono_stamina_factor; + attrs.mechanical_difficulty = mechanical_difficulty; + attrs.consistency_factor = consistency_factor; attrs.stars = star_rating; } diff --git a/src/taiko/difficulty/object.rs b/src/taiko/difficulty/object.rs index ec43aac0..5cf4046f 100644 --- a/src/taiko/difficulty/object.rs +++ b/src/taiko/difficulty/object.rs @@ -1,11 +1,17 @@ -use std::slice::Iter; +use std::{ + hash::{Hash, Hasher}, + slice::Iter, +}; use crate::{ Beatmap, any::difficulty::object::{HasStartTime, IDifficultyObject, IDifficultyObjects}, model::control_point::{EffectPoint, TimingPoint}, - taiko::object::{HitType, TaikoObject}, - util::{interval_grouping::HasInterval, sync::RefCount}, + taiko::{ + difficulty::utils::has_interval::HasInterval, + object::{HitType, TaikoObject}, + }, + util::sync::RefCount, }; use super::{color::color_data::ColorData, rhythm::rhythm_data::RhythmData}; @@ -230,3 +236,11 @@ impl PartialEq for TaikoDifficultyObject { self.idx == other.idx } } + +impl Eq for TaikoDifficultyObject {} + +impl Hash for TaikoDifficultyObject { + fn hash(&self, state: &mut H) { + self.idx.hash(state); + } +} diff --git a/src/taiko/difficulty/rhythm/data/same_rhythm_hit_object_grouping.rs b/src/taiko/difficulty/rhythm/data/same_rhythm_hit_object_grouping.rs index bda84426..d17e134d 100644 --- a/src/taiko/difficulty/rhythm/data/same_rhythm_hit_object_grouping.rs +++ b/src/taiko/difficulty/rhythm/data/same_rhythm_hit_object_grouping.rs @@ -1,9 +1,12 @@ use crate::{ - taiko::difficulty::object::TaikoDifficultyObject, - util::{ - interval_grouping::HasInterval, - sync::{RefCount, Weak}, + taiko::difficulty::{ + object::TaikoDifficultyObject, + utils::{ + delta_time_normalizer::DeltaTimeNormalizer, has_interval::HasInterval, + interval_grouping::IntervalGroupingUtils, + }, }, + util::sync::{RefCount, Weak}, }; #[derive(Debug)] @@ -17,36 +20,68 @@ pub struct SameRhythmHitObjectGrouping { } impl SameRhythmHitObjectGrouping { + const SNAP_TOLERANCE: f64 = IntervalGroupingUtils::MARGIN_OF_ERROR; + pub fn new( previous: Option>, hit_objects: Vec>, ) -> Self { - // * Calculate the average interval between hitobjects, or null if there are fewer than two - let hit_object_interval = if hit_objects.len() < 2 { - None + let upgraded_prev = upgraded_previous(previous.as_ref()); + + // * Cluster and normalise each hitobjects delta-time. + let normalized_hit_objects = + DeltaTimeNormalizer::normalize(&hit_objects, Self::SNAP_TOLERANCE); + + let normalized_hit_object_delta_time_count = hit_objects.len().saturating_sub(1); + + // * Secondary check to ensure there isn't any 'noise' or outliers by taking the modal delta time. + let modal_delta = hit_objects + .get(1) + .and_then(|h| normalized_hit_objects.get(h)) + .copied() + .map_or(0.0, f64::round_ties_even); + + // * Calculate the average interval between hitobjects. + let hit_object_interval = if normalized_hit_object_delta_time_count > 0 { + if let Some(previous_delta) = upgraded_prev + .as_ref() + .and_then(|rc| rc.get().hit_object_interval) + && f64::abs(modal_delta - previous_delta) <= Self::SNAP_TOLERANCE + { + Some(previous_delta) + } else { + Some(modal_delta) + } } else { - duration(&hit_objects).map(|duration| duration / (hit_objects.len() - 1) as f64) + None }; - let upgraded_prev = upgraded_previous(previous.as_ref()); - // * Calculate the ratio between this group's interval and the previous group's interval - let hit_object_interval_ratio = if let Some((prev, curr)) = upgraded_prev - .as_ref() - .and_then(|prev| prev.get().hit_object_interval) - .zip(hit_object_interval) + let hit_object_interval_ratio = if let Some((previous_interval, current_interval)) = + upgraded_prev + .as_ref() + .and_then(|rc| rc.get().hit_object_interval) + .zip(hit_object_interval) { - curr / prev + current_interval / previous_interval } else { 1.0 }; // * Calculate the interval from the previous group's start time - let interval = upgraded_prev + let interval = if let Some((prev, curr)) = upgraded_prev .as_ref() .and_then(|prev| prev.get().start_time()) .zip(start_time(&hit_objects)) - .map_or(f64::INFINITY, |(prev, curr)| curr - prev); + { + if f64::abs(curr - prev) <= Self::SNAP_TOLERANCE { + 0.0 + } else { + curr - prev + } + } else { + f64::INFINITY + }; Self { hit_objects, diff --git a/src/taiko/difficulty/rhythm/preprocessor.rs b/src/taiko/difficulty/rhythm/preprocessor.rs index 80b38006..2457d8a0 100644 --- a/src/taiko/difficulty/rhythm/preprocessor.rs +++ b/src/taiko/difficulty/rhythm/preprocessor.rs @@ -1,6 +1,9 @@ use crate::{ - taiko::difficulty::object::{TaikoDifficultyObject, TaikoDifficultyObjects}, - util::{interval_grouping::group_by_interval, sync::RefCount}, + taiko::difficulty::{ + object::{TaikoDifficultyObject, TaikoDifficultyObjects}, + utils::interval_grouping::group_by_interval, + }, + util::sync::RefCount, }; use super::data::{ diff --git a/src/taiko/difficulty/rhythm/rhythm_data.rs b/src/taiko/difficulty/rhythm/rhythm_data.rs index 85c28170..9a936c43 100644 --- a/src/taiko/difficulty/rhythm/rhythm_data.rs +++ b/src/taiko/difficulty/rhythm/rhythm_data.rs @@ -39,7 +39,7 @@ impl RhythmData { } } -#[allow(clippy::eq_op, reason = "keeping it in-sync with lazer")] +#[expect(clippy::eq_op, reason = "staying in-sync with lazer")] static COMMON_RATIOS: [f64; 9] = [ 1.0 / 1.0, 2.0 / 1.0, diff --git a/src/taiko/difficulty/skills/color.rs b/src/taiko/difficulty/skills/color.rs index ddc70b71..ab131cc5 100644 --- a/src/taiko/difficulty/skills/color.rs +++ b/src/taiko/difficulty/skills/color.rs @@ -1,17 +1,6 @@ -use std::f64::consts::E; - -use crate::{ - taiko::difficulty::{ - color::data::{ - alternating_mono_pattern::AlternatingMonoPattern, mono_streak::MonoStreak, - repeating_hit_patterns::RepeatingHitPatterns, - }, - object::{TaikoDifficultyObject, TaikoDifficultyObjects}, - }, - util::{ - difficulty::logistic_exp, - sync::{RefCount, Weak}, - }, +use crate::taiko::difficulty::{ + evaluators::ColorEvaluator, + object::{TaikoDifficultyObject, TaikoDifficultyObjects}, }; define_skill! { @@ -23,7 +12,7 @@ impl Color { const SKILL_MULTIPLIER: f64 = 0.12; const STRAIN_DECAY_BASE: f64 = 0.8; - #[allow(clippy::unused_self, reason = "required by `define_skill!` macro")] + #[expect(clippy::unused_self, reason = "required by `define_skill!` macro")] fn strain_value_of( &self, curr: &TaikoDifficultyObject, @@ -32,121 +21,3 @@ impl Color { ColorEvaluator::evaluate_difficulty_of(curr, objects) } } - -struct ColorEvaluator; - -impl ColorEvaluator { - fn consistent_ratio_penalty( - hit_object: &TaikoDifficultyObject, - objects: &TaikoDifficultyObjects, - threshold: Option, - max_objects_to_check: Option, - ) -> f64 { - let threshold = threshold.unwrap_or(0.01); - let max_objects_to_check = max_objects_to_check.unwrap_or(64); - - let curr = hit_object; - - let mut consistent_ratio_count = 0; - let mut total_ratio_count = 0.0; - - let prev_objects = - &objects.objects[curr.idx.saturating_sub(2 * max_objects_to_check)..=curr.idx]; - - for window in prev_objects.windows(3).rev().step_by(2) { - let [prev, _, curr] = window else { - unreachable!() - }; - - let curr = curr.get(); - let prev = prev.get(); - - let curr_ratio = curr.rhythm_data.ratio; - let prev_ratio = prev.rhythm_data.ratio; - - // * A consistent interval is defined as the percentage difference between the two rhythmic ratios with the margin of error. - if f64::abs(1.0 - curr_ratio / prev_ratio) <= threshold { - consistent_ratio_count += 1; - total_ratio_count += curr_ratio; - - break; - } - } - - // * Ensure no division by zero - 1.0 - total_ratio_count / f64::from(consistent_ratio_count + 1) * 0.8 - } - - fn evaluate_difficulty_of( - hit_object: &TaikoDifficultyObject, - objects: &TaikoDifficultyObjects, - ) -> f64 { - let color_data = &hit_object.color_data; - let mut difficulty = 0.0; - - if let Some(mono_streak) = color_data.mono_streak.as_ref().and_then(Weak::upgrade) - && let Some(first_hit_object) = mono_streak.get().first_hit_object() - && &*first_hit_object.get() == hit_object - { - difficulty += Self::eval_mono_streak_diff(&mono_streak); - } - - if let Some(alternating_mono_pattern) = color_data - .alternating_mono_pattern - .as_ref() - .and_then(Weak::upgrade) - && let Some(first_hit_object) = alternating_mono_pattern.get().first_hit_object() - && &*first_hit_object.get() == hit_object - { - difficulty += Self::eval_alternating_mono_pattern_diff(&alternating_mono_pattern); - } - - if let Some(repeating_hit_patterns) = color_data.repeating_hit_patterns.as_ref() - && let Some(first_hit_object) = repeating_hit_patterns.get().first_hit_object() - && &*first_hit_object.get() == hit_object - { - difficulty += Self::eval_repeating_hit_patterns_diff(repeating_hit_patterns); - } - - let consistency_penalty = Self::consistent_ratio_penalty(hit_object, objects, None, None); - difficulty *= consistency_penalty; - - difficulty - } - - fn eval_mono_streak_diff(mono_streak: &RefCount) -> f64 { - let mono_streak = mono_streak.get(); - - let parent_eval = mono_streak - .parent - .as_ref() - .and_then(Weak::upgrade) - .as_ref() - .map_or(1.0, Self::eval_alternating_mono_pattern_diff); - - logistic_exp(E * mono_streak.idx as f64 - 2.0 * E, None) * parent_eval * 0.5 - } - - fn eval_alternating_mono_pattern_diff( - alternating_mono_pattern: &RefCount, - ) -> f64 { - let alternating_mono_pattern = alternating_mono_pattern.get(); - - let parent_eval = alternating_mono_pattern - .parent - .as_ref() - .and_then(Weak::upgrade) - .as_ref() - .map_or(1.0, Self::eval_repeating_hit_patterns_diff); - - logistic_exp(E * alternating_mono_pattern.idx as f64 - 2.0 * E, None) * parent_eval - } - - fn eval_repeating_hit_patterns_diff( - repeating_hit_patterns: &RefCount, - ) -> f64 { - let repetition_interval = repeating_hit_patterns.get().repetition_interval as f64; - - 2.0 * (1.0 - logistic_exp(E * repetition_interval - 2.0 * E, None)) - } -} diff --git a/src/taiko/difficulty/skills/reading.rs b/src/taiko/difficulty/skills/reading.rs index 3c38661a..3cbf853e 100644 --- a/src/taiko/difficulty/skills/reading.rs +++ b/src/taiko/difficulty/skills/reading.rs @@ -1,5 +1,8 @@ use crate::{ - taiko::difficulty::object::{TaikoDifficultyObject, TaikoDifficultyObjects}, + taiko::difficulty::{ + evaluators::ReadingEvaluator, + object::{TaikoDifficultyObject, TaikoDifficultyObjects}, + }, util::{difficulty::logistic, sync::Weak}, }; @@ -42,60 +45,3 @@ impl Reading { self.current_strain } } - -struct ReadingEvaluator; - -impl ReadingEvaluator { - fn evaluate_diff_of(note_object: &TaikoDifficultyObject) -> f64 { - let high_velocity = VelocityRange::new(480.0, 640.0); - let mid_velocity = VelocityRange::new(360.0, 480.0); - - // * Apply a cap to prevent outlier values on maps that exceed the editor's parameters. - let effective_bpm = f64::max(1.0, note_object.effective_bpm); - - let mid_velocity_diff = 0.5 - * logistic( - effective_bpm, - mid_velocity.center(), - 1.0 / (mid_velocity.range() / 10.0), - None, - ); - - // * Expected DeltaTime is the DeltaTime this note would need to be spaced equally to a base slider velocity 1/4 note. - let expected_delta_time = 21_000.0 / effective_bpm; - let object_density = expected_delta_time / f64::max(1.0, note_object.delta_time); - - // * High density is penalised at high velocity as it is generally considered easier to read. - // * See https://www.desmos.com/calculator/u63f3ntdsi - let density_penalty = logistic(object_density, 0.925, 15.0, None); - - let high_velocity_diff = (1.0 - 0.33 * density_penalty) - * logistic( - effective_bpm, - high_velocity.center() + 8.0 * density_penalty, - (1.0 + 0.5 * density_penalty) / (high_velocity.range() / 10.0), - None, - ); - - mid_velocity_diff + high_velocity_diff - } -} - -struct VelocityRange { - min: f64, - max: f64, -} - -impl VelocityRange { - const fn new(min: f64, max: f64) -> Self { - Self { min, max } - } - - const fn center(&self) -> f64 { - (self.max + self.min) / 2.0 - } - - const fn range(&self) -> f64 { - self.max - self.min - } -} diff --git a/src/taiko/difficulty/skills/rhythm.rs b/src/taiko/difficulty/skills/rhythm.rs index 954dd34c..390fc12e 100644 --- a/src/taiko/difficulty/skills/rhythm.rs +++ b/src/taiko/difficulty/skills/rhythm.rs @@ -1,18 +1,11 @@ -use std::f64::consts::PI; - use crate::{ taiko::difficulty::{ + evaluators::{RhythmEvaluator, StaminaEvaluator}, object::{TaikoDifficultyObject, TaikoDifficultyObjects}, - rhythm::data::same_rhythm_hit_object_grouping::SameRhythmHitObjectGrouping, - }, - util::{ - difficulty::{bell_curve, logistic}, - sync::RefCount, }, + util::difficulty::logistic, }; -use super::stamina::StaminaEvaluator; - define_skill! { #[derive(Clone)] pub struct Rhythm: StrainDecaySkill => TaikoDifficultyObjects[TaikoDifficultyObject] { @@ -38,179 +31,3 @@ impl Rhythm { difficulty } } - -struct RhythmEvaluator; - -impl RhythmEvaluator { - fn evaluate_diff_of(hit_object: &TaikoDifficultyObject, hit_window: f64) -> f64 { - let rhythm_data = &hit_object.rhythm_data; - let mut difficulty = 0.0; - - let mut same_rhythm = 0.0; - let mut same_pattern = 0.0; - let mut interval_penalty = 0.0; - - // * Difficulty for SameRhythmGroupedHitObjects - if let Some(ref same_rhythm_grouped) = rhythm_data.same_rhythm_grouped_hit_objects - && same_rhythm_grouped - .get() - .first_hit_object() - .is_some_and(|h| &*h.get() == hit_object) - { - same_rhythm += 10.0 * Self::evaluate_diff_of_(same_rhythm_grouped, hit_window); - interval_penalty = - Self::repeated_interval_penalty(same_rhythm_grouped, hit_window, None); - } - - // * Difficulty for SamePatternsGroupedHitObjects - if let Some(ref same_pattern_grouped) = rhythm_data.same_patterns_grouped_hit_objects - && same_pattern_grouped - .get() - .first_hit_object() - .is_some_and(|h| &*h.get() == hit_object) - { - same_pattern += - 1.15 * Self::ratio_difficulty(same_pattern_grouped.get().interval_ratio(), None); - } - - difficulty += f64::max(same_rhythm, same_pattern) * interval_penalty; - - difficulty - } - - fn evaluate_diff_of_( - same_rhythm_grouped_hit_objects: &RefCount, - hit_window: f64, - ) -> f64 { - let mut interval_diff = Self::ratio_difficulty( - same_rhythm_grouped_hit_objects - .get() - .hit_object_interval_ratio, - None, - ); - let prev_interval = same_rhythm_grouped_hit_objects - .get() - .upgraded_previous() - .and_then(|h| h.get().hit_object_interval); - - interval_diff *= - Self::repeated_interval_penalty(same_rhythm_grouped_hit_objects, hit_window, None); - - let borrowed = same_rhythm_grouped_hit_objects.get(); - let duration = borrowed.duration(); - - // * If a previous interval exists and there are multiple hit objects in the sequence: - if let Some(prev_interval) = prev_interval.filter(|_| borrowed.hit_objects.len() > 1) - && let Some(duration) = duration - { - let expected_duration_from_prev = prev_interval * borrowed.hit_objects.len() as f64; - let duration_diff = duration - expected_duration_from_prev; - - if duration_diff > 0.0 { - interval_diff *= logistic(duration_diff / hit_window, 0.7, 1.0, Some(1.0)); - } - } - - // Penalise patterns that can be hit within a single hit window. - if let Some(duration) = duration { - interval_diff *= logistic(duration / hit_window, 0.6, 1.0, Some(1.0)); - } - - f64::powf(interval_diff, 0.75) - } - - fn repeated_interval_penalty( - same_rhythm_grouped_hit_objects: &RefCount, - hit_window: f64, - threshold: Option, - ) -> f64 { - let threshold = threshold.unwrap_or(0.1); - - let same_interval = - |start_object: RefCount, interval_count: usize| -> f64 { - let mut intervals = Vec::new(); - let mut curr_object = Some(start_object); - - let mut i = 0; - - while let Some(curr) = curr_object.filter(|_| i < interval_count) { - let curr = curr.get(); - - if let Some(interval) = curr.hit_object_interval { - intervals.push(interval); - } - - curr_object = curr.upgraded_previous(); - i += 1; - } - - if intervals.len() < interval_count { - return 1.0; // * No penalty if there aren't enough valid intervals. - } - - for i in 0..intervals.len() { - for j in i + 1..intervals.len() { - let ratio = intervals[i] / intervals[j]; - - // * If any two intervals are similar, apply a penalty. - if f64::abs(1.0 - ratio) <= threshold { - return 0.8; - } - } - } - - // * No penalty if all intervals are different. - 1.0 - }; - - let long_interval_penalty = - same_interval(RefCount::clone(same_rhythm_grouped_hit_objects), 3); - - let short_interval_penalty = if same_rhythm_grouped_hit_objects.get().hit_objects.len() < 6 - { - same_interval(RefCount::clone(same_rhythm_grouped_hit_objects), 4) - } else { - // * Returns a non-penalty if there are 6 or more notes within an interval. - 1.0 - }; - - // * The duration penalty is based on hit object duration relative to hitWindow. - let duration_penalty = same_rhythm_grouped_hit_objects - .get() - .duration() - .map_or(0.5, |duration| { - f64::max(1.0 - duration * 2.0 / hit_window, 0.5) - }); - - f64::min(long_interval_penalty, short_interval_penalty) * duration_penalty - } - - fn ratio_difficulty(mut ratio: f64, terms: Option) -> f64 { - let terms = terms.unwrap_or(8); - let mut difficulty = 0.0; - - // * Validate the ratio by ensuring it is a normal number in cases where maps breach regular mapping conditions. - ratio = if ratio.is_normal() { ratio } else { 0.0 }; - - for i in 1..=terms { - difficulty += Self::term_penalty(ratio, i, 4.0, 1.0); - } - - difficulty += f64::from(terms) / (1.0 + ratio); - - // * Give bonus to near-1 ratios - difficulty += bell_curve(ratio, 1.0, 0.5, None); - - // * Penalize ratios that are VERY near 1 - difficulty -= bell_curve(ratio, 1.0, 0.3, None); - - difficulty = f64::max(difficulty, 0.0); - difficulty /= f64::sqrt(8.0); - - difficulty - } - - fn term_penalty(ratio: f64, denominator: i32, power: f64, multiplier: f64) -> f64 { - -multiplier * f64::powf(f64::cos(f64::from(denominator) * PI * ratio), power) - } -} diff --git a/src/taiko/difficulty/skills/stamina.rs b/src/taiko/difficulty/skills/stamina.rs index 142bc38b..93e94a24 100644 --- a/src/taiko/difficulty/skills/stamina.rs +++ b/src/taiko/difficulty/skills/stamina.rs @@ -1,7 +1,13 @@ use crate::{ any::difficulty::{object::IDifficultyObject, skills::strain_decay}, - taiko::difficulty::object::{TaikoDifficultyObject, TaikoDifficultyObjects}, - util::{difficulty::logistic_exp, sync::Weak}, + taiko::difficulty::{ + evaluators::StaminaEvaluator, + object::{TaikoDifficultyObject, TaikoDifficultyObjects}, + }, + util::{ + difficulty::{logistic_exp, reverse_lerp}, + sync::Weak, + }, }; define_skill! { @@ -40,7 +46,7 @@ impl Stamina { objects: &TaikoDifficultyObjects, ) -> f64 { self.current_strain *= strain_decay(curr.delta_time, Self::STRAIN_DECAY_BASE); - self.current_strain += + let mut stamina_difficulty = StaminaEvaluator::evaluate_diff_of(curr, objects) * Self::SKILL_MULTIPLIER; // * Safely prevents previous strains from shifting as new notes are added. @@ -59,77 +65,25 @@ impl Stamina { }) .unwrap_or(0) as isize; - if self.single_color { - logistic_exp(-(index - 10) as f64 / 2.0, Some(self.current_strain)) - } else if self.is_convert { - self.current_strain + let mono_length_bonus = if self.is_convert { + 1.0 } else { - #[allow(clippy::manual_clamp)] - let monolength_bonus = 1.0 + f64::min(f64::max((index - 5) as f64 / 50.0, 0.0), 0.30); - - self.current_strain * monolength_bonus - } - } -} - -pub(super) struct StaminaEvaluator; - -impl StaminaEvaluator { - pub(super) fn evaluate_diff_of( - curr: &TaikoDifficultyObject, - objects: &TaikoDifficultyObjects, - ) -> f64 { - if !curr.base_hit_type.is_hit() { - return 0.0; - } - - // * Find the previous hit object hit by the current finger, which is n notes prior, n being the number of - // * available fingers. - let prev = curr.previous(1, objects); - let prev_mono = objects.previous_mono(curr, Self::available_fingers_for(curr, objects) - 1); - - // * Add a base strain to all objects - let mut object_strain = 0.5; - - let Some(prev) = prev else { - return object_strain; + 1.0 + 0.5 * reverse_lerp(index as f64, 5.0, 20.0) }; - if let Some(prev_mono) = prev_mono { - object_strain += Self::speed_bonus(curr.start_time - prev_mono.get().start_time) - + 0.5 * Self::speed_bonus(curr.start_time - prev.get().start_time); - } - - object_strain - } - - fn available_fingers_for( - hit_object: &TaikoDifficultyObject, - hit_objects: &TaikoDifficultyObjects, - ) -> usize { - let prev_color_change = hit_object.color_data.previous_color_change(hit_objects); - - if prev_color_change - .is_some_and(|change| hit_object.start_time - change.get().start_time < 300.0) - { - return 2; + // * Mono-streak bonus is only applied to colour-based stamina to reward longer sequences of same-colour hits within patterns. + if !self.single_color { + stamina_difficulty *= mono_length_bonus; } - let next_color_change = hit_object.color_data.next_color_change(hit_objects); + self.current_strain += stamina_difficulty; - if next_color_change - .is_some_and(|change| change.get().start_time - hit_object.start_time < 300.0) - { - return 2; + // * For converted maps, difficulty often comes entirely from long mono streams with no colour variation. + // * To avoid over-rewarding these maps based purely on stamina strain, we dampen the strain value once the index exceeds 10. + if self.single_color { + logistic_exp(-(index - 10) as f64 / 2.0, Some(self.current_strain)) + } else { + self.current_strain } - - 8 - } - - fn speed_bonus(mut interval: f64) -> f64 { - // * Interval is capped at a very small value to prevent infinite values. - interval = f64::max(interval, 1.0); - - 20.0 / interval } } diff --git a/src/taiko/difficulty/utils/delta_time_normalizer.rs b/src/taiko/difficulty/utils/delta_time_normalizer.rs new file mode 100644 index 00000000..cc32d713 --- /dev/null +++ b/src/taiko/difficulty/utils/delta_time_normalizer.rs @@ -0,0 +1,87 @@ +use std::collections::HashMap; + +use crate::{taiko::difficulty::object::TaikoDifficultyObject, util::sync::Weak}; + +pub struct DeltaTimeNormalizer; + +impl DeltaTimeNormalizer { + pub fn normalize( + hit_objects: &[Weak], + margin_of_error: f64, + ) -> HashMap, f64> { + let delta_times = { + let mut distinct_and_ordered: Vec = Vec::new(); + + for h in hit_objects.iter() { + let Some(h) = h.upgrade() else { continue }; + let delta_time = h.get().delta_time; + + if let Err(idx) = + distinct_and_ordered.binary_search_by(|probe| probe.total_cmp(&delta_time)) + { + distinct_and_ordered.insert(idx, delta_time); + } + } + + distinct_and_ordered + }; + + let mut sets = Vec::new(); + let mut delta_times_iter = delta_times.into_iter(); + + if let Some(value) = delta_times_iter.next() { + let mut curr = vec![value]; + + for value in delta_times_iter { + // * Add to the current group if within margin of error + if f64::abs(value - curr[0]) <= margin_of_error { + curr.push(value); + + continue; + } + + sets.push(curr); + + // * Otherwise begin a new group + curr = vec![value]; + } + + sets.push(curr); + } + + // * Compute median for each group + let mut median_lookup = HashMap::new(); + + for mut set in sets { + set.sort_by(f64::total_cmp); + let mid = set.len() / 2; + + let median = if set.len() % 2 == 1 { + set[mid] + } else { + (set[mid - 1] + set[mid]) / 2.0 + }; + + for v in set { + median_lookup.insert(v.to_bits(), median); + } + } + + let mut output = HashMap::with_capacity(hit_objects.len()); + + let iter = hit_objects.iter().filter_map(|h| { + let delta_time = h.upgrade()?.get().delta_time; + + let value = median_lookup + .get(&delta_time.to_bits()) + .copied() + .unwrap_or(delta_time); + + Some((Weak::clone(h), value)) + }); + + output.extend(iter); + + output + } +} diff --git a/src/taiko/difficulty/utils/has_interval.rs b/src/taiko/difficulty/utils/has_interval.rs new file mode 100644 index 00000000..83f0f00f --- /dev/null +++ b/src/taiko/difficulty/utils/has_interval.rs @@ -0,0 +1,3 @@ +pub trait HasInterval { + fn interval(&self) -> f64; +} diff --git a/src/util/interval_grouping.rs b/src/taiko/difficulty/utils/interval_grouping.rs similarity index 86% rename from src/util/interval_grouping.rs rename to src/taiko/difficulty/utils/interval_grouping.rs index 9a92b7b0..5c677367 100644 --- a/src/util/interval_grouping.rs +++ b/src/taiko/difficulty/utils/interval_grouping.rs @@ -1,9 +1,16 @@ -use crate::util::float_ext::FloatExt; - -use super::sync::{RefCount, Weak}; - -pub trait HasInterval { - fn interval(&self) -> f64; +use crate::{ + taiko::difficulty::utils::has_interval::HasInterval, + util::{ + float_ext::FloatExt, + sync::{RefCount, Weak}, + }, +}; + +pub struct IntervalGroupingUtils; + +impl IntervalGroupingUtils { + // * The margin of error when comparing intervals for grouping, or snapping intervals to a common value. + pub const MARGIN_OF_ERROR: f64 = 5.0; } pub const fn group_by_interval( @@ -44,7 +51,7 @@ impl Iterator for GroupedByIntervalIter<'_, T> { impl GroupedByIntervalIter<'_, T> { fn create_next_group(&mut self) -> Vec> { - const MARGIN_OF_ERROR: f64 = 5.0; + const MARGIN_OF_ERROR: f64 = IntervalGroupingUtils::MARGIN_OF_ERROR; let &mut Self { objects, ref mut i } = self; diff --git a/src/taiko/difficulty/utils/mod.rs b/src/taiko/difficulty/utils/mod.rs new file mode 100644 index 00000000..e638479f --- /dev/null +++ b/src/taiko/difficulty/utils/mod.rs @@ -0,0 +1,3 @@ +pub mod delta_time_normalizer; +pub mod has_interval; +pub mod interval_grouping; diff --git a/src/taiko/performance/calculator.rs b/src/taiko/performance/calculator.rs index 74009a67..9afd3795 100644 --- a/src/taiko/performance/calculator.rs +++ b/src/taiko/performance/calculator.rs @@ -1,13 +1,17 @@ use crate::{ GameMods, taiko::{TaikoDifficultyAttributes, TaikoPerformanceAttributes, TaikoScoreState}, - util::special_functions::{erf, erf_inv}, + util::{ + difficulty::{erf, erf_inv, logistic, reverse_lerp}, + float_ext::FloatExt, + }, }; pub(super) struct TaikoPerformanceCalculator<'mods> { attrs: TaikoDifficultyAttributes, mods: &'mods GameMods, state: TaikoScoreState, + is_classic: bool, } impl<'a> TaikoPerformanceCalculator<'a> { @@ -15,63 +19,84 @@ impl<'a> TaikoPerformanceCalculator<'a> { attrs: TaikoDifficultyAttributes, mods: &'a GameMods, state: TaikoScoreState, + is_classic: bool, ) -> Self { - Self { attrs, mods, state } + Self { + attrs, + mods, + state, + is_classic, + } } } impl TaikoPerformanceCalculator<'_> { pub fn calculate(self) -> TaikoPerformanceAttributes { - // * The effectiveMissCount is calculated by gaining a ratio for totalSuccessfulHits - // * and increasing the miss penalty for shorter object counts lower than 1000. - let total_successful_hits = self.total_successful_hits(); - - let estimated_unstable_rate = self.compute_deviation_upper_bound().map(|v| v * 10.0); - - let effective_miss_count = if total_successful_hits > 0 { - (1000.0 / f64::from(total_successful_hits)).max(1.0) - * f64::from(self.state.hitresults.misses) - } else { - 0.0 - }; - - let mut multiplier = 1.13; - - if self.mods.hd() && !self.attrs.is_convert { - multiplier *= 1.075; - } - - if self.mods.ez() { - multiplier *= 0.95; - } - - let diff_value = - self.compute_difficulty_value(effective_miss_count, estimated_unstable_rate); - let acc_value = self.compute_accuracy_value(estimated_unstable_rate); - - let pp = (diff_value.powf(1.1) + acc_value.powf(1.1)).powf(1.0 / 1.1) * multiplier; + let estimated_unstable_rate = + if self.state.hitresults.n300 == 0 || self.attrs.great_hit_window <= 0.0 { + None + } else { + Some( + self.compute_deviation_upper_bound( + f64::from(self.state.hitresults.n300) / self.total_hits(), + ) * 10.0, + ) + }; + + // * Total difficult hits measures the total difficulty of a map based on its consistency factor. + let total_difficult_hits = self.total_hits() * self.attrs.consistency_factor; + + let difficulty_value = + self.compute_difficulty_value(total_difficult_hits, estimated_unstable_rate) * 1.08; + let accuracy_value = + self.compute_accuracy_value(total_difficult_hits, estimated_unstable_rate) * 1.1; + + let pp = difficulty_value + accuracy_value; TaikoPerformanceAttributes { difficulty: self.attrs, pp, - pp_acc: acc_value, - pp_difficulty: diff_value, - effective_miss_count, + pp_acc: accuracy_value, + pp_difficulty: difficulty_value, estimated_unstable_rate, } } fn compute_difficulty_value( &self, - effective_miss_count: f64, + total_difficult_hits: f64, estimated_unstable_rate: Option, ) -> f64 { let Some(estimated_unstable_rate) = estimated_unstable_rate else { return 0.0; }; + if FloatExt::eq(total_difficult_hits, 0.0) { + return 0.0; + } + let attrs = &self.attrs; - let base_difficulty = 5.0 * f64::max(1.0, attrs.stars / 0.110) - 4.0; + + // * The estimated unstable rate for 100% accuracy, at which all rhythm difficulty has been played successfully. + let rhythm_expected_unstable_rate = self.compute_deviation_upper_bound(1.0) * 10.0; + + // * The unstable rate at which it can be assumed all rhythm difficulty has been ignored. + // * 0.8 represents 80% of total hits being greats, or 90% accuracy in-game + let rhythm_maximum_unstable_rate = self.compute_deviation_upper_bound(0.8) * 10.0; + + // * The fraction of star rating made up by rhythm difficulty, normalised to represent rhythm's perceived contribution to star rating. + let rhythm_factor = reverse_lerp(attrs.rhythm / attrs.stars, 0.15, 0.4); + + // * A penalty removing improperly played rhythm difficulty from star rating based on estimated unstable rate. + let rhythm_penalty = 1.0 + - logistic( + estimated_unstable_rate, + (rhythm_expected_unstable_rate + rhythm_maximum_unstable_rate) / 2.0, + 10.0 / (rhythm_maximum_unstable_rate - rhythm_expected_unstable_rate), + Some(0.25 * f64::powf(rhythm_factor, 3.0)), + ); + + let base_difficulty = 5.0 * f64::max(1.0, attrs.stars * rhythm_penalty / 0.11) - 4.0; let mut difficulty_value = f64::min( f64::powf(base_difficulty, 3.0) / 69052.51, @@ -80,17 +105,31 @@ impl TaikoPerformanceCalculator<'_> { difficulty_value *= 1.0 + 0.10 * f64::max(0.0, self.attrs.stars - 10.0); - let length_bonus = 1.0 + 0.1 * f64::min(1.0, f64::from(attrs.max_combo) / 1500.0); + // * Applies a bonus to maps with more total difficulty. + let length_bonus = 1.0 + 0.25 * total_difficult_hits / (total_difficult_hits + 4000.0); difficulty_value *= length_bonus; - difficulty_value *= f64::powf(0.986, effective_miss_count); - - if self.mods.ez() { - difficulty_value *= 0.9; - } + // * Scales miss penalty by the total difficult hits of a map, making misses more punishing on maps with less total difficulty. + let miss_penalty = 0.97 + 0.03 * total_difficult_hits / (total_difficult_hits + 1500.0); + difficulty_value *= f64::powf(miss_penalty, f64::from(self.state.hitresults.misses)); if self.mods.hd() { - difficulty_value *= 1.025; + let mut hidden_bonus = if self.attrs.is_convert { 0.025 } else { 0.1 }; + + // * Hidden+flashlight plays are excluded from reading-based penalties to hidden. + if !self.mods.fl() { + // * A penalty is applied to the bonus for hidden on non-classic scores, as the playfield can be made wider to make fast reading easier. + if !self.is_classic { + hidden_bonus *= 0.2; + } + + // * A penalty is applied to classic easy+hidden scores, as notes disappear later making fast reading easier. + if self.mods.ez() && self.is_classic { + hidden_bonus *= 0.5; + } + } + + difficulty_value *= 1.0 + hidden_bonus; } if self.mods.fl() { @@ -101,68 +140,74 @@ impl TaikoPerformanceCalculator<'_> { } // * Scale accuracy more harshly on nearly-completely mono (single coloured) speed maps. - let acc_scaling_exp = f64::from(2) + self.attrs.mono_stamina_factor; - let acc_scaling_shift = + let mono_acc_scaling_exponent = f64::from(2) + self.attrs.mono_stamina_factor; + let mono_acc_scaling_shift = f64::from(500) - f64::from(100) * (self.attrs.mono_stamina_factor * f64::from(3)); difficulty_value - * (erf(acc_scaling_shift / (f64::sqrt(2.0) * estimated_unstable_rate))) - .powf(acc_scaling_exp) + * (erf(mono_acc_scaling_shift / (f64::sqrt(2.0) * estimated_unstable_rate))) + .powf(mono_acc_scaling_exponent) } - fn compute_accuracy_value(&self, estimated_unstable_rate: Option) -> f64 { + fn compute_accuracy_value( + &self, + total_difficult_hits: f64, + estimated_unstable_rate: Option, + ) -> f64 { + let Some(estimated_unstable_rate) = estimated_unstable_rate else { + return 0.0; + }; + if self.attrs.great_hit_window <= 0.0 { return 0.0; } - let Some(estimated_unstable_rate) = estimated_unstable_rate else { - return 0.0; - }; + let mut accuracy_value = 470.0 * f64::powf(0.9885, estimated_unstable_rate); + + // * Scales up the bonus for lower unstable rate as star rating increases. + accuracy_value *= 1.0 + + f64::powf(50.0 / estimated_unstable_rate, 2.0) * f64::powf(self.attrs.stars, 2.8) + / 600.0; + + if self.mods.hd() && !self.attrs.is_convert { + accuracy_value *= 1.075; + } - let mut acc_value = f64::powf(70.0 / estimated_unstable_rate, 1.1) - * f64::powf(self.attrs.stars, 0.4) - * 100.0; + // * Applies a bonus to maps with more total difficulty, calculating this with a map's total hits and consistency factor. + accuracy_value *= 1.0 + 0.3 * total_difficult_hits / (total_difficult_hits + 4000.0); - let length_bonus = f64::min(1.15, f64::powf(self.total_hits() / 1500.0, 0.3)); + // * Applies a bonus to maps with more total memory required with HDFL. + let memory_length_bonus = f64::min(1.15, f64::powf(self.total_hits() / 1500.0, 0.3)); - // * Slight HDFL Bonus for accuracy. A clamp is used to prevent against negative values. - if self.mods.hd() && self.mods.fl() && !self.attrs.is_convert { - acc_value *= f64::max(1.0, 1.05 * length_bonus); + if self.mods.fl() && self.mods.hd() && !self.attrs.is_convert { + accuracy_value *= f64::max(1.0, 1.05 * memory_length_bonus); } - acc_value + accuracy_value } // * Computes an upper bound on the player's tap deviation based on the OD, number of circles and sliders, // * and the hit judgements, assuming the player's mean hit error is 0. The estimation is consistent in that // * two SS scores on the same map with the same settings will always return the same deviation. - fn compute_deviation_upper_bound(&self) -> Option { - if self.state.hitresults.n300 == 0 || self.attrs.great_hit_window <= 0.0 { - return None; - } - - #[allow(clippy::items_after_statements, clippy::unreadable_literal)] + fn compute_deviation_upper_bound(&self, accuracy: f64) -> f64 { + #[expect(clippy::unreadable_literal, reason = "staying in-sync with lazer")] // * 99% critical value for the normal distribution (one-tailed). const Z: f64 = 2.32634787404; let n = self.total_hits(); // * Proportion of greats hit. - let p = f64::from(self.state.hitresults.n300) / n; + let p = accuracy; // * We can be 99% confident that p is at least this value. let p_lower_bound = (n * p + Z * Z / 2.0) / (n + Z * Z) - Z / (n + Z * Z) * f64::sqrt(n * p * (1.0 - p) + Z * Z / 4.0); // * We can be 99% confident that the deviation is not higher than: - Some(self.attrs.great_hit_window / (f64::sqrt(2.0) * erf_inv(p_lower_bound))) + self.attrs.great_hit_window / (f64::sqrt(2.0) * erf_inv(p_lower_bound)) } const fn total_hits(&self) -> f64 { self.state.hitresults.total_hits() as f64 } - - const fn total_successful_hits(&self) -> u32 { - self.state.hitresults.n300 + self.state.hitresults.n100 - } } diff --git a/src/taiko/performance/gradual.rs b/src/taiko/performance/gradual.rs index 6e9e581e..cc4103a2 100644 --- a/src/taiko/performance/gradual.rs +++ b/src/taiko/performance/gradual.rs @@ -106,7 +106,7 @@ impl TaikoGradualPerformance { /// /// Note that the count is zero-indexed, so `n=0` will process 1 object, /// `n=1` will process 2, and so on. - #[allow(clippy::missing_panics_doc, reason = "technically false positive")] + #[expect(clippy::missing_panics_doc, reason = "unreachable")] pub fn nth(&mut self, state: TaikoScoreState, n: usize) -> Option { let performance = self .difficulty @@ -122,7 +122,7 @@ impl TaikoGradualPerformance { } /// Returns the amount of remaining objects. - #[allow(clippy::len_without_is_empty)] + #[expect(clippy::len_without_is_empty, reason = "TODO")] pub fn len(&self) -> usize { self.difficulty.len() } @@ -162,12 +162,12 @@ mod tests { if i % 2 == 0 { let next_gradual_2nd = gradual_2nd.nth(state, 1).unwrap(); - assert_eq!(next_gradual, next_gradual_2nd); + assert_eq_attrs(&next_gradual, &next_gradual_2nd); } if i % 3 == 0 { let next_gradual_3rd = gradual_3rd.nth(state, 2).unwrap(); - assert_eq!(next_gradual, next_gradual_3rd); + assert_eq_attrs(&next_gradual, &next_gradual_3rd); } let mut regular_calc = TaikoPerformance::new(&map) @@ -180,7 +180,28 @@ mod tests { let expected = regular_calc.calculate().unwrap(); - assert_eq!(next_gradual, expected); + assert_eq_attrs(&next_gradual, &expected); } } + + /// Asserts equality for two [`TaikoDifficultyAttributes`] instances. + /// + /// `NaN` values are considered to be equal. + #[track_caller] + fn assert_eq_attrs(a: &TaikoPerformanceAttributes, b: &TaikoPerformanceAttributes) { + let TaikoPerformanceAttributes { + difficulty, + pp, + pp_acc, + pp_difficulty, + estimated_unstable_rate, + } = a; + + crate::taiko::attributes::tests::assert_eq_attrs(difficulty, &b.difficulty); + + assert_eq!(*pp, b.pp); + assert_eq!(*pp_acc, b.pp_acc); + assert_eq!(*pp_difficulty, b.pp_difficulty); + assert_eq!(*estimated_unstable_rate, b.estimated_unstable_rate); + } } diff --git a/src/taiko/performance/hitresult_generator/fast.rs b/src/taiko/performance/hitresult_generator/fast.rs index ee694d01..ad16b7f9 100644 --- a/src/taiko/performance/hitresult_generator/fast.rs +++ b/src/taiko/performance/hitresult_generator/fast.rs @@ -50,7 +50,7 @@ impl HitResultGenerator for Fast { // Simplify by multiplying by total_hits: // acc * (2*total_hits) = 2*n300 + n100 - let target_total = f64::round(acc * f64::from(2 * total_hits)) as u32; + let target_total = f64::round_ties_even(acc * f64::from(2 * total_hits)) as u32; // Start by assuming every non-miss is an n100 // delta is how much we need to increase from the baseline (all n100s) diff --git a/src/taiko/performance/mod.rs b/src/taiko/performance/mod.rs index f0adb221..01140bf2 100644 --- a/src/taiko/performance/mod.rs +++ b/src/taiko/performance/mod.rs @@ -315,7 +315,12 @@ impl<'map> TaikoPerformance<'map> { MapOrAttrs::Map(ref map) => self.difficulty.calculate_for_mode::(map)?, }; - Ok(TaikoPerformanceCalculator::new(attrs, self.difficulty.get_mods(), state).calculate()) + let is_classic = !self.difficulty.get_lazer() || self.difficulty.get_mods().cl(); + + let calculator = + TaikoPerformanceCalculator::new(attrs, self.difficulty.get_mods(), state, is_classic); + + Ok(calculator.calculate()) } pub(crate) const fn from_map_or_attrs(map_or_attrs: MapOrAttrs<'map, Taiko>) -> Self { @@ -367,6 +372,7 @@ impl<'map> TryFrom> for TaikoPerformance<'map> { misses, hitresult_priority, hitresult_generator: _, + legacy_total_score: _, } = osu; Ok(Self { diff --git a/src/taiko/strains.rs b/src/taiko/strains.rs index 303f090d..e5f798ce 100644 --- a/src/taiko/strains.rs +++ b/src/taiko/strains.rs @@ -35,8 +35,10 @@ pub fn strains(difficulty: &Difficulty, map: &Beatmap) -> Result) -> f64 { 60_000.0 / i32_unwrap_or(delimiter, 4) as f64 / bpm @@ -38,6 +40,19 @@ pub fn bell_curve(x: f64, mean: f64, width: f64, multiplier: Option) -> f64 multiplier.unwrap_or(1.0) * f64::exp(E * -(f64::powf(x - mean, 2.0) / f64::powf(width, 2.0))) } +pub fn smoothstep_bell_curve(x: f64, mean: f64, width: f64) -> f64 { + let mut new_x = x; + + new_x -= mean; + new_x = if new_x > 0.0 { + width - new_x + } else { + width + new_x + }; + + smoothstep(new_x, 0.0, width) +} + pub const fn smoothstep(x: f64, start: f64, end: f64) -> f64 { let x = reverse_lerp(x, start, end); @@ -53,3 +68,69 @@ pub const fn smootherstep(x: f64, start: f64, end: f64) -> f64 { pub const fn reverse_lerp(x: f64, start: f64, end: f64) -> f64 { f64::clamp((x - start) / (end - start), 0.0, 1.0) } + +pub fn erf(x: f64) -> f64 { + #![expect(clippy::unreadable_literal, reason = "staying in-sync with lazer")] + + if FloatExt::eq(x, 0.0) { + return 0.0; + } + + if x.is_infinite() { + if x.is_sign_positive() { + return 1.0; + } + + if x.is_sign_negative() { + return -1.0; + } + } + + if x.is_nan() { + return f64::NAN; + } + + // * Constants for approximation (Abramowitz and Stegun formula 7.1.26) + let t = 1.0 / (1.0 + 0.3275911 * f64::abs(x)); + + let tau = t + * (0.254829592 + + t * (-0.284496736 + t * (1.421413741 + t * (-1.453152027 + t * 1.061405429)))); + + let erf = 1.0 - tau * f64::exp(-x * x); + + if x >= 0.0 { erf } else { -erf } +} + +pub fn erf_inv(mut x: f64) -> f64 { + if x <= -1.0 { + return f64::NEG_INFINITY; + } + + if x >= 1.0 { + return f64::INFINITY; + } + + if FloatExt::eq(x, 0.0) { + return 0.0; + } + + #[expect(clippy::items_after_statements, reason = "staying in-sync with lazer")] + const A: f64 = 0.147; + let sgn = f64::signum(x); + x = f64::abs(x); + + let ln = f64::ln(1.0 - x * x); + let t1 = 2.0 / (PI * A) + ln / 2.0; + let t2 = ln / A; + let base_approx = f64::sqrt(t1 * t1 - t2) - t1; + + // * Correction reduces max error from -0.005 to -0.00045. + let c = if x >= 0.85 { + f64::powf((x - 0.85) / 0.293, 8.0) + } else { + 0.0 + }; + + sgn * (f64::sqrt(base_approx) + c) +} diff --git a/src/util/hint.rs b/src/util/hint.rs index 8a59ddbf..2c324e39 100644 --- a/src/util/hint.rs +++ b/src/util/hint.rs @@ -1,10 +1,11 @@ +#![allow(unused, reason = "useful regardless")] + #[inline] #[cold] const fn cold() {} /// Hints at the compiler that the condition is likely `true`. #[inline] -#[allow(unused)] pub const fn likely(b: bool) -> bool { if !b { cold(); @@ -15,7 +16,6 @@ pub const fn likely(b: bool) -> bool { /// Hints at the compiler that the condition is likely `false`. #[inline] -#[allow(unused)] pub const fn unlikely(b: bool) -> bool { if b { cold(); diff --git a/src/util/macros.rs b/src/util/macros.rs index 0f0cd503..99118b50 100644 --- a/src/util/macros.rs +++ b/src/util/macros.rs @@ -27,11 +27,7 @@ macro_rules! define_skill { } $_new_vis:vis fn new( $( $arg_name:ident: $arg_type:ty ),* ) -> Self { - $( { $( $setup:tt )* } )? - - Self { - $( $assign_name:ident: $assign_expr:expr, )* - } + $( $body:tt )* } ) => { define_skill! { @@ -40,9 +36,64 @@ macro_rules! define_skill { fields { $( $field_name $field_type |, )* } struct { $( #[$meta] )* $vis $skill } new { - setup { $( $( $setup )* )? } + setup_body { $( $body )* } + setup {} args { $( $arg_name $arg_type, )* } - assigns { $( $assign_name $assign_expr, )* } + } + } + }; + + // Processing `new` function: Found the final `Self` return value + ( + @$trait:ident $objects:ty[$object:ty] + extend_fields $extend_fields:ident + fields { $( $fields:tt )* } + struct { $( $struct:tt )* } + new { + setup_body { + Self { $( $assign_name:ident: $assign_expr:expr, )* } // <- + } + setup { $( $setup:tt )* } + args { $( $args:tt )* } + } + ) => { + define_skill! { + @$trait $objects[$object] + extend_fields $trait + fields { $( $fields )* } + struct { $( $struct )* } + new { + setup { $( $setup )* } + args { $( $args )* } + assigns { $( $assign_name $assign_expr, )* } // <- + } + } + }; + + // Processing `new` function: Pop next statement from body and continue + ( + @$trait:ident $objects:ty[$object:ty] + extend_fields $extend_fields:ident + fields { $( $fields:tt )* } + struct { $( $struct:tt )* } + new { + setup_body { + $stmt:stmt; // <- + $( $rest:tt )+ + } + setup { $( $setup:tt )* } + args { $( $args:tt )* } + } + ) => { + define_skill! { + @$trait $objects[$object] + extend_fields $trait + fields { $( $fields )* } + struct { $( $struct )* } + new { + setup_body { $( $rest )* } // <- + setup { $( $setup )* $stmt } // <- + args { $( $args )* } } } }; @@ -167,7 +218,7 @@ macro_rules! define_skill { $( $field_name:ident $field_type:ty, )* } new { - setup { $( $setup:tt )* } + setup { $( $setup:stmt )* } args { $( $arg_name:ident $arg_type:ty, )* } assigns { $( $assign_name:ident $( $assign_expr:expr )?, )* } } @@ -178,7 +229,6 @@ macro_rules! define_skill { } impl $name { - #[allow(unused)] $vis fn new( $( $arg_name: $arg_type, )* ) -> Self { @@ -191,7 +241,7 @@ macro_rules! define_skill { } const _: () = { - #[allow(unused_imports)] + #[expect(unused_imports, reason = "fine for macros")] use crate::{ any::difficulty::{ object::{IDifficultyObject, IDifficultyObjects, HasStartTime}, @@ -241,6 +291,10 @@ macro_rules! define_skill { self.strain_skill_object_strains.push(strain); } + fn object_strains(&self) -> &[f64] { + &self.strain_skill_object_strains + } + fn count_top_weighted_strains(&self, difficulty_value: f64) -> f64 { crate::any::difficulty::skills::count_top_weighted_strains( &self.strain_skill_object_strains, diff --git a/src/util/map_or_attrs.rs b/src/util/map_or_attrs.rs index 7b6195e5..d7c472ee 100644 --- a/src/util/map_or_attrs.rs +++ b/src/util/map_or_attrs.rs @@ -33,7 +33,7 @@ impl MapOrAttrs<'_, M> { pub const unsafe fn get_attrs(&self) -> &M::DifficultyAttributes { // Returning an immutable reference while requiring a mutable reference // as argument, unfortunately, makes it impossible to pass another - // mutable reference later on so instead we split it up into two + // mutable reference later on. Instead we split it up into two // functions: first `insert_attrs` and then `get_attrs`. match self { Self::Attrs(attrs) => attrs, @@ -50,7 +50,7 @@ where { fn clone(&self) -> Self { match self { - Self::Map(converted) => Self::Map(converted.clone()), + Self::Map(map) => Self::Map(map.clone()), Self::Attrs(attrs) => Self::Attrs(attrs.clone()), } } @@ -62,8 +62,54 @@ where M::DifficultyAttributes: Debug, { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + /// Provides a more reasonable Debug implementation for [`Beatmap`] by + /// not printing list items in full, but rather their lengths. + struct SlimBeatmap<'a>(&'a Beatmap); + + impl Debug for SlimBeatmap<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + let Beatmap { + version, + is_convert, + stack_leniency, + mode, + ar, + cs, + hp, + od, + slider_multiplier, + slider_tick_rate, + breaks, + timing_points, + difficulty_points, + effect_points, + hit_objects, + hit_sounds, + } = self.0; + + f.debug_struct("Beatmap") + .field("version", version) + .field("is_convert", is_convert) + .field("stack_leniency", stack_leniency) + .field("mode", mode) + .field("ar", ar) + .field("cs", cs) + .field("hp", hp) + .field("od", od) + .field("slider_multiplier", slider_multiplier) + .field("slider_tick_rate", slider_tick_rate) + .field("breaks", &breaks.len()) + .field("timing_points", &timing_points.len()) + .field("difficulty_points", &difficulty_points.len()) + .field("effect_points", &effect_points.len()) + .field("hit_objects", &hit_objects.len()) + .field("hit_sounds", &hit_sounds.len()) + .finish() + } + } + match self { - Self::Map(converted) => f.debug_tuple("Map").field(converted).finish(), + Self::Map(map) => f.debug_tuple("Map").field(&SlimBeatmap(map)).finish(), Self::Attrs(attrs) => f.debug_tuple("Attrs").field(attrs).finish(), } } diff --git a/src/util/mod.rs b/src/util/mod.rs index 9efcbabd..b4f7a216 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,12 +1,11 @@ pub mod difficulty; pub mod float_ext; pub mod hint; -pub mod interval_grouping; pub mod limited_queue; pub mod map_or_attrs; pub mod random; +pub mod ruleset_ext; pub mod sort; -pub mod special_functions; pub mod strains_vec; pub mod sync; diff --git a/src/util/ruleset_ext.rs b/src/util/ruleset_ext.rs new file mode 100644 index 00000000..b97c773b --- /dev/null +++ b/src/util/ruleset_ext.rs @@ -0,0 +1,45 @@ +use crate::model::beatmap::BeatmapAttributes; + +pub fn calculate_difficulty_peppy_stars( + map_attrs: &BeatmapAttributes, + object_count: i32, + drain_len: i32, +) -> i32 { + /* + * WARNING: DO NOT TOUCH IF YOU DO NOT KNOW WHAT YOU ARE DOING + * + * It so happens that in stable, due to .NET Framework internals, float math would be performed + * using x87 registers and opcodes. + * .NET (Core) however uses SSE instructions on 32- and 64-bit words. + * x87 registers are _80 bits_ wide. Which is notably wider than _both_ float and double. + * Therefore, on a significant number of beatmaps, the rounding would not produce correct values. + * + * Thus, to crudely - but, seemingly *mostly* accurately, after checking across all ranked maps - emulate this, + * use `decimal`, which is slow, but has bigger precision than `double`. + * At the time of writing, there is _one_ ranked exception to this - namely https://osu.ppy.sh/beatmapsets/1156087#osu/2625853 - + * but it is considered an "acceptable casualty", since in that case scores aren't inflated by _that_ much compared to others. + */ + + // NOTE: we use f64 instead of C#'s decimal type for simplicity reasons and + // sacrifice precision while doing so + + let object_to_drain_ratio = if drain_len != 0 { + (f64::from(object_count) / f64::from(drain_len) * 8.0).clamp(0.0, 16.0) + } else { + 16.0 + }; + + /* + * Notably, THE `double` CASTS BELOW ARE IMPORTANT AND MUST REMAIN. + * Their goal is to trick the compiler / runtime into NOT promoting from single-precision float, as doing so would prompt it + * to attempt to "silently" fix the single-precision values when converting to decimal, + * which is NOT what the x87 FPU does. + */ + + let drain_rate = f64::from(map_attrs.hp()); + let overall_difficulty = f64::from(map_attrs.od()); + let circle_size = f64::from(map_attrs.cs()); + + ((drain_rate + overall_difficulty + circle_size + object_to_drain_ratio) / 38.0 * 5.0) + .round_ties_even() as i32 +} diff --git a/src/util/sort/csharp.rs b/src/util/sort/csharp.rs index 4bf17baf..7b6b2665 100644 --- a/src/util/sort/csharp.rs +++ b/src/util/sort/csharp.rs @@ -3,7 +3,7 @@ use std::cmp::Ordering; /// C#'s unstable sorting algorithm. /// /// -pub fn sort(keys: &mut [T], cmp: F) +pub fn sort_unstable_by(keys: &mut [T], cmp: F) where F: Fn(&T, &T) -> Ordering, { diff --git a/src/util/sort/mod.rs b/src/util/sort/mod.rs index 16fd6fdb..1170b082 100644 --- a/src/util/sort/mod.rs +++ b/src/util/sort/mod.rs @@ -1,39 +1,43 @@ use std::cmp::Ordering; -pub use self::{csharp::sort as csharp, osu_legacy::sort as osu_legacy, tandem::TandemSorter}; +pub use self::{ + csharp::sort_unstable_by as csharp, osu_legacy::sort_unstable_by as osu_legacy, + tandem::TandemSorter, +}; mod csharp; mod osu_legacy; mod tandem; -fn heap_sort(keys: &mut [T], lo: usize, hi: usize, cmp: &F) +fn heap_sort(keys: &mut [T], lo: usize, hi: usize, comparer: &F) where F: Fn(&T, &T) -> Ordering, { let n = hi - lo + 1; for i in (1..=n / 2).rev() { - down_heap(keys, i, n, lo, cmp); + down_heap(keys, i, n, lo, comparer); } for i in (2..=n).rev() { swap(keys, lo, lo + i - 1); - down_heap(keys, 1, i - 1, lo, cmp); + down_heap(keys, 1, i - 1, lo, comparer); } } -fn down_heap(keys: &mut [T], mut i: usize, n: usize, lo: usize, cmp: &F) +fn down_heap(keys: &mut [T], mut i: usize, n: usize, lo: usize, comparer: &F) where F: Fn(&T, &T) -> Ordering, { while i <= n / 2 { let mut child = 2 * i; - if child < n && cmp(&keys[lo + child - 1], &keys[lo + child]).is_lt() { + if child < n && comparer(&keys[lo + child - 1], &keys[lo + child]).is_lt() { child += 1; } - if cmp(&keys[lo + i - 1], &keys[lo + child - 1]).is_ge() { + // is_ge == !is_lt + if comparer(&keys[lo + i - 1], &keys[lo + child - 1]).is_ge() { break; } @@ -42,11 +46,11 @@ where } } -fn swap_if_greater(keys: &mut [T], cmp: &F, a: usize, b: usize) +fn swap_if_greater(keys: &mut [T], comparer: &F, a: usize, b: usize) where F: Fn(&T, &T) -> Ordering, { - if a != b && cmp(&keys[a], &keys[b]).is_gt() { + if a != b && comparer(&keys[a], &keys[b]).is_gt() { keys.swap(a, b); } } diff --git a/src/util/sort/osu_legacy.rs b/src/util/sort/osu_legacy.rs index 7749593e..165a8fa4 100644 --- a/src/util/sort/osu_legacy.rs +++ b/src/util/sort/osu_legacy.rs @@ -1,29 +1,39 @@ use std::cmp::Ordering; -use crate::model::hit_object::HitObject; - const QUICK_SORT_DEPTH_THRESHOLD: usize = 32; /// osu!'s legacy sorting algorithm. /// /// -pub fn sort(keys: &mut [HitObject]) { +pub fn sort_unstable_by(keys: &mut [T], comparer: F) +where + F: Fn(&T, &T) -> Ordering, +{ if keys.len() < 2 { return; } - depth_limited_quick_sort(keys, 0, keys.len() - 1, QUICK_SORT_DEPTH_THRESHOLD); + depth_limited_quick_sort( + keys, + 0, + keys.len() - 1, + &comparer, + QUICK_SORT_DEPTH_THRESHOLD, + ); } -fn depth_limited_quick_sort( - keys: &mut [HitObject], +fn depth_limited_quick_sort( + keys: &mut [T], mut left: usize, mut right: usize, + comparer: &F, mut depth_limit: usize, -) { +) where + F: Fn(&T, &T) -> Ordering, +{ loop { if depth_limit == 0 { - super::heap_sort(keys, left, right, &cmp); + super::heap_sort(keys, left, right, &comparer); return; } @@ -31,18 +41,18 @@ fn depth_limited_quick_sort( let mut i = left; let mut j = right; - let mid = i + ((j - i) >> 1); + let middle = i + ((j - i) >> 1); - super::swap_if_greater(keys, &cmp, i, mid); - super::swap_if_greater(keys, &cmp, i, j); - super::swap_if_greater(keys, &cmp, mid, j); + super::swap_if_greater(keys, &comparer, i, middle); + super::swap_if_greater(keys, &comparer, i, j); + super::swap_if_greater(keys, &comparer, middle, j); loop { - while keys[i] < keys[mid] { + while comparer(&keys[i], &keys[middle]).is_lt() { i += 1; } - while keys[mid] < keys[j] { + while comparer(&keys[middle], &keys[j]).is_lt() { j -= 1; } @@ -64,13 +74,13 @@ fn depth_limited_quick_sort( if j.saturating_sub(left) <= right - i { if left < j { - depth_limited_quick_sort(keys, left, j, depth_limit); + depth_limited_quick_sort(keys, left, j, comparer, depth_limit); } left = i; } else { if i < right { - depth_limited_quick_sort(keys, i, right, depth_limit); + depth_limited_quick_sort(keys, i, right, comparer, depth_limit); } right = j; @@ -81,7 +91,3 @@ fn depth_limited_quick_sort( } } } - -fn cmp(a: &HitObject, b: &HitObject) -> Ordering { - a.start_time.total_cmp(&b.start_time) -} diff --git a/src/util/special_functions.rs b/src/util/special_functions.rs deleted file mode 100644 index 2fb386cb..00000000 --- a/src/util/special_functions.rs +++ /dev/null @@ -1,281 +0,0 @@ -#![allow( - clippy::excessive_precision, - clippy::too_many_lines, - clippy::unreadable_literal, - clippy::many_single_char_names -)] - -#[rustfmt::skip] -mod consts { - pub const ERF_IMP_AN: &[f64] = &[ 0.00337916709551257388990745, -0.00073695653048167948530905, -0.374732337392919607868241, 0.0817442448733587196071743, -0.0421089319936548595203468, 0.0070165709512095756344528, -0.00495091255982435110337458, 0.000871646599037922480317225 ]; - pub const ERF_IMP_AD: &[f64] = &[ 1.0, -0.218088218087924645390535, 0.412542972725442099083918, -0.0841891147873106755410271, 0.0655338856400241519690695, -0.0120019604454941768171266, 0.00408165558926174048329689, -0.000615900721557769691924509 ]; - pub const ERF_IMP_BN: &[f64] = &[ -0.0361790390718262471360258, 0.292251883444882683221149, 0.281447041797604512774415, 0.125610208862766947294894, 0.0274135028268930549240776, 0.00250839672168065762786937 ]; - pub const ERF_IMP_BD: &[f64] = &[ 1.0, 1.8545005897903486499845, 1.43575803037831418074962, 0.582827658753036572454135, 0.124810476932949746447682, 0.0113724176546353285778481 ]; - pub const ERF_IMP_CN: &[f64] = &[ -0.0397876892611136856954425, 0.153165212467878293257683, 0.191260295600936245503129, 0.10276327061989304213645, 0.029637090615738836726027, 0.0046093486780275489468812, 0.000307607820348680180548455 ]; - pub const ERF_IMP_CD: &[f64] = &[ 1.0, 1.95520072987627704987886, 1.64762317199384860109595, 0.768238607022126250082483, 0.209793185936509782784315, 0.0319569316899913392596356, 0.00213363160895785378615014 ]; - pub const ERF_IMP_DN: &[f64] = &[ -0.0300838560557949717328341, 0.0538578829844454508530552, 0.0726211541651914182692959, 0.0367628469888049348429018, 0.00964629015572527529605267, 0.00133453480075291076745275, 0.778087599782504251917881e-4 ]; - pub const ERF_IMP_DD: &[f64] = &[ 1.0, 1.75967098147167528287343, 1.32883571437961120556307, 0.552528596508757581287907, 0.133793056941332861912279, 0.0179509645176280768640766, 0.00104712440019937356634038, -0.106640381820357337177643e-7 ]; - pub const ERF_IMP_EN: &[f64] = &[ -0.0117907570137227847827732, 0.014262132090538809896674, 0.0202234435902960820020765, 0.00930668299990432009042239, 0.00213357802422065994322516, 0.00025022987386460102395382, 0.120534912219588189822126e-4 ]; - pub const ERF_IMP_ED: &[f64] = &[ 1.0, 1.50376225203620482047419, 0.965397786204462896346934, 0.339265230476796681555511, 0.0689740649541569716897427, 0.00771060262491768307365526, 0.000371421101531069302990367 ]; - pub const ERF_IMP_FN: &[f64] = &[ -0.00546954795538729307482955, 0.00404190278731707110245394, 0.0054963369553161170521356, 0.00212616472603945399437862, 0.000394984014495083900689956, 0.365565477064442377259271e-4, 0.135485897109932323253786e-5 ]; - pub const ERF_IMP_FD: &[f64] = &[ 1.0, 1.21019697773630784832251, 0.620914668221143886601045, 0.173038430661142762569515, 0.0276550813773432047594539, 0.00240625974424309709745382, 0.891811817251336577241006e-4, -0.465528836283382684461025e-11 ]; - pub const ERF_IMP_GN: &[f64] = &[ -0.00270722535905778347999196, 0.0013187563425029400461378, 0.00119925933261002333923989, 0.00027849619811344664248235, 0.267822988218331849989363e-4, 0.923043672315028197865066e-6 ]; - pub const ERF_IMP_GD: &[f64] = &[ 1.0, 0.814632808543141591118279, 0.268901665856299542168425, 0.0449877216103041118694989, 0.00381759663320248459168994, 0.000131571897888596914350697, 0.404815359675764138445257e-11 ]; - pub const ERF_IMP_HN: &[f64] = &[ -0.00109946720691742196814323, 0.000406425442750422675169153, 0.000274499489416900707787024, 0.465293770646659383436343e-4, 0.320955425395767463401993e-5, 0.778286018145020892261936e-7 ]; - pub const ERF_IMP_HD: &[f64] = &[ 1.0, 0.588173710611846046373373, 0.139363331289409746077541, 0.0166329340417083678763028, 0.00100023921310234908642639, 0.24254837521587225125068e-4 ]; - pub const ERF_IMP_IN: &[f64] = &[ -0.00056907993601094962855594, 0.000169498540373762264416984, 0.518472354581100890120501e-4, 0.382819312231928859704678e-5, 0.824989931281894431781794e-7 ]; - pub const ERF_IMP_ID: &[f64] = &[ 1.0, 0.339637250051139347430323, 0.043472647870310663055044, 0.00248549335224637114641629, 0.535633305337152900549536e-4, -0.117490944405459578783846e-12 ]; - pub const ERF_IMP_JN: &[f64] = &[ -0.000241313599483991337479091, 0.574224975202501512365975e-4, 0.115998962927383778460557e-4, 0.581762134402593739370875e-6, 0.853971555085673614607418e-8 ]; - pub const ERF_IMP_JD: &[f64] = &[ 1.0, 0.233044138299687841018015, 0.0204186940546440312625597, 0.000797185647564398289151125, 0.117019281670172327758019e-4 ]; - pub const ERF_IMP_KN: &[f64] = &[ -0.000146674699277760365803642, 0.162666552112280519955647e-4, 0.269116248509165239294897e-5, 0.979584479468091935086972e-7, 0.101994647625723465722285e-8 ]; - pub const ERF_IMP_KD: &[f64] = &[ 1.0, 0.165907812944847226546036, 0.0103361716191505884359634, 0.000286593026373868366935721, 0.298401570840900340874568e-5 ]; - pub const ERF_IMP_LN: &[f64] = &[ -0.583905797629771786720406e-4, 0.412510325105496173512992e-5, 0.431790922420250949096906e-6, 0.993365155590013193345569e-8, 0.653480510020104699270084e-10 ]; - pub const ERF_IMP_LD: &[f64] = &[ 1.0, 0.105077086072039915406159, 0.00414278428675475620830226, 0.726338754644523769144108e-4, 0.477818471047398785369849e-6 ]; - pub const ERF_IMP_MN: &[f64] = &[ -0.196457797609229579459841e-4, 0.157243887666800692441195e-5, 0.543902511192700878690335e-7, 0.317472492369117710852685e-9 ]; - pub const ERF_IMP_MD: &[f64] = &[ 1.0, 0.052803989240957632204885, 0.000926876069151753290378112, 0.541011723226630257077328e-5, 0.535093845803642394908747e-15 ]; - pub const ERF_IMP_NN: &[f64] = &[ -0.789224703978722689089794e-5, 0.622088451660986955124162e-6, 0.145728445676882396797184e-7, 0.603715505542715364529243e-10 ]; - pub const ERF_IMP_ND: &[f64] = &[ 1.0, 0.0375328846356293715248719, 0.000467919535974625308126054, 0.193847039275845656900547e-5 ]; - - pub const ERV_INV_IMP_AN: &[f64] = &[ -0.000508781949658280665617, -0.00836874819741736770379, 0.0334806625409744615033, -0.0126926147662974029034, -0.0365637971411762664006, 0.0219878681111168899165, 0.00822687874676915743155, -0.00538772965071242932965 ]; - pub const ERV_INV_IMP_AD: &[f64] = &[ 1.0, -0.970005043303290640362, -1.56574558234175846809, 1.56221558398423026363, 0.662328840472002992063, -0.71228902341542847553, -0.0527396382340099713954, 0.0795283687341571680018, -0.00233393759374190016776, 0.000886216390456424707504 ]; - pub const ERV_INV_IMP_BN: &[f64] = &[ -0.202433508355938759655, 0.105264680699391713268, 8.37050328343119927838, 17.6447298408374015486, -18.8510648058714251895, -44.6382324441786960818, 17.445385985570866523, 21.1294655448340526258, -3.67192254707729348546 ]; - pub const ERV_INV_IMP_BD: &[f64] = &[ 1.0, 6.24264124854247537712, 3.9713437953343869095, -28.6608180499800029974, -20.1432634680485188801, 48.5609213108739935468, 10.8268667355460159008, -22.6436933413139721736, 1.72114765761200282724 ]; - pub const ERV_INV_IMP_CN: &[f64] = &[ -0.131102781679951906451, -0.163794047193317060787, 0.117030156341995252019, 0.387079738972604337464, 0.337785538912035898924, 0.142869534408157156766, 0.0290157910005329060432, 0.00214558995388805277169, -0.679465575181126350155e-6, 0.285225331782217055858e-7, -0.681149956853776992068e-9 ]; - pub const ERV_INV_IMP_CD: &[f64] = &[ 1.0, 3.46625407242567245975, 5.38168345707006855425, 4.77846592945843778382, 2.59301921623620271374, 0.848854343457902036425, 0.152264338295331783612, 0.01105924229346489121 ]; - pub const ERV_INV_IMP_DN: &[f64] = &[ -0.0350353787183177984712, -0.00222426529213447927281, 0.0185573306514231072324, 0.00950804701325919603619, 0.00187123492819559223345, 0.000157544617424960554631, 0.460469890584317994083e-5, -0.230404776911882601748e-9, 0.266339227425782031962e-11 ]; - pub const ERV_INV_IMP_DD: &[f64] = &[ 1.0, 1.3653349817554063097, 0.762059164553623404043, 0.220091105764131249824, 0.0341589143670947727934, 0.00263861676657015992959, 0.764675292302794483503e-4 ]; - pub const ERV_INV_IMP_EN: &[f64] = &[ -0.0167431005076633737133, -0.00112951438745580278863, 0.00105628862152492910091, 0.000209386317487588078668, 0.149624783758342370182e-4, 0.449696789927706453732e-6, 0.462596163522878599135e-8, -0.281128735628831791805e-13, 0.99055709973310326855e-16 ]; - pub const ERV_INV_IMP_ED: &[f64] = &[ 1.0, 0.591429344886417493481, 0.138151865749083321638, 0.0160746087093676504695, 0.000964011807005165528527, 0.275335474764726041141e-4, 0.282243172016108031869e-6 ]; - pub const ERV_INV_IMP_FN: &[f64] = &[ -0.0024978212791898131227, -0.779190719229053954292e-5, 0.254723037413027451751e-4, 0.162397777342510920873e-5, 0.396341011304801168516e-7, 0.411632831190944208473e-9, 0.145596286718675035587e-11, -0.116765012397184275695e-17 ]; - pub const ERV_INV_IMP_FD: &[f64] = &[ 1.0, 0.207123112214422517181, 0.0169410838120975906478, 0.000690538265622684595676, 0.145007359818232637924e-4, 0.144437756628144157666e-6, 0.509761276599778486139e-9 ]; - pub const ERV_INV_IMP_GN: &[f64] = &[ -0.000539042911019078575891, -0.28398759004727721098e-6, 0.899465114892291446442e-6, 0.229345859265920864296e-7, 0.225561444863500149219e-9, 0.947846627503022684216e-12, 0.135880130108924861008e-14, -0.348890393399948882918e-21 ]; - pub const ERV_INV_IMP_GD: &[f64] = &[ 1.0, 0.0845746234001899436914, 0.00282092984726264681981, 0.468292921940894236786e-4, 0.399968812193862100054e-6, 0.161809290887904476097e-8, 0.231558608310259605225e-11 ]; -} - -#[allow(clippy::wildcard_imports)] -use consts::*; - -pub fn erf(x: f64) -> f64 { - if x == 0.0 { - return 0.0; - } - - if x == f64::INFINITY { - return 1.0; - } - - if x == f64::NEG_INFINITY { - return -1.0; - } - - if x.is_nan() { - return f64::NAN; - } - - erf_imp(x, false) -} - -pub fn erf_inv(z: f64) -> f64 { - if z == 0.0 { - return 0.0; - } - - if z >= 1.0 { - return f64::INFINITY; - } - - if z <= -1.0 { - return f64::NEG_INFINITY; - } - - if z < 0.0 { - erf_inv_impl(-z, 1.0 - (-z), -1.0) - } else { - erf_inv_impl(z, 1.0 - z, 1.0) - } -} - -fn erf_imp(z: f64, mut invert: bool) -> f64 { - if z < 0.0 { - if !invert { - return -erf_imp(-z, false); - } - - if z < -0.5 { - return 2.0 - erf_imp(-z, true); - } - - return 1.0 + erf_imp(-z, false); - } - - let result = if z < 0.5 { - if z < 1e-10 { - (z * 1.125) + (z * 0.003379167095512573896158903121545171688) - } else { - (z * 1.125) - + (z * evaluate_polynomial(z, ERF_IMP_AN) / evaluate_polynomial(z, ERF_IMP_AD)) - } - } else if z < 110.0 { - invert = !invert; - - let (r, b) = if z < 0.75 { - ( - evaluate_polynomial(z - 0.5, ERF_IMP_BN) / evaluate_polynomial(z - 0.5, ERF_IMP_BD), - f64::from(0.3440242112_f32), - ) - } else if z < 1.25 { - ( - evaluate_polynomial(z - 0.75, ERF_IMP_CN) - / evaluate_polynomial(z - 0.75, ERF_IMP_CD), - f64::from(0.419990927_f32), - ) - } else if z < 2.25 { - ( - evaluate_polynomial(z - 1.25, ERF_IMP_DN) - / evaluate_polynomial(z - 1.25, ERF_IMP_DD), - f64::from(0.4898625016_f32), - ) - } else if z < 3.5 { - ( - evaluate_polynomial(z - 2.25, ERF_IMP_EN) - / evaluate_polynomial(z - 2.25, ERF_IMP_ED), - f64::from(0.5317370892_f32), - ) - } else if z < 5.25 { - ( - evaluate_polynomial(z - 3.5, ERF_IMP_FN) / evaluate_polynomial(z - 3.5, ERF_IMP_FD), - f64::from(0.5489973426_f32), - ) - } else if z < 8.0 { - ( - evaluate_polynomial(z - 5.25, ERF_IMP_GN) - / evaluate_polynomial(z - 5.25, ERF_IMP_GD), - f64::from(0.5571740866_f32), - ) - } else if z < 11.5 { - ( - evaluate_polynomial(z - 8.0, ERF_IMP_HN) / evaluate_polynomial(z - 8.0, ERF_IMP_HD), - f64::from(0.5609807968_f32), - ) - } else if z < 17.0 { - ( - evaluate_polynomial(z - 11.5, ERF_IMP_IN) - / evaluate_polynomial(z - 11.5, ERF_IMP_ID), - f64::from(0.5626493692_f32), - ) - } else if z < 24.0 { - ( - evaluate_polynomial(z - 17.0, ERF_IMP_JN) - / evaluate_polynomial(z - 17.0, ERF_IMP_JD), - f64::from(0.5634598136_f32), - ) - } else if z < 38.0 { - ( - evaluate_polynomial(z - 24.0, ERF_IMP_KN) - / evaluate_polynomial(z - 24.0, ERF_IMP_KD), - f64::from(0.5638477802_f32), - ) - } else if z < 60.0 { - ( - evaluate_polynomial(z - 38.0, ERF_IMP_LN) - / evaluate_polynomial(z - 38.0, ERF_IMP_LD), - f64::from(0.5640528202_f32), - ) - } else if z < 85.0 { - ( - evaluate_polynomial(z - 60.0, ERF_IMP_MN) - / evaluate_polynomial(z - 60.0, ERF_IMP_MD), - f64::from(0.5641309023_f32), - ) - } else { - ( - evaluate_polynomial(z - 85.0, ERF_IMP_NN) - / evaluate_polynomial(z - 85.0, ERF_IMP_ND), - f64::from(0.5641584396_f32), - ) - }; - - let g = f64::exp(-z * z) / z; - - (g * b) + (g * r) - } else { - invert = !invert; - - 0.0 - }; - - if invert { 1.0 - result } else { result } -} - -fn erf_inv_impl(p: f64, q: f64, s: f64) -> f64 { - let result = if p <= 0.5 { - const Y: f32 = 0.0891314744949340820313; - - let g = p * (p + 10.0); - let r = evaluate_polynomial(p, ERV_INV_IMP_AN) / evaluate_polynomial(p, ERV_INV_IMP_AD); - - (g * f64::from(Y)) + (g * r) - } else if q >= 0.25 { - const Y: f32 = 2.249481201171875; - - let g = (-2.0 * q.ln()).sqrt(); - let xs = q - 0.25; - let r = evaluate_polynomial(xs, ERV_INV_IMP_BN) / evaluate_polynomial(xs, ERV_INV_IMP_BD); - - g / (f64::from(Y) + r) - } else { - let x = (-q.ln()).sqrt(); - - if x < 3.0 { - const Y: f32 = 0.807220458984375; - - let xs = x - 1.125; - let r = - evaluate_polynomial(xs, ERV_INV_IMP_CN) / evaluate_polynomial(xs, ERV_INV_IMP_CD); - - (f64::from(Y) * x) + (r * x) - } else if x < 6.0 { - const Y: f32 = 0.93995571136474609375; - let xs = x - 3.0; - let r = - evaluate_polynomial(xs, ERV_INV_IMP_DN) / evaluate_polynomial(xs, ERV_INV_IMP_DD); - - (f64::from(Y) * x) + (r * x) - } else if x < 18.0 { - const Y: f32 = 0.98362827301025390625; - - let xs = x - 6.0; - let r = - evaluate_polynomial(xs, ERV_INV_IMP_EN) / evaluate_polynomial(xs, ERV_INV_IMP_ED); - - (f64::from(Y) * x) + (r * x) - } else if x < 44.0 { - const Y: f32 = 0.99714565277099609375; - - let xs = x - 18.0; - let r = - evaluate_polynomial(xs, ERV_INV_IMP_FN) / evaluate_polynomial(xs, ERV_INV_IMP_FD); - (f64::from(Y) * x) + (r * x) - } else { - const Y: f32 = 0.99941349029541015625; - - let xs = x - 44.0; - let r = - evaluate_polynomial(xs, ERV_INV_IMP_GN) / evaluate_polynomial(xs, ERV_INV_IMP_GD); - - (f64::from(Y) * x) + (r * x) - } - }; - - result * s -} - -fn evaluate_polynomial(z: f64, coefficients: &[f64]) -> f64 { - let mut coefficients = coefficients.iter().copied().rev(); - - let Some(last) = coefficients.next() else { - return 0.0; - }; - - coefficients.fold(last, |sum, coefficient| (sum * z) + coefficient) -} diff --git a/src/util/strains_vec.rs b/src/util/strains_vec.rs index 344127ea..029a6180 100644 --- a/src/util/strains_vec.rs +++ b/src/util/strains_vec.rs @@ -1,3 +1,6 @@ +// TODO: remove; suspicion checks should now be used to prevent calculation on +// malicious maps + pub use inner::*; #[cfg(not(feature = "raw_strains"))] diff --git a/src/util/sync.rs b/src/util/sync.rs index 6d44bb6e..34b6bde2 100644 --- a/src/util/sync.rs +++ b/src/util/sync.rs @@ -1,4 +1,7 @@ -use std::fmt; +use std::{ + fmt, + hash::{Hash, Hasher}, +}; pub use inner::*; @@ -9,6 +12,12 @@ mod inner { #[repr(transparent)] pub struct RefCount(pub(super) Rc>); + impl Clone for RefCount { + fn clone(&self) -> Self { + Self(Rc::clone(&self.0)) + } + } + #[repr(transparent)] pub struct Weak(pub(super) std::rc::Weak>); @@ -50,6 +59,12 @@ mod inner { #[repr(transparent)] pub struct RefCount(pub(super) Arc>); + impl Clone for RefCount { + fn clone(&self) -> Self { + Self(Arc::clone(&self.0)) + } + } + #[repr(transparent)] pub struct Weak(pub(super) std::sync::Weak>); @@ -130,12 +145,46 @@ impl fmt::Debug for RefCount { } } +impl PartialEq for RefCount { + fn eq(&self, other: &Self) -> bool { + self.get().eq(&other.get()) + } +} + +impl Eq for RefCount {} + +impl Hash for RefCount { + fn hash(&self, state: &mut H) { + self.get().hash(state); + } +} + +impl Clone for Weak { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + impl fmt::Debug for Weak { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fmt::Debug::fmt(&self.0, f) } } +impl PartialEq for Weak { + fn eq(&self, other: &Self) -> bool { + self.upgrade().eq(&other.upgrade()) + } +} + +impl Eq for Weak {} + +impl Hash for Weak { + fn hash(&self, state: &mut H) { + self.upgrade().hash(state); + } +} + /// ```compile_fail /// use rosu_pp::{taiko::TaikoGradualDifficulty, Beatmap, Difficulty}; /// diff --git a/tests/common.rs b/tests/common.rs index acfe875c..0666b3bc 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -1,4 +1,7 @@ -#[allow(unused, reason = "false positive")] +#[allow( + unused, + reason = "false positive (also 'expect' doesn't seem to work so we use 'allow'" +)] pub use self::{mods::*, paths::*}; /// Paths to .osu files @@ -11,7 +14,10 @@ mod paths { /// Bit values for mods mod mods { - #![allow(unused)] + #![allow( + unused, + reason = "false positive (also 'expect' doesn't seem to work so we use 'allow'" + )] pub const NM: u32 = 0; pub const NF: u32 = 1 << 0; @@ -31,7 +37,10 @@ pub fn assert_eq_float(a: F, b: F) { } #[track_caller] -#[allow(unused, reason = "false positive")] +#[allow( + unused, + reason = "false positive (also 'expect' doesn't seem to work so we use 'allow'" +)] pub fn assert_eq_option(a: Option, b: Option) { match (a, b) { (Some(a), Some(b)) => assert!((a - b).less_than_eps(), "{a} != {b}"), @@ -71,7 +80,10 @@ macro_rules! impl_float { impl_float!(f32 f64); /// Trait to compare two instances and panic if they are not equal. -#[allow(unused)] +#[allow( + unused, + reason = "false positive (also 'expect' doesn't seem to work so we use 'allow'" +)] pub trait AssertEq { fn assert_eq(&self, expected: &Self); } diff --git a/tests/difficulty.rs b/tests/difficulty.rs index a02fbc37..1911d764 100644 --- a/tests/difficulty.rs +++ b/tests/difficulty.rs @@ -38,9 +38,14 @@ macro_rules! test_cases { speed: $speed:literal, flashlight: $flashlight:literal, slider_factor: $slider_factor:literal, + aim_top_weighted_slider_factor: $aim_top_weighted_slider_factor:literal, + speed_top_weighted_slider_factor: $speed_top_weighted_slider_factor:literal, speed_note_count: $speed_note_count:literal, aim_difficult_strain_count: $aim_difficult_strain_count:literal, speed_difficult_strain_count: $speed_difficult_strain_count:literal, + nested_score_per_object: $nested_score_per_object:literal, + legacy_score_base_multiplier: $legacy_score_base_multiplier:literal, + maximum_legacy_combo_score: $maximum_legacy_combo_score:literal, ar: $ar:literal, great_hit_window: $great_hit_window:literal, ok_hit_window: $ok_hit_window:literal, @@ -59,9 +64,14 @@ macro_rules! test_cases { speed: $speed, flashlight: $flashlight, slider_factor: $slider_factor, + aim_top_weighted_slider_factor: $aim_top_weighted_slider_factor, + speed_top_weighted_slider_factor: $speed_top_weighted_slider_factor, speed_note_count: $speed_note_count, aim_difficult_strain_count: $aim_difficult_strain_count, speed_difficult_strain_count: $speed_difficult_strain_count, + nested_score_per_object: $nested_score_per_object, + legacy_score_base_multiplier: $legacy_score_base_multiplier, + maximum_legacy_combo_score: $maximum_legacy_combo_score, ar: $ar, great_hit_window: $great_hit_window, ok_hit_window: $ok_hit_window, @@ -83,6 +93,8 @@ macro_rules! test_cases { great_hit_window: $great_hit_window:literal, ok_hit_window: $ok_hit_window:literal, mono_stamina_factor: $mono_stamina_factor:literal, + mechanical_difficulty: $mechanical_difficulty:literal, + consistency_factor: $consistency_factor:literal, stars: $stars:literal, max_combo: $max_combo:literal, is_convert: $is_convert:literal, @@ -95,6 +107,8 @@ macro_rules! test_cases { great_hit_window: $great_hit_window, ok_hit_window: $ok_hit_window, mono_stamina_factor: $mono_stamina_factor, + mechanical_difficulty: $mechanical_difficulty, + consistency_factor: $consistency_factor, stars: $stars, max_combo: $max_combo, is_convert: $is_convert, @@ -102,7 +116,7 @@ macro_rules! test_cases { }; ( @Catch { stars: $stars:literal, - ar: $ar:literal, + preempt: $preempt:literal, n_fruits: $n_fruits:literal, n_droplets: $n_droplets:literal, n_tiny_droplets: $n_tiny_droplets:literal, @@ -110,7 +124,7 @@ macro_rules! test_cases { }) => { CatchDifficultyAttributes { stars: $stars, - ar: $ar, + preempt: $preempt, n_fruits: $n_fruits, n_droplets: $n_droplets, n_tiny_droplets: $n_tiny_droplets, @@ -140,129 +154,185 @@ fn basic_osu() { test_cases! { Osu: OSU { NM => { - aim: 2.8768763397837276, - aim_difficult_slider_count: 159.94435184493983, - speed: 2.4859791945784644, - flashlight: 2.287810214401711, - slider_factor: 0.9804419804851772, - speed_note_count: 203.4377249350029, - aim_difficult_strain_count: 109.81975624515795, - speed_difficult_strain_count: 79.45944001555702, + aim: 3.021506412510076, + aim_difficult_slider_count: 180.33980678704012, + speed: 2.5263145770639976, + flashlight: 0.0, + slider_factor: 0.9847225384137204, + aim_top_weighted_slider_factor: 1.3996332540321264, + speed_top_weighted_slider_factor: 0.6014562852677632, + speed_note_count: 202.24319351543616, + aim_difficult_strain_count: 108.47555309841259, + speed_difficult_strain_count: 78.39830024782772, + nested_score_per_object: 34.991680532445926, + legacy_score_base_multiplier: 5.0, + maximum_legacy_combo_score: 15729840.0, ar: 9.300000190734863, - great_hit_window: 27.19999885559082, - ok_hit_window: 69.5999984741211, - meh_hit_window: 111.99999809265137, + great_hit_window: 26.5, + ok_hit_window: 68.5, + meh_hit_window: 110.5, hp: 5.0, n_circles: 307, n_sliders: 293, n_large_ticks: 15, n_spinners: 1, - stars: 5.653394946111255, + stars: 5.740766046562339, max_combo: 909, }; HD => { - aim: 2.8768763397837276, - aim_difficult_slider_count: 159.94435184493983, - speed: 2.4859791945784644, - flashlight: 2.605769566257193, - slider_factor: 0.9804419804851772, - speed_note_count: 203.4377249350029, - aim_difficult_strain_count: 109.81975624515795, - speed_difficult_strain_count: 79.45944001555702, + aim: 3.121489829231887, + aim_difficult_slider_count: 180.33980678704012, + speed: 2.614171127905441, + flashlight: 0.0, + slider_factor: 0.9847225384137204, + aim_top_weighted_slider_factor: 1.3996332540321264, + speed_top_weighted_slider_factor: 0.6014562852677632, + speed_note_count: 202.24319351543616, + aim_difficult_strain_count: 108.47555309841259, + speed_difficult_strain_count: 78.39830024782772, + nested_score_per_object: 34.991680532445926, + legacy_score_base_multiplier: 5.0, + maximum_legacy_combo_score: 15729840.0, ar: 9.300000190734863, - great_hit_window: 27.19999885559082, - ok_hit_window: 69.5999984741211, - meh_hit_window: 111.99999809265137, + great_hit_window: 26.5, + ok_hit_window: 68.5, + meh_hit_window: 110.5, hp: 5.0, n_circles: 307, n_sliders: 293, n_large_ticks: 15, n_spinners: 1, - stars: 5.653394946111255, + stars: 5.934133851244851, max_combo: 909, }; HR => { - aim: 3.2524329530597154, - aim_difficult_slider_count: 166.54650855486952, - speed: 2.642601593996546, - flashlight: 2.8536623110985535, - slider_factor: 0.9698688450936457, - speed_note_count: 178.23533084488034, - aim_difficult_strain_count: 109.72159733275541, - speed_difficult_strain_count: 73.57962712314226, + aim: 3.4309052630747257, + aim_difficult_slider_count: 187.20300643263465, + speed: 2.6813963801152716, + flashlight: 0.0, + slider_factor: 0.9748562752795166, + aim_top_weighted_slider_factor: 1.3634873114118244, + speed_top_weighted_slider_factor: 0.6668815233244475, + speed_note_count: 185.01178339020348, + aim_difficult_strain_count: 112.28112750203013, + speed_difficult_strain_count: 74.53251006179151, + nested_score_per_object: 34.991680532445926, + legacy_score_base_multiplier: 5.0, + maximum_legacy_combo_score: 15729840.0, ar: 10.0, - great_hit_window: 20.0, - ok_hit_window: 60.0, - meh_hit_window: 100.0, + great_hit_window: 19.5, + ok_hit_window: 59.5, + meh_hit_window: 99.5, hp: 7.0, n_circles: 307, n_sliders: 293, n_large_ticks: 15, n_spinners: 1, - stars: 6.252509796432301, + stars: 6.375104448752039, max_combo: 909, }; DT => { - aim: 4.048049265438377, - aim_difficult_slider_count: 175.9330312155201, - speed: 3.5966087398365265, - flashlight: 3.3180899820069842, - slider_factor: 0.9780331301164369, - speed_note_count: 209.75822813365252, - aim_difficult_strain_count: 132.07784609607364, - speed_difficult_strain_count: 94.98876627500661, - ar: 10.53333346048991, - great_hit_window: 18.13333257039388, - ok_hit_window: 46.3999989827474, - meh_hit_window: 74.6666653951009, + aim: 4.3662195513104525, + aim_difficult_slider_count: 195.41476682131653, + speed: 3.7793477426295814, + flashlight: 0.0, + slider_factor: 0.9787310737204966, + aim_top_weighted_slider_factor: 1.3819099517666353, + speed_top_weighted_slider_factor: 0.6923456235877925, + speed_note_count: 208.98215163620375, + aim_difficult_strain_count: 130.48279566301667, + speed_difficult_strain_count: 93.64469563382437, + nested_score_per_object: 34.991680532445926, + legacy_score_base_multiplier: 5.0, + maximum_legacy_combo_score: 15729840.0, + ar: 10.533333460489908, + great_hit_window: 17.666666666666668, + ok_hit_window: 45.666666666666664, + meh_hit_window: 73.66666666666667, hp: 5.0, n_circles: 307, n_sliders: 293, n_large_ticks: 15, n_spinners: 1, - stars: 8.041658803681496, + stars: 8.40182116136074, max_combo: 909, }; FL => { - aim: 2.8768763397837276, - aim_difficult_slider_count: 159.94435184493983, - speed: 2.4859791945784644, - flashlight: 2.287810214401711, - slider_factor: 0.9804419804851772, - speed_note_count: 203.4377249350029, - aim_difficult_strain_count: 109.81975624515795, - speed_difficult_strain_count: 79.45944001555702, + aim: 3.021506412510076, + aim_difficult_slider_count: 180.33980678704012, + speed: 2.5263145770639976, + flashlight: 2.3005989208967885, + slider_factor: 0.9847225384137204, + aim_top_weighted_slider_factor: 1.3996332540321264, + speed_top_weighted_slider_factor: 0.6014562852677632, + speed_note_count: 202.24319351543616, + aim_difficult_strain_count: 108.47555309841259, + speed_difficult_strain_count: 78.39830024782772, + nested_score_per_object: 34.991680532445926, + legacy_score_base_multiplier: 5.0, + maximum_legacy_combo_score: 15729840.0, ar: 9.300000190734863, - great_hit_window: 27.19999885559082, - ok_hit_window: 69.5999984741211, - meh_hit_window: 111.99999809265137, + great_hit_window: 26.5, + ok_hit_window: 68.5, + meh_hit_window: 110.5, hp: 5.0, n_circles: 307, n_sliders: 293, n_large_ticks: 15, n_spinners: 1, - stars: 6.864894125872836, + stars: 6.864308231959398, + max_combo: 909, + }; + HD EZ => { + aim: 2.9818506123002706, + aim_difficult_slider_count: 173.00759261125233, + speed: 2.511531850339625, + flashlight: 0.0, + slider_factor: 0.9931728395338801, + aim_top_weighted_slider_factor: 1.4758126955064637, + speed_top_weighted_slider_factor: 0.48777057881852615, + speed_note_count: 211.97339651166865, + aim_difficult_strain_count: 107.45480335487801, + speed_difficult_strain_count: 78.94432491731223, + nested_score_per_object: 34.991680532445926, + legacy_score_base_multiplier: 3.0, + maximum_legacy_combo_score: 15729840.0, + ar: 4.650000095367432, + great_hit_window: 52.5, + ok_hit_window: 103.5, + meh_hit_window: 154.5, + hp: 2.5, + n_circles: 307, + n_sliders: 293, + n_large_ticks: 15, + n_spinners: 1, + stars: 5.680319048111094, max_combo: 909, }; HD FL => { - aim: 2.8768763397837276, - aim_difficult_slider_count: 159.94435184493983, - speed: 2.4859791945784644, - flashlight: 2.605769566257193, - slider_factor: 0.9804419804851772, - speed_note_count: 203.4377249350029, - aim_difficult_strain_count: 109.81975624515795, - speed_difficult_strain_count: 79.45944001555702, + aim: 3.121489829231887, + aim_difficult_slider_count: 180.33980678704012, + speed: 2.614171127905441, + flashlight: 2.620335643475851, + slider_factor: 0.9847225384137204, + aim_top_weighted_slider_factor: 1.3996332540321264, + speed_top_weighted_slider_factor: 0.6014562852677632, + speed_note_count: 202.24319351543616, + aim_difficult_strain_count: 108.47555309841259, + speed_difficult_strain_count: 78.39830024782772, + nested_score_per_object: 34.991680532445926, + legacy_score_base_multiplier: 5.0, + maximum_legacy_combo_score: 15729840.0, ar: 9.300000190734863, - great_hit_window: 27.19999885559082, - ok_hit_window: 69.5999984741211, - meh_hit_window: 111.99999809265137, + great_hit_window: 26.5, + ok_hit_window: 68.5, + meh_hit_window: 110.5, hp: 5.0, n_circles: 307, n_sliders: 293, n_large_ticks: 15, n_spinners: 1, - stars: 7.173433597920448, + stars: 7.2736222258399374, max_combo: 909, }; } @@ -275,38 +345,44 @@ fn basic_taiko() { test_cases! { Taiko: TAIKO { NM => { - stamina: 1.6946519339726132, - rhythm: 0.1469836127567273, - color: 0.517224522408358, - reading: 1.3847421923785603E-05, - great_hit_window: 35.0, - ok_hit_window: 80.0, - mono_stamina_factor: 2.5849839992846003E-07, - stars: 2.9052867123859096, + stamina: 2.0538739969959194, + rhythm: 0.20910773140367978, + color: 0.6533063635604147, + reading: 1.6783022238696096E-05, + great_hit_window: 34.5, + ok_hit_window: 79.5, + mono_stamina_factor: 2.585220903145618E-07, + mechanical_difficulty: 2.7071803605563343, + consistency_factor: 0.6315890845261888, + stars: 2.9163048749822527, max_combo: 289, is_convert: false, }; HR => { - stamina: 1.6946519339726132, - rhythm: 0.1481975365980239, - color: 0.517224522408358, - reading: 0.48269837569606633, - great_hit_window: 29.0, - ok_hit_window: 68.0, - mono_stamina_factor: 2.5849839992846003E-07, - stars: 2.987035945441977, + stamina: 1.7575509868953703, + rhythm: 0.1803685258404691, + color: 0.5590504800684293, + reading: 0.5006234843367281, + great_hit_window: 28.5, + ok_hit_window: 67.5, + mono_stamina_factor: 2.585220903145618E-07, + mechanical_difficulty: 2.3166014669637995, + consistency_factor: 0.6323578625670226, + stars: 2.9975934771409967, max_combo: 289, is_convert: false, }; DT => { - stamina: 2.469654573687006, - rhythm: 0.49890717637373116, - color: 0.6582785829498352, - reading: 0.18343567306243425, - great_hit_window: 23.333333333333332, - ok_hit_window: 53.333333333333336, - mono_stamina_factor: 2.464552111442399E-07, - stars: 4.032774431778195, + stamina: 2.5520061698054346, + rhythm: 0.5994985831319557, + color: 0.7173945446143202, + reading: 0.18956996617034277, + great_hit_window: 23.0, + ok_hit_window: 53.0, + mono_stamina_factor: 2.465693827167051E-07, + mechanical_difficulty: 3.2694007144197546, + consistency_factor: 0.621885489483516, + stars: 4.058469263722054, max_combo: 289, is_convert: false, }; @@ -319,38 +395,44 @@ fn convert_taiko() { test_cases! { Taiko: OSU { NM => { - stamina: 3.6244157068104066, - rhythm: 1.1790872487169137, - color: 1.3884965692008842, - reading: 1.7629163125391505, - great_hit_window: 23.59999942779541, - ok_hit_window: 57.19999885559082, + stamina: 2.207066240409116, + rhythm: 0.8365139147559882, + color: 0.8396618490452487, + reading: 1.0735173315689464, + great_hit_window: 22.5, + ok_hit_window: 56.5, mono_stamina_factor: 0.0014311041774359666, - stars: 4.856701972823887, + mechanical_difficulty: 3.0467280894543647, + consistency_factor: 0.6655548318078143, + stars: 4.9567593357792985, max_combo: 908, is_convert: true, }; HR => { - stamina: 3.6244157068104066, - rhythm: 1.198436503252946, - color: 1.3884965692008842, - reading: 2.26853983796185, - great_hit_window: 20.0, - ok_hit_window: 50.0, + stamina: 2.2682938123290377, + rhythm: 0.8719256263669988, + color: 0.8629554209868424, + reading: 1.4197363916621928, + great_hit_window: 19.5, + ok_hit_window: 49.5, mono_stamina_factor: 0.0014311041774359666, - stars: 5.335018644413355, + mechanical_difficulty: 3.13124923331588, + consistency_factor: 0.6679579465054277, + stars: 5.422911251345071, max_combo: 908, is_convert: true, }; DT => { - stamina: 5.506714422351734, - rhythm: 1.8409407770037334, - color: 1.8926518387821614, - reading: 3.0585370998057053, - great_hit_window: 15.733332951863607, - ok_hit_window: 38.13333257039388, + stamina: 3.210799549552373, + rhythm: 1.2585331678081546, + color: 1.0837796585509605, + reading: 1.7833409886818568, + great_hit_window: 15.0, + ok_hit_window: 37.666666666666664, mono_stamina_factor: 0.0014418086037955797, - stars: 7.1789968695409225, + mechanical_difficulty: 4.294579208103333, + consistency_factor: 0.6621583643602745, + stars: 7.336453364593345, max_combo: 908, is_convert: true, }; @@ -364,7 +446,7 @@ fn basic_catch() { Catch: CATCH { NM => { stars: 3.250266313373984, - ar: 8.0, + preempt: 750.0, n_fruits: 728, n_droplets: 2, n_tiny_droplets: 263, @@ -372,7 +454,7 @@ fn basic_catch() { }; HR => { stars: 4.313360856186517, - ar: 10.0, + preempt: 450.0, n_fruits: 728, n_droplets: 2, n_tiny_droplets: 263, @@ -380,7 +462,7 @@ fn basic_catch() { }; EZ => { stars: 4.06522224010957, - ar: 4.0, + preempt: 1320.0, n_fruits: 728, n_droplets: 2, n_tiny_droplets: 263, @@ -388,7 +470,7 @@ fn basic_catch() { }; DT => { stars: 4.635262826575386, - ar: 9.666666666666668, + preempt: 500.0, n_fruits: 728, n_droplets: 2, n_tiny_droplets: 263, @@ -404,7 +486,7 @@ fn convert_catch() { Catch: OSU { NM => { stars: 4.528720977989276, - ar: 9.300000190734863, + preempt: 554.9999713897705, n_fruits: 908, n_droplets: 0, n_tiny_droplets: 159, @@ -412,7 +494,7 @@ fn convert_catch() { }; HR => { stars: 5.076698043567007, - ar: 10.0, + preempt: 450.0, n_fruits: 908, n_droplets: 0, n_tiny_droplets: 159, @@ -420,7 +502,7 @@ fn convert_catch() { }; EZ => { stars: 3.593264064535228, - ar: 4.650000095367432, + preempt: 1241.9999885559082, n_fruits: 908, n_droplets: 0, n_tiny_droplets: 159, @@ -428,7 +510,7 @@ fn convert_catch() { }; DT => { stars: 6.15540143757313, - ar: 10.53333346048991, + preempt: 369.9999809265137, n_fruits: 908, n_droplets: 0, n_tiny_droplets: 159, @@ -500,9 +582,14 @@ impl AssertEq for OsuDifficultyAttributes { speed, flashlight, slider_factor, + aim_top_weighted_slider_factor, + speed_top_weighted_slider_factor, speed_note_count, aim_difficult_strain_count, speed_difficult_strain_count, + nested_score_per_object, + legacy_score_base_multiplier, + maximum_legacy_combo_score, ar, great_hit_window, ok_hit_window, @@ -524,6 +611,14 @@ impl AssertEq for OsuDifficultyAttributes { assert_eq_float(*speed, expected.speed); assert_eq_float(*flashlight, expected.flashlight); assert_eq_float(*slider_factor, expected.slider_factor); + assert_eq_float( + *aim_top_weighted_slider_factor, + expected.aim_top_weighted_slider_factor, + ); + assert_eq_float( + *speed_top_weighted_slider_factor, + expected.speed_top_weighted_slider_factor, + ); assert_eq_float(*speed_note_count, expected.speed_note_count); assert_eq_float( *aim_difficult_strain_count, @@ -533,6 +628,15 @@ impl AssertEq for OsuDifficultyAttributes { *speed_difficult_strain_count, expected.speed_difficult_strain_count, ); + assert_eq_float(*nested_score_per_object, expected.nested_score_per_object); + assert_eq_float( + *legacy_score_base_multiplier, + expected.legacy_score_base_multiplier, + ); + assert_eq_float( + *maximum_legacy_combo_score, + expected.maximum_legacy_combo_score, + ); assert_eq_float(*ar, expected.ar); assert_eq_float(*great_hit_window, expected.great_hit_window); assert_eq_float(*ok_hit_window, expected.ok_hit_window); @@ -557,6 +661,8 @@ impl AssertEq for TaikoDifficultyAttributes { great_hit_window, ok_hit_window, mono_stamina_factor, + mechanical_difficulty, + consistency_factor, stars, max_combo, is_convert, @@ -569,6 +675,8 @@ impl AssertEq for TaikoDifficultyAttributes { assert_eq_float(*great_hit_window, expected.great_hit_window); assert_eq_float(*ok_hit_window, expected.ok_hit_window); assert_eq_float(*mono_stamina_factor, expected.mono_stamina_factor); + assert_eq_float(*mechanical_difficulty, expected.mechanical_difficulty); + assert_eq_float(*consistency_factor, expected.consistency_factor); assert_eq_float(*stars, expected.stars); assert_eq!(*max_combo, expected.max_combo); assert_eq!(*is_convert, expected.is_convert); @@ -579,7 +687,7 @@ impl AssertEq for CatchDifficultyAttributes { fn assert_eq(&self, expected: &Self) { let Self { stars, - ar, + preempt, n_fruits, n_droplets, n_tiny_droplets, @@ -587,7 +695,7 @@ impl AssertEq for CatchDifficultyAttributes { } = self; assert_eq_float(*stars, expected.stars); - assert_eq_float(*ar, expected.ar); + assert_eq_float(*preempt, expected.preempt); assert_eq!(*n_fruits, expected.n_fruits); assert_eq!(*n_droplets, expected.n_droplets); assert_eq!(*n_tiny_droplets, expected.n_tiny_droplets); diff --git a/tests/performance.rs b/tests/performance.rs index 950c6fbf..81e219cb 100644 --- a/tests/performance.rs +++ b/tests/performance.rs @@ -36,6 +36,10 @@ macro_rules! test_cases { pp_speed: $pp_speed:expr, effective_miss_count: $effective_miss_count:expr, speed_deviation: $speed_deviation:expr, + combo_based_estimated_miss_count: $combo_based_estimated_miss_count:expr, + score_based_estimated_miss_count: $score_based_estimated_miss_count:expr, + aim_estimated_slider_breaks: $aim_estimated_slider_breaks:expr, + speed_estimated_slider_breaks: $speed_estimated_slider_breaks:expr, }) => { ( OsuPerformance::from(&$map).lazer(true), @@ -47,6 +51,10 @@ macro_rules! test_cases { pp_speed: $pp_speed, effective_miss_count: $effective_miss_count, speed_deviation: $speed_deviation, + combo_based_estimated_miss_count: $combo_based_estimated_miss_count, + score_based_estimated_miss_count: $score_based_estimated_miss_count, + aim_estimated_slider_breaks: $aim_estimated_slider_breaks, + speed_estimated_slider_breaks: $speed_estimated_slider_breaks, ..Default::default() }, ) @@ -56,7 +64,6 @@ macro_rules! test_cases { pp: $pp:expr, pp_acc: $pp_acc:expr, pp_difficulty: $pp_difficulty:expr, - effective_miss_count: $effective_miss_count:expr, estimated_unstable_rate: $estimated_unstable_rate:expr, }) => { ( @@ -65,7 +72,6 @@ macro_rules! test_cases { pp: $pp, pp_acc: $pp_acc, pp_difficulty: $pp_difficulty, - effective_miss_count: $effective_miss_count, estimated_unstable_rate: $estimated_unstable_rate, ..Default::default() }, @@ -105,67 +111,95 @@ fn basic_osu() { test_cases! { Osu: OSU { NM => { - pp: 273.55482143018787, - pp_acc: 97.62287463107766, - pp_aim: 98.91907543994563, + pp: 287.9051448920619, + pp_acc: 98.99847982709288, + pp_aim: 113.66811014707582, pp_flashlight: 0.0, - pp_speed: 65.89857918351103, + pp_speed: 65.7316947411581, effective_miss_count: 0.0, - speed_deviation: Some(11.855079578025586), + speed_deviation: Some(11.559405011202584), + combo_based_estimated_miss_count: 0.0, + score_based_estimated_miss_count: None, + aim_estimated_slider_breaks: 0.0, + speed_estimated_slider_breaks: 0.0, }; HD => { - pp: 300.22543969392876, - pp_acc: 105.43270460156388, - pp_aim: 109.6023348327671, + pp: 315.8674097332546, + pp_acc: 106.91835821326032, + pp_aim: 125.5489356876975, pp_flashlight: 0.0, - pp_speed: 73.01562523256396, + pp_speed: 72.9912208672784, effective_miss_count: 0.0, - speed_deviation: Some(11.855079578025586), + speed_deviation: Some(11.559405011202584), + combo_based_estimated_miss_count: 0.0, + score_based_estimated_miss_count: None, + aim_estimated_slider_breaks: 0.0, + speed_estimated_slider_breaks: 0.0, }; EZ HD => { - pp: 186.89279040924507, - pp_acc: 16.6270597231239, - pp_aim: 96.66495037071935, + pp: 200.88128466771315, + pp_acc: 17.33989029835826, + pp_aim: 109.17177789930311, pp_flashlight: 0.0, - pp_speed: 63.20254195234833, + pp_speed: 64.55964097206972, effective_miss_count: 0.0, - speed_deviation: Some(23.1539101317497), + speed_deviation: Some(22.768253044002595), + combo_based_estimated_miss_count: 0.0, + score_based_estimated_miss_count: None, + aim_estimated_slider_breaks: 0.0, + speed_estimated_slider_breaks: 0.0, }; HR => { - pp: 405.7923033508039, + pp: 422.8822464661912, pp_acc: 161.55575439788055, - pp_aim: 145.16956208688748, + pp_aim: 167.50210608714042, pp_flashlight: 0.0, - pp_speed: 81.7375074976892, + pp_speed: 78.89335639563441, effective_miss_count: 0.0, - speed_deviation: Some(8.857953596258472), + speed_deviation: Some(8.598712200750178), + combo_based_estimated_miss_count: 0.0, + score_based_estimated_miss_count: None, + aim_estimated_slider_breaks: 0.0, + speed_estimated_slider_breaks: 0.0, }; DT => { - pp: 741.3788121712137, - pp_acc: 184.09450675506795, - pp_aim: 301.88645138363296, + pp: 784.2400469306212, + pp_acc: 183.66566616694254, + pp_aim: 348.7917741691343, pp_flashlight: 0.0, - pp_speed: 224.91817628556774, + pp_speed: 224.8868678368528, effective_miss_count: 0.0, - speed_deviation: Some(7.873979522967204), + speed_deviation: Some(7.6754769185728815), + combo_based_estimated_miss_count: 0.0, + score_based_estimated_miss_count: None, + aim_estimated_slider_breaks: 0.0, + speed_estimated_slider_breaks: 0.0, }; FL => { - pp: 403.31324405364177, - pp_acc: 99.57533212369923, - pp_aim: 98.91907543994563, - pp_flashlight: 132.28811994208644, - pp_speed: 65.89857918351103, + pp: 415.9768919360004, + pp_acc: 100.97844942363474, + pp_aim: 113.66811014707582, + pp_flashlight: 132.3188848707867, + pp_speed: 65.7316947411581, effective_miss_count: 0.0, - speed_deviation: Some(11.855079578025586), + speed_deviation: Some(11.559405011202584), + combo_based_estimated_miss_count: 0.0, + score_based_estimated_miss_count: None, + aim_estimated_slider_breaks: 0.0, + speed_estimated_slider_breaks: 0.0, }; HD FL => { - pp: 470.3193633451629, - pp_acc: 107.54135869359516, - pp_aim: 109.6023348327671, - pp_flashlight: 171.61406165164135, - pp_speed: 73.01562523256396, + pp: 483.7752666636294, + pp_acc: 109.05672537752552, + pp_aim: 125.5489356876975, + pp_flashlight: 171.65397211175005, + pp_speed: 72.9912208672784, effective_miss_count: 0.0, - speed_deviation: Some(11.855079578025586), + speed_deviation: Some(11.559405011202584), + combo_based_estimated_miss_count: 0.0, + score_based_estimated_miss_count: None, + aim_estimated_slider_breaks: 0.0, + speed_estimated_slider_breaks: 0.0, }; } }; @@ -176,32 +210,28 @@ fn basic_taiko() { test_cases! { Taiko: TAIKO { NM => { - pp: 104.65974235594882, - pp_acc: 67.01508452097738, - pp_difficulty: 30.951117266143964, - effective_miss_count: 0.0, - estimated_unstable_rate: Some(148.44150180469418), + pp: 130.3342753050141, + pp_acc: 96.78235028730231, + pp_difficulty: 33.551925017711795, + estimated_unstable_rate: Some(146.32383579722838), }; HD => { - pp: 113.35231886537841, - pp_acc: 67.01508452097738, - pp_difficulty: 31.72489519779756, - effective_miss_count: 0.0, - estimated_unstable_rate: Some(148.44150180469418), + pp: 138.26399007691603, + pp_acc: 104.04102655884999, + pp_difficulty: 34.22296351806603, + estimated_unstable_rate: Some(146.32383579722838), }; HR => { - pp: 125.39316057548226, - pp_acc: 83.3355298805701, - pp_difficulty: 33.77220597125385, - effective_miss_count: 0.0, - estimated_unstable_rate: Some(122.99438720960376), + pp: 166.77434181278937, + pp_acc: 130.20134262470424, + pp_difficulty: 36.57299918808513, + estimated_unstable_rate: Some(120.87621218031911), }; DT => { - pp: 217.2255599983772, - pp_acc: 119.35453575917016, - pp_difficulty: 85.09547264616562, - effective_miss_count: 0.0, - estimated_unstable_rate: Some(98.96100120312946), + pp: 266.1232222806763, + pp_acc: 173.19552687459048, + pp_difficulty: 92.92769540608585, + estimated_unstable_rate: Some(97.54922386481893), }; } }; @@ -213,32 +243,28 @@ fn convert_taiko() { test_cases! { Taiko: OSU { NM => { - pp: 321.96508788209525, - pp_acc: 150.50068595207387, - pp_difficulty: 152.95500113793892, - effective_miss_count: 0.0, - estimated_unstable_rate: Some(85.75868894575865), + pp: 388.8389762212271, + pp_acc: 220.27109512284244, + pp_difficulty: 168.56788109838465, + estimated_unstable_rate: Some(81.74165086164194), }; HD => { - pp: 326.0279405978374, - pp_acc: 150.50068595207387, - pp_difficulty: 156.7788761663874, - effective_miss_count: 0.0, - estimated_unstable_rate: Some(85.75868894575865), + pp: 389.68181562671896, + pp_acc: 220.27109512284244, + pp_difficulty: 169.41072050387652, + estimated_unstable_rate: Some(81.74165086164194), }; HR => { - pp: 400.1259115798042, - pp_acc: 187.46770845243455, - pp_difficulty: 189.65602547641478, - effective_miss_count: 0.0, - estimated_unstable_rate: Some(72.67685680089848), + pp: 466.289743228387, + pp_acc: 259.1975812188925, + pp_difficulty: 207.0921620094945, + estimated_unstable_rate: Some(70.8427640800897), }; DT => { - pp: 688.6809319343615, - pp_acc: 274.8702821415836, - pp_difficulty: 373.46911205993484, - effective_miss_count: 0.0, - estimated_unstable_rate: Some(57.17245929717244), + pp: 805.083671145498, + pp_acc: 392.44103449845574, + pp_difficulty: 412.64263664704225, + estimated_unstable_rate: Some(54.494433907761305), }; } } @@ -311,6 +337,10 @@ impl AssertEq for OsuPerformanceAttributes { pp_speed, effective_miss_count, speed_deviation, + combo_based_estimated_miss_count, + score_based_estimated_miss_count, + aim_estimated_slider_breaks, + speed_estimated_slider_breaks, } = self; assert_eq_float(*pp, expected.pp); @@ -320,6 +350,22 @@ impl AssertEq for OsuPerformanceAttributes { assert_eq_float(*pp_speed, expected.pp_speed); assert_eq_float(*effective_miss_count, expected.effective_miss_count); assert_eq_option(*speed_deviation, expected.speed_deviation); + assert_eq_float( + *combo_based_estimated_miss_count, + expected.combo_based_estimated_miss_count, + ); + assert_eq_option( + *score_based_estimated_miss_count, + expected.score_based_estimated_miss_count, + ); + assert_eq_float( + *aim_estimated_slider_breaks, + expected.aim_estimated_slider_breaks, + ); + assert_eq_float( + *speed_estimated_slider_breaks, + expected.speed_estimated_slider_breaks, + ); } } @@ -330,14 +376,12 @@ impl AssertEq for TaikoPerformanceAttributes { pp, pp_acc, pp_difficulty, - effective_miss_count, estimated_unstable_rate, } = self; assert_eq_float(*pp, expected.pp); assert_eq_float(*pp_acc, expected.pp_acc); assert_eq_float(*pp_difficulty, expected.pp_difficulty); - assert_eq_float(*effective_miss_count, expected.effective_miss_count); assert_eq_option(*estimated_unstable_rate, expected.estimated_unstable_rate); } }