initial rewrite

This commit is contained in:
MaxOhn
2024-02-09 17:54:14 +01:00
parent ecf1b2c7f4
commit a8bf9601b6
190 changed files with 10892 additions and 18142 deletions
+2 -16
View File
@@ -12,13 +12,6 @@ jobs:
name: Clippy
runs-on: ubuntu-latest
strategy:
matrix:
feature:
- default,gradual
- async_tokio
- async_std
steps:
- name: Checkout project
uses: actions/checkout@v3
@@ -30,19 +23,12 @@ jobs:
uses: Swatinem/rust-cache@v2
- name: Run clippy
run: cargo clippy --no-default-features --features ${{ matrix.feature }} --all-targets
run: cargo clippy --all-targets
tests:
name: Tests
runs-on: ubuntu-latest
strategy:
matrix:
feature:
- default,gradual
- async_tokio
- async_std
steps:
- name: Checkout project
uses: actions/checkout@v3
@@ -57,7 +43,7 @@ jobs:
uses: taiki-e/install-action@nextest
- name: Run tests
run: cargo nextest run --no-default-features --features ${{ matrix.feature }} --no-fail-fast --failure-output=immediate-final
run: cargo nextest run --no-fail-fast --failure-output=immediate-final
- name: Run doctests
run: cargo test --doc
+1 -10
View File
@@ -2,13 +2,4 @@
Cargo.lock
output*
/.idea
/tests/custom.rs
/.idea
/pp-gen/.env
/pp-gen/target
/pp-gen/output.json
/pp-plot/.env
/pp-plot/target
/pp-plot/accuracy_*
/tests/custom.rs
+4 -32
View File
@@ -1,40 +1,12 @@
[package]
name = "rosu-pp"
version = "0.10.0"
authors = ["MaxOhn <ohn.m@hotmail.de>"]
version = "0.1.0"
edition = "2021"
license = "MIT"
readme = "README.md"
repository = "https://github.com/MaxOhn/rosu-pp"
documentation = "https://docs.rs/rosu-pp/"
description = "osu! difficulty and pp calculation for all modes"
keywords = ["osu", "pp", "stars", "async"]
[features]
default = []
async_std = ["async-std"]
async_tokio = ["tokio"]
gradual = []
tracing = ["rosu-map/tracing"]
[dependencies]
async-std = { version = "1.9", optional = true }
tokio = { version = "1.2", optional = true, default-features = false, features = ["fs", "io-util"] }
[dev-dependencies]
proptest = "1.3.1"
tokio = { version = "1.2", default-features = false, features = ["fs", "rt"] }
[profile.test]
opt-level = 2
[profile.test.package.proptest]
opt-level = 3
[profile.test.package.rand_chacha]
opt-level = 3
[package.metadata.docs.rs]
# document these features
features = ["gradual"]
# defines the configuration attribute `docsrs`
rustdoc-args = ["--cfg", "docsrs"]
rosu-map = { path = "../rosu-map" }
thiserror = { version = "1.0.50" }
+2
View File
@@ -2,6 +2,8 @@
# rosu-pp
TODO: Rewrite readme
A standalone crate to calculate star ratings and performance points for all [osu!](https://osu.ppy.sh/home) gamemodes.
Async is supported through features, see below.
-1
View File
@@ -1 +0,0 @@
MAP_PATH="path/to/.osu/files"
-13
View File
@@ -1,13 +0,0 @@
[package]
name = "pp-plot"
version = "0.1.0"
edition = "2021"
[dependencies]
dotenv = { version = "0.15", default-features = false }
futures = { version = "0.3", default-features = false, features = ["std"] }
plotters = { version = "0.3" }
rosu-pp = { path = "..", features = ["async_tokio"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0" }
tokio = { version = "1.0", default-features = false, features = ["fs", "rt-multi-thread"] }
-9
View File
@@ -1,9 +0,0 @@
# pp-gen
Small script to plot value differences between `rosu-pp`'s performance attributes and `pp-gen`'s `output.json`.
### How to use
- Rename `.env.example` to `.env` and put proper values for its variables:
- `MAP_PATH` is the path to the folder containing a bunch of `{map_id}.osu` files
- Run `cargo run --release`. The program will read the file at `../pp-gen/output.json`, calculate `rosu-pp` values, and plot the differences in the files `accuracy_{mode}.svg`.
-174
View File
@@ -1,174 +0,0 @@
<svg width="1024" height="256" viewBox="0 0 1024 256" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="1023" height="255" opacity="1" fill="#FFFFFF" stroke="none"/>
<text x="512" y="10" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="16.129032258064516" opacity="1" fill="#000000">
fruits (98 data points)
</text>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="104" y1="210" x2="104" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="114" y1="210" x2="114" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="124" y1="210" x2="124" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="133" y1="210" x2="133" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="143" y1="210" x2="143" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="153" y1="210" x2="153" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="162" y1="210" x2="162" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="172" y1="210" x2="172" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="182" y1="210" x2="182" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="191" y1="210" x2="191" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="201" y1="210" x2="201" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="211" y1="210" x2="211" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="221" y1="210" x2="221" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="230" y1="210" x2="230" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="240" y1="210" x2="240" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="250" y1="210" x2="250" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="259" y1="210" x2="259" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="269" y1="210" x2="269" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="279" y1="210" x2="279" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="288" y1="210" x2="288" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="298" y1="210" x2="298" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="308" y1="210" x2="308" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="317" y1="210" x2="317" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="327" y1="210" x2="327" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="337" y1="210" x2="337" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="347" y1="210" x2="347" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="356" y1="210" x2="356" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="366" y1="210" x2="366" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="376" y1="210" x2="376" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="385" y1="210" x2="385" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="395" y1="210" x2="395" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="405" y1="210" x2="405" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="414" y1="210" x2="414" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="424" y1="210" x2="424" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="434" y1="210" x2="434" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="444" y1="210" x2="444" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="453" y1="210" x2="453" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="463" y1="210" x2="463" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="473" y1="210" x2="473" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="482" y1="210" x2="482" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="492" y1="210" x2="492" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="502" y1="210" x2="502" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="511" y1="210" x2="511" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="521" y1="210" x2="521" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="531" y1="210" x2="531" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="540" y1="210" x2="540" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="550" y1="210" x2="550" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="560" y1="210" x2="560" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="570" y1="210" x2="570" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="579" y1="210" x2="579" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="589" y1="210" x2="589" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="599" y1="210" x2="599" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="608" y1="210" x2="608" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="618" y1="210" x2="618" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="628" y1="210" x2="628" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="637" y1="210" x2="637" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="647" y1="210" x2="647" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="657" y1="210" x2="657" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="667" y1="210" x2="667" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="676" y1="210" x2="676" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="686" y1="210" x2="686" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="696" y1="210" x2="696" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="705" y1="210" x2="705" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="715" y1="210" x2="715" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="725" y1="210" x2="725" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="734" y1="210" x2="734" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="744" y1="210" x2="744" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="754" y1="210" x2="754" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="763" y1="210" x2="763" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="773" y1="210" x2="773" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="783" y1="210" x2="783" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="793" y1="210" x2="793" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="802" y1="210" x2="802" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="812" y1="210" x2="812" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="822" y1="210" x2="822" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="831" y1="210" x2="831" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="841" y1="210" x2="841" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="851" y1="210" x2="851" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="860" y1="210" x2="860" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="870" y1="210" x2="870" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="880" y1="210" x2="880" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="890" y1="210" x2="890" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="899" y1="210" x2="899" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="909" y1="210" x2="909" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="919" y1="210" x2="919" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="928" y1="210" x2="928" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="938" y1="210" x2="938" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="948" y1="210" x2="948" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="957" y1="210" x2="957" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="967" y1="210" x2="967" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="977" y1="210" x2="977" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="986" y1="210" x2="986" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="996" y1="210" x2="996" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="1006" y1="210" x2="1006" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="165" x2="1009" y2="165"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="75" x2="1009" y2="75"/>
<text x="552" y="251" dy="-0.5ex" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
Away from actual value
</text>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="191" y1="210" x2="191" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="288" y1="210" x2="288" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="385" y1="210" x2="385" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="482" y1="210" x2="482" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="579" y1="210" x2="579" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="676" y1="210" x2="676" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="773" y1="210" x2="773" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="870" y1="210" x2="870" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="967" y1="210" x2="967" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="165" x2="1009" y2="165"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="75" x2="1009" y2="75"/>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="94,31 94,211 "/>
<text x="85" y="165" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
&quot;pp&quot;
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,165 94,165 "/>
<text x="85" y="75" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
&quot;stars&quot;
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,75 94,75 "/>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="95,211 1009,211 "/>
<text x="191" y="221" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.005
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="191,211 191,216 "/>
<text x="288" y="221" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.01
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="288,211 288,216 "/>
<text x="385" y="221" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.015
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="385,211 385,216 "/>
<text x="482" y="221" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.02
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="482,211 482,216 "/>
<text x="579" y="221" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.025
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="579,211 579,216 "/>
<text x="676" y="221" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.03
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="676,211 676,216 "/>
<text x="773" y="221" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.035
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="773,211 773,216 "/>
<text x="870" y="221" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.04
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="870,211 870,216 "/>
<text x="967" y="221" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.045
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="967,211 967,216 "/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="160" x2="95" y2="170"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="165" x2="100" y2="165"/>
<rect x="100" y="155" width="303" height="20" opacity="1" fill="none" stroke="#000000"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="133" y1="155" x2="133" y2="175"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="403" y1="165" x2="856" y2="165"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="856" y1="160" x2="856" y2="170"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="70" x2="95" y2="80"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="75" x2="95" y2="75"/>
<rect x="95" y="65" width="2" height="20" opacity="1" fill="none" stroke="#000000"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="65" x2="95" y2="85"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="97" y1="75" x2="101" y2="75"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="101" y1="70" x2="101" y2="80"/>
</svg>

Before

Width:  |  Height:  |  Size: 14 KiB

-162
View File
@@ -1,162 +0,0 @@
<svg width="1024" height="512" viewBox="0 0 1024 512" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="1023" height="511" opacity="1" fill="#FFFFFF" stroke="none"/>
<text x="512" y="10" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="16.129032258064516" opacity="1" fill="#000000">
mania (168 data points)
</text>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="112" y1="466" x2="112" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="129" y1="466" x2="129" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="146" y1="466" x2="146" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="163" y1="466" x2="163" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="180" y1="466" x2="180" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="197" y1="466" x2="197" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="215" y1="466" x2="215" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="232" y1="466" x2="232" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="249" y1="466" x2="249" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="266" y1="466" x2="266" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="283" y1="466" x2="283" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="300" y1="466" x2="300" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="317" y1="466" x2="317" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="335" y1="466" x2="335" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="352" y1="466" x2="352" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="369" y1="466" x2="369" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="386" y1="466" x2="386" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="403" y1="466" x2="403" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="420" y1="466" x2="420" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="438" y1="466" x2="438" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="455" y1="466" x2="455" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="472" y1="466" x2="472" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="489" y1="466" x2="489" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="506" y1="466" x2="506" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="523" y1="466" x2="523" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="540" y1="466" x2="540" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="558" y1="466" x2="558" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="575" y1="466" x2="575" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="592" y1="466" x2="592" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="609" y1="466" x2="609" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="626" y1="466" x2="626" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="643" y1="466" x2="643" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="660" y1="466" x2="660" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="678" y1="466" x2="678" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="695" y1="466" x2="695" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="712" y1="466" x2="712" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="729" y1="466" x2="729" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="746" y1="466" x2="746" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="763" y1="466" x2="763" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="781" y1="466" x2="781" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="798" y1="466" x2="798" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="815" y1="466" x2="815" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="832" y1="466" x2="832" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="849" y1="466" x2="849" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="866" y1="466" x2="866" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="883" y1="466" x2="883" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="901" y1="466" x2="901" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="918" y1="466" x2="918" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="935" y1="466" x2="935" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="952" y1="466" x2="952" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="969" y1="466" x2="969" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="986" y1="466" x2="986" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="1004" y1="466" x2="1004" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="411" x2="1009" y2="411"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="302" x2="1009" y2="302"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="193" x2="1009" y2="193"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="85" x2="1009" y2="85"/>
<text x="552" y="507" dy="-0.5ex" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
Away from actual value
</text>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="180" y1="466" x2="180" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="266" y1="466" x2="266" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="352" y1="466" x2="352" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="438" y1="466" x2="438" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="523" y1="466" x2="523" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="609" y1="466" x2="609" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="695" y1="466" x2="695" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="781" y1="466" x2="781" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="866" y1="466" x2="866" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="952" y1="466" x2="952" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="411" x2="1009" y2="411"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="302" x2="1009" y2="302"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="193" x2="1009" y2="193"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="85" x2="1009" y2="85"/>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="94,31 94,467 "/>
<text x="85" y="411" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
&quot;strain pp&quot;
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,411 94,411 "/>
<text x="85" y="302" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
&quot;accuracy pp&quot;
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,302 94,302 "/>
<text x="85" y="193" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
&quot;pp&quot;
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,193 94,193 "/>
<text x="85" y="85" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
&quot;stars&quot;
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,85 94,85 "/>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="95,467 1009,467 "/>
<text x="180" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.0
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="180,467 180,472 "/>
<text x="266" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.0
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="266,467 266,472 "/>
<text x="352" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.0
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="352,467 352,472 "/>
<text x="438" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.0
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="438,467 438,472 "/>
<text x="523" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.0
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="523,467 523,472 "/>
<text x="609" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.0
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="609,467 609,472 "/>
<text x="695" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.0
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="695,467 695,472 "/>
<text x="781" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.0
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="781,467 781,472 "/>
<text x="866" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.0
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="866,467 866,472 "/>
<text x="952" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.0
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="952,467 952,472 "/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="406" x2="95" y2="416"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="411" x2="95" y2="411"/>
<rect x="95" y="401" width="304" height="20" opacity="1" fill="none" stroke="#000000"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="401" x2="95" y2="421"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="399" y1="411" x2="856" y2="411"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="856" y1="406" x2="856" y2="416"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="297" x2="95" y2="307"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="302" x2="95" y2="302"/>
<rect x="95" y="292" width="76" height="20" opacity="1" fill="none" stroke="#000000"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="292" x2="95" y2="312"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="171" y1="302" x2="285" y2="302"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="285" y1="297" x2="285" y2="307"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="188" x2="95" y2="198"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="193" x2="95" y2="193"/>
<rect x="95" y="183" width="304" height="20" opacity="1" fill="none" stroke="#000000"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="183" x2="95" y2="203"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="399" y1="193" x2="856" y2="193"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="856" y1="188" x2="856" y2="198"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="80" x2="95" y2="90"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="85" x2="95" y2="85"/>
<rect x="95" y="75" width="9" height="20" opacity="1" fill="none" stroke="#000000"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="75" x2="95" y2="95"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="104" y1="85" x2="118" y2="85"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="118" y1="80" x2="118" y2="90"/>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

-165
View File
@@ -1,165 +0,0 @@
<svg width="1024" height="768" viewBox="0 0 1024 768" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="1023" height="767" opacity="1" fill="#FFFFFF" stroke="none"/>
<text x="512" y="10" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="16.129032258064516" opacity="1" fill="#000000">
osu (4908 data points)
</text>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="111" y1="722" x2="111" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="127" y1="722" x2="127" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="143" y1="722" x2="143" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="159" y1="722" x2="159" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="175" y1="722" x2="175" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="191" y1="722" x2="191" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="207" y1="722" x2="207" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="223" y1="722" x2="223" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="239" y1="722" x2="239" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="255" y1="722" x2="255" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="271" y1="722" x2="271" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="287" y1="722" x2="287" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="303" y1="722" x2="303" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="319" y1="722" x2="319" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="335" y1="722" x2="335" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="351" y1="722" x2="351" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="367" y1="722" x2="367" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="383" y1="722" x2="383" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="399" y1="722" x2="399" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="415" y1="722" x2="415" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="431" y1="722" x2="431" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="447" y1="722" x2="447" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="463" y1="722" x2="463" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="479" y1="722" x2="479" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="495" y1="722" x2="495" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="511" y1="722" x2="511" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="527" y1="722" x2="527" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="543" y1="722" x2="543" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="559" y1="722" x2="559" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="575" y1="722" x2="575" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="591" y1="722" x2="591" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="607" y1="722" x2="607" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="623" y1="722" x2="623" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="639" y1="722" x2="639" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="655" y1="722" x2="655" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="671" y1="722" x2="671" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="687" y1="722" x2="687" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="703" y1="722" x2="703" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="719" y1="722" x2="719" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="735" y1="722" x2="735" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="751" y1="722" x2="751" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="767" y1="722" x2="767" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="783" y1="722" x2="783" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="799" y1="722" x2="799" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="815" y1="722" x2="815" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="831" y1="722" x2="831" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="847" y1="722" x2="847" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="863" y1="722" x2="863" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="879" y1="722" x2="879" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="895" y1="722" x2="895" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="911" y1="722" x2="911" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="927" y1="722" x2="927" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="943" y1="722" x2="943" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="959" y1="722" x2="959" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="975" y1="722" x2="975" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="991" y1="722" x2="991" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="1007" y1="722" x2="1007" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="664" x2="1009" y2="664"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="549" x2="1009" y2="549"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="433" x2="1009" y2="433"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="318" x2="1009" y2="318"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="202" x2="1009" y2="202"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="88" x2="1009" y2="88"/>
<text x="552" y="763" dy="-0.5ex" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
Away from actual value
</text>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="255" y1="722" x2="255" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="415" y1="722" x2="415" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="575" y1="722" x2="575" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="735" y1="722" x2="735" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="895" y1="722" x2="895" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="664" x2="1009" y2="664"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="549" x2="1009" y2="549"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="433" x2="1009" y2="433"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="318" x2="1009" y2="318"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="202" x2="1009" y2="202"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="88" x2="1009" y2="88"/>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="94,31 94,723 "/>
<text x="85" y="664" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
&quot;speed pp&quot;
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,664 94,664 "/>
<text x="85" y="549" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
&quot;flashlight pp&quot;
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,549 94,549 "/>
<text x="85" y="433" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
&quot;aim pp&quot;
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,433 94,433 "/>
<text x="85" y="318" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
&quot;accuracy pp&quot;
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,318 94,318 "/>
<text x="85" y="202" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
&quot;pp&quot;
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,202 94,202 "/>
<text x="85" y="88" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
&quot;stars&quot;
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,88 94,88 "/>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="95,723 1009,723 "/>
<text x="255" y="733" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.005
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="255,723 255,728 "/>
<text x="415" y="733" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.01
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="415,723 415,728 "/>
<text x="575" y="733" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.015
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="575,723 575,728 "/>
<text x="735" y="733" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.02
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="735,723 735,728 "/>
<text x="895" y="733" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.025
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="895,723 895,728 "/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="659" x2="95" y2="669"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="664" x2="95" y2="664"/>
<rect x="95" y="654" width="0" height="20" opacity="1" fill="none" stroke="#000000"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="654" x2="95" y2="674"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="664" x2="95" y2="664"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="659" x2="95" y2="669"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="544" x2="95" y2="554"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="549" x2="101" y2="549"/>
<rect x="101" y="539" width="302" height="20" opacity="1" fill="none" stroke="#000000"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="165" y1="539" x2="165" y2="559"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="403" y1="549" x2="856" y2="549"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="856" y1="544" x2="856" y2="554"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="428" x2="95" y2="438"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="433" x2="95" y2="433"/>
<rect x="95" y="423" width="0" height="20" opacity="1" fill="none" stroke="#000000"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="423" x2="95" y2="443"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="433" x2="95" y2="433"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="428" x2="95" y2="438"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="313" x2="95" y2="323"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="318" x2="95" y2="318"/>
<rect x="95" y="308" width="0" height="20" opacity="1" fill="none" stroke="#000000"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="308" x2="95" y2="328"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="318" x2="95" y2="318"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="313" x2="95" y2="323"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="197" x2="95" y2="207"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="202" x2="95" y2="202"/>
<rect x="95" y="192" width="0" height="20" opacity="1" fill="none" stroke="#000000"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="192" x2="95" y2="212"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="202" x2="95" y2="202"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="197" x2="95" y2="207"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="83" x2="95" y2="93"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="88" x2="95" y2="88"/>
<rect x="95" y="78" width="0" height="20" opacity="1" fill="none" stroke="#000000"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="78" x2="95" y2="98"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="88" x2="95" y2="88"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="83" x2="95" y2="93"/>
</svg>

Before

Width:  |  Height:  |  Size: 13 KiB

-456
View File
@@ -1,456 +0,0 @@
use std::{env, error::Error as StdError, fmt, fs::File as StdFile};
use futures::{stream::FuturesUnordered, TryStreamExt};
use plotters::{
data::fitting_range,
prelude::{
Boxplot, ChartBuilder, DrawingAreaErrorKind, IntoDrawingArea, IntoSegmentedCoord,
Quartiles, SVGBackend, SegmentValue,
},
style::WHITE,
};
use rosu_pp::{Beatmap, BeatmapExt, PerformanceAttributes};
use serde::Deserialize;
use tokio::{fs::File, runtime::Runtime};
fn main() {
dotenv::dotenv().expect("failed to read .env file");
Runtime::new()
.expect("failed to create runtime")
.block_on(async_main());
}
async fn async_main() {
let map_path_ = env::var("MAP_PATH").expect("missing `MAP_PATH` environment variable");
let map_path = map_path_.as_str();
println!("Deserializing data from output.json...");
let file = StdFile::open("../pp-gen/output.json").expect("failed to open `output.json` file");
let data: Vec<SimulateData> =
serde_json::from_reader(file).expect("failed to deserialize data");
println!(
"Calculating values for {} map-mod combinations...",
data.len()
);
// Calculate rosu-pp's PerformanceAttributes on all map-mod pairs
let result = data
.into_iter()
.map(|data| async move {
let path = format!("{}/{}.osu", map_path, data.score.map_id);
let file = File::open(path).await?;
let map = Beatmap::parse(file).await?;
let mods = parse_mods(&data.score.mods);
let attrs = map.max_pp(mods);
Ok::<_, Error>((data, attrs, mods))
})
.collect::<FuturesUnordered<_>>()
.try_collect::<Vec<_>>()
.await;
let tuples = match result {
Ok(attrs) => attrs,
Err(err) => return print_err(err),
};
println!("Evaluating values...");
// Compare the values from output.json with the PerformanceAttribute values
let mut evaluators = [
Evaluator::new("osu"),
Evaluator::new("taiko"),
Evaluator::new("catch"),
Evaluator::new("mania"),
];
for (data, attributes, mods) in tuples {
evaluators[data.score.mode as usize].process(data, attributes, mods);
}
for evaluator in evaluators {
let mode = evaluator.mode;
if let Err(err) = evaluator.plot() {
eprintln!("failed to plot for {}", mode);
print_err(err);
}
}
}
/// Mode specific evaluator containing differences
/// of values from `Data` and `PerformanceAttributes`.
#[derive(Default)]
struct Evaluator {
mode: &'static str,
count: usize,
aim: Option<Vec<f64>>,
accuracy: Option<Vec<f64>>,
flashlight: Option<Vec<f64>>,
speed: Option<Vec<f64>>,
strain: Option<Vec<f64>>,
stars: Vec<f64>,
pp: Vec<f64>,
}
impl Evaluator {
fn new(mode: &'static str) -> Self {
Self {
mode,
..Default::default()
}
}
/// For all mode-specific data points, calculate the
/// differences of `data`'s value and `attrs`' value
fn process(&mut self, data: SimulateData, attrs: PerformanceAttributes, mods: u32) {
self.count += 1;
self.stars
.push(difference(data.difficulty.stars, attrs.stars()));
self.pp.push(difference(data.performance.pp, attrs.pp()));
match attrs {
PerformanceAttributes::Catch(_) => {}
PerformanceAttributes::Mania(attrs) => {
if let Some(acc) = data.performance.acc {
let values = self.accuracy.get_or_insert_with(Vec::new);
let entry = difference(acc, attrs.pp_acc);
values.push(entry);
}
if let Some(strain) = data.performance.difficulty {
let values = self.strain.get_or_insert_with(Vec::new);
let entry = difference(strain, attrs.pp_strain);
values.push(entry);
}
}
PerformanceAttributes::Osu(attrs) => {
if let Some(acc) = data.performance.acc {
let values = self.accuracy.get_or_insert_with(Vec::new);
let entry = difference(acc, attrs.pp_acc);
values.push(entry);
}
if let Some(aim) = data.performance.aim {
let values = self.aim.get_or_insert_with(Vec::new);
let entry = difference(aim, attrs.pp_aim);
values.push(entry);
}
if mods & 1024 > 0 {
if let Some(flashlight) = data.performance.flashlight {
let values = self.flashlight.get_or_insert_with(Vec::new);
let entry = difference(flashlight, attrs.pp_flashlight);
values.push(entry);
}
}
if let Some(speed) = data.performance.speed {
let values = self.speed.get_or_insert_with(Vec::new);
let entry = difference(speed, attrs.pp_speed);
values.push(entry);
}
}
PerformanceAttributes::Taiko(attrs) => {
if let Some(acc) = data.performance.acc {
let values = self.accuracy.get_or_insert_with(Vec::new);
let entry = difference(acc, attrs.pp_acc);
values.push(entry);
}
if let Some(strain) = data.performance.difficulty {
let values = self.strain.get_or_insert_with(Vec::new);
let entry = difference(strain, attrs.pp_strain);
values.push(entry);
}
}
}
}
/// Plot all gathered differences
fn plot(self) -> Result<(), Error> {
let mode = self.mode;
let output_path = format!("accuracy_{}.svg", mode);
let dataset = self.to_quartiles();
let kind_list: Vec<_> = dataset.iter().map(|(kind, _)| *kind).collect();
let height = kind_list.len() as u32 * 128;
let root = SVGBackend::new(&output_path, (1024, height)).into_drawing_area();
root.fill(&WHITE)?;
let root = root.margin(5, 5, 15, 15);
let values = dataset
.iter()
.map(|(_, quartiles)| quartiles.values())
.flatten()
.collect::<Vec<_>>();
let values_range = fitting_range(values.iter());
let caption = format!("{} ({} data points)", mode, self.count);
// Set the chart structure
let mut chart = ChartBuilder::on(&root)
.x_label_area_size(40)
.y_label_area_size(80)
.caption(caption, ("sans-serif", 20))
.build_cartesian_2d(
0.0..values_range.end + values_range.end * 0.2,
kind_list[..].into_segmented(),
)?;
chart
.configure_mesh()
.x_desc("Away from actual value")
.y_labels(kind_list.len())
.light_line_style(&WHITE)
.draw()?;
// Insert data into the chart
for (kind, quartile) in dataset.iter() {
chart.draw_series(std::iter::once(
Boxplot::new_horizontal(SegmentValue::CenterOf(kind), quartile)
.width(20)
.whisker_width(0.5),
))?;
}
root.present()?;
Ok(())
}
fn to_quartiles(&self) -> Vec<(&'static str, Quartiles)> {
let mut vec = Vec::new();
println!("---");
let max = self
.stars
.iter()
.fold(0.0, |m, &n| if n > m { n } else { m });
let avg = self.stars.iter().copied().sum::<f64>() / self.stars.len() as f64;
println!("[{}] Stars: average={} | max={}", self.mode, avg, max);
vec.push(("stars", Quartiles::new(&self.stars)));
let max = self.pp.iter().fold(0.0, |m, &n| if n > m { n } else { m });
let avg = self.pp.iter().copied().sum::<f64>() / self.pp.len() as f64;
println!("[{}] PP: average={} | max={}", self.mode, avg, max);
vec.push(("pp", Quartiles::new(&self.pp)));
if let Some(ref acc) = self.accuracy {
if !acc.is_empty() {
let max = acc.iter().fold(0.0, |m, &n| if n > m { n } else { m });
let avg = acc.iter().copied().sum::<f64>() / acc.len() as f64;
println!("[{}] Accuracy: average={} | max={}", self.mode, avg, max);
}
vec.push(("accuracy pp", Quartiles::new(acc)));
}
if let Some(ref aim) = self.aim {
if !aim.is_empty() {
let max = aim.iter().fold(0.0, |m, &n| if n > m { n } else { m });
let avg = aim.iter().copied().sum::<f64>() / aim.len() as f64;
println!("[{}] Aim: average={} | max={}", self.mode, avg, max);
}
vec.push(("aim pp", Quartiles::new(aim)));
}
if let Some(ref fl) = self.flashlight {
if !fl.is_empty() {
let max = fl.iter().fold(0.0, |m, &n| if n > m { n } else { m });
let avg = fl.iter().copied().sum::<f64>() / fl.len() as f64;
println!("[{}] Flashlight: average={} | max={}", self.mode, avg, max);
}
vec.push(("flashlight pp", Quartiles::new(fl)));
}
if let Some(ref speed) = self.speed {
if !speed.is_empty() {
let max = speed.iter().fold(0.0, |m, &n| if n > m { n } else { m });
let avg = speed.iter().copied().sum::<f64>() / speed.len() as f64;
println!("[{}] Speed: average={} | max={}", self.mode, avg, max);
}
vec.push(("speed pp", Quartiles::new(speed)));
}
if let Some(ref strain) = self.strain {
if !strain.is_empty() {
let max = strain.iter().fold(0.0, |m, &n| if n > m { n } else { m });
let avg = strain.iter().copied().sum::<f64>() / strain.len() as f64;
println!("[{}] Strain: average={} | max={}", self.mode, avg, max);
}
vec.push(("strain pp", Quartiles::new(strain)));
}
vec.reverse();
vec
}
}
#[derive(Debug)]
enum Error {
DrawingArea(String),
Io(std::io::Error),
ParseMap(rosu_pp::ParseError),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DrawingArea(src) => write!(f, "drawing area error: {}", src),
Self::Io(_) => f.write_str("io error"),
Self::ParseMap(_) => f.write_str("failed to parse map"),
}
}
}
impl StdError for Error {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
match self {
Self::DrawingArea(_) => None,
Self::Io(src) => Some(src),
Self::ParseMap(src) => Some(src),
}
}
}
impl<E: StdError + Send + Sync> From<DrawingAreaErrorKind<E>> for Error {
fn from(e: DrawingAreaErrorKind<E>) -> Self {
Self::DrawingArea(e.to_string())
}
}
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
impl From<rosu_pp::ParseError> for Error {
fn from(e: rosu_pp::ParseError) -> Self {
Self::ParseMap(e)
}
}
fn difference(actual: f64, calculated: f64) -> f64 {
(actual - calculated).abs()
}
fn parse_mods(mods_list: &[String]) -> u32 {
let mut mods = 0;
for m in mods_list {
match m.as_str() {
"NF" => mods += 1,
"EZ" => mods += 2,
"TD" => mods += 4,
"HD" => mods += 8,
"HR" => mods += 16,
"DT" => mods += 64,
"RX" => mods += 128,
"HT" => mods += 256,
"FL" => mods += 1024,
_ => panic!("unrecognized mod: {}", m),
}
}
mods
}
fn print_err(err: Error) {
let mut e: &dyn StdError = &err;
eprintln!("{}", err);
while let Some(src) = e.source() {
eprintln!(" - caused by: {}", src);
e = src;
}
}
#[derive(Deserialize)]
struct SimulateData {
score: Score,
performance: Performance,
difficulty: Difficulty,
}
#[derive(Deserialize)]
struct Score {
mode: u32,
map_id: u32,
mods: Vec<String>,
total_score: u32,
acc: f64,
combo: u32,
stats: Statistics,
}
#[derive(Deserialize)]
struct Statistics {
#[serde(default)]
perfect: usize,
great: usize,
#[serde(default)]
good: usize,
ok: usize,
meh: usize,
miss: usize,
}
#[derive(Deserialize)]
struct Performance {
#[serde(default)]
aim: Option<f64>,
#[serde(default)]
speed: Option<f64>,
#[serde(default)]
acc: Option<f64>,
#[serde(default)]
flashlight: Option<f64>,
#[serde(default)]
effective_miss_count: Option<f64>,
#[serde(default)]
scaled_score: Option<f64>,
#[serde(default)]
difficulty: Option<f64>,
pp: f64,
}
#[derive(Deserialize)]
struct Difficulty {
stars: f64,
max_combo: u32,
#[serde(default)]
aim: Option<f64>,
#[serde(default)]
speed: Option<f64>,
#[serde(default)]
flashlight: Option<f64>,
#[serde(default)]
slider_factor: Option<f64>,
#[serde(default)]
stamina: Option<f64>,
#[serde(default)]
rhythm: Option<f64>,
#[serde(default)]
colour: Option<f64>,
#[serde(default)]
ar: Option<f64>,
#[serde(default)]
od: Option<f64>,
#[serde(default)]
great_hit_window: Option<f64>,
#[serde(default)]
score_multiplier: Option<f64>,
}
-154
View File
@@ -1,154 +0,0 @@
<svg width="1024" height="512" viewBox="0 0 1024 512" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="1023" height="511" opacity="1" fill="#FFFFFF" stroke="none"/>
<text x="512" y="10" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="16.129032258064516" opacity="1" fill="#000000">
taiko (343 data points)
</text>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="108" y1="466" x2="108" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="122" y1="466" x2="122" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="136" y1="466" x2="136" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="150" y1="466" x2="150" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="164" y1="466" x2="164" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="178" y1="466" x2="178" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="192" y1="466" x2="192" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="206" y1="466" x2="206" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="220" y1="466" x2="220" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="234" y1="466" x2="234" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="248" y1="466" x2="248" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="262" y1="466" x2="262" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="276" y1="466" x2="276" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="290" y1="466" x2="290" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="304" y1="466" x2="304" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="318" y1="466" x2="318" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="332" y1="466" x2="332" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="346" y1="466" x2="346" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="360" y1="466" x2="360" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="374" y1="466" x2="374" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="388" y1="466" x2="388" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="402" y1="466" x2="402" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="416" y1="466" x2="416" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="430" y1="466" x2="430" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="444" y1="466" x2="444" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="458" y1="466" x2="458" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="472" y1="466" x2="472" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="486" y1="466" x2="486" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="499" y1="466" x2="499" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="513" y1="466" x2="513" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="527" y1="466" x2="527" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="541" y1="466" x2="541" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="555" y1="466" x2="555" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="569" y1="466" x2="569" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="583" y1="466" x2="583" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="597" y1="466" x2="597" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="611" y1="466" x2="611" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="625" y1="466" x2="625" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="639" y1="466" x2="639" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="653" y1="466" x2="653" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="667" y1="466" x2="667" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="681" y1="466" x2="681" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="695" y1="466" x2="695" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="709" y1="466" x2="709" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="723" y1="466" x2="723" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="737" y1="466" x2="737" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="751" y1="466" x2="751" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="765" y1="466" x2="765" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="779" y1="466" x2="779" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="793" y1="466" x2="793" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="807" y1="466" x2="807" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="821" y1="466" x2="821" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="835" y1="466" x2="835" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="849" y1="466" x2="849" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="863" y1="466" x2="863" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="877" y1="466" x2="877" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="891" y1="466" x2="891" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="904" y1="466" x2="904" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="918" y1="466" x2="918" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="932" y1="466" x2="932" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="946" y1="466" x2="946" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="960" y1="466" x2="960" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="974" y1="466" x2="974" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="988" y1="466" x2="988" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="1002" y1="466" x2="1002" y2="30"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="411" x2="1009" y2="411"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="302" x2="1009" y2="302"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="193" x2="1009" y2="193"/>
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="85" x2="1009" y2="85"/>
<text x="552" y="507" dy="-0.5ex" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
Away from actual value
</text>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="234" y1="466" x2="234" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="374" y1="466" x2="374" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="513" y1="466" x2="513" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="653" y1="466" x2="653" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="793" y1="466" x2="793" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="932" y1="466" x2="932" y2="30"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="411" x2="1009" y2="411"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="302" x2="1009" y2="302"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="193" x2="1009" y2="193"/>
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="85" x2="1009" y2="85"/>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="94,31 94,467 "/>
<text x="85" y="411" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
&quot;strain pp&quot;
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,411 94,411 "/>
<text x="85" y="302" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
&quot;accuracy pp&quot;
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,302 94,302 "/>
<text x="85" y="193" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
&quot;pp&quot;
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,193 94,193 "/>
<text x="85" y="85" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
&quot;stars&quot;
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,85 94,85 "/>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="95,467 1009,467 "/>
<text x="234" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
0.5
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="234,467 234,472 "/>
<text x="374" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
1.0
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="374,467 374,472 "/>
<text x="513" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
1.5
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="513,467 513,472 "/>
<text x="653" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
2.0
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="653,467 653,472 "/>
<text x="793" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
2.5
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="793,467 793,472 "/>
<text x="932" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
3.0
</text>
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="932,467 932,472 "/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="406" x2="95" y2="416"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="411" x2="95" y2="411"/>
<rect x="95" y="401" width="8" height="20" opacity="1" fill="none" stroke="#000000"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="96" y1="401" x2="96" y2="421"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="103" y1="411" x2="116" y2="411"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="116" y1="406" x2="116" y2="416"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="297" x2="95" y2="307"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="302" x2="95" y2="302"/>
<rect x="95" y="292" width="285" height="20" opacity="1" fill="none" stroke="#000000"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="292" x2="95" y2="312"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="380" y1="302" x2="809" y2="302"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="809" y1="297" x2="809" y2="307"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="188" x2="95" y2="198"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="193" x2="95" y2="193"/>
<rect x="95" y="183" width="305" height="20" opacity="1" fill="none" stroke="#000000"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="103" y1="183" x2="103" y2="203"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="400" y1="193" x2="856" y2="193"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="856" y1="188" x2="856" y2="198"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="80" x2="95" y2="90"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="85" x2="95" y2="85"/>
<rect x="95" y="75" width="0" height="20" opacity="1" fill="none" stroke="#000000"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="75" x2="95" y2="95"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="85" x2="95" y2="85"/>
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="80" x2="95" y2="90"/>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

+186
View File
@@ -0,0 +1,186 @@
use crate::{
catch::{Catch, CatchDifficultyAttributes, CatchPerformanceAttributes},
mania::{Mania, ManiaDifficultyAttributes, ManiaPerformanceAttributes},
model::mode::IGameMode,
osu::{Osu, OsuDifficultyAttributes, OsuPerformanceAttributes},
taiko::{Taiko, TaikoDifficultyAttributes, TaikoPerformanceAttributes},
};
use super::performance::Performance;
/// The result of a difficulty calculation based on the mode.
#[derive(Clone, Debug, PartialEq)]
pub enum DifficultyAttributes {
/// osu!standard difficulty calculation result.
Osu(OsuDifficultyAttributes),
/// osu!taiko difficulty calculation result.
Taiko(TaikoDifficultyAttributes),
/// osu!catch difficulty calculation result.
Catch(CatchDifficultyAttributes),
/// osu!mania difficulty calculation result.
Mania(ManiaDifficultyAttributes),
}
impl DifficultyAttributes {
/// The star value.
pub const fn stars(&self) -> f64 {
match self {
Self::Osu(attrs) => attrs.stars,
Self::Taiko(attrs) => attrs.stars,
Self::Catch(attrs) => attrs.stars,
Self::Mania(attrs) => attrs.stars,
}
}
/// The maximum combo of the map.
pub const fn max_combo(&self) -> u32 {
match self {
Self::Osu(attrs) => attrs.max_combo,
Self::Taiko(attrs) => attrs.max_combo,
Self::Catch(attrs) => attrs.max_combo(),
Self::Mania(attrs) => attrs.max_combo,
}
}
/// Returns a builder for performance calculation.
pub fn pp<'a>(self) -> Performance<'a> {
self.into()
}
}
/// The result of a performance calculation based on the mode.
#[derive(Clone, Debug, PartialEq)]
pub enum PerformanceAttributes {
/// osu!standard performance calculation result.
Osu(OsuPerformanceAttributes),
/// osu!taiko performance calculation result.
Taiko(TaikoPerformanceAttributes),
/// osu!catch performance calculation result.
Catch(CatchPerformanceAttributes),
/// osu!mania performance calculation result.
Mania(ManiaPerformanceAttributes),
}
impl PerformanceAttributes {
/// The pp value.
pub const fn pp(&self) -> f64 {
match self {
Self::Osu(attrs) => attrs.pp,
Self::Taiko(attrs) => attrs.pp,
Self::Catch(attrs) => attrs.pp,
Self::Mania(attrs) => attrs.pp,
}
}
/// The star value.
pub const fn stars(&self) -> f64 {
match self {
Self::Osu(attrs) => attrs.stars(),
Self::Taiko(attrs) => attrs.stars(),
Self::Catch(attrs) => attrs.stars(),
Self::Mania(attrs) => attrs.stars(),
}
}
/// Difficulty attributes that were used for the performance calculation.
pub fn difficulty_attributes(&self) -> DifficultyAttributes {
match self {
Self::Osu(attrs) => DifficultyAttributes::Osu(attrs.difficulty.clone()),
Self::Taiko(attrs) => DifficultyAttributes::Taiko(attrs.difficulty.clone()),
Self::Catch(attrs) => DifficultyAttributes::Catch(attrs.difficulty.clone()),
Self::Mania(attrs) => DifficultyAttributes::Mania(attrs.difficulty.clone()),
}
}
/// The maximum combo of the map.
pub const fn max_combo(&self) -> u32 {
match self {
Self::Osu(attrs) => attrs.difficulty.max_combo,
Self::Taiko(attrs) => attrs.difficulty.max_combo,
Self::Catch(attrs) => attrs.difficulty.max_combo(),
Self::Mania(attrs) => attrs.difficulty.max_combo,
}
}
}
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
pub trait AttributeProvider {
/// Provide the actual difficulty attributes.
fn attributes(self) -> DifficultyAttributes;
}
impl AttributeProvider for DifficultyAttributes {
fn attributes(self) -> DifficultyAttributes {
self
}
}
impl AttributeProvider for PerformanceAttributes {
fn attributes(self) -> DifficultyAttributes {
match self {
Self::Osu(attrs) => DifficultyAttributes::Osu(attrs.difficulty),
Self::Taiko(attrs) => DifficultyAttributes::Taiko(attrs.difficulty),
Self::Catch(attrs) => DifficultyAttributes::Catch(attrs.difficulty),
Self::Mania(attrs) => DifficultyAttributes::Mania(attrs.difficulty),
}
}
}
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
pub trait ModeAttributeProvider<M: IGameMode> {
/// Provide the actual difficulty attributes.
fn attributes(self) -> Option<M::DifficultyAttributes>;
}
impl<M: IGameMode> ModeAttributeProvider<M> for M::DifficultyAttributes {
fn attributes(self) -> Option<M::DifficultyAttributes> {
Some(self)
}
}
macro_rules! impl_attr_provider {
( $mode:ident: $difficulty:ident, $performance:ident ) => {
impl AttributeProvider for $difficulty {
fn attributes(self) -> DifficultyAttributes {
DifficultyAttributes::$mode(self)
}
}
impl AttributeProvider for $performance {
fn attributes(self) -> DifficultyAttributes {
DifficultyAttributes::$mode(self.difficulty)
}
}
impl ModeAttributeProvider<$mode> for $performance {
fn attributes(self) -> Option<<$mode as IGameMode>::DifficultyAttributes> {
Some(self.difficulty)
}
}
impl ModeAttributeProvider<$mode> for DifficultyAttributes {
fn attributes(self) -> Option<<$mode as IGameMode>::DifficultyAttributes> {
if let Self::$mode(attrs) = self {
Some(attrs)
} else {
None
}
}
}
impl ModeAttributeProvider<$mode> for PerformanceAttributes {
fn attributes(self) -> Option<<$mode as IGameMode>::DifficultyAttributes> {
if let Self::$mode(attrs) = self {
Some(attrs.difficulty)
} else {
None
}
}
}
};
}
impl_attr_provider!(Catch: CatchDifficultyAttributes, CatchPerformanceAttributes);
impl_attr_provider!(Mania: ManiaDifficultyAttributes, ManiaPerformanceAttributes);
impl_attr_provider!(Osu: OsuDifficultyAttributes, OsuPerformanceAttributes);
impl_attr_provider!(Taiko: TaikoDifficultyAttributes, TaikoPerformanceAttributes);
+210
View File
@@ -0,0 +1,210 @@
use std::borrow::Cow;
use rosu_map::section::general::GameMode;
use crate::{
catch::{Catch, CatchBeatmap},
mania::{Mania, ManiaBeatmap},
model::beatmap::{Beatmap, Converted},
osu::{Osu, OsuBeatmap},
taiko::{Taiko, TaikoBeatmap},
};
pub use self::mode::ModeDifficulty;
use super::attributes::DifficultyAttributes;
mod mode;
pub mod object;
pub mod skills;
/// Difficulty calculator on maps of any mode.
#[derive(Clone, Debug, PartialEq)]
pub struct Difficulty<'map> {
map: Cow<'map, Beatmap>,
is_convert: bool,
inner: ModeDifficulty,
}
impl<'map> Difficulty<'map> {
/// Create a new difficulty calculator for the given beatmap.
pub fn new(map: &'map Beatmap) -> Self {
Self::new_with_is_convert(Cow::Borrowed(map), false)
}
fn new_with_is_convert(map: Cow<'map, Beatmap>, is_convert: bool) -> Self {
Self {
map,
is_convert,
inner: ModeDifficulty::new(),
}
}
}
macro_rules! impl_from_mode {
( $mode:ident ) => {
impl<'a> From<Converted<'a, $mode>> for Difficulty<'a> {
fn from(converted: Converted<'a, $mode>) -> Self {
Self::new_with_is_convert(converted.map, converted.is_convert)
}
}
impl<'a, 'b: 'a> From<&'b Converted<'a, $mode>> for Difficulty<'a> {
fn from(converted: &'b Converted<'a, $mode>) -> Self {
Self::new_with_is_convert(
Cow::Borrowed(converted.map.as_ref()),
converted.is_convert,
)
}
}
};
}
impl_from_mode!(Osu);
impl_from_mode!(Taiko);
impl_from_mode!(Catch);
impl_from_mode!(Mania);
impl Difficulty<'_> {
/// Attempt to convert the map to the specified mode.
///
/// If the conversion is incompatible, `None` is returned.
///
/// If the given mode should be ignored in case it is incompatible, use
/// [`mode_or_ignore`] instead.
///
/// [`mode_or_ignore`]: Self::mode_or_ignore
pub fn try_mode(&mut self, mode: GameMode) -> Option<&mut Self> {
let (map, is_convert) = match mode {
GameMode::Osu => {
let converted = OsuBeatmap::try_from_ref(self.map.as_ref())?;
(converted.map, converted.is_convert)
}
GameMode::Taiko => {
let converted = TaikoBeatmap::try_from_ref(self.map.as_ref())?;
(converted.map, converted.is_convert)
}
GameMode::Catch => {
let converted = CatchBeatmap::try_from_ref(self.map.as_ref())?;
(converted.map, converted.is_convert)
}
GameMode::Mania => {
let converted = ManiaBeatmap::try_from_ref(self.map.as_ref())?;
(converted.map, converted.is_convert)
}
};
if matches!(map, Cow::Owned(_)) {
self.is_convert |= is_convert;
let map = map.into_owned();
self.map = Cow::Owned(map);
}
Some(self)
}
/// Attempt to convert the map to the specified mode.
///
/// If the conversion is incompatible, the map won't be modified.
///
/// To see whether the given mode is incompatible, use [`try_mode`]
/// instead.
///
/// [`try_mode`]: Self::try_mode
pub fn mode_or_ignore(&mut self, mode: GameMode) -> &mut Self {
let (map, is_convert) = match mode {
GameMode::Osu => {
let Some(converted) = OsuBeatmap::try_from_ref(self.map.as_ref()) else {
return self;
};
(converted.map, converted.is_convert)
}
GameMode::Taiko => {
let Some(converted) = TaikoBeatmap::try_from_ref(self.map.as_ref()) else {
return self;
};
(converted.map, converted.is_convert)
}
GameMode::Catch => {
let Some(converted) = CatchBeatmap::try_from_ref(self.map.as_ref()) else {
return self;
};
(converted.map, converted.is_convert)
}
GameMode::Mania => {
let Some(converted) = ManiaBeatmap::try_from_ref(self.map.as_ref()) else {
return self;
};
(converted.map, converted.is_convert)
}
};
if matches!(map, Cow::Owned(_)) {
self.is_convert |= is_convert;
let map = map.into_owned();
self.map = Cow::Owned(map);
}
self
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
pub fn mods(&mut self, mods: u32) -> &mut Self {
self.inner.mods(mods);
self
}
/// Amount of passed objects for partial plays, e.g. a fail.
pub fn passed_objects(&mut self, passed_objects: u32) -> &mut Self {
self.inner.passed_objects(passed_objects);
self
}
/// Adjust the clock rate used in the calculation.
///
/// If none is specified, it will take the clock rate based on the mods
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
pub fn clock_rate(&mut self, clock_rate: f64) -> &mut Self {
self.inner.clock_rate(clock_rate);
self
}
/// Perform the difficulty calculation.
///
/// The returned attributes depend on the map's mode.
pub fn calculate(&self) -> DifficultyAttributes {
let map = Cow::Borrowed(self.map.as_ref());
match self.map.mode {
GameMode::Osu => DifficultyAttributes::Osu(
self.inner
.calculate(&Converted::<Osu>::new(map, self.is_convert)),
),
GameMode::Taiko => DifficultyAttributes::Taiko(
self.inner
.calculate(&Converted::<Taiko>::new(map, self.is_convert)),
),
GameMode::Catch => DifficultyAttributes::Catch(
self.inner
.calculate(&Converted::<Catch>::new(map, self.is_convert)),
),
GameMode::Mania => DifficultyAttributes::Mania(
self.inner
.calculate(&Converted::<Mania>::new(map, self.is_convert)),
),
}
}
}
+69
View File
@@ -0,0 +1,69 @@
use crate::{
model::{beatmap::Converted, mode::IGameMode},
util::mods::Mods,
};
/// Difficulty calculator on maps of a given mode.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ModeDifficulty {
mods: u32,
passed_objects: Option<u32>,
clock_rate: Option<f64>,
}
impl ModeDifficulty {
/// Create a new difficulty calculator.
pub fn new() -> Self {
Self::default()
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
pub fn mods(&mut self, mods: u32) -> &mut Self {
self.mods = mods;
self
}
/// Amount of passed objects for partial plays, e.g. a fail.
pub fn passed_objects(&mut self, passed_objects: u32) -> &mut Self {
self.passed_objects = Some(passed_objects);
self
}
/// Adjust the clock rate used in the calculation.
///
/// If none is specified, it will take the clock rate based on the mods
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
pub fn clock_rate(&mut self, clock_rate: f64) -> &mut Self {
self.clock_rate = Some(clock_rate);
self
}
/// Perform the difficulty calculation for a [`Converted`] beatmap and
/// process the final skill values.
pub fn calculate<M: IGameMode>(&self, map: &Converted<'_, M>) -> M::DifficultyAttributes {
M::difficulty(self, map)
}
/// Perform a difficulty calculation for a [`Converted`] beatmap without
/// processing the final skill values.
pub fn strains<M: IGameMode>(&self, map: &Converted<'_, M>) -> M::Strains {
M::strains(self, map)
}
pub(crate) const fn get_mods(&self) -> u32 {
self.mods
}
pub(crate) fn get_clock_rate(&self) -> f64 {
self.clock_rate.unwrap_or_else(|| self.mods.clock_rate())
}
pub(crate) fn get_passed_objects(&self) -> usize {
self.passed_objects.map_or(usize::MAX, |n| n as usize)
}
}
+13
View File
@@ -0,0 +1,13 @@
pub trait IDifficultyObject: Sized {
fn idx(&self) -> usize;
fn previous<'a, D>(&self, backwards_idx: usize, diff_objects: &'a [D]) -> Option<&'a D> {
self.idx()
.checked_sub(backwards_idx + 1)
.and_then(|idx| diff_objects.get(idx))
}
fn next<'a, D>(&self, forwards_idx: usize, diff_objects: &'a [D]) -> Option<&'a D> {
diff_objects.get(self.idx() + (forwards_idx + 1))
}
}
+94
View File
@@ -0,0 +1,94 @@
pub fn strain_decay(ms: f64, strain_decay_base: f64) -> f64 {
strain_decay_base.powf(ms / 1000.0)
}
/// Wrapper around a difficulty skill that carries a list of all difficulty
/// objects.
pub struct Skill<'a, S: ISkill> {
pub inner: S,
pub diff_objects: &'a S::DifficultyObjects<'a>,
}
impl<'a, S: ISkill> Skill<'a, S> {
pub const fn new(skill: S, diff_objects: &'a S::DifficultyObjects<'a>) -> Self {
Self {
inner: skill,
diff_objects,
}
}
}
/// Trait required for [`Skill`].
pub trait ISkill {
type DifficultyObjects<'a>: ?Sized;
}
#[derive(Clone, Default)]
pub struct StrainSkill {
pub curr_section_peak: f64,
pub curr_section_end: f64,
pub strain_peaks: Vec<f64>, // TODO: default capacity
}
impl StrainSkill {
pub const DECAY_WEIGHT: f64 = 0.9;
pub const SECTION_LEN: f64 = 400.0;
pub fn save_curr_peak(&mut self) {
self.strain_peaks.push(self.curr_section_peak);
}
pub fn start_new_section_from(&mut self, initial_strain: f64) {
self.curr_section_peak = initial_strain;
}
pub fn get_curr_strain_peaks(self) -> Vec<f64> {
let mut strain_peaks = self.strain_peaks;
strain_peaks.push(self.curr_section_peak);
strain_peaks
}
pub fn difficulty_value(self, decay_weight: f64) -> f64 {
let mut difficulty = 0.0;
let mut weight = 1.0;
let mut peaks = self.get_curr_strain_peaks();
peaks.retain(|&strain| strain > 0.0);
peaks.sort_by(|a, b| b.total_cmp(a));
for strain in peaks {
difficulty += strain * weight;
weight *= decay_weight;
}
difficulty
}
}
#[derive(Clone, Default)]
pub struct StrainDecaySkill {
pub inner: StrainSkill,
pub curr_strain: f64,
}
impl StrainDecaySkill {
pub const DECAY_WEIGHT: f64 = StrainSkill::DECAY_WEIGHT;
pub const SECTION_LEN: f64 = StrainSkill::SECTION_LEN;
pub fn save_curr_peak(&mut self) {
self.inner.save_curr_peak();
}
pub fn start_new_section_from(&mut self, initial_strain: f64) {
self.inner.start_new_section_from(initial_strain);
}
pub fn get_curr_strain_peaks(self) -> Vec<f64> {
self.inner.get_curr_strain_peaks()
}
pub fn difficulty_value(self, decay_weight: f64) -> f64 {
self.inner.difficulty_value(decay_weight)
}
}
+13
View File
@@ -0,0 +1,13 @@
pub use self::{
attributes::{
AttributeProvider, DifficultyAttributes, ModeAttributeProvider, PerformanceAttributes,
},
difficulty::{Difficulty, ModeDifficulty},
performance::{HitResultPriority, Performance},
score_state::ScoreState,
};
mod attributes;
pub(crate) mod difficulty;
mod performance;
mod score_state;
+97 -157
View File
@@ -1,63 +1,41 @@
use std::borrow::Cow;
use rosu_map::section::general::GameMode;
use crate::{
catch::{CatchDifficultyAttributes, CatchPP, CatchPerformanceAttributes},
mania::{ManiaDifficultyAttributes, ManiaPP, ManiaPerformanceAttributes},
osu::{OsuDifficultyAttributes, OsuPP, OsuPerformanceAttributes},
taiko::{TaikoDifficultyAttributes, TaikoPP, TaikoPerformanceAttributes},
Beatmap, DifficultyAttributes, GameMode, PerformanceAttributes, ScoreState,
any::attributes::DifficultyAttributes,
catch::{Catch, CatchPerformance},
mania::{Mania, ManiaPerformance},
model::beatmap::{Beatmap, Converted},
osu::{Osu, OsuPerformance},
taiko::{Taiko, TaikoPerformance},
};
use super::{
attributes::{AttributeProvider, PerformanceAttributes},
score_state::ScoreState,
};
/// Performance calculator on maps of any mode.
///
/// # Example
///
/// ```no_run
/// use rosu_pp::{AnyPP, Beatmap};
///
/// # /*
/// let map: Beatmap = ...
/// # */
///
/// # let map = Beatmap::default();
/// let pp_result = AnyPP::new(&map)
/// .mods(8 + 64) // HDDT
/// .combo(1234)
/// .accuracy(98.5)
/// .n_misses(1)
/// .calculate();
///
/// println!("PP: {} | Stars: {}", pp_result.pp(), pp_result.stars());
///
/// let next_result = AnyPP::new(&map)
/// .attributes(pp_result) // reusing previous results for performance
/// .mods(8 + 64) // has to be the same to reuse attributes
/// .accuracy(99.5)
/// .calculate();
///
/// println!("PP: {} | Stars: {}", next_result.pp(), next_result.stars());
/// ```
#[allow(clippy::upper_case_acronyms)]
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
#[must_use]
pub enum AnyPP<'map> {
/// osu!standard performance calculator
Osu(OsuPP<'map>),
/// osu!taiko performance calculator
Taiko(TaikoPP<'map>),
/// osu!catch performance calculator
Catch(CatchPP<'map>),
/// osu!mania performance calculator
Mania(ManiaPP<'map>),
pub enum Performance<'map> {
Osu(OsuPerformance<'map>),
Taiko(TaikoPerformance<'map>),
Catch(CatchPerformance<'map>),
Mania(ManiaPerformance<'map>),
}
impl<'map> AnyPP<'map> {
impl<'map> Performance<'map> {
/// Create a new performance calculator for maps of any mode.
#[inline]
pub fn new(map: &'map Beatmap) -> Self {
let map = Cow::Borrowed(map);
match map.mode {
GameMode::Osu => Self::Osu(OsuPP::new(map)),
GameMode::Taiko => Self::Taiko(TaikoPP::new(map)),
GameMode::Catch => Self::Catch(CatchPP::new(map)),
GameMode::Mania => Self::Mania(ManiaPP::new(map)),
GameMode::Osu => Self::Osu(OsuPerformance::new(Converted::new(map, false))),
GameMode::Taiko => Self::Taiko(TaikoPerformance::new(Converted::new(map, false))),
GameMode::Catch => Self::Catch(CatchPerformance::new(Converted::new(map, false))),
GameMode::Mania => Self::Mania(ManiaPerformance::new(Converted::new(map, false))),
}
}
@@ -66,14 +44,12 @@ impl<'map> AnyPP<'map> {
///
/// Note that the map, mods, and passed object count should be the same
/// as when the attributes were calculated.
#[inline]
pub fn from_attributes(attributes: impl AttributeProvider) -> Self {
Self::from(attributes)
}
/// Consume the performance calculator and calculate
/// performance attributes for the given parameters.
#[inline]
pub fn calculate(self) -> PerformanceAttributes {
match self {
Self::Osu(o) => PerformanceAttributes::Osu(o.calculate()),
@@ -86,7 +62,6 @@ impl<'map> AnyPP<'map> {
/// Provide the result of a previous difficulty or performance calculation.
/// If you already calculated the attributes for the current map-mod combination,
/// be sure to put them in here so that they don't have to be recalculated.
#[inline]
pub fn attributes(self, attributes: impl AttributeProvider) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.attributes(attributes.attributes())),
@@ -96,23 +71,47 @@ impl<'map> AnyPP<'map> {
}
}
/// If the map is an osu!standard map, convert it to another mode.
/// Attempt to convert the map to the specified mode.
///
/// Returns `None` if `self` already replaced it's internal [`Beatmap`]
/// with difficulty attributes, i.e. if [`AnyPP::attributes`]
/// or [`AnyPP::generate_state`] was called.
#[inline]
/// Returns `None` if the conversion is incompatible or the internal
/// beatmap was already replaced with difficulty attributes, i.e. if
/// [`Performance::attributes`] or [`Performance::generate_state`] was
/// called.
///
/// If the given mode should be ignored in case it is incompatible or if
/// the internal beatmap was replaced, use [`mode_or_ignore`] instead.
///
/// [`mode_or_ignore`]: Self::mode_or_ignore
pub fn try_mode(self, mode: GameMode) -> Option<Self> {
match self {
AnyPP::Osu(o) => o.try_mode(mode),
other => Some(other),
match (self, mode) {
(Self::Osu(o), _) => o.try_mode(mode),
(this @ Self::Taiko(_), GameMode::Taiko)
| (this @ Self::Catch(_), GameMode::Catch)
| (this @ Self::Mania(_), GameMode::Mania) => Some(this),
_ => None,
}
}
/// Attempt to convert the map to the specified mode.
///
/// If the conversion is incompatible or if the internal beatmap was
/// already replaced with difficulty attributes, the map won't be modified.
///
/// To see whether the given mode is incompatible or the internal beatmap
/// was replaced, use [`try_mode`] instead.
///
/// [`try_mode`]: Self::try_mode
pub fn mode_or_ignore(self, mode: GameMode) -> Self {
if let Self::Osu(osu) = self {
osu.mode_or_ignore(mode)
} else {
self
}
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
#[inline]
pub fn mods(self, mods: u32) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.mods(mods)),
@@ -130,8 +129,7 @@ impl<'map> AnyPP<'map> {
using [`AnyPP`] multiple times with different `passed_objects`, you should use
[`GradualPerformanceAttributes`](crate::GradualPerformance)."
)]
#[inline]
pub fn passed_objects(self, passed_objects: usize) -> Self {
pub fn passed_objects(self, passed_objects: u32) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.passed_objects(passed_objects)),
Self::Taiko(t) => Self::Taiko(t.passed_objects(passed_objects)),
@@ -143,7 +141,6 @@ impl<'map> AnyPP<'map> {
/// Adjust the clock rate used in the calculation.
/// If none is specified, it will take the clock rate based on the mods
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
#[inline]
pub fn clock_rate(self, clock_rate: f64) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.clock_rate(clock_rate)),
@@ -153,20 +150,7 @@ impl<'map> AnyPP<'map> {
}
}
/// Specify whether the map is a convert.
///
/// This only needs to be specified if the map was converted manually beforehand.
#[inline]
pub fn is_convert(self, is_convert: bool) -> Self {
match self {
Self::Osu(_) | Self::Catch(_) => self,
Self::Taiko(t) => Self::Taiko(t.is_convert(is_convert)),
Self::Mania(m) => Self::Mania(m.is_convert(is_convert)),
}
}
/// Provide parameters through a [`ScoreState`].
#[inline]
pub fn state(self, state: ScoreState) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.state(state.into())),
@@ -177,7 +161,6 @@ impl<'map> AnyPP<'map> {
}
/// Set the accuracy between `0.0` and `100.0`.
#[inline]
pub fn accuracy(self, acc: f64) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.accuracy(acc)),
@@ -188,8 +171,7 @@ impl<'map> AnyPP<'map> {
}
/// Specify the amount of misses of a play.
#[inline]
pub fn n_misses(self, n_misses: usize) -> Self {
pub fn n_misses(self, n_misses: u32) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.n_misses(n_misses)),
Self::Taiko(t) => Self::Taiko(t.n_misses(n_misses)),
@@ -201,8 +183,7 @@ impl<'map> AnyPP<'map> {
/// Specify the max combo of the play.
///
/// Irrelevant for osu!mania.
#[inline]
pub fn combo(self, combo: usize) -> Self {
pub fn combo(self, combo: u32) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.combo(combo)),
Self::Taiko(t) => Self::Taiko(t.combo(combo)),
@@ -214,7 +195,6 @@ impl<'map> AnyPP<'map> {
/// Specify how hitresults should be generated.
///
/// Defauls to [`HitResultPriority::BestCase`].
#[inline]
pub fn hitresult_priority(self, priority: HitResultPriority) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.hitresult_priority(priority)),
@@ -225,8 +205,7 @@ impl<'map> AnyPP<'map> {
}
/// Specify the amount of 300s of a play.
#[inline]
pub fn n300(self, n300: usize) -> Self {
pub fn n300(self, n300: u32) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.n300(n300)),
Self::Taiko(t) => Self::Taiko(t.n300(n300)),
@@ -236,8 +215,7 @@ impl<'map> AnyPP<'map> {
}
/// Specify the amount of 100s of a play.
#[inline]
pub fn n100(self, n100: usize) -> Self {
pub fn n100(self, n100: u32) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.n100(n100)),
Self::Taiko(t) => Self::Taiko(t.n100(n100)),
@@ -249,8 +227,7 @@ impl<'map> AnyPP<'map> {
/// Specify the amount of 50s of a play.
///
/// Irrelevant for osu!taiko.
#[inline]
pub fn n50(self, n50: usize) -> Self {
pub fn n50(self, n50: u32) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.n50(n50)),
Self::Taiko(_) => self,
@@ -264,8 +241,7 @@ impl<'map> AnyPP<'map> {
/// This value is only relevant for osu!catch for which it represents
/// the amount of tiny droplet misses and osu!mania for which it.
/// repesents the amount of n200.
#[inline]
pub fn n_katu(self, n_katu: usize) -> Self {
pub fn n_katu(self, n_katu: u32) -> Self {
match self {
Self::Osu(_) | Self::Taiko(_) => self,
Self::Catch(f) => Self::Catch(f.tiny_droplet_misses(n_katu)),
@@ -277,8 +253,7 @@ impl<'map> AnyPP<'map> {
///
/// This value is only relevant for osu!mania for which it.
/// repesents the amount of n320.
#[inline]
pub fn n_geki(self, n_geki: usize) -> Self {
pub fn n_geki(self, n_geki: u32) -> Self {
match self {
Self::Osu(_) | Self::Taiko(_) | Self::Catch(_) => self,
Self::Mania(m) => Self::Mania(m.n320(n_geki)),
@@ -286,7 +261,6 @@ impl<'map> AnyPP<'map> {
}
/// Create the [`ScoreState`] that will be used for performance calculation.
#[inline]
pub fn generate_state(&mut self) -> ScoreState {
match self {
Self::Osu(o) => o.generate_state().into(),
@@ -297,15 +271,14 @@ impl<'map> AnyPP<'map> {
}
}
impl<A: AttributeProvider> From<A> for AnyPP<'_> {
#[inline]
impl<A: AttributeProvider> From<A> for Performance<'_> {
fn from(attrs: A) -> Self {
fn inner(attrs: DifficultyAttributes) -> AnyPP<'static> {
fn inner(attrs: DifficultyAttributes) -> Performance<'static> {
match attrs {
DifficultyAttributes::Osu(attrs) => AnyPP::Osu(attrs.pp()),
DifficultyAttributes::Taiko(attrs) => AnyPP::Taiko(attrs.pp()),
DifficultyAttributes::Catch(attrs) => AnyPP::Catch(attrs.pp()),
DifficultyAttributes::Mania(attrs) => AnyPP::Mania(attrs.pp()),
DifficultyAttributes::Osu(attrs) => Performance::Osu(attrs.pp()),
DifficultyAttributes::Taiko(attrs) => Performance::Taiko(attrs.pp()),
DifficultyAttributes::Catch(attrs) => Performance::Catch(attrs.pp()),
DifficultyAttributes::Mania(attrs) => Performance::Mania(attrs.pp()),
}
}
@@ -313,66 +286,33 @@ impl<A: AttributeProvider> From<A> for AnyPP<'_> {
}
}
/// While generating remaining hitresults, decide how they should be distributed.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum HitResultPriority {
/// Prioritize good hitresults over bad ones
BestCase,
/// Prioritize bad hitresults over good ones
WorstCase,
}
impl Default for HitResultPriority {
#[inline]
fn default() -> Self {
Self::BestCase
}
}
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
pub trait AttributeProvider {
/// Provide the actual difficulty attributes.
fn attributes(self) -> DifficultyAttributes;
}
impl AttributeProvider for DifficultyAttributes {
#[inline]
fn attributes(self) -> DifficultyAttributes {
self
}
}
impl AttributeProvider for PerformanceAttributes {
#[inline]
fn attributes(self) -> DifficultyAttributes {
match self {
Self::Osu(attrs) => DifficultyAttributes::Osu(attrs.difficulty),
Self::Taiko(attrs) => DifficultyAttributes::Taiko(attrs.difficulty),
Self::Catch(attrs) => DifficultyAttributes::Catch(attrs.difficulty),
Self::Mania(attrs) => DifficultyAttributes::Mania(attrs.difficulty),
}
}
}
macro_rules! impl_attr_provider {
($mode:ident: $difficulty:ident, $performance:ident) => {
impl AttributeProvider for $difficulty {
#[inline]
fn attributes(self) -> DifficultyAttributes {
DifficultyAttributes::$mode(self)
macro_rules! impl_from_mode {
( $mode:ident: $performance:ident ) => {
impl<'a> From<Converted<'a, $mode>> for Performance<'a> {
fn from(converted: Converted<'a, $mode>) -> Self {
Self::$mode($performance::new(converted))
}
}
impl AttributeProvider for $performance {
#[inline]
fn attributes(self) -> DifficultyAttributes {
DifficultyAttributes::$mode(self.difficulty)
impl<'a, 'b: 'a> From<&'b Converted<'a, $mode>> for Performance<'a> {
fn from(converted: &'b Converted<'a, $mode>) -> Self {
Self::$mode($performance::new(converted.as_owned()))
}
}
};
}
impl_attr_provider!(Catch: CatchDifficultyAttributes, CatchPerformanceAttributes);
impl_attr_provider!(Mania: ManiaDifficultyAttributes, ManiaPerformanceAttributes);
impl_attr_provider!(Osu: OsuDifficultyAttributes, OsuPerformanceAttributes);
impl_attr_provider!(Taiko: TaikoDifficultyAttributes, TaikoPerformanceAttributes);
impl_from_mode!(Osu: OsuPerformance);
impl_from_mode!(Taiko: TaikoPerformance);
impl_from_mode!(Catch: CatchPerformance);
impl_from_mode!(Mania: ManiaPerformance);
/// While generating remaining hitresults, decide how they should be distributed.
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
pub enum HitResultPriority {
/// Prioritize good hitresults over bad ones
#[default]
BestCase,
/// Prioritize bad hitresults over good ones
WorstCase,
}
+20 -28
View File
@@ -1,44 +1,44 @@
use rosu_map::section::general::GameMode;
use crate::{
catch::CatchScoreState, mania::ManiaScoreState, osu::OsuScoreState, taiko::TaikoScoreState,
GameMode,
};
/// Aggregation for a score's current state i.e. what is
/// the maximum combo so far, what are the current
/// hitresults and what is the current score.
/// Aggregation for a score's current state.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ScoreState {
/// Maximum combo that the score has had so far.
/// **Not** the maximum possible combo of the map so far.
/// Maximum combo that the score has had so far. **Not** the maximum
/// possible combo of the map so far.
///
/// Note that for osu!catch only fruits and droplets are considered for combo.
/// Note that for osu!catch only fruits and droplets are considered for
/// combo.
///
/// Irrelevant for osu!mania.
pub max_combo: usize,
pub max_combo: u32,
/// Amount of current gekis (n320 for osu!mania).
pub n_geki: usize,
/// Amount of current katus (tiny droplet misses for osu!catch / n200 for osu!mania).
pub n_katu: usize,
pub n_geki: u32,
/// Amount of current katus (tiny droplet misses for osu!catch / n200 for
/// osu!mania).
pub n_katu: u32,
/// Amount of current 300s (fruits for osu!catch).
pub n300: usize,
pub n300: u32,
/// Amount of current 100s (droplets for osu!catch).
pub n100: usize,
pub n100: u32,
/// Amount of current 50s (tiny droplets for osu!catch).
pub n50: usize,
pub n50: u32,
/// Amount of current misses (fruits + droplets for osu!catch).
pub n_misses: usize,
pub n_misses: u32,
}
impl ScoreState {
/// Create a new empty score state.
#[inline]
pub fn new() -> Self {
Self::default()
}
/// Return the total amount of hits by adding everything up based on the mode.
#[inline]
pub fn total_hits(&self, mode: GameMode) -> usize {
/// Return the total amount of hits by adding everything up based on the
/// mode.
pub fn total_hits(&self, mode: GameMode) -> u32 {
let mut amount = self.n300 + self.n100 + self.n_misses;
if mode != GameMode::Taiko {
@@ -46,7 +46,7 @@ impl ScoreState {
if mode != GameMode::Osu {
amount += self.n_katu;
amount += usize::from(mode != GameMode::Catch) * self.n_geki;
amount += u32::from(mode != GameMode::Catch) * self.n_geki;
}
}
@@ -55,7 +55,6 @@ impl ScoreState {
}
impl From<ScoreState> for OsuScoreState {
#[inline]
fn from(state: ScoreState) -> Self {
Self {
max_combo: state.max_combo,
@@ -68,7 +67,6 @@ impl From<ScoreState> for OsuScoreState {
}
impl From<ScoreState> for TaikoScoreState {
#[inline]
fn from(state: ScoreState) -> Self {
Self {
max_combo: state.max_combo,
@@ -80,7 +78,6 @@ impl From<ScoreState> for TaikoScoreState {
}
impl From<ScoreState> for CatchScoreState {
#[inline]
fn from(state: ScoreState) -> Self {
Self {
max_combo: state.max_combo,
@@ -94,7 +91,6 @@ impl From<ScoreState> for CatchScoreState {
}
impl From<ScoreState> for ManiaScoreState {
#[inline]
fn from(state: ScoreState) -> Self {
Self {
n320: state.n_geki,
@@ -108,7 +104,6 @@ impl From<ScoreState> for ManiaScoreState {
}
impl From<OsuScoreState> for ScoreState {
#[inline]
fn from(state: OsuScoreState) -> Self {
Self {
max_combo: state.max_combo,
@@ -123,7 +118,6 @@ impl From<OsuScoreState> for ScoreState {
}
impl From<TaikoScoreState> for ScoreState {
#[inline]
fn from(state: TaikoScoreState) -> Self {
Self {
max_combo: state.max_combo,
@@ -138,7 +132,6 @@ impl From<TaikoScoreState> for ScoreState {
}
impl From<CatchScoreState> for ScoreState {
#[inline]
fn from(state: CatchScoreState) -> Self {
Self {
max_combo: state.max_combo,
@@ -153,7 +146,6 @@ impl From<CatchScoreState> for ScoreState {
}
impl From<ManiaScoreState> for ScoreState {
#[inline]
fn from(state: ManiaScoreState) -> Self {
Self {
max_combo: 0,
-16
View File
@@ -1,16 +0,0 @@
/// A break point of a [`Beatmap`](crate::beatmap::Beatmap).
#[derive(Copy, Clone, Debug, Default, PartialEq)]
pub struct Break {
/// Start timestamp of the break.
pub start_time: f64,
/// End timestamp of the break.
pub end_time: f64,
}
impl Break {
/// Duration of the break.
#[inline]
pub fn duration(&self) -> f64 {
self.end_time - self.start_time
}
}
-133
View File
@@ -1,133 +0,0 @@
use std::cmp::Ordering;
/// New rhythm speed change.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct TimingPoint {
/// The beat length for this timing section
pub beat_len: f64,
/// The start time of this timing section
pub time: f64,
}
impl TimingPoint {
/// Create a new [`TimingPoint`].
#[inline]
pub const fn new(time: f64, beat_len: f64) -> Self {
Self { beat_len, time }
}
}
impl PartialOrd for TimingPoint {
#[inline]
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.time.partial_cmp(&other.time)
}
}
impl Default for TimingPoint {
#[inline]
fn default() -> Self {
Self::new(0.0, 60_000.0 / 60.0)
}
}
/// [`TimingPoint`] that depends on a previous one.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct DifficultyPoint {
/// The time at which the control point takes effect.
pub time: f64,
/// The slider velocity at this control point.
pub slider_vel: f64,
/// Legacy BPM multiplier that introduces floating-point errors for rulesets that depend on it.
pub bpm_mult: f64,
/// Whether or not slider ticks should be generated at this control point.
/// This exists for backwards compatibility with maps that abuse NaN
/// slider velocity behavior on osu!stable (e.g. /b/2628991).
pub generate_ticks: bool,
}
impl DifficultyPoint {
/// The default slider velocity for a [`DifficultyPoint`]
pub const DEFAULT_SLIDER_VEL: f64 = 1.0;
/// The default BPM multipler for a [`DifficultyPoint`]
pub const DEFAULT_BPM_MULT: f64 = 1.0;
/// The default for generating ticks of a [`DifficultyPoint`]
pub const DEFAULT_GENERATE_TICKS: bool = true;
/// Create a new [`DifficultyPoint`].
#[inline]
pub fn new(time: f64, beat_len: f64, speed_multiplier: f64) -> Self {
// * Note: In stable, the division occurs on floats, but with compiler optimisations
// * turned on actually seems to occur on doubles via some .NET black magic (possibly inlining?).
let bpm_mult = if beat_len < 0.0 {
f64::from(((-beat_len) as f32).clamp(10.0, 10_000.0)) / 100.0
} else {
1.0
};
Self {
time,
slider_vel: speed_multiplier.clamp(0.1, 10.0),
bpm_mult,
generate_ticks: !beat_len.is_nan(),
}
}
pub(crate) fn is_redundant(&self, existing: &Self) -> bool {
(self.slider_vel - existing.slider_vel).abs() <= f64::EPSILON
&& self.generate_ticks == existing.generate_ticks
}
}
impl PartialOrd for DifficultyPoint {
#[inline]
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.time.partial_cmp(&other.time)
}
}
impl Default for DifficultyPoint {
#[inline]
fn default() -> Self {
Self {
time: 0.0,
slider_vel: Self::DEFAULT_SLIDER_VEL,
bpm_mult: Self::DEFAULT_BPM_MULT,
generate_ticks: Self::DEFAULT_GENERATE_TICKS,
}
}
}
/// Control point storing effects and their timestamps.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct EffectPoint {
/// The time at which the control point takes effect.
pub time: f64,
/// Whether this control point enables Kiai mode.
pub kiai: bool,
}
impl EffectPoint {
/// The default slider velocity for a [`DifficultyPoint`]
pub const DEFAULT_KIAI: bool = false;
/// Create a new [`EffectPoint`].
#[inline]
pub const fn new(time: f64, kiai: bool) -> Self {
Self { time, kiai }
}
}
impl PartialOrd for EffectPoint {
#[inline]
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.time.partial_cmp(&other.time)
}
}
impl Default for EffectPoint {
#[inline]
fn default() -> Self {
Self::new(0.0, Self::DEFAULT_KIAI)
}
}
@@ -1,42 +0,0 @@
const INT_TO_REAL: f64 = 1.0 / (i32::MAX as f64 + 1.0);
const INT_MASK: u32 = 0x7F_FF_FF_FF;
pub(crate) struct Random {
x: u32,
y: u32,
z: u32,
w: u32,
}
impl Random {
pub(crate) const fn new(seed: i32) -> Self {
Self {
x: seed as u32,
y: 842_502_087,
z: 3_579_807_591,
w: 273_326_509,
}
}
pub(crate) fn gen_unsigned(&mut self) -> u32 {
let t = self.x ^ (self.x << 11);
self.x = self.y;
self.y = self.z;
self.z = self.w;
self.w = self.w ^ (self.w >> 19) ^ t ^ (t >> 8);
self.w
}
pub(crate) fn gen_signed(&mut self) -> i32 {
(INT_MASK & self.gen_unsigned()) as i32
}
pub(crate) fn gen_double(&mut self) -> f64 {
INT_TO_REAL * f64::from(self.gen_signed())
}
pub(crate) fn gen_int_range(&mut self, min: i32, max: i32) -> i32 {
(f64::from(min) + self.gen_double() * f64::from(max - min)) as i32
}
}
-220
View File
@@ -1,220 +0,0 @@
use std::cmp::Ordering;
use crate::{
curve::{Curve, CurveBuffers},
parse::{legacy_sort, HitObjectKind, Pos2},
util::{FloatExt, LimitedQueue},
Beatmap, GameMode,
};
use self::{
legacy_random::Random,
pattern::Pattern,
pattern_generator::{
distance_object::DistanceObjectPatternGenerator,
end_time_object::EndTimeObjectPatternGenerator, hit_object::HitObjectPatternGenerator,
},
pattern_type::PatternType,
};
mod legacy_random;
mod pattern;
mod pattern_generator;
mod pattern_type;
const MAX_NOTES_FOR_DENSITY: usize = 7;
impl Beatmap {
#[allow(clippy::too_many_lines)]
pub(in crate::beatmap) fn convert_to_mania(&self) -> Self {
let mut map = self.clone_without_hit_objects(false);
let mut n_circles = 0;
let mut n_sliders = 0;
let seed = (map.hp + map.cs).round_even() as i32 * 20
+ (map.od * 41.2) as i32
+ map.ar.round_even() as i32;
let mut random = Random::new(seed);
let rounded_cs = map.cs.round_even();
let rounded_od = map.od.round_even();
let slider_or_spinner_count = self
.hit_objects
.iter()
.filter(|h| {
matches!(
h.kind,
HitObjectKind::Slider { .. } | HitObjectKind::Spinner { .. }
)
})
.count();
let percent_slider_or_spinner =
f64::from(slider_or_spinner_count as f32 / self.hit_objects.len() as f32);
let target_columns = if percent_slider_or_spinner < 0.2 {
7.0
} else if percent_slider_or_spinner < 0.3 || rounded_cs >= 5.0 {
f32::from(6 + u8::from(rounded_od > 5.0))
} else if percent_slider_or_spinner > 0.6 {
f32::from(4 + u8::from(rounded_od > 4.0))
} else {
(rounded_od + 1.0).clamp(4.0, 7.0)
};
map.cs = target_columns;
let mut prev_note_times: LimitedQueue<f64, MAX_NOTES_FOR_DENSITY> = LimitedQueue::new();
let mut density = f64::from(i32::MAX);
let mut compute_density = |new_note_time: f64, d: &mut f64| {
prev_note_times.push(new_note_time);
if prev_note_times.len() >= 2 {
*d = (prev_note_times.last().unwrap() - prev_note_times[0])
/ prev_note_times.len() as f64;
}
};
let total_columns = map.cs as i32;
let mut last_values = PrevValues::default();
let mut curve_bufs = CurveBuffers::default();
for (obj, sound) in self.hit_objects.iter().zip(self.sounds.iter()) {
match obj.kind {
HitObjectKind::Circle => {
compute_density(obj.start_time, &mut density);
let mut gen = HitObjectPatternGenerator::new(
&mut random,
obj,
*sound,
total_columns,
&last_values,
density,
self,
);
let new_pattern = gen.generate();
last_values.stair = gen.stair_type;
last_values.time = obj.start_time;
last_values.pos = obj.pos;
let new_hit_objects = new_pattern.hit_objects.iter().cloned();
map.hit_objects.extend(new_hit_objects);
n_circles += new_pattern.hit_objects.len();
last_values.pattern = new_pattern;
}
HitObjectKind::Slider {
pixel_len,
repeats,
ref control_points,
ref edge_sounds,
} => {
let curve = Curve::new(control_points, pixel_len, &mut curve_bufs);
let mut gen = DistanceObjectPatternGenerator::new(
&mut random,
obj,
*sound,
total_columns,
&last_values.pattern,
self,
repeats,
&curve,
edge_sounds,
);
let segment_duration = f64::from(gen.segment_duration);
for i in 0..=repeats as i32 + 1 {
let time = obj.start_time + segment_duration * f64::from(i);
last_values.time = time;
last_values.pos = obj.pos;
compute_density(time, &mut density);
}
for new_pattern in gen.generate() {
let new_objects = new_pattern.hit_objects.iter().map(|h| {
if h.is_circle() {
n_circles += 1;
} else {
n_sliders += 1;
}
h.to_owned()
});
map.hit_objects.extend(new_objects);
last_values.pattern = new_pattern;
}
}
HitObjectKind::Spinner { end_time } | HitObjectKind::Hold { end_time } => {
let mut gen = EndTimeObjectPatternGenerator::new(
&mut random,
obj,
end_time,
*sound,
total_columns,
&last_values.pattern,
);
last_values.time = end_time;
last_values.pos = Pos2 { x: 256.0, y: 192.0 };
compute_density(end_time, &mut density);
let new_pattern = gen.generate();
let new_objects = new_pattern.hit_objects.into_iter().inspect(|h| {
if h.is_circle() {
n_circles += 1;
} else {
n_sliders += 1;
}
});
map.hit_objects.extend(new_objects);
}
}
}
map.n_circles = n_circles as u32;
map.n_sliders = n_sliders;
map.hit_objects
.sort_by(|p1, p2| p1.partial_cmp(p2).unwrap_or(Ordering::Equal));
legacy_sort(&mut map.hit_objects);
map.mode = GameMode::Mania;
map
}
}
pub(crate) struct PrevValues {
time: f64,
pos: Pos2,
pattern: Pattern,
stair: PatternType,
}
impl Default for PrevValues {
fn default() -> Self {
Self {
time: 0.0,
pos: Pos2::default(),
pattern: Pattern::default(),
stair: PatternType::STAIR,
}
}
}
@@ -1,97 +0,0 @@
use crate::{
beatmap::converts::mania::{
legacy_random::Random, pattern::Pattern, pattern_type::PatternType,
},
parse::{HitObject, HitSound},
Beatmap,
};
use super::PatternGenerator;
pub(crate) struct EndTimeObjectPatternGenerator<'h> {
pub(crate) hit_object: &'h HitObject,
pub(crate) end_time: f64,
pub(crate) total_columns: i32,
pub(crate) sample: u8,
convert_type: PatternType,
prev_pattern: &'h Pattern,
random: &'h mut Random,
}
impl<'h> EndTimeObjectPatternGenerator<'h> {
pub(crate) fn new(
random: &'h mut Random,
hit_object: &'h HitObject,
end_time: f64,
sample: u8,
total_columns: i32,
prev_pattern: &'h Pattern,
) -> Self {
let convert_type = if prev_pattern.column_with_objs() == total_columns {
PatternType::default()
} else {
PatternType::FORCE_NOT_STACK
};
Self {
hit_object,
end_time,
total_columns,
sample,
convert_type,
prev_pattern,
random,
}
}
pub(crate) fn generate(&mut self) -> Pattern {
let generate_hold = self.end_time - self.hit_object.start_time >= 100.0;
match self.total_columns {
8 if self.sample.finish() && self.end_time - self.hit_object.start_time < 1000.0 => {
Pattern::new_end_time_note(self, 0, generate_hold)
}
8 => {
let column = self.get_random_column(self.random_start());
Pattern::new_end_time_note(self, column, generate_hold)
}
_ => {
let column = self.get_random_column(0);
Pattern::new_end_time_note(self, column, generate_hold)
}
}
}
fn get_random_column(&mut self, lower: i32) -> u8 {
let column = PatternGenerator::get_random_column(self, Some(lower), None);
if self.convert_type.contains(PatternType::FORCE_NOT_STACK) {
self.find_available_column(column, Some(lower), None, None, None, &[self.prev_pattern])
} else {
self.find_available_column(column, Some(lower), None, None, None, &[])
}
}
}
impl PatternGenerator for EndTimeObjectPatternGenerator<'_> {
#[inline]
fn hit_object(&self) -> &HitObject {
self.hit_object
}
#[inline]
fn total_columns(&self) -> i32 {
self.total_columns
}
#[inline]
fn random(&mut self) -> &mut Random {
self.random
}
fn original_map(&self) -> &Beatmap {
panic!("trait method is not used")
}
}
@@ -1,139 +0,0 @@
use crate::{mania::ManiaObject, parse::HitObject, Beatmap};
use super::{legacy_random::Random, pattern::Pattern};
pub(super) mod distance_object;
pub(super) mod end_time_object;
pub(super) mod hit_object;
trait PatternGenerator {
fn hit_object(&self) -> &HitObject;
fn total_columns(&self) -> i32;
fn random(&mut self) -> &mut Random;
fn original_map(&self) -> &Beatmap;
// ----------------------------------
fn random_start(&self) -> i32 {
i32::from(self.total_columns() == 8)
}
fn get_column(&self, allow_special: Option<bool>) -> u8 {
let allow_special = allow_special.unwrap_or(false);
if allow_special && self.total_columns() == 8 {
const LOCAL_X_DIVISOR: f32 = 512.0 / 7.0;
((self.hit_object().pos.x / LOCAL_X_DIVISOR).floor() as u8).clamp(0, 6) + 1
} else {
ManiaObject::column(self.hit_object().pos.x, self.total_columns() as f32) as u8
}
}
fn get_random_note_count(
&mut self,
p2: f64,
p3: f64,
p4: Option<f64>,
p5: Option<f64>,
p6: Option<f64>,
) -> i32 {
let p4 = p4.unwrap_or(0.0);
let p5 = p5.unwrap_or(0.0);
let p6 = p6.unwrap_or(0.0);
let val = self.random().gen_double();
if val >= 1.0 - p6 {
6
} else if val >= 1.0 - p5 {
5
} else if val >= 1.0 - p4 {
4
} else if val >= 1.0 - p3 {
3
} else {
1 + i32::from(val >= 1.0 - p2)
}
}
fn conversion_difficulty(&self) -> f64 {
let orig = self.original_map();
let last_obj_time = orig.hit_objects.last().map_or(0.0, |h| h.start_time);
let first_obj_time = orig.hit_objects.first().map_or(0.0, |h| h.start_time);
// * Drain time in seconds
let total_break_time = orig.total_break_time();
let mut drain_time = ((last_obj_time - first_obj_time - total_break_time) / 1000.0) as i32;
if drain_time == 0 {
drain_time = 10_000;
}
let mut conversion_difficulty = 0.0;
conversion_difficulty += f64::from(orig.hp + orig.ar.clamp(4.0, 7.0)) / 1.5;
conversion_difficulty += orig.hit_objects.len() as f64 / f64::from(drain_time) * 9.0;
conversion_difficulty /= 38.0;
conversion_difficulty *= 5.0;
conversion_difficulty /= 1.15;
conversion_difficulty = conversion_difficulty.min(12.0);
conversion_difficulty
}
fn get_random_column(&mut self, lower: Option<i32>, upper: Option<i32>) -> u8 {
let lower = lower.unwrap_or_else(|| self.random_start());
let upper = upper.unwrap_or_else(|| self.total_columns());
self.random().gen_int_range(lower, upper) as u8
}
fn find_available_column(
&mut self,
mut initial_column: u8,
lower: Option<i32>,
upper: Option<i32>,
next_column: Option<fn(&mut Self, u8) -> u8>,
validation: Option<&dyn Fn(i32) -> bool>,
patterns: &[&Pattern],
) -> u8 {
let lower = lower.unwrap_or_else(|| self.random_start());
let upper = upper.unwrap_or_else(|| self.total_columns());
let is_valid = |column: i32| {
if let Some(fun) = validation {
if !(fun)(column) {
return false;
}
}
let column = column as u8;
patterns
.iter()
.all(|pattern| !pattern.column_has_obj(column))
};
// * Check for the initial column
if is_valid(i32::from(initial_column)) {
return initial_column;
}
// * Ensure that we have at least one free column, so that an endless loop is avoided
let has_valid_column = (lower..upper).any(is_valid);
assert!(has_valid_column);
// * Iterate until a valid column is found. This is a random iteration in the default case.
while {
initial_column = if let Some(fun) = next_column {
(fun)(self, initial_column)
} else {
PatternGenerator::get_random_column(self, Some(lower), Some(upper))
};
!is_valid(i32::from(initial_column))
} {}
initial_column
}
}
-2
View File
@@ -1,2 +0,0 @@
mod mania;
mod taiko;
-158
View File
@@ -1,158 +0,0 @@
use crate::{
curve::{Curve, CurveBuffers},
parse::{HitObject, HitObjectKind, Pos2},
util::TandemSorter,
Beatmap, GameMode,
};
const LEGACY_TAIKO_VELOCITY_MULTIPLIER: f32 = 1.4;
const OSU_BASE_SCORING_DIST: f32 = 100.0;
impl Beatmap {
pub(in crate::beatmap) fn convert_to_taiko(&self) -> Self {
let mut map = self.clone_without_hit_objects(true);
let mut curve_bufs = CurveBuffers::default();
map.slider_mult *= f64::from(LEGACY_TAIKO_VELOCITY_MULTIPLIER);
for (obj, sound) in self.hit_objects.iter().zip(self.sounds.iter()) {
match obj.kind {
HitObjectKind::Circle => {
map.hit_objects.push(obj.to_owned());
map.sounds.push(*sound);
map.n_circles += 1;
}
HitObjectKind::Slider {
pixel_len,
repeats,
ref control_points,
ref edge_sounds,
} => {
let curve = Curve::new(control_points, pixel_len, &mut curve_bufs);
let mut params = SliderParams::new(obj.start_time, repeats, &curve);
if map.should_convert_slider_to_taiko_hits(&mut params) {
let mut i = 0;
let mut j = obj.start_time;
let edge_sound_count = edge_sounds.len().max(1);
while j
<= obj.start_time
+ f64::from(params.duration)
+ params.tick_spacing / 8.0
{
let h = HitObject {
pos: Pos2::default(),
start_time: j,
kind: HitObjectKind::Circle,
};
map.hit_objects.push(h);
map.sounds.push(*edge_sounds.get(i).unwrap_or(sound));
map.n_circles += 1;
if params.tick_spacing.abs() <= f64::EPSILON {
break;
}
j += params.tick_spacing;
i = (i + 1) % edge_sound_count;
}
} else {
map.hit_objects.push(obj.to_owned());
map.sounds.push(*sound);
map.n_sliders += 1;
}
}
HitObjectKind::Spinner { .. } => {
map.hit_objects.push(obj.to_owned());
map.sounds.push(*sound);
map.n_spinners += 1;
}
// Pathological case; shouldn't realistically happen
HitObjectKind::Hold { end_time } => {
let obj = HitObject {
pos: obj.pos,
start_time: obj.start_time,
kind: HitObjectKind::Spinner { end_time },
};
map.hit_objects.push(obj);
map.sounds.push(*sound);
map.n_spinners += 1;
}
}
}
// We only convert STD to TKO so we don't need to remove objects
// with the same timestamp that would appear only in MNA
let mut sorter = TandemSorter::new(&map.hit_objects, true);
sorter.sort(&mut map.hit_objects);
sorter.toggle_marks();
sorter.sort(&mut map.sounds);
map.mode = GameMode::Taiko;
map
}
fn should_convert_slider_to_taiko_hits(&self, params: &mut SliderParams<'_>) -> bool {
let SliderParams {
curve,
duration,
repeats,
start_time,
tick_spacing,
} = params;
// * The true distance, accounting for any repeats. This ends up being the drum roll distance later
let spans = (*repeats + 1) as f64;
let dist = curve.dist() * spans * f64::from(LEGACY_TAIKO_VELOCITY_MULTIPLIER);
let timing_point = self.timing_point_at(*start_time);
let difficulty_point = self.difficulty_point_at(*start_time).unwrap_or_default();
let mut beat_len = timing_point.beat_len * difficulty_point.bpm_mult;
let slider_scoring_point_dist =
f64::from(OSU_BASE_SCORING_DIST) * self.slider_mult / self.tick_rate;
// * The velocity and duration of the taiko hit object - calculated as the velocity of a drum roll.
let taiko_vel = slider_scoring_point_dist * self.tick_rate;
*duration = (dist / taiko_vel * beat_len) as u32;
let osu_vel = taiko_vel * (f64::from(1000.0_f32) / beat_len);
// * osu-stable always uses the speed-adjusted beatlength to determine the osu! velocity, but only uses it for conversion if beatmap version < 8
if self.version >= 8 {
beat_len = timing_point.beat_len;
}
// * If the drum roll is to be split into hit circles, assume the ticks are 1/8 spaced within the duration of one beat
*tick_spacing = (beat_len / self.tick_rate).min(f64::from(*duration) / spans);
*tick_spacing > 0.0 && dist / osu_vel * 1000.0 < 2.0 * beat_len
}
}
struct SliderParams<'c> {
curve: &'c Curve<'c>,
duration: u32,
repeats: usize,
start_time: f64,
tick_spacing: f64,
}
impl<'c> SliderParams<'c> {
const fn new(start_time: f64, repeats: usize, curve: &'c Curve<'c>) -> Self {
Self {
curve,
repeats,
start_time,
duration: 0,
tick_spacing: 0.0,
}
}
}
-182
View File
@@ -1,182 +0,0 @@
use crate::{
catch::{
calculate_catch_width, CatchDifficultyAttributes, CatchObject, FruitOrJuice, FruitParams,
ALLOWED_CATCH_RANGE,
},
curve::CurveBuffers,
mania::{ManiaObject, ObjectParameters},
osu::{OsuDifficultyAttributes, OsuObject, ScalingFactor},
taiko::{IntoTaikoObjectIter, TaikoObject},
util::FloatExt,
AnyPP, AnyStars, Beatmap, CatchPP, CatchStars, GameMode, ManiaPP, ManiaStars, Mods, OsuPP,
OsuStars, PerformanceAttributes, Strains, TaikoPP, TaikoStars,
};
/// Provides some additional methods on [`Beatmap`].
pub trait BeatmapExt {
/// Calculate the stars and other attributes of a beatmap which are required for pp calculation.
fn stars(&self) -> AnyStars<'_>;
/// Calculate the max pp of a beatmap.
///
/// If you seek more fine-tuning you can use the [`pp`](BeatmapExt::pp) method.
fn max_pp(&self, mods: u32) -> PerformanceAttributes;
/// Returns a builder for performance calculation.
///
/// Convenient method that matches on the map's mode to choose the appropriate calculator.
fn pp(&self) -> AnyPP<'_>;
/// Calculate the strains of a map.
/// This essentially performs the same calculation as [`BeatmapExt::stars`] but
/// instead of evaluating the final strains, they are just returned as is.
///
/// Suitable to plot the difficulty of a map over time.
fn strains(&self, mods: u32) -> Strains;
/// Process each [`HitObject`](crate::parse::HitObject) into a an osu!-specific [`OsuObject`],
/// just like the difficulty calculation does.
fn osu_hitobjects(&self, mods: u32) -> Vec<OsuObject>;
/// Process each [`HitObject`](crate::parse::HitObject) into a an osu!taiko-specific [`TaikoObject`],
/// just like the difficulty calculation does.
///
/// Clockrate is *not* considered.
fn taiko_hitobjects(&self) -> Vec<TaikoObject>;
/// Process each [`HitObject`](crate::parse::HitObject) into a an osu!ctb-specific [`CatchObject`],
/// just like the difficulty calculation does.
///
/// A [`CatchObject`] is either a fruit or a droplet which means
/// tiny droplets and bananas are not included.
fn catch_hitobjects(&self, mods: u32) -> Vec<CatchObject>;
/// Process each [`HitObject`](crate::parse::HitObject) into a an osu!mania-specific [`ManiaObject`],
/// just like the difficulty calculation does.
///
/// Clockrate is *not* considered.
fn mania_hitobjects(&self) -> Vec<ManiaObject>;
}
impl BeatmapExt for Beatmap {
#[inline]
fn stars(&self) -> AnyStars<'_> {
match self.mode {
GameMode::Osu => AnyStars::Osu(OsuStars::new(self)),
GameMode::Taiko => AnyStars::Taiko(TaikoStars::new(self)),
GameMode::Catch => AnyStars::Catch(CatchStars::new(self)),
GameMode::Mania => AnyStars::Mania(ManiaStars::new(self)),
}
}
#[inline]
fn max_pp(&self, mods: u32) -> PerformanceAttributes {
match self.mode {
GameMode::Osu => PerformanceAttributes::Osu(OsuPP::new(self).mods(mods).calculate()),
GameMode::Taiko => {
PerformanceAttributes::Taiko(TaikoPP::new(self).mods(mods).calculate())
}
GameMode::Catch => {
PerformanceAttributes::Catch(CatchPP::new(self).mods(mods).calculate())
}
GameMode::Mania => {
PerformanceAttributes::Mania(ManiaPP::new(self).mods(mods).calculate())
}
}
}
#[inline]
fn pp(&self) -> AnyPP<'_> {
AnyPP::new(self)
}
#[inline]
fn strains(&self, mods: u32) -> Strains {
match self.mode {
GameMode::Osu => Strains::Osu(OsuStars::new(self).mods(mods).strains()),
GameMode::Taiko => Strains::Taiko(TaikoStars::new(self).mods(mods).strains()),
GameMode::Catch => Strains::Catch(CatchStars::new(self).mods(mods).strains()),
GameMode::Mania => Strains::Mania(ManiaStars::new(self).mods(mods).strains()),
}
}
fn osu_hitobjects(&self, mods: u32) -> Vec<OsuObject> {
let attrs = self.attributes().mods(mods).build();
let scaling_factor = ScalingFactor::new(attrs.cs);
let hr = mods.hr();
let time_preempt = f64::from((attrs.hit_windows.ar * attrs.clock_rate) as f32);
let mut attrs = OsuDifficultyAttributes::default();
crate::osu::create_osu_objects(
self,
&mut attrs,
&scaling_factor,
usize::MAX,
hr,
time_preempt,
)
}
fn taiko_hitobjects(&self) -> Vec<TaikoObject> {
let map = self.convert_mode(GameMode::Taiko);
map.taiko_objects()
.map(|(h, start_time)| TaikoObject {
start_time,
is_hit: h.is_hit,
is_rim: h.is_rim,
})
.collect()
}
fn catch_hitobjects(&self, mods: u32) -> Vec<CatchObject> {
let attrs = self.attributes().mods(mods).build();
let mut params = FruitParams {
attributes: CatchDifficultyAttributes::default(),
curve_bufs: CurveBuffers::default(),
last_pos: None,
last_time: 0.0,
ticks: Vec::new(),
with_hr: mods.hr(),
};
let mut hit_objects: Vec<_> = self
.hit_objects
.iter()
.filter_map(|h| FruitOrJuice::new(h, &mut params, self))
.flatten()
.collect();
let half_catcher_width =
f64::from(calculate_catch_width(attrs.cs as f32) / 2.0 / ALLOWED_CATCH_RANGE);
let mut last_direction = 0;
let mut last_excess = half_catcher_width;
for i in 1..hit_objects.len() {
// SAFETY: The indices are guaranteed the be included based on the loop condition
let window = unsafe { hit_objects.get_unchecked_mut(i - 1..=i) };
let [curr, next] = window else { unreachable!() };
curr.init_hyper_dash(
half_catcher_width,
&*next,
&mut last_direction,
&mut last_excess,
);
}
hit_objects
}
fn mania_hitobjects(&self) -> Vec<ManiaObject> {
let map = self.convert_mode(GameMode::Mania);
let total_columns = map.cs.round_even().max(1.0);
let mut params = ObjectParameters::new(map.as_ref());
self.hit_objects
.iter()
.map(|h| ManiaObject::new(h, total_columns, &mut params))
.collect()
}
}
-232
View File
@@ -1,232 +0,0 @@
use std::{borrow::Cow, cmp::Ordering, collections::HashMap};
use crate::parse::HitObject;
pub use self::{
attributes::{BeatmapAttributes, BeatmapAttributesBuilder, BeatmapHitWindows},
breaks::Break,
control_points::{DifficultyPoint, EffectPoint, TimingPoint},
ext::*,
mode::GameMode,
sorted_vec::{Sortable, SortedVec},
};
mod attributes;
mod breaks;
mod control_points;
mod converts;
mod ext;
mod mode;
mod sorted_vec;
/// The main beatmap struct containing all data relevant
/// for difficulty and performance calculation
#[derive(Clone, Default, Debug)]
pub struct Beatmap {
/// The game mode.
pub mode: GameMode,
/// The version of the .osu file.
pub version: u8,
/// The amount of circles.
pub n_circles: u32,
/// The amount of sliders.
pub n_sliders: u32,
/// The amount of spinners.
pub n_spinners: u32,
/// The approach rate.
pub ar: f32,
/// The overall difficulty.
pub od: f32,
/// The circle size.
pub cs: f32,
/// The health drain rate.
pub hp: f32,
/// Base slider velocity in pixels per beat
pub slider_mult: f64,
/// Amount of slider ticks per beat.
pub tick_rate: f64,
/// All hitobjects of the beatmap.
pub hit_objects: Vec<HitObject>,
/// Store the sounds for all objects in their own Vec to minimize the struct size.
/// Hitsounds are only used in osu!taiko in which they represent color.
pub sounds: Vec<u8>,
/// Timing points that indicate a new timing section.
pub timing_points: SortedVec<TimingPoint>,
/// Timing point for the current timing section.
pub difficulty_points: SortedVec<DifficultyPoint>,
/// Control points for effect sections.
pub effect_points: SortedVec<EffectPoint>,
/// The stack leniency that is used to calculate
/// the stack offset for stacked positions.
pub stack_leniency: f32,
/// All break points of the beatmap.
pub breaks: Vec<Break>,
}
impl Beatmap {
/// Extract a beatmap's attributes into their own type.
#[inline]
pub fn attributes(&self) -> BeatmapAttributesBuilder {
BeatmapAttributesBuilder::new(self)
}
/// The beats per minute of the map.
#[inline]
pub fn bpm(&self) -> f64 {
// This is incorrect if the last object is a slider since there
// is no reasonable way to get the slider end time at this point.
let last_time = self
.hit_objects
.last()
.map(HitObject::end_time)
.or_else(|| self.timing_points.last().map(|t| t.time))
.unwrap_or(0.0);
/// Maps `beat_len` to a cumulative duration
#[derive(Debug)]
struct BeatLenDuration {
last_time: f64,
map: HashMap<u64, f64>,
}
impl BeatLenDuration {
fn new(last_time: f64) -> Self {
Self {
last_time,
map: HashMap::default(),
}
}
fn add(&mut self, beat_len: f64, curr_time: f64, next_time: f64) {
let beat_len = (1000.0 * beat_len).round() / 1000.0;
let entry = self.map.entry(beat_len.to_bits()).or_default();
if curr_time <= self.last_time {
*entry += next_time - curr_time;
}
}
}
let mut bpm_points = BeatLenDuration::new(last_time);
// * osu-stable forced the first control point to start at 0.
// * This is reproduced here to maintain compatibility around
// * osu!mania scroll speed and song select display.
match &self.timing_points[..] {
[curr] => bpm_points.add(curr.beat_len, 0.0, last_time),
[curr, next, ..] => bpm_points.add(curr.beat_len, 0.0, next.time),
[] => {}
}
self.timing_points
.iter()
.skip(1)
.zip(self.timing_points.iter().skip(2).map(|t| t.time))
.for_each(|(curr, next_time)| bpm_points.add(curr.beat_len, curr.time, next_time));
if let [.., _, curr] = &self.timing_points[..] {
bpm_points.add(curr.beat_len, curr.time, last_time);
}
let most_common_beat_len = bpm_points
.map
.into_iter()
// * Get the most common one, or 0 as a suitable default
.max_by(|(_, a), (_, b)| a.total_cmp(b))
.map_or(0.0, |(beat_len, _)| f64::from_bits(beat_len));
60_000.0 / most_common_beat_len
}
/// Sum up the duration of all breaks (in milliseconds).
#[inline]
pub fn total_break_time(&self) -> f64 {
self.breaks.iter().map(Break::duration).sum()
}
/// Return the [`TimingPoint`] for the given timestamp.
#[inline]
pub fn timing_point_at(&self, time: f64) -> TimingPoint {
let idx_result = self
.timing_points
.binary_search_by(|probe| probe.time.partial_cmp(&time).unwrap_or(Ordering::Less));
match idx_result {
Ok(idx) => self.timing_points[idx],
Err(0) => self.timing_points.first().copied().unwrap_or_default(),
Err(idx) => self.timing_points[idx - 1],
}
}
/// Return the [`DifficultyPoint`] for the given timestamp.
///
/// If `time` is before the first difficulty point, `None` is returned.
#[inline]
pub fn difficulty_point_at(&self, time: f64) -> Option<DifficultyPoint> {
self.difficulty_points
.binary_search_by(|probe| probe.time.partial_cmp(&time).unwrap_or(Ordering::Less))
.map_or_else(|i| i.checked_sub(1), Some)
.map(|i| self.difficulty_points[i])
}
/// Return the [`EffectPoint`] for the given timestamp.
///
/// If `time` is before the first effect point, `None` is returned.
#[inline]
pub fn effect_point_at(&self, time: f64) -> Option<EffectPoint> {
self.effect_points
.binary_search_by(|probe| probe.time.partial_cmp(&time).unwrap_or(Ordering::Less))
.map_or_else(|i| i.checked_sub(1), Some)
.map(|i| self.effect_points[i])
}
/// Convert a [`Beatmap`] of some mode into a different mode.
///
/// # Note
/// - Since hitsounds are irrelevant for difficulty and performance calculations
/// in osu!mania, the resulting map of a conversion to mania will not contain hitsounds.
/// - To avoid having to clone the map for osu!catch conversions, the field `Beatmap::mode`
/// will not be adjusted in a osu!catch-converted map.
#[inline]
pub fn convert_mode(&self, mode: GameMode) -> Cow<'_, Self> {
if mode == self.mode {
return Cow::Borrowed(self);
}
match mode {
GameMode::Osu | GameMode::Catch => Cow::Borrowed(self),
GameMode::Taiko => Cow::Owned(self.convert_to_taiko()),
GameMode::Mania => Cow::Owned(self.convert_to_mania()),
}
}
fn clone_without_hit_objects(&self, with_sounds: bool) -> Self {
Self {
mode: self.mode,
version: self.version,
n_circles: 0,
n_sliders: 0,
n_spinners: 0,
ar: self.ar,
od: self.od,
cs: self.cs,
hp: self.hp,
slider_mult: self.slider_mult,
tick_rate: self.tick_rate,
hit_objects: Vec::with_capacity(self.hit_objects.len()),
sounds: Vec::with_capacity((usize::from(with_sounds)) * self.sounds.len()),
timing_points: self.timing_points.clone(),
difficulty_points: self.difficulty_points.clone(),
effect_points: self.effect_points.clone(),
stack_leniency: self.stack_leniency,
breaks: self.breaks.clone(),
}
}
}
-28
View File
@@ -1,28 +0,0 @@
/// The mode of a beatmap.
#[derive(Copy, Clone, Debug, Default, Hash, PartialEq, Eq)]
pub enum GameMode {
/// osu!standard
#[default]
Osu = 0,
/// osu!taiko
Taiko = 1,
/// osu!catch
Catch = 2,
/// osu!mania
Mania = 3,
}
impl From<u8> for GameMode {
#[inline]
fn from(mode: u8) -> Self {
// `0` will happen most commonly so it should be the first branch
#[allow(clippy::match_same_arms)]
match mode {
0 => Self::Osu,
1 => Self::Taiko,
2 => Self::Catch,
3 => Self::Mania,
_ => Self::Osu,
}
}
}
-293
View File
@@ -1,293 +0,0 @@
use std::{
cmp::Ordering,
fmt::{Debug, Formatter, Result as FmtResult},
ops::{Deref, Index},
slice::SliceIndex,
};
use crate::beatmap::{DifficultyPoint, EffectPoint, TimingPoint};
/// A [`Vec`] whose elements are guaranteed to be unique and in order.
#[derive(Clone)]
pub struct SortedVec<T> {
inner: Vec<T>,
}
impl<T> SortedVec<T> {
/// Constructs a new, empty `SortedVec<T>`.
#[inline]
pub const fn new() -> Self {
Self { inner: Vec::new() }
}
/// Constructs a new, empty `SortedVec<T>` with at least the specified capacity.
#[inline]
pub fn with_capacity(capacity: usize) -> Self {
Self {
inner: Vec::with_capacity(capacity),
}
}
/// Extracts the inner [`Vec`].
#[inline]
pub fn into_inner(self) -> Vec<T> {
self.inner
}
/// Extracts a slice containing the entire sorted vector.
pub fn as_slice(&self) -> &[T] {
self.inner.as_slice()
}
/// Returns a mutable reference to the underlying `Vec`.
///
/// # Safety
///
/// The caller must ensure that the items stay in order.
pub unsafe fn as_inner_mut(&mut self) -> &mut Vec<T> {
&mut self.inner
}
/// Removes the last element and returns it, or `None` if the vec is empty.
pub fn pop(&mut self) -> Option<T> {
self.inner.pop()
}
/// Retains only the elements specified by the predicate.
///
/// In other words, remove all items `i` for which `f(&i)` returns `false`.
pub fn retain<F>(&mut self, f: F)
where
F: FnMut(&T) -> bool,
{
self.inner.retain(f);
}
}
impl<T: Sortable> SortedVec<T> {
/// Same as [`slice::binary_search_by`] with the function
/// [`<T as Sortable>::cmp`](Sortable::cmp).
#[inline]
pub fn find(&self, value: &T) -> Result<usize, usize> {
self.inner
.binary_search_by(|probe| <T as Sortable>::cmp(probe, value))
}
/// Push a new value into the sorted list based on [`<T as Sortable>::push`](Sortable::push).
pub fn push(&mut self, value: T) {
<T as Sortable>::push(value, self);
}
}
impl<T> Deref for SortedVec<T> {
type Target = [T];
#[inline]
fn deref(&self) -> &Self::Target {
<Vec<T> as Deref>::deref(&self.inner)
}
}
impl<T, I> Index<I> for SortedVec<T>
where
I: SliceIndex<[T]>,
{
type Output = <I as SliceIndex<[T]>>::Output;
#[inline]
fn index(&self, index: I) -> &Self::Output {
<Vec<T> as Index<I>>::index(&self.inner, index)
}
}
impl<T: Debug> Debug for SortedVec<T> {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
<Vec<T> as Debug>::fmt(&self.inner, f)
}
}
impl<T> Default for SortedVec<T> {
#[inline]
fn default() -> Self {
Self::new()
}
}
impl<T: Sortable> From<Vec<T>> for SortedVec<T> {
fn from(mut v: Vec<T>) -> Self {
v.sort_by(<T as Sortable>::cmp);
v.dedup_by(|a, b| {
<T as Sortable>::cmp(a, b) == Ordering::Equal || <T as Sortable>::is_redundant(b, a)
});
Self { inner: v }
}
}
impl<T: Sortable> FromIterator<T> for SortedVec<T> {
fn from_iter<I: IntoIterator<Item = T>>(iter: I) -> Self {
Self::from(Vec::from_iter(iter))
}
}
impl<T: Sortable> Extend<T> for SortedVec<T> {
fn extend<I: IntoIterator<Item = T>>(&mut self, iter: I) {
for value in iter {
self.push(value);
}
}
}
/// Trait for types that can be sorted in a [`SortedVec`].
pub trait Sortable: Sized {
/// An [`Ordering`] between `self` and `other`.
fn cmp(&self, other: &Self) -> Ordering;
/// Indicates whether `self` and `_existing` are identical.
#[inline]
fn is_redundant(&self, _existing: &Self) -> bool {
false
}
/// Pushes a value into the [`SortedVec`].
#[inline]
fn push(self, sorted_vec: &mut SortedVec<Self>) {
match sorted_vec.find(&self) {
Ok(i) => sorted_vec.inner[i] = self,
Err(i) if i == sorted_vec.len() => sorted_vec.inner.push(self),
Err(i) => sorted_vec.inner.insert(i, self),
}
}
}
impl Sortable for TimingPoint {
#[inline]
fn cmp(&self, other: &Self) -> Ordering {
self.partial_cmp(other).unwrap_or(Ordering::Equal)
}
}
impl Sortable for DifficultyPoint {
#[inline]
fn cmp(&self, other: &Self) -> Ordering {
self.partial_cmp(other).unwrap_or(Ordering::Equal)
}
#[inline]
fn is_redundant(&self, existing: &Self) -> bool {
self.is_redundant(existing)
}
fn push(self, sorted_vec: &mut SortedVec<Self>) {
enum Action {
Insert(usize),
Replace(usize),
Push,
Skip,
}
let action = match sorted_vec.find(&self).map_err(|idx| idx.checked_sub(1)) {
Ok(i) | Err(Some(i)) if self.is_redundant(&sorted_vec[i]) => Action::Skip,
Ok(i) => Action::Replace(i),
Err(Some(i)) if i == sorted_vec.len() - 1 => Action::Push,
Err(Some(i)) => Action::Insert(i),
Err(None) if self.is_redundant(&Self::default()) => Action::Skip,
Err(None) => Action::Insert(0),
};
match action {
Action::Insert(i) => sorted_vec.inner.insert(i, self),
Action::Replace(i) => sorted_vec.inner[i] = self,
Action::Push => sorted_vec.inner.push(self),
Action::Skip => {}
}
}
}
impl Sortable for EffectPoint {
#[inline]
fn cmp(&self, other: &Self) -> Ordering {
self.partial_cmp(other).unwrap_or(Ordering::Equal)
}
}
impl<T: Ord> Sortable for T {
#[inline]
fn cmp(&self, other: &Self) -> Ordering {
<Self as Ord>::cmp(self, other)
}
}
#[cfg(test)]
mod tests {
use crate::beatmap::DifficultyPoint;
use super::SortedVec;
#[test]
fn sorts_on_push() {
let mut v = SortedVec::with_capacity(4);
v.push(42);
v.push(13);
v.push(20);
v.push(0);
assert_eq!(v.as_slice(), &[0_i32, 13, 20, 42]);
}
#[test]
fn no_push_if_redundant() {
let mut v = SortedVec::default();
v.push(DifficultyPoint::default());
assert_eq!(v.len(), 0);
v.push(DifficultyPoint::new(1.0, 2.0, 3.0));
assert_eq!(v.len(), 1);
v.push(DifficultyPoint::new(2.0, 2.0, 3.0));
v.push(DifficultyPoint::default());
assert_eq!(v.len(), 1);
}
#[test]
fn from_iter() {
let base = vec![
DifficultyPoint {
time: 5.0,
slider_vel: 10.0,
bpm_mult: 1.0,
generate_ticks: true,
},
DifficultyPoint {
time: 3.0,
slider_vel: 20.0,
bpm_mult: 2.0,
generate_ticks: false,
},
DifficultyPoint {
time: 6.0,
slider_vel: 10.0,
bpm_mult: 3.0,
generate_ticks: true,
},
DifficultyPoint {
time: 10.0,
slider_vel: 15.0,
bpm_mult: 4.0,
generate_ticks: true,
},
];
let sorted = SortedVec::from_iter(base);
let v: Vec<_> = sorted
.into_inner()
.into_iter()
.map(|tp| tp.bpm_mult)
.collect();
assert_eq!(v, vec![2.0, 1.0, 4.0]);
}
}
+113
View File
@@ -0,0 +1,113 @@
use crate::catch::performance::CatchPerformance;
/// The result of a difficulty calculation on an osu!catch map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CatchDifficultyAttributes {
/// The final star rating
pub stars: f64,
/// The approach rate.
pub ar: f64,
/// The amount of fruits.
pub n_fruits: u32,
/// The amount of droplets.
pub n_droplets: u32,
/// The amount of tiny droplets.
pub n_tiny_droplets: u32,
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
///
/// [`Beatmap`]: crate::model::beatmap::Beatmap
pub is_convert: bool,
}
impl CatchDifficultyAttributes {
/// Return the maximum combo.
pub const fn max_combo(&self) -> u32 {
self.n_fruits + self.n_droplets
}
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
///
/// [`Beatmap`]: crate::model::beatmap::Beatmap
pub const fn is_convert(&self) -> bool {
self.is_convert
}
/// Returns a builder for performance calculation.
pub fn pp<'a>(self) -> CatchPerformance<'a> {
self.into()
}
}
/// The result of a performance calculation on an osu!catch map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CatchPerformanceAttributes {
/// The difficulty attributes that were used for the performance calculation
pub difficulty: CatchDifficultyAttributes,
/// The final performance points.
pub pp: f64,
}
impl CatchPerformanceAttributes {
/// Return the star value.
pub const fn stars(&self) -> f64 {
self.difficulty.stars
}
/// Return the performance point value.
pub const fn pp(&self) -> f64 {
self.pp
}
/// Return the maximum combo of the map.
pub const fn max_combo(&self) -> u32 {
self.difficulty.max_combo()
}
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
///
/// [`Beatmap`]: crate::model::beatmap::Beatmap
pub const fn is_convert(&self) -> bool {
self.difficulty.is_convert
}
}
pub struct CatchDifficultyAttributesBuilder {
inner: CatchDifficultyAttributes,
take: usize,
}
impl CatchDifficultyAttributesBuilder {
pub const fn new(attrs: CatchDifficultyAttributes, take: usize) -> Self {
Self { inner: attrs, take }
}
pub const fn into_inner(self) -> CatchDifficultyAttributes {
self.inner
}
pub const fn take_more(&self) -> bool {
self.take > 0
}
pub fn inc_fruits(&mut self) {
Self::inc_value(&mut self.inner.n_fruits, &mut self.take);
}
pub fn inc_droplets(&mut self) {
Self::inc_value(&mut self.inner.n_droplets, &mut self.take);
}
/// Should only be used if [`take_more`] returns `true`.
///
/// [`take_more`]: Self::take_more
pub fn inc_tiny_droplets(&mut self) {
self.inner.n_tiny_droplets += 1;
}
fn inc_value(value: &mut u32, take: &mut usize) {
if *take > 0 {
*value += 1;
*take -= 1;
}
}
}
-95
View File
@@ -1,95 +0,0 @@
use crate::parse::Pos2;
use super::fruit_or_juice::FruitParams;
const PLAYFIELD_WIDTH: f32 = 512.0;
const BASE_SPEED: f64 = 1.0;
/// A [`HitObject`](crate::parse::HitObject) that was processed for the osu!ctb gamemode.
#[derive(Clone, Debug, PartialEq)]
pub struct CatchObject {
/// The X position of the object.
pub pos: f32,
/// The time of the object.
pub time: f64,
/// Whether the object is a hyper dash.
pub hyper_dash: bool,
/// The hyper distance to the next object
pub hyper_dist: f32,
}
impl CatchObject {
#[inline]
pub(crate) const fn new((pos, time): (Pos2, f64)) -> Self {
Self {
pos: pos.x,
time,
hyper_dash: false,
hyper_dist: 0.0,
}
}
pub(crate) fn with_hr(mut self, params: &mut FruitParams) -> Self {
let mut offset_pos = self.pos;
let time_diff = self.time - params.last_time;
if let Some(last_pos_ref) = params.last_pos.filter(|_| time_diff <= 1000.0) {
let pos_diff = offset_pos - last_pos_ref;
if pos_diff.abs() > f32::EPSILON {
if pos_diff.abs() < (time_diff as f32 / 3.0).floor() {
if pos_diff > 0.0 {
if offset_pos + pos_diff < PLAYFIELD_WIDTH {
offset_pos += pos_diff;
}
} else if offset_pos + pos_diff > 0.0 {
offset_pos += pos_diff;
}
}
params.last_pos.replace(offset_pos);
params.last_time = self.time;
}
self.pos = offset_pos;
} else {
params.last_pos.replace(offset_pos);
params.last_time = self.time;
}
self
}
pub(crate) fn init_hyper_dash(
&mut self,
half_catcher_width: f64,
next: &CatchObject,
last_direction: &mut i8,
last_excess: &mut f64,
) {
let next_x = next.pos;
let curr_x = self.pos;
let this_direction = i8::from(next_x > curr_x) * 2 - 1;
let time_to_next = next.time - self.time - 1000.0 / 60.0 / 4.0;
let sub = if *last_direction == this_direction {
*last_excess
} else {
half_catcher_width
};
let dist_to_next = f64::from((next_x - curr_x).abs()) - sub;
let hyper_dist = (time_to_next * BASE_SPEED - dist_to_next) as f32;
if hyper_dist < 0.0 {
self.hyper_dash = true;
*last_excess = half_catcher_width;
} else {
self.hyper_dist = hyper_dist;
*last_excess = f64::from(hyper_dist).clamp(0.0, half_catcher_width);
}
*last_direction = this_direction;
}
}
+20
View File
@@ -0,0 +1,20 @@
pub struct Catcher;
const AREA_CATCHER_SIZE: f32 = 106.75;
impl Catcher {
pub const BASE_SPEED: f64 = 1.0;
pub const ALLOWED_CATCH_RANGE: f32 = 0.8;
pub fn calculate_catch_width(cs: f32) -> f32 {
Self::calculate_catch_width_by_scale(Self::calculate_scale(cs))
}
fn calculate_catch_width_by_scale(scale: f32) -> f32 {
AREA_CATCHER_SIZE * scale.abs() * Self::ALLOWED_CATCH_RANGE
}
fn calculate_scale(cs: f32) -> f32 {
1.0 - 0.7 * (cs - 5.0) / 5.0
}
}
+315
View File
@@ -0,0 +1,315 @@
use std::borrow::Cow;
use rosu_map::section::{general::GameMode, hit_objects::CurveBuffers};
use crate::{
model::{
beatmap::{Beatmap, Converted},
hit_object::{HitObject, HitObjectKind, HoldNote, Spinner},
mode::ConvertStatus,
},
util::{float_ext::FloatExt, random::Random},
};
use super::{
attributes::CatchDifficultyAttributesBuilder,
catcher::Catcher,
object::{
banana_shower::BananaShower,
fruit::Fruit,
juice_stream::{JuiceStream, JuiceStreamBufs, NestedJuiceStreamObjectKind},
palpable::PalpableObject,
},
Catch, PLAYFIELD_WIDTH,
};
const RNG_SEED: i32 = 1337;
/// A [`Beatmap`] for [`Catch`] calculations.
pub type CatchBeatmap<'a> = Converted<'a, Catch>;
pub fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus {
match map.mode {
GameMode::Osu => {
map.to_mut().mode = GameMode::Catch;
ConvertStatus::Done
}
GameMode::Catch => ConvertStatus::Noop,
GameMode::Taiko | GameMode::Mania => ConvertStatus::Incompatible,
}
}
pub fn convert_objects(
converted: &CatchBeatmap<'_>,
attrs: &mut CatchDifficultyAttributesBuilder,
hr: bool,
cs: f32,
) -> Vec<PalpableObject> {
// TODO: check for better default capacity
let mut palpable_objects = Vec::with_capacity(converted.map.hit_objects.len() * 2);
let mut bufs = JuiceStreamBufs {
curve: CurveBuffers::default(),
nested_objects: Vec::new(), // TODO: default capacity
ticks: Vec::new(), // TODO: default capacity
};
let mut rng = Random::new(RNG_SEED);
let mut last_pos = None;
let mut last_start_time = 0.0;
for h in converted.map.hit_objects.iter() {
let mut new_objects = convert_object(h, converted, attrs, &mut bufs);
apply_pos_offset(
&mut new_objects,
hr,
&mut last_pos,
&mut last_start_time,
&mut rng,
);
palpable_objects.extend(new_objects);
}
palpable_objects.sort_by(|a, b| a.start_time.total_cmp(&b.start_time));
initialize_hyper_dash(cs, &mut palpable_objects);
palpable_objects
}
fn convert_object<'a>(
h: &'a HitObject,
converted: &CatchBeatmap<'_>,
attrs: &mut CatchDifficultyAttributesBuilder,
bufs: &'a mut JuiceStreamBufs,
) -> ObjectIter<'a> {
let state = match h.kind {
HitObjectKind::Circle => ObjectIterState::Fruit(Some(Fruit::new(attrs))),
HitObjectKind::Slider(ref slider) => {
let stream = JuiceStream::new(h.pos.x, h.start_time, slider, converted, attrs, bufs);
ObjectIterState::JuiceStream(stream)
}
HitObjectKind::Spinner(Spinner { end_time })
| HitObjectKind::Hold(HoldNote { end_time }) => {
ObjectIterState::BananaShower(BananaShower::new(h.start_time, end_time))
}
};
ObjectIter {
x: h.pos.x,
start_time: h.start_time,
state,
}
}
struct ObjectIter<'a> {
x: f32,
start_time: f64,
state: ObjectIterState<'a>,
}
enum ObjectIterState<'a> {
Fruit(Option<Fruit>),
JuiceStream(JuiceStream<'a>),
BananaShower(BananaShower),
}
impl Iterator for ObjectIter<'_> {
type Item = PalpableObject;
fn next(&mut self) -> Option<Self::Item> {
match self.state {
ObjectIterState::Fruit(ref mut fruit) => fruit
.take()
.map(|fruit| PalpableObject::new(self.x, fruit.x_offset, self.start_time)),
ObjectIterState::JuiceStream(ref mut stream) => stream
.nested_objects
.find(|nested| !matches!(nested.kind, NestedJuiceStreamObjectKind::TinyDroplet))
.map(|nested| PalpableObject::new(nested.pos, 0.0, nested.start_time)),
ObjectIterState::BananaShower(_) => None,
}
}
fn size_hint(&self) -> (usize, Option<usize>) {
let len = self.len();
(len, Some(len))
}
}
impl ExactSizeIterator for ObjectIter<'_> {
fn len(&self) -> usize {
match self.state {
ObjectIterState::Fruit(ref fruit) => usize::from(fruit.is_some()),
ObjectIterState::JuiceStream(ref stream) => stream.nested_objects.len(),
ObjectIterState::BananaShower(_) => 0,
}
}
}
fn apply_pos_offset(
hit_object: &mut ObjectIter<'_>,
should_apply_hr_offset: bool,
last_pos: &mut Option<f32>,
last_start_time: &mut f64,
rng: &mut Random,
) {
match hit_object.state {
ObjectIterState::Fruit(Some(ref mut fruit)) => {
if should_apply_hr_offset {
apply_hr_offset(
hit_object.x,
&mut fruit.x_offset,
hit_object.start_time,
last_pos,
last_start_time,
rng,
);
}
}
ObjectIterState::JuiceStream(ref stream) => {
let pos = hit_object.x
+ stream
.control_points
.last()
.map_or(0.0, |control_point| control_point.pos.x);
*last_pos = Some(pos);
*last_start_time = hit_object.start_time;
for nested in stream.nested_objects.as_slice() {
if let NestedJuiceStreamObjectKind::Droplet
| NestedJuiceStreamObjectKind::TinyDroplet = nested.kind
{
let _ = rng.next_int();
}
}
}
ObjectIterState::BananaShower(ref shower) => {
for _ in 0..shower.n_bananas {
let _ = rng.next_double();
let _ = rng.next_int();
let _ = rng.next_int();
let _ = rng.next_int();
}
}
ObjectIterState::Fruit(None) => unreachable!(),
}
}
fn apply_hr_offset(
x: f32,
x_offset: &mut f32,
start_time: f64,
last_pos: &mut Option<f32>,
last_start_time: &mut f64,
rng: &mut Random,
) {
let mut offset_pos = x;
let Some(last_pos) = last_pos else {
*last_pos = Some(offset_pos);
*last_start_time = start_time;
return;
};
let pos_diff = offset_pos - *last_pos;
let time_diff = (start_time - *last_start_time) as i32;
if time_diff > 1000 {
*last_pos = offset_pos;
*last_start_time = start_time;
return;
}
if pos_diff.eq(0.0) {
apply_random_offset(&mut offset_pos, f64::from(time_diff) / 4.0, rng);
*x_offset = offset_pos - x;
return;
}
if pos_diff.abs() < (time_diff / 3) as f32 {
apply_offset(&mut offset_pos, pos_diff);
}
*x_offset = offset_pos - x;
*last_pos = offset_pos;
*last_start_time = start_time;
}
fn apply_random_offset(pos: &mut f32, max_offset: f64, rng: &mut Random) {
let right = rng.next_bool();
let rand = (rng.next_double_range(0.0, max_offset.max(0.0)) as f32).min(20.0);
if right {
if *pos + rand <= PLAYFIELD_WIDTH {
*pos += rand;
} else {
*pos -= rand;
}
} else if *pos - rand >= 0.0 {
*pos -= rand;
} else {
*pos += rand;
}
}
fn apply_offset(pos: &mut f32, amount: f32) {
if amount > 0.0 {
if *pos + amount < PLAYFIELD_WIDTH {
*pos += amount;
}
} else if *pos + amount > 0.0 {
*pos += amount;
}
}
fn initialize_hyper_dash(cs: f32, palpable_objects: &mut [PalpableObject]) {
let mut half_catcher_width = f64::from(Catcher::calculate_catch_width(cs) / 2.0);
half_catcher_width /= f64::from(Catcher::ALLOWED_CATCH_RANGE);
let mut last_dir = 0;
let mut last_excess = half_catcher_width;
for i in 0..palpable_objects.len() - 1 {
let next = &palpable_objects[i + 1];
let curr = &palpable_objects[i];
let this_dir = if next.effective_x() > curr.effective_x() {
1
} else {
-1
};
let time_to_next = next.start_time - curr.start_time - f64::from(1000.0_f32 / 60.0 / 4.0);
let dist_to_next = f64::from((next.effective_x() - curr.effective_x()).abs())
- if last_dir == this_dir {
last_excess
} else {
half_catcher_width
};
let dist_to_hyper = (time_to_next * Catcher::BASE_SPEED - dist_to_next) as f32;
let curr = &mut palpable_objects[i];
if dist_to_hyper < 0.0 {
curr.hyper_dash = true;
last_excess = half_catcher_width;
} else {
curr.dist_to_hyper_dash = dist_to_hyper;
last_excess = f64::from(dist_to_hyper).clamp(0.0, half_catcher_width);
}
last_dir = this_dir;
}
}
+104
View File
@@ -0,0 +1,104 @@
use crate::{
any::difficulty::{skills::Skill, ModeDifficulty},
catch::{
catcher::Catcher, convert::convert_objects, difficulty::object::CatchDifficultyObject,
},
util::mods::Mods,
};
use self::skills::movement::Movement;
use super::{
attributes::{CatchDifficultyAttributes, CatchDifficultyAttributesBuilder},
convert::CatchBeatmap,
};
mod object;
mod skills;
const STAR_SCALING_FACTOR: f64 = 0.153;
pub fn difficulty(
difficulty: &ModeDifficulty,
converted: &CatchBeatmap<'_>,
) -> CatchDifficultyAttributes {
let DifficultyValues {
movement,
mut attrs,
} = DifficultyValues::calculate(difficulty, converted);
attrs.stars = movement.difficulty_value().sqrt() * STAR_SCALING_FACTOR;
attrs
}
pub struct DifficultyValues {
pub movement: Movement,
pub attrs: CatchDifficultyAttributes,
}
impl DifficultyValues {
pub fn calculate(difficulty: &ModeDifficulty, converted: &CatchBeatmap<'_>) -> Self {
let take = difficulty.get_passed_objects();
let clock_rate = difficulty.get_clock_rate();
let map_attrs = converted
.attributes()
.mods(difficulty.get_mods())
.clock_rate(clock_rate)
.build();
let attrs = CatchDifficultyAttributes {
ar: map_attrs.ar,
is_convert: converted.is_convert,
..Default::default()
};
let mut attrs = CatchDifficultyAttributesBuilder::new(attrs, take);
let hr = difficulty.get_mods().hr();
let movement = Movement::new(clock_rate);
let palpable_objects = convert_objects(converted, &mut attrs, hr, map_attrs.cs as f32);
let mut palpable_objects_iter = palpable_objects.iter().take(take);
let Some(mut last_object) = palpable_objects_iter.next() else {
return Self {
movement,
attrs: attrs.into_inner(),
};
};
let mut half_catcher_width = Catcher::calculate_catch_width(map_attrs.cs as f32) * 0.5;
half_catcher_width *= 1.0 - ((map_attrs.cs as f32 - 5.5).max(0.0) * 0.0625);
let scaling_factor =
CatchDifficultyObject::NORMALIZED_HITOBJECT_RADIUS / half_catcher_width;
let diff_objects: Vec<_> = palpable_objects_iter
.enumerate()
.map(|(i, hit_object)| {
let diff_object = CatchDifficultyObject::new(
hit_object,
last_object,
clock_rate,
scaling_factor,
i,
);
last_object = hit_object;
diff_object
})
.collect();
let mut movement = Skill::new(movement, &diff_objects);
for curr in diff_objects.iter() {
movement.process(curr);
}
Self {
movement: movement.inner,
attrs: attrs.into_inner(),
}
}
}
+56
View File
@@ -0,0 +1,56 @@
use crate::{any::difficulty::object::IDifficultyObject, catch::object::palpable::PalpableObject};
pub struct CatchDifficultyObject {
pub idx: usize,
pub start_time: f64,
pub delta_time: f64,
pub normalized_pos: f32,
pub last_normalized_pos: f32,
pub strain_time: f64,
pub last_object: LastObject,
}
impl CatchDifficultyObject {
pub const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0;
pub fn new(
hit_object: &PalpableObject,
last_object: &PalpableObject,
clock_rate: f64,
scaling_factor: f32,
idx: usize,
) -> Self {
let normalized_pos = hit_object.effective_x() * scaling_factor;
let last_normalized_pos = last_object.effective_x() * scaling_factor;
let start_time = hit_object.start_time / clock_rate;
let delta_time = (hit_object.start_time - last_object.start_time) / clock_rate;
let strain_time = delta_time.max(40.0);
let last_object = LastObject {
hyper_dash: last_object.hyper_dash,
dist_to_hyper_dash: last_object.dist_to_hyper_dash,
};
Self {
idx,
start_time,
delta_time,
normalized_pos,
last_normalized_pos,
strain_time,
last_object,
}
}
}
pub struct LastObject {
pub hyper_dash: bool,
pub dist_to_hyper_dash: f32,
}
impl IDifficultyObject for CatchDifficultyObject {
fn idx(&self) -> usize {
self.idx
}
}
+1
View File
@@ -0,0 +1 @@
pub mod movement;
+163
View File
@@ -0,0 +1,163 @@
use crate::{
any::difficulty::{
object::IDifficultyObject,
skills::{strain_decay, ISkill, Skill, StrainDecaySkill},
},
catch::difficulty::object::CatchDifficultyObject,
};
const ABSOLUTE_PLAYER_POSITIONING_ERROR: f32 = 16.0;
const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0;
const DIRECTION_CHANGE_BONUS: f64 = 21.0;
const SKILL_MULTIPLIER: f64 = 900.0;
const STRAIN_DECAY_BASE: f64 = 0.2;
const DECAY_WEIGHT: f64 = 0.94;
const SECTION_LEN: f64 = 750.0;
pub struct Movement {
inner: StrainDecaySkill,
last_player_pos: Option<f32>,
last_dist_moved: f32,
last_strain_time: f64,
clock_rate: f64,
}
impl Movement {
pub fn new(clock_rate: f64) -> Self {
Self {
inner: StrainDecaySkill::default(),
last_player_pos: None,
last_dist_moved: 0.0,
last_strain_time: 0.0,
clock_rate,
}
}
const fn curr_strain(&self) -> f64 {
self.inner.curr_strain
}
fn curr_strain_mut(&mut self) -> &mut f64 {
&mut self.inner.curr_strain
}
fn strain_value_at(&mut self, curr: &CatchDifficultyObject) -> f64 {
*self.curr_strain_mut() *= strain_decay(curr.delta_time, STRAIN_DECAY_BASE);
*self.curr_strain_mut() += self.strain_value_of(curr) * SKILL_MULTIPLIER;
self.curr_strain()
}
fn strain_value_of(&mut self, curr: &CatchDifficultyObject) -> f64 {
let last_player_pos = self.last_player_pos.unwrap_or(curr.last_normalized_pos);
let term = NORMALIZED_HITOBJECT_RADIUS - ABSOLUTE_PLAYER_POSITIONING_ERROR;
let mut player_pos =
last_player_pos.clamp(curr.normalized_pos - term, curr.normalized_pos + term);
let dist_moved = player_pos - last_player_pos;
let weighted_strain_time = curr.strain_time + 13.0 + (3.0 / self.clock_rate);
let mut dist_addition = f64::from(dist_moved.abs()).powf(1.3) / 510.0;
let sqrt_strain = weighted_strain_time.sqrt();
let mut edge_dash_bonus: f64 = 0.0;
if dist_moved.abs() > 0.1 {
if self.last_dist_moved.abs() > 0.1
&& dist_moved.signum() != self.last_dist_moved.signum()
{
let bonus_factor = f64::from(dist_moved.abs().min(50.0) / 50.0);
let anti_flow_factor =
f64::from(self.last_dist_moved.abs().min(70.0) / 70.0).max(0.38);
dist_addition += DIRECTION_CHANGE_BONUS / (self.last_strain_time + 16.0).sqrt()
* bonus_factor
* anti_flow_factor
* (1.0 - (weighted_strain_time / 1000.0).powi(3)).max(0.0);
}
dist_addition += 12.5
* f64::from(dist_moved.abs().min(NORMALIZED_HITOBJECT_RADIUS * 2.0))
/ f64::from(NORMALIZED_HITOBJECT_RADIUS * 6.0)
/ sqrt_strain;
}
if curr.last_object.dist_to_hyper_dash <= 20.0 {
if curr.last_object.hyper_dash {
player_pos = curr.normalized_pos;
} else {
edge_dash_bonus += 5.7;
}
dist_addition *= 1.0
+ edge_dash_bonus
* f64::from((20.0 - curr.last_object.dist_to_hyper_dash) / 20.0)
* ((curr.strain_time * self.clock_rate).min(265.0) / 265.0).powf(1.5);
}
self.last_player_pos = Some(player_pos);
self.last_dist_moved = dist_moved;
self.last_strain_time = curr.strain_time;
dist_addition / weighted_strain_time
}
pub fn get_curr_strain_peaks(self) -> Vec<f64> {
self.inner.get_curr_strain_peaks()
}
pub fn difficulty_value(self) -> f64 {
self.inner.difficulty_value(DECAY_WEIGHT)
}
}
impl ISkill for Movement {
type DifficultyObjects<'a> = [CatchDifficultyObject];
}
impl<'a> Skill<'a, Movement> {
fn calculate_initial_strain(&mut self, time: f64, curr: &CatchDifficultyObject) -> f64 {
let prev_start_time = curr
.previous(0, self.diff_objects)
.map_or(0.0, |prev| prev.start_time);
self.inner.curr_strain() * strain_decay(time - prev_start_time, STRAIN_DECAY_BASE)
}
const fn curr_section_peak(&self) -> f64 {
self.inner.inner.inner.curr_section_peak
}
fn curr_section_peak_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.inner.curr_section_peak
}
const fn curr_section_end(&self) -> f64 {
self.inner.inner.inner.curr_section_end
}
fn curr_section_end_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.inner.curr_section_end
}
pub fn process(&mut self, curr: &CatchDifficultyObject) {
if curr.idx == 0 {
*self.curr_section_end_mut() = (curr.start_time / SECTION_LEN).ceil() * SECTION_LEN;
}
while curr.start_time > self.curr_section_end() {
self.inner.inner.save_curr_peak();
let initial_strain = self.calculate_initial_strain(self.curr_section_end(), curr);
self.inner.inner.start_new_section_from(initial_strain);
*self.curr_section_end_mut() += SECTION_LEN;
}
let strain_value_at = self.inner.strain_value_at(curr);
*self.curr_section_peak_mut() = strain_value_at.max(self.curr_section_peak());
}
}
-46
View File
@@ -1,46 +0,0 @@
use super::CatchObject;
const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0;
pub(crate) struct DifficultyObject<'o> {
pub(crate) base: &'o CatchObject,
pub(crate) last: &'o CatchObject,
pub(crate) delta: f64,
pub(crate) start_time: f64,
pub(crate) normalized_pos: f32,
pub(crate) last_normalized_pos: f32,
pub(crate) strain_time: f64,
pub(crate) clock_rate: f64,
}
impl<'o> DifficultyObject<'o> {
#[inline]
pub(crate) fn new(
base: &'o CatchObject,
last: &'o CatchObject,
half_catcher_width: f32,
clock_rate: f64,
) -> Self {
let delta = (base.time - last.time) / clock_rate;
let start_time = base.time / clock_rate;
let strain_time = delta.max(40.0);
let scaling_factor = NORMALIZED_HITOBJECT_RADIUS / half_catcher_width;
let normalized_pos = base.pos * scaling_factor;
let last_normalized_pos = last.pos * scaling_factor;
Self {
base,
last,
delta,
start_time,
normalized_pos,
last_normalized_pos,
strain_time,
clock_rate,
}
}
}
-278
View File
@@ -1,278 +0,0 @@
use std::{iter::Map, vec::IntoIter};
use crate::{
curve::{Curve, CurveBuffers},
parse::{HitObject, HitObjectKind, Pos2},
Beatmap,
};
use super::{catch_object::CatchObject, CatchDifficultyAttributes};
const LEGACY_LAST_TICK_OFFSET: f64 = 36.0;
const BASE_SCORING_DISTANCE: f64 = 100.0;
#[derive(Clone, Debug)]
pub(crate) struct FruitParams {
pub(crate) attributes: CatchDifficultyAttributes,
pub(crate) curve_bufs: CurveBuffers,
pub(crate) last_pos: Option<f32>,
pub(crate) last_time: f64,
pub(crate) ticks: Vec<(Pos2, f64)>,
pub(crate) with_hr: bool,
}
type JuiceStream = Map<IntoIter<(Pos2, f64)>, fn((Pos2, f64)) -> CatchObject>;
#[derive(Clone, Debug)]
pub(crate) enum FruitOrJuice {
Fruit(Option<CatchObject>),
Juice(JuiceStream),
}
impl FruitOrJuice {
pub(crate) fn new(h: &HitObject, params: &mut FruitParams, map: &Beatmap) -> Option<Self> {
match &h.kind {
HitObjectKind::Circle => {
let mut h = CatchObject::new((h.pos, h.start_time));
if params.with_hr {
h = h.with_hr(params);
}
params.attributes.n_fruits += 1;
Some(FruitOrJuice::Fruit(Some(h)))
}
HitObjectKind::Slider {
pixel_len,
repeats,
control_points,
..
} => {
// HR business
params.last_pos = Some(h.pos.x + control_points[control_points.len() - 1].pos.x);
params.last_time = h.start_time;
let timing_point = map.timing_point_at(h.start_time);
let difficulty_point = map.difficulty_point_at(h.start_time).unwrap_or_default();
let vel_factor = BASE_SCORING_DISTANCE * map.slider_mult / timing_point.beat_len;
let tick_dist_factor = BASE_SCORING_DISTANCE * map.slider_mult / map.tick_rate;
let vel = vel_factor * difficulty_point.slider_vel;
let mut tick_dist = tick_dist_factor * difficulty_point.slider_vel;
let span_count = (*repeats + 1) as f64;
// Build the curve w.r.t. the control points
let curve = Curve::new(control_points, *pixel_len, &mut params.curve_bufs);
let total_duration = span_count * curve.dist() / vel;
let span_duration = total_duration / span_count;
// * A very lenient maximum length of a slider for ticks to be generated.
// * This exists for edge cases such as /b/1573664 where the beatmap has
// * been edited by the user, and should never be reached in normal usage.
let max_len = 100_000.0;
let len = curve.dist().min(max_len);
tick_dist = tick_dist.clamp(0.0, len);
let min_dist_from_end = vel * 10.0;
let mut curr_dist = tick_dist;
let pixel_len = pixel_len.unwrap_or(0.0);
let target = pixel_len - tick_dist / 8.0;
let mut slider_objects = vec![(h.pos, h.start_time)];
if tick_dist > 0.0 {
params.ticks.reserve((target / tick_dist) as usize);
// Tick of the first span
while curr_dist < len - min_dist_from_end {
let progress = curr_dist / len;
let pos = h.pos + curve.position_at(progress);
let time = h.start_time + progress * span_duration;
params.ticks.push((pos, time));
curr_dist += tick_dist;
}
if pixel_len > 0.0 {
let time_add = total_duration * tick_dist / (pixel_len * span_count);
params.attributes.n_tiny_droplets += tiny_droplet_count(
h.start_time,
time_add,
total_duration,
span_count as usize,
&params.ticks,
);
}
slider_objects.reserve(span_count as usize * (params.ticks.len()));
// Other spans
if *repeats == 0 {
slider_objects.append(&mut params.ticks); // automatically empties buffer for next slider
} else {
slider_objects.extend(&params.ticks);
for span_idx in 1..=*repeats {
let progress = f64::from(u8::from(span_idx % 2 == 1));
let pos = h.pos + curve.position_at(progress);
let time_offset = span_duration * span_idx as f64;
// Reverse tick
slider_objects.push((pos, h.start_time + time_offset));
// Actual ticks
if span_idx & 1 == 1 {
let tick_iter = params
.ticks
.iter()
.rev()
.zip(params.ticks.iter())
.map(|((pos, _), (_, time))| (*pos, *time + time_offset));
slider_objects.extend(tick_iter);
} else {
let tick_iter = params
.ticks
.iter()
.map(|(pos, time)| (*pos, *time + time_offset));
slider_objects.extend(tick_iter);
}
}
params.ticks.clear();
}
}
// Slider tail
let progress = f64::from(u8::from(*repeats % 2 == 0));
let pos = h.pos + curve.position_at(progress);
slider_objects.push((pos, h.start_time + total_duration));
let new_fruits = 2 + usize::from(tick_dist > 0.0) * *repeats;
params.attributes.n_fruits += new_fruits;
params.attributes.n_droplets += slider_objects.len() - new_fruits;
let iter = slider_objects
.into_iter()
.map(CatchObject::new as fn(_) -> _);
Some(FruitOrJuice::Juice(iter))
}
HitObjectKind::Spinner { .. } | HitObjectKind::Hold { .. } => None,
}
}
}
impl Iterator for FruitOrJuice {
type Item = CatchObject;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
match self {
Self::Fruit(fruit) => fruit.take(),
Self::Juice(slider) => slider.next(),
}
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
let len = self.len();
(len, Some(len))
}
}
impl ExactSizeIterator for FruitOrJuice {
#[inline]
fn len(&self) -> usize {
match self {
FruitOrJuice::Fruit(Some(_)) => 1,
FruitOrJuice::Fruit(None) => 0,
FruitOrJuice::Juice(slider) => slider.len(),
}
}
}
// BUG: Sometimes there are off-by-one errors,
// presumably caused by floating point inaccuracies
fn tiny_droplet_count(
start_time: f64,
time_between_ticks: f64,
duration: f64,
span_count: usize,
ticks: &[(Pos2, f64)],
) -> usize {
// tiny droplets preceeding a _tick_
let per_tick = if !ticks.is_empty() && time_between_ticks > 80.0 {
let time_between_tiny = shrink_down(time_between_ticks);
// add a little for floating point inaccuracies
let start = time_between_tiny + 0.001;
count_iterations(start, time_between_tiny, time_between_ticks)
} else {
0
};
// tiny droplets preceeding a _reverse_
let last = ticks.last().map_or(start_time, |(_, last)| *last);
let repeat_time = start_time + duration / span_count as f64;
let since_last_tick = repeat_time - last;
let span_last_section = if since_last_tick > 80.0 {
let time_between_tiny = shrink_down(since_last_tick);
count_iterations(time_between_tiny, time_between_tiny, since_last_tick)
} else {
0
};
// tiny droplets preceeding the slider tail
// necessary to handle distinctly because of the legacy last tick
let last = ticks.last().map_or(start_time, |(_, last)| *last);
let end_time = start_time + duration / span_count as f64 - LEGACY_LAST_TICK_OFFSET;
let since_last_tick = end_time - last;
let last_section = if since_last_tick > 80.0 {
let time_between_tiny = shrink_down(since_last_tick);
count_iterations(time_between_tiny, time_between_tiny, since_last_tick)
} else {
0
};
// Combine tiny droplets counts
per_tick * ticks.len() * span_count
+ span_last_section * (span_count.saturating_sub(1))
+ last_section
}
#[inline]
fn shrink_down(mut val: f64) -> f64 {
while val > 100.0 {
val /= 2.0;
}
val
}
#[inline]
fn count_iterations(mut start: f64, step: f64, end: f64) -> usize {
let mut count = 0;
while start < end {
count += 1;
start += step;
}
count
}
-269
View File
@@ -1,269 +0,0 @@
use std::iter;
use crate::{
catch::{difficulty_object::DifficultyObject, SECTION_LENGTH, STAR_SCALING_FACTOR},
curve::CurveBuffers,
parse::Pos2,
Beatmap, Mods,
};
use super::{
calculate_catch_width,
catch_object::CatchObject,
fruit_or_juice::{FruitOrJuice, FruitParams},
movement::Movement,
CatchDifficultyAttributes, ALLOWED_CATCH_RANGE,
};
/// Gradually calculate the difficulty attributes of an osu!catch map.
///
/// Note that this struct implements [`Iterator`].
/// On every call of [`Iterator::next`], the map's next fruit or droplet
/// will be processed and the [`CatchDifficultyAttributes`] will be updated and returned.
///
/// Note that it does not return attributes after a tiny droplet. Only for fruits and droplets.
///
/// If you want to calculate performance attributes, use
/// [`CatchGradualPerformance`](crate::catch::CatchGradualPerformance) instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, catch::CatchGradualDifficulty};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut iter = CatchGradualDifficulty::new(&map, mods);
///
/// let attrs1 = iter.next(); // the difficulty of the map after the first hit object
/// let attrs2 = iter.next(); // after the second hit object
///
/// // Remaining hit objects
/// for difficulty in iter {
/// // ...
/// }
/// ```
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Clone, Debug)]
pub struct CatchGradualDifficulty<'map> {
map: &'map Beatmap,
inner: CatchGradualDifficultyInner,
}
impl<'map> CatchGradualDifficulty<'map> {
/// Create a new difficulty attributes iterator for osu!catch maps.
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
let inner = CatchGradualDifficultyInner::new(map, mods);
Self { map, inner }
}
pub(crate) const fn idx(&self) -> usize {
self.inner.idx
}
}
impl Iterator for CatchGradualDifficulty<'_> {
type Item = CatchDifficultyAttributes;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
self.inner.next(self.map)
}
}
/// Gradually calculate the difficulty attributes of an osu!catch map.
///
/// Check [`CatchGradualDifficulty`] for more information. This struct does the same
/// but takes ownership of [`Beatmap`] to avoid being bound to a lifetime.
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Clone, Debug)]
pub struct CatchOwnedGradualDifficulty {
pub(crate) map: Beatmap,
inner: CatchGradualDifficultyInner,
}
impl CatchOwnedGradualDifficulty {
/// Create a new difficulty attributes iterator for osu!catch maps.
pub fn new(map: Beatmap, mods: u32) -> Self {
let inner = CatchGradualDifficultyInner::new(&map, mods);
Self { map, inner }
}
#[allow(unused)]
pub(crate) const fn idx(&self) -> usize {
self.inner.idx
}
}
impl Iterator for CatchOwnedGradualDifficulty {
type Item = CatchDifficultyAttributes;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
self.inner.next(&self.map)
}
}
#[derive(Clone, Debug)]
struct CatchObjectIter {
hit_object_idx: usize,
last_object: Option<FruitOrJuice>,
params: FruitParams,
}
impl CatchObjectIter {
fn new(mods: impl Mods, attributes: CatchDifficultyAttributes) -> Self {
let params = FruitParams {
attributes,
curve_bufs: CurveBuffers::default(),
last_pos: None,
last_time: 0.0,
ticks: Vec::new(),
with_hr: mods.hr(),
};
Self {
hit_object_idx: 0,
last_object: None,
params,
}
}
fn attributes(&self) -> CatchDifficultyAttributes {
self.params.attributes.clone()
}
fn next(&mut self, map: &Beatmap) -> Option<CatchObject> {
if let opt @ Some(_) = self.last_object.as_mut().and_then(Iterator::next) {
return opt;
}
map.hit_objects[self.hit_object_idx..]
.iter()
.find_map(|h| {
self.hit_object_idx += 1;
FruitOrJuice::new(h, &mut self.params, map)
})
.and_then(|h| self.last_object.insert(h).next())
}
}
#[derive(Clone, Debug)]
struct CatchGradualDifficultyInner {
idx: usize,
clock_rate: f64,
hit_objects: CatchObjectIter,
movement: Movement,
prev: CatchObject,
half_catcher_width: f64,
last_direction: i8,
last_excess: f64,
curr_section_end: f64,
strain_peak_buf: Vec<f64>,
}
impl CatchGradualDifficultyInner {
fn new(map: &Beatmap, mods: u32) -> Self {
let map_attributes = map.attributes().mods(mods).build();
let attributes = CatchDifficultyAttributes {
ar: map_attributes.ar,
..Default::default()
};
let hit_objects = CatchObjectIter::new(mods, attributes);
let half_catcher_width =
f64::from(calculate_catch_width(map_attributes.cs as f32) / 2.0 / ALLOWED_CATCH_RANGE);
let last_direction = 0;
let last_excess = half_catcher_width;
let movement = Movement::new(map_attributes.cs as f32);
let prev = CatchObject::new((Pos2::zero(), 0.0));
Self {
idx: 0,
clock_rate: mods.clock_rate(),
hit_objects,
movement,
prev,
half_catcher_width,
last_direction,
last_excess,
curr_section_end: 0.0,
strain_peak_buf: Vec::new(),
}
}
fn init_hyper_dash(&mut self, next: &CatchObject) {
self.prev.init_hyper_dash(
self.half_catcher_width,
next,
&mut self.last_direction,
&mut self.last_excess,
);
}
fn next(&mut self, map: &Beatmap) -> Option<CatchDifficultyAttributes> {
let curr = self.hit_objects.next(map)?;
self.idx += 1;
if self.idx == 1 {
self.prev = curr;
return Some(self.hit_objects.attributes());
}
self.init_hyper_dash(&curr);
let h = DifficultyObject::new(
&curr,
&self.prev,
self.movement.half_catcher_width,
self.clock_rate,
);
if self.idx == 2 {
self.curr_section_end =
(h.base.time / self.clock_rate / SECTION_LENGTH).ceil() * SECTION_LENGTH;
} else {
let base_time = h.base.time / self.clock_rate;
while base_time > self.curr_section_end {
self.movement.save_current_peak();
self.movement.start_new_section_from(self.curr_section_end);
self.curr_section_end += SECTION_LENGTH;
}
}
self.movement.process(&h);
self.prev = curr;
let len = self.movement.strain_peaks.len();
let missing = len + 1 - self.strain_peak_buf.len();
self.strain_peak_buf.extend(iter::repeat(0.0).take(missing));
self.strain_peak_buf[..len].copy_from_slice(&self.movement.strain_peaks);
if let Some(last) = self.strain_peak_buf.last_mut() {
*last = self.movement.curr_section_peak;
}
let stars =
Movement::difficulty_value(&mut self.strain_peak_buf).sqrt() * STAR_SCALING_FACTOR;
let attrs = CatchDifficultyAttributes {
stars,
..self.hit_objects.attributes()
};
Some(attrs)
}
}
-201
View File
@@ -1,201 +0,0 @@
use crate::{Beatmap, CatchPP};
use super::{
CatchGradualDifficulty, CatchOwnedGradualDifficulty, CatchPerformanceAttributes,
CatchScoreState,
};
/// Gradually calculate the performance attributes of an osu!catch map.
///
/// After each hit object you can call [`next`](`CatchGradualPerformance::next`)
/// and it will return the resulting current [`CatchPerformanceAttributes`].
/// To process multiple objects at once, use [`nth`](`CatchGradualPerformance::nth`) instead.
///
/// Both methods require a [`CatchScoreState`] that contains the current
/// hitresults as well as the maximum combo so far.
///
/// Note that neither hits nor misses of tiny droplets require
/// to be processed. Only fruits and droplets do.
///
/// If you only want to calculate difficulty attributes use [`CatchGradualDifficulty`] instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, catch::{CatchGradualPerformance, CatchScoreState}};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut gradual_perf = CatchGradualPerformance::new(&map, mods);
/// let mut state = CatchScoreState::new(); // empty state, everything is on 0.
///
/// // The first 10 hitresults are only fruits
/// for _ in 0..10 {
/// state.n_fruits += 1;
/// state.max_combo += 1;
///
/// # /*
/// let performance = gradual_perf.next(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.next(state.clone());
/// }
///
/// // Then comes a miss.
/// // Note that state's max combo won't be incremented for
/// // the next few objects because the combo is reset.
/// state.n_misses += 1;
/// # /*
/// let performance = gradual_perf.next(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.next(state.clone());
///
/// // The next 10 objects will be a mixture of fruits and droplets.
/// // Notice how tiny droplets from sliders do not count as hit objects
/// // that require processing. Only fruits and droplets do.
/// // Also notice how all 10 objects will be processed in one go.
/// state.n_fruits += 4;
/// state.n_droplets += 6;
/// state.n_tiny_droplets += 12;
/// // The `nth` method takes a zero-based value.
/// # /*
/// let performance = gradual_perf.nth(state.clone(), 9).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.nth(state.clone(), 9);
///
/// // Now comes another fruit. Note that the max combo gets incremented again.
/// state.n_fruits += 1;
/// state.max_combo += 1;
/// # /*
/// let performance = gradual_perf.next(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.next(state.clone());
///
/// // Skip to the end
/// # /*
/// state.max_combo = ...
/// state.n_fruits = ...
/// state.n_droplets = ...
/// state.n_tiny_droplets = ...
/// state.n_tiny_droplet_misses = ...
/// state.n_misses = ...
/// let final_performance = gradual_perf.nth(state.clone(), usize::MAX).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.nth(state.clone(), usize::MAX);
///
/// // Once the final performance was calculated,
/// // attempting to process further objects will return `None`.
/// assert!(gradual_perf.next(state).is_none());
/// ```
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Clone, Debug)]
pub struct CatchGradualPerformance<'map> {
difficulty: CatchGradualDifficulty<'map>,
performance: CatchPP<'map>,
}
impl<'map> CatchGradualPerformance<'map> {
/// Create a new gradual performance calculator for osu!standard maps.
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
let difficulty = CatchGradualDifficulty::new(map, mods);
let performance = CatchPP::new(map).mods(mods).passed_objects(0);
Self {
difficulty,
performance,
}
}
/// Process the next hit object and calculate the
/// performance attributes for the resulting score state.
///
/// Note that neither hits nor misses of tiny droplets require
/// to be processed. Only fruits and droplets do.
pub fn next(&mut self, state: CatchScoreState) -> Option<CatchPerformanceAttributes> {
self.nth(state, 0)
}
/// Process all remaining hit objects and calculate the final performance attributes.
pub fn last(&mut self, state: CatchScoreState) -> Option<CatchPerformanceAttributes> {
self.nth(state, usize::MAX)
}
/// Process everything up the the next `n`th hit object and calculate the performance
/// attributes for the resulting score state.
///
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
/// and so on.
pub fn nth(&mut self, state: CatchScoreState, n: usize) -> Option<CatchPerformanceAttributes> {
let difficulty = self.difficulty.by_ref().take(n.saturating_add(1)).last()?;
let performance = self
.performance
.clone()
.attributes(difficulty)
.state(state)
.passed_objects(self.difficulty.idx())
.calculate();
Some(performance)
}
}
/// Gradually calculate the performance attributes of an osu!catch map.
///
/// Check [`CatchGradualPerformance`] for more information. This struct does the same
/// but takes ownership of [`Beatmap`] to avoid being bound to a lifetime.
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Clone, Debug)]
pub struct CatchOwnedGradualPerformance {
difficulty: CatchOwnedGradualDifficulty,
mods: u32,
}
impl CatchOwnedGradualPerformance {
/// Create a new gradual performance calculator for osu!standard maps.
pub fn new(map: Beatmap, mods: u32) -> Self {
let difficulty = CatchOwnedGradualDifficulty::new(map, mods);
Self { difficulty, mods }
}
/// Process the next hit object and calculate the
/// performance attributes for the resulting score state.
///
/// Note that neither hits nor misses of tiny droplets require
/// to be processed. Only fruits and droplets do.
pub fn next(&mut self, state: CatchScoreState) -> Option<CatchPerformanceAttributes> {
self.nth(state, 0)
}
/// Process all remaining hit objects and calculate the final performance attributes.
pub fn last(&mut self, state: CatchScoreState) -> Option<CatchPerformanceAttributes> {
self.nth(state, usize::MAX)
}
/// Process everything up the the next `n`th hit object and calculate the performance
/// attributes for the resulting score state.
///
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
/// and so on.
pub fn nth(&mut self, state: CatchScoreState, n: usize) -> Option<CatchPerformanceAttributes> {
let difficulty = self.difficulty.by_ref().take(n.saturating_add(1)).last()?;
let performance = CatchPP::new(&self.difficulty.map)
.mods(self.mods)
.attributes(difficulty)
.state(state)
.passed_objects(self.difficulty.idx())
.calculate();
Some(performance)
}
}
+39 -329
View File
@@ -1,343 +1,53 @@
mod catch_object;
mod difficulty_object;
mod fruit_or_juice;
mod movement;
mod pp;
mod score_state;
use std::borrow::Cow;
#[cfg(feature = "gradual")]
mod gradual_difficulty;
#[cfg(feature = "gradual")]
mod gradual_performance;
use difficulty_object::DifficultyObject;
use movement::Movement;
pub use self::{catch_object::CatchObject, pp::*, score_state::CatchScoreState};
#[cfg(feature = "gradual")]
pub use self::{
gradual_difficulty::{CatchGradualDifficulty, CatchOwnedGradualDifficulty},
gradual_performance::{CatchGradualPerformance, CatchOwnedGradualPerformance},
use crate::{
any::ModeDifficulty,
model::{
beatmap::Beatmap,
mode::{ConvertStatus, IGameMode},
},
};
pub(crate) use self::fruit_or_juice::{FruitOrJuice, FruitParams};
pub use self::{
attributes::{CatchDifficultyAttributes, CatchPerformanceAttributes},
convert::CatchBeatmap,
performance::CatchPerformance,
score_state::CatchScoreState,
strains::CatchStrains,
};
use crate::{curve::CurveBuffers, Beatmap, Mods, OsuStars};
mod attributes;
mod catcher;
mod convert;
mod difficulty;
mod object;
mod performance;
mod score_state;
mod strains;
const SECTION_LENGTH: f64 = 750.0;
const STAR_SCALING_FACTOR: f64 = 0.153;
const PLAYFIELD_WIDTH: f32 = 512.0;
pub(crate) const ALLOWED_CATCH_RANGE: f32 = 0.8;
const CATCHER_SIZE: f32 = 106.75;
/// Difficulty calculator on osu!catch maps.
/// Marker type for [`GameMode::Catch`].
///
/// # Example
///
/// ```
/// use rosu_pp::{CatchStars, Beatmap};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let difficulty_attrs = CatchStars::new(&map)
/// .mods(8 + 64) // HDDT
/// .calculate();
///
/// println!("Stars: {}", difficulty_attrs.stars);
/// ```
#[derive(Clone, Debug)]
#[must_use]
pub struct CatchStars<'map> {
map: &'map Beatmap,
mods: u32,
passed_objects: Option<usize>,
clock_rate: Option<f64>,
}
/// [`GameMode::Catch`]: rosu_map::section::general::GameMode::Catch
pub struct Catch;
impl<'map> CatchStars<'map> {
/// Create a new difficulty calculator for osu!catch maps.
#[inline]
pub const fn new(map: &'map Beatmap) -> Self {
Self {
map,
mods: 0,
passed_objects: None,
clock_rate: None,
}
impl IGameMode for Catch {
type DifficultyAttributes = CatchDifficultyAttributes;
type Strains = CatchStrains;
fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus {
convert::try_convert(map)
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
#[inline]
pub const fn mods(mut self, mods: u32) -> Self {
self.mods = mods;
self
fn difficulty(
difficulty: &ModeDifficulty,
converted: &CatchBeatmap<'_>,
) -> Self::DifficultyAttributes {
difficulty::difficulty(difficulty, converted)
}
/// Amount of passed objects for partial plays, e.g. a fail.
///
#[cfg_attr(
feature = "gradual",
doc = "If you want to calculate the difficulty after every few objects, instead of
using [`CatchStars`] multiple times with different `passed_objects`, you should use
[`CatchGradualDifficultyAttributes`](crate::catch::CatchGradualDifficulty)."
)]
#[inline]
pub const fn passed_objects(mut self, passed_objects: usize) -> Self {
self.passed_objects = Some(passed_objects);
self
}
/// Adjust the clock rate used in the calculation.
/// If none is specified, it will take the clock rate based on the mods
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
#[inline]
pub const fn clock_rate(mut self, clock_rate: f64) -> Self {
self.clock_rate = Some(clock_rate);
self
}
/// Calculate all difficulty related values, including stars.
#[inline]
pub fn calculate(self) -> CatchDifficultyAttributes {
let (mut movement, mut attributes) = calculate_movement(self);
attributes.stars =
Movement::difficulty_value(&mut movement.strain_peaks).sqrt() * STAR_SCALING_FACTOR;
attributes
}
/// Calculate the skill strains.
///
/// Suitable to plot the difficulty of a map over time.
#[inline]
pub fn strains(self) -> CatchStrains {
let (movement, _) = calculate_movement(self);
CatchStrains {
section_len: SECTION_LENGTH,
movement: movement.strain_peaks,
}
}
}
/// The result of calculating the strains on a osu!catch map.
/// Suitable to plot the difficulty of a map over time.
#[derive(Clone, Debug)]
pub struct CatchStrains {
/// Time in ms inbetween two strains.
pub section_len: f64,
/// Strain peaks of the movement skill.
pub movement: Vec<f64>,
}
impl CatchStrains {
/// Returns the number of strain peaks per skill.
#[inline]
#[allow(clippy::len_without_is_empty)]
pub fn len(&self) -> usize {
self.movement.len()
}
}
#[allow(clippy::needless_pass_by_value)]
fn calculate_movement(params: CatchStars<'_>) -> (Movement, CatchDifficultyAttributes) {
let CatchStars {
map,
mods,
passed_objects,
clock_rate,
} = params;
let take = passed_objects.unwrap_or(usize::MAX);
let clock_rate = clock_rate.unwrap_or_else(|| mods.clock_rate());
let map_attributes = map.attributes().mods(mods).clock_rate(clock_rate).build();
let attributes = CatchDifficultyAttributes {
ar: map_attributes.ar,
..Default::default()
};
let mut params = FruitParams {
attributes,
curve_bufs: CurveBuffers::default(),
last_pos: None,
last_time: 0.0,
ticks: Vec::new(), // using the same buffer for all sliders
with_hr: mods.hr(),
};
// BUG: Incorrect object order on 2B maps that have fruits within sliders
let mut hit_objects = map
.hit_objects
.iter()
.filter_map(|h| FruitOrJuice::new(h, &mut params, map))
.flatten()
.take(take);
// Hyper dash business
let half_catcher_width =
f64::from(calculate_catch_width(map_attributes.cs as f32) / 2.0 / ALLOWED_CATCH_RANGE);
let mut last_direction = 0;
let mut last_excess = half_catcher_width;
// Strain business
let mut movement = Movement::new(map_attributes.cs as f32);
let (mut prev, curr) = match (hit_objects.next(), hit_objects.next()) {
(Some(prev), Some(curr)) => (prev, curr),
(_, None) => return (movement, params.attributes),
(None, Some(_)) => unreachable!(),
};
let mut curr_section_end = (curr.time / clock_rate / SECTION_LENGTH).ceil() * SECTION_LENGTH;
prev.init_hyper_dash(
half_catcher_width,
&curr,
&mut last_direction,
&mut last_excess,
);
// Handle first object distinctly
let h = DifficultyObject::new(&curr, &prev, movement.half_catcher_width, clock_rate);
movement.process(&h);
prev = curr;
// Handle all other objects
for curr in hit_objects {
prev.init_hyper_dash(
half_catcher_width,
&curr,
&mut last_direction,
&mut last_excess,
);
let h = DifficultyObject::new(&curr, &prev, movement.half_catcher_width, clock_rate);
let base_time = h.base.time / clock_rate;
while base_time > curr_section_end {
movement.save_current_peak();
movement.start_new_section_from(curr_section_end);
curr_section_end += SECTION_LENGTH;
// Optimization to finish the loop early if
// the current peak is 0.0 i.e. it can't decay further.
// If final values don't coincide perfectly anymore,
// this should be looked at and maybe adjusted.
if movement.curr_section_peak.abs() <= f64::EPSILON && base_time > curr_section_end {
let remaining_time = base_time - curr_section_end;
let remaining_iters = (remaining_time / SECTION_LENGTH).ceil();
curr_section_end += remaining_iters * SECTION_LENGTH;
}
}
movement.process(&h);
prev = curr;
}
movement.save_current_peak();
(movement, params.attributes)
}
#[inline]
pub(crate) fn calculate_catch_width(cs: f32) -> f32 {
let scale = 1.0 - 0.7 * (cs - 5.0) / 5.0;
CATCHER_SIZE * scale.abs() * ALLOWED_CATCH_RANGE
}
/// The result of a difficulty calculation on an osu!catch map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CatchDifficultyAttributes {
/// The final star rating
pub stars: f64,
/// The approach rate.
pub ar: f64,
/// The amount of fruits.
pub n_fruits: usize,
/// The amount of droplets.
pub n_droplets: usize,
/// The amount of tiny droplets.
pub n_tiny_droplets: usize,
}
impl CatchDifficultyAttributes {
/// Return the maximum combo.
#[inline]
pub const fn max_combo(&self) -> usize {
self.n_fruits + self.n_droplets
}
/// Returns a builder for performance calculation.
#[inline]
pub fn pp(self) -> CatchPP<'static> {
CatchPP::from(self)
}
}
/// The result of a performance calculation on an osu!catch map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CatchPerformanceAttributes {
/// The difficulty attributes that were used for the performance calculation
pub difficulty: CatchDifficultyAttributes,
/// The final performance points.
pub pp: f64,
}
impl CatchPerformanceAttributes {
/// Return the star value.
#[inline]
pub const fn stars(&self) -> f64 {
self.difficulty.stars
}
/// Return the performance point value.
#[inline]
pub const fn pp(&self) -> f64 {
self.pp
}
/// Return the maximum combo of the map.
#[inline]
pub const fn max_combo(&self) -> usize {
self.difficulty.max_combo()
}
}
impl From<CatchPerformanceAttributes> for CatchDifficultyAttributes {
#[inline]
fn from(attributes: CatchPerformanceAttributes) -> Self {
attributes.difficulty
}
}
impl<'map> From<OsuStars<'map>> for CatchStars<'map> {
#[inline]
fn from(osu: OsuStars<'map>) -> Self {
let OsuStars {
map,
mods,
passed_objects,
clock_rate,
} = osu;
Self {
map,
mods,
passed_objects,
clock_rate,
}
fn strains(difficulty: &ModeDifficulty, converted: &CatchBeatmap<'_>) -> Self::Strains {
strains::strains(difficulty, converted)
}
}
-147
View File
@@ -1,147 +0,0 @@
use super::DifficultyObject;
use std::cmp::Ordering;
const ABSOLUTE_PLAYER_POSITIONING_ERROR: f32 = 16.0;
const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0;
const POSITION_EPSILON: f32 = NORMALIZED_HITOBJECT_RADIUS - ABSOLUTE_PLAYER_POSITIONING_ERROR;
const DIRECTION_CHANGE_BONUS: f64 = 21.0;
const SKILL_MULTIPLIER: f64 = 900.0;
const STRAIN_DECAY_BASE: f64 = 0.2;
const DECAY_WEIGHT: f64 = 0.94;
#[derive(Clone, Debug)]
pub(crate) struct Movement {
pub(crate) half_catcher_width: f32,
last_player_position: Option<f32>,
last_distance_moved: f32,
last_strain_time: f64,
current_strain: f64,
pub(crate) curr_section_peak: f64,
pub(crate) strain_peaks: Vec<f64>,
prev_time: Option<f64>,
}
impl Movement {
#[inline]
pub(crate) fn new(cs: f32) -> Self {
let mut half_catcher_width = super::calculate_catch_width(cs) * 0.5;
half_catcher_width *= 1.0 - ((cs - 5.5).max(0.0) * 0.0625);
Self {
half_catcher_width,
last_player_position: None,
last_distance_moved: 0.0,
last_strain_time: 0.0,
current_strain: 1.0,
curr_section_peak: 1.0,
strain_peaks: Vec::with_capacity(128),
prev_time: None,
}
}
#[inline]
pub(crate) fn save_current_peak(&mut self) {
self.strain_peaks.push(self.curr_section_peak);
}
#[inline]
pub(crate) fn start_new_section_from(&mut self, time: f64) {
self.curr_section_peak = self.peak_strain(time - self.prev_time.unwrap());
}
pub(crate) fn process(&mut self, current: &DifficultyObject<'_>) {
self.current_strain *= strain_decay(current.delta);
self.current_strain += self.strain_value_of(current) * SKILL_MULTIPLIER;
self.curr_section_peak = self.current_strain.max(self.curr_section_peak);
self.prev_time.replace(current.start_time);
}
pub(crate) fn difficulty_value(strain_peaks: &mut [f64]) -> f64 {
let mut difficulty = 0.0;
let mut weight = 1.0;
strain_peaks.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
for &strain in strain_peaks.iter() {
difficulty += strain * weight;
weight *= DECAY_WEIGHT;
}
difficulty
}
fn strain_value_of(&mut self, current: &DifficultyObject<'_>) -> f64 {
let last_player_pos = self
.last_player_position
.unwrap_or(current.last_normalized_pos);
let mut pos = last_player_pos.clamp(
current.normalized_pos - POSITION_EPSILON,
current.normalized_pos + POSITION_EPSILON,
);
let dist_moved = pos - last_player_pos;
let weighted_strain_time = current.strain_time + 13.0 + (3.0 / current.clock_rate);
let mut dist_addition = f64::from(dist_moved.abs().powf(1.3) / 510.0);
if dist_moved.abs() > 0.1 {
if self.last_distance_moved.abs() > 0.1
&& dist_moved.signum() != self.last_distance_moved.signum()
{
let bonus_factor = f64::from(dist_moved.abs().min(50.0) / 50.0);
let anti_flow_factor =
f64::from((self.last_distance_moved.abs().min(70.0) / 70.0).max(0.38));
dist_addition += DIRECTION_CHANGE_BONUS / (self.last_strain_time + 16.0).sqrt()
* bonus_factor
* anti_flow_factor
* (1.0 - (weighted_strain_time / 1000.0).powi(3)).max(0.0);
}
dist_addition += f64::from(
12.5 * dist_moved.abs().min(NORMALIZED_HITOBJECT_RADIUS * 2.0)
/ (NORMALIZED_HITOBJECT_RADIUS * 6.0),
) / weighted_strain_time.sqrt();
}
let mut edge_dash_bonus = 0.0;
if current.last.hyper_dist <= 20.0 {
#[allow(clippy::if_not_else)]
if !current.last.hyper_dash {
edge_dash_bonus += 5.7;
} else {
pos = current.normalized_pos;
}
dist_addition *= 1.0
+ edge_dash_bonus
* f64::from((20.0 - current.last.hyper_dist) / 20.0)
* ((current.strain_time * current.clock_rate).min(265.0) / 265.0).powf(1.5);
}
self.last_player_position.replace(pos);
self.last_distance_moved = dist_moved;
self.last_strain_time = current.strain_time;
dist_addition / weighted_strain_time
}
#[inline]
fn peak_strain(&self, delta_time: f64) -> f64 {
self.current_strain * strain_decay(delta_time)
}
}
#[inline]
fn strain_decay(ms: f64) -> f64 {
STRAIN_DECAY_BASE.powf(ms / 1000.0)
}
+30
View File
@@ -0,0 +1,30 @@
pub struct BananaShower {
pub n_bananas: usize,
}
impl BananaShower {
pub fn new(start_time: f64, end_time: f64) -> Self {
let duration = end_time - start_time;
let mut spacing = duration;
while spacing > 100.0 {
spacing /= 2.0;
}
let n_bananas = if spacing <= 0.0 {
0
} else {
let mut time = start_time;
let mut i = 0;
while time <= end_time {
time += spacing;
i += 1;
}
i
};
Self { n_bananas }
}
}
+13
View File
@@ -0,0 +1,13 @@
use crate::catch::attributes::CatchDifficultyAttributesBuilder;
pub struct Fruit {
pub x_offset: f32,
}
impl Fruit {
pub fn new(attrs: &mut CatchDifficultyAttributesBuilder) -> Self {
attrs.inc_fruits();
Self { x_offset: 0.0 }
}
}
+154
View File
@@ -0,0 +1,154 @@
use std::vec::Drain;
use rosu_map::section::hit_objects::{
CurveBuffers, PathControlPoint, SliderEvent, SliderEventType, SliderEventsIter,
};
use crate::{
catch::{attributes::CatchDifficultyAttributesBuilder, convert::CatchBeatmap},
model::{
control_point::{DifficultyPoint, TimingPoint},
hit_object::Slider,
},
};
pub struct JuiceStream<'a> {
pub control_points: &'a [PathControlPoint], // needed for applying hr offset
pub nested_objects: Drain<'a, NestedJuiceStreamObject>,
}
impl<'a> JuiceStream<'a> {
pub const BASE_SCORING_DIST: f64 = 100.0;
pub fn new(
x: f32,
start_time: f64,
slider: &'a Slider,
converted: &CatchBeatmap<'_>,
attrs: &mut CatchDifficultyAttributesBuilder,
bufs: &'a mut JuiceStreamBufs,
) -> Self {
let slider_multiplier = converted.map.slider_multiplier;
let slider_tick_rate = converted.map.slider_tick_rate;
let beat_len = converted
.map
.timing_point_at(start_time)
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len);
let slider_velocity = converted
.map
.difficulty_point_at(start_time)
.map_or(DifficultyPoint::DEFAULT_SLIDER_VELOCITY, |point| {
point.slider_velocity
});
let path = slider.curve(&mut bufs.curve);
let velocity_factor = JuiceStream::BASE_SCORING_DIST * slider_multiplier / beat_len;
let velocity = velocity_factor * slider_velocity;
let tick_dist_factor =
JuiceStream::BASE_SCORING_DIST * slider_multiplier / slider_tick_rate;
let tick_dist_multiplier = if converted.map.version < 8 {
1.0
} else {
slider_velocity
};
let tick_dist = tick_dist_factor * tick_dist_multiplier;
let span_count = slider.span_count() as f64;
let duration = span_count * path.dist() / velocity;
let span_duration = duration / span_count;
let events = SliderEventsIter::new(
start_time,
span_duration,
velocity,
tick_dist,
path.dist(),
slider.span_count() as i32,
&mut bufs.ticks,
);
let mut last_event_time = None;
for e in events {
if let Some(last_event_time) = last_event_time.filter(|_| attrs.take_more()) {
let since_last_tick = e.time - last_event_time;
if since_last_tick > 80.0 {
let mut time_between_tiny = since_last_tick;
while time_between_tiny > 100.0 {
time_between_tiny /= 2.0;
}
let mut t = time_between_tiny;
while t < since_last_tick {
attrs.inc_tiny_droplets();
let nested = NestedJuiceStreamObject {
pos: 0.0, // not important
start_time: 0.0, // not important
kind: NestedJuiceStreamObjectKind::TinyDroplet,
};
bufs.nested_objects.push(nested);
t += time_between_tiny;
}
}
}
last_event_time = Some(e.time);
let kind = match e.kind {
SliderEventType::Tick => {
attrs.inc_droplets();
NestedJuiceStreamObjectKind::Droplet
}
SliderEventType::Head | SliderEventType::Repeat | SliderEventType::Tail => {
attrs.inc_fruits();
NestedJuiceStreamObjectKind::Fruit
}
SliderEventType::LastTick => continue,
};
let nested = NestedJuiceStreamObject {
pos: x + path.position_at(e.path_progress).x,
start_time: e.time,
kind,
};
bufs.nested_objects.push(nested);
}
Self {
control_points: slider.control_points.as_ref(),
nested_objects: bufs.nested_objects.drain(..),
}
}
}
pub struct NestedJuiceStreamObject {
pub pos: f32,
pub start_time: f64,
pub kind: NestedJuiceStreamObjectKind,
}
pub enum NestedJuiceStreamObjectKind {
Fruit,
Droplet,
TinyDroplet,
}
pub struct JuiceStreamBufs {
pub nested_objects: Vec<NestedJuiceStreamObject>,
pub curve: CurveBuffers,
pub ticks: Vec<SliderEvent>,
}
+4
View File
@@ -0,0 +1,4 @@
pub mod banana_shower;
pub mod fruit;
pub mod juice_stream;
pub mod palpable;
+23
View File
@@ -0,0 +1,23 @@
pub struct PalpableObject {
pub x: f32,
pub x_offset: f32,
pub start_time: f64,
pub dist_to_hyper_dash: f32,
pub hyper_dash: bool,
}
impl PalpableObject {
pub const fn new(x: f32, x_offset: f32, start_time: f64) -> Self {
Self {
x,
x_offset,
start_time,
dist_to_hyper_dash: 0.0,
hyper_dash: false,
}
}
pub fn effective_x(&self) -> f32 {
self.x + self.x_offset
}
}
+555
View File
@@ -0,0 +1,555 @@
use std::cmp::{self, Ordering};
use crate::{
any::ModeAttributeProvider,
any::ModeDifficulty,
osu::OsuPerformance,
util::{map_or_attrs::MapOrAttrs, mods::Mods},
};
use super::{
attributes::{CatchDifficultyAttributes, CatchPerformanceAttributes},
convert::CatchBeatmap,
score_state::CatchScoreState,
Catch,
};
/// Performance calculator on osu!catch maps.
#[derive(Clone, Debug, PartialEq)]
#[must_use]
pub struct CatchPerformance<'map> {
pub(crate) map_or_attrs: MapOrAttrs<'map, Catch>,
pub(crate) mods: u32,
pub(crate) acc: Option<f64>,
pub(crate) combo: Option<u32>,
pub(crate) n_fruits: Option<u32>,
pub(crate) n_droplets: Option<u32>,
pub(crate) n_tiny_droplets: Option<u32>,
pub(crate) n_tiny_droplet_misses: Option<u32>,
pub(crate) n_misses: Option<u32>,
pub(crate) passed_objects: Option<u32>,
pub(crate) clock_rate: Option<f64>,
}
impl<'map> CatchPerformance<'map> {
/// Create a new performance calculator for osu!catch maps.
pub fn new(map: CatchBeatmap<'map>) -> Self {
map.into()
}
/// Provide the result of a previous difficulty or performance calculation.
/// If you already calculated the attributes for the current map-mod combination,
/// be sure to put them in here so that they don't have to be recalculated.
pub fn attributes(mut self, attributes: impl ModeAttributeProvider<Catch>) -> Self {
if let Some(attrs) = attributes.attributes() {
self.map_or_attrs = MapOrAttrs::Attrs(attrs);
}
self
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
pub const fn mods(mut self, mods: u32) -> Self {
self.mods = mods;
self
}
/// Specify the max combo of the play.
pub const fn combo(mut self, combo: u32) -> Self {
self.combo = Some(combo);
self
}
/// Specify the amount of fruits of a play i.e. n300.
pub const fn fruits(mut self, n_fruits: u32) -> Self {
self.n_fruits = Some(n_fruits);
self
}
/// Specify the amount of droplets of a play i.e. n100.
pub const fn droplets(mut self, n_droplets: u32) -> Self {
self.n_droplets = Some(n_droplets);
self
}
/// Specify the amount of tiny droplets of a play i.e. n50.
pub const fn tiny_droplets(mut self, n_tiny_droplets: u32) -> Self {
self.n_tiny_droplets = Some(n_tiny_droplets);
self
}
/// Specify the amount of tiny droplet misses of a play i.e. `n_katu`.
pub const fn tiny_droplet_misses(mut self, n_tiny_droplet_misses: u32) -> Self {
self.n_tiny_droplet_misses = Some(n_tiny_droplet_misses);
self
}
/// Specify the amount of fruit / droplet misses of the play.
pub const fn misses(mut self, n_misses: u32) -> Self {
self.n_misses = Some(n_misses);
self
}
/// Amount of passed objects for partial plays, e.g. a fail.
///
#[cfg_attr(
feature = "gradual",
doc = "If you want to calculate the performance after every few objects, instead of
using [`CatchPP`] multiple times with different `passed_objects`, you should use
[`CatchGradualPerformanceAttributes`](crate::catch::CatchGradualPerformance)."
)]
pub const fn passed_objects(mut self, passed_objects: u32) -> Self {
self.passed_objects = Some(passed_objects);
self
}
/// Adjust the clock rate used in the calculation.
/// If none is specified, it will take the clock rate based on the mods
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
pub const fn clock_rate(mut self, clock_rate: f64) -> Self {
self.clock_rate = Some(clock_rate);
self
}
/// Provide parameters through an [`CatchScoreState`].
#[allow(clippy::needless_pass_by_value)]
pub const fn state(mut self, state: CatchScoreState) -> Self {
let CatchScoreState {
max_combo,
n_fruits,
n_droplets,
n_tiny_droplets,
n_tiny_droplet_misses,
n_misses,
} = state;
self.combo = Some(max_combo);
self.n_fruits = Some(n_fruits);
self.n_droplets = Some(n_droplets);
self.n_tiny_droplets = Some(n_tiny_droplets);
self.n_tiny_droplet_misses = Some(n_tiny_droplet_misses);
self.n_misses = Some(n_misses);
self
}
/// Specify the accuracy of a play between `0.0` and `100.0`.
/// This will be used to generate matching hitresults.
pub fn accuracy(mut self, acc: f64) -> Self {
self.acc = Some(acc / 100.0);
self
}
/// Create the [`CatchScoreState`] that will be used for performance calculation.
#[allow(clippy::too_many_lines)]
pub fn generate_state(&mut self) -> CatchScoreState {
let attrs = match self.map_or_attrs {
MapOrAttrs::Map(ref map) => {
let attrs = self.generate_attributes(map);
self.map_or_attrs.attrs_or_insert(attrs)
}
MapOrAttrs::Attrs(ref attrs) => attrs,
};
let n_misses = self
.n_misses
.map_or(0, |n| n.min(attrs.n_fruits + attrs.n_droplets));
let max_combo = self.combo.unwrap_or_else(|| attrs.max_combo() - n_misses);
let mut best_state = CatchScoreState {
max_combo,
n_misses,
..Default::default()
};
let mut best_dist = f64::INFINITY;
let (n_fruits, n_droplets) = match (self.n_fruits, self.n_droplets) {
(Some(mut n_fruits), Some(mut n_droplets)) => {
let n_remaining = (attrs.n_fruits + attrs.n_droplets)
.saturating_sub(n_fruits + n_droplets + n_misses);
let new_droplets = n_remaining.min(attrs.n_droplets.saturating_sub(n_droplets));
n_droplets += new_droplets;
n_fruits += n_remaining - new_droplets;
n_fruits = n_fruits
.min((attrs.n_fruits + attrs.n_droplets).saturating_sub(n_droplets + n_misses));
n_droplets =
n_droplets.min(attrs.n_fruits + attrs.n_droplets - n_fruits - n_misses);
(n_fruits, n_droplets)
}
(Some(mut n_fruits), None) => {
let n_droplets = attrs.n_droplets.saturating_sub(
n_misses.saturating_sub(attrs.n_fruits.saturating_sub(n_fruits)),
);
n_fruits = attrs.n_fruits + attrs.n_droplets - n_misses - n_droplets;
(n_fruits, n_droplets)
}
(None, Some(mut n_droplets)) => {
let n_fruits = attrs.n_fruits.saturating_sub(
n_misses.saturating_sub(attrs.n_droplets.saturating_sub(n_droplets)),
);
n_droplets = attrs.n_fruits + attrs.n_droplets - n_misses - n_fruits;
(n_fruits, n_droplets)
}
(None, None) => {
let n_droplets = attrs.n_droplets.saturating_sub(n_misses);
let n_fruits =
attrs.n_fruits - (n_misses - (attrs.n_droplets.saturating_sub(n_droplets)));
(n_fruits, n_droplets)
}
};
best_state.n_fruits = n_fruits;
best_state.n_droplets = n_droplets;
let mut find_best_tiny_droplets = |acc: f64| {
let raw_tiny_droplets = acc
* f64::from(attrs.n_fruits + attrs.n_droplets + attrs.n_tiny_droplets)
- f64::from(n_fruits + n_droplets);
let min_tiny_droplets =
cmp::min(attrs.n_tiny_droplets, raw_tiny_droplets.floor() as u32);
let max_tiny_droplets =
cmp::min(attrs.n_tiny_droplets, raw_tiny_droplets.ceil() as u32);
for n_tiny_droplets in min_tiny_droplets..=max_tiny_droplets {
let n_tiny_droplet_misses = attrs.n_tiny_droplets - n_tiny_droplets;
let curr_acc = accuracy(
n_fruits,
n_droplets,
n_tiny_droplets,
n_tiny_droplet_misses,
n_misses,
);
let curr_dist = (acc - curr_acc).abs();
if curr_dist < best_dist {
best_dist = curr_dist;
best_state.n_tiny_droplets = n_tiny_droplets;
best_state.n_tiny_droplet_misses = n_tiny_droplet_misses;
}
}
};
#[allow(clippy::single_match_else)]
match (self.n_tiny_droplets, self.n_tiny_droplet_misses) {
(Some(n_tiny_droplets), Some(n_tiny_droplet_misses)) => match self.acc {
Some(acc) => {
match (n_tiny_droplets + n_tiny_droplet_misses).cmp(&attrs.n_tiny_droplets) {
Ordering::Equal => {
best_state.n_tiny_droplets = n_tiny_droplets;
best_state.n_tiny_droplet_misses = n_tiny_droplet_misses;
}
Ordering::Less | Ordering::Greater => find_best_tiny_droplets(acc),
}
}
None => {
let n_remaining = attrs
.n_tiny_droplets
.saturating_sub(n_tiny_droplets + n_tiny_droplet_misses);
best_state.n_tiny_droplets = n_tiny_droplets + n_remaining;
best_state.n_tiny_droplet_misses = n_tiny_droplet_misses;
}
},
(Some(n_tiny_droplets), None) => {
best_state.n_tiny_droplets = attrs.n_tiny_droplets.min(n_tiny_droplets);
best_state.n_tiny_droplet_misses =
attrs.n_tiny_droplets.saturating_sub(n_tiny_droplets);
}
(None, Some(n_tiny_droplet_misses)) => {
best_state.n_tiny_droplets =
attrs.n_tiny_droplets.saturating_sub(n_tiny_droplet_misses);
best_state.n_tiny_droplet_misses = attrs.n_tiny_droplets.min(n_tiny_droplet_misses);
}
(None, None) => match self.acc {
Some(acc) => find_best_tiny_droplets(acc),
None => best_state.n_tiny_droplets = attrs.n_tiny_droplets,
},
}
best_state
}
/// Calculate all performance related values, including pp and stars.
pub fn calculate(mut self) -> CatchPerformanceAttributes {
let state = self.generate_state();
let attrs = match self.map_or_attrs {
MapOrAttrs::Map(ref map) => self.generate_attributes(map),
MapOrAttrs::Attrs(attrs) => attrs,
};
let inner = CatchPerformanceInner {
attrs,
mods: self.mods,
state,
};
inner.calculate()
}
fn generate_attributes(&self, map: &CatchBeatmap<'_>) -> CatchDifficultyAttributes {
let mut calculator = ModeDifficulty::new();
if let Some(passed_objects) = self.passed_objects {
calculator.passed_objects(passed_objects);
}
if let Some(clock_rate) = self.clock_rate {
calculator.clock_rate(clock_rate);
}
calculator.mods(self.mods).calculate(map)
}
/// Try to create [`CatchPerformance`] through a [`ModeAttributeProvider`].
///
/// If you already calculated the attributes for the current map-mod
/// combination, the [`CatchBeatmap`] is no longer necessary to calculate
/// performance attributes so this method can be used instead of
/// [`CatchPerformance::new`].
///
/// Returns `None` only if the [`ModeAttributeProvider`] did not contain
/// attributes for catch e.g. if it's [`DifficultyAttributes::Taiko`].
///
/// [`DifficultyAttributes::Taiko`]: crate::any::DifficultyAttributes::Taiko
pub fn try_from_attributes(attributes: impl ModeAttributeProvider<Catch>) -> Option<Self> {
attributes.attributes().map(Self::from)
}
/// Create [`CatchPerformance`] through a [`ModeAttributeProvider`].
///
/// If you already calculated the attributes for the current map-mod
/// combination, the [`CatchBeatmap`] is no longer necessary to calculate
/// performance attributes so this method can be used instead of
/// [`CatchPerformance::new`].
///
/// # Panics
///
/// Panics if the [`ModeAttributeProvider`] did not contain attributes for
/// catch e.g. if it's [`DifficultyAttributes::Taiko`].
///
/// [`DifficultyAttributes::Taiko`]: crate::any::DifficultyAttributes::Taiko
pub fn unchecked_from_attributes(attributes: impl ModeAttributeProvider<Catch>) -> Self {
Self::try_from_attributes(attributes).expect("invalid catch attributes")
}
}
impl<'map> TryFrom<OsuPerformance<'map>> for CatchPerformance<'map> {
type Error = OsuPerformance<'map>;
/// Try to create [`CatchPerformance`] through [`OsuPerformance`].
///
/// Returns `None` if [`OsuPerformance`] already replaced its internal
/// beatmap with [`OsuDifficultyAttributes`], i.e. if
/// [`OsuPerformance::attributes`] or [`OsuPerformance::generate_state`]
/// was called.
///
/// [`OsuDifficultyAttributes`]: crate::osu::OsuDifficultyAttributes
fn try_from(mut osu: OsuPerformance<'map>) -> Result<Self, Self::Error> {
let MapOrAttrs::Map(converted) = osu.map_or_attrs else {
return Err(osu);
};
let map = match converted.try_convert() {
Ok(map) => map,
Err(map) => {
osu.map_or_attrs = MapOrAttrs::Map(map);
return Err(osu);
}
};
let OsuPerformance {
map_or_attrs: _,
mods,
acc,
combo,
n300,
n100,
n50,
n_misses,
passed_objects,
clock_rate,
hitresult_priority: _,
} = osu;
Ok(Self {
map_or_attrs: MapOrAttrs::Map(map),
mods,
acc,
combo,
n_fruits: n300,
n_droplets: n100,
n_tiny_droplets: n50,
n_tiny_droplet_misses: None,
n_misses,
passed_objects,
clock_rate,
})
}
}
impl<'map> From<CatchBeatmap<'map>> for CatchPerformance<'map> {
fn from(map: CatchBeatmap<'map>) -> Self {
Self {
map_or_attrs: MapOrAttrs::Map(map),
mods: 0,
acc: None,
combo: None,
n_fruits: None,
n_droplets: None,
n_tiny_droplets: None,
n_tiny_droplet_misses: None,
n_misses: None,
passed_objects: None,
clock_rate: None,
}
}
}
impl From<CatchDifficultyAttributes> for CatchPerformance<'_> {
fn from(attrs: CatchDifficultyAttributes) -> Self {
Self {
map_or_attrs: MapOrAttrs::Attrs(attrs),
mods: 0,
acc: None,
combo: None,
n_fruits: None,
n_droplets: None,
n_tiny_droplets: None,
n_tiny_droplet_misses: None,
n_misses: None,
passed_objects: None,
clock_rate: None,
}
}
}
impl From<CatchPerformanceAttributes> for CatchPerformance<'_> {
fn from(attrs: CatchPerformanceAttributes) -> Self {
attrs.difficulty.into()
}
}
struct CatchPerformanceInner {
attrs: CatchDifficultyAttributes,
mods: u32,
state: CatchScoreState,
}
impl CatchPerformanceInner {
fn calculate(self) -> CatchPerformanceAttributes {
let attributes = &self.attrs;
let stars = attributes.stars;
let max_combo = attributes.max_combo();
// Relying heavily on aim
let mut pp = (5.0 * (stars / 0.0049).max(1.0) - 4.0).powi(2) / 100_000.0;
let mut combo_hits = self.combo_hits();
if combo_hits == 0 {
combo_hits = max_combo;
}
// Longer maps are worth more
let len_bonus = 0.95
+ 0.3 * (f64::from(combo_hits) / 2500.0).min(1.0)
+ f64::from(u8::from(combo_hits > 2500))
* (f64::from(combo_hits) / 2500.0).log10()
* 0.475;
pp *= len_bonus;
// Penalize misses exponentially
pp *= 0.97_f64.powi(self.state.n_misses as i32);
// Combo scaling
if self.state.max_combo > 0 {
pp *= (f64::from(self.state.max_combo) / f64::from(max_combo))
.powf(0.8)
.min(1.0);
}
// AR scaling
let ar = attributes.ar;
let mut ar_factor = 1.0;
if ar > 9.0 {
ar_factor += 0.1 * (ar - 9.0) + f64::from(u8::from(ar > 10.0)) * 0.1 * (ar - 10.0);
} else if ar < 8.0 {
ar_factor += 0.025 * (8.0 - ar);
}
pp *= ar_factor;
// HD bonus
if self.mods.hd() {
if ar <= 10.0 {
pp *= 1.05 + 0.075 * (10.0 - ar);
} else if ar > 10.0 {
pp *= 1.01 + 0.04 * (11.0 - ar.min(11.0));
}
}
// FL bonus
if self.mods.fl() {
pp *= 1.35 * len_bonus;
}
// Accuracy scaling
pp *= self.state.accuracy().powf(5.5);
// NF penalty
if self.mods.nf() {
pp *= 0.9;
}
CatchPerformanceAttributes {
difficulty: self.attrs,
pp,
}
}
const fn combo_hits(&self) -> u32 {
self.state.n_fruits + self.state.n_droplets + self.state.n_misses
}
}
fn accuracy(
n_fruits: u32,
n_droplets: u32,
n_tiny_droplets: u32,
n_tiny_droplet_misses: u32,
n_misses: u32,
) -> f64 {
let numerator = n_fruits + n_droplets + n_tiny_droplets;
let denominator = numerator + n_tiny_droplet_misses + n_misses;
f64::from(numerator) / f64::from(denominator)
}
-807
View File
@@ -1,807 +0,0 @@
use super::{CatchDifficultyAttributes, CatchPerformanceAttributes, CatchScoreState, CatchStars};
use crate::{
util::{MapOrElse, MapRef},
Beatmap, DifficultyAttributes, Mods, OsuPP, PerformanceAttributes,
};
use std::cmp::Ordering;
/// Performance calculator on osu!catch maps.
///
/// # Example
///
/// ```
/// use rosu_pp::{CatchPP, Beatmap};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let pp_result = CatchPP::new(&map)
/// .mods(8 + 64) // HDDT
/// .combo(1234)
/// .accuracy(98.5)
/// .misses(1)
/// .calculate();
///
/// println!("PP: {} | Stars: {}", pp_result.pp(), pp_result.stars());
///
/// let next_result = CatchPP::new(&map)
/// .attributes(pp_result) // reusing previous results for performance
/// .mods(8 + 64) // has to be the same to reuse attributes
/// .accuracy(99.5)
/// .calculate();
///
/// println!("PP: {} | Stars: {}", next_result.pp(), next_result.stars());
/// ```
#[derive(Clone, Debug)]
#[allow(clippy::upper_case_acronyms)]
#[must_use]
pub struct CatchPP<'map> {
pub(crate) map_or_attrs: MapOrElse<MapRef<'map>, CatchDifficultyAttributes>,
pub(crate) mods: u32,
pub(crate) acc: Option<f64>,
pub(crate) combo: Option<usize>,
pub(crate) n_fruits: Option<usize>,
pub(crate) n_droplets: Option<usize>,
pub(crate) n_tiny_droplets: Option<usize>,
pub(crate) n_tiny_droplet_misses: Option<usize>,
pub(crate) n_misses: Option<usize>,
pub(crate) passed_objects: Option<usize>,
pub(crate) clock_rate: Option<f64>,
}
impl<'map> CatchPP<'map> {
/// Create a new performance calculator for osu!catch maps.
#[inline]
pub fn new(map: &'map Beatmap) -> Self {
Self {
map_or_attrs: MapOrElse::from(map),
mods: 0,
acc: None,
combo: None,
n_fruits: None,
n_droplets: None,
n_tiny_droplets: None,
n_tiny_droplet_misses: None,
n_misses: None,
passed_objects: None,
clock_rate: None,
}
}
/// Provide the result of a previous difficulty or performance calculation.
/// If you already calculated the attributes for the current map-mod combination,
/// be sure to put them in here so that they don't have to be recalculated.
#[inline]
pub fn attributes(mut self, attributes: impl CatchAttributeProvider) -> Self {
if let Some(attrs) = attributes.attributes() {
self.map_or_attrs = MapOrElse::Else(attrs);
}
self
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
#[inline]
pub const fn mods(mut self, mods: u32) -> Self {
self.mods = mods;
self
}
/// Specify the max combo of the play.
#[inline]
pub const fn combo(mut self, combo: usize) -> Self {
self.combo = Some(combo);
self
}
/// Specify the amount of fruits of a play i.e. n300.
#[inline]
pub const fn fruits(mut self, n_fruits: usize) -> Self {
self.n_fruits = Some(n_fruits);
self
}
/// Specify the amount of droplets of a play i.e. n100.
#[inline]
pub const fn droplets(mut self, n_droplets: usize) -> Self {
self.n_droplets = Some(n_droplets);
self
}
/// Specify the amount of tiny droplets of a play i.e. n50.
#[inline]
pub const fn tiny_droplets(mut self, n_tiny_droplets: usize) -> Self {
self.n_tiny_droplets = Some(n_tiny_droplets);
self
}
/// Specify the amount of tiny droplet misses of a play i.e. `n_katu`.
#[inline]
pub const fn tiny_droplet_misses(mut self, n_tiny_droplet_misses: usize) -> Self {
self.n_tiny_droplet_misses = Some(n_tiny_droplet_misses);
self
}
/// Specify the amount of fruit / droplet misses of the play.
#[inline]
pub const fn misses(mut self, n_misses: usize) -> Self {
self.n_misses = Some(n_misses);
self
}
/// Amount of passed objects for partial plays, e.g. a fail.
///
#[cfg_attr(
feature = "gradual",
doc = "If you want to calculate the performance after every few objects, instead of
using [`CatchPP`] multiple times with different `passed_objects`, you should use
[`CatchGradualPerformanceAttributes`](crate::catch::CatchGradualPerformance)."
)]
#[inline]
pub const fn passed_objects(mut self, passed_objects: usize) -> Self {
self.passed_objects = Some(passed_objects);
self
}
/// Adjust the clock rate used in the calculation.
/// If none is specified, it will take the clock rate based on the mods
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
#[inline]
pub const fn clock_rate(mut self, clock_rate: f64) -> Self {
self.clock_rate = Some(clock_rate);
self
}
/// Provide parameters through an [`CatchScoreState`].
#[inline]
#[allow(clippy::needless_pass_by_value)]
pub const fn state(mut self, state: CatchScoreState) -> Self {
let CatchScoreState {
max_combo,
n_fruits,
n_droplets,
n_tiny_droplets,
n_tiny_droplet_misses,
n_misses,
} = state;
self.combo = Some(max_combo);
self.n_fruits = Some(n_fruits);
self.n_droplets = Some(n_droplets);
self.n_tiny_droplets = Some(n_tiny_droplets);
self.n_tiny_droplet_misses = Some(n_tiny_droplet_misses);
self.n_misses = Some(n_misses);
self
}
/// Specify the accuracy of a play between `0.0` and `100.0`.
/// This will be used to generate matching hitresults.
#[inline]
pub fn accuracy(mut self, acc: f64) -> Self {
self.acc = Some(acc / 100.0);
self
}
/// Create the [`CatchScoreState`] that will be used for performance calculation.
#[allow(clippy::too_many_lines)]
pub fn generate_state(&mut self) -> CatchScoreState {
let attrs = match self.map_or_attrs {
MapOrElse::Map(ref map) => {
let attrs = self.generate_attributes(map.as_ref());
self.map_or_attrs.else_or_insert(attrs)
}
MapOrElse::Else(ref attrs) => attrs,
};
let n_misses = self
.n_misses
.map_or(0, |n| n.min(attrs.n_fruits + attrs.n_droplets));
let max_combo = self.combo.unwrap_or_else(|| attrs.max_combo() - n_misses);
let mut best_state = CatchScoreState {
max_combo,
n_misses,
..Default::default()
};
let mut best_dist = f64::INFINITY;
let (n_fruits, n_droplets) = match (self.n_fruits, self.n_droplets) {
(Some(mut n_fruits), Some(mut n_droplets)) => {
let n_remaining = (attrs.n_fruits + attrs.n_droplets)
.saturating_sub(n_fruits + n_droplets + n_misses);
let new_droplets = n_remaining.min(attrs.n_droplets.saturating_sub(n_droplets));
n_droplets += new_droplets;
n_fruits += n_remaining - new_droplets;
n_fruits = n_fruits
.min((attrs.n_fruits + attrs.n_droplets).saturating_sub(n_droplets + n_misses));
n_droplets =
n_droplets.min(attrs.n_fruits + attrs.n_droplets - n_fruits - n_misses);
(n_fruits, n_droplets)
}
(Some(mut n_fruits), None) => {
let n_droplets = attrs.n_droplets.saturating_sub(
n_misses.saturating_sub(attrs.n_fruits.saturating_sub(n_fruits)),
);
n_fruits = attrs.n_fruits + attrs.n_droplets - n_misses - n_droplets;
(n_fruits, n_droplets)
}
(None, Some(mut n_droplets)) => {
let n_fruits = attrs.n_fruits.saturating_sub(
n_misses.saturating_sub(attrs.n_droplets.saturating_sub(n_droplets)),
);
n_droplets = attrs.n_fruits + attrs.n_droplets - n_misses - n_fruits;
(n_fruits, n_droplets)
}
(None, None) => {
let n_droplets = attrs.n_droplets.saturating_sub(n_misses);
let n_fruits =
attrs.n_fruits - (n_misses - (attrs.n_droplets.saturating_sub(n_droplets)));
(n_fruits, n_droplets)
}
};
best_state.n_fruits = n_fruits;
best_state.n_droplets = n_droplets;
let mut find_best_tiny_droplets = |acc: f64| {
let raw_tiny_droplets = acc
* (attrs.n_fruits + attrs.n_droplets + attrs.n_tiny_droplets) as f64
- (n_fruits + n_droplets) as f64;
let min_tiny_droplets = attrs
.n_tiny_droplets
.min(raw_tiny_droplets.floor() as usize);
let max_tiny_droplets = attrs.n_tiny_droplets.min(raw_tiny_droplets.ceil() as usize);
for n_tiny_droplets in min_tiny_droplets..=max_tiny_droplets {
let n_tiny_droplet_misses = attrs.n_tiny_droplets - n_tiny_droplets;
let curr_acc = accuracy(
n_fruits,
n_droplets,
n_tiny_droplets,
n_tiny_droplet_misses,
n_misses,
);
let curr_dist = (acc - curr_acc).abs();
if curr_dist < best_dist {
best_dist = curr_dist;
best_state.n_tiny_droplets = n_tiny_droplets;
best_state.n_tiny_droplet_misses = n_tiny_droplet_misses;
}
}
};
#[allow(clippy::single_match_else)]
match (self.n_tiny_droplets, self.n_tiny_droplet_misses) {
(Some(n_tiny_droplets), Some(n_tiny_droplet_misses)) => match self.acc {
Some(acc) => {
match (n_tiny_droplets + n_tiny_droplet_misses).cmp(&attrs.n_tiny_droplets) {
Ordering::Equal => {
best_state.n_tiny_droplets = n_tiny_droplets;
best_state.n_tiny_droplet_misses = n_tiny_droplet_misses;
}
Ordering::Less | Ordering::Greater => find_best_tiny_droplets(acc),
}
}
None => {
let n_remaining = attrs
.n_tiny_droplets
.saturating_sub(n_tiny_droplets + n_tiny_droplet_misses);
best_state.n_tiny_droplets = n_tiny_droplets + n_remaining;
best_state.n_tiny_droplet_misses = n_tiny_droplet_misses;
}
},
(Some(n_tiny_droplets), None) => {
best_state.n_tiny_droplets = attrs.n_tiny_droplets.min(n_tiny_droplets);
best_state.n_tiny_droplet_misses =
attrs.n_tiny_droplets.saturating_sub(n_tiny_droplets);
}
(None, Some(n_tiny_droplet_misses)) => {
best_state.n_tiny_droplets =
attrs.n_tiny_droplets.saturating_sub(n_tiny_droplet_misses);
best_state.n_tiny_droplet_misses = attrs.n_tiny_droplets.min(n_tiny_droplet_misses);
}
(None, None) => match self.acc {
Some(acc) => find_best_tiny_droplets(acc),
None => best_state.n_tiny_droplets = attrs.n_tiny_droplets,
},
}
best_state
}
/// Calculate all performance related values, including pp and stars.
pub fn calculate(mut self) -> CatchPerformanceAttributes {
let state = self.generate_state();
let attrs = match self.map_or_attrs {
MapOrElse::Map(ref map) => self.generate_attributes(map.as_ref()),
MapOrElse::Else(attrs) => attrs,
};
let inner = CatchPPInner {
attrs,
mods: self.mods,
state,
};
inner.calculate()
}
fn generate_attributes(&self, map: &Beatmap) -> CatchDifficultyAttributes {
let mut calculator = CatchStars::new(map).mods(self.mods);
if let Some(passed_objects) = self.passed_objects {
calculator = calculator.passed_objects(passed_objects);
}
if let Some(clock_rate) = self.clock_rate {
calculator = calculator.clock_rate(clock_rate);
}
calculator.calculate()
}
/// Try to create [`CatchPP`] through [`OsuPP`].
///
/// Returns `None` if [`OsuPP`] already replaced its internal [`Beatmap`]
/// with [`OsuDifficultyAttributes`], i.e. if [`OsuPP::attributes`]
/// or [`OsuPP::generate_state`] was called.
///
/// [`OsuDifficultyAttributes`]: crate::osu::OsuDifficultyAttributes
#[inline]
pub const fn try_from_osu(osu: OsuPP<'map>) -> Option<Self> {
let OsuPP {
map_or_attrs,
mods,
acc,
combo,
n300,
n100,
n50,
n_misses,
passed_objects,
clock_rate,
hitresult_priority: _,
} = osu;
let MapOrElse::Map(map) = map_or_attrs else {
return None;
};
Some(Self {
map_or_attrs: MapOrElse::Map(map),
mods,
acc,
combo,
n_fruits: n300,
n_droplets: n100,
n_tiny_droplets: n50,
n_tiny_droplet_misses: None,
n_misses,
passed_objects,
clock_rate,
})
}
/// Try to create [`CatchPP`] through a [`CatchAttributeProvider`].
///
/// If you already calculated the attributes for the current map-mod
/// combination, the [`Beatmap`] is no longer necessary to calculate
/// performance attributes so this method can be used instead of
/// [`CatchPP::new`].
///
/// Returns `None` only if the [`CatchAttributeProvider`] did not contain
/// attributes for catch e.g. if it's [`DifficultyAttributes::Taiko`].
#[inline]
pub fn try_from_attributes(attributes: impl CatchAttributeProvider) -> Option<Self> {
attributes.attributes().map(Self::from)
}
}
struct CatchPPInner {
attrs: CatchDifficultyAttributes,
mods: u32,
state: CatchScoreState,
}
impl CatchPPInner {
fn calculate(self) -> CatchPerformanceAttributes {
let attributes = &self.attrs;
let stars = attributes.stars;
let max_combo = attributes.max_combo();
// Relying heavily on aim
let mut pp = (5.0 * (stars / 0.0049).max(1.0) - 4.0).powi(2) / 100_000.0;
let mut combo_hits = self.combo_hits();
if combo_hits == 0 {
combo_hits = max_combo;
}
// Longer maps are worth more
let len_bonus = 0.95
+ 0.3 * (combo_hits as f64 / 2500.0).min(1.0)
+ f64::from(u8::from(combo_hits > 2500)) * (combo_hits as f64 / 2500.0).log10() * 0.475;
pp *= len_bonus;
// Penalize misses exponentially
pp *= 0.97_f64.powi(self.state.n_misses as i32);
// Combo scaling
if self.state.max_combo > 0 {
pp *= (self.state.max_combo as f64 / max_combo as f64)
.powf(0.8)
.min(1.0);
}
// AR scaling
let ar = attributes.ar;
let mut ar_factor = 1.0;
if ar > 9.0 {
ar_factor += 0.1 * (ar - 9.0) + f64::from(u8::from(ar > 10.0)) * 0.1 * (ar - 10.0);
} else if ar < 8.0 {
ar_factor += 0.025 * (8.0 - ar);
}
pp *= ar_factor;
// HD bonus
if self.mods.hd() {
if ar <= 10.0 {
pp *= 1.05 + 0.075 * (10.0 - ar);
} else if ar > 10.0 {
pp *= 1.01 + 0.04 * (11.0 - ar.min(11.0));
}
}
// FL bonus
if self.mods.fl() {
pp *= 1.35 * len_bonus;
}
// Accuracy scaling
pp *= self.state.accuracy().powf(5.5);
// NF penalty
if self.mods.nf() {
pp *= 0.9;
}
CatchPerformanceAttributes {
difficulty: self.attrs,
pp,
}
}
const fn combo_hits(&self) -> usize {
self.state.n_fruits + self.state.n_droplets + self.state.n_misses
}
}
fn accuracy(
n_fruits: usize,
n_droplets: usize,
n_tiny_droplets: usize,
n_tiny_droplet_misses: usize,
n_misses: usize,
) -> f64 {
let numerator = n_fruits + n_droplets + n_tiny_droplets;
let denominator = numerator + n_tiny_droplet_misses + n_misses;
numerator as f64 / denominator as f64
}
impl From<CatchDifficultyAttributes> for CatchPP<'_> {
fn from(attrs: CatchDifficultyAttributes) -> Self {
Self {
map_or_attrs: MapOrElse::Else(attrs),
mods: 0,
acc: None,
combo: None,
n_fruits: None,
n_droplets: None,
n_tiny_droplets: None,
n_tiny_droplet_misses: None,
n_misses: None,
passed_objects: None,
clock_rate: None,
}
}
}
impl From<CatchPerformanceAttributes> for CatchPP<'_> {
fn from(attrs: CatchPerformanceAttributes) -> Self {
attrs.difficulty.into()
}
}
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
pub trait CatchAttributeProvider {
/// Provide the actual difficulty attributes.
fn attributes(self) -> Option<CatchDifficultyAttributes>;
}
impl CatchAttributeProvider for CatchDifficultyAttributes {
#[inline]
fn attributes(self) -> Option<CatchDifficultyAttributes> {
Some(self)
}
}
impl CatchAttributeProvider for CatchPerformanceAttributes {
#[inline]
fn attributes(self) -> Option<CatchDifficultyAttributes> {
Some(self.difficulty)
}
}
impl CatchAttributeProvider for DifficultyAttributes {
#[inline]
fn attributes(self) -> Option<CatchDifficultyAttributes> {
if let Self::Catch(attributes) = self {
Some(attributes)
} else {
None
}
}
}
impl CatchAttributeProvider for PerformanceAttributes {
#[inline]
fn attributes(self) -> Option<CatchDifficultyAttributes> {
if let Self::Catch(attributes) = self {
Some(attributes.difficulty)
} else {
None
}
}
}
#[cfg(not(any(feature = "async_tokio", feature = "async_std")))]
#[cfg(test)]
mod test {
use super::*;
use crate::Beatmap;
use proptest::{option, prelude::*};
use std::sync::OnceLock;
static DATA: OnceLock<(Beatmap, CatchDifficultyAttributes)> = OnceLock::new();
const N_FRUITS: usize = 728;
const N_DROPLETS: usize = 2;
const N_TINY_DROPLETS: usize = 291;
fn test_data() -> (&'static Beatmap, CatchDifficultyAttributes) {
let (map, attrs) = DATA.get_or_init(|| {
let path = "./maps/2118524.osu";
let map = Beatmap::from_path(path).unwrap();
let attrs = CatchStars::new(&map).calculate();
assert_eq!(
(N_FRUITS, N_DROPLETS, N_TINY_DROPLETS),
(attrs.n_fruits, attrs.n_droplets, attrs.n_tiny_droplets)
);
(map, attrs)
});
(map, attrs.to_owned())
}
/// Checks all remaining hitresult combinations w.r.t. the given parameters
/// and returns the [`OsuScoreState`] that matches `acc` the best.
///
/// Very slow but accurate.
fn brute_force_best(
acc: f64,
n_fruits: Option<usize>,
n_droplets: Option<usize>,
n_tiny_droplets: Option<usize>,
n_tiny_droplet_misses: Option<usize>,
n_misses: usize,
) -> CatchScoreState {
let n_misses = n_misses.min(N_FRUITS + N_DROPLETS);
let mut best_state = CatchScoreState {
max_combo: N_FRUITS + N_DROPLETS - n_misses,
n_misses,
..Default::default()
};
let mut best_dist = f64::INFINITY;
let (new_fruits, new_droplets) = match (n_fruits, n_droplets) {
(Some(mut n_fruits), Some(mut n_droplets)) => {
let n_remaining =
(N_FRUITS + N_DROPLETS).saturating_sub(n_fruits + n_droplets + n_misses);
let new_droplets = n_remaining.min(N_DROPLETS.saturating_sub(n_droplets));
n_droplets += new_droplets;
n_fruits += n_remaining - new_droplets;
n_fruits =
n_fruits.min((N_FRUITS + N_DROPLETS).saturating_sub(n_droplets + n_misses));
n_droplets = n_droplets.min(N_FRUITS + N_DROPLETS - n_fruits - n_misses);
(n_fruits, n_droplets)
}
(Some(mut n_fruits), None) => {
let n_droplets = N_DROPLETS
.saturating_sub(n_misses.saturating_sub(N_FRUITS.saturating_sub(n_fruits)));
n_fruits = N_FRUITS + N_DROPLETS - n_misses - n_droplets;
(n_fruits, n_droplets)
}
(None, Some(mut n_droplets)) => {
let n_fruits = N_FRUITS
.saturating_sub(n_misses.saturating_sub(N_DROPLETS.saturating_sub(n_droplets)));
n_droplets = N_FRUITS + N_DROPLETS - n_misses - n_fruits;
(n_fruits, n_droplets)
}
(None, None) => {
let n_droplets = N_DROPLETS.saturating_sub(n_misses);
let n_fruits = N_FRUITS - (n_misses - (N_DROPLETS.saturating_sub(n_droplets)));
(n_fruits, n_droplets)
}
};
best_state.n_fruits = new_fruits;
best_state.n_droplets = new_droplets;
let (min_tiny_droplets, max_tiny_droplets) = match (n_tiny_droplets, n_tiny_droplet_misses)
{
(Some(n_tiny_droplets), Some(n_tiny_droplet_misses)) => {
match (n_tiny_droplets + n_tiny_droplet_misses).cmp(&N_TINY_DROPLETS) {
Ordering::Equal => (
N_TINY_DROPLETS.min(n_tiny_droplets),
N_TINY_DROPLETS.min(n_tiny_droplets),
),
Ordering::Less | Ordering::Greater => (0, N_TINY_DROPLETS),
}
}
(Some(n_tiny_droplets), None) => (
N_TINY_DROPLETS.min(n_tiny_droplets),
N_TINY_DROPLETS.min(n_tiny_droplets),
),
(None, Some(n_tiny_droplet_misses)) => (
N_TINY_DROPLETS.saturating_sub(n_tiny_droplet_misses),
N_TINY_DROPLETS.saturating_sub(n_tiny_droplet_misses),
),
(None, None) => (0, N_TINY_DROPLETS),
};
for new_tiny_droplets in min_tiny_droplets..=max_tiny_droplets {
let new_tiny_droplet_misses = N_TINY_DROPLETS - new_tiny_droplets;
let curr_acc = accuracy(
new_fruits,
new_droplets,
new_tiny_droplets,
new_tiny_droplet_misses,
n_misses,
);
let curr_dist = (acc - curr_acc).abs();
if curr_dist < best_dist {
best_dist = curr_dist;
best_state.n_tiny_droplets = new_tiny_droplets;
best_state.n_tiny_droplet_misses = new_tiny_droplet_misses;
}
}
best_state
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(20_000))]
#[test]
fn catch_hitresults(
acc in 0.0..=1.0,
n_fruits in option::weighted(0.10, 0_usize..=N_FRUITS + 10),
n_droplets in option::weighted(0.10, 0_usize..=N_DROPLETS + 10),
n_tiny_droplets in option::weighted(0.10, 0_usize..=N_TINY_DROPLETS + 10),
n_tiny_droplet_misses in option::weighted(0.10, 0_usize..=N_TINY_DROPLETS + 10),
n_misses in option::weighted(0.15, 0_usize..=N_FRUITS + N_DROPLETS + 10),
) {
let (map, attrs) = test_data();
let mut state = CatchPP::new(map)
.attributes(attrs)
.accuracy(acc * 100.0);
if let Some(n_fruits) = n_fruits {
state = state.fruits(n_fruits);
}
if let Some(n_droplets) = n_droplets {
state = state.droplets(n_droplets);
}
if let Some(n_tiny_droplets) = n_tiny_droplets {
state = state.tiny_droplets(n_tiny_droplets);
}
if let Some(n_tiny_droplet_misses) = n_tiny_droplet_misses {
state = state.tiny_droplet_misses(n_tiny_droplet_misses);
}
if let Some(n_misses) = n_misses {
state = state.misses(n_misses);
}
let state = state.generate_state();
let expected = brute_force_best(
acc,
n_fruits,
n_droplets,
n_tiny_droplets,
n_tiny_droplet_misses,
n_misses.unwrap_or(0),
);
assert_eq!(state, expected);
}
}
#[test]
fn fruits_missing_objects() {
let (map, attrs) = test_data();
let state = CatchPP::new(map)
.attributes(attrs)
.fruits(N_FRUITS - 10)
.droplets(N_DROPLETS - 1)
.tiny_droplets(N_TINY_DROPLETS - 50)
.tiny_droplet_misses(20)
.misses(2)
.generate_state();
let expected = CatchScoreState {
max_combo: N_FRUITS + N_DROPLETS - 2,
n_fruits: N_FRUITS - 2,
n_droplets: N_DROPLETS,
n_tiny_droplets: N_TINY_DROPLETS - 20,
n_tiny_droplet_misses: 20,
n_misses: 2,
};
assert_eq!(state, expected);
}
}
+10 -13
View File
@@ -1,22 +1,21 @@
/// Aggregation for a score's current state i.e. what was the
/// maximum combo so far and what are the current hitresults.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
/// Aggregation for a score's current state.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CatchScoreState {
/// Maximum combo that the score has had so far.
/// **Not** the maximum possible combo of the map so far.
///
/// Note that only fruits and droplets are considered for osu!catch combo.
pub max_combo: usize,
pub max_combo: u32,
/// Amount of current fruits (300s).
pub n_fruits: usize,
pub n_fruits: u32,
/// Amount of current droplets (100s).
pub n_droplets: usize,
pub n_droplets: u32,
/// Amount of current tiny droplets (50s).
pub n_tiny_droplets: usize,
pub n_tiny_droplets: u32,
/// Amount of current tiny droplet misses (katus).
pub n_tiny_droplet_misses: usize,
pub n_tiny_droplet_misses: u32,
/// Amount of current misses (fruits and droplets).
pub n_misses: usize,
pub n_misses: u32,
}
impl CatchScoreState {
@@ -26,8 +25,7 @@ impl CatchScoreState {
}
/// Return the total amount of hits by adding everything up.
#[inline]
pub const fn total_hits(&self) -> usize {
pub const fn total_hits(&self) -> u32 {
self.n_fruits
+ self.n_droplets
+ self.n_tiny_droplets
@@ -36,7 +34,6 @@ impl CatchScoreState {
}
/// Calculate the accuracy between `0.0` and `1.0` for this state.
#[inline]
pub fn accuracy(&self) -> f64 {
let total_hits = self.total_hits();
@@ -47,6 +44,6 @@ impl CatchScoreState {
let numerator = self.n_fruits + self.n_droplets + self.n_tiny_droplets;
let denominator = total_hits;
numerator as f64 / denominator as f64
f64::from(numerator) / f64::from(denominator)
}
}
+25
View File
@@ -0,0 +1,25 @@
use crate::{any::ModeDifficulty, catch::difficulty::DifficultyValues};
use super::convert::CatchBeatmap;
/// The result of calculating the strains on a osu!catch map.
///
/// Suitable to plot the difficulty of a map over time.
#[derive(Clone, Debug, PartialEq)]
pub struct CatchStrains {
/// Strain peaks of the movement skill.
pub movement: Vec<f64>,
}
impl CatchStrains {
/// Time between two strains in ms.
pub const SECTION_LEN: f64 = 750.0;
}
pub fn strains(difficulty: &ModeDifficulty, converted: &CatchBeatmap<'_>) -> CatchStrains {
let DifficultyValues { movement, .. } = DifficultyValues::calculate(difficulty, converted);
CatchStrains {
movement: movement.get_curr_strain_peaks(),
}
}
-546
View File
@@ -1,546 +0,0 @@
use std::{borrow::Cow, cmp::Ordering, convert::identity, f64::consts::PI, iter};
use crate::parse::{PathControlPoint, PathType, Pos2};
const BEZIER_TOLERANCE: f32 = 0.25;
const CATMULL_DETAIL: usize = 50;
const CIRCULAR_ARC_TOLERANCE: f32 = 0.1;
#[derive(Clone, Debug, Default)]
pub(crate) struct CurveBuffers {
path: Vec<Pos2>,
lengths: Vec<f64>,
vertices: Vec<Pos2>,
bezier: BezierBuffers,
}
#[derive(Clone, Debug, Default)]
struct BezierBuffers {
left: Vec<Pos2>,
right: Vec<Pos2>,
midpoints: Vec<Pos2>,
left_child: Vec<Pos2>,
}
impl BezierBuffers {
/// Fill the buffers with new elements until a
/// length of `len` is reached. Does nothing if `len`
/// is already smaller than the current buffer size.
fn extend_exact(&mut self, len: usize) {
if len <= self.left.len() {
return;
}
let additional = len - self.left.len();
self.left
.extend(iter::repeat(Pos2::zero()).take(additional));
self.right
.extend(iter::repeat(Pos2::zero()).take(additional));
self.midpoints
.extend(iter::repeat(Pos2::zero()).take(additional));
self.left_child
.extend(iter::repeat(Pos2::zero()).take(additional));
}
}
struct CircularArcProperties {
theta_start: f64,
theta_range: f64,
direction: f64,
radius: f32,
centre: Pos2,
}
pub(crate) struct Curve<'bufs> {
path: &'bufs [Pos2],
lengths: &'bufs [f64],
}
impl<'bufs> Curve<'bufs> {
pub(crate) fn new(
points: &[PathControlPoint],
expected_len: Option<f64>,
bufs: &'bufs mut CurveBuffers,
) -> Self {
Self::calculate_path(points, bufs);
Self::calculate_length(points, bufs, expected_len);
Self {
path: &bufs.path,
lengths: &bufs.lengths,
}
}
pub(crate) fn position_at(&self, progress: f64) -> Pos2 {
let d = self.progress_to_dist(progress);
let i = self.idx_of_dist(d);
self.interpolate_vertices(i, d)
}
fn progress_to_dist(&self, progress: f64) -> f64 {
progress.clamp(0.0, 1.0) * self.dist()
}
pub(crate) fn dist(&self) -> f64 {
self.lengths.last().copied().unwrap_or(0.0)
}
fn idx_of_dist(&self, d: f64) -> usize {
self.lengths
.binary_search_by(|len| len.partial_cmp(&d).unwrap_or(Ordering::Equal))
.map_or_else(identity, identity)
}
fn interpolate_vertices(&self, i: usize, d: f64) -> Pos2 {
if self.path.is_empty() {
return Pos2::zero();
}
let p1 = if i == 0 {
return self.path[0];
} else if let Some(p) = self.path.get(i) {
*p
} else {
return self.path[self.path.len() - 1];
};
let p0 = self.path[i - 1];
let d0 = self.lengths[i - 1];
let d1 = self.lengths[i];
// * Avoid division by an almost-zero number in case
// * two points are extremely close to each other
if (d0 - d1).abs() <= f64::EPSILON {
return p0;
}
let w = (d - d0) / (d1 - d0);
p0 + (p1 - p0) * w as f32
}
fn calculate_path(points: &[PathControlPoint], bufs: &mut CurveBuffers) {
bufs.path.clear();
if points.is_empty() {
return;
}
let CurveBuffers {
vertices,
bezier,
path,
..
} = bufs;
vertices.clear();
vertices.extend(points.iter().map(|p| p.pos));
let mut start = 0;
for i in 0..points.len() {
if points[i].kind.is_none() && i < points.len() - 1 {
continue;
}
// * The current vertex ends the segment
let segment_vertices = &vertices[start..=i];
let segment_kind = points[start].kind.unwrap_or(PathType::Linear);
Self::calculate_subpath(path, segment_vertices, segment_kind, bezier);
// * Start the new segment at the current vertex
start = i;
}
path.dedup();
}
fn calculate_length(
points: &[PathControlPoint],
bufs: &mut CurveBuffers,
expected_len: Option<f64>,
) {
let CurveBuffers {
path,
lengths: cumulative_len,
..
} = bufs;
cumulative_len.clear();
let mut calculated_len = 0.0;
cumulative_len.reserve(path.len());
cumulative_len.push(0.0);
let length_iter = path.iter().zip(path.iter().skip(1)).map(|(&curr, &next)| {
calculated_len += f64::from((next - curr).length());
calculated_len
});
cumulative_len.extend(length_iter);
if let Some(expected_len) =
expected_len.filter(|&len| (calculated_len - len).abs() >= f64::EPSILON)
{
// * In osu-stable, if the last two control points of a slider are equal, extension is not performed
let condition_opt = points
.len()
.checked_sub(2)
.and_then(|i| points.get(i..))
.filter(|suffix| suffix[0].pos == suffix[1].pos && expected_len > calculated_len);
if condition_opt.is_some() {
cumulative_len.push(calculated_len);
return;
}
// Shortcut when it's just (0,0) since there's nothing to do anyway
if cumulative_len.len() == 1 {
return;
}
// * The last length is always incorrect
cumulative_len.pop();
let last_valid = cumulative_len
.iter()
.rev()
.position(|l| *l < expected_len)
.map_or(0, |idx| cumulative_len.len() - idx);
// * The path will be shortened further, in which case we should trim
// * any more unnecessary lengths and their associated path segments
if last_valid < cumulative_len.len() {
cumulative_len.truncate(last_valid);
path.truncate(last_valid + 1);
if cumulative_len.is_empty() {
// * The expected distance is negative or zero
// * Perhaps negative path lengths should be disallowed altogether
cumulative_len.push(0.0);
return;
}
}
let end_idx = cumulative_len.len();
let prev_idx = end_idx - 1;
// * The direction of the segment to shorten or lengthen
let dir = (path[end_idx] - path[prev_idx]).normalize();
path[end_idx] = path[prev_idx] + dir * (expected_len - cumulative_len[prev_idx]) as f32;
cumulative_len.push(expected_len);
}
}
fn calculate_subpath(
path: &mut Vec<Pos2>,
sub_points: &[Pos2],
kind: PathType,
bufs: &mut BezierBuffers,
) {
match kind {
PathType::Bezier => Self::approximate_bezier(path, sub_points, bufs),
PathType::Catmull => Self::approximate_catmull(path, sub_points),
PathType::Linear => Self::approximate_linear(path, sub_points),
PathType::PerfectCurve => {
if let [a, b, c] = sub_points {
if Self::approximate_circular_arc(path, *a, *b, *c) {
return;
}
}
Self::approximate_bezier(path, sub_points, bufs);
}
}
}
fn approximate_bezier(path: &mut Vec<Pos2>, points: &[Pos2], bufs: &mut BezierBuffers) {
bufs.extend_exact(points.len());
Self::approximate_bspline(path, points, bufs);
}
fn approximate_catmull(path: &mut Vec<Pos2>, points: &[Pos2]) {
if points.len() == 1 {
return;
}
path.reserve_exact((points.len() - 1) * CATMULL_DETAIL * 2);
// Handle first iteration distinctly because of v1
let v1 = points[0];
let v2 = points[0];
let v3 = points.get(1).copied().unwrap_or(v2);
let v4 = points.get(2).copied().unwrap_or_else(|| v3 * 2.0 - v2);
Self::catmull_subpath(path, v1, v2, v3, v4);
// Remaining iterations
for (i, (&v1, &v2)) in (2..points.len()).zip(points.iter().zip(points.iter().skip(1))) {
let v3 = points.get(i).copied().unwrap_or_else(|| v2 * 2.0 - v1);
let v4 = points.get(i + 1).copied().unwrap_or_else(|| v3 * 2.0 - v2);
Self::catmull_subpath(path, v1, v2, v3, v4);
}
}
fn approximate_linear(path: &mut Vec<Pos2>, points: &[Pos2]) {
path.extend(points);
}
fn approximate_circular_arc(path: &mut Vec<Pos2>, a: Pos2, b: Pos2, c: Pos2) -> bool {
let Some(pr) = Self::circular_arc_properties(a, b, c) else {
return false;
};
// * We select the amount of points for the approximation by requiring the discrete curvature
// * to be smaller than the provided tolerance. The exact angle required to meet the tolerance
// * is: 2 * Math.Acos(1 - TOLERANCE / r)
// * The special case is required for extremely short sliders where the radius is smaller than
// * the tolerance. This is a pathological rather than a realistic case.
let amount_points = if 2.0 * pr.radius <= CIRCULAR_ARC_TOLERANCE {
2
} else {
let divisor = 2.0 * (1.0 - CIRCULAR_ARC_TOLERANCE / pr.radius).acos();
// In C# it holds `(int)Infinity == -2147483648` whereas in Rust it's 2147483647
// so we need to workaround this edge case, see map id 2568364
if divisor.abs() <= f32::EPSILON {
2
} else {
((pr.theta_range / f64::from(divisor)).ceil() as usize).max(2)
}
};
path.reserve_exact(amount_points);
let divisor = (amount_points - 1) as f64;
let directed_range = pr.direction * pr.theta_range;
let subpath = (0..amount_points).map(|i| {
let fract = i as f64 / divisor;
let theta = pr.theta_start + fract * directed_range;
let (sin, cos) = theta.sin_cos();
let origin = Pos2 {
x: cos as f32,
y: sin as f32,
};
pr.centre + origin * pr.radius
});
path.extend(subpath);
true
}
fn approximate_bspline(path: &mut Vec<Pos2>, points: &[Pos2], bufs: &mut BezierBuffers) {
let p = points.len();
let mut to_flatten = Vec::new();
let mut free_bufs = Vec::new();
// In osu!lazer's code, `p` is always 0 so the first big `if` can be omitted
to_flatten.push(Cow::Borrowed(points));
// * "toFlatten" contains all the curves which are not yet approximated well enough.
// * We use a stack to emulate recursion without the risk of running into a stack overflow.
// * (More specifically, we iteratively and adaptively refine our curve with a
// * <a href="https://en.wikipedia.org/wiki/Depth-first_search">Depth-first search</a>
// * over the tree resulting from the subdivisions we make.)
let BezierBuffers {
left,
right,
midpoints,
left_child,
} = bufs;
while let Some(mut parent) = to_flatten.pop() {
if Self::bezier_is_flat_enough(&parent) {
// * If the control points we currently operate on are sufficiently "flat", we use
// * an extension to De Casteljau's algorithm to obtain a piecewise-linear approximation
// * of the bezier curve represented by our control points, consisting of the same amount
// * of points as there are control points.
Self::bezier_approximate(&parent, path, left, right, midpoints);
free_bufs.push(parent);
continue;
}
// * If we do not yet have a sufficiently "flat" (in other words, detailed) approximation we keep
// * subdividing the curve we are currently operating on.
let mut right_child = free_bufs
.pop()
.unwrap_or_else(|| Cow::Owned(vec![Pos2::zero(); p]));
Self::bezier_subdivide(&parent, left_child, right_child.to_mut(), midpoints);
// * We re-use the buffer of the parent for one of the children, so that we save one allocation per iteration.
parent.to_mut().copy_from_slice(&left_child[..p]);
to_flatten.push(right_child);
to_flatten.push(parent);
}
path.push(points[p - 1]);
}
fn bezier_is_flat_enough(points: &[Pos2]) -> bool {
let limit = BEZIER_TOLERANCE * BEZIER_TOLERANCE * 4.0;
!points
.iter()
.zip(points.iter().skip(1))
.zip(points.iter().skip(2))
.any(|((&prev, &curr), &next)| (prev - curr * 2.0 + next).length_squared() > limit)
}
fn bezier_subdivide(points: &[Pos2], l: &mut [Pos2], r: &mut [Pos2], midpoints: &mut [Pos2]) {
let count = points.len();
midpoints[..count].copy_from_slice(&points[..count]);
for i in (1..count).rev() {
l[count - i - 1] = midpoints[0];
r[i] = midpoints[i];
for j in 0..i {
midpoints[j] = (midpoints[j] + midpoints[j + 1]) / 2.0;
}
}
l[count - 1] = midpoints[0];
r[0] = midpoints[0];
}
// * https://en.wikipedia.org/wiki/De_Casteljau%27s_algorithm
fn bezier_approximate(
points: &[Pos2],
path: &mut Vec<Pos2>,
l: &mut [Pos2],
r: &mut [Pos2],
midpoints: &mut [Pos2],
) {
let count = points.len();
Self::bezier_subdivide(points, l, r, midpoints);
path.push(points[0]);
let l = &l[..count];
let r = &r[1..count];
let subpath = l
.iter()
.chain(r)
.skip(1)
.zip(l.iter().chain(r).skip(2))
.zip(l.iter().chain(r).skip(3))
.step_by(2)
.map(|((&prev, &curr), &next)| (prev + curr * 2.0 + next) * 0.25);
path.extend(subpath);
}
fn catmull_subpath(path: &mut Vec<Pos2>, v1: Pos2, v2: Pos2, v3: Pos2, v4: Pos2) {
let x1 = 2.0 * v2.x;
let x2 = -v1.x + v3.x;
let x3 = 2.0 * v1.x - 5.0 * v2.x + 4.0 * v3.x - v4.x;
let x4 = -v1.x + 3.0 * (v2.x - v3.x) + v4.x;
let y1 = 2.0 * v2.y;
let y2 = -v1.y + v3.y;
let y3 = 2.0 * v1.y - 5.0 * v2.y + 4.0 * v3.y - v4.y;
let y4 = -v1.y + 3.0 * (v2.y - v3.y) + v4.y;
let catmull_detail = CATMULL_DETAIL as f32;
let subpath = (0..CATMULL_DETAIL).flat_map(|c| {
let c = c as f32;
let t1 = c / catmull_detail;
let t2 = t1 * t1;
let t3 = t2 * t1;
let pos1 = Pos2 {
x: 0.5 * (x1 + x2 * t1 + x3 * t2 + x4 * t3),
y: 0.5 * (y1 + y2 * t1 + y3 * t2 + y4 * t3),
};
let t1 = (c + 1.0) / catmull_detail;
let t2 = t1 * t1;
let t3 = t2 * t1;
let pos2 = Pos2 {
x: 0.5 * (x1 + x2 * t1 + x3 * t2 + x4 * t3),
y: 0.5 * (y1 + y2 * t1 + y3 * t2 + y4 * t3),
};
iter::once(pos1).chain(iter::once(pos2))
});
path.extend(subpath);
}
fn circular_arc_properties(a: Pos2, b: Pos2, c: Pos2) -> Option<CircularArcProperties> {
// * If we have a degenerate triangle where a side-length is almost zero,
// * then give up and fallback to a more numerically stable method.
if ((b.y - a.y) * (c.x - a.x) - (b.x - a.x) * (c.y - a.y)).abs() <= f32::EPSILON {
return None;
}
// * See: https://en.wikipedia.org/wiki/Circumscribed_circle#Cartesian_coordinates_2
let d = 2.0 * (a.x * (b - c).y + b.x * (c - a).y + c.x * (a - b).y);
let a_sq = a.length_squared();
let b_sq = b.length_squared();
let c_sq = c.length_squared();
let centre = Pos2 {
x: (a_sq * (b - c).y + b_sq * (c - a).y + c_sq * (a - b).y) / d,
y: (a_sq * (c - b).x + b_sq * (a - c).x + c_sq * (b - a).x) / d,
};
let d_a = a - centre;
let d_c = c - centre;
let radius = d_a.length();
let theta_start = f64::from(d_a.y).atan2(f64::from(d_a.x));
let mut theta_end = f64::from(d_c.y).atan2(f64::from(d_c.x));
while theta_end < theta_start {
theta_end += 2.0 * PI;
}
let mut direction = 1.0;
let mut theta_range = theta_end - theta_start;
// * Decide in which direction to draw the circle,
// * depending on which side of AC B lies.
let mut ortho_a_to_c = c - a;
ortho_a_to_c = Pos2 {
x: ortho_a_to_c.y,
y: -ortho_a_to_c.x,
};
if ortho_a_to_c.dot(b - a) < 0.0 {
direction = -direction;
theta_range = 2.0 * PI - theta_range;
}
Some(CircularArcProperties {
theta_start,
theta_range,
direction,
radius,
centre,
})
}
}
-363
View File
@@ -1,363 +0,0 @@
#![cfg(feature = "gradual")]
use crate::catch::{CatchOwnedGradualDifficulty, CatchOwnedGradualPerformance};
use crate::mania::{ManiaOwnedGradualDifficulty, ManiaOwnedGradualPerformance};
use crate::osu::OsuOwnedGradualPerformance;
use crate::taiko::TaikoOwnedGradualPerformance;
use crate::{
catch::{CatchGradualDifficulty, CatchGradualPerformance},
mania::{ManiaGradualDifficulty, ManiaGradualPerformance},
osu::{OsuGradualDifficulty, OsuGradualPerformance},
taiko::{TaikoGradualDifficulty, TaikoGradualPerformance},
Beatmap, DifficultyAttributes, GameMode, PerformanceAttributes, ScoreState,
};
/// Gradually calculate the difficulty attributes on maps of any mode.
///
/// Note that this struct implements [`Iterator`].
/// On every call of [`Iterator::next`], the map's next hit object will
/// be processed and the [`DifficultyAttributes`] will be updated and returned.
///
/// If you want to calculate performance attributes, use [`GradualPerformance`] instead.
///
/// # Example
///
/// ```no_run
/// use rosu_pp::{Beatmap, GradualDifficulty};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut iter = GradualDifficulty::new(&map, mods);
///
/// let attrs1 = iter.next(); // the difficulty of the map after the first hit object
/// let attrs2 = iter.next(); // after the second hit object
///
/// // Remaining hit objects
/// for difficulty in iter {
/// // ...
/// }
/// ```
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Debug)]
pub enum GradualDifficulty<'map> {
/// Gradual osu!standard difficulty attributes.
Osu(OsuGradualDifficulty),
/// Gradual osu!taiko difficulty attributes.
Taiko(TaikoGradualDifficulty),
/// Gradual osu!catch difficulty attributes.
Catch(CatchGradualDifficulty<'map>),
/// Gradual osu!mania difficulty attributes.
Mania(ManiaGradualDifficulty<'map>),
}
impl<'map> GradualDifficulty<'map> {
// FIXME: converted catch maps will always count as osu!std since their mode is not modified
/// Create a new gradual difficulty calculator for maps of any mode.
#[inline]
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
match map.mode {
GameMode::Osu => Self::Osu(OsuGradualDifficulty::new(map, mods)),
GameMode::Taiko => Self::Taiko(TaikoGradualDifficulty::new(map, mods)),
GameMode::Catch => Self::Catch(CatchGradualDifficulty::new(map, mods)),
GameMode::Mania => Self::Mania(ManiaGradualDifficulty::new(map, mods)),
}
}
}
impl Iterator for GradualDifficulty<'_> {
type Item = DifficultyAttributes;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
match self {
Self::Osu(o) => o.next().map(DifficultyAttributes::Osu),
Self::Taiko(t) => t.next().map(DifficultyAttributes::Taiko),
Self::Catch(f) => f.next().map(DifficultyAttributes::Catch),
Self::Mania(m) => m.next().map(DifficultyAttributes::Mania),
}
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
match self {
Self::Osu(o) => o.size_hint(),
Self::Taiko(t) => t.size_hint(),
Self::Catch(f) => f.size_hint(),
Self::Mania(m) => m.size_hint(),
}
}
#[inline]
fn nth(&mut self, n: usize) -> Option<Self::Item> {
match self {
Self::Osu(o) => o.nth(n).map(DifficultyAttributes::Osu),
Self::Taiko(t) => t.nth(n).map(DifficultyAttributes::Taiko),
Self::Catch(c) => c.nth(n).map(DifficultyAttributes::Catch),
Self::Mania(m) => m.nth(n).map(DifficultyAttributes::Mania),
}
}
}
/// Gradually calculate the difficulty attributes on maps of any mode.
///
/// Check [`GradualDifficulty`] for more information. This type does the same
/// but depending on the mode it might clone [`Beatmap`] to avoid being bound to a lifetime.
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum OwnedGradualDifficulty {
/// Gradual osu!standard difficulty attributes.
Osu(OsuGradualDifficulty),
/// Gradual osu!taiko difficulty attributes.
Taiko(TaikoGradualDifficulty),
/// Gradual osu!catch difficulty attributes.
Catch(CatchOwnedGradualDifficulty),
/// Gradual osu!mania difficulty attributes.
Mania(ManiaOwnedGradualDifficulty),
}
impl OwnedGradualDifficulty {
// FIXME: converted catch maps will always count as osu!std since their mode is not modified
/// Create a new gradual difficulty calculator for maps of any mode.
#[inline]
pub fn new(map: &Beatmap, mods: u32) -> Self {
match map.mode {
GameMode::Osu => Self::Osu(OsuGradualDifficulty::new(map, mods)),
GameMode::Taiko => Self::Taiko(TaikoGradualDifficulty::new(map, mods)),
GameMode::Catch => Self::Catch(CatchOwnedGradualDifficulty::new(map.to_owned(), mods)),
GameMode::Mania => Self::Mania(ManiaOwnedGradualDifficulty::new(map.to_owned(), mods)),
}
}
}
impl Iterator for OwnedGradualDifficulty {
type Item = DifficultyAttributes;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
match self {
Self::Osu(o) => o.next().map(DifficultyAttributes::Osu),
Self::Taiko(t) => t.next().map(DifficultyAttributes::Taiko),
Self::Catch(f) => f.next().map(DifficultyAttributes::Catch),
Self::Mania(m) => m.next().map(DifficultyAttributes::Mania),
}
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
match self {
Self::Osu(o) => o.size_hint(),
Self::Taiko(t) => t.size_hint(),
Self::Catch(f) => f.size_hint(),
Self::Mania(m) => m.size_hint(),
}
}
#[inline]
fn nth(&mut self, n: usize) -> Option<Self::Item> {
match self {
Self::Osu(o) => o.nth(n).map(DifficultyAttributes::Osu),
Self::Taiko(t) => t.nth(n).map(DifficultyAttributes::Taiko),
Self::Catch(c) => c.nth(n).map(DifficultyAttributes::Catch),
Self::Mania(m) => m.nth(n).map(DifficultyAttributes::Mania),
}
}
}
/// Gradually calculate the performance attributes on maps of any mode.
///
/// After each hit object you can call [`next`](`GradualPerformance::next`)
/// and it will return the resulting current [`PerformanceAttributes`].
/// To process multiple objects at once, use [`nth`](`GradualPerformance::nth`) instead.
///
/// Both methods require a [`ScoreState`] that contains the current hitresults
/// as well as the maximum combo so far or just the current score for osu!mania.
/// Since the map could have any mode, all fields of `ScoreState` could be of use
/// and should be updated properly.
///
/// Alternatively, you can match on the map's mode yourself and use the gradual
/// performance attribute struct for the corresponding mode, i.e. [`OsuGradualPerformance`],
/// [`TaikoGradualPerformance`], [`CatchGradualPerformance`], or [`ManiaGradualPerformance`].
///
/// If you only want to calculate difficulty attributes use [`GradualDifficulty`] instead.
///
/// # Example
///
/// ```no_run
/// use rosu_pp::{Beatmap, GradualPerformance, ScoreState};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut gradual_perf = GradualPerformance::new(&map, mods);
/// let mut state = ScoreState::new(); // empty state, everything is on 0.
///
/// // The first 10 hitresults are 300s
/// for _ in 0..10 {
/// state.n300 += 1;
/// state.max_combo += 1;
///
/// let performance = gradual_perf.next(state.clone()).unwrap();
/// println!("PP: {}", performance.pp());
/// }
///
/// // Then comes a miss.
/// // Note that state's max combo won't be incremented for
/// // the next few objects because the combo is reset.
/// state.n_misses += 1;
///
/// let performance = gradual_perf.next(state.clone()).unwrap();
/// println!("PP: {}", performance.pp());
///
/// // The next 10 objects will be a mixture of 300s, 100s, and 50s.
/// // Notice how all 10 objects will be processed in one go.
/// state.n300 += 2;
/// state.n100 += 7;
/// state.n50 += 1;
///
/// // The `nth` method takes a zero-based value.
/// let performance = gradual_perf.nth(state.clone(), 9).unwrap();
/// println!("PP: {}", performance.pp());
///
/// // Now comes another 300. Note that the max combo gets incremented again.
/// state.n300 += 1;
/// state.max_combo += 1;
///
/// let performance = gradual_perf.next(state.clone()).unwrap();
/// println!("PP: {}", performance.pp());
///
/// // Skip to the end
/// # /*
/// state.max_combo = ...
/// state.n300 = ...
/// ...
/// # */
/// let final_performance = gradual_perf.last(state.clone()).unwrap();
/// println!("PP: {}", performance.pp());
///
/// // Once the final performance was calculated,
/// // attempting to process further objects will return `None`.
/// assert!(gradual_perf.next(state).is_none());
/// ```
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum GradualPerformance<'map> {
/// Gradual osu!standard performance calculator.
Osu(OsuGradualPerformance<'map>),
/// Gradual osu!taiko performance calculator.
Taiko(TaikoGradualPerformance<'map>),
/// Gradual osu!catch performance calculator.
Catch(CatchGradualPerformance<'map>),
/// Gradual osu!mania performance calculator.
Mania(ManiaGradualPerformance<'map>),
}
impl<'map> GradualPerformance<'map> {
// FIXME: converted catch maps will always count as osu!std since their mode is not modified
/// Create a new gradual performance calculator for maps of any mode.
#[inline]
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
match map.mode {
GameMode::Osu => Self::Osu(OsuGradualPerformance::new(map, mods)),
GameMode::Taiko => Self::Taiko(TaikoGradualPerformance::new(map, mods)),
GameMode::Catch => Self::Catch(CatchGradualPerformance::new(map, mods)),
GameMode::Mania => Self::Mania(ManiaGradualPerformance::new(map, mods)),
}
}
/// Process the next hit object and calculate the
/// performance attributes for the resulting score.
#[inline]
pub fn next(&mut self, state: ScoreState) -> Option<PerformanceAttributes> {
self.nth(state, 0)
}
/// Process all remaining hit objects and calculate the final performance attributes.
#[inline]
pub fn last(&mut self, state: ScoreState) -> Option<PerformanceAttributes> {
self.nth(state, usize::MAX)
}
/// Process everything up the the next `n`th hit object and calculate the performance
/// attributes for the resulting score state.
///
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
/// and so on.
#[inline]
pub fn nth(&mut self, state: ScoreState, n: usize) -> Option<PerformanceAttributes> {
match self {
Self::Osu(o) => o.nth(state.into(), n).map(PerformanceAttributes::Osu),
Self::Taiko(t) => t.nth(state.into(), n).map(PerformanceAttributes::Taiko),
Self::Catch(f) => f.nth(state.into(), n).map(PerformanceAttributes::Catch),
Self::Mania(m) => m.nth(state.into(), n).map(PerformanceAttributes::Mania),
}
}
}
/// Gradually calculate the performance attributes on maps of any mode.
///
/// Check [`GradualPerformance`] for more information. This type does the same
/// but takes ownership of [`Beatmap`] to avoid being bound to a lifetime.
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum OwnedGradualPerformance {
/// Gradual osu!standard performance calculator.
Osu(OsuOwnedGradualPerformance),
/// Gradual osu!taiko performance calculator.
Taiko(TaikoOwnedGradualPerformance),
/// Gradual osu!catch performance calculator.
Catch(CatchOwnedGradualPerformance),
/// Gradual osu!mania performance calculator.
Mania(ManiaOwnedGradualPerformance),
}
impl OwnedGradualPerformance {
// FIXME: converted catch maps will always count as osu!std since their mode is not modified
/// Create a new gradual performance calculator for maps of any mode.
#[inline]
pub fn new(map: Beatmap, mods: u32) -> Self {
match map.mode {
GameMode::Osu => Self::Osu(OsuOwnedGradualPerformance::new(map, mods)),
GameMode::Taiko => Self::Taiko(TaikoOwnedGradualPerformance::new(map, mods)),
GameMode::Catch => Self::Catch(CatchOwnedGradualPerformance::new(map, mods)),
GameMode::Mania => Self::Mania(ManiaOwnedGradualPerformance::new(map, mods)),
}
}
/// Process the next hit object and calculate the
/// performance attributes for the resulting score.
#[inline]
pub fn next(&mut self, state: ScoreState) -> Option<PerformanceAttributes> {
self.nth(state, 0)
}
/// Process all remaining hit objects and calculate the final performance attributes.
#[inline]
pub fn last(&mut self, state: ScoreState) -> Option<PerformanceAttributes> {
self.nth(state, usize::MAX)
}
/// Process everything up the the next `n`th hit object and calculate the performance
/// attributes for the resulting score state.
///
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
/// and so on.
#[inline]
pub fn nth(&mut self, state: ScoreState, n: usize) -> Option<PerformanceAttributes> {
match self {
Self::Osu(o) => o.nth(state.into(), n).map(PerformanceAttributes::Osu),
Self::Taiko(t) => t.nth(state.into(), n).map(PerformanceAttributes::Taiko),
Self::Catch(f) => f.nth(state.into(), n).map(PerformanceAttributes::Catch),
Self::Mania(m) => m.nth(state.into(), n).map(PerformanceAttributes::Mania),
}
}
}
+32 -448
View File
@@ -1,459 +1,43 @@
#![cfg_attr(docsrs, feature(doc_cfg))]
#![warn(
clippy::all,
clippy::pedantic,
clippy::missing_const_for_fn,
nonstandard_style,
rust_2018_idioms,
unused,
warnings,
missing_debug_implementations,
missing_docs,
rustdoc::broken_intra_doc_links
)]
#![deny(rustdoc::broken_intra_doc_links)]
#![warn(clippy::missing_const_for_fn, clippy::pedantic)]
#![allow(
clippy::must_use_candidate,
clippy::inline_always,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap,
clippy::explicit_iter_loop,
clippy::items_after_statements,
clippy::missing_errors_doc,
clippy::module_name_repetitions
clippy::module_name_repetitions,
clippy::must_use_candidate,
clippy::struct_excessive_bools,
clippy::match_same_arms,
clippy::cast_possible_truncation,
clippy::cast_precision_loss,
clippy::cast_sign_loss,
clippy::explicit_iter_loop,
clippy::similar_names,
clippy::cast_possible_wrap
)]
//! A standalone crate to calculate star ratings and performance points for all [osu!](https://osu.ppy.sh/home) gamemodes.
//!
//! Async is supported through features, see below.
//!
//! ## Usage
//!
//! ```no_run
//! use rosu_pp::{Beatmap, BeatmapExt};
//!
//! # /*
//! // Parse the map yourself
//! let map = match Beatmap::from_path("/path/to/file.osu") {
//! Ok(map) => map,
//! Err(why) => panic!("Error while parsing map: {}", why),
//! };
//! # */ let map = Beatmap::default();
//!
//! // If `BeatmapExt` is included, you can make use of some methods
//! // on `Beatmap` to make your life simpler like `BeatmapExt::pp`.
//! let result = map.pp()
//! .mods(24) // HDHR
//! .combo(1234)
//! .accuracy(99.2)
//! .n_misses(2)
//! .calculate();
//!
//! println!("PP: {}", result.pp());
//!
//! // If you want to reuse the current map-mod combination, make use of the previous result!
//! // If attributes are given, then stars & co don't have to be recalculated.
//! let next_result = map.pp()
//! .mods(24) // HDHR
//! .attributes(result) // recycle
//! .combo(543)
//! .n_misses(5)
//! .n50(3)
//! .accuracy(96.5)
//! .calculate();
//!
//! println!("Next PP: {}", next_result.pp());
//!
//! let stars = map.stars()
//! .mods(16) // HR
//! .calculate()
//! .stars();
//!
//! let max_pp = map.max_pp(16).pp();
//!
//! println!("Stars: {} | Max PP: {}", stars, max_pp);
//! ```
//!
//! ## With async
//! If either the `async_tokio` or `async_std` feature is enabled, beatmap parsing will be async.
//!
//! ```no_run
//! use rosu_pp::{Beatmap, BeatmapExt};
//!
//! # /*
//! // Parse the map asynchronously
//! let map = match Beatmap::from_path("/path/to/file.osu").await {
//! Ok(map) => map,
//! Err(why) => panic!("Error while parsing map: {}", why),
//! };
//! # */ let map = Beatmap::default();
//!
//! // The rest stays the same
//! let result = map.pp()
//! .mods(24) // HDHR
//! .combo(1234)
//! .n_misses(2)
//! .accuracy(99.2)
//! .calculate();
//!
//! println!("PP: {}", result.pp());
//! ```
//!
#![cfg_attr(
feature = "gradual",
doc = r#"
## Gradual calculation
Sometimes you might want to calculate the difficulty of a map or performance of a score after each hit object.
This could be done by using `passed_objects` as the amount of objects that were passed so far.
However, this requires to recalculate the beginning again and again, we can be more efficient than that.
Instead, you should enable the `gradual` feature and use [`GradualDifficulty`] and [`GradualPerformance`]:
```no_run
use rosu_pp::{
Beatmap, BeatmapExt, GradualDifficulty, GradualPerformance, ScoreState,
taiko::TaikoScoreState,
};
# /*
let map = match Beatmap::from_path("/path/to/file.osu") {
Ok(map) => map,
Err(why) => panic!("Error while parsing map: {}", why),
};
# */
# let map = Beatmap::default();
let mods = 8 + 64; // HDDT
// If you're only interested in the star rating or other difficulty values,
// use `GradualDifficulty`.
let gradual_difficulty = GradualDifficulty::new(&map, mods);
// Since `GradualDifficulty` implements `Iterator`, you can use
// any iterate function on it, use it in loops, collect them into a `Vec`, ...
for (i, difficulty) in gradual_difficulty.enumerate() {
println!("Stars after object {}: {}", i, difficulty.stars());
}
// Gradually calculating performance values does the same as calculating
// difficulty attributes but it goes the extra step and also evaluates
// the state of a score for these difficulty attributes.
let mut gradual_performance = GradualPerformance::new(&map, mods);
// The default score state is kinda chunky because it considers all modes.
let state = ScoreState {
max_combo: 1,
n_geki: 0, // only relevant for mania
n_katu: 0, // only relevant for mania and ctb
n300: 1,
n100: 0,
n50: 0,
n_misses: 0,
};
// Process the score state after the first object
let curr_performance = match gradual_performance.next(state) {
Some(perf) => perf,
None => panic!("the map has no hit objects"),
};
println!("PP after the first object: {}", curr_performance.pp());
// If you're only interested in maps of a specific mode, consider
// using the mode's gradual calculator instead of the general one.
// Let's assume it's a taiko map.
// Instead of starting off with `GradualPerformance` one could have
// used `TaikoGradualPerformance`.
let mut gradual_performance = match gradual_performance {
GradualPerformance::Taiko(gradual) => gradual,
_ => panic!("the map was not taiko but {:?}", map.mode),
};
// A little simpler than the general score state.
let state = TaikoScoreState {
max_combo: 11,
n300: 9,
n100: 1,
n_misses: 1,
};
// Process the next 10 objects in one go (`nth` takes a zero-based value).
let curr_performance = match gradual_performance.nth(state, 9) {
Some(perf) => perf,
None => panic!("the last `next` already processed the last object"),
};
println!("PP after the first 11 objects: {}", curr_performance.pp());
```
"#
)]
//! ## Features
//!
//! | Flag | Description |
//! |---------------|-----|
//! | `default` | Beatmap parsing will be non-async |
//! | `async_tokio` | Beatmap parsing will be async through [tokio](https://github.com/tokio-rs/tokio) |
//! | `async_std` | Beatmap parsing will be async through [async-std](https://github.com/async-rs/async-std) |
//! | `gradual` | Enable gradual difficulty and performance calculation |
//!
/// Everything about osu!catch.
pub mod catch;
/// Everything about osu!mania.
pub mod mania;
/// Everything about osu!standard.
pub mod osu;
/// Everything about osu!taiko.
pub mod taiko;
/// Beatmap parsing
pub mod parse;
/// Beatmap and contained types
pub mod beatmap;
pub use beatmap::{Beatmap, BeatmapExt, GameMode};
#[cfg(feature = "gradual")]
mod gradual;
#[cfg(feature = "gradual")]
pub use gradual::{
GradualDifficulty, GradualPerformance, OwnedGradualDifficulty, OwnedGradualPerformance,
#[doc(inline)]
pub use self::{
any::{Difficulty, ModeDifficulty, Performance},
model::beatmap::{Beatmap, Converted},
};
mod pp;
pub use pp::{AnyPP, AttributeProvider, HitResultPriority};
/// Types for calculations of any mode.
pub mod any;
mod stars;
pub use stars::AnyStars;
/// Types for osu!standard calculations.
pub mod osu;
mod score_state;
pub use score_state::*;
/// Types for osu!taiko calculations.
pub mod taiko;
/// Types for osu!catch calculations.
pub mod catch;
/// Types for osu!mania calculations.
pub mod mania;
/// Types used in and around this crate.
pub mod model;
mod curve;
mod mods;
mod util;
pub use catch::{CatchPP, CatchStars};
pub use mania::{ManiaPP, ManiaStars};
pub use osu::{OsuPP, OsuStars};
pub use taiko::{TaikoPP, TaikoStars};
pub use mods::Mods;
pub use parse::{ParseError, ParseResult};
/// The result of calculating the strains on a map.
/// Suitable to plot the difficulty of a map over time.
#[derive(Clone, Debug)]
pub enum Strains {
/// osu!standard strain values.
Osu(osu::OsuStrains),
/// osu!taiko strain values.
Taiko(taiko::TaikoStrains),
/// osu!catch strain values.
Catch(catch::CatchStrains),
/// osu!mania strain values.
Mania(mania::ManiaStrains),
}
impl Strains {
/// Time in ms inbetween two strains.
#[inline]
pub const fn section_len(&self) -> f64 {
match self {
Strains::Osu(strains) => strains.section_len,
Strains::Taiko(strains) => strains.section_len,
Strains::Catch(strains) => strains.section_len,
Strains::Mania(strains) => strains.section_len,
}
}
/// Returns the number of strain peaks per skill.
#[inline]
#[allow(clippy::len_without_is_empty)]
pub fn len(&self) -> usize {
match self {
Strains::Osu(strains) => strains.len(),
Strains::Taiko(strains) => strains.len(),
Strains::Catch(strains) => strains.len(),
Strains::Mania(strains) => strains.len(),
}
}
}
/// The result of a difficulty calculation based on the mode.
#[derive(Clone, Debug)]
pub enum DifficultyAttributes {
/// osu!standard difficulty calculation result.
Osu(osu::OsuDifficultyAttributes),
/// osu!taiko difficulty calculation result.
Taiko(taiko::TaikoDifficultyAttributes),
/// osu!catch difficulty calculation result.
Catch(catch::CatchDifficultyAttributes),
/// osu!mania difficulty calculation result.
Mania(mania::ManiaDifficultyAttributes),
}
impl DifficultyAttributes {
/// The star value.
#[inline]
pub const fn stars(&self) -> f64 {
match self {
Self::Osu(attrs) => attrs.stars,
Self::Taiko(attrs) => attrs.stars,
Self::Catch(attrs) => attrs.stars,
Self::Mania(attrs) => attrs.stars,
}
}
/// The maximum combo of the map.
#[inline]
pub const fn max_combo(&self) -> usize {
match self {
Self::Osu(attrs) => attrs.max_combo,
Self::Taiko(attrs) => attrs.max_combo,
Self::Catch(attrs) => attrs.max_combo(),
Self::Mania(attrs) => attrs.max_combo,
}
}
/// Returns a builder for performance calculation.
#[inline]
pub fn pp(self) -> AnyPP<'static> {
AnyPP::from(self)
}
}
impl From<osu::OsuDifficultyAttributes> for DifficultyAttributes {
#[inline]
fn from(attributes: osu::OsuDifficultyAttributes) -> Self {
Self::Osu(attributes)
}
}
impl From<taiko::TaikoDifficultyAttributes> for DifficultyAttributes {
#[inline]
fn from(attributes: taiko::TaikoDifficultyAttributes) -> Self {
Self::Taiko(attributes)
}
}
impl From<catch::CatchDifficultyAttributes> for DifficultyAttributes {
#[inline]
fn from(attributes: catch::CatchDifficultyAttributes) -> Self {
Self::Catch(attributes)
}
}
impl From<mania::ManiaDifficultyAttributes> for DifficultyAttributes {
#[inline]
fn from(attributes: mania::ManiaDifficultyAttributes) -> Self {
Self::Mania(attributes)
}
}
/// The result of a performance calculation based on the mode.
#[derive(Clone, Debug)]
pub enum PerformanceAttributes {
/// osu!standard performance calculation result.
Osu(osu::OsuPerformanceAttributes),
/// osu!taiko performance calculation result.
Taiko(taiko::TaikoPerformanceAttributes),
/// osu!catch performance calculation result.
Catch(catch::CatchPerformanceAttributes),
/// osu!mania performance calculation result.
Mania(mania::ManiaPerformanceAttributes),
}
impl PerformanceAttributes {
/// The pp value.
#[inline]
pub const fn pp(&self) -> f64 {
match self {
Self::Osu(attrs) => attrs.pp,
Self::Taiko(attrs) => attrs.pp,
Self::Catch(attrs) => attrs.pp,
Self::Mania(attrs) => attrs.pp,
}
}
/// The star value.
#[inline]
pub const fn stars(&self) -> f64 {
match self {
Self::Osu(attrs) => attrs.stars(),
Self::Taiko(attrs) => attrs.stars(),
Self::Catch(attrs) => attrs.stars(),
Self::Mania(attrs) => attrs.stars(),
}
}
/// Difficulty attributes that were used for the performance calculation.
#[inline]
pub fn difficulty_attributes(&self) -> DifficultyAttributes {
match self {
Self::Osu(attrs) => DifficultyAttributes::Osu(attrs.difficulty.clone()),
Self::Taiko(attrs) => DifficultyAttributes::Taiko(attrs.difficulty.clone()),
Self::Catch(attrs) => DifficultyAttributes::Catch(attrs.difficulty.clone()),
Self::Mania(attrs) => DifficultyAttributes::Mania(attrs.difficulty.clone()),
}
}
#[inline]
/// The maximum combo of the map.
pub const fn max_combo(&self) -> usize {
match self {
Self::Osu(attrs) => attrs.difficulty.max_combo,
Self::Taiko(attrs) => attrs.difficulty.max_combo,
Self::Catch(attrs) => attrs.difficulty.max_combo(),
Self::Mania(attrs) => attrs.difficulty.max_combo,
}
}
}
impl From<PerformanceAttributes> for DifficultyAttributes {
#[inline]
fn from(attributes: PerformanceAttributes) -> Self {
match attributes {
PerformanceAttributes::Osu(attrs) => Self::Osu(attrs.difficulty),
PerformanceAttributes::Taiko(attrs) => Self::Taiko(attrs.difficulty),
PerformanceAttributes::Catch(attrs) => Self::Catch(attrs.difficulty),
PerformanceAttributes::Mania(attrs) => Self::Mania(attrs.difficulty),
}
}
}
impl From<osu::OsuPerformanceAttributes> for PerformanceAttributes {
#[inline]
fn from(attributes: osu::OsuPerformanceAttributes) -> Self {
Self::Osu(attributes)
}
}
impl From<taiko::TaikoPerformanceAttributes> for PerformanceAttributes {
#[inline]
fn from(attributes: taiko::TaikoPerformanceAttributes) -> Self {
Self::Taiko(attributes)
}
}
impl From<catch::CatchPerformanceAttributes> for PerformanceAttributes {
#[inline]
fn from(attributes: catch::CatchPerformanceAttributes) -> Self {
Self::Catch(attributes)
}
}
impl From<mania::ManiaPerformanceAttributes> for PerformanceAttributes {
#[inline]
fn from(attributes: mania::ManiaPerformanceAttributes) -> Self {
Self::Mania(attributes)
}
}
#[cfg(all(feature = "async_tokio", feature = "async_std"))]
compile_error!("Only one of the features `async_tokio` and `async_std` should be enabled");
// TODO: fix very low custom clock rates
+82
View File
@@ -0,0 +1,82 @@
use crate::mania::performance::ManiaPerformance;
/// The result of a difficulty calculation on an osu!mania map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ManiaDifficultyAttributes {
/// The final star rating.
pub stars: f64,
/// The perceived hit window for an n300 inclusive of rate-adjusting mods (DT/HT/etc).
pub hit_window: f64,
/// The amount of hitobjects in the map.
pub n_objects: u32,
/// The maximum achievable combo.
pub max_combo: u32,
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
///
/// [`Beatmap`]: crate::model::beatmap::Beatmap
pub is_convert: bool,
}
impl ManiaDifficultyAttributes {
/// Return the maximum combo.
pub const fn max_combo(&self) -> u32 {
self.max_combo
}
/// Return the amount of hitobjects.
pub const fn n_objects(&self) -> u32 {
self.n_objects
}
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
///
/// [`Beatmap`]: crate::model::beatmap::Beatmap
pub const fn is_convert(&self) -> bool {
self.is_convert
}
/// Returns a builder for performance calculation.
pub fn pp<'a>(self) -> ManiaPerformance<'a> {
self.into()
}
}
/// The result of a performance calculation on an osu!mania map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ManiaPerformanceAttributes {
/// The difficulty attributes that were used for the performance calculation.
pub difficulty: ManiaDifficultyAttributes,
/// The final performance points.
pub pp: f64,
/// The difficulty portion of the final pp.
pub pp_difficulty: f64,
}
impl ManiaPerformanceAttributes {
/// Return the star value.
pub const fn stars(&self) -> f64 {
self.difficulty.stars
}
/// Return the performance point value.
pub const fn pp(&self) -> f64 {
self.pp
}
/// Return the maximum combo of the map.
pub const fn max_combo(&self) -> u32 {
self.difficulty.max_combo
}
/// Return the amount of hitobjects.
pub const fn n_objects(&self) -> u32 {
self.difficulty.n_objects
}
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
///
/// [`Beatmap`]: crate::model::beatmap::Beatmap
pub const fn is_convert(&self) -> bool {
self.difficulty.is_convert
}
}
+257
View File
@@ -0,0 +1,257 @@
use std::borrow::Cow;
use rosu_map::{
section::{
general::GameMode,
hit_objects::{BorrowedCurve, CurveBuffers},
},
util::Pos,
};
use crate::{
model::{
beatmap::{Beatmap, Converted},
hit_object::{HitObjectKind, HoldNote, Spinner},
mode::ConvertStatus,
},
util::{
float_ext::FloatExt, legacy_sort::legacy_sort, limited_queue::LimitedQueue, random::Random,
},
};
use self::{
pattern::Pattern,
pattern_generator::{
distance_object::DistanceObjectPatternGenerator,
end_time_object::EndTimeObjectPatternGenerator, hit_object::HitObjectPatternGenerator,
},
pattern_type::PatternType,
};
use super::Mania;
mod pattern;
mod pattern_generator;
mod pattern_type;
/// A [`Beatmap`] for [`Mania`] calculations.
pub type ManiaBeatmap<'a> = Converted<'a, Mania>;
const MAX_NOTES_FOR_DENSITY: usize = 7;
pub fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus {
match map.mode {
GameMode::Osu => {
convert(map.to_mut());
ConvertStatus::Done
}
GameMode::Mania => ConvertStatus::Noop,
GameMode::Taiko | GameMode::Catch => ConvertStatus::Incompatible,
}
}
fn convert(map: &mut Beatmap) {
let seed = (map.hp + map.cs).round_even() as i32 * 20
+ (map.od * 41.2) as i32
+ map.ar.round_even() as i32;
let mut random = Random::new(seed);
map.cs = target_columns(map);
let mut prev_note_times = LimitedQueue::<f64, MAX_NOTES_FOR_DENSITY>::new();
let mut density = f64::from(i32::MAX);
let mut compute_density = |new_note_time: f64, d: &mut f64| {
prev_note_times.push(new_note_time);
if let ([first, ..], [.., last]) | ([], [first, .., last]) | ([first, .., last], []) =
prev_note_times.as_slices()
{
*d = (last - first) / prev_note_times.len() as f64;
}
};
let total_columns = map.cs as i32;
let mut last_values = PrevValues::default();
let mut curve_bufs = CurveBuffers::default();
// TODO: find proper default
let capacity = map.hit_objects.len() * 2;
let mut new_hit_objects = Vec::with_capacity(capacity);
for (obj, sound) in map.hit_objects.iter().zip(map.hit_sounds.iter().copied()) {
match obj.kind {
HitObjectKind::Circle => {
compute_density(obj.start_time, &mut density);
let mut gen = HitObjectPatternGenerator::new(
&mut random,
obj,
sound,
total_columns,
&last_values,
density,
map,
);
let new_pattern = gen.generate();
last_values.stair = gen.stair_type;
last_values.time = obj.start_time;
last_values.pos = obj.pos;
let new_hit_objects_iter = new_pattern.hit_objects.iter().cloned();
new_hit_objects.extend(new_hit_objects_iter);
last_values.pattern = new_pattern;
}
HitObjectKind::Slider(ref slider) => {
let curve = BorrowedCurve::new(
&slider.control_points,
slider.expected_dist,
&mut curve_bufs,
);
let mut gen = DistanceObjectPatternGenerator::new(
&mut random,
obj,
sound,
total_columns,
&last_values.pattern,
map,
slider.repeats,
&curve,
&slider.node_sounds,
);
let segment_duration = f64::from(gen.segment_duration);
for i in 0..=slider.repeats as i32 + 1 {
let time = obj.start_time + segment_duration * f64::from(i);
last_values.time = time;
last_values.pos = obj.pos;
compute_density(time, &mut density);
}
for new_pattern in gen.generate() {
new_hit_objects.extend_from_slice(&new_pattern.hit_objects);
last_values.pattern = new_pattern;
}
}
HitObjectKind::Spinner(Spinner { end_time })
| HitObjectKind::Hold(HoldNote { end_time }) => {
let mut gen = EndTimeObjectPatternGenerator::new(
&mut random,
obj,
end_time,
sound,
total_columns,
&last_values.pattern,
map,
);
last_values.time = end_time;
last_values.pos = Pos::new(256.0, 192.0);
compute_density(end_time, &mut density);
let new_pattern = gen.generate();
new_hit_objects.extend(new_pattern.hit_objects);
}
}
}
map.hit_sounds.clear();
map.hit_objects = new_hit_objects;
map.hit_objects
.sort_by(|a, b| a.start_time.total_cmp(&b.start_time));
legacy_sort(&mut map.hit_objects);
map.mode = GameMode::Mania;
}
pub struct PrevValues {
time: f64,
pos: Pos,
pattern: Pattern,
stair: PatternType,
}
impl Default for PrevValues {
fn default() -> Self {
Self {
time: 0.0,
pos: Pos::default(),
pattern: Pattern::default(),
stair: PatternType::STAIR,
}
}
}
fn target_columns(map: &Beatmap) -> f32 {
let rounded_cs = map.cs.round_even();
let rounded_od = map.od.round_even();
let slider_or_spinner_count = map
.hit_objects
.iter()
.filter(|h| matches!(h.kind, HitObjectKind::Slider(_) | HitObjectKind::Spinner(_)))
.count();
let len = map.hit_objects.len();
let percent_slider_or_spinner = f64::from(slider_or_spinner_count as f32 / len as f32);
if percent_slider_or_spinner < 0.2 {
7.0
} else if percent_slider_or_spinner < 0.3 || rounded_cs >= 5.0 {
f32::from(6 + u8::from(rounded_od > 5.0))
} else if percent_slider_or_spinner > 0.6 {
f32::from(4 + u8::from(rounded_od > 4.0))
} else {
(rounded_od + 1.0).clamp(4.0, 7.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn convert_mania() {
let converted = Beatmap::from_path("./resources/2785319.osu")
.unwrap()
.unchecked_into_converted::<Mania>();
assert!(converted.is_convert);
let map = converted.map;
assert_eq!(map.mode, GameMode::Mania);
assert_eq!(map.version, 14);
assert!(map.ar.eq(9.3), "{} != 9.3", map.ar);
assert!(map.od.eq(8.8), "{} != 8.8", map.od);
assert!(map.cs.eq(7.0), "{} != 7.0", map.cs);
assert!(map.hp.eq(5.0), "{} != 5.0", map.hp);
assert!(
map.slider_multiplier.eq(1.7),
"{} != 1.7",
map.slider_multiplier
);
assert!(
map.slider_tick_rate.eq(1.0),
"{} != 1.0",
map.slider_tick_rate
);
assert_eq!(map.hit_objects.len(), 1046);
assert_eq!(map.hit_sounds.len(), 0);
assert_eq!(map.timing_points.len(), 1);
assert_eq!(map.difficulty_points.len(), 50);
assert_eq!(map.effect_points.len(), 0);
assert!(map.stack_leniency.eq(0.5), "{} != 0.5", map.stack_leniency);
assert_eq!(map.breaks.len(), 1);
}
}
@@ -1,9 +1,6 @@
use std::collections::HashSet;
use rosu_map::util::Pos;
use crate::{
parse::{HitObject, HitObjectKind, Pos2},
util::ByteHasher,
};
use crate::model::hit_object::{HitObject, HitObjectKind, HoldNote};
use super::pattern_generator::{
distance_object::DistanceObjectPatternGenerator,
@@ -11,21 +8,21 @@ use super::pattern_generator::{
};
#[derive(Default)]
pub(crate) struct Pattern {
pub(crate) hit_objects: Vec<HitObject>,
contained_columns: HashSet<u8, ByteHasher>,
pub struct Pattern {
pub hit_objects: Vec<HitObject>,
contained_columns: ContainedColumns,
}
impl Pattern {
pub(crate) fn with_capacity(capacity: usize) -> Self {
pub fn with_capacity(capacity: usize) -> Self {
Self {
hit_objects: Vec::with_capacity(capacity),
contained_columns: HashSet::with_hasher(ByteHasher),
contained_columns: ContainedColumns::default(),
}
}
fn new_single(hit_object: HitObject, column: u8) -> Self {
let mut contained_columns = HashSet::with_capacity_and_hasher(1, ByteHasher);
let mut contained_columns = ContainedColumns::default();
contained_columns.insert(column);
let hit_objects = vec![hit_object];
@@ -35,20 +32,24 @@ impl Pattern {
}
}
pub(crate) fn new_note(generator: &HitObjectPatternGenerator<'_>, column: u8) -> Self {
pub fn new_note(generator: &HitObjectPatternGenerator<'_>, column: u8) -> Self {
let pos = column_to_pos(column, generator.inner.total_columns);
let hit_object = HitObject {
pos: Pos2::new(column_to_pos(column, generator.total_columns)),
start_time: generator.hit_object.start_time,
pos: Pos::new(pos, pos),
start_time: generator.inner.hit_object.start_time,
kind: HitObjectKind::Circle,
};
Self::new_single(hit_object, column)
}
pub(crate) fn add_note(&mut self, generator: &HitObjectPatternGenerator<'_>, column: u8) {
pub fn add_note(&mut self, generator: &HitObjectPatternGenerator<'_>, column: u8) {
let pos = column_to_pos(column, generator.inner.total_columns);
let hit_object = HitObject {
pos: Pos2::new(column_to_pos(column, generator.total_columns)),
start_time: generator.hit_object.start_time,
pos: Pos::new(pos, pos),
start_time: generator.inner.hit_object.start_time,
kind: HitObjectKind::Circle,
};
@@ -56,25 +57,26 @@ impl Pattern {
self.hit_objects.push(hit_object);
}
pub(crate) fn new_end_time_note(
pub fn new_end_time_note(
generator: &EndTimeObjectPatternGenerator<'_>,
column: u8,
hold_note: bool,
) -> Self {
let pos = Pos2::new(column_to_pos(column, generator.total_columns));
let pos = column_to_pos(column, generator.inner.total_columns);
let pos = Pos::new(pos, pos);
let hit_object = if hold_note {
HitObject {
pos,
start_time: generator.hit_object.start_time,
kind: HitObjectKind::Hold {
start_time: generator.inner.hit_object.start_time,
kind: HitObjectKind::Hold(HoldNote {
end_time: generator.end_time,
},
}),
}
} else {
HitObject {
pos,
start_time: generator.hit_object.start_time,
start_time: generator.inner.hit_object.start_time,
kind: HitObjectKind::Circle,
}
};
@@ -82,13 +84,14 @@ impl Pattern {
Self::new_single(hit_object, column)
}
pub(crate) fn new_slider_note(
pub fn new_slider_note(
generator: &DistanceObjectPatternGenerator<'_>,
column: u8,
start_time: i32,
end_time: i32,
) -> Self {
let pos = Pos2::new(column_to_pos(column, generator.total_columns));
let pos = column_to_pos(column, generator.inner.total_columns);
let pos = Pos::new(pos, pos);
let hit_object = if start_time == end_time {
HitObject {
@@ -100,23 +103,24 @@ impl Pattern {
HitObject {
pos,
start_time: f64::from(start_time),
kind: HitObjectKind::Hold {
kind: HitObjectKind::Hold(HoldNote {
end_time: f64::from(end_time),
},
}),
}
};
Self::new_single(hit_object, column)
}
pub(crate) fn add_slider_note(
pub fn add_slider_note(
&mut self,
generator: &DistanceObjectPatternGenerator<'_>,
column: u8,
start_time: i32,
end_time: i32,
) {
let pos = Pos2::new(column_to_pos(column, generator.total_columns));
let pos = column_to_pos(column, generator.inner.total_columns);
let pos = Pos::new(pos, pos);
let hit_object = if start_time == end_time {
HitObject {
@@ -128,9 +132,9 @@ impl Pattern {
HitObject {
pos,
start_time: f64::from(start_time),
kind: HitObjectKind::Hold {
kind: HitObjectKind::Hold(HoldNote {
end_time: f64::from(end_time),
},
}),
}
};
@@ -138,25 +142,24 @@ impl Pattern {
self.hit_objects.push(hit_object);
}
pub(crate) fn add_object(&mut self, obj: HitObject, column: u8) {
pub fn add_object(&mut self, obj: HitObject, column: u8) {
self.hit_objects.push(obj);
self.contained_columns.insert(column);
}
pub(crate) fn column_has_obj(&self, column: u8) -> bool {
self.contained_columns.contains(&column)
pub const fn column_has_obj(&self, column: u8) -> bool {
self.contained_columns.contains(column)
}
pub(crate) fn column_with_objs(&self) -> i32 {
pub const fn column_with_objs(&self) -> i32 {
self.contained_columns.len() as i32
}
/// Moves all values of `other` into `self`,
/// leaving `other` empty but keeps the capacities.
pub(crate) fn append(&mut self, other: &mut Self) {
pub fn append(&mut self, other: &mut Self) {
self.hit_objects.append(&mut other.hit_objects);
self.contained_columns
.extend(other.contained_columns.drain());
self.contained_columns.extend(other.contained_columns);
}
}
@@ -165,3 +168,24 @@ fn column_to_pos(column: u8, total_columns: i32) -> f32 {
(f32::from(column) * divisor).ceil()
}
#[derive(Copy, Clone, Default)]
pub struct ContainedColumns(u16);
impl ContainedColumns {
pub fn insert(&mut self, column: u8) {
self.0 |= 1 << column;
}
pub fn extend(&mut self, other: Self) {
self.0 |= other.0;
}
pub const fn len(self) -> u32 {
self.0.count_ones()
}
pub const fn contains(self, column: u8) -> bool {
self.0 & (1 << column) != 0
}
}
@@ -1,50 +1,48 @@
use rosu_map::section::hit_objects::{hit_samples::HitSoundType, BorrowedCurve};
use crate::{
beatmap::{
converts::mania::{legacy_random::Random, pattern::Pattern, pattern_type::PatternType},
EffectPoint,
mania::{
convert::{pattern::Pattern, pattern_type::PatternType},
object::ManiaObject,
},
curve::Curve,
mania::ManiaObject,
parse::{HitObject, HitSound},
util::FloatExt,
Beatmap,
model::{
beatmap::{get_precision_adjusted_beat_len_taiko_mania, Beatmap},
control_point::{EffectPoint, TimingPoint},
hit_object::HitObject,
},
util::{float_ext::FloatExt, random::Random},
};
use super::PatternGenerator;
pub(crate) struct DistanceObjectPatternGenerator<'h> {
pub(crate) hit_object: &'h HitObject,
pub(crate) segment_duration: i32,
pub(crate) total_columns: i32,
pub(crate) sample: u8,
pub struct DistanceObjectPatternGenerator<'h> {
pub segment_duration: i32,
pub sample: HitSoundType,
pub inner: PatternGenerator<'h>,
start_time: i32,
end_time: i32,
span_count: i32,
orig: &'h Beatmap,
prev_pattern: &'h Pattern,
convert_type: PatternType,
random: &'h mut Random,
edge_sounds: &'h [u8],
node_sounds: &'h [HitSoundType],
}
impl<'h> DistanceObjectPatternGenerator<'h> {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
pub fn new(
random: &'h mut Random,
hit_object: &'h HitObject,
sample: u8,
sample: HitSoundType,
total_columns: i32,
prev_pattern: &'h Pattern,
orig: &'h Beatmap,
repeats: usize,
curve: &Curve<'_>,
edge_sounds: &'h [u8],
curve: &BorrowedCurve<'_>,
node_sounds: &'h [HitSoundType],
) -> Self {
let timing_point = orig.timing_point_at(hit_object.start_time);
let difficulty_point = orig
.difficulty_point_at(hit_object.start_time)
.unwrap_or_default();
let beat_len = orig
.timing_point_at(hit_object.start_time)
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len);
let kiai = orig
.effect_point_at(hit_object.start_time)
@@ -56,35 +54,35 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
PatternType::LOW_PROBABILITY
};
let beat_len = timing_point.beat_len * difficulty_point.bpm_mult;
let beat_len =
get_precision_adjusted_beat_len_taiko_mania(orig, beat_len, hit_object.start_time);
let span_count = (repeats + 1) as i32;
let start_time = hit_object.start_time.round_even() as i32;
// * This matches stable's calculation.
let end_time = (f64::from(start_time)
+ curve.dist() * beat_len * f64::from(span_count) * 0.01 / orig.slider_mult)
+ curve.dist() * beat_len * f64::from(span_count) * 0.01 / orig.slider_multiplier)
.floor() as i32;
let segment_duration = (end_time - start_time) / span_count;
let inner = PatternGenerator::new(hit_object, total_columns, random, orig);
Self {
hit_object,
segment_duration,
total_columns,
sample,
inner,
start_time,
end_time,
span_count,
orig,
prev_pattern,
convert_type,
random,
edge_sounds,
node_sounds,
}
}
pub(crate) fn generate(&mut self) -> Vec<Pattern> {
pub fn generate(&mut self) -> Vec<Pattern> {
let orig_pattern = self.generate_();
if orig_pattern.hit_objects.len() == 1 {
@@ -98,7 +96,7 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
let mut end_time_pattern = Pattern::default();
for obj in orig_pattern.hit_objects {
let col = ManiaObject::column(obj.pos.x, self.total_columns as f32) as u8;
let col = ManiaObject::column(obj.pos.x, self.inner.total_columns as f32) as u8;
// Keeping it in-sync with lazer
#[allow(clippy::if_not_else)]
@@ -113,9 +111,9 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
}
fn generate_(&mut self) -> Pattern {
let conversion_diff = self.conversion_difficulty();
let conversion_diff = self.inner.conversion_difficulty();
if self.total_columns == 1 {
if self.inner.total_columns == 1 {
Pattern::new_slider_note(self, 0, self.start_time, self.end_time)
} else if self.span_count > 1 {
if self.segment_duration <= 90 {
@@ -131,14 +129,14 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
} else if self.end_time - self.start_time >= 4000 {
self.generate_n_random_notes(self.start_time, 0.23, 0.0, 0.0)
} else if self.segment_duration > 400
&& self.span_count < self.total_columns - 1 - self.random_start()
&& self.span_count < self.inner.total_columns - 1 - self.inner.random_start()
{
self.generate_tiled_hold_notes(self.start_time)
} else {
self.generate_hold_and_normal_notes(self.start_time, conversion_diff)
}
} else if self.segment_duration <= 110 {
if self.prev_pattern.column_with_objs() < self.total_columns {
if self.prev_pattern.column_with_objs() < self.inner.total_columns {
self.convert_type |= PatternType::FORCE_NOT_STACK;
} else {
self.convert_type &= !PatternType::FORCE_NOT_STACK;
@@ -180,29 +178,22 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
let mut pattern = Pattern::default();
let random_start = self.random_start();
let random_start = self.inner.random_start();
let usable_columns =
self.total_columns - random_start - self.prev_pattern.column_with_objs();
let mut next_column = PatternGenerator::get_random_column(self, None, None);
self.inner.total_columns - random_start - self.prev_pattern.column_with_objs();
let mut next_column = self.inner.get_random_column(None, None);
for _ in 0..usable_columns.min(note_count) {
// * Find available column
next_column = self.find_available_column(
next_column,
None,
None,
None,
None,
&[&pattern, self.prev_pattern],
);
next_column =
self.find_available_column(next_column, None, &[&pattern, self.prev_pattern]);
pattern.add_slider_note(self, next_column, start_time, self.end_time);
}
// * This is can't be combined with the above loop due to RNG
for _ in 0..note_count.saturating_sub(usable_columns) {
next_column =
self.find_available_column(next_column, None, None, None, None, &[&pattern]);
next_column = self.find_available_column(next_column, None, &[&pattern]);
pattern.add_slider_note(self, next_column, start_time, self.end_time);
}
@@ -217,19 +208,12 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
// * - - - x
// * x - - -
let mut next_column = self.get_column(Some(true));
let mut next_column = self.inner.get_column(Some(true));
if self.convert_type.contains(PatternType::FORCE_NOT_STACK)
&& self.prev_pattern.column_with_objs() < self.total_columns
&& self.prev_pattern.column_with_objs() < self.inner.total_columns
{
next_column = self.find_available_column(
next_column,
None,
None,
None,
None,
&[self.prev_pattern],
);
next_column = self.find_available_column(next_column, None, &[self.prev_pattern]);
}
let mut last_column = next_column;
@@ -240,9 +224,6 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
next_column = self.find_available_column(
next_column,
None,
None,
None,
Some(&|c| c != i32::from(last_column)),
&[],
);
@@ -264,8 +245,8 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
// * - x - -
// * x - - -
let mut column = i32::from(self.get_column(Some(true)));
let mut increasing = self.random.gen_double() > 0.5;
let mut column = i32::from(self.inner.get_column(Some(true)));
let mut increasing = self.inner.random.next_double() > 0.5;
let mut pattern = Pattern::with_capacity(self.span_count as usize + 1);
for _ in 0..=self.span_count as usize {
@@ -274,13 +255,13 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
// * Check if we're at the borders of the stage, and invert the pattern if so
if increasing {
if column >= self.total_columns - 1 {
if column >= self.inner.total_columns - 1 {
increasing = false;
column -= 1;
} else {
column += 1;
}
} else if column <= self.random_start() {
} else if column <= self.inner.random_start() {
increasing = true;
column += 1;
} else {
@@ -298,14 +279,15 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
// * - - - x
// * x - x -
let legacy = (4..=8).contains(&self.total_columns);
let legacy = (4..=8).contains(&self.inner.total_columns);
let interval = self
.inner
.random
.gen_int_range(1, self.total_columns - i32::from(legacy));
.next_int_range(1, self.inner.total_columns - i32::from(legacy));
let mut next_column = i32::from(self.get_column(Some(true)));
let random_start = self.random_start();
let not_2k = self.total_columns > 2;
let mut next_column = i32::from(self.inner.get_column(Some(true)));
let random_start = self.inner.random_start();
let not_2k = self.inner.total_columns > 2;
let mut pattern =
Pattern::with_capacity((self.span_count as usize + 1) * (1 + usize::from(not_2k)));
@@ -314,8 +296,9 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
next_column += interval;
if next_column >= self.total_columns - random_start {
next_column = next_column - self.total_columns - random_start + i32::from(legacy);
if next_column >= self.inner.total_columns - random_start {
next_column =
next_column - self.inner.total_columns - random_start + i32::from(legacy);
}
next_column += random_start;
@@ -325,7 +308,7 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
pattern.add_slider_note(self, next_column as u8, start_time, start_time);
}
next_column = i32::from(PatternGenerator::get_random_column(self, None, None));
next_column = i32::from(self.inner.get_random_column(None, None));
start_time += self.segment_duration;
}
@@ -344,7 +327,7 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
// * □ - □ □
// * ■ - ■ ■
match self.total_columns {
match self.inner.total_columns {
2 => {
p2 = 0.0;
p3 = 0.0;
@@ -368,7 +351,8 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
_ => {}
}
let is_double_sample = |sample: u8| sample.clap() || sample.finish();
let is_double_sample =
|sample: HitSoundType| sample.has_flag(HitSoundType::CLAP | HitSoundType::FINISH);
let can_generate_two_notes = !self.convert_type.contains(PatternType::LOW_PROBABILITY)
&& (is_double_sample(self.sample)
@@ -378,7 +362,9 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
p2 = 1.0;
}
let note_count = self.get_random_note_count(p2, p3, Some(p4), None, None);
let note_count = self
.inner
.get_random_note_count(p2, p3, Some(p4), None, None);
self.generate_random_hold_notes(start_time, note_count)
}
@@ -393,31 +379,23 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
// * □ ■ - -
// * ■ - - -
let column_repeat = self.span_count.min(self.total_columns) as usize;
let column_repeat = self.span_count.min(self.inner.total_columns) as usize;
// * Due to integer rounding, this is not guaranteed to be the same as EndTime (the class-level variable).
let end_time = start_time + self.segment_duration * self.span_count;
let mut next_column = self.get_column(Some(true));
let mut next_column = self.inner.get_column(Some(true));
if self.convert_type.contains(PatternType::FORCE_NOT_STACK)
&& self.prev_pattern.column_with_objs() < self.total_columns
&& self.prev_pattern.column_with_objs() < self.inner.total_columns
{
next_column = self.find_available_column(
next_column,
None,
None,
None,
None,
&[self.prev_pattern],
);
next_column = self.find_available_column(next_column, None, &[self.prev_pattern]);
}
let mut pattern = Pattern::with_capacity(column_repeat);
for _ in 0..column_repeat {
next_column =
self.find_available_column(next_column, None, None, None, None, &[&pattern]);
next_column = self.find_available_column(next_column, None, &[&pattern]);
pattern.add_slider_note(self, next_column, start_time, end_time);
start_time += self.segment_duration;
}
@@ -438,44 +416,47 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
let mut pattern = Pattern::default();
let mut hold_column = self.get_column(Some(true));
let mut hold_column = self.inner.get_column(Some(true));
if self.convert_type.contains(PatternType::FORCE_NOT_STACK)
&& self.prev_pattern.column_with_objs() < self.total_columns
&& self.prev_pattern.column_with_objs() < self.inner.total_columns
{
hold_column = self.find_available_column(
hold_column,
None,
None,
None,
None,
&[self.prev_pattern],
);
hold_column = self.find_available_column(hold_column, None, &[self.prev_pattern]);
}
// * Create the hold note
pattern.add_slider_note(self, hold_column, start_time, self.end_time);
let mut next_column = PatternGenerator::get_random_column(self, None, None);
let mut next_column = self.inner.get_random_column(None, None);
let mut note_count = if conversion_diff > 6.5 {
self.get_random_note_count(0.63, 0.0, None, None, None)
self.inner
.get_random_note_count(0.63, 0.0, None, None, None)
} else if conversion_diff > 4.0 {
let p2 = if self.total_columns < 6 { 0.12 } else { 0.45 };
let p2 = if self.inner.total_columns < 6 {
0.12
} else {
0.45
};
self.get_random_note_count(p2, 0.0, None, None, None)
self.inner.get_random_note_count(p2, 0.0, None, None, None)
} else if conversion_diff > 2.5 {
let p2 = if self.total_columns < 6 { 0.0 } else { 0.24 };
let p2 = if self.inner.total_columns < 6 {
0.0
} else {
0.24
};
self.get_random_note_count(p2, 0.0, None, None, None)
self.inner.get_random_note_count(p2, 0.0, None, None, None)
} else {
0
};
note_count = note_count.min(self.total_columns - 1);
note_count = note_count.min(self.inner.total_columns - 1);
let sample = self.sample_info_list_at(start_time);
let ignore_head = !(sample.whistle() || sample.finish() || sample.clap());
let ignore_head =
!sample.has_flag(HitSoundType::WHISTLE | HitSoundType::FINISH | HitSoundType::CLAP);
let mut row_pattern = Pattern::default();
let hold_column = i32::from(hold_column);
@@ -485,9 +466,6 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
for _ in 0..note_count {
next_column = self.find_available_column(
next_column,
None,
None,
None,
Some(&|c| c != hold_column),
&[&row_pattern],
);
@@ -503,41 +481,61 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
pattern
}
fn sample_info_list_at(&self, time: i32) -> u8 {
fn sample_info_list_at(&self, time: i32) -> HitSoundType {
self.note_samples_at(time)
.first()
.map_or(self.sample, |sample| *sample)
}
fn note_samples_at(&self, time: i32) -> &[u8] {
fn note_samples_at(&self, time: i32) -> &[HitSoundType] {
let idx = if self.segment_duration == 0 {
0
} else {
((time - self.start_time) / self.segment_duration) as usize
};
&self.edge_sounds[idx..]
}
}
impl PatternGenerator for DistanceObjectPatternGenerator<'_> {
#[inline]
fn hit_object(&self) -> &HitObject {
self.hit_object
}
#[inline]
fn total_columns(&self) -> i32 {
self.total_columns
}
#[inline]
fn random(&mut self) -> &mut Random {
self.random
}
#[inline]
fn original_map(&self) -> &Beatmap {
self.orig
&self.node_sounds[idx..]
}
fn find_available_column(
&mut self,
mut initial_column: u8,
validation: Option<&dyn Fn(i32) -> bool>,
patterns: &[&Pattern],
) -> u8 {
let lower = self.inner.random_start();
let upper = self.inner.total_columns;
let is_valid = |column: i32| {
if let Some(fun) = validation {
if !(fun)(column) {
return false;
}
}
let column = column as u8;
patterns
.iter()
.all(|pattern| !pattern.column_has_obj(column))
};
// * Check for the initial column
if is_valid(i32::from(initial_column)) {
return initial_column;
}
// * Ensure that we have at least one free column, so that an endless loop is avoided
let has_valid_column = (lower..upper).any(is_valid);
assert!(has_valid_column);
// * Iterate until a valid column is found. This is a random iteration in the default case.
while {
initial_column = self.inner.get_random_column(Some(lower), Some(upper));
!is_valid(i32::from(initial_column))
} {}
initial_column
}
}
@@ -0,0 +1,117 @@
use rosu_map::section::hit_objects::hit_samples::HitSoundType;
use crate::{
mania::convert::{pattern::Pattern, pattern_type::PatternType},
model::hit_object::HitObject,
util::random::Random,
Beatmap,
};
use super::PatternGenerator;
pub struct EndTimeObjectPatternGenerator<'h> {
pub end_time: f64,
pub sample: HitSoundType,
pub inner: PatternGenerator<'h>,
convert_type: PatternType,
prev_pattern: &'h Pattern,
}
impl<'h> EndTimeObjectPatternGenerator<'h> {
pub fn new(
random: &'h mut Random,
hit_object: &'h HitObject,
end_time: f64,
sample: HitSoundType,
total_columns: i32,
prev_pattern: &'h Pattern,
orig: &'h Beatmap,
) -> Self {
let convert_type = if prev_pattern.column_with_objs() == total_columns {
PatternType::default()
} else {
PatternType::FORCE_NOT_STACK
};
let inner = PatternGenerator::new(hit_object, total_columns, random, orig);
Self {
end_time,
sample,
inner,
convert_type,
prev_pattern,
}
}
pub fn generate(&mut self) -> Pattern {
let generate_hold = self.end_time - self.inner.hit_object.start_time >= 100.0;
match self.inner.total_columns {
8 if self.sample.has_flag(HitSoundType::FINISH)
&& self.end_time - self.inner.hit_object.start_time < 1000.0 =>
{
Pattern::new_end_time_note(self, 0, generate_hold)
}
8 => {
let column = self.get_random_column(self.inner.random_start());
Pattern::new_end_time_note(self, column, generate_hold)
}
_ => {
let column = self.get_random_column(0);
Pattern::new_end_time_note(self, column, generate_hold)
}
}
}
fn get_random_column(&mut self, lower: i32) -> u8 {
let column = self.inner.get_random_column(Some(lower), None);
if self.convert_type.contains(PatternType::FORCE_NOT_STACK) {
self.find_available_column(column, Some(lower), &[self.prev_pattern])
} else {
self.find_available_column(column, Some(lower), &[])
}
}
fn find_available_column(
&mut self,
mut initial_column: u8,
lower: Option<i32>,
// upper: Option<i32>,
// next_column: Option<&dyn Fn(u8) -> u8>,
// validation: Option<&dyn Fn(i32) -> bool>,
patterns: &[&Pattern],
) -> u8 {
let lower = lower.unwrap_or_else(|| self.inner.random_start());
let upper = self.inner.total_columns;
let is_valid = |column: i32| {
let column = column as u8;
patterns
.iter()
.all(|pattern| !pattern.column_has_obj(column))
};
// * Check for the initial column
if is_valid(i32::from(initial_column)) {
return initial_column;
}
// * Ensure that we have at least one free column, so that an endless loop is avoided
let has_valid_column = (lower..upper).any(is_valid);
assert!(has_valid_column);
// * Iterate until a valid column is found. This is a random iteration in the default case.
while {
initial_column = self.inner.get_random_column(Some(lower), Some(upper));
!is_valid(i32::from(initial_column))
} {}
initial_column
}
}
@@ -1,39 +1,41 @@
use rosu_map::section::hit_objects::hit_samples::HitSoundType;
use crate::{
beatmap::{
converts::mania::{
legacy_random::Random, pattern::Pattern, pattern_type::PatternType, PrevValues,
},
EffectPoint,
mania::{
convert::{pattern::Pattern, pattern_type::PatternType, PrevValues},
object::ManiaObject,
},
mania::ManiaObject,
parse::{HitObject, HitSound},
Beatmap,
model::{
beatmap::Beatmap,
control_point::{EffectPoint, TimingPoint},
hit_object::HitObject,
},
util::random::Random,
};
use super::PatternGenerator;
pub(crate) struct HitObjectPatternGenerator<'h> {
pub(crate) hit_object: &'h HitObject,
pub(crate) total_columns: i32,
pub(crate) sample: u8,
pub(crate) stair_type: PatternType,
pub struct HitObjectPatternGenerator<'h> {
pub sample: HitSoundType,
pub stair_type: PatternType,
pub inner: PatternGenerator<'h>,
convert_type: PatternType,
prev_pattern: &'h Pattern,
random: &'h mut Random,
orig: &'h Beatmap,
}
impl<'h> HitObjectPatternGenerator<'h> {
pub(crate) fn new(
pub fn new(
random: &'h mut Random,
hit_object: &'h HitObject,
sample: u8,
sample: HitSoundType,
total_columns: i32,
prev: &'h PrevValues,
density: f64,
orig: &'h Beatmap,
) -> Self {
let timing_point = orig.timing_point_at(hit_object.start_time);
let beat_len = orig
.timing_point_at(hit_object.start_time)
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len);
let pos_separation = (hit_object.pos - prev.pos).length();
let time_separation = hit_object.start_time - prev.time;
@@ -58,10 +60,10 @@ impl<'h> HitObjectPatternGenerator<'h> {
} else if time_separation <= 150.0 && pos_separation < 20.0 {
// * More than 100 BPM stream
convert_type |= PatternType::FORCE_STACK | PatternType::LOW_PROBABILITY;
} else if pos_separation < 20.0 && density >= timing_point.beat_len / 2.5 {
} else if pos_separation < 20.0 && density >= beat_len / 2.5 {
// * Low density stream
convert_type |= PatternType::REVERSE | PatternType::LOW_PROBABILITY;
} else if density < timing_point.beat_len / 2.5 {
} else if density < beat_len / 2.5 {
// * High density
} else {
let kiai = orig
@@ -76,36 +78,37 @@ impl<'h> HitObjectPatternGenerator<'h> {
}
if !convert_type.contains(PatternType::KEEP_SINGLE) {
if sample.finish() && total_columns != 8 {
if sample.has_flag(HitSoundType::FINISH) && total_columns != 8 {
convert_type |= PatternType::MIRROR;
} else if sample.clap() {
} else if sample.has_flag(HitSoundType::CLAP) {
convert_type |= PatternType::GATHERED;
}
}
let inner = PatternGenerator::new(hit_object, total_columns, random, orig);
Self {
hit_object,
stair_type: prev.stair,
convert_type,
total_columns,
sample,
prev_pattern: &prev.pattern,
random,
orig,
inner,
}
}
pub(crate) fn generate(&mut self) -> Pattern {
pub fn generate(&mut self) -> Pattern {
let pattern = self.generate_core();
for obj in pattern.hit_objects.iter() {
let col = ManiaObject::column(obj.pos.x, self.total_columns as f32) as i32;
let col = ManiaObject::column(obj.pos.x, self.inner.total_columns as f32) as i32;
if self.convert_type.contains(PatternType::STAIR) && col == self.total_columns - 1 {
if self.convert_type.contains(PatternType::STAIR) && col == self.inner.total_columns - 1
{
self.stair_type = PatternType::REVERSE_STAIR;
}
if self.convert_type.contains(PatternType::REVERSE_STAIR) && col == self.random_start()
if self.convert_type.contains(PatternType::REVERSE_STAIR)
&& col == self.inner.random_start()
{
self.stair_type = PatternType::STAIR;
}
@@ -115,24 +118,24 @@ impl<'h> HitObjectPatternGenerator<'h> {
}
fn generate_core(&mut self) -> Pattern {
if self.total_columns == 1 {
if self.inner.total_columns == 1 {
return Pattern::new_note(self, 0);
}
let last_column = self.prev_pattern.hit_objects.last().map_or(0, |h| {
ManiaObject::column(h.pos.x, self.total_columns as f32) as u8
ManiaObject::column(h.pos.x, self.inner.total_columns as f32) as u8
});
let random_start = self.random_start() as u8;
let random_start = self.inner.random_start() as u8;
if self.convert_type.contains(PatternType::REVERSE)
&& !self.prev_pattern.hit_objects.is_empty()
{
let mut pattern = Pattern::default();
for i in random_start..self.total_columns as u8 {
for i in random_start..self.inner.total_columns as u8 {
if self.prev_pattern.column_has_obj(i) {
pattern.add_note(self, random_start + self.total_columns as u8 - i - 1);
pattern.add_note(self, random_start + self.inner.total_columns as u8 - i - 1);
}
}
@@ -142,12 +145,12 @@ impl<'h> HitObjectPatternGenerator<'h> {
if self.convert_type.contains(PatternType::CYCLE)
&& self.prev_pattern.hit_objects.len() == 1
// * If we convert to 7K + 1, let's not overload the special key
&& (self.total_columns != 8 || last_column != 0)
&& (self.inner.total_columns != 8 || last_column != 0)
// * Make sure the last column was not the centre column
&& (self.total_columns % 2 == 0 || last_column != self.total_columns as u8 / 2)
&& (self.inner.total_columns % 2 == 0 || last_column != self.inner.total_columns as u8 / 2)
{
// * Generate a new pattern by cycling backwards (similar to Reverse but for only one hit object)
let column = random_start + self.total_columns as u8 - last_column - 1;
let column = random_start + self.inner.total_columns as u8 - last_column - 1;
return Pattern::new_note(self, column);
}
@@ -158,7 +161,7 @@ impl<'h> HitObjectPatternGenerator<'h> {
let mut pattern = Pattern::default();
// * Generate a new pattern by placing on the already filled columns
for i in random_start..self.total_columns as u8 {
for i in random_start..self.inner.total_columns as u8 {
if self.prev_pattern.column_has_obj(i) {
pattern.add_note(self, i);
}
@@ -173,7 +176,7 @@ impl<'h> HitObjectPatternGenerator<'h> {
// * cycling back to the start if there is no "next"
let mut target_column = last_column + 1;
if target_column == self.total_columns as u8 {
if target_column == self.inner.total_columns as u8 {
target_column = random_start;
}
@@ -186,7 +189,7 @@ impl<'h> HitObjectPatternGenerator<'h> {
let mut target_column = last_column as i8 - 1;
if target_column == random_start as i8 - 1 {
target_column = self.total_columns as i8 - 1;
target_column = self.inner.total_columns as i8 - 1;
}
return Pattern::new_note(self, target_column as u8);
@@ -197,7 +200,7 @@ impl<'h> HitObjectPatternGenerator<'h> {
return self.generate_random_notes(1);
}
let conversion_diff = self.conversion_difficulty();
let conversion_diff = self.inner.conversion_difficulty();
if self.convert_type.contains(PatternType::MIRROR) {
if conversion_diff > 6.5 {
@@ -236,30 +239,27 @@ impl<'h> HitObjectPatternGenerator<'h> {
let allow_stacking = !self.convert_type.contains(PatternType::FORCE_NOT_STACK);
if !allow_stacking {
note_count =
(self.total_columns - self.random_start() - self.prev_pattern.column_with_objs())
.min(note_count);
note_count = (self.inner.total_columns
- self.inner.random_start()
- self.prev_pattern.column_with_objs())
.min(note_count);
}
let mut next_column = self.get_column(Some(true));
let mut next_column = self.inner.get_column(Some(true));
for _ in 0..note_count {
next_column = if allow_stacking {
self.find_available_column(
next_column,
None,
None,
Some(Self::get_next_column),
None,
&[&pattern],
)
} else {
self.find_available_column(
next_column,
None,
None,
Some(Self::get_next_column),
None,
&[&pattern, self.prev_pattern],
)
};
@@ -274,25 +274,25 @@ impl<'h> HitObjectPatternGenerator<'h> {
if self.convert_type.contains(PatternType::GATHERED) {
last += 1;
if last == self.total_columns as u8 {
last = self.random_start() as u8;
if last == self.inner.total_columns as u8 {
last = self.inner.random_start() as u8;
}
} else {
last = PatternGenerator::get_random_column(self, None, None);
last = self.inner.get_random_column(None, None);
}
last
}
fn has_special_column(&self) -> bool {
self.sample.clap() && self.sample.finish()
const fn has_special_column(&self) -> bool {
self.sample.has_flag(HitSoundType::CLAP) && self.sample.has_flag(HitSoundType::FINISH)
}
fn generate_random_pattern(&mut self, p2: f64, p3: f64, p4: f64, p5: f64) -> Pattern {
let random_note_count = self.get_random_note_count(p2, p3, p4, p5);
let mut pattern = self.generate_random_notes(random_note_count);
if self.random_start() > 0 && self.has_special_column() {
if self.inner.random_start() > 0 && self.has_special_column() {
pattern.add_note(self, 0);
}
@@ -300,7 +300,7 @@ impl<'h> HitObjectPatternGenerator<'h> {
}
fn get_random_note_count(&mut self, mut p2: f64, mut p3: f64, mut p4: f64, mut p5: f64) -> i32 {
match self.total_columns {
match self.inner.total_columns {
2 => {
p2 = 0.0;
p3 = 0.0;
@@ -327,11 +327,12 @@ impl<'h> HitObjectPatternGenerator<'h> {
_ => {}
}
if self.sample.clap() {
if self.sample.has_flag(HitSoundType::CLAP) {
p2 = 1.0;
}
PatternGenerator::get_random_note_count(self, p2, p3, Some(p4), Some(p5), None)
self.inner
.get_random_note_count(p2, p3, Some(p4), Some(p5), None)
}
fn generate_random_pattern_with_mirrored(
@@ -349,37 +350,32 @@ impl<'h> HitObjectPatternGenerator<'h> {
let (note_count, add_to_centre) =
self.get_random_note_count_mirrored(centre_probability, p2, p3);
let column_limit = if self.total_columns % 2 == 0 {
self.total_columns / 2
let column_limit = if self.inner.total_columns % 2 == 0 {
self.inner.total_columns / 2
} else {
(self.total_columns - 1) / 2
(self.inner.total_columns - 1) / 2
};
let mut next_column = PatternGenerator::get_random_column(self, None, Some(column_limit));
let mut next_column = self.inner.get_random_column(None, Some(column_limit));
for _ in 0..note_count {
next_column = self.find_available_column(
next_column,
None,
Some(column_limit),
None,
None,
&[&pattern],
);
next_column =
self.find_available_column(next_column, Some(column_limit), None, &[&pattern]);
// * Add normal note
pattern.add_note(self, next_column);
// * Add mirrored note
let column = (self.random_start() + self.total_columns) as u8 - next_column - 1;
let column =
(self.inner.random_start() + self.inner.total_columns) as u8 - next_column - 1;
pattern.add_note(self, column);
}
if add_to_centre {
pattern.add_note(self, self.total_columns as u8 / 2);
pattern.add_note(self, self.inner.total_columns as u8 / 2);
}
if self.random_start() > 0 && self.has_special_column() {
if self.inner.random_start() > 0 && self.has_special_column() {
pattern.add_note(self, 0);
}
@@ -392,7 +388,7 @@ impl<'h> HitObjectPatternGenerator<'h> {
mut p2: f64,
mut p3: f64,
) -> (i32, bool) {
match self.total_columns {
match self.inner.total_columns {
2 => {
centre_probability = 0.0;
p2 = 0.0;
@@ -433,33 +429,53 @@ impl<'h> HitObjectPatternGenerator<'h> {
p2 = p2.clamp(0.0, 1.0);
p3 = p3.clamp(0.0, 1.0);
let centre_val = self.random.gen_double();
let note_count = PatternGenerator::get_random_note_count(self, p2, p3, None, None, None);
let add_to_centre =
self.total_columns % 2 != 0 && note_count != 3 && centre_val > 1.0 - centre_probability;
let centre_val = self.inner.random.next_double();
let note_count = self.inner.get_random_note_count(p2, p3, None, None, None);
let add_to_centre = self.inner.total_columns % 2 != 0
&& note_count != 3
&& centre_val > 1.0 - centre_probability;
(note_count, add_to_centre)
}
}
impl PatternGenerator for HitObjectPatternGenerator<'_> {
#[inline]
fn hit_object(&self) -> &HitObject {
self.hit_object
}
fn find_available_column(
&mut self,
mut initial_column: u8,
upper: Option<i32>,
next_column: Option<fn(&mut Self, u8) -> u8>,
patterns: &[&Pattern],
) -> u8 {
let lower = self.inner.random_start();
let upper = upper.unwrap_or(self.inner.total_columns);
#[inline]
fn total_columns(&self) -> i32 {
self.total_columns
}
let is_valid = |column: i32| {
let column = column as u8;
#[inline]
fn random(&mut self) -> &mut Random {
self.random
}
patterns
.iter()
.all(|pattern| !pattern.column_has_obj(column))
};
#[inline]
fn original_map(&self) -> &Beatmap {
self.orig
// * Check for the initial column
if is_valid(i32::from(initial_column)) {
return initial_column;
}
// * Ensure that we have at least one free column, so that an endless loop is avoided
let has_valid_column = (lower..upper).any(is_valid);
assert!(has_valid_column);
// * Iterate until a valid column is found. This is a random iteration in the default case.
while {
initial_column = if let Some(fun) = next_column {
(fun)(self, initial_column)
} else {
self.inner.get_random_column(Some(lower), Some(upper))
};
!is_valid(i32::from(initial_column))
} {}
initial_column
}
}
+106
View File
@@ -0,0 +1,106 @@
use crate::{
mania::object::ManiaObject,
model::{beatmap::Beatmap, hit_object::HitObject},
util::random::Random,
};
pub(super) mod distance_object;
pub(super) mod end_time_object;
pub(super) mod hit_object;
pub struct PatternGenerator<'a> {
pub hit_object: &'a HitObject,
pub total_columns: i32,
random: &'a mut Random,
original_map: &'a Beatmap,
}
impl<'a> PatternGenerator<'a> {
fn new(
hit_object: &'a HitObject,
total_columns: i32,
random: &'a mut Random,
original_map: &'a Beatmap,
) -> Self {
Self {
hit_object,
total_columns,
random,
original_map,
}
}
fn random_start(&self) -> i32 {
i32::from(self.total_columns == 8)
}
fn get_column(&self, allow_special: Option<bool>) -> u8 {
let allow_special = allow_special.unwrap_or(false);
if allow_special && self.total_columns == 8 {
const LOCAL_X_DIVISOR: f32 = 512.0 / 7.0;
((self.hit_object.pos.x / LOCAL_X_DIVISOR).floor() as u8).clamp(0, 6) + 1
} else {
ManiaObject::column(self.hit_object.pos.x, self.total_columns as f32) as u8
}
}
fn get_random_note_count(
&mut self,
p2: f64,
p3: f64,
p4: Option<f64>,
p5: Option<f64>,
p6: Option<f64>,
) -> i32 {
let p4 = p4.unwrap_or(0.0);
let p5 = p5.unwrap_or(0.0);
let p6 = p6.unwrap_or(0.0);
let val = self.random.next_double();
if val >= 1.0 - p6 {
6
} else if val >= 1.0 - p5 {
5
} else if val >= 1.0 - p4 {
4
} else if val >= 1.0 - p3 {
3
} else {
1 + i32::from(val >= 1.0 - p2)
}
}
fn conversion_difficulty(&self) -> f64 {
let orig = self.original_map;
let last_obj_time = orig.hit_objects.last().map_or(0.0, |h| h.start_time);
let first_obj_time = orig.hit_objects.first().map_or(0.0, |h| h.start_time);
// * Drain time in seconds
let total_break_time = orig.total_break_time();
let mut drain_time = ((last_obj_time - first_obj_time - total_break_time) / 1000.0) as i32;
if drain_time == 0 {
drain_time = 10_000;
}
let mut conversion_difficulty = 0.0;
conversion_difficulty += f64::from(orig.hp + orig.ar.clamp(4.0, 7.0)) / 1.5;
conversion_difficulty += orig.hit_objects.len() as f64 / f64::from(drain_time) * 9.0;
conversion_difficulty /= 38.0;
conversion_difficulty *= 5.0;
conversion_difficulty /= 1.15;
conversion_difficulty = conversion_difficulty.min(12.0);
conversion_difficulty
}
fn get_random_column(&mut self, lower: Option<i32>, upper: Option<i32>) -> u8 {
let lower = lower.unwrap_or_else(|| self.random_start());
let upper = upper.unwrap_or(self.total_columns);
self.random.next_int_range(lower, upper) as u8
}
}
@@ -4,23 +4,23 @@ use std::{
};
#[derive(Copy, Clone, Default)]
pub(crate) struct PatternType(u16);
pub struct PatternType(u16);
#[rustfmt::skip]
impl PatternType {
pub(crate) const FORCE_STACK: Self = Self(1 << 0);
pub(crate) const FORCE_NOT_STACK: Self = Self(1 << 1);
pub(crate) const KEEP_SINGLE: Self = Self(1 << 2);
pub(crate) const LOW_PROBABILITY: Self = Self(1 << 3);
// pub(crate) const ALTERNATE: Self = Self(1 << 4);
// pub(crate) const FORCE_SIG_SLIDER: Self = Self(1 << 5);
// pub(crate) const FORCE_NOT_SLIDER: Self = Self(1 << 6);
pub(crate) const GATHERED: Self = Self(1 << 7);
pub(crate) const MIRROR: Self = Self(1 << 8);
pub(crate) const REVERSE: Self = Self(1 << 9);
pub(crate) const CYCLE: Self = Self(1 << 10);
pub(crate) const STAIR: Self = Self(1 << 11);
pub(crate) const REVERSE_STAIR: Self = Self(1 << 12);
pub const FORCE_STACK: Self = Self(1 << 0);
pub const FORCE_NOT_STACK: Self = Self(1 << 1);
pub const KEEP_SINGLE: Self = Self(1 << 2);
pub const LOW_PROBABILITY: Self = Self(1 << 3);
// pub const ALTERNATE: Self = Self(1 << 4);
// pub const FORCE_SIG_SLIDER: Self = Self(1 << 5);
// pub const FORCE_NOT_SLIDER: Self = Self(1 << 6);
pub const GATHERED: Self = Self(1 << 7);
pub const MIRROR: Self = Self(1 << 8);
pub const REVERSE: Self = Self(1 << 9);
pub const CYCLE: Self = Self(1 << 10);
pub const STAIR: Self = Self(1 << 11);
pub const REVERSE_STAIR: Self = Self(1 << 12);
}
impl fmt::Display for PatternType {
@@ -66,7 +66,7 @@ impl fmt::Display for PatternType {
}
impl PatternType {
pub(crate) const fn contains(self, other: Self) -> bool {
pub const fn contains(self, other: Self) -> bool {
self.0 & other.0 == other.0
}
}
@@ -74,21 +74,18 @@ impl PatternType {
impl BitOr for PatternType {
type Output = Self;
#[inline]
fn bitor(self, rhs: Self) -> Self::Output {
Self(self.0 | rhs.0)
}
}
impl BitOrAssign for PatternType {
#[inline]
fn bitor_assign(&mut self, rhs: Self) {
self.0 |= rhs.0;
}
}
impl BitAndAssign for PatternType {
#[inline]
fn bitand_assign(&mut self, rhs: Self) {
self.0 &= rhs.0;
}
@@ -97,7 +94,6 @@ impl BitAndAssign for PatternType {
impl Not for PatternType {
type Output = Self;
#[inline]
fn not(self) -> Self::Output {
Self(!self.0)
}
+89
View File
@@ -0,0 +1,89 @@
use crate::{
any::difficulty::{skills::Skill, ModeDifficulty},
mania::{
difficulty::{object::ManiaDifficultyObject, skills::strain::Strain},
object::{ManiaObject, ObjectParams},
},
util::float_ext::FloatExt,
};
use super::{attributes::ManiaDifficultyAttributes, convert::ManiaBeatmap};
mod object;
mod skills;
const STAR_SCALING_FACTOR: f64 = 0.018;
pub fn difficulty(
difficulty: &ModeDifficulty,
converted: &ManiaBeatmap<'_>,
) -> ManiaDifficultyAttributes {
let hit_window = converted
.attributes()
.mods(difficulty.get_mods())
.clock_rate(difficulty.get_clock_rate())
.hit_windows()
.od;
let n_objects = converted.map.hit_objects.len() as u32;
let values = DifficultyValues::calculate(difficulty, converted);
ManiaDifficultyAttributes {
stars: values.strain.difficulty_value() * STAR_SCALING_FACTOR,
hit_window,
max_combo: values.max_combo,
n_objects,
is_convert: converted.is_convert,
}
}
pub struct DifficultyValues {
pub strain: Strain,
pub max_combo: u32,
}
impl DifficultyValues {
pub fn calculate(difficulty: &ModeDifficulty, converted: &ManiaBeatmap<'_>) -> Self {
let take = difficulty.get_passed_objects();
let total_columns = converted.map.cs.round_even().max(1.0);
let clock_rate = difficulty.get_clock_rate();
let mut params = ObjectParams::new(converted.map.as_ref());
let mut mania_objects = converted
.map
.hit_objects
.iter()
.map(|h| ManiaObject::new(h, total_columns, &mut params))
.take(take);
let Some(first) = mania_objects.next() else {
return DifficultyValues {
strain: Strain::new(total_columns as usize),
max_combo: 0,
};
};
let n_diff_objects = mania_objects.len();
let diff_objects_iter = mania_objects.enumerate().scan(first, |last, (i, base)| {
let diff_object = ManiaDifficultyObject::new(&base, last, clock_rate, i);
*last = base;
Some(diff_object)
});
let mut diff_objects = Vec::with_capacity(n_diff_objects);
diff_objects.extend(diff_objects_iter);
let mut strain = Skill::new(Strain::new(total_columns as usize), &diff_objects);
for curr in diff_objects.iter() {
strain.process(curr);
}
Self {
strain: strain.inner,
max_combo: params.into_max_combo(),
}
}
}
+27
View File
@@ -0,0 +1,27 @@
use crate::{any::difficulty::object::IDifficultyObject, mania::object::ManiaObject};
pub struct ManiaDifficultyObject {
pub idx: usize,
pub base_column: usize,
pub delta_time: f64,
pub start_time: f64,
pub end_time: f64,
}
impl ManiaDifficultyObject {
pub fn new(base: &ManiaObject, last: &ManiaObject, clock_rate: f64, idx: usize) -> Self {
Self {
idx,
base_column: base.column,
delta_time: (base.start_time - last.start_time) / clock_rate,
start_time: base.start_time / clock_rate,
end_time: base.end_time / clock_rate,
}
}
}
impl IDifficultyObject for ManiaDifficultyObject {
fn idx(&self) -> usize {
self.idx
}
}
+1
View File
@@ -0,0 +1 @@
pub mod strain;
+186
View File
@@ -0,0 +1,186 @@
use crate::{
any::difficulty::{
object::IDifficultyObject,
skills::{strain_decay, ISkill, Skill, StrainDecaySkill},
},
mania::difficulty::object::ManiaDifficultyObject,
};
const INDIVIDUAL_DECAY_BASE: f64 = 0.125;
const OVERALL_DECAY_BASE: f64 = 0.3;
const RELEASE_THRESHOLD: f64 = 24.0;
const SKILL_MULTIPLIER: f64 = 1.0;
const STRAIN_DECAY_BASE: f64 = 1.0;
#[allow(clippy::struct_field_names)]
pub struct Strain {
start_times: Box<[f64]>,
end_times: Box<[f64]>,
individual_strains: Box<[f64]>,
individual_strain: f64,
overall_strain: f64,
inner: StrainDecaySkill,
}
impl Strain {
pub fn new(total_columns: usize) -> Self {
Self {
start_times: vec![0.0; total_columns].into_boxed_slice(),
end_times: vec![0.0; total_columns].into_boxed_slice(),
individual_strains: vec![0.0; total_columns].into_boxed_slice(),
individual_strain: 0.0,
overall_strain: 1.0,
inner: StrainDecaySkill::default(),
}
}
pub fn get_curr_strain_peaks(self) -> Vec<f64> {
self.inner.get_curr_strain_peaks()
}
pub fn difficulty_value(self) -> f64 {
self.inner.difficulty_value(StrainDecaySkill::DECAY_WEIGHT)
}
const fn curr_strain(&self) -> f64 {
self.inner.curr_strain
}
fn curr_strain_mut(&mut self) -> &mut f64 {
&mut self.inner.curr_strain
}
fn strain_value_at(&mut self, curr: &ManiaDifficultyObject) -> f64 {
*self.curr_strain_mut() *= strain_decay(curr.delta_time, STRAIN_DECAY_BASE);
*self.curr_strain_mut() += self.strain_value_of(curr) * SKILL_MULTIPLIER;
self.curr_strain()
}
fn strain_value_of(&mut self, curr: &ManiaDifficultyObject) -> f64 {
let mania_curr = curr;
let start_time = mania_curr.start_time;
let end_time = mania_curr.end_time;
let column = mania_curr.base_column;
let mut is_overlapping = false;
// * Lowest value we can assume with the current information
let mut closest_end_time = (end_time - start_time).abs();
// * Factor to all additional strains in case something else is held
let mut hold_factor = 1.0;
// * Addition to the current note in case it's a hold and has to be released awkwardly
let mut hold_addition = 0.0;
for i in 0..self.end_times.len() {
// * The current note is overlapped if a previous note or end is overlapping the current note body
is_overlapping |=
self.end_times[i] > start_time + 1.0 && end_time > self.end_times[i] + 1.0;
// * We give a slight bonus to everything if something is held meanwhile
if self.end_times[i] > end_time + 1.0 {
hold_factor = 1.25;
}
closest_end_time = (end_time - self.end_times[i]).abs().min(closest_end_time);
}
// * The hold addition is given if there was an overlap, however it is only valid if there are no other note with a similar ending.
// * Releasing multiple notes is just as easy as releasing 1. Nerfs the hold addition by half if the closest release is release_threshold away.
// * holdAddition
// * ^
// * 1.0 + - - - - - -+-----------
// * | /
// * 0.5 + - - - - -/ Sigmoid Curve
// * | /|
// * 0.0 +--------+-+---------------> Release Difference / ms
// * release_threshold
if is_overlapping {
hold_addition = (1.0 + (0.5 * (RELEASE_THRESHOLD - closest_end_time)).exp()).recip();
}
// * Decay and increase individualStrains in own column
self.individual_strains[column] = apply_decay(
self.individual_strains[column],
start_time - self.start_times[column],
INDIVIDUAL_DECAY_BASE,
);
self.individual_strains[column] += 2.0 * hold_factor;
// * For notes at the same time (in a chord), the individualStrain should be the hardest individualStrain out of those columns
self.individual_strain = if mania_curr.delta_time <= 1.0 {
self.individual_strain.max(self.individual_strains[column])
} else {
self.individual_strains[column]
};
// * Decay and increase overallStrain
self.overall_strain = apply_decay(self.overall_strain, curr.delta_time, OVERALL_DECAY_BASE);
self.overall_strain += (1.0 + hold_addition) * hold_factor;
// * Update startTimes and endTimes arrays
self.start_times[column] = start_time;
self.end_times[column] = end_time;
// * By subtracting CurrentStrain, this skill effectively only considers the maximum strain of any one hitobject within each strain section.
self.individual_strain + self.overall_strain - self.curr_strain()
}
}
impl ISkill for Strain {
type DifficultyObjects<'a> = [ManiaDifficultyObject];
}
impl Skill<'_, Strain> {
fn calculate_initial_strain(&mut self, offset: f64, curr: &ManiaDifficultyObject) -> f64 {
let prev_start_time = curr
.previous(0, self.diff_objects)
.map_or(0.0, |prev| prev.start_time);
let time = offset - prev_start_time;
let individual = apply_decay(self.inner.individual_strain, time, INDIVIDUAL_DECAY_BASE);
let overall = apply_decay(self.inner.overall_strain, time, OVERALL_DECAY_BASE);
individual + overall
}
const fn curr_section_peak(&self) -> f64 {
self.inner.inner.inner.curr_section_peak
}
fn curr_section_peak_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.inner.curr_section_peak
}
const fn curr_section_end(&self) -> f64 {
self.inner.inner.inner.curr_section_end
}
fn curr_section_end_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.inner.curr_section_end
}
pub fn process(&mut self, curr: &ManiaDifficultyObject) {
if curr.idx == 0 {
*self.curr_section_end_mut() = (curr.start_time / StrainDecaySkill::SECTION_LEN).ceil()
* StrainDecaySkill::SECTION_LEN;
}
while curr.start_time > self.curr_section_end() {
self.inner.inner.save_curr_peak();
let initial_strain = self.calculate_initial_strain(self.curr_section_end(), curr);
self.inner.inner.start_new_section_from(initial_strain);
*self.curr_section_end_mut() += StrainDecaySkill::SECTION_LEN;
}
let strain_value_at = self.inner.strain_value_at(curr);
*self.curr_section_peak_mut() = strain_value_at.max(self.curr_section_peak());
}
}
fn apply_decay(value: f64, delta_time: f64, decay_base: f64) -> f64 {
value * decay_base.powf(delta_time / 1000.0)
}
-22
View File
@@ -1,22 +0,0 @@
use super::mania_object::ManiaObject;
#[derive(Clone, Debug)]
pub(crate) struct ManiaDifficultyObject {
pub(crate) idx: usize,
pub(crate) base_column: usize,
pub(crate) delta_time: f64,
pub(crate) start_time: f64,
pub(crate) end_time: f64,
}
impl ManiaDifficultyObject {
pub(crate) fn new(base: &ManiaObject, last: &ManiaObject, clock_rate: f64, idx: usize) -> Self {
Self {
idx,
base_column: base.column,
delta_time: (base.start_time - last.start_time) / clock_rate,
start_time: base.start_time / clock_rate,
end_time: base.end_time / clock_rate,
}
}
}
-314
View File
@@ -1,314 +0,0 @@
#![cfg(feature = "gradual")]
use std::borrow::Cow;
use crate::{
beatmap::BeatmapHitWindows,
parse::{HitObject, HitObjectKind},
util::FloatExt,
Beatmap, GameMode, Mods,
};
use super::{
difficulty_object::ManiaDifficultyObject,
mania_object::ObjectParameters,
skills::{Skill, Strain},
ManiaDifficultyAttributes, ManiaObject, STAR_SCALING_FACTOR,
};
/// Gradually calculate the difficulty attributes of an osu!mania map.
///
/// Note that this struct implements [`Iterator`].
/// On every call of [`Iterator::next`], the map's next hit object will
/// be processed and the [`ManiaDifficultyAttributes`] will be updated and returned.
///
/// If you want to calculate performance attributes, use
/// [`ManiaGradualPerformance`](crate::mania::ManiaGradualPerformance) instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, mania::ManiaGradualDifficulty};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut iter = ManiaGradualDifficulty::new(&map, mods);
///
/// let attrs1 = iter.next(); // the difficulty of the map after the first hit object
/// let attrs2 = iter.next(); // after the second hit object
///
/// // Remaining hit objects
/// for difficulty in iter {
/// // ...
/// }
/// ```
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Clone, Debug)]
pub struct ManiaGradualDifficulty<'map> {
map: Cow<'map, Beatmap>,
inner: ManiaGradualDifficultyInner,
}
impl<'map> ManiaGradualDifficulty<'map> {
/// Create a new difficulty attributes iterator for osu!mania maps.
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
let map = map.convert_mode(GameMode::Mania);
let is_convert = matches!(map, Cow::Owned(_));
let inner = ManiaGradualDifficultyInner::new(map.as_ref(), is_convert, mods);
Self { map, inner }
}
pub(crate) const fn idx(&self) -> usize {
self.inner.idx
}
}
impl Iterator for ManiaGradualDifficulty<'_> {
type Item = ManiaDifficultyAttributes;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
self.inner.next(&self.map.hit_objects)
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
self.inner.size_hint()
}
#[inline]
fn nth(&mut self, n: usize) -> Option<Self::Item> {
self.inner.nth(n, &self.map.hit_objects)
}
}
impl ExactSizeIterator for ManiaGradualDifficulty<'_> {
#[inline]
fn len(&self) -> usize {
self.inner.len()
}
}
/// Gradually calculate the difficulty attributes of an osu!mania map.
///
/// Check [`ManiaGradualDifficulty`] for more information. This struct does the same
/// but takes ownership of [`Beatmap`] to avoid being bound to a lifetime.
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Clone, Debug)]
pub struct ManiaOwnedGradualDifficulty {
// Technically only `Beatmap::hit_objects` are required here but storing
// the full map lets us get away with not storing the map in `ManiaOwnedGradualPerformance`.
pub(crate) map: Beatmap,
inner: ManiaGradualDifficultyInner,
}
impl ManiaOwnedGradualDifficulty {
/// Create a new owned difficulty attributes iterator for osu!mania maps.
pub fn new(map: Beatmap, mods: u32) -> Self {
let converted_map = map.convert_mode(GameMode::Mania);
let is_convert = matches!(converted_map, Cow::Owned(_));
let inner = ManiaGradualDifficultyInner::new(&converted_map, is_convert, mods);
let map = match converted_map {
Cow::Owned(map) => map,
Cow::Borrowed(_) => map,
};
Self { map, inner }
}
#[allow(unused)]
pub(crate) const fn idx(&self) -> usize {
self.inner.idx
}
}
impl Iterator for ManiaOwnedGradualDifficulty {
type Item = ManiaDifficultyAttributes;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
self.inner.next(&self.map.hit_objects)
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
self.inner.size_hint()
}
#[inline]
fn nth(&mut self, n: usize) -> Option<Self::Item> {
self.inner.nth(n, &self.map.hit_objects)
}
}
impl ExactSizeIterator for ManiaOwnedGradualDifficulty {
#[inline]
fn len(&self) -> usize {
self.inner.len()
}
}
#[derive(Clone, Debug)]
struct ManiaGradualDifficultyInner {
pub(crate) idx: usize,
hit_window: f64,
strain: Strain,
diff_objects: Box<[ManiaDifficultyObject]>,
curr_combo: usize,
clock_rate: f64,
is_convert: bool,
}
impl ManiaGradualDifficultyInner {
fn new(map: &Beatmap, is_convert: bool, mods: u32) -> Self {
let total_columns = map.cs.round_even().max(1.0);
let clock_rate = mods.clock_rate();
let strain = Strain::new(total_columns as usize);
let BeatmapHitWindows { od: hit_window, .. } = map
.attributes()
.mods(mods)
.converted(is_convert)
.clock_rate(clock_rate)
.hit_windows();
let mut params = ObjectParameters::new(map);
let mut curr_combo = 0;
let mut hit_objects = map.hit_objects.iter();
let first = match hit_objects.next() {
Some(h) => {
let hit_object = ManiaObject::new(h, total_columns, &mut params);
increment_combo_raw(
h,
hit_object.start_time,
hit_object.end_time,
&mut curr_combo,
);
hit_object
}
None => {
return Self {
idx: 0,
hit_window,
strain,
diff_objects: Box::from([]),
curr_combo: 0,
clock_rate,
is_convert,
}
}
};
let diff_objects_iter = hit_objects.enumerate().scan(first, |last, (i, h)| {
let base = ManiaObject::new(h, total_columns, &mut params);
let diff_object = ManiaDifficultyObject::new(&base, &*last, clock_rate, i);
*last = base;
Some(diff_object)
});
let mut diff_objects = Vec::with_capacity(map.hit_objects.len() - 1);
diff_objects.extend(diff_objects_iter);
debug_assert_eq!(diff_objects.len(), diff_objects.capacity());
Self {
idx: 0,
hit_window,
strain,
diff_objects: diff_objects.into_boxed_slice(),
curr_combo,
clock_rate,
is_convert,
}
}
fn next(&mut self, hit_objects: &[HitObject]) -> Option<ManiaDifficultyAttributes> {
// The first difficulty object belongs to the second note since each difficulty
// object requires the current and the last note. Hence, if we're still on the first
// object, we don't have a difficulty object yet and just skip processing.
if self.idx > 0 {
let curr = self.diff_objects.get(self.idx - 1)?;
self.strain.process(curr, &self.diff_objects);
let h = &hit_objects[self.idx];
increment_combo(h, curr, &mut self.curr_combo, self.clock_rate);
} else if hit_objects.is_empty() {
return None;
}
self.idx += 1;
Some(ManiaDifficultyAttributes {
stars: self.strain.clone().difficulty_value() * STAR_SCALING_FACTOR,
hit_window: self.hit_window,
max_combo: self.curr_combo,
is_convert: self.is_convert,
n_objects: self.diff_objects.len() + 1,
})
}
const fn size_hint(&self) -> (usize, Option<usize>) {
let len = self.len();
(len, Some(len))
}
fn nth(&mut self, n: usize, hit_objects: &[HitObject]) -> Option<ManiaDifficultyAttributes> {
let skip_iter = self
.diff_objects
.iter()
.zip(hit_objects.iter().skip(1))
.skip(self.idx.saturating_sub(1));
let mut take = n.min(self.len().saturating_sub(1));
// The first note has no difficulty object
if self.idx == 0 && take > 0 {
take -= 1;
self.idx += 1;
}
for (curr, h) in skip_iter.take(take) {
increment_combo(h, curr, &mut self.curr_combo, self.clock_rate);
self.strain.process(curr, &self.diff_objects);
self.idx += 1;
}
self.next(hit_objects)
}
const fn len(&self) -> usize {
self.diff_objects.len() + 1 - self.idx
}
}
fn increment_combo(
h: &HitObject,
diff_obj: &ManiaDifficultyObject,
curr_combo: &mut usize,
clock_rate: f64,
) {
increment_combo_raw(
h,
diff_obj.start_time * clock_rate,
diff_obj.end_time * clock_rate,
curr_combo,
);
}
fn increment_combo_raw(h: &HitObject, start_time: f64, end_time: f64, curr_combo: &mut usize) {
match h.kind {
HitObjectKind::Circle => *curr_combo += 1,
_ => *curr_combo += 1 + ((end_time - start_time) / 100.0) as usize,
}
}
-178
View File
@@ -1,178 +0,0 @@
#![cfg(feature = "gradual")]
use crate::{Beatmap, ManiaPP};
use super::{
ManiaGradualDifficulty, ManiaOwnedGradualDifficulty, ManiaPerformanceAttributes,
ManiaScoreState,
};
/// Gradually calculate the performance attributes of an osu!mania map.
///
/// After each hit object you can call [`next`](`ManiaGradualPerformance::next`)
/// and it will return the resulting current [`ManiaPerformanceAttributes`].
/// To process multiple objects at once, use [`nth`](`ManiaGradualPerformance::nth`) instead.
///
/// Both methods require a play's current score so far.
/// Be sure the given score is adjusted with respect to mods.
///
/// If you only want to calculate difficulty attributes use
/// [`ManiaGradualDifficulty`] instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, mania::{ManiaGradualPerformance, ManiaScoreState}};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut gradual_perf = ManiaGradualPerformance::new(&map, mods);
/// let mut state = ManiaScoreState::new(); // empty state, everything is on 0.
///
/// // The first 10 hitresults are 320s
/// for _ in 0..10 {
/// state.n320 += 1;
///
/// # /*
/// let performance = gradual_perf.next(score).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.next(state.clone());
/// }
///
/// // Then comes a miss.
/// state.n_misses += 1;
/// # /*
/// let performance = gradual_perf.next(score).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.next(state.clone());
///
/// // The next 10 objects will be a mixture of 320s and 100s.
/// // Notice how all 10 objects will be processed in one go.
/// state.n320 += 3;
/// state.n100 += 7;
/// // The `nth` method takes a zero-based value.
/// # /*
/// let performance = gradual_perf.nth(score, 9).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.nth(state.clone(), 9);
///
/// // Skip to the end
/// # /*
/// state.max_combo = ...
/// state.n300 = ...
/// state.n100 = ...
/// state.n_misses = ...
/// let final_performance = gradual_perf.nth(state.clone(), usize::MAX).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.nth(state.clone(), usize::MAX);
///
/// // Once the final performance was calculated,
/// // attempting to process further objects will return `None`.
/// assert!(gradual_perf.next(state).is_none());
/// ```
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Clone, Debug)]
pub struct ManiaGradualPerformance<'map> {
difficulty: ManiaGradualDifficulty<'map>,
performance: ManiaPP<'map>,
}
impl<'map> ManiaGradualPerformance<'map> {
/// Create a new gradual performance calculator for osu!mania maps.
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
let difficulty = ManiaGradualDifficulty::new(map, mods);
let performance = ManiaPP::new(map).mods(mods).passed_objects(0);
Self {
difficulty,
performance,
}
}
/// Process the next hit object and calculate the
/// performance attributes for the resulting score.
pub fn next(&mut self, state: ManiaScoreState) -> Option<ManiaPerformanceAttributes> {
self.nth(state, 0)
}
/// Process all remaining hit objects and calculate the final performance attributes.
pub fn last(&mut self, state: ManiaScoreState) -> Option<ManiaPerformanceAttributes> {
self.nth(state, usize::MAX)
}
/// Process everything up the the next `n`th hit object and calculate the performance
/// attributes for the resulting score state.
///
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
/// and so on.
pub fn nth(&mut self, state: ManiaScoreState, n: usize) -> Option<ManiaPerformanceAttributes> {
let difficulty = self.difficulty.nth(n)?;
let performance = self
.performance
.clone()
.attributes(difficulty)
.state(state)
.passed_objects(self.difficulty.idx())
.calculate();
Some(performance)
}
}
/// Gradually calculate the performance attributes of an osu!mania map.
///
/// Check [`ManiaGradualPerformance`] for more information. This struct does the same
/// but takes ownership of [`Beatmap`] to avoid being bound to a lifetime.
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Clone, Debug)]
pub struct ManiaOwnedGradualPerformance {
difficulty: ManiaOwnedGradualDifficulty,
mods: u32,
}
impl ManiaOwnedGradualPerformance {
/// Create a new gradual performance calculator for osu!mania maps.
pub fn new(map: Beatmap, mods: u32) -> Self {
let difficulty = ManiaOwnedGradualDifficulty::new(map, mods);
Self { difficulty, mods }
}
/// Process the next hit object and calculate the
/// performance attributes for the resulting score.
pub fn next(&mut self, state: ManiaScoreState) -> Option<ManiaPerformanceAttributes> {
self.nth(state, 0)
}
/// Process all remaining hit objects and calculate the final performance attributes.
pub fn last(&mut self, state: ManiaScoreState) -> Option<ManiaPerformanceAttributes> {
self.nth(state, usize::MAX)
}
/// Process everything up the the next `n`th hit object and calculate the performance
/// attributes for the resulting score state.
///
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
/// and so on.
pub fn nth(&mut self, state: ManiaScoreState, n: usize) -> Option<ManiaPerformanceAttributes> {
let difficulty = self.difficulty.nth(n)?;
let performance = ManiaPP::new(&self.difficulty.map)
.mods(self.mods)
.attributes(difficulty)
.state(state)
.passed_objects(self.difficulty.idx())
.calculate();
Some(performance)
}
}
-103
View File
@@ -1,103 +0,0 @@
use crate::{
curve::{Curve, CurveBuffers},
parse::{HitObject, HitObjectKind},
Beatmap,
};
const BASE_SCORING_DISTANCE: f64 = 100.0;
pub(crate) struct ObjectParameters<'a> {
pub(crate) map: &'a Beatmap,
pub(crate) max_combo: usize,
pub(crate) curve_bufs: CurveBuffers,
}
impl<'a> ObjectParameters<'a> {
pub(crate) fn new(map: &'a Beatmap) -> Self {
Self {
map,
max_combo: 0,
curve_bufs: CurveBuffers::default(),
}
}
}
/// A [`HitObject`] that was processed for the osu!mania gamemode.
#[derive(Clone, Debug, PartialEq)]
pub struct ManiaObject {
/// Start time of the object.
pub start_time: f64,
/// Endtime of the object.
pub end_time: f64,
/// Column of the object.
pub column: usize,
}
impl ManiaObject {
pub(crate) fn column(x: f32, total_columns: f32) -> usize {
let x_divisor = 512.0 / total_columns;
(x / x_divisor).floor().min(total_columns - 1.0) as usize
}
pub(crate) fn new(
h: &HitObject,
total_columns: f32,
params: &mut ObjectParameters<'_>,
) -> Self {
let ObjectParameters {
map,
max_combo,
curve_bufs,
} = params;
let column = Self::column(h.pos.x, total_columns);
*max_combo += 1;
match &h.kind {
HitObjectKind::Circle => Self {
start_time: h.start_time,
end_time: h.start_time,
column,
},
HitObjectKind::Slider {
pixel_len,
repeats,
control_points,
..
} => {
let span_count = *repeats as f64 + 1.0;
let curve = Curve::new(control_points, *pixel_len, curve_bufs);
let dist = curve.dist();
let timing_point = map.timing_point_at(h.start_time);
let difficulty_point = map.difficulty_point_at(h.start_time).unwrap_or_default();
let scoring_dist =
BASE_SCORING_DISTANCE * map.slider_mult * difficulty_point.slider_vel;
let vel = scoring_dist / timing_point.beat_len;
let duration = span_count * dist / vel;
let end_time = h.start_time + duration;
*max_combo += (duration / 100.0) as usize;
Self {
start_time: h.start_time,
end_time,
column,
}
}
HitObjectKind::Spinner { end_time } | HitObjectKind::Hold { end_time } => {
*max_combo += ((*end_time - h.start_time) / 100.0) as usize;
Self {
start_time: h.start_time,
end_time: *end_time,
column,
}
}
}
}
}
+34 -329
View File
@@ -1,345 +1,50 @@
mod difficulty_object;
mod mania_object;
mod pp;
mod score_state;
mod skills;
#[cfg(feature = "gradual")]
mod gradual_difficulty;
#[cfg(feature = "gradual")]
mod gradual_performance;
use std::borrow::Cow;
use crate::{beatmap::BeatmapHitWindows, util::FloatExt, Beatmap, GameMode, Mods, OsuStars};
use crate::{
any::ModeDifficulty,
model::{
beatmap::Beatmap,
mode::{ConvertStatus, IGameMode},
},
};
pub use self::{mania_object::ManiaObject, pp::*, score_state::ManiaScoreState};
#[cfg(feature = "gradual")]
pub use self::{
gradual_difficulty::{ManiaGradualDifficulty, ManiaOwnedGradualDifficulty},
gradual_performance::{ManiaGradualPerformance, ManiaOwnedGradualPerformance},
attributes::{ManiaDifficultyAttributes, ManiaPerformanceAttributes},
convert::ManiaBeatmap,
performance::ManiaPerformance,
score_state::ManiaScoreState,
strains::ManiaStrains,
};
pub(crate) use self::mania_object::ObjectParameters;
mod attributes;
mod convert;
mod difficulty;
mod object;
mod performance;
mod score_state;
mod strains;
use self::{
difficulty_object::ManiaDifficultyObject,
skills::{Skill, Strain},
};
const SECTION_LEN: f64 = 400.0;
const STAR_SCALING_FACTOR: f64 = 0.018;
/// Difficulty calculator on osu!mania maps.
/// Marker type for [`GameMode::Mania`].
///
/// # Example
///
/// ```
/// use rosu_pp::{ManiaStars, Beatmap};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let difficulty_attrs = ManiaStars::new(&map)
/// .mods(8 + 64) // HDDT
/// .calculate();
///
/// println!("Stars: {}", difficulty_attrs.stars);
/// ```
#[derive(Clone, Debug)]
#[must_use]
pub struct ManiaStars<'map> {
map: Cow<'map, Beatmap>,
mods: u32,
passed_objects: Option<usize>,
clock_rate: Option<f64>,
is_convert: bool,
}
/// [`GameMode::Mania`]: rosu_map::section::general::GameMode::Mania
pub struct Mania;
impl<'map> ManiaStars<'map> {
/// Create a new difficulty calculator for osu!mania maps.
#[inline]
pub fn new(map: &'map Beatmap) -> Self {
let map = map.convert_mode(GameMode::Mania);
let is_convert = matches!(map, Cow::Owned(_));
impl IGameMode for Mania {
type DifficultyAttributes = ManiaDifficultyAttributes;
type Strains = ManiaStrains;
Self {
map,
mods: 0,
passed_objects: None,
clock_rate: None,
is_convert,
}
fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus {
convert::try_convert(map)
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
#[inline]
pub const fn mods(mut self, mods: u32) -> Self {
self.mods = mods;
self
fn difficulty(
difficulty: &ModeDifficulty,
converted: &ManiaBeatmap<'_>,
) -> Self::DifficultyAttributes {
difficulty::difficulty(difficulty, converted)
}
/// Amount of passed objects for partial plays, e.g. a fail.
///
#[cfg_attr(
feature = "gradual",
doc = "If you want to calculate the difficulty after every few objects, instead of
using [`ManiaStars`] multiple times with different `passed_objects`, you should use
[`ManiaGradualDifficultyAttributes`](crate::mania::ManiaGradualDifficulty)."
)]
#[inline]
pub const fn passed_objects(mut self, passed_objects: usize) -> Self {
self.passed_objects = Some(passed_objects);
self
}
/// Adjust the clock rate used in the calculation.
/// If none is specified, it will take the clock rate based on the mods
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
#[inline]
pub const fn clock_rate(mut self, clock_rate: f64) -> Self {
self.clock_rate = Some(clock_rate);
self
}
/// Specify whether the map is a convert i.e. an osu!standard map.
///
/// This only needs to be specified if the map was converted manually beforehand.
#[inline]
pub const fn is_convert(mut self, is_convert: bool) -> Self {
self.is_convert = is_convert;
self
}
/// Calculate all difficulty related values, including stars.
#[inline]
pub fn calculate(self) -> ManiaDifficultyAttributes {
let is_convert = self.is_convert || matches!(self.map, Cow::Owned(_));
let clock_rate = self.clock_rate.unwrap_or_else(|| self.mods.clock_rate());
let BeatmapHitWindows { od: hit_window, .. } = self
.map
.attributes()
.mods(self.mods)
.converted(is_convert)
.clock_rate(clock_rate)
.hit_windows();
let n_objects = self.map.hit_objects.len();
let ManiaResult { strain, max_combo } = calculate_result(self);
ManiaDifficultyAttributes {
stars: strain.difficulty_value() * STAR_SCALING_FACTOR,
hit_window,
max_combo,
n_objects,
is_convert,
}
}
/// Calculate the skill strains.
///
/// Suitable to plot the difficulty of a map over time.
#[inline]
pub fn strains(self) -> ManiaStrains {
let ManiaResult { strain, .. } = calculate_result(self);
ManiaStrains {
section_len: SECTION_LEN,
strains: strain.strain_peaks.to_vec(),
}
}
}
/// The result of calculating the strains on a osu!taiko map.
/// Suitable to plot the difficulty of a map over time.
#[derive(Clone, Debug)]
pub struct ManiaStrains {
/// Time in ms inbetween two strains.
pub section_len: f64,
/// Strain peaks of the strain skill.
pub strains: Vec<f64>,
}
impl ManiaStrains {
/// Returns the number of strain peaks per skill.
#[inline]
#[allow(clippy::len_without_is_empty)]
pub fn len(&self) -> usize {
self.strains.len()
}
}
fn calculate_result(params: ManiaStars<'_>) -> ManiaResult {
let ManiaStars {
map,
mods,
passed_objects,
clock_rate,
is_convert: _,
} = params;
let take = passed_objects.unwrap_or(map.hit_objects.len());
let total_columns = map.cs.round_even().max(1.0);
let clock_rate = clock_rate.unwrap_or_else(|| mods.clock_rate());
let mut strain = Strain::new(total_columns as usize);
let mut params = ObjectParameters::new(map.as_ref());
let mut hit_objects = map.hit_objects.iter().take(take);
let first = match hit_objects.next() {
Some(h) => ManiaObject::new(h, total_columns, &mut params),
None => {
return ManiaResult {
strain,
max_combo: 0,
}
}
};
let diff_objects_iter = hit_objects.enumerate().scan(first, |last, (i, h)| {
let base = ManiaObject::new(h, total_columns, &mut params);
let diff_object = ManiaDifficultyObject::new(&base, &*last, clock_rate, i);
*last = base;
Some(diff_object)
});
let mut diff_objects = Vec::with_capacity(map.hit_objects.len().min(take).saturating_sub(1));
diff_objects.extend(diff_objects_iter);
for curr in diff_objects.iter() {
strain.process(curr, &diff_objects);
}
ManiaResult {
strain,
max_combo: params.max_combo,
}
}
struct ManiaResult {
strain: Strain,
max_combo: usize,
}
/// The result of a difficulty calculation on an osu!mania map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ManiaDifficultyAttributes {
/// The final star rating.
pub stars: f64,
/// The perceived hit window for an n300 inclusive of rate-adjusting mods (DT/HT/etc).
pub hit_window: f64,
/// The amount of hitobjects in the map.
pub n_objects: usize,
/// The maximum achievable combo.
pub max_combo: usize,
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
pub is_convert: bool,
}
impl ManiaDifficultyAttributes {
/// Return the maximum combo.
#[inline]
pub const fn max_combo(&self) -> usize {
self.max_combo
}
/// Return the amount of hitobjects.
#[inline]
pub const fn n_objects(&self) -> usize {
self.n_objects
}
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
#[inline]
pub const fn is_convert(&self) -> bool {
self.is_convert
}
/// Returns a builder for performance calculation.
#[inline]
pub fn pp(self) -> ManiaPP<'static> {
ManiaPP::from(self)
}
}
/// The result of a performance calculation on an osu!mania map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ManiaPerformanceAttributes {
/// The difficulty attributes that were used for the performance calculation.
pub difficulty: ManiaDifficultyAttributes,
/// The final performance points.
pub pp: f64,
/// The difficulty portion of the final pp.
pub pp_difficulty: f64,
}
impl ManiaPerformanceAttributes {
/// Return the star value.
#[inline]
pub const fn stars(&self) -> f64 {
self.difficulty.stars
}
/// Return the performance point value.
#[inline]
pub const fn pp(&self) -> f64 {
self.pp
}
/// Return the maximum combo of the map.
#[inline]
pub const fn max_combo(&self) -> usize {
self.difficulty.max_combo
}
/// Return the amount of hitobjects.
#[inline]
pub const fn n_objects(&self) -> usize {
self.difficulty.n_objects
}
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
#[inline]
pub const fn is_convert(&self) -> bool {
self.difficulty.is_convert
}
}
impl From<ManiaPerformanceAttributes> for ManiaDifficultyAttributes {
#[inline]
fn from(attributes: ManiaPerformanceAttributes) -> Self {
attributes.difficulty
}
}
impl<'map> From<OsuStars<'map>> for ManiaStars<'map> {
#[inline]
fn from(osu: OsuStars<'map>) -> Self {
let OsuStars {
map,
mods,
passed_objects,
clock_rate,
} = osu;
Self {
map: map.convert_mode(GameMode::Mania),
mods,
passed_objects,
clock_rate,
is_convert: true,
}
fn strains(difficulty: &ModeDifficulty, converted: &ManiaBeatmap<'_>) -> Self::Strains {
strains::strains(difficulty, converted)
}
}
+95
View File
@@ -0,0 +1,95 @@
use rosu_map::section::hit_objects::CurveBuffers;
use crate::model::{
beatmap::Beatmap,
control_point::{DifficultyPoint, TimingPoint},
hit_object::{HitObject, HitObjectKind, HoldNote, Spinner},
};
pub struct ManiaObject {
pub start_time: f64,
pub end_time: f64,
pub column: usize,
}
impl ManiaObject {
pub fn new(h: &HitObject, total_columns: f32, params: &mut ObjectParams<'_>) -> Self {
let column = Self::column(h.pos.x, total_columns);
params.max_combo += 1;
match h.kind {
HitObjectKind::Circle => Self {
start_time: h.start_time,
end_time: h.start_time,
column,
},
HitObjectKind::Slider(ref slider) => {
const BASE_SCORING_DIST: f32 = 100.0;
let dist = slider.curve(&mut params.curve_bufs).dist();
let beat_len = params
.map
.timing_point_at(h.start_time)
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len);
let slider_velocity = params
.map
.difficulty_point_at(h.start_time)
.map_or(DifficultyPoint::DEFAULT_SLIDER_VELOCITY, |point| {
point.slider_velocity
});
let scoring_dist =
f64::from(BASE_SCORING_DIST) * params.map.slider_multiplier * slider_velocity;
let velocity = scoring_dist / beat_len;
let duration = (slider.span_count() as f64) * dist / velocity;
params.max_combo += (duration / 100.0) as u32;
Self {
start_time: h.start_time,
end_time: h.start_time + duration,
column,
}
}
HitObjectKind::Spinner(Spinner { end_time })
| HitObjectKind::Hold(HoldNote { end_time }) => {
params.max_combo += ((end_time - h.start_time) / 100.0) as u32;
Self {
start_time: h.start_time,
end_time,
column,
}
}
}
}
pub fn column(x: f32, total_columns: f32) -> usize {
let x_divisor = 512.0 / total_columns;
(x / x_divisor).floor().min(total_columns - 1.0) as usize
}
}
pub struct ObjectParams<'a> {
map: &'a Beatmap,
max_combo: u32,
curve_bufs: CurveBuffers,
}
impl<'a> ObjectParams<'a> {
pub fn new(map: &'a Beatmap) -> Self {
Self {
map,
max_combo: 0,
curve_bufs: CurveBuffers::default(),
}
}
pub fn into_max_combo(self) -> u32 {
self.max_combo
}
}
File diff suppressed because it is too large Load Diff
+10 -13
View File
@@ -1,35 +1,32 @@
/// Aggregation for a score's current state i.e. what are the current hitresults.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
/// Aggregation for a score's current state.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ManiaScoreState {
/// Amount of current 320s.
pub n320: usize,
pub n320: u32,
/// Amount of current 300s.
pub n300: usize,
pub n300: u32,
/// Amount of current 200s.
pub n200: usize,
pub n200: u32,
/// Amount of current 100s.
pub n100: usize,
pub n100: u32,
/// Amount of current 50s.
pub n50: usize,
pub n50: u32,
/// Amount of current misses.
pub n_misses: usize,
pub n_misses: u32,
}
impl ManiaScoreState {
/// Create a new empty score state.
#[inline]
pub fn new() -> Self {
Self::default()
}
/// Return the total amount of hits by adding everything up.
#[inline]
pub const fn total_hits(&self) -> usize {
pub const fn total_hits(&self) -> u32 {
self.n320 + self.n300 + self.n200 + self.n100 + self.n50 + self.n_misses
}
/// Calculate the accuracy between `0.0` and `1.0` for this state.
#[inline]
pub fn accuracy(&self) -> f64 {
let total_hits = self.total_hits();
@@ -40,6 +37,6 @@ impl ManiaScoreState {
let numerator = 6 * (self.n320 + self.n300) + 4 * self.n200 + 2 * self.n100 + self.n50;
let denominator = 6 * total_hits;
numerator as f64 / denominator as f64
f64::from(numerator) / f64::from(denominator)
}
}
-18
View File
@@ -1,18 +0,0 @@
mod strain;
mod traits;
pub(crate) use self::{
strain::Strain,
traits::{Skill, StrainDecaySkill, StrainSkill},
};
use super::difficulty_object::ManiaDifficultyObject;
fn previous(
diff_objects: &[ManiaDifficultyObject],
curr: usize,
backwards_idx: usize,
) -> Option<&ManiaDifficultyObject> {
curr.checked_sub(backwards_idx + 1)
.and_then(|idx| diff_objects.get(idx))
}
-211
View File
@@ -1,211 +0,0 @@
use crate::{mania::difficulty_object::ManiaDifficultyObject, util::CompactVec};
use super::{previous, Skill, StrainDecaySkill, StrainSkill};
#[derive(Clone, Debug)]
#[allow(clippy::struct_field_names)]
pub(crate) struct Strain {
start_times: Vec<f64>,
end_times: Vec<f64>,
individual_strains: Vec<f64>,
individual_strain: f64,
overall_strain: f64,
curr_strain: f64,
curr_section_peak: f64,
curr_section_end: f64,
pub(crate) strain_peaks: CompactVec,
}
impl Strain {
const INDIVIDUAL_DECAY_BASE: f64 = 0.125;
const OVERALL_DECAY_BASE: f64 = 0.3;
const RELEASE_THRESHOLD: f64 = 24.0;
pub(crate) fn new(total_columns: usize) -> Self {
Self {
start_times: vec![0.0; total_columns],
end_times: vec![0.0; total_columns],
individual_strains: vec![0.0; total_columns],
individual_strain: 0.0,
overall_strain: 1.0,
curr_strain: 0.0,
curr_section_peak: 0.0,
curr_section_end: 0.0,
strain_peaks: CompactVec::new(),
}
}
fn apply_decay(value: f64, delta_time: f64, decay_base: f64) -> f64 {
value * decay_base.powf(delta_time / 1000.0)
}
}
impl Skill for Strain {
#[inline]
fn process(&mut self, curr: &ManiaDifficultyObject, diff_objects: &[ManiaDifficultyObject]) {
<Self as StrainSkill>::process(self, curr, diff_objects);
}
#[inline]
fn difficulty_value(self) -> f64 {
<Self as StrainSkill>::difficulty_value(self)
}
}
impl StrainSkill for Strain {
const DECAY_WEIGHT: f64 = 0.9;
#[inline]
fn curr_section_end(&self) -> f64 {
self.curr_section_end
}
#[inline]
fn curr_section_end_mut(&mut self) -> &mut f64 {
&mut self.curr_section_end
}
#[inline]
fn curr_section_peak(&self) -> f64 {
self.curr_section_peak
}
#[inline]
fn curr_section_peak_mut(&mut self) -> &mut f64 {
&mut self.curr_section_peak
}
#[inline]
fn strain_peaks_mut(&mut self) -> &mut CompactVec {
&mut self.strain_peaks
}
#[inline]
fn strain_value_at(&mut self, curr: &ManiaDifficultyObject) -> f64 {
<Self as StrainDecaySkill>::strain_value_at(self, curr)
}
#[inline]
fn calculate_initial_strain(
&self,
time: f64,
curr: &ManiaDifficultyObject,
diff_objects: &[ManiaDifficultyObject],
) -> f64 {
<Self as StrainDecaySkill>::calculate_initial_strain(self, time, curr, diff_objects)
}
}
impl StrainDecaySkill for Strain {
const SKILL_MULTIPLIER: f64 = 1.0;
const STRAIN_DECAY_BASE: f64 = 1.0;
#[inline]
fn curr_strain(&self) -> f64 {
self.curr_strain
}
#[inline]
fn curr_strain_mut(&mut self) -> &mut f64 {
&mut self.curr_strain
}
fn strain_value_of(&mut self, curr: &ManiaDifficultyObject) -> f64 {
let mania_curr = curr;
let start_time = mania_curr.start_time;
let end_time = mania_curr.end_time;
let col = mania_curr.base_column;
let mut is_overlapping = false;
// * Lowest value we can assume with the current information
let mut closest_end_time = (end_time - start_time).abs();
// * Factor to all additional strains in case something else is held
let mut hold_factor = 1.0;
// * Addition to the current note in case it's a hold and has to be released awkwardly
let mut hold_addition = 0.0;
for i in 0..self.end_times.len() {
// * The current note is overlapped if a previous note or end is overlapping the current note body
is_overlapping |=
self.end_times[i] > start_time + 1.0 && end_time > self.end_times[i] + 1.0;
// * We give a slight bonus to everything if something is held meanwhile
if self.end_times[i] > end_time + 1.0 {
hold_factor = 1.25;
}
closest_end_time = (end_time - self.end_times[i]).abs().min(closest_end_time);
}
// * The hold addition is given if there was an overlap, however it is only valid if there are no other note with a similar ending.
// * Releasing multiple notes is just as easy as releasing 1. Nerfs the hold addition by half if the closest release is release_threshold away.
// * holdAddition
// * ^
// * 1.0 + - - - - - -+-----------
// * | /
// * 0.5 + - - - - -/ Sigmoid Curve
// * | /|
// * 0.0 +--------+-+---------------> Release Difference / ms
// * release_threshold
if is_overlapping {
hold_addition =
(1.0 + (0.5 * (Self::RELEASE_THRESHOLD - closest_end_time)).exp()).recip();
}
// * Decay and increase individualStrains in own column
self.individual_strains[col] = Self::apply_decay(
self.individual_strains[col],
start_time - self.start_times[col],
Self::INDIVIDUAL_DECAY_BASE,
);
self.individual_strains[col] += 2.0 * hold_factor;
// * For notes at the same time (in a chord), the individualStrain should be the hardest individualStrain out of those columns
self.individual_strain = if mania_curr.delta_time <= 1.0 {
self.individual_strain.max(self.individual_strains[col])
} else {
self.individual_strains[col]
};
// * Decay and increase overallStrain
self.overall_strain = Self::apply_decay(
self.overall_strain,
curr.delta_time,
Self::OVERALL_DECAY_BASE,
);
self.overall_strain += (1.0 + hold_addition) * hold_factor;
// * Update startTimes and endTimes arrays
self.start_times[col] = start_time;
self.end_times[col] = end_time;
// * By subtracting CurrentStrain, this skill effectively only considers the maximum strain of any one hitobject within each strain section.
self.individual_strain + self.overall_strain - self.curr_strain
}
fn calculate_initial_strain(
&self,
offset: f64,
curr: &ManiaDifficultyObject,
diff_objects: &[ManiaDifficultyObject],
) -> f64 {
let prev_start = previous(diff_objects, curr.idx, 0).map_or(0.0, |h| h.start_time);
let individual_decay = Self::apply_decay(
self.individual_strain,
offset - prev_start,
Self::INDIVIDUAL_DECAY_BASE,
);
let overall_decay = Self::apply_decay(
self.overall_strain,
offset - prev_start,
Self::OVERALL_DECAY_BASE,
);
individual_decay + overall_decay
}
}
-129
View File
@@ -1,129 +0,0 @@
use std::{cmp::Ordering, mem};
use crate::{
mania::{difficulty_object::ManiaDifficultyObject, SECTION_LEN},
util::CompactVec,
};
pub(crate) trait Skill {
fn process(&mut self, curr: &ManiaDifficultyObject, diff_objects: &[ManiaDifficultyObject]);
fn difficulty_value(self) -> f64;
}
pub(crate) trait StrainSkill: Sized + Skill {
const DECAY_WEIGHT: f64 = 0.9;
fn curr_section_end(&self) -> f64;
fn curr_section_end_mut(&mut self) -> &mut f64;
fn curr_section_peak(&self) -> f64;
fn curr_section_peak_mut(&mut self) -> &mut f64;
fn strain_peaks_mut(&mut self) -> &mut CompactVec;
fn strain_value_at(&mut self, curr: &ManiaDifficultyObject) -> f64;
fn process(&mut self, curr: &ManiaDifficultyObject, diff_objects: &[ManiaDifficultyObject]) {
// * The first object doesn't generate a strain, so we begin with an incremented section end
if curr.idx == 0 {
*self.curr_section_end_mut() = (curr.start_time / SECTION_LEN).ceil() * SECTION_LEN;
}
while curr.start_time > self.curr_section_end() {
self.save_curr_peak();
self.start_new_section_from(self.curr_section_end(), curr, diff_objects);
*self.curr_section_end_mut() += SECTION_LEN;
// Optimization to finish the loop early if
// the current peak is 0.0 i.e. it can't decay further.
// If final values don't coincide perfectly anymore,
// this should be looked at and maybe adjusted.
if self.curr_section_peak().abs() <= f64::EPSILON
&& curr.start_time > self.curr_section_end()
{
let remaining_time = curr.start_time - self.curr_section_end();
let remaining_iters = (remaining_time / SECTION_LEN).ceil();
*self.curr_section_end_mut() += remaining_iters * SECTION_LEN;
}
}
*self.curr_section_peak_mut() = self.strain_value_at(curr).max(self.curr_section_peak());
}
fn save_curr_peak(&mut self) {
let curr_section_peak = self.curr_section_peak();
self.strain_peaks_mut().push(curr_section_peak);
}
fn start_new_section_from(
&mut self,
time: f64,
curr: &ManiaDifficultyObject,
diff_objects: &[ManiaDifficultyObject],
) {
*self.curr_section_peak_mut() = self.calculate_initial_strain(time, curr, diff_objects);
}
fn calculate_initial_strain(
&self,
time: f64,
curr: &ManiaDifficultyObject,
diff_objects: &[ManiaDifficultyObject],
) -> f64;
fn get_curr_strain_peaks(mut self) -> CompactVec {
let mut peaks = mem::take(self.strain_peaks_mut());
peaks.push(self.curr_section_peak());
peaks
}
fn difficulty_value(self) -> f64 {
let mut difficulty = 0.0;
let mut weight = 1.0;
// * Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
// * These sections will not contribute to the difficulty.
let mut peaks = self.get_curr_strain_peaks();
peaks.retain(|peak| peak > 0.0);
let mut peaks = peaks.to_vec();
peaks.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
// * Difficulty is the weighted sum of the highest strains from every section.
// * We're sorting from highest to lowest strain.
for strain in peaks {
difficulty += strain * weight;
weight *= Self::DECAY_WEIGHT;
}
difficulty
}
}
pub(crate) trait StrainDecaySkill: StrainSkill {
const SKILL_MULTIPLIER: f64;
const STRAIN_DECAY_BASE: f64;
fn curr_strain(&self) -> f64;
fn curr_strain_mut(&mut self) -> &mut f64;
fn strain_value_of(&mut self, curr: &ManiaDifficultyObject) -> f64;
fn calculate_initial_strain(
&self,
time: f64,
curr: &ManiaDifficultyObject,
diff_objects: &[ManiaDifficultyObject],
) -> f64;
fn strain_value_at(&mut self, curr: &ManiaDifficultyObject) -> f64 {
*self.curr_strain_mut() *= self.strain_decay(curr.delta_time);
*self.curr_strain_mut() += self.strain_value_of(curr) * Self::SKILL_MULTIPLIER;
self.curr_strain()
}
fn strain_decay(&self, ms: f64) -> f64 {
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
}
}
+25
View File
@@ -0,0 +1,25 @@
use crate::{any::ModeDifficulty, mania::difficulty::DifficultyValues};
use super::convert::ManiaBeatmap;
/// The result of calculating the strains on a osu!mania map.
///
/// Suitable to plot the difficulty of a map over time.
#[derive(Clone, Debug, PartialEq)]
pub struct ManiaStrains {
/// Strain peaks of the strain skill.
pub strains: Vec<f64>,
}
impl ManiaStrains {
/// Time between two strains in ms.
pub const SECTION_LEN: f64 = 400.0;
}
pub fn strains(difficulty: &ModeDifficulty, converted: &ManiaBeatmap<'_>) -> ManiaStrains {
let values = DifficultyValues::calculate(difficulty, converted);
ManiaStrains {
strains: values.strain.get_curr_strain_peaks(),
}
}
@@ -1,4 +1,8 @@
use crate::{Beatmap, GameMode, Mods};
use rosu_map::section::general::GameMode;
use crate::util::mods::Mods;
use super::{converted::Converted, Beatmap};
/// Summary struct for a [`Beatmap`]'s attributes.
#[derive(Clone, Debug, PartialEq)]
@@ -14,30 +18,29 @@ pub struct BeatmapAttributes {
/// The clock rate with respect to mods.
pub clock_rate: f64,
/// The hit windows for approach rate and overall difficulty.
pub hit_windows: BeatmapHitWindows,
pub hit_windows: HitWindows,
}
#[derive(Copy, Clone, Debug, PartialEq)]
/// AR and OD hit windows
pub struct BeatmapHitWindows {
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct HitWindows {
/// Hit window for approach rate i.e. TimePreempt in milliseconds.
pub ar: f64,
/// Hit window for overall difficulty i.e. time to hit a 300 ("Great") in milliseconds.
pub od: f64,
}
/// A builder for [`BeatmapAttributes`] and [`HitWindows`].
#[derive(Clone, Debug, Default, PartialEq)]
/// Specify values for this builder to get [`BeatmapAttributes`] or [`BeatmapHitWindows`] based on
/// mods & co.
pub struct BeatmapAttributesBuilder {
mode: GameMode,
ar: f32,
od: f32,
cs: f32,
hp: f32,
mods: Option<u32>,
mods: u32,
clock_rate: Option<f64>,
converted: bool,
is_convert: bool,
}
impl BeatmapAttributesBuilder {
@@ -49,21 +52,24 @@ impl BeatmapAttributesBuilder {
const TAIKO_AVG: f64 = 35.0;
const TAIKO_MAX: f64 = 20.0;
#[inline]
/// Create a new [`BeatmapAttributesBuilder`].
pub fn new(map: &Beatmap) -> Self {
Self::from(map)
map.into()
}
#[inline]
/// Specify the mode.
pub fn mode(&mut self, mode: GameMode) -> &mut Self {
self.mode = mode;
self
const fn new_internal(map: &Beatmap, is_convert: bool) -> Self {
Self {
mode: map.mode,
ar: map.ar,
od: map.od,
cs: map.cs,
hp: map.hp,
mods: 0,
clock_rate: None,
is_convert,
}
}
#[inline]
/// Specify the approach rate.
pub fn ar(&mut self, ar: f32) -> &mut Self {
self.ar = ar;
@@ -71,7 +77,6 @@ impl BeatmapAttributesBuilder {
self
}
#[inline]
/// Specify the overall difficulty.
pub fn od(&mut self, od: f32) -> &mut Self {
self.od = od;
@@ -79,7 +84,6 @@ impl BeatmapAttributesBuilder {
self
}
#[inline]
/// Specify the circle size.
pub fn cs(&mut self, cs: f32) -> &mut Self {
self.cs = cs;
@@ -87,7 +91,6 @@ impl BeatmapAttributesBuilder {
self
}
#[inline]
/// Specify the drain rate.
pub fn hp(&mut self, hp: f32) -> &mut Self {
self.hp = hp;
@@ -95,15 +98,13 @@ impl BeatmapAttributesBuilder {
self
}
#[inline]
/// Specify the mods.
pub fn mods(&mut self, mods: u32) -> &mut Self {
self.mods = Some(mods);
self.mods = mods;
self
}
#[inline]
/// Specify a custom clock rate.
pub fn clock_rate(&mut self, clock_rate: f64) -> &mut Self {
self.clock_rate = Some(clock_rate);
@@ -111,19 +112,9 @@ impl BeatmapAttributesBuilder {
self
}
#[inline]
/// Specify whether it's a converted map.
/// Only relevant for mania.
pub fn converted(&mut self, converted: bool) -> &mut Self {
self.converted = converted;
self
}
#[inline]
/// Calculate the AR and OD hit windows.
pub fn hit_windows(&self) -> BeatmapHitWindows {
let mods = self.mods.unwrap_or(0);
pub fn hit_windows(&self) -> HitWindows {
let mods = self.mods;
let clock_rate = self.clock_rate.unwrap_or_else(|| mods.clock_rate());
let mod_mult = |val: f32| {
@@ -164,7 +155,7 @@ impl BeatmapAttributesBuilder {
diff_range / clock_rate
}
GameMode::Mania => {
let mut value = if !self.converted {
let mut value = if !self.is_convert {
34.0 + 3.0 * (10.0 - self.od).clamp(0.0, 10.0)
} else if self.od > 4.0 {
34.0
@@ -182,7 +173,7 @@ impl BeatmapAttributesBuilder {
}
};
BeatmapHitWindows {
HitWindows {
ar: preempt,
od: hit_window,
}
@@ -190,7 +181,7 @@ impl BeatmapAttributesBuilder {
/// Calculate the [`BeatmapAttributes`].
pub fn build(&self) -> BeatmapAttributes {
let mods = self.mods.unwrap_or(0);
let mods = self.mods;
let clock_rate = self.clock_rate.unwrap_or_else(|| mods.clock_rate());
// HP
@@ -206,7 +197,7 @@ impl BeatmapAttributesBuilder {
}
let hit_windows = self.hit_windows();
let BeatmapHitWindows { ar, od } = hit_windows;
let HitWindows { ar, od } = hit_windows;
// AR
let ar = if ar > 1200.0 {
@@ -234,22 +225,17 @@ impl BeatmapAttributesBuilder {
}
impl From<&Beatmap> for BeatmapAttributesBuilder {
#[inline]
fn from(map: &Beatmap) -> Self {
Self {
mode: map.mode,
ar: map.ar,
od: map.od,
cs: map.cs,
hp: map.hp,
mods: None,
clock_rate: None,
converted: false,
}
Self::new_internal(map, false)
}
}
impl<M> From<&Converted<'_, M>> for BeatmapAttributesBuilder {
fn from(converted: &Converted<'_, M>) -> Self {
Self::new_internal(converted.map.as_ref(), converted.is_convert)
}
}
#[allow(clippy::similar_names)]
fn difficulty_range(difficulty: f64, min: f64, mid: f64, max: f64) -> f64 {
if difficulty > 5.0 {
mid + (max - mid) * (difficulty - 5.0) / 5.0
+66
View File
@@ -0,0 +1,66 @@
use std::collections::HashMap;
use crate::model::{control_point::TimingPoint, hit_object::HitObject};
pub fn bpm(last_hit_object: Option<&HitObject>, timing_points: &[TimingPoint]) -> f64 {
// This is incorrect if the last object is a slider since there
// is no reasonable way to get the slider end time at this point.
let last_time = last_hit_object
.map(HitObject::end_time)
.or_else(|| timing_points.last().map(|t| t.time))
.unwrap_or(0.0);
let mut bpm_points = BeatLenDuration::new(last_time);
// * osu-stable forced the first control point to start at 0.
// * This is reproduced here to maintain compatibility around
// * osu!mania scroll speed and song select display.
match timing_points {
[curr] => bpm_points.add(curr.beat_len, 0.0, last_time),
[curr, next, ..] => bpm_points.add(curr.beat_len, 0.0, next.time),
[] => {}
}
timing_points
.iter()
.skip(1)
.zip(timing_points.iter().skip(2).map(|t| t.time))
.for_each(|(curr, next_time)| bpm_points.add(curr.beat_len, curr.time, next_time));
if let [.., _, curr] = timing_points {
bpm_points.add(curr.beat_len, curr.time, last_time);
}
let most_common_beat_len = bpm_points
.map
.into_iter()
// * Get the most common one, or 0 as a suitable default
.max_by(|(_, a), (_, b)| a.total_cmp(b))
.map_or(0.0, |(beat_len, _)| f64::from_bits(beat_len));
60_000.0 / most_common_beat_len
}
/// Maps `beat_len` to a cumulative duration
struct BeatLenDuration {
last_time: f64,
map: HashMap<u64, f64>,
}
impl BeatLenDuration {
fn new(last_time: f64) -> Self {
Self {
last_time,
map: HashMap::default(),
}
}
fn add(&mut self, beat_len: f64, curr_time: f64, next_time: f64) {
let beat_len = (1000.0 * beat_len).round() / 1000.0;
let entry = self.map.entry(beat_len.to_bits()).or_default();
if curr_time <= self.last_time {
*entry += next_time - curr_time;
}
}
}
+186
View File
@@ -0,0 +1,186 @@
use std::{
any,
borrow::Cow,
fmt::{Debug, Formatter, Result as FmtResult},
marker::PhantomData,
};
use crate::model::mode::{ConvertStatus, IGameMode};
use super::{attributes::BeatmapAttributesBuilder, Beatmap};
const INCOMPATIBLE_MODES: &str = "the gamemodes were incompatible";
/// A [`Beatmap`] that is attached to a mode.
///
/// # Incompatibility
///
/// The following conversions are compatible:
/// - `Osu` → `Osu`
/// - `Taiko` → `Taiko`
/// - `Catch` → `Catch`
/// - `Mania` → `Mania`
/// - `Osu` → `Taiko`
/// - `Osu` → `Catch`
/// - `Osu` → `Mania`
///
/// All other conversions are incompatible.
pub struct Converted<'a, M> {
pub(crate) map: Cow<'a, Beatmap>,
pub(crate) is_convert: bool,
mode: PhantomData<M>,
}
impl<'a, M> Converted<'a, M> {
/// Initialize a [`Converted`] beatmap by promising the given map's mode
/// matches the generic type `M`.
pub(crate) const fn new(map: Cow<'a, Beatmap>, is_convert: bool) -> Self {
Self {
map,
is_convert,
mode: PhantomData,
}
}
}
impl<M: IGameMode> Converted<'_, M> {
/// Attempt to convert a [`Beatmap`] to the specified mode.
///
/// If the conversion is incompatible the [`Beatmap`] will be returned
/// unchanged as `Err`.
#[allow(clippy::result_large_err)]
pub fn try_from_owned(map: Beatmap) -> Result<Self, Beatmap> {
let mut map = Cow::Owned(map);
match M::try_convert(&mut map) {
ConvertStatus::Noop => Ok(Self::new(map, false)),
ConvertStatus::Done => Ok(Self::new(map, true)),
ConvertStatus::Incompatible => {
let Cow::Owned(map) = map else { unreachable!() };
Err(map)
}
}
}
/// Convert a [`Beatmap`] to the specified mode.
///
/// # Panics
///
/// Panics if the conversion is incompatible.
pub fn unchecked_from_owned(map: Beatmap) -> Self {
Self::try_from_owned(map).unwrap_or_else(|_| panic!("{}", INCOMPATIBLE_MODES))
}
/// Sum up the duration of all breaks (in milliseconds).
pub fn total_break_time(&self) -> f64 {
self.map.total_break_time()
}
/// Returns a [`BeatmapAttributesBuilder`] to calculate modified beatmap
/// attributes.
pub fn attributes(&self) -> BeatmapAttributesBuilder {
self.into()
}
/// The beats per minute of the map.
pub fn bpm(&self) -> f64 {
self.map.bpm()
}
}
impl<'a, M: IGameMode> Converted<'a, M> {
/// Borrow the contained [`Beatmap`] to cheaply create a new owned
/// [`Converted`].
///
/// This is the same as `.clone()` except cheap but its lifetime might be
/// shorter.
#[must_use]
pub fn as_owned(&'a self) -> Self {
Self::new(Cow::Borrowed(self.map.as_ref()), self.is_convert)
}
/// Attempt to convert a [`&Beatmap`] to the specified mode.
///
/// If the conversion is incompatible, `None` is returned.
///
/// [`&Beatmap`]: Beatmap
pub fn try_from_ref(map: &'a Beatmap) -> Option<Self> {
let mut map = Cow::Borrowed(map);
match M::try_convert(&mut map) {
ConvertStatus::Noop => Some(Self::new(map, false)),
ConvertStatus::Done => Some(Self::new(map, true)),
ConvertStatus::Incompatible => None,
}
}
/// Convert a [`&Beatmap`] to the specified mode.
///
/// # Panics
///
/// Panics if the conversion is incompatible.
///
/// [`&Beatmap`]: Beatmap
pub fn unchecked_from_ref(map: &'a Beatmap) -> Self {
Self::try_from_ref(map).expect(INCOMPATIBLE_MODES)
}
/// Attempt to convert a [`Converted`] from mode `M` to mode `N`.
///
/// If the conversion is incompatible the [`Converted`] will be returned
/// unchanged as `Err`.
#[allow(clippy::result_large_err)]
pub fn try_convert<N: IGameMode>(self) -> Result<Converted<'a, N>, Self> {
match self.map {
Cow::Borrowed(map) => Converted::<N>::try_from_ref(map).ok_or(self),
Cow::Owned(map) => Converted::<N>::try_from_owned(map)
.map_err(|map| Self::new(Cow::Owned(map), self.is_convert)),
}
}
}
impl<M> Clone for Converted<'_, M> {
fn clone(&self) -> Self {
Self::new(self.map.clone(), self.is_convert)
}
}
impl<M> Debug for Converted<'_, M> {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
struct GenericFormatter<T>(PhantomData<T>);
impl<T> Default for GenericFormatter<T> {
fn default() -> Self {
Self(PhantomData)
}
}
impl<T> Debug for GenericFormatter<T> {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
let full_type_name = any::type_name::<T>();
// Strip fully qualified syntax
if let Some(position) = full_type_name.rfind("::") {
if let Some(type_name) = full_type_name.get(position + 2..) {
f.write_str(type_name)?;
}
}
Ok(())
}
}
f.debug_struct("Converted")
.field("map", &self.map)
.field("is_convert", &self.is_convert)
.field("mode", &GenericFormatter::<M>::default())
.finish()
}
}
impl<M> PartialEq for Converted<'_, M> {
fn eq(&self, other: &Self) -> bool {
self.map == other.map && self.is_convert == other.is_convert
}
}
+706
View File
@@ -0,0 +1,706 @@
use std::{cmp, slice};
use rosu_map::{
section::{
difficulty::{Difficulty, DifficultyState, ParseDifficultyError},
events::{BreakPeriod, EventType, ParseEventTypeError},
general::{GameMode, GeneralKey, ParseGameModeError},
hit_objects::{
hit_samples::{HitSoundType, ParseHitSoundTypeError},
HitObjectType, ParseHitObjectTypeError, PathControlPoint, PathType,
},
timing_points::{ControlPoint, EffectFlags, ParseEffectFlagsError},
},
util::{KeyValue, ParseNumber, ParseNumberError, Pos, StrExt, MAX_PARSE_VALUE},
DecodeBeatmap, DecodeState,
};
use crate::{
model::{
control_point::{
difficulty_point_at, effect_point_at, DifficultyPoint, EffectPoint, TimingPoint,
},
hit_object::{HitObject, HitObjectKind, HoldNote, Slider, Spinner},
},
util::{float_ext::FloatExt, legacy_sort::legacy_sort, tandem_sort::TandemSorter},
};
use super::{Beatmap, DEFAULT_SLIDER_LENIENCY};
/// The state of a [`Beatmap`] for [`DecodeBeatmap`].
pub struct BeatmapState {
version: i32,
stack_leniency: f32,
mode: GameMode,
difficulty: DifficultyState,
breaks: Vec<BreakPeriod>,
timing_points: Vec<TimingPoint>,
difficulty_points: Vec<DifficultyPoint>,
effect_points: Vec<EffectPoint>,
hit_objects: Vec<HitObject>,
hit_sounds: Vec<HitSoundType>,
pending_control_points_time: f64,
pending_timing_point: Option<TimingPoint>,
pending_difficulty_point: Option<DifficultyPoint>,
pending_effect_point: Option<EffectPoint>,
curve_points: Vec<PathControlPoint>,
vertices: Vec<PathControlPoint>,
point_split: Vec<*const str>,
}
impl BeatmapState {
fn add_pending_point<P: Pending>(&mut self, time: f64, point: P, timing_change: bool) {
if time.not_eq(self.pending_control_points_time) {
self.flush_pending_points();
}
if timing_change {
point.push_front(self);
} else {
point.push_back(self);
}
self.pending_control_points_time = time;
}
fn flush_pending_points(&mut self) {
if let Some(point) = self.pending_timing_point.take() {
self.add_control_point(point);
}
if let Some(point) = self.pending_difficulty_point.take() {
self.add_control_point(point);
}
if let Some(point) = self.pending_effect_point.take() {
self.add_control_point(point);
}
}
fn add_control_point<P: ControlPoint<Self>>(&mut self, point: P) {
if !point.check_already_existing(self) {
point.add(self);
}
}
fn convert_path_str(&mut self, point_str: &str, offset: Pos) -> Result<(), ParseBeatmapError> {
let f = |this: &mut Self, point_split: &[&str]| {
let mut start_idx = 0;
let mut end_idx = 0;
let mut first = true;
while {
end_idx += 1;
end_idx < point_split.len()
} {
let is_letter = point_split[end_idx]
.chars()
.next()
.ok_or(ParseBeatmapError::InvalidHitObjectLine)?
.is_ascii_alphabetic();
if !is_letter {
continue;
}
let end_point = point_split.get(end_idx + 1).copied();
this.convert_points(&point_split[start_idx..end_idx], end_point, first, offset)?;
start_idx = end_idx;
first = false;
}
if end_idx > start_idx {
this.convert_points(&point_split[start_idx..end_idx], None, first, offset)?;
}
Ok(())
};
self.point_split(point_str.split('|'), f)
}
fn convert_points(
&mut self,
points: &[&str],
end_point: Option<&str>,
first: bool,
offset: Pos,
) -> Result<(), ParseBeatmapError> {
fn read_point(value: &str, start_pos: Pos) -> Result<PathControlPoint, ParseBeatmapError> {
let mut v = value
.split(':')
.map(|s| s.parse_with_limits(f64::from(MAX_COORDINATE_VALUE)));
let (x, y) = v
.next()
.zip(v.next())
.ok_or(ParseBeatmapError::InvalidHitObjectLine)?;
let pos = Pos::new(x? as i32 as f32, y? as i32 as f32);
Ok(PathControlPoint::new(pos - start_pos))
}
fn is_linear(p0: Pos, p1: Pos, p2: Pos) -> bool {
((p1.y - p0.y) * (p2.x - p0.x)).eq((p1.x - p0.x) * (p2.y - p0.y))
}
let mut path_type = points
.first()
.copied()
.map(PathType::new_from_str)
.ok_or(ParseBeatmapError::InvalidHitObjectLine)?;
let read_offset = usize::from(first);
let readable_points = points.len() - 1;
let end_point_len = usize::from(end_point.is_some());
self.vertices.clear();
self.vertices
.reserve(read_offset + readable_points + end_point_len);
if first {
self.vertices.push(PathControlPoint::default());
}
for &point in points.iter().skip(1) {
self.vertices.push(read_point(point, offset)?);
}
if let Some(end_point) = end_point {
self.vertices.push(read_point(end_point, offset)?);
}
if path_type == PathType::PERFECT_CURVE {
if let [a, b, c] = self.vertices.as_slice() {
if is_linear(a.pos, b.pos, c.pos) {
path_type = PathType::LINEAR;
}
} else {
path_type = PathType::BEZIER;
}
}
self.vertices[0].path_type = Some(path_type);
let mut start_idx = 0;
let mut end_idx = 0;
while {
end_idx += 1;
end_idx < self.vertices.len() - end_point_len
} {
if self.vertices[end_idx].pos != self.vertices[end_idx - 1].pos {
continue;
}
if path_type == PathType::CATMULL && end_idx > 1 {
continue;
}
if end_idx == self.vertices.len() - end_point_len - 1 {
continue;
}
self.vertices[end_idx - 1].path_type = Some(path_type);
self.curve_points.extend(&self.vertices[start_idx..end_idx]);
start_idx = end_idx + 1;
}
if end_idx > start_idx {
self.curve_points.extend(&self.vertices[start_idx..end_idx]);
}
Ok(())
}
fn point_split<'a, I, F, O>(&mut self, point_split: I, f: F) -> O
where
I: Iterator<Item = &'a str>,
F: FnOnce(&mut Self, &[&'a str]) -> O,
{
self.point_split.extend(point_split.map(|s| s as *const _));
let ptr = self.point_split.as_ptr();
let len = self.point_split.len();
// SAFETY:
// - *const str and &str have the same layout.
// - `self.point_split` is cleared after every use, ensuring that it
// does not contain any invalid pointers.
let point_split = unsafe { slice::from_raw_parts(ptr.cast(), len) };
let res = f(self, point_split);
self.point_split.clear();
res
}
}
impl DecodeState for BeatmapState {
fn create(version: i32) -> Self {
Self {
version,
stack_leniency: DEFAULT_SLIDER_LENIENCY,
mode: GameMode::Osu,
difficulty: DifficultyState::create(version),
breaks: Vec::new(),
timing_points: Vec::with_capacity(1),
difficulty_points: Vec::new(),
effect_points: Vec::with_capacity(32),
hit_objects: Vec::with_capacity(512),
hit_sounds: Vec::with_capacity(512),
pending_control_points_time: 0.0,
pending_timing_point: None,
pending_difficulty_point: None,
pending_effect_point: None,
curve_points: Vec::new(), // TODO: check for default size
vertices: Vec::new(), // TODO: check for default size
point_split: Vec::new(), // TODO: check for default size
}
}
}
impl From<BeatmapState> for Beatmap {
fn from(mut state: BeatmapState) -> Self {
state.flush_pending_points();
let Difficulty {
hp_drain_rate,
circle_size,
overall_difficulty,
approach_rate,
slider_multiplier,
slider_tick_rate,
} = state.difficulty.into();
let mut sorter = TandemSorter::new(
&state.hit_objects,
|a, b| a.start_time.total_cmp(&b.start_time),
true,
);
sorter.sort(&mut state.hit_objects);
sorter.sort(&mut state.hit_sounds);
if state.mode == GameMode::Mania {
legacy_sort(&mut state.hit_objects);
}
Beatmap {
version: state.version,
stack_leniency: state.stack_leniency,
mode: state.mode,
ar: approach_rate,
cs: circle_size,
hp: hp_drain_rate,
od: overall_difficulty,
slider_multiplier,
slider_tick_rate,
breaks: state.breaks,
timing_points: state.timing_points,
difficulty_points: state.difficulty_points,
effect_points: state.effect_points,
hit_objects: state.hit_objects,
hit_sounds: state.hit_sounds,
}
}
}
/// All the ways that parsing a [`Beatmap`] can fail.
#[derive(Debug, thiserror::Error)]
pub enum ParseBeatmapError {
#[error("failed to parse effect flags")]
EffectFlags(#[from] ParseEffectFlagsError),
#[error("failed to parse event type")]
EventType(#[from] ParseEventTypeError),
#[error("failed to parse hit object type")]
HitObjectType(#[from] ParseHitObjectTypeError),
#[error("failed to parse hit sound type")]
HitSoundType(#[from] ParseHitSoundTypeError),
#[error("invalid event line")]
InvalidEventLine,
#[error("repeat count is way too high")]
InvalidRepeatCount,
#[error("invalid timing point line")]
InvalidTimingPointLine,
#[error("invalid hit object line")]
InvalidHitObjectLine,
#[error("failed to parse mode")]
Mode(#[from] ParseGameModeError),
#[error("failed to parse number")]
Number(#[from] ParseNumberError),
#[error("beat length cannot be NaN in a timing control point")]
TimingControlPointNaN,
#[error("unknown hit object type")]
UnknownHitObjectType,
}
impl From<ParseDifficultyError> for ParseBeatmapError {
fn from(e: ParseDifficultyError) -> Self {
match e {
ParseDifficultyError::Number(e) => Self::Number(e),
}
}
}
const MAX_COORDINATE_VALUE: i32 = 131_072;
impl DecodeBeatmap for Beatmap {
type Error = ParseBeatmapError;
type State = BeatmapState;
fn parse_general(state: &mut Self::State, line: &str) -> Result<(), Self::Error> {
let Ok(KeyValue { key, value }) = KeyValue::parse(line.trim_comment()) else {
return Ok(());
};
match key {
GeneralKey::StackLeniency => state.stack_leniency = value.parse_num()?,
GeneralKey::Mode => state.mode = value.parse()?,
_ => {}
}
Ok(())
}
fn parse_editor(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
Ok(())
}
fn parse_metadata(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
Ok(())
}
fn parse_difficulty(state: &mut Self::State, line: &str) -> Result<(), Self::Error> {
Difficulty::parse_difficulty(&mut state.difficulty, line).map_err(ParseBeatmapError::from)
}
fn parse_events(state: &mut Self::State, line: &str) -> Result<(), Self::Error> {
let mut split = line.trim_comment().split(',');
let event_type: EventType = split
.next()
.ok_or(ParseBeatmapError::InvalidEventLine)?
.parse()?;
if event_type == EventType::Break {
let Some((start_time, end_time)) = split.next().zip(split.next()) else {
return Err(ParseBeatmapError::InvalidEventLine);
};
let start_time = f64::parse(start_time)?;
let end_time = start_time.max(f64::parse(end_time)?);
state.breaks.push(BreakPeriod {
start_time,
end_time,
});
}
Ok(())
}
fn parse_timing_points(state: &mut Self::State, line: &str) -> Result<(), Self::Error> {
let mut split = line.trim_comment().split(',');
let (time, beat_len) = split
.next()
.zip(split.next())
.ok_or(ParseBeatmapError::InvalidTimingPointLine)?;
let time = time.parse_num::<f64>()?;
// Manual `str::parse_num::<f64>` so that NaN does not cause an error
let beat_len = beat_len
.trim()
.parse::<f64>()
.map_err(ParseNumberError::InvalidFloat)?;
if beat_len < f64::from(-MAX_PARSE_VALUE) {
return Err(ParseNumberError::NumberUnderflow.into());
} else if beat_len > f64::from(MAX_PARSE_VALUE) {
return Err(ParseNumberError::NumberOverflow.into());
}
let speed_multiplier = if beat_len < 0.0 {
100.0 / -beat_len
} else {
1.0
};
let _ = split.next(); // timing signature
let _ = split.next(); // sample set
let _ = split.next(); // custom sample bank
let _ = split.next(); // sample volume
let timing_change = split
.next()
.map_or(true, |next| matches!(next.chars().next(), Some('1')));
let kiai = split
.next()
.map(str::parse::<EffectFlags>)
.transpose()?
.is_some_and(|flags| flags.has_flag(EffectFlags::KIAI));
if timing_change {
if beat_len.is_nan() {
return Err(ParseBeatmapError::TimingControlPointNaN);
}
let timing = TimingPoint::new(time, beat_len);
state.add_pending_point(time, timing, timing_change);
}
let difficulty = DifficultyPoint::new(time, beat_len, speed_multiplier);
state.add_pending_point(time, difficulty, timing_change);
let effect = EffectPoint::new(time, kiai);
state.add_pending_point(time, effect, timing_change);
state.pending_control_points_time = time;
Ok(())
}
fn parse_colors(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
Ok(())
}
fn parse_hit_objects(state: &mut Self::State, line: &str) -> Result<(), Self::Error> {
let mut split = line.trim_comment().split(',');
let (Some(x), Some(y), Some(start_time), Some(kind), Some(sound_type)) = (
split.next(),
split.next(),
split.next(),
split.next(),
split.next(),
) else {
return Err(ParseBeatmapError::InvalidHitObjectLine);
};
let pos = Pos {
x: x.parse_with_limits(MAX_COORDINATE_VALUE as f32)? as i32 as f32,
y: y.parse_with_limits(MAX_COORDINATE_VALUE as f32)? as i32 as f32,
};
let start_time = f64::parse(start_time)?;
let hit_object_type: HitObjectType = kind.parse()?;
let mut sound: HitSoundType = sound_type.parse()?;
let mut parse_custom_sound = |bank_info: Option<&str>| {
let mut split = match bank_info {
Some(s) if !s.is_empty() => s.split(':'),
_ => return Ok::<_, ParseNumberError>(()),
};
let _ = split.next().map(i32::parse).transpose()?; // normal bank
let _ = split.next().map(i32::parse).transpose()?; // additional bank
let _ = split.next().map(i32::parse).transpose()?; // custom sample bank
let _ = split.next().map(i32::parse).transpose()?; // volume
// filename
match split.next() {
None | Some("") => {}
Some(_) => sound = HitSoundType::default(),
}
Ok(())
};
let kind = if hit_object_type.has_flag(HitObjectType::CIRCLE) {
parse_custom_sound(split.next())?;
HitObjectKind::Circle
} else if hit_object_type.has_flag(HitObjectType::SLIDER) {
let (point_str, repeat_count) = split
.next()
.zip(split.next())
.ok_or(ParseBeatmapError::InvalidHitObjectLine)?;
let mut len = None;
let mut repeats = repeat_count.parse_num::<i32>()?;
if repeats > 9000 {
return Err(ParseBeatmapError::InvalidRepeatCount);
}
repeats = cmp::max(0, repeats - 1);
if let Some(next) = split.next() {
let new_len = next
.parse_with_limits(f64::from(MAX_COORDINATE_VALUE))?
.max(0.0);
if new_len.not_eq(0.0) {
len = Some(new_len);
}
}
let node_sounds = if let Some(sounds) = split.next().map(|sounds| sounds.split('|')) {
sounds.map(|s| s.parse().unwrap_or_default()).collect()
} else {
Box::default()
};
let _ = split.next(); // node banks
parse_custom_sound(split.next())?;
state.convert_path_str(point_str, pos)?;
let mut control_points = Vec::with_capacity(state.curve_points.len());
control_points.append(&mut state.curve_points);
let slider = Slider {
expected_dist: len,
repeats: repeats as usize,
control_points: control_points.into_boxed_slice(),
node_sounds,
};
HitObjectKind::Slider(slider)
} else if hit_object_type.has_flag(HitObjectType::SPINNER) {
let end_time = split
.next()
.ok_or(ParseBeatmapError::InvalidHitObjectLine)?
.parse_num::<f64>()?;
parse_custom_sound(split.next())?;
HitObjectKind::Spinner(Spinner { end_time })
} else if hit_object_type.has_flag(HitObjectType::HOLD) {
let end_time = if let Some(s) = split.next().filter(|s| !s.is_empty()) {
let (end_time, bank_info) = s
.split_once(':')
.ok_or(ParseBeatmapError::InvalidHitObjectLine)?;
parse_custom_sound(Some(bank_info))?;
end_time.parse_num::<f64>()?.max(start_time)
} else {
start_time
};
HitObjectKind::Hold(HoldNote { end_time })
} else {
return Err(ParseBeatmapError::UnknownHitObjectType);
};
state.hit_objects.push(HitObject {
pos,
start_time,
kind,
});
state.hit_sounds.push(sound);
Ok(())
}
fn parse_variables(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
Ok(())
}
fn parse_catch_the_beat(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
Ok(())
}
fn parse_mania(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
Ok(())
}
}
trait Pending: Sized {
fn pending(state: &mut BeatmapState) -> &mut Option<Self>;
fn push_front(self, state: &mut BeatmapState) {
let pending = Self::pending(state);
if pending.is_none() {
*pending = Some(self);
}
}
fn push_back(self, state: &mut BeatmapState) {
*Self::pending(state) = Some(self);
}
}
impl Pending for TimingPoint {
fn pending(state: &mut BeatmapState) -> &mut Option<Self> {
&mut state.pending_timing_point
}
}
impl Pending for DifficultyPoint {
fn pending(state: &mut BeatmapState) -> &mut Option<Self> {
&mut state.pending_difficulty_point
}
}
impl Pending for EffectPoint {
fn pending(state: &mut BeatmapState) -> &mut Option<Self> {
&mut state.pending_effect_point
}
}
impl ControlPoint<BeatmapState> for TimingPoint {
fn check_already_existing(&self, _: &BeatmapState) -> bool {
false
}
fn add(self, state: &mut BeatmapState) {
match state
.timing_points
.binary_search_by(|probe| probe.time.total_cmp(&self.time))
{
Err(i) => state.timing_points.insert(i, self),
Ok(i) => state.timing_points[i] = self,
}
}
}
impl ControlPoint<BeatmapState> for DifficultyPoint {
fn check_already_existing(&self, state: &BeatmapState) -> bool {
match difficulty_point_at(&state.difficulty_points, self.time) {
Some(existing) => self.is_redundant(existing),
None => self.is_redundant(&DifficultyPoint::default()),
}
}
fn add(self, state: &mut BeatmapState) {
match state
.difficulty_points
.binary_search_by(|probe| probe.time.total_cmp(&self.time))
{
Err(i) => state.difficulty_points.insert(i, self),
Ok(i) => state.difficulty_points[i] = self,
}
}
}
impl ControlPoint<BeatmapState> for EffectPoint {
fn check_already_existing(&self, state: &BeatmapState) -> bool {
match effect_point_at(&state.effect_points, self.time) {
Some(existing) => self.is_redundant(existing),
None => self.is_redundant(&EffectPoint::default()),
}
}
fn add(self, state: &mut BeatmapState) {
match state
.effect_points
.binary_search_by(|probe| probe.time.total_cmp(&self.time))
{
Err(i) => state.effect_points.insert(i, self),
Ok(i) => state.effect_points[i] = self,
}
}
}
+199
View File
@@ -0,0 +1,199 @@
use std::{io, path::Path, str::FromStr};
use rosu_map::{
section::{events::BreakPeriod, general::GameMode, hit_objects::hit_samples::HitSoundType},
LATEST_FORMAT_VERSION,
};
pub use self::{
attributes::{BeatmapAttributes, BeatmapAttributesBuilder, HitWindows},
converted::Converted,
decode::{BeatmapState, ParseBeatmapError},
};
use super::{
control_point::{
difficulty_point_at, effect_point_at, timing_point_at, DifficultyPoint, EffectPoint,
TimingPoint,
},
hit_object::HitObject,
mode::IGameMode,
};
mod attributes;
mod bpm;
mod converted;
mod decode;
/// All beatmap data that is relevant for difficulty and performance
/// calculation.
#[derive(Clone, Debug, PartialEq)]
pub struct Beatmap {
pub version: i32,
// General
pub stack_leniency: f32,
pub mode: GameMode,
// Difficulty
pub ar: f32,
pub cs: f32,
pub hp: f32,
pub od: f32,
pub slider_multiplier: f64,
pub slider_tick_rate: f64,
// Events
pub breaks: Vec<BreakPeriod>,
// TimingPoints
pub timing_points: Vec<TimingPoint>,
pub difficulty_points: Vec<DifficultyPoint>,
pub effect_points: Vec<EffectPoint>,
// HitObjects
pub hit_objects: Vec<HitObject>,
pub hit_sounds: Vec<HitSoundType>,
}
impl Beatmap {
/// Parse a [`Beatmap`] by providing a path to a `.osu` file.
pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, io::Error> {
rosu_map::from_path(path)
}
/// Parse a [`Beatmap`] by providing the content of a `.osu` file as a
/// slice of bytes.
pub fn from_bytes(bytes: &[u8]) -> Result<Self, io::Error> {
rosu_map::from_bytes(bytes)
}
/// Finds the [`TimingPoint`] that is active at the given time.
pub(crate) fn timing_point_at(&self, time: f64) -> Option<&TimingPoint> {
timing_point_at(&self.timing_points, time)
}
/// Finds the [`DifficultyPoint`] that is active at the given time.
pub(crate) fn difficulty_point_at(&self, time: f64) -> Option<&DifficultyPoint> {
difficulty_point_at(&self.difficulty_points, time)
}
/// Finds the [`EffectPoint`] that is active at the given time.
pub(crate) fn effect_point_at(&self, time: f64) -> Option<&EffectPoint> {
effect_point_at(&self.effect_points, time)
}
/// Sum up the duration of all breaks (in milliseconds).
pub fn total_break_time(&self) -> f64 {
self.breaks.iter().map(BreakPeriod::duration).sum()
}
/// Attempt to convert a [`&Beatmap`] to the specified mode.
///
/// If the conversion is incompatible, `None` is returned.
///
/// [`&Beatmap`]: Beatmap
pub fn try_as_converted<M: IGameMode>(&self) -> Option<Converted<'_, M>> {
Converted::try_from_ref(self)
}
/// Convert a [`&Beatmap`] to the specified mode.
///
/// # Panics
///
/// Panics if the conversion is incompatible.
///
/// [`&Beatmap`]: Beatmap
pub fn unchecked_as_converted<M: IGameMode>(&self) -> Converted<'_, M> {
Converted::unchecked_from_ref(self)
}
/// Attempt to convert a [`Beatmap`] to the specified mode.
///
/// If the conversion is incompatible the [`Beatmap`] will be returned
/// unchanged as `Err`.
#[allow(clippy::result_large_err)]
pub fn try_into_converted<'a, M: IGameMode>(self) -> Result<Converted<'a, M>, Self> {
Converted::try_from_owned(self)
}
/// Convert a [`Beatmap`] to the specified mode.
///
/// # Panics
///
/// Panics if the conversion is incompatible.
pub fn unchecked_into_converted<'a, M: IGameMode>(self) -> Converted<'a, M> {
Converted::unchecked_from_owned(self)
}
/// Returns a [`BeatmapAttributesBuilder`] to calculate modified beatmap
/// attributes.
pub fn attributes(&self) -> BeatmapAttributesBuilder {
self.into()
}
/// The beats per minute of the map.
pub fn bpm(&self) -> f64 {
bpm::bpm(self.hit_objects.last(), &self.timing_points)
}
}
impl FromStr for Beatmap {
type Err = io::Error;
/// Parse a [`Beatmap`] by providing the content of a `.osu` file as a
/// string.
fn from_str(s: &str) -> Result<Self, Self::Err> {
rosu_map::from_str(s)
}
}
const DEFAULT_SLIDER_LENIENCY: f32 = 0.7;
impl Default for Beatmap {
fn default() -> Self {
Self {
version: LATEST_FORMAT_VERSION,
stack_leniency: DEFAULT_SLIDER_LENIENCY,
mode: GameMode::default(),
ar: 5.0,
cs: 5.0,
hp: 5.0,
od: 5.0,
slider_multiplier: 1.4,
slider_tick_rate: 1.0,
breaks: Vec::default(),
timing_points: Vec::default(),
difficulty_points: Vec::default(),
effect_points: Vec::default(),
hit_objects: Vec::default(),
hit_sounds: Vec::default(),
}
}
}
macro_rules! define_beat_len_fn {
( $fn_name:ident, $clamp:literal) => {
#[allow(unused)]
pub(crate) fn $fn_name(map: &Beatmap, beat_len: f64, time: f64) -> f64 {
let slider_velocity = map
.difficulty_point_at(time)
.map_or(DifficultyPoint::DEFAULT_SLIDER_VELOCITY, |point| {
point.slider_velocity
});
let slider_velocity_as_beat_len = -100.0 / slider_velocity;
let bpm_mult = if slider_velocity_as_beat_len < 0.0 {
(((-slider_velocity_as_beat_len) as f32).clamp(10.0, $clamp) / 100.0) as f64
} else {
1.0
};
beat_len * bpm_mult
}
};
}
define_beat_len_fn!(get_precision_adjusted_beat_len_taiko_mania, 10_000.0);
define_beat_len_fn!(get_precision_adjusted_beat_len_osu_catch, 1000.0);
+8
View File
@@ -0,0 +1,8 @@
pub use rosu_map::section::timing_points::DifficultyPoint;
pub fn difficulty_point_at(points: &[DifficultyPoint], time: f64) -> Option<&DifficultyPoint> {
points
.binary_search_by(|probe| probe.time.total_cmp(&time))
.map_or_else(|i| i.checked_sub(1), Some)
.map(|i| &points[i])
}
+34
View File
@@ -0,0 +1,34 @@
/// Effect-related info about this control point.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct EffectPoint {
pub time: f64,
pub kiai: bool,
}
impl EffectPoint {
pub const DEFAULT_KIAI: bool = rosu_map::section::timing_points::EffectPoint::DEFAULT_KIAI;
pub const fn new(time: f64, kiai: bool) -> Self {
Self { time, kiai }
}
pub const fn is_redundant(&self, existing: &Self) -> bool {
self.kiai == existing.kiai
}
}
impl Default for EffectPoint {
fn default() -> Self {
Self {
time: 0.0,
kiai: Self::DEFAULT_KIAI,
}
}
}
pub fn effect_point_at(points: &[EffectPoint], time: f64) -> Option<&EffectPoint> {
points
.binary_search_by(|probe| probe.time.total_cmp(&time))
.map_or_else(|i| i.checked_sub(1), Some)
.map(|i| &points[i])
}
+9
View File
@@ -0,0 +1,9 @@
pub use self::{difficulty::DifficultyPoint, effect::EffectPoint, timing::TimingPoint};
pub(crate) use self::{
difficulty::difficulty_point_at, effect::effect_point_at, timing::timing_point_at,
};
mod difficulty;
mod effect;
mod timing;
+35
View File
@@ -0,0 +1,35 @@
/// Timing-related info about this control point.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct TimingPoint {
pub time: f64,
pub beat_len: f64,
}
impl TimingPoint {
pub const DEFAULT_BEAT_LEN: f64 =
rosu_map::section::timing_points::TimingPoint::DEFAULT_BEAT_LEN;
pub fn new(time: f64, beat_len: f64) -> Self {
Self {
time,
beat_len: beat_len.clamp(6.0, 60_000.0),
}
}
}
impl Default for TimingPoint {
fn default() -> Self {
Self {
time: 0.0,
beat_len: Self::DEFAULT_BEAT_LEN,
}
}
}
pub fn timing_point_at(points: &[TimingPoint], time: f64) -> Option<&TimingPoint> {
let i = points
.binary_search_by(|probe| probe.time.total_cmp(&time))
.unwrap_or_else(|i| i.saturating_sub(1));
points.get(i)
}
+94
View File
@@ -0,0 +1,94 @@
use std::cmp::Ordering;
use rosu_map::section::hit_objects::{BorrowedCurve, CurveBuffers};
pub use rosu_map::{
section::hit_objects::{hit_samples::HitSoundType, PathControlPoint},
util::Pos,
};
/// All hitobject related data required for difficulty and performance
/// calculation except for the [`HitSoundType`].
#[derive(Clone, Debug, PartialEq)]
pub struct HitObject {
pub pos: Pos,
pub start_time: f64,
pub kind: HitObjectKind,
}
impl HitObject {
/// Whether the hitobject is a circle.
pub const fn is_circle(&self) -> bool {
matches!(&self.kind, HitObjectKind::Circle)
}
/// Whether the hitobject is a slider.
pub const fn is_slider(&self) -> bool {
matches!(&self.kind, HitObjectKind::Slider(_))
}
/// Whether the hitobject is a spinner.
pub const fn is_spinner(&self) -> bool {
matches!(&self.kind, HitObjectKind::Spinner(_))
}
/// The end time of the object.
///
/// Note that this will not return the correct value for sliders.
pub(crate) const fn end_time(&self) -> f64 {
match &self.kind {
HitObjectKind::Circle | HitObjectKind::Slider { .. } => self.start_time,
HitObjectKind::Spinner(Spinner { end_time })
| HitObjectKind::Hold(HoldNote { end_time }) => *end_time,
}
}
}
impl PartialOrd for HitObject {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.start_time.partial_cmp(&other.start_time)
}
}
/// Additional data for a [`HitObject`].
///
/// Note that each mode handles hit objects differently.
#[derive(Clone, Debug, PartialEq)]
pub enum HitObjectKind {
Circle,
Slider(Slider),
Spinner(Spinner),
Hold(HoldNote),
}
/// A slider.
#[derive(Clone, Debug, PartialEq)]
pub struct Slider {
pub expected_dist: Option<f64>,
pub repeats: usize,
pub control_points: Box<[PathControlPoint]>,
pub node_sounds: Box<[HitSoundType]>,
}
impl Slider {
/// The amount of spans of the slider.
pub const fn span_count(&self) -> usize {
self.repeats + 1
}
pub(crate) fn curve<'a>(&self, bufs: &'a mut CurveBuffers) -> BorrowedCurve<'a> {
BorrowedCurve::new(&self.control_points, self.expected_dist, bufs)
}
}
/// A spinner.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct Spinner {
pub end_time: f64,
}
/// A hold note.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct HoldNote {
pub end_time: f64,
}
+11
View File
@@ -0,0 +1,11 @@
/// Beatmap related types.
pub mod beatmap;
/// Control point related types.
pub mod control_point;
/// Hitobject related types.
pub mod hit_object;
/// Gamemode related types.
pub mod mode;
+52
View File
@@ -0,0 +1,52 @@
use std::borrow::Cow;
pub use rosu_map::section::general::GameMode;
use crate::any::ModeDifficulty;
use super::beatmap::{Beatmap, Converted};
/// A way to specify a gamemode at compile-time.
///
/// Notably, this is implemented for the marker types [`Osu`], [`Taiko`],
/// [`Catch`], and [`Mania`].
///
/// [`Osu`]: crate::osu::Osu
/// [`Taiko`]: crate::taiko::Taiko
/// [`Catch`]: crate::catch::Catch
/// [`Mania`]: crate::mania::Mania
pub trait IGameMode: Sized {
/// The resulting type of a difficulty calculation.
type DifficultyAttributes;
/// The resulting type of a strain calculation.
type Strains;
/// Attempt to convert a beatmap.
///
/// In case [`ConvertStatus::Incompatible`] is returned, the map should
/// **not** be modified.
fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus;
/// Perform a difficulty calculation for a [`Converted`] beatmap and
/// process the final skill values.
fn difficulty(
difficulty: &ModeDifficulty,
map: &Converted<'_, Self>,
) -> Self::DifficultyAttributes;
/// Perform a difficulty calculation for a [`Converted`] beatmap without
/// processing the final skill values.
fn strains(difficulty: &ModeDifficulty, map: &Converted<'_, Self>) -> Self::Strains;
}
/// The status of a conversion through [`IGameMode::try_convert`].
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ConvertStatus {
/// Conversion was not necessary.
Noop,
/// Conversion was successful.
Done,
/// Conversion was not possible.
Incompatible,
}
-87
View File
@@ -1,87 +0,0 @@
macro_rules! impl_mods {
($func_name:ident, $const_name:ident) => {
#[inline]
fn $func_name(self) -> bool {
self & Self::$const_name > 0
}
};
}
/// Abstract type to define mods.
#[allow(missing_docs)]
pub trait Mods: Copy {
const NF: u32 = 1 << 0;
const EZ: u32 = 1 << 1;
const TD: u32 = 1 << 2;
const HD: u32 = 1 << 3;
const HR: u32 = 1 << 4;
const DT: u32 = 1 << 6;
const RX: u32 = 1 << 7;
const HT: u32 = 1 << 8;
const FL: u32 = 1 << 10;
const SO: u32 = 1 << 12;
/// If the clock rate is affected by the mods.
fn change_speed(self) -> bool;
/// If object time's or positions are affected by the mods.
fn change_map(self) -> bool;
/// The clock rate with the mods.
fn clock_rate(self) -> f64;
/// Multiplier for beatmap attributes with respect to the mods.
fn od_ar_hp_multiplier(self) -> f64;
fn nf(self) -> bool;
fn ez(self) -> bool;
fn td(self) -> bool;
fn hd(self) -> bool;
fn hr(self) -> bool;
fn dt(self) -> bool;
fn rx(self) -> bool;
fn ht(self) -> bool;
fn fl(self) -> bool;
fn so(self) -> bool;
}
impl Mods for u32 {
#[inline]
fn change_speed(self) -> bool {
self & (Self::HT | Self::DT) > 0
}
#[inline]
fn change_map(self) -> bool {
self & (Self::HT | Self::DT | Self::HR | Self::EZ) > 0
}
#[inline]
fn clock_rate(self) -> f64 {
if self & Self::DT > 0 {
1.5
} else if self & Self::HT > 0 {
0.75
} else {
1.0
}
}
#[inline]
fn od_ar_hp_multiplier(self) -> f64 {
if self & Self::HR > 0 {
1.4
} else if self & Self::EZ > 0 {
0.5
} else {
1.0
}
}
impl_mods!(nf, NF);
impl_mods!(ez, EZ);
impl_mods!(td, TD);
impl_mods!(hd, HD);
impl_mods!(hr, HR);
impl_mods!(dt, DT);
impl_mods!(rx, RX);
impl_mods!(ht, HT);
impl_mods!(fl, FL);
impl_mods!(so, SO);
}
+89
View File
@@ -0,0 +1,89 @@
use crate::osu::performance::OsuPerformance;
/// The result of a difficulty calculation on an osu!standard map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct OsuDifficultyAttributes {
/// The difficulty of the aim skill.
pub aim: f64,
/// The difficulty of the speed skill.
pub speed: f64,
/// The difficulty of the flashlight skill.
pub flashlight: f64,
/// The ratio of the aim strain with and without considering sliders
pub slider_factor: f64,
/// The number of clickable objects weighted by difficulty.
pub speed_note_count: f64,
/// The approach rate.
pub ar: f64,
/// The overall difficulty
pub od: f64,
/// The health drain rate.
pub hp: f64,
/// The amount of circles.
pub n_circles: u32,
/// The amount of sliders.
pub n_sliders: u32,
/// The amount of spinners.
pub n_spinners: u32,
/// The final star rating
pub stars: f64,
/// The maximum combo.
pub max_combo: u32,
}
impl OsuDifficultyAttributes {
/// Return the maximum combo.
pub const fn max_combo(&self) -> u32 {
self.max_combo
}
/// Return the amount of hitobjects.
pub const fn n_objects(&self) -> u32 {
self.n_circles + self.n_sliders + self.n_spinners
}
/// Returns a builder for performance calculation.
pub fn pp<'a>(self) -> OsuPerformance<'a> {
self.into()
}
}
/// The result of a performance calculation on an osu!standard map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct OsuPerformanceAttributes {
/// The difficulty attributes that were used for the performance calculation
pub difficulty: OsuDifficultyAttributes,
/// The final performance points.
pub pp: f64,
/// The accuracy portion of the final pp.
pub pp_acc: f64,
/// The aim portion of the final pp.
pub pp_aim: f64,
/// The flashlight portion of the final pp.
pub pp_flashlight: f64,
/// The speed portion of the final pp.
pub pp_speed: f64,
/// Misses including an approximated amount of slider breaks
pub effective_miss_count: f64,
}
impl OsuPerformanceAttributes {
/// Return the star value.
pub const fn stars(&self) -> f64 {
self.difficulty.stars
}
/// Return the performance point value.
pub const fn pp(&self) -> f64 {
self.pp
}
/// Return the maximum combo of the map.
pub const fn max_combo(&self) -> u32 {
self.difficulty.max_combo
}
/// Return the amount of hitobjects.
pub const fn n_objects(&self) -> u32 {
self.difficulty.n_objects()
}
}

Some files were not shown because too many files have changed in this diff Show More