diff --git a/Program.cs b/Program.cs index b20bdf4..b91b39e 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}"); + + ChuChart 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..2540399 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,10 @@ -MuConvert - 支持Simai与MA2谱面互转的新一代转谱器 +MuConvert - 新一代多功能音游转谱器 ================ -MuConvert 是一个支持**Simai与MA2互转**的转谱器。 +MuConvert 是一个多功能的音游转谱器。目前支持maimai、chunithm的谱面格式转换,未来还可能加入更多游戏/更多格式支持。 +- maimai:支持 Simai(自制谱社区最主流格式,[MajdataEdit](https://majdata.net/edit)、[Visual Maimai](https://github.com/CH3COOOHH/Visual-Maimai-Release)等都是这种格式)与 MA2(官方游戏格式)的双向互转。 +- chunithm:支持 UGC([Umiguri](https://umgr.inonote.jp/en/)的格式)与 C2S(官方游戏格式)的双向互转; + - 此外,还实验性地支持SUS与上述格式的双向互转(注意目前支持还不太完善,可能有较多bug,如遇bug欢迎反馈) > Kind reminder: To reduce developers’ workload, this README is maintained only in Chinese. We recommend using an LLM to translate and read this document. @@ -29,11 +32,16 @@ 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) + > 注意:SUS与C2S/UGC的互转,目前是实验性的、功能尚不完善,可能有较多bug,如遇bug欢迎反馈 - **`-o, --output`**:指定输出位置(可选);不传入此参数时,文件将保存到“输入文件所在的目录”。 - 会智能识别你传入的是目录还是文件,做智能的处理,将转谱结果输入到目录下或保存为文件。 - 此外,还可以传入 `-` ,表示输出到stdout。 @@ -44,19 +52,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 +88,27 @@ MuConvert "D:\charts\MyChart" -l 5,6 # 只转紫谱和白谱 # 生成的转谱结果位于D:\charts\MyChart\maidata.txt ``` +
+CHUNITHM转谱相关示例 + +**UGC → C2S**(默认输出同名 `.c2s`) + +```shell +MuConvert "D:\charts\Song\0003_00.ugc" # UGC -> C2S +# 转谱结果与输入同目录,生成 0003_00.c2s +``` + +**C2S → UGC** + +```shell +MuConvert "D:\charts\Song\0003_00.c2s" +# 默认同目录生成同名 .ugc +``` + +> C2S → SUS:`MuConvert "D:\charts\Song\0003_00.c2s" -t 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,22 @@ 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 谱面字符串 +// 以上得到的c2sChart、ugcChart,都是ChuChart类型的谱面表示对象; +// alerts是解析过程中可能产生的警告信息等,建议打印出来。 + +var (c2sText, alerts) = new C2sGenerator().Generate(ugcChart); // UGC -> C2S +var (ugcText, alerts) = new UgcGenerator().Generate(c2sChart); // C2S -> UGC +// 各种Generator的Generate方法,均接受 ChuChart(可将任一 Parser 产出的 ChuChart 互相传入)。 +// 同上,alerts是生成过程中可能产生的警告信息等,建议打印出来。 +``` + #### parser和generator的选项 - 部分parser和generator,在其构造参数中带有可选的选项参数,可以控制转谱时的一些行为。 - SimaiParser带有以下选项: @@ -163,15 +213,20 @@ finally - **parser(解析器)**:把“源格式文本”解析成中间表示 - `SimaiParser.Parse(string)` → `MaiChart` - `MA2Parser.Parse(string)` → `MaiChart` - - 返回值同时带有 `List`;如果遇到致命错误会抛出 `ConversionException` + - CHUNITHM的三种Parser(`C2sParser`、`UgcParser`、`SusParser`):`Parse(string)` → `ChuChart` + - 解析成功时,**返回值会同时带有 `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(ChuChart)` → 目标格式的谱面文本 + - 与parser类似,成功生成时,**返回值会同时带有 `List`**,这是转谱过程中可能遇到的警告等信息,建议打印出来(直接对`Alert`对象`ToString()`即可)。 + - 如果生成失败,会抛出 `ConversionException`;该异常对象中同样含有一个 `List`,是导致转谱失败的错误信息,可以同上打印出来。 ## 开发者指南 diff --git a/chart/BPMList.cs b/chart/BPMList.cs index ade5669..9009278 100644 --- a/chart/BPMList.cs +++ b/chart/BPMList.cs @@ -99,6 +99,15 @@ internal Rational ConvertTime(Rational startTime, Rational value, decimal? srcBp return result.CanonicalForm; } } + + internal (decimal, decimal, decimal, decimal) BPM_DEF() + { + var bpms = this.Select(x => x.Bpm).ToList(); + var max = bpms.Max(); + var min = bpms.Min(); + var modes = bpms.GroupBy(x => x).OrderByDescending(g => g.Count()).First().Key; // 众数 + return (this.First().Bpm, modes, max, min); + } } public record BPM(Rational Time, decimal Bpm); diff --git a/chart/chu/ChuChart.cs b/chart/chu/ChuChart.cs new file mode 100644 index 0000000..26624db --- /dev/null +++ b/chart/chu/ChuChart.cs @@ -0,0 +1,16 @@ +using MuConvert.chart; +using Rationals; + +namespace MuConvert.chu; + +public class ChuChart : BaseChart +{ + public string Title { get; set; } = ""; + public string Artist { get; set; } = ""; + public string Designer { get; set; } = ""; // 谱师 + public int Difficulty { get; set; } = 3; // 难度,0-basic, 1-advanced, ...。大多数情况下都是数字字符串。不直接存成数字是为了,万一自制谱这里写的不是数字、保留鲁棒性 + public string DisplayLevel { get; set; } = ""; // 显示等级,字符串 + public decimal Level { get; set; } // 定数,小数 + public string MusicId { get; set; } = "0"; + public List<(Rational Time, Rational Duration, decimal Multiplier)> SflList = []; // 所有变速声明构成的列表。 +} diff --git a/chart/chu/ChuNote.cs b/chart/chu/ChuNote.cs new file mode 100644 index 0000000..9760209 --- /dev/null +++ b/chart/chu/ChuNote.cs @@ -0,0 +1,60 @@ +using MuConvert.chart; +using Rationals; + +namespace MuConvert.chu; + +/** + * CHUNITHM 通用音符,C2S / UGC / SUS 共用此结构。 + */ +public class ChuNote: BaseNote +{ + /** 音符类型 (TAP, CHR, HLD, SLD, AIR, AHD 等) */ + public string Type { get; set; } = "TAP"; + /** 起始列 (0–15) */ + public int Cell { get; set; } + /** 宽度 (1–16) */ + public int Width { get; set; } = 1; + /** HLD/SLD/AHD/ASD等的 持续时长 */ + public Rational Duration { get; set => field = value.CanonicalForm; } = 0; + + /** SLD 终点列 */ + public int EndCell + { + get => _endCell ?? Cell; + set => _endCell = value; + } + /** SLD 终点宽度 */ + public int EndWidth + { + get => _endWidth ?? Width; + set => _endWidth = value; + } + + /** + * 当前音符的”前驱“。对不同类型的音符,其定义不同: + * - 对 Slide(SLD/SLC),是该slide对应的前一段slide。(对首段slide,该值为null) + * - 对 Air(AIR/AUR/AUL/ADW/ADR/ADL),是它所依附的音符(可以是tap\hold等任何类型,应该只要不是air系列和aircrush(ALD)都行) + * - 对 Air Slide(ASD/ASC):对首段slide,同Air的情况、是它所依附的音符;对第二段及之后的slide,同Slide的情况,是该slide对应的前一段slide。 + * + * 不难分析出,在完成整个chart之后,这个属性其实可以根据完整chart的列表动态推断的。 + * 因此,在BaseChuParser类中提供了FillAllPrevious方法,该方法应该在所有Note被正常解析完成后调用,填充所有上述类型的音符的targetNote信息。这样就不用每个Parser都写一段相似的逻辑。 + */ + public ChuNote? Previous; + + /** CHR/FLK/Air系列音符可能会具有的标记(如UP、L、DEF等) */ + public string Tag { get; set; } = ""; + + /** 起始高度。仅在Air Slide/Air Crush上具有。存储的是C2S格式中的数值,转UGC时需要乘以1.6。 */ + public decimal Height { get; set; } = 5; + /** 结束高度。仅在Air Slide/Air Crush上具有。存储的是C2S格式中的数值,转UGC时需要乘以1.6。 */ + public decimal EndHeight { get; set; } = 5; + /** Air Crush的interval值 */ + public int CrushInterval { get; set; } = 0; + + public override Rational EndTime => (Time + Duration).CanonicalForm; + /** Air系列音符/Slide系列音符的 关联的目标音符类型。仅供向前兼容使用。 */ + public string TargetNote => Previous?.Type ?? "N"; + + private int? _endCell; + private int? _endWidth; +} diff --git a/generator/chu/C2sGenerator.cs b/generator/chu/C2sGenerator.cs new file mode 100644 index 0000000..0b1259b --- /dev/null +++ b/generator/chu/C2sGenerator.cs @@ -0,0 +1,124 @@ +using System.Text; +using MuConvert.generator; +using MuConvert.utils; +using static MuConvert.utils.ChuUtils; + +namespace MuConvert.chu; + +public class C2sGenerator : IGenerator +{ + private const int RSL = 384; + + public (string, List) Generate(ChuChart chart) + { + var alerts = new List(); + var text = Serialize(chart, alerts); + return (text, alerts); + } + + private string Serialize(ChuChart chart, List alerts) + { + chart.Sort(); + + int.TryParse(chart.MusicId, out var musicId); + var sb = new StringBuilder(); + sb.AppendLine($"VERSION\t1.08.00\t1.08.00"); + sb.AppendLine($"MUSIC\t{musicId}"); + sb.AppendLine("SEQUENCEID\t0"); + sb.AppendLine($"DIFFICULT\t{chart.Difficulty:D2}"); + sb.AppendLine("LEVEL\t0.0"); + sb.AppendLine($"CREATOR\t{chart.Designer}"); + var bpm_def = chart.BpmList.BPM_DEF(); + sb.AppendLine(FormattableString.Invariant($"BPM_DEF\t{bpm_def.Item1}\t{bpm_def.Item2}\t{bpm_def.Item3}\t{bpm_def.Item4}")); + sb.AppendLine("MET_DEF\t4\t4"); + sb.AppendLine($"RESOLUTION\t{RSL}"); + sb.AppendLine($"CLK_DEF\t{RSL}"); + sb.AppendLine("PROGJUDGE_BPM\t240.000"); + sb.AppendLine("PROGJUDGE_AER\t0.999"); + sb.AppendLine("TUTORIAL\t0"); + sb.AppendLine(); + + foreach (var b in chart.BpmList) + { + var (m, o) = Utils.BarAndTick(b.Time, RSL); + sb.AppendLine(FormattableString.Invariant($"BPM\t{m}\t{o}\t{b.Bpm:0.000}")); + } + + foreach (var met in chart.MetList) + { + var (m, o) = Utils.BarAndTick(met.Time, RSL); + sb.AppendLine($"MET\t{m}\t{o}\t{met.Denominator}\t{met.Numerator}"); + } + + foreach (var s in chart.SflList.OrderBy(s => s.Time)) + { + var (m, o) = Utils.BarAndTick(s.Time, RSL); + var durTicks = Utils.Tick(s.Duration, RSL); + sb.AppendLine(FormattableString.Invariant($"SFL\t{m}\t{o}\t{durTicks}\t{s.Multiplier:0.000000}")); + } + sb.AppendLine(); + + foreach (var n in chart.Notes) + { + var line = FormatNote(n, RSL, alerts); + if (line != null) sb.AppendLine(line); + } + + sb.AppendLine(); + return sb.ToString(); + } + + private static readonly List allowedAirColors = ["DEF", "I"]; // TODO 搞清楚UGC里的'I'颜色,在C2S里,对应的字符串是什么 + private static string AirColorTag(ChuNote n) + { + if (allowedAirColors.Contains(n.Tag)) return n.Tag; + else return "DEF"; + } + + private static string FLKTag(ChuNote n) => n.Tag is "L" or "R" ? n.Tag : "L"; + + private static bool IsSlideContinueSegments(ChuNote n) // Air Slide的前驱只能是Air Slide,反之亦然。 + => (IsSlide(n) && IsSlide(n.Previous)) || (IsAirSlide(n) && IsAirSlide(n.Previous)); + + private static string? FormatNote(ChuNote n, int tpm, List alerts) + { + var (m, o) = Utils.BarAndTick(n.Time, tpm); + var durTicks = Utils.Tick(n.Duration, tpm); + if (IsSlideContinueSegments(n)) + { // 特殊地,对于slide的后续段:为了保证能接上,必须保证start tick接在前一个音符的endTime的后面,duration也采用end-start的方式计算durTicks。否则可能会,因为舍入的误差,造成没有办法接起来。 + (m, o) = Utils.BarAndTick(n.Previous!.EndTime, tpm); + durTicks = Utils.Tick(n.EndTime, tpm) - Utils.Tick(n.Previous!.EndTime, tpm); + } + return n.Type switch + { + "TAP" => $"TAP\t{m}\t{o}\t{n.Cell}\t{n.Width}", + "CHR" => $"CHR\t{m}\t{o}\t{n.Cell}\t{n.Width}\t{n.Tag}", + "HLD" or "HXD" => $"{n.Type}\t{m}\t{o}\t{n.Cell}\t{n.Width}\t{durTicks}", + "SLD" or "SLC" or "SXD" or "SXC" => $"{n.Type}\t{m}\t{o}\t{n.Cell}\t{n.Width}\t{durTicks}\t{n.EndCell}\t{n.EndWidth}", + "FLK" => $"FLK\t{m}\t{o}\t{n.Cell}\t{n.Width}\t{FLKTag(n)}", + "AIR" or "AUR" or "AUL" or "ADW" or "ADR" or "ADL" => + $"{n.Type}\t{m}\t{o}\t{n.Cell}\t{n.Width}\t{n.TargetNote}\t{AirColorTag(n)}", + "AHD" or "AHX" => $"{n.Type}\t{m}\t{o}\t{n.Cell}\t{n.Width}\t{n.TargetNote}\t{durTicks}\t{AirColorTag(n)}", + "ASD" or "ASC" => FormatAsdAsc(n, m, o, durTicks), + "ALD" => FormatAld(n, m, o, durTicks), + "MNE" => $"MNE\t{m}\t{o}\t{n.Cell}\t{n.Width}", + _ => alert(), + }; + + string? alert() + { + alerts.Add(new Alert(Alert.LEVEL.Warning, Locale.C2SUnknownNoteType, n.Time)); + return null; + } + } + + private static string FormatAsdAsc(ChuNote n, int m, int o, int durTicks) + { + return FormattableString.Invariant($"{n.Type}\t{m}\t{o}\t{n.Cell}\t{n.Width}\t{n.TargetNote}\t{n.Height:0.#}\t{durTicks}\t{n.EndCell}\t{n.EndWidth}\t{n.EndHeight:0.#}\t{AirColorTag(n)}"); + } + + private static string FormatAld(ChuNote n, int m, int o, int durTicks) + { + return FormattableString.Invariant($"ALD\t{m}\t{o}\t{n.Cell}\t{n.Width}\t{n.CrushInterval}\t{n.Height:0.#}\t{durTicks}\t{n.EndCell}\t{n.EndWidth}\t{n.EndHeight:0.#}"); + } +} diff --git a/generator/chu/SusGenerator.cs b/generator/chu/SusGenerator.cs new file mode 100644 index 0000000..c770247 --- /dev/null +++ b/generator/chu/SusGenerator.cs @@ -0,0 +1,63 @@ +using System.Text; +using MuConvert.generator; +using MuConvert.utils; + +namespace MuConvert.chu; + +public class SusGenerator : IGenerator +{ + private int RSL = 480 * 4; + + public (string, List) Generate(ChuChart chart) + { + var alerts = new List(); + var text = Serialize(chart); + return (text, alerts); + } + + private string Serialize(ChuChart sus) + { + sus.Sort(); + + 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(FormattableString.Invariant($"#BPM_DEF {sus.StartBpm:F2}")); + sb.AppendLine($"#REQUEST \"{RSL / 4}\""); + sb.AppendLine(); + + foreach (var n in sus.Notes) + { + var (m, o) = Utils.BarAndTick(n.Time, RSL); + sb.AppendLine($"#{m:X2}{o:X3}:{FormatData(n, RSL)}"); + } + + return sb.ToString(); + } + + private static string FormatData(ChuNote n, int tpm) + { + string lw = $"{n.Cell*2:X2}{n.Width*2:X2}"; + string tc = TypeCode(n.Type); + var durTicks = Utils.Tick(n.Duration, tpm); + string dur = $"{durTicks: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*2:X2}{n.EndWidth*2: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", "AHX" => "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..2b7eb2a --- /dev/null +++ b/generator/chu/UgcGenerator.cs @@ -0,0 +1,181 @@ +using System.Text; +using MuConvert.generator; +using MuConvert.utils; +using static MuConvert.utils.ChuUtils; + +namespace MuConvert.chu; + +public class UgcGenerator : IGenerator +{ + private int RSL = 480 * 4; + + public (string, List) Generate(ChuChart chart) + { + var alerts = new List(); + var text = Serialize(chart, alerts); + return (text, alerts); + } + + private string Serialize(ChuChart ugc, List alerts) + { + ugc.Sort(); + + var sb = new StringBuilder(); + sb.AppendLine("@VER\t8"); + 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{ugc.Difficulty}"); + sb.AppendLine($"@LEVEL\t{ugc.DisplayLevel}"); + sb.AppendLine(FormattableString.Invariant($"@CONST\t{ugc.Level:F5}")); + sb.AppendLine($"@SONGID\t{ugc.MusicId}"); + sb.AppendLine($"@TICKS\t{RSL / 4}"); + foreach (var met in ugc.MetList) + { + var (m, _) = Utils.BarAndTick(met.Time, RSL); + sb.AppendLine($"@BEAT\t{m}\t{met.Numerator}\t{met.Denominator}"); + } + foreach (var b in ugc.BpmList) + { + var (m, o) = Utils.BarAndTick(b.Time, RSL); + sb.AppendLine(FormattableString.Invariant($"@BPM\t{m}'{o}\t{b.Bpm:F5}")); + } + sb.AppendLine("@TIL\t0\t0'0\t1.00000"); + + foreach (var s in ugc.SflList.OrderBy(x => x.Time)) + { + var (m, o) = Utils.BarAndTick(s.Time, RSL); + sb.AppendLine(FormattableString.Invariant($"@SPDMOD\t{m}'{o}\t{s.Multiplier:0.00000}")); + } + + sb.AppendLine("@MAINTIL\t0"); + sb.AppendLine("@ENDHEAD"); + sb.AppendLine(); + + // UGC Slide / AIR-SLIDE (v8): + // - Chains (ChuNote.Previous) serialize as ONE parent line + follower lines (#OffsetTick from parent time). + // - Ground slide: parent `s`, followers `>s` / `>c` + end cell/width. + // - Air slide: parent `S` + cell/width + hh (base-36 ×2, C2S/UGC height units) + N/I; followers `>s`/`>c` + xw + hh. + // - First segment may attach to TAP/HLD via Previous; only skip emit when Previous is another segment of the same chain. + var slideChains = BuildSlideChains(ugc.Notes); + + foreach (var n in ugc.Notes) + { + if (IsSlideChainNote(n.Type) && IsSlideContinueSegments(n)) + continue; // 是Slide且不是第一段Slide,则应当已经被处理过了,直接跳过 + + var (m, o) = Utils.BarAndTick(n.Time, RSL); + var ucode = UCode(n); + if (ucode == "") + { + alerts.Add(new Alert(Alert.LEVEL.Warning, $"UGC Generator遇到了不支持的音符类型: {n.Type}", n.Time, (double)ugc.ToSecond(n.Time))); + continue; + } + sb.Append($"#{m}'{o}:{ucode}"); + sb.AppendLine(); + + if (IsSlideChainNote(n.Type)) + { + if (slideChains.TryGetValue(n, out var segments)) + { + var isAir = IsAirSlide(n.Type); + foreach (var seg in segments) + { + var endTicks = Utils.Tick(seg.EndTime - n.Time, RSL); + if (endTicks <= 0) continue; + if (isAir) + sb.AppendLine($"#{endTicks}>{SlideFollowerMarker(seg.Type)}{IToH36(seg.EndCell)}{IToH36(seg.EndWidth)}{EncodeAirHeight(seg.EndHeight)}"); + else + sb.AppendLine($"#{endTicks}>{SlideFollowerMarker(seg.Type)}{IToH36(seg.EndCell)}{IToH36(seg.EndWidth)}"); + } + } + continue; + } + + var durTicks = Utils.Tick(n.Duration, RSL); + if (n.Type is "HLD" or "HXD" && durTicks > 0) + sb.AppendLine($"#{durTicks}>s"); + else if (n.Type is "AHD" or "AHX" && durTicks > 0) + { + var marker = (n.Type == "AHX") ? 'c' : 's'; + sb.AppendLine($"#{durTicks}>{marker}"); + } + else if (n.Type is "ALD" && durTicks > 0) + sb.AppendLine($"#{durTicks}>c{IToH36(n.EndCell)}{IToH36(n.EndWidth)}{EncodeAirHeight(n.EndHeight)}"); + } + return sb.ToString(); + } + + private static Dictionary> BuildSlideChains(List notes) + { + var chains = new Dictionary>(); + foreach (var n in notes) + { + if (!IsSlideChainNote(n.Type)) continue; + var head = GetSlideHead(n); + if (!chains.TryGetValue(head, out var list)) + chains[head] = list = []; + list.Add(n); + } + + // Order segments by their end time so follower ticks are increasing. + foreach (var (_, segs) in chains) + { + segs.Sort((a, b) => + { + var t = a.EndTime.CompareTo(b.EndTime); + if (t != 0) return t; + // stable-ish tie-breakers + t = a.Time.CompareTo(b.Time); + if (t != 0) return t; + t = string.CompareOrdinal(a.Type, b.Type); + if (t != 0) return t; + return 0; + }); + } + + // For a valid chain, follower ticks should be strictly increasing; if the chart has + // degenerate segments, later code simply skips non-positive offsets. + return chains; + } + + private static ChuNote GetSlideHead(ChuNote n) + { + var cur = n; + while (IsSlideContinueSegments(cur)) cur = cur.Previous!; + return cur; + } + + private static bool IsSlideChainNote(string t) => IsSlide(t) || IsAirSlide(t); + // 返回true表示,当前ChuNote对应的Slide Segment,是第二段之后(也就是接在别的segment之后)的segment,而不是首段segment, + private static bool IsSlideContinueSegments(ChuNote n) // Air Slide的前驱只能是Air Slide,反之亦然。 + => (IsSlide(n) && IsSlide(n.Previous)) || (IsAirSlide(n) && IsAirSlide(n.Previous)); + private static char SlideFollowerMarker(string t) => t is "SLC" or "SXC" or "ASC" ? 'c' : 's'; + + private static string EncodeAirHeight(decimal value) => IToH36((int)Math.Round(C2U_Height(value) * 10)).PadLeft(2, '0'); + + private static string CrushColor(string t) => C2U_AirColor.GetValueOrDefault(t, t.Length > 0 ? t[..1] : "0"); + private static string CrushInterval(int crushInterval) => crushInterval > 10000 ? "$" : crushInterval.ToString(); + + private static string UCode(ChuNote n) + { + string c = IToH36(n.Cell), w = IToH36(n.Width); + return n.Type switch + { + "TAP" => $"t{c}{w}", + "CHR" => $"x{c}{w}{C2U_ChrExtras.GetValueOrDefault(n.Tag, n.Tag)}", + "HLD" or "HXD" => $"h{c}{w}", + "SLD" or "SXD" => $"s{c}{w}", + "SLC" or "SXC" => $"s{c}{w}", + "FLK" => $"f{c}{w}{n.Tag}", + "MNE" => $"d{c}{w}", + // AIR-SLIDE (v8): #BarTick:S x w hh c + "ASD" or "ASC" => $"S{c}{w}{EncodeAirHeight(n.Height)}{C2U_AirColor.GetValueOrDefault(n.Tag, "N")}", + "AIR" or "AUR" or "AUL" or "ADW" or "ADR" or "ADL" => $"a{c}{w}{C2U_AirDirections[n.Type]}{C2U_AirColor.GetValueOrDefault(n.Tag, "N")}", + // AIR-HOLD (v8): #BarTick:H x w c + 子行 #OffsetTick:s / :c(见 Umiguri Chart v8 doc) + "AHD" or "AHX" => $"H{c}{w}{C2U_AirColor.GetValueOrDefault(n.Tag, "N")}", + "ALD" => $"C{c}{w}{EncodeAirHeight(n.Height)}{CrushColor(n.Tag)},{CrushInterval(n.CrushInterval)}", + _ => "" + }; + } +} diff --git a/generator/mai/MA2Generator.cs b/generator/mai/MA2Generator.cs index 7aed680..f0bab35 100644 --- a/generator/mai/MA2Generator.cs +++ b/generator/mai/MA2Generator.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text; using MuConvert.generator; using MuConvert.utils; @@ -45,15 +46,6 @@ GENERATED_BY MuConvert v{8} "; private Rational __1_384 = new(1, 384); - - private (decimal, decimal, decimal, decimal) bpmStats() - { - var bpms = chart.BpmList.Select(x => x.Bpm).ToList(); - var max = bpms.Max(); - var min = bpms.Min(); - var modes = bpms.GroupBy(x => x).OrderByDescending(g => g.Count()).First().Key; // 众数 - return (chart.BpmList.First().Bpm, modes, max, min); - } /** * 把Rational的时间近似到RESOLUTION允许的最接近tick上 @@ -196,8 +188,8 @@ protected virtual List AddSlide(Slide slide, int bar, int tick) // 生成文件头 protected void GenerateFileHead(StringBuilder result) { - var bpmStatistics = bpmStats(); - string head = string.Format(headTemplate, + var bpmStatistics = chart.BpmList.BPM_DEF(); + string head = string.Format(CultureInfo.InvariantCulture, headTemplate, $"{MA2Version / 100}.{MA2Version % 100:D2}.00", IsUtage?1:0, bpmStatistics.Item1, bpmStatistics.Item2, bpmStatistics.Item3, bpmStatistics.Item4, RSL, RSL/4 * chart.ClockCount, Utils.AppVersion); @@ -210,7 +202,7 @@ protected void GenerateBPM(StringBuilder result) foreach (var bpm in chart.BpmList) { var (bar, tick) = BT(bpm.Time); - result.AppendLine($"BPM\t{bar}\t{tick}\t{bpm.Bpm:F3}"); + result.AppendLine(FormattableString.Invariant($"BPM\t{bar}\t{tick}\t{bpm.Bpm:F3}")); } result.AppendLine($"MET\t0\t0\t4\t{chart.ClockCount}"); result.AppendLine(); @@ -300,7 +292,7 @@ protected void GenerateStatistics(StringBuilder result, Statistics stats) result.AppendLine($"TTM_SCR_ALL\t{theoryScore}"); var score_sss = stats.WeightedNoteCount * 500; // 旧框扣除额外分 - result.AppendLine($"TTM_SCR_S\t{Math.Ceiling(score_sss * 0.97 / 50) * 50}"); + result.AppendLine(FormattableString.Invariant($"TTM_SCR_S\t{Math.Ceiling(score_sss * 0.97 / 50) * 50}")); result.AppendLine($"TTM_SCR_SS\t{score_sss}"); result.AppendLine($"TTM_RAT_ACV\t{(long)theoryScore * 10000 / score_sss }"); // 用long避免溢出 result.AppendLine(); diff --git a/generator/mai/SimaiGenerator.cs b/generator/mai/SimaiGenerator.cs index 2336ebf..2b36b90 100644 --- a/generator/mai/SimaiGenerator.cs +++ b/generator/mai/SimaiGenerator.cs @@ -123,7 +123,7 @@ private string DurationStr(Rational start, Duration duration, bool forceAbsTime } else { // 返回绝对时间 - return $"[#{(decimal)duration.Seconds:0.####}]"; + return FormattableString.Invariant($"[#{(decimal)duration.Seconds:0.####}]"); } } @@ -149,7 +149,7 @@ private string DurationStr(Rational start, Duration duration, bool forceAbsTime { var bpmChange = chart.BpmList[bpmIdx]; bpmIdx++; - buf.Add(new SimaiNote(bpmChange.Time, $"({bpmChange.Bpm:0.#######})", 0, true)); + buf.Add(new SimaiNote(bpmChange.Time, FormattableString.Invariant($"({bpmChange.Bpm:0.#######})"), 0, true)); continue; } @@ -197,7 +197,7 @@ private string DurationStr(Rational start, Duration duration, bool forceAbsTime forceAbsTime: nonStdWaitTime && Workaround_ForceUseAbsDurationForSlidesWithNonStandardWaitTime); if (nonStdWaitTime) { // 非标准等待时间的星星,应该加上等待时间标记。simai仅支持绝对时间的等待时间标记。 - durationStr = $"[{(decimal)slide.WaitTime.Seconds:0.####}##{durationStr[1..].TrimStart('#')}"; + durationStr = FormattableString.Invariant($"[{(decimal)slide.WaitTime.Seconds:0.####}##{durationStr[1..].TrimStart('#')}"); } res += durationStr; rollingTime += seg.Duration.Bar; 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 df5c2ec..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}) @@ -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 b7758c3..102e6fa 100644 --- a/i18n/Locale.resx +++ b/i18n/Locale.resx @@ -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/BaseChuParser.cs b/parser/chu/BaseChuParser.cs new file mode 100644 index 0000000..d215835 --- /dev/null +++ b/parser/chu/BaseChuParser.cs @@ -0,0 +1,89 @@ +using MuConvert.chu; +using MuConvert.utils; +using static MuConvert.utils.ChuUtils; + +namespace MuConvert.parser; + +public abstract class BaseChuParser : IParser +{ + public abstract (ChuChart, List) Parse(string text); + + /** + * 填充所有需要 Previous 的音符(见 注释)。 + * 只会填充当前Previous没有被设置过的音符:如果某个音符的Previous不为null(在Parse过程中已经被设置过了),则会尊重Parse的决定,不会再次设置。 + * + * 推断规则: + * - 前驱音符必须满足“首尾相接”:prev.EndTime == cur.Time 且 prev.EndCell == cur.Cell 且 prev.EndWidth == cur.Width + * - 再按音符类型施加额外约束(slide / air / air-slide) + * + * 该方法应在所有音符解析完成后调用。 + * + * 谱面对象 + * 过程中产生的警告会被放进这个数组里。 + * 可选。对C2S这种,谱面中原始记录了targetNote的类型的格式,可以将相关记录通过这个字典传过来,供本函数作为选择previous时的优先和参考。 + */ + protected virtual void FillAllPrevious(ChuChart chart, List alerts, Dictionary? rawTargetNote = null) + { + if (chart.Notes.Count == 0) return; + + var endDict = new Dictionary<(Rationals.Rational EndTime, int EndCell, int EndWidth), List>(); + foreach (var n in chart.Notes) + { + endDict.Add((n.EndTime, n.EndCell, n.EndWidth), n); + } + + foreach (var cur in chart.Notes) + { + if (!NeedsPrevious(cur)) continue; + if (cur.Previous != null) continue; // 若某些 parser 已提前填了 Previous,则保留 + + var key = (cur.Time, cur.Cell, cur.Width); + var candidates = endDict.GetValueOrDefault(key, []); + var filtered = FilterPreviousCandidates(cur, candidates); + + if (rawTargetNote != null && rawTargetNote.TryGetValue(cur, out var target) && !string.IsNullOrEmpty(target)) + { + var filteredByRaw = filtered.Where(x=>x.Type == target).ToList(); + if (filteredByRaw.Count == 0) + { + alerts.Add(new Alert(Alert.LEVEL.Warning, "未找到声明的前驱/依附音符", cur.Time, (double)chart.ToSecond(cur.Time))); + } + else filtered = filteredByRaw; // 缩小目标范围 + } + + if (filtered.Count > 0) + { + cur.Previous = filtered[0]; // 取第一个 + candidates.Remove(filtered[0]); + } + } + } + + private static bool NeedsPrevious(ChuNote n) + { + return IsSlide(n.Type) || IsAir(n.Type) || IsAirHold(n.Type) || IsAirSlide(n.Type); + } + + private static List FilterPreviousCandidates(ChuNote cur, List candidates) + { // 注意:候选列表已满足“首尾相接”,这里仅做类型约束 + List result = []; + candidates = candidates.Where(n => n != cur).ToList(); // 自己不能成为自己的candidate,防止自环 + + if (IsSlide(cur.Type)) + { // Slide 的 previous:上一段 slide(找不到则说明是首段,则为 null) + result.AddRange(candidates.Where(n => IsSlide(n.Type))); + } + else if (IsAirSlide(cur.Type)) + { // Air Slide:优先匹配“上一段airslide”,其次匹配“上一段其他 + result.AddRange(candidates.Where(n => IsAirSlide(n.Type))); + result.AddRange(candidates.Where(n => !IsAirSlide(n.Type) && IsLegalPreviousForAir(n.Type))); + } + else if (IsAir(cur.Type) || IsAirHold(cur.Type)) + { // Air 系列:依附在一个“非广义Air”的音符上 + result.AddRange(candidates.Where(n => IsLegalPreviousForAir(n.Type)).ToList()); + } + return result; + + bool IsLegalPreviousForAir(string t) => !(IsGeneralizedAir(t) || t == "MNE" || t == "CLICK"); + } +} \ No newline at end of file diff --git a/parser/chu/C2sParser.cs b/parser/chu/C2sParser.cs new file mode 100644 index 0000000..e8b52a0 --- /dev/null +++ b/parser/chu/C2sParser.cs @@ -0,0 +1,164 @@ +using System.Globalization; +using MuConvert.chart; +using MuConvert.parser; +using MuConvert.utils; +using Rationals; +using static MuConvert.utils.Alert.LEVEL; + +namespace MuConvert.chu; + +/** + * C2S 格式解析器(官方格式,RESOLUTION=384 tick/小节)。 + * Tab 分隔文本,识别 HEADER / TIMING / NOTES 区段。 + */ +public class C2sParser: BaseChuParser +{ + private int RSL = 384; + 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" }; + + // C2S 会原始记录 targetNote 字符串;用于在 Previous 推断有多个候选时优先匹配。 + private readonly Dictionary _rawTargetNote = new(); + + public override (ChuChart, List) Parse(string text) + { + var chart = new ChuChart(); + 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; + } + } + + FillAllPrevious(chart, alerts, _rawTargetNote); + chart.Sort(); + return (chart, alerts); + } + + private void ParseHeader(string[] p, ChuChart chart) + { + var tag = p[0].ToUpperInvariant(); + switch (tag) + { + case "MUSIC": chart.MusicId = Int(p, 1).ToString(); break; + case "DIFFICULT": chart.Difficulty = Int(p, 1); break; + case "CREATOR": chart.Designer = Str(p, 1); break; + case "RESOLUTION": RSL = Math.Max(1, Int(p, 1, 384)); break; + } + } + + private void ParseTiming(string[] p, ChuChart chart) + { + var tag = p[0].ToUpperInvariant(); + switch (tag) + { + case "BPM": + chart.BpmList.Add(new BPM(Int(p, 1) + new Rational(Int(p, 2), RSL), + decimal.Parse(p[3], CultureInfo.InvariantCulture))); + break; + case "MET": + chart.MetList.Add(new MET(Int(p, 1) + new Rational(Int(p, 2), RSL), Int(p, 4, 4), Int(p, 3, 4))); + break; + case "SFL": + chart.SflList.Add(( + Int(p, 1) + new Rational(Int(p, 2), RSL), + new Rational(Int(p, 3), RSL), + decimal.Parse(p[4], CultureInfo.InvariantCulture))); + break; + } + } + + private void ParseNote(string[] p, ChuChart chart, List alerts, int lineNum) + { + var tag = p[0].ToUpperInvariant(); + var note = new ChuNote { Type = tag, Time = Int(p, 1) + new Rational(Int(p, 2), RSL) }; + string? targetNote = null; + + 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.Tag = Str(p, 5); break; + case "HLD": case "HXD": + note.Cell = Int(p, 3); note.Width = Math.Max(1, Int(p, 4, 1)); note.Duration = new Rational(Int(p, 5), RSL); break; + case "SLD": case "SLC": case "SXD": case "SXC": + note.Cell = Int(p, 3); note.Width = Math.Max(1, Int(p, 4, 1)); + note.Duration = new Rational(Int(p, 5), RSL); + 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.Tag = 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)); targetNote = Str(p, 5); + if (p.Length >= 7) note.Tag = Str(p, 6); + break; + case "AHD": case "AHX": + note.Cell = Int(p, 3); note.Width = Math.Max(1, Int(p, 4, 1)); + targetNote = Str(p, 5); note.Duration = new Rational(Int(p, 6), RSL); + if (p.Length >= 8) note.Tag = Str(p, 7); + break; + case "ASD": case "ASC": + // 文档:M O Cell Width | TargetNote | 未知 | Duration | EndCell | EndWidth | 未知 | Tag + if (p.Length < 12) + { + alerts.Add(new Alert(Warning, $"{tag} 列数不足(期望至少 12 列)") { Line = lineNum }); + return; + } + note.Cell = Int(p, 3); note.Width = Math.Max(1, Int(p, 4, 1)); + targetNote = Str(p, 5); + note.Height = Decimal(p, 6, 5); note.EndHeight = Decimal(p, 10, 5); + note.Duration = new Rational(Int(p, 7), RSL); + note.EndCell = Int(p, 8); note.EndWidth = Math.Max(1, Int(p, 9, 1)); + note.Tag = Str(p, 11); + break; + case "ALD": + // 根据 https://github.com/MuNET-OSS/MuConvert/pull/3#issuecomment-4405859671 实现 + if (p.Length < 11) + { + alerts.Add(new Alert(Warning, "ALD 列数不足(期望至少 11 列)") { Line = lineNum }); + return; + } + note.Cell = Int(p, 3); note.Width = Math.Max(1, Int(p, 4, 1)); + note.CrushInterval = Int(p, 5); + note.Height = Decimal(p, 6, 5); note.EndHeight = Decimal(p, 10, 5); + note.Duration = new Rational(Int(p, 7), RSL); + note.EndCell = Int(p, 8); note.EndWidth = Math.Max(1, Int(p, 9, 1)); + note.Tag = Str(p, 11); + break; + default: + alerts.Add(new Alert(Warning, string.Format(Locale.C2SUnknownNoteType, tag)) { Line = lineNum }); return; + } + + if (targetNote != null) _rawTargetNote[note] = targetNote; + 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 decimal Decimal(string[] p, int i, decimal def = 0) => i < p.Length && decimal.TryParse(p[i], 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..086c2cd --- /dev/null +++ b/parser/chu/SusParser.cs @@ -0,0 +1,247 @@ +using System.Globalization; +using MuConvert.chart; +using MuConvert.parser; +using MuConvert.utils; +using Rationals; +using static MuConvert.utils.Alert.LEVEL; + +namespace MuConvert.chu; + +/** + * SUS 格式解析器(社区工具格式,REQUEST=480 tick/拍,lane 0–31)。 + * #MMTT:data 十六进制编码音符。 + */ +public class SusParser: BaseChuParser +{ + private int RSL = 480 * 4; + + 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 override (ChuChart, List) Parse(string text) + { + var chart = new ChuChart(); + 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); + } + } + + FillAllPrevious(chart, alerts); + chart.Sort(); + 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 void ParseHeaderLine(string content, ChuChart 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.BpmList.Add(new BPM(0, (decimal)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)) + RSL = ticks * 4; + else + alerts.Add(new Alert(Warning, $"REQUEST 格式错误: {content}") { Line = lineNum }); + } + } + + private void ParseNoteLine(string content, ChuChart 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, + Time = measure + new Rational(tick, RSL), + 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, RSL, alerts, lineNum); + break; + + case "SLD": + ParseSlideData(dataStr, note, RSL, alerts, lineNum); + break; + + case "AIR": + case "ADW": + ParseAirTarget(dataStr, note, RSL, alerts, lineNum); + break; + + case "AHD": + ParseAhdData(dataStr, note, RSL, alerts, lineNum); + break; + } + + chart.Notes.Add(note); + } + + private static void ParseHoldData(string dataStr, ChuNote note, int tpm, List alerts, int lineNum) + { + if (dataStr.Length >= 10) + { + note.Duration = new Rational(HexToInt(dataStr[6..10]), tpm); + } + else + { + alerts.Add(new Alert(Warning, $"HLD 音符缺少时长: {dataStr}") { Line = lineNum, RelevantNote = FormatNoteRef(note, tpm) }); + } + } + + private static void ParseSlideData(string dataStr, ChuNote note, int tpm, List alerts, int lineNum) + { + if (dataStr.Length >= 10) + { + note.Duration = new Rational(HexToInt(dataStr[6..10]), tpm); + } + else + { + alerts.Add(new Alert(Warning, $"SLD 音符缺少时长: {dataStr}") { Line = lineNum, RelevantNote = FormatNoteRef(note, tpm) }); + 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, int tpm, List alerts, int lineNum) + { + if (dataStr.Length < 8) + { + alerts.Add(new Alert(Warning, $"AIR/ADW 音符缺少目标: {dataStr}") { Line = lineNum, RelevantNote = FormatNoteRef(note, tpm) }); + } + } + + private static void ParseAhdData(string dataStr, ChuNote note, int tpm, List alerts, int lineNum) + { + if (dataStr.Length >= 10) + { + note.Duration = new Rational(HexToInt(dataStr[6..10]), tpm); + } + else + { + alerts.Add(new Alert(Warning, $"AHD 音符缺少时长: {dataStr}") { Line = lineNum, RelevantNote = FormatNoteRef(note, tpm) }); + } + } + + 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, int tpm) + { + var (m, o) = Utils.BarAndTick(note.Time, tpm); + return $"#{m:X2}{o:X3}:{note.Type}"; + } +} diff --git a/parser/chu/UgcParser.cs b/parser/chu/UgcParser.cs new file mode 100644 index 0000000..10a90bc --- /dev/null +++ b/parser/chu/UgcParser.cs @@ -0,0 +1,588 @@ +using System.Globalization; +using MuConvert.chart; +using MuConvert.parser; +using MuConvert.utils; +using Rationals; +using static MuConvert.utils.Alert.LEVEL; +using static MuConvert.utils.Utils; +using static MuConvert.utils.ChuUtils; + +namespace MuConvert.chu; + +/** + * UMIGURI语法文档: https://gist.github.com/inonote/5c01e73781cab17765a1d93641d52298 + */ +public class UgcParser: BaseChuParser +{ + private int RSL = 480 * 4; + private int Version = 8; + + public override (ChuChart, List) Parse(string text) + { + var chart = new ChuChart(); + 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); + } + } + + FinalizeUgcSflDurations(chart); + FillAllPrevious(chart, alerts); + chart.Sort(); + return (chart, alerts); + } + + private static void FinalizeUgcSflDurations(ChuChart chart) + { + if (chart.SflList.Count == 0) return; + chart.SflList = chart.SflList.OrderBy(s => s.Time).ToList(); + var endTime = Utils.Max(chart.SflList[^1].Time, chart.Notes.Max(x=>x.EndTime)); + + for (var i = 0; i < chart.SflList.Count; i++) + { + var t = chart.SflList[i].Time; + var dur = (i < chart.SflList.Count - 1 ? chart.SflList[i+1].Time : endTime) - t; + chart.SflList[i] = chart.SflList[i] with { Duration = dur.CanonicalForm }; + } + + chart.SflList = chart.SflList.Where(x => x.Multiplier != 1).ToList(); // 倍率为1的,没必要放进来的 + } + + private void ParseHeaderLine(string line, ChuChart 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 "@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; + } + else + { + chart.Difficulty = new string(value.Where(char.IsLetter).ToArray()).ToUpperInvariant() switch + { + "BASIC" => 0, + "ADVANCED" => 1, + "EXPERT" => 2, + "MASTER" => 3, + "WORLDSEND" => 4, + "ULTIMA" => 5, + _ => 3, + }; + } + break; + + case "@LEVEL": + chart.DisplayLevel = value; + break; + + case "@CONST": + if (decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var constant)) + chart.Level = constant; + else + alerts.Add(new Alert(Warning, $"@CONST 格式错误: {line}") { Line = lineNum }); + break; + + case "@SONGID": + chart.MusicId = value; + break; + + case "@TICKS": + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ticks)) + RSL = ticks * 4; + 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.MetList.Add(new MET(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)..]; + if (TryParseUgcMeasureTick(measureOffset, out var bpmMeasure, out var bpmOffset) + && decimal.TryParse(bpmValueStr, NumberStyles.Float, CultureInfo.InvariantCulture, out var bpmValue)) + { + chart.BpmList.Add(new BPM(bpmMeasure + new Rational(bpmOffset, RSL), bpmValue)); + } + else + { + alerts.Add(new Alert(Warning, $"@BPM 格式错误: {line}") { Line = lineNum }); + } + } + else + { + alerts.Add(new Alert(Warning, $"@BPM 格式错误: {line}") { Line = lineNum }); + } + break; + + case "@VER": + Version = int.Parse(value); + 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": case "@TIL": + break; + + case "@SPDMOD": + { + var parts = value.Split('\t', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length >= 2 + && TryParseUgcMeasureTick(parts[0], out var meas, out var tick) + && decimal.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var mult)) + { + chart.SflList.Add((meas + new Rational(tick, RSL), Rational.Zero, mult)); + } + else + alerts.Add(new Alert(Warning, $"@SPDMOD 格式错误: {line}") { Line = lineNum }); + break; + } + + default: + alerts.Add(new Alert(Info, $"未知头部标签: {tag}") { Line = lineNum }); + break; + } + } + + /** UGC 时刻字符串 measure'tick(@BPM、@SPDMOD、音符行 #m't 共用)。 */ + private static bool TryParseUgcMeasureTick(string measureTick, out int measure, out int tick) + { + measure = 0; + tick = 0; + measureTick = measureTick.Trim(); + var ap = measureTick.IndexOf('\''); + if (ap <= 0) + return false; + + return int.TryParse(measureTick[..ap], NumberStyles.Integer, CultureInfo.InvariantCulture, out measure) + && int.TryParse(measureTick[(ap + 1)..], NumberStyles.Integer, CultureInfo.InvariantCulture, out tick); + } + + private int ParseNoteLine(string[] lines, int idx, ChuChart chart, List alerts) + { + var line = lines[idx]; + var lineNum = idx + 1; + + // skip comment lines and inline directives + if (line.StartsWith('\'') || line.StartsWith('@')) + 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('#'); + if (hashIdx < 0) + { + alerts.Add(new Alert(Warning, $"音符行前缀格式错误: {line}") { Line = lineNum }); + return idx; + } + + if (!TryParseUgcMeasureTick(prefix[(hashIdx + 1)..], out var measure, out var tick)) + { + alerts.Add(new Alert(Warning, $"无法解析 measure'tick: {line}") { Line = lineNum }); + return idx; + } + + if (string.IsNullOrEmpty(code)) + { + alerts.Add(new Alert(Warning, $"音符行为空: {line}") { Line = lineNum }); + return idx; + } + + ChuNote? note = new ChuNote + { + Time = measure + new Rational(tick, RSL), + }; + + var typeChar = code[0]; + + switch (typeChar) + { + case 't': + ParseTapNote(code, note, alerts, lineNum, chart, false); + break; + case 'x': + ParseTapNote(code, note, alerts, lineNum, chart, true); + break; + + case 'h': + idx = ParseHoldNote(false, lines, idx, code, note, alerts, chart); + break; + case 'H': // Air Hold + idx = ParseHoldNote(true, lines, idx, code, note, alerts, chart); + break; + + case 's': + idx = ParseSlideNote(false, lines, idx, code, note, alerts, chart); + note = null; // ParseSlideNote中,会自己构造note并自己添加进chart。因此这里默认的统一note不应被添加进chart。 + break; + case 'S': // Air Slide + idx = ParseSlideNote(true, lines, idx, code, note, alerts, chart); + note = null; + break; + + case 'a': + ParseAirNote(code, note, alerts, lineNum, chart); + break; + case 'C': // Air Crush + idx = ParseAirCrushNote(lines, idx, code, note, alerts, chart); + note = null; + break; + + case 'f': + note.Type = "FLK"; + ParseCellWidth(code, 1, note, alerts, lineNum, chart); + if (code.Length > 3) note.Tag = code[3..]; + break; + + case 'c': // Umiguri的CLICK音符,疑似在C2s中是没有对应的。这个音符没有Cell和Width,除了Type什么都没有,所以直接存下来就可以了。 + note.Type = "CLICK"; + break; + + case 'd': + note.Type = "MNE"; + ParseCellWidth(code, 1, note, alerts, lineNum, chart); + break; + + default: + alerts.Add(new Alert(Warning, $"未知的音符类型前缀 '{typeChar}': {line}", note.Time, (double)chart.ToSecond(note.Time), lineNum, line)); + // 如果后面跟的是跟随行(子ノーツ)而非主行(親ノーツ)的话,把它们全部消耗掉 + while (idx + 1 < lines.Length) + { + var nextLine = lines[idx + 1].Trim(); + if (!TryParseFollowerLine(nextLine, out _, out _, out _, out _, out _, false)) + { + if (nextLine.StartsWith('\'') || nextLine.StartsWith('@')) { idx++; continue; } + break; + } + idx++; + } + return idx; + } + + if (note != null) chart.Notes.Add(note); + return idx; + } + + private void ParseTapNote(string code, ChuNote note, List alerts, int lineNum, ChuChart chart, bool isCHR) + { + note.Type = "TAP"; + ParseCellWidth(code, 1, note, alerts, lineNum, chart); + if (isCHR) + { + note.Type = "CHR"; + var extraRaw = code.Length > 3 ? code[3..] : ""; + note.Tag = U2C_ChrExtras.GetValueOrDefault(extraRaw, extraRaw); + } + } + + private void ParseHeightAndColor(ChuNote n, string str, List alerts, int lineNum, string noteType="") // 需要传入noteType是因为,不同版本的不同类型note在实现上还略有区别的。 + { + if (string.IsNullOrEmpty(str)) return; + if (str.Length == 1 && noteType is "H" or "S" && Version < 6) + { // 老版本的:H和:S,单独的一位是height而不是颜色,因此不能套用下面的逻辑 + if (TryH36ToI(str, out var height)) n.Height = U2C_Height(height); + else alerts.Add(new Alert(Warning, "解析Air系列音符的高度属性失败!", n.Time, null, lineNum, FormatNoteRef(n, str))); + return; + } + + // 先尝试解析interval + var posOfComma = str.IndexOf(','); + if (posOfComma >= 0) + { + var intervalStr = str[(posOfComma+1)..]; + str = str[..posOfComma]; + if (intervalStr == "$") n.CrushInterval = 38400; + else if (int.TryParse(intervalStr, out var interval)) n.CrushInterval = interval; + else alerts.Add(new Alert(Warning, "解析Air-Crush的interval属性失败!", n.Time, null, lineNum, FormatNoteRef(n, str))); + } + + // 剩的部分都满足:最后一位是颜色,前面是高度 + if (str.Length > 0) + { // 解析颜色 + var rawColorStr = str.Last().ToString(); + n.Tag = U2C_AirColor.GetValueOrDefault(rawColorStr, rawColorStr); + } + if (str.Length > 1) + { // 解析高度 + var heightStr = str[..^1]; + if (TryH36ToI(str[..^1], out var height)) + n.Height = U2C_Height(heightStr.Length == 1 ? height : height / 10m); // 一位时不用除以10,两位时需要除以10 + else alerts.Add(new Alert(Warning, "解析Air系列音符的高度属性失败!", n.Time, null, lineNum, FormatNoteRef(n, str))); + } + } + + private int ParseHoldNote(bool isAirHold, string[] lines, int idx, string code, ChuNote note, List alerts, ChuChart chart) + { + note.Type = isAirHold ? "AHD" : "HLD"; + ParseCellWidth(code, 1, note, alerts, idx + 1, chart); + if (isAirHold) ParseHeightAndColor(note, code[3..], alerts, idx+1, "H"); + + bool foundFirst = false; + while (idx + 1 < lines.Length) + { + var nextLine = lines[idx + 1].Trim(); + if (!TryParseFollowerLine(nextLine, out var marker, out var endTick, out _, out _, out _, false)) + { + if (nextLine.StartsWith('\'') || nextLine.StartsWith('@')) { idx++; continue; } + break; + } + + note.Duration = new Rational(endTick, RSL); + if (isAirHold && marker == "c") note.Type = "AHX"; // 可能是对应于UMIGURI文档中的 AirHold的 AIR-ACTION 无し终点 + idx++; + foundFirst = true; + } + + if (!foundFirst) + alerts.Add(new Alert(Warning, $"HLD 音符缺少时长跟随行") { Line = idx + 1, RelevantNote = lines[idx] }); + return idx; + } + + private int ParseSlideNote(bool isAirSlide, string[] lines, int idx, string code, ChuNote previousNote, List alerts, ChuChart chart) + { + // 注:一开始从外面传进来的previousNote,最后并不会被添加进chart里,只是作为第一段的起点参照而已。 + var startTime = previousNote.Time; + ParseCellWidth(code, 1, previousNote, alerts, idx + 1, chart); + if (isAirSlide) ParseHeightAndColor(previousNote, code[3..], alerts, idx+1, "S"); + previousNote.EndCell = previousNote.Cell; + previousNote.EndWidth = previousNote.Width; + previousNote.EndHeight = previousNote.Height; + + bool foundFirst = false; + while (idx + 1 < lines.Length) + { // 循环处理所有的跟随行。idx始终指向上一条已经处理完的行。 + var nextLine = lines[idx + 1].Trim(); + if (!TryParseFollowerLine(nextLine, out var marker, out var endTick, out var endCell, out var endWidth, out var endHeight, true)) + { + if (nextLine.StartsWith('\'') || nextLine.StartsWith('@')) { idx++; continue; } + break; + } + + var type = isAirSlide ? (marker == "s" ? "ASD" : "ASC") : (marker == "s" ? "SLD" : "SLC"); + + var segmentEnd = startTime + new Rational(endTick, RSL); + var note = new ChuNote + { + Type = type, Time = previousNote.EndTime, + Cell = previousNote.EndCell, Width = previousNote.EndWidth, Height = previousNote.EndHeight, + Duration = segmentEnd - previousNote.EndTime, Tag = previousNote.Tag, + EndCell = endCell!.Value, EndWidth = endWidth!.Value, + EndHeight = endHeight != null ? U2C_Height(endHeight.Value) : previousNote.EndHeight, + Previous = foundFirst ? previousNote : null, + }; + + chart.Notes.Add(note); + previousNote = note; + idx++; + foundFirst = true; + } + + if (!foundFirst) + alerts.Add(new Alert(Warning, $"SLD 音符缺少时长跟随行") { Line = idx + 1, RelevantNote = lines[idx] }); + + return idx; + } + + private static bool TryParseFollowerLine(string line, out string marker, out int endTick, out int? endCell, out int? endWidth, out decimal? height, bool requireEndCellWidth) + { + endTick = 0; + endCell = null; + endWidth = null; + marker = ""; + height = null; + + if (!line.StartsWith('#')) return false; + + // support both >s (SLD) and >c (SLC) follower lines + int sepIdx = line.IndexOfAny(['>', ':']); + if (sepIdx < 1) return false; + marker = line[sepIdx+1].ToString(); + int markerLen = 2; + + var endTickStr = line[1..sepIdx]; + if (!int.TryParse(endTickStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out endTick)) return false; + + var afterMarker = line[(sepIdx + markerLen)..]; + if (afterMarker.Length >= 2) + { + endCell = HToI(afterMarker[0]); + endWidth = HToI(afterMarker[1]); + } + else if (requireEndCellWidth) return false; + + if (afterMarker.Length > 2) + { + var heightStr = afterMarker[2..]; + if (TryH36ToI(heightStr, out var r)) height = heightStr.Length == 1 ? r : r / 10m; + } + + return true; + } + + private void ParseCellWidth(string code, int startIdx, ChuNote note, List alerts, int lineNum, ChuChart chart) + { + if (code.Length > startIdx) + { + note.Cell = HToI(code[startIdx]); + if (code.Length > startIdx + 1) + note.Width = HToI(code[startIdx + 1]); + else + alerts.Add(new Alert(Warning, $"音符缺少 width: {code}", note.Time, (double)chart.ToSecond(note.Time), lineNum, FormatNoteRef(note, code))); + } + else + { + alerts.Add(new Alert(Warning, $"音符缺少 cell 和 width: {code}", note.Time, (double)chart.ToSecond(note.Time), lineNum, FormatNoteRef(note, code))); + } + } + + private void ParseAirNote(string code, ChuNote note, List alerts, int lineNum, ChuChart chart) + { + if (code.Length < 5) + { + alerts.Add(new Alert(Warning, $"AIR 音符代码过短: {code}") { Line = lineNum }); + note.Type = "AIR"; + return; + } + + ParseCellWidth(code, 1, note, alerts, lineNum, chart); + var mainPart = code[3..]; + + if (mainPart.Length < 2) + { + alerts.Add(new Alert(Warning, $"AIR 音符方向代码过短: {code}") { Line = lineNum }); + note.Type = "AIR"; + return; + } + + var dir = mainPart[..2]; + if (U2C_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, code) }); + } + ParseHeightAndColor(note, mainPart[2..], alerts, lineNum, "a"); + } + + private int ParseAirCrushNote(string[] lines, int idx, string code, ChuNote note, List alerts, ChuChart chart) + { + note.Type = "ALD"; + ParseCellWidth(code, 1, note, alerts, idx + 1, chart); + if (code.Length <= 3) alerts.Add(new Alert(Warning, "AirCrush缺少参数!", note.Time, (double)chart.ToSecond(note.Time), idx+1, lines[idx])); + else ParseHeightAndColor(note, code[3..], alerts, idx+1, "C"); + + bool foundFirst = false; + bool intervalSet = Version >= 8; + while (idx + 1 < lines.Length) + { + var nextLine = lines[idx + 1].Trim(); + if (!TryParseFollowerLine(nextLine, out var marker, out var endTick, out var endCell, out var endWidth, out var endHeight, Version >= 8)) + { + if (nextLine.StartsWith('\'') || nextLine.StartsWith('@')) { idx++; continue; } + break; + } + + if (Version >= 8 && marker != "c") + alerts.Add(new Alert(Warning, $"Air-Crush(v8)子行标记应为 'c',实际为 '{marker}'", note.Time, (double)chart.ToSecond(note.Time), idx + 1, nextLine)); + + if (Version <= 6 && !intervalSet && marker == "s") + { + note.CrushInterval = endTick; + intervalSet = true; + } + + note.Duration = new Rational(endTick, RSL); + if (endCell != null) note.EndCell = endCell.Value; + if (endWidth != null) note.EndWidth = endWidth.Value; + if (endHeight != null) note.EndHeight = U2C_Height(endHeight.Value); + + idx++; + foundFirst = true; + } + chart.Notes.Add(note); + + if (!foundFirst) + alerts.Add(new Alert(Warning, $"air-crush 音符缺少时长跟随行") { Line = idx + 1, RelevantNote = lines[idx] }); + return idx; + } + + // ReSharper disable once UnusedParameter.Local + private string FormatNoteRef(ChuNote note, string code) + { + var (m, o) = Utils.BarAndTick(note.Time, RSL); + return $"#{m}'{o}:{code}"; + } +} + diff --git a/parser/mai/SimaiParser.cs b/parser/mai/SimaiParser.cs index cc0a56e..6b521de 100644 --- a/parser/mai/SimaiParser.cs +++ b/parser/mai/SimaiParser.cs @@ -1,4 +1,5 @@ -using System.Text.RegularExpressions; +using System.Globalization; +using System.Text.RegularExpressions; using Antlr4.Runtime; using Antlr4.Runtime.Misc; using Antlr4.Runtime.Tree; @@ -409,7 +410,7 @@ public sealed override object VisitNote(P.NoteContext context) public sealed override object VisitNumber(P.NumberContext context) { - return decimal.Parse(context.GetText()); + return decimal.Parse(context.GetText(), CultureInfo.InvariantCulture); } private void ApplyModifiers(P.ModifiersContext[] modifiersList, Note note, bool clearExtraArr = true) diff --git a/tests/chu/ChuTests.cs b/tests/chu/ChuTests.cs new file mode 100644 index 0000000..a10407f --- /dev/null +++ b/tests/chu/ChuTests.cs @@ -0,0 +1,163 @@ +using MuConvert.chu; +using MuConvert.utils; +using Rationals; + +namespace MuConvert.Tests.chu; + +public class ChuTests +{ + private static readonly Rational Tol768 = new(1, 768); + private static readonly Rational Tol384 = new(1, 384); + + private static string TestsetDir => Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "chu", "testset"); + private static string OfficialDir => Path.Combine(TestsetDir, "官谱"); + private static string CustomDir => Path.Combine(TestsetDir, "自制谱"); + + public static IEnumerable OfficialC2sChartPaths() + { + return Directory.EnumerateFiles(OfficialDir, "*.c2s", SearchOption.AllDirectories) + .OrderBy(p => p, StringComparer.OrdinalIgnoreCase).Select(path => (object[])[path]); + } + + public static IEnumerable CustomUgcChartPaths() + { + return Directory.EnumerateFiles(CustomDir, "*.ugc", SearchOption.AllDirectories) + .OrderBy(p => p, StringComparer.OrdinalIgnoreCase).Select(path => (object[])[path]); + } + + [Theory] + [MemberData(nameof(OfficialC2sChartPaths))] + public void C2sRoundTrip(string 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); + AssertNotesEqual(chart.Notes, reparsed.Notes); + } + + private static void AssertNotesEqual(IReadOnlyList expected_, IReadOnlyList actual_) + { + const string EOF = ""; + List expected = expected_.ToList(); + List actual = actual_.ToList(); + + for (var i = 0; i < Math.Max(expected.Count, actual.Count); i++) + { + bool result; + if (i >= expected.Count || i >= actual.Count) result = false; + else + { + result = CompareNote(expected[i], actual[i]); + if (!result) + { + // 尝试同一时刻的其他行有无相同的,如果有,交换之 + var j = i + 1; + while (j < expected.Count && expected[j].Time == actual[i].Time) + { + if (CompareNote(expected[j], actual[i])) + { + (expected[j], expected[i]) = (expected[i], expected[j]); + result = true; + break; + } + j++; + } + } + } + + if (!result) { + Assert.Fail( + $"Note mismatch at index {i}:{Environment.NewLine}" + + $"EXPECTED: {(i < expected.Count ? FormatNote(expected[i]) : EOF)}{Environment.NewLine}" + + $"ACTUAL : {(i < actual.Count ? FormatNote(actual[i]) : EOF)}"); + } + } + } + + /// + /// 比较两个音符是否实质等同;时间与时长等字段可命中宽容规则(见测试类内常量与分支注释)。 + /// + public static bool CompareNote(ChuNote expected, ChuNote actual) + { + if (expected.Type != actual.Type) return false; + if (!TimesEquivalent(expected.Time, actual.Time)) return false; + if (!DurationsEquivalent(expected, actual)) return false; + if (expected.Cell != actual.Cell || expected.Width != actual.Width) return false; + if (expected.EndCell != actual.EndCell || expected.EndWidth != actual.EndWidth) return false; + if (Math.Abs(expected.Height - actual.Height) > 0.05m || Math.Abs(expected.EndHeight - actual.EndHeight) > 0.05m) return false; + if (expected.CrushInterval != actual.CrushInterval) return false; + if (!TagsEquivalent(expected, actual)) return false; + if (expected.TargetNote != actual.TargetNote) return false; + return true; + } + + /// 规则 (a):time 相差 ≤ 1/768 视为相等。 + private static bool TimesEquivalent(Rational a, Rational b) => (a - b).Abs() <= Tol768; + + /// + /// 规则 (b):|Δduration| ≤ 1/768,或(|Δduration| ≤ 1/384 且 |ΔendTime| ≤ 1/768)时视为 duration 语义相等。 + /// + private static bool DurationsEquivalent(ChuNote e, ChuNote a) + { + var dd = (e.Duration - a.Duration).Abs().CanonicalForm; + return dd <= Tol768 || (dd <= Tol384 && (e.EndTime - a.EndTime).Abs() <= Tol768); + } + + /// 规则 (c)(d):广义 Air 的 DEF/空串;FLK 的 A/L。 + private static bool TagsEquivalent(ChuNote e, ChuNote a) + { + if (e.Tag == a.Tag) return true; + if (e.Type == "ALD") return true; // C2S的ALD行,根据观测,是不支持颜色tag的。因此不要比较 + if (ChuUtils.IsGeneralizedAir(e)) + { + if ((e.Tag == "DEF" && a.Tag == "") || (e.Tag == "" && a.Tag == "DEF")) + return true; + } + if (e.Type == "FLK") + { + if ((e.Tag == "A" && a.Tag == "L") || (e.Tag == "L" && a.Tag == "A")) + return true; + } + return false; + } + + private static string FormatNote(ChuNote n) => + $"{n.Type} t={n.Time} start=({n.Cell},{n.Width}) dur={n.Duration} end=({n.EndCell},{n.EndWidth}) " + + $"tag={n.Tag} tgt={n.TargetNote} h=({n.Height},{n.EndHeight}) crush={n.CrushInterval}"; + + [Theory] + [MemberData(nameof(CustomUgcChartPaths))] + public void UgcToC2sViaGenerator(string 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); + AssertNotesEqual(ugc.Notes.Where(n => n.Type != "CLICK").ToList(), c2sChart.Notes); + } + + [Theory] + [MemberData(nameof(OfficialC2sChartPaths))] + public void C2sToUgcViaGenerator(string 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); + AssertNotesEqual(c2s.Notes, ugcReparsed.Notes.Where(n => n.Type != "CLICK").ToList()); + } +} 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/\345\256\230\350\260\261/Virtual To Live/2098_03.c2s" "b/tests/chu/testset/\345\256\230\350\260\261/Virtual To Live/2098_03.c2s" new file mode 100644 index 0000000..bcaddd7 --- /dev/null +++ "b/tests/chu/testset/\345\256\230\350\260\261/Virtual To Live/2098_03.c2s" @@ -0,0 +1,1599 @@ +VERSION 1.10.01 1.10.01 +MUSIC 0 +SEQUENCEID 0 +DIFFICULT 00 +LEVEL 0.0 +CREATOR うさぎランドリー +BPM_DEF 156.000 156.000 156.000 156.000 +MET_DEF 4 4 +RESOLUTION 384 +CLK_DEF 384 +PROGJUDGE_BPM 240.000 +PROGJUDGE_AER 0.999 +TUTORIAL 0 + +BPM 0 0 156.000 +MET 0 0 4 4 + +CHR 4 288 0 4 UP +CHR 4 288 12 4 UP +HLD 5 0 0 4 2208 +TAP 5 0 6 8 +ASD 5 0 6 8 TAP 5 288 9 2 5 DEF +TAP 6 0 6 8 +ASD 6 0 6 8 TAP 5 288 9 2 5 DEF +TAP 7 0 6 8 +ASD 7 0 6 8 TAP 5 288 9 2 5 DEF +TAP 8 0 6 8 +ASD 8 0 6 8 TAP 5 288 9 2 5 DEF +TAP 9 0 6 8 +ASD 9 0 6 8 TAP 5 288 9 2 5 DEF +TAP 10 0 6 8 +ASD 10 0 6 8 TAP 5 288 9 2 5 DEF +CHR 11 0 0 8 UP +ASC 11 0 0 8 CHR 5 384 7 2 5 DEF +CHR 11 0 8 8 UP +ASC 11 0 8 8 CHR 5 384 7 2 5 DEF +ALD 12 0 0 8 1 5 4 0 8 1 +ALD 12 0 8 8 1 5 4 8 8 1 +CHR 13 0 0 6 UP +TAP 13 0 6 4 +TAP 13 96 6 4 +CHR 13 144 10 6 UP +TAP 13 192 6 4 +CHR 13 288 0 6 UP +TAP 13 288 6 4 +TAP 14 0 6 4 +CHR 14 0 13 3 UP +TAP 14 48 6 4 +CHR 14 48 10 3 UP +CHR 14 96 3 3 UP +TAP 14 96 6 4 +CHR 14 144 0 3 UP +TAP 14 144 6 4 +FLK 14 192 0 3 L +HLD 14 192 12 4 96 +FLK 14 208 3 3 L +FLK 14 224 6 3 L +FLK 14 240 9 3 L +FLK 14 256 6 3 L +FLK 14 272 3 3 L +FLK 14 288 0 3 L +AIR 14 288 12 4 HLD DEF +CHR 15 0 0 4 UP +AHD 15 0 0 4 CHR 96 DEF +CHR 15 0 12 4 UP +TAP 15 72 5 6 +TAP 15 96 0 4 +TAP 15 144 5 6 +CHR 15 192 0 4 UP +AHD 15 192 0 4 CHR 96 DEF +CHR 15 192 12 4 UP +TAP 15 264 5 6 +TAP 15 288 0 4 +TAP 15 336 5 6 +CHR 16 0 0 4 UP +AHD 16 0 0 4 CHR 96 DEF +CHR 16 0 12 4 UP +TAP 16 72 5 6 +TAP 16 96 0 4 +TAP 16 144 5 6 +TAP 16 192 0 4 +TAP 16 240 3 4 +TAP 16 288 6 4 +TAP 16 336 9 4 +CHR 17 0 0 4 UP +CHR 17 0 12 4 UP +AHD 17 0 12 4 CHR 96 DEF +TAP 17 72 5 6 +TAP 17 96 12 4 +TAP 17 144 5 6 +CHR 17 192 0 4 UP +CHR 17 192 12 4 UP +AHD 17 192 12 4 CHR 96 DEF +TAP 17 264 5 6 +TAP 17 288 12 4 +TAP 17 336 5 6 +CHR 18 0 0 4 UP +CHR 18 0 12 4 UP +AHD 18 0 12 4 CHR 96 DEF +TAP 18 72 5 6 +TAP 18 96 12 4 +TAP 18 144 5 6 +TAP 18 192 0 4 +TAP 18 192 12 4 +TAP 18 240 4 4 +TAP 18 240 8 4 +TAP 18 288 0 4 +AIR 18 288 0 4 TAP DEF +TAP 18 288 12 4 +AIR 18 288 12 4 TAP DEF +TAP 19 0 0 4 +AHD 19 0 0 4 TAP 96 DEF +SLC 19 0 12 4 4 11 4 SLD +SLC 19 4 11 4 5 10 4 SLD +SLC 19 9 10 4 5 9 4 SLD +SLC 19 14 9 4 7 8 4 SLD +SLC 19 21 8 4 9 7 4 SLD +SLC 19 30 7 4 14 6 4 SLD +SLC 19 44 6 4 4 6 4 SLD +SLC 19 48 6 4 4 6 4 SLD +SLC 19 52 6 4 14 7 4 SLD +SLC 19 66 7 4 9 8 4 SLD +SLC 19 75 8 4 7 9 4 SLD +SLC 19 82 9 4 5 10 4 SLD +SLC 19 87 10 4 5 11 4 SLD +SLD 19 92 11 4 4 12 4 SLD +SLC 19 96 0 4 4 1 4 SLD +AHD 19 96 12 4 SLD 96 DEF +SLC 19 100 1 4 5 2 4 SLD +SLC 19 105 2 4 5 3 4 SLD +SLC 19 110 3 4 7 4 4 SLD +SLC 19 117 4 4 9 5 4 SLD +SLC 19 126 5 4 14 6 4 SLD +SLC 19 140 6 4 4 6 4 SLD +SLC 19 144 6 4 4 6 4 SLD +SLC 19 148 6 4 14 5 4 SLD +SLC 19 162 5 4 9 4 4 SLD +SLC 19 171 4 4 7 3 4 SLD +SLC 19 178 3 4 5 2 4 SLD +SLC 19 183 2 4 5 1 4 SLD +SLC 19 188 1 4 4 0 4 SLD +SLC 19 192 0 4 96 0 4 SLD +TAP 19 192 12 4 +SLD 19 288 0 4 96 4 4 SLD +TAP 19 288 12 4 +TAP 19 336 10 4 +SLD 20 0 8 4 96 12 4 SLD +TAP 20 48 0 4 +TAP 20 96 2 4 +SLD 20 96 12 4 192 12 4 SLD +SLC 20 144 4 4 15 5 4 SLD +SLC 20 159 5 4 18 6 4 SLD +SLC 20 177 6 4 23 7 4 SLD +SLC 20 200 7 4 30 8 4 SLD +SLD 20 230 8 4 10 8 4 SLD +FLK 20 240 0 8 L +FLK 20 288 4 8 L +HLD 21 0 2 3 96 +HLD 21 0 11 3 96 +TAP 21 96 8 3 +TAP 21 120 5 3 +TAP 21 144 8 3 +TAP 21 192 0 4 +TAP 21 192 12 4 +TAP 21 264 2 4 +TAP 21 264 10 4 +TAP 21 336 5 3 +TAP 21 336 8 3 +TAP 22 0 7 2 +AHD 22 0 7 2 TAP 192 DEF +TAP 23 0 2 3 +TAP 23 0 9 2 +TAP 23 48 9 2 +TAP 23 96 2 3 +TAP 23 96 7 2 +TAP 23 144 5 2 +TAP 23 192 2 3 +HLD 23 192 7 2 96 +TAP 23 288 2 3 +TAP 23 336 7 2 +TAP 24 0 2 3 +TAP 24 0 5 2 +TAP 24 48 7 2 +TAP 24 96 2 3 +TAP 24 96 9 2 +HLD 24 192 2 3 96 +HLD 24 192 7 2 96 +FLK 24 288 9 3 L +TAP 24 336 5 2 +TAP 25 0 5 2 +TAP 25 0 11 3 +TAP 25 48 7 2 +TAP 25 96 9 2 +TAP 25 96 11 3 +TAP 25 144 7 2 +HLD 25 192 7 2 96 +TAP 25 192 11 3 +TAP 25 288 11 3 +TAP 25 336 7 2 +TAP 26 0 5 2 +TAP 26 0 11 3 +TAP 26 48 5 2 +TAP 26 96 7 2 +TAP 26 96 11 3 +TAP 26 144 9 2 +HLD 26 192 7 2 96 +HLD 26 192 11 3 96 +FLK 26 288 4 3 L +TAP 26 336 9 3 +TAP 27 0 3 3 +TAP 27 0 7 2 +TAP 27 48 5 3 +TAP 27 96 7 2 +TAP 27 96 10 3 +TAP 27 144 8 3 +TAP 27 192 3 3 +TAP 27 192 7 2 +TAP 27 288 7 2 +TAP 27 288 10 3 +TAP 27 336 8 3 +TAP 28 0 3 3 +TAP 28 0 7 2 +TAP 28 48 10 3 +TAP 28 96 3 3 +TAP 28 96 7 2 +TAP 28 144 5 3 +TAP 28 192 7 2 +TAP 28 192 10 3 +TAP 28 288 3 3 +TAP 28 288 7 2 +TAP 29 0 7 2 +SLC 29 0 13 3 23 12 3 SLD +SLC 29 23 12 3 27 11 3 SLD +SLC 29 50 11 3 35 10 3 SLD +SLD 29 85 10 3 11 10 3 SLD +TAP 29 96 7 2 +TAP 29 144 10 3 +SLC 29 192 0 3 23 1 3 SLD +TAP 29 192 7 2 +SLC 29 215 1 3 27 2 3 SLD +SLC 29 242 2 3 35 3 3 SLD +SLD 29 277 3 3 11 3 3 SLD +TAP 29 288 7 2 +TAP 29 336 3 3 +SLC 30 0 6 4 15 4 4 SLD +SLC 30 0 12 4 8 11 4 SLD +SLC 30 8 11 4 8 10 4 SLD +SLC 30 15 4 4 10 3 4 SLD +SLC 30 16 10 4 10 9 4 SLD +SLC 30 25 3 4 12 2 4 SLD +SLC 30 26 9 4 13 8 4 SLD +SLC 30 37 2 4 17 1 4 SLD +SLC 30 39 8 4 18 7 4 SLD +SLC 30 54 1 4 30 0 4 SLD +SLC 30 57 7 4 28 6 4 SLD +SLC 30 84 0 4 12 0 4 SLD +SLC 30 85 6 4 11 6 4 SLD +SLC 30 96 0 4 11 0 4 SLD +SLC 30 96 6 4 11 6 4 SLD +SLC 30 107 0 4 23 1 4 SLD +SLC 30 107 6 4 28 7 4 SLD +SLC 30 130 1 4 13 2 4 SLD +SLC 30 135 7 4 18 8 4 SLD +SLC 30 143 2 4 10 3 4 SLD +SLC 30 153 3 4 9 4 4 SLD +SLC 30 153 8 4 13 9 4 SLD +SLC 30 162 4 4 16 6 4 SLD +SLC 30 166 9 4 10 10 4 SLD +SLC 30 176 10 4 8 11 4 SLD +SLD 30 178 6 4 14 8 4 SLD +SLC 30 184 11 4 8 12 4 SLD +AHD 30 192 8 4 SLD 96 DEF +SLD 30 192 12 4 96 12 4 SLD +FLK 30 288 4 8 L +ADL 30 288 4 8 FLK DEF +TAP 31 0 9 6 +TAP 31 48 4 4 +TAP 31 96 2 4 +AUL 31 96 2 4 TAP DEF +TAP 31 144 8 4 +TAP 31 192 10 4 +AUR 31 192 10 4 TAP DEF +TAP 31 288 0 4 +TAP 31 288 6 4 +TAP 31 336 0 4 +TAP 31 336 6 4 +TAP 32 0 3 4 +TAP 32 0 9 4 +TAP 32 48 3 4 +TAP 32 48 9 4 +TAP 32 96 6 4 +AUL 32 96 6 4 TAP DEF +TAP 32 96 12 4 +AIR 32 96 12 4 TAP DEF +TAP 32 192 0 4 +SLC 32 192 0 4 6 0 5 SLD +ADW 32 192 0 4 TAP DEF +TAP 32 192 12 4 +SLC 32 192 12 4 6 11 5 SLD +ADW 32 192 12 4 TAP DEF +SLC 32 198 0 5 6 0 4 SLD +SLC 32 198 11 5 6 12 4 SLD +SLC 32 204 0 4 6 0 5 SLD +SLC 32 204 12 4 6 11 5 SLD +SLC 32 210 0 5 6 0 4 SLD +SLC 32 210 11 5 6 12 4 SLD +SLC 32 216 0 4 6 0 5 SLD +SLC 32 216 12 4 6 11 5 SLD +SLC 32 222 0 5 6 0 4 SLD +SLC 32 222 11 5 6 12 4 SLD +SLC 32 228 0 4 6 0 5 SLD +SLC 32 228 12 4 6 11 5 SLD +SLC 32 234 0 5 6 0 4 SLD +SLC 32 234 11 5 6 12 4 SLD +SLC 32 240 0 4 6 0 5 SLD +SLC 32 240 12 4 6 11 5 SLD +SLC 32 246 0 5 6 0 4 SLD +SLC 32 246 11 5 6 12 4 SLD +SLC 32 252 0 4 6 0 5 SLD +SLC 32 252 12 4 6 11 5 SLD +SLC 32 258 0 5 6 0 4 SLD +SLC 32 258 11 5 6 12 4 SLD +SLC 32 264 0 4 6 0 5 SLD +SLC 32 264 12 4 6 11 5 SLD +SLC 32 270 0 5 6 0 4 SLD +SLC 32 270 11 5 6 12 4 SLD +SLC 32 276 0 4 6 0 5 SLD +SLC 32 276 12 4 6 11 5 SLD +SLD 32 282 0 5 6 0 4 SLD +SLD 32 282 11 5 6 12 4 SLD +TAP 32 336 2 4 +SLC 33 0 2 4 192 2 4 SLD +TAP 33 48 6 4 +SLC 33 96 6 4 96 6 4 SLD +TAP 33 144 10 4 +SLC 33 192 2 4 24 10 4 SLD +SLC 33 192 6 4 24 10 4 SLD +HLD 33 192 10 4 384 +SLD 33 216 10 4 360 10 4 SLD +SLD 33 216 10 4 360 10 4 SLD +SLC 33 288 5 4 24 10 4 SLD +SLC 33 312 10 4 263 10 4 SLD +SLC 34 0 3 4 24 9 4 SLD +SLD 34 24 9 4 168 9 4 SLD +SLC 34 96 1 4 24 8 6 SLD +SLD 34 120 8 6 72 8 6 SLD +SLD 34 191 10 4 1 8 4 SLD +HLD 34 192 0 4 96 +AHD 34 192 8 4 SLD 96 DEF +AIR 34 192 8 6 SLD DEF +AIR 34 192 9 4 SLD DEF +AIR 34 192 10 4 SLD DEF +AIR 34 192 10 4 HLD DEF +AIR 34 192 10 4 SLD DEF +FLK 34 288 8 8 L +ADR 34 288 8 8 FLK DEF +TAP 35 0 1 6 +TAP 35 48 8 4 +TAP 35 96 10 4 +AUR 35 96 10 4 TAP DEF +TAP 35 144 4 4 +TAP 35 192 2 4 +AUL 35 192 2 4 TAP DEF +TAP 35 288 6 4 +TAP 35 288 12 4 +TAP 35 336 6 4 +TAP 35 336 12 4 +TAP 36 0 3 4 +TAP 36 0 9 4 +TAP 36 48 3 4 +TAP 36 48 9 4 +TAP 36 96 0 4 +AUR 36 96 0 4 TAP DEF +TAP 36 96 6 4 +AUR 36 96 6 4 TAP DEF +TAP 36 192 6 4 +TAP 36 192 12 4 +TAP 36 288 0 4 +TAP 36 288 6 4 +SLC 37 0 6 4 96 0 4 SLD +SLD 37 0 12 4 96 6 4 SLD +SLD 37 96 0 4 192 0 4 SLD +AHD 37 96 6 4 SLD 96 DEF +SLD 37 192 6 4 96 12 4 SLD +ASD 37 288 0 4 SLD 5 96 0 4 5 DEF +ASD 37 288 12 4 SLD 5 96 12 4 5 DEF +ASC 38 0 0 4 ASD 5 20 1 4 5 DEF +ASC 38 0 12 4 ASD 5 20 11 4 5 DEF +ASC 38 20 1 4 ASC 5 24 2 4 5 DEF +ASC 38 20 11 4 ASC 5 24 10 4 5 DEF +ASC 38 44 2 4 ASC 5 31 3 4 5 DEF +ASC 38 44 10 4 ASC 5 31 9 4 5 DEF +ASC 38 75 3 4 ASC 5 42 4 4 5 DEF +ASC 38 75 9 4 ASC 5 42 8 4 5 DEF +ASC 38 117 4 4 ASC 5 57 5 4 5 DEF +ASC 38 117 8 4 ASC 5 57 7 4 5 DEF +ASD 38 174 5 4 ASC 5 114 5 4 5 DEF +ASD 38 174 7 4 ASC 5 114 7 4 5 DEF +ALD 38 288 4 4 1 5 2 4 4 1 +ALD 38 288 8 4 1 5 2 8 4 1 +SLC 39 0 6 4 7 5 4 SLD +SLC 39 0 12 4 7 11 4 SLD +SLC 39 7 5 4 8 4 4 SLD +SLC 39 7 11 4 8 10 4 SLD +SLC 39 15 4 4 9 3 4 SLD +SLC 39 15 10 4 9 9 4 SLD +SLC 39 24 3 4 13 2 4 SLD +SLC 39 24 9 4 13 8 4 SLD +SLC 39 37 2 4 15 1 4 SLD +SLC 39 37 8 4 15 7 4 SLD +SLC 39 52 1 4 28 0 4 SLD +SLC 39 52 7 4 28 6 4 SLD +SLD 39 80 0 4 208 0 4 SLD +SLD 39 80 6 4 16 6 4 SLD +TAP 39 144 6 4 +SLD 39 192 6 4 96 7 2 SLD +AHD 39 288 0 4 SLD 96 DEF +FLK 39 288 4 3 L +AUR 39 288 4 3 FLK DEF +AHD 39 288 7 2 SLD 96 DEF +FLK 39 288 9 3 L +AUR 39 288 9 3 FLK DEF +SLC 40 0 0 4 7 1 4 SLD +SLC 40 0 6 4 7 7 4 SLD +SLC 40 7 1 4 8 2 4 SLD +SLC 40 7 7 4 8 8 4 SLD +SLC 40 15 2 4 9 3 4 SLD +SLC 40 15 8 4 9 9 4 SLD +SLC 40 24 3 4 13 4 4 SLD +SLC 40 24 9 4 13 10 4 SLD +SLC 40 37 4 4 15 5 4 SLD +SLC 40 37 10 4 15 11 4 SLD +SLC 40 52 5 4 28 6 4 SLD +SLC 40 52 11 4 28 12 4 SLD +SLD 40 80 6 4 16 6 4 SLD +SLD 40 80 12 4 208 12 4 SLD +TAP 40 144 6 4 +SLD 40 192 6 4 96 7 2 SLD +FLK 40 288 4 3 L +AUL 40 288 4 3 FLK DEF +AHD 40 288 7 2 SLD 96 DEF +FLK 40 288 9 3 L +AUL 40 288 9 3 FLK DEF +AHD 40 288 12 4 SLD 96 DEF +SLC 41 0 6 4 7 5 4 SLD +SLC 41 0 12 4 7 11 4 SLD +SLC 41 7 5 4 8 4 4 SLD +SLC 41 7 11 4 8 10 4 SLD +SLC 41 15 4 4 9 3 4 SLD +SLC 41 15 10 4 9 9 4 SLD +SLC 41 24 3 4 13 2 4 SLD +SLC 41 24 9 4 13 8 4 SLD +SLC 41 37 2 4 15 1 4 SLD +SLC 41 37 8 4 15 7 4 SLD +SLC 41 52 1 4 28 0 4 SLD +SLC 41 52 7 4 28 6 4 SLD +SLD 41 80 0 4 208 0 4 SLD +SLD 41 80 6 4 16 6 4 SLD +TAP 41 144 6 4 +SLD 41 192 6 4 96 7 2 SLD +FLK 41 288 4 3 L +FLK 41 288 9 3 L +TAP 42 0 2 4 +AHD 42 0 2 4 TAP 96 DEF +TAP 42 0 10 4 +TAP 42 48 6 4 +TAP 42 96 2 4 +TAP 42 96 10 4 +AHD 42 96 10 4 TAP 96 DEF +TAP 42 144 6 4 +CHR 42 192 0 8 UP +CHR 42 192 10 4 UP +TAP 42 288 0 4 +TAP 42 288 6 4 +TAP 42 336 4 4 +TAP 42 336 10 4 +SLC 43 0 6 4 7 5 4 SLD +SLC 43 0 12 4 7 11 4 SLD +SLC 43 7 5 4 8 4 4 SLD +SLC 43 7 11 4 8 10 4 SLD +SLC 43 15 4 4 9 3 4 SLD +SLC 43 15 10 4 9 9 4 SLD +SLC 43 24 3 4 13 2 4 SLD +SLC 43 24 9 4 13 8 4 SLD +SLC 43 37 2 4 15 1 4 SLD +SLC 43 37 8 4 15 7 4 SLD +SLC 43 52 1 4 28 0 4 SLD +SLC 43 52 7 4 28 6 4 SLD +SLD 43 80 0 4 208 0 4 SLD +SLD 43 80 6 4 16 6 4 SLD +TAP 43 144 6 4 +SLD 43 192 6 4 96 7 2 SLD +AHD 43 288 0 4 SLD 96 DEF +FLK 43 288 4 3 L +AUR 43 288 4 3 FLK DEF +AHD 43 288 7 2 SLD 96 DEF +FLK 43 288 9 3 L +AUR 43 288 9 3 FLK DEF +SLC 44 0 0 4 7 1 4 SLD +SLC 44 0 6 4 7 7 4 SLD +SLC 44 7 1 4 8 2 4 SLD +SLC 44 7 7 4 8 8 4 SLD +SLC 44 15 2 4 9 3 4 SLD +SLC 44 15 8 4 9 9 4 SLD +SLC 44 24 3 4 13 4 4 SLD +SLC 44 24 9 4 13 10 4 SLD +SLC 44 37 4 4 15 5 4 SLD +SLC 44 37 10 4 15 11 4 SLD +SLC 44 52 5 4 28 6 4 SLD +SLC 44 52 11 4 28 12 4 SLD +SLD 44 80 6 4 16 6 4 SLD +SLD 44 80 12 4 208 12 4 SLD +TAP 44 144 6 4 +SLD 44 192 6 4 96 7 2 SLD +FLK 44 288 4 3 L +FLK 44 288 9 3 L +TAP 45 0 0 4 +SLD 45 0 7 8 96 1 6 SLD +SLD 45 96 1 6 96 9 5 SLD +TAP 45 96 11 4 +TAP 45 192 2 4 +SLD 45 192 9 5 96 2 4 SLD +SLD 45 288 2 4 96 11 2 SLD +TAP 45 288 9 4 +SLC 46 0 3 2 96 3 2 SLD +SLC 46 0 11 2 96 11 2 SLD +SLD 46 96 3 2 96 0 16 SLD +SLD 46 96 11 2 96 0 16 SLD +AHD 46 192 0 16 SLD 96 DEF +AHD 46 192 0 16 SLD 96 DEF +CHR 46 288 0 16 UP +ADW 46 288 0 16 CHR DEF +CHR 47 0 0 4 UP +AHD 47 0 0 4 CHR 96 DEF +CHR 47 0 12 4 UP +TAP 47 72 5 6 +TAP 47 96 0 4 +TAP 47 144 5 6 +SLC 47 192 0 4 6 2 4 SLD +TAP 47 192 12 4 +AUL 47 192 12 4 TAP DEF +SLC 47 198 2 4 12 5 4 SLD +SLC 47 210 5 4 15 8 4 SLD +SLC 47 225 8 4 7 9 4 SLD +SLC 47 232 9 4 8 10 4 SLD +SLC 47 240 10 4 10 11 4 SLD +SLC 47 250 11 4 18 12 4 SLD +SLD 47 268 12 4 20 12 4 SLD +TAP 47 288 4 4 +SLC 47 288 4 4 2 5 4 SLD +ADR 47 288 4 4 TAP DEF +SLC 47 290 5 4 9 8 4 SLD +SLC 47 299 8 4 4 9 4 SLD +SLC 47 303 9 4 5 10 4 SLD +SLC 47 308 10 4 6 11 4 SLD +SLC 47 314 11 4 12 12 4 SLD +SLC 47 326 12 4 10 12 4 SLD +TAP 47 336 0 8 +SLC 47 336 12 4 10 12 4 SLD +SLC 47 346 12 4 24 11 4 SLD +SLC 47 370 11 4 13 10 4 SLD +SLC 47 383 10 4 1 2 12 SLD +CHR 48 0 2 4 UP +SLC 48 0 2 12 1 2 4 SLD +SLC 48 1 2 4 12 1 4 SLD +SLC 48 13 1 4 16 0 4 SLD +SLC 48 29 0 4 8 0 4 SLD +SLC 48 37 0 4 11 0 4 SLD +SLC 48 48 0 4 10 0 4 SLD +TAP 48 48 8 8 +SLC 48 58 0 4 23 1 4 SLD +SLC 48 81 1 4 14 2 4 SLD +SLC 48 95 2 4 1 2 12 SLD +SLC 48 96 2 12 1 10 4 SLD +CHR 48 96 10 4 UP +SLC 48 97 10 4 11 11 4 SLD +SLC 48 108 11 4 15 12 4 SLD +SLC 48 123 12 4 10 12 4 SLD +SLC 48 133 12 4 11 12 4 SLD +TAP 48 144 0 8 +SLC 48 144 12 4 10 12 4 SLD +SLC 48 154 12 4 23 11 4 SLD +SLD 48 177 11 4 15 10 4 SLD +CHR 48 192 0 6 UP +AUL 48 192 0 6 CHR DEF +FLK 48 192 6 6 L +AUL 48 192 6 6 FLK DEF +CHR 48 288 0 4 UP +FLK 48 288 4 4 L +ADR 48 288 4 4 FLK DEF +CHR 48 288 8 4 UP +FLK 48 288 12 4 L +ADR 48 288 12 4 FLK DEF +TAP 49 0 0 4 +TAP 49 0 12 4 +AHD 49 0 12 4 TAP 96 DEF +TAP 49 72 5 6 +TAP 49 96 12 4 +TAP 49 144 5 6 +TAP 49 192 0 4 +AIR 49 192 0 4 TAP DEF +SLC 49 192 12 4 4 10 4 SLD +SLC 49 196 10 4 6 8 4 SLD +SLC 49 202 8 4 9 6 4 SLD +SLC 49 211 6 4 6 5 4 SLD +SLC 49 217 5 4 9 4 4 SLD +SLC 49 226 4 4 13 3 4 SLD +SLC 49 239 3 4 8 3 4 SLD +SLC 49 247 3 4 16 4 5 SLD +SLC 49 263 4 5 13 5 6 SLD +SLD 49 276 5 6 12 5 6 SLD +SLC 49 288 0 4 24 5 6 SLD +SLD 49 288 5 6 288 5 6 SLD +SLC 49 312 5 6 264 5 6 SLD +SLC 49 336 12 4 24 5 6 SLD +SLC 49 360 5 6 216 5 6 SLD +SLC 50 0 0 4 24 5 6 SLD +SLC 50 24 5 6 168 5 6 SLD +SLC 50 48 12 4 24 5 6 SLD +SLC 50 72 5 6 120 5 6 SLD +SLC 50 96 0 4 24 5 6 SLD +SLC 50 120 5 6 72 5 6 SLD +SLC 50 144 12 4 24 5 6 SLD +SLC 50 168 5 6 24 5 6 SLD +SLD 50 192 5 6 48 0 4 SLD +SLD 50 192 5 6 56 2 4 SLD +SLD 50 192 5 6 64 4 4 SLD +SLD 50 192 5 6 72 6 4 SLD +SLD 50 192 5 6 80 8 4 SLD +SLD 50 192 5 6 88 10 4 SLD +SLD 50 192 5 6 96 12 4 SLD +TAP 51 0 4 4 +TAP 51 0 12 4 +TAP 51 48 0 4 +TAP 51 48 8 8 +TAP 51 96 0 8 +TAP 51 96 12 4 +TAP 51 144 2 6 +TAP 51 144 8 6 +TAP 51 192 4 4 +ASC 51 192 4 4 TAP 5 12 3 4 5 DEF +SLC 51 192 8 4 12 9 4 SLD +ASC 51 204 3 4 ASC 5 14 2 4 5 DEF +SLC 51 204 9 4 14 10 4 SLD +ASC 51 218 2 4 ASC 5 19 1 4 5 DEF +SLC 51 218 10 4 19 11 4 SLD +ASC 51 237 1 4 ASC 5 37 0 4 5 DEF +SLC 51 237 11 4 37 12 4 SLD +ASD 51 274 0 4 ASC 5 14 0 4 5 DEF +SLD 51 274 12 4 14 12 4 SLD +CHR 51 288 0 4 UP +ADW 51 288 0 4 CHR DEF +TAP 51 336 8 8 +TAP 52 0 0 4 +TAP 52 0 8 4 +TAP 52 48 0 8 +TAP 52 48 12 4 +TAP 52 96 0 4 +TAP 52 96 8 8 +TAP 52 144 2 6 +TAP 52 144 8 6 +SLC 52 192 4 4 12 3 4 SLD +TAP 52 192 8 4 +ASC 52 192 8 4 TAP 5 12 9 4 5 DEF +SLC 52 204 3 4 14 2 4 SLD +ASC 52 204 9 4 ASC 5 14 10 4 5 DEF +SLC 52 218 2 4 19 1 4 SLD +ASC 52 218 10 4 ASC 5 19 11 4 5 DEF +SLC 52 237 1 4 37 0 4 SLD +ASC 52 237 11 4 ASC 5 37 12 4 5 DEF +SLD 52 274 0 4 14 0 4 SLD +ASD 52 274 12 4 ASC 5 14 12 4 5 DEF +CHR 52 288 12 4 UP +ADW 52 288 12 4 CHR DEF +TAP 52 336 0 3 +SLC 53 0 0 3 48 5 3 SLD +SLC 53 0 8 3 48 13 3 SLD +SLC 53 48 5 3 144 5 3 SLD +SLC 53 48 13 3 144 13 3 SLD +TAP 53 96 9 3 +TAP 53 144 9 3 +SLC 53 192 5 3 48 2 3 SLD +TAP 53 192 9 3 +SLC 53 192 13 3 48 11 3 SLD +SLC 53 240 2 3 144 2 3 SLD +SLC 53 240 11 3 144 11 3 SLD +TAP 53 288 6 4 +TAP 53 336 6 4 +SLD 54 0 2 3 96 6 3 SLD +HLD 54 0 6 4 96 +SLD 54 0 11 3 96 7 3 SLD +FLK 54 96 2 4 L +FLK 54 96 10 4 L +CHR 54 192 3 3 UP +AHD 54 192 3 3 CHR 96 DEF +CHR 54 192 10 3 UP +AHD 54 192 10 3 CHR 96 DEF +CHR 54 288 0 3 UP +ADW 54 288 0 3 CHR DEF +CHR 54 288 3 3 UP +ADW 54 288 3 3 CHR DEF +CHR 54 288 6 4 UP +ADW 54 288 6 4 CHR DEF +CHR 54 288 10 3 UP +ADW 54 288 10 3 CHR DEF +CHR 54 288 13 3 UP +ADW 54 288 13 3 CHR DEF +CHR 55 0 0 4 UP +AHD 55 0 0 4 CHR 96 DEF +CHR 55 0 12 4 UP +TAP 55 72 5 6 +TAP 55 96 0 4 +TAP 55 144 5 6 +SLC 55 192 0 4 6 2 4 SLD +TAP 55 192 12 4 +AUL 55 192 12 4 TAP DEF +SLC 55 198 2 4 12 5 4 SLD +SLC 55 210 5 4 15 8 4 SLD +SLC 55 225 8 4 7 9 4 SLD +SLC 55 232 9 4 8 10 4 SLD +SLC 55 240 10 4 10 11 4 SLD +SLC 55 250 11 4 18 12 4 SLD +SLD 55 268 12 4 20 12 4 SLD +TAP 55 288 4 4 +SLC 55 288 4 4 2 5 4 SLD +ADR 55 288 4 4 TAP DEF +SLC 55 290 5 4 9 8 4 SLD +SLC 55 299 8 4 4 9 4 SLD +SLC 55 303 9 4 5 10 4 SLD +SLC 55 308 10 4 6 11 4 SLD +SLC 55 314 11 4 12 12 4 SLD +SLC 55 326 12 4 10 12 4 SLD +TAP 55 336 0 8 +SLC 55 336 12 4 11 12 4 SLD +SLC 55 347 12 4 15 11 4 SLD +SLC 55 362 11 4 9 10 4 SLD +SLC 55 371 10 4 7 9 4 SLD +SLD 55 378 9 4 6 8 4 SLD +SLC 56 0 0 4 2 1 4 SLD +SLC 56 2 1 4 9 4 4 SLD +SLC 56 11 4 4 4 5 4 SLD +SLC 56 15 5 4 5 6 4 SLD +SLC 56 20 6 4 6 7 4 SLD +SLC 56 26 7 4 8 8 4 SLD +SLC 56 34 8 4 11 9 4 SLD +SLC 56 45 9 4 3 9 4 SLD +TAP 56 48 0 6 +SLC 56 48 9 4 3 9 4 SLD +SLC 56 51 9 4 11 8 4 SLD +SLC 56 62 8 4 8 7 4 SLD +SLC 56 70 7 4 6 6 4 SLD +SLC 56 76 6 4 5 5 4 SLD +SLC 56 81 5 4 4 4 4 SLD +SLC 56 85 4 4 9 1 4 SLD +SLD 56 94 1 4 2 0 4 SLD +SLC 56 96 10 4 15 11 4 SLD +SLC 56 111 11 4 21 12 4 SLD +SLC 56 132 12 4 14 12 4 SLD +TAP 56 144 0 8 +SLC 56 146 12 4 17 11 4 SLD +SLC 56 163 11 4 9 10 4 SLD +SLC 56 172 10 4 7 9 4 SLD +SLC 56 179 9 4 5 8 4 SLD +SLD 56 184 8 4 8 6 4 SLD +SLC 56 192 0 4 2 1 4 SLD +AUL 56 192 6 4 SLD DEF +SLC 56 194 1 4 3 2 4 SLD +SLC 56 197 2 4 8 4 4 SLD +SLC 56 205 4 4 5 5 4 SLD +SLC 56 210 5 4 6 6 4 SLD +SLC 56 216 6 4 9 7 4 SLD +SLC 56 225 7 4 12 8 4 SLD +SLC 56 237 8 4 3 8 4 SLD +SLC 56 240 8 4 4 8 4 SLD +SLC 56 244 8 4 13 7 4 SLD +SLC 56 257 7 4 9 6 4 SLD +SLC 56 266 6 4 7 5 4 SLD +SLC 56 273 5 4 6 4 4 SLD +SLC 56 279 4 4 5 3 4 SLD +SLD 56 284 3 4 4 2 4 SLD +CHR 56 288 0 4 UP +AUL 56 288 0 4 CHR DEF +AUL 56 288 2 4 SLD DEF +FLK 56 288 8 8 L +ADR 56 288 8 8 FLK DEF +CHR 57 0 0 4 UP +CHR 57 0 12 4 UP +AHD 57 0 12 4 CHR 96 DEF +TAP 57 72 5 6 +TAP 57 96 12 4 +TAP 57 144 5 6 +TAP 57 192 0 4 +AUR 57 192 0 4 TAP DEF +SLC 57 192 12 4 6 10 4 SLD +SLC 57 198 10 4 12 7 4 SLD +SLC 57 210 7 4 15 4 4 SLD +SLC 57 225 4 4 7 3 4 SLD +SLC 57 232 3 4 8 2 4 SLD +SLC 57 240 2 4 10 1 4 SLD +SLC 57 250 1 4 18 0 4 SLD +SLD 57 268 0 4 20 0 4 SLD +TAP 57 288 8 4 +SLC 57 288 8 4 2 7 4 SLD +ADL 57 288 8 4 TAP DEF +SLC 57 290 7 4 9 4 4 SLD +SLC 57 299 4 4 4 3 4 SLD +SLC 57 303 3 4 5 2 4 SLD +SLC 57 308 2 4 6 1 4 SLD +SLC 57 314 1 4 12 0 4 SLD +SLC 57 326 0 4 10 0 4 SLD +SLC 57 336 0 4 11 0 4 SLD +TAP 57 336 8 8 +SLC 57 347 0 4 15 1 4 SLD +SLC 57 362 1 4 9 2 4 SLD +SLC 57 371 2 4 7 3 4 SLD +SLD 57 378 3 4 6 4 4 SLD +SLC 58 0 12 4 2 11 4 SLD +SLC 58 2 11 4 9 8 4 SLD +SLC 58 11 8 4 4 7 4 SLD +SLC 58 15 7 4 5 6 4 SLD +SLC 58 20 6 4 6 5 4 SLD +SLC 58 26 5 4 8 4 4 SLD +SLC 58 34 4 4 11 3 4 SLD +SLC 58 45 3 4 3 3 4 SLD +SLC 58 48 3 4 3 3 4 SLD +TAP 58 48 10 6 +SLC 58 51 3 4 11 4 4 SLD +SLC 58 62 4 4 8 5 4 SLD +SLC 58 70 5 4 6 6 4 SLD +SLC 58 76 6 4 5 7 4 SLD +SLC 58 81 7 4 4 8 4 SLD +SLC 58 85 8 4 9 11 4 SLD +SLD 58 94 11 4 2 12 4 SLD +SLC 58 96 2 4 15 1 4 SLD +SLC 58 111 1 4 21 0 4 SLD +SLC 58 132 0 4 14 0 4 SLD +TAP 58 144 8 8 +SLC 58 146 0 4 17 1 4 SLD +SLC 58 163 1 4 9 2 4 SLD +SLC 58 172 2 4 7 3 4 SLD +SLC 58 179 3 4 5 4 4 SLD +SLD 58 184 4 4 8 6 4 SLD +AUR 58 192 6 4 SLD DEF +SLC 58 192 12 4 2 11 4 SLD +SLC 58 194 11 4 3 10 4 SLD +SLC 58 197 10 4 8 8 4 SLD +SLC 58 205 8 4 5 7 4 SLD +SLC 58 210 7 4 6 6 4 SLD +SLC 58 216 6 4 9 5 4 SLD +SLC 58 225 5 4 12 4 4 SLD +SLC 58 237 4 4 3 4 4 SLD +SLC 58 240 4 4 4 4 4 SLD +SLC 58 244 4 4 13 5 4 SLD +SLC 58 257 5 4 9 6 4 SLD +SLC 58 266 6 4 7 7 4 SLD +SLC 58 273 7 4 6 8 4 SLD +SLC 58 279 8 4 5 9 4 SLD +SLD 58 284 9 4 4 10 4 SLD +FLK 58 288 0 8 L +ADL 58 288 0 8 FLK DEF +AUR 58 288 10 4 SLD DEF +CHR 58 288 12 4 UP +AUR 58 288 12 4 CHR DEF +TAP 59 0 4 4 +TAP 59 0 10 6 +TAP 59 48 0 6 +TAP 59 48 8 4 +TAP 59 96 4 6 +TAP 59 96 12 4 +TAP 59 144 0 4 +TAP 59 144 6 6 +TAP 59 192 6 4 +ASC 59 192 6 4 TAP 5 7 5 4 5 DEF +SLC 59 192 12 4 7 11 4 SLD +ASC 59 199 5 4 ASC 5 8 4 4 5 DEF +SLC 59 199 11 4 8 10 4 SLD +ASC 59 207 4 4 ASC 5 9 3 4 5 DEF +SLC 59 207 10 4 9 9 4 SLD +ASC 59 216 3 4 ASC 5 11 2 4 5 DEF +SLC 59 216 9 4 11 8 4 SLD +ASC 59 227 2 4 ASC 5 15 1 4 5 DEF +SLC 59 227 8 4 15 7 4 SLD +ASC 59 242 1 4 ASC 5 27 0 4 5 DEF +SLC 59 242 7 4 27 6 4 SLD +ASD 59 269 0 4 ASC 5 19 0 4 5 DEF +SLD 59 269 6 4 19 6 4 SLD +CHR 59 288 0 4 UP +ADW 59 288 0 4 CHR DEF +TAP 59 336 6 4 +TAP 60 0 4 4 +TAP 60 0 10 6 +TAP 60 48 0 6 +TAP 60 48 8 4 +TAP 60 96 4 6 +TAP 60 96 12 4 +TAP 60 144 0 4 +TAP 60 144 6 6 +SLC 60 192 6 4 7 5 4 SLD +TAP 60 192 12 4 +ASC 60 192 12 4 TAP 5 7 11 4 5 DEF +SLC 60 199 5 4 8 4 4 SLD +ASC 60 199 11 4 ASC 5 8 10 4 5 DEF +SLC 60 207 4 4 9 3 4 SLD +ASC 60 207 10 4 ASC 5 9 9 4 5 DEF +SLC 60 216 3 4 11 2 4 SLD +ASC 60 216 9 4 ASC 5 11 8 4 5 DEF +SLC 60 227 2 4 15 1 4 SLD +ASC 60 227 8 4 ASC 5 15 7 4 5 DEF +SLC 60 242 1 4 27 0 4 SLD +ASC 60 242 7 4 ASC 5 27 6 4 5 DEF +SLD 60 269 0 4 19 0 4 SLD +ASD 60 269 6 4 ASC 5 19 6 4 5 DEF +CHR 60 288 6 4 UP +ADW 60 288 6 4 CHR DEF +TAP 60 336 3 4 +TAP 60 336 9 4 +SLC 61 0 0 4 17 1 4 SLD +SLC 61 0 12 4 17 11 4 SLD +SLC 61 17 1 4 19 2 4 SLD +SLC 61 17 11 4 19 10 4 SLD +SLC 61 36 2 4 20 3 4 SLD +SLC 61 36 10 4 20 9 4 SLD +SLC 61 56 3 4 27 4 4 SLD +SLC 61 56 9 4 27 8 4 SLD +SLD 61 83 4 4 13 4 4 SLD +SLD 61 83 8 4 13 8 4 SLD +TAP 61 144 4 3 +TAP 61 144 9 3 +HLD 61 192 0 3 96 +HLD 61 192 3 3 96 +HLD 61 192 6 4 96 +HLD 61 192 10 3 96 +HLD 61 192 13 3 96 +AIR 61 288 0 3 HLD DEF +AIR 61 288 3 3 HLD DEF +AIR 61 288 6 4 HLD DEF +AIR 61 288 10 3 HLD DEF +AIR 61 288 13 3 HLD DEF +SLD 62 0 3 3 96 1 3 SLD +SLD 62 0 3 3 96 5 3 SLD +SLC 62 96 13 2 3 10 2 SLD +SLC 62 99 10 2 2 10 2 SLD +SLC 62 101 10 2 29 10 2 SLD +SLC 62 130 10 2 1 8 6 SLD +SLC 62 131 8 6 4 8 6 SLD +SLC 62 135 8 6 1 10 2 SLD +SLD 62 136 10 2 8 10 2 SLD +SLC 62 192 0 4 1 0 12 SLD +TAP 62 192 4 4 +AHX 62 192 4 4 TAP 96 DEF +TAP 62 192 8 4 +AHX 62 192 8 4 TAP 96 DEF +SLC 62 193 0 12 23 0 12 SLD +SLC 62 216 0 12 1 0 4 SLD +SLD 62 217 0 4 71 0 4 SLD +ALD 62 288 4 8 1 5 5 4 8 1 +CHR 63 0 0 4 UP +CHR 63 0 6 4 UP +ASC 63 0 6 4 CHR 5 6 7 4 5 DEF +ASC 63 6 7 4 ASC 5 7 8 4 5 DEF +ASC 63 13 8 4 ASC 5 8 9 4 5 DEF +ASC 63 21 9 4 ASC 5 11 10 4 5 DEF +ASC 63 32 10 4 ASC 5 13 11 4 5 DEF +ASC 63 45 11 4 ASC 5 25 12 4 5 DEF +ASD 63 70 12 4 ASC 5 26 12 4 5 DEF +TAP 63 72 5 6 +TAP 63 96 12 4 +TAP 63 144 5 6 +CHR 63 192 6 4 UP +ASC 63 192 6 4 CHR 5 6 5 4 5 DEF +CHR 63 192 12 4 UP +ASC 63 198 5 4 ASC 5 7 4 4 5 DEF +ASC 63 205 4 4 ASC 5 8 3 4 5 DEF +ASC 63 213 3 4 ASC 5 11 2 4 5 DEF +ASC 63 224 2 4 ASC 5 13 1 4 5 DEF +ASC 63 237 1 4 ASC 5 25 0 4 5 DEF +ASD 63 262 0 4 ASC 5 26 0 4 5 DEF +TAP 63 264 5 6 +TAP 63 288 0 4 +TAP 63 336 5 6 +CHR 64 0 0 4 UP +CHR 64 0 6 4 UP +ASC 64 0 6 4 CHR 5 6 7 4 5 DEF +ASC 64 6 7 4 ASC 5 7 8 4 5 DEF +ASC 64 13 8 4 ASC 5 8 9 4 5 DEF +ASC 64 21 9 4 ASC 5 11 10 4 5 DEF +ASC 64 32 10 4 ASC 5 13 11 4 5 DEF +ASC 64 45 11 4 ASC 5 25 12 4 5 DEF +ASD 64 70 12 4 ASC 5 26 12 4 5 DEF +TAP 64 72 5 6 +TAP 64 96 12 4 +TAP 64 144 5 6 +TAP 64 192 0 4 +TAP 64 240 4 4 +TAP 64 288 8 4 +TAP 64 336 12 4 +CHR 65 0 6 4 UP +ASC 65 0 6 4 CHR 5 6 5 4 5 DEF +CHR 65 0 12 4 UP +ASC 65 6 5 4 ASC 5 7 4 4 5 DEF +ASC 65 13 4 4 ASC 5 8 3 4 5 DEF +ASC 65 21 3 4 ASC 5 11 2 4 5 DEF +ASC 65 32 2 4 ASC 5 13 1 4 5 DEF +ASC 65 45 1 4 ASC 5 25 0 4 5 DEF +ASD 65 70 0 4 ASC 5 26 0 4 5 DEF +TAP 65 72 5 6 +TAP 65 96 0 4 +TAP 65 144 5 6 +CHR 65 192 0 4 UP +CHR 65 192 6 4 UP +ASC 65 192 6 4 CHR 5 6 7 4 5 DEF +ASC 65 198 7 4 ASC 5 7 8 4 5 DEF +ASC 65 205 8 4 ASC 5 8 9 4 5 DEF +ASC 65 213 9 4 ASC 5 11 10 4 5 DEF +ASC 65 224 10 4 ASC 5 13 11 4 5 DEF +ASC 65 237 11 4 ASC 5 25 12 4 5 DEF +ASD 65 262 12 4 ASC 5 26 12 4 5 DEF +TAP 65 264 5 6 +TAP 65 288 12 4 +TAP 65 336 5 6 +CHR 66 0 6 4 UP +ASC 66 0 6 4 CHR 5 6 5 4 5 DEF +CHR 66 0 12 4 UP +ASC 66 6 5 4 ASC 5 7 4 4 5 DEF +ASC 66 13 4 4 ASC 5 8 3 4 5 DEF +ASC 66 21 3 4 ASC 5 11 2 4 5 DEF +ASC 66 32 2 4 ASC 5 13 1 4 5 DEF +ASC 66 45 1 4 ASC 5 25 0 4 5 DEF +ASD 66 70 0 4 ASC 5 26 0 4 5 DEF +TAP 66 72 5 6 +TAP 66 96 0 4 +TAP 66 144 5 6 +TAP 66 192 0 4 +TAP 66 192 12 4 +TAP 66 240 4 4 +TAP 66 240 8 4 +TAP 66 288 0 4 +AIR 66 288 0 4 TAP DEF +TAP 66 288 12 4 +AIR 66 288 12 4 TAP DEF +SLC 67 0 0 4 4 1 4 SLD +TAP 67 0 12 4 +AHD 67 0 12 4 TAP 96 DEF +SLC 67 4 1 4 5 2 4 SLD +SLC 67 9 2 4 5 3 4 SLD +SLC 67 14 3 4 7 4 4 SLD +SLC 67 21 4 4 9 5 4 SLD +SLC 67 30 5 4 14 6 4 SLD +SLC 67 44 6 4 4 6 4 SLD +SLC 67 48 6 4 4 6 4 SLD +SLC 67 52 6 4 14 5 4 SLD +SLC 67 66 5 4 9 4 4 SLD +SLC 67 75 4 4 7 3 4 SLD +SLC 67 82 3 4 5 2 4 SLD +SLC 67 87 2 4 5 1 4 SLD +SLD 67 92 1 4 4 0 4 SLD +AHD 67 96 0 4 SLD 96 DEF +SLC 67 96 12 4 4 11 4 SLD +SLC 67 100 11 4 5 10 4 SLD +SLC 67 105 10 4 5 9 4 SLD +SLC 67 110 9 4 7 8 4 SLD +SLC 67 117 8 4 9 7 4 SLD +SLC 67 126 7 4 14 6 4 SLD +SLC 67 140 6 4 4 6 4 SLD +SLC 67 144 6 4 4 6 4 SLD +SLC 67 148 6 4 14 7 4 SLD +SLC 67 162 7 4 9 8 4 SLD +SLC 67 171 8 4 7 9 4 SLD +SLC 67 178 9 4 5 10 4 SLD +SLC 67 183 10 4 5 11 4 SLD +SLC 67 188 11 4 4 12 4 SLD +TAP 67 192 0 4 +SLC 67 192 12 4 96 12 4 SLD +TAP 67 288 0 4 +SLD 67 288 12 4 96 8 4 SLD +TAP 67 336 2 4 +SLD 68 0 4 4 96 0 4 SLD +TAP 68 48 12 4 +SLD 68 96 0 4 192 0 4 SLD +TAP 68 96 10 4 +SLC 68 144 8 4 15 7 4 SLD +SLC 68 159 7 4 18 6 4 SLD +SLC 68 177 6 4 23 5 4 SLD +SLC 68 200 5 4 30 4 4 SLD +SLD 68 230 4 4 10 4 4 SLD +FLK 68 240 8 8 L +FLK 68 288 4 8 L +HLD 69 0 2 3 96 +HLD 69 0 11 3 96 +TAP 69 96 5 3 +TAP 69 120 8 3 +TAP 69 144 5 3 +TAP 69 192 0 4 +TAP 69 192 12 4 +TAP 69 264 2 4 +TAP 69 264 10 4 +TAP 69 336 5 3 +TAP 69 336 8 3 +SLC 70 0 6 4 15 5 4 SLD +SLC 70 0 6 4 15 7 4 SLD +SLC 70 15 5 4 17 4 4 SLD +SLC 70 15 7 4 17 8 4 SLD +SLC 70 32 4 4 19 3 4 SLD +SLC 70 32 8 4 19 9 4 SLD +SLC 70 51 3 4 24 2 4 SLD +SLC 70 51 9 4 24 10 4 SLD +SLC 70 75 2 4 33 1 4 SLD +SLC 70 75 10 4 33 11 4 SLD +SLC 70 108 1 4 52 0 4 SLD +SLC 70 108 11 4 52 12 4 SLD +SLD 70 160 0 4 32 0 4 SLD +SLD 70 160 12 4 32 12 4 SLD +ASD 70 192 0 4 SLD 5 192 0 4 5 DEF +AHD 70 192 12 4 SLD 192 DEF +ASC 71 0 0 4 ASD 5 3 1 4 5 DEF +ALD 71 0 0 4 1 5 4 0 4 3 +SLC 71 0 12 4 3 11 4 SLD +ASC 71 3 1 4 ASC 5 10 4 4 5 DEF +SLC 71 3 11 4 10 8 4 SLD +ASC 71 13 4 4 ASC 5 11 7 4 5 DEF +SLC 71 13 8 4 11 5 4 SLD +SLC 71 24 5 4 5 4 4 SLD +ASC 71 24 7 4 ASC 5 5 8 4 5 DEF +SLC 71 29 4 4 5 3 4 SLD +ASC 71 29 8 4 ASC 5 5 9 4 5 DEF +SLC 71 34 3 4 8 2 4 SLD +ASC 71 34 9 4 ASC 5 8 10 4 5 DEF +SLC 71 42 2 4 10 1 4 SLD +ASC 71 42 10 4 ASC 5 10 11 4 5 DEF +SLC 71 52 1 4 19 0 4 SLD +ASC 71 52 11 4 ASC 5 19 12 4 5 DEF +SLD 71 71 0 4 25 0 4 SLD +ASD 71 71 12 4 ASC 5 25 12 4 5 DEF +SLC 71 96 0 4 3 1 4 SLD +ASC 71 96 12 4 ASD 5 3 11 4 5 DEF +ALD 71 96 12 4 1 5 4 12 4 3 +SLC 71 99 1 4 10 4 4 SLD +ASC 71 99 11 4 ASC 5 10 8 4 5 DEF +SLC 71 109 4 4 11 7 4 SLD +ASC 71 109 8 4 ASC 5 11 5 4 5 DEF +ASC 71 120 5 4 ASC 5 5 4 4 5 DEF +SLC 71 120 7 4 5 8 4 SLD +ASC 71 125 4 4 ASC 5 5 3 4 5 DEF +SLC 71 125 8 4 5 9 4 SLD +ASC 71 130 3 4 ASC 5 8 2 4 5 DEF +SLC 71 130 9 4 8 10 4 SLD +ASC 71 138 2 4 ASC 5 10 1 4 5 DEF +SLC 71 138 10 4 10 11 4 SLD +ASC 71 148 1 4 ASC 5 19 0 4 5 DEF +SLC 71 148 11 4 19 12 4 SLD +ASD 71 167 0 4 ASC 5 25 0 4 5 DEF +SLD 71 167 12 4 25 12 4 SLD +ASC 71 192 0 4 ASD 5 3 1 4 5 DEF +ALD 71 192 0 4 1 5 4 0 4 3 +SLC 71 192 12 4 3 11 4 SLD +ASC 71 195 1 4 ASC 5 10 4 4 5 DEF +SLC 71 195 11 4 10 8 4 SLD +ASC 71 205 4 4 ASC 5 11 7 4 5 DEF +SLC 71 205 8 4 11 5 4 SLD +SLC 71 216 5 4 5 4 4 SLD +ASC 71 216 7 4 ASC 5 5 8 4 5 DEF +SLC 71 221 4 4 5 3 4 SLD +ASC 71 221 8 4 ASC 5 5 9 4 5 DEF +SLC 71 226 3 4 8 2 4 SLD +ASC 71 226 9 4 ASC 5 8 10 4 5 DEF +SLC 71 234 2 4 10 1 4 SLD +ASC 71 234 10 4 ASC 5 10 11 4 5 DEF +SLC 71 244 1 4 19 0 4 SLD +ASC 71 244 11 4 ASC 5 19 12 4 5 DEF +SLD 71 263 0 4 25 0 4 SLD +ASD 71 263 12 4 ASC 5 25 12 4 5 DEF +SLC 71 288 0 4 3 1 4 SLD +ASC 71 288 12 4 ASD 5 3 11 4 5 DEF +ALD 71 288 12 4 1 5 4 12 4 3 +SLC 71 291 1 4 10 4 4 SLD +ASC 71 291 11 4 ASC 5 10 8 4 5 DEF +SLC 71 301 4 4 11 7 4 SLD +ASC 71 301 8 4 ASC 5 11 5 4 5 DEF +ASC 71 312 5 4 ASC 5 5 4 4 5 DEF +SLC 71 312 7 4 5 8 4 SLD +ASC 71 317 4 4 ASC 5 5 3 4 5 DEF +SLC 71 317 8 4 5 9 4 SLD +ASC 71 322 3 4 ASC 5 8 2 4 5 DEF +SLC 71 322 9 4 8 10 4 SLD +ASC 71 330 2 4 ASC 5 10 1 4 5 DEF +SLC 71 330 10 4 10 11 4 SLD +ASC 71 340 1 4 ASC 5 19 0 4 5 DEF +SLC 71 340 11 4 19 12 4 SLD +ASD 71 359 0 4 ASC 5 25 0 4 5 DEF +SLD 71 359 12 4 25 12 4 SLD +ASC 72 0 0 4 ASD 5 3 1 4 5 DEF +ALD 72 0 0 4 1 5 4 0 4 3 +SLC 72 0 12 4 3 11 4 SLD +ASC 72 3 1 4 ASC 5 10 4 4 5 DEF +SLC 72 3 11 4 10 8 4 SLD +ASC 72 13 4 4 ASC 5 11 7 4 5 DEF +SLC 72 13 8 4 11 5 4 SLD +SLC 72 24 5 4 5 4 4 SLD +ASC 72 24 7 4 ASC 5 5 8 4 5 DEF +SLC 72 29 4 4 5 3 4 SLD +ASC 72 29 8 4 ASC 5 5 9 4 5 DEF +SLC 72 34 3 4 8 2 4 SLD +ASC 72 34 9 4 ASC 5 8 10 4 5 DEF +SLC 72 42 2 4 10 1 4 SLD +ASC 72 42 10 4 ASC 5 10 11 4 5 DEF +SLC 72 52 1 4 19 0 4 SLD +ASC 72 52 11 4 ASC 5 19 12 4 5 DEF +SLD 72 71 0 4 25 0 4 SLD +ASD 72 71 12 4 ASC 5 25 12 4 5 DEF +SLC 72 96 0 4 3 1 4 SLD +ASC 72 96 12 4 ASD 5 3 11 4 5 DEF +ALD 72 96 12 4 1 5 4 12 4 3 +SLC 72 99 1 4 10 4 4 SLD +ASC 72 99 11 4 ASC 5 10 8 4 5 DEF +SLC 72 109 4 4 11 7 4 SLD +ASC 72 109 8 4 ASC 5 11 5 4 5 DEF +ASC 72 120 5 4 ASC 5 5 4 4 5 DEF +SLC 72 120 7 4 5 8 4 SLD +ASC 72 125 4 4 ASC 5 5 3 4 5 DEF +SLC 72 125 8 4 5 9 4 SLD +ASC 72 130 3 4 ASC 5 8 2 4 5 DEF +SLC 72 130 9 4 8 10 4 SLD +ASC 72 138 2 4 ASC 5 10 1 4 5 DEF +SLC 72 138 10 4 10 11 4 SLD +ASC 72 148 1 4 ASC 5 19 0 4 5 DEF +SLC 72 148 11 4 19 12 4 SLD +ASD 72 167 0 4 ASC 5 25 0 4 5 DEF +SLD 72 167 12 4 25 12 4 SLD +ASC 72 192 0 4 ASD 5 3 1 4 5 DEF +ALD 72 192 0 4 1 5 4 0 4 3 +SLC 72 192 12 4 3 11 4 SLD +ASC 72 195 1 4 ASC 5 10 4 4 5 DEF +SLC 72 195 11 4 10 8 4 SLD +ASC 72 205 4 4 ASC 5 11 7 4 5 DEF +SLC 72 205 8 4 11 5 4 SLD +SLC 72 216 5 4 5 4 4 SLD +ASC 72 216 7 4 ASC 5 5 8 4 5 DEF +SLC 72 221 4 4 5 3 4 SLD +ASC 72 221 8 4 ASC 5 5 9 4 5 DEF +SLC 72 226 3 4 8 2 4 SLD +ASC 72 226 9 4 ASC 5 8 10 4 5 DEF +SLC 72 234 2 4 10 1 4 SLD +ASC 72 234 10 4 ASC 5 10 11 4 5 DEF +SLC 72 244 1 4 19 0 4 SLD +ASC 72 244 11 4 ASC 5 19 12 4 5 DEF +SLD 72 263 0 4 25 0 4 SLD +ASD 72 263 12 4 ASC 5 25 12 4 5 DEF +SLC 72 288 0 4 3 1 4 SLD +ASC 72 288 12 4 ASD 5 3 11 4 5 DEF +ALD 72 288 12 4 1 5 4 12 4 3 +SLC 72 291 1 4 10 4 4 SLD +ASC 72 291 11 4 ASC 5 10 8 4 5 DEF +SLC 72 301 4 4 11 7 4 SLD +ASC 72 301 8 4 ASC 5 11 5 4 5 DEF +ASC 72 312 5 4 ASC 5 5 4 4 5 DEF +SLC 72 312 7 4 5 8 4 SLD +ASC 72 317 4 4 ASC 5 5 3 4 5 DEF +SLC 72 317 8 4 5 9 4 SLD +ASC 72 322 3 4 ASC 5 8 2 4 5 DEF +SLC 72 322 9 4 8 10 4 SLD +ASC 72 330 2 4 ASC 5 10 1 4 5 DEF +SLC 72 330 10 4 10 11 4 SLD +ASC 72 340 1 4 ASC 5 19 0 4 5 DEF +SLC 72 340 11 4 19 12 4 SLD +ASD 72 359 0 4 ASC 5 25 0 4 5 DEF +SLD 72 359 12 4 25 12 4 SLD +ASC 73 0 0 4 ASD 5 3 1 4 5 DEF +ALD 73 0 0 4 1 5 4 0 4 3 +SLC 73 0 12 4 3 11 4 SLD +ASC 73 3 1 4 ASC 5 10 4 4 5 DEF +SLC 73 3 11 4 10 8 4 SLD +ASC 73 13 4 4 ASC 5 11 7 4 5 DEF +SLC 73 13 8 4 11 5 4 SLD +SLC 73 24 5 4 5 4 4 SLD +ASC 73 24 7 4 ASC 5 5 8 4 5 DEF +SLC 73 29 4 4 5 3 4 SLD +ASC 73 29 8 4 ASC 5 5 9 4 5 DEF +SLC 73 34 3 4 8 2 4 SLD +ASC 73 34 9 4 ASC 5 8 10 4 5 DEF +SLC 73 42 2 4 10 1 4 SLD +ASC 73 42 10 4 ASC 5 10 11 4 5 DEF +SLC 73 52 1 4 19 0 4 SLD +ASC 73 52 11 4 ASC 5 19 12 4 5 DEF +SLD 73 71 0 4 25 0 4 SLD +ASD 73 71 12 4 ASC 5 25 12 4 5 DEF +SLC 73 96 0 4 3 1 4 SLD +ASC 73 96 12 4 ASD 5 3 11 4 5 DEF +ALD 73 96 12 4 1 5 4 12 4 3 +SLC 73 99 1 4 10 4 4 SLD +ASC 73 99 11 4 ASC 5 10 8 4 5 DEF +SLC 73 109 4 4 11 7 4 SLD +ASC 73 109 8 4 ASC 5 11 5 4 5 DEF +ASC 73 120 5 4 ASC 5 5 4 4 5 DEF +SLC 73 120 7 4 5 8 4 SLD +ASC 73 125 4 4 ASC 5 5 3 4 5 DEF +SLC 73 125 8 4 5 9 4 SLD +ASC 73 130 3 4 ASC 5 8 2 4 5 DEF +SLC 73 130 9 4 8 10 4 SLD +ASC 73 138 2 4 ASC 5 10 1 4 5 DEF +SLC 73 138 10 4 10 11 4 SLD +ASC 73 148 1 4 ASC 5 19 0 4 5 DEF +SLC 73 148 11 4 19 12 4 SLD +ASD 73 167 0 4 ASC 5 25 0 4 5 DEF +SLD 73 167 12 4 25 12 4 SLD +ASC 73 192 0 4 ASD 5 3 1 4 5 DEF +ALD 73 192 0 4 1 5 4 0 4 3 +SLC 73 192 12 4 3 11 4 SLD +ASC 73 195 1 4 ASC 5 10 4 4 5 DEF +SLC 73 195 11 4 10 8 4 SLD +ASC 73 205 4 4 ASC 5 11 7 4 5 DEF +SLC 73 205 8 4 11 5 4 SLD +SLC 73 216 5 4 5 4 4 SLD +ASC 73 216 7 4 ASC 5 5 8 4 5 DEF +SLC 73 221 4 4 5 3 4 SLD +ASC 73 221 8 4 ASC 5 5 9 4 5 DEF +SLC 73 226 3 4 8 2 4 SLD +ASC 73 226 9 4 ASC 5 8 10 4 5 DEF +SLC 73 234 2 4 10 1 4 SLD +ASC 73 234 10 4 ASC 5 10 11 4 5 DEF +SLC 73 244 1 4 19 0 4 SLD +ASC 73 244 11 4 ASC 5 19 12 4 5 DEF +SLD 73 263 0 4 25 0 4 SLD +ASD 73 263 12 4 ASC 5 25 12 4 5 DEF +SLC 73 288 0 4 3 1 4 SLD +ASC 73 288 12 4 ASD 5 3 11 4 5 DEF +ALD 73 288 12 4 1 5 4 12 4 3 +SLC 73 291 1 4 10 4 4 SLD +ASC 73 291 11 4 ASC 5 10 8 4 5 DEF +SLC 73 301 4 4 11 7 4 SLD +ASC 73 301 8 4 ASC 5 11 5 4 5 DEF +ASC 73 312 5 4 ASC 5 5 4 4 5 DEF +SLC 73 312 7 4 5 8 4 SLD +ASC 73 317 4 4 ASC 5 5 3 4 5 DEF +SLC 73 317 8 4 5 9 4 SLD +ASC 73 322 3 4 ASC 5 8 2 4 5 DEF +SLC 73 322 9 4 8 10 4 SLD +ASC 73 330 2 4 ASC 5 10 1 4 5 DEF +SLC 73 330 10 4 10 11 4 SLD +ASC 73 340 1 4 ASC 5 19 0 4 5 DEF +SLC 73 340 11 4 19 12 4 SLD +ASD 73 359 0 4 ASC 5 25 0 4 5 DEF +SLD 73 359 12 4 25 12 4 SLD +ASC 74 0 0 4 ASD 5 3 1 4 5 DEF +ALD 74 0 0 4 1 5 4 0 4 3 +SLC 74 0 12 4 3 11 4 SLD +ASC 74 3 1 4 ASC 5 10 4 4 5 DEF +SLC 74 3 11 4 10 8 4 SLD +ASC 74 13 4 4 ASC 5 11 7 4 5 DEF +SLC 74 13 8 4 11 5 4 SLD +SLC 74 24 5 4 5 4 4 SLD +ASC 74 24 7 4 ASC 5 5 8 4 5 DEF +SLC 74 29 4 4 5 3 4 SLD +ASC 74 29 8 4 ASC 5 5 9 4 5 DEF +SLC 74 34 3 4 8 2 4 SLD +ASC 74 34 9 4 ASC 5 8 10 4 5 DEF +SLC 74 42 2 4 10 1 4 SLD +ASC 74 42 10 4 ASC 5 10 11 4 5 DEF +SLC 74 52 1 4 19 0 4 SLD +ASC 74 52 11 4 ASC 5 19 12 4 5 DEF +SLD 74 71 0 4 25 0 4 SLD +ASD 74 71 12 4 ASC 5 25 12 4 5 DEF +SLC 74 96 0 4 3 1 4 SLD +ASC 74 96 12 4 ASD 5 3 11 4 5 DEF +ALD 74 96 12 4 1 5 4 12 4 3 +SLC 74 99 1 4 10 4 4 SLD +ASC 74 99 11 4 ASC 5 10 8 4 5 DEF +SLC 74 109 4 4 11 7 4 SLD +ASC 74 109 8 4 ASC 5 11 5 4 5 DEF +ASC 74 120 5 4 ASC 5 5 4 4 5 DEF +SLC 74 120 7 4 5 8 4 SLD +ASC 74 125 4 4 ASC 5 5 3 4 5 DEF +SLC 74 125 8 4 5 9 4 SLD +ASC 74 130 3 4 ASC 5 8 2 4 5 DEF +SLC 74 130 9 4 8 10 4 SLD +ASC 74 138 2 4 ASC 5 10 1 4 5 DEF +SLC 74 138 10 4 10 11 4 SLD +ASC 74 148 1 4 ASC 5 19 0 4 5 DEF +SLC 74 148 11 4 19 12 4 SLD +ASD 74 167 0 4 ASC 5 25 0 4 5 DEF +SLD 74 167 12 4 25 12 4 SLD +ASD 74 192 0 4 ASD 5 96 0 4 5 DEF +ALD 74 192 0 4 1 5 4 0 4 3 +ASD 74 192 12 4 SLD 5 96 12 4 5 DEF +ASD 74 288 0 4 ASD 5 96 0 4 5 DEF +ALD 74 288 0 4 1 5 4 0 4 3 +ASD 74 288 12 4 ASD 5 96 12 4 5 DEF +ALD 74 288 12 4 1 5 4 12 4 3 +SLC 75 0 0 4 3 1 4 SLD +ASC 75 0 12 4 ASD 5 3 11 4 5 DEF +ALD 75 0 12 4 1 5 4 12 4 3 +SLC 75 3 1 4 10 4 4 SLD +ASC 75 3 11 4 ASC 5 10 8 4 5 DEF +SLC 75 13 4 4 11 7 4 SLD +ASC 75 13 8 4 ASC 5 11 5 4 5 DEF +ASC 75 24 5 4 ASC 5 5 4 4 5 DEF +SLC 75 24 7 4 5 8 4 SLD +ASC 75 29 4 4 ASC 5 5 3 4 5 DEF +SLC 75 29 8 4 5 9 4 SLD +ASC 75 34 3 4 ASC 5 8 2 4 5 DEF +SLC 75 34 9 4 8 10 4 SLD +ASC 75 42 2 4 ASC 5 10 1 4 5 DEF +SLC 75 42 10 4 10 11 4 SLD +ASC 75 52 1 4 ASC 5 19 0 4 5 DEF +SLC 75 52 11 4 19 12 4 SLD +ASD 75 71 0 4 ASC 5 25 0 4 5 DEF +SLD 75 71 12 4 25 12 4 SLD +ASC 75 96 0 4 ASD 5 3 1 4 5 DEF +ALD 75 96 0 4 1 5 4 0 4 3 +SLC 75 96 12 4 3 11 4 SLD +ASC 75 99 1 4 ASC 5 10 4 4 5 DEF +SLC 75 99 11 4 10 8 4 SLD +ASC 75 109 4 4 ASC 5 11 7 4 5 DEF +SLC 75 109 8 4 11 5 4 SLD +SLC 75 120 5 4 5 4 4 SLD +ASC 75 120 7 4 ASC 5 5 8 4 5 DEF +SLC 75 125 4 4 5 3 4 SLD +ASC 75 125 8 4 ASC 5 5 9 4 5 DEF +SLC 75 130 3 4 8 2 4 SLD +ASC 75 130 9 4 ASC 5 8 10 4 5 DEF +SLC 75 138 2 4 10 1 4 SLD +ASC 75 138 10 4 ASC 5 10 11 4 5 DEF +SLC 75 148 1 4 19 0 4 SLD +ASC 75 148 11 4 ASC 5 19 12 4 5 DEF +SLD 75 167 0 4 25 0 4 SLD +ASD 75 167 12 4 ASC 5 25 12 4 5 DEF +SLC 75 192 0 4 3 1 4 SLD +ASC 75 192 12 4 ASD 5 3 11 4 5 DEF +ALD 75 192 12 4 1 5 4 12 4 3 +SLC 75 195 1 4 10 4 4 SLD +ASC 75 195 11 4 ASC 5 10 8 4 5 DEF +SLC 75 205 4 4 11 7 4 SLD +ASC 75 205 8 4 ASC 5 11 5 4 5 DEF +ASC 75 216 5 4 ASC 5 5 4 4 5 DEF +SLC 75 216 7 4 5 8 4 SLD +ASC 75 221 4 4 ASC 5 5 3 4 5 DEF +SLC 75 221 8 4 5 9 4 SLD +ASC 75 226 3 4 ASC 5 8 2 4 5 DEF +SLC 75 226 9 4 8 10 4 SLD +ASC 75 234 2 4 ASC 5 10 1 4 5 DEF +SLC 75 234 10 4 10 11 4 SLD +ASC 75 244 1 4 ASC 5 19 0 4 5 DEF +SLC 75 244 11 4 19 12 4 SLD +ASD 75 263 0 4 ASC 5 25 0 4 5 DEF +SLD 75 263 12 4 25 12 4 SLD +ASC 75 288 0 4 ASD 5 3 1 4 5 DEF +ALD 75 288 0 4 1 5 4 0 4 3 +SLC 75 288 12 4 3 11 4 SLD +ASC 75 291 1 4 ASC 5 10 4 4 5 DEF +SLC 75 291 11 4 10 8 4 SLD +ASC 75 301 4 4 ASC 5 11 7 4 5 DEF +SLC 75 301 8 4 11 5 4 SLD +SLC 75 312 5 4 5 4 4 SLD +ASC 75 312 7 4 ASC 5 5 8 4 5 DEF +SLC 75 317 4 4 5 3 4 SLD +ASC 75 317 8 4 ASC 5 5 9 4 5 DEF +SLC 75 322 3 4 8 2 4 SLD +ASC 75 322 9 4 ASC 5 8 10 4 5 DEF +SLC 75 330 2 4 10 1 4 SLD +ASC 75 330 10 4 ASC 5 10 11 4 5 DEF +SLC 75 340 1 4 19 0 4 SLD +ASC 75 340 11 4 ASC 5 19 12 4 5 DEF +SLD 75 359 0 4 25 0 4 SLD +ASD 75 359 12 4 ASC 5 25 12 4 5 DEF +SLC 76 0 0 4 3 1 4 SLD +ASC 76 0 12 4 ASD 5 3 11 4 5 DEF +ALD 76 0 12 4 1 5 4 12 4 3 +SLC 76 3 1 4 10 4 4 SLD +ASC 76 3 11 4 ASC 5 10 8 4 5 DEF +SLC 76 13 4 4 11 7 4 SLD +ASC 76 13 8 4 ASC 5 11 5 4 5 DEF +ASC 76 24 5 4 ASC 5 5 4 4 5 DEF +SLC 76 24 7 4 5 8 4 SLD +ASC 76 29 4 4 ASC 5 5 3 4 5 DEF +SLC 76 29 8 4 5 9 4 SLD +ASC 76 34 3 4 ASC 5 8 2 4 5 DEF +SLC 76 34 9 4 8 10 4 SLD +ASC 76 42 2 4 ASC 5 10 1 4 5 DEF +SLC 76 42 10 4 10 11 4 SLD +ASC 76 52 1 4 ASC 5 19 0 4 5 DEF +SLC 76 52 11 4 19 12 4 SLD +ASD 76 71 0 4 ASC 5 25 0 4 5 DEF +SLD 76 71 12 4 25 12 4 SLD +ASC 76 96 0 4 ASD 5 3 1 4 5 DEF +ALD 76 96 0 4 1 5 4 0 4 3 +SLC 76 96 12 4 3 11 4 SLD +ASC 76 99 1 4 ASC 5 10 4 4 5 DEF +SLC 76 99 11 4 10 8 4 SLD +ASC 76 109 4 4 ASC 5 11 7 4 5 DEF +SLC 76 109 8 4 11 5 4 SLD +SLC 76 120 5 4 5 4 4 SLD +ASC 76 120 7 4 ASC 5 5 8 4 5 DEF +SLC 76 125 4 4 5 3 4 SLD +ASC 76 125 8 4 ASC 5 5 9 4 5 DEF +SLC 76 130 3 4 8 2 4 SLD +ASC 76 130 9 4 ASC 5 8 10 4 5 DEF +SLC 76 138 2 4 10 1 4 SLD +ASC 76 138 10 4 ASC 5 10 11 4 5 DEF +SLC 76 148 1 4 19 0 4 SLD +ASC 76 148 11 4 ASC 5 19 12 4 5 DEF +SLD 76 167 0 4 25 0 4 SLD +ASD 76 167 12 4 ASC 5 25 12 4 5 DEF +AIR 76 192 0 4 SLD DEF +ALD 76 192 12 4 1 5 4 12 4 3 +CHR 76 288 0 3 CE +CHR 76 288 3 3 CE +CHR 76 288 6 4 CE +CHR 76 288 10 3 CE +CHR 76 288 13 3 CE +SLD 77 0 0 3 384 0 3 SLD +SLD 77 96 13 3 288 13 3 SLD +SLD 77 192 3 3 192 3 3 SLD +SLD 77 288 10 3 96 10 3 SLD +SLC 78 0 0 3 16 1 3 SLD +SLC 78 0 3 3 38 4 3 SLD +HLD 78 0 6 4 96 +SLC 78 0 10 3 38 9 3 SLD +SLC 78 0 13 3 16 12 3 SLD +SLC 78 16 1 3 18 2 3 SLD +SLC 78 16 12 3 18 11 3 SLD +SLC 78 34 2 3 21 3 3 SLD +SLC 78 34 11 3 21 10 3 SLD +SLC 78 38 4 3 44 5 3 SLD +SLC 78 38 9 3 44 8 3 SLD +SLC 78 55 3 3 31 4 3 SLD +SLC 78 55 10 3 31 9 3 SLD +SLD 78 82 5 3 14 5 3 SLD +SLD 78 82 8 3 14 8 3 SLD +SLD 78 86 4 3 10 4 3 SLD +SLD 78 86 9 3 10 9 3 SLD +HLD 78 192 0 8 96 +HLD 78 192 8 8 96 +CHR 79 0 0 16 UP +ASD 79 0 0 16 CHR 5 768 7 2 5 DEF + +T_REC_TAP 297 +T_REC_CHR 68 +T_REC_FLK 32 +T_REC_MNE 0 +T_REC_HLD 23 +T_REC_SLD 108 +T_REC_AIR 63 +T_REC_AHD 77 +T_REC_ALL 668 +T_NOTE_TAP 297 +T_NOTE_CHR 68 +T_NOTE_FLK 32 +T_NOTE_MNE 0 +T_NOTE_HLD 23 +T_NOTE_SLD 78 +T_NOTE_AIR 63 +T_NOTE_AHD 56 +T_NOTE_ALL 617 +T_NUM_TAP 398 +T_NUM_CHR 68 +T_NUM_FLK 32 +T_NUM_MNE 0 +T_NUM_HLD 23 +T_NUM_SLD 108 +T_NUM_AIR 119 +T_NUM_AHD 77 +T_NUM_AAC 219 +T_CHRTYPE_UP 63 +T_CHRTYPE_DW 0 +T_CHRTYPE_CE 5 +T_LEN_HLD 18448 +T_LEN_SLD 65819 +T_LEN_AHD 43427 +T_LEN_ALL 127694 +T_JUDGE_TAP 466 +T_JUDGE_HLD 89 +T_JUDGE_SLD 318 +T_JUDGE_AIR 402 +T_JUDGE_FLK 32 +T_JUDGE_ALL 1307 +T_FIRST_MSEC 7307 +T_FIRST_RES 1824 +T_FINAL_MSEC 124615 +T_FINAL_RES 31104 +T_PROG_00 57 +T_PROG_05 62 +T_PROG_10 42 +T_PROG_15 53 +T_PROG_20 46 +T_PROG_25 48 +T_PROG_30 45 +T_PROG_35 83 +T_PROG_40 66 +T_PROG_45 67 +T_PROG_50 60 +T_PROG_55 78 +T_PROG_60 94 +T_PROG_65 68 +T_PROG_70 84 +T_PROG_75 87 +T_PROG_80 66 +T_PROG_85 177 +T_PROG_90 196 +T_PROG_95 56 + 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/tests/chu/testset/\350\207\252\345\210\266\350\260\261/Terminal/terminal.ugc" "b/tests/chu/testset/\350\207\252\345\210\266\350\260\261/Terminal/terminal.ugc" new file mode 100644 index 0000000..bd3a5bf --- /dev/null +++ "b/tests/chu/testset/\350\207\252\345\210\266\350\260\261/Terminal/terminal.ugc" @@ -0,0 +1,2611 @@ +' Created with Margrete v1.7.8.1-87541f1 +@VER 8 +@EXVER 1 +@TITLE Terminal +@SORT +@ARTIST くるぶっこちゃん +@DESIGN 3丁目のこばぶる +@DIFF 3 +@LEVEL 13+ +@CONST 0.00000 +@SONGID terminal +@BGM terminal.wav +@BGMOFS -1.07000 +@BGMPRV 51.12000 69.93000 +@JACKET terminal.png +@BGIMG terminal_bg_1.png +@BGMODE PASSIVE FALSE +@FLDCOL -1 +@FLDIMG +@FLAG DIFFTTL FALSE +@FLAG SOFFSET TRUE +@FLAG CLICK TRUE +@FLAG EXLONG FALSE +@FLAG BGMWCMP TRUE +@FLAG HIPRECISION TRUE +@ATINFO AUTHORS +@ATINFO SITES +@DLURL +@COPYRIGHT +@LICENSE +@TICKS 480 +@BEAT 0 4 4 +@BEAT 64 5 4 +@BEAT 65 2 4 +@BPM 0'0 115.00000 +@TIL 0 0'0 1.00000 +@TIL 1 31'1800 9999.00000 +@TIL 1 33'0 1.00000 +@TIL 0 49'180 0.00000 +@TIL 0 49'240 1.00000 +@TIL 0 49'450 0.00000 +@TIL 0 49'510 1.00000 +@TIL 0 49'1590 0.00000 +@TIL 0 49'1710 1.00000 +@TIL 0 50'480 0.00000 +@TIL 0 50'600 1.00000 +@TIL 0 50'1200 0.00000 +@TIL 0 50'1320 1.00000 +@TIL 0 50'1560 0.00000 +@TIL 0 50'1680 1.00000 +@TIL 2 51'0 99999.00000 +@TIL 0 51'240 0.00000 +@TIL 0 51'360 1.00000 +@TIL 0 51'720 0.00000 +@TIL 0 51'810 1.00000 +@TIL 0 51'1200 0.00000 +@TIL 0 51'1230 1.00000 +@TIL 0 51'1500 0.00000 +@TIL 0 51'1560 1.00000 +@TIL 2 52'1680 1.00000 +@MAINTIL 0 +@ENDHEAD + +#8'0:h03 +#960>s +#8'960:a03URN +#8'360:s73 +#600>c73 +#601>c03 +#1080>c03 +#1082>c0E +#1198>c0E +#1200>cA4 +#1560>sA4 +#8'960:f26A +#8'600:t32 +#8'720:t32 +#8'1440:tA4 +#8'1320:tA4 +#8'1680:t04 +#4'0:h03 +#960>s +#8'720:t52 +#8'600:t52 +#4'360:s73 +#600>c73 +#603>c03 +#1080>c03 +#1110>c04 +#1320>c84 +#1560>s84 +#4'960:f26A +#4'600:t32 +#4'600:t52 +#4'720:t52 +#4'720:t32 +#4'1320:t84 +#0'0:h03 +#960>s +#0'240:s73 +#720>s73 +#0'600:t32 +#0'600:t52 +#0'720:t52 +#0'720:t32 +#0'960:f23R +#0'960:f53A +#0'1800:tA3 +#0'1680:t73 +#0'1320:t23 +#0'1440:t53 +#1'0:hD3 +#960>s +#1'240:s63 +#720>s63 +#1'600:tB2 +#1'600:t92 +#1'720:t92 +#1'720:tB2 +#1'960:fB3L +#1'960:f83A +#1'1320:tB3 +#2'0:h03 +#960>s +#2'240:s73 +#720>s73 +#2'600:t32 +#2'600:t52 +#2'720:t52 +#2'720:t32 +#2'960:f23R +#2'960:f53A +#2'1800:tA3 +#2'1680:t73 +#2'1320:t23 +#2'1440:t53 +#3'0:hD3 +#960>s +#3'240:s63 +#720>s63 +#3'600:tB2 +#3'600:t92 +#3'720:t92 +#3'720:tB2 +#3'960:fB3L +#3'960:f83A +#3'1320:tB3 +#1'1440:t24 +#1'1560:t24 +#1'1680:t84 +#1'1800:t84 +#3'1440:t24 +#3'1560:t24 +#3'1680:t84 +#3'1800:t84 +#7'0:h04 +#240>s +#7'240:h44 +#240>s +#7'480:h84 +#240>s +#7'720:hC4 +#240>s +#7'0:s04 +#1>c12 +#120>c04 +#239>c12 +#240>s04 +#7'240:s44 +#1>c42 +#120>c44 +#239>c42 +#240>s44 +#7'720:sC4 +#1>cE2 +#120>cC4 +#239>cE2 +#240>sC4 +#7'480:s84 +#120>c92 +#240>s84 +#8'0:x03U +#7'960:t04 +#7'1080:t04 +#7'1200:t44 +#7'1320:t44 +#7'1440:t84 +#7'1560:t84 +#7'1680:tC4 +#7'1800:tC4 +#4'1440:h84 +#240>s +#4'240:t73 +#5'0:h23 +#960>s +#5'240:s93 +#720>c93 +#723>c23 +#1200>c23 +#1230>c24 +#1440>cA4 +#1680>sA4 +#5'960:f46L +#5'600:t52 +#5'600:t72 +#5'720:t72 +#5'720:t52 +#5'1320:tA4 +#5'1440:hA4 +#240>s +#6'0:h43 +#960>s +#6'360:sB3 +#600>cB3 +#603>c43 +#1080>c43 +#1110>c44 +#1320>cC4 +#1560>sC4 +#6'960:f66L +#6'600:t72 +#6'600:t92 +#6'720:t92 +#6'720:t72 +#6'1320:tC4 +#6'1440:hC4 +#240>s +#6'240:tB3 +#8'240:t73 +@USETIL 1 +#31'1800:C727S1,0 +#480>c727S +#1020>c7200 +#1080>c7200 +#32'1770:C72001,0 +#30>c7200 +#32'1830:C72001,0 +#30>c7200 +#32'1890:C72001,0 +#30>c7200 +@USETIL 0 +#32'960:t15 +#32'960:tB3 +#32'1200:tC4 +#32'1440:tA2 +#32'1440:t07 +#32'1200:t22 +@USETIL 1 +#32'1710:C72001,0 +#30>c7200 +#32'1080:C72001,0 +#120>c7200 +#32'1320:C72001,0 +#120>c7200 +#32'1560:C72001,0 +#120>c7200 +@USETIL 0 +#5'1680:f64L +#8'960:C332S0,$ +#5>c0300 +#9'0:h24 +#240>s +#9'240:h84 +#240>s +#9'480:f04A +#9'600:h64 +#360>s +#9'960:hD3 +#480>s +#9'720:s04 +#240>c04 +#242>c64 +#420>c64 +#540>c54 +#600>c44 +#675>c24 +#720>s04 +#9'960:f08R +#9'960:f88A +#9'1320:tA3 +#9'1440:tA3 +#9'1680:t64 +#9'1800:t44 +#9'1560:t04 +#10'0:hD3 +#960>s +#10'960:aD3ULN +#10'360:s63 +#600>c63 +#601>cD3 +#1080>cD3 +#1082>c2E +#1198>c2E +#1200>c24 +#1560>s24 +#10'960:f86A +#10'1440:t24 +#10'1320:t24 +#10'1680:tC4 +#10'240:t63 +#10'960:CA32S0,$ +#5>cD300 +#11'0:hA4 +#240>s +#11'240:h44 +#240>s +#11'480:fC4A +#11'600:h64 +#360>s +#11'960:h03 +#480>s +#11'720:sC4 +#240>cC4 +#242>c64 +#420>c64 +#540>c74 +#600>c84 +#675>cA4 +#720>sC4 +#11'960:f88L +#11'960:f08A +#11'1320:t33 +#11'1440:t33 +#11'1680:tA4 +#11'1800:t64 +#11'1560:t64 +#10'480:tB2 +#10'600:tA2 +#10'720:t92 +#10'840:tA2 +#12'0:h03 +#960>s +#12'960:a03URN +#12'360:s73 +#600>c73 +#601>c03 +#1080>c03 +#1082>c0E +#1198>c0E +#1200>cA4 +#1560>sA4 +#12'960:f26L +#12'600:t32 +#12'720:t32 +#12'1440:tA4 +#12'1320:tA4 +#12'1680:t04 +#12'720:t52 +#12'600:t52 +#12'240:t33 +#12'960:C332S0,$ +#5>c0300 +#13'0:h24 +#240>s +#13'240:h84 +#240>s +#13'480:f04A +#13'600:h64 +#360>s +#13'960:hD3 +#480>s +#13'720:s04 +#240>c04 +#242>c64 +#420>c64 +#540>c54 +#600>c44 +#675>c24 +#720>s04 +#13'960:f08R +#13'960:f88A +#13'1320:tA3 +#13'1440:tA3 +#13'1680:t64 +#13'1800:t44 +#13'1560:t04 +#12'0:x03U +#12'120:t73 +#13'1680:tA4 +#13'1800:t04 +#14'0:hD3 +#960>s +#14'960:aD3ULN +#14'360:s63 +#600>c63 +#601>cD3 +#1080>cD3 +#1082>c2E +#1198>c2E +#1200>c24 +#1560>s24 +#14'960:f86A +#14'1440:t24 +#14'1320:t24 +#14'1680:tC4 +#14'240:tA3 +#14'960:CA32S0,$ +#5>cD300 +#15'0:hA4 +#240>s +#15'240:h44 +#240>s +#15'480:fC4A +#15'600:h64 +#360>s +#15'960:h03 +#480>s +#15'720:sC4 +#240>cC4 +#242>c64 +#420>c64 +#540>c74 +#600>c84 +#675>cA4 +#720>sC4 +#15'960:f88L +#15'960:f08A +#15'1320:t33 +#15'1440:t33 +#15'1680:tC4 +#15'1800:t04 +#15'1560:t64 +#14'120:t63 +#15'1680:t84 +#15'1800:t44 +#14'480:tB2 +#14'600:tA2 +#14'720:t92 +#14'840:tA2 +#14'1320:t64 +#14'1440:t64 +#12'1320:t64 +#12'1440:t64 +#16'0:s84 +#45>cA4 +#90>cB4 +#150>cC4 +#300>cD3 +#478>cD3 +#480>c83 +#1920>c83 +#1950>cA3 +#1980>cB3 +#2040>cC3 +#2160>cD3 +#2400>cD3 +#2401>c83 +#2880>c83 +#3840>s83 +#16'0:s84 +#45>cA4 +#90>cB4 +#150>cC4 +#300>cD3 +#960>cD3 +#990>cB3 +#1020>cA3 +#1080>c93 +#1200>c83 +#1440>c83 +#1442>cD3 +#1920>cD3 +#2880>cD3 +#2910>cB3 +#2940>cA3 +#3000>c93 +#3120>c83 +#3360>c83 +#3361>cD3 +#3840>sD3 +#16'480:f86L +#16'1440:fA6A +#17'480:f86L +#17'1440:fA6A +#18'0:s44 +#45>c24 +#90>c14 +#150>c04 +#300>c03 +#478>c03 +#480>c53 +#1920>c53 +#1950>c33 +#1980>c23 +#2040>c13 +#2160>c03 +#2400>c03 +#2401>c53 +#2880>c53 +#3840>s53 +#18'0:s44 +#45>c24 +#90>c14 +#150>c04 +#300>c03 +#960>c03 +#990>c23 +#1020>c33 +#1080>c43 +#1200>c53 +#1440>c53 +#1442>c03 +#1920>c03 +#2880>c03 +#2910>c23 +#2940>c33 +#3000>c43 +#3120>c53 +#3360>c53 +#3361>c03 +#3840>s03 +#18'480:f26R +#18'1440:f06A +#19'480:f26R +#19'1440:f06A +#16'0:x84I +#16'240:t23 +#16'360:t23 +#16'600:t03 +#16'840:t03 +#16'1080:t23 +#16'1200:t23 +#16'1440:h03 +#240>s +#17'240:t23 +#17'360:t23 +#17'600:t03 +#17'840:t03 +#17'1080:t23 +#17'1320:t23 +#17'1440:h23 +#240>s +#18'360:tB3 +#18'240:tB3 +#18'600:tD3 +#18'840:tD3 +#18'1320:tB3 +#18'1200:tB3 +#18'1560:t93 +#18'1800:t93 +#19'0:hB3 +#120>s +#19'240:hB3 +#120>s +#19'480:hE2 +#240>s +#19'840:hE2 +#240>s +#19'1200:hE2 +#240>s +#19'1560:hE2 +#240>s +#19'480:hC2 +#240>s +#19'840:hB2 +#240>s +#19'1200:hA2 +#240>s +#19'1560:h92 +#240>s +#20'120:t84 +#20'240:t44 +#20'360:t04 +#20'480:f77A +#20'600:t04 +#20'600:t44 +#20'840:t44 +#20'840:t04 +#20'840:tC4 +#20'1080:t64 +#20'1200:t24 +#20'1200:tA4 +#20'1320:t64 +#20'1440:f97A +#20'1440:t07 +#21'0:t04 +#21'120:t44 +#21'240:t84 +#21'360:tC4 +#22'0:tC4 +#22'120:t84 +#22'240:t44 +#22'360:t04 +#20'1680:h04 +#120>s +#20'1800:h44 +#120>s +#20'1680:hC4 +#120>s +#20'1800:h84 +#120>s +#21'480:f27A +#21'600:tC4 +#21'720:t84 +#21'840:t44 +#21'840:t04 +#21'840:hC4 +#240>s +#21'1080:h84 +#240>s +#21'1200:t44 +#21'1200:t04 +#21'1320:tC4 +#21'1440:f07A +#21'1440:tC4 +#21'1680:h04 +#120>s +#21'1680:hC4 +#120>s +#21'1800:h84 +#120>s +#21'1800:h44 +#120>s +#22'0:t04 +#20'0:x84U +#20'0:xC4U +#22'480:f77A +#22'600:t04 +#22'720:t44 +#22'840:t84 +#22'840:tC4 +#22'840:t04 +#22'1200:t04 +#22'1200:t84 +#22'1200:tC4 +#22'1320:tC4 +#22'1440:f07A +#22'1560:tC4 +#22'1680:t84 +#22'1800:t44 +#23'0:h04 +#120>s +#23'0:hC4 +#120>s +#23'240:h04 +#120>s +#23'240:hC4 +#120>s +#23'480:hC4 +#240>s +#23'480:h04 +#240>s +#23'840:hB4 +#240>s +#23'840:h14 +#240>s +#23'1200:h24 +#240>s +#23'1200:hA4 +#240>s +#23'600:t48 +#23'960:t56 +#23'1320:t64 +#23'1560:s94 +#240>s72 +#23'1560:s34 +#240>s72 +#23'1560:h72 +#240>s +#24'0:x04U +#24'0:xC4U +#24'0:h04 +#240>s +#24'120:h44 +#240>s +#24'240:h84 +#240>s +#24'360:hC4 +#240>s +#24'480:s14 +#240>s14 +#24'600:s54 +#240>s54 +#24'720:s94 +#240>s94 +#24'960:a94ULN +#24'840:sD3 +#120>sD3 +#24'960:aD3ULN +#25'480:fC4A +#25'600:h64 +#360>s +#25'960:h03 +#480>s +#25'720:sC4 +#240>cC4 +#242>c64 +#420>c64 +#540>c74 +#600>c84 +#675>cA4 +#720>sC4 +#25'960:f88L +#25'960:f08A +#25'1320:t33 +#25'1440:t33 +#25'1560:t64 +#25'1680:tC4 +#25'1680:t84 +#25'1800:t44 +#25'1800:t04 +#24'960:s24 +#25>c54 +#50>c74 +#90>c94 +#120>cA4 +#180>cB4 +#300>cC4 +#480>cC4 +#481>c0G +#599>c0G +#600>c04 +#960>s04 +#24'960:C642S0,$ +#1>c9400 +#24'960:CA32S0,$ +#1>cD300 +#24'1320:t04 +#24'1440:t44 +#24'1320:t44 +#24'1440:t04 +#24'1680:tC4 +#25'240:h23 +#240>s +#25'240:h53 +#240>s +#25'0:hB3 +#240>s +#25'0:h83 +#240>s +#26'480:sB4 +#240>sB4 +#26'600:s74 +#240>s74 +#26'720:s34 +#240>s34 +#26'960:a34URN +#26'840:s03 +#120>s03 +#26'960:a03URN +#27'480:f04A +#26'960:sA4 +#25>c74 +#50>c54 +#90>c34 +#120>c24 +#180>c14 +#300>c04 +#480>c04 +#481>c0G +#599>c0G +#600>cC4 +#960>sC4 +#26'960:C642S0,$ +#1>c3400 +#26'960:C332S0,$ +#1>c0300 +#26'1320:tC4 +#26'1440:t84 +#26'1320:t84 +#26'1440:tC4 +#26'1680:t04 +#27'240:hB3 +#240>s +#27'240:h83 +#240>s +#27'0:h23 +#240>s +#27'0:h53 +#240>s +#26'0:hC4 +#240>s +#26'0:h84 +#240>s +#26'240:h04 +#240>s +#26'240:h44 +#240>s +#27'840:fC4A +#27'600:t44 +#27'720:t84 +#27'960:t84 +#27'1080:t44 +#27'1200:t04 +#27'1200:tC4 +#27'1320:t84 +#27'1320:t44 +#27'1440:s04 +#7>c04 +#18>c14 +#30>c44 +#41>c24 +#52>c14 +#67>c14 +#78>c24 +#90>c54 +#101>c34 +#112>c24 +#127>c24 +#138>c34 +#150>c64 +#161>c44 +#172>c34 +#187>c34 +#198>c44 +#210>c64 +#217>c54 +#232>c44 +#247>c44 +#258>c54 +#270>c73 +#277>c63 +#292>c53 +#307>c53 +#322>c63 +#330>c73 +#352>c62 +#367>c62 +#390>c72 +#480>s72 +#27'1440:sC4 +#7>cC4 +#18>cB4 +#30>c84 +#41>cA4 +#52>cB4 +#67>cB4 +#78>cA4 +#90>c74 +#101>c94 +#112>cA4 +#127>cA4 +#138>c94 +#150>c64 +#161>c84 +#172>c94 +#187>c94 +#198>c84 +#210>c64 +#217>c74 +#232>c84 +#247>c84 +#258>c74 +#270>c63 +#277>c73 +#292>c83 +#307>c83 +#322>c73 +#330>c63 +#352>c82 +#367>c82 +#390>c72 +#480>s72 +#28'0:a72UCN +#27'1440:x04D +#27'1440:xC4D +#33'0:hA2 +#240>s +#33'0:h42 +#240>s +#33'360:s42 +#15>c32 +#45>c22 +#120>c12 +#240>s12 +#33'360:sA2 +#15>cB2 +#45>cC2 +#120>cD2 +#240>sD2 +#33'840:hD2 +#360>s +#33'840:h12 +#360>s +#33'600:f32R +#33'600:fB2L +#33'1200:hA2 +#240>s +#33'1200:h42 +#240>s +#33'1440:hC2 +#240>s +#33'1440:h22 +#240>s +#33'1680:s52 +#240>s44 +#33'1680:s92 +#240>s84 +#34'120:h84 +#240>s +#34'120:h44 +#240>s +#34'480:h84 +#120>s +#34'480:h44 +#120>s +#34'600:f14L +#34'600:fB4R +#34'840:hC4 +#840>s +#34'840:s04 +#240>c04 +#330>c08 +#420>c48 +#510>c88 +#600>cC4 +#840>sC4 +#34'1680:f94L +#28'0:t04 +#28'480:t64 +#28'480:a64UCN +#28'480:tC4 +#28'960:t64 +#28'960:a64UCN +#28'960:t04 +#28'1440:t64 +#28'1440:a64UCN +#28'1440:tC4 +#29'0:t03 +#29'0:t56 +#29'0:a56UCN +#29'480:tD3 +#29'480:t56 +#29'480:a56UCN +#29'960:t03 +#29'960:t56 +#29'960:a56UCN +#29'1440:tD3 +#29'1440:t56 +#29'1440:a56UCN +#28'240:t34 +#28'240:t94 +#28'720:t34 +#28'720:t94 +#28'600:tB4 +#28'840:t14 +#28'1200:t34 +#28'1200:t94 +#28'1320:tB4 +#28'1680:t34 +#28'1680:t94 +#28'1800:t14 +#29'240:t95 +#29'240:a95DCN +#29'240:t25 +#29'720:t95 +#29'720:t25 +#29'720:a25DCN +#29'600:tB4 +#29'1200:t25 +#29'1200:t95 +#29'1200:a95DCN +#29'1320:tB4 +#29'1680:t25 +#29'1680:a25DCN +#29'1680:t95 +#30'0:t02 +#30'0:t48 +#30'0:a48UCN +#29'1800:t14 +#29'1800:t76 +#30'480:tE2 +#30'480:t48 +#30'480:a48UCN +#30'960:t02 +#30'960:t48 +#30'960:a48UCN +#30'1440:tE2 +#30'1440:t48 +#30'1440:a48UCN +#31'0:t02 +#31'0:t48 +#31'0:a48UCN +#31'480:tE2 +#31'480:t48 +#31'480:a48UCN +#30'240:t25 +#30'720:t95 +#30'1200:t25 +#30'240:f95A +#30'240:a95DRN +#30'720:f25A +#30'720:a25DLN +#30'1200:f95A +#30'1200:a95DRN +#30'1680:f25A +#30'1680:a25DLN +#30'1680:t95 +#30'600:tC3 +#30'1080:t13 +#30'1560:tC3 +#31'120:t13 +#31'240:t24 +#31'360:t36 +#31'240:f95A +#31'240:a95DRN +#31'720:f25A +#31'720:a25DLN +#31'600:tC3 +#31'720:tA4 +#31'840:t76 +#31'960:t48 +#31'1080:t48 +#31'1200:t35 +#31'1200:t85 +#31'1320:t35 +#31'1320:t85 +#31'1440:t24 +#31'1440:tA4 +#31'1440:t64 +#31'1560:t14 +#31'1560:t56 +#31'1560:tB4 +#31'1680:t04 +#31'1800:t03 +#31'1680:tC4 +#31'1800:tD3 +#31'1680:t44 +#31'1680:t84 +#31'1800:t35 +#31'1800:t85 +#35'0:hD2 +#240>s +#35'360:sD2 +#15>cC2 +#45>cB2 +#120>cA2 +#240>sA2 +#35'600:fC2R +#35'840:hD2 +#240>s +#35'840:hB2 +#360>s +#35'1440:sB2 +#15>cA2 +#45>c92 +#120>c82 +#240>s82 +#35'1680:fA2R +#35'1800:fA2L +#36'120:t92 +#36'240:t92 +#36'480:tD2 +#36'600:tA2 +#36'720:tC2 +#36'960:tD2 +#36'960:tB2 +#36'1080:tB2 +#36'1080:tD2 +#36'1320:t92 +#36'1320:tB2 +#36'1440:h92 +#240>s +#36'1440:hB2 +#240>s +#36'1680:fD2A +#35'0:h52 +#240>s +#35'360:s52 +#15>c42 +#45>c32 +#120>c22 +#240>s22 +#35'600:f42R +#35'840:h52 +#240>s +#35'840:h32 +#360>s +#35'1440:s32 +#15>c22 +#45>c12 +#120>c02 +#240>s02 +#35'1680:f22R +#35'1800:f22L +#36'120:t12 +#36'240:t12 +#36'480:t52 +#36'600:t22 +#36'720:t42 +#36'960:t52 +#36'960:t32 +#36'1080:t32 +#36'1080:t52 +#36'1320:t52 +#36'1320:t32 +#36'1440:h52 +#240>s +#36'1440:h32 +#240>s +#36'1680:f12A +#37'0:h42 +#240>s +#37'0:hA2 +#240>s +#37'360:sA2 +#15>cB2 +#45>cC2 +#120>cD2 +#240>sD2 +#37'360:s42 +#15>c32 +#45>c22 +#120>c12 +#240>s12 +#37'840:h12 +#360>s +#37'840:hD2 +#360>s +#37'600:fB2L +#37'600:f32R +#37'1200:h42 +#240>s +#37'1200:hA2 +#240>s +#37'1440:h22 +#240>s +#37'1440:hC2 +#240>s +#37'1680:s92 +#240>s84 +#37'1680:s52 +#240>s44 +#38'120:h44 +#240>s +#38'120:h84 +#240>s +#38'480:h44 +#120>s +#38'480:h84 +#120>s +#38'600:fB4R +#38'600:f14A +#38'840:h04 +#840>s +#38'840:sC4 +#240>cC4 +#330>c88 +#420>c48 +#510>c08 +#600>c04 +#840>s04 +#38'1680:f34R +#39'0:h12 +#240>s +#39'360:s12 +#15>c22 +#45>c32 +#120>c42 +#240>s42 +#39'600:f22L +#39'840:h12 +#240>s +#39'840:h32 +#360>s +#39'1440:s32 +#15>c42 +#45>c52 +#120>c62 +#240>s62 +#39'1680:f42L +#39'1800:f42R +#40'120:t52 +#40'240:t52 +#40'480:t12 +#40'600:t42 +#40'720:t22 +#40'960:t12 +#40'960:t32 +#40'1080:t32 +#40'1080:t12 +#40'1320:t52 +#40'1320:t32 +#40'1440:h52 +#240>s +#40'1440:h32 +#240>s +#40'1680:f12A +#39'0:h92 +#240>s +#39'360:s92 +#15>cA2 +#45>cB2 +#120>cC2 +#240>sC2 +#39'600:fA2L +#39'840:h92 +#240>s +#39'840:hB2 +#360>s +#39'1440:sB2 +#15>cC2 +#45>cD2 +#120>cE2 +#240>sE2 +#39'1680:fC2L +#39'1800:fC2R +#40'120:tD2 +#40'240:tD2 +#40'480:t92 +#40'600:tC2 +#40'720:tA2 +#40'960:t92 +#40'960:tB2 +#40'1080:tB2 +#40'1080:t92 +#40'1320:t92 +#40'1320:tB2 +#40'1440:h92 +#240>s +#40'1440:hB2 +#240>s +#40'1680:fD2A +#41'0:s2C +#7680>c72 +#21120>s0G +#41'0:s2C +#7680>c72 +#8145>c72 +#8160>c64 +#8175>c72 +#9105>c72 +#9120>c56 +#9135>c72 +#10065>c72 +#10080>c48 +#10095>c64 +#11025>c64 +#11040>c3A +#11055>c64 +#11985>c64 +#12000>c2C +#12015>c56 +#12945>c56 +#12960>c1E +#12975>c56 +#13905>c56 +#13920>c0G +#13935>c48 +#14865>c48 +#14880>c0G +#14895>c48 +#15360>c48 +#15372>cA4 +#15396>c74 +#15420>c54 +#15444>c54 +#15468>c64 +#15492>c54 +#15516>c44 +#15540>c64 +#15552>c34 +#15576>c64 +#15600>c44 +#15624>c44 +#15648>c54 +#15672>c54 +#15696>c34 +#15720>c34 +#15732>c34 +#15756>c44 +#15780>c54 +#15804>cA4 +#15828>c94 +#15852>c54 +#15876>c44 +#15900>c44 +#15912>c44 +#15936>c54 +#15960>cA4 +#15984>c24 +#16008>c64 +#16032>c64 +#16056>c64 +#16080>c44 +#16092>c44 +#16116>c44 +#16140>c44 +#16164>c34 +#16188>c44 +#16212>c54 +#16236>cA4 +#16260>c74 +#16272>c54 +#16296>c54 +#16320>c64 +#16344>c44 +#16368>c54 +#16392>c54 +#16416>c54 +#16440>c44 +#16452>c34 +#16476>cA4 +#16500>c24 +#16524>cA4 +#16548>c84 +#16572>c44 +#16596>c34 +#16620>c34 +#16632>c54 +#16656>cA4 +#16680>c84 +#16704>c44 +#16728>c54 +#16752>cA4 +#16776>c84 +#16800>c54 +#16812>c44 +#16836>cA4 +#16860>c64 +#16884>c54 +#16908>c64 +#16932>c34 +#16956>cA4 +#16980>c24 +#16992>c54 +#17016>c44 +#17040>c64 +#17064>c64 +#17088>c54 +#17112>c54 +#17136>c64 +#17160>c44 +#17172>c44 +#17196>c34 +#17220>c34 +#17244>c34 +#17268>cA4 +#17280>c56 +#17316>c96 +#17340>c16 +#17352>c36 +#17376>c26 +#17400>c56 +#17424>c46 +#17448>c96 +#17472>c56 +#17496>c46 +#17520>c26 +#17532>c56 +#17556>c46 +#17580>c56 +#17604>c46 +#17628>c26 +#17652>c46 +#17676>c36 +#17700>c46 +#17712>c56 +#17736>c46 +#17760>c26 +#17784>c46 +#17808>c56 +#17832>c36 +#17856>c46 +#17880>c96 +#17892>c66 +#17916>c96 +#17940>c76 +#17964>c56 +#17988>c56 +#18012>c96 +#18036>c66 +#18060>c36 +#18072>c96 +#18096>c56 +#18120>c26 +#18144>c36 +#18168>c96 +#18192>c66 +#18216>c36 +#18240>c96 +#18252>c16 +#18276>c46 +#18300>c46 +#18324>c36 +#18348>c36 +#18372>c46 +#18396>c46 +#18420>c36 +#18432>c96 +#18456>c66 +#18480>c36 +#18504>c36 +#18528>c36 +#18552>c46 +#18576>c56 +#18600>c96 +#18612>c56 +#18636>c26 +#18660>c56 +#18684>c46 +#18708>c56 +#18732>c96 +#18756>c56 +#18780>c46 +#18792>c26 +#18816>c36 +#18840>c36 +#18864>c36 +#18888>c46 +#18912>c36 +#18936>c56 +#18960>c96 +#18972>c16 +#18996>c36 +#19020>c46 +#19044>c26 +#19068>c56 +#19092>c26 +#19116>c26 +#19140>c26 +#19152>c26 +#19176>c26 +#19200>c78 +#19224>c38 +#19248>c18 +#19272>c48 +#19296>c28 +#19320>c38 +#19332>c48 +#19356>c88 +#19380>c58 +#19404>c88 +#19428>c08 +#19452>c18 +#19476>c28 +#19500>c88 +#19512>c08 +#19536>c88 +#19560>c58 +#19584>c88 +#19608>c08 +#19632>c38 +#19656>c28 +#19680>c88 +#19692>c08 +#19716>c48 +#19740>c28 +#19764>c88 +#19788>c48 +#19812>c18 +#19836>c88 +#19860>c48 +#19872>c48 +#19896>c18 +#19920>c28 +#19944>c38 +#19968>c28 +#19992>c18 +#20016>c38 +#20040>c28 +#20052>c88 +#20076>c48 +#20100>c18 +#20124>c88 +#20148>c48 +#20172>c88 +#20196>c08 +#20220>c48 +#20232>c18 +#20256>c38 +#20280>c48 +#20304>c88 +#20328>c08 +#20352>c88 +#20376>c58 +#20400>c28 +#20412>c28 +#20436>c48 +#20460>c28 +#20484>c48 +#20508>c88 +#20532>c58 +#20556>c48 +#20580>c38 +#20592>c18 +#20616>c88 +#20640>c78 +#20664>c38 +#20688>c48 +#20712>c28 +#20736>c38 +#20760>c88 +#20772>c78 +#20796>c38 +#20820>c38 +#20844>c28 +#20868>c28 +#20892>c88 +#20916>c78 +#20940>c88 +#20952>c08 +#20976>c38 +#21000>c88 +#21024>c58 +#21048>c28 +#21072>c18 +#21096>c38 +#21120>s38 +#70'480:h22 +#120>s +#70'720:h22 +#120>s +#71'0:h22 +#240>s +#71'360:h32 +#240>s +#71'720:h42 +#240>s +#72'120:s52 +#240>s71 +#72'360:a71UCN +#72'120:h72 +#240>s +#72'360:a72UCN +#70'720:hC2 +#120>s +#71'0:hC2 +#240>s +#71'360:hB2 +#240>s +#71'720:hA2 +#240>s +#72'120:s92 +#240>s81 +#72'360:a81UCN +#71'120:t48 +#71'480:t56 +#71'840:t64 +#45'960:t33 +#45'960:S330UN +#480>c330U +#46'0:tA4 +#46'0:SA414N +#480>cA414 +#46'960:t15 +#46'960:S151EN +#480>c151E +#47'0:tA6 +#47'0:SA61ON +#480>cA61O +#47'960:t07 +#47'960:S071YN +#480>c071Y +#48'0:t88 +#48'0:S8828N +#480>c8828 +#48'960:t09 +#48'960:S092IN +#480>c092I +@USETIL 1 +#49'0:t0G +#49'0:S0G2SN +#5760>c0G82 +@USETIL 0 +#41'0:hE2 +#360>s +#41'360:aE2UCN +#41'1200:f08L +#41'1200:a08ULN +#41'1200:f88L +#42'720:f08R +#42'720:f88R +#42'720:a88URN +#41'720:sE2 +#150>cF1 +#330>cF1 +#480>sE2 +#42'360:s03 +#120>c02 +#240>c02 +#360>s03 +#41'1680:h12 +#240>s +#42'0:hD2 +#360>s +#42'1200:hC2 +#240>s +#42'1440:h22 +#240>s +#42'1680:hC2 +#240>s +#43'0:h22 +#360>s +#43'720:sC4 +#60>cD3 +#180>cE2 +#300>cE2 +#420>cD3 +#480>sC4 +#43'360:tC2 +#43'360:aC2UCN +#43'1200:f08L +#43'1200:a08ULN +#43'1200:f88L +#43'1680:h32 +#240>s +#44'0:hB2 +#360>s +#44'360:s05 +#30>c04 +#65>c03 +#135>c02 +#225>c02 +#295>c03 +#330>c04 +#360>s05 +#44'720:f08R +#44'720:f88R +#44'720:a88URN +#44'1200:hA2 +#240>s +#44'1440:h42 +#240>s +#44'1680:hA2 +#240>s +#45'0:SA20KN +#480>cA20K +#45'480:CA20KZ,$ +#120>cA20K +#45'1440:C330UZ,$ +#120>c330U +#46'480:CA414Z,$ +#120>cA414 +#46'1440:C151EZ,$ +#120>c151E +#47'480:CA61OZ,$ +#120>cA61O +#47'1440:C071YZ,$ +#120>c071Y +#48'480:C8828Z,$ +#120>c8828 +#48'1440:C092IZ,$ +#120>c092I +@USETIL 2 +#52'0:C7228D,0 +#960>c7228 +#52'960:C91286,0 +#240>c9128 +#52'1200:CA2288,0 +#240>cA228 +#52'1440:CC1281,0 +#240>cC128 +#52'960:C61286,0 +#240>c6128 +#52'1200:C42288,0 +#240>c4228 +#52'1440:C31281,0 +#240>c3128 +@USETIL 0 +#53'0:h03 +#240>s +#53'120:h34 +#240>s +#53'240:h74 +#240>s +#53'360:hB4 +#240>s +#53'480:s14 +#240>s14 +#53'600:s54 +#240>s54 +#53'720:s94 +#240>s94 +#53'960:a94ULN +#53'840:sD3 +#120>sD3 +#53'960:aD3ULN +#53'0:xC4C +#53'0:aC4UCN +#53'240:t75 +#53'600:t55 +#53'720:t95 +#53'960:s24 +#20>c54 +#40>c74 +#55>c84 +#75>c94 +#105>cA4 +#150>cB4 +#240>cC4 +#480>sC4 +#481>c08 +#720>s08 +#53'1200:h08 +#240>s +#53'1440:a08URN +#53'1440:f4AL +#53'1680:t88 +#53'1680:a88DRN +#53'1200:t08 +#53'1200:a08DLN +#55'0:hD3 +#240>s +#55'120:h94 +#240>s +#55'240:h54 +#240>s +#55'360:h14 +#240>s +#55'480:sB4 +#120>cA5 +#240>sB4 +#55'600:s73 +#120>c74 +#240>s74 +#55'720:s34 +#120>c43 +#240>s34 +#55'960:a34URN +#55'840:s04 +#120>s03 +#55'960:a03URN +#55'0:t04 +#55'0:a04UCN +#55'240:t45 +#55'600:t64 +#55'720:t25 +#55'960:sA4 +#20>c74 +#40>c54 +#55>c44 +#75>c34 +#105>c24 +#150>c14 +#240>c04 +#360>c06 +#480>s04 +#481>c88 +#600>cC4 +#720>s88 +#55'1200:h88 +#240>s +#55'1440:a88ULN +#55'1440:f2AR +#55'1680:t08 +#55'1680:a08DLN +#55'1200:t88 +#55'1200:a88DRN +#55'0:tC4 +#54'600:h64 +#360>s +#54'720:sC4 +#240>cC4 +#242>c64 +#960>s64 +#54'960:f88L +#54'960:f08L +#54'960:a08ULN +#54'1200:t04 +#54'1200:a04DCN +#54'1320:h04 +#120>s +#54'1440:f16R +#54'1440:f96R +#54'1440:a96URN +#54'1680:tC4 +#54'1680:aC4DCN +#54'0:t03 +#54'0:t43 +#54'240:t63 +#54'240:t23 +#54'120:t73 +#54'120:tB3 +#54'360:hD3 +#240>s +#54'360:s93 +#30>cB3 +#60>cC3 +#120>cD3 +#240>sD3 +#56'600:h64 +#360>s +#56'720:s04 +#120>c03 +#240>c04 +#242>c64 +#360>c72 +#480>c64 +#600>c72 +#720>c64 +#840>c72 +#960>s64 +#56'960:f08R +#56'960:f88R +#56'960:a88URN +#56'1200:tC4 +#56'1200:aC4DCN +#56'1320:hC4 +#120>s +#56'1440:f96L +#56'1440:f16L +#56'1440:a16ULN +#56'1680:t04 +#56'1680:a04DCN +#56'0:tD3 +#56'0:t93 +#56'240:t73 +#56'240:tB3 +#56'120:t63 +#56'120:t23 +#56'360:h03 +#240>s +#56'360:s43 +#30>c23 +#60>c13 +#120>c03 +#240>s03 +#60'0:tC4 +#60'60:t84 +#60'120:t44 +#60'180:t04 +#60'240:tC3 +#60'300:t84 +#60'360:t44 +#60'420:t13 +#60'480:tB3 +#60'540:t83 +#60'600:t53 +#60'660:t23 +#60'780:t83 +#60'840:t53 +#60'900:t32 +#60'720:tB2 +#60'960:s64 +#480>s72 +#60'1440:S7228N +#15>c8228 +#35>c9228 +#60>cA228 +#90>cB228 +#135>cC228 +#210>cD228 +#360>cE228 +#480>sE228 +#56'480:t64 +#60'960:s64 +#480>s72 +#60'1440:S7228N +#15>c6228 +#35>c5228 +#60>c4228 +#90>c3228 +#135>c2228 +#210>c1228 +#360>c0228 +#480>s0228 +#57'120:t33 +#57'240:t63 +#57'360:t93 +#57'840:tD3 +#57'720:tA3 +#57'600:t73 +#57'480:x43U +#57'0:x03U +#57'0:xA4U +#57'0:aA4UCN +#57'480:x04U +#57'480:a04UCN +#57'240:tC4 +#57'240:aC4DCN +#57'720:t24 +#57'720:a24DCN +#57'960:sA4 +#15>c74 +#30>c54 +#55>c34 +#75>c24 +#105>c14 +#180>c04 +#240>s04 +#57'960:x44U +#57'960:a44URN +#57'1200:hA4 +#240>s +#57'1200:sA4 +#120>cD3 +#240>sA4 +#57'1200:sA4 +#120>c83 +#240>sA4 +#57'1440:aA4ULN +#57'1200:tA4 +#57'1200:aA4DRN +#57'1440:s24 +#15>c54 +#30>c74 +#55>c94 +#75>cA4 +#105>cB4 +#180>cC4 +#240>sC4 +#57'1680:t08 +#57'1680:a08DLN +#57'1440:x24U +#57'960:C64280,$ +#1>c4400 +#53'960:C74280,$ +#1>c9400 +#53'960:CB3280,$ +#1>cD300 +#58'120:tA3 +#58'240:t73 +#58'360:t43 +#58'0:xD3U +#58'0:x24U +#58'0:a24UCN +#58'480:xC4U +#58'480:aC4UCN +#58'240:t04 +#58'240:a04DCN +#58'720:tA4 +#58'720:aA4DCN +#58'960:s24 +#15>c54 +#30>c74 +#55>c94 +#75>cA4 +#105>cB4 +#180>cC4 +#240>sC4 +#58'1200:h24 +#240>s +#58'1200:s24 +#120>c03 +#240>s24 +#58'1200:s24 +#120>c53 +#240>s24 +#58'1440:a24URN +#58'1200:t24 +#58'1200:a24DLN +#58'1440:sA4 +#15>c74 +#30>c54 +#55>c34 +#75>c24 +#105>c14 +#180>c04 +#240>s04 +#58'1680:t88 +#58'1680:a88DRN +#58'1440:xA4U +#58'960:x84U +#58'960:a84ULN +#58'720:t13 +#58'720:t53 +#58'600:t73 +#58'600:t33 +#59'120:tC4 +#59'240:t84 +#59'360:t84 +#59'480:t44 +#59'600:t44 +#59'720:t04 +#59'840:t04 +#59'960:tD3 +#59'960:tA3 +#59'1080:tA3 +#59'1080:tD3 +#59'1440:t03 +#59'1440:t33 +#59'1560:t33 +#59'1560:t03 +#59'1200:t56 +#59'1320:t56 +#59'1680:t56 +#59'1800:t56 +#59'0:xC4U +#61'0:s0G +#465>c0E +#480>sC2 +#495>c0E +#1425>c0A +#1440>s82 +#1455>c0A +#2385>c06 +#2400>s42 +#2415>c06 +#3360>s02 +#4290>c02 +#4320>s22 +#4350>c02 +#5240>c02 +#5280>s42 +#5320>c02 +#6180>c02 +#6240>s62 +#6300>c02 +#6720>c02 +#6760>c12 +#6800>c02 +#6840>c22 +#6880>c02 +#6920>c42 +#6960>c02 +#7000>c32 +#7040>c02 +#7080>c12 +#7120>c02 +#7160>c12 +#7200>c02 +#7680>s02 +#64'1920:S0228N +#480>s021O +#505>c221O +#520>c321O +#540>c421O +#565>c521O +#615>c621O +#720>c721O +#1440>s7214 +#1465>c5214 +#1480>c4214 +#1500>c3214 +#1525>c2214 +#1575>c1214 +#1680>c0214 +#2400>s0214 +#2425>c2214 +#2440>c3214 +#2460>c4214 +#2485>c5214 +#2535>c6214 +#2645>c7214 +#3360>s720K +#3385>c520K +#3400>c420K +#3420>c320K +#3445>c220K +#3495>c120K +#3600>c020K +#4320>s020K +#4345>c220K +#4360>c320K +#4380>c420K +#4405>c520K +#4455>c620K +#4560>c720K +#5280>s7200 +#5295>c6200 +#5315>c5200 +#5345>c4200 +#5400>c3200 +#5520>c2200 +#5640>c2200 +#61'480:fE2R +#61'480:aE2URN +#61'1440:fA6R +#61'1440:aA6URN +#62'480:f6AR +#62'480:a6AURN +#62'1440:f2ER +#62'1440:a2EURN +#61'0:x0GC +#61'0:a0GDCN +#63'240:tB3 +#63'360:tB3 +#63'600:t73 +#63'840:t73 +#63'1200:tD3 +#63'1320:tD3 +#63'1560:tA3 +#63'1800:tA3 +#64'0:hB3 +#120>s +#64'240:hB3 +#120>s +#64'480:hD3 +#240>s +#64'480:h93 +#240>s +#64'840:hC4 +#240>s +#64'840:h83 +#240>s +#64'1200:hB5 +#240>s +#64'1200:h73 +#240>s +#64'1560:hA6 +#240>s +#64'1560:h63 +#240>s +#63'480:f23R +#63'1440:f43R +#64'480:f63R +#64'1920:s97 +#60>cB5 +#105>cC4 +#180>cD3 +#360>cE2 +#480>cE2 +#505>cC2 +#520>cB2 +#540>cA2 +#565>c92 +#615>c82 +#720>c72 +#1440>c72 +#1465>c92 +#1480>cA2 +#1500>cB2 +#1525>cC2 +#1575>cD2 +#1680>cE2 +#2400>cE2 +#2425>cC2 +#2440>cB2 +#2460>cA2 +#2485>c92 +#2535>c82 +#2640>c72 +#3360>c72 +#3385>c92 +#3400>cA2 +#3420>cB2 +#3445>cC2 +#3495>cD2 +#3600>cE2 +#4320>cE2 +#4345>cC2 +#4360>cB2 +#4380>cA2 +#4405>c92 +#4455>c82 +#4560>c72 +#5280>c72 +#5295>c82 +#5315>c92 +#5345>cA2 +#5400>cB2 +#5520>cC2 +#5640>sC2 +#64'1920:x97U +#70'480:hC2 +#120>s +#53'0:x03C +@USETIL 2 +#52'1680:C010KC,0 +#240>c010K +#300>c010K +#480>c010U +#630>c011E +#720>c011Y +#810>c012I +#960>c0132 +#1140>c013C +#1260>c013C +#1440>c0132 +#1590>c012I +#1680>c011Y +#1770>c011E +#1920>c010U +#2100>c010K +#2160>c010K +#2220>c010K +#2400>c010U +#2550>c011E +#2640>c011Y +#2730>c012I +#2880>c0132 +#3060>c013C +#3180>c013C +#3360>c0132 +#3510>c012I +#3600>c011Y +#3690>c011E +#3840>c010U +#4020>c010K +#4080>c010K +#4140>c010K +#4320>c010U +#4470>c011E +#4560>c011Y +#4650>c012I +#4800>c0132 +#4980>c013C +#5100>c013C +#5280>c0132 +#5430>c012I +#5520>c011Y +#5610>c011E +#5760>c010U +#5940>c010K +#6000>c010K +#6060>c010K +#6240>c010U +#6390>c011E +#6480>c011Y +#6570>c012I +#6720>c0132 +#6900>c013C +#7020>c013C +#7200>c0132 +#7350>c012I +#7440>c011Y +#7530>c011E +#7680>c010U +#7860>c010K +#7920>c010K +#7980>c010K +#8160>c010U +#8310>c011E +#8400>c011Y +#8490>c012I +#8640>c0132 +#8820>c013C +#8940>c013C +#9120>c0132 +#9270>c012I +#9360>c011Y +#9450>c011E +#9600>c010U +#9780>c010K +#9840>c010K +#9900>c010K +#10080>c010U +#10230>c011E +#10320>c011Y +#10410>c012I +#10560>c0132 +#10740>c013C +#10860>c013C +#11040>c0132 +#11190>c012I +#11280>c011Y +#11370>c011E +#11520>c010U +#11700>c010K +#11760>c010K +#11820>c010K +#12000>c010U +#12150>c011E +#12240>c011Y +#12330>c012I +#12480>c0132 +#12660>c013C +#12780>c013C +#12960>c0132 +#13110>c012I +#13200>c011Y +#13290>c011E +#13440>c010U +#13620>c010K +#13680>c010K +#13720>c010K +#13924>c010U +#14087>c011E +#14209>c011Y +#14311>c012S +#14433>c0146 +#14555>c015U +#14677>c017S +#14820>c01A0 +#57'60:C010K1,0 +#240>c010K +#420>c010U +#570>c011E +#660>c011Y +#750>c012I +#900>c0132 +#1080>c013C +#1200>c013C +#1380>c0132 +#1530>c012I +#1620>c011Y +#1710>c011E +#1860>c010U +#2040>c010K +#2100>c010K +#2160>c010K +#2340>c010U +#2490>c011E +#2580>c011Y +#2670>c012I +#2820>c0132 +#3000>c013C +#3120>c013C +#3300>c0132 +#3450>c012I +#3540>c011Y +#3630>c011E +#3780>c010U +#3960>c010K +#4020>c010K +#4080>c010K +#4260>c010U +#4410>c011E +#4500>c011Y +#4590>c012I +#4740>c0132 +#4920>c013C +#5040>c013C +#5220>c0132 +#5370>c012I +#5460>c011Y +#5550>c011E +#5700>c010U +#5880>c010K +#5940>c010K +#5980>c010K +#6184>c010U +#6347>c011E +#6469>c011Y +#6571>c012S +#6693>c0146 +#6815>c015U +#6937>c017S +#7080>c01A0 +#58'300:C010K8,0 +#240>c010K +#420>c010U +#570>c011E +#660>c011Y +#750>c012I +#900>c0132 +#1080>c013C +#1200>c013C +#1380>c0132 +#1530>c012I +#1620>c011Y +#1710>c011E +#1860>c010U +#2040>c010K +#2100>c010K +#2160>c010K +#2340>c010U +#2490>c011E +#2580>c011Y +#2670>c012I +#2820>c0132 +#3000>c013C +#3120>c013C +#3300>c0132 +#3450>c012I +#3540>c011Y +#3630>c011E +#3780>c010U +#3960>c010K +#4020>c010K +#4060>c010K +#4264>c010U +#4427>c011E +#4549>c011Y +#4651>c012S +#4773>c0146 +#4895>c015U +#5017>c017S +#5160>c01A0 +#59'540:C010K6,0 +#240>c010K +#420>c010U +#570>c011E +#660>c011Y +#750>c012I +#900>c0132 +#1080>c013C +#1200>c013C +#1380>c0132 +#1530>c012I +#1620>c011Y +#1710>c011E +#1860>c010U +#2040>c010K +#2100>c010K +#2140>c010K +#2344>c010U +#2507>c011E +#2629>c011Y +#2731>c012S +#2853>c0146 +#2975>c015U +#3097>c017S +#3240>c01A0 +#52'1680:CF10KC,0 +#240>cF10K +#300>cF10K +#480>cF10U +#630>cF11E +#720>cF11Y +#810>cF12I +#960>cF132 +#1140>cF13C +#1260>cF13C +#1440>cF132 +#1590>cF12I +#1680>cF11Y +#1770>cF11E +#1920>cF10U +#2100>cF10K +#2160>cF10K +#2220>cF10K +#2400>cF10U +#2550>cF11E +#2640>cF11Y +#2730>cF12I +#2880>cF132 +#3060>cF13C +#3180>cF13C +#3360>cF132 +#3510>cF12I +#3600>cF11Y +#3690>cF11E +#3840>cF10U +#4020>cF10K +#4080>cF10K +#4140>cF10K +#4320>cF10U +#4470>cF11E +#4560>cF11Y +#4650>cF12I +#4800>cF132 +#4980>cF13C +#5100>cF13C +#5280>cF132 +#5430>cF12I +#5520>cF11Y +#5610>cF11E +#5760>cF10U +#5940>cF10K +#6000>cF10K +#6060>cF10K +#6240>cF10U +#6390>cF11E +#6480>cF11Y +#6570>cF12I +#6720>cF132 +#6900>cF13C +#7020>cF13C +#7200>cF132 +#7350>cF12I +#7440>cF11Y +#7530>cF11E +#7680>cF10U +#7860>cF10K +#7920>cF10K +#7980>cF10K +#8160>cF10U +#8310>cF11E +#8400>cF11Y +#8490>cF12I +#8640>cF132 +#8820>cF13C +#8940>cF13C +#9120>cF132 +#9270>cF12I +#9360>cF11Y +#9450>cF11E +#9600>cF10U +#9780>cF10K +#9840>cF10K +#9900>cF10K +#10080>cF10U +#10230>cF11E +#10320>cF11Y +#10410>cF12I +#10560>cF132 +#10740>cF13C +#10860>cF13C +#11040>cF132 +#11190>cF12I +#11280>cF11Y +#11370>cF11E +#11520>cF10U +#11700>cF10K +#11760>cF10K +#11820>cF10K +#12000>cF10U +#12150>cF11E +#12240>cF11Y +#12330>cF12I +#12480>cF132 +#12660>cF13C +#12780>cF13C +#12960>cF132 +#13110>cF12I +#13200>cF11Y +#13290>cF11E +#13440>cF10U +#13620>cF10K +#13680>cF10K +#13720>cF10K +#13924>cF10U +#14087>cF11E +#14209>cF11Y +#14311>cF12S +#14433>cF146 +#14555>cF15U +#14677>cF17S +#14820>cF1A0 +#57'60:CF10K1,0 +#240>cF10K +#420>cF10U +#570>cF11E +#660>cF11Y +#750>cF12I +#900>cF132 +#1080>cF13C +#1200>cF13C +#1380>cF132 +#1530>cF12I +#1620>cF11Y +#1710>cF11E +#1860>cF10U +#2040>cF10K +#2100>cF10K +#2160>cF10K +#2340>cF10U +#2490>cF11E +#2580>cF11Y +#2670>cF12I +#2820>cF132 +#3000>cF13C +#3120>cF13C +#3300>cF132 +#3450>cF12I +#3540>cF11Y +#3630>cF11E +#3780>cF10U +#3960>cF10K +#4020>cF10K +#4080>cF10K +#4260>cF10U +#4410>cF11E +#4500>cF11Y +#4590>cF12I +#4740>cF132 +#4920>cF13C +#5040>cF13C +#5220>cF132 +#5370>cF12I +#5460>cF11Y +#5550>cF11E +#5700>cF10U +#5880>cF10K +#5940>cF10K +#5980>cF10K +#6184>cF10U +#6347>cF11E +#6469>cF11Y +#6571>cF12S +#6693>cF146 +#6815>cF15U +#6937>cF17S +#7080>cF1A0 +#58'300:CF10K8,0 +#240>cF10K +#420>cF10U +#570>cF11E +#660>cF11Y +#750>cF12I +#900>cF132 +#1080>cF13C +#1200>cF13C +#1380>cF132 +#1530>cF12I +#1620>cF11Y +#1710>cF11E +#1860>cF10U +#2040>cF10K +#2100>cF10K +#2160>cF10K +#2340>cF10U +#2490>cF11E +#2580>cF11Y +#2670>cF12I +#2820>cF132 +#3000>cF13C +#3120>cF13C +#3300>cF132 +#3450>cF12I +#3540>cF11Y +#3630>cF11E +#3780>cF10U +#3960>cF10K +#4020>cF10K +#4060>cF10K +#4264>cF10U +#4427>cF11E +#4549>cF11Y +#4651>cF12S +#4773>cF146 +#4895>cF15U +#5017>cF17S +#5160>cF1A0 +#59'540:CF10K6,0 +#240>cF10K +#420>cF10U +#570>cF11E +#660>cF11Y +#750>cF12I +#900>cF132 +#1080>cF13C +#1200>cF13C +#1380>cF132 +#1530>cF12I +#1620>cF11Y +#1710>cF11E +#1860>cF10U +#2040>cF10K +#2100>cF10K +#2140>cF10K +#2344>cF10U +#2507>cF11E +#2629>cF11Y +#2731>cF12S +#2853>cF146 +#2975>cF15U +#3097>cF17S +#3240>cF1A0 diff --git "a/tests/mai/Simai\350\275\254MA2\346\265\213\350\257\225.cs" "b/tests/mai/Simai\350\275\254MA2\346\265\213\350\257\225.cs" index f2ab435..bd7bada 100644 --- "a/tests/mai/Simai\350\275\254MA2\346\265\213\350\257\225.cs" +++ "b/tests/mai/Simai\350\275\254MA2\346\265\213\350\257\225.cs" @@ -74,19 +74,24 @@ private static void AssertTextEqual(string expected, string actual) var exp = i < expectedLines.Length ? expectedLines[i] : ""; var act = i < actualLines.Length ? actualLines[i] : ""; var result = CompareLine(exp, act); - if (!result) + if (!result && i < actualLines.Length) { // 尝试同一时刻的其他行有无相同的,如果有,交换之 var j = i + 1; - while (j < expectedLines.Length && IsSameTime(expectedLines[i], actualLines[j])) + while (j < expectedLines.Length) { if (CompareLine(expectedLines[j], act)) - { + { // 匹配成功。交换之 (expectedLines[j], expectedLines[i]) = (expectedLines[i], expectedLines[j]); result = true; break; } - j++; + else if (IsSameTime(expectedLines[j], act)) + { // 虽然暂时匹配失败,但是游标j还在,act同一时刻的窗口范围内。则应该允许继续比较。 + j++; + continue; + } + else break; // 否则(匹配失败且j已经离开了同时刻的滑动窗口、说明未来也不再具有匹配上的可能性了),则中止匹配 } } diff --git a/utils/ChuUtils.cs b/utils/ChuUtils.cs new file mode 100644 index 0000000..ac37ddd --- /dev/null +++ b/utils/ChuUtils.cs @@ -0,0 +1,59 @@ +using MuConvert.chu; + +namespace MuConvert.utils; + +public class ChuUtils +{ + public static readonly Dictionary U2C_AirDirections = new() + { + ["UC"] = "AIR", + ["UR"] = "AUR", + ["UL"] = "AUL", + ["DC"] = "ADW", + ["DR"] = "ADR", + ["DL"] = "ADL", + }; + public static readonly Dictionary C2U_AirDirections = ReverseDict(U2C_AirDirections); + + public static readonly Dictionary U2C_ChrExtras = new() + { + ["U"] = "UP", + ["D"] = "DW", + ["C"] = "CE", + }; + public static readonly Dictionary C2U_ChrExtras = ReverseDict(U2C_ChrExtras); + + public static readonly Dictionary U2C_AirColor = new() + { + ["N"] = "DEF", + ["I"] = "I", // TODO 搞清楚UGC里的'I'颜色,在C2S里,对应的字符串是什么 + }; + public static readonly Dictionary C2U_AirColor = ReverseDict(U2C_AirColor); + + public static decimal U2C_Height(decimal input) => input / 1.6m; + public static decimal C2U_Height(decimal input) => input * 1.6m; + + private static Dictionary ReverseDict(Dictionary dict) => + dict.ToDictionary(x => x.Value, x => x.Key); + + public static bool IsHold(string t) => t is "HLD" or "HXD"; + public static bool IsSlide(string t) => t is "SLD" or "SLC" or "SXD" or "SXC"; + public static bool IsAirSlide(string t) => t is "ASD" or "ASC"; + public static bool IsAir(string t) => t is "AIR" or "AUR" or "AUL" or "ADW" or "ADR" or "ADL"; + public static bool IsAirHold(string t) => t is "AHD" or "AHX"; + public static bool IsAirCrush(string t) => t is "ALD"; + // 是否是广义的air音符(Air/Air Hold/Air Slide/Air Crush) + public static bool IsGeneralizedAir(string t) => IsAir(t) || IsAirHold(t) || IsAirSlide(t) || IsAirCrush(t); + + public static bool IsHold(ChuNote? n) => IsHold(n?.Type!); + public static bool IsSlide(ChuNote? n) => IsSlide(n?.Type!); + public static bool IsAirSlide(ChuNote? n) => IsAirSlide(n?.Type!); + public static bool IsAir(ChuNote? n) => IsAir(n?.Type!); + public static bool IsAirHold(ChuNote? n) => IsAirHold(n?.Type!); + public static bool IsAirCrush(ChuNote? n) => IsAirCrush(n?.Type!); + // 是否是广义的air音符(Air/Air Hold/Air Slide/Air Crush) + public static bool IsGeneralizedAir(ChuNote? n) => IsGeneralizedAir(n?.Type!); + + public static bool TryH36ToI(string str, out int result) => Utils.TryHToI(str, 36, out result); + public static string IToH36(int value) => Utils.IToH(value, 36); +} \ No newline at end of file diff --git a/utils/Utils.cs b/utils/Utils.cs index 5beebdd..b9b2404 100644 --- a/utils/Utils.cs +++ b/utils/Utils.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Numerics; using System.Reflection; +using System.Text; using Rationals; using L = MuConvert.Antlr.SimaiLexer; @@ -28,6 +29,8 @@ internal static Exception Fail(string msg = "") public static BigInteger Max(BigInteger a, BigInteger b) => a > b ? a : b; + public static Rational Max(Rational a, Rational b) => a > b ? a : b; + public static Rational Min(Rational a, Rational b) => a < b ? a : b; private static readonly Dictionary _simaiLexerMap = Enumerable.Range(1, L.ruleNames.Length) @@ -64,9 +67,77 @@ public static int Tick(Rational time, int resolution, int extraTicks = 0, int? m if (min != null && r < min) r = min.Value; return r; } + + /** + * 把N进制的一位数转为int。(返回bool成功与否,通过out变量传递结果) + * 可以是从11到36进制数,(如常见的16进制数或UGC的36进制数),都是能用的。因为本质都是10以后按照ABCDE...的顺序排列,所以没区别。 + */ + public static bool TryHToI(char _char, out int result) + { + result = _char switch + { + >= '0' and <= '9' => _char - '0', + >= 'a' and <= 'z' => _char - 'a' + 10, + >= 'A' and <= 'Z' => _char - 'A' + 10, + _ => -1 + }; + return _char >= 0; + } + + /** + * 把N进制的一位数转为int。(直接返回int,如果转换失败抛异常) + * 可以是从11到36进制数,(如常见的16进制数或UGC的36进制数),都是能用的。因为本质都是10以后按照ABCDE...的顺序排列,所以没区别。 + */ + public static int HToI(char _char) => + TryHToI(_char, out var i) ? i : throw new FormatException($"Cannot convert '{_char}' to int!"); + + /** + * 把N进制的多位数转为int。(返回bool成功与否,通过out变量传递结果) + * N进制的N值需要传入,需要满足 N在[11,36]之间。 + */ + public static bool TryHToI(string str, int N, out int result) + { + result = 0; + foreach (var ch in str) + { + if (!TryHToI(ch, out var bit)) return false; + result = result * N + bit; + } + return true; + } + + /** + * 把N进制的多位数转为int。(直接返回int,如果转换失败抛异常) + * N进制的N值需要传入,需要满足 N在[11,36]之间。 + */ + public static int HToI(string str, int N) => + TryHToI(str, N, out var i) ? i : throw new FormatException($"Cannot convert '{str}' to int!"); + + /** + * 把int转为N进制的字符串。(直接返回int,如果转换失败抛异常) + * N进制的N值需要传入,需要满足 N在[11,36]之间。 + */ + public static string IToH(int value, int N) + { + if (N is < 11 or > 36) throw new ArgumentOutOfRangeException(nameof(N), N, "N must be in [11, 36]."); + if (value == 0) return "0"; + + var negative = value < 0; + value = Math.Abs(value); + var sb = new StringBuilder(); + while (value > 0) + { + var d = value % N; + value /= N; + // 生成的时候先倒着生成字符数组,返回的时候统一reverse + sb.Append(d < 10 ? (char)('0' + d) : (char)('A' + (d - 10))); + } + if (negative) sb.Append('-'); + return new string(sb.ToString().Reverse().ToArray()); + } } -internal static class ExtensionUtils +public static class ExtensionUtils { internal static void Add(this Dictionary> dict, K key, V value) where K : notnull { @@ -84,7 +155,7 @@ internal static Dictionary EnsureKeys( } // 工作范围仅限正数 - public static Rational Ceil(this Rational r) + public static BigInteger Ceil(this Rational r) { if (r < 0) throw new ArgumentOutOfRangeException(nameof(r)); return r.WholePart + (r.FractionPart == 0 ? 0 : 1); @@ -106,6 +177,11 @@ public static Rational Sum(this IEnumerable source) return source.Aggregate(Rational.Zero, (acc, r) => acc + r); } + public static Rational Abs(this Rational r) + { + return r * r.Sign; + } + internal static Dictionary RemoveRange(this Dictionary dict, IEnumerable keys) where K : notnull { foreach (var key in keys) dict.Remove(key);