Path: blob/master/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs
2269 views
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using osuTK; using osu.Game.Rulesets.Objects.Types; using System; using System.Collections.Generic; using System.IO; using osu.Game.Beatmaps.Formats; using osu.Game.Audio; using System.Linq; using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Legacy; using osu.Game.Skinning; using osu.Game.Utils; using System.Buffers; namespace osu.Game.Rulesets.Objects.Legacy { /// <summary> /// A HitObjectParser to parse legacy Beatmaps. /// </summary> public class ConvertHitObjectParser : HitObjectParser { /// <summary> /// The offset to apply to all time values. /// </summary> private readonly double offset; /// <summary> /// The .osu format (beatmap) version. /// </summary> private readonly int formatVersion; /// <summary> /// Whether the current hitobject is the first hitobject in the beatmap. /// </summary> private bool firstObject = true; /// <summary> /// The last parsed hitobject. /// </summary> private ConvertHitObject? lastObject; internal ConvertHitObjectParser(double offset, int formatVersion) { this.offset = offset; this.formatVersion = formatVersion; } public override HitObject Parse(string text) { string[] split = text.Split(','); 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)); double startTime = Parsing.ParseDouble(split[2]) + offset; LegacyHitObjectType type = (LegacyHitObjectType)Parsing.ParseInt(split[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(split[4]); var bankInfo = new SampleBankInfo(); ConvertHitObject? result = null; if (type.HasFlag(LegacyHitObjectType.Circle)) { result = createHitCircle(pos, combo, comboOffset); if (split.Length > 5) readCustomSampleBanks(split[5], bankInfo); } else if (type.HasFlag(LegacyHitObjectType.Slider)) { double? length = null; int repeatCount = Parsing.ParseInt(split[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 (split.Length > 7) { length = Math.Max(0, Parsing.ParseDouble(split[7], Parsing.MAX_COORDINATE_VALUE)); if (length == 0) length = null; } if (split.Length > 10) readCustomSampleBanks(split[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<SampleBankInfo>(); for (int i = 0; i < nodes; i++) nodeBankInfos.Add(bankInfo.Clone()); // Read any per-node sample banks if (split.Length > 9 && split[9].Length > 0) { string[] sets = split[9].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<LegacyHitSoundType>(); for (int i = 0; i < nodes; i++) nodeSoundTypes.Add(soundType); // Read any per-node sound types if (split.Length > 8 && split[8].Length > 0) { string[] adds = split[8].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<IList<HitSampleInfo>>(nodes); 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); } else if (type.HasFlag(LegacyHitObjectType.Spinner)) { double duration = Math.Max(0, Parsing.ParseDouble(split[5]) + offset - startTime); result = createSpinner(new Vector2(512, 384) / 2, combo, duration); if (split.Length > 6) readCustomSampleBanks(split[6], bankInfo); } else if (type.HasFlag(LegacyHitObjectType.Hold)) { // Note: Hold is generated by BMS converts double endTime = Math.Max(startTime, Parsing.ParseDouble(split[2])); if (split.Length > 5 && !string.IsNullOrEmpty(split[5])) { string[] ss = split[5].Split(':'); endTime = Math.Max(startTime, Parsing.ParseDouble(ss[0])); readCustomSampleBanks(string.Join(':', ss.Skip(1)), bankInfo); } result = createHold(pos, endTime + offset - startTime); } if (result == null) throw new InvalidDataException($"Unknown hit object type: {split[3]}"); result.StartTime = startTime; result.LegacyType = type; if (result.Samples.Count == 0) result.Samples = convertSoundType(soundType, bankInfo); firstObject = false; return result; } private void readCustomSampleBanks(string str, SampleBankInfo bankInfo, bool banksOnly = false) { if (string.IsNullOrEmpty(str)) return; string[] split = str.Split(':'); var bank = (LegacySampleBank)Parsing.ParseInt(split[0]); if (!Enum.IsDefined(bank)) bank = LegacySampleBank.Normal; var addBank = (LegacySampleBank)Parsing.ParseInt(split[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 (split.Length > 2) bankInfo.CustomSampleBank = Parsing.ParseInt(split[2]); if (split.Length > 3) bankInfo.Volume = Math.Max(0, Parsing.ParseInt(split[3])); bankInfo.Filename = split.Length > 4 ? split[4] : 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; } } /// <summary> /// Converts a given point string into a set of path control points. /// </summary> /// <remarks> /// A point string takes the form: X|1:1|2:2|2:2|3:3|Y|1:1|2:2. /// This has three segments: /// <list type="number"> /// <item> /// <description>X: { (1,1), (2,2) } (implicit segment)</description> /// </item> /// <item> /// <description>X: { (2,2), (3,3) } (implicit segment)</description> /// </item> /// <item> /// <description>Y: { (3,3), (1,1), (2, 2) } (explicit segment)</description> /// </item> /// </list> /// </remarks> /// <param name="pointString">The point string.</param> /// <param name="offset">The positional offset to apply to the control points.</param> /// <returns>All control points in the resultant path.</returns> 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<Vector2>.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<ArraySegment<PathControlPoint>>(pointsCount); var allPoints = new ArraySegment<Vector2>(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<Vector2>.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; } } /// <summary> /// Converts a given point list into a set of path segments. /// </summary> /// <param name="type">The path type of the point list.</param> /// <param name="points">The point list.</param> /// <param name="endPoint">Any extra endpoint to consider as part of the points. This will NOT be returned.</param> /// <returns>The set of points contained by <paramref name="points"/> as one or more segments of the path.</returns> private IEnumerable<ArraySegment<PathControlPoint>> convertPoints(PathType type, ArraySegment<Vector2> 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<PathControlPoint>(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<PathControlPoint>(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<ArraySegment<PathControlPoint>> 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; } /// <summary> /// Creates a legacy Hit-type hit object. /// </summary> /// <param name="position">The position of the hit object.</param> /// <param name="newCombo">Whether the hit object creates a new combo.</param> /// <param name="comboOffset">When starting a new combo, the offset of the new combo relative to the current one.</param> /// <returns>The hit object.</returns> 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 }; } /// <summary> /// Creats a legacy Slider-type hit object. /// </summary> /// <param name="position">The position of the hit object.</param> /// <param name="newCombo">Whether the hit object creates a new combo.</param> /// <param name="comboOffset">When starting a new combo, the offset of the new combo relative to the current one.</param> /// <param name="controlPoints">The slider control points.</param> /// <param name="length">The slider length.</param> /// <param name="repeatCount">The slider repeat count.</param> /// <param name="nodeSamples">The samples to be played when the slider nodes are hit. This includes the head and tail of the slider.</param> /// <returns>The hit object.</returns> private ConvertHitObject createSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount, IList<IList<HitSampleInfo>> nodeSamples) { return lastObject = new ConvertSlider { Position = position, NewCombo = firstObject || lastObject is ConvertSpinner || newCombo, ComboOffset = newCombo ? comboOffset : 0, Path = new SliderPath(controlPoints, length), NodeSamples = nodeSamples, RepeatCount = repeatCount }; } /// <summary> /// Creates a legacy Spinner-type hit object. /// </summary> /// <param name="position">The position of the hit object.</param> /// <param name="newCombo">Whether the hit object creates a new combo.</param> /// <param name="duration">The spinner duration.</param> /// <returns>The hit object.</returns> private ConvertHitObject createSpinner(Vector2 position, bool newCombo, double duration) { return lastObject = new ConvertSpinner { Position = position, Duration = duration, NewCombo = newCombo // Spinners cannot have combo offset. }; } /// <summary> /// Creates a legacy Hold-type hit object. /// </summary> /// <param name="position">The position of the hit object.</param> /// <param name="duration">The hold duration.</param> private ConvertHitObject createHold(Vector2 position, double duration) { return lastObject = new ConvertHold { Position = position, Duration = duration }; } private List<HitSampleInfo> convertSoundType(LegacyHitSoundType type, SampleBankInfo bankInfo) { var soundTypes = new List<HitSampleInfo>(); 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 { // Todo: This should set the normal SampleInfo if the specified sample file isn't found, but that's a pretty edge-case scenario 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 { /// <summary> /// An optional overriding filename which causes all bank/sample specifications to be ignored. /// </summary> public string? Filename; /// <summary> /// The bank identifier to use for the base ("hitnormal") sample. /// Transferred to <see cref="HitSampleInfo.Bank"/> when appropriate. /// </summary> public string? BankForNormal; /// <summary> /// The bank identifier to use for additions ("hitwhistle", "hitfinish", "hitclap"). /// Transferred to <see cref="HitSampleInfo.Bank"/> when appropriate. /// </summary> public string? BankForAdditions; /// <summary> /// Hit sample volume (0-100). /// See <see cref="HitSampleInfo.Volume"/>. /// </summary> public int Volume; /// <summary> /// 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 <see cref="HitSampleInfo.Suffix"/>. /// </summary> public int CustomSampleBank; /// <summary> /// Whether the bank for additions should be inherited from the normal sample in edit. /// </summary> public bool EditorAutoBank = true; public SampleBankInfo Clone() => (SampleBankInfo)MemberwiseClone(); } public class LegacyHitSampleInfo : HitSampleInfo, IEquatable<LegacyHitSampleInfo> { public readonly int CustomSampleBank; /// <summary> /// Whether this hit sample is layered. /// </summary> /// <remarks> /// Layered hit samples are automatically added in all modes (except osu!mania), but can be disabled /// using the <see cref="SkinConfiguration.LegacySetting.LayeredHitSounds"/> skin config option. /// </remarks> public readonly bool IsLayered; /// <summary> /// Whether a bank was specified locally to the relevant hitobject. /// If <c>false</c>, a bank will be retrieved from the closest control point. /// </summary> 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, customSampleBank >= 2 ? customSampleBank.ToString() : null, volume, editorAutoBank) { CustomSampleBank = customSampleBank; BankSpecified = !string.IsNullOrEmpty(bank); IsLayered = isLayered; } public sealed override HitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<string?> newSuffix = default, Optional<int> newVolume = default, Optional<bool> newEditorAutoBank = default) => With(newName, newBank, newVolume, newEditorAutoBank); public virtual LegacyHitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<int> newVolume = default, Optional<bool> newEditorAutoBank = default, Optional<int> newCustomSampleBank = default, Optional<bool> 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); } private class FileHitSampleInfo : LegacyHitSampleInfo, IEquatable<FileHitSampleInfo> { 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. // Note that this does not change the lookup names, as they are overridden locally. : base(string.Empty, customSampleBank: 1, volume: volume) { Filename = filename; } public override IEnumerable<string> LookupNames => new[] { Filename, Path.ChangeExtension(Filename, null) }; public sealed override LegacyHitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<int> newVolume = default, Optional<bool> newEditorAutoBank = default, Optional<int> newCustomSampleBank = default, Optional<bool> 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); } } }