Skip to content

Commit 52af0a3

Browse files
committed
[+] UGC 对Air Slide和Air Hold的解析
参见文档: https://gist.github.com/inonote/5c01e73781cab17765a1d93641d52298
1 parent 9c13621 commit 52af0a3

2 files changed

Lines changed: 122 additions & 58 deletions

File tree

parser/chu/UgcParser.cs

Lines changed: 108 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@
88
namespace MuConvert.chu;
99

1010
/**
11-
* UGC 格式解析器(UMIGURI 格式,@TICKS=480 tick/拍)。
12-
* @HEADER 标签 + #measure'tick:code 音符格式。
11+
* UMIGURI语法文档: https://gist.github.com/inonote/5c01e73781cab17765a1d93641d52298
1312
*/
1413
public class UgcParser: BaseChuParser
1514
{
@@ -23,7 +22,6 @@ public class UgcParser: BaseChuParser
2322
["DC"] = "ADW",
2423
["DR"] = "ADR",
2524
["DL"] = "ADL",
26-
["HD"] = "AHD",
2725
};
2826

2927
private static readonly Dictionary<string, string> ChrExtras = new()
@@ -273,7 +271,7 @@ private static int ParseNoteLine(string[] lines, int idx, ChuChart chart, List<A
273271
Time = measure + new Rational(tick, RSL),
274272
};
275273

276-
var typeChar = char.ToLowerInvariant(code[0]);
274+
var typeChar = code[0];
277275

