removed no_leniency feature

This commit is contained in:
MaxOhn
2021-11-12 12:48:35 +01:00
parent d703f55b3c
commit 09943c9ea2
20 changed files with 543 additions and 1724 deletions
@@ -1,5 +1,5 @@
use crate::{
osu::precise::osu_object::{NestedObjectKind, OsuObjectKind},
osu::osu_object::{NestedObjectKind, OsuObjectKind},
parse::Pos2,
};
-61
View File
@@ -1,61 +0,0 @@
use super::OsuObject;
pub(crate) struct DifficultyObject<'h> {
pub(crate) base: &'h OsuObject,
pub(crate) prev: Option<(f32, f32)>, // (jump_dist, strain_time)
pub(crate) jump_dist: f32,
pub(crate) travel_dist: f32,
pub(crate) angle: Option<f32>,
pub(crate) delta: f32,
pub(crate) strain_time: f32,
}
impl<'h> DifficultyObject<'h> {
pub(crate) fn new(
base: &'h OsuObject,
prev: &OsuObject,
prev_vals: Option<(f32, f32)>, // (jump_dist, strain_time)
prev_prev: Option<OsuObject>,
scaling_factor: f32,
) -> Self {
let delta = base.time - prev.time;
let travel_dist = prev.travel_dist();
// Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects
let strain_time = delta.max(25.0);
// We don't need to calculate either angle or distance
// when one of the last->curr objects is a spinner
let (jump_dist, angle) = if base.is_spinner() || prev.is_spinner() {
(0.0, None)
} else {
let jump_dist = ((base.pos - prev.end_pos()) * scaling_factor).length();
let angle = prev_prev.map(|prev_prev| {
let v1 = prev_prev.end_pos() - prev.pos;
let v2 = base.pos - prev.pos;
let dot = v1.dot(v2);
let det = v1.x * v2.y - v1.y * v2.x;
det.atan2(dot).abs()
});
(jump_dist, angle)
};
Self {
base,
prev: prev_vals,
jump_dist,
travel_dist,
angle,
delta,
strain_time,
}
}
}
-324
View File
@@ -1,324 +0,0 @@
//! In addtion to not considering the positional offset caused by stack leniency, slider paths are also ignored.
//! This means stacked positions aswell as the travel distance of notes is completely omitted which
//! will cause notable inaccuracies.
//! The advantage is that it's considerably faster than `osu_precise`.
#![cfg(feature = "osu_fast")]
use std::mem;
use self::osu_object::ObjectParameters;
use super::DifficultyAttributes;
mod difficulty_object;
mod osu_object;
mod skill;
mod skill_kind;
mod slider_state;
use difficulty_object::DifficultyObject;
use osu_object::OsuObject;
use skill::Skill;
use skill_kind::SkillKind;
use slider_state::SliderState;
use crate::{Beatmap, Mods, Strains};
const OBJECT_RADIUS: f32 = 64.0;
const SECTION_LEN: f32 = 400.0;
const DIFFICULTY_MULTIPLIER: f32 = 0.0675;
const NORMALIZED_RADIUS: f32 = 52.0;
/// Star calculation for osu!standard maps.
///
/// Sliders are considered as regular hitcircles and stack leniency is ignored.
/// Still decently accurate results but definitely less precise than `osu_precise`.
/// However, this version is considerably faster.
///
/// In case of a partial play, e.g. a fail, one can specify the amount of passed objects.
pub fn stars(
map: &Beatmap,
mods: impl Mods,
passed_objects: Option<usize>,
) -> DifficultyAttributes {
let take = passed_objects.unwrap_or_else(|| map.hit_objects.len());
let attributes = map.attributes().mods(mods);
let hit_window = super::difficulty_range_od(attributes.od) / attributes.clock_rate;
let od = (80.0 - hit_window) / 6.0;
if take < 2 {
return DifficultyAttributes {
ar: attributes.ar,
hp: attributes.hp,
od,
..Default::default()
};
}
let radius = OBJECT_RADIUS * (1.0 - 0.7 * (attributes.cs - 5.0) / 5.0) / 2.0;
let mut scaling_factor = NORMALIZED_RADIUS / radius;
if radius < 30.0 {
let small_circle_bonus = (30.0 - radius).min(5.0) / 50.0;
scaling_factor *= 1.0 + small_circle_bonus;
}
let mut params = ObjectParameters {
map,
radius,
clock_rate: attributes.clock_rate,
max_combo: 0,
slider_state: SliderState::new(map),
};
let mut hit_objects = map
.hit_objects
.iter()
.take(take)
.filter_map(|h| OsuObject::new(h, &mut params));
let fl = mods.fl();
let mut skills = Vec::with_capacity(2 + fl as usize);
skills.push(Skill::new(SkillKind::Aim));
skills.push(Skill::new(SkillKind::speed(hit_window)));
if fl {
skills.push(Skill::new(SkillKind::flashlight(scaling_factor)));
}
let mut prev_prev = None;
let mut prev = hit_objects.next().unwrap();
let mut prev_vals = None;
// First object has no predecessor and thus no strain, handle distinctly
let mut current_section_end = (prev.time / SECTION_LEN).ceil() * SECTION_LEN;
// Handle second object separately to remove later if-branching
let curr = hit_objects.next().unwrap();
let h = DifficultyObject::new(&curr, &prev, prev_vals, prev_prev, scaling_factor);
while h.base.time > current_section_end {
for skill in skills.iter_mut() {
skill.start_new_section_from(current_section_end);
}
current_section_end += SECTION_LEN;
}
for skill in skills.iter_mut() {
skill.process(&h);
}
prev_prev = Some(prev);
prev_vals = Some((h.jump_dist, h.strain_time));
prev = curr;
// Handle all other objects
for curr in hit_objects {
let h = DifficultyObject::new(&curr, &prev, prev_vals, prev_prev, scaling_factor);
while h.base.time > current_section_end {
for skill in skills.iter_mut() {
skill.save_current_peak();
skill.start_new_section_from(current_section_end);
}
current_section_end += SECTION_LEN;
}
for skill in skills.iter_mut() {
skill.process(&h);
}
prev_prev = Some(prev);
prev_vals = Some((h.jump_dist, h.strain_time));
prev = curr;
}
for skill in skills.iter_mut() {
skill.save_current_peak();
}
let aim_rating = skills[0].difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let speed_rating = if mods.rx() {
0.0
} else {
skills[1].difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER
};
let flashlight_rating = skills.get_mut(2).map_or(0.0, |skill| {
skill.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER
});
let base_aim_performance = {
let base = 5.0 * (aim_rating / 0.0675).max(1.0) - 4.0;
base * base * base / 100_000.0
};
let base_speed_performance = {
let base = 5.0 * (speed_rating / 0.0675).max(1.0) - 4.0;
base * base * base / 100_000.0
};
let base_flashlight_performance = if 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 {
1.12_f32.cbrt()
* 0.027
* ((100_000.0 / (1.0_f32 / 1.1).exp2() * base_performance).cbrt() + 4.0)
} else {
0.0
};
DifficultyAttributes {
stars: star_rating,
ar: attributes.ar,
hp: attributes.hp,
od,
speed_strain: speed_rating,
aim_strain: aim_rating,
flashlight_rating,
max_combo: params.max_combo,
n_circles: map.n_circles as usize,
n_spinners: map.n_spinners as usize,
n_sliders: map.n_sliders as usize,
}
}
/// Essentially the same as the `stars` function but instead of
/// evaluating the final strains, it just returns them as is.
///
/// Suitable to plot the difficulty of a map over time.
pub fn strains(map: &Beatmap, mods: impl Mods) -> Strains {
let attributes = map.attributes().mods(mods);
let hit_window = super::difficulty_range_od(attributes.od) / attributes.clock_rate;
if map.hit_objects.len() < 2 {
return Strains::default();
}
let radius = OBJECT_RADIUS * (1.0 - 0.7 * (attributes.cs - 5.0) / 5.0) / 2.0;
let mut scaling_factor = NORMALIZED_RADIUS / radius;
if radius < 30.0 {
let small_circle_bonus = (30.0 - radius).min(5.0) / 50.0;
scaling_factor *= 1.0 + small_circle_bonus;
}
let mut params = ObjectParameters {
map,
radius,
clock_rate: attributes.clock_rate,
max_combo: 0,
slider_state: SliderState::new(map),
};
let mut hit_objects = map
.hit_objects
.iter()
.filter_map(|h| OsuObject::new(h, &mut params));
let fl = mods.fl();
let mut skills = Vec::with_capacity(2 + fl as usize);
skills.push(Skill::new(SkillKind::Aim));
skills.push(Skill::new(SkillKind::speed(hit_window)));
if fl {
skills.push(Skill::new(SkillKind::flashlight(scaling_factor)));
}
let mut prev_prev = None;
let mut prev = hit_objects.next().unwrap();
let mut prev_vals = None;
// First object has no predecessor and thus no strain, handle distinctly
let mut current_section_end = (prev.time / SECTION_LEN).ceil() * SECTION_LEN;
// Handle second object separately to remove later if-branching
let curr = hit_objects.next().unwrap();
let h = DifficultyObject::new(&curr, &prev, prev_vals, prev_prev, scaling_factor);
while h.base.time > current_section_end {
for skill in skills.iter_mut() {
skill.start_new_section_from(current_section_end);
}
current_section_end += SECTION_LEN;
}
for skill in skills.iter_mut() {
skill.process(&h);
}
prev_prev = Some(prev);
prev_vals = Some((h.jump_dist, h.strain_time));
prev = curr;
// Handle all other objects
for curr in hit_objects {
let h = DifficultyObject::new(&curr, &prev, prev_vals, prev_prev, scaling_factor);
while h.base.time > current_section_end {
for skill in skills.iter_mut() {
skill.save_current_peak();
skill.start_new_section_from(current_section_end);
}
current_section_end += SECTION_LEN;
}
for skill in skills.iter_mut() {
skill.process(&h);
}
prev_prev = Some(prev);
prev_vals = Some((h.jump_dist, h.strain_time));
prev = curr;
}
for skill in skills.iter_mut() {
skill.save_current_peak();
}
let mut speed_strains = skills.pop().unwrap().strain_peaks;
let mut aim_strains = skills.pop().unwrap().strain_peaks;
let strains = if let Some(mut flashlight_strains) = skills.pop().map(|s| s.strain_peaks) {
mem::swap(&mut speed_strains, &mut aim_strains);
mem::swap(&mut aim_strains, &mut flashlight_strains);
aim_strains
.into_iter()
.zip(speed_strains)
.zip(flashlight_strains)
.map(|((aim, speed), flashlight)| aim + speed + flashlight)
.collect()
} else {
aim_strains
.into_iter()
.zip(speed_strains)
.map(|(aim, speed)| aim + speed)
.collect()
};
Strains {
section_length: SECTION_LEN,
strains,
}
}
-172
View File
@@ -1,172 +0,0 @@
use crate::{
parse::{HitObject, HitObjectKind, Pos2},
Beatmap,
};
use super::slider_state::SliderState;
pub(crate) struct OsuObject {
pub(crate) pos: Pos2,
pub(crate) time: f32,
kind: OsuObjectKind,
}
pub(crate) enum OsuObjectKind {
Circle,
Slider { end_pos: Pos2, travel_dist: f32 },
Spinner,
}
pub(crate) struct ObjectParameters<'a> {
pub(crate) map: &'a Beatmap,
pub(crate) radius: f32,
pub(crate) clock_rate: f32,
pub(crate) max_combo: usize,
pub(crate) slider_state: SliderState<'a>,
}
impl OsuObject {
pub(crate) fn new(h: &HitObject, params: &mut ObjectParameters<'_>) -> Option<Self> {
let time = h.start_time / params.clock_rate;
let obj = match &h.kind {
HitObjectKind::Circle => {
params.max_combo += 1;
Self::circle(h.pos, time)
}
#[cfg(feature = "sliders")]
HitObjectKind::Slider {
pixel_len,
repeats,
control_points,
} => {
let span_count = *repeats + 1;
params.max_combo += params.slider_state.count_ticks(
h.start_time,
*pixel_len,
span_count,
params.map,
);
match control_points.last() {
Some(point) => {
let follow_circle_radius = params.radius * 3.0;
let travel_dist = Self::approximate_travel_dist(
follow_circle_radius,
span_count as f32,
point.pos,
);
let mut end_pos = h.pos;
if repeats % 2 == 0 && *pixel_len > follow_circle_radius {
end_pos += point.pos
}
Self {
pos: h.pos,
time,
kind: OsuObjectKind::Slider {
end_pos,
travel_dist,
},
}
}
None => Self::circle(h.pos, time),
}
}
#[cfg(not(feature = "sliders"))]
HitObjectKind::Slider {
pixel_len,
span_count,
last_control_point,
} => {
params.max_combo += params.slider_state.count_ticks(
h.start_time,
*pixel_len,
*span_count,
params.map,
);
let follow_circle_radius = params.radius * 3.0;
let travel_dist = Self::approximate_travel_dist(
follow_circle_radius,
*span_count as f32,
*last_control_point - h.pos,
);
let end_pos = if span_count % 2 == 1 && *pixel_len > follow_circle_radius {
*last_control_point
} else {
h.pos
};
Self {
pos: h.pos,
time,
kind: OsuObjectKind::Slider {
end_pos,
travel_dist,
},
}
}
HitObjectKind::Spinner { .. } => {
params.max_combo += 1;
Self {
pos: h.pos,
time,
kind: OsuObjectKind::Spinner,
}
}
HitObjectKind::Hold { .. } => return None,
};
Some(obj)
}
fn circle(pos: Pos2, time: f32) -> Self {
Self {
pos,
time,
kind: OsuObjectKind::Circle,
}
}
pub(crate) fn is_slider(&self) -> bool {
matches!(self.kind, OsuObjectKind::Slider { .. })
}
pub(crate) fn is_spinner(&self) -> bool {
matches!(self.kind, OsuObjectKind::Spinner)
}
pub(crate) fn end_pos(&self) -> Pos2 {
match &self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner => self.pos,
OsuObjectKind::Slider { end_pos, .. } => *end_pos,
}
}
pub(crate) fn travel_dist(&self) -> f32 {
match &self.kind {
OsuObjectKind::Circle | OsuObjectKind::Spinner => 0.0,
OsuObjectKind::Slider { travel_dist, .. } => *travel_dist,
}
}
// Approximating lower bound for lazy travel distance
fn approximate_travel_dist(
follow_circle_radius: f32,
span_count: f32,
last_control_point: Pos2,
) -> f32 {
let lazy_end_point_dist = follow_circle_radius * span_count;
let dist = last_control_point.length();
(dist * span_count - lazy_end_point_dist).max(0.0)
}
}
-109
View File
@@ -1,109 +0,0 @@
use crate::math_util;
use super::{skill_kind::calculate_speed_rhythm_bonus, DifficultyObject, SkillKind};
use std::cmp::Ordering;
const REDUCED_STRAIN_BASELINE: f32 = 0.75;
pub(crate) struct Skill {
curr_strain: f32,
curr_section_peak: f32,
kind: SkillKind,
pub(crate) strain_peaks: Vec<f32>,
prev_time: Option<f32>,
}
impl Skill {
#[inline]
pub(crate) fn new(kind: SkillKind) -> Self {
Self {
curr_strain: 1.0,
curr_section_peak: 0.0,
kind,
strain_peaks: Vec::with_capacity(128),
prev_time: None,
}
}
#[inline]
pub(crate) fn process(&mut self, curr: &DifficultyObject<'_>) {
self.kind.pre_process();
self.curr_section_peak = self.strain_value_at(curr).max(self.curr_section_peak);
self.prev_time = Some(curr.base.time);
self.kind.post_process(curr);
}
#[inline]
pub(crate) fn save_current_peak(&mut self) {
self.strain_peaks.push(self.curr_section_peak);
}
#[inline]
pub(crate) fn start_new_section_from(&mut self, time: f32) {
// The maximum strain of the new section is not zero by default
self.curr_section_peak = self.calculate_initial_strain(time);
}
pub(crate) fn difficulty_value(&mut self) -> f32 {
let mut difficulty = 0.0;
let mut weight = 1.0;
let decay_weight = self.kind.decay_weight();
let (reduced_section_count, difficulty_multiplier) = self.kind.difficulty_values();
let reduced_section_count_f32 = reduced_section_count as f32;
self.strain_peaks
.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
let peaks = self.strain_peaks.iter_mut();
for (i, strain) in peaks.take(reduced_section_count).enumerate() {
let clamped = (i as f32 / reduced_section_count_f32).clamp(0.0, 1.0);
let scale = (math_util::lerp(1.0, 10.0, clamped)).log10();
*strain *= math_util::lerp(REDUCED_STRAIN_BASELINE, 1.0, scale);
}
self.strain_peaks
.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
for &strain in &self.strain_peaks {
difficulty += strain * weight;
weight *= decay_weight;
}
difficulty * difficulty_multiplier
}
pub(crate) fn calculate_initial_strain(&self, time: f32) -> f32 {
let prev_time = self.prev_time.unwrap_or(0.0);
let decayed_strain = self.curr_strain * self.kind.strain_decay(time - prev_time);
match &self.kind {
SkillKind::Aim | SkillKind::Flashlight { .. } => decayed_strain,
SkillKind::Speed { curr_rhythm, .. } => curr_rhythm * decayed_strain,
}
}
pub(crate) fn strain_value_at(&mut self, curr: &DifficultyObject<'_>) -> f32 {
self.curr_strain *= self.kind.strain_decay(curr.delta);
self.curr_strain += self.kind.strain_value_of(curr) * self.kind.skill_multiplier();
match &mut self.kind {
SkillKind::Aim | SkillKind::Flashlight { .. } => self.curr_strain,
SkillKind::Speed {
curr_rhythm,
history,
hit_window,
} => {
*curr_rhythm = calculate_speed_rhythm_bonus(curr, history, *hit_window);
self.curr_strain * *curr_rhythm
}
}
}
}
-396
View File
@@ -1,396 +0,0 @@
use std::{collections::VecDeque, f32::consts::PI, iter};
use crate::{math_util, parse::Pos2};
use super::DifficultyObject;
const SINGLE_SPACING_TRESHOLD: f32 = 125.0;
const MIN_SPEED_BONUS: f32 = 75.0;
const SPEED_BALANCING_FACTOR: f32 = 40.0;
const TIMING_THRESHOLD: f32 = 107.0;
const AIM_SKILL_MULTIPLIER: f32 = 26.25;
const AIM_STRAIN_DECAY_BASE: f32 = 0.15;
const AIM_DECAY_WEIGHT: f32 = 0.9;
const AIM_DIFFICULTY_MULTIPLIER: f32 = 1.06;
const AIM_REDUCED_SECTION_COUNT: usize = 10;
const AIM_ANGLE_BONUS_BEGIN: f32 = std::f32::consts::FRAC_PI_3;
const SPEED_SKILL_MULTIPLIER: f32 = 1375.0;
const SPEED_STRAIN_DECAY_BASE: f32 = 0.3;
const SPEED_DECAY_WEIGHT: f32 = 0.9;
const SPEED_DIFFICULTY_MULTIPLIER: f32 = 1.04;
const SPEED_REDUCED_SECTION_COUNT: usize = 5;
const SPEED_HISTORY_LENGTH: usize = 32;
const SPEED_HISTORY_TIME_MAX: f32 = 5000.0;
const SPEED_RHYTHM_MULTIPLIER: f32 = 0.75;
const FLASHLIGHT_SKILL_MULTIPLIER: f32 = 0.15;
const FLASHLIGHT_STRAIN_DECAY_BASE: f32 = 0.15;
const FLASHLIGHT_DECAY_WEIGHT: f32 = 1.0;
const FLASHLIGHT_DIFFICULTY_MULTIPLIER: f32 = 1.06;
const FLASHLIGHT_REDUCED_SECTION_COUNT: usize = 10;
const FLASHLIGHT_HISTORY_LENGTH: usize = 10;
pub(crate) struct FlashlightHistoryEntry {
end_pos: Pos2,
is_spinner: bool,
jump_dist: f32,
strain_time: f32,
}
impl From<&DifficultyObject<'_>> for FlashlightHistoryEntry {
fn from(h: &DifficultyObject<'_>) -> Self {
Self {
end_pos: h.base.end_pos(),
is_spinner: h.base.is_spinner(),
jump_dist: h.jump_dist,
strain_time: h.strain_time,
}
}
}
pub(crate) struct SpeedHistoryEntry {
is_slider: bool,
start_time: f32,
strain_time: f32,
}
impl From<&DifficultyObject<'_>> for SpeedHistoryEntry {
fn from(h: &DifficultyObject<'_>) -> Self {
Self {
is_slider: h.base.is_slider(),
start_time: h.base.time,
strain_time: h.strain_time,
}
}
}
pub(crate) enum SkillKind {
Aim,
Flashlight {
history: VecDeque<FlashlightHistoryEntry>,
scaling_factor: f32,
},
Speed {
curr_rhythm: f32,
history: VecDeque<SpeedHistoryEntry>,
hit_window: f32,
},
}
impl SkillKind {
pub(crate) fn flashlight(scaling_factor: f32) -> Self {
Self::Flashlight {
history: VecDeque::with_capacity(FLASHLIGHT_HISTORY_LENGTH),
scaling_factor,
}
}
pub(crate) fn speed(hit_window: f32) -> Self {
Self::Speed {
curr_rhythm: 1.0,
history: VecDeque::with_capacity(SPEED_HISTORY_LENGTH),
hit_window,
}
}
pub(crate) fn pre_process(&mut self) {
match self {
Self::Aim => {}
Self::Flashlight { history, .. } => history.truncate(FLASHLIGHT_HISTORY_LENGTH),
Self::Speed { history, .. } => history.truncate(SPEED_HISTORY_LENGTH),
}
}
pub(crate) fn post_process(&mut self, current: &DifficultyObject<'_>) {
match self {
Self::Aim => {}
Self::Flashlight { history, .. } => history.push_front(current.into()),
Self::Speed { history, .. } => history.push_front(current.into()),
}
}
pub(crate) fn strain_value_of(&self, curr: &DifficultyObject<'_>) -> f32 {
match self {
Self::Aim => {
if curr.base.is_spinner() {
return 0.0;
}
let mut aim_strain = 0.0;
if let Some((prev_jump_dist, prev_strain_time)) = curr.prev {
if let Some(angle) = curr.angle.filter(|a| *a > AIM_ANGLE_BONUS_BEGIN) {
let scale = 90.0;
let angle_bonus = (((angle - AIM_ANGLE_BONUS_BEGIN).sin()).powi(2)
* (prev_jump_dist - scale).max(0.0)
* (curr.jump_dist - scale).max(0.0))
.sqrt();
aim_strain = 1.4 * apply_diminishing_exp(angle_bonus.max(0.0))
/ (TIMING_THRESHOLD).max(prev_strain_time)
}
}
let jump_dist_exp = apply_diminishing_exp(curr.jump_dist);
let travel_dist_exp = apply_diminishing_exp(curr.travel_dist);
let dist_exp =
jump_dist_exp + travel_dist_exp + (travel_dist_exp * jump_dist_exp).sqrt();
(aim_strain + dist_exp / (curr.strain_time).max(TIMING_THRESHOLD))
.max(dist_exp / curr.strain_time)
}
Self::Flashlight {
history,
scaling_factor,
} => {
if curr.base.is_spinner() {
return 0.0;
}
let mut small_dist_nerf = 1.0;
let mut result = 0.0;
let mut cumulative_strain_time = 0.0;
let mut history = history.iter();
if let Some(prev) = history.next() {
// Handle first entry distinctly for slight optimization
if !prev.is_spinner {
let jump_dist = (curr.base.pos - prev.end_pos).length();
cumulative_strain_time += prev.strain_time;
// * We want to nerf objects that can be easily seen within the Flashlight circle radius
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 = ((prev.jump_dist / scaling_factor) / 25.0).min(1.0);
result += stack_nerf * scaling_factor * jump_dist / cumulative_strain_time;
}
let factors = iter::successors(Some(0.8), |s| Some(s * 0.8));
for (factor, prev) in factors.zip(history) {
if !prev.is_spinner {
let jump_dist = (curr.base.pos - prev.end_pos).length();
cumulative_strain_time += prev.strain_time;
// * We also want to nerf stacks so that only the first object of the stack is accounted for
let stack_nerf = ((prev.jump_dist / scaling_factor) / 25.0).min(1.0);
result += factor * stack_nerf * scaling_factor * jump_dist
/ cumulative_strain_time;
}
}
}
result *= small_dist_nerf;
result * result
}
Self::Speed {
history,
hit_window,
..
} => {
if curr.base.is_spinner() {
return 0.0;
}
let mut strain_time = curr.strain_time;
let hit_window_full = hit_window * 2.0;
let speed_window_ratio = strain_time / hit_window_full;
let prev = history.front();
// * Aim to nerf cheesy rhythms (very fast consecutive doubles with large delta times between)
if let Some(prev) =
prev.filter(|p| strain_time < hit_window_full && p.strain_time > strain_time)
{
strain_time =
math_util::lerp(prev.strain_time, strain_time, speed_window_ratio);
}
// * Cap delta time to the OD 300 hit window
// * 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly,
// * whilst 0.92 limits the effect of the cap
strain_time /= (strain_time / hit_window_full / 0.93).clamp(0.92, 1.0);
// * Derive speed bonus for calculation
let mut speed_bonus = 1.0;
if strain_time < MIN_SPEED_BONUS {
let base = (MIN_SPEED_BONUS - strain_time) / SPEED_BALANCING_FACTOR;
speed_bonus = 1.0 + 0.75 * base * base;
}
let dist = SINGLE_SPACING_TRESHOLD.min(curr.travel_dist + curr.jump_dist);
(speed_bonus + speed_bonus * (dist / SINGLE_SPACING_TRESHOLD).powf(3.5))
/ strain_time
}
}
}
#[inline]
pub(crate) fn difficulty_values(&self) -> (usize, f32) {
match self {
Self::Aim => (AIM_REDUCED_SECTION_COUNT, AIM_DIFFICULTY_MULTIPLIER),
Self::Flashlight { .. } => (
FLASHLIGHT_REDUCED_SECTION_COUNT,
FLASHLIGHT_DIFFICULTY_MULTIPLIER,
),
Self::Speed { .. } => (SPEED_REDUCED_SECTION_COUNT, SPEED_DIFFICULTY_MULTIPLIER),
}
}
#[inline]
pub(crate) fn skill_multiplier(&self) -> f32 {
match self {
SkillKind::Aim => AIM_SKILL_MULTIPLIER,
SkillKind::Flashlight { .. } => FLASHLIGHT_SKILL_MULTIPLIER,
SkillKind::Speed { .. } => SPEED_SKILL_MULTIPLIER,
}
}
#[inline]
pub(crate) fn strain_decay_base(&self) -> f32 {
match self {
SkillKind::Aim => AIM_STRAIN_DECAY_BASE,
SkillKind::Flashlight { .. } => FLASHLIGHT_STRAIN_DECAY_BASE,
SkillKind::Speed { .. } => SPEED_STRAIN_DECAY_BASE,
}
}
#[inline]
pub(crate) fn decay_weight(&self) -> f32 {
match self {
SkillKind::Aim => AIM_DECAY_WEIGHT,
SkillKind::Flashlight { .. } => FLASHLIGHT_DECAY_WEIGHT,
SkillKind::Speed { .. } => SPEED_DECAY_WEIGHT,
}
}
#[inline]
pub(crate) fn strain_decay(&self, ms: f32) -> f32 {
self.strain_decay_base().powf(ms / 1000.0)
}
}
pub(crate) fn calculate_speed_rhythm_bonus(
current: &DifficultyObject<'_>,
history: &VecDeque<SpeedHistoryEntry>,
hit_window: f32,
) -> f32 {
if current.base.is_spinner() {
return 0.0;
}
let mut prev_island_size = 0;
let mut rhythm_complexity_sum = 0.0;
let mut island_size = 1;
let mut first_delta_switch = false;
let adjusted_hit_window = hit_window * 0.6;
let history_len = history.len() as f32;
// * Store the ratio of the current start of an island to buff for tighter rhythms
let mut start_ratio = 0.0;
let currs = history.iter();
let prevs = history.iter().skip(1);
let lasts = history.iter().skip(2);
for (((prev, curr), last), i) in prevs.zip(currs).zip(lasts).rev().zip(2..) {
let mut curr_historical_decay =
(SPEED_HISTORY_TIME_MAX - (current.base.time - curr.start_time)).max(0.0)
/ SPEED_HISTORY_TIME_MAX;
if curr_historical_decay.abs() > f32::EPSILON {
// * Either we're limited by time or limited by object count
curr_historical_decay = curr_historical_decay.min(i as f32 / history_len);
let curr_delta = curr.strain_time;
let prev_delta = prev.strain_time;
let last_delta = last.strain_time;
// * Fancy function to calculate rhythm bonuses
let base = (PI / (prev_delta.min(curr_delta) / prev_delta.max(curr_delta))).sin();
let curr_ratio = 1.0 + 6.0 * (base * base).min(0.5);
let lower_penalty = ((prev_delta - curr_delta).abs() - adjusted_hit_window).max(0.0);
let window_penalty = (lower_penalty / adjusted_hit_window).min(1.0);
let mut effective_ratio = window_penalty * curr_ratio;
if first_delta_switch {
if !(prev_delta > 1.25 * curr_delta || prev_delta * 1.25 < curr_delta) {
if island_size < 7 {
island_size += 1;
}
} else {
if curr.is_slider {
// * bpm change is into slider, this is easy acc window
effective_ratio *= 0.125;
}
if prev.is_slider {
// * bpm change was from a slider, this is easier typically than circle -> circle
effective_ratio *= 0.25;
}
if prev_island_size == island_size {
// * repeated island size (ex: triplet -> triplet)
effective_ratio *= 0.25;
}
if prev_island_size % 2 == island_size % 2 {
// * repeated island polarity (2 -> 4, 3 -> 5)
effective_ratio *= 0.5;
}
if last_delta > prev_delta + 10.0 && prev_delta > curr_delta + 10.0 {
// * previous increase happened a note ago, 1/1 -> 1/2-1/4, don't want to buff this
effective_ratio *= 0.125;
}
rhythm_complexity_sum += (effective_ratio * start_ratio).sqrt()
* curr_historical_decay
* ((4 + island_size) as f32).sqrt()
* ((4 + prev_island_size) as f32).sqrt()
/ 4.0;
start_ratio = effective_ratio;
prev_island_size = island_size;
island_size = 1;
// * we're slowing down, stop counting
if prev_delta * 1.25 < curr_delta {
// * if we're speeding up, this stays true and we keep counting island size
first_delta_switch = false;
}
}
} else if prev_delta > 1.25 * curr_delta {
// * we want to be speeding up
// * begin counting island until we change speed again
first_delta_switch = true;
start_ratio = effective_ratio;
island_size = 1;
}
}
}
// * produces multiplier that can be applied to strain. range [1, infinity) (not really though)
(4.0 + rhythm_complexity_sum * SPEED_RHYTHM_MULTIPLIER).sqrt() / 2.0
}
#[inline]
fn apply_diminishing_exp(val: f32) -> f32 {
val.powf(0.99)
}
-57
View File
@@ -1,57 +0,0 @@
use std::f32::NEG_INFINITY;
use crate::{Beatmap, ControlPoint, ControlPointIter};
pub(crate) struct SliderState<'p> {
control_points: ControlPointIter<'p>,
next_time: f32,
px_per_beat: f32,
prev_sv: f32,
}
impl<'p> SliderState<'p> {
#[inline]
pub(crate) fn new(map: &'p Beatmap) -> Self {
Self {
control_points: ControlPointIter::new(map),
next_time: NEG_INFINITY,
px_per_beat: 1.0,
prev_sv: 1.0,
}
}
pub(crate) fn count_ticks(
&mut self,
time: f32,
pixel_len: f32,
span_count: usize,
map: &Beatmap,
) -> usize {
while time >= self.next_time {
self.px_per_beat = map.slider_mult * 100.0 * self.prev_sv;
match self.control_points.next() {
Some(ControlPoint::Timing { time, .. }) => {
self.next_time = time;
self.prev_sv = 1.0;
}
Some(ControlPoint::Difficulty {
time,
slider_velocity,
}) => {
self.next_time = time;
self.prev_sv = slider_velocity;
}
None => break,
}
}
let spans = span_count as f32;
let beats = pixel_len * spans / self.px_per_beat;
let ticks = ((beats - 0.1) / spans * map.tick_rate).ceil() as usize;
ticks
.checked_sub(1)
.map_or(0, |ticks| ticks * span_count + span_count + 1)
}
}
+525 -10
View File
@@ -1,22 +1,537 @@
#![cfg(feature = "osu")]
mod difficulty_object;
mod osu_object;
mod pp;
mod scaling_factor;
mod skill;
mod skill_kind;
mod slider_state;
use difficulty_object::DifficultyObject;
use osu_object::{ObjectParameters, OsuObject};
pub use pp::*;
use scaling_factor::ScalingFactor;
use skill::Skill;
use skill_kind::SkillKind;
use slider_state::SliderState;
#[cfg(feature = "osu_precise")]
#[cfg_attr(docsrs, doc(cfg(feature = "osu_precise")))]
mod precise;
use std::mem;
#[cfg(feature = "osu_precise")]
pub use precise::*;
use crate::{curve::CurveBuffers, Beatmap, Mods, Strains};
#[cfg(feature = "osu_fast")]
#[cfg_attr(docsrs, doc(cfg(feature = "osu_fast")))]
mod fast;
const SECTION_LEN: f64 = 400.0;
const DIFFICULTY_MULTIPLIER: f64 = 0.0675;
const NORMALIZED_RADIUS: f32 = 50.0; // * diameter of 100; easier mental maths.
const STACK_DISTANCE: f32 = 3.0;
#[cfg(feature = "osu_fast")]
pub use fast::*;
/// Star calculation for osu!standard maps.
///
/// Slider paths aswell as stack leniency are considered.
/// Both of these drag the performance down but in turn the values are much more accurate
///
/// In case of a partial play, e.g. a fail, one can specify the amount of passed objects.
pub fn stars(
map: &Beatmap,
mods: impl Mods,
passed_objects: Option<usize>,
) -> DifficultyAttributes {
let take = passed_objects.unwrap_or_else(|| map.hit_objects.len());
let map_attributes = map.attributes().mods(mods);
let hit_window = difficulty_range_od(map_attributes.od) / map_attributes.clock_rate;
let od = (80.0 - hit_window) / 6.0;
if take < 2 {
return DifficultyAttributes {
ar: map_attributes.ar,
hp: map_attributes.hp,
od,
..Default::default()
};
}
let mut raw_ar = map.ar as f64;
let hr = mods.hr();
if hr {
raw_ar = (raw_ar * 1.4).min(10.0);
} else if mods.ez() {
raw_ar *= 0.5;
}
let time_preempt = difficulty_range_ar(raw_ar);
let scaling_factor = ScalingFactor::new(map_attributes.cs);
let mut params = ObjectParameters {
map,
max_combo: 0,
slider_state: SliderState::new(map),
ticks: Vec::new(),
curve_bufs: CurveBuffers::default(),
};
let hit_objects_iter = map
.hit_objects
.iter()
.take(take)
.filter_map(|h| OsuObject::new(h, hr, &mut params));
let mut hit_objects = Vec::with_capacity(take);
hit_objects.extend(hit_objects_iter);
let stack_threshold = time_preempt * map.stack_leniency as f64;
if map.version >= 6 {
stacking(&mut hit_objects, stack_threshold);
} else {
old_stacking(&mut hit_objects, stack_threshold);
}
// let scale_factor = (scaling_factor.scale * -6.4) as f32;
let mut hit_objects = hit_objects.into_iter().map(|mut h| {
// let stack_offset = Pos2::new(h.stack_height * scale_factor);
let stack_offset = scaling_factor.stack_offset(h.stack_height);
h.pos += stack_offset;
h.time /= map_attributes.clock_rate;
h
});
let fl = mods.fl();
let mut skills = Vec::with_capacity(2 + fl as usize);
skills.push(Skill::aim());
skills.push(Skill::speed(hit_window));
if fl {
// NOTE: Instead of having `NORMALIZED_RADIUS` as dividend, it still uses 52.0.
skills.push(Skill::flashlight(52.0 / scaling_factor.radius() as f64));
}
let mut prev_prev = None;
let mut prev = hit_objects.next().unwrap();
// First object has no predecessor and thus no strain, handle distinctly
let mut current_section_end = (prev.time / SECTION_LEN).ceil() * SECTION_LEN;
// Handle second object separately to remove later if-branching
let curr = hit_objects.next().unwrap();
let h = DifficultyObject::new(
&curr,
&mut prev,
prev_prev.as_ref(),
&scaling_factor,
map_attributes.clock_rate,
);
while h.base.time > current_section_end {
for skill in skills.iter_mut() {
skill.start_new_section_from(current_section_end);
}
current_section_end += SECTION_LEN;
}
for skill in skills.iter_mut() {
skill.process(&h);
}
prev_prev = Some(prev);
prev = curr;
// Handle all other objects
for curr in hit_objects {
let h = DifficultyObject::new(
&curr,
&mut prev,
prev_prev.as_ref(),
&scaling_factor,
map_attributes.clock_rate,
);
while h.base.time > current_section_end {
for skill in skills.iter_mut() {
skill.save_current_peak();
skill.start_new_section_from(current_section_end);
}
current_section_end += SECTION_LEN;
}
for skill in skills.iter_mut() {
skill.process(&h);
}
prev_prev = Some(prev);
prev = curr;
}
for skill in skills.iter_mut() {
skill.save_current_peak();
}
let aim_rating = skills[0].difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let speed_rating = if mods.rx() {
0.0
} else {
skills[1].difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER
};
let flashlight_rating = skills.get_mut(2).map_or(0.0, |skill| {
skill.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER
});
let base_aim_performance = {
let base = 5.0 * (aim_rating / 0.0675).max(1.0) - 4.0;
base * base * base / 100_000.0
};
let base_speed_performance = {
let base = 5.0 * (speed_rating / 0.0675).max(1.0) - 4.0;
base * base * base / 100_000.0
};
let base_flashlight_performance = if 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 {
1.12_f64.cbrt()
* 0.027
* ((100_000.0 / (1.0_f64 / 1.1).exp2() * base_performance).cbrt() + 4.0)
} else {
0.0
};
DifficultyAttributes {
ar: map_attributes.ar,
hp: map_attributes.hp,
od,
aim_strain: aim_rating,
speed_strain: speed_rating,
flashlight_rating,
n_circles: map.n_circles as usize,
n_sliders: map.n_sliders as usize,
n_spinners: map.n_spinners as usize,
stars: star_rating,
max_combo: params.max_combo,
}
}
/// Essentially the same as the `stars` function but instead of
/// evaluating the final strains, it just returns them as is.
///
/// Suitable to plot the difficulty of a map over time.
pub fn strains(map: &Beatmap, mods: impl Mods) -> Strains {
let map_attributes = map.attributes().mods(mods);
let hit_window = difficulty_range_od(map_attributes.od) / map_attributes.clock_rate;
if map.hit_objects.len() < 2 {
return Strains::default();
}
let mut raw_ar = map.ar as f64;
let hr = mods.hr();
if hr {
raw_ar *= 1.4;
} else if mods.ez() {
raw_ar *= 0.5;
}
let time_preempt = difficulty_range_ar(raw_ar);
let scaling_factor = ScalingFactor::new(map_attributes.cs);
let mut params = ObjectParameters {
map,
max_combo: 0,
slider_state: SliderState::new(map),
ticks: Vec::new(),
curve_bufs: CurveBuffers::default(),
};
let hit_objects_iter = map
.hit_objects
.iter()
.filter_map(|h| OsuObject::new(h, hr, &mut params));
let mut hit_objects = Vec::with_capacity(map.hit_objects.len());
hit_objects.extend(hit_objects_iter);
let stack_threshold = time_preempt * map.stack_leniency as f64;
if map.version >= 6 {
stacking(&mut hit_objects, stack_threshold);
} else {
old_stacking(&mut hit_objects, stack_threshold);
}
// let scale_factor = (scaling_factor.scale * -6.4) as f32;
let mut hit_objects = hit_objects.into_iter().map(|mut h| {
// let stack_offset = Pos2::new(h.stack_height * scale_factor);
let stack_offset = scaling_factor.stack_offset(h.stack_height);
h.pos += stack_offset;
h.time /= map_attributes.clock_rate;
h
});
let fl = mods.fl();
let mut skills = Vec::with_capacity(2 + fl as usize);
skills.push(Skill::aim());
skills.push(Skill::speed(hit_window));
if fl {
// NOTE: Instead of having `NORMALIZED_RADIUS` as dividend, it still uses 52.0.
skills.push(Skill::flashlight(52.0 / scaling_factor.radius() as f64));
}
let mut prev_prev = None;
let mut prev = hit_objects.next().unwrap();
// First object has no predecessor and thus no strain, handle distinctly
let mut current_section_end = (prev.time / SECTION_LEN).ceil() * SECTION_LEN;
// Handle second object separately to remove later if-branching
let curr = hit_objects.next().unwrap();
let h = DifficultyObject::new(
&curr,
&mut prev,
prev_prev.as_ref(),
&scaling_factor,
map_attributes.clock_rate,
);
while h.base.time > current_section_end {
for skill in skills.iter_mut() {
skill.start_new_section_from(current_section_end);
}
current_section_end += SECTION_LEN;
}
for skill in skills.iter_mut() {
skill.process(&h);
}
prev_prev = Some(prev);
prev = curr;
// Handle all other objects
for curr in hit_objects {
let h = DifficultyObject::new(
&curr,
&mut prev,
prev_prev.as_ref(),
&scaling_factor,
map_attributes.clock_rate,
);
while h.base.time > current_section_end {
for skill in skills.iter_mut() {
skill.save_current_peak();
skill.start_new_section_from(current_section_end);
}
current_section_end += SECTION_LEN;
}
for skill in skills.iter_mut() {
skill.process(&h);
}
prev_prev = Some(prev);
prev = curr;
}
for skill in skills.iter_mut() {
skill.save_current_peak();
}
let mut speed_strains = skills.pop().unwrap().strain_peaks;
let mut aim_strains = skills.pop().unwrap().strain_peaks;
let strains = if let Some(mut flashlight_strains) = skills.pop().map(|s| s.strain_peaks) {
mem::swap(&mut speed_strains, &mut aim_strains);
mem::swap(&mut aim_strains, &mut flashlight_strains);
aim_strains
.into_iter()
.zip(speed_strains)
.zip(flashlight_strains)
.map(|((aim, speed), flashlight)| aim + speed + flashlight)
.collect()
} else {
aim_strains
.into_iter()
.zip(speed_strains)
.map(|(aim, speed)| aim + speed)
.collect()
};
Strains {
section_length: SECTION_LEN,
strains,
}
}
fn stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
let mut extended_start_idx = 0;
let extended_end_idx = hit_objects.len() - 1;
// First big `if` in osu!lazer's function can be skipped
for i in (1..=extended_end_idx).rev() {
let mut n = i;
let mut obj_i_idx = i;
// * We should check every note which has not yet got a stack.
// * Consider the case we have two interwound stacks and this will make sense.
// * o <-1 o <-2
// * o <-3 o <-4
// * We first process starting from 4 and handle 2,
// * then we come backwards on the i loop iteration until we reach 3 and handle 1.
// * 2 and 1 will be ignored in the i loop because they already have a stack value.
if hit_objects[obj_i_idx].stack_height.abs() > 0.0 || hit_objects[obj_i_idx].is_spinner() {
continue;
}
// * If this object is a hitcircle, then we enter this "special" case.
// * It either ends with a stack of hitcircles only,
// * or a stack of hitcircles that are underneath a slider.
// * Any other case is handled by the "is_slider" code below this.
if hit_objects[obj_i_idx].is_circle() {
loop {
n = match n.checked_sub(1) {
Some(n) => n,
None => break,
};
if hit_objects[n].is_spinner() {
continue;
} else if hit_objects[obj_i_idx].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]
.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].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;
} else if hit_objects[obj_i_idx].time - hit_objects[n].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.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 end_pos = hit_objects[i].end_pos();
let mut slider_stack = 0.0;
for j in i + 1..hit_objects.len() {
if hit_objects[j].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(end_pos) < STACK_DISTANCE {
slider_stack += 1.0;
hit_objects[j].stack_height -= slider_stack;
start_time = hit_objects[j].end_time();
}
}
}
}
#[inline]
fn difficulty_range_ar(ar: f64) -> f64 {
crate::difficulty_range(ar, 450.0, 1200.0, 1800.0)
}
/// Various data created through the star calculation.
/// This data is necessary to calculate PP.
-538
View File
@@ -1,538 +0,0 @@
//! Every aspect of osu!'s pp calculation is being used.
//! This should result in the most accurate values but with
//! drawback of being slower than `osu_fast`.
#![cfg(feature = "osu_precise")]
use std::mem;
mod difficulty_object;
mod osu_object;
mod scaling_factor;
mod skill;
mod skill_kind;
mod slider_state;
use difficulty_object::DifficultyObject;
use osu_object::{ObjectParameters, OsuObject};
use scaling_factor::ScalingFactor;
use skill::Skill;
use skill_kind::SkillKind;
use slider_state::SliderState;
use crate::{curve::CurveBuffers, Beatmap, Mods, Strains};
use super::DifficultyAttributes;
const SECTION_LEN: f64 = 400.0;
const DIFFICULTY_MULTIPLIER: f64 = 0.0675;
const NORMALIZED_RADIUS: f32 = 50.0; // * diameter of 100; easier mental maths.
const STACK_DISTANCE: f32 = 3.0;
/// Star calculation for osu!standard maps.
///
/// Slider paths aswell as stack leniency are considered.
/// Both of these drag the performance down but in turn the values are much more accurate
///
/// In case of a partial play, e.g. a fail, one can specify the amount of passed objects.
pub fn stars(
map: &Beatmap,
mods: impl Mods,
passed_objects: Option<usize>,
) -> DifficultyAttributes {
let take = passed_objects.unwrap_or_else(|| map.hit_objects.len());
let map_attributes = map.attributes().mods(mods);
let hit_window = super::difficulty_range_od(map_attributes.od) / map_attributes.clock_rate;
let od = (80.0 - hit_window) / 6.0;
if take < 2 {
return DifficultyAttributes {
ar: map_attributes.ar,
hp: map_attributes.hp,
od,
..Default::default()
};
}
let mut raw_ar = map.ar as f64;
let hr = mods.hr();
if hr {
raw_ar = (raw_ar * 1.4).min(10.0);
} else if mods.ez() {
raw_ar *= 0.5;
}
let time_preempt = difficulty_range_ar(raw_ar);
let scaling_factor = ScalingFactor::new(map_attributes.cs);
let mut params = ObjectParameters {
map,
max_combo: 0,
slider_state: SliderState::new(map),
ticks: Vec::new(),
curve_bufs: CurveBuffers::default(),
};
let hit_objects_iter = map
.hit_objects
.iter()
.take(take)
.filter_map(|h| OsuObject::new(h, hr, &mut params));
let mut hit_objects = Vec::with_capacity(take);
hit_objects.extend(hit_objects_iter);
let stack_threshold = time_preempt * map.stack_leniency as f64;
if map.version >= 6 {
stacking(&mut hit_objects, stack_threshold);
} else {
old_stacking(&mut hit_objects, stack_threshold);
}
// let scale_factor = (scaling_factor.scale * -6.4) as f32;
let mut hit_objects = hit_objects.into_iter().map(|mut h| {
// let stack_offset = Pos2::new(h.stack_height * scale_factor);
let stack_offset = scaling_factor.stack_offset(h.stack_height);
h.pos += stack_offset;
h.time /= map_attributes.clock_rate;
h
});
let fl = mods.fl();
let mut skills = Vec::with_capacity(2 + fl as usize);
skills.push(Skill::aim());
skills.push(Skill::speed(hit_window));
if fl {
// NOTE: Instead of having `NORMALIZED_RADIUS` as dividend, it still uses 52.0.
skills.push(Skill::flashlight(52.0 / scaling_factor.radius() as f64));
}
let mut prev_prev = None;
let mut prev = hit_objects.next().unwrap();
// First object has no predecessor and thus no strain, handle distinctly
let mut current_section_end = (prev.time / SECTION_LEN).ceil() * SECTION_LEN;
// Handle second object separately to remove later if-branching
let curr = hit_objects.next().unwrap();
let h = DifficultyObject::new(
&curr,
&mut prev,
prev_prev.as_ref(),
&scaling_factor,
map_attributes.clock_rate,
);
while h.base.time > current_section_end {
for skill in skills.iter_mut() {
skill.start_new_section_from(current_section_end);
}
current_section_end += SECTION_LEN;
}
for skill in skills.iter_mut() {
skill.process(&h);
}
prev_prev = Some(prev);
prev = curr;
// Handle all other objects
for curr in hit_objects {
let h = DifficultyObject::new(
&curr,
&mut prev,
prev_prev.as_ref(),
&scaling_factor,
map_attributes.clock_rate,
);
while h.base.time > current_section_end {
for skill in skills.iter_mut() {
skill.save_current_peak();
skill.start_new_section_from(current_section_end);
}
current_section_end += SECTION_LEN;
}
for skill in skills.iter_mut() {
skill.process(&h);
}
prev_prev = Some(prev);
prev = curr;
}
for skill in skills.iter_mut() {
skill.save_current_peak();
}
let aim_rating = skills[0].difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let speed_rating = if mods.rx() {
0.0
} else {
skills[1].difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER
};
let flashlight_rating = skills.get_mut(2).map_or(0.0, |skill| {
skill.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER
});
let base_aim_performance = {
let base = 5.0 * (aim_rating / 0.0675).max(1.0) - 4.0;
base * base * base / 100_000.0
};
let base_speed_performance = {
let base = 5.0 * (speed_rating / 0.0675).max(1.0) - 4.0;
base * base * base / 100_000.0
};
let base_flashlight_performance = if 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 {
1.12_f64.cbrt()
* 0.027
* ((100_000.0 / (1.0_f64 / 1.1).exp2() * base_performance).cbrt() + 4.0)
} else {
0.0
};
DifficultyAttributes {
ar: map_attributes.ar,
hp: map_attributes.hp,
od,
aim_strain: aim_rating,
speed_strain: speed_rating,
flashlight_rating,
n_circles: map.n_circles as usize,
n_sliders: map.n_sliders as usize,
n_spinners: map.n_spinners as usize,
stars: star_rating,
max_combo: params.max_combo,
}
}
/// Essentially the same as the `stars` function but instead of
/// evaluating the final strains, it just returns them as is.
///
/// Suitable to plot the difficulty of a map over time.
pub fn strains(map: &Beatmap, mods: impl Mods) -> Strains {
let map_attributes = map.attributes().mods(mods);
let hit_window = super::difficulty_range_od(map_attributes.od) / map_attributes.clock_rate;
if map.hit_objects.len() < 2 {
return Strains::default();
}
let mut raw_ar = map.ar as f64;
let hr = mods.hr();
if hr {
raw_ar *= 1.4;
} else if mods.ez() {
raw_ar *= 0.5;
}
let time_preempt = difficulty_range_ar(raw_ar);
let scaling_factor = ScalingFactor::new(map_attributes.cs);
let mut params = ObjectParameters {
map,
max_combo: 0,
slider_state: SliderState::new(map),
ticks: Vec::new(),
curve_bufs: CurveBuffers::default(),
};
let hit_objects_iter = map
.hit_objects
.iter()
.filter_map(|h| OsuObject::new(h, hr, &mut params));
let mut hit_objects = Vec::with_capacity(map.hit_objects.len());
hit_objects.extend(hit_objects_iter);
let stack_threshold = time_preempt * map.stack_leniency as f64;
if map.version >= 6 {
stacking(&mut hit_objects, stack_threshold);
} else {
old_stacking(&mut hit_objects, stack_threshold);
}
// let scale_factor = (scaling_factor.scale * -6.4) as f32;
let mut hit_objects = hit_objects.into_iter().map(|mut h| {
// let stack_offset = Pos2::new(h.stack_height * scale_factor);
let stack_offset = scaling_factor.stack_offset(h.stack_height);
h.pos += stack_offset;
h.time /= map_attributes.clock_rate;
h
});
let fl = mods.fl();
let mut skills = Vec::with_capacity(2 + fl as usize);
skills.push(Skill::aim());
skills.push(Skill::speed(hit_window));
if fl {
// NOTE: Instead of having `NORMALIZED_RADIUS` as dividend, it still uses 52.0.
skills.push(Skill::flashlight(52.0 / scaling_factor.radius() as f64));
}
let mut prev_prev = None;
let mut prev = hit_objects.next().unwrap();
// First object has no predecessor and thus no strain, handle distinctly
let mut current_section_end = (prev.time / SECTION_LEN).ceil() * SECTION_LEN;
// Handle second object separately to remove later if-branching
let curr = hit_objects.next().unwrap();
let h = DifficultyObject::new(
&curr,
&mut prev,
prev_prev.as_ref(),
&scaling_factor,
map_attributes.clock_rate,
);
while h.base.time > current_section_end {
for skill in skills.iter_mut() {
skill.start_new_section_from(current_section_end);
}
current_section_end += SECTION_LEN;
}
for skill in skills.iter_mut() {
skill.process(&h);
}
prev_prev = Some(prev);
prev = curr;
// Handle all other objects
for curr in hit_objects {
let h = DifficultyObject::new(
&curr,
&mut prev,
prev_prev.as_ref(),
&scaling_factor,
map_attributes.clock_rate,
);
while h.base.time > current_section_end {
for skill in skills.iter_mut() {
skill.save_current_peak();
skill.start_new_section_from(current_section_end);
}
current_section_end += SECTION_LEN;
}
for skill in skills.iter_mut() {
skill.process(&h);
}
prev_prev = Some(prev);
prev = curr;
}
for skill in skills.iter_mut() {
skill.save_current_peak();
}
let mut speed_strains = skills.pop().unwrap().strain_peaks;
let mut aim_strains = skills.pop().unwrap().strain_peaks;
let strains = if let Some(mut flashlight_strains) = skills.pop().map(|s| s.strain_peaks) {
mem::swap(&mut speed_strains, &mut aim_strains);
mem::swap(&mut aim_strains, &mut flashlight_strains);
aim_strains
.into_iter()
.zip(speed_strains)
.zip(flashlight_strains)
.map(|((aim, speed), flashlight)| aim + speed + flashlight)
.collect()
} else {
aim_strains
.into_iter()
.zip(speed_strains)
.map(|(aim, speed)| aim + speed)
.collect()
};
Strains {
section_length: SECTION_LEN,
strains,
}
}
fn stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
let mut extended_start_idx = 0;
let extended_end_idx = hit_objects.len() - 1;
// First big `if` in osu!lazer's function can be skipped
for i in (1..=extended_end_idx).rev() {
let mut n = i;
let mut obj_i_idx = i;
// * We should check every note which has not yet got a stack.
// * Consider the case we have two interwound stacks and this will make sense.
// * o <-1 o <-2
// * o <-3 o <-4
// * We first process starting from 4 and handle 2,
// * then we come backwards on the i loop iteration until we reach 3 and handle 1.
// * 2 and 1 will be ignored in the i loop because they already have a stack value.
if hit_objects[obj_i_idx].stack_height.abs() > 0.0 || hit_objects[obj_i_idx].is_spinner() {
continue;
}
// * If this object is a hitcircle, then we enter this "special" case.
// * It either ends with a stack of hitcircles only,
// * or a stack of hitcircles that are underneath a slider.
// * Any other case is handled by the "is_slider" code below this.
if hit_objects[obj_i_idx].is_circle() {
loop {
n = match n.checked_sub(1) {
Some(n) => n,
None => break,
};
if hit_objects[n].is_spinner() {
continue;
} else if hit_objects[obj_i_idx].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]
.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].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;
} else if hit_objects[obj_i_idx].time - hit_objects[n].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.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 end_pos = hit_objects[i].end_pos();
let mut slider_stack = 0.0;
for j in i + 1..hit_objects.len() {
if hit_objects[j].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(end_pos) < STACK_DISTANCE {
slider_stack += 1.0;
hit_objects[j].stack_height -= slider_stack;
start_time = hit_objects[j].end_time();
}
}
}
}
#[inline]
fn difficulty_range_ar(ar: f64) -> f64 {
crate::difficulty_range(ar, 450.0, 1200.0, 1800.0)
}