Skip to content

Commit e5bfc86

Browse files
authored
Merge pull request #2 from Applesaber/master
feat: 新增 C2S/UGC/SUS 谱面格式支持 (chu)
2 parents 54c62a4 + 25f364b commit e5bfc86

30 files changed

Lines changed: 6817 additions & 109 deletions

Program.cs

Lines changed: 133 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.CommandLine;
22
using System.Text;
33
using System.Text.RegularExpressions;
4+
using MuConvert.chu;
45
using MuConvert.mai;
56
using MuConvert.utils;
67

@@ -40,51 +41,51 @@ private static Command BuildRootCommand()
4041
{
4142
var root = new RootCommand
4243
{
43-
Description = $"MuConvert {Utils.AppVersion} — 新一代Simai与MA2互转转谱器\n"
44+
Description = $"MuConvert {Utils.AppVersion} — 新一代多功能音游转谱器\n" +
45+
$"使用文档详见:https://github.com/MuNET-OSS/MuConvert/blob/master/README.md"
4446
};
4547

4648
var levelsOption = new Option<string?>("--levels", "-l")
4749
{
48-
Description = "仅转换指定难度(以maidata中的&inote_编号为准),多个难度用逗号分隔;省略则转换全部难度。",
50+
Description = "仅转换指定难度,多个难度用逗号分隔;省略则转换全部难度。",
4951
HelpName = "N[,N...]"
5052
};
5153

54+
var targetOption = new Option<string?>("--target", "-t")
55+
{
56+
Description = "强制指定输出格式。目前仅有C2S->SUS必须指定本参数,其他情况省略使用默认值即可。",
57+
HelpName = "format"
58+
};
59+
5260
var outputOption = new Option<string?>("--output", "-o")
5361
{
54-
Description =
55-
"输出位置:\n" +
56-
"· 省略:写入输入文件同目录,文件名按默认规则(maidata.txt、lv_N.ma2 等)。\n" +
57-
"· 目录:写入该目录,文件名同上按默认规则。\n" +
58-
"· 文件:仅当本次转换只会生成一个输出文件时可用;扩展名须为 .txt(输出 maidata)或 .ma2(输出 MA2)。\n" +
59-
\"-\":仅当本次转换只会生成一个输出文件时可用;将输出内容写到stdout。",
62+
Description = "指定输出位置。可指定文件或目录,或\"-\"(stdout);不指定则默认为输入文件所在目录。",
6063
HelpName = "path"
6164
};
6265

6366
var strictOption = new Option<bool>("--strict")
6467
{
65-
Description = "Simai转MA2时,解析使用严格模式。不可与 --lax 同时使用。",
68+
Description = "解析使用严格模式(仅在Simai转MA2模式下有效)",
6669
Arity = ArgumentArity.ZeroOrOne,
6770
DefaultValueFactory = _ => false
6871
};
6972

7073
var laxOption = new Option<bool>("--lax")
7174
{
72-
Description = "Simai转MA2时,解析使用宽松模式。不可与 --strict 同时使用。",
75+
Description = "解析使用宽松模式(仅在Simai转MA2模式下有效)",
7376
Arity = ArgumentArity.ZeroOrOne,
7477
DefaultValueFactory = _ => false
7578
};
7679

7780
var inputArgument = new Argument<string>("path")
7881
{
79-
Description = "可以输入以下几种情况:\n" +
80-
"1.单个.txt文件(标准maidata.txt,或是不含maidata的头信息、直接是Simai的Notes的文件,都可以)。会把它转为MA2。请通过-l指定要转换的谱面难度,不指定则默认转换全部难度。\n" +
81-
"2.单个.ma2文件。会把它转为Simai,输出maidata.txt。如果想要转换多个难度,请传入目录,详见第4条。\n" +
82-
"3.一个包含有maidata.txt的目录。行为同第一条。\n" +
83-
"4.一个包含有一个或多个.ma2文件的目录。会把它们转为一个maidata.txt。请通过-l指定要转换的谱面难度,不指定则默认转换全部难度。",
82+
Description = "可以输入文件或目录。会自动根据输入的类型,智能执行相应的转换程序。\n" +
83+
"例如,输入一个包含多个.ma2文件的目录,则会把各个难度合并转为一个maidata.txt。",
8484
Arity = ArgumentArity.ExactlyOne
8585
};
8686

8787
root.Options.Add(levelsOption);
88+
root.Options.Add(targetOption);
8889
root.Options.Add(outputOption);
8990
root.Options.Add(strictOption);
9091
root.Options.Add(laxOption);
@@ -95,6 +96,8 @@ private static Command BuildRootCommand()
9596
var inputPath = parseResult.GetValue(inputArgument)
9697
?? throw new InvalidOperationException("缺少参数 path。");
9798
var levelsRaw = parseResult.GetValue(levelsOption);
99+
var targetRaw = parseResult.GetValue(targetOption);
100+
_cliTargetNormalized = string.IsNullOrWhiteSpace(targetRaw) ? null : targetRaw.Trim().ToLowerInvariant();
98101
_outputSpec = OutputSpec.Parse(parseResult.GetValue(outputOption));
99102

100103
var cliStrict = parseResult.GetValue(strictOption);
@@ -112,6 +115,9 @@ private static Command BuildRootCommand()
112115
/// <summary>由 CLI 在每次 <c>SetAction</c> 入口赋值;转换逻辑只读此字段。</summary>
113116
private static OutputSpec _outputSpec;
114117
private static SimaiParser.StrictLevelEnum _simaiStrictLevel = SimaiParser.StrictLevelEnum.Normal;
118+
119+
/// <summary>由 CLI 赋值;为 null 表示按输入类型使用默认输出格式,否则为小写的目标格式名(如 sus、ma2)。</summary>
120+
private static string? _cliTargetNormalized;
115121

116122
private enum OutputSinkKind { Default, Stdout, Directory, File }
117123

@@ -149,6 +155,8 @@ private static void RunConvert(string inputPath, string? levelsRaw)
149155
else
150156
throw new ArgumentException($"找不到路径: {inputPath}");
151157
}
158+
159+
private static readonly string[] supportedPostfixs = new[] { "maidata.txt", ".ma2", ".c2s", ".ugc", ".sus" };
152160

