Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
54e5c27
feat: 新增 C2S/UGC/SUS 谱面格式支持
Applesaber May 1, 2026
e2488df
fix: 修复数字解析区域依赖和 tick 缩放溢出问题
Applesaber May 1, 2026
e4619d1
fix: 修复 review bot 发现的 9 个问题
Applesaber May 1, 2026
cd61fd7
fix: 修复 cubic 第2轮审查的 5 个问题
Applesaber May 1, 2026
91a0455
[F&R] 修复若干问题
Starrah May 1, 2026
828dbe6
fix: UgcGenerator.UCode 补充 HXD/SXD/SXC/SLC 映射
Applesaber May 1, 2026
5abf8ae
[F&O] 修复一些小问题,补充测试等
Starrah May 1, 2026
ec9d78d
[F] 修复UgcParser,未能正确实现对AIR的解析,在多字符 TargetNote的AIR时会解析错误的问题
Starrah May 1, 2026
41bc684
[F] 优化HexToInt
Starrah May 1, 2026
7f19976
Merge remote-tracking branch 'origin/master'
Starrah May 1, 2026
f1ec9ee
fix: C2sParser.ParseNote ALD/ASD Cell/Width 错误赋值
Applesaber May 1, 2026
8265abc
[+&O] CLI支持新增的中二转谱;同时优化提示文本,避免太罗嗦
Starrah May 1, 2026
4041dc3
[+] CLI for 中二
Starrah May 1, 2026
1cde69b
fix: UgcParser 兼容大写类型前缀和 >c 跟随行
Applesaber May 2, 2026
ca6db1e
fix: UgcParser 兼容独立跟随行和 @USETIL 指令
Applesaber May 2, 2026
b483eb0
[F] 为 ParseHoldNote 实现与 ParseSlideNote 相同的多行跟随消费逻辑(循环读取合法 #…>s/#…>c,跳…
Starrah May 2, 2026
05b8945
[R&doc]优化CLI和README
Starrah May 2, 2026
d404a68
[R] 优化中文等语言下的报错行号提示文本(删掉一个空格)
Starrah May 2, 2026
1cc5037
[R] ChuNote,重命名Extra为Tag
Starrah May 3, 2026
37c6b6d
Merge branch 'master' into dev
Starrah May 3, 2026
5f8bddb
[R] ChuNote的重新设计
Starrah May 3, 2026
0b2e921
[R] 适应 5f8bddb 中所做的修改,使能过编译
Starrah May 3, 2026
a0ff8b0
[R] Chart相关重构(初步)
Starrah May 3, 2026
84a39e5
[R] Chart相关重构(第二步)——使用BaseChart中提供的List对象
Starrah May 3, 2026
ba1bbcc
[F&R] 1. 现在的ugcgenerator缺少SflEvent的生成/写出。
Starrah May 3, 2026
09c5ead
[R] Chart相关重构(第三步)——移除各个谱面中的Resolution,而是parser解析存成分数、generator使用写死的固定值
Starrah May 3, 2026
d92a9e3
[R] Chart相关重构(最后一步)——三种Chart合并为统一的ChuChart!
Starrah May 3, 2026
54bc9f4
[F] 不要屏蔽本应出现的警告
Starrah May 3, 2026
fa9dbcb
[F] UGCParser对Slide的解析
Starrah May 6, 2026
9c13621
[+] 在ChuNote中新增Previous字段,新增BaseChuParser和FillAllPrevious通用工具方法。以实现sl…
Starrah May 7, 2026
52af0a3
[+] UGC 对Air Slide和Air Hold的解析
Starrah May 7, 2026
b54b7e9
[+] UgcGenerator Slide、Air Hold、Air Slide的正确实现
Starrah May 7, 2026
d580e85
[F] 修一些小问题
Starrah May 7, 2026
00178c1
[R] 把UGC常用的一些工具方法,提取到ChuUtils中
Starrah May 8, 2026
a994505
[+&R&F] ChuNote新增Height和CrushInterval;UgcParser和UgcGenerator,重构height…
Starrah May 9, 2026
028305c
[Test&F] 新增几个测试谱面,并修bug
Starrah May 9, 2026
317ea02
[F] 修复一些问题:
Starrah May 11, 2026
bcfbe78
[Test] 重构测试代码比较音符相等的整体逻辑;同时修复若干问题。
Starrah May 11, 2026
afd485b
[F] lineNum等一点小问题
Starrah May 11, 2026
a9a83a1
[+] UGC AirCrush
Starrah May 11, 2026
c9fe48d
[F] RSL不再是static的,解决多实例冲突问题
Starrah May 11, 2026
477a767
[F] decimal.Parse InvarientCulture
Starrah May 11, 2026
d0de6ad
[F] 生成谱面时,字符串插值确保使用InvarientCultrue
Starrah May 11, 2026
db62da3
[F] C2S的高度只准保留一位小数后,舍入误差引起测试爆炸
Starrah May 11, 2026
25f364b
[doc] Update README.md
Starrah May 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions chart/chu/C2sChart.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using MuConvert.chart;

namespace MuConvert.chu;

/**
* C2S 格式谱面 IR(官方格式,RESOLUTION=384 tick/小节)。
*/
public class C2sChart : BaseChart<ChuNote>, IChuChart
{
public string Version { get; set; } = "1.08.00\t1.08.00";
public int MusicId { get; set; }
public int DifficultId { get; set; }
public string Creator { get; set; } = "";
public int Resolution { get; set; } = 384;
public double DefBpm { get; set; } = 120.0;
public List<(int Measure, int Offset, double Bpm)> BpmEvents = [];
public List<(int Measure, int Offset, int Denom, int Num)> MetEvents = [];
public List<(int Measure, int Offset, int Duration, double Multiplier)> SflEvents = [];

public override decimal StartBpm => (decimal)(BpmEvents.Count > 0 ? BpmEvents[0].Bpm : DefBpm);
public override decimal StartTime => Notes.Count > 0 ? Notes.Min(n => n.Measure * Resolution + n.Offset) / (decimal)Resolution * 240m / StartBpm : 0;
public override decimal EndTime => Notes.Count > 0 ? Notes.Max(n => n.Measure * Resolution + n.Offset + Math.Max(n.HoldDuration, Math.Max(n.SlideDuration, n.AirHoldDuration))) / (decimal)Resolution * 240m / StartBpm : 0;
public override int TotalNotes => Notes.Count;
}
38 changes: 38 additions & 0 deletions chart/chu/ChuNote.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
namespace MuConvert.chu;

/**
* CHUNITHM 通用音符,C2S / UGC / SUS 共用此结构。
*/
public class ChuNote
{
/** 音符类型 (TAP, CHR, HLD, SLD, AIR, AHD 等) */
public string Type { get; set; } = "TAP";
/** 小节号 */
public int Measure { get; set; }
/** 小节内偏移 (C2S: 0–383, UGC/SUS: 0–1919) */
public int Offset { get; set; }
/** 起始列 (0–15) */
public int Cell { get; set; }
/** 宽度 (1–16) */
public int Width { get; set; } = 1;
/** HLD 持续时长 */
public int HoldDuration { get; set; }
/** SLD 持续时长 */
public int SlideDuration { get; set; }
/** SLD 终点列 */
public int EndCell { get; set; }
/** SLD 终点宽度 */
public int EndWidth { get; set; } = 1;
/** CHR/FLK 附加数据(方向等) */
public string Extra { get; set; } = "";
/** AIR/AHD 关联的目标音符类型 */
public string TargetNote { get; set; } = "";
/** AHD 持续时长 */
public int AirHoldDuration { get; set; }
/** Air Crush 起始高度 */
public int StartHeight { get; set; }
/** Air Crush 目标高度 */
public int TargetHeight { get; set; }
/** Air Crush 颜色 */
public string NoteColor { get; set; } = "";
}
8 changes: 8 additions & 0 deletions chart/chu/IChuChart.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using MuConvert.chart;

namespace MuConvert.chu;

/**
* CHUNITHM 所有谱面格式的统一接口,作为 Generator 的输入类型。
*/
public interface IChuChart : IBaseChart;
20 changes: 20 additions & 0 deletions chart/chu/SusChart.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using MuConvert.chart;

namespace MuConvert.chu;

/**
* SUS 格式谱面 IR(REQUEST=480 tick/拍,lane 0–31)。
*/
public class SusChart : BaseChart<ChuNote>, IChuChart
{
public string Title { get; set; } = "";
public string Artist { get; set; } = "";
public string Designer { get; set; } = "";
public int TicksPerBeat { get; set; } = 480;
public double Bpm { get; set; } = 120.0;

public override decimal StartBpm => (decimal)Bpm;
public override decimal StartTime => Notes.Count > 0 ? Notes.Min(n => n.Measure * TicksPerBeat * 4 + n.Offset) / (decimal)(TicksPerBeat * 4) * 240m / StartBpm : 0;
public override decimal EndTime => Notes.Count > 0 && StartBpm > 0 ? Notes.Max(n => n.Measure * TicksPerBeat * 4 + n.Offset + Math.Max(n.HoldDuration, Math.Max(n.SlideDuration, n.AirHoldDuration))) / (decimal)(TicksPerBeat * 4) * 240m / StartBpm : 0;
public override int TotalNotes => Notes.Count;
}
27 changes: 27 additions & 0 deletions chart/chu/UgcChart.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using MuConvert.chart;

namespace MuConvert.chu;

/**
* UGC 格式谱面 IR(UMIGURI 格式,@TICKS=480 tick/拍)。
*/
public class UgcChart : BaseChart<ChuNote>, IChuChart
{
public string Version { get; set; } = "6";
public string Title { get; set; } = "";
public string Artist { get; set; } = "";
public string Designer { get; set; } = "";
public string Difficulty { get; set; } = "";
public int Level { get; set; }
public double Constant { get; set; }
public string SongId { get; set; } = "";
public int TicksPerBeat { get; set; } = 480;
public List<(int Measure, int Num, int Den)> BeatEvents = [];
public List<(int Measure, int Offset, double Bpm)> BpmEvents = [];
public List<(int Measure, int Offset, double Multiplier)> SpeedEvents = [];

public override decimal StartBpm => (decimal)(BpmEvents.Count > 0 ? BpmEvents[0].Bpm : 120.0);
public override decimal StartTime => Notes.Count > 0 ? Notes.Min(n => n.Measure * TicksPerBeat * 4 + n.Offset) / (decimal)(TicksPerBeat * 4) * 240m / StartBpm : 0;
public override decimal EndTime => Notes.Count > 0 && StartBpm > 0 ? Notes.Max(n => n.Measure * TicksPerBeat * 4 + n.Offset + Math.Max(n.HoldDuration, Math.Max(n.SlideDuration, n.AirHoldDuration))) / (decimal)(TicksPerBeat * 4) * 240m / StartBpm : 0;
public override int TotalNotes => Notes.Count;
}
125 changes: 125 additions & 0 deletions generator/chu/C2sGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
using System.Globalization;
using System.Text;
using MuConvert.chart;
using MuConvert.generator;
using MuConvert.utils;
using static MuConvert.utils.Alert.LEVEL;

namespace MuConvert.chu;

/**
* C2S 格式生成器。
* 输入 IChuChart,内部自动转换后输出 C2S 文本。
*/
public class C2sGenerator : IGenerator<IChuChart>
{
private const int C2sResolution = 384;

public (string, List<Alert>) Generate(IChuChart chart)
{
var alerts = new List<Alert>();
var c2s = ConvertToC2s(chart, alerts);
var text = Serialize(c2s);
return (text, alerts);
}

private static C2sChart ConvertToC2s(IChuChart chart, List<Alert> alerts)
{
if (chart is C2sChart c2s) return c2s;

if (chart is UgcChart ugc)
{
var result = new C2sChart
{
Version = "1.08.00\t1.08.00",
Creator = ugc.Designer,
DefBpm = ugc.BpmEvents.Count > 0 ? ugc.BpmEvents[0].Bpm : 120.0,
};
foreach (var b in ugc.BpmEvents)
result.BpmEvents.Add((b.Measure, ScaleDown(b.Offset, ugc.TicksPerBeat), b.Bpm));
foreach (var b in ugc.BeatEvents)
result.MetEvents.Add((b.Measure, 0, b.Den, b.Num));
foreach (var n in ugc.Notes)
result.Notes.Add(ScaleNote(n, ugc.TicksPerBeat));
return result;
}

if (chart is SusChart sus)
{
var result = new C2sChart { DefBpm = sus.Bpm };
result.BpmEvents.Add((0, 0, sus.Bpm));
foreach (var n in sus.Notes)
result.Notes.Add(ScaleNote(n, sus.TicksPerBeat));
return result;
}

alerts.Add(new Alert(Error, string.Format(Locale.ChuGeneratorUnsupported, "→ C2S")));
throw new ConversionException(alerts);
}

private static ChuNote ScaleNote(ChuNote n, int tpb)
{
int scaleDown(int v) => (int)((long)v * (C2sResolution / 4) / tpb);
return new ChuNote
{
Type = n.Type, Measure = n.Measure, Offset = scaleDown(n.Offset),
Cell = n.Cell, Width = n.Width,
HoldDuration = scaleDown(n.HoldDuration), SlideDuration = scaleDown(n.SlideDuration),
EndCell = n.EndCell, EndWidth = n.EndWidth,
Extra = n.Extra, TargetNote = n.TargetNote, AirHoldDuration = scaleDown(n.AirHoldDuration),
StartHeight = n.StartHeight, TargetHeight = n.TargetHeight, NoteColor = n.NoteColor,
};
}

private static int ScaleDown(int ticks, int tpb) => (int)((long)ticks * (C2sResolution / 4) / tpb);

private static string Serialize(C2sChart chart)
{
var sb = new StringBuilder();
sb.AppendLine($"VERSION\t{chart.Version}");
sb.AppendLine($"MUSIC\t{chart.MusicId}");
sb.AppendLine("SEQUENCEID\t0");
sb.AppendLine($"DIFFICULT\t{chart.DifficultId:D2}");
sb.AppendLine("LEVEL\t0.0");
sb.AppendLine($"CREATOR\t{chart.Creator}");
sb.AppendLine($"BPM_DEF\t{Fmt(chart.DefBpm)}\t{Fmt(chart.DefBpm)}\t{Fmt(chart.DefBpm)}\t{Fmt(chart.DefBpm)}");
sb.AppendLine("MET_DEF\t4\t4");
sb.AppendLine($"RESOLUTION\t{chart.Resolution}");
sb.AppendLine($"CLK_DEF\t{chart.Resolution}");
sb.AppendLine("PROGJUDGE_BPM\t240.000");
sb.AppendLine("PROGJUDGE_AER\t0.999");
sb.AppendLine("TUTORIAL\t0");
sb.AppendLine();

foreach (var b in chart.BpmEvents)
sb.AppendLine($"BPM\t{b.Measure}\t{b.Offset}\t{Fmt(b.Bpm)}");
foreach (var m in chart.MetEvents)
sb.AppendLine($"MET\t{m.Measure}\t{m.Offset}\t{m.Denom}\t{m.Num}");
foreach (var s in chart.SflEvents)
sb.AppendLine($"SFL\t{s.Measure}\t{s.Offset}\t{s.Duration}\t{Mlt(s.Multiplier)}");
sb.AppendLine();

foreach (var n in chart.Notes.OrderBy(n => n.Measure * C2sResolution + n.Offset))
sb.AppendLine(FormatNote(n));
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated

sb.AppendLine();
return sb.ToString();
}

private static string FormatNote(ChuNote n) => n.Type switch
{
"TAP" => $"TAP\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}",
"CHR" => $"CHR\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.Extra}",
"HLD" or "HXD" => $"{n.Type}\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.HoldDuration}",
"SLD" or "SLC" or "SXD" or "SXC" => $"{n.Type}\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.SlideDuration}\t{n.EndCell}\t{n.EndWidth}",
"FLK" => $"FLK\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.Extra}",
"AIR" or "AUR" or "AUL" or "ADW" or "ADR" or "ADL" => $"{n.Type}\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.TargetNote}",
"AHD" => $"AHD\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.TargetNote}\t{n.AirHoldDuration}",
"ALD" or "ASD" => $"{n.Type}\t{n.Measure}\t{n.Offset}\t{n.StartHeight}\t{n.SlideDuration}\t{n.EndCell}\t{n.EndWidth}\t{n.TargetHeight}\t{n.NoteColor}",
"MNE" => $"MNE\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}",
_ => $"TAP\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}"
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
};

private static string Fmt(double v) => v.ToString("0.000", CultureInfo.InvariantCulture);
private static string Mlt(double v) => v.ToString("0.000000", CultureInfo.InvariantCulture);
}
105 changes: 105 additions & 0 deletions generator/chu/SusGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using System.Text;
using MuConvert.chart;
using MuConvert.generator;
using MuConvert.utils;
using static MuConvert.utils.Alert.LEVEL;

namespace MuConvert.chu;

/**
* SUS 格式生成器。
* 输入 IChuChart,内部自动转换后输出 SUS 文本。
*/
public class SusGenerator : IGenerator<IChuChart>
{
private const int SusTpb = 480;

public (string, List<Alert>) Generate(IChuChart chart)
{
var alerts = new List<Alert>();
var sus = ConvertToSus(chart, alerts);
var text = Serialize(sus);
return (text, alerts);
}

private static SusChart ConvertToSus(IChuChart chart, List<Alert> alerts)
{
if (chart is SusChart sus) return sus;

double bpm = 120.0;
string title = "", artist = "";

if (chart is C2sChart c2s)
{
bpm = c2s.BpmEvents.Count > 0 ? c2s.BpmEvents[0].Bpm : c2s.DefBpm;
int c2sTpb = c2s.Resolution / 4;
var result = new SusChart { Bpm = bpm, TicksPerBeat = SusTpb, Title = title, Artist = artist };
foreach (var n in c2s.Notes) result.Notes.Add(ScaleUp(n, c2sTpb));
return result;
}

if (chart is UgcChart ugc)
{
bpm = ugc.BpmEvents.Count > 0 ? ugc.BpmEvents[0].Bpm : 120.0;
var result = new SusChart { Bpm = bpm, TicksPerBeat = SusTpb, Title = ugc.Title, Artist = ugc.Artist };
foreach (var n in ugc.Notes) result.Notes.Add(ScaleUp(n, ugc.TicksPerBeat));
return result;
}

alerts.Add(new Alert(Error, string.Format(Locale.ChuGeneratorUnsupported, "→ SUS")));
throw new ConversionException(alerts);
}

private static ChuNote ScaleUp(ChuNote n, int sourceTicksPerBeat)
{
int s(int v) => (int)((long)v * SusTpb / sourceTicksPerBeat);
Comment thread
Starrah marked this conversation as resolved.
Outdated
return new ChuNote
{
Type = n.Type, Measure = n.Measure, Offset = s(n.Offset),
Cell = n.Cell * 2, Width = n.Width * 2,
HoldDuration = s(n.HoldDuration), SlideDuration = s(n.SlideDuration),
EndCell = n.EndCell * 2, EndWidth = n.EndWidth * 2,
Extra = n.Extra, TargetNote = n.TargetNote, AirHoldDuration = s(n.AirHoldDuration),
};
}

private static string Serialize(SusChart sus)
{
var sb = new StringBuilder();
if (!string.IsNullOrEmpty(sus.Title)) sb.AppendLine($"#TITLE \"{sus.Title}\"");
if (!string.IsNullOrEmpty(sus.Artist)) sb.AppendLine($"#ARTIST \"{sus.Artist}\"");
if (!string.IsNullOrEmpty(sus.Designer)) sb.AppendLine($"#DESIGNER \"{sus.Designer}\"");
sb.AppendLine($"#BPM_DEF {sus.Bpm:F2}");
sb.AppendLine($"#REQUEST \"{sus.TicksPerBeat}\"");
sb.AppendLine();

foreach (var n in sus.Notes.OrderBy(n => n.Measure).ThenBy(n => n.Offset))
sb.AppendLine($"#{n.Measure:X2}{n.Offset:X3}:{FormatData(n)}");
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated

return sb.ToString();
}

private static string FormatData(ChuNote n)
{
string lw = $"{n.Cell:X2}{n.Width:X2}";
string tc = TypeCode(n.Type);
string dur = $"{(n.HoldDuration > 0 ? n.HoldDuration : n.SlideDuration > 0 ? n.SlideDuration : n.AirHoldDuration):X4}";
return tc switch
{
"01" or "02" or "03" or "10" => $"{tc}{lw}",
"05" or "08" => $"{tc}{lw}{dur}",
"06" => $"{tc}{lw}{dur}{n.EndCell:X2}{n.EndWidth:X2}",
"07" or "09" => $"{tc}{lw}{n.TargetNote}",
_ => $"01{lw}"
};
}

private static string TypeCode(string t) => t switch
{
"TAP" => "01", "CHR" => "02", "FLK" => "03",
"HLD" => "05", "SLD" => "06", "SLC" => "06",
"AIR" => "07", "AUR" => "07", "AUL" => "07",
"AHD" => "08", "ADW" => "09", "ADR" => "09", "ADL" => "09",
"MNE" => "10", _ => "01"
};
Comment thread
Starrah marked this conversation as resolved.
}
Loading