278276
switch (typeChar)
279277
{
@@ -285,17 +283,29 @@ private static int ParseNoteLine(string[] lines, int idx, ChuChart chart, List<A
285283
break;
286284

287285
case 'h':
288-
idx = ParseHoldNote(lines, idx, code, note, alerts, chart);
286+
idx = ParseHoldNote(false, lines, idx, code, note, alerts, chart);
287+
break;
288+
case 'H': // Air Hold
289+
idx = ParseHoldNote(true, lines, idx, code, note, alerts, chart);
289290
break;
290291

291292
case 's':
292-
idx = ParseSlideNote(lines, idx, code, note, alerts, chart);
293+
idx = ParseSlideNote(false, lines, idx, code, note, alerts, chart);
293294
note = null; // ParseSlideNote中,会自己构造note并自己添加进chart。因此这里默认的统一note不应被添加进chart。
294295
break;
296+
case 'S': // Air Slide
297+
idx = ParseSlideNote(true, lines, idx, code, note, alerts, chart);
298+
note = null;
299+
break;
295300

296301
case 'a':
297302
ParseAirNote(code, note, alerts, lineNum, chart);
298303
break;
304+
case 'C': // Air Crush
305+
case 'T': // 暂时不确定这是什么,只出现在v7以前版本中,v8开始已经没有了
306+
idx = ParseAirCrushNote(lines, idx, code, note, alerts, chart);
307+
note = null;
308+
break;
299309

300310
case 'f':
301311
note.Type = "FLK";
@@ -334,54 +344,70 @@ private static void ParseTapNote(string code, ChuNote note, List<Alert> alerts,
334344
}
335345
}
336346

337-
private static int ParseHoldNote(string[] lines, int idx, string code, ChuNote note, List<Alert> alerts, ChuChart chart)
347+
private static int ParseHoldNote(bool isAirHold, string[] lines, int idx, string code, ChuNote note, List<Alert> alerts, ChuChart chart)
338348
{
339-
note.Type = "HLD";
349+
note.Type = isAirHold ? "AHD" : "HLD";
340350
ParseCellWidth(code, 1, note, alerts, idx + 1, chart);
341351

352+
if (isAirHold)
353+
{
354+
// 解析颜色数据。目前只解析、不使用。
355+
_ = code.Last(); // var colorChar 颜色标记 N/I
356+
}
357+
342358
bool foundFirst = false;
343359
while (idx + 1 < lines.Length)
344360
{
345361
var nextLine = lines[idx + 1].Trim();
346-
if (!TryParseFollowerLine(nextLine, out _, out var duration, out _, out _))
362+
if (!TryParseFollowerLine(nextLine, out var marker, out var duration, out _, out _, out _, false))
347363
{
348364
if (nextLine.StartsWith('\'') || nextLine.StartsWith('@')) { idx++; continue; }
349365
break;
350366
}
351367

352368
note.Duration += new Rational(duration, RSL);
369+
if (isAirHold && marker == "c") note.Type = "AHX"; // 可能是对应于UMIGURI文档中的 AirHold的 AIR-ACTION 无し终点
353370
idx++;
354371
foundFirst = true;
355372
}
356373

357374
if (!foundFirst)
358-
alerts.Add(new Alert(Warning, $"HLD 音符缺少时长跟随行") { Line = idx + 1, RelevantNote = FormatNoteRef(note, chart) });
375+
alerts.Add(new Alert(Warning, $"HLD 音符缺少时长跟随行") { Line = idx + 1, RelevantNote = lines[idx] });
359376
return idx;
360377
}
361378

362-
private static int ParseSlideNote(string[] lines, int idx, string code, ChuNote previousNote, List<Alert> alerts, ChuChart chart)
379+
private static int ParseSlideNote(bool isAirSlide, string[] lines, int idx, string code, ChuNote previousNote, List<Alert> alerts, ChuChart chart)
363380
{
364381
// 注:一开始从外面传进来的previousNote,最后并不会被添加进chart里,只是作为第一段的起点参照而已。
365382
var startTime = previousNote.Time;
366383
ParseCellWidth(code, 1, previousNote, alerts, idx + 1, chart);
367384
previousNote.EndCell = previousNote.Cell;
368385
previousNote.EndWidth = previousNote.Width;
369386

387+
if (isAirSlide)
388+
{
389+
// 解析高度和颜色数据。目前只解析、不使用。
390+
TryParseUgcBase36Int2(code.AsSpan(3, code.Length - 4), out _); // out var startHeight 起始的高度值
391+
_ = code.Last(); // var colorChar 颜色标记 N/I
392+
}
393+
370394
bool foundFirst = false;
371395
while (idx + 1 < lines.Length)
372396
{ // 循环处理所有的跟随行。idx始终指向上一条已经处理完的行。
373397
var nextLine = lines[idx + 1].Trim();
374-
if (!TryParseFollowerLine(nextLine, out var marker, out var duration, out var endCell, out var endWidth, true))
398+
if (!TryParseFollowerLine(nextLine, out var marker, out var duration, out var endCell, out var endWidth, out _, true))
375399
{
376400
if (nextLine.StartsWith('\'') || nextLine.StartsWith('@')) { idx++; continue; }
377401
break;
378402
}
379403

404+
var type = isAirSlide ? (marker == "s" ? "ASD" : "ASC") : (marker == "s" ? "SLD" : "SLC");
405+
380406
var segmentEnd = startTime + new Rational(duration, RSL);
381407
var note = new ChuNote
382408
{
383-
Type = marker == "s" ? "SLD" : "SLC",
384-
Time = previousNote.EndTime, Cell = previousNote.EndCell, Width = previousNote.EndWidth,
409+
Type = type, Time = previousNote.EndTime,
410+
Cell = previousNote.EndCell, Width = previousNote.EndWidth,
385411
Duration = segmentEnd - previousNote.EndTime,
386412
EndCell = endCell, EndWidth = endWidth,
387413
Previous = foundFirst ? previousNote : null,
@@ -393,37 +419,56 @@ private static int ParseSlideNote(string[] lines, int idx, string code, ChuNote
393419
}
394420

395421
if (!foundFirst)
396-
alerts.Add(new Alert(Warning, $"SLD 音符缺少时长跟随行") { Line = idx + 1, RelevantNote = FormatNoteRef(previousNote, chart) });
422+
alerts.Add(new Alert(Warning, $"SLD 音符缺少时长跟随行") { Line = idx + 1, RelevantNote = lines[idx] });
397423

398424
return idx;
399425
}
400426

401-
private static bool TryParseFollowerLine(string line, out string marker, out int duration, out int endCell, out int endWidth, bool requireEndCellWidth = false)
427+
private static bool TryParseUgcBase36Int2(ReadOnlySpan<char> twoChars, out int value)
402428
{
403-
duration = 0;
429+
value = 0;
430+
if (twoChars.Length == 0) return false;
431+
else if (twoChars.Length == 1)
432+
{
433+
if (!TryHexCharToInt(twoChars[0], out value)) return false;
434+
}
435+
else
436+
{
437+
if (!TryHexCharToInt(twoChars[0], out var hi) || !TryHexCharToInt(twoChars[1], out var lo)) return false;
438+
value = hi * 36 + lo;
439+
}
440+
return true;
441+
}
442+
443+
private static bool TryParseFollowerLine(string line, out string marker, out int endTick, out int endCell, out int endWidth, out int? height, bool requireEndCellWidth)
444+
{
445+
endTick = 0;
404446
endCell = 0;
405447
endWidth = 1;
406448
marker = "";
449+
height = null;
407450

408451
if (!line.StartsWith('#')) return false;
409452

410453
// support both >s (SLD) and >c (SLC) follower lines
411-
int gtIdx = line.IndexOfAny(['>', ':']);
412-
if (gtIdx < 1) return false;
413-
marker = line[gtIdx+1].ToString();
454+
int sepIdx = line.IndexOfAny(['>', ':']);
455+
if (sepIdx < 1) return false;
456+
marker = line[sepIdx+1].ToString();
414457
int markerLen = 2;
415458

416-
var durationStr = line[1..gtIdx];
417-
if (!int.TryParse(durationStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out duration)) return false;
459+
var endTickStr = line[1..sepIdx];
460+
if (!int.TryParse(endTickStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out endTick)) return false;
418461

419-
var afterMarker = line[(gtIdx + markerLen)..];
462+
var afterMarker = line[(sepIdx + markerLen)..];
420463
if (afterMarker.Length >= 2)
421464
{
422465
endCell = HexCharToInt(afterMarker[0]);
423-
endWidth = WidthHexCharToInt(afterMarker[1]);
466+
endWidth = HexCharToInt(afterMarker[1]);
424467
}
425468
else if (requireEndCellWidth) return false;
426469

470+
if (afterMarker.Length > 2 && TryParseUgcBase36Int2(afterMarker.AsSpan()[2..], out var heightV)) height = heightV;
471+
427472
return true;
428473
}
429474

@@ -433,19 +478,18 @@ private static void ParseCellWidth(string code, int startIdx, ChuNote note, List
433478
{
434479
note.Cell = HexCharToInt(code[startIdx]);
435480
if (code.Length > startIdx + 1)
436-
note.Width = WidthHexCharToInt(code[startIdx + 1]);
481+
note.Width = HexCharToInt(code[startIdx + 1]);
437482
else
438-
alerts.Add(new Alert(Warning, $"音符缺少 width: {code}") { Line = lineNum, RelevantNote = FormatNoteRef(note, chart) });
483+
alerts.Add(new Alert(Warning, $"音符缺少 width: {code}") { Line = lineNum, RelevantNote = FormatNoteRef(note, code) });
439484
}
440485
else
441486
{
442-
alerts.Add(new Alert(Warning, $"音符缺少 cell 和 width: {code}") { Line = lineNum, RelevantNote = FormatNoteRef(note, chart) });
487+
alerts.Add(new Alert(Warning, $"音符缺少 cell 和 width: {code}") { Line = lineNum, RelevantNote = FormatNoteRef(note, code) });
443488
}
444489
}
445490

446491
private static void ParseAirNote(string code, ChuNote note, List<Alert> alerts, int lineNum, ChuChart chart)
447492
{
448-
// Matches UgcGenerator: "a" + cell + width + two-letter direction + targetNote [ + "_" + airHoldDuration for AHD ]
449493
if (code.Length < 5)
450494
{
451495
alerts.Add(new Alert(Warning, $"AIR 音符代码过短: {code}") { Line = lineNum });
@@ -454,9 +498,7 @@ private static void ParseAirNote(string code, ChuNote note, List<Alert> alerts,
454498
}
455499

456500
ParseCellWidth(code, 1, note, alerts, lineNum, chart);
457-
var afterCellWidth = code[3..];
458-
var underscoreIdx = afterCellWidth.IndexOf('_');
459-
var mainPart = underscoreIdx >= 0 ? afterCellWidth[..underscoreIdx] : afterCellWidth;
501+
var mainPart = code[3..5];
460502

461503
if (mainPart.Length < 2)
462504
{
@@ -473,44 +515,58 @@ private static void ParseAirNote(string code, ChuNote note, List<Alert> alerts,
473515
else
474516
{
475517
note.Type = "AIR";
476-
alerts.Add(new Alert(Warning, $"未知的 AIR 方向: {dir}") { Line = lineNum, RelevantNote = FormatNoteRef(note, chart) });
518+
alerts.Add(new Alert(Warning, $"未知的 AIR 方向: {dir}") { Line = lineNum, RelevantNote = FormatNoteRef(note, code) });
477519
}
520+
}
478521

479-
if (underscoreIdx >= 0 && note.Type == "AHD")
480-
{
481-
var durStr = afterCellWidth[(underscoreIdx + 1)..];
482-
if (int.TryParse(durStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ahdDuration))
483-
note.Duration = new Rational(ahdDuration, RSL);
522+
private static int ParseAirCrushNote(string[] lines, int idx, string code, ChuNote previousNote, List<Alert> alerts, ChuChart chart)
523+
{
524+
// TODO 尚未实现,所以先给个警告
525+
alerts.Add(new Alert(Warning, "当前版本尚未实现对Air-Crush(UMIGURI的':C'或':T'音符)的解析。") { Line = idx, RelevantNote = lines[idx] });
526+
527+
bool foundFirst = false;
528+
while (idx + 1 < lines.Length)
529+
{ // 循环处理所有的跟随行。idx始终指向上一条已经处理完的行。
530+
var nextLine = lines[idx + 1].Trim();
531+
if (!TryParseFollowerLine(nextLine, out var marker, out var duration, out _, out _, out _, false))
532+
{
533+
if (nextLine.StartsWith('\'') || nextLine.StartsWith('@')) { idx++; continue; }
534+
break;
535+
}
536+
537+
// TODO 尚未实现
538+
idx++;
539+
foundFirst = true;
484540
}
541+
542+
if (!foundFirst)
543+
alerts.Add(new Alert(Warning, $"air-crush 音符缺少时长跟随行") { Line = idx + 1, RelevantNote = lines[idx] });
544+
return idx;
485545
}
486546

487547
private static int HexCharToInt(char c)
488548
{
489-
return c switch
490-
{
491-
>= '0' and <= '9' => c - '0',
492-
>= 'A' and <= 'F' => c - 'A' + 10,
493-
>= 'a' and <= 'f' => c - 'a' + 10,
494-
_ => 0,
495-
};
549+
if (!TryHexCharToInt(c, out var result)) result = 0;
550+
return result;
496551
}
497552

498-
private static int WidthHexCharToInt(char c)
553+
private static bool TryHexCharToInt(char c, out int result)
499554
{
500-
return c switch
555+
result = c switch
501556
{
502-
>= '1' and <= '9' => c - '1' + 1,
503-
>= 'A' and <= 'G' => c - 'A' + 10,
504-
>= 'a' and <= 'g' => c - 'a' + 10,
505-
_ => 1,
557+
>= '0' and <= '9' => c - '0',
558+
>= 'A' and <= 'Z' => c - 'A' + 10,
559+
>= 'a' and <= 'z' => c - 'a' + 10,
560+
_ => -1,
506561
};
562+
return result >= 0;
507563
}
508564

509565
// ReSharper disable once UnusedParameter.Local
510-
private static string FormatNoteRef(ChuNote note, ChuChart chart)
566+
private static string FormatNoteRef(ChuNote note, string code)
511567
{
512568
var (m, o) = Utils.BarAndTick(note.Time, RSL);
513-
return $"#{m}'{o}:{note.Type}";
569+
return $"#{m}'{o}:{code}";
514570
}
515571
}
516572

tests/chu/ChuTests.cs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ public void C2sRoundTrip()
4747
/// <summary>
4848
/// Builds a stable, comparable string from a note's public instance properties (name-sorted)
4949
/// so round-trip tests verify no field loss without hard-coding each property in the test.
50+
/// Omits <see cref="ChuNote.ExtraData"/> and <see cref="ChuNote.EndTime"/> (redundant with Time/Duration or not stable across formats).
5051
/// </summary>
5152
private static string SnapshotNote(ChuNote note)
5253
{
@@ -60,9 +61,11 @@ private static string SnapshotNote(ChuNote note)
6061

6162
var propParts = typeof(ChuNote).GetProperties(BindingFlags.Instance | BindingFlags.Public)
6263
.OrderBy(p => p.Name)
64+
.Where(p => p.Name != nameof(ChuNote.EndTime))
6365
.Select(p => $"{p.Name}={F(p.GetValue(note))}");
6466
var fieldParts = typeof(ChuNote).GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)
6567
.OrderBy(f => f.Name)
68+
.Where(f => f.Name != nameof(ChuNote.ExtraData))
6669
.Select(f => $"{f.Name}={F(f.GetValue(note))}");
6770
return string.Join("|", propParts.Concat(fieldParts));
6871
}
@@ -114,20 +117,25 @@ private static void AssertUgcNotesEquivalentToReparsedC2s(ChuChart ugc, ChuChart
114117
{
115118
if (isUgcReference)
116119
{
117-
var ugcSnaps = ugc.Notes
118-
.Where(n=>n.Type != "CLICK")
120+
var ugcSnaps = ugc.Notes.Where(n=>n.Type != "CLICK")
121+
.OrderBy(n=>n.Time).ThenBy(n=>n.Cell).ThenBy(n=>n.Width).ThenBy(n=>n.Duration).ThenBy(n=>n.Type)
119122
.Select(n => SnapshotNote(UgcNoteScaledToC2sTicks(n, 480, 384)))
120-
.OrderBy(s => s)
121123
.ToArray();
122-
var c2sSnaps = c2s.Notes.Select(SnapshotNote).OrderBy(s => s).ToArray();
124+
var c2sSnaps = c2s.Notes
125+
.OrderBy(n=>n.Time).ThenBy(n=>n.Cell).ThenBy(n=>n.Width).ThenBy(n=>n.Duration).ThenBy(n=>n.Type)
126+
.Select(SnapshotNote)
127+
.ToArray();
123128
Assert.Equal(ugcSnaps, c2sSnaps);
124129
}
125130
else
126131
{
127-
var ugcSnaps = ugc.Notes.Select(SnapshotNote).OrderBy(s => s).ToArray();
132+
var ugcSnaps = ugc.Notes.Where(n=>n.Type != "CLICK")
133+
.OrderBy(n=>n.Time).ThenBy(n=>n.Cell).ThenBy(n=>n.Width).ThenBy(n=>n.Duration).ThenBy(n=>n.Type)
134+
.Select(SnapshotNote)
135+
.ToArray();
128136
var c2sSnaps = c2s.Notes
137+
.OrderBy(n=>n.Time).ThenBy(n=>n.Cell).ThenBy(n=>n.Width).ThenBy(n=>n.Duration).ThenBy(n=>n.Type)
129138
.Select(n => SnapshotNote(C2sNoteScaledToUgcTicks(n, 480, 384)))
130-
.OrderBy(s => s)
131139
.ToArray();
132140
Assert.Equal(c2sSnaps, ugcSnaps);
133141
}

0 commit comments

Comments
 (0)