feat: begin port of current oppai rx/ap

This commit is contained in:
tsunyoku
2022-11-20 14:15:29 +00:00
parent e3e583ce40
commit 3f6af95873
12 changed files with 1919 additions and 0 deletions
+8
View File
@@ -64,6 +64,12 @@ pub struct Beatmap {
/// All break points of the beatmap.
pub breaks: Vec<Break>,
/// The creator of the beatmap
pub creator: String,
/// The beatmap ID of the map
pub beatmap_id: u32,
}
impl Beatmap {
@@ -164,6 +170,8 @@ impl Beatmap {
effect_points: self.effect_points.clone(),
stack_leniency: self.stack_leniency,
breaks: self.breaks.clone(),
creator: self.creator.clone(),
beatmap_id: self.beatmap_id,
}
}
}
+3
View File
@@ -188,6 +188,9 @@ pub mod mania;
/// Everything about osu!standard.
pub mod osu;
/// osu! 2019 (for relax)
pub mod osu_2019;
/// Everything about osu!taiko.
pub mod taiko;
+3
View File
@@ -20,6 +20,7 @@ pub trait Mods: Copy {
const HT: u32 = 1 << 8;
const FL: u32 = 1 << 10;
const SO: u32 = 1 << 12;
const AP: u32 = 1 << 13;
/// If the clock rate is affected by the mods.
fn change_speed(self) -> bool;
@@ -39,6 +40,7 @@ pub trait Mods: Copy {
fn ht(self) -> bool;
fn fl(self) -> bool;
fn so(self) -> bool;
fn ap(self) -> bool;
}
impl Mods for u32 {
@@ -84,4 +86,5 @@ impl Mods for u32 {
impl_mods!(ht, HT);
impl_mods!(fl, FL);
impl_mods!(so, SO);
impl_mods!(ap, AP);
}
+539
View File
@@ -0,0 +1,539 @@
use std::{borrow::Cow, cmp::Ordering, convert::identity, f64::consts::PI, iter};
use crate::parse::{PathControlPoint, PathType, Pos2};
const BEZIER_TOLERANCE: f32 = 0.25;
const CATMULL_DETAIL: usize = 50;
const CIRCULAR_ARC_TOLERANCE: f32 = 0.1;
#[derive(Clone, Debug, Default)]
pub(crate) struct CurveBuffers {
path: Vec<Pos2>,
lengths: Vec<f64>,
vertices: Vec<Pos2>,
bezier: BezierBuffers,
}
#[derive(Clone, Debug, Default)]
struct BezierBuffers {
left: Vec<Pos2>,
right: Vec<Pos2>,
midpoints: Vec<Pos2>,
left_child: Vec<Pos2>,
}
impl BezierBuffers {
/// Fill the buffers with new elements until a
/// length of `len` is reached. Does nothing if `len`
/// is already smaller than the current buffer size.
fn extend_exact(&mut self, len: usize) {
if len <= self.left.len() {
return;
}
let additional = len - self.left.len();
self.left
.extend(iter::repeat(Pos2::zero()).take(additional));
self.right
.extend(iter::repeat(Pos2::zero()).take(additional));
self.midpoints
.extend(iter::repeat(Pos2::zero()).take(additional));
self.left_child
.extend(iter::repeat(Pos2::zero()).take(additional));
}
}
struct CircularArcProperties {
theta_start: f64,
theta_range: f64,
direction: f64,
radius: f32,
centre: Pos2,
}
pub(crate) struct Curve<'bufs> {
path: &'bufs [Pos2],
lengths: &'bufs [f64],
}
impl<'bufs> Curve<'bufs> {
pub(crate) fn new(
points: &[PathControlPoint],
expected_len: Option<f64>,
bufs: &'bufs mut CurveBuffers,
) -> Self {
Self::calculate_path(points, bufs);
Self::calculate_length(points, bufs, expected_len);
Self {
path: &bufs.path,
lengths: &bufs.lengths,
}
}
pub(crate) fn position_at(&self, progress: f64) -> Pos2 {
let d = self.progress_to_dist(progress);
let i = self.idx_of_dist(d);
self.interpolate_vertices(i, d)
}
fn progress_to_dist(&self, progress: f64) -> f64 {
progress.clamp(0.0, 1.0) * self.dist()
}
pub(crate) fn dist(&self) -> f64 {
self.lengths.last().copied().unwrap_or(0.0)
}
fn idx_of_dist(&self, d: f64) -> usize {
self.lengths
.binary_search_by(|len| len.partial_cmp(&d).unwrap_or(Ordering::Equal))
.map_or_else(identity, identity)
}
fn interpolate_vertices(&self, i: usize, d: f64) -> Pos2 {
if self.path.is_empty() {
return Pos2::zero();
}
let p1 = if i == 0 {
return self.path[0];
} else if let Some(p) = self.path.get(i) {
*p
} else {
return self.path[self.path.len() - 1];
};
let p0 = self.path[i - 1];
let d0 = self.lengths[i - 1];
let d1 = self.lengths[i];
// * Avoid division by an almost-zero number in case
// * two points are extremely close to each other
if (d0 - d1).abs() <= f64::EPSILON {
return p0;
}
let w = (d - d0) / (d1 - d0);
p0 + (p1 - p0) * w as f32
}
fn calculate_path(points: &[PathControlPoint], bufs: &mut CurveBuffers) {
bufs.path.clear();
if points.is_empty() {
return;
}
let CurveBuffers {
vertices,
bezier,
path,
..
} = bufs;
vertices.clear();
vertices.extend(points.iter().map(|p| p.pos));
let mut start = 0;
for i in 0..points.len() {
if points[i].kind.is_none() && i < points.len() - 1 {
continue;
}
// * The current vertex ends the segment
let segment_vertices = &vertices[start..i + 1];
let segment_kind = points[start].kind.unwrap_or(PathType::Linear);
Self::calculate_subpath(path, segment_vertices, segment_kind, bezier);
// * Start the new segment at the current vertex
start = i;
}
path.dedup();
}
fn calculate_length(
points: &[PathControlPoint],
bufs: &mut CurveBuffers,
expected_len: Option<f64>,
) {
let CurveBuffers {
path,
lengths: cumulative_len,
..
} = bufs;
cumulative_len.clear();
let mut calculated_len = 0.0;
cumulative_len.reserve(path.len());
cumulative_len.push(0.0);
let length_iter = path.iter().zip(path.iter().skip(1)).map(|(&curr, &next)| {
calculated_len += (next - curr).length() as f64;
calculated_len
});
cumulative_len.extend(length_iter);
if let Some(expected_len) = expected_len.filter(|&len| calculated_len != len) {
// * In osu-stable, if the last two control points of a slider are equal, extension is not performed
let condition_opt = points
.len()
.checked_sub(2)
.and_then(|i| points.get(i..))
.filter(|suffix| suffix[0].pos == suffix[1].pos && expected_len > calculated_len);
if condition_opt.is_some() {
cumulative_len.push(calculated_len);
return;
}
// Shortcut when it's just (0,0) since there's nothing to do anyway
if cumulative_len.len() == 1 {
return;
}
// * The last length is always incorrect
cumulative_len.pop();
let last_valid = cumulative_len
.iter()
.rev()
.position(|l| *l < expected_len)
.map_or(0, |idx| cumulative_len.len() - idx);
// * The path will be shortened further, in which case we should trim
// * any more unnecessary lengths and their associated path segments
if last_valid < cumulative_len.len() {
cumulative_len.truncate(last_valid);
path.truncate(last_valid + 1);
if cumulative_len.is_empty() {
// * The expected distance is negative or zero
// * Perhaps negative path lengths should be disallowed altogether
cumulative_len.push(0.0);
return;
}
}
let end_idx = cumulative_len.len();
let prev_idx = end_idx - 1;
// * The direction of the segment to shorten or lengthen
let dir = (path[end_idx] - path[prev_idx]).normalize();
path[end_idx] = path[prev_idx] + dir * (expected_len - cumulative_len[prev_idx]) as f32;
cumulative_len.push(expected_len);
}
}
fn calculate_subpath(
path: &mut Vec<Pos2>,
sub_points: &[Pos2],
kind: PathType,
bufs: &mut BezierBuffers,
) {
match kind {
PathType::Bezier => Self::approximate_bezier(path, sub_points, bufs),
PathType::Catmull => Self::approximate_catmull(path, sub_points),
PathType::Linear => Self::approximate_linear(path, sub_points),
PathType::PerfectCurve => {
if let [a, b, c] = sub_points {
if Self::approximate_circular_arc(path, *a, *b, *c) {
return;
}
}
Self::approximate_bezier(path, sub_points, bufs)
}
}
}
fn approximate_bezier(path: &mut Vec<Pos2>, points: &[Pos2], bufs: &mut BezierBuffers) {
bufs.extend_exact(points.len());
Self::approximate_bspline(path, points, bufs);
}
fn approximate_catmull(path: &mut Vec<Pos2>, points: &[Pos2]) {
if points.len() == 1 {
return;
}
path.reserve_exact((points.len() - 1) * CATMULL_DETAIL * 2);
// Handle first iteration distinctly because of v1
let v1 = points[0];
let v2 = points[0];
let v3 = points.get(1).copied().unwrap_or(v2);
let v4 = points.get(2).copied().unwrap_or_else(|| v3 * 2.0 - v2);
Self::catmull_subpath(path, v1, v2, v3, v4);
// Remaining iterations
for (i, (&v1, &v2)) in (2..points.len()).zip(points.iter().zip(points.iter().skip(1))) {
let v3 = points.get(i).copied().unwrap_or_else(|| v2 * 2.0 - v1);
let v4 = points.get(i + 1).copied().unwrap_or_else(|| v3 * 2.0 - v2);
Self::catmull_subpath(path, v1, v2, v3, v4);
}
}
fn approximate_linear(path: &mut Vec<Pos2>, points: &[Pos2]) {
path.extend(points)
}
fn approximate_circular_arc(path: &mut Vec<Pos2>, a: Pos2, b: Pos2, c: Pos2) -> bool {
let pr = match Self::circular_arc_properties(a, b, c) {
Some(pr) => pr,
None => return false,
};
// * We select the amount of points for the approximation by requiring the discrete curvature
// * to be smaller than the provided tolerance. The exact angle required to meet the tolerance
// * is: 2 * Math.Acos(1 - TOLERANCE / r)
// * The special case is required for extremely short sliders where the radius is smaller than
// * the tolerance. This is a pathological rather than a realistic case.
let amount_points = if 2.0 * pr.radius <= CIRCULAR_ARC_TOLERANCE {
2
} else {
let divisor = 2.0 * (1.0 - CIRCULAR_ARC_TOLERANCE / pr.radius).acos();
((pr.theta_range / divisor as f64).ceil() as usize).max(2)
};
path.reserve_exact(amount_points);
let divisor = (amount_points - 1) as f64;
let directed_range = pr.direction * pr.theta_range;
let subpath = (0..amount_points).map(|i| {
let fract = i as f64 / divisor;
let theta = pr.theta_start + fract * directed_range;
let (sin, cos) = theta.sin_cos();
let origin = Pos2 {
x: cos as f32,
y: sin as f32,
};
pr.centre + origin * pr.radius
});
path.extend(subpath);
true
}
fn approximate_bspline(path: &mut Vec<Pos2>, points: &[Pos2], bufs: &mut BezierBuffers) {
let p = points.len();
let mut to_flatten = Vec::new();
let mut free_bufs = Vec::new();
// In osu!lazer's code, `p` is always 0 so the first big `if` can be omitted
to_flatten.push(Cow::Borrowed(points));
// * "toFlatten" contains all the curves which are not yet approximated well enough.
// * We use a stack to emulate recursion without the risk of running into a stack overflow.
// * (More specifically, we iteratively and adaptively refine our curve with a
// * <a href="https://en.wikipedia.org/wiki/Depth-first_search">Depth-first search</a>
// * over the tree resulting from the subdivisions we make.)
let BezierBuffers {
left,
right,
midpoints,
left_child,
} = bufs;
while let Some(mut parent) = to_flatten.pop() {
if Self::bezier_is_flat_enough(&parent) {
// * If the control points we currently operate on are sufficiently "flat", we use
// * an extension to De Casteljau's algorithm to obtain a piecewise-linear approximation
// * of the bezier curve represented by our control points, consisting of the same amount
// * of points as there are control points.
Self::bezier_approximate(&parent, path, left, right, midpoints);
free_bufs.push(parent);
continue;
}
// * If we do not yet have a sufficiently "flat" (in other words, detailed) approximation we keep
// * subdividing the curve we are currently operating on.
let mut right_child = free_bufs
.pop()
.unwrap_or_else(|| Cow::Owned(vec![Pos2::zero(); p]));
Self::bezier_subdivide(&parent, left_child, right_child.to_mut(), midpoints);
// * We re-use the buffer of the parent for one of the children, so that we save one allocation per iteration.
parent.to_mut().copy_from_slice(&left_child[..p]);
to_flatten.push(right_child);
to_flatten.push(parent);
}
path.push(points[p - 1]);
}
fn bezier_is_flat_enough(points: &[Pos2]) -> bool {
let limit = BEZIER_TOLERANCE * BEZIER_TOLERANCE * 4.0;
!points
.iter()
.zip(points.iter().skip(1))
.zip(points.iter().skip(2))
.any(|((&prev, &curr), &next)| (prev - curr * 2.0 + next).length_squared() > limit)
}
fn bezier_subdivide(points: &[Pos2], l: &mut [Pos2], r: &mut [Pos2], midpoints: &mut [Pos2]) {
let count = points.len();
midpoints[..count].copy_from_slice(&points[..count]);
for i in (1..count).rev() {
l[count - i - 1] = midpoints[0];
r[i] = midpoints[i];
for j in 0..i {
midpoints[j] = (midpoints[j] + midpoints[j + 1]) / 2.0;
}
}
l[count - 1] = midpoints[0];
r[0] = midpoints[0];
}
// * https://en.wikipedia.org/wiki/De_Casteljau%27s_algorithm
fn bezier_approximate(
points: &[Pos2],
path: &mut Vec<Pos2>,
l: &mut [Pos2],
r: &mut [Pos2],
midpoints: &mut [Pos2],
) {
let count = points.len();
Self::bezier_subdivide(points, l, r, midpoints);
path.push(points[0]);
let l = &l[..count];
let r = &r[1..count];
let subpath = l
.iter()
.chain(r)
.skip(1)
.zip(l.iter().chain(r).skip(2))
.zip(l.iter().chain(r).skip(3))
.step_by(2)
.map(|((&prev, &curr), &next)| (prev + curr * 2.0 + next) * 0.25);
path.extend(subpath);
}
fn catmull_subpath(path: &mut Vec<Pos2>, v1: Pos2, v2: Pos2, v3: Pos2, v4: Pos2) {
let x1 = 2.0 * v2.x;
let x2 = -v1.x + v3.x;
let x3 = 2.0 * v1.x - 5.0 * v2.x + 4.0 * v3.x - v4.x;
let x4 = -v1.x + 3.0 * (v2.x - v3.x) + v4.x;
let y1 = 2.0 * v2.y;
let y2 = -v1.y + v3.y;
let y3 = 2.0 * v1.y - 5.0 * v2.y + 4.0 * v3.y - v4.y;
let y4 = -v1.y + 3.0 * (v2.y - v3.y) + v4.y;
let catmull_detail = CATMULL_DETAIL as f32;
let subpath = (0..CATMULL_DETAIL).flat_map(|c| {
let c = c as f32;
let t1 = c / catmull_detail;
let t2 = t1 * t1;
let t3 = t2 * t1;
let pos1 = Pos2 {
x: 0.5 * (x1 + x2 * t1 + x3 * t2 + x4 * t3),
y: 0.5 * (y1 + y2 * t1 + y3 * t2 + y4 * t3),
};
let t1 = (c + 1.0) / catmull_detail;
let t2 = t1 * t1;
let t3 = t2 * t1;
let pos2 = Pos2 {
x: 0.5 * (x1 + x2 * t1 + x3 * t2 + x4 * t3),
y: 0.5 * (y1 + y2 * t1 + y3 * t2 + y4 * t3),
};
iter::once(pos1).chain(iter::once(pos2))
});
path.extend(subpath);
}
fn circular_arc_properties(a: Pos2, b: Pos2, c: Pos2) -> Option<CircularArcProperties> {
// * If we have a degenerate triangle where a side-length is almost zero,
// * then give up and fallback to a more numerically stable method.
if ((b.y - a.y) * (c.x - a.x) - (b.x - a.x) * (c.y - a.y)).abs() <= f32::EPSILON {
return None;
}
// * See: https://en.wikipedia.org/wiki/Circumscribed_circle#Cartesian_coordinates_2
let d = 2.0 * (a.x * (b - c).y + b.x * (c - a).y + c.x * (a - b).y);
let a_sq = a.length_squared();
let b_sq = b.length_squared();
let c_sq = c.length_squared();
let centre = Pos2 {
x: (a_sq * (b - c).y + b_sq * (c - a).y + c_sq * (a - b).y) / d,
y: (a_sq * (c - b).x + b_sq * (a - c).x + c_sq * (b - a).x) / d,
};
let d_a = a - centre;
let d_c = c - centre;
let radius = d_a.length();
let theta_start = (d_a.y as f64).atan2(d_a.x as f64);
let mut theta_end = (d_c.y as f64).atan2(d_c.x as f64);
while theta_end < theta_start {
theta_end += 2.0 * PI;
}
let mut direction = 1.0;
let mut theta_range = theta_end - theta_start;
// * Decide in which direction to draw the circle,
// * depending on which side of AC B lies.
let mut ortho_a_to_c = c - a;
ortho_a_to_c = Pos2 {
x: ortho_a_to_c.y,
y: -ortho_a_to_c.x,
};
if ortho_a_to_c.dot(b - a) < 0.0 {
direction = -direction;
theta_range = 2.0 * PI - theta_range;
}
Some(CircularArcProperties {
theta_start,
theta_range,
direction,
radius,
centre,
})
}
}
+61
View File
@@ -0,0 +1,61 @@
use super::OsuObject;
pub(crate) struct DifficultyObject<'h> {
pub(crate) base: &'h OsuObject,
pub(crate) prev: Option<(f32, f32)>, // (jump_dist, strain_time)
pub(crate) jump_dist: f32,
pub(crate) travel_dist: f32,
pub(crate) angle: Option<f32>,
pub(crate) delta: f32,
pub(crate) strain_time: f32,
}
impl<'h> DifficultyObject<'h> {
pub(crate) fn new(
base: &'h OsuObject,
prev: &OsuObject,
prev_vals: Option<(f32, f32)>, // (jump_dist, strain_time)
prev_prev: Option<OsuObject>,
clock_rate: f32,
scaling_factor: f32,
) -> Self {
let delta = (base.time - prev.time) / clock_rate;
let strain_time = delta.max(50.0);
let pos = base.pos;
let travel_dist = prev.travel_dist.unwrap_or(0.0);
let prev_cursor_pos = prev.end_pos;
let jump_dist = if base.is_spinner() {
0.0
} else {
((pos - prev_cursor_pos) * scaling_factor).length()
};
let angle = prev_prev.map(|prev_prev| {
let prev_prev_cursor_pos = prev_prev.end_pos;
let v1 = prev_prev_cursor_pos - prev.pos;
let v2 = pos - prev_cursor_pos;
let dot = v1.dot(v2);
let det = v1.x * v2.y - v1.y * v2.x;
det.atan2(dot).abs()
});
Self {
base,
prev: prev_vals,
jump_dist,
travel_dist,
angle,
delta,
strain_time,
}
}
}
+19
View File
@@ -0,0 +1,19 @@
mod curve;
use curve::Curve;
mod difficulty_object;
use difficulty_object::DifficultyObject;
mod osu_object;
use osu_object::OsuObject;
mod pp;
pub use pp::{OsuAttributeProvider, OsuPP};
mod skill;
use skill::Skill;
mod skill_kind;
use skill_kind::SkillKind;
mod stars;
+175
View File
@@ -0,0 +1,175 @@
use super::{curve::CurveBuffers, stars::OsuDifficultyAttributes, Curve};
use crate::{
parse::{HitObject, HitObjectKind, Pos2},
Beatmap,
};
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,
scaling_factor: f32,
ticks: &mut Vec<f32>,
attributes: &mut OsuDifficultyAttributes,
curve_bufs: &mut CurveBuffers,
) -> Option<Self> {
attributes.max_combo += 1; // hitcircle, slider head, or spinner
let obj = match &h.kind {
HitObjectKind::Circle => {
attributes.n_circles += 1;
Self {
time: h.start_time as f32,
pos: h.pos,
end_pos: h.pos,
travel_dist: Some(0.0),
}
}
HitObjectKind::Slider {
pixel_len,
repeats,
control_points,
..
} => {
let timing_point = map.timing_point_at(h.start_time);
let difficulty_point = map.difficulty_point_at(h.start_time).unwrap_or_default();
// Key values which are computed here
let mut end_pos = h.pos;
let mut travel_dist = 0.0;
let approx_follow_circle_radius = radius * 3.0;
let mut tick_distance = 100.0 * map.slider_mult as f32 / map.tick_rate as f32;
if map.version >= 8 {
tick_distance /= (100.0 / difficulty_point.slider_vel as f32)
.max(10.0)
.min(1000.0)
/ 100.0;
}
// Build the curve w.r.t. the curve points
let curve = Curve::new(control_points, *pixel_len, curve_bufs);
let pixel_len = pixel_len.unwrap_or(0.0) as f32;
let duration = *repeats as f32 * timing_point.beat_len as f32 * pixel_len
/ (map.slider_mult as f32 * difficulty_point.slider_vel as f32)
/ 100.0;
let span_duration = duration / *repeats as f32;
// 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 as f32) / span_duration;
if progress % 2.0 >= 1.0 {
progress = 1.0 - progress % 1.0;
} else {
progress %= 1.0;
}
let curr_pos = h.pos + curve.position_at(progress as f64);
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 as f32 + 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 as f32 + 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 as f32 + final_span_idx as f32 * span_duration;
let final_span_end_time = (h.start_time as f32 + duration / 2.0)
.max(final_span_start_time + span_duration - LEGACY_LAST_TICK_OFFSET);
compute_vertex(final_span_end_time);
ticks.clear();
travel_dist *= scaling_factor;
Self {
time: h.start_time as f32,
pos: h.pos,
end_pos,
travel_dist: Some(travel_dist),
}
}
HitObjectKind::Spinner { .. } => {
attributes.n_spinners += 1;
Self {
time: h.start_time as f32,
pos: h.pos,
end_pos: h.pos,
travel_dist: None,
}
}
HitObjectKind::Hold { .. } => return None,
};
Some(obj)
}
#[inline]
pub(crate) fn is_spinner(&self) -> bool {
self.travel_dist.is_none()
}
}
+706
View File
@@ -0,0 +1,706 @@
use super::stars::{stars, OsuDifficultyAttributes, OsuPerformanceAttributes};
use crate::{Beatmap, Mods};
/// Calculator for pp on osu!standard maps.
///
/// # Example
///
/// ```
/// # use rosu_pp::{OsuPP, Beatmap};
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
/// let attrs = OsuPP::new(&map)
/// .mods(8 + 64) // HDDT
/// .combo(1234)
/// .misses(1)
/// .accuracy(98.5) // should be set last
/// .calculate();
///
/// println!("PP: {} | Stars: {}", attrs.pp(), attrs.stars());
///
/// let next_result = OsuPP::new(&map)
/// .attributes(attrs) // reusing previous results for performance
/// .mods(8 + 64) // has to be the same to reuse attributes
/// .accuracy(99.5)
/// .calculate();
///
/// println!("PP: {} | Stars: {}", next_result.pp(), next_result.stars());
/// ```
#[derive(Clone, Debug)]
pub struct OsuPP<'m> {
map: &'m Beatmap,
attributes: Option<OsuDifficultyAttributes>,
mods: u32,
combo: Option<usize>,
acc: Option<f32>,
n300: Option<usize>,
n100: Option<usize>,
n50: Option<usize>,
n_misses: usize,
passed_objects: Option<usize>,
}
impl<'m> OsuPP<'m> {
/// Creates a new calculator for the given map.
#[inline]
pub fn new(map: &'m Beatmap) -> Self {
Self {
map,
attributes: None,
mods: 0,
combo: None,
acc: None,
n300: None,
n100: None,
n50: None,
n_misses: 0,
passed_objects: None,
}
}
/// [`OsuAttributeProvider`] is implemented by [`DifficultyAttributes`](crate::osu::DifficultyAttributes)
/// and by [`PpResult`](crate::PpResult) meaning you can give the
/// result of a star calculation or a pp calculation.
/// If you already calculated the attributes for the current map-mod combination,
/// be sure to put them in here so that they don't have to be recalculated.
#[inline]
pub fn attributes(mut self, attributes: impl OsuAttributeProvider) -> Self {
if let Some(attributes) = attributes.attributes() {
self.attributes.replace(attributes);
}
self
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
#[inline]
pub fn mods(mut self, mods: u32) -> Self {
self.mods = mods;
self
}
/// Specify the max combo of the play.
#[inline]
pub fn combo(mut self, combo: usize) -> Self {
self.combo.replace(combo);
self
}
/// Specify the amount of 300s of a play.
#[inline]
pub fn n300(mut self, n300: usize) -> Self {
self.n300.replace(n300);
self
}
/// Specify the amount of 100s of a play.
#[inline]
pub fn n100(mut self, n100: usize) -> Self {
self.n100.replace(n100);
self
}
/// Specify the amount of 50s of a play.
#[inline]
pub fn n50(mut self, n50: usize) -> Self {
self.n50.replace(n50);
self
}
/// Specify the amount of misses of a play.
#[inline]
pub fn misses(mut self, n_misses: usize) -> Self {
self.n_misses = n_misses;
self
}
/// Amount of passed objects for partial plays, e.g. a fail.
#[inline]
pub fn passed_objects(mut self, passed_objects: usize) -> Self {
self.passed_objects.replace(passed_objects);
self
}
/// Generate the hit results with respect to the given accuracy between `0` and `100`.
///
/// Be sure to set `misses` beforehand!
/// In case of a partial play, be also sure to set `passed_objects` beforehand!
pub fn accuracy(mut self, acc: f32) -> Self {
let n_objects = self.passed_objects.unwrap_or(self.map.hit_objects.len());
let acc = acc / 100.0;
if self.n100.or(self.n50).is_some() {
let mut n100 = self.n100.unwrap_or(0);
let mut n50 = self.n50.unwrap_or(0);
let placed_points = 2 * n100 + n50 + self.n_misses;
let missing_objects = n_objects - n100 - n50 - self.n_misses;
let missing_points =
((6.0 * acc * n_objects as f32).round() as usize).saturating_sub(placed_points);
let mut n300 = missing_objects.min(missing_points / 6);
n50 += missing_objects - n300;
if let Some(orig_n50) = self.n50.filter(|_| self.n100.is_none()) {
// Only n50s were changed, try to load some off again onto n100s
let difference = n50 - orig_n50;
let n = n300.min(difference / 4);
n300 -= n;
n100 += 5 * n;
n50 -= 4 * n;
}
self.n300.replace(n300);
self.n100.replace(n100);
self.n50.replace(n50);
} else {
let misses = self.n_misses.min(n_objects);
let target_total = (acc * n_objects as f32 * 6.0).round() as usize;
let delta = target_total - (n_objects - misses);
let mut n300 = delta / 5;
let mut n100 = delta % 5;
let mut n50 = n_objects - n300 - n100 - misses;
// Sacrifice n300s to transform n50s into n100s
let n = n300.min(n50 / 4);
n300 -= n;
n100 += 5 * n;
n50 -= 4 * n;
self.n300.replace(n300);
self.n100.replace(n100);
self.n50.replace(n50);
}
let acc = (6 * self.n300.unwrap() + 2 * self.n100.unwrap() + self.n50.unwrap()) as f32
/ (6 * n_objects) as f32;
self.acc.replace(acc);
self
}
fn assert_hitresults(&mut self) {
if self.acc.is_none() {
let n_objects = self.passed_objects.unwrap_or(self.map.hit_objects.len());
let remaining = n_objects
.saturating_sub(self.n300.unwrap_or(0))
.saturating_sub(self.n100.unwrap_or(0))
.saturating_sub(self.n50.unwrap_or(0))
.saturating_sub(self.n_misses);
if remaining > 0 {
if self.n300.is_none() {
self.n300.replace(remaining);
self.n100.get_or_insert(0);
self.n50.get_or_insert(0);
} else if self.n100.is_none() {
self.n100.replace(remaining);
self.n50.get_or_insert(0);
} else if self.n50.is_none() {
self.n50.replace(remaining);
} else {
*self.n300.as_mut().unwrap() += remaining;
}
} else {
self.n300.get_or_insert(0);
self.n100.get_or_insert(0);
self.n50.get_or_insert(0);
}
let numerator = self.n50.unwrap() + self.n100.unwrap() * 2 + self.n300.unwrap() * 6;
self.acc.replace(numerator as f32 / n_objects as f32 / 6.0);
}
}
/// Returns an object which contains the pp and [`DifficultyAttributes`](crate::osu::DifficultyAttributes)
/// containing stars and other attributes.
pub fn calculate(mut self) -> OsuPerformanceAttributes {
if self.attributes.is_none() {
let attributes = stars(self.map, self.mods, self.passed_objects);
self.attributes.replace(attributes);
}
// Make sure the hitresults and accuracy are set
self.assert_hitresults();
let total_hits = self.total_hits() as f32;
let mut multiplier = 1.12;
// NF penalty
if self.mods.nf() {
multiplier *= 0.9_f32.max(1.0 - 0.2 * self.n_misses as f32);
}
// SO penalty
if self.mods.so() {
multiplier *=
1.0 - (self.attributes.as_ref().unwrap().n_spinners as f32 / total_hits).powf(0.85);
}
let mut aim_value = self.compute_aim_value(total_hits);
let speed_value = self.compute_speed_value(total_hits);
let acc_value = self.compute_accuracy_value(total_hits);
let mut acc_depression = 1.0;
if self.mods.rx() {
let streams_nerf = aim_value / speed_value;
if streams_nerf < 1.0 {
acc_depression = match self.acc.unwrap() < 1.0 {
true => 0.95 - (1.0 - self.acc.unwrap()),
false => 0.95,
};
if acc_depression > 0.0 {
aim_value *= acc_depression;
}
}
}
let nodt_bonus =
match !self.mods.dt() && !self.mods.ht() && self.mods.rx() && acc_depression == 1.0 {
true => 1.01,
false => 1.0,
};
let speed_factor = match self.mods.rx() {
true => speed_value.powf(0.83 * acc_depression),
false => match self.mods.ap() {
true => speed_value.powf(1.12),
false => speed_value.powf(1.1),
},
};
let aim_factor = match self.mods.rx() {
true => aim_value.powf(1.18 * nodt_bonus),
false => match self.mods.ap() {
true => 0.0,
false => aim_value.powf(1.1),
},
};
let acc_factor = match self.mods.rx() {
true => acc_value.powf(1.15 * nodt_bonus),
false => match self.mods.ap() {
true => acc_value.powf(1.12),
false => acc_value.powf(1.1),
},
};
let mut pp = (aim_factor + speed_factor + acc_factor).powf(1.0 / 1.1) * multiplier;
if self.mods.rx() {
if self.mods.dt() && self.mods.hr() {
pp *= 1.025;
}
if self.map.creator == "ParkourWizard" {
pp *= 0.9;
}
pp *= match self.map.beatmap_id {
// Louder than steel [ok this is epic]
1808605 => 0.85,
// over the top [Above the stars]
1821147 => 0.70,
// Just press F [Parkour's ok this is epic]
1844776 => 0.64,
// Hardawre Store [skyapple mode]
1777768 => 0.90,
// HONESTY [RIGHTEOUSNESS OF MORALITY]
2079597 => 0.90,
// Akatsuki compilation [ok this is akatsuki]
1962833 => {
pp *= 0.885;
if self.mods.dt() {
0.83
} else {
1.0
}
}
_ => 1.0,
}
}
OsuPerformanceAttributes {
difficulty: self.attributes.unwrap(),
pp_acc: acc_value as f64,
pp_aim: aim_value as f64,
pp_flashlight: 0.0,
pp_speed: speed_value as f64,
pp: pp as f64,
}
}
fn compute_aim_value(&self, total_hits: f32) -> f32 {
let attributes = self.attributes.as_ref().unwrap();
// TD penalty
let raw_aim = if self.mods.td() {
attributes.aim_strain.powf(0.8) as f32
} else {
attributes.aim_strain as f32
};
let mut aim_value = (5.0 * (raw_aim / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
// Longer maps are worth more
let bonus_factor = if self.mods.rx() { 0.88 } else { 0.95 };
let len_bonus = bonus_factor
+ 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;
// Penalize misses
let penalty_factor: f32 = if self.mods.rx() { 0.96 } else { 0.97 };
aim_value *= penalty_factor.powi(self.n_misses as i32);
// 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);
}
// AR bonus
let mut ar_factor = if self.mods.rx() {
if attributes.ar > 10.67 {
0.45 * (attributes.ar - 10.67)
} else {
0.0
}
} else if attributes.ar > 10.33 {
0.3 * (attributes.ar - 10.33)
} else {
0.0
};
if attributes.ar < 8.0 {
ar_factor = 0.025 * (8.0 - attributes.ar);
}
aim_value *= 1.0 + ar_factor as f32;
// HD bonus
if self.mods.hd() {
aim_value *= match self.mods.rx() {
true => 1.0 + 0.05 * (11.0 - attributes.ar) as f32,
false => 1.0 + 0.04 * (12.0 - attributes.ar) as f32,
}
}
// FL bonus
if self.mods.fl() {
let first_factor = match self.mods.rx() {
true => 0.3,
false => 0.35,
};
let second_factor = match self.mods.rx() {
true => 0.25,
false => 0.3,
};
let third_factor = match self.mods.rx() {
true => 1600.0,
false => 1200.0,
};
aim_value *= 1.0
+ first_factor * (total_hits / 200.0).min(1.0)
+ (total_hits > 200.0) as u8 as f32
* second_factor
* ((total_hits - 200.0) / 300.0).min(1.0)
+ (total_hits > 500.0) as u8 as f32 * (total_hits - 500.0) / third_factor;
}
// EZ bonus
if self.mods.ez() {
let mut base_buff = 1.08_f32;
if attributes.ar <= 8.0 {
base_buff += (7.0 - attributes.ar as f32) / 100.0;
}
aim_value *= base_buff;
}
// Scale with accuracy
if self.mods.rx() {
aim_value *= match attributes.od >= 10.6 {
true => 0.5 + self.acc.unwrap() / 2.0,
false => match self.acc.unwrap() >= 0.97 {
true => 0.4 + self.acc.unwrap() / 2.0,
false => 0.3 + self.acc.unwrap() / 2.0,
},
}
} else {
aim_value *= 0.5 + self.acc.unwrap() / 2.0;
}
aim_value *= 0.98 + attributes.od as f32 * attributes.od as f32 / 2500.0;
aim_value
}
fn compute_speed_value(&self, total_hits: f32) -> f32 {
let attributes = self.attributes.as_ref().unwrap();
let mut speed_value =
(5.0 * (attributes.speed_strain as f32 / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
// Longer maps are worth more
let bonus_factor = if self.mods.rx() { 0.88 } else { 0.95 };
let len_bonus = bonus_factor
+ 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;
// Penalize misses
let penalty_factor: f32 = if self.mods.rx() { 0.94 } else { 0.97 };
speed_value *= penalty_factor.powi(self.n_misses as i32);
// 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);
}
// AR bonus
if attributes.ar > 10.33 {
let mut ar_factor = if self.mods.rx() {
if attributes.ar > 10.67 {
0.45 * (attributes.ar - 10.67)
} else {
0.0
}
} else if attributes.ar > 10.33 {
0.3 * (attributes.ar - 10.33)
} else {
0.0
};
if attributes.ar < 8.0 {
ar_factor = 0.025 * (8.0 - attributes.ar);
}
speed_value *= 1.0 + ar_factor as f32;
}
// HD bonus
if self.mods.hd() {
speed_value *= match self.mods.rx() {
true => 1.0 + 0.05 * (11.0 - attributes.ar) as f32,
false => 1.0 + 0.04 * (12.0 - attributes.ar) as f32,
}
}
// Scaling the speed value with accuracy and OD
speed_value *= (0.95 + attributes.od as f32 * attributes.od as f32 / 750.0)
* self
.acc
.unwrap()
.powf((14.5 - attributes.od.max(8.0) as f32) / 2.0);
speed_value *= 0.98_f32.powf(match (self.n50.unwrap() as f32) < total_hits / 500.0 {
true => 0.0,
false => self.n50.unwrap() as f32 - total_hits / 500.0,
});
if self.mods.ap() {
speed_value *= 0.5 + self.acc.unwrap() / 2.0;
speed_value *= 0.98 + attributes.od as f32 * attributes.od as f32 / 2500.0;
// FL bonus
if self.mods.fl() {
speed_value *= 1.0
+ 0.25 * (total_hits / 200.0).min(1.0)
+ (total_hits > 200.0) as u8 as f32
* 0.2
* ((total_hits - 200.0) / 300.0).min(1.0)
+ (total_hits > 500.0) as u8 as f32 * (total_hits - 500.0) / 1100.0;
}
speed_value *= 0.98_f32.powf(match (self.n50.unwrap() as f32) < total_hits / 500.0 {
true => 0.0,
false => self.n50.unwrap() as f32 - total_hits / 500.0,
});
}
speed_value
}
fn compute_accuracy_value(&self, total_hits: f32) -> f32 {
let attributes = self.attributes.as_ref().unwrap();
let n_circles = attributes.n_circles as f32;
let n300 = self.n300.unwrap_or(0) as f32;
let n100 = self.n100.unwrap_or(0) as f32;
let n50 = self.n50.unwrap_or(0) as f32;
let better_acc_percentage = (n_circles > 0.0) as u8 as f32
* (((n300 - (total_hits - n_circles)) * 6.0 + n100 * 2.0 + n50) / (n_circles * 6.0))
.max(0.0);
let mut acc_value =
1.52163_f32.powf(attributes.od as f32) * better_acc_percentage.powi(24) * 2.83;
// 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 {
let n_objects = self.passed_objects.unwrap_or(self.map.hit_objects.len());
(self.n300.unwrap_or(0) + self.n100.unwrap_or(0) + self.n50.unwrap_or(0) + self.n_misses)
.min(n_objects)
}
}
/// Provides attributes for an osu! beatmap.
pub trait OsuAttributeProvider {
/// Returns the attributes of the map.
fn attributes(self) -> Option<OsuDifficultyAttributes>;
}
impl OsuAttributeProvider for OsuDifficultyAttributes {
#[inline]
fn attributes(self) -> Option<OsuDifficultyAttributes> {
Some(self)
}
}
impl OsuAttributeProvider for OsuPerformanceAttributes {
#[inline]
fn attributes(self) -> Option<OsuDifficultyAttributes> {
Some(self.difficulty)
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::Beatmap;
#[test]
fn osu_only_accuracy() {
let map = Beatmap::default();
let total_objects = 1234;
let target_acc = 97.5;
let calculator = OsuPP::new(&map)
.passed_objects(total_objects)
.accuracy(target_acc);
let numerator = 6 * calculator.n300.unwrap_or(0)
+ 2 * calculator.n100.unwrap_or(0)
+ calculator.n50.unwrap_or(0);
let denominator = 6 * total_objects;
let acc = 100.0 * numerator as f32 / denominator as f32;
assert!(
(target_acc - acc).abs() < 1.0,
"Expected: {} | Actual: {}",
target_acc,
acc
);
}
#[test]
fn osu_accuracy_and_n50() {
let map = Beatmap::default();
let total_objects = 1234;
let target_acc = 97.5;
let n50 = 30;
let calculator = OsuPP::new(&map)
.passed_objects(total_objects)
.n50(n50)
.accuracy(target_acc);
assert!(
(calculator.n50.unwrap() as i32 - n50 as i32).abs() <= 4,
"Expected: {} | Actual: {}",
n50,
calculator.n50.unwrap()
);
let numerator = 6 * calculator.n300.unwrap_or(0)
+ 2 * calculator.n100.unwrap_or(0)
+ calculator.n50.unwrap_or(0);
let denominator = 6 * total_objects;
let acc = 100.0 * numerator as f32 / denominator as f32;
assert!(
(target_acc - acc).abs() < 1.0,
"Expected: {} | Actual: {}",
target_acc,
acc
);
}
#[test]
fn osu_missing_objects() {
let map = Beatmap::default();
let total_objects = 1234;
let n300 = 1000;
let n100 = 200;
let n50 = 30;
let mut calculator = OsuPP::new(&map)
.passed_objects(total_objects)
.n300(n300)
.n100(n100)
.n50(n50);
calculator.assert_hitresults();
let n_objects = calculator.n300.unwrap()
+ calculator.n100.unwrap()
+ calculator.n50.unwrap()
+ calculator.n_misses;
assert_eq!(
total_objects, n_objects,
"Expected: {} | Actual: {}",
total_objects, n_objects
);
}
}
+95
View File
@@ -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,
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) {
self.strain_peaks.push(self.current_section_peak);
}
#[inline]
pub(crate) fn start_new_section_from(&mut self, time: f32) {
self.current_section_peak = self.peak_strain(time - self.prev_time.unwrap());
}
#[inline]
pub(crate) fn process(&mut self, current: &DifficultyObject<'_>) {
self.current_strain *= self.strain_decay(current.delta);
self.current_strain += self.kind.strain_value_of(current) * self.skill_multiplier();
self.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)
}
}
+100
View File
@@ -0,0 +1,100 @@
use super::DifficultyObject;
const SINGLE_SPACING_TRESHOLD: f32 = 125.0;
const SPEED_ANGLE_BONUS_BEGIN: f32 = 5.0 * std::f32::consts::FRAC_PI_6;
const PI_OVER_4: f32 = std::f32::consts::FRAC_PI_4;
const PI_OVER_2: f32 = std::f32::consts::FRAC_PI_2;
const MIN_SPEED_BONUS: f32 = 75.0;
const MAX_SPEED_BONUS: f32 = 45.0;
const SPEED_BALANCING_FACTOR: f32 = 40.0;
const AIM_ANGLE_BONUS_BEGIN: f32 = std::f32::consts::FRAC_PI_3;
const TIMING_THRESHOLD: f32 = 107.0;
#[derive(Copy, Clone)]
pub(crate) enum SkillKind {
Aim,
Speed,
}
impl SkillKind {
pub(crate) fn strain_value_of(self, current: &DifficultyObject<'_>) -> f32 {
match self {
Self::Aim => {
if current.base.is_spinner() {
return 0.0;
}
let mut result = 0.0;
if let Some((prev_jump_dist, prev_strain_time)) = current.prev {
if let Some(angle) = current.angle.filter(|a| *a > AIM_ANGLE_BONUS_BEGIN) {
let scale = 90.0;
let angle_bonus = (((angle - AIM_ANGLE_BONUS_BEGIN).sin()).powi(2)
* (prev_jump_dist - scale).max(0.0)
* (current.jump_dist - scale).max(0.0))
.sqrt();
result = 1.5 * apply_diminishing_exp(angle_bonus.max(0.0))
/ (TIMING_THRESHOLD).max(prev_strain_time)
}
}
let jump_dist_exp = apply_diminishing_exp(current.jump_dist);
let travel_dist_exp = apply_diminishing_exp(current.travel_dist);
let dist_exp =
jump_dist_exp + travel_dist_exp + (travel_dist_exp * jump_dist_exp).sqrt();
(result + dist_exp / (current.strain_time).max(TIMING_THRESHOLD))
.max(dist_exp / current.strain_time)
}
Self::Speed => {
if current.base.is_spinner() {
return 0.0;
}
let dist = SINGLE_SPACING_TRESHOLD.min(current.travel_dist + current.jump_dist);
let delta_time = MAX_SPEED_BONUS.max(current.delta);
let mut speed_bonus = 1.0;
if delta_time < MIN_SPEED_BONUS {
let exp_base = (MIN_SPEED_BONUS - delta_time) / SPEED_BALANCING_FACTOR;
speed_bonus += exp_base * exp_base;
}
let mut angle_bonus = 1.0;
if let Some(angle) = current.angle.filter(|a| *a < SPEED_ANGLE_BONUS_BEGIN) {
let exp_base = (1.5 * (SPEED_ANGLE_BONUS_BEGIN - angle)).sin();
angle_bonus = 1.0 + exp_base * exp_base / 3.57;
if angle < PI_OVER_2 {
angle_bonus = 1.28;
if dist < 90.0 && angle < PI_OVER_4 {
angle_bonus += (1.0 - angle_bonus) * ((90.0 - dist) / 10.0).min(1.0);
} else if dist < 90.0 {
angle_bonus += (1.0 - angle_bonus)
* ((90.0 - dist) / 10.0).min(1.0)
* ((PI_OVER_2 - angle) / PI_OVER_4).sin();
}
}
}
(1.0 + (speed_bonus - 1.0) * 0.75)
* angle_bonus
* (0.95 + speed_bonus * (dist / SINGLE_SPACING_TRESHOLD).powf(3.5))
/ current.strain_time
}
}
}
}
#[inline]
fn apply_diminishing_exp(val: f32) -> f32 {
val.powf(0.99)
}
+160
View File
@@ -0,0 +1,160 @@
//! The positional offset of notes created by stack leniency is not considered.
//! This means the jump distance inbetween notes might be slightly off, resulting in small inaccuracies.
//! Since calculating these offsets is relatively expensive though, this version is faster than `all_included`.
use super::{curve::CurveBuffers, DifficultyObject, OsuObject, Skill, SkillKind};
use crate::Beatmap;
const OBJECT_RADIUS: f32 = 64.0;
const SECTION_LEN: f32 = 400.0;
const DIFFICULTY_MULTIPLIER: f32 = 0.0675;
const NORMALIZED_RADIUS: f32 = 52.0;
/// Star calculation for osu!standard maps.
///
/// Slider paths are considered but stack leniency is ignored.
/// As most maps don't even make use of leniency and even if,
/// it has generally little effect on stars, the results are close to perfect.
/// This version is considerably more efficient than `all_included` since
/// processing stack leniency is relatively expensive.
///
/// In case of a partial play, e.g. a fail, one can specify the amount of passed objects.
pub fn stars(map: &Beatmap, mods: u32, passed_objects: Option<usize>) -> OsuDifficultyAttributes {
let take = passed_objects.unwrap_or(map.hit_objects.len());
let map_attributes = map.attributes().mods(mods).build();
let mut diff_attributes = OsuDifficultyAttributes {
ar: map_attributes.ar,
od: map_attributes.od,
..Default::default()
};
if take < 2 {
return diff_attributes;
}
let section_len = SECTION_LEN * map_attributes.clock_rate as f32;
let radius = OBJECT_RADIUS * (1.0 - 0.7 * (map_attributes.cs as f32 - 5.0) / 5.0) / 2.0;
let mut scaling_factor = NORMALIZED_RADIUS / radius;
if radius < 30.0 {
let small_circle_bonus = (30.0 - radius).min(5.0) / 50.0;
scaling_factor *= 1.0 + small_circle_bonus;
}
let mut ticks_buf = Vec::new();
let mut curve_bufs = CurveBuffers::default();
let mut hit_objects = map.hit_objects.iter().take(take).filter_map(|h| {
OsuObject::new(
h,
map,
radius,
scaling_factor,
&mut ticks_buf,
&mut diff_attributes,
&mut curve_bufs,
)
});
let mut aim = Skill::new(SkillKind::Aim);
let mut speed = Skill::new(SkillKind::Speed);
// First object has no predecessor and thus no strain, handle distinctly
let mut current_section_end =
(map.hit_objects[0].start_time as f32 / section_len).ceil() * section_len;
let mut prev_prev = None;
let mut prev = hit_objects.next().unwrap();
let mut prev_vals = None;
// Handle second object separately to remove later if-branching
let curr = hit_objects.next().unwrap();
let h = DifficultyObject::new(
&curr,
&prev,
prev_vals,
prev_prev,
map_attributes.clock_rate as f32,
scaling_factor,
);
while h.base.time as f32 > current_section_end {
current_section_end += section_len;
}
aim.process(&h);
speed.process(&h);
prev_prev = Some(prev);
prev_vals = Some((h.jump_dist, h.strain_time));
prev = curr;
// Handle all other objects
for curr in hit_objects {
let h = DifficultyObject::new(
&curr,
&prev,
prev_vals,
prev_prev,
map_attributes.clock_rate as f32,
scaling_factor,
);
while h.base.time as f32 > current_section_end {
aim.save_current_peak();
aim.start_new_section_from(current_section_end);
speed.save_current_peak();
speed.start_new_section_from(current_section_end);
current_section_end += section_len;
}
aim.process(&h);
speed.process(&h);
prev_prev = Some(prev);
prev_vals = Some((h.jump_dist, h.strain_time));
prev = curr;
}
aim.save_current_peak();
speed.save_current_peak();
let aim_strain = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let speed_strain = speed.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
let stars = aim_strain + speed_strain + (aim_strain - speed_strain).abs() / 2.0;
diff_attributes.stars = stars as f64;
diff_attributes.speed_strain = speed_strain as f64;
diff_attributes.aim_strain = aim_strain as f64;
diff_attributes
}
#[derive(Clone, Debug, Default)]
pub struct OsuDifficultyAttributes {
pub aim_strain: f64,
pub speed_strain: f64,
pub ar: f64,
pub od: f64,
pub hp: f64,
pub n_circles: usize,
pub n_sliders: usize,
pub n_spinners: usize,
pub stars: f64,
pub max_combo: usize,
}
#[derive(Clone, Debug)]
pub struct OsuPerformanceAttributes {
pub difficulty: OsuDifficultyAttributes,
pub pp: f64,
pub pp_acc: f64,
pub pp_aim: f64,
pub pp_flashlight: f64,
pub pp_speed: f64,
}
+50
View File
@@ -151,6 +151,37 @@ macro_rules! parse_general_body {
}};
}
macro_rules! parse_metadata_body {
($self:ident, $reader:ident, $section:ident) => {{
let mut empty = true;
let mut creator = None;
let mut beatmap_id = None;
while next_line!($reader)? != 0 {
if let Some(bytes) = $reader.get_section() {
*$section = Section::from_bytes(bytes);
empty = false;
break;
}
let (key, value) = $reader.split_colon().ok_or(ParseError::BadLine)?;
if key == b"Creator" {
creator = Some(value)
} else if key == b"BeatmapID" {
if let Some(val) = u32::parse_in_range(value) {
beatmap_id = Some(val);
}
}
}
$self.creator = creator.unwrap_or("").to_string();
$self.beatmap_id = beatmap_id.unwrap_or(0);
Ok(empty)
}};
}
macro_rules! parse_difficulty_body {
($self:ident, $reader:ident, $section:ident) => {{
let mut ar = None;
@@ -752,6 +783,7 @@ macro_rules! parse_body {
loop {
match section {
Section::General => section!(map, parse_general, reader, section),
Section::Metadata => section!(map, parse_metadata, reader, section),
Section::Difficulty => section!(map, parse_difficulty, reader, section),
Section::Events => section!(map, parse_events, reader, section),
Section::TimingPoints => section!(map, parse_timingpoints, reader, section),
@@ -956,6 +988,14 @@ impl Beatmap {
parse_general_body!(self, reader, section)
}
fn parse_metadata<R: Read>(
&mut self,
reader: &mut FileReader<R>,
section: &mut Section,
) -> ParseResult<bool> {
parse_metadata_body!(self, reader, section)
}
fn parse_difficulty<R: Read>(
&mut self,
reader: &mut FileReader<R>,
@@ -1023,6 +1063,14 @@ impl Beatmap {
parse_general_body!(self, reader, section)
}
async fn parse_metadata<R: AsyncRead + Unpin>(
&mut self,
reader: &mut FileReader<R>,
section: &mut Section,
) -> ParseResult<bool> {
parse_metadata_body!(self, reader, section)
}
async fn parse_difficulty<R: AsyncRead + Unpin>(
&mut self,
reader: &mut FileReader<R>,
@@ -1076,6 +1124,7 @@ enum Section {
TimingPoints,
HitObjects,
Events,
Metadata,
}
impl Section {
@@ -1086,6 +1135,7 @@ impl Section {
b"TimingPoints" => Self::TimingPoints,
b"HitObjects" => Self::HitObjects,
b"Events" => Self::Events,
b"Metadata" => Self::Metadata,
_ => Self::None,
}
}