153161
private static void RunConvertDirectory(string dir, string? levelsRaw)
154162
{
@@ -158,28 +166,22 @@ private static void RunConvertDirectory(string dir, string? levelsRaw)
158166
MatchCasing = MatchCasing.CaseInsensitive,
159167
RecurseSubdirectories = false
160168
};
169+
var inputPaths = Directory.EnumerateFiles(dir, "*", enumOpts)
170+
.Where(file => supportedPostfixs.Any(file.EndsWith)).ToArray();
161171

162-
var maidataPaths = Directory.GetFiles(dir, "maidata.txt", enumOpts);
163-
var ma2Paths = Directory.GetFiles(dir, "*.ma2", enumOpts);
164-
165-
var hasMaidata = maidataPaths.Length > 0;
166-
var hasMa2 = ma2Paths.Length > 0;
167-
168-
if (hasMaidata && hasMa2)
169-
throw new ArgumentException("目录中同时存在 maidata.txt 与 .ma2,请只保留其中一种输入。");
170-
if (!hasMaidata && !hasMa2)
171-
throw new ArgumentException("目录中未找到 maidata.txt 或 .ma2 文件。");
172-
173-
if (hasMaidata)
172+
if (inputPaths.Length > 1)
174173
{
175-
if (maidataPaths.Length > 1)
176-
throw new ArgumentException("目录中存在多个 maidata.txt,请只保留一个。");
177-
RunConvertTxtFile(maidataPaths[0], levelsRaw);
178-
return;
174+
if (inputPaths.All(file=>file.EndsWith(".ma2")))
175+
{ // 只有多个MA2这种情况是允许的,直接调用ConvertMa2PathsToMaidata
176+
var title = new DirectoryInfo(dir).Name;
177+
ConvertMa2PathsToMaidata(dir, title, inputPaths, levelsRaw);
178+
}
179+
else
180+
{
181+
throw new ArgumentException($"目录中存在多种/多个谱面文件:{string.Join(", ", inputPaths)}。请直接指定到具体的文件路径,或者删除多余的文件。");
182+
}
179183
}
180-
181-
var title = new DirectoryInfo(dir).Name;
182-
ConvertMa2PathsToMaidata(dir, title, ma2Paths, levelsRaw);
184+
else RunConvertFile(inputPaths[0], levelsRaw);
183185
}
184186

