bring back over akatsuki 2019 changes
This commit is contained in:
@@ -180,6 +180,9 @@ pub mod catch;
|
|||||||
/// Types for osu!mania calculations.
|
/// Types for osu!mania calculations.
|
||||||
pub mod mania;
|
pub mod mania;
|
||||||
|
|
||||||
|
/// Types for osu!standard 2019 for relax calculations.
|
||||||
|
pub mod osu_2019;
|
||||||
|
|
||||||
/// Types used in and around this crate.
|
/// Types used in and around this crate.
|
||||||
pub mod model;
|
pub mod model;
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use rosu_map::{
|
|||||||
hit_samples::{HitSoundType, ParseHitSoundTypeError},
|
hit_samples::{HitSoundType, ParseHitSoundTypeError},
|
||||||
HitObjectType, ParseHitObjectTypeError, PathControlPoint, PathType,
|
HitObjectType, ParseHitObjectTypeError, PathControlPoint, PathType,
|
||||||
},
|
},
|
||||||
|
metadata::MetadataKey,
|
||||||
timing_points::{ControlPoint, EffectFlags, ParseEffectFlagsError},
|
timing_points::{ControlPoint, EffectFlags, ParseEffectFlagsError},
|
||||||
},
|
},
|
||||||
util::{KeyValue, ParseNumber, ParseNumberError, Pos, StrExt, MAX_PARSE_VALUE},
|
util::{KeyValue, ParseNumber, ParseNumberError, Pos, StrExt, MAX_PARSE_VALUE},
|
||||||
@@ -49,6 +50,9 @@ pub struct BeatmapState {
|
|||||||
curve_points: Vec<PathControlPoint>,
|
curve_points: Vec<PathControlPoint>,
|
||||||
vertices: Vec<PathControlPoint>,
|
vertices: Vec<PathControlPoint>,
|
||||||
point_split: Vec<*const str>,
|
point_split: Vec<*const str>,
|
||||||
|
|
||||||
|
creator: String,
|
||||||
|
beatmap_id: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BeatmapState {
|
impl BeatmapState {
|
||||||
@@ -270,6 +274,8 @@ impl DecodeState for BeatmapState {
|
|||||||
vertices: Vec::with_capacity(8),
|
vertices: Vec::with_capacity(8),
|
||||||
// mean=19.97 | median=8
|
// mean=19.97 | median=8
|
||||||
point_split: Vec::with_capacity(8),
|
point_split: Vec::with_capacity(8),
|
||||||
|
creator: String::default(),
|
||||||
|
beatmap_id: i32::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,6 +321,8 @@ impl From<BeatmapState> for Beatmap {
|
|||||||
effect_points: state.effect_points,
|
effect_points: state.effect_points,
|
||||||
hit_objects: state.hit_objects,
|
hit_objects: state.hit_objects,
|
||||||
hit_sounds: state.hit_sounds,
|
hit_sounds: state.hit_sounds,
|
||||||
|
creator: state.creator,
|
||||||
|
beatmap_id: state.beatmap_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,7 +455,17 @@ impl DecodeBeatmap for Beatmap {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_metadata(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
|
fn parse_metadata(state: &mut Self::State, line: &str) -> Result<(), Self::Error> {
|
||||||
|
let Ok(KeyValue { key, value }) = KeyValue::parse(line.trim_comment()) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
match key {
|
||||||
|
MetadataKey::Creator => state.creator = value.to_string(),
|
||||||
|
MetadataKey::BeatmapID => state.beatmap_id = value.parse_num()?,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ pub struct Beatmap {
|
|||||||
// HitObjects
|
// HitObjects
|
||||||
pub hit_objects: Vec<HitObject>,
|
pub hit_objects: Vec<HitObject>,
|
||||||
pub hit_sounds: Vec<HitSoundType>,
|
pub hit_sounds: Vec<HitSoundType>,
|
||||||
|
|
||||||
|
pub creator: String,
|
||||||
|
pub beatmap_id: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Beatmap {
|
impl Beatmap {
|
||||||
@@ -225,6 +228,8 @@ impl Default for Beatmap {
|
|||||||
effect_points: Vec::default(),
|
effect_points: Vec::default(),
|
||||||
hit_objects: Vec::default(),
|
hit_objects: Vec::default(),
|
||||||
hit_sounds: Vec::default(),
|
hit_sounds: Vec::default(),
|
||||||
|
creator: String::default(),
|
||||||
|
beatmap_id: i32::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,6 +227,9 @@ impl_has_mod! {
|
|||||||
rx: + Relax ["Relax"],
|
rx: + Relax ["Relax"],
|
||||||
fl: + Flashlight ["Flashlight"],
|
fl: + Flashlight ["Flashlight"],
|
||||||
so: + SpunOut ["SpunOut"],
|
so: + SpunOut ["SpunOut"],
|
||||||
|
dt: + DoubleTime ["DoubleTime"],
|
||||||
|
nc: + Nightcore ["Nightcore"],
|
||||||
|
ht: + HalfTime ["HalfTime"],
|
||||||
bl: - Blinds ["Blinds"],
|
bl: - Blinds ["Blinds"],
|
||||||
cl: - Classic ["Classic"],
|
cl: - Classic ["Classic"],
|
||||||
tc: - Traceable ["Traceable"],
|
tc: - Traceable ["Traceable"],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
use rosu_map::{
|
||||||
|
section::hit_objects::{BorrowedCurve, CurveBuffers},
|
||||||
|
util::Pos,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
model::{
|
||||||
|
control_point::{DifficultyPoint, TimingPoint},
|
||||||
|
hit_object::{HitObject, HitObjectKind, Slider},
|
||||||
|
},
|
||||||
|
Beatmap,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::stars::OsuDifficultyAttributes;
|
||||||
|
|
||||||
|
const LEGACY_LAST_TICK_OFFSET: f64 = 36.0;
|
||||||
|
const BASE_SCORING_DISTANCE: f64 = 100.0;
|
||||||
|
|
||||||
|
pub(crate) struct OsuObject {
|
||||||
|
pub(crate) time: f32,
|
||||||
|
pub(crate) pos: Pos,
|
||||||
|
pub(crate) end_pos: Pos,
|
||||||
|
// 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<f64>,
|
||||||
|
attrs: &mut OsuDifficultyAttributes,
|
||||||
|
curve_bufs: &mut CurveBuffers,
|
||||||
|
) -> Self {
|
||||||
|
attrs.max_combo += 1; // hitcircle, slider head, or spinner
|
||||||
|
|
||||||
|
match &h.kind {
|
||||||
|
HitObjectKind::Circle => {
|
||||||
|
attrs.n_circles += 1;
|
||||||
|
|
||||||
|
Self {
|
||||||
|
time: h.start_time as f32,
|
||||||
|
pos: h.pos,
|
||||||
|
end_pos: h.pos,
|
||||||
|
travel_dist: Some(0.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HitObjectKind::Slider(Slider {
|
||||||
|
expected_dist,
|
||||||
|
repeats,
|
||||||
|
control_points,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
attrs.n_sliders += 1;
|
||||||
|
|
||||||
|
let beat_len = timing_point_at(&map.timing_points, h.start_time)
|
||||||
|
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len);
|
||||||
|
|
||||||
|
let (slider_vel, generate_ticks) =
|
||||||
|
difficulty_point_at(&map.difficulty_points, h.start_time).map_or(
|
||||||
|
(
|
||||||
|
DifficultyPoint::DEFAULT_SLIDER_VELOCITY,
|
||||||
|
DifficultyPoint::DEFAULT_GENERATE_TICKS,
|
||||||
|
),
|
||||||
|
|point| (point.slider_velocity, point.generate_ticks),
|
||||||
|
);
|
||||||
|
|
||||||
|
let scoring_dist = BASE_SCORING_DISTANCE * map.slider_multiplier * slider_vel;
|
||||||
|
let vel = scoring_dist / beat_len;
|
||||||
|
|
||||||
|
// 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 tick_dist_mult = if map.version < 8 {
|
||||||
|
slider_vel.recip()
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut tick_dist = if generate_ticks {
|
||||||
|
scoring_dist / map.slider_tick_rate * tick_dist_mult
|
||||||
|
} else {
|
||||||
|
f64::INFINITY
|
||||||
|
};
|
||||||
|
|
||||||
|
let span_count = (*repeats + 1) as f64;
|
||||||
|
|
||||||
|
// Build the curve w.r.t. the curve points
|
||||||
|
let curve = BorrowedCurve::new(control_points, *expected_dist, 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;
|
||||||
|
|
||||||
|
// 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: f64| {
|
||||||
|
attrs.max_combo += 1;
|
||||||
|
|
||||||
|
let mut progress = (time - h.start_time) / 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);
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
|
||||||
|
if tick_dist != 0.0 {
|
||||||
|
ticks.reserve((len / tick_dist) as usize);
|
||||||
|
|
||||||
|
// Tick 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;
|
||||||
|
compute_vertex(curr_time);
|
||||||
|
ticks.push(curr_time);
|
||||||
|
|
||||||
|
curr_dist += tick_dist;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other spans
|
||||||
|
for span_idx in 1..=*repeats {
|
||||||
|
let span_idx_f64 = span_idx as f64;
|
||||||
|
|
||||||
|
// Repeat point
|
||||||
|
let curr_time = h.start_time + span_duration * span_idx_f64;
|
||||||
|
compute_vertex(curr_time);
|
||||||
|
|
||||||
|
let span_offset = span_idx_f64 * span_duration;
|
||||||
|
|
||||||
|
// Ticks
|
||||||
|
if span_idx & 1 == 1 {
|
||||||
|
let base = h.start_time + h.start_time + span_duration;
|
||||||
|
|
||||||
|
for time in ticks.iter().rev() {
|
||||||
|
compute_vertex(span_offset + base - time);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for time in ticks.iter() {
|
||||||
|
compute_vertex(span_offset + time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ticks.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
compute_vertex(final_span_end_time);
|
||||||
|
|
||||||
|
travel_dist *= scaling_factor;
|
||||||
|
|
||||||
|
Self {
|
||||||
|
time: h.start_time as f32,
|
||||||
|
pos: h.pos,
|
||||||
|
end_pos,
|
||||||
|
travel_dist: Some(travel_dist),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HitObjectKind::Spinner { .. } | HitObjectKind::Hold { .. } => {
|
||||||
|
attrs.n_spinners += 1;
|
||||||
|
|
||||||
|
Self {
|
||||||
|
time: h.start_time as f32,
|
||||||
|
pos: h.pos,
|
||||||
|
end_pos: h.pos,
|
||||||
|
travel_dist: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub(crate) fn is_spinner(&self) -> bool {
|
||||||
|
self.travel_dist.is_none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timing_point_at(points: &[TimingPoint], time: f64) -> Option<&TimingPoint> {
|
||||||
|
let i = points
|
||||||
|
.binary_search_by(|probe| probe.time.total_cmp(&time))
|
||||||
|
.unwrap_or_else(|i| i.saturating_sub(1));
|
||||||
|
|
||||||
|
points.get(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn difficulty_point_at(points: &[DifficultyPoint], time: f64) -> Option<&DifficultyPoint> {
|
||||||
|
points
|
||||||
|
.binary_search_by(|probe| probe.time.total_cmp(&time))
|
||||||
|
.map_or_else(|i| i.checked_sub(1), Some)
|
||||||
|
.map(|i| &points[i])
|
||||||
|
}
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
use super::stars::{stars, OsuDifficultyAttributes, OsuPerformanceAttributes};
|
||||||
|
use crate::{Beatmap, GameMods};
|
||||||
|
|
||||||
|
/// 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: GameMods,
|
||||||
|
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: GameMods::default(),
|
||||||
|
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: impl Into<GameMods>) -> Self {
|
||||||
|
self.mods = mods.into();
|
||||||
|
|
||||||
|
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.clone(), 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.dt() || self.mods.nc() || self.mods.ht()) {
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Penalize misses
|
||||||
|
if effective_miss_count > 0.0 {
|
||||||
|
let miss_penalty = self.calculate_miss_penalty(effective_miss_count);
|
||||||
|
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;
|
||||||
|
|
||||||
|
// HD bonus
|
||||||
|
if self.mods.hd() {
|
||||||
|
aim_value *= 1.0 + 0.05 * (11.0 - attributes.ar) as f32;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FL bonus
|
||||||
|
if self.mods.fl() {
|
||||||
|
aim_value *= 1.0
|
||||||
|
+ 0.3 * (total_hits / 200.0).min(1.0)
|
||||||
|
+ (total_hits > 200.0) as u8 as f32
|
||||||
|
* 0.25
|
||||||
|
* ((total_hits - 200.0) / 300.0).min(1.0)
|
||||||
|
+ (total_hits > 500.0) as u8 as f32 * (total_hits - 500.0) / 1600.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;
|
||||||
|
|
||||||
|
// Penalize misses
|
||||||
|
if effective_miss_count > 0.0 {
|
||||||
|
let miss_penalty = self.calculate_miss_penalty(effective_miss_count);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// 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) -> f32 {
|
||||||
|
let total_hits = self.total_hits() as f32;
|
||||||
|
|
||||||
|
0.97 * (1.0 - (effective_miss_count / total_hits).powf(0.5))
|
||||||
|
.powf(1.0 + (effective_miss_count / 1.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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.1 * 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
//! 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::{DifficultyObject, OsuObject, Skill, SkillKind};
|
||||||
|
|
||||||
|
use crate::{Beatmap, GameMods};
|
||||||
|
|
||||||
|
use rosu_map::section::hit_objects::CurveBuffers;
|
||||||
|
|
||||||
|
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: GameMods,
|
||||||
|
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| {
|
||||||
|
Some(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 = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||||
|
let speed_strain = speed.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||||
|
|
||||||
|
let aim_difficult_strain_count = aim.count_difficult_strains();
|
||||||
|
let speed_difficult_strain_count = speed.count_difficult_strains();
|
||||||
|
|
||||||
|
let stars = aim_strain + speed_strain + (aim_strain - speed_strain).abs() / 2.0;
|
||||||
|
|
||||||
|
diff_attributes.stars = stars as f64;
|
||||||
|
diff_attributes.speed_strain = speed_strain as f64;
|
||||||
|
diff_attributes.aim_strain = aim_strain as f64;
|
||||||
|
diff_attributes.aim_difficult_strain_count = aim_difficult_strain_count;
|
||||||
|
diff_attributes.speed_difficult_strain_count = speed_difficult_strain_count;
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user