using Cryville.Crtr.Browsing; using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Text; namespace Cryville.Crtr.Extensions.osu { #pragma warning disable IDE1006 public class osuChartConverter : ResourceConverter { #pragma warning restore IDE1006 static readonly string[] SUPPORTED_FORMATS = { ".osu" }; const double OFFSET = 0.07; public override string[] GetSupportedFormats() { return SUPPORTED_FORMATS; } public override IEnumerable ConvertFrom(FileInfo file) { List result = new List(); var meta = new ChartMeta { song = new SongMetaInfo() }; var group = new Chart.Group() { tracks = new List(), notes = new List(), motions = new List(), }; var chart = new Chart { format = 2, time = new BeatTime(-4, 0, 1), sigs = new List(), sounds = new List(), motions = new List(), groups = new List { group }, }; var diff = new DifficultyInfo(); var evs = new List(); bool ftc = false; using (var reader = new StreamReader(file.FullName, Encoding.UTF8)) { Section section = Section.General; int version; bool flag = false; string line; while ((line = reader.ReadLine()) != null) { if (!flag) { if (line.StartsWith("osu file format v")) { version = int.Parse(line.Substring(17), CultureInfo.InvariantCulture); if (version > 14) throw new NotSupportedException("osu! chart format version too high"); else if (version < 5) throw new NotImplementedException("osu! chart format version too low"); // TODO apply offset } else throw new NotSupportedException("Unrecognized osu! chart format"); flag = true; } if (ShouldSkipLine(line)) continue; if (section != Section.Metadata) line = StripComments(line); line = line.TrimEnd(); if (line.StartsWith('[') && line.EndsWith(']')) { Enum.TryParse(line.Substring(1, line.Length - 2), out section); continue; } ParseLine(meta, chart, diff, evs, ref ftc, section, line); } } if (meta.ruleset == "osu!mania") { chart.ruleset = meta.ruleset += "." + diff.CircleSize.ToString(CultureInfo.InvariantCulture) + "k"; } if (!ftc) throw new InvalidOperationException("Unconvertible chart: no timing point is present in this beatmap"); result.Add(new RawChartResource(string.Format("{0} - {1}", meta.song.name, meta.name), chart, meta)); var evc = evs.Count; for (int i = 0; i < evc; i++) if (evs[i].IsLong) evs.Add(new osuEvent.EndEvent(evs[i])); evs.Sort(); var longevs = new Dictionary(); Chart.Sound bgmEv = null; TimeTimingModel tm = null; foreach (var ev in evs) { if (tm != null) tm.ForwardTo(ev.StartTime / 1e3); if (ev is osuEvent.Audio) { var tev = (osuEvent.Audio)ev; chart.sounds.Add(bgmEv = new Chart.Sound { time = new BeatTime(0, 0, 1), id = meta.song.name }); result.Add(new SongResource(meta.song.name, new FileInfo(Path.Combine(file.DirectoryName, tev.AudioFile)))); } else if (ev is osuEvent.Background) { meta.cover = ((osuEvent.Background)ev).FileName; } else if (ev is osuEvent.EffectPoint) { var tev = (osuEvent.EffectPoint)ev; group.motions.Add(new Chart.Motion { time = tm.FractionalBeatTime, motion = string.Format(CultureInfo.InvariantCulture, "svm:{0}", tev.ScrollSpeed) }); } else if (ev is osuEvent.EndEvent) { if (tm == null) throw new InvalidOperationException("Unconvertible chart: timed event before first timing point"); var tev = (osuEvent.EndEvent)ev; longevs[tev.Original].endtime = tm.FractionalBeatTime; } else if (ev is osuEvent.HOMania) { if (tm == null) throw new InvalidOperationException("Unconvertible chart: timed event before first timing point"); var tev = (osuEvent.HOMania)ev; var rn = new Chart.Note { time = tm.FractionalBeatTime, motions = new List { new Chart.Motion{ motion = string.Format(CultureInfo.InvariantCulture, "track:{0}", (int)(tev.X * diff.CircleSize / 512)) } }, }; group.notes.Add(rn); if (tev.IsLong) longevs.Add(tev, rn); } else if (ev is osuEvent.TimingChange) { var tev = (osuEvent.TimingChange)ev; if (tm == null) { tm = new TimeTimingModel(tev.StartTime / 1e3); bgmEv.offset = (float)(tev.StartTime / 1e3 + OFFSET); } tm.BeatLength = tev.BeatLength / 1e3; chart.sigs.Add(new Chart.Signature { time = tm.FractionalBeatTime, tempo = (float)tm.BPM, }); } else throw new NotSupportedException("Unsupported event detected"); } var endbeat = tm.FractionalBeatTime; endbeat.b += 4; chart.endtime = endbeat; meta.length = (float)tm.Time; meta.note_count = group.notes.Count; return result; } void ParseLine(ChartMeta meta, Chart chart, DifficultyInfo diff, List evs, ref bool ftc, Section section, string line) { switch (section) { case Section.General: HandleGeneral(meta, chart, evs, line); return; case Section.Metadata: HandleMetadata(meta, line); return; case Section.Difficulty: HandleDifficulty(diff, line); return; case Section.Events: HandleEvent(evs, line); return; case Section.TimingPoints: HandleTimingPoint(chart, evs, ref ftc, line); return; case Section.HitObjects: HandleHitObject(evs, line); return; } } void HandleGeneral(ChartMeta meta, Chart chart, List evs, string line) { var pair = SplitKeyVal(line); switch (pair.Key) { case @"AudioFilename": evs.Add(new osuEvent.Audio { StartTime = double.NegativeInfinity, AudioFile = pair.Value }); break; case @"Mode": int rulesetID = int.Parse(pair.Value, CultureInfo.InvariantCulture); var ruleset = "osu!"; switch (rulesetID) { case 0: /*ruleset += "standard";*/ throw new NotImplementedException("osu!standard mode is not supported yet"); case 1: /*ruleset += "taiko";*/ throw new NotImplementedException("osu!taiko mode is not supported yet"); case 2: /*ruleset += "catch";*/ throw new NotImplementedException("osu!catch mode is not supported yet"); case 3: ruleset += "mania"; break; } meta.ruleset = chart.ruleset = ruleset; break; } } void HandleMetadata(ChartMeta meta, string line) { var pair = SplitKeyVal(line); switch (pair.Key) { case @"Title": if (meta.song.name == null) meta.song.name = pair.Value; break; case @"TitleUnicode": meta.song.name = pair.Value; break; case @"Artist": if (meta.song.author == null) meta.song.author = pair.Value; break; case @"ArtistUnicode": meta.song.author = pair.Value; break; case @"Creator": meta.author = pair.Value; break; case @"Version": meta.name = pair.Value; break; } } void HandleDifficulty(DifficultyInfo diff, string line) { var pair = SplitKeyVal(line); switch (pair.Key) { case @"CircleSize": diff.CircleSize = float.Parse(pair.Value, CultureInfo.InvariantCulture); break; case @"SliderMultiplier": diff.SliderMultiplier = double.Parse(pair.Value, CultureInfo.InvariantCulture); break; } } void HandleEvent(List evs, string line) { string[] split = line.Split(','); if (!Enum.TryParse(split[0], out LegacyEventType type)) throw new InvalidDataException($@"Unknown event type: {split[0]}"); switch (type) { case LegacyEventType.Sprite: if (evs.Count == 0 || !(evs[evs.Count - 1] is osuEvent.Background)) evs.Add(new osuEvent.Background { StartTime = double.NegativeInfinity, FileName = CleanFilename(split[3]) }); break; case LegacyEventType.Background: evs.Add(new osuEvent.Background { StartTime = double.NegativeInfinity, FileName = CleanFilename(split[2]) }); break; } } enum LegacyEventType { Background = 0, Video = 1, Break = 2, Colour = 3, Sprite = 4, Sample = 5, Animation = 6 } void HandleTimingPoint(Chart chart, List evs, ref bool ftc, string line) { string[] split = line.Split(','); double time = double.Parse(split[0].Trim(), CultureInfo.InvariantCulture)/* + offset*/; // 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 = double.Parse(split[1].Trim(), CultureInfo.InvariantCulture); // If beatLength is NaN, speedMultiplier should still be 1 because all comparisons against NaN are false. double speedMultiplier = beatLength < 0 ? 100.0 / -beatLength : 1; int timeSignature = 4; if (split.Length >= 3) timeSignature = split[2][0] == '0' ? 4 : int.Parse(split[2], CultureInfo.InvariantCulture); bool timingChange = true; if (split.Length >= 7) timingChange = split[6][0] == '1'; //bool omitFirstBarSignature = false; //if (split.Length >= 8) { // int effectFlags = int.Parse(split[7], CultureInfo.InvariantCulture); // omitFirstBarSignature = (effectFlags & 0x8) != 0; //} if (timingChange) { if (double.IsNaN(beatLength)) throw new InvalidDataException("Beat length cannot be NaN in a timing control point"); var ev = new osuEvent.TimingChange { StartTime = time, BeatLength = beatLength, TimeSignature = timeSignature }; if (!ftc) { ftc = true; ev.StartTime = time % beatLength - beatLength; } evs.Add(ev); } // osu!taiko and osu!mania use effect points rather than difficulty points for scroll speed adjustments. if (chart.ruleset == "osu!taiko" || chart.ruleset == "osu!mania") evs.Add(new osuEvent.EffectPoint { StartTime = time, ScrollSpeed = speedMultiplier }); } void HandleHitObject(List evs, string line) { string[] split = line.Split(','); int posx = (int)float.Parse(split[0], CultureInfo.InvariantCulture); // int posy = (int)float.Parse(split[1], CultureInfo.InvariantCulture); double startTime = double.Parse(split[2], CultureInfo.InvariantCulture)/* + Offset*/; LegacyHitObjectType type = (LegacyHitObjectType)int.Parse(split[3], CultureInfo.InvariantCulture); // int comboOffset = (int)(type & LegacyHitObjectType.ComboOffset) >> 4; type &= ~LegacyHitObjectType.ComboOffset; // bool combo = type.HasFlag(LegacyHitObjectType.NewCombo); type &= ~LegacyHitObjectType.NewCombo; osuEvent.HitObject result; if (type.HasFlag(LegacyHitObjectType.Circle)) { result = new osuEvent.HOManiaHit { X = posx }; } else if (type.HasFlag(LegacyHitObjectType.Hold)) { double endTime = Math.Max(startTime, double.Parse(split[2], CultureInfo.InvariantCulture)); if (split.Length > 5 && !string.IsNullOrEmpty(split[5])) { string[] ss = split[5].Split(':'); endTime = Math.Max(startTime, double.Parse(ss[0], CultureInfo.InvariantCulture)); } result = new osuEvent.HOManiaHold { X = posx, EndTime = endTime }; } else throw new NotSupportedException(string.Format("Hit objects of type {0} is not supported yet", type)); if (result == null) throw new InvalidDataException($"Unknown hit object type: {split[3]}"); result.StartTime = startTime; if (result != null) evs.Add(result); } [Flags] enum LegacyHitObjectType { Circle = 1, Slider = 1 << 1, NewCombo = 1 << 2, Spinner = 1 << 3, ComboOffset = (1 << 4) | (1 << 5) | (1 << 6), Hold = 1 << 7 } static bool ShouldSkipLine(string line) => string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith("//", StringComparison.Ordinal) || line.StartsWith(' ') || line.StartsWith('_'); protected string StripComments(string line) { int index = line.IndexOf("//"); if (index > 0) return line.Substring(0, index); return line; } KeyValuePair SplitKeyVal(string line, char separator = ':', bool shouldTrim = true) { string[] split = line.Split(separator, 2); if (shouldTrim) { for (int i = 0; i < split.Length; i++) split[i] = split[i].Trim(); } return new KeyValuePair ( split[0], split.Length > 1 ? split[1] : string.Empty ); } static string CleanFilename(string path) => path.Replace(@"\\", @"\").Trim('"'); enum Section { General, Editor, Metadata, Difficulty, Events, TimingPoints, Colours, HitObjects, Variables, Fonts, CatchTheBeat, Mania, } class DifficultyInfo { public float CircleSize { get; internal set; } public double SliderMultiplier { get; set; } } #pragma warning disable IDE1006 // Naming Styles abstract class osuEvent : IComparable { public virtual double StartTime { get; set; } public virtual double EndTime { get; set; } public bool IsLong { get { return EndTime - StartTime > 0 && EndTime > 0; } } public abstract int Priority { get; } public int CompareTo(osuEvent other) { var c = StartTime.CompareTo(other.StartTime); if (c != 0) return c; return Priority.CompareTo(other.Priority); } public class EndEvent : osuEvent { public osuEvent Original; public EndEvent(osuEvent ev) { if (!ev.IsLong) throw new ArgumentException("Event is not long"); Original = ev; } public override double StartTime { get { return Original.EndTime; } } public override double EndTime { get { return 0; } } public override int Priority { get { return Original.Priority - 1; } } } public class Audio : osuEvent { public string AudioFile { get; set; } public override int Priority { get { return 0; } } } public class TimingChange : osuEvent { public double BeatLength { get; set; } public int TimeSignature { get; set; } public override int Priority { get { return -4; } } } public class EffectPoint : osuEvent { public double ScrollSpeed { get; set; } public override int Priority { get { return -2; } } } public class Background : osuEvent { public string FileName { get; set; } public override int Priority { get { return 0; } } } public class HitObject : osuEvent { public sealed override int Priority { get { return 0; } } } public class HOMania : HitObject { public float X { get; set; } } public class HOManiaHit : HOMania { } public class HOManiaHold : HOMania { } } #pragma warning restore IDE1006 // Naming Styles } }