Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
18 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
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
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,8 @@ riderModule.iml
.cursor
/.tmp*
*scratch*
*.lscache
*.lscache

# 测试 dump 输出
*_output.*
placeholder.txt
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) / 384m * 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))) / 384m * 240m / StartBpm : 0;
Comment thread
Starrah marked this conversation as resolved.
Outdated
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
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 => 0;
Comment thread
Starrah marked this conversation as resolved.
Outdated
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
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 => 0;
Comment thread
Starrah marked this conversation as resolved.
Outdated
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
public override int TotalNotes => Notes.Count;
}
124 changes: 124 additions & 0 deletions generator/chu/C2sGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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(Warning, string.Format(Locale.ChuGeneratorUnsupported, "→ C2S")));
return new C2sChart();
}

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.

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}",
"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.
};
Comment thread
Starrah marked this conversation as resolved.

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;
private const int C2sRsl = 384;

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;
var result = new SusChart { Bpm = bpm, TicksPerBeat = SusTpb, Title = title, Artist = artist };
foreach (var n in c2s.Notes) result.Notes.Add(ScaleUp(n));
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));
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
return result;
}

alerts.Add(new Alert(Warning, string.Format(Locale.ChuGeneratorUnsupported, "→ SUS")));
return new SusChart();
}

private static ChuNote ScaleUp(ChuNote n)
{
int s(int v) => (int)((long)v * SusTpb / (C2sRsl / 4));
Comment thread
cubic-dev-ai[bot] 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.

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