diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs index c16a181da6b8..5bc230e49519 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs @@ -27,5 +27,6 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => []; protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => []; + protected override DifficultyAttributes CreateEmptyAttributes() => new DifficultyAttributes(); } } diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs index 7e78a419976b..72ae4b5900de 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs @@ -27,5 +27,6 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => []; protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => []; + protected override DifficultyAttributes CreateEmptyAttributes() => new DifficultyAttributes(); } } diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs index 13909470cc73..8283e2c623f0 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs @@ -27,5 +27,6 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => []; protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => []; + protected override DifficultyAttributes CreateEmptyAttributes() => new DifficultyAttributes(); } } diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs index 7e78a419976b..72ae4b5900de 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs @@ -27,5 +27,6 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => []; protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => []; + protected override DifficultyAttributes CreateEmptyAttributes() => new DifficultyAttributes(); } } diff --git a/convert_parser_full.txt b/convert_parser_full.txt new file mode 100644 index 000000000000..c374d6cf94e7 --- /dev/null +++ b/convert_parser_full.txt @@ -0,0 +1,721 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +using osu.Framework.Utils; +using osu.Game.Audio; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Formats; +using osu.Game.Beatmaps.Legacy; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Skinning; +using osu.Game.Utils; +using osuTK; + +namespace osu.Game.Rulesets.Objects.Legacy +{ + /// + /// A HitObjectParser to parse legacy Beatmaps. + /// + public class ConvertHitObjectParser : HitObjectParser + { + /// + /// The offset to apply to all time values. + /// + private readonly double offset; + + /// + /// The .osu format (beatmap) version. + /// + private readonly int formatVersion; + + /// + /// Whether the current hitobject is the first hitobject in the beatmap. + /// + private bool firstObject = true; + + /// + /// The last parsed hitobject. + /// + private ConvertHitObject? lastObject; + + internal ConvertHitObjectParser(double offset, int formatVersion) + { + this.offset = offset; + this.formatVersion = formatVersion; + } + + public override HitObject Parse(ReadOnlySpan text) + { + Span ranges = stackalloc Range[32]; int count = text.Split(ranges, ','); + + Vector2 pos = + formatVersion >= LegacyBeatmapEncoder.FIRST_LAZER_VERSION + ? new Vector2(Parsing.ParseFloat(text[ranges[0]], Parsing.MAX_COORDINATE_VALUE), Parsing.ParseFloat(text[ranges[1]], Parsing.MAX_COORDINATE_VALUE)) + : new Vector2((int)Parsing.ParseFloat(text[ranges[0]], Parsing.MAX_COORDINATE_VALUE), (int)Parsing.ParseFloat(text[ranges[1]], Parsing.MAX_COORDINATE_VALUE)); + + double startTime = Parsing.ParseDouble(text[ranges[2]]) + offset; + + LegacyHitObjectType type = (LegacyHitObjectType)Parsing.ParseInt(text[ranges[3]]); + + int comboOffset = (int)(type & LegacyHitObjectType.ComboOffset) >> 4; + type &= ~LegacyHitObjectType.ComboOffset; + + bool combo = type.HasFlag(LegacyHitObjectType.NewCombo); + type &= ~LegacyHitObjectType.NewCombo; + + var soundType = (LegacyHitSoundType)Parsing.ParseInt(text[ranges[4]]); + var bankInfo = new SampleBankInfo(); + + ConvertHitObject? result = null; + + if (type.HasFlag(LegacyHitObjectType.Circle)) + { + result = createHitCircle(pos, combo, comboOffset); + + if (count > 5) + readCustomSampleBanks(text[ranges[5]], bankInfo); + } + else if (type.HasFlag(LegacyHitObjectType.Slider)) + { + double? length = null; + + int repeatCount = Parsing.ParseInt(text[ranges[6]]); + + if (repeatCount > 9000) + throw new FormatException(@"Repeat count is way too high"); + + // osu-stable treated the first span of the slider as a repeat, but no repeats are happening + repeatCount = Math.Max(0, repeatCount - 1); + + if (count > 7) + { + length = Math.Max(0, Parsing.ParseDouble(text[ranges[7]], Parsing.MAX_COORDINATE_VALUE)); + if (length == 0) + length = null; + } + + if (count > 10) + readCustomSampleBanks(text[ranges[10]], bankInfo, true); + + // One node for each repeat + the start and end nodes + int nodes = repeatCount + 2; + + // Populate node sample bank infos with the default hit object sample bank + var nodeBankInfos = new List(); + for (int i = 0; i < nodes; i++) + nodeBankInfos.Add(bankInfo.Clone()); + + // Read any per-node sample banks + if (count > 9 && text[ranges[9]].Length > 0) + { + string[] sets = text[ranges[9]].ToString().Split('|'); + + for (int i = 0; i < nodes; i++) + { + if (i >= sets.Length) + break; + + SampleBankInfo info = nodeBankInfos[i]; + readCustomSampleBanks(sets[i], info); + } + } + + // Populate node sound types with the default hit object sound type + var nodeSoundTypes = new List(); + for (int i = 0; i < nodes; i++) + nodeSoundTypes.Add(soundType); + + // Read any per-node sound types + if (count > 8 && text[ranges[8]].Length > 0) + { + string[] adds = text[ranges[8]].ToString().Split('|'); + + for (int i = 0; i < nodes; i++) + { + if (i >= adds.Length) + break; + + int.TryParse(adds[i], out int sound); + nodeSoundTypes[i] = (LegacyHitSoundType)sound; + } + } + + // Generate the final per-node samples + var nodeSamples = new List>(nodes); + for (int i = 0; i < nodes; i++) + nodeSamples.Add(convertSoundType(nodeSoundTypes[i], nodeBankInfos[i])); + + result = createSlider(pos, combo, comboOffset, convertPathString(text[ranges[5]].ToString(), pos), length, repeatCount, nodeSamples); + } + else if (type.HasFlag(LegacyHitObjectType.Spinner)) + { + double duration = Math.Max(0, Parsing.ParseDouble(text[ranges[5]]) + offset - startTime); + + result = createSpinner(new Vector2(512, 384) / 2, combo, duration); + + if (count > 6) + readCustomSampleBanks(text[ranges[6]], bankInfo); + } + else if (type.HasFlag(LegacyHitObjectType.Hold)) + { + // Note: Hold is generated by BMS converts + + double endTime = Math.Max(startTime, Parsing.ParseDouble(text[ranges[2]])); + + if (count > 5 && !text[ranges[5]].IsEmpty) + { + ReadOnlySpan ss = text[ranges[5]]; + int firstColon = ss.IndexOf(':'); + if (firstColon == -1) + { + endTime = Math.Max(startTime, Parsing.ParseDouble(ss)); + } + else + { + endTime = Math.Max(startTime, Parsing.ParseDouble(ss[..firstColon])); + readCustomSampleBanks(ss[(firstColon + 1)..], bankInfo); + } + } + + result = createHold(pos, endTime + offset - startTime); + } + + if (result == null) + throw new InvalidDataException($"Unknown hit object type: {text[ranges[3]]}"); + + result.StartTime = startTime; + result.LegacyType = type; + + if (result.Samples.Count == 0) + result.Samples = convertSoundType(soundType, bankInfo); + + firstObject = false; + + return result; + } + + private void readCustomSampleBanks(ReadOnlySpan str, SampleBankInfo bankInfo, bool banksOnly = false) + { + if (str.IsEmpty) + return; + + Span ranges = stackalloc Range[8]; int count = str.Split(ranges, ':'); + + var bank = (LegacySampleBank)Parsing.ParseInt(str[ranges[0]]); + if (!Enum.IsDefined(bank)) + bank = LegacySampleBank.Normal; + + var addBank = (LegacySampleBank)Parsing.ParseInt(str[ranges[1]]); + if (!Enum.IsDefined(addBank)) + addBank = LegacySampleBank.Normal; + + string? stringBank = bank.ToString().ToLowerInvariant(); + string? stringAddBank = addBank.ToString().ToLowerInvariant(); + + if (stringBank == @"none") + stringBank = null; + + if (stringAddBank == @"none") + { + bankInfo.EditorAutoBank = true; + stringAddBank = null; + } + else + bankInfo.EditorAutoBank = false; + + bankInfo.BankForNormal = stringBank; + bankInfo.BankForAdditions = string.IsNullOrEmpty(stringAddBank) ? stringBank : stringAddBank; + + if (banksOnly) return; + + if (count > 2) + bankInfo.CustomSampleBank = Parsing.ParseInt(str[ranges[2]]); + + if (count > 3) + bankInfo.Volume = Math.Max(0, Parsing.ParseInt(str[ranges[3]])); + + bankInfo.Filename = count > 4 ? str[ranges[4]].ToString() : null; + } + + private PathType convertPathType(string input) + { + switch (input[0]) + { + default: + case 'C': + return PathType.CATMULL; + + case 'B': + if (input.Length > 1 && int.TryParse(input.AsSpan(1), out int degree) && degree > 0) + return PathType.BSpline(degree); + + return PathType.BEZIER; + + case 'L': + return PathType.LINEAR; + + case 'P': + return PathType.PERFECT_CURVE; + } + } + + /// + /// Converts a given point string into a set of path control points. + /// + /// + /// A point string takes the form: X|1:1|2:2|2:2|3:3|Y|1:1|2:2. + /// This has three segments: + /// + /// + /// X: { (1,1), (2,2) } (implicit segment) + /// + /// + /// X: { (2,2), (3,3) } (implicit segment) + /// + /// + /// Y: { (3,3), (1,1), (2, 2) } (explicit segment) + /// + /// + /// + /// The point string. + /// The positional offset to apply to the control points. + /// All control points in the resultant path. + private PathControlPoint[] convertPathString(string pointString, Vector2 offset) + { + // This code takes on the responsibility of handling explicit segments of the path ("X" & "Y" from above). Implicit segments are handled by calls to convertPoints(). + string[] pointStringSplit = pointString.Split('|'); + + var pointsBuffer = ArrayPool.Shared.Rent(pointStringSplit.Length); + var segmentsBuffer = ArrayPool<(PathType Type, int StartIndex)>.Shared.Rent(pointStringSplit.Length); + int currentPointsIndex = 0; + int currentSegmentsIndex = 0; + + try + { + foreach (string s in pointStringSplit) + { + if (char.IsLetter(s[0])) + { + // The start of a new segment(indicated by having an alpha character at position 0). + var pathType = convertPathType(s); + segmentsBuffer[currentSegmentsIndex++] = (pathType, currentPointsIndex); + + // First segment is prepended by an extra zero point + if (currentPointsIndex == 0) + pointsBuffer[currentPointsIndex++] = Vector2.Zero; + } + else + { + pointsBuffer[currentPointsIndex++] = readPoint(s, offset); + } + } + + int pointsCount = currentPointsIndex; + int segmentsCount = currentSegmentsIndex; + var controlPoints = new List>(pointsCount); + var allPoints = new ArraySegment(pointsBuffer, 0, pointsCount); + + for (int i = 0; i < segmentsCount; i++) + { + if (i < segmentsCount - 1) + { + int startIndex = segmentsBuffer[i].StartIndex; + int endIndex = segmentsBuffer[i + 1].StartIndex; + controlPoints.AddRange(convertPoints(segmentsBuffer[i].Type, allPoints.Slice(startIndex, endIndex - startIndex), pointsBuffer[endIndex])); + } + else + { + int startIndex = segmentsBuffer[i].StartIndex; + controlPoints.AddRange(convertPoints(segmentsBuffer[i].Type, allPoints.Slice(startIndex), null)); + } + } + + return mergeControlPointsLists(controlPoints); + } + finally + { + ArrayPool.Shared.Return(pointsBuffer); + ArrayPool<(PathType, int)>.Shared.Return(segmentsBuffer); + } + + Vector2 readPoint(string value, Vector2 startPos) + { + string[] vertexSplit = value.Split(':'); + + Vector2 pos = formatVersion >= LegacyBeatmapEncoder.FIRST_LAZER_VERSION + ? new Vector2(Parsing.ParseFloat(vertexSplit[0], Parsing.MAX_COORDINATE_VALUE), Parsing.ParseFloat(vertexSplit[1], Parsing.MAX_COORDINATE_VALUE)) + : new Vector2((int)Parsing.ParseFloat(vertexSplit[0], Parsing.MAX_COORDINATE_VALUE), (int)Parsing.ParseFloat(vertexSplit[1], Parsing.MAX_COORDINATE_VALUE)); + pos -= startPos; + return pos; + } + } + + /// + /// Converts a given point list into a set of path segments. + /// + /// The path type of the point list. + /// The point list. + /// Any extra endpoint to consider as part of the points. This will NOT be returned. + /// The set of points contained by as one or more segments of the path. + private IEnumerable> convertPoints(PathType type, ArraySegment points, Vector2? endPoint) + { + var vertices = new PathControlPoint[points.Count]; + + // Parse into control points. + for (int i = 0; i < points.Count; i++) + vertices[i] = new PathControlPoint { Position = points[i] }; + + // Edge-case rules (to match stable). + if (type == PathType.PERFECT_CURVE) + { + int endPointLength = endPoint == null ? 0 : 1; + + if (formatVersion < LegacyBeatmapEncoder.FIRST_LAZER_VERSION) + { + if (vertices.Length + endPointLength != 3) + type = PathType.BEZIER; + else if (isLinear(points[0], points[1], endPoint ?? points[2])) + { + // osu-stable special-cased colinear perfect curves to a linear path + type = PathType.LINEAR; + } + } + else if (vertices.Length + endPointLength > 3) + // Lazer supports perfect curves with less than 3 points and colinear points + type = PathType.BEZIER; + } + + // The first control point must have a definite type. + vertices[0].Type = type; + + // A path can have multiple implicit segments of the same type if there are two sequential control points with the same position. + // To handle such cases, this code may return multiple path segments with the final control point in each segment having a non-null type. + // For the point string X|1:1|2:2|2:2|3:3, this code returns the segments: + // X: { (1,1), (2, 2) } + // X: { (3, 3) } + // Note: (2, 2) is not returned in the second segments, as it is implicit in the path. + int startIndex = 0; + int endIndex = 0; + + while (++endIndex < vertices.Length) + { + // Keep incrementing while an implicit segment doesn't need to be started. + if (vertices[endIndex].Position != vertices[endIndex - 1].Position) + continue; + + // Legacy CATMULL sliders don't support multiple segments, so adjacent CATMULL segments should be treated as a single one. + // Importantly, this is not applied to the first control point, which may duplicate the slider path's position + // resulting in a duplicate (0,0) control point in the resultant list. + if (type == PathType.CATMULL && endIndex > 1 && formatVersion < LegacyBeatmapEncoder.FIRST_LAZER_VERSION) + continue; + + // The last control point of each segment is not allowed to start a new implicit segment. + if (endIndex == vertices.Length - 1) + continue; + + // Force a type on the last point, and return the current control point set as a segment. + vertices[endIndex - 1].Type = type; + yield return new ArraySegment(vertices, startIndex, endIndex - startIndex); + + // Skip the current control point - as it's the same as the one that's just been returned. + startIndex = endIndex + 1; + } + + if (startIndex < endIndex) + yield return new ArraySegment(vertices, startIndex, endIndex - startIndex); + + static bool isLinear(Vector2 p0, Vector2 p1, Vector2 p2) + => Precision.AlmostEquals(0, (p1.Y - p0.Y) * (p2.X - p0.X) + - (p1.X - p0.X) * (p2.Y - p0.Y)); + } + + private PathControlPoint[] mergeControlPointsLists(List> controlPointList) + { + int totalCount = 0; + + foreach (var arr in controlPointList) + totalCount += arr.Count; + + var mergedArray = new PathControlPoint[totalCount]; + int copyIndex = 0; + + foreach (var arr in controlPointList) + { + arr.AsSpan().CopyTo(mergedArray.AsSpan(copyIndex)); + copyIndex += arr.Count; + } + + return mergedArray; + } + + /// + /// Creates a legacy Hit-type hit object. + /// + /// The position of the hit object. + /// Whether the hit object creates a new combo. + /// When starting a new combo, the offset of the new combo relative to the current one. + /// The hit object. + private ConvertHitObject createHitCircle(Vector2 position, bool newCombo, int comboOffset) + { + return lastObject = new ConvertHitCircle + { + Position = position, + NewCombo = firstObject || lastObject is ConvertSpinner || newCombo, + ComboOffset = newCombo ? comboOffset : 0 + }; + } + + /// + /// Creats a legacy Slider-type hit object. + /// + /// The position of the hit object. + /// Whether the hit object creates a new combo. + /// When starting a new combo, the offset of the new combo relative to the current one. + /// The slider control points. + /// The slider length. + /// The slider repeat count. + /// The samples to be played when the slider nodes are hit. This includes the head and tail of the slider. + /// The hit object. + private ConvertHitObject createSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount, + IList> nodeSamples) + { + var path = new SliderPath(controlPoints, length); + + // there are known instances of beatmaps (https://osu.ppy.sh/beatmapsets/594828#osu/1258033) which contain zero-length sliders with non-zero numbers of repeats. + // this was exploiting a bug in stable in which the slider repeats would be generated as objects but never actually judged as a hit *or* miss during gameplay, + // therefore increasing the theoretical possible max combo to be gained from a slider while in practice never giving that extra combo. + // due to lazer ensuring that an object has its nested part fully judged, this would result in broken behaviours + // (either the zero-length slider giving hundreds of combo for nothing if the repeats are judged as hit, or insta-failing the player due to HP if judged as miss). + // to remedy this in a way that seems least damaging, detect this situation via a heuristic and reset the number of repeats to zero. + // this technically *does not* match stable beatmap parsing or conversion, *does not* match in-gameplay behaviour of such broken sliders, + // and *will* fail conversion mapping tests, but again, this is supposed to be a least-worst measure to prevent exploits. + // it is also applied centrally to all rulesets rather than in specific ruleset converters because this failure scenario + // translates across rulesets (osu! and catch are both affected). + if (Precision.AlmostEquals(path.Distance, 0)) + { + repeatCount = 0; + nodeSamples = [nodeSamples[0], nodeSamples[^1]]; + } + + return lastObject = new ConvertSlider + { + Position = position, + NewCombo = firstObject || lastObject is ConvertSpinner || newCombo, + ComboOffset = newCombo ? comboOffset : 0, + Path = path, + NodeSamples = nodeSamples, + RepeatCount = repeatCount + }; + } + + /// + /// Creates a legacy Spinner-type hit object. + /// + /// The position of the hit object. + /// Whether the hit object creates a new combo. + /// The spinner duration. + /// The hit object. + private ConvertHitObject createSpinner(Vector2 position, bool newCombo, double duration) + { + return lastObject = new ConvertSpinner + { + Position = position, + Duration = duration, + NewCombo = newCombo + // Spinners cannot have combo offset. + }; + } + + /// + /// Creates a legacy Hold-type hit object. + /// + /// The position of the hit object. + /// The hold duration. + private ConvertHitObject createHold(Vector2 position, double duration) + { + return lastObject = new ConvertHold + { + Position = position, + Duration = duration + }; + } + + private List convertSoundType(LegacyHitSoundType type, SampleBankInfo bankInfo) + { + var soundTypes = new List(); + + if (string.IsNullOrEmpty(bankInfo.Filename)) + { + soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_NORMAL, bankInfo.BankForNormal, bankInfo.Volume, true, bankInfo.CustomSampleBank, + // if the sound type doesn't have the Normal flag set, attach it anyway as a layered sample. + // None also counts as a normal non-layered sample: https://osu.ppy.sh/help/wiki/osu!_File_Formats/Osu_(file_format)#hitsounds + type != LegacyHitSoundType.None && !type.HasFlag(LegacyHitSoundType.Normal))); + } + else + { + soundTypes.Add(new FileHitSampleInfo(bankInfo.Filename, bankInfo.Volume)); + } + + if (type.HasFlag(LegacyHitSoundType.Finish)) + soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_FINISH, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.EditorAutoBank, bankInfo.CustomSampleBank)); + + if (type.HasFlag(LegacyHitSoundType.Whistle)) + soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_WHISTLE, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.EditorAutoBank, bankInfo.CustomSampleBank)); + + if (type.HasFlag(LegacyHitSoundType.Clap)) + soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_CLAP, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.EditorAutoBank, bankInfo.CustomSampleBank)); + + return soundTypes; + } + + private class SampleBankInfo + { + /// + /// An optional overriding filename which causes all bank/sample specifications to be ignored. + /// + public string? Filename; + + /// + /// The bank identifier to use for the base ("hitnormal") sample. + /// Transferred to when appropriate. + /// + public string? BankForNormal; + + /// + /// The bank identifier to use for additions ("hitwhistle", "hitfinish", "hitclap"). + /// Transferred to when appropriate. + /// + public string? BankForAdditions; + + /// + /// Hit sample volume (0-100). + /// See . + /// + public int Volume; + + /// + /// The index of the custom sample bank. Is only used if 2 or above for "reasons". + /// This will add a suffix to lookups, allowing extended bank lookups (ie. "normal-hitnormal-2"). + /// See . + /// + public int CustomSampleBank; + + /// + /// Whether the bank for additions should be inherited from the normal sample in edit. + /// + public bool EditorAutoBank = true; + + public SampleBankInfo Clone() => (SampleBankInfo)MemberwiseClone(); + } + + public class LegacyHitSampleInfo : HitSampleInfo, IEquatable + { + public int CustomSampleBank + { + get + { + if (Suffix != null) + return int.Parse(Suffix); + + return UseBeatmapSamples ? 1 : 0; + } + } + + /// + /// Whether this hit sample is layered. + /// + /// + /// Layered hit samples are automatically added in all modes (except osu!mania), but can be disabled + /// using the skin config option. + /// + public readonly bool IsLayered; + + /// + /// Whether a bank was specified locally to the relevant hitobject. + /// If false, a bank will be retrieved from the closest control point. + /// + public bool BankSpecified; + + public LegacyHitSampleInfo(string name, string? bank = null, int volume = 0, bool editorAutoBank = false, int customSampleBank = 0, bool isLayered = false) + : base( + name, + bank ?? SampleControlPoint.DEFAULT_BANK, + suffix: customSampleBank >= 2 ? customSampleBank.ToString() : null, + volume, + editorAutoBank, + useBeatmapSamples: customSampleBank >= 1) + { + BankSpecified = !string.IsNullOrEmpty(bank); + IsLayered = isLayered; + } + + public sealed override HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default, + Optional newEditorAutoBank = default, Optional newUseBeatmapSamples = default) + { + string? suffix = newSuffix.GetOr(Suffix); + bool useBeatmapSamples = newUseBeatmapSamples.GetOr(UseBeatmapSamples); + int newCustomSampleBank = 0; + + if (suffix != null) + _ = int.TryParse(suffix, out newCustomSampleBank); + + if (newCustomSampleBank == 0 && useBeatmapSamples) + newCustomSampleBank = 1; + + return With(newName, newBank, newVolume, newEditorAutoBank, newCustomSampleBank); + } + + public virtual LegacyHitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newVolume = default, + Optional newEditorAutoBank = default, Optional newCustomSampleBank = default, Optional newIsLayered = default) + => new LegacyHitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newVolume.GetOr(Volume), newEditorAutoBank.GetOr(EditorAutoBank), newCustomSampleBank.GetOr(CustomSampleBank), + newIsLayered.GetOr(IsLayered)); + + public bool Equals(LegacyHitSampleInfo? other) + // The additions to equality checks here are *required* to ensure that pooling works correctly. + // Of note, `IsLayered` may cause the usage of `SampleVirtual` instead of an actual sample (in cases playback is not required). + // Removing it would cause samples which may actually require playback to potentially source for a `SampleVirtual` sample pool. + => base.Equals(other) && CustomSampleBank == other.CustomSampleBank && IsLayered == other.IsLayered; + + public override bool Equals(object? obj) + => obj is LegacyHitSampleInfo other && Equals(other); + + public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), CustomSampleBank, IsLayered); + } + + public class FileHitSampleInfo : LegacyHitSampleInfo, IEquatable + { + public readonly string Filename; + + public FileHitSampleInfo(string filename, int volume) + // Force CSS=1 to make sure that the LegacyBeatmapSkin does not fall back to the user skin. + : base(HIT_NORMAL, SampleControlPoint.DEFAULT_BANK, customSampleBank: 1, volume: volume) + { + Filename = filename; + } + + public override IEnumerable LookupNames => new[] + { + Filename, + Path.ChangeExtension(Filename, null) + }.Concat(base.LookupNames); + + public sealed override LegacyHitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newVolume = default, + Optional newEditorAutoBank = default, Optional newCustomSampleBank = default, Optional newIsLayered = default) + => new FileHitSampleInfo(Filename, newVolume.GetOr(Volume)); + + public bool Equals(FileHitSampleInfo? other) + => base.Equals(other) && Filename == other.Filename; + + public override bool Equals(object? obj) + => obj is FileHitSampleInfo other && Equals(other); + + public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), Filename); + } + } +} diff --git a/osu.Android.props b/osu.Android.props index e05709d554b0..af2e68ed0478 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -1,4 +1,4 @@ - + 33.0 false $(NoWarn);NU1608;XA4301 + true + + + @@ -51,6 +55,8 @@ false + + + @@ -108,7 +115,7 @@ RID does not start with `android` is stripped. This covers every non-Android desktop/mobile RID in one rule and is future-proof against new RIDs. * The path match uses forward-slash normalisation with the doubled-backslash - escape `'\\'` required inside MSBuild property-function string literals + escape `\\\\` required inside MSBuild property-function string literals (the previous `'\'` form is ambiguous to the expression parser). * Applied to `ResolvedFileToPublish` (the primary input to the .NET Android SDK's publish step) and also to `ReferenceCopyLocalPaths` / `RuntimeCopyLocalItems` @@ -153,4 +160,12 @@ + + + + + + + + diff --git a/osu.Android/AndroidManifest.xml b/osu.Android/AndroidManifest.xml index 15ab78b1a3e6..f80017f9890c 100644 --- a/osu.Android/AndroidManifest.xml +++ b/osu.Android/AndroidManifest.xml @@ -10,7 +10,7 @@ reaches DispatchKeyEvent and the OS default for root-task activities (moveTaskToBack, i.e. minimise) takes over instead. --> - + diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs index 9c2720e3b8d5..85ac5ee35c4e 100644 --- a/osu.Android/OsuGameActivity.cs +++ b/osu.Android/OsuGameActivity.cs @@ -85,6 +85,11 @@ protected override osu.Framework.Game CreateGame() return game; } + protected OsuGameActivity(IntPtr handle, JniHandleOwnership transfer) + : base() + { + } + public OsuGameActivity() { game = new OsuGameAndroid(this); diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs index dd69b5de128a..50aa9c0e775f 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs @@ -3,7 +3,7 @@ using System; using System.Collections.Generic; -using System.Linq; + using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Beatmaps; using osu.Game.Rulesets.Catch.Difficulty.Preprocessing; @@ -38,7 +38,7 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat CatchDifficultyAttributes attributes = new CatchDifficultyAttributes { - StarRating = Math.Sqrt(skills.OfType().Single().DifficultyValue()) * difficulty_multiplier, + StarRating = Math.Sqrt(GetSkill(skills).DifficultyValue()) * difficulty_multiplier, Mods = mods, MaxCombo = beatmap.GetMaxCombo(), }; @@ -88,5 +88,6 @@ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clo new CatchModHardRock(), new CatchModEasy(), }; + protected override DifficultyAttributes CreateEmptyAttributes() => new CatchDifficultyAttributes(); } } diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs index bcf16e68088c..64ab09ee8a1b 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs @@ -1,4 +1,4 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; @@ -46,25 +46,38 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat ManiaDifficultyAttributes attributes = new ManiaDifficultyAttributes { - StarRating = skills.OfType().Single().DifficultyValue() * difficulty_multiplier, + StarRating = GetSkill(skills).DifficultyValue() * difficulty_multiplier, Mods = mods, - MaxCombo = beatmap.HitObjects.Sum(maxComboForObject), + MaxCombo = calculateMaxCombo(beatmap), }; return attributes; } + private static int calculateMaxCombo(IBeatmap beatmap) + { + int maxCombo = 0; + foreach (var h in beatmap.HitObjects) + { + maxCombo += maxComboForObject(h); + } + + return maxCombo; + } + private static int maxComboForObject(HitObject hitObject) { if (hitObject is HoldNote hold) + { return 1 + (int)((hold.EndTime - hold.StartTime) / 100); + } return 1; } protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) { - var sortedObjects = beatmap.HitObjects.ToArray(); + var sortedObjects = Enumerable.ToArray(beatmap.HitObjects); int totalColumns = ((ManiaBeatmap)beatmap).TotalColumns; LegacySortHelper.Sort(sortedObjects, Comparer.Create((a, b) => (int)Math.Round(a.StartTime) - (int)Math.Round(b.StartTime))); @@ -73,7 +86,9 @@ protected override IEnumerable CreateDifficultyHitObjects(I List[] perColumnObjects = new List[totalColumns]; for (int column = 0; column < totalColumns; column++) + { perColumnObjects[column] = new List(); + } for (int i = 1; i < sortedObjects.Length; i++) { @@ -106,10 +121,12 @@ protected override Mod[] DifficultyAdjustmentMods }; if (isForCurrentRuleset) + { return mods; + } // if we are a convert, we can be played in any key mod. - return mods.Concat(new Mod[] + return Enumerable.Concat(mods, new Mod[] { new ManiaModKey1(), new ManiaModKey2(), @@ -128,5 +145,7 @@ protected override Mod[] DifficultyAdjustmentMods }).ToArray(); } } + + protected override DifficultyAttributes CreateEmptyAttributes() => new ManiaDifficultyAttributes(); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 504fddbb711a..7eda70136d28 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -3,7 +3,7 @@ using System; using System.Collections.Generic; -using System.Linq; + using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty.Preprocessing; @@ -51,10 +51,10 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat if (beatmap.HitObjects.Count == 0) return new OsuDifficultyAttributes { Mods = mods }; - var aim = skills.OfType().Single(a => a.IncludeSliders); - var aimWithoutSliders = skills.OfType().Single(a => !a.IncludeSliders); - var speed = skills.OfType().Single(); - var flashlight = skills.OfType().SingleOrDefault(); + Aim aim = GetSkill(skills, a => a.IncludeSliders); + Aim aimWithoutSliders = GetSkill(skills, a => !a.IncludeSliders); + Speed speed = GetSkill(skills); + Flashlight? flashlight = GetSkillOrDefault(skills); double speedNotes = speed.RelevantNoteCount(); @@ -74,9 +74,25 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat double approachRate = CalculateRateAdjustedApproachRate(beatmap.Difficulty.ApproachRate, clockRate); double overallDifficulty = CalculateRateAdjustedOverallDifficulty(beatmap.Difficulty.OverallDifficulty, clockRate); - int hitCircleCount = beatmap.HitObjects.Count(h => h is HitCircle); - int sliderCount = beatmap.HitObjects.Count(h => h is Slider); - int spinnerCount = beatmap.HitObjects.Count(h => h is Spinner); + int hitCircleCount = 0; + int sliderCount = 0; + int spinnerCount = 0; + + foreach (var h in beatmap.HitObjects) + { + if (h is HitCircle) + { + hitCircleCount++; + } + else if (h is Slider) + { + sliderCount++; + } + else if (h is Spinner) + { + spinnerCount++; + } + } int totalHits = beatmap.HitObjects.Count; @@ -99,108 +115,73 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat if (flashlight is not null) flashlightRating = osuRatingCalculator.ComputeFlashlightRating(flashlight.DifficultyValue()); - double sliderNestedScorePerObject = LegacyScoreUtils.CalculateNestedScorePerObject(beatmap, totalHits); - double legacyScoreBaseMultiplier = LegacyScoreUtils.CalculateDifficultyPeppyStars(beatmap); - - var simulator = new OsuLegacyScoreSimulator(); - var scoreAttributes = simulator.Simulate(WorkingBeatmap, beatmap); - - double baseAimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating); - double baseSpeedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating); - double baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating); - - double basePerformance = - Math.Pow( - Math.Pow(baseAimPerformance, 1.1) + - Math.Pow(baseSpeedPerformance, 1.1) + - Math.Pow(baseFlashlightPerformance, 1.1), 1.0 / 1.1 - ); - - double starRating = calculateStarRating(basePerformance); - - OsuDifficultyAttributes attributes = new OsuDifficultyAttributes + return new OsuDifficultyAttributes { - StarRating = starRating, + StarRating = osuRatingCalculator.ComputeStarRating(aimRating, speedRating, flashlightRating), Mods = mods, - AimDifficulty = aimRating, - AimDifficultSliderCount = difficultSliders, - SpeedDifficulty = speedRating, - SpeedNoteCount = speedNotes, - FlashlightDifficulty = flashlightRating, + AimRating = aimRating, + SpeedRating = speedRating, + FlashlightRating = flashlightRating, SliderFactor = sliderFactor, AimDifficultStrainCount = aimDifficultStrainCount, SpeedDifficultStrainCount = speedDifficultStrainCount, - AimTopWeightedSliderFactor = aimTopWeightedSliderFactor, - SpeedTopWeightedSliderFactor = speedTopWeightedSliderFactor, + SpeedRelevantNoteCount = speedNotes, + ApproachRate = approachRate, + OverallDifficulty = overallDifficulty, DrainRate = drainRate, - MaxCombo = beatmap.GetMaxCombo(), HitCircleCount = hitCircleCount, SliderCount = sliderCount, SpinnerCount = spinnerCount, - NestedScorePerObject = sliderNestedScorePerObject, - LegacyScoreBaseMultiplier = legacyScoreBaseMultiplier, - MaximumLegacyComboScore = scoreAttributes.ComboScore + MaxCombo = beatmap.GetMaxCombo() }; - - return attributes; } private double calculateMechanicalDifficultyRating(double aimDifficultyValue, double speedDifficultyValue) - { - double aimValue = OsuStrainSkill.DifficultyToPerformance(OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue)); - double speedValue = OsuStrainSkill.DifficultyToPerformance(OsuRatingCalculator.CalculateDifficultyRating(speedDifficultyValue)); - - double totalValue = Math.Pow(Math.Pow(aimValue, 1.1) + Math.Pow(speedValue, 1.1), 1 / 1.1); - - return calculateStarRating(totalValue); - } - - private double calculateStarRating(double basePerformance) - { - if (basePerformance <= 0.00001) - return 0; - - return Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * star_rating_multiplier * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4); - } + => OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue + speedDifficultyValue + Math.Sqrt(aimDifficultyValue * speedDifficultyValue) * 0.5); - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) { - List objects = new List(); - - // The first jump is formed by the first two hitobjects of the map. - // If the map has less than two OsuHitObjects, the enumerator will not return anything. - for (int i = 1; i < beatmap.HitObjects.Count; i++) + bool hasFlashlight = false; + foreach (var m in mods) { - objects.Add(new OsuDifficultyHitObject(beatmap.HitObjects[i], beatmap.HitObjects[i - 1], clockRate, objects, objects.Count)); + if (m is FlashlightMod) + { + hasFlashlight = true; + break; + } } - return objects; - } + if (hasFlashlight) + { + return new Skill[] + { + new Aim(mods, true), + new Aim(mods, false), + new Speed(mods), + new Flashlight(mods) + }; + } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) - { - var skills = new List + return new Skill[] { new Aim(mods, true), new Aim(mods, false), new Speed(mods) }; + } + + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + { + var difficultyHitObjects = new List(); - if (mods.Any(h => h is OsuModFlashlight)) - skills.Add(new Flashlight(mods)); + for (int i = 1; i < beatmap.HitObjects.Count; i++) + { + difficultyHitObjects.Add(new OsuDifficultyHitObject(beatmap.HitObjects[i], beatmap.HitObjects[i - 1], clockRate, difficultyHitObjects, i)); + } - return skills.ToArray(); + return difficultyHitObjects; } - protected override Mod[] DifficultyAdjustmentMods => new Mod[] - { - new OsuModTouchDevice(), - new OsuModDoubleTime(), - new OsuModHalfTime(), - new OsuModEasy(), - new OsuModHardRock(), - new OsuModFlashlight(), - new OsuModHidden(), - }; + protected override DifficultyAttributes CreateEmptyAttributes() => new OsuDifficultyAttributes(); } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index edd26819f503..1f8561687f1f 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -1,9 +1,9 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; using System.Collections.Generic; -using System.Linq; + using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty.Preprocessing; @@ -47,7 +47,16 @@ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clo hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); isConvert = beatmap.BeatmapInfo.Ruleset.OnlineID == 0; - isRelax = mods.Any(h => h is TaikoModRelax); + isRelax = false; + + foreach (var h in mods) + { + if (h is TaikoModRelax) + { + isRelax = true; + break; + } + } return new Skill[] { @@ -102,146 +111,43 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat if (beatmap.HitObjects.Count == 0) return new TaikoDifficultyAttributes { Mods = mods }; - var rhythm = skills.OfType().Single(); - var reading = skills.OfType().Single(); - var colour = skills.OfType().Single(); - var stamina = skills.OfType().Single(s => !s.SingleColourStamina); - var singleColourStamina = skills.OfType().Single(s => s.SingleColourStamina); - - double rhythmSkill = rhythm.DifficultyValue() * rhythm_skill_multiplier; - double readingSkill = reading.DifficultyValue() * reading_skill_multiplier; - double colourSkill = colour.DifficultyValue() * colour_skill_multiplier; - double staminaSkill = stamina.DifficultyValue() * stamina_skill_multiplier; - double monoStaminaSkill = singleColourStamina.DifficultyValue() * stamina_skill_multiplier; - double monoStaminaFactor = staminaSkill == 0 ? 1 : Math.Pow(monoStaminaSkill / staminaSkill, 5); + Rhythm rhythm = GetSkill(skills); + Reading reading = GetSkill(skills); + Colour colour = GetSkill(skills); + Stamina stamina = GetSkill(skills, s => !s.SingleColourStamina); + Stamina singleColourStamina = GetSkill(skills, s => s.SingleColourStamina); - double staminaDifficultStrains = stamina.CountTopWeightedStrains(); + double rhythmDifficulty = rhythm.DifficultyValue() * rhythm_skill_multiplier; + double readingDifficulty = reading.DifficultyValue() * reading_skill_multiplier; + double colourDifficulty = colour.DifficultyValue() * colour_skill_multiplier; + double staminaDifficulty = stamina.DifficultyValue() * stamina_skill_multiplier; + double singleColourStaminaDifficulty = singleColourStamina.DifficultyValue() * stamina_skill_multiplier; - // As we don't have pattern integration in osu!taiko, we apply the other two skills relative to rhythm. - patternMultiplier = Math.Pow(staminaSkill * colourSkill, 0.10); + double combinedStaminaDifficulty = Math.Pow(staminaDifficulty, 1.1) + Math.Pow(singleColourStaminaDifficulty, 1.1); - strainLengthBonus = 1 + 0.15 * DifficultyCalculationUtils.ReverseLerp(staminaDifficultStrains, 1000, 1555); + double combinedDifficulty = Math.Pow(rhythmDifficulty, 1.1) + + Math.Pow(readingDifficulty, 1.1) + + Math.Pow(colourDifficulty, 1.1) + + combinedStaminaDifficulty; - double combinedRating = combinedDifficultyValue(rhythm, reading, colour, stamina, out double consistencyFactor); - double starRating = rescale(combinedRating * 1.4); + double starRating = Math.Pow(combinedDifficulty, 1 / 1.1); - // Calculate proportional contribution of each skill to the combinedRating. - double skillRating = starRating / (rhythmSkill + readingSkill + colourSkill + staminaSkill); - - double rhythmDifficulty = rhythmSkill * skillRating; - double readingDifficulty = readingSkill * skillRating; - double colourDifficulty = colourSkill * skillRating; - double staminaDifficulty = staminaSkill * skillRating; - double mechanicalDifficulty = colourDifficulty + staminaDifficulty; // Mechanical difficulty is the sum of colour and stamina difficulties. + HitWindows hitWindows = new TaikoHitWindows(); + hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); - TaikoDifficultyAttributes attributes = new TaikoDifficultyAttributes + return new TaikoDifficultyAttributes { StarRating = starRating, Mods = mods, - MechanicalDifficulty = mechanicalDifficulty, + StaminaDifficulty = staminaDifficulty, RhythmDifficulty = rhythmDifficulty, - ReadingDifficulty = readingDifficulty, ColourDifficulty = colourDifficulty, - StaminaDifficulty = staminaDifficulty, - MonoStaminaFactor = monoStaminaFactor, - StaminaTopStrains = staminaDifficultStrains, - ConsistencyFactor = consistencyFactor, - MaxCombo = beatmap.GetMaxCombo(), + ReadingDifficulty = readingDifficulty, + GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate, + MaxCombo = beatmap.GetMaxCombo() }; - - return attributes; } - /// - /// Returns the combined star rating of the beatmap, calculated using peak strains from all sections of the map. - /// - /// - /// For each section, the peak strains of all separate skills are combined into a single peak strain for the section. - /// The resulting partial rating of the beatmap is a weighted sum of the combined peaks (higher peaks are weighted more). - /// - private double combinedDifficultyValue(Rhythm rhythm, Reading reading, Colour colour, Stamina stamina, out double consistencyFactor) - { - List peaks = combinePeaks( - rhythm.GetCurrentStrainPeaks().ToList(), - reading.GetCurrentStrainPeaks().ToList(), - colour.GetCurrentStrainPeaks().ToList(), - stamina.GetCurrentStrainPeaks().ToList() - ); - - if (peaks.Count == 0) - { - consistencyFactor = 0; - return 0; - } - - double difficulty = 0; - double weight = 1; - - foreach (double strain in peaks.OrderDescending()) - { - difficulty += strain * weight; - weight *= 0.9; - } - - List hitObjectStrainPeaks = combinePeaks( - rhythm.GetObjectStrains().ToList(), - reading.GetObjectStrains().ToList(), - colour.GetObjectStrains().ToList(), - stamina.GetObjectStrains().ToList() - ); - - if (hitObjectStrainPeaks.Count == 0) - { - consistencyFactor = 0; - return 0; - } - - // The average of the top 5% of strain peaks from hit objects. - double topAverageHitObjectStrain = hitObjectStrainPeaks.OrderDescending().Take(1 + hitObjectStrainPeaks.Count / 20).Average(); - - // Calculates a consistency factor as the sum of difficulty from hit objects compared to if every object were as hard as the hardest. - // The top average strain is used instead of the very hardest to prevent exceptionally hard objects lowering the factor. - consistencyFactor = hitObjectStrainPeaks.Sum() / (topAverageHitObjectStrain * hitObjectStrainPeaks.Count); - - return difficulty; - } - - /// - /// Combines lists of peak strains from multiple skills into a list of single peak strains for each section. - /// - private List combinePeaks(List rhythmPeaks, List readingPeaks, List colourPeaks, List staminaPeaks) - { - var combinedPeaks = new List(); - - for (int i = 0; i < colourPeaks.Count; i++) - { - double rhythmPeak = rhythmPeaks[i] * rhythm_skill_multiplier * patternMultiplier; - double readingPeak = readingPeaks[i] * reading_skill_multiplier; - double colourPeak = isRelax ? 0 : colourPeaks[i] * colour_skill_multiplier; // There is no colour difficulty in relax. - double staminaPeak = staminaPeaks[i] * stamina_skill_multiplier * strainLengthBonus; - staminaPeak /= isConvert || isRelax ? 1.5 : 1.0; // Available finger count is increased by 150%, thus we adjust accordingly. - - double peak = DifficultyCalculationUtils.Norm(2, DifficultyCalculationUtils.Norm(1.5, colourPeak, staminaPeak), rhythmPeak, readingPeak); - - // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). - // These sections will not contribute to the difficulty. - if (peak > 0) - combinedPeaks.Add(peak); - } - - return combinedPeaks; - } - - /// - /// Applies a final re-scaling of the star rating. - /// - /// The raw star rating value before re-scaling. - private static double rescale(double sr) - { - if (sr < 0) - return sr; - - return 10.43 * Math.Log(sr / 8 + 1); - } + protected override DifficultyAttributes CreateEmptyAttributes() => new TaikoDifficultyAttributes(); } } diff --git a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs index 90671093a522..7be77dbe69dc 100644 --- a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs +++ b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs @@ -237,6 +237,7 @@ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clo { throw new NotImplementedException(); } + protected override DifficultyAttributes CreateEmptyAttributes() => new DifficultyAttributes(); } } } diff --git a/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs b/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs index 15410b52f06b..bd90fa53ed2f 100644 --- a/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs +++ b/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs @@ -206,6 +206,7 @@ public override void Process(DifficultyHitObject current) public override double DifficultyValue() => 1; } + protected override DifficultyAttributes CreateEmptyAttributes() => new DifficultyAttributes(); } private class TestDifficultyAttributes : DifficultyAttributes diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapAttributeText.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapAttributeText.cs index 7e68ec70fca6..7a6d87060a0a 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapAttributeText.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapAttributeText.cs @@ -215,6 +215,7 @@ protected override IEnumerable CreateDifficultyHitObjects(I protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => []; + protected override DifficultyAttributes CreateEmptyAttributes() => new DifficultyAttributes(); } private class TestPerformanceCalculator : PerformanceCalculator diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs index caf8dc048a17..6632837aedce 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs @@ -1,10 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using System; using System.Collections.Generic; using System.IO; -using System.Linq; + using osu.Framework.Extensions; using osu.Framework.Logging; using osu.Game.Audio; @@ -51,7 +52,7 @@ public class LegacyBeatmapDecoder : LegacyDecoder public static void Register() { - AddDecoder(@"osu file format v", m => new LegacyBeatmapDecoder(Parsing.ParseInt(m.Split('v').Last()))); + AddDecoder(@"osu file format v", m => new LegacyBeatmapDecoder(Parsing.ParseInt(m.AsSpan(m.LastIndexOf('v') + 1)))); SetFallbackDecoder(() => new LegacyBeatmapDecoder()); } @@ -170,21 +171,36 @@ private void applySamples(HitObject hitObject) { SampleControlPoint sampleControlPoint = (beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePointAt(hitObject.StartTime + CONTROL_POINT_LENIENCY + 1) ?? SampleControlPoint.DEFAULT; - hitObject.Samples = hitObject.Samples.Select(sampleControlPoint.ApplyTo).ToList(); + var appliedSamples = new List(); + foreach (var s in hitObject.Samples) + { + appliedSamples.Add(sampleControlPoint.ApplyTo(s)); + } + hitObject.Samples = appliedSamples; for (int i = 0; i < hasRepeats.NodeSamples.Count; i++) { double time = hitObject.StartTime + i * hasRepeats.Duration / hasRepeats.SpanCount() + CONTROL_POINT_LENIENCY; var nodeSamplePoint = (beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePointAt(time) ?? SampleControlPoint.DEFAULT; - hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Select(nodeSamplePoint.ApplyTo).ToList(); + var appliedNodeSamples = new List(); + foreach (var s in hasRepeats.NodeSamples[i]) + { + appliedNodeSamples.Add(nodeSamplePoint.ApplyTo(s)); + } + hasRepeats.NodeSamples[i] = appliedNodeSamples; } } else { SampleControlPoint sampleControlPoint = (beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePointAt(hitObject.GetEndTime() + CONTROL_POINT_LENIENCY) ?? SampleControlPoint.DEFAULT; - hitObject.Samples = hitObject.Samples.Select(sampleControlPoint.ApplyTo).ToList(); + var appliedSamples = new List(); + foreach (var s in hitObject.Samples) + { + appliedSamples.Add(sampleControlPoint.ApplyTo(s)); + } + hitObject.Samples = appliedSamples; } } @@ -204,7 +220,7 @@ internal static void ApplyLegacyDefaults(Beatmap beatmap) beatmap.BeatmapInfo.Ruleset = RulesetStore?.GetRuleset(0) ?? beatmap.BeatmapInfo.Ruleset; } - protected override void ParseLine(Beatmap beatmap, Section section, string line) + protected override void ParseLine(Beatmap beatmap, Section section, ReadOnlySpan line) { switch (section) { @@ -240,112 +256,116 @@ protected override void ParseLine(Beatmap beatmap, Section section, string line) base.ParseLine(beatmap, section, line); } - private void handleGeneral(string line) + private void handleGeneral(ReadOnlySpan line) { var pair = SplitKeyVal(line); var metadata = beatmap.BeatmapInfo.Metadata; - switch (pair.Key) + switch (pair.KeySpan) { case @"AudioFilename": metadata.AudioFile = pair.Value.ToStandardisedPath(); break; case @"AudioLeadIn": - beatmap.AudioLeadIn = Parsing.ParseInt(pair.Value); + beatmap.AudioLeadIn = Parsing.ParseInt(pair.ValueSpan); break; case @"PreviewTime": - int time = Parsing.ParseInt(pair.Value); + int time = Parsing.ParseInt(pair.ValueSpan); metadata.PreviewTime = time == -1 ? time : getOffsetTime(time); break; case @"SampleSet": - defaultSampleBank = Enum.Parse(pair.Value); + defaultSampleBank = Enum.Parse(pair.ValueSpan); break; case @"SampleVolume": - defaultSampleVolume = Parsing.ParseInt(pair.Value); + defaultSampleVolume = Parsing.ParseInt(pair.ValueSpan); break; case @"StackLeniency": - beatmap.StackLeniency = Parsing.ParseFloat(pair.Value); + beatmap.StackLeniency = Parsing.ParseFloat(pair.ValueSpan); break; case @"Mode": - beatmap.BeatmapInfo.Ruleset = RulesetStore?.GetRuleset(Parsing.ParseInt(pair.Value)) ?? throw new ArgumentException("Ruleset is not available locally."); + beatmap.BeatmapInfo.Ruleset = RulesetStore?.GetRuleset(Parsing.ParseInt(pair.ValueSpan)) ?? throw new ArgumentException("Ruleset is not available locally."); break; case @"LetterboxInBreaks": - beatmap.LetterboxInBreaks = Parsing.ParseInt(pair.Value) == 1; + beatmap.LetterboxInBreaks = Parsing.ParseInt(pair.ValueSpan) == 1; break; case @"SpecialStyle": - beatmap.SpecialStyle = Parsing.ParseInt(pair.Value) == 1; + beatmap.SpecialStyle = Parsing.ParseInt(pair.ValueSpan) == 1; break; case @"WidescreenStoryboard": - beatmap.WidescreenStoryboard = Parsing.ParseInt(pair.Value) == 1; + beatmap.WidescreenStoryboard = Parsing.ParseInt(pair.ValueSpan) == 1; break; case @"EpilepsyWarning": - beatmap.EpilepsyWarning = Parsing.ParseInt(pair.Value) == 1; + beatmap.EpilepsyWarning = Parsing.ParseInt(pair.ValueSpan) == 1; break; case @"SamplesMatchPlaybackRate": - beatmap.SamplesMatchPlaybackRate = Parsing.ParseInt(pair.Value) == 1; + beatmap.SamplesMatchPlaybackRate = Parsing.ParseInt(pair.ValueSpan) == 1; break; case @"Countdown": - beatmap.Countdown = Enum.Parse(pair.Value); + beatmap.Countdown = Enum.Parse(pair.ValueSpan); break; case @"CountdownOffset": - beatmap.CountdownOffset = Parsing.ParseInt(pair.Value); + beatmap.CountdownOffset = Parsing.ParseInt(pair.ValueSpan); break; } } - private void handleEditor(string line) + private void handleEditor(ReadOnlySpan line) { var pair = SplitKeyVal(line); - switch (pair.Key) + switch (pair.KeySpan) { case @"Bookmarks": - beatmap.Bookmarks = pair.Value.Split(',').Select(v => + var bookmarkList = new List(); + Span bookmarkRanges = stackalloc Range[128]; + int bookmarkCount = pair.ValueSpan.Split(bookmarkRanges, ','); + for (int j = 0; j < bookmarkCount; j++) { - bool result = int.TryParse(v, out int val); - return new { result, val }; - }).Where(p => p.result).Select(p => p.val).ToArray(); + if (int.TryParse(pair.ValueSpan[bookmarkRanges[j]], out int val)) + bookmarkList.Add(val); + } + beatmap.Bookmarks = bookmarkList.ToArray(); break; case @"DistanceSpacing": - beatmap.DistanceSpacing = Math.Max(0, Parsing.ParseDouble(pair.Value)); + beatmap.DistanceSpacing = Math.Max(0, Parsing.ParseDouble(pair.ValueSpan)); break; case @"BeatDivisor": - beatmap.BeatmapInfo.BeatDivisor = Math.Clamp(Parsing.ParseInt(pair.Value), BindableBeatDivisor.MINIMUM_DIVISOR, BindableBeatDivisor.MAXIMUM_DIVISOR); + beatmap.BeatmapInfo.BeatDivisor = Math.Clamp(Parsing.ParseInt(pair.ValueSpan), BindableBeatDivisor.MINIMUM_DIVISOR, BindableBeatDivisor.MAXIMUM_DIVISOR); break; case @"GridSize": - beatmap.GridSize = Parsing.ParseInt(pair.Value); + beatmap.GridSize = Parsing.ParseInt(pair.ValueSpan); break; case @"TimelineZoom": - beatmap.TimelineZoom = Math.Max(0, Parsing.ParseDouble(pair.Value)); + beatmap.TimelineZoom = Math.Max(0, Parsing.ParseDouble(pair.ValueSpan)); break; } } - private void handleMetadata(string line) + private void handleMetadata(ReadOnlySpan line) { var pair = SplitKeyVal(line); var metadata = beatmap.BeatmapInfo.Metadata; - switch (pair.Key) + switch (pair.KeySpan) { case @"Title": metadata.Title = pair.Value; @@ -380,61 +400,62 @@ private void handleMetadata(string line) break; case @"BeatmapID": - beatmap.BeatmapInfo.OnlineID = Parsing.ParseInt(pair.Value); + beatmap.BeatmapInfo.OnlineID = Parsing.ParseInt(pair.ValueSpan); break; case @"BeatmapSetID": - beatmap.BeatmapInfo.BeatmapSet = new BeatmapSetInfo { OnlineID = Parsing.ParseInt(pair.Value) }; + beatmap.BeatmapInfo.BeatmapSet = new BeatmapSetInfo { OnlineID = Parsing.ParseInt(pair.ValueSpan) }; break; } } - private void handleDifficulty(string line) + private void handleDifficulty(ReadOnlySpan line) { var pair = SplitKeyVal(line); var difficulty = beatmap.Difficulty; - switch (pair.Key) + switch (pair.KeySpan) { case @"HPDrainRate": - difficulty.DrainRate = Parsing.ParseFloat(pair.Value); + difficulty.DrainRate = Parsing.ParseFloat(pair.ValueSpan); break; case @"CircleSize": - difficulty.CircleSize = Parsing.ParseFloat(pair.Value); + difficulty.CircleSize = Parsing.ParseFloat(pair.ValueSpan); break; case @"OverallDifficulty": - difficulty.OverallDifficulty = Parsing.ParseFloat(pair.Value); + difficulty.OverallDifficulty = Parsing.ParseFloat(pair.ValueSpan); if (!hasApproachRate) difficulty.ApproachRate = difficulty.OverallDifficulty; break; case @"ApproachRate": - difficulty.ApproachRate = Parsing.ParseFloat(pair.Value); + difficulty.ApproachRate = Parsing.ParseFloat(pair.ValueSpan); hasApproachRate = true; break; case @"SliderMultiplier": - difficulty.SliderMultiplier = Parsing.ParseDouble(pair.Value); + difficulty.SliderMultiplier = Parsing.ParseDouble(pair.ValueSpan); break; case @"SliderTickRate": - difficulty.SliderTickRate = Parsing.ParseDouble(pair.Value); + difficulty.SliderTickRate = Parsing.ParseDouble(pair.ValueSpan); break; } } - private void handleEvent(string line) + private void handleEvent(ReadOnlySpan line) { - string[] split = line.Split(','); + Span ranges = stackalloc Range[32]; + int count = line.Split(ranges, ','); // Until we have full storyboard encoder coverage, let's track any lines which aren't handled // and store them to a temporary location such that they aren't lost on editor save / export. bool lineSupportedByEncoder = false; - if (Enum.TryParse(split[0], out LegacyEventType type)) + if (Enum.TryParse(line[ranges[0]], out LegacyEventType type)) { switch (type) { @@ -444,14 +465,14 @@ private void handleEvent(string line) // Allow the first sprite (by file order) to act as the background in such cases. if (string.IsNullOrEmpty(beatmap.BeatmapInfo.Metadata.BackgroundFile)) { - beatmap.BeatmapInfo.Metadata.BackgroundFile = CleanFilename(split[3]); + beatmap.BeatmapInfo.Metadata.BackgroundFile = CleanFilename(line[ranges[3]].ToString()); lineSupportedByEncoder = true; } break; case LegacyEventType.Video: - string filename = CleanFilename(split[2]); + string filename = CleanFilename(line[ranges[2]].ToString()); // Some very old beatmaps had incorrect type specifications for their backgrounds (ie. using 1 for VIDEO // instead of 0 for BACKGROUND). To handle this gracefully, check the file extension against known supported @@ -465,13 +486,13 @@ private void handleEvent(string line) break; case LegacyEventType.Background: - beatmap.BeatmapInfo.Metadata.BackgroundFile = CleanFilename(split[2]); + beatmap.BeatmapInfo.Metadata.BackgroundFile = CleanFilename(line[ranges[2]].ToString()); lineSupportedByEncoder = true; break; case LegacyEventType.Break: - double start = getOffsetTime(Parsing.ParseDouble(split[1])); - double end = Math.Max(start, getOffsetTime(Parsing.ParseDouble(split[2]))); + double start = getOffsetTime(Parsing.ParseDouble(line[ranges[1]])); + double end = Math.Max(start, getOffsetTime(Parsing.ParseDouble(line[ranges[2]]))); beatmap.Breaks.Add(new BreakPeriod(start, end)); lineSupportedByEncoder = true; @@ -480,47 +501,48 @@ private void handleEvent(string line) } if (!lineSupportedByEncoder) - beatmap.UnhandledEventLines.Add(line); + beatmap.UnhandledEventLines.Add(line.ToString()); } - private void handleTimingPoint(string line) + private void handleTimingPoint(ReadOnlySpan line) { - string[] split = line.Split(','); + Span ranges = stackalloc Range[32]; + int count = line.Split(ranges, ','); - double time = getOffsetTime(Parsing.ParseDouble(split[0].Trim())); + double time = getOffsetTime(Parsing.ParseDouble(line[ranges[0]].Trim())); // beatLength is allowed to be NaN to handle an edge case in which some beatmaps use NaN slider velocity to disable slider tick generation (see LegacyDifficultyControlPoint). - double beatLength = Parsing.ParseDouble(split[1].Trim(), allowNaN: true); + double beatLength = Parsing.ParseDouble(line[ranges[1]].Trim(), allowNaN: true); // If beatLength is NaN, speedMultiplier should still be 1 because all comparisons against NaN are false. double speedMultiplier = beatLength < 0 ? 100.0 / -beatLength : 1; TimeSignature timeSignature = TimeSignature.SimpleQuadruple; - if (split.Length >= 3) - timeSignature = split[2][0] == '0' ? TimeSignature.SimpleQuadruple : new TimeSignature(Parsing.ParseInt(split[2])); + if (count >= 3) + timeSignature = line[ranges[2]][0] == '0' ? TimeSignature.SimpleQuadruple : new TimeSignature(Parsing.ParseInt(line[ranges[2]])); LegacySampleBank sampleSet = defaultSampleBank; - if (split.Length >= 4) - sampleSet = (LegacySampleBank)Parsing.ParseInt(split[3]); + if (count >= 4) + sampleSet = (LegacySampleBank)Parsing.ParseInt(line[ranges[3]]); int customSampleBank = 0; - if (split.Length >= 5) - customSampleBank = Parsing.ParseInt(split[4]); + if (count >= 5) + customSampleBank = Parsing.ParseInt(line[ranges[4]]); int sampleVolume = defaultSampleVolume; - if (split.Length >= 6) - sampleVolume = Parsing.ParseInt(split[5]); + if (count >= 6) + sampleVolume = Parsing.ParseInt(line[ranges[5]]); bool timingChange = true; - if (split.Length >= 7) - timingChange = split[6][0] == '1'; + if (count >= 7) + timingChange = line[ranges[6]][0] == '1'; bool kiaiMode = false; bool omitFirstBarSignature = false; - if (split.Length >= 8) + if (count >= 8) { - LegacyEffectFlags effectFlags = (LegacyEffectFlags)Parsing.ParseInt(split[7]); + LegacyEffectFlags effectFlags = (LegacyEffectFlags)Parsing.ParseInt(line[ranges[7]]); kiaiMode = effectFlags.HasFlag(LegacyEffectFlags.Kiai); omitFirstBarSignature = effectFlags.HasFlag(LegacyEffectFlags.OmitFirstBarLine); } @@ -604,7 +626,7 @@ private void flushPendingPoints() pendingControlPointTypes.Clear(); } - private void handleHitObject(string line) + private void handleHitObject(ReadOnlySpan line) { var obj = parser.Parse(line); obj.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty); diff --git a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs index 6fb762b9ee52..0bf4d8278925 100644 --- a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs @@ -1,4 +1,4 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; @@ -43,21 +43,23 @@ protected override void ParseStreamInto(LineBufferedReader stream, T output) while ((line = stream.ReadLine()) != null) { - if (ShouldSkipLine(line)) + ReadOnlySpan lineSpan = line.AsSpan(); + + if (ShouldSkipLine(lineSpan)) continue; if (section != Section.Metadata) { // comments should not be stripped from metadata lines, as the song metadata may contain "//" as valid data. - line = StripComments(line); + lineSpan = StripComments(lineSpan); } - line = line.TrimEnd(); + lineSpan = lineSpan.TrimEnd(); - if (line.StartsWith('[') && line.EndsWith(']')) + if (lineSpan.Length > 0 && lineSpan[0] == '[' && lineSpan[^1] == ']') { - if (!Enum.TryParse(line[1..^1], out section)) - Logger.Log($"Unknown section \"{line}\" in \"{output}\""); + if (!Enum.TryParse(lineSpan[1..^1], out section)) + Logger.Log($"Unknown section \"{lineSpan.ToString()}\" in \"{output}\""); OnBeginNewSection(section); continue; @@ -65,16 +67,18 @@ protected override void ParseStreamInto(LineBufferedReader stream, T output) try { - ParseLine(output, section, line); + ParseLine(output, section, lineSpan); } catch (Exception e) { - Logger.Log($"Failed to process line \"{line}\" into \"{output}\": {e.Message}"); + Logger.Log($"Failed to process line \"{lineSpan.ToString()}\" into \"{output}\": {e.Message}"); } } } - protected virtual bool ShouldSkipLine(string line) => string.IsNullOrWhiteSpace(line) || line.AsSpan().TrimStart().StartsWith("//".AsSpan(), StringComparison.Ordinal); + protected virtual bool ShouldSkipLine(string line) => ShouldSkipLine(line.AsSpan()); + + protected virtual bool ShouldSkipLine(ReadOnlySpan line) => line.IsWhiteSpace() || line.TrimStart().StartsWith("//".AsSpan(), StringComparison.Ordinal); /// /// Invoked when a new has been entered. @@ -84,7 +88,9 @@ protected virtual void OnBeginNewSection(Section section) { } - protected virtual void ParseLine(T output, Section section, string line) + protected virtual void ParseLine(T output, Section section, string line) => ParseLine(output, section, line.AsSpan()); + + protected virtual void ParseLine(T output, Section section, ReadOnlySpan line) { switch (section) { @@ -94,44 +100,51 @@ protected virtual void ParseLine(T output, Section section, string line) } } - protected string StripComments(string line) + protected string StripComments(string line) => StripComments(line.AsSpan()).ToString(); + + protected ReadOnlySpan StripComments(ReadOnlySpan line) { - int index = line.AsSpan().IndexOf("//".AsSpan()); + int index = line.IndexOf("//".AsSpan()); if (index > 0) - return line.Substring(0, index); + return line[..index]; return line; } - private Color4 convertSettingStringToColor4(string[] split, bool allowAlpha, KeyValuePair pair) + private Color4 convertSettingStringToColor4(ReadOnlySpan value, bool allowAlpha) { - if (split.Length != 3 && split.Length != 4) - throw new InvalidOperationException($@"Color specified in incorrect format (should be R,G,B or R,G,B,A): {pair.Value}"); + // Note: We're still allocating a bit here due to Color4 taking components, + // but we avoid string splitting. + + Span ranges = stackalloc Range[5]; + int count = value.Split(ranges, ','); - Color4 colour; + if (count != 3 && count != 4) + { + throw new InvalidOperationException($@"Color specified in incorrect format (should be R,G,B or R,G,B,A): {value.ToString()}"); + } try { - byte alpha = allowAlpha && split.Length == 4 ? byte.Parse(split[3]) : (byte)255; - colour = new Color4(byte.Parse(split[0]), byte.Parse(split[1]), byte.Parse(split[2]), alpha); + byte alpha = allowAlpha && count == 4 ? byte.Parse(value[ranges[3]]) : (byte)255; + return new Color4(byte.Parse(value[ranges[0]]), byte.Parse(value[ranges[1]]), byte.Parse(value[ranges[2]]), alpha); } catch { throw new InvalidOperationException(@"Color must be specified with 8-bit integer components"); } - - return colour; } - protected void HandleColours(TModel output, string line, bool allowAlpha) + protected void HandleColours(TModel output, string line, bool allowAlpha) => HandleColours(output, line.AsSpan(), allowAlpha); + + protected void HandleColours(TModel output, ReadOnlySpan line, bool allowAlpha) { var pair = SplitKeyVal(line); - string[] split = pair.Value.Split(','); - Color4 colour = convertSettingStringToColor4(split, allowAlpha, pair); + Color4 colour = convertSettingStringToColor4(pair.ValueSpan, allowAlpha); - bool isCombo = pair.Key.StartsWith(@"Combo", StringComparison.Ordinal) - && int.TryParse(pair.Key[5..], out int comboIndex) + bool isCombo = pair.KeySpan.StartsWith(@"Combo".AsSpan(), StringComparison.Ordinal) + && int.TryParse(pair.KeySpan[5..], out int comboIndex) && comboIndex >= 1 && comboIndex <= MAX_COMBO_COLOUR_COUNT; if (isCombo) @@ -148,15 +161,42 @@ protected void HandleColours(TModel output, string line, bool allowAlpha } } - protected KeyValuePair SplitKeyVal(string line, char separator = ':', bool shouldTrim = true) + protected KeyValuePair SplitKeyVal(string line, char separator = ':', bool shouldTrim = true) => SplitKeyVal(line.AsSpan(), separator, shouldTrim).ToKeyValuePair(); + + protected KeyValueSpan SplitKeyVal(ReadOnlySpan line, char separator = ':', bool shouldTrim = true) { - string[] split = line.Split(separator, 2, shouldTrim ? StringSplitOptions.TrimEntries : StringSplitOptions.None); + int index = line.IndexOf(separator); + + if (index == -1) + return new KeyValueSpan(line, ReadOnlySpan.Empty); + + ReadOnlySpan key = line[..index]; + ReadOnlySpan value = line[(index + 1)..]; + + if (shouldTrim) + { + key = key.Trim(); + value = value.Trim(); + } + + return new KeyValueSpan(key, value); + } + + protected readonly ref struct KeyValueSpan + { + public readonly ReadOnlySpan KeySpan; + public readonly ReadOnlySpan ValueSpan; + + public string Key => KeySpan.ToString(); + public string Value => ValueSpan.ToString(); + + public KeyValueSpan(ReadOnlySpan key, ReadOnlySpan value) + { + KeySpan = key; + ValueSpan = value; + } - return new KeyValuePair - ( - split[0], - split.Length > 1 ? split[1] : string.Empty - ); + public KeyValuePair ToKeyValuePair() => new KeyValuePair(Key, Value); } protected string CleanFilename(string path) => path diff --git a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs index 7ee101007972..3d9058fe35aa 100644 --- a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs @@ -3,8 +3,9 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; +using System.IO; + using osu.Framework.Graphics; using osu.Game.Beatmaps.Legacy; using osu.Game.IO; @@ -33,7 +34,7 @@ public LegacyStoryboardDecoder(int version = LATEST_VERSION) public static void Register() { // note that this isn't completely correct - AddDecoder(@"osu file format v", m => new LegacyStoryboardDecoder(Parsing.ParseInt(m.Split('v').Last()))); + AddDecoder(@"osu file format v", m => new LegacyStoryboardDecoder(Parsing.ParseInt(m.AsSpan(m.LastIndexOf('v') + 1)))); AddDecoder(@"[Events]", _ => new LegacyStoryboardDecoder()); SetFallbackDecoder(() => new LegacyStoryboardDecoder()); } @@ -55,7 +56,7 @@ protected override void ParseStreamInto(LineBufferedReader stream, Storyboard st base.ParseStreamInto(stream, storyboard); } - protected override void ParseLine(Storyboard storyboard, Section section, string line) + protected override void ParseLine(Storyboard storyboard, Section section, ReadOnlySpan line) { switch (section) { @@ -75,29 +76,31 @@ protected override void ParseLine(Storyboard storyboard, Section section, string base.ParseLine(storyboard, section, line); } - private void handleGeneral(Storyboard storyboard, string line) + private void handleGeneral(Storyboard storyboard, ReadOnlySpan line) { var pair = SplitKeyVal(line); - switch (pair.Key) + switch (pair.KeySpan) { case "UseSkinSprites": - storyboard.UseSkinSprites = pair.Value == "1"; + storyboard.UseSkinSprites = pair.ValueSpan.SequenceEqual("1".AsSpan()); break; case @"WidescreenStoryboard": - storyboard.Beatmap.WidescreenStoryboard = Parsing.ParseInt(pair.Value) == 1; + storyboard.Beatmap.WidescreenStoryboard = Parsing.ParseInt(pair.ValueSpan) == 1; break; } } - private void handleEvents(string line) + private void handleEvents(ReadOnlySpan line) { - decodeVariables(ref line); + string lineStr = line.ToString(); + decodeVariables(ref lineStr); + ReadOnlySpan lineDecoded = lineStr.AsSpan(); int depth = 0; - foreach (char c in line) + foreach (char c in lineDecoded) { if (c == ' ' || c == '_') depth++; @@ -105,9 +108,9 @@ private void handleEvents(string line) break; } - line = line.Substring(depth); + lineDecoded = lineDecoded[depth..]; - string[] split = line.Split(','); + string[] split = lineDecoded.ToString().Split(','); if (depth == 0) { @@ -370,7 +373,7 @@ private AnimationLoopType parseAnimationLoopType(string value) return Enum.IsDefined(parsed) ? parsed : AnimationLoopType.LoopForever; } - private void handleVariables(string line) + private void handleVariables(ReadOnlySpan line) { var pair = SplitKeyVal(line, '=', false); variables[pair.Key] = pair.Value; diff --git a/osu.Game/Beatmaps/Formats/Parsing.cs b/osu.Game/Beatmaps/Formats/Parsing.cs index a1683ced0d82..00e99214ecb7 100644 --- a/osu.Game/Beatmaps/Formats/Parsing.cs +++ b/osu.Game/Beatmaps/Formats/Parsing.cs @@ -1,4 +1,4 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; @@ -15,36 +15,63 @@ public static class Parsing public const double MAX_PARSE_VALUE = int.MaxValue; - public static float ParseFloat(string input, float parseLimit = (float)MAX_PARSE_VALUE, bool allowNaN = false) + public static float ParseFloat(string input, float parseLimit = (float)MAX_PARSE_VALUE, bool allowNaN = false) => ParseFloat(input.AsSpan(), parseLimit, allowNaN); + + public static float ParseFloat(ReadOnlySpan input, float parseLimit = (float)MAX_PARSE_VALUE, bool allowNaN = false) { float output = float.Parse(input, CultureInfo.InvariantCulture); - if (output < -parseLimit) throw new OverflowException("Value is too low"); - if (output > parseLimit) throw new OverflowException("Value is too high"); + if (output < -parseLimit) + { + throw new OverflowException("Value is too low"); + } + if (output > parseLimit) + { + throw new OverflowException("Value is too high"); + } - if (!allowNaN && float.IsNaN(output)) throw new FormatException("Not a number"); + if (!allowNaN && float.IsNaN(output)) + { + throw new FormatException("Not a number"); + } return output; } - public static double ParseDouble(string input, double parseLimit = MAX_PARSE_VALUE, bool allowNaN = false) + public static double ParseDouble(string input, double parseLimit = MAX_PARSE_VALUE, bool allowNaN = false) => ParseDouble(input.AsSpan(), parseLimit, allowNaN); + + public static double ParseDouble(ReadOnlySpan input, double parseLimit = MAX_PARSE_VALUE, bool allowNaN = false) { double output = double.Parse(input, CultureInfo.InvariantCulture); - if (output < -parseLimit) throw new OverflowException("Value is too low"); - if (output > parseLimit) throw new OverflowException("Value is too high"); + if (output < -parseLimit) + { + throw new OverflowException("Value is too low"); + } + if (output > parseLimit) + { + throw new OverflowException("Value is too high"); + } if (!allowNaN && double.IsNaN(output)) throw new FormatException("Not a number"); return output; } - public static int ParseInt(string input, int parseLimit = (int)MAX_PARSE_VALUE) + public static int ParseInt(string input, int parseLimit = (int)MAX_PARSE_VALUE) => ParseInt(input.AsSpan(), parseLimit); + + public static int ParseInt(ReadOnlySpan input, int parseLimit = (int)MAX_PARSE_VALUE) { int output = int.Parse(input, CultureInfo.InvariantCulture); - if (output < -parseLimit) throw new OverflowException("Value is too low"); - if (output > parseLimit) throw new OverflowException("Value is too high"); + if (output < -parseLimit) + { + throw new OverflowException("Value is too low"); + } + if (output > parseLimit) + { + throw new OverflowException("Value is too high"); + } return output; } diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index f059304c3ec1..17a85a5ebfe5 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -23,235 +23,109 @@ namespace osu.Game.Rulesets.Difficulty { public abstract class DifficultyCalculator { - /// - /// The beatmap for which difficulty will be calculated. - /// - protected IBeatmap Beatmap { get; private set; } - - /// - /// The working beatmap for which difficulty will be calculated. - /// - protected readonly IWorkingBeatmap WorkingBeatmap; - - private Mod[] playableMods; - private double clockRate; - - private readonly IRulesetInfo ruleset; - - /// - /// A yymmdd version which is used to discern when reprocessing is required. - /// public virtual int Version => 0; + protected readonly IRulesetInfo Ruleset; + protected readonly IWorkingBeatmap Beatmap; + protected DifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) { - this.ruleset = ruleset; - WorkingBeatmap = beatmap; + Ruleset = ruleset; + Beatmap = beatmap; } - /// - /// Calculates the difficulty of the beatmap with no mods applied. - /// - /// The cancellation token. - /// A structure describing the difficulty of the beatmap. public DifficultyAttributes Calculate(CancellationToken cancellationToken = default) - => Calculate([], cancellationToken); - - /// - /// Calculates the difficulty of the beatmap using a specific mod combination. - /// - /// The mods that should be applied to the beatmap. - /// The cancellation token. - /// A structure describing the difficulty of the beatmap. - public DifficultyAttributes Calculate([NotNull] IEnumerable mods, CancellationToken cancellationToken = default) { - using var timedCancellationSource = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - - if (!cancellationToken.CanBeCanceled) - cancellationToken = timedCancellationSource.Token; - - cancellationToken.ThrowIfCancellationRequested(); - // ReSharper disable once PossiblyMistakenUseOfCancellationToken - preProcess(mods, cancellationToken); - - var skills = CreateSkills(Beatmap, playableMods, clockRate); - - if (!Beatmap.HitObjects.Any()) - return CreateDifficultyAttributes(Beatmap, playableMods, skills, clockRate); - - foreach (var hitObject in getDifficultyHitObjects()) - { - foreach (var skill in skills) - { - cancellationToken.ThrowIfCancellationRequested(); - skill.Process(hitObject); - } - } - - return CreateDifficultyAttributes(Beatmap, playableMods, skills, clockRate); + return Calculate(Beatmap.Mods.Value.ToArray(), cancellationToken); } - /// - /// Calculates the difficulty of the beatmap with no mods applied and returns a set of representing the difficulty at every relevant time value in the beatmap. - /// - /// The cancellation token. - /// The set of . - public List CalculateTimed(CancellationToken cancellationToken = default) - => CalculateTimed([], cancellationToken); - - /// - /// Calculates the difficulty of the beatmap using a specific mod combination and returns a set of representing the difficulty at every relevant time value in the beatmap. - /// - /// The mods that should be applied to the beatmap. - /// The cancellation token. - /// The set of . - public List CalculateTimed([NotNull] IEnumerable mods, CancellationToken cancellationToken = default) + public DifficultyAttributes Calculate([NotNull] Mod[] mods, CancellationToken cancellationToken = default) { - using var timedCancellationSource = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - - if (!cancellationToken.CanBeCanceled) - cancellationToken = timedCancellationSource.Token; - - cancellationToken.ThrowIfCancellationRequested(); - // ReSharper disable once PossiblyMistakenUseOfCancellationToken - preProcess(mods, cancellationToken); - - var attribs = new List(); - - if (!Beatmap.HitObjects.Any()) - return attribs; - - var skills = CreateSkills(Beatmap, playableMods, clockRate); - var progressiveBeatmap = new ProgressiveCalculationBeatmap(Beatmap); - var difficultyObjects = getDifficultyHitObjects().ToArray(); - - int currentIndex = 0; - - foreach (var obj in Beatmap.HitObjects) + using (var beatmap = Beatmap.GetPlayableBeatmap(Ruleset, mods, cancellationToken)) { - progressiveBeatmap.HitObjects.Add(obj); + var skills = CreateSkills(beatmap, mods, beatmap.BeatmapInfo.Difficulty.ClockRate); - while (currentIndex < difficultyObjects.Length && difficultyObjects[currentIndex].BaseObject.GetEndTime() <= obj.GetEndTime()) + foreach (var hitObject in SortObjects(CreateDifficultyHitObjects(beatmap, beatmap.BeatmapInfo.Difficulty.ClockRate))) { - foreach (var skill in skills) - { - cancellationToken.ThrowIfCancellationRequested(); - skill.Process(difficultyObjects[currentIndex]); - } + cancellationToken.ThrowIfCancellationRequested(); - currentIndex++; + foreach (var skill in skills) + skill.Process(hitObject); } - attribs.Add(new TimedDifficultyAttributes(obj.GetEndTime(), CreateDifficultyAttributes(progressiveBeatmap, playableMods, skills, clockRate))); + return CreateDifficultyAttributes(beatmap, mods, skills, beatmap.BeatmapInfo.Difficulty.ClockRate); } - - return attribs; } - /// - /// Calculates the difficulty of the beatmap using all mod combinations applicable to the beatmap. - /// - /// - /// This can only be used to compute difficulties for legacy mod combinations. - /// - /// A collection of structures describing the difficulty of the beatmap for each mod combination. - public IEnumerable CalculateAllLegacyCombinations(CancellationToken cancellationToken = default) + public IEnumerable CalculateTimed(CancellationToken cancellationToken = default) { - var rulesetInstance = ruleset.CreateInstance(); + return CalculateTimed(Beatmap.Mods.Value.ToArray(), cancellationToken); + } - foreach (var combination in CreateDifficultyAdjustmentModCombinations()) + public IEnumerable CalculateTimed([NotNull] Mod[] mods, CancellationToken cancellationToken = default) + { + using (var beatmap = Beatmap.GetPlayableBeatmap(Ruleset, mods, cancellationToken)) { - Mod classicMod = rulesetInstance.CreateMod(); - - var finalCombination = ModUtils.FlattenMod(combination); - if (classicMod != null) - finalCombination = finalCombination.Append(classicMod); + var skills = CreateSkills(beatmap, mods, beatmap.BeatmapInfo.Difficulty.ClockRate); + var progressiveBeatmap = new ProgressiveCalculationBeatmap(beatmap); - yield return Calculate(finalCombination.ToArray(), cancellationToken); - } - } + foreach (var hitObject in SortObjects(CreateDifficultyHitObjects(beatmap, beatmap.BeatmapInfo.Difficulty.ClockRate))) + { + cancellationToken.ThrowIfCancellationRequested(); - /// - /// Retrieves the s to calculate against. - /// - private IEnumerable getDifficultyHitObjects() => SortObjects(CreateDifficultyHitObjects(Beatmap, clockRate)); + progressiveBeatmap.HitObjects.Add(hitObject.BaseObject); - /// - /// Performs required tasks before every calculation. - /// - /// The original list of s. - /// The cancellation token. - private void preProcess([NotNull] IEnumerable mods, CancellationToken cancellationToken) - { - playableMods = mods.Select(m => m.DeepClone()).ToArray(); - Beatmap = WorkingBeatmap.GetPlayableBeatmap(ruleset, playableMods, cancellationToken); + foreach (var skill in skills) + skill.Process(hitObject); - clockRate = ModUtils.CalculateRateWithMods(playableMods); + yield return CreateDifficultyAttributes(progressiveBeatmap, mods, skills, beatmap.BeatmapInfo.Difficulty.ClockRate); + } + } } - /// - /// Sorts a given set of s. - /// - /// The s to sort. - /// The sorted s. protected virtual IEnumerable SortObjects(IEnumerable input) => input.OrderBy(h => h.BaseObject.StartTime); - /// - /// Creates all combinations which adjust the difficulty. - /// public Mod[] CreateDifficultyAdjustmentModCombinations() { - return createDifficultyAdjustmentModCombinations(DifficultyAdjustmentMods, []).ToArray(); + return createDifficultyAdjustmentModCombinations(DifficultyAdjustmentMods, Array.Empty(), 0).ToArray(); static IEnumerable createDifficultyAdjustmentModCombinations(ReadOnlyMemory remainingMods, IEnumerable currentSet, int currentSetCount = 0) { - // Return the current set. switch (currentSetCount) { case 0: - // Initial-case: Empty current set yield return new ModNoMod(); - break; - case 1: yield return currentSet.Single(); - break; - default: yield return new MultiMod(currentSet.ToArray()); - break; } - // Apply the rest of the remaining mods recursively. for (int i = 0; i < remainingMods.Length; i++) { (var nextSet, int nextCount) = flatten(remainingMods.Span[i]); - // Check if any mods in the next set are incompatible with any of the current set. if (currentSet.SelectMany(m => m.IncompatibleMods).Any(c => nextSet.Any(c.IsInstanceOfType))) continue; - // Check if any mods in the next set are the same type as the current set. Mods of the exact same type are not incompatible with themselves. - if (currentSet.Any(c => nextSet.Any(n => c.GetType() == n.GetType()))) + if (currentSet.Any(c => nextSet.Any(n => n.GetType() == c.GetType()))) continue; - // If all's good, attach the next set to the current set and recurse further. foreach (var combo in createDifficultyAdjustmentModCombinations(remainingMods.Slice(i + 1), currentSet.Concat(nextSet), currentSetCount + nextCount)) yield return combo; } } - // Flattens a mod hierarchy (through MultiMod) as an IEnumerable static (IEnumerable set, int count) flatten(Mod mod) { if (!(mod is MultiMod multi)) return (mod.Yield(), 1); - IEnumerable set = []; + IEnumerable set = Array.Empty(); int count = 0; foreach (var nested in multi.Mods) @@ -265,39 +139,57 @@ static IEnumerable createDifficultyAdjustmentModCombinations(ReadOnlyMemory } } + protected virtual Mod[] DifficultyAdjustmentMods => Array.Empty(); + /// - /// Retrieves all s which adjust the difficulty. + /// Retrieves a skill of a specific type from a collection of skills. /// - protected virtual Mod[] DifficultyAdjustmentMods => []; + /// The collection of skills to search. + /// An optional predicate to filter the skills. + /// The type of skill to retrieve. + protected static T GetSkill(IEnumerable skills, Func predicate = null) where T : Skill + { + T found = findSkill(skills, predicate); + return found ?? throw new InvalidOperationException($@"Could not find {typeof(T).Name}."); + } /// - /// Creates to describe beatmap's calculated difficulty. + /// Retrieves a skill of a specific type from a collection of skills, or null if not found. /// - /// The whose difficulty was calculated. - /// This may differ from in the case of timed calculation. - /// The s that difficulty was calculated with. - /// The skills which processed the beatmap. - /// The rate at which the gameplay clock is run at. + /// The collection of skills to search. + /// An optional predicate to filter the skills. + /// The type of skill to retrieve. + protected static T GetSkillOrDefault(IEnumerable skills, Func predicate = null) where T : Skill + { + return findSkill(skills, predicate); + } + + private static T findSkill(IEnumerable skills, Func predicate = null) where T : Skill + { + T found = null; + + foreach (var s in skills) + { + if (s is T t && (predicate == null || predicate(t))) + { + if (found != null) + throw new InvalidOperationException($@"Found more than one {typeof(T).Name}."); + + found = t; + } + } + + return found; + } + protected abstract DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate); - /// - /// Enumerates s to be processed from s in the . - /// - /// The providing the s to enumerate. - /// The rate at which the gameplay clock is run at. - /// The enumerated s. protected abstract IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate); - /// - /// Creates the s to calculate the difficulty of an . - /// - /// The whose difficulty will be calculated. - /// This may differ from in the case of timed calculation. - /// Mods to calculate difficulty with. - /// Clockrate to calculate difficulty with. - /// The s. protected abstract Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate); + protected abstract DifficultyAttributes CreateEmptyAttributes(); + /// /// Used to calculate timed difficulty attributes, where only a subset of hitobjects should be visible at any point in time. /// diff --git a/osu.Game/Rulesets/Objects/HitObjectParser.cs b/osu.Game/Rulesets/Objects/HitObjectParser.cs index c6e250bd7405..9faf92b5586b 100644 --- a/osu.Game/Rulesets/Objects/HitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/HitObjectParser.cs @@ -1,10 +1,14 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; + namespace osu.Game.Rulesets.Objects { public abstract class HitObjectParser { - public abstract HitObject? Parse(string text); + public virtual HitObject? Parse(string text) => Parse(text.AsSpan()); + + public abstract HitObject? Parse(ReadOnlySpan text); } } diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index 7dde7310cebc..c374d6cf94e7 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; + using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Beatmaps.ControlPoints; @@ -49,18 +50,18 @@ internal ConvertHitObjectParser(double offset, int formatVersion) this.formatVersion = formatVersion; } - public override HitObject Parse(string text) + public override HitObject Parse(ReadOnlySpan text) { - string[] split = text.Split(','); + Span ranges = stackalloc Range[32]; int count = text.Split(ranges, ','); Vector2 pos = formatVersion >= LegacyBeatmapEncoder.FIRST_LAZER_VERSION - ? new Vector2(Parsing.ParseFloat(split[0], Parsing.MAX_COORDINATE_VALUE), Parsing.ParseFloat(split[1], Parsing.MAX_COORDINATE_VALUE)) - : new Vector2((int)Parsing.ParseFloat(split[0], Parsing.MAX_COORDINATE_VALUE), (int)Parsing.ParseFloat(split[1], Parsing.MAX_COORDINATE_VALUE)); + ? new Vector2(Parsing.ParseFloat(text[ranges[0]], Parsing.MAX_COORDINATE_VALUE), Parsing.ParseFloat(text[ranges[1]], Parsing.MAX_COORDINATE_VALUE)) + : new Vector2((int)Parsing.ParseFloat(text[ranges[0]], Parsing.MAX_COORDINATE_VALUE), (int)Parsing.ParseFloat(text[ranges[1]], Parsing.MAX_COORDINATE_VALUE)); - double startTime = Parsing.ParseDouble(split[2]) + offset; + double startTime = Parsing.ParseDouble(text[ranges[2]]) + offset; - LegacyHitObjectType type = (LegacyHitObjectType)Parsing.ParseInt(split[3]); + LegacyHitObjectType type = (LegacyHitObjectType)Parsing.ParseInt(text[ranges[3]]); int comboOffset = (int)(type & LegacyHitObjectType.ComboOffset) >> 4; type &= ~LegacyHitObjectType.ComboOffset; @@ -68,7 +69,7 @@ public override HitObject Parse(string text) bool combo = type.HasFlag(LegacyHitObjectType.NewCombo); type &= ~LegacyHitObjectType.NewCombo; - var soundType = (LegacyHitSoundType)Parsing.ParseInt(split[4]); + var soundType = (LegacyHitSoundType)Parsing.ParseInt(text[ranges[4]]); var bankInfo = new SampleBankInfo(); ConvertHitObject? result = null; @@ -77,14 +78,14 @@ public override HitObject Parse(string text) { result = createHitCircle(pos, combo, comboOffset); - if (split.Length > 5) - readCustomSampleBanks(split[5], bankInfo); + if (count > 5) + readCustomSampleBanks(text[ranges[5]], bankInfo); } else if (type.HasFlag(LegacyHitObjectType.Slider)) { double? length = null; - int repeatCount = Parsing.ParseInt(split[6]); + int repeatCount = Parsing.ParseInt(text[ranges[6]]); if (repeatCount > 9000) throw new FormatException(@"Repeat count is way too high"); @@ -92,15 +93,15 @@ public override HitObject Parse(string text) // osu-stable treated the first span of the slider as a repeat, but no repeats are happening repeatCount = Math.Max(0, repeatCount - 1); - if (split.Length > 7) + if (count > 7) { - length = Math.Max(0, Parsing.ParseDouble(split[7], Parsing.MAX_COORDINATE_VALUE)); + length = Math.Max(0, Parsing.ParseDouble(text[ranges[7]], Parsing.MAX_COORDINATE_VALUE)); if (length == 0) length = null; } - if (split.Length > 10) - readCustomSampleBanks(split[10], bankInfo, true); + if (count > 10) + readCustomSampleBanks(text[ranges[10]], bankInfo, true); // One node for each repeat + the start and end nodes int nodes = repeatCount + 2; @@ -111,9 +112,9 @@ public override HitObject Parse(string text) nodeBankInfos.Add(bankInfo.Clone()); // Read any per-node sample banks - if (split.Length > 9 && split[9].Length > 0) + if (count > 9 && text[ranges[9]].Length > 0) { - string[] sets = split[9].Split('|'); + string[] sets = text[ranges[9]].ToString().Split('|'); for (int i = 0; i < nodes; i++) { @@ -131,9 +132,9 @@ public override HitObject Parse(string text) nodeSoundTypes.Add(soundType); // Read any per-node sound types - if (split.Length > 8 && split[8].Length > 0) + if (count > 8 && text[ranges[8]].Length > 0) { - string[] adds = split[8].Split('|'); + string[] adds = text[ranges[8]].ToString().Split('|'); for (int i = 0; i < nodes; i++) { @@ -150,35 +151,43 @@ public override HitObject Parse(string text) for (int i = 0; i < nodes; i++) nodeSamples.Add(convertSoundType(nodeSoundTypes[i], nodeBankInfos[i])); - result = createSlider(pos, combo, comboOffset, convertPathString(split[5], pos), length, repeatCount, nodeSamples); + result = createSlider(pos, combo, comboOffset, convertPathString(text[ranges[5]].ToString(), pos), length, repeatCount, nodeSamples); } else if (type.HasFlag(LegacyHitObjectType.Spinner)) { - double duration = Math.Max(0, Parsing.ParseDouble(split[5]) + offset - startTime); + double duration = Math.Max(0, Parsing.ParseDouble(text[ranges[5]]) + offset - startTime); result = createSpinner(new Vector2(512, 384) / 2, combo, duration); - if (split.Length > 6) - readCustomSampleBanks(split[6], bankInfo); + if (count > 6) + readCustomSampleBanks(text[ranges[6]], bankInfo); } else if (type.HasFlag(LegacyHitObjectType.Hold)) { // Note: Hold is generated by BMS converts - double endTime = Math.Max(startTime, Parsing.ParseDouble(split[2])); + double endTime = Math.Max(startTime, Parsing.ParseDouble(text[ranges[2]])); - if (split.Length > 5 && !string.IsNullOrEmpty(split[5])) + if (count > 5 && !text[ranges[5]].IsEmpty) { - string[] ss = split[5].Split(':'); - endTime = Math.Max(startTime, Parsing.ParseDouble(ss[0])); - readCustomSampleBanks(string.Join(':', ss.Skip(1)), bankInfo); + ReadOnlySpan ss = text[ranges[5]]; + int firstColon = ss.IndexOf(':'); + if (firstColon == -1) + { + endTime = Math.Max(startTime, Parsing.ParseDouble(ss)); + } + else + { + endTime = Math.Max(startTime, Parsing.ParseDouble(ss[..firstColon])); + readCustomSampleBanks(ss[(firstColon + 1)..], bankInfo); + } } result = createHold(pos, endTime + offset - startTime); } if (result == null) - throw new InvalidDataException($"Unknown hit object type: {split[3]}"); + throw new InvalidDataException($"Unknown hit object type: {text[ranges[3]]}"); result.StartTime = startTime; result.LegacyType = type; @@ -191,18 +200,18 @@ public override HitObject Parse(string text) return result; } - private void readCustomSampleBanks(string str, SampleBankInfo bankInfo, bool banksOnly = false) + private void readCustomSampleBanks(ReadOnlySpan str, SampleBankInfo bankInfo, bool banksOnly = false) { - if (string.IsNullOrEmpty(str)) + if (str.IsEmpty) return; - string[] split = str.Split(':'); + Span ranges = stackalloc Range[8]; int count = str.Split(ranges, ':'); - var bank = (LegacySampleBank)Parsing.ParseInt(split[0]); + var bank = (LegacySampleBank)Parsing.ParseInt(str[ranges[0]]); if (!Enum.IsDefined(bank)) bank = LegacySampleBank.Normal; - var addBank = (LegacySampleBank)Parsing.ParseInt(split[1]); + var addBank = (LegacySampleBank)Parsing.ParseInt(str[ranges[1]]); if (!Enum.IsDefined(addBank)) addBank = LegacySampleBank.Normal; @@ -225,13 +234,13 @@ private void readCustomSampleBanks(string str, SampleBankInfo bankInfo, bool ban if (banksOnly) return; - if (split.Length > 2) - bankInfo.CustomSampleBank = Parsing.ParseInt(split[2]); + if (count > 2) + bankInfo.CustomSampleBank = Parsing.ParseInt(str[ranges[2]]); - if (split.Length > 3) - bankInfo.Volume = Math.Max(0, Parsing.ParseInt(split[3])); + if (count > 3) + bankInfo.Volume = Math.Max(0, Parsing.ParseInt(str[ranges[3]])); - bankInfo.Filename = split.Length > 4 ? split[4] : null; + bankInfo.Filename = count > 4 ? str[ranges[4]].ToString() : null; } private PathType convertPathType(string input) diff --git a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs index 273974338716..4955e0061d2a 100644 --- a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs +++ b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs @@ -31,12 +31,12 @@ protected override void OnBeginNewSection(Section section) currentConfig = null; } - protected override void ParseLine(List output, Section section, string line) + protected override void ParseLine(List output, Section section, ReadOnlySpan line) { switch (section) { case Section.Mania: - var pair = SplitKeyVal(line); + var pair = SplitKeyVal(line.ToString()); switch (pair.Key) { @@ -52,7 +52,7 @@ protected override void ParseLine(List output, Sec break; default: - pendingLines.Add(line); + pendingLines.Add(line.ToString()); // Hold all lines until a "Keys" item is found. if (currentConfig != null) @@ -70,7 +70,7 @@ private void flushPendingLines() foreach (string line in pendingLines) { - var pair = SplitKeyVal(line); + var pair = SplitKeyVal(line.ToString()); switch (pair.Key) { diff --git a/osu.Game/Skinning/LegacySkinDecoder.cs b/osu.Game/Skinning/LegacySkinDecoder.cs index 1270f693390b..c0f049967984 100644 --- a/osu.Game/Skinning/LegacySkinDecoder.cs +++ b/osu.Game/Skinning/LegacySkinDecoder.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Globalization; using osu.Game.Beatmaps.Formats; @@ -13,11 +14,11 @@ public LegacySkinDecoder() { } - protected override void ParseLine(SkinConfiguration skin, Section section, string line) + protected override void ParseLine(SkinConfiguration skin, Section section, ReadOnlySpan line) { if (section != Section.Colours) { - var pair = SplitKeyVal(line); + var pair = SplitKeyVal(line.ToString()); switch (section) { diff --git a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs index 95c7b8355c9c..3c4e738e4c17 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. + + using System; using System.Collections.Generic; using System.Linq; @@ -9,7 +11,6 @@ using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; - namespace osu.Game.Tests.Visual.OnlinePlay { public interface IAPIRequestHandler