385 lines
14 KiB
C#
385 lines
14 KiB
C#
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.05;
|
|
|
|
public override string[] GetSupportedFormats() {
|
|
return SUPPORTED_FORMATS;
|
|
}
|
|
|
|
public override IEnumerable<Resource> ConvertFrom(FileInfo file) {
|
|
List<Resource> result = new List<Resource>();
|
|
var meta = new ChartMeta { song = new SongMetaInfo() };
|
|
var group = new Chart.Group() {
|
|
tracks = new List<Chart.Track>(),
|
|
notes = new List<Chart.Note>(),
|
|
motions = new List<Chart.Motion>(),
|
|
};
|
|
var chart = new Chart {
|
|
format = 2,
|
|
time = new BeatTime(-4, 0, 1),
|
|
sigs = new List<Chart.Signature>(),
|
|
sounds = new List<Chart.Sound>(),
|
|
motions = new List<Chart.Motion>(),
|
|
groups = new List<Chart.Group> { group },
|
|
};
|
|
var diff = new DifficultyInfo();
|
|
var evs = new List<osuEvent>();
|
|
|
|
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<osuEvent, ChartEvent>();
|
|
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<Chart.Motion> {
|
|
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) { InputTimeAccuracy = 2e-3 };
|
|
bgmEv.offset = (float)(tev.StartTime / 1e3 + OFFSET);
|
|
}
|
|
tm.BeatLength = tev.BeatLength / 1e3;
|
|
tm.ForceSnap();
|
|
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<osuEvent> 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<osuEvent> 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<osuEvent> 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<osuEvent> 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<osuEvent> 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<string, string> 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<string, string> (
|
|
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<osuEvent> {
|
|
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
|
|
}
|
|
}
|