From 54e5c27ebacfa5dbe7a655af7722dc5be1aa2119 Mon Sep 17 00:00:00 2001 From: Applesaber Date: Fri, 1 May 2026 10:13:41 +0800 Subject: [PATCH 01/17] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20C2S/UGC/SUS?= =?UTF-8?q?=20=E8=B0=B1=E9=9D=A2=E6=A0=BC=E5=BC=8F=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parser-Converter-Generator 三层架构,统一 IChuChart 接口,Generator 内嵌转换逻辑。 5 种语言 i18n 覆盖,4 项 xUnit 测试,219/219 全过。 --- .gitignore | 6 +- chart/chu/C2sChart.cs | 24 + chart/chu/ChuNote.cs | 38 ++ chart/chu/IChuChart.cs | 8 + chart/chu/SusChart.cs | 20 + chart/chu/UgcChart.cs | 27 ++ generator/chu/C2sGenerator.cs | 124 +++++ generator/chu/SusGenerator.cs | 105 +++++ generator/chu/UgcGenerator.cs | 133 ++++++ i18n/Locale.Designer.cs | 12 + i18n/Locale.ja.resx | 78 +++- i18n/Locale.ko.resx | 78 +++- i18n/Locale.resx | 6 + i18n/Locale.zh-hant.resx | 6 + i18n/Locale.zh.resx | 6 + parser/chu/C2sParser.cs | 117 +++++ parser/chu/SusParser.cs | 249 ++++++++++ parser/chu/UgcParser.cs | 430 ++++++++++++++++++ tests/chu/ChuTests.cs | 69 +++ tests/chu/example.cs | 12 - tests/chu/testset/placeholder.txt | 2 - .../B.B.K.K.B.K.K/0003_00.c2s" | Bin 0 -> 9724 bytes .../Example/basic.ugc" | 375 +++++++++++++++ 23 files changed, 1898 insertions(+), 27 deletions(-) create mode 100644 chart/chu/C2sChart.cs create mode 100644 chart/chu/ChuNote.cs create mode 100644 chart/chu/IChuChart.cs create mode 100644 chart/chu/SusChart.cs create mode 100644 chart/chu/UgcChart.cs create mode 100644 generator/chu/C2sGenerator.cs create mode 100644 generator/chu/SusGenerator.cs create mode 100644 generator/chu/UgcGenerator.cs create mode 100644 parser/chu/C2sParser.cs create mode 100644 parser/chu/SusParser.cs create mode 100644 parser/chu/UgcParser.cs create mode 100644 tests/chu/ChuTests.cs delete mode 100644 tests/chu/example.cs delete mode 100644 tests/chu/testset/placeholder.txt create mode 100644 "tests/chu/testset/\345\256\230\350\260\261/B.B.K.K.B.K.K/0003_00.c2s" create mode 100644 "tests/chu/testset/\350\207\252\345\210\266\350\260\261/Example/basic.ugc" diff --git a/.gitignore b/.gitignore index 2a6c82b..a92c882 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,8 @@ riderModule.iml .cursor /.tmp* *scratch* -*.lscache \ No newline at end of file +*.lscache + +# 测试 dump 输出 +*_output.* +placeholder.txt \ No newline at end of file diff --git a/chart/chu/C2sChart.cs b/chart/chu/C2sChart.cs new file mode 100644 index 0000000..ef6e2e9 --- /dev/null +++ b/chart/chu/C2sChart.cs @@ -0,0 +1,24 @@ +using MuConvert.chart; + +namespace MuConvert.chu; + +/** + * C2S 格式谱面 IR(官方格式,RESOLUTION=384 tick/小节)。 + */ +public class C2sChart : BaseChart, IChuChart +{ + public string Version { get; set; } = "1.08.00\t1.08.00"; + public int MusicId { get; set; } + public int DifficultId { get; set; } + public string Creator { get; set; } = ""; + public int Resolution { get; set; } = 384; + public double DefBpm { get; set; } = 120.0; + public List<(int Measure, int Offset, double Bpm)> BpmEvents = []; + public List<(int Measure, int Offset, int Denom, int Num)> MetEvents = []; + public List<(int Measure, int Offset, int Duration, double Multiplier)> SflEvents = []; + + public override decimal StartBpm => (decimal)(BpmEvents.Count > 0 ? BpmEvents[0].Bpm : DefBpm); + public override decimal StartTime => Notes.Count > 0 ? Notes.Min(n => n.Measure * Resolution + n.Offset) / 384m * 240m / StartBpm : 0; + public override decimal EndTime => Notes.Count > 0 ? Notes.Max(n => n.Measure * Resolution + n.Offset + Math.Max(n.HoldDuration, Math.Max(n.SlideDuration, n.AirHoldDuration))) / 384m * 240m / StartBpm : 0; + public override int TotalNotes => Notes.Count; +} diff --git a/chart/chu/ChuNote.cs b/chart/chu/ChuNote.cs new file mode 100644 index 0000000..61130b8 --- /dev/null +++ b/chart/chu/ChuNote.cs @@ -0,0 +1,38 @@ +namespace MuConvert.chu; + +/** + * CHUNITHM 通用音符,C2S / UGC / SUS 共用此结构。 + */ +public class ChuNote +{ + /** 音符类型 (TAP, CHR, HLD, SLD, AIR, AHD 等) */ + public string Type { get; set; } = "TAP"; + /** 小节号 */ + public int Measure { get; set; } + /** 小节内偏移 (C2S: 0–383, UGC/SUS: 0–1919) */ + public int Offset { get; set; } + /** 起始列 (0–15) */ + public int Cell { get; set; } + /** 宽度 (1–16) */ + public int Width { get; set; } = 1; + /** HLD 持续时长 */ + public int HoldDuration { get; set; } + /** SLD 持续时长 */ + public int SlideDuration { get; set; } + /** SLD 终点列 */ + public int EndCell { get; set; } + /** SLD 终点宽度 */ + public int EndWidth { get; set; } = 1; + /** CHR/FLK 附加数据(方向等) */ + public string Extra { get; set; } = ""; + /** AIR/AHD 关联的目标音符类型 */ + public string TargetNote { get; set; } = ""; + /** AHD 持续时长 */ + public int AirHoldDuration { get; set; } + /** Air Crush 起始高度 */ + public int StartHeight { get; set; } + /** Air Crush 目标高度 */ + public int TargetHeight { get; set; } + /** Air Crush 颜色 */ + public string NoteColor { get; set; } = ""; +} diff --git a/chart/chu/IChuChart.cs b/chart/chu/IChuChart.cs new file mode 100644 index 0000000..d632bfd --- /dev/null +++ b/chart/chu/IChuChart.cs @@ -0,0 +1,8 @@ +using MuConvert.chart; + +namespace MuConvert.chu; + +/** + * CHUNITHM 所有谱面格式的统一接口,作为 Generator 的输入类型。 + */ +public interface IChuChart : IBaseChart; diff --git a/chart/chu/SusChart.cs b/chart/chu/SusChart.cs new file mode 100644 index 0000000..0076053 --- /dev/null +++ b/chart/chu/SusChart.cs @@ -0,0 +1,20 @@ +using MuConvert.chart; + +namespace MuConvert.chu; + +/** + * SUS 格式谱面 IR(REQUEST=480 tick/拍,lane 0–31)。 + */ +public class SusChart : BaseChart, IChuChart +{ + public string Title { get; set; } = ""; + public string Artist { get; set; } = ""; + public string Designer { get; set; } = ""; + public int TicksPerBeat { get; set; } = 480; + public double Bpm { get; set; } = 120.0; + + public override decimal StartBpm => (decimal)Bpm; + public override decimal StartTime => Notes.Count > 0 ? Notes.Min(n => n.Measure * TicksPerBeat * 4 + n.Offset) / (decimal)(TicksPerBeat * 4) * 240m / StartBpm : 0; + public override decimal EndTime => 0; + public override int TotalNotes => Notes.Count; +} diff --git a/chart/chu/UgcChart.cs b/chart/chu/UgcChart.cs new file mode 100644 index 0000000..3b947c6 --- /dev/null +++ b/chart/chu/UgcChart.cs @@ -0,0 +1,27 @@ +using MuConvert.chart; + +namespace MuConvert.chu; + +/** + * UGC 格式谱面 IR(UMIGURI 格式,@TICKS=480 tick/拍)。 + */ +public class UgcChart : BaseChart, IChuChart +{ + public string Version { get; set; } = "6"; + public string Title { get; set; } = ""; + public string Artist { get; set; } = ""; + public string Designer { get; set; } = ""; + public string Difficulty { get; set; } = ""; + public int Level { get; set; } + public double Constant { get; set; } + public string SongId { get; set; } = ""; + public int TicksPerBeat { get; set; } = 480; + public List<(int Measure, int Num, int Den)> BeatEvents = []; + public List<(int Measure, int Offset, double Bpm)> BpmEvents = []; + public List<(int Measure, int Offset, double Multiplier)> SpeedEvents = []; + + public override decimal StartBpm => (decimal)(BpmEvents.Count > 0 ? BpmEvents[0].Bpm : 120.0); + public override decimal StartTime => Notes.Count > 0 ? Notes.Min(n => n.Measure * TicksPerBeat * 4 + n.Offset) / (decimal)(TicksPerBeat * 4) * 240m / StartBpm : 0; + public override decimal EndTime => 0; + public override int TotalNotes => Notes.Count; +} diff --git a/generator/chu/C2sGenerator.cs b/generator/chu/C2sGenerator.cs new file mode 100644 index 0000000..a5e5436 --- /dev/null +++ b/generator/chu/C2sGenerator.cs @@ -0,0 +1,124 @@ +using System.Globalization; +using System.Text; +using MuConvert.chart; +using MuConvert.generator; +using MuConvert.utils; +using static MuConvert.utils.Alert.LEVEL; + +namespace MuConvert.chu; + +/** + * C2S 格式生成器。 + * 输入 IChuChart,内部自动转换后输出 C2S 文本。 + */ +public class C2sGenerator : IGenerator +{ + private const int C2sResolution = 384; + + public (string, List) Generate(IChuChart chart) + { + var alerts = new List(); + var c2s = ConvertToC2s(chart, alerts); + var text = Serialize(c2s); + return (text, alerts); + } + + private static C2sChart ConvertToC2s(IChuChart chart, List alerts) + { + if (chart is C2sChart c2s) return c2s; + + if (chart is UgcChart ugc) + { + var result = new C2sChart + { + Version = "1.08.00\t1.08.00", + Creator = ugc.Designer, + DefBpm = ugc.BpmEvents.Count > 0 ? ugc.BpmEvents[0].Bpm : 120.0, + }; + foreach (var b in ugc.BpmEvents) + result.BpmEvents.Add((b.Measure, ScaleDown(b.Offset, ugc.TicksPerBeat), b.Bpm)); + foreach (var b in ugc.BeatEvents) + result.MetEvents.Add((b.Measure, 0, b.Den, b.Num)); + foreach (var n in ugc.Notes) + result.Notes.Add(ScaleNote(n, ugc.TicksPerBeat)); + return result; + } + + if (chart is SusChart sus) + { + var result = new C2sChart { DefBpm = sus.Bpm }; + result.BpmEvents.Add((0, 0, sus.Bpm)); + foreach (var n in sus.Notes) + result.Notes.Add(ScaleNote(n, sus.TicksPerBeat)); + return result; + } + + alerts.Add(new Alert(Warning, string.Format(Locale.ChuGeneratorUnsupported, "→ C2S"))); + return new C2sChart(); + } + + private static ChuNote ScaleNote(ChuNote n, int tpb) + { + int scaleDown(int v) => (int)((long)v * (C2sResolution / 4) / tpb); + return new ChuNote + { + Type = n.Type, Measure = n.Measure, Offset = scaleDown(n.Offset), + Cell = n.Cell, Width = n.Width, + HoldDuration = scaleDown(n.HoldDuration), SlideDuration = scaleDown(n.SlideDuration), + EndCell = n.EndCell, EndWidth = n.EndWidth, + Extra = n.Extra, TargetNote = n.TargetNote, AirHoldDuration = scaleDown(n.AirHoldDuration), + StartHeight = n.StartHeight, TargetHeight = n.TargetHeight, NoteColor = n.NoteColor, + }; + } + + private static int ScaleDown(int ticks, int tpb) => (int)((long)ticks * (C2sResolution / 4) / tpb); + + private static string Serialize(C2sChart chart) + { + var sb = new StringBuilder(); + sb.AppendLine($"VERSION\t{chart.Version}"); + sb.AppendLine($"MUSIC\t{chart.MusicId}"); + sb.AppendLine("SEQUENCEID\t0"); + sb.AppendLine($"DIFFICULT\t{chart.DifficultId:D2}"); + sb.AppendLine("LEVEL\t0.0"); + sb.AppendLine($"CREATOR\t{chart.Creator}"); + sb.AppendLine($"BPM_DEF\t{Fmt(chart.DefBpm)}\t{Fmt(chart.DefBpm)}\t{Fmt(chart.DefBpm)}\t{Fmt(chart.DefBpm)}"); + sb.AppendLine("MET_DEF\t4\t4"); + sb.AppendLine($"RESOLUTION\t{chart.Resolution}"); + sb.AppendLine($"CLK_DEF\t{chart.Resolution}"); + sb.AppendLine("PROGJUDGE_BPM\t240.000"); + sb.AppendLine("PROGJUDGE_AER\t0.999"); + sb.AppendLine("TUTORIAL\t0"); + sb.AppendLine(); + + foreach (var b in chart.BpmEvents) + sb.AppendLine($"BPM\t{b.Measure}\t{b.Offset}\t{Fmt(b.Bpm)}"); + foreach (var m in chart.MetEvents) + sb.AppendLine($"MET\t{m.Measure}\t{m.Offset}\t{m.Denom}\t{m.Num}"); + foreach (var s in chart.SflEvents) + sb.AppendLine($"SFL\t{s.Measure}\t{s.Offset}\t{s.Duration}\t{Mlt(s.Multiplier)}"); + sb.AppendLine(); + + foreach (var n in chart.Notes.OrderBy(n => n.Measure * C2sResolution + n.Offset)) + sb.AppendLine(FormatNote(n)); + + sb.AppendLine(); + return sb.ToString(); + } + + private static string FormatNote(ChuNote n) => n.Type switch + { + "TAP" => $"TAP\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}", + "CHR" => $"CHR\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.Extra}", + "HLD" or "HXD" => $"{n.Type}\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.HoldDuration}", + "SLD" or "SLC" or "SXD" or "SXC" => $"{n.Type}\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.SlideDuration}\t{n.EndCell}\t{n.EndWidth}", + "FLK" => $"FLK\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.Extra}", + "AIR" or "AUR" or "AUL" or "ADW" or "ADR" or "ADL" => $"{n.Type}\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.TargetNote}", + "AHD" => $"AHD\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.TargetNote}\t{n.AirHoldDuration}", + "MNE" => $"MNE\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}", + _ => $"TAP\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}" + }; + + private static string Fmt(double v) => v.ToString("0.000", CultureInfo.InvariantCulture); + private static string Mlt(double v) => v.ToString("0.000000", CultureInfo.InvariantCulture); +} diff --git a/generator/chu/SusGenerator.cs b/generator/chu/SusGenerator.cs new file mode 100644 index 0000000..0911079 --- /dev/null +++ b/generator/chu/SusGenerator.cs @@ -0,0 +1,105 @@ +using System.Text; +using MuConvert.chart; +using MuConvert.generator; +using MuConvert.utils; +using static MuConvert.utils.Alert.LEVEL; + +namespace MuConvert.chu; + +/** + * SUS 格式生成器。 + * 输入 IChuChart,内部自动转换后输出 SUS 文本。 + */ +public class SusGenerator : IGenerator +{ + private const int SusTpb = 480; + private const int C2sRsl = 384; + + public (string, List) Generate(IChuChart chart) + { + var alerts = new List(); + var sus = ConvertToSus(chart, alerts); + var text = Serialize(sus); + return (text, alerts); + } + + private static SusChart ConvertToSus(IChuChart chart, List alerts) + { + if (chart is SusChart sus) return sus; + + double bpm = 120.0; + string title = "", artist = ""; + + if (chart is C2sChart c2s) + { + bpm = c2s.BpmEvents.Count > 0 ? c2s.BpmEvents[0].Bpm : c2s.DefBpm; + var result = new SusChart { Bpm = bpm, TicksPerBeat = SusTpb, Title = title, Artist = artist }; + foreach (var n in c2s.Notes) result.Notes.Add(ScaleUp(n)); + return result; + } + + if (chart is UgcChart ugc) + { + bpm = ugc.BpmEvents.Count > 0 ? ugc.BpmEvents[0].Bpm : 120.0; + var result = new SusChart { Bpm = bpm, TicksPerBeat = SusTpb, Title = ugc.Title, Artist = ugc.Artist }; + foreach (var n in ugc.Notes) result.Notes.Add(ScaleUp(n)); + return result; + } + + alerts.Add(new Alert(Warning, string.Format(Locale.ChuGeneratorUnsupported, "→ SUS"))); + return new SusChart(); + } + + private static ChuNote ScaleUp(ChuNote n) + { + int s(int v) => v * SusTpb / (C2sRsl / 4); + return new ChuNote + { + Type = n.Type, Measure = n.Measure, Offset = s(n.Offset), + Cell = n.Cell * 2, Width = n.Width * 2, + HoldDuration = s(n.HoldDuration), SlideDuration = s(n.SlideDuration), + EndCell = n.EndCell * 2, EndWidth = n.EndWidth * 2, + Extra = n.Extra, TargetNote = n.TargetNote, AirHoldDuration = s(n.AirHoldDuration), + }; + } + + private static string Serialize(SusChart sus) + { + var sb = new StringBuilder(); + if (!string.IsNullOrEmpty(sus.Title)) sb.AppendLine($"#TITLE \"{sus.Title}\""); + if (!string.IsNullOrEmpty(sus.Artist)) sb.AppendLine($"#ARTIST \"{sus.Artist}\""); + if (!string.IsNullOrEmpty(sus.Designer)) sb.AppendLine($"#DESIGNER \"{sus.Designer}\""); + sb.AppendLine($"#BPM_DEF {sus.Bpm:F2}"); + sb.AppendLine($"#REQUEST \"{sus.TicksPerBeat}\""); + sb.AppendLine(); + + foreach (var n in sus.Notes.OrderBy(n => n.Measure).ThenBy(n => n.Offset)) + sb.AppendLine($"#{n.Measure:X2}{n.Offset:X3}:{FormatData(n)}"); + + return sb.ToString(); + } + + private static string FormatData(ChuNote n) + { + string lw = $"{n.Cell:X2}{n.Width:X2}"; + string tc = TypeCode(n.Type); + string dur = $"{(n.HoldDuration > 0 ? n.HoldDuration : n.SlideDuration > 0 ? n.SlideDuration : n.AirHoldDuration):X4}"; + return tc switch + { + "01" or "02" or "03" or "10" => $"{tc}{lw}", + "05" or "08" => $"{tc}{lw}{dur}", + "06" => $"{tc}{lw}{dur}{n.EndCell:X2}{n.EndWidth:X2}", + "07" or "09" => $"{tc}{lw}{n.TargetNote}", + _ => $"01{lw}" + }; + } + + private static string TypeCode(string t) => t switch + { + "TAP" => "01", "CHR" => "02", "FLK" => "03", + "HLD" => "05", "SLD" => "06", "SLC" => "06", + "AIR" => "07", "AUR" => "07", "AUL" => "07", + "AHD" => "08", "ADW" => "09", "ADR" => "09", "ADL" => "09", + "MNE" => "10", _ => "01" + }; +} diff --git a/generator/chu/UgcGenerator.cs b/generator/chu/UgcGenerator.cs new file mode 100644 index 0000000..0897b3f --- /dev/null +++ b/generator/chu/UgcGenerator.cs @@ -0,0 +1,133 @@ +using System.Text; +using MuConvert.chart; +using MuConvert.generator; +using MuConvert.utils; +using static MuConvert.utils.Alert.LEVEL; + +namespace MuConvert.chu; + +/** + * UGC 格式生成器。 + * 输入 IChuChart,内部自动转换后输出 UGC 文本。 + */ +public class UgcGenerator : IGenerator +{ + private const int UgcTicksPerBeat = 480; + private const int C2sResolution = 384; + + public (string, List) Generate(IChuChart chart) + { + var alerts = new List(); + var ugc = ConvertToUgc(chart, alerts); + var text = Serialize(ugc); + return (text, alerts); + } + + private static UgcChart ConvertToUgc(IChuChart chart, List alerts) + { + if (chart is UgcChart ugc) return ugc; + + if (chart is C2sChart c2s) + { + var result = new UgcChart + { + TicksPerBeat = UgcTicksPerBeat, + Designer = c2s.Creator, + Difficulty = MapDiffId(c2s.DifficultId), + SongId = c2s.MusicId.ToString(), + }; + foreach (var b in c2s.BpmEvents) + result.BpmEvents.Add((b.Measure, ScaleUp(b.Offset), b.Bpm)); + foreach (var m in c2s.MetEvents) + result.BeatEvents.Add((m.Measure, m.Num, m.Denom)); + foreach (var n in c2s.Notes) + result.Notes.Add(ScaleUpNote(n)); + return result; + } + + alerts.Add(new Alert(Warning, string.Format(Locale.ChuGeneratorUnsupported, "→ UGC"))); + return new UgcChart(); + } + + private static ChuNote ScaleUpNote(ChuNote n) + { + int s(int v) => v * UgcTicksPerBeat / (C2sResolution / 4); + return new ChuNote + { + Type = n.Type, Measure = n.Measure, Offset = s(n.Offset), + Cell = n.Cell, Width = n.Width, + HoldDuration = s(n.HoldDuration), SlideDuration = s(n.SlideDuration), + EndCell = n.EndCell, EndWidth = n.EndWidth, + Extra = n.Extra, TargetNote = IsAir(n.Type) ? "N" : n.TargetNote, + AirHoldDuration = s(n.AirHoldDuration), + StartHeight = n.StartHeight, TargetHeight = n.TargetHeight, NoteColor = n.NoteColor, + }; + } + + private static int ScaleUp(int v) => v * UgcTicksPerBeat / (C2sResolution / 4); + + private static bool IsAir(string t) => t is "AIR" or "AUR" or "AUL" or "AHD" or "ADW" or "ADR" or "ADL"; + + private static string MapDiffId(int id) => id switch + { + 0 => "BASIC", 1 => "ADVANCED", 2 => "EXPERT", 3 => "MASTER", 4 => "ULTIMA", _ => "0" + }; + + private static string Serialize(UgcChart ugc) + { + var sb = new StringBuilder(); + sb.AppendLine("@VER\t6"); + if (!string.IsNullOrEmpty(ugc.Title)) sb.AppendLine($"@TITLE\t{ugc.Title}"); + if (!string.IsNullOrEmpty(ugc.Artist)) sb.AppendLine($"@ARTIST\t{ugc.Artist}"); + if (!string.IsNullOrEmpty(ugc.Designer)) sb.AppendLine($"@DESIGN\t{ugc.Designer}"); + sb.AppendLine($"@DIFF\t{DiffId(ugc.Difficulty)}"); + sb.AppendLine($"@LEVEL\t{ugc.Level}"); + sb.AppendLine($"@CONST\t{ugc.Constant:F5}"); + sb.AppendLine($"@SONGID\t{ugc.SongId}"); + sb.AppendLine($"@TICKS\t{ugc.TicksPerBeat}"); + foreach (var b in ugc.BeatEvents) sb.AppendLine($"@BEAT\t{b.Measure}\t{b.Num}\t{b.Den}"); + foreach (var b in ugc.BpmEvents) sb.AppendLine($"@BPM\t{b.Measure}'{b.Offset}\t{b.Bpm:F5}"); + sb.AppendLine("@TIL\t0\t0'0\t1.00000"); + sb.AppendLine("@MAINTIL\t0"); + sb.AppendLine("@ENDHEAD"); + sb.AppendLine(); + + var notes = ugc.Notes.OrderBy(n => n.Measure).ThenBy(n => n.Offset).ToList(); + foreach (var n in notes) + { + sb.Append($"#{n.Measure}'{n.Offset}:{UCode(n)}"); + sb.AppendLine(); + if (n.Type == "HLD" && n.HoldDuration > 0) + sb.AppendLine($"#{n.HoldDuration}>s"); + else if (n.Type == "SLD" && n.SlideDuration > 0) + sb.AppendLine($"#{n.SlideDuration}>s{Hx(n.EndCell)}{Hw(n.EndWidth)}"); + } + return sb.ToString(); + } + + private static string UCode(ChuNote n) + { + string c = Hx(n.Cell), w = Hw(n.Width); + return n.Type switch + { + "TAP" => $"t{c}{w}", + "CHR" => $"x{c}{w}{n.Extra}", + "HLD" => $"h{c}{w}", + "SLD" => $"s{c}{w}", + "FLK" => $"f{c}{w}A", + "MNE" => $"d{c}{w}", + "AIR" => $"a{c}{w}UC{n.TargetNote}", + "AUR" => $"a{c}{w}UR{n.TargetNote}", + "AUL" => $"a{c}{w}UL{n.TargetNote}", + "AHD" => $"a{c}{w}HD{n.TargetNote}_{n.AirHoldDuration}", + "ADW" => $"a{c}{w}DC{n.TargetNote}", + "ADR" => $"a{c}{w}DR{n.TargetNote}", + "ADL" => $"a{c}{w}DL{n.TargetNote}", + _ => $"t{c}{w}" + }; + } + + private static string Hx(int v) => "0123456789ABCDEF"[Math.Clamp(v, 0, 15)].ToString(); + private static string Hw(int v) => "123456789ABCDEFG"[Math.Clamp(v - 1, 0, 15)].ToString(); + private static int DiffId(string d) => d switch { "BASIC" => 0, "ADVANCED" => 1, "EXPERT" => 2, "MASTER" => 3, "ULTIMA" => 4, _ => 0 }; +} diff --git a/i18n/Locale.Designer.cs b/i18n/Locale.Designer.cs index 8690ef7..b9556cd 100644 --- a/i18n/Locale.Designer.cs +++ b/i18n/Locale.Designer.cs @@ -517,5 +517,17 @@ public static string WarnNonStdMA2Version { return ResourceManager.GetString("WarnNonStdMA2Version", resourceCulture); } } + + public static string C2SUnknownNoteType { + get { + return ResourceManager.GetString("C2SUnknownNoteType", resourceCulture); + } + } + + public static string ChuGeneratorUnsupported { + get { + return ResourceManager.GetString("ChuGeneratorUnsupported", resourceCulture); + } + } } } diff --git a/i18n/Locale.ja.resx b/i18n/Locale.ja.resx index ab5bab3..1edf3f8 100644 --- a/i18n/Locale.ja.resx +++ b/i18n/Locale.ja.resx @@ -1,5 +1,64 @@ + @@ -28,9 +87,9 @@ - - - + + + @@ -53,10 +112,10 @@ 2.0 - System.Resources.ResXResourceReader, System.Windows.Forms, Version=10.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=10.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 MuConvert で内部エラーが発生しました(AssertionFailed)。`https://github.com/MuNet-OSS/MuConvert/issues` に報告してください。({0}) @@ -211,4 +270,11 @@ 同じ時刻・同じ位置に別のスライド頭/タップが検出されました。ゲーム内で判定問題を引き起こすため、自動修復しました(余分なスライド頭を削除)。PS:同頭スライドを意図する場合は「1-2*-3」のように書き、「1-2/1-3」は避けてください(この問題を引き起こします)。 - + + + + + + + + \ No newline at end of file diff --git a/i18n/Locale.ko.resx b/i18n/Locale.ko.resx index edb8843..5cbc09a 100644 --- a/i18n/Locale.ko.resx +++ b/i18n/Locale.ko.resx @@ -1,5 +1,64 @@ + @@ -28,9 +87,9 @@ - - - + + + @@ -53,10 +112,10 @@ 2.0 - System.Resources.ResXResourceReader, System.Windows.Forms, Version=10.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=10.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 MuConvert에서 내부 오류가 발생했습니다(AssertionFailed). `https://github.com/MuNet-OSS/MuConvert/issues` 에 제보해 주세요. ({0}) @@ -211,4 +270,11 @@ 이 슬라이드 헤드와 동일한 시간/위치에 다른 슬라이드 헤드/탭이 감지되었습니다. 게임 내 판정 문제를 유발할 수 있어 자동으로 수정했습니다(중복 슬라이드 헤드 제거). PS: 같은 헤드의 슬라이드를 의도했다면 "1-2*-3" 같은 문법을 사용하세요. "1-2/1-3"는 이 문제를 유발합니다. - + + + + + + + + \ No newline at end of file diff --git a/i18n/Locale.resx b/i18n/Locale.resx index 6b292ce..ac56c90 100644 --- a/i18n/Locale.resx +++ b/i18n/Locale.resx @@ -211,4 +211,10 @@ Detected another Slide head/Tap at the same time and position as this Slide head. This would cause judgement issues in-game. Fixed automatically (removed the redundant Slide head(s)). PS: If you intend same-head Slides, use syntax like "1-2*-3" rather than "1-2/1-3"; the latter triggers this issue. + + Unknown C2S note type: {0} + + + Cannot convert chart to target format: {0} + diff --git a/i18n/Locale.zh-hant.resx b/i18n/Locale.zh-hant.resx index 095e0cd..beef74f 100644 --- a/i18n/Locale.zh-hant.resx +++ b/i18n/Locale.zh-hant.resx @@ -211,4 +211,10 @@ 檢測到在星星頭所在的時刻與位置,存在其他星星頭/Tap,這會造成遊戲內的絕對無理。已自動為您修復(移除多餘的星星頭)。PS:若您要編寫同頭星星,請使用類似「1-2*-3」而非「1-2/1-3」的寫法;後者會造成上述情況。 + + C2S 中存在無法識別的音符類型: {0} + + + 無法將譜面轉換為目標格式: {0} + diff --git a/i18n/Locale.zh.resx b/i18n/Locale.zh.resx index 47c8cd8..cbdbb27 100644 --- a/i18n/Locale.zh.resx +++ b/i18n/Locale.zh.resx @@ -211,4 +211,10 @@ 检测到在星星头所在的时刻和位置,存在着其他星星头/Tap,这会造成游戏内的绝对无理。已自动为您修复(去除多余的星星头)。PS:如果您要编写同头星星,请使用类似"1-2*-3"而非"1-2/1-3"的写法,后者就会造成上面的情况。 + + C2S 中存在无法识别的音符类型: {0} + + + 无法将谱面转换为目标格式: {0} + diff --git a/parser/chu/C2sParser.cs b/parser/chu/C2sParser.cs new file mode 100644 index 0000000..347527e --- /dev/null +++ b/parser/chu/C2sParser.cs @@ -0,0 +1,117 @@ +using System.Globalization; +using MuConvert.chart; +using MuConvert.parser; +using MuConvert.utils; +using static MuConvert.utils.Alert.LEVEL; + +namespace MuConvert.chu; + +/** + * C2S 格式解析器(官方格式,RESOLUTION=384 tick/小节)。 + * Tab 分隔文本,识别 HEADER / TIMING / NOTES 区段。 + */ +public class C2sParser : IParser +{ + private static readonly HashSet HeadTags = new(StringComparer.OrdinalIgnoreCase) + { "VERSION", "MUSIC", "SEQUENCEID", "DIFFICULT", "LEVEL", "CREATOR", "BPM_DEF", "MET_DEF", "RESOLUTION", "CLK_DEF", "PROGJUDGE_BPM", "PROGJUDGE_AER", "TUTORIAL" }; + private static readonly HashSet TimingTags = new(StringComparer.OrdinalIgnoreCase) + { "BPM", "MET", "SFL" }; + + public (C2sChart, List) Parse(string text) + { + var chart = new C2sChart(); + var alerts = new List(); + var lines = text.Replace("\r\n", "\n").Split('\n'); + bool inNotes = false; + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i]; + if (string.IsNullOrWhiteSpace(line)) continue; + if (line.StartsWith("T_")) continue; + + var parts = line.Split('\t'); + var tag = parts[0].ToUpperInvariant(); + + if (inNotes || !HeadTags.Contains(tag) && !TimingTags.Contains(tag)) + { + inNotes = true; + ParseNote(parts, chart, alerts, i + 1); + } + else if (HeadTags.Contains(tag)) + { + ParseHeader(parts, chart); + } + else if (TimingTags.Contains(tag)) + { + ParseTiming(parts, chart); + inNotes = false; + } + } + + return (chart, alerts); + } + + private static void ParseHeader(string[] p, C2sChart chart) + { + var tag = p[0].ToUpperInvariant(); + switch (tag) + { + case "VERSION": chart.Version = Str(p, 1); break; + case "MUSIC": chart.MusicId = Int(p, 1); break; + case "DIFFICULT": chart.DifficultId = Int(p, 1); break; + case "CREATOR": chart.Creator = Str(p, 1); break; + case "BPM_DEF": chart.DefBpm = Dbl(p, 1, 120.0); break; + case "RESOLUTION": chart.Resolution = Math.Max(1, Int(p, 1, 384)); break; + } + } + + private static void ParseTiming(string[] p, C2sChart chart) + { + var tag = p[0].ToUpperInvariant(); + switch (tag) + { + case "BPM": + chart.BpmEvents.Add((Int(p, 1), Int(p, 2), Dbl(p, 3, 120.0))); + break; + case "MET": + chart.MetEvents.Add((Int(p, 1), Int(p, 2), Int(p, 3, 4), Int(p, 4, 4))); + break; + case "SFL": + chart.SflEvents.Add((Int(p, 1), Int(p, 2), Int(p, 3), Dbl(p, 4, 1.0))); + break; + } + } + + private static void ParseNote(string[] p, C2sChart chart, List alerts, int lineNum) + { + var tag = p[0].ToUpperInvariant(); + var note = new ChuNote { Type = tag, Measure = Int(p, 1), Offset = Int(p, 2), Cell = Int(p, 3), Width = Math.Max(1, Int(p, 4, 1)) }; + + switch (tag) + { + case "TAP": case "MNE": break; + case "CHR": note.Extra = Str(p, 5); break; + case "HLD": case "HXD": note.HoldDuration = Int(p, 5); break; + case "SLD": case "SLC": case "SXD": case "SXC": + note.SlideDuration = Int(p, 5); note.EndCell = Int(p, 6); note.EndWidth = Math.Max(1, Int(p, 7, 1)); break; + case "FLK": note.Extra = Str(p, 5); break; + case "AIR": case "AUR": case "AUL": case "ADW": case "ADR": case "ADL": + note.TargetNote = Str(p, 5); break; + case "AHD": + note.TargetNote = Str(p, 5); note.AirHoldDuration = Int(p, 6); break; + case "ALD": case "ASD": + note.StartHeight = Int(p, 3); note.SlideDuration = Int(p, 4); + note.EndCell = Int(p, 5); note.EndWidth = Math.Max(1, Int(p, 6, 1)); + note.TargetHeight = Int(p, 7); note.NoteColor = Str(p, 8); break; + default: + alerts.Add(new Alert(Warning, string.Format(Locale.C2SUnknownNoteType, tag)) { Line = lineNum }); return; + } + + chart.Notes.Add(note); + } + + private static int Int(string[] p, int i, int def = 0) => i < p.Length && int.TryParse(p[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : def; + private static double Dbl(string[] p, int i, double def = 0) => i < p.Length && double.TryParse(p[i], NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : def; + private static string Str(string[] p, int i) => i < p.Length ? p[i] : ""; +} diff --git a/parser/chu/SusParser.cs b/parser/chu/SusParser.cs new file mode 100644 index 0000000..73ee265 --- /dev/null +++ b/parser/chu/SusParser.cs @@ -0,0 +1,249 @@ +using MuConvert.chart; +using MuConvert.parser; +using MuConvert.utils; +using static MuConvert.utils.Alert.LEVEL; + +namespace MuConvert.chu; + +/** + * SUS 格式解析器(社区工具格式,REQUEST=480 tick/拍,lane 0–31)。 + * #MMTT:data 十六进制编码音符。 + */ +public class SusParser : IParser +{ + private static readonly Dictionary TypeMap = new() + { + [0x01] = "TAP", + [0x02] = "CHR", + [0x03] = "FLK", + [0x05] = "HLD", + [0x06] = "SLD", + [0x07] = "AIR", + [0x08] = "AHD", + [0x09] = "ADW", + [0x0A] = "MNE", + }; + + public (SusChart, List) Parse(string text) + { + var chart = new SusChart(); + var alerts = new List(); + var lines = text.Replace("\r\n", "\n").Split('\n'); + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i]; + if (string.IsNullOrWhiteSpace(line)) continue; + + if (!line.StartsWith('#')) + { + alerts.Add(new Alert(Warning, $"意外的行(不以 # 开头): {line}") { Line = i + 1 }); + continue; + } + + var content = line[1..]; + + if (IsHeaderLine(content)) + { + ParseHeaderLine(content, chart, alerts, i + 1); + } + else + { + ParseNoteLine(content, chart, alerts, i + 1); + } + } + + return (chart, alerts); + } + + private static bool IsHeaderLine(string content) + { + return content.StartsWith("TITLE ") + || content.StartsWith("ARTIST ") + || content.StartsWith("DESIGNER ") + || content.StartsWith("BPM_DEF ") + || content.StartsWith("REQUEST "); + } + + private static void ParseHeaderLine(string content, SusChart chart, List alerts, int lineNum) + { + if (content.StartsWith("TITLE ")) + { + chart.Title = Unquote(content[6..]); + } + else if (content.StartsWith("ARTIST ")) + { + chart.Artist = Unquote(content[7..]); + } + else if (content.StartsWith("DESIGNER ")) + { + chart.Designer = Unquote(content[9..]); + } + else if (content.StartsWith("BPM_DEF ")) + { + var bpmStr = content[8..].Trim().Trim('"'); + if (double.TryParse(bpmStr, out var bpm)) + chart.Bpm = bpm; + else + alerts.Add(new Alert(Warning, $"BPM_DEF 格式错误: {content}") { Line = lineNum }); + } + else if (content.StartsWith("REQUEST ")) + { + var reqStr = content[8..].Trim().Trim('"'); + if (int.TryParse(reqStr, out var ticks)) + chart.TicksPerBeat = ticks; + else + alerts.Add(new Alert(Warning, $"REQUEST 格式错误: {content}") { Line = lineNum }); + } + } + + private static void ParseNoteLine(string content, SusChart chart, List alerts, int lineNum) + { + var colonIdx = content.IndexOf(':'); + if (colonIdx < 0) + { + alerts.Add(new Alert(Warning, $"音符行缺少冒号: {content}") { Line = lineNum }); + return; + } + + var timingStr = content[..colonIdx]; + var dataStr = content[(colonIdx + 1)..]; + + if (timingStr.Length < 4) + { + alerts.Add(new Alert(Warning, $"音符行时序部分过短: {content}") { Line = lineNum }); + return; + } + + var measure = HexToInt(timingStr[..2]); + var tick = HexToInt(timingStr[2..4]); + + if (dataStr.Length < 6) + { + alerts.Add(new Alert(Warning, $"音符行数据部分过短: {content}") { Line = lineNum }); + return; + } + + var typeCode = HexToInt(dataStr[..2]); + var lane = HexToInt(dataStr[2..4]); + var width = HexToInt(dataStr[4..6]); + + if (!TypeMap.TryGetValue(typeCode, out var typeName)) + { + alerts.Add(new Alert(Warning, $"未知的音符类型码 0x{typeCode:X2}: {content}") { Line = lineNum }); + return; + } + + var note = new ChuNote + { + Type = typeName, + Measure = measure, + Offset = tick, + Cell = lane / 2, + Width = Math.Max(1, width / 2), + }; + + switch (note.Type) + { + case "TAP": + case "CHR": + case "FLK": + case "MNE": + break; + + case "HLD": + ParseHoldData(dataStr, note, alerts, lineNum); + break; + + case "SLD": + ParseSlideData(dataStr, note, alerts, lineNum); + break; + + case "AIR": + case "ADW": + ParseAirTarget(dataStr, note, alerts, lineNum); + break; + + case "AHD": + ParseAhdData(dataStr, note, alerts, lineNum); + break; + } + + chart.Notes.Add(note); + } + + private static void ParseHoldData(string dataStr, ChuNote note, List alerts, int lineNum) + { + if (dataStr.Length >= 10) + { + note.HoldDuration = HexToInt(dataStr[6..10]); + } + else + { + alerts.Add(new Alert(Warning, $"HLD 音符缺少时长: {dataStr}") { Line = lineNum, RelevantNote = FormatNoteRef(note) }); + } + } + + private static void ParseSlideData(string dataStr, ChuNote note, List alerts, int lineNum) + { + if (dataStr.Length >= 10) + { + note.SlideDuration = HexToInt(dataStr[6..10]); + } + else + { + alerts.Add(new Alert(Warning, $"SLD 音符缺少时长: {dataStr}") { Line = lineNum, RelevantNote = FormatNoteRef(note) }); + return; + } + + if (dataStr.Length >= 14) + { + note.EndCell = HexToInt(dataStr[10..12]) / 2; + note.EndWidth = Math.Max(1, HexToInt(dataStr[12..14]) / 2); + } + } + + private static void ParseAirTarget(string dataStr, ChuNote note, List alerts, int lineNum) + { + if (dataStr.Length >= 8) + { + note.TargetNote = HexToInt(dataStr[6..8]).ToString(); + } + else + { + alerts.Add(new Alert(Warning, $"AIR/ADW 音符缺少目标: {dataStr}") { Line = lineNum, RelevantNote = FormatNoteRef(note) }); + } + } + + private static void ParseAhdData(string dataStr, ChuNote note, List alerts, int lineNum) + { + if (dataStr.Length >= 10) + { + note.AirHoldDuration = HexToInt(dataStr[6..10]); + } + else + { + alerts.Add(new Alert(Warning, $"AHD 音符缺少时长: {dataStr}") { Line = lineNum, RelevantNote = FormatNoteRef(note) }); + } + } + + private static int HexToInt(string hex) + { + if (hex.All(c => c is >= '0' and <= '9' or >= 'A' and <= 'F' or >= 'a' and <= 'f')) + return Convert.ToInt32(hex, 16); + return 0; + } + + private static string Unquote(string s) + { + var trimmed = s.Trim(); + if (trimmed.Length >= 2 && trimmed[0] == '"' && trimmed[^1] == '"') + return trimmed[1..^1]; + return trimmed; + } + + private static string FormatNoteRef(ChuNote note) + { + return $"#{note.Measure:X2}{note.Offset:X2}:{note.Type}"; + } +} diff --git a/parser/chu/UgcParser.cs b/parser/chu/UgcParser.cs new file mode 100644 index 0000000..cceb6e6 --- /dev/null +++ b/parser/chu/UgcParser.cs @@ -0,0 +1,430 @@ +using MuConvert.chart; +using MuConvert.parser; +using MuConvert.utils; +using static MuConvert.utils.Alert.LEVEL; + +namespace MuConvert.chu; + +/** + * UGC 格式解析器(UMIGURI 格式,@TICKS=480 tick/拍)。 + * @HEADER 标签 + #measure'tick:code 音符格式。 + */ +public class UgcParser : IParser +{ + private static readonly Dictionary AirDirections = new() + { + ["UC"] = "AIR", + ["UR"] = "AUR", + ["UL"] = "AUL", + ["DC"] = "ADW", + ["DR"] = "ADR", + ["DL"] = "ADL", + ["HD"] = "AHD", + }; + + private static readonly Dictionary ChrExtras = new() + { + ["U"] = "UP", + ["D"] = "DW", + ["C"] = "CE", + }; + + public (UgcChart, List) Parse(string text) + { + var chart = new UgcChart(); + var alerts = new List(); + var lines = text.Replace("\r\n", "\n").Split('\n'); + var inHeader = true; + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i]; + if (string.IsNullOrWhiteSpace(line)) continue; + + if (inHeader) + { + if (line == "@ENDHEAD") + { + inHeader = false; + continue; + } + ParseHeaderLine(line, chart, alerts, i + 1); + } + else + { + i = ParseNoteLine(lines, i, chart, alerts); + } + } + + return (chart, alerts); + } + + private static void ParseHeaderLine(string line, UgcChart chart, List alerts, int lineNum) + { + if (!line.StartsWith('@')) + { + alerts.Add(new Alert(Warning, $"意外的非头部行: {line}") { Line = lineNum }); + return; + } + + var spaceIdx = line.IndexOf('\t'); + var tag = spaceIdx > 0 ? line[..spaceIdx] : line; + var value = spaceIdx > 0 ? line[(spaceIdx + 1)..].Trim() : ""; + + switch (tag) + { + case "@VER": + chart.Version = value; + break; + + case "@TITLE": + chart.Title = value; + break; + + case "@ARTIST": + chart.Artist = value; + break; + + case "@DESIGN": + chart.Designer = value; + break; + + case "@DIFF": + if (int.TryParse(value, out var diff)) + { + chart.Difficulty = diff switch + { + 0 => "BASIC", + 1 => "ADVANCED", + 2 => "EXPERT", + 3 => "MASTER", + 4 => "ULTIMA", + _ => value, + }; + } + else + { + chart.Difficulty = value; + } + break; + + case "@LEVEL": + if (int.TryParse(value, out var level)) + chart.Level = level; + else + alerts.Add(new Alert(Warning, $"@LEVEL 格式错误: {line}") { Line = lineNum }); + break; + + case "@CONST": + if (double.TryParse(value, out var constant)) + chart.Constant = constant; + else + alerts.Add(new Alert(Warning, $"@CONST 格式错误: {line}") { Line = lineNum }); + break; + + case "@SONGID": + chart.SongId = value; + break; + + case "@TICKS": + if (int.TryParse(value, out var ticks)) + chart.TicksPerBeat = ticks; + else + alerts.Add(new Alert(Warning, $"@TICKS 格式错误: {line}") { Line = lineNum }); + break; + + case "@BEAT": + var beatParts = value.Split(' '); + if (beatParts.Length >= 3 + && int.TryParse(beatParts[0], out var beatMeasure) + && int.TryParse(beatParts[1], out var beatNum) + && int.TryParse(beatParts[2], out var beatDen)) + { + chart.BeatEvents.Add((beatMeasure, beatNum, beatDen)); + } + else + { + alerts.Add(new Alert(Warning, $"@BEAT 格式错误: {line}") { Line = lineNum }); + } + break; + + case "@BPM": + var bpmPart = value; + var bpmSpaceIdx = bpmPart.IndexOf(' '); + if (bpmSpaceIdx > 0) + { + var measureOffset = bpmPart[..bpmSpaceIdx]; + var bpmValueStr = bpmPart[(bpmSpaceIdx + 1)..]; + var apostropheIdx = measureOffset.IndexOf('\''); + if (apostropheIdx > 0 + && int.TryParse(measureOffset[..apostropheIdx], out var bpmMeasure) + && int.TryParse(measureOffset[(apostropheIdx + 1)..], out var bpmOffset) + && double.TryParse(bpmValueStr, out var bpmValue)) + { + chart.BpmEvents.Add((bpmMeasure, bpmOffset, bpmValue)); + } + else + { + alerts.Add(new Alert(Warning, $"@BPM 格式错误: {line}") { Line = lineNum }); + } + } + else + { + alerts.Add(new Alert(Warning, $"@BPM 格式错误: {line}") { Line = lineNum }); + } + break; + + default: + alerts.Add(new Alert(Info, $"未知头部标签: {tag}") { Line = lineNum }); + break; + } + } + + private static int ParseNoteLine(string[] lines, int idx, UgcChart chart, List alerts) + { + var line = lines[idx]; + var lineNum = idx + 1; + + var colonIdx = line.IndexOf(':'); + if (colonIdx < 0) + { + alerts.Add(new Alert(Warning, $"无法解析的音符行: {line}") { Line = lineNum }); + return idx; + } + + var prefix = line[..colonIdx]; + var code = line[(colonIdx + 1)..]; + var hashIdx = prefix.IndexOf('#'); + var apostropheIdx = prefix.IndexOf('\''); + if (hashIdx < 0 || apostropheIdx < 0 || apostropheIdx <= hashIdx + 1) + { + alerts.Add(new Alert(Warning, $"音符行前缀格式错误: {line}") { Line = lineNum }); + return idx; + } + + if (!int.TryParse(prefix[(hashIdx + 1)..apostropheIdx], out var measure)) + { + alerts.Add(new Alert(Warning, $"无法解析 measure: {line}") { Line = lineNum }); + return idx; + } + if (!int.TryParse(prefix[(apostropheIdx + 1)..], out var tick)) + { + alerts.Add(new Alert(Warning, $"无法解析 tick: {line}") { Line = lineNum }); + return idx; + } + + if (string.IsNullOrEmpty(code)) + { + alerts.Add(new Alert(Warning, $"音符行为空: {line}") { Line = lineNum }); + return idx; + } + + var note = new ChuNote + { + Measure = measure, + Offset = tick, + }; + + var typeChar = code[0]; + + switch (typeChar) + { + case 't': + ParseTapNote(code, note, alerts, lineNum); + break; + + case 'h': + idx = ParseHoldNote(lines, idx, code, note, alerts); + break; + + case 's': + idx = ParseSlideNote(lines, idx, code, note, alerts); + break; + + case 'a': + ParseAirNote(code, note, alerts, lineNum); + break; + + case 'x': + ParseChrNote(code, note, alerts, lineNum); + break; + + case 'f': + note.Type = "FLK"; + break; + + case 'd': + note.Type = "MNE"; + break; + + default: + alerts.Add(new Alert(Warning, $"未知的音符类型前缀 '{typeChar}': {line}") { Line = lineNum }); + return idx; + } + + chart.Notes.Add(note); + return idx; + } + + private static void ParseTapNote(string code, ChuNote note, List alerts, int lineNum) + { + note.Type = "TAP"; + ParseCellWidth(code, 1, note, alerts, lineNum); + } + + private static int ParseHoldNote(string[] lines, int idx, string code, ChuNote note, List alerts) + { + note.Type = "HLD"; + ParseCellWidth(code, 1, note, alerts, idx + 1); + + if (idx + 1 < lines.Length) + { + var nextLine = lines[idx + 1].Trim(); + if (TryParseFollowerLine(nextLine, out var duration, out _, out _)) + { + note.HoldDuration = duration; + return idx + 1; + } + } + alerts.Add(new Alert(Warning, $"HLD 音符缺少时长跟随行") { Line = idx + 1, RelevantNote = FormatNoteRef(note) }); + return idx; + } + + private static int ParseSlideNote(string[] lines, int idx, string code, ChuNote note, List alerts) + { + note.Type = "SLD"; + ParseCellWidth(code, 1, note, alerts, idx + 1); + + if (idx + 1 < lines.Length) + { + var nextLine = lines[idx + 1].Trim(); + if (TryParseFollowerLine(nextLine, out var duration, out var endCell, out var endWidth)) + { + note.SlideDuration = duration; + note.EndCell = endCell; + note.EndWidth = endWidth; + return idx + 1; + } + } + alerts.Add(new Alert(Warning, $"SLD 音符缺少时长跟随行") { Line = idx + 1, RelevantNote = FormatNoteRef(note) }); + return idx; + } + + private static bool TryParseFollowerLine(string line, out int duration, out int endCell, out int endWidth) + { + duration = 0; + endCell = 0; + endWidth = 1; + + if (!line.StartsWith('#')) return false; + + var gtSIdx = line.IndexOf(">s"); + if (gtSIdx < 1) return false; + + var durationStr = line[1..gtSIdx]; + if (!int.TryParse(durationStr, out duration)) return false; + + var afterMarker = line[(gtSIdx + 2)..]; + if (afterMarker.Length >= 2) + { + endCell = HexCharToInt(afterMarker[0]); + endWidth = WidthHexCharToInt(afterMarker[1]); + } + + return true; + } + + private static void ParseCellWidth(string code, int startIdx, ChuNote note, List alerts, int lineNum) + { + if (code.Length > startIdx) + { + note.Cell = HexCharToInt(code[startIdx]); + if (code.Length > startIdx + 1) + note.Width = WidthHexCharToInt(code[startIdx + 1]); + else + alerts.Add(new Alert(Warning, $"音符缺少 width: {code}") { Line = lineNum, RelevantNote = FormatNoteRef(note) }); + } + else + { + alerts.Add(new Alert(Warning, $"音符缺少 cell 和 width: {code}") { Line = lineNum, RelevantNote = FormatNoteRef(note) }); + } + } + + private static void ParseAirNote(string code, ChuNote note, List alerts, int lineNum) + { + var remaining = code[1..]; + var underscoreIdx = remaining.IndexOf('_'); + var mainPart = underscoreIdx >= 0 ? remaining[..underscoreIdx] : remaining; + + if (mainPart.Length < 2) + { + alerts.Add(new Alert(Warning, $"AIR 音符方向代码过短: {code}") { Line = lineNum }); + note.Type = "AIR"; + return; + } + + var dir = mainPart[..2]; + if (AirDirections.TryGetValue(dir, out var airType)) + { + note.Type = airType; + } + else + { + note.Type = "AIR"; + alerts.Add(new Alert(Warning, $"未知的 AIR 方向: {dir}") { Line = lineNum, RelevantNote = FormatNoteRef(note) }); + } + + if (mainPart.Length > 2) + { + note.TargetNote = mainPart[2].ToString(); + } + else + { + note.TargetNote = "N"; + } + + if (underscoreIdx >= 0 && note.Type == "AHD") + { + var durStr = remaining[(underscoreIdx + 1)..]; + if (int.TryParse(durStr, out var ahdDuration)) + note.AirHoldDuration = ahdDuration; + } + } + + private static void ParseChrNote(string code, ChuNote note, List alerts, int lineNum) + { + note.Type = "CHR"; + var extra = code[1..]; + if (ChrExtras.TryGetValue(extra, out var chrDir)) + note.Extra = chrDir; + else + note.Extra = extra; + } + + private static int HexCharToInt(char c) + { + return c switch + { + >= '0' and <= '9' => c - '0', + >= 'A' and <= 'F' => c - 'A' + 10, + >= 'a' and <= 'f' => c - 'a' + 10, + _ => 0, + }; + } + + private static int WidthHexCharToInt(char c) + { + return c switch + { + >= '1' and <= '9' => c - '1' + 1, + >= 'A' and <= 'G' => c - 'A' + 10, + >= 'a' and <= 'g' => c - 'a' + 10, + _ => 1, + }; + } + + private static string FormatNoteRef(ChuNote note) + { + return $"#{note.Measure}'{note.Offset}:{note.Type}"; + } +} diff --git a/tests/chu/ChuTests.cs b/tests/chu/ChuTests.cs new file mode 100644 index 0000000..bd741ca --- /dev/null +++ b/tests/chu/ChuTests.cs @@ -0,0 +1,69 @@ +using MuConvert.chu; +using MuConvert.parser; +using MuConvert.utils; + +namespace MuConvert.Tests.chu; + +public class ChuTests +{ + private static string TestsetDir => Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "chu", "testset"); + private static string OfficialDir => Path.Combine(TestsetDir, "官谱", "B.B.K.K.B.K.K"); + private static string CustomDir => Path.Combine(TestsetDir, "自制谱", "Example"); + private static string C2sPath => Path.Combine(OfficialDir, "0003_00.c2s"); + private static string UgcPath => Path.Combine(CustomDir, "basic.ugc"); + + [Fact] + public void CanParseOfficialC2S() + { + if (!File.Exists(C2sPath)) throw new SkipException($"Missing: {C2sPath}"); + var (chart, _) = new C2sParser().Parse(File.ReadAllText(C2sPath)); + Assert.NotEmpty(chart.Notes); + Assert.Equal(384, chart.Resolution); + } + + [Fact] + public void C2sRoundTrip() + { + if (!File.Exists(C2sPath)) throw new SkipException($"Missing: {C2sPath}"); + var (chart, _) = new C2sParser().Parse(File.ReadAllText(C2sPath)); + var (rt, _) = new C2sGenerator().Generate(chart); + var (reparsed, _) = new C2sParser().Parse(rt); + Assert.Equal(chart.Notes.Count, reparsed.Notes.Count); + } + + [Fact] + public void CanParseUgc() + { + if (!File.Exists(UgcPath)) throw new SkipException($"Missing: {UgcPath}"); + var (chart, _) = new UgcParser().Parse(File.ReadAllText(UgcPath)); + Assert.NotEmpty(chart.Notes); + Assert.Equal("MASTER", chart.Difficulty); + } + + [Fact] + public void UgcToC2sViaGenerator() + { + if (!File.Exists(UgcPath)) throw new SkipException($"Missing: {UgcPath}"); + var (ugc, _) = new UgcParser().Parse(File.ReadAllText(UgcPath)); + var (c2sText, _) = new C2sGenerator().Generate(ugc); + Assert.Contains("VERSION", c2sText); + Assert.Contains("TAP\t", c2sText); + } + + [Fact] + public void DumpOutputFiles() + { + if (!File.Exists(UgcPath)) throw new SkipException($"Missing: {UgcPath}"); + var (ugc, _) = new UgcParser().Parse(File.ReadAllText(UgcPath)); + var (c2sText, _) = new C2sGenerator().Generate(ugc); + File.WriteAllText(Path.Combine(OfficialDir, "basic_output.c2s"), c2sText); + + if (!File.Exists(C2sPath)) throw new SkipException($"Missing: {C2sPath}"); + var (c2s, _) = new C2sParser().Parse(File.ReadAllText(C2sPath)); + var (ugcText, _) = new UgcGenerator().Generate(c2s); + File.WriteAllText(Path.Combine(OfficialDir, "0003_output.ugc"), ugcText); + + Assert.True(File.Exists(Path.Combine(OfficialDir, "basic_output.c2s"))); + Assert.True(File.Exists(Path.Combine(OfficialDir, "0003_output.ugc"))); + } +} diff --git a/tests/chu/example.cs b/tests/chu/example.cs deleted file mode 100644 index 8ea2b17..0000000 --- a/tests/chu/example.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace MuConvert.Tests.chu; - -public class Example -{ - // TODO 示例测试,仅用来把文件夹创出来(git不会跟踪空文件夹) - // 之后删掉即可 - [Fact] - public void ExampleTest() - { - Assert.Equal(1, 1); - } -} \ No newline at end of file diff --git a/tests/chu/testset/placeholder.txt b/tests/chu/testset/placeholder.txt deleted file mode 100644 index a94df34..0000000 --- a/tests/chu/testset/placeholder.txt +++ /dev/null @@ -1,2 +0,0 @@ -TODO 示例测试数据,仅用来把文件夹创出来(git不会跟踪空文件夹) -中二相关的测试数据,请按一定的结构组织在这里(tests/chu/testset)下。 \ No newline at end of file diff --git "a/tests/chu/testset/\345\256\230\350\260\261/B.B.K.K.B.K.K/0003_00.c2s" "b/tests/chu/testset/\345\256\230\350\260\261/B.B.K.K.B.K.K/0003_00.c2s" new file mode 100644 index 0000000000000000000000000000000000000000..957c33493b3f9d029d5a386916d8e67145a85622 GIT binary patch literal 9724 zcmb7KNpd7b5X@_1#vkSdSnu6BHc}4~BWVWI0^_9TapTHg_yZ^I4B_eWj?Bu4GQ(ur z%473bGPA4d>%YI0bNRWvl!x-UJeNgTmG8>39P7{J@c*~voAOj%tNg8@n}RDZX5%Nj zdNvEU_Vc0K8Mdt0o&ERGz8(yDEsy0=5n_)f3;&Px8vMfAt;KULCzJeae=W-2%RiUD zFF$|8JHX!k2c3BBZI9u92Su~5Xd)GhOZ*skuFCjN&Z`WgNWpS>nR;0N$ zE$U5CSVY95RUq`}u__v1xyM6l6G+Aa*u2f0#m&3N)q4)woC2fI43*amm){6q)$GL? zB$n|dD&J%NiOY9{uijaY^;uaa#X3XfIm6}I@q6oftTW65IO7{QZ6WI?n-0tmW#mJ0 zw9r;(Wh3g17)`LXl!J|}<5+7MO4xOxW!1BsD=V)?PH7D{MRZeprcNEaPV^?*R z5bxZA{I?P>*9vMzRT-|KF&i+DKg#zN1TJrmyDv>AYlJ-vE4g|^Yx;fdn{};-ylF*y zwKWhvZ%ZV_Nr6Sl$@kjgW{s5v_s(cqChNL-$hR~`32|*D+@t!Lb$5TB8Fk?Ly56^z zdNl6?YR^ZDPKu&+ANl&fuDeY(14QM0?D4go53;!?M$1tZ=63>Wi&dnLXprqFbEn;eGV&q0aW~mqcayPt#Io8Gm4{4QEFv7tczt@t+SJu9&mT19+4G|r z-CS3RsA|4fWT-4>xHv_)R)@G3{e+m~@*YcQjfnCIEU#9(Mr7Jz7U5uv>lv1NhNf6W z+FBzrY}yCf(>C4uz-su8)=P<*vg&f0s%^0IF>21$eLcxhN^IPktoSD@UD-}1Ou6W- zVh@y6=3!wwH&2glQ+wui(&4VFh}u1PTlXbc%~9q!#}!*E;nAooIg1|6SkY}=v&67J zy%L*NTT@L`ag0;Ht=*7WZg-M#ZgJe$h= zF6pgBtvIvmvdEeH@ha&w*hj4sH`c7aPP^KDtW$=HZ-#3g9oM|>uHA}QkuRN5M+u;< zgjY-*8Q0TS${ZJiTtXaT3B)A^)BFKr{_t0X%6o)sy&Pv?S7#ubAfmFIX^Yi}@2^wh zov4fxqvs5j=aCb&Ip0ZFap+0L~SFHn4RsAd#We%t!%(N9>q>r@$dXX-$Jo7;3JZp7` zy!zS@)eNXCXWHTv>EjytOy_=ISF*W^LwC-1zAo%%*9D@o97PSwIV;@P&R7v6E6ovt}>|kmu zO0w#$BU_%=5z9N}Q0H2{HQT)fO?Bkir`l1kSUc6MXk0N(uViF_arMz?9^+W&Ch}14 zn#^;BzB27S=#>IXWIFZ^l(~+o2vpu9JfF!!IFrf}QCVgkn_KMHz(-uE)NC=DvZ7S>zkn=6q|uW8Xmac?z}ZPoT%m8Gjy62 zb@Z`*GLTlwO zMmR;~5l@MV{<=^*Kbu={Q)^C%zHU-NE&sqxEDTAdeOux()Y{72mTx zHNIG68EeNQ-j0sU9UHkjHnMkYN#k!I18`!8NVuzXEC1jXX_=r0T5?^ zSj3-5#GXjRok+x-hn=u6s;8B zAJJOs*@n!sb-`n233~$T;_E>xDQmckkGEdDfAs#yn^gttEi8$3i}8%nW>$_wwi#r}8)1<%n( mn|q5ei$$NnB7cXf7sttS=c?&y&nM3hF?KVPmz&NXc>D($m0*eh literal 0 HcmV?d00001 diff --git "a/tests/chu/testset/\350\207\252\345\210\266\350\260\261/Example/basic.ugc" "b/tests/chu/testset/\350\207\252\345\210\266\350\260\261/Example/basic.ugc" new file mode 100644 index 0000000..e7bb9dd --- /dev/null +++ "b/tests/chu/testset/\350\207\252\345\210\266\350\260\261/Example/basic.ugc" @@ -0,0 +1,375 @@ +' Created with Margrete v1.5.0.1-2852550 +@VER 6 +@EXVER 1 +@TITLE Example +@SORT EXAMPLE +@ARTIST Artist +@DESIGN inonote +@DIFF 3 +@LEVEL 1 +@CONST 1.00000 +@SONGID umgr_example +@BGM +@BGMOFS 0.00000 +@BGMPRV 0.00000 0.00000 +@JACKET jacket.png +@BGIMG +@BGMODE PASSIVE FALSE +@FLDCOL 0 +@FLDIMG +@FLAG DIFFTTL FALSE +@FLAG SOFFSET TRUE +@FLAG CLICK TRUE +@FLAG EXLONG TRUE +@FLAG BGMWCMP TRUE +@ATINFO AUTHORS +@ATINFO SITES +@DLURL +@COPYRIGHT +@LICENSE +@TICKS 480 +@BEAT 0 4 4 +@BPM 0'0 120.00000 +@TIL 0 0'0 1.00000 +@TIL 3 25'0 -1.00000 +@TIL 2 25'240 -1.00000 +@TIL 1 25'480 -1.00000 +@TIL 3 25'480 1.00000 +@TIL 2 25'720 1.00000 +@TIL 1 25'960 1.00000 +@SPDMOD 22'480 1.00000 +@SPDMOD 22'600 1.50000 +@SPDMOD 22'720 2.00000 +@SPDMOD 22'840 2.50000 +@SPDMOD 22'960 3.00000 +@SPDMOD 22'1080 3.50000 +@SPDMOD 22'1200 4.00000 +@SPDMOD 22'1320 4.50000 +@SPDMOD 22'1440 5.00000 +@SPDMOD 22'1916 2.00000 +@SPDMOD 23'0 1.00000 +@SPDMOD 23'480 1.00000 +@SPDMOD 23'540 -0.06250 +@SPDMOD 23'600 1.50000 +@SPDMOD 23'660 -0.06250 +@SPDMOD 23'720 2.00000 +@SPDMOD 23'780 -0.06250 +@SPDMOD 23'840 2.50000 +@SPDMOD 23'900 -0.06250 +@SPDMOD 23'960 3.00000 +@SPDMOD 23'1020 -0.06250 +@SPDMOD 23'1080 3.50000 +@SPDMOD 23'1140 -0.06250 +@SPDMOD 23'1200 4.00000 +@SPDMOD 23'1260 -0.06250 +@SPDMOD 23'1320 4.50000 +@SPDMOD 23'1380 -0.06250 +@SPDMOD 23'1440 5.00000 +@SPDMOD 23'1500 -0.06250 +@SPDMOD 23'1916 0.12500 +@SPDMOD 24'0 1.00000 +@SPDMOD 24'960 0.50000 +@SPDMOD 24'964 1.00000 +@SPDMOD 24'1440 2.00000 +@SPDMOD 24'1444 1.00000 +@MAINTIL 0 +@ENDHEAD + +#0'0:t04 +#0'480:t44 +#0'960:t84 +#0'1440:tC4 +#1'0:x04U +#1'480:x44U +#1'960:x84U +#1'1440:xC4U +#2'0:x04U +#2'240:x44D +#2'480:x84C +#2'720:xC4L +#2'960:x04R +#2'1200:x44A +#2'1440:x84W +#2'1680:xC4I +#3'0:f04A +#3'480:f44A +#3'960:f84A +#3'1440:fC4A +#4'0:f02R +#4'60:f22R +#4'120:f42R +#4'180:f62R +#4'240:f82R +#4'300:fA2R +#4'360:fC2R +#4'420:fE2R +#4'960:fE2L +#4'1020:fC2L +#4'1080:fA2L +#4'1140:f82L +#4'1200:f62L +#4'1260:f42L +#4'1320:f22L +#4'1380:f02L +#5'0:s04 +#960>sC4 +#6'0:s04 +#480>cC4 +#960>s04 +#1440>sC4 +#7'0:h64 +#960>s +#8'0:t48 +#8'0:a48UCN +#8'480:t48 +#8'480:a48ULN +#8'960:t48 +#8'960:a48URN +#8'1440:t48 +#8'1440:a48DCN +#9'0:t48 +#9'0:a48DLN +#9'480:t48 +#9'480:a48DRN +#9'960:t48 +#9'960:a48UCI +#9'1440:t48 +#9'1440:a48DCI +#10'0:t48 +#10'0:H488N +#480>s +#960>s +#11'0:t48 +#11'0:S488N +#480>c888 +#960>s088 +#1080>c588 +#1200>c788 +#1320>c888 +#1560>c888 +#1680>c788 +#1800>c588 +#1920>s088 +#2040>c08E +#2160>c08G +#2280>c08E +#2400>c088 +#2520>c082 +#2640>c080 +#2760>c082 +#2880>s088 +#13'0:t04 +#13'0:S040N +#120>c148 +#240>c24C +#360>c34E +#480>c44F +#600>c54F +#720>c64C +#840>c748 +#960>s841 +#13'0:t44 +#13'0:S440N +#120>c548 +#240>c64C +#360>c74E +#480>c84F +#600>c94F +#720>cA4C +#840>cB48 +#960>sC41 +#14'0:C0400 +#5>s +#10>s048 +#14'480:C4400 +#5>s +#10>s448 +#14'960:C8400 +#5>s +#10>s848 +#14'1440:CC400 +#5>s +#10>sC48 +#15'0:T6480 +#720>cC48 +#1440>sC48 +#15'0:T0480 +#720>c848 +#1440>s048 +#16'0:C0480 +#240>s +#480>s +#719>c648 +#720>s +#960>s +#1200>s +#1440>s088 +#16'0:C6480 +#240>s +#480>s +#719>cC48 +#720>s +#960>s +#1200>s +#1440>sC48 +#18'0:C040Z +#4>s +#8>s +#12>s828 +#18'480:CC40Z +#4>s +#8>s +#12>s628 +#18'960:C040Z +#4>s +#8>s +#12>s828 +#18'960:CC48Z +#4>s +#8>s +#12>s620 +#18'1440:C048Z +#4>s +#8>s +#12>s820 +#18'1440:CC40Z +#4>s +#8>s +#12>s628 +#19'0:t04 +#19'0:H048N +#1440>c +#19'0:CC480 +#480>s +#1440>cC48 +#19'0:t44 +#19'0:S448N +#480>s488 +#960>c488 +#1440>c448 +#20'0:TC403 +#1>cC48 +#1440>s048 +#20'0:t64 +#20'0:S648N +#1440>sC48 +#20'0:tC4 +#20'0:SC48N +#1440>s048 +#20'0:t04 +#20'0:S048N +#1440>s648 +#21'0:d04 +#21'240:d44 +#21'480:d84 +#21'720:dC4 +#21'960:d84 +#21'1200:d44 +#21'1440:d04 +#22'0:h04 +#1916>s +#23'0:hC4 +#1916>s +#24'0:s04 +#960>sC4 +#1440>s04 +#22'480:d64 +#22'600:d64 +#22'840:d64 +#22'720:d64 +#22'960:d64 +#22'1080:d64 +#22'1200:d64 +#22'1320:d64 +#22'1440:d64 +#23'480:d64 +#23'600:d64 +#23'840:d64 +#23'720:d64 +#23'960:d64 +#23'1080:d64 +#23'1200:d64 +#23'1320:d64 +#23'1440:d64 +#22'480:d64 +#22'600:d64 +#22'840:d64 +#22'720:d64 +#22'960:d64 +#22'1080:d64 +#22'1200:d64 +#22'1320:d64 +#22'1440:d64 +#23'480:d64 +#23'600:d64 +#23'840:d64 +#23'720:d64 +#23'960:d64 +#23'1080:d64 +#23'1200:d64 +#23'1320:d64 +#23'1440:d64 +#23'480:d64 +#23'600:d64 +#23'840:d64 +#23'720:d64 +#23'960:d64 +#23'1080:d64 +#23'1200:d64 +#23'1320:d64 +#23'1440:d64 +#23'540:d04 +#23'660:d04 +#23'780:d04 +#23'900:d04 +#23'1020:d04 +#23'1140:d04 +#23'1260:d04 +#23'1380:d04 +#23'1500:d04 +#20'0:T6409 +#1>c648 +#1440>sC48 +#20'0:T0406 +#1>c048 +#1440>s648 +#25'0:h64 +@USETIL 1 +#1440>s +@USETIL 0 +#25'0:hA4 +@USETIL 3 +#1440>s +@USETIL 0 +#25'0:h24 +@USETIL 2 +#1440>s +@USETIL 0 +#17'0:T1141 +#1440>s114 +#17'0:T2142 +#1440>s214 +#17'0:T3143 +#1440>s314 +#17'0:T4144 +#1440>s414 +#17'0:T5145 +#1440>s514 +#17'0:T6146 +#1440>s614 +#17'0:T7147 +#1440>s714 +#17'0:T8148 +#1440>s814 +#17'0:TA14A +#1440>sA14 +#17'0:TC14Y +#1440>sC14 +#17'0:TB14B +#1440>sB14 +#17'0:T9149 +#1440>s914 +#17'0:TD14C +#1440>sD14 +#17'0:TE14D +#1440>sE14 From e2488df113c55a60c8ccba7f6e59fe1e595e70fd Mon Sep 17 00:00:00 2001 From: Applesaber Date: Fri, 1 May 2026 10:55:03 +0800 Subject: [PATCH 02/17] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=95=B0?= =?UTF-8?q?=E5=AD=97=E8=A7=A3=E6=9E=90=E5=8C=BA=E5=9F=9F=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E5=92=8C=20tick=20=E7=BC=A9=E6=94=BE=E6=BA=A2=E5=87=BA?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UgcParser/SusParser: TryParse 统一使用 CultureInfo.InvariantCulture UgcGenerator/SusGenerator: ScaleUp 乘法提升为 long 防溢出 --- generator/chu/SusGenerator.cs | 2 +- generator/chu/UgcGenerator.cs | 4 ++-- parser/chu/SusParser.cs | 5 +++-- parser/chu/UgcParser.cs | 30 ++++++++++++++++-------------- 4 files changed, 22 insertions(+), 19 deletions(-) diff --git a/generator/chu/SusGenerator.cs b/generator/chu/SusGenerator.cs index 0911079..e13b601 100644 --- a/generator/chu/SusGenerator.cs +++ b/generator/chu/SusGenerator.cs @@ -52,7 +52,7 @@ private static SusChart ConvertToSus(IChuChart chart, List alerts) private static ChuNote ScaleUp(ChuNote n) { - int s(int v) => v * SusTpb / (C2sRsl / 4); + int s(int v) => (int)((long)v * SusTpb / (C2sRsl / 4)); return new ChuNote { Type = n.Type, Measure = n.Measure, Offset = s(n.Offset), diff --git a/generator/chu/UgcGenerator.cs b/generator/chu/UgcGenerator.cs index 0897b3f..383064d 100644 --- a/generator/chu/UgcGenerator.cs +++ b/generator/chu/UgcGenerator.cs @@ -51,7 +51,7 @@ private static UgcChart ConvertToUgc(IChuChart chart, List alerts) private static ChuNote ScaleUpNote(ChuNote n) { - int s(int v) => v * UgcTicksPerBeat / (C2sResolution / 4); + int s(int v) => (int)((long)v * UgcTicksPerBeat / (C2sResolution / 4)); return new ChuNote { Type = n.Type, Measure = n.Measure, Offset = s(n.Offset), @@ -64,7 +64,7 @@ private static ChuNote ScaleUpNote(ChuNote n) }; } - private static int ScaleUp(int v) => v * UgcTicksPerBeat / (C2sResolution / 4); + private static int ScaleUp(int v) => (int)((long)v * UgcTicksPerBeat / (C2sResolution / 4)); private static bool IsAir(string t) => t is "AIR" or "AUR" or "AUL" or "AHD" or "ADW" or "ADR" or "ADL"; diff --git a/parser/chu/SusParser.cs b/parser/chu/SusParser.cs index 73ee265..79ff15d 100644 --- a/parser/chu/SusParser.cs +++ b/parser/chu/SusParser.cs @@ -1,3 +1,4 @@ +using System.Globalization; using MuConvert.chart; using MuConvert.parser; using MuConvert.utils; @@ -82,7 +83,7 @@ private static void ParseHeaderLine(string content, SusChart chart, List else if (content.StartsWith("BPM_DEF ")) { var bpmStr = content[8..].Trim().Trim('"'); - if (double.TryParse(bpmStr, out var bpm)) + if (double.TryParse(bpmStr, NumberStyles.Float, CultureInfo.InvariantCulture, out var bpm)) chart.Bpm = bpm; else alerts.Add(new Alert(Warning, $"BPM_DEF 格式错误: {content}") { Line = lineNum }); @@ -90,7 +91,7 @@ private static void ParseHeaderLine(string content, SusChart chart, List else if (content.StartsWith("REQUEST ")) { var reqStr = content[8..].Trim().Trim('"'); - if (int.TryParse(reqStr, out var ticks)) + if (int.TryParse(reqStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ticks)) chart.TicksPerBeat = ticks; else alerts.Add(new Alert(Warning, $"REQUEST 格式错误: {content}") { Line = lineNum }); diff --git a/parser/chu/UgcParser.cs b/parser/chu/UgcParser.cs index cceb6e6..1913126 100644 --- a/parser/chu/UgcParser.cs +++ b/parser/chu/UgcParser.cs @@ -1,3 +1,4 @@ +using System.Globalization; using MuConvert.chart; using MuConvert.parser; using MuConvert.utils; @@ -90,7 +91,7 @@ private static void ParseHeaderLine(string line, UgcChart chart, List ale break; case "@DIFF": - if (int.TryParse(value, out var diff)) + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var diff)) { chart.Difficulty = diff switch { @@ -109,14 +110,14 @@ private static void ParseHeaderLine(string line, UgcChart chart, List ale break; case "@LEVEL": - if (int.TryParse(value, out var level)) + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var level)) chart.Level = level; else alerts.Add(new Alert(Warning, $"@LEVEL 格式错误: {line}") { Line = lineNum }); break; case "@CONST": - if (double.TryParse(value, out var constant)) + if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var constant)) chart.Constant = constant; else alerts.Add(new Alert(Warning, $"@CONST 格式错误: {line}") { Line = lineNum }); @@ -127,7 +128,7 @@ private static void ParseHeaderLine(string line, UgcChart chart, List ale break; case "@TICKS": - if (int.TryParse(value, out var ticks)) + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ticks)) chart.TicksPerBeat = ticks; else alerts.Add(new Alert(Warning, $"@TICKS 格式错误: {line}") { Line = lineNum }); @@ -136,9 +137,9 @@ private static void ParseHeaderLine(string line, UgcChart chart, List ale case "@BEAT": var beatParts = value.Split(' '); if (beatParts.Length >= 3 - && int.TryParse(beatParts[0], out var beatMeasure) - && int.TryParse(beatParts[1], out var beatNum) - && int.TryParse(beatParts[2], out var beatDen)) + && int.TryParse(beatParts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var beatMeasure) + && int.TryParse(beatParts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var beatNum) + && int.TryParse(beatParts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var beatDen)) { chart.BeatEvents.Add((beatMeasure, beatNum, beatDen)); } @@ -157,9 +158,9 @@ private static void ParseHeaderLine(string line, UgcChart chart, List ale var bpmValueStr = bpmPart[(bpmSpaceIdx + 1)..]; var apostropheIdx = measureOffset.IndexOf('\''); if (apostropheIdx > 0 - && int.TryParse(measureOffset[..apostropheIdx], out var bpmMeasure) - && int.TryParse(measureOffset[(apostropheIdx + 1)..], out var bpmOffset) - && double.TryParse(bpmValueStr, out var bpmValue)) + && int.TryParse(measureOffset[..apostropheIdx], NumberStyles.Integer, CultureInfo.InvariantCulture, out var bpmMeasure) + && int.TryParse(measureOffset[(apostropheIdx + 1)..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var bpmOffset) + && double.TryParse(bpmValueStr, NumberStyles.Float, CultureInfo.InvariantCulture, out var bpmValue)) { chart.BpmEvents.Add((bpmMeasure, bpmOffset, bpmValue)); } @@ -202,12 +203,12 @@ private static int ParseNoteLine(string[] lines, int idx, UgcChart chart, List= 2) @@ -386,7 +387,7 @@ private static void ParseAirNote(string code, ChuNote note, List alerts, if (underscoreIdx >= 0 && note.Type == "AHD") { var durStr = remaining[(underscoreIdx + 1)..]; - if (int.TryParse(durStr, out var ahdDuration)) + if (int.TryParse(durStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ahdDuration)) note.AirHoldDuration = ahdDuration; } } @@ -428,3 +429,4 @@ private static string FormatNoteRef(ChuNote note) return $"#{note.Measure}'{note.Offset}:{note.Type}"; } } + From e4619d1242167002c80d4e394b3f7c71298ea58b Mon Sep 17 00:00:00 2001 From: Applesaber Date: Fri, 1 May 2026 11:34:38 +0800 Subject: [PATCH 03/17] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20review=20bot?= =?UTF-8?q?=20=E5=8F=91=E7=8E=B0=E7=9A=84=209=20=E4=B8=AA=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: ALD/ASD 序列化缺失、@BEAT tab/space 不一致、SUS MNE 类型码、SUS tick 位数、ko/ja locale null crash、UGC→SUS 缩放错误 P2: EndTime=0、Dump 写临时目录、Air TargetNote 覆写、C2sChart 用 Resolution 代替硬编码 --- chart/chu/C2sChart.cs | 4 ++-- chart/chu/SusChart.cs | 2 +- chart/chu/UgcChart.cs | 2 +- generator/chu/C2sGenerator.cs | 1 + generator/chu/SusGenerator.cs | 11 ++++++++++- generator/chu/UgcGenerator.cs | 2 +- i18n/Locale.ja.resx | 8 ++++---- i18n/Locale.ko.resx | 8 ++++---- parser/chu/SusParser.cs | 6 +++--- parser/chu/UgcParser.cs | 4 ++-- tests/chu/ChuTests.cs | 30 ++++++++++++++++++++---------- 11 files changed, 49 insertions(+), 29 deletions(-) diff --git a/chart/chu/C2sChart.cs b/chart/chu/C2sChart.cs index ef6e2e9..7418c5c 100644 --- a/chart/chu/C2sChart.cs +++ b/chart/chu/C2sChart.cs @@ -18,7 +18,7 @@ public class C2sChart : BaseChart, IChuChart public List<(int Measure, int Offset, int Duration, double Multiplier)> SflEvents = []; public override decimal StartBpm => (decimal)(BpmEvents.Count > 0 ? BpmEvents[0].Bpm : DefBpm); - public override decimal StartTime => Notes.Count > 0 ? Notes.Min(n => n.Measure * Resolution + n.Offset) / 384m * 240m / StartBpm : 0; - public override decimal EndTime => Notes.Count > 0 ? Notes.Max(n => n.Measure * Resolution + n.Offset + Math.Max(n.HoldDuration, Math.Max(n.SlideDuration, n.AirHoldDuration))) / 384m * 240m / StartBpm : 0; + public override decimal StartTime => Notes.Count > 0 ? Notes.Min(n => n.Measure * Resolution + n.Offset) / (decimal)Resolution * 240m / StartBpm : 0; + public override decimal EndTime => Notes.Count > 0 ? Notes.Max(n => n.Measure * Resolution + n.Offset + Math.Max(n.HoldDuration, Math.Max(n.SlideDuration, n.AirHoldDuration))) / (decimal)Resolution * 240m / StartBpm : 0; public override int TotalNotes => Notes.Count; } diff --git a/chart/chu/SusChart.cs b/chart/chu/SusChart.cs index 0076053..96e88f8 100644 --- a/chart/chu/SusChart.cs +++ b/chart/chu/SusChart.cs @@ -15,6 +15,6 @@ public class SusChart : BaseChart, IChuChart public override decimal StartBpm => (decimal)Bpm; public override decimal StartTime => Notes.Count > 0 ? Notes.Min(n => n.Measure * TicksPerBeat * 4 + n.Offset) / (decimal)(TicksPerBeat * 4) * 240m / StartBpm : 0; - public override decimal EndTime => 0; + public override decimal EndTime => Notes.Count > 0 ? Notes.Max(n => n.Measure * TicksPerBeat * 4 + n.Offset + Math.Max(n.HoldDuration, Math.Max(n.SlideDuration, n.AirHoldDuration))) / (decimal)(TicksPerBeat * 4) * 240m / StartBpm : 0; public override int TotalNotes => Notes.Count; } diff --git a/chart/chu/UgcChart.cs b/chart/chu/UgcChart.cs index 3b947c6..1141156 100644 --- a/chart/chu/UgcChart.cs +++ b/chart/chu/UgcChart.cs @@ -22,6 +22,6 @@ public class UgcChart : BaseChart, IChuChart public override decimal StartBpm => (decimal)(BpmEvents.Count > 0 ? BpmEvents[0].Bpm : 120.0); public override decimal StartTime => Notes.Count > 0 ? Notes.Min(n => n.Measure * TicksPerBeat * 4 + n.Offset) / (decimal)(TicksPerBeat * 4) * 240m / StartBpm : 0; - public override decimal EndTime => 0; + public override decimal EndTime => Notes.Count > 0 ? Notes.Max(n => n.Measure * TicksPerBeat * 4 + n.Offset + Math.Max(n.HoldDuration, Math.Max(n.SlideDuration, n.AirHoldDuration))) / (decimal)(TicksPerBeat * 4) * 240m / StartBpm : 0; public override int TotalNotes => Notes.Count; } diff --git a/generator/chu/C2sGenerator.cs b/generator/chu/C2sGenerator.cs index a5e5436..16d8d19 100644 --- a/generator/chu/C2sGenerator.cs +++ b/generator/chu/C2sGenerator.cs @@ -115,6 +115,7 @@ private static string Serialize(C2sChart chart) "FLK" => $"FLK\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.Extra}", "AIR" or "AUR" or "AUL" or "ADW" or "ADR" or "ADL" => $"{n.Type}\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.TargetNote}", "AHD" => $"AHD\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.TargetNote}\t{n.AirHoldDuration}", + "ALD" or "ASD" => $"{n.Type}\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.StartHeight}\t{n.SlideDuration}\t{n.EndCell}\t{n.EndWidth}\t{n.TargetHeight}\t{n.NoteColor}", "MNE" => $"MNE\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}", _ => $"TAP\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}" }; diff --git a/generator/chu/SusGenerator.cs b/generator/chu/SusGenerator.cs index e13b601..48bc9a0 100644 --- a/generator/chu/SusGenerator.cs +++ b/generator/chu/SusGenerator.cs @@ -42,7 +42,7 @@ private static SusChart ConvertToSus(IChuChart chart, List alerts) { bpm = ugc.BpmEvents.Count > 0 ? ugc.BpmEvents[0].Bpm : 120.0; var result = new SusChart { Bpm = bpm, TicksPerBeat = SusTpb, Title = ugc.Title, Artist = ugc.Artist }; - foreach (var n in ugc.Notes) result.Notes.Add(ScaleUp(n)); + foreach (var n in ugc.Notes) result.Notes.Add(MapLaneOnly(n)); return result; } @@ -63,6 +63,15 @@ private static ChuNote ScaleUp(ChuNote n) }; } + private static ChuNote MapLaneOnly(ChuNote n) => new() + { + Type = n.Type, Measure = n.Measure, Offset = n.Offset, + Cell = n.Cell * 2, Width = n.Width * 2, + HoldDuration = n.HoldDuration, SlideDuration = n.SlideDuration, + EndCell = n.EndCell * 2, EndWidth = n.EndWidth * 2, + Extra = n.Extra, TargetNote = n.TargetNote, AirHoldDuration = n.AirHoldDuration, + }; + private static string Serialize(SusChart sus) { var sb = new StringBuilder(); diff --git a/generator/chu/UgcGenerator.cs b/generator/chu/UgcGenerator.cs index 383064d..56af0e9 100644 --- a/generator/chu/UgcGenerator.cs +++ b/generator/chu/UgcGenerator.cs @@ -58,7 +58,7 @@ private static ChuNote ScaleUpNote(ChuNote n) Cell = n.Cell, Width = n.Width, HoldDuration = s(n.HoldDuration), SlideDuration = s(n.SlideDuration), EndCell = n.EndCell, EndWidth = n.EndWidth, - Extra = n.Extra, TargetNote = IsAir(n.Type) ? "N" : n.TargetNote, + Extra = n.Extra, TargetNote = IsAir(n.Type) && string.IsNullOrEmpty(n.TargetNote) ? "N" : n.TargetNote, AirHoldDuration = s(n.AirHoldDuration), StartHeight = n.StartHeight, TargetHeight = n.TargetHeight, NoteColor = n.NoteColor, }; diff --git a/i18n/Locale.ja.resx b/i18n/Locale.ja.resx index 1edf3f8..a9f3c6f 100644 --- a/i18n/Locale.ja.resx +++ b/i18n/Locale.ja.resx @@ -271,10 +271,10 @@ 同じ時刻・同じ位置に別のスライド頭/タップが検出されました。ゲーム内で判定問題を引き起こすため、自動修復しました(余分なスライド頭を削除)。PS:同頭スライドを意図する場合は「1-2*-3」のように書き、「1-2/1-3」は避けてください(この問題を引き起こします)。 - - + + 不明なC2Sノートタイプ: {0} - - + + チャートを変換できません: {0} \ No newline at end of file diff --git a/i18n/Locale.ko.resx b/i18n/Locale.ko.resx index 5cbc09a..14ae2ff 100644 --- a/i18n/Locale.ko.resx +++ b/i18n/Locale.ko.resx @@ -271,10 +271,10 @@ 이 슬라이드 헤드와 동일한 시간/위치에 다른 슬라이드 헤드/탭이 감지되었습니다. 게임 내 판정 문제를 유발할 수 있어 자동으로 수정했습니다(중복 슬라이드 헤드 제거). PS: 같은 헤드의 슬라이드를 의도했다면 "1-2*-3" 같은 문법을 사용하세요. "1-2/1-3"는 이 문제를 유발합니다. - - + + Unknown C2S note type: {0} - - + + Cannot convert chart to target format: {0} \ No newline at end of file diff --git a/parser/chu/SusParser.cs b/parser/chu/SusParser.cs index 79ff15d..ef65ba2 100644 --- a/parser/chu/SusParser.cs +++ b/parser/chu/SusParser.cs @@ -22,7 +22,7 @@ public class SusParser : IParser [0x07] = "AIR", [0x08] = "AHD", [0x09] = "ADW", - [0x0A] = "MNE", + [0x10] = "MNE", }; public (SusChart, List) Parse(string text) @@ -110,14 +110,14 @@ private static void ParseNoteLine(string content, SusChart chart, List al var timingStr = content[..colonIdx]; var dataStr = content[(colonIdx + 1)..]; - if (timingStr.Length < 4) + if (timingStr.Length < 5) { alerts.Add(new Alert(Warning, $"音符行时序部分过短: {content}") { Line = lineNum }); return; } var measure = HexToInt(timingStr[..2]); - var tick = HexToInt(timingStr[2..4]); + var tick = HexToInt(timingStr[2..5]); if (dataStr.Length < 6) { diff --git a/parser/chu/UgcParser.cs b/parser/chu/UgcParser.cs index 1913126..c0ff933 100644 --- a/parser/chu/UgcParser.cs +++ b/parser/chu/UgcParser.cs @@ -135,7 +135,7 @@ private static void ParseHeaderLine(string line, UgcChart chart, List ale break; case "@BEAT": - var beatParts = value.Split(' '); + var beatParts = value.Split(['\t', ' '], StringSplitOptions.RemoveEmptyEntries); if (beatParts.Length >= 3 && int.TryParse(beatParts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var beatMeasure) && int.TryParse(beatParts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var beatNum) @@ -151,7 +151,7 @@ private static void ParseHeaderLine(string line, UgcChart chart, List ale case "@BPM": var bpmPart = value; - var bpmSpaceIdx = bpmPart.IndexOf(' '); + var bpmSpaceIdx = bpmPart.IndexOfAny(['\t', ' ']); if (bpmSpaceIdx > 0) { var measureOffset = bpmPart[..bpmSpaceIdx]; diff --git a/tests/chu/ChuTests.cs b/tests/chu/ChuTests.cs index bd741ca..cf009b9 100644 --- a/tests/chu/ChuTests.cs +++ b/tests/chu/ChuTests.cs @@ -53,17 +53,27 @@ public void UgcToC2sViaGenerator() [Fact] public void DumpOutputFiles() { - if (!File.Exists(UgcPath)) throw new SkipException($"Missing: {UgcPath}"); - var (ugc, _) = new UgcParser().Parse(File.ReadAllText(UgcPath)); - var (c2sText, _) = new C2sGenerator().Generate(ugc); - File.WriteAllText(Path.Combine(OfficialDir, "basic_output.c2s"), c2sText); + var tempDir = Path.Combine(Path.GetTempPath(), "ChuTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); - if (!File.Exists(C2sPath)) throw new SkipException($"Missing: {C2sPath}"); - var (c2s, _) = new C2sParser().Parse(File.ReadAllText(C2sPath)); - var (ugcText, _) = new UgcGenerator().Generate(c2s); - File.WriteAllText(Path.Combine(OfficialDir, "0003_output.ugc"), ugcText); + try + { + if (!File.Exists(UgcPath)) throw new SkipException($"Missing: {UgcPath}"); + var (ugc, _) = new UgcParser().Parse(File.ReadAllText(UgcPath)); + var (c2sText, _) = new C2sGenerator().Generate(ugc); + File.WriteAllText(Path.Combine(tempDir, "basic_output.c2s"), c2sText); + + if (!File.Exists(C2sPath)) throw new SkipException($"Missing: {C2sPath}"); + var (c2s, _) = new C2sParser().Parse(File.ReadAllText(C2sPath)); + var (ugcText, _) = new UgcGenerator().Generate(c2s); + File.WriteAllText(Path.Combine(tempDir, "0003_output.ugc"), ugcText); - Assert.True(File.Exists(Path.Combine(OfficialDir, "basic_output.c2s"))); - Assert.True(File.Exists(Path.Combine(OfficialDir, "0003_output.ugc"))); + Assert.True(File.Exists(Path.Combine(tempDir, "basic_output.c2s"))); + Assert.True(File.Exists(Path.Combine(tempDir, "0003_output.ugc"))); + } + finally + { + try { Directory.Delete(tempDir, true); } catch { } + } } } From cd61fd7f3c046b972a55d30e2d516c5f8a55a8b4 Mon Sep 17 00:00:00 2001 From: Applesaber Date: Fri, 1 May 2026 11:45:46 +0800 Subject: [PATCH 04/17] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20cubic=20?= =?UTF-8?q?=E7=AC=AC2=E8=BD=AE=E5=AE=A1=E6=9F=A5=E7=9A=84=205=20=E4=B8=AA?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. ALD/ASD 序列化字段顺序修正 (去掉多余的 Cell+Width) 2. EndTime 加 BPM>0 防 DivByZero 3. ko.resx 韩文翻译 --- chart/chu/SusChart.cs | 2 +- chart/chu/UgcChart.cs | 2 +- generator/chu/C2sGenerator.cs | 2 +- i18n/Locale.ko.resx | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/chart/chu/SusChart.cs b/chart/chu/SusChart.cs index 96e88f8..5e2a442 100644 --- a/chart/chu/SusChart.cs +++ b/chart/chu/SusChart.cs @@ -15,6 +15,6 @@ public class SusChart : BaseChart, IChuChart public override decimal StartBpm => (decimal)Bpm; public override decimal StartTime => Notes.Count > 0 ? Notes.Min(n => n.Measure * TicksPerBeat * 4 + n.Offset) / (decimal)(TicksPerBeat * 4) * 240m / StartBpm : 0; - public override decimal EndTime => Notes.Count > 0 ? Notes.Max(n => n.Measure * TicksPerBeat * 4 + n.Offset + Math.Max(n.HoldDuration, Math.Max(n.SlideDuration, n.AirHoldDuration))) / (decimal)(TicksPerBeat * 4) * 240m / StartBpm : 0; + public override decimal EndTime => Notes.Count > 0 && StartBpm > 0 ? Notes.Max(n => n.Measure * TicksPerBeat * 4 + n.Offset + Math.Max(n.HoldDuration, Math.Max(n.SlideDuration, n.AirHoldDuration))) / (decimal)(TicksPerBeat * 4) * 240m / StartBpm : 0; public override int TotalNotes => Notes.Count; } diff --git a/chart/chu/UgcChart.cs b/chart/chu/UgcChart.cs index 1141156..b0059ca 100644 --- a/chart/chu/UgcChart.cs +++ b/chart/chu/UgcChart.cs @@ -22,6 +22,6 @@ public class UgcChart : BaseChart, IChuChart public override decimal StartBpm => (decimal)(BpmEvents.Count > 0 ? BpmEvents[0].Bpm : 120.0); public override decimal StartTime => Notes.Count > 0 ? Notes.Min(n => n.Measure * TicksPerBeat * 4 + n.Offset) / (decimal)(TicksPerBeat * 4) * 240m / StartBpm : 0; - public override decimal EndTime => Notes.Count > 0 ? Notes.Max(n => n.Measure * TicksPerBeat * 4 + n.Offset + Math.Max(n.HoldDuration, Math.Max(n.SlideDuration, n.AirHoldDuration))) / (decimal)(TicksPerBeat * 4) * 240m / StartBpm : 0; + public override decimal EndTime => Notes.Count > 0 && StartBpm > 0 ? Notes.Max(n => n.Measure * TicksPerBeat * 4 + n.Offset + Math.Max(n.HoldDuration, Math.Max(n.SlideDuration, n.AirHoldDuration))) / (decimal)(TicksPerBeat * 4) * 240m / StartBpm : 0; public override int TotalNotes => Notes.Count; } diff --git a/generator/chu/C2sGenerator.cs b/generator/chu/C2sGenerator.cs index 16d8d19..fff0e82 100644 --- a/generator/chu/C2sGenerator.cs +++ b/generator/chu/C2sGenerator.cs @@ -115,7 +115,7 @@ private static string Serialize(C2sChart chart) "FLK" => $"FLK\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.Extra}", "AIR" or "AUR" or "AUL" or "ADW" or "ADR" or "ADL" => $"{n.Type}\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.TargetNote}", "AHD" => $"AHD\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.TargetNote}\t{n.AirHoldDuration}", - "ALD" or "ASD" => $"{n.Type}\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.StartHeight}\t{n.SlideDuration}\t{n.EndCell}\t{n.EndWidth}\t{n.TargetHeight}\t{n.NoteColor}", + "ALD" or "ASD" => $"{n.Type}\t{n.Measure}\t{n.Offset}\t{n.StartHeight}\t{n.SlideDuration}\t{n.EndCell}\t{n.EndWidth}\t{n.TargetHeight}\t{n.NoteColor}", "MNE" => $"MNE\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}", _ => $"TAP\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}" }; diff --git a/i18n/Locale.ko.resx b/i18n/Locale.ko.resx index 14ae2ff..dbc23b7 100644 --- a/i18n/Locale.ko.resx +++ b/i18n/Locale.ko.resx @@ -272,9 +272,9 @@ - Unknown C2S note type: {0} + 알 수 없는 C2S 노트 타입: {0} - Cannot convert chart to target format: {0} + 차트를 대상 형식으로 변환할 수 없습니다: {0} \ No newline at end of file From 91a04553fdb129d7dac1b573bd12341362d9d4ec Mon Sep 17 00:00:00 2001 From: Starrah Date: Fri, 1 May 2026 12:31:42 +0800 Subject: [PATCH 05/17] =?UTF-8?q?[F&R]=20=E4=BF=AE=E5=A4=8D=E8=8B=A5?= =?UTF-8?q?=E5=B9=B2=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 +----- generator/chu/C2sGenerator.cs | 4 ++-- generator/chu/SusGenerator.cs | 23 +++++++---------------- generator/chu/UgcGenerator.cs | 4 ++-- tests/chu/ChuTests.cs | 29 ++++++----------------------- 5 files changed, 18 insertions(+), 48 deletions(-) diff --git a/.gitignore b/.gitignore index a92c882..2a6c82b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,4 @@ riderModule.iml .cursor /.tmp* *scratch* -*.lscache - -# 测试 dump 输出 -*_output.* -placeholder.txt \ No newline at end of file +*.lscache \ No newline at end of file diff --git a/generator/chu/C2sGenerator.cs b/generator/chu/C2sGenerator.cs index fff0e82..2a9b79f 100644 --- a/generator/chu/C2sGenerator.cs +++ b/generator/chu/C2sGenerator.cs @@ -53,8 +53,8 @@ private static C2sChart ConvertToC2s(IChuChart chart, List alerts) return result; } - alerts.Add(new Alert(Warning, string.Format(Locale.ChuGeneratorUnsupported, "→ C2S"))); - return new C2sChart(); + alerts.Add(new Alert(Error, string.Format(Locale.ChuGeneratorUnsupported, "→ C2S"))); + throw new ConversionException(alerts); } private static ChuNote ScaleNote(ChuNote n, int tpb) diff --git a/generator/chu/SusGenerator.cs b/generator/chu/SusGenerator.cs index 48bc9a0..6142d88 100644 --- a/generator/chu/SusGenerator.cs +++ b/generator/chu/SusGenerator.cs @@ -13,7 +13,6 @@ namespace MuConvert.chu; public class SusGenerator : IGenerator { private const int SusTpb = 480; - private const int C2sRsl = 384; public (string, List) Generate(IChuChart chart) { @@ -33,8 +32,9 @@ private static SusChart ConvertToSus(IChuChart chart, List alerts) if (chart is C2sChart c2s) { bpm = c2s.BpmEvents.Count > 0 ? c2s.BpmEvents[0].Bpm : c2s.DefBpm; + int c2sTpb = c2s.Resolution / 4; var result = new SusChart { Bpm = bpm, TicksPerBeat = SusTpb, Title = title, Artist = artist }; - foreach (var n in c2s.Notes) result.Notes.Add(ScaleUp(n)); + foreach (var n in c2s.Notes) result.Notes.Add(ScaleUp(n, c2sTpb)); return result; } @@ -42,17 +42,17 @@ private static SusChart ConvertToSus(IChuChart chart, List alerts) { bpm = ugc.BpmEvents.Count > 0 ? ugc.BpmEvents[0].Bpm : 120.0; var result = new SusChart { Bpm = bpm, TicksPerBeat = SusTpb, Title = ugc.Title, Artist = ugc.Artist }; - foreach (var n in ugc.Notes) result.Notes.Add(MapLaneOnly(n)); + foreach (var n in ugc.Notes) result.Notes.Add(ScaleUp(n, ugc.TicksPerBeat)); return result; } - alerts.Add(new Alert(Warning, string.Format(Locale.ChuGeneratorUnsupported, "→ SUS"))); - return new SusChart(); + alerts.Add(new Alert(Error, string.Format(Locale.ChuGeneratorUnsupported, "→ SUS"))); + throw new ConversionException(alerts); } - private static ChuNote ScaleUp(ChuNote n) + private static ChuNote ScaleUp(ChuNote n, int sourceTicksPerBeat) { - int s(int v) => (int)((long)v * SusTpb / (C2sRsl / 4)); + int s(int v) => (int)((long)v * SusTpb / sourceTicksPerBeat); return new ChuNote { Type = n.Type, Measure = n.Measure, Offset = s(n.Offset), @@ -63,15 +63,6 @@ private static ChuNote ScaleUp(ChuNote n) }; } - private static ChuNote MapLaneOnly(ChuNote n) => new() - { - Type = n.Type, Measure = n.Measure, Offset = n.Offset, - Cell = n.Cell * 2, Width = n.Width * 2, - HoldDuration = n.HoldDuration, SlideDuration = n.SlideDuration, - EndCell = n.EndCell * 2, EndWidth = n.EndWidth * 2, - Extra = n.Extra, TargetNote = n.TargetNote, AirHoldDuration = n.AirHoldDuration, - }; - private static string Serialize(SusChart sus) { var sb = new StringBuilder(); diff --git a/generator/chu/UgcGenerator.cs b/generator/chu/UgcGenerator.cs index 56af0e9..0f3e98d 100644 --- a/generator/chu/UgcGenerator.cs +++ b/generator/chu/UgcGenerator.cs @@ -45,8 +45,8 @@ private static UgcChart ConvertToUgc(IChuChart chart, List alerts) return result; } - alerts.Add(new Alert(Warning, string.Format(Locale.ChuGeneratorUnsupported, "→ UGC"))); - return new UgcChart(); + alerts.Add(new Alert(Error, string.Format(Locale.ChuGeneratorUnsupported, "→ UGC"))); + throw new ConversionException(alerts); } private static ChuNote ScaleUpNote(ChuNote n) diff --git a/tests/chu/ChuTests.cs b/tests/chu/ChuTests.cs index cf009b9..8af86d7 100644 --- a/tests/chu/ChuTests.cs +++ b/tests/chu/ChuTests.cs @@ -51,29 +51,12 @@ public void UgcToC2sViaGenerator() } [Fact] - public void DumpOutputFiles() + public void C2sToUgcViaGenerator() { - var tempDir = Path.Combine(Path.GetTempPath(), "ChuTests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(tempDir); - - try - { - if (!File.Exists(UgcPath)) throw new SkipException($"Missing: {UgcPath}"); - var (ugc, _) = new UgcParser().Parse(File.ReadAllText(UgcPath)); - var (c2sText, _) = new C2sGenerator().Generate(ugc); - File.WriteAllText(Path.Combine(tempDir, "basic_output.c2s"), c2sText); - - if (!File.Exists(C2sPath)) throw new SkipException($"Missing: {C2sPath}"); - var (c2s, _) = new C2sParser().Parse(File.ReadAllText(C2sPath)); - var (ugcText, _) = new UgcGenerator().Generate(c2s); - File.WriteAllText(Path.Combine(tempDir, "0003_output.ugc"), ugcText); - - Assert.True(File.Exists(Path.Combine(tempDir, "basic_output.c2s"))); - Assert.True(File.Exists(Path.Combine(tempDir, "0003_output.ugc"))); - } - finally - { - try { Directory.Delete(tempDir, true); } catch { } - } + if (!File.Exists(C2sPath)) throw new SkipException($"Missing: {C2sPath}"); + var (c2s, _) = new C2sParser().Parse(File.ReadAllText(C2sPath)); + var (ugcText, _) = new UgcGenerator().Generate(c2s); + Assert.Contains("@VER", ugcText); + Assert.Contains("#5'0", ugcText); } } From 828dbe6b1e79a502caf5d43cab5b68342f36f8ea Mon Sep 17 00:00:00 2001 From: Applesaber Date: Fri, 1 May 2026 12:49:03 +0800 Subject: [PATCH 06/17] =?UTF-8?q?fix:=20UgcGenerator.UCode=20=E8=A1=A5?= =?UTF-8?q?=E5=85=85=20HXD/SXD/SXC/SLC=20=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实际 C2S ULTIMA 谱面 (_04.c2s) 包含这些 EX 类型,UGC 无对应码,映射为基础类型 --- generator/chu/UgcGenerator.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/generator/chu/UgcGenerator.cs b/generator/chu/UgcGenerator.cs index 0f3e98d..f73d9ed 100644 --- a/generator/chu/UgcGenerator.cs +++ b/generator/chu/UgcGenerator.cs @@ -112,8 +112,9 @@ private static string UCode(ChuNote n) { "TAP" => $"t{c}{w}", "CHR" => $"x{c}{w}{n.Extra}", - "HLD" => $"h{c}{w}", - "SLD" => $"s{c}{w}", + "HLD" or "HXD" => $"h{c}{w}", + "SLD" or "SXD" => $"s{c}{w}", + "SLC" or "SXC" => $"s{c}{w}", "FLK" => $"f{c}{w}A", "MNE" => $"d{c}{w}", "AIR" => $"a{c}{w}UC{n.TargetNote}", From 5abf8ae56b0c4fb8220d96fa9ff5360d2bbd14d7 Mon Sep 17 00:00:00 2001 From: Starrah Date: Fri, 1 May 2026 13:27:02 +0800 Subject: [PATCH 07/17] =?UTF-8?q?[F&O]=20=E4=BF=AE=E5=A4=8D=E4=B8=80?= =?UTF-8?q?=E4=BA=9B=E5=B0=8F=E9=97=AE=E9=A2=98=EF=BC=8C=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- parser/chu/UgcParser.cs | 11 ++++-- tests/chu/ChuTests.cs | 77 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/parser/chu/UgcParser.cs b/parser/chu/UgcParser.cs index c0ff933..9a1ddbc 100644 --- a/parser/chu/UgcParser.cs +++ b/parser/chu/UgcParser.cs @@ -299,19 +299,25 @@ private static int ParseSlideNote(string[] lines, int idx, string code, ChuNote if (idx + 1 < lines.Length) { var nextLine = lines[idx + 1].Trim(); - if (TryParseFollowerLine(nextLine, out var duration, out var endCell, out var endWidth)) + if (TryParseFollowerLine(nextLine, out var duration, out var endCell, out var endWidth, requireEndCellWidth: true)) { note.SlideDuration = duration; note.EndCell = endCell; note.EndWidth = endWidth; return idx + 1; } + if (TryParseFollowerLine(nextLine, out duration, out _, out _, requireEndCellWidth: false)) + { + note.SlideDuration = duration; + alerts.Add(new Alert(Warning, $"SLD 跟随行缺少结束位置(cell 与 width): {nextLine}") { Line = idx + 2, RelevantNote = FormatNoteRef(note) }); + return idx + 1; + } } alerts.Add(new Alert(Warning, $"SLD 音符缺少时长跟随行") { Line = idx + 1, RelevantNote = FormatNoteRef(note) }); return idx; } - private static bool TryParseFollowerLine(string line, out int duration, out int endCell, out int endWidth) + private static bool TryParseFollowerLine(string line, out int duration, out int endCell, out int endWidth, bool requireEndCellWidth = false) { duration = 0; endCell = 0; @@ -331,6 +337,7 @@ private static bool TryParseFollowerLine(string line, out int duration, out int endCell = HexCharToInt(afterMarker[0]); endWidth = WidthHexCharToInt(afterMarker[1]); } + else if (requireEndCellWidth) return false; return true; } diff --git a/tests/chu/ChuTests.cs b/tests/chu/ChuTests.cs index 8af86d7..8eda332 100644 --- a/tests/chu/ChuTests.cs +++ b/tests/chu/ChuTests.cs @@ -1,6 +1,5 @@ +using System.Reflection; using MuConvert.chu; -using MuConvert.parser; -using MuConvert.utils; namespace MuConvert.Tests.chu; @@ -28,7 +27,67 @@ public void C2sRoundTrip() var (chart, _) = new C2sParser().Parse(File.ReadAllText(C2sPath)); var (rt, _) = new C2sGenerator().Generate(chart); var (reparsed, _) = new C2sParser().Parse(rt); + Assert.Equal(chart.Notes.Count, reparsed.Notes.Count); + + var originalSnapshots = chart.Notes + .Select(SnapshotNote) + .OrderBy(s => s) + .ToArray(); + + var reparsedSnapshots = reparsed.Notes + .Select(SnapshotNote) + .OrderBy(s => s) + .ToArray(); + + Assert.Equal(originalSnapshots, reparsedSnapshots); + } + + /// + /// Builds a stable, comparable string from a note's public instance properties (name-sorted) + /// so round-trip tests verify no field loss without hard-coding each property in the test. + /// + private static string SnapshotNote(ChuNote note) + { + var props = typeof(ChuNote).GetProperties(BindingFlags.Instance | BindingFlags.Public); + var parts = props + .OrderBy(p => p.Name) + .Select(p => $"{p.Name}={p.GetValue(note)}"); + return string.Join("|", parts); + } + + /// + /// Same tick scaling as when converting UGC → C2S (384 ticks per measure). + /// + private static ChuNote UgcNoteScaledToC2sTicks(ChuNote n, int ticksPerBeat) + { + const int c2sResolution = 384; + int scaleDown(int v) => (int)((long)v * (c2sResolution / 4) / ticksPerBeat); + return new ChuNote + { + Type = n.Type, Measure = n.Measure, Offset = scaleDown(n.Offset), + Cell = n.Cell, Width = n.Width, + HoldDuration = scaleDown(n.HoldDuration), SlideDuration = scaleDown(n.SlideDuration), + EndCell = n.EndCell, EndWidth = n.EndWidth, + Extra = n.Extra, TargetNote = n.TargetNote, AirHoldDuration = scaleDown(n.AirHoldDuration), + StartHeight = n.StartHeight, TargetHeight = n.TargetHeight, NoteColor = n.NoteColor, + }; + } + + /// + /// Compares UGC-side notes to C2S-side notes in C2S tick space (384): snapshots of + /// for each note vs snapshots of notes. + /// Use with UgcToC2sViaGenerator (source UGC, C2S from generate+parse) or C2sToUgcViaGenerator (UGC from generate+parse, source C2S). + /// + private static void AssertUgcNotesEquivalentToReparsedC2s(UgcChart ugc, C2sChart c2s, bool isUgcReference) + { + var ugcSnaps = ugc.Notes + .Select(n => SnapshotNote(UgcNoteScaledToC2sTicks(n, ugc.TicksPerBeat))) + .OrderBy(s => s) + .ToArray(); + var c2sSnaps = c2s.Notes.Select(SnapshotNote).OrderBy(s => s).ToArray(); + if (isUgcReference) Assert.Equal(ugcSnaps, c2sSnaps); + else Assert.Equal(c2sSnaps, ugcSnaps); } [Fact] @@ -45,9 +104,16 @@ public void UgcToC2sViaGenerator() { if (!File.Exists(UgcPath)) throw new SkipException($"Missing: {UgcPath}"); var (ugc, _) = new UgcParser().Parse(File.ReadAllText(UgcPath)); + Assert.NotEmpty(ugc.Notes); + var (c2sText, _) = new C2sGenerator().Generate(ugc); Assert.Contains("VERSION", c2sText); Assert.Contains("TAP\t", c2sText); + + // 再把转出来的c2s,parse回去,比较是否和一开始的ugc等价(注意不是文本 round-trip,而是 IR 等价,允许字段重排但不允许信息丢失) + var (c2sChart, _) = new C2sParser().Parse(c2sText); + Assert.NotEmpty(c2sChart.Notes); + AssertUgcNotesEquivalentToReparsedC2s(ugc, c2sChart, true); } [Fact] @@ -55,8 +121,15 @@ public void C2sToUgcViaGenerator() { if (!File.Exists(C2sPath)) throw new SkipException($"Missing: {C2sPath}"); var (c2s, _) = new C2sParser().Parse(File.ReadAllText(C2sPath)); + Assert.NotEmpty(c2s.Notes); + var (ugcText, _) = new UgcGenerator().Generate(c2s); Assert.Contains("@VER", ugcText); Assert.Contains("#5'0", ugcText); + + // 再把转出来的ugc,parse回去,比较是否和一开始的c2s等价 + var (ugcReparsed, _) = new UgcParser().Parse(ugcText); + Assert.NotEmpty(ugcReparsed.Notes); + AssertUgcNotesEquivalentToReparsedC2s(ugcReparsed, c2s, false); } } From ec9d78d36a88058469d273f8414e040b9fac0d8c Mon Sep 17 00:00:00 2001 From: Starrah Date: Fri, 1 May 2026 15:42:02 +0800 Subject: [PATCH 08/17] =?UTF-8?q?[F]=20=E4=BF=AE=E5=A4=8DUgcParser?= =?UTF-8?q?=EF=BC=8C=E6=9C=AA=E8=83=BD=E6=AD=A3=E7=A1=AE=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=AF=B9AIR=E7=9A=84=E8=A7=A3=E6=9E=90=EF=BC=8C=E5=9C=A8?= =?UTF-8?q?=E5=A4=9A=E5=AD=97=E7=AC=A6=20TargetNote=E7=9A=84AIR=E6=97=B6?= =?UTF-8?q?=E4=BC=9A=E8=A7=A3=E6=9E=90=E9=94=99=E8=AF=AF=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit a47ef2e4d81d006e5935de62c1aaf44cd2915721) --- parser/chu/UgcParser.cs | 43 +++++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/parser/chu/UgcParser.cs b/parser/chu/UgcParser.cs index 9a1ddbc..f73544b 100644 --- a/parser/chu/UgcParser.cs +++ b/parser/chu/UgcParser.cs @@ -252,10 +252,14 @@ private static int ParseNoteLine(string[] lines, int idx, UgcChart chart, List 3) + note.Extra = code[3..]; break; case 'd': note.Type = "MNE"; + ParseCellWidth(code, 1, note, alerts, lineNum); break; default: @@ -360,9 +364,18 @@ private static void ParseCellWidth(string code, int startIdx, ChuNote note, List private static void ParseAirNote(string code, ChuNote note, List alerts, int lineNum) { - var remaining = code[1..]; - var underscoreIdx = remaining.IndexOf('_'); - var mainPart = underscoreIdx >= 0 ? remaining[..underscoreIdx] : remaining; + // Matches UgcGenerator: "a" + cell + width + two-letter direction + targetNote [ + "_" + airHoldDuration for AHD ] + if (code.Length < 5) + { + alerts.Add(new Alert(Warning, $"AIR 音符代码过短: {code}") { Line = lineNum }); + note.Type = "AIR"; + return; + } + + ParseCellWidth(code, 1, note, alerts, lineNum); + var afterCellWidth = code[3..]; + var underscoreIdx = afterCellWidth.IndexOf('_'); + var mainPart = underscoreIdx >= 0 ? afterCellWidth[..underscoreIdx] : afterCellWidth; if (mainPart.Length < 2) { @@ -382,18 +395,11 @@ private static void ParseAirNote(string code, ChuNote note, List alerts, alerts.Add(new Alert(Warning, $"未知的 AIR 方向: {dir}") { Line = lineNum, RelevantNote = FormatNoteRef(note) }); } - if (mainPart.Length > 2) - { - note.TargetNote = mainPart[2].ToString(); - } - else - { - note.TargetNote = "N"; - } + note.TargetNote = mainPart.Length > 2 ? mainPart[2..] : "N"; if (underscoreIdx >= 0 && note.Type == "AHD") { - var durStr = remaining[(underscoreIdx + 1)..]; + var durStr = afterCellWidth[(underscoreIdx + 1)..]; if (int.TryParse(durStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ahdDuration)) note.AirHoldDuration = ahdDuration; } @@ -402,11 +408,18 @@ private static void ParseAirNote(string code, ChuNote note, List alerts, private static void ParseChrNote(string code, ChuNote note, List alerts, int lineNum) { note.Type = "CHR"; - var extra = code[1..]; - if (ChrExtras.TryGetValue(extra, out var chrDir)) + if (code.Length < 3) + { + alerts.Add(new Alert(Warning, $"CHR 音符代码过短: {code}") { Line = lineNum }); + return; + } + + ParseCellWidth(code, 1, note, alerts, lineNum); + var extraRaw = code.Length > 3 ? code[3..] : ""; + if (ChrExtras.TryGetValue(extraRaw, out var chrDir)) note.Extra = chrDir; else - note.Extra = extra; + note.Extra = extraRaw; } private static int HexCharToInt(char c) From 41bc684ab7d6d39a94eceaf3312d533eaba83cdb Mon Sep 17 00:00:00 2001 From: Starrah Date: Fri, 1 May 2026 15:59:00 +0800 Subject: [PATCH 09/17] =?UTF-8?q?[F]=20=E4=BC=98=E5=8C=96HexToInt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- parser/chu/SusParser.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/parser/chu/SusParser.cs b/parser/chu/SusParser.cs index ef65ba2..89d0033 100644 --- a/parser/chu/SusParser.cs +++ b/parser/chu/SusParser.cs @@ -228,12 +228,8 @@ private static void ParseAhdData(string dataStr, ChuNote note, List alert } } - private static int HexToInt(string hex) - { - if (hex.All(c => c is >= '0' and <= '9' or >= 'A' and <= 'F' or >= 'a' and <= 'f')) - return Convert.ToInt32(hex, 16); - return 0; - } + private static int HexToInt(string hex) => + int.TryParse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var result) ? result : 0; private static string Unquote(string s) { From f1ec9eef080df63c62f9e862bb0d1a285e8da69d Mon Sep 17 00:00:00 2001 From: Applesaber Date: Fri, 1 May 2026 16:23:57 +0800 Subject: [PATCH 10/17] =?UTF-8?q?fix:=20C2sParser.ParseNote=20ALD/ASD=20Ce?= =?UTF-8?q?ll/Width=20=E9=94=99=E8=AF=AF=E8=B5=8B=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构为各分支独立设置字段,ALD/ASD 不再从 p[3]/p[4] 误设 Cell/Width --- parser/chu/C2sParser.cs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/parser/chu/C2sParser.cs b/parser/chu/C2sParser.cs index 347527e..de903f5 100644 --- a/parser/chu/C2sParser.cs +++ b/parser/chu/C2sParser.cs @@ -86,19 +86,25 @@ private static void ParseTiming(string[] p, C2sChart chart) private static void ParseNote(string[] p, C2sChart chart, List alerts, int lineNum) { var tag = p[0].ToUpperInvariant(); - var note = new ChuNote { Type = tag, Measure = Int(p, 1), Offset = Int(p, 2), Cell = Int(p, 3), Width = Math.Max(1, Int(p, 4, 1)) }; + var note = new ChuNote { Type = tag, Measure = Int(p, 1), Offset = Int(p, 2) }; switch (tag) { - case "TAP": case "MNE": break; - case "CHR": note.Extra = Str(p, 5); break; - case "HLD": case "HXD": note.HoldDuration = Int(p, 5); break; + case "TAP": case "MNE": + note.Cell = Int(p, 3); note.Width = Math.Max(1, Int(p, 4, 1)); break; + case "CHR": + note.Cell = Int(p, 3); note.Width = Math.Max(1, Int(p, 4, 1)); note.Extra = Str(p, 5); break; + case "HLD": case "HXD": + note.Cell = Int(p, 3); note.Width = Math.Max(1, Int(p, 4, 1)); note.HoldDuration = Int(p, 5); break; case "SLD": case "SLC": case "SXD": case "SXC": + note.Cell = Int(p, 3); note.Width = Math.Max(1, Int(p, 4, 1)); note.SlideDuration = Int(p, 5); note.EndCell = Int(p, 6); note.EndWidth = Math.Max(1, Int(p, 7, 1)); break; - case "FLK": note.Extra = Str(p, 5); break; + case "FLK": + note.Cell = Int(p, 3); note.Width = Math.Max(1, Int(p, 4, 1)); note.Extra = Str(p, 5); break; case "AIR": case "AUR": case "AUL": case "ADW": case "ADR": case "ADL": - note.TargetNote = Str(p, 5); break; + note.Cell = Int(p, 3); note.Width = Math.Max(1, Int(p, 4, 1)); note.TargetNote = Str(p, 5); break; case "AHD": + note.Cell = Int(p, 3); note.Width = Math.Max(1, Int(p, 4, 1)); note.TargetNote = Str(p, 5); note.AirHoldDuration = Int(p, 6); break; case "ALD": case "ASD": note.StartHeight = Int(p, 3); note.SlideDuration = Int(p, 4); From 8265abc7d37f64affc0c2c752a618bbe81427205 Mon Sep 17 00:00:00 2001 From: Starrah Date: Fri, 1 May 2026 17:18:49 +0800 Subject: [PATCH 11/17] =?UTF-8?q?[+&O]=20CLI=E6=94=AF=E6=8C=81=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=9A=84=E4=B8=AD=E4=BA=8C=E8=BD=AC=E8=B0=B1=EF=BC=9B?= =?UTF-8?q?=E5=90=8C=E6=97=B6=E4=BC=98=E5=8C=96=E6=8F=90=E7=A4=BA=E6=96=87?= =?UTF-8?q?=E6=9C=AC=EF=BC=8C=E9=81=BF=E5=85=8D=E5=A4=AA=E7=BD=97=E5=97=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Program.cs | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/Program.cs b/Program.cs index b20bdf4..05a2641 100644 --- a/Program.cs +++ b/Program.cs @@ -40,47 +40,40 @@ private static Command BuildRootCommand() { var root = new RootCommand { - Description = $"MuConvert {Utils.AppVersion} — 新一代Simai与MA2互转转谱器\n" + Description = $"MuConvert {Utils.AppVersion} — 新一代多功能音游转谱器\n" + + $"使用文档详见:https://github.com/MuNET-OSS/MuConvert/blob/master/README.md" }; var levelsOption = new Option("--levels", "-l") { - Description = "仅转换指定难度(以maidata中的&inote_编号为准),多个难度用逗号分隔;省略则转换全部难度。", + Description = "仅转换指定难度,多个难度用逗号分隔;省略则转换全部难度。", HelpName = "N[,N...]" }; var outputOption = new Option("--output", "-o") { - Description = - "输出位置:\n" + - "· 省略:写入输入文件同目录,文件名按默认规则(maidata.txt、lv_N.ma2 等)。\n" + - "· 目录:写入该目录,文件名同上按默认规则。\n" + - "· 文件:仅当本次转换只会生成一个输出文件时可用;扩展名须为 .txt(输出 maidata)或 .ma2(输出 MA2)。\n" + - "· \"-\":仅当本次转换只会生成一个输出文件时可用;将输出内容写到stdout。", + Description = "指定输出位置。可指定文件或目录,或\"-\"(stdout);不指定则默认为输入文件所在目录。", HelpName = "path" }; var strictOption = new Option("--strict") { - Description = "Simai转MA2时,解析使用严格模式。不可与 --lax 同时使用。", + Description = "解析使用严格模式(仅在Simai转MA2模式下有效)", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = _ => false }; var laxOption = new Option("--lax") { - Description = "Simai转MA2时,解析使用宽松模式。不可与 --strict 同时使用。", + Description = "解析使用宽松模式(仅在Simai转MA2模式下有效)", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = _ => false }; var inputArgument = new Argument("path") { - Description = "可以输入以下几种情况:\n" + - "1.单个.txt文件(标准maidata.txt,或是不含maidata的头信息、直接是Simai的Notes的文件,都可以)。会把它转为MA2。请通过-l指定要转换的谱面难度,不指定则默认转换全部难度。\n" + - "2.单个.ma2文件。会把它转为Simai,输出maidata.txt。如果想要转换多个难度,请传入目录,详见第4条。\n" + - "3.一个包含有maidata.txt的目录。行为同第一条。\n" + - "4.一个包含有一个或多个.ma2文件的目录。会把它们转为一个maidata.txt。请通过-l指定要转换的谱面难度,不指定则默认转换全部难度。", + Description = "可以输入文件或目录。会自动根据输入的类型,智能执行相应的转换程序。\n" + + "例如,输入一个包含多个.ma2文件的目录,则会把各个难度合并转为一个maidata.txt。", Arity = ArgumentArity.ExactlyOne }; From 4041dc3de00c1e5f0e379b6feac38f2728bf0e88 Mon Sep 17 00:00:00 2001 From: Starrah Date: Sat, 2 May 2026 01:20:11 +0800 Subject: [PATCH 12/17] =?UTF-8?q?[+]=20CLI=20for=20=E4=B8=AD=E4=BA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Program.cs | 148 ++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 135 insertions(+), 13 deletions(-) diff --git a/Program.cs b/Program.cs index 05a2641..c9dbb1c 100644 --- a/Program.cs +++ b/Program.cs @@ -1,6 +1,7 @@ using System.CommandLine; using System.Text; using System.Text.RegularExpressions; +using MuConvert.chu; using MuConvert.mai; using MuConvert.utils; @@ -50,6 +51,12 @@ private static Command BuildRootCommand() HelpName = "N[,N...]" }; + var targetOption = new Option("--target", "-t") + { + Description = "强制指定输出格式。目前仅有C2S->SUS必须指定本参数,其他情况省略使用默认值即可。", + HelpName = "format" + }; + var outputOption = new Option("--output", "-o") { Description = "指定输出位置。可指定文件或目录,或\"-\"(stdout);不指定则默认为输入文件所在目录。", @@ -78,6 +85,7 @@ private static Command BuildRootCommand() }; root.Options.Add(levelsOption); + root.Options.Add(targetOption); root.Options.Add(outputOption); root.Options.Add(strictOption); root.Options.Add(laxOption); @@ -88,6 +96,8 @@ private static Command BuildRootCommand() var inputPath = parseResult.GetValue(inputArgument) ?? throw new InvalidOperationException("缺少参数 path。"); var levelsRaw = parseResult.GetValue(levelsOption); + var targetRaw = parseResult.GetValue(targetOption); + _cliTargetNormalized = string.IsNullOrWhiteSpace(targetRaw) ? null : targetRaw.Trim().ToLowerInvariant(); _outputSpec = OutputSpec.Parse(parseResult.GetValue(outputOption)); var cliStrict = parseResult.GetValue(strictOption); @@ -105,6 +115,9 @@ private static Command BuildRootCommand() /// 由 CLI 在每次 SetAction 入口赋值;转换逻辑只读此字段。 private static OutputSpec _outputSpec; private static SimaiParser.StrictLevelEnum _simaiStrictLevel = SimaiParser.StrictLevelEnum.Normal; + + /// 由 CLI 赋值;为 null 表示按输入类型使用默认输出格式,否则为小写的目标格式名(如 sus、ma2)。 + private static string? _cliTargetNormalized; private enum OutputSinkKind { Default, Stdout, Directory, File } @@ -142,6 +155,8 @@ private static void RunConvert(string inputPath, string? levelsRaw) else throw new ArgumentException($"找不到路径: {inputPath}"); } + + private static readonly string[] chuExtentions = new[] { ".c2s", ".ugc", ".sus" }; private static void RunConvertDirectory(string dir, string? levelsRaw) { @@ -154,25 +169,39 @@ private static void RunConvertDirectory(string dir, string? levelsRaw) var maidataPaths = Directory.GetFiles(dir, "maidata.txt", enumOpts); var ma2Paths = Directory.GetFiles(dir, "*.ma2", enumOpts); + var chuPaths = Directory.EnumerateFiles(dir, "*", enumOpts) + .Where(file => chuExtentions.Contains(Path.GetExtension(file).ToLower())).ToArray(); var hasMaidata = maidataPaths.Length > 0; var hasMa2 = ma2Paths.Length > 0; + var hasChu = chuPaths.Length > 0; if (hasMaidata && hasMa2) throw new ArgumentException("目录中同时存在 maidata.txt 与 .ma2,请只保留其中一种输入。"); - if (!hasMaidata && !hasMa2) - throw new ArgumentException("目录中未找到 maidata.txt 或 .ma2 文件。"); + if ((hasMaidata || hasMa2) && hasChu) + throw new ArgumentException("目录中不能同时存在 maimai 谱(maidata.txt / .ma2)与中二谱(.c2s / .ugc / .sus),请分开转换。"); + if (!hasMaidata && !hasMa2 && !hasChu) + throw new ArgumentException("目录中未找到任何支持的谱面文件"); + string filename = ""; if (hasMaidata) { - if (maidataPaths.Length > 1) - throw new ArgumentException("目录中存在多个 maidata.txt,请只保留一个。"); - RunConvertTxtFile(maidataPaths[0], levelsRaw); - return; + if (maidataPaths.Length > 1) throw new ArgumentException("目录中存在多个 maidata.txt,请只保留一个。"); + filename = maidataPaths[0]; } - - var title = new DirectoryInfo(dir).Name; - ConvertMa2PathsToMaidata(dir, title, ma2Paths, levelsRaw); + else if (hasMa2) + { + if (ma2Paths.Length > 1) + { // 多个文件,无法直接转发给RunConvertFile,故自行调用ConvertMa2PathsToMaidata + var title = new DirectoryInfo(dir).Name; + ConvertMa2PathsToMaidata(dir, title, ma2Paths, levelsRaw); + return; + } + else filename = ma2Paths[0]; + } + else filename = chuPaths[0]; + + RunConvertFile(filename, levelsRaw); } private static void RunConvertFile(string filePath, string? levelsRaw) @@ -192,7 +221,18 @@ private static void RunConvertFile(string filePath, string? levelsRaw) return; } - throw new ArgumentException($"不支持的输入扩展名「{ext}」。支持 .txt、.ma2,或目录。"); + if (string.Equals(ext, ".c2s", StringComparison.OrdinalIgnoreCase) || + string.Equals(ext, ".ugc", StringComparison.OrdinalIgnoreCase) || + string.Equals(ext, ".sus", StringComparison.OrdinalIgnoreCase)) + { + if (levelsRaw != null) throw new ArgumentException("-l / --levels 仅适用于 maimai 的 maidata 或目录中的 .ma2,不适用于中二谱(.c2s / .ugc / .sus)。"); + AssertStrictLaxOnlyForSimaiToMa2(" 中二谱(.c2s / .ugc / .sus)"); + var kind = ext.TrimStart('.').ToLowerInvariant(); + RunConvertChuSingleFile(filePath, kind); + return; + } + + throw new ArgumentException($"不支持的输入扩展名「{ext}」。支持 .txt、.ma2、.c2s、.ugc、.sus,或目录。"); } private static void RunConvertTxtFile(string inputPath, string? levelsRaw) @@ -202,6 +242,9 @@ private static void RunConvertTxtFile(string inputPath, string? levelsRaw) var inputDir = Path.GetDirectoryName(Path.GetFullPath(inputPath))!; var text = File.ReadAllText(inputPath, Encoding.UTF8); + var targetFormat = _cliTargetNormalized ?? "ma2"; + if (targetFormat != "ma2") throw new ArgumentException($"不支持的输出类型「{targetFormat}」。输入文件为simai时,输出格式仅支持ma2。"); + if (LooksLikeMaidata(text)) { var maidata = new Maidata(text); @@ -271,8 +314,10 @@ private static void ConvertMa2PathsToMaidata(string outputDir, string title, IRe { if (ma2FullPaths.Count == 0) throw new ArgumentException("未提供任何 .ma2 文件。"); - if (_simaiStrictLevel != SimaiParser.StrictLevelEnum.Normal) - throw new ArgumentException("--strict / --lax 仅适用于 Simai(.txt / maidata)转 MA2,不能用于 MA2 转 Simai。"); + AssertStrictLaxOnlyForSimaiToMa2(" MA2 转 Simai"); + + var targetFormat = _cliTargetNormalized ?? "simai"; + if (targetFormat != "simai") throw new ArgumentException($"不支持的输出类型「{targetFormat}」。输入文件为ma2时,输出格式仅支持simai。"); var paths = ma2FullPaths.Select(Path.GetFullPath).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); var levelFilter = string.IsNullOrWhiteSpace(levelsRaw) ? null : ParseLevelList(levelsRaw); @@ -293,7 +338,7 @@ private static void ConvertMa2PathsToMaidata(string outputDir, string title, IRe foreach (var (fullPath, levelId) in assignments) { - Console.Error.WriteLine($"Simai → MA2: {fullPath}(lv{levelId}) → {destNote}"); + Console.Error.WriteLine($"MA2 → Simai: {fullPath}(lv{levelId}) → {destNote}"); var ma2Text = File.ReadAllText(fullPath, Encoding.UTF8); var (chart, parseAlerts) = new MA2Parser().Parse(ma2Text); PrintAlerts(parseAlerts); @@ -412,6 +457,83 @@ private static void ValidateOutputFileExtension(string filePath, string required throw new ArgumentException($"输出文件扩展名须为「{requiredExt}」,当前为「{(string.IsNullOrEmpty(ext) ? "(无)" : ext)}」。"); } + private static void AssertStrictLaxOnlyForSimaiToMa2(string contextSuffix) + { + if (_simaiStrictLevel != SimaiParser.StrictLevelEnum.Normal) + throw new ArgumentException($"--strict / --lax 仅适用于 Simai(.txt / maidata 或纯 inote)转 MA2,不能用于{contextSuffix}。"); + } + + private static readonly Dictionary chuTargetsDict = new() + { + ["c2s"] = ["ugc", "sus"], + ["ugc"] = ["c2s", "sus"], + ["sus"] = ["c2s"], + }; + + private static void ValidateOutputForSingleChuText(string inputFormat, string targetFormat) + { + var validTargets = chuTargetsDict.GetValueOrDefault(inputFormat) ?? []; + if (!validTargets.Contains(targetFormat)) throw new ArgumentException($"不支持的输出类型「{targetFormat}」。输入文件为{inputFormat}时,输出格式仅支持{validTargets}。"); + + if (_outputSpec.Kind == OutputSinkKind.Stdout) return; + if (_outputSpec.Kind == OutputSinkKind.File) + ValidateOutputFileExtension(_outputSpec.FsPath!, "." + targetFormat); + } + + private static void RunConvertChuSingleFile(string filePath, string inputKind) + { + var targetFormat = _cliTargetNormalized ?? chuTargetsDict[inputKind][0]; + ValidateOutputForSingleChuText(inputKind, targetFormat); + + var full = Path.GetFullPath(filePath); + var inputDir = Path.GetDirectoryName(full)!; + var text = File.ReadAllText(full, Encoding.UTF8); + + var baseDir = _outputSpec.ResolveOutputDir(inputDir); + var outPath = _outputSpec.Kind == OutputSinkKind.File ? _outputSpec.FsPath! : Path.Combine(baseDir, Path.GetFileNameWithoutExtension(full) + "." + targetFormat); + var destNote = _outputSpec.Kind == OutputSinkKind.Stdout ? "(标准输出)" : outPath; + Console.Error.WriteLine($"{inputKind.ToUpperInvariant()} → {targetFormat.ToUpperInvariant()}: {full} → {destNote}"); + + IChuChart chart; + List parseAlerts; + switch (inputKind) + { + case "c2s": + (chart, parseAlerts) = new C2sParser().Parse(text); + break; + case "ugc": + (chart, parseAlerts) = new UgcParser().Parse(text); + break; + case "sus": + (chart, parseAlerts) = new SusParser().Parse(text); + break; + default: + throw new ArgumentException($"内部错误:未知中二输入种类「{inputKind}」。"); + } + PrintAlerts(parseAlerts); + + string outText; + List genAlerts; + switch (targetFormat) + { + case "ugc": + (outText, genAlerts) = new UgcGenerator().Generate(chart); + break; + case "sus": + (outText, genAlerts) = new SusGenerator().Generate(chart); + break; + case "c2s": + (outText, genAlerts) = new C2sGenerator().Generate(chart); + break; + default: + throw new ArgumentException($"内部错误:未实现的中二输出类型「{targetFormat}」。"); + } + PrintAlerts(genAlerts); + + if (_outputSpec.Kind == OutputSinkKind.Stdout) Console.Out.Write(outText); + else File.WriteAllText(outPath, outText, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + private static string SimaiToMa2(string inote, int clockCount = 4, bool bigTouch = false, bool isUtage = false, SimaiParser.StrictLevelEnum strictLevel = SimaiParser.StrictLevelEnum.Normal) { From 1cde69bc2de049fd58dea4c9da92c02dc8c6f116 Mon Sep 17 00:00:00 2001 From: Applesaber Date: Sat, 2 May 2026 16:30:34 +0800 Subject: [PATCH 13/17] =?UTF-8?q?fix:=20UgcParser=20=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E5=A4=A7=E5=86=99=E7=B1=BB=E5=9E=8B=E5=89=8D=E7=BC=80=E5=92=8C?= =?UTF-8?q?=20>c=20=E8=B7=9F=E9=9A=8F=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - typeChar 统一转小写,兼容 H/S 等大写前缀 - TryParseFollowerLine 支持 >c (SLC) 跟随行 --- parser/chu/UgcParser.cs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/parser/chu/UgcParser.cs b/parser/chu/UgcParser.cs index f73544b..e354eba 100644 --- a/parser/chu/UgcParser.cs +++ b/parser/chu/UgcParser.cs @@ -226,7 +226,7 @@ private static int ParseNoteLine(string[] lines, int idx, UgcChart chart, Lists"); - if (gtSIdx < 1) return false; + // support both >s (SLD) and >c (SLC) follower lines + int gtIdx = -1; + int markerLen = 0; + if (line.Contains(">s")) { gtIdx = line.IndexOf(">s"); markerLen = 2; } + else if (line.Contains(">c")) { gtIdx = line.IndexOf(">c"); markerLen = 2; } + if (gtIdx < 1) return false; - var durationStr = line[1..gtSIdx]; + var durationStr = line[1..gtIdx]; if (!int.TryParse(durationStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out duration)) return false; - var afterMarker = line[(gtSIdx + 2)..]; + var afterMarker = line[(gtIdx + markerLen)..]; if (afterMarker.Length >= 2) { endCell = HexCharToInt(afterMarker[0]); From ca6db1ed126256efd931df368385469ec12c141c Mon Sep 17 00:00:00 2001 From: Applesaber Date: Sat, 2 May 2026 16:39:23 +0800 Subject: [PATCH 14/17] =?UTF-8?q?fix:=20UgcParser=20=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E8=B7=9F=E9=9A=8F=E8=A1=8C=E5=92=8C=20@USETI?= =?UTF-8?q?L=20=E6=8C=87=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TryParseStandaloneFollower 处理不紧邻的 >s/>c 跟随行 - 跳过音符段中的 @USETIL 内部指令 --- parser/chu/UgcParser.cs | 80 ++++++++++++++++++++++++++++++++++------- 1 file changed, 68 insertions(+), 12 deletions(-) diff --git a/parser/chu/UgcParser.cs b/parser/chu/UgcParser.cs index e354eba..542f7b0 100644 --- a/parser/chu/UgcParser.cs +++ b/parser/chu/UgcParser.cs @@ -42,6 +42,9 @@ public class UgcParser : IParser var line = lines[i]; if (string.IsNullOrWhiteSpace(line)) continue; + // UGC comment lines (starting with ') + if (line.StartsWith('\'')) continue; + if (inHeader) { if (line == "@ENDHEAD") @@ -175,6 +178,22 @@ private static void ParseHeaderLine(string line, UgcChart chart, List ale } break; + // silently ignored metadata tags + case "@EXVER": case "@SORT": case "@BGM": case "@BGMOFS": case "@BGMPRV": + case "@JACKET": case "@BGIMG": case "@BGMODE": case "@FLDCOL": case "@FLDIMG": + case "@FLAG": case "@ATINFO": case "@DLURL": case "@COPYRIGHT": case "@LICENSE": + case "@MAINTIL": + break; + + case "@TIL": case "@SPDMOD": + { + var parts = value.Split(['\t', ' '], StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2 && int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var tilMeasure) + && double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var tilMult)) + chart.SpeedEvents.Add((tilMeasure, 0, tilMult)); + } + break; + default: alerts.Add(new Alert(Info, $"未知头部标签: {tag}") { Line = lineNum }); break; @@ -186,6 +205,14 @@ private static int ParseNoteLine(string[] lines, int idx, UgcChart chart, Lists") || line.Contains(">c"))) + return idx; + var colonIdx = line.IndexOf(':'); if (colonIdx < 0) { @@ -257,6 +284,9 @@ private static int ParseNoteLine(string[] lines, int idx, UgcChart chart, List alerts) + { + var line = lines[idx]; + if (!line.StartsWith('#') || !line.Contains(">s") && !line.Contains(">c")) return false; + + if (!TryParseFollowerLine(line, out var duration, out var endCell, out var endWidth)) return false; + + // find the last SLD or HLD note and attach duration + for (int i = chart.Notes.Count - 1; i >= 0; i--) + { + var n = chart.Notes[i]; + if (n.Type is "SLD" or "HLD") { - note.SlideDuration = duration; - alerts.Add(new Alert(Warning, $"SLD 跟随行缺少结束位置(cell 与 width): {nextLine}") { Line = idx + 2, RelevantNote = FormatNoteRef(note) }); - return idx + 1; + if (n.Type == "SLD") { n.SlideDuration = duration; n.EndCell = endCell; n.EndWidth = endWidth; } + else { n.HoldDuration = duration; } + return true; } } - alerts.Add(new Alert(Warning, $"SLD 音符缺少时长跟随行") { Line = idx + 1, RelevantNote = FormatNoteRef(note) }); - return idx; + return false; } private static bool TryParseFollowerLine(string line, out int duration, out int endCell, out int endWidth, bool requireEndCellWidth = false) From b483eb05ad95f976de29d7e5d5eb2159f4d066f6 Mon Sep 17 00:00:00 2001 From: Starrah Date: Sat, 2 May 2026 20:27:03 +0800 Subject: [PATCH 15/17] =?UTF-8?q?[F]=20=E4=B8=BA=20ParseHoldNote=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E4=B8=8E=20ParseSlideNote=20=E7=9B=B8?= =?UTF-8?q?=E5=90=8C=E7=9A=84=E5=A4=9A=E8=A1=8C=E8=B7=9F=E9=9A=8F=E6=B6=88?= =?UTF-8?q?=E8=B4=B9=E9=80=BB=E8=BE=91=EF=BC=88=E5=BE=AA=E7=8E=AF=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E5=90=88=E6=B3=95=20#=E2=80=A6>s/#=E2=80=A6>c?= =?UTF-8?q?=EF=BC=8C=E8=B7=B3=E8=BF=87=20'=20=E4=B8=8E=20@=20=E8=A1=8C?= =?UTF-8?q?=EF=BC=8C=E7=B4=AF=E5=8A=A0=20HoldDuration=EF=BC=89=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- parser/chu/UgcParser.cs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/parser/chu/UgcParser.cs b/parser/chu/UgcParser.cs index 542f7b0..a597761 100644 --- a/parser/chu/UgcParser.cs +++ b/parser/chu/UgcParser.cs @@ -312,19 +312,23 @@ private static int ParseHoldNote(string[] lines, int idx, string code, ChuNote n note.Type = "HLD"; ParseCellWidth(code, 1, note, alerts, idx + 1); - if (idx + 1 < lines.Length) + bool foundFirst = false; + while (idx + 1 < lines.Length) { var nextLine = lines[idx + 1].Trim(); - if (TryParseFollowerLine(nextLine, out var duration, out _, out _)) + if (!TryParseFollowerLine(nextLine, out var duration, out _, out _)) { - note.HoldDuration = duration; - return idx + 1; + if (nextLine.StartsWith('\'') || nextLine.StartsWith('@')) { idx++; continue; } + break; } - // next line might be a comment or directive, not a warning - if (nextLine.StartsWith('\'') || nextLine.StartsWith('@')) - return idx; + + note.HoldDuration += duration; + idx++; + foundFirst = true; } - alerts.Add(new Alert(Warning, $"HLD 音符缺少时长跟随行") { Line = idx + 1, RelevantNote = FormatNoteRef(note) }); + + if (!foundFirst) + alerts.Add(new Alert(Warning, $"HLD 音符缺少时长跟随行") { Line = idx + 1, RelevantNote = FormatNoteRef(note) }); return idx; } From 05b89452f746f2cfc4d17b5fcfe0e969c011fead Mon Sep 17 00:00:00 2001 From: Starrah Date: Sat, 2 May 2026 19:23:38 +0800 Subject: [PATCH 16/17] =?UTF-8?q?[R&doc]=E4=BC=98=E5=8C=96CLI=E5=92=8CREAD?= =?UTF-8?q?ME?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Program.cs | 44 +++++++---------------- README.md | 101 +++++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 94 insertions(+), 51 deletions(-) diff --git a/Program.cs b/Program.cs index c9dbb1c..7cb11ee 100644 --- a/Program.cs +++ b/Program.cs @@ -156,7 +156,7 @@ private static void RunConvert(string inputPath, string? levelsRaw) throw new ArgumentException($"找不到路径: {inputPath}"); } - private static readonly string[] chuExtentions = new[] { ".c2s", ".ugc", ".sus" }; + private static readonly string[] supportedPostfixs = new[] { "maidata.txt", ".ma2", ".c2s", ".ugc", ".sus" }; private static void RunConvertDirectory(string dir, string? levelsRaw) { @@ -166,42 +166,22 @@ private static void RunConvertDirectory(string dir, string? levelsRaw) MatchCasing = MatchCasing.CaseInsensitive, RecurseSubdirectories = false }; + var inputPaths = Directory.EnumerateFiles(dir, "*", enumOpts) + .Where(file => supportedPostfixs.Any(file.EndsWith)).ToArray(); - var maidataPaths = Directory.GetFiles(dir, "maidata.txt", enumOpts); - var ma2Paths = Directory.GetFiles(dir, "*.ma2", enumOpts); - var chuPaths = Directory.EnumerateFiles(dir, "*", enumOpts) - .Where(file => chuExtentions.Contains(Path.GetExtension(file).ToLower())).ToArray(); - - var hasMaidata = maidataPaths.Length > 0; - var hasMa2 = ma2Paths.Length > 0; - var hasChu = chuPaths.Length > 0; - - if (hasMaidata && hasMa2) - throw new ArgumentException("目录中同时存在 maidata.txt 与 .ma2,请只保留其中一种输入。"); - if ((hasMaidata || hasMa2) && hasChu) - throw new ArgumentException("目录中不能同时存在 maimai 谱(maidata.txt / .ma2)与中二谱(.c2s / .ugc / .sus),请分开转换。"); - if (!hasMaidata && !hasMa2 && !hasChu) - throw new ArgumentException("目录中未找到任何支持的谱面文件"); - - string filename = ""; - if (hasMaidata) - { - if (maidataPaths.Length > 1) throw new ArgumentException("目录中存在多个 maidata.txt,请只保留一个。"); - filename = maidataPaths[0]; - } - else if (hasMa2) + if (inputPaths.Length > 1) { - if (ma2Paths.Length > 1) - { // 多个文件,无法直接转发给RunConvertFile,故自行调用ConvertMa2PathsToMaidata + if (inputPaths.All(file=>file.EndsWith(".ma2"))) + { // 只有多个MA2这种情况是允许的,直接调用ConvertMa2PathsToMaidata var title = new DirectoryInfo(dir).Name; - ConvertMa2PathsToMaidata(dir, title, ma2Paths, levelsRaw); - return; + ConvertMa2PathsToMaidata(dir, title, inputPaths, levelsRaw); + } + else + { + throw new ArgumentException($"目录中存在多种/多个谱面文件:{string.Join(", ", inputPaths)}。请直接指定到具体的文件路径,或者删除多余的文件。"); } - else filename = ma2Paths[0]; } - else filename = chuPaths[0]; - - RunConvertFile(filename, levelsRaw); + else RunConvertFile(inputPaths[0], levelsRaw); } private static void RunConvertFile(string filePath, string? levelsRaw) diff --git a/README.md b/README.md index ab7e078..76dfb53 100644 --- a/README.md +++ b/README.md @@ -29,11 +29,15 @@ MuConvert 是一个支持**Simai与MA2互转**的转谱器。 #### 基本用法 ```shell -MuConvert.exe [-l|--levels N[,N...]] [-o|--output <输出路径或->] [--strict|--lax] +MuConvert.exe [-l|--levels N[,N...]] [-t|--target ] [-o|--output <输出路径或->] [--strict|--lax] ``` -- **`path`**:输入路径(必填),可以是 `.txt` / `.ma2` / 目录(见下文) +- **`path`**:输入路径(必填),可以是单个文件或目录,输入目录时会自动找到和处理目录下的谱面文件(详见下文)。 - **`-l, --levels`**:仅转换指定难度(以 `maidata.txt` 的 `&inote_编号` 为准),多个难度用英文逗号分隔;省略则转换全部难度 +- **`-t, --target`**:强制指定输出格式(不区分大小写)。 + - 多数情况下不需要指定,直接使用默认值即可。默认值根据输入类型的不同而不同,但一般来说能满足常见的场景需求。 + - 具体而言,默认的转换输出格式为:Simai → `ma2`,MA2 → `simai`,C2S → `ugc`,UGC/SUS → `c2s`。 + - 目前仅有一种情况是必须指定该参数的:即想要C2S转SUS的情况,必须指定`-t sus`(否则默认转出来的是UGC) - **`-o, --output`**:指定输出位置(可选);不传入此参数时,文件将保存到“输入文件所在的目录”。 - 会智能识别你传入的是目录还是文件,做智能的处理,将转谱结果输入到目录下或保存为文件。 - 此外,还可以传入 `-` ,表示输出到stdout。 @@ -44,19 +48,24 @@ MuConvert.exe [-l|--levels N[,N...]] [-o|--output <输出路径或->] [-- #### `path` 支持的输入形式与输出规则 通过命令行传入的参数,既可以是文件,也可以是目录。 -- **输入 `.txt`(`maidata.txt` 或“纯 simai 单谱”)**:把Simai转为MA2。 - - **如果是 `maidata.txt`(含 `&inote_`)**:会在输入文件的相同目录下,产生 `lv_{id}.ma2`(每个难度一个文件)。 - - 可用类似 `-l 5,6` 的选项,只导出部分难度 - - **如果是纯 simai Notes(不含 maidata 头信息)**:会在输入文件的相同目录下,产生 `lv_0.ma2`。 - -- **输入 `.ma2` 文件**:把MA2转为Simai。 - - 输出:会在输入文件的相同目录下,产生 `maidata.txt`(当然,里面只有您传入的MA2所对应的一个难度)。 - - 如果想把多张不同难度的 `.ma2` 合并进一个 `maidata.txt`,请直接传入目录(见下一条)。 - -- **输入目录**:智能识别 - - **目录中包含 `maidata.txt`**:等价于输入该 `maidata.txt` - - **目录中包含一个或多个 `.ma2`**:将它们合并转为同目录的 `maidata.txt` - - 若目录中 **同时存在** `maidata.txt` 与 `.ma2`,或两者都不存在,会报错 +- **输入单个 maimai 相关格式文件**(`.txt` / `.ma2`)时: + - **输入 `.txt`**:把Simai转为MA2。 + - **如果是 `maidata.txt`(含 `&inote_`)**:会在输入文件的相同目录下,产生 `lv_{id}.ma2`(每个难度一个文件)。 + - 可用类似 `-l 5,6` 的选项,只导出部分难度。 + - **如果是纯 simai Notes(不含 maidata 头信息)**:会在输入文件的相同目录下,产生 `lv_0.ma2`。 + - **输入 `.ma2` 文件**:把MA2转为Simai。 + - 输出:会在输入文件的相同目录下,产生 `maidata.txt`(当然,里面只有您传入的MA2所对应的一个难度)。 + - 如果想把多张不同难度的 `.ma2` 合并进一个 `maidata.txt`,请直接传入目录(见下一条)。 + +- **输入单个 CHUNITHM 相关格式文件**(`.c2s` / `.ugc` / `.sus`)时:在 C2S、UGC、SUS之间互转。 + - 不指定 `-t` 时,默认:`.c2s` → 同目录下同名 `.ugc`;`.ugc` 或 `.sus` → 同目录下同名 `.c2s`。 + - 如果想从 C2S 转出 SUS ,则须显式指定 `-t sus`。 + +- **输入目录**时:会尝试在该目录下寻找谱面文件: + - 如果找到恰好一个:则等价于上面的输入单个文件的情况、处理这一个文件。 + - 如果找到多个: + - 如果都是MA2文件,会把这多张不同难度的 `.ma2`谱面转为simai,并合并进同一个 `maidata.txt`。 + - 否则,则是输入不明确的情况,会报错退出。 #### 示例 - **Simai(maidata)→ MA2(按难度导出)** @@ -75,6 +84,31 @@ MuConvert "D:\charts\MyChart" -l 5,6 # 只转紫谱和白谱 # 生成的转谱结果位于D:\charts\MyChart\maidata.txt ``` +
+CHUNITHM转谱相关示例 + +**UGC/SUS → C2S**(默认输出同名 `.c2s`) + +```shell +MuConvert "D:\charts\Song\0003_00.ugc" # UGC -> C2S +MuConvert "D:\charts\Song\0003_00.sus" # SUS -> C2S +# 转谱结果与输入同目录,生成 0003_00.c2s + +MuConvert "D:\charts\Song\0003_00.ugc" -t sus # 也可 UGC直接 -> C2S +``` + +**C2S → UGC / SUS** + +```shell +MuConvert "D:\charts\Song\0003_00.c2s" +# 默认同目录生成同名 .ugc + +MuConvert "D:\charts\Song\0003_00.c2s" -t sus +# 需要 SUS 时须显式指定 -t sus(否则默认为 UGC) +``` + +
+ ### 2) 将本项目作为依赖库使用 #### 导入依赖库 - **推荐做法**:把本仓库作为 git submodule 引入你的工程仓库,然后把 `MuConvert.csproj` 加入你的 `.sln`/`.slnx`。 @@ -84,7 +118,7 @@ git submodule add https://github.com/MuNET-OSS/MuConvert MuConvert # 将本项 dotnet sln .\YourSolution.sln add .\MuConvert\MuConvert.csproj # 将项目加入解决方案 ``` -#### 使用方法(TLDR): +#### maimai转谱 - 使用方法(TLDR): > 以下 C# 示例中的 `Maidata`、`MaiChart`、`SimaiParser`、`MA2Parser`、`SimaiGenerator`、`MA2Generator` 等均位于命名空间 `MuConvert.mai`中,使用时需添加 `using MuConvert.mai;`。 **Simai → MA2**: @@ -118,6 +152,24 @@ var maidataText = maidata.ToString(); // 通过ToString方法将Maidata对象序 return maidataText; // maidataText即为转谱结果 ``` +#### CHUNITHM转谱 - 使用方法(TLDR): +> 以下 C# 示例中的各种Parser、Generator等,均位于命名空间 `MuConvert.chu`中,使用时需添加 `using MuConvert.chu;`。 + +```csharp +// 首先使用File.ReadAllText等方法,将谱面整体读取为字符串 +var (c2sChart, alerts) = new C2sParser().Parse(c2sText); // 解析 C2S 谱面字符串 +var (ugcChart, alerts) = new UgcParser().Parse(ugcText); // 解析 UGC 谱面字符串 +var (susChart, alerts) = new SusParser().Parse(susText); // 解析 SUS 谱面字符串 +// 以上得到的c2sChart、ugcChart、susChart,都是IChuChart类型的谱面表示对象; +// alerts是解析过程中可能产生的警告信息等,建议打印出来。 + +var (c2sText, alerts) = new C2sGenerator().Generate(ugcChart); // UGC -> C2S +var (ugcText, alerts) = new UgcGenerator().Generate(c2sChart); // C2S -> UGC +var (susText, alerts) = new SusGenerator().Generate(c2sChart); // C2S -> SUS +// 各种Generator的Generate方法,均接受任意的IChuChart对象。 +// 同上,alerts是生成过程中可能产生的警告信息等,建议打印出来。 +``` + #### parser和generator的选项 - 部分parser和generator,在其构造参数中带有可选的选项参数,可以控制转谱时的一些行为。 - SimaiParser带有以下选项: @@ -163,15 +215,26 @@ finally - **parser(解析器)**:把“源格式文本”解析成中间表示 - `SimaiParser.Parse(string)` → `MaiChart` - `MA2Parser.Parse(string)` → `MaiChart` - - 返回值同时带有 `List`;如果遇到致命错误会抛出 `ConversionException` + - CHUNITHM的三种Parser(`C2sParser`、`UgcParser`、`SusParser`):`Parse(string)` → `IChuChart` + >
+ > 关于IChuChart + > 当前实现IChuChart是一个通用的接口而非具体的类型,这是因为目前不同Parser解析出的谱面的IR尚未能够完全统一,所以只能都各自继承自IChuChart。 + > 不过不用担心,任意的Generator都接受任意IChuChart对象,因此你可以不在意它们之间的差异,直接拿来用就行了。 + > 未来如果有机会的话,我们会把它们进一步统一成同一个具体类型的IR,以进一步提升代码的可维护性和可读性。 + >
+ - 解析成功时,**返回值会同时带有 `List`**,这是转谱过程中可能遇到的警告等信息,建议打印出来(直接对`Alert`对象`ToString()`即可)。 + - 如果解析失败,会抛出 `ConversionException`;该异常对象中同样含有一个 `List`,是导致转谱失败的错误信息,可以同上打印出来。 - **中间表示 IR(Chart)**:MuConvert 内部统一的谱面数据结构 - 对maimai,类型为 `MuConvert.mai.MaiChart` - 关键字段包括 `Chart.BpmList` 与 `Chart.Notes`,以及 `Touch/Hold/Slide` 等具体 `Note` 子类 - **generator(生成器)**:把中间表示转回“目标格式文本” - - `SimaiGenerator.Generate(MaiChart)` → simai 文本(可写入 `maidata.txt` 的 `&inote_*`) - - `MA2Generator.Generate(MaiChart)` → `.ma2` 文本 + - `SimaiGenerator.Generate(MaiChart)` → Simai 单谱文本(可写入 `maidata.txt` 的 `&inote_*`) + - `MA2Generator.Generate(MaiChart)` → MA2 文本 + - CHUNITHM的三种Generator(`C2sGenerator`、`UgcGenerator`、`SusGenerator`):`Generate(IChuChart)` → 目标格式的谱面文本 + - 与parser类似,成功生成时,**返回值会同时带有 `List`**,这是转谱过程中可能遇到的警告等信息,建议打印出来(直接对`Alert`对象`ToString()`即可)。 + - 如果生成失败,会抛出 `ConversionException`;该异常对象中同样含有一个 `List`,是导致转谱失败的错误信息,可以同上打印出来。 ## 开发者指南 From d404a6876b4bde43406f13cc1205694f9581a5fe Mon Sep 17 00:00:00 2001 From: Starrah Date: Sat, 2 May 2026 21:06:14 +0800 Subject: [PATCH 17/17] =?UTF-8?q?[R]=20=E4=BC=98=E5=8C=96=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E7=AD=89=E8=AF=AD=E8=A8=80=E4=B8=8B=E7=9A=84=E6=8A=A5?= =?UTF-8?q?=E9=94=99=E8=A1=8C=E5=8F=B7=E6=8F=90=E7=A4=BA=E6=96=87=E6=9C=AC?= =?UTF-8?q?=EF=BC=88=E5=88=A0=E6=8E=89=E4=B8=80=E4=B8=AA=E7=A9=BA=E6=A0=BC?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- i18n/Locale.Designer.cs | 32 +++++++++++++++++++------------- i18n/Locale.ko.resx | 2 +- i18n/Locale.resx | 2 +- utils/Error.cs | 2 +- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/i18n/Locale.Designer.cs b/i18n/Locale.Designer.cs index b9556cd..d1bfaaf 100644 --- a/i18n/Locale.Designer.cs +++ b/i18n/Locale.Designer.cs @@ -104,6 +104,24 @@ public static string BreakHoldOrSlideIn103 { } } + /// + /// Looks up a localized string similar to Unknown C2S note type: {0}. + /// + public static string C2SUnknownNoteType { + get { + return ResourceManager.GetString("C2SUnknownNoteType", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cannot convert chart to target format: {0}. + /// + public static string ChuGeneratorUnsupported { + get { + return ResourceManager.GetString("ChuGeneratorUnsupported", resourceCulture); + } + } + /// /// Looks up a localized string similar to The chart contains features not supported by MA2 1.03 (multi-segment connecting slides). Only the first slide segment was kept.. /// @@ -330,7 +348,7 @@ public static string MA2NoteSentenceTooManyParam { } /// - /// Looks up a localized string similar to At. + /// Looks up a localized string similar to At . /// public static string MessageAt { get { @@ -517,17 +535,5 @@ public static string WarnNonStdMA2Version { return ResourceManager.GetString("WarnNonStdMA2Version", resourceCulture); } } - - public static string C2SUnknownNoteType { - get { - return ResourceManager.GetString("C2SUnknownNoteType", resourceCulture); - } - } - - public static string ChuGeneratorUnsupported { - get { - return ResourceManager.GetString("ChuGeneratorUnsupported", resourceCulture); - } - } } } diff --git a/i18n/Locale.ko.resx b/i18n/Locale.ko.resx index dbc23b7..7517ef6 100644 --- a/i18n/Locale.ko.resx +++ b/i18n/Locale.ko.resx @@ -133,7 +133,7 @@ 잘못된 슬라이드 정의: {0} - 위치 + 위치 {0}번째 줄 diff --git a/i18n/Locale.resx b/i18n/Locale.resx index ac56c90..102e6fa 100644 --- a/i18n/Locale.resx +++ b/i18n/Locale.resx @@ -74,7 +74,7 @@ Invalid slide definition: {0} - At + At line {0} diff --git a/utils/Error.cs b/utils/Error.cs index d3ce27f..19bc992 100644 --- a/utils/Error.cs +++ b/utils/Error.cs @@ -50,7 +50,7 @@ public override string ToString() else if (TimeInBar != null) tags.Add(string.Format(Locale.MessageBar, TimeInBar.Value.CanonicalForm)); else if (TimeInSeconds != null) tags.Add(string.Format(Locale.MessageTime, TimeInSeconds.Value)); if (RelevantNote != null) tags.Add(string.Format(Locale.MessageParsing, RelevantNote)); - var tagString = tags.Count > 0 ? $"({Locale.MessageAt} {string.Join(", ", tags)}) " : ""; + var tagString = tags.Count > 0 ? $"({Locale.MessageAt}{string.Join(", ", tags)}) " : ""; string head = ""; switch (Level)