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
+113
View File
@@ -0,0 +1,113 @@
use crate::catch::performance::CatchPerformance;
/// The result of a difficulty calculation on an osu!catch map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CatchDifficultyAttributes {
/// The final star rating
pub stars: f64,
/// The approach rate.
pub ar: f64,
/// The amount of fruits.
pub n_fruits: u32,
/// The amount of droplets.
pub n_droplets: u32,
/// The amount of tiny droplets.
pub n_tiny_droplets: u32,
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
///
/// [`Beatmap`]: crate::model::beatmap::Beatmap
pub is_convert: bool,
}
impl CatchDifficultyAttributes {
/// Return the maximum combo.
pub const fn max_combo(&self) -> u32 {
self.n_fruits + self.n_droplets
}
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
///
/// [`Beatmap`]: crate::model::beatmap::Beatmap
pub const fn is_convert(&self) -> bool {
self.is_convert
}
/// Returns a builder for performance calculation.
pub fn pp<'a>(self) -> CatchPerformance<'a> {
self.into()
}
}
/// The result of a performance calculation on an osu!catch map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CatchPerformanceAttributes {
/// The difficulty attributes that were used for the performance calculation
pub difficulty: CatchDifficultyAttributes,
/// The final performance points.
pub pp: f64,
}
impl CatchPerformanceAttributes {
/// 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()
}
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
///
/// [`Beatmap`]: crate::model::beatmap::Beatmap
pub const fn is_convert(&self) -> bool {
self.difficulty.is_convert
}
}
pub struct CatchDifficultyAttributesBuilder {
inner: CatchDifficultyAttributes,
take: usize,
}
impl CatchDifficultyAttributesBuilder {
pub const fn new(attrs: CatchDifficultyAttributes, take: usize) -> Self {
Self { inner: attrs, take }
}
pub const fn into_inner(self) -> CatchDifficultyAttributes {
self.inner
}
pub const fn take_more(&self) -> bool {
self.take > 0
}
pub fn inc_fruits(&mut self) {
Self::inc_value(&mut self.inner.n_fruits, &mut self.take);
}
pub fn inc_droplets(&mut self) {
Self::inc_value(&mut self.inner.n_droplets, &mut self.take);
}
/// Should only be used if [`take_more`] returns `true`.
///
/// [`take_more`]: Self::take_more
pub fn inc_tiny_droplets(&mut self) {
self.inner.n_tiny_droplets += 1;
}
fn inc_value(value: &mut u32, take: &mut usize) {
if *take > 0 {
*value += 1;
*take -= 1;
}
}
}
-95
View File
@@ -1,95 +0,0 @@
use crate::parse::Pos2;
use super::fruit_or_juice::FruitParams;
const PLAYFIELD_WIDTH: f32 = 512.0;
const BASE_SPEED: f64 = 1.0;
/// A [`HitObject`](crate::parse::HitObject) that was processed for the osu!ctb gamemode.
#[derive(Clone, Debug, PartialEq)]
pub struct CatchObject {
/// The X position of the object.
pub pos: f32,
/// The time of the object.
pub time: f64,
/// Whether the object is a hyper dash.
pub hyper_dash: bool,
/// The hyper distance to the next object
pub hyper_dist: f32,
}
impl CatchObject {
#[inline]
pub(crate) const fn new((pos, time): (Pos2, f64)) -> Self {
Self {
pos: pos.x,
time,
hyper_dash: false,
hyper_dist: 0.0,
}
}
pub(crate) fn with_hr(mut self, params: &mut FruitParams) -> Self {
let mut offset_pos = self.pos;
let time_diff = self.time - params.last_time;
if let Some(last_pos_ref) = params.last_pos.filter(|_| time_diff <= 1000.0) {
let pos_diff = offset_pos - last_pos_ref;
if pos_diff.abs() > f32::EPSILON {
if pos_diff.abs() < (time_diff as f32 / 3.0).floor() {
if pos_diff > 0.0 {
if offset_pos + pos_diff < PLAYFIELD_WIDTH {
offset_pos += pos_diff;
}
} else if offset_pos + pos_diff > 0.0 {
offset_pos += pos_diff;
}
}
params.last_pos.replace(offset_pos);
params.last_time = self.time;
}
self.pos = offset_pos;
} else {
params.last_pos.replace(offset_pos);
params.last_time = self.time;
}
self
}
pub(crate) fn init_hyper_dash(
&mut self,
half_catcher_width: f64,
next: &CatchObject,
last_direction: &mut i8,
last_excess: &mut f64,
) {
let next_x = next.pos;
let curr_x = self.pos;
let this_direction = i8::from(next_x > curr_x) * 2 - 1;
let time_to_next = next.time - self.time - 1000.0 / 60.0 / 4.0;
let sub = if *last_direction == this_direction {
*last_excess
} else {
half_catcher_width
};
let dist_to_next = f64::from((next_x - curr_x).abs()) - sub;
let hyper_dist = (time_to_next * BASE_SPEED - dist_to_next) as f32;
if hyper_dist < 0.0 {
self.hyper_dash = true;
*last_excess = half_catcher_width;
} else {
self.hyper_dist = hyper_dist;
*last_excess = f64::from(hyper_dist).clamp(0.0, half_catcher_width);
}
*last_direction = this_direction;
}
}
+20
View File
@@ -0,0 +1,20 @@
pub struct Catcher;
const AREA_CATCHER_SIZE: f32 = 106.75;
impl Catcher {
pub const BASE_SPEED: f64 = 1.0;
pub const ALLOWED_CATCH_RANGE: f32 = 0.8;
pub fn calculate_catch_width(cs: f32) -> f32 {
Self::calculate_catch_width_by_scale(Self::calculate_scale(cs))
}
fn calculate_catch_width_by_scale(scale: f32) -> f32 {
AREA_CATCHER_SIZE * scale.abs() * Self::ALLOWED_CATCH_RANGE
}
fn calculate_scale(cs: f32) -> f32 {
1.0 - 0.7 * (cs - 5.0) / 5.0
}
}
+315
View File
@@ -0,0 +1,315 @@
use std::borrow::Cow;
use rosu_map::section::{general::GameMode, hit_objects::CurveBuffers};
use crate::{
model::{
beatmap::{Beatmap, Converted},
hit_object::{HitObject, HitObjectKind, HoldNote, Spinner},
mode::ConvertStatus,
},
util::{float_ext::FloatExt, random::Random},
};
use super::{
attributes::CatchDifficultyAttributesBuilder,
catcher::Catcher,
object::{
banana_shower::BananaShower,
fruit::Fruit,
juice_stream::{JuiceStream, JuiceStreamBufs, NestedJuiceStreamObjectKind},
palpable::PalpableObject,
},
Catch, PLAYFIELD_WIDTH,
};
const RNG_SEED: i32 = 1337;
/// A [`Beatmap`] for [`Catch`] calculations.
pub type CatchBeatmap<'a> = Converted<'a, Catch>;
pub fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus {
match map.mode {
GameMode::Osu => {
map.to_mut().mode = GameMode::Catch;
ConvertStatus::Done
}
GameMode::Catch => ConvertStatus::Noop,
GameMode::Taiko | GameMode::Mania => ConvertStatus::Incompatible,
}
}
pub fn convert_objects(
converted: &CatchBeatmap<'_>,
attrs: &mut CatchDifficultyAttributesBuilder,
hr: bool,
cs: f32,
) -> Vec<PalpableObject> {
// TODO: check for better default capacity
let mut palpable_objects = Vec::with_capacity(converted.map.hit_objects.len() * 2);
let mut bufs = JuiceStreamBufs {
curve: CurveBuffers::default(),
nested_objects: Vec::new(), // TODO: default capacity
ticks: Vec::new(), // TODO: default capacity
};
let mut rng = Random::new(RNG_SEED);
let mut last_pos = None;
let mut last_start_time = 0.0;
for h in converted.map.hit_objects.iter() {
let mut new_objects = convert_object(h, converted, attrs, &mut bufs);
apply_pos_offset(
&mut new_objects,
hr,
&mut last_pos,
&mut last_start_time,
&mut rng,
);
palpable_objects.extend(new_objects);
}
palpable_objects.sort_by(|a, b| a.start_time.total_cmp(&b.start_time));
initialize_hyper_dash(cs, &mut palpable_objects);
palpable_objects
}
fn convert_object<'a>(
h: &'a HitObject,
converted: &CatchBeatmap<'_>,
attrs: &mut CatchDifficultyAttributesBuilder,
bufs: &'a mut JuiceStreamBufs,
) -> ObjectIter<'a> {
let state = match h.kind {
HitObjectKind::Circle => ObjectIterState::Fruit(Some(Fruit::new(attrs))),
HitObjectKind::Slider(ref slider) => {
let stream = JuiceStream::new(h.pos.x, h.start_time, slider, converted, attrs, bufs);
ObjectIterState::JuiceStream(stream)
}
HitObjectKind::Spinner(Spinner { end_time })
| HitObjectKind::Hold(HoldNote { end_time }) => {
ObjectIterState::BananaShower(BananaShower::new(h.start_time, end_time))
}
};
ObjectIter {
x: h.pos.x,
start_time: h.start_time,
state,
}
}
struct ObjectIter<'a> {
x: f32,
start_time: f64,
state: ObjectIterState<'a>,
}
enum ObjectIterState<'a> {
Fruit(Option<Fruit>),
JuiceStream(JuiceStream<'a>),
BananaShower(BananaShower),
}
impl Iterator for ObjectIter<'_> {
type Item = PalpableObject;
fn next(&mut self) -> Option<Self::Item> {
match self.state {
ObjectIterState::Fruit(ref mut fruit) => fruit
.take()
.map(|fruit| PalpableObject::new(self.x, fruit.x_offset, self.start_time)),
ObjectIterState::JuiceStream(ref mut stream) => stream
.nested_objects
.find(|nested| !matches!(nested.kind, NestedJuiceStreamObjectKind::TinyDroplet))
.map(|nested| PalpableObject::new(nested.pos, 0.0, nested.start_time)),
ObjectIterState::BananaShower(_) => None,
}
}
fn size_hint(&self) -> (usize, Option<usize>) {
let len = self.len();
(len, Some(len))
}
}
impl ExactSizeIterator for ObjectIter<'_> {
fn len(&self) -> usize {
match self.state {
ObjectIterState::Fruit(ref fruit) => usize::from(fruit.is_some()),
ObjectIterState::JuiceStream(ref stream) => stream.nested_objects.len(),
ObjectIterState::BananaShower(_) => 0,
}
}
}
fn apply_pos_offset(
hit_object: &mut ObjectIter<'_>,
should_apply_hr_offset: bool,
last_pos: &mut Option<f32>,
last_start_time: &mut f64,
rng: &mut Random,
) {
match hit_object.state {
ObjectIterState::Fruit(Some(ref mut fruit)) => {
if should_apply_hr_offset {
apply_hr_offset(
hit_object.x,
&mut fruit.x_offset,
hit_object.start_time,
last_pos,
last_start_time,
rng,
);
}
}
ObjectIterState::JuiceStream(ref stream) => {
let pos = hit_object.x
+ stream
.control_points
.last()
.map_or(0.0, |control_point| control_point.pos.x);
*last_pos = Some(pos);
*last_start_time = hit_object.start_time;
for nested in stream.nested_objects.as_slice() {
if let NestedJuiceStreamObjectKind::Droplet
| NestedJuiceStreamObjectKind::TinyDroplet = nested.kind
{
let _ = rng.next_int();
}
}
}
ObjectIterState::BananaShower(ref shower) => {
for _ in 0..shower.n_bananas {
let _ = rng.next_double();
let _ = rng.next_int();
let _ = rng.next_int();
let _ = rng.next_int();
}
}
ObjectIterState::Fruit(None) => unreachable!(),
}
}
fn apply_hr_offset(
x: f32,
x_offset: &mut f32,
start_time: f64,
last_pos: &mut Option<f32>,
last_start_time: &mut f64,
rng: &mut Random,
) {
let mut offset_pos = x;
let Some(last_pos) = last_pos else {
*last_pos = Some(offset_pos);
*last_start_time = start_time;
return;
};
let pos_diff = offset_pos - *last_pos;
let time_diff = (start_time - *last_start_time) as i32;
if time_diff > 1000 {
*last_pos = offset_pos;
*last_start_time = start_time;
return;
}
if pos_diff.eq(0.0) {
apply_random_offset(&mut offset_pos, f64::from(time_diff) / 4.0, rng);
*x_offset = offset_pos - x;
return;
}
if pos_diff.abs() < (time_diff / 3) as f32 {
apply_offset(&mut offset_pos, pos_diff);
}
*x_offset = offset_pos - x;
*last_pos = offset_pos;
*last_start_time = start_time;
}
fn apply_random_offset(pos: &mut f32, max_offset: f64, rng: &mut Random) {
let right = rng.next_bool();
let rand = (rng.next_double_range(0.0, max_offset.max(0.0)) as f32).min(20.0);
if right {
if *pos + rand <= PLAYFIELD_WIDTH {
*pos += rand;
} else {
*pos -= rand;
}
} else if *pos - rand >= 0.0 {
*pos -= rand;
} else {
*pos += rand;
}
}
fn apply_offset(pos: &mut f32, amount: f32) {
if amount > 0.0 {
if *pos + amount < PLAYFIELD_WIDTH {
*pos += amount;
}
} else if *pos + amount > 0.0 {
*pos += amount;
}
}
fn initialize_hyper_dash(cs: f32, palpable_objects: &mut [PalpableObject]) {
let mut half_catcher_width = f64::from(Catcher::calculate_catch_width(cs) / 2.0);
half_catcher_width /= f64::from(Catcher::ALLOWED_CATCH_RANGE);
let mut last_dir = 0;
let mut last_excess = half_catcher_width;
for i in 0..palpable_objects.len() - 1 {
let next = &palpable_objects[i + 1];
let curr = &palpable_objects[i];
let this_dir = if next.effective_x() > curr.effective_x() {
1
} else {
-1
};
let time_to_next = next.start_time - curr.start_time - f64::from(1000.0_f32 / 60.0 / 4.0);
let dist_to_next = f64::from((next.effective_x() - curr.effective_x()).abs())
- if last_dir == this_dir {
last_excess
} else {
half_catcher_width
};
let dist_to_hyper = (time_to_next * Catcher::BASE_SPEED - dist_to_next) as f32;
let curr = &mut palpable_objects[i];
if dist_to_hyper < 0.0 {
curr.hyper_dash = true;
last_excess = half_catcher_width;
} else {
curr.dist_to_hyper_dash = dist_to_hyper;
last_excess = f64::from(dist_to_hyper).clamp(0.0, half_catcher_width);
}
last_dir = this_dir;
}
}
+104
View File
@@ -0,0 +1,104 @@
use crate::{
any::difficulty::{skills::Skill, ModeDifficulty},
catch::{
catcher::Catcher, convert::convert_objects, difficulty::object::CatchDifficultyObject,
},
util::mods::Mods,
};
use self::skills::movement::Movement;
use super::{
attributes::{CatchDifficultyAttributes, CatchDifficultyAttributesBuilder},
convert::CatchBeatmap,
};
mod object;
mod skills;
const STAR_SCALING_FACTOR: f64 = 0.153;
pub fn difficulty(
difficulty: &ModeDifficulty,
converted: &CatchBeatmap<'_>,
) -> CatchDifficultyAttributes {
let DifficultyValues {
movement,
mut attrs,
} = DifficultyValues::calculate(difficulty, converted);
attrs.stars = movement.difficulty_value().sqrt() * STAR_SCALING_FACTOR;
attrs
}
pub struct DifficultyValues {
pub movement: Movement,
pub attrs: CatchDifficultyAttributes,
}
impl DifficultyValues {
pub fn calculate(difficulty: &ModeDifficulty, converted: &CatchBeatmap<'_>) -> Self {
let take = difficulty.get_passed_objects();
let clock_rate = difficulty.get_clock_rate();
let map_attrs = converted
.attributes()
.mods(difficulty.get_mods())
.clock_rate(clock_rate)
.build();
let attrs = CatchDifficultyAttributes {
ar: map_attrs.ar,
is_convert: converted.is_convert,
..Default::default()
};
let mut attrs = CatchDifficultyAttributesBuilder::new(attrs, take);
let hr = difficulty.get_mods().hr();
let movement = Movement::new(clock_rate);
let palpable_objects = convert_objects(converted, &mut attrs, hr, map_attrs.cs as f32);
let mut palpable_objects_iter = palpable_objects.iter().take(take);
let Some(mut last_object) = palpable_objects_iter.next() else {
return Self {
movement,
attrs: attrs.into_inner(),
};
};
let mut half_catcher_width = Catcher::calculate_catch_width(map_attrs.cs as f32) * 0.5;
half_catcher_width *= 1.0 - ((map_attrs.cs as f32 - 5.5).max(0.0) * 0.0625);
let scaling_factor =
CatchDifficultyObject::NORMALIZED_HITOBJECT_RADIUS / half_catcher_width;
let diff_objects: Vec<_> = palpable_objects_iter
.enumerate()
.map(|(i, hit_object)| {
let diff_object = CatchDifficultyObject::new(
hit_object,
last_object,
clock_rate,
scaling_factor,
i,
);
last_object = hit_object;
diff_object
})
.collect();
let mut movement = Skill::new(movement, &diff_objects);
for curr in diff_objects.iter() {
movement.process(curr);
}
Self {
movement: movement.inner,
attrs: attrs.into_inner(),
}
}
}
+56
View File
@@ -0,0 +1,56 @@
use crate::{any::difficulty::object::IDifficultyObject, catch::object::palpable::PalpableObject};
pub struct CatchDifficultyObject {
pub idx: usize,
pub start_time: f64,
pub delta_time: f64,
pub normalized_pos: f32,
pub last_normalized_pos: f32,
pub strain_time: f64,
pub last_object: LastObject,
}
impl CatchDifficultyObject {
pub const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0;
pub fn new(
hit_object: &PalpableObject,
last_object: &PalpableObject,
clock_rate: f64,
scaling_factor: f32,
idx: usize,
) -> Self {
let normalized_pos = hit_object.effective_x() * scaling_factor;
let last_normalized_pos = last_object.effective_x() * scaling_factor;
let start_time = hit_object.start_time / clock_rate;
let delta_time = (hit_object.start_time - last_object.start_time) / clock_rate;
let strain_time = delta_time.max(40.0);
let last_object = LastObject {
hyper_dash: last_object.hyper_dash,
dist_to_hyper_dash: last_object.dist_to_hyper_dash,
};
Self {
idx,
start_time,
delta_time,
normalized_pos,
last_normalized_pos,
strain_time,
last_object,
}
}
}
pub struct LastObject {
pub hyper_dash: bool,
pub dist_to_hyper_dash: f32,
}
impl IDifficultyObject for CatchDifficultyObject {
fn idx(&self) -> usize {
self.idx
}
}
+1
View File
@@ -0,0 +1 @@
pub mod movement;
+163
View File
@@ -0,0 +1,163 @@
use crate::{
any::difficulty::{
object::IDifficultyObject,
skills::{strain_decay, ISkill, Skill, StrainDecaySkill},
},
catch::difficulty::object::CatchDifficultyObject,
};
const ABSOLUTE_PLAYER_POSITIONING_ERROR: f32 = 16.0;
const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0;
const DIRECTION_CHANGE_BONUS: f64 = 21.0;
const SKILL_MULTIPLIER: f64 = 900.0;
const STRAIN_DECAY_BASE: f64 = 0.2;
const DECAY_WEIGHT: f64 = 0.94;
const SECTION_LEN: f64 = 750.0;
pub struct Movement {
inner: StrainDecaySkill,
last_player_pos: Option<f32>,
last_dist_moved: f32,
last_strain_time: f64,
clock_rate: f64,
}
impl Movement {
pub fn new(clock_rate: f64) -> Self {
Self {
inner: StrainDecaySkill::default(),
last_player_pos: None,
last_dist_moved: 0.0,
last_strain_time: 0.0,
clock_rate,
}
}
const fn curr_strain(&self) -> f64 {
self.inner.curr_strain
}
fn curr_strain_mut(&mut self) -> &mut f64 {
&mut self.inner.curr_strain
}
fn strain_value_at(&mut self, curr: &CatchDifficultyObject) -> f64 {
*self.curr_strain_mut() *= strain_decay(curr.delta_time, STRAIN_DECAY_BASE);
*self.curr_strain_mut() += self.strain_value_of(curr) * SKILL_MULTIPLIER;
self.curr_strain()
}
fn strain_value_of(&mut self, curr: &CatchDifficultyObject) -> f64 {
let last_player_pos = self.last_player_pos.unwrap_or(curr.last_normalized_pos);
let term = NORMALIZED_HITOBJECT_RADIUS - ABSOLUTE_PLAYER_POSITIONING_ERROR;
let mut player_pos =
last_player_pos.clamp(curr.normalized_pos - term, curr.normalized_pos + term);
let dist_moved = player_pos - last_player_pos;
let weighted_strain_time = curr.strain_time + 13.0 + (3.0 / self.clock_rate);
let mut dist_addition = f64::from(dist_moved.abs()).powf(1.3) / 510.0;
let sqrt_strain = weighted_strain_time.sqrt();
let mut edge_dash_bonus: f64 = 0.0;
if dist_moved.abs() > 0.1 {
if self.last_dist_moved.abs() > 0.1
&& dist_moved.signum() != self.last_dist_moved.signum()
{
let bonus_factor = f64::from(dist_moved.abs().min(50.0) / 50.0);
let anti_flow_factor =
f64::from(self.last_dist_moved.abs().min(70.0) / 70.0).max(0.38);
dist_addition += DIRECTION_CHANGE_BONUS / (self.last_strain_time + 16.0).sqrt()
* bonus_factor
* anti_flow_factor
* (1.0 - (weighted_strain_time / 1000.0).powi(3)).max(0.0);
}
dist_addition += 12.5
* f64::from(dist_moved.abs().min(NORMALIZED_HITOBJECT_RADIUS * 2.0))
/ f64::from(NORMALIZED_HITOBJECT_RADIUS * 6.0)
/ sqrt_strain;
}
if curr.last_object.dist_to_hyper_dash <= 20.0 {
if curr.last_object.hyper_dash {
player_pos = curr.normalized_pos;
} else {
edge_dash_bonus += 5.7;
}
dist_addition *= 1.0
+ edge_dash_bonus
* f64::from((20.0 - curr.last_object.dist_to_hyper_dash) / 20.0)
* ((curr.strain_time * self.clock_rate).min(265.0) / 265.0).powf(1.5);
}
self.last_player_pos = Some(player_pos);
self.last_dist_moved = dist_moved;
self.last_strain_time = curr.strain_time;
dist_addition / weighted_strain_time
}
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(DECAY_WEIGHT)
}
}
impl ISkill for Movement {
type DifficultyObjects<'a> = [CatchDifficultyObject];
}
impl<'a> Skill<'a, Movement> {
fn calculate_initial_strain(&mut self, time: f64, curr: &CatchDifficultyObject) -> 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.inner.curr_section_peak
}
fn curr_section_peak_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.inner.curr_section_peak
}
const fn curr_section_end(&self) -> f64 {
self.inner.inner.inner.curr_section_end
}
fn curr_section_end_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.inner.curr_section_end
}
pub fn process(&mut self, curr: &CatchDifficultyObject) {
if curr.idx == 0 {
*self.curr_section_end_mut() = (curr.start_time / SECTION_LEN).ceil() * 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() += SECTION_LEN;
}
let strain_value_at = self.inner.strain_value_at(curr);
*self.curr_section_peak_mut() = strain_value_at.max(self.curr_section_peak());
}
}
-46
View File
@@ -1,46 +0,0 @@
use super::CatchObject;
const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0;
pub(crate) struct DifficultyObject<'o> {
pub(crate) base: &'o CatchObject,
pub(crate) last: &'o CatchObject,
pub(crate) delta: f64,
pub(crate) start_time: f64,
pub(crate) normalized_pos: f32,
pub(crate) last_normalized_pos: f32,
pub(crate) strain_time: f64,
pub(crate) clock_rate: f64,
}
impl<'o> DifficultyObject<'o> {
#[inline]
pub(crate) fn new(
base: &'o CatchObject,
last: &'o CatchObject,
half_catcher_width: f32,
clock_rate: f64,
) -> Self {
let delta = (base.time - last.time) / clock_rate;
let start_time = base.time / clock_rate;
let strain_time = delta.max(40.0);
let scaling_factor = NORMALIZED_HITOBJECT_RADIUS / half_catcher_width;
let normalized_pos = base.pos * scaling_factor;
let last_normalized_pos = last.pos * scaling_factor;
Self {
base,
last,
delta,
start_time,
normalized_pos,
last_normalized_pos,
strain_time,
clock_rate,
}
}
}
-278
View File
@@ -1,278 +0,0 @@
use std::{iter::Map, vec::IntoIter};
use crate::{
curve::{Curve, CurveBuffers},
parse::{HitObject, HitObjectKind, Pos2},
Beatmap,
};
use super::{catch_object::CatchObject, CatchDifficultyAttributes};
const LEGACY_LAST_TICK_OFFSET: f64 = 36.0;
const BASE_SCORING_DISTANCE: f64 = 100.0;
#[derive(Clone, Debug)]
pub(crate) struct FruitParams {
pub(crate) attributes: CatchDifficultyAttributes,
pub(crate) curve_bufs: CurveBuffers,
pub(crate) last_pos: Option<f32>,
pub(crate) last_time: f64,
pub(crate) ticks: Vec<(Pos2, f64)>,
pub(crate) with_hr: bool,
}
type JuiceStream = Map<IntoIter<(Pos2, f64)>, fn((Pos2, f64)) -> CatchObject>;
#[derive(Clone, Debug)]
pub(crate) enum FruitOrJuice {
Fruit(Option<CatchObject>),
Juice(JuiceStream),
}
impl FruitOrJuice {
pub(crate) fn new(h: &HitObject, params: &mut FruitParams, map: &Beatmap) -> Option<Self> {
match &h.kind {
HitObjectKind::Circle => {
let mut h = CatchObject::new((h.pos, h.start_time));
if params.with_hr {
h = h.with_hr(params);
}
params.attributes.n_fruits += 1;
Some(FruitOrJuice::Fruit(Some(h)))
}
HitObjectKind::Slider {
pixel_len,
repeats,
control_points,
..
} => {
// HR business
params.last_pos = Some(h.pos.x + control_points[control_points.len() - 1].pos.x);
params.last_time = h.start_time;
let timing_point = map.timing_point_at(h.start_time);
let difficulty_point = map.difficulty_point_at(h.start_time).unwrap_or_default();
let vel_factor = BASE_SCORING_DISTANCE * map.slider_mult / timing_point.beat_len;
let tick_dist_factor = BASE_SCORING_DISTANCE * map.slider_mult / map.tick_rate;
let vel = vel_factor * difficulty_point.slider_vel;
let mut tick_dist = tick_dist_factor * difficulty_point.slider_vel;
let span_count = (*repeats + 1) as f64;
// Build the curve w.r.t. the control points
let curve = Curve::new(control_points, *pixel_len, &mut params.curve_bufs);
let total_duration = span_count * curve.dist() / vel;
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;
let pixel_len = pixel_len.unwrap_or(0.0);
let target = pixel_len - tick_dist / 8.0;
let mut slider_objects = vec![(h.pos, h.start_time)];
if tick_dist > 0.0 {
params.ticks.reserve((target / tick_dist) as usize);
// Tick of the first span
while curr_dist < len - min_dist_from_end {
let progress = curr_dist / len;
let pos = h.pos + curve.position_at(progress);
let time = h.start_time + progress * span_duration;
params.ticks.push((pos, time));
curr_dist += tick_dist;
}
if pixel_len > 0.0 {
let time_add = total_duration * tick_dist / (pixel_len * span_count);
params.attributes.n_tiny_droplets += tiny_droplet_count(
h.start_time,
time_add,
total_duration,
span_count as usize,
&params.ticks,
);
}
slider_objects.reserve(span_count as usize * (params.ticks.len()));
// Other spans
if *repeats == 0 {
slider_objects.append(&mut params.ticks); // automatically empties buffer for next slider
} else {
slider_objects.extend(&params.ticks);
for span_idx in 1..=*repeats {
let progress = f64::from(u8::from(span_idx % 2 == 1));
let pos = h.pos + curve.position_at(progress);
let time_offset = span_duration * span_idx as f64;
// Reverse tick
slider_objects.push((pos, h.start_time + time_offset));
// Actual ticks
if span_idx & 1 == 1 {
let tick_iter = params
.ticks
.iter()
.rev()
.zip(params.ticks.iter())
.map(|((pos, _), (_, time))| (*pos, *time + time_offset));
slider_objects.extend(tick_iter);
} else {
let tick_iter = params
.ticks
.iter()
.map(|(pos, time)| (*pos, *time + time_offset));
slider_objects.extend(tick_iter);
}
}
params.ticks.clear();
}
}
// Slider tail
let progress = f64::from(u8::from(*repeats % 2 == 0));
let pos = h.pos + curve.position_at(progress);
slider_objects.push((pos, h.start_time + total_duration));
let new_fruits = 2 + usize::from(tick_dist > 0.0) * *repeats;
params.attributes.n_fruits += new_fruits;
params.attributes.n_droplets += slider_objects.len() - new_fruits;
let iter = slider_objects
.into_iter()
.map(CatchObject::new as fn(_) -> _);
Some(FruitOrJuice::Juice(iter))
}
HitObjectKind::Spinner { .. } | HitObjectKind::Hold { .. } => None,
}
}
}
impl Iterator for FruitOrJuice {
type Item = CatchObject;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
match self {
Self::Fruit(fruit) => fruit.take(),
Self::Juice(slider) => slider.next(),
}
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
let len = self.len();
(len, Some(len))
}
}
impl ExactSizeIterator for FruitOrJuice {
#[inline]
fn len(&self) -> usize {
match self {
FruitOrJuice::Fruit(Some(_)) => 1,
FruitOrJuice::Fruit(None) => 0,
FruitOrJuice::Juice(slider) => slider.len(),
}
}
}
// BUG: Sometimes there are off-by-one errors,
// presumably caused by floating point inaccuracies
fn tiny_droplet_count(
start_time: f64,
time_between_ticks: f64,
duration: f64,
span_count: usize,
ticks: &[(Pos2, f64)],
) -> usize {
// tiny droplets preceeding a _tick_
let per_tick = if !ticks.is_empty() && time_between_ticks > 80.0 {
let time_between_tiny = shrink_down(time_between_ticks);
// add a little for floating point inaccuracies
let start = time_between_tiny + 0.001;
count_iterations(start, time_between_tiny, time_between_ticks)
} else {
0
};
// tiny droplets preceeding a _reverse_
let last = ticks.last().map_or(start_time, |(_, last)| *last);
let repeat_time = start_time + duration / span_count as f64;
let since_last_tick = repeat_time - last;
let span_last_section = if since_last_tick > 80.0 {
let time_between_tiny = shrink_down(since_last_tick);
count_iterations(time_between_tiny, time_between_tiny, since_last_tick)
} else {
0
};
// tiny droplets preceeding the slider tail
// necessary to handle distinctly because of the legacy last tick
let last = ticks.last().map_or(start_time, |(_, last)| *last);
let end_time = start_time + duration / span_count as f64 - LEGACY_LAST_TICK_OFFSET;
let since_last_tick = end_time - last;
let last_section = if since_last_tick > 80.0 {
let time_between_tiny = shrink_down(since_last_tick);
count_iterations(time_between_tiny, time_between_tiny, since_last_tick)
} else {
0
};
// Combine tiny droplets counts
per_tick * ticks.len() * span_count
+ span_last_section * (span_count.saturating_sub(1))
+ last_section
}
#[inline]
fn shrink_down(mut val: f64) -> f64 {
while val > 100.0 {
val /= 2.0;
}
val
}
#[inline]
fn count_iterations(mut start: f64, step: f64, end: f64) -> usize {
let mut count = 0;
while start < end {
count += 1;
start += step;
}
count
}
-269
View File
@@ -1,269 +0,0 @@
use std::iter;
use crate::{
catch::{difficulty_object::DifficultyObject, SECTION_LENGTH, STAR_SCALING_FACTOR},
curve::CurveBuffers,
parse::Pos2,
Beatmap, Mods,
};
use super::{
calculate_catch_width,
catch_object::CatchObject,
fruit_or_juice::{FruitOrJuice, FruitParams},
movement::Movement,
CatchDifficultyAttributes, ALLOWED_CATCH_RANGE,
};
/// Gradually calculate the difficulty attributes of an osu!catch map.
///
/// Note that this struct implements [`Iterator`].
/// On every call of [`Iterator::next`], the map's next fruit or droplet
/// will be processed and the [`CatchDifficultyAttributes`] will be updated and returned.
///
/// Note that it does not return attributes after a tiny droplet. Only for fruits and droplets.
///
/// If you want to calculate performance attributes, use
/// [`CatchGradualPerformance`](crate::catch::CatchGradualPerformance) instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, catch::CatchGradualDifficulty};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut iter = CatchGradualDifficulty::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")))]
#[derive(Clone, Debug)]
pub struct CatchGradualDifficulty<'map> {
map: &'map Beatmap,
inner: CatchGradualDifficultyInner,
}
impl<'map> CatchGradualDifficulty<'map> {
/// Create a new difficulty attributes iterator for osu!catch maps.
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
let inner = CatchGradualDifficultyInner::new(map, mods);
Self { map, inner }
}
pub(crate) const fn idx(&self) -> usize {
self.inner.idx
}
}
impl Iterator for CatchGradualDifficulty<'_> {
type Item = CatchDifficultyAttributes;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
self.inner.next(self.map)
}
}
/// Gradually calculate the difficulty attributes of an osu!catch map.
///
/// Check [`CatchGradualDifficulty`] 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(Clone, Debug)]
pub struct CatchOwnedGradualDifficulty {
pub(crate) map: Beatmap,
inner: CatchGradualDifficultyInner,
}
impl CatchOwnedGradualDifficulty {
/// Create a new difficulty attributes iterator for osu!catch maps.
pub fn new(map: Beatmap, mods: u32) -> Self {
let inner = CatchGradualDifficultyInner::new(&map, mods);
Self { map, inner }
}
#[allow(unused)]
pub(crate) const fn idx(&self) -> usize {
self.inner.idx
}
}
impl Iterator for CatchOwnedGradualDifficulty {
type Item = CatchDifficultyAttributes;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
self.inner.next(&self.map)
}
}
#[derive(Clone, Debug)]
struct CatchObjectIter {
hit_object_idx: usize,
last_object: Option<FruitOrJuice>,
params: FruitParams,
}
impl CatchObjectIter {
fn new(mods: impl Mods, attributes: CatchDifficultyAttributes) -> Self {
let params = FruitParams {
attributes,
curve_bufs: CurveBuffers::default(),
last_pos: None,
last_time: 0.0,
ticks: Vec::new(),
with_hr: mods.hr(),
};
Self {
hit_object_idx: 0,
last_object: None,
params,
}
}
fn attributes(&self) -> CatchDifficultyAttributes {
self.params.attributes.clone()
}
fn next(&mut self, map: &Beatmap) -> Option<CatchObject> {
if let opt @ Some(_) = self.last_object.as_mut().and_then(Iterator::next) {
return opt;
}
map.hit_objects[self.hit_object_idx..]
.iter()
.find_map(|h| {
self.hit_object_idx += 1;
FruitOrJuice::new(h, &mut self.params, map)
})
.and_then(|h| self.last_object.insert(h).next())
}
}
#[derive(Clone, Debug)]
struct CatchGradualDifficultyInner {
idx: usize,
clock_rate: f64,
hit_objects: CatchObjectIter,
movement: Movement,
prev: CatchObject,
half_catcher_width: f64,
last_direction: i8,
last_excess: f64,
curr_section_end: f64,
strain_peak_buf: Vec<f64>,
}
impl CatchGradualDifficultyInner {
fn new(map: &Beatmap, mods: u32) -> Self {
let map_attributes = map.attributes().mods(mods).build();
let attributes = CatchDifficultyAttributes {
ar: map_attributes.ar,
..Default::default()
};
let hit_objects = CatchObjectIter::new(mods, attributes);
let half_catcher_width =
f64::from(calculate_catch_width(map_attributes.cs as f32) / 2.0 / ALLOWED_CATCH_RANGE);
let last_direction = 0;
let last_excess = half_catcher_width;
let movement = Movement::new(map_attributes.cs as f32);
let prev = CatchObject::new((Pos2::zero(), 0.0));
Self {
idx: 0,
clock_rate: mods.clock_rate(),
hit_objects,
movement,
prev,
half_catcher_width,
last_direction,
last_excess,
curr_section_end: 0.0,
strain_peak_buf: Vec::new(),
}
}
fn init_hyper_dash(&mut self, next: &CatchObject) {
self.prev.init_hyper_dash(
self.half_catcher_width,
next,
&mut self.last_direction,
&mut self.last_excess,
);
}
fn next(&mut self, map: &Beatmap) -> Option<CatchDifficultyAttributes> {
let curr = self.hit_objects.next(map)?;
self.idx += 1;
if self.idx == 1 {
self.prev = curr;
return Some(self.hit_objects.attributes());
}
self.init_hyper_dash(&curr);
let h = DifficultyObject::new(
&curr,
&self.prev,
self.movement.half_catcher_width,
self.clock_rate,
);
if self.idx == 2 {
self.curr_section_end =
(h.base.time / self.clock_rate / SECTION_LENGTH).ceil() * SECTION_LENGTH;
} else {
let base_time = h.base.time / self.clock_rate;
while base_time > self.curr_section_end {
self.movement.save_current_peak();
self.movement.start_new_section_from(self.curr_section_end);
self.curr_section_end += SECTION_LENGTH;
}
}
self.movement.process(&h);
self.prev = curr;
let len = self.movement.strain_peaks.len();
let missing = len + 1 - self.strain_peak_buf.len();
self.strain_peak_buf.extend(iter::repeat(0.0).take(missing));
self.strain_peak_buf[..len].copy_from_slice(&self.movement.strain_peaks);
if let Some(last) = self.strain_peak_buf.last_mut() {
*last = self.movement.curr_section_peak;
}
let stars =
Movement::difficulty_value(&mut self.strain_peak_buf).sqrt() * STAR_SCALING_FACTOR;
let attrs = CatchDifficultyAttributes {
stars,
..self.hit_objects.attributes()
};
Some(attrs)
}
}
-201
View File
@@ -1,201 +0,0 @@
use crate::{Beatmap, CatchPP};
use super::{
CatchGradualDifficulty, CatchOwnedGradualDifficulty, CatchPerformanceAttributes,
CatchScoreState,
};
/// Gradually calculate the performance attributes of an osu!catch map.
///
/// After each hit object you can call [`next`](`CatchGradualPerformance::next`)
/// and it will return the resulting current [`CatchPerformanceAttributes`].
/// To process multiple objects at once, use [`nth`](`CatchGradualPerformance::nth`) instead.
///
/// Both methods require a [`CatchScoreState`] that contains the current
/// hitresults as well as the maximum combo so far.
///
/// Note that neither hits nor misses of tiny droplets require
/// to be processed. Only fruits and droplets do.
///
/// If you only want to calculate difficulty attributes use [`CatchGradualDifficulty`] instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, catch::{CatchGradualPerformance, CatchScoreState}};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut gradual_perf = CatchGradualPerformance::new(&map, mods);
/// let mut state = CatchScoreState::new(); // empty state, everything is on 0.
///
/// // The first 10 hitresults are only fruits
/// for _ in 0..10 {
/// state.n_fruits += 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 fruits and droplets.
/// // Notice how tiny droplets from sliders do not count as hit objects
/// // that require processing. Only fruits and droplets do.
/// // Also notice how all 10 objects will be processed in one go.
/// state.n_fruits += 4;
/// state.n_droplets += 6;
/// state.n_tiny_droplets += 12;
/// // 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 fruit. Note that the max combo gets incremented again.
/// state.n_fruits += 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.n_fruits = ...
/// state.n_droplets = ...
/// state.n_tiny_droplets = ...
/// state.n_tiny_droplet_misses = ...
/// 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(Clone, Debug)]
pub struct CatchGradualPerformance<'map> {
difficulty: CatchGradualDifficulty<'map>,
performance: CatchPP<'map>,
}
impl<'map> CatchGradualPerformance<'map> {
/// Create a new gradual performance calculator for osu!standard maps.
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
let difficulty = CatchGradualDifficulty::new(map, mods);
let performance = CatchPP::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.
///
/// Note that neither hits nor misses of tiny droplets require
/// to be processed. Only fruits and droplets do.
pub fn next(&mut self, state: CatchScoreState) -> Option<CatchPerformanceAttributes> {
self.nth(state, 0)
}
/// Process all remaining hit objects and calculate the final performance attributes.
pub fn last(&mut self, state: CatchScoreState) -> Option<CatchPerformanceAttributes> {
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: CatchScoreState, n: usize) -> Option<CatchPerformanceAttributes> {
let difficulty = self.difficulty.by_ref().take(n.saturating_add(1)).last()?;
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!catch map.
///
/// Check [`CatchGradualPerformance`] 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(Clone, Debug)]
pub struct CatchOwnedGradualPerformance {
difficulty: CatchOwnedGradualDifficulty,
mods: u32,
}
impl CatchOwnedGradualPerformance {
/// Create a new gradual performance calculator for osu!standard maps.
pub fn new(map: Beatmap, mods: u32) -> Self {
let difficulty = CatchOwnedGradualDifficulty::new(map, mods);
Self { difficulty, mods }
}
/// Process the next hit object and calculate the
/// performance attributes for the resulting score state.
///
/// Note that neither hits nor misses of tiny droplets require
/// to be processed. Only fruits and droplets do.
pub fn next(&mut self, state: CatchScoreState) -> Option<CatchPerformanceAttributes> {
self.nth(state, 0)
}
/// Process all remaining hit objects and calculate the final performance attributes.
pub fn last(&mut self, state: CatchScoreState) -> Option<CatchPerformanceAttributes> {
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: CatchScoreState, n: usize) -> Option<CatchPerformanceAttributes> {
let difficulty = self.difficulty.by_ref().take(n.saturating_add(1)).last()?;
let performance = CatchPP::new(&self.difficulty.map)
.mods(self.mods)
.attributes(difficulty)
.state(state)
.passed_objects(self.difficulty.idx())
.calculate();
Some(performance)
}
}
+39 -329
View File
@@ -1,343 +1,53 @@
mod catch_object;
mod difficulty_object;
mod fruit_or_juice;
mod movement;
mod pp;
mod score_state;
use std::borrow::Cow;
#[cfg(feature = "gradual")]
mod gradual_difficulty;
#[cfg(feature = "gradual")]
mod gradual_performance;
use difficulty_object::DifficultyObject;
use movement::Movement;
pub use self::{catch_object::CatchObject, pp::*, score_state::CatchScoreState};
#[cfg(feature = "gradual")]
pub use self::{
gradual_difficulty::{CatchGradualDifficulty, CatchOwnedGradualDifficulty},
gradual_performance::{CatchGradualPerformance, CatchOwnedGradualPerformance},
use crate::{
any::ModeDifficulty,
model::{
beatmap::Beatmap,
mode::{ConvertStatus, IGameMode},
},
};
pub(crate) use self::fruit_or_juice::{FruitOrJuice, FruitParams};
pub use self::{
attributes::{CatchDifficultyAttributes, CatchPerformanceAttributes},
convert::CatchBeatmap,
performance::CatchPerformance,
score_state::CatchScoreState,
strains::CatchStrains,
};
use crate::{curve::CurveBuffers, Beatmap, Mods, OsuStars};
mod attributes;
mod catcher;
mod convert;
mod difficulty;
mod object;
mod performance;
mod score_state;
mod strains;
const SECTION_LENGTH: f64 = 750.0;
const STAR_SCALING_FACTOR: f64 = 0.153;
const PLAYFIELD_WIDTH: f32 = 512.0;
pub(crate) const ALLOWED_CATCH_RANGE: f32 = 0.8;
const CATCHER_SIZE: f32 = 106.75;
/// Difficulty calculator on osu!catch maps.
/// Marker type for [`GameMode::Catch`].
///
/// # Example
///
/// ```
/// use rosu_pp::{CatchStars, Beatmap};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let difficulty_attrs = CatchStars::new(&map)
/// .mods(8 + 64) // HDDT
/// .calculate();
///
/// println!("Stars: {}", difficulty_attrs.stars);
/// ```
#[derive(Clone, Debug)]
#[must_use]
pub struct CatchStars<'map> {
map: &'map Beatmap,
mods: u32,
passed_objects: Option<usize>,
clock_rate: Option<f64>,
}
/// [`GameMode::Catch`]: rosu_map::section::general::GameMode::Catch
pub struct Catch;
impl<'map> CatchStars<'map> {
/// Create a new difficulty calculator for osu!catch maps.
#[inline]
pub const fn new(map: &'map Beatmap) -> Self {
Self {
map,
mods: 0,
passed_objects: None,
clock_rate: None,
}
impl IGameMode for Catch {
type DifficultyAttributes = CatchDifficultyAttributes;
type Strains = CatchStrains;
fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus {
convert::try_convert(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;
self
fn difficulty(
difficulty: &ModeDifficulty,
converted: &CatchBeatmap<'_>,
) -> Self::DifficultyAttributes {
difficulty::difficulty(difficulty, converted)
}
/// 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 [`CatchStars`] multiple times with different `passed_objects`, you should use
[`CatchGradualDifficultyAttributes`](crate::catch::CatchGradualDifficulty)."
)]
#[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) -> CatchDifficultyAttributes {
let (mut movement, mut attributes) = calculate_movement(self);
attributes.stars =
Movement::difficulty_value(&mut movement.strain_peaks).sqrt() * STAR_SCALING_FACTOR;
attributes
}
/// Calculate the skill strains.
///
/// Suitable to plot the difficulty of a map over time.
#[inline]
pub fn strains(self) -> CatchStrains {
let (movement, _) = calculate_movement(self);
CatchStrains {
section_len: SECTION_LENGTH,
movement: movement.strain_peaks,
}
}
}
/// The result of calculating the strains on a osu!catch map.
/// Suitable to plot the difficulty of a map over time.
#[derive(Clone, Debug)]
pub struct CatchStrains {
/// Time in ms inbetween two strains.
pub section_len: f64,
/// Strain peaks of the movement skill.
pub movement: Vec<f64>,
}
impl CatchStrains {
/// Returns the number of strain peaks per skill.
#[inline]
#[allow(clippy::len_without_is_empty)]
pub fn len(&self) -> usize {
self.movement.len()
}
}
#[allow(clippy::needless_pass_by_value)]
fn calculate_movement(params: CatchStars<'_>) -> (Movement, CatchDifficultyAttributes) {
let CatchStars {
map,
mods,
passed_objects,
clock_rate,
} = params;
let take = passed_objects.unwrap_or(usize::MAX);
let clock_rate = clock_rate.unwrap_or_else(|| mods.clock_rate());
let map_attributes = map.attributes().mods(mods).clock_rate(clock_rate).build();
let attributes = CatchDifficultyAttributes {
ar: map_attributes.ar,
..Default::default()
};
let mut params = FruitParams {
attributes,
curve_bufs: CurveBuffers::default(),
last_pos: None,
last_time: 0.0,
ticks: Vec::new(), // using the same buffer for all sliders
with_hr: mods.hr(),
};
// BUG: Incorrect object order on 2B maps that have fruits within sliders
let mut hit_objects = map
.hit_objects
.iter()
.filter_map(|h| FruitOrJuice::new(h, &mut params, map))
.flatten()
.take(take);
// Hyper dash business
let half_catcher_width =
f64::from(calculate_catch_width(map_attributes.cs as f32) / 2.0 / ALLOWED_CATCH_RANGE);
let mut last_direction = 0;
let mut last_excess = half_catcher_width;
// Strain business
let mut movement = Movement::new(map_attributes.cs as f32);
let (mut prev, curr) = match (hit_objects.next(), hit_objects.next()) {
(Some(prev), Some(curr)) => (prev, curr),
(_, None) => return (movement, params.attributes),
(None, Some(_)) => unreachable!(),
};
let mut curr_section_end = (curr.time / clock_rate / SECTION_LENGTH).ceil() * SECTION_LENGTH;
prev.init_hyper_dash(
half_catcher_width,
&curr,
&mut last_direction,
&mut last_excess,
);
// Handle first object distinctly
let h = DifficultyObject::new(&curr, &prev, movement.half_catcher_width, clock_rate);
movement.process(&h);
prev = curr;
// Handle all other objects
for curr in hit_objects {
prev.init_hyper_dash(
half_catcher_width,
&curr,
&mut last_direction,
&mut last_excess,
);
let h = DifficultyObject::new(&curr, &prev, movement.half_catcher_width, clock_rate);
let base_time = h.base.time / clock_rate;
while base_time > curr_section_end {
movement.save_current_peak();
movement.start_new_section_from(curr_section_end);
curr_section_end += SECTION_LENGTH;
// 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 movement.curr_section_peak.abs() <= f64::EPSILON && base_time > curr_section_end {
let remaining_time = base_time - curr_section_end;
let remaining_iters = (remaining_time / SECTION_LENGTH).ceil();
curr_section_end += remaining_iters * SECTION_LENGTH;
}
}
movement.process(&h);
prev = curr;
}
movement.save_current_peak();
(movement, params.attributes)
}
#[inline]
pub(crate) fn calculate_catch_width(cs: f32) -> f32 {
let scale = 1.0 - 0.7 * (cs - 5.0) / 5.0;
CATCHER_SIZE * scale.abs() * ALLOWED_CATCH_RANGE
}
/// The result of a difficulty calculation on an osu!catch map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CatchDifficultyAttributes {
/// The final star rating
pub stars: f64,
/// The approach rate.
pub ar: f64,
/// The amount of fruits.
pub n_fruits: usize,
/// The amount of droplets.
pub n_droplets: usize,
/// The amount of tiny droplets.
pub n_tiny_droplets: usize,
}
impl CatchDifficultyAttributes {
/// Return the maximum combo.
#[inline]
pub const fn max_combo(&self) -> usize {
self.n_fruits + self.n_droplets
}
/// Returns a builder for performance calculation.
#[inline]
pub fn pp(self) -> CatchPP<'static> {
CatchPP::from(self)
}
}
/// The result of a performance calculation on an osu!catch map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CatchPerformanceAttributes {
/// The difficulty attributes that were used for the performance calculation
pub difficulty: CatchDifficultyAttributes,
/// The final performance points.
pub pp: f64,
}
impl CatchPerformanceAttributes {
/// 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()
}
}
impl From<CatchPerformanceAttributes> for CatchDifficultyAttributes {
#[inline]
fn from(attributes: CatchPerformanceAttributes) -> Self {
attributes.difficulty
}
}
impl<'map> From<OsuStars<'map>> for CatchStars<'map> {
#[inline]
fn from(osu: OsuStars<'map>) -> Self {
let OsuStars {
map,
mods,
passed_objects,
clock_rate,
} = osu;
Self {
map,
mods,
passed_objects,
clock_rate,
}
fn strains(difficulty: &ModeDifficulty, converted: &CatchBeatmap<'_>) -> Self::Strains {
strains::strains(difficulty, converted)
}
}
-147
View File
@@ -1,147 +0,0 @@
use super::DifficultyObject;
use std::cmp::Ordering;
const ABSOLUTE_PLAYER_POSITIONING_ERROR: f32 = 16.0;
const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0;
const POSITION_EPSILON: f32 = NORMALIZED_HITOBJECT_RADIUS - ABSOLUTE_PLAYER_POSITIONING_ERROR;
const DIRECTION_CHANGE_BONUS: f64 = 21.0;
const SKILL_MULTIPLIER: f64 = 900.0;
const STRAIN_DECAY_BASE: f64 = 0.2;
const DECAY_WEIGHT: f64 = 0.94;
#[derive(Clone, Debug)]
pub(crate) struct Movement {
pub(crate) half_catcher_width: f32,
last_player_position: Option<f32>,
last_distance_moved: f32,
last_strain_time: f64,
current_strain: f64,
pub(crate) curr_section_peak: f64,
pub(crate) strain_peaks: Vec<f64>,
prev_time: Option<f64>,
}
impl Movement {
#[inline]
pub(crate) fn new(cs: f32) -> Self {
let mut half_catcher_width = super::calculate_catch_width(cs) * 0.5;
half_catcher_width *= 1.0 - ((cs - 5.5).max(0.0) * 0.0625);
Self {
half_catcher_width,
last_player_position: None,
last_distance_moved: 0.0,
last_strain_time: 0.0,
current_strain: 1.0,
curr_section_peak: 1.0,
strain_peaks: Vec::with_capacity(128),
prev_time: None,
}
}
#[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: f64) {
self.curr_section_peak = self.peak_strain(time - self.prev_time.unwrap());
}
pub(crate) fn process(&mut self, current: &DifficultyObject<'_>) {
self.current_strain *= strain_decay(current.delta);
self.current_strain += self.strain_value_of(current) * SKILL_MULTIPLIER;
self.curr_section_peak = self.current_strain.max(self.curr_section_peak);
self.prev_time.replace(current.start_time);
}
pub(crate) fn difficulty_value(strain_peaks: &mut [f64]) -> f64 {
let mut difficulty = 0.0;
let mut weight = 1.0;
strain_peaks.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
for &strain in strain_peaks.iter() {
difficulty += strain * weight;
weight *= DECAY_WEIGHT;
}
difficulty
}
fn strain_value_of(&mut self, current: &DifficultyObject<'_>) -> f64 {
let last_player_pos = self
.last_player_position
.unwrap_or(current.last_normalized_pos);
let mut pos = last_player_pos.clamp(
current.normalized_pos - POSITION_EPSILON,
current.normalized_pos + POSITION_EPSILON,
);
let dist_moved = pos - last_player_pos;
let weighted_strain_time = current.strain_time + 13.0 + (3.0 / current.clock_rate);
let mut dist_addition = f64::from(dist_moved.abs().powf(1.3) / 510.0);
if dist_moved.abs() > 0.1 {
if self.last_distance_moved.abs() > 0.1
&& dist_moved.signum() != self.last_distance_moved.signum()
{
let bonus_factor = f64::from(dist_moved.abs().min(50.0) / 50.0);
let anti_flow_factor =
f64::from((self.last_distance_moved.abs().min(70.0) / 70.0).max(0.38));
dist_addition += DIRECTION_CHANGE_BONUS / (self.last_strain_time + 16.0).sqrt()
* bonus_factor
* anti_flow_factor
* (1.0 - (weighted_strain_time / 1000.0).powi(3)).max(0.0);
}
dist_addition += f64::from(
12.5 * dist_moved.abs().min(NORMALIZED_HITOBJECT_RADIUS * 2.0)
/ (NORMALIZED_HITOBJECT_RADIUS * 6.0),
) / weighted_strain_time.sqrt();
}
let mut edge_dash_bonus = 0.0;
if current.last.hyper_dist <= 20.0 {
#[allow(clippy::if_not_else)]
if !current.last.hyper_dash {
edge_dash_bonus += 5.7;
} else {
pos = current.normalized_pos;
}
dist_addition *= 1.0
+ edge_dash_bonus
* f64::from((20.0 - current.last.hyper_dist) / 20.0)
* ((current.strain_time * current.clock_rate).min(265.0) / 265.0).powf(1.5);
}
self.last_player_position.replace(pos);
self.last_distance_moved = dist_moved;
self.last_strain_time = current.strain_time;
dist_addition / weighted_strain_time
}
#[inline]
fn peak_strain(&self, delta_time: f64) -> f64 {
self.current_strain * strain_decay(delta_time)
}
}
#[inline]
fn strain_decay(ms: f64) -> f64 {
STRAIN_DECAY_BASE.powf(ms / 1000.0)
}
+30
View File
@@ -0,0 +1,30 @@
pub struct BananaShower {
pub n_bananas: usize,
}
impl BananaShower {
pub fn new(start_time: f64, end_time: f64) -> Self {
let duration = end_time - start_time;
let mut spacing = duration;
while spacing > 100.0 {
spacing /= 2.0;
}
let n_bananas = if spacing <= 0.0 {
0
} else {
let mut time = start_time;
let mut i = 0;
while time <= end_time {
time += spacing;
i += 1;
}
i
};
Self { n_bananas }
}
}
+13
View File
@@ -0,0 +1,13 @@
use crate::catch::attributes::CatchDifficultyAttributesBuilder;
pub struct Fruit {
pub x_offset: f32,
}
impl Fruit {
pub fn new(attrs: &mut CatchDifficultyAttributesBuilder) -> Self {
attrs.inc_fruits();
Self { x_offset: 0.0 }
}
}
+154
View File
@@ -0,0 +1,154 @@
use std::vec::Drain;
use rosu_map::section::hit_objects::{
CurveBuffers, PathControlPoint, SliderEvent, SliderEventType, SliderEventsIter,
};
use crate::{
catch::{attributes::CatchDifficultyAttributesBuilder, convert::CatchBeatmap},
model::{
control_point::{DifficultyPoint, TimingPoint},
hit_object::Slider,
},
};
pub struct JuiceStream<'a> {
pub control_points: &'a [PathControlPoint], // needed for applying hr offset
pub nested_objects: Drain<'a, NestedJuiceStreamObject>,
}
impl<'a> JuiceStream<'a> {
pub const BASE_SCORING_DIST: f64 = 100.0;
pub fn new(
x: f32,
start_time: f64,
slider: &'a Slider,
converted: &CatchBeatmap<'_>,
attrs: &mut CatchDifficultyAttributesBuilder,
bufs: &'a mut JuiceStreamBufs,
) -> Self {
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 = converted
.map
.difficulty_point_at(start_time)
.map_or(DifficultyPoint::DEFAULT_SLIDER_VELOCITY, |point| {
point.slider_velocity
});
let path = slider.curve(&mut bufs.curve);
let velocity_factor = JuiceStream::BASE_SCORING_DIST * slider_multiplier / beat_len;
let velocity = velocity_factor * slider_velocity;
let tick_dist_factor =
JuiceStream::BASE_SCORING_DIST * slider_multiplier / slider_tick_rate;
let tick_dist_multiplier = if converted.map.version < 8 {
1.0
} else {
slider_velocity
};
let tick_dist = tick_dist_factor * tick_dist_multiplier;
let span_count = slider.span_count() as f64;
let duration = span_count * path.dist() / velocity;
let span_duration = duration / span_count;
let events = SliderEventsIter::new(
start_time,
span_duration,
velocity,
tick_dist,
path.dist(),
slider.span_count() as i32,
&mut bufs.ticks,
);
let mut last_event_time = None;
for e in events {
if let Some(last_event_time) = last_event_time.filter(|_| attrs.take_more()) {
let since_last_tick = e.time - last_event_time;
if since_last_tick > 80.0 {
let mut time_between_tiny = since_last_tick;
while time_between_tiny > 100.0 {
time_between_tiny /= 2.0;
}
let mut t = time_between_tiny;
while t < since_last_tick {
attrs.inc_tiny_droplets();
let nested = NestedJuiceStreamObject {
pos: 0.0, // not important
start_time: 0.0, // not important
kind: NestedJuiceStreamObjectKind::TinyDroplet,
};
bufs.nested_objects.push(nested);
t += time_between_tiny;
}
}
}
last_event_time = Some(e.time);
let kind = match e.kind {
SliderEventType::Tick => {
attrs.inc_droplets();
NestedJuiceStreamObjectKind::Droplet
}
SliderEventType::Head | SliderEventType::Repeat | SliderEventType::Tail => {
attrs.inc_fruits();
NestedJuiceStreamObjectKind::Fruit
}
SliderEventType::LastTick => continue,
};
let nested = NestedJuiceStreamObject {
pos: x + path.position_at(e.path_progress).x,
start_time: e.time,
kind,
};
bufs.nested_objects.push(nested);
}
Self {
control_points: slider.control_points.as_ref(),
nested_objects: bufs.nested_objects.drain(..),
}
}
}
pub struct NestedJuiceStreamObject {
pub pos: f32,
pub start_time: f64,
pub kind: NestedJuiceStreamObjectKind,
}
pub enum NestedJuiceStreamObjectKind {
Fruit,
Droplet,
TinyDroplet,
}
pub struct JuiceStreamBufs {
pub nested_objects: Vec<NestedJuiceStreamObject>,
pub curve: CurveBuffers,
pub ticks: Vec<SliderEvent>,
}
+4
View File
@@ -0,0 +1,4 @@
pub mod banana_shower;
pub mod fruit;
pub mod juice_stream;
pub mod palpable;
+23
View File
@@ -0,0 +1,23 @@
pub struct PalpableObject {
pub x: f32,
pub x_offset: f32,
pub start_time: f64,
pub dist_to_hyper_dash: f32,
pub hyper_dash: bool,
}
impl PalpableObject {
pub const fn new(x: f32, x_offset: f32, start_time: f64) -> Self {
Self {
x,
x_offset,
start_time,
dist_to_hyper_dash: 0.0,
hyper_dash: false,
}
}
pub fn effective_x(&self) -> f32 {
self.x + self.x_offset
}
}
+555
View File
@@ -0,0 +1,555 @@
use std::cmp::{self, Ordering};
use crate::{
any::ModeAttributeProvider,
any::ModeDifficulty,
osu::OsuPerformance,
util::{map_or_attrs::MapOrAttrs, mods::Mods},
};
use super::{
attributes::{CatchDifficultyAttributes, CatchPerformanceAttributes},
convert::CatchBeatmap,
score_state::CatchScoreState,
Catch,
};
/// Performance calculator on osu!catch maps.
#[derive(Clone, Debug, PartialEq)]
#[must_use]
pub struct CatchPerformance<'map> {
pub(crate) map_or_attrs: MapOrAttrs<'map, Catch>,
pub(crate) mods: u32,
pub(crate) acc: Option<f64>,
pub(crate) combo: Option<u32>,
pub(crate) n_fruits: Option<u32>,
pub(crate) n_droplets: Option<u32>,
pub(crate) n_tiny_droplets: Option<u32>,
pub(crate) n_tiny_droplet_misses: Option<u32>,
pub(crate) n_misses: Option<u32>,
pub(crate) passed_objects: Option<u32>,
pub(crate) clock_rate: Option<f64>,
}
impl<'map> CatchPerformance<'map> {
/// Create a new performance calculator for osu!catch maps.
pub fn new(map: CatchBeatmap<'map>) -> Self {
map.into()
}
/// 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.
pub fn attributes(mut self, attributes: impl ModeAttributeProvider<Catch>) -> Self {
if let Some(attrs) = attributes.attributes() {
self.map_or_attrs = MapOrAttrs::Attrs(attrs);
}
self
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
pub const fn mods(mut self, mods: u32) -> Self {
self.mods = mods;
self
}
/// Specify the max combo of the play.
pub const fn combo(mut self, combo: u32) -> Self {
self.combo = Some(combo);
self
}
/// Specify the amount of fruits of a play i.e. n300.
pub const fn fruits(mut self, n_fruits: u32) -> Self {
self.n_fruits = Some(n_fruits);
self
}
/// Specify the amount of droplets of a play i.e. n100.
pub const fn droplets(mut self, n_droplets: u32) -> Self {
self.n_droplets = Some(n_droplets);
self
}
/// Specify the amount of tiny droplets of a play i.e. n50.
pub const fn tiny_droplets(mut self, n_tiny_droplets: u32) -> Self {
self.n_tiny_droplets = Some(n_tiny_droplets);
self
}
/// Specify the amount of tiny droplet misses of a play i.e. `n_katu`.
pub const fn tiny_droplet_misses(mut self, n_tiny_droplet_misses: u32) -> Self {
self.n_tiny_droplet_misses = Some(n_tiny_droplet_misses);
self
}
/// Specify the amount of fruit / droplet misses of the play.
pub const fn misses(mut self, n_misses: u32) -> Self {
self.n_misses = Some(n_misses);
self
}
/// Amount of passed objects for partial plays, e.g. a fail.
///
#[cfg_attr(
feature = "gradual",
doc = "If you want to calculate the performance after every few objects, instead of
using [`CatchPP`] multiple times with different `passed_objects`, you should use
[`CatchGradualPerformanceAttributes`](crate::catch::CatchGradualPerformance)."
)]
pub const fn passed_objects(mut self, passed_objects: u32) -> 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.
pub const fn clock_rate(mut self, clock_rate: f64) -> Self {
self.clock_rate = Some(clock_rate);
self
}
/// Provide parameters through an [`CatchScoreState`].
#[allow(clippy::needless_pass_by_value)]
pub const fn state(mut self, state: CatchScoreState) -> Self {
let CatchScoreState {
max_combo,
n_fruits,
n_droplets,
n_tiny_droplets,
n_tiny_droplet_misses,
n_misses,
} = state;
self.combo = Some(max_combo);
self.n_fruits = Some(n_fruits);
self.n_droplets = Some(n_droplets);
self.n_tiny_droplets = Some(n_tiny_droplets);
self.n_tiny_droplet_misses = Some(n_tiny_droplet_misses);
self.n_misses = Some(n_misses);
self
}
/// Specify the accuracy of a play between `0.0` and `100.0`.
/// This will be used to generate matching hitresults.
pub fn accuracy(mut self, acc: f64) -> Self {
self.acc = Some(acc / 100.0);
self
}
/// Create the [`CatchScoreState`] that will be used for performance calculation.
#[allow(clippy::too_many_lines)]
pub fn generate_state(&mut self) -> CatchScoreState {
let attrs = match self.map_or_attrs {
MapOrAttrs::Map(ref map) => {
let attrs = self.generate_attributes(map);
self.map_or_attrs.attrs_or_insert(attrs)
}
MapOrAttrs::Attrs(ref attrs) => attrs,
};
let n_misses = self
.n_misses
.map_or(0, |n| n.min(attrs.n_fruits + attrs.n_droplets));
let max_combo = self.combo.unwrap_or_else(|| attrs.max_combo() - n_misses);
let mut best_state = CatchScoreState {
max_combo,
n_misses,
..Default::default()
};
let mut best_dist = f64::INFINITY;
let (n_fruits, n_droplets) = match (self.n_fruits, self.n_droplets) {
(Some(mut n_fruits), Some(mut n_droplets)) => {
let n_remaining = (attrs.n_fruits + attrs.n_droplets)
.saturating_sub(n_fruits + n_droplets + n_misses);
let new_droplets = n_remaining.min(attrs.n_droplets.saturating_sub(n_droplets));
n_droplets += new_droplets;
n_fruits += n_remaining - new_droplets;
n_fruits = n_fruits
.min((attrs.n_fruits + attrs.n_droplets).saturating_sub(n_droplets + n_misses));
n_droplets =
n_droplets.min(attrs.n_fruits + attrs.n_droplets - n_fruits - n_misses);
(n_fruits, n_droplets)
}
(Some(mut n_fruits), None) => {
let n_droplets = attrs.n_droplets.saturating_sub(
n_misses.saturating_sub(attrs.n_fruits.saturating_sub(n_fruits)),
);
n_fruits = attrs.n_fruits + attrs.n_droplets - n_misses - n_droplets;
(n_fruits, n_droplets)
}
(None, Some(mut n_droplets)) => {
let n_fruits = attrs.n_fruits.saturating_sub(
n_misses.saturating_sub(attrs.n_droplets.saturating_sub(n_droplets)),
);
n_droplets = attrs.n_fruits + attrs.n_droplets - n_misses - n_fruits;
(n_fruits, n_droplets)
}
(None, None) => {
let n_droplets = attrs.n_droplets.saturating_sub(n_misses);
let n_fruits =
attrs.n_fruits - (n_misses - (attrs.n_droplets.saturating_sub(n_droplets)));
(n_fruits, n_droplets)
}
};
best_state.n_fruits = n_fruits;
best_state.n_droplets = n_droplets;
let mut find_best_tiny_droplets = |acc: f64| {
let raw_tiny_droplets = acc
* f64::from(attrs.n_fruits + attrs.n_droplets + attrs.n_tiny_droplets)
- f64::from(n_fruits + n_droplets);
let min_tiny_droplets =
cmp::min(attrs.n_tiny_droplets, raw_tiny_droplets.floor() as u32);
let max_tiny_droplets =
cmp::min(attrs.n_tiny_droplets, raw_tiny_droplets.ceil() as u32);
for n_tiny_droplets in min_tiny_droplets..=max_tiny_droplets {
let n_tiny_droplet_misses = attrs.n_tiny_droplets - n_tiny_droplets;
let curr_acc = accuracy(
n_fruits,
n_droplets,
n_tiny_droplets,
n_tiny_droplet_misses,
n_misses,
);
let curr_dist = (acc - curr_acc).abs();
if curr_dist < best_dist {
best_dist = curr_dist;
best_state.n_tiny_droplets = n_tiny_droplets;
best_state.n_tiny_droplet_misses = n_tiny_droplet_misses;
}
}
};
#[allow(clippy::single_match_else)]
match (self.n_tiny_droplets, self.n_tiny_droplet_misses) {
(Some(n_tiny_droplets), Some(n_tiny_droplet_misses)) => match self.acc {
Some(acc) => {
match (n_tiny_droplets + n_tiny_droplet_misses).cmp(&attrs.n_tiny_droplets) {
Ordering::Equal => {
best_state.n_tiny_droplets = n_tiny_droplets;
best_state.n_tiny_droplet_misses = n_tiny_droplet_misses;
}
Ordering::Less | Ordering::Greater => find_best_tiny_droplets(acc),
}
}
None => {
let n_remaining = attrs
.n_tiny_droplets
.saturating_sub(n_tiny_droplets + n_tiny_droplet_misses);
best_state.n_tiny_droplets = n_tiny_droplets + n_remaining;
best_state.n_tiny_droplet_misses = n_tiny_droplet_misses;
}
},
(Some(n_tiny_droplets), None) => {
best_state.n_tiny_droplets = attrs.n_tiny_droplets.min(n_tiny_droplets);
best_state.n_tiny_droplet_misses =
attrs.n_tiny_droplets.saturating_sub(n_tiny_droplets);
}
(None, Some(n_tiny_droplet_misses)) => {
best_state.n_tiny_droplets =
attrs.n_tiny_droplets.saturating_sub(n_tiny_droplet_misses);
best_state.n_tiny_droplet_misses = attrs.n_tiny_droplets.min(n_tiny_droplet_misses);
}
(None, None) => match self.acc {
Some(acc) => find_best_tiny_droplets(acc),
None => best_state.n_tiny_droplets = attrs.n_tiny_droplets,
},
}
best_state
}
/// Calculate all performance related values, including pp and stars.
pub fn calculate(mut self) -> CatchPerformanceAttributes {
let state = self.generate_state();
let attrs = match self.map_or_attrs {
MapOrAttrs::Map(ref map) => self.generate_attributes(map),
MapOrAttrs::Attrs(attrs) => attrs,
};
let inner = CatchPerformanceInner {
attrs,
mods: self.mods,
state,
};
inner.calculate()
}
fn generate_attributes(&self, map: &CatchBeatmap<'_>) -> CatchDifficultyAttributes {
let mut calculator = ModeDifficulty::new();
if let Some(passed_objects) = self.passed_objects {
calculator.passed_objects(passed_objects);
}
if let Some(clock_rate) = self.clock_rate {
calculator.clock_rate(clock_rate);
}
calculator.mods(self.mods).calculate(map)
}
/// Try to create [`CatchPerformance`] through a [`ModeAttributeProvider`].
///
/// If you already calculated the attributes for the current map-mod
/// combination, the [`CatchBeatmap`] is no longer necessary to calculate
/// performance attributes so this method can be used instead of
/// [`CatchPerformance::new`].
///
/// Returns `None` only if the [`ModeAttributeProvider`] did not contain
/// attributes for catch e.g. if it's [`DifficultyAttributes::Taiko`].
///
/// [`DifficultyAttributes::Taiko`]: crate::any::DifficultyAttributes::Taiko
pub fn try_from_attributes(attributes: impl ModeAttributeProvider<Catch>) -> Option<Self> {
attributes.attributes().map(Self::from)
}
/// Create [`CatchPerformance`] through a [`ModeAttributeProvider`].
///
/// If you already calculated the attributes for the current map-mod
/// combination, the [`CatchBeatmap`] is no longer necessary to calculate
/// performance attributes so this method can be used instead of
/// [`CatchPerformance::new`].
///
/// # Panics
///
/// Panics if the [`ModeAttributeProvider`] did not contain attributes for
/// catch e.g. if it's [`DifficultyAttributes::Taiko`].
///
/// [`DifficultyAttributes::Taiko`]: crate::any::DifficultyAttributes::Taiko
pub fn unchecked_from_attributes(attributes: impl ModeAttributeProvider<Catch>) -> Self {
Self::try_from_attributes(attributes).expect("invalid catch attributes")
}
}
impl<'map> TryFrom<OsuPerformance<'map>> for CatchPerformance<'map> {
type Error = OsuPerformance<'map>;
/// Try to create [`CatchPerformance`] through [`OsuPerformance`].
///
/// Returns `None` if [`OsuPerformance`] already replaced its internal
/// beatmap with [`OsuDifficultyAttributes`], i.e. if
/// [`OsuPerformance::attributes`] or [`OsuPerformance::generate_state`]
/// was called.
///
/// [`OsuDifficultyAttributes`]: crate::osu::OsuDifficultyAttributes
fn try_from(mut osu: OsuPerformance<'map>) -> Result<Self, Self::Error> {
let MapOrAttrs::Map(converted) = osu.map_or_attrs else {
return Err(osu);
};
let map = match converted.try_convert() {
Ok(map) => map,
Err(map) => {
osu.map_or_attrs = MapOrAttrs::Map(map);
return Err(osu);
}
};
let OsuPerformance {
map_or_attrs: _,
mods,
acc,
combo,
n300,
n100,
n50,
n_misses,
passed_objects,
clock_rate,
hitresult_priority: _,
} = osu;
Ok(Self {
map_or_attrs: MapOrAttrs::Map(map),
mods,
acc,
combo,
n_fruits: n300,
n_droplets: n100,
n_tiny_droplets: n50,
n_tiny_droplet_misses: None,
n_misses,
passed_objects,
clock_rate,
})
}
}
impl<'map> From<CatchBeatmap<'map>> for CatchPerformance<'map> {
fn from(map: CatchBeatmap<'map>) -> Self {
Self {
map_or_attrs: MapOrAttrs::Map(map),
mods: 0,
acc: None,
combo: None,
n_fruits: None,
n_droplets: None,
n_tiny_droplets: None,
n_tiny_droplet_misses: None,
n_misses: None,
passed_objects: None,
clock_rate: None,
}
}
}
impl From<CatchDifficultyAttributes> for CatchPerformance<'_> {
fn from(attrs: CatchDifficultyAttributes) -> Self {
Self {
map_or_attrs: MapOrAttrs::Attrs(attrs),
mods: 0,
acc: None,
combo: None,
n_fruits: None,
n_droplets: None,
n_tiny_droplets: None,
n_tiny_droplet_misses: None,
n_misses: None,
passed_objects: None,
clock_rate: None,
}
}
}
impl From<CatchPerformanceAttributes> for CatchPerformance<'_> {
fn from(attrs: CatchPerformanceAttributes) -> Self {
attrs.difficulty.into()
}
}
struct CatchPerformanceInner {
attrs: CatchDifficultyAttributes,
mods: u32,
state: CatchScoreState,
}
impl CatchPerformanceInner {
fn calculate(self) -> CatchPerformanceAttributes {
let attributes = &self.attrs;
let stars = attributes.stars;
let max_combo = attributes.max_combo();
// Relying heavily on aim
let mut pp = (5.0 * (stars / 0.0049).max(1.0) - 4.0).powi(2) / 100_000.0;
let mut combo_hits = self.combo_hits();
if combo_hits == 0 {
combo_hits = max_combo;
}
// Longer maps are worth more
let len_bonus = 0.95
+ 0.3 * (f64::from(combo_hits) / 2500.0).min(1.0)
+ f64::from(u8::from(combo_hits > 2500))
* (f64::from(combo_hits) / 2500.0).log10()
* 0.475;
pp *= len_bonus;
// Penalize misses exponentially
pp *= 0.97_f64.powi(self.state.n_misses as i32);
// Combo scaling
if self.state.max_combo > 0 {
pp *= (f64::from(self.state.max_combo) / f64::from(max_combo))
.powf(0.8)
.min(1.0);
}
// AR scaling
let ar = attributes.ar;
let mut ar_factor = 1.0;
if ar > 9.0 {
ar_factor += 0.1 * (ar - 9.0) + f64::from(u8::from(ar > 10.0)) * 0.1 * (ar - 10.0);
} else if ar < 8.0 {
ar_factor += 0.025 * (8.0 - ar);
}
pp *= ar_factor;
// HD bonus
if self.mods.hd() {
if ar <= 10.0 {
pp *= 1.05 + 0.075 * (10.0 - ar);
} else if ar > 10.0 {
pp *= 1.01 + 0.04 * (11.0 - ar.min(11.0));
}
}
// FL bonus
if self.mods.fl() {
pp *= 1.35 * len_bonus;
}
// Accuracy scaling
pp *= self.state.accuracy().powf(5.5);
// NF penalty
if self.mods.nf() {
pp *= 0.9;
}
CatchPerformanceAttributes {
difficulty: self.attrs,
pp,
}
}
const fn combo_hits(&self) -> u32 {
self.state.n_fruits + self.state.n_droplets + self.state.n_misses
}
}
fn accuracy(
n_fruits: u32,
n_droplets: u32,
n_tiny_droplets: u32,
n_tiny_droplet_misses: u32,
n_misses: u32,
) -> f64 {
let numerator = n_fruits + n_droplets + n_tiny_droplets;
let denominator = numerator + n_tiny_droplet_misses + n_misses;
f64::from(numerator) / f64::from(denominator)
}
-807
View File
@@ -1,807 +0,0 @@
use super::{CatchDifficultyAttributes, CatchPerformanceAttributes, CatchScoreState, CatchStars};
use crate::{
util::{MapOrElse, MapRef},
Beatmap, DifficultyAttributes, Mods, OsuPP, PerformanceAttributes,
};
use std::cmp::Ordering;
/// Performance calculator on osu!catch maps.
///
/// # Example
///
/// ```
/// use rosu_pp::{CatchPP, Beatmap};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let pp_result = CatchPP::new(&map)
/// .mods(8 + 64) // HDDT
/// .combo(1234)
/// .accuracy(98.5)
/// .misses(1)
/// .calculate();
///
/// println!("PP: {} | Stars: {}", pp_result.pp(), pp_result.stars());
///
/// let next_result = CatchPP::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)]
#[must_use]
pub struct CatchPP<'map> {
pub(crate) map_or_attrs: MapOrElse<MapRef<'map>, CatchDifficultyAttributes>,
pub(crate) mods: u32,
pub(crate) acc: Option<f64>,
pub(crate) combo: Option<usize>,
pub(crate) n_fruits: Option<usize>,
pub(crate) n_droplets: Option<usize>,
pub(crate) n_tiny_droplets: Option<usize>,
pub(crate) n_tiny_droplet_misses: Option<usize>,
pub(crate) n_misses: Option<usize>,
pub(crate) passed_objects: Option<usize>,
pub(crate) clock_rate: Option<f64>,
}
impl<'map> CatchPP<'map> {
/// Create a new performance calculator for osu!catch maps.
#[inline]
pub fn new(map: &'map Beatmap) -> Self {
Self {
map_or_attrs: MapOrElse::from(map),
mods: 0,
acc: None,
combo: None,
n_fruits: None,
n_droplets: None,
n_tiny_droplets: None,
n_tiny_droplet_misses: None,
n_misses: None,
passed_objects: None,
clock_rate: None,
}
}
/// 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 CatchAttributeProvider) -> Self {
if let Some(attrs) = attributes.attributes() {
self.map_or_attrs = MapOrElse::Else(attrs);
}
self
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
#[inline]
pub const fn mods(mut self, mods: u32) -> Self {
self.mods = mods;
self
}
/// Specify the max combo of the play.
#[inline]
pub const fn combo(mut self, combo: usize) -> Self {
self.combo = Some(combo);
self
}
/// Specify the amount of fruits of a play i.e. n300.
#[inline]
pub const fn fruits(mut self, n_fruits: usize) -> Self {
self.n_fruits = Some(n_fruits);
self
}
/// Specify the amount of droplets of a play i.e. n100.
#[inline]
pub const fn droplets(mut self, n_droplets: usize) -> Self {
self.n_droplets = Some(n_droplets);
self
}
/// Specify the amount of tiny droplets of a play i.e. n50.
#[inline]
pub const fn tiny_droplets(mut self, n_tiny_droplets: usize) -> Self {
self.n_tiny_droplets = Some(n_tiny_droplets);
self
}
/// Specify the amount of tiny droplet misses of a play i.e. `n_katu`.
#[inline]
pub const fn tiny_droplet_misses(mut self, n_tiny_droplet_misses: usize) -> Self {
self.n_tiny_droplet_misses = Some(n_tiny_droplet_misses);
self
}
/// Specify the amount of fruit / droplet misses of the play.
#[inline]
pub const fn misses(mut self, n_misses: usize) -> Self {
self.n_misses = Some(n_misses);
self
}
/// Amount of passed objects for partial plays, e.g. a fail.
///
#[cfg_attr(
feature = "gradual",
doc = "If you want to calculate the performance after every few objects, instead of
using [`CatchPP`] multiple times with different `passed_objects`, you should use
[`CatchGradualPerformanceAttributes`](crate::catch::CatchGradualPerformance)."
)]
#[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
}
/// Provide parameters through an [`CatchScoreState`].
#[inline]
#[allow(clippy::needless_pass_by_value)]
pub const fn state(mut self, state: CatchScoreState) -> Self {
let CatchScoreState {
max_combo,
n_fruits,
n_droplets,
n_tiny_droplets,
n_tiny_droplet_misses,
n_misses,
} = state;
self.combo = Some(max_combo);
self.n_fruits = Some(n_fruits);
self.n_droplets = Some(n_droplets);
self.n_tiny_droplets = Some(n_tiny_droplets);
self.n_tiny_droplet_misses = Some(n_tiny_droplet_misses);
self.n_misses = Some(n_misses);
self
}
/// 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);
self
}
/// Create the [`CatchScoreState`] that will be used for performance calculation.
#[allow(clippy::too_many_lines)]
pub fn generate_state(&mut self) -> CatchScoreState {
let attrs = match self.map_or_attrs {
MapOrElse::Map(ref map) => {
let attrs = self.generate_attributes(map.as_ref());
self.map_or_attrs.else_or_insert(attrs)
}
MapOrElse::Else(ref attrs) => attrs,
};
let n_misses = self
.n_misses
.map_or(0, |n| n.min(attrs.n_fruits + attrs.n_droplets));
let max_combo = self.combo.unwrap_or_else(|| attrs.max_combo() - n_misses);
let mut best_state = CatchScoreState {
max_combo,
n_misses,
..Default::default()
};
let mut best_dist = f64::INFINITY;
let (n_fruits, n_droplets) = match (self.n_fruits, self.n_droplets) {
(Some(mut n_fruits), Some(mut n_droplets)) => {
let n_remaining = (attrs.n_fruits + attrs.n_droplets)
.saturating_sub(n_fruits + n_droplets + n_misses);
let new_droplets = n_remaining.min(attrs.n_droplets.saturating_sub(n_droplets));
n_droplets += new_droplets;
n_fruits += n_remaining - new_droplets;
n_fruits = n_fruits
.min((attrs.n_fruits + attrs.n_droplets).saturating_sub(n_droplets + n_misses));
n_droplets =
n_droplets.min(attrs.n_fruits + attrs.n_droplets - n_fruits - n_misses);
(n_fruits, n_droplets)
}
(Some(mut n_fruits), None) => {
let n_droplets = attrs.n_droplets.saturating_sub(
n_misses.saturating_sub(attrs.n_fruits.saturating_sub(n_fruits)),
);
n_fruits = attrs.n_fruits + attrs.n_droplets - n_misses - n_droplets;
(n_fruits, n_droplets)
}
(None, Some(mut n_droplets)) => {
let n_fruits = attrs.n_fruits.saturating_sub(
n_misses.saturating_sub(attrs.n_droplets.saturating_sub(n_droplets)),
);
n_droplets = attrs.n_fruits + attrs.n_droplets - n_misses - n_fruits;
(n_fruits, n_droplets)
}
(None, None) => {
let n_droplets = attrs.n_droplets.saturating_sub(n_misses);
let n_fruits =
attrs.n_fruits - (n_misses - (attrs.n_droplets.saturating_sub(n_droplets)));
(n_fruits, n_droplets)
}
};
best_state.n_fruits = n_fruits;
best_state.n_droplets = n_droplets;
let mut find_best_tiny_droplets = |acc: f64| {
let raw_tiny_droplets = acc
* (attrs.n_fruits + attrs.n_droplets + attrs.n_tiny_droplets) as f64
- (n_fruits + n_droplets) as f64;
let min_tiny_droplets = attrs
.n_tiny_droplets
.min(raw_tiny_droplets.floor() as usize);
let max_tiny_droplets = attrs.n_tiny_droplets.min(raw_tiny_droplets.ceil() as usize);
for n_tiny_droplets in min_tiny_droplets..=max_tiny_droplets {
let n_tiny_droplet_misses = attrs.n_tiny_droplets - n_tiny_droplets;
let curr_acc = accuracy(
n_fruits,
n_droplets,
n_tiny_droplets,
n_tiny_droplet_misses,
n_misses,
);
let curr_dist = (acc - curr_acc).abs();
if curr_dist < best_dist {
best_dist = curr_dist;
best_state.n_tiny_droplets = n_tiny_droplets;
best_state.n_tiny_droplet_misses = n_tiny_droplet_misses;
}
}
};
#[allow(clippy::single_match_else)]
match (self.n_tiny_droplets, self.n_tiny_droplet_misses) {
(Some(n_tiny_droplets), Some(n_tiny_droplet_misses)) => match self.acc {
Some(acc) => {
match (n_tiny_droplets + n_tiny_droplet_misses).cmp(&attrs.n_tiny_droplets) {
Ordering::Equal => {
best_state.n_tiny_droplets = n_tiny_droplets;
best_state.n_tiny_droplet_misses = n_tiny_droplet_misses;
}
Ordering::Less | Ordering::Greater => find_best_tiny_droplets(acc),
}
}
None => {
let n_remaining = attrs
.n_tiny_droplets
.saturating_sub(n_tiny_droplets + n_tiny_droplet_misses);
best_state.n_tiny_droplets = n_tiny_droplets + n_remaining;
best_state.n_tiny_droplet_misses = n_tiny_droplet_misses;
}
},
(Some(n_tiny_droplets), None) => {
best_state.n_tiny_droplets = attrs.n_tiny_droplets.min(n_tiny_droplets);
best_state.n_tiny_droplet_misses =
attrs.n_tiny_droplets.saturating_sub(n_tiny_droplets);
}
(None, Some(n_tiny_droplet_misses)) => {
best_state.n_tiny_droplets =
attrs.n_tiny_droplets.saturating_sub(n_tiny_droplet_misses);
best_state.n_tiny_droplet_misses = attrs.n_tiny_droplets.min(n_tiny_droplet_misses);
}
(None, None) => match self.acc {
Some(acc) => find_best_tiny_droplets(acc),
None => best_state.n_tiny_droplets = attrs.n_tiny_droplets,
},
}
best_state
}
/// Calculate all performance related values, including pp and stars.
pub fn calculate(mut self) -> CatchPerformanceAttributes {
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,
};
let inner = CatchPPInner {
attrs,
mods: self.mods,
state,
};
inner.calculate()
}
fn generate_attributes(&self, map: &Beatmap) -> CatchDifficultyAttributes {
let mut calculator = CatchStars::new(map).mods(self.mods);
if let Some(passed_objects) = self.passed_objects {
calculator = calculator.passed_objects(passed_objects);
}
if let Some(clock_rate) = self.clock_rate {
calculator = calculator.clock_rate(clock_rate);
}
calculator.calculate()
}
/// Try to create [`CatchPP`] through [`OsuPP`].
///
/// Returns `None` if [`OsuPP`] already replaced its internal [`Beatmap`]
/// with [`OsuDifficultyAttributes`], i.e. if [`OsuPP::attributes`]
/// or [`OsuPP::generate_state`] was called.
///
/// [`OsuDifficultyAttributes`]: crate::osu::OsuDifficultyAttributes
#[inline]
pub const fn try_from_osu(osu: OsuPP<'map>) -> Option<Self> {
let OsuPP {
map_or_attrs,
mods,
acc,
combo,
n300,
n100,
n50,
n_misses,
passed_objects,
clock_rate,
hitresult_priority: _,
} = osu;
let MapOrElse::Map(map) = map_or_attrs else {
return None;
};
Some(Self {
map_or_attrs: MapOrElse::Map(map),
mods,
acc,
combo,
n_fruits: n300,
n_droplets: n100,
n_tiny_droplets: n50,
n_tiny_droplet_misses: None,
n_misses,
passed_objects,
clock_rate,
})
}
/// Try to create [`CatchPP`] through a [`CatchAttributeProvider`].
///
/// If you already calculated the attributes for the current map-mod
/// combination, the [`Beatmap`] is no longer necessary to calculate
/// performance attributes so this method can be used instead of
/// [`CatchPP::new`].
///
/// Returns `None` only if the [`CatchAttributeProvider`] did not contain
/// attributes for catch e.g. if it's [`DifficultyAttributes::Taiko`].
#[inline]
pub fn try_from_attributes(attributes: impl CatchAttributeProvider) -> Option<Self> {
attributes.attributes().map(Self::from)
}
}
struct CatchPPInner {
attrs: CatchDifficultyAttributes,
mods: u32,
state: CatchScoreState,
}
impl CatchPPInner {
fn calculate(self) -> CatchPerformanceAttributes {
let attributes = &self.attrs;
let stars = attributes.stars;
let max_combo = attributes.max_combo();
// Relying heavily on aim
let mut pp = (5.0 * (stars / 0.0049).max(1.0) - 4.0).powi(2) / 100_000.0;
let mut combo_hits = self.combo_hits();
if combo_hits == 0 {
combo_hits = max_combo;
}
// Longer maps are worth more
let len_bonus = 0.95
+ 0.3 * (combo_hits as f64 / 2500.0).min(1.0)
+ f64::from(u8::from(combo_hits > 2500)) * (combo_hits as f64 / 2500.0).log10() * 0.475;
pp *= len_bonus;
// Penalize misses exponentially
pp *= 0.97_f64.powi(self.state.n_misses as i32);
// Combo scaling
if self.state.max_combo > 0 {
pp *= (self.state.max_combo as f64 / max_combo as f64)
.powf(0.8)
.min(1.0);
}
// AR scaling
let ar = attributes.ar;
let mut ar_factor = 1.0;
if ar > 9.0 {
ar_factor += 0.1 * (ar - 9.0) + f64::from(u8::from(ar > 10.0)) * 0.1 * (ar - 10.0);
} else if ar < 8.0 {
ar_factor += 0.025 * (8.0 - ar);
}
pp *= ar_factor;
// HD bonus
if self.mods.hd() {
if ar <= 10.0 {
pp *= 1.05 + 0.075 * (10.0 - ar);
} else if ar > 10.0 {
pp *= 1.01 + 0.04 * (11.0 - ar.min(11.0));
}
}
// FL bonus
if self.mods.fl() {
pp *= 1.35 * len_bonus;
}
// Accuracy scaling
pp *= self.state.accuracy().powf(5.5);
// NF penalty
if self.mods.nf() {
pp *= 0.9;
}
CatchPerformanceAttributes {
difficulty: self.attrs,
pp,
}
}
const fn combo_hits(&self) -> usize {
self.state.n_fruits + self.state.n_droplets + self.state.n_misses
}
}
fn accuracy(
n_fruits: usize,
n_droplets: usize,
n_tiny_droplets: usize,
n_tiny_droplet_misses: usize,
n_misses: usize,
) -> f64 {
let numerator = n_fruits + n_droplets + n_tiny_droplets;
let denominator = numerator + n_tiny_droplet_misses + n_misses;
numerator as f64 / denominator as f64
}
impl From<CatchDifficultyAttributes> for CatchPP<'_> {
fn from(attrs: CatchDifficultyAttributes) -> Self {
Self {
map_or_attrs: MapOrElse::Else(attrs),
mods: 0,
acc: None,
combo: None,
n_fruits: None,
n_droplets: None,
n_tiny_droplets: None,
n_tiny_droplet_misses: None,
n_misses: None,
passed_objects: None,
clock_rate: None,
}
}
}
impl From<CatchPerformanceAttributes> for CatchPP<'_> {
fn from(attrs: CatchPerformanceAttributes) -> Self {
attrs.difficulty.into()
}
}
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
pub trait CatchAttributeProvider {
/// Provide the actual difficulty attributes.
fn attributes(self) -> Option<CatchDifficultyAttributes>;
}
impl CatchAttributeProvider for CatchDifficultyAttributes {
#[inline]
fn attributes(self) -> Option<CatchDifficultyAttributes> {
Some(self)
}
}
impl CatchAttributeProvider for CatchPerformanceAttributes {
#[inline]
fn attributes(self) -> Option<CatchDifficultyAttributes> {
Some(self.difficulty)
}
}
impl CatchAttributeProvider for DifficultyAttributes {
#[inline]
fn attributes(self) -> Option<CatchDifficultyAttributes> {
if let Self::Catch(attributes) = self {
Some(attributes)
} else {
None
}
}
}
impl CatchAttributeProvider for PerformanceAttributes {
#[inline]
fn attributes(self) -> Option<CatchDifficultyAttributes> {
if let Self::Catch(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, CatchDifficultyAttributes)> = OnceLock::new();
const N_FRUITS: usize = 728;
const N_DROPLETS: usize = 2;
const N_TINY_DROPLETS: usize = 291;
fn test_data() -> (&'static Beatmap, CatchDifficultyAttributes) {
let (map, attrs) = DATA.get_or_init(|| {
let path = "./maps/2118524.osu";
let map = Beatmap::from_path(path).unwrap();
let attrs = CatchStars::new(&map).calculate();
assert_eq!(
(N_FRUITS, N_DROPLETS, N_TINY_DROPLETS),
(attrs.n_fruits, attrs.n_droplets, attrs.n_tiny_droplets)
);
(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,
n_fruits: Option<usize>,
n_droplets: Option<usize>,
n_tiny_droplets: Option<usize>,
n_tiny_droplet_misses: Option<usize>,
n_misses: usize,
) -> CatchScoreState {
let n_misses = n_misses.min(N_FRUITS + N_DROPLETS);
let mut best_state = CatchScoreState {
max_combo: N_FRUITS + N_DROPLETS - n_misses,
n_misses,
..Default::default()
};
let mut best_dist = f64::INFINITY;
let (new_fruits, new_droplets) = match (n_fruits, n_droplets) {
(Some(mut n_fruits), Some(mut n_droplets)) => {
let n_remaining =
(N_FRUITS + N_DROPLETS).saturating_sub(n_fruits + n_droplets + n_misses);
let new_droplets = n_remaining.min(N_DROPLETS.saturating_sub(n_droplets));
n_droplets += new_droplets;
n_fruits += n_remaining - new_droplets;
n_fruits =
n_fruits.min((N_FRUITS + N_DROPLETS).saturating_sub(n_droplets + n_misses));
n_droplets = n_droplets.min(N_FRUITS + N_DROPLETS - n_fruits - n_misses);
(n_fruits, n_droplets)
}
(Some(mut n_fruits), None) => {
let n_droplets = N_DROPLETS
.saturating_sub(n_misses.saturating_sub(N_FRUITS.saturating_sub(n_fruits)));
n_fruits = N_FRUITS + N_DROPLETS - n_misses - n_droplets;
(n_fruits, n_droplets)
}
(None, Some(mut n_droplets)) => {
let n_fruits = N_FRUITS
.saturating_sub(n_misses.saturating_sub(N_DROPLETS.saturating_sub(n_droplets)));
n_droplets = N_FRUITS + N_DROPLETS - n_misses - n_fruits;
(n_fruits, n_droplets)
}
(None, None) => {
let n_droplets = N_DROPLETS.saturating_sub(n_misses);
let n_fruits = N_FRUITS - (n_misses - (N_DROPLETS.saturating_sub(n_droplets)));
(n_fruits, n_droplets)
}
};
best_state.n_fruits = new_fruits;
best_state.n_droplets = new_droplets;
let (min_tiny_droplets, max_tiny_droplets) = match (n_tiny_droplets, n_tiny_droplet_misses)
{
(Some(n_tiny_droplets), Some(n_tiny_droplet_misses)) => {
match (n_tiny_droplets + n_tiny_droplet_misses).cmp(&N_TINY_DROPLETS) {
Ordering::Equal => (
N_TINY_DROPLETS.min(n_tiny_droplets),
N_TINY_DROPLETS.min(n_tiny_droplets),
),
Ordering::Less | Ordering::Greater => (0, N_TINY_DROPLETS),
}
}
(Some(n_tiny_droplets), None) => (
N_TINY_DROPLETS.min(n_tiny_droplets),
N_TINY_DROPLETS.min(n_tiny_droplets),
),
(None, Some(n_tiny_droplet_misses)) => (
N_TINY_DROPLETS.saturating_sub(n_tiny_droplet_misses),
N_TINY_DROPLETS.saturating_sub(n_tiny_droplet_misses),
),
(None, None) => (0, N_TINY_DROPLETS),
};
for new_tiny_droplets in min_tiny_droplets..=max_tiny_droplets {
let new_tiny_droplet_misses = N_TINY_DROPLETS - new_tiny_droplets;
let curr_acc = accuracy(
new_fruits,
new_droplets,
new_tiny_droplets,
new_tiny_droplet_misses,
n_misses,
);
let curr_dist = (acc - curr_acc).abs();
if curr_dist < best_dist {
best_dist = curr_dist;
best_state.n_tiny_droplets = new_tiny_droplets;
best_state.n_tiny_droplet_misses = new_tiny_droplet_misses;
}
}
best_state
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(20_000))]
#[test]
fn catch_hitresults(
acc in 0.0..=1.0,
n_fruits in option::weighted(0.10, 0_usize..=N_FRUITS + 10),
n_droplets in option::weighted(0.10, 0_usize..=N_DROPLETS + 10),
n_tiny_droplets in option::weighted(0.10, 0_usize..=N_TINY_DROPLETS + 10),
n_tiny_droplet_misses in option::weighted(0.10, 0_usize..=N_TINY_DROPLETS + 10),
n_misses in option::weighted(0.15, 0_usize..=N_FRUITS + N_DROPLETS + 10),
) {
let (map, attrs) = test_data();
let mut state = CatchPP::new(map)
.attributes(attrs)
.accuracy(acc * 100.0);
if let Some(n_fruits) = n_fruits {
state = state.fruits(n_fruits);
}
if let Some(n_droplets) = n_droplets {
state = state.droplets(n_droplets);
}
if let Some(n_tiny_droplets) = n_tiny_droplets {
state = state.tiny_droplets(n_tiny_droplets);
}
if let Some(n_tiny_droplet_misses) = n_tiny_droplet_misses {
state = state.tiny_droplet_misses(n_tiny_droplet_misses);
}
if let Some(n_misses) = n_misses {
state = state.misses(n_misses);
}
let state = state.generate_state();
let expected = brute_force_best(
acc,
n_fruits,
n_droplets,
n_tiny_droplets,
n_tiny_droplet_misses,
n_misses.unwrap_or(0),
);
assert_eq!(state, expected);
}
}
#[test]
fn fruits_missing_objects() {
let (map, attrs) = test_data();
let state = CatchPP::new(map)
.attributes(attrs)
.fruits(N_FRUITS - 10)
.droplets(N_DROPLETS - 1)
.tiny_droplets(N_TINY_DROPLETS - 50)
.tiny_droplet_misses(20)
.misses(2)
.generate_state();
let expected = CatchScoreState {
max_combo: N_FRUITS + N_DROPLETS - 2,
n_fruits: N_FRUITS - 2,
n_droplets: N_DROPLETS,
n_tiny_droplets: N_TINY_DROPLETS - 20,
n_tiny_droplet_misses: 20,
n_misses: 2,
};
assert_eq!(state, expected);
}
}
+10 -13
View File
@@ -1,22 +1,21 @@
/// 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(Clone, Debug, Default, PartialEq, Eq)]
pub struct CatchScoreState {
/// Maximum combo that the score has had so far.
/// **Not** the maximum possible combo of the map so far.
///
/// Note that only fruits and droplets are considered for osu!catch combo.
pub max_combo: usize,
pub max_combo: u32,
/// Amount of current fruits (300s).
pub n_fruits: usize,
pub n_fruits: u32,
/// Amount of current droplets (100s).
pub n_droplets: usize,
pub n_droplets: u32,
/// Amount of current tiny droplets (50s).
pub n_tiny_droplets: usize,
pub n_tiny_droplets: u32,
/// Amount of current tiny droplet misses (katus).
pub n_tiny_droplet_misses: usize,
pub n_tiny_droplet_misses: u32,
/// Amount of current misses (fruits and droplets).
pub n_misses: usize,
pub n_misses: u32,
}
impl CatchScoreState {
@@ -26,8 +25,7 @@ impl CatchScoreState {
}
/// 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.n_fruits
+ self.n_droplets
+ self.n_tiny_droplets
@@ -36,7 +34,6 @@ impl CatchScoreState {
}
/// Calculate the accuracy between `0.0` and `1.0` for this state.
#[inline]
pub fn accuracy(&self) -> f64 {
let total_hits = self.total_hits();
@@ -47,6 +44,6 @@ impl CatchScoreState {
let numerator = self.n_fruits + self.n_droplets + self.n_tiny_droplets;
let denominator = total_hits;
numerator as f64 / denominator as f64
f64::from(numerator) / f64::from(denominator)
}
}
+25
View File
@@ -0,0 +1,25 @@
use crate::{any::ModeDifficulty, catch::difficulty::DifficultyValues};
use super::convert::CatchBeatmap;
/// The result of calculating the strains on a osu!catch map.
///
/// Suitable to plot the difficulty of a map over time.
#[derive(Clone, Debug, PartialEq)]
pub struct CatchStrains {
/// Strain peaks of the movement skill.
pub movement: Vec<f64>,
}
impl CatchStrains {
/// Time between two strains in ms.
pub const SECTION_LEN: f64 = 750.0;
}
pub fn strains(difficulty: &ModeDifficulty, converted: &CatchBeatmap<'_>) -> CatchStrains {
let DifficultyValues { movement, .. } = DifficultyValues::calculate(difficulty, converted);
CatchStrains {
movement: movement.get_curr_strain_peaks(),
}
}