MajRadar 是一个独立的的Simai谱面雷达分析和拟合定数估计组件。通过分析SimaiChart格式的已解析谱面计算固定七维特征产生雷达轴和拟合定数。
推荐直接复制 Samples~/MajdataPlay 中的静态薄调用层。
ChartRadarSnapshot snapshot = await ChartRadarService.AnalyzeAsync(
simaiChart,
cancellationToken);静态 facade 只持有无单谱状态的 Runtime;显示当前谱面的组件仍负责 token、selection
generation 和缓存。sample 返回 ChartRadarSnapshot
固定维度可以通过 enum 直接读取,同时保留原有字符串字典接口:
double? noteScore = snapshot.GetScore(RadarOutputDimension.Note);
double? trickyRaw = snapshot.GetRawValue(RadarOutputDimension.SlideTricky);
// 动态 UI 和现有代码仍可直接使用字符串 key。
double? sameNoteScore = snapshot.Scores["note"];RadarOutputDimension 只包含公开的六个雷达轴和 FittedConstant,不包含仅用于拟合的
slide_cumulate。拟合定数仍优先从 snapshot.FittedConstant 读取。
已有 MajSimai 解析结果时,优先复用现成的谱面:
RadarResult result = runtime.Analyze(existingSimaiChart, cancellationToken);需要保持 Unity 主线程响应时,使用异步包装:
RadarResult result = await _runtime.AnalyzeAsync(
existingSimaiChart,
cancellationToken);只有一段 inote 文本时,可以使用:
RadarResult result = await _runtime.ParseAndAnalyzeAsync(
inote,
cancellationToken);未注入 provider 时,MajRadar 使用内置的 DefaultExtendedSlideBarCountProvider,
它来自固定版本的 Play 纯 .NET 几何实现,后端无需安装 Unity。
Play 仍应显式注入自己的 provider,以游戏当前使用的几何为准:
internal sealed class PlayExtendedSlideBarCountProvider
: IExtendedSlideBarCountProvider
{
public int ResolveBarCount(string slideCode)
{
var path = SlideCodeParser.Parse(slideCode);
return SlideDataBuilder.BuildArrowData(path).Length - 2;
}
}provider只接受SlideCode,例如 1P6K7,并且必须返回正数 arrow/bar count。
provider 抛出的异常或非正结果会变成结构化适配失败,不会再次尝试备用实现。
只有构造时未提供 provider(或传入 null)才选择内置实现;普通 Slide 不受影响。
备用实现无共享的单谱状态,可并发调用。数据库缓存版本应包含
DefaultExtendedSlideBarCountProvider.GeometryVersion;宿主 provider 使用自己的版本标识。
几何来源和对照测试见 备用几何说明。
NuGet 依赖最低 MajSimai 2.2.3,其中修复了 1A3P9K5 等带 A/B/C 路径的
Slidecode 被误识别为 Touch 的问题。
取消采用合作式机制,并作为结果数据返回:
if (result.IsCancelled)
return;AnalyzeAsync会在下一个检查点返回Status == "cancelled"的RadarResult。- MajSimai 没有提供解析中途取消,因此
ParseAndAnalyzeAsync只能parser 返回后立即再次检查。 - 同步provider应调用中途不会被取消。
应为当前选歌持有一个 CancellationTokenSource。歌曲或难度变化时,取消上一份
token、启动新分析,并丢弃过期的结果。
if (result.IsSuccess)
{
foreach (var dimension in result.DimensionOrder)
Render(dimension, result.Scores[dimension]);
}
else if (!result.IsCancelled)
{
LogErrors(result.Errors);
}Analysis.Features包含模型使用的固定七维,包括内部维度slide_cumulate。RawValues和Scores始终保持公开形状:六个雷达轴加fitted_constant;不可用项为null。- 只有七维 raw 全部成功时才生成
FittedConstant和映射后的 score。 - 拟合输出在模块内部限位;上下限由
RegressionBetaModel的MinimumFittedConstant/MaximumFittedConstant常量配置,当前为 0~18。 partial会保留已完成的特征,但不会生成拟合定数。
dotnet test MajRadar.slnx
dotnet pack MajRadar.csproj -c ReleaseNuGet包以 netstandard2.1 为目标,包含MajSimai包依赖。
MajRadar is a Unity-free radar analyser and fitted-constant estimator for Simai
charts. It consumes MajSimai's typed SimaiChart, preserves chart-relative
timing, computes seven fixed raw features, applies the frozen regression model,
and maps the public radar axes.
MajdataPlay should copy the static integration facade from
Samples~/MajdataPlay. The sample wires the single
PlayExtendedSlideBarCountProvider directly into one long-lived RadarRuntime;
callers do not hold a service instance and do not need factories, options, or
per-request dependency selection:
ChartRadarSnapshot snapshot = await ChartRadarService.AnalyzeAsync(
simaiChart,
cancellationToken);The static facade retains only the Runtime, which has no per-chart state. The component that owns the current chart still owns its token, selection generation, and cache. The sample returns a lightweight snapshot so UI caches do not retain the full adapted event list and seven-dimension analysis graph.
Known dimensions can be read through the enum while the existing string-keyed dictionaries remain available:
double? noteScore = snapshot.GetScore(RadarOutputDimension.Note);
double? trickyRaw = snapshot.GetRawValue(RadarOutputDimension.SlideTricky);
// Existing and dynamic UI code can keep using literal keys.
double? sameNoteScore = snapshot.Scores["note"];RadarOutputDimension contains the six public radar axes and
FittedConstant; it intentionally excludes the internal regression-only
slide_cumulate. Prefer snapshot.FittedConstant when reading the fitted value.
Reuse an existing chart parsed by MajSimai whenever possible:
RadarResult result = runtime.Analyze(existingSimaiChart, cancellationToken);Use the asynchronous wrapper when the caller must keep the Unity main thread responsive:
RadarResult result = await _runtime.AnalyzeAsync(
existingSimaiChart,
cancellationToken);Standalone callers that only have one inote string may use:
RadarResult result = await _runtime.ParseAndAnalyzeAsync(
inote,
cancellationToken);ParseAndAnalyzeAsync is a convenience boundary, not a song loader. The caller
continues to own files, song metadata, chart type, artwork, and audio offsets.
When no provider is supplied, extended K Slides use the built-in
DefaultExtendedSlideBarCountProvider, a pinned, Unity-free copy of Play's
geometry. Play should still inject its own provider so its current gameplay
geometry remains authoritative:
internal sealed class PlayExtendedSlideBarCountProvider
: IExtendedSlideBarCountProvider
{
public int ResolveBarCount(string slideCode)
{
var path = SlideCodeParser.Parse(slideCode);
return SlideDataBuilder.BuildArrowData(path).Length - 2;
}
}The provider receives a normalized SlideCode such as 1P6K7. It must return a
positive arrow/bar count. Provider exceptions and non-positive results become a
structured adaptation failure; they never trigger a retry through the fallback.
The fallback is selected only when the constructor receives no provider or
null. It creates a fresh path per call and can be shared concurrently.
Include DefaultExtendedSlideBarCountProvider.GeometryVersion in persistent
cache identities, or your host provider's own version when overriding it.
See fallback geometry for provenance and
the focused geometry and provider regression tests.
NuGet requires MajSimai 2.2.3 or newer. That release fixes Slidecodes containing
A/B/C path commands, such as 1A3P9K5, being misclassified as Touch notes.
Ordinary Slides, feature parameters, regression coefficients, and score mapping are built in and are not dependency-injected.
Cancellation is cooperative and returned as data:
if (result.IsCancelled)
return;Analyzechecks the token during adaptation, between feature dimensions, and throughout the high-complexity Sweep candidate, family, hand-motion, and selection loops.- Other dimensions check at their feature or bounded section boundaries.
AnalyzeAsyncdoes not forcibly abort its worker thread; it returns aRadarResultwithStatus == "cancelled"after the next observation point.- MajSimai does not expose cancellation for an in-progress parse. Therefore
ParseAndAnalyzeAsyncchecks immediately before parsing and again when the parser returns. - The synchronous provider method is expected to be short and bounded; it is not interrupted in the middle of a call.
The host owns one CancellationTokenSource per current selection. On a song or
difficulty change, cancel the previous source, start a new analysis, and discard
any result whose selection generation is no longer current. Cancellation alone
does not replace the generation check because a completed older task may race a
new selection.
if (result.IsSuccess)
{
foreach (var dimension in result.DimensionOrder)
Render(dimension, result.Scores[dimension]);
}
else if (!result.IsCancelled)
{
LogErrors(result.Errors);
}Analysis.Featurescontains all seven fixed model inputs, including internalslide_cumulate.RawValuesandScoresalways use the shape-stable public order: six radar axes plusfitted_constant; unavailable entries arenull.FittedConstantand mapped scores are produced only when all seven raw features succeed.- Predictions are clamped inside the module using
MinimumFittedConstantandMaximumFittedConstantinRegressionBetaModel(currently 0 and 18). partialpreserves completed feature results but does not produce a fitted constant.fitted_constantis identity-mapped and is not on the radar axes' 0-250 scale.
MajdataPlay should pin MajRadar as a Git submodule beside its existing MajSimai
submodule. Unity compiles Runtime/ through MajRadar.asmdef, which references
the single existing MajSimai assembly. Do not install the MajRadar NuGet package
into the same Unity project.
MajRadar is code-only and ignores Unity-generated *.meta files inside the
submodule. The Play repository should still commit the
Assets/Plugins/MajRadar.meta file for the submodule directory itself. Revisit
this policy if the package later adds prefabs, ScriptableObjects, or other assets
that require stable GUIDs.
dotnet test MajRadar.slnx
dotnet pack MajRadar.csproj -c ReleaseThe NuGet package targets netstandard2.1 and declares one MajSimai package
dependency. Pull requests build and test; main-branch pushes publish unique CI
prereleases, while v* tags publish stable versions.
Compatibility tests can compile against a source checkout matching Play's pin:
dotnet test MajRadar.slnx \
-p:MajSimaiProject=/absolute/path/to/MajSimai.csprojSong discovery, metadata, audio offsets, visualizers, training, and experimental pipelines intentionally remain outside this runtime component.
下面是每个维度对应的计算方式以及映射方式。
Note 和 Peak 共用物量的加权逻辑
-
Tap:
1 -
长度不超过半拍的 Hold:
1 -
长 Hold:
2 -
Slide:SlidePrefab的BarCount /64,向上取整。
-
Touch:同拍相邻 Touch 合并为一个连通分量;相差不超过 {16}的相邻 Touch 也可以跨时间合并。
无头 Slide 会保留并参与 Note、Peak,但不算作SlideGroup。
将谱面按固定1.5 s划分窗口,空谱返回 0。每个窗口的密度为:density_i = workload_i / 1.5
设所有窗口密度的均值为 μ,总体标准差为 σ:
NoteRaw = μ × (1 + 0.3 × σ / μ)
= μ + 0.3σ
以 0.5 s 为步长扫描候选位置。对候选起点 t,计算三个相邻 1.5 s 窗口的加权密度:
Peak(t) = 0.2D_{t-1} + 0.6D_{t} + 0.2D_{t+1}
候选按强度排序,最多选择三个相距至少 4.5 s 的峰值:
PeakRaw = 0.5 × P1 + 0.3 × P2 + 0.2 × P3
Peak中连续单键扫键进行对数衰减,对第四个及之后的扫键权重乘上1 / log2(n-2),避免简单连续扫键无限抬高 Peak。此外Touch 组权重降为0.5。
Sweep 分为四个阶段:
- 识别单手或双手 Sweep 主干;
- 将相邻 Sweep 连接为 family;
- 用DP寻找平均位移最低的双手动作分配;
- 聚合最强的多个peak window。
识别规则包括:
- 每拍最多三个外键;
- 普通单位移动间隔不超过{12};
- 允许连续八分,以及减速到八分;
- 长 Hold 可以连接跨两个键位的移动,避免锁手圈被打断;
- 支持单键、双键、宽度变化、换手和折返;
每个 Sweep batch 的基础负荷为:
noteWeight = normalCount + 0.3 × exCount
speedFactor = sqrt((1 / 12) / unitIntervalSeconds)
base = noteWeight × speedFactor
连续单手简单 Sweep 在第 16 个 attack 后开始衰减:
decay = excessRank^(-1/2)
双手动态规划按以下优先级寻找动作分配:
- 最少过快跳跃;
- 最少空闲手接管;
- 最短总环形键位距离;
- 最短主动移动距离。
将不在处理键的手定义为空闲,计算它处理接下来的note之前需要走的距离。空闲手移动压力为:
idlePressure =
distance × sqrt(max(1, distance / (elapsedSeconds × 10)))
动作负荷为:
motion =
0.5 × idlePressure
+ 1.0 × freeHandTakeover
+ 2.0 × fastJumpViolation
重复规律动作的 motion 乘以 0.1。同方向连接和同方向换手分别提供局部 0.2 bonus。
最终选择top3不重合2 s窗口:
windowValue = baseDensity + motionDensity
weight_k = k^(-1/2)
最终结果:
SweepRaw =
Σ(weight_k × windowValue_k)
/ Σweight_k
SlideTricky 衡量 Slide头打下到启动,星星运动之间中插入其他操作所造成的错位压力,为了避免放手星星处理配置造成的异常情况,星星运动的计算时间取min(星星运动时间,一拍四分音符)
对物件计算的主要加权规则:
- 与 Slide 目标同位置的按键×1.5
- 启动拍上的总物量x0.5,最高2.
- Touch 组权重1.5,一次错位最多只算两组
- 一次错位最多计算16个物件
普通按键的速度因子由中位时间间隔计算:
ratio = (30 / medianGapSeconds) / 180
speed =
sqrt(ratio) , ratio < 1
min(1.5, ratio) , ratio >= 1
多Slide增益为:
concurrency = Σsqrt(pathCount)
multiplier =
1 + 0.15 × max(0, concurrency - 1)
每次错位的负荷为:
clusterLoad =
(internalLoad + launchLoad)
× objectCapFactor
× multiplier
最终取负荷最高Top5,做对数衰减:
weight_k = 1 / log2(k + 1)
SlideTrickyRaw =
Σ(weight_k × clusterLoad_k)
/ Σweight_k
SlideSequence 衡量连续星星阵列的节奏、长度和是否存在双押。slide头之间间距<=四分音即可合并为一组。
序列要求相邻有效节点至少间隔八分,避免扫星星的巨大加成,并通过动态规划依次优先选择:
- 更长的序列;
- 更多直接连接;
- 更高速度;
- 更高并发。
节奏项:
cadence =
average(0.5 / intervalSeconds)
并发项:
concurrency =
average(max(0, Σsqrt(pathCount) - 1))
section 强度:
sectionIntensity =
cadence × lengthFactor(sequenceLength)
+ 0.5 × concurrency
长度在短序列阶段线性增长,超过 16 个节点后转为平方根增长,避免超长序列无限线性放大。
最终取top5连接段落:
weight_k = 1 / log2(k + 1)
SlideSequenceRaw =
Σ(weight_k × sectionIntensity_k)
/ Σweight_k
Jack 在八个外键上分别寻找纵连。
序列允许一次出现4个偏移纵连位置的tap(类AMABIE配置,纵连轴交互);偏移之前必须有两个及以上的同键纵连才会将他们前后组合为纵连。 基础强度为:
strength =
normalAnchors //纵连物量
+ 0.3 × exAnchors
+ 1.5 × (
normalInterruptions //外部飞键
+ 0.3 × exInterruptions
)
根据首尾键计算等效十六分 BPM,以180bpm为基准:
bpm =
15 × (anchorTimeCount - 1)
/ spanSeconds
speedFactor = (bpm / 180)^1.5
单个序列的速度加权强度:
weightedStrength = strength × speedFactor
最终取Top5:
JackRaw =
Σ(
weightedStrength_k
× 1.3
× 0.645^(k - 1)
)
SlideCumulate 衡量连续 Slide 阵列中逐步累积的错位压力。只参与拟合不在对外雷达中展示。
设错位内部累计物件数为 n,内部总负荷为:
internalTotal(n) =
n + 0.35 × log2(n!)
每个 section 的强度为:
sectionIntensity =
0.8 × mean(onsetLoad)
+ 0.2 × RMS(onsetLoad)
有效长度函数为:
effectiveLength(n) = n
当 n <= 5;超过 5 后使用对数压缩:
effectiveLength(n) =
5 × (
1 + log_5(
1 + (n - 5) / 5
)
)
最终结果按整谱时长归一化:
SlideCumulateRaw =
Σ(
effectiveLength(sectionLength)
× sectionIntensity
)
/ (chartDurationSeconds / 0.5)
七个维度按以下固定顺序输入模型:
note
peak
sweep
slide_tricky
slide_sequence
jack
slide_cumulate
使用 center 和 scale 标准化:z_i = (x_i - center_i) / scale_i
随后进行二次回归:
FittedConstant =
intercept
+ Σ(a_i × z_i)
+ Σ(b_ij × z_i × z_j) ,
0 <= i <= j < 7
参数作为常量hardcode。
七个 raw 维度各自具有固定的四个 raw 锚点,使用线性插值,在官谱分布上拟合了整个分布,每维度最高记作 T4Maximum
T1 → 50
T2 → 100
T3 → 150
T4 → 200
score(x) =
S_i
+ (S_(i+1) - S_i)
× (x - T_i)
/ (T_(i+1) - T_i)
从 T4 到该维度的 T4Maximum 保持 200 分平台:
T4 < x <= T4Maximum
→ score = 200
为了兼容自制的难度,允许超越官谱的最高数值,留有200~250的headroom。超过 T4Maximum 后使用log渐进。在最初采用同样斜率避免跳变,然后对数衰减渐进到250。
score(x) =
200
+ headroom × (
1 - exp(
-initialSlope
× (x - T4Maximum)
/ headroom
)
)