initial rewrite

This commit is contained in:
MaxOhn
2024-02-09 17:54:14 +01:00
parent ecf1b2c7f4
commit a8bf9601b6
190 changed files with 10892 additions and 18142 deletions
+89
View File
@@ -0,0 +1,89 @@
use crate::osu::performance::OsuPerformance;
/// The result of a difficulty calculation on an osu!standard map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct OsuDifficultyAttributes {
/// The difficulty of the aim skill.
pub aim: f64,
/// The difficulty of the speed skill.
pub speed: f64,
/// The difficulty of the flashlight skill.
pub flashlight: f64,
/// The ratio of the aim strain with and without considering sliders
pub slider_factor: f64,
/// The number of clickable objects weighted by difficulty.
pub speed_note_count: f64,
/// The approach rate.
pub ar: f64,
/// The overall difficulty
pub od: f64,
/// The health drain rate.
pub hp: f64,
/// The amount of circles.
pub n_circles: u32,
/// The amount of sliders.
pub n_sliders: u32,
/// The amount of spinners.
pub n_spinners: u32,
/// The final star rating
pub stars: f64,
/// The maximum combo.
pub max_combo: u32,
}
impl OsuDifficultyAttributes {
/// Return the maximum combo.
pub const fn max_combo(&self) -> u32 {
self.max_combo
}
/// Return the amount of hitobjects.
pub const fn n_objects(&self) -> u32 {
self.n_circles + self.n_sliders + self.n_spinners
}
/// Returns a builder for performance calculation.
pub fn pp<'a>(self) -> OsuPerformance<'a> {
self.into()
}
}
/// The result of a performance calculation on an osu!standard map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct OsuPerformanceAttributes {
/// The difficulty attributes that were used for the performance calculation
pub difficulty: OsuDifficultyAttributes,
/// The final performance points.
pub pp: f64,
/// The accuracy portion of the final pp.
pub pp_acc: f64,
/// The aim portion of the final pp.
pub pp_aim: f64,
/// The flashlight portion of the final pp.
pub pp_flashlight: f64,
/// The speed portion of the final pp.
pub pp_speed: f64,
/// Misses including an approximated amount of slider breaks
pub effective_miss_count: f64,
}
impl OsuPerformanceAttributes {
/// Return the star value.
pub const fn stars(&self) -> f64 {
self.difficulty.stars
}
/// Return the performance point value.
pub const fn pp(&self) -> f64 {
self.pp
}
/// Return the maximum combo of the map.
pub const fn max_combo(&self) -> u32 {
self.difficulty.max_combo
}
/// Return the amount of hitobjects.
pub const fn n_objects(&self) -> u32 {
self.difficulty.n_objects()
}
}
+235
View File
@@ -0,0 +1,235 @@
use std::borrow::Cow;
use rosu_map::section::{general::GameMode, hit_objects::CurveBuffers};
use crate::model::{
beatmap::{Beatmap, Converted},
mode::ConvertStatus,
};
use super::{
attributes::OsuDifficultyAttributes,
difficulty::scaling_factor::ScalingFactor,
object::{OsuObject, OsuObjectKind},
Osu,
};
/// A [`Beatmap`] for [`Osu`] calculations.
pub type OsuBeatmap<'a> = Converted<'a, Osu>;
pub fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus {
if map.mode == GameMode::Osu {
ConvertStatus::Noop
} else {
ConvertStatus::Incompatible
}
}
pub fn convert_objects(
converted: &OsuBeatmap<'_>,
scaling_factor: &ScalingFactor,
hr: bool,
time_preempt: f64,
mut take: usize,
attrs: &mut OsuDifficultyAttributes,
) -> Vec<OsuObject> {
let mut curve_bufs = CurveBuffers::default();
let mut ticks_buf = Vec::new(); // TODO: default capacity
let mut osu_objects: Vec<_> = converted
.map
.hit_objects
.iter()
.map(|h| OsuObject::new(h, converted, &mut curve_bufs, &mut ticks_buf))
.inspect(|h| {
if take == 0 {
return;
}
take -= 1;
attrs.max_combo += 1;
match h.kind {
OsuObjectKind::Circle => attrs.n_circles += 1,
OsuObjectKind::Slider(ref slider) => {
attrs.n_sliders += 1;
attrs.max_combo += slider.nested_objects.len() as u32;
}
OsuObjectKind::Spinner(_) => attrs.n_spinners += 1,
}
})
.collect();
if hr {
osu_objects
.iter_mut()
.for_each(OsuObject::reflect_vertically);
} else {
osu_objects.iter_mut().for_each(OsuObject::finalize_tail);
}
let stack_threshold = time_preempt * f64::from(converted.map.stack_leniency);
if converted.map.version >= 6 {
stacking(&mut osu_objects, stack_threshold);
} else {
old_stacking(&mut osu_objects, stack_threshold);
}
for h in osu_objects.iter_mut() {
h.stack_offset = scaling_factor.stack_offset(h.stack_height);
if let OsuObjectKind::Slider(ref mut slider) = h.kind {
slider.lazy_end_pos += h.pos + h.stack_offset;
}
}
osu_objects
}
const STACK_DISTANCE: f32 = 3.0;
fn stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
let mut extended_start_idx = 0;
let Some(extended_end_idx) = hit_objects.len().checked_sub(1) else {
return;
};
// First big `if` in osu!lazer's function can be skipped
for i in (1..=extended_end_idx).rev() {
let mut n = i;
let mut obj_i_idx = i;
// * We should check every note which has not yet got a stack.
// * Consider the case we have two interwound stacks and this will make sense.
// * o <-1 o <-2
// * o <-3 o <-4
// * We first process starting from 4 and handle 2,
// * then we come backwards on the i loop iteration until we reach 3 and handle 1.
// * 2 and 1 will be ignored in the i loop because they already have a stack value.
if hit_objects[obj_i_idx].stack_height != 0 || hit_objects[obj_i_idx].is_spinner() {
continue;
}
// * If this object is a hitcircle, then we enter this "special" case.
// * It either ends with a stack of hitcircles only,
// * or a stack of hitcircles that are underneath a slider.
// * Any other case is handled by the "is_slider" code below this.
if hit_objects[obj_i_idx].is_circle() {
loop {
n = match n.checked_sub(1) {
Some(n) => n,
None => break,
};
if hit_objects[n].is_spinner() {
continue;
}
if hit_objects[obj_i_idx].start_time - hit_objects[n].end_time() > stack_threshold {
break; // * We are no longer within stacking range of the previous object.
}
// * HitObjects before the specified update range haven't been reset yet
if n < extended_start_idx {
hit_objects[n].stack_height = 0;
extended_start_idx = n;
}
// * This is a special case where hticircles are moved DOWN and RIGHT (negative stacking)
// * if they are under the *last* slider in a stacked pattern.
// * o==o <- slider is at original location
// * o <- hitCircle has stack of -1
// * o <- hitCircle has stack of -2
if hit_objects[n].is_slider()
&& hit_objects[n]
.end_pos()
.distance(hit_objects[obj_i_idx].pos)
< STACK_DISTANCE
{
let offset =
hit_objects[obj_i_idx].stack_height - hit_objects[n].stack_height + 1;
for j in n + 1..=i {
// * For each object which was declared under this slider, we will offset
// * it to appear *below* the slider end (rather than above).
if hit_objects[n].end_pos().distance(hit_objects[j].pos) < STACK_DISTANCE {
hit_objects[j].stack_height -= offset;
}
}
// * We have hit a slider. We should restart calculation using this as the new base.
// * Breaking here will mean that the slider still has StackCount of 0,
// * so will be handled in the i-outer-loop.
break;
}
if hit_objects[n].pos.distance(hit_objects[obj_i_idx].pos) < STACK_DISTANCE {
// * Keep processing as if there are no sliders.
// * If we come across a slider, this gets cancelled out.
// * NOTE: Sliders with start positions stacking
// * are a special case that is also handled here.
hit_objects[n].stack_height = hit_objects[obj_i_idx].stack_height + 1;
obj_i_idx = n;
}
}
} else if hit_objects[obj_i_idx].is_slider() {
// * We have hit the first slider in a possible stack.
// * From this point on, we ALWAYS stack positive regardless.
loop {
n = match n.checked_sub(1) {
Some(n) => n,
None => break,
};
if hit_objects[n].is_spinner() {
continue;
}
if hit_objects[obj_i_idx].start_time - hit_objects[n].start_time > stack_threshold {
break; // * We are no longer within stacking range of the previous object.
}
if hit_objects[n]
.end_pos()
.distance(hit_objects[obj_i_idx].pos)
< STACK_DISTANCE
{
hit_objects[n].stack_height = hit_objects[obj_i_idx].stack_height + 1;
obj_i_idx = n;
}
}
}
}
}
fn old_stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
for i in 0..hit_objects.len() {
if hit_objects[i].stack_height != 0 && !hit_objects[i].is_slider() {
continue;
}
let mut start_time = hit_objects[i].end_time();
let pos2 = hit_objects[i].end_pos();
let mut slider_stack = 0;
for j in i + 1..hit_objects.len() {
if hit_objects[j].start_time - stack_threshold > start_time {
break;
}
if hit_objects[j].pos.distance(hit_objects[i].pos) < STACK_DISTANCE {
hit_objects[i].stack_height += 1;
start_time = hit_objects[j].end_time();
} else if hit_objects[j].pos.distance(pos2) < STACK_DISTANCE {
slider_stack += 1;
hit_objects[j].stack_height -= slider_stack;
start_time = hit_objects[j].end_time();
}
}
}
}
+210
View File
@@ -0,0 +1,210 @@
use std::cmp;
use crate::{
any::difficulty::{skills::Skill, ModeDifficulty},
osu::{
convert::convert_objects,
difficulty::{object::OsuDifficultyObject, scaling_factor::ScalingFactor},
object::OsuObject,
performance::PERFORMANCE_BASE_MULTIPLIER,
},
util::mods::Mods,
};
use self::skills::{aim::Aim, flashlight::Flashlight, speed::Speed};
use super::{attributes::OsuDifficultyAttributes, convert::OsuBeatmap};
mod object;
pub mod scaling_factor;
mod skills;
const DIFFICULTY_MULTIPLIER: f64 = 0.0675;
const HD_FADE_IN_DURATION_MULTIPLIER: f64 = 0.4;
const HD_FADE_OUT_DURATION_MULTIPLIER: f64 = 0.3;
pub fn difficulty(
difficulty: &ModeDifficulty,
converted: &OsuBeatmap<'_>,
) -> OsuDifficultyAttributes {
let DifficultyValues {
aim,
aim_no_sliders,
speed,
flashlight,
mut attrs,
} = DifficultyValues::calculate(difficulty, converted);
let mut aim_rating = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let aim_rating_no_sliders = aim_no_sliders.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let speed_notes = speed.relevant_note_count();
let mut speed_rating = speed.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let mut flashlight_rating = flashlight.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let slider_factor = if aim_rating > 0.0 {
aim_rating_no_sliders / aim_rating
} else {
1.0
};
let mods = difficulty.get_mods();
if mods.td() {
aim_rating = aim_rating.powf(0.8);
flashlight_rating = flashlight_rating.powf(0.8);
}
if mods.rx() {
aim_rating *= 0.9;
speed_rating = 0.0;
flashlight_rating *= 0.7;
}
let base_aim_performance = (5.0 * (aim_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
let base_speed_performance = (5.0 * (speed_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
let base_flashlight_performance = if mods.fl() {
flashlight_rating * flashlight_rating * 25.0
} else {
0.0
};
let base_performance = ((base_aim_performance).powf(1.1)
+ (base_speed_performance).powf(1.1)
+ (base_flashlight_performance).powf(1.1))
.powf(1.0 / 1.1);
let star_rating = if base_performance > 0.00001 {
PERFORMANCE_BASE_MULTIPLIER.cbrt()
* 0.027
* ((100_000.0 / 2.0_f64.powf(1.0 / 1.1) * base_performance).cbrt() + 4.0)
} else {
0.0
};
attrs.aim = aim_rating;
attrs.speed = speed_rating;
attrs.flashlight = flashlight_rating;
attrs.slider_factor = slider_factor;
attrs.stars = star_rating;
attrs.speed_note_count = speed_notes;
attrs
}
pub struct DifficultyValues {
pub aim: Aim,
pub aim_no_sliders: Aim,
pub speed: Speed,
pub flashlight: Flashlight,
pub attrs: OsuDifficultyAttributes,
}
impl DifficultyValues {
pub fn calculate(difficulty: &ModeDifficulty, converted: &OsuBeatmap<'_>) -> Self {
let mods = difficulty.get_mods();
let take = difficulty.get_passed_objects();
let clock_rate = difficulty.get_clock_rate();
let map_attrs = converted
.attributes()
.mods(mods)
.clock_rate(clock_rate)
.build();
let scaling_factor = ScalingFactor::new(map_attrs.cs);
let hr = mods.hr();
let hit_window = 2.0 * map_attrs.hit_windows.od;
let time_preempt = f64::from((map_attrs.hit_windows.ar * clock_rate) as f32);
// * Preempt time can go below 450ms. Normally, this is achieved via the DT mod
// * which uniformly speeds up all animations game wide regardless of AR.
// * This uniform speedup is hard to match 1:1, however we can at least make
// * AR>10 (via mods) feel good by extending the upper linear function above.
// * Note that this doesn't exactly match the AR>10 visuals as they're
// * classically known, but it feels good.
// * This adjustment is necessary for AR>10, otherwise TimePreempt can
// * become smaller leading to hitcircles not fully fading in.
let time_fade_in = if mods.hd() {
time_preempt * HD_FADE_IN_DURATION_MULTIPLIER
} else {
400.0 * (time_preempt / OsuObject::PREEMPT_MIN).min(1.0)
};
let mut attrs = OsuDifficultyAttributes {
ar: map_attrs.ar,
hp: map_attrs.hp,
od: map_attrs.od,
..Default::default()
};
let mut osu_objects = convert_objects(
converted,
&scaling_factor,
hr,
time_preempt,
take,
&mut attrs,
);
let mut osu_objects_iter = osu_objects
.iter_mut()
.map(|h| OsuDifficultyObject::compute_slider_cursor_pos(h, scaling_factor.radius));
let aim = Aim::new(true);
let aim_no_sliders = Aim::new(false);
let speed = Speed::new(hit_window);
let flashlight = Flashlight::new(mods, scaling_factor.radius, time_preempt, time_fade_in);
let Some(mut last) = osu_objects_iter.next().filter(|_| take > 0) else {
return Self {
aim,
aim_no_sliders,
speed,
flashlight,
attrs,
};
};
let mut last_last = None;
let diff_objects: Vec<_> = osu_objects_iter
.enumerate()
.map(|(idx, h)| {
let diff_object =
OsuDifficultyObject::new(h, last, last_last, clock_rate, idx, &scaling_factor);
last_last = Some(last);
last = h;
diff_object
})
.collect();
let mut aim = Skill::new(aim, &diff_objects);
let mut aim_no_sliders = Skill::new(aim_no_sliders, &diff_objects);
let mut speed = Skill::new(speed, &diff_objects);
let mut flashlight = Skill::new(flashlight, &diff_objects);
// The first hit object has no difficulty object
let take_diff_objects = cmp::min(converted.map.hit_objects.len(), take) - 1;
for hit_object in diff_objects.iter().take(take_diff_objects) {
aim.process(hit_object);
aim_no_sliders.process(hit_object);
speed.process(hit_object);
flashlight.process(hit_object);
}
Self {
aim: aim.inner,
aim_no_sliders: aim_no_sliders.inner,
speed: speed.inner,
flashlight: flashlight.inner,
attrs,
}
}
}
+212
View File
@@ -0,0 +1,212 @@
use rosu_map::util::Pos;
use crate::{
any::difficulty::object::IDifficultyObject,
osu::object::{OsuObject, OsuObjectKind},
};
use super::{scaling_factor::ScalingFactor, HD_FADE_OUT_DURATION_MULTIPLIER};
pub struct OsuDifficultyObject<'a> {
pub idx: usize,
pub base: &'a OsuObject,
pub start_time: f64,
pub delta_time: f64,
pub strain_time: f64,
pub lazy_jump_dist: f64,
pub min_jump_dist: f64,
pub min_jump_time: f64,
pub travel_dist: f64,
pub travel_time: f64,
pub angle: Option<f64>,
}
impl<'a> OsuDifficultyObject<'a> {
pub const NORMALIZED_RADIUS: f32 = 50.0;
const MIN_DELTA_TIME: f64 = 25.0;
const MAX_SLIDER_RADIUS: f32 = Self::NORMALIZED_RADIUS * 2.4;
const ASSUMED_SLIDER_RADIUS: f32 = Self::NORMALIZED_RADIUS * 1.8;
pub fn new(
hit_object: &'a OsuObject,
last_object: &'a OsuObject,
last_last_object: Option<&OsuObject>,
clock_rate: f64,
idx: usize,
scaling_factor: &ScalingFactor,
) -> Self {
let delta_time = (hit_object.start_time - last_object.start_time) / clock_rate;
let start_time = hit_object.start_time / clock_rate;
let strain_time = delta_time.max(Self::MIN_DELTA_TIME);
let mut this = Self {
idx,
base: hit_object,
start_time,
delta_time,
strain_time,
lazy_jump_dist: 0.0,
min_jump_dist: 0.0,
min_jump_time: 0.0,
travel_dist: 0.0,
travel_time: 0.0,
angle: None,
};
this.set_distances(last_object, last_last_object, clock_rate, scaling_factor);
this
}
pub fn opacity_at(&self, time: f64, hidden: bool, time_preempt: f64, time_fade_in: f64) -> f64 {
if time > self.base.start_time {
// * Consider a hitobject as being invisible when its start time is passed.
// * In reality the hitobject will be visible beyond its start time up until its hittable window has passed,
// * but this is an approximation and such a case is unlikely to be hit where this function is used.
return 0.0;
}
let fade_in_start_time = self.base.start_time - time_preempt;
let fade_in_duration = time_fade_in;
if hidden {
// * Taken from OsuModHidden.
let fade_out_start_time = self.base.start_time - time_preempt + time_fade_in;
let fade_out_duration = time_preempt * HD_FADE_OUT_DURATION_MULTIPLIER;
(((time - fade_in_start_time) / fade_in_duration).clamp(0.0, 1.0))
.min(1.0 - ((time - fade_out_start_time) / fade_out_duration).clamp(0.0, 1.0))
} else {
((time - fade_in_start_time) / fade_in_duration).clamp(0.0, 1.0)
}
}
fn set_distances(
&mut self,
last_object: &OsuObject,
last_last_object: Option<&OsuObject>,
clock_rate: f64,
scaling_factor: &ScalingFactor,
) {
if let OsuObjectKind::Slider(ref slider) = self.base.kind {
self.travel_dist = f64::from(
slider.lazy_travel_dist
* ((1.0 + slider.repeat_count() as f64 / 2.5).powf(1.0 / 2.5)) as f32,
);
self.travel_time = (self.base.lazy_travel_time() / clock_rate)
.max(OsuDifficultyObject::MIN_DELTA_TIME);
}
if self.base.is_spinner() || last_object.is_spinner() {
return;
}
let scaling_factor = scaling_factor.factor_with_small_circle_bonus;
let last_cursor_pos = Self::get_end_cursor_pos(last_object);
self.lazy_jump_dist = f64::from(
(self.base.stacked_pos() * scaling_factor - last_cursor_pos * scaling_factor).length(),
);
self.min_jump_time = self.strain_time;
self.min_jump_dist = self.lazy_jump_dist;
if let OsuObjectKind::Slider(ref last_slider) = last_object.kind {
let last_travel_time = (last_object.lazy_travel_time() / clock_rate)
.max(OsuDifficultyObject::MIN_DELTA_TIME);
self.min_jump_time =
(self.strain_time - last_travel_time).max(OsuDifficultyObject::MIN_DELTA_TIME);
let tail_pos = last_slider
.nested_objects
.last()
.map_or(last_object.pos, |nested| nested.pos);
let stacked_tail_pos = tail_pos + last_object.stack_offset;
let tail_jump_dist =
(stacked_tail_pos - self.base.stacked_pos()).length() * scaling_factor;
let diff = f64::from(
OsuDifficultyObject::MAX_SLIDER_RADIUS - OsuDifficultyObject::ASSUMED_SLIDER_RADIUS,
);
let min = f64::from(tail_jump_dist - OsuDifficultyObject::MAX_SLIDER_RADIUS);
self.min_jump_dist = ((self.lazy_jump_dist - diff).min(min)).max(0.0);
}
if let Some(last_last_object) = last_last_object.filter(|h| !h.is_spinner()) {
let last_last_cursor_pos = Self::get_end_cursor_pos(last_last_object);
let v1 = last_last_cursor_pos - last_object.stacked_pos();
let v2 = self.base.stacked_pos() - last_cursor_pos;
let dot = v1.dot(v2);
let det = v1.x * v2.y - v1.y * v2.x;
self.angle = Some((f64::from(det).atan2(f64::from(dot))).abs());
}
}
pub fn compute_slider_cursor_pos(h: &mut OsuObject, radius: f64) -> &OsuObject {
let stack_offset = h.stack_offset;
let OsuObjectKind::Slider(ref mut slider) = h.kind else {
return h;
};
let mut curr_cursor_pos = h.pos + stack_offset;
let scaling_factor = f64::from(OsuDifficultyObject::NORMALIZED_RADIUS) / radius;
for (curr_movement_obj, i) in slider.nested_objects.iter().zip(1..) {
let mut curr_movement = curr_movement_obj.pos + stack_offset - curr_cursor_pos;
let mut curr_movement_len = scaling_factor * f64::from(curr_movement.length());
let mut required_movement = f64::from(OsuDifficultyObject::ASSUMED_SLIDER_RADIUS);
if i == slider.nested_objects.len() {
let lazy_movement = slider.lazy_end_pos - curr_cursor_pos;
if lazy_movement.length() < curr_movement.length() {
curr_movement = lazy_movement;
}
curr_movement_len = scaling_factor * f64::from(curr_movement.length());
} else if curr_movement_obj.is_repeat() {
required_movement = f64::from(OsuDifficultyObject::NORMALIZED_RADIUS);
}
if curr_movement_len > required_movement {
curr_cursor_pos += curr_movement
* ((curr_movement_len - required_movement) / curr_movement_len) as f32;
curr_movement_len *= (curr_movement_len - required_movement) / curr_movement_len;
slider.lazy_travel_dist += curr_movement_len as f32;
}
if i == slider.nested_objects.len() {
slider.lazy_end_pos = curr_cursor_pos;
}
}
h
}
fn get_end_cursor_pos(hit_object: &OsuObject) -> Pos {
if let OsuObjectKind::Slider(ref slider) = hit_object.kind {
// We don't have access to the slider's curve at this point so we
// take the pre-computed value.
slider.lazy_end_pos
} else {
hit_object.stacked_pos()
}
}
}
impl IDifficultyObject for OsuDifficultyObject<'_> {
fn idx(&self) -> usize {
self.idx
}
}
+46
View File
@@ -0,0 +1,46 @@
use rosu_map::util::Pos;
use crate::osu::object::OsuObject;
use super::object::OsuDifficultyObject;
/// Fields around the scaling of hit objects.
///
/// osu!lazer stores these in each hit object but since all objects share the
/// same scaling (w.r.t. difficulty & performance), we store them only once.
pub struct ScalingFactor {
/// `NORMALIZED_RADIUS / Radius`
pub factor: f32,
/// `NORMALIZED_RADIUS / Radius` and then adjusted if `Radius < 30`
pub factor_with_small_circle_bonus: f32,
pub radius: f64,
pub scale: f32,
}
impl ScalingFactor {
pub fn new(cs: f64) -> Self {
let scale = (1.0 - 0.7 * (cs as f32 - 5.0) / 5.0) / 2.0;
let radius = f64::from(OsuObject::OBJECT_RADIUS * scale);
let factor = OsuDifficultyObject::NORMALIZED_RADIUS / radius as f32;
let factor_with_small_circle_bonus = if radius < 30.0 {
factor * (1.0 + (30.0 - radius as f32).min(5.0) / 50.0)
} else {
factor
};
Self {
factor,
factor_with_small_circle_bonus,
radius,
scale,
}
}
pub fn stack_offset(&self, stack_height: i32) -> Pos {
let stack_offset = stack_height as f32 * self.scale * -6.4;
Pos::new(stack_offset, stack_offset)
}
}
@@ -1,100 +1,104 @@
use std::f64::consts::{FRAC_PI_2, PI};
use crate::{osu::difficulty_object::OsuDifficultyObject, util::CompactVec};
use crate::{
any::difficulty::{
object::IDifficultyObject,
skills::{strain_decay, ISkill, Skill},
},
osu::difficulty::object::OsuDifficultyObject,
util::float_ext::FloatExt,
};
use super::{previous, previous_start_time, OsuStrainSkill, Skill, StrainSkill};
use super::strain::OsuStrainSkill;
#[derive(Clone, Debug)]
pub(crate) struct Aim {
curr_strain: f64,
curr_section_peak: f64,
curr_section_end: f64,
pub(crate) strain_peaks: CompactVec,
const SKILL_MULTIPLIER: f64 = 23.55;
const STRAIN_DECAY_BASE: f64 = 0.15;
pub struct Aim {
with_sliders: bool,
curr_strain: f64,
inner: OsuStrainSkill,
}
impl Aim {
const SKILL_MULTIPLIER: f64 = 23.55;
const STRAIN_DECAY_BASE: f64 = 0.15;
pub(crate) fn new(with_sliders: bool) -> Self {
pub fn new(with_sliders: bool) -> Self {
Self {
curr_strain: 0.0,
curr_section_peak: 0.0,
curr_section_end: 0.0,
strain_peaks: CompactVec::new(),
with_sliders,
curr_strain: 0.0,
inner: OsuStrainSkill::default(),
}
}
fn strain_decay(ms: f64) -> f64 {
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
pub fn get_curr_strain_peaks(self) -> Vec<f64> {
self.inner.get_curr_strain_peaks()
}
pub fn difficulty_value(self) -> f64 {
self.inner.difficulty_value(
OsuStrainSkill::REDUCED_SECTION_COUNT,
OsuStrainSkill::REDUCED_STRAIN_BASELINE,
OsuStrainSkill::DECAY_WEIGHT,
OsuStrainSkill::DIFFICULTY_MULTIPLER,
)
}
}
impl Skill for Aim {
#[inline]
fn process(
&mut self,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) {
<Self as StrainSkill>::process(self, curr, diff_objects);
}
#[inline]
fn difficulty_value(&mut self) -> f64 {
<Self as OsuStrainSkill>::difficulty_value(self)
}
impl ISkill for Aim {
type DifficultyObjects<'a> = [OsuDifficultyObject<'a>];
}
impl StrainSkill for Aim {
#[inline]
fn strain_peaks_mut(&mut self) -> &mut CompactVec {
&mut self.strain_peaks
impl<'a> Skill<'a, Aim> {
fn calculate_initial_strain(&mut self, time: f64, curr: &'a OsuDifficultyObject<'a>) -> f64 {
let prev_start_time = curr
.previous(0, self.diff_objects)
.map_or(0.0, |prev| prev.start_time);
self.inner.curr_strain * strain_decay(time - prev_start_time, STRAIN_DECAY_BASE)
}
#[inline]
fn curr_section_peak(&mut self) -> &mut f64 {
&mut self.curr_section_peak
const fn curr_section_peak(&self) -> f64 {
self.inner.inner.inner.curr_section_peak
}
#[inline]
fn curr_section_end(&mut self) -> &mut f64 {
&mut self.curr_section_end
fn curr_section_peak_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.inner.curr_section_peak
}
#[inline]
fn strain_value_at(
&mut self,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) -> f64 {
self.curr_strain *= Self::strain_decay(curr.delta_time);
self.curr_strain += AimEvaluator::evaluate_diff_of(curr, diff_objects, self.with_sliders)
* Self::SKILL_MULTIPLIER;
self.curr_strain
const fn curr_section_end(&self) -> f64 {
self.inner.inner.inner.curr_section_end
}
#[inline]
fn calculate_initial_strain(
&self,
time: f64,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) -> f64 {
self.curr_strain * Self::strain_decay(time - previous_start_time(diff_objects, curr.idx, 0))
fn curr_section_end_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.inner.curr_section_end
}
#[inline]
fn difficulty_value(&mut self) -> f64 {
<Self as OsuStrainSkill>::difficulty_value(self)
pub fn process(&mut self, curr: &'a OsuDifficultyObject<'a>) {
if curr.idx == 0 {
*self.curr_section_end_mut() = (curr.start_time / OsuStrainSkill::SECTION_LEN).ceil()
* OsuStrainSkill::SECTION_LEN;
}
while curr.start_time > self.curr_section_end() {
self.inner.inner.save_curr_peak();
let initial_strain = self.calculate_initial_strain(self.curr_section_end(), curr);
self.inner.inner.start_new_section_from(initial_strain);
*self.curr_section_end_mut() += OsuStrainSkill::SECTION_LEN;
}
let strain_value_at = self.strain_value_at(curr);
*self.curr_section_peak_mut() = strain_value_at.max(self.curr_section_peak());
}
fn strain_value_at(&mut self, curr: &'a OsuDifficultyObject<'a>) -> f64 {
self.inner.curr_strain *= strain_decay(curr.delta_time, STRAIN_DECAY_BASE);
self.inner.curr_strain +=
AimEvaluator::evaluate_diff_of(curr, self.diff_objects, self.inner.with_sliders)
* SKILL_MULTIPLIER;
self.inner.curr_strain
}
}
impl OsuStrainSkill for Aim {}
struct AimEvaluator;
impl AimEvaluator {
@@ -103,15 +107,16 @@ impl AimEvaluator {
const SLIDER_MULTIPLIER: f64 = 1.35;
const VELOCITY_CHANGE_MULTIPLIER: f64 = 0.75;
fn evaluate_diff_of(
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
fn evaluate_diff_of<'a>(
curr: &'a OsuDifficultyObject<'a>,
diff_objects: &'a [OsuDifficultyObject<'a>],
with_sliders: bool,
) -> f64 {
let osu_curr_obj = curr;
let Some((osu_last_last_obj, osu_last_obj)) = previous(diff_objects, curr.idx, 1)
.zip(previous(diff_objects, curr.idx, 0))
let Some((osu_last_last_obj, osu_last_obj)) = curr
.previous(1, diff_objects)
.zip(curr.previous(0, diff_objects))
.filter(|(_, last)| !(curr.base.is_spinner() || last.base.is_spinner()))
else {
return 0.0;
@@ -119,27 +124,26 @@ impl AimEvaluator {
// * Calculate the velocity to the current hitobject, which starts
// * with a base distance / time assuming the last object is a hitcircle.
let mut curr_vel = osu_curr_obj.dists.lazy_jump_dist / osu_curr_obj.strain_time;
let mut curr_vel = osu_curr_obj.lazy_jump_dist / osu_curr_obj.strain_time;
// * But if the last object is a slider, then we extend the travel
// * velocity through the slider into the current object.
if osu_last_obj.base.is_slider() && with_sliders {
// * calculate the slider velocity from slider head to slider end.
let travel_vel = osu_last_obj.dists.travel_dist / osu_last_obj.dists.travel_time;
let travel_vel = osu_last_obj.travel_dist / osu_last_obj.travel_time;
// * calculate the movement velocity from slider end to current object
let movement_vel = osu_curr_obj.dists.min_jump_dist / osu_curr_obj.dists.min_jump_time;
let movement_vel = osu_curr_obj.min_jump_dist / osu_curr_obj.min_jump_time;
// * take the larger total combined velocity.
curr_vel = curr_vel.max(movement_vel + travel_vel);
}
// * As above, do the same for the previous hitobject.
let mut prev_vel = osu_last_obj.dists.lazy_jump_dist / osu_last_obj.strain_time;
let mut prev_vel = osu_last_obj.lazy_jump_dist / osu_last_obj.strain_time;
if osu_last_last_obj.base.is_slider() && with_sliders {
let travel_vel =
osu_last_last_obj.dists.travel_dist / osu_last_last_obj.dists.travel_time;
let movement_vel = osu_last_obj.dists.min_jump_dist / osu_last_obj.dists.min_jump_time;
let travel_vel = osu_last_last_obj.travel_dist / osu_last_last_obj.travel_time;
let movement_vel = osu_last_obj.min_jump_dist / osu_last_obj.min_jump_time;
prev_vel = prev_vel.max(movement_vel + travel_vel);
}
@@ -157,10 +161,9 @@ impl AimEvaluator {
< 1.25 * osu_curr_obj.strain_time.min(osu_last_obj.strain_time)
{
if let Some(((curr_angle, last_angle), last_last_angle)) = osu_curr_obj
.dists
.angle
.zip(osu_last_obj.dists.angle)
.zip(osu_last_last_obj.dists.angle)
.zip(osu_last_obj.angle)
.zip(osu_last_last_obj.angle)
{
// * Rewarding angles, take the smaller velocity as base.
let angle_bonus = curr_vel.min(prev_vel);
@@ -176,7 +179,7 @@ impl AimEvaluator {
(FRAC_PI_2 * ((100.0 - osu_curr_obj.strain_time) / 25.0).min(1.0)).sin();
let base2 = (FRAC_PI_2
* ((osu_curr_obj.dists.lazy_jump_dist).clamp(50.0, 100.0) - 50.0)
* ((osu_curr_obj.lazy_jump_dist).clamp(50.0, 100.0) - 50.0)
/ 50.0)
.sin();
@@ -204,13 +207,13 @@ impl AimEvaluator {
}
}
if prev_vel.max(curr_vel).abs() > f64::EPSILON {
if prev_vel.max(curr_vel).not_eq(0.0) {
// * We want to use the average velocity over the whole object when awarding
// * differences, not the individual jump and slider path velocities.
prev_vel = (osu_last_obj.dists.lazy_jump_dist + osu_last_last_obj.dists.travel_dist)
prev_vel = (osu_last_obj.lazy_jump_dist + osu_last_last_obj.travel_dist)
/ osu_last_obj.strain_time;
curr_vel = (osu_curr_obj.dists.lazy_jump_dist + osu_last_obj.dists.travel_dist)
/ osu_curr_obj.strain_time;
curr_vel =
(osu_curr_obj.lazy_jump_dist + osu_last_obj.travel_dist) / osu_curr_obj.strain_time;
// * Scale with ratio of difference compared to 0.5 * max dist.
let dist_ratio_base =
@@ -231,7 +234,7 @@ impl AimEvaluator {
if osu_last_obj.base.is_slider() {
// * Reward sliders based on velocity.
slider_bonus = osu_last_obj.dists.travel_dist / osu_last_obj.dists.travel_time;
slider_bonus = osu_last_obj.travel_dist / osu_last_obj.travel_time;
}
// * Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger.
+230
View File
@@ -0,0 +1,230 @@
use crate::{
any::difficulty::{
object::IDifficultyObject,
skills::{strain_decay, ISkill, Skill, StrainSkill},
},
osu::{difficulty::object::OsuDifficultyObject, object::OsuObjectKind},
util::mods::Mods,
};
use super::strain::OsuStrainSkill;
const SKILL_MULTIPLIER: f64 = 0.052;
const STRAIN_DECAY_BASE: f64 = 0.15;
pub struct Flashlight {
curr_strain: f64,
has_hidden_mod: bool,
inner: StrainSkill,
evaluator: FlashlightEvaluator,
}
impl Flashlight {
pub fn new(mods: u32, radius: f64, time_preempt: f64, time_fade_in: f64) -> Self {
let scaling_factor = 52.0 / radius;
Self {
curr_strain: 0.0,
has_hidden_mod: mods.hd(),
inner: StrainSkill::default(),
evaluator: FlashlightEvaluator::new(scaling_factor, time_preempt, time_fade_in),
}
}
pub fn get_curr_strain_peaks(self) -> Vec<f64> {
self.inner.get_curr_strain_peaks()
}
pub fn difficulty_value(self) -> f64 {
self.get_curr_strain_peaks().into_iter().sum::<f64>() * OsuStrainSkill::DIFFICULTY_MULTIPLER
}
}
impl ISkill for Flashlight {
type DifficultyObjects<'a> = [OsuDifficultyObject<'a>];
}
impl<'a> Skill<'a, Flashlight> {
fn calculate_initial_strain(&mut self, time: f64, curr: &'a OsuDifficultyObject<'a>) -> f64 {
let prev_start_time = curr
.previous(0, self.diff_objects)
.map_or(0.0, |prev| prev.start_time);
self.inner.curr_strain * strain_decay(time - prev_start_time, STRAIN_DECAY_BASE)
}
const fn curr_section_peak(&self) -> f64 {
self.inner.inner.curr_section_peak
}
fn curr_section_peak_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.curr_section_peak
}
const fn curr_section_end(&self) -> f64 {
self.inner.inner.curr_section_end
}
fn curr_section_end_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.curr_section_end
}
pub fn process(&mut self, curr: &'a OsuDifficultyObject<'a>) {
if curr.idx == 0 {
*self.curr_section_end_mut() =
(curr.start_time / StrainSkill::SECTION_LEN).ceil() * StrainSkill::SECTION_LEN;
}
while curr.start_time > self.curr_section_end() {
self.inner.inner.save_curr_peak();
let initial_strain = self.calculate_initial_strain(self.curr_section_end(), curr);
self.inner.inner.start_new_section_from(initial_strain);
*self.curr_section_end_mut() += StrainSkill::SECTION_LEN;
}
let strain_value_at = self.strain_value_at(curr);
*self.curr_section_peak_mut() = strain_value_at.max(self.curr_section_peak());
}
fn strain_value_at(&mut self, curr: &'a OsuDifficultyObject<'a>) -> f64 {
self.inner.curr_strain *= strain_decay(curr.delta_time, STRAIN_DECAY_BASE);
self.inner.curr_strain += self.inner.evaluator.evaluate_diff_of(
curr,
self.diff_objects,
self.inner.has_hidden_mod,
) * SKILL_MULTIPLIER;
self.inner.curr_strain
}
}
struct FlashlightEvaluator {
scaling_factor: f64,
time_preempt: f64,
time_fade_in: f64,
}
impl FlashlightEvaluator {
const MAX_OPACITY_BONUS: f64 = 0.4;
const HIDDEN_BONUS: f64 = 0.2;
const MIN_VELOCITY: f64 = 0.5;
const SLIDER_MULTIPLIER: f64 = 1.3;
const MIN_ANGLE_MULTIPLIER: f64 = 0.2;
const fn new(scaling_factor: f64, time_preempt: f64, time_fade_in: f64) -> Self {
Self {
scaling_factor,
time_preempt,
time_fade_in,
}
}
fn evaluate_diff_of<'a>(
&self,
curr: &'a OsuDifficultyObject<'a>,
diff_objects: &'a [OsuDifficultyObject<'a>],
hidden: bool,
) -> f64 {
if curr.base.is_spinner() {
return 0.0;
}
let osu_curr = curr;
let osu_hit_obj = curr.base;
let mut small_dist_nerf = 1.0;
let mut cumulative_strain_time = 0.0;
let mut result = 0.0;
let mut last_obj = osu_curr;
let mut angle_repeat_count = 0.0;
// * This is iterating backwards in time from the current object.
for i in 0..curr.idx.min(10) {
let Some(curr_obj) = curr.previous(i, diff_objects) else {
break;
};
let curr_hit_obj = curr_obj.base;
if !curr_obj.base.is_spinner() {
let jump_dist = f64::from(
(osu_hit_obj.stacked_pos() - curr_hit_obj.stacked_end_pos()).length(),
);
cumulative_strain_time += last_obj.strain_time;
// * We want to nerf objects that can be easily seen within the Flashlight circle radius.
if i == 0 {
small_dist_nerf = (jump_dist / 75.0).min(1.0);
}
// * We also want to nerf stacks so that only the first object of the stack is accounted for.
let stack_nerf = ((curr_obj.lazy_jump_dist / self.scaling_factor) / 25.0).min(1.0);
// * Bonus based on how visible the object is.
let opacity_bonus = 1.0
+ Self::MAX_OPACITY_BONUS
* (1.0
- osu_curr.opacity_at(
curr_hit_obj.start_time,
hidden,
self.time_preempt,
self.time_fade_in,
));
result += stack_nerf * opacity_bonus * self.scaling_factor * jump_dist
/ cumulative_strain_time;
if let Some((curr_obj_angle, osu_curr_angle)) = curr_obj.angle.zip(osu_curr.angle) {
// * Objects further back in time should count less for the nerf.
if (curr_obj_angle - osu_curr_angle).abs() < 0.02 {
angle_repeat_count += (1.0 - 0.1 * i as f64).max(0.0);
}
}
}
last_obj = curr_obj;
}
let base = small_dist_nerf * result;
result = base * base;
// * Additional bonus for Hidden due to there being no approach circles.
if hidden {
result *= 1.0 + Self::HIDDEN_BONUS;
}
// * Nerf patterns with repeated angles.
result *= Self::MIN_ANGLE_MULTIPLIER
+ (1.0 - Self::MIN_ANGLE_MULTIPLIER) / (angle_repeat_count + 1.0);
let mut slider_bonus = 0.0;
if let OsuObjectKind::Slider(slider) = &osu_curr.base.kind {
// * Invert the scaling factor to determine the true travel distance independent of circle size.
let pixel_travel_dist = f64::from(slider.lazy_travel_dist) / self.scaling_factor;
// * Reward sliders based on velocity.
slider_bonus =
((pixel_travel_dist / osu_curr.travel_time - Self::MIN_VELOCITY).max(0.0)).sqrt();
// * Longer sliders require more memorisation.
slider_bonus *= pixel_travel_dist;
// * Nerf sliders with repeats, as less memorisation is required.
let repeat_count = slider.repeat_count();
if repeat_count > 0 {
slider_bonus /= (repeat_count + 1) as f64;
}
}
result += slider_bonus * Self::SLIDER_MULTIPLIER;
result
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod aim;
pub mod flashlight;
pub mod speed;
pub mod strain;
@@ -1,45 +1,58 @@
use std::{cmp::Ordering, f64::consts::PI};
use std::f64::consts::PI;
use crate::{osu::difficulty_object::OsuDifficultyObject, util::CompactVec};
use crate::{
any::difficulty::{
object::IDifficultyObject,
skills::{strain_decay, ISkill, Skill},
},
osu::difficulty::object::OsuDifficultyObject,
};
use super::{next, previous, previous_start_time, OsuStrainSkill, Skill, StrainSkill};
use super::strain::OsuStrainSkill;
#[derive(Clone, Debug)]
pub(crate) struct Speed {
const SKILL_MULTIPLIER: f64 = 1375.0;
const STRAIN_DECAY_BASE: f64 = 0.3;
const DIFFICULTY_MULTIPLER: f64 = 1.04;
const REDUCED_SECTION_COUNT: usize = 5;
pub struct Speed {
curr_strain: f64,
curr_section_peak: f64,
curr_section_end: f64,
curr_rhythm: f64,
pub(crate) strain_peaks: CompactVec,
object_strains: Vec<f64>,
hit_window: f64,
inner: OsuStrainSkill,
}
impl Speed {
const SKILL_MULTIPLIER: f64 = 1375.0;
const STRAIN_DECAY_BASE: f64 = 0.3;
pub(crate) fn new(hit_window: f64) -> Self {
pub fn new(hit_window: f64) -> Self {
Self {
curr_strain: 0.0,
curr_section_peak: 0.0,
curr_section_end: 0.0,
curr_rhythm: 0.0,
strain_peaks: CompactVec::new(),
object_strains: Vec::new(),
object_strains: Vec::new(), // TODO: default capacity
hit_window,
inner: OsuStrainSkill::default(),
}
}
fn strain_decay(ms: f64) -> f64 {
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
pub fn get_curr_strain_peaks(self) -> Vec<f64> {
self.inner.get_curr_strain_peaks()
}
pub(crate) fn relevant_note_count(&self) -> f64 {
pub fn difficulty_value(self) -> f64 {
self.inner.difficulty_value(
REDUCED_SECTION_COUNT,
OsuStrainSkill::REDUCED_STRAIN_BASELINE,
OsuStrainSkill::DECAY_WEIGHT,
DIFFICULTY_MULTIPLER,
)
}
pub fn relevant_note_count(&self) -> f64 {
self.object_strains
.iter()
.max_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal))
.copied()
.max_by(f64::total_cmp)
.filter(|&n| n > 0.0)
.map_or(0.0, |max_strain| {
self.object_strains.iter().fold(0.0, |sum, strain| {
@@ -49,75 +62,66 @@ impl Speed {
}
}
impl Skill for Speed {
#[inline]
fn process(
&mut self,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) {
<Self as StrainSkill>::process(self, curr, diff_objects);
}
#[inline]
fn difficulty_value(&mut self) -> f64 {
<Self as OsuStrainSkill>::difficulty_value(self)
}
impl ISkill for Speed {
type DifficultyObjects<'a> = [OsuDifficultyObject<'a>];
}
impl StrainSkill for Speed {
#[inline]
fn strain_peaks_mut(&mut self) -> &mut CompactVec {
&mut self.strain_peaks
impl<'a> Skill<'a, Speed> {
fn calculate_initial_strain(&mut self, time: f64, curr: &'a OsuDifficultyObject<'a>) -> f64 {
let prev_start_time = curr
.previous(0, self.diff_objects)
.map_or(0.0, |prev| prev.start_time);
(self.inner.curr_strain * self.inner.curr_rhythm)
* strain_decay(time - prev_start_time, STRAIN_DECAY_BASE)
}
#[inline]
fn curr_section_peak(&mut self) -> &mut f64 {
&mut self.curr_section_peak
const fn curr_section_peak(&self) -> f64 {
self.inner.inner.inner.curr_section_peak
}
#[inline]
fn curr_section_end(&mut self) -> &mut f64 {
&mut self.curr_section_end
fn curr_section_peak_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.inner.curr_section_peak
}
#[inline]
fn strain_value_at(
&mut self,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) -> f64 {
self.curr_strain *= Self::strain_decay(curr.strain_time);
self.curr_strain += SpeedEvaluator::evaluate_diff_of(curr, diff_objects, self.hit_window)
* Self::SKILL_MULTIPLIER;
self.curr_rhythm = RhythmEvaluator::evaluate_diff_of(curr, diff_objects, self.hit_window);
const fn curr_section_end(&self) -> f64 {
self.inner.inner.inner.curr_section_end
}
let total_strain = self.curr_strain * self.curr_rhythm;
self.object_strains.push(total_strain);
fn curr_section_end_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.inner.curr_section_end
}
pub fn process(&mut self, curr: &'a OsuDifficultyObject<'a>) {
if curr.idx == 0 {
*self.curr_section_end_mut() = (curr.start_time / OsuStrainSkill::SECTION_LEN).ceil()
* OsuStrainSkill::SECTION_LEN;
}
while curr.start_time > self.curr_section_end() {
self.inner.inner.save_curr_peak();
let initial_strain = self.calculate_initial_strain(self.curr_section_end(), curr);
self.inner.inner.start_new_section_from(initial_strain);
*self.curr_section_end_mut() += OsuStrainSkill::SECTION_LEN;
}
let strain_value_at = self.strain_value_at(curr);
*self.curr_section_peak_mut() = strain_value_at.max(self.curr_section_peak());
}
fn strain_value_at(&mut self, curr: &'a OsuDifficultyObject<'a>) -> f64 {
self.inner.curr_strain *= strain_decay(curr.strain_time, STRAIN_DECAY_BASE);
self.inner.curr_strain +=
SpeedEvaluator::evaluate_diff_of(curr, self.diff_objects, self.inner.hit_window)
* SKILL_MULTIPLIER;
self.inner.curr_rhythm =
RhythmEvaluator::evaluate_diff_of(curr, self.diff_objects, self.inner.hit_window);
let total_strain = self.inner.curr_strain * self.inner.curr_rhythm;
self.inner.object_strains.push(total_strain);
total_strain
}
#[inline]
fn calculate_initial_strain(
&self,
time: f64,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) -> f64 {
(self.curr_strain * self.curr_rhythm)
* Self::strain_decay(time - previous_start_time(diff_objects, curr.idx, 0))
}
#[inline]
fn difficulty_value(&mut self) -> f64 {
<Self as OsuStrainSkill>::difficulty_value(self)
}
}
impl OsuStrainSkill for Speed {
const REDUCED_SECTION_COUNT: usize = 5;
const DIFFICULTY_MULTIPLER: f64 = 1.04;
}
struct SpeedEvaluator;
@@ -127,9 +131,9 @@ impl SpeedEvaluator {
const MIN_SPEED_BONUS: f64 = 75.0; // ~200BPM
const SPEED_BALANCING_FACTOR: f64 = 40.;
fn evaluate_diff_of(
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
fn evaluate_diff_of<'a>(
curr: &'a OsuDifficultyObject<'a>,
diff_objects: &'a [OsuDifficultyObject<'a>],
hit_window: f64,
) -> f64 {
if curr.base.is_spinner() {
@@ -138,8 +142,8 @@ impl SpeedEvaluator {
// * derive strainTime for calculation
let osu_curr_obj = curr;
let osu_prev_obj = previous(diff_objects, curr.idx, 0);
let osu_next_obj = next(diff_objects, curr.idx, 0);
let osu_prev_obj = curr.previous(0, diff_objects);
let osu_next_obj = curr.next(0, diff_objects);
let mut strain_time = curr.strain_time;
let mut doubletapness = 1.0;
@@ -168,9 +172,8 @@ impl SpeedEvaluator {
1.0
};
let travel_dist = osu_prev_obj.map_or(0.0, |obj| obj.dists.travel_dist);
let dist =
Self::SINGLE_SPACING_THRESHOLD.min(travel_dist + osu_curr_obj.dists.min_jump_dist);
let travel_dist = osu_prev_obj.map_or(0.0, |obj| obj.travel_dist);
let dist = Self::SINGLE_SPACING_THRESHOLD.min(travel_dist + osu_curr_obj.min_jump_dist);
(speed_bonus + speed_bonus * (dist / Self::SINGLE_SPACING_THRESHOLD).powf(3.5))
* doubletapness
@@ -185,9 +188,9 @@ impl RhythmEvaluator {
const HISTORY_TIME_MAX: u32 = 5000;
const RHYTHM_MULTIPLIER: f64 = 0.75;
fn evaluate_diff_of(
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
fn evaluate_diff_of<'a>(
curr: &'a OsuDifficultyObject<'a>,
diff_objects: &'a [OsuDifficultyObject<'a>],
hit_window: f64,
) -> f64 {
if curr.base.is_spinner() {
@@ -207,7 +210,8 @@ impl RhythmEvaluator {
let mut rhythm_start = 0;
while previous(diff_objects, curr.idx, rhythm_start)
while curr
.previous(rhythm_start, diff_objects)
.filter(|prev| {
rhythm_start + 2 < historical_note_count
&& curr.start_time - prev.start_time < f64::from(Self::HISTORY_TIME_MAX)
@@ -218,9 +222,10 @@ impl RhythmEvaluator {
}
for i in (1..=rhythm_start).rev() {
let Some(((curr_obj, prev_obj), last_obj)) = previous(diff_objects, curr.idx, i - 1)
.zip(previous(diff_objects, curr.idx, i))
.zip(previous(diff_objects, curr.idx, i + 1))
let Some(((curr_obj, prev_obj), last_obj)) = curr
.previous(i - 1, diff_objects)
.zip(curr.previous(i, diff_objects))
.zip(curr.previous(i + 1, diff_objects))
else {
break;
};
+63
View File
@@ -0,0 +1,63 @@
use crate::any::difficulty::skills::StrainSkill;
#[derive(Default)]
pub struct OsuStrainSkill {
pub inner: StrainSkill,
}
impl OsuStrainSkill {
pub const REDUCED_SECTION_COUNT: usize = 10;
pub const REDUCED_STRAIN_BASELINE: f64 = 0.75;
pub const DIFFICULTY_MULTIPLER: f64 = 1.06;
pub const DECAY_WEIGHT: f64 = 0.9;
pub const SECTION_LEN: f64 = 400.0;
pub fn save_curr_peak(&mut self) {
self.inner.save_curr_peak();
}
pub fn start_new_section_from(&mut self, initial_strain: f64) {
self.inner.start_new_section_from(initial_strain);
}
pub fn get_curr_strain_peaks(self) -> Vec<f64> {
self.inner.get_curr_strain_peaks()
}
pub fn difficulty_value(
self,
reduced_section_count: usize,
reduced_strain_baseline: f64,
decay_weight: f64,
difficulty_multiplier: f64,
) -> f64 {
let mut difficulty = 0.0;
let mut weight = 1.0;
let mut peaks = self.get_curr_strain_peaks();
peaks.retain(|&strain| strain > 0.0);
peaks.sort_by(|a, b| b.total_cmp(a));
let peaks_iter = peaks.iter_mut().take(reduced_section_count);
for (i, strain) in peaks_iter.enumerate() {
let clamped = f64::from((i as f32 / reduced_section_count as f32).clamp(0.0, 1.0));
let scale = (lerp(1.0, 10.0, clamped)).log10();
*strain *= lerp(reduced_strain_baseline, 1.0, scale);
}
peaks.sort_by(|a, b| b.total_cmp(a));
for strain in peaks {
difficulty += strain * weight;
weight *= decay_weight;
}
difficulty * difficulty_multiplier
}
}
fn lerp(start: f64, end: f64, amount: f64) -> f64 {
start + (end - start) * amount
}
-261
View File
@@ -1,261 +0,0 @@
use crate::{
osu::osu_object::{NestedObjectKind, OsuObjectKind},
parse::Pos2,
};
use std::pin::Pin;
use super::{osu_object::OsuSlider, OsuObject, ScalingFactor};
#[derive(Clone, Debug)]
pub(crate) struct OsuDifficultyObject<'h> {
pub(crate) start_time: f64,
pub(crate) delta_time: f64,
pub(crate) base: Pin<&'h OsuObject>,
pub(crate) strain_time: f64,
pub(crate) dists: Distances,
pub(crate) idx: usize,
}
impl<'h> OsuDifficultyObject<'h> {
pub(crate) const MIN_DELTA_TIME: u32 = 25;
pub(crate) fn new(
base: Pin<&'h OsuObject>,
last: &'h OsuObject,
clock_rate: f64,
idx: usize,
dists: Distances,
) -> Self {
let start_time = base.start_time / clock_rate;
let delta_time = (base.start_time - last.start_time) / clock_rate;
// * Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
let strain_time = delta_time.max(f64::from(Self::MIN_DELTA_TIME));
Self {
start_time,
delta_time,
base,
strain_time,
dists,
idx,
}
}
pub(crate) fn opacity_at(
&self,
time: f64,
hidden: bool,
time_preempt: f64,
time_fade_in: f64,
) -> f64 {
if time > self.base.start_time {
// * Consider a hitobject as being invisible when its start time is passed.
// * In reality the hitobject will be visible beyond its start time up until its hittable window has passed,
// * but this is an approximation and such a case is unlikely to be hit where this function is used.
return 0.0;
}
let fade_in_start_time = self.base.start_time - time_preempt;
let fade_in_duration = time_fade_in;
if hidden {
// * Taken from OsuModHidden.
let fade_out_start_time = self.base.start_time - time_preempt + time_fade_in;
const FADE_OUT_DURATION_MULTIPLIER: f64 = 0.3;
let fade_out_duration = time_preempt * FADE_OUT_DURATION_MULTIPLIER;
(((time - fade_in_start_time) / fade_in_duration).clamp(0.0, 1.0))
.min(1.0 - ((time - fade_out_start_time) / fade_out_duration).clamp(0.0, 1.0))
} else {
((time - fade_in_start_time) / fade_in_duration).clamp(0.0, 1.0)
}
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct Distances {
pub(crate) lazy_jump_dist: f64,
pub(crate) lazy_travel_dist: f32,
pub(crate) min_jump_dist: f64,
pub(crate) min_jump_time: f64,
pub(crate) travel_dist: f64,
pub(crate) travel_time: f64,
pub(crate) angle: Option<f64>,
}
impl Distances {
pub(crate) const NORMALISED_RADIUS: f32 = 50.0;
const MAXIMUM_SLIDER_RADIUS: f32 = Self::NORMALISED_RADIUS * 2.4;
const ASSUMED_SLIDER_RADIUS: f32 = Self::NORMALISED_RADIUS * 1.8;
/// Create a new instance of [`Distances`].
///
/// By taking in [`Pin<&mut OsuObject>`](Pin), we imply that the argument will be
/// modified but it won't be moved.
#[allow(clippy::similar_names)]
pub(crate) fn new(
base: &mut Pin<&mut OsuObject>,
last: &OsuObject,
last_last: Option<&OsuObject>,
clock_rate: f64,
strain_time: f64,
scaling_factor_: &ScalingFactor,
) -> Self {
let pos = base.pos();
let stack_offset = base.stack_offset;
let mut this = if let OsuObjectKind::Slider(ref mut slider) = base.kind {
let lazy_travel_dist =
Self::compute_slider_travel_dist(pos, stack_offset, slider, scaling_factor_);
let repeat_count = slider.repeat_count();
Self {
// * Bonus for repeat sliders until a better per nested object strain system can be achieved.
travel_dist: f64::from(
lazy_travel_dist * (1.0 + repeat_count as f64 / 2.5).powf(1.0 / 2.5) as f32,
),
travel_time: (base.lazy_travel_time() / clock_rate)
.max(f64::from(OsuDifficultyObject::MIN_DELTA_TIME)),
lazy_travel_dist,
..Default::default()
}
} else {
Self::default()
};
// * We don't need to calculate either angle or distance when
// * one of the last->curr objects is a spinner
if base.is_spinner() || last.is_spinner() {
return this;
}
// * We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
let scaling_factor = scaling_factor_.factor;
let last_cursor_pos = Self::get_end_cursor_pos(last);
this.lazy_jump_dist = f64::from(
(base.stacked_pos() * scaling_factor - last_cursor_pos * scaling_factor).length(),
);
this.min_jump_time = strain_time;
this.min_jump_dist = this.lazy_jump_dist;
if let OsuObjectKind::Slider(slider) = &last.kind {
let last_travel_time = (last.lazy_travel_time() / clock_rate)
.max(f64::from(OsuDifficultyObject::MIN_DELTA_TIME));
this.min_jump_time = (strain_time - last_travel_time)
.max(f64::from(OsuDifficultyObject::MIN_DELTA_TIME));
// * There are two types of slider-to-object patterns to consider in order
// * to better approximate the real movement a player will take to jump between the hitobjects.
// *
// * 1. The anti-flow pattern, where players cut the slider short in order to move to the next hitobject.
// *
// * <======o==> ← slider
// * | ← most natural jump path
// * o ← a follow-up hitcircle
// *
// * In this case the most natural jump path is approximated by LazyJumpDistance.
// *
// * 2. The flow pattern, where players follow through the slider to its
// * visual extent into the next hitobject.
// *
// * <======o==>---o
// * ↑
// * most natural jump path
// *
// * In this case the most natural jump path is better approximated by a new distance
// * called "tailJumpDistance" - the distance between the slider's tail and the next hitobject.
// *
// * Thus, the player is assumed to jump the minimum of these two distances in all cases.
let stacked_tail_pos =
slider.tail().map_or_else(|| last.pos(), |tail| tail.pos) + last.stack_offset;
let tail_jump_dist = (stacked_tail_pos - base.stacked_pos()).length() * scaling_factor;
let diff = f64::from(Self::MAXIMUM_SLIDER_RADIUS - Self::ASSUMED_SLIDER_RADIUS);
let min = f64::from(tail_jump_dist - Self::MAXIMUM_SLIDER_RADIUS);
// "attributes on expressions are experimental see issue #15701 https://github.com/rust-lang/rust/issues/15701"
// rust pls...
#[allow(clippy::manual_clamp)]
let tmp = (this.lazy_jump_dist - diff).min(min).max(0.0);
this.min_jump_dist = tmp;
}
if let Some(last_last) = last_last.filter(|obj| !obj.is_spinner()) {
let last_last_cursor_pos = Self::get_end_cursor_pos(last_last);
let v1 = last_last_cursor_pos - last.stacked_pos();
let v2 = base.stacked_pos() - last_cursor_pos;
let dot = f64::from(v1.dot(v2));
let det = f64::from(v1.x * v2.y - v1.y * v2.x);
this.angle = Some(det.atan2(dot).abs());
}
this
}
pub(crate) fn compute_slider_travel_dist(
pos: Pos2,
stack_offset: Pos2,
slider: &mut OsuSlider,
scaling_factor_: &ScalingFactor,
) -> f32 {
let mut curr_cursor_pos = pos + stack_offset;
let scaling_factor = f64::from(Self::NORMALISED_RADIUS) / f64::from(scaling_factor_.radius);
let mut lazy_travel_dist: f32 = 0.0;
for (curr_movement_obj, i) in slider.nested_objects.iter().zip(1..) {
let mut curr_movement = (curr_movement_obj.pos + stack_offset) - curr_cursor_pos;
let mut curr_movement_len = scaling_factor * f64::from(curr_movement.length());
// * Amount of movement required so that the cursor position needs to be updated.
let mut required_movement = f64::from(Self::ASSUMED_SLIDER_RADIUS);
if i == slider.nested_objects.len() {
// * The end of a slider has special aim rules due
// * to the relaxed time constraint on position.
// * There is both a lazy end position as well as the actual end slider position.
// * We assume the player takes the simpler movement.
// * For sliders that are circular, the lazy end position
// * may actually be farther away than the sliders true end.
// * This code is designed to prevent buffing situations
// * where lazy end is actually a less efficient movement.
let lazy_movement = slider.lazy_end_pos - curr_cursor_pos;
if lazy_movement.length() < curr_movement.length() {
curr_movement = lazy_movement;
}
curr_movement_len = scaling_factor * f64::from(curr_movement.length());
} else if let NestedObjectKind::Repeat = curr_movement_obj.kind {
// * For a slider repeat, assume a tighter movement threshold to better assess repeat sliders.
required_movement = f64::from(Self::NORMALISED_RADIUS);
}
if curr_movement_len > required_movement {
// * this finds the positional delta from the required radius and the current position, and updates the currCursorPosition accordingly, as well as rewarding distance.
curr_cursor_pos += curr_movement
* ((curr_movement_len - required_movement) / curr_movement_len) as f32;
curr_movement_len *= (curr_movement_len - required_movement) / curr_movement_len;
lazy_travel_dist += curr_movement_len as f32;
}
}
slider.lazy_end_pos = curr_cursor_pos;
lazy_travel_dist
}
fn get_end_cursor_pos(hit_object: &OsuObject) -> Pos2 {
hit_object.lazy_end_pos()
}
}
-375
View File
@@ -1,375 +0,0 @@
#![cfg(feature = "gradual")]
use std::{
fmt::{Debug, Formatter, Result as FmtResult},
mem,
pin::Pin,
};
use crate::{Beatmap, Mods};
use self::osu_objects::OsuObjects;
use super::{
difficulty_object::{Distances, OsuDifficultyObject},
osu_object::{OsuObject, OsuObjectKind},
scaling_factor::ScalingFactor,
skills::{Skill, Skills},
OsuDifficultyAttributes, DIFFICULTY_MULTIPLIER, FADE_IN_DURATION_MULTIPLIER,
PERFORMANCE_BASE_MULTIPLIER, PREEMPT_MIN,
};
/// Gradually calculate the difficulty attributes of an osu!standard map.
///
/// Note that this struct implements [`Iterator`].
/// On every call of [`Iterator::next`], the map's next hit object will
/// be processed and the [`OsuDifficultyAttributes`] will be updated and returned.
///
/// If you want to calculate performance attributes, use
/// [`OsuGradualPerformance`](crate::osu::OsuGradualPerformance) instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, osu::OsuGradualDifficulty};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut iter = OsuGradualDifficulty::new(&map, mods);
///
/// let attrs1 = iter.next(); // the difficulty of the map after the first hit object
/// let attrs2 = iter.next(); // after the second hit object
///
/// // Remaining hit objects
/// for difficulty in iter {
/// // ...
/// }
/// ```
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
pub struct OsuGradualDifficulty {
pub(crate) idx: usize,
mods: u32,
attrs: OsuDifficultyAttributes,
skills: Skills,
// Lifetimes actually depend on `osu_objects` so this type is self-referential.
// This field must be treated with great caution, moving `osu_objects` will immediately
// invalidate `diff_objects`.
diff_objects: Vec<OsuDifficultyObject<'static>>,
osu_objects: OsuObjects,
// Additional safety measure that this type can't be cloned which would invalidate
// `diff_objects`.
_not_clonable: NotClonable,
}
struct NotClonable;
impl Debug for OsuGradualDifficulty {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
f.debug_struct("OsuGradualDifficulty")
.field("mods", &self.mods)
.field("idx", &self.idx)
.field("attrs", &self.attrs)
.field("diff_objects", &self.diff_objects)
.field("osu_objects", &"...")
.field("skills", &self.skills)
.field("_not_clonable", &"...")
.finish()
}
}
impl OsuGradualDifficulty {
/// Create a new difficulty attributes iterator for osu!standard maps.
pub fn new(map: &Beatmap, mods: u32) -> Self {
let clock_rate = mods.clock_rate();
let map_attrs = map.attributes().mods(mods).build();
let scaling_factor = ScalingFactor::new(map_attrs.cs);
let hr = mods.hr();
let hit_window = 2.0 * map_attrs.hit_windows.od;
let time_preempt = f64::from((map_attrs.hit_windows.ar * clock_rate) as f32);
// * Preempt time can go below 450ms. Normally, this is achieved via the DT mod
// * which uniformly speeds up all animations game wide regardless of AR.
// * This uniform speedup is hard to match 1:1, however we can at least make
// * AR>10 (via mods) feel good by extending the upper linear function above.
// * Note that this doesn't exactly match the AR>10 visuals as they're
// * classically known, but it feels good.
// * This adjustment is necessary for AR>10, otherwise TimePreempt can
// * become smaller leading to hitcircles not fully fading in.
let time_fade_in = if mods.hd() {
time_preempt * FADE_IN_DURATION_MULTIPLIER
} else {
400.0 * (time_preempt / PREEMPT_MIN).min(1.0)
};
let mut attrs = OsuDifficultyAttributes {
ar: map_attrs.ar,
hp: map_attrs.hp,
od: map_attrs.od,
..Default::default()
};
let hit_objects = crate::osu::create_osu_objects(
map,
&mut attrs,
&scaling_factor,
map.hit_objects.len(),
hr,
time_preempt,
);
let mut osu_objects = OsuObjects::new(hit_objects);
attrs.n_circles = 0;
attrs.n_sliders = 0;
attrs.n_spinners = 0;
attrs.max_combo = 0;
let skills = Skills::new(
mods,
scaling_factor.radius,
time_preempt,
time_fade_in,
hit_window,
);
let mut osu_objects_iter = osu_objects.iter_mut();
let Some(mut last) = osu_objects_iter.next() else {
return Self {
idx: 0,
mods,
attrs,
skills,
diff_objects: Vec::new(),
osu_objects: OsuObjects::new(Vec::new()),
_not_clonable: NotClonable,
};
};
Self::increment_combo(last.as_ref().get_ref(), &mut attrs);
let mut last_last = None;
// Prepare `lazy_travel_dist` and `lazy_end_pos` for `last` manually
let last_pos = last.pos();
let last_stack_offset = last.stack_offset;
if let OsuObjectKind::Slider(ref mut slider) = last.kind {
Distances::compute_slider_travel_dist(
last_pos,
last_stack_offset,
slider,
&scaling_factor,
);
}
let mut last = last.into_ref();
let mut diff_objects = Vec::with_capacity(map.hit_objects.len().saturating_sub(2));
for (i, mut curr) in osu_objects_iter.enumerate() {
let delta_time = (curr.start_time - last.start_time) / clock_rate;
// * Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
let strain_time = delta_time.max(f64::from(OsuDifficultyObject::MIN_DELTA_TIME));
let dists = Distances::new(
&mut curr,
last.get_ref(),
last_last.map(Pin::get_ref),
clock_rate,
strain_time,
&scaling_factor,
);
let curr = curr.into_ref();
let diff_obj = OsuDifficultyObject::new(curr, last.get_ref(), clock_rate, i, dists);
diff_objects.push(diff_obj);
last_last = Some(last);
last = curr;
}
Self {
idx: 0,
mods,
attrs,
skills,
diff_objects: extend_lifetime(diff_objects),
osu_objects,
_not_clonable: NotClonable,
}
}
fn increment_combo(h: &OsuObject, attrs: &mut OsuDifficultyAttributes) {
attrs.max_combo += 1;
match &h.kind {
OsuObjectKind::Circle => attrs.n_circles += 1,
OsuObjectKind::Slider(slider) => {
attrs.n_sliders += 1;
attrs.max_combo += slider.nested_objects.len();
}
OsuObjectKind::Spinner { .. } => attrs.n_spinners += 1,
}
}
}
fn extend_lifetime(
diff_objects: Vec<OsuDifficultyObject<'_>>,
) -> Vec<OsuDifficultyObject<'static>> {
// SAFETY: Owned values of the references will be contained in the same struct (same lifetime).
// Also, the only mutable access wraps them in `Pin` to ensure that they won't move.
unsafe { mem::transmute(diff_objects) }
}
impl Iterator for OsuGradualDifficulty {
type Item = OsuDifficultyAttributes;
fn next(&mut self) -> Option<Self::Item> {
// The first difficulty object belongs to the second note since each difficulty
// object requires the current and the last note. Hence, if we're still on the first
// object, we don't have a difficulty object yet and just skip processing.
if self.idx > 0 {
let curr = self.diff_objects.get(self.idx - 1)?;
self.skills.process(curr, &self.diff_objects);
Self::increment_combo(curr.base.get_ref(), &mut self.attrs);
} else if self.osu_objects.is_empty() {
return None;
}
self.idx += 1;
let Skills {
mut aim,
mut aim_no_sliders,
mut speed,
mut flashlight,
} = self.skills.clone();
let mut aim_rating = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let aim_rating_no_sliders =
aim_no_sliders.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let speed_notes = speed.relevant_note_count();
let mut speed_rating = speed.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let mut flashlight_rating = flashlight.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let slider_factor = if aim_rating > 0.0 {
aim_rating_no_sliders / aim_rating
} else {
1.0
};
if self.mods.td() {
aim_rating = aim_rating.powf(0.8);
flashlight_rating = flashlight_rating.powf(0.8);
}
if self.mods.rx() {
aim_rating *= 0.9;
speed_rating = 0.0;
flashlight_rating *= 0.7;
}
let base_aim_performance = (5.0 * (aim_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
let base_speed_performance =
(5.0 * (speed_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
let base_flashlight_performance = if self.mods.fl() {
flashlight_rating * flashlight_rating * 25.0
} else {
0.0
};
let base_performance = ((base_aim_performance).powf(1.1)
+ (base_speed_performance).powf(1.1)
+ (base_flashlight_performance).powf(1.1))
.powf(1.0 / 1.1);
let star_rating = if base_performance > 0.00001 {
PERFORMANCE_BASE_MULTIPLIER.cbrt()
* 0.027
* ((100_000.0 / 2.0_f64.powf(1.0 / 1.1) * base_performance).cbrt() + 4.0)
} else {
0.0
};
let attrs = OsuDifficultyAttributes {
aim: aim_rating,
speed: speed_rating,
flashlight: flashlight_rating,
slider_factor,
stars: star_rating,
speed_note_count: speed_notes,
..self.attrs.clone()
};
Some(attrs)
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
let len = self.len();
(len, Some(len))
}
fn nth(&mut self, n: usize) -> Option<Self::Item> {
let skip_iter = self.diff_objects.iter().skip(self.idx.saturating_sub(1));
let mut take = n.min(self.len().saturating_sub(1));
// The first note has no difficulty object
if self.idx == 0 && take > 0 {
take -= 1;
self.idx += 1;
}
for curr in skip_iter.take(take) {
self.skills.process(curr, &self.diff_objects);
Self::increment_combo(curr.base.get_ref(), &mut self.attrs);
self.idx += 1;
}
self.next()
}
}
impl ExactSizeIterator for OsuGradualDifficulty {
#[inline]
fn len(&self) -> usize {
self.diff_objects.len() + 1 - self.idx
}
}
mod osu_objects {
use crate::osu::OsuObject;
use std::pin::Pin;
// Wrapper to ensure that the data will not be moved
pub(super) struct OsuObjects {
objects: Box<[OsuObject]>,
}
impl OsuObjects {
pub(super) fn new(objects: Vec<OsuObject>) -> Self {
Self {
objects: objects.into_boxed_slice(),
}
}
pub(super) const fn is_empty(&self) -> bool {
self.objects.is_empty()
}
pub(super) fn iter_mut(&mut self) -> impl Iterator<Item = Pin<&mut OsuObject>> {
self.objects.iter_mut().map(Pin::new)
}
}
}
-194
View File
@@ -1,194 +0,0 @@
#![cfg(feature = "gradual")]
use crate::{Beatmap, OsuPP};
use super::{OsuGradualDifficulty, OsuPerformanceAttributes, OsuScoreState};
/// Gradually calculate the performance attributes of an osu!standard map.
///
/// After each hit object you can call [`next`](`OsuGradualPerformance::next`)
/// and it will return the resulting current [`OsuPerformanceAttributes`].
/// To process multiple objects at once, use [`nth`](`OsuGradualPerformance::nth`) instead.
///
/// Both methods require an [`OsuScoreState`] that contains the current
/// hitresults as well as the maximum combo so far.
///
/// If you only want to calculate difficulty attributes use
/// [`OsuGradualDifficulty`] instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, osu::{OsuGradualPerformance, OsuScoreState}};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut gradual_perf = OsuGradualPerformance::new(&map, mods);
/// let mut state = OsuScoreState::new(); // empty state, everything is on 0.
///
/// // The first 10 hitresults are 300s and there are no sliders for additional combo
/// for _ in 0..10 {
/// state.n300 += 1;
/// state.max_combo += 1;
///
/// # /*
/// let performance = gradual_perf.next(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.next(state.clone());
/// }
///
/// // Then comes a miss.
/// // Note that state's max combo won't be incremented for
/// // the next few objects because the combo is reset.
/// state.n_misses += 1;
/// # /*
/// let performance = gradual_perf.next(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.next(state.clone());
///
/// // The next 10 objects will be a mixture of 300s, 100s, and 50s.
/// // Notice how all 10 objects will be processed in one go.
/// state.n300 += 2;
/// state.n100 += 7;
/// state.n50 += 1;
/// // The `nth` method takes a zero-based value.
/// # /*
/// let performance = gradual_perf.nth(state.clone(), 9).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.nth(state.clone(), 9);
///
/// // Now comes another 300. Note that the max combo gets incremented again.
/// state.n300 += 1;
/// state.max_combo += 1;
/// # /*
/// let performance = gradual_perf.next(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.next(state.clone());
///
/// // Skip to the end
/// # /*
/// state.max_combo = ...
/// state.n300 = ...
/// state.n100 = ...
/// state.n50 = ...
/// state.n_misses = ...
/// let final_performance = gradual_perf.nth(state.clone(), usize::MAX).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.nth(state.clone(), usize::MAX);
///
/// // Once the final performance was calculated,
/// // attempting to process further objects will return `None`.
/// assert!(gradual_perf.next(state).is_none());
/// ```
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Debug)]
pub struct OsuGradualPerformance<'map> {
difficulty: OsuGradualDifficulty,
performance: OsuPP<'map>,
}
impl<'map> OsuGradualPerformance<'map> {
/// Create a new gradual performance calculator for osu!standard maps.
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
let difficulty = OsuGradualDifficulty::new(map, mods);
let performance = OsuPP::new(map).mods(mods).passed_objects(0);
Self {
difficulty,
performance,
}
}
/// Process the next hit object and calculate the
/// performance attributes for the resulting score state.
pub fn next(&mut self, state: OsuScoreState) -> Option<OsuPerformanceAttributes> {
self.nth(state, 0)
}
/// Process all remaining hit objects and calculate the final performance attributes.
pub fn last(&mut self, state: OsuScoreState) -> Option<OsuPerformanceAttributes> {
self.nth(state, usize::MAX)
}
/// Process everything up the the next `n`th hit object and calculate the performance
/// attributes for the resulting score state.
///
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
/// and so on.
pub fn nth(&mut self, state: OsuScoreState, n: usize) -> Option<OsuPerformanceAttributes> {
let difficulty = self.difficulty.nth(n)?;
let performance = self
.performance
.clone()
.attributes(difficulty)
.state(state)
.passed_objects(self.difficulty.idx)
.calculate();
Some(performance)
}
}
/// Gradually calculate the performance attributes of an osu!standard map.
///
/// Check [`OsuGradualPerformance`] for more information. This struct does the same
/// but takes ownership of [`Beatmap`] to avoid being bound to a lifetime.
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
#[derive(Debug)]
pub struct OsuOwnedGradualPerformance {
difficulty: OsuGradualDifficulty,
map: Beatmap,
mods: u32,
}
impl OsuOwnedGradualPerformance {
/// Create a new gradual performance calculator for osu!standard maps.
pub fn new(map: Beatmap, mods: u32) -> Self {
let difficulty = OsuGradualDifficulty::new(&map, mods);
Self {
difficulty,
map,
mods,
}
}
/// Process the next hit object and calculate the
/// performance attributes for the resulting score state.
pub fn next(&mut self, state: OsuScoreState) -> Option<OsuPerformanceAttributes> {
self.nth(state, 0)
}
/// Process all remaining hit objects and calculate the final performance attributes.
pub fn last(&mut self, state: OsuScoreState) -> Option<OsuPerformanceAttributes> {
self.nth(state, usize::MAX)
}
/// Process everything up the the next `n`th hit object and calculate the performance
/// attributes for the resulting score state.
///
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
/// and so on.
pub fn nth(&mut self, state: OsuScoreState, n: usize) -> Option<OsuPerformanceAttributes> {
let difficulty = self.difficulty.nth(n)?;
let performance = OsuPP::new(&self.map)
.mods(self.mods)
.attributes(difficulty)
.state(state)
.passed_objects(self.difficulty.idx)
.calculate();
Some(performance)
}
}
+37 -643
View File
@@ -1,660 +1,54 @@
mod difficulty_object;
mod osu_object;
mod pp;
mod scaling_factor;
mod score_state;
mod skills;
use std::borrow::Cow;
#[cfg(feature = "gradual")]
mod gradual_difficulty;
#[cfg(feature = "gradual")]
mod gradual_performance;
use rosu_map::util::Pos;
use crate::{curve::CurveBuffers, parse::Pos2, AnyStars, Beatmap, GameMode, Mods};
use std::pin::Pin;
use self::{
difficulty_object::{Distances, OsuDifficultyObject},
skills::{Skill, Skills},
use crate::{
any::ModeDifficulty,
model::{
beatmap::Beatmap,
mode::{ConvertStatus, IGameMode},
},
};
pub use self::{osu_object::*, pp::*, score_state::OsuScoreState};
#[cfg(feature = "gradual")]
pub use self::{
gradual_difficulty::OsuGradualDifficulty,
gradual_performance::{OsuGradualPerformance, OsuOwnedGradualPerformance},
attributes::{OsuDifficultyAttributes, OsuPerformanceAttributes},
convert::OsuBeatmap,
performance::OsuPerformance,
score_state::OsuScoreState,
strains::OsuStrains,
};
pub(crate) use self::scaling_factor::ScalingFactor;
mod attributes;
mod convert;
mod difficulty;
mod object;
mod performance;
mod score_state;
mod strains;
const SECTION_LEN: f64 = 400.0;
const DIFFICULTY_MULTIPLIER: f64 = 0.0675;
// * Change radius to 50 to make 100 the diameter. Easier for mental maths.
const NORMALIZED_RADIUS: f32 = 50.0;
const STACK_DISTANCE: f32 = 3.0;
// * This is being adjusted to keep the final pp value scaled around what it used to be when changing things.
const PERFORMANCE_BASE_MULTIPLIER: f64 = 1.14;
const PREEMPT_MIN: f64 = 450.0;
const FADE_IN_DURATION_MULTIPLIER: f64 = 0.4;
const PLAYFIELD_BASE_SIZE: Pos2 = Pos2 { x: 512.0, y: 384.0 };
const PLAYFIELD_BASE_SIZE: Pos = Pos::new(512.0, 384.0);
/// Difficulty calculator on osu!standard maps.
/// Marker type for [`GameMode::Osu`].
///
/// # Example
///
/// ```
/// use rosu_pp::{OsuStars, Beatmap};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let difficulty_attrs = OsuStars::new(&map)
/// .mods(8 + 64) // HDDT
/// .calculate();
///
/// println!("Stars: {}", difficulty_attrs.stars);
/// ```
#[derive(Clone, Debug)]
#[must_use]
pub struct OsuStars<'map> {
pub(crate) map: &'map Beatmap,
pub(crate) mods: u32,
pub(crate) passed_objects: Option<usize>,
pub(crate) clock_rate: Option<f64>,
}
/// [`GameMode::Osu`]: rosu_map::section::general::GameMode::Osu
pub struct Osu;
impl<'map> OsuStars<'map> {
/// Create a new difficulty calculator for osu!standard maps.
#[inline]
pub const fn new(map: &'map Beatmap) -> Self {
Self {
map,
mods: 0,
passed_objects: None,
clock_rate: None,
}
impl IGameMode for Osu {
type DifficultyAttributes = OsuDifficultyAttributes;
type Strains = OsuStrains;
fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus {
convert::try_convert(map)
}
/// Convert the map into another mode.
#[inline]
pub fn mode(self, mode: GameMode) -> AnyStars<'map> {
match mode {
GameMode::Osu => AnyStars::Osu(self),
GameMode::Taiko => AnyStars::Taiko(self.into()),
GameMode::Catch => AnyStars::Catch(self.into()),
GameMode::Mania => AnyStars::Mania(self.into()),
}
fn difficulty(
difficulty: &ModeDifficulty,
converted: &OsuBeatmap<'_>,
) -> Self::DifficultyAttributes {
difficulty::difficulty(difficulty, converted)
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
#[inline]
pub const fn mods(mut self, mods: u32) -> Self {
self.mods = mods;
self
}
/// Amount of passed objects for partial plays, e.g. a fail.
///
#[cfg_attr(
feature = "gradual",
doc = "If you want to calculate the difficulty after every few objects, instead of
using [`OsuStars`] multiple times with different `passed_objects`, you should use
[`OsuGradualDifficulty`]."
)]
#[inline]
pub const fn passed_objects(mut self, passed_objects: usize) -> Self {
self.passed_objects = Some(passed_objects);
self
}
/// Adjust the clock rate used in the calculation.
/// If none is specified, it will take the clock rate based on the mods
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
#[inline]
pub const fn clock_rate(mut self, clock_rate: f64) -> Self {
self.clock_rate = Some(clock_rate);
self
}
/// Calculate all difficulty related values, including stars.
#[inline]
pub fn calculate(self) -> OsuDifficultyAttributes {
let mods = self.mods;
let (skills, mut attrs) = calculate_skills(self);
let Skills {
mut aim,
mut aim_no_sliders,
mut speed,
mut flashlight,
} = skills;
let mut aim_rating = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let aim_rating_no_sliders =
aim_no_sliders.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let speed_notes = speed.relevant_note_count();
let mut speed_rating = speed.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let mut flashlight_rating = flashlight.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let slider_factor = if aim_rating > 0.0 {
aim_rating_no_sliders / aim_rating
} else {
1.0
};
if mods.td() {
aim_rating = aim_rating.powf(0.8);
flashlight_rating = flashlight_rating.powf(0.8);
}
if mods.rx() {
aim_rating *= 0.9;
speed_rating = 0.0;
flashlight_rating *= 0.7;
}
let base_aim_performance = (5.0 * (aim_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
let base_speed_performance =
(5.0 * (speed_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
let base_flashlight_performance = if mods.fl() {
flashlight_rating * flashlight_rating * 25.0
} else {
0.0
};
let base_performance = ((base_aim_performance).powf(1.1)
+ (base_speed_performance).powf(1.1)
+ (base_flashlight_performance).powf(1.1))
.powf(1.0 / 1.1);
let star_rating = if base_performance > 0.00001 {
PERFORMANCE_BASE_MULTIPLIER.cbrt()
* 0.027
* ((100_000.0 / 2.0_f64.powf(1.0 / 1.1) * base_performance).cbrt() + 4.0)
} else {
0.0
};
attrs.aim = aim_rating;
attrs.speed = speed_rating;
attrs.flashlight = flashlight_rating;
attrs.slider_factor = slider_factor;
attrs.stars = star_rating;
attrs.speed_note_count = speed_notes;
attrs
}
/// Calculate the skill strains.
///
/// Suitable to plot the difficulty of a map over time.
#[inline]
pub fn strains(self) -> OsuStrains {
let (skills, _) = calculate_skills(self);
let Skills {
aim,
aim_no_sliders,
speed,
flashlight,
} = skills;
OsuStrains {
section_len: SECTION_LEN,
aim: aim.strain_peaks.to_vec(),
aim_no_sliders: aim_no_sliders.strain_peaks.to_vec(),
speed: speed.strain_peaks.to_vec(),
flashlight: flashlight.strain_peaks.to_vec(),
}
}
}
/// The result of calculating the strains on a osu! map.
/// Suitable to plot the difficulty of a map over time.
#[derive(Clone, Debug)]
pub struct OsuStrains {
/// Time in ms inbetween two strains.
pub section_len: f64, // TODO: remove field, make it a method
/// Strain peaks of the aim skill.
pub aim: Vec<f64>,
/// Strain peaks of the aim skill without sliders.
pub aim_no_sliders: Vec<f64>,
/// Strain peaks of the speed skill.
pub speed: Vec<f64>,
/// Strain peaks of the flashlight skill.
pub flashlight: Vec<f64>,
}
impl OsuStrains {
/// Returns the number of strain peaks per skill.
#[inline]
#[allow(clippy::len_without_is_empty)]
pub fn len(&self) -> usize {
self.aim.len()
}
}
#[allow(clippy::needless_pass_by_value)]
fn calculate_skills(params: OsuStars<'_>) -> (Skills, OsuDifficultyAttributes) {
let OsuStars {
map,
mods,
passed_objects,
clock_rate,
} = params;
let take = passed_objects.unwrap_or(map.hit_objects.len());
let clock_rate = clock_rate.unwrap_or_else(|| mods.clock_rate());
let map_attrs = map.attributes().mods(mods).clock_rate(clock_rate).build();
let scaling_factor = ScalingFactor::new(map_attrs.cs);
let hr = mods.hr();
let hit_window = 2.0 * map_attrs.hit_windows.od;
let time_preempt = f64::from((map_attrs.hit_windows.ar * clock_rate) as f32);
// * Preempt time can go below 450ms. Normally, this is achieved via the DT mod
// * which uniformly speeds up all animations game wide regardless of AR.
// * This uniform speedup is hard to match 1:1, however we can at least make
// * AR>10 (via mods) feel good by extending the upper linear function above.
// * Note that this doesn't exactly match the AR>10 visuals as they're
// * classically known, but it feels good.
// * This adjustment is necessary for AR>10, otherwise TimePreempt can
// * become smaller leading to hitcircles not fully fading in.
let time_fade_in = if mods.hd() {
time_preempt * FADE_IN_DURATION_MULTIPLIER
} else {
400.0 * (time_preempt / PREEMPT_MIN).min(1.0)
};
let mut attrs = OsuDifficultyAttributes {
ar: map_attrs.ar,
hp: map_attrs.hp,
od: map_attrs.od,
..Default::default()
};
let mut hit_objects =
create_osu_objects(map, &mut attrs, &scaling_factor, take, hr, time_preempt);
let mut hit_objects_iter = hit_objects.iter_mut().map(Pin::new);
let mut skills = Skills::new(
mods,
scaling_factor.radius,
time_preempt,
time_fade_in,
hit_window,
);
let Some(mut last) = hit_objects_iter.next() else {
return (skills, attrs);
};
let mut last_last = None;
// Prepare `lazy_travel_dist` and `lazy_end_pos` for `last` manually
let last_pos = last.pos();
let last_stack_offset = last.stack_offset;
if let OsuObjectKind::Slider(ref mut slider) = last.kind {
Distances::compute_slider_travel_dist(last_pos, last_stack_offset, slider, &scaling_factor);
}
let mut last = last.into_ref();
let mut diff_objects = Vec::with_capacity(hit_objects_iter.len());
for (i, mut curr) in hit_objects_iter.enumerate() {
let delta_time = (curr.start_time - last.start_time) / clock_rate;
// * Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
let strain_time = delta_time.max(f64::from(OsuDifficultyObject::MIN_DELTA_TIME));
let dists = Distances::new(
&mut curr,
last.get_ref(),
last_last.map(Pin::get_ref),
clock_rate,
strain_time,
&scaling_factor,
);
let curr = curr.into_ref();
let diff_obj = OsuDifficultyObject::new(curr, last.get_ref(), clock_rate, i, dists);
diff_objects.push(diff_obj);
last_last = Some(last);
last = curr;
}
for curr in diff_objects.iter() {
skills.process(curr, &diff_objects);
}
(skills, attrs)
}
pub(crate) fn create_osu_objects(
map: &Beatmap,
attrs: &mut OsuDifficultyAttributes,
scaling_factor: &ScalingFactor,
take: usize,
hr: bool,
time_preempt: f64,
) -> Vec<OsuObject> {
let mut params = ObjectParameters {
map,
attrs,
ticks: Vec::new(),
curve_bufs: CurveBuffers::default(),
};
let mut hit_objects = Vec::with_capacity(map.hit_objects.len());
let mut iter = map.hit_objects.iter();
let hit_object_iter = iter
.by_ref()
.take(take)
.map(|h| OsuObject::new(h, &mut params));
hit_objects.extend(hit_object_iter);
// remaining objects unfortunately still need to be processed since they might influence stacking
if take < map.hit_objects.len() {
let ObjectParameters {
map,
attrs,
ticks,
curve_bufs,
} = params;
// use a new instance for attributes so that max combo & co remain correct in the original
let mut throw_away_attrs = attrs.clone();
let mut params = ObjectParameters {
map,
attrs: &mut throw_away_attrs,
ticks,
curve_bufs,
};
hit_objects.extend(iter.map(|h| OsuObject::new(h, &mut params)));
}
let stack_threshold = time_preempt * f64::from(map.stack_leniency);
if map.version >= 6 {
stacking(&mut hit_objects, stack_threshold);
} else {
old_stacking(&mut hit_objects, stack_threshold);
}
hit_objects.truncate(take);
hit_objects
.iter_mut()
.for_each(|h| h.post_process(hr, scaling_factor));
hit_objects
}
fn stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
let mut extended_start_idx = 0;
let Some(extended_end_idx) = hit_objects.len().checked_sub(1) else {
return;
};
// First big `if` in osu!lazer's function can be skipped
for i in (1..=extended_end_idx).rev() {
let mut n = i;
let mut obj_i_idx = i;
// * We should check every note which has not yet got a stack.
// * Consider the case we have two interwound stacks and this will make sense.
// * o <-1 o <-2
// * o <-3 o <-4
// * We first process starting from 4 and handle 2,
// * then we come backwards on the i loop iteration until we reach 3 and handle 1.
// * 2 and 1 will be ignored in the i loop because they already have a stack value.
if hit_objects[obj_i_idx].stack_height.abs() > 0.0 || hit_objects[obj_i_idx].is_spinner() {
continue;
}
// * If this object is a hitcircle, then we enter this "special" case.
// * It either ends with a stack of hitcircles only,
// * or a stack of hitcircles that are underneath a slider.
// * Any other case is handled by the "is_slider" code below this.
if hit_objects[obj_i_idx].is_circle() {
loop {
n = match n.checked_sub(1) {
Some(n) => n,
None => break,
};
if hit_objects[n].is_spinner() {
continue;
}
if hit_objects[obj_i_idx].start_time - hit_objects[n].end_time() > stack_threshold {
break; // * We are no longer within stacking range of the previous object.
}
// * HitObjects before the specified update range haven't been reset yet
if n < extended_start_idx {
hit_objects[n].stack_height = 0.0;
extended_start_idx = n;
}
// * This is a special case where hticircles are moved DOWN and RIGHT (negative stacking)
// * if they are under the *last* slider in a stacked pattern.
// * o==o <- slider is at original location
// * o <- hitCircle has stack of -1
// * o <- hitCircle has stack of -2
if hit_objects[n].is_slider()
&& hit_objects[n]
.pre_stacked_end_pos()
.distance(hit_objects[obj_i_idx].pos())
< STACK_DISTANCE
{
let offset =
hit_objects[obj_i_idx].stack_height - hit_objects[n].stack_height + 1.0;
for j in n + 1..=i {
// * For each object which was declared under this slider, we will offset
// * it to appear *below* the slider end (rather than above).
if hit_objects[n]
.pre_stacked_end_pos()
.distance(hit_objects[j].pos())
< STACK_DISTANCE
{
hit_objects[j].stack_height -= offset;
}
}
// * We have hit a slider. We should restart calculation using this as the new base.
// * Breaking here will mean that the slider still has StackCount of 0,
// * so will be handled in the i-outer-loop.
break;
}
if hit_objects[n].pos().distance(hit_objects[obj_i_idx].pos()) < STACK_DISTANCE {
// * Keep processing as if there are no sliders.
// * If we come across a slider, this gets cancelled out.
// * NOTE: Sliders with start positions stacking
// * are a special case that is also handled here.
hit_objects[n].stack_height = hit_objects[obj_i_idx].stack_height + 1.0;
obj_i_idx = n;
}
}
} else if hit_objects[obj_i_idx].is_slider() {
// * We have hit the first slider in a possible stack.
// * From this point on, we ALWAYS stack positive regardless.
loop {
n = match n.checked_sub(1) {
Some(n) => n,
None => break,
};
if hit_objects[n].is_spinner() {
continue;
}
if hit_objects[obj_i_idx].start_time - hit_objects[n].start_time > stack_threshold {
break; // * We are no longer within stacking range of the previous object.
}
if hit_objects[n]
.pre_stacked_end_pos()
.distance(hit_objects[obj_i_idx].pos())
< STACK_DISTANCE
{
hit_objects[n].stack_height = hit_objects[obj_i_idx].stack_height + 1.0;
obj_i_idx = n;
}
}
}
}
}
fn old_stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
for i in 0..hit_objects.len() {
if hit_objects[i].stack_height != 0.0 && !hit_objects[i].is_slider() {
continue;
}
let mut start_time = hit_objects[i].end_time();
let pos2 = hit_objects[i].old_stacking_pos2();
let mut slider_stack = 0.0;
for j in i + 1..hit_objects.len() {
if hit_objects[j].start_time - stack_threshold > start_time {
break;
}
if hit_objects[j].pos().distance(hit_objects[i].pos()) < STACK_DISTANCE {
hit_objects[i].stack_height += 1.0;
start_time = hit_objects[j].end_time();
} else if hit_objects[j].pos().distance(pos2) < STACK_DISTANCE {
slider_stack += 1.0;
hit_objects[j].stack_height -= slider_stack;
start_time = hit_objects[j].end_time();
}
}
}
}
/// The result of a difficulty calculation on an osu!standard map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct OsuDifficultyAttributes {
/// The aim portion of the total strain.
pub aim: f64,
/// The speed portion of the total strain.
pub speed: f64,
/// The flashlight portion of the total strain.
pub flashlight: f64,
/// The ratio of the aim strain with and without considering sliders
pub slider_factor: f64,
/// The number of clickable objects weighted by difficulty.
pub speed_note_count: f64,
/// The approach rate.
pub ar: f64,
/// The overall difficulty
pub od: f64,
/// The health drain rate.
pub hp: f64,
/// The amount of circles.
pub n_circles: usize,
/// The amount of sliders.
pub n_sliders: usize,
/// The amount of spinners.
pub n_spinners: usize,
/// The final star rating
pub stars: f64,
/// The maximum combo.
pub max_combo: usize,
}
impl OsuDifficultyAttributes {
/// Return the maximum combo.
#[inline]
pub const fn max_combo(&self) -> usize {
self.max_combo
}
/// Return the amount of hitobjects.
#[inline]
pub const fn n_objects(&self) -> usize {
self.n_circles + self.n_sliders + self.n_spinners
}
/// Returns a builder for performance calculation.
#[inline]
pub fn pp(self) -> OsuPP<'static> {
OsuPP::from(self)
}
}
/// The result of a performance calculation on an osu!standard map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct OsuPerformanceAttributes {
/// The difficulty attributes that were used for the performance calculation
pub difficulty: OsuDifficultyAttributes,
/// The final performance points.
pub pp: f64,
/// The accuracy portion of the final pp.
pub pp_acc: f64,
/// The aim portion of the final pp.
pub pp_aim: f64,
/// The flashlight portion of the final pp.
pub pp_flashlight: f64,
/// The speed portion of the final pp.
pub pp_speed: f64,
/// Misses including an approximated amount of slider breaks
pub effective_miss_count: f64,
}
impl OsuPerformanceAttributes {
/// Return the star value.
#[inline]
pub const fn stars(&self) -> f64 {
self.difficulty.stars
}
/// Return the performance point value.
#[inline]
pub const fn pp(&self) -> f64 {
self.pp
}
/// Return the maximum combo of the map.
#[inline]
pub const fn max_combo(&self) -> usize {
self.difficulty.max_combo
}
/// Return the amount of hitobjects.
#[inline]
pub const fn n_objects(&self) -> usize {
self.difficulty.n_objects()
}
}
impl From<OsuPerformanceAttributes> for OsuDifficultyAttributes {
#[inline]
fn from(attributes: OsuPerformanceAttributes) -> Self {
attributes.difficulty
fn strains(difficulty: &ModeDifficulty, converted: &OsuBeatmap<'_>) -> Self::Strains {
strains::strains(difficulty, converted)
}
}
+300
View File
@@ -0,0 +1,300 @@
use rosu_map::{
section::hit_objects::{
BorrowedCurve, CurveBuffers, SliderEvent, SliderEventType, SliderEventsIter,
},
util::Pos,
};
use crate::model::{
control_point::{DifficultyPoint, TimingPoint},
hit_object::{HitObject, HitObjectKind, HoldNote, Slider, Spinner},
};
use super::{convert::OsuBeatmap, PLAYFIELD_BASE_SIZE};
pub struct OsuObject {
pub pos: Pos,
pub start_time: f64,
pub stack_height: i32,
pub stack_offset: Pos,
pub kind: OsuObjectKind,
}
impl OsuObject {
pub const OBJECT_RADIUS: f32 = 64.0;
pub const PREEMPT_MIN: f64 = 450.0;
const BASE_SCORING_DIST: f32 = 100.0;
pub fn new(
h: &HitObject,
converted: &OsuBeatmap<'_>,
curve_bufs: &mut CurveBuffers,
ticks_buf: &mut Vec<SliderEvent>,
) -> Self {
let kind = match h.kind {
HitObjectKind::Circle => OsuObjectKind::Circle,
HitObjectKind::Slider(ref slider) => {
OsuObjectKind::Slider(OsuSlider::new(h, slider, converted, curve_bufs, ticks_buf))
}
HitObjectKind::Spinner(spinner) => OsuObjectKind::Spinner(spinner),
HitObjectKind::Hold(HoldNote { end_time }) => {
OsuObjectKind::Spinner(Spinner { end_time })
}
};
Self {
pos: h.pos,
start_time: h.start_time,
stack_height: 0,
stack_offset: Pos::default(),
kind,
}
}
pub fn reflect_vertically(&mut self) {
fn reflect_y(y: &mut f32) {
*y = PLAYFIELD_BASE_SIZE.y - *y;
}
reflect_y(&mut self.pos.y);
if let OsuObjectKind::Slider(ref mut slider) = self.kind {
// Requires `stack_offset` so we can't add `h.pos` just yet
slider.lazy_end_pos.y = -slider.lazy_end_pos.y;
let mut nested_iter = slider.nested_objects.iter_mut();
// The tail is adjusted differently than ticks and repeats
if let Some(tail) = nested_iter.next_back() {
let mut tail_pos = self.pos; // already reflected at this point
tail_pos += Pos::new(tail.pos.x, -tail.pos.y);
tail.pos = tail_pos;
for nested in nested_iter {
reflect_y(&mut nested.pos.y);
}
}
}
}
pub fn finalize_tail(&mut self) {
if let OsuObjectKind::Slider(ref mut slider) = self.kind {
if let Some(tail) = slider.nested_objects.last_mut() {
tail.pos += self.pos;
}
}
}
pub const fn end_time(&self) -> f64 {
match self.kind {
OsuObjectKind::Circle => self.start_time,
OsuObjectKind::Slider(ref slider) => slider.end_time,
OsuObjectKind::Spinner(ref spinner) => spinner.end_time,
}
}
pub fn stacked_pos(&self) -> Pos {
self.pos + self.stack_offset
}
pub fn end_pos(&self) -> Pos {
match self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner(_) => self.pos,
OsuObjectKind::Slider(ref slider) => {
slider.nested_objects[slider.nested_objects.len() - 1].pos
}
}
}
pub fn stacked_end_pos(&self) -> Pos {
self.end_pos() + self.stack_offset
}
pub fn lazy_travel_time(&self) -> f64 {
match self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner(_) => 0.0,
OsuObjectKind::Slider(ref slider) => {
slider
.nested_objects
.last()
.map_or(0.0, |nested| nested.start_time)
- self.start_time
}
}
}
pub const fn is_circle(&self) -> bool {
matches!(self.kind, OsuObjectKind::Circle)
}
pub const fn is_slider(&self) -> bool {
matches!(self.kind, OsuObjectKind::Slider { .. })
}
pub const fn is_spinner(&self) -> bool {
matches!(self.kind, OsuObjectKind::Spinner(_))
}
}
pub enum OsuObjectKind {
Circle,
Slider(OsuSlider),
Spinner(Spinner),
}
pub struct OsuSlider {
pub end_time: f64,
pub lazy_end_pos: Pos,
pub lazy_travel_dist: f32,
pub nested_objects: Vec<NestedSliderObject>,
}
impl OsuSlider {
fn new(
h: &HitObject,
slider: &Slider,
converted: &OsuBeatmap<'_>,
curve_bufs: &mut CurveBuffers,
ticks_buf: &mut Vec<SliderEvent>,
) -> Self {
let start_time = h.start_time;
let slider_multiplier = converted.map.slider_multiplier;
let slider_tick_rate = converted.map.slider_tick_rate;
let beat_len = converted
.map
.timing_point_at(start_time)
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len);
let (slider_velocity, generate_ticks) =
converted.map.difficulty_point_at(start_time).map_or(
(
DifficultyPoint::DEFAULT_SLIDER_VELOCITY,
DifficultyPoint::DEFAULT_GENERATE_TICKS,
),
|point| (point.slider_velocity, point.generate_ticks),
);
let path = BorrowedCurve::new(&slider.control_points, slider.expected_dist, curve_bufs);
let span_count = slider.span_count() as f64;
let scoring_dist =
f64::from(OsuObject::BASE_SCORING_DIST) * slider_multiplier * slider_velocity;
let velocity = scoring_dist / beat_len;
let end_time = start_time + span_count * path.dist() / velocity;
let duration = end_time - start_time;
let span_duration = duration / span_count;
let tick_dist_multiplier = if converted.map.version < 8 {
slider_velocity.recip()
} else {
1.0
};
let tick_dist = if generate_ticks {
scoring_dist / slider_tick_rate * tick_dist_multiplier
} else {
f64::INFINITY
};
let events = SliderEventsIter::new(
start_time,
span_duration,
velocity,
tick_dist,
path.dist(),
slider.span_count() as i32,
ticks_buf,
);
let span_at = |progress: f64| (progress * span_count) as i32;
let obj_progress_at = |progress: f64| {
let p = progress * span_count % 1.0;
if span_at(progress) % 2 == 1 {
1.0 - p
} else {
p
}
};
let end_path_pos = path.position_at(obj_progress_at(1.0));
let nested_objects: Vec<_> = events
.filter_map(|e| {
let obj = match e.kind {
SliderEventType::Tick => NestedSliderObject {
pos: h.pos + path.position_at(e.path_progress),
start_time: e.time,
kind: NestedSliderObjectKind::Tick,
},
SliderEventType::Repeat => NestedSliderObject {
pos: h.pos + path.position_at(e.path_progress),
start_time: start_time + f64::from(e.span_idx + 1) * span_duration,
kind: NestedSliderObjectKind::Repeat,
},
SliderEventType::LastTick => NestedSliderObject {
pos: end_path_pos, // no `h.pos` yet to keep order of float operations
start_time: e.time,
kind: NestedSliderObjectKind::Tail,
},
SliderEventType::Head | SliderEventType::Tail => return None,
};
Some(obj)
})
.collect();
let lazy_travel_time = nested_objects
.last()
.map_or(0.0, |nested| nested.start_time - h.start_time);
let mut end_time_min = lazy_travel_time / span_duration;
if end_time_min % 2.0 >= 1.0 {
end_time_min = 1.0 - end_time_min % 1.0;
} else {
end_time_min %= 1.0;
}
let lazy_end_pos = path.position_at(end_time_min);
Self {
end_time,
lazy_end_pos,
lazy_travel_dist: 0.0,
nested_objects,
}
}
pub fn repeat_count(&self) -> usize {
self.nested_objects
.iter()
.filter(|nested| matches!(nested.kind, NestedSliderObjectKind::Repeat))
.count()
}
}
pub struct NestedSliderObject {
pub pos: Pos,
pub start_time: f64,
pub kind: NestedSliderObjectKind,
}
impl NestedSliderObject {
pub const fn is_repeat(&self) -> bool {
matches!(self.kind, NestedSliderObjectKind::Repeat)
}
}
#[derive(Debug)]
pub enum NestedSliderObjectKind {
Repeat,
Tail,
Tick,
}
-509
View File
@@ -1,509 +0,0 @@
use super::{scaling_factor::ScalingFactor, OsuDifficultyAttributes, PLAYFIELD_BASE_SIZE};
use crate::{
curve::{Curve, CurveBuffers},
parse::{HitObject, HitObjectKind, Pos2},
Beatmap,
};
const LEGACY_LAST_TICK_OFFSET: f64 = 36.0;
const BASE_SCORING_DISTANCE: f64 = 100.0;
/// A [`HitObject`] that was processed for the osu! gamemode.
#[derive(Clone, Debug, PartialEq)]
pub struct OsuObject {
/// Position of the object.
///
/// Note that stacking is **not** applied, use the [`stacked_pos`](OsuObject::stacked_pos) method instead.
pub pos: Pos2,
/// Start time of the object.
pub start_time: f64,
/// The positional offset due to stacking.
pub stack_offset: Pos2,
/// The height of the stacking offset.
pub stack_height: f32,
/// Type of the object.
pub kind: OsuObjectKind,
}
/// The type of an [`OsuObject`].
#[derive(Clone, Debug, PartialEq)]
pub enum OsuObjectKind {
/// A hitcircle object.
Circle,
/// A slider object.
Slider(OsuSlider),
/// A spinner object.
Spinner {
/// The endtime of the spinner.
end_time: f64,
},
}
/// A [`HitObject`] that was processed as a slider for the osu! gamemode.
#[derive(Clone, Debug, PartialEq)]
pub struct OsuSlider {
/// The endtime of the slider.
pub end_time: f64,
/// The lazy end position of the cursor.
pub lazy_end_pos: Pos2,
/// All nested objects of the slider except for the slider head.
pub nested_objects: Vec<NestedObject>,
}
impl OsuSlider {
/// The amount of repeat points.
pub fn repeat_count(&self) -> usize {
self.nested_objects.iter().fold(0, |count, nested| {
count + usize::from(matches!(nested.kind, NestedObjectKind::Repeat))
})
}
/// The position of the slider tail.
///
/// This is usually but not necessarily the position of the last nested object.
pub fn end_pos(&self) -> Option<Pos2> {
self.tail().map(|tail| tail.pos)
}
/// A shared reference to the slider tail.
///
/// This is usually but not necessarily the last nested object.
pub fn tail(&self) -> Option<&NestedObject> {
self.nested_objects
.iter()
.rev()
.find(|nested| matches!(nested.kind, NestedObjectKind::Tail))
}
/// An exclusive reference to the slider tail and its index in the nested object list.
///
/// This is usually but not necessarily the last nested object.
pub fn tail_mut(&mut self) -> Option<(usize, &mut NestedObject)> {
self.nested_objects
.iter_mut()
.enumerate()
.rev()
.find(|(_, nested)| matches!(nested.kind, NestedObjectKind::Tail))
}
}
/// A nested object within a slider.
#[derive(Clone, Debug, PartialEq)]
pub struct NestedObject {
/// Position of the object.
///
/// Note: `pos` does not include stacking.
pub pos: Pos2,
/// Start time of the object.
pub start_time: f64,
/// Type of the object.
pub kind: NestedObjectKind,
}
/// Type of a [`NestedObject`].
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum NestedObjectKind {
/// A repeat point.
Repeat,
/// The logical slider tail i.e. the legacy last tick.
Tail,
/// A regular slider tick.
Tick,
}
pub(crate) struct ObjectParameters<'a> {
pub(crate) map: &'a Beatmap,
pub(crate) attrs: &'a mut OsuDifficultyAttributes,
pub(crate) ticks: Vec<(Pos2, f64)>,
pub(crate) curve_bufs: CurveBuffers,
}
impl OsuObject {
#[allow(clippy::too_many_lines)]
pub(crate) fn new(h: &HitObject, params: &mut ObjectParameters<'_>) -> Self {
let ObjectParameters {
map,
attrs,
ticks,
curve_bufs,
} = params;
attrs.max_combo += 1; // hitcircle, slider head, or spinner
let pos = h.pos;
match &h.kind {
HitObjectKind::Circle => {
attrs.n_circles += 1;
Self {
start_time: h.start_time,
pos,
stack_offset: Pos2::default(),
stack_height: 0.0,
kind: OsuObjectKind::Circle,
}
}
HitObjectKind::Slider {
pixel_len,
repeats,
control_points,
..
} => {
attrs.n_sliders += 1;
let timing_point = map.timing_point_at(h.start_time);
let difficulty_point = map.difficulty_point_at(h.start_time).unwrap_or_default();
let scoring_dist =
BASE_SCORING_DISTANCE * map.slider_mult * difficulty_point.slider_vel;
let vel = scoring_dist / timing_point.beat_len;
// * prior to v8, speed multipliers don't adjust for how many ticks are generated over the same distance.
// * this results in more (or less) ticks being generated in <v8 maps for the same time duration.
let tick_dist_mult = if map.version < 8 {
difficulty_point.slider_vel.recip()
} else {
1.0
};
let mut tick_dist = if difficulty_point.generate_ticks {
scoring_dist / map.tick_rate * tick_dist_mult
} else {
f64::INFINITY
};
let span_count = (*repeats + 1) as f64;
// Build the curve w.r.t. the control points
let curve = Curve::new(control_points, *pixel_len, curve_bufs);
let end_time = h.start_time + span_count * curve.dist() / vel;
let total_duration = end_time - h.start_time;
let span_duration = total_duration / span_count;
// * A very lenient maximum length of a slider for ticks to be generated.
// * This exists for edge cases such as /b/1573664 where the beatmap has
// * been edited by the user, and should never be reached in normal usage.
let max_len = 100_000.0;
let len = curve.dist().min(max_len);
tick_dist = tick_dist.clamp(0.0, len);
let min_dist_from_end = vel * 10.0;
let mut curr_dist = tick_dist;
ticks.clear();
#[allow(clippy::if_not_else)]
let mut nested_objects = if tick_dist != 0.0 {
ticks.reserve((len / tick_dist) as usize);
let mut nested_objects =
Vec::with_capacity((len * span_count / tick_dist) as usize);
// Ticks of the first span
while curr_dist < len - min_dist_from_end {
let progress = curr_dist / len;
let curr_time = h.start_time + progress * span_duration;
let curr_pos = h.pos + curve.position_at(progress);
let tick = NestedObject {
pos: curr_pos,
start_time: curr_time,
kind: NestedObjectKind::Tick,
};
nested_objects.push(tick);
ticks.push((curr_pos, curr_time));
curr_dist += tick_dist;
}
// Other spans
for span_idx in 1..=*repeats {
let progress = f64::from(u8::from(span_idx % 2 == 1));
let span_idx_f64 = span_idx as f64;
// Repeat point
let curr_time = h.start_time + span_duration * span_idx_f64;
let curr_pos = h.pos + curve.position_at(progress);
let repeat = NestedObject {
pos: curr_pos,
start_time: curr_time,
kind: NestedObjectKind::Repeat,
};
nested_objects.push(repeat);
let span_offset = span_idx_f64 * span_duration;
// Ticks
if span_idx & 1 == 1 {
let base = h.start_time + h.start_time + span_duration;
let tick_iter = ticks.iter().rev().map(|(pos, time)| NestedObject {
pos: *pos,
start_time: span_offset + base - time,
kind: NestedObjectKind::Tick,
});
nested_objects.extend(tick_iter);
} else {
let tick_iter = ticks.iter().map(|(pos, time)| NestedObject {
pos: *pos,
start_time: time + span_offset,
kind: NestedObjectKind::Tick,
});
nested_objects.extend(tick_iter);
}
}
nested_objects
} else {
Vec::new()
};
// Slider tail
let final_span_start_time = h.start_time + *repeats as f64 * span_duration;
let final_span_end_time = (h.start_time + total_duration / 2.0)
.max(final_span_start_time + span_duration - LEGACY_LAST_TICK_OFFSET);
let progress = f64::from(u8::from(*repeats % 2 == 0));
let end_pos = curve.position_at(progress);
// * we need to use the LegacyLastTick here for compatibility reasons (difficulty).
// * it is *okay* to use this because the TailCircle is not used for any meaningful purpose in gameplay.
// * if this is to change, we should revisit this.
let legacy_last_tick = NestedObject {
pos: end_pos,
start_time: final_span_end_time,
kind: NestedObjectKind::Tail,
};
// On very short buzz sliders it can happen that the
// legacy last tick is not the last object time-wise
match nested_objects.last() {
Some(last) if last.start_time > final_span_end_time => {
let idx = nested_objects
.iter()
.rev()
.position(|nested| nested.start_time <= final_span_end_time)
.map_or(0, |i| nested_objects.len() - i);
nested_objects.insert(idx, legacy_last_tick);
}
_ => nested_objects.push(legacy_last_tick),
};
attrs.max_combo += nested_objects.len();
let last_time = nested_objects
.last()
.map_or(final_span_end_time, |nested| nested.start_time);
let lazy_travel_time = last_time - h.start_time;
let mut end_time_min = lazy_travel_time / span_duration;
if end_time_min % 2.0 >= 1.0 {
end_time_min = 1.0 - end_time_min % 1.0;
} else {
end_time_min %= 1.0;
}
// * temporary lazy end position until a real result can be derived.
// The position is added after the stacking for the correct order of
// floating point operations.
let lazy_end_pos = curve.position_at(end_time_min);
let slider = OsuSlider {
end_time,
lazy_end_pos,
nested_objects,
};
Self {
start_time: h.start_time,
pos,
stack_offset: Pos2::default(),
stack_height: 0.0,
kind: OsuObjectKind::Slider(slider),
}
}
HitObjectKind::Spinner { end_time } | HitObjectKind::Hold { end_time } => {
attrs.n_spinners += 1;
Self {
start_time: h.start_time,
pos,
stack_offset: Pos2::default(),
stack_height: 0.0,
kind: OsuObjectKind::Spinner {
end_time: *end_time,
},
}
}
}
}
/// Endtime of the object.
pub const fn end_time(&self) -> f64 {
match &self.kind {
OsuObjectKind::Circle => self.start_time,
OsuObjectKind::Slider(slider) => slider.end_time,
OsuObjectKind::Spinner { end_time } => *end_time,
}
}
/// End position of the object.
pub fn end_pos(&self) -> Pos2 {
match &self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => self.pos,
OsuObjectKind::Slider(slider) => slider.end_pos().unwrap_or(self.pos),
}
}
pub(crate) fn pre_stacked_end_pos(&self) -> Pos2 {
match &self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => self.pos,
OsuObjectKind::Slider(slider) => slider
.end_pos()
.map_or(self.pos, |end_pos| self.pos + end_pos),
}
}
pub(crate) fn old_stacking_pos2(&self) -> Pos2 {
match &self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => self.pos,
OsuObjectKind::Slider(slider) => {
// Old stacking requires the path end position
// instead of slider end position
let repeat_count = slider.repeat_count();
if repeat_count % 2 == 0 {
slider
.end_pos()
.map_or(self.pos, |end_pos| self.pos + end_pos)
} else {
slider
.nested_objects
.iter()
.find(|nested| matches!(nested.kind, NestedObjectKind::Repeat))
.map_or(self.pos, |repeat| repeat.pos)
}
}
}
}
/// Position of the object without stacking.
pub const fn pos(&self) -> Pos2 {
self.pos
}
/// Stacked position of the object.
pub fn stacked_pos(&self) -> Pos2 {
self.pos + self.stack_offset
}
/// Stacked end position of the object.
pub fn stacked_end_pos(&self) -> Pos2 {
self.end_pos() + self.stack_offset
}
/// Lazy end position of the object. Stacking is included.
pub fn lazy_end_pos(&self) -> Pos2 {
match &self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => self.stacked_pos(),
OsuObjectKind::Slider(slider) => slider.lazy_end_pos,
}
}
/// Lazy travel time of the object.
pub fn lazy_travel_time(&self) -> f64 {
match &self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => 0.0,
OsuObjectKind::Slider(slider) => slider
.nested_objects
.last()
.map_or(0.0, |nested| nested.start_time - self.start_time),
}
}
/// Whether the object is a hitcircle.
pub const fn is_circle(&self) -> bool {
matches!(self.kind, OsuObjectKind::Circle)
}
/// Whether the object is a slider.
pub const fn is_slider(&self) -> bool {
matches!(self.kind, OsuObjectKind::Slider { .. })
}
/// Whether the object is a spinner.
pub const fn is_spinner(&self) -> bool {
matches!(self.kind, OsuObjectKind::Spinner { .. })
}
/// Applies stack offset, flips playfield for HR,
/// and adjusts slider tails and lazy end positions.
pub(crate) fn post_process(&mut self, hr: bool, scaling_factor: &ScalingFactor) {
self.stack_offset = scaling_factor.stack_offset(self.stack_height);
let pos = self.pos();
if let OsuObjectKind::Slider(slider) = &mut self.kind {
if hr {
let mut lazy_end_pos = pos;
lazy_end_pos.y = PLAYFIELD_BASE_SIZE.y - lazy_end_pos.y;
lazy_end_pos += self.stack_offset;
lazy_end_pos += Pos2 {
x: slider.lazy_end_pos.x,
y: -slider.lazy_end_pos.y,
};
slider.lazy_end_pos = lazy_end_pos;
let tail_idx = slider.tail_mut().map(|(tail_idx, tail)| {
let mut tail_pos = pos;
tail_pos.y = PLAYFIELD_BASE_SIZE.y - tail_pos.y;
tail_pos += Pos2 {
x: tail.pos.x,
y: -tail.pos.y,
};
tail.pos = tail_pos;
tail_idx
});
if let Some(tail_idx) = tail_idx {
for nested in slider.nested_objects[..tail_idx].iter_mut() {
nested.pos.y = PLAYFIELD_BASE_SIZE.y - nested.pos.y;
}
for nested in slider.nested_objects[tail_idx + 1..].iter_mut() {
nested.pos.y = PLAYFIELD_BASE_SIZE.y - nested.pos.y;
}
} else {
// Should never happen since sliders are bound to have a tail
for nested in slider.nested_objects.iter_mut() {
nested.pos.y = PLAYFIELD_BASE_SIZE.y - nested.pos.y;
}
}
} else {
slider.lazy_end_pos += pos + self.stack_offset;
if let Some((_, tail)) = slider.tail_mut() {
tail.pos += pos;
}
}
}
if hr {
self.pos.y = PLAYFIELD_BASE_SIZE.y - pos.y;
}
}
}
+213 -504
View File
@@ -1,101 +1,102 @@
use super::{
OsuDifficultyAttributes, OsuPerformanceAttributes, OsuScoreState, PERFORMANCE_BASE_MULTIPLIER,
};
use std::cmp;
use rosu_map::section::general::GameMode;
use crate::{
util::{MapOrElse, MapRef},
AnyPP, Beatmap, CatchPP, DifficultyAttributes, GameMode, HitResultPriority, ManiaPP, Mods,
OsuStars, PerformanceAttributes, TaikoPP,
any::ModeDifficulty,
any::{HitResultPriority, ModeAttributeProvider, Performance},
catch::CatchPerformance,
mania::ManiaPerformance,
taiko::TaikoPerformance,
util::{float_ext::FloatExt, map_or_attrs::MapOrAttrs, mods::Mods},
};
use super::{
attributes::{OsuDifficultyAttributes, OsuPerformanceAttributes},
convert::OsuBeatmap,
score_state::OsuScoreState,
Osu,
};
/// Performance calculator on osu!standard maps.
///
/// # Example
///
/// ```
/// use rosu_pp::{OsuPP, Beatmap};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let pp_result = OsuPP::new(&map)
/// .mods(8 + 64) // HDDT
/// .combo(1234)
/// .accuracy(98.5)
/// .n_misses(1)
/// .calculate();
///
/// println!("PP: {} | Stars: {}", pp_result.pp(), pp_result.stars());
///
/// let next_result = OsuPP::new(&map)
/// .attributes(pp_result) // reusing previous results for performance
/// .mods(8 + 64) // has to be the same to reuse attributes
/// .accuracy(99.5)
/// .calculate();
///
/// println!("PP: {} | Stars: {}", next_result.pp(), next_result.stars());
/// ```
#[derive(Clone, Debug)]
#[allow(clippy::upper_case_acronyms)]
#[derive(Clone, Debug, PartialEq)]
#[must_use]
pub struct OsuPP<'map> {
pub(crate) map_or_attrs: MapOrElse<MapRef<'map>, OsuDifficultyAttributes>,
pub struct OsuPerformance<'map> {
pub(crate) map_or_attrs: MapOrAttrs<'map, Osu>,
pub(crate) mods: u32,
pub(crate) acc: Option<f64>,
pub(crate) combo: Option<usize>,
pub(crate) combo: Option<u32>,
pub(crate) n300: Option<usize>,
pub(crate) n100: Option<usize>,
pub(crate) n50: Option<usize>,
pub(crate) n_misses: Option<usize>,
pub(crate) passed_objects: Option<usize>,
pub(crate) n300: Option<u32>,
pub(crate) n100: Option<u32>,
pub(crate) n50: Option<u32>,
pub(crate) n_misses: Option<u32>,
pub(crate) passed_objects: Option<u32>,
pub(crate) clock_rate: Option<f64>,
pub(crate) hitresult_priority: Option<HitResultPriority>,
pub(crate) hitresult_priority: HitResultPriority,
}
impl<'map> OsuPP<'map> {
impl<'map> OsuPerformance<'map> {
/// Create a new performance calculator for osu!standard maps.
#[inline]
pub fn new(map: &'map Beatmap) -> Self {
Self {
map_or_attrs: MapOrElse::from(map),
mods: 0,
acc: None,
combo: None,
pub fn new(map: OsuBeatmap<'map>) -> Self {
map.into()
}
n300: None,
n100: None,
n50: None,
n_misses: None,
passed_objects: None,
clock_rate: None,
hitresult_priority: None,
/// Attempt to convert the map to the specified mode.
///
/// Returns `None` if the internal beatmap was already replaced with
/// [`OsuDifficultyAttributes`], i.e. if [`OsuPerformance::attributes`] or
/// [`OsuPerformance::generate_state`] was called.
///
/// If the given mode should be ignored in case the internal beatmap was
/// replaced, use [`mode_or_ignore`] instead.
///
/// [`mode_or_ignore`]: Self::mode_or_ignore
pub fn try_mode(self, mode: GameMode) -> Option<Performance<'map>> {
match mode {
GameMode::Osu => Some(Performance::Osu(self)),
GameMode::Taiko => TaikoPerformance::try_from(self)
.map(Performance::Taiko)
.ok(),
GameMode::Catch => CatchPerformance::try_from(self)
.map(Performance::Catch)
.ok(),
GameMode::Mania => ManiaPerformance::try_from(self)
.map(Performance::Mania)
.ok(),
}
}
/// Convert the map into another mode.
/// Attempt to convert the map to the specified mode.
///
/// Returns `None` if `self` already replaced it's internal [`Beatmap`]
/// with [`OsuDifficultyAttributes`], i.e. if [`OsuPP::attributes`]
/// or [`OsuPP::generate_state`] was called.
#[inline]
pub fn try_mode(self, mode: GameMode) -> Option<AnyPP<'map>> {
/// If the internal beatmap was already replaced with difficulty
/// attributes, the map won't be modified.
///
/// To see whether the internal beatmap was replaced, use [`try_mode`]
/// instead.
///
/// [`try_mode`]: Self::try_mode
pub fn mode_or_ignore(self, mode: GameMode) -> Performance<'map> {
match mode {
GameMode::Osu => Some(AnyPP::Osu(self)),
GameMode::Taiko => TaikoPP::try_from_osu(self).map(AnyPP::Taiko),
GameMode::Catch => CatchPP::try_from_osu(self).map(AnyPP::Catch),
GameMode::Mania => ManiaPP::try_from_osu(self).map(AnyPP::Mania),
GameMode::Osu => Performance::Osu(self),
GameMode::Taiko => {
TaikoPerformance::try_from(self).map_or_else(Performance::Osu, Performance::Taiko)
}
GameMode::Catch => {
CatchPerformance::try_from(self).map_or_else(Performance::Osu, Performance::Catch)
}
GameMode::Mania => {
ManiaPerformance::try_from(self).map_or_else(Performance::Osu, Performance::Mania)
}
}
}
/// Provide the result of a previous difficulty or performance calculation.
/// If you already calculated the attributes for the current map-mod combination,
/// be sure to put them in here so that they don't have to be recalculated.
#[inline]
pub fn attributes(mut self, attributes: impl OsuAttributeProvider) -> Self {
pub fn attributes(mut self, attributes: impl ModeAttributeProvider<Osu>) -> Self {
if let Some(attrs) = attributes.attributes() {
self.map_or_attrs = MapOrElse::Else(attrs);
self.map_or_attrs = MapOrAttrs::Attrs(attrs);
}
self
@@ -104,7 +105,6 @@ impl<'map> OsuPP<'map> {
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
#[inline]
pub const fn mods(mut self, mods: u32) -> Self {
self.mods = mods;
@@ -112,8 +112,7 @@ impl<'map> OsuPP<'map> {
}
/// Specify the max combo of the play.
#[inline]
pub const fn combo(mut self, combo: usize) -> Self {
pub const fn combo(mut self, combo: u32) -> Self {
self.combo = Some(combo);
self
@@ -122,40 +121,35 @@ impl<'map> OsuPP<'map> {
/// Specify how hitresults should be generated.
///
/// Defauls to [`HitResultPriority::BestCase`].
#[inline]
pub const fn hitresult_priority(mut self, priority: HitResultPriority) -> Self {
self.hitresult_priority = Some(priority);
self.hitresult_priority = priority;
self
}
/// Specify the amount of 300s of a play.
#[inline]
pub const fn n300(mut self, n300: usize) -> Self {
pub const fn n300(mut self, n300: u32) -> Self {
self.n300 = Some(n300);
self
}
/// Specify the amount of 100s of a play.
#[inline]
pub const fn n100(mut self, n100: usize) -> Self {
pub const fn n100(mut self, n100: u32) -> Self {
self.n100 = Some(n100);
self
}
/// Specify the amount of 50s of a play.
#[inline]
pub const fn n50(mut self, n50: usize) -> Self {
pub const fn n50(mut self, n50: u32) -> Self {
self.n50 = Some(n50);
self
}
/// Specify the amount of misses of a play.
#[inline]
pub const fn n_misses(mut self, n_misses: usize) -> Self {
pub const fn n_misses(mut self, n_misses: u32) -> Self {
self.n_misses = Some(n_misses);
self
@@ -169,8 +163,7 @@ impl<'map> OsuPP<'map> {
using [`OsuPP`] multiple times with different `passed_objects`, you should use
[`OsuGradualPerformanceAttributes`](crate::osu::OsuGradualPerformance)."
)]
#[inline]
pub const fn passed_objects(mut self, passed_objects: usize) -> Self {
pub const fn passed_objects(mut self, passed_objects: u32) -> Self {
self.passed_objects = Some(passed_objects);
self
@@ -179,7 +172,6 @@ impl<'map> OsuPP<'map> {
/// Adjust the clock rate used in the calculation.
/// If none is specified, it will take the clock rate based on the mods
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
#[inline]
pub const fn clock_rate(mut self, clock_rate: f64) -> Self {
self.clock_rate = Some(clock_rate);
@@ -187,7 +179,6 @@ impl<'map> OsuPP<'map> {
}
/// Provide parameters through an [`OsuScoreState`].
#[inline]
#[allow(clippy::needless_pass_by_value)]
pub const fn state(mut self, state: OsuScoreState) -> Self {
let OsuScoreState {
@@ -209,7 +200,6 @@ impl<'map> OsuPP<'map> {
/// Specify the accuracy of a play between `0.0` and `100.0`.
/// This will be used to generate matching hitresults.
#[inline]
pub fn accuracy(mut self, acc: f64) -> Self {
self.acc = Some(acc / 100.0);
@@ -220,17 +210,17 @@ impl<'map> OsuPP<'map> {
#[allow(clippy::too_many_lines)]
pub fn generate_state(&mut self) -> OsuScoreState {
let attrs = match self.map_or_attrs {
MapOrElse::Map(ref map) => {
let attrs = self.generate_attributes(map.as_ref());
MapOrAttrs::Map(ref map) => {
let attrs = self.generate_attributes(map);
self.map_or_attrs.else_or_insert(attrs)
self.map_or_attrs.attrs_or_insert(attrs)
}
MapOrElse::Else(ref attrs) => attrs,
MapOrAttrs::Attrs(ref attrs) => attrs,
};
let max_combo = attrs.max_combo;
let n_objects = self.passed_objects.unwrap_or(attrs.n_objects());
let priority = self.hitresult_priority.unwrap_or_default();
let priority = self.hitresult_priority;
let n_misses = self.n_misses.map_or(0, |n| n.min(n_objects));
let n_remaining = n_objects - n_misses;
@@ -240,7 +230,7 @@ impl<'map> OsuPP<'map> {
let mut n50 = self.n50.map_or(0, |n| n.min(n_remaining));
if let Some(acc) = self.acc {
let target_total = acc * (6 * n_objects) as f64;
let target_total = acc * f64::from(6 * n_objects);
match (self.n300, self.n100, self.n50) {
(Some(_), Some(_), Some(_)) => {
@@ -260,9 +250,9 @@ impl<'map> OsuPP<'map> {
n300 = n300.min(n_remaining);
let n_remaining = n_remaining - n300;
let raw_n100 = target_total - (n_remaining + 6 * n300) as f64;
let min_n100 = n_remaining.min(raw_n100.floor() as usize);
let max_n100 = n_remaining.min(raw_n100.ceil() as usize);
let raw_n100 = target_total - f64::from(n_remaining + 6 * n300);
let min_n100 = n_remaining.min(raw_n100.floor() as u32);
let max_n100 = n_remaining.min(raw_n100.ceil() as u32);
for new100 in min_n100..=max_n100 {
let new50 = n_remaining - new100;
@@ -281,9 +271,9 @@ impl<'map> OsuPP<'map> {
n100 = n100.min(n_remaining);
let n_remaining = n_remaining - n100;
let raw_n300 = (target_total - (n_remaining + 2 * n100) as f64) / 5.0;
let min_n300 = n_remaining.min(raw_n300.floor() as usize);
let max_n300 = n_remaining.min(raw_n300.ceil() as usize);
let raw_n300 = (target_total - f64::from(n_remaining + 2 * n100)) / 5.0;
let min_n300 = n_remaining.min(raw_n300.floor() as u32);
let max_n300 = n_remaining.min(raw_n300.ceil() as u32);
for new300 in min_n300..=max_n300 {
let new50 = n_remaining - new300;
@@ -302,11 +292,12 @@ impl<'map> OsuPP<'map> {
n50 = n50.min(n_remaining);
let n_remaining = n_remaining - n50;
let raw_n300 =
(target_total + (2 * n_misses + n50) as f64 - (2 * n_objects) as f64) / 4.0;
let raw_n300 = (target_total + f64::from(2 * n_misses + n50)
- f64::from(2 * n_objects))
/ 4.0;
let min_n300 = n_remaining.min(raw_n300.floor() as usize);
let max_n300 = n_remaining.min(raw_n300.ceil() as usize);
let min_n300 = n_remaining.min(raw_n300.floor() as u32);
let max_n300 = n_remaining.min(raw_n300.ceil() as u32);
for new300 in min_n300..=max_n300 {
let new100 = n_remaining - new300;
@@ -322,14 +313,14 @@ impl<'map> OsuPP<'map> {
(None, None, None) => {
let mut best_dist = f64::MAX;
let raw_n300 = (target_total - n_remaining as f64) / 5.0;
let min_n300 = n_remaining.min(raw_n300.floor() as usize);
let max_n300 = n_remaining.min(raw_n300.ceil() as usize);
let raw_n300 = (target_total - f64::from(n_remaining)) / 5.0;
let min_n300 = cmp::min(n_remaining, raw_n300.floor() as u32);
let max_n300 = cmp::min(n_remaining, raw_n300.ceil() as u32);
for new300 in min_n300..=max_n300 {
let raw_n100 = target_total - (n_remaining + 5 * new300) as f64;
let min_n100 = (raw_n100.floor() as usize).min(n_remaining - new300);
let max_n100 = (raw_n100.ceil() as usize).min(n_remaining - new300);
let raw_n100 = target_total - f64::from(n_remaining + 5 * new300);
let min_n100 = cmp::min(raw_n100.floor() as u32, n_remaining - new300);
let max_n100 = cmp::min(raw_n100.ceil() as u32, n_remaining - new300);
for new100 in min_n100..=max_n100 {
let new50 = n_remaining - new300 - new100;
@@ -401,13 +392,13 @@ impl<'map> OsuPP<'map> {
let state = self.generate_state();
let attrs = match self.map_or_attrs {
MapOrElse::Map(ref map) => self.generate_attributes(map.as_ref()),
MapOrElse::Else(attrs) => attrs,
MapOrAttrs::Map(ref map) => self.generate_attributes(map),
MapOrAttrs::Attrs(attrs) => attrs,
};
let effective_miss_count = calculate_effective_misses(&attrs, &state);
let inner = OsuPpInner {
let inner = OsuPerformanceInner {
attrs,
mods: self.mods,
acc: state.accuracy(),
@@ -418,36 +409,100 @@ impl<'map> OsuPP<'map> {
inner.calculate()
}
fn generate_attributes(&self, map: &Beatmap) -> OsuDifficultyAttributes {
let mut calculator = OsuStars::new(map).mods(self.mods);
fn generate_attributes(&self, map: &OsuBeatmap<'_>) -> OsuDifficultyAttributes {
let mut calculator = ModeDifficulty::new();
if let Some(passed_objects) = self.passed_objects {
calculator = calculator.passed_objects(passed_objects);
calculator.passed_objects(passed_objects);
}
if let Some(clock_rate) = self.clock_rate {
calculator = calculator.clock_rate(clock_rate);
calculator.clock_rate(clock_rate);
}
calculator.calculate()
calculator.mods(self.mods).calculate(map)
}
/// Try to create [`OsuPP`] through a [`OsuAttributeProvider`].
/// Try to create [`OsuPerformance`] through a [`ModeAttributeProvider`].
///
/// If you already calculated the attributes for the current map-mod
/// combination, the [`Beatmap`] is no longer necessary to calculate
/// combination, the [`OsuBeatmap`] is no longer necessary to calculate
/// performance attributes so this method can be used instead of
/// [`OsuPP::new`].
/// [`OsuPerformance::new`].
///
/// Returns `None` only if the [`OsuAttributeProvider`] did not contain
/// Returns `None` only if the [`ModeAttributeProvider`] did not contain
/// attributes for osu e.g. if it's [`DifficultyAttributes::Taiko`].
#[inline]
pub fn try_from_attributes(attributes: impl OsuAttributeProvider) -> Option<Self> {
///
/// [`DifficultyAttributes::Taiko`]: crate::any::DifficultyAttributes::Taiko
pub fn try_from_attributes(attributes: impl ModeAttributeProvider<Osu>) -> Option<Self> {
attributes.attributes().map(Self::from)
}
/// Create [`OsuPerformance`] through a [`ModeAttributeProvider`].
///
/// If you already calculated the attributes for the current map-mod
/// combination, the [`OsuBeatmap`] is no longer necessary to calculate
/// performance attributes so this method can be used instead of
/// [`OsuPerformance::new`].
///
/// # Panics
///
/// Panics if the [`ModeAttributeProvider`] did not contain attributes for
/// osu e.g. if it's [`DifficultyAttributes::Taiko`].
///
/// [`DifficultyAttributes::Taiko`]: crate::any::DifficultyAttributes::Taiko
pub fn unchecked_from_attributes(attributes: impl ModeAttributeProvider<Osu>) -> Self {
Self::try_from_attributes(attributes).expect("invalid osu attributes")
}
}
struct OsuPpInner {
impl<'map> From<OsuBeatmap<'map>> for OsuPerformance<'map> {
fn from(map: OsuBeatmap<'map>) -> Self {
Self {
map_or_attrs: MapOrAttrs::Map(map),
mods: 0,
acc: None,
combo: None,
n300: None,
n100: None,
n50: None,
n_misses: None,
passed_objects: None,
clock_rate: None,
hitresult_priority: HitResultPriority::default(),
}
}
}
impl From<OsuDifficultyAttributes> for OsuPerformance<'_> {
fn from(attrs: OsuDifficultyAttributes) -> Self {
Self {
map_or_attrs: MapOrAttrs::Attrs(attrs),
mods: 0,
acc: None,
combo: None,
n300: None,
n100: None,
n50: None,
n_misses: None,
passed_objects: None,
clock_rate: None,
hitresult_priority: HitResultPriority::default(),
}
}
}
impl From<OsuPerformanceAttributes> for OsuPerformance<'_> {
fn from(attrs: OsuPerformanceAttributes) -> Self {
attrs.difficulty.into()
}
}
pub const PERFORMANCE_BASE_MULTIPLIER: f64 = 1.14;
struct OsuPerformanceInner {
attrs: OsuDifficultyAttributes,
mods: u32,
acc: f64,
@@ -455,7 +510,7 @@ struct OsuPpInner {
effective_miss_count: f64,
}
impl OsuPpInner {
impl OsuPerformanceInner {
fn calculate(mut self) -> OsuPerformanceAttributes {
let total_hits = self.state.total_hits();
@@ -466,7 +521,7 @@ impl OsuPpInner {
};
}
let total_hits = total_hits as f64;
let total_hits = f64::from(total_hits);
let mut multiplier = PERFORMANCE_BASE_MULTIPLIER;
@@ -475,7 +530,7 @@ impl OsuPpInner {
}
if self.mods.so() && total_hits > 0.0 {
multiplier *= 1.0 - (self.attrs.n_spinners as f64 / total_hits).powf(0.85);
multiplier *= 1.0 - (f64::from(self.attrs.n_spinners) / total_hits).powf(0.85);
}
if self.mods.rx() {
@@ -494,9 +549,9 @@ impl OsuPpInner {
// * As we're adding Oks and Mehs to an approximated number of combo breaks the result can be
// * higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it.
self.effective_miss_count = (self.effective_miss_count
+ self.state.n100 as f64
+ f64::from(self.state.n100)
+ n100_mult
+ self.state.n50 as f64 * n50_mult)
+ f64::from(self.state.n50) * n50_mult)
.min(total_hits);
}
@@ -563,14 +618,14 @@ impl OsuPpInner {
}
// * We assume 15% of sliders in a map are difficult since there's no way to tell from the performance calculator.
let estimate_diff_sliders = self.attrs.n_sliders as f64 * 0.15;
let estimate_diff_sliders = f64::from(self.attrs.n_sliders) * 0.15;
if self.attrs.n_sliders > 0 {
let estimate_slider_ends_dropped =
((self.state.n100 + self.state.n50 + self.state.n_misses)
.min(self.attrs.max_combo.saturating_sub(self.state.max_combo))
as f64)
.clamp(0.0, estimate_diff_sliders);
let estimate_slider_ends_dropped = f64::from(
(self.state.n100 + self.state.n50 + self.state.n_misses)
.min(self.attrs.max_combo.saturating_sub(self.state.max_combo)),
)
.clamp(0.0, estimate_diff_sliders);
let slider_nerf_factor = (1.0 - self.attrs.slider_factor)
* (1.0 - estimate_slider_ends_dropped / estimate_diff_sliders).powi(3)
+ self.attrs.slider_factor;
@@ -628,15 +683,15 @@ impl OsuPpInner {
// * Calculate accuracy assuming the worst case scenario
let relevant_total_diff = total_hits - self.attrs.speed_note_count;
let relevant_n300 = (self.state.n300 as f64 - relevant_total_diff).max(0.0);
let relevant_n100 = (self.state.n100 as f64
- (relevant_total_diff - self.state.n300 as f64).max(0.0))
let relevant_n300 = (f64::from(self.state.n300) - relevant_total_diff).max(0.0);
let relevant_n100 = (f64::from(self.state.n100)
- (relevant_total_diff - f64::from(self.state.n300)).max(0.0))
.max(0.0);
let relevant_n50 = (self.state.n50 as f64
- (relevant_total_diff - (self.state.n300 + self.state.n100) as f64).max(0.0))
let relevant_n50 = (f64::from(self.state.n50)
- (relevant_total_diff - f64::from(self.state.n300 + self.state.n100)).max(0.0))
.max(0.0);
let relevant_acc = if self.attrs.speed_note_count.abs() <= f64::EPSILON {
let relevant_acc = if self.attrs.speed_note_count.eq(0.0) {
0.0
} else {
(relevant_n300 * 6.0 + relevant_n100 * 2.0 + relevant_n50)
@@ -649,8 +704,8 @@ impl OsuPpInner {
// * Scale the speed value with # of 50s to punish doubletapping.
speed_value *= 0.99_f64.powf(
f64::from(u8::from(self.state.n50 as f64 >= total_hits / 500.0))
* (self.state.n50 as f64 - total_hits / 500.0),
f64::from(u8::from(f64::from(self.state.n50) >= total_hits / 500.0))
* (f64::from(self.state.n50) - total_hits / 500.0),
);
speed_value
@@ -672,8 +727,8 @@ impl OsuPpInner {
if self.state.n300 < sub {
0.0
} else {
((self.state.n300 - sub) * 6 + self.state.n100 * 2 + self.state.n50) as f64
/ (amount_hit_objects_with_acc * 6) as f64
f64::from((self.state.n300 - sub) * 6 + self.state.n100 * 2 + self.state.n50)
/ f64::from(amount_hit_objects_with_acc * 6)
}
} else {
0.0
@@ -684,7 +739,7 @@ impl OsuPpInner {
let mut acc_value = 1.52163_f64.powf(self.attrs.od) * better_acc_percentage.powi(24) * 2.83;
// * Bonus for many hitcircles - it's harder to keep good accuracy up for longer.
acc_value *= (amount_hit_objects_with_acc as f64 / 1000.0)
acc_value *= (f64::from(amount_hit_objects_with_acc) / 1000.0)
.powf(0.3)
.min(1.15);
@@ -737,7 +792,7 @@ impl OsuPpInner {
if self.attrs.max_combo == 0 {
1.0
} else {
((self.state.max_combo as f64).powf(0.8) / (self.attrs.max_combo as f64).powf(0.8))
(f64::from(self.state.max_combo).powf(0.8) / f64::from(self.attrs.max_combo).powf(0.8))
.min(1.0)
}
}
@@ -752,21 +807,21 @@ fn calculate_effective_misses(attrs: &OsuDifficultyAttributes, state: &OsuScoreS
let mut combo_based_miss_count = 0.0;
if attrs.n_sliders > 0 {
let full_combo_threshold = attrs.max_combo as f64 - 0.1 * attrs.n_sliders as f64;
let full_combo_threshold = f64::from(attrs.max_combo) - 0.1 * f64::from(attrs.n_sliders);
if (state.max_combo as f64) < full_combo_threshold {
combo_based_miss_count = full_combo_threshold / (state.max_combo as f64).max(1.0);
if f64::from(state.max_combo) < full_combo_threshold {
combo_based_miss_count = full_combo_threshold / f64::from(state.max_combo).max(1.0);
}
}
// * Clamp miss count to maximum amount of possible breaks
combo_based_miss_count =
combo_based_miss_count.min((state.n100 + state.n50 + state.n_misses) as f64);
combo_based_miss_count.min(f64::from(state.n100 + state.n50 + state.n_misses));
combo_based_miss_count.max(state.n_misses as f64)
combo_based_miss_count.max(f64::from(state.n_misses))
}
fn accuracy(n300: usize, n100: usize, n50: usize, n_misses: usize) -> f64 {
fn accuracy(n300: u32, n100: u32, n50: u32, n_misses: u32) -> f64 {
if n300 + n100 + n50 + n_misses == 0 {
return 0.0;
}
@@ -774,351 +829,5 @@ fn accuracy(n300: usize, n100: usize, n50: usize, n_misses: usize) -> f64 {
let numerator = 6 * n300 + 2 * n100 + n50;
let denominator = 6 * (n300 + n100 + n50 + n_misses);
numerator as f64 / denominator as f64
}
impl From<OsuDifficultyAttributes> for OsuPP<'_> {
#[inline]
fn from(attrs: OsuDifficultyAttributes) -> Self {
Self {
map_or_attrs: MapOrElse::Else(attrs),
mods: 0,
acc: None,
combo: None,
n300: None,
n100: None,
n50: None,
n_misses: None,
passed_objects: None,
clock_rate: None,
hitresult_priority: None,
}
}
}
impl From<OsuPerformanceAttributes> for OsuPP<'_> {
#[inline]
fn from(attrs: OsuPerformanceAttributes) -> Self {
attrs.difficulty.into()
}
}
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
pub trait OsuAttributeProvider {
/// Provide the actual difficulty attributes.
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)
}
}
impl OsuAttributeProvider for DifficultyAttributes {
#[inline]
fn attributes(self) -> Option<OsuDifficultyAttributes> {
if let Self::Osu(attributes) = self {
Some(attributes)
} else {
None
}
}
}
impl OsuAttributeProvider for PerformanceAttributes {
#[inline]
fn attributes(self) -> Option<OsuDifficultyAttributes> {
if let Self::Osu(attributes) = self {
Some(attributes.difficulty)
} else {
None
}
}
}
#[cfg(not(any(feature = "async_tokio", feature = "async_std")))]
#[cfg(test)]
mod test {
use super::*;
use crate::Beatmap;
use proptest::{option, prelude::*};
use std::sync::OnceLock;
static DATA: OnceLock<(Beatmap, OsuDifficultyAttributes)> = OnceLock::new();
const N_OBJECTS: usize = 601;
fn test_data() -> (&'static Beatmap, OsuDifficultyAttributes) {
let (map, attrs) = DATA.get_or_init(|| {
let path = "./maps/2785319.osu";
let map = Beatmap::from_path(path).unwrap();
let attrs = OsuStars::new(&map).calculate();
assert_eq!(
(attrs.n_circles, attrs.n_sliders, attrs.n_spinners),
(307, 293, 1)
);
assert_eq!(
attrs.n_circles + attrs.n_sliders + attrs.n_spinners,
N_OBJECTS,
);
(map, attrs)
});
(map, attrs.to_owned())
}
/// Checks all remaining hitresult combinations w.r.t. the given parameters
/// and returns the [`OsuScoreState`] that matches `acc` the best.
///
/// Very slow but accurate.
fn brute_force_best(
acc: f64,
n300: Option<usize>,
n100: Option<usize>,
n50: Option<usize>,
n_misses: usize,
best_case: bool,
) -> OsuScoreState {
let n_misses = n_misses.min(N_OBJECTS);
let mut best_state = OsuScoreState {
n_misses,
..Default::default()
};
let mut best_dist = f64::INFINITY;
let n_remaining = N_OBJECTS - n_misses;
let (min_n300, max_n300) = match (n300, n100, n50) {
(Some(n300), ..) => (n_remaining.min(n300), n_remaining.min(n300)),
(None, Some(n100), Some(n50)) => (
n_remaining.saturating_sub(n100 + n50),
n_remaining.saturating_sub(n100 + n50),
),
(None, ..) => (
0,
n_remaining.saturating_sub(n100.unwrap_or(0) + n50.unwrap_or(0)),
),
};
for new300 in min_n300..=max_n300 {
let (min_n100, max_n100) = match (n100, n50) {
(Some(n100), _) => (n_remaining.min(n100), n_remaining.min(n100)),
(None, Some(n50)) => (
n_remaining.saturating_sub(new300 + n50),
n_remaining.saturating_sub(new300 + n50),
),
(None, None) => (0, n_remaining - new300),
};
for new100 in min_n100..=max_n100 {
let new50 = match n50 {
Some(n50) => n_remaining.min(n50),
None => n_remaining.saturating_sub(new300 + new100),
};
let curr_acc = accuracy(new300, new100, new50, n_misses);
let curr_dist = (acc - curr_acc).abs();
if curr_dist < best_dist {
best_dist = curr_dist;
best_state.n300 = new300;
best_state.n100 = new100;
best_state.n50 = new50;
}
}
}
if best_state.n300 + best_state.n100 + best_state.n50 < n_remaining {
let remaining = n_remaining - (best_state.n300 + best_state.n100 + best_state.n50);
if best_case {
best_state.n300 += remaining;
} else {
best_state.n50 += remaining;
}
}
if n300.is_none() && n100.is_none() && n50.is_none() {
if best_case {
let n = best_state.n300.min(best_state.n50 / 4);
best_state.n300 -= n;
best_state.n100 += 5 * n;
best_state.n50 -= 4 * n;
} else {
let n = best_state.n100 / 5;
best_state.n300 += n;
best_state.n100 -= 5 * n;
best_state.n50 += 4 * n;
}
}
best_state
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(10_000))]
#[test]
fn osu_hitresults(
acc in 0.0..=1.0,
n300 in option::weighted(0.10, 0_usize..=N_OBJECTS + 10),
n100 in option::weighted(0.10, 0_usize..=N_OBJECTS + 10),
n50 in option::weighted(0.10, 0_usize..=N_OBJECTS + 10),
n_misses in option::weighted(0.15, 0_usize..=N_OBJECTS + 10),
best_case in prop::bool::ANY,
) {
let (map, attrs) = test_data();
let max_combo = attrs.max_combo();
let priority = if best_case {
HitResultPriority::BestCase
} else {
HitResultPriority::WorstCase
};
let mut state = OsuPP::new(map)
.attributes(attrs)
.accuracy(acc * 100.0)
.hitresult_priority(priority);
if let Some(n300) = n300 {
state = state.n300(n300);
}
if let Some(n100) = n100 {
state = state.n100(n100);
}
if let Some(n50) = n50 {
state = state.n50(n50);
}
if let Some(n_misses) = n_misses {
state = state.n_misses(n_misses);
}
let state = state.generate_state();
let mut expected = brute_force_best(
acc,
n300,
n100,
n50,
n_misses.unwrap_or(0),
best_case,
);
expected.max_combo = max_combo.saturating_sub(n_misses.map_or(0, |n| n.min(N_OBJECTS)));
assert_eq!(state, expected);
}
}
#[test]
fn hitresults_n300_n100_n_misses_best() {
let (map, attrs) = test_data();
let state = OsuPP::new(map)
.attributes(attrs)
.combo(500)
.n300(300)
.n100(20)
.n_misses(2)
.hitresult_priority(HitResultPriority::BestCase)
.generate_state();
let expected = OsuScoreState {
max_combo: 500,
n300: 300,
n100: 20,
n50: 279,
n_misses: 2,
};
assert_eq!(state, expected);
}
#[test]
fn hitresults_n300_n50_n_misses_best() {
let (map, attrs) = test_data();
let state = OsuPP::new(map)
.attributes(attrs)
.combo(500)
.n300(300)
.n50(10)
.n_misses(2)
.hitresult_priority(HitResultPriority::BestCase)
.generate_state();
let expected = OsuScoreState {
max_combo: 500,
n300: 300,
n100: 289,
n50: 10,
n_misses: 2,
};
assert_eq!(state, expected);
}
#[test]
fn hitresults_n50_n_misses_worst() {
let (map, attrs) = test_data();
let state = OsuPP::new(map)
.attributes(attrs)
.combo(500)
.n50(10)
.n_misses(2)
.hitresult_priority(HitResultPriority::WorstCase)
.generate_state();
let expected = OsuScoreState {
max_combo: 500,
n300: 0,
n100: 589,
n50: 10,
n_misses: 2,
};
assert_eq!(state, expected);
}
#[test]
fn hitresults_n300_n100_n50_n_misses_worst() {
let (map, attrs) = test_data();
let state = OsuPP::new(map)
.attributes(attrs)
.combo(500)
.n300(300)
.n100(50)
.n50(10)
.n_misses(2)
.hitresult_priority(HitResultPriority::WorstCase)
.generate_state();
let expected = OsuScoreState {
max_combo: 500,
n300: 300,
n100: 50,
n50: 249,
n_misses: 2,
};
assert_eq!(state, expected);
}
f64::from(numerator) / f64::from(denominator)
}
-37
View File
@@ -1,37 +0,0 @@
use crate::parse::Pos2;
use super::NORMALIZED_RADIUS;
const OBJECT_RADIUS: f32 = 64.0;
#[derive(Copy, Clone, Debug)]
pub(crate) struct ScalingFactor {
pub(crate) factor: f32,
pub(crate) radius: f32,
scale: f32,
}
impl ScalingFactor {
pub(crate) fn new(cs: f64) -> Self {
let scale = (1.0 - 0.7 * (cs as f32 - 5.0) / 5.0) / 2.0;
let radius = OBJECT_RADIUS * scale;
let factor = NORMALIZED_RADIUS / radius;
let factor = if radius < 30.0 {
factor * (1.0 + (30.0 - radius).min(5.0) / 50.0)
} else {
factor
};
Self {
factor,
radius,
scale,
}
}
pub(crate) fn stack_offset(&self, stack_height: f32) -> Pos2 {
Pos2::new(stack_height * self.scale * -6.4)
}
}
+9 -13
View File
@@ -1,35 +1,31 @@
/// Aggregation for a score's current state i.e. what was the
/// maximum combo so far and what are the current hitresults.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
/// Aggregation for a score's current state.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct OsuScoreState {
/// Maximum combo that the score has had so far.
/// **Not** the maximum possible combo of the map so far.
pub max_combo: usize,
pub max_combo: u32,
/// Amount of current 300s.
pub n300: usize,
pub n300: u32,
/// Amount of current 100s.
pub n100: usize,
pub n100: u32,
/// Amount of current 50s.
pub n50: usize,
pub n50: u32,
/// Amount of current misses.
pub n_misses: usize,
pub n_misses: u32,
}
impl OsuScoreState {
/// Create a new empty score state.
#[inline]
pub fn new() -> Self {
Self::default()
}
/// Return the total amount of hits by adding everything up.
#[inline]
pub const fn total_hits(&self) -> usize {
pub const fn total_hits(&self) -> u32 {
self.n300 + self.n100 + self.n50 + self.n_misses
}
/// Calculate the accuracy between `0.0` and `1.0` for this state.
#[inline]
pub fn accuracy(&self) -> f64 {
let total_hits = self.total_hits();
@@ -40,6 +36,6 @@ impl OsuScoreState {
let numerator = 6 * self.n300 + 2 * self.n100 + self.n50;
let denominator = 6 * total_hits;
numerator as f64 / denominator as f64
f64::from(numerator) / f64::from(denominator)
}
}
-236
View File
@@ -1,236 +0,0 @@
use crate::{
osu::{difficulty_object::OsuDifficultyObject, osu_object::OsuObjectKind},
util::CompactVec,
Mods,
};
use super::{previous, previous_start_time, OsuStrainSkill, Skill, StrainSkill};
#[derive(Clone, Debug)]
pub(crate) struct Flashlight {
curr_strain: f64,
curr_section_peak: f64,
curr_section_end: f64,
pub(crate) strain_peaks: CompactVec,
has_hidden_mod: bool,
scaling_factor: f64,
time_preempt: f64,
time_fade_in: f64,
}
impl Flashlight {
const SKILL_MULTIPLIER: f64 = 0.052;
const STRAIN_DECAY_BASE: f64 = 0.15;
pub(crate) fn new(mods: u32, radius: f32, time_preempt: f64, time_fade_in: f64) -> Self {
Self {
curr_strain: 0.0,
curr_section_peak: 0.0,
curr_section_end: 0.0,
strain_peaks: CompactVec::new(),
has_hidden_mod: mods.hd(),
scaling_factor: 52.0 / f64::from(radius),
time_preempt,
time_fade_in,
}
}
fn strain_decay(ms: f64) -> f64 {
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
}
}
impl Skill for Flashlight {
#[inline]
fn process(
&mut self,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) {
<Self as StrainSkill>::process(self, curr, diff_objects);
}
#[inline]
fn difficulty_value(&mut self) -> f64 {
<Self as StrainSkill>::difficulty_value(self)
}
}
impl StrainSkill for Flashlight {
const DECAY_WEIGHT: f64 = 0.9;
#[inline]
fn strain_peaks_mut(&mut self) -> &mut CompactVec {
&mut self.strain_peaks
}
#[inline]
fn curr_section_peak(&mut self) -> &mut f64 {
&mut self.curr_section_peak
}
#[inline]
fn curr_section_end(&mut self) -> &mut f64 {
&mut self.curr_section_end
}
#[inline]
fn strain_value_at(
&mut self,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) -> f64 {
self.curr_strain *= Self::strain_decay(curr.delta_time);
self.curr_strain += FlashlightEvaluator::evaluate_diff_of(
curr,
diff_objects,
self.has_hidden_mod,
self.scaling_factor,
self.time_preempt,
self.time_fade_in,
) * Self::SKILL_MULTIPLIER;
self.curr_strain
}
#[inline]
fn calculate_initial_strain(
&self,
time: f64,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) -> f64 {
self.curr_strain * Self::strain_decay(time - previous_start_time(diff_objects, curr.idx, 0))
}
#[inline]
fn difficulty_value(&mut self) -> f64 {
self.get_curr_strain_peaks().sum() * Self::DIFFICULTY_MULTIPLER
}
}
impl OsuStrainSkill for Flashlight {}
struct FlashlightEvaluator;
impl FlashlightEvaluator {
const MAX_OPACITY_BONUS: f64 = 0.4;
const HIDDEN_BONUS: f64 = 0.2;
const MIN_VELOCITY: f64 = 0.5;
const SLIDER_MULTIPLIER: f64 = 1.3;
const MIN_ANGLE_MULTIPLIER: f64 = 0.2;
fn evaluate_diff_of(
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
hidden: bool,
scaling_factor: f64,
time_preempt: f64,
time_fade_in: f64,
) -> f64 {
if curr.base.is_spinner() {
return 0.0;
}
let osu_curr = curr;
let osu_hit_obj = curr.base;
let mut small_dist_nerf = 1.0;
let mut cumulative_strain_time = 0.0;
let mut result = 0.0;
let mut last_obj = osu_curr;
let mut angle_repeat_count = 0.0;
// * This is iterating backwards in time from the current object.
for i in 0..curr.idx.min(10) {
let Some(curr_obj) = previous(diff_objects, curr.idx, i) else {
break;
};
let curr_hit_obj = curr_obj.base;
if !curr_obj.base.is_spinner() {
let jump_dist = f64::from(
(osu_hit_obj.stacked_pos() - curr_hit_obj.stacked_end_pos()).length(),
);
cumulative_strain_time += last_obj.strain_time;
// * We want to nerf objects that can be easily seen within the Flashlight circle radius.
if i == 0 {
small_dist_nerf = (jump_dist / 75.0).min(1.0);
}
// * We also want to nerf stacks so that only the first object of the stack is accounted for.
let stack_nerf = ((curr_obj.dists.lazy_jump_dist / scaling_factor) / 25.0).min(1.0);
// * Bonus based on how visible the object is.
let opacity_bonus = 1.0
+ Self::MAX_OPACITY_BONUS
* (1.0
- osu_curr.opacity_at(
curr_hit_obj.start_time,
hidden,
time_preempt,
time_fade_in,
));
result += stack_nerf * opacity_bonus * scaling_factor * jump_dist
/ cumulative_strain_time;
if let Some((curr_obj_angle, osu_curr_angle)) =
curr_obj.dists.angle.zip(osu_curr.dists.angle)
{
// * Objects further back in time should count less for the nerf.
if (curr_obj_angle - osu_curr_angle).abs() < 0.02 {
angle_repeat_count += (1.0 - 0.1 * i as f64).max(0.0);
}
}
}
last_obj = curr_obj;
}
let base = small_dist_nerf * result;
result = base * base;
// * Additional bonus for Hidden due to there being no approach circles.
if hidden {
result *= 1.0 + Self::HIDDEN_BONUS;
}
// * Nerf patterns with repeated angles.
result *= Self::MIN_ANGLE_MULTIPLIER
+ (1.0 - Self::MIN_ANGLE_MULTIPLIER) / (angle_repeat_count + 1.0);
let mut slider_bonus = 0.0;
if let OsuObjectKind::Slider(slider) = &osu_curr.base.kind {
// * Invert the scaling factor to determine the true travel distance independent of circle size.
let pixel_travel_dist = f64::from(osu_curr.dists.lazy_travel_dist) / scaling_factor;
// * Reward sliders based on velocity.
slider_bonus = ((pixel_travel_dist / osu_curr.dists.travel_time - Self::MIN_VELOCITY)
.max(0.0))
.sqrt();
// * Longer sliders require more memorisation.
slider_bonus *= pixel_travel_dist;
// * Nerf sliders with repeats, as less memorisation is required.
let repeat_count = slider.repeat_count();
if repeat_count > 0 {
slider_bonus /= (repeat_count + 1) as f64;
}
}
result += slider_bonus * Self::SLIDER_MULTIPLIER;
result
}
}
-74
View File
@@ -1,74 +0,0 @@
mod aim;
mod flashlight;
mod speed;
mod traits;
use crate::osu::difficulty_object::OsuDifficultyObject;
pub(crate) use self::{
aim::Aim,
flashlight::Flashlight,
speed::Speed,
traits::{OsuStrainSkill, Skill, StrainSkill},
};
#[derive(Clone, Debug)]
pub(crate) struct Skills {
pub aim: Aim,
pub aim_no_sliders: Aim,
pub speed: Speed,
pub flashlight: Flashlight,
}
impl Skills {
pub(crate) fn new(
mods: u32,
radius: f32,
time_preempt: f64,
time_fade_in: f64,
hit_window: f64,
) -> Self {
Self {
aim: Aim::new(true),
aim_no_sliders: Aim::new(false),
speed: Speed::new(hit_window),
flashlight: Flashlight::new(mods, radius, time_preempt, time_fade_in),
}
}
pub(crate) fn process(
&mut self,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) {
<Aim as Skill>::process(&mut self.aim, curr, diff_objects);
<Aim as Skill>::process(&mut self.aim_no_sliders, curr, diff_objects);
<Speed as Skill>::process(&mut self.speed, curr, diff_objects);
<Flashlight as Skill>::process(&mut self.flashlight, curr, diff_objects);
}
}
fn previous<'map, 'objects>(
diff_objects: &'objects [OsuDifficultyObject<'map>],
curr: usize,
backwards_idx: usize,
) -> Option<&'objects OsuDifficultyObject<'map>> {
curr.checked_sub(backwards_idx + 1)
.and_then(|idx| diff_objects.get(idx))
}
fn previous_start_time(
diff_objects: &[OsuDifficultyObject<'_>],
curr: usize,
backwards_idx: usize,
) -> f64 {
previous(diff_objects, curr, backwards_idx).map_or(0.0, |h| h.start_time)
}
fn next<'map, 'objects>(
diff_objects: &'objects [OsuDifficultyObject<'map>],
curr: usize,
forwards_idx: usize,
) -> Option<&'objects OsuDifficultyObject<'map>> {
diff_objects.get(curr + (forwards_idx + 1))
}
-145
View File
@@ -1,145 +0,0 @@
use std::{cmp::Ordering, mem};
use crate::{
osu::{difficulty_object::OsuDifficultyObject, SECTION_LEN},
util::CompactVec,
};
pub(crate) trait Skill {
fn process(&mut self, curr: &OsuDifficultyObject<'_>, diff_objects: &[OsuDifficultyObject<'_>]);
fn difficulty_value(&mut self) -> f64;
}
pub(crate) trait StrainSkill: Skill + Sized {
const DECAY_WEIGHT: f64 = 0.9;
fn strain_peaks_mut(&mut self) -> &mut CompactVec;
fn curr_section_peak(&mut self) -> &mut f64;
fn curr_section_end(&mut self) -> &mut f64;
fn strain_value_at(
&mut self,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) -> f64;
fn calculate_initial_strain(
&self,
time: f64,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) -> f64;
fn process(
&mut self,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) {
// * The first object doesn't generate a strain, so we begin with an incremented section end
if curr.idx == 0 {
let section_len = SECTION_LEN;
*self.curr_section_end() = (curr.start_time / section_len).ceil() * section_len;
}
while curr.start_time > *self.curr_section_end() {
self.save_curr_peak();
{
let section_end = *self.curr_section_end();
self.start_new_section_from(section_end, curr, diff_objects);
}
*self.curr_section_end() += SECTION_LEN;
// Optimization to finish the loop early if
// the current peak is 0.0 i.e. it can't decay further.
// If final values don't coincide perfectly anymore,
// this should be looked at and maybe adjusted.
if self.curr_section_peak().abs() <= f64::EPSILON
&& curr.start_time > *self.curr_section_end()
{
let remaining_time = curr.start_time - *self.curr_section_end();
let remaining_iters = (remaining_time / SECTION_LEN).ceil();
*self.curr_section_end() += remaining_iters * SECTION_LEN;
}
}
*self.curr_section_peak() = self
.strain_value_at(curr, diff_objects)
.max(*self.curr_section_peak());
}
#[inline]
fn save_curr_peak(&mut self) {
let peak = *self.curr_section_peak();
self.strain_peaks_mut().push(peak);
}
#[inline]
fn start_new_section_from(
&mut self,
time: f64,
curr: &OsuDifficultyObject<'_>,
diff_objects: &[OsuDifficultyObject<'_>],
) {
// * The maximum strain of the new section is not zero by default
// * This means we need to capture the strain level at the beginning of the new section,
// * and use that as the initial peak level.
*self.curr_section_peak() = self.calculate_initial_strain(time, curr, diff_objects);
}
fn difficulty_value(&mut self) -> f64;
#[inline]
fn get_curr_strain_peaks(&mut self) -> CompactVec {
let curr_peak = *self.curr_section_peak();
let mut strain_peaks = mem::take(self.strain_peaks_mut());
strain_peaks.push(curr_peak);
strain_peaks
}
}
pub(crate) trait OsuStrainSkill: StrainSkill + Sized {
const REDUCED_SECTION_COUNT: usize = 10;
const REDUCED_STRAIN_BASELINE: f64 = 0.75;
const DIFFICULTY_MULTIPLER: f64 = 1.06;
fn difficulty_value(&mut self) -> f64 {
let mut difficulty = 0.0;
let mut weight = 1.0;
// * Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
// * These sections will not contribute to the difficulty.
let mut peaks = self.get_curr_strain_peaks();
peaks.retain(|peak| peak > 0.0);
let mut peaks = peaks.to_vec();
peaks.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
let peak_iter = peaks.iter_mut().take(Self::REDUCED_SECTION_COUNT);
fn lerp(start: f64, end: f64, amount: f64) -> f64 {
start + (end - start) * amount
}
// * We are reducing the highest strains first to account for extreme difficulty spikes
for (i, strain) in peak_iter.enumerate() {
let clamped =
f64::from((i as f32 / Self::REDUCED_SECTION_COUNT as f32).clamp(0.0, 1.0));
let scale = (lerp(1.0, 10.0, clamped)).log10();
*strain *= lerp(Self::REDUCED_STRAIN_BASELINE, 1.0, scale);
}
peaks.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
// * Difficulty is the weighted sum of the highest strains from every section.
// * We're sorting from highest to lowest strain.
for strain in peaks {
difficulty += strain * weight;
weight *= Self::DECAY_WEIGHT;
}
difficulty * Self::DIFFICULTY_MULTIPLER
}
}
+40
View File
@@ -0,0 +1,40 @@
use crate::any::ModeDifficulty;
use super::{convert::OsuBeatmap, difficulty::DifficultyValues};
/// The result of calculating the strains on a osu! map.
///
/// Suitable to plot the difficulty of a map over time.
#[derive(Clone, Debug, PartialEq)]
pub struct OsuStrains {
/// Strain peaks of the aim skill.
pub aim: Vec<f64>,
/// Strain peaks of the aim skill without sliders.
pub aim_no_sliders: Vec<f64>,
/// Strain peaks of the speed skill.
pub speed: Vec<f64>,
/// Strain peaks of the flashlight skill.
pub flashlight: Vec<f64>,
}
impl OsuStrains {
/// Time between two strains in ms.
pub const SECTION_LEN: f64 = 400.0;
}
pub fn strains(difficulty: &ModeDifficulty, converted: &OsuBeatmap<'_>) -> OsuStrains {
let DifficultyValues {
aim,
aim_no_sliders,
speed,
flashlight,
attrs: _,
} = DifficultyValues::calculate(difficulty, converted);
OsuStrains {
aim: aim.get_curr_strain_peaks(),
aim_no_sliders: aim_no_sliders.get_curr_strain_peaks(),
speed: speed.get_curr_strain_peaks(),
flashlight: flashlight.get_curr_strain_peaks(),
}
}