From 8977b6b350a0e0dca304cadc6028a9ad7dfff318 Mon Sep 17 00:00:00 2001 From: SniperPigeon Date: Wed, 23 Sep 2026 16:56:38 +0800 Subject: [PATCH 01/10] feat: add MajSimai chart adaptation core --- Runtime/Core/BeatPosition.cs | 105 +++++ Runtime/Core/RadarChartInput.cs | 59 +++ .../IExtendedSlideBarCountProvider.cs | 10 + .../MajSimaiAdapter/MajSimaiChartAdapter.cs | 432 ++++++++++++++++++ Runtime/MajSimaiAdapter/SlidePathResolver.cs | 174 +++++++ .../MajSimaiAdapter/StandardSlideBarCounts.cs | 94 ++++ 6 files changed, 874 insertions(+) create mode 100644 Runtime/Core/BeatPosition.cs create mode 100644 Runtime/Core/RadarChartInput.cs create mode 100644 Runtime/MajSimaiAdapter/IExtendedSlideBarCountProvider.cs create mode 100644 Runtime/MajSimaiAdapter/MajSimaiChartAdapter.cs create mode 100644 Runtime/MajSimaiAdapter/SlidePathResolver.cs create mode 100644 Runtime/MajSimaiAdapter/StandardSlideBarCounts.cs diff --git a/Runtime/Core/BeatPosition.cs b/Runtime/Core/BeatPosition.cs new file mode 100644 index 0000000..5a06c1d --- /dev/null +++ b/Runtime/Core/BeatPosition.cs @@ -0,0 +1,105 @@ +using System; +using System.Globalization; + +#nullable enable + +namespace MajRadar.Core; + +/// A reduced rational position on the global quarter-note beat axis. +public readonly struct BeatPosition : IComparable, IEquatable +{ + public long Numerator { get; } + public long Denominator { get; } + + public BeatPosition(long numerator, long denominator = 1) + { + if (denominator == 0) throw new ArgumentOutOfRangeException(nameof(denominator)); + if (denominator < 0) + { + numerator = -numerator; + denominator = -denominator; + } + + var divisor = GreatestCommonDivisor(Math.Abs(numerator), denominator); + Numerator = numerator / divisor; + Denominator = denominator / divisor; + } + + public static BeatPosition Zero => new(0); + + /// + /// Bins a floating-point beat distance to a rational subdivision. A sufficiently + /// close simple fraction wins; otherwise the closest fraction within the allowed + /// denominator range is returned, so ordinary floating-point noise never rejects + /// an otherwise usable chart. + /// + public static BeatPosition SnapFromDouble( + double value, + int maxDenominator, + double tolerance) + { + if (double.IsNaN(value) || double.IsInfinity(value)) + throw new ArgumentOutOfRangeException(nameof(value)); + if (maxDenominator < 1) throw new ArgumentOutOfRangeException(nameof(maxDenominator)); + if (double.IsNaN(tolerance) || double.IsInfinity(tolerance) || tolerance < 0) + throw new ArgumentOutOfRangeException(nameof(tolerance)); + + long bestNumerator = 0; + var bestDenominator = 1; + var bestError = double.PositiveInfinity; + for (var denominator = 1; denominator <= maxDenominator; denominator++) + { + var numerator = checked((long)Math.Round(value * denominator)); + var candidate = (double)numerator / denominator; + var error = Math.Abs(candidate - value); + if (error <= tolerance) + return new BeatPosition(numerator, denominator); + if (error < bestError) + { + bestNumerator = numerator; + bestDenominator = denominator; + bestError = error; + } + } + return new BeatPosition(bestNumerator, bestDenominator); + } + + public int CompareTo(BeatPosition other) => + checked(Numerator * other.Denominator).CompareTo(checked(other.Numerator * Denominator)); + + public bool Equals(BeatPosition other) => + Numerator == other.Numerator && Denominator == other.Denominator; + + public override bool Equals(object? obj) => obj is BeatPosition other && Equals(other); + public override int GetHashCode() => HashCode.Combine(Numerator, Denominator); + public double ToDouble() => (double)Numerator / Denominator; + public override string ToString() => Denominator == 1 + ? Numerator.ToString(CultureInfo.InvariantCulture) + : $"{Numerator.ToString(CultureInfo.InvariantCulture)}/{Denominator.ToString(CultureInfo.InvariantCulture)}"; + + public static BeatPosition operator +(BeatPosition left, BeatPosition right) => + new(checked(left.Numerator * right.Denominator + right.Numerator * left.Denominator), + checked(left.Denominator * right.Denominator)); + + public static BeatPosition operator -(BeatPosition left, BeatPosition right) => + new(checked(left.Numerator * right.Denominator - right.Numerator * left.Denominator), + checked(left.Denominator * right.Denominator)); + + public static bool operator ==(BeatPosition left, BeatPosition right) => left.Equals(right); + public static bool operator !=(BeatPosition left, BeatPosition right) => !left.Equals(right); + public static bool operator <(BeatPosition left, BeatPosition right) => left.CompareTo(right) < 0; + public static bool operator <=(BeatPosition left, BeatPosition right) => left.CompareTo(right) <= 0; + public static bool operator >(BeatPosition left, BeatPosition right) => left.CompareTo(right) > 0; + public static bool operator >=(BeatPosition left, BeatPosition right) => left.CompareTo(right) >= 0; + + private static long GreatestCommonDivisor(long left, long right) + { + while (right != 0) + { + var remainder = left % right; + left = right; + right = remainder; + } + return left == 0 ? 1 : left; + } +} diff --git a/Runtime/Core/RadarChartInput.cs b/Runtime/Core/RadarChartInput.cs new file mode 100644 index 0000000..e0e566d --- /dev/null +++ b/Runtime/Core/RadarChartInput.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; + +#nullable enable + +namespace MajRadar.Core; + +public enum RadarEventKind +{ + Tap, + Hold, + Touch, + TouchHold, + Slide, + Timing +} + +public sealed class SlidePathSegment +{ + public string Shape { get; set; } = string.Empty; + public int StartPosition { get; set; } + public int? ViaPosition { get; set; } + public int EndPosition { get; set; } + public int BarCount { get; set; } + public double StartTimeSeconds { get; set; } + public double EndTimeSeconds { get; set; } + public string RawSegment { get; set; } = string.Empty; +} + +public sealed class RadarEvent +{ + public int EventId { get; set; } + public RadarEventKind Kind { get; set; } + public bool? IsSlideHead { get; set; } + public double? SlideDeclareTimeSeconds { get; set; } + public BeatPosition? SlideDeclareBeat { get; set; } + public double StartTimeSeconds { get; set; } + public double EndTimeSeconds { get; set; } + public BeatPosition StartBeat { get; set; } + public BeatPosition EndBeat { get; set; } + public double? Bpm { get; set; } + public string? Position { get; set; } + public int? HeadEventId { get; set; } + public int? SlideGroupId { get; set; } + public IReadOnlyList? SlidePath { get; set; } + public bool? IsBreak { get; set; } + public bool? IsEx { get; set; } + public bool? IsMine { get; set; } + public IReadOnlyDictionary? Flags { get; set; } + public string RawToken { get; set; } = string.Empty; + public int DeclarationOrder { get; set; } +} + +public sealed class RadarChartInput +{ + public IReadOnlyList Events { get; set; } = Array.Empty(); + public double ChartEndTimeSeconds { get; set; } + public double? LastEventEndTimeSeconds { get; set; } +} diff --git a/Runtime/MajSimaiAdapter/IExtendedSlideBarCountProvider.cs b/Runtime/MajSimaiAdapter/IExtendedSlideBarCountProvider.cs new file mode 100644 index 0000000..55b94ed --- /dev/null +++ b/Runtime/MajSimaiAdapter/IExtendedSlideBarCountProvider.cs @@ -0,0 +1,10 @@ +namespace MajRadar.MajSimaiAdapter; + +/// +/// Supplies the gameplay arrow count for a normalized extended K Slide code. +/// Standard Slides continue to use MajRadar's fixed reference table. +/// +public interface IExtendedSlideBarCountProvider +{ + int ResolveBarCount(string slideCode); +} diff --git a/Runtime/MajSimaiAdapter/MajSimaiChartAdapter.cs b/Runtime/MajSimaiAdapter/MajSimaiChartAdapter.cs new file mode 100644 index 0000000..3cdc6cb --- /dev/null +++ b/Runtime/MajSimaiAdapter/MajSimaiChartAdapter.cs @@ -0,0 +1,432 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MajSimai; +using MajRadar.Core; + +#nullable enable + +namespace MajRadar.MajSimaiAdapter; + +public sealed class AdaptationResult +{ + public RadarChartInput? Chart { get; set; } + public IReadOnlyList Errors { get; set; } = Array.Empty(); + public bool IsCancelled { get; set; } + public bool IsSuccess => Chart is not null && Errors.Count == 0 && !IsCancelled; +} + +public sealed class MajSimaiChartAdapter +{ + private const double TimeTolerance = 1e-8; + private const int MaximumBeatDenominator = 4096; + private const double BeatSnapTolerance = 1e-7; + private readonly SlidePathResolver _slidePaths; + + public MajSimaiChartAdapter(IExtendedSlideBarCountProvider? extendedSlides = null) + { + _slidePaths = new SlidePathResolver(extendedSlides); + } + + public async Task ParseAndAdaptAsync( + string inote, + CancellationToken cancellationToken = default) + { + try + { + if (cancellationToken.IsCancellationRequested) return Cancelled(); + var chart = await SimaiParser.ParseChartAsync(inote).ConfigureAwait(false); + return Adapt(chart, cancellationToken); + } + catch (Exception exception) + { + return Failure($"MajSimai parse failed: {exception.Message}"); + } + } + + public AdaptationResult Adapt( + SimaiChart chart, + CancellationToken cancellationToken = default) + { + try + { + if (cancellationToken.IsCancellationRequested) return Cancelled(); + var commaTimings = chart.CommaTimings.ToArray(); + if (commaTimings.Length == 0) + return Failure("MajSimai returned no comma timing points."); + + var timeline = BuildTimeline(commaTimings); + var pending = new List(); + AddTimingEvents(timeline, pending); + + var declarationOrder = pending.Count; + var slideGroupId = 0; + foreach (var timing in chart.NoteTimings) + { + if (cancellationToken.IsCancellationRequested) return Cancelled(); + var containsExplicitNoHead = timing.RawContent.IndexOf('?') >= 0 || + timing.RawContent.IndexOf('!') >= 0; + var declarationBeat = timeline.BeatAt(timing.Timing); + var declarationTime = timeline.TimeAt(declarationBeat); + PendingEvent? currentHead = null; + int? currentSlideGroupId = null; + int? currentSlideStartPosition = null; + foreach (var note in timing.Notes) + { + if (cancellationToken.IsCancellationRequested) return Cancelled(); + declarationOrder++; + if (note.Type == SimaiNoteType.Slide) + { + if (note.IsSlideNoHead && containsExplicitNoHead) + { + pending.Add(CreateSlide( + note, timing, declarationTime, declarationBeat, declarationOrder, + null, null, timeline)); + continue; + } + if (!note.IsSlideNoHead) + { + currentHead = CreateHead( + note, timing, declarationTime, declarationBeat, declarationOrder); + pending.Add(currentHead); + currentSlideGroupId = ++slideGroupId; + currentSlideStartPosition = note.StartPosition; + } + else if (currentSlideGroupId is null || + currentSlideStartPosition != note.StartPosition) + { + currentHead = null; + currentSlideGroupId = ++slideGroupId; + currentSlideStartPosition = note.StartPosition; + } + + pending.Add(CreateSlide( + note, timing, declarationTime, declarationBeat, declarationOrder, + currentHead?.TemporaryId, currentSlideGroupId, timeline)); + continue; + } + + currentHead = null; + currentSlideGroupId = null; + currentSlideStartPosition = null; + pending.Add(CreateOrdinary( + note, timing, declarationTime, declarationBeat, declarationOrder, timeline)); + } + } + + var ordered = pending + .OrderBy(item => item.Event.StartTimeSeconds) + .ThenBy(item => item.SourcePosition) + .ThenBy(item => item.Event.DeclarationOrder) + .ToArray(); + var idMap = new Dictionary(); + for (var index = 0; index < ordered.Length; index++) + idMap[ordered[index].TemporaryId] = index + 1; + + var events = new List(ordered.Length); + for (var index = 0; index < ordered.Length; index++) + { + if (cancellationToken.IsCancellationRequested) return Cancelled(); + var source = ordered[index].Event; + events.Add(new RadarEvent + { + EventId = index + 1, + Kind = source.Kind, + IsSlideHead = source.IsSlideHead, + SlideDeclareTimeSeconds = source.SlideDeclareTimeSeconds, + SlideDeclareBeat = source.SlideDeclareBeat, + StartTimeSeconds = source.StartTimeSeconds, + EndTimeSeconds = source.EndTimeSeconds, + StartBeat = source.StartBeat, + EndBeat = source.EndBeat, + Bpm = source.Bpm, + Position = source.Position, + HeadEventId = ordered[index].HeadTemporaryId is int head ? idMap[head] : null, + SlideGroupId = source.SlideGroupId, + SlidePath = source.SlidePath, + IsBreak = source.IsBreak, + IsEx = source.IsEx, + IsMine = source.IsMine, + Flags = source.Flags, + RawToken = source.RawToken, + DeclarationOrder = source.DeclarationOrder + }); + } + + var objects = events.Where(item => item.Kind != RadarEventKind.Timing).ToArray(); + if (objects.Length == 0) + return Failure("MajSimai returned no analyzable chart objects."); + return new AdaptationResult + { + Chart = new RadarChartInput + { + Events = events, + ChartEndTimeSeconds = timeline.Points[^1].CanonicalTime, + LastEventEndTimeSeconds = objects.Max(item => item.EndTimeSeconds) + } + }; + } + catch (Exception exception) + { + return Failure($"MajSimai output could not be adapted safely: {exception.Message}"); + } + } + + private PendingEvent CreateSlide( + SimaiNote note, + SimaiTimingPoint timing, + double declarationTime, + BeatPosition declarationBeat, + int order, + int? headTemporaryId, + int? slideGroupId, + Timeline timeline) + { + var startBeat = timeline.BeatAt(note.SlideStartTime); + var endBeat = timeline.BeatAt(note.SlideStartTime + note.SlideTime); + var startTime = timeline.TimeAt(startBeat); + var endTime = timeline.TimeAt(endBeat); + return NewPending(new RadarEvent + { + EventId = 0, + Kind = RadarEventKind.Slide, + IsSlideHead = false, + SlideDeclareTimeSeconds = declarationTime, + SlideDeclareBeat = declarationBeat, + StartTimeSeconds = startTime, + EndTimeSeconds = endTime, + StartBeat = startBeat, + EndBeat = endBeat, + Position = note.StartPosition.ToString(), + SlideGroupId = slideGroupId, + SlidePath = _slidePaths.Resolve(note.RawContent, startTime, endTime), + IsBreak = note.IsSlideBreak, + IsEx = false, + IsMine = note.IsMineSlide, + Flags = Flags(note), + RawToken = note.RawContent, + DeclarationOrder = order + }, timing.RawTextPosition, headTemporaryId); + } + + private static PendingEvent CreateHead( + SimaiNote note, + SimaiTimingPoint timing, + double declarationTime, + BeatPosition declarationBeat, + int order) => + NewPending(new RadarEvent + { + EventId = 0, + Kind = RadarEventKind.Tap, + IsSlideHead = true, + StartTimeSeconds = declarationTime, + EndTimeSeconds = declarationTime, + StartBeat = declarationBeat, + EndBeat = declarationBeat, + Position = note.StartPosition.ToString(), + IsBreak = note.IsBreak, + IsEx = note.IsEx, + IsMine = note.IsMine, + Flags = Flags(note), + RawToken = note.RawContent, + DeclarationOrder = order + }, timing.RawTextPosition); + + private static PendingEvent CreateOrdinary( + SimaiNote note, + SimaiTimingPoint timing, + double declarationTime, + BeatPosition declarationBeat, + int order, + Timeline timeline) + { + var kind = note.Type switch + { + SimaiNoteType.Tap => RadarEventKind.Tap, + SimaiNoteType.Hold => RadarEventKind.Hold, + SimaiNoteType.Touch => RadarEventKind.Touch, + SimaiNoteType.TouchHold => RadarEventKind.TouchHold, + _ => throw new MajSimaiAdaptationException($"Unsupported MajSimai note type: {note.Type}") + }; + var endBeat = timeline.BeatAt(timing.Timing + note.HoldTime); + var endTime = timeline.TimeAt(endBeat); + return NewPending(new RadarEvent + { + EventId = 0, + Kind = kind, + IsSlideHead = false, + StartTimeSeconds = declarationTime, + EndTimeSeconds = endTime, + StartBeat = declarationBeat, + EndBeat = endBeat, + Position = kind is RadarEventKind.Touch or RadarEventKind.TouchHold + ? TouchPosition(note) + : note.StartPosition.ToString(), + IsBreak = note.IsBreak, + IsEx = note.IsEx, + IsMine = note.IsMine, + Flags = Flags(note), + RawToken = note.RawContent, + DeclarationOrder = order + }, timing.RawTextPosition); + } + + private static string TouchPosition(SimaiNote note) => + note.TouchArea == 'C' ? "C" : $"{note.TouchArea}{note.StartPosition}"; + + private static IReadOnlyDictionary Flags(SimaiNote note) => + new Dictionary + { + ["using_sv"] = note.UsingSV, + ["force_star"] = note.IsForceStar, + ["fake_rotate"] = note.IsFakeRotate, + ["hanabi"] = note.IsHanabi, + ["tap_head"] = note.IsTapHeadSlide + }; + + private static void AddTimingEvents(Timeline timeline, List output) + { + float? previous = null; + var order = 0; + foreach (var point in timeline.Points) + { + if (previous is not null && Math.Abs(previous.Value - point.Bpm) < 1e-6) continue; + previous = point.Bpm; + output.Add(NewPending(new RadarEvent + { + EventId = 0, + Kind = RadarEventKind.Timing, + StartTimeSeconds = point.CanonicalTime, + EndTimeSeconds = point.CanonicalTime, + StartBeat = point.Beat, + EndBeat = point.Beat, + Bpm = point.Bpm, + RawToken = string.Empty, + DeclarationOrder = order++ + }, point.SourcePosition)); + } + } + + private Timeline BuildTimeline(SimaiTimingPoint[] commaTimings) + { + var points = new List(commaTimings.Length); + var beatValue = 0.0; + var canonicalTime = 0.0; + for (var index = 0; index < commaTimings.Length; index++) + { + var point = commaTimings[index]; + if (double.IsNaN(point.Timing) || double.IsInfinity(point.Timing) || + float.IsNaN(point.Bpm) || float.IsInfinity(point.Bpm) || point.Bpm <= 0) + throw new MajSimaiAdaptationException("MajSimai returned a non-finite or non-positive timing value."); + if (index > 0) + { + var previous = commaTimings[index - 1]; + var elapsed = point.Timing - previous.Timing; + if (elapsed < -TimeTolerance) + throw new MajSimaiAdaptationException("MajSimai comma timings are not ordered."); + beatValue += elapsed * previous.Bpm / 60.0; + } + var beat = SnapBeatValue(beatValue); + if (index > 0) + { + var previous = points[index - 1]; + canonicalTime = previous.CanonicalTime + + (beat - previous.Beat).ToDouble() * 60.0 / previous.Bpm; + } + points.Add(new TimelinePoint( + point.Timing, canonicalTime, beat, point.Bpm, point.RawTextPosition)); + } + return new Timeline(points); + } + + private BeatPosition SnapBeatValue(double beatValue) => + BeatPosition.SnapFromDouble( + beatValue, + MaximumBeatDenominator, + BeatSnapTolerance); + + private static PendingEvent NewPending(RadarEvent item, int sourcePosition, int? head = null) => + new(item, sourcePosition, head, PendingEvent.NextId()); + + private static AdaptationResult Failure(string message) => + new() { Errors = new[] { message } }; + + private static AdaptationResult Cancelled() => new() { IsCancelled = true }; + + private sealed class Timeline + { + public IReadOnlyList Points { get; } + public Timeline(IReadOnlyList points) => Points = points; + + public BeatPosition BeatAt(double time) + { + for (var index = Points.Count - 1; index >= 0; index--) + { + var point = Points[index]; + if (Math.Abs(time - point.RawTime) <= TimeTolerance) return point.Beat; + if (time > point.RawTime) + return BeatPosition.SnapFromDouble( + point.Beat.ToDouble() + (time - point.RawTime) * point.Bpm / 60.0, + MaximumBeatDenominator, + BeatSnapTolerance); + } + throw new MajSimaiAdaptationException($"Time {time} precedes the MajSimai timeline."); + } + + public double TimeAt(BeatPosition beat) + { + for (var index = Points.Count - 1; index >= 0; index--) + { + var point = Points[index]; + if (point.Beat <= beat) + return point.CanonicalTime + (beat - point.Beat).ToDouble() * 60.0 / point.Bpm; + } + throw new MajSimaiAdaptationException($"Beat {beat} precedes the MajSimai timeline."); + } + } + + private sealed class TimelinePoint + { + public TimelinePoint( + double rawTime, + double canonicalTime, + BeatPosition beat, + float bpm, + int sourcePosition) + { + RawTime = rawTime; + CanonicalTime = canonicalTime; + Beat = beat; + Bpm = bpm; + SourcePosition = sourcePosition; + } + + public double RawTime { get; } + public double CanonicalTime { get; } + public BeatPosition Beat { get; } + public float Bpm { get; } + public int SourcePosition { get; } + } + + private sealed class PendingEvent + { + private static int _nextId; + + public PendingEvent(RadarEvent item, int sourcePosition, int? headTemporaryId, int temporaryId) + { + Event = item; + SourcePosition = sourcePosition; + HeadTemporaryId = headTemporaryId; + TemporaryId = temporaryId; + } + + public RadarEvent Event { get; } + public int SourcePosition { get; } + public int? HeadTemporaryId { get; } + public int TemporaryId { get; } + public static int NextId() => Interlocked.Increment(ref _nextId); + } +} diff --git a/Runtime/MajSimaiAdapter/SlidePathResolver.cs b/Runtime/MajSimaiAdapter/SlidePathResolver.cs new file mode 100644 index 0000000..b6ef783 --- /dev/null +++ b/Runtime/MajSimaiAdapter/SlidePathResolver.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MajRadar.Core; + +#nullable enable + +namespace MajRadar.MajSimaiAdapter; + +/// +/// Interprets only the path retained in a parsed SimaiNote. It never reads the +/// original chart and never creates notes that MajSimai did not return. +/// +internal sealed class SlidePathResolver +{ + private const string SingleCharacterShapes = "-^v<>Vpqszw"; + private readonly IExtendedSlideBarCountProvider? _extendedSlides; + + internal SlidePathResolver(IExtendedSlideBarCountProvider? extendedSlides = null) + { + _extendedSlides = extendedSlides; + } + + public IReadOnlyList Resolve( + string rawContent, + double slideStartTimeSeconds, + double slideEndTimeSeconds) + { + if (string.IsNullOrEmpty(rawContent) || rawContent[0] is < '1' or > '8') + throw new MajSimaiAdaptationException($"Invalid MajSimai Slide RawContent: {rawContent}"); + if (rawContent.Contains('K')) + return ResolveExtended(rawContent, slideStartTimeSeconds, slideEndTimeSeconds); + + var parsed = new List(); + var start = rawContent[0] - '0'; + var cursor = 1; + while (cursor < rawContent.Length) + { + var segmentStart = cursor; + string shape; + if (cursor + 1 < rawContent.Length && + (rawContent.AsSpan(cursor, 2).SequenceEqual("pp".AsSpan()) || + rawContent.AsSpan(cursor, 2).SequenceEqual("qq".AsSpan()))) + { + shape = rawContent.Substring(cursor, 2); + cursor += 2; + } + else if (SingleCharacterShapes.Contains(rawContent[cursor])) + { + shape = rawContent[cursor++].ToString(); + } + else + { + throw new MajSimaiAdaptationException( + $"Unknown Slide path in MajSimai RawContent at offset {cursor}: {rawContent}"); + } + + var positionCount = shape == "V" ? 2 : 1; + if (cursor + positionCount > rawContent.Length) + throw new MajSimaiAdaptationException($"Incomplete Slide endpoint: {rawContent}"); + var via = shape == "V" ? ParsePosition(rawContent[cursor++], rawContent) : (int?)null; + var end = ParsePosition(rawContent[cursor++], rawContent); + + while (cursor < rawContent.Length && rawContent[cursor] is 'b' or 'm' or 'c') cursor++; + if (cursor < rawContent.Length && rawContent[cursor] == '[') + { + var close = rawContent.IndexOf(']', cursor + 1); + if (close < 0) throw new MajSimaiAdaptationException($"Unclosed Slide duration: {rawContent}"); + cursor = close + 1; + } + + var rawSegment = rawContent.Substring(segmentStart, cursor - segmentStart); + var barCount = StandardSlideBarCounts.Resolve(shape, start, via, end); + if (barCount <= 0) + throw new MajSimaiAdaptationException($"Non-positive bar count for {start}{shape}{via}{end}."); + parsed.Add(new ParsedSegment( + shape, start, via, end, barCount, + rawSegment)); + start = end; + } + + if (parsed.Count == 0) throw new MajSimaiAdaptationException($"Slide has no path: {rawContent}"); + var totalBars = parsed.Sum(segment => segment.BarCount); + var duration = slideEndTimeSeconds - slideStartTimeSeconds; + var elapsedBars = 0; + var output = new List(parsed.Count); + for (var index = 0; index < parsed.Count; index++) + { + var segment = parsed[index]; + var segmentStartTime = slideStartTimeSeconds + duration * elapsedBars / totalBars; + elapsedBars += segment.BarCount; + var segmentEndTime = index == parsed.Count - 1 + ? slideEndTimeSeconds + : slideStartTimeSeconds + duration * elapsedBars / totalBars; + output.Add(new SlidePathSegment + { + Shape = segment.Shape, + StartPosition = segment.Start, + ViaPosition = segment.Via, + EndPosition = segment.End, + BarCount = segment.BarCount, + StartTimeSeconds = segmentStartTime, + EndTimeSeconds = segmentEndTime, + RawSegment = segment.Raw + }); + } + return output; + } + + private IReadOnlyList ResolveExtended( + string rawContent, + double slideStartTimeSeconds, + double slideEndTimeSeconds) + { + if (_extendedSlides is null) + throw new MajSimaiAdaptationException( + "Extended K Slides require an IExtendedSlideBarCountProvider."); + var marker = rawContent.IndexOf('K'); + if (marker < 1 || marker + 1 >= rawContent.Length) + throw new MajSimaiAdaptationException($"Incomplete extended Slide endpoint: {rawContent}"); + var start = ParsePosition(rawContent[0], rawContent); + var end = ParsePosition(rawContent[marker + 1], rawContent); + var slideCode = rawContent.Substring(0, marker + 2); + var barCount = _extendedSlides.ResolveBarCount(slideCode); + if (barCount <= 0) + throw new MajSimaiAdaptationException( + $"Non-positive arrow count for extended Slide: {rawContent}"); + return new[] + { + new SlidePathSegment + { + Shape = "slidecode", + StartPosition = start, + EndPosition = end, + BarCount = barCount, + StartTimeSeconds = slideStartTimeSeconds, + EndTimeSeconds = slideEndTimeSeconds, + RawSegment = rawContent + } + }; + } + + private static int ParsePosition(char value, string rawContent) + { + if (value is < '1' or > '8') + throw new MajSimaiAdaptationException($"Invalid Slide endpoint in: {rawContent}"); + return value - '0'; + } + + private sealed class ParsedSegment + { + public ParsedSegment(string shape, int start, int? via, int end, int barCount, string raw) + { + Shape = shape; + Start = start; + Via = via; + End = end; + BarCount = barCount; + Raw = raw; + } + + public string Shape { get; } + public int Start { get; } + public int? Via { get; } + public int End { get; } + public int BarCount { get; } + public string Raw { get; } + } +} + +internal sealed class MajSimaiAdaptationException : Exception +{ + public MajSimaiAdaptationException(string message) : base(message) { } +} diff --git a/Runtime/MajSimaiAdapter/StandardSlideBarCounts.cs b/Runtime/MajSimaiAdapter/StandardSlideBarCounts.cs new file mode 100644 index 0000000..75026c8 --- /dev/null +++ b/Runtime/MajSimaiAdapter/StandardSlideBarCounts.cs @@ -0,0 +1,94 @@ +#nullable enable + +namespace MajRadar.MajSimaiAdapter; + +/// +/// Standard Majdata Slide prefab lengths. These are gameplay length units +/// used to divide connected-Slide time, not physical distance or note counts. +/// +internal static class StandardSlideBarCounts +{ + internal static int Resolve( + string shape, + int startPosition, + int? viaPosition, + int endPosition) + { + var relativeEnd = PositiveModulo(endPosition - startPosition, 8) + 1; + var prefab = shape switch + { + "-" => $"line{relativeEnd}", + ">" => $"circle{(startPosition is 7 or 8 or 1 or 2 ? relativeEnd : Mirror(relativeEnd))}", + "<" => $"circle{(startPosition is 3 or 4 or 5 or 6 ? relativeEnd : Mirror(relativeEnd))}", + "^" => $"circle{(relativeEnd < 5 ? relativeEnd : Mirror(relativeEnd))}", + "v" => $"v{relativeEnd}", + "p" => $"pq{relativeEnd}", + "q" => $"pq{Mirror(relativeEnd)}", + "pp" => $"ppqq{relativeEnd}", + "qq" => $"ppqq{Mirror(relativeEnd)}", + "s" or "z" => "s", + "w" => "wifi", + "V" => ResolveLargeV(startPosition, viaPosition, relativeEnd), + _ => throw new MajSimaiAdaptationException($"Unsupported Slide shape: {shape}") + }; + return prefab switch + { + "line3" => 14, + "line4" => 19, + "line5" => 20, + "line6" => 19, + "line7" => 14, + "circle1" => 64, + "circle2" => 8, + "circle3" => 16, + "circle4" => 24, + "circle5" => 32, + "circle6" => 40, + "circle7" => 48, + "circle8" => 56, + // Keep the established analysis reference: the dedicated Star_V_1 + // prefab has 21 children and describes the intended 1v1 path. + "v1" => 21, + "v2" or "v3" or "v4" or "v6" or "v7" or "v8" => 20, + "ppqq1" => 36, + "ppqq2" => 29, + "ppqq3" => 23, + "ppqq4" or "ppqq5" => 50, + "ppqq6" => 49, + "ppqq7" => 47, + "ppqq8" => 42, + "pq1" => 34, + "pq2" => 31, + "pq3" => 28, + "pq4" => 25, + "pq5" => 22, + "pq6" => 43, + "pq7" => 41, + "pq8" => 37, + "s" => 31, + "wifi" => 12, + "L2" => 33, + "L3" => 35, + "L4" => 33, + "L5" => 29, + _ => throw new MajSimaiAdaptationException( + $"No standard bar count for {startPosition}{shape}{viaPosition}{endPosition} ({prefab}).") + }; + } + + private static string ResolveLargeV(int start, int? via, int relativeEnd) + { + if (via is null) + throw new MajSimaiAdaptationException("A V Slide requires a via position."); + var turn = PositiveModulo(via.Value - start, 8); + return turn switch + { + 6 => $"L{relativeEnd}", + 2 => $"L{Mirror(relativeEnd)}", + _ => throw new MajSimaiAdaptationException("A V Slide has an invalid via position.") + }; + } + + private static int Mirror(int position) => position == 1 ? 1 : 10 - position; + private static int PositiveModulo(int value, int modulus) => (value % modulus + modulus) % modulus; +} From 936f3d7fa40dbf0ba54d05f933eec9b399686c55 Mon Sep 17 00:00:00 2001 From: SniperPigeon Date: Wed, 23 Sep 2026 16:56:45 +0800 Subject: [PATCH 02/10] feat: port seven-dimension radar analysis --- Runtime/Analysis/AnalysisResult.cs | 99 +++ .../Analysis/Features/JackSequenceAnalyzer.cs | 141 ++++ .../Analysis/Features/NoteDensityAnalyzer.cs | 31 + .../Analysis/Features/PeakDensityAnalyzer.cs | 156 ++++ .../Features/SlideCumulateAnalyzer.cs | 221 ++++++ .../Features/SlideSequenceAnalyzer.cs | 200 +++++ .../Analysis/Features/SlideTrickyAnalyzer.cs | 408 ++++++++++ .../Analysis/Features/SweepBurstAnalyzer.cs | 393 ++++++++++ .../Analysis/Features/SweepFamilyBuilder.cs | 144 ++++ Runtime/Analysis/Features/SweepHandMotion.cs | 335 +++++++++ Runtime/Analysis/Features/SweepModel.cs | 107 +++ Runtime/Analysis/Features/SweepRecognizer.cs | 707 ++++++++++++++++++ Runtime/Analysis/Features/Workload.cs | 225 ++++++ Runtime/Analysis/RadarAnalyzer.cs | 70 ++ 14 files changed, 3237 insertions(+) create mode 100644 Runtime/Analysis/AnalysisResult.cs create mode 100644 Runtime/Analysis/Features/JackSequenceAnalyzer.cs create mode 100644 Runtime/Analysis/Features/NoteDensityAnalyzer.cs create mode 100644 Runtime/Analysis/Features/PeakDensityAnalyzer.cs create mode 100644 Runtime/Analysis/Features/SlideCumulateAnalyzer.cs create mode 100644 Runtime/Analysis/Features/SlideSequenceAnalyzer.cs create mode 100644 Runtime/Analysis/Features/SlideTrickyAnalyzer.cs create mode 100644 Runtime/Analysis/Features/SweepBurstAnalyzer.cs create mode 100644 Runtime/Analysis/Features/SweepFamilyBuilder.cs create mode 100644 Runtime/Analysis/Features/SweepHandMotion.cs create mode 100644 Runtime/Analysis/Features/SweepModel.cs create mode 100644 Runtime/Analysis/Features/SweepRecognizer.cs create mode 100644 Runtime/Analysis/Features/Workload.cs create mode 100644 Runtime/Analysis/RadarAnalyzer.cs diff --git a/Runtime/Analysis/AnalysisResult.cs b/Runtime/Analysis/AnalysisResult.cs new file mode 100644 index 0000000..2bec917 --- /dev/null +++ b/Runtime/Analysis/AnalysisResult.cs @@ -0,0 +1,99 @@ +using MajRadar.Core; +using System.Threading; + +namespace MajRadar.Analysis; + +public static class RadarFeatureNames +{ + public const string Note = "note"; + public const string Peak = "peak"; + public const string Sweep = "sweep"; + public const string SlideTricky = "slide_tricky"; + public const string SlideSequence = "slide_sequence"; + public const string Jack = "jack"; + public const string SlideCumulate = "slide_cumulate"; + public const string FittedConstant = "fitted_constant"; + + // Fixed regression input columns. These indexes also align Center, Scale and + // the first seven linear coefficients in RegressionBetaParameters: + // 0 Note, 1 Peak, 2 Sweep, 3 SlideTricky, + // 4 SlideSequence, 5 Jack, 6 SlideCumulate. + // This is not a UI-axis setting. Changing a name or its position requires + // retraining the model and replacing all fitted constants together. + public static readonly IReadOnlyList ModelInputOrder = Array.AsReadOnly(new[] + { + Note, Peak, Sweep, SlideTricky, SlideSequence, Jack, SlideCumulate + }); + + // Stable enumeration order for callers that expose every scored scalar. + // A UI may display a subset, but model input always remains ModelInputOrder. + public static readonly IReadOnlyList ScoredOutputOrder = Array.AsReadOnly(new[] + { + Note, Peak, Sweep, SlideTricky, SlideSequence, Jack, SlideCumulate, + FittedConstant + }); +} + +public sealed class RadarFeatureResult +{ + public double? Value { get; set; } + public bool IsSuccess { get; set; } + public string? Error { get; set; } + + public static RadarFeatureResult Success(double value) => new() + { + Value = value, + IsSuccess = true + }; + + public static RadarFeatureResult Failure(string error) => new() + { + IsSuccess = false, + Error = error + }; +} + +public sealed class RadarAnalysisResult +{ + public IReadOnlyDictionary Features { get; set; } = + new Dictionary(); + + public bool IsCancelled { get; set; } + + public bool IsSuccess => Features.Count == RadarFeatureNames.ModelInputOrder.Count && + Features.Values.All(result => result.IsSuccess) && !IsCancelled; + + public string Status + { + get + { + if (IsCancelled) return "cancelled"; + var successful = Features.Values.Count(result => result.IsSuccess); + if (successful == Features.Count && Features.Count > 0) return "ok"; + return successful > 0 ? "partial" : "error"; + } + } +} + +internal readonly struct AnalysisContext +{ + internal AnalysisContext(RadarChartInput chart, CancellationToken cancellationToken) + { + Events = chart.Events; + ChartEndTimeSeconds = chart.ChartEndTimeSeconds; + LastEventEndTimeSeconds = chart.LastEventEndTimeSeconds; + CancellationToken = cancellationToken; + } + + internal IReadOnlyList Events { get; } + internal double ChartEndTimeSeconds { get; } + internal double? LastEventEndTimeSeconds { get; } + internal CancellationToken CancellationToken { get; } + internal double DurationSeconds => Math.Max(ChartEndTimeSeconds, LastEventEndTimeSeconds ?? 0); + internal void ThrowIfCancellationRequested() => CancellationToken.ThrowIfCancellationRequested(); +} + +internal interface IRadarFeatureAnalyzer +{ + RadarFeatureResult Analyze(AnalysisContext context); +} diff --git a/Runtime/Analysis/Features/JackSequenceAnalyzer.cs b/Runtime/Analysis/Features/JackSequenceAnalyzer.cs new file mode 100644 index 0000000..b53ef00 --- /dev/null +++ b/Runtime/Analysis/Features/JackSequenceAnalyzer.cs @@ -0,0 +1,141 @@ +using MajRadar.Core; + +namespace MajRadar.Analysis.Features; + +internal sealed class JackSequenceAnalyzer : IRadarFeatureAnalyzer +{ + private const int TopK = 5; + private const int MaxInterruptingTaps = 4; + private const double InterruptingTapWeight = 1.5; + private const double ExNoteWeight = 0.3; + private const double SpeedReferenceSixteenthBpm = 180; + private const double SpeedExponent = 1.5; + private const double TopOneMultiplier = 1.3; + private const double TopRankDecay = 0.645; + + private sealed class Batch + { + internal BeatPosition Beat { get; set; } + internal double TimeSeconds { get; set; } + internal IReadOnlyList Events { get; set; } = Array.Empty(); + } + + private sealed class Sequence + { + internal string Position { get; set; } = string.Empty; + internal BeatPosition StartBeat { get; set; } + internal int AnchorCount { get; set; } + internal int ExAnchorCount { get; set; } + internal int Interruptions { get; set; } + internal int ExInterruptions { get; set; } + internal double SpeedFactor { get; set; } + internal double Strength => AnchorCount - ExAnchorCount + ExNoteWeight * ExAnchorCount + + InterruptingTapWeight * (Interruptions - ExInterruptions + ExNoteWeight * ExInterruptions); + internal double WeightedStrength => Strength * SpeedFactor; + } + + public RadarFeatureResult Analyze(AnalysisContext context) + { + context.ThrowIfCancellationRequested(); + if (context.DurationSeconds <= 0) return RadarFeatureResult.Failure("Chart duration must be positive."); + var sequences = Sequences(context.Events); + var score = sequences.Take(TopK).Select((item, index) => + item.WeightedStrength * TopOneMultiplier * Math.Pow(TopRankDecay, index)).Sum(); + return RadarFeatureResult.Success(score); + } + + private static IReadOnlyList Sequences(IReadOnlyList events) + { + var batches = events + .Where(item => item.Kind is RadarEventKind.Tap or RadarEventKind.Hold) + .GroupBy(item => item.StartBeat) + .OrderBy(group => group.Key) + .Select(group => new Batch + { + Beat = group.Key, + TimeSeconds = group.Min(item => item.StartTimeSeconds), + Events = group.OrderBy(item => item.EventId).ToArray() + }).ToArray(); + var output = new List(); + for (var position = 1; position <= 8; position++) + output.AddRange(PositionSequences(batches, position.ToString())); + return output.OrderByDescending(item => item.WeightedStrength) + .ThenByDescending(item => item.Strength) + .ThenByDescending(item => item.AnchorCount) + .ThenBy(item => item.StartBeat) + .ThenBy(item => int.Parse(item.Position)).ToArray(); + } + + private static IReadOnlyList PositionSequences(IReadOnlyList batches, string position) + { + var output = new List(); + BeatPosition? startBeat = null, previousBeat = null, pendingStartBeat = null; + double? startTime = null, endTime = null; + var anchorCount = 0; var exAnchorCount = 0; var anchorTimeCount = 0; + var anchorsSinceInterruption = 0; var pending = 0; var pendingEx = 0; + var committed = 0; var committedEx = 0; + + void Finish() + { + if (anchorTimeCount >= 2) + { + var span = endTime!.Value - startTime!.Value; + if (span <= 0) throw new InvalidOperationException("Jack sequence must advance in time."); + var bpm = 15 * (anchorTimeCount - 1) / span; + output.Add(new Sequence + { + Position = position, + StartBeat = startBeat!.Value, + AnchorCount = anchorCount, + ExAnchorCount = exAnchorCount, + Interruptions = committed, + ExInterruptions = committedEx, + SpeedFactor = Math.Pow(bpm / SpeedReferenceSixteenthBpm, SpeedExponent) + }); + } + startBeat = previousBeat = pendingStartBeat = null; + startTime = endTime = null; + anchorCount = exAnchorCount = anchorTimeCount = anchorsSinceInterruption = 0; + pending = pendingEx = committed = committedEx = 0; + } + + foreach (var batch in batches) + { + var anchors = batch.Events.Where(item => item.Position == position).ToArray(); + if (previousBeat is not null && batch.Beat - previousBeat.Value > Workload.EighthBeat) Finish(); + if (anchors.Length > 0) + { + var returned = pendingStartBeat is not null; + if (returned && batch.Beat - pendingStartBeat!.Value > Workload.EighthBeat) + { + Finish(); returned = false; + } + else if (returned) + { + committed += pending; committedEx += pendingEx; + } + if (startBeat is null) { startBeat = batch.Beat; startTime = batch.TimeSeconds; } + endTime = batch.TimeSeconds; + anchorCount += anchors.Length; + exAnchorCount += anchors.Count(item => item.IsEx == true); + anchorTimeCount++; + anchorsSinceInterruption = returned ? 1 : anchorsSinceInterruption + 1; + pending = pendingEx = 0; pendingStartBeat = null; previousBeat = batch.Beat; + continue; + } + if (startBeat is null) continue; + if (batch.Events.Any(item => item.Kind != RadarEventKind.Tap) || + pendingStartBeat is null && anchorsSinceInterruption < 2 || + pending + batch.Events.Count > MaxInterruptingTaps) + { + Finish(); continue; + } + pendingStartBeat ??= batch.Beat; + pending += batch.Events.Count; + pendingEx += batch.Events.Count(item => item.IsEx == true); + previousBeat = batch.Beat; + } + Finish(); + return output; + } +} diff --git a/Runtime/Analysis/Features/NoteDensityAnalyzer.cs b/Runtime/Analysis/Features/NoteDensityAnalyzer.cs new file mode 100644 index 0000000..0ea82e8 --- /dev/null +++ b/Runtime/Analysis/Features/NoteDensityAnalyzer.cs @@ -0,0 +1,31 @@ +namespace MajRadar.Analysis.Features; + +internal sealed class NoteDensityAnalyzer : IRadarFeatureAnalyzer +{ + private const double BurstWeight = 0.3; + + public RadarFeatureResult Analyze(AnalysisContext context) + { + context.ThrowIfCancellationRequested(); + var duration = context.DurationSeconds; + if (duration <= 0) return RadarFeatureResult.Failure("Chart duration must be positive."); + var ratio = duration / Workload.WindowSeconds; + var nearest = Math.Round(ratio); + if (Math.Abs(ratio - nearest) <= 1e-12) ratio = nearest; + var count = Math.Max(1, (int)Math.Ceiling(ratio)); + var workload = new double[count]; + foreach (var point in Workload.CorrectedPoints(context.Events)) + { + context.ThrowIfCancellationRequested(); + var index = Math.Min( + (int)Math.Floor((point.TimeSeconds + 1e-9) / Workload.WindowSeconds), + count - 1); + workload[index] += point.Weight; + } + var densities = workload.Select(value => value / Workload.WindowSeconds).ToArray(); + var mean = densities.Sum() / count; + if (mean == 0) return RadarFeatureResult.Success(0); + var variance = densities.Sum(value => (value - mean) * (value - mean)) / count; + return RadarFeatureResult.Success(mean * (1 + BurstWeight * Math.Sqrt(variance) / mean)); + } +} diff --git a/Runtime/Analysis/Features/PeakDensityAnalyzer.cs b/Runtime/Analysis/Features/PeakDensityAnalyzer.cs new file mode 100644 index 0000000..4d31cf1 --- /dev/null +++ b/Runtime/Analysis/Features/PeakDensityAnalyzer.cs @@ -0,0 +1,156 @@ +using MajRadar.Core; + +namespace MajRadar.Analysis.Features; + +internal sealed class PeakDensityAnalyzer : IRadarFeatureAnalyzer +{ + private const double StepSeconds = 0.5; + private const double LeftWeight = 0.2; + private const double CenterWeight = 0.6; + private const double RightWeight = 0.2; + private const double TouchGroupWeight = 0.5; + private const int PeakCount = 3; + private static readonly double[] PeakWeights = { 0.5, 0.3, 0.2 }; + private const double NeighborhoodSeconds = 3 * Workload.WindowSeconds; + + private sealed class ButtonOnset + { + internal double TimeSeconds { get; set; } + internal BeatPosition Beat { get; set; } + internal int Position { get; set; } + internal double Weight { get; set; } + } + + public RadarFeatureResult Analyze(AnalysisContext context) + { + context.ThrowIfCancellationRequested(); + if (context.DurationSeconds <= 0) return RadarFeatureResult.Failure("Chart duration must be positive."); + return RadarFeatureResult.Success(PeakDensity(Points(context.Events), context.DurationSeconds)); + } + + private static IReadOnlyList Points(IReadOnlyList events) + { + var buttons = new List(); + var slides = new List(); + var touches = new List(); + foreach (var item in events) + { + if (item.Kind is RadarEventKind.Tap or RadarEventKind.Hold) + { + var weight = item.Kind == RadarEventKind.Hold && + Workload.BeatDuration(item) > Workload.EighthBeat ? 2 : 1; + buttons.Add(new ButtonOnset + { + TimeSeconds = item.StartTimeSeconds, + Beat = item.StartBeat, + Position = int.Parse(item.Position!), + Weight = weight + }); + } + else if (item.Kind == RadarEventKind.Slide) slides.Add(item); + else if (item.Kind is RadarEventKind.Touch or RadarEventKind.TouchHold) touches.Add(item); + } + var output = DecaySweepButtons(buttons).ToList(); + output.AddRange(Workload.SlidePoints(slides, null)); + output.AddRange(Workload.TouchPoints(touches, TouchGroupWeight)); + return output; + } + + private static IReadOnlyList DecaySweepButtons(IReadOnlyList buttons) + { + var byBeat = buttons.GroupBy(item => item.Beat).OrderBy(group => group.Key); + var output = new List(); + var runLength = 0; + ButtonOnset? previous = null; + BeatPosition? interval = null; + int? direction = null; + foreach (var group in byBeat) + { + var simultaneous = group.ToArray(); + if (simultaneous.Length != 1) + { + output.AddRange(simultaneous.Select(item => new Workload.Point(item.TimeSeconds, item.Weight))); + runLength = 0; previous = null; interval = null; direction = null; + continue; + } + var current = simultaneous[0]; + var currentDirection = previous is null ? null : Direction(previous.Position, current.Position); + BeatPosition? currentInterval = previous is null ? null : current.Beat - previous.Beat; + var continues = previous is not null && currentDirection is not null && + currentInterval > BeatPosition.Zero && + (runLength == 1 || currentDirection == direction && currentInterval == interval); + if (continues) + { + runLength++; + if (runLength == 2) { direction = currentDirection; interval = currentInterval; } + } + else if (previous is not null && currentDirection is not null && currentInterval > BeatPosition.Zero) + { + runLength = 2; direction = currentDirection; interval = currentInterval; + } + else + { + runLength = 1; direction = null; interval = null; + } + var divisor = Math.Log(Math.Max(2, runLength - 2), 2); + output.Add(new Workload.Point(current.TimeSeconds, current.Weight / divisor)); + previous = current; + } + return output; + } + + private static int? Direction(int left, int right) + { + if (right == left % 8 + 1) return 1; + if (right == (left - 2 + 8) % 8 + 1) return -1; + return null; + } + + private static double PeakDensity(IReadOnlyList source, double duration) + { + if (source.Count == 0) return 0; + var points = source.OrderBy(item => item.TimeSeconds).ThenBy(item => item.Weight).ToArray(); + var times = points.Select(item => item.TimeSeconds).ToArray(); + var prefix = new double[points.Length + 1]; + for (var index = 0; index < points.Length; index++) prefix[index + 1] = prefix[index] + points[index].Weight; + double Density(double start) + { + var begin = LowerBound(times, start - 1e-9); + var end = LowerBound(times, start + Workload.WindowSeconds - 1e-9); + return (prefix[end] - prefix[begin]) / Workload.WindowSeconds; + } + var ratio = duration / StepSeconds; + var nearest = Math.Round(ratio); + if (Math.Abs(ratio - nearest) <= 1e-12) ratio = nearest; + var candidates = Enumerable.Range(0, (int)Math.Floor(ratio) + 1) + .Select(index => + { + var start = index * StepSeconds; + var score = LeftWeight * Density(start - Workload.WindowSeconds) + + CenterWeight * Density(start) + + RightWeight * Density(start + Workload.WindowSeconds); + return (Score: score, Start: start); + }) + .OrderByDescending(item => item.Score).ThenBy(item => item.Start); + var selected = new List<(double Score, double Start)>(); + foreach (var candidate in candidates) + { + if (selected.All(item => Math.Abs(candidate.Start - item.Start) >= NeighborhoodSeconds)) + selected.Add(candidate); + if (selected.Count == PeakCount) break; + } + return selected.Select((item, index) => item.Score * PeakWeights[index]).Sum(); + } + + private static int LowerBound(double[] values, double target) + { + var left = 0; var right = values.Length; + while (left < right) + { + var middle = left + (right - left) / 2; + if (values[middle] < target) left = middle + 1; + else right = middle; + } + return left; + } +} diff --git a/Runtime/Analysis/Features/SlideCumulateAnalyzer.cs b/Runtime/Analysis/Features/SlideCumulateAnalyzer.cs new file mode 100644 index 0000000..35e9bdb --- /dev/null +++ b/Runtime/Analysis/Features/SlideCumulateAnalyzer.cs @@ -0,0 +1,221 @@ +using MajRadar.Core; + +namespace MajRadar.Analysis.Features; + +internal sealed class SlideCumulateAnalyzer : IRadarFeatureAnalyzer +{ + private const double Alpha = 0.35; + private const double ReferenceSeconds = 0.5; + private const double SimultaneousSeconds = 1.0 / 60; + private const int EffectiveLengthBase = 5; + private const double TouchWeight = 1.5; + private const double Tolerance = 1e-9; + + private sealed class SlideGroup + { + internal int Key { get; set; } + internal IReadOnlyList Events { get; set; } = Array.Empty(); + internal double DeclarationTime { get; set; } + internal BeatPosition DeclarationBeat { get; set; } + internal double LaunchTime { get; set; } + internal int? HeadEventId { get; set; } + } + + private sealed class Onset + { + internal double DeclarationTime { get; set; } + internal BeatPosition DeclarationBeat { get; set; } + internal IReadOnlyList Groups { get; set; } = Array.Empty(); + } + + private sealed class Point + { + internal string Id { get; set; } = string.Empty; + internal double Time { get; set; } + internal double Weight { get; set; } + internal int? OwnerGroup { get; set; } + } + + public RadarFeatureResult Analyze(AnalysisContext context) + { + context.ThrowIfCancellationRequested(); + var duration = context.DurationSeconds; + if (duration <= 0) return RadarFeatureResult.Failure("Chart duration must be positive."); + var groups = Groups(context.Events); + if (groups.Count == 0) return RadarFeatureResult.Success(0); + var onsets = Onsets(groups); + var assigned = Assign(onsets, Points(context.Events, groups)); + var totalLoad = 0.0; + foreach (var section in Sections(onsets)) + { + context.ThrowIfCancellationRequested(); + var loads = section.Select(onset => OnsetLoad(onset, assigned[onsets.IndexOf(onset)])).ToArray(); + var mean = loads.Sum() / loads.Length; + var rms = Math.Sqrt(loads.Sum(load => load * load) / loads.Length); + var intensity = 0.8 * mean + 0.2 * rms; + totalLoad += EffectiveLength(loads.Length) * intensity; + } + return RadarFeatureResult.Success(totalLoad / (duration / ReferenceSeconds)); + } + + private static List Groups(IReadOnlyList events) + { + var output = new List(); + foreach (var grouping in events.Where(item => + item.Kind == RadarEventKind.Slide && item.SlideGroupId is not null) + .GroupBy(item => item.SlideGroupId!.Value)) + { + var paths = grouping.ToArray(); + var declarationTimes = paths.Select(item => item.SlideDeclareTimeSeconds).Distinct().ToArray(); + var declarationBeats = paths.Select(item => item.SlideDeclareBeat).Distinct().ToArray(); + var headIds = paths.Select(item => item.HeadEventId).Distinct().ToArray(); + if (declarationTimes.Length != 1 || declarationTimes[0] is null || + declarationBeats.Length != 1 || declarationBeats[0] is null || headIds.Length != 1) + throw new InvalidOperationException("Slide group declaration metadata is inconsistent."); + var valid = paths.Where(item => item.EndTimeSeconds > item.StartTimeSeconds).ToArray(); + if (valid.Length == 0) continue; + output.Add(new SlideGroup + { + Key = grouping.Key, + Events = valid, + DeclarationTime = declarationTimes[0]!.Value, + DeclarationBeat = declarationBeats[0]!.Value, + LaunchTime = valid.Max(item => item.StartTimeSeconds), + HeadEventId = headIds[0] + }); + } + return output.OrderBy(item => item.DeclarationBeat) + .ThenBy(item => item.DeclarationTime).ThenBy(item => item.Key).ToList(); + } + + private static List Onsets(IReadOnlyList groups) + { + var buckets = new List>(); + foreach (var group in groups) + { + if (buckets.Count == 0 || + group.DeclarationTime - buckets[^1][0].DeclarationTime > SimultaneousSeconds + Tolerance) + buckets.Add(new List()); + buckets[^1].Add(group); + } + return buckets.Select(bucket => new Onset + { + DeclarationTime = bucket[0].DeclarationTime, + DeclarationBeat = bucket[0].DeclarationBeat, + Groups = bucket + }).ToList(); + } + + private static IReadOnlyList> Sections(IReadOnlyList onsets) + { + var output = new List>(); + var current = new List { onsets[0] }; + foreach (var onset in onsets.Skip(1)) + { + var delta = (onset.DeclarationBeat - current[^1].DeclarationBeat).ToDouble(); + if (delta > Tolerance && delta <= 1 + Tolerance) current.Add(onset); + else { output.Add(current); current = new List { onset }; } + } + output.Add(current); + return output; + } + + private static List Points(IReadOnlyList events, IReadOnlyList groups) + { + var headOwners = groups.Where(item => item.HeadEventId is not null) + .ToDictionary(item => item.HeadEventId!.Value, item => item.Key); + var output = events.Where(item => item.Kind is RadarEventKind.Tap or RadarEventKind.Hold) + .Select(item => new Point + { + Id = $"event:{item.EventId}", Time = item.StartTimeSeconds, Weight = 1, + OwnerGroup = headOwners.TryGetValue(item.EventId, out var owner) ? owner : null + }).ToList(); + var touches = events.Where(item => item.Kind is RadarEventKind.Touch or RadarEventKind.TouchHold).ToArray(); + var touchIndex = 0; + output.AddRange(Workload.SimultaneousTouchPoints(touches, TouchWeight).Select(item => new Point + { + Id = $"touch:{touchIndex++}", Time = item.TimeSeconds, Weight = item.Weight + })); + foreach (var group in groups) + { + var index = 0; + foreach (var launch in group.Events.Select(item => item.StartTimeSeconds).Distinct().OrderBy(value => value)) + output.Add(new Point + { + Id = $"slide:{group.Key}:{index++}", Time = launch, Weight = 1, OwnerGroup = group.Key + }); + } + return output.OrderBy(item => item.Time).ThenBy(item => item.Id, StringComparer.Ordinal).ToList(); + } + + private static List> Assign(IReadOnlyList onsets, IReadOnlyList points) + { + var memberKeys = onsets.SelectMany(item => item.Groups).Select(item => item.Key).ToHashSet(); + var assigned = Enumerable.Range(0, onsets.Count).Select(_ => new List()).ToList(); + foreach (var point in points) + { + if (point.OwnerGroup is int owner && memberKeys.Contains(owner)) continue; + var candidates = new List<(double Distance, int Index)>(); + for (var index = 0; index < onsets.Count; index++) + { + var pending = onsets[index].Groups.Where(group => + point.Time >= group.DeclarationTime - Tolerance && + point.Time <= group.LaunchTime + Tolerance).ToArray(); + if (pending.Length > 0) + candidates.Add((pending.Min(group => Math.Max(0, group.LaunchTime - point.Time)), index)); + } + if (candidates.Count > 0) + { + var selected = candidates.OrderBy(item => item.Distance).ThenBy(item => item.Index).First(); + assigned[selected.Index].Add(point); + } + } + return assigned; + } + + private static double OnsetLoad(Onset onset, IReadOnlyList points) + { + var launchTimes = onset.Groups.Select(item => item.LaunchTime).ToArray(); + var launchPoints = points.Where(point => launchTimes.Any(time => SameTime(point.Time, time))).ToArray(); + var launchIds = launchPoints.Select(item => item.Id).ToHashSet(); + var internalPoints = points.Where(item => !launchIds.Contains(item.Id)).ToArray(); + var internalLoad = 0.0; + var previousCount = 0; + foreach (var batch in Batches(internalPoints)) + { + var nextCount = previousCount + batch.Count; + var marginal = (InternalTotal(nextCount) - InternalTotal(previousCount)) / batch.Count; + internalLoad += marginal * batch.Sum(item => item.Weight); + previousCount = nextCount; + } + return internalLoad + launchPoints.Sum(item => item.Weight); + } + + private static IReadOnlyList> Batches(IReadOnlyList points) + { + var output = new List>(); + foreach (var point in points) + { + if (output.Count == 0 || !SameTime(output[^1][0].Time, point.Time)) + output.Add(new List()); + ((List)output[^1]).Add(point); + } + return output; + } + + private static double InternalTotal(int count) + { + var logFactorialBase2 = 0.0; + for (var value = 2; value <= count; value++) logFactorialBase2 += Math.Log(value, 2); + return count + Alpha * logFactorialBase2; + } + + private static double EffectiveLength(int count) + { + if (count <= EffectiveLengthBase) return count; + var scaled = 1 + (double)(count - EffectiveLengthBase) / EffectiveLengthBase; + return EffectiveLengthBase * (1 + Math.Log(scaled, EffectiveLengthBase)); + } + + private static bool SameTime(double left, double right) => Math.Abs(left - right) <= Tolerance; +} diff --git a/Runtime/Analysis/Features/SlideSequenceAnalyzer.cs b/Runtime/Analysis/Features/SlideSequenceAnalyzer.cs new file mode 100644 index 0000000..fd9995e --- /dev/null +++ b/Runtime/Analysis/Features/SlideSequenceAnalyzer.cs @@ -0,0 +1,200 @@ +using MajRadar.Core; + +namespace MajRadar.Analysis.Features; + +internal sealed class SlideSequenceAnalyzer : IRadarFeatureAnalyzer +{ + private const double CadenceReferenceSeconds = 0.5; + private const double SimultaneousSeconds = 1.0 / 60; + private static readonly BeatPosition MinimumInterval = new(1, 2); + private const int LengthBase = 4; + private const int LengthKnee = 16; + private const double LengthKneeFactor = 3; + private const int TopCount = 5; + private const double ConcurrencyWeight = 0.5; + private const double Tolerance = 1e-9; + private static readonly double[] TopWeights = Enumerable.Range(1, TopCount) + .Select(rank => 1 / Math.Log(rank + 1, 2)).ToArray(); + + private sealed class Group + { + internal BeatPosition DeclarationBeat { get; set; } + internal double DeclarationTime { get; set; } + internal int PathCount { get; set; } + internal int Key { get; set; } + } + + private sealed class Cluster + { + internal BeatPosition DeclarationBeat { get; set; } + internal double DeclarationTime { get; set; } + internal IReadOnlyList Groups { get; set; } = Array.Empty(); + } + + private sealed class PathState + { + internal IReadOnlyList Indexes { get; set; } = Array.Empty(); + internal int DirectLinks { get; set; } + internal double CadenceSum { get; set; } + internal double ConcurrencySum { get; set; } + } + + public RadarFeatureResult Analyze(AnalysisContext context) + { + context.ThrowIfCancellationRequested(); + if (context.DurationSeconds <= 0) return RadarFeatureResult.Failure("Chart duration must be positive."); + var groups = Groups(context.Events); + if (groups.Count == 0) return RadarFeatureResult.Success(0); + var intensities = Sections(Clusters(groups)).Select(SectionIntensity) + .Where(value => value > 0).OrderByDescending(value => value).Take(TopCount).ToArray(); + var weighted = intensities.Select((value, index) => value * TopWeights[index]).Sum(); + return RadarFeatureResult.Success(weighted / TopWeights.Sum()); + } + + private static List Groups(IReadOnlyList events) + { + var output = new List(); + foreach (var grouping in events.Where(item => + item.Kind == RadarEventKind.Slide && item.SlideGroupId is not null) + .GroupBy(item => item.SlideGroupId!.Value)) + { + var valid = grouping.Where(item => item.EndTimeSeconds > item.StartTimeSeconds).ToArray(); + if (valid.Length == 0) continue; + var times = valid.Select(item => item.SlideDeclareTimeSeconds).Distinct().ToArray(); + var beats = valid.Select(item => item.SlideDeclareBeat).Distinct().ToArray(); + if (times.Length != 1 || times[0] is null || beats.Length != 1 || beats[0] is null) + throw new InvalidOperationException("Slide group declaration metadata is inconsistent."); + output.Add(new Group + { + Key = grouping.Key, + DeclarationTime = times[0]!.Value, + DeclarationBeat = beats[0]!.Value, + PathCount = valid.Length + }); + } + return output.OrderBy(item => item.DeclarationBeat) + .ThenBy(item => item.DeclarationTime).ThenBy(item => item.Key).ToList(); + } + + private static List Clusters(IReadOnlyList groups) + { + var buckets = new List>(); + foreach (var group in groups) + { + if (buckets.Count == 0 || + group.DeclarationTime - buckets[^1][0].DeclarationTime > SimultaneousSeconds + Tolerance) + buckets.Add(new List()); + buckets[^1].Add(group); + } + return buckets.Select(bucket => new Cluster + { + DeclarationTime = bucket[0].DeclarationTime, + DeclarationBeat = bucket[0].DeclarationBeat, + Groups = bucket + }).ToList(); + } + + private static IReadOnlyList> Sections(IReadOnlyList clusters) + { + var output = new List>(); + var current = new List { clusters[0] }; + foreach (var cluster in clusters.Skip(1)) + { + var delta = (cluster.DeclarationBeat - current[^1].DeclarationBeat).ToDouble(); + if (delta > Tolerance && delta <= 1 + Tolerance) current.Add(cluster); + else { output.Add(current); current = new List { cluster }; } + } + output.Add(current); + return output; + } + + private static double SectionIntensity(IReadOnlyList section) + { + var selected = SequenceOnsets(section); + var cadence = selected.Count == 1 ? 1 : selected.Zip(selected.Skip(1), (left, right) => + { + var interval = right.DeclarationTime - left.DeclarationTime; + if (interval <= 0) throw new InvalidOperationException("Distinct Slide onsets must advance in time."); + return CadenceReferenceSeconds / interval; + }).Average(); + var concurrency = selected.Average(Concurrency); + var continuous = selected.Count >= 2 ? cadence * LengthFactor(selected.Count) : 0; + return continuous + ConcurrencyWeight * concurrency; + } + + private static IReadOnlyList SequenceOnsets(IReadOnlyList section) + { + var bestAt = new List>(); + for (var index = 0; index < section.Count; index++) + { + var current = new Dictionary + { + [false] = new PathState + { + Indexes = new[] { index }, + ConcurrencySum = Concurrency(section[index]) + } + }; + for (var previous = index - 1; previous >= 0; previous--) + { + var gap = section[index].DeclarationBeat - section[previous].DeclarationBeat; + if (gap > new BeatPosition(1)) break; + if (gap < MinimumInterval) continue; + var elapsed = section[index].DeclarationTime - section[previous].DeclarationTime; + if (elapsed <= 0) throw new InvalidOperationException("Distinct Slide onsets must advance in time."); + foreach (var pair in bestAt[previous]) + { + var direct = previous == index - 1; + var candidate = new PathState + { + Indexes = pair.Value.Indexes.Concat(new[] { index }).ToArray(), + DirectLinks = pair.Value.DirectLinks + (direct ? 1 : 0), + CadenceSum = pair.Value.CadenceSum + CadenceReferenceSeconds / elapsed, + ConcurrencySum = pair.Value.ConcurrencySum + Concurrency(section[index]) + }; + var key = pair.Key || direct; + if (!current.TryGetValue(key, out var incumbent) || Compare(candidate, incumbent) > 0) + current[key] = candidate; + } + } + bestAt.Add(current); + } + var eligible = bestAt.Where(item => item.ContainsKey(true)).Select(item => item[true]).ToArray(); + IReadOnlyList indexes; + if (eligible.Length > 0) + indexes = eligible.Aggregate((best, item) => Compare(item, best) > 0 ? item : best).Indexes; + else + indexes = new[] { Enumerable.Range(0, section.Count) + .OrderByDescending(index => Concurrency(section[index])).ThenBy(index => index).First() }; + return indexes.Select(index => section[index]).ToArray(); + } + + private static int Compare(PathState left, PathState right) + { + var comparisons = new[] + { + left.Indexes.Count.CompareTo(right.Indexes.Count), + left.DirectLinks.CompareTo(right.DirectLinks), + left.CadenceSum.CompareTo(right.CadenceSum), + left.ConcurrencySum.CompareTo(right.ConcurrencySum) + }; + foreach (var comparison in comparisons) if (comparison != 0) return comparison; + for (var index = 0; index < Math.Min(left.Indexes.Count, right.Indexes.Count); index++) + { + var comparison = right.Indexes[index].CompareTo(left.Indexes[index]); + if (comparison != 0) return comparison; + } + return 0; + } + + private static double Concurrency(Cluster cluster) => + Math.Max(0, cluster.Groups.Sum(group => Math.Sqrt(group.PathCount)) - 1); + + private static double LengthFactor(int count) + { + if (count <= LengthBase) return 1; + if (count <= LengthKnee) + return 1 + (LengthKneeFactor - 1) * (double)(count - LengthBase) / (LengthKnee - LengthBase); + return LengthKneeFactor * Math.Sqrt((double)count / LengthKnee); + } +} diff --git a/Runtime/Analysis/Features/SlideTrickyAnalyzer.cs b/Runtime/Analysis/Features/SlideTrickyAnalyzer.cs new file mode 100644 index 0000000..666c070 --- /dev/null +++ b/Runtime/Analysis/Features/SlideTrickyAnalyzer.cs @@ -0,0 +1,408 @@ +using MajRadar.Core; + +namespace MajRadar.Analysis.Features; + +internal sealed class SlideTrickyAnalyzer : IRadarFeatureAnalyzer +{ + private const double SimultaneousSeconds = 1.0 / 60; + private const int ObjectCap = 16; + private const int TopCount = 5; + private const double WaitBucketSeconds = 0.05; + private const double MaxWaitModeRatio = 4; + private const double SpeedReferenceEighthBpm = 180; + private const double SpeedExponent = 0.5; + private const double SpeedMaxFactor = 1.5; + private const double TouchWeight = 1.5; + private const int TouchCap = 2; + private const double SamePositionMultiplier = 1.5; + private const double PendingHeadMultiplier = 2; + private const double MultiSlideUplift = 0.15; + private const double Tolerance = 1e-9; + private static readonly double[] TopWeights = Enumerable.Range(1, TopCount) + .Select(rank => 1 / Math.Log(rank + 1, 2)).ToArray(); + + private sealed class Group + { + internal int Key { get; set; } + internal IReadOnlyList Events { get; set; } = Array.Empty(); + internal double DeclarationTime { get; set; } + internal BeatPosition DeclarationBeat { get; set; } + internal double LaunchTime { get; set; } + internal IReadOnlyList LaunchTimes { get; set; } = Array.Empty(); + internal IReadOnlyList<(double Start, double End)> ActiveIntervals { get; set; } = + Array.Empty<(double, double)>(); + internal int? HeadEventId { get; set; } + } + + private sealed class Cluster + { + internal double DeclarationTime { get; set; } + internal BeatPosition DeclarationBeat { get; set; } + internal IReadOnlyList Groups { get; set; } = Array.Empty(); + } + + private sealed class Point + { + internal int Id { get; set; } + internal double Time { get; set; } + internal double Weight { get; set; } + internal string Kind { get; set; } = string.Empty; + internal BeatPosition? Beat { get; set; } + internal string? Position { get; set; } + internal int? OwnerGroup { get; set; } + internal double HeadBodyWeight { get; set; } + } + + private readonly struct Assigned + { + internal Assigned(Point point, string phase) { Point = point; Phase = phase; } + internal Point Point { get; } + internal string Phase { get; } + } + + private readonly struct TrickyValue + { + internal TrickyValue(double load) => Load = load; + internal double Load { get; } + } + + private readonly struct TempoPoint + { + internal TempoPoint(double time, BeatPosition beat, double bpm) + { Time = time; Beat = beat; Bpm = bpm; } + internal double Time { get; } + internal BeatPosition Beat { get; } + internal double Bpm { get; } + } + + public RadarFeatureResult Analyze(AnalysisContext context) + { + context.ThrowIfCancellationRequested(); + if (context.DurationSeconds <= 0) return RadarFeatureResult.Failure("Chart duration must be positive."); + var groups = Groups(context.Events); + if (groups.Count == 0) return RadarFeatureResult.Success(0); + var clusters = Clusters(groups); + var points = Points(context.Events, groups); + var assigned = Assign(clusters, points); + var modeWait = ModalWait(groups); + var loads = new List(); + for (var index = 0; index < clusters.Count; index++) + { + context.ThrowIfCancellationRequested(); + var filtered = Filter(clusters[index], modeWait); + if (filtered is null) continue; + var clusterPoints = assigned[index]; + if (!ReferenceEquals(filtered, clusters[index])) + clusterPoints = Assign(new[] { filtered }, clusterPoints.Select(item => item.Point).ToArray())[0]; + loads.Add(ClusterTricky(filtered, clusterPoints).Load); + } + var top = loads.OrderByDescending(value => value).Take(TopCount).ToArray(); + return RadarFeatureResult.Success( + top.Select((value, index) => value * TopWeights[index]).Sum() / TopWeights.Sum()); + } + + private static List Groups(IReadOnlyList events) + { + var tempo = events.Where(item => item.Kind == RadarEventKind.Timing && item.Bpm is not null) + .Select(item => new TempoPoint(item.StartTimeSeconds, item.StartBeat, item.Bpm!.Value)) + .OrderBy(item => item.Beat).ToArray(); + var output = new List(); + foreach (var grouping in events.Where(item => + item.Kind == RadarEventKind.Slide && item.SlideGroupId is not null) + .GroupBy(item => item.SlideGroupId!.Value)) + { + var paths = grouping.ToArray(); + var valid = paths.Where(item => item.EndTimeSeconds > item.StartTimeSeconds).ToArray(); + if (valid.Length == 0) continue; + var times = paths.Select(item => item.SlideDeclareTimeSeconds).Distinct().ToArray(); + var beats = paths.Select(item => item.SlideDeclareBeat).Distinct().ToArray(); + var headIds = paths.Select(item => item.HeadEventId).Distinct().ToArray(); + if (times.Length != 1 || times[0] is null || beats.Length != 1 || beats[0] is null || + headIds.Length != 1) + throw new InvalidOperationException("Slide group declaration metadata is inconsistent."); + output.Add(new Group + { + Key = grouping.Key, + Events = valid, + DeclarationTime = times[0]!.Value, + DeclarationBeat = beats[0]!.Value, + LaunchTime = valid.Max(item => item.StartTimeSeconds), + LaunchTimes = valid.Select(item => item.StartTimeSeconds).Distinct().OrderBy(value => value).ToArray(), + ActiveIntervals = valid.Select(item => + { + var oneBeatEnd = SecondsAtBeat(item.StartBeat + new BeatPosition(1), tempo); + return (item.StartTimeSeconds, Math.Min(item.EndTimeSeconds, oneBeatEnd)); + }).ToArray(), + HeadEventId = headIds[0] + }); + } + return output.OrderBy(item => item.DeclarationBeat) + .ThenBy(item => item.DeclarationTime).ThenBy(item => item.Key).ToList(); + } + + private static double SecondsAtBeat(BeatPosition beat, IReadOnlyList tempo) + { + for (var index = tempo.Count - 1; index >= 0; index--) + { + if (tempo[index].Beat <= beat) + return tempo[index].Time + (beat - tempo[index].Beat).ToDouble() * 60 / tempo[index].Bpm; + } + throw new InvalidOperationException("Beat precedes the first BPM event."); + } + + private static List Clusters(IReadOnlyList groups) + { + var buckets = new List>(); + foreach (var group in groups) + { + if (buckets.Count == 0 || + group.DeclarationTime - buckets[^1][0].DeclarationTime > SimultaneousSeconds + Tolerance) + buckets.Add(new List()); + buckets[^1].Add(group); + } + return buckets.Select(bucket => new Cluster + { + DeclarationTime = bucket[0].DeclarationTime, + DeclarationBeat = bucket[0].DeclarationBeat, + Groups = bucket + }).ToList(); + } + + private static List Points(IReadOnlyList events, IReadOnlyList groups) + { + var headOwners = groups.Where(item => item.HeadEventId is not null) + .ToDictionary(item => item.HeadEventId!.Value, item => item.Key); + var headWeights = groups.Where(item => item.HeadEventId is not null) + .ToDictionary(item => item.HeadEventId!.Value, item => (double)item.Events.Count); + var output = new List(); + var id = 0; + foreach (var item in events.Where(item => item.Kind is RadarEventKind.Tap or RadarEventKind.Hold)) + output.Add(new Point + { + Id = ++id, Time = item.StartTimeSeconds, Weight = 1, Kind = "button", + Beat = item.StartBeat, Position = item.Position, + OwnerGroup = headOwners.TryGetValue(item.EventId, out var owner) ? owner : null, + HeadBodyWeight = headWeights.TryGetValue(item.EventId, out var weight) ? weight : 0 + }); + var touches = events.Where(item => item.Kind is RadarEventKind.Touch or RadarEventKind.TouchHold).ToArray(); + foreach (var touch in Workload.SimultaneousTouchPoints(touches, TouchWeight)) + output.Add(new Point { Id = ++id, Time = touch.TimeSeconds, Weight = touch.Weight, Kind = "touch" }); + foreach (var group in groups) + { + foreach (var launch in group.Events.GroupBy(item => item.StartTimeSeconds).OrderBy(item => item.Key)) + output.Add(new Point + { + Id = ++id, Time = launch.Key, Weight = launch.Count(), Kind = "slide_launch", + OwnerGroup = group.Key + }); + } + return output.OrderBy(item => item.Time).ThenBy(item => item.Id).ToList(); + } + + private static List> Assign(IReadOnlyList clusters, IReadOnlyList points) + { + var assigned = Enumerable.Range(0, clusters.Count).Select(_ => new List()).ToList(); + foreach (var point in points) + { + var candidates = new List<(int Priority, double Distance, int Index, string Phase)>(); + for (var index = 0; index < clusters.Count; index++) + { + var cluster = clusters[index]; + var keys = cluster.Groups.Select(item => item.Key).ToHashSet(); + if (point.OwnerGroup is int owner && keys.Contains(owner)) continue; + var launch = cluster.Groups.Any(group => group.LaunchTimes.Any(time => SameTime(point.Time, time))); + var waiting = cluster.Groups.Where(group => + point.Time >= group.DeclarationTime - Tolerance && + point.Time < group.LaunchTime - Tolerance).ToArray(); + var active = cluster.Groups.SelectMany(group => group.ActiveIntervals) + .Where(interval => point.Time > interval.Start + Tolerance && + point.Time <= interval.End + Tolerance).ToArray(); + if (launch) candidates.Add((0, 0, index, "launch")); + else if (waiting.Length > 0) + candidates.Add((1, waiting.Min(group => group.LaunchTime - point.Time), index, "waiting")); + else if (active.Length > 0) + candidates.Add((2, active.Min(interval => point.Time - interval.Start), index, "active")); + } + if (candidates.Count > 0) + { + var selected = candidates.OrderBy(item => item.Priority).ThenBy(item => item.Distance) + .ThenBy(item => item.Index).First(); + assigned[selected.Index].Add(new Assigned(point, selected.Phase)); + } + } + return assigned; + } + + private static TrickyValue ClusterTricky(Cluster cluster, IReadOnlyList assigned) + { + var targets = cluster.Groups.SelectMany(group => group.Events).Select(item => item.Position!) + .ToHashSet(); + var touchIds = assigned.Where(item => item.Point.Kind == "touch") + .OrderBy(item => item.Point.Time).ThenBy(item => item.Point.Id) + .Take(TouchCap).Select(item => item.Point.Id).ToHashSet(); + var counted = assigned.Where(item => item.Point.Kind != "touch" || touchIds.Contains(item.Point.Id)).ToArray(); + var launch = counted.Where(item => item.Phase == "launch").Select(item => item.Point).ToArray(); + var internalItems = counted.Where(item => item.Phase != "launch").ToArray(); + var internalButtons = internalItems.Where(item => item.Point.Kind == "button").Select(item => item.Point).ToArray(); + var internalOther = internalItems.Where(item => item.Point.Kind != "button").Select(item => item.Point).ToArray(); + var waitingIds = internalItems.Where(item => item.Phase == "waiting" && item.Point.Kind == "button") + .Select(item => item.Point.Id).ToHashSet(); + var speed = OrdinarySpeed(cluster, counted); + var internalLoad = SweepAdjustedButtons(internalButtons, targets, waitingIds, speed) + + internalOther.Sum(item => item.Weight); + var headOwners = launch.Where(item => item.Kind == "button" && item.HeadBodyWeight > 0) + .Select(item => item.OwnerGroup).ToHashSet(); + var deduplicatedLaunch = launch.Where(item => + !(item.Kind == "slide_launch" && headOwners.Contains(item.OwnerGroup))).ToArray(); + var launchLoad = deduplicatedLaunch.Sum(item => item.Weight + item.HeadBodyWeight) / 2; + var logicalCount = internalOther.Sum(item => item.Kind == "slide_launch" ? (int)item.Weight : 1) + + internalButtons.Length + deduplicatedLaunch.Sum(item => + item.Kind == "slide_launch" ? (int)item.Weight : + item.Kind == "button" ? 1 + (int)item.HeadBodyWeight : 1); + var capFactor = logicalCount > 0 ? Math.Min(1, (double)ObjectCap / logicalCount) : 1; + var concurrency = cluster.Groups.Sum(group => Math.Sqrt(group.Events.Count)); + var multiplier = 1 + MultiSlideUplift * Math.Max(0, concurrency - 1); + return new TrickyValue((internalLoad + launchLoad) * capFactor * multiplier); + } + + private static double SweepAdjustedButtons( + IReadOnlyList points, + HashSet targets, + HashSet samePositionIds, + double speed) + { + if (points.Count == 0) return 0; + var batches = points.GroupBy(item => item.Beat!.Value).OrderBy(item => item.Key); + var runLength = 0; + Dictionary? previousPositions = null; + BeatPosition? previousBeat = null, interval = null; + int? direction = null; + var total = 0.0; + foreach (var batchGroup in batches) + { + var batch = batchGroup.ToArray(); + var batchWeight = batch.Sum(point => ButtonWeight(point, targets, samePositionIds, speed)); + if (batch.Length is not (1 or 2)) + { + total += batchWeight; runLength = 0; previousPositions = null; + previousBeat = interval = null; direction = null; continue; + } + var positions = batch.GroupBy(item => int.Parse(item.Position!)) + .ToDictionary(item => item.Key, item => item.Count()); + BeatPosition? currentInterval = previousBeat is null + ? null + : batchGroup.Key - previousBeat.Value; + var currentDirection = previousPositions is null || previousPositions.Values.Sum() != batch.Length + ? null : AdjacentDirection(previousPositions, positions); + var continues = previousBeat is not null && currentDirection is not null && + currentInterval > BeatPosition.Zero && + (runLength == 1 || currentDirection == direction && currentInterval == interval); + if (continues) + { + runLength++; + if (runLength == 2) { direction = currentDirection; interval = currentInterval; } + } + else if (previousBeat is not null && currentDirection is not null && currentInterval > BeatPosition.Zero) + { runLength = 2; direction = currentDirection; interval = currentInterval; } + else { runLength = 1; direction = null; interval = null; } + total += batchWeight / Math.Log(Math.Max(2, runLength - 1), 2); + previousPositions = positions; previousBeat = batchGroup.Key; + } + return total; + } + + private static double ButtonWeight(Point point, HashSet targets, HashSet eligible, double speed) + { + var multiplier = eligible.Contains(point.Id) && targets.Contains(point.Position!) + ? SamePositionMultiplier : 1; + if (point.HeadBodyWeight > 0) multiplier = Math.Max(multiplier, PendingHeadMultiplier); + return point.Weight * multiplier * (point.HeadBodyWeight == 0 ? speed : 1); + } + + private static int? AdjacentDirection(Dictionary left, Dictionary right) + { + bool EqualShift(int delta) + { + var shifted = left.ToDictionary(pair => (pair.Key - 1 + delta + 8) % 8 + 1, pair => pair.Value); + return shifted.Count == right.Count && shifted.All(pair => + right.TryGetValue(pair.Key, out var value) && value == pair.Value); + } + if (EqualShift(1)) return 1; + if (EqualShift(-1)) return -1; + return null; + } + + private static double OrdinarySpeed(Cluster cluster, IReadOnlyList assigned) + { + var times = assigned.Where(item => item.Point.Kind == "button" && item.Point.HeadBodyWeight == 0) + .Select(item => item.Point.Time).Distinct().OrderBy(value => value).ToArray(); + if (times.Length == 0) return 1; + var timeline = new[] { cluster.DeclarationTime }.Concat(times).ToArray(); + var gaps = timeline.Zip(timeline.Skip(1), (left, right) => right - left) + .Where(gap => gap > Tolerance).OrderBy(value => value).ToArray(); + if (gaps.Length == 0) return 1; + var median = gaps.Length % 2 == 1 ? gaps[gaps.Length / 2] : + (gaps[gaps.Length / 2 - 1] + gaps[gaps.Length / 2]) / 2; + var ratio = (30 / median) / SpeedReferenceEighthBpm; + return ratio < 1 ? Math.Pow(ratio, SpeedExponent) : Math.Min(SpeedMaxFactor, ratio); + } + + private static double? ModalWait(IReadOnlyList groups) + { + var buckets = new Dictionary>(); + foreach (var group in groups) + foreach (var path in group.Events) + { + var wait = path.StartTimeSeconds - group.DeclarationTime; + if (wait <= Tolerance) continue; + var bucket = (int)Math.Floor((wait + Tolerance) / WaitBucketSeconds + 0.5); + if (!buckets.TryGetValue(bucket, out var values)) buckets[bucket] = values = new List(); + values.Add(wait); + } + if (buckets.Count == 0) return null; + var winner = buckets.OrderByDescending(item => item.Value.Count).ThenByDescending(item => item.Key).First().Value; + winner.Sort(); + return winner.Count % 2 == 1 ? winner[winner.Count / 2] : + (winner[winner.Count / 2 - 1] + winner[winner.Count / 2]) / 2; + } + + private static Cluster? Filter(Cluster cluster, double? modeWait) + { + if (modeWait is null) return cluster; + var threshold = modeWait.Value * MaxWaitModeRatio; + var groups = new List(); + var changed = false; + foreach (var group in cluster.Groups) + { + var keptIndexes = Enumerable.Range(0, group.Events.Count).Where(index => + { + var wait = group.Events[index].StartTimeSeconds - group.DeclarationTime; + var remove = wait > threshold && Math.Abs(wait - threshold) > Tolerance; + changed |= remove; + return !remove; + }).ToArray(); + if (keptIndexes.Length == group.Events.Count) groups.Add(group); + else if (keptIndexes.Length > 0) + { + var events = keptIndexes.Select(index => group.Events[index]).ToArray(); + groups.Add(new Group + { + Key = group.Key, Events = events, DeclarationTime = group.DeclarationTime, + DeclarationBeat = group.DeclarationBeat, HeadEventId = group.HeadEventId, + LaunchTime = events.Max(item => item.StartTimeSeconds), + LaunchTimes = events.Select(item => item.StartTimeSeconds).Distinct().OrderBy(value => value).ToArray(), + ActiveIntervals = keptIndexes.Select(index => group.ActiveIntervals[index]).ToArray() + }); + } + } + if (!changed) return cluster; + return groups.Count == 0 ? null : new Cluster + { + DeclarationTime = cluster.DeclarationTime, + DeclarationBeat = cluster.DeclarationBeat, + Groups = groups + }; + } + + private static bool SameTime(double left, double right) => Math.Abs(left - right) <= Tolerance; +} diff --git a/Runtime/Analysis/Features/SweepBurstAnalyzer.cs b/Runtime/Analysis/Features/SweepBurstAnalyzer.cs new file mode 100644 index 0000000..30c8de9 --- /dev/null +++ b/Runtime/Analysis/Features/SweepBurstAnalyzer.cs @@ -0,0 +1,393 @@ +using MajRadar.Core; +using System.Threading; + +namespace MajRadar.Analysis.Features; + +/// +/// Scores the three strongest non-overlapping two-second Sweep windows. The +/// constants below are the active fitted-feature definition, not user options. +/// +internal sealed class SweepBurstAnalyzer : IRadarFeatureAnalyzer +{ + private const double WindowSeconds = 2; + private const int WindowCount = 3; + private const double WindowRankDecayExponent = 0.5; + private const double ReferenceIntervalSeconds = 1.0 / 12; + private const double SpeedExponent = 0.5; + private const double ProtectedNoteWeight = 0.3; + private const int SimpleRunFullAttacks = 16; + private const double SimpleRunDecayExponent = 0.5; + private const double SameDirectionConnectionBonus = 0.2; + private const double SameDirectionHandoffBonus = 0.2; + private const double EighthGapGroupBonus = 0.05; + private const double IdleDistanceWeight = 0.5; + private const double IdleSpeedReferenceKeysPerSecond = 10; + private const double TakeoverWeight = 1; + private const double FastJumpWeight = 2; + private const double PatternMotionFloor = 0.1; + private const int PatternMinimumGroups = 6; + private const int PatternMaximumPeriod = 4; + private const double PatternMinimumMatchRatio = 0.8; + private const double Tolerance = 1e-9; + private static readonly BeatPosition EighthBeat = new(1, 2); + + private readonly struct PatternToken : IEquatable + { + internal PatternToken(char hand, int direction) { Hand = hand; Direction = direction; } + internal char Hand { get; } + internal int Direction { get; } + public bool Equals(PatternToken other) => Hand == other.Hand && Direction == other.Direction; + public override bool Equals(object? obj) => obj is PatternToken other && Equals(other); + public override int GetHashCode() => HashCode.Combine(Hand, Direction); + } + + private sealed class PointValue + { + internal double Base { get; set; } + internal double Motion { get; set; } + internal double RawMotion { get; set; } + } + + public RadarFeatureResult Analyze(AnalysisContext context) + { + context.ThrowIfCancellationRequested(); + if (context.DurationSeconds <= 0) + return RadarFeatureResult.Failure("Chart duration must be positive."); + return RadarFeatureResult.Success(Score( + context.Events, context.DurationSeconds, context.CancellationToken).Value); + } + + internal static SweepBurstResult Score( + IReadOnlyList events, + double durationSeconds, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var sequences = SweepRecognizer.Recognize(events, cancellationToken); + var (groups, families) = SweepFamilyBuilder.Build(sequences, cancellationToken); + var groupsById = groups.ToDictionary(item => item.Id); + var motions = families.ToDictionary( + family => family.Id, family => SweepHandMotion.ForFamily( + family, groups, cancellationToken)); + var eighthBonusGroups = EighthBonusGroups(groups); + var points = new SortedDictionary(); + + void AddPoint(double time, double baseValue = 0, double motion = 0, double rawMotion = 0) + { + if (!points.TryGetValue(time, out var point)) points[time] = point = new PointValue(); + point.Base += baseValue; + point.Motion += motion; + point.RawMotion += rawMotion; + } + + var firstBatchPhysicalBase = new Dictionary(); + foreach (var group in groups) + { + cancellationToken.ThrowIfCancellationRequested(); + var sequence = group.Sequence; + var speedByBatch = new[] { sequence.UnitTimeIntervals[0] } + .Concat(sequence.UnitTimeIntervals).ToArray(); + var simpleRunLength = 0; + for (var index = 0; index < sequence.Times.Count; index++) + { + var noteWeight = sequence.NormalDeclarations[index] + + sequence.ProtectedDeclarations[index] * ProtectedNoteWeight; + var speedFactor = Math.Pow( + ReferenceIntervalSeconds / speedByBatch[index], SpeedExponent); + if (index == 0) + firstBatchPhysicalBase[group.Id] = sequence.Widths[index] * speedFactor; + + var decay = 1.0; + if (sequence.Widths[index] >= 2) simpleRunLength = 0; + else + { + if (sequence.SpeedSwitches.Contains(index) || + sequence.DirectionSwitches.Contains(index)) simpleRunLength = 0; + simpleRunLength++; + if (simpleRunLength > SimpleRunFullAttacks) + { + var excessRank = simpleRunLength - SimpleRunFullAttacks + 1; + decay = Math.Pow(excessRank, -SimpleRunDecayExponent); + } + } + var baseValue = noteWeight * speedFactor * decay; + if (eighthBonusGroups.Contains(group.Id)) + baseValue += sequence.Widths[index] * speedFactor * EighthGapGroupBonus; + if (sequence.DoubleHandoffs.Contains(index) && + !sequence.DirectionSwitches.Contains(index)) + baseValue += sequence.Widths[index] * speedFactor * + SameDirectionHandoffBonus; + AddPoint(sequence.Times[index], baseValue); + } + } + + var sameDirectionGroups = new HashSet(); + foreach (var group in groups) + { + cancellationToken.ThrowIfCancellationRequested(); + if (group.ParentId is null) continue; + var parent = groupsById[group.ParentId.Value]; + if (!SweepFamilyBuilder.SameDirection(parent, group)) continue; + sameDirectionGroups.Add(group.Id); + AddPoint(group.Sequence.StartTime, + firstBatchPhysicalBase[group.Id] * SameDirectionConnectionBonus); + } + + foreach (var family in families) + { + cancellationToken.ThrowIfCancellationRequested(); + var motion = motions[family.Id]; + var regularStarts = RegularPatternGroups( + family, groupsById, motion, sameDirectionGroups) + .Select(id => groupsById[id].Sequence.StartTime).ToHashSet(); + double? lastLeftUse = null, lastRightUse = null; + foreach (var assignment in motion.Assignments) + { + cancellationToken.ThrowIfCancellationRequested(); + var weightedIdle = 0.0; + if (assignment.LeftIdleDistance != 0) + { + if (lastLeftUse is null) + throw new InvalidOperationException("Idle displacement has no prior left-hand use."); + weightedIdle += IdlePressure( + assignment.LeftIdleDistance, assignment.Time - lastLeftUse.Value); + } + if (assignment.RightIdleDistance != 0) + { + if (lastRightUse is null) + throw new InvalidOperationException("Idle displacement has no prior right-hand use."); + weightedIdle += IdlePressure( + assignment.RightIdleDistance, assignment.Time - lastRightUse.Value); + } + if (assignment.LeftLanes.Count != 0) lastLeftUse = assignment.Time; + if (assignment.RightLanes.Count != 0) lastRightUse = assignment.Time; + + var idleBonus = weightedIdle * IdleDistanceWeight; + var otherBonus = assignment.FreeHandTakeover * TakeoverWeight + + assignment.FastJumpViolations * FastJumpWeight; + var rawBonus = idleBonus + otherBonus; + if (rawBonus == 0) continue; + var adjusted = regularStarts.Contains(assignment.Time) + ? rawBonus * PatternMotionFloor : rawBonus; + AddPoint(assignment.Time, motion: adjusted, rawMotion: rawBonus); + } + } + cancellationToken.ThrowIfCancellationRequested(); + return Windows(points, durationSeconds); + } + + private static HashSet EighthBonusGroups(IReadOnlyList groups) + { + var output = new HashSet(); + var endingAt = new Dictionary>(); + foreach (var group in groups) + { + if (group.ParentId is null && endingAt.TryGetValue( + group.Sequence.StartBeat - EighthBeat, out var previous) && + previous.Any(parent => SameSpeed( + parent.Sequence.MedianIntervalSeconds, + group.Sequence.MedianIntervalSeconds, 0.1))) + output.Add(group.Id); + if (!endingAt.TryGetValue(group.Sequence.EndBeat, out var ending)) + endingAt[group.Sequence.EndBeat] = ending = new List(); + ending.Add(group); + } + return output; + } + + private static HashSet RegularPatternGroups( + SweepFamily family, + IReadOnlyDictionary groups, + HandMotionResult motion, + IReadOnlyCollection protectedGroups) + { + var assignments = motion.Assignments.ToDictionary(item => item.Time); + var output = new HashSet(); + var segment = new List<(int GroupId, PatternToken Token)>(); + foreach (var group in family.GroupIds.Select(id => groups[id]) + .OrderBy(item => item.Sequence.StartTime)) + { + var token = SimpleToken(group, assignments); + if (token is null) + { + output.UnionWith(MarkRegular(segment)); + segment.Clear(); + } + else segment.Add((group.Id, token.Value)); + } + output.UnionWith(MarkRegular(segment)); + output.ExceptWith(protectedGroups); + return output; + } + + private static PatternToken? SimpleToken( + ScoredSweepGroup group, IReadOnlyDictionary assignments) + { + var sequence = group.Sequence; + if (sequence.Widths.Any(width => width != 1) || sequence.SpeedSwitches.Count != 0 || + sequence.DirectionSwitches.Count != 0 || sequence.DoubleHandoffs.Count != 0) + return null; + var directions = sequence.Strands.SelectMany(strand => + new[] { strand.InitialDirection, strand.FinalDirection }).Distinct().ToArray(); + if (directions.Length != 1) return null; + char? hand = null; + foreach (var time in sequence.Times) + { + if (!assignments.TryGetValue(time, out var assignment)) return null; + var current = assignment.LeftLanes.Count != 0 && assignment.RightLanes.Count == 0 + ? 'L' : assignment.RightLanes.Count != 0 && assignment.LeftLanes.Count == 0 + ? 'R' : (char?)null; + if (current is null || hand is not null && hand != current) return null; + hand = current; + } + return new PatternToken(hand!.Value, directions[0]); + } + + private static HashSet MarkRegular( + IReadOnlyList<(int GroupId, PatternToken Token)> segment) + { + if (segment.Count < PatternMinimumGroups) return new HashSet(); + PatternToken[]? bestTemplate = null; + var bestQuality = double.NegativeInfinity; + var bestPeriod = int.MaxValue; + for (var period = 1; + period <= Math.Min(PatternMaximumPeriod, segment.Count / 3); + period++) + { + var template = Enumerable.Range(0, period).Select(phase => + MostCommon(segment.Where((_, index) => index % period == phase) + .Select(item => item.Token))).ToArray(); + var matched = segment.Select((item, index) => + item.Token.Equals(template[index % period])).Count(value => value); + var ratio = (double)matched / segment.Count; + if (ratio < PatternMinimumMatchRatio) continue; + var quality = ratio - 0.02 * (period - 1); + if (quality > bestQuality || quality == bestQuality && period < bestPeriod) + { + bestQuality = quality; + bestPeriod = period; + bestTemplate = template; + } + } + if (bestTemplate is null) return new HashSet(); + return segment.Select((item, index) => (item, index)) + .Where(pair => pair.item.Token.Equals(bestTemplate[pair.index % bestTemplate.Length])) + .Select(pair => pair.item.GroupId).ToHashSet(); + } + + private static PatternToken MostCommon(IEnumerable values) + { + var counts = new Dictionary(); + var index = 0; + foreach (var value in values) + { + if (counts.TryGetValue(value, out var row)) counts[value] = (row.Count + 1, row.First); + else counts[value] = (1, index); + index++; + } + return counts.OrderByDescending(pair => pair.Value.Count) + .ThenBy(pair => pair.Value.First).First().Key; + } + + private static double IdlePressure(int distance, double elapsed) + { + if (distance == 0) return 0; + if (elapsed <= 0) throw new InvalidOperationException("Idle hand travel requires positive time."); + var ratio = distance / (elapsed * IdleSpeedReferenceKeysPerSecond); + return distance * Math.Sqrt(Math.Max(1, ratio)); + } + + private static SweepBurstResult Windows( + SortedDictionary points, double duration) + { + var maxStart = Math.Max(0, duration - WindowSeconds); + var starts = new HashSet { 0, maxStart }; + foreach (var time in points.Keys) + { + starts.Add(Math.Min(maxStart, Math.Max(0, time))); + starts.Add(Math.Min(maxStart, Math.Max(0, time - WindowSeconds))); + } + var times = points.Keys.ToArray(); + var basePrefix = Prefix(points.Values.Select(item => item.Base)); + var motionPrefix = Prefix(points.Values.Select(item => item.Motion)); + var rawPrefix = Prefix(points.Values.Select(item => item.RawMotion)); + var candidates = starts.OrderBy(value => value).Select(start => + { + var left = LowerBound(times, start - Tolerance); + var right = LowerBound(times, start + WindowSeconds - Tolerance); + var baseDensity = (basePrefix[right] - basePrefix[left]) / WindowSeconds; + var motionDensity = (motionPrefix[right] - motionPrefix[left]) / WindowSeconds; + var rawDensity = (rawPrefix[right] - rawPrefix[left]) / WindowSeconds; + return new SweepBurstWindow + { + Value = baseDensity + motionDensity, + BaseDensity = baseDensity, + MotionDensity = motionDensity, + RawMotionDensity = rawDensity, + Start = start, + End = start + WindowSeconds + }; + }).ToArray(); + + var selected = new List(); + foreach (var candidate in candidates.OrderByDescending(item => item.Value) + .ThenBy(item => item.Start)) + { + if (candidate.Value <= 0 || selected.Any(other => + candidate.Start < other.End - Tolerance && + other.Start < candidate.End - Tolerance)) continue; + selected.Add(candidate); + if (selected.Count == WindowCount) break; + } + if (selected.Count == 0) + { + var empty = new SweepBurstWindow { Start = 0, End = WindowSeconds }; + return new SweepBurstResult + { + StrongestWindowStart = 0, + StrongestWindowEnd = WindowSeconds, + Windows = new[] { empty } + }; + } + + var weights = Enumerable.Range(1, selected.Count) + .Select(rank => Math.Pow(rank, -WindowRankDecayExponent)).ToArray(); + var weightTotal = weights.Sum(); + double Weighted(Func property) => + selected.Select((window, index) => weights[index] * property(window)).Sum() / weightTotal; + return new SweepBurstResult + { + Value = Weighted(item => item.Value), + BaseDensity = Weighted(item => item.BaseDensity), + MotionDensity = Weighted(item => item.MotionDensity), + RawMotionDensity = Weighted(item => item.RawMotionDensity), + StrongestWindowStart = selected[0].Start, + StrongestWindowEnd = selected[0].End, + Windows = selected + }; + } + + private static double[] Prefix(IEnumerable values) + { + var output = new List { 0 }; + foreach (var value in values) output.Add(output[^1] + value); + return output.ToArray(); + } + + private static int LowerBound(IReadOnlyList values, double target) + { + var low = 0; + var high = values.Count; + while (low < high) + { + var middle = low + (high - low) / 2; + if (values[middle] < target) low = middle + 1; + else high = middle; + } + return low; + } + + private static bool SameSpeed(double left, double right, double relativeTolerance) => + Math.Abs(left - right) <= Math.Max(Tolerance, + relativeTolerance * Math.Max(Math.Abs(left), Math.Abs(right))); +} diff --git a/Runtime/Analysis/Features/SweepFamilyBuilder.cs b/Runtime/Analysis/Features/SweepFamilyBuilder.cs new file mode 100644 index 0000000..a3fbcdf --- /dev/null +++ b/Runtime/Analysis/Features/SweepFamilyBuilder.cs @@ -0,0 +1,144 @@ +namespace MajRadar.Analysis.Features; + +using System.Threading; + +/// +/// Connects recognized groups into chronological families. Only the constants +/// used by the active Sweep model live here; experimental Python modes are not +/// part of the runtime surface. +/// +internal static class SweepFamilyBuilder +{ + private const double SingleConnectionIncrement = 0.2; + private const double DoubleConnectionIncrement = 0.0; + private const double SpeedChangeIncrement = 0.2; + private const double SameDirectionIncrement = 0.1; + private const double ReversalIncrement = 0.2; + private const double Tolerance = 1e-9; + private const int MaximumConnectionChecks = 1_000_000; + + internal static (IReadOnlyList Groups, IReadOnlyList Families) + Build( + IReadOnlyList sequences, + CancellationToken cancellationToken = default) + { + var ordered = sequences.OrderBy(item => item.StartTime).ThenBy(item => item.EndTime) + .ThenBy(item => item, Comparer.Create(CompareLanes)).ToArray(); + var multipliers = Enumerable.Repeat(1.0, ordered.Length).ToArray(); + var parents = new int?[ordered.Length]; + var activeParents = new List(); + var connectionChecks = 0; + for (var childIndex = 0; childIndex < ordered.Length; childIndex++) + { + cancellationToken.ThrowIfCancellationRequested(); + for (var index = activeParents.Count - 1; index >= 0; index--) + { + var parent = ordered[activeParents[index]]; + if (parent.EndTime + parent.MedianIntervalSeconds + Tolerance < + ordered[childIndex].StartTime) + activeParents.RemoveAt(index); + } + (double Multiplier, double Nearness, int Earlier, int Parent)? best = null; + foreach (var parentIndex in activeParents) + { + if (++connectionChecks > MaximumConnectionChecks) + throw new InvalidOperationException( + $"Sweep family connection budget exceeded ({MaximumConnectionChecks})."); + var increment = ConnectionIncrement(ordered[parentIndex], ordered[childIndex]); + if (increment is null) continue; + var candidate = ( + multipliers[parentIndex] + increment.Value, + -Math.Abs(ordered[childIndex].StartTime - ordered[parentIndex].EndTime), + -parentIndex, + parentIndex); + if (best is null || CompareChoice(candidate, best.Value) > 0) best = candidate; + } + if (best is not null) + { + multipliers[childIndex] = best.Value.Multiplier; + parents[childIndex] = best.Value.Parent; + } + activeParents.Add(childIndex); + } + + var groups = ordered.Select((sequence, index) => new ScoredSweepGroup + { + Id = index + 1, + Sequence = sequence, + ParentId = parents[index] is null ? null : parents[index] + 1 + }).ToArray(); + var byRoot = new SortedDictionary>(); + var roots = new int[groups.Length]; + for (var index = 0; index < groups.Length; index++) + { + var root = parents[index] is int parent ? roots[parent] : index; + roots[index] = root; + if (!byRoot.TryGetValue(root, out var members)) byRoot[root] = members = new List(); + members.Add(groups[index].Id); + } + var families = byRoot.Select((pair, index) => new SweepFamily + { + Id = index + 1, + GroupIds = pair.Value + }).ToArray(); + return (groups, families); + } + + internal static bool SameDirection(ScoredSweepGroup parent, ScoredSweepGroup child) => + parent.Sequence.Strands.Select(item => item.FinalDirection) + .Intersect(child.Sequence.Strands.Select(item => item.InitialDirection)).Any(); + + private static double? ConnectionIncrement(SweepSequence parent, SweepSequence child) + { + var gap = child.StartTime - parent.EndTime; + if (gap < -Tolerance || gap > parent.MedianIntervalSeconds + Tolerance) return null; + var doubleConnection = Math.Abs(gap) <= Tolerance; + var speedChanged = !SameSpeed(parent.MedianIntervalSeconds, child.MedianIntervalSeconds); + var parentDirections = parent.Strands.Select(item => item.FinalDirection).ToHashSet(); + var childDirections = child.Strands.Select(item => item.InitialDirection).ToHashSet(); + var nearStarts = parent.LanesByBatch[0].Any(left => + child.LanesByBatch[0].Any(right => SweepRecognizer.CircularDistance(left, right) <= 1)); + var sameDirection = parentDirections.Overlaps(childDirections); + var fold = parentDirections.Any(left => childDirections.Contains(-left)) && + parent.LanesByBatch[^1].Any(left => child.LanesByBatch[0] + .Any(right => SweepRecognizer.CircularDistance(left, right) <= 1)); + var sameDirectionBonus = sameDirection && nearStarts; + var increment = doubleConnection ? DoubleConnectionIncrement : + sameDirectionBonus || fold ? SingleConnectionIncrement : 0; + if (speedChanged) increment += SpeedChangeIncrement; + if (sameDirectionBonus) increment += SameDirectionIncrement; + else if (fold) increment += ReversalIncrement; + return increment; + } + + private static bool SameSpeed(double left, double right) => + Math.Abs(left - right) <= Math.Max(Tolerance, + SweepRecognizer.SpeedRelativeTolerance * Math.Max(Math.Abs(left), Math.Abs(right))); + + private static int CompareChoice( + (double Multiplier, double Nearness, int Earlier, int Parent) left, + (double Multiplier, double Nearness, int Earlier, int Parent) right) + { + var result = left.Multiplier.CompareTo(right.Multiplier); + if (result != 0) return result; + result = left.Nearness.CompareTo(right.Nearness); + return result != 0 ? result : left.Earlier.CompareTo(right.Earlier); + } + + private static int CompareLanes(SweepSequence left, SweepSequence right) + { + for (var batch = 0; batch < Math.Min(left.LanesByBatch.Count, right.LanesByBatch.Count); batch++) + { + var a = left.LanesByBatch[batch]; + var b = right.LanesByBatch[batch]; + for (var lane = 0; lane < Math.Min(a.Count, b.Count); lane++) + { + var result = a[lane].CompareTo(b[lane]); + if (result != 0) return result; + } + var length = a.Count.CompareTo(b.Count); + if (length != 0) return length; + } + return left.LanesByBatch.Count.CompareTo(right.LanesByBatch.Count); + } +} diff --git a/Runtime/Analysis/Features/SweepHandMotion.cs b/Runtime/Analysis/Features/SweepHandMotion.cs new file mode 100644 index 0000000..832632f --- /dev/null +++ b/Runtime/Analysis/Features/SweepHandMotion.cs @@ -0,0 +1,335 @@ +namespace MajRadar.Analysis.Features; + +using System.Buffers; +using System.Threading; + +/// Two-hand displacement DP used by Sweep burst motion scoring. +internal static class SweepHandMotion +{ + private const double ReferenceStepSeconds = 1.0 / 12; + + private readonly struct MotionState : IEquatable + { + internal MotionState(int? left, int? right, int? leftBatch, int? rightBatch) + { + Left = left; Right = right; LeftBatch = leftBatch; RightBatch = rightBatch; + } + internal int? Left { get; } + internal int? Right { get; } + internal int? LeftBatch { get; } + internal int? RightBatch { get; } + public bool Equals(MotionState other) => Left == other.Left && Right == other.Right && + LeftBatch == other.LeftBatch && RightBatch == other.RightBatch; + public override bool Equals(object? obj) => obj is MotionState other && Equals(other); + public override int GetHashCode() => HashCode.Combine(Left, Right, LeftBatch, RightBatch); + } + + private readonly struct MotionLexKey : IComparable, IEquatable + { + internal MotionLexKey(int prefix, int left, int right) + { Prefix = prefix; Left = left; Right = right; } + internal int Prefix { get; } + internal int Left { get; } + internal int Right { get; } + public int CompareTo(MotionLexKey other) + { + var result = Prefix.CompareTo(other.Prefix); + if (result != 0) return result; + result = Left.CompareTo(other.Left); + return result != 0 ? result : Right.CompareTo(other.Right); + } + public bool Equals(MotionLexKey other) => + Prefix == other.Prefix && Left == other.Left && Right == other.Right; + public override bool Equals(object? obj) => obj is MotionLexKey other && Equals(other); + public override int GetHashCode() => HashCode.Combine(Prefix, Left, Right); + } + + private sealed class MotionRecord + { + internal int FastJumps { get; set; } + internal int TotalDistance { get; set; } + internal int ActiveDistance { get; set; } + internal int IdleDistance { get; set; } + internal int Takeovers { get; set; } + internal MotionRecord? Previous { get; set; } + internal int Length { get; set; } + internal int LexRank { get; set; } + internal MotionLexKey LexKey { get; set; } + internal double Time { get; set; } + internal IReadOnlyList LeftLanes { get; set; } = Array.Empty(); + internal IReadOnlyList RightLanes { get; set; } = Array.Empty(); + internal int? LeftPosition { get; set; } + internal int? RightPosition { get; set; } + internal int LeftIdleDistance { get; set; } + internal int RightIdleDistance { get; set; } + internal int AssignmentTakeover { get; set; } + internal int AssignmentFastJumps { get; set; } + } + + private sealed class AssignmentOption + { + internal IReadOnlyList LeftLanes { get; set; } = Array.Empty(); + internal IReadOnlyList RightLanes { get; set; } = Array.Empty(); + internal int? LeftTarget { get; set; } + internal int? RightTarget { get; set; } + } + + private readonly struct MoveCost + { + internal MoveCost(int distance, int active, int idle, int fast) + { Distance = distance; Active = active; Idle = idle; Fast = fast; } + internal int Distance { get; } + internal int Active { get; } + internal int Idle { get; } + internal int Fast { get; } + } + + internal static HandMotionResult ForFamily( + SweepFamily family, + IReadOnlyList groups, + CancellationToken cancellationToken = default) + { + if (family.GroupIds.Count == 1) + { + var sequence = groups[family.GroupIds[0] - 1].Sequence; + return Calculate( + sequence.Times, + sequence.LanesByBatch, + cancellationToken: cancellationToken); + } + var byId = groups.ToDictionary(item => item.Id); + var batches = new SortedDictionary>(); + foreach (var groupId in family.GroupIds) + { + cancellationToken.ThrowIfCancellationRequested(); + var group = byId[groupId]; + for (var index = 0; index < group.Sequence.Times.Count; index++) + { + var time = group.Sequence.Times[index]; + if (!batches.TryGetValue(time, out var lanes)) + batches[time] = lanes = new HashSet(); + lanes.UnionWith(group.Sequence.LanesByBatch[index]); + } + } + return Calculate( + batches.Keys.ToArray(), + batches.Values.Select(lanes => (IReadOnlyList)lanes.OrderBy(x => x).ToArray()).ToArray(), + cancellationToken: cancellationToken); + } + + internal static HandMotionResult Calculate( + IReadOnlyList times, + IReadOnlyList> lanesByBatch, + IReadOnlyCollection? idleTransitionIndexes = null, + CancellationToken cancellationToken = default) + { + if (times.Count == 0 || times.Count != lanesByBatch.Count) + throw new InvalidOperationException( + "Sweep hand-motion times and lane batches must have the same positive length."); + var lanes = lanesByBatch.Select(batch => + (IReadOnlyList)batch.Distinct().OrderBy(x => x).ToArray()).ToArray(); + var idle = idleTransitionIndexes is null + ? new HashSet() : idleTransitionIndexes.ToHashSet(); + if (times.Select((time, index) => (time, index)).Any(item => + double.IsNaN(item.time) || double.IsInfinity(item.time) || + item.index > 0 && item.time <= times[item.index - 1])) + throw new InvalidOperationException("Sweep hand-motion times must be finite and increasing."); + if (lanes.Any(batch => batch.Count is < 1 or > 3 || batch.Any(x => x is < 1 or > 8))) + throw new InvalidOperationException("A Sweep hand-motion batch needs one to three outer lanes."); + + var states = new Dictionary + { + [new MotionState(null, null, null, null)] = new MotionRecord() + }; + var optionCache = new Dictionary>(); + for (var batchIndex = 0; batchIndex < times.Count; batchIndex++) + { + cancellationToken.ThrowIfCancellationRequested(); + var laneMask = lanes[batchIndex].Aggregate(0, (mask, lane) => mask | 1 << (lane - 1)); + if (!optionCache.TryGetValue(laneMask, out var options)) + optionCache[laneMask] = options = Options(lanes[batchIndex]); + var next = new Dictionary(); + foreach (var pair in states) + foreach (var option in options) + { + cancellationToken.ThrowIfCancellationRequested(); + var previousLeftUsed = pair.Key.LeftBatch == batchIndex - 1; + var previousRightUsed = pair.Key.RightBatch == batchIndex - 1; + var left = Cost(pair.Key.Left, option.LeftTarget, pair.Key.LeftBatch, + batchIndex, times, idle.Contains(batchIndex)); + var right = Cost(pair.Key.Right, option.RightTarget, pair.Key.RightBatch, + batchIndex, times, idle.Contains(batchIndex)); + var usedLeft = option.LeftTarget is not null; + var usedRight = option.RightTarget is not null; + var takeover = lanes[batchIndex].Count == 1 && + (usedLeft && !previousLeftUsed && previousRightUsed || + usedRight && !previousRightUsed && previousLeftUsed) ? 1 : 0; + var state = new MotionState( + usedLeft ? option.LeftTarget : pair.Key.Left, + usedRight ? option.RightTarget : pair.Key.Right, + usedLeft ? batchIndex : pair.Key.Left is null ? null : -1, + usedRight ? batchIndex : pair.Key.Right is null ? null : -1); + var fastJumps = pair.Value.FastJumps + left.Fast + right.Fast; + var totalDistance = pair.Value.TotalDistance + left.Distance + right.Distance; + var activeDistance = pair.Value.ActiveDistance + left.Active + right.Active; + var idleDistance = pair.Value.IdleDistance + left.Idle + right.Idle; + var takeovers = pair.Value.Takeovers + takeover; + var lexKey = new MotionLexKey( + pair.Value.LexRank, state.Left ?? 0, state.Right ?? 0); + if (next.TryGetValue(state, out var previous) && CompareCandidate( + fastJumps, takeovers, totalDistance, activeDistance, lexKey, previous) >= 0) + continue; + next[state] = new MotionRecord + { + FastJumps = fastJumps, + TotalDistance = totalDistance, + ActiveDistance = activeDistance, + IdleDistance = idleDistance, + Takeovers = takeovers, + Previous = pair.Value, + Length = pair.Value.Length + 1, + LexKey = lexKey, + Time = times[batchIndex], + LeftLanes = option.LeftLanes, + RightLanes = option.RightLanes, + LeftPosition = state.Left, + RightPosition = state.Right, + LeftIdleDistance = left.Idle, + RightIdleDistance = right.Idle, + AssignmentTakeover = takeover, + AssignmentFastJumps = left.Fast + right.Fast + }; + } + + var ranks = ArrayPool.Shared.Rent(next.Count); + var rankIndex = 0; + foreach (var record in next.Values) ranks[rankIndex++] = record.LexKey; + Array.Sort(ranks, 0, next.Count); + var uniqueRankCount = 0; + for (var index = 0; index < next.Count; index++) + if (uniqueRankCount == 0 || !ranks[index].Equals(ranks[uniqueRankCount - 1])) + ranks[uniqueRankCount++] = ranks[index]; + try + { + foreach (var record in next.Values) + record.LexRank = Array.BinarySearch( + ranks, 0, uniqueRankCount, record.LexKey); + } + finally + { + ArrayPool.Shared.Return(ranks); + } + states = next; + } + var best = states.Values.Aggregate((left, right) => Compare(left, right) <= 0 ? left : right); + var assignments = new HandAssignment[best.Length]; + for (var record = best; record.Previous is not null; record = record.Previous) + assignments[record.Length - 1] = new HandAssignment + { + Time = record.Time, + LeftLanes = record.LeftLanes, + RightLanes = record.RightLanes, + LeftPosition = record.LeftPosition, + RightPosition = record.RightPosition, + LeftIdleDistance = record.LeftIdleDistance, + RightIdleDistance = record.RightIdleDistance, + FreeHandTakeover = record.AssignmentTakeover, + FastJumpViolations = record.AssignmentFastJumps + }; + return new HandMotionResult + { + TotalDistance = best.TotalDistance, + ActiveDistance = best.ActiveDistance, + IdleDistance = best.IdleDistance, + FreeHandTakeovers = best.Takeovers, + FastJumpViolations = best.FastJumps, + Assignments = assignments + }; + } + + private static int CompareCandidate( + int fastJumps, + int takeovers, + int totalDistance, + int activeDistance, + MotionLexKey lexKey, + MotionRecord right) + { + var result = fastJumps.CompareTo(right.FastJumps); + if (result != 0) return result; + result = takeovers.CompareTo(right.Takeovers); + if (result != 0) return result; + result = totalDistance.CompareTo(right.TotalDistance); + if (result != 0) return result; + result = activeDistance.CompareTo(right.ActiveDistance); + return result != 0 ? result : lexKey.CompareTo(right.LexKey); + } + + private static IReadOnlyList Options(IReadOnlyList lanes) + { + if (lanes.Count == 1) + { + var lane = lanes[0]; + return new[] + { + Option(new[] { lane }, Array.Empty(), lane, null), + Option(Array.Empty(), new[] { lane }, null, lane) + }; + } + if (lanes.Count == 2) + { + return new[] + { + Option(new[] { lanes[0] }, new[] { lanes[1] }, lanes[0], lanes[1]), + Option(new[] { lanes[1] }, new[] { lanes[0] }, lanes[1], lanes[0]) + }; + } + var output = new List(); + for (var singleIndex = 0; singleIndex < 3; singleIndex++) + { + var single = lanes[singleIndex]; + var pair = lanes.Where((_, index) => index != singleIndex).ToArray(); + if (SweepRecognizer.CircularDistance(pair[0], pair[1]) != 1) continue; + foreach (var pairPosition in pair) + { + output.Add(Option(pair, new[] { single }, pairPosition, single)); + output.Add(Option(new[] { single }, pair, single, pairPosition)); + } + } + return output; + } + + private static AssignmentOption Option( + IReadOnlyList left, IReadOnlyList right, int? leftTarget, int? rightTarget) => + new() { LeftLanes = left, RightLanes = right, LeftTarget = leftTarget, RightTarget = rightTarget }; + + private static MoveCost Cost( + int? previous, int? target, int? lastBatch, int batchIndex, + IReadOnlyList times, bool forceIdle) + { + if (target is null || previous is null) return new MoveCost(0, 0, 0, 0); + var distance = SweepRecognizer.CircularDistance(previous.Value, target.Value); + var active = lastBatch == batchIndex - 1 && !forceIdle; + var fast = 0; + if (active && batchIndex > 0) + { + var allowed = Math.Max(1, (int)Math.Floor( + (times[batchIndex] - times[batchIndex - 1]) / ReferenceStepSeconds + 1e-9)); + fast = distance > allowed ? 1 : 0; + } + return new MoveCost(distance, active ? distance : 0, active ? 0 : distance, fast); + } + + private static int Compare(MotionRecord left, MotionRecord right) + { + var result = left.FastJumps.CompareTo(right.FastJumps); + if (result != 0) return result; + result = left.Takeovers.CompareTo(right.Takeovers); + if (result != 0) return result; + result = left.TotalDistance.CompareTo(right.TotalDistance); + if (result != 0) return result; + result = left.ActiveDistance.CompareTo(right.ActiveDistance); + if (result != 0) return result; + return left.LexRank.CompareTo(right.LexRank); + } +} diff --git a/Runtime/Analysis/Features/SweepModel.cs b/Runtime/Analysis/Features/SweepModel.cs new file mode 100644 index 0000000..5a451f0 --- /dev/null +++ b/Runtime/Analysis/Features/SweepModel.cs @@ -0,0 +1,107 @@ +using MajRadar.Core; + +namespace MajRadar.Analysis.Features; + +// Internal data passed between the three independently testable Sweep stages. +internal sealed class SweepAttack +{ + internal int Id { get; set; } + internal int Lane { get; set; } + internal BeatPosition Beat { get; set; } + internal double TimeSeconds { get; set; } + internal IReadOnlyList EventIds { get; set; } = Array.Empty(); + internal int NormalDeclarations { get; set; } + internal int ProtectedDeclarations { get; set; } +} + +internal sealed class SweepStrand +{ + internal IReadOnlyList AttackIds { get; set; } = Array.Empty(); + internal IReadOnlyList Lanes { get; set; } = Array.Empty(); + internal int InitialDirection { get; set; } + internal int FinalDirection { get; set; } + internal int TurnCount { get; set; } +} + +internal sealed class SweepSequence +{ + internal IReadOnlyList> LanesByBatch { get; set; } = + Array.Empty>(); + internal IReadOnlyList Times { get; set; } = Array.Empty(); + internal IReadOnlyList UnitTimeIntervals { get; set; } = Array.Empty(); + internal IReadOnlyCollection SpeedSwitches { get; set; } = new HashSet(); + internal IReadOnlyCollection DirectionSwitches { get; set; } = new HashSet(); + internal IReadOnlyCollection WidthSwitches { get; set; } = new HashSet(); + internal IReadOnlyCollection DoubleHandoffs { get; set; } = new HashSet(); + internal IReadOnlyList NormalDeclarations { get; set; } = Array.Empty(); + internal IReadOnlyList ProtectedDeclarations { get; set; } = Array.Empty(); + internal IReadOnlyList Strands { get; set; } = Array.Empty(); + internal IReadOnlyList Widths { get; set; } = Array.Empty(); + internal BeatPosition StartBeat { get; set; } + internal BeatPosition EndBeat { get; set; } + internal int AttackCount { get; set; } + internal int BatchCount { get; set; } + internal double MedianIntervalSeconds { get; set; } + + internal double StartTime => Times[0]; + internal double EndTime => Times[^1]; +} + +internal sealed class ScoredSweepGroup +{ + internal int Id { get; set; } + internal SweepSequence Sequence { get; set; } = null!; + internal int? ParentId { get; set; } +} + +internal sealed class SweepFamily +{ + internal int Id { get; set; } + internal IReadOnlyList GroupIds { get; set; } = Array.Empty(); +} + +internal sealed class HandAssignment +{ + internal double Time { get; set; } + internal IReadOnlyList LeftLanes { get; set; } = Array.Empty(); + internal IReadOnlyList RightLanes { get; set; } = Array.Empty(); + internal int? LeftPosition { get; set; } + internal int? RightPosition { get; set; } + internal int LeftIdleDistance { get; set; } + internal int RightIdleDistance { get; set; } + internal int FreeHandTakeover { get; set; } + internal int FastJumpViolations { get; set; } +} + +internal sealed class HandMotionResult +{ + internal int TotalDistance { get; set; } + internal int ActiveDistance { get; set; } + internal int IdleDistance { get; set; } + internal int FreeHandTakeovers { get; set; } + internal int FastJumpViolations { get; set; } + internal IReadOnlyList Assignments { get; set; } = + Array.Empty(); +} + +internal sealed class SweepBurstWindow +{ + internal double Value { get; set; } + internal double BaseDensity { get; set; } + internal double MotionDensity { get; set; } + internal double RawMotionDensity { get; set; } + internal double Start { get; set; } + internal double End { get; set; } +} + +internal sealed class SweepBurstResult +{ + internal double Value { get; set; } + internal double BaseDensity { get; set; } + internal double MotionDensity { get; set; } + internal double RawMotionDensity { get; set; } + internal double StrongestWindowStart { get; set; } + internal double StrongestWindowEnd { get; set; } + internal IReadOnlyList Windows { get; set; } = + Array.Empty(); +} diff --git a/Runtime/Analysis/Features/SweepRecognizer.cs b/Runtime/Analysis/Features/SweepRecognizer.cs new file mode 100644 index 0000000..4d54de9 --- /dev/null +++ b/Runtime/Analysis/Features/SweepRecognizer.cs @@ -0,0 +1,707 @@ +using System.Buffers; +using System.Threading; +using MajRadar.Core; + +namespace MajRadar.Analysis.Features; + +/// +/// Recognizes maximal one-spine, variable-width button sweeps. Scan states and +/// prefix candidates are persistent/lightweight; only selected sequences expand +/// their per-batch arrays. +/// +internal static class SweepRecognizer +{ + internal const int MaximumStates = 300_000; + internal const int MaximumButtonAttacks = 30_000; + internal const int MaximumLightweightCandidates = 50_000; + internal const int MaximumSelectionStates = 100_000; + internal const double SpeedRelativeTolerance = 0.005; + private static readonly BeatPosition ShortHoldMaximum = new(1, 4); + private static readonly BeatPosition BaseMaximumUnit = new(1, 3); + private static readonly BeatPosition EighthBeat = new(1, 2); + private const double TimeTolerance = 1e-9; + + private sealed class HoldOccupancy + { + internal int Lane { get; set; } + internal BeatPosition Start { get; set; } + internal BeatPosition End { get; set; } + } + + private sealed class AttackBatch + { + internal BeatPosition Beat { get; set; } + internal double Time { get; set; } + internal IReadOnlyList AttackIds { get; set; } = Array.Empty(); + } + + private sealed class SpineState + { + internal SpineState? Previous { get; set; } + internal int BatchIndex { get; set; } + internal int StartBatchIndex { get; set; } + internal int EntryAttackId { get; set; } + internal int ExitAttackId { get; set; } + internal int Length { get; set; } + internal int? Direction { get; set; } + internal int RunSteps { get; set; } + internal int TurnCount { get; set; } + internal BeatPosition? UnitBeatInterval { get; set; } + internal double? UnitTimeInterval { get; set; } + internal bool SpeedSwitched { get; set; } + internal bool DirectionSwitched { get; set; } + internal int SpeedSwitchCount { get; set; } + internal int DirectionSwitchCount { get; set; } + internal int WidthSwitchCount { get; set; } + internal int HandoffCount { get; set; } + internal int LaneMask { get; set; } + internal int EntryLexRank { get; set; } + internal int ExitLexRank { get; set; } + } + + private sealed class Candidate + { + internal SpineState State { get; set; } = null!; + internal int OriginalIndex { get; set; } + internal int StartBatch => State.StartBatchIndex; + internal int EndBatch => State.BatchIndex; + internal int EventCount { get; set; } + internal int LaneCount { get; set; } + } + + private sealed class Selection + { + internal static readonly Selection Empty = new(); + internal int EventCount { get; set; } + internal int SequenceCount { get; set; } + internal int BatchCount { get; set; } + internal int[] CandidateIndexes { get; set; } = Array.Empty(); + + internal Selection Add(Candidate candidate) + { + var indexes = new int[CandidateIndexes.Length + 1]; + var insert = Array.BinarySearch(CandidateIndexes, candidate.OriginalIndex); + insert = insert < 0 ? ~insert : insert; + Array.Copy(CandidateIndexes, 0, indexes, 0, insert); + indexes[insert] = candidate.OriginalIndex; + Array.Copy(CandidateIndexes, insert, indexes, insert + 1, + CandidateIndexes.Length - insert); + return new Selection + { + EventCount = EventCount + candidate.EventCount, + SequenceCount = SequenceCount + 1, + BatchCount = BatchCount + candidate.State.Length, + CandidateIndexes = indexes + }; + } + } + + private readonly struct StateKey : IEquatable + { + private readonly int _exit; + private readonly int? _direction; + private readonly int _run; + private readonly double? _seconds; + private readonly BeatPosition? _beats; + + internal StateKey(SpineState state) + { + _exit = state.ExitAttackId; + _direction = state.Direction; + _run = Math.Min(state.RunSteps, 2); + _seconds = state.UnitTimeInterval is double seconds + ? Math.Round(seconds, 9) : null; + _beats = state.UnitBeatInterval; + } + + public bool Equals(StateKey other) => _exit == other._exit && + _direction == other._direction && _run == other._run && + _seconds == other._seconds && _beats == other._beats; + public override bool Equals(object? obj) => obj is StateKey other && Equals(other); + public override int GetHashCode() => + HashCode.Combine(_exit, _direction, _run, _seconds, _beats); + } + + private readonly struct LexKey : IComparable, IEquatable + { + internal LexKey(int length, int prefix, int current) + { Length = length; Prefix = prefix; Current = current; } + internal int Length { get; } + internal int Prefix { get; } + internal int Current { get; } + public int CompareTo(LexKey other) + { + var result = Length.CompareTo(other.Length); + if (result != 0) return result; + result = Prefix.CompareTo(other.Prefix); + return result != 0 ? result : Current.CompareTo(other.Current); + } + public bool Equals(LexKey other) => + Length == other.Length && Prefix == other.Prefix && Current == other.Current; + public override bool Equals(object? obj) => obj is LexKey other && Equals(other); + public override int GetHashCode() => HashCode.Combine(Length, Prefix, Current); + } + + internal static IReadOnlyList ButtonAttacks(IReadOnlyList events) + { + var grouped = new Dictionary<(BeatPosition Beat, int Lane), List>(); + foreach (var item in events) + { + if (item.Kind is not (RadarEventKind.Tap or RadarEventKind.Hold)) continue; + if (item.Kind == RadarEventKind.Hold && item.EndBeat - item.StartBeat > ShortHoldMaximum) + continue; + if (!int.TryParse(item.Position, out var lane) || lane is < 1 or > 8) + throw new InvalidOperationException("Button event is missing a valid outer lane."); + var key = (item.StartBeat, lane); + if (!grouped.TryGetValue(key, out var declarations)) + grouped[key] = declarations = new List(); + declarations.Add(item); + } + + var output = new List(); + foreach (var pair in grouped.OrderBy(pair => pair.Key.Beat).ThenBy(pair => pair.Key.Lane)) + { + var declarations = pair.Value.OrderBy(item => item.EventId).ToArray(); + var time = declarations[0].StartTimeSeconds; + if (declarations.Skip(1).Any(item => Math.Abs(item.StartTimeSeconds - time) > 1e-12)) + throw new InvalidOperationException("Same-beat duplicate attacks disagree on chart time."); + output.Add(new SweepAttack + { + Id = output.Count, + Lane = pair.Key.Lane, + Beat = pair.Key.Beat, + TimeSeconds = time, + EventIds = declarations.Select(item => item.EventId).ToArray(), + NormalDeclarations = declarations.Count(item => item.IsEx != true), + ProtectedDeclarations = declarations.Count(item => item.IsEx == true) + }); + } + return output; + } + + internal static IReadOnlyList Recognize( + IReadOnlyList events, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var attacks = ButtonAttacks(events); + if (attacks.Count > MaximumButtonAttacks) + throw new InvalidOperationException( + $"Sweep attack budget exceeded ({attacks.Count} > {MaximumButtonAttacks})."); + var batches = Batches(attacks); + var candidates = CandidateStates( + attacks, batches, LongHolds(events), cancellationToken); + var selected = SelectDisjoint(candidates, cancellationToken); + return selected.Select(candidate => ToSequence(candidate.State, attacks, batches)) + .OrderBy(item => item, Comparer.Create(CompareSequenceOrder)) + .ToArray(); + } + + private static IReadOnlyList LongHolds(IReadOnlyList events) => + events.Where(item => item.Kind == RadarEventKind.Hold && + item.EndBeat - item.StartBeat > ShortHoldMaximum) + .Select(item => new HoldOccupancy + { + Lane = int.TryParse(item.Position, out var lane) ? lane : + throw new InvalidOperationException("Hold event is missing a valid outer lane."), + Start = item.StartBeat, + End = item.EndBeat + }).ToArray(); + + private static IReadOnlyList Batches(IReadOnlyList attacks) => + attacks.GroupBy(item => item.Beat).OrderBy(group => group.Key).Select(group => + { + var members = group.OrderBy(item => item.Lane).ThenBy(item => item.Id).ToArray(); + var time = members[0].TimeSeconds; + if (members.Skip(1).Any(item => Math.Abs(item.TimeSeconds - time) > 1e-12)) + throw new InvalidOperationException("Same-beat attacks disagree on chart time."); + return new AttackBatch + { + Beat = group.Key, + Time = time, + AttackIds = members.Select(item => item.Id).ToArray() + }; + }).ToArray(); + + private static IReadOnlyList CandidateStates( + IReadOnlyList attacks, + IReadOnlyList batches, + IReadOnlyList holds, + CancellationToken cancellationToken) + { + var attackPrefix = new int[batches.Count + 1]; + var eventPrefix = new int[batches.Count + 1]; + for (var index = 0; index < batches.Count; index++) + { + attackPrefix[index + 1] = attackPrefix[index] + batches[index].AttackIds.Count; + eventPrefix[index + 1] = eventPrefix[index] + batches[index].AttackIds + .Sum(id => attacks[id].EventIds.Count); + } + + var orderedKeys = new List<(int Start, int End)>(); + var candidates = new Dictionary<(int Start, int End), Candidate>(); + var active = new List(); + var visited = 0; + for (var batchIndex = 0; batchIndex < batches.Count; batchIndex++) + { + cancellationToken.ThrowIfCancellationRequested(); + var batch = batches[batchIndex]; + if (batch.AttackIds.Count > 3) + { + active.Clear(); + continue; + } + + var next = batch.AttackIds.Select(attackId => InitialState( + batchIndex, attackId, attacks[attackId].Lane)).ToList(); + foreach (var state in active) + foreach (var entry in batch.AttackIds) + foreach (var exit in batch.AttackIds) + { + cancellationToken.ThrowIfCancellationRequested(); + var advanced = Advance( + state, batchIndex, entry, exit, attacks, batches, holds); + if (advanced is not null) next.Add(advanced); + } + + var keys = new List(); + var deduplicated = new Dictionary(); + AssignLexRanks(next, entry: true); + AssignLexRanks(next, entry: false); + foreach (var state in next) + { + if (++visited > MaximumStates) + throw new InvalidOperationException( + "Sweep main-spine state budget exceeded; no partial result returned."); + var key = new StateKey(state); + if (!deduplicated.TryGetValue(key, out var previous)) + { + keys.Add(key); + deduplicated[key] = state; + } + else if (CompareStateQuality(state, previous) > 0) + deduplicated[key] = state; + } + active = keys.Select(key => deduplicated[key]).ToList(); + foreach (var state in active.Where(Complete)) + { + var range = (state.StartBatchIndex, state.BatchIndex); + var candidate = new Candidate + { + State = state, + EventCount = eventPrefix[range.Item2 + 1] - eventPrefix[range.Item1], + LaneCount = attackPrefix[range.Item2 + 1] - attackPrefix[range.Item1] + }; + if (!candidates.TryGetValue(range, out var previous)) + { + if (orderedKeys.Count >= MaximumLightweightCandidates) + throw new InvalidOperationException( + $"Sweep lightweight candidate budget exceeded ({MaximumLightweightCandidates})."); + candidate.OriginalIndex = orderedKeys.Count; + orderedKeys.Add(range); + candidates[range] = candidate; + } + else if (CompareCandidateQuality(candidate, previous) > 0) + { + candidate.OriginalIndex = previous.OriginalIndex; + candidates[range] = candidate; + } + } + } + + var unique = orderedKeys.Select(key => candidates[key]).ToArray(); + var retained = new bool[unique.Length]; + var furthestEnd = -1; + foreach (var candidate in unique.OrderBy(item => item.StartBatch) + .ThenByDescending(item => item.EndBatch) + .ThenBy(item => item.OriginalIndex)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (candidate.EndBatch <= furthestEnd) continue; + retained[candidate.OriginalIndex] = true; + furthestEnd = candidate.EndBatch; + } + return unique.Where(item => retained[item.OriginalIndex]).ToArray(); + } + + private static SpineState InitialState(int batchIndex, int attackId, int lane) => new() + { + BatchIndex = batchIndex, + StartBatchIndex = batchIndex, + EntryAttackId = attackId, + ExitAttackId = attackId, + Length = 1, + LaneMask = 1 << (lane - 1) + }; + + private static SpineState? Advance( + SpineState state, + int batchIndex, + int entryId, + int exitId, + IReadOnlyList attacks, + IReadOnlyList batches, + IReadOnlyList holds) + { + var move = Move(attacks[state.ExitAttackId], attacks[entryId], holds); + if (move is null) return null; + var (direction, units) = move.Value; + var previousBatch = batches[state.BatchIndex]; + var currentBatch = batches[batchIndex]; + var gapBeat = currentBatch.Beat - previousBatch.Beat; + var gapTime = currentBatch.Time - previousBatch.Time; + if (gapBeat <= BeatPosition.Zero || gapTime <= 0) return null; + var unitBeat = Divide(gapBeat, units); + var unitTime = gapTime / units; + + bool speedSwitch; + if (state.UnitTimeInterval is null) + { + if (unitBeat > BaseMaximumUnit) return null; + speedSwitch = false; + } + else + { + var previousTime = state.UnitTimeInterval.Value; + speedSwitch = !SameSpeed(unitTime, previousTime, SpeedRelativeTolerance); + var ordinary = unitBeat <= BaseMaximumUnit; + var continuingEighth = unitBeat == EighthBeat && + state.UnitBeatInterval == EighthBeat && !speedSwitch; + var deceleratingToEighth = unitBeat == EighthBeat && speedSwitch && + unitTime > previousTime; + if (!ordinary && !continuingEighth && !deceleratingToEighth) return null; + } + + var directionSwitched = false; + int runSteps, turns; + if (state.Direction is null) + { + runSteps = 1; + turns = state.TurnCount; + } + else if (state.Direction == direction) + { + runSteps = state.RunSteps + 1; + turns = state.TurnCount; + } + else + { + if (state.RunSteps < 2) return null; + runSteps = 1; + turns = state.TurnCount + 1; + directionSwitched = true; + } + return new SpineState + { + Previous = state, + BatchIndex = batchIndex, + StartBatchIndex = state.StartBatchIndex, + EntryAttackId = entryId, + ExitAttackId = exitId, + Length = state.Length + 1, + Direction = direction, + RunSteps = runSteps, + TurnCount = turns, + UnitBeatInterval = unitBeat, + UnitTimeInterval = unitTime, + SpeedSwitched = speedSwitch, + DirectionSwitched = directionSwitched, + SpeedSwitchCount = state.SpeedSwitchCount + (speedSwitch ? 1 : 0), + DirectionSwitchCount = state.DirectionSwitchCount + (directionSwitched ? 1 : 0), + WidthSwitchCount = state.WidthSwitchCount + + (previousBatch.AttackIds.Count != currentBatch.AttackIds.Count ? 1 : 0), + HandoffCount = state.HandoffCount + (entryId != exitId ? 1 : 0), + LaneMask = state.LaneMask | + 1 << (attacks[entryId].Lane - 1) | + 1 << (attacks[exitId].Lane - 1) + }; + } + + private static (int Direction, int Units)? Move( + SweepAttack left, SweepAttack right, IReadOnlyList holds) + { + var clockwise = Mod(right.Lane - left.Lane, 8); + var counterclockwise = Mod(left.Lane - right.Lane, 8); + if (clockwise == 1) return (1, 1); + if (counterclockwise == 1) return (-1, 1); + var direction = clockwise == 2 ? 1 : counterclockwise == 2 ? -1 : 0; + if (direction == 0) return null; + var skipped = Ring(left.Lane + direction); + return holds.Any(hold => hold.Lane == skipped && hold.Start <= left.Beat && + hold.End >= right.Beat) + ? (direction, 2) : null; + } + + private static bool Complete(SpineState state) => + state.Length >= 3 && state.Direction is not null && + (state.TurnCount == 0 || state.RunSteps >= 2) && CountBits(state.LaneMask) >= 3; + + private static SweepSequence ToSequence( + SpineState state, + IReadOnlyList attacks, + IReadOnlyList batches) + { + var chain = new SpineState[state.Length]; + for (var current = state; current is not null; current = current.Previous) + chain[current.Length - 1] = current; + var widths = new int[chain.Length]; + var lanes = new IReadOnlyList[chain.Length]; + var times = new double[chain.Length]; + var normal = new int[chain.Length]; + var protectedCounts = new int[chain.Length]; + var unitTimes = new double[chain.Length - 1]; + var mainAttackIds = new int[chain.Length]; + var mainLanes = new int[chain.Length]; + var speedSwitches = new HashSet(); + var directionSwitches = new HashSet(); + var widthSwitches = new HashSet(); + var doubleHandoffs = new HashSet(); + + for (var index = 0; index < chain.Length; index++) + { + var item = chain[index]; + var batch = batches[item.BatchIndex]; + widths[index] = batch.AttackIds.Count; + lanes[index] = batch.AttackIds.Select(id => attacks[id].Lane).ToArray(); + times[index] = batch.Time; + normal[index] = batch.AttackIds.Sum(id => attacks[id].NormalDeclarations); + protectedCounts[index] = batch.AttackIds.Sum(id => attacks[id].ProtectedDeclarations); + mainAttackIds[index] = item.EntryAttackId; + mainLanes[index] = attacks[item.EntryAttackId].Lane; + if (index == 0) continue; + unitTimes[index - 1] = item.UnitTimeInterval!.Value; + if (item.SpeedSwitched) speedSwitches.Add(index); + if (item.DirectionSwitched) directionSwitches.Add(index - 1); + if (widths[index - 1] != widths[index]) widthSwitches.Add(index); + if (item.EntryAttackId != item.ExitAttackId) doubleHandoffs.Add(index); + } + + return new SweepSequence + { + LanesByBatch = lanes, + Times = times, + UnitTimeIntervals = unitTimes, + SpeedSwitches = speedSwitches, + DirectionSwitches = directionSwitches, + WidthSwitches = widthSwitches, + DoubleHandoffs = doubleHandoffs, + NormalDeclarations = normal, + ProtectedDeclarations = protectedCounts, + Widths = widths, + StartBeat = batches[chain[0].BatchIndex].Beat, + EndBeat = batches[chain[^1].BatchIndex].Beat, + AttackCount = widths.Sum(), + BatchCount = chain.Length, + MedianIntervalSeconds = Median(unitTimes), + Strands = new[] + { + new SweepStrand + { + AttackIds = mainAttackIds, + Lanes = mainLanes, + InitialDirection = chain[1].Direction!.Value, + FinalDirection = chain[^1].Direction!.Value, + TurnCount = state.TurnCount + } + } + }; + } + + private static IReadOnlyList SelectDisjoint( + IReadOnlyList candidates, + CancellationToken cancellationToken) + { + if (candidates.Count == 0) return Array.Empty(); + var byOriginal = candidates.ToDictionary(item => item.OriginalIndex); + var components = new List>(); + List? current = null; + var furthestEnd = -1; + foreach (var candidate in candidates.OrderBy(item => item.StartBatch) + .ThenBy(item => item.EndBatch).ThenBy(item => item.OriginalIndex)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (current is null || candidate.StartBatch > furthestEnd) + { + current = new List(); + components.Add(current); + furthestEnd = candidate.EndBatch; + } + else furthestEnd = Math.Max(furthestEnd, candidate.EndBatch); + current.Add(candidate); + } + + var selected = new List(); + var selectionStates = 0; + foreach (var component in components) + { + cancellationToken.ThrowIfCancellationRequested(); + if (component.Count == 1) + { + selected.Add(component[0]); + continue; + } + var ordered = component.OrderBy(item => item.EndBatch) + .ThenBy(item => item.StartBatch).ThenBy(item => item.OriginalIndex).ToArray(); + var ends = ordered.Select(item => item.EndBatch).ToArray(); + var best = new Selection[ordered.Length + 1]; + best[0] = Selection.Empty; + for (var index = 0; index < ordered.Length; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + if (++selectionStates > MaximumSelectionStates) + throw new InvalidOperationException( + $"Sweep selection budget exceeded ({MaximumSelectionStates})."); + var previous = LastBefore(ends, ordered[index].StartBatch, index); + var included = best[previous + 1].Add(ordered[index]); + var excluded = best[index]; + best[index + 1] = CompareSelection(included, excluded) >= 0 + ? included : excluded; + } + selected.AddRange(best[^1].CandidateIndexes.Select(index => byOriginal[index])); + } + return selected; + } + + private static int CompareStateQuality(SpineState left, SpineState right) + { + var result = left.Length.CompareTo(right.Length); + if (result != 0) return result; + result = right.SpeedSwitchCount.CompareTo(left.SpeedSwitchCount); + if (result != 0) return result; + result = right.TurnCount.CompareTo(left.TurnCount); + if (result != 0) return result; + result = right.HandoffCount.CompareTo(left.HandoffCount); + if (result != 0) return result; + result = right.EntryLexRank.CompareTo(left.EntryLexRank); + return result != 0 ? result : right.ExitLexRank.CompareTo(left.ExitLexRank); + } + + private static int CompareCandidateQuality(Candidate left, Candidate right) + { + var result = left.LaneCount.CompareTo(right.LaneCount); + if (result != 0) return result; + result = right.State.SpeedSwitchCount.CompareTo(left.State.SpeedSwitchCount); + if (result != 0) return result; + result = left.State.Length.CompareTo(right.State.Length); + if (result != 0) return result; + result = right.State.DirectionSwitchCount.CompareTo(left.State.DirectionSwitchCount); + return result != 0 ? result : + right.State.WidthSwitchCount.CompareTo(left.State.WidthSwitchCount); + } + + private static int CompareSelection(Selection left, Selection right) + { + var result = left.EventCount.CompareTo(right.EventCount); + if (result != 0) return result; + result = right.SequenceCount.CompareTo(left.SequenceCount); + if (result != 0) return result; + result = left.BatchCount.CompareTo(right.BatchCount); + return result != 0 ? result : -LexicographicCompare( + left.CandidateIndexes, right.CandidateIndexes); + } + + private static void AssignLexRanks(IReadOnlyList states, bool entry) + { + var keys = ArrayPool.Shared.Rent(states.Count); + try + { + for (var index = 0; index < states.Count; index++) + keys[index] = LexKeyFor(states[index], entry); + Array.Sort(keys, 0, states.Count); + var uniqueCount = 0; + for (var index = 0; index < states.Count; index++) + if (uniqueCount == 0 || !keys[index].Equals(keys[uniqueCount - 1])) + keys[uniqueCount++] = keys[index]; + foreach (var item in states) + { + var rank = Array.BinarySearch(keys, 0, uniqueCount, LexKeyFor(item, entry)); + if (entry) item.EntryLexRank = rank; + else item.ExitLexRank = rank; + } + } + finally + { + ArrayPool.Shared.Return(keys); + } + } + + private static LexKey LexKeyFor(SpineState item, bool entry) => new( + item.Length, + item.Previous is null ? 0 : + entry ? item.Previous.EntryLexRank : item.Previous.ExitLexRank, + entry ? item.EntryAttackId : item.ExitAttackId); + + private static int CompareSequenceOrder(SweepSequence left, SweepSequence right) + { + var result = left.StartBeat.CompareTo(right.StartBeat); + if (result != 0) return result; + result = left.EndBeat.CompareTo(right.EndBeat); + return result != 0 ? result : CompareNested(left.LanesByBatch, right.LanesByBatch); + } + + private static int LastBefore(int[] sortedEnds, int start, int exclusiveEnd) + { + var low = 0; + var high = exclusiveEnd; + while (low < high) + { + var middle = low + (high - low) / 2; + if (sortedEnds[middle] < start) low = middle + 1; + else high = middle; + } + return low - 1; + } + + private static double Median(double[] values) + { + var ordered = (double[])values.Clone(); + Array.Sort(ordered); + var middle = ordered.Length / 2; + return ordered.Length % 2 == 1 + ? ordered[middle] + : (ordered[middle - 1] + ordered[middle]) / 2; + } + + private static int CountBits(int value) + { + var count = 0; + while (value != 0) { value &= value - 1; count++; } + return count; + } + + private static BeatPosition Divide(BeatPosition value, int divisor) => + new(value.Numerator, checked(value.Denominator * divisor)); + private static int Ring(int lane) => Mod(lane - 1, 8) + 1; + private static int Mod(int value, int divisor) => (value % divisor + divisor) % divisor; + internal static int CircularDistance(int left, int right) + { + var distance = Math.Abs(left - right); + return Math.Min(distance, 8 - distance); + } + private static bool SameSpeed(double left, double right, double tolerance) => + Math.Abs(left - right) <= Math.Max(TimeTolerance, + tolerance * Math.Max(Math.Abs(left), Math.Abs(right))); + + private static int LexicographicCompare(IReadOnlyList left, IReadOnlyList right) + where T : IComparable + { + for (var index = 0; index < Math.Min(left.Count, right.Count); index++) + { + var result = left[index].CompareTo(right[index]); + if (result != 0) return result; + } + return left.Count.CompareTo(right.Count); + } + + private static int CompareNested( + IReadOnlyList> left, + IReadOnlyList> right) + { + for (var index = 0; index < Math.Min(left.Count, right.Count); index++) + { + var result = LexicographicCompare(left[index], right[index]); + if (result != 0) return result; + } + return left.Count.CompareTo(right.Count); + } +} diff --git a/Runtime/Analysis/Features/Workload.cs b/Runtime/Analysis/Features/Workload.cs new file mode 100644 index 0000000..4edc36d --- /dev/null +++ b/Runtime/Analysis/Features/Workload.cs @@ -0,0 +1,225 @@ +using MajRadar.Core; + +namespace MajRadar.Analysis.Features; + +internal static class Workload +{ + internal const double WindowSeconds = 1.5; + internal const int SlideLengthUnit = 64; + internal static readonly BeatPosition EighthBeat = new(1, 2); + internal static readonly BeatPosition SixteenthBeat = new(1, 4); + + private static readonly IReadOnlyDictionary> TouchAdjacency = + BuildTouchAdjacency(); + + internal readonly struct Point + { + internal Point(double timeSeconds, double weight) + { + TimeSeconds = timeSeconds; + Weight = weight; + } + internal double TimeSeconds { get; } + internal double Weight { get; } + } + + private sealed class TouchComponent + { + internal int Id { get; set; } + internal BeatPosition Beat { get; set; } + internal double TimeSeconds { get; set; } + internal IReadOnlyList Positions { get; set; } = Array.Empty(); + } + + internal static BeatPosition BeatDuration(RadarEvent item) + { + var duration = item.EndBeat - item.StartBeat; + if (duration < BeatPosition.Zero) throw new InvalidOperationException("Hold has a negative beat interval."); + return duration; + } + + internal static IReadOnlyList CorrectedPoints(IReadOnlyList events) + { + var output = new List(); + var slides = new List(); + var touches = new List(); + foreach (var item in events) + { + switch (item.Kind) + { + case RadarEventKind.Tap: + output.Add(new Point(item.StartTimeSeconds, 1)); + break; + case RadarEventKind.Hold: + output.Add(new Point(item.StartTimeSeconds, BeatDuration(item) <= EighthBeat ? 1 : 2)); + break; + case RadarEventKind.Slide: + slides.Add(item); + break; + case RadarEventKind.Touch: + case RadarEventKind.TouchHold: + touches.Add(item); + break; + } + } + output.AddRange(SlidePoints(slides, SlideLengthUnit)); + output.AddRange(TouchPoints(touches, 1)); + return output; + } + + internal static IReadOnlyList SlidePoints( + IReadOnlyList slides, + int? lengthUnit) + { + var groups = slides.GroupBy(item => item.SlideGroupId is int groupId + ? (Grouped: true, Id: groupId) + : (Grouped: false, Id: item.EventId)); + var output = new List(); + foreach (var group in groups) + { + var totalLength = group.Sum(item => item.SlidePath?.Sum(segment => segment.BarCount) ?? 0); + if (totalLength <= 0) throw new InvalidOperationException("Slide group has no positive length."); + var weight = lengthUnit is null + ? 1.0 + : (double)((totalLength + lengthUnit.Value - 1) / lengthUnit.Value); + output.Add(new Point(group.Min(item => item.StartTimeSeconds), weight)); + } + return output; + } + + internal static IReadOnlyList TouchPoints( + IReadOnlyList touches, + double groupWeight) + { + if (touches.Count == 0) return Array.Empty(); + var components = BuildTouchComponents(touches); + var beats = components.Keys.ToArray(); + var used = new HashSet(); + var output = new List(); + for (var leftIndex = 0; leftIndex < beats.Length; leftIndex++) + { + var leftBeat = beats[leftIndex]; + for (var rightIndex = leftIndex + 1; rightIndex < beats.Length; rightIndex++) + { + var rightBeat = beats[rightIndex]; + if (rightBeat - leftBeat > SixteenthBeat) break; + var left = components[leftBeat].Where(item => !used.Contains(item.Id)).ToArray(); + var right = components[rightBeat].Where(item => !used.Contains(item.Id)).ToArray(); + if (left.Length == 0 || right.Length == 0) continue; + foreach (var group in CrossTimeComponents(left, right)) + { + foreach (var item in group) used.Add(item.Id); + output.Add(new Point(group.Min(item => item.TimeSeconds), groupWeight)); + } + } + } + foreach (var item in components.Values.SelectMany(items => items)) + if (!used.Contains(item.Id)) output.Add(new Point(item.TimeSeconds, groupWeight)); + return output.OrderBy(item => item.TimeSeconds).ToArray(); + } + + internal static IReadOnlyList SimultaneousTouchPoints( + IReadOnlyList touches, + double weight) => BuildTouchComponents(touches).Values + .SelectMany(items => items) + .Select(item => new Point(item.TimeSeconds, weight)) + .OrderBy(item => item.TimeSeconds) + .ToArray(); + + private static SortedDictionary> BuildTouchComponents( + IReadOnlyList touches) + { + var byBeat = touches.GroupBy(item => item.StartBeat).OrderBy(group => group.Key).ToArray(); + var components = new SortedDictionary>(); + var nextId = 0; + foreach (var beatGroup in byBeat) + { + var simultaneous = beatGroup.ToArray(); + var groups = GraphComponents(simultaneous, (left, right) => Adjacent(left.Position!, right.Position!)); + components[beatGroup.Key] = groups.Select(group => new TouchComponent + { + Id = ++nextId, + Beat = beatGroup.Key, + TimeSeconds = group.Min(item => item.StartTimeSeconds), + Positions = group.Select(item => item.Position!).ToArray() + }).ToList(); + } + return components; + } + + private static IReadOnlyList> CrossTimeComponents( + IReadOnlyList left, + IReadOnlyList right) + { + var nodes = left.Concat(right).ToArray(); + var leftIds = left.Select(item => item.Id).ToHashSet(); + var rightIds = right.Select(item => item.Id).ToHashSet(); + return GraphComponents(nodes, (first, second) => + { + var crosses = leftIds.Contains(first.Id) && rightIds.Contains(second.Id) || + rightIds.Contains(first.Id) && leftIds.Contains(second.Id); + return crosses && first.Positions.Any(a => second.Positions.Any(b => Adjacent(a, b))); + }).Where(group => group.Count > 1).ToArray(); + } + + private static IReadOnlyList> GraphComponents( + IReadOnlyList nodes, + Func connected) + { + var remaining = new SortedSet(Enumerable.Range(0, nodes.Count)); + var output = new List>(); + while (remaining.Count > 0) + { + var first = remaining.Min; + remaining.Remove(first); + var stack = new Stack(); + stack.Push(first); + var indexes = new List(); + while (stack.Count > 0) + { + var current = stack.Pop(); + indexes.Add(current); + var neighbors = remaining.Where(candidate => connected(nodes[current], nodes[candidate])).ToArray(); + foreach (var neighbor in neighbors) + { + remaining.Remove(neighbor); + stack.Push(neighbor); + } + } + indexes.Sort(); + output.Add(indexes.Select(index => nodes[index]).ToArray()); + } + return output; + } + + private static bool Adjacent(string left, string right) => + left != right && TouchAdjacency.TryGetValue(left, out var neighbors) && neighbors.Contains(right); + + private static IReadOnlyDictionary> BuildTouchAdjacency() + { + var graph = new Dictionary> { ["C"] = new HashSet() }; + foreach (var family in "ABDE") + for (var index = 1; index <= 8; index++) graph[$"{family}{index}"] = new HashSet(); + + void Connect(string left, string right) + { + graph[left].Add(right); + graph[right].Add(left); + } + int Ring(int index) => (index - 1 + 8) % 8 + 1; + for (var index = 1; index <= 8; index++) + { + var previous = Ring(index - 1); + var following = Ring(index + 1); + var a = $"A{index}"; + var b = $"B{index}"; + var d = $"D{index}"; + var e = $"E{index}"; + Connect(d, $"A{previous}"); Connect(d, a); + Connect(e, $"A{previous}"); Connect(e, a); + Connect(e, $"B{previous}"); Connect(e, b); + Connect(a, b); Connect(b, $"B{previous}"); Connect(b, $"B{following}"); Connect(b, "C"); + } + return graph; + } +} diff --git a/Runtime/Analysis/RadarAnalyzer.cs b/Runtime/Analysis/RadarAnalyzer.cs new file mode 100644 index 0000000..bbffcb3 --- /dev/null +++ b/Runtime/Analysis/RadarAnalyzer.cs @@ -0,0 +1,70 @@ +using MajRadar.Analysis.Features; +using MajRadar.Core; +using System.Threading; + +namespace MajRadar.Analysis; + +public sealed class RadarAnalyzer +{ + private const int MaximumChartEvents = 40_000; + // Keep this fixed execution/output order identical to ModelInputOrder. + // Display-axis selection belongs after scoring and must not edit this list. + private readonly IReadOnlyList<(string Name, IRadarFeatureAnalyzer Analyzer)> _features = + new (string, IRadarFeatureAnalyzer)[] + { + (RadarFeatureNames.Note, new NoteDensityAnalyzer()), + (RadarFeatureNames.Peak, new PeakDensityAnalyzer()), + (RadarFeatureNames.Sweep, new SweepBurstAnalyzer()), + (RadarFeatureNames.SlideTricky, new SlideTrickyAnalyzer()), + (RadarFeatureNames.SlideSequence, new SlideSequenceAnalyzer()), + (RadarFeatureNames.Jack, new JackSequenceAnalyzer()), + (RadarFeatureNames.SlideCumulate, new SlideCumulateAnalyzer()) + }; + + public RadarAnalysisResult Analyze( + RadarChartInput? chart, + CancellationToken cancellationToken = default) + { + var results = new Dictionary(); + if (cancellationToken.IsCancellationRequested) + return new RadarAnalysisResult { Features = results, IsCancelled = true }; + if (chart is null) + { + foreach (var feature in _features) + results[feature.Name] = RadarFeatureResult.Failure("Chart input is null."); + return new RadarAnalysisResult { Features = results }; + } + if (chart.Events.Count > MaximumChartEvents) + { + foreach (var feature in _features) + results[feature.Name] = RadarFeatureResult.Failure( + $"Chart event budget exceeded ({chart.Events.Count} > {MaximumChartEvents})."); + return new RadarAnalysisResult { Features = results }; + } + + var context = new AnalysisContext(chart, cancellationToken); + foreach (var feature in _features) + { + if (cancellationToken.IsCancellationRequested) + return new RadarAnalysisResult { Features = results, IsCancelled = true }; + try + { + var result = feature.Analyzer.Analyze(context); + if (result.IsSuccess && + (result.Value is null || double.IsNaN(result.Value.Value) || + double.IsInfinity(result.Value.Value))) + throw new InvalidOperationException("Successful feature returned a non-finite value."); + results[feature.Name] = result; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return new RadarAnalysisResult { Features = results, IsCancelled = true }; + } + catch (Exception exception) + { + results[feature.Name] = RadarFeatureResult.Failure(exception.Message); + } + } + return new RadarAnalysisResult { Features = results }; + } +} From 3521a7e14aae01caae076e50099836dd2e7b8309 Mon Sep 17 00:00:00 2001 From: SniperPigeon Date: Wed, 23 Sep 2026 16:56:51 +0800 Subject: [PATCH 03/10] feat: add fitted radar runtime and scoring --- Runtime/Regression/RegressionBetaModel.cs | 45 +++++ .../Regression/RegressionBetaParameters.cs | 43 +++++ Runtime/Runtime/RadarRuntime.cs | 167 ++++++++++++++++++ Runtime/Scoring/RadarScoreMapper.cs | 95 ++++++++++ 4 files changed, 350 insertions(+) create mode 100644 Runtime/Regression/RegressionBetaModel.cs create mode 100644 Runtime/Regression/RegressionBetaParameters.cs create mode 100644 Runtime/Runtime/RadarRuntime.cs create mode 100644 Runtime/Scoring/RadarScoreMapper.cs diff --git a/Runtime/Regression/RegressionBetaModel.cs b/Runtime/Regression/RegressionBetaModel.cs new file mode 100644 index 0000000..37c9a04 --- /dev/null +++ b/Runtime/Regression/RegressionBetaModel.cs @@ -0,0 +1,45 @@ +using MajRadar.Analysis; + +namespace MajRadar.Regression; + +public sealed class RegressionBetaModel +{ + public IReadOnlyList InputFeatures => RadarFeatureNames.ModelInputOrder; + + public double Predict(IReadOnlyList rawValues) + { + if (rawValues.Count != RegressionBetaParameters.Center.Length) + throw new ArgumentException("Regression input must contain all seven raw features.", nameof(rawValues)); + var normalized = new double[rawValues.Count]; + for (var index = 0; index < rawValues.Count; index++) + { + var value = rawValues[index]; + if (double.IsNaN(value) || double.IsInfinity(value)) + throw new ArgumentException("Regression input must be finite.", nameof(rawValues)); + normalized[index] = (value - RegressionBetaParameters.Center[index]) / + RegressionBetaParameters.Scale[index]; + } + + var coefficients = RegressionBetaParameters.Coefficients; + var coefficientIndex = 0; + var result = RegressionBetaParameters.Intercept; + for (var index = 0; index < normalized.Length; index++) + result += coefficients[coefficientIndex++] * normalized[index]; + for (var left = 0; left < normalized.Length; left++) + for (var right = left; right < normalized.Length; right++) + result += coefficients[coefficientIndex++] * normalized[left] * normalized[right]; + if (coefficientIndex != coefficients.Length || double.IsNaN(result) || double.IsInfinity(result)) + throw new InvalidOperationException("Regression prediction is invalid."); + return result; + } + + public double Predict(IReadOnlyDictionary rawFeatures) + { + var values = RadarFeatureNames.ModelInputOrder.Select(name => + rawFeatures.TryGetValue(name, out var value) + ? value + : throw new ArgumentException($"Missing regression feature '{name}'.", nameof(rawFeatures))) + .ToArray(); + return Predict(values); + } +} diff --git a/Runtime/Regression/RegressionBetaParameters.cs b/Runtime/Regression/RegressionBetaParameters.cs new file mode 100644 index 0000000..4787456 --- /dev/null +++ b/Runtime/Regression/RegressionBetaParameters.cs @@ -0,0 +1,43 @@ +namespace MajRadar.Regression; + +internal static class RegressionBetaParameters +{ + internal const string Version = "regression-beta"; + + // Every per-feature array index uses RadarFeatureNames.ModelInputOrder: + // Note, Peak, Sweep, SlideTricky, SlideSequence, Jack, SlideCumulate. + internal const double Intercept = 13.421390240489782; + + internal static readonly double[] Center = + { + 6.583066612466581, 9.6550952251217, 6.179626925449038, + 4.8343204736812755, 2.095301197972254, 6.329271451413314, + 0.38384054125629863 + }; + + internal static readonly double[] Scale = + { + 1.3511352743860408, 2.2844524358639355, 5.952295066152166, + 1.920381187287462, 0.8202885193649694, 7.968367752400153, + 0.2019909694784162 + }; + + // sklearn PolynomialFeatures(degree=2, include_bias=false): seven linear + // terms followed by z_i*z_j for 0 <= i <= j < 7. + internal static readonly double[] Coefficients = + { + 0.18812824296954617, 0.16507115796950486, 0.18470062769263107, + 0.14424114300159413, 0.07612279963773454, 0.18199524154559943, + 0.021410152639497, + 0.07929203833774148, 0.07031391382359192, -0.12072702513395878, + -0.08878269593588692, -0.060613684308699164, -0.02995220321852967, + -0.0318125496553048, -0.05473990607096268, 0.03380889133159353, + -0.002274497042358758, -0.00205546575535922, -0.022573192794332304, + 0.03068815804047847, -0.012124125812265968, 0.02343265494599222, + -0.003229825209071087, 0.0042885820040079, -0.010792486429865217, + -0.01462715803214476, 0.03310835199163244, 0.03227115208419505, + 0.008565722300080302, 0.026357315894005754, -0.005292245227171245, + -0.022818050009862954, -0.010127191115771803, -0.03823180888267009, + 0.017798691441253935 + }; +} diff --git a/Runtime/Runtime/RadarRuntime.cs b/Runtime/Runtime/RadarRuntime.cs new file mode 100644 index 0000000..3482d21 --- /dev/null +++ b/Runtime/Runtime/RadarRuntime.cs @@ -0,0 +1,167 @@ +using MajSimai; +using MajRadar.Analysis; +using MajRadar.Core; +using MajRadar.MajSimaiAdapter; +using MajRadar.Regression; +using MajRadar.Scoring; +using System.Threading; + +namespace MajRadar.Runtime; + +/// Stable public scalar projection for Play and other UI callers. +public static class RadarOutputDimensions +{ + public const string Note = RadarFeatureNames.Note; + public const string Peak = RadarFeatureNames.Peak; + public const string Sweep = RadarFeatureNames.Sweep; + public const string SlideTricky = RadarFeatureNames.SlideTricky; + public const string SlideSequence = RadarFeatureNames.SlideSequence; + public const string Jack = RadarFeatureNames.Jack; + public const string FittedConstant = RadarFeatureNames.FittedConstant; + + public static readonly IReadOnlyList DefaultOrder = Array.AsReadOnly(new[] + { + Note, Peak, Sweep, SlideTricky, SlideSequence, Jack, FittedConstant + }); + + internal static IReadOnlyDictionary EmptyValues() => + DefaultOrder.ToDictionary(name => name, _ => (double?)null); +} + +public sealed class RadarResult +{ + public RadarChartInput? ChartInput { get; set; } + public RadarAnalysisResult? Analysis { get; set; } + public double? FittedConstant { get; set; } + public IReadOnlyDictionary RawValues { get; set; } = + RadarOutputDimensions.EmptyValues(); + public IReadOnlyDictionary Scores { get; set; } = + RadarOutputDimensions.EmptyValues(); + public IReadOnlyList DimensionOrder { get; set; } = + RadarOutputDimensions.DefaultOrder; + public string ModelVersion { get; set; } = RegressionBetaParameters.Version; + public string? MappingVersion { get; set; } + public bool IsCancelled { get; set; } + public string Status => IsCancelled ? "cancelled" : IsSuccess ? "ok" : + Analysis?.IsSuccess == true ? "error" : Analysis?.Status ?? "error"; + public IReadOnlyList Errors { get; set; } = Array.Empty(); + public bool IsSuccess => ChartInput is not null && Analysis?.IsSuccess == true && + FittedConstant is not null && MappingVersion is not null && + Errors.Count == 0 && !IsCancelled; +} + +/// Unity-free public entry point for Play and standalone callers. +public sealed class RadarRuntime +{ + private readonly MajSimaiChartAdapter _adapter; + private readonly RadarAnalyzer _analyzer = new(); + private readonly RegressionBetaModel _model = new(); + private readonly RadarScoreMapper _scorer = new(); + + public RadarRuntime(IExtendedSlideBarCountProvider? extendedSlides = null) + { + _adapter = new MajSimaiChartAdapter(extendedSlides); + } + + public RadarResult Analyze( + SimaiChart? chart, + CancellationToken cancellationToken = default) + { + if (chart is null) + return Failure("SimaiChart is null."); + if (cancellationToken.IsCancellationRequested) + return Cancelled(); + var adapted = _adapter.Adapt(chart, cancellationToken); + if (adapted.IsCancelled) + return Cancelled(); + if (!adapted.IsSuccess) + return Failure(adapted.Errors); + return Analyze(adapted.Chart!, cancellationToken); + } + + public Task AnalyzeAsync( + SimaiChart? chart, + CancellationToken cancellationToken = default) => + Task.Run(() => Analyze(chart, cancellationToken)); + + public async Task ParseAndAnalyzeAsync( + string inote, + CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + return Cancelled(); + var adapted = await _adapter.ParseAndAdaptAsync(inote, cancellationToken).ConfigureAwait(false); + if (adapted.IsCancelled) + return Cancelled(); + if (!adapted.IsSuccess) + return Failure(adapted.Errors); + return Analyze(adapted.Chart!, cancellationToken); + } + + public RadarResult Analyze( + RadarChartInput? chart, + CancellationToken cancellationToken = default) + { + if (chart is null) + return Failure("RadarChartInput is null."); + var result = new RadarResult { ChartInput = chart }; + try + { + var analysis = _analyzer.Analyze(chart, cancellationToken); + result.Analysis = analysis; + if (analysis.IsCancelled) + { + result.IsCancelled = true; + return result; + } + + var rawValues = analysis.Features + .Where(pair => pair.Value.IsSuccess && pair.Value.Value is not null) + .ToDictionary(pair => pair.Key, pair => pair.Value.Value!.Value); + result.RawValues = Project(rawValues); + if (!analysis.IsSuccess) + { + result.Errors = analysis.Features + .Where(pair => !pair.Value.IsSuccess) + .Select(pair => $"{pair.Key}: {pair.Value.Error}") + .ToArray(); + return result; + } + var raw = RadarFeatureNames.ModelInputOrder + .Select(name => analysis.Features[name].Value!.Value).ToArray(); + result.FittedConstant = _model.Predict(raw); + rawValues[RadarFeatureNames.FittedConstant] = result.FittedConstant.Value; + result.RawValues = Project(rawValues); + result.Scores = Project(_scorer.Map(analysis, result.FittedConstant.Value)); + result.MappingVersion = RadarScoreMapper.MappingVersion; + return result; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + result.IsCancelled = true; + return result; + } + catch (Exception exception) + { + result.Errors = new[] { $"Radar regression or scoring failed: {exception.Message}" }; + return result; + } + } + + private static RadarResult Cancelled() => new() { IsCancelled = true }; + + private static RadarResult Failure(string error) => Failure(new[] { error }); + + private static RadarResult Failure(IReadOnlyList errors) => new() + { + Errors = errors + }; + + private static IReadOnlyDictionary Project( + IReadOnlyDictionary? source) => + RadarOutputDimensions.DefaultOrder.ToDictionary( + name => name, + name => source is not null && source.TryGetValue(name, out var value) + ? (double?)value + : null); +} diff --git a/Runtime/Scoring/RadarScoreMapper.cs b/Runtime/Scoring/RadarScoreMapper.cs new file mode 100644 index 0000000..070a6aa --- /dev/null +++ b/Runtime/Scoring/RadarScoreMapper.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using MajRadar.Analysis; + +#nullable enable + +namespace MajRadar.Scoring; + +/// Frozen visualizer mapping applied after raw analysis and regression. +internal sealed class RadarScoreMapper +{ + internal const string MappingVersion = "mapping-profile-2026-09-16T15-44-08-825Z"; + private const double MaximumScore = 250; + + // Fixed calibration for the seven raw dimensions. FittedConstant is appended + // unchanged. UI code may select axes only after this mapping step. + private static readonly IReadOnlyDictionary Dimensions = + new Dictionary + { + [RadarFeatureNames.Note] = new( + 5.726436880674509, 6.967085905412081, 7.8980899226483094, + 9.726945003226584, 11.24420363123459), + [RadarFeatureNames.Peak] = new( + 8.190000000000001, 10.08, 11.746367029516382, + 16.128373810879122, 22.568482830208243), + [RadarFeatureNames.Sweep] = new( + 2.118693243901749, 6.631886165225393, 11.445833613896523, + 25.019131254128578, 45.612758813473654), + [RadarFeatureNames.SlideTricky] = new( + 3.624219535832218, 5.025615522059357, 6.525702339111531, + 10.55540386715839, 16.901251108712703), + [RadarFeatureNames.SlideSequence] = new( + 1.5833333333333566, 2.353449955917629, 2.880594338282008, + 4.18329204883843, 5.959960226275402), + [RadarFeatureNames.Jack] = new( + 5.2091929612604035, 10.007454228271365, 16.321518687821236, + 55.493906948148926, 146.65442246453955), + [RadarFeatureNames.SlideCumulate] = new( + 0.2697357566988737, 0.4447430791696216, 0.556824272562115, + 0.9616680639691454, 1.6578864692145834) + }; + + internal IReadOnlyDictionary Map( + RadarAnalysisResult analysis, + double fittedConstant) + { + if (!analysis.IsSuccess) + throw new ArgumentException("Scoring requires all seven raw features.", nameof(analysis)); + if (double.IsNaN(fittedConstant) || double.IsInfinity(fittedConstant)) + throw new ArgumentOutOfRangeException(nameof(fittedConstant)); + + var values = new Dictionary(); + foreach (var name in RadarFeatureNames.ModelInputOrder) + values[name] = MapValue(analysis.Features[name].Value!.Value, Dimensions[name]); + values[RadarFeatureNames.FittedConstant] = fittedConstant; + return values; + } + + private static double MapValue(double value, Parameters parameters) + { + if (double.IsNaN(value) || double.IsInfinity(value)) + throw new ArgumentOutOfRangeException(nameof(value)); + if (value <= 0) return 0; + + var raw = parameters.Anchors; + var score = new[] { 50.0, 100.0, 150.0, 200.0 }; + var lowerRaw = 0.0; + var lowerScore = 0.0; + for (var index = 0; index < raw.Length; index++) + { + if (value <= raw[index]) + return lowerScore + (score[index] - lowerScore) * + (value - lowerRaw) / (raw[index] - lowerRaw); + lowerRaw = raw[index]; + lowerScore = score[index]; + } + if (value <= parameters.T4Maximum) return 200; + var initialSlope = 50 / (raw[3] - raw[2]); + var headroom = MaximumScore - 200; + return 200 + headroom * + (1 - Math.Exp(-initialSlope * (value - parameters.T4Maximum) / headroom)); + } + + private sealed class Parameters + { + internal Parameters(double t1, double t2, double t3, double t4, double t4Maximum) + { + Anchors = new[] { t1, t2, t3, t4 }; + T4Maximum = t4Maximum; + } + + internal double[] Anchors { get; } + internal double T4Maximum { get; } + } +} From 4f7f088d62bd1f5f3d99b6c3ac489cf513e41760 Mon Sep 17 00:00:00 2001 From: SniperPigeon Date: Wed, 23 Sep 2026 16:57:07 +0800 Subject: [PATCH 04/10] build: support Unity source and NuGet distribution --- .gitignore | 65 ++++--------------- Documentation~/MajdataPlay.md | 9 +++ MajRadar.csproj | 32 +++++++++ README.md | 50 +++++++++++++- Runtime/net.majdata.majradar.asmdef | 16 +++++ .../PlayExtendedSlideBarCountProvider.cs | 14 ++++ Samples~/MajdataPlay/README.md | 12 ++++ package.json | 18 +++++ 8 files changed, 163 insertions(+), 53 deletions(-) create mode 100644 Documentation~/MajdataPlay.md create mode 100644 MajRadar.csproj create mode 100644 Runtime/net.majdata.majradar.asmdef create mode 100644 Samples~/MajdataPlay/PlayExtendedSlideBarCountProvider.cs create mode 100644 Samples~/MajdataPlay/README.md create mode 100644 package.json diff --git a/.gitignore b/.gitignore index 3c335ae..d21159a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,4 @@ -# This .gitignore file should be placed at the root of your Unity project directory -# -# Get latest from https://github.com/github/gitignore/blob/main/Unity.gitignore -# -# Recommended: add any editor/OS/tool-specific ignore rules from the Global/ templates as needed. -# See: https://github.com/github/gitignore/tree/main/Global -# +# Unity-generated directories .utmp/ /[Ll]ibrary/ /[Tt]emp/ @@ -15,38 +9,19 @@ /[Uu]ser[Ss]ettings/ *.log -# By default unity supports Blender asset imports, *.blend1 blender files do not need to be commited to version control. *.blend1 *.blend1.meta - -# MemoryCaptures can get excessive in size. -# They also could contain extremely sensitive data /[Mm]emoryCaptures/ - -# Recordings can get excessive in size /[Rr]ecordings/ - -# Uncomment this line if you wish to ignore the asset store tools plugin -# /[Aa]ssets/AssetStoreTools* - -# Autogenerated Jetbrains Rider plugin /[Aa]ssets/Plugins/Editor/JetBrains* -# Jetbrains Rider personal-layer settings *.DotSettings.user -# Visual Studio cache directory +# Editor and generated project state .vs/ - -# Gradle cache directory .gradle/ - -# Autogenerated VS/MD/Consulo solution and project files ExportedObj/ .consulo/ -*.csproj *.unityproj -*.sln -*.slnx *.suo *.tmp *.user @@ -59,48 +34,34 @@ ExportedObj/ *.opendb *.VC.db -# Unity3D generated meta files *.pidb.meta *.pdb.meta *.mdb.meta - -# Unity3D generated file on crash reports sysinfo.txt - -# Mono auto generated files mono_crash.* -# Builds +# Unity build artifacts *.apk *.aab *.unitypackage *.unitypackage.meta *.app - -# Crashlytics generated file crashlytics-build.properties - -# TestRunner generated files InitTestScene*.unity* - -# Addressables default ignores, before user customizations /ServerData /[Aa]ssets/StreamingAssets/aa* /[Aa]ssets/AddressableAssetsData/link.xml* /[Aa]ssets/Addressables_Temp* -# By default, Addressables content builds will generate addressables_content_state.bin -# files in platform-specific subfolders, for example: -# /Assets/AddressableAssetsData/OSX/addressables_content_state.bin /[Aa]ssets/AddressableAssetsData/*/*.bin* - -# Visual Scripting auto-generated files -/[Aa]ssets/Unity.VisualScripting.Generated/VisualScripting.Flow/UnitOptions.db -/[Aa]ssets/Unity.VisualScripting.Generated/VisualScripting.Flow/UnitOptions.db.meta -/[Aa]ssets/Unity.VisualScripting.Generated/VisualScripting.Core/Property Providers -/[Aa]ssets/Unity.VisualScripting.Generated/VisualScripting.Core/Property Providers.meta - -# Auto-generated scenes by play mode tests +/[Aa]ssets/VisualScripting.Generated/ /[Aa]ssets/[Ii]nit[Tt]est[Ss]cene*.unity* - -# Auto-generated cache in Assets folder /[Aa]ssets/[Ss]ceneDependencyCache* + +# .NET build and test artifacts +bin/ +obj/ +TestResults/ +artifacts/ + +# OS files +.DS_Store diff --git a/Documentation~/MajdataPlay.md b/Documentation~/MajdataPlay.md new file mode 100644 index 0000000..b03ee1e --- /dev/null +++ b/Documentation~/MajdataPlay.md @@ -0,0 +1,9 @@ +# MajdataPlay integration + +MajdataPlay should pin this repository as `Assets/Plugins/MajRadar` and keep its +existing `Assets/Plugins/MajSimai` pin. `MajRadar.asmdef` references that one +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. diff --git a/MajRadar.csproj b/MajRadar.csproj new file mode 100644 index 0000000..ed9aac4 --- /dev/null +++ b/MajRadar.csproj @@ -0,0 +1,32 @@ + + + netstandard2.1 + latest + enable + enable + false + MajRadar + MajRadar + TeamMajdata.MajRadar + 0.1.0 + Team Majdata + Radar analysis and constant estimation for Simai charts. + GPL-3.0-only + README.md + https://github.com/TeamMajdata/MajRadar + git + true + + + + + + + + + + + + + + diff --git a/README.md b/README.md index eee5297..d390add 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,50 @@ # MajRadar -A radar analyser and constant estimationfor module for simai charts, inspired by BEMANI series games + +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. + +## Runtime API + +```csharp +var runtime = new RadarRuntime(); +RadarResult result = runtime.Analyze(existingSimaiChart, cancellationToken); +``` + +Extended `K` Slides need gameplay geometry supplied by the host: + +```csharp +var runtime = new RadarRuntime(new PlayExtendedSlideBarCountProvider()); +``` + +The provider receives a normalized SlideCode such as `1P6K7`. Ordinary Slides, +feature parameters, regression coefficients, and score mapping are built in. + +## MajdataPlay source integration + +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. + +## .NET and NuGet + +```sh +dotnet test MajRadar.slnx +dotnet pack MajRadar.csproj -c Release +``` + +The 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: + +```sh +dotnet test MajRadar.slnx \ + -p:MajSimaiProject=/absolute/path/to/MajSimai.csproj +``` + +Song discovery, metadata, audio offsets, visualizers, training, and experimental +pipelines intentionally remain outside this runtime component. diff --git a/Runtime/net.majdata.majradar.asmdef b/Runtime/net.majdata.majradar.asmdef new file mode 100644 index 0000000..2fa77cf --- /dev/null +++ b/Runtime/net.majdata.majradar.asmdef @@ -0,0 +1,16 @@ +{ + "name": "MajRadar", + "rootNamespace": "MajRadar", + "references": [ + "MajSimai" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Samples~/MajdataPlay/PlayExtendedSlideBarCountProvider.cs b/Samples~/MajdataPlay/PlayExtendedSlideBarCountProvider.cs new file mode 100644 index 0000000..4edda94 --- /dev/null +++ b/Samples~/MajdataPlay/PlayExtendedSlideBarCountProvider.cs @@ -0,0 +1,14 @@ +using MajdataPlay.Scenes.Game.Parsing; +using MajRadar.MajSimaiAdapter; + +namespace MajdataPlay.Utils.ChartRadar; + +/// Resolves extended Slide geometry with the same path used by gameplay. +internal sealed class PlayExtendedSlideBarCountProvider : IExtendedSlideBarCountProvider +{ + public int ResolveBarCount(string slideCode) + { + var path = SlideCodeParser.Parse(slideCode); + return SlideDataBuilder.BuildArrowData(path).Length - 2; + } +} diff --git a/Samples~/MajdataPlay/README.md b/Samples~/MajdataPlay/README.md new file mode 100644 index 0000000..9b68456 --- /dev/null +++ b/Samples~/MajdataPlay/README.md @@ -0,0 +1,12 @@ +# MajdataPlay integration + +Copy `PlayExtendedSlideBarCountProvider.cs` into the Play assembly and construct +one reusable runtime instance: + +```csharp +private readonly RadarRuntime _radar = + new(new PlayExtendedSlideBarCountProvider()); +``` + +Pass Play's existing `SimaiChart` to `_radar.Analyze(chart, token)`. Play remains +responsible for selection caching, cancellation ownership, logging, and UI. diff --git a/package.json b/package.json new file mode 100644 index 0000000..dd78ead --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "net.majdata.majradar", + "displayName": "MajRadar", + "version": "0.1.0", + "unity": "6000.0", + "description": "Radar analysis and constant estimation for Simai charts.", + "license": "GPL-3.0-only", + "dependencies": { + "dev.majdata.majsimai": "1.0.0" + }, + "samples": [ + { + "displayName": "MajdataPlay integration", + "description": "Extended Slide provider using MajdataPlay gameplay geometry.", + "path": "Samples~/MajdataPlay" + } + ] +} From c1850005fa3ba25ed02b4d0cda882b6d8c13cbd7 Mon Sep 17 00:00:00 2001 From: SniperPigeon Date: Wed, 23 Sep 2026 16:57:19 +0800 Subject: [PATCH 05/10] test: verify adapters runtime and package delivery --- .github/workflows/ci.yml | 42 ++ .github/workflows/publish.yml | 40 ++ MajRadar.slnx | 4 + Runtime/AssemblyInfo.cs | 3 + .../AnalysisAndRegressionTests.cs | 398 ++++++++++++++++++ Tests~/MajRadar.Tests/MajRadar.Tests.csproj | 21 + .../MajSimaiChartAdapterTests.cs | 357 ++++++++++++++++ 7 files changed, 865 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 MajRadar.slnx create mode 100644 Runtime/AssemblyInfo.cs create mode 100644 Tests~/MajRadar.Tests/AnalysisAndRegressionTests.cs create mode 100644 Tests~/MajRadar.Tests/MajRadar.Tests.csproj create mode 100644 Tests~/MajRadar.Tests/MajSimaiChartAdapterTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5149a72 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + pull_request: + push: + branches: + - dev + +jobs: + test-and-pack: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - run: dotnet restore MajRadar.slnx + - run: dotnet test MajRadar.slnx --no-restore -c Release + - run: dotnet pack MajRadar.csproj --no-restore -c Release -o artifacts + - uses: actions/upload-artifact@v4 + with: + name: MajRadar-nuget + path: artifacts/*.nupkg + + play-majsimai-pin: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: TeamMajdata/MajSimai + ref: fdb2a3e39d8997a0abbf8b4679062d854473cc77 + path: external/MajSimai + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - run: >- + dotnet restore MajRadar.slnx + -p:MajSimaiProject=${{ github.workspace }}/external/MajSimai/MajSimai.csproj + - run: >- + dotnet test MajRadar.slnx --no-restore -c Release + -p:MajSimaiProject=${{ github.workspace }}/external/MajSimai/MajSimai.csproj diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..dfcad46 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,40 @@ +name: Publish NuGet + +on: + push: + branches: + - main + tags: + - "v*" + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - id: version + name: Select package version + shell: bash + run: | + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + value="${GITHUB_REF_NAME#v}" + else + prefix="$(dotnet msbuild MajRadar.csproj -nologo -getProperty:VersionPrefix)" + value="${prefix}-ci.${GITHUB_RUN_NUMBER}" + fi + echo "value=$value" >> "$GITHUB_OUTPUT" + - run: dotnet restore MajRadar.slnx + - run: dotnet test MajRadar.slnx --no-restore -c Release + - run: >- + dotnet pack MajRadar.csproj --no-restore -c Release -o artifacts + -p:PackageVersion=${{ steps.version.outputs.value }} + - name: Publish package + run: >- + dotnet nuget push "artifacts/*.nupkg" + --api-key "${{ secrets.NUGET_API_KEY }}" + --source https://api.nuget.org/v3/index.json + --skip-duplicate diff --git a/MajRadar.slnx b/MajRadar.slnx new file mode 100644 index 0000000..b9701de --- /dev/null +++ b/MajRadar.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/Runtime/AssemblyInfo.cs b/Runtime/AssemblyInfo.cs new file mode 100644 index 0000000..304dd7a --- /dev/null +++ b/Runtime/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MajRadar.Tests")] diff --git a/Tests~/MajRadar.Tests/AnalysisAndRegressionTests.cs b/Tests~/MajRadar.Tests/AnalysisAndRegressionTests.cs new file mode 100644 index 0000000..22f5e86 --- /dev/null +++ b/Tests~/MajRadar.Tests/AnalysisAndRegressionTests.cs @@ -0,0 +1,398 @@ +using MajRadar.Analysis; +using MajRadar.Analysis.Features; +using MajRadar.Core; +using MajRadar.MajSimaiAdapter; +using MajRadar.Regression; +using MajRadar.Runtime; +using MajSimai; +using Xunit; + +namespace MajRadar.Runtime.Tests; + +public sealed class AnalysisAndRegressionTests +{ + [Theory] + [InlineData("(120){4}1,2,3,1,2,3,E", 2.0)] + [InlineData("(120){4}1/2/3/4/5/6,,,,,,E", 2.6)] + [InlineData("(120){4}1-5[4:1]*-5[4:1]*-5[4:1],E", 1.3333333333333333)] + public async Task NoteMatchesReviewedPythonCases(string inote, double expected) + { + var analysis = await Analyze(inote); + Assert.Equal(expected, analysis.Features[RadarFeatureNames.Note].Value!.Value, 12); + } + + [Fact] + public async Task PeakUsesFlatSlideAndHalfTouchGroups() + { + var adapted = await new MajSimaiChartAdapter().ParseAndAdaptAsync( + "(120){4}1?<1<1[4:2]/A1/E1,E"); + Assert.True(adapted.IsSuccess, string.Join("; ", adapted.Errors)); + var analysis = new RadarAnalyzer().Analyze(adapted.Chart!); + Assert.True(analysis.Features[RadarFeatureNames.Peak].IsSuccess); + Assert.Equal(0.3, analysis.Features[RadarFeatureNames.Peak].Value!.Value, 12); + } + + [Fact] + public async Task JackAppliesExWeightsBeforeTopK() + { + var analysis = await Analyze("(180){16}1/1x,1xh[16:1],1bx,E"); + Assert.Equal(2.47, analysis.Features[RadarFeatureNames.Jack].Value!.Value, 12); + } + + [Theory] + [InlineData("(180){16}1,2,3,E", 1.5)] + [InlineData("(180){16}2,3,2,3,E", 0.0)] + [InlineData("(180){16}3,4,56,7,8,E", 3.2)] + [InlineData("(180){16}73,84,15,26,37,48,51,26,37,E", 9.0)] + [InlineData("(180){16}5,6,7,8,1,27,36,45,E", 5.5)] + [InlineData("(180){16}1/2h[4:1],,3,4,E", 1.5)] + [InlineData("(180){16}1,2,3,8,1,2,E", 3.6)] + [InlineData("(180){16}1/1x,2x,3,E", 1.3)] + public async Task SweepMatchesReviewedPythonCases(string inote, double expected) + { + var analysis = await Analyze(inote); + Assert.Equal(expected, analysis.Features[RadarFeatureNames.Sweep].Value!.Value, 12); + } + + [Theory] + [InlineData("(180){16}1,28,37,46,5,46,37,28,1,E", "1,2,2,2,1,2,2,2,1")] + [InlineData("(180){16}1,2,3,4,51,26,37,E", "1,1,1,1,2,2,2")] + [InlineData("(180){16}18,27,36,45,6,7,8,E", "2,2,2,2,1,1,1")] + public async Task SweepRecognitionPreservesVariableWidthMainSpine( + string inote, string expectedWidths) + { + var adapted = await new MajSimaiChartAdapter().ParseAndAdaptAsync(inote); + Assert.True(adapted.IsSuccess, string.Join("; ", adapted.Errors)); + + var sequence = Assert.Single(SweepRecognizer.Recognize(adapted.Chart!.Events)); + + Assert.Equal(expectedWidths, + string.Join(",", sequence.Widths.Select(value => value.ToString()))); + } + + [Fact] + public void SweepHandMotionMatchesReviewedDynamicProgrammingCases() + { + var adjacent = SweepHandMotion.Calculate( + new[] { 0.0, 0.1, 0.2 }, + new IReadOnlyList[] { new[] { 1 }, new[] { 2 }, new[] { 3 } }); + Assert.Equal((2, 2, 0, 0, 0), ( + adjacent.TotalDistance, adjacent.ActiveDistance, adjacent.IdleDistance, + adjacent.FreeHandTakeovers, adjacent.FastJumpViolations)); + + var takeover = SweepHandMotion.Calculate( + new[] { 0.0, 0.1, 0.2 }, + new IReadOnlyList[] { new[] { 1 }, new[] { 2 }, new[] { 6 } }); + Assert.Equal((1, 1, 0, 1, 0), ( + takeover.TotalDistance, takeover.ActiveDistance, takeover.IdleDistance, + takeover.FreeHandTakeovers, takeover.FastJumpViolations)); + + var doubleSweep = SweepHandMotion.Calculate( + new[] { 0.0, 0.05 }, + new IReadOnlyList[] { new[] { 1, 5 }, new[] { 3, 7 } }); + Assert.Equal(2, doubleSweep.FastJumpViolations); + Assert.Equal(4, doubleSweep.TotalDistance); + } + + [Fact] + public async Task SweepLongRunAndPatternMotionMatchReviewedPythonCases() + { + var longBody = string.Join(",", Enumerable.Repeat("12345678", 3) + .SelectMany(item => item.Select(character => character.ToString()))); + var longRun = await Analyze($"(180){{16}}{longBody},E"); + Assert.Equal(9.852385066637913, + longRun.Features[RadarFeatureNames.Sweep].Value!.Value, 12); + + var patternBody = string.Join(",", Enumerable.Repeat( + new[] { "1,2,3,4", "8,7,6,5" }, 4).SelectMany(item => item)); + var adapted = await new MajSimaiChartAdapter().ParseAndAdaptAsync( + $"(180){{16}}{patternBody},E"); + Assert.True(adapted.IsSuccess, string.Join("; ", adapted.Errors)); + var result = SweepBurstAnalyzer.Score( + adapted.Chart!.Events, + Math.Max(adapted.Chart.ChartEndTimeSeconds, + adapted.Chart.LastEventEndTimeSeconds ?? 0)); + Assert.Equal(12.75, result.Value, 12); + Assert.Equal(12.0, result.BaseDensity, 12); + Assert.Equal(0.75, result.MotionDensity, 12); + Assert.Equal(7.5, result.RawMotionDensity, 12); + } + + [Fact] + public void SweepSelectionHandlesManyIndependentFamiliesWithoutRecursion() + { + var events = new List(); + for (var group = 0; group < 3_333; group++) + for (var offset = 0; offset < 3; offset++) + { + var beat = new BeatPosition(group * 8 + offset, 4); + events.Add(ButtonEvent(events.Count + 1, offset + 1, beat)); + } + + var sequences = SweepRecognizer.Recognize(events); + + Assert.Equal(3_333, sequences.Count); + } + + [Fact] + public void TenThousandEventContinuousSweepCompletesWithBoundedState() + { + var events = Enumerable.Range(0, 10_000) + .Select(index => ButtonEvent( + index + 1, index % 8 + 1, new BeatPosition(index, 4))) + .ToArray(); + var chart = new RadarChartInput + { + Events = events, + ChartEndTimeSeconds = events[^1].StartTimeSeconds + 1, + LastEventEndTimeSeconds = events[^1].EndTimeSeconds + }; + + var result = new RadarAnalyzer().Analyze(chart); + + Assert.Equal("ok", result.Status); + Assert.True(result.Features[RadarFeatureNames.Note].IsSuccess); + Assert.True(result.Features[RadarFeatureNames.Sweep].IsSuccess); + } + + [Theory] + [InlineData("(120){16}1-5[10:1],2,3,4,5,E", 3.5033834823231462)] + [InlineData("(120){4}1-5[10:1],A1,E", 0.7500000000000001)] + [InlineData("(120){4}1-5[10:1]/2-6[10:1]/3,,E", 0.5)] + public async Task SlideCumulateMatchesReviewedPythonCases(string inote, double expected) + { + var analysis = await Analyze(inote); + Assert.Equal(expected, analysis.Features[RadarFeatureNames.SlideCumulate].Value!.Value, 12); + } + + [Theory] + [InlineData("(120){8}1-5[10:1],2-6[10:1],E", 0.6783204105472322)] + [InlineData("(120){4}1-5[10:1]/2-6[10:1],E", 0.16958010263680806)] + [InlineData("(120){16}1-5[10:1],,2-6[10:1],3-7[10:1],,4-8[10:1],E", 0.5652670087893602)] + public async Task SlideSequenceMatchesReviewedPythonCases(string inote, double expected) + { + var analysis = await Analyze(inote); + Assert.Equal(expected, analysis.Features[RadarFeatureNames.SlideSequence].Value!.Value, 12); + } + + [Theory] + [InlineData("(120){16}1-5[10:1],2,3,4,5,E", 1.5262209237312725)] + [InlineData("(120){4}1-5[10:1],A1,E", 0.25437015395521206)] + [InlineData("(120){16}1-5[10:1],2,3,4,5,6,7,E", 1.9784345307627607)] + [InlineData("(120){4}1-5[10:1]/2-6[10:1],3,E", 0.19501711803232927)] + public async Task SlideTrickyMatchesReviewedPythonCases(string inote, double expected) + { + var analysis = await Analyze(inote); + Assert.Equal(expected, analysis.Features[RadarFeatureNames.SlideTricky].Value!.Value, 10); + } + + [Fact] + public async Task PublicRuntimeReturnsAllSevenFeaturesAndFittedConstant() + { + var result = await new RadarRuntime().ParseAndAnalyzeAsync("(120){4}1,2,E"); + Assert.True(result.IsSuccess, string.Join("; ", result.Errors)); + Assert.NotNull(result.Analysis); + Assert.Equal("ok", result.Analysis!.Status); + Assert.Equal(7, result.Analysis.Features.Count); + Assert.NotNull(result.FittedConstant); + Assert.Equal(RadarOutputDimensions.DefaultOrder, result.DimensionOrder); + Assert.Equal(RadarOutputDimensions.DefaultOrder, result.RawValues.Keys); + Assert.Equal(RadarOutputDimensions.DefaultOrder, result.Scores.Keys); + Assert.All(result.RawValues.Values, value => Assert.NotNull(value)); + Assert.All(result.Scores.Values, value => Assert.NotNull(value)); + Assert.False(string.IsNullOrWhiteSpace(result.ModelVersion)); + Assert.False(string.IsNullOrWhiteSpace(result.MappingVersion)); + Assert.Empty(result.Errors); + } + + [Fact] + public async Task PublicRuntimeReturnsCancelledDataWithoutThrowing() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + var result = await new RadarRuntime().ParseAndAnalyzeAsync( + "(120){4}1,2,E", cancellation.Token); + + Assert.False(result.IsSuccess); + Assert.True(result.IsCancelled); + Assert.Empty(result.Errors); + Assert.Null(result.FittedConstant); + } + + [Fact] + public async Task PublicAnalyzeAsyncReturnsCancelledDataWithoutFaultingTheTask() + { + var chart = await SimaiParser.ParseChartAsync("(120){4}1,2,E"); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + var result = await new RadarRuntime().AnalyzeAsync(chart, cancellation.Token); + + Assert.Equal("cancelled", result.Status); + Assert.True(result.IsCancelled); + Assert.Empty(result.Errors); + } + + [Fact] + public async Task PublicRuntimePassesNormalizedSlideCodeToInjectedGeometry() + { + var provider = new RecordingExtendedSlideProvider(); + + var result = await new RadarRuntime(provider) + .ParseAndAnalyzeAsync("(120){4}1P6K7[4:1],2,E"); + + Assert.True(result.IsSuccess, string.Join("; ", result.Errors)); + Assert.Equal("1P6K7", provider.SlideCode); + } + + [Fact] + public async Task UngroupedNoHeadSlideContributesToIntensityButNotSlideGroupFeatures() + { + var adapted = await new MajSimaiChartAdapter().ParseAndAdaptAsync( + "(120){4}1?-5[4:1],E"); + Assert.True(adapted.IsSuccess, string.Join("; ", adapted.Errors)); + var slide = Assert.Single(adapted.Chart!.Events, + item => item.Kind == RadarEventKind.Slide); + Assert.Null(slide.SlideGroupId); + + var analysis = new RadarAnalyzer().Analyze(adapted.Chart); + + Assert.True(analysis.IsSuccess); + Assert.True(analysis.Features[RadarFeatureNames.Note].Value > 0); + Assert.Equal(0, analysis.Features[RadarFeatureNames.SlideTricky].Value); + Assert.Equal(0, analysis.Features[RadarFeatureNames.SlideSequence].Value); + Assert.Equal(0, analysis.Features[RadarFeatureNames.SlideCumulate].Value); + } + + [Theory] + [InlineData("(0){4}1,E", "non-finite or non-positive")] + [InlineData("(120){4}1K5[4:1],E", "Extended K Slides")] + public async Task AdaptationFailuresReturnDataWithoutLeakingExceptions( + string inote, + string expectedError) + { + RadarResult? result = null; + var exception = await Record.ExceptionAsync(async () => + result = await new RadarRuntime().ParseAndAnalyzeAsync(inote)); + + Assert.Null(exception); + Assert.NotNull(result); + Assert.False(result!.IsSuccess); + Assert.Null(result.ChartInput); + Assert.Null(result.Analysis); + Assert.Contains(expectedError, Assert.Single(result.Errors)); + } + + [Theory] + [InlineData("(120){4}bad,E")] + public async Task EmptyMajSimaiOutputIsUnavailableWithoutClaimingParseDiagnostics(string inote) + { + var result = await new RadarRuntime().ParseAndAnalyzeAsync(inote); + + Assert.False(result.IsSuccess); + Assert.Null(result.ChartInput); + Assert.Null(result.Analysis); + Assert.Contains("no analyzable chart objects", Assert.Single(result.Errors)); + Assert.DoesNotContain( + result.Errors, + error => error.Contains("parse", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void FeatureFailuresAreIsolatedInsideRadarAnalyzer() + { + var chart = new RadarChartInput + { + ChartEndTimeSeconds = 1, + LastEventEndTimeSeconds = 1, + Events = new[] + { + new RadarEvent + { + EventId = 1, + Kind = RadarEventKind.Tap, + Position = "1", + StartBeat = BeatPosition.Zero, + EndBeat = BeatPosition.Zero + }, + new RadarEvent + { + EventId = 2, + Kind = RadarEventKind.Slide, + Position = "1", + SlideDeclareTimeSeconds = 0, + SlideDeclareBeat = BeatPosition.Zero, + StartTimeSeconds = 0.5, + EndTimeSeconds = 1, + StartBeat = new BeatPosition(1), + EndBeat = new BeatPosition(2), + SlideGroupId = 1, + SlidePath = Array.Empty() + } + } + }; + + var result = new RadarAnalyzer().Analyze(chart); + + Assert.Equal("partial", result.Status); + Assert.True(result.Features[RadarFeatureNames.Jack].IsSuccess); + Assert.False(result.Features[RadarFeatureNames.Note].IsSuccess); + Assert.False(result.Features[RadarFeatureNames.SlideTricky].IsSuccess); + } + + [Theory] + [MemberData(nameof(RegressionVectors))] + public void EmbeddedRegressionMatchesFrozenPythonVectors(double[] raw, double expected) + { + var actual = new RegressionBetaModel().Predict(raw); + Assert.Equal(expected, actual, 9); + } + + public static IEnumerable RegressionVectors() + { + yield return Vector(new[] { 6.727708533441772, 10.213333333333333, 0.0, 3.500000000000045, 2.3435703486210016, 5.223136339994041, 0.32687803060913356 }, 13.18826234659077); + yield return Vector(new[] { 5.3448652850017435, 9.373333333333333, 5.151860505357921, 3.8688372913206157, 1.527537949608422, 3.4463156768134153, 0.5098107142089721 }, 13.04867082557079); + yield return Vector(new[] { 6.556341447489688, 9.728309501643864, 2.7754445459480404, 4.146342997793008, 1.977721867042476, 10.365354504785971, 0.276398788581657 }, 13.335359785629928); + yield return Vector(new[] { 4.518910180239341, 7.165237190142859, 3.5146923810362276, 6.267413072276536, 1.4699399122083654, 2.9498185823014618, 0.5787667794900735 }, 13.074927456441534); + yield return Vector(new[] { 8.675146946575055, 12.596563941258626, 14.182273623300125, 3.7269030666426044, 1.8412214862620846, 3.4609545033032254, 0.5401266930336726 }, 14.174345298608007); + yield return Vector(new[] { 5.18055031687776, 7.697457300476198, 0.0, 4.252135533296645, 1.6280299529891435, 4.9236758330127754, 0.23376260698583867 }, 12.682036829185888); + yield return Vector(new[] { 5.78473668281803, 8.333333333333334, 2.1322249739613124, 5.21319786300373, 1.4959570282923484, 27.349477481658905, 0.50129663471255 }, 13.52754748578909); + yield return Vector(new[] { 5.43961412987999, 9.366666666666665, 0.0, 5.318180151048526, 2.5515716996273325, 3.307739064132819, 0.20708790434230476 }, 13.012286585333174); + } + + private static object[] Vector(double[] raw, double expected) => new object[] { raw, expected }; + + private static RadarEvent ButtonEvent(int eventId, int lane, BeatPosition beat) + { + var time = beat.ToDouble() / 3; + return new RadarEvent + { + EventId = eventId, + Kind = RadarEventKind.Tap, + Position = lane.ToString(), + StartBeat = beat, + EndBeat = beat, + StartTimeSeconds = time, + EndTimeSeconds = time + }; + } + + private static async Task Analyze(string inote) + { + var adapted = await new MajSimaiChartAdapter().ParseAndAdaptAsync(inote); + Assert.True(adapted.IsSuccess, string.Join("; ", adapted.Errors)); + return new RadarAnalyzer().Analyze(adapted.Chart!); + } + + private sealed class RecordingExtendedSlideProvider : IExtendedSlideBarCountProvider + { + internal string? SlideCode { get; private set; } + + public int ResolveBarCount(string slideCode) + { + SlideCode = slideCode; + return 22; + } + } +} diff --git a/Tests~/MajRadar.Tests/MajRadar.Tests.csproj b/Tests~/MajRadar.Tests/MajRadar.Tests.csproj new file mode 100644 index 0000000..4182d30 --- /dev/null +++ b/Tests~/MajRadar.Tests/MajRadar.Tests.csproj @@ -0,0 +1,21 @@ + + + net10.0 + enable + enable + false + true + MajRadar.Tests + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/Tests~/MajRadar.Tests/MajSimaiChartAdapterTests.cs b/Tests~/MajRadar.Tests/MajSimaiChartAdapterTests.cs new file mode 100644 index 0000000..bc7c721 --- /dev/null +++ b/Tests~/MajRadar.Tests/MajSimaiChartAdapterTests.cs @@ -0,0 +1,357 @@ +using MajRadar.Core; +using MajRadar.MajSimaiAdapter; +using Xunit; + +namespace MajRadar.MajSimaiAdapter.Tests; + +public sealed class MajSimaiChartAdapterTests +{ + [Fact] + public void BeatBinningRemovesFloatingPointNoise() + { + var beat = BeatPosition.SnapFromDouble( + 0.33333333333333326, + maxDenominator: 64, + tolerance: 1e-12); + + Assert.Equal("1/3", beat.ToString()); + } + + [Fact] + public void BeatBinningFallsBackToTheClosestAllowedSubdivision() + { + var beat = BeatPosition.SnapFromDouble( + 0.3141592653589793, + maxDenominator: 16, + tolerance: 0); + + Assert.Equal("5/16", beat.ToString()); + } + + [Fact] + public async Task MapsTypedMajSimaiOutputWithoutReparsingTheChart() + { + var adapter = new MajSimaiChartAdapter(); + + var result = await adapter.ParseAndAdaptAsync( + "(120){4},1bx-5[4:1]b*-7[4:2]m,1?-3[4:1],(180)B1,,E"); + + Assert.True(result.IsSuccess, string.Join("; ", result.Errors)); + var chart = Assert.IsType(result.Chart); + Assert.Equal(2.1666666666666665, chart.ChartEndTimeSeconds, 12); + Assert.Equal(2.0, chart.LastEventEndTimeSeconds); + + var slides = chart.Events.Where(item => item.Kind == RadarEventKind.Slide).ToArray(); + Assert.Equal(3, slides.Length); + Assert.NotNull(slides[0].HeadEventId); + Assert.Equal(slides[0].HeadEventId, slides[1].HeadEventId); + Assert.Null(slides[2].HeadEventId); + Assert.Equal(0.5, slides[0].SlideDeclareTimeSeconds); + Assert.Equal("2", slides[0].StartBeat.ToString()); + Assert.Equal("3", slides[0].EndBeat.ToString()); + Assert.True(slides[0].IsBreak); + Assert.True(slides[1].IsMine); + + var touch = Assert.Single(chart.Events, item => item.Kind == RadarEventKind.Touch); + Assert.Equal("B1", touch.Position); + Assert.Equal("3", touch.StartBeat.ToString()); + Assert.Equal(new[] { 120d, 180d }, chart.Events + .Where(item => item.Kind == RadarEventKind.Timing) + .Select(item => item.Bpm!.Value)); + } + + [Fact] + public async Task ReconstructsFractionalBeatsFromActualCommaTimes() + { + var adapter = new MajSimaiChartAdapter(); + + var result = await adapter.ParseAndAdaptAsync("(120){8}1,2,3h[8:1],,E"); + + Assert.True(result.IsSuccess, string.Join("; ", result.Errors)); + var objects = result.Chart!.Events.Where(item => item.Kind != RadarEventKind.Timing).ToArray(); + Assert.Equal(new[] { "0", "1/2", "1" }, objects.Select(item => item.StartBeat.ToString())); + Assert.Equal("3/2", objects[2].EndBeat.ToString()); + } + + [Fact] + public async Task BinsMajSimaiAccumulationAtAnUnfriendlyTempo() + { + var adapter = new MajSimaiChartAdapter(); + var slots = string.Join(",", Enumerable.Repeat(string.Empty, 48)); + + var result = await adapter.ParseAndAdaptAsync($"(137){{192}}1,{slots}2,E"); + + Assert.True(result.IsSuccess, string.Join("; ", result.Errors)); + var taps = result.Chart!.Events.Where(item => item.Kind == RadarEventKind.Tap).ToArray(); + Assert.Equal("0", taps[0].StartBeat.ToString()); + Assert.Equal("1", taps[1].StartBeat.ToString()); + } + + [Fact] + public async Task PreservesLeadingRestAndKeepsChartEndSeparateFromObjects() + { + var adapter = new MajSimaiChartAdapter(); + + var result = await adapter.ParseAndAdaptAsync("(120){4},,1h[4:1],,,E"); + + Assert.True(result.IsSuccess, string.Join("; ", result.Errors)); + var hold = Assert.Single(result.Chart!.Events, item => item.Kind == RadarEventKind.Hold); + Assert.Equal(1.0, hold.StartTimeSeconds); + Assert.Equal("2", hold.StartBeat.ToString()); + Assert.True(result.Chart.ChartEndTimeSeconds > result.Chart.LastEventEndTimeSeconds); + } + + [Fact] + public async Task EmptyChartReturnsUnavailableData() + { + var result = await new MajSimaiChartAdapter().ParseAndAdaptAsync("(120){4},,E"); + + Assert.False(result.IsSuccess); + Assert.False(result.IsCancelled); + Assert.Null(result.Chart); + Assert.Contains("no analyzable chart objects", Assert.Single(result.Errors)); + } + + [Fact] + public async Task CancellationReturnsDataWithoutParsing() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + var result = await new MajSimaiChartAdapter().ParseAndAdaptAsync( + "(120){4}1,E", cancellation.Token); + + Assert.False(result.IsSuccess); + Assert.True(result.IsCancelled); + Assert.Null(result.Chart); + Assert.Empty(result.Errors); + } + + [Fact] + public async Task IntegratesAContinuedObjectAcrossBpmChangesAndPastChartEnd() + { + var adapter = new MajSimaiChartAdapter(); + + var result = await adapter.ParseAndAdaptAsync("(120){4}1h[4:4],(240),,,,,E"); + + Assert.True(result.IsSuccess, string.Join("; ", result.Errors)); + var hold = Assert.Single(result.Chart!.Events, item => item.Kind == RadarEventKind.Hold); + Assert.Equal(2.0, hold.EndTimeSeconds); + Assert.Equal("7", hold.EndBeat.ToString()); + Assert.Equal(1.75, result.Chart.ChartEndTimeSeconds); + Assert.Equal(2.0, result.Chart.LastEventEndTimeSeconds); + } + + [Fact] + public async Task RepeatedEqualBpmDoesNotShiftPhaseOrCreateAnalysisNoise() + { + var adapter = new MajSimaiChartAdapter(); + + var result = await adapter.ParseAndAdaptAsync("(120){4}1,(120)2,E"); + + Assert.True(result.IsSuccess, string.Join("; ", result.Errors)); + var taps = result.Chart!.Events.Where(item => item.Kind == RadarEventKind.Tap).ToArray(); + Assert.Equal(new[] { "0", "1" }, taps.Select(item => item.StartBeat.ToString())); + Assert.Single(result.Chart.Events, item => item.Kind == RadarEventKind.Timing); + } + + [Fact] + public async Task MapsEveryNonSlideNoteKindAndItsOwnFlags() + { + var adapter = new MajSimaiChartAdapter(); + + var result = await adapter.ParseAndAdaptAsync("(120){4}A1h[4:1]/C/1b/2x/3m,E"); + + Assert.True(result.IsSuccess, string.Join("; ", result.Errors)); + var objects = result.Chart!.Events.Where(item => item.Kind != RadarEventKind.Timing).ToArray(); + Assert.Equal( + new[] + { + RadarEventKind.TouchHold, + RadarEventKind.Touch, + RadarEventKind.Tap, + RadarEventKind.Tap, + RadarEventKind.Tap + }, + objects.Select(item => item.Kind)); + Assert.Equal(new[] { "A1", "C", "1", "2", "3" }, objects.Select(item => item.Position)); + Assert.True(objects[2].IsBreak); + Assert.True(objects[3].IsEx); + Assert.True(objects[4].IsMine); + } + + [Fact] + public async Task CommentCommasDoNotAdvanceTheMajSimaiTimeline() + { + var adapter = new MajSimaiChartAdapter(); + + var result = await adapter.ParseAndAdaptAsync("(120){4}1,|| comment,with,commas\n2,E"); + + Assert.True(result.IsSuccess, string.Join("; ", result.Errors)); + var taps = result.Chart!.Events.Where(item => item.Kind == RadarEventKind.Tap).ToArray(); + Assert.Equal(new[] { "0", "1" }, taps.Select(item => item.StartBeat.ToString())); + } + + [Fact] + public async Task KeepsDuplicateDeclarations() + { + var adapter = new MajSimaiChartAdapter(); + + var result = await adapter.ParseAndAdaptAsync("(120){4}1/1,1-5[4:1]/1-5[4:1],E"); + + Assert.True(result.IsSuccess, string.Join("; ", result.Errors)); + Assert.Equal(2, result.Chart!.Events.Count(item => + item.Kind == RadarEventKind.Tap && item.StartTimeSeconds == 0)); + Assert.Equal(2, result.Chart.Events.Count(item => item.Kind == RadarEventKind.Slide)); + } + + [Fact] + public async Task PreservesAmbiguousNoHeadSlidesWithoutGuessingTheirGroup() + { + var adapter = new MajSimaiChartAdapter(); + + var result = await adapter.ParseAndAdaptAsync( + "(120){4}1-5[4:1]*-7[4:1]/1?-3[4:1],E"); + + Assert.True(result.IsSuccess, string.Join("; ", result.Errors)); + var slides = result.Chart!.Events.Where(item => item.Kind == RadarEventKind.Slide).ToArray(); + Assert.Equal(3, slides.Length); + Assert.Single(slides, item => item.SlideGroupId is not null); + Assert.Equal(2, slides.Count(item => item.SlideGroupId is null)); + } + + [Fact] + public async Task KeepsInterleavedIndependentNoHeadSlideGroupsSeparate() + { + var adapter = new MajSimaiChartAdapter(); + + var result = await adapter.ParseAndAdaptAsync( + "(120){4}4-2[4:1]/5-8[4:1]/4?-8[4:1]/5?-1[4:1],E"); + + Assert.True(result.IsSuccess, string.Join("; ", result.Errors)); + var slides = result.Chart!.Events.Where(item => item.Kind == RadarEventKind.Slide).ToArray(); + Assert.Equal(4, slides.Length); + Assert.Equal(2, slides.Count(item => item.SlideGroupId is not null)); + Assert.Equal(2, slides.Count(item => item.SlideGroupId is null)); + Assert.Equal(2, slides.Count(item => item.HeadEventId is null)); + } + + [Fact] + public async Task DistributesConnectedSlideTimeByFixedStandardBarCounts() + { + var adapter = new MajSimaiChartAdapter(); + + var result = await adapter.ParseAndAdaptAsync("(120){4}1-3-5[4:2],E"); + + Assert.True(result.IsSuccess, string.Join("; ", result.Errors)); + var slide = Assert.Single(result.Chart!.Events, item => item.Kind == RadarEventKind.Slide); + Assert.Equal(2, slide.SlidePath!.Count); + Assert.Equal(slide.StartTimeSeconds, slide.SlidePath[0].StartTimeSeconds); + Assert.Equal(slide.EndTimeSeconds, slide.SlidePath[1].EndTimeSeconds); + Assert.Equal(1.0, slide.SlidePath[0].EndTimeSeconds, 12); + Assert.Equal(slide.SlidePath[0].EndTimeSeconds, slide.SlidePath[1].StartTimeSeconds); + } + + [Theory] + [InlineData("1-3[4:1]", "-", 1, null, 3)] + [InlineData("1^3[4:1]", "^", 1, null, 3)] + [InlineData("1v3[4:1]", "v", 1, null, 3)] + [InlineData("1>3[4:1]", ">", 1, null, 3)] + [InlineData("1<7[4:1]", "<", 1, null, 7)] + [InlineData("1p3[4:1]", "p", 1, null, 3)] + [InlineData("1q7[4:1]", "q", 1, null, 7)] + [InlineData("1V75[4:1]", "V", 1, 7, 5)] + [InlineData("1pp5[4:1]", "pp", 1, null, 5)] + [InlineData("1qq5[4:1]", "qq", 1, null, 5)] + [InlineData("1s5[4:1]", "s", 1, null, 5)] + [InlineData("1z5[4:1]", "z", 1, null, 5)] + [InlineData("1w5[4:1]", "w", 1, null, 5)] + public void InterpretsOnlyTheSlidePathRetainedByMajSimai( + string rawContent, + string shape, + int start, + int? via, + int end) + { + var resolver = new SlidePathResolver(); + + var path = Assert.Single(resolver.Resolve(rawContent, 1.0, 2.0)); + + Assert.Equal(shape, path.Shape); + Assert.Equal(start, path.StartPosition); + Assert.Equal(via, path.ViaPosition); + Assert.Equal(end, path.EndPosition); + Assert.True(path.BarCount > 0); + } + + [Fact] + public async Task ExtendedSlideReturnsFailureWithoutThrowingAcrossThePublicBoundary() + { + var adapter = new MajSimaiChartAdapter(); + + var result = await adapter.ParseAndAdaptAsync("(120){4}1K5[4:1],E"); + + Assert.False(result.IsSuccess); + Assert.Null(result.Chart); + Assert.NotEmpty(result.Errors); + } + + [Fact] + public async Task ExtendedSlideUsesCallerSuppliedPlayGeometry() + { + var provider = new FixedExtendedSlideProvider(22); + var adapter = new MajSimaiChartAdapter(provider); + + var result = await adapter.ParseAndAdaptAsync("(120){4}1P6K7[4:1],E"); + + Assert.True(result.IsSuccess, string.Join("; ", result.Errors)); + Assert.Equal("1P6K7", provider.SlideCode); + var slide = Assert.Single(result.Chart!.Events, item => item.Kind == RadarEventKind.Slide); + var path = Assert.Single(slide.SlidePath!); + Assert.Equal("slidecode", path.Shape); + Assert.Equal(1, path.StartPosition); + Assert.Equal(7, path.EndPosition); + Assert.Equal(22, path.BarCount); + Assert.Equal(slide.StartTimeSeconds, path.StartTimeSeconds); + Assert.Equal(slide.EndTimeSeconds, path.EndTimeSeconds); + } + + [Fact] + public async Task ExtendedSlideRejectsNonPositiveProviderOutput() + { + var result = await new MajSimaiChartAdapter(new FixedExtendedSlideProvider(0)) + .ParseAndAdaptAsync("(120){4}1K5[4:1],E"); + + Assert.False(result.IsSuccess); + Assert.Contains("Non-positive arrow count", Assert.Single(result.Errors)); + } + + [Fact] + public async Task ExtendedSlideProviderExceptionStaysInsideTheAdaptationBoundary() + { + var result = await new MajSimaiChartAdapter(new ThrowingExtendedSlideProvider()) + .ParseAndAdaptAsync("(120){4}1K5[4:1],E"); + + Assert.False(result.IsSuccess); + Assert.Contains("geometry unavailable", Assert.Single(result.Errors)); + } + + private sealed class FixedExtendedSlideProvider : IExtendedSlideBarCountProvider + { + private readonly int _barCount; + internal FixedExtendedSlideProvider(int barCount) => _barCount = barCount; + internal string? SlideCode { get; private set; } + + public int ResolveBarCount(string slideCode) + { + SlideCode = slideCode; + return _barCount; + } + } + + private sealed class ThrowingExtendedSlideProvider : IExtendedSlideBarCountProvider + { + public int ResolveBarCount(string slideCode) => + throw new InvalidOperationException("geometry unavailable"); + } +} From f2254cf0013de475a2b88c27983c785ad51474e9 Mon Sep 17 00:00:00 2001 From: SniperPigeon Date: Wed, 23 Sep 2026 17:10:54 +0800 Subject: [PATCH 06/10] docs: define runtime concurrency and cancellation --- README.md | 125 +++++++++++++++++- .../IExtendedSlideBarCountProvider.cs | 2 + Runtime/Runtime/RadarRuntime.cs | 6 +- .../AnalysisAndRegressionTests.cs | 17 +++ 4 files changed, 143 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d390add..036cf5b 100644 --- a/README.md +++ b/README.md @@ -5,21 +5,134 @@ 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. -## Runtime API +## Runtime lifetime + +Create one `RadarRuntime` for the lifetime of the host radar service and reuse +it across chart selections. `RadarRuntime` does not cache charts or results and +does not retain per-analysis state. It is intentionally a normal object rather +than a global singleton so tests and other hosts can supply different extended +Slide implementations. + +```csharp +private readonly RadarRuntime _runtime = + new(new PlayExtendedSlideBarCountProvider()); +``` + +The same runtime may analyze multiple charts concurrently when both conditions +below hold: + +- the injected `IExtendedSlideBarCountProvider` is stateless and thread-safe; +- callers do not mutate a supplied `SimaiChart` or `RadarChartInput` while it is + being analyzed. + +`AnalyzeAsync` executes the synchronous analysis on the thread pool. A provider +used with it must not access Unity main-thread-only objects. Play's provider is +suitable because it creates a local Slide path and uses only pure geometry. + +## Calling the runtime + +Reuse an existing chart parsed by MajSimai whenever possible: ```csharp -var runtime = new RadarRuntime(); RadarResult result = runtime.Analyze(existingSimaiChart, cancellationToken); ``` -Extended `K` Slides need gameplay geometry supplied by the host: +Use the asynchronous wrapper when the caller must keep the Unity main thread +responsive: + +```csharp +RadarResult result = await _runtime.AnalyzeAsync( + existingSimaiChart, + cancellationToken); +``` + +Standalone callers that only have one inote string may use: + +```csharp +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. + +## Extended Slide dependency + +Extended `K` Slides need gameplay geometry supplied by the host. MajRadar keeps +the dependency narrow and does not reference MajdataPlay: + +```csharp +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. Without a provider, ordinary charts still work, +while a chart containing a `K` Slide returns an error instead of guessing. + +Ordinary Slides, feature parameters, regression coefficients, and score mapping +are built in and are not dependency-injected. + +## Cancellation and selection ownership + +Cancellation is cooperative and returned as data: + +```csharp +if (result.IsCancelled) + return; +``` + +- `Analyze` checks 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. +- `AnalyzeAsync` does not forcibly abort its worker thread; it returns a + `RadarResult` with `Status == "cancelled"` after the next observation point. +- MajSimai does not expose cancellation for an in-progress parse. Therefore + `ParseAndAnalyzeAsync` checks 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. + +## Result contract ```csharp -var runtime = new RadarRuntime(new PlayExtendedSlideBarCountProvider()); +if (result.IsSuccess) +{ + foreach (var dimension in result.DimensionOrder) + Render(dimension, result.Scores[dimension]); +} +else if (!result.IsCancelled) +{ + LogErrors(result.Errors); +} ``` -The provider receives a normalized SlideCode such as `1P6K7`. Ordinary Slides, -feature parameters, regression coefficients, and score mapping are built in. +- `Analysis.Features` contains all seven fixed model inputs, including internal + `slide_cumulate`. +- `RawValues` and `Scores` always use the shape-stable public order: six radar + axes plus `fitted_constant`; unavailable entries are `null`. +- `FittedConstant` and mapped scores are produced only when all seven raw + features succeed. +- `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 + scale. ## MajdataPlay source integration diff --git a/Runtime/MajSimaiAdapter/IExtendedSlideBarCountProvider.cs b/Runtime/MajSimaiAdapter/IExtendedSlideBarCountProvider.cs index 55b94ed..dbede2f 100644 --- a/Runtime/MajSimaiAdapter/IExtendedSlideBarCountProvider.cs +++ b/Runtime/MajSimaiAdapter/IExtendedSlideBarCountProvider.cs @@ -3,6 +3,8 @@ namespace MajRadar.MajSimaiAdapter; /// /// Supplies the gameplay arrow count for a normalized extended K Slide code. /// Standard Slides continue to use MajRadar's fixed reference table. +/// Implementations used by a shared RadarRuntime must be thread-safe and +/// must not access Unity main-thread-only objects. /// public interface IExtendedSlideBarCountProvider { diff --git a/Runtime/Runtime/RadarRuntime.cs b/Runtime/Runtime/RadarRuntime.cs index 3482d21..c368ec8 100644 --- a/Runtime/Runtime/RadarRuntime.cs +++ b/Runtime/Runtime/RadarRuntime.cs @@ -50,7 +50,11 @@ FittedConstant is not null && MappingVersion is not null && Errors.Count == 0 && !IsCancelled; } -/// Unity-free public entry point for Play and standalone callers. +/// +/// Unity-free public entry point for Play and standalone callers. Instances do +/// not retain chart or result state and may be reused concurrently when the +/// injected extended-Slide provider is thread-safe. +/// public sealed class RadarRuntime { private readonly MajSimaiChartAdapter _adapter; diff --git a/Tests~/MajRadar.Tests/AnalysisAndRegressionTests.cs b/Tests~/MajRadar.Tests/AnalysisAndRegressionTests.cs index 22f5e86..e183d12 100644 --- a/Tests~/MajRadar.Tests/AnalysisAndRegressionTests.cs +++ b/Tests~/MajRadar.Tests/AnalysisAndRegressionTests.cs @@ -246,6 +246,23 @@ public async Task PublicRuntimePassesNormalizedSlideCodeToInjectedGeometry() Assert.Equal("1P6K7", provider.SlideCode); } + [Fact] + public async Task OneRuntimeCanAnalyzeTheSameChartConcurrently() + { + var chart = await SimaiParser.ParseChartAsync( + "(180){16}1,2,3,4,5,6,7,8,1-5[8:1],E"); + var runtime = new RadarRuntime(); + + var results = await Task.WhenAll(Enumerable.Range(0, 16) + .Select(_ => runtime.AnalyzeAsync(chart))); + + Assert.All(results, result => + Assert.True(result.IsSuccess, string.Join("; ", result.Errors))); + foreach (var name in RadarOutputDimensions.DefaultOrder) + Assert.All(results.Skip(1), result => + Assert.Equal(results[0].RawValues[name], result.RawValues[name])); + } + [Fact] public async Task UngroupedNoHeadSlideContributesToIntensityButNotSlideGroupFeatures() { From 6e4d2e497fbf9c3c65bd06c9e1e8fdc2e2941acf Mon Sep 17 00:00:00 2001 From: SniperPigeon Date: Wed, 23 Sep 2026 17:16:06 +0800 Subject: [PATCH 07/10] docs: provide bilingual README --- README.md | 167 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 160 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 036cf5b..d18cd0b 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,164 @@ # MajRadar +[中文](#中文) | [English](#english) + + + +## 中文 + +MajRadar 是一个不依赖 Unity 的 Simai 谱面雷达分析和拟合定数估计组件。 +它接收 MajSimai 输出的类型化 `SimaiChart`,保留谱面相对时间,计算固定七维 +raw 特征,运行冻结的回归模型,并映射对外展示的雷达轴。 + +### Runtime 生命周期 + +建议由宿主的雷达服务创建一个 `RadarRuntime`,并在应用生命周期内跨选歌复用。 +`RadarRuntime` 不缓存谱面或结果,也不保留单次分析状态。它是普通对象而不是全局 +静态单例,便于测试和其他宿主注入不同的扩展 Slide 实现。 + +```csharp +private readonly RadarRuntime _runtime = + new(new PlayExtendedSlideBarCountProvider()); +``` + +满足以下条件时,同一 Runtime 可以并发分析多张谱面: + +- 注入的 `IExtendedSlideBarCountProvider` 无状态且线程安全; +- 分析期间调用方不修改传入的 `SimaiChart` 或 `RadarChartInput`。 + +`AnalyzeAsync` 在线程池运行同步分析。与它一起使用的 provider 不能访问仅限 Unity +主线程的对象。Play provider 每次创建局部 Slide 路径,并且只调用纯几何计算,符合此约束。 + +### 调用 Runtime + +已有 MajSimai 解析结果时,优先复用现成的谱面: + +```csharp +RadarResult result = runtime.Analyze(existingSimaiChart, cancellationToken); +``` + +需要保持 Unity 主线程响应时,使用异步包装: + +```csharp +RadarResult result = await _runtime.AnalyzeAsync( + existingSimaiChart, + cancellationToken); +``` + +独立调用方只有一段 inote 文本时,可以使用: + +```csharp +RadarResult result = await _runtime.ParseAndAnalyzeAsync( + inote, + cancellationToken); +``` + +`ParseAndAnalyzeAsync` 只是便利边界,不是歌曲加载器。文件、歌曲 metadata、谱面类型、 +曲绘和音频 offset 仍由调用方管理。 + +### Extended Slide 依赖 + +扩展 `K` Slide 的游戏几何由宿主提供。MajRadar 只保留一个窄接口,不引用 +MajdataPlay: + +```csharp +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 时,普通谱面仍可 +正常分析;遇到 `K` Slide 会返回错误,而不是猜测几何。 + +普通 Slide、特征参数、回归系数和 score 映射均内置,不参与依赖注入。 + +### 取消与选歌状态 + +取消采用合作式机制,并作为结果数据返回: + +```csharp +if (result.IsCancelled) + return; +``` + +- `Analyze` 在适配过程、各特征维度之间,以及高复杂度 Sweep 的候选、family、手部 + 动态规划和选择循环中检查 token。 +- 其他维度在特征或有界 section 边界检查。 +- `AnalyzeAsync` 不会强制终止工作线程;它会在下一个检查点返回 + `Status == "cancelled"` 的 `RadarResult`。 +- MajSimai 没有提供解析中途取消,因此 `ParseAndAnalyzeAsync` 会在解析前检查一次, + 并在 parser 返回后立即再次检查。 +- 同步 provider 应保持短小且有界,调用中途不会被取消。 + +宿主应为当前选歌持有一个 `CancellationTokenSource`。歌曲或难度变化时,取消上一份 +token、启动新分析,并丢弃 selection generation 已经过期的结果。仅取消还不够,因为旧任务 +可能恰好在新选歌后完成。 + +### 结果契约 + +```csharp +if (result.IsSuccess) +{ + foreach (var dimension in result.DimensionOrder) + Render(dimension, result.Scores[dimension]); +} +else if (!result.IsCancelled) +{ + LogErrors(result.Errors); +} +``` + +- `Analysis.Features` 包含模型使用的固定七维 raw,包括内部维度 `slide_cumulate`。 +- `RawValues` 和 `Scores` 始终保持固定公开形状:六个雷达轴加 + `fitted_constant`;不可用项为 `null`。 +- 只有七维 raw 全部成功时才生成 `FittedConstant` 和映射后的 score。 +- `partial` 会保留已完成的特征,但不会生成拟合定数。 +- `fitted_constant` 使用恒等映射,不属于雷达轴的 0–250 标度。 + +### MajdataPlay 源码接入 + +MajdataPlay 应在现有 MajSimai submodule 旁固定 MajRadar submodule。Unity 通过 +`MajRadar.asmdef` 编译 `Runtime/`,该程序集引用项目内唯一的 `MajSimai` 程序集。 +同一个 Unity 项目中不要再安装 MajRadar NuGet 包。 + +### .NET 与 NuGet + +```sh +dotnet test MajRadar.slnx +dotnet pack MajRadar.csproj -c Release +``` + +NuGet 包以 `netstandard2.1` 为目标,并声明一个 MajSimai 包依赖。Pull Request 会执行 +构建与测试;main 分支 push 发布唯一的 CI 预发行版本,`v*` tag 发布正式版本。 + +兼容性测试可以直接引用与 Play pin 一致的 MajSimai 源码项目: + +```sh +dotnet test MajRadar.slnx \ + -p:MajSimaiProject=/absolute/path/to/MajSimai.csproj +``` + +歌曲发现、metadata、音频 offset、visualizer、训练和实验管线均明确留在 Runtime 组件之外。 + +--- + + + +## English + 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. -## Runtime lifetime +### Runtime lifetime Create one `RadarRuntime` for the lifetime of the host radar service and reuse it across chart selections. `RadarRuntime` does not cache charts or results and @@ -29,7 +182,7 @@ below hold: used with it must not access Unity main-thread-only objects. Play's provider is suitable because it creates a local Slide path and uses only pure geometry. -## Calling the runtime +### Calling the runtime Reuse an existing chart parsed by MajSimai whenever possible: @@ -57,7 +210,7 @@ RadarResult result = await _runtime.ParseAndAnalyzeAsync( `ParseAndAnalyzeAsync` is a convenience boundary, not a song loader. The caller continues to own files, song metadata, chart type, artwork, and audio offsets. -## Extended Slide dependency +### Extended Slide dependency Extended `K` Slides need gameplay geometry supplied by the host. MajRadar keeps the dependency narrow and does not reference MajdataPlay: @@ -82,7 +235,7 @@ while a chart containing a `K` Slide returns an error instead of guessing. Ordinary Slides, feature parameters, regression coefficients, and score mapping are built in and are not dependency-injected. -## Cancellation and selection ownership +### Cancellation and selection ownership Cancellation is cooperative and returned as data: @@ -109,7 +262,7 @@ 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. -## Result contract +### Result contract ```csharp if (result.IsSuccess) @@ -134,14 +287,14 @@ else if (!result.IsCancelled) - `fitted_constant` is identity-mapped and is not on the radar axes' 0-250 scale. -## MajdataPlay source integration +### MajdataPlay source integration 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. -## .NET and NuGet +### .NET and NuGet ```sh dotnet test MajRadar.slnx From 446e3f64077e13e8cd2e78179e55c6443d3240da Mon Sep 17 00:00:00 2001 From: SniperPigeon Date: Wed, 23 Sep 2026 17:28:49 +0800 Subject: [PATCH 08/10] docs: revise README --- README.md | 52 +++++++++++++++++----------------------------------- 1 file changed, 17 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index d18cd0b..7e0c36a 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,7 @@ ## 中文 -MajRadar 是一个不依赖 Unity 的 Simai 谱面雷达分析和拟合定数估计组件。 -它接收 MajSimai 输出的类型化 `SimaiChart`,保留谱面相对时间,计算固定七维 -raw 特征,运行冻结的回归模型,并映射对外展示的雷达轴。 +MajRadar 是一个独立的的Simai谱面雷达分析和拟合定数估计组件。通过分析`SimaiChart`格式的已解析谱面计算固定七维特征产生雷达轴和拟合定数。 ### Runtime 生命周期 @@ -45,7 +43,7 @@ RadarResult result = await _runtime.AnalyzeAsync( cancellationToken); ``` -独立调用方只有一段 inote 文本时,可以使用: +只有一段 inote 文本时,可以使用: ```csharp RadarResult result = await _runtime.ParseAndAnalyzeAsync( @@ -53,13 +51,9 @@ RadarResult result = await _runtime.ParseAndAnalyzeAsync( cancellationToken); ``` -`ParseAndAnalyzeAsync` 只是便利边界,不是歌曲加载器。文件、歌曲 metadata、谱面类型、 -曲绘和音频 offset 仍由调用方管理。 +### Slidecode 依赖 -### Extended Slide 依赖 - -扩展 `K` Slide 的游戏几何由宿主提供。MajRadar 只保留一个窄接口,不引用 -MajdataPlay: +扩展Slidecode的长度算法需要依赖Play自身的Parser。 ```csharp internal sealed class PlayExtendedSlideBarCountProvider @@ -72,12 +66,9 @@ internal sealed class PlayExtendedSlideBarCountProvider } } ``` - -provider 接收规范化后的 SlideCode,例如 `1P6K7`,并且必须返回正数 arrow/bar count。 +provider只接受SlideCode,例如 `1P6K7`,并且必须返回正数 arrow/bar count。 provider 抛出的异常或非正结果会变成结构化适配失败。未提供 provider 时,普通谱面仍可 -正常分析;遇到 `K` Slide 会返回错误,而不是猜测几何。 - -普通 Slide、特征参数、回归系数和 score 映射均内置,不参与依赖注入。 +正常分析;遇到 `K` Slide 会返回错误 ### 取消与选歌状态 @@ -88,20 +79,15 @@ if (result.IsCancelled) return; ``` -- `Analyze` 在适配过程、各特征维度之间,以及高复杂度 Sweep 的候选、family、手部 - 动态规划和选择循环中检查 token。 -- 其他维度在特征或有界 section 边界检查。 -- `AnalyzeAsync` 不会强制终止工作线程;它会在下一个检查点返回 +- `AnalyzeAsync`会在下一个检查点返回 `Status == "cancelled"` 的 `RadarResult`。 -- MajSimai 没有提供解析中途取消,因此 `ParseAndAnalyzeAsync` 会在解析前检查一次, - 并在 parser 返回后立即再次检查。 -- 同步 provider 应保持短小且有界,调用中途不会被取消。 +- MajSimai 没有提供解析中途取消,因此 `ParseAndAnalyzeAsync` 只能parser 返回后立即再次检查。 +- 同步provider应调用中途不会被取消。 -宿主应为当前选歌持有一个 `CancellationTokenSource`。歌曲或难度变化时,取消上一份 -token、启动新分析,并丢弃 selection generation 已经过期的结果。仅取消还不够,因为旧任务 -可能恰好在新选歌后完成。 +应为当前选歌持有一个 `CancellationTokenSource`。歌曲或难度变化时,取消上一份 +token、启动新分析,并丢弃过期的结果。 -### 结果契约 +### 结果处理方式 ```csharp if (result.IsSuccess) @@ -115,13 +101,11 @@ else if (!result.IsCancelled) } ``` -- `Analysis.Features` 包含模型使用的固定七维 raw,包括内部维度 `slide_cumulate`。 -- `RawValues` 和 `Scores` 始终保持固定公开形状:六个雷达轴加 +- `Analysis.Features` 包含模型使用的固定七维,包括内部维度 `slide_cumulate`。 +- `RawValues` 和 `Scores` 始终保持公开形状:六个雷达轴加 `fitted_constant`;不可用项为 `null`。 - 只有七维 raw 全部成功时才生成 `FittedConstant` 和映射后的 score。 - `partial` 会保留已完成的特征,但不会生成拟合定数。 -- `fitted_constant` 使用恒等映射,不属于雷达轴的 0–250 标度。 - ### MajdataPlay 源码接入 MajdataPlay 应在现有 MajSimai submodule 旁固定 MajRadar submodule。Unity 通过 @@ -135,17 +119,15 @@ dotnet test MajRadar.slnx dotnet pack MajRadar.csproj -c Release ``` -NuGet 包以 `netstandard2.1` 为目标,并声明一个 MajSimai 包依赖。Pull Request 会执行 -构建与测试;main 分支 push 发布唯一的 CI 预发行版本,`v*` tag 发布正式版本。 - -兼容性测试可以直接引用与 Play pin 一致的 MajSimai 源码项目: +NuGet包以 `netstandard2.1` 为目标,包含MajSimai包依赖。Pull Request 会执行 +构建与测试;main在push发布prerelease,在v标签发布正式版。 +兼容性测试可以直接引用与Play一致的 MajSimai 源码项目: ```sh dotnet test MajRadar.slnx \ -p:MajSimaiProject=/absolute/path/to/MajSimai.csproj ``` -歌曲发现、metadata、音频 offset、visualizer、训练和实验管线均明确留在 Runtime 组件之外。 --- From 694be62b6b2865091792cccfabdc693dbbce5754 Mon Sep 17 00:00:00 2001 From: SniperPigeon Date: Wed, 23 Sep 2026 17:31:29 +0800 Subject: [PATCH 09/10] docs: revise README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7e0c36a..f374f56 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ MajRadar 是一个独立的的Simai谱面雷达分析和拟合定数估计组件 建议由宿主的雷达服务创建一个 `RadarRuntime`,并在应用生命周期内跨选歌复用。 `RadarRuntime` 不缓存谱面或结果,也不保留单次分析状态。它是普通对象而不是全局 -静态单例,便于测试和其他宿主注入不同的扩展 Slide 实现。 - +静态单例,便于测试和其他宿主注入不同的扩展 Slide 实现。为了避免代码重复,MajSimai的拓展`Slidecode`星星的长度计算交给Play内部的组件负责,因而需要依赖注入。 +此Provider将同步到Play仓库里。 ```csharp private readonly RadarRuntime _runtime = new(new PlayExtendedSlideBarCountProvider()); From cbde4724117cebb54a93ad1553dff19c17fb8057 Mon Sep 17 00:00:00 2001 From: SniperPigeon Date: Wed, 23 Sep 2026 17:43:18 +0800 Subject: [PATCH 10/10] CI: new CI and nuget publishing --- .github/workflows/ci.yml | 37 +++++++++++++++++------ .github/workflows/publish.yml | 56 +++++++++++++++++++++++++++++++---- 2 files changed, 79 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5149a72..961c0fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,11 @@ on: push: branches: - dev + # publish.yml calls both jobs on the commit being published. + workflow_call: + +permissions: + contents: read jobs: test-and-pack: @@ -14,19 +19,29 @@ jobs: - uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.x - - run: dotnet restore MajRadar.slnx - - run: dotnet test MajRadar.slnx --no-restore -c Release - - run: dotnet pack MajRadar.csproj --no-restore -c Release -o artifacts - - uses: actions/upload-artifact@v4 + + - name: Restore + run: dotnet restore MajRadar.slnx + + - name: Test default dependencies + run: dotnet test MajRadar.slnx --no-restore -c Release + + - name: Verify package build + run: dotnet pack MajRadar.csproj --no-restore -c Release -o artifacts + + - name: Upload CI package + uses: actions/upload-artifact@v4 with: name: MajRadar-nuget path: artifacts/*.nupkg + if-no-files-found: error play-majsimai-pin: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/checkout@v4 + - name: Checkout pinned MajSimai + uses: actions/checkout@v4 with: repository: TeamMajdata/MajSimai ref: fdb2a3e39d8997a0abbf8b4679062d854473cc77 @@ -34,9 +49,13 @@ jobs: - uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.x - - run: >- + + - name: Restore with pinned MajSimai + run: >- dotnet restore MajRadar.slnx - -p:MajSimaiProject=${{ github.workspace }}/external/MajSimai/MajSimai.csproj - - run: >- + -p:MajSimaiProject="$GITHUB_WORKSPACE/external/MajSimai/MajSimai.csproj" + + - name: Test with pinned MajSimai + run: >- dotnet test MajRadar.slnx --no-restore -c Release - -p:MajSimaiProject=${{ github.workspace }}/external/MajSimai/MajSimai.csproj + -p:MajSimaiProject="$GITHUB_WORKSPACE/external/MajSimai/MajSimai.csproj" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index dfcad46..d00e434 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,20 +2,34 @@ name: Publish NuGet on: push: + # main pushes publish preview packages; v* tags select an explicit version. branches: - main tags: - "v*" workflow_dispatch: +permissions: + contents: read + jobs: + validate: + uses: ./.github/workflows/ci.yml + publish: + # Both CI jobs must pass before this job starts. + needs: validate runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.x + - id: version name: Select package version shell: bash @@ -24,17 +38,49 @@ jobs: value="${GITHUB_REF_NAME#v}" else prefix="$(dotnet msbuild MajRadar.csproj -nologo -getProperty:VersionPrefix)" + if [[ -z "$prefix" ]]; then + echo "::error::VersionPrefix is empty. Set it in MajRadar.csproj or Directory.Build.props." + exit 1 + fi value="${prefix}-ci.${GITHUB_RUN_NUMBER}" fi + if [[ -z "$value" ]]; then + echo "::error::The version tag must contain a NuGet version after v." + exit 1 + fi echo "value=$value" >> "$GITHUB_OUTPUT" - - run: dotnet restore MajRadar.slnx - - run: dotnet test MajRadar.slnx --no-restore -c Release - - run: >- + + - name: Restore + run: dotnet restore MajRadar.slnx + + - name: Build release package + env: + PACKAGE_VERSION: ${{ steps.version.outputs.value }} + run: >- dotnet pack MajRadar.csproj --no-restore -c Release -o artifacts - -p:PackageVersion=${{ steps.version.outputs.value }} + -p:PackageVersion="$PACKAGE_VERSION" + + - name: Upload versioned package + uses: actions/upload-artifact@v4 + with: + name: MajRadar-${{ steps.version.outputs.value }} + path: artifacts/*.nupkg + if-no-files-found: error + + # Repository variable NUGET_USER is your nuget.org username, not email. + # Register this repository and publish.yml in NuGet Trusted Publishing. + - name: NuGet login + uses: NuGet/login@v1 + id: nuget-login + with: + user: ${{ vars.NUGET_USER }} + - name: Publish package + env: + NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} + # An existing version is skipped on rerun; it is never overwritten. run: >- dotnet nuget push "artifacts/*.nupkg" - --api-key "${{ secrets.NUGET_API_KEY }}" + --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate