Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 5 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,22 +40,23 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout pinned MajSimai
- name: Checkout MajSimai release tag
uses: actions/checkout@v4
with:
repository: TeamMajdata/MajSimai
ref: fdb2a3e39d8997a0abbf8b4679062d854473cc77
# Keep this tag aligned with the NuGet version in MajRadar.csproj.
ref: refs/tags/2.2.3
path: external/MajSimai
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x

- name: Restore with pinned MajSimai
- name: Restore with MajSimai release tag
run: >-
dotnet restore MajRadar.slnx
-p:MajSimaiProject="$GITHUB_WORKSPACE/external/MajSimai/MajSimai.csproj"

- name: Test with pinned MajSimai
- name: Test with MajSimai release tag
run: >-
dotnet test MajRadar.slnx --no-restore -c Release
-p:MajSimaiProject="$GITHUB_WORKSPACE/external/MajSimai/MajSimai.csproj"
69 changes: 69 additions & 0 deletions Documentation~/FallbackGeometry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Fallback extended-Slide geometry

`new RadarRuntime()` and `new MajSimaiChartAdapter()` select
`DefaultExtendedSlideBarCountProvider.Instance` when the optional provider is
null. An explicitly supplied provider is authoritative, including its exceptions
and non-positive results. Standard Slides continue using the existing lookup
table and never call the extended-Slide provider.

## Source and maintenance

The geometry snapshot is adapted from TeamMajdata/MajdataPlay commit
`acd295d3f878f53ecd412d150936a465ed2e3d28` (GPL-3.0):

https://github.com/TeamMajdata/MajdataPlay/tree/acd295d3f878f53ecd412d150936a465ed2e3d28/Assets/Scripts/Scenes/Game/Misc/Parsing

`Runtime/MajSimaiAdapter/FallbackGeometry/` retains the pure `System.Numerics`
path parser, circles, path constructor and segments. Namespaces and top-level
visibility are changed to keep these implementation types internal. No Unity
types, assets, rendering or judgement-area lookup are needed.

The provider follows `SlideDataBuilder.BuildArrowData(path).Length - 2` but only
counts placements: it does not allocate the arrow coordinate array. It preserves
the exact segment transition order, floating-point operations, default spacing,
`SmoothAlign` and `ForceAlign` rules. A total-length/divisor approximation would
change counts. Each call owns its path because alignment mutates segment spacing.
Invalid or non-advancing geometry throws and is converted into the existing
structured adaptation error at the public analysis boundary.

The provider is synchronous, like the host-provider interface. Cancellation does
not interrupt a call midway; hosts should bound input sizes and analysis
concurrency. The fallback adds no process-wide result cache.

Persisted analysis cache keys should include the MajRadar and MajSimai package
versions plus `DefaultExtendedSlideBarCountProvider.GeometryVersion`. When
updating this snapshot, change the geometry version and review the geometry
regressions against the upstream change. An injected host provider should
use its own geometry version instead.

## Focused regression tests

`DefaultExtendedSlideBarCountProviderTests` keeps 13 representative Play counts
covering A/B/C nodes, clockwise/counterclockwise orbits, repeated circles,
tangent transfers, and both alignment markers. Default selection, explicit
provider precedence, provider failures, malformed geometry and concurrent path
ownership are tested separately. Run them with `dotnet test MajRadar.slnx`.

The previous 5,757-row snapshot and its generator were removed: the broad grid
duplicated the same path rules and encoded macOS floating-point boundary choices
as universal integer answers. .NET trigonometric operations use the native C
runtime and may differ across operating systems and architectures:

https://learn.microsoft.com/en-us/dotnet/api/system.math.sin#remarks

Four SmoothAlign paths (`8Q69K4`, `4P39K1`, `8Q69K8`, `1Q39K4`) end within a few
ULPs of a segment boundary or endpoint. Play's retained strict comparisons may
therefore include one additional arrow sample. Their tests explicitly allow
the two observed macOS/Ubuntu counts; other representative cases still require
exact values. Concurrency tests compare with sequential results from the same
process. The runtime algorithm and `GeometryVersion` are unchanged.

The fallback follows the pinned Play algorithm on the running platform; it does
not promise bit-identical arrow counts across all platforms. Making those counts
platform-independent would require a shared numerical policy in Play and MajRadar,
not a test-only change.

