restructured workspace into single crate
This commit is contained in:
+148
@@ -0,0 +1,148 @@
|
||||
use crate::{math_util, Pos2};
|
||||
|
||||
const SLIDER_QUALITY: f32 = 50.0;
|
||||
|
||||
pub(crate) enum Points {
|
||||
Single(Pos2),
|
||||
Multi(Vec<Pos2>),
|
||||
}
|
||||
|
||||
pub(crate) enum Curve {
|
||||
Linear {
|
||||
a: Pos2,
|
||||
b: Pos2,
|
||||
},
|
||||
Bezier(Points),
|
||||
Catmull(Points),
|
||||
Perfect {
|
||||
origin: Pos2,
|
||||
cx: f32,
|
||||
cy: f32,
|
||||
radius: f32,
|
||||
},
|
||||
}
|
||||
|
||||
impl Curve {
|
||||
#[inline]
|
||||
pub(crate) fn linear(a: Pos2, b: Pos2) -> Self {
|
||||
Self::Linear { a, b }
|
||||
}
|
||||
|
||||
pub(crate) fn bezier(points: &[Pos2]) -> Self {
|
||||
if points.len() == 1 {
|
||||
return Self::Bezier(Points::Single(points[0]));
|
||||
}
|
||||
|
||||
let mut start = 0;
|
||||
let mut end = 0;
|
||||
let mut result = Vec::with_capacity(4);
|
||||
|
||||
for i in 0..points.len() - 1 {
|
||||
if end - start > 1 && points[i] == points[end - 1] {
|
||||
Self::_bezier(&mut result, &points[start..end]);
|
||||
start = end;
|
||||
}
|
||||
|
||||
end += 1;
|
||||
}
|
||||
|
||||
Self::_bezier(&mut result, &points[start..end + 1]);
|
||||
|
||||
Self::Bezier(Points::Multi(result))
|
||||
}
|
||||
|
||||
fn _bezier(result: &mut Vec<Pos2>, points: &[Pos2]) {
|
||||
let step = 0.25 / SLIDER_QUALITY / points.len() as f32;
|
||||
let mut i = 0.0;
|
||||
let n = points.len() as i32 - 1;
|
||||
|
||||
while i < 1.0 + step {
|
||||
let point = (0..=n).fold(Pos2 { x: 0.0, y: 0.0 }, |point, p| {
|
||||
let factor = math_util::cpn(p, n) * (1.0 - i).powi(n - p) * i.powi(p);
|
||||
|
||||
point + points[p as usize] * factor
|
||||
});
|
||||
|
||||
result.push(point);
|
||||
i += step;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn catmull(points: &[Pos2]) -> Self {
|
||||
if points.len() == 1 {
|
||||
return Self::Catmull(Points::Single(points[0]));
|
||||
}
|
||||
|
||||
let order = points.len();
|
||||
let step = 2.5 / SLIDER_QUALITY;
|
||||
let target = step + 1.0;
|
||||
|
||||
let mut resulting_points = Vec::with_capacity(4);
|
||||
|
||||
for x in 0..order - 1 {
|
||||
let mut t = 0.0;
|
||||
|
||||
while t < target {
|
||||
let v1 = if x >= 1 { points[x - 1] } else { points[x] };
|
||||
let v2 = points[x];
|
||||
|
||||
let v3 = if x + 1 < order {
|
||||
points[x + 1]
|
||||
} else {
|
||||
v2.add_scaled(v2.add_scaled(v1, -1.0), 1.0)
|
||||
};
|
||||
|
||||
let v4 = if x + 2 < order {
|
||||
points[x + 2]
|
||||
} else {
|
||||
v3.add_scaled(v3.add_scaled(v2, -1.0), 1.0)
|
||||
};
|
||||
|
||||
let point = Self::catmull_point(v1, v2, v3, v4, t);
|
||||
resulting_points.push(point);
|
||||
t += step;
|
||||
}
|
||||
}
|
||||
|
||||
Self::Catmull(Points::Multi(resulting_points))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn catmull_point(p0: Pos2, p1: Pos2, p2: Pos2, p3: Pos2, len: f32) -> Pos2 {
|
||||
Pos2 {
|
||||
x: math_util::catmull(p0.x, p1.x, p2.x, p3.x, len),
|
||||
y: math_util::catmull(p0.y, p1.y, p2.y, p3.y, len),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn perfect(points: &[Pos2]) -> Self {
|
||||
let (cx, cy, mut radius) = math_util::get_circum_circle(&points);
|
||||
radius *= ((!math_util::is_left(&points)) as i8 * 2 - 1) as f32;
|
||||
|
||||
Self::Perfect {
|
||||
origin: points[0],
|
||||
cx,
|
||||
cy,
|
||||
radius,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn point_at_distance(&self, len: f32) -> Pos2 {
|
||||
let points = match self {
|
||||
Self::Bezier(points) => points,
|
||||
Self::Catmull(points) => points,
|
||||
Self::Linear { a, b } => return math_util::point_on_line(*a, *b, len),
|
||||
Self::Perfect {
|
||||
origin,
|
||||
cx,
|
||||
cy,
|
||||
radius,
|
||||
} => return math_util::rotate(*cx, *cy, *origin, len / *radius),
|
||||
};
|
||||
|
||||
match points {
|
||||
Points::Single(point) => *point,
|
||||
Points::Multi(points) => math_util::point_at_distance(points, len),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
use crate::Pos2;
|
||||
|
||||
const PLAYFIELD_WIDHT: f32 = 512.0;
|
||||
const BASE_SPEED: f32 = 1.0;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CatchObject {
|
||||
pub(crate) pos: f32,
|
||||
pub(crate) time: f32,
|
||||
|
||||
pub(crate) hyper_dash: bool,
|
||||
pub(crate) hyper_dist: f32,
|
||||
}
|
||||
|
||||
impl CatchObject {
|
||||
#[inline]
|
||||
pub(crate) fn new((pos, time): (Pos2, f32)) -> Self {
|
||||
Self {
|
||||
pos: pos.x,
|
||||
time,
|
||||
hyper_dash: false,
|
||||
hyper_dist: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_hr(mut self, last_pos: &mut Option<f32>, last_time: &mut f32) -> Self {
|
||||
let mut offset_pos = self.pos;
|
||||
let time_diff = self.time - *last_time;
|
||||
|
||||
if let Some(last_pos_ref) = 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 / 3.0).floor() {
|
||||
if pos_diff > 0.0 {
|
||||
if offset_pos + pos_diff < PLAYFIELD_WIDHT {
|
||||
offset_pos += pos_diff;
|
||||
}
|
||||
} else if offset_pos + pos_diff > 0.0 {
|
||||
offset_pos += pos_diff;
|
||||
}
|
||||
}
|
||||
|
||||
last_pos.replace(offset_pos);
|
||||
*last_time = self.time;
|
||||
}
|
||||
|
||||
self.pos = offset_pos;
|
||||
} else {
|
||||
last_pos.replace(offset_pos);
|
||||
*last_time = self.time;
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn init_hyper_dash(
|
||||
&mut self,
|
||||
half_catcher_width: f32,
|
||||
next: &CatchObject,
|
||||
last_direction: &mut i8,
|
||||
last_excess: &mut f32,
|
||||
) {
|
||||
let next_x = next.pos;
|
||||
let curr_x = self.pos;
|
||||
|
||||
let this_direction = (next_x > curr_x) as i8 * 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 = (next_x - curr_x).abs() - sub;
|
||||
let hyper_dist = time_to_next * BASE_SPEED - dist_to_next;
|
||||
|
||||
if hyper_dist < 0.0 {
|
||||
self.hyper_dash = true;
|
||||
*last_excess = half_catcher_width;
|
||||
} else {
|
||||
self.hyper_dist = hyper_dist;
|
||||
*last_excess = hyper_dist.max(0.0).min(half_catcher_width);
|
||||
}
|
||||
|
||||
*last_direction = this_direction;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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: f32,
|
||||
|
||||
pub(crate) normalized_pos: f32,
|
||||
pub(crate) last_normalized_pos: f32,
|
||||
|
||||
pub(crate) strain_time: f32,
|
||||
pub(crate) clock_rate: f32,
|
||||
}
|
||||
|
||||
impl<'o> DifficultyObject<'o> {
|
||||
#[inline]
|
||||
pub(crate) fn new(
|
||||
base: &'o CatchObject,
|
||||
last: &'o CatchObject,
|
||||
half_catcher_width: f32,
|
||||
clock_rate: f32,
|
||||
) -> Self {
|
||||
let delta = (base.time - last.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,
|
||||
normalized_pos,
|
||||
last_normalized_pos,
|
||||
strain_time,
|
||||
clock_rate,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
mod catch_object;
|
||||
mod difficulty_object;
|
||||
mod movement;
|
||||
mod pp;
|
||||
|
||||
use catch_object::CatchObject;
|
||||
use difficulty_object::DifficultyObject;
|
||||
use movement::Movement;
|
||||
pub use pp::*;
|
||||
|
||||
use crate::{curve::Curve, Beatmap, HitObjectKind, Mods, PathType};
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::convert::identity;
|
||||
|
||||
const SECTION_LENGTH: f32 = 750.0;
|
||||
const STAR_SCALING_FACTOR: f32 = 0.153;
|
||||
|
||||
const ALLOWED_CATCH_RANGE: f32 = 0.8;
|
||||
const CATCHER_SIZE: f32 = 106.75;
|
||||
|
||||
macro_rules! binary_search {
|
||||
($slice:expr, $target:expr) => {
|
||||
$slice.binary_search_by(|p| p.time.partial_cmp(&$target).unwrap_or(Ordering::Equal))
|
||||
};
|
||||
}
|
||||
|
||||
/// Star calculation for osu!ctb maps
|
||||
// Slider parsing based on https://github.com/osufx/catch-the-pp
|
||||
pub fn stars(map: &Beatmap, mods: impl Mods) -> DifficultyAttributes {
|
||||
if map.hit_objects.len() < 2 {
|
||||
return DifficultyAttributes::default();
|
||||
}
|
||||
|
||||
let attributes = map.attributes().mods(mods);
|
||||
let with_hr = mods.hr();
|
||||
let mut ticks = Vec::new(); // using the same buffer for all sliders
|
||||
|
||||
let mut fruits = 0;
|
||||
let mut droplets = 0;
|
||||
|
||||
// BUG: Incorrect object order on 2B maps that have fruits within sliders
|
||||
let mut hit_objects = map
|
||||
.hit_objects
|
||||
.iter()
|
||||
.scan((None, 0.0), |(last_pos, last_time), h| match &h.kind {
|
||||
HitObjectKind::Circle => {
|
||||
let mut h = CatchObject::new((h.pos, h.start_time));
|
||||
|
||||
if with_hr {
|
||||
h = h.with_hr(last_pos, last_time);
|
||||
}
|
||||
|
||||
fruits += 1;
|
||||
|
||||
Some(Some(FruitOrJuice::Fruit(Some(h))))
|
||||
}
|
||||
HitObjectKind::Slider {
|
||||
pixel_len,
|
||||
repeats,
|
||||
curve_points,
|
||||
path_type,
|
||||
} => {
|
||||
// HR business
|
||||
last_pos
|
||||
.replace(h.pos.x + curve_points[curve_points.len() - 1].x - curve_points[0].x);
|
||||
*last_time = h.start_time;
|
||||
|
||||
let (beat_len, timing_time) = {
|
||||
match binary_search!(map.timing_points, h.start_time) {
|
||||
Ok(idx) => {
|
||||
let point = &map.timing_points[idx];
|
||||
(point.beat_len, point.time)
|
||||
}
|
||||
Err(0) => (1000.0, 0.0),
|
||||
Err(idx) => {
|
||||
let point = &map.timing_points[idx - 1];
|
||||
(point.beat_len, point.time)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let (speed_multiplier, diff_time) = {
|
||||
match binary_search!(map.difficulty_points, h.start_time) {
|
||||
Ok(idx) => {
|
||||
let point = &map.difficulty_points[idx];
|
||||
(point.speed_multiplier, point.time)
|
||||
}
|
||||
Err(0) => (1.0, 0.0),
|
||||
Err(idx) => {
|
||||
let point = &map.difficulty_points[idx - 1];
|
||||
(point.speed_multiplier, point.time)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut tick_distance = 100.0 * map.sv / map.tick_rate;
|
||||
|
||||
if map.version >= 8 {
|
||||
tick_distance /= (100.0 / speed_multiplier).max(10.0).min(1000.0) / 100.0;
|
||||
}
|
||||
|
||||
let spm = if timing_time > diff_time {
|
||||
1.0
|
||||
} else {
|
||||
speed_multiplier
|
||||
};
|
||||
|
||||
let duration = *repeats as f32 * beat_len * *pixel_len / (map.sv * spm) / 100.0;
|
||||
|
||||
let path_type = if *path_type == PathType::PerfectCurve && curve_points.len() > 3 {
|
||||
PathType::Bezier
|
||||
} else if curve_points.len() == 2 {
|
||||
PathType::Linear
|
||||
} else {
|
||||
*path_type
|
||||
};
|
||||
|
||||
let curve = match path_type {
|
||||
PathType::Linear => Curve::linear(curve_points[0], curve_points[1]),
|
||||
PathType::Bezier => Curve::bezier(curve_points),
|
||||
PathType::Catmull => Curve::catmull(curve_points),
|
||||
PathType::PerfectCurve => Curve::perfect(curve_points),
|
||||
};
|
||||
|
||||
let mut current_distance = tick_distance;
|
||||
let time_add = duration * (tick_distance / (*pixel_len * *repeats as f32));
|
||||
|
||||
let target = *pixel_len - tick_distance / 8.0;
|
||||
ticks.reserve((target / tick_distance) as usize);
|
||||
|
||||
while current_distance < target {
|
||||
let pos = curve.point_at_distance(current_distance);
|
||||
|
||||
ticks.push((pos, h.start_time + time_add * (ticks.len() + 1) as f32));
|
||||
current_distance += tick_distance;
|
||||
}
|
||||
|
||||
let mut slider_objects = Vec::with_capacity(repeats * (ticks.len() + 1));
|
||||
slider_objects.push((h.pos, h.start_time));
|
||||
|
||||
if *repeats <= 1 {
|
||||
slider_objects.append(&mut ticks); // automatically empties buffer for next slider
|
||||
} else {
|
||||
slider_objects.append(&mut ticks.clone());
|
||||
|
||||
for repeat_id in 1..*repeats - 1 {
|
||||
let dist = (repeat_id % 2) as f32 * *pixel_len;
|
||||
let time_offset = (duration / *repeats as f32) * repeat_id as f32;
|
||||
let pos = curve.point_at_distance(dist);
|
||||
|
||||
// Reverse tick / last legacy tick
|
||||
slider_objects.push((pos, h.start_time + time_offset));
|
||||
|
||||
ticks.reverse();
|
||||
slider_objects.extend_from_slice(&ticks); // tick time doesn't need to be adjusted for some reason
|
||||
}
|
||||
|
||||
// Handling last span separatly so that `ticks` vector isn't cloned again
|
||||
let dist = ((*repeats - 1) % 2) as f32 * *pixel_len;
|
||||
let time_offset = (duration / *repeats as f32) * (*repeats - 1) as f32;
|
||||
let pos = curve.point_at_distance(dist);
|
||||
|
||||
slider_objects.push((pos, h.start_time + time_offset));
|
||||
|
||||
ticks.reverse();
|
||||
slider_objects.append(&mut ticks); // automatically empties buffer for next slider
|
||||
}
|
||||
|
||||
// Slider tail
|
||||
let dist_end = (*repeats % 2) as f32 * *pixel_len;
|
||||
let pos = curve.point_at_distance(dist_end);
|
||||
slider_objects.push((pos, h.start_time + duration));
|
||||
|
||||
fruits += 1 + *repeats;
|
||||
droplets += slider_objects.len() - 1 - *repeats;
|
||||
|
||||
let iter = slider_objects.into_iter().map(CatchObject::new);
|
||||
|
||||
Some(Some(FruitOrJuice::Juice(iter)))
|
||||
}
|
||||
HitObjectKind::Spinner { .. } | HitObjectKind::Hold { .. } => Some(None),
|
||||
})
|
||||
.filter_map(identity)
|
||||
.flatten();
|
||||
|
||||
// Hyper dash business
|
||||
let half_catcher_width = calculate_catch_width(attributes.cs) / 2.0 / ALLOWED_CATCH_RANGE;
|
||||
let mut last_direction = 0;
|
||||
let mut last_excess = half_catcher_width;
|
||||
|
||||
// Strain business
|
||||
let mut movement = Movement::new(attributes.cs);
|
||||
let section_len = SECTION_LENGTH * attributes.clock_rate;
|
||||
let mut current_section_end =
|
||||
(map.hit_objects[0].start_time / section_len).ceil() * section_len;
|
||||
|
||||
let mut prev = hit_objects.next().unwrap();
|
||||
let mut curr = hit_objects.next().unwrap();
|
||||
|
||||
prev.init_hyper_dash(
|
||||
half_catcher_width,
|
||||
&curr,
|
||||
&mut last_direction,
|
||||
&mut last_excess,
|
||||
);
|
||||
|
||||
for next in hit_objects {
|
||||
curr.init_hyper_dash(
|
||||
half_catcher_width,
|
||||
&next,
|
||||
&mut last_direction,
|
||||
&mut last_excess,
|
||||
);
|
||||
|
||||
let h = DifficultyObject::new(
|
||||
&curr,
|
||||
&prev,
|
||||
movement.half_catcher_width,
|
||||
attributes.clock_rate,
|
||||
);
|
||||
|
||||
while h.base.time > current_section_end {
|
||||
movement.save_current_peak();
|
||||
movement.start_new_section_from(current_section_end);
|
||||
current_section_end += section_len;
|
||||
}
|
||||
|
||||
movement.process(&h);
|
||||
|
||||
prev = curr;
|
||||
curr = next;
|
||||
}
|
||||
|
||||
// Same as in loop but without init_hyper_dash because `curr` is the last element
|
||||
let h = DifficultyObject::new(
|
||||
&curr,
|
||||
&prev,
|
||||
movement.half_catcher_width,
|
||||
attributes.clock_rate,
|
||||
);
|
||||
|
||||
while h.base.time > current_section_end {
|
||||
movement.save_current_peak();
|
||||
movement.start_new_section_from(current_section_end);
|
||||
|
||||
current_section_end += section_len;
|
||||
}
|
||||
|
||||
movement.process(&h);
|
||||
movement.save_current_peak();
|
||||
|
||||
let stars = movement.difficulty_value().sqrt() * STAR_SCALING_FACTOR;
|
||||
|
||||
DifficultyAttributes {
|
||||
stars,
|
||||
n_fruits: fruits,
|
||||
n_droplets: droplets,
|
||||
max_combo: fruits + droplets,
|
||||
}
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
||||
|
||||
enum FruitOrJuice<I> {
|
||||
Fruit(Option<CatchObject>),
|
||||
Juice(I),
|
||||
}
|
||||
|
||||
impl<I: Iterator<Item = CatchObject>> Iterator for FruitOrJuice<I> {
|
||||
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>) {
|
||||
match self {
|
||||
Self::Fruit(Some(_)) => (1, Some(1)),
|
||||
Self::Fruit(None) => (0, Some(0)),
|
||||
Self::Juice(slider) => slider.size_hint(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DifficultyAttributes {
|
||||
pub stars: f32,
|
||||
pub max_combo: usize,
|
||||
pub n_fruits: usize,
|
||||
pub n_droplets: usize,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::File;
|
||||
|
||||
#[test]
|
||||
fn test_single() {
|
||||
let map_id = 1972149;
|
||||
let file = match File::open(format!("E:/Games/osu!/beatmaps/{}.osu", map_id)) {
|
||||
Ok(file) => file,
|
||||
Err(why) => panic!("Could not open file: {}", why),
|
||||
};
|
||||
// let file = match File::open(format!("E:/Games/osu!/beatmaps/{}.osu", map_id)) {
|
||||
// Ok(file) => file,
|
||||
// Err(why) => panic!("Could not open file: {}", why),
|
||||
// };
|
||||
|
||||
let map = match Beatmap::parse(file) {
|
||||
Ok(map) => map,
|
||||
Err(why) => panic!("Error while parsing map: {}", why),
|
||||
};
|
||||
|
||||
let mods = 0;
|
||||
let stars = stars(&map, mods).stars;
|
||||
|
||||
println!("Stars: {} [map={} | mods={}]", stars, map_id, mods);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fruits() {
|
||||
let margin = 0.005;
|
||||
|
||||
#[rustfmt::skip]
|
||||
let data = vec![
|
||||
(1977380, 1 << 8, 2.0564713386286573),// HT
|
||||
(1977380, 0, 2.5695489769068742), // NM
|
||||
(1977380, 1 << 6, 3.589887228221038), // DT
|
||||
(1977380, 1 << 4, 3.1515873669521928),// HR
|
||||
(1977380, 1 << 1, 3.0035260129778396),// EZ
|
||||
|
||||
(1974968, 1 << 8, 1.9544305373156605),// HT
|
||||
(1974968, 0, 2.521701539665241), // NM
|
||||
(1974968, 1 << 6, 3.650649037957456), // DT
|
||||
(1974968, 1 << 4, 3.566302788963401), // HR
|
||||
(1974968, 1 << 1, 2.2029392066882654),// EZ
|
||||
|
||||
(2420076, 1 << 8, 4.791039358886245), // HT
|
||||
(2420076, 0, 6.223136555625056), // NM
|
||||
(2420076, 1 << 6, 8.908315960310958), // DT
|
||||
(2420076, 1 << 4, 6.54788067620051), // HR
|
||||
(2420076, 1 << 1, 6.067971540209479), // EZ
|
||||
|
||||
(2206596, 1 << 8, 4.767182611189798), // HT
|
||||
(2206596, 0, 6.157660207091584), // NM
|
||||
(2206596, 1 << 6, 8.93391286552717), // DT
|
||||
(2206596, 1 << 4, 6.8639096665110735),// HR
|
||||
(2206596, 1 << 1, 5.60279198088948), // EZ
|
||||
|
||||
// Super long juice stream towards end
|
||||
// (1972149, 1 << 8, 4.671425766413811), // HT
|
||||
// (1972149, 0, 6.043742871084152), // NM
|
||||
// (1972149, 1 << 6, 8.469259368304225), // DT
|
||||
// (1972149, 1 << 4, 6.81222485322862), // HR
|
||||
// (1972149, 1 << 1, 5.289343020686747), // EZ
|
||||
|
||||
// Convert slider fiesta
|
||||
// (1657535, 1 << 8, 3.862453635711741), // HT
|
||||
// (1657535, 0, 4.792543335869686), // NM
|
||||
// (1657535, 1 << 6, 6.655478646330863), // DT
|
||||
// (1657535, 1 << 4, 5.259728567781568), // HR
|
||||
// (1657535, 1 << 1, 4.127535166776765), // EZ
|
||||
];
|
||||
|
||||
for (map_id, mods, expected_stars) in data {
|
||||
let file = match File::open(format!("./test/{}.osu", map_id)) {
|
||||
Ok(file) => file,
|
||||
Err(why) => panic!("Could not open file {}.osu: {}", map_id, why),
|
||||
};
|
||||
|
||||
let map = match Beatmap::parse(file) {
|
||||
Ok(map) => map,
|
||||
Err(why) => panic!("Error while parsing map {}: {}", map_id, why),
|
||||
};
|
||||
|
||||
let stars = stars(&map, mods).stars;
|
||||
|
||||
assert!(
|
||||
(stars - expected_stars).abs() < margin,
|
||||
"Stars: {} | Expected: {} => {} margin [map {} | mods {}]",
|
||||
stars,
|
||||
expected_stars,
|
||||
(stars - expected_stars).abs(),
|
||||
map_id,
|
||||
mods
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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: f32 = 21.0;
|
||||
const SKILL_MULTIPLIER: f32 = 900.0;
|
||||
const STRAIN_DECAY_BASE: f32 = 0.2;
|
||||
const DECAY_WEIGHT: f32 = 0.94;
|
||||
|
||||
pub(crate) struct Movement {
|
||||
pub(crate) half_catcher_width: f32,
|
||||
|
||||
last_player_position: Option<f32>,
|
||||
last_distance_moved: f32,
|
||||
last_strain_time: f32,
|
||||
|
||||
current_strain: f32,
|
||||
current_section_peak: f32,
|
||||
|
||||
strain_peaks: Vec<f32>,
|
||||
prev_time: Option<f32>,
|
||||
}
|
||||
|
||||
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,
|
||||
current_section_peak: 1.0,
|
||||
|
||||
strain_peaks: Vec::with_capacity(128),
|
||||
prev_time: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn save_current_peak(&mut self) {
|
||||
// TODO: Remove branching
|
||||
if self.prev_time.is_some() {
|
||||
self.strain_peaks.push(self.current_section_peak);
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn start_new_section_from(&mut self, time: f32) {
|
||||
if let Some(prev_time) = self.prev_time {
|
||||
self.current_section_peak = self.peak_strain(time - prev_time);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn process(&mut self, current: &DifficultyObject) {
|
||||
self.current_strain *= strain_decay(current.delta);
|
||||
self.current_strain += self.strain_value_of(¤t) * SKILL_MULTIPLIER;
|
||||
self.current_section_peak = self.current_strain.max(self.current_section_peak);
|
||||
self.prev_time.replace(current.base.time);
|
||||
}
|
||||
|
||||
pub(crate) fn difficulty_value(&mut self) -> f32 {
|
||||
let mut difficulty = 0.0;
|
||||
let mut weight = 1.0;
|
||||
|
||||
self.strain_peaks
|
||||
.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
|
||||
|
||||
for &strain in self.strain_peaks.iter() {
|
||||
difficulty += strain * weight;
|
||||
weight *= DECAY_WEIGHT;
|
||||
}
|
||||
|
||||
difficulty
|
||||
}
|
||||
|
||||
fn strain_value_of(&mut self, current: &DifficultyObject) -> f32 {
|
||||
let last_player_pos = self
|
||||
.last_player_position
|
||||
.unwrap_or(current.last_normalized_pos);
|
||||
|
||||
let mut pos = last_player_pos
|
||||
.max(current.normalized_pos - POSITION_EPSILON)
|
||||
.min(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 = 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 = dist_moved.abs().min(50.0) / 50.0;
|
||||
let anti_flow_factor = (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 += 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 {
|
||||
if !current.last.hyper_dash {
|
||||
edge_dash_bonus += 5.7;
|
||||
} else {
|
||||
pos = current.normalized_pos;
|
||||
}
|
||||
|
||||
dist_addition *= 1.0
|
||||
+ edge_dash_bonus
|
||||
* ((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: f32) -> f32 {
|
||||
self.current_strain * strain_decay(delta_time)
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_decay(ms: f32) -> f32 {
|
||||
STRAIN_DECAY_BASE.powf(ms / 1000.0)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
use super::{stars, DifficultyAttributes};
|
||||
use crate::{Beatmap, Mods};
|
||||
|
||||
pub struct PpResult {
|
||||
pub pp: f32,
|
||||
pub stars: f32,
|
||||
}
|
||||
|
||||
pub trait PpProvider {
|
||||
fn pp(&self) -> PpCalculator;
|
||||
}
|
||||
|
||||
impl PpProvider for Beatmap {
|
||||
#[inline]
|
||||
fn pp(&self) -> PpCalculator {
|
||||
PpCalculator::new(self)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Allow partial plays
|
||||
pub struct PpCalculator<'m> {
|
||||
map: &'m Beatmap,
|
||||
attributes: Option<DifficultyAttributes>,
|
||||
mods: u32,
|
||||
combo: Option<usize>,
|
||||
|
||||
n_fruits: Option<usize>,
|
||||
n_droplets: Option<usize>,
|
||||
n_tiny_droplets: Option<usize>,
|
||||
n_tiny_droplet_misses: Option<usize>,
|
||||
n_misses: usize,
|
||||
}
|
||||
|
||||
impl<'m> PpCalculator<'m> {
|
||||
#[inline]
|
||||
pub fn new(map: &'m Beatmap) -> Self {
|
||||
Self {
|
||||
map,
|
||||
attributes: None,
|
||||
mods: 0,
|
||||
combo: None,
|
||||
|
||||
n_fruits: None,
|
||||
n_droplets: None,
|
||||
n_tiny_droplets: None,
|
||||
n_tiny_droplet_misses: None,
|
||||
n_misses: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn attributes(mut self, attributes: DifficultyAttributes) -> Self {
|
||||
self.attributes.replace(attributes);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn mods(mut self, mods: u32) -> Self {
|
||||
self.mods = mods;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn combo(mut self, combo: usize) -> Self {
|
||||
self.combo.replace(combo);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn fruits(mut self, n_fruits: usize) -> Self {
|
||||
self.n_fruits.replace(n_fruits);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn droplets(mut self, n_droplets: usize) -> Self {
|
||||
self.n_droplets.replace(n_droplets);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn tiny_droplets(mut self, n_tiny_droplets: usize) -> Self {
|
||||
self.n_tiny_droplets.replace(n_tiny_droplets);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn tiny_droplet_misses(mut self, n_tiny_droplet_misses: usize) -> Self {
|
||||
self.n_tiny_droplet_misses.replace(n_tiny_droplet_misses);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn misses(mut self, n_misses: usize) -> Self {
|
||||
self.n_misses = n_misses;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Generate the hit results with respect to the given accuracy between `0` and `100`.
|
||||
///
|
||||
/// Be sure to set `misses` beforehand! Also, if available, set `attributes` beforehand.
|
||||
pub fn accuracy(mut self, acc: f32) -> Self {
|
||||
if self.attributes.is_none() {
|
||||
self.attributes.replace(stars(self.map, self.mods));
|
||||
}
|
||||
|
||||
let attributes = self.attributes.as_ref().unwrap();
|
||||
|
||||
let n_droplets = self
|
||||
.n_droplets
|
||||
.unwrap_or_else(|| attributes.n_droplets.saturating_sub(self.n_misses));
|
||||
|
||||
let n_fruits = self.n_fruits.unwrap_or_else(|| {
|
||||
attributes
|
||||
.max_combo
|
||||
.saturating_sub(self.n_misses.saturating_sub(n_droplets))
|
||||
});
|
||||
|
||||
let max_tiny_droplets = 0; // TODO
|
||||
|
||||
let n_tiny_droplets = self.n_tiny_droplets.unwrap_or_else(|| {
|
||||
((acc * (attributes.max_combo + max_tiny_droplets) as f32).round() as usize)
|
||||
.saturating_sub(n_fruits)
|
||||
.saturating_sub(n_droplets)
|
||||
});
|
||||
|
||||
let n_tiny_droplet_misses = max_tiny_droplets - n_tiny_droplets;
|
||||
|
||||
self.n_fruits.replace(n_fruits);
|
||||
self.n_droplets.replace(n_droplets);
|
||||
self.n_tiny_droplets.replace(n_tiny_droplets);
|
||||
self.n_tiny_droplet_misses.replace(n_tiny_droplet_misses);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn calculate(mut self) -> PpResult {
|
||||
let attributes = self
|
||||
.attributes
|
||||
.take()
|
||||
.unwrap_or_else(|| stars(self.map, self.mods));
|
||||
|
||||
let stars = attributes.stars;
|
||||
|
||||
// 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 = attributes.max_combo;
|
||||
}
|
||||
|
||||
// Longer maps are worth more
|
||||
let len_bonus = 0.95
|
||||
+ 0.3 * (combo_hits as f32 / 2500.0).min(1.0)
|
||||
+ (combo_hits > 2500) as u8 as f32 * (combo_hits as f32 / 2500.0).log10() * 0.475;
|
||||
pp *= len_bonus;
|
||||
|
||||
// Penalize misses exponentially
|
||||
pp *= 0.97_f32.powi(self.n_misses as i32);
|
||||
|
||||
// Combo scaling
|
||||
if let Some(combo) = self.combo.filter(|_| attributes.max_combo > 0) {
|
||||
pp *= (combo as f32 / attributes.max_combo as f32)
|
||||
.powf(0.8)
|
||||
.min(1.0);
|
||||
}
|
||||
|
||||
// AR scaling
|
||||
let ar = self.map.ar;
|
||||
let mut ar_factor = 1.0;
|
||||
if ar > 9.0 {
|
||||
ar_factor += 0.1 * (ar - 9.0) + (ar > 10.0) as u8 as f32 * 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.acc().powf(5.5);
|
||||
|
||||
// NF penalty
|
||||
if self.mods.nf() {
|
||||
pp *= 0.9;
|
||||
}
|
||||
|
||||
PpResult { pp, stars }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn combo_hits(&self) -> usize {
|
||||
self.n_fruits.unwrap_or(0) + self.n_droplets.unwrap_or(0) + self.n_misses
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn successful_hits(&self) -> usize {
|
||||
self.n_fruits.unwrap_or(0)
|
||||
+ self.n_droplets.unwrap_or(0)
|
||||
+ self.n_tiny_droplets.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn total_hits(&self) -> usize {
|
||||
self.successful_hits() + self.n_tiny_droplet_misses.unwrap_or(0) + self.n_misses
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn acc(&self) -> f32 {
|
||||
let total_hits = self.total_hits();
|
||||
|
||||
if total_hits == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(self.successful_hits() as f32 / total_hits as f32)
|
||||
.max(0.0)
|
||||
.min(1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
pub mod fruits;
|
||||
pub mod mania;
|
||||
pub mod osu;
|
||||
pub mod parse;
|
||||
pub mod taiko;
|
||||
|
||||
mod curve;
|
||||
mod math_util;
|
||||
|
||||
pub use parse::*;
|
||||
|
||||
#[inline]
|
||||
fn difficulty_range(val: f32, max: f32, avg: f32, min: f32) -> f32 {
|
||||
if val > 5.0 {
|
||||
avg + (max - avg) * (val - 5.0) / 5.0
|
||||
} else if val < 5.0 {
|
||||
avg - (avg - min) * (5.0 - val) / 5.0
|
||||
} else {
|
||||
avg
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
mod strain;
|
||||
|
||||
use strain::Strain;
|
||||
|
||||
use crate::{Beatmap, HitObject, Mods};
|
||||
|
||||
const SECTION_LEN: f32 = 400.0;
|
||||
const STAR_SCALING_FACTOR: f32 = 0.018;
|
||||
|
||||
/// Star calculation for osu!mania maps
|
||||
pub fn stars(map: &Beatmap, mods: impl Mods) -> f32 {
|
||||
if map.hit_objects.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let clock_rate = mods.speed();
|
||||
let section_len = SECTION_LEN * clock_rate;
|
||||
let mut strain = Strain::new(map.cs as u8);
|
||||
|
||||
let hit_objects = map
|
||||
.hit_objects
|
||||
.iter()
|
||||
.skip(1)
|
||||
.zip(map.hit_objects.iter())
|
||||
.map(|(base, prev)| DifficultyHitObject::new(base, prev, map.cs, clock_rate));
|
||||
|
||||
// No strain for first object
|
||||
let mut current_section_end =
|
||||
(map.hit_objects[0].start_time / section_len).ceil() * section_len;
|
||||
|
||||
for h in hit_objects {
|
||||
while h.base.start_time > current_section_end {
|
||||
strain.save_current_peak();
|
||||
strain.start_new_section_from(current_section_end);
|
||||
|
||||
current_section_end += section_len;
|
||||
}
|
||||
|
||||
strain.process(&h);
|
||||
}
|
||||
|
||||
strain.save_current_peak();
|
||||
|
||||
strain.difficulty_value() * STAR_SCALING_FACTOR
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct DifficultyHitObject<'o> {
|
||||
base: &'o HitObject,
|
||||
column: usize,
|
||||
delta: f32,
|
||||
}
|
||||
|
||||
impl<'o> DifficultyHitObject<'o> {
|
||||
fn new(base: &'o HitObject, prev: &'o HitObject, cs: f32, clock_rate: f32) -> Self {
|
||||
let x_divisor = 512.0 / cs;
|
||||
let column = (base.pos.x / x_divisor).floor() as usize;
|
||||
|
||||
Self {
|
||||
base,
|
||||
column,
|
||||
delta: (base.start_time - prev.start_time) / clock_rate,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PpResult {
|
||||
pub pp: f32,
|
||||
pub stars: f32,
|
||||
}
|
||||
|
||||
pub trait PpProvider {
|
||||
fn pp(&self) -> PpCalculator;
|
||||
}
|
||||
|
||||
impl PpProvider for Beatmap {
|
||||
#[inline]
|
||||
fn pp(&self) -> PpCalculator {
|
||||
PpCalculator::new(self)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Allow partial plays
|
||||
pub struct PpCalculator<'m> {
|
||||
map: &'m Beatmap,
|
||||
stars: Option<f32>,
|
||||
mods: u32,
|
||||
score: f32,
|
||||
}
|
||||
|
||||
impl<'m> PpCalculator<'m> {
|
||||
#[inline]
|
||||
pub fn new(map: &'m Beatmap) -> Self {
|
||||
Self {
|
||||
map,
|
||||
stars: None,
|
||||
mods: 0,
|
||||
score: 1_000_000.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn stars(mut self, stars: f32) -> Self {
|
||||
self.stars.replace(stars);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn mods(mut self, mods: u32) -> Self {
|
||||
self.mods = mods;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn score(mut self, score: u32) -> Self {
|
||||
self.score = score as f32;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn calculate(self) -> PpResult {
|
||||
let stars = self.stars.unwrap_or_else(|| stars(self.map, self.mods));
|
||||
|
||||
let ez = self.mods.ez();
|
||||
let nf = self.mods.nf();
|
||||
let ht = self.mods.ht();
|
||||
|
||||
let score_multiplier = 0.5_f32.powi(ez as i32 + nf as i32 + ht as i32);
|
||||
let scaled_score = self.score / score_multiplier;
|
||||
|
||||
let mut multiplier = 0.8;
|
||||
|
||||
if nf {
|
||||
multiplier *= 0.9;
|
||||
}
|
||||
|
||||
if ez {
|
||||
multiplier *= 0.5;
|
||||
}
|
||||
|
||||
let hit_window = {
|
||||
let od = (10.0 - self.map.od).max(0.0).min(10.0);
|
||||
let mut val = 34.0 + 3.0 * od;
|
||||
|
||||
if ez {
|
||||
val *= 1.4;
|
||||
} else if self.mods.hr() {
|
||||
val /= 1.4;
|
||||
}
|
||||
|
||||
val * self.mods.speed()
|
||||
};
|
||||
|
||||
let strain_value = self.compute_strain(scaled_score, stars);
|
||||
let acc_value = self.compute_accuracy_value(scaled_score, strain_value, hit_window);
|
||||
|
||||
let pp = (strain_value.powf(1.1) + acc_value.powf(1.1)).powf(1.0 / 1.1) * multiplier;
|
||||
|
||||
PpResult { pp, stars }
|
||||
}
|
||||
|
||||
fn compute_strain(&self, score: f32, stars: f32) -> f32 {
|
||||
let mut strain_value = (5.0 * (stars / 0.2).max(1.0) - 4.0).powf(2.2) / 135.0;
|
||||
|
||||
strain_value *= 1.0 + 0.1 * (self.map.hit_objects.len() as f32 / 1500.0).min(1.0);
|
||||
|
||||
if score <= 500_000.0 {
|
||||
strain_value = 0.0;
|
||||
} else if score <= 600_000.0 {
|
||||
strain_value *= (score - 500_000.0) / 100_000.0 * 0.3;
|
||||
} else if score <= 700_000.0 {
|
||||
strain_value *= 0.3 + (score - 600_000.0) / 100_000.0 * 0.25;
|
||||
} else if score <= 800_000.0 {
|
||||
strain_value *= 0.55 + (score - 700_000.0) / 100_000.0 * 0.2;
|
||||
} else if score <= 900_000.0 {
|
||||
strain_value *= 0.75 + (score - 800_000.0) / 100_000.0 * 0.15;
|
||||
} else {
|
||||
strain_value *= 0.9 + (score - 900_000.0) / 100_000.0 * 0.1;
|
||||
}
|
||||
|
||||
strain_value
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn compute_accuracy_value(&self, score: f32, strain: f32, hit_window: f32) -> f32 {
|
||||
(0.2 - (hit_window - 34.0) * 0.006667).max(0.0)
|
||||
* strain
|
||||
* ((score - 960_000.0).max(0.0) / 40_000.0).powf(1.1)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::File;
|
||||
|
||||
#[test]
|
||||
fn test_single() {
|
||||
let file = match File::open("E:/Games/osu!/beatmaps/1355822.osu") {
|
||||
Ok(file) => file,
|
||||
Err(why) => panic!("Could not open file: {}", why),
|
||||
};
|
||||
|
||||
let map = match Beatmap::parse(file) {
|
||||
Ok(map) => map,
|
||||
Err(why) => panic!("Error while parsing map: {}", why),
|
||||
};
|
||||
|
||||
let stars = stars(&map, 256);
|
||||
|
||||
println!("Stars: {}", stars);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mania() {
|
||||
let margin = 0.005;
|
||||
|
||||
#[rustfmt::skip]
|
||||
let data = vec![
|
||||
(1355822, 1 << 8, 2.2710870990702627), // HT
|
||||
(1355822, 0, 2.7966565927524574), // NM
|
||||
(1355822, 1 << 6, 3.748525363730352), // DT
|
||||
|
||||
(1974394, 1 << 8, 3.8736942117487256), // HT
|
||||
(1974394, 0, 4.801793001581714), // NM
|
||||
(1974394, 1 << 6, 6.517894438878535), // DT
|
||||
|
||||
(992512, 1 << 8, 5.29507262961579), // HT
|
||||
(992512, 0, 6.536292432114728), // NM
|
||||
(992512, 1 << 6, 8.944195050951032), // DT
|
||||
];
|
||||
|
||||
for (map_id, mods, expected_stars) in data {
|
||||
let file = match File::open(format!("./test/{}.osu", map_id)) {
|
||||
Ok(file) => file,
|
||||
Err(why) => panic!("Could not open file {}.osu: {}", map_id, why),
|
||||
};
|
||||
|
||||
let map = match Beatmap::parse(file) {
|
||||
Ok(map) => map,
|
||||
Err(why) => panic!("Error while parsing map {}: {}", map_id, why),
|
||||
};
|
||||
|
||||
let stars = stars(&map, mods);
|
||||
|
||||
assert!(
|
||||
(stars - expected_stars).abs() < margin,
|
||||
"Stars: {} | Expected: {} => {} margin [map {} | mods {}]",
|
||||
stars,
|
||||
expected_stars,
|
||||
(stars - expected_stars).abs(),
|
||||
map_id,
|
||||
mods
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
use super::DifficultyHitObject;
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
pub(crate) struct Strain {
|
||||
current_strain: f32,
|
||||
current_section_peak: f32,
|
||||
|
||||
individual_strain: f32,
|
||||
overall_strain: f32,
|
||||
|
||||
hold_end_times: Vec<f32>,
|
||||
individual_strains: Vec<f32>,
|
||||
pub strain_peaks: Vec<f32>,
|
||||
|
||||
prev_time: Option<f32>,
|
||||
}
|
||||
|
||||
const INDIVISUAL_DECAY_BASE: f32 = 0.125;
|
||||
const OVERALL_DECAY_BASE: f32 = 0.3;
|
||||
const STRAIN_DECAY_BASE: f32 = 1.0;
|
||||
|
||||
const SKILL_MULTIPLIER: f32 = 1.0;
|
||||
const DECAY_WEIGHT: f32 = 0.9;
|
||||
|
||||
impl Strain {
|
||||
#[inline]
|
||||
pub(crate) fn new(column_count: u8) -> Self {
|
||||
Self {
|
||||
current_strain: 1.0,
|
||||
current_section_peak: 1.0,
|
||||
|
||||
individual_strain: 0.0,
|
||||
overall_strain: 1.0,
|
||||
|
||||
hold_end_times: vec![0.0; column_count as usize],
|
||||
individual_strains: vec![0.0; column_count as usize],
|
||||
strain_peaks: Vec::with_capacity(128),
|
||||
|
||||
prev_time: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn save_current_peak(&mut self) {
|
||||
// TODO: Remove branching
|
||||
if self.prev_time.is_some() {
|
||||
self.strain_peaks.push(self.current_section_peak);
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn start_new_section_from(&mut self, time: f32) {
|
||||
if let Some(prev) = self.prev_time {
|
||||
self.current_section_peak = self.peak_strain(time - prev);
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn peak_strain(&self, delta_time: f32) -> f32 {
|
||||
apply_decay(self.individual_strain, delta_time, INDIVISUAL_DECAY_BASE)
|
||||
+ apply_decay(self.overall_strain, delta_time, OVERALL_DECAY_BASE)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_decay(&self, ms: f32) -> f32 {
|
||||
STRAIN_DECAY_BASE.powf(ms / 1000.0)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn process(&mut self, current: &DifficultyHitObject) {
|
||||
self.current_strain *= self.strain_decay(current.delta);
|
||||
self.current_strain += self.strain_value_of(¤t) * SKILL_MULTIPLIER;
|
||||
self.current_section_peak = self.current_strain.max(self.current_section_peak);
|
||||
self.prev_time.replace(current.base.start_time);
|
||||
}
|
||||
|
||||
fn strain_value_of(&mut self, current: &DifficultyHitObject) -> f32 {
|
||||
let end_time = current.base.end_time();
|
||||
|
||||
let mut hold_factor = 1.0;
|
||||
let mut hold_addition = 0.0;
|
||||
|
||||
for col in 0..self.hold_end_times.len() {
|
||||
let hold_end_time = self.hold_end_times[col as usize];
|
||||
|
||||
if end_time > hold_end_time + 1.0 {
|
||||
if hold_end_time > current.base.start_time + 1.0 {
|
||||
hold_addition = 1.0;
|
||||
}
|
||||
} else if (end_time - hold_end_time).abs() < 1.0 {
|
||||
hold_addition = 0.0;
|
||||
} else if end_time < hold_end_time - 1.0 {
|
||||
hold_factor = 1.25;
|
||||
}
|
||||
|
||||
self.individual_strains[col] = apply_decay(
|
||||
self.individual_strains[col],
|
||||
current.delta,
|
||||
INDIVISUAL_DECAY_BASE,
|
||||
);
|
||||
}
|
||||
|
||||
self.hold_end_times[current.column] = end_time;
|
||||
self.individual_strains[current.column] += 2.0 * hold_factor;
|
||||
self.individual_strain = self.individual_strains[current.column];
|
||||
|
||||
self.overall_strain = apply_decay(self.overall_strain, current.delta, OVERALL_DECAY_BASE)
|
||||
+ (1.0 + hold_addition) * hold_factor;
|
||||
|
||||
self.individual_strain + self.overall_strain - self.current_strain
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn difficulty_value(&mut self) -> f32 {
|
||||
let mut difficulty = 0.0;
|
||||
let mut weight = 1.0;
|
||||
|
||||
self.strain_peaks
|
||||
.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
|
||||
|
||||
for &strain in self.strain_peaks.iter() {
|
||||
difficulty += strain * weight;
|
||||
weight *= DECAY_WEIGHT;
|
||||
}
|
||||
|
||||
difficulty
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn apply_decay(value: f32, delta_time: f32, decay_base: f32) -> f32 {
|
||||
value * decay_base.powf(delta_time / 1000.0)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
use crate::Pos2;
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn cpn(mut p: i32, n: i32) -> f32 {
|
||||
if p < 0 || p > n {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
p = p.min(n - p);
|
||||
let mut out = 1.0;
|
||||
|
||||
for i in 1..=p {
|
||||
out *= (n - p + i) as f32 / i as f32;
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn catmull(p0: f32, p1: f32, p2: f32, p3: f32, t: f32) -> f32 {
|
||||
0.5 * ((2.0 * p1)
|
||||
+ (-p0 + p2) * t
|
||||
+ (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t.powi(2)
|
||||
+ (-p0 + 3.0 * p1 - 3.0 * p2 + p3 * t.powi(3)))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn point_on_line(p1: Pos2, p2: Pos2, len: f32) -> Pos2 {
|
||||
let mut full_len = ((p1.x - p2.x).powi(2) + (p1.y - p2.y).powi(2)).sqrt();
|
||||
let n = full_len - len;
|
||||
|
||||
if full_len.abs() < f32::EPSILON {
|
||||
full_len = 1.0;
|
||||
}
|
||||
|
||||
(p1 * n + p2 * len) / full_len
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn angle_from_points(p0: Pos2, p1: Pos2) -> f32 {
|
||||
(p1.y - p0.y).atan2(p1.x - p0.x)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn distance_from_points(arr: &[Pos2]) -> f32 {
|
||||
arr.iter()
|
||||
.skip(1)
|
||||
.zip(arr.iter())
|
||||
.map(|(curr, prev)| curr.distance(prev))
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn cart_from_pol(r: f32, t: f32) -> Pos2 {
|
||||
Pos2 {
|
||||
x: r * t.cos(),
|
||||
y: r * t.sin(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn point_at_distance(array: &[Pos2], distance: f32) -> Pos2 {
|
||||
if array.len() < 2 {
|
||||
return Pos2 { x: 0.0, y: 0.0 };
|
||||
} else if distance.abs() < f32::EPSILON {
|
||||
return array[0];
|
||||
} else if distance_from_points(array) <= distance {
|
||||
return array[array.len() - 1];
|
||||
}
|
||||
|
||||
let mut i = 0;
|
||||
let mut current_distance = 0.0;
|
||||
let mut new_distance = 0.0;
|
||||
|
||||
while i < array.len() - 2 {
|
||||
new_distance = (array[i] - array[i + 1]).length();
|
||||
current_distance += new_distance;
|
||||
|
||||
if distance <= current_distance {
|
||||
break;
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
current_distance -= new_distance;
|
||||
|
||||
if (distance - current_distance).abs() <= f32::EPSILON {
|
||||
array[i]
|
||||
} else {
|
||||
let angle = angle_from_points(array[i], array[i + 1]);
|
||||
let cart = cart_from_pol(distance - current_distance, angle);
|
||||
|
||||
array[i] + cart * ((array[i].x <= array[i + 1].x) as i8 * 2 - 1) as f32
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get_circum_circle(p: &[Pos2]) -> (f32, f32, f32) {
|
||||
let d = 2.0
|
||||
* (p[0].x * (p[1].y - p[2].y) + p[1].x * (p[2].y - p[0].y) + p[2].x * (p[0].y - p[1].y));
|
||||
|
||||
let p0 = p[0].x * p[0].x + p[0].y * p[0].y;
|
||||
let p1 = p[1].x * p[1].x + p[1].y * p[1].y;
|
||||
let p2 = p[2].x * p[2].x + p[2].y * p[2].y;
|
||||
|
||||
let ux = (p0 * (p[1].y - p[2].y) + p1 * (p[2].y - p[0].y) + p2 * (p[0].y - p[1].y)) / d;
|
||||
let uy = (p0 * (p[2].x - p[1].x) + p1 * (p[0].x - p[2].x) + p2 * (p[1].x - p[0].x)) / d;
|
||||
|
||||
let px = ux - p[0].x;
|
||||
let py = uy - p[0].y;
|
||||
let r = (px * px + py * py).sqrt();
|
||||
|
||||
(ux, uy, r)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn is_left(p: &[Pos2]) -> bool {
|
||||
((p[1].x - p[0].x) * (p[2].y - p[0].y) - (p[1].y - p[0].y) * (p[2].x - p[0].x)) < 0.0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn rotate(cx: f32, cy: f32, p: Pos2, radians: f32) -> Pos2 {
|
||||
let cos = radians.cos();
|
||||
let sin = radians.sin();
|
||||
|
||||
Pos2 {
|
||||
x: (cos * (p.x - cx)) - (sin * (p.y - cy)) + cx,
|
||||
y: (sin * (p.x - cx)) + (cos * (p.y - cy)) + cy,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
mod pp;
|
||||
mod versions;
|
||||
|
||||
pub use pp::*;
|
||||
pub use versions::*;
|
||||
|
||||
#[derive(Clone, Default, Debug)]
|
||||
pub struct DifficultyAttributes {
|
||||
pub stars: f32,
|
||||
pub ar: f32,
|
||||
pub od: f32,
|
||||
pub speed_strain: f32,
|
||||
pub aim_strain: f32,
|
||||
pub max_combo: usize,
|
||||
pub n_circles: usize,
|
||||
pub n_spinners: usize,
|
||||
}
|
||||
|
||||
const HITWINDOW_OD_MIN: f32 = 80.0;
|
||||
const HITWINDOW_OD_AVG: f32 = 50.0;
|
||||
const HITWINDOW_OD_MAX: f32 = 20.0;
|
||||
|
||||
const HITWINDOW_AR_MIN: f32 = 1800.0;
|
||||
const HITWINDOW_AR_AVG: f32 = 1200.0;
|
||||
const HITWINDOW_AR_MAX: f32 = 450.0;
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn difficulty_range_od(od: f32) -> f32 {
|
||||
crate::difficulty_range(od, HITWINDOW_OD_MAX, HITWINDOW_OD_AVG, HITWINDOW_OD_MIN)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn difficulty_range_ar(ar: f32) -> f32 {
|
||||
crate::difficulty_range(ar, HITWINDOW_AR_MAX, HITWINDOW_AR_AVG, HITWINDOW_AR_MIN)
|
||||
}
|
||||
+426
@@ -0,0 +1,426 @@
|
||||
use super::DifficultyAttributes as Attributes;
|
||||
use crate::{Beatmap, Mods};
|
||||
|
||||
pub struct PpResult {
|
||||
pub pp: f32,
|
||||
pub attributes: Attributes,
|
||||
}
|
||||
|
||||
pub trait PpProvider {
|
||||
fn pp(&self) -> PpCalculator;
|
||||
}
|
||||
|
||||
impl PpProvider for Beatmap {
|
||||
#[inline]
|
||||
fn pp(&self) -> PpCalculator {
|
||||
PpCalculator::new(self)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Allow partial plays
|
||||
pub struct PpCalculator<'m> {
|
||||
map: &'m Beatmap,
|
||||
attributes: Option<Attributes>,
|
||||
mods: u32,
|
||||
combo: Option<usize>,
|
||||
acc: Option<f32>,
|
||||
|
||||
n300: Option<usize>,
|
||||
n100: Option<usize>,
|
||||
n50: Option<usize>,
|
||||
n_misses: usize,
|
||||
}
|
||||
|
||||
impl<'m> PpCalculator<'m> {
|
||||
#[inline]
|
||||
pub fn new(map: &'m Beatmap) -> Self {
|
||||
Self {
|
||||
map,
|
||||
attributes: None,
|
||||
mods: 0,
|
||||
combo: None,
|
||||
acc: None,
|
||||
|
||||
n300: None,
|
||||
n100: None,
|
||||
n50: None,
|
||||
n_misses: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn attributes(mut self, attributes: Attributes) -> Self {
|
||||
self.attributes.replace(attributes);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn mods(mut self, mods: u32) -> Self {
|
||||
self.mods = mods;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn combo(mut self, combo: usize) -> Self {
|
||||
self.combo.replace(combo);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn n300(mut self, n300: usize) -> Self {
|
||||
self.n300.replace(n300);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn n100(mut self, n100: usize) -> Self {
|
||||
self.n100.replace(n100);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn n50(mut self, n50: usize) -> Self {
|
||||
self.n50.replace(n50);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn misses(mut self, n_misses: usize) -> Self {
|
||||
self.n_misses = n_misses;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Generate the hit results with respect to the given accuracy between `0` and `100`.
|
||||
///
|
||||
/// Be sure to set `misses` beforehand!
|
||||
pub fn accuracy(mut self, acc: f32) -> Self {
|
||||
let n_objects = self.map.hit_objects.len();
|
||||
let acc = acc / 100.0;
|
||||
|
||||
if self.n100.or(self.n50).is_some() {
|
||||
self.n300.replace(
|
||||
n_objects - self.n100.unwrap_or(0) - self.n50.unwrap_or(0) - self.n_misses,
|
||||
);
|
||||
self.n100.get_or_insert(0);
|
||||
self.n50.get_or_insert(0);
|
||||
} else {
|
||||
let target_total = (acc * n_objects as f32 * 6.0).round() as usize;
|
||||
let delta = target_total - (n_objects - self.n_misses);
|
||||
|
||||
self.n300.replace(delta / 5);
|
||||
self.n100.replace(delta % 5);
|
||||
|
||||
// println!(
|
||||
// "{} - {} - {} - {}",
|
||||
// n_objects,
|
||||
// self.n300.unwrap(),
|
||||
// self.n100.unwrap(),
|
||||
// self.n_misses
|
||||
// );
|
||||
|
||||
self.n50
|
||||
.replace(n_objects - self.n300.unwrap() - self.n100.unwrap() - self.n_misses);
|
||||
}
|
||||
|
||||
let acc = (6 * self.n300.unwrap() + 2 * self.n100.unwrap() + self.n50.unwrap()) as f32
|
||||
/ (6 * n_objects) as f32;
|
||||
|
||||
self.acc.replace(acc);
|
||||
|
||||
// println!(
|
||||
// "n300: {:?} | n100: {:?} | n50: {:?} | nMiss: {:?} => {}",
|
||||
// self.n300, self.n100, self.n50, self.n_misses, acc
|
||||
// );
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Consume the calculator and calculate the pp.
|
||||
///
|
||||
/// `stars_func` will be used to calculate stars and other necessary attributes if not already given.
|
||||
pub fn calculate(mut self, stars_func: impl FnOnce(&Beatmap, u32) -> Attributes) -> PpResult {
|
||||
if self.attributes.is_none() {
|
||||
let attributes = stars_func(self.map, self.mods);
|
||||
self.attributes.replace(attributes);
|
||||
}
|
||||
|
||||
if self.acc.is_none() {
|
||||
let n_objects = self.map.hit_objects.len();
|
||||
|
||||
let remaining = n_objects
|
||||
.saturating_sub(self.n300.unwrap_or(0))
|
||||
.saturating_sub(self.n100.unwrap_or(0))
|
||||
.saturating_sub(self.n50.unwrap_or(0))
|
||||
.saturating_sub(self.n_misses);
|
||||
|
||||
if remaining > 0 {
|
||||
if self.n300.is_none() {
|
||||
self.n300.replace(remaining);
|
||||
self.n100.get_or_insert(0);
|
||||
self.n50.get_or_insert(0);
|
||||
} else if self.n100.is_none() {
|
||||
self.n100.replace(remaining);
|
||||
self.n50.get_or_insert(0);
|
||||
} else if self.n50.is_none() {
|
||||
self.n50.replace(remaining);
|
||||
} else {
|
||||
*self.n300.as_mut().unwrap() += remaining;
|
||||
}
|
||||
}
|
||||
|
||||
// println!(
|
||||
// "n300: {:?} | n100: {:?} | n50: {:?} | nMiss: {:?}",
|
||||
// self.n300, self.n100, self.n50, self.n_misses
|
||||
// );
|
||||
|
||||
let numerator = self.n50.unwrap() + self.n100.unwrap() * 2 + self.n300.unwrap() * 6;
|
||||
self.acc.replace(numerator as f32 / n_objects as f32 / 6.0);
|
||||
}
|
||||
|
||||
let total_hits = self.total_hits();
|
||||
let mut multiplier = 1.12;
|
||||
|
||||
if self.mods.nf() {
|
||||
multiplier *= (1.0 - 0.02 * self.n_misses as f32).max(0.9);
|
||||
}
|
||||
|
||||
if self.mods.so() {
|
||||
let n_spinners = self.attributes.as_ref().unwrap().n_spinners;
|
||||
multiplier *= 1.0 - (n_spinners as f32 / total_hits as f32).powf(0.85);
|
||||
}
|
||||
|
||||
let aim_value = self.compute_aim_value(total_hits as f32);
|
||||
let speed_value = self.compute_speed_value(total_hits as f32);
|
||||
let acc_value = self.compute_accuracy_value(total_hits);
|
||||
|
||||
// println!(
|
||||
// "aim={} | speed={} | acc={}",
|
||||
// aim_value, speed_value, acc_value
|
||||
// );
|
||||
|
||||
let pp = (aim_value.powf(1.1) + speed_value.powf(1.1) + acc_value.powf(1.1))
|
||||
.powf(1.0 / 1.1)
|
||||
* multiplier;
|
||||
|
||||
PpResult {
|
||||
pp,
|
||||
attributes: self.attributes.unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_aim_value(&self, total_hits: f32) -> f32 {
|
||||
let attributes = self.attributes.as_ref().unwrap();
|
||||
|
||||
// println!("aim_strain={}", attributes.aim_strain);
|
||||
|
||||
// TD penalty
|
||||
let raw_aim = if self.mods.td() {
|
||||
attributes.aim_strain.powf(0.8)
|
||||
} else {
|
||||
attributes.aim_strain
|
||||
};
|
||||
|
||||
// println!("raw={}", raw_aim);
|
||||
|
||||
let mut aim_value = (5.0 * (raw_aim / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||
|
||||
// println!("init: {}", aim_value);
|
||||
|
||||
// Longer maps are worth more
|
||||
let len_bonus = 0.95
|
||||
+ 0.4 * (total_hits / 2000.0).min(1.0)
|
||||
+ (total_hits > 2000.0) as u8 as f32 * 0.5 * (total_hits / 2000.0).log10();
|
||||
aim_value *= len_bonus;
|
||||
|
||||
// println!("len bonus: {} => {}", len_bonus, aim_value);
|
||||
|
||||
// Penalize misses
|
||||
if self.n_misses > 0 {
|
||||
aim_value *= 0.97
|
||||
* (1.0 - (self.n_misses as f32 / total_hits).powf(0.775))
|
||||
.powi(self.n_misses as i32);
|
||||
}
|
||||
|
||||
// println!("miss penalty: {}", aim_value);
|
||||
|
||||
// println!(
|
||||
// "combo={:?} | max_combo={}",
|
||||
// self.combo, attributes.max_combo
|
||||
// );
|
||||
|
||||
// Combo scaling
|
||||
if let Some(combo) = self.combo.filter(|_| attributes.max_combo > 0) {
|
||||
aim_value *= ((combo as f32 / attributes.max_combo as f32).powf(0.8)).min(1.0);
|
||||
}
|
||||
|
||||
// println!("combo scaling: {}", aim_value);
|
||||
|
||||
// AR bonus
|
||||
let mut ar_factor = 0.0;
|
||||
if attributes.ar > 10.33 {
|
||||
ar_factor += 0.4 * (attributes.ar - 10.33);
|
||||
} else if attributes.ar < 8.0 {
|
||||
ar_factor += 0.1 * (8.0 - attributes.ar);
|
||||
}
|
||||
aim_value *= 1.0 + ar_factor.min(ar_factor * total_hits / 1000.0);
|
||||
|
||||
// println!("ar bonus: {} => {}", ar_factor, aim_value);
|
||||
|
||||
// HD bonus
|
||||
if self.mods.hd() {
|
||||
aim_value *= 1.0 + 0.04 * (12.0 - attributes.ar);
|
||||
}
|
||||
|
||||
// println!("hd bonus: {}", aim_value);
|
||||
|
||||
// FL bonus
|
||||
if self.mods.fl() {
|
||||
aim_value *= 1.0
|
||||
+ 0.35 * (total_hits / 200.0).min(1.0)
|
||||
+ (total_hits > 200.0) as u8 as f32 * 0.3 * ((total_hits - 200.0) / 300.0).min(1.0)
|
||||
+ (total_hits > 500.0) as u8 as f32 * (total_hits - 500.0) / 1200.0;
|
||||
}
|
||||
|
||||
// println!("fl bonus: {}", aim_value);
|
||||
|
||||
// Scale with accuracy
|
||||
aim_value *= 0.5 + self.acc.unwrap() / 2.0;
|
||||
aim_value *= 0.98 + attributes.od * attributes.od / 2500.0;
|
||||
|
||||
// println!("> acc: {:?}", self.acc);
|
||||
|
||||
// println!("final: {}", aim_value);
|
||||
|
||||
aim_value
|
||||
}
|
||||
|
||||
fn compute_speed_value(&self, total_hits: f32) -> f32 {
|
||||
let attributes = self.attributes.as_ref().unwrap();
|
||||
|
||||
// println!("speed_strain={}", attributes.speed_strain);
|
||||
|
||||
let mut speed_value =
|
||||
(5.0 * (attributes.speed_strain / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||
|
||||
// println!(
|
||||
// "curr={} | modified={}",
|
||||
// speed_value,
|
||||
// (5.0 * (2.0994549379474163_f32 / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0
|
||||
// );
|
||||
|
||||
// println!("init: {}", speed_value);
|
||||
|
||||
// Longer maps are worth more
|
||||
let len_bonus = 0.95
|
||||
+ 0.4 * (total_hits / 2000.0).min(1.0)
|
||||
+ (total_hits > 2000.0) as u8 as f32 * 0.5 * (total_hits / 2000.0).log10();
|
||||
speed_value *= len_bonus;
|
||||
|
||||
// println!("len bonus: {} => {}", len_bonus, speed_value);
|
||||
|
||||
// Penalize misses
|
||||
if self.n_misses > 0 {
|
||||
speed_value *= 0.97
|
||||
* (1.0 - (self.n_misses as f32 / total_hits).powf(0.775))
|
||||
.powf((self.n_misses as f32).powf(0.875));
|
||||
}
|
||||
|
||||
// println!("miss penalty: {}", speed_value);
|
||||
|
||||
// Combo scaling
|
||||
if let Some(combo) = self.combo.filter(|_| attributes.max_combo > 0) {
|
||||
speed_value *= ((combo as f32 / attributes.max_combo as f32).powf(0.8)).min(1.0);
|
||||
}
|
||||
|
||||
// println!("combo scaling: {}", speed_value);
|
||||
|
||||
// AR bonus
|
||||
if attributes.ar > 10.33 {
|
||||
let ar_factor = 0.4 * (attributes.ar - 10.33);
|
||||
speed_value *= 1.0 + ar_factor.min(ar_factor * total_hits / 1000.0);
|
||||
}
|
||||
|
||||
// println!("ar bonus: {}", speed_value);
|
||||
|
||||
// HD bonus
|
||||
if self.mods.hd() {
|
||||
speed_value *= 1.0 + 0.04 * (12.0 - attributes.ar);
|
||||
}
|
||||
|
||||
// println!("hidden bonus: {}", speed_value);
|
||||
|
||||
// Scaling the speed value with accuracy and OD
|
||||
let od_factor = 0.95 + attributes.od * attributes.od / 750.0;
|
||||
let acc_factor = self
|
||||
.acc
|
||||
.unwrap()
|
||||
.powf((14.5 - attributes.od.max(8.0)) / 2.0);
|
||||
speed_value *= od_factor * acc_factor;
|
||||
|
||||
// println!("acc & od scaling: {}", speed_value);
|
||||
|
||||
// Penalize n50s
|
||||
speed_value *= 0.98_f32.powf(
|
||||
(self.n50.unwrap_or(0) as f32 >= total_hits / 500.0) as u8 as f32
|
||||
* (self.n50.unwrap_or(0) as f32 - total_hits / 500.0),
|
||||
);
|
||||
|
||||
// println!("final: {}", speed_value);
|
||||
|
||||
speed_value
|
||||
}
|
||||
|
||||
fn compute_accuracy_value(&self, total_hits: usize) -> f32 {
|
||||
let attributes = self.attributes.as_ref().unwrap();
|
||||
let n_circles = attributes.n_circles;
|
||||
|
||||
// println!("n_circles={}", n_circles);
|
||||
|
||||
let better_acc_percentage = (n_circles > 0) as u8 as f32
|
||||
* (((self.n300.unwrap() - (total_hits - n_circles)) * 6
|
||||
+ self.n100.unwrap_or(0) * 2
|
||||
+ self.n50.unwrap_or(0)) as f32
|
||||
/ (n_circles * 6) as f32)
|
||||
.max(0.0);
|
||||
|
||||
// println!("better_acc_percentage={}", better_acc_percentage);
|
||||
|
||||
let attributes = self.attributes.as_ref().unwrap();
|
||||
|
||||
let mut acc_value = 1.52163_f32.powf(attributes.od) * better_acc_percentage.powi(24) * 2.83;
|
||||
|
||||
// println!(
|
||||
// "1.52163^{} * {}^24 * 2.83 = {}",
|
||||
// attributes.od, better_acc_percentage, acc_value
|
||||
// );
|
||||
|
||||
// println!("init: {}", acc_value);
|
||||
|
||||
// Bonus for many hitcircles
|
||||
acc_value *= ((n_circles as f32 / 1000.0).powf(0.3)).min(1.15);
|
||||
|
||||
// HD bonus
|
||||
if self.mods.hd() {
|
||||
acc_value *= 1.08;
|
||||
}
|
||||
|
||||
// FL bonus
|
||||
if self.mods.fl() {
|
||||
acc_value *= 1.02;
|
||||
}
|
||||
|
||||
acc_value
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn total_hits(&self) -> usize {
|
||||
self.n300.unwrap_or(0) + self.n100.unwrap_or(0) + self.n50.unwrap_or(0) + self.n_misses
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use super::OsuObject;
|
||||
|
||||
const NORMALIZED_RADIUS: f32 = 52.0;
|
||||
|
||||
pub(crate) struct DifficultyObject {
|
||||
pub(crate) base: OsuObject,
|
||||
pub(crate) prev: Option<(f32, f32)>, // (jump_dist, strain_time)
|
||||
|
||||
pub(crate) jump_dist: f32,
|
||||
pub(crate) travel_dist: f32,
|
||||
pub(crate) angle: Option<f32>,
|
||||
|
||||
pub(crate) delta: f32,
|
||||
pub(crate) strain_time: f32,
|
||||
}
|
||||
|
||||
impl DifficultyObject {
|
||||
pub(crate) fn new(
|
||||
base: OsuObject,
|
||||
prev: OsuObject,
|
||||
prev_diff: Option<DifficultyObject>,
|
||||
prev_prev: Option<OsuObject>,
|
||||
clock_rate: f32,
|
||||
) -> Self {
|
||||
let delta = (base.time() - prev.time()) / clock_rate;
|
||||
let strain_time = delta.max(50.0);
|
||||
|
||||
let radius = base.radius();
|
||||
let mut scaling_factor = NORMALIZED_RADIUS / radius;
|
||||
|
||||
if radius < 30.0 {
|
||||
let small_circle_bonus = (30.0 - radius).min(5.0) / 50.0;
|
||||
scaling_factor *= 1.0 + small_circle_bonus;
|
||||
}
|
||||
|
||||
let travel_dist = prev.travel_dist();
|
||||
let prev_cursor_pos = prev.cursor_end_position();
|
||||
|
||||
// println!("travel_dist={} | prev_cursor_pos={:?}", travel_dist, prev_cursor_pos);
|
||||
|
||||
let jump_dist = match base {
|
||||
OsuObject::Spinner { .. } => 0.0,
|
||||
_ => (base.stacked_pos() * scaling_factor - prev_cursor_pos * scaling_factor).length(),
|
||||
};
|
||||
|
||||
let angle = prev_prev.map(|prev_prev| {
|
||||
let prev_prev_cursor_pos = prev_prev.cursor_end_position();
|
||||
|
||||
let v1 = prev_prev_cursor_pos - prev.stacked_pos();
|
||||
let v2 = base.stacked_pos() - prev_cursor_pos;
|
||||
|
||||
let dot = v1.dot(v2);
|
||||
let det = v1.x * v2.y - v1.y * v2.x;
|
||||
|
||||
det.atan2(dot).abs()
|
||||
});
|
||||
|
||||
let prev = prev_diff.map(|o| (o.jump_dist, o.strain_time));
|
||||
|
||||
Self {
|
||||
base,
|
||||
prev,
|
||||
|
||||
jump_dist,
|
||||
travel_dist,
|
||||
angle,
|
||||
|
||||
delta,
|
||||
strain_time,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
mod difficulty_object;
|
||||
mod osu_object;
|
||||
mod skill;
|
||||
mod skill_kind;
|
||||
|
||||
use difficulty_object::DifficultyObject;
|
||||
use osu_object::OsuObject;
|
||||
use skill::Skill;
|
||||
use skill_kind::SkillKind;
|
||||
|
||||
use super::super::DifficultyAttributes;
|
||||
use crate::{Beatmap, Mods};
|
||||
|
||||
const SECTION_LEN: f32 = 400.0;
|
||||
const DIFFICULTY_MULTIPLIER: f32 = 0.0675;
|
||||
|
||||
/// Star calculation for osu!standard maps
|
||||
pub fn stars(map: &Beatmap, mods: impl Mods) -> DifficultyAttributes {
|
||||
let attributes = map.attributes().mods(mods);
|
||||
|
||||
if map.hit_objects.len() < 2 {
|
||||
return DifficultyAttributes {
|
||||
ar: attributes.ar,
|
||||
od: attributes.od,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
let section_len = SECTION_LEN * attributes.clock_rate;
|
||||
|
||||
let mut hit_objects = map
|
||||
.hit_objects
|
||||
.iter()
|
||||
.map(|h| OsuObject::new(h, map, &attributes));
|
||||
|
||||
let mut skills = vec![Skill::new(SkillKind::Aim), Skill::new(SkillKind::Speed)];
|
||||
|
||||
let mut current_section_end =
|
||||
(map.hit_objects[0].start_time / section_len).ceil() * section_len;
|
||||
|
||||
let mut prev_prev = None;
|
||||
let mut prev = hit_objects.next().unwrap();
|
||||
let mut prev_diff = None;
|
||||
|
||||
let mut _i = 0;
|
||||
|
||||
for curr in hit_objects {
|
||||
let h = DifficultyObject::new(
|
||||
curr.clone(),
|
||||
prev.clone(),
|
||||
prev_diff,
|
||||
prev_prev,
|
||||
attributes.clock_rate,
|
||||
);
|
||||
|
||||
// println!(
|
||||
// "strain_time={} | travel_dist={} | jump_dist={} | angle={:?}",
|
||||
// h.strain_time, h.travel_dist, h.jump_dist, h.angle
|
||||
// );
|
||||
|
||||
// println!("[{}] time={}", _i, curr.time());
|
||||
|
||||
while h.base.time() > current_section_end {
|
||||
for skill in skills.iter_mut() {
|
||||
skill.save_current_peak();
|
||||
skill.start_new_section_from(current_section_end);
|
||||
|
||||
_i += 1;
|
||||
}
|
||||
|
||||
current_section_end += section_len;
|
||||
}
|
||||
|
||||
for skill in skills.iter_mut() {
|
||||
skill.process(&h);
|
||||
}
|
||||
|
||||
prev_prev = Some(prev);
|
||||
prev = curr;
|
||||
prev_diff = Some(h);
|
||||
}
|
||||
|
||||
for skill in skills.iter_mut() {
|
||||
skill.save_current_peak();
|
||||
}
|
||||
|
||||
// println!("Aim:");
|
||||
// for (i, strain) in skills[0].strain_peaks.iter().enumerate() {
|
||||
// println!("{}: {}", i, strain);
|
||||
// }
|
||||
|
||||
// println!("Speed:");
|
||||
// for (i, strain) in skills[1].strain_peaks.iter().enumerate() {
|
||||
// println!("{}: {}", i, strain);
|
||||
// }
|
||||
|
||||
// println!("Aim: {:?}", skills[0].strain_peaks);
|
||||
// println!("Speed: {:?}", skills[1].strain_peaks);
|
||||
|
||||
let aim_rating = skills[0].difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
// println!("After:\n{:?}", skills[0].strain_peaks);
|
||||
|
||||
let speed_rating = skills[1].difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
// println!("After:\n{:?}", skills[1].strain_peaks);
|
||||
|
||||
let stars = aim_rating + speed_rating + (aim_rating - speed_rating).abs() / 2.0;
|
||||
|
||||
DifficultyAttributes {
|
||||
stars,
|
||||
ar: attributes.ar,
|
||||
od: attributes.od,
|
||||
speed_strain: speed_rating,
|
||||
aim_strain: aim_rating,
|
||||
max_combo: 0, // TODO
|
||||
n_circles: 0, // TODO
|
||||
n_spinners: 0, // TODO
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::super::PpCalculator;
|
||||
use super::stars;
|
||||
use crate::Beatmap;
|
||||
use std::fs::File;
|
||||
|
||||
#[test]
|
||||
fn all_included_single_stars() {
|
||||
// let file = match File::open("E:/Games/osu!/beatmaps/1851299.osu") {
|
||||
// Ok(file) => file,
|
||||
// Err(why) => panic!("Could not open file: {}", why),
|
||||
// };
|
||||
let file = match File::open("C:/Users/Max/Desktop/2578801.osu") {
|
||||
Ok(file) => file,
|
||||
Err(why) => panic!("Could not open file: {}", why),
|
||||
};
|
||||
|
||||
let map = match Beatmap::parse(file) {
|
||||
Ok(map) => map,
|
||||
Err(why) => panic!("Error while parsing map: {}", why),
|
||||
};
|
||||
|
||||
let stars = stars(&map, 0).stars;
|
||||
|
||||
println!("Stars: {}", stars);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn all_included_stars() {
|
||||
let margin = 0.005;
|
||||
|
||||
#[rustfmt::skip]
|
||||
// TODO: More mods
|
||||
let data = vec![
|
||||
(1851299, 1 << 8, 4.23514130038547), // HT
|
||||
(1851299, 0, 5.356786475158158), // NM
|
||||
(1851299, 1 << 6, 7.450616908751305), // DT
|
||||
(1851299, 1 << 4, 5.6834681957637665),// HR
|
||||
(1851299, 1 << 1, 4.937817303399699), // EZ
|
||||
|
||||
(70090, 1 << 8, 2.2929922580201803), // HT
|
||||
(70090, 0, 2.8322940761833983), // NM
|
||||
(70090, 1 << 6, 3.8338563325375485), // DT
|
||||
(70090, 1 << 4, 3.0617492228478174), // HR
|
||||
(70090, 1 << 1, 2.698823231324141), // EZ
|
||||
|
||||
(1241370, 1 << 8, 5.662809600985943), // HT
|
||||
(1241370, 0, 7.0367002127481975), // NM
|
||||
(1241370, 1 << 6, 11.144720506574934),// DT
|
||||
(1241370, 1 << 4, 7.641688110458715), // HR
|
||||
(1241370, 1 << 1, 6.316288616688052), // EZ
|
||||
|
||||
// Slider fiesta
|
||||
// (1657535, 1 << 8, 4.1727975286379895),// HT
|
||||
// (1657535, 0, 5.16048239944917), // NM
|
||||
// (1657535, 1 << 6, 7.125936779100417), // DT
|
||||
// (1657535, 1 << 4, 5.545877027713307), // HR
|
||||
// (1657535, 1 << 1, 4.66015083361088), // EZ
|
||||
];
|
||||
|
||||
for (map_id, mods, expected_stars) in data {
|
||||
let file = match File::open(format!("./test/{}.osu", map_id)) {
|
||||
Ok(file) => file,
|
||||
Err(why) => panic!("Could not open file {}.osu: {}", map_id, why),
|
||||
};
|
||||
|
||||
let map = match Beatmap::parse(file) {
|
||||
Ok(map) => map,
|
||||
Err(why) => panic!("Error while parsing map {}: {}", map_id, why),
|
||||
};
|
||||
|
||||
let stars = stars(&map, mods).stars;
|
||||
|
||||
assert!(
|
||||
(stars - expected_stars).abs() < margin,
|
||||
"Stars: {} | Expected: {} => {} margin [map {} | mods {}]",
|
||||
stars,
|
||||
expected_stars,
|
||||
(stars - expected_stars).abs(),
|
||||
map_id,
|
||||
mods
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_included_single_pp() {
|
||||
// let file = match File::open("E:/Games/osu!/beatmaps/1851299.osu") {
|
||||
// Ok(file) => file,
|
||||
// Err(why) => panic!("Could not open file: {}", why),
|
||||
// };
|
||||
let file = match File::open("C:/Users/Max/Desktop/2578801.osu") {
|
||||
Ok(file) => file,
|
||||
Err(why) => panic!("Could not open file: {}", why),
|
||||
};
|
||||
|
||||
let map = match Beatmap::parse(file) {
|
||||
Ok(map) => map,
|
||||
Err(why) => panic!("Error while parsing map: {}", why),
|
||||
};
|
||||
|
||||
let calculator = PpCalculator::new(&map).mods(0);
|
||||
let result = calculator.calculate(stars);
|
||||
|
||||
println!("PP: {}", result.pp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
#![allow(unused)]
|
||||
|
||||
use crate::{curve::Curve, Beatmap, BeatmapAttributes, HitObject, HitObjectKind, PathType, Pos2};
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
macro_rules! binary_search {
|
||||
($slice:expr, $target:expr) => {
|
||||
$slice.binary_search_by(|p| p.time.partial_cmp(&$target).unwrap_or(Ordering::Equal))
|
||||
};
|
||||
}
|
||||
|
||||
const OBJECT_RADIUS: f32 = 64.0;
|
||||
const STACK_DIST: f32 = 3.0;
|
||||
const LEGACY_LAST_TICK_OFFSET: f32 = 36.0;
|
||||
|
||||
#[derive(Clone)] // TODO: Remove clone
|
||||
pub(crate) enum OsuObject {
|
||||
Circle {
|
||||
pos: Pos2,
|
||||
time: f32,
|
||||
|
||||
scale: f32,
|
||||
stack_height: f32,
|
||||
},
|
||||
Slider {
|
||||
pixel_len: f32, // TODO: Redundant?
|
||||
repeats: usize, // TODO: Redundant?
|
||||
|
||||
objects: Vec<SliderTick>,
|
||||
|
||||
scale: f32,
|
||||
stack_height: f32,
|
||||
|
||||
cursor_end_pos: Pos2,
|
||||
cursor_travel_dist: f32,
|
||||
},
|
||||
Spinner {
|
||||
pos: Pos2,
|
||||
time: f32,
|
||||
},
|
||||
}
|
||||
|
||||
impl OsuObject {
|
||||
pub(crate) fn new(h: &HitObject, map: &Beatmap, attributes: &BeatmapAttributes) -> Self {
|
||||
let pos = h.pos;
|
||||
let time = h.start_time;
|
||||
|
||||
let scale = (1.0 - 0.7 * (attributes.cs - 5.0) / 5.0) / 2.0;
|
||||
let mut stack_height = 0.0;
|
||||
|
||||
match &h.kind {
|
||||
HitObjectKind::Circle => Self::Circle {
|
||||
pos,
|
||||
time,
|
||||
scale,
|
||||
stack_height,
|
||||
},
|
||||
HitObjectKind::Slider {
|
||||
pixel_len,
|
||||
repeats,
|
||||
curve_points,
|
||||
path_type,
|
||||
} => {
|
||||
let (beat_len, timing_time) = {
|
||||
match binary_search!(map.timing_points, time) {
|
||||
Ok(idx) => {
|
||||
let point = &map.timing_points[idx];
|
||||
(point.beat_len, point.time)
|
||||
}
|
||||
Err(0) => (1000.0, 0.0),
|
||||
Err(idx) => {
|
||||
let point = &map.timing_points[idx - 1];
|
||||
(point.beat_len, point.time)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let (speed_multiplier, diff_time) = {
|
||||
match binary_search!(map.difficulty_points, time) {
|
||||
Ok(idx) => {
|
||||
let point = &map.difficulty_points[idx];
|
||||
(point.speed_multiplier, point.time)
|
||||
}
|
||||
Err(0) => (1.0, 0.0),
|
||||
Err(idx) => {
|
||||
let point = &map.difficulty_points[idx - 1];
|
||||
(point.speed_multiplier, point.time)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut tick_distance = 100.0 * map.sv / map.tick_rate;
|
||||
|
||||
if map.version >= 8 {
|
||||
tick_distance /= (100.0 / speed_multiplier).max(10.0).min(1000.0) / 100.0;
|
||||
}
|
||||
|
||||
let spm = if timing_time > diff_time {
|
||||
1.0
|
||||
} else {
|
||||
speed_multiplier
|
||||
};
|
||||
|
||||
let duration = *repeats as f32 * beat_len * pixel_len / (map.sv * spm) / 100.0;
|
||||
|
||||
// let velocity = *pixel_len as f32 / duration;
|
||||
|
||||
// println!("duration={}", duration);
|
||||
// println!("velocity={}", velocity);
|
||||
|
||||
let path_type = if *path_type == PathType::PerfectCurve && curve_points.len() > 3 {
|
||||
PathType::Bezier
|
||||
} else if curve_points.len() == 2 {
|
||||
PathType::Linear
|
||||
} else {
|
||||
*path_type
|
||||
};
|
||||
|
||||
let curve = match path_type {
|
||||
PathType::Linear => Curve::linear(curve_points[0], curve_points[1]),
|
||||
PathType::Bezier => Curve::bezier(&curve_points),
|
||||
PathType::Catmull => Curve::catmull(&curve_points),
|
||||
PathType::PerfectCurve => Curve::perfect(&curve_points),
|
||||
};
|
||||
|
||||
let mut current_distance = tick_distance;
|
||||
let time_add = duration * (tick_distance / (pixel_len * *repeats as f32));
|
||||
|
||||
let target = pixel_len - tick_distance / 8.0;
|
||||
let mut ticks = Vec::with_capacity((target / tick_distance) as usize);
|
||||
|
||||
while current_distance < target {
|
||||
let pos = curve.point_at_distance(current_distance);
|
||||
let time = h.start_time + time_add * (ticks.len() + 1) as f32;
|
||||
ticks.push(SliderTick::new(pos, time));
|
||||
current_distance += tick_distance;
|
||||
}
|
||||
|
||||
let mut slider_objects = Vec::with_capacity(repeats * (ticks.len() + 1));
|
||||
slider_objects.push(SliderTick::new(h.pos, h.start_time));
|
||||
|
||||
if *repeats <= 1 {
|
||||
slider_objects.append(&mut ticks);
|
||||
} else {
|
||||
slider_objects.append(&mut ticks.clone());
|
||||
|
||||
for repeat_id in 1..repeats - 1 {
|
||||
let dist = (repeat_id % 2) as f32 * pixel_len;
|
||||
let time_offset = (duration / *repeats as f32) * repeat_id as f32;
|
||||
let pos = curve.point_at_distance(dist);
|
||||
|
||||
// Reverse tick / last legacy tick
|
||||
slider_objects.push(SliderTick::new(pos, h.start_time + time_offset));
|
||||
|
||||
ticks.reverse();
|
||||
slider_objects.extend_from_slice(&ticks); // tick time doesn't need to be adjusted for some reason
|
||||
}
|
||||
|
||||
// Handling last span separatly so that `ticks` vector isn't cloned again
|
||||
let dist = ((repeats - 1) % 2) as f32 * pixel_len;
|
||||
let time_offset = (duration / *repeats as f32) * (repeats - 1) as f32;
|
||||
let pos = curve.point_at_distance(dist);
|
||||
|
||||
slider_objects.push(SliderTick::new(pos, h.start_time + time_offset));
|
||||
|
||||
ticks.reverse();
|
||||
slider_objects.append(&mut ticks);
|
||||
}
|
||||
|
||||
// Slider tail
|
||||
let span_duration = duration / *repeats as f32;
|
||||
let final_span_idx = repeats.saturating_sub(1);
|
||||
let final_span_start_time = h.start_time + final_span_idx as f32 * span_duration;
|
||||
let final_span_end_time = (h.start_time + duration / 2.0)
|
||||
.max(final_span_start_time + span_duration - LEGACY_LAST_TICK_OFFSET);
|
||||
let mut final_progress =
|
||||
(final_span_end_time - final_span_start_time) / span_duration;
|
||||
|
||||
if *repeats & 1 == 0 {
|
||||
final_progress = 1.0 - final_progress;
|
||||
}
|
||||
|
||||
// println!(
|
||||
// "final_span_index={} | final_span_start_time={} | \
|
||||
// final_span_end_time={} | final_progress={}",
|
||||
// final_span_idx, final_span_start_time, final_span_end_time, final_progress
|
||||
// );
|
||||
|
||||
// println!("len={}", final_progress * *pixel_len as f32);
|
||||
|
||||
let dist_end = (repeats % 2) as f32 * pixel_len;
|
||||
|
||||
let pos = curve.point_at_distance(dist_end);
|
||||
slider_objects.push(SliderTick::new(pos, final_span_end_time));
|
||||
|
||||
// println!(
|
||||
// "start_time={} | span_duration={} | vel={} | \
|
||||
// tick_dist={} | dist={} | span_count={} | \
|
||||
// legacy_last_tick_offset={}",
|
||||
// h.start_time,
|
||||
// duration / *repeats as f32,
|
||||
// *pixel_len as f32 / duration,
|
||||
// tick_distance,
|
||||
// *pixel_len,
|
||||
// *repeats,
|
||||
// 36
|
||||
// );
|
||||
|
||||
// println!("> Slider: {:?}", slider_objects);
|
||||
|
||||
let radius = OBJECT_RADIUS * scale;
|
||||
|
||||
let stack_offset = {
|
||||
let c = stack_height * scale * -6.4;
|
||||
|
||||
Pos2 { x: c, y: c }
|
||||
};
|
||||
|
||||
// println!("radius={} | stack_offset={:?}", radius, stack_offset);
|
||||
|
||||
let pos = h.pos;
|
||||
let stacked_pos = pos + stack_offset; // TODO: Simplify for below
|
||||
|
||||
// println!(
|
||||
// "stacked_pos = {:?} + {:?} = {:?}",
|
||||
// pos, stack_offset, stacked_pos
|
||||
// );
|
||||
|
||||
let mut cursor_end_pos = stacked_pos;
|
||||
let mut cursor_travel_dist = 0.0;
|
||||
let approx_follow_circle_radius = radius * 3.0;
|
||||
|
||||
// println!(
|
||||
// "stacked_pos={:?} | approx_follow_circle_radius={}",
|
||||
// stacked_pos, approx_follow_circle_radius
|
||||
// );
|
||||
|
||||
// let mut curr_offset = tick_distance;
|
||||
|
||||
for (i, tick) in slider_objects.iter().skip(1).enumerate() {
|
||||
let mut progress = (tick.time - h.start_time) / span_duration;
|
||||
|
||||
if progress % 2.0 >= 1.0 {
|
||||
progress = 1.0 - progress % 1.0;
|
||||
} else {
|
||||
progress %= 1.0;
|
||||
}
|
||||
|
||||
let curr_dist = pixel_len * progress;
|
||||
let curr_pos = curve.point_at_distance(curr_dist);
|
||||
|
||||
let diff = stacked_pos + curr_pos - pos - cursor_end_pos;
|
||||
let mut dist = diff.length();
|
||||
|
||||
// println!(
|
||||
// "position at: progress=? | d={} => {:?}",
|
||||
// curr_offset, tick.pos
|
||||
// );
|
||||
// curr_offset += tick_distance;
|
||||
|
||||
println!(
|
||||
"[{}] diff = {:?} + {:?} - {:?} = {:?} | dist={}",
|
||||
i,
|
||||
stacked_pos,
|
||||
tick.pos - pos,
|
||||
cursor_end_pos,
|
||||
diff,
|
||||
dist
|
||||
);
|
||||
|
||||
// println!("{} > {}", dist, approx_follow_circle_radius);
|
||||
|
||||
if dist > approx_follow_circle_radius {
|
||||
let normalized = diff.normalize();
|
||||
// println!("diff before: {:?}", diff);
|
||||
// println!("diff after: {:?}", normalized);
|
||||
dist -= approx_follow_circle_radius;
|
||||
cursor_end_pos += normalized * dist;
|
||||
|
||||
// println!("+= {} * {} => {:?}", normalized, dist, cursor_end_pos);
|
||||
|
||||
cursor_travel_dist += dist;
|
||||
// println!("+= {} => {}", dist, cursor_travel_dist);
|
||||
}
|
||||
}
|
||||
|
||||
println!("cursor_travel_dist={}", cursor_travel_dist);
|
||||
|
||||
println!("---");
|
||||
|
||||
Self::Slider {
|
||||
pixel_len: *pixel_len,
|
||||
repeats: *repeats,
|
||||
|
||||
objects: slider_objects,
|
||||
|
||||
scale,
|
||||
stack_height,
|
||||
|
||||
cursor_end_pos,
|
||||
cursor_travel_dist,
|
||||
}
|
||||
}
|
||||
HitObjectKind::Spinner { .. } => Self::Spinner { pos, time },
|
||||
HitObjectKind::Hold { .. } => panic!("found Hold object in osu!standard file"),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn time(&self) -> f32 {
|
||||
match self {
|
||||
Self::Circle { time, .. } => *time,
|
||||
Self::Slider { objects, .. } => objects[0].time,
|
||||
Self::Spinner { time, .. } => *time,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn radius(&self) -> f32 {
|
||||
OBJECT_RADIUS * self.scale()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn travel_dist(&self) -> f32 {
|
||||
match self {
|
||||
Self::Slider {
|
||||
cursor_travel_dist, ..
|
||||
} => *cursor_travel_dist,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn stacked_pos(&self) -> Pos2 {
|
||||
self.pos() + self.stack_offset()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn cursor_end_position(&self) -> Pos2 {
|
||||
match self {
|
||||
Self::Circle { pos, .. } => *pos,
|
||||
Self::Slider { cursor_end_pos, .. } => *cursor_end_pos,
|
||||
Self::Spinner { pos, .. } => *pos,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn is_spinner(&self) -> bool {
|
||||
matches!(self, Self::Spinner { .. })
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn scale(&self) -> f32 {
|
||||
match self {
|
||||
Self::Circle { scale, .. } => *scale,
|
||||
Self::Slider { scale, .. } => *scale,
|
||||
Self::Spinner { .. } => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn stack_height(&self) -> f32 {
|
||||
match self {
|
||||
Self::Circle { stack_height, .. } => *stack_height,
|
||||
Self::Slider { stack_height, .. } => *stack_height,
|
||||
Self::Spinner { .. } => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Remove pub
|
||||
#[inline]
|
||||
pub fn pos(&self) -> Pos2 {
|
||||
match self {
|
||||
Self::Circle { pos, .. } => *pos,
|
||||
Self::Slider { objects, .. } => objects[0].pos,
|
||||
Self::Spinner { .. } => Pos2::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn stack_offset(&self) -> Pos2 {
|
||||
let c = self.stack_height() * self.scale() * -6.4;
|
||||
|
||||
Pos2 { x: c, y: c }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub(crate) struct SliderTick {
|
||||
pos: Pos2,
|
||||
time: f32,
|
||||
}
|
||||
|
||||
impl SliderTick {
|
||||
fn new(pos: Pos2, time: f32) -> Self {
|
||||
Self { pos, time }
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Remove
|
||||
impl std::fmt::Debug for SliderTick {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(f, "{{pos={:?} | time={}}}", self.pos, self.time)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use super::{DifficultyObject, SkillKind};
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
const SPEED_SKILL_MULTIPLIER: f32 = 1400.0;
|
||||
const SPEED_STRAIN_DECAY_BASE: f32 = 0.3;
|
||||
|
||||
const AIM_SKILL_MULTIPLIER: f32 = 26.25;
|
||||
const AIM_STRAIN_DECAY_BASE: f32 = 0.15;
|
||||
|
||||
const DECAY_WEIGHT: f32 = 0.9;
|
||||
|
||||
pub(crate) struct Skill {
|
||||
pub current_strain: f32,
|
||||
current_section_peak: f32,
|
||||
|
||||
kind: SkillKind,
|
||||
pub strain_peaks: Vec<f32>, // TODO: Remove pub
|
||||
|
||||
prev_time: Option<f32>,
|
||||
}
|
||||
|
||||
impl Skill {
|
||||
#[inline]
|
||||
pub(crate) fn new(kind: SkillKind) -> Self {
|
||||
Self {
|
||||
current_strain: 1.0,
|
||||
current_section_peak: 1.0,
|
||||
|
||||
kind,
|
||||
strain_peaks: Vec::with_capacity(128),
|
||||
|
||||
prev_time: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn save_current_peak(&mut self) {
|
||||
if self.prev_time.is_some() {
|
||||
self.strain_peaks.push(self.current_section_peak);
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn start_new_section_from(&mut self, time: f32) {
|
||||
if let Some(prev) = self.prev_time {
|
||||
self.current_section_peak = self.peak_strain(time - prev);
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn process(&mut self, current: &DifficultyObject) {
|
||||
self.current_strain *= self.strain_decay(current.delta);
|
||||
self.current_strain += self.kind.strain_value_of(¤t) * self.skill_multiplier();
|
||||
self.current_section_peak = self.current_section_peak.max(self.current_strain);
|
||||
self.prev_time.replace(current.base.time());
|
||||
}
|
||||
|
||||
pub(crate) fn difficulty_value(&mut self) -> f32 {
|
||||
let mut difficulty = 0.0;
|
||||
let mut weight = 1.0;
|
||||
|
||||
self.strain_peaks
|
||||
.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
|
||||
|
||||
for &strain in self.strain_peaks.iter() {
|
||||
difficulty += strain * weight;
|
||||
weight *= DECAY_WEIGHT;
|
||||
}
|
||||
|
||||
difficulty
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn skill_multiplier(&self) -> f32 {
|
||||
match self.kind {
|
||||
SkillKind::Aim => AIM_SKILL_MULTIPLIER,
|
||||
SkillKind::Speed => SPEED_SKILL_MULTIPLIER,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_decay_base(&self) -> f32 {
|
||||
match self.kind {
|
||||
SkillKind::Aim => AIM_STRAIN_DECAY_BASE,
|
||||
SkillKind::Speed => SPEED_STRAIN_DECAY_BASE,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn peak_strain(&self, delta_time: f32) -> f32 {
|
||||
self.current_strain * self.strain_decay(delta_time)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_decay(&self, ms: f32) -> f32 {
|
||||
self.strain_decay_base().powf(ms / 1000.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use super::DifficultyObject;
|
||||
|
||||
const SINGLE_SPACING_TRESHOLD: f32 = 125.0;
|
||||
const SPEED_ANGLE_BONUS_BEGIN: f32 = 5.0 * std::f32::consts::FRAC_PI_6;
|
||||
const PI_OVER_4: f32 = std::f32::consts::FRAC_PI_4;
|
||||
const PI_OVER_2: f32 = std::f32::consts::FRAC_PI_2;
|
||||
|
||||
const MIN_SPEED_BONUS: f32 = 75.0;
|
||||
const MAX_SPEED_BONUS: f32 = 45.0;
|
||||
const SPEED_BALANCING_FACTOR: f32 = 40.0;
|
||||
|
||||
const AIM_ANGLE_BONUS_BEGIN: f32 = std::f32::consts::FRAC_PI_3;
|
||||
const TIMING_THRESHOLD: f32 = 107.0;
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub(crate) enum SkillKind {
|
||||
Aim,
|
||||
Speed,
|
||||
}
|
||||
|
||||
impl SkillKind {
|
||||
pub(crate) fn strain_value_of(self, current: &DifficultyObject) -> f32 {
|
||||
match self {
|
||||
Self::Aim => {
|
||||
if current.base.is_spinner() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// println!("pos={:?}", current.base.pos());
|
||||
|
||||
let mut result = 0.0;
|
||||
|
||||
if let Some((prev_jump_dist, prev_strain_time)) = current.prev {
|
||||
if let Some(angle) = current.angle.filter(|a| *a > AIM_ANGLE_BONUS_BEGIN) {
|
||||
let scale = 90.0;
|
||||
|
||||
let angle_bonus = (((angle - AIM_ANGLE_BONUS_BEGIN).sin()).powi(2)
|
||||
* (prev_jump_dist - scale).max(0.0)
|
||||
* (current.jump_dist - scale).max(0.0))
|
||||
.sqrt();
|
||||
|
||||
// println!("angle_bonus={}", angle_bonus);
|
||||
|
||||
result = 1.5 * apply_diminishing_exp(angle_bonus.max(0.0))
|
||||
/ (TIMING_THRESHOLD).max(prev_strain_time)
|
||||
} else {
|
||||
// println!("nop");
|
||||
}
|
||||
} else {
|
||||
// println!("no prev");
|
||||
}
|
||||
|
||||
let jump_dist_exp = apply_diminishing_exp(current.jump_dist);
|
||||
let travel_dist_exp = apply_diminishing_exp(current.travel_dist);
|
||||
|
||||
// println!("jump_dist={} => {}", current.jump_dist, jump_dist_exp);
|
||||
// println!("travel_dist={} => {}", current.travel_dist, travel_dist_exp);
|
||||
|
||||
let dist_exp =
|
||||
jump_dist_exp + travel_dist_exp + (travel_dist_exp * jump_dist_exp).sqrt();
|
||||
|
||||
(result + dist_exp / (current.strain_time).max(TIMING_THRESHOLD))
|
||||
.max(dist_exp / current.strain_time)
|
||||
}
|
||||
Self::Speed => {
|
||||
if current.base.is_spinner() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let dist = SINGLE_SPACING_TRESHOLD.min(current.travel_dist + current.jump_dist);
|
||||
let delta_time = MAX_SPEED_BONUS.max(current.delta);
|
||||
|
||||
let mut speed_bonus = 1.0;
|
||||
|
||||
if delta_time < MIN_SPEED_BONUS {
|
||||
let exp_base = (MIN_SPEED_BONUS - delta_time) / SPEED_BALANCING_FACTOR;
|
||||
speed_bonus = 1.0 + exp_base * exp_base;
|
||||
}
|
||||
|
||||
let mut angle_bonus = 1.0;
|
||||
|
||||
// println!("angle: {:?}", current.angle);
|
||||
|
||||
if let Some(angle) = current.angle.filter(|a| *a < SPEED_ANGLE_BONUS_BEGIN) {
|
||||
let exp_base = (1.5 * (SPEED_ANGLE_BONUS_BEGIN - angle)).sin();
|
||||
angle_bonus = 1.0 + exp_base * exp_base / 3.57;
|
||||
|
||||
if angle < PI_OVER_2 {
|
||||
angle_bonus = 1.28;
|
||||
|
||||
// TODO: Improve ifs
|
||||
if dist < 90.0 && angle < PI_OVER_4 {
|
||||
angle_bonus += (1.0 - angle_bonus) * ((90.0 - dist) / 10.0).min(1.0);
|
||||
} else if dist < 90.0 {
|
||||
angle_bonus += (1.0 - angle_bonus)
|
||||
* ((90.0 - dist) / 10.0).min(1.0)
|
||||
* ((PI_OVER_2 - angle) / PI_OVER_4).sin();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// println!(
|
||||
// "dist={} | speed_bonus={} | angle_bonus={}",
|
||||
// dist, speed_bonus, angle_bonus
|
||||
// );
|
||||
|
||||
(1.0 + (speed_bonus - 1.0) * 0.75)
|
||||
* angle_bonus
|
||||
* (0.95 + speed_bonus * (dist / SINGLE_SPACING_TRESHOLD).powf(3.5))
|
||||
/ current.strain_time
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn apply_diminishing_exp(val: f32) -> f32 {
|
||||
val.powf(0.99)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod all_included;
|
||||
pub mod no_leniency;
|
||||
pub mod no_sliders_no_leniency;
|
||||
@@ -0,0 +1,74 @@
|
||||
use crate::{Beatmap, DifficultyPoint, TimingPoint};
|
||||
|
||||
use std::slice::Iter;
|
||||
|
||||
macro_rules! next_tuple {
|
||||
($iter:expr, ($first:ident, $second:ident)) => {
|
||||
$iter.next().map(|e| (e.$first, e.$second))
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) struct ControlPointIter<'p> {
|
||||
timing_points: Iter<'p, TimingPoint>,
|
||||
difficulty_points: Iter<'p, DifficultyPoint>,
|
||||
|
||||
next_timing: Option<(f32, f32)>,
|
||||
next_difficulty: Option<(f32, f32)>,
|
||||
}
|
||||
|
||||
impl<'p> ControlPointIter<'p> {
|
||||
#[inline]
|
||||
pub(crate) fn new(map: &'p Beatmap) -> Self {
|
||||
let mut timing_points = map.timing_points.iter();
|
||||
let mut difficulty_points = map.difficulty_points.iter();
|
||||
|
||||
Self {
|
||||
next_timing: next_tuple!(timing_points, (time, beat_len)),
|
||||
next_difficulty: next_tuple!(difficulty_points, (time, speed_multiplier)),
|
||||
|
||||
timing_points,
|
||||
difficulty_points,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum ControlPoint {
|
||||
Timing { time: f32, beat_len: f32 },
|
||||
Difficulty { time: f32, speed_mult: f32 },
|
||||
}
|
||||
|
||||
impl ControlPoint {
|
||||
#[inline]
|
||||
pub(crate) fn time(&self) -> f32 {
|
||||
match self {
|
||||
Self::Timing { time, .. } => *time,
|
||||
Self::Difficulty { time, .. } => *time,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'p> Iterator for ControlPointIter<'p> {
|
||||
type Item = ControlPoint;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
match (self.next_timing, self.next_difficulty) {
|
||||
(Some((time, beat_len)), Some((d, _))) if time <= d => {
|
||||
self.next_timing = next_tuple!(self.timing_points, (time, beat_len));
|
||||
|
||||
Some(ControlPoint::Timing { time, beat_len })
|
||||
}
|
||||
(_, Some((time, speed_mult))) => {
|
||||
self.next_difficulty =
|
||||
next_tuple!(self.difficulty_points, (time, speed_multiplier));
|
||||
|
||||
Some(ControlPoint::Difficulty { time, speed_mult })
|
||||
}
|
||||
(Some((time, beat_len)), None) => {
|
||||
self.next_timing = next_tuple!(self.timing_points, (time, beat_len));
|
||||
|
||||
Some(ControlPoint::Timing { time, beat_len })
|
||||
}
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use super::OsuObject;
|
||||
|
||||
pub(crate) struct DifficultyObject<'h> {
|
||||
pub(crate) base: &'h OsuObject,
|
||||
pub(crate) prev: Option<(f32, f32)>, // (jump_dist, strain_time)
|
||||
|
||||
pub(crate) jump_dist: f32,
|
||||
pub(crate) travel_dist: f32,
|
||||
pub(crate) angle: Option<f32>,
|
||||
|
||||
pub(crate) delta: f32,
|
||||
pub(crate) strain_time: f32,
|
||||
}
|
||||
|
||||
impl<'h> DifficultyObject<'h> {
|
||||
pub(crate) fn new(
|
||||
base: &'h OsuObject,
|
||||
prev: &OsuObject,
|
||||
prev_vals: Option<(f32, f32)>, // (jump_dist, strain_time)
|
||||
prev_prev: Option<OsuObject>,
|
||||
clock_rate: f32,
|
||||
scaling_factor: f32,
|
||||
) -> Self {
|
||||
let delta = (base.time - prev.time) / clock_rate;
|
||||
let strain_time = delta.max(50.0);
|
||||
|
||||
let pos = base.pos;
|
||||
let travel_dist = prev.travel_dist.unwrap_or(0.0);
|
||||
let prev_cursor_pos = prev.end_pos;
|
||||
|
||||
let jump_dist = if base.is_spinner() {
|
||||
0.0
|
||||
} else {
|
||||
(pos * scaling_factor - prev_cursor_pos * scaling_factor).length()
|
||||
};
|
||||
|
||||
let angle = prev_prev.map(|prev_prev| {
|
||||
let prev_prev_cursor_pos = prev_prev.end_pos;
|
||||
|
||||
let v1 = prev_prev_cursor_pos - prev_cursor_pos;
|
||||
let v2 = pos - prev_cursor_pos;
|
||||
|
||||
let dot = v1.dot(v2);
|
||||
let det = v1.x * v2.y - v1.y * v2.x;
|
||||
|
||||
det.atan2(dot).abs()
|
||||
});
|
||||
|
||||
Self {
|
||||
base,
|
||||
prev: prev_vals,
|
||||
|
||||
jump_dist,
|
||||
travel_dist,
|
||||
angle,
|
||||
|
||||
delta,
|
||||
strain_time,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
use super::super::DifficultyAttributes;
|
||||
|
||||
mod control_point_iter;
|
||||
mod difficulty_object;
|
||||
mod osu_object;
|
||||
mod skill;
|
||||
mod skill_kind;
|
||||
mod slider_state;
|
||||
|
||||
use difficulty_object::DifficultyObject;
|
||||
use osu_object::OsuObject;
|
||||
use skill::Skill;
|
||||
use skill_kind::SkillKind;
|
||||
use slider_state::SliderState;
|
||||
|
||||
use crate::{Beatmap, Mods};
|
||||
|
||||
const OBJECT_RADIUS: f32 = 64.0;
|
||||
const SECTION_LEN: f32 = 400.0;
|
||||
const DIFFICULTY_MULTIPLIER: f32 = 0.0675;
|
||||
const NORMALIZED_RADIUS: f32 = 52.0;
|
||||
|
||||
/// Star calculation for osu!standard maps.
|
||||
///
|
||||
/// Slider paths are considered but stack leniency is ignored.
|
||||
/// As most maps don't even make use of leniency and even if,
|
||||
/// it has generally little effect on stars, the results are close to perfect.
|
||||
/// This version is considerably more efficient than `all_included` since
|
||||
/// processing stack leniency is relatively expensive.
|
||||
pub fn stars(map: &Beatmap, mods: impl Mods) -> DifficultyAttributes {
|
||||
let map_attributes = map.attributes().mods(mods);
|
||||
|
||||
let mut diff_attributes = DifficultyAttributes {
|
||||
ar: map_attributes.ar,
|
||||
od: map_attributes.od,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if map.hit_objects.len() < 2 {
|
||||
return diff_attributes;
|
||||
}
|
||||
|
||||
let section_len = SECTION_LEN * map_attributes.clock_rate;
|
||||
let radius = OBJECT_RADIUS * (1.0 - 0.7 * (map_attributes.cs - 5.0) / 5.0) / 2.0;
|
||||
let mut scaling_factor = NORMALIZED_RADIUS / radius;
|
||||
|
||||
if radius < 30.0 {
|
||||
let small_circle_bonus = (30.0 - radius).min(5.0) / 50.0;
|
||||
scaling_factor *= 1.0 + small_circle_bonus;
|
||||
}
|
||||
|
||||
let mut slider_state = SliderState::new(&map);
|
||||
let mut ticks_buf = Vec::new();
|
||||
|
||||
let mut hit_objects = map.hit_objects.iter().map(|h| {
|
||||
OsuObject::new(
|
||||
h,
|
||||
map,
|
||||
radius,
|
||||
&mut ticks_buf,
|
||||
&mut diff_attributes,
|
||||
&mut slider_state,
|
||||
)
|
||||
});
|
||||
|
||||
let mut aim = Skill::new(SkillKind::Aim);
|
||||
let mut speed = Skill::new(SkillKind::Speed);
|
||||
|
||||
// First object has no predecessor and thus no strain, handle distinctly
|
||||
let mut current_section_end =
|
||||
(map.hit_objects[0].start_time / section_len).ceil() * section_len;
|
||||
|
||||
let mut prev_prev = None;
|
||||
let mut prev = hit_objects.next().unwrap();
|
||||
let mut prev_vals = None;
|
||||
|
||||
// Handle second object separately to remove later if-branching
|
||||
let curr = hit_objects.next().unwrap();
|
||||
let h = DifficultyObject::new(
|
||||
&curr,
|
||||
&prev,
|
||||
prev_vals,
|
||||
prev_prev,
|
||||
map_attributes.clock_rate,
|
||||
scaling_factor,
|
||||
);
|
||||
|
||||
aim.process(&h);
|
||||
speed.process(&h);
|
||||
|
||||
prev_prev = Some(prev);
|
||||
prev_vals = Some((h.jump_dist, h.strain_time));
|
||||
prev = curr;
|
||||
|
||||
// Handle all other objects
|
||||
for curr in hit_objects {
|
||||
let h = DifficultyObject::new(
|
||||
&curr,
|
||||
&prev,
|
||||
prev_vals,
|
||||
prev_prev,
|
||||
map_attributes.clock_rate,
|
||||
scaling_factor,
|
||||
);
|
||||
|
||||
while h.base.time > current_section_end {
|
||||
aim.save_current_peak();
|
||||
aim.start_new_section_from(current_section_end);
|
||||
speed.save_current_peak();
|
||||
speed.start_new_section_from(current_section_end);
|
||||
|
||||
current_section_end += section_len;
|
||||
}
|
||||
|
||||
aim.process(&h);
|
||||
speed.process(&h);
|
||||
|
||||
prev_prev = Some(prev);
|
||||
prev_vals = Some((h.jump_dist, h.strain_time));
|
||||
prev = curr;
|
||||
}
|
||||
|
||||
aim.save_current_peak();
|
||||
speed.save_current_peak();
|
||||
|
||||
let aim_strain = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
let speed_strain = speed.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let stars = aim_strain + speed_strain + (aim_strain - speed_strain).abs() / 2.0;
|
||||
|
||||
diff_attributes.stars = stars;
|
||||
diff_attributes.speed_strain = speed_strain;
|
||||
diff_attributes.aim_strain = aim_strain;
|
||||
|
||||
diff_attributes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::super::PpCalculator;
|
||||
use super::stars;
|
||||
use crate::Beatmap;
|
||||
use std::fs::File;
|
||||
|
||||
#[test]
|
||||
fn no_leniency_single_stars() {
|
||||
let file = match File::open("./test/70090.osu") {
|
||||
Ok(file) => file,
|
||||
Err(why) => panic!("Could not open file: {}", why),
|
||||
};
|
||||
// let file = match File::open("C:/Users/Max/Desktop/2578801.osu") {
|
||||
// Ok(file) => file,
|
||||
// Err(why) => panic!("Could not open file: {}", why),
|
||||
// };
|
||||
|
||||
let map = match Beatmap::parse(file) {
|
||||
Ok(map) => map,
|
||||
Err(why) => panic!("Error while parsing map: {}", why),
|
||||
};
|
||||
|
||||
let stars = stars(&map, 0).stars;
|
||||
|
||||
println!("Stars: {}", stars);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn no_leniency_stars() {
|
||||
let margin = 0.005;
|
||||
|
||||
#[rustfmt::skip]
|
||||
// TODO: More mods
|
||||
let data = vec![
|
||||
(1851299, 1 << 8, 4.23514130038547), // HT
|
||||
(1851299, 0, 5.356786475158158), // NM
|
||||
(1851299, 1 << 6, 7.450616908751305), // DT
|
||||
(1851299, 1 << 4, 5.6834681957637665),// HR
|
||||
(1851299, 1 << 1, 4.937817303399699), // EZ
|
||||
|
||||
(70090, 1 << 8, 2.2929922580201803), // HT
|
||||
(70090, 0, 2.8322940761833983), // NM
|
||||
(70090, 1 << 6, 3.8338563325375485), // DT
|
||||
(70090, 1 << 4, 3.0617492228478174), // HR
|
||||
(70090, 1 << 1, 2.698823231324141), // EZ
|
||||
|
||||
(1241370, 1 << 8, 5.662809600985943), // HT
|
||||
(1241370, 0, 7.0367002127481975), // NM
|
||||
(1241370, 1 << 6, 11.144720506574934),// DT
|
||||
(1241370, 1 << 4, 7.641688110458715), // HR
|
||||
(1241370, 1 << 1, 6.316288616688052), // EZ
|
||||
|
||||
// Slider fiesta
|
||||
// (1657535, 1 << 8, 4.1727975286379895),// HT
|
||||
// (1657535, 0, 5.16048239944917), // NM
|
||||
// (1657535, 1 << 6, 7.125936779100417), // DT
|
||||
// (1657535, 1 << 4, 5.545877027713307), // HR
|
||||
// (1657535, 1 << 1, 4.66015083361088), // EZ
|
||||
];
|
||||
|
||||
for (map_id, mods, expected_stars) in data {
|
||||
let file = match File::open(format!("./test/{}.osu", map_id)) {
|
||||
Ok(file) => file,
|
||||
Err(why) => panic!("Could not open file {}.osu: {}", map_id, why),
|
||||
};
|
||||
|
||||
let map = match Beatmap::parse(file) {
|
||||
Ok(map) => map,
|
||||
Err(why) => panic!("Error while parsing map {}: {}", map_id, why),
|
||||
};
|
||||
|
||||
let stars = stars(&map, mods).stars;
|
||||
|
||||
assert!(
|
||||
(stars - expected_stars).abs() < margin,
|
||||
"Stars: {} | Expected: {} => {} margin [map {} | mods {}]",
|
||||
stars,
|
||||
expected_stars,
|
||||
(stars - expected_stars).abs(),
|
||||
map_id,
|
||||
mods
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_leniency_single_pp() {
|
||||
let file = match File::open("E:/Games/osu!/beatmaps/1241370.osu") {
|
||||
Ok(file) => file,
|
||||
Err(why) => panic!("Could not open file: {}", why),
|
||||
};
|
||||
// let file = match File::open("C:/Users/Max/Desktop/2578801.osu") {
|
||||
// Ok(file) => file,
|
||||
// Err(why) => panic!("Could not open file: {}", why),
|
||||
// };
|
||||
|
||||
let map = match Beatmap::parse(file) {
|
||||
Ok(map) => map,
|
||||
Err(why) => panic!("Error while parsing map: {}", why),
|
||||
};
|
||||
|
||||
let calculator = PpCalculator::new(&map)
|
||||
// .misses(2)
|
||||
// .accuracy(96.78)
|
||||
// .combo(1876)
|
||||
// .n100(0)
|
||||
.mods(8 + 16);
|
||||
|
||||
let result = calculator.calculate(stars);
|
||||
|
||||
println!("Stars: {}", result.attributes.stars);
|
||||
println!("PP: {}", result.pp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use super::super::super::DifficultyAttributes;
|
||||
use super::slider_state::SliderState;
|
||||
|
||||
use crate::{curve::Curve, Beatmap, HitObject, HitObjectKind, PathType, Pos2};
|
||||
|
||||
const LEGACY_LAST_TICK_OFFSET: f32 = 36.0;
|
||||
|
||||
pub(crate) struct OsuObject {
|
||||
pub(crate) time: f32,
|
||||
pub(crate) pos: Pos2,
|
||||
pub(crate) end_pos: Pos2,
|
||||
// circle: Some(0.0) | slider: Some(_) | spinner: None
|
||||
pub(crate) travel_dist: Option<f32>,
|
||||
}
|
||||
|
||||
impl OsuObject {
|
||||
pub(crate) fn new(
|
||||
h: &HitObject,
|
||||
map: &Beatmap,
|
||||
radius: f32,
|
||||
ticks: &mut Vec<f32>,
|
||||
attributes: &mut DifficultyAttributes,
|
||||
slider_state: &mut SliderState,
|
||||
) -> Self {
|
||||
attributes.max_combo += 1; // hitcircle, slider head, or spinner
|
||||
|
||||
match &h.kind {
|
||||
HitObjectKind::Circle => {
|
||||
attributes.n_circles += 1;
|
||||
|
||||
Self {
|
||||
time: h.start_time,
|
||||
pos: h.pos,
|
||||
end_pos: h.pos,
|
||||
travel_dist: Some(0.0),
|
||||
}
|
||||
}
|
||||
HitObjectKind::Slider {
|
||||
pixel_len,
|
||||
repeats,
|
||||
curve_points,
|
||||
path_type,
|
||||
} => {
|
||||
// Key values which are computed here
|
||||
let mut end_pos = h.pos;
|
||||
let mut travel_dist = 0.0;
|
||||
|
||||
slider_state.update(h.start_time); // Responsible for timing point values
|
||||
let approx_follow_circle_radius = radius * 3.0;
|
||||
let mut tick_distance = 100.0 * map.sv / map.tick_rate;
|
||||
|
||||
if map.version >= 8 {
|
||||
tick_distance /=
|
||||
(100.0 / slider_state.speed_mult).max(10.0).min(1000.0) / 100.0;
|
||||
}
|
||||
|
||||
let duration = *repeats as f32 * slider_state.beat_len * pixel_len
|
||||
/ (map.sv * slider_state.speed_mult)
|
||||
/ 100.0;
|
||||
let span_duration = duration / *repeats as f32;
|
||||
|
||||
// Ensure path type validity
|
||||
let path_type = if *path_type == PathType::PerfectCurve && curve_points.len() > 3 {
|
||||
PathType::Bezier
|
||||
} else if curve_points.len() == 2 {
|
||||
PathType::Linear
|
||||
} else {
|
||||
*path_type
|
||||
};
|
||||
|
||||
// Build the curve w.r.t. the curve points
|
||||
let curve = match path_type {
|
||||
PathType::Linear => Curve::linear(curve_points[0], curve_points[1]),
|
||||
PathType::Bezier => Curve::bezier(&curve_points),
|
||||
PathType::Catmull => Curve::catmull(&curve_points),
|
||||
PathType::PerfectCurve => Curve::perfect(&curve_points),
|
||||
};
|
||||
|
||||
// Called on each slider object except for the head.
|
||||
// Increases combo and adjusts `end_pos` and `travel_dist`
|
||||
// w.r.t. the object position at the given time on the slider curve.
|
||||
let mut compute_vertex = |time: f32| {
|
||||
attributes.max_combo += 1;
|
||||
|
||||
let mut progress = (time - h.start_time) / span_duration;
|
||||
|
||||
if progress % 2.0 >= 1.0 {
|
||||
progress = 1.0 - progress % 1.0;
|
||||
} else {
|
||||
progress %= 1.0;
|
||||
}
|
||||
|
||||
let curr_dist = pixel_len * progress;
|
||||
let curr_pos = curve.point_at_distance(curr_dist);
|
||||
|
||||
let diff = curr_pos - end_pos;
|
||||
let mut dist = diff.length();
|
||||
|
||||
if dist > approx_follow_circle_radius {
|
||||
dist -= approx_follow_circle_radius;
|
||||
end_pos += diff.normalize() * dist;
|
||||
travel_dist += dist;
|
||||
}
|
||||
};
|
||||
|
||||
let mut current_distance = tick_distance;
|
||||
let time_add = duration * (tick_distance / (pixel_len * *repeats as f32));
|
||||
|
||||
let target = pixel_len - tick_distance / 8.0;
|
||||
ticks.reserve((target / tick_distance) as usize);
|
||||
|
||||
// Tick of the first span
|
||||
if current_distance < target {
|
||||
for tick_idx in 1.. {
|
||||
let time = h.start_time + time_add * tick_idx as f32;
|
||||
compute_vertex(time);
|
||||
ticks.push(time);
|
||||
current_distance += tick_distance;
|
||||
|
||||
if current_distance >= target {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Other spans
|
||||
if *repeats > 1 {
|
||||
for repeat_id in 1..*repeats {
|
||||
let time_offset = (duration / *repeats as f32) * repeat_id as f32;
|
||||
|
||||
// Reverse tick
|
||||
compute_vertex(h.start_time + time_offset);
|
||||
|
||||
// Actual ticks
|
||||
if repeat_id & 1 == 1 {
|
||||
ticks.iter().rev().for_each(|&time| compute_vertex(time));
|
||||
} else {
|
||||
ticks.iter().for_each(|&time| compute_vertex(time));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Slider tail
|
||||
let final_span_idx = repeats.saturating_sub(1);
|
||||
let final_span_start_time = h.start_time + final_span_idx as f32 * span_duration;
|
||||
let final_span_end_time = (h.start_time + duration / 2.0)
|
||||
.max(final_span_start_time + span_duration - LEGACY_LAST_TICK_OFFSET);
|
||||
compute_vertex(final_span_end_time);
|
||||
|
||||
ticks.clear();
|
||||
|
||||
Self {
|
||||
time: h.start_time,
|
||||
pos: h.pos,
|
||||
end_pos,
|
||||
travel_dist: Some(travel_dist),
|
||||
}
|
||||
}
|
||||
HitObjectKind::Spinner { .. } => {
|
||||
attributes.n_spinners += 1;
|
||||
|
||||
Self {
|
||||
time: h.start_time,
|
||||
pos: h.pos,
|
||||
end_pos: h.pos,
|
||||
travel_dist: None,
|
||||
}
|
||||
}
|
||||
HitObjectKind::Hold { .. } => panic!("found Hold object in osu!standard file"),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn is_spinner(&self) -> bool {
|
||||
self.travel_dist.is_none()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use super::{DifficultyObject, SkillKind};
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
const SPEED_SKILL_MULTIPLIER: f32 = 1400.0;
|
||||
const SPEED_STRAIN_DECAY_BASE: f32 = 0.3;
|
||||
|
||||
const AIM_SKILL_MULTIPLIER: f32 = 26.25;
|
||||
const AIM_STRAIN_DECAY_BASE: f32 = 0.15;
|
||||
|
||||
const DECAY_WEIGHT: f32 = 0.9;
|
||||
|
||||
pub(crate) struct Skill {
|
||||
current_strain: f32,
|
||||
current_section_peak: f32,
|
||||
|
||||
kind: SkillKind,
|
||||
strain_peaks: Vec<f32>,
|
||||
|
||||
prev_time: Option<f32>,
|
||||
}
|
||||
|
||||
impl Skill {
|
||||
#[inline]
|
||||
pub(crate) fn new(kind: SkillKind) -> Self {
|
||||
Self {
|
||||
current_strain: 1.0,
|
||||
current_section_peak: 1.0,
|
||||
|
||||
kind,
|
||||
strain_peaks: Vec::with_capacity(128),
|
||||
|
||||
prev_time: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn save_current_peak(&mut self) {
|
||||
self.strain_peaks.push(self.current_section_peak);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn start_new_section_from(&mut self, time: f32) {
|
||||
self.current_section_peak = self.peak_strain(time - self.prev_time.unwrap());
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn process(&mut self, current: &DifficultyObject) {
|
||||
self.current_strain *= self.strain_decay(current.delta);
|
||||
self.current_strain += self.kind.strain_value_of(¤t) * self.skill_multiplier();
|
||||
self.current_section_peak = self.current_section_peak.max(self.current_strain);
|
||||
self.prev_time.replace(current.base.time);
|
||||
}
|
||||
|
||||
pub(crate) fn difficulty_value(&mut self) -> f32 {
|
||||
let mut difficulty = 0.0;
|
||||
let mut weight = 1.0;
|
||||
|
||||
self.strain_peaks
|
||||
.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
|
||||
|
||||
for &strain in self.strain_peaks.iter() {
|
||||
difficulty += strain * weight;
|
||||
weight *= DECAY_WEIGHT;
|
||||
}
|
||||
|
||||
difficulty
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn skill_multiplier(&self) -> f32 {
|
||||
match self.kind {
|
||||
SkillKind::Aim => AIM_SKILL_MULTIPLIER,
|
||||
SkillKind::Speed => SPEED_SKILL_MULTIPLIER,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_decay_base(&self) -> f32 {
|
||||
match self.kind {
|
||||
SkillKind::Aim => AIM_STRAIN_DECAY_BASE,
|
||||
SkillKind::Speed => SPEED_STRAIN_DECAY_BASE,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn peak_strain(&self, delta_time: f32) -> f32 {
|
||||
self.current_strain * self.strain_decay(delta_time)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_decay(&self, ms: f32) -> f32 {
|
||||
self.strain_decay_base().powf(ms / 1000.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use super::DifficultyObject;
|
||||
|
||||
const SINGLE_SPACING_TRESHOLD: f32 = 125.0;
|
||||
const SPEED_ANGLE_BONUS_BEGIN: f32 = 5.0 * std::f32::consts::FRAC_PI_6;
|
||||
const PI_OVER_4: f32 = std::f32::consts::FRAC_PI_4;
|
||||
const PI_OVER_2: f32 = std::f32::consts::FRAC_PI_2;
|
||||
|
||||
const MIN_SPEED_BONUS: f32 = 75.0;
|
||||
const MAX_SPEED_BONUS: f32 = 45.0;
|
||||
const SPEED_BALANCING_FACTOR: f32 = 40.0;
|
||||
|
||||
const AIM_ANGLE_BONUS_BEGIN: f32 = std::f32::consts::FRAC_PI_3;
|
||||
const TIMING_THRESHOLD: f32 = 107.0;
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub(crate) enum SkillKind {
|
||||
Aim,
|
||||
Speed,
|
||||
}
|
||||
|
||||
impl SkillKind {
|
||||
pub(crate) fn strain_value_of(self, current: &DifficultyObject) -> f32 {
|
||||
match self {
|
||||
Self::Aim => {
|
||||
if current.base.is_spinner() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut result = 0.0;
|
||||
|
||||
if let Some((prev_jump_dist, prev_strain_time)) = current.prev {
|
||||
if let Some(angle) = current.angle.filter(|a| *a > AIM_ANGLE_BONUS_BEGIN) {
|
||||
let scale = 90.0;
|
||||
|
||||
let angle_bonus = (((angle - AIM_ANGLE_BONUS_BEGIN).sin()).powi(2)
|
||||
* (prev_jump_dist - scale).max(0.0)
|
||||
* (current.jump_dist - scale).max(0.0))
|
||||
.sqrt();
|
||||
|
||||
result = 1.5 * apply_diminishing_exp(angle_bonus.max(0.0))
|
||||
/ (TIMING_THRESHOLD).max(prev_strain_time)
|
||||
}
|
||||
}
|
||||
|
||||
let jump_dist_exp = apply_diminishing_exp(current.jump_dist);
|
||||
let travel_dist_exp = apply_diminishing_exp(current.travel_dist);
|
||||
|
||||
let dist_exp =
|
||||
jump_dist_exp + travel_dist_exp + (travel_dist_exp * jump_dist_exp).sqrt();
|
||||
|
||||
(result + dist_exp / (current.strain_time).max(TIMING_THRESHOLD))
|
||||
.max(dist_exp / current.strain_time)
|
||||
}
|
||||
Self::Speed => {
|
||||
if current.base.is_spinner() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let dist = SINGLE_SPACING_TRESHOLD.min(current.travel_dist + current.jump_dist);
|
||||
let delta_time = MAX_SPEED_BONUS.max(current.delta);
|
||||
|
||||
let mut speed_bonus = 1.0;
|
||||
|
||||
if delta_time < MIN_SPEED_BONUS {
|
||||
let exp_base = (MIN_SPEED_BONUS - delta_time) / SPEED_BALANCING_FACTOR;
|
||||
speed_bonus = 1.0 + exp_base * exp_base;
|
||||
}
|
||||
|
||||
let mut angle_bonus = 1.0;
|
||||
|
||||
if let Some(angle) = current.angle.filter(|a| *a < SPEED_ANGLE_BONUS_BEGIN) {
|
||||
let exp_base = (1.5 * (SPEED_ANGLE_BONUS_BEGIN - angle)).sin();
|
||||
angle_bonus = 1.0 + exp_base * exp_base / 3.57;
|
||||
|
||||
if angle < PI_OVER_2 {
|
||||
angle_bonus = 1.28;
|
||||
|
||||
if dist < 90.0 && angle < PI_OVER_4 {
|
||||
angle_bonus += (1.0 - angle_bonus) * ((90.0 - dist) / 10.0).min(1.0);
|
||||
} else if dist < 90.0 {
|
||||
angle_bonus += (1.0 - angle_bonus)
|
||||
* ((90.0 - dist) / 10.0).min(1.0)
|
||||
* ((PI_OVER_2 - angle) / PI_OVER_4).sin();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(1.0 + (speed_bonus - 1.0) * 0.75)
|
||||
* angle_bonus
|
||||
* (0.95 + speed_bonus * (dist / SINGLE_SPACING_TRESHOLD).powf(3.5))
|
||||
/ current.strain_time
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn apply_diminishing_exp(val: f32) -> f32 {
|
||||
val.powf(0.99)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use super::control_point_iter::{ControlPoint, ControlPointIter};
|
||||
use crate::Beatmap;
|
||||
|
||||
pub(crate) struct SliderState<'p> {
|
||||
control_points: ControlPointIter<'p>,
|
||||
next: Option<ControlPoint>,
|
||||
pub(crate) beat_len: f32,
|
||||
pub(crate) speed_mult: f32,
|
||||
}
|
||||
|
||||
impl<'p> SliderState<'p> {
|
||||
#[inline]
|
||||
pub(crate) fn new(map: &'p Beatmap) -> Self {
|
||||
let mut control_points = ControlPointIter::new(map);
|
||||
|
||||
let (beat_len, speed_mult) = match control_points.next() {
|
||||
Some(ControlPoint::Timing { beat_len, .. }) => (beat_len, 1.0),
|
||||
Some(ControlPoint::Difficulty { speed_mult, .. }) => (1000.0, speed_mult),
|
||||
None => (1000.0, 1.0),
|
||||
};
|
||||
|
||||
Self {
|
||||
next: control_points.next(),
|
||||
control_points,
|
||||
beat_len,
|
||||
speed_mult,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn update(&mut self, time: f32) {
|
||||
while let Some(next) = self.next.as_ref().filter(|n| time >= n.time()) {
|
||||
match next {
|
||||
ControlPoint::Timing { beat_len, .. } => {
|
||||
self.beat_len = *beat_len;
|
||||
self.speed_mult = 1.0;
|
||||
}
|
||||
ControlPoint::Difficulty { speed_mult, .. } => self.speed_mult = *speed_mult,
|
||||
}
|
||||
|
||||
self.next = self.control_points.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use crate::{Beatmap, DifficultyPoint, TimingPoint};
|
||||
|
||||
use std::slice::Iter;
|
||||
|
||||
pub(crate) struct ControlPointIter<'p> {
|
||||
timing_points: Iter<'p, TimingPoint>,
|
||||
difficulty_points: Iter<'p, DifficultyPoint>,
|
||||
|
||||
next_timing: Option<f32>,
|
||||
next_difficulty: Option<(f32, f32)>,
|
||||
}
|
||||
|
||||
impl<'p> ControlPointIter<'p> {
|
||||
pub(crate) fn new(map: &'p Beatmap) -> Self {
|
||||
let mut timing_points = map.timing_points.iter();
|
||||
let mut difficulty_points = map.difficulty_points.iter();
|
||||
|
||||
Self {
|
||||
next_timing: timing_points.next().map(|t| t.time),
|
||||
next_difficulty: difficulty_points
|
||||
.next()
|
||||
.map(|d| (d.time, d.speed_multiplier)),
|
||||
|
||||
timing_points,
|
||||
difficulty_points,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum ControlPoint {
|
||||
Timing { time: f32 },
|
||||
Difficulty { time: f32, speed_multiplier: f32 },
|
||||
}
|
||||
|
||||
impl<'p> Iterator for ControlPointIter<'p> {
|
||||
type Item = ControlPoint;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
match (self.next_timing, self.next_difficulty) {
|
||||
(Some(time), Some((d, _))) if time <= d => {
|
||||
self.next_timing = self.timing_points.next().map(|t| t.time);
|
||||
|
||||
Some(ControlPoint::Timing { time })
|
||||
}
|
||||
(Some(_), Some((time, speed_multiplier))) => {
|
||||
self.next_difficulty = self
|
||||
.difficulty_points
|
||||
.next()
|
||||
.map(|d| (d.time, d.speed_multiplier));
|
||||
|
||||
Some(ControlPoint::Difficulty {
|
||||
time,
|
||||
speed_multiplier,
|
||||
})
|
||||
}
|
||||
(Some(time), _) => {
|
||||
self.next_timing = self.timing_points.next().map(|t| t.time);
|
||||
|
||||
Some(ControlPoint::Timing { time })
|
||||
}
|
||||
(_, Some((time, speed_multiplier))) => {
|
||||
self.next_difficulty = self
|
||||
.difficulty_points
|
||||
.next()
|
||||
.map(|d| (d.time, d.speed_multiplier));
|
||||
|
||||
Some(ControlPoint::Difficulty {
|
||||
time,
|
||||
speed_multiplier,
|
||||
})
|
||||
}
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use crate::HitObject;
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
pub(crate) struct DifficultyObject<'h> {
|
||||
pub(crate) base: &'h HitObject,
|
||||
pub(crate) prev: Option<(f32, f32)>, // (jump_dist, strain_time)
|
||||
|
||||
pub(crate) jump_dist: f32,
|
||||
pub(crate) angle: Option<f32>,
|
||||
|
||||
pub(crate) delta: f32,
|
||||
pub(crate) strain_time: f32,
|
||||
}
|
||||
|
||||
impl<'h> DifficultyObject<'h> {
|
||||
pub(crate) fn new(
|
||||
base: &'h HitObject,
|
||||
prev: &HitObject,
|
||||
prev_vals: Option<(f32, f32)>, // (jump_dist, strain_time)
|
||||
prev_prev: Option<Cow<HitObject>>,
|
||||
clock_rate: f32,
|
||||
scaling_factor: f32,
|
||||
) -> Self {
|
||||
let delta = (base.start_time - prev.start_time) / clock_rate;
|
||||
let strain_time = delta.max(50.0);
|
||||
|
||||
let jump_dist = if base.is_spinner() {
|
||||
0.0
|
||||
} else {
|
||||
(base.pos * scaling_factor - prev.pos * scaling_factor).length()
|
||||
};
|
||||
|
||||
let angle = prev_prev.map(|prev_prev| {
|
||||
let v1 = prev_prev.pos - prev.pos;
|
||||
let v2 = base.pos - prev.pos;
|
||||
|
||||
let dot = v1.dot(v2);
|
||||
let det = v1.x * v2.y - v1.y * v2.x;
|
||||
|
||||
det.atan2(dot).abs()
|
||||
});
|
||||
|
||||
Self {
|
||||
base,
|
||||
prev: prev_vals,
|
||||
|
||||
jump_dist,
|
||||
angle,
|
||||
|
||||
delta,
|
||||
strain_time,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
use super::super::{difficulty_range_ar, difficulty_range_od, DifficultyAttributes};
|
||||
|
||||
mod control_point_iter;
|
||||
mod difficulty_object;
|
||||
mod skill;
|
||||
mod skill_kind;
|
||||
mod slider_state;
|
||||
|
||||
use difficulty_object::DifficultyObject;
|
||||
use skill::Skill;
|
||||
use skill_kind::SkillKind;
|
||||
use slider_state::SliderState;
|
||||
|
||||
use crate::{Beatmap, HitObject, HitObjectKind, Mods};
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
const OBJECT_RADIUS: f32 = 64.0;
|
||||
const SECTION_LEN: f32 = 400.0;
|
||||
const DIFFICULTY_MULTIPLIER: f32 = 0.0675;
|
||||
const NORMALIZED_RADIUS: f32 = 52.0;
|
||||
|
||||
/// Star calculation for osu!standard maps.
|
||||
///
|
||||
/// Sliders are considered as regular hitcircles and stack leniency is ignored.
|
||||
/// Still very good results but the least precise version in general.
|
||||
/// However, this is the most efficient one.
|
||||
pub fn stars(map: &Beatmap, mods: impl Mods) -> DifficultyAttributes {
|
||||
let attributes = map.attributes().mods(mods);
|
||||
|
||||
if map.hit_objects.len() < 2 {
|
||||
return DifficultyAttributes {
|
||||
ar: attributes.ar,
|
||||
od: attributes.od,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
let section_len = SECTION_LEN * attributes.clock_rate;
|
||||
let radius = OBJECT_RADIUS * (1.0 - 0.7 * (attributes.cs - 5.0) / 5.0) / 2.0;
|
||||
let mut scaling_factor = NORMALIZED_RADIUS / radius;
|
||||
|
||||
if radius < 30.0 {
|
||||
let small_circle_bonus = (30.0 - radius).min(5.0) / 50.0;
|
||||
scaling_factor *= 1.0 + small_circle_bonus;
|
||||
}
|
||||
|
||||
let mut max_combo = 0;
|
||||
let mut n_circles = 0;
|
||||
let mut n_spinners = 0;
|
||||
let mut state = SliderState::new(&map);
|
||||
|
||||
let mut hit_objects = map.hit_objects.iter().map(|h| match &h.kind {
|
||||
HitObjectKind::Circle => {
|
||||
max_combo += 1;
|
||||
n_circles += 1;
|
||||
|
||||
Cow::Borrowed(h)
|
||||
}
|
||||
HitObjectKind::Slider {
|
||||
pixel_len, repeats, ..
|
||||
} => {
|
||||
max_combo += state.count_ticks(h.start_time, *pixel_len, *repeats, &map);
|
||||
|
||||
Cow::Owned(HitObject {
|
||||
pos: h.pos,
|
||||
start_time: h.start_time,
|
||||
kind: HitObjectKind::Circle,
|
||||
sound: h.sound,
|
||||
})
|
||||
}
|
||||
HitObjectKind::Spinner { .. } => {
|
||||
max_combo += 1;
|
||||
n_spinners += 1;
|
||||
|
||||
Cow::Borrowed(h)
|
||||
}
|
||||
HitObjectKind::Hold { .. } => panic!("found Hold object in osu!standard map"),
|
||||
});
|
||||
|
||||
let mut aim = Skill::new(SkillKind::Aim);
|
||||
let mut speed = Skill::new(SkillKind::Speed);
|
||||
|
||||
// First object has no predecessor and thus no strain, handle distinctly
|
||||
let mut current_section_end =
|
||||
(map.hit_objects[0].start_time / section_len).ceil() * section_len;
|
||||
|
||||
let mut prev_prev = None;
|
||||
let mut prev = hit_objects.next().unwrap();
|
||||
let mut prev_vals = None;
|
||||
|
||||
// Handle second object separately to remove later if-branching
|
||||
let curr = hit_objects.next().unwrap();
|
||||
let h = DifficultyObject::new(
|
||||
curr.as_ref(),
|
||||
prev.as_ref(),
|
||||
prev_vals,
|
||||
prev_prev,
|
||||
attributes.clock_rate,
|
||||
scaling_factor,
|
||||
);
|
||||
|
||||
aim.process(&h);
|
||||
speed.process(&h);
|
||||
|
||||
prev_prev = Some(prev);
|
||||
prev_vals = Some((h.jump_dist, h.strain_time));
|
||||
prev = curr;
|
||||
|
||||
// Handle all other objects
|
||||
for curr in hit_objects {
|
||||
let h = DifficultyObject::new(
|
||||
curr.as_ref(),
|
||||
prev.as_ref(),
|
||||
prev_vals,
|
||||
prev_prev,
|
||||
attributes.clock_rate,
|
||||
scaling_factor,
|
||||
);
|
||||
|
||||
while h.base.start_time > current_section_end {
|
||||
aim.save_current_peak();
|
||||
aim.start_new_section_from(current_section_end);
|
||||
speed.save_current_peak();
|
||||
speed.start_new_section_from(current_section_end);
|
||||
|
||||
current_section_end += section_len;
|
||||
}
|
||||
|
||||
aim.process(&h);
|
||||
speed.process(&h);
|
||||
|
||||
prev_prev = Some(prev);
|
||||
prev_vals = Some((h.jump_dist, h.strain_time));
|
||||
prev = curr;
|
||||
}
|
||||
|
||||
aim.save_current_peak();
|
||||
speed.save_current_peak();
|
||||
|
||||
let aim_strain = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
let speed_strain = speed.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let stars = aim_strain + speed_strain + (aim_strain - speed_strain).abs() / 2.0;
|
||||
|
||||
let hit_window_od = difficulty_range_od(attributes.od) as i32 as f32 / attributes.clock_rate;
|
||||
let hit_window_ar = difficulty_range_ar(attributes.ar) as i32 as f32 / attributes.clock_rate;
|
||||
|
||||
let od = (80.0 - hit_window_od) / 6.0;
|
||||
let ar = if hit_window_ar > 1200.0 {
|
||||
(1800.0 - hit_window_ar) / 120.0
|
||||
} else {
|
||||
(1200.0 - hit_window_ar) / 150.0 + 5.0
|
||||
};
|
||||
|
||||
DifficultyAttributes {
|
||||
stars,
|
||||
ar,
|
||||
od,
|
||||
speed_strain,
|
||||
aim_strain,
|
||||
max_combo,
|
||||
n_circles,
|
||||
n_spinners,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::super::PpCalculator;
|
||||
use super::stars;
|
||||
use crate::Beatmap;
|
||||
use std::fs::File;
|
||||
|
||||
#[test]
|
||||
fn no_sliders_single_stars() {
|
||||
let file = match File::open("E:/Games/osu!/beatmaps/70090.osu") {
|
||||
Ok(file) => file,
|
||||
Err(why) => panic!("Could not open file: {}", why),
|
||||
};
|
||||
// let file = match File::open("C:/Users/Max/Desktop/1241370.osu") {
|
||||
// Ok(file) => file,
|
||||
// Err(why) => panic!("Could not open file: {}", why),
|
||||
// };
|
||||
|
||||
let map = match Beatmap::parse(file) {
|
||||
Ok(map) => map,
|
||||
Err(why) => panic!("Error while parsing map: {}", why),
|
||||
};
|
||||
|
||||
let stars = stars(&map, 1024 + 8 + 64 + 16).stars;
|
||||
|
||||
println!("Stars: {}", stars);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn no_sliders_stars() {
|
||||
let margin = 0.5;
|
||||
|
||||
#[rustfmt::skip]
|
||||
// TODO: More mods
|
||||
let data = vec![
|
||||
(1851299, 1 << 8, 4.23514130038547), // HT
|
||||
(1851299, 0, 5.356786475158158), // NM
|
||||
(1851299, 1 << 6, 7.450616908751305), // DT
|
||||
(1851299, 1 << 4, 5.6834681957637665),// HR
|
||||
(1851299, 1 << 1, 4.937817303399699), // EZ
|
||||
|
||||
(70090, 1 << 8, 2.2929922580201803), // HT
|
||||
(70090, 0, 2.8322940761833983), // NM
|
||||
(70090, 1 << 6, 3.8338563325375485), // DT
|
||||
(70090, 1 << 4, 3.0617492228478174), // HR
|
||||
(70090, 1 << 1, 2.698823231324141), // EZ
|
||||
|
||||
(1241370, 1 << 8, 5.662809600985943), // HT
|
||||
(1241370, 0, 7.0367002127481975), // NM
|
||||
(1241370, 1 << 6, 11.144720506574934),// DT
|
||||
(1241370, 1 << 4, 7.641688110458715), // HR
|
||||
(1241370, 1 << 1, 6.316288616688052), // EZ
|
||||
|
||||
// Slider fiesta
|
||||
// (1657535, 1 << 8, 4.1727975286379895),// HT
|
||||
// (1657535, 0, 5.16048239944917), // NM
|
||||
// (1657535, 1 << 6, 7.125936779100417), // DT
|
||||
// (1657535, 1 << 4, 5.545877027713307), // HR
|
||||
// (1657535, 1 << 1, 4.66015083361088), // EZ
|
||||
];
|
||||
|
||||
for (map_id, mods, expected_stars) in data {
|
||||
let file = match File::open(format!("./test/{}.osu", map_id)) {
|
||||
Ok(file) => file,
|
||||
Err(why) => panic!("Could not open file {}.osu: {}", map_id, why),
|
||||
};
|
||||
|
||||
let map = match Beatmap::parse(file) {
|
||||
Ok(map) => map,
|
||||
Err(why) => panic!("Error while parsing map {}: {}", map_id, why),
|
||||
};
|
||||
|
||||
let stars = stars(&map, mods).stars;
|
||||
|
||||
assert!(
|
||||
(stars - expected_stars).abs() < margin,
|
||||
"Stars: {} | Expected: {} => {} margin [map {} | mods {}]",
|
||||
stars,
|
||||
expected_stars,
|
||||
(stars - expected_stars).abs(),
|
||||
map_id,
|
||||
mods
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_sliders_single_pp() {
|
||||
let file = match File::open("E:/Games/osu!/beatmaps/1241370.osu") {
|
||||
Ok(file) => file,
|
||||
Err(why) => panic!("Could not open file: {}", why),
|
||||
};
|
||||
// let file = match File::open("C:/Users/Max/Desktop/1851299_base.osu") {
|
||||
// Ok(file) => file,
|
||||
// Err(why) => panic!("Could not open file: {}", why),
|
||||
// };
|
||||
|
||||
let map = match Beatmap::parse(file) {
|
||||
Ok(map) => map,
|
||||
Err(why) => panic!("Error while parsing map: {}", why),
|
||||
};
|
||||
|
||||
let calculator = PpCalculator::new(&map)
|
||||
.misses(2)
|
||||
.accuracy(96.78)
|
||||
.combo(1876)
|
||||
// .n100(0)
|
||||
.mods(8 + 16);
|
||||
|
||||
let result = calculator.calculate(stars);
|
||||
|
||||
println!("Stars: {}", result.attributes.stars);
|
||||
println!("PP: {}", result.pp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use super::{DifficultyObject, SkillKind};
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
const SPEED_SKILL_MULTIPLIER: f32 = 1400.0;
|
||||
const SPEED_STRAIN_DECAY_BASE: f32 = 0.3;
|
||||
|
||||
const AIM_SKILL_MULTIPLIER: f32 = 26.25;
|
||||
const AIM_STRAIN_DECAY_BASE: f32 = 0.15;
|
||||
|
||||
const DECAY_WEIGHT: f32 = 0.9;
|
||||
|
||||
pub(crate) struct Skill {
|
||||
current_strain: f32,
|
||||
current_section_peak: f32,
|
||||
|
||||
kind: SkillKind,
|
||||
strain_peaks: Vec<f32>,
|
||||
|
||||
prev_time: Option<f32>,
|
||||
}
|
||||
|
||||
impl Skill {
|
||||
#[inline]
|
||||
pub(crate) fn new(kind: SkillKind) -> Self {
|
||||
Self {
|
||||
current_strain: 1.0,
|
||||
current_section_peak: 1.0,
|
||||
|
||||
kind,
|
||||
strain_peaks: Vec::with_capacity(128),
|
||||
|
||||
prev_time: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn save_current_peak(&mut self) {
|
||||
self.strain_peaks.push(self.current_section_peak);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn start_new_section_from(&mut self, time: f32) {
|
||||
self.current_section_peak = self.peak_strain(time - self.prev_time.unwrap());
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn process(&mut self, current: &DifficultyObject) {
|
||||
self.current_strain *= self.strain_decay(current.delta);
|
||||
self.current_strain += self.kind.strain_value_of(¤t) * self.skill_multiplier();
|
||||
self.current_section_peak = self.current_section_peak.max(self.current_strain);
|
||||
self.prev_time.replace(current.base.start_time);
|
||||
}
|
||||
|
||||
pub(crate) fn difficulty_value(&mut self) -> f32 {
|
||||
let mut difficulty = 0.0;
|
||||
let mut weight = 1.0;
|
||||
|
||||
self.strain_peaks
|
||||
.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
|
||||
|
||||
for &strain in self.strain_peaks.iter() {
|
||||
difficulty += strain * weight;
|
||||
weight *= DECAY_WEIGHT;
|
||||
}
|
||||
|
||||
difficulty
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn skill_multiplier(&self) -> f32 {
|
||||
match self.kind {
|
||||
SkillKind::Aim => AIM_SKILL_MULTIPLIER,
|
||||
SkillKind::Speed => SPEED_SKILL_MULTIPLIER,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_decay_base(&self) -> f32 {
|
||||
match self.kind {
|
||||
SkillKind::Aim => AIM_STRAIN_DECAY_BASE,
|
||||
SkillKind::Speed => SPEED_STRAIN_DECAY_BASE,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn peak_strain(&self, delta_time: f32) -> f32 {
|
||||
self.current_strain * self.strain_decay(delta_time)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_decay(&self, ms: f32) -> f32 {
|
||||
self.strain_decay_base().powf(ms / 1000.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use super::DifficultyObject;
|
||||
|
||||
const SINGLE_SPACING_TRESHOLD: f32 = 125.0;
|
||||
const SPEED_ANGLE_BONUS_BEGIN: f32 = 5.0 * std::f32::consts::FRAC_PI_6;
|
||||
const PI_OVER_4: f32 = std::f32::consts::FRAC_PI_4;
|
||||
const PI_OVER_2: f32 = std::f32::consts::FRAC_PI_2;
|
||||
|
||||
const MIN_SPEED_BONUS: f32 = 75.0;
|
||||
const MAX_SPEED_BONUS: f32 = 45.0;
|
||||
const SPEED_BALANCING_FACTOR: f32 = 40.0;
|
||||
|
||||
const AIM_ANGLE_BONUS_BEGIN: f32 = std::f32::consts::FRAC_PI_3;
|
||||
const TIMING_THRESHOLD: f32 = 107.0;
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub(crate) enum SkillKind {
|
||||
Aim,
|
||||
Speed,
|
||||
}
|
||||
|
||||
impl SkillKind {
|
||||
pub(crate) fn strain_value_of(self, current: &DifficultyObject) -> f32 {
|
||||
match self {
|
||||
Self::Aim => {
|
||||
if current.base.is_spinner() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut result = 0.0;
|
||||
|
||||
if let Some((prev_jump_dist, prev_strain_time)) = current.prev {
|
||||
if let Some(angle) = current.angle.filter(|a| *a > AIM_ANGLE_BONUS_BEGIN) {
|
||||
let scale = 90.0;
|
||||
|
||||
let angle_bonus = (((angle - AIM_ANGLE_BONUS_BEGIN).sin()).powi(2)
|
||||
* (prev_jump_dist - scale).max(0.0)
|
||||
* (current.jump_dist - scale).max(0.0))
|
||||
.sqrt();
|
||||
|
||||
result = 1.5 * apply_diminishing_exp(angle_bonus.max(0.0))
|
||||
/ (TIMING_THRESHOLD).max(prev_strain_time)
|
||||
}
|
||||
}
|
||||
|
||||
let jump_dist_exp = apply_diminishing_exp(current.jump_dist);
|
||||
|
||||
(result + jump_dist_exp / (current.strain_time).max(TIMING_THRESHOLD))
|
||||
.max(jump_dist_exp / current.strain_time)
|
||||
}
|
||||
Self::Speed => {
|
||||
if current.base.is_spinner() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let dist = SINGLE_SPACING_TRESHOLD.min(current.jump_dist);
|
||||
let delta_time = MAX_SPEED_BONUS.max(current.delta);
|
||||
|
||||
let mut speed_bonus = 1.0;
|
||||
|
||||
if delta_time < MIN_SPEED_BONUS {
|
||||
let exp_base = (MIN_SPEED_BONUS - delta_time) / SPEED_BALANCING_FACTOR;
|
||||
speed_bonus = 1.0 + exp_base * exp_base;
|
||||
}
|
||||
|
||||
let mut angle_bonus = 1.0;
|
||||
|
||||
if let Some(angle) = current.angle.filter(|a| *a < SPEED_ANGLE_BONUS_BEGIN) {
|
||||
let exp_base = (1.5 * (SPEED_ANGLE_BONUS_BEGIN - angle)).sin();
|
||||
angle_bonus = 1.0 + exp_base * exp_base / 3.57;
|
||||
|
||||
if angle < PI_OVER_2 {
|
||||
angle_bonus = 1.28;
|
||||
|
||||
if dist < 90.0 && angle < PI_OVER_4 {
|
||||
angle_bonus += (1.0 - angle_bonus) * ((90.0 - dist) / 10.0).min(1.0);
|
||||
} else if dist < 90.0 {
|
||||
angle_bonus += (1.0 - angle_bonus)
|
||||
* ((90.0 - dist) / 10.0).min(1.0)
|
||||
* ((PI_OVER_2 - angle) / PI_OVER_4).sin();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(1.0 + (speed_bonus - 1.0) * 0.75)
|
||||
* angle_bonus
|
||||
* (0.95 + speed_bonus * (dist / SINGLE_SPACING_TRESHOLD).powf(3.5))
|
||||
/ current.strain_time
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn apply_diminishing_exp(val: f32) -> f32 {
|
||||
val.powf(0.99)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use super::control_point_iter::{ControlPoint, ControlPointIter};
|
||||
|
||||
use crate::Beatmap;
|
||||
|
||||
pub(crate) struct SliderState<'p> {
|
||||
control_points: ControlPointIter<'p>,
|
||||
next_time: f32,
|
||||
px_per_beat: f32,
|
||||
prev_sv: f32,
|
||||
}
|
||||
|
||||
impl<'p> SliderState<'p> {
|
||||
#[inline]
|
||||
pub(crate) fn new(map: &'p Beatmap) -> Self {
|
||||
Self {
|
||||
control_points: ControlPointIter::new(map),
|
||||
next_time: std::f32::NEG_INFINITY,
|
||||
px_per_beat: 1.0,
|
||||
prev_sv: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn count_ticks(
|
||||
&mut self,
|
||||
time: f32,
|
||||
pixel_len: f32,
|
||||
repeats: usize,
|
||||
map: &Beatmap,
|
||||
) -> usize {
|
||||
while time >= self.next_time {
|
||||
self.px_per_beat = map.sv * 100.0 * self.prev_sv;
|
||||
|
||||
match self.control_points.next() {
|
||||
Some(ControlPoint::Timing { time }) => {
|
||||
self.next_time = time;
|
||||
self.prev_sv = 1.0;
|
||||
}
|
||||
Some(ControlPoint::Difficulty {
|
||||
time,
|
||||
speed_multiplier,
|
||||
}) => {
|
||||
self.next_time = time;
|
||||
self.prev_sv = speed_multiplier;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
let spans = repeats as f32;
|
||||
let beats = pixel_len * spans / self.px_per_beat;
|
||||
let ticks = ((beats - 0.1) / spans * map.tick_rate).ceil() as usize;
|
||||
|
||||
ticks
|
||||
.checked_sub(1)
|
||||
.map_or(0, |ticks| ticks * repeats + repeats + 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use super::Mods;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BeatmapAttributes {
|
||||
pub ar: f32,
|
||||
pub od: f32,
|
||||
pub cs: f32,
|
||||
pub hp: f32,
|
||||
pub clock_rate: f32,
|
||||
}
|
||||
|
||||
impl BeatmapAttributes {
|
||||
const AR0_MS: f32 = 1800.0;
|
||||
const AR5_MS: f32 = 1200.0;
|
||||
const AR10_MS: f32 = 450.0;
|
||||
const AR_MS_STEP_1: f32 = (Self::AR0_MS - Self::AR5_MS) / 5.0;
|
||||
const AR_MS_STEP_2: f32 = (Self::AR5_MS - Self::AR10_MS) / 5.0;
|
||||
|
||||
const OD0_MS: f32 = 80.0;
|
||||
const OD10_MS: f32 = 20.0;
|
||||
const OD_MS_STEP: f32 = (Self::OD0_MS - Self::OD10_MS) / 10.0;
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn new(ar: f32, od: f32, cs: f32, hp: f32) -> Self {
|
||||
Self {
|
||||
ar,
|
||||
od,
|
||||
cs,
|
||||
hp,
|
||||
clock_rate: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mods(self, mods: impl Mods) -> Self {
|
||||
if !mods.change_map() {
|
||||
return self;
|
||||
}
|
||||
|
||||
let clock_rate = mods.speed();
|
||||
let multiplier = mods.od_ar_hp_multiplier();
|
||||
|
||||
// AR
|
||||
let mut ar = self.ar * multiplier;
|
||||
let mut ar_ms = if ar <= 5.0 {
|
||||
Self::AR0_MS - Self::AR_MS_STEP_1 * ar
|
||||
} else {
|
||||
Self::AR5_MS - Self::AR_MS_STEP_2 * (ar - 5.0)
|
||||
};
|
||||
|
||||
ar_ms = ar_ms.max(Self::AR10_MS).min(Self::AR0_MS);
|
||||
ar_ms /= clock_rate;
|
||||
|
||||
ar = if ar_ms > Self::AR5_MS {
|
||||
(Self::AR0_MS - ar_ms) / Self::AR_MS_STEP_1
|
||||
} else {
|
||||
5.0 + (Self::AR5_MS - ar_ms) / Self::AR_MS_STEP_2
|
||||
};
|
||||
|
||||
// OD
|
||||
let mut od = self.od * multiplier;
|
||||
let mut od_ms = Self::OD0_MS - (Self::OD_MS_STEP * od).ceil();
|
||||
od_ms = od_ms.max(Self::OD10_MS).min(Self::OD0_MS);
|
||||
od_ms /= clock_rate;
|
||||
od = (Self::OD0_MS - od_ms) / Self::OD_MS_STEP;
|
||||
|
||||
// CS
|
||||
let mut cs = self.cs;
|
||||
if mods.hr() {
|
||||
cs *= 1.3;
|
||||
} else if mods.ez() {
|
||||
cs *= 0.5;
|
||||
}
|
||||
cs = cs.min(10.0);
|
||||
|
||||
// HP
|
||||
let hp = (self.hp * multiplier).min(10.0);
|
||||
|
||||
Self {
|
||||
ar,
|
||||
od,
|
||||
cs,
|
||||
hp,
|
||||
clock_rate,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use std::cmp::Ordering;
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
pub struct TimingPoint {
|
||||
pub beat_len: f32,
|
||||
pub bpm: f32,
|
||||
pub time: f32,
|
||||
}
|
||||
|
||||
impl PartialOrd for TimingPoint {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
self.time.partial_cmp(&other.time)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
pub struct DifficultyPoint {
|
||||
pub time: f32,
|
||||
pub speed_multiplier: f32,
|
||||
}
|
||||
|
||||
impl PartialOrd for DifficultyPoint {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
self.time.partial_cmp(&other.time)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use super::OSU_FILE_HEADER;
|
||||
|
||||
use std::error::Error as StdError;
|
||||
use std::fmt;
|
||||
use std::io::Error as IOError;
|
||||
use std::num::{ParseFloatError, ParseIntError};
|
||||
|
||||
pub type ParseResult<T> = Result<T, ParseError>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ParseError {
|
||||
IOError(IOError),
|
||||
IncorrectFileHeader,
|
||||
BadLine,
|
||||
InvalidCurvePoints,
|
||||
InvalidInteger,
|
||||
InvalidEffectFlag,
|
||||
InvalidFloatingPoint,
|
||||
InvalidMode,
|
||||
InvalidPathType,
|
||||
InvalidTimingSignature,
|
||||
MissingField(usize),
|
||||
UnsupportedMode,
|
||||
UnknownHitObjectKind,
|
||||
NoHitobjects,
|
||||
UnsortedHitobjects,
|
||||
UnsortedTimingPoints,
|
||||
}
|
||||
|
||||
impl fmt::Display for ParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
Self::IOError(_) => f.write_str("IO error"),
|
||||
Self::IncorrectFileHeader => {
|
||||
write!(f, "expected `{}` at file begin", OSU_FILE_HEADER)
|
||||
}
|
||||
Self::BadLine => f.write_str("line not in `Key:Value` pattern"),
|
||||
Self::InvalidCurvePoints => f.write_str("invalid curve point"),
|
||||
Self::InvalidInteger => f.write_str("invalid integer"),
|
||||
Self::InvalidEffectFlag => f.write_str("invalid effect flag"),
|
||||
Self::InvalidFloatingPoint => f.write_str("invalid floating-point number"),
|
||||
Self::InvalidMode => f.write_str("invalid mode"),
|
||||
Self::InvalidPathType => f.write_str("invalid path type"),
|
||||
Self::InvalidTimingSignature => f.write_str("invalid timing signature"),
|
||||
Self::MissingField(line) => write!(f, "missing field on line {}", line),
|
||||
Self::UnsupportedMode => f.write_str("unsupported osu! mode"),
|
||||
Self::UnknownHitObjectKind => f.write_str("unsupported hitobject kind"),
|
||||
Self::NoHitobjects => f.write_str("beatmap has no hitobjects"),
|
||||
Self::UnsortedHitobjects => f.write_str("hitobjects are not sorted by time"),
|
||||
Self::UnsortedTimingPoints => f.write_str("timing points are not sorted by time"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StdError for ParseError {
|
||||
fn source(&self) -> Option<&(dyn StdError + 'static)> {
|
||||
match self {
|
||||
Self::IOError(inner) => Some(inner),
|
||||
Self::IncorrectFileHeader => None,
|
||||
Self::BadLine => None,
|
||||
Self::InvalidCurvePoints => None,
|
||||
Self::InvalidInteger => None,
|
||||
Self::InvalidEffectFlag => None,
|
||||
Self::InvalidFloatingPoint => None,
|
||||
Self::InvalidMode => None,
|
||||
Self::InvalidPathType => None,
|
||||
Self::InvalidTimingSignature => None,
|
||||
Self::MissingField(_) => None,
|
||||
Self::UnsupportedMode => None,
|
||||
Self::UnknownHitObjectKind => None,
|
||||
Self::NoHitobjects => None,
|
||||
Self::UnsortedHitobjects => None,
|
||||
Self::UnsortedTimingPoints => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<IOError> for ParseError {
|
||||
fn from(other: IOError) -> Self {
|
||||
Self::IOError(other)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ParseIntError> for ParseError {
|
||||
fn from(_: ParseIntError) -> Self {
|
||||
Self::InvalidInteger
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ParseFloatError> for ParseError {
|
||||
fn from(_: ParseFloatError) -> Self {
|
||||
Self::InvalidFloatingPoint
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use super::{PathType, Pos2};
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct HitObject {
|
||||
pub pos: Pos2,
|
||||
pub start_time: f32,
|
||||
pub kind: HitObjectKind,
|
||||
pub sound: u8,
|
||||
}
|
||||
|
||||
impl HitObject {
|
||||
#[inline]
|
||||
pub fn end_time(&self) -> f32 {
|
||||
match &self.kind {
|
||||
HitObjectKind::Circle { .. } => self.start_time,
|
||||
HitObjectKind::Slider { .. } => self.start_time, // wrong but should be unreachable
|
||||
HitObjectKind::Spinner { end_time } => *end_time,
|
||||
HitObjectKind::Hold { end_time, .. } => *end_time,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_circle(&self) -> bool {
|
||||
matches!(self.kind, HitObjectKind::Circle { .. })
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_slider(&self) -> bool {
|
||||
matches!(self.kind, HitObjectKind::Slider { .. })
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_spinner(&self) -> bool {
|
||||
matches!(self.kind, HitObjectKind::Spinner { .. })
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for HitObject {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
self.start_time.partial_cmp(&other.start_time)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum HitObjectKind {
|
||||
Circle,
|
||||
Slider {
|
||||
pixel_len: f32,
|
||||
repeats: usize,
|
||||
curve_points: Vec<Pos2>,
|
||||
path_type: PathType,
|
||||
},
|
||||
Spinner {
|
||||
end_time: f32,
|
||||
},
|
||||
Hold {
|
||||
end_time: f32,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
const HITSOUND_WHISTLE: u8 = 1 << 1;
|
||||
const HITSOUND_FINISH: u8 = 1 << 2;
|
||||
const HITSOUND_CLAP: u8 = 1 << 3;
|
||||
|
||||
pub trait HitSound {
|
||||
fn normal(self) -> bool;
|
||||
fn whistle(self) -> bool;
|
||||
fn finish(self) -> bool;
|
||||
fn clap(self) -> bool;
|
||||
}
|
||||
|
||||
impl HitSound for u8 {
|
||||
fn normal(self) -> bool {
|
||||
self == 0
|
||||
}
|
||||
|
||||
fn whistle(self) -> bool {
|
||||
self & HITSOUND_WHISTLE > 0
|
||||
}
|
||||
|
||||
fn finish(self) -> bool {
|
||||
self & HITSOUND_FINISH > 0
|
||||
}
|
||||
|
||||
fn clap(self) -> bool {
|
||||
self & HITSOUND_CLAP > 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
mod attributes;
|
||||
mod control_point;
|
||||
mod error;
|
||||
mod hitobject;
|
||||
mod hitsound;
|
||||
mod mods;
|
||||
mod pos2;
|
||||
mod sort;
|
||||
|
||||
pub use attributes::BeatmapAttributes;
|
||||
pub use control_point::{DifficultyPoint, TimingPoint};
|
||||
pub use error::{ParseError, ParseResult};
|
||||
pub use hitobject::{HitObject, HitObjectKind};
|
||||
pub use hitsound::HitSound;
|
||||
pub use mods::Mods;
|
||||
pub use pos2::Pos2;
|
||||
use sort::sort;
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::str::FromStr;
|
||||
|
||||
macro_rules! sort {
|
||||
($slice:expr) => {
|
||||
$slice.sort_unstable_by(|p1, p2| p1.partial_cmp(&p2).unwrap_or(Ordering::Equal))
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! next_field {
|
||||
($opt:expr, $nmbr:ident) => {
|
||||
$opt.ok_or(ParseError::MissingField($nmbr))?
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! validate_float {
|
||||
($x:expr) => {{
|
||||
if $x.is_finite() {
|
||||
$x
|
||||
} else {
|
||||
return Err(ParseError::InvalidFloatingPoint);
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum GameMode {
|
||||
STD = 0,
|
||||
TKO = 1,
|
||||
CTB = 2,
|
||||
MNA = 3,
|
||||
}
|
||||
|
||||
impl Default for GameMode {
|
||||
fn default() -> Self {
|
||||
Self::STD
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Debug)]
|
||||
pub struct Beatmap {
|
||||
pub mode: GameMode,
|
||||
pub version: u8,
|
||||
|
||||
pub n_circles: u32,
|
||||
pub n_sliders: u32,
|
||||
pub n_spinners: u32,
|
||||
|
||||
pub ar: f32,
|
||||
pub od: f32,
|
||||
pub cs: f32,
|
||||
pub hp: f32,
|
||||
pub sv: f32,
|
||||
pub tick_rate: f32,
|
||||
pub stack_leniency: f32,
|
||||
|
||||
pub hit_objects: Vec<HitObject>,
|
||||
pub timing_points: Vec<TimingPoint>,
|
||||
pub difficulty_points: Vec<DifficultyPoint>,
|
||||
}
|
||||
|
||||
pub(crate) const OSU_FILE_HEADER: &str = "osu file format v";
|
||||
|
||||
impl Beatmap {
|
||||
const CIRCLE_FLAG: u8 = 1 << 0;
|
||||
const SLIDER_FLAG: u8 = 1 << 1;
|
||||
#[allow(unused)]
|
||||
const NEW_COMBO_FLAG: u8 = 1 << 2;
|
||||
const SPINNER_FLAG: u8 = 1 << 3;
|
||||
#[allow(unused)]
|
||||
const COMBO_OFFSET_FLAG: u8 = (1 << 4) | (1 << 5) | (1 << 6);
|
||||
const HOLD_FLAG: u8 = 1 << 7;
|
||||
|
||||
pub fn parse<R: Read>(input: R) -> ParseResult<Self> {
|
||||
let mut reader = BufReader::new(input);
|
||||
let mut buf = String::new();
|
||||
|
||||
reader.read_line(&mut buf)?;
|
||||
|
||||
let mut map = Self::default();
|
||||
|
||||
map.version = match buf.find(OSU_FILE_HEADER) {
|
||||
Some(idx) => buf[idx + OSU_FILE_HEADER.len()..].trim_end().parse()?,
|
||||
None => return Err(ParseError::IncorrectFileHeader),
|
||||
};
|
||||
|
||||
// version 4 and lower had an incorrect offset (stable has this set as 24ms off)
|
||||
let offset = if map.version < 5 { 24.0 } else { 0.0 };
|
||||
|
||||
buf.clear();
|
||||
map.hit_objects.reserve(256);
|
||||
|
||||
let mut mode = None;
|
||||
let mut ar = None;
|
||||
let mut od = None;
|
||||
let mut cs = None;
|
||||
let mut hp = None;
|
||||
let mut sv = None;
|
||||
let mut tick_rate = None;
|
||||
let mut stack_leniency = None;
|
||||
|
||||
let mut section = Section::None;
|
||||
let mut prev_time = 0.0;
|
||||
let mut prev_diff = 0.0;
|
||||
let mut unsorted_timings = false;
|
||||
let mut unsorted_difficulties = false;
|
||||
let mut unsorted_hits = false;
|
||||
|
||||
let mut nmbr = 1;
|
||||
|
||||
while reader.read_line(&mut buf)? != 0 {
|
||||
let mut line = buf.trim_end();
|
||||
nmbr += 1;
|
||||
|
||||
if line.is_empty()
|
||||
|| line.starts_with("//")
|
||||
|| line.starts_with(' ')
|
||||
|| line.starts_with('_')
|
||||
{
|
||||
buf.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.starts_with('[') && line.ends_with(']') {
|
||||
section = Section::from_str(&line[1..line.len() - 1]);
|
||||
buf.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(idx) = line.find("//") {
|
||||
line = &line[..idx];
|
||||
}
|
||||
|
||||
match section {
|
||||
Section::General => {
|
||||
let (key, value) = split_colon(&line).ok_or(ParseError::BadLine)?;
|
||||
|
||||
if key == "Mode" {
|
||||
mode = match value {
|
||||
"0" => Some(GameMode::STD),
|
||||
"1" => Some(GameMode::TKO),
|
||||
"2" => Some(GameMode::CTB),
|
||||
"3" => Some(GameMode::MNA),
|
||||
_ => return Err(ParseError::InvalidMode),
|
||||
};
|
||||
} else if key == "StackLeniency" {
|
||||
stack_leniency = Some(value.parse()?);
|
||||
}
|
||||
}
|
||||
Section::Difficulty => {
|
||||
let (key, value) = split_colon(&line).ok_or(ParseError::BadLine)?;
|
||||
|
||||
match key {
|
||||
"ApproachRate" => ar = Some(value.parse()?),
|
||||
"OverallDifficulty" => od = Some(value.parse()?),
|
||||
"CircleSize" => cs = Some(value.parse()?),
|
||||
"HPDrainRate" => hp = Some(value.parse()?),
|
||||
"SliderTickRate" => tick_rate = Some(value.parse()?),
|
||||
"SliderMultiplier" => sv = Some(value.parse()?),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Section::TimingPoints => {
|
||||
let mut split = line.split(',');
|
||||
|
||||
let time = offset + next_field!(split.next(), nmbr).trim().parse::<f32>()?;
|
||||
validate_float!(time);
|
||||
|
||||
let beat_len = next_field!(split.next(), nmbr).trim().parse::<f32>()?;
|
||||
|
||||
if beat_len.is_sign_negative() {
|
||||
let point = DifficultyPoint {
|
||||
time,
|
||||
speed_multiplier: -100.0 / beat_len,
|
||||
};
|
||||
|
||||
map.difficulty_points.push(point);
|
||||
|
||||
if time < prev_diff {
|
||||
unsorted_difficulties = true;
|
||||
} else {
|
||||
prev_diff = time;
|
||||
}
|
||||
} else {
|
||||
let point = TimingPoint {
|
||||
time,
|
||||
bpm: 60_000.0 / beat_len,
|
||||
beat_len,
|
||||
};
|
||||
|
||||
map.timing_points.push(point);
|
||||
|
||||
if time < prev_time {
|
||||
unsorted_timings = true;
|
||||
} else {
|
||||
prev_time = time;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section::HitObjects => {
|
||||
let mut split = line.split(',');
|
||||
|
||||
let pos = Pos2 {
|
||||
x: next_field!(split.next(), nmbr).parse()?,
|
||||
y: next_field!(split.next(), nmbr).parse()?,
|
||||
};
|
||||
|
||||
let time = offset + next_field!(split.next(), nmbr).trim().parse::<f32>()?;
|
||||
validate_float!(time);
|
||||
|
||||
if !map.hit_objects.is_empty() && time < prev_time {
|
||||
unsorted_hits = true;
|
||||
}
|
||||
|
||||
let kind: u8 = next_field!(split.next(), nmbr).parse()?;
|
||||
let sound = split.next().map(str::parse).transpose()?.unwrap_or(0);
|
||||
|
||||
let kind = if kind & Self::CIRCLE_FLAG > 0 {
|
||||
map.n_circles += 1;
|
||||
|
||||
HitObjectKind::Circle
|
||||
} else if kind & Self::SLIDER_FLAG > 0 {
|
||||
map.n_sliders += 1;
|
||||
let mut curve_points = Vec::with_capacity(16);
|
||||
curve_points.push(pos);
|
||||
|
||||
let mut curve_point_iter = next_field!(split.next(), nmbr).split('|');
|
||||
|
||||
let mut path_type: PathType =
|
||||
next_field!(curve_point_iter.next(), nmbr).parse()?;
|
||||
|
||||
for pos in curve_point_iter {
|
||||
let mut v = pos.split(':').map(str::parse);
|
||||
|
||||
match (v.next(), v.next()) {
|
||||
(Some(Ok(x)), Some(Ok(y))) => curve_points.push(Pos2 { x, y }),
|
||||
_ => return Err(ParseError::InvalidCurvePoints),
|
||||
}
|
||||
}
|
||||
|
||||
if map.version <= 6 && curve_points.len() >= 2 {
|
||||
if path_type == PathType::Linear {
|
||||
path_type = PathType::Bezier;
|
||||
}
|
||||
|
||||
if curve_points.len() == 2
|
||||
&& (pos == curve_points[0] || pos == curve_points[1])
|
||||
{
|
||||
path_type = PathType::Linear;
|
||||
}
|
||||
}
|
||||
|
||||
if curve_points.is_empty() {
|
||||
HitObjectKind::Circle
|
||||
} else {
|
||||
let repeats = next_field!(split.next(), nmbr).parse::<usize>()?;
|
||||
let len: f32 = next_field!(split.next(), nmbr).parse()?;
|
||||
|
||||
HitObjectKind::Slider {
|
||||
repeats,
|
||||
pixel_len: len,
|
||||
curve_points,
|
||||
path_type,
|
||||
}
|
||||
}
|
||||
} else if kind & Self::SPINNER_FLAG > 0 {
|
||||
map.n_spinners += 1;
|
||||
let end_time = next_field!(split.next(), nmbr).parse()?;
|
||||
|
||||
HitObjectKind::Spinner { end_time }
|
||||
} else if kind & Self::HOLD_FLAG > 0 {
|
||||
map.n_sliders += 1;
|
||||
let mut end = time;
|
||||
|
||||
if let Some(next) = split.next() {
|
||||
end = end.max(next_field!(next.split(':').next(), nmbr).parse()?);
|
||||
}
|
||||
|
||||
HitObjectKind::Hold { end_time: end }
|
||||
} else {
|
||||
return Err(ParseError::UnknownHitObjectKind);
|
||||
};
|
||||
|
||||
map.hit_objects.push(HitObject {
|
||||
pos,
|
||||
start_time: time,
|
||||
kind,
|
||||
sound,
|
||||
});
|
||||
|
||||
prev_time = time;
|
||||
}
|
||||
|
||||
Section::None => {}
|
||||
}
|
||||
|
||||
buf.clear();
|
||||
}
|
||||
|
||||
map.mode = next_field!(mode, nmbr);
|
||||
map.ar = next_field!(ar, nmbr);
|
||||
map.od = next_field!(od, nmbr);
|
||||
map.cs = next_field!(cs, nmbr);
|
||||
map.hp = next_field!(hp, nmbr);
|
||||
map.sv = next_field!(sv, nmbr);
|
||||
map.tick_rate = next_field!(tick_rate, nmbr);
|
||||
map.stack_leniency = next_field!(stack_leniency, nmbr);
|
||||
|
||||
if unsorted_timings {
|
||||
sort!(map.timing_points);
|
||||
}
|
||||
|
||||
if unsorted_difficulties {
|
||||
sort!(map.difficulty_points);
|
||||
}
|
||||
|
||||
if map.mode == GameMode::MNA {
|
||||
sort(&mut map.hit_objects);
|
||||
} else if unsorted_hits {
|
||||
sort!(map.hit_objects);
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn attributes(&self) -> BeatmapAttributes {
|
||||
BeatmapAttributes::new(self.ar, self.od, self.cs, self.hp)
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn split_colon(line: &str) -> Option<(&str, &str)> {
|
||||
let mut split = line.split(':');
|
||||
|
||||
Some((split.next()?, split.next()?.trim()))
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum PathType {
|
||||
Catmull = 0,
|
||||
Bezier = 1,
|
||||
Linear = 2,
|
||||
PerfectCurve = 3,
|
||||
}
|
||||
|
||||
impl FromStr for PathType {
|
||||
type Err = ParseError;
|
||||
|
||||
#[inline]
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"L" => Ok(Self::Linear),
|
||||
"C" => Ok(Self::Catmull),
|
||||
"B" => Ok(Self::Bezier),
|
||||
"P" => Ok(Self::PerfectCurve),
|
||||
_ => Err(ParseError::InvalidPathType),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
enum Section {
|
||||
None,
|
||||
General,
|
||||
Difficulty,
|
||||
TimingPoints,
|
||||
HitObjects,
|
||||
}
|
||||
|
||||
impl Section {
|
||||
#[inline]
|
||||
fn from_str(s: &str) -> Self {
|
||||
match s {
|
||||
"General" => Self::General,
|
||||
"Difficulty" => Self::Difficulty,
|
||||
"TimingPoints" => Self::TimingPoints,
|
||||
"HitObjects" => Self::HitObjects,
|
||||
_ => Self::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::File;
|
||||
|
||||
#[test]
|
||||
fn parsing_works() {
|
||||
let file = match File::open("E:/Games/osu!/beatmaps/2223745.osu") {
|
||||
Ok(file) => file,
|
||||
Err(why) => panic!("Could not read file: {}", why),
|
||||
};
|
||||
|
||||
let map = match Beatmap::parse(file) {
|
||||
Ok(map) => map,
|
||||
Err(why) => panic!("Error while parsing map: {}", why),
|
||||
};
|
||||
|
||||
println!("Mode: {}", map.mode as u8);
|
||||
println!("n_circles: {}", map.n_circles);
|
||||
println!("n_sliders: {}", map.n_sliders);
|
||||
println!("n_spinners: {}", map.n_spinners);
|
||||
println!("ar: {}", map.ar);
|
||||
println!("od: {}", map.od);
|
||||
println!("cs: {}", map.cs);
|
||||
println!("hp: {}", map.hp);
|
||||
println!("sv: {}", map.sv);
|
||||
println!("tick_rate: {}", map.tick_rate);
|
||||
println!("stack_leniency: {}", map.stack_leniency);
|
||||
println!("hit_objects: {}", map.hit_objects.len());
|
||||
println!("timing_points: {}", map.timing_points.len());
|
||||
println!("difficulty_points: {}", map.difficulty_points.len());
|
||||
|
||||
assert_eq!(2 + 2, 4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
pub trait Mods: Copy {
|
||||
const NF: u32 = 1 << 0;
|
||||
const EZ: u32 = 1 << 1;
|
||||
const TD: u32 = 1 << 2;
|
||||
const HD: u32 = 1 << 3;
|
||||
const HR: u32 = 1 << 4;
|
||||
const DT: u32 = 1 << 6;
|
||||
const HT: u32 = 1 << 8;
|
||||
const NC: u32 = Self::DT | (1 << 9);
|
||||
const FL: u32 = 1 << 10;
|
||||
const SO: u32 = 1 << 12;
|
||||
|
||||
fn change_speed(self) -> bool;
|
||||
fn change_map(self) -> bool;
|
||||
fn speed(self) -> f32;
|
||||
fn od_ar_hp_multiplier(self) -> f32;
|
||||
fn nf(self) -> bool;
|
||||
fn ez(self) -> bool;
|
||||
fn td(self) -> bool;
|
||||
fn hd(self) -> bool;
|
||||
fn hr(self) -> bool;
|
||||
fn dt(self) -> bool;
|
||||
fn ht(self) -> bool;
|
||||
fn nc(self) -> bool;
|
||||
fn fl(self) -> bool;
|
||||
fn so(self) -> bool;
|
||||
}
|
||||
|
||||
impl Mods for u32 {
|
||||
#[inline]
|
||||
fn change_speed(self) -> bool {
|
||||
self & (Self::HT | Self::DT) > 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn change_map(self) -> bool {
|
||||
self & (Self::HT | Self::DT | Self::HR | Self::EZ) > 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn speed(self) -> f32 {
|
||||
if self & Self::DT > 0 {
|
||||
1.5
|
||||
} else if self & Self::HT > 0 {
|
||||
0.75
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn od_ar_hp_multiplier(self) -> f32 {
|
||||
if self & Self::HR > 0 {
|
||||
1.4
|
||||
} else if self & Self::EZ > 0 {
|
||||
0.5
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn nf(self) -> bool {
|
||||
self & Self::NF > 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn ez(self) -> bool {
|
||||
self & Self::EZ > 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn td(self) -> bool {
|
||||
self & Self::TD > 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn hd(self) -> bool {
|
||||
self & Self::HD > 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn hr(self) -> bool {
|
||||
self & Self::HR > 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn dt(self) -> bool {
|
||||
self & Self::DT > 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn ht(self) -> bool {
|
||||
self & Self::HT > 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn nc(self) -> bool {
|
||||
self & Self::NC > 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn fl(self) -> bool {
|
||||
self & Self::FL > 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn so(self) -> bool {
|
||||
self & Self::SO > 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
use std::fmt;
|
||||
use std::ops;
|
||||
|
||||
#[derive(Clone, Copy, Default, PartialEq)]
|
||||
pub struct Pos2 {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
impl Pos2 {
|
||||
#[inline]
|
||||
pub fn length_squared(&self) -> f32 {
|
||||
self.dot(*self)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn length(&self) -> f32 {
|
||||
self.length_squared().sqrt()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn dot(&self, other: Self) -> f32 {
|
||||
self.x * other.x + self.y * other.y
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn distance(&self, other: &Self) -> f32 {
|
||||
(*self - *other).length()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn add_scaled(self, other: Pos2, factor: f32) -> Pos2 {
|
||||
self + other * factor
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn normalize(self) -> Pos2 {
|
||||
self / self.length()
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::Add<Pos2> for Pos2 {
|
||||
type Output = Self;
|
||||
|
||||
#[inline]
|
||||
fn add(self, rhs: Self) -> Self::Output {
|
||||
Self {
|
||||
x: self.x + rhs.x,
|
||||
y: self.y + rhs.y,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::Sub<Pos2> for Pos2 {
|
||||
type Output = Self;
|
||||
|
||||
#[inline]
|
||||
fn sub(self, rhs: Self) -> Self::Output {
|
||||
Self {
|
||||
x: self.x - rhs.x,
|
||||
y: self.y - rhs.y,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::Mul<f32> for Pos2 {
|
||||
type Output = Self;
|
||||
|
||||
#[inline]
|
||||
fn mul(self, rhs: f32) -> Self::Output {
|
||||
Self {
|
||||
x: self.x * rhs,
|
||||
y: self.y * rhs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::Div<f32> for Pos2 {
|
||||
type Output = Self;
|
||||
|
||||
#[inline]
|
||||
fn div(self, rhs: f32) -> Self::Output {
|
||||
Self {
|
||||
x: self.x / rhs,
|
||||
y: self.y / rhs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::AddAssign for Pos2 {
|
||||
fn add_assign(&mut self, other: Self) {
|
||||
*self = Self {
|
||||
x: self.x + other.x,
|
||||
y: self.y + other.y,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Pos2 {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Pos2 {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "({}, {})", self.x, self.y)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
use super::HitObject;
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
const QUICK_SORT_DEPTH_THRESHOLD: usize = 32;
|
||||
|
||||
/// Algorithm from https://github.com/ppy/osu/blob/master/osu.Game.Rulesets.Mania/MathUtils/LegacySortHelper.cs#L21
|
||||
pub(crate) fn sort(keys: &mut [HitObject]) {
|
||||
if keys.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
depth_limited_quick_sort(keys, 0, keys.len() - 1, QUICK_SORT_DEPTH_THRESHOLD);
|
||||
}
|
||||
|
||||
fn depth_limited_quick_sort(
|
||||
keys: &mut [HitObject],
|
||||
mut left: usize,
|
||||
mut right: usize,
|
||||
mut depth_limit: usize,
|
||||
) {
|
||||
loop {
|
||||
if depth_limit == 0 {
|
||||
heap_sort(keys, left, right);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut i = left;
|
||||
let mut j = right;
|
||||
|
||||
let mid = i + ((j - i) >> 1);
|
||||
|
||||
if keys[i] > keys[mid] {
|
||||
keys.swap(i, mid);
|
||||
}
|
||||
|
||||
if keys[i] > keys[j] {
|
||||
keys.swap(i, j);
|
||||
}
|
||||
|
||||
if keys[mid] > keys[j] {
|
||||
keys.swap(mid, j);
|
||||
}
|
||||
|
||||
loop {
|
||||
while keys[i] < keys[mid] {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
while keys[mid] < keys[j] {
|
||||
j -= 1;
|
||||
}
|
||||
|
||||
match i.cmp(&j) {
|
||||
Ordering::Less => keys.swap(i, j),
|
||||
Ordering::Equal => {}
|
||||
Ordering::Greater => break,
|
||||
}
|
||||
|
||||
i += 1;
|
||||
j = j.saturating_sub(1);
|
||||
|
||||
if i > j {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
depth_limit -= 1;
|
||||
|
||||
if j.saturating_sub(left) <= right - i {
|
||||
if left < j {
|
||||
depth_limited_quick_sort(keys, left, j, depth_limit);
|
||||
}
|
||||
|
||||
left = i;
|
||||
} else {
|
||||
if i < right {
|
||||
depth_limited_quick_sort(keys, i, right, depth_limit);
|
||||
}
|
||||
|
||||
right = j;
|
||||
}
|
||||
|
||||
if left >= right {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn heap_sort(keys: &mut [HitObject], lo: usize, hi: usize) {
|
||||
let n = hi - lo + 1;
|
||||
|
||||
for i in (1..=n / 2).rev() {
|
||||
down_heap(keys, i, n, lo);
|
||||
}
|
||||
|
||||
for i in (2..=n).rev() {
|
||||
keys.swap(lo, lo + i - 1);
|
||||
down_heap(keys, 1, i - 1, lo);
|
||||
}
|
||||
}
|
||||
|
||||
fn down_heap(keys: &mut [HitObject], mut i: usize, n: usize, lo: usize) {
|
||||
while i <= n / 2 {
|
||||
let mut child = 2 * i;
|
||||
|
||||
if child < n && keys[lo + child - 1] < keys[lo + child] {
|
||||
child += 1;
|
||||
}
|
||||
|
||||
if keys[lo + i - 1] >= keys[lo + child - 1] {
|
||||
break;
|
||||
}
|
||||
|
||||
keys.swap(lo + i - 1, lo + child - 1);
|
||||
i = child;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use super::{closest_rhythm, HitObjectRhythm};
|
||||
use crate::HitObject;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct DifficultyObject<'o> {
|
||||
pub(crate) idx: usize,
|
||||
pub(crate) base: &'o HitObject,
|
||||
pub(crate) prev: &'o HitObject,
|
||||
pub(crate) delta: f32,
|
||||
pub(crate) rhythm: &'static HitObjectRhythm,
|
||||
}
|
||||
|
||||
impl<'o> DifficultyObject<'o> {
|
||||
#[inline]
|
||||
pub(crate) fn new(
|
||||
idx: usize,
|
||||
base: &'o HitObject,
|
||||
prev: &'o HitObject,
|
||||
prev_prev: &HitObject,
|
||||
clock_rate: f32,
|
||||
) -> Self {
|
||||
let delta = (base.start_time - prev.start_time) / clock_rate;
|
||||
let rhythm = closest_rhythm(delta, prev, prev_prev, clock_rate);
|
||||
|
||||
Self {
|
||||
idx,
|
||||
base,
|
||||
prev,
|
||||
delta,
|
||||
rhythm,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use crate::HitObject;
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
/// lazy_static required for `f32` division's
|
||||
/// lack of const-ness as of now.
|
||||
static ref COMMON_RHYTHMS: Vec<HitObjectRhythm> = vec![
|
||||
HitObjectRhythm::new(1.0, 1.0, 0.0),
|
||||
HitObjectRhythm::new(2.0, 1.0, 0.3),
|
||||
HitObjectRhythm::new(1.0, 2.0, 0.5),
|
||||
HitObjectRhythm::new(3.0, 1.0, 0.3),
|
||||
HitObjectRhythm::new(1.0, 3.0, 0.35),
|
||||
HitObjectRhythm::new(3.0, 2.0, 0.6),
|
||||
HitObjectRhythm::new(2.0, 3.0, 0.4),
|
||||
HitObjectRhythm::new(5.0, 4.0, 0.5),
|
||||
HitObjectRhythm::new(4.0, 5.0, 0.7),
|
||||
];
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub(crate) struct HitObjectRhythm {
|
||||
ratio: f32,
|
||||
pub(crate) difficulty: f32,
|
||||
}
|
||||
|
||||
impl PartialEq for HitObjectRhythm {
|
||||
#[inline]
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
(self.ratio - other.ratio).abs() < f32::EPSILON
|
||||
&& (self.difficulty - other.difficulty).abs() < f32::EPSILON
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for HitObjectRhythm {}
|
||||
|
||||
impl HitObjectRhythm {
|
||||
#[inline]
|
||||
fn new(numerator: f32, denominator: f32, difficulty: f32) -> Self {
|
||||
Self {
|
||||
ratio: numerator / denominator,
|
||||
difficulty,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn closest_rhythm(
|
||||
delta_time: f32,
|
||||
last: &HitObject,
|
||||
last_last: &HitObject,
|
||||
clock_rate: f32,
|
||||
) -> &'static HitObjectRhythm {
|
||||
let prev_len = (last.start_time - last_last.start_time) / clock_rate;
|
||||
let ratio = delta_time / prev_len;
|
||||
|
||||
COMMON_RHYTHMS
|
||||
.iter()
|
||||
.min_by(|r1, r2| {
|
||||
(r1.ratio - ratio)
|
||||
.abs()
|
||||
.partial_cmp(&(r2.ratio - ratio).abs())
|
||||
.unwrap_or(Ordering::Equal)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use std::cmp::Ordering;
|
||||
use std::ops::Index;
|
||||
|
||||
pub(crate) struct LimitedQueue<T> {
|
||||
queue: Vec<T>,
|
||||
start: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
impl<T> LimitedQueue<T> {
|
||||
/// Panics if `capacity` is zero.
|
||||
#[inline]
|
||||
pub(crate) fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
end: capacity - 1,
|
||||
start: 0,
|
||||
queue: Vec::with_capacity(capacity),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn push(&mut self, elem: T) {
|
||||
let capacity = self.queue.capacity();
|
||||
self.end = (self.end + 1) % capacity;
|
||||
|
||||
if self.queue.len() == capacity {
|
||||
self.start = (self.start + 1) % capacity;
|
||||
self.queue[self.end as usize] = elem;
|
||||
} else {
|
||||
self.queue.push(elem);
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.queue.len()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn last(&self) -> Option<&T> {
|
||||
self.queue.get(self.end as usize)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.start = 0;
|
||||
self.end = self.queue.capacity() - 1;
|
||||
self.queue.clear();
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn full(&self) -> bool {
|
||||
self.queue.len() == self.queue.capacity()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: PartialOrd> LimitedQueue<T> {
|
||||
pub(crate) fn min(&self) -> Option<&T> {
|
||||
let mut iter = self.queue.iter();
|
||||
|
||||
let first = iter.next()?;
|
||||
let min = iter.fold(first, |min, next| match min.partial_cmp(next) {
|
||||
Some(Ordering::Less) => min,
|
||||
Some(Ordering::Equal) => min,
|
||||
Some(Ordering::Greater) => next,
|
||||
None => min,
|
||||
});
|
||||
|
||||
Some(min)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Index<usize> for LimitedQueue<T> {
|
||||
type Output = T;
|
||||
|
||||
#[inline]
|
||||
fn index(&self, idx: usize) -> &Self::Output {
|
||||
&self.queue[(self.start + idx) % self.queue.capacity()]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
mod difficulty_object;
|
||||
mod hitobject_rhythm;
|
||||
mod limited_queue;
|
||||
mod pp;
|
||||
mod rim;
|
||||
mod skill;
|
||||
mod skill_kind;
|
||||
mod stamina_cheese;
|
||||
|
||||
use difficulty_object::DifficultyObject;
|
||||
use hitobject_rhythm::{closest_rhythm, HitObjectRhythm};
|
||||
use limited_queue::LimitedQueue;
|
||||
pub use pp::*;
|
||||
use rim::Rim;
|
||||
use skill::Skill;
|
||||
use skill_kind::SkillKind;
|
||||
use stamina_cheese::StaminaCheeseDetector;
|
||||
|
||||
use crate::{Beatmap, Mods};
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::f32::consts::PI;
|
||||
|
||||
const SECTION_LEN: f32 = 400.0;
|
||||
|
||||
const COLOR_SKILL_MULTIPLIER: f32 = 0.01;
|
||||
const RHYTHM_SKILL_MULTIPLIER: f32 = 0.014;
|
||||
const STAMINA_SKILL_MULTIPLIER: f32 = 0.02;
|
||||
|
||||
/// Star calculation for osu!taiko maps
|
||||
pub fn stars(map: &Beatmap, mods: impl Mods) -> f32 {
|
||||
if map.hit_objects.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// True if the object at that index is stamina cheese
|
||||
let cheese = map.find_cheese();
|
||||
|
||||
let mut skills = vec![
|
||||
Skill::new(SkillKind::color()),
|
||||
Skill::new(SkillKind::rhythm()),
|
||||
Skill::new(SkillKind::stamina(true)),
|
||||
Skill::new(SkillKind::stamina(false)),
|
||||
];
|
||||
|
||||
let clock_rate = mods.speed();
|
||||
let section_len = SECTION_LEN * clock_rate;
|
||||
|
||||
// No strain for first object
|
||||
let mut current_section_end =
|
||||
(map.hit_objects[0].start_time / section_len).ceil() * section_len;
|
||||
|
||||
let hit_objects = map
|
||||
.hit_objects
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(2)
|
||||
.zip(map.hit_objects.iter().skip(1))
|
||||
.zip(map.hit_objects.iter())
|
||||
.map(|(((idx, base), prev), prev_prev)| {
|
||||
DifficultyObject::new(idx, base, prev, prev_prev, clock_rate)
|
||||
});
|
||||
|
||||
for h in hit_objects {
|
||||
while h.base.start_time > current_section_end {
|
||||
for skill in skills.iter_mut() {
|
||||
skill.save_current_peak();
|
||||
skill.start_new_section_from(current_section_end);
|
||||
}
|
||||
|
||||
current_section_end += section_len;
|
||||
}
|
||||
|
||||
for skill in skills.iter_mut() {
|
||||
skill.process(&h, &cheese);
|
||||
}
|
||||
}
|
||||
|
||||
for skill in skills.iter_mut() {
|
||||
skill.save_current_peak();
|
||||
}
|
||||
|
||||
let mut buf = vec![0.0; skills[0].strain_peaks.len()];
|
||||
|
||||
let color_rating = skills[0].difficulty_value(&mut buf) * COLOR_SKILL_MULTIPLIER;
|
||||
let rhythm_rating = skills[1].difficulty_value(&mut buf) * RHYTHM_SKILL_MULTIPLIER;
|
||||
|
||||
let mut stamina_rating = (skills[2].difficulty_value(&mut buf)
|
||||
+ skills[3].difficulty_value(&mut buf))
|
||||
* STAMINA_SKILL_MULTIPLIER;
|
||||
|
||||
let stamina_penalty = simple_color_penalty(stamina_rating, color_rating);
|
||||
stamina_rating *= stamina_penalty;
|
||||
|
||||
let combined_rating = locally_combined_difficulty(&skills, stamina_penalty);
|
||||
let separate_rating = norm(1.5, color_rating, rhythm_rating, stamina_rating);
|
||||
|
||||
rescale(1.4 * separate_rating + 0.5 * combined_rating)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn rescale(stars: f32) -> f32 {
|
||||
if stars < 0.0 {
|
||||
stars
|
||||
} else {
|
||||
10.43 * (stars / 8.0 + 1.0).ln()
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn simple_color_penalty(stamina: f32, color: f32) -> f32 {
|
||||
if color <= 0.0 {
|
||||
0.79 - 0.25
|
||||
} else {
|
||||
0.79 - (stamina / color - 12.0).atan() / PI / 2.0
|
||||
}
|
||||
}
|
||||
|
||||
fn locally_combined_difficulty(skills: &[Skill], stamina_penalty: f32) -> f32 {
|
||||
let mut peaks = Vec::with_capacity(skills[0].strain_peaks.len());
|
||||
|
||||
let iter = skills[0]
|
||||
.strain_peaks
|
||||
.iter()
|
||||
.zip(skills[1].strain_peaks.iter())
|
||||
.zip(skills[2].strain_peaks.iter())
|
||||
.zip(skills[3].strain_peaks.iter())
|
||||
.map(|(((&color, &rhythm), &stamina_right), &stamina_left)| {
|
||||
norm(
|
||||
2.0,
|
||||
color * COLOR_SKILL_MULTIPLIER,
|
||||
rhythm * RHYTHM_SKILL_MULTIPLIER,
|
||||
(stamina_right + stamina_left) * STAMINA_SKILL_MULTIPLIER * stamina_penalty,
|
||||
)
|
||||
});
|
||||
|
||||
peaks.extend(iter);
|
||||
peaks.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
|
||||
|
||||
let mut difficulty = 0.0;
|
||||
let mut weight = 1.0;
|
||||
|
||||
for strain in peaks {
|
||||
difficulty += strain * weight;
|
||||
weight *= 0.9;
|
||||
}
|
||||
|
||||
difficulty
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn norm(p: f32, a: f32, b: f32, c: f32) -> f32 {
|
||||
(a.powf(p) + b.powf(p) + c.powf(p)).powf(p.recip())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::File;
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn taiko_single() {
|
||||
let file = match File::open("E:/Games/osu!/beatmaps/1097541.osu") {
|
||||
Ok(file) => file,
|
||||
Err(why) => panic!("Could not open file: {}", why),
|
||||
};
|
||||
|
||||
let map = match Beatmap::parse(file) {
|
||||
Ok(map) => map,
|
||||
Err(why) => panic!("Error while parsing map: {}", why),
|
||||
};
|
||||
|
||||
let result = PpCalculator::new(&map).mods(256).calculate();
|
||||
|
||||
println!("Stars: {}", result.stars);
|
||||
println!("PP: {}", result.pp);
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
use crate::{Beatmap, Mods};
|
||||
|
||||
pub struct PpResult {
|
||||
pub pp: f32,
|
||||
pub stars: f32,
|
||||
}
|
||||
|
||||
pub trait PpProvider {
|
||||
fn pp(&self) -> PpCalculator;
|
||||
}
|
||||
|
||||
impl PpProvider for Beatmap {
|
||||
#[inline]
|
||||
fn pp(&self) -> PpCalculator {
|
||||
PpCalculator::new(self)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Allow partial plays
|
||||
pub struct PpCalculator<'m> {
|
||||
map: &'m Beatmap,
|
||||
stars: Option<f32>,
|
||||
mods: u32,
|
||||
max_combo: usize,
|
||||
combo: Option<usize>,
|
||||
acc: f32,
|
||||
n_misses: usize,
|
||||
}
|
||||
|
||||
impl<'m> PpCalculator<'m> {
|
||||
#[inline]
|
||||
pub fn new(map: &'m Beatmap) -> Self {
|
||||
let max_combo = map.hit_objects.iter().filter(|h| h.is_circle()).count();
|
||||
|
||||
Self {
|
||||
map,
|
||||
stars: None,
|
||||
mods: 0,
|
||||
max_combo,
|
||||
combo: None,
|
||||
acc: 100.0,
|
||||
n_misses: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn stars(mut self, stars: f32) -> Self {
|
||||
self.stars.replace(stars);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn mods(mut self, mods: u32) -> Self {
|
||||
self.mods = mods;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn combo(mut self, combo: usize) -> Self {
|
||||
self.combo.replace(combo);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn misses(mut self, n_misses: usize) -> Self {
|
||||
self.n_misses = n_misses;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the accuracy between 0.0 and 100.0;
|
||||
#[inline]
|
||||
pub fn accuracy(mut self, acc: f32) -> Self {
|
||||
self.acc = acc / 100.0;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn calculate(self) -> PpResult {
|
||||
let stars = self
|
||||
.stars
|
||||
.unwrap_or_else(|| super::stars(self.map, self.mods));
|
||||
|
||||
let mut multiplier = 1.1;
|
||||
|
||||
if self.mods.nf() {
|
||||
multiplier *= 0.9;
|
||||
}
|
||||
|
||||
if self.mods.hd() {
|
||||
multiplier *= 1.1;
|
||||
}
|
||||
|
||||
// TODO: Consider HR & co?
|
||||
let hit_window = difficulty_range_od(self.map.od) as i32 as f32 / self.mods.speed();
|
||||
|
||||
let strain_value = self.compute_strain_value(stars);
|
||||
let acc_value = self.compute_accuracy_value(hit_window);
|
||||
|
||||
let pp = (strain_value.powf(1.1) + acc_value.powf(1.1)).powf(1.0 / 1.1) * multiplier;
|
||||
|
||||
PpResult { stars, pp }
|
||||
}
|
||||
|
||||
fn compute_strain_value(&self, stars: f32) -> f32 {
|
||||
let exp_base = 5.0 * (stars / 0.0075).max(1.0) - 4.0;
|
||||
let mut strain = exp_base * exp_base / 100_000.0;
|
||||
|
||||
// Longer maps are worth more
|
||||
let len_bonus = 1.0 + 0.1 * (self.max_combo as f32 / 1500.0).min(1.0);
|
||||
strain *= len_bonus;
|
||||
|
||||
// Penalize misses exponentially
|
||||
strain *= 0.985_f32.powi(self.n_misses as i32);
|
||||
|
||||
// HD bonus
|
||||
if self.mods.hd() {
|
||||
strain *= 1.025;
|
||||
}
|
||||
|
||||
// FL bonus
|
||||
if self.mods.fl() {
|
||||
strain *= 1.05 * len_bonus;
|
||||
}
|
||||
|
||||
// Scale with accuracy
|
||||
strain * self.acc
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn compute_accuracy_value(&self, hit_window: f32) -> f32 {
|
||||
(150.0 / hit_window).powf(1.1)
|
||||
* self.acc.powi(15)
|
||||
* 22.0
|
||||
* (self.max_combo as f32 / 1500.0).powf(0.3).min(1.15)
|
||||
}
|
||||
}
|
||||
|
||||
const HITWINDOW_MIN: f32 = 50.0;
|
||||
const HITWINDOW_AVG: f32 = 35.0;
|
||||
const HITWINDOW_MAX: f32 = 20.0;
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn difficulty_range_od(ar: f32) -> f32 {
|
||||
crate::difficulty_range(ar, HITWINDOW_MAX, HITWINDOW_AVG, HITWINDOW_MIN)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
use crate::{HitObject, HitSound};
|
||||
|
||||
pub(crate) trait Rim {
|
||||
fn is_rim(&self) -> bool;
|
||||
}
|
||||
|
||||
impl Rim for HitObject {
|
||||
#[inline]
|
||||
fn is_rim(&self) -> bool {
|
||||
self.sound.clap() || self.sound.whistle()
|
||||
}
|
||||
}
|
||||
|
||||
impl Rim for u8 {
|
||||
#[inline]
|
||||
fn is_rim(&self) -> bool {
|
||||
self.clap() || self.whistle()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use super::{DifficultyObject, SkillKind};
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
const DECAY_WEIGHT: f32 = 0.9;
|
||||
|
||||
pub(crate) struct Skill {
|
||||
pub current_strain: f32,
|
||||
current_section_peak: f32,
|
||||
|
||||
kind: SkillKind,
|
||||
pub(crate) strain_peaks: Vec<f32>,
|
||||
|
||||
prev_time: Option<f32>,
|
||||
}
|
||||
|
||||
impl Skill {
|
||||
#[inline]
|
||||
pub(crate) fn new(kind: SkillKind) -> Self {
|
||||
Self {
|
||||
current_strain: 1.0,
|
||||
current_section_peak: 1.0,
|
||||
|
||||
kind,
|
||||
strain_peaks: Vec::with_capacity(128),
|
||||
|
||||
prev_time: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn save_current_peak(&mut self) {
|
||||
if self.prev_time.is_some() {
|
||||
self.strain_peaks.push(self.current_section_peak);
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn start_new_section_from(&mut self, time: f32) {
|
||||
if let Some(prev) = self.prev_time {
|
||||
self.current_section_peak = self.peak_strain(time - prev);
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn process(&mut self, current: &DifficultyObject, cheese: &[bool]) {
|
||||
self.current_strain *= self.strain_decay(current.delta);
|
||||
self.current_strain +=
|
||||
self.kind.strain_value_of(¤t, cheese) * self.skill_multiplier();
|
||||
self.current_section_peak = self.current_section_peak.max(self.current_strain);
|
||||
self.prev_time.replace(current.base.start_time);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn difficulty_value(&self, buf: &mut [f32]) -> f32 {
|
||||
let mut difficulty = 0.0;
|
||||
let mut weight = 1.0;
|
||||
|
||||
buf.copy_from_slice(&self.strain_peaks);
|
||||
buf.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
|
||||
|
||||
for &strain in buf.iter() {
|
||||
difficulty += strain * weight;
|
||||
weight *= DECAY_WEIGHT;
|
||||
}
|
||||
|
||||
difficulty
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn skill_multiplier(&self) -> f32 {
|
||||
// TODO: Use constants
|
||||
match self.kind {
|
||||
SkillKind::Color { .. } => 1.0,
|
||||
SkillKind::Rhythm { .. } => 10.0,
|
||||
SkillKind::Stamina { .. } => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_decay_base(&self) -> f32 {
|
||||
// TODO: Use constants
|
||||
match self.kind {
|
||||
SkillKind::Color { .. } => 0.4,
|
||||
SkillKind::Rhythm { .. } => 0.0,
|
||||
SkillKind::Stamina { .. } => 0.4,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn peak_strain(&self, delta_time: f32) -> f32 {
|
||||
self.current_strain * self.strain_decay(delta_time)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_decay(&self, ms: f32) -> f32 {
|
||||
self.strain_decay_base().powf(ms / 1000.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
use super::{DifficultyObject, HitObjectRhythm, LimitedQueue, Rim};
|
||||
|
||||
const RHYTHM_STRAIN_DECAY: f32 = 0.96;
|
||||
const MOST_RECENT_PATTERNS_TO_COMPARE: usize = 2;
|
||||
|
||||
const MONO_HISTORY_MAX_LEN: usize = 5;
|
||||
const RHYTHM_HISTORY_MAX_LEN: usize = 8;
|
||||
const STAMINA_HISTORY_MAX_LEN: usize = 2;
|
||||
|
||||
pub(crate) enum SkillKind {
|
||||
Color {
|
||||
mono_history: LimitedQueue<usize>,
|
||||
prev_is_rim: Option<bool>,
|
||||
current_mono_len: usize,
|
||||
},
|
||||
Rhythm {
|
||||
rhythm_history: LimitedQueue<(usize, HitObjectRhythm)>, // (idx, rhythm)
|
||||
notes_since_rhythm_change: usize,
|
||||
current_strain: f32,
|
||||
},
|
||||
Stamina {
|
||||
note_pair_duration_history: LimitedQueue<f32>,
|
||||
hand: u8,
|
||||
off_hand_object_duration: f32,
|
||||
},
|
||||
}
|
||||
|
||||
impl SkillKind {
|
||||
#[inline]
|
||||
pub(crate) fn color() -> Self {
|
||||
Self::Color {
|
||||
mono_history: LimitedQueue::new(MONO_HISTORY_MAX_LEN),
|
||||
prev_is_rim: None,
|
||||
current_mono_len: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn rhythm() -> Self {
|
||||
Self::Rhythm {
|
||||
rhythm_history: LimitedQueue::new(RHYTHM_HISTORY_MAX_LEN),
|
||||
notes_since_rhythm_change: 0,
|
||||
current_strain: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn stamina(right_hand: bool) -> Self {
|
||||
Self::Stamina {
|
||||
note_pair_duration_history: LimitedQueue::new(STAMINA_HISTORY_MAX_LEN),
|
||||
hand: right_hand as u8,
|
||||
off_hand_object_duration: f32::MAX,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn strain_value_of(&mut self, current: &DifficultyObject, cheese: &[bool]) -> f32 {
|
||||
match self {
|
||||
Self::Color {
|
||||
mono_history,
|
||||
prev_is_rim,
|
||||
current_mono_len,
|
||||
} => {
|
||||
let prev_is_circle = current.prev.is_circle();
|
||||
let base_is_circle = current.base.is_circle();
|
||||
let curr_is_rim = current.base.is_rim();
|
||||
|
||||
if !(current.delta < 1000.0 && prev_is_circle && base_is_circle) {
|
||||
mono_history.clear();
|
||||
*current_mono_len = base_is_circle as usize;
|
||||
|
||||
*prev_is_rim = if base_is_circle {
|
||||
Some(curr_is_rim)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut strain = 0.0;
|
||||
|
||||
if prev_is_rim
|
||||
.filter(|&is_rim| is_rim != curr_is_rim)
|
||||
.is_some()
|
||||
{
|
||||
strain = if mono_history.len() < 2
|
||||
|| (*mono_history.last().unwrap() + *current_mono_len) % 2 == 0
|
||||
{
|
||||
0.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
let mut reps_penalty = 1.0;
|
||||
|
||||
mono_history.push(*current_mono_len);
|
||||
|
||||
let iter = (0..mono_history
|
||||
.len()
|
||||
.saturating_sub(MOST_RECENT_PATTERNS_TO_COMPARE))
|
||||
.rev();
|
||||
|
||||
for start in iter {
|
||||
let different_pattern = (0..MOST_RECENT_PATTERNS_TO_COMPARE).any(|i| {
|
||||
mono_history[start + i]
|
||||
!= mono_history
|
||||
[mono_history.len() + i - MOST_RECENT_PATTERNS_TO_COMPARE]
|
||||
});
|
||||
|
||||
if different_pattern {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut notes_since = 0;
|
||||
|
||||
for i in start..mono_history.len() {
|
||||
notes_since += mono_history[i];
|
||||
}
|
||||
|
||||
reps_penalty *= repetition_penalty(notes_since);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
strain *= reps_penalty;
|
||||
|
||||
*current_mono_len = 1;
|
||||
} else {
|
||||
*current_mono_len += 1;
|
||||
}
|
||||
|
||||
*prev_is_rim = Some(curr_is_rim);
|
||||
|
||||
strain
|
||||
}
|
||||
Self::Rhythm {
|
||||
rhythm_history,
|
||||
notes_since_rhythm_change,
|
||||
current_strain,
|
||||
} => {
|
||||
let base_is_circle = current.base.is_circle();
|
||||
|
||||
if !base_is_circle {
|
||||
*current_strain = 0.0;
|
||||
*notes_since_rhythm_change = 0;
|
||||
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
*current_strain *= RHYTHM_STRAIN_DECAY;
|
||||
*notes_since_rhythm_change += 1;
|
||||
|
||||
if current.rhythm.difficulty.abs() < f32::EPSILON {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut strain = current.rhythm.difficulty;
|
||||
|
||||
rhythm_history.push((current.idx, *current.rhythm));
|
||||
|
||||
let mut reps_penalty = 1.0;
|
||||
|
||||
for most_recent_patterns_to_compare in 2..=RHYTHM_HISTORY_MAX_LEN / 2 {
|
||||
let iter = (0..rhythm_history
|
||||
.len()
|
||||
.saturating_sub(most_recent_patterns_to_compare))
|
||||
.rev();
|
||||
|
||||
for start in iter {
|
||||
let different_pattern = (0..most_recent_patterns_to_compare).any(|i| {
|
||||
rhythm_history[start + i].1
|
||||
!= rhythm_history
|
||||
[rhythm_history.len() + i - most_recent_patterns_to_compare]
|
||||
.1
|
||||
});
|
||||
|
||||
if different_pattern {
|
||||
continue;
|
||||
}
|
||||
|
||||
reps_penalty *= repetition_penalty(current.idx - rhythm_history[start].0);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let speed_penalty = if current.delta < 80.0 {
|
||||
1.0
|
||||
} else if current.delta < 210.0 {
|
||||
(1.4 - 0.005 * current.delta).max(0.0)
|
||||
} else {
|
||||
*current_strain = 0.0;
|
||||
*notes_since_rhythm_change = 0;
|
||||
|
||||
0.0
|
||||
};
|
||||
|
||||
strain *= reps_penalty;
|
||||
strain *= pattern_len_penalty(*notes_since_rhythm_change);
|
||||
strain *= speed_penalty;
|
||||
|
||||
*notes_since_rhythm_change = 0;
|
||||
*current_strain += strain;
|
||||
|
||||
*current_strain
|
||||
}
|
||||
Self::Stamina {
|
||||
hand,
|
||||
note_pair_duration_history,
|
||||
off_hand_object_duration,
|
||||
} => {
|
||||
let base_is_circle = current.base.is_circle();
|
||||
|
||||
if !base_is_circle {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
if current.idx % 2 == *hand as usize {
|
||||
if current.idx == 1 {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
let mut strain = 1.0;
|
||||
note_pair_duration_history.push(current.delta + *off_hand_object_duration);
|
||||
let shortest_recent_note = *note_pair_duration_history.min().unwrap();
|
||||
strain += speed_bonus(shortest_recent_note);
|
||||
|
||||
if cheese[current.idx] {
|
||||
let p = cheese_penalty(current.delta + *off_hand_object_duration);
|
||||
strain *= p;
|
||||
}
|
||||
|
||||
return strain;
|
||||
}
|
||||
|
||||
*off_hand_object_duration = current.delta;
|
||||
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn pattern_len_penalty(pattern_len: usize) -> f32 {
|
||||
let short_pattern_penalty = (0.15 * pattern_len as f32).min(1.0);
|
||||
let long_pattern_penalty = (2.5 - 0.15 * pattern_len as f32).max(0.0).min(1.0);
|
||||
|
||||
short_pattern_penalty.min(long_pattern_penalty)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn cheese_penalty(note_pair_duration: f32) -> f32 {
|
||||
if note_pair_duration > 125.0 {
|
||||
1.0
|
||||
} else if note_pair_duration < 100.0 {
|
||||
0.6
|
||||
} else {
|
||||
0.6 + (note_pair_duration - 100.0) * 0.016
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn speed_bonus(note_pair_duration: f32) -> f32 {
|
||||
if note_pair_duration > 200.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut bonus = 200.0 - note_pair_duration;
|
||||
bonus *= bonus;
|
||||
|
||||
bonus / 100_000.0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn repetition_penalty(notes_since: usize) -> f32 {
|
||||
(0.032 * notes_since as f32).min(1.0)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use super::{LimitedQueue, Rim};
|
||||
|
||||
use crate::{Beatmap, HitObject};
|
||||
|
||||
const ROLL_MIN_REPETITIONS: usize = 12;
|
||||
const TL_MIN_REPETITIONS: isize = 16;
|
||||
|
||||
pub(crate) trait StaminaCheeseDetector {
|
||||
fn find_cheese(&self) -> Vec<bool>;
|
||||
fn find_rolls(&self, pattern_len: usize, cheese: &mut [bool]);
|
||||
fn find_tl_tap(&self, parity: usize, is_rin: bool, cheese: &mut [bool]);
|
||||
}
|
||||
|
||||
impl StaminaCheeseDetector for Beatmap {
|
||||
// TODO: Optimize
|
||||
fn find_cheese(&self) -> Vec<bool> {
|
||||
let mut cheese = vec![false; self.hit_objects.len()];
|
||||
|
||||
self.find_rolls(3, &mut cheese);
|
||||
self.find_rolls(4, &mut cheese);
|
||||
|
||||
self.find_tl_tap(0, true, &mut cheese);
|
||||
self.find_tl_tap(1, true, &mut cheese);
|
||||
self.find_tl_tap(0, false, &mut cheese);
|
||||
self.find_tl_tap(1, false, &mut cheese);
|
||||
|
||||
cheese
|
||||
}
|
||||
|
||||
fn find_rolls(&self, pattern_len: usize, cheese: &mut [bool]) {
|
||||
let mut history = LimitedQueue::new(2 * pattern_len);
|
||||
|
||||
let mut index_before_last_repeat = -1;
|
||||
let mut last_mark_end = 0;
|
||||
|
||||
for i in 0..self.hit_objects.len() {
|
||||
history.push(&self.hit_objects[i]);
|
||||
|
||||
if !history.full() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let contains = contains_pattern_repeat(&history, pattern_len);
|
||||
|
||||
if !contains {
|
||||
index_before_last_repeat = (i + 1 - history.len()) as isize;
|
||||
continue;
|
||||
}
|
||||
|
||||
let repeated_len = (i as isize - index_before_last_repeat) as usize;
|
||||
|
||||
if repeated_len < ROLL_MIN_REPETITIONS {
|
||||
continue;
|
||||
}
|
||||
|
||||
mark_as_cheese(last_mark_end.max(i + 1 - repeated_len), i, cheese);
|
||||
|
||||
last_mark_end = i;
|
||||
}
|
||||
}
|
||||
|
||||
fn find_tl_tap(&self, parity: usize, is_rin: bool, cheese: &mut [bool]) {
|
||||
let mut tl_len = -2;
|
||||
let mut last_mark_end = 0;
|
||||
|
||||
for i in (parity..self.hit_objects.len()).step_by(2) {
|
||||
if self.hit_objects[i].is_rim() == is_rin {
|
||||
tl_len += 2;
|
||||
} else {
|
||||
tl_len = -2;
|
||||
}
|
||||
|
||||
if tl_len < TL_MIN_REPETITIONS {
|
||||
continue;
|
||||
}
|
||||
|
||||
mark_as_cheese(
|
||||
(i as isize + 1 - tl_len).max(last_mark_end as isize) as usize,
|
||||
i,
|
||||
cheese,
|
||||
);
|
||||
|
||||
last_mark_end = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn mark_as_cheese(start: usize, end: usize, cheese: &mut [bool]) {
|
||||
cheese
|
||||
.iter_mut()
|
||||
.take(end + 1)
|
||||
.skip(start)
|
||||
.for_each(|b| *b = true);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn contains_pattern_repeat(history: &LimitedQueue<&HitObject>, pattern_len: usize) -> bool {
|
||||
for j in 0..pattern_len {
|
||||
if history[j].is_rim() != history[j + pattern_len].is_rim() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
Reference in New Issue
Block a user