Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
54e5c27
feat: 新增 C2S/UGC/SUS 谱面格式支持
Applesaber May 1, 2026
e2488df
fix: 修复数字解析区域依赖和 tick 缩放溢出问题
Applesaber May 1, 2026
e4619d1
fix: 修复 review bot 发现的 9 个问题
Applesaber May 1, 2026
cd61fd7
fix: 修复 cubic 第2轮审查的 5 个问题
Applesaber May 1, 2026
91a0455
[F&R] 修复若干问题
Starrah May 1, 2026
828dbe6
fix: UgcGenerator.UCode 补充 HXD/SXD/SXC/SLC 映射
Applesaber May 1, 2026
5abf8ae
[F&O] 修复一些小问题,补充测试等
Starrah May 1, 2026
ec9d78d
[F] 修复UgcParser,未能正确实现对AIR的解析,在多字符 TargetNote的AIR时会解析错误的问题
Starrah May 1, 2026
41bc684
[F] 优化HexToInt
Starrah May 1, 2026
7f19976
Merge remote-tracking branch 'origin/master'
Starrah May 1, 2026
f1ec9ee
fix: C2sParser.ParseNote ALD/ASD Cell/Width 错误赋值
Applesaber May 1, 2026
8265abc
[+&O] CLI支持新增的中二转谱;同时优化提示文本,避免太罗嗦
Starrah May 1, 2026
4041dc3
[+] CLI for 中二
Starrah May 1, 2026
1cde69b
fix: UgcParser 兼容大写类型前缀和 >c 跟随行
Applesaber May 2, 2026
ca6db1e
fix: UgcParser 兼容独立跟随行和 @USETIL 指令
Applesaber May 2, 2026
b483eb0
[F] 为 ParseHoldNote 实现与 ParseSlideNote 相同的多行跟随消费逻辑(循环读取合法 #…>s/#…>c,跳…
Starrah May 2, 2026
05b8945
[R&doc]优化CLI和README
Starrah May 2, 2026
d404a68
[R] 优化中文等语言下的报错行号提示文本(删掉一个空格)
Starrah May 2, 2026
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
171 changes: 133 additions & 38 deletions Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.CommandLine;
using System.Text;
using System.Text.RegularExpressions;
using MuConvert.chu;
using MuConvert.mai;
using MuConvert.utils;

Expand Down Expand Up @@ -40,51 +41,51 @@ 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<string?>("--levels", "-l")
{
Description = "仅转换指定难度(以maidata中的&inote_编号为准),多个难度用逗号分隔;省略则转换全部难度。",
Description = "仅转换指定难度,多个难度用逗号分隔;省略则转换全部难度。",
HelpName = "N[,N...]"
};

var targetOption = new Option<string?>("--target", "-t")
{
Description = "强制指定输出格式。目前仅有C2S->SUS必须指定本参数,其他情况省略使用默认值即可。",
HelpName = "format"
};

var outputOption = new Option<string?>("--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<bool>("--strict")
{
Description = "Simai转MA2时,解析使用严格模式。不可与 --lax 同时使用。",
Description = "解析使用严格模式(仅在Simai转MA2模式下有效)",
Arity = ArgumentArity.ZeroOrOne,
DefaultValueFactory = _ => false
};

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

var inputArgument = new Argument<string>("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
};

root.Options.Add(levelsOption);
root.Options.Add(targetOption);
root.Options.Add(outputOption);
root.Options.Add(strictOption);
root.Options.Add(laxOption);
Expand All @@ -95,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);
Expand All @@ -112,6 +115,9 @@ private static Command BuildRootCommand()
/// <summary>由 CLI 在每次 <c>SetAction</c> 入口赋值;转换逻辑只读此字段。</summary>
private static OutputSpec _outputSpec;
private static SimaiParser.StrictLevelEnum _simaiStrictLevel = SimaiParser.StrictLevelEnum.Normal;

/// <summary>由 CLI 赋值;为 null 表示按输入类型使用默认输出格式,否则为小写的目标格式名(如 sus、ma2)。</summary>
private static string? _cliTargetNormalized;

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

Expand Down Expand Up @@ -149,6 +155,8 @@ private static void RunConvert(string inputPath, string? levelsRaw)
else
throw new ArgumentException($"找不到路径: {inputPath}");
}

private static readonly string[] supportedPostfixs = new[] { "maidata.txt", ".ma2", ".c2s", ".ugc", ".sus" };

private static void RunConvertDirectory(string dir, string? levelsRaw)
{
Expand All @@ -158,28 +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 hasMaidata = maidataPaths.Length > 0;
var hasMa2 = ma2Paths.Length > 0;

if (hasMaidata && hasMa2)
throw new ArgumentException("目录中同时存在 maidata.txt 与 .ma2,请只保留其中一种输入。");
if (!hasMaidata && !hasMa2)
throw new ArgumentException("目录中未找到 maidata.txt 或 .ma2 文件。");

if (hasMaidata)
if (inputPaths.Length > 1)
{
if (maidataPaths.Length > 1)
throw new ArgumentException("目录中存在多个 maidata.txt,请只保留一个。");
RunConvertTxtFile(maidataPaths[0], levelsRaw);
return;
if (inputPaths.All(file=>file.EndsWith(".ma2")))
{ // 只有多个MA2这种情况是允许的,直接调用ConvertMa2PathsToMaidata
var title = new DirectoryInfo(dir).Name;
ConvertMa2PathsToMaidata(dir, title, inputPaths, levelsRaw);
}
else
{
throw new ArgumentException($"目录中存在多种/多个谱面文件:{string.Join(", ", inputPaths)}。请直接指定到具体的文件路径,或者删除多余的文件。");
}
}

var title = new DirectoryInfo(dir).Name;
ConvertMa2PathsToMaidata(dir, title, ma2Paths, levelsRaw);
else RunConvertFile(inputPaths[0], levelsRaw);
}

private static void RunConvertFile(string filePath, string? levelsRaw)
Expand All @@ -199,7 +201,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)
Expand All @@ -209,6 +222,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);
Expand Down Expand Up @@ -278,8 +294,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);
Expand All @@ -300,7 +318,7 @@ private static void ConvertMa2PathsToMaidata(string outputDir, string title, IRe

foreach (var (fullPath, levelId) in assignments)
{
Console.Error.WriteLine($"SimaiMA2: {fullPath}(lv{levelId}) → {destNote}");
Console.Error.WriteLine($"MA2Simai: {fullPath}(lv{levelId}) → {destNote}");
var ma2Text = File.ReadAllText(fullPath, Encoding.UTF8);
var (chart, parseAlerts) = new MA2Parser().Parse(ma2Text);
PrintAlerts(parseAlerts);
Expand Down Expand Up @@ -419,6 +437,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<string, string[]> 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<Alert> 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<Alert> 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)
{
Expand Down
Loading