Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 30 additions & 17 deletions parser/chu/C2sParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,23 @@ 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<string> 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<string> TimingTags = new(StringComparer.OrdinalIgnoreCase)
{ "BPM", "MET", "SFL", "SLP" };

private bool _used;
private int _version;
// C2S 会原始记录 targetNote 字符串;用于在 FillAllPrevious 推断有多个候选时优先匹配。
private readonly Dictionary<ChuNote, string> _rawTargetNote = new();
private readonly Dictionary<(Rational Time, int Cell, int Width), List<(Rational, int)>> _slaRecords = new();
private readonly Dictionary<SegDictKey, List<ChuNote>> segDict = new();

public override (ChuChart, List<Alert>) 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<Alert>();
var lines = text.Replace("\r\n", "\n").Split('\n');
Expand Down Expand Up @@ -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;
Expand All @@ -95,7 +98,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":
Expand Down Expand Up @@ -127,11 +130,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.EndWidth));
if (note.IsAir) seg.EndHeight = Decimal(p, durationIdx + 3, 5);
}

return seg;
}

Expand All @@ -141,7 +144,8 @@ private void ParseNote(string[] p, ChuChart chart, List<Alert> 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")
Expand All @@ -151,7 +155,7 @@ private void ParseNote(string[] p, ChuChart chart, List<Alert> alerts, int lineN
_slaRecords.Add((note.Time, note.Cell, note.Width), (length, groupId));
return;
}

var t = type switch
{
"TAP" or "CHR" => (ChuNoteType.Tap, false),
Expand All @@ -167,16 +171,22 @@ private void ParseNote(string[] p, ChuChart chart, List<Alert> 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<ExDirection>(Str(p, 5), x=>note.Ex = x);
if (type == "CHR")
{
note.Ex = ExDirection.UP; // default value when CHR direction parsing failed
var direction = Str(p, 5);
if (!(string.IsNullOrEmpty(direction) && _version < 108))
ParseEnum<ExDirection>(direction, x => note.Ex = x);
}
else if (note is { Type: ChuNoteType.Tap, IsAir: true })
{
ParseEnum<AirDirection>(type, x=>note.AirDirection = x);
ParseEnum<AirDirection>(type, x => note.AirDirection = x);
targetNote = Str(p, 5);
if (p.Length >= 7) ParseEnum<NoteColor>(Str(p, 6), x=>note.Color = x);
if (p.Length >= 7) ParseEnum<NoteColor>(Str(p, 6), x => note.Color = x);
}
}
else
Expand All @@ -186,10 +196,13 @@ private void ParseNote(string[] p, ChuChart chart, List<Alert> 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<NoteColor>(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<NoteColor>(color, x => note.Color = x);
}
if (type is "HXD" or "SXD" or "SXC") // 解析Ex
ParseEnum<ExDirection>(Str(p, type == "HXD" ? 6 : 9), x=>note.Ex = x);
ParseEnum<ExDirection>(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 })
Expand Down
46 changes: 43 additions & 3 deletions tests/chu/ChuTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/// <summary>
/// 绝对坐标下的一段 Slide/Hold/Crush 路径,用于跨 note 的 segment 错配回收。
/// </summary>
Expand Down Expand Up @@ -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);
Expand Down
Loading