bring back over akatsuki 2019 changes

This commit is contained in:
tsunyoku
2024-10-13 14:35:05 +01:00
parent 9f45d9594d
commit 7b3d637dea
11 changed files with 1349 additions and 1 deletions
+3
View File
@@ -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;
+19 -1
View File
@@ -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(())
}
+5
View File
@@ -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(),
}
}
}
+3
View File
@@ -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"],
+61
View File
@@ -0,0 +1,61 @@
use super::OsuObject;
pub(crate) struct DifficultyObject<'h> {
pub(crate) base: &'h OsuObject,
pub(crate) prev: Option<(f32, f32)>, // (jump_dist, strain_time)
pub(crate) jump_dist: f32,
pub(crate) travel_dist: f32,
pub(crate) angle: Option<f32>,
pub(crate) delta: f32,
pub(crate) strain_time: f32,
}
impl<'h> DifficultyObject<'h> {
pub(crate) fn new(
base: &'h OsuObject,
prev: &OsuObject,
prev_vals: Option<(f32, f32)>, // (jump_dist, strain_time)
prev_prev: Option<OsuObject>,
clock_rate: f32,
scaling_factor: f32,
) -> Self {
let delta = (base.time - prev.time) / clock_rate;
let strain_time = delta.max(50.0);
let pos = base.pos;
let travel_dist = prev.travel_dist.unwrap_or(0.0);
let prev_cursor_pos = prev.end_pos;
let jump_dist = if base.is_spinner() {
0.0
} else {
((pos - prev_cursor_pos) * scaling_factor).length()
};
let angle = prev_prev.map(|prev_prev| {
let prev_prev_cursor_pos = prev_prev.end_pos;
let v1 = prev_prev_cursor_pos - prev.pos;
let v2 = pos - prev_cursor_pos;
let dot = v1.dot(v2);
let det = v1.x * v2.y - v1.y * v2.x;
det.atan2(dot).abs()
});
Self {
base,
prev: prev_vals,
jump_dist,
travel_dist,
angle,
delta,
strain_time,
}
}
}
+16
View File
@@ -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;
+222
View File
@@ -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])
}
+632
View File
@@ -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
);
}
}
+112
View File
@@ -0,0 +1,112 @@
use super::{DifficultyObject, SkillKind};
use std::cmp::Ordering;
const SPEED_SKILL_MULTIPLIER: f32 = 1400.0;
const SPEED_STRAIN_DECAY_BASE: f32 = 0.3;
const AIM_SKILL_MULTIPLIER: f32 = 26.25;
const AIM_STRAIN_DECAY_BASE: f32 = 0.15;
const DECAY_WEIGHT: f32 = 0.9;
pub(crate) struct Skill {
current_strain: f32,
current_section_peak: f32,
kind: SkillKind,
pub(crate) strain_peaks: Vec<f32>,
prev_time: Option<f32>,
pub(crate) object_strains: Vec<f32>,
}
impl Skill {
#[inline]
pub(crate) fn new(kind: SkillKind) -> Self {
Self {
current_strain: 1.0,
current_section_peak: 1.0,
kind,
strain_peaks: Vec::with_capacity(128),
prev_time: None,
object_strains: Vec::new(),
}
}
#[inline]
pub(crate) fn save_current_peak(&mut self) {
self.strain_peaks.push(self.current_section_peak);
}
#[inline]
pub(crate) fn start_new_section_from(&mut self, time: f32) {
self.current_section_peak = self.peak_strain(time - self.prev_time.unwrap());
}
#[inline]
pub(crate) fn process(&mut self, current: &DifficultyObject<'_>) {
self.current_strain *= self.strain_decay(current.delta);
self.current_strain += self.kind.strain_value_of(current) * self.skill_multiplier();
self.object_strains.push(self.current_strain);
self.current_section_peak = self.current_section_peak.max(self.current_strain);
self.prev_time.replace(current.base.time);
}
pub(crate) fn difficulty_value(&mut self) -> f32 {
let mut difficulty = 0.0;
let mut weight = 1.0;
self.strain_peaks
.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
for &strain in self.strain_peaks.iter() {
difficulty += strain * weight;
weight *= DECAY_WEIGHT;
}
difficulty
}
pub(crate) fn count_difficult_strains(&mut self) -> f64 {
let top_strain = self
.object_strains
.iter()
.fold(f64::NEG_INFINITY, |prev, curr| prev.max(*curr as f64));
self.object_strains
.iter()
.map(|strain| (strain / top_strain as f32).powi(4))
.sum::<f32>() as f64
}
#[inline]
fn skill_multiplier(&self) -> f32 {
match self.kind {
SkillKind::Aim => AIM_SKILL_MULTIPLIER,
SkillKind::Speed => SPEED_SKILL_MULTIPLIER,
}
}
#[inline]
fn strain_decay_base(&self) -> f32 {
match self.kind {
SkillKind::Aim => AIM_STRAIN_DECAY_BASE,
SkillKind::Speed => SPEED_STRAIN_DECAY_BASE,
}
}
#[inline]
fn peak_strain(&self, delta_time: f32) -> f32 {
self.current_strain * self.strain_decay(delta_time)
}
#[inline]
fn strain_decay(&self, ms: f32) -> f32 {
self.strain_decay_base().powf(ms / 1000.0)
}
}
+100
View File
@@ -0,0 +1,100 @@
use super::DifficultyObject;
const SINGLE_SPACING_TRESHOLD: f32 = 125.0;
const SPEED_ANGLE_BONUS_BEGIN: f32 = 5.0 * std::f32::consts::FRAC_PI_6;
const PI_OVER_4: f32 = std::f32::consts::FRAC_PI_4;
const PI_OVER_2: f32 = std::f32::consts::FRAC_PI_2;
const MIN_SPEED_BONUS: f32 = 75.0;
const MAX_SPEED_BONUS: f32 = 45.0;
const SPEED_BALANCING_FACTOR: f32 = 40.0;
const AIM_ANGLE_BONUS_BEGIN: f32 = std::f32::consts::FRAC_PI_3;
const TIMING_THRESHOLD: f32 = 107.0;
#[derive(Copy, Clone)]
pub(crate) enum SkillKind {
Aim,
Speed,
}
impl SkillKind {
pub(crate) fn strain_value_of(self, current: &DifficultyObject<'_>) -> f32 {
match self {
Self::Aim => {
if current.base.is_spinner() {
return 0.0;
}
let mut result = 0.0;
if let Some((prev_jump_dist, prev_strain_time)) = current.prev {
if let Some(angle) = current.angle.filter(|a| *a > AIM_ANGLE_BONUS_BEGIN) {
let scale = 90.0;
let angle_bonus = (((angle - AIM_ANGLE_BONUS_BEGIN).sin()).powi(2)
* (prev_jump_dist - scale).max(0.0)
* (current.jump_dist - scale).max(0.0))
.sqrt();
result = 1.5 * apply_diminishing_exp(angle_bonus.max(0.0))
/ (TIMING_THRESHOLD).max(prev_strain_time)
}
}
let jump_dist_exp = apply_diminishing_exp(current.jump_dist);
let travel_dist_exp = apply_diminishing_exp(current.travel_dist);
let dist_exp =
jump_dist_exp + travel_dist_exp + (travel_dist_exp * jump_dist_exp).sqrt();
(result + dist_exp / (current.strain_time).max(TIMING_THRESHOLD))
.max(dist_exp / current.strain_time)
}
Self::Speed => {
if current.base.is_spinner() {
return 0.0;
}
let dist = SINGLE_SPACING_TRESHOLD.min(current.travel_dist + current.jump_dist);
let delta_time = MAX_SPEED_BONUS.max(current.delta);
let mut speed_bonus = 1.0;
if delta_time < MIN_SPEED_BONUS {
let exp_base = (MIN_SPEED_BONUS - delta_time) / SPEED_BALANCING_FACTOR;
speed_bonus += exp_base * exp_base;
}
let mut angle_bonus = 1.0;
if let Some(angle) = current.angle.filter(|a| *a < SPEED_ANGLE_BONUS_BEGIN) {
let exp_base = (1.5 * (SPEED_ANGLE_BONUS_BEGIN - angle)).sin();
angle_bonus = 1.0 + exp_base * exp_base / 3.57;
if angle < PI_OVER_2 {
angle_bonus = 1.28;
if dist < 90.0 && angle < PI_OVER_4 {
angle_bonus += (1.0 - angle_bonus) * ((90.0 - dist) / 10.0).min(1.0);
} else if dist < 90.0 {
angle_bonus += (1.0 - angle_bonus)
* ((90.0 - dist) / 10.0).min(1.0)
* ((PI_OVER_2 - angle) / PI_OVER_4).sin();
}
}
}
(1.0 + (speed_bonus - 1.0) * 0.75)
* angle_bonus
* (0.95 + speed_bonus * (dist / SINGLE_SPACING_TRESHOLD).powf(3.5))
/ current.strain_time
}
}
}
}
#[inline]
fn apply_diminishing_exp(val: f32) -> f32 {
val.powf(0.99)
}
+176
View File
@@ -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,
}