Skip to content

Commit f651ce0

Browse files
committed
[+&R] Program.cs CLI 支持MA2->Simai
1 parent 189212d commit f651ce0

4 files changed

Lines changed: 179 additions & 31 deletions

File tree

Program.cs

Lines changed: 165 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
using System.CommandLine;
22
using System.Text;
3+
using System.Text.RegularExpressions;
34
using MuConvert.generator;
45
using MuConvert.maidata;
5-
using MuConvert.parser.simai;
6+
using MuConvert.parser;
67
using MuConvert.utils;
78

89
namespace MuConvert;
@@ -33,19 +34,22 @@ private static Command BuildRootCommand()
3334
{
3435
var root = new RootCommand
3536
{
36-
Description = $"MuConvert {Utils.AppVersion} — simai / maidata → MA2\n" +
37-
"将 .txt 格式的 simai 单谱或 maidata 转为 MA2,输出与输入同目录的 lv_N.ma2。"
37+
Description = $"MuConvert {Utils.AppVersion} — 新一代Simai ↔ MA2互转转谱器\n"
3838
};
3939

4040
var levelsOption = new Option<string?>("--levels", "-l")
4141
{
42-
Description = "仅转换指定难度(maidata 的 inote 编号),逗号分隔;省略则全部。纯 simai 单谱不可使用本选项。",
42+
Description = "仅转换指定难度(以maidata中的&inote_编号为准),多个难度用逗号分隔;省略则转换全部难度。",
4343
HelpName = "N[,N...]"
4444
};
4545

46-
var inputArgument = new Argument<string>("inputfile")
46+
var inputArgument = new Argument<string>("path")
4747
{
48-
Description = "输入 .txt(单谱 simai 或 maidata)",
48+
Description = "可以输入以下几种情况:\n" +
49+
"1.单个.txt文件(标准maidata.txt,或是不含maidata的头信息、直接是Simai的Notes的文件,都可以)。请通过-l指定要转换的谱面难度,不指定则默认转换全部难度。\n" +
50+
"2.单个.ma2文件。会把它转为Simai,输出maidata.txt。如果想要转换多个难度,请传入目录,详见第4条。\n" +
51+
"3.一个包含有maidata.txt的目录。行为同第一条。\n" +
52+
"4.一个包含有一个或多个.ma2文件的目录。会把它们转为一个maidata.txt。请通过-l指定要转换的谱面难度,不指定则默认转换全部难度。",
4953
Arity = ArgumentArity.ExactlyOne
5054
};
5155

@@ -55,7 +59,7 @@ private static Command BuildRootCommand()
5559
root.SetAction(parseResult =>
5660
{
5761
var inputPath = parseResult.GetValue(inputArgument)
58-
?? throw new InvalidOperationException("缺少参数 inputfile。");
62+
?? throw new InvalidOperationException("缺少参数 path。");
5963
var levelsRaw = parseResult.GetValue(levelsOption);
6064
RunConvert(inputPath, levelsRaw);
6165
});
@@ -65,27 +69,162 @@ private static Command BuildRootCommand()
6569

6670
private static void RunConvert(string inputPath, string? levelsRaw)
6771
{
68-
var levelFilter = string.IsNullOrWhiteSpace(levelsRaw)
69-
? null
70-
: ParseLevelList(levelsRaw);
72+
var fullPath = Path.GetFullPath(inputPath.Trim());
7173

72-
var ext = Path.GetExtension(inputPath);
74+
if (Directory.Exists(fullPath))
75+
RunConvertDirectory(fullPath, levelsRaw);
76+
else if (File.Exists(fullPath))
77+
RunConvertFile(fullPath, levelsRaw);
78+
else
79+
throw new ArgumentException($"找不到路径: {inputPath}");
80+
}
81+
82+
private static void RunConvertDirectory(string dir, string? levelsRaw)
83+
{
84+
var enumOpts = new EnumerationOptions
85+
{
86+
IgnoreInaccessible = true,
87+
MatchCasing = MatchCasing.CaseInsensitive,
88+
RecurseSubdirectories = false
89+
};
90+
91+
var maidataPaths = Directory.GetFiles(dir, "maidata.txt", enumOpts);
92+
var ma2Paths = Directory.GetFiles(dir, "*.ma2", enumOpts);
93+
94+
var hasMaidata = maidataPaths.Length > 0;
95+
var hasMa2 = ma2Paths.Length > 0;
96+
97+
if (hasMaidata && hasMa2)
98+
throw new ArgumentException("目录中同时存在 maidata.txt 与 .ma2,请只保留其中一种输入。");
99+
if (!hasMaidata && !hasMa2)
100+
throw new ArgumentException("目录中未找到 maidata.txt 或 .ma2 文件。");
101+
102+
if (hasMaidata)
103+
{
104+
if (maidataPaths.Length > 1)
105+
throw new ArgumentException("目录中存在多个 maidata.txt,请只保留一个。");
106+
RunConvertTxtFile(maidataPaths[0], levelsRaw);
107+
return;
108+
}
109+
110+
var title = new DirectoryInfo(dir).Name;
111+
ConvertMa2PathsToMaidata(dir, title, ma2Paths, levelsRaw);
112+
}
113+
114+
private static void RunConvertFile(string filePath, string? levelsRaw)
115+
{
116+
var ext = Path.GetExtension(filePath);
73117
if (string.Equals(ext, ".ma2", StringComparison.OrdinalIgnoreCase))
74-
throw new NotImplementedException("从 .ma2 输入的转换尚未实现。");
118+
{
119+
var parent = Path.GetDirectoryName(Path.GetFullPath(filePath))!;
120+
var title = new DirectoryInfo(parent).Name;
121+
ConvertMa2PathsToMaidata(parent, title, [filePath], levelsRaw);
122+
return;
123+
}
124+
125+
if (string.Equals(ext, ".txt", StringComparison.OrdinalIgnoreCase))
126+
{
127+
RunConvertTxtFile(filePath, levelsRaw);
128+
return;
129+
}
75130

76-
if (!string.Equals(ext, ".txt", StringComparison.OrdinalIgnoreCase))
77-
throw new ArgumentException($"不支持的输入扩展名「{ext}」。目前仅支持 .txt(simai / maidata)。");
131+
throw new ArgumentException($"不支持的输入扩展名「{ext}」。支持 .txt、.ma2,或目录。");
132+
}
78133

79-
if (!File.Exists(inputPath))
80-
throw new ArgumentException($"找不到文件: {inputPath}");
134+
private static void RunConvertTxtFile(string inputPath, string? levelsRaw)
135+
{
136+
var levelFilter = string.IsNullOrWhiteSpace(levelsRaw) ? null : ParseLevelList(levelsRaw);
81137

82138
var inputDir = Path.GetDirectoryName(Path.GetFullPath(inputPath))!;
83139
var text = File.ReadAllText(inputPath, Encoding.UTF8);
84140

85141
if (LooksLikeMaidata(text))
86-
ConvertMaidata(text, inputDir, levelFilter);
142+
ConvertMaidata(text, inputDir, levelFilter, inputPath);
87143
else
88-
ConvertPlainSimai(text, inputDir, levelFilter);
144+
ConvertPlainSimai(text, inputDir, levelFilter, inputPath);
145+
}
146+
147+
/// <summary>
148+
/// 与测试集约定一致:<c>*XX.ma2</c> 中 XX 为游戏难度后缀时,maidata inote = XX + 2;<c>lv_N.ma2</c> 为本工具导出,inote = N。
149+
/// </summary>
150+
private static bool TryParseMaidataLevelFromMa2FileName(string filePath, out int levelId)
151+
{
152+
var stem = Path.GetFileNameWithoutExtension(filePath);
153+
154+
if (stem.StartsWith("lv_", StringComparison.OrdinalIgnoreCase) && stem.Length > 3 &&
155+
int.TryParse(stem.AsSpan(3), out var lv) && lv > 0)
156+
{
157+
levelId = lv;
158+
return true;
159+
}
160+
161+
var m = Regex.Match(stem, @"(\d{2})$");
162+
if (m.Success && int.TryParse(m.Groups[1].Value, out var suffix))
163+
{
164+
levelId = suffix + 2;
165+
return true;
166+
}
167+
168+
levelId = 5;
169+
return false;
170+
}
171+
172+
private static List<(string FullPath, int LevelId)> AssignMaidataLevelsForMa2Files(string[] ma2Paths)
173+
{
174+
Array.Sort(ma2Paths, StringComparer.OrdinalIgnoreCase);
175+
var used = new HashSet<int>();
176+
var list = new List<(string, int)>(ma2Paths.Length);
177+
178+
foreach (var path in ma2Paths)
179+
{
180+
var suggested = TryParseMaidataLevelFromMa2FileName(path, out var parsed) ? parsed : 5;
181+
182+
var id = suggested;
183+
while (used.Contains(id))
184+
id++;
185+
186+
used.Add(id);
187+
list.Add((path, id));
188+
}
189+
190+
return list;
191+
}
192+
193+
private static void ConvertMa2PathsToMaidata(string outputDir, string title, IReadOnlyList<string> ma2FullPaths, string? levelsRaw)
194+
{
195+
if (ma2FullPaths.Count == 0)
196+
throw new ArgumentException("未提供任何 .ma2 文件。");
197+
198+
var paths = ma2FullPaths.Select(Path.GetFullPath).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
199+
var levelFilter = string.IsNullOrWhiteSpace(levelsRaw) ? null : ParseLevelList(levelsRaw);
200+
var assignments = AssignMaidataLevelsForMa2Files(paths)
201+
.OrderBy(t => t.LevelId)
202+
.Where((_, lv)=> levelFilter == null || levelFilter.Contains(lv))
203+
.ToList();
204+
var outPath = Path.Combine(outputDir, "maidata.txt");
205+
206+
int clockCount = 4;
207+
var inoteBlocks = new List<(int LevelId, string Inote)>();
208+
209+
foreach (var (fullPath, levelId) in assignments)
210+
{
211+
Console.WriteLine($"Simai → MA2: {fullPath}(lv{levelId}) → {outPath}");
212+
var ma2Text = File.ReadAllText(fullPath, Encoding.UTF8);
213+
var (chart, parseAlerts) = new MA2Parser().Parse(ma2Text);
214+
PrintAlerts(parseAlerts);
215+
var (simai, genAlerts) = new SimaiGenerator().Generate(chart);
216+
PrintAlerts(genAlerts);
217+
inoteBlocks.Add((levelId, simai));
218+
clockCount = chart.ClockCount;
219+
}
220+
221+
var maidata = new Maidata();
222+
maidata["title"] = title;
223+
maidata["first"] = "0";
224+
maidata["clock_count"] = clockCount.ToString();
225+
foreach (var (levelId, inote) in inoteBlocks)
226+
maidata.AddLevel(levelId, new MaidataChart(inote));
227+
File.WriteAllText(outPath, maidata.ToString(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
89228
}
90229

91230
private static HashSet<int> ParseLevelList(string s)
@@ -133,7 +272,7 @@ private static void PrintAlerts(IReadOnlyList<Alert> alerts, string? header = nu
133272
Console.Error.WriteLine(a.ToString());
134273
}
135274

136-
private static void ConvertMaidata(string text, string outputDir, HashSet<int>? levelFilter)
275+
private static void ConvertMaidata(string text, string outputDir, HashSet<int>? levelFilter, string inputPath)
137276
{
138277
var maidata = new Maidata(text);
139278
var ids = maidata.Levels.Keys.OrderBy(k => k).ToList();
@@ -149,34 +288,34 @@ private static void ConvertMaidata(string text, string outputDir, HashSet<int>?
149288

150289
foreach (var id in selected)
151290
{
291+
var outPath = Path.Combine(outputDir, $"lv_{id}.ma2");
292+
Console.WriteLine($"Simai → MA2: {inputPath}(lv${id}) → {outPath}");
152293
var chartInfo = maidata.Levels[id];
153294
var bigTouch = id is 2 or 3;
154295
var isUtage = IsUtageFromLevelString(chartInfo.Level);
155296
var ma2 = SimaiToMa2(chartInfo.Inote, maidata.ClockCount, bigTouch, isUtage);
156-
var outPath = Path.Combine(outputDir, $"lv_{id}.ma2");
157297
File.WriteAllText(outPath, ma2, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
158298
}
159299
}
160300

161-
private static void ConvertPlainSimai(string text, string outputDir, HashSet<int>? levelFilter)
301+
private static void ConvertPlainSimai(string text, string outputDir, HashSet<int>? levelFilter, string inputPath)
162302
{
163303
if (levelFilter != null)
164304
throw new ArgumentException("纯 simai 单谱(非 maidata)不能使用 -l / --levels。");
165305

166306
const int outputLevel = 0;
167-
var ma2 = SimaiToMa2(text);
168307
var outPath = Path.Combine(outputDir, $"lv_{outputLevel}.ma2");
308+
Console.WriteLine($"Simai → MA2: {inputPath}(lv${outputLevel}) → {outPath}");
309+
var ma2 = SimaiToMa2(text);
169310
File.WriteAllText(outPath, ma2, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
170311
}
171312

172313
private static string SimaiToMa2(string inote, int clockCount=4, bool bigTouch=false, bool isUtage=false)
173314
{
174315
var (chart, parseAlerts) = new SimaiParser(bigTouch, isUtage, clockCount).Parse(inote);
316+
PrintAlerts(parseAlerts);
175317
var (ma2, genAlerts) = new MA2Generator().Generate(chart);
176-
var combined = new List<Alert>(parseAlerts.Count + genAlerts.Count);
177-
combined.AddRange(parseAlerts);
178-
combined.AddRange(genAlerts);
179-
PrintAlerts(combined);
318+
PrintAlerts(genAlerts);
180319
return ma2;
181320
}
182321
}

maidata/Maidata.cs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
namespace MuConvert.maidata;
55

6-
public record MaidataChart(string? Level, string? NoteDesigner, string Inote);
6+
public record MaidataChart(string Inote, string? Level = null, string? NoteDesigner = null);
77

88
public class Maidata : Dictionary<string, string>
99
{
@@ -19,6 +19,15 @@ public class Maidata : Dictionary<string, string>
1919
* 而由于Maidata类继承自Dictionary,如果用一般的Dict的方法遍历/访问Maidata对象的话,拿到的是整个maidata的、包含inote等在内的所有信息。
2020
*/
2121
public Dictionary<string, string> Infos => _splitLevels().Item2;
22+
23+
public void AddLevel(int levelId, MaidataChart maidataChart)
24+
{
25+
this[$"inote_{levelId}"] = maidataChart.Inote;
26+
if (maidataChart.Level != null) this[$"lv_{levelId}"] = maidataChart.Level;
27+
if (maidataChart.NoteDesigner != null) this[$"des_{levelId}"] = maidataChart.NoteDesigner;
28+
}
29+
30+
public Maidata() {}
2231

2332
/**
2433
* 将maidata.txt的文本传给此函数,即可构造Maidata对象。
@@ -70,7 +79,7 @@ private void _putKey(string? key, StringBuilder content)
7079
infos.Remove(k, out var v);
7180
infos.Remove($"lv_{id}", out var level);
7281
infos.Remove($"des_{id}", out var noteDesigner);
73-
levels.Add(id, new MaidataChart(level, noteDesigner, v!));
82+
levels.Add(id, new MaidataChart(v!, level, noteDesigner));
7483
}
7584
}
7685
return (levels, infos);
@@ -102,7 +111,7 @@ public override string ToString()
102111
}
103112

104113
var (levels, infos) = _splitLevels();
105-
foreach (var (k, v) in Infos)
114+
foreach (var (k, v) in infos)
106115
{
107116
if (fixedKeys.Contains(k)) continue; // 刚刚已经输出过了
108117
result.AppendLine($"&{k}={v}");

parser/simai/SimaiParser.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
using static MuConvert.utils.Alert.LEVEL;
88
using P = MuConvert.Antlr.SimaiParser;
99

10-
namespace MuConvert.parser.simai;
10+
namespace MuConvert.parser;
1111

1212
public class SimaiParser : SimaiBaseVisitor<object>, IParser
1313
{

tests/自制谱转MA2测试.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
using System.Text;
22
using MuConvert.generator;
33
using MuConvert.maidata;
4-
using MuConvert.parser.simai;
4+
using MuConvert.parser;
55
using MuConvert.utils;
66
using Xunit.Abstractions;
77
using static MuConvert.Tests.TestUtils;

0 commit comments

Comments
 (0)