185187
private static void RunConvertFile(string filePath, string? levelsRaw)
@@ -199,7 +201,18 @@ private static void RunConvertFile(string filePath, string? levelsRaw)
199201
return;
200202
}
201203

202-
throw new ArgumentException($"不支持的输入扩展名「{ext}」。支持 .txt、.ma2,或目录。");
204+
if (string.Equals(ext, ".c2s", StringComparison.OrdinalIgnoreCase) ||
205+
string.Equals(ext, ".ugc", StringComparison.OrdinalIgnoreCase) ||
206+
string.Equals(ext, ".sus", StringComparison.OrdinalIgnoreCase))
207+
{
208+
if (levelsRaw != null) throw new ArgumentException("-l / --levels 仅适用于 maimai 的 maidata 或目录中的 .ma2,不适用于中二谱(.c2s / .ugc / .sus)。");
209+
AssertStrictLaxOnlyForSimaiToMa2(" 中二谱(.c2s / .ugc / .sus)");
210+
var kind = ext.TrimStart('.').ToLowerInvariant();
211+
RunConvertChuSingleFile(filePath, kind);
212+
return;
213+
}
214+
215+
throw new ArgumentException($"不支持的输入扩展名「{ext}」。支持 .txt、.ma2、.c2s、.ugc、.sus,或目录。");
203216
}
204217

205218
private static void RunConvertTxtFile(string inputPath, string? levelsRaw)
@@ -209,6 +222,9 @@ private static void RunConvertTxtFile(string inputPath, string? levelsRaw)
209222
var inputDir = Path.GetDirectoryName(Path.GetFullPath(inputPath))!;
210223
var text = File.ReadAllText(inputPath, Encoding.UTF8);
211224

