diff --git a/Program.cs b/Program.cs index b20bdf4..7cb11ee 100644 --- a/Program.cs +++ b/Program.cs @@ -1,6 +1,7 @@ using System.CommandLine; using System.Text; using System.Text.RegularExpressions; +using MuConvert.chu; using MuConvert.mai; using MuConvert.utils; @@ -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("--levels", "-l") { - Description = "仅转换指定难度(以maidata中的&inote_编号为准),多个难度用逗号分隔;省略则转换全部难度。", + Description = "仅转换指定难度,多个难度用逗号分隔;省略则转换全部难度。", HelpName = "N[,N...]" }; + var targetOption = new Option("--target", "-t") + { + Description = "强制指定输出格式。目前仅有C2S->SUS必须指定本参数,其他情况省略使用默认值即可。", + HelpName = "format" + }; + var outputOption = new Option("--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("--strict") { - Description = "Simai转MA2时,解析使用严格模式。不可与 --lax 同时使用。", + Description = "解析使用严格模式(仅在Simai转MA2模式下有效)", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = _ => false }; var laxOption = new Option("--lax") { - Description = "Simai转MA2时,解析使用宽松模式。不可与 --strict 同时使用。", + Description = "解析使用宽松模式(仅在Simai转MA2模式下有效)", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = _ => false }; var inputArgument = new Argument("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); @@ -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); @@ -112,6 +115,9 @@ private static Command BuildRootCommand() /// 由 CLI 在每次 SetAction 入口赋值;转换逻辑只读此字段。 private static OutputSpec _outputSpec; private static SimaiParser.StrictLevelEnum _simaiStrictLevel = SimaiParser.StrictLevelEnum.Normal; + + /// 由 CLI 赋值;为 null 表示按输入类型使用默认输出格式,否则为小写的目标格式名(如 sus、ma2)。 + private static string? _cliTargetNormalized; private enum OutputSinkKind { Default, Stdout, Directory, File } @@ -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) { @@ -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) @@ -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) @@ -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); @@ -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); @@ -300,7 +318,7 @@ private static void ConvertMa2PathsToMaidata(string outputDir, string title, IRe foreach (var (fullPath, levelId) in assignments) { - Console.Error.WriteLine($"Simai → MA2: {fullPath}(lv{levelId}) → {destNote}"); + Console.Error.WriteLine($"MA2 → Simai: {fullPath}(lv{levelId}) → {destNote}"); var ma2Text = File.ReadAllText(fullPath, Encoding.UTF8); var (chart, parseAlerts) = new MA2Parser().Parse(ma2Text); PrintAlerts(parseAlerts); @@ -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 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 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 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) { diff --git a/README.md b/README.md index ab7e078..76dfb53 100644 --- a/README.md +++ b/README.md @@ -29,11 +29,15 @@ MuConvert 是一个支持**Simai与MA2互转**的转谱器。 #### 基本用法 ```shell -MuConvert.exe [-l|--levels N[,N...]] [-o|--output <输出路径或->] [--strict|--lax] +MuConvert.exe [-l|--levels N[,N...]] [-t|--target ] [-o|--output <输出路径或->] [--strict|--lax] ``` -- **`path`**:输入路径(必填),可以是 `.txt` / `.ma2` / 目录(见下文) +- **`path`**:输入路径(必填),可以是单个文件或目录,输入目录时会自动找到和处理目录下的谱面文件(详见下文)。 - **`-l, --levels`**:仅转换指定难度(以 `maidata.txt` 的 `&inote_编号` 为准),多个难度用英文逗号分隔;省略则转换全部难度 +- **`-t, --target`**:强制指定输出格式(不区分大小写)。 + - 多数情况下不需要指定,直接使用默认值即可。默认值根据输入类型的不同而不同,但一般来说能满足常见的场景需求。 + - 具体而言,默认的转换输出格式为:Simai → `ma2`,MA2 → `simai`,C2S → `ugc`,UGC/SUS → `c2s`。 + - 目前仅有一种情况是必须指定该参数的:即想要C2S转SUS的情况,必须指定`-t sus`(否则默认转出来的是UGC) - **`-o, --output`**:指定输出位置(可选);不传入此参数时,文件将保存到“输入文件所在的目录”。 - 会智能识别你传入的是目录还是文件,做智能的处理,将转谱结果输入到目录下或保存为文件。 - 此外,还可以传入 `-` ,表示输出到stdout。 @@ -44,19 +48,24 @@ MuConvert.exe [-l|--levels N[,N...]] [-o|--output <输出路径或->] [-- #### `path` 支持的输入形式与输出规则 通过命令行传入的参数,既可以是文件,也可以是目录。 -- **输入 `.txt`(`maidata.txt` 或“纯 simai 单谱”)**:把Simai转为MA2。 - - **如果是 `maidata.txt`(含 `&inote_`)**:会在输入文件的相同目录下,产生 `lv_{id}.ma2`(每个难度一个文件)。 - - 可用类似 `-l 5,6` 的选项,只导出部分难度 - - **如果是纯 simai Notes(不含 maidata 头信息)**:会在输入文件的相同目录下,产生 `lv_0.ma2`。 - -- **输入 `.ma2` 文件**:把MA2转为Simai。 - - 输出:会在输入文件的相同目录下,产生 `maidata.txt`(当然,里面只有您传入的MA2所对应的一个难度)。 - - 如果想把多张不同难度的 `.ma2` 合并进一个 `maidata.txt`,请直接传入目录(见下一条)。 - -- **输入目录**:智能识别 - - **目录中包含 `maidata.txt`**:等价于输入该 `maidata.txt` - - **目录中包含一个或多个 `.ma2`**:将它们合并转为同目录的 `maidata.txt` - - 若目录中 **同时存在** `maidata.txt` 与 `.ma2`,或两者都不存在,会报错 +- **输入单个 maimai 相关格式文件**(`.txt` / `.ma2`)时: + - **输入 `.txt`**:把Simai转为MA2。 + - **如果是 `maidata.txt`(含 `&inote_`)**:会在输入文件的相同目录下,产生 `lv_{id}.ma2`(每个难度一个文件)。 + - 可用类似 `-l 5,6` 的选项,只导出部分难度。 + - **如果是纯 simai Notes(不含 maidata 头信息)**:会在输入文件的相同目录下,产生 `lv_0.ma2`。 + - **输入 `.ma2` 文件**:把MA2转为Simai。 + - 输出:会在输入文件的相同目录下,产生 `maidata.txt`(当然,里面只有您传入的MA2所对应的一个难度)。 + - 如果想把多张不同难度的 `.ma2` 合并进一个 `maidata.txt`,请直接传入目录(见下一条)。 + +- **输入单个 CHUNITHM 相关格式文件**(`.c2s` / `.ugc` / `.sus`)时:在 C2S、UGC、SUS之间互转。 + - 不指定 `-t` 时,默认:`.c2s` → 同目录下同名 `.ugc`;`.ugc` 或 `.sus` → 同目录下同名 `.c2s`。 + - 如果想从 C2S 转出 SUS ,则须显式指定 `-t sus`。 + +- **输入目录**时:会尝试在该目录下寻找谱面文件: + - 如果找到恰好一个:则等价于上面的输入单个文件的情况、处理这一个文件。 + - 如果找到多个: + - 如果都是MA2文件,会把这多张不同难度的 `.ma2`谱面转为simai,并合并进同一个 `maidata.txt`。 + - 否则,则是输入不明确的情况,会报错退出。 #### 示例 - **Simai(maidata)→ MA2(按难度导出)** @@ -75,6 +84,31 @@ MuConvert "D:\charts\MyChart" -l 5,6 # 只转紫谱和白谱 # 生成的转谱结果位于D:\charts\MyChart\maidata.txt ``` +
+CHUNITHM转谱相关示例 + +**UGC/SUS → C2S**(默认输出同名 `.c2s`) + +```shell +MuConvert "D:\charts\Song\0003_00.ugc" # UGC -> C2S +MuConvert "D:\charts\Song\0003_00.sus" # SUS -> C2S +# 转谱结果与输入同目录,生成 0003_00.c2s + +MuConvert "D:\charts\Song\0003_00.ugc" -t sus # 也可 UGC直接 -> C2S +``` + +**C2S → UGC / SUS** + +```shell +MuConvert "D:\charts\Song\0003_00.c2s" +# 默认同目录生成同名 .ugc + +MuConvert "D:\charts\Song\0003_00.c2s" -t sus +# 需要 SUS 时须显式指定 -t sus(否则默认为 UGC) +``` + +
+ ### 2) 将本项目作为依赖库使用 #### 导入依赖库 - **推荐做法**:把本仓库作为 git submodule 引入你的工程仓库,然后把 `MuConvert.csproj` 加入你的 `.sln`/`.slnx`。 @@ -84,7 +118,7 @@ git submodule add https://github.com/MuNET-OSS/MuConvert MuConvert # 将本项 dotnet sln .\YourSolution.sln add .\MuConvert\MuConvert.csproj # 将项目加入解决方案 ``` -#### 使用方法(TLDR): +#### maimai转谱 - 使用方法(TLDR): > 以下 C# 示例中的 `Maidata`、`MaiChart`、`SimaiParser`、`MA2Parser`、`SimaiGenerator`、`MA2Generator` 等均位于命名空间 `MuConvert.mai`中,使用时需添加 `using MuConvert.mai;`。 **Simai → MA2**: @@ -118,6 +152,24 @@ var maidataText = maidata.ToString(); // 通过ToString方法将Maidata对象序 return maidataText; // maidataText即为转谱结果 ``` +#### CHUNITHM转谱 - 使用方法(TLDR): +> 以下 C# 示例中的各种Parser、Generator等,均位于命名空间 `MuConvert.chu`中,使用时需添加 `using MuConvert.chu;`。 + +```csharp +// 首先使用File.ReadAllText等方法,将谱面整体读取为字符串 +var (c2sChart, alerts) = new C2sParser().Parse(c2sText); // 解析 C2S 谱面字符串 +var (ugcChart, alerts) = new UgcParser().Parse(ugcText); // 解析 UGC 谱面字符串 +var (susChart, alerts) = new SusParser().Parse(susText); // 解析 SUS 谱面字符串 +// 以上得到的c2sChart、ugcChart、susChart,都是IChuChart类型的谱面表示对象; +// alerts是解析过程中可能产生的警告信息等,建议打印出来。 + +var (c2sText, alerts) = new C2sGenerator().Generate(ugcChart); // UGC -> C2S +var (ugcText, alerts) = new UgcGenerator().Generate(c2sChart); // C2S -> UGC +var (susText, alerts) = new SusGenerator().Generate(c2sChart); // C2S -> SUS +// 各种Generator的Generate方法,均接受任意的IChuChart对象。 +// 同上,alerts是生成过程中可能产生的警告信息等,建议打印出来。 +``` + #### parser和generator的选项 - 部分parser和generator,在其构造参数中带有可选的选项参数,可以控制转谱时的一些行为。 - SimaiParser带有以下选项: @@ -163,15 +215,26 @@ finally - **parser(解析器)**:把“源格式文本”解析成中间表示 - `SimaiParser.Parse(string)` → `MaiChart` - `MA2Parser.Parse(string)` → `MaiChart` - - 返回值同时带有 `List`;如果遇到致命错误会抛出 `ConversionException` + - CHUNITHM的三种Parser(`C2sParser`、`UgcParser`、`SusParser`):`Parse(string)` → `IChuChart` + >
+ > 关于IChuChart + > 当前实现IChuChart是一个通用的接口而非具体的类型,这是因为目前不同Parser解析出的谱面的IR尚未能够完全统一,所以只能都各自继承自IChuChart。 + > 不过不用担心,任意的Generator都接受任意IChuChart对象,因此你可以不在意它们之间的差异,直接拿来用就行了。 + > 未来如果有机会的话,我们会把它们进一步统一成同一个具体类型的IR,以进一步提升代码的可维护性和可读性。 + >
+ - 解析成功时,**返回值会同时带有 `List`**,这是转谱过程中可能遇到的警告等信息,建议打印出来(直接对`Alert`对象`ToString()`即可)。 + - 如果解析失败,会抛出 `ConversionException`;该异常对象中同样含有一个 `List`,是导致转谱失败的错误信息,可以同上打印出来。 - **中间表示 IR(Chart)**:MuConvert 内部统一的谱面数据结构 - 对maimai,类型为 `MuConvert.mai.MaiChart` - 关键字段包括 `Chart.BpmList` 与 `Chart.Notes`,以及 `Touch/Hold/Slide` 等具体 `Note` 子类 - **generator(生成器)**:把中间表示转回“目标格式文本” - - `SimaiGenerator.Generate(MaiChart)` → simai 文本(可写入 `maidata.txt` 的 `&inote_*`) - - `MA2Generator.Generate(MaiChart)` → `.ma2` 文本 + - `SimaiGenerator.Generate(MaiChart)` → Simai 单谱文本(可写入 `maidata.txt` 的 `&inote_*`) + - `MA2Generator.Generate(MaiChart)` → MA2 文本 + - CHUNITHM的三种Generator(`C2sGenerator`、`UgcGenerator`、`SusGenerator`):`Generate(IChuChart)` → 目标格式的谱面文本 + - 与parser类似,成功生成时,**返回值会同时带有 `List`**,这是转谱过程中可能遇到的警告等信息,建议打印出来(直接对`Alert`对象`ToString()`即可)。 + - 如果生成失败,会抛出 `ConversionException`;该异常对象中同样含有一个 `List`,是导致转谱失败的错误信息,可以同上打印出来。 ## 开发者指南 diff --git a/chart/chu/C2sChart.cs b/chart/chu/C2sChart.cs new file mode 100644 index 0000000..7418c5c --- /dev/null +++ b/chart/chu/C2sChart.cs @@ -0,0 +1,24 @@ +using MuConvert.chart; + +namespace MuConvert.chu; + +/** + * C2S 格式谱面 IR(官方格式,RESOLUTION=384 tick/小节)。 + */ +public class C2sChart : BaseChart, IChuChart +{ + public string Version { get; set; } = "1.08.00\t1.08.00"; + public int MusicId { get; set; } + public int DifficultId { get; set; } + public string Creator { get; set; } = ""; + public int Resolution { get; set; } = 384; + public double DefBpm { get; set; } = 120.0; + public List<(int Measure, int Offset, double Bpm)> BpmEvents = []; + public List<(int Measure, int Offset, int Denom, int Num)> MetEvents = []; + public List<(int Measure, int Offset, int Duration, double Multiplier)> SflEvents = []; + + public override decimal StartBpm => (decimal)(BpmEvents.Count > 0 ? BpmEvents[0].Bpm : DefBpm); + public override decimal StartTime => Notes.Count > 0 ? Notes.Min(n => n.Measure * Resolution + n.Offset) / (decimal)Resolution * 240m / StartBpm : 0; + public override decimal EndTime => Notes.Count > 0 ? Notes.Max(n => n.Measure * Resolution + n.Offset + Math.Max(n.HoldDuration, Math.Max(n.SlideDuration, n.AirHoldDuration))) / (decimal)Resolution * 240m / StartBpm : 0; + public override int TotalNotes => Notes.Count; +} diff --git a/chart/chu/ChuNote.cs b/chart/chu/ChuNote.cs new file mode 100644 index 0000000..61130b8 --- /dev/null +++ b/chart/chu/ChuNote.cs @@ -0,0 +1,38 @@ +namespace MuConvert.chu; + +/** + * CHUNITHM 通用音符,C2S / UGC / SUS 共用此结构。 + */ +public class ChuNote +{ + /** 音符类型 (TAP, CHR, HLD, SLD, AIR, AHD 等) */ + public string Type { get; set; } = "TAP"; + /** 小节号 */ + public int Measure { get; set; } + /** 小节内偏移 (C2S: 0–383, UGC/SUS: 0–1919) */ + public int Offset { get; set; } + /** 起始列 (0–15) */ + public int Cell { get; set; } + /** 宽度 (1–16) */ + public int Width { get; set; } = 1; + /** HLD 持续时长 */ + public int HoldDuration { get; set; } + /** SLD 持续时长 */ + public int SlideDuration { get; set; } + /** SLD 终点列 */ + public int EndCell { get; set; } + /** SLD 终点宽度 */ + public int EndWidth { get; set; } = 1; + /** CHR/FLK 附加数据(方向等) */ + public string Extra { get; set; } = ""; + /** AIR/AHD 关联的目标音符类型 */ + public string TargetNote { get; set; } = ""; + /** AHD 持续时长 */ + public int AirHoldDuration { get; set; } + /** Air Crush 起始高度 */ + public int StartHeight { get; set; } + /** Air Crush 目标高度 */ + public int TargetHeight { get; set; } + /** Air Crush 颜色 */ + public string NoteColor { get; set; } = ""; +} diff --git a/chart/chu/IChuChart.cs b/chart/chu/IChuChart.cs new file mode 100644 index 0000000..d632bfd --- /dev/null +++ b/chart/chu/IChuChart.cs @@ -0,0 +1,8 @@ +using MuConvert.chart; + +namespace MuConvert.chu; + +/** + * CHUNITHM 所有谱面格式的统一接口,作为 Generator 的输入类型。 + */ +public interface IChuChart : IBaseChart; diff --git a/chart/chu/SusChart.cs b/chart/chu/SusChart.cs new file mode 100644 index 0000000..5e2a442 --- /dev/null +++ b/chart/chu/SusChart.cs @@ -0,0 +1,20 @@ +using MuConvert.chart; + +namespace MuConvert.chu; + +/** + * SUS 格式谱面 IR(REQUEST=480 tick/拍,lane 0–31)。 + */ +public class SusChart : BaseChart, IChuChart +{ + public string Title { get; set; } = ""; + public string Artist { get; set; } = ""; + public string Designer { get; set; } = ""; + public int TicksPerBeat { get; set; } = 480; + public double Bpm { get; set; } = 120.0; + + public override decimal StartBpm => (decimal)Bpm; + public override decimal StartTime => Notes.Count > 0 ? Notes.Min(n => n.Measure * TicksPerBeat * 4 + n.Offset) / (decimal)(TicksPerBeat * 4) * 240m / StartBpm : 0; + public override decimal EndTime => Notes.Count > 0 && StartBpm > 0 ? Notes.Max(n => n.Measure * TicksPerBeat * 4 + n.Offset + Math.Max(n.HoldDuration, Math.Max(n.SlideDuration, n.AirHoldDuration))) / (decimal)(TicksPerBeat * 4) * 240m / StartBpm : 0; + public override int TotalNotes => Notes.Count; +} diff --git a/chart/chu/UgcChart.cs b/chart/chu/UgcChart.cs new file mode 100644 index 0000000..b0059ca --- /dev/null +++ b/chart/chu/UgcChart.cs @@ -0,0 +1,27 @@ +using MuConvert.chart; + +namespace MuConvert.chu; + +/** + * UGC 格式谱面 IR(UMIGURI 格式,@TICKS=480 tick/拍)。 + */ +public class UgcChart : BaseChart, IChuChart +{ + public string Version { get; set; } = "6"; + public string Title { get; set; } = ""; + public string Artist { get; set; } = ""; + public string Designer { get; set; } = ""; + public string Difficulty { get; set; } = ""; + public int Level { get; set; } + public double Constant { get; set; } + public string SongId { get; set; } = ""; + public int TicksPerBeat { get; set; } = 480; + public List<(int Measure, int Num, int Den)> BeatEvents = []; + public List<(int Measure, int Offset, double Bpm)> BpmEvents = []; + public List<(int Measure, int Offset, double Multiplier)> SpeedEvents = []; + + public override decimal StartBpm => (decimal)(BpmEvents.Count > 0 ? BpmEvents[0].Bpm : 120.0); + public override decimal StartTime => Notes.Count > 0 ? Notes.Min(n => n.Measure * TicksPerBeat * 4 + n.Offset) / (decimal)(TicksPerBeat * 4) * 240m / StartBpm : 0; + public override decimal EndTime => Notes.Count > 0 && StartBpm > 0 ? Notes.Max(n => n.Measure * TicksPerBeat * 4 + n.Offset + Math.Max(n.HoldDuration, Math.Max(n.SlideDuration, n.AirHoldDuration))) / (decimal)(TicksPerBeat * 4) * 240m / StartBpm : 0; + public override int TotalNotes => Notes.Count; +} diff --git a/generator/chu/C2sGenerator.cs b/generator/chu/C2sGenerator.cs new file mode 100644 index 0000000..2a9b79f --- /dev/null +++ b/generator/chu/C2sGenerator.cs @@ -0,0 +1,125 @@ +using System.Globalization; +using System.Text; +using MuConvert.chart; +using MuConvert.generator; +using MuConvert.utils; +using static MuConvert.utils.Alert.LEVEL; + +namespace MuConvert.chu; + +/** + * C2S 格式生成器。 + * 输入 IChuChart,内部自动转换后输出 C2S 文本。 + */ +public class C2sGenerator : IGenerator +{ + private const int C2sResolution = 384; + + public (string, List) Generate(IChuChart chart) + { + var alerts = new List(); + var c2s = ConvertToC2s(chart, alerts); + var text = Serialize(c2s); + return (text, alerts); + } + + private static C2sChart ConvertToC2s(IChuChart chart, List alerts) + { + if (chart is C2sChart c2s) return c2s; + + if (chart is UgcChart ugc) + { + var result = new C2sChart + { + Version = "1.08.00\t1.08.00", + Creator = ugc.Designer, + DefBpm = ugc.BpmEvents.Count > 0 ? ugc.BpmEvents[0].Bpm : 120.0, + }; + foreach (var b in ugc.BpmEvents) + result.BpmEvents.Add((b.Measure, ScaleDown(b.Offset, ugc.TicksPerBeat), b.Bpm)); + foreach (var b in ugc.BeatEvents) + result.MetEvents.Add((b.Measure, 0, b.Den, b.Num)); + foreach (var n in ugc.Notes) + result.Notes.Add(ScaleNote(n, ugc.TicksPerBeat)); + return result; + } + + if (chart is SusChart sus) + { + var result = new C2sChart { DefBpm = sus.Bpm }; + result.BpmEvents.Add((0, 0, sus.Bpm)); + foreach (var n in sus.Notes) + result.Notes.Add(ScaleNote(n, sus.TicksPerBeat)); + return result; + } + + alerts.Add(new Alert(Error, string.Format(Locale.ChuGeneratorUnsupported, "→ C2S"))); + throw new ConversionException(alerts); + } + + private static ChuNote ScaleNote(ChuNote n, int tpb) + { + int scaleDown(int v) => (int)((long)v * (C2sResolution / 4) / tpb); + return new ChuNote + { + Type = n.Type, Measure = n.Measure, Offset = scaleDown(n.Offset), + Cell = n.Cell, Width = n.Width, + HoldDuration = scaleDown(n.HoldDuration), SlideDuration = scaleDown(n.SlideDuration), + EndCell = n.EndCell, EndWidth = n.EndWidth, + Extra = n.Extra, TargetNote = n.TargetNote, AirHoldDuration = scaleDown(n.AirHoldDuration), + StartHeight = n.StartHeight, TargetHeight = n.TargetHeight, NoteColor = n.NoteColor, + }; + } + + private static int ScaleDown(int ticks, int tpb) => (int)((long)ticks * (C2sResolution / 4) / tpb); + + private static string Serialize(C2sChart chart) + { + var sb = new StringBuilder(); + sb.AppendLine($"VERSION\t{chart.Version}"); + sb.AppendLine($"MUSIC\t{chart.MusicId}"); + sb.AppendLine("SEQUENCEID\t0"); + sb.AppendLine($"DIFFICULT\t{chart.DifficultId:D2}"); + sb.AppendLine("LEVEL\t0.0"); + sb.AppendLine($"CREATOR\t{chart.Creator}"); + sb.AppendLine($"BPM_DEF\t{Fmt(chart.DefBpm)}\t{Fmt(chart.DefBpm)}\t{Fmt(chart.DefBpm)}\t{Fmt(chart.DefBpm)}"); + sb.AppendLine("MET_DEF\t4\t4"); + sb.AppendLine($"RESOLUTION\t{chart.Resolution}"); + sb.AppendLine($"CLK_DEF\t{chart.Resolution}"); + sb.AppendLine("PROGJUDGE_BPM\t240.000"); + sb.AppendLine("PROGJUDGE_AER\t0.999"); + sb.AppendLine("TUTORIAL\t0"); + sb.AppendLine(); + + foreach (var b in chart.BpmEvents) + sb.AppendLine($"BPM\t{b.Measure}\t{b.Offset}\t{Fmt(b.Bpm)}"); + foreach (var m in chart.MetEvents) + sb.AppendLine($"MET\t{m.Measure}\t{m.Offset}\t{m.Denom}\t{m.Num}"); + foreach (var s in chart.SflEvents) + sb.AppendLine($"SFL\t{s.Measure}\t{s.Offset}\t{s.Duration}\t{Mlt(s.Multiplier)}"); + sb.AppendLine(); + + foreach (var n in chart.Notes.OrderBy(n => n.Measure * C2sResolution + n.Offset)) + sb.AppendLine(FormatNote(n)); + + sb.AppendLine(); + return sb.ToString(); + } + + private static string FormatNote(ChuNote n) => n.Type switch + { + "TAP" => $"TAP\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}", + "CHR" => $"CHR\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.Extra}", + "HLD" or "HXD" => $"{n.Type}\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.HoldDuration}", + "SLD" or "SLC" or "SXD" or "SXC" => $"{n.Type}\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.SlideDuration}\t{n.EndCell}\t{n.EndWidth}", + "FLK" => $"FLK\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.Extra}", + "AIR" or "AUR" or "AUL" or "ADW" or "ADR" or "ADL" => $"{n.Type}\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.TargetNote}", + "AHD" => $"AHD\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.TargetNote}\t{n.AirHoldDuration}", + "ALD" or "ASD" => $"{n.Type}\t{n.Measure}\t{n.Offset}\t{n.StartHeight}\t{n.SlideDuration}\t{n.EndCell}\t{n.EndWidth}\t{n.TargetHeight}\t{n.NoteColor}", + "MNE" => $"MNE\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}", + _ => $"TAP\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}" + }; + + private static string Fmt(double v) => v.ToString("0.000", CultureInfo.InvariantCulture); + private static string Mlt(double v) => v.ToString("0.000000", CultureInfo.InvariantCulture); +} diff --git a/generator/chu/SusGenerator.cs b/generator/chu/SusGenerator.cs new file mode 100644 index 0000000..6142d88 --- /dev/null +++ b/generator/chu/SusGenerator.cs @@ -0,0 +1,105 @@ +using System.Text; +using MuConvert.chart; +using MuConvert.generator; +using MuConvert.utils; +using static MuConvert.utils.Alert.LEVEL; + +namespace MuConvert.chu; + +/** + * SUS 格式生成器。 + * 输入 IChuChart,内部自动转换后输出 SUS 文本。 + */ +public class SusGenerator : IGenerator +{ + private const int SusTpb = 480; + + public (string, List) Generate(IChuChart chart) + { + var alerts = new List(); + var sus = ConvertToSus(chart, alerts); + var text = Serialize(sus); + return (text, alerts); + } + + private static SusChart ConvertToSus(IChuChart chart, List alerts) + { + if (chart is SusChart sus) return sus; + + double bpm = 120.0; + string title = "", artist = ""; + + if (chart is C2sChart c2s) + { + bpm = c2s.BpmEvents.Count > 0 ? c2s.BpmEvents[0].Bpm : c2s.DefBpm; + int c2sTpb = c2s.Resolution / 4; + var result = new SusChart { Bpm = bpm, TicksPerBeat = SusTpb, Title = title, Artist = artist }; + foreach (var n in c2s.Notes) result.Notes.Add(ScaleUp(n, c2sTpb)); + return result; + } + + if (chart is UgcChart ugc) + { + bpm = ugc.BpmEvents.Count > 0 ? ugc.BpmEvents[0].Bpm : 120.0; + var result = new SusChart { Bpm = bpm, TicksPerBeat = SusTpb, Title = ugc.Title, Artist = ugc.Artist }; + foreach (var n in ugc.Notes) result.Notes.Add(ScaleUp(n, ugc.TicksPerBeat)); + return result; + } + + alerts.Add(new Alert(Error, string.Format(Locale.ChuGeneratorUnsupported, "→ SUS"))); + throw new ConversionException(alerts); + } + + private static ChuNote ScaleUp(ChuNote n, int sourceTicksPerBeat) + { + int s(int v) => (int)((long)v * SusTpb / sourceTicksPerBeat); + return new ChuNote + { + Type = n.Type, Measure = n.Measure, Offset = s(n.Offset), + Cell = n.Cell * 2, Width = n.Width * 2, + HoldDuration = s(n.HoldDuration), SlideDuration = s(n.SlideDuration), + EndCell = n.EndCell * 2, EndWidth = n.EndWidth * 2, + Extra = n.Extra, TargetNote = n.TargetNote, AirHoldDuration = s(n.AirHoldDuration), + }; + } + + private static string Serialize(SusChart sus) + { + var sb = new StringBuilder(); + if (!string.IsNullOrEmpty(sus.Title)) sb.AppendLine($"#TITLE \"{sus.Title}\""); + if (!string.IsNullOrEmpty(sus.Artist)) sb.AppendLine($"#ARTIST \"{sus.Artist}\""); + if (!string.IsNullOrEmpty(sus.Designer)) sb.AppendLine($"#DESIGNER \"{sus.Designer}\""); + sb.AppendLine($"#BPM_DEF {sus.Bpm:F2}"); + sb.AppendLine($"#REQUEST \"{sus.TicksPerBeat}\""); + sb.AppendLine(); + + foreach (var n in sus.Notes.OrderBy(n => n.Measure).ThenBy(n => n.Offset)) + sb.AppendLine($"#{n.Measure:X2}{n.Offset:X3}:{FormatData(n)}"); + + return sb.ToString(); + } + + private static string FormatData(ChuNote n) + { + string lw = $"{n.Cell:X2}{n.Width:X2}"; + string tc = TypeCode(n.Type); + string dur = $"{(n.HoldDuration > 0 ? n.HoldDuration : n.SlideDuration > 0 ? n.SlideDuration : n.AirHoldDuration):X4}"; + return tc switch + { + "01" or "02" or "03" or "10" => $"{tc}{lw}", + "05" or "08" => $"{tc}{lw}{dur}", + "06" => $"{tc}{lw}{dur}{n.EndCell:X2}{n.EndWidth:X2}", + "07" or "09" => $"{tc}{lw}{n.TargetNote}", + _ => $"01{lw}" + }; + } + + private static string TypeCode(string t) => t switch + { + "TAP" => "01", "CHR" => "02", "FLK" => "03", + "HLD" => "05", "SLD" => "06", "SLC" => "06", + "AIR" => "07", "AUR" => "07", "AUL" => "07", + "AHD" => "08", "ADW" => "09", "ADR" => "09", "ADL" => "09", + "MNE" => "10", _ => "01" + }; +} diff --git a/generator/chu/UgcGenerator.cs b/generator/chu/UgcGenerator.cs new file mode 100644 index 0000000..f73d9ed --- /dev/null +++ b/generator/chu/UgcGenerator.cs @@ -0,0 +1,134 @@ +using System.Text; +using MuConvert.chart; +using MuConvert.generator; +using MuConvert.utils; +using static MuConvert.utils.Alert.LEVEL; + +namespace MuConvert.chu; + +/** + * UGC 格式生成器。 + * 输入 IChuChart,内部自动转换后输出 UGC 文本。 + */ +public class UgcGenerator : IGenerator +{ + private const int UgcTicksPerBeat = 480; + private const int C2sResolution = 384; + + public (string, List) Generate(IChuChart chart) + { + var alerts = new List(); + var ugc = ConvertToUgc(chart, alerts); + var text = Serialize(ugc); + return (text, alerts); + } + + private static UgcChart ConvertToUgc(IChuChart chart, List alerts) + { + if (chart is UgcChart ugc) return ugc; + + if (chart is C2sChart c2s) + { + var result = new UgcChart + { + TicksPerBeat = UgcTicksPerBeat, + Designer = c2s.Creator, + Difficulty = MapDiffId(c2s.DifficultId), + SongId = c2s.MusicId.ToString(), + }; + foreach (var b in c2s.BpmEvents) + result.BpmEvents.Add((b.Measure, ScaleUp(b.Offset), b.Bpm)); + foreach (var m in c2s.MetEvents) + result.BeatEvents.Add((m.Measure, m.Num, m.Denom)); + foreach (var n in c2s.Notes) + result.Notes.Add(ScaleUpNote(n)); + return result; + } + + alerts.Add(new Alert(Error, string.Format(Locale.ChuGeneratorUnsupported, "→ UGC"))); + throw new ConversionException(alerts); + } + + private static ChuNote ScaleUpNote(ChuNote n) + { + int s(int v) => (int)((long)v * UgcTicksPerBeat / (C2sResolution / 4)); + return new ChuNote + { + Type = n.Type, Measure = n.Measure, Offset = s(n.Offset), + Cell = n.Cell, Width = n.Width, + HoldDuration = s(n.HoldDuration), SlideDuration = s(n.SlideDuration), + EndCell = n.EndCell, EndWidth = n.EndWidth, + Extra = n.Extra, TargetNote = IsAir(n.Type) && string.IsNullOrEmpty(n.TargetNote) ? "N" : n.TargetNote, + AirHoldDuration = s(n.AirHoldDuration), + StartHeight = n.StartHeight, TargetHeight = n.TargetHeight, NoteColor = n.NoteColor, + }; + } + + private static int ScaleUp(int v) => (int)((long)v * UgcTicksPerBeat / (C2sResolution / 4)); + + private static bool IsAir(string t) => t is "AIR" or "AUR" or "AUL" or "AHD" or "ADW" or "ADR" or "ADL"; + + private static string MapDiffId(int id) => id switch + { + 0 => "BASIC", 1 => "ADVANCED", 2 => "EXPERT", 3 => "MASTER", 4 => "ULTIMA", _ => "0" + }; + + private static string Serialize(UgcChart ugc) + { + var sb = new StringBuilder(); + sb.AppendLine("@VER\t6"); + if (!string.IsNullOrEmpty(ugc.Title)) sb.AppendLine($"@TITLE\t{ugc.Title}"); + if (!string.IsNullOrEmpty(ugc.Artist)) sb.AppendLine($"@ARTIST\t{ugc.Artist}"); + if (!string.IsNullOrEmpty(ugc.Designer)) sb.AppendLine($"@DESIGN\t{ugc.Designer}"); + sb.AppendLine($"@DIFF\t{DiffId(ugc.Difficulty)}"); + sb.AppendLine($"@LEVEL\t{ugc.Level}"); + sb.AppendLine($"@CONST\t{ugc.Constant:F5}"); + sb.AppendLine($"@SONGID\t{ugc.SongId}"); + sb.AppendLine($"@TICKS\t{ugc.TicksPerBeat}"); + foreach (var b in ugc.BeatEvents) sb.AppendLine($"@BEAT\t{b.Measure}\t{b.Num}\t{b.Den}"); + foreach (var b in ugc.BpmEvents) sb.AppendLine($"@BPM\t{b.Measure}'{b.Offset}\t{b.Bpm:F5}"); + sb.AppendLine("@TIL\t0\t0'0\t1.00000"); + sb.AppendLine("@MAINTIL\t0"); + sb.AppendLine("@ENDHEAD"); + sb.AppendLine(); + + var notes = ugc.Notes.OrderBy(n => n.Measure).ThenBy(n => n.Offset).ToList(); + foreach (var n in notes) + { + sb.Append($"#{n.Measure}'{n.Offset}:{UCode(n)}"); + sb.AppendLine(); + if (n.Type == "HLD" && n.HoldDuration > 0) + sb.AppendLine($"#{n.HoldDuration}>s"); + else if (n.Type == "SLD" && n.SlideDuration > 0) + sb.AppendLine($"#{n.SlideDuration}>s{Hx(n.EndCell)}{Hw(n.EndWidth)}"); + } + return sb.ToString(); + } + + private static string UCode(ChuNote n) + { + string c = Hx(n.Cell), w = Hw(n.Width); + return n.Type switch + { + "TAP" => $"t{c}{w}", + "CHR" => $"x{c}{w}{n.Extra}", + "HLD" or "HXD" => $"h{c}{w}", + "SLD" or "SXD" => $"s{c}{w}", + "SLC" or "SXC" => $"s{c}{w}", + "FLK" => $"f{c}{w}A", + "MNE" => $"d{c}{w}", + "AIR" => $"a{c}{w}UC{n.TargetNote}", + "AUR" => $"a{c}{w}UR{n.TargetNote}", + "AUL" => $"a{c}{w}UL{n.TargetNote}", + "AHD" => $"a{c}{w}HD{n.TargetNote}_{n.AirHoldDuration}", + "ADW" => $"a{c}{w}DC{n.TargetNote}", + "ADR" => $"a{c}{w}DR{n.TargetNote}", + "ADL" => $"a{c}{w}DL{n.TargetNote}", + _ => $"t{c}{w}" + }; + } + + private static string Hx(int v) => "0123456789ABCDEF"[Math.Clamp(v, 0, 15)].ToString(); + private static string Hw(int v) => "123456789ABCDEFG"[Math.Clamp(v - 1, 0, 15)].ToString(); + private static int DiffId(string d) => d switch { "BASIC" => 0, "ADVANCED" => 1, "EXPERT" => 2, "MASTER" => 3, "ULTIMA" => 4, _ => 0 }; +} diff --git a/i18n/Locale.Designer.cs b/i18n/Locale.Designer.cs index 8690ef7..d1bfaaf 100644 --- a/i18n/Locale.Designer.cs +++ b/i18n/Locale.Designer.cs @@ -104,6 +104,24 @@ public static string BreakHoldOrSlideIn103 { } } + /// + /// Looks up a localized string similar to Unknown C2S note type: {0}. + /// + public static string C2SUnknownNoteType { + get { + return ResourceManager.GetString("C2SUnknownNoteType", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cannot convert chart to target format: {0}. + /// + public static string ChuGeneratorUnsupported { + get { + return ResourceManager.GetString("ChuGeneratorUnsupported", resourceCulture); + } + } + /// /// Looks up a localized string similar to The chart contains features not supported by MA2 1.03 (multi-segment connecting slides). Only the first slide segment was kept.. /// @@ -330,7 +348,7 @@ public static string MA2NoteSentenceTooManyParam { } /// - /// Looks up a localized string similar to At. + /// Looks up a localized string similar to At . /// public static string MessageAt { get { diff --git a/i18n/Locale.ja.resx b/i18n/Locale.ja.resx index ab5bab3..a9f3c6f 100644 --- a/i18n/Locale.ja.resx +++ b/i18n/Locale.ja.resx @@ -1,5 +1,64 @@ + @@ -28,9 +87,9 @@ - - - + + + @@ -53,10 +112,10 @@ 2.0 - System.Resources.ResXResourceReader, System.Windows.Forms, Version=10.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=10.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 MuConvert で内部エラーが発生しました(AssertionFailed)。`https://github.com/MuNet-OSS/MuConvert/issues` に報告してください。({0}) @@ -211,4 +270,11 @@ 同じ時刻・同じ位置に別のスライド頭/タップが検出されました。ゲーム内で判定問題を引き起こすため、自動修復しました(余分なスライド頭を削除)。PS:同頭スライドを意図する場合は「1-2*-3」のように書き、「1-2/1-3」は避けてください(この問題を引き起こします)。 - + + + 不明なC2Sノートタイプ: {0} + + + チャートを変換できません: {0} + + \ No newline at end of file diff --git a/i18n/Locale.ko.resx b/i18n/Locale.ko.resx index edb8843..7517ef6 100644 --- a/i18n/Locale.ko.resx +++ b/i18n/Locale.ko.resx @@ -1,5 +1,64 @@ + @@ -28,9 +87,9 @@ - - - + + + @@ -53,10 +112,10 @@ 2.0 - System.Resources.ResXResourceReader, System.Windows.Forms, Version=10.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=10.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 MuConvert에서 내부 오류가 발생했습니다(AssertionFailed). `https://github.com/MuNet-OSS/MuConvert/issues` 에 제보해 주세요. ({0}) @@ -74,7 +133,7 @@ 잘못된 슬라이드 정의: {0} - 위치 + 위치 {0}번째 줄 @@ -211,4 +270,11 @@ 이 슬라이드 헤드와 동일한 시간/위치에 다른 슬라이드 헤드/탭이 감지되었습니다. 게임 내 판정 문제를 유발할 수 있어 자동으로 수정했습니다(중복 슬라이드 헤드 제거). PS: 같은 헤드의 슬라이드를 의도했다면 "1-2*-3" 같은 문법을 사용하세요. "1-2/1-3"는 이 문제를 유발합니다. - + + + 알 수 없는 C2S 노트 타입: {0} + + + 차트를 대상 형식으로 변환할 수 없습니다: {0} + + \ No newline at end of file diff --git a/i18n/Locale.resx b/i18n/Locale.resx index 6b292ce..102e6fa 100644 --- a/i18n/Locale.resx +++ b/i18n/Locale.resx @@ -74,7 +74,7 @@ Invalid slide definition: {0} - At + At line {0} @@ -211,4 +211,10 @@ Detected another Slide head/Tap at the same time and position as this Slide head. This would cause judgement issues in-game. Fixed automatically (removed the redundant Slide head(s)). PS: If you intend same-head Slides, use syntax like "1-2*-3" rather than "1-2/1-3"; the latter triggers this issue. + + Unknown C2S note type: {0} + + + Cannot convert chart to target format: {0} + diff --git a/i18n/Locale.zh-hant.resx b/i18n/Locale.zh-hant.resx index 095e0cd..beef74f 100644 --- a/i18n/Locale.zh-hant.resx +++ b/i18n/Locale.zh-hant.resx @@ -211,4 +211,10 @@ 檢測到在星星頭所在的時刻與位置,存在其他星星頭/Tap,這會造成遊戲內的絕對無理。已自動為您修復(移除多餘的星星頭)。PS:若您要編寫同頭星星,請使用類似「1-2*-3」而非「1-2/1-3」的寫法;後者會造成上述情況。 + + C2S 中存在無法識別的音符類型: {0} + + + 無法將譜面轉換為目標格式: {0} + diff --git a/i18n/Locale.zh.resx b/i18n/Locale.zh.resx index 47c8cd8..cbdbb27 100644 --- a/i18n/Locale.zh.resx +++ b/i18n/Locale.zh.resx @@ -211,4 +211,10 @@ 检测到在星星头所在的时刻和位置,存在着其他星星头/Tap,这会造成游戏内的绝对无理。已自动为您修复(去除多余的星星头)。PS:如果您要编写同头星星,请使用类似"1-2*-3"而非"1-2/1-3"的写法,后者就会造成上面的情况。 + + C2S 中存在无法识别的音符类型: {0} + + + 无法将谱面转换为目标格式: {0} + diff --git a/parser/chu/C2sParser.cs b/parser/chu/C2sParser.cs new file mode 100644 index 0000000..de903f5 --- /dev/null +++ b/parser/chu/C2sParser.cs @@ -0,0 +1,123 @@ +using System.Globalization; +using MuConvert.chart; +using MuConvert.parser; +using MuConvert.utils; +using static MuConvert.utils.Alert.LEVEL; + +namespace MuConvert.chu; + +/** + * C2S 格式解析器(官方格式,RESOLUTION=384 tick/小节)。 + * Tab 分隔文本,识别 HEADER / TIMING / NOTES 区段。 + */ +public class C2sParser : IParser +{ + private static readonly HashSet HeadTags = new(StringComparer.OrdinalIgnoreCase) + { "VERSION", "MUSIC", "SEQUENCEID", "DIFFICULT", "LEVEL", "CREATOR", "BPM_DEF", "MET_DEF", "RESOLUTION", "CLK_DEF", "PROGJUDGE_BPM", "PROGJUDGE_AER", "TUTORIAL" }; + private static readonly HashSet TimingTags = new(StringComparer.OrdinalIgnoreCase) + { "BPM", "MET", "SFL" }; + + public (C2sChart, List) Parse(string text) + { + var chart = new C2sChart(); + var alerts = new List(); + var lines = text.Replace("\r\n", "\n").Split('\n'); + bool inNotes = false; + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i]; + if (string.IsNullOrWhiteSpace(line)) continue; + if (line.StartsWith("T_")) continue; + + var parts = line.Split('\t'); + var tag = parts[0].ToUpperInvariant(); + + if (inNotes || !HeadTags.Contains(tag) && !TimingTags.Contains(tag)) + { + inNotes = true; + ParseNote(parts, chart, alerts, i + 1); + } + else if (HeadTags.Contains(tag)) + { + ParseHeader(parts, chart); + } + else if (TimingTags.Contains(tag)) + { + ParseTiming(parts, chart); + inNotes = false; + } + } + + return (chart, alerts); + } + + private static void ParseHeader(string[] p, C2sChart chart) + { + var tag = p[0].ToUpperInvariant(); + switch (tag) + { + case "VERSION": chart.Version = Str(p, 1); break; + case "MUSIC": chart.MusicId = Int(p, 1); break; + case "DIFFICULT": chart.DifficultId = Int(p, 1); break; + case "CREATOR": chart.Creator = Str(p, 1); break; + case "BPM_DEF": chart.DefBpm = Dbl(p, 1, 120.0); break; + case "RESOLUTION": chart.Resolution = Math.Max(1, Int(p, 1, 384)); break; + } + } + + private static void ParseTiming(string[] p, C2sChart chart) + { + var tag = p[0].ToUpperInvariant(); + switch (tag) + { + case "BPM": + chart.BpmEvents.Add((Int(p, 1), Int(p, 2), Dbl(p, 3, 120.0))); + break; + case "MET": + chart.MetEvents.Add((Int(p, 1), Int(p, 2), Int(p, 3, 4), Int(p, 4, 4))); + break; + case "SFL": + chart.SflEvents.Add((Int(p, 1), Int(p, 2), Int(p, 3), Dbl(p, 4, 1.0))); + break; + } + } + + private static void ParseNote(string[] p, C2sChart chart, List alerts, int lineNum) + { + var tag = p[0].ToUpperInvariant(); + var note = new ChuNote { Type = tag, Measure = Int(p, 1), Offset = Int(p, 2) }; + + switch (tag) + { + case "TAP": case "MNE": + note.Cell = Int(p, 3); note.Width = Math.Max(1, Int(p, 4, 1)); break; + case "CHR": + note.Cell = Int(p, 3); note.Width = Math.Max(1, Int(p, 4, 1)); note.Extra = Str(p, 5); break; + case "HLD": case "HXD": + note.Cell = Int(p, 3); note.Width = Math.Max(1, Int(p, 4, 1)); note.HoldDuration = Int(p, 5); break; + case "SLD": case "SLC": case "SXD": case "SXC": + note.Cell = Int(p, 3); note.Width = Math.Max(1, Int(p, 4, 1)); + note.SlideDuration = Int(p, 5); note.EndCell = Int(p, 6); note.EndWidth = Math.Max(1, Int(p, 7, 1)); break; + case "FLK": + note.Cell = Int(p, 3); note.Width = Math.Max(1, Int(p, 4, 1)); note.Extra = Str(p, 5); break; + case "AIR": case "AUR": case "AUL": case "ADW": case "ADR": case "ADL": + note.Cell = Int(p, 3); note.Width = Math.Max(1, Int(p, 4, 1)); note.TargetNote = Str(p, 5); break; + case "AHD": + note.Cell = Int(p, 3); note.Width = Math.Max(1, Int(p, 4, 1)); + note.TargetNote = Str(p, 5); note.AirHoldDuration = Int(p, 6); break; + case "ALD": case "ASD": + note.StartHeight = Int(p, 3); note.SlideDuration = Int(p, 4); + note.EndCell = Int(p, 5); note.EndWidth = Math.Max(1, Int(p, 6, 1)); + note.TargetHeight = Int(p, 7); note.NoteColor = Str(p, 8); break; + default: + alerts.Add(new Alert(Warning, string.Format(Locale.C2SUnknownNoteType, tag)) { Line = lineNum }); return; + } + + chart.Notes.Add(note); + } + + private static int Int(string[] p, int i, int def = 0) => i < p.Length && int.TryParse(p[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : def; + private static double Dbl(string[] p, int i, double def = 0) => i < p.Length && double.TryParse(p[i], NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : def; + private static string Str(string[] p, int i) => i < p.Length ? p[i] : ""; +} diff --git a/parser/chu/SusParser.cs b/parser/chu/SusParser.cs new file mode 100644 index 0000000..89d0033 --- /dev/null +++ b/parser/chu/SusParser.cs @@ -0,0 +1,246 @@ +using System.Globalization; +using MuConvert.chart; +using MuConvert.parser; +using MuConvert.utils; +using static MuConvert.utils.Alert.LEVEL; + +namespace MuConvert.chu; + +/** + * SUS 格式解析器(社区工具格式,REQUEST=480 tick/拍,lane 0–31)。 + * #MMTT:data 十六进制编码音符。 + */ +public class SusParser : IParser +{ + private static readonly Dictionary TypeMap = new() + { + [0x01] = "TAP", + [0x02] = "CHR", + [0x03] = "FLK", + [0x05] = "HLD", + [0x06] = "SLD", + [0x07] = "AIR", + [0x08] = "AHD", + [0x09] = "ADW", + [0x10] = "MNE", + }; + + public (SusChart, List) Parse(string text) + { + var chart = new SusChart(); + var alerts = new List(); + var lines = text.Replace("\r\n", "\n").Split('\n'); + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i]; + if (string.IsNullOrWhiteSpace(line)) continue; + + if (!line.StartsWith('#')) + { + alerts.Add(new Alert(Warning, $"意外的行(不以 # 开头): {line}") { Line = i + 1 }); + continue; + } + + var content = line[1..]; + + if (IsHeaderLine(content)) + { + ParseHeaderLine(content, chart, alerts, i + 1); + } + else + { + ParseNoteLine(content, chart, alerts, i + 1); + } + } + + return (chart, alerts); + } + + private static bool IsHeaderLine(string content) + { + return content.StartsWith("TITLE ") + || content.StartsWith("ARTIST ") + || content.StartsWith("DESIGNER ") + || content.StartsWith("BPM_DEF ") + || content.StartsWith("REQUEST "); + } + + private static void ParseHeaderLine(string content, SusChart chart, List alerts, int lineNum) + { + if (content.StartsWith("TITLE ")) + { + chart.Title = Unquote(content[6..]); + } + else if (content.StartsWith("ARTIST ")) + { + chart.Artist = Unquote(content[7..]); + } + else if (content.StartsWith("DESIGNER ")) + { + chart.Designer = Unquote(content[9..]); + } + else if (content.StartsWith("BPM_DEF ")) + { + var bpmStr = content[8..].Trim().Trim('"'); + if (double.TryParse(bpmStr, NumberStyles.Float, CultureInfo.InvariantCulture, out var bpm)) + chart.Bpm = bpm; + else + alerts.Add(new Alert(Warning, $"BPM_DEF 格式错误: {content}") { Line = lineNum }); + } + else if (content.StartsWith("REQUEST ")) + { + var reqStr = content[8..].Trim().Trim('"'); + if (int.TryParse(reqStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ticks)) + chart.TicksPerBeat = ticks; + else + alerts.Add(new Alert(Warning, $"REQUEST 格式错误: {content}") { Line = lineNum }); + } + } + + private static void ParseNoteLine(string content, SusChart chart, List alerts, int lineNum) + { + var colonIdx = content.IndexOf(':'); + if (colonIdx < 0) + { + alerts.Add(new Alert(Warning, $"音符行缺少冒号: {content}") { Line = lineNum }); + return; + } + + var timingStr = content[..colonIdx]; + var dataStr = content[(colonIdx + 1)..]; + + if (timingStr.Length < 5) + { + alerts.Add(new Alert(Warning, $"音符行时序部分过短: {content}") { Line = lineNum }); + return; + } + + var measure = HexToInt(timingStr[..2]); + var tick = HexToInt(timingStr[2..5]); + + if (dataStr.Length < 6) + { + alerts.Add(new Alert(Warning, $"音符行数据部分过短: {content}") { Line = lineNum }); + return; + } + + var typeCode = HexToInt(dataStr[..2]); + var lane = HexToInt(dataStr[2..4]); + var width = HexToInt(dataStr[4..6]); + + if (!TypeMap.TryGetValue(typeCode, out var typeName)) + { + alerts.Add(new Alert(Warning, $"未知的音符类型码 0x{typeCode:X2}: {content}") { Line = lineNum }); + return; + } + + var note = new ChuNote + { + Type = typeName, + Measure = measure, + Offset = tick, + Cell = lane / 2, + Width = Math.Max(1, width / 2), + }; + + switch (note.Type) + { + case "TAP": + case "CHR": + case "FLK": + case "MNE": + break; + + case "HLD": + ParseHoldData(dataStr, note, alerts, lineNum); + break; + + case "SLD": + ParseSlideData(dataStr, note, alerts, lineNum); + break; + + case "AIR": + case "ADW": + ParseAirTarget(dataStr, note, alerts, lineNum); + break; + + case "AHD": + ParseAhdData(dataStr, note, alerts, lineNum); + break; + } + + chart.Notes.Add(note); + } + + private static void ParseHoldData(string dataStr, ChuNote note, List alerts, int lineNum) + { + if (dataStr.Length >= 10) + { + note.HoldDuration = HexToInt(dataStr[6..10]); + } + else + { + alerts.Add(new Alert(Warning, $"HLD 音符缺少时长: {dataStr}") { Line = lineNum, RelevantNote = FormatNoteRef(note) }); + } + } + + private static void ParseSlideData(string dataStr, ChuNote note, List alerts, int lineNum) + { + if (dataStr.Length >= 10) + { + note.SlideDuration = HexToInt(dataStr[6..10]); + } + else + { + alerts.Add(new Alert(Warning, $"SLD 音符缺少时长: {dataStr}") { Line = lineNum, RelevantNote = FormatNoteRef(note) }); + return; + } + + if (dataStr.Length >= 14) + { + note.EndCell = HexToInt(dataStr[10..12]) / 2; + note.EndWidth = Math.Max(1, HexToInt(dataStr[12..14]) / 2); + } + } + + private static void ParseAirTarget(string dataStr, ChuNote note, List alerts, int lineNum) + { + if (dataStr.Length >= 8) + { + note.TargetNote = HexToInt(dataStr[6..8]).ToString(); + } + else + { + alerts.Add(new Alert(Warning, $"AIR/ADW 音符缺少目标: {dataStr}") { Line = lineNum, RelevantNote = FormatNoteRef(note) }); + } + } + + private static void ParseAhdData(string dataStr, ChuNote note, List alerts, int lineNum) + { + if (dataStr.Length >= 10) + { + note.AirHoldDuration = HexToInt(dataStr[6..10]); + } + else + { + alerts.Add(new Alert(Warning, $"AHD 音符缺少时长: {dataStr}") { Line = lineNum, RelevantNote = FormatNoteRef(note) }); + } + } + + private static int HexToInt(string hex) => + int.TryParse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var result) ? result : 0; + + private static string Unquote(string s) + { + var trimmed = s.Trim(); + if (trimmed.Length >= 2 && trimmed[0] == '"' && trimmed[^1] == '"') + return trimmed[1..^1]; + return trimmed; + } + + private static string FormatNoteRef(ChuNote note) + { + return $"#{note.Measure:X2}{note.Offset:X2}:{note.Type}"; + } +} diff --git a/parser/chu/UgcParser.cs b/parser/chu/UgcParser.cs new file mode 100644 index 0000000..a597761 --- /dev/null +++ b/parser/chu/UgcParser.cs @@ -0,0 +1,516 @@ +using System.Globalization; +using MuConvert.chart; +using MuConvert.parser; +using MuConvert.utils; +using static MuConvert.utils.Alert.LEVEL; + +namespace MuConvert.chu; + +/** + * UGC 格式解析器(UMIGURI 格式,@TICKS=480 tick/拍)。 + * @HEADER 标签 + #measure'tick:code 音符格式。 + */ +public class UgcParser : IParser +{ + private static readonly Dictionary AirDirections = new() + { + ["UC"] = "AIR", + ["UR"] = "AUR", + ["UL"] = "AUL", + ["DC"] = "ADW", + ["DR"] = "ADR", + ["DL"] = "ADL", + ["HD"] = "AHD", + }; + + private static readonly Dictionary ChrExtras = new() + { + ["U"] = "UP", + ["D"] = "DW", + ["C"] = "CE", + }; + + public (UgcChart, List) Parse(string text) + { + var chart = new UgcChart(); + var alerts = new List(); + var lines = text.Replace("\r\n", "\n").Split('\n'); + var inHeader = true; + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i]; + if (string.IsNullOrWhiteSpace(line)) continue; + + // UGC comment lines (starting with ') + if (line.StartsWith('\'')) continue; + + if (inHeader) + { + if (line == "@ENDHEAD") + { + inHeader = false; + continue; + } + ParseHeaderLine(line, chart, alerts, i + 1); + } + else + { + i = ParseNoteLine(lines, i, chart, alerts); + } + } + + return (chart, alerts); + } + + private static void ParseHeaderLine(string line, UgcChart chart, List alerts, int lineNum) + { + if (!line.StartsWith('@')) + { + alerts.Add(new Alert(Warning, $"意外的非头部行: {line}") { Line = lineNum }); + return; + } + + var spaceIdx = line.IndexOf('\t'); + var tag = spaceIdx > 0 ? line[..spaceIdx] : line; + var value = spaceIdx > 0 ? line[(spaceIdx + 1)..].Trim() : ""; + + switch (tag) + { + case "@VER": + chart.Version = value; + break; + + case "@TITLE": + chart.Title = value; + break; + + case "@ARTIST": + chart.Artist = value; + break; + + case "@DESIGN": + chart.Designer = value; + break; + + case "@DIFF": + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var diff)) + { + chart.Difficulty = diff switch + { + 0 => "BASIC", + 1 => "ADVANCED", + 2 => "EXPERT", + 3 => "MASTER", + 4 => "ULTIMA", + _ => value, + }; + } + else + { + chart.Difficulty = value; + } + break; + + case "@LEVEL": + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var level)) + chart.Level = level; + else + alerts.Add(new Alert(Warning, $"@LEVEL 格式错误: {line}") { Line = lineNum }); + break; + + case "@CONST": + if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var constant)) + chart.Constant = constant; + else + alerts.Add(new Alert(Warning, $"@CONST 格式错误: {line}") { Line = lineNum }); + break; + + case "@SONGID": + chart.SongId = value; + break; + + case "@TICKS": + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ticks)) + chart.TicksPerBeat = ticks; + else + alerts.Add(new Alert(Warning, $"@TICKS 格式错误: {line}") { Line = lineNum }); + break; + + case "@BEAT": + var beatParts = value.Split(['\t', ' '], StringSplitOptions.RemoveEmptyEntries); + if (beatParts.Length >= 3 + && int.TryParse(beatParts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var beatMeasure) + && int.TryParse(beatParts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var beatNum) + && int.TryParse(beatParts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var beatDen)) + { + chart.BeatEvents.Add((beatMeasure, beatNum, beatDen)); + } + else + { + alerts.Add(new Alert(Warning, $"@BEAT 格式错误: {line}") { Line = lineNum }); + } + break; + + case "@BPM": + var bpmPart = value; + var bpmSpaceIdx = bpmPart.IndexOfAny(['\t', ' ']); + if (bpmSpaceIdx > 0) + { + var measureOffset = bpmPart[..bpmSpaceIdx]; + var bpmValueStr = bpmPart[(bpmSpaceIdx + 1)..]; + var apostropheIdx = measureOffset.IndexOf('\''); + if (apostropheIdx > 0 + && int.TryParse(measureOffset[..apostropheIdx], NumberStyles.Integer, CultureInfo.InvariantCulture, out var bpmMeasure) + && int.TryParse(measureOffset[(apostropheIdx + 1)..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var bpmOffset) + && double.TryParse(bpmValueStr, NumberStyles.Float, CultureInfo.InvariantCulture, out var bpmValue)) + { + chart.BpmEvents.Add((bpmMeasure, bpmOffset, bpmValue)); + } + else + { + alerts.Add(new Alert(Warning, $"@BPM 格式错误: {line}") { Line = lineNum }); + } + } + else + { + alerts.Add(new Alert(Warning, $"@BPM 格式错误: {line}") { Line = lineNum }); + } + break; + + // silently ignored metadata tags + case "@EXVER": case "@SORT": case "@BGM": case "@BGMOFS": case "@BGMPRV": + case "@JACKET": case "@BGIMG": case "@BGMODE": case "@FLDCOL": case "@FLDIMG": + case "@FLAG": case "@ATINFO": case "@DLURL": case "@COPYRIGHT": case "@LICENSE": + case "@MAINTIL": + break; + + case "@TIL": case "@SPDMOD": + { + var parts = value.Split(['\t', ' '], StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2 && int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var tilMeasure) + && double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var tilMult)) + chart.SpeedEvents.Add((tilMeasure, 0, tilMult)); + } + break; + + default: + alerts.Add(new Alert(Info, $"未知头部标签: {tag}") { Line = lineNum }); + break; + } + } + + private static int ParseNoteLine(string[] lines, int idx, UgcChart chart, List alerts) + { + var line = lines[idx]; + var lineNum = idx + 1; + + // skip comment lines and inline directives + if (line.StartsWith('\'') || line.StartsWith('@')) + return idx; + + // standalone follower line: silently skip (will be attached by parent or ignored) + if (line.StartsWith('#') && !line.Contains(':') && (line.Contains(">s") || line.Contains(">c"))) + return idx; + + var colonIdx = line.IndexOf(':'); + if (colonIdx < 0) + { + alerts.Add(new Alert(Warning, $"无法解析的音符行: {line}") { Line = lineNum }); + return idx; + } + + var prefix = line[..colonIdx]; + var code = line[(colonIdx + 1)..]; + var hashIdx = prefix.IndexOf('#'); + var apostropheIdx = prefix.IndexOf('\''); + if (hashIdx < 0 || apostropheIdx < 0 || apostropheIdx <= hashIdx + 1) + { + alerts.Add(new Alert(Warning, $"音符行前缀格式错误: {line}") { Line = lineNum }); + return idx; + } + + if (!int.TryParse(prefix[(hashIdx + 1)..apostropheIdx], NumberStyles.Integer, CultureInfo.InvariantCulture, out var measure)) + { + alerts.Add(new Alert(Warning, $"无法解析 measure: {line}") { Line = lineNum }); + return idx; + } + if (!int.TryParse(prefix[(apostropheIdx + 1)..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var tick)) + { + alerts.Add(new Alert(Warning, $"无法解析 tick: {line}") { Line = lineNum }); + return idx; + } + + if (string.IsNullOrEmpty(code)) + { + alerts.Add(new Alert(Warning, $"音符行为空: {line}") { Line = lineNum }); + return idx; + } + + var note = new ChuNote + { + Measure = measure, + Offset = tick, + }; + + var typeChar = char.ToLowerInvariant(code[0]); + + switch (typeChar) + { + case 't': + ParseTapNote(code, note, alerts, lineNum); + break; + + case 'h': + idx = ParseHoldNote(lines, idx, code, note, alerts); + break; + + case 's': + idx = ParseSlideNote(lines, idx, code, note, alerts); + break; + + case 'a': + ParseAirNote(code, note, alerts, lineNum); + break; + + case 'x': + ParseChrNote(code, note, alerts, lineNum); + break; + + case 'f': + note.Type = "FLK"; + ParseCellWidth(code, 1, note, alerts, lineNum); + if (code.Length > 3) + note.Extra = code[3..]; + break; + + case 'c': + return idx; // Margrete Air Crush, silently skip + + case 'd': + note.Type = "MNE"; + ParseCellWidth(code, 1, note, alerts, lineNum); + break; + + default: + alerts.Add(new Alert(Warning, $"未知的音符类型前缀 '{typeChar}': {line}") { Line = lineNum }); + return idx; + } + + chart.Notes.Add(note); + return idx; + } + + private static void ParseTapNote(string code, ChuNote note, List alerts, int lineNum) + { + note.Type = "TAP"; + ParseCellWidth(code, 1, note, alerts, lineNum); + } + + private static int ParseHoldNote(string[] lines, int idx, string code, ChuNote note, List alerts) + { + note.Type = "HLD"; + ParseCellWidth(code, 1, note, alerts, idx + 1); + + bool foundFirst = false; + while (idx + 1 < lines.Length) + { + var nextLine = lines[idx + 1].Trim(); + if (!TryParseFollowerLine(nextLine, out var duration, out _, out _)) + { + if (nextLine.StartsWith('\'') || nextLine.StartsWith('@')) { idx++; continue; } + break; + } + + note.HoldDuration += duration; + idx++; + foundFirst = true; + } + + if (!foundFirst) + alerts.Add(new Alert(Warning, $"HLD 音符缺少时长跟随行") { Line = idx + 1, RelevantNote = FormatNoteRef(note) }); + return idx; + } + + private static int ParseSlideNote(string[] lines, int idx, string code, ChuNote note, List alerts) + { + note.Type = "SLD"; + ParseCellWidth(code, 1, note, alerts, idx + 1); + + bool foundFirst = false; + while (idx + 1 < lines.Length) + { + var nextLine = lines[idx + 1].Trim(); + if (!TryParseFollowerLine(nextLine, out var duration, out var endCell, out var endWidth)) + { + if (nextLine.StartsWith('\'') || nextLine.StartsWith('@')) { idx++; continue; } + break; + } + + note.SlideDuration += duration; + note.EndCell = endCell; + note.EndWidth = endWidth; + idx++; + foundFirst = true; + } + + if (!foundFirst) + alerts.Add(new Alert(Warning, $"SLD 音符缺少时长跟随行") { Line = idx + 1, RelevantNote = FormatNoteRef(note) }); + + return idx; + } + + private static bool TryParseStandaloneFollower(string[] lines, int idx, UgcChart chart, List alerts) + { + var line = lines[idx]; + if (!line.StartsWith('#') || !line.Contains(">s") && !line.Contains(">c")) return false; + + if (!TryParseFollowerLine(line, out var duration, out var endCell, out var endWidth)) return false; + + // find the last SLD or HLD note and attach duration + for (int i = chart.Notes.Count - 1; i >= 0; i--) + { + var n = chart.Notes[i]; + if (n.Type is "SLD" or "HLD") + { + if (n.Type == "SLD") { n.SlideDuration = duration; n.EndCell = endCell; n.EndWidth = endWidth; } + else { n.HoldDuration = duration; } + return true; + } + } + return false; + } + + private static bool TryParseFollowerLine(string line, out int duration, out int endCell, out int endWidth, bool requireEndCellWidth = false) + { + duration = 0; + endCell = 0; + endWidth = 1; + + if (!line.StartsWith('#')) return false; + + // support both >s (SLD) and >c (SLC) follower lines + int gtIdx = -1; + int markerLen = 0; + if (line.Contains(">s")) { gtIdx = line.IndexOf(">s"); markerLen = 2; } + else if (line.Contains(">c")) { gtIdx = line.IndexOf(">c"); markerLen = 2; } + if (gtIdx < 1) return false; + + var durationStr = line[1..gtIdx]; + if (!int.TryParse(durationStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out duration)) return false; + + var afterMarker = line[(gtIdx + markerLen)..]; + if (afterMarker.Length >= 2) + { + endCell = HexCharToInt(afterMarker[0]); + endWidth = WidthHexCharToInt(afterMarker[1]); + } + else if (requireEndCellWidth) return false; + + return true; + } + + private static void ParseCellWidth(string code, int startIdx, ChuNote note, List alerts, int lineNum) + { + if (code.Length > startIdx) + { + note.Cell = HexCharToInt(code[startIdx]); + if (code.Length > startIdx + 1) + note.Width = WidthHexCharToInt(code[startIdx + 1]); + else + alerts.Add(new Alert(Warning, $"音符缺少 width: {code}") { Line = lineNum, RelevantNote = FormatNoteRef(note) }); + } + else + { + alerts.Add(new Alert(Warning, $"音符缺少 cell 和 width: {code}") { Line = lineNum, RelevantNote = FormatNoteRef(note) }); + } + } + + private static void ParseAirNote(string code, ChuNote note, List alerts, int lineNum) + { + // Matches UgcGenerator: "a" + cell + width + two-letter direction + targetNote [ + "_" + airHoldDuration for AHD ] + if (code.Length < 5) + { + alerts.Add(new Alert(Warning, $"AIR 音符代码过短: {code}") { Line = lineNum }); + note.Type = "AIR"; + return; + } + + ParseCellWidth(code, 1, note, alerts, lineNum); + var afterCellWidth = code[3..]; + var underscoreIdx = afterCellWidth.IndexOf('_'); + var mainPart = underscoreIdx >= 0 ? afterCellWidth[..underscoreIdx] : afterCellWidth; + + if (mainPart.Length < 2) + { + alerts.Add(new Alert(Warning, $"AIR 音符方向代码过短: {code}") { Line = lineNum }); + note.Type = "AIR"; + return; + } + + var dir = mainPart[..2]; + if (AirDirections.TryGetValue(dir, out var airType)) + { + note.Type = airType; + } + else + { + note.Type = "AIR"; + alerts.Add(new Alert(Warning, $"未知的 AIR 方向: {dir}") { Line = lineNum, RelevantNote = FormatNoteRef(note) }); + } + + note.TargetNote = mainPart.Length > 2 ? mainPart[2..] : "N"; + + if (underscoreIdx >= 0 && note.Type == "AHD") + { + var durStr = afterCellWidth[(underscoreIdx + 1)..]; + if (int.TryParse(durStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ahdDuration)) + note.AirHoldDuration = ahdDuration; + } + } + + private static void ParseChrNote(string code, ChuNote note, List alerts, int lineNum) + { + note.Type = "CHR"; + if (code.Length < 3) + { + alerts.Add(new Alert(Warning, $"CHR 音符代码过短: {code}") { Line = lineNum }); + return; + } + + ParseCellWidth(code, 1, note, alerts, lineNum); + var extraRaw = code.Length > 3 ? code[3..] : ""; + if (ChrExtras.TryGetValue(extraRaw, out var chrDir)) + note.Extra = chrDir; + else + note.Extra = extraRaw; + } + + private static int HexCharToInt(char c) + { + return c switch + { + >= '0' and <= '9' => c - '0', + >= 'A' and <= 'F' => c - 'A' + 10, + >= 'a' and <= 'f' => c - 'a' + 10, + _ => 0, + }; + } + + private static int WidthHexCharToInt(char c) + { + return c switch + { + >= '1' and <= '9' => c - '1' + 1, + >= 'A' and <= 'G' => c - 'A' + 10, + >= 'a' and <= 'g' => c - 'a' + 10, + _ => 1, + }; + } + + private static string FormatNoteRef(ChuNote note) + { + return $"#{note.Measure}'{note.Offset}:{note.Type}"; + } +} + diff --git a/tests/chu/ChuTests.cs b/tests/chu/ChuTests.cs new file mode 100644 index 0000000..8eda332 --- /dev/null +++ b/tests/chu/ChuTests.cs @@ -0,0 +1,135 @@ +using System.Reflection; +using MuConvert.chu; + +namespace MuConvert.Tests.chu; + +public class ChuTests +{ + private static string TestsetDir => Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "chu", "testset"); + private static string OfficialDir => Path.Combine(TestsetDir, "官谱", "B.B.K.K.B.K.K"); + private static string CustomDir => Path.Combine(TestsetDir, "自制谱", "Example"); + private static string C2sPath => Path.Combine(OfficialDir, "0003_00.c2s"); + private static string UgcPath => Path.Combine(CustomDir, "basic.ugc"); + + [Fact] + public void CanParseOfficialC2S() + { + if (!File.Exists(C2sPath)) throw new SkipException($"Missing: {C2sPath}"); + var (chart, _) = new C2sParser().Parse(File.ReadAllText(C2sPath)); + Assert.NotEmpty(chart.Notes); + Assert.Equal(384, chart.Resolution); + } + + [Fact] + public void C2sRoundTrip() + { + if (!File.Exists(C2sPath)) throw new SkipException($"Missing: {C2sPath}"); + var (chart, _) = new C2sParser().Parse(File.ReadAllText(C2sPath)); + var (rt, _) = new C2sGenerator().Generate(chart); + var (reparsed, _) = new C2sParser().Parse(rt); + + Assert.Equal(chart.Notes.Count, reparsed.Notes.Count); + + var originalSnapshots = chart.Notes + .Select(SnapshotNote) + .OrderBy(s => s) + .ToArray(); + + var reparsedSnapshots = reparsed.Notes + .Select(SnapshotNote) + .OrderBy(s => s) + .ToArray(); + + Assert.Equal(originalSnapshots, reparsedSnapshots); + } + + /// + /// Builds a stable, comparable string from a note's public instance properties (name-sorted) + /// so round-trip tests verify no field loss without hard-coding each property in the test. + /// + private static string SnapshotNote(ChuNote note) + { + var props = typeof(ChuNote).GetProperties(BindingFlags.Instance | BindingFlags.Public); + var parts = props + .OrderBy(p => p.Name) + .Select(p => $"{p.Name}={p.GetValue(note)}"); + return string.Join("|", parts); + } + + /// + /// Same tick scaling as when converting UGC → C2S (384 ticks per measure). + /// + private static ChuNote UgcNoteScaledToC2sTicks(ChuNote n, int ticksPerBeat) + { + const int c2sResolution = 384; + int scaleDown(int v) => (int)((long)v * (c2sResolution / 4) / ticksPerBeat); + return new ChuNote + { + Type = n.Type, Measure = n.Measure, Offset = scaleDown(n.Offset), + Cell = n.Cell, Width = n.Width, + HoldDuration = scaleDown(n.HoldDuration), SlideDuration = scaleDown(n.SlideDuration), + EndCell = n.EndCell, EndWidth = n.EndWidth, + Extra = n.Extra, TargetNote = n.TargetNote, AirHoldDuration = scaleDown(n.AirHoldDuration), + StartHeight = n.StartHeight, TargetHeight = n.TargetHeight, NoteColor = n.NoteColor, + }; + } + + /// + /// Compares UGC-side notes to C2S-side notes in C2S tick space (384): snapshots of + /// for each note vs snapshots of notes. + /// Use with UgcToC2sViaGenerator (source UGC, C2S from generate+parse) or C2sToUgcViaGenerator (UGC from generate+parse, source C2S). + /// + private static void AssertUgcNotesEquivalentToReparsedC2s(UgcChart ugc, C2sChart c2s, bool isUgcReference) + { + var ugcSnaps = ugc.Notes + .Select(n => SnapshotNote(UgcNoteScaledToC2sTicks(n, ugc.TicksPerBeat))) + .OrderBy(s => s) + .ToArray(); + var c2sSnaps = c2s.Notes.Select(SnapshotNote).OrderBy(s => s).ToArray(); + if (isUgcReference) Assert.Equal(ugcSnaps, c2sSnaps); + else Assert.Equal(c2sSnaps, ugcSnaps); + } + + [Fact] + public void CanParseUgc() + { + if (!File.Exists(UgcPath)) throw new SkipException($"Missing: {UgcPath}"); + var (chart, _) = new UgcParser().Parse(File.ReadAllText(UgcPath)); + Assert.NotEmpty(chart.Notes); + Assert.Equal("MASTER", chart.Difficulty); + } + + [Fact] + public void UgcToC2sViaGenerator() + { + if (!File.Exists(UgcPath)) throw new SkipException($"Missing: {UgcPath}"); + var (ugc, _) = new UgcParser().Parse(File.ReadAllText(UgcPath)); + Assert.NotEmpty(ugc.Notes); + + var (c2sText, _) = new C2sGenerator().Generate(ugc); + Assert.Contains("VERSION", c2sText); + Assert.Contains("TAP\t", c2sText); + + // 再把转出来的c2s,parse回去,比较是否和一开始的ugc等价(注意不是文本 round-trip,而是 IR 等价,允许字段重排但不允许信息丢失) + var (c2sChart, _) = new C2sParser().Parse(c2sText); + Assert.NotEmpty(c2sChart.Notes); + AssertUgcNotesEquivalentToReparsedC2s(ugc, c2sChart, true); + } + + [Fact] + public void C2sToUgcViaGenerator() + { + if (!File.Exists(C2sPath)) throw new SkipException($"Missing: {C2sPath}"); + var (c2s, _) = new C2sParser().Parse(File.ReadAllText(C2sPath)); + Assert.NotEmpty(c2s.Notes); + + var (ugcText, _) = new UgcGenerator().Generate(c2s); + Assert.Contains("@VER", ugcText); + Assert.Contains("#5'0", ugcText); + + // 再把转出来的ugc,parse回去,比较是否和一开始的c2s等价 + var (ugcReparsed, _) = new UgcParser().Parse(ugcText); + Assert.NotEmpty(ugcReparsed.Notes); + AssertUgcNotesEquivalentToReparsedC2s(ugcReparsed, c2s, false); + } +} diff --git a/tests/chu/example.cs b/tests/chu/example.cs deleted file mode 100644 index 8ea2b17..0000000 --- a/tests/chu/example.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace MuConvert.Tests.chu; - -public class Example -{ - // TODO 示例测试,仅用来把文件夹创出来(git不会跟踪空文件夹) - // 之后删掉即可 - [Fact] - public void ExampleTest() - { - Assert.Equal(1, 1); - } -} \ No newline at end of file diff --git a/tests/chu/testset/placeholder.txt b/tests/chu/testset/placeholder.txt deleted file mode 100644 index a94df34..0000000 --- a/tests/chu/testset/placeholder.txt +++ /dev/null @@ -1,2 +0,0 @@ -TODO 示例测试数据,仅用来把文件夹创出来(git不会跟踪空文件夹) -中二相关的测试数据,请按一定的结构组织在这里(tests/chu/testset)下。 \ No newline at end of file diff --git "a/tests/chu/testset/\345\256\230\350\260\261/B.B.K.K.B.K.K/0003_00.c2s" "b/tests/chu/testset/\345\256\230\350\260\261/B.B.K.K.B.K.K/0003_00.c2s" new file mode 100644 index 0000000..957c334 Binary files /dev/null and "b/tests/chu/testset/\345\256\230\350\260\261/B.B.K.K.B.K.K/0003_00.c2s" differ diff --git "a/tests/chu/testset/\350\207\252\345\210\266\350\260\261/Example/basic.ugc" "b/tests/chu/testset/\350\207\252\345\210\266\350\260\261/Example/basic.ugc" new file mode 100644 index 0000000..e7bb9dd --- /dev/null +++ "b/tests/chu/testset/\350\207\252\345\210\266\350\260\261/Example/basic.ugc" @@ -0,0 +1,375 @@ +' Created with Margrete v1.5.0.1-2852550 +@VER 6 +@EXVER 1 +@TITLE Example +@SORT EXAMPLE +@ARTIST Artist +@DESIGN inonote +@DIFF 3 +@LEVEL 1 +@CONST 1.00000 +@SONGID umgr_example +@BGM +@BGMOFS 0.00000 +@BGMPRV 0.00000 0.00000 +@JACKET jacket.png +@BGIMG +@BGMODE PASSIVE FALSE +@FLDCOL 0 +@FLDIMG +@FLAG DIFFTTL FALSE +@FLAG SOFFSET TRUE +@FLAG CLICK TRUE +@FLAG EXLONG TRUE +@FLAG BGMWCMP TRUE +@ATINFO AUTHORS +@ATINFO SITES +@DLURL +@COPYRIGHT +@LICENSE +@TICKS 480 +@BEAT 0 4 4 +@BPM 0'0 120.00000 +@TIL 0 0'0 1.00000 +@TIL 3 25'0 -1.00000 +@TIL 2 25'240 -1.00000 +@TIL 1 25'480 -1.00000 +@TIL 3 25'480 1.00000 +@TIL 2 25'720 1.00000 +@TIL 1 25'960 1.00000 +@SPDMOD 22'480 1.00000 +@SPDMOD 22'600 1.50000 +@SPDMOD 22'720 2.00000 +@SPDMOD 22'840 2.50000 +@SPDMOD 22'960 3.00000 +@SPDMOD 22'1080 3.50000 +@SPDMOD 22'1200 4.00000 +@SPDMOD 22'1320 4.50000 +@SPDMOD 22'1440 5.00000 +@SPDMOD 22'1916 2.00000 +@SPDMOD 23'0 1.00000 +@SPDMOD 23'480 1.00000 +@SPDMOD 23'540 -0.06250 +@SPDMOD 23'600 1.50000 +@SPDMOD 23'660 -0.06250 +@SPDMOD 23'720 2.00000 +@SPDMOD 23'780 -0.06250 +@SPDMOD 23'840 2.50000 +@SPDMOD 23'900 -0.06250 +@SPDMOD 23'960 3.00000 +@SPDMOD 23'1020 -0.06250 +@SPDMOD 23'1080 3.50000 +@SPDMOD 23'1140 -0.06250 +@SPDMOD 23'1200 4.00000 +@SPDMOD 23'1260 -0.06250 +@SPDMOD 23'1320 4.50000 +@SPDMOD 23'1380 -0.06250 +@SPDMOD 23'1440 5.00000 +@SPDMOD 23'1500 -0.06250 +@SPDMOD 23'1916 0.12500 +@SPDMOD 24'0 1.00000 +@SPDMOD 24'960 0.50000 +@SPDMOD 24'964 1.00000 +@SPDMOD 24'1440 2.00000 +@SPDMOD 24'1444 1.00000 +@MAINTIL 0 +@ENDHEAD + +#0'0:t04 +#0'480:t44 +#0'960:t84 +#0'1440:tC4 +#1'0:x04U +#1'480:x44U +#1'960:x84U +#1'1440:xC4U +#2'0:x04U +#2'240:x44D +#2'480:x84C +#2'720:xC4L +#2'960:x04R +#2'1200:x44A +#2'1440:x84W +#2'1680:xC4I +#3'0:f04A +#3'480:f44A +#3'960:f84A +#3'1440:fC4A +#4'0:f02R +#4'60:f22R +#4'120:f42R +#4'180:f62R +#4'240:f82R +#4'300:fA2R +#4'360:fC2R +#4'420:fE2R +#4'960:fE2L +#4'1020:fC2L +#4'1080:fA2L +#4'1140:f82L +#4'1200:f62L +#4'1260:f42L +#4'1320:f22L +#4'1380:f02L +#5'0:s04 +#960>sC4 +#6'0:s04 +#480>cC4 +#960>s04 +#1440>sC4 +#7'0:h64 +#960>s +#8'0:t48 +#8'0:a48UCN +#8'480:t48 +#8'480:a48ULN +#8'960:t48 +#8'960:a48URN +#8'1440:t48 +#8'1440:a48DCN +#9'0:t48 +#9'0:a48DLN +#9'480:t48 +#9'480:a48DRN +#9'960:t48 +#9'960:a48UCI +#9'1440:t48 +#9'1440:a48DCI +#10'0:t48 +#10'0:H488N +#480>s +#960>s +#11'0:t48 +#11'0:S488N +#480>c888 +#960>s088 +#1080>c588 +#1200>c788 +#1320>c888 +#1560>c888 +#1680>c788 +#1800>c588 +#1920>s088 +#2040>c08E +#2160>c08G +#2280>c08E +#2400>c088 +#2520>c082 +#2640>c080 +#2760>c082 +#2880>s088 +#13'0:t04 +#13'0:S040N +#120>c148 +#240>c24C +#360>c34E +#480>c44F +#600>c54F +#720>c64C +#840>c748 +#960>s841 +#13'0:t44 +#13'0:S440N +#120>c548 +#240>c64C +#360>c74E +#480>c84F +#600>c94F +#720>cA4C +#840>cB48 +#960>sC41 +#14'0:C0400 +#5>s +#10>s048 +#14'480:C4400 +#5>s +#10>s448 +#14'960:C8400 +#5>s +#10>s848 +#14'1440:CC400 +#5>s +#10>sC48 +#15'0:T6480 +#720>cC48 +#1440>sC48 +#15'0:T0480 +#720>c848 +#1440>s048 +#16'0:C0480 +#240>s +#480>s +#719>c648 +#720>s +#960>s +#1200>s +#1440>s088 +#16'0:C6480 +#240>s +#480>s +#719>cC48 +#720>s +#960>s +#1200>s +#1440>sC48 +#18'0:C040Z +#4>s +#8>s +#12>s828 +#18'480:CC40Z +#4>s +#8>s +#12>s628 +#18'960:C040Z +#4>s +#8>s +#12>s828 +#18'960:CC48Z +#4>s +#8>s +#12>s620 +#18'1440:C048Z +#4>s +#8>s +#12>s820 +#18'1440:CC40Z +#4>s +#8>s +#12>s628 +#19'0:t04 +#19'0:H048N +#1440>c +#19'0:CC480 +#480>s +#1440>cC48 +#19'0:t44 +#19'0:S448N +#480>s488 +#960>c488 +#1440>c448 +#20'0:TC403 +#1>cC48 +#1440>s048 +#20'0:t64 +#20'0:S648N +#1440>sC48 +#20'0:tC4 +#20'0:SC48N +#1440>s048 +#20'0:t04 +#20'0:S048N +#1440>s648 +#21'0:d04 +#21'240:d44 +#21'480:d84 +#21'720:dC4 +#21'960:d84 +#21'1200:d44 +#21'1440:d04 +#22'0:h04 +#1916>s +#23'0:hC4 +#1916>s +#24'0:s04 +#960>sC4 +#1440>s04 +#22'480:d64 +#22'600:d64 +#22'840:d64 +#22'720:d64 +#22'960:d64 +#22'1080:d64 +#22'1200:d64 +#22'1320:d64 +#22'1440:d64 +#23'480:d64 +#23'600:d64 +#23'840:d64 +#23'720:d64 +#23'960:d64 +#23'1080:d64 +#23'1200:d64 +#23'1320:d64 +#23'1440:d64 +#22'480:d64 +#22'600:d64 +#22'840:d64 +#22'720:d64 +#22'960:d64 +#22'1080:d64 +#22'1200:d64 +#22'1320:d64 +#22'1440:d64 +#23'480:d64 +#23'600:d64 +#23'840:d64 +#23'720:d64 +#23'960:d64 +#23'1080:d64 +#23'1200:d64 +#23'1320:d64 +#23'1440:d64 +#23'480:d64 +#23'600:d64 +#23'840:d64 +#23'720:d64 +#23'960:d64 +#23'1080:d64 +#23'1200:d64 +#23'1320:d64 +#23'1440:d64 +#23'540:d04 +#23'660:d04 +#23'780:d04 +#23'900:d04 +#23'1020:d04 +#23'1140:d04 +#23'1260:d04 +#23'1380:d04 +#23'1500:d04 +#20'0:T6409 +#1>c648 +#1440>sC48 +#20'0:T0406 +#1>c048 +#1440>s648 +#25'0:h64 +@USETIL 1 +#1440>s +@USETIL 0 +#25'0:hA4 +@USETIL 3 +#1440>s +@USETIL 0 +#25'0:h24 +@USETIL 2 +#1440>s +@USETIL 0 +#17'0:T1141 +#1440>s114 +#17'0:T2142 +#1440>s214 +#17'0:T3143 +#1440>s314 +#17'0:T4144 +#1440>s414 +#17'0:T5145 +#1440>s514 +#17'0:T6146 +#1440>s614 +#17'0:T7147 +#1440>s714 +#17'0:T8148 +#1440>s814 +#17'0:TA14A +#1440>sA14 +#17'0:TC14Y +#1440>sC14 +#17'0:TB14B +#1440>sB14 +#17'0:T9149 +#1440>s914 +#17'0:TD14C +#1440>sD14 +#17'0:TE14D +#1440>sE14 diff --git a/utils/Error.cs b/utils/Error.cs index d3ce27f..19bc992 100644 --- a/utils/Error.cs +++ b/utils/Error.cs @@ -50,7 +50,7 @@ public override string ToString() else if (TimeInBar != null) tags.Add(string.Format(Locale.MessageBar, TimeInBar.Value.CanonicalForm)); else if (TimeInSeconds != null) tags.Add(string.Format(Locale.MessageTime, TimeInSeconds.Value)); if (RelevantNote != null) tags.Add(string.Format(Locale.MessageParsing, RelevantNote)); - var tagString = tags.Count > 0 ? $"({Locale.MessageAt} {string.Join(", ", tags)}) " : ""; + var tagString = tags.Count > 0 ? $"({Locale.MessageAt}{string.Join(", ", tags)}) " : ""; string head = ""; switch (Level)