1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-17 21:53:26 +08:00
Files
osu-lazer/osu.Game/Beatmaps/Beatmap.cs
T
Bartłomiej Dach 8f927ea7b5 Fix Beatmap.GetMostCommonBeatLength() potentially returning a beat length smaller or larger than the actual limits (#35827)
Closes https://github.com/ppy/osu/issues/35807.

The reason this closes the aforementioned issue is as follows:

Taking https://osu.ppy.sh/beatmapsets/1236180#osu/4650477 as the
example, we have:

```
minBeatLength = 342.857142857143
maxBeatLength = 419.58041958042003
mostCommonBeatLength = 342.85700000000003
```

Note that `mostCommonBeatLength < minBeatLength` here.

Taking the inverse of that to compute BPM, we get

```
minBpm = 174.99999999999991
maxBpm = 142.99999999999986
mostCommonBpm = 175.00007291669704
```

which without DT present doesn't do anything bad, but when DT is
engaged (and thus BPM is multiplied by 1.5), midpoint rounding causes
the min BPM to become 262, and the most common BPM to become 263.
2025-11-28 08:25:47 +09:00

164 lines
6.3 KiB
C#

// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Game.Beatmaps.Timing;
using osu.Game.Rulesets.Objects;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps.ControlPoints;
using Newtonsoft.Json;
using osu.Framework.Lists;
using osu.Game.Beatmaps.Formats;
using osu.Game.IO.Serialization.Converters;
namespace osu.Game.Beatmaps
{
public class Beatmap<T> : IBeatmap<T>
where T : HitObject
{
private BeatmapDifficulty difficulty = new BeatmapDifficulty();
public BeatmapDifficulty Difficulty
{
get => difficulty;
set
{
difficulty = value;
beatmapInfo.Difficulty = difficulty.Clone();
}
}
private BeatmapInfo beatmapInfo;
public BeatmapInfo BeatmapInfo
{
get => beatmapInfo;
set
{
beatmapInfo = value;
Difficulty = beatmapInfo.Difficulty.Clone();
}
}
public Beatmap()
{
beatmapInfo = new BeatmapInfo
{
Metadata = new BeatmapMetadata
{
Artist = @"Unknown",
Title = @"Unknown",
Author = { Username = @"Unknown Creator" },
},
DifficultyName = @"Normal",
Difficulty = Difficulty,
};
}
[JsonIgnore]
public BeatmapMetadata Metadata => BeatmapInfo.Metadata;
public ControlPointInfo ControlPointInfo { get; set; } = new ControlPointInfo();
public SortedList<BreakPeriod> Breaks { get; set; } = new SortedList<BreakPeriod>(Comparer<BreakPeriod>.Default);
public List<string> UnhandledEventLines { get; set; } = new List<string>();
[JsonIgnore]
public double TotalBreakTime => Breaks.Sum(b => b.Duration);
[JsonConverter(typeof(TypedListConverter<HitObject>))]
public List<T> HitObjects { get; set; } = new List<T>();
IReadOnlyList<T> IBeatmap<T>.HitObjects => HitObjects;
IReadOnlyList<HitObject> IBeatmap.HitObjects => HitObjects;
public virtual IEnumerable<BeatmapStatistic> GetStatistics() => Enumerable.Empty<BeatmapStatistic>();
public double GetMostCommonBeatLength()
{
double lastTime;
// The last playable time in the beatmap - the last timing point extends to this time.
// Note: This is more accurate and may present different results because osu-stable didn't have the ability to calculate slider durations in this context.
if (!HitObjects.Any())
lastTime = ControlPointInfo.TimingPoints.LastOrDefault()?.Time ?? 0;
else
lastTime = this.GetLastObjectTime();
var mostCommon =
// Construct a set of (beatLength, duration) tuples for each individual timing point.
ControlPointInfo.TimingPoints.Select((t, i) =>
{
if (t.Time > lastTime)
return (beatLength: t.BeatLength, 0);
// osu-stable forced the first control point to start at 0.
// This is reproduced here to maintain compatibility around osu!mania scroll speed and song select display.
double currentTime = i == 0 ? 0 : t.Time;
double nextTime = i == ControlPointInfo.TimingPoints.Count - 1 ? lastTime : ControlPointInfo.TimingPoints[i + 1].Time;
return (beatLength: t.BeatLength, duration: nextTime - currentTime);
})
// Aggregate durations into a set of (beatLength, duration) tuples for each beat length
// Rounding is applied here (to 1e-3 milliseconds) to neutralise potential effects of floating point inaccuracies
.GroupBy(t => Math.Round(t.beatLength * 1000) / 1000)
.Select(g => (beatLength: g.Key, duration: g.Sum(t => t.duration)))
// Get the most common one, or 0 as a suitable default (see handling below)
.OrderByDescending(i => i.duration).FirstOrDefault();
if (mostCommon.beatLength == 0)
return TimingControlPoint.DEFAULT_BEAT_LENGTH;
// Because of the rounding applied to the beat length above, it is possible for the "most common" beat length as determined by the linq query above
// to actually be less or more than the raw range of unrounded beat lengths present in the map
// To ensure this does not become a problem anywhere else further, clamp the result to the known raw range
double minBeatLength = ControlPointInfo.TimingPoints.Min(t => t.BeatLength);
double maxBeatLength = ControlPointInfo.TimingPoints.Max(t => t.BeatLength);
return Math.Clamp(mostCommon.beatLength, minBeatLength, maxBeatLength);
}
public double AudioLeadIn { get; set; }
public float StackLeniency { get; set; } = 0.7f;
public bool SpecialStyle { get; set; }
public bool LetterboxInBreaks { get; set; }
public bool WidescreenStoryboard { get; set; } = true;
public bool EpilepsyWarning { get; set; }
public bool SamplesMatchPlaybackRate { get; set; }
public double DistanceSpacing { get; set; } = 1.0;
public int GridSize { get; set; }
public double TimelineZoom { get; set; } = 1.0;
public CountdownType Countdown { get; set; } = CountdownType.None;
public int CountdownOffset { get; set; }
public int[] Bookmarks { get; set; } = Array.Empty<int>();
public int BeatmapVersion { get; set; } = LegacyBeatmapEncoder.FIRST_LAZER_VERSION;
IBeatmap IBeatmap.Clone() => Clone();
public Beatmap<T> Clone() => (Beatmap<T>)MemberwiseClone();
public override string ToString() => BeatmapInfo.ToString();
}
public class Beatmap : Beatmap<HitObject>
{
}
}