chore: bump to older tag build

This commit is contained in:
2026-06-26 13:48:41 +02:00
commit da105ffb14
115 changed files with 28879 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
name: Clippy and Tests
on:
push:
branches:
- main
- next
pull_request:
jobs:
clippy:
name: Clippy
runs-on: ubuntu-latest
strategy:
matrix:
feature: [default, async_tokio, async_std]
steps:
- name: Checkout project
uses: actions/checkout@v3
- name: Install Rust toolchain
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
components: clippy
override: true
- name: Run clippy
uses: actions-rs/cargo@v1
with:
command: clippy
args: --no-default-features --features ${{ matrix.feature }}
tests:
name: Tests
runs-on: ubuntu-latest
strategy:
matrix:
feature: [default, async_tokio, async_std]
steps:
- name: Checkout project
uses: actions/checkout@v3
- name: Install Rust toolchain
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
components: clippy
override: true
- name: Run tests
uses: actions-rs/cargo@v1
with:
command: test
args: --no-default-features --features ${{ matrix.feature }}
+12
View File
@@ -0,0 +1,12 @@
/target
Cargo.lock
output*
/tests/custom.rs
/pp-gen/.env
/pp-gen/target
/pp-gen/output.json
/pp-plot/.env
/pp-plot/target
/pp-plot/accuracy_*
+223
View File
@@ -0,0 +1,223 @@
## Upcoming
Nothing as of now
# v0.9.2 (2022-11-08)
- __Adjustments:__
- When passing an osu!std map to `TaikoGradualDifficultyAttributes` or `ManiaGradualDifficultyAttributes`, it now automatically converts the map internally. For osu!catch is was already happening trivially.
- __Fixes:__
- Fixed passed object count for taiko by ignoring non-circles
- Fixed a niche panic on UTF-16 encoded maps ([#18])
- Fixed an occasional underflow when calculating accuracy pp
- Fixed an infinite loop on special ctb maps
## v0.9.1 (2022-10-26)
- __Adjustments:__
- When passing an osu!std map to `TaikoPP` or `ManiaPP`, it now automatically converts the map internally. For osu!catch it was already happening trivially.
- __Fixes:__
- The fields `section_len` for all strain structs no longer depends on the clock rate.
## v0.9.0 (2022-10-24)
Big changes including the most recent [osu!](https://osu.ppy.sh/home/news/2022-09-30-changes-to-osu-sr-and-pp), [taiko](https://osu.ppy.sh/home/news/2022-09-28-changes-to-osu-taiko-sr-and-pp), and [mania](https://osu.ppy.sh/home/news/2022-10-09-changes-to-osu-mania-sr-and-pp) updates, aswell as various breaking changes.
- __Breaking changes:__
- `TimingPoint` and `DifficultyPoint` no longer contain a `kiai` field
- `DifficultyPoint` now has the additional fields `bpm_mult` and `generate_ticks`
- `Beatmap` now stores timing- and difficulty points in a `SortedVec`
- `Beatmap` now has the additional field `effect_points`
- For the performance calculators `OsuPP`, `TaikoPP`, `ManiaPP`, and `AnyPP` the method `misses` has been renamed to `n_misses`
- The accuracy method for `OsuPP`, `TaikoPP`, and `ManiaPP` is no longer required to be called last
- `ManiaPP` no longer has a `score` method. Instead it has `n320`, `n300`. `n200`, `n100`, `n50`, and `n_misses` methods, aswell as a `state` method
- Gradual performance calculation for mania now requires a `ManiaScoreState` instead of `score`
- `ManiaDifficultyAttributes` now have a `max_combo` field and method
- `OsuDifficultyAttributes` now have a `speed_note_count` field
- `OsuPerformanceAttributes` and `TaikoPerformanceAttributes` now have a `effective_miss_count` field
- `TaikoDifficultyAttributes` now have a `peak` and `hit_window` field
- Some other things I likely forgot about :S
- __Additions:__
- The performance calculators `OsuPP`, `TaikoPP`, `ManiaPP`, and `AnyPP` now have a `hitresult_priority` method to specify how hitresults should be generated
- __Fixes:__
- Fixed a bunch of fringe yet significant bugs for taiko and mania converts
- Fixed various floating point inaccuracies for osu!standard
- Fixed parsing difficulty points from .osu files
- Instead of throwing an error, invalid lines during parsing will just be ignored in some cases
- Fixed an unsafe transmute between incompatible types while parsing sliders
## v0.8.0 (2022-08-02)
- __Fixes:__
- Fixed stack overflow bug when allocating ticks for some sliders on converted catch maps ([#14])
- __Breaking changes:__
- `Beatmap::attributes` now returns a new type `BeatmapAttributesBuilder` to allow for more
fine-grained calculations. `BeatmapAttributes` now contains expected values and also includes
a `BeatmapHitWindows` field containing the AR (preempt) and OD (great) hit windows in
milliseconds. ([#15])
## v0.7.1 (2022-07-12)
- __Fixes:__
- Parsing edge sounds is now mindful about overflowing a byte (ref. ranked map id 80799)
- Parsing the event section now attempts to read non-ASCII before eventually failing (ref. ranked map id 49374)
## v0.7.0 (2022-07-06)
- __Fixes:__
- Slider velocity is now adjusted properly for taiko converts
- Fixed missing slider sounds for taiko converts
- __Breaking changes:__
- Replaced the simple `Strains` struct with a new struct `{Mode}Strains` that contains more detail w.r.t. the mode.
- Renamed all `GameMode` variants to more idiomatic names
- Renamed `ParseError::IOError` to `ParseError::IoError`
## v0.6.0 (2022-07-05)
- __Additions__:
- Added the `ControlPoint` and `ControlPointerIter` types to the public interface
- `TimingPoint` and `DifficultyPoint` now implement `Default`
- Added new methods to `Beatmap`:
- `convert_mode`: Convert a map into another mode. (doesn't do anything if the starting map is not osu!standard)
- `control_points`: Return an iterator over all control points of a map
- `total_break_time`: Return the accumulated break time in milliseconds
- `timing_point_at`: Return the timing point for the given timestamp
- `difficulty_point_at`: Return the difficulty point for the given timestamp if available
- __Breaking changes:__
- Moved some types to a different module. The following types can now be found in `rosu_pp::beatmap`:
- `Beatmap`
- `BeatmapAttributes`
- `ControlPoint`
- `ControlPointIter`
- `DifficultyPoint`
- `GameMode`
- `TimingPoint`
- Added a new field `kiai: bool` to both `TimingPoint` and `DifficultyPoint` to denote whether the current timing section is in kiai mode
- Added a new field `breaks: Vec<Break>` to `Beatmap` that contains all breaks throughout the map
- Added a new field `edge_sounds: Vec<u8>` to the `Slider` variant of `HitObjectKind` to denote the sample played on slider heads, ends, and repeats
- __Other:__
- Small performance improvements for osu!taiko calculations
## v0.5.2 (2022-06-14)
- __Fixes:__
- Fixed parsing non-UTF-8 encoded files and improved parse performance overall ([#9])
- Handle missing approach rate properly this time
## v0.5.1 (2022-03-21)
- __Fixes:__
- Performance calculation for taiko & mania now considers custom clock rates properly
## v0.5.0 (2022-03-21)
- __Fixes:__
- Fixed panic on maps with 0 objects
- Fixed droplet timings on juicestreams with span count >1
- Fixed timing point parsing on some (older) maps where "uninherited" value did not coincide with beat length
- Fixed handling .osu files with missing difficulty attributes
- Fixed huge memory allocations caused by incorrectly parsing .osu files
- __Breaking changes:__
- The `stars` and `strains` functions for all modes were removed. Instead use the `{Mode}Stars` builder pattern which is similar to `{Mode}PP`.
- `BeatmapExt::stars`'s definition was adjusted to use the `AnyStars` builder struct
- Store `HitObject::sound` in `Beatmap::sounds` instead to reduce the struct size
- Removed the mode features `osu`, `fruits`, `taiko`, and `mania`. Now all modes are always supported.
- Renamed the `rosu_pp::fruits` module to `rosu_pp::catch`. Similarly, all structs `Fruits{Name}` were renamed to `Catch{Name}` and enums over the mode have their `Fruits` variant renamed to `Catch`
- Renamed `Mods`' method `speed` to `clock_rate`
- __Additions:__
- Added `AttributeProvider` impl for `{Mode}PerformanceAttributes`
- Added the method `clock_rate` to `{Mode}PP` and `{Mode}Stars` to consider a custom clock rate instead of the one dictated by mods.
## v0.4.0 (2021-11-25)
- Fixed out of bounds panic on maps with single-control-point linear sliders
- Fixed incorrect attributes on maps with only 1 or 2 hit objects for all modes
- Added method `Beatmap::from_path` so the file does not have to be created manually for `Beatmap::parse`.
- Added a bunch of documentation.
- Added method `Beatmap::bpm`
- Added method `max_combo` for `DifficultyAttributes`, `PerformanceAttributes`, and all `{Mode}PerformanceAttributes`
- Added methods `TaikoDifficultyAttributes::max_combo` and `OsuDifficultyAttributes::max_combo`
- Added structs `{Mode}GradualDifficultyAttributes` to calculate a map's difficulty after every or every few objects instead of calling the mode's `stars` function over and over.
- Added structs `{Mode}GradualPerformanceAttributes` to calculate the performance on a map after every or every few objects instead of using `{Mode}PP` over and over.
- Added `BeatmapExt::gradual_difficulty` and `BeatmapExt::gradual_performance` to gradually calculate the difficulty or performance on maps of any mode, hit object by hit object.
- Added methods `{Mode}PP::state` that take a `{Mode}ScoreState` (same for `AnyPP` and `ScoreState`) to set all parameters at once.
- [BREAKING] Removed the `ParseError` variants `InvalidPathType` and `InvalidTimingSignature` and renamed `InvalidFloatingPoint` to `InvalidDecimalNumber`.
- [BREAKING] Removed the `last_control_point` field of `HitObjectKind::Slider` when neither the `osu` nor the `fruits` feature is enabled.
- [BREAKING] Added the field `TaikoDifficultyAttributes::max_combo`
- [BREAKING] Renamed the `attributes` field to `difficulty` for all `{Mode}PerformanceAttributes` structs
- [BREAKING] Replaced field `FruitsDifficultyAttributes::max_combo` by a method with the same name
## v0.3.0 (2021-11-14)
- [BREAKING] With the importance of sliders for osu!standard, the `no_sliders_no_leniency` feature became too inaccurate. Additionally, since considering sliders now inherently drags performance down a little more, the difference between `no_leniency` and `all_included` became too small. Hence, the three osu features `no_sliders_no_leniency`, `no_leniency`, and `all_included` were removed. When the `osu` feature is enabled, it will now essentially use `all_included` under the hood.
Additionally, instead of importing through `rosu_pp::osu::{version}`, you now have to import through `rosu_pp::osu`.
- [BREAKING] Instead of returning `PpResult`, performance calculations now return `{Mode}PerformanceAttributes` and `PpResult` has been renamed to `PerformanceAttributes`.
- [BREAKING] Instead of returning `StarResult`, difficulty calculations now return `{Mode}DifficultyAttributes` and `StarResult` has been renamed to `DifficultyAttributes`.
- [BREAKING] Various fields and methods now include `f64` instead of `f32` to stay true to osu!'s original code
- Added internal binary crate `pp-gen` to calculate difficulty & pp values via `PerformanceCalculator.dll`
- Added internal binary crate `pp-plot` to plot out differences between `pp-gen`'s output and `rosu-pp` values
- osu: Updated up to commit [9fb2402781ad91c197d51aeec716b0000f52c4d1](https://github.com/ppy/osu/commit/9fb2402781ad91c197d51aeec716b0000f52c4d1) (2021-11-12)
## v0.2.3 (2021-08-09)
- Reduced amount of required features of `async_std` and `async_tokio`
- Fixed a panic for some mania difficulty calculations on converts
- Updated the difficulty & pp changes from 21.07.27
- Fixed dead loop when reading empty `.osu` files ([#2] - [@Pure-Peace])
- Updated osu's clockrate bugfix for all modes
## v0.2.2 (2021-05-05)
- osu & fruits:
- Fixed specific slider patterns
- Optimized Bezier, Catmull, and other small things
Benchmarking for osu!standard showed a 25%+ improvement for performance aswell as accuracy
- fruits:
- Fixed tick timing for reverse sliders
- taiko:
- Micro optimizations
## v0.2.1 (2021-04-17)
- parse & osu:
- Cleanup and tiny optimizations
## v0.2.0 (2021-02-25)
- Async beatmap parsing through features `async_tokio` or `async_std` ([#1] - [@Pure-Peace])
- [BREAKING] Hide various parsing related types further inwards, i.e. `rosu_pp::parse::some_type` instead of `rosu_pp::some_type`
- Affected types: `DifficultyPoint`, `HitObject`, `Pos2`, `TimingPoint`, `HitObjectKind`, `PathType`, `HitSound`
## v0.1.1 (2021-02-15)
- parse:
- Efficiently handle huge amounts of curvepoints
- osu:
- Fixed panic on unwrapping unavailable hit results
- Fixed occasional underflow when calculating pp with passed_objects
- taiko:
- Fixed missing flooring of hitwindow for pp calculation
- fruits:
- Fixed passed objects in star calculation
- mania:
- Fixed pp calculation on HR
[@Pure-Peace]: https://github.com/Pure-Peace
[#1]: https://github.com/MaxOhn/rosu-pp/pull/1
[#2]: https://github.com/MaxOhn/rosu-pp/pull/2
[#9]: https://github.com/MaxOhn/rosu-pp/pull/9
[#14]: https://github.com/MaxOhn/rosu-pp/pull/14
[#15]: https://github.com/MaxOhn/rosu-pp/pull/15
[#18]: https://github.com/MaxOhn/rosu-pp/pull/18
+36
View File
@@ -0,0 +1,36 @@
[package]
name = "akatsuki-pp"
version = "1.0.1"
authors = ["MaxOhn <ohn.m@hotmail.de>", "tsunyoku <tsunyoku@gmail.com>"]
edition = "2018"
license = "MIT"
readme = "README.md"
repository = "https://github.com/osuAkatsuki/akatsuki-pp"
description = "osu! difficulty and pp calculation for all modes"
keywords = ["osu", "pp", "stars", "async"]
[features]
default = []
async_std = ["async-std"]
async_tokio = ["tokio"]
[dependencies.async-std]
version = "1.9"
optional = true
default-features = false
features = ["async-io", "std"]
[dependencies.tokio]
version = "1.2"
optional = true
default-features = false
features = ["fs", "io-util"]
[dev-dependencies.tokio]
version = "1.2"
default-features = false
features = ["fs", "rt"]
[dev-dependencies.async-std]
version = "1.9"
default-features = true
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Max
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+208
View File
@@ -0,0 +1,208 @@
[![crates.io](https://img.shields.io/crates/v/rosu-pp.svg)](https://crates.io/crates/rosu-pp) [![docs](https://docs.rs/rosu-pp/badge.svg)](https://docs.rs/rosu-pp)
# rosu-pp
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
```rust
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),
};
// If `BeatmapExt` is included, you can make use of
// some methods on `Beatmap` to make your life simpler.
let result = map.pp()
.mods(24) // HDHR
.combo(1234)
.accuracy(99.2)
.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)
.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.
```rust
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),
};
// 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());
```
### 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 use `GradualDifficultyAttributes` and `GradualPerformanceAttributes`:
```rust
use rosu_pp::{
Beatmap, BeatmapExt, GradualPerformanceAttributes, 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 mods = 8 + 64; // HDDT
// If you're only interested in the star rating or other difficulty value,
// use `GradualDifficultyAttributes`, either through its function `new`
// or through the method `BeatmapExt::gradual_difficulty`.
let gradual_difficulty = map.gradual_difficulty(mods);
// Since `GradualDifficultyAttributes` 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 = map.gradual_performance(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.process_next_object(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 `BeatmapExt::gradual_performance` one could have
// created the struct via `TaikoGradualPerformanceAttributes::new`.
let mut gradual_performance = match gradual_performance {
GradualPerformanceAttributes::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
let curr_performance = match gradual_performance.process_next_n_objects(state, 10) {
Some(perf) => perf,
None => panic!("the last `process_next_object` 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) |
### Version
A large portion of this repository is a port of [osu!lazer](https://github.com/ppy/osu)'s difficulty and performance calculation.
- osu!:
- osu!lazer: Commit `85adfc2df7d931164181e145377a6ced8db2bfb3` (Wed Sep 28 18:26:36 2022 +0300)
- osu!tools: Commit `146d5916937161ef65906aa97f85d367035f3712` (Sat Oct 8 14:28:49 2022 +0900)
- [Article](https://osu.ppy.sh/home/news/2022-09-30-changes-to-osu-sr-and-pp)
- taiko:
- osu!lazer: Commit `234c6ac7998fbc6742503e1a589536255554e56a` (Wed Oct 5 20:21:15 2022 +0900)
- osu!tools: Commit `146d5916937161ef65906aa97f85d367035f3712` (Sat Oct 8 14:28:49 2022 +0900)
- [Article](https://osu.ppy.sh/home/news/2022-09-28-changes-to-osu-taiko-sr-and-pp)
- catch: (will be updated on the next rework)
- osu!lazer: -
- osu!tools: -
- mania:
- osu!lazer: Commit `7342fb7f51b34533a42bffda89c3d6c569cc69ce` (Tue Oct 11 14:34:50 2022 +0900)
- osu!tools: Commit `146d5916937161ef65906aa97f85d367035f3712` (Sat Oct 8 14:28:49 2022 +0900)
- [Article](https://osu.ppy.sh/home/news/2022-10-09-changes-to-osu-mania-sr-and-pp)
### Accuracy
The difficulty and performance attributes generated by [osu-tools](https://github.com/ppy/osu-tools) itself were compared with rosu-pp's results when running on `130,000` different maps. Additionally, multiple mod combinations were tested depending on the mode:
- osu!: NM, EZ, HD, HR, DT
- taiko: NM, HD, HR, DT (+ all osu! converts)
- catch: -
- mania: NM, DT (+ all osu! converts)
For every (!) comparison of the star and pp values, the error margin was below `0.000000001`, ensuing a great accuracy.
### Benchmark
To be done
### Bindings
Using rosu-pp from other languages than Rust:
- JavaScript: [rosu-pp-js](https://github.com/MaxOhn/rosu-pp-js)
- Python: [rosu-pp-py](https://github.com/MaxOhn/rosu-pp-py)
+356
View File
@@ -0,0 +1,356 @@
osu file format v14
[General]
AudioFilename: audio.mp3
AudioLeadIn: 0
PreviewTime: 8074
Countdown: 0
SampleSet: Normal
StackLeniency: 0.7
Mode: 1
LetterboxInBreaks: 0
WidescreenStoryboard: 0
[Editor]
DistanceSpacing: 0.6
BeatDivisor: 4
GridSize: 16
TimelineZoom: 1.5
[Metadata]
Title:Inferno
TitleUnicode:インフェルノ
Artist:9mm Parabellum Bullet
ArtistUnicode:9mm Parabellum Bullet
Creator:Nofool
Version:Muzukashii
Source:ベルセルク
Tags:Berserk (2016)
BeatmapID:1028484
BeatmapSetID:481954
[Difficulty]
HPDrainRate:6
CircleSize:2
OverallDifficulty:5
ApproachRate:8
SliderMultiplier:1.4
SliderTickRate:1
[Events]
//Background and Video events
0,0,"berserk_armor_bg.jpg",0,0
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Sound Samples
[TimingPoints]
690,307.692307692308,3,1,0,100,1,0
15459,-100,3,1,0,100,0,1
22536,-100,3,1,0,100,0,0
37536,-125,3,1,0,70,0,0
44920,-100,3,1,0,100,0,0
78228,-100,3,1,0,100,0,1
85305,-100,3,1,0,100,0,0
87382,-83.3333333333333,3,1,0,100,0,0
[HitObjects]
256,192,690,1,8,0:0:0:0:
256,192,843,1,8,0:0:0:0:
256,192,997,1,0,0:0:0:0:
256,192,1151,1,8,0:0:0:0:
256,192,1459,1,0,0:0:0:0:
256,192,1613,1,0,0:0:0:0:
256,192,1920,1,0,0:0:0:0:
256,192,2228,1,8,0:0:0:0:
256,192,2536,1,8,0:0:0:0:
256,192,2690,1,8,0:0:0:0:
256,192,2843,1,0,0:0:0:0:
256,192,2997,1,8,0:0:0:0:
256,192,3305,1,0,0:0:0:0:
256,192,3459,1,0,0:0:0:0:
256,192,3766,1,8,0:0:0:0:
256,192,4074,1,8,0:0:0:0:
256,192,4382,1,8,0:0:0:0:
256,192,4536,1,8,0:0:0:0:
256,192,4690,1,0,0:0:0:0:
256,192,4843,1,8,0:0:0:0:
256,192,5151,1,0,0:0:0:0:
256,192,5305,1,8,0:0:0:0:
256,192,5613,1,8,0:0:0:0:
256,192,5920,1,8,0:0:0:0:
256,192,6228,1,8,0:0:0:0:
256,192,6382,1,8,0:0:0:0:
256,192,6536,1,0,0:0:0:0:
256,192,6690,1,8,0:0:0:0:
256,192,6997,1,0,0:0:0:0:
256,192,7151,1,8,0:0:0:0:
256,192,7459,1,0,0:0:0:0:
256,192,7766,1,0,0:0:0:0:
256,192,8074,5,4,0:0:0:0:
256,192,8997,1,4,0:0:0:0:
256,192,9920,1,4,0:0:0:0:
256,192,10843,1,4,0:0:0:0:
256,192,11766,1,8,0:0:0:0:
256,192,11920,1,8,0:0:0:0:
256,192,12074,1,8,0:0:0:0:
256,192,12228,1,8,0:0:0:0:
256,192,12382,1,8,0:0:0:0:
256,192,12690,1,0,0:0:0:0:
256,192,12843,1,0,0:0:0:0:
256,192,12997,1,0,0:0:0:0:
256,192,13151,1,0,0:0:0:0:
256,192,13305,1,0,0:0:0:0:
256,192,13613,1,8,0:0:0:0:
256,192,13766,1,8,0:0:0:0:
256,192,13920,1,8,0:0:0:0:
256,192,14074,1,8,0:0:0:0:
256,192,14228,1,8,0:0:0:0:
256,192,14536,12,0,15305,0:0:0:0:
256,192,15459,5,4,0:0:0:0:
256,192,15766,1,8,0:0:0:0:
256,192,15843,1,8,0:0:0:0:
256,192,15920,1,8,0:0:0:0:
256,192,16074,1,0,0:0:0:0:
256,192,16228,1,8,0:0:0:0:
256,192,16382,1,0,0:0:0:0:
256,192,16690,1,8,0:0:0:0:
256,192,16766,1,8,0:0:0:0:
256,192,16843,1,8,0:0:0:0:
256,192,16997,1,0,0:0:0:0:
256,192,17151,1,8,0:0:0:0:
256,192,17305,1,0,0:0:0:0:
256,192,17613,1,8,0:0:0:0:
256,192,17690,1,8,0:0:0:0:
256,192,17766,1,8,0:0:0:0:
256,192,17920,1,0,0:0:0:0:
256,192,18074,1,8,0:0:0:0:
256,192,18228,2,0,L|400:192,2,140
256,192,19151,1,12,0:0:0:0:
256,192,19459,1,0,0:0:0:0:
256,192,19613,1,0,0:0:0:0:
256,192,19690,1,0,0:0:0:0:
256,192,19766,1,0,0:0:0:0:
256,192,19920,1,0,0:0:0:0:
256,192,20074,1,8,0:0:0:0:
256,192,20382,1,0,0:0:0:0:
256,192,20536,1,0,0:0:0:0:
256,192,20613,1,0,0:0:0:0:
256,192,20690,1,0,0:0:0:0:
256,192,20843,1,0,0:0:0:0:
256,192,20997,1,8,0:0:0:0:
256,192,21305,1,0,0:0:0:0:
256,192,21459,1,0,0:0:0:0:
256,192,21536,1,0,0:0:0:0:
256,192,21613,1,0,0:0:0:0:
256,192,21766,1,8,0:0:0:0:
256,192,21920,2,0,L|400:192,2,140
256,192,22843,1,0,0:0:0:0:
256,192,22920,1,0,0:0:0:0:
256,192,22997,1,0,0:0:0:0:
256,192,23151,1,0,0:0:0:0:
256,192,23305,1,0,0:0:0:0:
256,192,23613,1,0,0:0:0:0:
256,192,23766,1,0,0:0:0:0:
256,192,24690,1,8,0:0:0:0:
256,192,24766,1,8,0:0:0:0:
256,192,24843,1,8,0:0:0:0:
256,192,24997,1,8,0:0:0:0:
256,192,25151,1,8,0:0:0:0:
256,192,25459,1,8,0:0:0:0:
256,192,25613,1,8,0:0:0:0:
256,192,26536,1,0,0:0:0:0:
256,192,26690,1,0,0:0:0:0:
256,192,26843,1,0,0:0:0:0:
256,192,27459,1,8,0:0:0:0:
256,192,27613,1,8,0:0:0:0:
256,192,27766,1,8,0:0:0:0:
256,192,28382,1,0,0:0:0:0:
256,192,28459,1,0,0:0:0:0:
256,192,28536,1,0,0:0:0:0:
256,192,28690,1,0,0:0:0:0:
256,192,28843,1,0,0:0:0:0:
256,192,29151,1,0,0:0:0:0:
256,192,29305,1,8,0:0:0:0:
256,192,30228,1,0,0:0:0:0:
256,192,30382,1,0,0:0:0:0:
256,192,30536,1,8,0:0:0:0:
256,192,30997,1,0,0:0:0:0:
256,192,31151,1,0,0:0:0:0:
256,192,31305,1,8,0:0:0:0:
256,192,31459,1,0,0:0:0:0:
256,192,31613,1,8,0:0:0:0:
256,192,31690,1,8,0:0:0:0:
256,192,31766,1,8,0:0:0:0:
256,192,32074,1,0,0:0:0:0:
256,192,32228,1,0,0:0:0:0:
256,192,32382,1,8,0:0:0:0:
256,192,32843,1,0,0:0:0:0:
256,192,32997,1,8,0:0:0:0:
256,192,33151,1,0,0:0:0:0:
256,192,33305,1,0,0:0:0:0:
256,192,33459,1,8,0:0:0:0:
256,192,33536,1,8,0:0:0:0:
256,192,33613,1,8,0:0:0:0:
256,192,33920,1,4,0:0:0:0:
256,192,34843,1,12,0:0:0:0:
256,192,35766,1,4,0:0:0:0:
256,192,36228,1,4,0:0:0:0:
256,192,36690,1,12,0:0:0:0:
256,192,37613,1,4,0:0:0:0:
256,192,38536,1,0,0:0:0:0:
256,192,39459,1,0,0:0:0:0:
256,192,39920,1,0,0:0:0:0:
256,192,40382,1,0,0:0:0:0:
256,192,41305,1,12,0:0:0:0:
256,192,42228,1,8,0:0:0:0:
256,192,43151,1,8,0:0:0:0:
256,192,43613,1,8,0:0:0:0:
256,192,44074,1,8,0:0:0:0:
256,192,44997,1,8,0:0:0:0:
256,192,45151,1,8,0:0:0:0:
256,192,45305,1,0,0:0:0:0:
256,192,45459,1,8,0:0:0:0:
256,192,45766,1,0,0:0:0:0:
256,192,45920,1,8,0:0:0:0:
256,192,46228,1,0,0:0:0:0:
256,192,46536,1,8,0:0:0:0:
256,192,46843,1,8,0:0:0:0:
256,192,46997,1,8,0:0:0:0:
256,192,47151,1,0,0:0:0:0:
256,192,47305,1,8,0:0:0:0:
256,192,47613,1,0,0:0:0:0:
256,192,47766,1,8,0:0:0:0:
256,192,48074,1,8,0:0:0:0:
256,192,48382,1,8,0:0:0:0:
256,192,48690,1,8,0:0:0:0:
256,192,48843,1,8,0:0:0:0:
256,192,48997,1,0,0:0:0:0:
256,192,49151,1,8,0:0:0:0:
256,192,49459,1,0,0:0:0:0:
256,192,49613,1,8,0:0:0:0:
256,192,49920,1,8,0:0:0:0:
256,192,50228,1,0,0:0:0:0:
256,192,50536,1,8,0:0:0:0:
256,192,50690,1,8,0:0:0:0:
256,192,50843,1,0,0:0:0:0:
256,192,50997,1,8,0:0:0:0:
256,192,51305,1,0,0:0:0:0:
256,192,51459,1,8,0:0:0:0:
256,192,51766,1,0,0:0:0:0:
256,192,52074,1,8,0:0:0:0:
256,192,52382,1,4,0:0:0:0:
256,192,54228,1,12,0:0:0:0:
256,192,54690,1,12,0:0:0:0:
256,192,55151,1,12,0:0:0:0:
256,192,55459,1,12,0:0:0:0:
256,192,56074,1,4,0:0:0:0:
256,192,56997,1,4,0:0:0:0:
256,192,57920,1,4,0:0:0:0:
256,192,58843,1,4,0:0:0:0:
256,192,59766,1,8,0:0:0:0:
256,192,59920,1,8,0:0:0:0:
256,192,60074,1,8,0:0:0:0:
256,192,60228,1,8,0:0:0:0:
256,192,60382,1,8,0:0:0:0:
256,192,60690,1,0,0:0:0:0:
256,192,60843,1,0,0:0:0:0:
256,192,60997,1,0,0:0:0:0:
256,192,61151,1,0,0:0:0:0:
256,192,61305,1,0,0:0:0:0:
256,192,61613,1,8,0:0:0:0:
256,192,61766,1,8,0:0:0:0:
256,192,61920,1,8,0:0:0:0:
256,192,62074,1,8,0:0:0:0:
256,192,62228,1,8,0:0:0:0:
256,192,62536,12,0,63305,0:0:0:0:
256,192,63459,5,12,0:0:0:0:
256,192,63766,1,4,0:0:0:0:
256,192,64074,1,4,0:0:0:0:
256,192,64382,1,12,0:0:0:0:
256,192,64997,1,12,0:0:0:0:
256,192,65305,1,4,0:0:0:0:
256,192,65766,1,4,0:0:0:0:
256,192,66228,1,12,0:0:0:0:
256,192,67151,1,12,0:0:0:0:
256,192,67459,1,4,0:0:0:0:
256,192,67766,1,4,0:0:0:0:
256,192,68074,1,12,0:0:0:0:
256,192,68997,1,12,0:0:0:0:
256,192,69305,1,4,0:0:0:0:
256,192,69613,1,4,0:0:0:0:
256,192,69920,1,12,0:0:0:0:
256,192,70843,5,4,0:0:0:0:
256,192,71151,1,8,0:0:0:0:
256,192,71305,1,8,0:0:0:0:
256,192,71459,1,8,0:0:0:0:
256,192,71766,1,8,0:0:0:0:
256,192,72074,1,0,0:0:0:0:
256,192,72690,1,4,0:0:0:0:
256,192,72997,1,8,0:0:0:0:
256,192,73151,1,8,0:0:0:0:
256,192,73305,1,8,0:0:0:0:
256,192,73459,1,8,0:0:0:0:
256,192,73613,1,0,0:0:0:0:
256,192,73920,1,8,0:0:0:0:
256,192,74536,1,4,0:0:0:0:
256,192,75459,1,4,0:0:0:0:
256,192,76382,1,4,0:0:0:0:
256,192,77305,1,12,0:0:0:0:
256,192,77459,1,12,0:0:0:0:
256,192,77613,1,12,0:0:0:0:
256,192,78228,5,4,0:0:0:0:
256,192,78536,1,8,0:0:0:0:
256,192,78613,1,8,0:0:0:0:
256,192,78690,1,8,0:0:0:0:
256,192,78843,1,0,0:0:0:0:
256,192,78997,1,8,0:0:0:0:
256,192,79151,1,0,0:0:0:0:
256,192,79459,1,8,0:0:0:0:
256,192,79536,1,8,0:0:0:0:
256,192,79613,1,8,0:0:0:0:
256,192,79766,1,0,0:0:0:0:
256,192,79920,1,8,0:0:0:0:
256,192,80074,1,0,0:0:0:0:
256,192,80382,1,8,0:0:0:0:
256,192,80459,1,8,0:0:0:0:
256,192,80536,1,8,0:0:0:0:
256,192,80690,1,0,0:0:0:0:
256,192,80843,1,8,0:0:0:0:
256,192,80997,2,0,L|400:192,2,140
256,192,81920,1,12,0:0:0:0:
256,192,82228,1,0,0:0:0:0:
256,192,82382,1,0,0:0:0:0:
256,192,82459,1,0,0:0:0:0:
256,192,82536,1,0,0:0:0:0:
256,192,82690,1,0,0:0:0:0:
256,192,82843,1,8,0:0:0:0:
256,192,83151,1,0,0:0:0:0:
256,192,83305,1,0,0:0:0:0:
256,192,83382,1,0,0:0:0:0:
256,192,83459,1,0,0:0:0:0:
256,192,83613,1,0,0:0:0:0:
256,192,83766,1,8,0:0:0:0:
256,192,84074,1,0,0:0:0:0:
256,192,84228,1,0,0:0:0:0:
256,192,84305,1,0,0:0:0:0:
256,192,84382,1,0,0:0:0:0:
256,192,84536,1,8,0:0:0:0:
256,192,84690,2,0,L|400:192,2,140
256,192,85613,1,8,0:0:0:0:
256,192,85766,1,8,0:0:0:0:
256,192,85920,1,0,0:0:0:0:
256,192,86074,1,8,0:0:0:0:
256,192,86228,1,8,0:0:0:0:
256,192,86382,1,0,0:0:0:0:
256,192,86536,1,8,0:0:0:0:
256,192,86843,1,0,0:0:0:0:
256,192,87151,1,8,0:0:0:0:
256,192,87459,1,4,0:0:0:0:
256,192,87766,1,4,0:0:0:0:
256,192,88074,1,4,0:0:0:0:
256,192,88382,1,4,0:0:0:0:
+5055
View File
File diff suppressed because it is too large Load Diff
+593
View File
@@ -0,0 +1,593 @@
osu file format v14
[General]
AudioFilename: -trappola_bewitching-.mp3
AudioLeadIn: 0
PreviewTime: 36319
Countdown: 0
SampleSet: Normal
StackLeniency: 0.7
Mode: 2
LetterboxInBreaks: 0
WidescreenStoryboard: 1
[Editor]
DistanceSpacing: 1
BeatDivisor: 4
GridSize: 8
TimelineZoom: 1.5
[Metadata]
Title:trappola bewitching
TitleUnicode:妖艶魔女 -trappola bewitching-
Artist:gmtn. (witch's slave)
ArtistUnicode:gmtn. (witch's slave)
Creator:Du5t
Version:Platter
Source:Arcaea
Tags:Vicious Labyrinth Conflict アーケア gothic hardcore Hardtrapcore sanyi idust idu5t daletto nuvolina greaper
BeatmapID:2118524
BeatmapSetID:994994
[Difficulty]
HPDrainRate:5
CircleSize:3.5
OverallDifficulty:8
ApproachRate:8
SliderMultiplier:1.45
SliderTickRate:1
[Events]
//Background and Video events
0,0,"background.jpg",0,0
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Layer 4 (Overlay)
//Storyboard Sound Samples
[TimingPoints]
1353,315.789473684211,4,1,11,50,1,0
3879,-100,4,1,11,75,0,0
5142,-100,4,2,11,50,0,0
16195,-100,4,1,22,80,0,0
16510,-100,4,1,11,70,0,0
18800,-100,4,1,11,70,0,0
19037,-100,4,1,11,70,0,0
23774,-100,4,1,11,70,0,0
24089,-100,4,1,11,74,0,0
26300,-100,4,1,22,80,0,0
26616,-100,4,1,33,78,0,1
26695,-100,4,1,33,78,0,0
31353,-100,4,1,22,80,0,0
31668,-100,4,1,33,78,0,1
31747,-100,4,1,33,78,0,0
36405,-100,4,1,33,60,0,0
36721,-100,4,1,11,83,0,1
56931,-100,4,1,44,73,0,0
61984,-100,4,1,44,73,0,0
64510,-100,4,1,44,76,0,0
65142,-100,4,1,44,79,0,0
65458,-100,4,1,44,82,0,0
65774,-100,4,1,22,70,0,0
66405,-100,4,1,22,75,0,0
66721,-100,4,1,22,80,0,0
67037,-100,4,2,22,65,0,0
75879,-100,4,1,22,70,0,0
76510,-100,4,1,22,75,0,0
76826,-100,4,1,22,80,0,0
77142,-100,4,1,22,60,0,0
77774,-100,4,2,22,75,0,0
80931,-100,4,2,22,80,0,0
81563,-100,4,2,22,80,0,0
82195,-100,4,2,22,75,0,0
86931,-100,4,1,22,80,0,0
87247,-100,4,1,33,70,0,1
87326,-100,4,1,33,78,0,0
89774,-100,4,1,33,78,0,1
89853,-100,4,1,33,78,0,0
91984,-100,4,1,22,80,0,0
92300,-100,4,1,11,83,0,1
97195,-100,4,1,11,83,0,0
97353,-100,4,1,44,83,0,1
102247,-100,4,1,44,83,0,0
102405,-100,4,1,44,87,0,1
104931,-100,4,1,11,90,0,1
106195,-100,4,1,44,94,0,1
107458,-100,4,1,11,80,0,0
111879,-100,4,1,22,70,0,0
112510,-100,4,1,11,80,0,0
115037,-100,4,1,44,70,0,0
115353,-100,4,1,44,75,0,0
115668,-100,4,1,44,80,0,0
115826,-100,4,1,44,85,0,0
115984,-100,4,1,44,90,0,0
116142,-100,4,1,44,95,0,0
116300,-100,4,2,11,60,0,0
[Colours]
Combo1 : 128,128,255
Combo2 : 105,50,252
Combo3 : 87,0,174
[HitObjects]
256,192,1353,12,0,2300,0:0:0:0:
104,278,2616,6,0,P|46:250|122:151,1,217.5,4|0,0:0|0:0,0:0:0:0:
296,95,3563,1,0,0:0:0:0:
223,87,3721,1,0,0:0:0:0:
377,239,3879,6,0,L|463:215,3,72.5,8|8|8|8,0:0|0:0|0:0|0:0,0:0:0:0:
291,102,4510,2,0,P|227:128|221:155,1,96.6666666666667,8|8,0:0|0:0,0:0:0:0:
327,169,4931,1,8,0:0:0:0:
47,271,5142,6,0,P|14:234|74:178,1,145,12|8,0:0|0:0,0:0:0:0:
322,97,5774,2,0,L|328:177,1,72.5,8|0,0:0|0:0,0:0:0:0:
235,159,6089,2,0,L|225:236,1,72.5,8|0,0:0|0:0,0:0:0:0:
380,310,6405,6,0,P|415:288|355:217,1,145,8|8,0:0|0:0,0:0:0:0:
136,88,7037,2,0,L|231:81,1,72.5,8|0,0:0|0:0,0:0:0:0:
288,71,7353,2,0,L|380:65,1,72.5,8|0,0:0|0:0,0:0:0:0:
207,294,7668,6,0,P|166:266|225:216,1,145,8|8,0:0|0:0,0:0:0:0:
474,87,8300,2,0,L|480:174,1,72.5,8|0,0:0|0:0,0:0:0:0:
394,216,8616,2,0,L|377:319,1,72.5,8|0,0:0|0:0,0:0:0:0:
228,106,8931,6,0,L|311:98,2,72.5,8|0|8,0:0|0:0|0:0,0:0:0:0:
473,80,9563,2,0,L|474:182,1,72.5,8|0,0:0|0:0,0:0:0:0:
396,212,9879,2,0,L|477:212,1,72.5,8|0,0:0|0:0,0:0:0:0:
314,299,10195,6,0,P|276:242|335:209,1,145,8|8,0:0|0:0,0:0:0:0:
65,78,10826,2,0,L|54:167,1,72.5,8|0,0:0|0:0,0:0:0:0:
148,151,11142,2,0,L|162:247,1,72.5,8|0,0:0|0:0,0:0:0:0:
313,163,11458,6,0,P|351:126|277:73,1,145,8|8,0:0|0:0,0:0:0:0:
40,252,12089,2,0,L|121:258,1,72.5,8|0,0:0|0:0,0:0:0:0:
192,202,12405,2,0,L|110:194,1,72.5,8|0,0:0|0:0,0:0:0:0:
275,280,12721,6,0,P|276:231|182:197,1,145,8|8,0:0|0:0,0:0:0:0:
460,55,13353,2,0,L|449:138,1,72.5,8|0,0:0|0:0,0:0:0:0:
356,136,13668,2,0,L|381:231,1,72.5,8|0,0:0|0:0,0:0:0:0:
219,220,13984,2,0,L|295:225,2,72.5
366,148,14616,6,0,L|281:138,1,72.5
209,281,14931,2,0,L|293:267,1,72.5
374,284,15247,6,0,P|433:267|367:109,1,290
83,109,16195,2,0,L|95:208,1,72.5,8|0,0:0|0:0,0:0:0:0:
311,291,16510,6,0,P|356:270|299:208,1,145,12|8,0:0|0:0,0:0:0:0:
245,198,16984,1,0,0:0:0:0:
398,157,17142,2,0,L|424:82,1,72.5,10|2,0:0|0:0,0:0:0:0:
386,83,17379,1,2,0:0:0:0:
349,79,17458,1,10,0:0:0:0:
75,139,17774,6,0,L|58:222,1,72.5,8|0,0:0|0:0,0:0:0:0:
147,213,18089,2,0,L|178:299,1,72.5,8|0,0:0|0:0,0:0:0:0:
329,203,18405,2,0,L|423:187,1,72.5,10|2,0:0|0:0,0:0:0:0:
364,197,18642,1,2,0:0:0:0:
55,83,19037,6,0,L|139:93,1,72.5,8|0,0:0|0:0,0:0:0:0:
215,142,19353,2,0,L|228:236,1,72.5,8|0,0:0|0:0,0:0:0:0:
74,183,19668,2,0,L|57:273,1,72.5,10|2,0:0|0:0,0:0:0:0:
96,261,19905,1,2,0:0:0:0:
131,268,19984,1,10,0:0:0:0:
403,223,20300,6,0,L|481:211,1,72.5,8|0,0:0|0:0,0:0:0:0:
387,153,20616,2,0,L|311:141,1,72.5,8|0,0:0|0:0,0:0:0:0:
468,127,20931,2,0,L|491:57,1,72.5,10|2,0:0|0:0,0:0:0:0:
454,48,21168,1,2,0:0:0:0:
417,43,21247,1,10,0:0:0:0:
146,171,21563,6,0,L|153:257,1,72.5,8|0,0:0|0:0,0:0:0:0:
245,226,21879,2,0,L|225:313,1,72.5,8|0,0:0|0:0,0:0:0:0:
74,73,22195,2,0,L|69:160,1,72.5,10|2,0:0|0:0,0:0:0:0:
107,140,22431,1,2,0:0:0:0:
142,134,22510,1,10,0:0:0:0:
417,284,22826,6,0,L|426:199,1,72.5,8|0,0:0|0:0,0:0:0:0:
342,207,23142,2,0,L|335:121,1,72.5,8|0,0:0|0:0,0:0:0:0:
182,108,23458,2,0,L|104:102,1,72.5,10|2,0:0|0:0,0:0:0:0:
146,104,23695,1,2,0:0:0:0:
434,284,24089,5,8,0:0:0:0:
486,214,24247,1,8,0:0:0:0:
462,130,24405,1,8,0:0:0:0:
382,95,24563,1,8,0:0:0:0:
228,204,24721,2,0,L|226:113,1,72.5,10|10,0:0|0:0,0:0:0:0:
263,131,24958,1,2,0:0:0:0:
299,131,25037,1,10,0:0:0:0:
53,275,25353,6,0,L|37:233,2,36.25,8|8|8,0:0|0:0|0:0,0:0:0:0:
153,179,25668,1,8,0:0:0:0:
116,176,25747,1,8,0:0:0:0:
79,174,25826,1,8,0:0:0:0:
175,100,25984,1,8,0:0:0:0:
138,97,26063,1,8,0:0:0:0:
101,93,26142,1,8,0:0:0:0:
338,264,26300,2,0,L|415:255,1,72.5,8|0,0:0|0:0,0:0:0:0:
184,220,26616,6,0,P|156:181|204:138,1,108.75,12|8,0:0|0:0,0:0:0:0:
386,133,27089,2,0,L|399:7,1,108.75,8|8,0:0|0:0,0:0:0:0:
199,27,27563,1,8,0:0:0:0:
276,22,27721,1,8,0:0:0:0:
49,266,27879,6,0,P|34:222|91:182,1,108.75,8|8,0:0|0:0,0:0:0:0:
182,189,28353,1,8,0:0:0:0:
352,146,28510,2,0,L|379:103,2,36.25,14|2|2,0:0|0:0|0:0,0:0:0:0:
183,88,28826,1,14,0:0:0:0:
146,84,28905,1,2,0:0:0:0:
110,78,28984,1,2,0:0:0:0:
351,297,29142,6,0,P|386:268|342:229,1,108.75,8|8,0:0|0:0,0:0:0:0:
155,216,29616,2,0,L|146:99,1,108.75,8|8,0:0|0:0,0:0:0:0:
341,71,30089,1,8,0:0:0:0:
419,64,30247,1,8,0:0:0:0:
187,267,30405,6,0,P|186:225|250:196,1,108.75,8|8,0:0|0:0,0:0:0:0:
130,152,30879,1,8,0:0:0:0:
299,120,31037,2,0,L|383:111,1,72.5,8|8,0:0|0:0,0:0:0:0:
132,37,31353,2,0,L|108:111,1,72.5,8|0,0:0|0:0,0:0:0:0:
336,305,31668,6,0,P|386:290|407:220,1,108.75,12|8,0:0|0:0,0:0:0:0:
212,226,32142,2,0,P|169:214|142:146,1,108.75,8|8,0:0|0:0,0:0:0:0:
336,81,32616,1,8,0:0:0:0:
260,70,32774,1,8,0:0:0:0:
485,287,32931,6,0,P|493:231|428:210,1,108.75,8|8,0:0|0:0,0:0:0:0:
346,209,33405,1,8,0:0:0:0:
176,152,33563,2,0,L|151:115,2,36.25,14|2|2,0:0|0:0|0:0,0:0:0:0:
346,111,33879,1,14,0:0:0:0:
382,107,33958,1,2,0:0:0:0:
418,103,34037,1,2,0:0:0:0:
193,276,34195,6,0,P|163:247|198:192,1,108.75,8|8,0:0|0:0,0:0:0:0:
382,159,34668,2,0,L|264:142,1,108.75,8|8,0:0|0:0,0:0:0:0:
80,69,35142,1,8,0:0:0:0:
45,143,35300,1,8,0:0:0:0:
276,286,35458,6,0,L|361:274,1,72.5,8|0,0:0|0:0,0:0:0:0:
311,268,35695,1,8,0:0:0:0:
155,177,35853,2,0,L|144:225,1,36.25,0|8,0:0|0:0,0:0:0:0:
381,169,36089,1,8,0:0:0:0:
426,156,36168,1,8,0:0:0:0:
463,127,36247,1,8,0:0:0:0:
479,82,36326,1,8,0:0:0:0:
477,34,36405,1,0,0:0:0:0:
95,267,36721,6,0,L|247:253,1,145,14|8,0:0|0:0,0:0:0:0:
161,205,37195,1,2,0:0:0:0:
314,206,37353,2,0,L|319:118,1,72.5,10|2,0:0|0:0,0:0:0:0:
165,120,37668,2,0,L|167:32,1,72.5,10|2,0:0|0:0,0:0:0:0:
430,52,38142,6,0,L|477:54,1,36.25,2|2,0:0|0:0,0:0:0:0:
430,52,38300,2,0,L|335:49,1,72.5,10|2,0:0|0:0,0:0:0:0:
272,145,38616,2,0,L|216:142,2,36.25,10|2|2,0:0|0:0|0:0,0:0:0:0:
427,255,38931,1,10,0:0:0:0:
354,247,39089,1,2,0:0:0:0:
127,252,39247,6,0,P|97:214|173:172,1,145,10|8,0:0|0:0,0:0:0:0:
242,166,39721,1,2,0:0:0:0:
87,103,39879,2,0,L|76:189,1,72.5,10|2,0:0|0:0,0:0:0:0:
232,60,40195,2,0,L|325:73,1,72.5,10|2,0:0|0:0,0:0:0:0:
72,266,40510,6,0,L|28:260,2,36.25,10|2|2,0:0|0:0|0:0,0:0:0:0:
156,202,40826,1,10,0:0:0:0:
119,198,40905,1,2,0:0:0:0:
82,195,40984,1,2,0:0:0:0:
241,176,41142,5,10,0:0:0:0:
275,149,41221,1,0,0:0:0:0:
292,109,41300,1,2,0:0:0:0:
288,65,41379,1,0,0:0:0:0:
258,33,41458,1,10,0:0:0:0:
215,24,41537,1,0,0:0:0:0:
172,32,41616,1,10,0:0:0:0:
404,302,41774,6,0,P|441:273|379:216,1,145,14|8,0:0|0:0,0:0:0:0:
319,211,42247,1,2,0:0:0:0:
471,65,42405,2,0,L|484:161,1,72.5,10|2,0:0|0:0,0:0:0:0:
326,55,42721,2,0,L|337:150,1,72.5,10|2,0:0|0:0,0:0:0:0:
81,177,43195,6,0,L|38:174,1,36.25,2|2,0:0|0:0,0:0:0:0:
81,177,43353,2,0,L|164:183,1,72.5,10|2,0:0|0:0,0:0:0:0:
240,187,43668,2,0,L|283:188,1,36.25,10|2,0:0|0:0,0:0:0:0:
276,187,43826,1,2,0:0:0:0:
123,273,43984,1,10,0:0:0:0:
200,270,44142,1,2,0:0:0:0:
426,279,44300,6,0,P|465:256|407:198,1,145,14|8,0:0|0:0,0:0:0:0:
338,197,44774,1,2,0:0:0:0:
186,146,44931,2,0,L|107:141,1,72.5,14|2,0:0|0:0,0:0:0:0:
268,70,45247,2,0,L|361:75,1,72.5,10|2,0:0|0:0,0:0:0:0:
116,301,45563,6,0,L|108:216,1,72.5,14|2,0:0|0:0,0:0:0:0:
145,226,45800,1,2,0:0:0:0:
181,223,45879,2,0,L|82:227,1,72.5,14|2,0:0|0:0,0:0:0:0:
337,187,46195,5,14,0:0:0:0:
377,162,46274,1,2,0:0:0:0:
391,117,46353,1,10,0:0:0:0:
373,74,46431,1,2,0:0:0:0:
329,56,46510,1,14,0:0:0:0:
368,51,46589,1,2,0:0:0:0:
407,42,46668,1,10,0:0:0:0:
177,297,46826,6,0,P|128:254|185:206,1,145,14|8,0:0|0:0,0:0:0:0:
246,204,47300,1,2,0:0:0:0:
94,74,47458,2,0,L|84:157,1,72.5,10|2,0:0|0:0,0:0:0:0:
237,55,47774,2,0,L|158:48,1,72.5,10|2,0:0|0:0,0:0:0:0:
425,198,48247,6,0,L|430:149,1,36.25,2|2,0:0|0:0,0:0:0:0:
392,157,48405,2,0,L|317:152,1,72.5,10|0,0:0|0:0,0:0:0:0:
401,84,48721,2,0,L|445:86,2,36.25,10|2|2,0:0|0:0|0:0,0:0:0:0:
246,47,49037,1,10,0:0:0:0:
319,42,49195,1,2,0:0:0:0:
86,272,49353,6,0,P|41:261|41:166,1,145,10|8,0:0|0:0,0:0:0:0:
104,173,49826,1,2,0:0:0:0:
257,187,49984,2,0,L|263:111,1,72.5,10|2,0:0|0:0,0:0:0:0:
110,41,50300,2,0,L|200:48,1,72.5,10|2,0:0|0:0,0:0:0:0:
414,255,50616,6,0,L|461:257,2,36.25,10|2|2,0:0|0:0|0:0,0:0:0:0:
333,137,50931,2,0,L|330:188,1,36.25,10|2,0:0|0:0,0:0:0:0:
368,178,51089,1,2,0:0:0:0:
223,249,51247,5,10,0:0:0:0:
188,217,51326,1,0,0:0:0:0:
178,170,51405,1,2,0:0:0:0:
188,123,51484,1,0,0:0:0:0:
224,92,51563,1,10,0:0:0:0:
262,75,51642,1,0,0:0:0:0:
308,67,51721,1,10,0:0:0:0:
61,298,51879,6,0,P|62:243|144:201,1,145,14|8,0:0|0:0,0:0:0:0:
205,197,52353,1,2,0:0:0:0:
356,160,52510,2,0,L|363:248,1,72.5,10|2,0:0|0:0,0:0:0:0:
205,92,52826,2,0,L|294:87,1,72.5,10|2,0:0|0:0,0:0:0:0:
18,181,53300,6,0,L|24:228,1,36.25,2|2,0:0|0:0,0:0:0:0:
59,216,53458,2,0,L|138:205,1,72.5,10|2,0:0|0:0,0:0:0:0:
218,195,53774,1,10,0:0:0:0:
229,160,53853,1,2,0:0:0:0:
240,126,53931,1,2,0:0:0:0:
88,71,54089,1,10,0:0:0:0:
164,63,54247,1,2,0:0:0:0:
392,298,54405,6,0,P|437:265|384:220,1,145,14|8,0:0|0:0,0:0:0:0:
312,205,54879,1,2,0:0:0:0:
161,118,55037,2,0,L|246:113,1,72.5,14|2,0:0|0:0,0:0:0:0:
389,79,55353,2,0,L|391:164,1,72.5,10|2,0:0|0:0,0:0:0:0:
161,248,55668,6,0,L|80:251,1,72.5,14|2,0:0|0:0,0:0:0:0:
177,178,55984,2,0,L|91:180,1,72.5,14|2,0:0|0:0,0:0:0:0:
334,278,56300,5,14,0:0:0:0:
368,240,56379,1,2,0:0:0:0:
364,189,56458,1,10,0:0:0:0:
327,153,56537,1,2,0:0:0:0:
276,145,56616,1,14,0:0:0:0:
316,130,56695,1,2,0:0:0:0:
358,119,56774,1,10,0:0:0:0:
117,283,56931,6,0,P|83:259|147:229,1,145,4|8,0:0|0:0,0:0:0:0:
296,183,57563,2,0,L|377:188,2,72.5,0|0|8,0:0|0:0|0:0,0:0:0:0:
142,74,58037,6,0,L|152:154,1,72.5
231,141,58353,2,0,L|246:228,1,72.5,0|8,0:0|0:0,0:0:0:0:
161,220,58668,1,0,0:0:0:0:
318,272,58826,2,0,L|400:261,1,72.5,10|0,0:0|0:0,0:0:0:0:
236,174,59142,2,0,L|153:187,1,72.5,10|0,0:0|0:0,0:0:0:0:
394,176,59458,6,0,P|433:145|370:105,1,145,4|8,0:0|0:0,0:0:0:0:
228,102,60089,2,0,L|144:98,2,72.5,0|0|8,0:0|0:0|0:0,0:0:0:0:
381,182,60563,6,0,L|395:264,1,72.5
319,227,60879,2,0,L|301:304,1,72.5,0|8,0:0|0:0,0:0:0:0:
382,311,61195,1,0,0:0:0:0:
225,218,61353,2,0,L|236:133,1,72.5,10|2,0:0|0:0,0:0:0:0:
387,120,61668,2,0,L|375:37,1,72.5,10|2,0:0|0:0,0:0:0:0:
147,165,61984,6,0,L|65:161,1,72.5,8|0,0:0|0:0,0:0:0:0:
158,93,62300,2,0,L|247:99,1,72.5,8|0,0:0|0:0,0:0:0:0:
141,189,62616,2,0,L|132:281,1,72.5,8|0,0:0|0:0,0:0:0:0:
219,196,62931,2,0,L|227:102,1,72.5,8|0,0:0|0:0,0:0:0:0:
380,285,63247,6,0,L|386:192,1,72.5,8|0,0:0|0:0,0:0:0:0:
309,213,63563,2,0,L|316:120,1,72.5,8|0,0:0|0:0,0:0:0:0:
161,92,63879,2,0,L|84:87,1,72.5,10|0,0:0|0:0,0:0:0:0:
242,33,64195,2,0,L|327:38,1,72.5,10|0,0:0|0:0,0:0:0:0:
84,273,64510,5,8,0:0:0:0:
40,189,64668,1,8,0:0:0:0:
83,105,64826,1,8,0:0:0:0:
171,71,64984,1,8,0:0:0:0:
323,217,65142,6,0,L|379:218,2,36.25,8|8|8,0:0|0:0|0:0,0:0:0:0:
169,146,65458,1,8,0:0:0:0:
129,144,65537,1,8,0:0:0:0:
89,141,65616,1,8,0:0:0:0:
317,303,65774,1,8,0:0:0:0:
341,220,65931,1,8,0:0:0:0:
303,141,66089,1,8,0:0:0:0:
221,111,66247,1,8,0:0:0:0:
373,70,66405,6,0,L|433:73,2,36.25,8|8|8,0:0|0:0|0:0,0:0:0:0:
219,200,66721,1,8,0:0:0:0:
255,204,66800,1,8,0:0:0:0:
291,207,66879,1,8,0:0:0:0:
61,118,67037,5,2,0:0:0:0:
208,137,67353,2,0,L|200:225,1,72.5
128,202,67668,1,8,0:0:0:0:
272,121,67984,2,0,L|264:220,1,72.5
113,181,68300,5,2,0:0:0:0:
257,304,68616,2,0,L|262:226,1,72.5,0|0,0:0|0:0,0:0:0:0:
184,219,68931,1,8,0:0:0:0:
260,147,69089,2,0,L|167:141,2,72.5,0|2|0,0:0|0:0|0:0,0:0:0:0:
485,157,69563,5,2,0:0:0:0:
328,224,69879,2,0,L|244:219,1,72.5,0|0,0:0|0:0,0:0:0:0:
344,149,70195,1,8,0:0:0:0:
192,209,70510,2,0,L|110:203,1,72.5,0|0,0:0|0:0,0:0:0:0:
273,161,70826,5,2,0:0:0:0:
134,141,71142,2,0,L|49:135,1,72.5
216,78,71458,2,0,L|209:165,1,72.5,8|0,0:0|0:0,0:0:0:0:
363,192,71774,1,2,0:0:0:0:
390,269,71931,1,2,0:0:0:0:
167,251,72089,5,2,0:0:0:0:
308,145,72405,2,0,L|213:140,1,72.5
323,73,72721,1,8,0:0:0:0:
175,215,73037,2,0,L|259:222,1,72.5
93,153,73353,5,2,0:0:0:0:
241,50,73668,2,0,L|149:45,1,72.5
248,117,73984,1,8,0:0:0:0:
159,172,74142,2,0,L|154:270,1,72.5,0|2,0:0|0:0,0:0:0:0:
236,249,74458,1,0,0:0:0:0:
461,254,74616,5,2,0:0:0:0:
490,208,74695,1,0,0:0:0:0:
477,155,74774,1,0,0:0:0:0:
397,119,74931,2,0,L|486:104,1,72.5,0|0,0:0|0:0,0:0:0:0:
379,47,75247,1,8,0:0:0:0:
238,212,75563,6,0,L|323:207,1,72.5,0|0,0:0|0:0,0:0:0:0:
158,137,75879,1,8,0:0:0:0:
129,201,76037,1,8,0:0:0:0:
282,122,76195,1,8,0:0:0:0:
310,46,76353,1,8,0:0:0:0:
157,292,76510,5,8,0:0:0:0:
119,242,76589,1,8,0:0:0:0:
141,184,76668,1,8,0:0:0:0:
189,145,76747,1,8,0:0:0:0:
238,136,76826,1,8,0:0:0:0:
192,122,76905,1,8,0:0:0:0:
146,110,76984,1,8,0:0:0:0:
298,237,77142,2,0,L|342:233,1,36.25
209,169,77458,2,0,L|155:166,1,36.25,0|0,0:0|0:0,0:0:0:0:
484,162,77774,6,0,L|468:87,1,72.5,6|0,0:0|0:0,0:0:0:0:
315,228,78089,1,2,0:0:0:0:
386,219,78247,1,2,0:0:0:0:
320,168,78405,2,0,L|342:81,1,72.5,0|2,0:0|0:0,0:0:0:0:
185,227,78721,6,0,L|110:223,1,72.5,2|2,0:0|0:0,0:0:0:0:
266,161,79037,2,0,L|344:165,3,72.5,8|0|2|0,0:0|0:0|0:0|0:0,0:0:0:0:
105,284,79668,6,0,L|97:205,1,72.5,2|0,0:0|0:0,0:0:0:0:
182,136,79984,2,0,L|100:139,1,72.5,2|0,0:0|0:0,0:0:0:0:
266,273,80300,2,0,L|306:198,1,72.5,10|0,0:0|0:0,0:0:0:0:
147,63,80616,1,2,0:0:0:0:
110,139,80774,1,2,0:0:0:0:
338,62,80931,5,2,0:0:0:0:
416,126,81089,2,0,L|332:117,2,72.5,2|2|2,0:0|0:0|0:0,0:0:0:0:
264,275,81563,2,0,L|258:197,1,72.5,8|2,0:0|0:0,0:0:0:0:
411,169,81879,1,2,0:0:0:0:
331,146,82037,1,2,0:0:0:0:
101,146,82195,6,0,L|97:66,1,72.5,6|0,0:0|0:0,0:0:0:0:
172,66,82510,1,10,0:0:0:0:
408,266,82826,6,0,L|421:172,1,72.5,10|0,0:0|0:0,0:0:0:0:
263,123,83142,1,10,0:0:0:0:
347,111,83300,1,10,0:0:0:0:
360,77,83379,1,10,0:0:0:0:
373,43,83458,1,2,0:0:0:0:
284,35,83616,5,0,0:0:0:0:
195,234,83774,2,0,L|279:240,1,72.5,10|0,0:0|0:0,0:0:0:0:
114,116,84089,2,0,L|107:205,1,72.5,10|0,0:0|0:0,0:0:0:0:
192,59,84405,1,10,0:0:0:0:
155,57,84484,1,8,0:0:0:0:
118,53,84563,1,8,0:0:0:0:
356,287,84721,5,10,0:0:0:0:
417,215,84879,1,8,0:0:0:0:
402,121,85037,1,8,0:0:0:0:
316,81,85195,1,8,0:0:0:0:
165,132,85353,1,8,0:0:0:0:
127,206,85510,1,8,0:0:0:0:
279,162,85668,1,8,0:0:0:0:
310,234,85826,1,8,0:0:0:0:
157,295,85984,6,0,L|149:202,1,72.5,10|0,0:0|0:0,0:0:0:0:
236,235,86300,1,8,0:0:0:0:
149,224,86458,1,8,0:0:0:0:
301,168,86616,1,8,0:0:0:0:
344,172,86695,1,8,0:0:0:0:
387,176,86774,1,8,0:0:0:0:
163,87,86931,2,0,L|144:169,1,72.5,8|0,0:0|0:0,0:0:0:0:
384,311,87247,6,0,P|406:275|349:230,1,108.75,14|8,0:0|0:0,0:0:0:0:
175,137,87721,2,0,L|255:128,2,72.5,10|2|2,0:0|0:0|0:0,0:0:0:0:
330,106,88195,2,0,L|344:192,1,72.5,10|10,0:0|0:0,0:0:0:0:
116,274,88510,6,0,L|36:265,1,72.5,8|2,0:0|0:0,0:0:0:0:
80,266,88747,1,10,0:0:0:0:
115,260,88826,1,2,0:0:0:0:
43,190,88984,1,10,0:0:0:0:
196,181,89142,2,0,L|236:183,2,36.25,14|2|2,0:0|0:0|0:0,0:0:0:0:
43,68,89458,1,14,0:0:0:0:
82,69,89537,1,2,0:0:0:0:
121,72,89616,1,2,0:0:0:0:
351,289,89774,6,0,P|383:254|340:206,1,108.75,14|8,0:0|0:0,0:0:0:0:
165,135,90247,2,0,L|244:130,2,72.5,10|2|2,0:0|0:0|0:0,0:0:0:0:
319,79,90721,2,0,L|399:73,1,72.5,10|10,0:0|0:0,0:0:0:0:
163,231,91037,6,0,L|113:233,1,36.25,10|2,0:0|0:0,0:0:0:0:
90,232,91195,1,2,0:0:0:0:
242,156,91353,2,0,L|244:207,1,36.25,2|2,0:0|0:0,0:0:0:0:
207,194,91510,1,10,0:0:0:0:
352,108,91668,2,0,L|399:108,2,36.25,10|2|10,0:0|0:0|0:0,0:0:0:0:
128,60,91984,2,0,L|118:152,1,72.5,8|0,0:0|0:0,0:0:0:0:
361,308,92300,6,0,P|401:281|338:223,1,145,14|8,0:0|0:0,0:0:0:0:
282,223,92774,1,2,0:0:0:0:
433,165,92931,2,0,L|440:71,1,72.5,10|2,0:0|0:0,0:0:0:0:
284,125,93247,2,0,L|278:33,1,72.5,10|2,0:0|0:0,0:0:0:0:
39,227,93721,6,0,L|99:219,1,36.25,2|2,0:0|0:0,0:0:0:0:
111,218,93879,2,0,L|194:209,1,72.5,10|2,0:0|0:0,0:0:0:0:
261,174,94195,1,10,0:0:0:0:
270,139,94274,1,2,0:0:0:0:
279,103,94353,1,2,0:0:0:0:
128,59,94510,1,10,0:0:0:0:
200,51,94668,1,2,0:0:0:0:
423,295,94826,6,0,P|456:249|392:194,1,145,14|8,0:0|0:0,0:0:0:0:
331,197,95300,1,2,0:0:0:0:
179,113,95458,2,0,L|170:199,1,72.5,14|2,0:0|0:0,0:0:0:0:
324,74,95774,2,0,L|404:77,1,72.5,10|2,0:0|0:0,0:0:0:0:
159,307,96089,6,0,L|155:225,1,72.5,14|2,0:0|0:0,0:0:0:0:
193,235,96326,1,2,0:0:0:0:
228,235,96405,2,0,L|150:235,1,72.5,14|2,0:0|0:0,0:0:0:0:
387,222,96721,1,14,0:0:0:0:
426,196,96800,1,2,0:0:0:0:
437,150,96879,1,10,0:0:0:0:
410,110,96958,1,2,0:0:0:0:
364,97,97037,2,0,L|369:48,1,36.25,14|2,0:0|0:0,0:0:0:0:
407,64,97195,1,10,0:0:0:0:
171,293,97353,6,0,P|127:279|109:186,1,145,6|10,0:0|0:0,0:0:0:0:
178,189,97826,1,2,0:0:0:0:
331,190,97984,2,0,L|345:100,1,72.5,2|0,0:0|0:0,0:0:0:0:
259,49,98300,2,0,L|338:56,1,72.5,10|0,0:0|0:0,0:0:0:0:
178,246,98616,5,2,0:0:0:0:
268,200,98774,2,0,L|275:149,1,36.25,2|2,0:0|0:0,0:0:0:0:
237,155,98931,2,0,L|152:143,1,72.5,10|2,0:0|0:0,0:0:0:0:
318,97,99247,2,0,L|404:88,1,72.5,2|0,0:0|0:0,0:0:0:0:
310,180,99563,2,0,L|395:172,1,72.5,10|2,0:0|0:0,0:0:0:0:
153,190,99879,6,0,L|138:287,1,72.5,2|2,0:0|0:0,0:0:0:0:
178,264,100116,1,0,0:0:0:0:
214,268,100195,1,8,0:0:0:0:
293,271,100353,1,2,0:0:0:0:
142,89,100510,2,0,L|135:169,1,72.5,2|2,0:0|0:0,0:0:0:0:
288,43,100826,1,10,0:0:0:0:
210,32,100984,1,2,0:0:0:0:
440,297,101142,6,0,L|457:208,1,72.5,2|2,0:0|0:0,0:0:0:0:
417,221,101379,1,2,0:0:0:0:
380,217,101458,2,0,L|288:209,1,72.5,10|2,0:0|0:0,0:0:0:0:
460,153,101774,1,2,0:0:0:0:
478,113,101853,1,0,0:0:0:0:
457,71,101931,1,0,0:0:0:0:
414,50,102010,1,0,0:0:0:0:
367,45,102089,2,0,L|442:30,1,72.5,10|2,0:0|0:0,0:0:0:0:
210,265,102405,6,0,L|115:258,1,72.5,10|10,0:0|0:0,0:0:0:0:
292,211,102721,2,0,L|385:217,1,72.5,10|10,0:0|0:0,0:0:0:0:
204,55,103037,2,0,L|216:137,1,72.5,10|10,0:0|0:0,0:0:0:0:
304,106,103353,2,0,L|317:179,1,72.5,10|10,0:0|0:0,0:0:0:0:
94,264,103668,6,0,L|84:181,1,72.5,10|10,0:0|0:0,0:0:0:0:
238,189,103984,2,0,L|247:272,1,72.5,10|10,0:0|0:0,0:0:0:0:
93,99,104300,2,0,L|183:91,1,72.5,10|10,0:0|0:0,0:0:0:0:
251,83,104616,1,10,0:0:0:0:
165,92,104774,1,10,0:0:0:0:
399,238,104931,6,0,L|442:242,2,36.25,12|8|8,0:0|0:0|0:0,0:0:0:0:
315,171,105247,1,8,0:0:0:0:
358,166,105326,1,8,0:0:0:0:
401,160,105405,1,10,0:0:0:0:
249,115,105563,1,10,0:0:0:0:
205,115,105642,1,8,0:0:0:0:
161,115,105721,1,10,0:0:0:0:
390,62,105879,2,0,L|401:109,1,36.25,10|8,0:0|0:0,0:0:0:0:
354,102,106037,1,10,0:0:0:0:
117,277,106195,6,0,L|231:265,1,108.75,14|10,0:0|0:0,0:0:0:0:
116,179,106668,1,10,0:0:0:0:
347,138,106826,1,10,0:0:0:0:
263,72,106984,1,10,0:0:0:0:
251,114,107063,1,10,0:0:0:0:
297,124,107142,2,0,L|395:141,1,72.5,10|10,0:0|0:0,0:0:0:0:
135,282,107458,6,0,P|97:254|162:201,1,145,14|10,0:0|0:0,0:0:0:0:
404,220,108089,2,0,L|415:128,1,72.5,12|8,0:0|0:0,0:0:0:0:
376,144,108326,1,8,0:0:0:0:
340,138,108405,1,8,0:0:0:0:
188,145,108563,2,0,L|179:65,1,72.5,12|8,0:0|0:0,0:0:0:0:
216,70,108800,1,8,0:0:0:0:
252,65,108879,1,8,0:0:0:0:
406,285,109037,6,0,L|419:206,1,72.5,12|8,0:0|0:0,0:0:0:0:
264,188,109353,2,0,L|184:179,1,72.5,10|0,0:0|0:0,0:0:0:0:
344,103,109668,2,0,L|431:113,1,72.5,10|0,0:0|0:0,0:0:0:0:
193,256,109984,5,8,0:0:0:0:
157,251,110063,1,8,0:0:0:0:
121,245,110142,2,0,L|102:161,1,72.5,8|14,0:0|0:0,0:0:0:0:
366,122,110616,2,0,L|451:114,1,72.5,12|8,0:0|0:0,0:0:0:0:
401,121,110853,1,8,0:0:0:0:
365,121,110931,1,8,0:0:0:0:
213,67,111089,2,0,L|123:75,1,72.5,12|8,0:0|0:0,0:0:0:0:
177,72,111326,1,8,0:0:0:0:
213,67,111405,1,8,0:0:0:0:
365,222,111563,6,0,L|450:213,1,72.5,12|8,0:0|0:0,0:0:0:0:
284,141,111879,1,8,0:0:0:0:
244,189,111984,1,8,0:0:0:0:
247,251,112089,1,8,0:0:0:0:
280,304,112195,1,8,0:0:0:0:
337,327,112300,1,8,0:0:0:0:
49,237,112510,6,0,P|21:210|83:141,1,145,14|10,0:0|0:0,0:0:0:0:
341,95,113142,2,0,L|255:88,1,72.5,12|8,0:0|0:0,0:0:0:0:
305,92,113379,1,8,0:0:0:0:
341,96,113458,1,8,0:0:0:0:
190,208,113616,2,0,L|283:201,1,72.5,12|8,0:0|0:0,0:0:0:0:
225,205,113853,1,8,0:0:0:0:
189,208,113931,1,8,0:0:0:0:
341,132,114089,6,0,L|260:126,1,72.5,12|8,0:0|0:0,0:0:0:0:
423,290,114405,2,0,L|435:211,1,72.5,10|0,0:0|0:0,0:0:0:0:
282,212,114721,2,0,L|295:133,1,72.5,10|0,0:0|0:0,0:0:0:0:
64,257,115037,5,14,0:0:0:0:
17,183,115195,1,8,0:0:0:0:
54,104,115353,1,14,0:0:0:0:
135,74,115510,1,8,0:0:0:0:
288,75,115668,2,0,L|345:78,2,36.25,12|8|8,0:0|0:0|0:0,0:0:0:0:
66,206,115984,1,12,0:0:0:0:
103,208,116063,1,8,0:0:0:0:
139,209,116142,1,8,0:0:0:0:
359,284,116300,6,0,L|437:270,1,72.5,8|8,0:0|0:0,0:0:0:0:
335,207,116616,2,0,L|422:193,1,72.5,8|8,0:0|0:0,0:0:0:0:
316,127,116931,2,0,L|396:115,1,72.5,8|8,0:0|0:0,0:0:0:0:
146,145,117247,1,12,0:0:0:0:
+796
View File
@@ -0,0 +1,796 @@
osu file format v14
[General]
AudioFilename: audio.mp3
AudioLeadIn: 0
PreviewTime: 25309
Countdown: 0
SampleSet: Soft
StackLeniency: 0.5
Mode: 0
LetterboxInBreaks: 0
WidescreenStoryboard: 1
[Editor]
DistanceSpacing: 0.7
BeatDivisor: 8
GridSize: 4
TimelineZoom: 1.8
[Metadata]
Title:re[in]flaw
TitleUnicode:re[in]flaw
Artist:MYUKKE.
ArtistUnicode:MYUKKE.
Creator:captin1
Version:toybot's Expert
Source:
Tags:fa featured artist anna apple toybot vikala meiikyuu drumstep dubstep electronic instrumental reinflaw
BeatmapID:2785319
BeatmapSetID:1344871
[Difficulty]
HPDrainRate:5
CircleSize:4.5
OverallDifficulty:8.8
ApproachRate:9.3
SliderMultiplier:1.7
SliderTickRate:1
[Events]
//Background and Video events
0,0,"87195968_p0.jpg",0,0
//Break Periods
2,48186,51667
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Layer 4 (Overlay)
//Storyboard Sound Samples
[TimingPoints]
2810,352.941176470588,4,2,0,60,1,0
13751,-500,4,2,1,60,0,0
14104,-66.6666666666667,4,2,1,70,0,0
19398,-133.333333333333,4,2,1,70,0,0
19751,-100,4,2,1,70,0,0
22927,-100,4,2,1,80,0,0
23633,-100,4,2,1,70,0,0
23986,-66.6666666666667,4,2,1,60,0,0
25045,-50,4,2,1,60,0,0
25398,-50,4,2,1,90,0,0
25574,-66.6666666666667,4,2,1,90,0,0
26810,-40,4,2,1,90,0,0
27339,-66.6666666666667,4,2,1,90,0,0
30294,-66.6666666666667,4,2,1,5,0,0
30339,-66.6666666666667,4,2,1,90,0,0
30559,-66.6666666666667,4,2,1,50,0,0
30692,-133.333333333333,4,2,1,50,0,0
30868,-133.333333333333,4,2,1,70,0,0
31045,-66.6666666666667,4,2,1,90,0,0
32457,-40,4,2,1,90,0,0
32986,-66.6666666666667,4,2,1,90,0,0
35280,-66.6666666666667,4,2,1,80,0,0
35457,-100,4,2,1,80,0,0
36692,-66.6666666666667,4,2,1,90,0,0
37751,-50,4,2,1,90,0,0
37883,-50,4,2,1,5,0,0
37927,-50,4,2,1,90,0,0
38059,-50,4,2,1,5,0,0
38104,-66.6666666666667,4,2,1,90,0,0
39294,-66.6666666666667,4,2,1,5,0,0
39339,-66.6666666666667,4,2,1,60,0,0
39515,-66.6666666666667,4,2,1,90,0,0
40883,-66.6666666666667,4,2,1,5,0,0
40927,-66.6666666666667,4,2,1,90,0,0
41854,-66.6666666666667,4,2,1,50,0,0
41986,-133.333333333333,4,2,1,50,0,0
42162,-133.333333333333,4,2,1,70,0,0
42339,-66.6666666666667,4,2,1,90,0,0
43398,-50,4,2,1,90,0,0
43530,-50,4,2,1,5,0,0
43574,-50,4,2,1,90,0,0
43706,-50,4,2,1,5,0,0
43751,-66.6666666666667,4,2,1,90,0,0
44942,-66.6666666666667,4,2,1,5,0,0
44986,-66.6666666666667,4,2,1,60,0,0
45162,-66.6666666666667,4,2,1,90,0,0
45515,-50,4,2,1,90,0,0
46574,-100,4,2,1,80,0,0
47280,-66.6666666666667,4,2,1,80,0,0
47633,-66.6666666666667,4,2,1,70,0,0
52574,-66.6666666666667,4,2,1,60,0,0
59633,-133.333333333333,4,2,0,60,0,0
70574,-100,4,2,1,70,0,0
71986,-100,4,2,1,80,0,0
76221,-100,4,2,1,70,0,0
76751,-80,4,2,1,70,0,0
79398,-80,4,2,1,75,0,0
79751,-80,4,2,1,80,0,0
80457,-66.6666666666667,4,2,1,60,0,0
81515,-50,4,2,1,60,0,0
81868,-50,4,2,1,90,0,0
82353,-50,4,2,1,5,0,0
82398,-50,4,2,1,90,0,0
83280,-33.3333333333333,4,2,1,90,0,0
83633,-50,4,2,1,90,0,0
84294,-50,4,2,1,5,0,0
84339,-50,4,2,1,90,0,0
85177,-50,4,2,1,5,0,0
85221,-50,4,2,1,90,0,0
86412,-50,4,2,1,5,0,0
86457,-50,4,2,1,90,0,0
86589,-50,4,2,1,5,0,0
86633,-50,4,2,1,90,0,0
87162,-50,4,2,1,70,0,0
87515,-50,4,2,1,90,0,0
87647,-50,4,2,1,5,0,0
87692,-50,4,2,1,90,0,0
88927,-40,4,2,1,90,0,0
89280,-50,4,2,1,90,0,0
89942,-50,4,2,1,5,0,0
89986,-50,4,2,1,90,0,0
90471,-50,4,2,1,5,0,0
90515,-50,4,2,1,90,0,0
91927,-100,4,2,1,90,0,0
93162,-50,4,2,1,90,0,0
93515,-40,4,2,1,90,0,0
94221,-50,4,2,1,90,0,0
94927,-100,4,2,1,90,0,0
94971,-100,4,2,1,5,0,0
95015,-100,4,2,1,90,0,0
95059,-100,4,2,1,5,0,0
95104,-100,4,2,1,90,0,0
95148,-100,4,2,1,5,0,0
95192,-100,4,2,1,90,0,0
95236,-100,4,2,1,5,0,0
95280,-100,4,2,1,90,0,0
95457,-50,4,2,1,90,0,0
96339,-40,4,2,1,90,0,0
96471,-40,4,2,1,5,0,0
96515,-40,4,2,1,90,0,0
97045,-50,4,2,1,90,0,0
98324,-50,4,2,1,60,0,0
98457,-50,4,2,1,70,0,0
98810,-50,4,2,1,90,0,0
98942,-50,4,2,1,5,0,0
98986,-50,4,2,1,90,0,0
100000,-50,4,2,1,5,0,0
100045,-50,4,2,1,90,0,0
100574,-100,4,2,1,90,0,0
100618,-100,4,2,1,5,0,0
100662,-100,4,2,1,90,0,0
100706,-100,4,2,1,5,0,0
100751,-100,4,2,1,90,0,0
100795,-100,4,2,1,5,0,0
100839,-100,4,2,1,90,0,0
100883,-100,4,2,1,5,0,0
100927,-100,4,2,1,90,0,0
101104,-50,4,2,1,90,0,0
101412,-50,4,2,1,5,0,0
101457,-50,4,2,1,70,0,0
101633,-50,4,2,1,90,0,0
101765,-50,4,2,1,5,0,0
101810,-50,4,2,1,90,0,0
101986,-40,4,2,1,90,0,0
102339,-50,4,2,1,90,0,0
103045,-100,4,2,1,80,0,0
104104,-66.6666666666667,4,2,0,60,0,0
104456,-66.6666666666667,4,2,1,60,0,0
104986,-100,4,2,1,60,0,0
113986,-50,4,2,1,60,0,0
114339,-66.6666666666667,4,2,1,60,0,0
[Colours]
Combo1 : 255,0,0
Combo2 : 192,192,192
Combo3 : 128,128,128
Combo4 : 255,128,128
Combo5 : 128,255,255
Combo6 : 128,0,255
Combo7 : 255,0,128
[HitObjects]
34,31,2810,5,6,2:0:0:0:
49,79,2986,1,0,2:0:0:0:
66,126,3162,1,2,2:3:0:0:
0,225,3339,1,2,2:0:0:0:
23,214,3427,1,0,2:0:0:0:
40,195,3515,1,0,2:0:0:0:
45,220,3604,1,0,2:0:0:0:
61,239,3692,1,2,2:3:0:0:
14,292,3868,2,0,P|65:271|107:274,1,85,2|0,2:0|2:0,2:0:0:0:
145,323,4133,1,0,2:0:0:0:
145,323,4221,5,2,2:0:0:0:
192,341,4398,1,0,2:0:0:0:
239,358,4574,1,2,2:3:0:0:
228,285,4751,1,0,2:0:0:0:
213,213,4927,1,2,2:0:0:0:
284,236,5104,1,2,2:3:0:0:
180,230,5280,1,2,2:0:0:0:
320,222,5457,2,0,L|330:166,1,42.5,0|0,2:0|2:0,2:0:0:0:
368,271,5633,85,2,2:0:0:0:
417,258,5810,1,0,2:0:0:0:
466,245,5986,1,2,2:3:0:0:
450,103,6162,1,2,2:0:0:0:
424,105,6251,1,0,2:0:0:0:
407,124,6339,1,0,2:0:0:0:
406,149,6427,1,0,2:0:0:0:
420,170,6515,1,2,2:3:0:0:
491,159,6692,2,0,P|481:117|482:75,1,85,2|0,2:0|2:0,2:0:0:0:
456,7,6957,1,0,2:0:0:0:
456,7,7045,6,0,L|351:27,1,85,2|0,2:0|2:0,2:0:0:0:
312,62,7398,1,2,2:3:0:0:
262,51,7574,1,0,2:0:0:0:
211,42,7751,1,2,2:0:0:0:
229,112,7927,1,2,2:3:0:0:
253,176,8104,1,2,2:0:0:0:
154,225,8280,1,0,2:0:0:0:
51,269,8457,5,2,2:0:0:0:
41,234,8633,1,2,2:3:0:0:
74,243,8810,1,10,2:0:0:0:
114,302,8986,1,2,2:0:0:0:
124,304,9074,1,0,2:0:0:0:
134,307,9162,1,0,2:0:0:0:
144,310,9251,1,0,2:0:0:0:
155,312,9339,1,2,2:3:0:0:
213,358,9515,1,10,2:0:0:0:
318,272,9692,5,0,2:0:0:0:
294,262,9780,1,0,2:0:0:0:
277,281,9868,1,2,2:0:0:0:
311,344,10045,1,2,2:3:0:0:
277,191,10221,86,0,L|265:140,1,42.5,10|0,2:0|2:0,2:0:0:0:
218,130,10398,2,0,L|268:145,1,42.5,0|0,2:0|2:0,2:0:0:0:
301,109,10574,2,2,L|262:144,1,42.5,2|0,2:0|2:0,2:0:0:0:
210,199,10751,5,0,2:0:0:0:
342,166,10927,1,10,2:0:0:0:
440,264,11104,1,0,2:0:0:0:
438,338,11280,85,2,2:0:0:0:
408,361,11457,1,2,2:3:0:0:
404,325,11633,1,10,2:0:0:0:
496,217,11810,1,2,2:0:0:0:
493,206,11898,1,0,2:0:0:0:
490,196,11986,1,0,2:0:0:0:
488,186,12074,1,0,2:0:0:0:
485,175,12162,1,2,2:3:0:0:
443,113,12339,1,10,2:0:0:0:
450,5,12515,5,0,2:0:0:0:
470,19,12604,1,0,2:0:0:0:
469,44,12692,1,2,2:0:0:0:
396,56,12868,1,2,2:3:0:0:
242,161,13045,85,10,2:0:0:0:
345,172,13221,1,0,2:0:0:0:
512,279,13398,5,2,2:0:0:0:
384,297,13574,1,2,2:3:0:0:
167,327,13751,6,0,P|169:314|180:309,1,25.5,10|0,2:0|2:0,2:0:0:0:
223,367,14104,86,0,P|164:343|100:346,1,127.5,6|2,3:1|2:0,2:0:0:0:
36,323,14368,2,0,P|53:351|97:361,1,63.75,0|8,2:0|1:0,2:0:0:0:
107,239,14633,2,2,B|90:104|90:104|75:157,1,191.25,2|0,2:1|2:0,2:1:0:0:
75,157,14986,5,2,3:2:0:0:
145,178,15074,1,0,2:0:0:0:
197,133,15162,2,0,L|273:145,1,63.75,10|0,1:0|2:0,2:0:0:0:
149,188,15339,85,0,2:0:0:0:
202,145,15427,1,0,2:0:0:0:
268,162,15515,2,0,P|299:236|276:306,1,127.5,2|2,3:1|2:0,2:0:0:0:
260,337,15780,2,0,P|234:319|223:289,1,63.75,0|8,2:0|1:0,2:0:0:0:
348,157,16045,2,0,L|360:63,1,63.75,2|0,2:1|2:0,2:0:0:0:
416,63,16221,5,2,2:0:0:0:
406,27,16310,1,2,2:0:0:0:
416,63,16398,1,2,3:2:0:0:
512,132,16574,1,10,1:0:0:0:
415,218,16751,1,0,2:0:0:0:
384,280,16839,1,0,2:0:0:0:
441,321,16927,86,0,P|466:243|421:183,1,127.5,2|2,3:1|2:0,2:0:0:0:
376,171,17192,2,0,P|388:199|415:218,1,63.75,0|8,2:0|1:0,2:0:0:0:
509,53,17457,2,2,B|409:32|429:125|308:96,1,191.25,2|0,2:1|2:0,2:1:0:0:
271,65,17810,5,2,3:2:0:0:
244,129,17898,1,0,2:0:0:0:
177,142,17986,2,0,L|101:118,1,63.75,10|0,1:0|2:0,2:0:0:0:
249,142,18162,85,0,2:0:0:0:
177,157,18251,1,0,2:0:0:0:
109,134,18339,2,0,P|103:73|76:10,1,127.5,2|2,3:1|2:0,2:0:0:0:
18,44,18604,2,0,P|37:68|66:80,1,63.75,0|8,2:0|1:0,2:0:0:0:
132,220,18868,2,0,L|113:312,1,63.75,2|0,2:1|2:0,2:0:0:0:
141,345,19045,6,0,P|168:328|184:301,1,63.75,2|0,2:0|2:0,2:0:0:0:
63,194,19221,1,2,3:2:0:0:
141,345,19398,86,2,P|184:340|237:382,1,95.6250000000002,10|0,1:0|2:0,2:1:0:0:
286,329,19751,6,2,P|275:268|234:232,1,85,10|2,1:0|2:0,2:1:0:0:
226,55,20104,2,0,P|237:116|278:152,1,85,8|2,1:0|2:1,2:0:0:0:
314,186,20457,2,0,P|350:164|393:162,1,85,8|2,1:0|2:0,2:0:0:0:
197,197,20810,2,0,P|161:219|119:222,1,85,10|2,1:0|2:0,2:0:0:0:
77,170,21074,1,0,2:0:0:0:
77,170,21162,86,2,P|130:179|162:224,1,85,10|2,1:0|2:0,2:1:0:0:
435,214,21515,2,0,P|381:204|349:159,1,85,8|2,1:0|2:1,2:0:0:0:
302,84,21868,2,0,P|303:41|325:5,1,85,8|2,1:0|2:0,2:0:0:0:
209,299,22221,2,0,P|208:342|186:378,1,85,10|0,1:0|2:0,2:0:0:0:
125,360,22486,1,0,2:0:0:0:
125,360,22574,6,0,P|153:341|193:345,1,42.5,8|0,1:0|2:0,2:0:0:0:
207,231,22751,2,0,P|228:232|247:241,1,42.5,8|0,1:0|2:0,2:0:0:0:
356,197,22927,2,0,P|369:212|376:232,1,42.5,8|0,1:0|2:0,2:0:0:0:
485,278,23104,2,0,P|483:299|474:318,1,42.5,10|0,1:0|2:0,2:0:0:0:
474,318,23280,85,8,1:0:0:0:
431,294,23368,1,8,1:0:0:0:
423,250,23457,1,10,1:0:0:0:
446,208,23545,1,8,1:0:0:0:
480,186,23633,1,10,1:0:0:0:
505,155,23721,1,8,1:0:0:0:
497,116,23810,1,8,1:0:0:0:
472,100,23898,1,8,1:0:0:0:
451,102,23986,54,0,B|418:98|418:98|420:67,1,63.75,2|0,2:1|2:0,2:0:0:0:
317,20,24162,1,2,2:3:0:0:
317,20,24251,1,2,2:0:0:0:
317,20,24339,2,0,B|349:15|349:15|358:74,1,63.75,2|0,2:1|2:0,2:0:0:0:
298,89,24515,2,0,L|204:106,1,63.75,2|0,2:1|2:0,2:0:0:0:
128,147,24692,6,0,B|152:166|152:166|190:154,1,63.75,2|0,2:3|2:0,2:0:0:0:
201,37,24868,2,0,B|177:18|177:18|139:30,1,63.75,2|0,2:1|2:0,2:0:0:0:
101,85,25045,2,2,B|113:158|113:158|135:182|135:182|146:251,1,170,10|0,1:0|2:0,2:1:0:0:
126,272,25310,1,0,2:0:0:0:
41,258,25398,38,0,P|32:232|54:255,1,85,4|0,3:2|2:0,2:0:0:0:
163,333,25574,2,2,B|263:311|263:311|315:343,1,127.5,2|8,2:1|1:0,2:1:0:0:
428,341,25927,2,2,P|367:370|379:268,1,191.25,2|0,2:1|2:0,2:1:0:0:
419,282,26280,6,0,P|433:309|427:340,1,63.75,2|0,3:2|2:0,2:0:0:0:
351,277,26457,2,2,B|368:181|464:249|480:123,1,191.25,8|0,1:0|2:0,2:1:0:0:
416,50,26810,6,2,B|415:56|415:56|420:60|420:60|415:65|415:65|421:69|421:69|416:73|416:73|422:76|422:76|418:81|418:81|423:85|423:85|418:89|418:89|423:92|423:92|419:97|419:97|424:100|424:100|421:104|421:104|426:108|426:108|423:112|423:112|428:115|428:115|424:119|424:119|429:122|429:122|425:126|425:126|430:130|430:130|426:134|426:134|431:137|431:137|428:143|428:143|433:146|433:146|428:150|428:150|434:153|434:153|430:157|430:157|451:202|515:217|548:156|493:100|441:177,1,318.75,0|2,3:0|2:1,2:1:0:0:
382,113,27162,1,8,1:0:0:0:
224,163,27339,2,0,B|279:165|358:185|358:185|319:171|303:125,1,191.25,2|0,2:0|2:0,2:0:0:0:
315,151,27692,2,0,P|306:111|324:61,1,63.75,2|0,3:1|2:0,2:0:0:0:
157,28,27868,70,0,P|142:10|165:18,1,63.75,8|0,1:0|2:0,2:0:0:0:
237,24,28045,1,2,2:3:0:0:
202,87,28133,1,2,2:3:0:0:
128,97,28221,6,0,P|87:110|77:159,1,63.75,0|0,3:0|2:0,2:0:0:0:
17,79,28398,2,2,P|45:137|55:199,1,127.5,2|8,2:1|1:0,2:1:0:0:
164,326,28751,2,2,P|246:325|224:245,1,191.25,2|0,2:1|2:0,2:1:0:0:
223,244,29104,6,0,P|191:241|164:262,1,63.7500024318696,0|0,3:0|2:0,2:0:0:0:
29,215,29280,2,2,B|93:212|110:268|81:255|102:332|171:325,1,191.250007295609,10|0,1:0|2:0,2:1:0:0:
329,263,29633,69,2,2:3:0:0:
366,255,29721,1,2,2:3:0:0:
382,324,29810,2,0,P|351:331|323:347,1,63.75,2|0,2:3|2:0,2:0:0:0:
291,188,29986,2,0,P|322:189|354:186,1,63.75,8|2,1:0|2:3,2:0:0:0:
249,327,30162,6,2,L|225:215,1,95.6249999999999,2|0,2:1|2:0,2:1:0:0:
188,177,30339,2,2,P|165:202|162:241,1,63.75,2|0,2:1|2:0,2:1:0:0:
303,104,30515,2,0,P|272:86|240:89,1,63.75,0|0,3:0|1:0,1:0:0:0:
174,103,30692,6,0,L|129:88,1,31.8750000000001,0|0,1:0|2:0,2:0:0:0:
434,212,30868,69,8,2:3:0:0:
454,207,30957,1,4,2:3:0:0:
391,273,31045,6,0,P|391:299|415:286,1,63.75,0|0,3:0|2:0,2:0:0:0:
362,196,31221,2,0,L|236:220,1,127.5,2|8,2:1|1:0,2:0:0:0:
106,214,31574,2,2,B|44:162|84:77|166:98|150:178,1,191.25,2|0,2:1|2:0,2:1:0:0:
152,158,31927,6,0,P|142:202|109:219,1,63.75,0|0,3:0|2:0,2:0:0:0:
0,298,32104,2,2,B|63:288|115:252|115:252|78:286|85:324,1,191.25,10|0,1:0|2:0,2:1:0:0:
167,322,32457,70,2,B|220:302|178:206|93:220|60:294|66:356|113:400|193:370|193:286,1,318.75,0|2,3:0|2:1,2:1:0:0:
247,358,32810,1,8,1:0:0:0:
322,189,32986,2,0,P|294:133|234:119,1,127.5,2|2,2:0|2:0,2:0:0:0:
252,195,33251,1,0,2:0:0:0:
252,195,33339,2,0,P|278:196|314:160,1,63.75,2|0,3:1|2:0,2:0:0:0:
341,48,33515,6,0,P|322:33|333:54,1,63.75,8|0,1:0|2:0,2:0:0:0:
390,106,33692,1,2,2:3:0:0:
459,89,33780,1,2,2:3:0:0:
492,152,33868,6,0,L|472:268,1,63.75,0|0,2:0|2:0,2:0:0:0:
492,315,34045,2,2,P|453:244|375:243,1,127.5,2|8,2:1|1:0,2:1:0:0:
268,272,34398,2,2,B|215:322|257:375|257:375|317:374|303:304,1,191.25,2|0,2:1|2:0,2:1:0:0:
305,330,34751,70,0,P|297:300|274:279,1,63.75,0|0,3:0|2:0,2:0:0:0:
181,194,34927,2,8,B|106:168|56:223|81:309|155:262|106:195,1,191.25,10|0,1:0|2:0,1:0:0:0:
118,218,35280,69,0,3:0:0:0:
65,56,35457,2,0,P|51:46|25:39,2,42.5,2|2|8,2:0|2:0|1:0,2:0:0:0:
159,117,35810,1,2,2:0:0:0:
159,117,35898,1,0,2:0:0:0:
159,117,35986,2,0,P|172:71|198:21,1,85,0|2,2:0|3:1,2:0:0:0:
453,167,36339,69,10,1:0:0:0:
453,167,36515,2,0,L|392:159,1,42.5,2|2,2:3|2:3,2:0:0:0:
343,139,36692,54,0,P|320:137|342:147,1,63.75,4|0,3:2|2:0,2:0:0:0:
429,274,36868,2,2,L|441:310,2,31.875,2|0|0,2:1|2:0|2:0,2:1:0:0:
366,225,37045,2,2,L|235:256,1,127.5,10|0,1:0|2:0,2:1:0:0:
113,342,37398,6,0,P|78:326|70:290,1,63.75,2|0,2:3|2:0,2:0:0:0:
134,274,37574,1,0,3:0:0:0:
144,88,37751,2,0,P|167:126|126:195,1,127.5,8|0,1:0|2:0,2:0:0:0:
65,156,37927,2,0,P|84:98|144:88,1,127.5,2|0,2:1|2:0,2:0:0:0:
209,125,38104,6,0,P|241:119|271:111,1,63.75,2|0,3:2|2:0,2:0:0:0:
330,58,38280,1,2,2:1:0:0:
301,232,38457,1,2,1:3:0:0:
347,210,38545,1,2,2:3:0:0:
387,236,38633,1,2,2:3:0:0:
382,287,38721,1,2,2:3:0:0:
310,308,38810,69,2,2:3:0:0:
313,321,38898,1,2,2:3:0:0:
251,364,38986,6,0,L|235:302,1,63.75,0|0,3:0|2:0,2:0:0:0:
140,152,39162,2,0,P|182:149|202:213,1,95.6249999999999,8|0,1:0|2:0,2:0:0:0:
191,210,39339,2,0,B|150:222|150:222|192:230,1,63.75,0|0,1:0|1:0,1:0:0:0:
346,167,39515,6,0,L|258:151,1,63.75,0|0,3:0|2:0,2:0:0:0:
218,68,39692,2,2,L|210:17,2,31.875,2|0|0,2:1|2:0|2:0,2:1:0:0:
140,152,39868,2,2,P|175:103|259:108,1,127.5,10|0,1:0|2:0,2:1:0:0:
362,252,40221,70,0,L|337:331,1,63.75,2|0,2:3|2:0,2:0:0:0:
398,363,40398,1,0,3:0:0:0:
370,167,40574,6,0,P|350:152|359:175,1,63.75,8|2,1:0|2:3,2:0:0:0:
463,316,40751,2,2,P|488:340|476:307,1,95.6249999999999,2|0,2:1|2:0,2:1:0:0:
476,307,40927,6,0,L|406:322,1,63.75,2|0,3:2|2:0,2:0:0:0:
292,369,41104,2,2,L|252:362,2,31.875,2|0|0,2:1|2:0|2:0,2:1:0:0:
250,292,41280,1,2,1:3:0:0:
212,300,41368,1,2,2:3:0:0:
174,308,41457,1,2,2:3:0:0:
185,328,41545,1,2,2:3:0:0:
194,346,41633,69,2,2:3:0:0:
126,364,41721,1,2,2:3:0:0:
99,298,41810,2,0,P|103:266|112:235,1,63.75,0|0,3:0|1:0,1:0:0:0:
52,103,41986,6,0,L|44:59,1,31.8750000000001,0|0,1:0|2:0,2:0:0:0:
184,220,42162,5,8,2:3:0:0:
214,237,42251,1,4,2:3:0:0:
240,171,42339,70,0,P|243:147|227:165,1,63.75,0|0,3:0|2:0,2:0:0:0:
101,165,42515,2,2,L|60:182,2,31.875,2|0|0,2:1|2:0|2:0,2:1:0:0:
139,104,42692,2,2,P|199:111|227:165,1,127.5,10|0,1:0|2:0,2:1:0:0:
335,262,43045,6,0,L|320:344,1,63.75,2|0,2:3|2:0,2:0:0:0:
442,229,43221,1,0,3:0:0:0:
344,86,43398,2,0,B|352:49|352:49|368:99|385:132,1,127.5,8|0,1:0|2:0,2:0:0:0:
356,192,43574,2,2,B|349:231|349:231|333:181|317:149,1,127.5,2|0,2:1|2:0,2:1:0:0:
317,149,43751,6,0,P|276:161|242:179,1,63.75,2|0,3:2|2:0,2:0:0:0:
206,238,43927,1,2,2:1:0:0:
61,88,44104,1,2,1:3:0:0:
67,131,44192,1,2,2:3:0:0:
106,150,44280,1,2,2:3:0:0:
148,136,44368,1,2,2:3:0:0:
176,71,44457,69,2,2:3:0:0:
174,59,44545,1,2,2:3:0:0:
246,75,44633,6,0,L|229:161,1,63.75,0|0,3:0|2:0,2:0:0:0:
134,261,44810,2,0,P|154:229|223:223,1,95.6249999999999,8|0,1:0|2:0,2:0:0:0:
258,267,44986,2,0,B|247:281|247:281|235:227,1,63.75,0|0,1:0|1:0,1:0:0:0:
386,127,45162,6,0,P|420:143|435:180,1,63.75,0|0,3:0|2:0,2:0:0:0:
459,235,45339,2,2,L|471:283,2,31.875,2|0|0,2:1|2:0|2:0,2:1:0:0:
387,221,45515,2,2,B|317:178|339:91|383:66|422:99|463:115|425:179,1,255,10|2,1:0|2:1,2:1:0:0:
378,292,45868,70,0,P|378:325|396:297,1,85,2|0,2:1|2:0,2:0:0:0:
214,255,46045,2,0,P|211:222|231:248,1,85,2|0,3:1|2:0,2:0:0:0:
387,221,46221,2,2,B|338:205|284:264|324:267|281:350|201:345|161:301,1,255,10|0,1:0|2:0,2:1:0:0:
91,358,46574,70,0,P|88:321|76:274,1,85,0|2,3:0|2:0,2:0:0:0:
129,223,46839,1,2,2:0:0:0:
110,192,46927,1,8,1:0:0:0:
227,47,47104,2,0,P|222:67|220:88,1,42.5,0|0,3:0|2:0,2:0:0:0:
260,148,47280,6,0,P|287:153|283:126,1,63.75,8|0,1:0|2:0,2:0:0:0:
151,74,47457,2,0,L|90:95,1,63.75,8|0,1:0|2:0,2:0:0:0:
219,246,47633,53,0,1:0:0:0:
231,245,47721,1,0,1:0:0:0:
243,249,47810,1,0,1:0:0:0:
253,256,47898,1,0,1:0:0:0:
258,267,47986,1,0,1:0:0:0:
256,192,52221,12,0,53545,2:0:0:0:
469,100,53633,5,0,3:0:0:0:
401,125,53810,1,2,2:0:0:0:
393,198,53986,1,2,2:0:0:0:
415,206,54162,1,2,2:0:0:0:
338,269,54339,5,8,1:0:0:0:
273,198,54515,1,2,2:0:0:0:
313,206,54692,1,2,2:0:0:0:
225,252,54868,1,2,2:0:0:0:
169,61,55045,5,0,3:0:0:0:
233,129,55221,1,2,2:0:0:0:
151,228,55398,1,2,2:0:0:0:
196,222,55574,1,2,2:0:0:0:
43,309,55751,85,8,1:0:0:0:
105,286,55927,1,2,2:0:0:0:
101,359,56104,1,2,2:0:0:0:
51,233,56280,1,2,2:0:0:0:
148,122,56457,5,0,3:0:0:0:
53,63,56633,1,2,2:0:0:0:
96,72,56810,1,2,2:0:0:0:
53,63,56986,1,2,2:0:0:0:
235,27,57162,85,8,1:0:0:0:
179,133,57339,1,2,2:0:0:0:
227,120,57515,1,2,2:0:0:0:
138,48,57692,1,2,2:0:0:0:
330,39,57868,5,0,3:0:0:0:
385,135,58045,1,2,2:0:0:0:
333,173,58221,1,2,2:0:0:0:
385,135,58398,1,2,2:0:0:0:
487,176,58574,85,8,1:0:0:0:
502,105,58751,1,2,2:0:0:0:
469,52,58927,1,2,2:0:0:0:
425,41,59104,1,0,2:0:0:0:
394,55,59280,69,0,1:0:0:0:
262,85,59457,1,0,1:0:0:0:
306,23,59633,2,0,P|327:63|332:96,1,63.7500000000002,8|2,2:0|2:0,2:0:0:0:
379,136,59898,1,0,2:0:0:0:
379,136,59986,2,0,L|366:192,2,31.8750000000001,2|0|8,2:0|2:0|2:0,2:0:0:0:
297,270,60339,2,0,P|305:233|326:199,1,63.7500000000002,2|2,2:0|2:0,2:0:0:0:
352,323,60604,1,0,2:0:0:0:
352,323,60692,6,0,P|317:296|280:296,1,63.7500000000002,0|2,1:0|2:0,2:0:0:0:
191,239,61045,2,0,P|159:250|145:270,1,31.8750000000001,2|0,2:0|2:0,2:0:0:0:
117,300,61221,2,0,P|109:314|108:330,1,31.8750000000001,0|0,1:0|2:0,2:0:0:0:
184,269,61398,86,0,P|190:235|180:213,1,31.8750000000001,2|0,2:0|2:0,2:0:0:0:
167,173,61574,2,0,P|158:159|145:150,1,31.8750000000001,2|0,2:0|2:0,2:0:0:0:
161,248,61751,6,0,P|186:270|210:272,1,31.8750000000001,8|0,2:0|2:0,2:0:0:0:
252,281,61927,2,0,P|268:280|282:273,1,31.8750000000001,2|2,2:0|2:0,2:0:0:0:
325,216,62104,86,0,L|313:153,1,63.7500000000002,0|0,1:0|1:0,2:0:0:0:
232,102,62457,2,0,P|244:66|277:60,1,63.7500000000002,8|2,2:0|2:0,2:0:0:0:
335,83,62721,1,0,2:0:0:0:
335,83,62810,2,0,L|389:73,2,31.8750000000001,2|0|0,2:0|2:0|1:0,2:0:0:0:
187,17,63162,2,0,L|102:7,1,63.7500000000002,2|2,2:0|2:0,2:0:0:0:
69,55,63427,1,0,2:0:0:0:
69,55,63515,5,0,1:0:0:0:
124,104,63692,1,2,2:0:0:0:
116,123,63780,1,0,2:0:0:0:
138,156,63868,1,2,2:0:0:0:
123,166,63957,1,0,2:0:0:0:
128,203,64045,85,0,1:0:0:0:
80,262,64221,1,2,2:0:0:0:
103,280,64310,1,0,2:0:0:0:
92,320,64398,1,2,2:0:0:0:
113,325,64486,1,0,2:0:0:0:
130,359,64574,1,2,2:3:0:0:
147,351,64662,1,2,2:3:0:0:
130,359,64751,1,2,2:3:0:0:
147,351,64839,1,2,2:3:0:0:
189,362,64927,5,0,1:0:0:0:
325,324,65104,1,0,1:0:0:0:
267,374,65280,2,0,P|254:333|249:303,1,63.7500000000002,8|2,2:0|2:0,2:0:0:0:
196,272,65545,1,0,2:0:0:0:
196,272,65633,2,0,L|166:257,2,31.8750000000001,2|0|8,2:0|2:0|2:0,2:0:0:0:
337,153,65986,2,0,P|323:185|314:220,1,63.7500000000002,2|2,2:0|2:0,2:0:0:0:
374,259,66251,1,0,2:0:0:0:
374,259,66339,86,0,P|398:258|376:243,1,63.7500000000002,0|2,1:0|2:0,2:0:0:0:
237,162,66692,2,0,P|213:135|210:117,1,31.8750000000001,2|0,2:0|2:0,2:0:0:0:
208,69,66868,2,0,L|228:18,1,31.8750000000001,0|0,1:0|2:0,2:0:0:0:
245,131,67045,6,0,P|243:146|236:161,1,31.8750000000001,2|0,2:0|2:0,2:0:0:0:
178,202,67221,2,0,L|146:206,1,31.8750000000001,2|0,2:0|2:0,2:0:0:0:
215,139,67398,86,0,P|229:132|245:131,1,31.8750000000001,8|0,2:0|2:0,2:0:0:0:
309,161,67574,2,0,L|343:203,1,31.8750000000001,2|2,2:0|2:0,2:0:0:0:
315,253,67751,6,0,L|402:271,1,63.7500000000002,0|0,1:0|1:0,2:0:0:0:
452,339,68104,2,0,P|489:328|508:299,1,63.7500000000002,8|2,2:0|2:0,2:0:0:0:
510,241,68368,1,0,2:0:0:0:
510,241,68457,2,0,L|498:192,1,31.8750000000001,2|0,2:0|2:0,2:0:0:0:
440,176,68633,1,0,1:0:0:0:
488,22,68810,2,0,L|473:110,1,63.7500000000002,2|2,2:0|2:0,2:0:0:0:
452,106,69074,1,0,2:0:0:0:
452,106,69162,85,0,1:0:0:0:
384,51,69339,1,2,2:0:0:0:
370,66,69427,1,0,2:0:0:0:
331,62,69515,1,2,2:0:0:0:
329,80,69604,1,0,2:0:0:0:
293,92,69692,5,0,1:0:0:0:
201,58,69868,1,2,2:0:0:0:
210,96,69957,1,0,2:0:0:0:
173,108,70045,1,2,2:0:0:0:
189,128,70133,1,0,2:0:0:0:
176,159,70221,86,0,L|214:169,1,31.8750000000001,8|0,2:0|2:0,2:0:0:0:
96,94,70398,1,8,2:0:0:0:
57,302,70574,22,0,P|84:269|123:253,1,85,4|0,3:1|2:0,2:0:0:0:
304,277,70927,1,10,2:0:0:0:
180,360,71104,1,2,2:0:0:0:
210,290,71280,5,0,2:0:0:0:
325,365,71457,1,2,3:2:0:0:
436,310,71633,2,0,P|459:269|453:214,1,85,10|0,2:0|2:0,2:0:0:0:
406,179,71898,1,0,2:0:0:0:
406,179,71986,86,0,L|423:282,1,85,2|0,3:2|2:0,2:0:0:0:
365,310,72251,2,0,L|348:378,1,42.5,0|8,2:0|2:0,2:0:0:0:
478,162,72515,1,2,2:0:0:0:
302,118,72692,2,0,P|300:75|332:26,1,85,0|2,2:0|3:2,2:0:0:0:
370,91,72957,1,0,2:0:0:0:
370,91,73045,6,0,P|347:122|323:128,1,42.5,10|0,2:0|2:0,2:0:0:0:
234,149,73221,2,0,P|214:145|197:133,1,42.5,2|0,2:0|2:0,2:0:0:0:
152,26,73398,86,0,L|46:63,1,85,0|0,3:0|2:0,2:0:0:0:
8,277,73751,1,10,2:0:0:0:
127,148,73927,1,2,2:0:0:0:
21,108,74104,5,0,2:0:0:0:
136,240,74280,1,2,3:2:0:0:
51,366,74457,2,0,P|94:330|128:325,1,85,10|2,2:0|2:0,2:0:0:0:
189,296,74721,1,0,2:0:0:0:
189,296,74810,86,0,L|277:324,1,85,0|0,3:0|2:0,2:0:0:0:
365,335,75074,2,0,P|335:356|299:352,1,42.5,0|10,2:0|1:2,2:0:0:0:
349,198,75339,2,0,P|366:159|369:117,1,85,2|0,2:0|2:0,2:0:0:0:
313,73,75604,1,0,2:0:0:0:
313,73,75692,5,2,3:2:0:0:
398,264,75868,2,0,L|458:281,1,42.5,10|0,1:2|2:0,2:0:0:0:
493,222,76045,2,0,P|463:230|438:269,1,42.5,2|0,2:0|2:0,2:0:0:0:
365,335,76221,5,8,1:0:0:0:
230,230,76398,1,8,1:0:0:0:
180,182,76486,1,0,2:0:0:0:
215,123,76574,1,10,1:2:0:0:
312,259,76751,2,0,L|295:319,1,53.125,10|0,1:0|2:0,2:0:0:0:
298,188,76927,86,0,P|312:171|362:165,1,53.125,8|0,1:0|2:0,2:0:0:0:
232,336,77104,2,0,P|210:332|176:292,1,53.125,10|0,1:0|2:0,2:0:0:0:
398,303,77280,2,0,P|405:323|385:372,1,53.125,10|0,1:0|2:0,2:0:0:0:
220,250,77457,1,10,1:2:0:0:
228,223,77545,1,0,2:0:0:0:
166,193,77633,6,0,P|149:143|148:88,1,106.25,10|8,1:0|1:0,2:0:0:0:
168,22,77898,1,0,2:0:0:0:
239,12,77986,2,0,P|254:34|258:67,1,53.125,10|0,1:2|2:0,2:0:0:0:
138,199,78162,85,10,1:0:0:0:
53,96,78339,2,0,P|62:71|82:53,1,53.125,8|0,1:0|2:0,2:0:0:0:
261,133,78515,2,0,P|253:157|226:180,1,53.125,10|0,1:0|2:0,2:0:0:0:
105,265,78692,2,0,P|83:250|70:227,1,53.125,10|0,1:0|2:0,2:0:0:0:
304,76,78868,1,8,1:0:0:0:
318,88,78957,1,0,2:0:0:0:
327,125,79045,5,10,1:0:0:0:
390,156,79133,1,8,2:0:0:0:
451,141,79221,1,8,2:0:0:0:
470,96,79310,1,8,2:0:0:0:
458,61,79398,1,10,1:0:0:0:
430,50,79486,1,8,2:0:0:0:
410,58,79574,1,10,2:1:0:0:
405,75,79662,1,8,2:0:0:0:
408,94,79751,1,8,1:0:0:0:
424,115,79839,1,8,2:0:0:0:
457,132,79927,1,10,2:1:0:0:
492,156,80015,1,8,2:0:0:0:
512,206,80104,1,10,1:0:0:0:
502,259,80192,1,8,2:0:0:0:
444,288,80280,1,8,2:0:0:0:
381,259,80368,1,8,2:0:0:0:
371,247,80457,6,0,B|344:258|344:258|356:291,1,63.7500024318696,2|0,2:1|2:0,2:0:0:0:
187,353,80633,2,0,B|154:341|154:341|159:312,1,63.75,2|0,2:3|2:0,2:0:0:0:
247,169,80810,2,0,L|227:295,1,127.5,2|2,2:1|2:1,2:0:0:0:
295,342,81074,1,0,2:0:0:0:
295,342,81162,6,0,P|282:313|256:296,1,63.75,2|0,2:3|2:0,2:0:0:0:
96,242,81339,2,0,P|79:259|103:255,1,63.75,2|0,2:1|2:0,2:0:0:0:
208,223,81515,2,0,B|220:135|121:163|153:40,1,170,10|0,1:0|2:0,2:0:0:0:
164,37,81780,1,0,2:0:0:0:
256,36,81868,38,0,B|301:68|280:118|280:118|227:124|208:67,1,170,0|2,3:0|2:1,2:0:0:0:
318,238,82221,6,0,P|332:177|359:117,1,127.5,2|0,3:2|2:0,2:0:0:0:
350,41,82398,2,2,P|365:19|352:44,1,85,2|0,2:1|2:0,2:1:0:0:
395,263,82574,54,0,B|392:294|417:296|417:296|400:283|398:262,1,85,2|0,3:2|2:0,2:0:0:0:
512,168,82751,1,2,2:1:0:0:
407,190,82927,6,2,B|524:159|525:295|449:257|473:221,1,170,2|2,3:1|2:3,2:1:0:0:
470,228,83280,38,2,B|397:211|344:246|382:270|314:344|227:298,1,255,0|0,3:0|2:0,2:1:0:0:
57,231,83633,6,0,B|97:250|97:301|97:301|122:253|191:252,1,170,2|2,3:2|2:1,2:0:0:0:
182,329,83898,1,0,2:0:0:0:
182,329,83986,2,0,P|169:352|179:326,1,85,0|0,3:0|2:0,2:0:0:0:
125,192,84162,6,2,B|111:141|148:109|197:144|130:182,1,127.5,2|0,2:1|2:0,2:1:0:0:
163,254,84339,2,0,P|198:190|173:152,1,85,2|0,3:2|2:0,2:0:0:0:
35,61,84515,70,0,P|39:86|60:104,1,42.5,2|0,2:3|2:0,2:0:0:0:
51,66,84604,2,0,P|70:80|107:73,1,42.5,2|0,2:3|2:0,2:0:0:0:
56,52,84692,6,0,B|109:39|161:60|161:60|175:21|225:8,1,170,0|2,3:0|2:1,2:0:0:0:
393,101,85045,6,0,P|349:56|287:56,1,127.5,2|0,3:2|2:0,2:0:0:0:
242,109,85221,2,2,P|243:79|233:108,1,85,2|0,2:1|2:0,2:1:0:0:
319,127,85398,70,0,B|294:175|318:157|292:206,1,85,2|0,3:2|2:0,2:0:0:0:
310,313,85574,1,2,2:1:0:0:
355,253,85751,6,2,B|329:225|329:225|243:245|243:245|241:288,1,170,2|0,3:1|2:0,2:1:0:0:
487,39,86104,5,2,3:0:0:0:
480,34,86192,1,2,2:3:0:0:
487,30,86280,2,0,P|476:91|467:160,1,127.5,2|0,2:3|2:0,2:0:0:0:
413,200,86457,2,0,B|419:239|460:239|463:216|501:221|510:262,1,127.5,2|0,3:0|2:0,2:0:0:0:
450,296,86633,2,0,B|437:317|443:340|443:340|458:320|447:290,1,85,2|0,2:1|2:0,2:0:0:0:
403,87,86810,70,2,P|346:145|266:131,1,170,2|0,3:1|2:0,2:1:0:0:
81,289,87162,38,0,L|76:247,1,42.5,8|0,1:0|2:0,2:0:0:0:
50,52,87339,2,0,L|56:95,1,42.5,8|0,1:0|2:0,2:0:0:0:
3,176,87515,70,0,B|78:158|48:188|133:169,1,127.5,0|0,3:0|2:0,2:0:0:0:
193,142,87692,2,2,P|186:110|132:108,1,85,2|0,2:1|2:0,2:1:0:0:
97,359,87868,2,0,P|122:360|148:304,1,85,8|0,1:0|2:0,2:0:0:0:
194,262,88045,6,2,B|184:245|150:242|150:242|205:218|257:278|207:291|254:353|325:339,1,255,0|0,2:1|2:0,2:1:0:0:
373,320,88398,2,0,P|388:292|366:313,1,85,0|0,3:0|2:0,2:0:0:0:
336,173,88574,70,2,B|347:146|379:141|379:141|292:125|252:214|311:278,1,255,10|0,1:0|2:0,2:1:0:0:
306,340,88927,6,2,P|322:248|269:28,1,318.75,0|2,3:0|2:1,2:1:0:0:
464,7,89280,6,0,B|498:19|496:37|496:37|473:35|466:7,1,85,8|0,1:0|2:0,2:0:0:0:
399,29,89457,2,0,B|417:81|462:98|462:98|403:94|394:35,1,170,2|2,2:0|2:0,2:0:0:0:
243,159,89810,70,0,P|279:116|363:62,1,127.5,0|0,3:0|2:0,2:0:0:0:
402,62,89986,2,0,P|398:107|359:136,1,85,10|0,1:0|2:0,2:0:0:0:
173,115,90162,6,0,P|154:93|154:56,1,42.5,2|0,2:3|2:0,2:0:0:0:
175,80,90251,2,0,P|171:52|196:16,1,42.5,2|0,2:3|2:0,2:0:0:0:
193,90,90339,6,0,B|213:177|181:130|206:240,1,127.5,0|0,3:0|2:0,2:0:0:0:
200,284,90515,2,2,P|246:272|265:222,1,85,2|0,2:1|2:0,2:1:0:0:
71,172,90692,2,0,P|39:225|60:274,1,85,8|0,1:0|2:0,2:0:0:0:
146,333,90868,70,2,B|154:351|188:357|188:357|131:376|85:312|136:303|94:237|22:245,1,255,2|0,2:1|2:0,2:1:0:0:
18,318,91221,2,0,P|38:341|26:313,1,85,0|0,3:0|2:0,2:0:0:0:
130,208,91398,6,2,B|176:139|123:80|123:80|57:108|72:187,1,255,10|0,1:0|2:0,2:1:0:0:
70,171,91751,53,0,3:0:0:0:
217,118,91927,2,0,P|215:97|207:77,2,42.5,2|2|8,2:0|2:0|1:0,2:0:0:0:
290,310,92280,1,0,2:0:0:0:
290,310,92368,1,0,2:0:0:0:
290,310,92457,2,0,P|311:269|312:220,1,85,0|2,2:0|3:1,2:0:0:0:
303,60,92810,69,2,1:0:0:0:
298,54,92898,1,8,2:3:0:0:
303,49,92986,2,0,L|291:100,1,42.5,8|8,2:3|2:0,2:0:0:0:
219,205,93162,54,0,B|248:192|248:192|264:166|264:166|316:156|316:156|320:154|320:154|319:155|319:155|370:145,1,170,4|2,3:2|2:1,2:0:0:0:
512,239,93515,2,2,B|516:282|516:282|456:321|456:321|427:258|427:258|450:221,1,212.5,2|2,3:1|2:0,2:1:0:0:
133,154,93868,6,2,P|283:191|441:233,1,318.75,2|0,3:1|2:0,2:1:0:0:
483,170,94221,2,0,B|492:160|492:160|488:132|488:132|474:157|474:157|481:170,1,85,2|2,3:0|2:3,2:0:0:0:
363,286,94398,6,0,B|311:303|350:304|269:336,1,85,2|0,2:1|2:0,2:0:0:0:
230,289,94574,2,0,B|304:290|329:348|329:348|298:315|253:332,1,170,0|2,3:0|2:1,2:0:0:0:
64,367,94927,70,0,P|53:368|43:364,1,21.25,2|0,3:0|2:0,2:0:0:0:
65,301,95015,2,0,P|56:294|52:284,1,21.25,2|0,2:3|2:0,2:0:0:0:
110,248,95104,2,0,P|108:237|112:227,1,21.25,2|0,2:3|2:0,2:0:0:0:
179,242,95192,2,0,P|185:233|195:229,1,21.25,2|0,2:3|2:0,2:0:0:0:
268,223,95280,5,2,3:0:0:0:
268,223,95368,1,0,2:0:0:0:
268,223,95457,2,2,P|266:181|240:145,1,85,2|0,2:1|2:0,2:1:0:0:
122,97,95633,6,2,B|97:78|100:97|66:83|66:83|71:140|198:154|192:48|141:20,1,255,0|0,3:0|2:0,2:1:0:0:
91,23,95986,70,0,P|93:65|121:96,1,85,0|0,3:0|2:0,2:0:0:0:
305,112,96162,2,2,P|280:78|239:78,1,85,2|0,2:1|2:0,2:1:0:0:
125,331,96339,2,0,B|148:309|190:312|190:312|221:279|192:224,1,159.375,2|0,3:1|2:0,2:1:0:0:
195,229,96515,6,2,P|176:199|191:230,1,106.25,2|0,2:1|2:0,2:1:0:0:
37,259,96692,6,2,P|188:212|345:183,1,318.75,2|0,3:1|2:0,2:1:0:0:
419,185,97045,2,0,B|431:179|431:179|456:192|456:192|427:197|427:197|417:186,1,85,2|2,3:0|2:3,2:0:0:0:
377,46,97221,70,0,B|396:60|396:60|380:137,1,85,2|0,2:1|2:0,2:0:0:0:
353,326,97398,2,0,B|317:308|317:308|332:242|332:242|331:240|331:240|333:237|333:237|333:234|333:234|345:182,1,170,0|2,3:0|2:1,2:0:0:0:
236,33,97751,5,2,3:0:0:0:
296,64,97839,1,2,2:3:0:0:
312,90,97927,1,2,2:3:0:0:
296,64,98015,1,2,2:3:0:0:
234,38,98104,5,2,3:0:0:0:
164,57,98192,1,2,2:3:0:0:
138,99,98280,2,0,B|132:187|151:159|161:243,1,127.5,0|0,1:0|1:0,1:0:0:0:
135,291,98457,70,0,P|106:280|106:254,1,42.5,8|0,1:0|2:0,2:0:0:0:
83,45,98633,2,0,P|69:60|65:81,1,42.5,8|0,1:0|2:0,2:0:0:0:
54,183,98810,6,0,P|113:166|179:160,1,127.5,4|0,3:2|2:0,2:0:0:0:
236,118,98986,2,2,P|251:162|226:213,1,85,2|0,2:1|2:0,2:1:0:0:
414,279,99162,2,2,B|371:293|330:256|372:244|358:163|295:169|237:189|248:263|299:270,1,255,10|0,1:0|2:0,2:1:0:0:
242,322,99515,6,2,B|258:343|256:361|256:361|265:347|292:339,1,85,2|0,2:1|2:0,2:1:0:0:
501,190,99692,2,0,P|459:196|418:207,1,85,0|0,3:0|2:0,2:0:0:0:
345,131,99868,70,0,P|348:71|405:50,1,127.5,10|0,1:0|2:0,2:0:0:0:
419,121,100045,2,2,P|372:121|345:84,1,85,2|0,2:1|2:0,2:1:0:0:
477,21,100221,5,0,3:0:0:0:
417,207,100398,2,2,P|427:235|426:205,1,85,2|0,2:1|2:0,2:1:0:0:
261,101,100574,6,0,P|259:91|255:81,1,21.25,2|0,1:3|2:0,2:0:0:0:
193,110,100662,2,0,P|184:104|174:100,1,21.25,2|0,2:3|2:0,2:0:0:0:
149,164,100751,2,0,P|139:166|129:170,1,21.25,2|0,2:3|2:0,2:0:0:0:
160,232,100839,2,0,P|154:241|150:251,1,21.25,2|0,2:3|2:0,2:0:0:0:
222,288,100927,69,2,2:3:0:0:
222,288,101015,1,2,2:3:0:0:
222,288,101104,2,0,B|268:300|268:300|238:278,1,85,0|0,3:0|2:0,2:0:0:0:
67,224,101280,2,0,B|55:177|55:177|62:153|62:153|45:85,1,127.5,8|0,1:0|2:0,2:0:0:0:
49,100,101457,2,0,B|32:74|32:74|22:106|22:106|65:103,1,85,0|0,1:0|1:0,1:0:0:0:
40,296,101633,6,0,P|82:257|167:257,1,127.5,0|0,3:0|2:0,2:0:0:0:
214,283,101810,2,2,P|189:316|148:321,1,85,2|0,2:1|2:0,2:1:0:0:
67,224,101986,6,2,B|156:208|243:256|278:327|278:327|309:297|363:301,1,318.75,10|0,1:0|2:0,2:1:0:0:
390,251,102339,70,2,B|409:264|411:284|411:284|409:258|409:258|375:243,1,85,2|0,2:1|2:0,2:1:0:0:
318,47,102515,2,2,B|306:87|306:87|309:91|309:91|330:146,1,85,2|0,3:1|2:0,2:1:0:0:
338,301,102692,2,2,B|310:268|310:268|333:182|349:98|349:98|410:80,1,255,10|0,1:0|2:0,2:1:0:0:
451,121,103045,70,0,P|439:139|422:151,1,42.5,0|0,3:0|2:0,2:0:0:0:
286,191,103221,2,0,P|250:182|234:168,2,42.5,2|2|8,2:0|2:0|1:0,2:0:0:0:
113,21,103574,1,2,2:1:0:0:
113,21,103662,1,0,2:0:0:0:
113,21,103751,6,0,P|149:49|163:93,1,85,0|2,2:0|3:2,2:0:0:0:
156,226,104104,2,2,P|176:237|161:220,1,63.75,8|0,2:1|2:0,2:1:0:0:
138,155,104280,86,0,B|78:173|118:200|32:236,1,127.5,0|4,2:0|3:1,2:0:0:0:
9,303,104633,1,2,2:3:0:0:
61,352,104810,1,8,2:0:0:0:
130,345,104986,2,2,P|129:323|120:304,3,42.5,2|0|0|0,2:0|2:0|2:0|2:0,2:0:0:0:
174,256,105339,1,2,3:0:0:0:
248,243,105515,1,10,2:0:0:0:
311,283,105692,1,2,2:0:0:0:
478,200,105868,5,0,3:0:0:0:
358,224,106045,1,2,2:3:0:0:
464,184,106221,1,10,2:0:0:0:
396,157,106398,1,2,2:0:0:0:
467,167,106574,1,2,2:0:0:0:
362,91,106751,1,0,3:0:0:0:
480,153,106927,1,10,2:0:0:0:
446,85,107104,1,0,2:0:0:0:
493,18,107280,85,2,3:2:0:0:
430,64,107457,1,2,2:3:0:0:
354,59,107633,1,10,2:0:0:0:
292,17,107810,2,2,P|270:17|251:25,3,42.5,2|0|0|0,2:0|2:0|2:0|2:0,2:0:0:0:
179,17,108162,1,2,3:0:0:0:
115,58,108339,1,10,2:0:0:0:
101,129,108515,1,2,2:0:0:0:
200,208,108692,5,2,3:2:0:0:
94,251,108868,1,2,2:3:0:0:
208,228,109045,1,10,2:0:0:0:
162,293,109221,1,2,2:0:0:0:
225,242,109398,1,2,2:0:0:0:
216,349,109574,1,0,3:0:0:0:
249,246,109751,1,8,2:0:0:0:
267,282,109927,1,0,2:0:0:0:
359,213,110104,86,0,P|377:184|378:134,1,85,8|2,2:0|2:3,2:0:0:0:
277,89,110457,2,0,P|248:71|198:70,1,85,8|2,2:0|2:0,2:0:0:0:
153,171,110810,2,0,P|135:200|134:250,1,85,8|0,2:0|2:0,2:0:0:0:
235,295,111162,2,0,P|264:313|314:314,1,85,8|10,2:0|2:0,2:0:0:0:
387,324,111427,1,10,2:0:0:0:
387,324,111515,22,0,P|349:347|305:344,1,85,8|2,2:0|2:3,2:0:0:0:
125,60,111868,2,0,P|163:37|207:40,1,85,8|2,2:0|2:0,2:0:0:0:
300,106,112221,2,0,P|277:141|273:183,1,85,8|0,2:0|2:0,2:0:0:0:
215,281,112574,2,0,P|237:245|241:203,1,85,8|8,2:0|2:0,2:0:0:0:
186,158,112839,1,8,2:0:0:0:
186,158,112927,86,0,P|155:163|137:184,1,42.5,10|8,2:0|2:0,2:0:0:0:
75,171,113104,2,0,P|70:202|84:225,1,42.5,8|8,2:0|2:0,2:0:0:0:
51,284,113280,2,0,P|79:298|105:291,1,42.5,10|8,2:0|2:0,2:0:0:0:
153,347,113457,2,0,P|175:324|177:297,1,42.5,10|8,2:0|2:0,2:0:0:0:
240,243,113633,22,8,L|208:253,1,21.25,0|0,2:0|2:0,2:0:0:0:
260,236,113721,2,8,L|228:246,1,21.25,0|0,2:0|2:0,2:0:0:0:
280,229,113810,2,8,L|248:239,1,21.25,10|8,2:0|2:0,2:0:0:0:
300,222,113898,2,8,L|268:232,1,21.25,0|0,2:0|2:0,2:0:0:0:
367,255,113986,6,0,B|416:263|416:263|364:115|364:115|331:176,1,255,10|0,1:2|2:0,2:0:0:0:
340,157,114339,38,0,L|433:177,1,63.75,8|0,1:0|2:0,2:0:0:0:
248,82,114515,1,0,3:0:0:0:
261,304,114692,1,0,3:0:0:0:
174,230,114868,6,0,L|81:210,1,63.75,8|0,1:0|2:0,2:0:0:0:
269,312,115045,1,0,3:0:0:0:
241,74,115221,1,0,3:0:0:0:
267,197,115398,86,0,P|250:179|258:202,1,63.75,12|0,1:2|2:0,2:0:0:0:
+1
View File
@@ -0,0 +1 @@
MAP_PATH="path/to/.osu/files"
+13
View File
@@ -0,0 +1,13 @@
[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
@@ -0,0 +1,9 @@
# 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
@@ -0,0 +1,174 @@
<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>

After

Width:  |  Height:  |  Size: 14 KiB

+162
View File
@@ -0,0 +1,162 @@
<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>

After

Width:  |  Height:  |  Size: 12 KiB

+165
View File
@@ -0,0 +1,165 @@
<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>

After

Width:  |  Height:  |  Size: 13 KiB

+456
View File
@@ -0,0 +1,456 @@
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
@@ -0,0 +1,154 @@
<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>

After

Width:  |  Height:  |  Size: 12 KiB

+256
View File
@@ -0,0 +1,256 @@
use crate::{Beatmap, GameMode, Mods};
/// Summary struct for a [`Beatmap`]'s attributes.
#[derive(Clone, Debug, PartialEq)]
pub struct BeatmapAttributes {
/// The approach rate.
pub ar: f64,
/// The overall difficulty.
pub od: f64,
/// The circle size.
pub cs: f64,
/// The health drain rate
pub hp: f64,
/// The clock rate with respect to mods.
pub clock_rate: f64,
/// The hit windows for approach rate and overall difficulty.
pub hit_windows: BeatmapHitWindows,
}
#[derive(Copy, Clone, Debug, PartialEq)]
/// AR and OD hit windows
pub struct BeatmapHitWindows {
/// 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,
}
#[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>,
clock_rate: Option<f64>,
converted: bool,
}
impl BeatmapAttributesBuilder {
const OSU_MIN: f64 = 80.0;
const OSU_AVG: f64 = 50.0;
const OSU_MAX: f64 = 20.0;
const TAIKO_MIN: f64 = 50.0;
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)
}
#[inline]
/// Specify the mode.
pub fn mode(&mut self, mode: GameMode) -> &mut Self {
self.mode = mode;
self
}
#[inline]
/// Specify the approach rate.
pub fn ar(&mut self, ar: f32) -> &mut Self {
self.ar = ar;
self
}
#[inline]
/// Specify the overall difficulty.
pub fn od(&mut self, od: f32) -> &mut Self {
self.od = od;
self
}
#[inline]
/// Specify the circle size.
pub fn cs(&mut self, cs: f32) -> &mut Self {
self.cs = cs;
self
}
#[inline]
/// Specify the drain rate.
pub fn hp(&mut self, hp: f32) -> &mut Self {
self.hp = hp;
self
}
#[inline]
/// Specify the mods.
pub fn mods(&mut self, mods: u32) -> &mut Self {
self.mods = Some(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);
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);
let clock_rate = self.clock_rate.unwrap_or_else(|| mods.clock_rate());
let mod_mult = |val: f32| {
if mods.hr() {
(val * 1.4).min(10.0)
} else if mods.ez() {
val * 0.5
} else {
val
}
};
let raw_ar = mod_mult(self.ar);
let preempt = difficulty_range(raw_ar as f64, 1800.0, 1200.0, 450.0) / clock_rate;
// OD
let hit_window = match self.mode {
GameMode::Osu | GameMode::Catch => {
let raw_od = mod_mult(self.od);
difficulty_range(raw_od as f64, Self::OSU_MIN, Self::OSU_AVG, Self::OSU_MAX)
/ clock_rate
}
GameMode::Taiko => {
let raw_od = mod_mult(self.od);
let diff_range = difficulty_range(
raw_od as f64,
Self::TAIKO_MIN,
Self::TAIKO_AVG,
Self::TAIKO_MAX,
);
diff_range / clock_rate
}
GameMode::Mania => {
let mut value = if !self.converted {
34.0 + 3.0 * (10.0 - self.od).clamp(0.0, 10.0)
} else if self.od > 4.0 {
34.0
} else {
47.0
};
if mods.hr() {
value /= 1.4;
} else if mods.ez() {
value *= 1.4;
}
((value as f64 * clock_rate).floor() / clock_rate).ceil()
}
};
BeatmapHitWindows {
ar: preempt,
od: hit_window,
}
}
/// Calculate the [`BeatmapAttributes`].
pub fn build(&self) -> BeatmapAttributes {
let mods = self.mods.unwrap_or(0);
let clock_rate = self.clock_rate.unwrap_or_else(|| mods.clock_rate());
// HP
let hp = (self.hp * mods.od_ar_hp_multiplier() as f32).min(10.0);
// CS
let mut cs = self.cs;
if mods.hr() {
cs = (cs * 1.3).min(10.);
} else if mods.ez() {
cs *= 0.5;
}
let hit_windows = self.hit_windows();
let BeatmapHitWindows { ar, od } = hit_windows;
// AR
let ar = if ar > 1200.0 {
(1800.0 - ar) / 120.0
} else {
(1200.0 - ar) / 150.0 + 5.0
};
// OD
let od = match self.mode {
GameMode::Osu => (Self::OSU_MIN - od) / 6.0,
GameMode::Taiko => (Self::TAIKO_MIN - od) / (Self::TAIKO_MIN - Self::TAIKO_AVG) * 5.0,
GameMode::Catch | GameMode::Mania => self.od as f64,
};
BeatmapAttributes {
ar,
od,
cs: cs as f64,
hp: hp as f64,
clock_rate,
hit_windows,
}
}
}
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,
}
}
}
fn difficulty_range(difficulty: f64, min: f64, mid: f64, max: f64) -> f64 {
if difficulty > 5.0 {
mid + (max - mid) * (difficulty - 5.0) / 5.0
} else if difficulty < 5.0 {
mid - (mid - min) * (5.0 - difficulty) / 5.0
} else {
mid
}
}
+16
View File
@@ -0,0 +1,16 @@
/// 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
}
}
+126
View File
@@ -0,0 +1,126 @@
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 fn new(time: f64, beat_len: f64) -> Self {
Self { time, beat_len }
}
}
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_multiplier = if beat_len < 0.0 {
((-beat_len) as f32).clamp(10.0, 10_000.0) as f64 / 100.0
} else {
1.0
};
Self {
time,
slider_vel: speed_multiplier.clamp(0.1, 10.0),
bpm_mult: bpm_multiplier as f64,
generate_ticks: !beat_len.is_nan(),
}
}
pub(crate) fn is_redundant(&self, existing: &DifficultyPoint) -> 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 fn new(time: f64, kiai: bool) -> Self {
Self { time, kiai }
}
}
impl Default for EffectPoint {
#[inline]
fn default() -> Self {
Self::new(0.0, Self::DEFAULT_KIAI)
}
}
@@ -0,0 +1,42 @@
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) 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 * self.gen_signed() as f64
}
pub(crate) fn gen_int_range(&mut self, min: i32, max: i32) -> i32 {
(min as f64 + self.gen_double() * (max - min) as f64) as i32
}
}
+219
View File
@@ -0,0 +1,219 @@
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 {
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 =
(slider_or_spinner_count as f32 / self.hit_objects.len() as f32) as f64;
let target_columns = if percent_slider_or_spinner < 0.2 {
7.0
} else if percent_slider_or_spinner < 0.3 || rounded_cs >= 5.0 {
(6 + (rounded_od > 5.0) as u8) as f32
} else if percent_slider_or_spinner as f64 > 0.6 {
(4 + (rounded_od > 4.0) as u8) as f32
} 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 = i32::MAX as f64;
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 = gen.segment_duration as f64;
for i in 0..=repeats as i32 + 1 {
let time = obj.start_time + segment_duration * i as f64;
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,
}
}
}
+167
View File
@@ -0,0 +1,167 @@
use std::collections::HashSet;
use crate::{
parse::{HitObject, HitObjectKind, Pos2},
util::ByteHasher,
};
use super::pattern_generator::{
distance_object::DistanceObjectPatternGenerator,
end_time_object::EndTimeObjectPatternGenerator, hit_object::HitObjectPatternGenerator,
};
#[derive(Default)]
pub(crate) struct Pattern {
pub(crate) hit_objects: Vec<HitObject>,
contained_columns: HashSet<u8, ByteHasher>,
}
impl Pattern {
pub(crate) fn with_capacity(capacity: usize) -> Self {
Self {
hit_objects: Vec::with_capacity(capacity),
contained_columns: HashSet::with_hasher(ByteHasher),
}
}
fn new_single(hit_object: HitObject, column: u8) -> Self {
let mut contained_columns = HashSet::with_capacity_and_hasher(1, ByteHasher);
contained_columns.insert(column);
let hit_objects = vec![hit_object];
Self {
hit_objects,
contained_columns,
}
}
pub(crate) fn new_note(generator: &HitObjectPatternGenerator<'_>, column: u8) -> Self {
let hit_object = HitObject {
pos: Pos2::new(column_to_pos(column, generator.total_columns)),
start_time: generator.hit_object.start_time,
kind: HitObjectKind::Circle,
};
Self::new_single(hit_object, column)
}
pub(crate) fn add_note(&mut self, generator: &HitObjectPatternGenerator<'_>, column: u8) {
let hit_object = HitObject {
pos: Pos2::new(column_to_pos(column, generator.total_columns)),
start_time: generator.hit_object.start_time,
kind: HitObjectKind::Circle,
};
self.contained_columns.insert(column);
self.hit_objects.push(hit_object);
}
pub(crate) 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 hit_object = if hold_note {
HitObject {
pos,
start_time: generator.hit_object.start_time,
kind: HitObjectKind::Hold {
end_time: generator.end_time,
},
}
} else {
HitObject {
pos,
start_time: generator.hit_object.start_time,
kind: HitObjectKind::Circle,
}
};
Self::new_single(hit_object, column)
}
pub(crate) 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 hit_object = if start_time == end_time {
HitObject {
pos,
start_time: start_time as f64,
kind: HitObjectKind::Circle,
}
} else {
HitObject {
pos,
start_time: start_time as f64,
kind: HitObjectKind::Hold {
end_time: end_time as f64,
},
}
};
Self::new_single(hit_object, column)
}
pub(crate) 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 hit_object = if start_time == end_time {
HitObject {
pos,
start_time: start_time as f64,
kind: HitObjectKind::Circle,
}
} else {
HitObject {
pos,
start_time: start_time as f64,
kind: HitObjectKind::Hold {
end_time: end_time as f64,
},
}
};
self.contained_columns.insert(column);
self.hit_objects.push(hit_object);
}
pub(crate) 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(crate) 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) {
self.hit_objects.append(&mut other.hit_objects);
self.contained_columns
.extend(other.contained_columns.drain());
}
}
fn column_to_pos(column: u8, total_columns: i32) -> f32 {
let divisor = 512.0 / total_columns as f32;
(column as f32 * divisor).ceil()
}
@@ -0,0 +1,541 @@
use crate::{
beatmap::{
converts::mania::{legacy_random::Random, pattern::Pattern, pattern_type::PatternType},
EffectPoint,
},
curve::Curve,
mania::ManiaObject,
parse::{HitObject, HitSound},
util::FloatExt,
Beatmap,
};
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,
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],
}
impl<'h> DistanceObjectPatternGenerator<'h> {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
random: &'h mut Random,
hit_object: &'h HitObject,
sample: u8,
total_columns: i32,
prev_pattern: &'h Pattern,
orig: &'h Beatmap,
repeats: usize,
curve: &Curve<'_>,
edge_sounds: &'h [u8],
) -> 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 kiai = orig
.effect_point_at(hit_object.start_time)
.map_or(EffectPoint::DEFAULT_KIAI, |point| point.kiai);
let convert_type = if kiai {
PatternType::default()
} else {
PatternType::LOW_PROBABILITY
};
let beat_len = timing_point.beat_len * difficulty_point.bpm_mult;
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 = (start_time as f64
+ curve.dist() * beat_len * span_count as f64 * 0.01 / orig.slider_mult)
.floor() as i32;
let segment_duration = (end_time - start_time) / span_count;
Self {
hit_object,
segment_duration,
total_columns,
sample,
start_time,
end_time,
span_count,
orig,
prev_pattern,
convert_type,
random,
edge_sounds,
}
}
pub(crate) fn generate(&mut self) -> Vec<Pattern> {
let orig_pattern = self.generate_();
if orig_pattern.hit_objects.len() == 1 {
return vec![orig_pattern];
}
// * We need to split the intermediate pattern into two new patterns:
// * 1. A pattern containing all objects that do not end at our EndTime.
// * 2. A pattern containing all objects that end at our EndTime. This will be used for further pattern generation.
let mut intermediate_pattern = Pattern::default();
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;
if self.end_time != obj.end_time().round_even() as i32 {
intermediate_pattern.add_object(obj, col);
} else {
end_time_pattern.add_object(obj, col);
}
}
vec![intermediate_pattern, end_time_pattern]
}
fn generate_(&mut self) -> Pattern {
let conversion_diff = self.conversion_difficulty();
if self.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 {
self.generate_random_hold_notes(self.start_time, 1)
} else if self.segment_duration <= 120 {
self.convert_type |= PatternType::FORCE_NOT_STACK;
self.generate_random_notes(self.start_time, self.span_count + 1)
} else if self.segment_duration <= 160 {
self.generate_stair(self.start_time)
} else if self.segment_duration <= 200 && conversion_diff > 3.0 {
self.generate_random_multiple_notes(self.start_time)
} 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.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 {
self.convert_type |= PatternType::FORCE_NOT_STACK;
} else {
self.convert_type &= !PatternType::FORCE_NOT_STACK;
}
let note_count = 1 + (self.segment_duration >= 80) as i32;
self.generate_random_notes(self.start_time, note_count)
} else if conversion_diff > 6.5 {
if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
self.generate_n_random_notes(self.start_time, 0.78, 0.3, 0.0)
} else {
self.generate_n_random_notes(self.start_time, 0.85, 0.36, 0.03)
}
} else if conversion_diff > 4.0 {
if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
self.generate_n_random_notes(self.start_time, 0.43, 0.08, 0.0)
} else {
self.generate_n_random_notes(self.start_time, 0.56, 0.18, 0.0)
}
} else if conversion_diff > 2.5 {
if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
self.generate_n_random_notes(self.start_time, 0.3, 0.0, 0.0)
} else {
self.generate_n_random_notes(self.start_time, 0.37, 0.08, 0.0)
}
} else if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
self.generate_n_random_notes(self.start_time, 0.17, 0.0, 0.0)
} else {
self.generate_n_random_notes(self.start_time, 0.27, 0.0, 0.0)
}
}
fn generate_random_hold_notes(&mut self, start_time: i32, note_count: i32) -> Pattern {
// * - - - -
// * ■ - ■ ■
// * □ - □ □
// * ■ - ■ ■
let mut pattern = Pattern::default();
let random_start = self.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);
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],
);
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]);
pattern.add_slider_note(self, next_column, start_time, self.end_time);
}
pattern
}
fn generate_random_notes(&mut self, mut start_time: i32, note_count: i32) -> Pattern {
// * - - - -
// * x - - -
// * - - x -
// * - - - x
// * x - - -
let mut next_column = self.get_column(Some(true));
if self.convert_type.contains(PatternType::FORCE_NOT_STACK)
&& self.prev_pattern.column_with_objs() < self.total_columns
{
next_column = self.find_available_column(
next_column,
None,
None,
None,
None,
&[self.prev_pattern],
);
}
let mut last_column = next_column;
let mut pattern = Pattern::with_capacity(note_count as usize);
for _ in 0..note_count {
pattern.add_slider_note(self, next_column, start_time, start_time);
next_column = self.find_available_column(
next_column,
None,
None,
None,
Some(&|c| c != last_column as i32),
&[],
);
last_column = next_column;
start_time += self.segment_duration;
}
pattern
}
fn generate_stair(&mut self, mut start_time: i32) -> Pattern {
// * - - - -
// * x - - -
// * - x - -
// * - - x -
// * - - - x
// * - - x -
// * - x - -
// * x - - -
let mut column = self.get_column(Some(true)) as i32;
let mut increasing = self.random.gen_double() > 0.5;
let mut pattern = Pattern::with_capacity(self.span_count as usize + 1);
for _ in 0..=self.span_count as usize {
pattern.add_slider_note(self, column as u8, start_time, start_time);
start_time += self.segment_duration;
// * Check if we're at the borders of the stage, and invert the pattern if so
if increasing {
if column >= self.total_columns - 1 {
increasing = false;
column -= 1;
} else {
column += 1;
}
} else if column <= self.random_start() {
increasing = true;
column += 1;
} else {
column -= 1;
}
}
pattern
}
fn generate_random_multiple_notes(&mut self, mut start_time: i32) -> Pattern {
// * - - - -
// * x - - -
// * - x x -
// * - - - x
// * x - x -
let legacy = (4..=8).contains(&self.total_columns);
let interval = self
.random
.gen_int_range(1, self.total_columns as i32 - (legacy as i32));
let mut next_column = self.get_column(Some(true)) as i32;
let random_start = self.random_start();
let not_2k = self.total_columns > 2;
let mut pattern =
Pattern::with_capacity((self.span_count as usize + 1) * (1 + not_2k as usize));
for _ in 0..=self.span_count as usize {
pattern.add_slider_note(self, next_column as u8, start_time, start_time);
next_column += interval;
if next_column >= self.total_columns - random_start {
next_column = next_column - self.total_columns - random_start + (legacy as i32);
}
next_column += random_start;
// * If we're in 2K, let's not add many consecutive doubles
if not_2k {
pattern.add_slider_note(self, next_column as u8, start_time, start_time);
}
next_column = PatternGenerator::get_random_column(self, None, None) as i32;
start_time += self.segment_duration;
}
pattern
}
fn generate_n_random_notes(
&mut self,
start_time: i32,
mut p2: f64,
mut p3: f64,
mut p4: f64,
) -> Pattern {
// * - - - -
// * ■ - ■ ■
// * □ - □ □
// * ■ - ■ ■
match self.total_columns {
2 => {
p2 = 0.0;
p3 = 0.0;
p4 = 0.0;
}
3 => {
p2 = p2.min(0.1);
p3 = 0.0;
p4 = 0.0;
}
4 => {
p2 = p2.min(0.3);
p3 = p3.min(0.04);
p4 = 0.0;
}
5 => {
p2 = p2.min(0.34);
p3 = p3.min(0.1);
p4 = p4.min(0.03);
}
_ => {}
}
let is_double_sample = |sample: u8| sample.clap() || sample.finish();
let can_generate_two_notes = !self.convert_type.contains(PatternType::LOW_PROBABILITY)
&& (is_double_sample(self.sample)
|| is_double_sample(self.sample_info_list_at(self.start_time)));
if can_generate_two_notes {
p2 = 1.0;
}
let note_count = self.get_random_note_count(p2, p3, Some(p4), None, None);
self.generate_random_hold_notes(start_time, note_count)
}
fn generate_tiled_hold_notes(&mut self, mut start_time: i32) -> Pattern {
// * - - - -
// * ■ ■ ■ ■
// * □ □ □ □
// * □ □ □ □
// * □ □ □ ■
// * □ □ ■ -
// * □ ■ - -
// * ■ - - -
let column_repeat = self.span_count.min(self.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));
if self.convert_type.contains(PatternType::FORCE_NOT_STACK)
&& self.prev_pattern.column_with_objs() < self.total_columns
{
next_column = self.find_available_column(
next_column,
None,
None,
None,
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]);
pattern.add_slider_note(self, next_column, start_time, end_time);
start_time += self.segment_duration;
}
pattern
}
fn generate_hold_and_normal_notes(
&mut self,
mut start_time: i32,
conversion_diff: f64,
) -> Pattern {
// * - - - -
// * ■ x x -
// * ■ - x x
// * ■ x - x
// * ■ - x x
let mut pattern = Pattern::default();
let mut hold_column = self.get_column(Some(true));
if self.convert_type.contains(PatternType::FORCE_NOT_STACK)
&& self.prev_pattern.column_with_objs() < self.total_columns
{
hold_column = self.find_available_column(
hold_column,
None,
None,
None,
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 note_count = if conversion_diff > 6.5 {
self.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 };
self.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 };
self.get_random_note_count(p2, 0.0, None, None, None)
} else {
0
};
note_count = note_count.min(self.total_columns - 1);
let sample = self.sample_info_list_at(start_time);
let ignore_head = !(sample.whistle() || sample.finish() || sample.clap());
let mut row_pattern = Pattern::default();
let hold_column = hold_column as i32;
for _ in 0..=self.span_count as usize {
if !(ignore_head && start_time == self.start_time) {
for _ in 0..note_count {
next_column = self.find_available_column(
next_column,
None,
None,
None,
Some(&|c| c != hold_column),
&[&row_pattern],
);
row_pattern.add_slider_note(self, next_column, start_time, start_time);
}
}
pattern.append(&mut row_pattern);
start_time += self.segment_duration;
}
pattern
}
fn sample_info_list_at(&self, time: i32) -> u8 {
self.note_samples_at(time)
.first()
.map_or(self.sample, |sample| *sample)
}
fn note_samples_at(&self, time: i32) -> &[u8] {
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
}
}
@@ -0,0 +1,97 @@
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")
}
}
@@ -0,0 +1,465 @@
use crate::{
beatmap::{
converts::mania::{
legacy_random::Random, pattern::Pattern, pattern_type::PatternType, PrevValues,
},
EffectPoint,
},
mania::ManiaObject,
parse::{HitObject, HitSound},
Beatmap,
};
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,
convert_type: PatternType,
prev_pattern: &'h Pattern,
random: &'h mut Random,
orig: &'h Beatmap,
}
impl<'h> HitObjectPatternGenerator<'h> {
pub(crate) fn new(
random: &'h mut Random,
hit_object: &'h HitObject,
sample: u8,
total_columns: i32,
prev: &'h PrevValues,
density: f64,
orig: &'h Beatmap,
) -> Self {
let timing_point = orig.timing_point_at(hit_object.start_time);
let pos_separation = (hit_object.pos - prev.pos).length();
let time_separation = hit_object.start_time - prev.time;
let mut convert_type = PatternType::default();
if time_separation <= 80.0 {
// * More than 187 BPM
convert_type |= PatternType::FORCE_NOT_STACK | PatternType::KEEP_SINGLE;
} else if time_separation <= 95.0 {
// * More than 157 BPM
convert_type |= PatternType::FORCE_NOT_STACK | PatternType::KEEP_SINGLE | prev.stair;
} else if time_separation <= 105.0 {
// * More than 140 BPM
convert_type |= PatternType::FORCE_NOT_STACK | PatternType::LOW_PROBABILITY;
} else if time_separation <= 125.0 {
// * More than 120 BPM
convert_type |= PatternType::FORCE_NOT_STACK;
} else if time_separation <= 135.0 && pos_separation < 20.0 {
// * More than 111 BPM stream
convert_type |= PatternType::CYCLE | PatternType::KEEP_SINGLE;
} 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 {
// * Low density stream
convert_type |= PatternType::REVERSE | PatternType::LOW_PROBABILITY;
} else if density < timing_point.beat_len / 2.5 {
// * High density
} else {
let kiai = orig
.effect_point_at(hit_object.start_time)
.map_or(EffectPoint::DEFAULT_KIAI, |point| point.kiai);
if kiai {
// * High density
} else {
convert_type |= PatternType::LOW_PROBABILITY;
}
}
if !convert_type.contains(PatternType::KEEP_SINGLE) {
if sample.finish() && total_columns != 8 {
convert_type |= PatternType::MIRROR;
} else if sample.clap() {
convert_type |= PatternType::GATHERED;
}
}
Self {
hit_object,
stair_type: prev.stair,
convert_type,
total_columns,
sample,
prev_pattern: &prev.pattern,
random,
orig,
}
}
pub(crate) 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;
if self.convert_type.contains(PatternType::STAIR) && col == self.total_columns - 1 {
self.stair_type = PatternType::REVERSE_STAIR;
}
if self.convert_type.contains(PatternType::REVERSE_STAIR) && col == self.random_start()
{
self.stair_type = PatternType::STAIR;
}
}
pattern
}
fn generate_core(&mut self) -> Pattern {
if self.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
});
let random_start = self.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 {
if self.prev_pattern.column_has_obj(i) {
pattern.add_note(self, random_start + self.total_columns as u8 - i - 1);
}
}
return pattern;
}
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)
// * Make sure the last column was not the centre column
&& (self.total_columns % 2 == 0 || last_column != self.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;
return Pattern::new_note(self, column);
}
if self.convert_type.contains(PatternType::FORCE_STACK)
&& !self.prev_pattern.hit_objects.is_empty()
{
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 {
if self.prev_pattern.column_has_obj(i) {
pattern.add_note(self, i);
}
}
return pattern;
}
if self.prev_pattern.hit_objects.len() == 1 {
if self.convert_type.contains(PatternType::STAIR) {
// * Generate a new pattern by placing on the next column,
// * 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 {
target_column = random_start;
}
return Pattern::new_note(self, target_column);
}
if self.convert_type.contains(PatternType::REVERSE_STAIR) {
// * Generate a new pattern by placing on the previous column,
// * cycling back to the end if there is no "previous"
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;
}
return Pattern::new_note(self, target_column as u8);
}
}
if self.convert_type.contains(PatternType::KEEP_SINGLE) {
return self.generate_random_notes(1);
}
let conversion_diff = self.conversion_difficulty();
if self.convert_type.contains(PatternType::MIRROR) {
if conversion_diff > 6.5 {
self.generate_random_pattern_with_mirrored(0.12, 0.38, 0.12)
} else if conversion_diff > 4.0 {
self.generate_random_pattern_with_mirrored(0.12, 0.17, 0.0)
} else {
self.generate_random_pattern_with_mirrored(0.12, 0.0, 0.0)
}
} else if conversion_diff > 6.5 {
if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
self.generate_random_pattern(0.78, 0.42, 0.0, 0.0)
} else {
self.generate_random_pattern(1.0, 0.62, 0.0, 0.0)
}
} else if conversion_diff > 4.0 {
if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
self.generate_random_pattern(0.35, 0.08, 0.0, 0.0)
} else {
self.generate_random_pattern(0.52, 0.15, 0.0, 0.0)
}
} else if conversion_diff > 2.0 {
if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
self.generate_random_pattern(0.18, 0.0, 0.0, 0.0)
} else {
self.generate_random_pattern(0.45, 0.0, 0.0, 0.0)
}
} else {
self.generate_random_pattern(0.0, 0.0, 0.0, 0.0)
}
}
fn generate_random_notes(&mut self, mut note_count: i32) -> Pattern {
let mut pattern = Pattern::default();
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);
}
let mut next_column = self.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],
)
};
pattern.add_note(self, next_column);
}
pattern
}
fn get_next_column(&mut self, mut last: u8) -> u8 {
if self.convert_type.contains(PatternType::GATHERED) {
last += 1;
if last == self.total_columns as u8 {
last = self.random_start() as u8;
}
} else {
last = PatternGenerator::get_random_column(self, None, None);
}
last
}
fn has_special_column(&self) -> bool {
self.sample.clap() && self.sample.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() {
pattern.add_note(self, 0);
}
pattern
}
fn get_random_note_count(&mut self, mut p2: f64, mut p3: f64, mut p4: f64, mut p5: f64) -> i32 {
match self.total_columns {
2 => {
p2 = 0.0;
p3 = 0.0;
p4 = 0.0;
p5 = 0.0;
}
3 => {
p2 = p2.min(0.1);
p3 = 0.0;
p4 = 0.0;
p5 = 0.0;
}
4 => {
p2 = p2.min(0.23);
p3 = p3.min(0.04);
p4 = 0.0;
p5 = 0.0;
}
5 => {
p3 = p3.min(0.15);
p4 = p4.min(0.03);
p5 = 0.0;
}
_ => {}
}
if self.sample.clap() {
p2 = 1.0;
}
PatternGenerator::get_random_note_count(self, p2, p3, Some(p4), Some(p5), None)
}
fn generate_random_pattern_with_mirrored(
&mut self,
centre_probability: f64,
p2: f64,
p3: f64,
) -> Pattern {
if self.convert_type.contains(PatternType::FORCE_NOT_STACK) {
return self.generate_random_pattern(1.0 / 2.0 + p2 / 2.0, p2, (p2 + p3) / 2.0, p3);
}
let mut pattern = Pattern::default();
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
} else {
(self.total_columns - 1) / 2
};
let mut next_column = PatternGenerator::get_random_column(self, None, Some(column_limit));
for _ in 0..note_count {
next_column = self.find_available_column(
next_column,
None,
Some(column_limit),
None,
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;
pattern.add_note(self, column);
}
if add_to_centre {
pattern.add_note(self, self.total_columns as u8 / 2);
}
if self.random_start() > 0 && self.has_special_column() {
pattern.add_note(self, 0);
}
pattern
}
fn get_random_note_count_mirrored(
&mut self,
mut centre_probability: f64,
mut p2: f64,
mut p3: f64,
) -> (i32, bool) {
match self.total_columns {
2 => {
centre_probability = 0.0;
p2 = 0.0;
p3 = 0.0;
}
3 => {
centre_probability = centre_probability.min(0.03);
p2 = 0.0;
p3 = 0.0;
}
4 => {
centre_probability = 0.0;
// * Stable requires rngValue > x, which is an inverse-probability. Lazer uses true probability (1 - x).
// * But multiplying this value by 2 (stable) is not the same operation as dividing it by 2 (lazer),
// * so it needs to be converted to from a probability and then back after the multiplication.
p2 = 1.0 - ((1.0 - p2) * 2.0).max(0.8);
p3 = 0.0;
}
5 => {
centre_probability = centre_probability.min(0.03);
p3 = 0.0;
}
6 => {
centre_probability = 0.0;
// * Stable requires rngValue > x, which is an inverse-probability. Lazer uses true probability (1 - x).
// * But multiplying this value by 2 (stable) is not the same operation as dividing it by 2 (lazer),
// * so it needs to be converted to from a probability and then back after the multiplication.
p2 = 1.0 - ((1.0 - p2) * 2.0).max(0.05);
p3 = 1.0 - ((1.0 - p3) * 2.0).max(0.85);
}
_ => {}
}
// * The stable values were allowed to exceed 1, which indicate <0% probability.
// * These values needs to be clamped otherwise GetRandomNoteCount() will throw an exception.
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;
(note_count, add_to_centre)
}
}
impl PatternGenerator for HitObjectPatternGenerator<'_> {
#[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
}
}
@@ -0,0 +1,139 @@
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 {
(self.total_columns() == 8) as i32
}
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 + (val >= 1.0 - p2) as i32
}
}
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 += (orig.hp + orig.ar.clamp(4.0, 7.0)) as f64 / 1.5;
conversion_difficulty += orig.hit_objects.len() as f64 / drain_time as f64 * 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(initial_column as i32) {
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(initial_column as i32)
} {}
initial_column
}
}
+104
View File
@@ -0,0 +1,104 @@
use std::{
fmt,
ops::{BitAndAssign, BitOr, BitOrAssign, Not},
};
#[derive(Copy, Clone, Default)]
pub(crate) 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);
}
impl fmt::Display for PatternType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut written = false;
macro_rules! write_pattern {
($self:ident, $f:ident, $written:ident: $($pat:ident,)*) => {
$(
if $self.contains(Self::$pat) {
if $written {
$f.write_str(", ")?;
} else {
$written = true;
}
$f.write_str(stringify!($pat))?;
}
)*
}
}
write_pattern! {
self, f, written:
FORCE_STACK,
FORCE_NOT_STACK,
KEEP_SINGLE,
LOW_PROBABILITY,
GATHERED,
MIRROR,
REVERSE,
CYCLE,
STAIR,
REVERSE_STAIR,
}
if !written {
f.write_str("NONE")?;
}
Ok(())
}
}
impl PatternType {
pub(crate) fn contains(self, other: Self) -> bool {
self.0 & other.0 == other.0
}
}
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;
}
}
impl Not for PatternType {
type Output = Self;
#[inline]
fn not(self) -> Self::Output {
Self(!self.0)
}
}
+2
View File
@@ -0,0 +1,2 @@
mod mania;
mod taiko;
+156
View File
@@ -0,0 +1,156 @@
use crate::{
curve::{Curve, CurveBuffers},
parse::{HitObject, HitObjectKind},
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 *= LEGACY_TAIKO_VELOCITY_MULTIPLIER as f64;
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 + params.duration as f64 + params.tick_spacing / 8.0
{
let h = HitObject {
pos: Default::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 * LEGACY_TAIKO_VELOCITY_MULTIPLIER as f64;
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 =
OSU_BASE_SCORING_DIST as f64 * 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 * (1000.0_f32 as f64 / 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(*duration as f64 / 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> {
fn new(start_time: f64, repeats: usize, curve: &'c Curve<'c>) -> Self {
Self {
curve,
repeats,
start_time,
duration: 0,
tick_spacing: 0.0,
}
}
}
+177
View File
@@ -0,0 +1,177 @@
use std::{borrow::Cow, cmp::Ordering};
use crate::{parse::HitObject, util::SortedVec};
pub use self::{
attributes::{BeatmapAttributes, BeatmapAttributesBuilder, BeatmapHitWindows},
breaks::Break,
control_points::{DifficultyPoint, EffectPoint, TimingPoint},
mode::GameMode,
};
mod attributes;
mod breaks;
mod control_points;
mod converts;
mod mode;
/// 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>,
/// The creator of the beatmap
pub creator: String,
/// The beatmap ID of the map
pub beatmap_id: u32,
}
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 {
match self.timing_points.first() {
Some(point) => point.beat_len.recip() * 1000.0 * 60.0,
None => 0.0,
}
}
/// 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((with_sounds as usize) * 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(),
creator: self.creator.clone(),
beatmap_id: self.beatmap_id,
}
}
}
+19
View File
@@ -0,0 +1,19 @@
/// The mode of a beatmap.
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq)]
pub enum GameMode {
/// osu!standard
Osu = 0,
/// osu!taiko
Taiko = 1,
/// osu!catch
Catch = 2,
/// osu!mania
Mania = 3,
}
impl Default for GameMode {
#[inline]
fn default() -> Self {
Self::Osu
}
}
+91
View File
@@ -0,0 +1,91 @@
use crate::parse::Pos2;
use super::fruit_or_juice::FruitParams;
const PLAYFIELD_WIDTH: f32 = 512.0;
const BASE_SPEED: f64 = 1.0;
#[derive(Clone, Debug)]
pub struct CatchObject {
pub(crate) pos: f32,
pub(crate) time: f64,
pub(crate) hyper_dash: bool,
pub(crate) hyper_dist: f32,
}
impl CatchObject {
#[inline]
pub(crate) 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 = (next_x > curr_x) as i8 * 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 = (next_x - curr_x).abs() as f64 - 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 = (hyper_dist as f64).max(0.0).min(half_catcher_width);
}
*last_direction = this_direction;
}
}
+46
View File
@@ -0,0 +1,46 @@
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,
}
}
}
+284
View File
@@ -0,0 +1,284 @@
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<'a> {
pub(crate) attributes: CatchDifficultyAttributes,
pub(crate) curve_bufs: CurveBuffers,
pub(crate) last_pos: Option<f32>,
pub(crate) last_time: f64,
pub(crate) map: &'a Beatmap,
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<'_>) -> 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 = params.map.timing_point_at(h.start_time);
let difficulty_point = params
.map
.difficulty_point_at(h.start_time)
.unwrap_or_default();
let vel_factor =
BASE_SCORING_DISTANCE * params.map.slider_mult / timing_point.beat_len;
let tick_dist_factor =
BASE_SCORING_DISTANCE * params.map.slider_mult / params.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 = (span_idx % 2 == 1) as u8 as f64;
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 = (*repeats % 2 == 0) as u8 as f64;
let pos = h.pos + curve.position_at(progress);
slider_objects.push((pos, h.start_time + total_duration));
let new_fruits = 2 + (tick_dist > 0.0) as usize * *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
}
+211
View File
@@ -0,0 +1,211 @@
use std::{iter, slice::Iter};
use crate::{
catch::{difficulty_object::DifficultyObject, SECTION_LENGTH, STAR_SCALING_FACTOR},
curve::CurveBuffers,
parse::{HitObject, 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`](std::iter::Iterator).
/// On every call of [`Iterator::next`](std::iter::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
/// [`CatchGradualPerformanceAttributes`](crate::catch::CatchGradualPerformanceAttributes) instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, catch::CatchGradualDifficultyAttributes};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut iter = CatchGradualDifficultyAttributes::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 {
/// // ...
/// }
/// ```
#[derive(Clone, Debug)]
pub struct CatchGradualDifficultyAttributes<'map> {
pub(crate) idx: usize,
clock_rate: f64,
hit_objects: CatchObjectIter<'map>,
movement: Movement,
prev: CatchObject,
half_catcher_width: f64,
last_direction: i8,
last_excess: f64,
curr_section_end: f64,
strain_peak_buf: Vec<f64>,
}
impl<'map> CatchGradualDifficultyAttributes<'map> {
/// Create a new difficulty attributes iterator for osu!catch maps.
pub fn new(map: &'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(map, mods, attributes);
let half_catcher_width =
(calculate_catch_width(map_attributes.cs as f32) / 2.0 / ALLOWED_CATCH_RANGE) as f64;
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,
);
}
}
impl Iterator for CatchGradualDifficultyAttributes<'_> {
type Item = CatchDifficultyAttributes;
fn next(&mut self) -> Option<Self::Item> {
let curr = self.hit_objects.next()?;
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 mut attributes = self.hit_objects.attributes();
attributes.stars =
Movement::difficulty_value(&mut self.strain_peak_buf).sqrt() * STAR_SCALING_FACTOR;
Some(attributes)
}
}
#[derive(Clone, Debug)]
struct CatchObjectIter<'map> {
last_object: Option<FruitOrJuice>,
hit_objects: Iter<'map, HitObject>,
params: FruitParams<'map>,
}
impl<'map> CatchObjectIter<'map> {
fn new(map: &'map Beatmap, mods: impl Mods, attributes: CatchDifficultyAttributes) -> Self {
let params = FruitParams {
attributes,
curve_bufs: CurveBuffers::default(),
last_pos: None,
last_time: 0.0,
map,
ticks: Vec::new(),
with_hr: mods.hr(),
};
Self {
last_object: None,
hit_objects: map.hit_objects.iter(),
params,
}
}
fn attributes(&self) -> CatchDifficultyAttributes {
self.params.attributes.clone()
}
}
impl Iterator for CatchObjectIter<'_> {
type Item = CatchObject;
fn next(&mut self) -> Option<Self::Item> {
if let Some(h) = self.last_object.as_mut().and_then(Iterator::next) {
return Some(h);
}
for h in &mut self.hit_objects {
if let Some(h) = FruitOrJuice::new(h, &mut self.params) {
return self.last_object.insert(h).next();
}
}
None
}
}
+189
View File
@@ -0,0 +1,189 @@
use crate::{Beatmap, CatchPP};
use super::{CatchGradualDifficultyAttributes, CatchPerformanceAttributes};
/// Aggregation for a score's current state i.e. what was the
/// maximum combo so far and what are the current hitresults.
///
/// This struct is used for [`CatchGradualPerformanceAttributes`].
#[derive(Clone, Debug, Default, Eq, PartialEq)]
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,
/// Amount of current fruits (300s).
pub n_fruits: usize,
/// Amount of current droplets (100s).
pub n_droplets: usize,
/// Amount of current tiny droplets (50s).
pub n_tiny_droplets: usize,
/// Amount of current tiny droplet misses (katus).
pub n_tiny_droplet_misses: usize,
/// Amount of current misses (fruits and droplets).
pub n_misses: usize,
}
impl CatchScoreState {
/// Create a new empty score state.
pub fn new() -> Self {
Self::default()
}
}
/// Gradually calculate the performance attributes of an osu!catch map.
///
/// After each hit object you can call
/// [`process_next_object`](`CatchGradualPerformanceAttributes::process_next_object`)
/// and it will return the resulting current [`CatchPerformanceAttributes`].
/// To process multiple objects at once, use
/// [`process_next_n_objects`](`CatchGradualPerformanceAttributes::process_next_n_objects`) 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
/// [`CatchGradualDifficultyAttributes`](crate::catch::CatchGradualDifficultyAttributes) instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, catch::{CatchGradualPerformanceAttributes, CatchScoreState}};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut gradual_perf = CatchGradualPerformanceAttributes::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.process_next_object(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_object(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.process_next_object(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_object(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;
/// # /*
/// let performance = gradual_perf.process_next_n_objects(state.clone(), 10).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_n_objects(state.clone(), 10);
///
/// // Now comes another fruit. Note that the max combo gets incremented again.
/// state.n_fruits += 1;
/// state.max_combo += 1;
/// # /*
/// let performance = gradual_perf.process_next_object(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_object(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.process_next_n_objects(state.clone(), usize::MAX).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_n_objects(state.clone(), usize::MAX);
///
/// // Once the final performance was calculated,
/// // attempting to process further objects will return `None`.
/// assert!(gradual_perf.process_next_object(state).is_none());
/// ```
#[derive(Clone, Debug)]
pub struct CatchGradualPerformanceAttributes<'map> {
difficulty: CatchGradualDifficultyAttributes<'map>,
performance: CatchPP<'map>,
}
impl<'map> CatchGradualPerformanceAttributes<'map> {
/// Create a new gradual performance calculator for osu!standard maps.
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
let difficulty = CatchGradualDifficultyAttributes::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 process_next_object(
&mut self,
state: CatchScoreState,
) -> Option<CatchPerformanceAttributes> {
self.process_next_n_objects(state, 1)
}
/// Same as [`process_next_object`](`CatchGradualPerformanceAttributes::process_next_object`)
/// but instead of processing only one object it process `n` many.
///
/// If `n` is 0 it will be considered as 1.
/// If there are still objects to be processed but `n` is larger than the amount
/// of remaining objects, `n` will be considered as the amount of remaining objects.
pub fn process_next_n_objects(
&mut self,
state: CatchScoreState,
n: usize,
) -> Option<CatchPerformanceAttributes> {
let mut difficulty = None;
for _ in 0..n.max(1) {
match self.difficulty.next() {
Some(attrs) => difficulty = Some(attrs),
None => break,
}
}
let difficulty = difficulty?;
let performance = self
.performance
.clone()
.attributes(difficulty)
.state(state)
.passed_objects(self.difficulty.idx)
.calculate();
Some(performance)
}
}
+314
View File
@@ -0,0 +1,314 @@
mod catch_object;
mod difficulty_object;
mod fruit_or_juice;
mod gradual_difficulty;
mod gradual_performance;
mod movement;
mod pp;
use catch_object::CatchObject;
use difficulty_object::DifficultyObject;
use fruit_or_juice::FruitOrJuice;
pub use gradual_difficulty::*;
pub use gradual_performance::*;
use movement::Movement;
pub use pp::*;
use crate::{catch::fruit_or_juice::FruitParams, curve::CurveBuffers, Beatmap, Mods, OsuStars};
const SECTION_LENGTH: f64 = 750.0;
const STAR_SCALING_FACTOR: f64 = 0.153;
const ALLOWED_CATCH_RANGE: f32 = 0.8;
const CATCHER_SIZE: f32 = 106.75;
/// Difficulty calculator on osu!catch maps.
///
/// # 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)]
pub struct CatchStars<'map> {
map: &'map Beatmap,
mods: u32,
passed_objects: Option<usize>,
clock_rate: Option<f64>,
}
impl<'map> CatchStars<'map> {
/// Create a new difficulty calculator for osu!catch maps.
#[inline]
pub fn new(map: &'map Beatmap) -> Self {
Self {
map,
mods: 0,
passed_objects: None,
clock_rate: None,
}
}
/// 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(mut self, mods: u32) -> Self {
self.mods = mods;
self
}
/// Amount of passed objects for partial plays, e.g. a fail.
///
/// 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::CatchGradualDifficultyAttributes).
#[inline]
pub 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 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()
}
}
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,
map,
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))
.flatten()
.take(take);
// Hyper dash business
let half_catcher_width =
(calculate_catch_width(map_attributes.cs as f32) / 2.0 / ALLOWED_CATCH_RANGE) as f64;
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),
(Some(_), None) | (None, 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;
}
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 fn max_combo(&self) -> usize {
self.n_fruits + self.n_droplets
}
}
/// 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 fn stars(&self) -> f64 {
self.difficulty.stars
}
/// Return the performance point value.
#[inline]
pub fn pp(&self) -> f64 {
self.pp
}
/// Return the maximum combo of the map.
#[inline]
pub 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,
}
}
}
+144
View File
@@ -0,0 +1,144 @@
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
.max(current.normalized_pos - POSITION_EPSILON)
.min(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 = (dist_moved.abs().powf(1.3) / 510.0) as f64;
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 = (dist_moved.abs().min(50.0) / 50.0) as f64;
let anti_flow_factor =
(self.last_distance_moved.abs().min(70.0) / 70.0).max(0.38) as f64;
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 * dist_moved.abs().min(NORMALIZED_HITOBJECT_RADIUS * 2.0)
/ (NORMALIZED_HITOBJECT_RADIUS * 6.0)) as f64
/ weighted_strain_time.sqrt();
}
let mut edge_dash_bonus = 0.0;
if current.last.hyper_dist <= 20.0 {
if !current.last.hyper_dash {
edge_dash_bonus += 5.7;
} else {
pos = current.normalized_pos;
}
dist_addition *= 1.0
+ edge_dash_bonus
* ((20.0 - current.last.hyper_dist) / 20.0) as f64
* ((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)
}
+653
View File
@@ -0,0 +1,653 @@
use super::{CatchDifficultyAttributes, CatchPerformanceAttributes, CatchScoreState, CatchStars};
use crate::{Beatmap, DifficultyAttributes, Mods, OsuPP, PerformanceAttributes};
/// 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)]
pub struct CatchPP<'map> {
map: &'map Beatmap,
attributes: Option<CatchDifficultyAttributes>,
mods: u32,
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>,
passed_objects: Option<usize>,
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,
attributes: None,
mods: 0,
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(attributes) = attributes.attributes() {
self.attributes.replace(attributes);
}
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(mut self, mods: u32) -> Self {
self.mods = mods;
self
}
/// Specify the max combo of the play.
#[inline]
pub fn combo(mut self, combo: usize) -> Self {
self.combo.replace(combo);
self
}
/// Specify the amount of fruits of a play i.e. n300.
#[inline]
pub fn fruits(mut self, n_fruits: usize) -> Self {
self.n_fruits.replace(n_fruits);
self
}
/// Specify the amount of droplets of a play i.e. n100.
#[inline]
pub fn droplets(mut self, n_droplets: usize) -> Self {
self.n_droplets.replace(n_droplets);
self
}
/// Specify the amount of tiny droplets of a play i.e. n50.
#[inline]
pub fn tiny_droplets(mut self, n_tiny_droplets: usize) -> Self {
self.n_tiny_droplets.replace(n_tiny_droplets);
self
}
/// Specify the amount of tiny droplet misses of a play i.e. n_katu.
#[inline]
pub fn tiny_droplet_misses(mut self, n_tiny_droplet_misses: usize) -> Self {
self.n_tiny_droplet_misses.replace(n_tiny_droplet_misses);
self
}
/// Specify the amount of fruit / droplet misses of the play.
#[inline]
pub 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.
///
/// 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::CatchGradualPerformanceAttributes).
#[inline]
pub fn passed_objects(mut self, passed_objects: usize) -> Self {
self.passed_objects.replace(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 fn clock_rate(mut self, clock_rate: f64) -> Self {
self.clock_rate = Some(clock_rate);
self
}
/// Provide parameters through an [`CatchScoreState`].
#[inline]
pub 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
}
// TODO: adjust this on the next rework
/// Generate the hit results with respect to the given accuracy between `0.0` and `100.0`.
///
/// Be sure to set `misses` beforehand! Also, if available, set `attributes` beforehand.
pub fn accuracy(mut self, mut acc: f64) -> Self {
if self.attributes.is_none() {
let mut calculator = CatchStars::new(self.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);
}
self.attributes = Some(calculator.calculate());
}
let attributes = self.attributes.as_ref().unwrap();
let n_droplets = self.n_droplets.unwrap_or_else(|| {
attributes
.n_droplets
.saturating_sub(self.n_misses.unwrap_or(0))
});
let max_combo = attributes.max_combo();
let n_fruits = self.n_fruits.unwrap_or_else(|| {
max_combo
.saturating_sub(self.n_misses.unwrap_or(0))
.saturating_sub(n_droplets)
});
let max_tiny_droplets = attributes.n_tiny_droplets;
acc /= 100.0;
let n_tiny_droplets = self.n_tiny_droplets.unwrap_or_else(|| {
((acc * (max_combo + max_tiny_droplets) as f64).round() as usize)
.saturating_sub(n_fruits)
.saturating_sub(n_droplets)
});
let n_tiny_droplet_misses = max_tiny_droplets.saturating_sub(n_tiny_droplets);
self.n_fruits.replace(n_fruits);
self.n_droplets.replace(n_droplets);
self.n_tiny_droplets.replace(n_tiny_droplets);
self.n_tiny_droplet_misses.replace(n_tiny_droplet_misses);
self
}
fn assert_hitresults(self, attributes: CatchDifficultyAttributes) -> CatchPPInner {
let max_combo = attributes.max_combo();
let correct_combo_hits = self
.n_fruits
.and_then(|f| self.n_droplets.map(|d| f + d + self.n_misses.unwrap_or(0)))
.filter(|h| *h == max_combo);
let correct_fruits = self.n_fruits.filter(|f| {
*f >= attributes
.n_fruits
.saturating_sub(self.n_misses.unwrap_or(0))
});
let correct_droplets = self.n_droplets.filter(|d| {
*d >= attributes
.n_droplets
.saturating_sub(self.n_misses.unwrap_or(0))
});
let correct_tinies = self
.n_tiny_droplets
.and_then(|t| self.n_tiny_droplet_misses.map(|m| t + m))
.filter(|h| *h == attributes.n_tiny_droplets);
if correct_combo_hits
.and(correct_fruits)
.and(correct_droplets)
.and(correct_tinies)
.is_none()
{
let mut n_fruits = self.n_fruits.unwrap_or(0);
let mut n_droplets = self.n_droplets.unwrap_or(0);
let mut n_tiny_droplets = self.n_tiny_droplets.unwrap_or(0);
let n_tiny_droplet_misses = self.n_tiny_droplet_misses.unwrap_or(0);
let missing = max_combo
.saturating_sub(n_fruits)
.saturating_sub(n_droplets)
.saturating_sub(self.n_misses.unwrap_or(0));
let missing_fruits =
missing.saturating_sub(attributes.n_droplets.saturating_sub(n_droplets));
n_fruits += missing_fruits;
n_droplets += missing.saturating_sub(missing_fruits);
n_tiny_droplets += attributes
.n_tiny_droplets
.saturating_sub(n_tiny_droplets)
.saturating_sub(n_tiny_droplet_misses);
return CatchPPInner {
attributes,
mods: self.mods,
combo: self.combo,
n_fruits,
n_droplets,
n_tiny_droplets,
n_tiny_droplet_misses,
n_misses: self.n_misses.unwrap_or(0),
};
}
CatchPPInner {
attributes,
mods: self.mods,
combo: self.combo,
n_fruits: self.n_fruits.unwrap_or(0),
n_droplets: self.n_droplets.unwrap_or(0),
n_tiny_droplets: self.n_tiny_droplets.unwrap_or(0),
n_tiny_droplet_misses: self.n_tiny_droplet_misses.unwrap_or(0),
n_misses: self.n_misses.unwrap_or(0),
}
}
/// Calculate all performance related values, including pp and stars.
pub fn calculate(mut self) -> CatchPerformanceAttributes {
let attributes = self.attributes.take().unwrap_or_else(|| {
let mut calculator = CatchStars::new(self.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()
});
self.assert_hitresults(attributes).calculate()
}
}
struct CatchPPInner {
attributes: CatchDifficultyAttributes,
mods: u32,
combo: Option<usize>,
n_fruits: usize,
n_droplets: usize,
n_tiny_droplets: usize,
n_tiny_droplet_misses: usize,
n_misses: usize,
}
impl CatchPPInner {
fn calculate(self) -> CatchPerformanceAttributes {
let attributes = &self.attributes;
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)
+ (combo_hits > 2500) as u8 as f64 * (combo_hits as f64 / 2500.0).log10() * 0.475;
pp *= len_bonus;
// Penalize misses exponentially
pp *= 0.97_f64.powi(self.n_misses as i32);
// Combo scaling
if let Some(combo) = self.combo.filter(|_| max_combo > 0) {
pp *= (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) + (ar > 10.0) as u8 as f64 * 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.acc().powf(5.5);
// NF penalty
if self.mods.nf() {
pp *= 0.9;
}
CatchPerformanceAttributes {
difficulty: self.attributes,
pp,
}
}
#[inline]
fn combo_hits(&self) -> usize {
self.n_fruits + self.n_droplets + self.n_misses
}
#[inline]
fn successful_hits(&self) -> usize {
self.n_fruits + self.n_droplets + self.n_tiny_droplets
}
#[inline]
fn total_hits(&self) -> usize {
self.successful_hits() + self.n_tiny_droplet_misses + self.n_misses
}
#[inline]
fn acc(&self) -> f64 {
let total_hits = self.total_hits();
if total_hits == 0 {
1.0
} else {
(self.successful_hits() as f64 / total_hits as f64)
.max(0.0)
.min(1.0)
}
}
}
impl<'map> From<OsuPP<'map>> for CatchPP<'map> {
#[inline]
fn from(osu: OsuPP<'map>) -> Self {
let OsuPP {
map,
mods,
acc,
combo,
n300,
n100,
n50,
n_misses,
passed_objects,
clock_rate,
..
} = osu;
let res = Self {
map,
attributes: None,
mods,
combo,
n_fruits: n300,
n_droplets: n100,
n_tiny_droplets: n50,
n_tiny_droplet_misses: None,
n_misses,
passed_objects,
clock_rate,
};
match acc {
Some(acc) => res.accuracy(acc),
None => res,
}
}
}
/// 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> {
#[allow(irrefutable_let_patterns)]
if let Self::Catch(attributes) = self {
Some(attributes)
} else {
None
}
}
}
impl CatchAttributeProvider for PerformanceAttributes {
#[inline]
fn attributes(self) -> Option<CatchDifficultyAttributes> {
#[allow(irrefutable_let_patterns)]
if let Self::Catch(attributes) = self {
Some(attributes.difficulty)
} else {
None
}
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::Beatmap;
fn attributes() -> CatchDifficultyAttributes {
CatchDifficultyAttributes {
n_fruits: 1234,
n_droplets: 567,
n_tiny_droplets: 2345,
..Default::default()
}
}
#[test]
fn fruits_only_accuracy() {
let map = Beatmap::default();
let attributes = attributes();
let total_objects = attributes.n_fruits + attributes.n_droplets;
let target_acc = 97.5;
let calculator = CatchPP::new(&map)
.attributes(attributes)
.passed_objects(total_objects)
.accuracy(target_acc);
let numerator = calculator.n_fruits.unwrap_or(0)
+ calculator.n_droplets.unwrap_or(0)
+ calculator.n_tiny_droplets.unwrap_or(0);
let denominator = numerator
+ calculator.n_tiny_droplet_misses.unwrap_or(0)
+ calculator.n_misses.unwrap_or(0);
let acc = 100.0 * numerator as f64 / denominator as f64;
assert!(
(target_acc - acc).abs() < 1.0,
"Expected: {} | Actual: {}",
target_acc,
acc
);
}
#[test]
fn fruits_accuracy_droplets_and_tiny_droplets() {
let map = Beatmap::default();
let attributes = attributes();
let total_objects = attributes.n_fruits + attributes.n_droplets;
let target_acc = 97.5;
let n_droplets = 550;
let n_tiny_droplets = 2222;
let calculator = CatchPP::new(&map)
.attributes(attributes)
.passed_objects(total_objects)
.droplets(n_droplets)
.tiny_droplets(n_tiny_droplets)
.accuracy(target_acc);
assert_eq!(
n_droplets,
calculator.n_droplets.unwrap(),
"Expected: {} | Actual: {}",
n_droplets,
calculator.n_droplets.unwrap()
);
let numerator = calculator.n_fruits.unwrap_or(0)
+ calculator.n_droplets.unwrap_or(0)
+ calculator.n_tiny_droplets.unwrap_or(0);
let denominator = numerator
+ calculator.n_tiny_droplet_misses.unwrap_or(0)
+ calculator.n_misses.unwrap_or(0);
let acc = 100.0 * numerator as f64 / denominator as f64;
assert!(
(target_acc - acc).abs() < 1.0,
"Expected: {} | Actual: {}",
target_acc,
acc
);
}
#[test]
fn fruits_missing_objects() {
let map = Beatmap::default();
let attributes = attributes();
let total_objects = attributes.n_fruits + attributes.n_droplets;
let n_fruits = attributes.n_fruits - 10;
let n_droplets = attributes.n_droplets - 5;
let n_tiny_droplets = attributes.n_tiny_droplets - 50;
let n_tiny_droplet_misses = 20;
let n_misses = 2;
let calculator = CatchPP::new(&map)
.attributes(attributes.clone())
.passed_objects(total_objects)
.fruits(n_fruits)
.droplets(n_droplets)
.tiny_droplets(n_tiny_droplets)
.tiny_droplet_misses(n_tiny_droplet_misses)
.misses(n_misses)
.assert_hitresults(attributes.clone());
assert!(
(attributes.n_fruits as i32 - calculator.n_fruits as i32).abs() <= n_misses as i32,
"Expected: {} | Actual: {} [+/- {} misses]",
attributes.n_fruits,
calculator.n_fruits,
n_misses
);
assert_eq!(
attributes.n_droplets,
calculator.n_droplets - (n_misses - (attributes.n_fruits - calculator.n_fruits)),
"Expected: {} | Actual: {}",
attributes.n_droplets,
calculator.n_droplets - (n_misses - (attributes.n_fruits - calculator.n_fruits)),
);
assert_eq!(
attributes.n_tiny_droplets,
calculator.n_tiny_droplets + calculator.n_tiny_droplet_misses,
"Expected: {} | Actual: {}",
attributes.n_tiny_droplets,
calculator.n_tiny_droplets + calculator.n_tiny_droplet_misses,
);
}
}
+539
View File
@@ -0,0 +1,539 @@
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 + 1];
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 += (next - curr).length() as f64;
calculated_len
});
cumulative_len.extend(length_iter);
if let Some(expected_len) = expected_len.filter(|&len| calculated_len != len) {
// * 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 pr = match Self::circular_arc_properties(a, b, c) {
Some(pr) => pr,
None => 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();
((pr.theta_range / divisor as f64).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 = (d_a.y as f64).atan2(d_a.x as f64);
let mut theta_end = (d_c.y as f64).atan2(d_c.x as f64);
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,
})
}
}
+326
View File
@@ -0,0 +1,326 @@
use crate::{
catch::{CatchGradualDifficultyAttributes, CatchGradualPerformanceAttributes, CatchScoreState},
mania::{ManiaGradualDifficultyAttributes, ManiaGradualPerformanceAttributes, ManiaScoreState},
osu::{OsuGradualDifficultyAttributes, OsuGradualPerformanceAttributes, OsuScoreState},
taiko::{TaikoGradualDifficultyAttributes, TaikoGradualPerformanceAttributes, TaikoScoreState},
Beatmap, DifficultyAttributes, GameMode, PerformanceAttributes,
};
/// Gradually calculate the difficulty attributes on maps of any mode.
///
/// Note that this struct implements [`Iterator`](std::iter::Iterator).
/// On every call of [`Iterator::next`](std::iter::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
/// [`GradualPerformanceAttributes`](crate::GradualPerformanceAttributes) instead.
///
/// # Example
///
/// ```no_run
/// use rosu_pp::{Beatmap, GradualDifficultyAttributes};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut iter = GradualDifficultyAttributes::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 {
/// // ...
/// }
/// ```
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum GradualDifficultyAttributes<'map> {
/// Gradual osu!standard difficulty attributes.
Osu(OsuGradualDifficultyAttributes),
/// Gradual osu!taiko difficulty attributes.
Taiko(TaikoGradualDifficultyAttributes),
/// Gradual osu!catch difficulty attributes.
Catch(CatchGradualDifficultyAttributes<'map>),
/// Gradual osu!mania difficulty attributes.
Mania(ManiaGradualDifficultyAttributes<'map>),
}
impl<'map> GradualDifficultyAttributes<'map> {
/// Create a new gradual difficulty calculator for maps of any mode.
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
match map.mode {
GameMode::Osu => Self::Osu(OsuGradualDifficultyAttributes::new(map, mods)),
GameMode::Taiko => Self::Taiko(TaikoGradualDifficultyAttributes::new(map, mods)),
GameMode::Catch => Self::Catch(CatchGradualDifficultyAttributes::new(map, mods)),
GameMode::Mania => Self::Mania(ManiaGradualDifficultyAttributes::new(map, mods)),
}
}
}
impl Iterator for GradualDifficultyAttributes<'_> {
type Item = DifficultyAttributes;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
match self {
GradualDifficultyAttributes::Osu(o) => o.next().map(DifficultyAttributes::Osu),
GradualDifficultyAttributes::Taiko(t) => t.next().map(DifficultyAttributes::Taiko),
GradualDifficultyAttributes::Catch(f) => f.next().map(DifficultyAttributes::Catch),
GradualDifficultyAttributes::Mania(m) => m.next().map(DifficultyAttributes::Mania),
}
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
match self {
GradualDifficultyAttributes::Osu(o) => o.size_hint(),
GradualDifficultyAttributes::Taiko(t) => t.size_hint(),
GradualDifficultyAttributes::Catch(f) => f.size_hint(),
GradualDifficultyAttributes::Mania(m) => m.size_hint(),
}
}
}
/// 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.
///
/// This struct is used for [`GradualPerformanceAttributes`].
#[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.
///
/// Note that for osu!catch only fruits and droplets are considered for combo.
///
/// Irrelevant for osu!mania.
pub max_combo: usize,
/// 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,
/// Amount of current 300s (fruits for osu!catch).
pub n300: usize,
/// Amount of current 100s (droplets for osu!catch).
pub n100: usize,
/// Amount of current 50s (tiny droplets for osu!catch).
pub n50: usize,
/// Amount of current misses (fruits + droplets for osu!catch).
pub n_misses: usize,
}
impl ScoreState {
/// Create a new empty score state.
pub fn new() -> Self {
Self::default()
}
}
impl From<ScoreState> for OsuScoreState {
#[inline]
fn from(state: ScoreState) -> Self {
Self {
max_combo: state.max_combo,
n300: state.n300,
n100: state.n100,
n50: state.n50,
n_misses: state.n_misses,
}
}
}
impl From<ScoreState> for TaikoScoreState {
#[inline]
fn from(state: ScoreState) -> Self {
Self {
max_combo: state.max_combo,
n300: state.n300,
n100: state.n100,
n_misses: state.n_misses,
}
}
}
impl From<ScoreState> for CatchScoreState {
#[inline]
fn from(state: ScoreState) -> Self {
Self {
max_combo: state.max_combo,
n_fruits: state.n300,
n_droplets: state.n100,
n_tiny_droplets: state.n50,
n_tiny_droplet_misses: state.n_katu,
n_misses: state.n_misses,
}
}
}
impl From<ScoreState> for ManiaScoreState {
#[inline]
fn from(state: ScoreState) -> Self {
Self {
n320: state.n_geki,
n300: state.n300,
n200: state.n_katu,
n100: state.n100,
n50: state.n50,
n_misses: state.n_misses,
}
}
}
/// Gradually calculate the performance attributes on maps of any mode.
///
/// After each hit object you can call
/// [`process_next_object`](`GradualPerformanceAttributes::process_next_object`)
/// and it will return the resulting current [`PerformanceAttributes`].
/// To process multiple objects at once, use
/// [`process_next_n_objects`](`GradualPerformanceAttributes::process_next_n_objects`) 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.
/// [`OsuGradualPerformanceAttributes`],
/// [`TaikoGradualPerformanceAttributes`],
/// [`CatchGradualPerformanceAttributes`], or
/// [`ManiaGradualPerformanceAttributes`].
///
/// If you only want to calculate difficulty attributes use
/// [`GradualDifficultyAttributes`](crate::GradualDifficultyAttributes) instead.
///
/// # Example
///
/// ```no_run
/// use rosu_pp::{Beatmap, GradualPerformanceAttributes, ScoreState};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut gradual_perf = GradualPerformanceAttributes::new(&map, mods);
/// let mut state = ScoreState::new(); // empty state, everything is on 0.
///
/// // The first 10 hitresults are 300s and increase the score by 123 each.
/// for _ in 0..10 {
/// state.n300 += 1;
/// state.max_combo += 1;
///
/// # /*
/// let performance = gradual_perf.process_next_object(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_object(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.process_next_object(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_object(state.clone());
///
/// // 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;
/// // Don't forget state.n_katu
/// # /*
/// let performance = gradual_perf.process_next_n_objects(state.clone(), 10).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_n_objects(state.clone(), 10);
///
/// // Now comes another 300. Note that the max combo gets incremented again.
/// state.n300 += 1;
/// state.max_combo += 1;
/// # /*
/// let performance = gradual_perf.process_next_object(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_object(state.clone());
///
/// // Skip to the end
/// # /*
/// state.max_combo = ...
/// state.n300 = ...
/// ...
/// let final_performance = gradual_perf.process_next_n_objects(state.clone(), usize::MAX).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_n_objects(state.clone(), usize::MAX);
///
/// // Once the final performance was calculated,
/// // attempting to process further objects will return `None`.
/// assert!(gradual_perf.process_next_object(state).is_none());
/// ```
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum GradualPerformanceAttributes<'map> {
/// Gradual osu!standard performance attributes.
Osu(OsuGradualPerformanceAttributes<'map>),
/// Gradual osu!taiko performance attributes.
Taiko(TaikoGradualPerformanceAttributes<'map>),
/// Gradual osu!catch performance attributes.
Catch(CatchGradualPerformanceAttributes<'map>),
/// Gradual osu!mania performance attributes.
Mania(ManiaGradualPerformanceAttributes<'map>),
}
impl<'map> GradualPerformanceAttributes<'map> {
/// Create a new gradual performance calculator for maps of any mode.
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
match map.mode {
GameMode::Osu => Self::Osu(OsuGradualPerformanceAttributes::new(map, mods)),
GameMode::Taiko => Self::Taiko(TaikoGradualPerformanceAttributes::new(map, mods)),
GameMode::Catch => Self::Catch(CatchGradualPerformanceAttributes::new(map, mods)),
GameMode::Mania => Self::Mania(ManiaGradualPerformanceAttributes::new(map, mods)),
}
}
/// Process the next hit object and calculate the
/// performance attributes for the resulting score.
pub fn process_next_object(&mut self, state: ScoreState) -> Option<PerformanceAttributes> {
self.process_next_n_objects(state, 1)
}
/// Same as [`process_next_object`](`GradualPerformanceAttributes::process_next_object`)
/// but instead of processing only one object it process `n` many.
///
/// If `n` is 0 it will be considered as 1.
/// If there are still objects to be processed but `n` is larger than the amount
/// of remaining objects, `n` will be considered as the amount of remaining objects.
pub fn process_next_n_objects(
&mut self,
state: ScoreState,
n: usize,
) -> Option<PerformanceAttributes> {
match self {
GradualPerformanceAttributes::Osu(o) => o
.process_next_n_objects(state.into(), n)
.map(PerformanceAttributes::Osu),
GradualPerformanceAttributes::Taiko(t) => t
.process_next_n_objects(state.into(), n)
.map(PerformanceAttributes::Taiko),
GradualPerformanceAttributes::Catch(f) => f
.process_next_n_objects(state.into(), n)
.map(PerformanceAttributes::Catch),
GradualPerformanceAttributes::Mania(m) => m
.process_next_n_objects(state.into(), n)
.map(PerformanceAttributes::Mania),
}
}
}
+514
View File
@@ -0,0 +1,514 @@
//! 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());
//! ```
//!
//! ## 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 use [`GradualDifficultyAttributes`] and [`GradualPerformanceAttributes`]:
//!
//! ```no_run
//! use rosu_pp::{
//! Beatmap, BeatmapExt, GradualPerformanceAttributes, 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 value,
//! // use `GradualDifficultyAttributes`, either through its function `new`
//! // or through the method `BeatmapExt::gradual_difficulty`.
//! let gradual_difficulty = map.gradual_difficulty(mods);
//!
//! // Since `GradualDifficultyAttributes` 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 = map.gradual_performance(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.process_next_object(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 `BeatmapExt::gradual_performance` one could have
//! // created the struct via `TaikoGradualPerformanceAttributes::new`.
//! let mut gradual_performance = match gradual_performance {
//! GradualPerformanceAttributes::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
//! let curr_performance = match gradual_performance.process_next_n_objects(state, 10) {
//! Some(perf) => perf,
//! None => panic!("the last `process_next_object` 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) |
//!
#![cfg_attr(docsrs, feature(doc_cfg), deny(broken_intra_doc_links))]
#![deny(
clippy::all,
nonstandard_style,
rust_2018_idioms,
missing_debug_implementations
)]
/// Everything about osu!catch.
pub mod catch;
/// Everything about osu!mania.
pub mod mania;
/// Everything about osu!standard.
pub mod osu;
/// osu! 2019 (for relax)
pub mod osu_2019;
/// Everything about osu!taiko.
pub mod taiko;
/// Beatmap parsing
pub mod parse;
/// Beatmap and contained types
pub mod beatmap;
pub use beatmap::{Beatmap, GameMode};
mod gradual;
pub use gradual::{GradualDifficultyAttributes, GradualPerformanceAttributes, ScoreState};
mod pp;
pub use pp::{AnyPP, AttributeProvider, HitResultPriority};
mod stars;
pub use stars::AnyStars;
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};
pub use util::SortedVec;
/// 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;
/// Return an iterator that gives you the [`DifficultyAttributes`] after each hit object.
///
/// Suitable to efficiently get the map's star rating after multiple different locations.
fn gradual_difficulty(&self, mods: u32) -> GradualDifficultyAttributes<'_>;
/// Return a struct that gives you the [`PerformanceAttributes`] after every (few) hit object(s).
///
/// Suitable to efficiently get a score's performance after multiple different locations,
/// i.e. live update a score's pp.
fn gradual_performance(&self, mods: u32) -> GradualPerformanceAttributes<'_>;
}
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()),
}
}
#[inline]
fn gradual_difficulty(&self, mods: u32) -> GradualDifficultyAttributes<'_> {
GradualDifficultyAttributes::new(self, mods)
}
#[inline]
fn gradual_performance(&self, mods: u32) -> GradualPerformanceAttributes<'_> {
GradualPerformanceAttributes::new(self, mods)
}
}
/// 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 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 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 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,
}
}
}
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 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 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),
}
}
#[inline]
/// The maximum combo of the map.
pub 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");
+22
View File
@@ -0,0 +1,22 @@
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,
}
}
}
+184
View File
@@ -0,0 +1,184 @@
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`](std::iter::Iterator).
/// On every call of [`Iterator::next`](std::iter::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
/// [`ManiaGradualPerformanceAttributes`](crate::mania::ManiaGradualPerformanceAttributes) instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, mania::ManiaGradualDifficultyAttributes};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut iter = ManiaGradualDifficultyAttributes::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 {
/// // ...
/// }
/// ```
#[derive(Clone, Debug)]
pub struct ManiaGradualDifficultyAttributes<'map> {
pub(crate) idx: usize,
map: Cow<'map, Beatmap>,
hit_window: f64,
strain: Strain,
diff_objects: Vec<ManiaDifficultyObject>,
curr_combo: usize,
clock_rate: f64,
}
impl<'map> ManiaGradualDifficultyAttributes<'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 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(matches!(map, Cow::Owned(_)))
.clock_rate(clock_rate)
.hit_windows();
let mut params = ObjectParameters::new(map.as_ref());
let mut hit_objects = map.hit_objects.iter();
let first = match hit_objects.next() {
Some(h) => ManiaObject::new(h, total_columns, &mut params),
None => {
return Self {
idx: 0,
map,
hit_window,
strain,
diff_objects: Vec::new(),
curr_combo: 0,
clock_rate,
}
}
};
let curr_combo = params.max_combo;
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().saturating_sub(1));
diff_objects.extend(diff_objects_iter);
Self {
idx: 0,
map,
hit_window,
strain,
diff_objects,
curr_combo,
clock_rate,
}
}
fn increment_combo(
h: &HitObject,
diff_obj: &ManiaDifficultyObject,
curr_combo: &mut usize,
clock_rate: f64,
) {
match &h.kind {
HitObjectKind::Circle => *curr_combo += 1,
_ => {
let start_time = diff_obj.start_time * clock_rate;
let end_time = diff_obj.end_time * clock_rate;
let duration = end_time - start_time;
*curr_combo += 1 + (duration / 100.0) as usize;
}
}
}
}
impl Iterator for ManiaGradualDifficultyAttributes<'_> {
type Item = ManiaDifficultyAttributes;
fn next(&mut self) -> Option<Self::Item> {
let curr = self.diff_objects.get(self.idx)?;
self.idx += 1;
if let Some(h) = self.map.hit_objects.get(self.idx) {
Self::increment_combo(h, curr, &mut self.curr_combo, self.clock_rate);
}
self.strain.process(curr, &self.diff_objects);
Some(ManiaDifficultyAttributes {
stars: self.strain.clone().difficulty_value() * STAR_SCALING_FACTOR,
hit_window: self.hit_window,
max_combo: self.curr_combo,
})
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
let len = self.len();
(len, Some(len))
}
fn nth(&mut self, n: usize) -> Option<Self::Item> {
let skip = n.min(self.len()).saturating_sub(1);
for _ in 0..skip {
let curr = self.diff_objects.get(self.idx)?;
self.idx += 1;
if let Some(h) = self.map.hit_objects.get(self.idx) {
Self::increment_combo(h, curr, &mut self.curr_combo, self.clock_rate);
}
self.strain.process(curr, &self.diff_objects);
}
self.next()
}
}
impl ExactSizeIterator for ManiaGradualDifficultyAttributes<'_> {
#[inline]
fn len(&self) -> usize {
self.diff_objects.len() - self.idx
}
}
+177
View File
@@ -0,0 +1,177 @@
use crate::{Beatmap, ManiaPP};
use super::{ManiaGradualDifficultyAttributes, ManiaPerformanceAttributes};
/// Aggregation for a score's current state
/// i.e. what are the current hitresults.
///
/// This struct is used for [`ManiaGradualPerformanceAttributes`].
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ManiaScoreState {
/// Amount of current 320s.
pub n320: usize,
/// Amount of current 300s.
pub n300: usize,
/// Amount of current 200s.
pub n200: usize,
/// Amount of current 100s.
pub n100: usize,
/// Amount of current 50s.
pub n50: usize,
/// Amount of current misses.
pub n_misses: usize,
}
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 fn total_hits(&self) -> usize {
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();
if total_hits == 0 {
return 0.0;
}
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
}
}
/// Gradually calculate the performance attributes of an osu!mania map.
///
/// After each hit object you can call
/// [`process_next_object`](`ManiaGradualPerformanceAttributes::process_next_object`)
/// and it will return the resulting current [`ManiaPerformanceAttributes`].
/// To process multiple objects at once, use
/// [`process_next_n_objects`](`ManiaGradualPerformanceAttributes::process_next_n_objects`) 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
/// [`ManiaGradualDifficultyAttributes`](crate::mania::ManiaGradualDifficultyAttributes) instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, mania::{ManiaGradualPerformanceAttributes, ManiaScoreState}};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut gradual_perf = ManiaGradualPerformanceAttributes::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.process_next_object(score).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_object(state.clone());
/// }
///
/// // Then comes a miss.
/// state.n_misses += 1;
/// # /*
/// let performance = gradual_perf.process_next_object(score).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_object(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;
/// # /*
/// let performance = gradual_perf.process_next_n_objects(score, 10).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_n_objects(state.clone(), 10);
///
/// // Skip to the end
/// # /*
/// state.max_combo = ...
/// state.n300 = ...
/// state.n100 = ...
/// state.n_misses = ...
/// let final_performance = gradual_perf.process_next_n_objects(state.clone(), usize::MAX).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_n_objects(state.clone(), usize::MAX);
///
/// // Once the final performance was calculated,
/// // attempting to process further objects will return `None`.
/// assert!(gradual_perf.process_next_object(state).is_none());
/// ```
#[derive(Clone, Debug)]
pub struct ManiaGradualPerformanceAttributes<'map> {
difficulty: ManiaGradualDifficultyAttributes<'map>,
performance: ManiaPP<'map>,
}
impl<'map> ManiaGradualPerformanceAttributes<'map> {
/// Create a new gradual performance calculator for osu!mania maps.
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
let difficulty = ManiaGradualDifficultyAttributes::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 process_next_object(
&mut self,
state: ManiaScoreState,
) -> Option<ManiaPerformanceAttributes> {
self.process_next_n_objects(state, 1)
}
/// Same as [`process_next_object`](`ManiaGradualPerformanceAttributes::process_next_object`)
/// but instead of processing only one object it process `n` many.
///
/// If `n` is 0 it will be considered as 1.
/// If there are still objects to be processed but `n` is larger than the amount
/// of remaining objects, `n` will be considered as the amount of remaining objects.
pub fn process_next_n_objects(
&mut self,
state: ManiaScoreState,
n: usize,
) -> Option<ManiaPerformanceAttributes> {
let sub = (self.difficulty.idx == 0) as usize;
let difficulty = self.difficulty.nth(n.saturating_sub(sub))?;
let performance = self
.performance
.clone()
.attributes(difficulty)
.state(state)
.passed_objects(self.difficulty.idx)
.calculate();
Some(performance)
}
}
+98
View File
@@ -0,0 +1,98 @@
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(),
}
}
}
pub(crate) struct ManiaObject {
pub(crate) start_time: f64,
pub(crate) end_time: f64,
pub(crate) 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,
}
}
}
}
}
+293
View File
@@ -0,0 +1,293 @@
mod difficulty_object;
mod gradual_difficulty;
mod gradual_performance;
mod mania_object;
mod pp;
mod skills;
use std::borrow::Cow;
use crate::{beatmap::BeatmapHitWindows, util::FloatExt, Beatmap, GameMode, Mods, OsuStars};
pub use self::{gradual_difficulty::*, gradual_performance::*, pp::*};
pub(crate) use self::mania_object::ManiaObject;
use self::{
difficulty_object::ManiaDifficultyObject,
mania_object::ObjectParameters,
skills::{Skill, Strain},
};
const SECTION_LEN: f64 = 400.0;
const STAR_SCALING_FACTOR: f64 = 0.018;
/// Difficulty calculator on osu!mania maps.
///
/// # 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)]
pub struct ManiaStars<'map> {
map: Cow<'map, Beatmap>,
mods: u32,
passed_objects: Option<usize>,
clock_rate: Option<f64>,
is_convert: bool,
}
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(_));
Self {
map,
mods: 0,
passed_objects: None,
clock_rate: None,
is_convert,
}
}
/// 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(mut self, mods: u32) -> Self {
self.mods = mods;
self
}
/// Amount of passed objects for partial plays, e.g. a fail.
///
/// 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::ManiaGradualDifficultyAttributes).
#[inline]
pub 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 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.
#[inline]
pub 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 ManiaResult { strain, max_combo } = calculate_result(self);
ManiaDifficultyAttributes {
stars: strain.difficulty_value() * STAR_SCALING_FACTOR,
hit_window,
max_combo,
}
}
/// 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,
}
}
}
/// 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(Copy, 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 maximum achievable combo.
pub max_combo: usize,
}
impl ManiaDifficultyAttributes {
/// Return the maximum combo.
#[inline]
pub fn max_combo(&self) -> usize {
self.max_combo
}
}
/// The result of a performance calculation on an osu!mania map.
#[derive(Copy, 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 fn stars(&self) -> f64 {
self.difficulty.stars
}
/// Return the performance point value.
#[inline]
pub fn pp(&self) -> f64 {
self.pp
}
/// Return the maximum combo of the map.
#[inline]
pub fn max_combo(&self) -> usize {
self.difficulty.max_combo
}
}
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,
}
}
}
+1063
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
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))
}
+210
View File
@@ -0,0 +1,210 @@
use crate::mania::difficulty_object::ManiaDifficultyObject;
use super::{previous, Skill, StrainDecaySkill, StrainSkill};
#[derive(Clone, Debug)]
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: Vec<f64>,
}
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: Vec::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 Vec<f64> {
&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
}
}
+113
View File
@@ -0,0 +1,113 @@
use std::{cmp::Ordering, mem};
use crate::mania::{difficulty_object::ManiaDifficultyObject, SECTION_LEN};
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 Vec<f64>;
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;
}
*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) -> Vec<f64> {
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);
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)
}
}
+90
View File
@@ -0,0 +1,90 @@
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;
const AP: u32 = 1 << 13;
/// 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;
fn ap(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);
impl_mods!(ap, AP);
}
+266
View File
@@ -0,0 +1,266 @@
use crate::{
osu::osu_object::{NestedObjectKind, OsuObjectKind},
parse::Pos2,
};
use super::{osu_object::OsuSlider, OsuObject, ScalingFactor};
#[derive(Clone, Debug)]
pub(crate) struct OsuDifficultyObject<'h> {
pub(crate) start_time: f64,
pub(crate) delta_time: f64,
pub(crate) base: &'h OsuObject,
pub(crate) strain_time: f64,
pub(crate) dists: Distances,
pub(crate) idx: usize,
}
impl<'h> OsuDifficultyObject<'h> {
pub(crate) const MIN_DELTA_TIME: u32 = 25;
pub(crate) fn new(
base: &'h OsuObject,
last: &'h OsuObject,
clock_rate: f64,
idx: usize,
dists: Distances,
) -> Self {
let start_time = base.start_time / clock_rate;
let delta_time = (base.start_time - last.start_time) / clock_rate;
// * Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
let strain_time = delta_time.max(Self::MIN_DELTA_TIME as f64);
Self {
start_time,
delta_time,
base,
strain_time,
dists,
idx,
}
}
pub(crate) fn opacity_at(
&self,
time: f64,
hidden: bool,
time_preempt: f64,
time_fade_in: f64,
) -> f64 {
if time > self.base.start_time {
// * Consider a hitobject as being invisible when its start time is passed.
// * In reality the hitobject will be visible beyond its start time up until its hittable window has passed,
// * but this is an approximation and such a case is unlikely to be hit where this function is used.
return 0.0;
}
let fade_in_start_time = self.base.start_time - time_preempt;
let fade_in_duration = time_fade_in;
if hidden {
// * Taken from OsuModHidden.
let fade_out_start_time = self.base.start_time - time_preempt + time_fade_in;
const FADE_OUT_DURATION_MULTIPLIER: f64 = 0.3;
let fade_out_duration = time_preempt * FADE_OUT_DURATION_MULTIPLIER;
(((time - fade_in_start_time) / fade_in_duration).clamp(0.0, 1.0))
.min(1.0 - ((time - fade_out_start_time) / fade_out_duration).clamp(0.0, 1.0))
} else {
((time - fade_in_start_time) / fade_in_duration).clamp(0.0, 1.0)
}
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct Distances {
pub(crate) lazy_jump_dist: f64,
pub(crate) lazy_travel_dist: f32,
pub(crate) min_jump_dist: f64,
pub(crate) min_jump_time: f64,
pub(crate) travel_dist: f64,
pub(crate) travel_time: f64,
pub(crate) angle: Option<f64>,
}
impl Distances {
pub(crate) const NORMALISED_RADIUS: f32 = 50.0;
const MAXIMUM_SLIDER_RADIUS: f32 = Self::NORMALISED_RADIUS * 2.4;
const ASSUMED_SLIDER_RADIUS: f32 = Self::NORMALISED_RADIUS * 1.8;
pub(crate) fn new(
base: &mut OsuObject,
last: &OsuObject,
last_last: Option<&OsuObject>,
clock_rate: f64,
strain_time: f64,
scaling_factor_: &ScalingFactor,
) -> Self {
let mut this =
if let Some(slider_values) = Self::compute_slider_cursor_pos(base, scaling_factor_) {
let SliderValues {
lazy_travel_dist,
slider,
} = slider_values;
let repeat_count = slider.repeat_count();
Self {
// * Bonus for repeat sliders until a better per nested object strain system can be achieved.
travel_dist: (lazy_travel_dist
* (1.0 + repeat_count as f64 / 2.5).powf(1.0 / 2.5) as f32)
as f64,
travel_time: (base.lazy_travel_time() / clock_rate)
.max(OsuDifficultyObject::MIN_DELTA_TIME as f64),
lazy_travel_dist,
..Default::default()
}
} else {
Self::default()
};
// * We don't need to calculate either angle or distance when
// * one of the last->curr objects is a spinner
if base.is_spinner() || last.is_spinner() {
return this;
}
// * We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
let scaling_factor = scaling_factor_.factor;
let last_cursor_pos = Self::get_end_cursor_pos(last);
this.lazy_jump_dist = (base.stacked_pos() * scaling_factor
- last_cursor_pos * scaling_factor)
.length() as f64;
this.min_jump_time = strain_time;
this.min_jump_dist = this.lazy_jump_dist;
if let OsuObjectKind::Slider(slider) = &last.kind {
let last_travel_time = (last.lazy_travel_time() / clock_rate)
.max(OsuDifficultyObject::MIN_DELTA_TIME as f64);
this.min_jump_time =
(strain_time - last_travel_time).max(OsuDifficultyObject::MIN_DELTA_TIME as f64);
// * There are two types of slider-to-object patterns to consider in order
// * to better approximate the real movement a player will take to jump between the hitobjects.
// *
// * 1. The anti-flow pattern, where players cut the slider short in order to move to the next hitobject.
// *
// * <======o==> ← slider
// * | ← most natural jump path
// * o ← a follow-up hitcircle
// *
// * In this case the most natural jump path is approximated by LazyJumpDistance.
// *
// * 2. The flow pattern, where players follow through the slider to its
// * visual extent into the next hitobject.
// *
// * <======o==>---o
// * ↑
// * most natural jump path
// *
// * In this case the most natural jump path is better approximated by a new distance
// * called "tailJumpDistance" - the distance between the slider's tail and the next hitobject.
// *
// * Thus, the player is assumed to jump the minimum of these two distances in all cases.
let stacked_tail_pos =
slider.tail().map_or_else(|| last.pos(), |tail| tail.pos) + last.stack_offset;
let tail_jump_dist = (stacked_tail_pos - base.stacked_pos()).length() * scaling_factor;
this.min_jump_dist = (this.lazy_jump_dist
- (Self::MAXIMUM_SLIDER_RADIUS - Self::ASSUMED_SLIDER_RADIUS) as f64)
.min((tail_jump_dist - Self::MAXIMUM_SLIDER_RADIUS) as f64)
.max(0.0);
}
if let Some(last_last) = last_last.filter(|obj| !obj.is_spinner()) {
let last_last_cursor_pos = Self::get_end_cursor_pos(last_last);
let v1 = last_last_cursor_pos - last.stacked_pos();
let v2 = base.stacked_pos() - last_cursor_pos;
let dot = v1.dot(v2) as f64;
let det = (v1.x * v2.y - v1.y * v2.x) as f64;
this.angle = Some(det.atan2(dot).abs());
}
this
}
pub(crate) fn compute_slider_cursor_pos<'h>(
hit_object: &'h mut OsuObject,
scaling_factor_: &ScalingFactor,
) -> Option<SliderValues<'h>> {
let pos = hit_object.pos();
let slider = if let OsuObjectKind::Slider(slider) = &mut hit_object.kind {
slider
} else {
return None;
};
let mut curr_cursor_pos = pos + hit_object.stack_offset;
let scaling_factor = Self::NORMALISED_RADIUS as f64 / scaling_factor_.radius as f64;
let mut lazy_travel_dist: f32 = 0.0;
for (curr_movement_obj, i) in slider.nested_iter().zip(1..) {
let mut curr_movement =
(curr_movement_obj.pos + hit_object.stack_offset) - curr_cursor_pos;
let mut curr_movement_len = scaling_factor * curr_movement.length() as f64;
// * Amount of movement required so that the cursor position needs to be updated.
let mut required_movement = Self::ASSUMED_SLIDER_RADIUS as f64;
if i == slider.nested_len() {
// * The end of a slider has special aim rules due
// * to the relaxed time constraint on position.
// * There is both a lazy end position as well as the actual end slider position.
// * We assume the player takes the simpler movement.
// * For sliders that are circular, the lazy end position
// * may actually be farther away than the sliders true end.
// * This code is designed to prevent buffing situations
// * where lazy end is actually a less efficient movement.
let lazy_movement = slider.lazy_end_pos - curr_cursor_pos;
if lazy_movement.length() < curr_movement.length() {
curr_movement = lazy_movement;
}
curr_movement_len = scaling_factor * curr_movement.length() as f64;
} else if let NestedObjectKind::Repeat = curr_movement_obj.kind {
// * For a slider repeat, assume a tighter movement threshold to better assess repeat sliders.
required_movement = Self::NORMALISED_RADIUS as f64;
}
if curr_movement_len > required_movement {
// * this finds the positional delta from the required radius and the current position, and updates the currCursorPosition accordingly, as well as rewarding distance.
curr_cursor_pos += curr_movement
* ((curr_movement_len - required_movement) / curr_movement_len) as f32;
curr_movement_len *= (curr_movement_len - required_movement) / curr_movement_len;
lazy_travel_dist += curr_movement_len as f32;
}
}
slider.lazy_end_pos = curr_cursor_pos;
Some(SliderValues {
lazy_travel_dist,
slider,
})
}
fn get_end_cursor_pos(hit_object: &OsuObject) -> Pos2 {
hit_object.lazy_end_pos()
}
}
pub(crate) struct SliderValues<'s> {
lazy_travel_dist: f32,
slider: &'s OsuSlider,
}
+328
View File
@@ -0,0 +1,328 @@
use std::{
fmt::{Debug, Formatter, Result as FmtResult},
mem,
};
use crate::{curve::CurveBuffers, Beatmap, Mods};
use super::{
difficulty_object::{Distances, OsuDifficultyObject},
old_stacking,
osu_object::{ObjectParameters, OsuObject, OsuObjectKind},
scaling_factor::ScalingFactor,
skills::{OsuStrainSkill, Skills},
stacking, OsuDifficultyAttributes, DIFFICULTY_MULTIPLIER, FADE_IN_DURATION_MULTIPLIER,
PERFORMANCE_BASE_MULTIPLIER, PREEMPT_MIN,
};
/// Gradually calculate the difficulty attributes of an osu!standard map.
///
/// Note that this struct implements [`Iterator`](std::iter::Iterator).
/// On every call of [`Iterator::next`](std::iter::Iterator::next), the map's next hit object will
/// be processed and the [`OsuDifficultyAttributes`] will be updated and returned.
///
/// If you want to calculate performance attributes, use
/// [`OsuGradualPerformanceAttributes`](crate::osu::OsuGradualPerformanceAttributes) instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, osu::OsuGradualDifficultyAttributes};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut iter = OsuGradualDifficultyAttributes::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 {
/// // ...
/// }
/// ```
#[derive(Clone)]
pub struct OsuGradualDifficultyAttributes {
pub(crate) idx: usize,
mods: u32,
attrs: OsuDifficultyAttributes,
// Unused but `diff_objects`' lifetimes secretly depend on it
#[allow(unused)]
hit_objects: Vec<OsuObject>,
diff_objects: Vec<OsuDifficultyObject<'static>>,
skills: Skills,
}
impl Debug for OsuGradualDifficultyAttributes {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
f.debug_struct("OsuGradualDifficultyAttributes")
.field("idx", &self.idx)
.field("attrs", &self.attrs)
.field("diff_objects", &self.diff_objects)
.field("skills", &self.skills)
.finish()
}
}
impl OsuGradualDifficultyAttributes {
/// Create a new difficulty attributes iterator for osu!standard maps.
pub fn new(map: &Beatmap, mods: u32) -> Self {
let clock_rate = mods.clock_rate();
let map_attrs = map.attributes().mods(mods).build();
let scaling_factor = ScalingFactor::new(map_attrs.cs);
let hr = mods.hr();
let hit_window = 2.0 * map_attrs.hit_windows.od;
let time_preempt = (map_attrs.hit_windows.ar * clock_rate) as f32 as f64;
// * Preempt time can go below 450ms. Normally, this is achieved via the DT mod
// * which uniformly speeds up all animations game wide regardless of AR.
// * This uniform speedup is hard to match 1:1, however we can at least make
// * AR>10 (via mods) feel good by extending the upper linear function above.
// * Note that this doesn't exactly match the AR>10 visuals as they're
// * classically known, but it feels good.
// * This adjustment is necessary for AR>10, otherwise TimePreempt can
// * become smaller leading to hitcircles not fully fading in.
let time_fade_in = if mods.hd() {
time_preempt * FADE_IN_DURATION_MULTIPLIER
} else {
400.0 * (time_preempt / PREEMPT_MIN).min(1.0)
};
let mut attrs = OsuDifficultyAttributes {
ar: map_attrs.ar,
hp: map_attrs.hp,
od: map_attrs.od,
..Default::default()
};
let mut params = ObjectParameters {
map,
attrs: &mut attrs,
ticks: Vec::new(),
curve_bufs: CurveBuffers::default(),
};
let mut hit_objects: Vec<_> = map
.hit_objects
.iter()
.map(|h| OsuObject::new(h, &mut params))
.collect();
attrs.n_circles = 0;
attrs.n_sliders = 0;
attrs.n_spinners = 0;
attrs.max_combo = 0;
let stack_threshold = time_preempt * map.stack_leniency as f64;
if map.version >= 6 {
stacking(&mut hit_objects, stack_threshold);
} else {
old_stacking(&mut hit_objects, stack_threshold);
}
let mut hit_objects_iter = hit_objects.iter_mut().map(|h| {
h.post_process(hr, &scaling_factor);
h
});
let skills = Skills::new(
mods,
scaling_factor.radius,
time_preempt,
time_fade_in,
hit_window,
);
let last = match hit_objects_iter.next() {
Some(prev) => prev,
None => {
return Self {
idx: 0,
mods,
attrs,
hit_objects: Vec::new(),
diff_objects: Vec::new(),
skills,
}
}
};
Self::increment_combo(last, &mut attrs);
let mut last_last = None;
// Prepare `lazy_travel_dist` and `lazy_end_pos` for `last` manually
Distances::compute_slider_cursor_pos(last, &scaling_factor);
let mut last = &*last;
let mut diff_objects = Vec::with_capacity(map.hit_objects.len().saturating_sub(2));
for (i, curr) in hit_objects_iter.enumerate() {
let delta_time = (curr.start_time - last.start_time) / clock_rate;
// * Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
let strain_time = delta_time.max(OsuDifficultyObject::MIN_DELTA_TIME as f64);
let dists = Distances::new(
curr,
last,
last_last,
clock_rate,
strain_time,
&scaling_factor,
);
let diff_obj = OsuDifficultyObject::new(curr, last, clock_rate, i, dists);
diff_objects.push(diff_obj);
last_last = Some(last);
last = &*curr;
}
Self {
idx: 0,
mods,
attrs,
diff_objects: extend_lifetime(diff_objects),
hit_objects,
skills,
}
}
fn increment_combo(h: &OsuObject, attrs: &mut OsuDifficultyAttributes) {
attrs.max_combo += 1;
match &h.kind {
OsuObjectKind::Circle => attrs.n_circles += 1,
OsuObjectKind::Slider(slider) => {
attrs.n_sliders += 1;
attrs.max_combo += slider.nested_len();
}
OsuObjectKind::Spinner { .. } => attrs.n_spinners += 1,
}
}
}
fn extend_lifetime(
diff_objects: Vec<OsuDifficultyObject<'_>>,
) -> Vec<OsuDifficultyObject<'static>> {
// SAFETY: Owned values of the references will be contained
// in the same struct and hence live just as long as this vec.
unsafe { mem::transmute(diff_objects) }
}
impl Iterator for OsuGradualDifficultyAttributes {
type Item = OsuDifficultyAttributes;
fn next(&mut self) -> Option<Self::Item> {
let curr = self.diff_objects.get(self.idx)?;
self.idx += 1;
self.skills.process(curr, &self.diff_objects);
Self::increment_combo(curr.base, &mut self.attrs);
let Skills {
mut aim,
mut aim_no_sliders,
mut speed,
mut flashlight,
} = self.skills.clone();
let mut aim_rating = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let aim_rating_no_sliders =
aim_no_sliders.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let speed_notes = speed.relevant_note_count();
let mut speed_rating = speed.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let mut flashlight_rating = flashlight.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let slider_factor = if aim_rating > 0.0 {
aim_rating_no_sliders / aim_rating
} else {
1.0
};
if self.mods.td() {
aim_rating = aim_rating.powf(0.8);
flashlight_rating = flashlight_rating.powf(0.8);
}
if self.mods.rx() {
aim_rating *= 0.9;
speed_rating = 0.0;
flashlight_rating *= 0.7;
}
let base_aim_performance = (5.0 * (aim_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
let base_speed_performance =
(5.0 * (speed_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
let base_flashlight_performance = if self.mods.fl() {
flashlight_rating * flashlight_rating * 25.0
} else {
0.0
};
let base_performance = ((base_aim_performance).powf(1.1)
+ (base_speed_performance).powf(1.1)
+ (base_flashlight_performance).powf(1.1))
.powf(1.0 / 1.1);
let star_rating = if base_performance > 0.00001 {
PERFORMANCE_BASE_MULTIPLIER.cbrt()
* 0.027
* ((100_000.0 / 2.0_f64.powf(1.0 / 1.1) * base_performance).cbrt() + 4.0)
} else {
0.0
};
let mut attrs = self.attrs.clone();
attrs.aim = aim_rating;
attrs.speed = speed_rating;
attrs.flashlight = flashlight_rating;
attrs.slider_factor = slider_factor;
attrs.stars = star_rating;
attrs.speed_note_count = speed_notes;
attrs.aim_difficult_strain_count = aim.count_difficult_strains();
attrs.speed_difficult_strain_count = speed.count_difficult_strains();
Some(attrs)
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
let len = self.len();
(len, Some(len))
}
fn nth(&mut self, n: usize) -> Option<Self::Item> {
let skip = n.min(self.len()).saturating_sub(1);
for _ in 0..skip {
let curr = self.diff_objects.get(self.idx)?;
self.idx += 1;
self.skills.process(curr, &self.diff_objects);
Self::increment_combo(curr.base, &mut self.attrs);
}
self.next()
}
}
impl ExactSizeIterator for OsuGradualDifficultyAttributes {
#[inline]
fn len(&self) -> usize {
self.diff_objects.len() - self.idx
}
}
+190
View File
@@ -0,0 +1,190 @@
use crate::{Beatmap, OsuPP};
use super::{OsuGradualDifficultyAttributes, OsuPerformanceAttributes};
/// Aggregation for a score's current state i.e. what was the
/// maximum combo so far and what are the current hitresults.
///
/// This struct is used for [`OsuGradualPerformanceAttributes`].
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct OsuScoreState {
/// Maximum combo that the score has had so far.
/// **Not** the maximum possible combo of the map so far.
pub max_combo: usize,
/// Amount of current 300s.
pub n300: usize,
/// Amount of current 100s.
pub n100: usize,
/// Amount of current 50s.
pub n50: usize,
/// Amount of current misses.
pub n_misses: usize,
}
impl OsuScoreState {
/// 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 fn total_hits(&self) -> usize {
self.n300 + 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();
if total_hits == 0 {
return 0.0;
}
let numerator = 6 * self.n300 + 2 * self.n100 + self.n50;
let denominator = 6 * total_hits;
numerator as f64 / denominator as f64
}
}
/// Gradually calculate the performance attributes of an osu!standard map.
///
/// After each hit object you can call
/// [`process_next_object`](`OsuGradualPerformanceAttributes::process_next_object`)
/// and it will return the resulting current [`OsuPerformanceAttributes`].
/// To process multiple objects at once, use
/// [`process_next_n_objects`](`OsuGradualPerformanceAttributes::process_next_n_objects`) instead.
///
/// Both methods require an [`OsuScoreState`] that contains the current
/// hitresults as well as the maximum combo so far.
///
/// If you only want to calculate difficulty attributes use
/// [`OsuGradualDifficultyAttributes`](crate::osu::OsuGradualDifficultyAttributes) instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, osu::{OsuGradualPerformanceAttributes, OsuScoreState}};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut gradual_perf = OsuGradualPerformanceAttributes::new(&map, mods);
/// let mut state = OsuScoreState::new(); // empty state, everything is on 0.
///
/// // The first 10 hitresults are 300s and there are no sliders for additional combo
/// for _ in 0..10 {
/// state.n300 += 1;
/// state.max_combo += 1;
///
/// # /*
/// let performance = gradual_perf.process_next_object(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_object(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.process_next_object(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_object(state.clone());
///
/// // 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;
/// # /*
/// let performance = gradual_perf.process_next_n_objects(state.clone(), 10).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_n_objects(state.clone(), 10);
///
/// // Now comes another 300. Note that the max combo gets incremented again.
/// state.n300 += 1;
/// state.max_combo += 1;
/// # /*
/// let performance = gradual_perf.process_next_object(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_object(state.clone());
///
/// // Skip to the end
/// # /*
/// state.max_combo = ...
/// state.n300 = ...
/// state.n100 = ...
/// state.n50 = ...
/// state.n_misses = ...
/// let final_performance = gradual_perf.process_next_n_objects(state.clone(), usize::MAX).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_n_objects(state.clone(), usize::MAX);
///
/// // Once the final performance was calculated,
/// // attempting to process further objects will return `None`.
/// assert!(gradual_perf.process_next_object(state).is_none());
/// ```
#[derive(Debug)]
pub struct OsuGradualPerformanceAttributes<'map> {
difficulty: OsuGradualDifficultyAttributes,
performance: OsuPP<'map>,
}
impl<'map> OsuGradualPerformanceAttributes<'map> {
/// Create a new gradual performance calculator for osu!standard maps.
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
let difficulty = OsuGradualDifficultyAttributes::new(map, mods);
let performance = OsuPP::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.
pub fn process_next_object(
&mut self,
state: OsuScoreState,
) -> Option<OsuPerformanceAttributes> {
self.process_next_n_objects(state, 1)
}
/// Same as [`process_next_object`](`OsuGradualPerformanceAttributes::process_next_object`)
/// but instead of processing only one object it process `n` many.
///
/// If `n` is 0 it will be considered as 1.
/// If there are still objects to be processed but `n` is larger than the amount
/// of remaining objects, `n` will be considered as the amount of remaining objects.
pub fn process_next_n_objects(
&mut self,
state: OsuScoreState,
n: usize,
) -> Option<OsuPerformanceAttributes> {
let sub = (self.difficulty.idx == 0) as usize;
let difficulty = self.difficulty.nth(n.saturating_sub(sub))?;
let performance = self
.performance
.clone()
.attributes(difficulty)
.state(state)
.passed_objects(self.difficulty.idx + 1)
.calculate();
Some(performance)
}
}
+596
View File
@@ -0,0 +1,596 @@
mod difficulty_object;
mod gradual_difficulty;
mod gradual_performance;
mod osu_object;
mod pp;
mod scaling_factor;
mod skills;
use skills::OsuStrainSkill;
use crate::{curve::CurveBuffers, parse::Pos2, AnyStars, Beatmap, GameMode, Mods};
use self::{
difficulty_object::{Distances, OsuDifficultyObject},
osu_object::{ObjectParameters, OsuObject},
scaling_factor::ScalingFactor,
skills::Skills,
};
pub use self::{gradual_difficulty::*, gradual_performance::*, pp::*};
const SECTION_LEN: f64 = 400.0;
const DIFFICULTY_MULTIPLIER: f64 = 0.0675;
// * Change radius to 50 to make 100 the diameter. Easier for mental maths.
const NORMALIZED_RADIUS: f32 = 50.0;
const STACK_DISTANCE: f32 = 3.0;
// * This is being adjusted to keep the final pp value scaled around what it used to be when changing things.
const PERFORMANCE_BASE_MULTIPLIER: f64 = 1.14;
const PREEMPT_MIN: f64 = 450.0;
const FADE_IN_DURATION_MULTIPLIER: f64 = 0.4;
const PLAYFIELD_BASE_SIZE: Pos2 = Pos2 { x: 512.0, y: 384.0 };
/// Difficulty calculator on osu!standard maps.
///
/// # Example
///
/// ```
/// use rosu_pp::{OsuStars, Beatmap};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let difficulty_attrs = OsuStars::new(&map)
/// .mods(8 + 64) // HDDT
/// .calculate();
///
/// println!("Stars: {}", difficulty_attrs.stars);
/// ```
#[derive(Clone, Debug)]
pub struct OsuStars<'map> {
pub(crate) map: &'map Beatmap,
pub(crate) mods: u32,
pub(crate) passed_objects: Option<usize>,
pub(crate) clock_rate: Option<f64>,
}
impl<'map> OsuStars<'map> {
/// Create a new difficulty calculator for osu!standard maps.
#[inline]
pub fn new(map: &'map Beatmap) -> Self {
Self {
map,
mods: 0,
passed_objects: None,
clock_rate: None,
}
}
/// Convert the map into another mode.
#[inline]
pub fn mode(self, mode: GameMode) -> AnyStars<'map> {
match mode {
GameMode::Osu => AnyStars::Osu(self),
GameMode::Taiko => AnyStars::Taiko(self.into()),
GameMode::Catch => AnyStars::Catch(self.into()),
GameMode::Mania => AnyStars::Mania(self.into()),
}
}
/// 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(mut self, mods: u32) -> Self {
self.mods = mods;
self
}
/// Amount of passed objects for partial plays, e.g. a fail.
///
/// If you want to calculate the difficulty after every few objects, instead of
/// using [`OsuStars`] multiple times with different `passed_objects`, you should use
/// [`OsuGradualDifficultyAttributes`](crate::osu::OsuGradualDifficultyAttributes).
#[inline]
pub 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 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) -> OsuDifficultyAttributes {
let mods = self.mods;
let (skills, mut attrs) = calculate_skills(self);
let Skills {
mut aim,
mut aim_no_sliders,
mut speed,
mut flashlight,
} = skills;
let mut aim_rating = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let aim_rating_no_sliders =
aim_no_sliders.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let speed_notes = speed.relevant_note_count();
let mut speed_rating = speed.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let mut flashlight_rating = flashlight.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let slider_factor = if aim_rating > 0.0 {
aim_rating_no_sliders / aim_rating
} else {
1.0
};
if mods.td() {
aim_rating = aim_rating.powf(0.8);
flashlight_rating = flashlight_rating.powf(0.8);
}
if mods.rx() {
aim_rating *= 0.9;
speed_rating = 0.0;
flashlight_rating *= 0.7;
}
if mods.ap() {
aim_rating = 0.0;
flashlight_rating *= 0.4;
}
let base_aim_performance = (5.0 * (aim_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
let base_speed_performance =
(5.0 * (speed_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
let base_flashlight_performance = if mods.fl() {
flashlight_rating * flashlight_rating * 25.0
} else {
0.0
};
let base_performance = ((base_aim_performance).powf(1.1)
+ (base_speed_performance).powf(1.1)
+ (base_flashlight_performance).powf(1.1))
.powf(1.0 / 1.1);
let star_rating = if base_performance > 0.00001 {
PERFORMANCE_BASE_MULTIPLIER.cbrt()
* 0.027
* ((100_000.0 / 2.0_f64.powf(1.0 / 1.1) * base_performance).cbrt() + 4.0)
} else {
0.0
};
attrs.aim = aim_rating;
attrs.speed = speed_rating;
attrs.flashlight = flashlight_rating;
attrs.slider_factor = slider_factor;
attrs.stars = star_rating;
attrs.speed_note_count = speed_notes;
attrs.aim_difficult_strain_count = aim.count_difficult_strains();
attrs.speed_difficult_strain_count = speed.count_difficult_strains();
attrs
}
/// Calculate the skill strains.
///
/// Suitable to plot the difficulty of a map over time.
#[inline]
pub fn strains(self) -> OsuStrains {
let (skills, _) = calculate_skills(self);
let Skills {
aim,
aim_no_sliders,
speed,
flashlight,
} = skills;
OsuStrains {
section_len: SECTION_LEN,
aim: aim.strain_peaks,
aim_no_sliders: aim_no_sliders.strain_peaks,
speed: speed.strain_peaks,
flashlight: flashlight.strain_peaks,
}
}
}
/// The result of calculating the strains on a osu! map.
/// Suitable to plot the difficulty of a map over time.
#[derive(Clone, Debug)]
pub struct OsuStrains {
/// Time in ms inbetween two strains.
pub section_len: f64, // TODO: remove field, make it a method
/// Strain peaks of the aim skill.
pub aim: Vec<f64>,
/// Strain peaks of the aim skill without sliders.
pub aim_no_sliders: Vec<f64>,
/// Strain peaks of the speed skill.
pub speed: Vec<f64>,
/// Strain peaks of the flashlight skill.
pub flashlight: Vec<f64>,
}
impl OsuStrains {
/// Returns the number of strain peaks per skill.
#[inline]
#[allow(clippy::len_without_is_empty)]
pub fn len(&self) -> usize {
self.aim.len()
}
}
fn calculate_skills(params: OsuStars<'_>) -> (Skills, OsuDifficultyAttributes) {
let OsuStars {
map,
mods,
passed_objects,
clock_rate,
} = params;
let take = passed_objects.unwrap_or(map.hit_objects.len());
let clock_rate = clock_rate.unwrap_or_else(|| mods.clock_rate());
let map_attrs = map.attributes().mods(mods).clock_rate(clock_rate).build();
let scaling_factor = ScalingFactor::new(map_attrs.cs);
let hr = mods.hr();
let hit_window = 2.0 * map_attrs.hit_windows.od;
let time_preempt = (map_attrs.hit_windows.ar * clock_rate) as f32 as f64;
// * Preempt time can go below 450ms. Normally, this is achieved via the DT mod
// * which uniformly speeds up all animations game wide regardless of AR.
// * This uniform speedup is hard to match 1:1, however we can at least make
// * AR>10 (via mods) feel good by extending the upper linear function above.
// * Note that this doesn't exactly match the AR>10 visuals as they're
// * classically known, but it feels good.
// * This adjustment is necessary for AR>10, otherwise TimePreempt can
// * become smaller leading to hitcircles not fully fading in.
let time_fade_in = if mods.hd() {
time_preempt * FADE_IN_DURATION_MULTIPLIER
} else {
400.0 * (time_preempt / PREEMPT_MIN).min(1.0)
};
let mut attrs = OsuDifficultyAttributes {
ar: map_attrs.ar,
hp: map_attrs.hp,
od: map_attrs.od,
..Default::default()
};
let mut params = ObjectParameters {
map,
attrs: &mut attrs,
ticks: Vec::new(),
curve_bufs: CurveBuffers::default(),
};
let mut hit_objects: Vec<_> = map
.hit_objects
.iter()
.take(take)
.map(|h| OsuObject::new(h, &mut params))
.collect();
let stack_threshold = time_preempt * map.stack_leniency as f64;
if map.version >= 6 {
stacking(&mut hit_objects, stack_threshold);
} else {
old_stacking(&mut hit_objects, stack_threshold);
}
let mut hit_objects = hit_objects.iter_mut().map(|h| {
h.post_process(hr, &scaling_factor);
h
});
let mut skills = Skills::new(
mods,
scaling_factor.radius,
time_preempt,
time_fade_in,
hit_window,
);
let last = match hit_objects.next() {
Some(prev) => prev,
None => return (skills, attrs),
};
let mut last_last = None;
// Prepare `lazy_travel_dist` and `lazy_end_pos` for `last` manually
Distances::compute_slider_cursor_pos(last, &scaling_factor);
let mut last = &*last;
let mut diff_objects = Vec::with_capacity(hit_objects.len());
for (i, curr) in hit_objects.enumerate() {
let delta_time = (curr.start_time - last.start_time) / clock_rate;
// * Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
let strain_time = delta_time.max(OsuDifficultyObject::MIN_DELTA_TIME as f64);
let dists = Distances::new(
curr,
last,
last_last,
clock_rate,
strain_time,
&scaling_factor,
);
let diff_obj = OsuDifficultyObject::new(curr, last, clock_rate, i, dists);
diff_objects.push(diff_obj);
last_last = Some(last);
last = &*curr;
}
for curr in diff_objects.iter() {
skills.process(curr, &diff_objects);
}
(skills, attrs)
}
fn stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
let mut extended_start_idx = 0;
let extended_end_idx = match hit_objects.len().checked_sub(1) {
Some(idx) => idx,
None => return,
};
// First big `if` in osu!lazer's function can be skipped
for i in (1..=extended_end_idx).rev() {
let mut n = i;
let mut obj_i_idx = i;
// * We should check every note which has not yet got a stack.
// * Consider the case we have two interwound stacks and this will make sense.
// * o <-1 o <-2
// * o <-3 o <-4
// * We first process starting from 4 and handle 2,
// * then we come backwards on the i loop iteration until we reach 3 and handle 1.
// * 2 and 1 will be ignored in the i loop because they already have a stack value.
if hit_objects[obj_i_idx].stack_height.abs() > 0.0 || hit_objects[obj_i_idx].is_spinner() {
continue;
}
// * If this object is a hitcircle, then we enter this "special" case.
// * It either ends with a stack of hitcircles only,
// * or a stack of hitcircles that are underneath a slider.
// * Any other case is handled by the "is_slider" code below this.
if hit_objects[obj_i_idx].is_circle() {
loop {
n = match n.checked_sub(1) {
Some(n) => n,
None => break,
};
if hit_objects[n].is_spinner() {
continue;
} else if hit_objects[obj_i_idx].start_time - hit_objects[n].end_time()
> stack_threshold
{
break; // * We are no longer within stacking range of the previous object.
}
// * HitObjects before the specified update range haven't been reset yet
if n < extended_start_idx {
hit_objects[n].stack_height = 0.0;
extended_start_idx = n;
}
// * This is a special case where hticircles are moved DOWN and RIGHT (negative stacking)
// * if they are under the *last* slider in a stacked pattern.
// * o==o <- slider is at original location
// * o <- hitCircle has stack of -1
// * o <- hitCircle has stack of -2
if hit_objects[n].is_slider()
&& hit_objects[n]
.pre_stacked_end_pos()
.distance(hit_objects[obj_i_idx].pos())
< STACK_DISTANCE
{
let offset =
hit_objects[obj_i_idx].stack_height - hit_objects[n].stack_height + 1.0;
for j in n + 1..=i {
// * For each object which was declared under this slider, we will offset
// * it to appear *below* the slider end (rather than above).
if hit_objects[n]
.pre_stacked_end_pos()
.distance(hit_objects[j].pos())
< STACK_DISTANCE
{
hit_objects[j].stack_height -= offset;
}
}
// * We have hit a slider. We should restart calculation using this as the new base.
// * Breaking here will mean that the slider still has StackCount of 0,
// * so will be handled in the i-outer-loop.
break;
}
if hit_objects[n].pos().distance(hit_objects[obj_i_idx].pos()) < STACK_DISTANCE {
// * Keep processing as if there are no sliders.
// * If we come across a slider, this gets cancelled out.
// * NOTE: Sliders with start positions stacking
// * are a special case that is also handled here.
hit_objects[n].stack_height = hit_objects[obj_i_idx].stack_height + 1.0;
obj_i_idx = n;
}
}
} else if hit_objects[obj_i_idx].is_slider() {
// * We have hit the first slider in a possible stack.
// * From this point on, we ALWAYS stack positive regardless.
loop {
n = match n.checked_sub(1) {
Some(n) => n,
None => break,
};
if hit_objects[n].is_spinner() {
continue;
}
if hit_objects[obj_i_idx].start_time - hit_objects[n].start_time > stack_threshold {
break; // * We are no longer within stacking range of the previous object.
}
if hit_objects[n]
.pre_stacked_end_pos()
.distance(hit_objects[obj_i_idx].pos())
< STACK_DISTANCE
{
hit_objects[n].stack_height = hit_objects[obj_i_idx].stack_height + 1.0;
obj_i_idx = n;
}
}
}
}
}
fn old_stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
for i in 0..hit_objects.len() {
if hit_objects[i].stack_height != 0.0 && !hit_objects[i].is_slider() {
continue;
}
let mut start_time = hit_objects[i].end_time();
let pos2 = hit_objects[i].old_stacking_pos2();
let mut slider_stack = 0.0;
for j in i + 1..hit_objects.len() {
if hit_objects[j].start_time - stack_threshold > start_time {
break;
}
if hit_objects[j].pos().distance(hit_objects[i].pos()) < STACK_DISTANCE {
hit_objects[i].stack_height += 1.0;
start_time = hit_objects[j].end_time();
} else if hit_objects[j].pos().distance(pos2) < STACK_DISTANCE {
slider_stack += 1.0;
hit_objects[j].stack_height -= slider_stack;
start_time = hit_objects[j].end_time();
}
}
}
}
/// The result of a difficulty calculation on an osu!standard map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct OsuDifficultyAttributes {
/// The aim portion of the total strain.
pub aim: f64,
/// The speed portion of the total strain.
pub speed: f64,
/// The flashlight portion of the total strain.
pub flashlight: f64,
/// The ratio of the aim strain with and without considering sliders
pub slider_factor: f64,
/// The number of difficult aim strains.
pub aim_difficult_strain_count: f64,
/// The number of difficult speed strains.
pub speed_difficult_strain_count: 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: usize,
/// The amount of sliders.
pub n_sliders: usize,
/// The amount of spinners.
pub n_spinners: usize,
/// The final star rating
pub stars: f64,
/// The maximum combo.
pub max_combo: usize,
}
impl OsuDifficultyAttributes {
/// Return the maximum combo.
#[inline]
pub fn max_combo(&self) -> usize {
self.max_combo
}
}
/// 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.
#[inline]
pub fn stars(&self) -> f64 {
self.difficulty.stars
}
/// Return the performance point value.
#[inline]
pub fn pp(&self) -> f64 {
self.pp
}
/// Return the maximum combo of the map.
#[inline]
pub fn max_combo(&self) -> usize {
self.difficulty.max_combo
}
}
impl From<OsuPerformanceAttributes> for OsuDifficultyAttributes {
#[inline]
fn from(attributes: OsuPerformanceAttributes) -> Self {
attributes.difficulty
}
}
+486
View File
@@ -0,0 +1,486 @@
use std::slice::Iter;
use super::{scaling_factor::ScalingFactor, OsuDifficultyAttributes, PLAYFIELD_BASE_SIZE};
use crate::{
curve::{Curve, CurveBuffers},
parse::{HitObject, HitObjectKind, Pos2},
Beatmap,
};
const LEGACY_LAST_TICK_OFFSET: f64 = 36.0;
const BASE_SCORING_DISTANCE: f64 = 100.0;
#[derive(Clone, Debug)]
pub(crate) struct OsuObject {
pos: Pos2,
pub(crate) start_time: f64,
pub(crate) stack_offset: Pos2,
pub(crate) stack_height: f32,
pub(crate) kind: OsuObjectKind,
}
#[derive(Clone, Debug)]
pub(crate) enum OsuObjectKind {
Circle,
Slider(OsuSlider),
Spinner { end_time: f64 },
}
#[derive(Clone, Debug)]
pub(crate) struct OsuSlider {
pub(crate) end_time: f64,
pub(crate) lazy_end_pos: Pos2,
nested_objects: Vec<NestedObject>,
}
impl OsuSlider {
pub(crate) fn nested_len(&self) -> usize {
self.nested_objects.len()
}
pub(crate) fn nested_iter(&self) -> Iter<'_, NestedObject> {
self.nested_objects.iter()
}
pub(crate) fn repeat_count(&self) -> usize {
self.nested_objects.iter().fold(0, |count, nested| {
count + matches!(nested.kind, NestedObjectKind::Repeat) as usize
})
}
pub(crate) fn end_pos(&self) -> Option<Pos2> {
self.tail().map(|tail| tail.pos)
}
pub(crate) fn tail(&self) -> Option<&NestedObject> {
self.nested_objects
.iter()
.rev()
.find(|nested| matches!(nested.kind, NestedObjectKind::Tail))
}
pub(crate) fn tail_mut(&mut self) -> Option<(usize, &mut NestedObject)> {
self.nested_objects
.iter_mut()
.enumerate()
.rev()
.find(|(_, nested)| matches!(nested.kind, NestedObjectKind::Tail))
}
}
#[derive(Clone, Debug)]
pub(crate) struct NestedObject {
/// Note: `pos` does not include stacking!
pub(crate) pos: Pos2,
pub(crate) start_time: f64,
pub(crate) kind: NestedObjectKind,
}
#[derive(Copy, Clone, Debug)]
pub(crate) enum NestedObjectKind {
Repeat,
Tail,
Tick,
}
pub(crate) struct ObjectParameters<'a> {
pub(crate) map: &'a Beatmap,
pub(crate) attrs: &'a mut OsuDifficultyAttributes,
pub(crate) ticks: Vec<(Pos2, f64)>,
pub(crate) curve_bufs: CurveBuffers,
}
impl OsuObject {
pub(crate) fn new(h: &HitObject, params: &mut ObjectParameters<'_>) -> Self {
let ObjectParameters {
map,
attrs,
ticks,
curve_bufs,
} = params;
attrs.max_combo += 1; // hitcircle, slider head, or spinner
let pos = h.pos;
match &h.kind {
HitObjectKind::Circle => {
attrs.n_circles += 1;
Self {
start_time: h.start_time,
pos,
stack_offset: Pos2::default(),
stack_height: 0.0,
kind: OsuObjectKind::Circle,
}
}
HitObjectKind::Slider {
pixel_len,
repeats,
control_points,
..
} => {
attrs.n_sliders += 1;
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;
// * prior to v8, speed multipliers don't adjust for how many ticks are generated over the same distance.
// * this results in more (or less) ticks being generated in <v8 maps for the same time duration.
let tick_dist_mult = if map.version < 8 {
difficulty_point.slider_vel.recip()
} else {
1.0
};
let mut tick_dist = if difficulty_point.generate_ticks {
scoring_dist / map.tick_rate * tick_dist_mult
} else {
f64::INFINITY
};
let span_count = (*repeats + 1) as f64;
// Build the curve w.r.t. the control points
let curve = Curve::new(control_points, *pixel_len, curve_bufs);
let end_time = h.start_time + span_count * curve.dist() / vel;
let total_duration = end_time - h.start_time;
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;
ticks.clear();
let mut nested_objects = if tick_dist != 0.0 {
ticks.reserve((len / tick_dist) as usize);
let mut nested_objects =
Vec::with_capacity((len * span_count / tick_dist) as usize);
// Ticks of the first span
while curr_dist < len - min_dist_from_end {
let progress = curr_dist / len;
let curr_time = h.start_time + progress * span_duration;
let curr_pos = h.pos + curve.position_at(progress);
let tick = NestedObject {
pos: curr_pos,
start_time: curr_time,
kind: NestedObjectKind::Tick,
};
nested_objects.push(tick);
ticks.push((curr_pos, curr_time));
curr_dist += tick_dist;
}
// Other spans
for span_idx in 1..=*repeats {
let progress = (span_idx % 2 == 1) as u8 as f64;
let span_idx_f64 = span_idx as f64;
// Repeat point
let curr_time = h.start_time + span_duration * span_idx_f64;
let curr_pos = h.pos + curve.position_at(progress);
let repeat = NestedObject {
pos: curr_pos,
start_time: curr_time,
kind: NestedObjectKind::Repeat,
};
nested_objects.push(repeat);
let span_offset = span_idx_f64 * span_duration;
// Ticks
if span_idx & 1 == 1 {
// S-------->R | Span 0
// 2 4 6 8 | => span_duration = 8
// R<--------- | Span 1
// 16 14 12 10 | => offset = 1 * span_duration
// --------->R | Span 2
// 18 20 22 24 | => not reverse; simple case
// T<--------- | Span 3
// 32 30 28 26 | => offset = 3 * span_duration
//
// n = offset + tick
// 26 = 24 + 2
// 28 = 24 + 4
// 30 = 24 + 6
// 32 = 24 + 8
let base = h.start_time + h.start_time + span_duration;
let tick_iter = ticks.iter().rev().map(|(pos, time)| NestedObject {
pos: *pos,
start_time: span_offset + base - time,
kind: NestedObjectKind::Tick,
});
nested_objects.extend(tick_iter);
} else {
let tick_iter = ticks.iter().map(|(pos, time)| NestedObject {
pos: *pos,
start_time: time + span_offset,
kind: NestedObjectKind::Tick,
});
nested_objects.extend(tick_iter);
}
}
nested_objects
} else {
Vec::new()
};
// Slider tail
let final_span_start_time = h.start_time + *repeats as f64 * span_duration;
let final_span_end_time = (h.start_time + total_duration / 2.0)
.max(final_span_start_time + span_duration - LEGACY_LAST_TICK_OFFSET);
let progress = (*repeats % 2 == 0) as u8 as f64;
let end_pos = curve.position_at(progress);
// * we need to use the LegacyLastTick here for compatibility reasons (difficulty).
// * it is *okay* to use this because the TailCircle is not used for any meaningful purpose in gameplay.
// * if this is to change, we should revisit this.
let legacy_last_tick = NestedObject {
pos: end_pos,
start_time: final_span_end_time,
kind: NestedObjectKind::Tail,
};
// On very short buzz sliders it can happen that the
// legacy last tick is not the last object time-wise
match nested_objects.last() {
Some(last) if last.start_time > final_span_end_time => {
let idx = nested_objects
.iter()
.rev()
.position(|nested| nested.start_time <= final_span_end_time)
.map_or(0, |i| nested_objects.len() - i);
nested_objects.insert(idx, legacy_last_tick);
}
_ => nested_objects.push(legacy_last_tick),
};
attrs.max_combo += nested_objects.len();
let last_time = nested_objects
.last()
.map_or(final_span_end_time, |nested| nested.start_time);
let lazy_travel_time = last_time - h.start_time;
let mut end_time_min = lazy_travel_time / span_duration;
if end_time_min % 2.0 >= 1.0 {
end_time_min = 1.0 - end_time_min % 1.0;
} else {
end_time_min %= 1.0;
}
// * temporary lazy end position until a real result can be derived.
// The position is added after the stacking for the correct order of
// floating point operations.
let lazy_end_pos = curve.position_at(end_time_min);
let slider = OsuSlider {
end_time,
lazy_end_pos,
nested_objects,
};
Self {
start_time: h.start_time,
pos,
stack_offset: Pos2::default(),
stack_height: 0.0,
kind: OsuObjectKind::Slider(slider),
}
}
HitObjectKind::Spinner { end_time } | HitObjectKind::Hold { end_time } => {
attrs.n_spinners += 1;
Self {
start_time: h.start_time,
pos,
stack_offset: Pos2::default(),
stack_height: 0.0,
kind: OsuObjectKind::Spinner {
end_time: *end_time,
},
}
}
}
}
pub(crate) fn end_time(&self) -> f64 {
match &self.kind {
OsuObjectKind::Circle => self.start_time,
OsuObjectKind::Slider(slider) => slider.end_time,
OsuObjectKind::Spinner { end_time } => *end_time,
}
}
pub(crate) fn end_pos(&self) -> Pos2 {
match &self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => self.pos,
OsuObjectKind::Slider(slider) => slider.end_pos().unwrap_or(self.pos),
}
}
pub(crate) fn pre_stacked_end_pos(&self) -> Pos2 {
match &self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => self.pos,
OsuObjectKind::Slider(slider) => slider
.end_pos()
.map_or(self.pos, |end_pos| self.pos + end_pos),
}
}
pub(crate) fn old_stacking_pos2(&self) -> Pos2 {
match &self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => self.pos,
OsuObjectKind::Slider(slider) => {
// Old stacking requires the path end position
// instead of slider end position
let repeat_count = slider.repeat_count();
if repeat_count % 2 == 0 {
slider
.end_pos()
.map_or(self.pos, |end_pos| self.pos + end_pos)
} else {
slider
.nested_iter()
.find(|nested| matches!(nested.kind, NestedObjectKind::Repeat))
.map_or(self.pos, |repeat| repeat.pos)
}
}
}
}
pub(crate) const fn pos(&self) -> Pos2 {
self.pos
}
pub(crate) fn stacked_pos(&self) -> Pos2 {
self.pos + self.stack_offset
}
pub(crate) fn stacked_end_pos(&self) -> Pos2 {
self.end_pos() + self.stack_offset
}
pub(crate) fn lazy_end_pos(&self) -> Pos2 {
match &self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => self.stacked_pos(),
OsuObjectKind::Slider(slider) => slider.lazy_end_pos,
}
}
pub(crate) fn lazy_travel_time(&self) -> f64 {
match &self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => 0.0,
OsuObjectKind::Slider(slider) => slider
.nested_objects
.last()
.map_or(0.0, |nested| nested.start_time - self.start_time),
}
}
#[inline]
pub(crate) fn is_circle(&self) -> bool {
matches!(self.kind, OsuObjectKind::Circle)
}
#[inline]
pub(crate) fn is_slider(&self) -> bool {
matches!(self.kind, OsuObjectKind::Slider { .. })
}
#[inline]
pub(crate) fn is_spinner(&self) -> bool {
matches!(self.kind, OsuObjectKind::Spinner { .. })
}
/// Applies stack offset, flips playfield for HR,
/// and adjusts slider tails and lazy_end_positions.
pub(crate) fn post_process(&mut self, hr: bool, scaling_factor: &ScalingFactor) {
self.stack_offset = scaling_factor.stack_offset(self.stack_height);
let pos = self.pos();
if let OsuObjectKind::Slider(slider) = &mut self.kind {
if hr {
let mut lazy_end_pos = pos;
lazy_end_pos.y = PLAYFIELD_BASE_SIZE.y - lazy_end_pos.y;
lazy_end_pos += self.stack_offset;
lazy_end_pos += Pos2 {
x: slider.lazy_end_pos.x,
y: -slider.lazy_end_pos.y,
};
slider.lazy_end_pos = lazy_end_pos;
let tail_idx = slider.tail_mut().map(|(tail_idx, tail)| {
let mut tail_pos = pos;
tail_pos.y = PLAYFIELD_BASE_SIZE.y - tail_pos.y;
tail_pos += Pos2 {
x: tail.pos.x,
y: -tail.pos.y,
};
tail.pos = tail_pos;
tail_idx
});
if let Some(tail_idx) = tail_idx {
for nested in slider.nested_objects[..tail_idx].iter_mut() {
nested.pos.y = PLAYFIELD_BASE_SIZE.y - nested.pos.y;
}
for nested in slider.nested_objects[tail_idx + 1..].iter_mut() {
nested.pos.y = PLAYFIELD_BASE_SIZE.y - nested.pos.y;
}
} else {
// Should never happen since sliders are bound to have a tail
for nested in slider.nested_objects.iter_mut() {
nested.pos.y = PLAYFIELD_BASE_SIZE.y - nested.pos.y;
}
}
} else {
slider.lazy_end_pos += pos + self.stack_offset;
if let Some((_, tail)) = slider.tail_mut() {
tail.pos += pos;
}
}
}
if hr {
self.pos.y = PLAYFIELD_BASE_SIZE.y - pos.y
}
}
}
+1024
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
use crate::parse::Pos2;
use super::NORMALIZED_RADIUS;
const OBJECT_RADIUS: f32 = 64.0;
#[derive(Copy, Clone, Debug)]
pub(crate) struct ScalingFactor {
pub(crate) factor: f32,
pub(crate) radius: f32,
scale: f32,
}
impl ScalingFactor {
pub(crate) fn new(cs: f64) -> Self {
let scale = (1.0 - 0.7 * (cs as f32 - 5.0) / 5.0) / 2.0;
let radius = OBJECT_RADIUS * scale;
let factor = NORMALIZED_RADIUS / radius;
let factor = if radius < 30.0 {
factor * (1.0 + (30.0 - radius).min(5.0) / 50.0)
} else {
factor
};
Self {
factor,
radius,
scale,
}
}
pub(crate) fn stack_offset(&self, stack_height: f32) -> Pos2 {
Pos2::new(stack_height * self.scale * -6.4)
}
}
+281
View File
@@ -0,0 +1,281 @@
use std::f64::consts::{FRAC_PI_2, PI};
use crate::osu::difficulty_object::OsuDifficultyObject;
use super::{previous, previous_start_time, OsuStrainSkill, Skill, StrainSkill};
#[derive(Clone, Debug)]
pub(crate) struct Aim {
curr_strain: f64,
curr_section_peak: f64,
curr_section_end: f64,
pub(crate) strain_peaks: Vec<f64>,
with_sliders: bool,
object_strains: Vec<f64>,
difficulty: f64,
}
impl Aim {
const SKILL_MULTIPLIER: f64 = 23.55;
const STRAIN_DECAY_BASE: f64 = 0.15;
pub(crate) fn new(with_sliders: bool) -> Self {
Self {
curr_strain: 0.0,
curr_section_peak: 0.0,
curr_section_end: 0.0,
strain_peaks: Vec::new(),
with_sliders,
object_strains: Vec::new(),
difficulty: 0.0,
}
}
fn strain_decay(ms: f64) -> f64 {
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
}
}
impl Skill for Aim {
#[inline]
fn process(
&mut self,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) {
<Self as StrainSkill>::process(self, curr, diff_objects)
}
#[inline]
fn difficulty_value(&mut self) -> f64 {
<Self as OsuStrainSkill>::difficulty_value(self)
}
}
impl StrainSkill for Aim {
#[inline]
fn strain_peaks_mut(&mut self) -> &mut Vec<f64> {
&mut self.strain_peaks
}
#[inline]
fn curr_section_peak(&mut self) -> &mut f64 {
&mut self.curr_section_peak
}
#[inline]
fn curr_section_end(&mut self) -> &mut f64 {
&mut self.curr_section_end
}
#[inline]
fn strain_value_at(
&mut self,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) -> f64 {
self.curr_strain *= Self::strain_decay(curr.delta_time);
self.curr_strain += AimEvaluator::evaluate_diff_of(curr, diff_objects, self.with_sliders)
* Self::SKILL_MULTIPLIER;
self.object_strains.push(self.curr_strain);
self.curr_strain
}
#[inline]
fn calculate_initial_strain(
&self,
time: f64,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) -> f64 {
self.curr_strain * Self::strain_decay(time - previous_start_time(diff_objects, curr.idx, 0))
}
#[inline]
fn difficulty_value(&mut self) -> f64 {
<Self as OsuStrainSkill>::difficulty_value(self)
}
}
impl OsuStrainSkill for Aim {
fn strains(&self) -> &Vec<f64> {
&self.object_strains
}
fn set_raw_difficulty_value(&mut self, value: f64) {
self.difficulty = value;
}
fn get_raw_difficulty_value(&self) -> f64 {
self.difficulty
}
}
struct AimEvaluator;
impl AimEvaluator {
const WIDE_ANGLE_MULTIPLIER: f64 = 1.5;
const ACUTE_ANGLE_MULTIPLIER: f64 = 1.95;
const SLIDER_MULTIPLIER: f64 = 1.35;
const VELOCITY_CHANGE_MULTIPLIER: f64 = 0.75;
fn evaluate_diff_of(
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
with_sliders: bool,
) -> f64 {
let osu_curr_obj = curr;
let (osu_last_last_obj, osu_last_obj) = if let Some(tuple) =
previous(diff_objects, curr.idx, 1)
.zip(previous(diff_objects, curr.idx, 0))
.filter(|(_, last)| !(curr.base.is_spinner() || last.base.is_spinner()))
{
tuple
} else {
return 0.0;
};
// * Calculate the velocity to the current hitobject, which starts
// * with a base distance / time assuming the last object is a hitcircle.
let mut curr_vel = osu_curr_obj.dists.lazy_jump_dist / osu_curr_obj.strain_time;
// * But if the last object is a slider, then we extend the travel
// * velocity through the slider into the current object.
if osu_last_obj.base.is_slider() && with_sliders {
// * calculate the slider velocity from slider head to slider end.
let travel_vel = osu_last_obj.dists.travel_dist / osu_last_obj.dists.travel_time;
// * calculate the movement velocity from slider end to current object
let movement_vel = osu_curr_obj.dists.min_jump_dist / osu_curr_obj.dists.min_jump_time;
// * take the larger total combined velocity.
curr_vel = curr_vel.max(movement_vel + travel_vel);
}
// * As above, do the same for the previous hitobject.
let mut prev_vel = osu_last_obj.dists.lazy_jump_dist / osu_last_obj.strain_time;
if osu_last_last_obj.base.is_slider() && with_sliders {
let travel_vel =
osu_last_last_obj.dists.travel_dist / osu_last_last_obj.dists.travel_time;
let movement_vel = osu_last_obj.dists.min_jump_dist / osu_last_obj.dists.min_jump_time;
prev_vel = prev_vel.max(movement_vel + travel_vel);
}
let mut wide_angle_bonus = 0.0;
let mut acute_angle_bonus = 0.0;
let mut slider_bonus = 0.0;
let mut vel_change_bonus = 0.0;
// * Start strain with regular velocity.
let mut aim_strain = curr_vel;
// * If rhythms are the same.
if osu_curr_obj.strain_time.max(osu_last_obj.strain_time)
< 1.25 * osu_curr_obj.strain_time.min(osu_last_obj.strain_time)
{
if let Some(((curr_angle, last_angle), last_last_angle)) = osu_curr_obj
.dists
.angle
.zip(osu_last_obj.dists.angle)
.zip(osu_last_last_obj.dists.angle)
{
// * Rewarding angles, take the smaller velocity as base.
let angle_bonus = curr_vel.min(prev_vel);
wide_angle_bonus = Self::calc_wide_angle_bonus(curr_angle);
acute_angle_bonus = Self::calc_acute_angle_bonus(curr_angle);
// * Only buff deltaTime exceeding 300 bpm 1/2.
if osu_curr_obj.strain_time > 100.0 {
acute_angle_bonus = 0.0;
} else {
let base1 =
(FRAC_PI_2 * ((100.0 - osu_curr_obj.strain_time) / 25.0).min(1.0)).sin();
let base2 = (FRAC_PI_2
* ((osu_curr_obj.dists.lazy_jump_dist).clamp(50.0, 100.0) - 50.0)
/ 50.0)
.sin();
// * Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern.
acute_angle_bonus *= Self::calc_acute_angle_bonus(last_angle)
// * The maximum velocity we buff is equal to 125 / strainTime
* angle_bonus.min(125.0 / osu_curr_obj.strain_time)
// * scale buff from 150 bpm 1/4 to 200 bpm 1/4
* base1
* base1
// * Buff distance exceeding 50 (radius) up to 100 (diameter).
* base2
* base2;
}
// * Penalize wide angles if they're repeated, reducing the penalty as the lastAngle gets more acute.
wide_angle_bonus *= angle_bonus
* (1.0 - wide_angle_bonus.min(Self::calc_wide_angle_bonus(last_angle).powi(3)));
// * Penalize acute angles if they're repeated, reducing the penalty as the lastLastAngle gets more obtuse.
acute_angle_bonus *= 0.5
+ 0.5
* (1.0
- acute_angle_bonus
.min(Self::calc_acute_angle_bonus(last_last_angle).powi(3)));
}
}
if prev_vel.max(curr_vel).abs() > f64::EPSILON {
// * We want to use the average velocity over the whole object when awarding
// * differences, not the individual jump and slider path velocities.
prev_vel = (osu_last_obj.dists.lazy_jump_dist + osu_last_last_obj.dists.travel_dist)
/ osu_last_obj.strain_time;
curr_vel = (osu_curr_obj.dists.lazy_jump_dist + osu_last_obj.dists.travel_dist)
/ osu_curr_obj.strain_time;
// * Scale with ratio of difference compared to 0.5 * max dist.
let dist_ratio_base =
(FRAC_PI_2 * (prev_vel - curr_vel).abs() / prev_vel.max(curr_vel)).sin();
let dist_ratio = dist_ratio_base * dist_ratio_base;
// * Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
let overlap_vel_buff = (125.0 / osu_curr_obj.strain_time.min(osu_last_obj.strain_time))
.min((prev_vel - curr_vel).abs());
vel_change_bonus = overlap_vel_buff * dist_ratio;
// * Penalize for rhythm changes.
let bonus_base = (osu_curr_obj.strain_time).min(osu_last_obj.strain_time)
/ (osu_curr_obj.strain_time).max(osu_last_obj.strain_time);
vel_change_bonus *= bonus_base * bonus_base;
}
if osu_last_obj.base.is_slider() {
// * Reward sliders based on velocity.
slider_bonus = osu_last_obj.dists.travel_dist / osu_last_obj.dists.travel_time
}
// * Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger.
aim_strain += (acute_angle_bonus * Self::ACUTE_ANGLE_MULTIPLIER).max(
wide_angle_bonus * Self::WIDE_ANGLE_MULTIPLIER
+ vel_change_bonus * Self::VELOCITY_CHANGE_MULTIPLIER,
);
// * Add in additional slider velocity bonus.
if with_sliders {
aim_strain += slider_bonus * Self::SLIDER_MULTIPLIER;
}
aim_strain
}
fn calc_wide_angle_bonus(angle: f64) -> f64 {
let base = (3.0 / 4.0 * ((5.0 / 6.0 * PI).min(angle.max(PI / 6.0)) - PI / 6.0)).sin();
base * base
}
fn calc_acute_angle_bonus(angle: f64) -> f64 {
1.0 - Self::calc_wide_angle_bonus(angle)
}
}
+249
View File
@@ -0,0 +1,249 @@
use crate::{
osu::{difficulty_object::OsuDifficultyObject, osu_object::OsuObjectKind},
Mods,
};
use super::{previous, previous_start_time, OsuStrainSkill, Skill, StrainSkill};
#[derive(Clone, Debug)]
pub(crate) struct Flashlight {
curr_strain: f64,
curr_section_peak: f64,
curr_section_end: f64,
pub(crate) strain_peaks: Vec<f64>,
has_hidden_mod: bool,
scaling_factor: f64,
time_preempt: f64,
time_fade_in: f64,
}
impl Flashlight {
const SKILL_MULTIPLIER: f64 = 0.052;
const STRAIN_DECAY_BASE: f64 = 0.15;
pub(crate) fn new(mods: u32, radius: f32, time_preempt: f64, time_fade_in: f64) -> Self {
Self {
curr_strain: 0.0,
curr_section_peak: 0.0,
curr_section_end: 0.0,
strain_peaks: Vec::new(),
has_hidden_mod: mods.hd(),
scaling_factor: 52.0 / radius as f64,
time_preempt,
time_fade_in,
}
}
fn strain_decay(ms: f64) -> f64 {
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
}
}
impl Skill for Flashlight {
#[inline]
fn process(
&mut self,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) {
<Self as StrainSkill>::process(self, curr, diff_objects)
}
#[inline]
fn difficulty_value(&mut self) -> f64 {
<Self as StrainSkill>::difficulty_value(self)
}
}
impl StrainSkill for Flashlight {
const DECAY_WEIGHT: f64 = 0.9;
#[inline]
fn strain_peaks_mut(&mut self) -> &mut Vec<f64> {
&mut self.strain_peaks
}
#[inline]
fn curr_section_peak(&mut self) -> &mut f64 {
&mut self.curr_section_peak
}
#[inline]
fn curr_section_end(&mut self) -> &mut f64 {
&mut self.curr_section_end
}
#[inline]
fn strain_value_at(
&mut self,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) -> f64 {
self.curr_strain *= Self::strain_decay(curr.delta_time);
self.curr_strain += FlashlightEvaluator::evaluate_diff_of(
curr,
diff_objects,
self.has_hidden_mod,
self.scaling_factor,
self.time_preempt,
self.time_fade_in,
) * Self::SKILL_MULTIPLIER;
self.curr_strain
}
#[inline]
fn calculate_initial_strain(
&self,
time: f64,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) -> f64 {
self.curr_strain * Self::strain_decay(time - previous_start_time(diff_objects, curr.idx, 0))
}
#[inline]
fn difficulty_value(&mut self) -> f64 {
self.get_curr_strain_peaks().into_iter().sum::<f64>() * Self::DIFFICULTY_MULTIPLER
}
}
const HOLDING_VEC: &Vec<f64> = &vec![];
impl OsuStrainSkill for Flashlight {
fn strains(&self) -> &Vec<f64> {
HOLDING_VEC
}
fn set_raw_difficulty_value(&mut self, _value: f64) {}
fn get_raw_difficulty_value(&self) -> f64 {
0.0
}
}
struct FlashlightEvaluator;
impl FlashlightEvaluator {
const MAX_OPACITY_BONUS: f64 = 0.4;
const HIDDEN_BONUS: f64 = 0.2;
const MIN_VELOCITY: f64 = 0.5;
const SLIDER_MULTIPLIER: f64 = 1.3;
const MIN_ANGLE_MULTIPLIER: f64 = 0.2;
fn evaluate_diff_of(
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
hidden: bool,
scaling_factor: f64,
time_preempt: f64,
time_fade_in: f64,
) -> f64 {
if curr.base.is_spinner() {
return 0.0;
}
let osu_curr = curr;
let osu_hit_obj = curr.base;
let mut small_dist_nerf = 1.0;
let mut cumulative_strain_time = 0.0;
let mut result = 0.0;
let mut last_obj = osu_curr;
let mut angle_repeat_count = 0.0;
// * This is iterating backwards in time from the current object.
for i in 0..curr.idx.min(10) {
let curr_obj = if let Some(curr_obj) = previous(diff_objects, curr.idx, i) {
curr_obj
} else {
break;
};
let curr_hit_obj = curr_obj.base;
if !curr_obj.base.is_spinner() {
let jump_dist =
(osu_hit_obj.stacked_pos() - curr_hit_obj.stacked_end_pos()).length() as f64;
cumulative_strain_time += last_obj.strain_time;
// * We want to nerf objects that can be easily seen within the Flashlight circle radius.
if i == 0 {
small_dist_nerf = (jump_dist / 75.0).min(1.0);
}
// * We also want to nerf stacks so that only the first object of the stack is accounted for.
let stack_nerf = ((curr_obj.dists.lazy_jump_dist / scaling_factor) / 25.0).min(1.0);
// * Bonus based on how visible the object is.
let opacity_bonus = 1.0
+ Self::MAX_OPACITY_BONUS
* (1.0
- osu_curr.opacity_at(
curr_hit_obj.start_time,
hidden,
time_preempt,
time_fade_in,
));
result += stack_nerf * opacity_bonus * scaling_factor * jump_dist
/ cumulative_strain_time;
if let Some((curr_obj_angle, osu_curr_angle)) =
curr_obj.dists.angle.zip(osu_curr.dists.angle)
{
// * Objects further back in time should count less for the nerf.
if (curr_obj_angle - osu_curr_angle).abs() < 0.02 {
angle_repeat_count += (1.0 - 0.1 * i as f64).max(0.0);
}
}
}
last_obj = curr_obj;
}
let base = small_dist_nerf * result;
result = base * base;
// * Additional bonus for Hidden due to there being no approach circles.
if hidden {
result *= 1.0 + Self::HIDDEN_BONUS;
}
// * Nerf patterns with repeated angles.
result *= Self::MIN_ANGLE_MULTIPLIER
+ (1.0 - Self::MIN_ANGLE_MULTIPLIER) / (angle_repeat_count + 1.0);
let mut slider_bonus = 0.0;
if let OsuObjectKind::Slider(slider) = &osu_curr.base.kind {
// * Invert the scaling factor to determine the true travel distance independent of circle size.
let pixel_travel_dist = osu_curr.dists.lazy_travel_dist as f64 / scaling_factor;
// * Reward sliders based on velocity.
slider_bonus = ((pixel_travel_dist / osu_curr.dists.travel_time as f64
- Self::MIN_VELOCITY)
.max(0.0))
.sqrt();
// * Longer sliders require more memorisation.
slider_bonus *= pixel_travel_dist;
// * Nerf sliders with repeats, as less memorisation is required.
let repeat_count = slider.repeat_count();
if repeat_count > 0 {
slider_bonus /= (repeat_count + 1) as f64;
}
}
result += slider_bonus * Self::SLIDER_MULTIPLIER;
result
}
}
+74
View File
@@ -0,0 +1,74 @@
mod aim;
mod flashlight;
mod speed;
mod traits;
use crate::osu::difficulty_object::OsuDifficultyObject;
pub(crate) use self::{
aim::Aim,
flashlight::Flashlight,
speed::Speed,
traits::{OsuStrainSkill, Skill, StrainSkill},
};
#[derive(Clone, Debug)]
pub(crate) struct Skills {
pub aim: Aim,
pub aim_no_sliders: Aim,
pub speed: Speed,
pub flashlight: Flashlight,
}
impl Skills {
pub(crate) fn new(
mods: u32,
radius: f32,
time_preempt: f64,
time_fade_in: f64,
hit_window: f64,
) -> Self {
Self {
aim: Aim::new(true),
aim_no_sliders: Aim::new(false),
speed: Speed::new(hit_window, mods),
flashlight: Flashlight::new(mods, radius, time_preempt, time_fade_in),
}
}
pub(crate) fn process(
&mut self,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) {
<Aim as Skill>::process(&mut self.aim, curr, diff_objects);
<Aim as Skill>::process(&mut self.aim_no_sliders, curr, diff_objects);
<Speed as Skill>::process(&mut self.speed, curr, diff_objects);
<Flashlight as Skill>::process(&mut self.flashlight, curr, diff_objects);
}
}
fn previous<'map, 'objects>(
diff_objects: &'objects [OsuDifficultyObject<'map>],
curr: usize,
backwards_idx: usize,
) -> Option<&'objects OsuDifficultyObject<'map>> {
curr.checked_sub(backwards_idx + 1)
.and_then(|idx| diff_objects.get(idx))
}
fn previous_start_time(
diff_objects: &[OsuDifficultyObject<'_>],
curr: usize,
backwards_idx: usize,
) -> f64 {
previous(diff_objects, curr, backwards_idx).map_or(0.0, |h| h.start_time)
}
fn next<'map, 'objects>(
diff_objects: &'objects [OsuDifficultyObject<'map>],
curr: usize,
forwards_idx: usize,
) -> Option<&'objects OsuDifficultyObject<'map>> {
diff_objects.get(curr + (forwards_idx + 1))
}
+345
View File
@@ -0,0 +1,345 @@
use std::{cmp::Ordering, f64::consts::PI};
use crate::{osu::difficulty_object::OsuDifficultyObject, Mods};
use super::{next, previous, previous_start_time, OsuStrainSkill, Skill, StrainSkill};
#[derive(Clone, Debug)]
pub(crate) struct Speed {
curr_strain: f64,
curr_section_peak: f64,
curr_section_end: f64,
curr_rhythm: f64,
pub(crate) strain_peaks: Vec<f64>,
object_strains: Vec<f64>,
hit_window: f64,
mods: u32,
difficulty: f64,
}
impl Speed {
const SKILL_MULTIPLIER: f64 = 1375.0;
const STRAIN_DECAY_BASE: f64 = 0.3;
pub(crate) fn new(hit_window: f64, mods: u32) -> Self {
Self {
curr_strain: 0.0,
curr_section_peak: 0.0,
curr_section_end: 0.0,
curr_rhythm: 0.0,
strain_peaks: Vec::new(),
object_strains: Vec::new(),
hit_window,
mods,
difficulty: 0.0,
}
}
fn strain_decay(ms: f64) -> f64 {
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
}
pub(crate) fn relevant_note_count(&self) -> f64 {
self.object_strains
.iter()
.max_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal))
.copied()
.filter(|&n| n > 0.0)
.map_or(0.0, |max_strain| {
self.object_strains.iter().fold(0.0, |sum, strain| {
sum + (1.0 + (-(strain / max_strain * 12.0 - 6.0)).exp()).recip()
})
})
}
}
impl Skill for Speed {
#[inline]
fn process(
&mut self,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) {
<Self as StrainSkill>::process(self, curr, diff_objects)
}
#[inline]
fn difficulty_value(&mut self) -> f64 {
<Self as OsuStrainSkill>::difficulty_value(self)
}
}
impl StrainSkill for Speed {
#[inline]
fn strain_peaks_mut(&mut self) -> &mut Vec<f64> {
&mut self.strain_peaks
}
#[inline]
fn curr_section_peak(&mut self) -> &mut f64 {
&mut self.curr_section_peak
}
#[inline]
fn curr_section_end(&mut self) -> &mut f64 {
&mut self.curr_section_end
}
#[inline]
fn strain_value_at(
&mut self,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) -> f64 {
self.curr_strain *= Self::strain_decay(curr.strain_time);
self.curr_strain +=
SpeedEvaluator::evaluate_diff_of(curr, diff_objects, self.hit_window, self.mods)
* Self::SKILL_MULTIPLIER;
self.curr_rhythm = RhythmEvaluator::evaluate_diff_of(curr, diff_objects, self.hit_window);
let total_strain = self.curr_strain * self.curr_rhythm;
self.object_strains.push(total_strain);
total_strain
}
#[inline]
fn calculate_initial_strain(
&self,
time: f64,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) -> f64 {
(self.curr_strain * self.curr_rhythm)
* Self::strain_decay(time - previous_start_time(diff_objects, curr.idx, 0))
}
#[inline]
fn difficulty_value(&mut self) -> f64 {
<Self as OsuStrainSkill>::difficulty_value(self)
}
}
impl OsuStrainSkill for Speed {
const REDUCED_SECTION_COUNT: usize = 5;
const DIFFICULTY_MULTIPLER: f64 = 1.04;
fn strains(&self) -> &Vec<f64> {
&self.object_strains
}
fn set_raw_difficulty_value(&mut self, value: f64) {
self.difficulty = value;
}
fn get_raw_difficulty_value(&self) -> f64 {
self.difficulty
}
}
struct SpeedEvaluator;
impl SpeedEvaluator {
const SINGLE_SPACING_THRESHOLD: f64 = 125.0;
const MIN_SPEED_BONUS: f64 = 75.0; // ~200BPM
const SPEED_BALANCING_FACTOR: f64 = 40.;
fn evaluate_diff_of(
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
hit_window: f64,
mods: u32,
) -> f64 {
if curr.base.is_spinner() {
return 0.0;
}
// * derive strainTime for calculation
let osu_curr_obj = curr;
let osu_prev_obj = previous(diff_objects, curr.idx, 0);
let osu_next_obj = next(diff_objects, curr.idx, 0);
let mut strain_time = curr.strain_time;
let mut doubletapness = 1.0;
// * Nerf doubletappable doubles.
if let Some(osu_next_obj) = osu_next_obj {
let curr_delta_time = osu_curr_obj.delta_time.max(1.0);
let next_delta_time = osu_next_obj.delta_time.max(1.0);
let delta_diff = (next_delta_time - curr_delta_time).abs();
let speed_ratio = curr_delta_time / curr_delta_time.max(delta_diff);
let window_ratio_base = (curr_delta_time / hit_window).min(1.0);
let window_ratio = window_ratio_base * window_ratio_base;
doubletapness = speed_ratio.powf(1.0 - window_ratio);
}
// * Cap deltatime to the OD 300 hitwindow.
// * 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap.
strain_time /= ((strain_time / hit_window) / 0.93).clamp(0.92, 1.0);
// * derive speedBonus for calculation
let speed_bonus = if strain_time < Self::MIN_SPEED_BONUS {
let base = (Self::MIN_SPEED_BONUS - strain_time) / Self::SPEED_BALANCING_FACTOR;
1.0 + 0.75 * base * base
} else {
1.0
};
let travel_dist = osu_prev_obj.map_or(0.0, |obj| obj.dists.travel_dist);
let dist = match mods.ap() {
true => 0.0,
false => {
Self::SINGLE_SPACING_THRESHOLD.min(travel_dist + osu_curr_obj.dists.min_jump_dist)
}
};
(speed_bonus + speed_bonus * (dist / Self::SINGLE_SPACING_THRESHOLD).powf(3.5))
* doubletapness
/ strain_time
}
}
struct RhythmEvaluator;
impl RhythmEvaluator {
// * 5 seconds of calculatingRhythmBonus max.
const HISTORY_TIME_MAX: u32 = 5000;
const RHYTHM_MULTIPLIER: f64 = 0.75;
fn evaluate_diff_of(
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
hit_window: f64,
) -> f64 {
if curr.base.is_spinner() {
return 0.0;
}
let mut prev_island_size = 0;
let mut rhythm_complexity_sum = 0.0;
let mut island_size = 1;
// * store the ratio of the current start of an island to buff for tighter rhythms
let mut start_ratio = 0.0;
let mut first_delta_switch = false;
let historical_note_count = curr.idx.min(32);
let mut rhythm_start = 0;
while previous(diff_objects, curr.idx, rhythm_start)
.filter(|prev| {
rhythm_start + 2 < historical_note_count
&& curr.start_time - prev.start_time < Self::HISTORY_TIME_MAX as f64
})
.is_some()
{
rhythm_start += 1;
}
for i in (1..=rhythm_start).rev() {
let (curr_obj, prev_obj, last_obj) = if let Some(((curr, prev), last)) =
previous(diff_objects, curr.idx, i - 1)
.zip(previous(diff_objects, curr.idx, i))
.zip(previous(diff_objects, curr.idx, i + 1))
{
(curr, prev, last)
} else {
break;
};
// * scales note 0 to 1 from history to now
let mut curr_historical_decay = (Self::HISTORY_TIME_MAX as f64
- (curr.start_time - curr_obj.start_time))
/ Self::HISTORY_TIME_MAX as f64;
// * either we're limited by time or limited by object count.
curr_historical_decay = curr_historical_decay
.min((historical_note_count - i) as f64 / historical_note_count as f64);
let curr_delta = curr_obj.strain_time;
let prev_delta = prev_obj.strain_time;
let last_delta = last_obj.strain_time;
// * fancy function to calculate rhythmbonuses.
let base = (PI / (prev_delta.min(curr_delta) / prev_delta.max(curr_delta))).sin();
let curr_ratio = 1.0 + 6.0 * (base * base).min(0.5);
let hit_window = !curr_obj.base.is_spinner() as u64 as f64 * hit_window;
let mut window_penalty = ((((prev_delta - curr_delta).abs() - hit_window * 0.3)
.max(0.0))
/ (hit_window * 0.3))
.min(1.0);
window_penalty = window_penalty.min(1.0);
let mut effective_ratio = window_penalty * curr_ratio;
if first_delta_switch {
if !(prev_delta > 1.25 * curr_delta || prev_delta * 1.25 < curr_delta) {
if island_size < 7 {
// * island is still progressing, count size.
island_size += 1;
}
} else {
// * bpm change is into slider, this is easy acc window
if curr_obj.base.is_slider() {
effective_ratio *= 0.125;
}
// * bpm change was from a slider, this is easier typically than circle -> circle
if prev_obj.base.is_slider() {
effective_ratio *= 0.25;
}
// * repeated island size (ex: triplet -> triplet)
if prev_island_size == island_size {
effective_ratio *= 0.25;
}
// * repeated island polartiy (2 -> 4, 3 -> 5)
if prev_island_size % 2 == island_size % 2 {
effective_ratio *= 0.5;
}
// * previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this.
if last_delta > prev_delta + 10.0 && prev_delta > curr_delta + 10.0 {
effective_ratio *= 0.125;
}
rhythm_complexity_sum += (effective_ratio * start_ratio).sqrt()
* curr_historical_decay
* ((4 + island_size) as f64).sqrt()
/ 2.0
* ((4 + prev_island_size) as f64).sqrt()
/ 2.0;
start_ratio = effective_ratio;
// * log the last island size.
prev_island_size = island_size;
// * we're slowing down, stop counting
if prev_delta * 1.25 < curr_delta {
// * if we're speeding up, this stays true and we keep counting island size.
first_delta_switch = false;
}
island_size = 1;
}
} else if prev_delta > 1.25 * curr_delta {
// * we want to be speeding up.
// * Begin counting island until we change speed again.
first_delta_switch = true;
start_ratio = effective_ratio;
island_size = 1;
}
}
// * produces multiplier that can be applied to strain. range [1, infinity) (not really though)
(4.0 + rhythm_complexity_sum * Self::RHYTHM_MULTIPLIER).sqrt() / 2.0
}
}
+152
View File
@@ -0,0 +1,152 @@
use std::{cmp::Ordering, mem};
use crate::osu::{difficulty_object::OsuDifficultyObject, SECTION_LEN};
pub(crate) trait Skill {
fn process(&mut self, curr: &OsuDifficultyObject<'_>, diff_objects: &[OsuDifficultyObject<'_>]);
fn difficulty_value(&mut self) -> f64;
}
pub(crate) trait StrainSkill: Skill + Sized {
const DECAY_WEIGHT: f64 = 0.9;
fn strain_peaks_mut(&mut self) -> &mut Vec<f64>;
fn curr_section_peak(&mut self) -> &mut f64;
fn curr_section_end(&mut self) -> &mut f64;
fn strain_value_at(
&mut self,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) -> f64;
fn calculate_initial_strain(
&self,
time: f64,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) -> f64;
fn process(
&mut self,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) {
// * The first object doesn't generate a strain, so we begin with an incremented section end
if curr.idx == 0 {
let section_len = SECTION_LEN as f64;
*self.curr_section_end() = (curr.start_time / section_len).ceil() * section_len;
}
while curr.start_time > *self.curr_section_end() {
self.save_curr_peak();
{
let section_end = *self.curr_section_end();
self.start_new_section_from(section_end, curr, diff_objects);
}
*self.curr_section_end() += SECTION_LEN as f64;
}
*self.curr_section_peak() = self
.strain_value_at(curr, diff_objects)
.max(*self.curr_section_peak());
}
#[inline]
fn save_curr_peak(&mut self) {
let peak = *self.curr_section_peak();
self.strain_peaks_mut().push(peak);
}
#[inline]
fn start_new_section_from(
&mut self,
time: f64,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) {
// * The maximum strain of the new section is not zero by default
// * This means we need to capture the strain level at the beginning of the new section,
// * and use that as the initial peak level.
*self.curr_section_peak() = self.calculate_initial_strain(time, curr, diff_objects);
}
fn difficulty_value(&mut self) -> f64;
#[inline]
fn get_curr_strain_peaks(&mut self) -> Vec<f64> {
let curr_peak = *self.curr_section_peak();
let mut strain_peaks = mem::take(self.strain_peaks_mut());
strain_peaks.push(curr_peak);
strain_peaks
}
}
pub(crate) trait OsuStrainSkill: StrainSkill + Sized {
const REDUCED_SECTION_COUNT: usize = 10;
const REDUCED_STRAIN_BASELINE: f64 = 0.75;
const DIFFICULTY_MULTIPLER: f64 = 1.06;
fn difficulty_value(&mut 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);
peaks.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
let peak_iter = peaks.iter_mut().take(Self::REDUCED_SECTION_COUNT);
fn lerp(start: f64, end: f64, amount: f64) -> f64 {
start + (end - start) * amount
}
// * We are reducing the highest strains first to account for extreme difficulty spikes
for (i, strain) in peak_iter.enumerate() {
let clamped = (i as f32 / Self::REDUCED_SECTION_COUNT as f32).clamp(0.0, 1.0) as f64;
let scale = (lerp(1.0, 10.0, clamped)).log10();
*strain *= lerp(Self::REDUCED_STRAIN_BASELINE, 1.0, scale);
}
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;
}
self.set_raw_difficulty_value(difficulty);
difficulty * Self::DIFFICULTY_MULTIPLER
}
fn strains(&self) -> &Vec<f64>;
fn set_raw_difficulty_value(&mut self, value: f64);
fn get_raw_difficulty_value(&self) -> f64;
fn count_difficult_strains(&mut self) -> f64 {
let difficulty_value = self.get_raw_difficulty_value();
if difficulty_value == 0.0 {
0.0
} else {
// * What would the top strain be if all strain values were identical
let consistent_top_strain = difficulty_value / 10.0;
let strains = self.strains();
// Use a weighted sum of all strains. Constants are arbitrary and give nice values
strains
.iter()
.map(|&s| 1.1 / (1.0 + (-10.0 * (s / consistent_top_strain - 0.88)).exp()))
.sum()
}
}
}
+539
View File
@@ -0,0 +1,539 @@
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 + 1];
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 += (next - curr).length() as f64;
calculated_len
});
cumulative_len.extend(length_iter);
if let Some(expected_len) = expected_len.filter(|&len| calculated_len != len) {
// * 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 pr = match Self::circular_arc_properties(a, b, c) {
Some(pr) => pr,
None => 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();
((pr.theta_range / divisor as f64).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 = (d_a.y as f64).atan2(d_a.x as f64);
let mut theta_end = (d_c.y as f64).atan2(d_c.x as f64);
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,
})
}
}
+61
View File
@@ -0,0 +1,61 @@
use super::OsuObject;
pub(crate) struct DifficultyObject<'h> {
pub(crate) base: &'h OsuObject,
pub(crate) prev: Option<(f32, f32)>, // (jump_dist, strain_time)
pub(crate) jump_dist: f32,
pub(crate) travel_dist: f32,
pub(crate) angle: Option<f32>,
pub(crate) delta: f32,
pub(crate) strain_time: f32,
}
impl<'h> DifficultyObject<'h> {
pub(crate) fn new(
base: &'h OsuObject,
prev: &OsuObject,
prev_vals: Option<(f32, f32)>, // (jump_dist, strain_time)
prev_prev: Option<OsuObject>,
clock_rate: f32,
scaling_factor: f32,
) -> Self {
let delta = (base.time - prev.time) / clock_rate;
let strain_time = delta.max(50.0);
let pos = base.pos;
let travel_dist = prev.travel_dist.unwrap_or(0.0);
let prev_cursor_pos = prev.end_pos;
let jump_dist = if base.is_spinner() {
0.0
} else {
((pos - prev_cursor_pos) * scaling_factor).length()
};
let angle = prev_prev.map(|prev_prev| {
let prev_prev_cursor_pos = prev_prev.end_pos;
let v1 = prev_prev_cursor_pos - prev.pos;
let v2 = pos - prev_cursor_pos;
let dot = v1.dot(v2);
let det = v1.x * v2.y - v1.y * v2.x;
det.atan2(dot).abs()
});
Self {
base,
prev: prev_vals,
jump_dist,
travel_dist,
angle,
delta,
strain_time,
}
}
}
+19
View File
@@ -0,0 +1,19 @@
mod curve;
use curve::Curve;
mod difficulty_object;
use difficulty_object::DifficultyObject;
mod osu_object;
use osu_object::OsuObject;
mod pp;
pub use pp::{OsuAttributeProvider, OsuPP};
mod skill;
use skill::Skill;
mod skill_kind;
use skill_kind::SkillKind;
pub mod stars;
+175
View File
@@ -0,0 +1,175 @@
use super::{curve::CurveBuffers, stars::OsuDifficultyAttributes, Curve};
use crate::{
parse::{HitObject, HitObjectKind, Pos2},
Beatmap,
};
const LEGACY_LAST_TICK_OFFSET: f32 = 36.0;
pub(crate) struct OsuObject {
pub(crate) time: f32,
pub(crate) pos: Pos2,
pub(crate) end_pos: Pos2,
// circle: Some(0.0) | slider: Some(_) | spinner: None
pub(crate) travel_dist: Option<f32>,
}
impl OsuObject {
pub(crate) fn new(
h: &HitObject,
map: &Beatmap,
radius: f32,
scaling_factor: f32,
ticks: &mut Vec<f32>,
attributes: &mut OsuDifficultyAttributes,
curve_bufs: &mut CurveBuffers,
) -> Option<Self> {
attributes.max_combo += 1; // hitcircle, slider head, or spinner
let obj = match &h.kind {
HitObjectKind::Circle => {
attributes.n_circles += 1;
Self {
time: h.start_time as f32,
pos: h.pos,
end_pos: h.pos,
travel_dist: Some(0.0),
}
}
HitObjectKind::Slider {
pixel_len,
repeats,
control_points,
..
} => {
let timing_point = map.timing_point_at(h.start_time);
let difficulty_point = map.difficulty_point_at(h.start_time).unwrap_or_default();
// Key values which are computed here
let mut end_pos = h.pos;
let mut travel_dist = 0.0;
let approx_follow_circle_radius = radius * 3.0;
let mut tick_distance = 100.0 * map.slider_mult as f32 / map.tick_rate as f32;
if map.version >= 8 {
tick_distance /= (100.0 / difficulty_point.slider_vel as f32)
.max(10.0)
.min(1000.0)
/ 100.0;
}
// Build the curve w.r.t. the curve points
let curve = Curve::new(control_points, *pixel_len, curve_bufs);
let pixel_len = pixel_len.unwrap_or(0.0) as f32;
let duration = *repeats as f32 * timing_point.beat_len as f32 * pixel_len
/ (map.slider_mult as f32 * difficulty_point.slider_vel as f32)
/ 100.0;
let span_duration = duration / *repeats as f32;
// Called on each slider object except for the head.
// Increases combo and adjusts `end_pos` and `travel_dist`
// w.r.t. the object position at the given time on the slider curve.
let mut compute_vertex = |time: f32| {
attributes.max_combo += 1;
let mut progress = (time - h.start_time as f32) / span_duration;
if progress % 2.0 >= 1.0 {
progress = 1.0 - progress % 1.0;
} else {
progress %= 1.0;
}
let curr_pos = h.pos + curve.position_at(progress as f64);
let diff = curr_pos - end_pos;
let mut dist = diff.length();
if dist > approx_follow_circle_radius {
dist -= approx_follow_circle_radius;
end_pos += diff.normalize() * dist;
travel_dist += dist;
}
};
let mut current_distance = tick_distance;
let time_add = duration * (tick_distance / (pixel_len * *repeats as f32));
let target = pixel_len - tick_distance / 8.0;
ticks.reserve((target / tick_distance) as usize);
// Tick of the first span
if current_distance < target {
for tick_idx in 1.. {
let time = h.start_time as f32 + time_add * tick_idx as f32;
compute_vertex(time);
ticks.push(time);
current_distance += tick_distance;
if current_distance >= target {
break;
}
}
}
// Other spans
if *repeats > 1 {
for repeat_id in 1..*repeats {
let time_offset = (duration / *repeats as f32) * repeat_id as f32;
// Reverse tick
compute_vertex(h.start_time as f32 + time_offset);
// Actual ticks
if repeat_id & 1 == 1 {
ticks.iter().rev().for_each(|&time| compute_vertex(time));
} else {
ticks.iter().for_each(|&time| compute_vertex(time));
}
}
}
// Slider tail
let final_span_idx = repeats.saturating_sub(1);
let final_span_start_time =
h.start_time as f32 + final_span_idx as f32 * span_duration;
let final_span_end_time = (h.start_time as f32 + duration / 2.0)
.max(final_span_start_time + span_duration - LEGACY_LAST_TICK_OFFSET);
compute_vertex(final_span_end_time);
ticks.clear();
travel_dist *= scaling_factor;
Self {
time: h.start_time as f32,
pos: h.pos,
end_pos,
travel_dist: Some(travel_dist),
}
}
HitObjectKind::Spinner { .. } => {
attributes.n_spinners += 1;
Self {
time: h.start_time as f32,
pos: h.pos,
end_pos: h.pos,
travel_dist: None,
}
}
HitObjectKind::Hold { .. } => return None,
};
Some(obj)
}
#[inline]
pub(crate) fn is_spinner(&self) -> bool {
self.travel_dist.is_none()
}
}
+656
View File
@@ -0,0 +1,656 @@
use super::stars::{stars, OsuDifficultyAttributes, OsuPerformanceAttributes};
use crate::{Beatmap, Mods};
/// Calculator for pp on osu!standard maps.
///
/// # Example
///
/// ```
/// # use rosu_pp::{OsuPP, Beatmap};
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
/// let attrs = OsuPP::new(&map)
/// .mods(8 + 64) // HDDT
/// .combo(1234)
/// .misses(1)
/// .accuracy(98.5) // should be set last
/// .calculate();
///
/// println!("PP: {} | Stars: {}", attrs.pp(), attrs.stars());
///
/// let next_result = OsuPP::new(&map)
/// .attributes(attrs) // 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)]
pub struct OsuPP<'m> {
map: &'m Beatmap,
attributes: Option<OsuDifficultyAttributes>,
mods: u32,
combo: Option<usize>,
acc: Option<f32>,
n300: Option<usize>,
n100: Option<usize>,
n50: Option<usize>,
n_misses: usize,
passed_objects: Option<usize>,
}
impl<'m> OsuPP<'m> {
/// Creates a new calculator for the given map.
#[inline]
pub fn new(map: &'m Beatmap) -> Self {
Self {
map,
attributes: None,
mods: 0,
combo: None,
acc: None,
n300: None,
n100: None,
n50: None,
n_misses: 0,
passed_objects: None,
}
}
/// [`OsuAttributeProvider`] is implemented by [`DifficultyAttributes`](crate::osu::DifficultyAttributes)
/// and by [`PpResult`](crate::PpResult) meaning you can give the
/// result of a star calculation or a pp 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 OsuAttributeProvider) -> Self {
if let Some(attributes) = attributes.attributes() {
self.attributes.replace(attributes);
}
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(mut self, mods: u32) -> Self {
self.mods = mods;
self
}
/// Specify the max combo of the play.
#[inline]
pub fn combo(mut self, combo: usize) -> Self {
self.combo.replace(combo);
self
}
/// Specify the amount of 300s of a play.
#[inline]
pub fn n300(mut self, n300: usize) -> Self {
self.n300.replace(n300);
self
}
/// Specify the amount of 100s of a play.
#[inline]
pub fn n100(mut self, n100: usize) -> Self {
self.n100.replace(n100);
self
}
/// Specify the amount of 50s of a play.
#[inline]
pub fn n50(mut self, n50: usize) -> Self {
self.n50.replace(n50);
self
}
/// Specify the amount of misses of a play.
#[inline]
pub fn misses(mut self, n_misses: usize) -> Self {
self.n_misses = n_misses;
self
}
/// Amount of passed objects for partial plays, e.g. a fail.
#[inline]
pub fn passed_objects(mut self, passed_objects: usize) -> Self {
self.passed_objects.replace(passed_objects);
self
}
/// Generate the hit results with respect to the given accuracy between `0` and `100`.
///
/// Be sure to set `misses` beforehand!
/// In case of a partial play, be also sure to set `passed_objects` beforehand!
pub fn accuracy(mut self, acc: f32) -> Self {
let n_objects = self.passed_objects.unwrap_or(self.map.hit_objects.len());
let acc = acc / 100.0;
if self.n100.or(self.n50).is_some() {
let mut n100 = self.n100.unwrap_or(0);
let mut n50 = self.n50.unwrap_or(0);
let placed_points = 2 * n100 + n50 + self.n_misses;
let missing_objects = n_objects - n100 - n50 - self.n_misses;
let missing_points =
((6.0 * acc * n_objects as f32).round() as usize).saturating_sub(placed_points);
let mut n300 = missing_objects.min(missing_points / 6);
n50 += missing_objects - n300;
if let Some(orig_n50) = self.n50.filter(|_| self.n100.is_none()) {
// Only n50s were changed, try to load some off again onto n100s
let difference = n50 - orig_n50;
let n = n300.min(difference / 4);
n300 -= n;
n100 += 5 * n;
n50 -= 4 * n;
}
self.n300.replace(n300);
self.n100.replace(n100);
self.n50.replace(n50);
} else {
let misses = self.n_misses.min(n_objects);
let target_total = (acc * n_objects as f32 * 6.0).round() as usize;
let delta = target_total - (n_objects - misses);
let mut n300 = delta / 5;
let mut n100 = delta % 5;
let mut n50 = n_objects - n300 - n100 - misses;
// Sacrifice n300s to transform n50s into n100s
let n = n300.min(n50 / 4);
n300 -= n;
n100 += 5 * n;
n50 -= 4 * n;
self.n300.replace(n300);
self.n100.replace(n100);
self.n50.replace(n50);
}
let acc = (6 * self.n300.unwrap() + 2 * self.n100.unwrap() + self.n50.unwrap()) as f32
/ (6 * n_objects) as f32;
self.acc.replace(acc);
self
}
fn assert_hitresults(&mut self) {
if self.acc.is_none() {
let n_objects = self.passed_objects.unwrap_or(self.map.hit_objects.len());
let remaining = n_objects
.saturating_sub(self.n300.unwrap_or(0))
.saturating_sub(self.n100.unwrap_or(0))
.saturating_sub(self.n50.unwrap_or(0))
.saturating_sub(self.n_misses);
if remaining > 0 {
if self.n300.is_none() {
self.n300.replace(remaining);
self.n100.get_or_insert(0);
self.n50.get_or_insert(0);
} else if self.n100.is_none() {
self.n100.replace(remaining);
self.n50.get_or_insert(0);
} else if self.n50.is_none() {
self.n50.replace(remaining);
} else {
*self.n300.as_mut().unwrap() += remaining;
}
} else {
self.n300.get_or_insert(0);
self.n100.get_or_insert(0);
self.n50.get_or_insert(0);
}
let numerator = self.n50.unwrap() + self.n100.unwrap() * 2 + self.n300.unwrap() * 6;
self.acc.replace(numerator as f32 / n_objects as f32 / 6.0);
}
}
/// Returns an object which contains the pp and [`DifficultyAttributes`](crate::osu::DifficultyAttributes)
/// containing stars and other attributes.
pub fn calculate(mut self) -> OsuPerformanceAttributes {
if self.attributes.is_none() {
let attributes = stars(self.map, self.mods, self.passed_objects);
self.attributes.replace(attributes);
}
// Make sure the hitresults and accuracy are set
self.assert_hitresults();
let total_hits = self.total_hits() as f32;
let mut multiplier = 1.09;
let effective_miss_count = self.calculate_effective_miss_count();
// SO penalty
if self.mods.so() {
multiplier *=
1.0 - (self.attributes.as_ref().unwrap().n_spinners as f32 / total_hits).powf(0.85);
}
let mut aim_value = self.compute_aim_value(total_hits, effective_miss_count);
let speed_value = self.compute_speed_value(total_hits, effective_miss_count);
let acc_value = self.compute_accuracy_value(total_hits);
let mut acc_depression = 1.0;
let difficulty = self.attributes.as_ref().unwrap();
let streams_nerf =
((difficulty.aim_strain / difficulty.speed_strain) * 100.0).round() / 100.0;
if streams_nerf < 1.09 {
let acc_factor = (1.0 - self.acc.unwrap()).abs();
acc_depression = (0.86 - acc_factor).max(0.5);
if acc_depression > 0.0 {
aim_value *= acc_depression;
}
}
let nodt_bonus = match !self.mods.change_speed() {
true => 1.02,
false => 1.0,
};
let mut pp = (aim_value.powf(1.185 * nodt_bonus)
+ speed_value.powf(0.83 * acc_depression)
+ acc_value.powf(1.14 * nodt_bonus))
.powf(1.0 / 1.1)
* multiplier;
if self.mods.dt() && self.mods.hr() {
pp *= 1.025;
}
if self.map.creator == "gwb" || self.map.creator == "Plasma" {
pp *= 0.9;
}
pp *= match self.map.beatmap_id {
// Louder than steel [ok this is epic]
1808605 => 0.85,
// over the top [Above the stars]
1821147 => 0.70,
// Just press F [Parkour's ok this is epic]
1844776 => 0.64,
// Hardware Store [skyapple mode]
1777768 => 0.90,
// Akatsuki compilation [ok this is akatsuki]
1962833 => {
pp *= 0.885;
if self.mods.dt() {
0.83
} else {
1.0
}
}
// Songs Compilation [Marathon]
2403677 => 0.85,
// Songs Compilation [Remembrance]
2174272 => 0.85,
// Apocalypse 1992 [Universal Annihilation]
2382377 => 0.85,
_ => 1.0,
};
OsuPerformanceAttributes {
difficulty: self.attributes.unwrap(),
pp_acc: 0.0,
pp_aim: aim_value as f64,
pp_flashlight: 0.0,
pp_speed: speed_value as f64,
pp: pp as f64,
effective_miss_count: effective_miss_count as f64,
}
}
fn compute_aim_value(&self, total_hits: f32, effective_miss_count: f32) -> f32 {
let attributes = self.attributes.as_ref().unwrap();
// TD penalty
let raw_aim = if self.mods.td() {
attributes.aim_strain.powf(0.8) as f32
} else {
attributes.aim_strain as f32
};
let mut aim_value = (5.0 * (raw_aim / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
// Longer maps are worth more
let len_bonus = 0.88
+ 0.4 * (total_hits / 1400.0).min(1.0)
+ (total_hits > 1400.0) as u8 as f32 * 0.2 * (total_hits / 1400.0).log10();
aim_value *= len_bonus;
// Penalize misses
if effective_miss_count > 0.0 {
let miss_penalty = self.calculate_miss_penalty(
effective_miss_count,
attributes.aim_difficult_strain_count as f32,
);
aim_value *= miss_penalty;
}
// AR bonus
let mut ar_factor = if attributes.ar > 10.33 {
0.3 * (attributes.ar - 10.33)
} else {
0.0
};
if attributes.ar < 8.0 {
ar_factor = 0.025 * (8.0 - attributes.ar);
}
aim_value *= 1.0 + ar_factor as f32 * len_bonus;
// HD bonus
if self.mods.hd() {
let ar_capped = attributes.ar.min(10.67).max(0.0);
let hd_bonus_factor = (10.67 - ar_capped) as f32;
aim_value *= 1.0 + 0.075 * hd_bonus_factor;
}
// FL bonus
if self.mods.fl() {
aim_value *= 1.0
+ 0.2 * (total_hits / 200.0).min(1.0)
+ (total_hits > 200.0) as u8 as f32
* 0.15
* ((total_hits - 200.0) / 300.0).min(1.0)
+ (total_hits > 500.0) as u8 as f32 * (total_hits - 500.0) / 2500.0;
}
// EZ bonus
if self.mods.ez() {
let mut base_buff = 1.08_f32;
if attributes.ar <= 8.0 {
base_buff += (7.0 - attributes.ar as f32) / 100.0;
}
aim_value *= base_buff;
}
// Precision buff (reading)
if attributes.cs > 5.58 {
aim_value *= ((attributes.cs as f32 - 5.46).powf(1.8) + 1.0).powf(0.03);
}
// Scale with accuracy
aim_value *= 0.3 + self.acc.unwrap() / 2.0;
aim_value *= 0.98 + attributes.od as f32 * attributes.od as f32 / 2500.0;
aim_value
}
fn compute_speed_value(&self, total_hits: f32, effective_miss_count: f32) -> f32 {
let attributes = self.attributes.as_ref().unwrap();
let mut speed_value =
(5.0 * (attributes.speed_strain as f32 / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
// Longer maps are worth more
let len_bonus = 0.88
+ 0.4 * (total_hits / 1600.0).min(1.0)
+ (total_hits > 1600.0) as u8 as f32 * 0.5 * (total_hits / 1600.0).log10();
speed_value *= len_bonus;
// Penalize misses
if effective_miss_count > 0.0 {
let miss_penalty = self.calculate_miss_penalty(
effective_miss_count,
attributes.speed_difficult_strain_count as f32,
);
speed_value *= miss_penalty;
}
// AR bonus
if attributes.ar > 10.33 {
let mut ar_factor = if attributes.ar > 10.33 {
0.3 * (attributes.ar - 10.33)
} else {
0.0
};
if attributes.ar < 8.0 {
ar_factor = 0.025 * (8.0 - attributes.ar);
}
speed_value *= 1.0 + ar_factor as f32 * len_bonus;
}
// HD bonus
if self.mods.hd() {
speed_value *= 1.0 + 0.05 * (11.0 - attributes.ar) as f32;
}
// Scaling the speed value with accuracy and OD
speed_value *= (0.93 + attributes.od as f32 * attributes.od as f32 / 750.0)
* self
.acc
.unwrap()
.powf((14.5 - attributes.od.max(8.0) as f32) / 2.0);
speed_value *= 0.98_f32.powf(match (self.n50.unwrap() as f32) < total_hits / 500.0 {
true => 0.0,
false => self.n50.unwrap() as f32 - total_hits / 500.0,
});
speed_value
}
fn compute_accuracy_value(&self, total_hits: f32) -> f32 {
let attributes = self.attributes.as_ref().unwrap();
let n_circles = attributes.n_circles as f32;
let n300 = self.n300.unwrap_or(0) as f32;
let n100 = self.n100.unwrap_or(0) as f32;
let n50 = self.n50.unwrap_or(0) as f32;
let better_acc_percentage = (n_circles > 0.0) as u8 as f32
* (((n300 - (total_hits - n_circles)) * 6.0 + n100 * 2.0 + n50) / (n_circles * 6.0))
.max(0.0);
let mut acc_value =
1.52163_f32.powf(attributes.od as f32) * better_acc_percentage.powi(24) * 2.83;
// Bonus for many hitcircles
acc_value *= ((n_circles as f32 / 1000.0).powf(0.3)).min(1.15);
// HD bonus
if self.mods.hd() {
acc_value *= 1.08;
}
// FL bonus
if self.mods.fl() {
acc_value *= 1.02;
}
acc_value
}
#[inline]
fn total_hits(&self) -> usize {
let n_objects = self.passed_objects.unwrap_or(self.map.hit_objects.len());
(self.n300.unwrap_or(0) + self.n100.unwrap_or(0) + self.n50.unwrap_or(0) + self.n_misses)
.min(n_objects)
}
#[inline]
fn calculate_miss_penalty(
&self,
effective_miss_count: f32,
difficult_strain_count: f32,
) -> f32 {
0.96 / ((effective_miss_count / (4.0 * difficult_strain_count.ln().powf(0.94))) + 1.0)
}
#[inline]
fn calculate_effective_miss_count(&self) -> f32 {
let mut combo_based_miss_count = 0.0;
let attributes = self.attributes.as_ref().unwrap();
let combo = self.combo.unwrap_or(attributes.max_combo) as f32;
let n100 = self.n100.unwrap_or(0) as f32;
let n50 = self.n50.unwrap_or(0) as f32;
if attributes.n_sliders > 0 {
let fc_threshold = attributes.max_combo as f32 - (0.2 * attributes.n_sliders as f32);
if combo < fc_threshold {
combo_based_miss_count = fc_threshold / combo.max(1.0);
}
}
combo_based_miss_count = combo_based_miss_count.min(n100 + n50 + self.n_misses as f32);
combo_based_miss_count.max(self.n_misses as f32)
}
}
/// Provides attributes for an osu! beatmap.
pub trait OsuAttributeProvider {
/// Returns the attributes of the map.
fn attributes(self) -> Option<OsuDifficultyAttributes>;
}
impl OsuAttributeProvider for OsuDifficultyAttributes {
#[inline]
fn attributes(self) -> Option<OsuDifficultyAttributes> {
Some(self)
}
}
impl OsuAttributeProvider for OsuPerformanceAttributes {
#[inline]
fn attributes(self) -> Option<OsuDifficultyAttributes> {
Some(self.difficulty)
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::Beatmap;
#[test]
fn osu_only_accuracy() {
let map = Beatmap::default();
let total_objects = 1234;
let target_acc = 97.5;
let calculator = OsuPP::new(&map)
.passed_objects(total_objects)
.accuracy(target_acc);
let numerator = 6 * calculator.n300.unwrap_or(0)
+ 2 * calculator.n100.unwrap_or(0)
+ calculator.n50.unwrap_or(0);
let denominator = 6 * total_objects;
let acc = 100.0 * numerator as f32 / denominator as f32;
assert!(
(target_acc - acc).abs() < 1.0,
"Expected: {} | Actual: {}",
target_acc,
acc
);
}
#[test]
fn osu_accuracy_and_n50() {
let map = Beatmap::default();
let total_objects = 1234;
let target_acc = 97.5;
let n50 = 30;
let calculator = OsuPP::new(&map)
.passed_objects(total_objects)
.n50(n50)
.accuracy(target_acc);
assert!(
(calculator.n50.unwrap() as i32 - n50 as i32).abs() <= 4,
"Expected: {} | Actual: {}",
n50,
calculator.n50.unwrap()
);
let numerator = 6 * calculator.n300.unwrap_or(0)
+ 2 * calculator.n100.unwrap_or(0)
+ calculator.n50.unwrap_or(0);
let denominator = 6 * total_objects;
let acc = 100.0 * numerator as f32 / denominator as f32;
assert!(
(target_acc - acc).abs() < 1.0,
"Expected: {} | Actual: {}",
target_acc,
acc
);
}
#[test]
fn osu_missing_objects() {
let map = Beatmap::default();
let total_objects = 1234;
let n300 = 1000;
let n100 = 200;
let n50 = 30;
let mut calculator = OsuPP::new(&map)
.passed_objects(total_objects)
.n300(n300)
.n100(n100)
.n50(n50);
calculator.assert_hitresults();
let n_objects = calculator.n300.unwrap()
+ calculator.n100.unwrap()
+ calculator.n50.unwrap()
+ calculator.n_misses;
assert_eq!(
total_objects, n_objects,
"Expected: {} | Actual: {}",
total_objects, n_objects
);
}
}
+112
View File
@@ -0,0 +1,112 @@
use super::{DifficultyObject, SkillKind};
use std::cmp::Ordering;
const SPEED_SKILL_MULTIPLIER: f32 = 1400.0;
const SPEED_STRAIN_DECAY_BASE: f32 = 0.3;
const AIM_SKILL_MULTIPLIER: f32 = 26.25;
const AIM_STRAIN_DECAY_BASE: f32 = 0.15;
const DECAY_WEIGHT: f32 = 0.9;
pub(crate) struct Skill {
current_strain: f32,
current_section_peak: f32,
kind: SkillKind,
pub(crate) strain_peaks: Vec<f32>,
prev_time: Option<f32>,
pub(crate) object_strains: Vec<f32>,
}
impl Skill {
#[inline]
pub(crate) fn new(kind: SkillKind) -> Self {
Self {
current_strain: 1.0,
current_section_peak: 1.0,
kind,
strain_peaks: Vec::with_capacity(128),
prev_time: None,
object_strains: Vec::new(),
}
}
#[inline]
pub(crate) fn save_current_peak(&mut self) {
self.strain_peaks.push(self.current_section_peak);
}
#[inline]
pub(crate) fn start_new_section_from(&mut self, time: f32) {
self.current_section_peak = self.peak_strain(time - self.prev_time.unwrap());
}
#[inline]
pub(crate) fn process(&mut self, current: &DifficultyObject<'_>) {
self.current_strain *= self.strain_decay(current.delta);
self.current_strain += self.kind.strain_value_of(current) * self.skill_multiplier();
self.object_strains.push(self.current_strain);
self.current_section_peak = self.current_section_peak.max(self.current_strain);
self.prev_time.replace(current.base.time);
}
pub(crate) fn difficulty_value(&mut self) -> f32 {
let mut difficulty = 0.0;
let mut weight = 1.0;
self.strain_peaks
.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
for &strain in self.strain_peaks.iter() {
difficulty += strain * weight;
weight *= DECAY_WEIGHT;
}
difficulty
}
pub(crate) fn count_difficult_strains(&mut self) -> f64 {
let top_strain = self
.object_strains
.iter()
.fold(f64::NEG_INFINITY, |prev, curr| prev.max(*curr as f64));
self.object_strains
.iter()
.map(|strain| (strain / top_strain as f32).powi(4))
.sum::<f32>() as f64
}
#[inline]
fn skill_multiplier(&self) -> f32 {
match self.kind {
SkillKind::Aim => AIM_SKILL_MULTIPLIER,
SkillKind::Speed => SPEED_SKILL_MULTIPLIER,
}
}
#[inline]
fn strain_decay_base(&self) -> f32 {
match self.kind {
SkillKind::Aim => AIM_STRAIN_DECAY_BASE,
SkillKind::Speed => SPEED_STRAIN_DECAY_BASE,
}
}
#[inline]
fn peak_strain(&self, delta_time: f32) -> f32 {
self.current_strain * self.strain_decay(delta_time)
}
#[inline]
fn strain_decay(&self, ms: f32) -> f32 {
self.strain_decay_base().powf(ms / 1000.0)
}
}
+100
View File
@@ -0,0 +1,100 @@
use super::DifficultyObject;
const SINGLE_SPACING_TRESHOLD: f32 = 125.0;
const SPEED_ANGLE_BONUS_BEGIN: f32 = 5.0 * std::f32::consts::FRAC_PI_6;
const PI_OVER_4: f32 = std::f32::consts::FRAC_PI_4;
const PI_OVER_2: f32 = std::f32::consts::FRAC_PI_2;
const MIN_SPEED_BONUS: f32 = 75.0;
const MAX_SPEED_BONUS: f32 = 45.0;
const SPEED_BALANCING_FACTOR: f32 = 40.0;
const AIM_ANGLE_BONUS_BEGIN: f32 = std::f32::consts::FRAC_PI_3;
const TIMING_THRESHOLD: f32 = 107.0;
#[derive(Copy, Clone)]
pub(crate) enum SkillKind {
Aim,
Speed,
}
impl SkillKind {
pub(crate) fn strain_value_of(self, current: &DifficultyObject<'_>) -> f32 {
match self {
Self::Aim => {
if current.base.is_spinner() {
return 0.0;
}
let mut result = 0.0;
if let Some((prev_jump_dist, prev_strain_time)) = current.prev {
if let Some(angle) = current.angle.filter(|a| *a > AIM_ANGLE_BONUS_BEGIN) {
let scale = 90.0;
let angle_bonus = (((angle - AIM_ANGLE_BONUS_BEGIN).sin()).powi(2)
* (prev_jump_dist - scale).max(0.0)
* (current.jump_dist - scale).max(0.0))
.sqrt();
result = 1.5 * apply_diminishing_exp(angle_bonus.max(0.0))
/ (TIMING_THRESHOLD).max(prev_strain_time)
}
}
let jump_dist_exp = apply_diminishing_exp(current.jump_dist);
let travel_dist_exp = apply_diminishing_exp(current.travel_dist);
let dist_exp =
jump_dist_exp + travel_dist_exp + (travel_dist_exp * jump_dist_exp).sqrt();
(result + dist_exp / (current.strain_time).max(TIMING_THRESHOLD))
.max(dist_exp / current.strain_time)
}
Self::Speed => {
if current.base.is_spinner() {
return 0.0;
}
let dist = SINGLE_SPACING_TRESHOLD.min(current.travel_dist + current.jump_dist);
let delta_time = MAX_SPEED_BONUS.max(current.delta);
let mut speed_bonus = 1.0;
if delta_time < MIN_SPEED_BONUS {
let exp_base = (MIN_SPEED_BONUS - delta_time) / SPEED_BALANCING_FACTOR;
speed_bonus += exp_base * exp_base;
}
let mut angle_bonus = 1.0;
if let Some(angle) = current.angle.filter(|a| *a < SPEED_ANGLE_BONUS_BEGIN) {
let exp_base = (1.5 * (SPEED_ANGLE_BONUS_BEGIN - angle)).sin();
angle_bonus = 1.0 + exp_base * exp_base / 3.57;
if angle < PI_OVER_2 {
angle_bonus = 1.28;
if dist < 90.0 && angle < PI_OVER_4 {
angle_bonus += (1.0 - angle_bonus) * ((90.0 - dist) / 10.0).min(1.0);
} else if dist < 90.0 {
angle_bonus += (1.0 - angle_bonus)
* ((90.0 - dist) / 10.0).min(1.0)
* ((PI_OVER_2 - angle) / PI_OVER_4).sin();
}
}
}
(1.0 + (speed_bonus - 1.0) * 0.75)
* angle_bonus
* (0.95 + speed_bonus * (dist / SINGLE_SPACING_TRESHOLD).powf(3.5))
/ current.strain_time
}
}
}
}
#[inline]
fn apply_diminishing_exp(val: f32) -> f32 {
val.powf(0.99)
}
+185
View File
@@ -0,0 +1,185 @@
//! The positional offset of notes created by stack leniency is not considered.
//! This means the jump distance inbetween notes might be slightly off, resulting in small inaccuracies.
//! Since calculating these offsets is relatively expensive though, this version is faster than `all_included`.
use super::{curve::CurveBuffers, DifficultyObject, OsuObject, Skill, SkillKind};
use crate::Beatmap;
const OBJECT_RADIUS: f32 = 64.0;
const SECTION_LEN: f32 = 400.0;
const DIFFICULTY_MULTIPLIER: f32 = 0.0675;
const NORMALIZED_RADIUS: f32 = 52.0;
/// Star calculation for osu!standard maps.
///
/// Slider paths are considered but stack leniency is ignored.
/// As most maps don't even make use of leniency and even if,
/// it has generally little effect on stars, the results are close to perfect.
/// This version is considerably more efficient than `all_included` since
/// processing stack leniency is relatively expensive.
///
/// In case of a partial play, e.g. a fail, one can specify the amount of passed objects.
pub fn stars(map: &Beatmap, mods: u32, passed_objects: Option<usize>) -> OsuDifficultyAttributes {
let take = passed_objects.unwrap_or(map.hit_objects.len());
let map_attributes = map.attributes().mods(mods).build();
let mut diff_attributes = OsuDifficultyAttributes {
ar: map_attributes.ar,
od: map_attributes.od,
cs: map_attributes.cs,
..Default::default()
};
if take < 2 {
return diff_attributes;
}
let section_len = SECTION_LEN * map_attributes.clock_rate as f32;
let radius = OBJECT_RADIUS * (1.0 - 0.7 * (map_attributes.cs as f32 - 5.0) / 5.0) / 2.0;
let mut scaling_factor = NORMALIZED_RADIUS / radius;
if radius < 30.05 {
let small_circle_bonus = ((30.05 - radius) / 50.0).powf(1.1) * 1.45;
scaling_factor *= 1.0 + small_circle_bonus;
}
let mut ticks_buf = Vec::new();
let mut curve_bufs = CurveBuffers::default();
let mut hit_objects = map.hit_objects.iter().take(take).filter_map(|h| {
OsuObject::new(
h,
map,
radius,
scaling_factor,
&mut ticks_buf,
&mut diff_attributes,
&mut curve_bufs,
)
});
let mut aim = Skill::new(SkillKind::Aim);
let mut speed = Skill::new(SkillKind::Speed);
// First object has no predecessor and thus no strain, handle distinctly
let mut current_section_end =
(map.hit_objects[0].start_time as f32 / section_len).ceil() * section_len;
let mut prev_prev = None;
let mut prev = hit_objects.next().unwrap();
let mut prev_vals = None;
// Handle second object separately to remove later if-branching
let curr = hit_objects.next().unwrap();
let h = DifficultyObject::new(
&curr,
&prev,
prev_vals,
prev_prev,
map_attributes.clock_rate as f32,
scaling_factor,
);
while h.base.time as f32 > current_section_end {
current_section_end += section_len;
}
aim.process(&h);
speed.process(&h);
prev_prev = Some(prev);
prev_vals = Some((h.jump_dist, h.strain_time));
prev = curr;
// Handle all other objects
for curr in hit_objects {
let h = DifficultyObject::new(
&curr,
&prev,
prev_vals,
prev_prev,
map_attributes.clock_rate as f32,
scaling_factor,
);
while h.base.time as f32 > current_section_end {
aim.save_current_peak();
aim.start_new_section_from(current_section_end);
speed.save_current_peak();
speed.start_new_section_from(current_section_end);
current_section_end += section_len;
}
aim.process(&h);
speed.process(&h);
prev_prev = Some(prev);
prev_vals = Some((h.jump_dist, h.strain_time));
prev = curr;
}
aim.save_current_peak();
speed.save_current_peak();
let aim_strain_raw = (aim.difficulty_value().sqrt() as f64) * (DIFFICULTY_MULTIPLIER as f64);
let speed_strain = (speed.difficulty_value().sqrt() as f64) * (DIFFICULTY_MULTIPLIER as f64);
let aim_difficult_strain_count = aim.count_difficult_strains() as f64;
let speed_difficult_strain_count = speed.count_difficult_strains() as f64;
let ar = diff_attributes.ar;
let ar_multiplier: f64 = if ar <= 9.0 {
1.25
} else if ar <= 10.67 {
1.25 - (ar - 9.0) * (0.25 / 1.67)
} else {
1.0 - (ar - 10.67) * (0.05 / 0.33)
};
let intensity_gate = ((aim_difficult_strain_count - 2.0) / 5.0).clamp(0.0, 1.0);
let final_ar_bonus = 1.0 + (ar_multiplier - 1.0) * intensity_gate;
let aim_strain = aim_strain_raw * final_ar_bonus;
let stars = aim_strain + speed_strain + (aim_strain - speed_strain).abs() / 2.0;
diff_attributes.stars = stars;
diff_attributes.speed_strain = speed_strain;
diff_attributes.aim_strain = aim_strain;
diff_attributes.aim_difficult_strain_count = aim_difficult_strain_count;
diff_attributes.speed_difficult_strain_count = speed_difficult_strain_count;
diff_attributes.n_sliders = map.n_sliders as usize;
diff_attributes
}
#[derive(Clone, Debug, Default)]
pub struct OsuDifficultyAttributes {
pub aim_strain: f64,
pub speed_strain: f64,
pub ar: f64,
pub od: f64,
pub hp: f64,
pub cs: f64,
pub n_circles: usize,
pub n_sliders: usize,
pub n_spinners: usize,
pub stars: f64,
pub max_combo: usize,
pub aim_difficult_strain_count: f64,
pub speed_difficult_strain_count: f64,
}
#[derive(Clone, Debug)]
pub struct OsuPerformanceAttributes {
pub difficulty: OsuDifficultyAttributes,
pub pp: f64,
pub pp_acc: f64,
pub pp_aim: f64,
pub pp_flashlight: f64,
pub pp_speed: f64,
pub effective_miss_count: f64,
}
+70
View File
@@ -0,0 +1,70 @@
use std::{error::Error as StdError, fmt, io::Error as IoError, num::ParseFloatError};
/// `Result<_, ParseError>`
pub type ParseResult<T> = Result<T, ParseError>;
/// Anything that could go wrong while parsing a [`Beatmap`](crate::Beatmap).
#[derive(Debug)]
#[allow(clippy::upper_case_acronyms)]
pub enum ParseError {
/// Some IO operation failed.
IoError(IoError),
/// The initial data of an `.osu` file was incorrect.
IncorrectFileHeader,
/// Line in `.osu` was unexpectedly not of the form `key:value`.
BadLine,
/// Line in `.osu` that contains a slider was not in the proper format.
InvalidCurvePoints,
/// Expected a decimal number, got something else.
InvalidDecimalNumber,
/// Failed to parse game mode.
InvalidMode,
/// Expected an additional field.
MissingField(&'static str),
/// Failed to recognized specified type for hitobjects.
UnknownHitObjectKind,
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::IoError(_) => f.write_str("IO error"),
Self::IncorrectFileHeader => {
write!(f, "expected `osu file format v` at file begin")
}
Self::BadLine => f.write_str("line not in `Key:Value` pattern"),
Self::InvalidCurvePoints => f.write_str("invalid curve point"),
Self::InvalidDecimalNumber => f.write_str("invalid float number"),
Self::InvalidMode => f.write_str("invalid mode"),
Self::MissingField(field) => write!(f, "missing field `{}`", field),
Self::UnknownHitObjectKind => f.write_str("unsupported hitobject kind"),
}
}
}
impl StdError for ParseError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
match self {
Self::IoError(inner) => Some(inner),
Self::IncorrectFileHeader => None,
Self::BadLine => None,
Self::InvalidCurvePoints => None,
Self::InvalidDecimalNumber => None,
Self::InvalidMode => None,
Self::MissingField(_) => None,
Self::UnknownHitObjectKind => None,
}
}
}
impl From<IoError> for ParseError {
fn from(other: IoError) -> Self {
Self::IoError(other)
}
}
impl From<ParseFloatError> for ParseError {
fn from(_: ParseFloatError) -> Self {
Self::InvalidDecimalNumber
}
}
+83
View File
@@ -0,0 +1,83 @@
use std::cmp::Ordering;
use super::{PathControlPoint, Pos2};
/// "Intermediate" hitobject created through parsing.
/// Each mode will handle them differently.
#[derive(Clone, Debug, PartialEq)]
pub struct HitObject {
/// The position of the object.
pub pos: Pos2,
/// The start time of the object.
pub start_time: f64,
/// The type of the object.
pub kind: HitObjectKind,
}
impl HitObject {
/// The end time of the object.
#[inline]
pub fn end_time(&self) -> f64 {
match &self.kind {
HitObjectKind::Circle => self.start_time,
// incorrect, only called in mania which has no sliders though
HitObjectKind::Slider { .. } => self.start_time,
HitObjectKind::Spinner { end_time } => *end_time,
HitObjectKind::Hold { end_time, .. } => *end_time,
}
}
/// If the object is a circle.
#[inline]
pub fn is_circle(&self) -> bool {
matches!(self.kind, HitObjectKind::Circle)
}
/// If the object is a slider.
#[inline]
pub fn is_slider(&self) -> bool {
matches!(self.kind, HitObjectKind::Slider { .. })
}
/// If the object is a spinner.
#[inline]
pub fn is_spinner(&self) -> bool {
matches!(self.kind, HitObjectKind::Spinner { .. })
}
}
impl PartialOrd for HitObject {
#[inline]
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.start_time.partial_cmp(&other.start_time)
}
}
/// Further data related to specific object types.
#[derive(Clone, Debug, PartialEq)]
pub enum HitObjectKind {
/// A circle object.
Circle,
/// A full slider object.
Slider {
/// Total length of the slider in pixels.
pixel_len: Option<f64>,
/// The amount of repeat points of the slider.
repeats: usize,
/// The control points of the slider.
control_points: Vec<PathControlPoint>,
/// Sample sounds for the slider head, end, and repeat points.
/// Required for converts.
edge_sounds: Vec<u8>,
},
/// A spinner object.
Spinner {
/// The end time of the spinner.
end_time: f64,
},
/// A hold note object for osu!mania.
Hold {
/// The end time of the hold object.
end_time: f64,
},
}
+34
View File
@@ -0,0 +1,34 @@
/// Abstract type to define hitsounds.
#[allow(missing_docs)]
pub trait HitSound {
const HITSOUND_WHISTLE: u8 = 1 << 1;
const HITSOUND_FINISH: u8 = 1 << 2;
const HITSOUND_CLAP: u8 = 1 << 3;
fn normal(self) -> bool;
fn whistle(self) -> bool;
fn finish(self) -> bool;
fn clap(self) -> bool;
}
impl HitSound for u8 {
#[inline]
fn normal(self) -> bool {
self == 0
}
#[inline]
fn whistle(self) -> bool {
self & Self::HITSOUND_WHISTLE > 0
}
#[inline]
fn finish(self) -> bool {
self & Self::HITSOUND_FINISH > 0
}
#[inline]
fn clap(self) -> bool {
self & Self::HITSOUND_CLAP > 0
}
}
+1137
View File
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
use std::fmt;
use std::ops;
/// Simple (x, y) coordinate / vector
#[derive(Clone, Copy, Default, PartialEq)]
pub struct Pos2 {
/// Position on the x-axis.
pub x: f32,
/// Position on the y-axis.
pub y: f32,
}
impl Pos2 {
/// Return the null vector.
#[inline]
pub fn zero() -> Self {
Self::default()
}
/// Return a position with both coordinates on the given value.
#[inline]
pub fn new(value: f32) -> Self {
Self { x: value, y: value }
}
/// Return the position's length squared.
#[inline]
pub fn length_squared(&self) -> f32 {
self.dot(*self)
}
/// Return the position's length.
#[inline]
pub fn length(&self) -> f32 {
((self.x * self.x + self.y * self.y) as f64).sqrt() as f32
}
/// Return the dot product.
#[inline]
pub fn dot(&self, other: Self) -> f32 {
(self.x * other.x) + (self.y * other.y)
}
/// Return the distance to another position.
#[inline]
pub fn distance(&self, other: Self) -> f32 {
(*self - other).length()
}
/// Normalize the coordinates with respect to the vector's length.
#[inline]
pub fn normalize(mut self) -> Pos2 {
let scale = self.length().recip();
self.x *= scale;
self.y *= scale;
self
}
}
impl ops::Add<Pos2> for Pos2 {
type Output = Self;
#[inline]
fn add(self, rhs: Self) -> Self::Output {
Self {
x: self.x + rhs.x,
y: self.y + rhs.y,
}
}
}
impl ops::Sub<Pos2> for Pos2 {
type Output = Self;
#[inline]
fn sub(self, rhs: Self) -> Self::Output {
Self {
x: self.x - rhs.x,
y: self.y - rhs.y,
}
}
}
impl ops::Mul<f32> for Pos2 {
type Output = Self;
#[inline]
fn mul(self, rhs: f32) -> Self::Output {
Self {
x: self.x * rhs,
y: self.y * rhs,
}
}
}
impl ops::Div<f32> for Pos2 {
type Output = Self;
#[inline]
fn div(self, rhs: f32) -> Self::Output {
Self {
x: self.x / rhs,
y: self.y / rhs,
}
}
}
impl ops::AddAssign for Pos2 {
fn add_assign(&mut self, other: Self) {
*self = Self {
x: self.x + other.x,
y: self.y + other.y,
};
}
}
impl fmt::Display for Pos2 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(self, f)
}
}
impl fmt::Debug for Pos2 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
+261
View File
@@ -0,0 +1,261 @@
use std::io::{Error as IoError, ErrorKind as IoErrorKind};
#[cfg(not(any(feature = "async_std", feature = "async_tokio")))]
use std::io::{BufRead, BufReader, Read};
#[cfg(feature = "async_tokio")]
use tokio::io::{AsyncBufReadExt, AsyncRead as Read, BufReader};
#[cfg(feature = "async_std")]
use async_std::io::{prelude::BufReadExt, BufReader, Read};
use crate::ParseError;
#[derive(Eq, PartialEq)]
enum Encoding {
Utf8,
Utf16,
}
pub(crate) struct FileReader<R> {
buf: Vec<u8>,
encoding: Encoding,
#[cfg(feature = "async_std")]
inner: BufReader<R>,
#[cfg(feature = "async_tokio")]
inner: BufReader<R>,
#[cfg(not(any(feature = "async_std", feature = "async_tokio")))]
inner: BufReader<R>,
}
macro_rules! read_until {
($self:expr) => {{
#[cfg(not(any(feature = "async_std", feature = "async_tokio")))]
{
$self.inner.read_until(b'\n', &mut $self.buf)
}
#[cfg(any(feature = "async_std", feature = "async_tokio"))]
{
$self.inner.read_until(b'\n', &mut $self.buf).await
}
}};
}
#[allow(unused_macro_rules)]
macro_rules! impl_reader {
() => {
impl<R: Read> FileReader<R> {
impl_reader!(@NEW);
impl_reader!(@NEXT_LINE);
}
};
(async) => {
impl<R: Read + Unpin> FileReader<R> {
impl_reader!(@NEW);
impl_reader!(@ASYNC NEXT_LINE);
}
};
(@NEW) => {
pub(crate) fn new(src: R) -> Self {
Self {
buf: Vec::with_capacity(32),
encoding: Encoding::Utf8,
inner: BufReader::new(src),
}
}
};
(@NEXT_LINE) => {
pub(crate) fn next_line(&mut self) -> Result<usize, IoError> {
impl_reader!(@NEXT_LINE_BODY, self);
}
};
(@ASYNC NEXT_LINE) => {
pub(crate) async fn next_line(&mut self) -> Result<usize, IoError> {
impl_reader!(@NEXT_LINE_BODY, self);
}
};
(@NEXT_LINE_BODY, $self:ident) => {
loop {
$self.buf.clear();
let bytes = read_until!($self)?;
if bytes == 0 {
return Ok(bytes);
}
$self.truncate();
if !$self.buf.is_empty() {
return Ok(bytes);
}
}
};
}
#[cfg(not(any(feature = "async_std", feature = "async_tokio")))]
impl_reader!();
#[cfg(any(feature = "async_tokio", feature = "async_std"))]
impl_reader!(async);
impl<R> FileReader<R> {
#[allow(clippy::wrong_self_convention)]
pub(crate) fn is_initial_empty_line(&mut self) -> bool {
if self.buf.starts_with(&[239, 187, 191]) {
// UTF-8
self.buf.rotate_left(3);
self.buf.len() == 3
} else {
if self.buf.starts_with(&[255, 254]) {
// UTF-16
self.encoding = Encoding::Utf16;
// will rotate by 1 so this truncates `255`
let mut sub = 1;
if self.buf.len() >= 3 {
// truncate one `0` that was left after truncating `\n` when reading the line.
// additionally truncate `0\r` if possible.
sub += 1 + 2
* (self.buf.len() >= 4 && self.buf[self.buf.len() - 2] == b'\r') as usize;
}
self.buf.rotate_left(1);
self.buf.truncate(self.buf.len() - sub);
self.decode_utf16();
}
self.buf.is_empty()
}
}
pub(crate) fn version(&self) -> Result<u8, ParseError> {
self.buf
.iter()
.position(|&byte| byte == b'o')
.and_then(|idx| {
self.buf[idx..]
.starts_with(b"osu file format v")
.then_some(idx + 17)
})
.map(|idx| {
let mut n = 0;
for byte in &self.buf[idx..] {
if !(b'0'..=b'9').contains(byte) {
break;
}
n = 10 * n + (*byte & 0xF);
}
n
})
.ok_or(ParseError::IncorrectFileHeader)
}
/// Returns the bytes inbetween '[' and ']'.
pub(crate) fn get_section(&self) -> Option<&[u8]> {
if self.buf[0] == b'[' {
if let Some(end) = self.buf[1..].iter().position(|&byte| byte == b']') {
return Some(&self.buf[1..=end]);
}
}
None
}
/// Parse the buffer into a string, returning `None` if the UTF-8 validation fails.
pub(crate) fn get_line(&self) -> Result<&str, ParseError> {
std::str::from_utf8(&self.buf)
.map_err(|e| ParseError::IoError(IoError::new(IoErrorKind::InvalidData, Box::new(e))))
}
pub(crate) fn get_line_ascii(&mut self) -> Result<&str, ParseError> {
self.buf.iter_mut().for_each(|byte| {
if *byte >= 128 {
*byte = b'?';
}
});
std::str::from_utf8(&self.buf)
.map_err(|e| ParseError::IoError(IoError::new(IoErrorKind::InvalidData, Box::new(e))))
}
/// Split the buffer at the first ':', then parse the second half into a string.
///
/// Returns `None` if there is no ':' or if the second half is invalid UTF-8.
pub(crate) fn split_colon(&self) -> Option<(&[u8], &str)> {
let idx = self.buf.iter().position(|&byte| byte == b':')?;
let front = &self.buf[..idx];
let back = std::str::from_utf8(&self.buf[idx + 1..]).ok()?;
Some((front, back.trim_start()))
}
/// Truncate away trailing `\r\n`, `\n`, content after `//` and whitespace
fn truncate(&mut self) {
if self.encoding == Encoding::Utf16 {
self.decode_utf16();
}
// necessary check for the edge case `//\r\n`
if self.buf.starts_with(&[b'/', b'/']) {
return self.buf.clear();
}
// len without "//" or alternatively len without trailing "(\r)\n"
let len = self
.buf
.windows(3)
.rev()
.step_by(2)
.zip(1..)
.find_map(|(window, i)| {
if window[1] == b'/' {
if window[0] == b'/' {
return Some(self.buf.len() - 2 * i - 1);
} else if window[2] == b'/' {
return Some(self.buf.len() - 2 * i);
}
}
None
})
.unwrap_or_else(|| match &self.buf[..] {
[.., b'\r', b'\n'] => self.buf.len() - 2,
[.., b'\n'] => self.buf.len() - 1,
_ => self.buf.len(),
});
// trim whitespace
let len = self.buf[..len]
.iter()
.enumerate()
.rev()
.find_map(|(i, byte)| (!matches!(byte, b' ' | b'\t')).then_some(i + 1))
.unwrap_or(0);
self.buf.truncate(len);
}
/// Assumes the buffer is of the form `[_, a, 0, b, 0, c, ...]` so it removes
/// the first element and all the 0's, turning it into `[a, b, c, ...]`.
fn decode_utf16(&mut self) {
// remove the 0's
let limit = self.buf.len() / 2 + 1;
for i in 2..limit {
self.buf.swap(i, i * 2 - 1);
}
self.buf.truncate(limit);
// remove the first element
// panics if buffer is empty
self.buf.rotate_left(1);
self.buf.truncate(self.buf.len() - 1);
}
}
+119
View File
@@ -0,0 +1,119 @@
use super::HitObject;
use std::cmp::Ordering;
const QUICK_SORT_DEPTH_THRESHOLD: usize = 32;
/// Algorithm from https://github.com/ppy/osu/blob/master/osu.Game.Rulesets.Mania/MathUtils/LegacySortHelper.cs#L21
pub(crate) fn legacy_sort(keys: &mut [HitObject]) {
if keys.is_empty() {
return;
}
depth_limited_quick_sort(keys, 0, keys.len() - 1, QUICK_SORT_DEPTH_THRESHOLD);
}
fn depth_limited_quick_sort(
keys: &mut [HitObject],
mut left: usize,
mut right: usize,
mut depth_limit: usize,
) {
loop {
if depth_limit == 0 {
heap_sort(keys, left, right);
return;
}
let mut i = left;
let mut j = right;
let mid = i + ((j - i) >> 1);
if keys[i] > keys[mid] {
keys.swap(i, mid);
}
if keys[i] > keys[j] {
keys.swap(i, j);
}
if keys[mid] > keys[j] {
keys.swap(mid, j);
}
loop {
while keys[i] < keys[mid] {
i += 1;
}
while keys[mid] < keys[j] {
j -= 1;
}
match i.cmp(&j) {
Ordering::Less => keys.swap(i, j),
Ordering::Equal => {}
Ordering::Greater => break,
}
i += 1;
j = j.saturating_sub(1);
if i > j {
break;
}
}
depth_limit -= 1;
if j.saturating_sub(left) <= right - i {
if left < j {
depth_limited_quick_sort(keys, left, j, depth_limit);
}
left = i;
} else {
if i < right {
depth_limited_quick_sort(keys, i, right, depth_limit);
}
right = j;
}
if left >= right {
break;
}
}
}
fn heap_sort(keys: &mut [HitObject], lo: usize, hi: usize) {
let n = hi - lo + 1;
for i in (1..=n / 2).rev() {
down_heap(keys, i, n, lo);
}
for i in (2..=n).rev() {
keys.swap(lo, lo + i - 1);
down_heap(keys, 1, i - 1, lo);
}
}
fn down_heap(keys: &mut [HitObject], mut i: usize, n: usize, lo: usize) {
while i <= n / 2 {
let mut child = 2 * i;
if child < n && keys[lo + child - 1] < keys[lo + child] {
child += 1;
}
if keys[lo + i - 1] >= keys[lo + child - 1] {
break;
}
keys.swap(lo + i - 1, lo + child - 1);
i = child;
}
}
+316
View File
@@ -0,0 +1,316 @@
use crate::{
catch::{CatchDifficultyAttributes, CatchPP, CatchPerformanceAttributes},
mania::{ManiaDifficultyAttributes, ManiaPP, ManiaPerformanceAttributes},
osu::{OsuDifficultyAttributes, OsuPP, OsuPerformanceAttributes},
taiko::{TaikoDifficultyAttributes, TaikoPP, TaikoPerformanceAttributes},
Beatmap, DifficultyAttributes, GameMode, PerformanceAttributes, 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)]
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>),
}
impl<'map> AnyPP<'map> {
/// Create a new performance calculator for maps of any mode.
#[inline]
pub fn new(map: &'map Beatmap) -> Self {
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)),
}
}
/// 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()),
Self::Taiko(t) => PerformanceAttributes::Taiko(t.calculate()),
Self::Catch(f) => PerformanceAttributes::Catch(f.calculate()),
Self::Mania(m) => PerformanceAttributes::Mania(m.calculate()),
}
}
/// 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())),
Self::Taiko(t) => Self::Taiko(t.attributes(attributes.attributes())),
Self::Catch(f) => Self::Catch(f.attributes(attributes.attributes())),
Self::Mania(m) => Self::Mania(m.attributes(attributes.attributes())),
}
}
/// If the map is an osu!standard map, convert it to another mode.
#[inline]
pub fn mode(self, mode: GameMode) -> Self {
match self {
AnyPP::Osu(o) => match mode {
GameMode::Osu => AnyPP::Osu(o),
GameMode::Taiko => AnyPP::Taiko(o.into()),
GameMode::Catch => AnyPP::Catch(o.into()),
GameMode::Mania => AnyPP::Mania(o.into()),
},
other => other,
}
}
/// 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)),
Self::Taiko(t) => Self::Taiko(t.mods(mods)),
Self::Catch(f) => Self::Catch(f.mods(mods)),
Self::Mania(m) => Self::Mania(m.mods(mods)),
}
}
/// Amount of passed objects for partial plays, e.g. a fail.
///
/// If you want to calculate the performance after every few objects, instead of
/// using [`AnyPP`] multiple times with different `passed_objects`, you should use
/// [`GradualPerformanceAttributes`](crate::GradualPerformanceAttributes).
#[inline]
pub fn passed_objects(self, passed_objects: usize) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.passed_objects(passed_objects)),
Self::Taiko(t) => Self::Taiko(t.passed_objects(passed_objects)),
Self::Catch(f) => Self::Catch(f.passed_objects(passed_objects)),
Self::Mania(m) => Self::Mania(m.passed_objects(passed_objects)),
}
}
/// 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)),
Self::Taiko(t) => Self::Taiko(t.clock_rate(clock_rate)),
Self::Catch(f) => Self::Catch(f.clock_rate(clock_rate)),
Self::Mania(m) => Self::Mania(m.clock_rate(clock_rate)),
}
}
/// Provide parameters through a [`ScoreState`].
#[inline]
pub fn state(self, state: ScoreState) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.state(state.into())),
Self::Taiko(t) => Self::Taiko(t.state(state.into())),
Self::Catch(f) => Self::Catch(f.state(state.into())),
Self::Mania(m) => Self::Mania(m.state(state.into())),
}
}
/// 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)),
Self::Taiko(t) => Self::Taiko(t.accuracy(acc)),
Self::Catch(f) => Self::Catch(f.accuracy(acc)),
Self::Mania(m) => Self::Mania(m.accuracy(acc)),
}
}
/// Specify the amount of misses of a play.
#[inline]
pub fn n_misses(self, n_misses: usize) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.n_misses(n_misses)),
Self::Taiko(t) => Self::Taiko(t.n_misses(n_misses)),
Self::Catch(f) => Self::Catch(f.misses(n_misses)),
Self::Mania(m) => Self::Mania(m.n_misses(n_misses)),
}
}
/// Specify the max combo of the play.
///
/// Irrelevant for osu!mania.
#[inline]
pub fn combo(self, combo: usize) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.combo(combo)),
Self::Taiko(t) => Self::Taiko(t.combo(combo)),
Self::Catch(f) => Self::Catch(f.combo(combo)),
Self::Mania(_) => self,
}
}
/// Specify the amount of 300s of a play.
#[inline]
pub fn n300(self, n300: usize) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.n300(n300)),
Self::Taiko(t) => Self::Taiko(t.n300(n300)),
Self::Catch(f) => Self::Catch(f.fruits(n300)),
Self::Mania(m) => Self::Mania(m.n300(n300)),
}
}
/// Specify the amount of 100s of a play.
#[inline]
pub fn n100(self, n100: usize) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.n100(n100)),
Self::Taiko(t) => Self::Taiko(t.n100(n100)),
Self::Catch(f) => Self::Catch(f.droplets(n100)),
Self::Mania(m) => Self::Mania(m.n100(n100)),
}
}
/// Specify the amount of 50s of a play.
///
/// Irrelevant for osu!taiko.
#[inline]
pub fn n50(self, n50: usize) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.n50(n50)),
Self::Taiko(_) => self,
Self::Catch(f) => Self::Catch(f.tiny_droplets(n50)),
Self::Mania(m) => Self::Mania(m.n50(n50)),
}
}
/// Specify the amount of katus of a play.
///
/// 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 {
match self {
Self::Osu(_) => self,
Self::Taiko(_) => self,
Self::Catch(f) => Self::Catch(f.tiny_droplet_misses(n_katu)),
Self::Mania(m) => Self::Mania(m.n200(n_katu)),
}
}
/// Specify the amount of gekis of a play.
///
/// 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 {
match self {
Self::Osu(_) => self,
Self::Taiko(_) => self,
Self::Catch(_) => self,
Self::Mania(m) => Self::Mania(m.n320(n_geki)),
}
}
}
/// 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)
}
}
impl AttributeProvider for $performance {
#[inline]
fn attributes(self) -> DifficultyAttributes {
DifficultyAttributes::$mode(self.difficulty)
}
}
};
}
impl_attr_provider!(Catch: CatchDifficultyAttributes, CatchPerformanceAttributes);
impl_attr_provider!(Mania: ManiaDifficultyAttributes, ManiaPerformanceAttributes);
impl_attr_provider!(Osu: OsuDifficultyAttributes, OsuPerformanceAttributes);
impl_attr_provider!(Taiko: TaikoDifficultyAttributes, TaikoPerformanceAttributes);
+127
View File
@@ -0,0 +1,127 @@
use crate::{
Beatmap, CatchStars, DifficultyAttributes, GameMode, ManiaStars, OsuStars, Strains, TaikoStars,
};
/// Difficulty calculator on maps of any mode.
///
/// # Example
///
/// ```
/// use rosu_pp::{AnyStars, Beatmap};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let difficulty_attrs = AnyStars::new(&map)
/// .mods(8 + 64) // HDDT
/// .calculate();
///
/// println!("Stars: {}", difficulty_attrs.stars());
/// ```
#[derive(Clone, Debug)]
pub enum AnyStars<'map> {
/// osu!standard difficulty calculator
Osu(OsuStars<'map>),
/// osu!taiko difficulty calculator
Taiko(TaikoStars<'map>),
/// osu!catch difficulty calculator
Catch(CatchStars<'map>),
/// osu!mania difficulty calculator
Mania(ManiaStars<'map>),
}
impl<'map> AnyStars<'map> {
/// Create a new difficulty calculator for maps of any mode.
#[inline]
pub fn new(map: &'map Beatmap) -> Self {
match map.mode {
GameMode::Osu => Self::Osu(OsuStars::new(map)),
GameMode::Taiko => Self::Taiko(TaikoStars::new(map)),
GameMode::Catch => Self::Catch(CatchStars::new(map)),
GameMode::Mania => Self::Mania(ManiaStars::new(map)),
}
}
/// If the map is an osu!standard map, convert it to another mode.
#[inline]
pub fn mode(self, mode: GameMode) -> Self {
match self {
AnyStars::Osu(o) => match mode {
GameMode::Osu => AnyStars::Osu(o),
GameMode::Taiko => AnyStars::Taiko(o.into()),
GameMode::Catch => AnyStars::Catch(o.into()),
GameMode::Mania => AnyStars::Mania(o.into()),
},
other => other,
}
}
/// 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)),
Self::Taiko(t) => Self::Taiko(t.mods(mods)),
Self::Catch(f) => Self::Catch(f.mods(mods)),
Self::Mania(m) => Self::Mania(m.mods(mods)),
}
}
/// Amount of passed objects for partial plays, e.g. a fail.
///
/// If you want to calculate the performance after every few objects, instead of
/// using [`AnyStars`] multiple times with different `passed_objects`, you should use
/// [`GradualDifficultyAttributes`](crate::GradualDifficultyAttributes).
#[inline]
pub fn passed_objects(self, passed_objects: usize) -> Self {
match self {
Self::Osu(o) => Self::Osu(o.passed_objects(passed_objects)),
Self::Taiko(t) => Self::Taiko(t.passed_objects(passed_objects)),
Self::Catch(f) => Self::Catch(f.passed_objects(passed_objects)),
Self::Mania(m) => Self::Mania(m.passed_objects(passed_objects)),
}
}
/// 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)),
Self::Taiko(t) => Self::Taiko(t.clock_rate(clock_rate)),
Self::Catch(f) => Self::Catch(f.clock_rate(clock_rate)),
Self::Mania(m) => Self::Mania(m.clock_rate(clock_rate)),
}
}
/// Consume the difficulty calculator and calculate
/// difficulty attributes for the given parameters.
#[inline]
pub fn calculate(self) -> DifficultyAttributes {
match self {
Self::Osu(o) => DifficultyAttributes::Osu(o.calculate()),
Self::Taiko(t) => DifficultyAttributes::Taiko(t.calculate()),
Self::Catch(f) => DifficultyAttributes::Catch(f.calculate()),
Self::Mania(m) => DifficultyAttributes::Mania(m.calculate()),
}
}
/// Consume the difficulty calculator and calculate
/// skill strains for the given parameters.
///
/// Suitable to plot the difficulty of a map over time.
#[inline]
pub fn strains(self) -> Strains {
match self {
Self::Osu(o) => Strains::Osu(o.strains()),
Self::Taiko(t) => Strains::Taiko(t.strains()),
Self::Catch(f) => Strains::Catch(f.strains()),
Self::Mania(m) => Strains::Mania(m.strains()),
}
}
}
@@ -0,0 +1,72 @@
use std::{
cell::RefCell,
fmt::{Debug, Formatter, Result as FmtResult},
rc::{Rc, Weak},
};
use crate::taiko::difficulty_object::TaikoDifficultyObject;
use super::{mono_streak::MonoStreak, repeating_hit_patterns::RepeatingHitPatterns};
pub(crate) struct AlternatingMonoPattern {
pub(crate) mono_streaks: Vec<Rc<RefCell<MonoStreak>>>,
pub(crate) parent: Option<Weak<RefCell<RepeatingHitPatterns>>>,
pub(crate) idx: usize,
}
impl Debug for AlternatingMonoPattern {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
write!(
f,
"(idx={}, mono_len={}, has_parent={})",
self.idx,
self.mono_streaks.len(),
self.parent.is_some()
)
}
}
impl AlternatingMonoPattern {
pub(crate) fn new() -> Rc<RefCell<Self>> {
let this = Self {
mono_streaks: Vec::new(),
parent: None,
idx: 0,
};
Rc::new(RefCell::new(this))
}
pub(crate) fn first_hit_object(&self) -> Option<Weak<RefCell<TaikoDifficultyObject>>> {
self.mono_streaks
.first()
.and_then(|streak| streak.borrow().first_hit_object())
}
pub(crate) fn is_repetition_of(&self, other: &Self) -> bool {
self.has_identical_mono_len(other)
&& other.mono_streaks.len() == self.mono_streaks.len()
&& other
.mono_streaks
.first()
.map(|streak| streak.borrow().hit_kind())
== self
.mono_streaks
.first()
.map(|streak| streak.borrow().hit_kind())
}
pub(crate) fn has_identical_mono_len(&self, other: &Self) -> bool {
let other_len = other
.mono_streaks
.first()
.map(|streak| streak.borrow().run_len());
let self_len = self
.mono_streaks
.first()
.map(|streak| streak.borrow().run_len());
other_len == self_len
}
}
+27
View File
@@ -0,0 +1,27 @@
use std::{
cell::RefCell,
rc::{Rc, Weak},
};
pub(crate) use self::{
alternating_mono_pattern::AlternatingMonoPattern, mono_streak::MonoStreak,
preprocessor::ColourDifficultyPreprocessor, repeating_hit_patterns::RepeatingHitPatterns,
};
mod alternating_mono_pattern;
mod mono_streak;
mod preprocessor;
mod repeating_hit_patterns;
#[derive(Clone, Debug, Default)]
pub(crate) struct TaikoDifficultyColour {
pub(crate) mono_streak: Option<Weak<RefCell<MonoStreak>>>,
pub(crate) alternating_mono_pattern: Option<Weak<RefCell<AlternatingMonoPattern>>>,
pub(crate) repeating_hit_patterns: Option<Rc<RefCell<RepeatingHitPatterns>>>,
}
#[derive(Copy, Clone, Eq, PartialEq)]
pub(crate) enum HitKind {
Centre,
Rim,
}
+58
View File
@@ -0,0 +1,58 @@
use std::{
cell::RefCell,
fmt::{Debug, Formatter, Result as FmtResult},
rc::{Rc, Weak},
};
use crate::taiko::difficulty_object::{MonoIndex, TaikoDifficultyObject};
use super::{alternating_mono_pattern::AlternatingMonoPattern, HitKind};
pub(crate) struct MonoStreak {
pub(crate) hit_objects: Vec<Weak<RefCell<TaikoDifficultyObject>>>,
pub(crate) parent: Option<Weak<RefCell<AlternatingMonoPattern>>>,
pub(crate) idx: usize,
}
impl Debug for MonoStreak {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
write!(
f,
"(idx={}, obj_len={}, has_parent={})",
self.idx,
self.hit_objects.len(),
self.parent.is_some()
)
}
}
impl MonoStreak {
pub(crate) fn new() -> Rc<RefCell<Self>> {
let this = Self {
hit_objects: Vec::new(),
parent: None,
idx: 0,
};
Rc::new(RefCell::new(this))
}
pub(crate) fn first_hit_object(&self) -> Option<Weak<RefCell<TaikoDifficultyObject>>> {
self.hit_objects.first().map(Weak::clone)
}
pub(crate) fn hit_kind(&self) -> Option<HitKind> {
self.hit_objects
.first()
.and_then(Weak::upgrade)
.and_then(|obj| match obj.borrow().mono_idx {
MonoIndex::Centre(_) => Some(HitKind::Centre),
MonoIndex::Rim(_) => Some(HitKind::Rim),
MonoIndex::None => None,
})
}
pub(crate) fn run_len(&self) -> usize {
self.hit_objects.len()
}
}
+205
View File
@@ -0,0 +1,205 @@
use std::{
cell::RefCell,
collections::VecDeque,
rc::{Rc, Weak},
};
use crate::taiko::difficulty_object::ObjectLists;
use super::{
alternating_mono_pattern::AlternatingMonoPattern, mono_streak::MonoStreak,
repeating_hit_patterns::RepeatingHitPatterns,
};
pub(crate) struct ColourDifficultyPreprocessor;
impl ColourDifficultyPreprocessor {
pub(crate) fn process_and_assign(lists: &mut ObjectLists) {
// * Assign indexing and encoding data to all relevant objects. Only the first note of each encoding type is
// * assigned with the relevant encodings.
for repeating_hit_pattern in Self::encode(lists) {
if let Some(obj) = repeating_hit_pattern
.borrow()
.first_hit_object()
.as_ref()
.and_then(Weak::upgrade)
{
obj.borrow_mut().colour.repeating_hit_patterns =
Some(Rc::clone(&repeating_hit_pattern));
}
// * The outermost loop is kept a ForEach loop since it doesn't need index information, and we want to
// * keep i and j for AlternatingMonoPattern's and MonoStreak's index respectively, to keep it in line with
// * documentation.
for i in 0..repeating_hit_pattern
.borrow()
.alternating_mono_patterns
.len()
{
let borrowed_repeating_hit_pattern = repeating_hit_pattern.borrow();
let mono_pattern = &borrowed_repeating_hit_pattern.alternating_mono_patterns[i];
{
let mut borrowed = mono_pattern.borrow_mut();
borrowed.parent = Some(Rc::downgrade(&repeating_hit_pattern));
borrowed.idx = i;
}
if let Some(obj) = mono_pattern
.borrow()
.first_hit_object()
.as_ref()
.and_then(Weak::upgrade)
{
obj.borrow_mut().colour.alternating_mono_pattern =
Some(Rc::downgrade(mono_pattern));
}
for j in 0..mono_pattern.borrow().mono_streaks.len() {
let borrowed_mono_pattern = mono_pattern.borrow();
let mono_streak = &borrowed_mono_pattern.mono_streaks[j];
{
let mut borrowed = mono_streak.borrow_mut();
borrowed.parent = Some(Rc::downgrade(mono_pattern));
borrowed.idx = j;
}
if let Some(obj) = mono_streak
.borrow()
.first_hit_object()
.as_ref()
.and_then(Weak::upgrade)
{
obj.borrow_mut().colour.mono_streak = Some(Rc::downgrade(mono_streak));
};
}
}
}
}
fn encode(data: &mut ObjectLists) -> Vec<Rc<RefCell<RepeatingHitPatterns>>> {
let mono_streaks = Self::encode_mono_streak(data);
let alternating_mono_patterns = Self::encode_alternating_mono_pattern(mono_streaks);
Self::encode_repeating_hit_pattern(alternating_mono_patterns)
}
fn encode_mono_streak(data: &mut ObjectLists) -> Vec<Rc<RefCell<MonoStreak>>> {
let mut mono_streaks = vec![MonoStreak::new()];
let mut curr_mono_streak = mono_streaks.last_mut();
let mut data_iter = data.all.iter();
if let (Some(curr), Some(taiko_obj)) = (&curr_mono_streak, data_iter.next()) {
curr.borrow_mut().hit_objects.push(Rc::downgrade(taiko_obj));
}
for taiko_obj in data_iter {
// * This ignores all non-note objects, which may or may not be the desired behaviour
let prev = data.prev_note(taiko_obj.borrow().idx, 0);
// * If this is the first object in the list or the colour changed, create a new mono streak
let condition = prev.filter(|prev| {
!(taiko_obj.borrow().base.is_hit
&& prev.borrow().base.is_hit
&& (taiko_obj.borrow().base.is_rim != prev.borrow().base.is_rim))
});
if condition.is_none() {
mono_streaks.push(MonoStreak::new());
curr_mono_streak = mono_streaks.last_mut();
}
// * Add the current object to the encoded payload.
if let Some(ref curr) = curr_mono_streak {
curr.borrow_mut().hit_objects.push(Rc::downgrade(taiko_obj));
}
}
mono_streaks
}
fn encode_alternating_mono_pattern(
data: Vec<Rc<RefCell<MonoStreak>>>,
) -> VecDeque<Rc<RefCell<AlternatingMonoPattern>>> {
let mut mono_patterns = VecDeque::new();
mono_patterns.push_back(AlternatingMonoPattern::new());
let mut curr_mono_pattern = mono_patterns.back_mut();
if let (Some(curr), Some(mono)) = (&curr_mono_pattern, data.first()) {
curr.borrow_mut().mono_streaks.push(Rc::clone(mono));
}
for (prev, curr) in data.iter().zip(data.iter().skip(1)) {
// * Start a new AlternatingMonoPattern if the previous MonoStreak has a different mono length,
// * or if this is the first MonoStreak in the list.
if curr.borrow().run_len() != prev.borrow().run_len() {
mono_patterns.push_back(AlternatingMonoPattern::new());
curr_mono_pattern = mono_patterns.back_mut();
}
// * Add the current MonoStreak to the encoded payload.
if let Some(ref curr_mono_pattern) = curr_mono_pattern {
curr_mono_pattern
.borrow_mut()
.mono_streaks
.push(Rc::clone(curr));
}
}
mono_patterns
}
fn encode_repeating_hit_pattern(
mut data: VecDeque<Rc<RefCell<AlternatingMonoPattern>>>,
) -> Vec<Rc<RefCell<RepeatingHitPatterns>>> {
let mut hit_patterns = Vec::new();
let mut curr_hit_pattern: Option<Rc<std::cell::RefCell<_>>> = None;
while !data.is_empty() {
let old = curr_hit_pattern.as_ref().map(Rc::downgrade);
let curr_hit_pattern = curr_hit_pattern.insert(RepeatingHitPatterns::new(old));
let mut is_coupled = data.get(2).map_or(false, |other| {
data[0].borrow().is_repetition_of(&other.borrow())
});
if is_coupled {
// * If so, add the current AlternatingMonoPattern to the encoded payload and start repeatedly checking if the
// * subsequent AlternatingMonoPatterns should be grouped by increasing i and doing the appropriate isCoupled check.
while is_coupled {
curr_hit_pattern
.borrow_mut()
.alternating_mono_patterns
.push(data.pop_front().unwrap());
is_coupled = data.get(2).map_or(false, |other| {
data[0].borrow().is_repetition_of(&other.borrow())
});
}
// * Skip over viewed data and add the rest to the payload
for front in data.drain(..2) {
curr_hit_pattern
.borrow_mut()
.alternating_mono_patterns
.push(front);
}
} else {
// * If not, add the current AlternatingMonoPattern to the encoded payload and continue.
curr_hit_pattern
.borrow_mut()
.alternating_mono_patterns
.push(data.pop_front().unwrap());
}
hit_patterns.push(Rc::clone(&*curr_hit_pattern));
}
hit_patterns
.iter_mut()
.for_each(|pattern| pattern.borrow_mut().find_repetition_interval());
hit_patterns
}
}
@@ -0,0 +1,90 @@
use std::{
cell::RefCell,
fmt::{Debug, Formatter, Result as FmtResult},
rc::{Rc, Weak},
};
use crate::taiko::difficulty_object::TaikoDifficultyObject;
use super::alternating_mono_pattern::AlternatingMonoPattern;
pub(crate) struct RepeatingHitPatterns {
pub(crate) alternating_mono_patterns: Vec<Rc<RefCell<AlternatingMonoPattern>>>,
pub(crate) prev: Option<Weak<RefCell<Self>>>,
pub(crate) repetition_interval: usize,
}
impl Debug for RepeatingHitPatterns {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
write!(
f,
"(interval={}, alt_len={}, has_prev={})",
self.repetition_interval,
self.alternating_mono_patterns.len(),
self.prev.is_some()
)
}
}
impl RepeatingHitPatterns {
const MAX_REPETITION_INTERVAL: usize = 16;
pub(crate) fn new(prev: Option<Weak<RefCell<Self>>>) -> Rc<RefCell<Self>> {
let this = Self {
alternating_mono_patterns: Vec::new(),
prev,
repetition_interval: 0,
};
Rc::new(RefCell::new(this))
}
pub(crate) fn first_hit_object(&self) -> Option<Weak<RefCell<TaikoDifficultyObject>>> {
self.alternating_mono_patterns
.first()
.and_then(|pattern| pattern.borrow().first_hit_object())
}
fn is_repetition_of(&self, other: &Self) -> bool {
if self.alternating_mono_patterns.len() != other.alternating_mono_patterns.len() {
return false;
}
self.alternating_mono_patterns
.iter()
.zip(other.alternating_mono_patterns.iter())
.take(2)
.all(|(self_pat, other_pat)| {
self_pat
.borrow()
.has_identical_mono_len(&other_pat.borrow())
})
}
pub(crate) fn find_repetition_interval(&mut self) {
let mut other = match self.prev.as_ref().and_then(Weak::upgrade) {
Some(prev) => prev,
None => return self.repetition_interval = Self::MAX_REPETITION_INTERVAL + 1,
};
let mut interval = 1;
while interval < Self::MAX_REPETITION_INTERVAL {
if self.is_repetition_of(&other.borrow()) {
return self.repetition_interval = interval.min(Self::MAX_REPETITION_INTERVAL);
}
let next = match other.borrow().prev.as_ref().and_then(Weak::upgrade) {
Some(prev) => prev,
None => break,
};
// gotta love NLL...
other = next;
interval += 1;
}
self.repetition_interval = Self::MAX_REPETITION_INTERVAL + 1;
}
}
+193
View File
@@ -0,0 +1,193 @@
use std::{cell::RefCell, cmp::Ordering, rc::Rc};
use super::{colours::TaikoDifficultyColour, taiko_object::TaikoObject};
#[derive(Clone, Debug)]
pub(crate) struct TaikoDifficultyObject {
pub(crate) base: TaikoObject,
pub(crate) prev_time: f64,
pub(crate) colour: TaikoDifficultyColour,
pub(crate) rhythm: &'static HitObjectRhythm,
pub(crate) mono_idx: MonoIndex,
pub(crate) note_idx: Option<usize>,
pub(crate) idx: usize,
pub(crate) start_time: f64,
pub(crate) delta: f64,
}
impl TaikoDifficultyObject {
pub(crate) fn new(
base: TaikoObject,
base_start_time: f64,
last_start_time: f64,
last_last_start_time: f64,
clock_rate: f64,
lists: &ObjectLists,
idx: usize,
) -> Self {
// * Create the Colour object, its properties should be filled in by TaikoDifficultyPreprocessor
let colour = TaikoDifficultyColour::default();
let delta = (base_start_time - last_start_time) / clock_rate;
let rhythm = closest_rhythm(delta, last_start_time, last_last_start_time, clock_rate);
let mono_idx = if !base.is_hit {
MonoIndex::None
} else if base.is_rim {
MonoIndex::Rim(lists.rims.len())
} else {
MonoIndex::Centre(lists.centres.len())
};
let note_idx = base.is_hit.then_some(lists.notes.len());
Self {
base,
prev_time: last_start_time / clock_rate,
colour,
rhythm,
mono_idx,
note_idx,
idx,
start_time: base_start_time / clock_rate,
delta,
}
}
}
#[rustfmt::skip]
pub(crate) static COMMON_RHYTHMS: [HitObjectRhythm; 9] = [
HitObjectRhythm { id: 0, ratio: 1.0, difficulty: 0.0 },
HitObjectRhythm { id: 1, ratio: 2.0 / 1.0, difficulty: 0.3 },
HitObjectRhythm { id: 2, ratio: 1.0 / 2.0, difficulty: 0.5 },
HitObjectRhythm { id: 3, ratio: 3.0 / 1.0, difficulty: 0.3 },
HitObjectRhythm { id: 4, ratio: 1.0 / 3.0, difficulty: 0.35 },
// * purposefully higher (requires hand switch in full alternating gameplay style)
HitObjectRhythm { id: 5, ratio: 3.0 / 2.0, difficulty: 0.6 },
HitObjectRhythm { id: 6, ratio: 2.0 / 3.0, difficulty: 0.4 },
HitObjectRhythm { id: 7, ratio: 5.0 / 4.0, difficulty: 0.5 },
HitObjectRhythm { id: 8, ratio: 4.0 / 5.0, difficulty: 0.7 },
];
#[derive(Copy, Clone, Debug)]
pub(crate) struct HitObjectRhythm {
id: u8,
pub(crate) ratio: f64,
pub(crate) difficulty: f64,
}
impl HitObjectRhythm {
pub(crate) fn static_ref() -> &'static Self {
&COMMON_RHYTHMS[0]
}
}
impl PartialEq for HitObjectRhythm {
#[inline]
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Eq for HitObjectRhythm {}
fn closest_rhythm(
delta_time: f64,
last_start_time: f64,
last_last_start_time: f64,
clock_rate: f64,
) -> &'static HitObjectRhythm {
let prev_len = (last_start_time - last_last_start_time) / clock_rate;
let ratio = delta_time / prev_len;
COMMON_RHYTHMS
.iter()
.min_by(|r1, r2| {
(r1.ratio - ratio)
.abs()
.partial_cmp(&(r2.ratio - ratio).abs())
.unwrap_or(Ordering::Equal)
})
.unwrap()
}
#[derive(Clone, Debug, Default)]
pub(crate) struct ObjectLists {
pub(crate) all: Vec<Rc<RefCell<TaikoDifficultyObject>>>,
pub(crate) centres: Vec<usize>,
pub(crate) rims: Vec<usize>,
pub(crate) notes: Vec<usize>,
}
impl ObjectLists {
pub(crate) fn prev_mono(
&self,
curr: usize,
backwards_idx: usize,
) -> Option<&'_ Rc<RefCell<TaikoDifficultyObject>>> {
let curr = &self.all[curr];
let prev = match curr.borrow().mono_idx {
MonoIndex::Centre(idx) => idx
.checked_sub(backwards_idx + 1)
.and_then(|idx| self.centres.get(idx))?,
MonoIndex::Rim(idx) => idx
.checked_sub(backwards_idx + 1)
.and_then(|idx| self.rims.get(idx))?,
MonoIndex::None => return None,
};
self.all.get(*prev)
}
#[allow(unused)]
pub(crate) fn next_mono(
&self,
curr: usize,
forwards_idx: usize,
) -> Option<&'_ Rc<RefCell<TaikoDifficultyObject>>> {
let curr = &self.all[curr];
let next = match curr.borrow().mono_idx {
MonoIndex::Centre(idx) => self.centres.get(idx + (forwards_idx + 1))?,
MonoIndex::Rim(idx) => self.rims.get(idx + (forwards_idx + 1))?,
MonoIndex::None => return None,
};
self.all.get(*next)
}
pub(crate) fn prev_note(
&self,
curr: usize,
backwards_idx: usize,
) -> Option<&'_ Rc<RefCell<TaikoDifficultyObject>>> {
let curr = &self.all[curr];
let note_idx = curr.borrow().note_idx?;
let idx = note_idx.checked_sub(backwards_idx + 1)?;
let prev = self.notes.get(idx)?;
self.all.get(*prev)
}
#[allow(unused)]
pub(crate) fn next_note(
&self,
curr: usize,
forwards_idx: usize,
) -> Option<&'_ Rc<RefCell<TaikoDifficultyObject>>> {
let curr = &self.all[curr];
let note_idx = curr.borrow().note_idx?;
let idx = note_idx + (forwards_idx + 1);
let prev = self.notes.get(idx)?;
self.all.get(*prev)
}
}
#[derive(Copy, Clone, Debug)]
pub(crate) enum MonoIndex {
Centre(usize),
Rim(usize),
None,
}
+233
View File
@@ -0,0 +1,233 @@
use std::{borrow::Cow, cell::RefCell, rc::Rc, vec::IntoIter};
use crate::{beatmap::BeatmapHitWindows, taiko::rescale, Beatmap, GameMode, Mods};
use super::{
colours::ColourDifficultyPreprocessor,
difficulty_object::{MonoIndex, ObjectLists, TaikoDifficultyObject},
skills::{Peaks, PeaksDifficultyValues, Skill},
taiko_object::IntoTaikoObjectIter,
TaikoDifficultyAttributes, DIFFICULTY_MULTIPLIER,
};
/// Gradually calculate the difficulty attributes of an osu!taiko map.
///
/// Note that this struct implements [`Iterator`](std::iter::Iterator).
/// On every call of [`Iterator::next`](std::iter::Iterator::next), the map's next hit object will
/// be processed and the [`TaikoDifficultyAttributes`] will be updated and returned.
///
/// If you want to calculate performance attributes, use
/// [`TaikoGradualPerformanceAttributes`](crate::taiko::TaikoGradualPerformanceAttributes) instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, taiko::TaikoGradualDifficultyAttributes};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut iter = TaikoGradualDifficultyAttributes::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 {
/// // ...
/// }
/// ```
#[derive(Clone, Debug)]
pub struct TaikoGradualDifficultyAttributes {
attrs: TaikoDifficultyAttributes,
hit_objects: IntoIter<Rc<RefCell<TaikoDifficultyObject>>>,
lists: ObjectLists,
peaks: Peaks,
total_hits: usize,
is_convert: bool,
pub(crate) started: bool,
}
impl TaikoGradualDifficultyAttributes {
/// Create a new difficulty attributes iterator for osu!taiko maps.
pub fn new(map: &Beatmap, mods: u32) -> Self {
let map = map.convert_mode(GameMode::Taiko);
let is_convert = matches!(map, Cow::Owned(_));
let peaks = Peaks::new();
let clock_rate = mods.clock_rate();
let BeatmapHitWindows { od: hit_window, .. } = map
.attributes()
.mods(mods)
.clock_rate(clock_rate)
.hit_windows();
let mut attrs = TaikoDifficultyAttributes {
stamina: 0.0,
rhythm: 0.0,
colour: 0.0,
peak: 0.0,
hit_window,
stars: 0.0,
max_combo: 0,
};
if map.hit_objects.len() < 2 {
return Self {
hit_objects: Vec::new().into_iter(),
lists: ObjectLists::default(),
peaks,
attrs,
total_hits: 0,
is_convert,
started: false,
};
}
attrs.max_combo += map.hit_objects[0].is_circle() as usize;
attrs.max_combo += map.hit_objects[1].is_circle() as usize;
let mut total_hits = attrs.max_combo;
let mut diff_objects = map
.taiko_objects()
.skip(2)
.zip(map.hit_objects.iter().skip(1))
.zip(map.hit_objects.iter())
.enumerate()
.fold(
ObjectLists::default(),
|mut lists, (idx, (((base, base_start_time), last), last_last))| {
total_hits += base.is_hit as usize;
let diff_obj = TaikoDifficultyObject::new(
base,
base_start_time,
last.start_time,
last_last.start_time,
clock_rate,
&lists,
idx,
);
match &diff_obj.mono_idx {
MonoIndex::Centre(_) => lists.centres.push(idx),
MonoIndex::Rim(_) => lists.rims.push(idx),
MonoIndex::None => {}
}
if diff_obj.note_idx.is_some() {
lists.notes.push(idx);
}
lists.all.push(Rc::new(RefCell::new(diff_obj)));
lists
},
);
ColourDifficultyPreprocessor::process_and_assign(&mut diff_objects);
Self {
hit_objects: diff_objects.all.clone().into_iter(),
lists: diff_objects,
peaks,
attrs,
total_hits,
is_convert,
started: false,
}
}
}
impl Iterator for TaikoGradualDifficultyAttributes {
type Item = TaikoDifficultyAttributes;
fn next(&mut self) -> Option<Self::Item> {
self.started = true;
loop {
let curr = self.hit_objects.next()?;
let borrowed = curr.borrow();
self.peaks.process(&borrowed, &self.lists);
if borrowed.base.is_hit {
self.attrs.max_combo += 1;
break;
}
}
let PeaksDifficultyValues {
mut colour_rating,
mut rhythm_rating,
mut stamina_rating,
mut combined_rating,
} = self.peaks.clone().difficulty_values();
colour_rating *= DIFFICULTY_MULTIPLIER;
rhythm_rating *= DIFFICULTY_MULTIPLIER;
stamina_rating *= DIFFICULTY_MULTIPLIER;
combined_rating *= DIFFICULTY_MULTIPLIER;
let mut star_rating = rescale(combined_rating * 1.4);
// * TODO: This is temporary measure as we don't detect abuse of multiple-input
// * playstyles of converts within the current system.
if self.is_convert {
star_rating *= 0.925;
// * For maps with low colour variance and high stamina requirement,
// * multiple inputs are more likely to be abused.
if colour_rating < 2.0 && stamina_rating > 8.0 {
star_rating *= 0.8;
}
}
self.attrs.stamina = stamina_rating;
self.attrs.colour = colour_rating;
self.attrs.rhythm = rhythm_rating;
self.attrs.peak = combined_rating;
self.attrs.stars = star_rating;
Some(self.attrs.clone())
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
let len = self.len();
(len, Some(len))
}
fn nth(&mut self, n: usize) -> Option<Self::Item> {
let skip = n
.min(self.total_hits - self.attrs.max_combo)
.saturating_sub(1);
for _ in 0..skip {
loop {
let curr = self.hit_objects.next()?;
let borrowed = curr.borrow();
self.peaks.process(&borrowed, &self.lists);
if borrowed.base.is_hit {
self.attrs.max_combo += 1;
break;
}
}
}
self.next()
}
}
impl ExactSizeIterator for TaikoGradualDifficultyAttributes {
#[inline]
fn len(&self) -> usize {
self.hit_objects.len()
}
}
+187
View File
@@ -0,0 +1,187 @@
use crate::{Beatmap, TaikoPP};
use super::{TaikoGradualDifficultyAttributes, TaikoPerformanceAttributes};
/// Aggregation for a score's current state i.e. what was the
/// maximum combo so far and what are the current hitresults.
///
/// This struct is used for [`TaikoGradualPerformanceAttributes`].
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct TaikoScoreState {
/// Maximum combo that the score has had so far.
/// **Not** the maximum possible combo of the map so far.
pub max_combo: usize,
/// Amount of current 300s.
pub n300: usize,
/// Amount of current 100s.
pub n100: usize,
/// Amount of current misses.
pub n_misses: usize,
}
impl TaikoScoreState {
/// 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 fn total_hits(&self) -> usize {
self.n300 + self.n100 + 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();
if total_hits == 0 {
return 0.0;
}
let numerator = 2 * self.n300 + self.n100;
let denominator = 2 * total_hits;
numerator as f64 / denominator as f64
}
}
/// Gradually calculate the performance attributes of an osu!taiko map.
///
/// After each hit object you can call
/// [`process_next_object`](`TaikoGradualPerformanceAttributes::process_next_object`)
/// and it will return the resulting current [`TaikoPerformanceAttributes`].
/// To process multiple objects at once, use
/// [`process_next_n_objects`](`TaikoGradualPerformanceAttributes::process_next_n_objects`) instead.
///
/// Both methods require a [`TaikoScoreState`] that contains the current
/// hitresults as well as the maximum combo so far.
///
/// If you only want to calculate difficulty attributes use
/// [`TaikoGradualDifficultyAttributes`](crate::taiko::TaikoGradualDifficultyAttributes) instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, taiko::{TaikoGradualPerformanceAttributes, TaikoScoreState}};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut gradual_perf = TaikoGradualPerformanceAttributes::new(&map, mods);
/// let mut state = TaikoScoreState::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.process_next_object(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_object(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.process_next_object(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_object(state.clone());
///
/// // The next 10 objects will be a mixture of 300s and 100s.
/// // Notice how all 10 objects will be processed in one go.
/// state.n300 += 3;
/// state.n100 += 7;
/// # /*
/// let performance = gradual_perf.process_next_n_objects(state.clone(), 10).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_n_objects(state.clone(), 10);
///
/// // Now comes another 300. Note that the max combo gets incremented again.
/// state.n300 += 1;
/// state.max_combo += 1;
/// # /*
/// let performance = gradual_perf.process_next_object(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_object(state.clone());
///
/// // Skip to the end
/// # /*
/// state.max_combo = ...
/// state.n300 = ...
/// state.n100 = ...
/// state.n_misses = ...
/// let final_performance = gradual_perf.process_next_n_objects(state.clone(), usize::MAX).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_n_objects(state.clone(), usize::MAX);
///
/// // Once the final performance was calculated,
/// // attempting to process further objects will return `None`.
/// assert!(gradual_perf.process_next_object(state).is_none());
/// ```
#[derive(Clone, Debug)]
pub struct TaikoGradualPerformanceAttributes<'map> {
difficulty: TaikoGradualDifficultyAttributes,
performance: TaikoPP<'map>,
}
impl<'map> TaikoGradualPerformanceAttributes<'map> {
/// Create a new gradual performance calculator for osu!taiko maps.
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
let difficulty = TaikoGradualDifficultyAttributes::new(map, mods);
let performance = TaikoPP::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 process_next_object(
&mut self,
state: TaikoScoreState,
) -> Option<TaikoPerformanceAttributes> {
self.process_next_n_objects(state, 1)
}
/// Same as [`process_next_object`](`TaikoGradualPerformanceAttributes::process_next_object`)
/// but instead of processing only one object it process `n` many.
///
/// If `n` is 0 it will be considered as 1.
/// If there are still objects to be processed but `n` is larger than the amount
/// of remaining objects, `n` will be considered as the amount of remaining objects.
pub fn process_next_n_objects(
&mut self,
state: TaikoScoreState,
n: usize,
) -> Option<TaikoPerformanceAttributes> {
let sub = 2 * !self.difficulty.started as usize;
let difficulty = self.difficulty.nth(n.saturating_sub(sub))?;
let passed_objects = difficulty.max_combo;
let performance = self
.performance
.clone()
.attributes(difficulty)
.state(state)
.passed_objects(passed_objects)
.calculate();
Some(performance)
}
}
+375
View File
@@ -0,0 +1,375 @@
mod colours;
mod difficulty_object;
mod gradual_difficulty;
mod gradual_performance;
mod pp;
mod rim;
mod skills;
mod taiko_object;
use std::{borrow::Cow, cell::RefCell, rc::Rc};
pub use self::{gradual_difficulty::*, gradual_performance::*, pp::*};
use crate::{beatmap::BeatmapHitWindows, Beatmap, GameMode, Mods, OsuStars};
use self::{
colours::ColourDifficultyPreprocessor,
difficulty_object::{MonoIndex, ObjectLists, TaikoDifficultyObject},
skills::{Peaks, PeaksDifficultyValues, PeaksRaw, Skill},
taiko_object::IntoTaikoObjectIter,
};
const SECTION_LEN: usize = 400;
const DIFFICULTY_MULTIPLIER: f64 = 1.35;
/// Difficulty calculator on osu!taiko maps.
///
/// # Example
///
/// ```
/// use rosu_pp::{TaikoStars, Beatmap};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let difficulty_attrs = TaikoStars::new(&map)
/// .mods(8 + 64) // HDDT
/// .calculate();
///
/// println!("Stars: {}", difficulty_attrs.stars);
/// ```
#[derive(Clone, Debug)]
pub struct TaikoStars<'map> {
map: Cow<'map, Beatmap>,
mods: u32,
passed_objects: Option<usize>,
clock_rate: Option<f64>,
is_convert: bool,
}
impl<'map> TaikoStars<'map> {
/// Create a new difficulty calculator for osu!taiko maps.
#[inline]
pub fn new(map: &'map Beatmap) -> Self {
let map = map.convert_mode(GameMode::Taiko);
let is_convert = matches!(map, Cow::Owned(_));
Self {
map,
mods: 0,
passed_objects: None,
clock_rate: None,
is_convert,
}
}
/// 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(mut self, mods: u32) -> Self {
self.mods = mods;
self
}
/// Amount of passed objects for partial plays, e.g. a fail.
///
/// If you want to calculate the difficulty after every few objects, instead of
/// using [`TaikoStars`] multiple times with different `passed_objects`, you should use
/// [`TaikoGradualDifficultyAttributes`](crate::taiko::TaikoGradualDifficultyAttributes).
#[inline]
pub 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 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.
#[inline]
pub 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) -> TaikoDifficultyAttributes {
let clock_rate = self.clock_rate.unwrap_or_else(|| self.mods.clock_rate());
let BeatmapHitWindows { od: hit_window, .. } = self
.map
.attributes()
.mods(self.mods)
.clock_rate(clock_rate)
.hit_windows();
let is_convert = self.is_convert || matches!(self.map, Cow::Owned(_));
let (peaks, max_combo) = calculate_skills(self);
let PeaksDifficultyValues {
mut colour_rating,
mut rhythm_rating,
mut stamina_rating,
mut combined_rating,
} = peaks.difficulty_values();
colour_rating *= DIFFICULTY_MULTIPLIER;
rhythm_rating *= DIFFICULTY_MULTIPLIER;
stamina_rating *= DIFFICULTY_MULTIPLIER;
combined_rating *= DIFFICULTY_MULTIPLIER;
let mut star_rating = rescale(combined_rating * 1.4);
// * TODO: This is temporary measure as we don't detect abuse of multiple-input
// * playstyles of converts within the current system.
if is_convert {
star_rating *= 0.925;
// * For maps with low colour variance and high stamina requirement,
// * multiple inputs are more likely to be abused.
if colour_rating < 2.0 && stamina_rating > 8.0 {
star_rating *= 0.8;
}
}
TaikoDifficultyAttributes {
stamina: stamina_rating,
rhythm: rhythm_rating,
colour: colour_rating,
peak: combined_rating,
hit_window,
stars: star_rating,
max_combo,
}
}
/// Calculate the skill strains.
///
/// Suitable to plot the difficulty of a map over time.
#[inline]
pub fn strains(self) -> TaikoStrains {
let (peaks, _) = calculate_skills(self);
let PeaksRaw {
colour,
rhythm,
stamina,
} = peaks.into_raw();
TaikoStrains {
section_len: SECTION_LEN as f64,
color: colour,
rhythm,
stamina,
}
}
}
/// 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 TaikoStrains {
/// Time in ms inbetween two strains.
pub section_len: f64,
/// Strain peaks of the color skill.
pub color: Vec<f64>,
/// Strain peaks of the rhythm skill.
pub rhythm: Vec<f64>,
/// Strain peaks of the stamina skill.
pub stamina: Vec<f64>,
}
impl TaikoStrains {
/// Returns the number of strain peaks per skill.
#[inline]
#[allow(clippy::len_without_is_empty)]
pub fn len(&self) -> usize {
self.color.len()
}
}
fn calculate_skills(params: TaikoStars<'_>) -> (Peaks, usize) {
let TaikoStars {
map,
mods,
passed_objects,
clock_rate,
is_convert: _,
} = params;
let mut take = passed_objects.unwrap_or(map.hit_objects.len());
let clock_rate = clock_rate.unwrap_or_else(|| mods.clock_rate());
let mut peaks = Peaks::new();
let mut max_combo = 0;
let mut diff_objects = map
.taiko_objects()
.take_while(|(h, _)| {
if h.is_hit {
if take == 0 {
return false;
}
max_combo += 1;
take -= 1;
}
true
})
.skip(2)
.zip(map.hit_objects.iter().skip(1))
.zip(map.hit_objects.iter())
.enumerate()
.fold(
ObjectLists::default(),
|mut lists, (idx, (((base, base_start_time), last), last_last))| {
let diff_obj = TaikoDifficultyObject::new(
base,
base_start_time,
last.start_time,
last_last.start_time,
clock_rate,
&lists,
idx,
);
match &diff_obj.mono_idx {
MonoIndex::Centre(_) => lists.centres.push(idx),
MonoIndex::Rim(_) => lists.rims.push(idx),
MonoIndex::None => {}
}
if diff_obj.note_idx.is_some() {
lists.notes.push(idx);
}
lists.all.push(Rc::new(RefCell::new(diff_obj)));
lists
},
);
ColourDifficultyPreprocessor::process_and_assign(&mut diff_objects);
for hit_object in diff_objects.all.iter() {
peaks.process(&hit_object.borrow(), &diff_objects);
}
(peaks, max_combo)
}
#[inline]
fn rescale(stars: f64) -> f64 {
if stars < 0.0 {
stars
} else {
10.43 * (stars / 8.0 + 1.0).ln()
}
}
/// The result of a difficulty calculation on an osu!taiko map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TaikoDifficultyAttributes {
/// The difficulty corresponding to the stamina skill.
pub stamina: f64,
/// The difficulty corresponding to the rhythm skill.
pub rhythm: f64,
/// The difficulty corresponding to the colour skill.
pub colour: f64,
/// The difficulty corresponding to the hardest parts of the map.
pub peak: f64,
/// The perceived hit window for an n300 inclusive of rate-adjusting mods (DT/HT/etc)
pub hit_window: f64,
/// The final star rating.
pub stars: f64,
/// The maximum combo.
pub max_combo: usize,
}
impl TaikoDifficultyAttributes {
/// Return the maximum combo.
#[inline]
pub fn max_combo(&self) -> usize {
self.max_combo
}
}
/// The result of a performance calculation on an osu!taiko map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TaikoPerformanceAttributes {
/// The difficulty attributes that were used for the performance calculation
pub difficulty: TaikoDifficultyAttributes,
/// The final performance points.
pub pp: f64,
/// The accuracy portion of the final pp.
pub pp_acc: f64,
/// The strain portion of the final pp.
pub pp_difficulty: f64,
/// Scaled miss count based on total hits.
pub effective_miss_count: f64,
}
impl TaikoPerformanceAttributes {
/// Return the star value.
#[inline]
pub fn stars(&self) -> f64 {
self.difficulty.stars
}
/// Return the performance point value.
#[inline]
pub fn pp(&self) -> f64 {
self.pp
}
/// Return the maximum combo of the map.
#[inline]
pub fn max_combo(&self) -> usize {
self.difficulty.max_combo
}
}
impl From<TaikoPerformanceAttributes> for TaikoDifficultyAttributes {
#[inline]
fn from(attributes: TaikoPerformanceAttributes) -> Self {
attributes.difficulty
}
}
impl<'map> From<OsuStars<'map>> for TaikoStars<'map> {
#[inline]
fn from(osu: OsuStars<'map>) -> Self {
let OsuStars {
map,
mods,
passed_objects,
clock_rate,
} = osu;
Self {
map: map.convert_mode(GameMode::Taiko),
mods,
passed_objects,
clock_rate,
is_convert: true,
}
}
}
+563
View File
@@ -0,0 +1,563 @@
use std::borrow::Cow;
use super::{TaikoDifficultyAttributes, TaikoPerformanceAttributes, TaikoScoreState, TaikoStars};
use crate::{
Beatmap, DifficultyAttributes, GameMode, HitResultPriority, Mods, OsuPP, PerformanceAttributes,
};
/// Performance calculator on osu!taiko maps.
///
/// # Example
///
/// ```
/// use rosu_pp::{TaikoPP, Beatmap};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let pp_result = TaikoPP::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 = TaikoPP::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)]
pub struct TaikoPP<'map> {
pub(crate) map: Cow<'map, Beatmap>,
attributes: Option<TaikoDifficultyAttributes>,
mods: u32,
combo: Option<usize>,
acc: Option<f64>,
passed_objects: Option<usize>,
clock_rate: Option<f64>,
hitresult_priority: Option<HitResultPriority>,
pub(crate) n300: Option<usize>,
pub(crate) n100: Option<usize>,
pub(crate) n_misses: Option<usize>,
}
impl<'map> TaikoPP<'map> {
/// Create a new performance calculator for osu!taiko maps.
#[inline]
pub fn new(map: &'map Beatmap) -> Self {
Self {
map: map.convert_mode(GameMode::Taiko),
attributes: None,
mods: 0,
combo: None,
acc: None,
n_misses: None,
passed_objects: None,
clock_rate: None,
n300: None,
n100: None,
hitresult_priority: 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, attrs: impl TaikoAttributeProvider) -> Self {
if let Some(attrs) = attrs.attributes() {
self.attributes = Some(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 fn mods(mut self, mods: u32) -> Self {
self.mods = mods;
self
}
/// Specify the max combo of the play.
#[inline]
pub fn combo(mut self, combo: usize) -> Self {
self.combo = Some(combo);
self
}
/// Specify how hitresults should be generated.
///
/// Defauls to [`HitResultPriority::BestCase`].
#[inline]
pub fn hitresult_priority(mut self, priority: HitResultPriority) -> Self {
self.hitresult_priority = Some(priority);
self
}
/// Specify the amount of 300s of a play.
#[inline]
pub fn n300(mut self, n300: usize) -> Self {
self.n300 = Some(n300);
self
}
/// Specify the amount of 100s of a play.
#[inline]
pub fn n100(mut self, n100: usize) -> Self {
self.n100 = Some(n100);
self
}
/// Specify the amount of misses of the play.
#[inline]
pub fn n_misses(mut self, n_misses: usize) -> Self {
self.n_misses = Some(n_misses.min(self.map.n_circles as usize));
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
}
/// Amount of passed objects for partial plays, e.g. a fail.
///
/// If you want to calculate the performance after every few objects, instead of
/// using [`TaikoPP`] multiple times with different `passed_objects`, you should use
/// [`TaikoGradualPerformanceAttributes`](crate::taiko::TaikoGradualPerformanceAttributes).
#[inline]
pub 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 fn clock_rate(mut self, clock_rate: f64) -> Self {
self.clock_rate = Some(clock_rate);
self
}
/// Provide parameters through a [`TaikoScoreState`].
#[inline]
pub fn state(mut self, state: TaikoScoreState) -> Self {
let TaikoScoreState {
max_combo,
n300,
n100,
n_misses,
} = state;
self.combo = Some(max_combo);
self.n300 = Some(n300);
self.n100 = Some(n100);
self.n_misses = Some(n_misses);
self
}
/// Calculate all performance related values, including pp and stars.
pub fn calculate(mut self) -> TaikoPerformanceAttributes {
let attrs = self.attributes.take().unwrap_or_else(|| {
let mut calculator = TaikoStars::new(self.map.as_ref())
.mods(self.mods)
.is_convert(matches!(self.map, Cow::Owned(_)));
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()
});
let inner = TaikoPpInner {
mods: self.mods,
state: self.generate_hitresults(attrs.max_combo),
attrs,
};
inner.calculate()
}
fn generate_hitresults(&self, max_combo: usize) -> TaikoScoreState {
let total_result_count = if let Some(passed_objects) = self.passed_objects {
max_combo.min(passed_objects)
} else {
max_combo
};
let priority = self.hitresult_priority.unwrap_or_default();
let mut n300 = self.n300.unwrap_or(0);
let mut n100 = self.n100.unwrap_or(0);
let n_misses = self.n_misses.unwrap_or(0);
if let Some(acc) = self.acc {
match (self.n300, self.n100) {
(Some(_), Some(_)) => {
let remaining = total_result_count.saturating_sub(n300 + n100 + n_misses);
match priority {
HitResultPriority::BestCase => n300 += remaining,
HitResultPriority::WorstCase => n100 += remaining,
}
}
(Some(_), None) => n100 += total_result_count.saturating_sub(n300 + n_misses),
(None, Some(_)) => n300 += total_result_count.saturating_sub(n100 + n_misses),
(None, None) => {
let target_total = (acc * (total_result_count * 2) as f64).round() as usize;
n300 = target_total - (total_result_count.saturating_sub(n_misses));
n100 = total_result_count.saturating_sub(n300 + n_misses);
}
}
} else {
let remaining = total_result_count.saturating_sub(n300 + n100 + n_misses);
match priority {
HitResultPriority::BestCase => match (self.n300, self.n100) {
(Some(_), None) => n100 = remaining,
(Some(_), Some(_)) => n300 += remaining,
(None, _) => n300 = remaining,
},
HitResultPriority::WorstCase => match (self.n300, self.n100) {
(None, Some(_)) => n300 = remaining,
(Some(_), Some(_)) => n100 += remaining,
(_, None) => n100 = remaining,
},
}
}
let max_combo = self.combo.map_or(max_combo, |combo| combo.min(max_combo));
TaikoScoreState {
max_combo,
n300,
n100,
n_misses,
}
}
}
struct TaikoPpInner {
attrs: TaikoDifficultyAttributes,
mods: u32,
state: TaikoScoreState,
}
impl TaikoPpInner {
fn calculate(self) -> TaikoPerformanceAttributes {
// * The effectiveMissCount is calculated by gaining a ratio for totalSuccessfulHits
// * and increasing the miss penalty for shorter object counts lower than 1000.
let total_successful_hits = self.total_successful_hits();
let effective_miss_count = if total_successful_hits > 0 {
(1000.0 / (total_successful_hits as f64)).max(1.0) * self.state.n_misses as f64
} else {
0.0
};
let mut multiplier = 1.13;
if self.mods.hd() {
multiplier *= 1.075;
}
if self.mods.ez() {
multiplier *= 0.975;
}
let diff_value = self.compute_difficulty_value(effective_miss_count);
let acc_value = self.compute_accuracy_value();
let pp = (diff_value.powf(1.1) + acc_value.powf(1.1)).powf(1.0 / 1.1) * multiplier;
TaikoPerformanceAttributes {
difficulty: self.attrs,
pp,
pp_acc: acc_value,
pp_difficulty: diff_value,
effective_miss_count,
}
}
fn compute_difficulty_value(&self, effective_miss_count: f64) -> f64 {
let attrs = &self.attrs;
let exp_base = 5.0 * (attrs.stars / 0.115).max(1.0) - 4.0;
let mut diff_value = exp_base.powf(2.25) / 1150.0;
let len_bonus = 1.0 + 0.1 * (attrs.max_combo as f64 / 1500.0).min(1.0);
diff_value *= len_bonus;
diff_value *= 0.986_f64.powf(effective_miss_count);
if self.mods.ez() {
diff_value *= 0.985;
}
if self.mods.hd() {
diff_value *= 1.025;
}
if self.mods.hr() {
diff_value *= 1.05;
}
if self.mods.fl() {
diff_value *= 1.05 * len_bonus;
}
let acc = self.custom_accuracy();
diff_value * acc * acc
}
#[inline]
fn compute_accuracy_value(&self) -> f64 {
if self.attrs.hit_window <= 0.0 {
return 0.0;
}
let mut acc_value = (60.0 / self.attrs.hit_window).powf(1.1)
* self.custom_accuracy().powi(8)
* self.attrs.stars.powf(0.4)
* 27.0;
let len_bonus = (self.total_hits() / 1500.0).powf(0.3).min(1.15);
acc_value *= len_bonus;
// * Slight HDFL Bonus for accuracy. A clamp is used to prevent against negative values
if self.mods.hd() && self.mods.fl() {
acc_value *= (1.075 * len_bonus).max(1.05);
}
acc_value
}
fn total_hits(&self) -> f64 {
self.state.total_hits() as f64
}
fn total_successful_hits(&self) -> usize {
self.state.n300 + self.state.n100
}
fn custom_accuracy(&self) -> f64 {
let total_hits = self.state.total_hits();
if total_hits == 0 {
return 0.0;
}
let numerator = self.state.n300 * 300 + self.state.n100 * 150;
let denominator = total_hits * 300;
numerator as f64 / denominator as f64
}
}
impl<'map> From<OsuPP<'map>> for TaikoPP<'map> {
#[inline]
fn from(osu: OsuPP<'map>) -> Self {
let OsuPP {
map,
attributes: _,
mods,
acc,
combo,
n300,
n100,
n50: _,
n_misses,
passed_objects,
clock_rate,
hitresult_priority,
} = osu;
Self {
map: map.convert_mode(GameMode::Taiko),
attributes: None,
mods,
combo,
acc,
passed_objects,
clock_rate,
hitresult_priority,
n300,
n100,
n_misses,
}
}
}
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
pub trait TaikoAttributeProvider {
/// Provide the actual difficulty attributes.
fn attributes(self) -> Option<TaikoDifficultyAttributes>;
}
impl TaikoAttributeProvider for TaikoDifficultyAttributes {
#[inline]
fn attributes(self) -> Option<TaikoDifficultyAttributes> {
Some(self)
}
}
impl TaikoAttributeProvider for TaikoPerformanceAttributes {
#[inline]
fn attributes(self) -> Option<TaikoDifficultyAttributes> {
Some(self.difficulty)
}
}
impl TaikoAttributeProvider for DifficultyAttributes {
#[inline]
fn attributes(self) -> Option<TaikoDifficultyAttributes> {
#[allow(irrefutable_let_patterns)]
if let Self::Taiko(attributes) = self {
Some(attributes)
} else {
None
}
}
}
impl TaikoAttributeProvider for PerformanceAttributes {
#[inline]
fn attributes(self) -> Option<TaikoDifficultyAttributes> {
#[allow(irrefutable_let_patterns)]
if let Self::Taiko(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;
fn test_data() -> (Beatmap, TaikoDifficultyAttributes) {
let path = "./maps/1028484.osu";
let map = Beatmap::from_path(path).unwrap();
let attrs = TaikoDifficultyAttributes {
stamina: 1.4528845068865617,
rhythm: 0.20130047251681948,
colour: 1.0487315549761433,
peak: 1.8881824429738323,
hit_window: 35.0,
stars: 2.9778030386845606,
max_combo: 289,
};
(map, attrs)
}
#[test]
fn hitresults_n300_n_misses_best() {
let (map, attrs) = test_data();
let max_combo = attrs.max_combo();
let state = TaikoPP::new(&map)
.attributes(attrs)
.combo(100)
.n300(150)
.n_misses(2)
.hitresult_priority(HitResultPriority::BestCase)
.generate_hitresults(max_combo);
let expected = TaikoScoreState {
max_combo: 100,
n300: 150,
n100: 137,
n_misses: 2,
};
assert_eq!(state, expected);
}
#[test]
fn hitresults_n_misses_best() {
let (map, attrs) = test_data();
let max_combo = attrs.max_combo();
let state = TaikoPP::new(&map)
.attributes(attrs)
.combo(100)
.n_misses(2)
.hitresult_priority(HitResultPriority::BestCase)
.generate_hitresults(max_combo);
let expected = TaikoScoreState {
max_combo: 100,
n300: 287,
n100: 0,
n_misses: 2,
};
assert_eq!(state, expected);
}
#[test]
fn hitresults_acc_n_misses_worst() {
let (map, attrs) = test_data();
let max_combo = attrs.max_combo();
let state = TaikoPP::new(&map)
.attributes(attrs)
.combo(100)
.accuracy(97.2)
.n_misses(2)
.hitresult_priority(HitResultPriority::WorstCase)
.generate_hitresults(max_combo);
let expected = TaikoScoreState {
max_combo: 100,
n300: 275,
n100: 12,
n_misses: 2,
};
assert_eq!(
state,
expected,
"{}% vs {}%",
state.accuracy(),
expected.accuracy()
);
}
}
+12
View File
@@ -0,0 +1,12 @@
use crate::parse::HitSound;
pub(crate) trait Rim {
fn is_rim(&self) -> bool;
}
impl Rim for u8 {
#[inline]
fn is_rim(&self) -> bool {
self.clap() || self.whistle()
}
}
+160
View File
@@ -0,0 +1,160 @@
use std::{
cell::RefCell,
rc::{Rc, Weak},
};
use crate::taiko::{
colours::{AlternatingMonoPattern, MonoStreak, RepeatingHitPatterns},
difficulty_object::{ObjectLists, TaikoDifficultyObject},
};
use super::{Skill, StrainDecaySkill, StrainSkill};
#[derive(Clone, Debug)]
pub(crate) struct Colour {
curr_strain: f64,
curr_section_peak: f64,
curr_section_end: f64,
pub(crate) strain_peaks: Vec<f64>,
}
impl Colour {
pub(crate) fn new() -> Self {
Self {
curr_strain: 0.0,
curr_section_peak: 0.0,
curr_section_end: 0.0,
strain_peaks: Vec::new(),
}
}
}
impl Skill for Colour {
#[inline]
fn process(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists) {
<Self as StrainSkill>::process(self, curr, hit_objects)
}
#[inline]
fn difficulty_value(self) -> f64 {
<Self as StrainSkill>::difficulty_value(self)
}
}
impl StrainSkill for Colour {
#[inline]
fn strain_peaks_mut(&mut self) -> &mut Vec<f64> {
&mut self.strain_peaks
}
#[inline]
fn curr_section_peak(&mut self) -> &mut f64 {
&mut self.curr_section_peak
}
#[inline]
fn curr_section_end(&mut self) -> &mut f64 {
&mut self.curr_section_end
}
#[inline]
fn strain_value_at(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists) -> f64 {
<Self as StrainDecaySkill>::strain_value_at(self, curr, hit_objects)
}
#[inline]
fn calculate_initial_strain(&self, time: f64, curr: &TaikoDifficultyObject) -> f64 {
<Self as StrainDecaySkill>::calculate_initial_strain(self, time, curr)
}
}
impl StrainDecaySkill for Colour {
const SKILL_MULTIPLIER: f64 = 0.12;
const STRAIN_DECAY_BASE: f64 = 0.8;
#[inline]
fn curr_strain(&self) -> f64 {
self.curr_strain
}
#[inline]
fn curr_strain_mut(&mut self) -> &mut f64 {
&mut self.curr_strain
}
#[inline]
fn strain_value_of(&mut self, curr: &TaikoDifficultyObject, _: &ObjectLists) -> f64 {
ColourEvaluator::evaluate_diff_of(curr)
}
}
struct ColourEvaluator;
impl ColourEvaluator {
fn sigmoid(val: f64, center: f64, width: f64, middle: f64, height: f64) -> f64 {
let sigmoid = (std::f64::consts::E * -(val - center) / width).tanh();
sigmoid * (height / 2.0) + middle
}
fn evaluate_diff_of_mono_streak(mono_streak: Rc<RefCell<MonoStreak>>) -> f64 {
let mono_streak = mono_streak.borrow();
let parent_eval = mono_streak
.parent
.as_ref()
.and_then(Weak::upgrade)
.map_or(1.0, Self::evaluate_diff_of_alternating_mono_pattern);
Self::sigmoid(mono_streak.idx as f64, 2.0, 2.0, 0.5, 1.0) * parent_eval * 0.5
}
fn evaluate_diff_of_alternating_mono_pattern(
alternating_mono_pattern: Rc<RefCell<AlternatingMonoPattern>>,
) -> f64 {
let alternating_mono_pattern = alternating_mono_pattern.borrow();
let parent_eval = alternating_mono_pattern
.parent
.as_ref()
.and_then(Weak::upgrade)
.map_or(1.0, Self::evaluate_diff_of_repeating_hit_patterns);
Self::sigmoid(alternating_mono_pattern.idx as f64, 2.0, 2.0, 0.5, 1.0) * parent_eval
}
fn evaluate_diff_of_repeating_hit_patterns(
repeating_hit_patterns: Rc<RefCell<RepeatingHitPatterns>>,
) -> f64 {
let repetition_interval = repeating_hit_patterns.borrow().repetition_interval as f64;
2.0 * (1.0 - Self::sigmoid(repetition_interval, 2.0, 2.0, 0.5, 1.0))
}
fn evaluate_diff_of(hit_object: &TaikoDifficultyObject) -> f64 {
let colour = &hit_object.colour;
let mut difficulty = 0.0;
// * Difficulty for MonoStreak
if let Some(mono_streak) = colour.mono_streak.as_ref().and_then(Weak::upgrade) {
difficulty += Self::evaluate_diff_of_mono_streak(mono_streak);
}
// * Difficulty for AlternatingMonoPattern
if let Some(alternating_mono_pattern) = colour
.alternating_mono_pattern
.as_ref()
.and_then(Weak::upgrade)
{
difficulty += Self::evaluate_diff_of_alternating_mono_pattern(alternating_mono_pattern);
}
// * Difficulty for RepeatingHitPattern
if let Some(repeating_hit_patterns) = colour.repeating_hit_patterns.as_ref().map(Rc::clone)
{
difficulty += Self::evaluate_diff_of_repeating_hit_patterns(repeating_hit_patterns);
}
difficulty
}
}
+10
View File
@@ -0,0 +1,10 @@
mod colour;
mod peaks;
mod rhythm;
mod stamina;
mod traits;
pub(crate) use self::{
peaks::{Peaks, PeaksDifficultyValues, PeaksRaw},
traits::{Skill, StrainDecaySkill, StrainSkill},
};
+122
View File
@@ -0,0 +1,122 @@
use std::cmp::Ordering;
use crate::taiko::difficulty_object::{ObjectLists, TaikoDifficultyObject};
use super::{colour::Colour, rhythm::Rhythm, stamina::Stamina, Skill, StrainSkill};
#[derive(Clone, Debug)]
pub(crate) struct Peaks {
colour: Colour,
rhythm: Rhythm,
stamina: Stamina,
}
impl Peaks {
const RHYTHM_SKILL_MULTIPLIER: f64 = 0.2 * Self::FINAL_MULTIPLIER;
const COLOUR_SKILL_MULTIPLIER: f64 = 0.375 * Self::FINAL_MULTIPLIER;
const STAMINA_SKILL_MULTIPLIER: f64 = 0.375 * Self::FINAL_MULTIPLIER;
const FINAL_MULTIPLIER: f64 = 0.0625;
pub(crate) fn new() -> Self {
Self {
colour: Colour::new(),
rhythm: Rhythm::new(),
stamina: Stamina::new(),
}
}
pub(crate) fn difficulty_values(self) -> PeaksDifficultyValues {
let colour_rating = <Colour as StrainSkill>::difficulty_value(self.colour.clone())
* Self::COLOUR_SKILL_MULTIPLIER;
let rhythm_rating = <Rhythm as StrainSkill>::difficulty_value(self.rhythm.clone())
* Self::RHYTHM_SKILL_MULTIPLIER;
let stamina_rating = <Stamina as StrainSkill>::difficulty_value(self.stamina.clone())
* Self::STAMINA_SKILL_MULTIPLIER;
PeaksDifficultyValues {
colour_rating,
rhythm_rating,
stamina_rating,
combined_rating: self.difficulty_value(),
}
}
pub(crate) fn into_raw(self) -> PeaksRaw {
PeaksRaw {
colour: self.colour.strain_peaks,
rhythm: self.rhythm.strain_peaks,
stamina: self.stamina.strain_peaks,
}
}
fn norm(p: f64, values: impl IntoIterator<Item = f64>) -> f64 {
values
.into_iter()
.fold(0.0, |sum, x| sum + x.powf(p))
.powf(p.recip())
}
}
impl Skill for Peaks {
#[inline]
fn process(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists) {
<Colour as Skill>::process(&mut self.colour, curr, hit_objects);
<Rhythm as Skill>::process(&mut self.rhythm, curr, hit_objects);
<Stamina as Skill>::process(&mut self.stamina, curr, hit_objects);
}
fn difficulty_value(self) -> f64 {
let mut peaks = Vec::new();
let colour_peaks = self.colour.get_curr_strain_peaks();
let rhythm_peaks = self.rhythm.get_curr_strain_peaks();
let stamina_peaks = self.stamina.get_curr_strain_peaks();
let zip = colour_peaks
.into_iter()
.zip(rhythm_peaks)
.zip(stamina_peaks);
for ((mut colour_peak, mut rhythm_peak), mut stamina_peak) in zip {
colour_peak *= Self::COLOUR_SKILL_MULTIPLIER;
rhythm_peak *= Self::RHYTHM_SKILL_MULTIPLIER;
stamina_peak *= Self::STAMINA_SKILL_MULTIPLIER;
let mut peak = Self::norm(1.5, [colour_peak, stamina_peak]);
peak = Self::norm(2.0, [peak, rhythm_peak]);
// * Sections with 0 strain are excluded to avoid worst-case
// * time complexity of the following sort (e.g. /b/2351871).
// * These sections will not contribute to the difficulty.
if peak > 0.0 {
peaks.push(peak);
}
}
let mut difficulty = 0.0;
let mut weight = 1.0;
peaks.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
for strain in peaks {
difficulty += strain * weight;
weight *= 0.9;
}
difficulty
}
}
pub(crate) struct PeaksDifficultyValues {
pub(crate) colour_rating: f64,
pub(crate) rhythm_rating: f64,
pub(crate) stamina_rating: f64,
pub(crate) combined_rating: f64,
}
pub(crate) struct PeaksRaw {
pub(crate) colour: Vec<f64>,
pub(crate) rhythm: Vec<f64>,
pub(crate) stamina: Vec<f64>,
}
+210
View File
@@ -0,0 +1,210 @@
use crate::{
taiko::difficulty_object::{HitObjectRhythm, ObjectLists, TaikoDifficultyObject},
util::LimitedQueue,
};
use super::{Skill, StrainDecaySkill, StrainSkill};
const HISTORY_MAX_LEN: usize = 8;
#[derive(Clone, Debug)]
pub(crate) struct Rhythm {
// Equal to osu's CurrentStrain from abstract class StrainDecaySkill
curr_decay_strain: f64,
// Equal to osu's currentStrain from class Rhythm
curr_strain: f64,
notes_since_rhythm_change: usize,
history: LimitedQueue<HistoryElement, HISTORY_MAX_LEN>,
curr_section_peak: f64,
curr_section_end: f64,
pub(crate) strain_peaks: Vec<f64>,
}
impl Rhythm {
const STRAIN_DECAY: f64 = 0.96;
pub(crate) fn new() -> Self {
Self {
curr_decay_strain: 0.0,
curr_strain: 0.0,
notes_since_rhythm_change: 0,
history: LimitedQueue::new(),
curr_section_peak: 0.0,
curr_section_end: 0.0,
strain_peaks: Vec::new(),
}
}
fn reset_rhythm_and_strain(&mut self) {
self.curr_strain = 0.0;
self.notes_since_rhythm_change = 0;
}
fn repetition_penalties(&mut self, hit_object: &TaikoDifficultyObject) -> f64 {
let mut penalty = 1.0;
self.history.push(HistoryElement::new(hit_object));
for most_recent_patterns_to_compare in 2..=(HISTORY_MAX_LEN / 2).min(self.history.len()) {
for start in (0..self.history.len() - most_recent_patterns_to_compare).rev() {
if !self.same_pattern(start, most_recent_patterns_to_compare) {
continue;
}
let notes_since = hit_object.idx - self.history[start].idx;
penalty *= Self::repetition_penalty(notes_since);
break;
}
}
penalty
}
fn same_pattern(&self, start: usize, most_recent_patterns_to_compare: usize) -> bool {
let start = self.history.iter().skip(start);
let most_recent_patterns_to_compare = self
.history
.iter()
.skip(self.history.len() - most_recent_patterns_to_compare);
start
.zip(most_recent_patterns_to_compare)
.all(|(a, b)| a.rhythm == b.rhythm)
}
fn repetition_penalty(notes_since: usize) -> f64 {
(0.032 * notes_since as f64).min(1.0)
}
fn pattern_len_penalty(pattern_len: usize) -> f64 {
let pattern_len = pattern_len as f64;
let short_pattern_penalty = (0.15 * pattern_len).min(1.0);
let long_pattern_penalty = (2.5 - 0.15 * pattern_len).clamp(0.0, 1.0);
short_pattern_penalty.min(long_pattern_penalty)
}
fn speed_penalty(&mut self, delta: f64) -> f64 {
if delta < 80.0 {
return 1.0;
} else if delta < 210.0 {
return (1.4 - 0.005 * delta).max(0.0);
}
self.reset_rhythm_and_strain();
0.0
}
}
impl Skill for Rhythm {
#[inline]
fn process(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists) {
<Self as StrainSkill>::process(self, curr, hit_objects)
}
#[inline]
fn difficulty_value(self) -> f64 {
<Self as StrainSkill>::difficulty_value(self)
}
}
impl StrainSkill for Rhythm {
#[inline]
fn strain_peaks_mut(&mut self) -> &mut Vec<f64> {
&mut self.strain_peaks
}
#[inline]
fn curr_section_peak(&mut self) -> &mut f64 {
&mut self.curr_section_peak
}
#[inline]
fn curr_section_end(&mut self) -> &mut f64 {
&mut self.curr_section_end
}
#[inline]
fn strain_value_at(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists) -> f64 {
<Self as StrainDecaySkill>::strain_value_at(self, curr, hit_objects)
}
#[inline]
fn calculate_initial_strain(&self, time: f64, curr: &TaikoDifficultyObject) -> f64 {
<Self as StrainDecaySkill>::calculate_initial_strain(self, time, curr)
}
}
impl StrainDecaySkill for Rhythm {
const SKILL_MULTIPLIER: f64 = 10.0;
const STRAIN_DECAY_BASE: f64 = 0.0;
#[inline]
fn curr_strain(&self) -> f64 {
self.curr_decay_strain
}
#[inline]
fn curr_strain_mut(&mut self) -> &mut f64 {
&mut self.curr_decay_strain
}
fn strain_value_of(&mut self, curr: &TaikoDifficultyObject, _: &ObjectLists) -> f64 {
let base_is_circle = curr.base.is_hit;
// * drum rolls and swells are exempt.
if !base_is_circle {
self.reset_rhythm_and_strain();
return 0.0;
}
self.curr_strain *= Self::STRAIN_DECAY;
self.notes_since_rhythm_change += 1;
// * rhythm difficulty zero (due to rhythm not changing) => no rhythm strain.
if curr.rhythm.difficulty.abs() <= f64::EPSILON {
return 0.0;
}
let mut obj_strain = curr.rhythm.difficulty;
obj_strain *= self.repetition_penalties(curr);
obj_strain *= Self::pattern_len_penalty(self.notes_since_rhythm_change);
obj_strain *= self.speed_penalty(curr.delta);
// * careful - needs to be done here since calls above read this value
self.notes_since_rhythm_change = 0;
self.curr_strain += obj_strain;
self.curr_strain
}
}
#[derive(Copy, Clone, Debug)]
pub(crate) struct HistoryElement {
idx: usize,
rhythm: &'static HitObjectRhythm,
}
impl HistoryElement {
fn new(difficulty_object: &TaikoDifficultyObject) -> Self {
Self {
idx: difficulty_object.idx,
rhythm: difficulty_object.rhythm,
}
}
}
impl Default for HistoryElement {
fn default() -> Self {
Self {
idx: 0,
rhythm: HitObjectRhythm::static_ref(),
}
}
}
+112
View File
@@ -0,0 +1,112 @@
use crate::taiko::difficulty_object::{ObjectLists, TaikoDifficultyObject};
use super::{Skill, StrainDecaySkill, StrainSkill};
#[derive(Clone, Debug)]
pub(crate) struct Stamina {
curr_strain: f64,
curr_section_peak: f64,
curr_section_end: f64,
pub(crate) strain_peaks: Vec<f64>,
}
impl Stamina {
pub(crate) fn new() -> Self {
Self {
curr_strain: 0.0,
curr_section_peak: 0.0,
curr_section_end: 0.0,
strain_peaks: Vec::new(),
}
}
}
impl Skill for Stamina {
#[inline]
fn process(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists) {
<Self as StrainSkill>::process(self, curr, hit_objects)
}
#[inline]
fn difficulty_value(self) -> f64 {
<Self as StrainSkill>::difficulty_value(self)
}
}
impl StrainSkill for Stamina {
#[inline]
fn strain_peaks_mut(&mut self) -> &mut Vec<f64> {
&mut self.strain_peaks
}
#[inline]
fn curr_section_peak(&mut self) -> &mut f64 {
&mut self.curr_section_peak
}
#[inline]
fn curr_section_end(&mut self) -> &mut f64 {
&mut self.curr_section_end
}
#[inline]
fn strain_value_at(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists) -> f64 {
<Self as StrainDecaySkill>::strain_value_at(self, curr, hit_objects)
}
#[inline]
fn calculate_initial_strain(&self, time: f64, curr: &TaikoDifficultyObject) -> f64 {
<Self as StrainDecaySkill>::calculate_initial_strain(self, time, curr)
}
}
impl StrainDecaySkill for Stamina {
const SKILL_MULTIPLIER: f64 = 1.1;
const STRAIN_DECAY_BASE: f64 = 0.4;
#[inline]
fn curr_strain(&self) -> f64 {
self.curr_strain
}
#[inline]
fn curr_strain_mut(&mut self) -> &mut f64 {
&mut self.curr_strain
}
#[inline]
fn strain_value_of(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists) -> f64 {
StaminaEvaluator::evaluate_diff_of(curr, hit_objects)
}
}
struct StaminaEvaluator;
impl StaminaEvaluator {
fn speed_bonus(mut interval: f64) -> f64 {
// * Cap to 600bpm 1/4, 25ms note interval, 50ms key interval
// * Interval will be capped at a very small value to avoid infinite/negative speed bonuses.
// * TODO - This is a temporary measure as we need to implement methods of detecting playstyle-abuse of SpeedBonus.
interval = interval.max(50.0);
30.0 / interval
}
fn evaluate_diff_of(hit_object: &TaikoDifficultyObject, hit_objects: &ObjectLists) -> f64 {
if !hit_object.base.is_hit {
return 0.0;
}
// * Find the previous hit object hit by the current key, which is two notes of the same colour prior.
let curr = hit_object;
let key_prev = hit_objects.prev_mono(curr.idx, 1);
if let Some(key_prev) = key_prev {
// * Add a base strain to all objects
0.5 + Self::speed_bonus(curr.start_time - key_prev.borrow().start_time)
} else {
// * There is no previous hit object hit by the current key
0.0
}
}
}
+118
View File
@@ -0,0 +1,118 @@
use std::{cmp::Ordering, mem};
use crate::taiko::{
difficulty_object::{ObjectLists, TaikoDifficultyObject},
SECTION_LEN,
};
pub(crate) trait Skill: Sized {
fn process(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists);
fn difficulty_value(self) -> f64;
}
pub(crate) trait StrainSkill: Skill {
const DECAY_WEIGHT: f64 = 0.9;
fn strain_peaks_mut(&mut self) -> &mut Vec<f64>;
fn curr_section_peak(&mut self) -> &mut f64;
fn curr_section_end(&mut self) -> &mut f64;
fn strain_value_at(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists) -> f64;
fn calculate_initial_strain(&self, time: f64, curr: &TaikoDifficultyObject) -> f64;
fn process(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists) {
// * The first object doesn't generate a strain, so we begin with an incremented section end
if curr.idx == 0 {
let section_len = SECTION_LEN as f64;
*self.curr_section_end() = (curr.start_time / section_len).ceil() * section_len;
}
while curr.start_time > *self.curr_section_end() {
self.save_curr_peak();
{
let section_end = *self.curr_section_end();
self.start_new_section_from(section_end, curr);
}
*self.curr_section_end() += SECTION_LEN as f64;
}
*self.curr_section_peak() = self
.strain_value_at(curr, hit_objects)
.max(*self.curr_section_peak());
}
#[inline]
fn save_curr_peak(&mut self) {
let peak = *self.curr_section_peak();
self.strain_peaks_mut().push(peak);
}
#[inline]
fn start_new_section_from(&mut self, time: f64, curr: &TaikoDifficultyObject) {
// * The maximum strain of the new section is not zero by default
// * This means we need to capture the strain level at the beginning of the new section,
// * and use that as the initial peak level.
*self.curr_section_peak() = self.calculate_initial_strain(time, curr);
}
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);
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
}
#[inline]
fn get_curr_strain_peaks(mut self) -> Vec<f64> {
let curr_peak = *self.curr_section_peak();
let mut strain_peaks = mem::take(self.strain_peaks_mut());
strain_peaks.push(curr_peak);
strain_peaks
}
}
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: &TaikoDifficultyObject, hit_objects: &ObjectLists) -> f64;
#[inline]
fn calculate_initial_strain(&self, time: f64, curr: &TaikoDifficultyObject) -> f64 {
self.curr_strain() * self.strain_decay(time - curr.prev_time)
}
#[inline]
fn strain_value_at(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists) -> f64 {
*self.curr_strain_mut() *= self.strain_decay(curr.delta);
*self.curr_strain_mut() += self.strain_value_of(curr, hit_objects) * Self::SKILL_MULTIPLIER;
self.curr_strain()
}
#[inline]
fn strain_decay(&self, ms: f64) -> f64 {
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
}
}

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