225+
var targetFormat = _cliTargetNormalized ?? "ma2";
226+
if (targetFormat != "ma2") throw new ArgumentException($"不支持的输出类型「{targetFormat}」。输入文件为simai时,输出格式仅支持ma2。");
227+
212228
if (LooksLikeMaidata(text))
213229
{
214230
var maidata = new Maidata(text);
@@ -278,8 +294,10 @@ private static void ConvertMa2PathsToMaidata(string outputDir, string title, IRe
278294
{
279295
if (ma2FullPaths.Count == 0)
280296
throw new ArgumentException("未提供任何 .ma2 文件。");
281-
if (_simaiStrictLevel != SimaiParser.StrictLevelEnum.Normal)
282-
throw new ArgumentException("--strict / --lax 仅适用于 Simai(.txt / maidata)转 MA2,不能用于 MA2 转 Simai。");
297+
AssertStrictLaxOnlyForSimaiToMa2(" MA2 转 Simai");
298+
299+
var targetFormat = _cliTargetNormalized ?? "simai";
300+
if (targetFormat != "simai") throw new ArgumentException($"不支持的输出类型「{targetFormat}」。输入文件为ma2时,输出格式仅支持simai。");
283301

284302
var paths = ma2FullPaths.Select(Path.GetFullPath).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
285303
var levelFilter = string.IsNullOrWhiteSpace(levelsRaw) ? null : ParseLevelList(levelsRaw);
@@ -300,7 +318,7 @@ private static void ConvertMa2PathsToMaidata(string outputDir, string title, IRe
300318

301319
foreach (var (fullPath, levelId) in assignments)
302320
{
303-
Console.Error.WriteLine($"SimaiMA2: {fullPath}(lv{levelId}) → {destNote}");
321+
Console.Error.WriteLine($"MA2Simai: {fullPath}(lv{levelId}) → {destNote}");
304322
var ma2Text = File.ReadAllText(fullPath, Encoding.UTF8);
305323
var (chart, parseAlerts) = new MA2Parser().Parse(ma2Text);
306324
PrintAlerts(parseAlerts);
@@ -419,6 +437,83 @@ private static void ValidateOutputFileExtension(string filePath, string required
419437
throw new ArgumentException($"输出文件扩展名须为「{requiredExt}」,当前为「{(string.IsNullOrEmpty(ext) ? "(无)" : ext)}」。");
420438
}
421439

440+
private static void AssertStrictLaxOnlyForSimaiToMa2(string contextSuffix)
441+
{
442+
if (_simaiStrictLevel != SimaiParser.StrictLevelEnum.Normal)
443+
throw new ArgumentException($"--strict / --lax 仅适用于 Simai(.txt / maidata 或纯 inote)转 MA2,不能用于{contextSuffix}。");
444+
}
445+
446+
private static readonly Dictionary<string, string[]> chuTargetsDict = new()
447+
{
448+
["c2s"] = ["ugc", "sus"],
449+
["ugc"] = ["c2s", "sus"],
450+
["sus"] = ["c2s"],
451+
};
452+
453+
private static void ValidateOutputForSingleChuText(string inputFormat, string targetFormat)
454+
{
455+
var validTargets = chuTargetsDict.GetValueOrDefault(inputFormat) ?? [];
456+
if (!validTargets.Contains(targetFormat)) throw new ArgumentException($"不支持的输出类型「{targetFormat}」。输入文件为{inputFormat}时,输出格式仅支持{validTargets}。");
457+
458+
if (_outputSpec.Kind == OutputSinkKind.Stdout) return;
459+
if (_outputSpec.Kind == OutputSinkKind.File)
460+
ValidateOutputFileExtension(_outputSpec.FsPath!, "." + targetFormat);
461+
}
462+
463+
private static void RunConvertChuSingleFile(string filePath, string inputKind)
464+
{
465+
var targetFormat = _cliTargetNormalized ?? chuTargetsDict[inputKind][0];
466+
ValidateOutputForSingleChuText(inputKind, targetFormat);
467+
468+
var full = Path.GetFullPath(filePath);
469+
var inputDir = Path.GetDirectoryName(full)!;
470+
var text = File.ReadAllText(full, Encoding.UTF8);
471+
472+
var baseDir = _outputSpec.ResolveOutputDir(inputDir);
473+
var outPath = _outputSpec.Kind == OutputSinkKind.File ? _outputSpec.FsPath! : Path.Combine(baseDir, Path.GetFileNameWithoutExtension(full) + "." + targetFormat);
474+
var destNote = _outputSpec.Kind == OutputSinkKind.Stdout ? "(标准输出)" : outPath;
475+
Console.Error.WriteLine($"{inputKind.ToUpperInvariant()}{targetFormat.ToUpperInvariant()}: {full}{destNote}");
476+
477+
ChuChart chart;
478+
List<Alert> parseAlerts;
479+
switch (inputKind)
480+
{
481+
case "c2s":
482+
(chart, parseAlerts) = new C2sParser().Parse(text);
483+
break;
484+
case "ugc":
485+
(chart, parseAlerts) = new UgcParser().Parse(text);
486+
break;
487+
case "sus":
488+
(chart, parseAlerts) = new SusParser().Parse(text);
489+
break;
490+
default:
491+
throw new ArgumentException($"内部错误:未知中二输入种类「{inputKind}」。");
492+
}
493+
PrintAlerts(parseAlerts);
494+
495+
string outText;
496+
List<Alert> genAlerts;
497+
switch (targetFormat)
498+
{
499+
case "ugc":
500+
(outText, genAlerts) = new UgcGenerator().Generate(chart);
501+
break;
502+
case "sus":
503+
(outText, genAlerts) = new SusGenerator().Generate(chart);
504+
break;
505+
case "c2s":
506+
(outText, genAlerts) = new C2sGenerator().Generate(chart);
507+
break;
508+
default:
509+
throw new ArgumentException($"内部错误:未实现的中二输出类型「{targetFormat}」。");
510+
}
511+
PrintAlerts(genAlerts);
512+
513+
if (_outputSpec.Kind == OutputSinkKind.Stdout) Console.Out.Write(outText);
514+
else File.WriteAllText(outPath, outText, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
515+
}
516+
422517
private static string SimaiToMa2(string inote, int clockCount = 4, bool bigTouch = false, bool isUtage = false,
423518
SimaiParser.StrictLevelEnum strictLevel = SimaiParser.StrictLevelEnum.Normal)
424519
{

0 commit comments

Comments
 (0)