MajSimai 2.2.3 is required for end-to-end parsing: version 2.2.2 misclassifies
some extended paths containing A/B/C, even when the geometry provider is correct.
CI checks out the `2.2.3` release tag (`refs/tags/2.2.3`), aligned with the
NuGet dependency in `MajRadar.csproj`. This tag includes the same parser fix.
5 changes: 4 additions & 1 deletion Documentation~/MajdataPlay.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ MajSimai assembly; the NuGet package is not installed into the Unity project.

Extended `K` Slides are resolved through `IExtendedSlideBarCountProvider`. The
sample provider calls Play's existing `SlideCodeParser` and `SlideDataBuilder`,
so MajRadar does not copy or depend on gameplay geometry.
so Play's current gameplay geometry always takes precedence over MajRadar's
built-in fallback. Hosts without a provider use a pinned Unity-free geometry
copy; see [FallbackGeometry.md](FallbackGeometry.md). An injected provider's
failure remains a failure and is never retried using the fallback.

The sample `ChartRadarService` is a static Play-only facade. It owns one
thread-safe `RadarRuntime` with the single supported provider, while each UI
Expand Down
2 changes: 1 addition & 1 deletion MajRadar.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
<Compile Include="Runtime/**/*.cs" />
</ItemGroup>
<ItemGroup Condition="'$(MajSimaiProject)' == ''">
<PackageReference Include="Lingfeng-bbben.MajSimai" Version="2.2.2" />
<PackageReference Include="Lingfeng-bbben.MajSimai" Version="2.2.3" />
</ItemGroup>
<ItemGroup Condition="'$(MajSimaiProject)' != ''">
<ProjectReference Include="$(MajSimaiProject)" />
Expand Down
36 changes: 29 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ RadarResult result = await _runtime.ParseAndAnalyzeAsync(

### Slidecode 依赖

扩展Slidecode的长度算法需要依赖Play自身的Parser。
未注入 provider 时,MajRadar 使用内置的 `DefaultExtendedSlideBarCountProvider`,
它来自固定版本的 Play 纯 .NET 几何实现,后端无需安装 Unity。
Play 仍应显式注入自己的 provider,以游戏当前使用的几何为准:

```csharp
internal sealed class PlayExtendedSlideBarCountProvider
Expand All @@ -72,8 +74,14 @@ internal sealed class PlayExtendedSlideBarCountProvider
}
```
provider只接受SlideCode,例如 `1P6K7`,并且必须返回正数 arrow/bar count。
provider 抛出的异常或非正结果会变成结构化适配失败。未提供 provider 时,普通谱面仍可
正常分析;遇到 `K` Slide 会返回错误
provider 抛出的异常或非正结果会变成结构化适配失败,不会再次尝试备用实现。
只有构造时未提供 provider(或传入 `null`)才选择内置实现;普通 Slide 不受影响。
备用实现无共享的单谱状态,可并发调用。数据库缓存版本应包含
`DefaultExtendedSlideBarCountProvider.GeometryVersion`;宿主 provider 使用自己的版本标识。
几何来源和对照测试见 [备用几何说明](Documentation~/FallbackGeometry.md)。

NuGet 依赖最低 MajSimai 2.2.3,其中修复了 `1A3P9K5` 等带 A/B/C 路径的
Slidecode 被误识别为 Touch 的问题。

### 取消与选歌状态

Expand Down Expand Up @@ -110,6 +118,8 @@ else if (!result.IsCancelled)
- `RawValues` 和 `Scores` 始终保持公开形状:六个雷达轴加
`fitted_constant`;不可用项为 `null`。
- 只有七维 raw 全部成功时才生成 `FittedConstant` 和映射后的 score。
- 拟合输出在模块内部限位;上下限由 `RegressionBetaModel` 的
`MinimumFittedConstant` / `MaximumFittedConstant` 常量配置,当前为 0~18。
- `partial` 会保留已完成的特征,但不会生成拟合定数。


Expand Down Expand Up @@ -196,8 +206,10 @@ continues to own files, song metadata, chart type, artwork, and audio offsets.

### Extended Slide dependency

Extended `K` Slides need gameplay geometry supplied by the host. MajRadar keeps
the dependency narrow and does not reference MajdataPlay:
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:

```csharp
internal sealed class PlayExtendedSlideBarCountProvider
Expand All @@ -213,8 +225,16 @@ internal sealed class PlayExtendedSlideBarCountProvider

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. Without a provider, ordinary charts still work,
while a chart containing a `K` Slide returns an error instead of guessing.
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](Documentation~/FallbackGeometry.md) 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.
Expand Down Expand Up @@ -266,6 +286,8 @@ else if (!result.IsCancelled)
axes plus `fitted_constant`; unavailable entries are `null`.
- `FittedConstant` and mapped scores are produced only when all seven raw
features succeed.
- Predictions are clamped inside the module using `MinimumFittedConstant` and
`MaximumFittedConstant` in `RegressionBetaModel` (currently 0 and 18).
- `partial` preserves completed feature results but does not produce a fitted
constant.
- `fitted_constant` is identity-mapped and is not on the radar axes' 0-250
Expand Down
74 changes: 74 additions & 0 deletions Runtime/MajSimaiAdapter/DefaultExtendedSlideBarCountProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using System;
using MajRadar.MajSimaiAdapter.FallbackGeometry;
using MajRadar.MajSimaiAdapter.FallbackGeometry.Slide;

namespace MajRadar.MajSimaiAdapter;

/// <summary>
/// Unity-free fallback for hosts without their own extended-Slide geometry.
/// Counts arrows using a pinned copy of MajdataPlay's path and alignment rules.
/// Each call creates its own path, so the shared instance is thread-safe.
/// </summary>
public sealed class DefaultExtendedSlideBarCountProvider : IExtendedSlideBarCountProvider
{
/// <summary>Include this identifier in persistent analysis cache versions.</summary>
public const string GeometryVersion = "play-acd295d-count-v1";

public static DefaultExtendedSlideBarCountProvider Instance { get; } = new();

public int ResolveBarCount(string slideCode)
{
if (slideCode is null)
throw new ArgumentNullException(nameof(slideCode));
if (slideCode.Length < 3 || slideCode[0] is < '1' or > '8' ||
slideCode[^2] != 'K' || slideCode[^1] is < '1' or > '8')
throw new ArgumentException("Expected a normalized extended Slide code, such as 1P6K7.", nameof(slideCode));

var path = SlideCodeParser.Parse(slideCode);
var totalLength = path.GetPathLength();
if (!IsFinite(totalLength) || totalLength <= 0)
throw new ArgumentException("Extended Slide path must have a finite positive length.", nameof(slideCode));

var currentLength = 0.0;
var segmentIndex = 0;
var samples = 0;
while (currentLength < totalLength)
{
samples = checked(samples + 1);
var segment = path.Segments[segmentIndex];
var nextLength = currentLength + segment.ArrowDistance;

// Keep the single-segment transition and floating-point operation order
// from Play's BuildArrowData. Rounding the total length is not equivalent.
if (segmentIndex < path.Segments.Length - 1 &&
nextLength >= path.AccumulatedLengths[segmentIndex])
{
var nextSegment = path.Segments[segmentIndex + 1];
if (nextSegment.ParseMarker == SlideParseMarker.SmoothAlign)
{
var delta = path.AccumulatedLengths[segmentIndex + 1] - currentLength;
var n = Math.Round(delta / SlideGeo.DefaultDistance);
nextSegment.SetArrowDistance(delta / n);
nextLength = currentLength + delta / n;
}
if (segment.ParseMarker == SlideParseMarker.ForceAlign)
nextLength = path.AccumulatedLengths[segmentIndex] + nextSegment.ArrowDistance;
segmentIndex++;
}

// Invalid geometry must fail rather than stall a server worker forever.
if (!IsFinite(nextLength) || nextLength <= currentLength)
throw new ArgumentException("Extended Slide arrow placement must advance by a finite positive distance.", nameof(slideCode));
currentLength = nextLength;
}

// BuildArrowData includes the starting sample and appends the endpoint.
// Its Length - 2 therefore equals the number of loop samples minus one.
var count = samples - 1;
if (count <= 0)
throw new ArgumentException("Extended Slide path has no arrows.", nameof(slideCode));
return count;
}

private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value);
}
21 changes: 21 additions & 0 deletions Runtime/MajSimaiAdapter/FallbackGeometry/ComplexCircle.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Adapted from TeamMajdata/MajdataPlay, commit acd295d3f878f53ecd412d150936a465ed2e3d28.
// GPL-3.0; see Documentation~/FallbackGeometry.md for provenance and parity tests.
using System;
using System.Collections.Generic;
using System.Numerics;
using System.Text;

namespace MajRadar.MajSimaiAdapter.FallbackGeometry
{
internal readonly struct ComplexCircle
{
public readonly Complex Center;
public readonly double Radius;

public ComplexCircle(Complex center, double radius)
{
Center = center;
Radius = radius;
}
}
}
Loading
Loading