├── .github └── workflows │ ├── check.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE ├── README.md ├── proptest-regressions ├── catch │ └── performance │ │ └── mod.txt ├── mania │ └── performance │ │ └── mod.txt ├── osu │ └── performance │ │ └── mod.txt ├── taiko │ └── performance │ │ └── mod.txt └── util │ └── sort │ └── tandem.txt ├── resources ├── 1028484.osu ├── 1638954.osu ├── 2118524.osu └── 2785319.osu ├── src ├── any │ ├── attributes.rs │ ├── difficulty │ │ ├── gradual.rs │ │ ├── inspect.rs │ │ ├── mod.rs │ │ ├── object.rs │ │ └── skills.rs │ ├── mod.rs │ ├── performance │ │ ├── gradual.rs │ │ ├── into.rs │ │ └── mod.rs │ ├── score_state.rs │ └── strains.rs ├── catch │ ├── attributes.rs │ ├── catcher.rs │ ├── convert.rs │ ├── difficulty │ │ ├── gradual.rs │ │ ├── mod.rs │ │ ├── object.rs │ │ └── skills │ │ │ ├── mod.rs │ │ │ └── movement.rs │ ├── mod.rs │ ├── object │ │ ├── banana_shower.rs │ │ ├── fruit.rs │ │ ├── juice_stream.rs │ │ ├── mod.rs │ │ └── palpable.rs │ ├── performance │ │ ├── calculator.rs │ │ ├── gradual.rs │ │ └── mod.rs │ ├── score_state.rs │ └── strains.rs ├── lib.rs ├── mania │ ├── attributes.rs │ ├── convert │ │ ├── mod.rs │ │ ├── pattern.rs │ │ ├── pattern_generator │ │ │ ├── end_time_object.rs │ │ │ ├── hit_object.rs │ │ │ ├── mod.rs │ │ │ └── path_object.rs │ │ └── pattern_type.rs │ ├── difficulty │ │ ├── gradual.rs │ │ ├── mod.rs │ │ ├── object.rs │ │ └── skills │ │ │ ├── mod.rs │ │ │ └── strain.rs │ ├── mod.rs │ ├── object.rs │ ├── performance │ │ ├── calculator.rs │ │ ├── gradual.rs │ │ └── mod.rs │ ├── score_state.rs │ └── strains.rs ├── model │ ├── beatmap │ │ ├── attributes.rs │ │ ├── bpm.rs │ │ ├── decode.rs │ │ ├── mod.rs │ │ └── suspicious.rs │ ├── control_point │ │ ├── difficulty.rs │ │ ├── effect.rs │ │ ├── mod.rs │ │ └── timing.rs │ ├── hit_object.rs │ ├── mod.rs │ ├── mode.rs │ └── mods.rs ├── osu │ ├── attributes.rs │ ├── convert.rs │ ├── difficulty │ │ ├── gradual.rs │ │ ├── mod.rs │ │ ├── object.rs │ │ ├── scaling_factor.rs │ │ └── skills │ │ │ ├── aim.rs │ │ │ ├── flashlight.rs │ │ │ ├── mod.rs │ │ │ ├── speed.rs │ │ │ └── strain.rs │ ├── mod.rs │ ├── object.rs │ ├── performance │ │ ├── calculator.rs │ │ ├── gradual.rs │ │ └── mod.rs │ ├── score_state.rs │ └── strains.rs ├── taiko │ ├── attributes.rs │ ├── convert.rs │ ├── difficulty │ │ ├── color │ │ │ ├── color_data.rs │ │ │ ├── data │ │ │ │ ├── alternating_mono_pattern.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── mono_streak.rs │ │ │ │ └── repeating_hit_patterns.rs │ │ │ ├── mod.rs │ │ │ └── preprocessor.rs │ │ ├── gradual.rs │ │ ├── mod.rs │ │ ├── object.rs │ │ ├── rhythm │ │ │ ├── data │ │ │ │ ├── mod.rs │ │ │ │ ├── same_patterns_grouped_hit_objects.rs │ │ │ │ └── same_rhythm_hit_object_grouping.rs │ │ │ ├── mod.rs │ │ │ ├── preprocessor.rs │ │ │ └── rhythm_data.rs │ │ └── skills │ │ │ ├── color.rs │ │ │ ├── mod.rs │ │ │ ├── reading.rs │ │ │ ├── rhythm.rs │ │ │ └── stamina.rs │ ├── mod.rs │ ├── object.rs │ ├── performance │ │ ├── calculator.rs │ │ ├── gradual.rs │ │ └── mod.rs │ ├── score_state.rs │ └── strains.rs └── util │ ├── difficulty.rs │ ├── float_ext.rs │ ├── hint.rs │ ├── interval_grouping.rs │ ├── limited_queue.rs │ ├── macros.rs │ ├── map_or_attrs.rs │ ├── mod.rs │ ├── random │ ├── csharp.rs │ ├── mod.rs │ └── osu.rs │ ├── sort │ ├── csharp.rs │ ├── mod.rs │ ├── osu_legacy.rs │ └── tandem.rs │ ├── special_functions.rs │ ├── strains_vec.rs │ └── sync.rs └── tests ├── common.rs ├── decode.rs ├── difficulty.rs └── performance.rs /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Clippy checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - next 8 | pull_request: 9 | 10 | jobs: 11 | clippy: 12 | name: Clippy 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout project 17 | uses: actions/checkout@v4 18 | 19 | - name: Install stable toolchain 20 | uses: dtolnay/rust-toolchain@stable 21 | 22 | - name: Cache dependencies 23 | uses: Swatinem/rust-cache@v2 24 | 25 | - name: Run clippy 26 | run: cargo clippy --all-targets 27 | 28 | - name: Check if README is up to date 29 | run: | 30 | cargo install cargo-rdme 31 | cargo rdme --check --no-fail-on-warnings 32 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - next 8 | pull_request: 9 | 10 | jobs: 11 | doc: 12 | name: Doc tests 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout project 17 | uses: actions/checkout@v4 18 | 19 | - name: Install stable toolchain 20 | uses: dtolnay/rust-toolchain@stable 21 | 22 | - name: Cache dependencies 23 | uses: Swatinem/rust-cache@v2 24 | 25 | - name: Run doctests 26 | run: > 27 | cargo test 28 | --doc 29 | --no-default-features 30 | 31 | default: 32 | name: Default tests 33 | runs-on: ubuntu-latest 34 | 35 | steps: 36 | - name: Checkout project 37 | uses: actions/checkout@v4 38 | 39 | - name: Install stable toolchain 40 | uses: dtolnay/rust-toolchain@stable 41 | 42 | - name: Cache dependencies 43 | uses: Swatinem/rust-cache@v2 44 | 45 | - name: Install nextest 46 | uses: taiki-e/install-action@nextest 47 | 48 | - name: Run all tests 49 | run: > 50 | cargo nextest run 51 | --no-default-features 52 | --no-fail-fast --failure-output=immediate-final 53 | 54 | sync: 55 | name: Test sync feature 56 | runs-on: ubuntu-latest 57 | 58 | steps: 59 | - name: Checkout project 60 | uses: actions/checkout@v4 61 | 62 | - name: Install stable toolchain 63 | uses: dtolnay/rust-toolchain@stable 64 | 65 | - name: Cache dependencies 66 | uses: Swatinem/rust-cache@v2 67 | 68 | - name: Install nextest 69 | uses: taiki-e/install-action@nextest 70 | 71 | - name: Run specific tests 72 | run: > 73 | cargo nextest run 74 | --features sync 75 | --filter-expr 'test(util::sync::tests::share_gradual_taiko)' 76 | --filter-expr 'test(taiko::difficulty::gradual::tests::next_and_nth)' 77 | --no-fail-fast --failure-output=immediate-final 78 | 79 | non_compact: 80 | name: Test with raw_strains feature 81 | runs-on: ubuntu-latest 82 | 83 | steps: 84 | - name: Checkout project 85 | uses: actions/checkout@v4 86 | 87 | - name: Install stable toolchain 88 | uses: dtolnay/rust-toolchain@stable 89 | 90 | - name: Cache dependencies 91 | uses: Swatinem/rust-cache@v2 92 | 93 | - name: Install nextest 94 | uses: taiki-e/install-action@nextest 95 | 96 | - name: Run integration tests 97 | run: > 98 | cargo nextest run 99 | --no-default-features 100 | --features raw_strains 101 | --test '*' 102 | --no-fail-fast --failure-output=immediate-final 103 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | output* 4 | /.idea 5 | /tests/custom.rs 6 | expanded.rs 7 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rosu-pp" 3 | version = "3.0.0" 4 | edition = "2021" 5 | authors = ["MaxOhn "] 6 | license = "MIT" 7 | readme = "README.md" 8 | repository = "https://github.com/MaxOhn/rosu-pp" 9 | documentation = "https://docs.rs/rosu-pp/" 10 | description = "Difficulty and performance calculation for osu!" 11 | keywords = ["osu", "pp", "stars", "performance", "difficulty"] 12 | 13 | [features] 14 | default = [] 15 | raw_strains = [] 16 | sync = [] 17 | tracing = ["rosu-map/tracing"] 18 | 19 | [dependencies] 20 | rosu-map = { version = "0.2.1" } 21 | rosu-mods = { version = "0.3.0" } 22 | 23 | [dev-dependencies] 24 | proptest = "1.6.0" 25 | 26 | [profile.test.package.proptest] 27 | opt-level = 3 28 | 29 | [profile.test.package.rand_chacha] 30 | opt-level = 3 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Max 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /proptest-regressions/catch/performance/mod.txt: -------------------------------------------------------------------------------- 1 | # Seeds for failure cases proptest has generated in the past. It is 2 | # automatically read and these particular cases re-run before any 3 | # novel cases are generated. 4 | # 5 | # It is recommended to check this file in to source control so that 6 | # everyone who runs the test benefits from these saved cases. 7 | cc c75ef0e1156e1fb7c3eec623d7bb3ff00373825b76fe5a699be004eabfab596f # shrinks to acc = 0.0, n_fruits = None, n_droplets = None, n_tiny_droplets = None, n_tiny_droplet_misses = None, n_misses = None 8 | cc 7bf5d798492945aadd68cf23a1d25c530171b4e396be8c7c8b7b5273e3fb5a75 # shrinks to acc = 0.0, n_fruits = Some(0), n_droplets = None, n_tiny_droplets = None, n_tiny_droplet_misses = None, n_misses = None 9 | cc 3b895b7f5cf43539ab1536bb51a0212bc354b0de7959cd0f548c8eb19085f99c # shrinks to acc = 0.9572076425636069, n_fruits = None, n_droplets = None, n_tiny_droplets = None, n_tiny_droplet_misses = None, n_misses = Some(2) 10 | cc fffc517d7d7aeadfd6e7e9514cdfe04239233e7fb9c35dca2a6fe47b6bb3c215 # shrinks to acc = 0.0, n_fruits = None, n_droplets = Some(0), n_tiny_droplets = None, n_tiny_droplet_misses = None, n_misses = None 11 | cc fdeec58e1c568f52970c0a74a824ab6cf0300892af19f4bd3826f2af19a38d08 # shrinks to acc = 0.0, n_fruits = Some(10), n_droplets = None, n_tiny_droplets = None, n_tiny_droplet_misses = None, n_misses = Some(721) 12 | cc dd03c40dec8e5efd5031fca53c2a1804e9537a3e864b0c21136169b8d411ec23 # shrinks to acc = 0.0, n_fruits = None, n_droplets = None, n_tiny_droplets = Some(0), n_tiny_droplet_misses = Some(0), n_misses = None 13 | cc dd7bf3c50e5165f9e2f265336af23a38c78a0bfafd9b1dc54d32f50dfeb84c79 # shrinks to acc = 0.9407007157860147, n_fruits = Some(671), n_droplets = Some(0), n_tiny_droplets = None, n_tiny_droplet_misses = None, n_misses = Some(61) 14 | cc 6e78f69d0a44ad58d5a2408cbadf7913cc3b77934b6c16eb1f41587fc151545d # shrinks to acc = 0.0, n_fruits = None, n_droplets = None, n_tiny_droplets = None, n_tiny_droplet_misses = Some(292), n_misses = None 15 | -------------------------------------------------------------------------------- /proptest-regressions/mania/performance/mod.txt: -------------------------------------------------------------------------------- 1 | # Seeds for failure cases proptest has generated in the past. It is 2 | # automatically read and these particular cases re-run before any 3 | # novel cases are generated. 4 | # 5 | # It is recommended to check this file in to source control so that 6 | # everyone who runs the test benefits from these saved cases. 7 | cc f43e82d27eb4d8c4b748c7b83a8fb3b6160daa5dc5cb863856a15e6480a325d0 # shrinks to acc = 0.0, n320 = None, n300 = None, n200 = None, n100 = None, n50 = None, n_misses = None, best_case = false 8 | cc 0bb9723c2bb7e394cd0f105b1c775d9f233885477b14ecc0b5167282b402a310 # shrinks to acc = 0.190179679226917, n320 = None, n300 = None, n200 = None, n100 = None, n50 = None, n_misses = None, best_case = false 9 | cc af7f41af420babe5dd8de26efc4831957214ee21d6991ded489a412bdcd8818a # shrinks to acc = 0.0, n320 = None, n300 = None, n200 = None, n100 = None, n50 = Some(0), n_misses = Some(0), best_case = true 10 | cc a254712f60935104af638a970cf19c33ea69b442bf5bde67f331b5bb8e65b42b # shrinks to acc = 0.0, n320 = None, n300 = Some(1), n200 = None, n100 = None, n50 = None, n_misses = None, best_case = false 11 | cc 43e15d089c0bf50dde4e2afa78e58955fa5c3826abd856cff5bdad8cc9b00e1d # shrinks to acc = 0.7251029619347622, n320 = None, n300 = None, n200 = Some(233), n100 = None, n50 = None, n_misses = None, best_case = false 12 | cc cc91915aa4df7793cc0caf070575495c40f781b6d9769824cf87a640523cbb7d # shrinks to acc = 0.0, n320 = None, n300 = Some(1), n200 = None, n100 = None, n50 = None, n_misses = None, best_case = true 13 | cc 4657290897894a74501af88cadbc328ee89ca52e7c7e2f0823b5fe6fdbb6e428 # shrinks to acc = 0.0, n320 = Some(1), n300 = None, n200 = Some(290), n100 = None, n50 = None, n_misses = Some(688), best_case = false 14 | cc c6c864a257c332ce9b9490ac45cece5ef0aaa59b78e333664c05ca38de0a4cf6 # shrinks to acc = 0.0, n320 = Some(374), n300 = None, n200 = None, n100 = Some(605), n50 = None, n_misses = None, best_case = false 15 | cc 2f2e0a687f294c30e4e055eaa860452a165cc4f896a3e71346ebbdb0f1e18e0d # shrinks to acc = 0.8763364816127952, n320 = None, n300 = None, n200 = None, n100 = None, n50 = Some(1), n_misses = Some(121), best_case = false 16 | cc b5c21ba35da112003a5e1062b091e93381ab3d6d227c5f0cdc7cce0669747152 # shrinks to acc = 0.4032924787038701, n320 = None, n300 = None, n200 = None, n100 = None, n50 = Some(0), n_misses = None, best_case = true 17 | cc 049e6f4db0c2cd283843a6d777d7fbf3001e07eb39eb8ae23338e6f371fffd24 # shrinks to acc = 0.9038578552279879, n320 = None, n300 = Some(0), n200 = None, n100 = None, n50 = None, n_misses = None, best_case = false 18 | cc 8067939c5014c26f15a25c1255fbc3452f42b159be3b1271029ba577a7da5af3 # shrinks to acc = 0.6801960172852813, n320 = Some(0), n300 = None, n200 = None, n100 = Some(0), n50 = None, n_misses = None, best_case = false 19 | cc 0924f546edd96e86ee6163aa08dd6b77fc5a6219be6766a5b5d3b6d0caa80145 # shrinks to acc = 0.5630003027452851, n320 = None, n300 = None, n200 = Some(4), n100 = None, n50 = Some(37), n_misses = None, best_case = false 20 | cc a3c828166cc0a217e7a4e2d3592372209f886b6ea78a3da40664f3201df5a49b # shrinks to acc = 0.0, n320 = Some(0), n300 = Some(66), n200 = None, n100 = None, n50 = None, n_misses = Some(529), best_case = false 21 | cc 1d15772c531f04fc3c0cc28ce76cf219ce7db950f36d3688bc01714a72fb8e5a # shrinks to acc = 0.0, n320 = None, n300 = None, n200 = None, n100 = None, n50 = None, n_misses = Some(595), best_case = false 22 | cc d3ee41c1d71505bc965f52f591792539d06b370305428f1f20d798b0c525328d 23 | cc c52017a8d3f2ecdd36b2d522a8e5f17b7fb7b951a9eb84c8d2537b857f019f47 # shrinks to classic = false, acc = 0.463979922129014, n320 = None, n300 = Some(1), n200 = None, n100 = None, n50 = None, n_misses = Some(384), best_case = false 24 | cc c7995a555d9fc698d9eb52c3ec30604cae4463c57d7e54abe1af1b823dce3bbd # shrinks to classic = false, acc = 0.3455386317199003, n320 = None, n300 = None, n200 = None, n100 = Some(0), n50 = None, n_misses = None, best_case = true 25 | cc e6baa2d0e1559edaf17898b9cfea4ad24b1ac0f4a336fa372b9e768e79675d21 # shrinks to classic = false, acc = 0.0, n320 = None, n300 = None, n200 = None, n100 = None, n50 = Some(0), n_misses = None, best_case = true 26 | -------------------------------------------------------------------------------- /proptest-regressions/osu/performance/mod.txt: -------------------------------------------------------------------------------- 1 | # Seeds for failure cases proptest has generated in the past. It is 2 | # automatically read and these particular cases re-run before any 3 | # novel cases are generated. 4 | # 5 | # It is recommended to check this file in to source control so that 6 | # everyone who runs the test benefits from these saved cases. 7 | cc aee5b74b9ef816122b8e9746e1666c6f1ee226bdf4fa6074dc561ead81e687d6 # shrinks to acc = 0.0, combo = None, n300 = None, n100 = None, n50 = None, n_misses = None, best_case = false 8 | cc 65db688d970d7dd0c2e0921aa54f207810145b4d3db4c71a3332b7daf75c813f # shrinks to acc = 0.0, combo = None, n300 = None, n100 = None, n50 = None, n_misses = Some(1), best_case = false 9 | cc 4dd36fb5fc6aaeb637305941c1dbd224df904729734425f6bd61aded44c20b82 # shrinks to acc = 0.7854494370626834, combo = None, n300 = None, n100 = Some(194), n50 = None, n_misses = None, best_case = false 10 | cc 6df5e623c62ffa2830f17f4e7a9bb573cb3eb4c4c45bc3c0793aad26aa72cf95 # shrinks to acc = 0.0, combo = None, n300 = None, n100 = None, n50 = Some(0), n_misses = None, best_case = false 11 | cc e5a861f6c665dd09e46423e71d7596edf98897d4130d3144aa6f5be580f31a8b # shrinks to acc = 0.0, combo = None, n300 = None, n100 = Some(293), n50 = None, n_misses = Some(309), best_case = false 12 | cc 2cd5c105bcca0b4255afccc15bee3894b06bd20ac3f5c5d3b785f7e0ef99df46 # shrinks to acc = 0.0, combo = None, n300 = Some(0), n100 = None, n50 = Some(479), n_misses = Some(123), best_case = false 13 | cc 2cba8a76243aac7233e9207a3162aaa1f08f933c0cb3a2ac79580ece3a7329fc # shrinks to acc = 0.0, n300 = Some(0), n100 = Some(0), n50 = Some(0), n_misses = None, best_case = false 14 | cc e93787ad8a849ec6d05750c8d09494b8f5a9fa785f843d9a8e2db986c0b32645 # shrinks to acc = 0.0, n300 = None, n100 = None, n50 = None, n_misses = Some(602), best_case = false 15 | cc a53cb48861126aa63be54606f9a770db5eae95242c9a9d75cf1fd101cfb21729 # shrinks to lazer = true, acc = 0.5679586776392227, n_slider_ticks = None, n_slider_ends = None, n300 = None, n100 = None, n50 = Some(0), n_misses = None, best_case = false 16 | cc cacb94cb2a61cf05e7083e332b378290a6267a499bf30821228bc0ae4dfe46f6 # shrinks to lazer = true, acc = 0.5270982297689498, n_slider_ticks = None, n_slider_ends = None, n300 = Some(70), n100 = None, n50 = None, n_misses = None, best_case = false 17 | cc 5679a686382f641f1fa3407a6e19e1caa0adff27e42c397778a2d178361719a3 # shrinks to lazer = true, classic = false, acc = 0.4911232243285752, large_tick_hits = None, slider_end_hits = Some(0), n300 = None, n100 = None, n50 = None, n_misses = None, best_case = false 18 | -------------------------------------------------------------------------------- /proptest-regressions/taiko/performance/mod.txt: -------------------------------------------------------------------------------- 1 | # Seeds for failure cases proptest has generated in the past. It is 2 | # automatically read and these particular cases re-run before any 3 | # novel cases are generated. 4 | # 5 | # It is recommended to check this file in to source control so that 6 | # everyone who runs the test benefits from these saved cases. 7 | cc 1883e1e612026e7f7c8035803830ff21b6d98c946cfb92c5c144e682a5d72ad2 # shrinks to acc = 0.0, n300 = None, n100 = None, n_misses = None, best_case = false 8 | cc 84792fb0ec8971122d0eb2e61355a637e9326ac2b7469226bba06a2defdc44dd # shrinks to acc = 0.659020825478002, n300 = None, n100 = None, n_misses = Some(99), best_case = false 9 | cc 94fb95e246e580e010115334e5fd7de6160b3a24a735e4cb4ab4341f5f7b769e # shrinks to acc = 0.0, n300 = Some(0), n100 = Some(1), n_misses = None, best_case = false 10 | cc fcc52507f0304061f5b94a7aa8bb5cc40e7156effe1e2c3434161a5c152409d0 # shrinks to acc = 0.0, n300 = None, n100 = Some(1), n_misses = None, best_case = false 11 | cc 8d6b15c9881ebf33b1b91bb9398264048b28a95cb902f747af6c8320063c21a6 # shrinks to acc = 0.0, n300 = Some(240), n100 = Some(50), n_misses = None, best_case = false 12 | cc eda23b52ad1453caba35977df01c1aecf4151a8fc64968fec38f66e2b4970422 # shrinks to acc = 0.0, n300 = None, n100 = None, n_misses = Some(290), best_case = false 13 | -------------------------------------------------------------------------------- /proptest-regressions/util/sort/tandem.txt: -------------------------------------------------------------------------------- 1 | # Seeds for failure cases proptest has generated in the past. It is 2 | # automatically read and these particular cases re-run before any 3 | # novel cases are generated. 4 | # 5 | # It is recommended to check this file in to source control so that 6 | # everyone who runs the test benefits from these saved cases. 7 | cc aae32727f89a3bbed40390df4d260ec2206af666c17147c2ff550efe2e388594 # shrinks to mut actual = [3, 0, 0, 0] 8 | -------------------------------------------------------------------------------- /src/any/attributes.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | catch::{CatchDifficultyAttributes, CatchPerformanceAttributes}, 3 | mania::{ManiaDifficultyAttributes, ManiaPerformanceAttributes}, 4 | osu::{OsuDifficultyAttributes, OsuPerformanceAttributes}, 5 | taiko::{TaikoDifficultyAttributes, TaikoPerformanceAttributes}, 6 | }; 7 | 8 | use super::performance::{into::IntoPerformance, Performance}; 9 | 10 | /// The result of a difficulty calculation based on the mode. 11 | #[derive(Clone, Debug, PartialEq)] 12 | pub enum DifficultyAttributes { 13 | /// osu!standard difficulty calculation result. 14 | Osu(OsuDifficultyAttributes), 15 | /// osu!taiko difficulty calculation result. 16 | Taiko(TaikoDifficultyAttributes), 17 | /// osu!catch difficulty calculation result. 18 | Catch(CatchDifficultyAttributes), 19 | /// osu!mania difficulty calculation result. 20 | Mania(ManiaDifficultyAttributes), 21 | } 22 | 23 | impl DifficultyAttributes { 24 | /// The star value. 25 | pub const fn stars(&self) -> f64 { 26 | match self { 27 | Self::Osu(attrs) => attrs.stars, 28 | Self::Taiko(attrs) => attrs.stars, 29 | Self::Catch(attrs) => attrs.stars, 30 | Self::Mania(attrs) => attrs.stars, 31 | } 32 | } 33 | 34 | /// The maximum combo of the map. 35 | pub const fn max_combo(&self) -> u32 { 36 | match self { 37 | Self::Osu(attrs) => attrs.max_combo, 38 | Self::Taiko(attrs) => attrs.max_combo, 39 | Self::Catch(attrs) => attrs.max_combo(), 40 | Self::Mania(attrs) => attrs.max_combo, 41 | } 42 | } 43 | 44 | /// Returns a builder for performance calculation. 45 | pub fn performance<'a>(self) -> Performance<'a> { 46 | self.into_performance() 47 | } 48 | } 49 | 50 | /// The result of a performance calculation based on the mode. 51 | #[derive(Clone, Debug, PartialEq)] 52 | pub enum PerformanceAttributes { 53 | /// osu!standard performance calculation result. 54 | Osu(OsuPerformanceAttributes), 55 | /// osu!taiko performance calculation result. 56 | Taiko(TaikoPerformanceAttributes), 57 | /// osu!catch performance calculation result. 58 | Catch(CatchPerformanceAttributes), 59 | /// osu!mania performance calculation result. 60 | Mania(ManiaPerformanceAttributes), 61 | } 62 | 63 | impl PerformanceAttributes { 64 | /// The pp value. 65 | pub const fn pp(&self) -> f64 { 66 | match self { 67 | Self::Osu(attrs) => attrs.pp, 68 | Self::Taiko(attrs) => attrs.pp, 69 | Self::Catch(attrs) => attrs.pp, 70 | Self::Mania(attrs) => attrs.pp, 71 | } 72 | } 73 | 74 | /// The star value. 75 | pub const fn stars(&self) -> f64 { 76 | match self { 77 | Self::Osu(attrs) => attrs.stars(), 78 | Self::Taiko(attrs) => attrs.stars(), 79 | Self::Catch(attrs) => attrs.stars(), 80 | Self::Mania(attrs) => attrs.stars(), 81 | } 82 | } 83 | 84 | /// Difficulty attributes that were used for the performance calculation. 85 | pub fn difficulty_attributes(&self) -> DifficultyAttributes { 86 | match self { 87 | Self::Osu(attrs) => DifficultyAttributes::Osu(attrs.difficulty.clone()), 88 | Self::Taiko(attrs) => DifficultyAttributes::Taiko(attrs.difficulty.clone()), 89 | Self::Catch(attrs) => DifficultyAttributes::Catch(attrs.difficulty.clone()), 90 | Self::Mania(attrs) => DifficultyAttributes::Mania(attrs.difficulty.clone()), 91 | } 92 | } 93 | 94 | /// The maximum combo of the map. 95 | pub const fn max_combo(&self) -> u32 { 96 | match self { 97 | Self::Osu(attrs) => attrs.difficulty.max_combo, 98 | Self::Taiko(attrs) => attrs.difficulty.max_combo, 99 | Self::Catch(attrs) => attrs.difficulty.max_combo(), 100 | Self::Mania(attrs) => attrs.difficulty.max_combo, 101 | } 102 | } 103 | 104 | /// Returns a builder for performance calculation. 105 | pub fn performance<'a>(self) -> Performance<'a> { 106 | self.into_performance() 107 | } 108 | } 109 | 110 | impl From for DifficultyAttributes { 111 | fn from(attrs: PerformanceAttributes) -> Self { 112 | attrs.difficulty_attributes() 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/any/difficulty/gradual.rs: -------------------------------------------------------------------------------- 1 | use rosu_map::section::general::GameMode; 2 | 3 | use crate::{ 4 | any::DifficultyAttributes, 5 | catch::{Catch, CatchGradualDifficulty}, 6 | mania::{Mania, ManiaGradualDifficulty}, 7 | model::mode::{ConvertError, IGameMode}, 8 | osu::{Osu, OsuGradualDifficulty}, 9 | taiko::{Taiko, TaikoGradualDifficulty}, 10 | Beatmap, Difficulty, 11 | }; 12 | 13 | /// Gradually calculate the difficulty attributes on maps of any mode. 14 | /// 15 | /// Note that this type implements [`Iterator`]. On every call of 16 | /// [`Iterator::next`], the next object will be processed and the 17 | /// [`DifficultyAttributes`] will be updated and returned. 18 | /// 19 | /// If you want to calculate performance attributes, use [`GradualPerformance`] instead. 20 | /// 21 | /// # Example 22 | /// 23 | /// ``` 24 | /// use rosu_pp::{Beatmap, GradualDifficulty, Difficulty}; 25 | /// 26 | /// let map = Beatmap::from_path("./resources/2785319.osu").unwrap(); 27 | /// let difficulty = Difficulty::new().mods(64); // DT 28 | /// let mut iter = GradualDifficulty::new(difficulty, &map); 29 | /// 30 | /// // the difficulty of the map after the first object 31 | /// let attrs1 = iter.next(); 32 | /// // ... after the second object 33 | /// let attrs2 = iter.next(); 34 | /// 35 | /// // Remaining objects 36 | /// for difficulty in iter { 37 | /// // ... 38 | /// } 39 | /// ``` 40 | /// 41 | /// [`GradualPerformance`]: crate::GradualPerformance 42 | // 504 vs 184 bytes is an acceptable difference and the Osu variant (424 bytes) 43 | // is likely the most used one anyway. 44 | #[allow(clippy::large_enum_variant)] 45 | pub enum GradualDifficulty { 46 | Osu(OsuGradualDifficulty), 47 | Taiko(TaikoGradualDifficulty), 48 | Catch(CatchGradualDifficulty), 49 | Mania(ManiaGradualDifficulty), 50 | } 51 | 52 | impl GradualDifficulty { 53 | /// Create a [`GradualDifficulty`] for a map of any mode. 54 | #[allow(clippy::missing_panics_doc)] 55 | pub fn new(difficulty: Difficulty, map: &Beatmap) -> Self { 56 | Self::new_with_mode(difficulty, map, map.mode).expect("no conversion required") 57 | } 58 | 59 | /// Create a [`GradualDifficulty`] for a [`Beatmap`] on a specific [`GameMode`]. 60 | pub fn new_with_mode( 61 | difficulty: Difficulty, 62 | map: &Beatmap, 63 | mode: GameMode, 64 | ) -> Result { 65 | match mode { 66 | GameMode::Osu => Osu::gradual_difficulty(difficulty, map).map(Self::Osu), 67 | GameMode::Taiko => Taiko::gradual_difficulty(difficulty, map).map(Self::Taiko), 68 | GameMode::Catch => Catch::gradual_difficulty(difficulty, map).map(Self::Catch), 69 | GameMode::Mania => Mania::gradual_difficulty(difficulty, map).map(Self::Mania), 70 | } 71 | } 72 | } 73 | 74 | impl Iterator for GradualDifficulty { 75 | type Item = DifficultyAttributes; 76 | 77 | fn next(&mut self) -> Option { 78 | match self { 79 | GradualDifficulty::Osu(gradual) => gradual.next().map(DifficultyAttributes::Osu), 80 | GradualDifficulty::Taiko(gradual) => gradual.next().map(DifficultyAttributes::Taiko), 81 | GradualDifficulty::Catch(gradual) => gradual.next().map(DifficultyAttributes::Catch), 82 | GradualDifficulty::Mania(gradual) => gradual.next().map(DifficultyAttributes::Mania), 83 | } 84 | } 85 | 86 | fn size_hint(&self) -> (usize, Option) { 87 | match self { 88 | GradualDifficulty::Osu(gradual) => gradual.size_hint(), 89 | GradualDifficulty::Taiko(gradual) => gradual.size_hint(), 90 | GradualDifficulty::Catch(gradual) => gradual.size_hint(), 91 | GradualDifficulty::Mania(gradual) => gradual.size_hint(), 92 | } 93 | } 94 | 95 | fn nth(&mut self, n: usize) -> Option { 96 | match self { 97 | GradualDifficulty::Osu(gradual) => gradual.nth(n).map(DifficultyAttributes::Osu), 98 | GradualDifficulty::Taiko(gradual) => gradual.nth(n).map(DifficultyAttributes::Taiko), 99 | GradualDifficulty::Catch(gradual) => gradual.nth(n).map(DifficultyAttributes::Catch), 100 | GradualDifficulty::Mania(gradual) => gradual.nth(n).map(DifficultyAttributes::Mania), 101 | } 102 | } 103 | } 104 | 105 | impl ExactSizeIterator for GradualDifficulty { 106 | fn len(&self) -> usize { 107 | match self { 108 | GradualDifficulty::Osu(gradual) => gradual.len(), 109 | GradualDifficulty::Taiko(gradual) => gradual.len(), 110 | GradualDifficulty::Catch(gradual) => gradual.len(), 111 | GradualDifficulty::Mania(gradual) => gradual.len(), 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/any/difficulty/inspect.rs: -------------------------------------------------------------------------------- 1 | use crate::{model::mods::GameMods, Difficulty}; 2 | 3 | use super::ModsDependent; 4 | 5 | /// [`Difficulty`] but all fields are public for inspection. 6 | #[derive(Clone, Debug, Default, PartialEq)] 7 | pub struct InspectDifficulty { 8 | /// Specify mods. 9 | pub mods: GameMods, 10 | /// Amount of passed objects for partial plays, e.g. a fail. 11 | pub passed_objects: Option, 12 | /// Adjust the clock rate used in the calculation. 13 | pub clock_rate: Option, 14 | /// Override a beatmap's set AR. 15 | /// 16 | /// Only relevant for osu! and osu!catch. 17 | pub ar: Option, 18 | /// Override a beatmap's set CS. 19 | /// 20 | /// Only relevant for osu! and osu!catch. 21 | pub cs: Option, 22 | /// Override a beatmap's set HP. 23 | pub hp: Option, 24 | /// Override a beatmap's set OD. 25 | pub od: Option, 26 | /// Adjust patterns as if the HR mod is enabled. 27 | /// 28 | /// Only relevant for osu!catch. 29 | pub hardrock_offsets: Option, 30 | /// Whether the calculated attributes belong to an osu!lazer or osu!stable 31 | /// score. 32 | /// 33 | /// Defaults to `true`. 34 | pub lazer: Option, 35 | } 36 | 37 | impl InspectDifficulty { 38 | /// Convert `self` into a [`Difficulty`]. 39 | pub fn into_difficulty(self) -> Difficulty { 40 | let Self { 41 | mods, 42 | passed_objects, 43 | clock_rate, 44 | ar, 45 | cs, 46 | hp, 47 | od, 48 | hardrock_offsets, 49 | lazer, 50 | } = self; 51 | 52 | let mut difficulty = Difficulty::new().mods(mods); 53 | 54 | if let Some(passed_objects) = passed_objects { 55 | difficulty = difficulty.passed_objects(passed_objects); 56 | } 57 | 58 | if let Some(clock_rate) = clock_rate { 59 | difficulty = difficulty.clock_rate(clock_rate); 60 | } 61 | 62 | if let Some(ar) = ar { 63 | difficulty = difficulty.ar(ar.value, ar.with_mods); 64 | } 65 | 66 | if let Some(cs) = cs { 67 | difficulty = difficulty.cs(cs.value, cs.with_mods); 68 | } 69 | 70 | if let Some(hp) = hp { 71 | difficulty = difficulty.hp(hp.value, hp.with_mods); 72 | } 73 | 74 | if let Some(od) = od { 75 | difficulty = difficulty.od(od.value, od.with_mods); 76 | } 77 | 78 | if let Some(hardrock_offsets) = hardrock_offsets { 79 | difficulty = difficulty.hardrock_offsets(hardrock_offsets); 80 | } 81 | 82 | if let Some(lazer) = lazer { 83 | difficulty = difficulty.lazer(lazer); 84 | } 85 | 86 | difficulty 87 | } 88 | } 89 | 90 | impl From for Difficulty { 91 | fn from(difficulty: InspectDifficulty) -> Self { 92 | difficulty.into_difficulty() 93 | } 94 | } 95 | 96 | impl From for InspectDifficulty { 97 | fn from(difficulty: Difficulty) -> Self { 98 | difficulty.inspect() 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/any/difficulty/object.rs: -------------------------------------------------------------------------------- 1 | pub trait IDifficultyObject { 2 | type DifficultyObjects: IDifficultyObjects + ?Sized; 3 | 4 | fn idx(&self) -> usize; 5 | 6 | fn previous<'a>( 7 | &self, 8 | backwards_idx: usize, 9 | diff_objects: &'a Self::DifficultyObjects, 10 | ) -> Option<&'a ::DifficultyObject> { 11 | self.idx() 12 | .checked_sub(backwards_idx + 1) 13 | .and_then(|idx| diff_objects.get(idx)) 14 | } 15 | 16 | fn next<'a, D>(&self, forwards_idx: usize, diff_objects: &'a [D]) -> Option<&'a D> { 17 | diff_objects.get(self.idx() + (forwards_idx + 1)) 18 | } 19 | } 20 | 21 | pub trait IDifficultyObjects { 22 | type DifficultyObject: HasStartTime; 23 | 24 | fn get(&self, idx: usize) -> Option<&Self::DifficultyObject>; 25 | } 26 | 27 | impl IDifficultyObjects for [T] { 28 | type DifficultyObject = T; 29 | 30 | fn get(&self, idx: usize) -> Option<&Self::DifficultyObject> { 31 | self.get(idx) 32 | } 33 | } 34 | 35 | pub trait HasStartTime { 36 | fn start_time(&self) -> f64; 37 | } 38 | -------------------------------------------------------------------------------- /src/any/difficulty/skills.rs: -------------------------------------------------------------------------------- 1 | use crate::util::{float_ext::FloatExt, hint::unlikely, strains_vec::StrainsVec}; 2 | 3 | pub trait StrainSkill: Sized { 4 | type DifficultyObject<'a>; 5 | type DifficultyObjects<'a>: ?Sized; 6 | 7 | const DECAY_WEIGHT: f64 = 0.9; 8 | const SECTION_LENGTH: i32 = 400; 9 | 10 | fn process<'a>( 11 | &mut self, 12 | curr: &Self::DifficultyObject<'a>, 13 | objects: &Self::DifficultyObjects<'a>, 14 | ); 15 | 16 | fn count_top_weighted_strains(&self, difficulty_value: f64) -> f64; 17 | 18 | fn save_current_peak(&mut self); 19 | 20 | fn start_new_section_from<'a>( 21 | &mut self, 22 | time: f64, 23 | curr: &Self::DifficultyObject<'a>, 24 | objects: &Self::DifficultyObjects<'a>, 25 | ); 26 | 27 | fn into_current_strain_peaks(self) -> StrainsVec; 28 | 29 | fn get_current_strain_peaks( 30 | mut strain_peaks: StrainsVec, 31 | current_section_peak: f64, 32 | ) -> StrainsVec { 33 | strain_peaks.push(current_section_peak); 34 | 35 | strain_peaks 36 | } 37 | 38 | fn difficulty_value(current_strain_peaks: StrainsVec) -> f64; 39 | 40 | fn into_difficulty_value(self) -> f64; 41 | 42 | fn cloned_difficulty_value(&self) -> f64; 43 | } 44 | 45 | pub trait StrainDecaySkill: StrainSkill { 46 | fn calculate_initial_strain<'a>( 47 | &self, 48 | time: f64, 49 | curr: &Self::DifficultyObject<'a>, 50 | objects: &Self::DifficultyObjects<'a>, 51 | ) -> f64; 52 | 53 | fn strain_value_at<'a>( 54 | &mut self, 55 | curr: &Self::DifficultyObject<'a>, 56 | objects: &Self::DifficultyObjects<'a>, 57 | ) -> f64; 58 | 59 | fn strain_decay(ms: f64) -> f64; 60 | } 61 | 62 | pub fn count_top_weighted_strains(object_strains: &[f64], difficulty_value: f64) -> f64 { 63 | if unlikely(object_strains.is_empty()) { 64 | return 0.0; 65 | } 66 | 67 | // * What would the top strain be if all strain values were identical 68 | let consistent_top_strain = difficulty_value / 10.0; 69 | 70 | if unlikely(FloatExt::eq(consistent_top_strain, 0.0)) { 71 | return object_strains.len() as f64; 72 | } 73 | 74 | // * Use a weighted sum of all strains. Constants are arbitrary and give nice values 75 | object_strains 76 | .iter() 77 | .map(|s| 1.1 / (1.0 + f64::exp(-10.0 * (s / consistent_top_strain - 0.88)))) 78 | .sum() 79 | } 80 | 81 | pub fn difficulty_value(current_strain_peaks: StrainsVec, decay_weight: f64) -> f64 { 82 | let mut difficulty = 0.0; 83 | let mut weight = 1.0; 84 | 85 | // * Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). 86 | // * These sections will not contribute to the difficulty. 87 | let mut peaks = current_strain_peaks; 88 | peaks.retain_non_zero_and_sort(); 89 | 90 | // SAFETY: we just removed all zeros 91 | let peaks = unsafe { peaks.transmute_into_vec() }; 92 | 93 | // * Difficulty is the weighted sum of the highest strains from every section. 94 | // * We're sorting from highest to lowest strain. 95 | for strain in peaks { 96 | difficulty += strain * weight; 97 | weight *= decay_weight; 98 | } 99 | 100 | difficulty 101 | } 102 | 103 | pub fn strain_decay(ms: f64, strain_decay_base: f64) -> f64 { 104 | f64::powf(strain_decay_base, ms / 1000.0) 105 | } 106 | -------------------------------------------------------------------------------- /src/any/mod.rs: -------------------------------------------------------------------------------- 1 | pub use self::{ 2 | attributes::{DifficultyAttributes, PerformanceAttributes}, 3 | difficulty::{ 4 | gradual::GradualDifficulty, inspect::InspectDifficulty, Difficulty, ModsDependent, 5 | }, 6 | performance::{ 7 | gradual::GradualPerformance, 8 | into::{IntoModePerformance, IntoPerformance}, 9 | HitResultPriority, Performance, 10 | }, 11 | score_state::ScoreState, 12 | strains::Strains, 13 | }; 14 | 15 | mod attributes; 16 | pub(crate) mod difficulty; 17 | mod performance; 18 | mod score_state; 19 | mod strains; 20 | -------------------------------------------------------------------------------- /src/any/performance/into.rs: -------------------------------------------------------------------------------- 1 | use rosu_map::section::general::GameMode; 2 | 3 | use crate::{ 4 | any::{DifficultyAttributes, PerformanceAttributes}, 5 | model::mode::IGameMode, 6 | Beatmap, Performance, 7 | }; 8 | 9 | /// Turning a type into the generic [`IGameMode`]'s performance calculator. 10 | pub trait IntoModePerformance<'map, M: IGameMode> { 11 | fn into_performance(self) -> M::Performance<'map>; 12 | } 13 | 14 | /// Turning a type into a performance calculator of any mode. 15 | pub trait IntoPerformance<'a> { 16 | fn into_performance(self) -> Performance<'a>; 17 | } 18 | 19 | macro_rules! impl_from_mode { 20 | ( 21 | $( 22 | $module:ident { 23 | $mode:ident, $diff:ident, $perf:ident 24 | } 25 | ,)* 26 | ) => { 27 | $( 28 | macro_rules! mode { 29 | () => { crate::$module::$mode }; 30 | } 31 | 32 | impl<'map> IntoModePerformance<'map, mode!()> for crate::$module::$diff { 33 | fn into_performance(self) -> ::Performance<'map> { 34 | ::Performance::from_map_or_attrs(self.into()) 35 | } 36 | } 37 | 38 | impl<'map> IntoModePerformance<'map, mode!()> for crate::$module::$perf { 39 | fn into_performance(self) -> ::Performance<'map> { 40 | ::Performance::from_map_or_attrs(self.difficulty.into()) 41 | } 42 | } 43 | 44 | impl<'a> IntoPerformance<'a> for crate::$module::$diff { 45 | fn into_performance(self) -> Performance<'a> { 46 | Performance::$mode( 47 | >::into_performance(self) 48 | ) 49 | } 50 | } 51 | 52 | impl<'a> IntoPerformance<'a> for crate::$module::$perf { 53 | fn into_performance(self) -> Performance<'a> { 54 | Performance::$mode( 55 | >::into_performance(self) 56 | ) 57 | } 58 | } 59 | 60 | impl<'map> IntoModePerformance<'map, mode!()> for &'map Beatmap { 61 | fn into_performance(self) -> ::Performance<'map> { 62 | ::Performance::from_map_or_attrs(self.into()) 63 | } 64 | } 65 | 66 | impl<'a> IntoModePerformance<'a, mode!()> for Beatmap { 67 | fn into_performance(self) -> ::Performance<'a> { 68 | ::Performance::from_map_or_attrs(self.into()) 69 | } 70 | } 71 | )* 72 | }; 73 | } 74 | 75 | impl_from_mode!( 76 | osu { 77 | Osu, 78 | OsuDifficultyAttributes, 79 | OsuPerformanceAttributes 80 | }, 81 | taiko { 82 | Taiko, 83 | TaikoDifficultyAttributes, 84 | TaikoPerformanceAttributes 85 | }, 86 | catch { 87 | Catch, 88 | CatchDifficultyAttributes, 89 | CatchPerformanceAttributes 90 | }, 91 | mania { 92 | Mania, 93 | ManiaDifficultyAttributes, 94 | ManiaPerformanceAttributes 95 | }, 96 | ); 97 | 98 | impl<'a> IntoPerformance<'a> for Beatmap { 99 | fn into_performance(self) -> Performance<'a> { 100 | match self.mode { 101 | GameMode::Osu => Performance::Osu(self.into()), 102 | GameMode::Taiko => Performance::Taiko(self.into()), 103 | GameMode::Catch => Performance::Catch(self.into()), 104 | GameMode::Mania => Performance::Mania(self.into()), 105 | } 106 | } 107 | } 108 | 109 | impl<'map> IntoPerformance<'map> for &'map Beatmap { 110 | fn into_performance(self) -> Performance<'map> { 111 | match self.mode { 112 | GameMode::Osu => Performance::Osu(self.into()), 113 | GameMode::Taiko => Performance::Taiko(self.into()), 114 | GameMode::Catch => Performance::Catch(self.into()), 115 | GameMode::Mania => Performance::Mania(self.into()), 116 | } 117 | } 118 | } 119 | 120 | impl<'a> IntoPerformance<'a> for DifficultyAttributes { 121 | fn into_performance(self) -> Performance<'a> { 122 | match self { 123 | Self::Osu(attrs) => Performance::Osu(attrs.into()), 124 | Self::Taiko(attrs) => Performance::Taiko(attrs.into()), 125 | Self::Catch(attrs) => Performance::Catch(attrs.into()), 126 | Self::Mania(attrs) => Performance::Mania(attrs.into()), 127 | } 128 | } 129 | } 130 | 131 | impl<'a> IntoPerformance<'a> for PerformanceAttributes { 132 | fn into_performance(self) -> Performance<'a> { 133 | match self { 134 | Self::Osu(attrs) => Performance::Osu(attrs.difficulty.into()), 135 | Self::Taiko(attrs) => Performance::Taiko(attrs.difficulty.into()), 136 | Self::Catch(attrs) => Performance::Catch(attrs.difficulty.into()), 137 | Self::Mania(attrs) => Performance::Mania(attrs.difficulty.into()), 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/any/strains.rs: -------------------------------------------------------------------------------- 1 | use crate::{catch::CatchStrains, mania::ManiaStrains, osu::OsuStrains, taiko::TaikoStrains}; 2 | 3 | /// The result of calculating the strains on a map. 4 | /// 5 | /// Suitable to plot the difficulty of a map over time. 6 | #[derive(Clone, Debug, PartialEq)] 7 | pub enum Strains { 8 | Osu(OsuStrains), 9 | Taiko(TaikoStrains), 10 | Catch(CatchStrains), 11 | Mania(ManiaStrains), 12 | } 13 | 14 | impl Strains { 15 | /// Time inbetween two strains in ms. 16 | pub const fn section_len(&self) -> f64 { 17 | match self { 18 | Strains::Osu(_) => OsuStrains::SECTION_LEN, 19 | Strains::Taiko(_) => TaikoStrains::SECTION_LEN, 20 | Strains::Catch(_) => CatchStrains::SECTION_LEN, 21 | Strains::Mania(_) => ManiaStrains::SECTION_LEN, 22 | } 23 | } 24 | } 25 | 26 | macro_rules! from_mode_strains { 27 | ( $mode:ident: $strains:ident ) => { 28 | impl From<$strains> for Strains { 29 | fn from(strains: $strains) -> Self { 30 | Self::$mode(strains) 31 | } 32 | } 33 | }; 34 | } 35 | 36 | from_mode_strains!(Osu: OsuStrains); 37 | from_mode_strains!(Taiko: TaikoStrains); 38 | from_mode_strains!(Catch: CatchStrains); 39 | from_mode_strains!(Mania: ManiaStrains); 40 | -------------------------------------------------------------------------------- /src/catch/catcher.rs: -------------------------------------------------------------------------------- 1 | pub struct Catcher; 2 | 3 | const AREA_CATCHER_SIZE: f32 = 106.75; 4 | 5 | impl Catcher { 6 | pub const BASE_SPEED: f64 = 1.0; 7 | pub const ALLOWED_CATCH_RANGE: f32 = 0.8; 8 | 9 | pub fn calculate_catch_width(cs: f32) -> f32 { 10 | Self::calculate_catch_width_by_scale(Self::calculate_scale(cs)) 11 | } 12 | 13 | fn calculate_catch_width_by_scale(scale: f32) -> f32 { 14 | AREA_CATCHER_SIZE * scale.abs() * Self::ALLOWED_CATCH_RANGE 15 | } 16 | 17 | fn calculate_scale(cs: f32) -> f32 { 18 | ((f64::from(1.0_f32) - f64::from(0.7_f32) * ((f64::from(cs) - 5.0) / 5.0)) as f32 / 2.0 19 | * 1.0) 20 | * 2.0 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/catch/difficulty/mod.rs: -------------------------------------------------------------------------------- 1 | use rosu_map::section::general::GameMode; 2 | 3 | use crate::{ 4 | any::difficulty::{skills::StrainSkill, Difficulty}, 5 | catch::{ 6 | catcher::Catcher, convert::convert_objects, difficulty::object::CatchDifficultyObject, 7 | }, 8 | model::{beatmap::BeatmapAttributes, mode::ConvertError}, 9 | Beatmap, 10 | }; 11 | 12 | use self::skills::movement::Movement; 13 | 14 | use super::{ 15 | attributes::{CatchDifficultyAttributes, ObjectCountBuilder}, 16 | object::palpable::PalpableObject, 17 | }; 18 | 19 | pub mod gradual; 20 | mod object; 21 | mod skills; 22 | 23 | const DIFFICULTY_MULTIPLIER: f64 = 4.59; 24 | 25 | pub fn difficulty( 26 | difficulty: &Difficulty, 27 | map: &Beatmap, 28 | ) -> Result { 29 | let map = map.convert_ref(GameMode::Catch, difficulty.get_mods())?; 30 | 31 | let DifficultyValues { 32 | movement, 33 | mut attrs, 34 | } = DifficultyValues::calculate(difficulty, &map); 35 | 36 | DifficultyValues::eval(&mut attrs, movement.into_difficulty_value()); 37 | 38 | Ok(attrs) 39 | } 40 | 41 | pub struct CatchDifficultySetup { 42 | map_attrs: BeatmapAttributes, 43 | attrs: CatchDifficultyAttributes, 44 | } 45 | 46 | impl CatchDifficultySetup { 47 | pub fn new(difficulty: &Difficulty, map: &Beatmap) -> Self { 48 | let map_attrs = map.attributes().difficulty(difficulty).build(); 49 | 50 | let attrs = CatchDifficultyAttributes { 51 | ar: map_attrs.ar, 52 | is_convert: map.is_convert, 53 | ..Default::default() 54 | }; 55 | 56 | Self { map_attrs, attrs } 57 | } 58 | } 59 | 60 | pub struct DifficultyValues { 61 | pub movement: Movement, 62 | pub attrs: CatchDifficultyAttributes, 63 | } 64 | 65 | impl DifficultyValues { 66 | pub fn calculate(difficulty: &Difficulty, map: &Beatmap) -> Self { 67 | let take = difficulty.get_passed_objects(); 68 | let clock_rate = difficulty.get_clock_rate(); 69 | 70 | let CatchDifficultySetup { 71 | map_attrs, 72 | mut attrs, 73 | } = CatchDifficultySetup::new(difficulty, map); 74 | 75 | let hr_offsets = difficulty.get_hardrock_offsets(); 76 | let reflection = difficulty.get_mods().reflection(); 77 | let mut count = ObjectCountBuilder::new_regular(take); 78 | 79 | let palpable_objects = 80 | convert_objects(map, &mut count, reflection, hr_offsets, map_attrs.cs as f32); 81 | 82 | let mut half_catcher_width = Catcher::calculate_catch_width(map_attrs.cs as f32) * 0.5; 83 | half_catcher_width *= 1.0 - ((map_attrs.cs as f32 - 5.5).max(0.0) * 0.0625); 84 | 85 | let diff_objects = Self::create_difficulty_objects( 86 | clock_rate, 87 | half_catcher_width, 88 | palpable_objects.iter().take(take), 89 | ); 90 | 91 | let mut movement = Movement::new(half_catcher_width, clock_rate); 92 | 93 | for curr in diff_objects.iter() { 94 | movement.process(curr, &diff_objects); 95 | } 96 | 97 | attrs.set_object_count(&count.into_regular()); 98 | 99 | Self { movement, attrs } 100 | } 101 | 102 | pub fn eval(attrs: &mut CatchDifficultyAttributes, movement_difficulty_value: f64) { 103 | attrs.stars = movement_difficulty_value.sqrt() * DIFFICULTY_MULTIPLIER; 104 | } 105 | 106 | pub fn create_difficulty_objects<'a>( 107 | clock_rate: f64, 108 | half_catcher_width: f32, 109 | mut palpable_objects: impl ExactSizeIterator, 110 | ) -> Box<[CatchDifficultyObject]> { 111 | let Some(mut last_object) = palpable_objects.next() else { 112 | return Box::default(); 113 | }; 114 | 115 | let scaling_factor = 116 | CatchDifficultyObject::NORMALIZED_HITOBJECT_RADIUS / half_catcher_width; 117 | 118 | palpable_objects 119 | .enumerate() 120 | .map(|(i, hit_object)| { 121 | let diff_object = CatchDifficultyObject::new( 122 | hit_object, 123 | last_object, 124 | clock_rate, 125 | scaling_factor, 126 | i, 127 | ); 128 | last_object = hit_object; 129 | 130 | diff_object 131 | }) 132 | .collect() 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/catch/difficulty/object.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | any::difficulty::object::{HasStartTime, IDifficultyObject}, 3 | catch::object::palpable::PalpableObject, 4 | }; 5 | 6 | pub struct CatchDifficultyObject { 7 | pub idx: usize, 8 | pub start_time: f64, 9 | pub delta_time: f64, 10 | pub normalized_pos: f32, 11 | pub last_normalized_pos: f32, 12 | pub strain_time: f64, 13 | pub last_object: LastObject, 14 | } 15 | 16 | impl CatchDifficultyObject { 17 | pub const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0; 18 | 19 | pub fn new( 20 | hit_object: &PalpableObject, 21 | last_object: &PalpableObject, 22 | clock_rate: f64, 23 | scaling_factor: f32, 24 | idx: usize, 25 | ) -> Self { 26 | let normalized_pos = hit_object.effective_x() * scaling_factor; 27 | let last_normalized_pos = last_object.effective_x() * scaling_factor; 28 | 29 | let start_time = hit_object.start_time / clock_rate; 30 | let delta_time = (hit_object.start_time - last_object.start_time) / clock_rate; 31 | let strain_time = delta_time.max(40.0); 32 | 33 | let last_object = LastObject { 34 | hyper_dash: last_object.hyper_dash, 35 | dist_to_hyper_dash: last_object.dist_to_hyper_dash, 36 | }; 37 | 38 | Self { 39 | idx, 40 | start_time, 41 | delta_time, 42 | normalized_pos, 43 | last_normalized_pos, 44 | strain_time, 45 | last_object, 46 | } 47 | } 48 | } 49 | 50 | pub struct LastObject { 51 | pub hyper_dash: bool, 52 | pub dist_to_hyper_dash: f32, 53 | } 54 | 55 | impl IDifficultyObject for CatchDifficultyObject { 56 | type DifficultyObjects = [Self]; 57 | 58 | fn idx(&self) -> usize { 59 | self.idx 60 | } 61 | } 62 | 63 | impl HasStartTime for CatchDifficultyObject { 64 | fn start_time(&self) -> f64 { 65 | self.start_time 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/catch/difficulty/skills/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod movement; 2 | -------------------------------------------------------------------------------- /src/catch/difficulty/skills/movement.rs: -------------------------------------------------------------------------------- 1 | use crate::{catch::difficulty::object::CatchDifficultyObject, util::float_ext::FloatExt}; 2 | 3 | define_skill! { 4 | pub struct Movement: StrainDecaySkill => [CatchDifficultyObject][CatchDifficultyObject] { 5 | half_catcher_width: f32, 6 | clock_rate: f64, 7 | last_player_pos: Option = None, 8 | last_dist_moved: f32 = 0.0, 9 | last_exact_dist_moved: f32 = 0.0, 10 | last_strain_time: f64 = 0.0, 11 | is_in_buzz_section: bool = false, 12 | } 13 | } 14 | 15 | impl Movement { 16 | const ABSOLUTE_PLAYER_POSITIONING_ERROR: f32 = 16.0; 17 | const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0; 18 | const DIRECTION_CHANGE_BONUS: f64 = 21.0; 19 | 20 | const SKILL_MULTIPLIER: f64 = 1.0; 21 | const STRAIN_DECAY_BASE: f64 = 0.2; 22 | 23 | const DECAY_WEIGHT: f64 = 0.94; 24 | 25 | const SECTION_LENGTH: f64 = 750.0; 26 | 27 | fn strain_value_of( 28 | &mut self, 29 | curr: &CatchDifficultyObject, 30 | _: &[CatchDifficultyObject], 31 | ) -> f64 { 32 | let last_player_pos = self.last_player_pos.unwrap_or(curr.last_normalized_pos); 33 | 34 | let term = Self::NORMALIZED_HITOBJECT_RADIUS - Self::ABSOLUTE_PLAYER_POSITIONING_ERROR; 35 | let mut player_pos = 36 | last_player_pos.clamp(curr.normalized_pos - term, curr.normalized_pos + term); 37 | 38 | let dist_moved = player_pos - last_player_pos; 39 | 40 | // * For the exact position we consider that the catcher is in the correct position for both objects 41 | let exact_dist_moved = curr.normalized_pos - last_player_pos; 42 | 43 | let weighted_strain_time = curr.strain_time + 13.0 + (3.0 / self.clock_rate); 44 | 45 | let mut dist_addition = f64::from(dist_moved.abs()).powf(1.3) / 510.0; 46 | let sqrt_strain = weighted_strain_time.sqrt(); 47 | 48 | let mut edge_dash_bonus: f64 = 0.0; 49 | 50 | if dist_moved.abs() > 0.1 { 51 | if self.last_dist_moved.abs() > 0.1 52 | && dist_moved.signum() != self.last_dist_moved.signum() 53 | { 54 | let bonus_factor = f64::from(dist_moved.abs().min(50.0) / 50.0); 55 | let anti_flow_factor = 56 | f64::from(self.last_dist_moved.abs().min(70.0) / 70.0).max(0.38); 57 | 58 | dist_addition += Self::DIRECTION_CHANGE_BONUS 59 | / (self.last_strain_time + 16.0).sqrt() 60 | * bonus_factor 61 | * anti_flow_factor 62 | * (1.0 - (weighted_strain_time / 1000.0).powf(3.0)).max(0.0); 63 | } 64 | 65 | dist_addition += 12.5 66 | * f64::from(f32::abs(dist_moved).min(Self::NORMALIZED_HITOBJECT_RADIUS * 2.0)) 67 | / f64::from(Self::NORMALIZED_HITOBJECT_RADIUS * 6.0) 68 | / sqrt_strain; 69 | } 70 | 71 | if curr.last_object.dist_to_hyper_dash <= 20.0 { 72 | if curr.last_object.hyper_dash { 73 | player_pos = curr.normalized_pos; 74 | } else { 75 | edge_dash_bonus += 5.7; 76 | } 77 | 78 | dist_addition *= 1.0 79 | + edge_dash_bonus 80 | * f64::from((20.0 - curr.last_object.dist_to_hyper_dash) / 20.0) 81 | * ((curr.strain_time * self.clock_rate).min(265.0) / 265.0).powf(1.5); 82 | } 83 | 84 | // * There is an edge case where horizontal back and forth sliders create "buzz" patterns which are repeated "movements" with a distance lower than 85 | // * the platter's width but high enough to be considered a movement due to the absolute_player_positioning_error and normalized_hitobject_radius offsets 86 | // * We are detecting this exact scenario. The first back and forth is counted but all subsequent ones are nullified. 87 | // * To achieve that, we need to store the exact distances (distance ignoring absolute_player_positioning_error and normalized_hitobject_radius) 88 | if exact_dist_moved.abs() <= self.half_catcher_width * 2.0 89 | && ::eq(exact_dist_moved, -self.last_exact_dist_moved) 90 | && ::eq(curr.strain_time, self.last_strain_time) 91 | { 92 | if self.is_in_buzz_section { 93 | dist_addition = 0.0; 94 | } else { 95 | self.is_in_buzz_section = true; 96 | } 97 | } else { 98 | self.is_in_buzz_section = false; 99 | } 100 | 101 | self.last_player_pos = Some(player_pos); 102 | self.last_dist_moved = dist_moved; 103 | self.last_strain_time = curr.strain_time; 104 | self.last_exact_dist_moved = exact_dist_moved; 105 | 106 | dist_addition / weighted_strain_time 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/catch/mod.rs: -------------------------------------------------------------------------------- 1 | use rosu_map::section::general::GameMode; 2 | 3 | use crate::{ 4 | model::{ 5 | beatmap::Beatmap, 6 | mode::{ConvertError, IGameMode}, 7 | }, 8 | Difficulty, 9 | }; 10 | 11 | pub use self::{ 12 | attributes::{CatchDifficultyAttributes, CatchPerformanceAttributes}, 13 | difficulty::gradual::CatchGradualDifficulty, 14 | performance::{gradual::CatchGradualPerformance, CatchPerformance}, 15 | score_state::CatchScoreState, 16 | strains::CatchStrains, 17 | }; 18 | 19 | mod attributes; 20 | mod catcher; 21 | mod convert; 22 | mod difficulty; 23 | mod object; 24 | mod performance; 25 | mod score_state; 26 | mod strains; 27 | 28 | const PLAYFIELD_WIDTH: f32 = 512.0; 29 | 30 | /// Marker type for [`GameMode::Catch`]. 31 | /// 32 | /// [`GameMode::Catch`]: rosu_map::section::general::GameMode::Catch 33 | pub struct Catch; 34 | 35 | impl Catch { 36 | pub fn convert(map: &mut Beatmap) { 37 | debug_assert!(!map.is_convert && map.mode == GameMode::Osu); 38 | convert::convert(map); 39 | } 40 | } 41 | 42 | impl IGameMode for Catch { 43 | type DifficultyAttributes = CatchDifficultyAttributes; 44 | type Strains = CatchStrains; 45 | type Performance<'map> = CatchPerformance<'map>; 46 | type GradualDifficulty = CatchGradualDifficulty; 47 | type GradualPerformance = CatchGradualPerformance; 48 | 49 | fn difficulty( 50 | difficulty: &Difficulty, 51 | map: &Beatmap, 52 | ) -> Result { 53 | difficulty::difficulty(difficulty, map) 54 | } 55 | 56 | fn strains(difficulty: &Difficulty, map: &Beatmap) -> Result { 57 | strains::strains(difficulty, map) 58 | } 59 | 60 | fn performance(map: &Beatmap) -> Self::Performance<'_> { 61 | CatchPerformance::new(map) 62 | } 63 | 64 | fn gradual_difficulty( 65 | difficulty: Difficulty, 66 | map: &Beatmap, 67 | ) -> Result { 68 | CatchGradualDifficulty::new(difficulty, map) 69 | } 70 | 71 | fn gradual_performance( 72 | difficulty: Difficulty, 73 | map: &Beatmap, 74 | ) -> Result { 75 | CatchGradualPerformance::new(difficulty, map) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/catch/object/banana_shower.rs: -------------------------------------------------------------------------------- 1 | pub struct BananaShower { 2 | pub n_bananas: usize, 3 | } 4 | 5 | impl BananaShower { 6 | pub fn new(start_time: f64, end_time: f64) -> Self { 7 | // * Int truncation added to match osu!stable. 8 | let start_time = start_time as i32; 9 | let end_time = end_time as i32; 10 | let mut spacing = (end_time - start_time) as f32; 11 | 12 | while spacing > 100.0 { 13 | spacing /= 2.0; 14 | } 15 | 16 | let n_bananas = if spacing <= 0.0 { 17 | 0 18 | } else { 19 | let end_time = end_time as f32; 20 | let mut time = start_time as f32; 21 | let mut count = 0; 22 | 23 | while time <= end_time { 24 | time += spacing; 25 | count += 1; 26 | } 27 | 28 | count 29 | }; 30 | 31 | Self { n_bananas } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/catch/object/fruit.rs: -------------------------------------------------------------------------------- 1 | use crate::catch::attributes::ObjectCountBuilder; 2 | 3 | pub struct Fruit { 4 | pub x_offset: f32, 5 | } 6 | 7 | impl Fruit { 8 | pub fn new(count: &mut ObjectCountBuilder) -> Self { 9 | count.record_fruit(); 10 | 11 | Self { x_offset: 0.0 } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/catch/object/juice_stream.rs: -------------------------------------------------------------------------------- 1 | use std::vec::Drain; 2 | 3 | use rosu_map::section::{ 4 | general::GameMode, 5 | hit_objects::{CurveBuffers, PathControlPoint, SliderEvent, SliderEventType, SliderEventsIter}, 6 | }; 7 | 8 | use crate::{ 9 | catch::attributes::ObjectCountBuilder, 10 | model::{ 11 | control_point::{DifficultyPoint, TimingPoint}, 12 | hit_object::Slider, 13 | }, 14 | util::get_precision_adjusted_beat_len, 15 | Beatmap, 16 | }; 17 | 18 | pub struct JuiceStream<'a> { 19 | pub control_points: &'a [PathControlPoint], // needed for applying hr offset 20 | pub nested_objects: Drain<'a, NestedJuiceStreamObject>, 21 | } 22 | 23 | impl<'a> JuiceStream<'a> { 24 | pub const BASE_SCORING_DIST: f64 = 100.0; 25 | 26 | pub fn new( 27 | effective_x: f32, 28 | start_time: f64, 29 | slider: &'a Slider, 30 | map: &Beatmap, 31 | count: &mut ObjectCountBuilder, 32 | bufs: &'a mut JuiceStreamBufs, 33 | ) -> Self { 34 | let slider_multiplier = map.slider_multiplier; 35 | let slider_tick_rate = map.slider_tick_rate; 36 | 37 | let beat_len = map 38 | .timing_point_at(start_time) 39 | .map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len); 40 | 41 | let slider_velocity = map 42 | .difficulty_point_at(start_time) 43 | .map_or(DifficultyPoint::DEFAULT_SLIDER_VELOCITY, |point| { 44 | point.slider_velocity 45 | }); 46 | 47 | let path = slider.curve(GameMode::Catch, &mut bufs.curve); 48 | 49 | let velocity = JuiceStream::BASE_SCORING_DIST * slider_multiplier 50 | / get_precision_adjusted_beat_len(slider_velocity, beat_len); 51 | let scoring_dist = velocity * beat_len; 52 | 53 | let tick_dist_multiplier = if map.version < 8 { 54 | slider_velocity.recip() 55 | } else { 56 | 1.0 57 | }; 58 | 59 | let tick_dist = scoring_dist / slider_tick_rate * tick_dist_multiplier; 60 | 61 | let span_count = slider.span_count() as f64; 62 | let duration = span_count * path.dist() / velocity; 63 | let span_duration = duration / span_count; 64 | 65 | let events = SliderEventsIter::new( 66 | start_time, 67 | span_duration, 68 | velocity, 69 | tick_dist, 70 | path.dist(), 71 | slider.span_count() as i32, 72 | &mut bufs.ticks, 73 | ); 74 | 75 | let mut last_event_time = None; 76 | 77 | for e in events { 78 | if let Some(last_event_time) = last_event_time { 79 | let mut tiny_droplets = 0; 80 | let since_last_tick = f64::from(e.time as i32 - last_event_time as i32); 81 | 82 | if since_last_tick > 80.0 { 83 | let mut time_between_tiny = since_last_tick; 84 | 85 | while time_between_tiny > 100.0 { 86 | time_between_tiny /= 2.0; 87 | } 88 | 89 | let mut t = time_between_tiny; 90 | 91 | while t < since_last_tick { 92 | tiny_droplets += 1; 93 | 94 | let nested = NestedJuiceStreamObject { 95 | pos: 0.0, // not important 96 | start_time: 0.0, // not important 97 | kind: NestedJuiceStreamObjectKind::TinyDroplet, 98 | }; 99 | 100 | bufs.nested_objects.push(nested); 101 | 102 | t += time_between_tiny; 103 | } 104 | } 105 | 106 | count.record_tiny_droplets(tiny_droplets); 107 | } 108 | 109 | last_event_time = Some(e.time); 110 | 111 | let kind = match e.kind { 112 | SliderEventType::Tick => { 113 | count.record_droplet(); 114 | 115 | NestedJuiceStreamObjectKind::Droplet 116 | } 117 | SliderEventType::Head | SliderEventType::Repeat | SliderEventType::Tail => { 118 | count.record_fruit(); 119 | 120 | NestedJuiceStreamObjectKind::Fruit 121 | } 122 | SliderEventType::LastTick => continue, 123 | }; 124 | 125 | let nested = NestedJuiceStreamObject { 126 | pos: effective_x + path.position_at(e.path_progress).x, 127 | start_time: e.time, 128 | kind, 129 | }; 130 | 131 | bufs.nested_objects.push(nested); 132 | } 133 | 134 | Self { 135 | control_points: slider.control_points.as_ref(), 136 | nested_objects: bufs.nested_objects.drain(..), 137 | } 138 | } 139 | } 140 | 141 | #[derive(Debug)] 142 | pub struct NestedJuiceStreamObject { 143 | pub pos: f32, 144 | pub start_time: f64, 145 | pub kind: NestedJuiceStreamObjectKind, 146 | } 147 | 148 | #[derive(Debug)] 149 | pub enum NestedJuiceStreamObjectKind { 150 | Fruit, 151 | Droplet, 152 | TinyDroplet, 153 | } 154 | 155 | pub struct JuiceStreamBufs { 156 | pub nested_objects: Vec, 157 | pub curve: CurveBuffers, 158 | pub ticks: Vec, 159 | } 160 | -------------------------------------------------------------------------------- /src/catch/object/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod banana_shower; 2 | pub mod fruit; 3 | pub mod juice_stream; 4 | pub mod palpable; 5 | -------------------------------------------------------------------------------- /src/catch/object/palpable.rs: -------------------------------------------------------------------------------- 1 | use crate::catch::PLAYFIELD_WIDTH; 2 | 3 | pub struct PalpableObject { 4 | pub x: f32, 5 | pub x_offset: f32, 6 | pub start_time: f64, 7 | pub dist_to_hyper_dash: f32, 8 | pub hyper_dash: bool, 9 | } 10 | 11 | impl PalpableObject { 12 | pub const fn new(x: f32, x_offset: f32, start_time: f64) -> Self { 13 | Self { 14 | x, 15 | x_offset, 16 | start_time, 17 | dist_to_hyper_dash: 0.0, 18 | hyper_dash: false, 19 | } 20 | } 21 | 22 | pub fn effective_x(&self) -> f32 { 23 | (self.x + self.x_offset).clamp(0.0, PLAYFIELD_WIDTH) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/catch/performance/calculator.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | catch::{CatchDifficultyAttributes, CatchPerformanceAttributes, CatchScoreState}, 3 | GameMods, 4 | }; 5 | 6 | pub(super) struct CatchPerformanceCalculator<'mods> { 7 | attrs: CatchDifficultyAttributes, 8 | mods: &'mods GameMods, 9 | state: CatchScoreState, 10 | } 11 | 12 | impl<'a> CatchPerformanceCalculator<'a> { 13 | pub const fn new( 14 | attrs: CatchDifficultyAttributes, 15 | mods: &'a GameMods, 16 | state: CatchScoreState, 17 | ) -> Self { 18 | Self { attrs, mods, state } 19 | } 20 | } 21 | 22 | impl CatchPerformanceCalculator<'_> { 23 | pub fn calculate(self) -> CatchPerformanceAttributes { 24 | let attributes = &self.attrs; 25 | let stars = attributes.stars; 26 | let max_combo = attributes.max_combo(); 27 | 28 | // Relying heavily on aim 29 | let mut pp = (5.0 * (stars / 0.0049).max(1.0) - 4.0).powf(2.0) / 100_000.0; 30 | 31 | let mut combo_hits = self.combo_hits(); 32 | 33 | if combo_hits == 0 { 34 | combo_hits = max_combo; 35 | } 36 | 37 | // Longer maps are worth more 38 | let mut len_bonus = 0.95 + 0.3 * (f64::from(combo_hits) / 2500.0).min(1.0); 39 | 40 | if combo_hits > 2500 { 41 | len_bonus += (f64::from(combo_hits) / 2500.0).log10() * 0.475; 42 | } 43 | 44 | pp *= len_bonus; 45 | 46 | // Penalize misses exponentially 47 | pp *= 0.97_f64.powf(f64::from(self.state.misses)); 48 | 49 | // Combo scaling 50 | if self.state.max_combo > 0 { 51 | pp *= (f64::from(self.state.max_combo).powf(0.8) / f64::from(max_combo).powf(0.8)) 52 | .min(1.0); 53 | } 54 | 55 | // AR scaling 56 | let ar = attributes.ar; 57 | let mut ar_factor = 1.0; 58 | if ar > 9.0 { 59 | ar_factor += 0.1 * (ar - 9.0) + f64::from(u8::from(ar > 10.0)) * 0.1 * (ar - 10.0); 60 | } else if ar < 8.0 { 61 | ar_factor += 0.025 * (8.0 - ar); 62 | } 63 | pp *= ar_factor; 64 | 65 | // HD bonus 66 | if self.mods.hd() { 67 | if ar <= 10.0 { 68 | pp *= 1.05 + 0.075 * (10.0 - ar); 69 | } else if ar > 10.0 { 70 | pp *= 1.01 + 0.04 * (11.0 - ar.min(11.0)); 71 | } 72 | } 73 | 74 | // FL bonus 75 | if self.mods.fl() { 76 | pp *= 1.35 * len_bonus; 77 | } 78 | 79 | // Accuracy scaling 80 | pp *= self.state.accuracy().powf(5.5); 81 | 82 | // NF penalty 83 | if self.mods.nf() { 84 | pp *= (1.0 - 0.02 * f64::from(self.state.misses)).max(0.9); 85 | } 86 | 87 | CatchPerformanceAttributes { 88 | difficulty: self.attrs, 89 | pp, 90 | } 91 | } 92 | 93 | const fn combo_hits(&self) -> u32 { 94 | self.state.fruits + self.state.droplets + self.state.misses 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/catch/score_state.rs: -------------------------------------------------------------------------------- 1 | /// Aggregation for a score's current state. 2 | #[derive(Clone, Debug, PartialEq, Eq)] 3 | pub struct CatchScoreState { 4 | /// Maximum combo that the score has had so far. 5 | /// **Not** the maximum possible combo of the map so far. 6 | /// 7 | /// Note that only fruits and droplets are considered for osu!catch combo. 8 | pub max_combo: u32, 9 | /// Amount of current fruits (300s). 10 | pub fruits: u32, 11 | /// Amount of current droplets (100s). 12 | pub droplets: u32, 13 | /// Amount of current tiny droplets (50s). 14 | pub tiny_droplets: u32, 15 | /// Amount of current tiny droplet misses (katus). 16 | pub tiny_droplet_misses: u32, 17 | /// Amount of current misses (fruits and droplets). 18 | pub misses: u32, 19 | } 20 | 21 | impl CatchScoreState { 22 | /// Create a new empty score state. 23 | pub const fn new() -> Self { 24 | Self { 25 | max_combo: 0, 26 | fruits: 0, 27 | droplets: 0, 28 | tiny_droplets: 0, 29 | tiny_droplet_misses: 0, 30 | misses: 0, 31 | } 32 | } 33 | 34 | /// Return the total amount of hits by adding everything up. 35 | pub const fn total_hits(&self) -> u32 { 36 | self.fruits + self.droplets + self.tiny_droplets + self.tiny_droplet_misses + self.misses 37 | } 38 | 39 | /// Calculate the accuracy between `0.0` and `1.0` for this state. 40 | pub fn accuracy(&self) -> f64 { 41 | let total_hits = self.total_hits(); 42 | 43 | if total_hits == 0 { 44 | return 0.0; 45 | } 46 | 47 | let numerator = self.fruits + self.droplets + self.tiny_droplets; 48 | let denominator = total_hits; 49 | 50 | f64::from(numerator) / f64::from(denominator) 51 | } 52 | } 53 | 54 | impl Default for CatchScoreState { 55 | fn default() -> Self { 56 | Self::new() 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/catch/strains.rs: -------------------------------------------------------------------------------- 1 | use rosu_map::section::general::GameMode; 2 | 3 | use crate::{ 4 | any::{difficulty::skills::StrainSkill, Difficulty}, 5 | catch::difficulty::DifficultyValues, 6 | model::mode::ConvertError, 7 | Beatmap, 8 | }; 9 | 10 | /// The result of calculating the strains on a osu!catch map. 11 | /// 12 | /// Suitable to plot the difficulty of a map over time. 13 | #[derive(Clone, Debug, PartialEq)] 14 | pub struct CatchStrains { 15 | /// Strain peaks of the movement skill. 16 | pub movement: Vec, 17 | } 18 | 19 | impl CatchStrains { 20 | /// Time between two strains in ms. 21 | pub const SECTION_LEN: f64 = 750.0; 22 | } 23 | 24 | pub fn strains(difficulty: &Difficulty, map: &Beatmap) -> Result { 25 | let map = map.convert_ref(GameMode::Catch, difficulty.get_mods())?; 26 | let DifficultyValues { movement, .. } = DifficultyValues::calculate(difficulty, &map); 27 | 28 | Ok(CatchStrains { 29 | movement: movement.into_current_strain_peaks().into_vec(), 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /src/mania/attributes.rs: -------------------------------------------------------------------------------- 1 | use crate::mania::performance::ManiaPerformance; 2 | 3 | /// The result of a difficulty calculation on an osu!mania map. 4 | #[derive(Clone, Debug, Default, PartialEq)] 5 | pub struct ManiaDifficultyAttributes { 6 | /// The final star rating. 7 | pub stars: f64, 8 | /// The amount of hitobjects in the map. 9 | pub n_objects: u32, 10 | /// The amount of hold notes in the map. 11 | pub n_hold_notes: u32, 12 | /// The maximum achievable combo. 13 | pub max_combo: u32, 14 | /// Whether the [`Beatmap`] was a convert i.e. an osu!standard map. 15 | /// 16 | /// [`Beatmap`]: crate::model::beatmap::Beatmap 17 | pub is_convert: bool, 18 | } 19 | 20 | impl ManiaDifficultyAttributes { 21 | /// Return the maximum combo. 22 | pub const fn max_combo(&self) -> u32 { 23 | self.max_combo 24 | } 25 | 26 | /// Return the amount of hitobjects. 27 | pub const fn n_objects(&self) -> u32 { 28 | self.n_objects 29 | } 30 | 31 | /// Whether the [`Beatmap`] was a convert i.e. an osu!standard map. 32 | /// 33 | /// [`Beatmap`]: crate::model::beatmap::Beatmap 34 | pub const fn is_convert(&self) -> bool { 35 | self.is_convert 36 | } 37 | 38 | /// Returns a builder for performance calculation. 39 | pub fn performance<'a>(self) -> ManiaPerformance<'a> { 40 | self.into() 41 | } 42 | } 43 | 44 | /// The result of a performance calculation on an osu!mania map. 45 | #[derive(Clone, Debug, Default, PartialEq)] 46 | pub struct ManiaPerformanceAttributes { 47 | /// The difficulty attributes that were used for the performance calculation. 48 | pub difficulty: ManiaDifficultyAttributes, 49 | /// The final performance points. 50 | pub pp: f64, 51 | /// The difficulty portion of the final pp. 52 | pub pp_difficulty: f64, 53 | } 54 | 55 | impl ManiaPerformanceAttributes { 56 | /// Return the star value. 57 | pub const fn stars(&self) -> f64 { 58 | self.difficulty.stars 59 | } 60 | 61 | /// Return the performance point value. 62 | pub const fn pp(&self) -> f64 { 63 | self.pp 64 | } 65 | 66 | /// Return the maximum combo of the map. 67 | pub const fn max_combo(&self) -> u32 { 68 | self.difficulty.max_combo 69 | } 70 | 71 | /// Return the amount of hitobjects. 72 | pub const fn n_objects(&self) -> u32 { 73 | self.difficulty.n_objects 74 | } 75 | 76 | /// Whether the [`Beatmap`] was a convert i.e. an osu!standard map. 77 | /// 78 | /// [`Beatmap`]: crate::model::beatmap::Beatmap 79 | pub const fn is_convert(&self) -> bool { 80 | self.difficulty.is_convert 81 | } 82 | 83 | /// Returns a builder for performance calculation. 84 | pub fn performance<'a>(self) -> ManiaPerformance<'a> { 85 | self.difficulty.into() 86 | } 87 | } 88 | 89 | impl From for ManiaDifficultyAttributes { 90 | fn from(attributes: ManiaPerformanceAttributes) -> Self { 91 | attributes.difficulty 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/mania/convert/pattern_generator/end_time_object.rs: -------------------------------------------------------------------------------- 1 | use rosu_map::section::hit_objects::hit_samples::HitSoundType; 2 | 3 | use crate::{ 4 | mania::convert::{pattern::Pattern, pattern_type::PatternType}, 5 | model::hit_object::HitObject, 6 | util::random::osu::Random, 7 | Beatmap, 8 | }; 9 | 10 | use super::PatternGenerator; 11 | 12 | pub struct EndTimeObjectPatternGenerator<'h> { 13 | pub end_time: f64, 14 | pub sample: HitSoundType, 15 | pub inner: PatternGenerator<'h>, 16 | convert_type: PatternType, 17 | prev_pattern: &'h Pattern, 18 | } 19 | 20 | impl<'h> EndTimeObjectPatternGenerator<'h> { 21 | pub fn new( 22 | random: &'h mut Random, 23 | hit_object: &'h HitObject, 24 | end_time: f64, 25 | sample: HitSoundType, 26 | total_columns: i32, 27 | prev_pattern: &'h Pattern, 28 | orig: &'h Beatmap, 29 | ) -> Self { 30 | let convert_type = if prev_pattern.column_with_objs() == total_columns { 31 | PatternType::default() 32 | } else { 33 | PatternType::FORCE_NOT_STACK 34 | }; 35 | 36 | let inner = PatternGenerator::new(hit_object, total_columns, random, orig); 37 | 38 | Self { 39 | end_time, 40 | sample, 41 | inner, 42 | convert_type, 43 | prev_pattern, 44 | } 45 | } 46 | 47 | pub fn generate(&mut self) -> Pattern { 48 | let generate_hold = self.end_time - self.inner.hit_object.start_time >= 100.0; 49 | 50 | match self.inner.total_columns { 51 | 8 if self.sample.has_flag(HitSoundType::FINISH) 52 | && self.end_time - self.inner.hit_object.start_time < 1000.0 => 53 | { 54 | Pattern::new_end_time_note(self, 0, generate_hold) 55 | } 56 | 8 => { 57 | let column = self.get_random_column(self.inner.random_start()); 58 | 59 | Pattern::new_end_time_note(self, column, generate_hold) 60 | } 61 | _ => { 62 | let column = self.get_random_column(0); 63 | 64 | Pattern::new_end_time_note(self, column, generate_hold) 65 | } 66 | } 67 | } 68 | 69 | fn get_random_column(&mut self, lower: i32) -> u8 { 70 | let column = self.inner.get_random_column(Some(lower), None); 71 | 72 | if self.convert_type.contains(PatternType::FORCE_NOT_STACK) { 73 | self.find_available_column(column, Some(lower), &[self.prev_pattern]) 74 | } else { 75 | self.find_available_column(column, Some(lower), &[]) 76 | } 77 | } 78 | 79 | fn find_available_column( 80 | &mut self, 81 | mut initial_column: u8, 82 | lower: Option, 83 | patterns: &[&Pattern], 84 | ) -> u8 { 85 | let lower = lower.unwrap_or_else(|| self.inner.random_start()); 86 | let upper = self.inner.total_columns; 87 | 88 | let is_valid = |column: i32| { 89 | let column = column as u8; 90 | 91 | patterns 92 | .iter() 93 | .all(|pattern| !pattern.column_has_obj(column)) 94 | }; 95 | 96 | // * Check for the initial column 97 | if is_valid(i32::from(initial_column)) { 98 | return initial_column; 99 | } 100 | 101 | // * Ensure that we have at least one free column, so that an endless loop is avoided 102 | let has_valid_column = (lower..upper).any(is_valid); 103 | assert!(has_valid_column); 104 | 105 | // * Iterate until a valid column is found. This is a random iteration in the default case. 106 | while { 107 | initial_column = self.inner.get_random_column(Some(lower), Some(upper)); 108 | 109 | !is_valid(i32::from(initial_column)) 110 | } {} 111 | 112 | initial_column 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/mania/convert/pattern_generator/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | mania::object::ManiaObject, 3 | model::{beatmap::Beatmap, hit_object::HitObject}, 4 | util::random::osu::Random, 5 | }; 6 | 7 | pub(super) mod end_time_object; 8 | pub(super) mod hit_object; 9 | pub(super) mod path_object; 10 | 11 | pub struct PatternGenerator<'a> { 12 | pub hit_object: &'a HitObject, 13 | pub total_columns: i32, 14 | random: &'a mut Random, 15 | original_map: &'a Beatmap, 16 | } 17 | 18 | impl<'a> PatternGenerator<'a> { 19 | const fn new( 20 | hit_object: &'a HitObject, 21 | total_columns: i32, 22 | random: &'a mut Random, 23 | original_map: &'a Beatmap, 24 | ) -> Self { 25 | Self { 26 | hit_object, 27 | total_columns, 28 | random, 29 | original_map, 30 | } 31 | } 32 | 33 | fn random_start(&self) -> i32 { 34 | i32::from(self.total_columns == 8) 35 | } 36 | 37 | fn get_column(&self, allow_special: Option) -> u8 { 38 | let allow_special = allow_special.unwrap_or(false); 39 | 40 | if allow_special && self.total_columns == 8 { 41 | const LOCAL_X_DIVISOR: f32 = 512.0 / 7.0; 42 | 43 | ((self.hit_object.pos.x / LOCAL_X_DIVISOR).floor() as u8).clamp(0, 6) + 1 44 | } else { 45 | ManiaObject::column(self.hit_object.pos.x, self.total_columns as f32) as u8 46 | } 47 | } 48 | 49 | fn get_random_note_count( 50 | &mut self, 51 | p2: f64, 52 | p3: f64, 53 | p4: Option, 54 | p5: Option, 55 | p6: Option, 56 | ) -> i32 { 57 | let p4 = p4.unwrap_or(0.0); 58 | let p5 = p5.unwrap_or(0.0); 59 | let p6 = p6.unwrap_or(0.0); 60 | 61 | let val = self.random.next_double(); 62 | 63 | if val >= 1.0 - p6 { 64 | 6 65 | } else if val >= 1.0 - p5 { 66 | 5 67 | } else if val >= 1.0 - p4 { 68 | 4 69 | } else if val >= 1.0 - p3 { 70 | 3 71 | } else { 72 | 1 + i32::from(val >= 1.0 - p2) 73 | } 74 | } 75 | 76 | fn conversion_difficulty(&self) -> f64 { 77 | let orig = self.original_map; 78 | let last_obj_time = orig.hit_objects.last().map_or(0.0, |h| h.start_time); 79 | let first_obj_time = orig.hit_objects.first().map_or(0.0, |h| h.start_time); 80 | 81 | // * Drain time in seconds 82 | let total_break_time = orig.total_break_time(); 83 | let mut drain_time = ((last_obj_time - first_obj_time - total_break_time) / 1000.0) as i32; 84 | 85 | if drain_time == 0 { 86 | drain_time = 10_000; 87 | } 88 | 89 | let mut conversion_difficulty = 0.0; 90 | conversion_difficulty += f64::from(orig.hp + orig.ar.clamp(4.0, 7.0)) / 1.5; 91 | conversion_difficulty += orig.hit_objects.len() as f64 / f64::from(drain_time) * 9.0; 92 | conversion_difficulty /= 38.0; 93 | conversion_difficulty *= 5.0; 94 | conversion_difficulty /= 1.15; 95 | conversion_difficulty = conversion_difficulty.min(12.0); 96 | 97 | conversion_difficulty 98 | } 99 | 100 | fn get_random_column(&mut self, lower: Option, upper: Option) -> u8 { 101 | let lower = lower.unwrap_or_else(|| self.random_start()); 102 | let upper = upper.unwrap_or(self.total_columns); 103 | 104 | self.random.next_int_range(lower, upper) as u8 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/mania/convert/pattern_type.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt, 3 | ops::{BitAndAssign, BitOr, BitOrAssign, Not}, 4 | }; 5 | 6 | #[derive(Copy, Clone, Default)] 7 | pub struct PatternType(u16); 8 | 9 | #[rustfmt::skip] 10 | impl PatternType { 11 | pub const FORCE_STACK: Self = Self(1 << 0); 12 | pub const FORCE_NOT_STACK: Self = Self(1 << 1); 13 | pub const KEEP_SINGLE: Self = Self(1 << 2); 14 | pub const LOW_PROBABILITY: Self = Self(1 << 3); 15 | // pub const ALTERNATE: Self = Self(1 << 4); 16 | // pub const FORCE_SIG_SLIDER: Self = Self(1 << 5); 17 | // pub const FORCE_NOT_SLIDER: Self = Self(1 << 6); 18 | pub const GATHERED: Self = Self(1 << 7); 19 | pub const MIRROR: Self = Self(1 << 8); 20 | pub const REVERSE: Self = Self(1 << 9); 21 | pub const CYCLE: Self = Self(1 << 10); 22 | pub const STAIR: Self = Self(1 << 11); 23 | pub const REVERSE_STAIR: Self = Self(1 << 12); 24 | } 25 | 26 | impl fmt::Display for PatternType { 27 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 28 | let mut written = false; 29 | 30 | macro_rules! write_pattern { 31 | ($self:ident, $f:ident, $written:ident: $($pat:ident,)*) => { 32 | $( 33 | if $self.contains(Self::$pat) { 34 | if $written { 35 | $f.write_str(", ")?; 36 | } else { 37 | $written = true; 38 | } 39 | 40 | $f.write_str(stringify!($pat))?; 41 | } 42 | )* 43 | } 44 | } 45 | 46 | write_pattern! { 47 | self, f, written: 48 | FORCE_STACK, 49 | FORCE_NOT_STACK, 50 | KEEP_SINGLE, 51 | LOW_PROBABILITY, 52 | GATHERED, 53 | MIRROR, 54 | REVERSE, 55 | CYCLE, 56 | STAIR, 57 | REVERSE_STAIR, 58 | } 59 | 60 | if !written { 61 | f.write_str("NONE")?; 62 | } 63 | 64 | Ok(()) 65 | } 66 | } 67 | 68 | impl PatternType { 69 | pub const fn contains(self, other: Self) -> bool { 70 | self.0 & other.0 == other.0 71 | } 72 | } 73 | 74 | impl BitOr for PatternType { 75 | type Output = Self; 76 | 77 | fn bitor(self, rhs: Self) -> Self::Output { 78 | Self(self.0 | rhs.0) 79 | } 80 | } 81 | 82 | impl BitOrAssign for PatternType { 83 | fn bitor_assign(&mut self, rhs: Self) { 84 | self.0 |= rhs.0; 85 | } 86 | } 87 | 88 | impl BitAndAssign for PatternType { 89 | fn bitand_assign(&mut self, rhs: Self) { 90 | self.0 &= rhs.0; 91 | } 92 | } 93 | 94 | impl Not for PatternType { 95 | type Output = Self; 96 | 97 | fn not(self) -> Self::Output { 98 | Self(!self.0) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/mania/difficulty/mod.rs: -------------------------------------------------------------------------------- 1 | use std::cmp; 2 | 3 | use rosu_map::section::general::GameMode; 4 | 5 | use crate::{ 6 | any::difficulty::{skills::StrainSkill, Difficulty}, 7 | mania::{ 8 | difficulty::{object::ManiaDifficultyObject, skills::strain::Strain}, 9 | object::{ManiaObject, ObjectParams}, 10 | }, 11 | model::mode::ConvertError, 12 | Beatmap, 13 | }; 14 | 15 | use super::{attributes::ManiaDifficultyAttributes, convert}; 16 | 17 | pub mod gradual; 18 | mod object; 19 | mod skills; 20 | 21 | const DIFFICULTY_MULTIPLIER: f64 = 0.018; 22 | 23 | pub fn difficulty( 24 | difficulty: &Difficulty, 25 | map: &Beatmap, 26 | ) -> Result { 27 | let mut map = map.convert_ref(GameMode::Mania, difficulty.get_mods())?; 28 | 29 | if difficulty.get_mods().ho() { 30 | convert::apply_hold_off_to_beatmap(map.to_mut()); 31 | } 32 | 33 | if difficulty.get_mods().invert() { 34 | convert::apply_invert_to_beatmap(map.to_mut()); 35 | } 36 | 37 | if let Some(seed) = difficulty.get_mods().random_seed() { 38 | convert::apply_random_to_beatmap(map.to_mut(), seed); 39 | } 40 | 41 | let n_objects = cmp::min(difficulty.get_passed_objects(), map.hit_objects.len()) as u32; 42 | 43 | let values = DifficultyValues::calculate(difficulty, &map); 44 | 45 | Ok(ManiaDifficultyAttributes { 46 | stars: values.strain.into_difficulty_value() * DIFFICULTY_MULTIPLIER, 47 | max_combo: values.max_combo, 48 | n_objects, 49 | n_hold_notes: values.n_hold_notes, 50 | is_convert: map.is_convert, 51 | }) 52 | } 53 | 54 | pub struct DifficultyValues { 55 | pub strain: Strain, 56 | pub max_combo: u32, 57 | pub n_hold_notes: u32, 58 | } 59 | 60 | impl DifficultyValues { 61 | pub fn calculate(difficulty: &Difficulty, map: &Beatmap) -> Self { 62 | let take = difficulty.get_passed_objects(); 63 | let total_columns = map.cs.round_ties_even().max(1.0); 64 | let clock_rate = difficulty.get_clock_rate(); 65 | let mut params = ObjectParams::new(map); 66 | 67 | let mania_objects = map 68 | .hit_objects 69 | .iter() 70 | .map(|h| ManiaObject::new(h, total_columns, &mut params)) 71 | .take(take); 72 | 73 | let diff_objects = Self::create_difficulty_objects(clock_rate, mania_objects); 74 | 75 | let mut strain = Strain::new(total_columns as usize); 76 | 77 | for curr in diff_objects.iter() { 78 | strain.process(curr, &diff_objects); 79 | } 80 | 81 | Self { 82 | strain, 83 | max_combo: params.max_combo(), 84 | n_hold_notes: params.n_hold_notes(), 85 | } 86 | } 87 | 88 | pub fn create_difficulty_objects( 89 | clock_rate: f64, 90 | mut mania_objects: impl ExactSizeIterator, 91 | ) -> Box<[ManiaDifficultyObject]> { 92 | let Some(first) = mania_objects.next() else { 93 | return Box::default(); 94 | }; 95 | 96 | let n_diff_objects = mania_objects.len(); 97 | 98 | let diff_objects_iter = mania_objects.enumerate().scan(first, |last, (i, base)| { 99 | let diff_object = ManiaDifficultyObject::new(&base, last, clock_rate, i); 100 | *last = base; 101 | 102 | Some(diff_object) 103 | }); 104 | 105 | let mut diff_objects = Vec::with_capacity(n_diff_objects); 106 | diff_objects.extend(diff_objects_iter); 107 | 108 | debug_assert_eq!(n_diff_objects, diff_objects.len()); 109 | 110 | diff_objects.into_boxed_slice() 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/mania/difficulty/object.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | any::difficulty::object::{HasStartTime, IDifficultyObject}, 3 | mania::object::ManiaObject, 4 | }; 5 | 6 | pub struct ManiaDifficultyObject { 7 | pub idx: usize, 8 | pub base_column: usize, 9 | pub delta_time: f64, 10 | pub start_time: f64, 11 | pub end_time: f64, 12 | } 13 | 14 | impl ManiaDifficultyObject { 15 | pub fn new(base: &ManiaObject, last: &ManiaObject, clock_rate: f64, idx: usize) -> Self { 16 | Self { 17 | idx, 18 | base_column: base.column, 19 | delta_time: (base.start_time - last.start_time) / clock_rate, 20 | start_time: base.start_time / clock_rate, 21 | end_time: base.end_time / clock_rate, 22 | } 23 | } 24 | } 25 | 26 | impl IDifficultyObject for ManiaDifficultyObject { 27 | type DifficultyObjects = [Self]; 28 | 29 | fn idx(&self) -> usize { 30 | self.idx 31 | } 32 | } 33 | 34 | impl HasStartTime for ManiaDifficultyObject { 35 | fn start_time(&self) -> f64 { 36 | self.start_time 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/mania/difficulty/skills/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod strain; 2 | -------------------------------------------------------------------------------- /src/mania/difficulty/skills/strain.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | any::difficulty::object::{HasStartTime, IDifficultyObject}, 3 | mania::difficulty::object::ManiaDifficultyObject, 4 | util::difficulty::logistic, 5 | }; 6 | 7 | define_skill! { 8 | #[allow(clippy::struct_field_names)] 9 | pub struct Strain: StrainDecaySkill => [ManiaDifficultyObject][ManiaDifficultyObject] { 10 | start_times: Box<[f64]>, 11 | end_times: Box<[f64]>, 12 | individual_strains: Box<[f64]>, 13 | individual_strain: f64 = 0.0, 14 | overall_strain: f64 = 1.0, 15 | } 16 | 17 | pub fn new(total_columns: usize) -> Self { 18 | Self { 19 | start_times: vec![0.0; total_columns].into_boxed_slice(), 20 | end_times: vec![0.0; total_columns].into_boxed_slice(), 21 | individual_strains: vec![0.0; total_columns].into_boxed_slice(), 22 | individual_strain: 0.0, 23 | overall_strain: 1.0, 24 | } 25 | } 26 | } 27 | 28 | impl Strain { 29 | const INDIVIDUAL_DECAY_BASE: f64 = 0.125; 30 | const OVERALL_DECAY_BASE: f64 = 0.3; 31 | const RELEASE_THRESHOLD: f64 = 30.0; 32 | 33 | const SKILL_MULTIPLIER: f64 = 1.0; 34 | const STRAIN_DECAY_BASE: f64 = 1.0; 35 | 36 | fn calculate_initial_strain( 37 | &self, 38 | offset: f64, 39 | curr: &ManiaDifficultyObject, 40 | objects: &[ManiaDifficultyObject], 41 | ) -> f64 { 42 | let prev_start_time = curr 43 | .previous(0, objects) 44 | .map_or(0.0, HasStartTime::start_time); 45 | 46 | apply_decay( 47 | self.individual_strain, 48 | offset - prev_start_time, 49 | Self::INDIVIDUAL_DECAY_BASE, 50 | ) + apply_decay( 51 | self.overall_strain, 52 | offset - prev_start_time, 53 | Self::OVERALL_DECAY_BASE, 54 | ) 55 | } 56 | 57 | fn strain_value_of( 58 | &mut self, 59 | curr: &ManiaDifficultyObject, 60 | _: &[ManiaDifficultyObject], 61 | ) -> f64 { 62 | let mania_curr = curr; 63 | let start_time = mania_curr.start_time; 64 | let end_time = mania_curr.end_time; 65 | let column = mania_curr.base_column; 66 | let mut is_overlapping = false; 67 | 68 | // * Lowest value we can assume with the current information 69 | let mut closest_end_time = (end_time - start_time).abs(); 70 | // * Factor to all additional strains in case something else is held 71 | let mut hold_factor = 1.0; 72 | // * Addition to the current note in case it's a hold and has to be released awkwardly 73 | let mut hold_addition = 0.0; 74 | 75 | for i in 0..self.end_times.len() { 76 | // * The current note is overlapped if a previous note or end is overlapping the current note body 77 | is_overlapping |= self.end_times[i] > start_time + 1.0 78 | && end_time > self.end_times[i] + 1.0 79 | && start_time > self.start_times[i] + 1.0; 80 | 81 | // * We give a slight bonus to everything if something is held meanwhile 82 | if self.end_times[i] > end_time + 1.0 && start_time > self.start_times[i] + 1.0 { 83 | hold_factor = 1.25; 84 | } 85 | 86 | closest_end_time = (end_time - self.end_times[i]).abs().min(closest_end_time); 87 | } 88 | 89 | // * 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. 90 | // * Releasing multiple notes is just as easy as releasing 1. Nerfs the hold addition by half if the closest release is release_threshold away. 91 | // * holdAddition 92 | // * ^ 93 | // * 1.0 + - - - - - -+----------- 94 | // * | / 95 | // * 0.5 + - - - - -/ Sigmoid Curve 96 | // * | /| 97 | // * 0.0 +--------+-+---------------> Release Difference / ms 98 | // * release_threshold 99 | if is_overlapping { 100 | hold_addition = logistic(closest_end_time, Self::RELEASE_THRESHOLD, 0.27, None); 101 | } 102 | 103 | // * Decay and increase individualStrains in own column 104 | self.individual_strains[column] = apply_decay( 105 | self.individual_strains[column], 106 | start_time - self.start_times[column], 107 | Self::INDIVIDUAL_DECAY_BASE, 108 | ); 109 | self.individual_strains[column] += 2.0 * hold_factor; 110 | 111 | // * For notes at the same time (in a chord), the individualStrain should be the hardest individualStrain out of those columns 112 | self.individual_strain = if mania_curr.delta_time <= 1.0 { 113 | self.individual_strain.max(self.individual_strains[column]) 114 | } else { 115 | self.individual_strains[column] 116 | }; 117 | 118 | // * Decay and increase overallStrain 119 | self.overall_strain = apply_decay( 120 | self.overall_strain, 121 | curr.delta_time, 122 | Self::OVERALL_DECAY_BASE, 123 | ); 124 | self.overall_strain += (1.0 + hold_addition) * hold_factor; 125 | 126 | // * Update startTimes and endTimes arrays 127 | self.start_times[column] = start_time; 128 | self.end_times[column] = end_time; 129 | 130 | // * By subtracting CurrentStrain, this skill effectively only considers the maximum strain of any one hitobject within each strain section. 131 | self.individual_strain + self.overall_strain - self.strain_decay_skill_current_strain 132 | } 133 | } 134 | 135 | fn apply_decay(value: f64, delta_time: f64, decay_base: f64) -> f64 { 136 | value * f64::powf(decay_base, delta_time / 1000.0) 137 | } 138 | -------------------------------------------------------------------------------- /src/mania/mod.rs: -------------------------------------------------------------------------------- 1 | use rosu_map::section::general::GameMode; 2 | 3 | use crate::{ 4 | model::{ 5 | beatmap::Beatmap, 6 | mode::{ConvertError, IGameMode}, 7 | }, 8 | Difficulty, GameMods, 9 | }; 10 | 11 | pub use self::{ 12 | attributes::{ManiaDifficultyAttributes, ManiaPerformanceAttributes}, 13 | difficulty::gradual::ManiaGradualDifficulty, 14 | performance::{gradual::ManiaGradualPerformance, ManiaPerformance}, 15 | score_state::ManiaScoreState, 16 | strains::ManiaStrains, 17 | }; 18 | 19 | mod attributes; 20 | mod convert; 21 | mod difficulty; 22 | mod object; 23 | mod performance; 24 | mod score_state; 25 | mod strains; 26 | 27 | /// Marker type for [`GameMode::Mania`]. 28 | /// 29 | /// [`GameMode::Mania`]: rosu_map::section::general::GameMode::Mania 30 | pub struct Mania; 31 | 32 | impl Mania { 33 | pub(crate) fn convert(map: &mut Beatmap, mods: &GameMods) { 34 | debug_assert!(!map.is_convert && map.mode == GameMode::Osu); 35 | convert::convert(map, mods); 36 | } 37 | } 38 | 39 | impl IGameMode for Mania { 40 | type DifficultyAttributes = ManiaDifficultyAttributes; 41 | type Strains = ManiaStrains; 42 | type Performance<'map> = ManiaPerformance<'map>; 43 | type GradualDifficulty = ManiaGradualDifficulty; 44 | type GradualPerformance = ManiaGradualPerformance; 45 | 46 | fn difficulty( 47 | difficulty: &Difficulty, 48 | map: &Beatmap, 49 | ) -> Result { 50 | difficulty::difficulty(difficulty, map) 51 | } 52 | 53 | fn strains(difficulty: &Difficulty, map: &Beatmap) -> Result { 54 | strains::strains(difficulty, map) 55 | } 56 | 57 | fn performance(map: &Beatmap) -> Self::Performance<'_> { 58 | ManiaPerformance::new(map) 59 | } 60 | 61 | fn gradual_difficulty( 62 | difficulty: Difficulty, 63 | map: &Beatmap, 64 | ) -> Result { 65 | ManiaGradualDifficulty::new(difficulty, map) 66 | } 67 | 68 | fn gradual_performance( 69 | difficulty: Difficulty, 70 | map: &Beatmap, 71 | ) -> Result { 72 | ManiaGradualPerformance::new(difficulty, map) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/mania/object.rs: -------------------------------------------------------------------------------- 1 | use rosu_map::section::{general::GameMode, hit_objects::CurveBuffers}; 2 | 3 | use crate::model::{ 4 | beatmap::Beatmap, 5 | control_point::{DifficultyPoint, TimingPoint}, 6 | hit_object::{HitObject, HitObjectKind, HoldNote, Spinner}, 7 | }; 8 | 9 | pub struct ManiaObject { 10 | pub start_time: f64, 11 | pub end_time: f64, 12 | pub column: usize, 13 | } 14 | 15 | impl ManiaObject { 16 | pub fn new(h: &HitObject, total_columns: f32, params: &mut ObjectParams<'_>) -> Self { 17 | let column = Self::column(h.pos.x, total_columns); 18 | params.max_combo += 1; 19 | 20 | match h.kind { 21 | HitObjectKind::Circle => Self { 22 | start_time: h.start_time, 23 | end_time: h.start_time, 24 | column, 25 | }, 26 | HitObjectKind::Slider(ref slider) => { 27 | const BASE_SCORING_DIST: f32 = 100.0; 28 | 29 | let dist = slider.curve(GameMode::Mania, &mut params.curve_bufs).dist(); 30 | 31 | let beat_len = params 32 | .map 33 | .timing_point_at(h.start_time) 34 | .map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len); 35 | 36 | let slider_velocity = params 37 | .map 38 | .difficulty_point_at(h.start_time) 39 | .map_or(DifficultyPoint::DEFAULT_SLIDER_VELOCITY, |point| { 40 | point.slider_velocity 41 | }); 42 | 43 | let scoring_dist = 44 | f64::from(BASE_SCORING_DIST) * params.map.slider_multiplier * slider_velocity; 45 | let velocity = scoring_dist / beat_len; 46 | 47 | let duration = (slider.span_count() as f64) * dist / velocity; 48 | 49 | params.max_combo += (duration / 100.0) as u32; 50 | params.n_hold_notes += 1; 51 | 52 | Self { 53 | start_time: h.start_time, 54 | end_time: h.start_time + duration, 55 | column, 56 | } 57 | } 58 | HitObjectKind::Spinner(Spinner { duration }) 59 | | HitObjectKind::Hold(HoldNote { duration }) => { 60 | params.max_combo += (duration / 100.0) as u32; 61 | params.n_hold_notes += 1; 62 | 63 | Self { 64 | start_time: h.start_time, 65 | end_time: h.start_time + duration, 66 | column, 67 | } 68 | } 69 | } 70 | } 71 | 72 | pub fn column(x: f32, total_columns: f32) -> usize { 73 | let x_divisor = 512.0 / total_columns; 74 | 75 | (x / x_divisor).floor().min(total_columns - 1.0) as usize 76 | } 77 | } 78 | 79 | pub struct ObjectParams<'a> { 80 | map: &'a Beatmap, 81 | max_combo: u32, 82 | n_hold_notes: u32, 83 | curve_bufs: CurveBuffers, 84 | } 85 | 86 | impl<'a> ObjectParams<'a> { 87 | pub fn new(map: &'a Beatmap) -> Self { 88 | Self { 89 | map, 90 | max_combo: 0, 91 | n_hold_notes: 0, 92 | curve_bufs: CurveBuffers::default(), 93 | } 94 | } 95 | 96 | pub const fn max_combo(&self) -> u32 { 97 | self.max_combo 98 | } 99 | 100 | pub const fn n_hold_notes(&self) -> u32 { 101 | self.n_hold_notes 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/mania/performance/calculator.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | mania::{ManiaDifficultyAttributes, ManiaPerformanceAttributes, ManiaScoreState}, 3 | GameMods, 4 | }; 5 | 6 | pub(super) struct ManiaPerformanceCalculator<'mods> { 7 | attrs: ManiaDifficultyAttributes, 8 | mods: &'mods GameMods, 9 | state: ManiaScoreState, 10 | } 11 | 12 | impl<'a> ManiaPerformanceCalculator<'a> { 13 | pub const fn new( 14 | attrs: ManiaDifficultyAttributes, 15 | mods: &'a GameMods, 16 | state: ManiaScoreState, 17 | ) -> Self { 18 | Self { attrs, mods, state } 19 | } 20 | } 21 | 22 | impl ManiaPerformanceCalculator<'_> { 23 | pub fn calculate(self) -> ManiaPerformanceAttributes { 24 | let mut multiplier = 1.0; 25 | 26 | if self.mods.nf() { 27 | multiplier *= 0.75; 28 | } 29 | 30 | if self.mods.ez() { 31 | multiplier *= 0.5; 32 | } 33 | 34 | let difficulty_value = self.compute_difficulty_value(); 35 | let pp = difficulty_value * multiplier; 36 | 37 | ManiaPerformanceAttributes { 38 | difficulty: self.attrs, 39 | pp, 40 | pp_difficulty: difficulty_value, 41 | } 42 | } 43 | 44 | fn compute_difficulty_value(&self) -> f64 { 45 | // * Star rating to pp curve 46 | 8.0 * f64::powf(f64::max(self.attrs.stars - 0.15, 0.05), 2.2) 47 | // * From 80% accuracy, 1/20th of total pp is awarded per additional 1% accuracy 48 | * f64::max(0.0, 5.0 * self.calculate_custom_accuracy() - 4.0) 49 | // * Length bonus, capped at 1500 notes 50 | * (1.0 + 0.1 * f64::min(1.0, self.total_hits() / 1500.0)) 51 | } 52 | 53 | const fn total_hits(&self) -> f64 { 54 | self.state.total_hits() as f64 55 | } 56 | 57 | fn calculate_custom_accuracy(&self) -> f64 { 58 | let ManiaScoreState { 59 | n320, 60 | n300, 61 | n200, 62 | n100, 63 | n50, 64 | misses: _, 65 | } = &self.state; 66 | 67 | let total_hits = self.state.total_hits(); 68 | 69 | if total_hits == 0 { 70 | return 0.0; 71 | } 72 | 73 | custom_accuracy(*n320, *n300, *n200, *n100, *n50, total_hits) 74 | } 75 | } 76 | 77 | pub(super) fn custom_accuracy( 78 | n320: u32, 79 | n300: u32, 80 | n200: u32, 81 | n100: u32, 82 | n50: u32, 83 | total_hits: u32, 84 | ) -> f64 { 85 | let numerator = n320 * 32 + n300 * 30 + n200 * 20 + n100 * 10 + n50 * 5; 86 | let denominator = total_hits * 32; 87 | 88 | f64::from(numerator) / f64::from(denominator) 89 | } 90 | -------------------------------------------------------------------------------- /src/mania/score_state.rs: -------------------------------------------------------------------------------- 1 | /// Aggregation for a score's current state. 2 | #[derive(Clone, Debug, PartialEq, Eq)] 3 | pub struct ManiaScoreState { 4 | /// Amount of current 320s. 5 | pub n320: u32, 6 | /// Amount of current 300s. 7 | pub n300: u32, 8 | /// Amount of current 200s. 9 | pub n200: u32, 10 | /// Amount of current 100s. 11 | pub n100: u32, 12 | /// Amount of current 50s. 13 | pub n50: u32, 14 | /// Amount of current misses. 15 | pub misses: u32, 16 | } 17 | 18 | impl ManiaScoreState { 19 | /// Create a new empty score state. 20 | pub const fn new() -> Self { 21 | Self { 22 | n320: 0, 23 | n300: 0, 24 | n200: 0, 25 | n100: 0, 26 | n50: 0, 27 | misses: 0, 28 | } 29 | } 30 | 31 | /// Return the total amount of hits by adding everything up. 32 | pub const fn total_hits(&self) -> u32 { 33 | self.n320 + self.n300 + self.n200 + self.n100 + self.n50 + self.misses 34 | } 35 | 36 | /// Calculate the accuracy between `0.0` and `1.0` for this state. 37 | pub fn accuracy(&self, classic: bool) -> f64 { 38 | let total_hits = self.total_hits(); 39 | 40 | if total_hits == 0 { 41 | return 0.0; 42 | } 43 | 44 | let perfect_weight = if classic { 60 } else { 61 }; 45 | 46 | let numerator = perfect_weight * self.n320 47 | + 60 * self.n300 48 | + 40 * self.n200 49 | + 20 * self.n100 50 | + 10 * self.n50; 51 | 52 | let denominator = perfect_weight * total_hits; 53 | 54 | f64::from(numerator) / f64::from(denominator) 55 | } 56 | } 57 | 58 | impl Default for ManiaScoreState { 59 | fn default() -> Self { 60 | Self::new() 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/mania/strains.rs: -------------------------------------------------------------------------------- 1 | use rosu_map::section::general::GameMode; 2 | 3 | use crate::{ 4 | any::{difficulty::skills::StrainSkill, Difficulty}, 5 | mania::difficulty::DifficultyValues, 6 | model::mode::ConvertError, 7 | Beatmap, 8 | }; 9 | 10 | /// The result of calculating the strains on a osu!mania map. 11 | /// 12 | /// Suitable to plot the difficulty of a map over time. 13 | #[derive(Clone, Debug, PartialEq)] 14 | pub struct ManiaStrains { 15 | /// Strain peaks of the strain skill. 16 | pub strains: Vec, 17 | } 18 | 19 | impl ManiaStrains { 20 | /// Time between two strains in ms. 21 | pub const SECTION_LEN: f64 = 400.0; 22 | } 23 | 24 | pub fn strains(difficulty: &Difficulty, map: &Beatmap) -> Result { 25 | let map = map.convert_ref(GameMode::Mania, difficulty.get_mods())?; 26 | let values = DifficultyValues::calculate(difficulty, &map); 27 | 28 | Ok(ManiaStrains { 29 | strains: values.strain.into_current_strain_peaks().into_vec(), 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /src/model/beatmap/bpm.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use crate::model::{control_point::TimingPoint, hit_object::HitObject}; 4 | 5 | pub fn bpm(last_hit_object: Option<&HitObject>, timing_points: &[TimingPoint]) -> f64 { 6 | // This is incorrect if the last object is a slider since there 7 | // is no reasonable way to get the slider end time at this point. 8 | let last_time = last_hit_object 9 | .map(HitObject::end_time) 10 | .or_else(|| timing_points.last().map(|t| t.time)) 11 | .unwrap_or(0.0); 12 | 13 | let mut bpm_points = BeatLenDuration::new(last_time); 14 | 15 | // * osu-stable forced the first control point to start at 0. 16 | // * This is reproduced here to maintain compatibility around 17 | // * osu!mania scroll speed and song select display. 18 | match timing_points { 19 | [curr] => bpm_points.add(curr.beat_len, 0.0, last_time), 20 | [curr, next, ..] => bpm_points.add(curr.beat_len, 0.0, next.time), 21 | [] => {} 22 | } 23 | 24 | timing_points 25 | .iter() 26 | .skip(1) 27 | .zip(timing_points.iter().skip(2).map(|t| t.time)) 28 | .for_each(|(curr, next_time)| bpm_points.add(curr.beat_len, curr.time, next_time)); 29 | 30 | if let [.., _, curr] = timing_points { 31 | bpm_points.add(curr.beat_len, curr.time, last_time); 32 | } 33 | 34 | let most_common_beat_len = bpm_points 35 | .map 36 | .into_iter() 37 | // * Get the most common one, or 0 as a suitable default 38 | .max_by(|(_, a), (_, b)| a.total_cmp(b)) 39 | .map_or(0.0, |(beat_len, _)| f64::from_bits(beat_len)); 40 | 41 | 60_000.0 / most_common_beat_len 42 | } 43 | 44 | /// Maps `beat_len` to a cumulative duration 45 | struct BeatLenDuration { 46 | last_time: f64, 47 | map: HashMap, 48 | } 49 | 50 | impl BeatLenDuration { 51 | fn new(last_time: f64) -> Self { 52 | Self { 53 | last_time, 54 | map: HashMap::default(), 55 | } 56 | } 57 | 58 | fn add(&mut self, beat_len: f64, curr_time: f64, next_time: f64) { 59 | let beat_len = (1000.0 * beat_len).round() / 1000.0; 60 | let entry = self.map.entry(beat_len.to_bits()).or_default(); 61 | 62 | if curr_time <= self.last_time { 63 | *entry += next_time - curr_time; 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/model/control_point/difficulty.rs: -------------------------------------------------------------------------------- 1 | use crate::util::float_ext::FloatExt; 2 | 3 | /// Difficulty-related info about this control point. 4 | #[derive(Clone, Debug, PartialEq)] 5 | pub struct DifficultyPoint { 6 | pub time: f64, 7 | pub slider_velocity: f64, 8 | pub bpm_multiplier: f64, 9 | pub generate_ticks: bool, 10 | } 11 | 12 | impl DifficultyPoint { 13 | pub const DEFAULT_SLIDER_VELOCITY: f64 = 1.0; 14 | pub const DEFAULT_BPM_MULTIPLIER: f64 = 1.0; 15 | pub const DEFAULT_GENERATE_TICKS: bool = true; 16 | 17 | pub fn new(time: f64, beat_len: f64, speed_multiplier: f64) -> Self { 18 | Self { 19 | time, 20 | slider_velocity: speed_multiplier.clamp(0.1, 10.0), 21 | bpm_multiplier: if beat_len < 0.0 { 22 | f64::from((-beat_len) as f32).clamp(10.0, 10_000.0) / 100.0 23 | } else { 24 | 1.0 25 | }, 26 | generate_ticks: !beat_len.is_nan(), 27 | } 28 | } 29 | 30 | pub fn is_redundant(&self, existing: &Self) -> bool { 31 | self.generate_ticks == existing.generate_ticks 32 | && self.slider_velocity.eq(existing.slider_velocity) 33 | } 34 | } 35 | 36 | impl Default for DifficultyPoint { 37 | fn default() -> Self { 38 | Self { 39 | time: 0.0, 40 | slider_velocity: Self::DEFAULT_SLIDER_VELOCITY, 41 | bpm_multiplier: Self::DEFAULT_BPM_MULTIPLIER, 42 | generate_ticks: Self::DEFAULT_GENERATE_TICKS, 43 | } 44 | } 45 | } 46 | 47 | pub fn difficulty_point_at(points: &[DifficultyPoint], time: f64) -> Option<&DifficultyPoint> { 48 | points 49 | .binary_search_by(|probe| probe.time.total_cmp(&time)) 50 | .map_or_else(|i| i.checked_sub(1), Some) 51 | .map(|i| &points[i]) 52 | } 53 | -------------------------------------------------------------------------------- /src/model/control_point/effect.rs: -------------------------------------------------------------------------------- 1 | use crate::util::float_ext::FloatExt; 2 | 3 | /// Effect-related info about this control point. 4 | #[derive(Copy, Clone, Debug, PartialEq)] 5 | pub struct EffectPoint { 6 | pub time: f64, 7 | pub kiai: bool, 8 | pub scroll_speed: f64, 9 | } 10 | 11 | impl EffectPoint { 12 | pub const DEFAULT_KIAI: bool = rosu_map::section::timing_points::EffectPoint::DEFAULT_KIAI; 13 | pub const DEFAULT_SCROLL_SPEED: f64 = 14 | rosu_map::section::timing_points::EffectPoint::DEFAULT_SCROLL_SPEED; 15 | 16 | pub const fn new(time: f64, kiai: bool) -> Self { 17 | Self { 18 | time, 19 | kiai, 20 | scroll_speed: Self::DEFAULT_SCROLL_SPEED, 21 | } 22 | } 23 | 24 | pub fn is_redundant(&self, existing: &Self) -> bool { 25 | self.kiai == existing.kiai && FloatExt::eq(self.scroll_speed, existing.scroll_speed) 26 | } 27 | } 28 | 29 | impl Default for EffectPoint { 30 | fn default() -> Self { 31 | Self { 32 | time: 0.0, 33 | kiai: Self::DEFAULT_KIAI, 34 | scroll_speed: Self::DEFAULT_SCROLL_SPEED, 35 | } 36 | } 37 | } 38 | 39 | pub fn effect_point_at(points: &[EffectPoint], time: f64) -> Option<&EffectPoint> { 40 | points 41 | .binary_search_by(|probe| probe.time.total_cmp(&time)) 42 | .map_or_else(|i| i.checked_sub(1), Some) 43 | .map(|i| &points[i]) 44 | } 45 | -------------------------------------------------------------------------------- /src/model/control_point/mod.rs: -------------------------------------------------------------------------------- 1 | pub use self::{difficulty::DifficultyPoint, effect::EffectPoint, timing::TimingPoint}; 2 | 3 | pub(crate) use self::{ 4 | difficulty::difficulty_point_at, effect::effect_point_at, timing::timing_point_at, 5 | }; 6 | 7 | mod difficulty; 8 | mod effect; 9 | mod timing; 10 | -------------------------------------------------------------------------------- /src/model/control_point/timing.rs: -------------------------------------------------------------------------------- 1 | /// Timing-related info about this control point. 2 | #[derive(Copy, Clone, Debug, PartialEq)] 3 | pub struct TimingPoint { 4 | pub time: f64, 5 | pub beat_len: f64, 6 | } 7 | 8 | impl TimingPoint { 9 | pub const DEFAULT_BEAT_LEN: f64 = 10 | rosu_map::section::timing_points::TimingPoint::DEFAULT_BEAT_LEN; 11 | 12 | pub const DEFAULT_BPM: f64 = 60_000.0 / Self::DEFAULT_BEAT_LEN; 13 | 14 | pub const fn new(time: f64, beat_len: f64) -> Self { 15 | Self { 16 | time, 17 | beat_len: beat_len.clamp(6.0, 60_000.0), 18 | } 19 | } 20 | 21 | pub const fn bpm(&self) -> f64 { 22 | 60_000.0 / self.beat_len 23 | } 24 | } 25 | 26 | impl Default for TimingPoint { 27 | fn default() -> Self { 28 | Self { 29 | time: 0.0, 30 | beat_len: Self::DEFAULT_BEAT_LEN, 31 | } 32 | } 33 | } 34 | 35 | pub fn timing_point_at(points: &[TimingPoint], time: f64) -> Option<&TimingPoint> { 36 | let i = points 37 | .binary_search_by(|probe| probe.time.total_cmp(&time)) 38 | .unwrap_or_else(|i| i.saturating_sub(1)); 39 | 40 | points.get(i) 41 | } 42 | -------------------------------------------------------------------------------- /src/model/hit_object.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Ordering; 2 | 3 | use rosu_map::section::{ 4 | general::GameMode, 5 | hit_objects::{BorrowedCurve, CurveBuffers}, 6 | }; 7 | 8 | pub use rosu_map::{ 9 | section::hit_objects::{hit_samples::HitSoundType, PathControlPoint, PathType, SplineType}, 10 | util::Pos, 11 | }; 12 | 13 | /// All hitobject related data required for difficulty and performance 14 | /// calculation except for the [`HitSoundType`]. 15 | #[derive(Clone, Debug, PartialEq)] 16 | pub struct HitObject { 17 | pub pos: Pos, 18 | pub start_time: f64, 19 | pub kind: HitObjectKind, 20 | } 21 | 22 | impl HitObject { 23 | /// Whether the hitobject is a circle. 24 | pub const fn is_circle(&self) -> bool { 25 | matches!(&self.kind, HitObjectKind::Circle) 26 | } 27 | 28 | /// Whether the hitobject is a slider. 29 | pub const fn is_slider(&self) -> bool { 30 | matches!(&self.kind, HitObjectKind::Slider(_)) 31 | } 32 | 33 | /// Whether the hitobject is a spinner. 34 | pub const fn is_spinner(&self) -> bool { 35 | matches!(&self.kind, HitObjectKind::Spinner(_)) 36 | } 37 | 38 | /// Whether the hitobject is a hold note. 39 | pub const fn is_hold_note(&self) -> bool { 40 | matches!(&self.kind, HitObjectKind::Hold(_)) 41 | } 42 | 43 | /// The end time of the object. 44 | /// 45 | /// Note that this will not return the correct value for sliders. 46 | pub(crate) fn end_time(&self) -> f64 { 47 | match &self.kind { 48 | HitObjectKind::Circle | HitObjectKind::Slider { .. } => self.start_time, 49 | HitObjectKind::Spinner(Spinner { duration }) 50 | | HitObjectKind::Hold(HoldNote { duration }) => self.start_time + *duration, 51 | } 52 | } 53 | } 54 | 55 | impl PartialOrd for HitObject { 56 | fn partial_cmp(&self, other: &Self) -> Option { 57 | self.start_time.partial_cmp(&other.start_time) 58 | } 59 | } 60 | 61 | /// Additional data for a [`HitObject`]. 62 | /// 63 | /// Note that each mode handles hit objects differently. 64 | #[derive(Clone, Debug, PartialEq)] 65 | pub enum HitObjectKind { 66 | Circle, 67 | Slider(Slider), 68 | Spinner(Spinner), 69 | Hold(HoldNote), 70 | } 71 | 72 | /// A slider. 73 | #[derive(Clone, Debug, PartialEq)] 74 | pub struct Slider { 75 | pub expected_dist: Option, 76 | pub repeats: usize, 77 | pub control_points: Box<[PathControlPoint]>, 78 | pub node_sounds: Box<[HitSoundType]>, 79 | } 80 | 81 | impl Slider { 82 | /// The amount of spans of the slider. 83 | pub const fn span_count(&self) -> usize { 84 | self.repeats + 1 85 | } 86 | 87 | pub(crate) fn curve<'a>( 88 | &self, 89 | mode: GameMode, 90 | bufs: &'a mut CurveBuffers, 91 | ) -> BorrowedCurve<'a> { 92 | BorrowedCurve::new(mode, &self.control_points, self.expected_dist, bufs) 93 | } 94 | } 95 | 96 | /// A spinner. 97 | #[derive(Copy, Clone, Debug, PartialEq)] 98 | pub struct Spinner { 99 | pub duration: f64, 100 | } 101 | 102 | /// A hold note. 103 | #[derive(Copy, Clone, Debug, PartialEq)] 104 | pub struct HoldNote { 105 | pub duration: f64, 106 | } 107 | -------------------------------------------------------------------------------- /src/model/mod.rs: -------------------------------------------------------------------------------- 1 | /// Beatmap related types. 2 | pub mod beatmap; 3 | 4 | /// Control point related types. 5 | pub mod control_point; 6 | 7 | /// Hitobject related types. 8 | pub mod hit_object; 9 | 10 | /// Gamemode related types. 11 | pub mod mode; 12 | 13 | /// Gamemods related types. 14 | pub mod mods; 15 | -------------------------------------------------------------------------------- /src/model/mode.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | error::Error, 3 | fmt::{Display, Formatter, Result as FmtResult}, 4 | }; 5 | 6 | pub use rosu_map::section::general::GameMode; 7 | 8 | use crate::Difficulty; 9 | 10 | use super::beatmap::Beatmap; 11 | 12 | /// A way to specify a gamemode at compile-time. 13 | /// 14 | /// Notably, this is implemented for the marker types [`Osu`], [`Taiko`], 15 | /// [`Catch`], and [`Mania`]. 16 | /// 17 | /// [`Osu`]: crate::osu::Osu 18 | /// [`Taiko`]: crate::taiko::Taiko 19 | /// [`Catch`]: crate::catch::Catch 20 | /// [`Mania`]: crate::mania::Mania 21 | pub trait IGameMode: Sized { 22 | /// The resulting type of a difficulty calculation. 23 | type DifficultyAttributes; 24 | 25 | /// The resulting type of a strain calculation. 26 | type Strains; 27 | 28 | /// The type of a performance calculator. 29 | type Performance<'map>; 30 | 31 | /// The type of a gradual difficulty calculator. 32 | type GradualDifficulty; 33 | 34 | /// The type of a gradual performance calculator. 35 | type GradualPerformance; 36 | 37 | /// Perform a difficulty calculation for a [`Beatmap`] and process the 38 | /// final skill values. 39 | fn difficulty( 40 | difficulty: &Difficulty, 41 | map: &Beatmap, 42 | ) -> Result; 43 | 44 | /// Perform a difficulty calculation for a [`Beatmap`] without processing 45 | /// the final skill values. 46 | fn strains(difficulty: &Difficulty, map: &Beatmap) -> Result; 47 | 48 | /// Create a performance calculator for a [`Beatmap`]. 49 | fn performance(map: &Beatmap) -> Self::Performance<'_>; 50 | 51 | /// Create a gradual difficulty calculator for a [`Beatmap`]. 52 | fn gradual_difficulty( 53 | difficulty: Difficulty, 54 | map: &Beatmap, 55 | ) -> Result; 56 | 57 | /// Create a gradual performance calculator for a [`Beatmap`]. 58 | fn gradual_performance( 59 | difficulty: Difficulty, 60 | map: &Beatmap, 61 | ) -> Result; 62 | } 63 | 64 | /// Error type when failing to convert a [`Beatmap`] from one [`GameMode`] to 65 | /// another. 66 | #[derive(Copy, Clone, Debug)] 67 | pub enum ConvertError { 68 | /// Cannot convert an already converted map 69 | AlreadyConverted, 70 | /// Cannot convert from [`GameMode`] `from` to `to` 71 | Convert { from: GameMode, to: GameMode }, 72 | } 73 | 74 | impl Error for ConvertError { 75 | fn source(&self) -> Option<&(dyn Error + 'static)> { 76 | None 77 | } 78 | } 79 | 80 | impl Display for ConvertError { 81 | fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { 82 | match self { 83 | ConvertError::AlreadyConverted => { 84 | f.write_str("Cannot convert an already converted map") 85 | } 86 | ConvertError::Convert { from, to } => { 87 | write!(f, "Cannot convert from {from:?} to {to:?}") 88 | } 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/osu/attributes.rs: -------------------------------------------------------------------------------- 1 | use crate::{model::beatmap::BeatmapAttributesBuilder, osu::performance::OsuPerformance}; 2 | 3 | /// The result of a difficulty calculation on an osu!standard map. 4 | #[derive(Clone, Debug, Default, PartialEq)] 5 | pub struct OsuDifficultyAttributes { 6 | /// The difficulty of the aim skill. 7 | pub aim: f64, 8 | /// The number of sliders weighted by difficulty. 9 | pub aim_difficult_slider_count: f64, 10 | /// The difficulty of the speed skill. 11 | pub speed: f64, 12 | /// The difficulty of the flashlight skill. 13 | pub flashlight: f64, 14 | /// The ratio of the aim strain with and without considering sliders 15 | pub slider_factor: f64, 16 | /// The number of clickable objects weighted by difficulty. 17 | pub speed_note_count: f64, 18 | /// Weighted sum of aim strains. 19 | pub aim_difficult_strain_count: f64, 20 | /// Weighted sum of speed strains. 21 | pub speed_difficult_strain_count: f64, 22 | /// The approach rate. 23 | pub ar: f64, 24 | /// The great hit window. 25 | pub great_hit_window: f64, 26 | /// The ok hit window. 27 | pub ok_hit_window: f64, 28 | /// The meh hit window. 29 | pub meh_hit_window: f64, 30 | /// The health drain rate. 31 | pub hp: f64, 32 | /// The amount of circles. 33 | pub n_circles: u32, 34 | /// The amount of sliders. 35 | pub n_sliders: u32, 36 | /// The amount of "large ticks". 37 | /// 38 | /// The meaning depends on the kind of score: 39 | /// - if set on osu!stable, this value is irrelevant 40 | /// - if set on osu!lazer *with* slider accuracy, this value is the amount 41 | /// of hit slider ticks and repeats 42 | /// - if set on osu!lazer *without* slider accuracy, this value is the 43 | /// amount of hit slider heads, ticks, and repeats 44 | pub n_large_ticks: u32, 45 | /// The amount of spinners. 46 | pub n_spinners: u32, 47 | /// The final star rating 48 | pub stars: f64, 49 | /// The maximum combo. 50 | pub max_combo: u32, 51 | } 52 | 53 | impl OsuDifficultyAttributes { 54 | /// Return the maximum combo. 55 | pub const fn max_combo(&self) -> u32 { 56 | self.max_combo 57 | } 58 | 59 | /// Return the amount of hitobjects. 60 | pub const fn n_objects(&self) -> u32 { 61 | self.n_circles + self.n_sliders + self.n_spinners 62 | } 63 | 64 | /// The overall difficulty 65 | pub const fn od(&self) -> f64 { 66 | BeatmapAttributesBuilder::osu_great_hit_window_to_od(self.great_hit_window) 67 | } 68 | 69 | /// Returns a builder for performance calculation. 70 | pub fn performance<'a>(self) -> OsuPerformance<'a> { 71 | self.into() 72 | } 73 | } 74 | 75 | /// The result of a performance calculation on an osu!standard map. 76 | #[derive(Clone, Debug, Default, PartialEq)] 77 | pub struct OsuPerformanceAttributes { 78 | /// The difficulty attributes that were used for the performance calculation 79 | pub difficulty: OsuDifficultyAttributes, 80 | /// The final performance points. 81 | pub pp: f64, 82 | /// The accuracy portion of the final pp. 83 | pub pp_acc: f64, 84 | /// The aim portion of the final pp. 85 | pub pp_aim: f64, 86 | /// The flashlight portion of the final pp. 87 | pub pp_flashlight: f64, 88 | /// The speed portion of the final pp. 89 | pub pp_speed: f64, 90 | /// Misses including an approximated amount of slider breaks 91 | pub effective_miss_count: f64, 92 | /// Approximated unstable-rate 93 | pub speed_deviation: Option, 94 | } 95 | 96 | impl OsuPerformanceAttributes { 97 | /// Return the star value. 98 | pub const fn stars(&self) -> f64 { 99 | self.difficulty.stars 100 | } 101 | 102 | /// Return the performance point value. 103 | pub const fn pp(&self) -> f64 { 104 | self.pp 105 | } 106 | 107 | /// Return the maximum combo of the map. 108 | pub const fn max_combo(&self) -> u32 { 109 | self.difficulty.max_combo 110 | } 111 | /// Return the amount of hitobjects. 112 | pub const fn n_objects(&self) -> u32 { 113 | self.difficulty.n_objects() 114 | } 115 | 116 | /// Returns a builder for performance calculation. 117 | pub fn performance<'a>(self) -> OsuPerformance<'a> { 118 | self.difficulty.into() 119 | } 120 | } 121 | 122 | impl From for OsuDifficultyAttributes { 123 | fn from(attributes: OsuPerformanceAttributes) -> Self { 124 | attributes.difficulty 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/osu/difficulty/scaling_factor.rs: -------------------------------------------------------------------------------- 1 | use rosu_map::util::Pos; 2 | 3 | use crate::osu::object::OsuObject; 4 | 5 | use super::object::OsuDifficultyObject; 6 | 7 | const BROKEN_GAMEFIELD_ROUNDING_ALLOWANCE: f32 = 1.00041; 8 | 9 | /// Fields around the scaling of hit objects. 10 | /// 11 | /// osu!lazer stores these in each hit object but since all objects share the 12 | /// same scaling (w.r.t. difficulty & performance), we store them only once. 13 | pub struct ScalingFactor { 14 | /// `NORMALIZED_RADIUS / Radius` and then adjusted if `Radius < 30` 15 | pub factor: f32, 16 | pub radius: f64, 17 | pub scale: f32, 18 | } 19 | 20 | impl ScalingFactor { 21 | pub fn new(cs: f64) -> Self { 22 | let scale = (f64::from(1.0_f32) - f64::from(0.7_f32) * ((cs - 5.0) / 5.0)) as f32 / 2.0 23 | * BROKEN_GAMEFIELD_ROUNDING_ALLOWANCE; 24 | 25 | let radius = f64::from(OsuObject::OBJECT_RADIUS * scale); 26 | let factor = OsuDifficultyObject::NORMALIZED_RADIUS as f32 / radius as f32; 27 | 28 | let factor_with_small_circle_bonus = if radius < 30.0 { 29 | factor * (1.0 + (30.0 - radius as f32).min(5.0) / 50.0) 30 | } else { 31 | factor 32 | }; 33 | 34 | Self { 35 | factor: factor_with_small_circle_bonus, 36 | radius, 37 | scale, 38 | } 39 | } 40 | 41 | pub fn stack_offset(&self, stack_height: i32) -> Pos { 42 | let stack_offset = stack_height as f32 * self.scale * -6.4; 43 | 44 | Pos::new(stack_offset, stack_offset) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/osu/difficulty/skills/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | any::difficulty::skills::StrainSkill, 3 | model::{beatmap::BeatmapAttributes, mods::GameMods}, 4 | osu::object::OsuObject, 5 | }; 6 | 7 | use self::{aim::Aim, flashlight::Flashlight, speed::Speed}; 8 | 9 | use super::{ 10 | object::OsuDifficultyObject, scaling_factor::ScalingFactor, HD_FADE_IN_DURATION_MULTIPLIER, 11 | }; 12 | 13 | pub mod aim; 14 | pub mod flashlight; 15 | pub mod speed; 16 | pub mod strain; 17 | 18 | pub struct OsuSkills { 19 | pub aim: Aim, 20 | pub aim_no_sliders: Aim, 21 | pub speed: Speed, 22 | pub flashlight: Flashlight, 23 | } 24 | 25 | impl OsuSkills { 26 | pub fn new( 27 | mods: &GameMods, 28 | scaling_factor: &ScalingFactor, 29 | map_attrs: &BeatmapAttributes, 30 | time_preempt: f64, 31 | ) -> Self { 32 | let hit_window = 2.0 * map_attrs.hit_windows.od_great; 33 | 34 | // * Preempt time can go below 450ms. Normally, this is achieved via the DT mod 35 | // * which uniformly speeds up all animations game wide regardless of AR. 36 | // * This uniform speedup is hard to match 1:1, however we can at least make 37 | // * AR>10 (via mods) feel good by extending the upper linear function above. 38 | // * Note that this doesn't exactly match the AR>10 visuals as they're 39 | // * classically known, but it feels good. 40 | // * This adjustment is necessary for AR>10, otherwise TimePreempt can 41 | // * become smaller leading to hitcircles not fully fading in. 42 | let time_fade_in = if mods.hd() { 43 | time_preempt * HD_FADE_IN_DURATION_MULTIPLIER 44 | } else { 45 | 400.0 * (time_preempt / OsuObject::PREEMPT_MIN).min(1.0) 46 | }; 47 | 48 | let aim = Aim::new(true); 49 | let aim_no_sliders = Aim::new(false); 50 | let speed = Speed::new(hit_window, mods.ap()); 51 | let flashlight = Flashlight::new(mods, scaling_factor.radius, time_preempt, time_fade_in); 52 | 53 | Self { 54 | aim, 55 | aim_no_sliders, 56 | speed, 57 | flashlight, 58 | } 59 | } 60 | 61 | pub fn process(&mut self, curr: &OsuDifficultyObject<'_>, objects: &[OsuDifficultyObject<'_>]) { 62 | self.aim.process(curr, objects); 63 | self.aim_no_sliders.process(curr, objects); 64 | self.speed.process(curr, objects); 65 | self.flashlight.process(curr, objects); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/osu/difficulty/skills/strain.rs: -------------------------------------------------------------------------------- 1 | use crate::util::strains_vec::StrainsVec; 2 | 3 | pub trait OsuStrainSkill { 4 | const REDUCED_SECTION_COUNT: usize = 10; 5 | const REDUCED_STRAIN_BASELINE: f64 = 0.75; 6 | 7 | fn difficulty_to_performance(difficulty: f64) -> f64 { 8 | difficulty_to_performance(difficulty) 9 | } 10 | } 11 | 12 | pub fn difficulty_value( 13 | current_strain_peaks: StrainsVec, 14 | reduced_section_count: usize, 15 | reduced_strain_baseline: f64, 16 | decay_weight: f64, 17 | ) -> f64 { 18 | let mut difficulty = 0.0; 19 | let mut weight = 1.0; 20 | 21 | let mut peaks = current_strain_peaks; 22 | 23 | // Note that we remove all initial zeros here. 24 | let peaks_iter = peaks.sorted_non_zero_iter_mut().take(reduced_section_count); 25 | 26 | for (i, strain) in peaks_iter.enumerate() { 27 | // Note that unless `reduced_strain_baseline == 0.0`, `strain` can 28 | // never be `0.0`. 29 | let clamped = f64::from((i as f32 / reduced_section_count as f32).clamp(0.0, 1.0)); 30 | let scale = f64::log10(lerp(1.0, 10.0, clamped)); 31 | *strain *= lerp(reduced_strain_baseline, 1.0, scale); 32 | } 33 | 34 | peaks.sort_desc(); 35 | 36 | // Sanity assert; will most definitely never panic 37 | debug_assert!(reduced_strain_baseline != 0.0); 38 | 39 | // SAFETY: As noted, zeros were removed from all initial strains and no 40 | // strain was mutated to a zero afterwards. 41 | let peaks = unsafe { peaks.transmute_into_vec() }; 42 | 43 | // Using `Vec` is much faster for iteration than `StrainsVec` 44 | 45 | for strain in peaks { 46 | difficulty += strain * weight; 47 | weight *= decay_weight; 48 | } 49 | 50 | difficulty 51 | } 52 | 53 | pub fn difficulty_to_performance(difficulty: f64) -> f64 { 54 | f64::powf(5.0 * f64::max(1.0, difficulty / 0.0675) - 4.0, 3.0) / 100_000.0 55 | } 56 | 57 | const fn lerp(start: f64, end: f64, amount: f64) -> f64 { 58 | start + (end - start) * amount 59 | } 60 | -------------------------------------------------------------------------------- /src/osu/mod.rs: -------------------------------------------------------------------------------- 1 | use rosu_map::util::Pos; 2 | 3 | use crate::{ 4 | model::{ 5 | beatmap::Beatmap, 6 | mode::{ConvertError, IGameMode}, 7 | }, 8 | Difficulty, 9 | }; 10 | 11 | pub use self::{ 12 | attributes::{OsuDifficultyAttributes, OsuPerformanceAttributes}, 13 | difficulty::gradual::OsuGradualDifficulty, 14 | performance::{gradual::OsuGradualPerformance, OsuPerformance}, 15 | score_state::{OsuScoreOrigin, OsuScoreState}, 16 | strains::OsuStrains, 17 | }; 18 | 19 | mod attributes; 20 | mod convert; 21 | mod difficulty; 22 | mod object; 23 | mod performance; 24 | mod score_state; 25 | mod strains; 26 | 27 | const PLAYFIELD_BASE_SIZE: Pos = Pos::new(512.0, 384.0); 28 | 29 | /// Marker type for [`GameMode::Osu`]. 30 | /// 31 | /// [`GameMode::Osu`]: rosu_map::section::general::GameMode::Osu 32 | pub struct Osu; 33 | 34 | impl IGameMode for Osu { 35 | type DifficultyAttributes = OsuDifficultyAttributes; 36 | type Strains = OsuStrains; 37 | type Performance<'map> = OsuPerformance<'map>; 38 | type GradualDifficulty = OsuGradualDifficulty; 39 | type GradualPerformance = OsuGradualPerformance; 40 | 41 | fn difficulty( 42 | difficulty: &Difficulty, 43 | map: &Beatmap, 44 | ) -> Result { 45 | difficulty::difficulty(difficulty, map) 46 | } 47 | 48 | fn strains(difficulty: &Difficulty, map: &Beatmap) -> Result { 49 | strains::strains(difficulty, map) 50 | } 51 | 52 | fn performance(map: &Beatmap) -> Self::Performance<'_> { 53 | OsuPerformance::new(map) 54 | } 55 | 56 | fn gradual_difficulty( 57 | difficulty: Difficulty, 58 | map: &Beatmap, 59 | ) -> Result { 60 | OsuGradualDifficulty::new(difficulty, map) 61 | } 62 | 63 | fn gradual_performance( 64 | difficulty: Difficulty, 65 | map: &Beatmap, 66 | ) -> Result { 67 | OsuGradualPerformance::new(difficulty, map) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/osu/score_state.rs: -------------------------------------------------------------------------------- 1 | use crate::util::{float_ext::FloatExt, hint::unlikely}; 2 | 3 | /// Aggregation for a score's current state. 4 | #[derive(Clone, Debug, PartialEq, Eq)] 5 | pub struct OsuScoreState { 6 | /// Maximum combo that the score has had so far. **Not** the maximum 7 | /// possible combo of the map so far. 8 | pub max_combo: u32, 9 | /// "Large tick" hits. 10 | /// 11 | /// The meaning depends on the kind of score: 12 | /// - if set on osu!stable, this field is irrelevant and can be `0` 13 | /// - if set on osu!lazer *with* slider accuracy, this field is the amount 14 | /// of hit slider ticks and repeats 15 | /// - if set on osu!lazer *without* slider accuracy, this field is the 16 | /// amount of hit slider heads, ticks, and repeats 17 | /// 18 | /// Only relevant for osu!lazer. 19 | pub large_tick_hits: u32, 20 | /// "Small ticks" hits. 21 | /// 22 | /// These are essentially the slider end hits for lazer scores without 23 | /// slider accuracy. 24 | /// 25 | /// Only relevant for osu!lazer. 26 | pub small_tick_hits: u32, 27 | /// Amount of successfully hit slider ends. 28 | /// 29 | /// Only relevant for osu!lazer. 30 | pub slider_end_hits: u32, 31 | /// Amount of current 300s. 32 | pub n300: u32, 33 | /// Amount of current 100s. 34 | pub n100: u32, 35 | /// Amount of current 50s. 36 | pub n50: u32, 37 | /// Amount of current misses. 38 | pub misses: u32, 39 | } 40 | 41 | impl OsuScoreState { 42 | /// Create a new empty score state. 43 | pub const fn new() -> Self { 44 | Self { 45 | max_combo: 0, 46 | large_tick_hits: 0, 47 | small_tick_hits: 0, 48 | slider_end_hits: 0, 49 | n300: 0, 50 | n100: 0, 51 | n50: 0, 52 | misses: 0, 53 | } 54 | } 55 | 56 | /// Return the total amount of hits by adding everything up. 57 | pub const fn total_hits(&self) -> u32 { 58 | self.n300 + self.n100 + self.n50 + self.misses 59 | } 60 | 61 | /// Calculate the accuracy between `0.0` and `1.0` for this state. 62 | pub fn accuracy(&self, origin: OsuScoreOrigin) -> f64 { 63 | let mut numerator = f64::from(6 * self.n300 + 2 * self.n100 + self.n50); 64 | let mut denominator = f64::from(6 * (self.n300 + self.n100 + self.n50 + self.misses)); 65 | 66 | match origin { 67 | OsuScoreOrigin::Stable => {} 68 | OsuScoreOrigin::WithSliderAcc { 69 | max_large_ticks, 70 | max_slider_ends, 71 | } => { 72 | let slider_end_hits = self.slider_end_hits.min(max_slider_ends); 73 | let large_tick_hits = self.large_tick_hits.min(max_large_ticks); 74 | 75 | numerator += f64::from(3 * slider_end_hits) + 0.6 * f64::from(large_tick_hits); 76 | denominator += f64::from(3 * max_slider_ends) + 0.6 * f64::from(max_large_ticks); 77 | } 78 | OsuScoreOrigin::WithoutSliderAcc { 79 | max_large_ticks, 80 | max_small_ticks, 81 | } => { 82 | let large_tick_hits = self.large_tick_hits.min(max_large_ticks); 83 | let small_tick_hits = self.small_tick_hits.min(max_small_ticks); 84 | 85 | numerator += 0.6 * f64::from(large_tick_hits) + 0.2 * f64::from(small_tick_hits); 86 | denominator += 0.6 * f64::from(max_large_ticks) + 0.2 * f64::from(max_small_ticks); 87 | } 88 | } 89 | 90 | if unlikely(denominator.eq(0.0)) { 91 | 0.0 92 | } else { 93 | numerator / denominator 94 | } 95 | } 96 | } 97 | 98 | impl Default for OsuScoreState { 99 | fn default() -> Self { 100 | Self::new() 101 | } 102 | } 103 | 104 | /// Type to pass [`OsuScoreState::accuracy`] and specify the origin of a score. 105 | #[derive(Copy, Clone, Debug, PartialEq, Eq)] 106 | pub enum OsuScoreOrigin { 107 | /// For scores set on osu!stable 108 | Stable, 109 | /// For scores set on osu!lazer with slider accuracy 110 | WithSliderAcc { 111 | max_large_ticks: u32, 112 | max_slider_ends: u32, 113 | }, 114 | /// For scores set on osu!lazer without slider accuracy 115 | WithoutSliderAcc { 116 | max_large_ticks: u32, 117 | max_small_ticks: u32, 118 | }, 119 | } 120 | -------------------------------------------------------------------------------- /src/osu/strains.rs: -------------------------------------------------------------------------------- 1 | use rosu_map::section::general::GameMode; 2 | 3 | use crate::{any::difficulty::skills::StrainSkill, model::mode::ConvertError, Beatmap, Difficulty}; 4 | 5 | use super::difficulty::{skills::OsuSkills, DifficultyValues}; 6 | 7 | /// The result of calculating the strains on a osu! map. 8 | /// 9 | /// Suitable to plot the difficulty of a map over time. 10 | #[derive(Clone, Debug, PartialEq)] 11 | pub struct OsuStrains { 12 | /// Strain peaks of the aim skill. 13 | pub aim: Vec, 14 | /// Strain peaks of the aim skill without sliders. 15 | pub aim_no_sliders: Vec, 16 | /// Strain peaks of the speed skill. 17 | pub speed: Vec, 18 | /// Strain peaks of the flashlight skill. 19 | pub flashlight: Vec, 20 | } 21 | 22 | impl OsuStrains { 23 | /// Time between two strains in ms. 24 | pub const SECTION_LEN: f64 = 400.0; 25 | } 26 | 27 | pub fn strains(difficulty: &Difficulty, map: &Beatmap) -> Result { 28 | let map = map.convert_ref(GameMode::Osu, difficulty.get_mods())?; 29 | 30 | let DifficultyValues { 31 | skills: 32 | OsuSkills { 33 | aim, 34 | aim_no_sliders, 35 | speed, 36 | flashlight, 37 | }, 38 | attrs: _, 39 | } = DifficultyValues::calculate(difficulty, &map); 40 | 41 | Ok(OsuStrains { 42 | aim: aim.into_current_strain_peaks().into_vec(), 43 | aim_no_sliders: aim_no_sliders.into_current_strain_peaks().into_vec(), 44 | speed: speed.into_current_strain_peaks().into_vec(), 45 | flashlight: flashlight.into_current_strain_peaks().into_vec(), 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /src/taiko/attributes.rs: -------------------------------------------------------------------------------- 1 | use crate::taiko::performance::TaikoPerformance; 2 | 3 | /// The result of a difficulty calculation on an osu!taiko map. 4 | #[derive(Clone, Debug, Default, PartialEq)] 5 | pub struct TaikoDifficultyAttributes { 6 | /// The difficulty of the stamina skill. 7 | pub stamina: f64, 8 | /// The difficulty of the rhythm skill. 9 | pub rhythm: f64, 10 | /// The difficulty of the color skill. 11 | pub color: f64, 12 | /// The difficulty of the reading skill. 13 | pub reading: f64, 14 | /// The perceived hit window for an n300 inclusive of rate-adjusting mods (DT/HT/etc) 15 | pub great_hit_window: f64, 16 | /// The perceived hit window for an n100 inclusive of rate-adjusting mods (DT/HT/etc) 17 | pub ok_hit_window: f64, 18 | /// The ratio of stamina difficulty from mono-color (single color) streams to total 19 | /// stamina difficulty. 20 | pub mono_stamina_factor: f64, 21 | /// The final star rating. 22 | pub stars: f64, 23 | /// The maximum combo. 24 | pub max_combo: u32, 25 | /// Whether the [`Beatmap`] was a convert i.e. an osu!standard map. 26 | /// 27 | /// [`Beatmap`]: crate::model::beatmap::Beatmap 28 | pub is_convert: bool, 29 | } 30 | 31 | impl TaikoDifficultyAttributes { 32 | /// Return the maximum combo. 33 | pub const fn max_combo(&self) -> u32 { 34 | self.max_combo 35 | } 36 | 37 | /// Whether the [`Beatmap`] was a convert i.e. an osu!standard map. 38 | /// 39 | /// [`Beatmap`]: crate::model::beatmap::Beatmap 40 | pub const fn is_convert(&self) -> bool { 41 | self.is_convert 42 | } 43 | 44 | /// Returns a builder for performance calculation. 45 | pub fn performance<'a>(self) -> TaikoPerformance<'a> { 46 | self.into() 47 | } 48 | } 49 | 50 | /// The result of a performance calculation on an osu!taiko map. 51 | #[derive(Clone, Debug, Default, PartialEq)] 52 | pub struct TaikoPerformanceAttributes { 53 | /// The difficulty attributes that were used for the performance calculation 54 | pub difficulty: TaikoDifficultyAttributes, 55 | /// The final performance points. 56 | pub pp: f64, 57 | /// The accuracy portion of the final pp. 58 | pub pp_acc: f64, 59 | /// The strain portion of the final pp. 60 | pub pp_difficulty: f64, 61 | /// Scaled miss count based on total hits. 62 | pub effective_miss_count: f64, 63 | /// Upper bound on the player's tap deviation. 64 | pub estimated_unstable_rate: Option, 65 | } 66 | 67 | impl TaikoPerformanceAttributes { 68 | /// Return the star value. 69 | pub const fn stars(&self) -> f64 { 70 | self.difficulty.stars 71 | } 72 | 73 | /// Return the performance point value. 74 | pub const fn pp(&self) -> f64 { 75 | self.pp 76 | } 77 | 78 | /// Return the maximum combo of the map. 79 | pub const fn max_combo(&self) -> u32 { 80 | self.difficulty.max_combo 81 | } 82 | 83 | /// Whether the [`Beatmap`] was a convert i.e. an osu!standard map. 84 | /// 85 | /// [`Beatmap`]: crate::model::beatmap::Beatmap 86 | pub const fn is_convert(&self) -> bool { 87 | self.difficulty.is_convert 88 | } 89 | 90 | /// Returns a builder for performance calculation. 91 | pub fn performance<'a>(self) -> TaikoPerformance<'a> { 92 | self.difficulty.into() 93 | } 94 | } 95 | 96 | impl From for TaikoDifficultyAttributes { 97 | fn from(attributes: TaikoPerformanceAttributes) -> Self { 98 | attributes.difficulty 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/taiko/difficulty/color/color_data.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | taiko::difficulty::object::{TaikoDifficultyObject, TaikoDifficultyObjects}, 3 | util::sync::{RefCount, Weak}, 4 | }; 5 | 6 | use super::data::{ 7 | alternating_mono_pattern::AlternatingMonoPattern, mono_streak::MonoStreak, 8 | repeating_hit_patterns::RepeatingHitPatterns, 9 | }; 10 | 11 | #[derive(Debug, Default)] 12 | pub struct ColorData { 13 | pub mono_streak: Option>, 14 | pub alternating_mono_pattern: Option>, 15 | pub repeating_hit_patterns: Option>, 16 | } 17 | 18 | impl ColorData { 19 | pub fn previous_color_change<'a>( 20 | &self, 21 | hit_objects: &'a TaikoDifficultyObjects, 22 | ) -> Option<&'a RefCount> { 23 | self.mono_streak 24 | .as_ref() 25 | .and_then(Weak::upgrade) 26 | .and_then(|mono| mono.get().first_hit_object()) 27 | .and_then(|h| hit_objects.previous_note(&h.get(), 0)) 28 | } 29 | 30 | pub fn next_color_change<'a>( 31 | &self, 32 | hit_objects: &'a TaikoDifficultyObjects, 33 | ) -> Option<&'a RefCount> { 34 | self.mono_streak 35 | .as_ref() 36 | .and_then(Weak::upgrade) 37 | .and_then(|mono| mono.get().last_hit_object()) 38 | .and_then(|h| hit_objects.next_note(&h.get(), 0)) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/taiko/difficulty/color/data/alternating_mono_pattern.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | taiko::difficulty::object::TaikoDifficultyObject, 3 | util::sync::{RefCount, Weak}, 4 | }; 5 | 6 | use super::{mono_streak::MonoStreak, repeating_hit_patterns::RepeatingHitPatterns}; 7 | 8 | #[derive(Debug)] 9 | pub struct AlternatingMonoPattern { 10 | pub mono_streaks: Vec>, 11 | pub parent: Option>, 12 | pub idx: usize, 13 | } 14 | 15 | impl AlternatingMonoPattern { 16 | pub fn new() -> RefCount { 17 | RefCount::new(Self { 18 | mono_streaks: Vec::new(), 19 | parent: None, 20 | idx: 0, 21 | }) 22 | } 23 | 24 | pub fn is_repetition_of(&self, other: &Self) -> bool { 25 | self.has_identical_mono_len(other) 26 | && self.mono_streaks.len() == other.mono_streaks.len() 27 | && self.mono_streaks[0].get().hit_type() == other.mono_streaks[0].get().hit_type() 28 | } 29 | 30 | pub fn has_identical_mono_len(&self, other: &Self) -> bool { 31 | self.mono_streaks[0].get().run_len() == other.mono_streaks[0].get().run_len() 32 | } 33 | 34 | pub fn first_hit_object(&self) -> Option> { 35 | self.mono_streaks 36 | .first() 37 | .and_then(|mono| mono.get().first_hit_object()) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/taiko/difficulty/color/data/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod alternating_mono_pattern; 2 | pub mod mono_streak; 3 | pub mod repeating_hit_patterns; 4 | -------------------------------------------------------------------------------- /src/taiko/difficulty/color/data/mono_streak.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | taiko::{difficulty::object::TaikoDifficultyObject, object::HitType}, 3 | util::sync::{RefCount, Weak}, 4 | }; 5 | 6 | use super::alternating_mono_pattern::AlternatingMonoPattern; 7 | 8 | #[derive(Debug)] 9 | pub struct MonoStreak { 10 | pub hit_objects: Vec>, 11 | pub parent: Option>, 12 | pub idx: usize, 13 | } 14 | 15 | impl MonoStreak { 16 | pub fn new() -> RefCount { 17 | RefCount::new(Self { 18 | hit_objects: Vec::new(), 19 | parent: None, 20 | idx: 0, 21 | }) 22 | } 23 | 24 | pub fn run_len(&self) -> usize { 25 | self.hit_objects.len() 26 | } 27 | 28 | pub fn hit_type(&self) -> Option { 29 | self.hit_objects 30 | .first() 31 | .and_then(Weak::upgrade) 32 | .map(|h| h.get().base_hit_type) 33 | } 34 | 35 | pub fn first_hit_object(&self) -> Option> { 36 | self.hit_objects.first().and_then(Weak::upgrade) 37 | } 38 | 39 | pub fn last_hit_object(&self) -> Option> { 40 | self.hit_objects.last().and_then(Weak::upgrade) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/taiko/difficulty/color/data/repeating_hit_patterns.rs: -------------------------------------------------------------------------------- 1 | use std::cmp; 2 | 3 | use crate::{ 4 | taiko::difficulty::object::TaikoDifficultyObject, 5 | util::sync::{RefCount, Weak}, 6 | }; 7 | 8 | use super::alternating_mono_pattern::AlternatingMonoPattern; 9 | 10 | const MAX_REPETITION_INTERVAL: usize = 16; 11 | 12 | #[derive(Debug)] 13 | pub struct RepeatingHitPatterns { 14 | pub alternating_mono_patterns: Vec>, 15 | pub prev: Option>, 16 | pub repetition_interval: usize, 17 | } 18 | 19 | impl RepeatingHitPatterns { 20 | pub fn new(prev: Option>) -> RefCount { 21 | RefCount::new(Self { 22 | alternating_mono_patterns: Vec::new(), 23 | prev, 24 | repetition_interval: 0, 25 | }) 26 | } 27 | 28 | pub fn find_repetition_interval(&mut self) { 29 | let Some(mut other) = self.prev.as_ref().and_then(Weak::upgrade) else { 30 | return self.repetition_interval = MAX_REPETITION_INTERVAL + 1; 31 | }; 32 | 33 | let mut interval = 1; 34 | 35 | while interval < MAX_REPETITION_INTERVAL { 36 | if self.is_repetition_of(&other.get()) { 37 | self.repetition_interval = cmp::min(interval, MAX_REPETITION_INTERVAL); 38 | 39 | return; 40 | } 41 | 42 | let Some(next) = other.get().prev.as_ref().and_then(Weak::upgrade) else { 43 | break; 44 | }; 45 | 46 | other = next; 47 | interval += 1; 48 | } 49 | 50 | self.repetition_interval = MAX_REPETITION_INTERVAL + 1; 51 | } 52 | 53 | fn is_repetition_of(&self, other: &Self) -> bool { 54 | if self.alternating_mono_patterns.len() != other.alternating_mono_patterns.len() { 55 | return false; 56 | } 57 | 58 | self.alternating_mono_patterns 59 | .iter() 60 | .zip(other.alternating_mono_patterns.iter()) 61 | .take(2) 62 | .all(|(self_pat, other_pat)| self_pat.get().has_identical_mono_len(&other_pat.get())) 63 | } 64 | 65 | pub fn first_hit_object(&self) -> Option> { 66 | self.alternating_mono_patterns 67 | .first() 68 | .and_then(|mono| mono.get().first_hit_object()) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/taiko/difficulty/color/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod color_data; 2 | pub mod data; 3 | pub mod preprocessor; 4 | -------------------------------------------------------------------------------- /src/taiko/difficulty/rhythm/data/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod same_patterns_grouped_hit_objects; 2 | pub mod same_rhythm_hit_object_grouping; 3 | -------------------------------------------------------------------------------- /src/taiko/difficulty/rhythm/data/same_patterns_grouped_hit_objects.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | taiko::difficulty::object::TaikoDifficultyObject, 3 | util::sync::{RefCount, Weak}, 4 | }; 5 | 6 | use super::same_rhythm_hit_object_grouping::SameRhythmHitObjectGrouping; 7 | 8 | #[derive(Debug)] 9 | pub struct SamePatternsGroupedHitObjects { 10 | pub groups: Vec>, 11 | pub previous: Option>, 12 | } 13 | 14 | impl SamePatternsGroupedHitObjects { 15 | pub const fn new( 16 | previous: Option>, 17 | groups: Vec>, 18 | ) -> Self { 19 | Self { groups, previous } 20 | } 21 | 22 | pub fn group_interval(&self) -> Option { 23 | self.groups 24 | .get(1) 25 | .unwrap_or(&self.groups[0]) 26 | .upgrade() 27 | .map(|grouped| grouped.get().interval) 28 | } 29 | 30 | pub fn interval_ratio(&self) -> f64 { 31 | self.group_interval() 32 | .zip( 33 | self.previous 34 | .as_ref() 35 | .and_then(Weak::upgrade) 36 | .and_then(|prev| prev.get().group_interval()), 37 | ) 38 | .map_or(1.0, |(this, prev)| this / prev) 39 | } 40 | 41 | pub fn first_hit_object(&self) -> Option> { 42 | self.groups 43 | .first() 44 | .and_then(Weak::upgrade) 45 | .and_then(|group| group.get().first_hit_object()) 46 | } 47 | 48 | pub fn upgraded_groups( 49 | &self, 50 | ) -> impl Iterator> + use<'_> { 51 | self.groups.iter().filter_map(Weak::upgrade) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/taiko/difficulty/rhythm/data/same_rhythm_hit_object_grouping.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | taiko::difficulty::object::TaikoDifficultyObject, 3 | util::{ 4 | interval_grouping::HasInterval, 5 | sync::{RefCount, Weak}, 6 | }, 7 | }; 8 | 9 | #[derive(Debug)] 10 | pub struct SameRhythmHitObjectGrouping { 11 | pub hit_objects: Vec>, 12 | /// Use [`Self::upgraded_previous`] to access 13 | previous: Option>, 14 | pub hit_object_interval: Option, 15 | pub hit_object_interval_ratio: f64, 16 | pub interval: f64, 17 | } 18 | 19 | impl SameRhythmHitObjectGrouping { 20 | pub fn new( 21 | previous: Option>, 22 | hit_objects: Vec>, 23 | ) -> Self { 24 | // * Calculate the average interval between hitobjects, or null if there are fewer than two 25 | let hit_object_interval = if hit_objects.len() < 2 { 26 | None 27 | } else { 28 | duration(&hit_objects).map(|duration| duration / (hit_objects.len() - 1) as f64) 29 | }; 30 | 31 | let upgraded_prev = upgraded_previous(previous.as_ref()); 32 | 33 | // * Calculate the ratio between this group's interval and the previous group's interval 34 | let hit_object_interval_ratio = if let Some((prev, curr)) = upgraded_prev 35 | .as_ref() 36 | .and_then(|prev| prev.get().hit_object_interval) 37 | .zip(hit_object_interval) 38 | { 39 | curr / prev 40 | } else { 41 | 1.0 42 | }; 43 | 44 | // * Calculate the interval from the previous group's start time 45 | let interval = upgraded_prev 46 | .as_ref() 47 | .and_then(|prev| prev.get().start_time()) 48 | .zip(start_time(&hit_objects)) 49 | .map_or(f64::INFINITY, |(prev, curr)| curr - prev); 50 | 51 | Self { 52 | hit_objects, 53 | previous, 54 | hit_object_interval, 55 | hit_object_interval_ratio, 56 | interval, 57 | } 58 | } 59 | 60 | pub fn upgraded_previous(&self) -> Option> { 61 | upgraded_previous(self.previous.as_ref()) 62 | } 63 | 64 | pub fn first_hit_object(&self) -> Option> { 65 | first_hit_object(&self.hit_objects) 66 | } 67 | 68 | pub fn start_time(&self) -> Option { 69 | start_time(&self.hit_objects) 70 | } 71 | 72 | pub fn duration(&self) -> Option { 73 | duration(&self.hit_objects) 74 | } 75 | 76 | pub fn upgraded_hit_objects( 77 | &self, 78 | ) -> impl Iterator> + use<'_> { 79 | self.hit_objects.iter().filter_map(Weak::upgrade) 80 | } 81 | } 82 | 83 | fn upgraded_previous( 84 | previous: Option<&Weak>, 85 | ) -> Option> { 86 | previous.and_then(Weak::upgrade) 87 | } 88 | 89 | fn first_hit_object( 90 | hit_objects: &[Weak], 91 | ) -> Option> { 92 | hit_objects.first().and_then(Weak::upgrade) 93 | } 94 | 95 | fn start_time(hit_objects: &[Weak]) -> Option { 96 | first_hit_object(hit_objects).map(|h| h.get().start_time) 97 | } 98 | 99 | fn duration(hit_objects: &[Weak]) -> Option { 100 | hit_objects 101 | .last() 102 | .and_then(Weak::upgrade) 103 | .zip(start_time(hit_objects)) 104 | .map(|(last, start)| last.get().start_time - start) 105 | } 106 | 107 | impl HasInterval for SameRhythmHitObjectGrouping { 108 | fn interval(&self) -> f64 { 109 | self.interval 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/taiko/difficulty/rhythm/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod data; 2 | pub mod preprocessor; 3 | pub mod rhythm_data; 4 | -------------------------------------------------------------------------------- /src/taiko/difficulty/rhythm/preprocessor.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | taiko::difficulty::object::{TaikoDifficultyObject, TaikoDifficultyObjects}, 3 | util::{interval_grouping::group_by_interval, sync::RefCount}, 4 | }; 5 | 6 | use super::data::{ 7 | same_patterns_grouped_hit_objects::SamePatternsGroupedHitObjects, 8 | same_rhythm_hit_object_grouping::SameRhythmHitObjectGrouping, 9 | }; 10 | 11 | pub struct RhythmDifficultyPreprocessor; 12 | 13 | impl RhythmDifficultyPreprocessor { 14 | pub fn process_and_assign(hit_objects: &TaikoDifficultyObjects) { 15 | let rhythm_groups = create_same_rhythm_grouped_hit_objects(&hit_objects.note_objects); 16 | 17 | for rhythm_group in rhythm_groups.iter() { 18 | for hit_object in rhythm_group.get().hit_objects.iter() { 19 | if let Some(hit_object) = hit_object.upgrade() { 20 | hit_object 21 | .get_mut() 22 | .rhythm_data 23 | .same_rhythm_grouped_hit_objects = Some(RefCount::clone(rhythm_group)); 24 | } 25 | } 26 | } 27 | 28 | let pattern_groups = create_same_pattern_grouped_hit_objects(&rhythm_groups); 29 | 30 | for pattern_group in pattern_groups { 31 | for group in pattern_group.get().upgraded_groups() { 32 | for hit_object in group.get().upgraded_hit_objects() { 33 | hit_object 34 | .get_mut() 35 | .rhythm_data 36 | .same_patterns_grouped_hit_objects = Some(RefCount::clone(&pattern_group)); 37 | } 38 | } 39 | } 40 | } 41 | } 42 | 43 | fn create_same_rhythm_grouped_hit_objects( 44 | hit_objects: &[RefCount], 45 | ) -> Vec> { 46 | let mut rhythm_groups = Vec::new(); 47 | 48 | for grouped in group_by_interval(hit_objects) { 49 | rhythm_groups.push(RefCount::new(SameRhythmHitObjectGrouping::new( 50 | rhythm_groups.last().map(RefCount::downgrade), 51 | grouped, 52 | ))); 53 | } 54 | 55 | rhythm_groups 56 | } 57 | 58 | fn create_same_pattern_grouped_hit_objects( 59 | rhythm_groups: &[RefCount], 60 | ) -> impl Iterator> + use<'_> { 61 | group_by_interval(rhythm_groups).scan(None, |prev, grouped| { 62 | let curr = RefCount::new(SamePatternsGroupedHitObjects::new(prev.take(), grouped)); 63 | *prev = Some(curr.downgrade()); 64 | 65 | Some(curr) 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /src/taiko/difficulty/rhythm/rhythm_data.rs: -------------------------------------------------------------------------------- 1 | use crate::util::sync::RefCount; 2 | 3 | use super::data::{ 4 | same_patterns_grouped_hit_objects::SamePatternsGroupedHitObjects, 5 | same_rhythm_hit_object_grouping::SameRhythmHitObjectGrouping, 6 | }; 7 | 8 | #[derive(Debug)] 9 | pub struct RhythmData { 10 | pub same_rhythm_grouped_hit_objects: Option>, 11 | pub same_patterns_grouped_hit_objects: Option>, 12 | pub ratio: f64, 13 | } 14 | 15 | impl RhythmData { 16 | pub fn new(delta_time: f64, prev_delta_time: Option) -> Self { 17 | let Some(prev_delta_time) = prev_delta_time else { 18 | return Self { 19 | same_rhythm_grouped_hit_objects: None, 20 | same_patterns_grouped_hit_objects: None, 21 | ratio: 1.0, 22 | }; 23 | }; 24 | 25 | let actual_ratio = delta_time / prev_delta_time; 26 | 27 | let actual_diff = |r| f64::abs(r - actual_ratio); 28 | 29 | let closest_ratio = COMMON_RATIOS 30 | .iter() 31 | .min_by(|r1, r2| actual_diff(*r1).total_cmp(&actual_diff(*r2))) 32 | .unwrap(); 33 | 34 | Self { 35 | same_rhythm_grouped_hit_objects: None, 36 | same_patterns_grouped_hit_objects: None, 37 | ratio: *closest_ratio, 38 | } 39 | } 40 | } 41 | 42 | #[allow(clippy::eq_op, reason = "keeping it in-sync with lazer")] 43 | static COMMON_RATIOS: [f64; 9] = [ 44 | 1.0 / 1.0, 45 | 2.0 / 1.0, 46 | 1.0 / 2.0, 47 | 3.0 / 1.0, 48 | 1.0 / 3.0, 49 | 3.0 / 2.0, 50 | 2.0 / 3.0, 51 | 5.0 / 4.0, 52 | 4.0 / 5.0, 53 | ]; 54 | -------------------------------------------------------------------------------- /src/taiko/difficulty/skills/color.rs: -------------------------------------------------------------------------------- 1 | use std::f64::consts::E; 2 | 3 | use crate::{ 4 | taiko::difficulty::{ 5 | color::data::{ 6 | alternating_mono_pattern::AlternatingMonoPattern, mono_streak::MonoStreak, 7 | repeating_hit_patterns::RepeatingHitPatterns, 8 | }, 9 | object::{TaikoDifficultyObject, TaikoDifficultyObjects}, 10 | }, 11 | util::{ 12 | difficulty::logistic_exp, 13 | sync::{RefCount, Weak}, 14 | }, 15 | }; 16 | 17 | define_skill! { 18 | #[derive(Clone)] 19 | pub struct Color: StrainDecaySkill => TaikoDifficultyObjects[TaikoDifficultyObject] {} 20 | } 21 | 22 | impl Color { 23 | const SKILL_MULTIPLIER: f64 = 0.12; 24 | const STRAIN_DECAY_BASE: f64 = 0.8; 25 | 26 | #[allow(clippy::unused_self, reason = "required by `define_skill!` macro")] 27 | fn strain_value_of( 28 | &self, 29 | curr: &TaikoDifficultyObject, 30 | objects: &TaikoDifficultyObjects, 31 | ) -> f64 { 32 | ColorEvaluator::evaluate_difficulty_of(curr, objects) 33 | } 34 | } 35 | 36 | struct ColorEvaluator; 37 | 38 | impl ColorEvaluator { 39 | fn consistent_ratio_penalty( 40 | hit_object: &TaikoDifficultyObject, 41 | objects: &TaikoDifficultyObjects, 42 | threshold: Option, 43 | max_objects_to_check: Option, 44 | ) -> f64 { 45 | let threshold = threshold.unwrap_or(0.01); 46 | let max_objects_to_check = max_objects_to_check.unwrap_or(64); 47 | 48 | let curr = hit_object; 49 | 50 | let mut consistent_ratio_count = 0; 51 | let mut total_ratio_count = 0.0; 52 | 53 | let prev_objects = 54 | &objects.objects[curr.idx.saturating_sub(2 * max_objects_to_check)..=curr.idx]; 55 | 56 | for window in prev_objects.windows(3).rev().step_by(2) { 57 | let [prev, _, curr] = window else { 58 | unreachable!() 59 | }; 60 | 61 | let curr = curr.get(); 62 | let prev = prev.get(); 63 | 64 | let curr_ratio = curr.rhythm_data.ratio; 65 | let prev_ratio = prev.rhythm_data.ratio; 66 | 67 | // * A consistent interval is defined as the percentage difference between the two rhythmic ratios with the margin of error. 68 | if f64::abs(1.0 - curr_ratio / prev_ratio) <= threshold { 69 | consistent_ratio_count += 1; 70 | total_ratio_count += curr_ratio; 71 | 72 | break; 73 | } 74 | } 75 | 76 | // * Ensure no division by zero 77 | 1.0 - total_ratio_count / f64::from(consistent_ratio_count + 1) * 0.8 78 | } 79 | 80 | fn evaluate_difficulty_of( 81 | hit_object: &TaikoDifficultyObject, 82 | objects: &TaikoDifficultyObjects, 83 | ) -> f64 { 84 | let color_data = &hit_object.color_data; 85 | let mut difficulty = 0.0; 86 | 87 | if let Some(mono_streak) = color_data.mono_streak.as_ref().and_then(Weak::upgrade) { 88 | if let Some(first_hit_object) = mono_streak.get().first_hit_object() { 89 | if &*first_hit_object.get() == hit_object { 90 | difficulty += Self::eval_mono_streak_diff(&mono_streak); 91 | } 92 | } 93 | } 94 | 95 | if let Some(alternating_mono_pattern) = color_data 96 | .alternating_mono_pattern 97 | .as_ref() 98 | .and_then(Weak::upgrade) 99 | { 100 | if let Some(first_hit_object) = alternating_mono_pattern.get().first_hit_object() { 101 | if &*first_hit_object.get() == hit_object { 102 | difficulty += 103 | Self::eval_alternating_mono_pattern_diff(&alternating_mono_pattern); 104 | } 105 | } 106 | } 107 | 108 | if let Some(repeating_hit_patterns) = color_data.repeating_hit_patterns.as_ref() { 109 | if let Some(first_hit_object) = repeating_hit_patterns.get().first_hit_object() { 110 | if &*first_hit_object.get() == hit_object { 111 | difficulty += Self::eval_repeating_hit_patterns_diff(repeating_hit_patterns); 112 | } 113 | } 114 | } 115 | 116 | let consistency_penalty = Self::consistent_ratio_penalty(hit_object, objects, None, None); 117 | difficulty *= consistency_penalty; 118 | 119 | difficulty 120 | } 121 | 122 | fn eval_mono_streak_diff(mono_streak: &RefCount) -> f64 { 123 | let mono_streak = mono_streak.get(); 124 | 125 | let parent_eval = mono_streak 126 | .parent 127 | .as_ref() 128 | .and_then(Weak::upgrade) 129 | .as_ref() 130 | .map_or(1.0, Self::eval_alternating_mono_pattern_diff); 131 | 132 | logistic_exp(E * mono_streak.idx as f64 - 2.0 * E, None) * parent_eval * 0.5 133 | } 134 | 135 | fn eval_alternating_mono_pattern_diff( 136 | alternating_mono_pattern: &RefCount, 137 | ) -> f64 { 138 | let alternating_mono_pattern = alternating_mono_pattern.get(); 139 | 140 | let parent_eval = alternating_mono_pattern 141 | .parent 142 | .as_ref() 143 | .and_then(Weak::upgrade) 144 | .as_ref() 145 | .map_or(1.0, Self::eval_repeating_hit_patterns_diff); 146 | 147 | logistic_exp(E * alternating_mono_pattern.idx as f64 - 2.0 * E, None) * parent_eval 148 | } 149 | 150 | fn eval_repeating_hit_patterns_diff( 151 | repeating_hit_patterns: &RefCount, 152 | ) -> f64 { 153 | let repetition_interval = repeating_hit_patterns.get().repetition_interval as f64; 154 | 155 | 2.0 * (1.0 - logistic_exp(E * repetition_interval - 2.0 * E, None)) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/taiko/difficulty/skills/mod.rs: -------------------------------------------------------------------------------- 1 | use reading::Reading; 2 | 3 | use self::{color::Color, rhythm::Rhythm, stamina::Stamina}; 4 | 5 | pub mod color; 6 | pub mod reading; 7 | pub mod rhythm; 8 | pub mod stamina; 9 | 10 | #[derive(Clone)] 11 | pub struct TaikoSkills { 12 | pub rhythm: Rhythm, 13 | pub reading: Reading, 14 | pub color: Color, 15 | pub stamina: Stamina, 16 | pub single_color_stamina: Stamina, 17 | } 18 | 19 | impl TaikoSkills { 20 | pub fn new(great_hit_window: f64, is_convert: bool) -> Self { 21 | Self { 22 | rhythm: Rhythm::new(great_hit_window), 23 | reading: Reading::new(), 24 | color: Color::new(), 25 | stamina: Stamina::new(false, is_convert), 26 | single_color_stamina: Stamina::new(true, is_convert), 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/taiko/difficulty/skills/reading.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | taiko::difficulty::object::{TaikoDifficultyObject, TaikoDifficultyObjects}, 3 | util::{difficulty::logistic, sync::Weak}, 4 | }; 5 | 6 | define_skill! { 7 | #[derive(Clone)] 8 | pub struct Reading: StrainDecaySkill => TaikoDifficultyObjects[TaikoDifficultyObject] { 9 | current_strain: f64 = 0.0, 10 | } 11 | } 12 | 13 | impl Reading { 14 | const SKILL_MULTIPLIER: f64 = 1.0; 15 | const STRAIN_DECAY_BASE: f64 = 0.4; 16 | 17 | fn strain_value_of(&mut self, curr: &TaikoDifficultyObject, _: &TaikoDifficultyObjects) -> f64 { 18 | // * Drum Rolls and Swells are exempt. 19 | if !curr.base_hit_type.is_hit() { 20 | return 0.0; 21 | } 22 | 23 | let index = curr 24 | .color_data 25 | .mono_streak 26 | .as_ref() 27 | .and_then(Weak::upgrade) 28 | .and_then(|mono| { 29 | mono.get().hit_objects.iter().position(|h| { 30 | let Some(h) = h.upgrade() else { return false }; 31 | let h = h.get(); 32 | 33 | h.idx == curr.idx 34 | }) 35 | }) 36 | .unwrap_or(0) as isize; 37 | 38 | self.current_strain *= logistic(index as f64, 4.0, -1.0 / 25.0, Some(0.5)) + 0.5; 39 | self.current_strain *= Self::STRAIN_DECAY_BASE; 40 | self.current_strain += ReadingEvaluator::evaluate_diff_of(curr) * Self::SKILL_MULTIPLIER; 41 | 42 | self.current_strain 43 | } 44 | } 45 | 46 | struct ReadingEvaluator; 47 | 48 | impl ReadingEvaluator { 49 | fn evaluate_diff_of(note_object: &TaikoDifficultyObject) -> f64 { 50 | let high_velocity = VelocityRange::new(480.0, 640.0); 51 | let mid_velocity = VelocityRange::new(360.0, 480.0); 52 | 53 | // * Apply a cap to prevent outlier values on maps that exceed the editor's parameters. 54 | let effective_bpm = f64::max(1.0, note_object.effective_bpm); 55 | 56 | let mid_velocity_diff = 0.5 57 | * logistic( 58 | effective_bpm, 59 | mid_velocity.center(), 60 | 1.0 / (mid_velocity.range() / 10.0), 61 | None, 62 | ); 63 | 64 | // * Expected DeltaTime is the DeltaTime this note would need to be spaced equally to a base slider velocity 1/4 note. 65 | let expected_delta_time = 21_000.0 / effective_bpm; 66 | let object_density = expected_delta_time / f64::max(1.0, note_object.delta_time); 67 | 68 | // * High density is penalised at high velocity as it is generally considered easier to read. 69 | // * See https://www.desmos.com/calculator/u63f3ntdsi 70 | let density_penalty = logistic(object_density, 0.925, 15.0, None); 71 | 72 | let high_velocity_diff = (1.0 - 0.33 * density_penalty) 73 | * logistic( 74 | effective_bpm, 75 | high_velocity.center() + 8.0 * density_penalty, 76 | (1.0 + 0.5 * density_penalty) / (high_velocity.range() / 10.0), 77 | None, 78 | ); 79 | 80 | mid_velocity_diff + high_velocity_diff 81 | } 82 | } 83 | 84 | struct VelocityRange { 85 | min: f64, 86 | max: f64, 87 | } 88 | 89 | impl VelocityRange { 90 | const fn new(min: f64, max: f64) -> Self { 91 | Self { min, max } 92 | } 93 | 94 | const fn center(&self) -> f64 { 95 | (self.max + self.min) / 2.0 96 | } 97 | 98 | const fn range(&self) -> f64 { 99 | self.max - self.min 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/taiko/difficulty/skills/stamina.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | any::difficulty::{object::IDifficultyObject, skills::strain_decay}, 3 | taiko::difficulty::object::{TaikoDifficultyObject, TaikoDifficultyObjects}, 4 | util::{difficulty::logistic_exp, sync::Weak}, 5 | }; 6 | 7 | define_skill! { 8 | #[derive(Clone)] 9 | pub struct Stamina: StrainSkill => TaikoDifficultyObjects[TaikoDifficultyObject] { 10 | single_color: bool, 11 | is_convert: bool, 12 | current_strain: f64 = 0.0, 13 | } 14 | } 15 | 16 | impl Stamina { 17 | const SKILL_MULTIPLIER: f64 = 1.1; 18 | const STRAIN_DECAY_BASE: f64 = 0.4; 19 | 20 | fn calculate_initial_strain( 21 | &mut self, 22 | time: f64, 23 | curr: &TaikoDifficultyObject, 24 | objects: &TaikoDifficultyObjects, 25 | ) -> f64 { 26 | if self.single_color { 27 | return 0.0; 28 | } 29 | 30 | let prev_start_time = curr 31 | .previous(0, objects) 32 | .map_or(0.0, |prev| prev.get().start_time); 33 | 34 | self.current_strain * strain_decay(time - prev_start_time, Self::STRAIN_DECAY_BASE) 35 | } 36 | 37 | fn strain_value_at( 38 | &mut self, 39 | curr: &TaikoDifficultyObject, 40 | objects: &TaikoDifficultyObjects, 41 | ) -> f64 { 42 | self.current_strain *= strain_decay(curr.delta_time, Self::STRAIN_DECAY_BASE); 43 | self.current_strain += 44 | StaminaEvaluator::evaluate_diff_of(curr, objects) * Self::SKILL_MULTIPLIER; 45 | 46 | // * Safely prevents previous strains from shifting as new notes are added. 47 | let index = curr 48 | .color_data 49 | .mono_streak 50 | .as_ref() 51 | .and_then(Weak::upgrade) 52 | .and_then(|mono| { 53 | mono.get().hit_objects.iter().position(|h| { 54 | let Some(h) = h.upgrade() else { return false }; 55 | let h = h.get(); 56 | 57 | h.idx == curr.idx 58 | }) 59 | }) 60 | .unwrap_or(0) as isize; 61 | 62 | if self.single_color { 63 | logistic_exp(-(index - 10) as f64 / 2.0, Some(self.current_strain)) 64 | } else if self.is_convert { 65 | self.current_strain 66 | } else { 67 | #[allow(clippy::manual_clamp)] 68 | let monolength_bonus = 1.0 + f64::min(f64::max((index - 5) as f64 / 50.0, 0.0), 0.30); 69 | 70 | self.current_strain * monolength_bonus 71 | } 72 | } 73 | } 74 | 75 | pub(super) struct StaminaEvaluator; 76 | 77 | impl StaminaEvaluator { 78 | pub(super) fn evaluate_diff_of( 79 | curr: &TaikoDifficultyObject, 80 | objects: &TaikoDifficultyObjects, 81 | ) -> f64 { 82 | if !curr.base_hit_type.is_hit() { 83 | return 0.0; 84 | } 85 | 86 | // * Find the previous hit object hit by the current finger, which is n notes prior, n being the number of 87 | // * available fingers. 88 | let prev = curr.previous(1, objects); 89 | let prev_mono = objects.previous_mono(curr, Self::available_fingers_for(curr, objects) - 1); 90 | 91 | // * Add a base strain to all objects 92 | let mut object_strain = 0.5; 93 | 94 | let Some(prev) = prev else { 95 | return object_strain; 96 | }; 97 | 98 | if let Some(prev_mono) = prev_mono { 99 | object_strain += Self::speed_bonus(curr.start_time - prev_mono.get().start_time) 100 | + 0.5 * Self::speed_bonus(curr.start_time - prev.get().start_time); 101 | } 102 | 103 | object_strain 104 | } 105 | 106 | fn available_fingers_for( 107 | hit_object: &TaikoDifficultyObject, 108 | hit_objects: &TaikoDifficultyObjects, 109 | ) -> usize { 110 | let prev_color_change = hit_object.color_data.previous_color_change(hit_objects); 111 | 112 | if prev_color_change 113 | .is_some_and(|change| hit_object.start_time - change.get().start_time < 300.0) 114 | { 115 | return 2; 116 | } 117 | 118 | let next_color_change = hit_object.color_data.next_color_change(hit_objects); 119 | 120 | if next_color_change 121 | .is_some_and(|change| change.get().start_time - hit_object.start_time < 300.0) 122 | { 123 | return 2; 124 | } 125 | 126 | 8 127 | } 128 | 129 | fn speed_bonus(mut interval: f64) -> f64 { 130 | // * Interval is capped at a very small value to prevent infinite values. 131 | interval = f64::max(interval, 1.0); 132 | 133 | 20.0 / interval 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/taiko/mod.rs: -------------------------------------------------------------------------------- 1 | use rosu_map::section::general::GameMode; 2 | 3 | use crate::{ 4 | model::{ 5 | beatmap::Beatmap, 6 | mode::{ConvertError, IGameMode}, 7 | }, 8 | Difficulty, 9 | }; 10 | 11 | pub use self::{ 12 | attributes::{TaikoDifficultyAttributes, TaikoPerformanceAttributes}, 13 | difficulty::gradual::TaikoGradualDifficulty, 14 | performance::{gradual::TaikoGradualPerformance, TaikoPerformance}, 15 | score_state::TaikoScoreState, 16 | strains::TaikoStrains, 17 | }; 18 | 19 | mod attributes; 20 | mod convert; 21 | mod difficulty; 22 | mod object; 23 | mod performance; 24 | mod score_state; 25 | mod strains; 26 | 27 | /// Marker type for [`GameMode::Taiko`]. 28 | /// 29 | /// [`GameMode::Taiko`]: rosu_map::section::general::GameMode::Taiko 30 | pub struct Taiko; 31 | 32 | impl Taiko { 33 | pub fn convert(map: &mut Beatmap) { 34 | debug_assert!(!map.is_convert && map.mode == GameMode::Osu); 35 | convert::convert(map); 36 | } 37 | } 38 | 39 | impl IGameMode for Taiko { 40 | type DifficultyAttributes = TaikoDifficultyAttributes; 41 | type Strains = TaikoStrains; 42 | type Performance<'map> = TaikoPerformance<'map>; 43 | type GradualDifficulty = TaikoGradualDifficulty; 44 | type GradualPerformance = TaikoGradualPerformance; 45 | 46 | fn difficulty( 47 | difficulty: &Difficulty, 48 | map: &Beatmap, 49 | ) -> Result { 50 | difficulty::difficulty(difficulty, map) 51 | } 52 | 53 | fn strains(difficulty: &Difficulty, map: &Beatmap) -> Result { 54 | strains::strains(difficulty, map) 55 | } 56 | 57 | fn performance(map: &Beatmap) -> Self::Performance<'_> { 58 | TaikoPerformance::new(map) 59 | } 60 | 61 | fn gradual_difficulty( 62 | difficulty: Difficulty, 63 | map: &Beatmap, 64 | ) -> Result { 65 | TaikoGradualDifficulty::new(difficulty, map) 66 | } 67 | 68 | fn gradual_performance( 69 | difficulty: Difficulty, 70 | map: &Beatmap, 71 | ) -> Result { 72 | TaikoGradualPerformance::new(difficulty, map) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/taiko/object.rs: -------------------------------------------------------------------------------- 1 | use rosu_map::section::hit_objects::hit_samples::HitSoundType; 2 | 3 | use crate::model::hit_object::HitObject; 4 | 5 | pub struct TaikoObject { 6 | pub start_time: f64, 7 | pub hit_type: HitType, 8 | } 9 | 10 | impl TaikoObject { 11 | pub const fn new(h: &HitObject, sound: HitSoundType) -> Self { 12 | Self { 13 | start_time: h.start_time, 14 | hit_type: if !h.is_circle() { 15 | HitType::NonHit 16 | } else if sound.has_flag(HitSoundType::CLAP | HitSoundType::WHISTLE) { 17 | HitType::Rim 18 | } else { 19 | HitType::Center 20 | }, 21 | } 22 | } 23 | 24 | pub const fn is_hit(&self) -> bool { 25 | self.hit_type.is_hit() 26 | } 27 | } 28 | 29 | #[derive(Copy, Clone, Debug, PartialEq, Eq)] 30 | pub enum HitType { 31 | Center, 32 | Rim, 33 | NonHit, 34 | } 35 | 36 | impl HitType { 37 | pub const fn is_hit(self) -> bool { 38 | !matches!(self, Self::NonHit) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/taiko/score_state.rs: -------------------------------------------------------------------------------- 1 | /// Aggregation for a score's current state. 2 | #[derive(Copy, Clone, Debug, PartialEq, Eq)] 3 | pub struct TaikoScoreState { 4 | /// Maximum combo that the score has had so far. 5 | /// **Not** the maximum possible combo of the map so far. 6 | pub max_combo: u32, 7 | /// Amount of current 300s. 8 | pub n300: u32, 9 | /// Amount of current 100s. 10 | pub n100: u32, 11 | /// Amount of current misses. 12 | pub misses: u32, 13 | } 14 | 15 | impl TaikoScoreState { 16 | /// Create a new empty score state. 17 | pub const fn new() -> Self { 18 | Self { 19 | max_combo: 0, 20 | n300: 0, 21 | n100: 0, 22 | misses: 0, 23 | } 24 | } 25 | 26 | /// Return the total amount of hits by adding everything up. 27 | pub const fn total_hits(&self) -> u32 { 28 | self.n300 + self.n100 + self.misses 29 | } 30 | 31 | /// Calculate the accuracy between `0.0` and `1.0` for this state. 32 | pub fn accuracy(&self) -> f64 { 33 | let total_hits = self.total_hits(); 34 | 35 | if total_hits == 0 { 36 | return 0.0; 37 | } 38 | 39 | let numerator = 2 * self.n300 + self.n100; 40 | let denominator = 2 * total_hits; 41 | 42 | f64::from(numerator) / f64::from(denominator) 43 | } 44 | } 45 | 46 | impl Default for TaikoScoreState { 47 | fn default() -> Self { 48 | Self::new() 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/taiko/strains.rs: -------------------------------------------------------------------------------- 1 | use rosu_map::section::general::GameMode; 2 | 3 | use crate::{ 4 | any::difficulty::skills::StrainSkill, model::mode::ConvertError, 5 | taiko::difficulty::DifficultyValues, Beatmap, Difficulty, 6 | }; 7 | 8 | use super::difficulty::TaikoSkills; 9 | 10 | /// The result of calculating the strains on a osu!taiko map. 11 | /// 12 | /// Suitable to plot the difficulty of a map over time. 13 | #[derive(Clone, Debug, PartialEq)] 14 | pub struct TaikoStrains { 15 | /// Strain peaks of the color skill. 16 | pub color: Vec, 17 | /// Strain peaks of the reading skill. 18 | pub reading: Vec, 19 | /// Strain peaks of the rhythm skill. 20 | pub rhythm: Vec, 21 | /// Strain peaks of the stamina skill. 22 | pub stamina: Vec, 23 | /// Strain peaks of the single color stamina skill. 24 | pub single_color_stamina: Vec, 25 | } 26 | 27 | impl TaikoStrains { 28 | /// Time between two strains in ms. 29 | pub const SECTION_LEN: f64 = 400.0; 30 | } 31 | 32 | pub fn strains(difficulty: &Difficulty, map: &Beatmap) -> Result { 33 | let map = map.convert_ref(GameMode::Taiko, difficulty.get_mods())?; 34 | 35 | let great_hit_window = map 36 | .attributes() 37 | .difficulty(difficulty) 38 | .hit_windows() 39 | .od_great; 40 | 41 | let values = DifficultyValues::calculate(difficulty, &map, great_hit_window); 42 | 43 | let TaikoSkills { 44 | rhythm, 45 | reading, 46 | color, 47 | stamina, 48 | single_color_stamina, 49 | } = values.skills; 50 | 51 | Ok(TaikoStrains { 52 | color: color.into_current_strain_peaks().into_vec(), 53 | reading: reading.into_current_strain_peaks().into_vec(), 54 | rhythm: rhythm.into_current_strain_peaks().into_vec(), 55 | stamina: stamina.into_current_strain_peaks().into_vec(), 56 | single_color_stamina: single_color_stamina.into_current_strain_peaks().into_vec(), 57 | }) 58 | } 59 | -------------------------------------------------------------------------------- /src/util/difficulty.rs: -------------------------------------------------------------------------------- 1 | use std::f64::consts::E; 2 | 3 | pub const fn bpm_to_milliseconds(bpm: f64, delimiter: Option) -> f64 { 4 | 60_000.0 / i32_unwrap_or(delimiter, 4) as f64 / bpm 5 | } 6 | 7 | pub const fn milliseconds_to_bpm(ms: f64, delimiter: Option) -> f64 { 8 | 60_000.0 / (ms * i32_unwrap_or(delimiter, 4) as f64) 9 | } 10 | 11 | // `Option::unwrap_or` is not const 12 | const fn i32_unwrap_or(option: Option, default: i32) -> i32 { 13 | match option { 14 | Some(value) => value, 15 | None => default, 16 | } 17 | } 18 | 19 | // `f64::exp` is not const 20 | pub fn logistic(x: f64, midpoint_offset: f64, multiplier: f64, max_value: Option) -> f64 { 21 | max_value.unwrap_or(1.0) / (1.0 + f64::exp(multiplier * (midpoint_offset - x))) 22 | } 23 | 24 | // `f64::exp` is not const 25 | pub fn logistic_exp(exp: f64, max_value: Option) -> f64 { 26 | max_value.unwrap_or(1.0) / (1.0 + f64::exp(exp)) 27 | } 28 | 29 | pub fn norm(p: f64, values: [f64; N]) -> f64 { 30 | values 31 | .into_iter() 32 | .map(|x| f64::powf(x, p)) 33 | .sum::() 34 | .powf(p.recip()) 35 | } 36 | 37 | pub fn bell_curve(x: f64, mean: f64, width: f64, multiplier: Option) -> f64 { 38 | multiplier.unwrap_or(1.0) * f64::exp(E * -(f64::powf(x - mean, 2.0) / f64::powf(width, 2.0))) 39 | } 40 | 41 | pub const fn smoothstep(x: f64, start: f64, end: f64) -> f64 { 42 | let x = reverse_lerp(x, start, end); 43 | 44 | x * x * (3.0 - 2.0 * x) 45 | } 46 | 47 | pub const fn smootherstep(x: f64, start: f64, end: f64) -> f64 { 48 | let x = reverse_lerp(x, start, end); 49 | 50 | x * x * x * (x * (6.0 * x - 15.0) + 10.0) 51 | } 52 | 53 | pub const fn reverse_lerp(x: f64, start: f64, end: f64) -> f64 { 54 | f64::clamp((x - start) / (end - start), 0.0, 1.0) 55 | } 56 | -------------------------------------------------------------------------------- /src/util/float_ext.rs: -------------------------------------------------------------------------------- 1 | pub trait FloatExt: Sized { 2 | const EPS: Self; 3 | 4 | /// `self == other` 5 | fn eq(self, other: Self) -> bool { 6 | self.almost_eq(other, Self::EPS) 7 | } 8 | 9 | /// `self ~= other` (within `acceptable_difference`) 10 | fn almost_eq(self, other: Self, acceptable_difference: Self) -> bool; 11 | 12 | /// `self != other` 13 | fn not_eq(self, other: Self) -> bool; 14 | 15 | /// Performs a linear interpolation between two values based on the given weight. 16 | fn lerp(value1: Self, value2: Self, amount: Self) -> Self; 17 | } 18 | 19 | macro_rules! impl_float_ext { 20 | ( $ty:ty ) => { 21 | impl FloatExt for $ty { 22 | const EPS: Self = <$ty>::EPSILON; 23 | 24 | fn almost_eq(self, other: Self, acceptable_difference: Self) -> bool { 25 | (self - other).abs() <= acceptable_difference 26 | } 27 | 28 | fn not_eq(self, other: Self) -> bool { 29 | (self - other).abs() >= Self::EPS 30 | } 31 | 32 | // 33 | fn lerp(value1: Self, value2: Self, amount: Self) -> Self { 34 | (value1 * (1.0 - amount)) + (value2 * amount) 35 | } 36 | } 37 | }; 38 | } 39 | 40 | impl_float_ext!(f32); 41 | impl_float_ext!(f64); 42 | -------------------------------------------------------------------------------- /src/util/hint.rs: -------------------------------------------------------------------------------- 1 | #[inline] 2 | #[cold] 3 | const fn cold() {} 4 | 5 | /// Hints at the compiler that the condition is likely `true`. 6 | #[inline] 7 | #[allow(unused)] 8 | pub const fn likely(b: bool) -> bool { 9 | if !b { 10 | cold(); 11 | } 12 | 13 | b 14 | } 15 | 16 | /// Hints at the compiler that the condition is likely `false`. 17 | #[inline] 18 | #[allow(unused)] 19 | pub const fn unlikely(b: bool) -> bool { 20 | if b { 21 | cold(); 22 | } 23 | 24 | b 25 | } 26 | -------------------------------------------------------------------------------- /src/util/interval_grouping.rs: -------------------------------------------------------------------------------- 1 | use crate::util::float_ext::FloatExt; 2 | 3 | use super::sync::{RefCount, Weak}; 4 | 5 | pub trait HasInterval { 6 | fn interval(&self) -> f64; 7 | } 8 | 9 | pub const fn group_by_interval( 10 | objects: &[RefCount], 11 | ) -> GroupedByIntervalIter<'_, T> { 12 | GroupedByIntervalIter::new(objects) 13 | } 14 | 15 | pub struct GroupedByIntervalIter<'a, T> { 16 | objects: &'a [RefCount], 17 | i: usize, 18 | } 19 | 20 | impl<'a, T> GroupedByIntervalIter<'a, T> { 21 | const fn new(objects: &'a [RefCount]) -> Self { 22 | Self { objects, i: 0 } 23 | } 24 | } 25 | 26 | impl Iterator for GroupedByIntervalIter<'_, T> { 27 | type Item = Vec>; 28 | 29 | fn next(&mut self) -> Option { 30 | if self.i < self.objects.len() { 31 | Some(self.create_next_group()) 32 | } else { 33 | None 34 | } 35 | } 36 | 37 | fn size_hint(&self) -> (usize, Option) { 38 | let min = usize::from(!self.objects.is_empty()); 39 | let max = self.objects.len() - self.i; 40 | 41 | (min, Some(max)) 42 | } 43 | } 44 | 45 | impl GroupedByIntervalIter<'_, T> { 46 | fn create_next_group(&mut self) -> Vec> { 47 | const MARGIN_OF_ERROR: f64 = 5.0; 48 | 49 | let &mut Self { objects, ref mut i } = self; 50 | 51 | // * This never compares the first two elements in the group. 52 | // * This sounds wrong but is apparently "as intended" (https://github.com/ppy/osu/pull/31636#discussion_r1942673329) 53 | let mut grouped_objects = vec![objects[*i].downgrade()]; 54 | 55 | *i += 1; 56 | 57 | while *i < objects.len() - 1 { 58 | if !(objects[*i] 59 | .get() 60 | .interval() 61 | .almost_eq(objects[*i + 1].get().interval(), MARGIN_OF_ERROR)) 62 | { 63 | // * When an interval change occurs, include the object with the differing interval in the case it increased 64 | // * See https://github.com/ppy/osu/pull/31636#discussion_r1942368372 for rationale. 65 | if objects[*i + 1].get().interval() > objects[*i].get().interval() + MARGIN_OF_ERROR 66 | { 67 | grouped_objects.push(objects[*i].downgrade()); 68 | *i += 1; 69 | } 70 | 71 | return grouped_objects; 72 | } 73 | 74 | // * No interval change occurred 75 | grouped_objects.push(objects[*i].downgrade()); 76 | 77 | *i += 1; 78 | } 79 | 80 | // * Check if the last two objects in the object form a "flat" rhythm pattern within the specified margin of error. 81 | // * If true, add the current object to the group and increment the index to process the next object. 82 | if objects.len() > 2 83 | && *i < objects.len() 84 | && objects[objects.len() - 1] 85 | .get() 86 | .interval() 87 | .almost_eq(objects[objects.len() - 2].get().interval(), MARGIN_OF_ERROR) 88 | { 89 | grouped_objects.push(objects[*i].downgrade()); 90 | *i += 1; 91 | } 92 | 93 | grouped_objects 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/util/limited_queue.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Index; 2 | 3 | /// Efficient counterpart to osu!'s [`LimitedCapacityQueue`] i.e. an indexed 4 | /// queue with limited capacity. 5 | /// 6 | /// [`LimitedQueue`] will use an internal array as queue which is stored on 7 | /// the stack. Hence, if `size_of() * N` is very large, consider using a 8 | /// different type since heap allocation might be favorable. 9 | /// 10 | /// [`LimitedCapacityQueue`]: https://github.com/ppy/osu/blob/b49a1aab8ac6e16e48dffd03f55635cdc1771adf/osu.Game/Rulesets/Difficulty/Utils/LimitedCapacityQueue.cs 11 | #[derive(Clone, Debug)] 12 | pub struct LimitedQueue { 13 | queue: [T; N], 14 | /// If the queue is not empty, `end` is the index of the last element. 15 | /// Otherwise, it has no meaning. 16 | end: usize, 17 | /// Amount of elements in the queue. This is equal to `end + 1` if the 18 | /// queue is not full, or `N` otherwise. 19 | len: usize, 20 | } 21 | 22 | impl Default for LimitedQueue 23 | where 24 | T: Copy + Clone + Default, 25 | { 26 | fn default() -> Self { 27 | Self { 28 | end: N - 1, 29 | queue: [T::default(); N], 30 | len: 0, 31 | } 32 | } 33 | } 34 | 35 | impl LimitedQueue 36 | where 37 | T: Copy + Clone + Default, 38 | { 39 | pub fn new() -> Self { 40 | Self::default() 41 | } 42 | } 43 | 44 | impl LimitedQueue { 45 | pub fn push(&mut self, elem: T) { 46 | self.end = (self.end + 1) % N; 47 | self.queue[self.end] = elem; 48 | self.len += usize::from(self.len < N); 49 | } 50 | 51 | #[cfg(test)] 52 | pub const fn is_empty(&self) -> bool { 53 | self.len == 0 54 | } 55 | 56 | pub const fn is_full(&self) -> bool { 57 | self.len == N 58 | } 59 | 60 | pub const fn len(&self) -> usize { 61 | self.len 62 | } 63 | 64 | #[cfg(test)] 65 | pub const fn last(&self) -> Option<&T> { 66 | if self.is_empty() { 67 | None 68 | } else { 69 | Some(&self.queue[self.end]) 70 | } 71 | } 72 | 73 | pub fn as_slices(&self) -> (&[T], &[T]) { 74 | if self.is_full() { 75 | (&self.queue[self.end + 1..N], &self.queue[0..=self.end]) 76 | } else { 77 | (&[], &self.queue[0..self.len]) 78 | } 79 | } 80 | } 81 | 82 | impl Index for LimitedQueue { 83 | type Output = T; 84 | 85 | fn index(&self, idx: usize) -> &Self::Output { 86 | let idx = (idx + usize::from(self.len == N) * (self.end + 1)) % N; 87 | 88 | &self.queue[idx] 89 | } 90 | } 91 | 92 | #[cfg(test)] 93 | mod test { 94 | use std::cmp; 95 | 96 | use super::LimitedQueue; 97 | 98 | #[test] 99 | fn empty() { 100 | let queue = LimitedQueue::::default(); 101 | assert!(queue.is_empty()); 102 | assert_eq!(queue.last(), None); 103 | } 104 | 105 | #[test] 106 | fn single_push() { 107 | let mut queue = LimitedQueue::::default(); 108 | let elem = 42; 109 | queue.push(elem); 110 | assert!(!queue.is_empty()); 111 | assert_eq!(queue.len(), 1); 112 | assert_eq!(queue.last(), Some(&elem)); 113 | assert_eq!(queue[0], elem); 114 | } 115 | 116 | #[test] 117 | fn overfull() { 118 | let mut queue = LimitedQueue::::default(); 119 | 120 | for i in 1..=5 { 121 | queue.push(i as u8); 122 | assert_eq!(cmp::min(i, 4), queue.len()); 123 | } 124 | 125 | assert_eq!(queue.last(), Some(&5)); 126 | assert_eq!(queue[0], 2); 127 | assert_eq!(queue[3], 5); 128 | } 129 | 130 | #[test] 131 | fn as_slices() { 132 | let mut queue = LimitedQueue::::default(); 133 | assert_eq!(queue.as_slices(), ([].as_slice(), [].as_slice())); 134 | 135 | // Start by filling the tail slice 136 | queue.push(1); 137 | assert_eq!(queue.as_slices(), ([].as_slice(), [1].as_slice())); 138 | queue.push(2); 139 | assert_eq!(queue.as_slices(), ([].as_slice(), [1, 2].as_slice())); 140 | queue.push(3); 141 | assert_eq!(queue.as_slices(), ([].as_slice(), [1, 2, 3].as_slice())); 142 | 143 | // The buffer is full and wraps around so now it uses the head slice 144 | queue.push(4); 145 | assert_eq!(queue.as_slices(), ([2, 3].as_slice(), [4].as_slice())); 146 | queue.push(5); 147 | assert_eq!(queue.as_slices(), ([3].as_slice(), [4, 5].as_slice())); 148 | queue.push(6); 149 | assert_eq!(queue.as_slices(), ([].as_slice(), [4, 5, 6].as_slice())); 150 | 151 | queue.push(7); 152 | assert_eq!(queue.as_slices(), ([5, 6].as_slice(), [7].as_slice())); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/util/map_or_attrs.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | borrow::Cow, 3 | fmt::{Debug, Formatter, Result as FmtResult}, 4 | }; 5 | 6 | use crate::{model::mode::IGameMode, Beatmap}; 7 | 8 | pub enum MapOrAttrs<'map, M: IGameMode> { 9 | Map(Cow<'map, Beatmap>), 10 | Attrs(M::DifficultyAttributes), 11 | } 12 | 13 | impl MapOrAttrs<'_, M> { 14 | /// Insert `attrs` into `self` and return a mutable reference to them. 15 | pub fn insert_attrs(&mut self, attrs: M::DifficultyAttributes) -> &mut M::DifficultyAttributes { 16 | *self = Self::Attrs(attrs); 17 | 18 | let Self::Attrs(ref mut attrs) = self else { 19 | unreachable!() 20 | }; 21 | 22 | attrs 23 | } 24 | } 25 | 26 | impl Clone for MapOrAttrs<'_, M> 27 | where 28 | M: IGameMode, 29 | M::DifficultyAttributes: Clone, 30 | { 31 | fn clone(&self) -> Self { 32 | match self { 33 | Self::Map(converted) => Self::Map(converted.clone()), 34 | Self::Attrs(attrs) => Self::Attrs(attrs.clone()), 35 | } 36 | } 37 | } 38 | 39 | impl Debug for MapOrAttrs<'_, M> 40 | where 41 | M: IGameMode, 42 | M::DifficultyAttributes: Debug, 43 | { 44 | fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { 45 | match self { 46 | Self::Map(converted) => f.debug_tuple("Map").field(converted).finish(), 47 | Self::Attrs(attrs) => f.debug_tuple("Attrs").field(attrs).finish(), 48 | } 49 | } 50 | } 51 | 52 | impl PartialEq for MapOrAttrs<'_, M> 53 | where 54 | M: IGameMode, 55 | M::DifficultyAttributes: PartialEq, 56 | { 57 | fn eq(&self, other: &Self) -> bool { 58 | match (self, other) { 59 | (Self::Map(a), Self::Map(b)) => a == b, 60 | (Self::Attrs(a), Self::Attrs(b)) => a == b, 61 | _ => false, 62 | } 63 | } 64 | } 65 | 66 | impl<'map, M: IGameMode> From<&'map Beatmap> for MapOrAttrs<'map, M> { 67 | fn from(map: &'map Beatmap) -> Self { 68 | Self::Map(Cow::Borrowed(map)) 69 | } 70 | } 71 | 72 | impl From for MapOrAttrs<'_, M> { 73 | fn from(map: Beatmap) -> Self { 74 | Self::Map(Cow::Owned(map)) 75 | } 76 | } 77 | 78 | macro_rules! from_attrs { 79 | ( 80 | $( 81 | $module:ident { 82 | $mode:ident, $diff:ident, $perf:ident 83 | } 84 | ,)* 85 | ) => { 86 | $( 87 | impl From for MapOrAttrs<'_, crate::$module::$mode> { 88 | fn from(attrs: crate::$module::$diff) -> Self { 89 | Self::Attrs(attrs) 90 | } 91 | } 92 | 93 | impl From for MapOrAttrs<'_, crate::$module::$mode> { 94 | fn from(attrs: crate::$module::$perf) -> Self { 95 | Self::Attrs(attrs.difficulty) 96 | } 97 | } 98 | )* 99 | }; 100 | } 101 | 102 | from_attrs!( 103 | osu { 104 | Osu, 105 | OsuDifficultyAttributes, 106 | OsuPerformanceAttributes 107 | }, 108 | taiko { 109 | Taiko, 110 | TaikoDifficultyAttributes, 111 | TaikoPerformanceAttributes 112 | }, 113 | catch { 114 | Catch, 115 | CatchDifficultyAttributes, 116 | CatchPerformanceAttributes 117 | }, 118 | mania { 119 | Mania, 120 | ManiaDifficultyAttributes, 121 | ManiaPerformanceAttributes 122 | }, 123 | ); 124 | -------------------------------------------------------------------------------- /src/util/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod difficulty; 2 | pub mod float_ext; 3 | pub mod hint; 4 | pub mod interval_grouping; 5 | pub mod limited_queue; 6 | pub mod map_or_attrs; 7 | pub mod random; 8 | pub mod sort; 9 | pub mod special_functions; 10 | pub mod strains_vec; 11 | pub mod sync; 12 | 13 | #[macro_use] 14 | mod macros; 15 | 16 | pub fn get_precision_adjusted_beat_len(slider_velocity_multiplier: f64, beat_len: f64) -> f64 { 17 | let slider_velocity_as_beat_len = -100.0 / slider_velocity_multiplier; 18 | 19 | let bpm_multiplier = if slider_velocity_as_beat_len < 0.0 { 20 | f64::from(((-slider_velocity_as_beat_len) as f32).clamp(10.0, 10_000.0)) / 100.0 21 | } else { 22 | 1.0 23 | }; 24 | 25 | beat_len * bpm_multiplier 26 | } 27 | -------------------------------------------------------------------------------- /src/util/random/csharp.rs: -------------------------------------------------------------------------------- 1 | use crate::util::hint::unlikely; 2 | 3 | // 4 | pub struct Random { 5 | prng: CompatPrng, 6 | } 7 | 8 | impl Random { 9 | // 10 | pub fn new(seed: i32) -> Self { 11 | Self { 12 | // 13 | prng: CompatPrng::initialize(seed), 14 | } 15 | } 16 | 17 | // 18 | pub const fn next(&mut self) -> i32 { 19 | self.prng.internal_sample() 20 | } 21 | 22 | // 23 | pub fn next_max(&mut self, max: i32) -> i32 { 24 | (self.prng.sample() * f64::from(max)) as i32 25 | } 26 | } 27 | 28 | // 29 | struct CompatPrng { 30 | seed_array: [i32; 56], 31 | inext: i32, 32 | inextp: i32, 33 | } 34 | 35 | impl CompatPrng { 36 | fn initialize(seed: i32) -> Self { 37 | let mut seed_array = [0; 56]; 38 | 39 | let subtraction = if unlikely(seed == i32::MIN) { 40 | i32::MAX 41 | } else { 42 | i32::abs(seed) 43 | }; 44 | 45 | let mut mj = 161_803_398 - subtraction; // * magic number based on Phi (golden ratio) 46 | seed_array[55] = mj; 47 | let mut mk = 1; 48 | let mut ii = 0; 49 | 50 | for _ in 1..55 { 51 | // * The range [1..55] is special (Knuth) and so we're wasting the 0'th position. 52 | ii += 21; 53 | 54 | if ii >= 55 { 55 | ii -= 55; 56 | } 57 | 58 | seed_array[ii] = mk; 59 | mk = mj - mk; 60 | if mk < 0 { 61 | mk += i32::MAX; 62 | } 63 | 64 | mj = seed_array[ii]; 65 | } 66 | 67 | for _ in 1..5 { 68 | for i in 1..56 { 69 | let mut n = i + 30; 70 | 71 | if n >= 55 { 72 | n -= 55; 73 | } 74 | 75 | seed_array[i] = seed_array[i].wrapping_sub(seed_array[1 + n]); 76 | 77 | if seed_array[i] < 0 { 78 | seed_array[i] += i32::MAX; 79 | } 80 | } 81 | } 82 | 83 | Self { 84 | seed_array, 85 | inext: 0, 86 | inextp: 21, 87 | } 88 | } 89 | 90 | fn sample(&mut self) -> f64 { 91 | f64::from(self.internal_sample()) * (1.0 / f64::from(i32::MAX)) 92 | } 93 | 94 | const fn internal_sample(&mut self) -> i32 { 95 | let mut loc_inext = self.inext; 96 | loc_inext += 1; 97 | 98 | if loc_inext >= 56 { 99 | loc_inext = 1; 100 | } 101 | 102 | let mut loc_inextp = self.inextp; 103 | loc_inextp += 1; 104 | 105 | if loc_inextp >= 56 { 106 | loc_inextp = 1; 107 | } 108 | 109 | let mut ret_val = 110 | self.seed_array[loc_inext as usize] - self.seed_array[loc_inextp as usize]; 111 | 112 | if ret_val == i32::MAX { 113 | ret_val -= 1; 114 | } 115 | 116 | if ret_val < 0 { 117 | ret_val += i32::MAX; 118 | } 119 | 120 | self.seed_array[loc_inext as usize] = ret_val; 121 | self.inext = loc_inext; 122 | self.inextp = loc_inextp; 123 | 124 | ret_val 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/util/random/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod csharp; 2 | pub mod osu; 3 | -------------------------------------------------------------------------------- /src/util/random/osu.rs: -------------------------------------------------------------------------------- 1 | const INT_TO_REAL: f64 = 1.0 / (i32::MAX as f64 + 1.0); 2 | const INT_MASK: u32 = 0x7F_FF_FF_FF; 3 | 4 | pub struct Random { 5 | x: u32, 6 | y: u32, 7 | z: u32, 8 | w: u32, 9 | bit_buf: u32, 10 | bit_idx: i32, 11 | } 12 | 13 | impl Random { 14 | pub const fn new(seed: i32) -> Self { 15 | Self { 16 | x: seed as u32, 17 | y: 842_502_087, 18 | z: 3_579_807_591, 19 | w: 273_326_509, 20 | bit_buf: 0, 21 | bit_idx: 32, 22 | } 23 | } 24 | 25 | pub const fn gen_unsigned(&mut self) -> u32 { 26 | let t = self.x ^ (self.x << 11); 27 | self.x = self.y; 28 | self.y = self.z; 29 | self.z = self.w; 30 | self.w = self.w ^ (self.w >> 19) ^ t ^ (t >> 8); 31 | 32 | self.w 33 | } 34 | 35 | pub const fn next_int(&mut self) -> i32 { 36 | (INT_MASK & self.gen_unsigned()) as i32 37 | } 38 | 39 | pub fn next_double(&mut self) -> f64 { 40 | INT_TO_REAL * f64::from(self.next_int()) 41 | } 42 | 43 | pub fn next_int_range(&mut self, min: i32, max: i32) -> i32 { 44 | (f64::from(min) + self.next_double() * f64::from(max - min)) as i32 45 | } 46 | 47 | pub fn next_double_range(&mut self, min: f64, max: f64) -> i32 { 48 | (min + self.next_double() * (max - min)) as i32 49 | } 50 | 51 | pub const fn next_bool(&mut self) -> bool { 52 | if self.bit_idx == 32 { 53 | self.bit_buf = self.gen_unsigned(); 54 | self.bit_idx = 1; 55 | } else { 56 | self.bit_idx += 1; 57 | self.bit_buf >>= 1; 58 | } 59 | 60 | (self.bit_buf & 1) == 1 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/util/sort/csharp.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Ordering; 2 | 3 | /// C#'s unstable sorting algorithm. 4 | /// 5 | /// 6 | pub fn sort(keys: &mut [T], cmp: F) 7 | where 8 | F: Fn(&T, &T) -> Ordering, 9 | { 10 | introspective_sort(keys, 0, keys.len(), &cmp); 11 | } 12 | 13 | fn introspective_sort(keys: &mut [T], left: usize, len: usize, cmp: &F) 14 | where 15 | F: Fn(&T, &T) -> Ordering, 16 | { 17 | if len >= 2 { 18 | intro_sort(keys, left, len + left - 1, 2 * keys.len().ilog2(), cmp); 19 | } 20 | } 21 | 22 | fn intro_sort(keys: &mut [T], lo: usize, mut hi: usize, mut depth_limit: u32, cmp: &F) 23 | where 24 | F: Fn(&T, &T) -> Ordering, 25 | { 26 | const INTRO_SORT_SIZE_THRESHOLD: usize = 16; 27 | 28 | while hi > lo { 29 | let partition_size = hi - lo + 1; 30 | 31 | if partition_size <= INTRO_SORT_SIZE_THRESHOLD { 32 | match partition_size { 33 | 1 => {} 34 | 2 => super::swap_if_greater(keys, cmp, lo, hi), 35 | 3 => { 36 | super::swap_if_greater(keys, cmp, lo, hi - 1); 37 | super::swap_if_greater(keys, cmp, lo, hi); 38 | super::swap_if_greater(keys, cmp, hi - 1, hi); 39 | } 40 | _ => insertion_sort(keys, lo, hi, cmp), 41 | } 42 | 43 | break; 44 | } 45 | 46 | if depth_limit == 0 { 47 | super::heap_sort(keys, lo, hi, cmp); 48 | 49 | break; 50 | } 51 | 52 | depth_limit -= 1; 53 | let p = pick_pivot_and_partition(keys, lo, hi, cmp); 54 | intro_sort(keys, p + 1, hi, depth_limit, cmp); 55 | hi = p - 1; 56 | } 57 | } 58 | 59 | fn pick_pivot_and_partition(keys: &mut [T], lo: usize, hi: usize, cmp: &F) -> usize 60 | where 61 | F: Fn(&T, &T) -> Ordering, 62 | { 63 | let mid = lo + (hi - lo) / 2; 64 | super::swap_if_greater(keys, cmp, lo, mid); 65 | super::swap_if_greater(keys, cmp, lo, hi); 66 | super::swap_if_greater(keys, cmp, mid, hi); 67 | super::swap(keys, mid, hi - 1); 68 | let mut left = lo; 69 | let mut right = hi - 1; 70 | let pivot_idx = right; 71 | 72 | while left < right { 73 | while { 74 | left += 1; 75 | 76 | cmp(&keys[left], &keys[pivot_idx]).is_lt() 77 | } {} 78 | 79 | while { 80 | right -= 1; 81 | 82 | cmp(&keys[pivot_idx], &keys[right]).is_lt() 83 | } {} 84 | 85 | if left >= right { 86 | break; 87 | } 88 | 89 | super::swap(keys, left, right); 90 | } 91 | 92 | super::swap(keys, left, hi - 1); 93 | 94 | left 95 | } 96 | 97 | fn insertion_sort(keys: &mut [T], lo: usize, hi: usize, cmp: F) 98 | where 99 | F: Fn(&T, &T) -> Ordering, 100 | { 101 | for i in lo..hi { 102 | let t = &keys[i + 1]; 103 | 104 | let shift = keys[lo..=i] 105 | .iter() 106 | .rev() 107 | .take_while(|curr| cmp(t, curr).is_lt()) 108 | .count(); 109 | 110 | keys[i + 1 - shift..=i + 1].rotate_right(1); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/util/sort/mod.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Ordering; 2 | 3 | pub use self::{csharp::sort as csharp, osu_legacy::sort as osu_legacy, tandem::TandemSorter}; 4 | 5 | mod csharp; 6 | mod osu_legacy; 7 | mod tandem; 8 | 9 | fn heap_sort(keys: &mut [T], lo: usize, hi: usize, cmp: &F) 10 | where 11 | F: Fn(&T, &T) -> Ordering, 12 | { 13 | let n = hi - lo + 1; 14 | 15 | for i in (1..=n / 2).rev() { 16 | down_heap(keys, i, n, lo, cmp); 17 | } 18 | 19 | for i in (2..=n).rev() { 20 | swap(keys, lo, lo + i - 1); 21 | down_heap(keys, 1, i - 1, lo, cmp); 22 | } 23 | } 24 | 25 | fn down_heap(keys: &mut [T], mut i: usize, n: usize, lo: usize, cmp: &F) 26 | where 27 | F: Fn(&T, &T) -> Ordering, 28 | { 29 | while i <= n / 2 { 30 | let mut child = 2 * i; 31 | 32 | if child < n && cmp(&keys[lo + child - 1], &keys[lo + child]).is_lt() { 33 | child += 1; 34 | } 35 | 36 | if cmp(&keys[lo + i - 1], &keys[lo + child - 1]).is_ge() { 37 | break; 38 | } 39 | 40 | keys.swap(lo + i - 1, lo + child - 1); 41 | i = child; 42 | } 43 | } 44 | 45 | fn swap_if_greater(keys: &mut [T], cmp: &F, a: usize, b: usize) 46 | where 47 | F: Fn(&T, &T) -> Ordering, 48 | { 49 | if a != b && cmp(&keys[a], &keys[b]).is_gt() { 50 | keys.swap(a, b); 51 | } 52 | } 53 | 54 | const fn swap(keys: &mut [T], i: usize, j: usize) { 55 | if i != j { 56 | keys.swap(i, j); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/util/sort/osu_legacy.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Ordering; 2 | 3 | use crate::model::hit_object::HitObject; 4 | 5 | const QUICK_SORT_DEPTH_THRESHOLD: usize = 32; 6 | 7 | /// osu!'s legacy sorting algorithm. 8 | /// 9 | /// 10 | pub fn sort(keys: &mut [HitObject]) { 11 | if keys.len() < 2 { 12 | return; 13 | } 14 | 15 | depth_limited_quick_sort(keys, 0, keys.len() - 1, QUICK_SORT_DEPTH_THRESHOLD); 16 | } 17 | 18 | fn depth_limited_quick_sort( 19 | keys: &mut [HitObject], 20 | mut left: usize, 21 | mut right: usize, 22 | mut depth_limit: usize, 23 | ) { 24 | loop { 25 | if depth_limit == 0 { 26 | super::heap_sort(keys, left, right, &cmp); 27 | 28 | return; 29 | } 30 | 31 | let mut i = left; 32 | let mut j = right; 33 | 34 | let mid = i + ((j - i) >> 1); 35 | 36 | super::swap_if_greater(keys, &cmp, i, mid); 37 | super::swap_if_greater(keys, &cmp, i, j); 38 | super::swap_if_greater(keys, &cmp, mid, j); 39 | 40 | loop { 41 | while keys[i] < keys[mid] { 42 | i += 1; 43 | } 44 | 45 | while keys[mid] < keys[j] { 46 | j -= 1; 47 | } 48 | 49 | match i.cmp(&j) { 50 | Ordering::Less => keys.swap(i, j), 51 | Ordering::Equal => {} 52 | Ordering::Greater => break, 53 | } 54 | 55 | i += 1; 56 | j = j.saturating_sub(1); 57 | 58 | if i > j { 59 | break; 60 | } 61 | } 62 | 63 | depth_limit -= 1; 64 | 65 | if j.saturating_sub(left) <= right - i { 66 | if left < j { 67 | depth_limited_quick_sort(keys, left, j, depth_limit); 68 | } 69 | 70 | left = i; 71 | } else { 72 | if i < right { 73 | depth_limited_quick_sort(keys, i, right, depth_limit); 74 | } 75 | 76 | right = j; 77 | } 78 | 79 | if left >= right { 80 | break; 81 | } 82 | } 83 | } 84 | 85 | fn cmp(a: &HitObject, b: &HitObject) -> Ordering { 86 | a.start_time.total_cmp(&b.start_time) 87 | } 88 | -------------------------------------------------------------------------------- /src/util/sort/tandem.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Ordering; 2 | 3 | /// Stores the sorted order for an initial list so that multiple 4 | /// lists can be sorted based on that order. 5 | pub struct TandemSorter { 6 | indices: Box<[usize]>, 7 | should_reset: bool, 8 | } 9 | 10 | macro_rules! new_fn { 11 | ( $fn:ident: $sort:expr ) => { 12 | /// Sort indices based on the given slice. 13 | /// 14 | /// Note that this does **not** sort the given slice. 15 | pub fn $fn(slice: &[T], cmp: fn(&T, &T) -> Ordering) -> Self { 16 | let mut indices: Box<[usize]> = (0..slice.len()).collect(); 17 | $sort(&mut indices, |&i, &j| cmp(&slice[i], &slice[j])); 18 | 19 | Self { 20 | indices, 21 | should_reset: false, 22 | } 23 | } 24 | }; 25 | } 26 | 27 | impl TandemSorter { 28 | new_fn!(new_stable: <[_]>::sort_by); 29 | 30 | /// Sort the given slice based on the internal ordering. 31 | pub fn sort(&mut self, slice: &mut [T]) { 32 | if self.should_reset { 33 | self.toggle_marks(); 34 | self.should_reset = false; 35 | } 36 | 37 | for i in 0..self.indices.len() { 38 | let i_idx = self.indices[i]; 39 | 40 | if Self::idx_is_marked(i_idx) { 41 | continue; 42 | } 43 | 44 | let mut j = i; 45 | let mut j_idx = i_idx; 46 | 47 | // When we loop back to the first index, we stop 48 | while j_idx != i { 49 | self.indices[j] = Self::toggle_mark_idx(j_idx); 50 | slice.swap(j, j_idx); 51 | j = j_idx; 52 | j_idx = self.indices[j]; 53 | } 54 | 55 | self.indices[j] = Self::toggle_mark_idx(j_idx); 56 | } 57 | 58 | self.should_reset = true; 59 | } 60 | 61 | fn toggle_marks(&mut self) { 62 | for idx in self.indices.iter_mut() { 63 | *idx = Self::toggle_mark_idx(*idx); 64 | } 65 | } 66 | 67 | const fn idx_is_marked(idx: usize) -> bool { 68 | // Check if first bit is set 69 | idx.leading_zeros() == 0 70 | } 71 | 72 | const fn toggle_mark_idx(idx: usize) -> usize { 73 | // Flip the first bit 74 | idx ^ !(usize::MAX >> 1) 75 | } 76 | } 77 | 78 | #[cfg(test)] 79 | mod tests { 80 | use proptest::prelude::*; 81 | 82 | use super::TandemSorter; 83 | 84 | proptest! { 85 | #![proptest_config(ProptestConfig::with_cases(1000))] 86 | 87 | #[test] 88 | fn sort(mut actual in prop::collection::vec(0_u8..100, 0..100)) { 89 | let mut expected_sorted = actual.clone(); 90 | expected_sorted.sort_unstable(); 91 | 92 | let mut sorter = TandemSorter::new_stable(&actual, u8::cmp); 93 | 94 | sorter.sort(&mut actual); 95 | assert_eq!(actual, expected_sorted); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/util/sync.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | pub use inner::*; 4 | 5 | #[cfg(not(feature = "sync"))] 6 | mod inner { 7 | use std::{cell::RefCell, rc::Rc}; 8 | 9 | #[repr(transparent)] 10 | pub struct RefCount(pub(super) Rc>); 11 | 12 | #[repr(transparent)] 13 | pub struct Weak(pub(super) std::rc::Weak>); 14 | 15 | pub type Ref<'a, T> = std::cell::Ref<'a, T>; 16 | 17 | pub type RefMut<'a, T> = std::cell::RefMut<'a, T>; 18 | 19 | impl RefCount { 20 | pub fn new(inner: T) -> Self { 21 | Self(Rc::new(RefCell::new(inner))) 22 | } 23 | 24 | pub fn clone(this: &Self) -> Self { 25 | Self(Rc::clone(&this.0)) 26 | } 27 | 28 | pub fn downgrade(&self) -> Weak { 29 | Weak(Rc::downgrade(&self.0)) 30 | } 31 | 32 | pub fn get(&self) -> Ref<'_, T> { 33 | self.0.borrow() 34 | } 35 | 36 | pub fn get_mut(&self) -> RefMut<'_, T> { 37 | self.0.borrow_mut() 38 | } 39 | } 40 | } 41 | 42 | #[cfg(feature = "sync")] 43 | mod inner { 44 | use std::{ 45 | marker::PhantomData, 46 | ops, 47 | sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}, 48 | }; 49 | 50 | #[repr(transparent)] 51 | pub struct RefCount(pub(super) Arc>); 52 | 53 | #[repr(transparent)] 54 | pub struct Weak(pub(super) std::sync::Weak>); 55 | 56 | pub struct Ref<'a, T: ?Sized>(RwLockReadGuard<'a, T>); 57 | 58 | pub type RefMut<'a, T> = RwLockWriteGuard<'a, T>; 59 | 60 | impl RefCount { 61 | pub fn new(inner: T) -> Self { 62 | Self(Arc::new(RwLock::new(inner))) 63 | } 64 | 65 | pub fn clone(this: &Self) -> Self { 66 | Self(Arc::clone(&this.0)) 67 | } 68 | 69 | pub fn downgrade(&self) -> Weak { 70 | Weak(Arc::downgrade(&self.0)) 71 | } 72 | 73 | pub fn get(&self) -> Ref<'_, T> { 74 | Ref(self.0.read().unwrap()) 75 | } 76 | 77 | pub fn get_mut(&self) -> RefMut<'_, T> { 78 | self.0.write().unwrap() 79 | } 80 | } 81 | 82 | impl Ref<'_, T> { 83 | pub const fn map(orig: Ref<'_, T>, f: F) -> RefWrap<'_, T, U, F> 84 | where 85 | F: Copy + FnOnce(&T) -> &U, 86 | { 87 | RefWrap { 88 | orig, 89 | access: f, 90 | _phantom: PhantomData, 91 | } 92 | } 93 | } 94 | 95 | pub struct RefWrap<'a, T, U: ?Sized, F> { 96 | orig: Ref<'a, T>, 97 | access: F, 98 | _phantom: PhantomData, 99 | } 100 | 101 | impl ops::Deref for RefWrap<'_, T, U, F> 102 | where 103 | F: Copy + FnOnce(&T) -> &U, 104 | { 105 | type Target = U; 106 | 107 | fn deref(&self) -> &Self::Target { 108 | (self.access)(&self.orig) 109 | } 110 | } 111 | 112 | impl ops::Deref for Ref<'_, T> { 113 | type Target = T; 114 | 115 | fn deref(&self) -> &Self::Target { 116 | ops::Deref::deref(&self.0) 117 | } 118 | } 119 | } 120 | 121 | impl Weak { 122 | pub fn upgrade(&self) -> Option> { 123 | self.0.upgrade().map(RefCount) 124 | } 125 | } 126 | 127 | impl fmt::Debug for RefCount { 128 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 129 | fmt::Debug::fmt(&self.0, f) 130 | } 131 | } 132 | 133 | impl fmt::Debug for Weak { 134 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 135 | fmt::Debug::fmt(&self.0, f) 136 | } 137 | } 138 | 139 | /// ```compile_fail 140 | /// use rosu_pp::{taiko::TaikoGradualDifficulty, Beatmap, Difficulty}; 141 | /// 142 | /// let map = Beatmap::from_bytes(&[]).unwrap(); 143 | /// let difficulty = Difficulty::new(); 144 | /// let mut gradual = TaikoGradualDifficulty::new(&difficulty, &map).unwrap(); 145 | /// 146 | /// // Rc> cannot be shared across threads so compilation should fail 147 | /// std::thread::spawn(move || { let _ = gradual.next(); }); 148 | /// ``` 149 | #[cfg(not(feature = "sync"))] 150 | const fn _share_gradual_taiko() {} 151 | 152 | #[cfg(all(test, feature = "sync"))] 153 | mod tests { 154 | #[test] 155 | fn share_gradual_taiko() { 156 | use crate::{taiko::TaikoGradualDifficulty, Beatmap, Difficulty}; 157 | 158 | let map = Beatmap::from_bytes(&[]).unwrap(); 159 | let mut gradual = TaikoGradualDifficulty::new(Difficulty::new(), &map).unwrap(); 160 | 161 | // Arc> *can* be shared across threads so this should compile 162 | std::thread::spawn(move || { 163 | let _ = gradual.next(); 164 | }); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /tests/common.rs: -------------------------------------------------------------------------------- 1 | #[allow(unused, reason = "false positive")] 2 | pub use self::{mods::*, paths::*}; 3 | 4 | /// Paths to .osu files 5 | mod paths { 6 | pub const OSU: &str = "./resources/2785319.osu"; 7 | pub const TAIKO: &str = "./resources/1028484.osu"; 8 | pub const CATCH: &str = "./resources/2118524.osu"; 9 | pub const MANIA: &str = "./resources/1638954.osu"; 10 | } 11 | 12 | /// Bit values for mods 13 | mod mods { 14 | #![allow(unused)] 15 | 16 | pub const NM: u32 = 0; 17 | pub const NF: u32 = 1 << 0; 18 | pub const EZ: u32 = 1 << 1; 19 | pub const TD: u32 = 1 << 2; 20 | pub const HD: u32 = 1 << 3; 21 | pub const HR: u32 = 1 << 4; 22 | pub const DT: u32 = 1 << 6; 23 | pub const HT: u32 = 1 << 8; 24 | pub const FL: u32 = 1 << 10; 25 | pub const SO: u32 = 1 << 12; 26 | } 27 | 28 | #[track_caller] 29 | pub fn assert_eq_float(a: F, b: F) { 30 | assert!((a - b).less_than_eps(), "{a} != {b}") 31 | } 32 | 33 | #[track_caller] 34 | #[allow(unused, reason = "false positive")] 35 | pub fn assert_eq_option(a: Option, b: Option) { 36 | match (a, b) { 37 | (Some(a), Some(b)) => assert!((a - b).less_than_eps(), "{a} != {b}"), 38 | (None, None) => {} 39 | (None, Some(b)) => panic!("None != Some({b})"), 40 | (Some(a), None) => panic!("Some({a}) != None"), 41 | } 42 | } 43 | 44 | /// Trait to provide flexibility in the `assert_eq_float` function. 45 | pub trait Float: 46 | Copy + std::fmt::Display + std::ops::Sub + PartialOrd + Sized 47 | { 48 | const EPSILON: Self; 49 | 50 | fn abs(self) -> Self; 51 | 52 | fn less_than_eps(self) -> bool { 53 | self.abs() < Self::EPSILON 54 | } 55 | } 56 | 57 | macro_rules! impl_float { 58 | ( $( $ty:ty )* ) => { 59 | $( 60 | impl Float for $ty { 61 | const EPSILON: Self = Self::EPSILON; 62 | 63 | fn abs(self) -> Self { 64 | self.abs() 65 | } 66 | } 67 | )* 68 | } 69 | } 70 | 71 | impl_float!(f32 f64); 72 | 73 | /// Trait to compare two instances and panic if they are not equal. 74 | #[allow(unused)] 75 | pub trait AssertEq { 76 | fn assert_eq(&self, expected: &Self); 77 | } 78 | -------------------------------------------------------------------------------- /tests/decode.rs: -------------------------------------------------------------------------------- 1 | use rosu_pp::{model::mode::GameMode, Beatmap, GameMods}; 2 | 3 | use crate::common::assert_eq_float; 4 | 5 | mod common; 6 | 7 | #[test] 8 | fn osu() { 9 | let map = Beatmap::from_path(common::OSU).unwrap(); 10 | 11 | assert_eq!(map.mode, GameMode::Osu); 12 | assert_eq!(map.version, 14); 13 | assert_eq_float(map.ar, 9.3); 14 | assert_eq_float(map.od, 8.8); 15 | assert_eq_float(map.cs, 4.5); 16 | assert_eq_float(map.hp, 5.0); 17 | assert_eq_float(map.slider_multiplier, 1.7); 18 | assert_eq_float(map.slider_tick_rate, 1.0); 19 | assert_eq!(map.hit_objects.len(), 601); 20 | assert_eq!(map.hit_sounds.len(), 601); 21 | assert_eq!(map.timing_points.len(), 1); 22 | assert_eq!(map.difficulty_points.len(), 50); 23 | assert_eq!(map.effect_points.len(), 0); 24 | assert_eq_float(map.stack_leniency, 0.5); 25 | assert_eq!(map.breaks.len(), 1); 26 | } 27 | 28 | #[test] 29 | fn taiko() { 30 | let map = Beatmap::from_path(common::TAIKO).unwrap(); 31 | 32 | assert_eq!(map.mode, GameMode::Taiko); 33 | assert_eq!(map.version, 14); 34 | assert_eq_float(map.ar, 8.0); 35 | assert_eq_float(map.od, 5.0); 36 | assert_eq_float(map.cs, 2.0); 37 | assert_eq_float(map.hp, 6.0); 38 | assert_eq_float(map.slider_multiplier, 1.4); 39 | assert_eq_float(map.slider_tick_rate, 1.0); 40 | assert_eq!(map.hit_objects.len(), 295); 41 | assert_eq!(map.hit_sounds.len(), 295); 42 | assert_eq!(map.timing_points.len(), 1); 43 | assert_eq!(map.difficulty_points.len(), 3); 44 | assert_eq!(map.effect_points.len(), 7); 45 | assert_eq_float(map.stack_leniency, 0.7); 46 | assert_eq!(map.breaks.len(), 0); 47 | } 48 | 49 | #[test] 50 | fn catch() { 51 | let map = Beatmap::from_path(common::CATCH).unwrap(); 52 | 53 | assert_eq!(map.mode, GameMode::Catch); 54 | assert_eq!(map.version, 14); 55 | assert_eq_float(map.ar, 8.0); 56 | assert_eq_float(map.od, 8.0); 57 | assert_eq_float(map.cs, 3.5); 58 | assert_eq_float(map.hp, 5.0); 59 | assert_eq_float(map.slider_multiplier, 1.45); 60 | assert_eq_float(map.slider_tick_rate, 1.0); 61 | assert_eq!(map.hit_objects.len(), 477); 62 | assert_eq!(map.hit_sounds.len(), 477); 63 | assert_eq!(map.timing_points.len(), 1); 64 | assert_eq!(map.difficulty_points.len(), 0); 65 | assert_eq!(map.effect_points.len(), 16); 66 | assert_eq_float(map.stack_leniency, 0.7); 67 | assert_eq!(map.breaks.len(), 0); 68 | } 69 | 70 | #[test] 71 | fn mania() { 72 | let map = Beatmap::from_path(common::MANIA).unwrap(); 73 | 74 | assert_eq!(map.mode, GameMode::Mania); 75 | assert_eq!(map.version, 14); 76 | assert_eq_float(map.ar, 5.0); 77 | assert_eq_float(map.od, 8.0); 78 | assert_eq_float(map.cs, 4.0); 79 | assert_eq_float(map.hp, 8.0); 80 | assert_eq_float(map.slider_multiplier, 1.4); 81 | assert_eq_float(map.slider_tick_rate, 1.0); 82 | assert_eq!(map.hit_objects.len(), 594); 83 | assert_eq!(map.hit_sounds.len(), 594); 84 | assert_eq!(map.timing_points.len(), 1); 85 | assert_eq!(map.difficulty_points.len(), 0); 86 | assert_eq!(map.effect_points.len(), 0); 87 | assert_eq_float(map.stack_leniency, 0.7); 88 | assert_eq!(map.breaks.len(), 0); 89 | } 90 | 91 | #[test] 92 | fn empty_osu() { 93 | let map = Beatmap::from_bytes(&[]).unwrap(); 94 | let _ = map.convert(GameMode::Osu, &GameMods::default()); 95 | } 96 | 97 | #[test] 98 | fn empty_taiko() { 99 | let map = Beatmap::from_bytes(&[]).unwrap(); 100 | let _ = map.convert(GameMode::Taiko, &GameMods::default()); 101 | } 102 | 103 | #[test] 104 | fn empty_catch() { 105 | let map = Beatmap::from_bytes(&[]).unwrap(); 106 | let _ = map.convert(GameMode::Catch, &GameMods::default()); 107 | } 108 | 109 | #[test] 110 | fn empty_mania() { 111 | let map = Beatmap::from_bytes(&[]).unwrap(); 112 | let _ = map.convert(GameMode::Mania, &GameMods::default()); 113 | } 114 | --------------------------------------------------------------------------------