bring back over akatsuki 2019 changes
This commit is contained in:
@@ -180,6 +180,9 @@ pub mod catch;
|
||||
/// Types for osu!mania calculations.
|
||||
pub mod mania;
|
||||
|
||||
/// Types for osu!standard 2019 for relax calculations.
|
||||
pub mod osu_2019;
|
||||
|
||||
/// Types used in and around this crate.
|
||||
pub mod model;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use rosu_map::{
|
||||
hit_samples::{HitSoundType, ParseHitSoundTypeError},
|
||||
HitObjectType, ParseHitObjectTypeError, PathControlPoint, PathType,
|
||||
},
|
||||
metadata::MetadataKey,
|
||||
timing_points::{ControlPoint, EffectFlags, ParseEffectFlagsError},
|
||||
},
|
||||
util::{KeyValue, ParseNumber, ParseNumberError, Pos, StrExt, MAX_PARSE_VALUE},
|
||||
@@ -49,6 +50,9 @@ pub struct BeatmapState {
|
||||
curve_points: Vec<PathControlPoint>,
|
||||
vertices: Vec<PathControlPoint>,
|
||||
point_split: Vec<*const str>,
|
||||
|
||||
creator: String,
|
||||
beatmap_id: i32,
|
||||
}
|
||||
|
||||
impl BeatmapState {
|
||||
@@ -270,6 +274,8 @@ impl DecodeState for BeatmapState {
|
||||
vertices: Vec::with_capacity(8),
|
||||
// mean=19.97 | median=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,
|
||||
hit_objects: state.hit_objects,
|
||||
hit_sounds: state.hit_sounds,
|
||||
creator: state.creator,
|
||||
beatmap_id: state.beatmap_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -447,7 +455,17 @@ impl DecodeBeatmap for Beatmap {
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,9 @@ pub struct Beatmap {
|
||||
// HitObjects
|
||||
pub hit_objects: Vec<HitObject>,
|
||||
pub hit_sounds: Vec<HitSoundType>,
|
||||
|
||||
pub creator: String,
|
||||
pub beatmap_id: i32,
|
||||
}
|
||||
|
||||
impl Beatmap {
|
||||
@@ -225,6 +228,8 @@ impl Default for Beatmap {
|
||||
effect_points: Vec::default(),
|
||||
hit_objects: Vec::default(),
|
||||
hit_sounds: Vec::default(),
|
||||
creator: String::default(),
|
||||
beatmap_id: i32::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +227,9 @@ impl_has_mod! {
|
||||
rx: + Relax ["Relax"],
|
||||
fl: + Flashlight ["Flashlight"],
|
||||
so: + SpunOut ["SpunOut"],
|
||||
dt: + DoubleTime ["DoubleTime"],
|
||||
nc: + Nightcore ["Nightcore"],
|
||||
ht: + HalfTime ["HalfTime"],
|
||||
bl: - Blinds ["Blinds"],
|
||||
cl: - Classic ["Classic"],
|
||||
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