From ef62fb4d3f9d700f3192d588247ae6235fe3a266 Mon Sep 17 00:00:00 2001 From: dudehacker Date: Tue, 6 Oct 2026 22:08:49 -0400 Subject: [PATCH 1/2] Fix legacy C2S slider segment parsing --- parser/chu/C2sParser.cs | 37 ++++++++++++++++++++------------- tests/chu/ChuTests.cs | 46 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/parser/chu/C2sParser.cs b/parser/chu/C2sParser.cs index 4a7ca38..68a286a 100644 --- a/parser/chu/C2sParser.cs +++ b/parser/chu/C2sParser.cs @@ -12,7 +12,7 @@ namespace MuConvert.chu; * C2S 格式解析器(官方格式,RESOLUTION=384 tick/小节)。 * Tab 分隔文本,识别 HEADER / TIMING / NOTES 区段。 */ -public class C2sParser: BaseChuParser +public class C2sParser : BaseChuParser { private int RSL = 384; private static readonly HashSet HeadTags = new(StringComparer.OrdinalIgnoreCase) @@ -95,7 +95,7 @@ private void ParseTiming(string[] p, ChuChart chart) switch (tag) { case "BPM": - chart.BpmList.Add(new BPM(Int(p, 1) + new Rational(Int(p, 2), RSL), + chart.BpmList.Add(new BPM(Int(p, 1) + new Rational(Int(p, 2), RSL), decimal.Parse(p[3], CultureInfo.InvariantCulture))); break; case "MET": @@ -127,11 +127,11 @@ private ChuSegment ParseSegment(ChuNote note, string[] p, string type) seg.Length = new Rational(Int(p, durationIdx), RSL); if (note.Type is ChuNoteType.Slide or ChuNoteType.Crush) { - seg.EndCell = Int(p, durationIdx + 1); - seg.EndWidth = Math.Max(1, Int(p, durationIdx + 2, 1)); + seg.EndCell = Int(p, durationIdx + 1); + seg.EndWidth = Math.Max(1, Int(p, durationIdx + 2, note.Width)); if (note.IsAir) seg.EndHeight = Decimal(p, durationIdx + 3, 5); } - + return seg; } @@ -141,7 +141,8 @@ private void ParseNote(string[] p, ChuChart chart, List alerts, int lineN ChuNote? note = new ChuNote { Time = Int(p, 1) + new Rational(Int(p, 2), RSL), - Cell = Int(p, 3), Width = Math.Max(1, Int(p, 4, 1)), + Cell = Int(p, 3), + Width = Math.Max(1, Int(p, 4, 1)), }; if (type == "SLA") @@ -151,7 +152,7 @@ private void ParseNote(string[] p, ChuChart chart, List alerts, int lineN _slaRecords.Add((note.Time, note.Cell, note.Width), (length, groupId)); return; } - + var t = type switch { "TAP" or "CHR" => (ChuNoteType.Tap, false), @@ -167,16 +168,21 @@ private void ParseNote(string[] p, ChuChart chart, List alerts, int lineN }; if (t == null) return; (note.Type, note.IsAir) = t.Value; - + string? targetNote = null; if (note.Type is ChuNoteType.Tap or ChuNoteType.Mine or ChuNoteType.Flick) { - if (type == "CHR") ParseEnum(Str(p, 5), x=>note.Ex = x); + if (type == "CHR") + { + var direction = Str(p, 5); + if (string.IsNullOrEmpty(direction)) note.Ex = ExDirection.UP; + else ParseEnum(direction, x => note.Ex = x); + } else if (note is { Type: ChuNoteType.Tap, IsAir: true }) { - ParseEnum(type, x=>note.AirDirection = x); + ParseEnum(type, x => note.AirDirection = x); targetNote = Str(p, 5); - if (p.Length >= 7) ParseEnum(Str(p, 6), x=>note.Color = x); + if (p.Length >= 7) ParseEnum(Str(p, 6), x => note.Color = x); } } else @@ -186,10 +192,13 @@ private void ParseNote(string[] p, ChuChart chart, List alerts, int lineN if (ChuUtils.ShouldHaveHeight(note)) note.Height = Decimal(p, 6, 5); if (note.Type == ChuNoteType.Crush) note.CrushInterval = CrushInterval(p, 5); if (note.IsAir) // 解析颜色 - ParseEnum(Str(p, note.Type == ChuNoteType.Hold ? 7 : 11), x=>note.Color = x); + { + var color = Str(p, note.Type == ChuNoteType.Hold ? 7 : 11); + if (!string.IsNullOrEmpty(color)) ParseEnum(color, x => note.Color = x); + } if (type is "HXD" or "SXD" or "SXC") // 解析Ex - ParseEnum(Str(p, type == "HXD" ? 6 : 9), x=>note.Ex = x); - + ParseEnum(Str(p, type == "HXD" ? 6 : 9), x => note.Ex = x); + // 首先,对Air Hold/Air Slide,需要读取TargetNote,确定它是否是接续段;其他类型的音符,则默认允许是接续段 bool canConnect = true, isConnect = false; if (note is { IsAir: true, Type: ChuNoteType.Hold or ChuNoteType.Slide }) diff --git a/tests/chu/ChuTests.cs b/tests/chu/ChuTests.cs index 89ebdeb..943e6d0 100644 --- a/tests/chu/ChuTests.cs +++ b/tests/chu/ChuTests.cs @@ -39,6 +39,46 @@ public void C2sRoundTrip(string c2sPath) AssertNotesEqual(chart.Notes, reparsed.Notes); } + [Fact] + public void C2sMissingOptionalAttributesPreservesAirNoteParents() + { + var c2s = string.Join('\n', + "BPM\t0\t0\t120", + "CHR\t0\t0\t0\t4", + "AHD\t0\t0\t0\t4\tCHR\t96", + "SLD\t0\t0\t0\t4\t96\t6\t4\tSLD", + "AHD\t0\t96\t6\t4\tSLD\t96"); + + var (chart, alerts) = new C2sParser().Parse(c2s); + + Assert.Empty(alerts); + var chr = Assert.Single(chart.Notes, n => n.Type == ChuNoteType.Tap); + Assert.Equal(ExDirection.UP, chr.Ex); + + var slide = Assert.Single(chart.Notes, n => n.Type == ChuNoteType.Slide); + var airHolds = chart.Notes.Where(n => n is { Type: ChuNoteType.Hold, IsAir: true }).ToList(); + Assert.Equal(2, airHolds.Count); + Assert.Same(chr, airHolds.Single(n => n.TargetNote?.Type == ChuNoteType.Tap).TargetNote); + Assert.Same(slide, airHolds.Single(n => n.TargetNote?.Type == ChuNoteType.Slide).TargetNote); + Assert.All(airHolds, note => Assert.Equal(NoteColor.DEF, note.Color)); + } + + [Fact] + public void C2sSlideMissingEndWidthContinuesAtStartWidth() + { + var c2s = string.Join('\n', + "BPM\t0\t0\t120", + "SLC\t0\t0\t6\t4\t24\t6", + "SLC\t0\t24\t6\t4\t52\t5"); + + var (chart, alerts) = new C2sParser().Parse(c2s); + + Assert.Empty(alerts); + var slide = Assert.Single(chart.Notes); + Assert.Equal(2, slide.Segments.Count); + Assert.Equal(4, slide.Segments[0].EndWidth); + } + /// /// 绝对坐标下的一段 Slide/Hold/Crush 路径,用于跨 note 的 segment 错配回收。 /// @@ -690,15 +730,15 @@ public void C2sToUgcViaGenerator(string 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); - + // Sheriruth Expert/Master 存在同位置 CHR+HLD/SLD 叠放(非 ExLong);默认会把 CHR 消费进长条, // 导致与官谱 IR 不对齐,故仅对这两张开启 NoExLong。 var noExLong = c2sPath.Contains("2351_02") || c2sPath.Contains("2351_03"); - + // 再把转出来的ugc,parse回去,比较是否和一开始的c2s等价 var (ugcReparsed, _) = new UgcParser(noExLong: noExLong).Parse(ugcText); Assert.NotEmpty(ugcReparsed.Notes); From fc038360827b8775aefe6d67422621c45910e2ee Mon Sep 17 00:00:00 2001 From: Starrah Date: Thu, 8 Oct 2026 16:53:32 +0800 Subject: [PATCH 2/2] refactor: C2SParser add "VERSION" parsing, and use VERSION to decide ParseEnum alert. --- parser/chu/C2sParser.cs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/parser/chu/C2sParser.cs b/parser/chu/C2sParser.cs index 68a286a..5c29884 100644 --- a/parser/chu/C2sParser.cs +++ b/parser/chu/C2sParser.cs @@ -20,7 +20,7 @@ public class C2sParser : BaseChuParser private static readonly HashSet TimingTags = new(StringComparer.OrdinalIgnoreCase) { "BPM", "MET", "SFL", "SLP" }; - private bool _used; + private int _version; // C2S 会原始记录 targetNote 字符串;用于在 FillAllPrevious 推断有多个候选时优先匹配。 private readonly Dictionary _rawTargetNote = new(); private readonly Dictionary<(Rational Time, int Cell, int Width), List<(Rational, int)>> _slaRecords = new(); @@ -28,8 +28,7 @@ public class C2sParser : BaseChuParser public override (ChuChart, List) Parse(string text) { - if (_used) throw new Exception(Locale.InstanceMultipleUsage); - _used = true; + if (_version > 0) throw new Exception(Locale.InstanceMultipleUsage); var chart = new ChuChart(); var alerts = new List(); var lines = text.Replace("\r\n", "\n").Split('\n'); @@ -81,6 +80,10 @@ private void ParseHeader(string[] p, ChuChart chart) var tag = p[0].ToUpperInvariant(); switch (tag) { + case "VERSION": + var segs = p[1].Split('.').Select(int.Parse).ToArray(); + _version = segs[0] * 100 + segs[1]; + break; case "MUSIC": chart.MusicId = Int(p, 1).ToString(); break; case "DIFFICULT": chart.Difficulty = Int(p, 1); break; case "LEVEL": chart.Level = Decimal(p, 1); break; @@ -128,7 +131,7 @@ private ChuSegment ParseSegment(ChuNote note, string[] p, string type) if (note.Type is ChuNoteType.Slide or ChuNoteType.Crush) { seg.EndCell = Int(p, durationIdx + 1); - seg.EndWidth = Math.Max(1, Int(p, durationIdx + 2, note.Width)); + seg.EndWidth = Math.Max(1, Int(p, durationIdx + 2, note.EndWidth)); if (note.IsAir) seg.EndHeight = Decimal(p, durationIdx + 3, 5); } @@ -174,9 +177,10 @@ private void ParseNote(string[] p, ChuChart chart, List alerts, int lineN { if (type == "CHR") { + note.Ex = ExDirection.UP; // default value when CHR direction parsing failed var direction = Str(p, 5); - if (string.IsNullOrEmpty(direction)) note.Ex = ExDirection.UP; - else ParseEnum(direction, x => note.Ex = x); + if (!(string.IsNullOrEmpty(direction) && _version < 108)) + ParseEnum(direction, x => note.Ex = x); } else if (note is { Type: ChuNoteType.Tap, IsAir: true }) {