Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 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
1cc5037
[R] ChuNote,重命名Extra为Tag
Starrah May 3, 2026
37c6b6d
Merge branch 'master' into dev
Starrah May 3, 2026
5f8bddb
[R] ChuNote的重新设计
Starrah May 3, 2026
0b2e921
[R] 适应 5f8bddb 中所做的修改,使能过编译
Starrah May 3, 2026
a0ff8b0
[R] Chart相关重构(初步)
Starrah May 3, 2026
84a39e5
[R] Chart相关重构(第二步)——使用BaseChart中提供的List对象
Starrah May 3, 2026
ba1bbcc
[F&R] 1. 现在的ugcgenerator缺少SflEvent的生成/写出。
Starrah May 3, 2026
09c5ead
[R] Chart相关重构(第三步)——移除各个谱面中的Resolution,而是parser解析存成分数、generator使用写死的固定值
Starrah May 3, 2026
d92a9e3
[R] Chart相关重构(最后一步)——三种Chart合并为统一的ChuChart!
Starrah May 3, 2026
54bc9f4
[F] 不要屏蔽本应出现的警告
Starrah May 3, 2026
fa9dbcb
[F] UGCParser对Slide的解析
Starrah May 6, 2026
9c13621
[+] 在ChuNote中新增Previous字段,新增BaseChuParser和FillAllPrevious通用工具方法。以实现sl…
Starrah May 7, 2026
52af0a3
[+] UGC 对Air Slide和Air Hold的解析
Starrah May 7, 2026
b54b7e9
[+] UgcGenerator Slide、Air Hold、Air Slide的正确实现
Starrah May 7, 2026
d580e85
[F] 修一些小问题
Starrah May 7, 2026
00178c1
[R] 把UGC常用的一些工具方法,提取到ChuUtils中
Starrah May 8, 2026
a994505
[+&R&F] ChuNote新增Height和CrushInterval;UgcParser和UgcGenerator,重构height…
Starrah May 9, 2026
028305c
[Test&F] 新增几个测试谱面,并修bug
Starrah May 9, 2026
317ea02
[F] 修复一些问题:
Starrah May 11, 2026
bcfbe78
[Test] 重构测试代码比较音符相等的整体逻辑;同时修复若干问题。
Starrah May 11, 2026
afd485b
[F] lineNum等一点小问题
Starrah May 11, 2026
a9a83a1
[+] UGC AirCrush
Starrah May 11, 2026
c9fe48d
[F] RSL不再是static的,解决多实例冲突问题
Starrah May 11, 2026
477a767
[F] decimal.Parse InvarientCulture
Starrah May 11, 2026
d0de6ad
[F] 生成谱面时,字符串插值确保使用InvarientCultrue
Starrah May 11, 2026
db62da3
[F] C2S的高度只准保留一位小数后,舍入误差引起测试爆炸
Starrah May 11, 2026
25f364b
[doc] Update README.md
Starrah May 11, 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}");

ChuChart 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