독립 검증 도구 추가와 앱 상태 책임 분리

This commit is contained in:
2026-09-06 18:09:11 +09:00
parent 0f0431de5a
commit 002a922e1b
62 changed files with 3454 additions and 2008 deletions
+4
View File
@@ -13,6 +13,10 @@
*.csproj *.csproj
*.sln *.sln
*.slnx *.slnx
!/Verification/**/*.csproj
/Verification/**/bin/
/Verification/**/obj/
/Verification/**/TestResults/
*.user *.user
*.userprefs *.userprefs
@@ -0,0 +1,37 @@
using System;
namespace ProjectQuint.Core.Release
{
[Serializable]
public sealed class ProjectQuintUpdateManifest
{
public int schemaVersion;
public string channel = string.Empty;
public bool available;
public string version = string.Empty;
public int buildNumber;
public string publishedAt = string.Empty;
public string downloadUrl = string.Empty;
public string notesUrl = string.Empty;
public string sha256 = string.Empty;
public long sizeBytes;
}
public readonly struct ProjectQuintUpdateCheckResult
{
public ProjectQuintUpdateCheckResult(
bool succeeded,
ProjectQuintUpdateManifest manifest,
string error)
{
Succeeded = succeeded;
Manifest = manifest;
Error = error ?? string.Empty;
}
public bool Succeeded { get; }
public ProjectQuintUpdateManifest Manifest { get; }
public string Error { get; }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9ccf2a029bb949a9a0962f8df10627a4
@@ -0,0 +1,35 @@
using System;
namespace ProjectQuint.Core.Release
{
public static class UpdatePolicy
{
public static readonly TimeSpan AutomaticCheckInterval = TimeSpan.FromHours(24);
public static bool IsUpdateAvailable(ProjectQuintUpdateManifest manifest) =>
manifest != null &&
manifest.available &&
manifest.buildNumber > ProjectQuintReleaseVersion.BuildNumber;
public static bool ShouldOffer(
ProjectQuintUpdateManifest manifest,
string skippedVersion,
bool manual) =>
IsUpdateAvailable(manifest) &&
(manual ||
!string.Equals(
manifest.version,
skippedVersion?.Trim(),
StringComparison.OrdinalIgnoreCase));
public static bool ShouldCheckAutomatically(long lastSuccessfulCheckUtcTicks, long nowUtcTicks)
{
if (lastSuccessfulCheckUtcTicks <= 0 || nowUtcTicks <= lastSuccessfulCheckUtcTicks)
return true;
return new TimeSpan(nowUtcTicks - lastSuccessfulCheckUtcTicks) >=
AutomaticCheckInterval;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8bcdf0d1dd20443ea0f4d55eb2954005
@@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using ProjectQuint.Core.Progression;
using ProjectQuint.Library;
using ProjectQuint.Persistence;
namespace ProjectQuint.Bootstrap
{
internal sealed class ProfileRatingThumbnailCatalog
{
private long _sourceSignature = long.MinValue;
public IReadOnlyList<SongLibraryEntry> Songs { get; private set; } = Array.Empty<SongLibraryEntry>();
public int Revision { get; private set; }
public void Refresh(IReadOnlyList<RatingSummary> contexts,
IReadOnlyList<SongLibraryEntry> activeSongs, int songsRevision, BmsPackRegistry registry)
{
var requestedChartIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var context in contexts ?? Array.Empty<RatingSummary>())
{
foreach (var entry in context.BestPlays ?? Array.Empty<RatingChartEntry>())
{
if (!string.IsNullOrWhiteSpace(entry.ChartId))
requestedChartIds.Add(entry.ChartId);
}
}
var signature = 17L;
unchecked
{
signature = signature * 31 + (songsRevision);
foreach (var chartId in requestedChartIds.OrderBy(id => id, StringComparer.Ordinal))
signature = signature * 31 + StringComparer.Ordinal.GetHashCode(chartId);
foreach (var pack in registry?.Packs ?? Array.Empty<BmsPackDefinition>())
{
var cachePath = registry.CachePathFor(pack);
var cache = new FileInfo(cachePath);
signature = signature * 31 + StringComparer.OrdinalIgnoreCase.GetHashCode(pack.Id ?? string.Empty);
signature = signature * 31 + (cache.Exists ? cache.Length : 0L);
signature = signature * 31 + (cache.Exists ? cache.LastWriteTimeUtc.Ticks : 0L);
}
}
if (signature == _sourceSignature)
return;
var songsByChartId = new Dictionary<string, SongLibraryEntry>(StringComparer.Ordinal);
void AddSong(SongLibraryEntry song)
{
if (song == null)
return;
var chartId = ChartIdentityResolver.Resolve(song).Value;
if (requestedChartIds.Contains(chartId) && !songsByChartId.ContainsKey(chartId))
songsByChartId.Add(chartId, song);
}
foreach (var song in activeSongs ?? Array.Empty<SongLibraryEntry>())
AddSong(song);
foreach (var pack in registry?.Packs ?? Array.Empty<BmsPackDefinition>())
{
foreach (var song in SongLibraryScanner.LoadCachedThumbnailEntries(
registry.CachePathFor(pack),
pack.ContentRoot,
pack.Id,
requestedChartIds))
AddSong(song);
}
Songs = songsByChartId.Values.ToArray();
_sourceSignature = signature;
unchecked
{
Revision++;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: eaab54db67674a42ba9d968467641b78
@@ -11,7 +11,9 @@ using ProjectQuint.Features.Gameplay;
using ProjectQuint.Features.Library; using ProjectQuint.Features.Library;
using ProjectQuint.Features.Loading; using ProjectQuint.Features.Loading;
using ProjectQuint.Features.Results; using ProjectQuint.Features.Results;
using ProjectQuint.Features.Profile;
using ProjectQuint.Features.Settings; using ProjectQuint.Features.Settings;
using ProjectQuint.Features.Updates;
using ProjectQuint.Audio; using ProjectQuint.Audio;
using ProjectQuint.Core.Bms; using ProjectQuint.Core.Bms;
using ProjectQuint.Core.Charts; using ProjectQuint.Core.Charts;
@@ -138,31 +140,10 @@ namespace ProjectQuint.Bootstrap
private Texture2D _profileImageTexture; private Texture2D _profileImageTexture;
private string _profileImageStatus = string.Empty; private string _profileImageStatus = string.Empty;
private string _profileTransferStatus = "PROFILE ARCHIVE READY"; private string _profileTransferStatus = "PROFILE ARCHIVE READY";
private string _updateStatus = "NOT CHECKED"; private readonly UpdateSessionController _updateSession = new UpdateSessionController();
private ProjectQuintUpdateManifest _availableUpdate;
private bool _automaticUpdateCheckStarted;
private bool _updateCheckRunning;
private bool _updateOfferPending;
private bool _updateModalOpen;
private bool _updateDismissedThisRun;
private int _updateModalSelection;
private bool _profileImportConfirmationPending; private bool _profileImportConfirmationPending;
private ProfileProgressSnapshot _profileSnapshot; private readonly ProfileSessionController _profileSession = new ProfileSessionController();
private RatingSummary _homeRatingSummary; private readonly ProfileRatingThumbnailCatalog _profileThumbnails = new ProfileRatingThumbnailCatalog();
private RatingSummary _profileRatingSummary;
private IReadOnlyList<RatingSummary> _ratingContexts = Array.Empty<RatingSummary>();
private int _profileRatingContextIndex;
private IReadOnlyList<SongLibraryEntry> _profileRatingThumbnailSongs =
Array.Empty<SongLibraryEntry>();
private int _profileRatingThumbnailSongsRevision;
private long _profileRatingThumbnailSourceSignature = long.MinValue;
private ProfileCollectionSnapshot _profileCollection = ProfileCollectionSnapshot.Empty;
private ProfileCourseSnapshot _profileCourses = ProfileCourseSnapshot.Empty;
private bool _profileModalOpen;
private int _profileModalTab;
private int _profileBadgeSelection;
private int _profileCollectionColumn;
private readonly int[] _profileCollectionSelections = new int[3];
private string _libraryRoot = string.Empty; private string _libraryRoot = string.Empty;
private string _libraryLoadError = string.Empty; private string _libraryLoadError = string.Empty;
private bool _libraryReady; private bool _libraryReady;
@@ -205,8 +186,7 @@ namespace ProjectQuint.Bootstrap
private bool _autoScratchEnabled; private bool _autoScratchEnabled;
private bool _gameplayDiagnosticsEnabled; private bool _gameplayDiagnosticsEnabled;
// Compatibility accessors keep this first extraction behavior-neutral. Feature controllers // These accessors share the persisted settings object; they do not keep local copies.
// can receive GameSettings directly as they are split out in later refactoring stages.
private Key[] _laneKeys => _settings.LaneKeys; private Key[] _laneKeys => _settings.LaneKeys;
private Key _centerAuxKey => _settings.CenterAuxKey; private Key _centerAuxKey => _settings.CenterAuxKey;
private bool _reactiveKeySoundsEnabled { get => _settings.ReactiveKeySoundsEnabled; set => _settings.ReactiveKeySoundsEnabled = value; } private bool _reactiveKeySoundsEnabled { get => _settings.ReactiveKeySoundsEnabled; set => _settings.ReactiveKeySoundsEnabled = value; }
@@ -854,7 +834,7 @@ namespace ProjectQuint.Bootstrap
_libraryThumbnail = null; _libraryThumbnail = null;
_stageTexture = null; _stageTexture = null;
_profileSnapshot = currentProfile; _profileSession.SetPreviewSnapshot(currentProfile);
_resultSession.BeginPlayback(); _resultSession.BeginPlayback();
_resultSession.SetCommitOutcome(new PlayCommitOutcome(profileUpdate, ratingUpdate)); _resultSession.SetCommitOutcome(new PlayCommitOutcome(profileUpdate, ratingUpdate));
_resultSession.BeginResults( _resultSession.BeginResults(
@@ -872,9 +852,8 @@ namespace ProjectQuint.Bootstrap
{ {
var key = (surface ?? string.Empty).Trim().ToLowerInvariant(); var key = (surface ?? string.Empty).Trim().ToLowerInvariant();
var selectedSong = _libraryController?.State?.SelectedSong; var selectedSong = _libraryController?.State?.SelectedSong;
_updateModalOpen = false; _updateSession.Cancel();
_updateOfferPending = false; _profileSession.Close();
_profileModalOpen = false;
_packCacheManagerOpen = false; _packCacheManagerOpen = false;
_pinnedPackCacheConfirmationPending = false; _pinnedPackCacheConfirmationPending = false;
_packRemovalConfirmationPending = false; _packRemovalConfirmationPending = false;
@@ -894,7 +873,7 @@ namespace ProjectQuint.Bootstrap
_mode = AppMode.TeamDeniaBranding; _mode = AppMode.TeamDeniaBranding;
break; break;
case "update": case "update":
_availableUpdate = new ProjectQuintUpdateManifest _updateSession.ShowPreview(new ProjectQuintUpdateManifest
{ {
schemaVersion = 1, schemaVersion = 1,
channel = ProjectQuintReleaseVersion.ReleaseChannel, channel = ProjectQuintReleaseVersion.ReleaseChannel,
@@ -908,10 +887,7 @@ namespace ProjectQuint.Bootstrap
notesUrl = "/changelog/", notesUrl = "/changelog/",
sha256 = new string('a', 64), sha256 = new string('a', 64),
sizeBytes = 78_000_000, sizeBytes = 78_000_000,
}; });
_updateModalSelection = 0;
_updateOfferPending = false;
_updateModalOpen = true;
_mode = AppMode.Home; _mode = AppMode.Home;
break; break;
case "pack": case "pack":
@@ -1120,7 +1096,7 @@ namespace ProjectQuint.Bootstrap
HandleExternalAudioConfigurationChange(); HandleExternalAudioConfigurationChange();
TryStartAutomaticUpdateCheck(); TryStartAutomaticUpdateCheck();
TryPresentPendingUpdate(); TryPresentPendingUpdate();
if (_updateModalOpen) if (_updateSession.IsModalOpen)
{ {
UpdateUpdateNoticeInput(); UpdateUpdateNoticeInput();
FlushPendingLibraryViewState(force: false); FlushPendingLibraryViewState(force: false);
@@ -1194,15 +1170,15 @@ namespace ProjectQuint.Bootstrap
: Time.realtimeSinceStartupAsDouble - _teamDeniaBrandingEnteredRealtime; : Time.realtimeSinceStartupAsDouble - _teamDeniaBrandingEnteredRealtime;
_startupBrandingPresenter?.Update(_mode, startupElapsed); _startupBrandingPresenter?.Update(_mode, startupElapsed);
var homeVisible = _mode == AppMode.Home && !_profileModalOpen; var homeVisible = _mode == AppMode.Home && !_profileSession.IsOpen;
_homeScreenPresenter?.SetVisible(homeVisible); _homeScreenPresenter?.SetVisible(homeVisible);
_updateNoticePresenter?.SetVisible(_updateModalOpen); _updateNoticePresenter?.SetVisible(_updateSession.IsModalOpen);
if (_updateModalOpen && _availableUpdate != null) if (_updateSession.IsModalOpen && _updateSession.Manifest != null)
{ {
_updateNoticePresenter?.Update( _updateNoticePresenter?.Update(
_availableUpdate.version, _updateSession.Manifest.version,
_availableUpdate.buildNumber, _updateSession.Manifest.buildNumber,
_updateModalSelection, _updateSession.Selection,
_sparkleUpdater?.IsReady == true); _sparkleUpdater?.IsReady == true);
} }
@@ -1277,7 +1253,7 @@ namespace ProjectQuint.Bootstrap
_resultSession.RatingUpdate, _resultSession.RatingUpdate,
_resultSession.RecordSaveError, _resultSession.RecordSaveError,
_resultSession.PlayedUtcTicks, _resultSession.PlayedUtcTicks,
_profileSnapshot, _profileSession.Snapshot,
_resultsSelection, _resultsSelection,
Math.Max(0.0, Time.realtimeSinceStartupAsDouble - _resultEnteredRealtime), Math.Max(0.0, Time.realtimeSinceStartupAsDouble - _resultEnteredRealtime),
_gameplaySession?.IsAutoScratch == true); _gameplaySession?.IsAutoScratch == true);
@@ -1290,7 +1266,7 @@ namespace ProjectQuint.Bootstrap
_coursePassed, _coursePassed,
_courseTrophy, _courseTrophy,
_courseBadgeEarned, _courseBadgeEarned,
_profileSnapshot, _profileSession.Snapshot,
_courseResultSelection); _courseResultSelection);
break; break;
case AppMode.Error: case AppMode.Error:
@@ -1374,7 +1350,7 @@ namespace ProjectQuint.Bootstrap
? _catalogMatchedCharts ? _catalogMatchedCharts
: -1, : -1,
selectedTableDataAvailable, selectedTableDataAvailable,
_profileSnapshot, _profileSession.Snapshot,
_status, _status,
_packKeyModeModalOpen, _packKeyModeModalOpen,
_packKeyModeModalSelection, _packKeyModeModalSelection,
@@ -1439,7 +1415,7 @@ namespace ProjectQuint.Bootstrap
_gameplaySkin, _gameplaySkin,
_bgaPlaybackMode, _bgaPlaybackMode,
_reactiveKeySoundsEnabled, _reactiveKeySoundsEnabled,
_profileSnapshot, _profileSession.Snapshot,
library.ViewRevision); library.ViewRevision);
break; break;
} }
@@ -1453,7 +1429,7 @@ namespace ProjectQuint.Bootstrap
var optionsVisible = _mode == AppMode.Options; var optionsVisible = _mode == AppMode.Options;
var profileVisible = var profileVisible =
_profileModalOpen && _profileSession.IsOpen &&
(_mode == AppMode.Home || _mode == AppMode.Library); (_mode == AppMode.Home || _mode == AppMode.Library);
var courseVisible = _mode == AppMode.CourseSelect; var courseVisible = _mode == AppMode.CourseSelect;
_optionsProfileCoursePresenter.SetOptionsVisible(optionsVisible); _optionsProfileCoursePresenter.SetOptionsVisible(optionsVisible);
@@ -1504,27 +1480,27 @@ namespace ProjectQuint.Bootstrap
GameSettings.FormatLaneKeyLabel(_settings.ScratchKey), GameSettings.FormatLaneKeyLabel(_settings.ScratchKey),
_rebindLane, _rebindLane,
_profileTransferStatus, _profileTransferStatus,
_updateStatus, _updateSession.Status,
_profileImportConfirmationPending, _profileImportConfirmationPending,
_profileSnapshot, _profileSession.Snapshot,
BuildAudioSyncCalibrationView()); BuildAudioSyncCalibrationView());
} }
if (profileVisible) if (profileVisible)
{ {
_optionsProfileCoursePresenter.UpdateProfile( _optionsProfileCoursePresenter.UpdateProfile(
_profileSnapshot, _profileSession.Snapshot,
_profileRatingSummary, _profileSession.RatingSummary,
_profileCollection, _profileSession.Collection,
_profileCourses, _profileSession.Courses,
_profileModalTab, _profileSession.Tab,
_profileRatingContextIndex, _profileSession.RatingContextIndex,
_ratingContexts.Count, _profileSession.RatingContexts.Count,
_profileBadgeSelection, _profileSession.BadgeSelection,
_profileCollectionColumn, _profileSession.CollectionColumn,
_profileCollectionSelections, _profileSession.CollectionSelections,
_profileRatingThumbnailSongs, _profileThumbnails.Songs,
_profileRatingThumbnailSongsRevision); _profileThumbnails.Revision);
} }
if (courseVisible) if (courseVisible)
@@ -1536,7 +1512,7 @@ namespace ProjectQuint.Bootstrap
EffectiveCourseModifier, EffectiveCourseModifier,
_noteSpeed, _noteSpeed,
_courseStatus, _courseStatus,
_profileSnapshot); _profileSession.Snapshot);
} }
} }
@@ -2178,14 +2154,8 @@ namespace ProjectQuint.Bootstrap
switch (action) switch (action)
{ {
case HomeMenuAction.Play: case HomeMenuAction.Play:
_packSelection = Mathf.Clamp( _flow.TryBeginPlayFromHome(
ResolveActivePackSelection(), ResolveActivePackSelection(), _packRegistry?.Packs.Count ?? 0);
0,
Math.Max(0, (_packRegistry?.Packs.Count ?? 0) + 1));
_status = _packRegistry?.Packs.Count > 0
? $"{_packRegistry.Packs.Count:N0} BMS PACKS REGISTERED"
: "SELECT A FOLDER OR ZIP TO BEGIN";
_mode = AppMode.PackSelect;
break; break;
case HomeMenuAction.Options: case HomeMenuAction.Options:
OpenOptions(AppMode.Home); OpenOptions(AppMode.Home);
@@ -4947,99 +4917,49 @@ namespace ProjectQuint.Bootstrap
private void TryStartAutomaticUpdateCheck() private void TryStartAutomaticUpdateCheck()
{ {
if (_automaticUpdateCheckStarted || if (_settings != null && _updateSession.TryBeginAutomaticCheck(
_settings == null || _mode, Application.isEditor, _settings.LastUpdateCheckUtcTicks,
_mode != AppMode.Home) DateTime.UtcNow.Ticks, out var generation))
return; StartCoroutine(CheckForUpdates(generation));
_automaticUpdateCheckStarted = true;
if (Application.isEditor)
return;
if (!ProjectQuintUpdateService.ShouldCheckAutomatically(
_settings.LastUpdateCheckUtcTicks,
DateTime.UtcNow.Ticks))
{
_updateStatus = "CHECKED RECENTLY";
return;
}
RequestUpdateCheck(manual: false);
} }
private void RequestUpdateCheck(bool manual) private void RequestUpdateCheck(bool manual)
{ {
if (_updateCheckRunning) if (_updateSession.TryBeginCheck(manual, out var generation))
{ StartCoroutine(CheckForUpdates(generation));
if (manual)
_updateStatus = "CHECKING...";
return;
}
if (manual)
{
_updateDismissedThisRun = false;
_updateOfferPending = false;
}
_updateStatus = "CHECKING...";
StartCoroutine(CheckForUpdates(manual));
} }
private IEnumerator CheckForUpdates(bool manual) private IEnumerator CheckForUpdates(int generation)
{ {
_updateCheckRunning = true; try
ProjectQuintUpdateCheckResult result = default;
yield return ProjectQuintUpdateService.Check(value => result = value);
_updateCheckRunning = false;
if (!result.Succeeded)
{ {
if (manual) ProjectQuintUpdateCheckResult result = default;
_updateStatus = "CHECK FAILED"; yield return ProjectQuintUpdateService.Check(value => result = value);
else if (!_updateSession.CompleteCheck(generation, result,
_updateStatus = "NOT CHECKED"; _settings.SkippedUpdateVersion, DateTime.UtcNow.Ticks))
Debug.LogWarning($"[ProjectQuint Update] {result.Error}"); yield break;
yield break; if (!result.Succeeded || result.Manifest == null)
{
Debug.LogWarning($"[ProjectQuint Update] {_updateSession.Error}");
yield break;
}
_settings.LastUpdateCheckUtcTicks = _updateSession.LastSuccessfulCheckUtcTicks;
PersistSettings();
} }
finally
_settings.LastUpdateCheckUtcTicks = DateTime.UtcNow.Ticks;
PersistSettings();
_availableUpdate = result.Manifest;
if (!ProjectQuintUpdateService.IsUpdateAvailable(_availableUpdate))
{ {
_updateStatus = $"UP TO DATE // {ProjectQuintReleaseVersion.DisplayVersion.ToUpperInvariant()}"; _updateSession.AbortCheck(generation);
yield break;
} }
_updateStatus = $"AVAILABLE // {_availableUpdate.version.ToUpperInvariant()}";
if (ProjectQuintUpdateService.ShouldOffer(
_availableUpdate,
_settings.SkippedUpdateVersion,
manual))
_updateOfferPending = true;
} }
private void TryPresentPendingUpdate() private void TryPresentPendingUpdate()
{ {
if (!_updateOfferPending || var blocked = (_mode == AppMode.Home && _profileSession.IsOpen) ||
_updateModalOpen || (_mode == AppMode.Options &&
_updateDismissedThisRun || (_audioSyncCalibration != null ||
_availableUpdate == null) !string.IsNullOrWhiteSpace(_audioSyncCalibrationError) ||
return; _packCacheManagerOpen || _profileImportConfirmationPending));
_updateSession.TryPresent(_mode, blocked);
var safeHome = _mode == AppMode.Home && !_profileModalOpen;
var safeOptions =
_mode == AppMode.Options &&
_audioSyncCalibration == null &&
string.IsNullOrWhiteSpace(_audioSyncCalibrationError) &&
!_packCacheManagerOpen &&
!_profileImportConfirmationPending;
if (!safeHome && !safeOptions)
return;
_updateOfferPending = false;
_updateModalSelection = 0;
_updateModalOpen = true;
} }
private void UpdateUpdateNoticeInput() private void UpdateUpdateNoticeInput()
@@ -5056,19 +4976,19 @@ namespace ProjectQuint.Bootstrap
if (keyboard.leftArrowKey.wasPressedThisFrame || if (keyboard.leftArrowKey.wasPressedThisFrame ||
keyboard.upArrowKey.wasPressedThisFrame) keyboard.upArrowKey.wasPressedThisFrame)
_updateModalSelection = (_updateModalSelection + 2) % 3; _updateSession.MoveSelection(-1);
else if (keyboard.rightArrowKey.wasPressedThisFrame || else if (keyboard.rightArrowKey.wasPressedThisFrame ||
keyboard.downArrowKey.wasPressedThisFrame) keyboard.downArrowKey.wasPressedThisFrame)
_updateModalSelection = (_updateModalSelection + 1) % 3; _updateSession.MoveSelection(1);
if (keyboard.enterKey.wasPressedThisFrame || if (keyboard.enterKey.wasPressedThisFrame ||
keyboard.numpadEnterKey.wasPressedThisFrame) keyboard.numpadEnterKey.wasPressedThisFrame)
HandleUpdateNoticeAction((UpdateNoticeAction)_updateModalSelection); HandleUpdateNoticeAction((UpdateNoticeAction)_updateSession.Selection);
} }
private void HandleUpdateNoticeAction(UpdateNoticeAction action) private void HandleUpdateNoticeAction(UpdateNoticeAction action)
{ {
if (!_updateModalOpen || _availableUpdate == null) if (!_updateSession.IsModalOpen || _updateSession.Manifest == null)
return; return;
switch (action) switch (action)
@@ -5078,7 +4998,7 @@ namespace ProjectQuint.Bootstrap
if (_sparkleUpdater != null && if (_sparkleUpdater != null &&
_sparkleUpdater.TryCheckForUpdates(out sparkleError)) _sparkleUpdater.TryCheckForUpdates(out sparkleError))
{ {
_updateStatus = "OPENING SECURE UPDATE INSTALLER..."; _updateSession.MarkInstallerOpening();
} }
else else
{ {
@@ -5088,14 +5008,12 @@ namespace ProjectQuint.Bootstrap
} }
break; break;
case UpdateNoticeAction.Skip: case UpdateNoticeAction.Skip:
_settings.SkippedUpdateVersion = _availableUpdate.version; _settings.SkippedUpdateVersion = _updateSession.Manifest.version;
PersistSettings(); PersistSettings();
break; break;
} }
_updateDismissedThisRun = true; _updateSession.Dismiss();
_updateOfferPending = false;
_updateModalOpen = false;
} }
private void PersistSettings() => _settingsRepository.Save(_settings); private void PersistSettings() => _settingsRepository.Save(_settings);
@@ -5171,7 +5089,7 @@ namespace ProjectQuint.Bootstrap
{ {
if (keyboard == null) if (keyboard == null)
return false; return false;
if (_profileModalOpen) if (_profileSession.IsOpen)
{ {
if (keyboard.iKey.wasPressedThisFrame) if (keyboard.iKey.wasPressedThisFrame)
{ {
@@ -5185,43 +5103,43 @@ namespace ProjectQuint.Bootstrap
return true; return true;
} }
if (keyboard.leftShiftKey.wasPressedThisFrame) if (keyboard.leftShiftKey.wasPressedThisFrame)
_profileModalTab = (_profileModalTab + 3) % 4; _profileSession.ShiftTab(-1);
if (keyboard.rightShiftKey.wasPressedThisFrame) if (keyboard.rightShiftKey.wasPressedThisFrame)
_profileModalTab = (_profileModalTab + 1) % 4; _profileSession.ShiftTab(1);
if (_profileModalTab == 1 && _ratingContexts.Count > 1) if (_profileSession.Tab == 1 && _profileSession.RatingContexts.Count > 1)
{ {
if (keyboard.upArrowKey.wasPressedThisFrame) if (keyboard.upArrowKey.wasPressedThisFrame)
ShiftProfileRatingContext(-1); _profileSession.ShiftRatingContext(-1);
if (keyboard.downArrowKey.wasPressedThisFrame) if (keyboard.downArrowKey.wasPressedThisFrame)
ShiftProfileRatingContext(1); _profileSession.ShiftRatingContext(1);
} }
if (_profileModalTab == 2) if (_profileSession.Tab == 2)
{ {
if (keyboard.leftArrowKey.wasPressedThisFrame) if (keyboard.leftArrowKey.wasPressedThisFrame)
ShiftProfileCollectionColumn(-1); _profileSession.ShiftCollectionColumn(-1);
if (keyboard.rightArrowKey.wasPressedThisFrame) if (keyboard.rightArrowKey.wasPressedThisFrame)
ShiftProfileCollectionColumn(1); _profileSession.ShiftCollectionColumn(1);
if (keyboard.upArrowKey.wasPressedThisFrame) if (keyboard.upArrowKey.wasPressedThisFrame)
ShiftProfileCollectionSelection(-1); _profileSession.ShiftCollectionSelection(-1);
if (keyboard.downArrowKey.wasPressedThisFrame) if (keyboard.downArrowKey.wasPressedThisFrame)
ShiftProfileCollectionSelection(1); _profileSession.ShiftCollectionSelection(1);
} }
if (_profileModalTab == 3 && _profileCourses.BadgeCount > 0) if (_profileSession.Tab == 3 && _profileSession.Courses.BadgeCount > 0)
{ {
if (keyboard.upArrowKey.wasPressedThisFrame) if (keyboard.upArrowKey.wasPressedThisFrame)
ShiftProfileBadgeSelection(-1); _profileSession.ShiftBadge(-1);
if (keyboard.downArrowKey.wasPressedThisFrame) if (keyboard.downArrowKey.wasPressedThisFrame)
ShiftProfileBadgeSelection(1); _profileSession.ShiftBadge(1);
if (keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame) if (keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame)
SetFeaturedProfileBadge(_profileBadgeSelection); SetFeaturedProfileBadge(_profileSession.BadgeSelection);
} }
var enterCloses = _profileModalTab != 3 || _profileCourses.BadgeCount == 0; var enterCloses = _profileSession.Tab != 3 || _profileSession.Courses.BadgeCount == 0;
if (keyboard.escapeKey.wasPressedThisFrame || if (keyboard.escapeKey.wasPressedThisFrame ||
keyboard.f8Key.wasPressedThisFrame || keyboard.f8Key.wasPressedThisFrame ||
keyboard.f11Key.wasPressedThisFrame || keyboard.f11Key.wasPressedThisFrame ||
enterCloses && (keyboard.enterKey.wasPressedThisFrame || enterCloses && (keyboard.enterKey.wasPressedThisFrame ||
keyboard.numpadEnterKey.wasPressedThisFrame)) keyboard.numpadEnterKey.wasPressedThisFrame))
_profileModalOpen = false; _profileSession.Close();
return true; return true;
} }
@@ -5234,9 +5152,9 @@ namespace ProjectQuint.Bootstrap
private void OpenProfileModal(int tab) private void OpenProfileModal(int tab)
{ {
RefreshProfileSummary(); RefreshProfileSummary();
RefreshProfileRatingThumbnailSongs(); _profileThumbnails.Refresh(_profileSession.RatingContexts,
_profileModalTab = Mathf.Clamp(tab, 0, 3); _libraryController?.State?.AllSongs, _libraryController?.State?.SongsRevision ?? 0, _packRegistry);
_profileModalOpen = true; _profileSession.Open(tab);
} }
private void HandleProfileModalUi(ProfileModalUiResult profileResult) private void HandleProfileModalUi(ProfileModalUiResult profileResult)
@@ -5246,19 +5164,19 @@ namespace ProjectQuint.Bootstrap
if (profileResult.ResetProfileImage) if (profileResult.ResetProfileImage)
ResetProfileImage(); ResetProfileImage();
if (profileResult.SelectedTab >= 0) if (profileResult.SelectedTab >= 0)
_profileModalTab = profileResult.SelectedTab; _profileSession.SelectTab(profileResult.SelectedTab);
if (profileResult.RatingContextDelta != 0) if (profileResult.RatingContextDelta != 0)
ShiftProfileRatingContext(profileResult.RatingContextDelta); _profileSession.ShiftRatingContext(profileResult.RatingContextDelta);
if (profileResult.SelectedBadgeIndex >= 0) if (profileResult.SelectedBadgeIndex >= 0)
_profileBadgeSelection = profileResult.SelectedBadgeIndex; _profileSession.SelectBadge(profileResult.SelectedBadgeIndex);
if (profileResult.SelectedCollectionColumn >= 0) if (profileResult.SelectedCollectionColumn >= 0)
SetProfileCollectionSelection( _profileSession.SelectCollection(
profileResult.SelectedCollectionColumn, profileResult.SelectedCollectionColumn,
profileResult.SelectedCollectionIndex); profileResult.SelectedCollectionIndex);
if (!string.IsNullOrWhiteSpace(profileResult.FeaturedBadgeCourseId)) if (!string.IsNullOrWhiteSpace(profileResult.FeaturedBadgeCourseId))
SetFeaturedProfileBadge(_profileBadgeSelection); SetFeaturedProfileBadge(_profileSession.BadgeSelection);
if (profileResult.Close) if (profileResult.Close)
_profileModalOpen = false; _profileSession.Close();
} }
private void LoadProfileImage() private void LoadProfileImage()
@@ -6193,292 +6111,32 @@ namespace ProjectQuint.Bootstrap
private void RefreshProfileSummary() private void RefreshProfileSummary()
{ {
_profileSnapshot = _progressionRepository?.Snapshot() ?? default; var snapshot = _progressionRepository?.Snapshot() ?? default;
_profileCollection = SongRecordRepository.Shared.CalculateCollection(); var records = SongRecordRepository.Shared;
_profileCourses = CourseRecordRepository.Shared.CalculateCollection( var contexts = ProfileRatingContextBuilder.Build(
_progressionRepository?.FeaturedCourseBadgeId); records.CalculateRatingContexts(), _difficultyTableRegistry?.Tables,
_ratingContexts = BuildProfileRatingContexts( _libraryController?.State?.AllSongs, _activeDifficultyTable?.Id,
SongRecordRepository.Shared.CalculateRatingContexts(), snapshot, records.CalculateRating);
_difficultyTableRegistry?.Tables, var fallbackRating = snapshot.LastRatingKeyCount > 0 &&
_libraryController?.State?.AllSongs, !string.IsNullOrWhiteSpace(snapshot.LastRatingProviderId)
_activeDifficultyTable?.Id, ? records.CalculateRating(snapshot.LastRatingProviderId,
_profileSnapshot, snapshot.LastRatingProviderDisplayName,
SongRecordRepository.Shared.CalculateRating); snapshot.LastRatingKeyCount, snapshot.LastRatingHasScratch)
_homeRatingSummary = _profileSnapshot.LastRatingKeyCount > 0 && : records.CalculateMostRecentRatingContext(
!string.IsNullOrWhiteSpace(_profileSnapshot.LastRatingProviderId) snapshot.LastRatingKeyCount, snapshot.LastRatingHasScratch);
? SongRecordRepository.Shared.CalculateRating( _profileSession.Refresh(snapshot, records.CalculateCollection(),
_profileSnapshot.LastRatingProviderId, CourseRecordRepository.Shared.CalculateCollection(_progressionRepository?.FeaturedCourseBadgeId),
_profileSnapshot.LastRatingProviderDisplayName, contexts, fallbackRating);
_profileSnapshot.LastRatingKeyCount,
_profileSnapshot.LastRatingHasScratch)
: SongRecordRepository.Shared.CalculateMostRecentRatingContext(
_profileSnapshot.LastRatingKeyCount,
_profileSnapshot.LastRatingHasScratch);
_profileRatingContextIndex = FindRatingContextIndex(_homeRatingSummary);
if (_ratingContexts.Count > 0)
_profileRatingContextIndex = 0;
_profileRatingSummary = _ratingContexts.Count > 0
? _ratingContexts[_profileRatingContextIndex]
: _homeRatingSummary;
_profileBadgeSelection = FindFeaturedBadgeIndex(_profileCourses);
ClampProfileCollectionSelections();
}
private void RefreshProfileRatingThumbnailSongs()
{
var requestedChartIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var context in _ratingContexts ?? Array.Empty<RatingSummary>())
{
foreach (var entry in context.BestPlays ?? Array.Empty<RatingChartEntry>())
{
if (!string.IsNullOrWhiteSpace(entry.ChartId))
requestedChartIds.Add(entry.ChartId);
}
}
var signature = 17L;
unchecked
{
signature = signature * 31 + (_libraryController?.State?.SongsRevision ?? 0);
foreach (var chartId in requestedChartIds.OrderBy(id => id, StringComparer.Ordinal))
signature = signature * 31 + StringComparer.Ordinal.GetHashCode(chartId);
foreach (var pack in _packRegistry?.Packs ?? Array.Empty<BmsPackDefinition>())
{
var cachePath = _packRegistry.CachePathFor(pack);
var cache = new FileInfo(cachePath);
signature = signature * 31 + StringComparer.OrdinalIgnoreCase.GetHashCode(pack.Id ?? string.Empty);
signature = signature * 31 + (cache.Exists ? cache.Length : 0L);
signature = signature * 31 + (cache.Exists ? cache.LastWriteTimeUtc.Ticks : 0L);
}
}
if (signature == _profileRatingThumbnailSourceSignature)
return;
var songsByChartId = new Dictionary<string, SongLibraryEntry>(StringComparer.Ordinal);
void AddSong(SongLibraryEntry song)
{
if (song == null)
return;
var chartId = ChartIdentityResolver.Resolve(song).Value;
if (requestedChartIds.Contains(chartId) && !songsByChartId.ContainsKey(chartId))
songsByChartId.Add(chartId, song);
}
foreach (var song in _libraryController?.State?.AllSongs ?? Array.Empty<SongLibraryEntry>())
AddSong(song);
foreach (var pack in _packRegistry?.Packs ?? Array.Empty<BmsPackDefinition>())
{
foreach (var song in SongLibraryScanner.LoadCachedThumbnailEntries(
_packRegistry.CachePathFor(pack),
pack.ContentRoot,
pack.Id,
requestedChartIds))
AddSong(song);
}
_profileRatingThumbnailSongs = songsByChartId.Values.ToArray();
_profileRatingThumbnailSourceSignature = signature;
unchecked
{
_profileRatingThumbnailSongsRevision++;
}
}
internal static IReadOnlyList<RatingSummary> BuildProfileRatingContexts(
IReadOnlyList<RatingSummary> recordedContexts,
IReadOnlyList<BmsDifficultyTableDefinition> registeredTables,
IReadOnlyList<SongLibraryEntry> activeSongs,
string activeTableId,
ProfileProgressSnapshot profile,
Func<string, string, int, bool, RatingSummary> resolveRating)
{
var contexts = new Dictionary<string, RatingSummary>(StringComparer.OrdinalIgnoreCase);
var tableNames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var table in registeredTables ?? Array.Empty<BmsDifficultyTableDefinition>())
{
if (table == null || string.IsNullOrWhiteSpace(table.Id))
continue;
tableNames[table.Id.Trim()] = string.IsNullOrWhiteSpace(table.DisplayName)
? table.Id.Trim()
: table.DisplayName.Trim();
}
void AddResolved(string providerId, string providerName, int keyCount, bool hasScratch)
{
if (string.IsNullOrWhiteSpace(providerId) || keyCount < 4 || keyCount > 7)
return;
var normalizedId = providerId.Trim();
var key = RatingContextIdentity(normalizedId, keyCount, hasScratch);
if (contexts.ContainsKey(key))
return;
var displayName = string.IsNullOrWhiteSpace(providerName)
? tableNames.TryGetValue(normalizedId, out var registeredName)
? registeredName
: normalizedId
: providerName.Trim();
contexts[key] = resolveRating?.Invoke(normalizedId, displayName, keyCount, hasScratch) ??
new RatingSummary(normalizedId, displayName, keyCount, hasScratch, 0.0, 0);
}
foreach (var recorded in recordedContexts ?? Array.Empty<RatingSummary>())
{
if (!recorded.HasContext)
continue;
contexts[RatingContextIdentity(recorded.ProviderId, recorded.KeyCount, recorded.HasScratch)] = recorded;
}
foreach (var table in registeredTables ?? Array.Empty<BmsDifficultyTableDefinition>())
{
if (table == null)
continue;
var preset = BmsDifficultyTableIndex.FindPreset(table.Id, table.HeaderUrl);
if (preset?.DefaultKeyCount >= 4)
AddResolved(table.Id, table.DisplayName, preset.Value.DefaultKeyCount,
preset.Value.DefaultHasScratch);
}
if (!string.IsNullOrWhiteSpace(activeTableId))
{
var activeName = tableNames.TryGetValue(activeTableId, out var registeredName)
? registeredName
: activeTableId;
foreach (var form in (activeSongs ?? Array.Empty<SongLibraryEntry>())
.Where(song => song?.Metadata != null &&
song.EffectiveKeyCount >= 4 && song.EffectiveKeyCount <= 7 &&
song.Catalog?.HasDifficulty == true &&
string.Equals(song.Catalog.ProviderId, activeTableId,
StringComparison.OrdinalIgnoreCase))
.Select(song => new { KeyCount = song.EffectiveKeyCount, song.Metadata.HasScratch })
.Distinct())
AddResolved(activeTableId, activeName, form.KeyCount, form.HasScratch);
}
AddResolved(
profile.LastRatingProviderId,
profile.LastRatingProviderDisplayName,
profile.LastRatingKeyCount,
profile.LastRatingHasScratch);
return contexts.Values
.OrderByDescending(summary => summary.ActivityPlayCount)
.ThenByDescending(summary => summary.FilledSlots)
.ThenBy(summary => summary.ProviderDisplayName, StringComparer.OrdinalIgnoreCase)
.ThenBy(summary => summary.KeyCount)
.ThenBy(summary => summary.HasScratch)
.ToArray();
}
private static string RatingContextIdentity(string providerId, int keyCount, bool hasScratch) =>
$"{(providerId ?? string.Empty).Trim().ToLowerInvariant()}:{keyCount}:{hasScratch}";
private int FindRatingContextIndex(RatingSummary target)
{
for (var index = 0; index < _ratingContexts.Count; index++)
{
var context = _ratingContexts[index];
if (string.Equals(context.ProviderId, target.ProviderId, StringComparison.OrdinalIgnoreCase) &&
context.KeyCount == target.KeyCount && context.HasScratch == target.HasScratch)
return index;
}
return 0;
}
private void ShiftProfileRatingContext(int delta)
{
if (_ratingContexts.Count == 0 || delta == 0)
return;
_profileRatingContextIndex = (_profileRatingContextIndex + delta) % _ratingContexts.Count;
if (_profileRatingContextIndex < 0)
_profileRatingContextIndex += _ratingContexts.Count;
_profileRatingSummary = _ratingContexts[_profileRatingContextIndex];
}
private IReadOnlyList<CollectionGroupSummary> ProfileCollectionGroups(int column)
{
return Mathf.Clamp(column, 0, 2) switch
{
0 => _profileCollection.Packs,
1 => _profileCollection.KeyModes,
_ => _profileCollection.Difficulties,
};
}
private void ShiftProfileCollectionColumn(int delta)
{
if (delta == 0)
return;
_profileCollectionColumn = (_profileCollectionColumn + delta) % 3;
if (_profileCollectionColumn < 0)
_profileCollectionColumn += 3;
ClampProfileCollectionSelections();
}
private void ShiftProfileCollectionSelection(int delta)
{
var groups = ProfileCollectionGroups(_profileCollectionColumn);
if (groups == null || groups.Count == 0 || delta == 0)
return;
var current = _profileCollectionSelections[_profileCollectionColumn];
current = (current + delta) % groups.Count;
if (current < 0)
current += groups.Count;
_profileCollectionSelections[_profileCollectionColumn] = current;
}
private void SetProfileCollectionSelection(int column, int index)
{
_profileCollectionColumn = Mathf.Clamp(column, 0, 2);
var groups = ProfileCollectionGroups(_profileCollectionColumn);
_profileCollectionSelections[_profileCollectionColumn] = groups == null || groups.Count == 0
? 0
: Mathf.Clamp(index, 0, groups.Count - 1);
}
private void ClampProfileCollectionSelections()
{
_profileCollectionColumn = Mathf.Clamp(_profileCollectionColumn, 0, 2);
for (var column = 0; column < _profileCollectionSelections.Length; column++)
{
var groups = ProfileCollectionGroups(column);
_profileCollectionSelections[column] = groups == null || groups.Count == 0
? 0
: Mathf.Clamp(_profileCollectionSelections[column], 0, groups.Count - 1);
}
}
private static int FindFeaturedBadgeIndex(ProfileCourseSnapshot courses)
{
if (courses == null)
return 0;
for (var index = 0; index < courses.CertificationBadges.Count; index++)
{
if (string.Equals(
courses.CertificationBadges[index].CourseId,
courses.FeaturedBadgeCourseId,
StringComparison.OrdinalIgnoreCase))
return index;
}
return 0;
}
private void ShiftProfileBadgeSelection(int delta)
{
if (_profileCourses.BadgeCount == 0 || delta == 0)
return;
_profileBadgeSelection = (_profileBadgeSelection + delta) % _profileCourses.BadgeCount;
if (_profileBadgeSelection < 0)
_profileBadgeSelection += _profileCourses.BadgeCount;
} }
private void SetFeaturedProfileBadge(int badgeIndex) private void SetFeaturedProfileBadge(int badgeIndex)
{ {
if (_profileCourses.BadgeCount == 0) _profileSession.SelectBadge(badgeIndex);
var courseId = _profileSession.SelectedBadgeCourseId;
if (string.IsNullOrEmpty(courseId))
return; return;
var normalized = Mathf.Clamp(badgeIndex, 0, _profileCourses.BadgeCount - 1); _progressionRepository?.SetFeaturedCourseBadge(courseId);
var badge = _profileCourses.CertificationBadges[normalized]; _profileSession.RefreshCourses(CourseRecordRepository.Shared.CalculateCollection(courseId));
_progressionRepository?.SetFeaturedCourseBadge(badge.CourseId);
_profileCourses = CourseRecordRepository.Shared.CalculateCollection(badge.CourseId);
_profileBadgeSelection = FindFeaturedBadgeIndex(_profileCourses);
} }
private void UpdateResultsInput() private void UpdateResultsInput()
@@ -7190,6 +6848,7 @@ namespace ProjectQuint.Bootstrap
private void OnDestroy() private void OnDestroy()
{ {
_updateSession.Cancel();
CloseAudioSyncCalibration(); CloseAudioSyncCalibration();
FlushPendingLibraryViewState(force: true); FlushPendingLibraryViewState(force: true);
_audio?.Resume(); _audio?.Resume();
@@ -8,38 +8,6 @@ using UnityEngine.Networking;
namespace ProjectQuint.Bootstrap namespace ProjectQuint.Bootstrap
{ {
[Serializable]
public sealed class ProjectQuintUpdateManifest
{
public int schemaVersion;
public string channel = string.Empty;
public bool available;
public string version = string.Empty;
public int buildNumber;
public string publishedAt = string.Empty;
public string downloadUrl = string.Empty;
public string notesUrl = string.Empty;
public string sha256 = string.Empty;
public long sizeBytes;
}
public readonly struct ProjectQuintUpdateCheckResult
{
public ProjectQuintUpdateCheckResult(
bool succeeded,
ProjectQuintUpdateManifest manifest,
string error)
{
Succeeded = succeeded;
Manifest = manifest;
Error = error ?? string.Empty;
}
public bool Succeeded { get; }
public ProjectQuintUpdateManifest Manifest { get; }
public string Error { get; }
}
public static class ProjectQuintUpdateService public static class ProjectQuintUpdateService
{ {
public const string ManifestUrl = public const string ManifestUrl =
@@ -47,7 +15,6 @@ namespace ProjectQuint.Bootstrap
public const string DownloadPageUrl = public const string DownloadPageUrl =
"https://projectquint.duckdns.org/download/"; "https://projectquint.duckdns.org/download/";
public const string ExpectedHost = "projectquint.duckdns.org"; public const string ExpectedHost = "projectquint.duckdns.org";
public static readonly TimeSpan AutomaticCheckInterval = TimeSpan.FromHours(24);
private static readonly Regex AlphaVersionPattern = new Regex( private static readonly Regex AlphaVersionPattern = new Regex(
@"^\d+\.\d+\.\d+-alpha\.\d+$", @"^\d+\.\d+\.\d+-alpha\.\d+$",
@@ -146,31 +113,6 @@ namespace ProjectQuint.Bootstrap
return true; return true;
} }
public static bool IsUpdateAvailable(ProjectQuintUpdateManifest manifest) =>
manifest != null &&
manifest.available &&
manifest.buildNumber > ProjectQuintReleaseVersion.BuildNumber;
public static bool ShouldOffer(
ProjectQuintUpdateManifest manifest,
string skippedVersion,
bool manual) =>
IsUpdateAvailable(manifest) &&
(manual ||
!string.Equals(
manifest.version,
skippedVersion?.Trim(),
StringComparison.OrdinalIgnoreCase));
public static bool ShouldCheckAutomatically(long lastSuccessfulCheckUtcTicks, long nowUtcTicks)
{
if (lastSuccessfulCheckUtcTicks <= 0 || nowUtcTicks <= lastSuccessfulCheckUtcTicks)
return true;
return new TimeSpan(nowUtcTicks - lastSuccessfulCheckUtcTicks) >=
AutomaticCheckInterval;
}
private static bool TryValidateNotesUrl(string value) private static bool TryValidateNotesUrl(string value)
{ {
if (string.IsNullOrWhiteSpace(value)) if (string.IsNullOrWhiteSpace(value))
@@ -67,6 +67,21 @@ namespace ProjectQuint.Features.Flow
Mode = mode; Mode = mode;
} }
public bool TryBeginPlayFromHome(int activePackSelection = 0, int packCount = 0)
{
if (Mode != AppMode.Home)
return false;
if (packCount < 0)
throw new ArgumentOutOfRangeException(nameof(packCount));
PackSelection = Math.Max(0, Math.Min(activePackSelection, packCount + 1));
Status = packCount > 0
? $"{packCount:N0} BMS PACKS REGISTERED"
: "SELECT A FOLDER OR ZIP TO BEGIN";
TransitionTo(AppMode.PackSelect);
return true;
}
public void BeginOptions() public void BeginOptions()
{ {
RebindLane = -1; RebindLane = -1;
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: a699850f7cb543659720ced37887b8b6
folderAsset: yes
@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using System.Linq;
using ProjectQuint.Core.Progression;
using ProjectQuint.Library;
namespace ProjectQuint.Features.Profile
{
internal static class ProfileRatingContextBuilder
{
public static IReadOnlyList<RatingSummary> Build(
IReadOnlyList<RatingSummary> recordedContexts,
IReadOnlyList<BmsDifficultyTableDefinition> registeredTables,
IReadOnlyList<SongLibraryEntry> activeSongs,
string activeTableId,
ProfileProgressSnapshot profile,
Func<string, string, int, bool, RatingSummary> resolveRating)
{
var contexts = new Dictionary<string, RatingSummary>(StringComparer.OrdinalIgnoreCase);
var tableNames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var table in registeredTables ?? Array.Empty<BmsDifficultyTableDefinition>())
{
if (table == null || string.IsNullOrWhiteSpace(table.Id))
continue;
tableNames[table.Id.Trim()] = string.IsNullOrWhiteSpace(table.DisplayName)
? table.Id.Trim()
: table.DisplayName.Trim();
}
void AddResolved(string providerId, string providerName, int keyCount, bool hasScratch)
{
if (string.IsNullOrWhiteSpace(providerId) || keyCount < 4 || keyCount > 7)
return;
var normalizedId = providerId.Trim();
var key = RatingContextIdentity(normalizedId, keyCount, hasScratch);
if (contexts.ContainsKey(key))
return;
var displayName = string.IsNullOrWhiteSpace(providerName)
? tableNames.TryGetValue(normalizedId, out var registeredName)
? registeredName
: normalizedId
: providerName.Trim();
contexts[key] = resolveRating?.Invoke(normalizedId, displayName, keyCount, hasScratch) ??
new RatingSummary(normalizedId, displayName, keyCount, hasScratch, 0.0, 0);
}
foreach (var recorded in recordedContexts ?? Array.Empty<RatingSummary>())
{
if (!recorded.HasContext)
continue;
contexts[RatingContextIdentity(recorded.ProviderId, recorded.KeyCount, recorded.HasScratch)] = recorded;
}
foreach (var table in registeredTables ?? Array.Empty<BmsDifficultyTableDefinition>())
{
if (table == null)
continue;
var preset = BmsDifficultyTableIndex.FindPreset(table.Id, table.HeaderUrl);
if (preset?.DefaultKeyCount >= 4)
AddResolved(table.Id, table.DisplayName, preset.Value.DefaultKeyCount,
preset.Value.DefaultHasScratch);
}
if (!string.IsNullOrWhiteSpace(activeTableId))
{
var activeName = tableNames.TryGetValue(activeTableId, out var registeredName)
? registeredName
: activeTableId;
foreach (var form in (activeSongs ?? Array.Empty<SongLibraryEntry>())
.Where(song => song?.Metadata != null &&
song.EffectiveKeyCount >= 4 && song.EffectiveKeyCount <= 7 &&
song.Catalog?.HasDifficulty == true &&
string.Equals(song.Catalog.ProviderId, activeTableId,
StringComparison.OrdinalIgnoreCase))
.Select(song => new { KeyCount = song.EffectiveKeyCount, song.Metadata.HasScratch })
.Distinct())
AddResolved(activeTableId, activeName, form.KeyCount, form.HasScratch);
}
AddResolved(
profile.LastRatingProviderId,
profile.LastRatingProviderDisplayName,
profile.LastRatingKeyCount,
profile.LastRatingHasScratch);
return contexts.Values
.OrderByDescending(summary => summary.ActivityPlayCount)
.ThenByDescending(summary => summary.FilledSlots)
.ThenBy(summary => summary.ProviderDisplayName, StringComparer.OrdinalIgnoreCase)
.ThenBy(summary => summary.KeyCount)
.ThenBy(summary => summary.HasScratch)
.ToArray();
}
private static string RatingContextIdentity(string providerId, int keyCount, bool hasScratch) =>
$"{(providerId ?? string.Empty).Trim().ToLowerInvariant()}:{keyCount}:{hasScratch}";
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 65c9c48983064911802938aef1b8ae09
@@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using ProjectQuint.Core.Progression;
namespace ProjectQuint.Features.Profile
{
/// <summary>Owns profile navigation independently of the input device, UI and storage.</summary>
internal sealed class ProfileSessionController
{
private readonly int[] _collectionSelections = new int[3];
public ProfileProgressSnapshot Snapshot { get; private set; }
public ProfileCollectionSnapshot Collection { get; private set; } = ProfileCollectionSnapshot.Empty;
public ProfileCourseSnapshot Courses { get; private set; } = ProfileCourseSnapshot.Empty;
public IReadOnlyList<RatingSummary> RatingContexts { get; private set; } = Array.Empty<RatingSummary>();
public RatingSummary RatingSummary { get; private set; }
public int RatingContextIndex { get; private set; }
public int BadgeSelection { get; private set; }
public int CollectionColumn { get; private set; }
public IReadOnlyList<int> CollectionSelections { get; }
public bool IsOpen { get; private set; }
public int Tab { get; private set; }
public string SelectedBadgeCourseId => Courses.BadgeCount == 0
? string.Empty : Courses.CertificationBadges[BadgeSelection].CourseId;
public ProfileSessionController() => CollectionSelections = Array.AsReadOnly(_collectionSelections);
public void Refresh(ProfileProgressSnapshot snapshot, ProfileCollectionSnapshot collection,
ProfileCourseSnapshot courses, IReadOnlyList<RatingSummary> ratingContexts, RatingSummary fallbackRating)
{
Snapshot = snapshot;
Collection = collection ?? ProfileCollectionSnapshot.Empty;
RatingContexts = ratingContexts ?? Array.Empty<RatingSummary>();
// Contexts are already ranked by activity. Opening the profile starts with that ranking.
RatingContextIndex = 0;
RatingSummary = RatingContexts.Count > 0 ? RatingContexts[0] : fallbackRating;
RefreshCourses(courses);
for (var column = 0; column < _collectionSelections.Length; column++)
_collectionSelections[column] = Clamp(_collectionSelections[column], Groups(column).Count - 1);
}
public void RefreshCourses(ProfileCourseSnapshot courses)
{
Courses = courses ?? ProfileCourseSnapshot.Empty;
BadgeSelection = 0;
for (var index = 0; index < Courses.BadgeCount; index++)
if (string.Equals(Courses.CertificationBadges[index].CourseId,
Courses.FeaturedBadgeCourseId, StringComparison.OrdinalIgnoreCase))
{
BadgeSelection = index;
break;
}
}
public void Open(int tab)
{
SelectTab(tab);
IsOpen = true;
}
public void Close() => IsOpen = false;
public void SelectTab(int tab) => Tab = Clamp(tab, 3);
public void ShiftTab(int delta) => Tab = Wrap(Tab, delta, 4);
public void ShiftRatingContext(int delta)
{
if (RatingContexts.Count == 0)
return;
RatingContextIndex = Wrap(RatingContextIndex, delta, RatingContexts.Count);
RatingSummary = RatingContexts[RatingContextIndex];
}
public void ShiftCollectionColumn(int delta) => CollectionColumn = Wrap(CollectionColumn, delta, 3);
public void ShiftCollectionSelection(int delta) =>
_collectionSelections[CollectionColumn] = Wrap(
_collectionSelections[CollectionColumn], delta, Groups(CollectionColumn).Count);
public void SelectCollection(int column, int index)
{
CollectionColumn = Clamp(column, 2);
_collectionSelections[CollectionColumn] = Clamp(index, Groups(CollectionColumn).Count - 1);
}
public void SelectBadge(int index) => BadgeSelection = Clamp(index, Courses.BadgeCount - 1);
public void ShiftBadge(int delta) => BadgeSelection = Wrap(BadgeSelection, delta, Courses.BadgeCount);
private IReadOnlyList<CollectionGroupSummary> Groups(int column) => column switch
{
0 => Collection.Packs,
1 => Collection.KeyModes,
_ => Collection.Difficulties,
};
private static int Clamp(int value, int maximum) => Math.Max(0, Math.Min(value, maximum));
private static int Wrap(int value, int delta, int count) => count <= 0
? 0 : (int)((((long)value + delta) % count + count) % count);
#if UNITY_EDITOR
public void SetPreviewSnapshot(ProfileProgressSnapshot snapshot) => Snapshot = snapshot;
#endif
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3d53feec096341b8a67e3bf4404774dd
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: fee265429b204c6fbc7524f90b8c438d
folderAsset: yes
@@ -0,0 +1,131 @@
using ProjectQuint.Core.Release;
using ProjectQuint.Features.Flow;
namespace ProjectQuint.Features.Updates
{
/// <summary>
/// Owns one update-check session. A generation prevents an abandoned network request
/// from reopening a dismissed modal or overwriting a newer manual check.
/// Transport, persistence and installer invocation remain at the application boundary.
/// </summary>
internal sealed class UpdateSessionController
{
private int _generation;
private bool _manual;
private bool _automaticCheckStarted;
private bool _offerPending;
public bool IsChecking { get; private set; }
public bool IsModalOpen { get; private set; }
public int Selection { get; private set; }
public string Status { get; private set; } = "NOT CHECKED";
public string Error { get; private set; } = string.Empty;
public ProjectQuintUpdateManifest Manifest { get; private set; }
public long LastSuccessfulCheckUtcTicks { get; private set; }
public bool TryBeginAutomaticCheck(AppMode mode, bool isEditor,
long lastSuccessfulCheckUtcTicks, long nowUtcTicks, out int generation)
{
generation = 0;
if (_automaticCheckStarted || mode != AppMode.Home)
return false;
_automaticCheckStarted = true;
if (isEditor)
return false;
if (!UpdatePolicy.ShouldCheckAutomatically(lastSuccessfulCheckUtcTicks, nowUtcTicks))
{
Status = "CHECKED RECENTLY";
return false;
}
return TryBeginCheck(false, out generation);
}
public bool TryBeginCheck(bool manual, out int generation)
{
generation = _generation;
if (IsChecking)
return false;
generation = ++_generation;
_manual = manual;
IsChecking = true;
IsModalOpen = false;
_offerPending = false;
Manifest = null;
Error = string.Empty;
Status = "CHECKING...";
return true;
}
public bool CompleteCheck(int generation, ProjectQuintUpdateCheckResult result,
string skippedVersion, long nowUtcTicks)
{
if (!IsChecking || generation != _generation)
return false;
IsChecking = false;
if (!result.Succeeded || result.Manifest == null)
{
Error = result.Succeeded ? "UPDATE MANIFEST IS MISSING" : result.Error;
Status = _manual ? "CHECK FAILED" : "NOT CHECKED";
return true;
}
LastSuccessfulCheckUtcTicks = nowUtcTicks;
Manifest = result.Manifest;
Status = UpdatePolicy.IsUpdateAvailable(Manifest)
? $"AVAILABLE // {Manifest.version.ToUpperInvariant()}"
: $"UP TO DATE // {ProjectQuintReleaseVersion.DisplayVersion.ToUpperInvariant()}";
_offerPending = UpdatePolicy.ShouldOffer(Manifest, skippedVersion, _manual);
return true;
}
public bool TryPresent(AppMode mode, bool blocked)
{
if (blocked || IsChecking || !_offerPending || IsModalOpen || Manifest == null ||
(mode != AppMode.Home && mode != AppMode.Options))
return false;
_offerPending = false;
Selection = 0;
IsModalOpen = true;
return true;
}
public void MoveSelection(int delta)
{
if (IsModalOpen)
Selection = ((Selection + delta) % 3 + 3) % 3;
}
public void MarkInstallerOpening() => Status = "OPENING SECURE UPDATE INSTALLER...";
public void Dismiss()
{
IsModalOpen = false;
_offerPending = false;
}
public void AbortCheck(int generation)
{
if (IsChecking && generation == _generation)
Cancel();
}
public void Cancel()
{
++_generation;
if (IsChecking)
Status = "CHECK CANCELLED";
IsChecking = false;
Dismiss();
}
#if UNITY_EDITOR
public void ShowPreview(ProjectQuintUpdateManifest manifest)
{
Cancel();
Manifest = manifest;
Selection = 0;
IsModalOpen = true;
}
#endif
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3f678ce98b05486bac254d968c77eddf
+3
View File
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 9a1a356268ef4303b32df7e8fdc9e78f
folderAsset: yes
@@ -0,0 +1,468 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using NUnit.Framework;
using ProjectQuint.Core.Bms;
using ProjectQuint.Core.Charts;
using ProjectQuint.Core.Course;
using ProjectQuint.Core.Gauge;
using ProjectQuint.Core.Judgement;
using ProjectQuint.Core.Timing;
namespace ProjectQuint.Tests
{
public sealed class BmsParsingTests
{
[Test]
public void Parser_DecodesShiftJisJapaneseMetadata()
{
var directory = Path.Combine(Path.GetTempPath(), "ProjectQuint-ShiftJis-" + Guid.NewGuid().ToString("N"));
var path = Path.Combine(directory, "chart.bms");
Directory.CreateDirectory(directory);
try
{
var bytes = new List<byte>();
bytes.AddRange(Encoding.ASCII.GetBytes("#TITLE PUPA\r\n#ARTIST "));
bytes.AddRange(new byte[]
{
0x83, 0x82, 0x83, 0x8A, 0x83, 0x82, 0x83, 0x8A,
0x82, 0xA0, 0x82, 0xC2, 0x82, 0xB5,
});
bytes.AddRange(Encoding.ASCII.GetBytes("\r\n#BPM 202\r\n"));
File.WriteAllBytes(path, bytes.ToArray());
var metadata = BmsParser.ReadMetadataFile(path);
Assert.That(metadata.Title, Is.EqualTo("PUPA"));
Assert.That(metadata.Artist, Is.EqualTo("モリモリあつし"));
}
finally
{
if (Directory.Exists(directory))
Directory.Delete(directory, true);
}
}
[Test]
public void Parser_Base62PreservesCaseSensitiveObjectIdsAcrossAllTimelines()
{
const string source = @"
#TITLE Base62 Test
#BPM 120
#WAVAA upper.wav
#WAVAa lower.wav
#BMPBA upper.png
#BMPBa lower.png
#BPMCA 150
#BPMCa 180
#STOPDA 48
#STOPDa 96
#SCROLLEA 1.25
#SCROLLEa 0.5
#LNOBJ Aa
#00101:AAAa
#00104:BABa
#00108:CACa
#00109:DADa
#001SC:EAEa
#00211:AAAAAa
#RANDOM 1
#IF 1
#BASE 62
#ENDIF
#ENDRANDOM
";
var document = BmsParser.ParseText("base62.bms", source);
var compiled = BmsTimelineCompiler.Compile(document);
var chart = BmsPlayableChartConverter.Convert(compiled).Chart;
Assert.That(document.Metadata.ObjectBase, Is.EqualTo(62));
Assert.That(document.WavDefinitions, Has.Count.EqualTo(2));
Assert.That(document.WavDefinitions["AA"], Is.EqualTo("upper.wav"));
Assert.That(document.WavDefinitions["Aa"], Is.EqualTo("lower.wav"));
Assert.That(document.BmpDefinitions["BA"], Is.EqualTo("upper.png"));
Assert.That(document.BmpDefinitions["Ba"], Is.EqualTo("lower.png"));
Assert.That(document.ExtendedBpms.Keys, Is.EquivalentTo(new[] { "CA", "Ca" }));
Assert.That(document.Stops.Keys, Is.EquivalentTo(new[] { "DA", "Da" }));
Assert.That(document.Scrolls.Keys, Is.EquivalentTo(new[] { "EA", "Ea" }));
Assert.That(document.Metadata.LnObj, Is.EqualTo("Aa"));
Assert.That(chart.AudioResources, Has.Count.EqualTo(2));
Assert.That(chart.AudioResources["AA"], Is.EqualTo("upper.wav"));
Assert.That(chart.AudioResources["Aa"], Is.EqualTo("lower.wav"));
Assert.That(chart.BackgroundAudioEvents.Select(item => item.SoundId),
Is.EqualTo(new[] { "AA", "Aa" }));
Assert.That(chart.VisualEvents.Select(item => item.ResourceId),
Is.EqualTo(new[] { "BA", "Ba" }));
Assert.That(chart.Notes, Has.Count.EqualTo(2));
Assert.That(chart.Notes.Count(item => item.IsLong), Is.EqualTo(1));
Assert.That(chart.Notes.Single(item => item.IsLong).TailSoundId, Is.EqualTo("Aa"));
Assert.That(chart.Warnings.Any(item => item.StartsWith("Undefined", StringComparison.Ordinal)), Is.False);
}
[Test]
public void Parser_Base36NormalizesLowercaseObjectIdsEvenWhenBaseIsDeclaredLast()
{
const string source = @"
#BPM 120
#WAVAa hit.wav
#BMPbB image.png
#LNOBJ cC
#00111:AaAaCc
#00104:bB
#BASE 36
";
var document = BmsParser.ParseText("base36-lowercase.bms", source);
var chart = BmsTimelineCompiler.Compile(document);
Assert.That(document.Metadata.ObjectBase, Is.EqualTo(36));
Assert.That(document.WavDefinitions.Keys, Is.EqualTo(new[] { "AA" }));
Assert.That(document.BmpDefinitions.Keys, Is.EqualTo(new[] { "BB" }));
Assert.That(document.Metadata.LnObj, Is.EqualTo("CC"));
Assert.That(document.Events.All(item => item.ObjectId == item.ObjectId.ToUpperInvariant()), Is.True);
Assert.That(chart.Notes, Has.Count.EqualTo(2));
Assert.That(chart.Notes.Single(item => item.IsLong).TailSoundId, Is.EqualTo("CC"));
Assert.That(chart.BgaEvents.Single().ResourceId, Is.EqualTo("BB"));
}
[Test]
public void Parser_RejectsUnknownObjectBaseInsteadOfSilentlyCorruptingIds()
{
const string source = "#BASE 64\n#WAVAA hit.wav\n#00111:AA";
Assert.That(
() => BmsParser.ParseText("unsupported-base.bms", source),
Throws.TypeOf<FormatException>()
.With.Message.Contains("supports only #BASE 36 and #BASE 62"));
}
[Test]
public void Parser_RejectsObsoleteLnTypeTwoInsteadOfMisreadingLongNotes()
{
const string source = "#LNTYPE 2\n#BPM 120\n#00151:0001010100";
Assert.That(
() => BmsParser.ParseText("obsolete-mgq-ln.bms", source),
Throws.TypeOf<FormatException>()
.With.Message.Contains("Unsupported BMS #LNTYPE 2")
.And.Message.Contains("#LNTYPE 1 and #LNOBJ"));
}
[Test]
public void Parser_AcceptsSupportedLnTypeOne()
{
const string source = "#LNTYPE 1\n#BPM 120\n#00151:00010001";
var document = BmsParser.ParseText("standard-ln.bms", source);
var chart = BmsTimelineCompiler.Compile(document);
Assert.That(document.Metadata.LnType, Is.EqualTo(1));
Assert.That(chart.Notes.Count(note => note.IsLong), Is.EqualTo(1));
}
[Test]
public void Converter_PreservesScratchAsFixedLeftLane()
{
const string source = @"
#TITLE Smoke Test
#ARTIST ProjectQuint
#BPM 120
#WAV01 kick.wav
#00111:01
#00112:0001
#00116:01
";
var compiled = BmsTimelineCompiler.Compile(BmsParser.ParseText("smoke.bms", source));
var chart = BmsPlayableChartConverter.Convert(compiled).Chart;
Assert.That(chart.Metadata.Title, Is.EqualTo("Smoke Test"));
Assert.That(chart.Metadata.KeyCount, Is.EqualTo(5));
Assert.That(chart.Metadata.HasScratch, Is.True);
Assert.That(chart.Metadata.TotalLaneCount, Is.EqualTo(6));
Assert.That(chart.Notes, Has.Count.EqualTo(3));
Assert.That(chart.Notes[0].Lane, Is.EqualTo(0));
Assert.That(chart.Notes[0].IsScratch, Is.True);
Assert.That(chart.Notes[0].StartTime, Is.EqualTo(2.0).Within(0.000001));
Assert.That(chart.Notes, Has.Some.Property("Lane").EqualTo(1));
Assert.That(chart.Notes, Has.Some.Property("Lane").EqualTo(2));
Assert.That(chart.ScratchObjectCount, Is.EqualTo(1));
}
[Test]
public void Parser_PreservesDeclaredTotalForGaugeCalculation()
{
const string source = @"
#TITLE Gauge Test
#BPM 120
#TOTAL 470.5
#00111:01
";
var chart = BmsTimelineCompiler.Compile(BmsParser.ParseText("total.bms", source));
Assert.That(chart.Metadata.Total, Is.EqualTo(470.5).Within(0.000001));
}
[Test]
public void LaneModifier_MirrorReversesAllFiveLanes()
{
var laneMap = LanePermutation.Create(LaneModifier.Mirror);
Assert.That(laneMap, Is.EqualTo(new[] { 4, 3, 2, 1, 0 }));
}
[Test]
public void LaneModifier_MirrorKeepsScratchFixedAndReversesSevenKeys()
{
var laneMap = LanePermutation.Create(LaneModifier.Mirror, keyCount: 7, hasScratch: true);
Assert.That(laneMap, Is.EqualTo(new[] { 0, 7, 6, 5, 4, 3, 2, 1 }));
}
[Test]
public void ParserAndConverter_SupportOnePlusSevenAsEightGameplayLanes()
{
const string source = @"
#TITLE 1+7 Test
#BPM 120
#WAV01 hit.wav
#00111:01
#00112:01
#00113:01
#00114:01
#00115:01
#00118:01
#00119:01
#00116:01
";
var compiled = BmsTimelineCompiler.Compile(BmsParser.ParseText("one-plus-seven.bms", source));
var chart = BmsPlayableChartConverter.Convert(compiled).Chart;
Assert.That(chart.Metadata.KeyCount, Is.EqualTo(7));
Assert.That(chart.Metadata.HasScratch, Is.True);
Assert.That(chart.Metadata.TotalLaneCount, Is.EqualTo(8));
Assert.That(chart.Notes, Has.Count.EqualTo(8));
Assert.That(chart.Notes.Select(note => note.Lane), Is.EquivalentTo(Enumerable.Range(0, 8)));
Assert.That(chart.Notes.Single(note => note.IsScratch).Lane, Is.Zero);
}
[TestCase(7, false, "7K")]
[TestCase(7, true, "1+7K")]
[TestCase(5, false, "5K")]
[TestCase(5, true, "1+5K")]
public void KeyModeLabel_DistinguishesKeysFromScratchAndKeys(
int keyCount,
bool hasScratch,
string expected)
{
Assert.That(
ChartKeyMode.FormatLabel(keyCount, hasScratch),
Is.EqualTo(expected));
}
[TestCase("#00111:01\n#00112:01\n#00114:01\n#00115:01", 4)]
[TestCase("#00111:01\n#00112:01\n#00113:01\n#00114:01\n#00115:01", 5)]
[TestCase("#00111:01\n#00112:01\n#00113:01\n#00115:01\n#00118:01\n#00119:01", 6)]
[TestCase("#00111:01\n#00112:01\n#00113:01\n#00114:01\n#00115:01\n#00118:01\n#00119:01", 7)]
[TestCase("#00151:0102\n#00152:0102\n#00154:0102\n#00155:0102", 4)]
public void Parser_DetectsSupportedKeyLayouts(string events, int expectedKeyCount)
{
var document = BmsParser.ParseText(
"layout.bms",
"#BPM 120\n#WAV01 hit.wav\n#WAV02 tail.wav\n" + events);
var chart = BmsTimelineCompiler.Compile(document);
Assert.That(chart.Metadata.KeyCount, Is.EqualTo(expectedKeyCount));
Assert.That(chart.Notes.Select(note => note.Lane).Distinct().Count(), Is.EqualTo(expectedKeyCount));
}
[Test]
public void SixKeyContract_SkipsCenterChannelAndKeepsRightmostChannel()
{
var document = BmsParser.ParseText(
"six-key-with-center-stray.bms",
"#BPM 120\n" +
"#WAV01 one.wav\n#WAV02 two.wav\n#WAV03 three.wav\n" +
"#WAV04 stray.wav\n#WAV05 five.wav\n#WAV06 six.wav\n#WAV07 seven.wav\n" +
"#00111:01\n#00112:02\n#00113:03\n#00114:04\n" +
"#00115:05\n#00118:06\n#00119:07\n");
document.Metadata.KeyCount = 6;
var chart = BmsTimelineCompiler.Compile(document);
Assert.That(chart.Metadata.KeyCount, Is.EqualTo(6));
Assert.That(chart.Notes, Has.Count.EqualTo(6));
Assert.That(chart.Notes.Select(note => note.Lane),
Is.EquivalentTo(Enumerable.Range(0, 6)));
Assert.That(chart.Notes.Select(note => note.SoundId), Does.Contain("07"));
Assert.That(chart.Notes.Select(note => note.SoundId), Does.Not.Contain("04"));
}
[Test]
public void LaneModifier_RandomCreatesOneDeterministicPermutationPerPlay()
{
var first = LanePermutation.Create(LaneModifier.Random, randomSeed: 31415);
var second = LanePermutation.Create(LaneModifier.Random, randomSeed: 31415);
Assert.That(first, Is.EqualTo(second));
Assert.That(first, Is.EquivalentTo(new[] { 0, 1, 2, 3, 4 }));
Assert.That(first, Is.Not.EqualTo(new[] { 0, 1, 2, 3, 4 }));
}
[Test]
public void PlayableChartConverter_AppliesTheSameMappingWithoutMutatingSourceNotes()
{
var notes = Enumerable.Range(0, 5)
.Select(lane => new BmsNote { Id = lane, Lane = lane })
.ToArray();
var source = new BmsChart();
source.Notes.AddRange(notes);
var conversion = BmsPlayableChartConverter.Convert(source, LaneModifier.Random, randomSeed: 7);
for (var lane = 0; lane < notes.Length; lane++)
{
Assert.That(conversion.Chart.Notes.Single(note => note.Id == lane).Lane,
Is.EqualTo(conversion.LaneMap[lane]));
Assert.That(notes[lane].Lane, Is.EqualTo(lane));
}
}
[Test]
public void InvisibleBmsChannels_BecomeModifierAwareLaneSoundEvents()
{
var document = BmsParser.ParseText(
"invisible.bms",
"#BPM 120\n#WAV01 visible.wav\n#WAV02 empty.wav\n#00111:01\n#00231:02");
var source = BmsTimelineCompiler.Compile(document);
Assert.That(source.LaneSoundEvents, Has.Count.EqualTo(1));
Assert.That(source.LaneSoundEvents[0].Lane, Is.Zero);
Assert.That(source.LaneSoundEvents[0].SoundId, Is.EqualTo("02"));
var conversion = BmsPlayableChartConverter.Convert(source, LaneModifier.Mirror);
Assert.That(conversion.Chart.LaneSoundEvents, Has.Count.EqualTo(1));
Assert.That(conversion.Chart.LaneSoundEvents[0].Lane, Is.EqualTo(4));
Assert.That(conversion.Chart.LaneSoundEvents[0].SoundId, Is.EqualTo("02"));
}
[Test]
public void PlayableChartConverter_PreservesLongNoteIdentityTimingAndResourceTimelines()
{
var source = new BmsChart
{
Metadata = new BmsMetadata
{
ChartPath = "/songs/example/chart.bms",
Title = "Example",
Artist = "Composer",
StageFile = "stage.png",
Total = 420,
},
};
source.WavDefinitions["01"] = "head.wav";
source.BmpDefinitions["02"] = "movie.mp4";
source.Notes.Add(new BmsNote
{
Id = 42,
Lane = 1,
StartTime = 1.25,
EndTime = 3.5,
SoundId = "01",
TailSoundId = "09",
});
source.BackgroundSounds.Add(new BmsAudioEvent { Time = 0.5, SoundId = "01" });
source.BgaEvents.Add(new BmsBgaEvent
{
Time = 0.75,
ResourceId = "02",
Layer = BmsBgaLayer.Layer,
});
var chart = BmsPlayableChartConverter.Convert(source, LaneModifier.Mirror).Chart;
Assert.That(chart.Notes, Has.Count.EqualTo(1));
Assert.That(chart.Notes[0].Id, Is.EqualTo(42));
Assert.That(chart.Notes[0].Lane, Is.EqualTo(3));
Assert.That(chart.Notes[0].StartTime, Is.EqualTo(1.25));
Assert.That(chart.Notes[0].EndTime, Is.EqualTo(3.5));
Assert.That(chart.Notes[0].SoundId, Is.EqualTo("01"));
Assert.That(chart.Notes[0].TailSoundId, Is.EqualTo("09"));
Assert.That(chart.Metadata.SourcePath, Is.EqualTo("/songs/example/chart.bms"));
Assert.That(chart.Metadata.GaugeTotal, Is.EqualTo(420));
Assert.That(chart.AudioResources["01"], Is.EqualTo("head.wav"));
Assert.That(chart.VisualResources["02"], Is.EqualTo("movie.mp4"));
Assert.That(chart.BackgroundAudioEvents, Has.Count.EqualTo(1));
Assert.That(chart.VisualEvents[0].Layer, Is.EqualTo(PlayableVisualLayer.Overlay));
}
[Test]
public void PlayableChartConverter_PreservesScratchSoundAndDuration()
{
var source = new BmsChart
{
Metadata = new BmsMetadata { KeyCount = 5, HasScratch = true },
ScratchObjectCount = 1,
Duration = 102.0,
};
source.Notes.Add(new BmsNote
{
Id = 1,
Lane = 1,
StartTime = 1.0,
EndTime = 1.0,
SoundId = "KEY",
});
source.Notes.Add(new BmsNote
{
Id = 2,
Lane = 0,
StartTime = 100.0,
EndTime = 100.0,
SoundId = "SCRATCH",
IsScratch = true,
});
var chart = BmsPlayableChartConverter.Convert(source).Chart;
Assert.That(chart.Notes, Has.Count.EqualTo(2));
Assert.That(chart.Notes, Has.Some.Property("SoundId").EqualTo("KEY"));
Assert.That(chart.Notes, Has.Some.Property("SoundId").EqualTo("SCRATCH"));
Assert.That(chart.ScratchObjectCount, Is.EqualTo(1));
Assert.That(chart.Duration, Is.EqualTo(102.0));
Assert.That(source.Notes, Has.Count.EqualTo(2));
Assert.That(source.Duration, Is.EqualTo(102.0));
}
[Test]
public void ParserAndTimeline_CompileBmsScrollWithoutChangingJudgementTime()
{
const string source = @"
#TITLE Scroll Test
#BPM 120
#SCROLL01 0.5
#SCROLL02 2.0
#001SC:0102
#00111:0101
";
var document = BmsParser.ParseText("scroll.bms", source);
var chart = BmsTimelineCompiler.Compile(document);
Assert.That(document.Scrolls["01"], Is.EqualTo(0.5));
Assert.That(document.Scrolls["02"], Is.EqualTo(2.0));
Assert.That(chart.Notes[0].StartTime, Is.EqualTo(2.0).Within(0.000001));
Assert.That(chart.Notes[1].StartTime, Is.EqualTo(3.0).Within(0.000001));
Assert.That(chart.ScrollMap.PositionAtBeat(4), Is.EqualTo(4).Within(0.000001));
Assert.That(chart.ScrollMap.PositionAtBeat(6), Is.EqualTo(5).Within(0.000001));
Assert.That(chart.ScrollMap.PositionAtBeat(8), Is.EqualTo(9).Within(0.000001));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 11e00ecd33ce4a44b4ba5ae35921a2b9
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 551153b8a58bd4ddd8daf9dd5dcde978
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ff984ac8f483b429aace8d868437ea96
@@ -0,0 +1,280 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using NUnit.Framework;
using ProjectQuint.Core.Bms;
using ProjectQuint.Core.Charts;
using ProjectQuint.Core.Course;
using ProjectQuint.Core.Gauge;
using ProjectQuint.Core.Judgement;
using ProjectQuint.Core.Timing;
namespace ProjectQuint.Tests
{
public sealed class GaugeTests
{
[Test]
public void PlayGauge_NormalUsesTotalPerNoteAndSeventyFivePercentBorder()
{
var gauge = new PlayGauge(GaugeMode.Normal, declaredTotal: 100, totalNotes: 100);
Assert.That(gauge.Value, Is.EqualTo(20));
Assert.That(gauge.IsCleared, Is.False);
for (var index = 0; index < 55; index++)
gauge.Apply(JudgementGrade.Kool);
Assert.That(gauge.Value, Is.EqualTo(75).Within(0.000001));
Assert.That(gauge.IsCleared, Is.True);
gauge.Apply(JudgementGrade.Miss);
Assert.That(gauge.Value, Is.EqualTo(69).Within(0.000001));
}
[Test]
public void PlayGauge_CourseModeCarriesValueAndAppliesLr2DamageReduction()
{
var gauge = new PlayGauge(
GaugeMode.Class,
declaredTotal: 0,
totalNotes: 100,
restoredValue: 29.0);
gauge.Apply(JudgementGrade.Miss);
Assert.That(gauge.Value, Is.EqualTo(27.2).Within(0.000001));
gauge.ApplyEmptyInputPenalty();
Assert.That(gauge.Value, Is.EqualTo(26.0).Within(0.000001));
gauge.Apply(JudgementGrade.Kool);
Assert.That(gauge.Value, Is.EqualTo(26.1).Within(0.000001));
Assert.That(gauge.IsCleared, Is.True);
}
[Test]
public void PlayGauge_FiveKeyCourseProfileUsesConfiguredDeltas()
{
var gauge = new PlayGauge(
GaugeMode.Class,
declaredTotal: 0,
totalNotes: 100,
restoredValue: 50.0,
courseGaugeProfile: CourseGaugeProfile.FiveKey);
gauge.Apply(JudgementGrade.Kool);
gauge.Apply(JudgementGrade.Good);
gauge.Apply(JudgementGrade.Miss);
gauge.ApplyEmptyInputPenalty();
Assert.That(gauge.Value, Is.EqualTo(48.51).Within(0.000001));
}
[Test]
public void PlayGauge_SevenKeyCourseProfileUsesThresholdDamageReduction()
{
var gauge = new PlayGauge(
GaugeMode.Class,
declaredTotal: 0,
totalNotes: 100,
restoredValue: 24.0,
courseGaugeProfile: CourseGaugeProfile.SevenKey);
gauge.Apply(JudgementGrade.Miss);
Assert.That(gauge.Value, Is.EqualTo(21.6).Within(0.000001));
gauge.Apply(JudgementGrade.Cool);
Assert.That(gauge.Value, Is.EqualTo(21.72).Within(0.000001));
}
[Test]
public void PlayGauge_NormalNeverDropsBelowTwoPercent()
{
var gauge = new PlayGauge(GaugeMode.Normal, declaredTotal: 300, totalNotes: 100);
for (var index = 0; index < 20; index++)
gauge.Apply(JudgementGrade.Miss);
Assert.That(gauge.Value, Is.EqualTo(2));
Assert.That(gauge.IsFailed, Is.False);
Assert.That(gauge.IsCleared, Is.False);
}
[Test]
public void PlayGauge_HardRecoversAndUsesLowGaugeDamageReduction()
{
var gauge = new PlayGauge(GaugeMode.Hard, declaredTotal: 300, totalNotes: 100);
for (var index = 0; index < 6; index++)
gauge.Apply(JudgementGrade.Miss);
Assert.That(gauge.Value, Is.EqualTo(40));
gauge.Apply(JudgementGrade.Miss);
Assert.That(gauge.Value, Is.EqualTo(32));
gauge.Apply(JudgementGrade.Kool);
gauge.Apply(JudgementGrade.Cool);
gauge.Apply(JudgementGrade.Good);
Assert.That(gauge.Value, Is.EqualTo(32.3).Within(0.000001));
Assert.That(gauge.IsFailed, Is.False);
}
[Test]
public void PlayGauge_StandardHardRecoversAndReducesDamageAtLowGauge()
{
var gauge = new PlayGauge(
GaugeMode.Hard,
declaredTotal: 300,
totalNotes: 100,
restoredValue: 49.0);
gauge.Apply(JudgementGrade.Kool);
Assert.That(gauge.Value, Is.EqualTo(49.15).Within(0.000001));
gauge.Apply(JudgementGrade.Miss);
Assert.That(gauge.Value, Is.EqualTo(41.15).Within(0.000001));
Assert.That(gauge.IsFailed, Is.False);
}
[Test]
public void PlayGauge_StandardHardSurvivesTenConsecutiveMisses()
{
var gauge = new PlayGauge(
GaugeMode.Hard,
declaredTotal: 300,
totalNotes: 100);
for (var index = 0; index < 10; index++)
gauge.Apply(JudgementGrade.Miss);
Assert.That(gauge.Value, Is.EqualTo(14.0).Within(0.000001));
Assert.That(gauge.IsFailed, Is.False);
}
[Test]
public void PlayGauge_ExHardUsesReducedRecoveryAndNoLowGaugeDamageReduction()
{
var gauge = new PlayGauge(
GaugeMode.ExHard,
declaredTotal: 300,
totalNotes: 100,
restoredValue: 50.0);
gauge.Apply(JudgementGrade.Kool);
gauge.Apply(JudgementGrade.Cool);
gauge.Apply(JudgementGrade.Good);
Assert.That(gauge.Value, Is.EqualTo(50.21).Within(0.000001));
gauge.Apply(JudgementGrade.Miss);
Assert.That(gauge.Value, Is.EqualTo(34.21).Within(0.000001));
gauge.ApplyEmptyInputPenalty();
Assert.That(gauge.Value, Is.EqualTo(26.21).Within(0.000001));
}
[Test]
public void PlayGauge_ExHardFailsOnSeventhConsecutiveMiss()
{
var gauge = new PlayGauge(
GaugeMode.ExHard,
declaredTotal: 300,
totalNotes: 100);
for (var index = 0; index < 6; index++)
gauge.Apply(JudgementGrade.Miss);
Assert.That(gauge.Value, Is.EqualTo(4.0).Within(0.000001));
Assert.That(gauge.IsFailed, Is.False);
gauge.Apply(JudgementGrade.Miss);
Assert.That(gauge.Value, Is.Zero);
Assert.That(gauge.IsFailed, Is.True);
}
[Test]
public void GaugeFallbackChain_UsesExactParallelHardAndNormalStates()
{
var chain = new GaugeFallbackChain(
GaugeMode.ExHard,
declaredTotal: 300,
totalNotes: 100,
fallbackEnabled: true);
var hardReference = new PlayGauge(GaugeMode.Hard, 300, 100);
var normalReference = new PlayGauge(GaugeMode.Normal, 300, 100);
GaugeTransition transition = default;
for (var index = 0; index < 7; index++)
{
transition = chain.Apply(JudgementGrade.Miss);
hardReference.Apply(JudgementGrade.Miss);
normalReference.Apply(JudgementGrade.Miss);
}
Assert.That(transition.Changed, Is.True);
Assert.That(transition.From, Is.EqualTo(GaugeMode.ExHard));
Assert.That(transition.To, Is.EqualTo(GaugeMode.Hard));
Assert.That(chain.ActiveGauge.Mode, Is.EqualTo(GaugeMode.Hard));
Assert.That(chain.ActiveGauge.Value, Is.EqualTo(hardReference.Value).Within(0.000001));
while (chain.ActiveGauge.Mode == GaugeMode.Hard)
{
transition = chain.Apply(JudgementGrade.Miss);
hardReference.Apply(JudgementGrade.Miss);
normalReference.Apply(JudgementGrade.Miss);
}
Assert.That(transition.Changed, Is.True);
Assert.That(transition.From, Is.EqualTo(GaugeMode.Hard));
Assert.That(transition.To, Is.EqualTo(GaugeMode.Normal));
Assert.That(chain.ActiveGauge.Value, Is.EqualTo(normalReference.Value).Within(0.000001));
for (var index = 0; index < 25; index++)
{
chain.Apply(JudgementGrade.Kool);
normalReference.Apply(JudgementGrade.Kool);
}
Assert.That(chain.ActiveGauge.Mode, Is.EqualTo(GaugeMode.Normal));
Assert.That(chain.ActiveGauge.Value, Is.EqualTo(normalReference.Value).Within(0.000001));
Assert.That(chain.ActiveGauge.IsCleared, Is.True);
}
[TestCase(GaugeMode.Class)]
[TestCase(GaugeMode.Normal)]
public void GaugeFallbackChain_DoesNotCreateUnsupportedFallbacks(GaugeMode mode)
{
var chain = new GaugeFallbackChain(
mode,
declaredTotal: 300,
totalNotes: 100,
fallbackEnabled: true);
Assert.That(chain.GaugeCount, Is.EqualTo(1));
Assert.That(chain.HasFallback, Is.False);
}
[Test]
public void GaugeFallbackChain_DisabledExHardStillFailsWithoutTransition()
{
var chain = new GaugeFallbackChain(
GaugeMode.ExHard,
declaredTotal: 300,
totalNotes: 100,
fallbackEnabled: false);
GaugeTransition transition = default;
for (var index = 0; index < 7; index++)
transition = chain.Apply(JudgementGrade.Miss);
Assert.That(transition.Changed, Is.False);
Assert.That(chain.ActiveGauge.Mode, Is.EqualTo(GaugeMode.ExHard));
Assert.That(chain.ActiveGauge.IsFailed, Is.True);
}
[Test]
public void PlayGauge_UsesOfficialDefaultTotalWhenHeaderIsMissing()
{
var gauge = new PlayGauge(GaugeMode.Normal, declaredTotal: 0, totalNotes: 1000);
Assert.That(gauge.Total, Is.EqualTo(7.605 * 1000 / 16.5).Within(0.000001));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 443a38441d2c477db7e4983a4b938f83
@@ -0,0 +1,263 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using NUnit.Framework;
using ProjectQuint.Core.Bms;
using ProjectQuint.Core.Charts;
using ProjectQuint.Core.Course;
using ProjectQuint.Core.Gauge;
using ProjectQuint.Core.Judgement;
using ProjectQuint.Core.Timing;
namespace ProjectQuint.Tests
{
public sealed class MusicalTimingTests
{
[Test]
public void Compiler_PreservesBmpDefinitionsAndBgaLayerEvents()
{
const string source = @"
#BPM 120
#BMP01 movie.mpg
#BMP02 poor.png
#BMP03 layer.png
#00104:01
#00106:02
#00107:03
";
var chart = BmsTimelineCompiler.Compile(BmsParser.ParseText("bga.bms", source));
Assert.That(chart.BmpDefinitions["01"], Is.EqualTo("movie.mpg"));
Assert.That(chart.BgaEvents, Has.Count.EqualTo(3));
Assert.That(chart.BgaEvents[0].Time, Is.EqualTo(2.0).Within(0.000001));
Assert.That(chart.BgaEvents, Has.Some.Property("Layer").EqualTo(BmsBgaLayer.Base));
Assert.That(chart.BgaEvents, Has.Some.Property("Layer").EqualTo(BmsBgaLayer.Poor));
Assert.That(chart.BgaEvents, Has.Some.Property("Layer").EqualTo(BmsBgaLayer.Layer));
}
[Test]
public void Compiler_PairsLongNoteChannelWithoutTailJudgement()
{
const string source = @"
#BPM 120
#WAV01 head.wav
#WAV02 tail.wav
#00151:0102
";
var chart = BmsTimelineCompiler.Compile(BmsParser.ParseText("long.bms", source));
Assert.That(chart.Notes, Has.Count.EqualTo(1));
Assert.That(chart.Notes[0].IsLong, Is.True);
Assert.That(chart.Notes[0].StartTime, Is.EqualTo(2.0).Within(0.000001));
Assert.That(chart.Notes[0].EndTime, Is.EqualTo(3.0).Within(0.000001));
}
[TestCase(120.0, 0.125)]
[TestCase(150.0, 0.100)]
[TestCase(180.0, 0.0833333333333333)]
public void LongNoteMusicalSixteenths_FollowTempo(double bpm, double expectedInterval)
{
var tempoMap = TempoMap.Constant(bpm);
var note = new PlayableNote
{
Id = 1,
Lane = 0,
StartTime = 0,
EndTime = tempoMap.BeatToTime(1.0),
StartBeat = 0,
EndBeat = 1.0,
};
var ticks = LongNoteTickPolicy.MusicalSixteenths.CreateTickTimes(note, tempoMap);
Assert.That(ticks, Has.Length.EqualTo(4));
Assert.That(ticks[0], Is.EqualTo(expectedInterval).Within(0.000001));
for (var index = 1; index < ticks.Length; index++)
Assert.That(ticks[index] - ticks[index - 1],
Is.EqualTo(expectedInterval).Within(0.000001));
}
[Test]
public void Compiler_PreservesMeasureLengthTempoPriorityAndStopInTempoMap()
{
const string source = @"
#BPM 120
#BPM01 180
#STOP01 48
#00002:0.5
#00003:0078
#00008:0001
#00009:0001
#00011:0001
#00112:01
";
var chart = BmsTimelineCompiler.Compile(BmsParser.ParseText("timing.bms", source));
var stopNote = chart.Notes.Single(note => note.Lane == 0);
var nextMeasureNote = chart.Notes.Single(note => note.Lane == 1);
Assert.That(stopNote.StartBeat, Is.EqualTo(1.0).Within(0.000001));
Assert.That(stopNote.StartTime, Is.EqualTo(0.5).Within(0.000001),
"Objects at a STOP position are judged before the STOP duration.");
Assert.That(nextMeasureNote.StartBeat, Is.EqualTo(2.0).Within(0.000001));
Assert.That(nextMeasureNote.StartTime, Is.EqualTo(7.0 / 6.0).Within(0.000001));
Assert.That(chart.TempoMap.Nodes.Count, Is.EqualTo(2));
Assert.That(chart.TempoMap.Nodes[1].Bpm, Is.EqualTo(180));
Assert.That(chart.TempoMap.Nodes[1].StopDuration, Is.EqualTo(1.0 / 3.0).Within(0.000001));
}
[Test]
public void Compiler_DerivesBpmRangeFromUsedTempoEventsOnly()
{
const string source = @"
#BPM 150
#BPM01 210
#BPM02 999
#SCROLL01 0.5
#00103:5A
#00208:01
#002SC:01
#00311:01
";
var compiled = BmsTimelineCompiler.Compile(
BmsParser.ParseText("tempo-range.bms", source));
var playable = BmsPlayableChartConverter.Convert(compiled).Chart;
Assert.That(compiled.Metadata.InitialBpm, Is.EqualTo(150));
Assert.That(compiled.Metadata.MinimumBpm, Is.EqualTo(90));
Assert.That(compiled.Metadata.MaximumBpm, Is.EqualTo(210));
Assert.That(playable.Metadata.InitialTempo, Is.EqualTo(150));
Assert.That(playable.Metadata.MinimumTempo, Is.EqualTo(90));
Assert.That(playable.Metadata.MaximumTempo, Is.EqualTo(210));
Assert.That(
compiled.Metadata.MaximumBpm,
Is.Not.EqualTo(999),
"An unused extended BPM definition must not enter the displayed range.");
}
[Test]
public void ScrollMap_HoldsPositionForStopWhileSongTimeAdvances()
{
var tempoMap = new TempoMap(new[]
{
new TempoMapNode(0, 0, 120),
new TempoMapNode(1, 0.5, 180, stopDuration: 1.0 / 3.0),
});
var scrollMap = new ScrollMap(tempoMap);
Assert.That(scrollMap.PositionAtTime(0.5), Is.EqualTo(1.0).Within(0.000001));
Assert.That(scrollMap.PositionAtTime(0.7), Is.EqualTo(1.0).Within(0.000001));
Assert.That(scrollMap.PositionAtTime(5.0 / 6.0), Is.EqualTo(1.0).Within(0.000001));
Assert.That(scrollMap.PositionAtTime(1.0), Is.EqualTo(1.5).Within(0.000001));
Assert.That(tempoMap.IsStopped(0.7), Is.True);
Assert.That(tempoMap.IsStopped(1.0), Is.False);
}
[Test]
public void ScrollMap_TraversesZeroScrollBeforeFindingForwardVisibilityEnd()
{
var tempoMap = TempoMap.Constant(120);
var scrollMap = new ScrollMap(tempoMap, new[]
{
new ScrollMapNode(1, 0),
new ScrollMapNode(3, 1),
});
var found = scrollMap.TryGetTimeAtForwardDistance(
songTime: 0.25,
distance: 1.0,
out var visibilityEndTime);
Assert.That(found, Is.True);
Assert.That(visibilityEndTime, Is.EqualTo(1.75).Within(0.000001));
Assert.That(scrollMap.PositionAtTime(0.5), Is.EqualTo(1).Within(0.000001));
Assert.That(scrollMap.PositionAtTime(1.5), Is.EqualTo(1).Within(0.000001));
}
[Test]
public void Timeline_WarnsWhenScrollObjectIsUndefined()
{
var chart = BmsTimelineCompiler.Compile(BmsParser.ParseText(
"undefined-scroll.bms",
"#BPM 120\n#001SC:01\n#00111:01"));
Assert.That(chart.Warnings, Has.Some.Contains("Undefined SCROLL object 01"));
Assert.That(chart.ScrollMap.HasVariableScroll, Is.False);
}
[Test]
public void TempoMap_NegativePrerollIsNotShiftedByAStopAtBeatZero()
{
var tempoMap = new TempoMap(new[]
{
new TempoMapNode(0, 0, 120, stopDuration: 0.5),
});
Assert.That(tempoMap.TimeToBeat(-0.25), Is.EqualTo(-0.5).Within(0.000001));
Assert.That(tempoMap.TimeToBeat(0.25), Is.Zero.Within(0.000001));
Assert.That(tempoMap.TimeToBeat(0.75), Is.EqualTo(0.5).Within(0.000001));
}
[Test]
public void LongNoteMusicalTicks_CrossTempoChangeWithoutDuplication()
{
var tempoMap = new TempoMap(new[]
{
new TempoMapNode(0, 0, 120),
new TempoMapNode(1, 0.5, 180),
});
var note = new PlayableNote
{
Id = 1,
Lane = 0,
StartTime = 0,
EndTime = 5.0 / 6.0,
StartBeat = 0,
EndBeat = 2,
};
var ticks = LongNoteTickPolicy.MusicalSixteenths.CreateTickTimes(note, tempoMap);
var engine = new RhythmJudgementEngine(new[] { note }, tempoMap: tempoMap);
engine.Press(0, 0);
engine.Update(note.EndTime);
Assert.That(ticks, Has.Length.EqualTo(8));
Assert.That(ticks[3], Is.EqualTo(0.5).Within(0.000001));
Assert.That(ticks[4] - ticks[3], Is.EqualTo(1.0 / 12.0).Within(0.000001));
Assert.That(engine.TotalScoringUnitCount, Is.EqualTo(9));
Assert.That(engine.Combo, Is.EqualTo(9));
Assert.That(engine.Score, Is.EqualTo(1_000_009));
}
[Test]
public void SongClock_UsesDspNormallyAndFallsBackOnlyAfterARealStall()
{
var clock = new ResilientSongClock();
clock.Reset(currentDspTime: 100.0, startDspTime: 100.0, currentRealtime: 0.0);
Assert.That(clock.Sample(100.1, 100.0, 0.1), Is.EqualTo(0.1).Within(0.000001));
Assert.That(clock.IsFallbackActive, Is.False);
Assert.That(clock.Sample(100.1, 100.0, 0.2), Is.EqualTo(0.1).Within(0.000001));
Assert.That(clock.IsFallbackActive, Is.False);
Assert.That(clock.Sample(100.1, 100.0, 0.4), Is.EqualTo(0.4).Within(0.000001));
Assert.That(clock.IsFallbackActive, Is.True);
Assert.That(clock.Sample(100.1, 100.0, 0.5), Is.EqualTo(0.5).Within(0.000001));
}
[Test]
public void SongClock_MapsInputEventRelativeToTheCurrentInputClock()
{
var mapped = ResilientSongClock.ResolveInputEventSongTime(
inputEventTime: 10.995,
currentInputTime: 11.000,
currentSongTime: 2.000);
Assert.That(mapped, Is.EqualTo(1.995).Within(0.000001));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 32a7895b9d504aa8bdbe439bebff65c7
@@ -0,0 +1,8 @@
{
"name": "ProjectQuint.Tests.Core",
"references": ["ProjectQuint.Core"],
"includePlatforms": ["Editor"],
"optionalUnityReferences": ["TestAssemblies"],
"autoReferenced": false,
"noEngineReferences": true
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 95d4580a877742e9ad20db382f8d33d3
@@ -0,0 +1,497 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using NUnit.Framework;
using ProjectQuint.Core.Bms;
using ProjectQuint.Core.Charts;
using ProjectQuint.Core.Course;
using ProjectQuint.Core.Gauge;
using ProjectQuint.Core.Judgement;
using ProjectQuint.Core.Timing;
namespace ProjectQuint.Tests
{
public sealed class RhythmJudgementTests
{
[Test]
public void LongNoteReleasePolicy_ExposesEightyThreeAndOneHundredMillisecondBoundaries()
{
var strictCandidate = new LongNoteReleasePolicy(0.083333);
var djmaxCandidate = LongNoteReleasePolicy.DjmaxLike;
Assert.That(strictCandidate.Accepts(1.916667, 2.0), Is.True);
Assert.That(strictCandidate.Accepts(1.916665, 2.0), Is.False);
Assert.That(djmaxCandidate.Accepts(1.900000, 2.0), Is.True);
Assert.That(djmaxCandidate.Accepts(1.899998, 2.0), Is.False);
}
[Test]
public void Judgement_KoolBoundaryIsInclusive()
{
var profile = JudgementProfile.ProjectQuint;
Assert.That(profile.Grade(0.042), Is.EqualTo(JudgementGrade.Kool));
Assert.That(profile.Grade(0.042001), Is.EqualTo(JudgementGrade.Cool));
Assert.That(profile.Grade(0.083), Is.EqualTo(JudgementGrade.Cool));
Assert.That(profile.Grade(0.083001), Is.EqualTo(JudgementGrade.Good));
Assert.That(profile.Grade(0.125), Is.EqualTo(JudgementGrade.Good));
Assert.That(profile.Grade(0.125001), Is.EqualTo(JudgementGrade.Miss));
}
[Test]
public void JudgementBreakdown_CountsEveryFinalGradeExactlyOnce()
{
var notes = Enumerable.Range(1, 4)
.Select(index => new PlayableNote
{
Id = index,
Lane = 0,
StartTime = index,
EndTime = index,
})
.ToArray();
var engine = new RhythmJudgementEngine(notes);
engine.Press(0, 1.0);
engine.Press(0, 2.05);
engine.Press(0, 3.10);
engine.Update(4.2);
Assert.That(engine.KoolCount, Is.EqualTo(1));
Assert.That(engine.CoolCount, Is.EqualTo(1));
Assert.That(engine.GoodCount, Is.EqualTo(1));
Assert.That(engine.MissCount, Is.EqualTo(1));
Assert.That(
engine.KoolCount + engine.CoolCount + engine.GoodCount + engine.MissCount,
Is.EqualTo(engine.JudgedNoteCount));
Assert.That(engine.AccuracyPercent, Is.EqualTo(47.5).Within(0.000001));
Assert.That(engine.Score, Is.EqualTo(475001));
Assert.That(engine.Combo, Is.Zero);
}
[Test]
public void LongNote_EarlyReleaseBreaksComboButRemainsVisibleUntilTail()
{
var note = new PlayableNote { Id = 7, Lane = 2, StartTime = 1.0, EndTime = 2.0, SoundId = "01" };
var engine = new RhythmJudgementEngine(new[] { note });
var head = engine.Press(2, 1.0);
var release = engine.Release(2, 1.5);
var retry = engine.Press(2, 1.6);
Assert.That(head?.Grade, Is.EqualTo(JudgementGrade.Kool));
Assert.That(release?.Grade, Is.EqualTo(JudgementGrade.Miss));
Assert.That(release?.LongNoteBreak, Is.True);
Assert.That(retry, Is.Null);
Assert.That(engine.GetState(7), Is.EqualTo(RuntimeNoteState.BrokenHolding));
Assert.That(engine.Combo, Is.Zero);
Assert.That(engine.Score, Is.EqualTo(545461));
Assert.That(engine.JudgedNoteCount, Is.EqualTo(11));
Assert.That(engine.ResultSequence, Is.EqualTo(7),
"The head, five earned ticks, and long-note break must retrigger HUD feedback.");
Assert.That(engine.AccuracyPercent, Is.EqualTo(600.0 / 11.0).Within(0.000001));
Assert.That(engine.KoolCount, Is.EqualTo(6));
Assert.That(engine.MissCount, Is.EqualTo(5));
Assert.That(engine.PurePerfectCount, Is.EqualTo(6));
Assert.That(engine.IsFinished, Is.False);
engine.Update(2.0);
Assert.That(engine.GetState(7), Is.EqualTo(RuntimeNoteState.Failed));
Assert.That(engine.IsFinished, Is.True);
}
[Test]
public void LongNote_MissedHeadRemainsUnfinishedUntilItsTail()
{
var note = new PlayableNote { Id = 8, Lane = 1, StartTime = 1.0, EndTime = 3.0, SoundId = "01" };
var engine = new RhythmJudgementEngine(new[] { note });
engine.Update(1.2);
Assert.That(engine.GetState(8), Is.EqualTo(RuntimeNoteState.BrokenHolding),
"A missed long-note head must enter the dead-until-tail state, not the terminal failed state.");
Assert.That(engine.MissCount, Is.GreaterThan(0));
Assert.That(engine.IsFinished, Is.False,
"A head-missed long note must remain in the gameplay lifecycle until its tail.");
engine.Update(2.999);
Assert.That(engine.IsFinished, Is.False);
engine.Update(3.0);
Assert.That(engine.GetState(8), Is.EqualTo(RuntimeNoteState.Failed));
Assert.That(engine.IsFinished, Is.True);
}
[Test]
public void Accuracy_UsesApprovedGradeWeightsAndOnlyJudgedNotes()
{
var notes = Enumerable.Range(0, 119)
.Select(index => new PlayableNote
{
Id = index,
Lane = 0,
StartTime = index,
EndTime = index,
SoundId = "01",
})
.ToArray();
var engine = new RhythmJudgementEngine(notes);
Assert.That(engine.AccuracyPercent, Is.EqualTo(100.0));
for (var index = 0; index < 118; index++)
engine.Press(0, index);
engine.Press(0, 118.05);
Assert.That(engine.JudgedNoteCount, Is.EqualTo(119));
Assert.That(engine.AccuracyPercent, Is.EqualTo(99.7478991597).Within(0.0000001));
}
[Test]
public void Score_NormalizesAccuracyToOneMillionAndAddsPurePerfectBonus()
{
var notes = Enumerable.Range(0, 16)
.Select(index => new PlayableNote
{
Id = index,
Lane = 0,
StartTime = index,
EndTime = index,
SoundId = "01",
})
.ToArray();
var engine = new RhythmJudgementEngine(notes);
for (var index = 0; index < 15; index++)
engine.Press(0, index);
engine.Press(0, 15.05);
Assert.That(engine.AccuracyPercent, Is.EqualTo(98.125).Within(0.000001));
Assert.That(engine.PurePerfectCount, Is.EqualTo(15));
Assert.That(engine.Score, Is.EqualTo(981265));
}
[Test]
public void CourseScore_SumsPerStageScoresAndKeepsAccuracyAcrossStageTransition()
{
var first = new RhythmJudgementEngine(new[]
{
new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0 },
new PlayableNote { Id = 2, Lane = 0, StartTime = 2.0 },
});
first.Press(0, 1.0);
first.Press(0, 2.05);
var progress = new CourseProgress();
progress.AddStage(first, cleared: true);
var second = new RhythmJudgementEngine(new[]
{
new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0 },
new PlayableNote { Id = 4, Lane = 0, StartTime = 2.0 },
});
Assert.That(progress.Score, Is.EqualTo(first.Score));
Assert.That(progress.StageResults.Count, Is.EqualTo(1));
Assert.That(
progress.CombinedAccuracyPercent(second),
Is.EqualTo(first.AccuracyPercent).Within(0.000001),
"An unjudged next stage must not make the cumulative HUD rate jump to 100%.");
Assert.That(progress.CombinedScore(second), Is.EqualTo(first.Score));
second.Press(0, 1.0);
second.Press(0, 2.0);
progress.AddStage(second, cleared: true);
Assert.That(progress.Score, Is.EqualTo(first.Score + second.Score));
Assert.That(progress.StageResults.Count, Is.EqualTo(2));
Assert.That(progress.StageResults[1].Score, Is.EqualTo(second.Score));
}
[Test]
public void PurePerfectTheoreticalScore_AddsOnePointPerNeutralTimedNote()
{
const int noteCount = 3200;
var notes = Enumerable.Range(0, noteCount)
.Select(index => new PlayableNote
{
Id = index,
Lane = index % 5,
StartTime = index * 0.01,
EndTime = index * 0.01,
})
.ToArray();
var engine = new RhythmJudgementEngine(notes);
foreach (var note in notes)
engine.Press(note.Lane, note.StartTime);
Assert.That(engine.AccuracyPercent, Is.EqualTo(100.0));
Assert.That(engine.PurePerfectCount, Is.EqualTo(noteCount));
Assert.That(engine.Score, Is.EqualTo(1_003_200));
}
[Test]
public void TimingStats_CountSuccessfulPressesButExcludeAutomaticMisses()
{
var notes = new[]
{
new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0, EndTime = 1.0, SoundId = "01" },
new PlayableNote { Id = 2, Lane = 1, StartTime = 2.0, EndTime = 2.0, SoundId = "01" },
new PlayableNote { Id = 3, Lane = 2, StartTime = 3.0, EndTime = 3.0, SoundId = "01" },
new PlayableNote { Id = 4, Lane = 3, StartTime = 4.0, EndTime = 4.0, SoundId = "01" },
};
var engine = new RhythmJudgementEngine(notes);
engine.Press(0, 0.98);
engine.Press(1, 2.03);
engine.Press(2, 3.0);
engine.Update(4.2);
Assert.That(engine.FastCount, Is.Zero);
Assert.That(engine.LateCount, Is.EqualTo(1));
Assert.That(engine.RecentTimingSamples, Has.Count.EqualTo(1));
Assert.That(engine.RecentTimingSamples[0].OffsetSeconds, Is.EqualTo(0.03).Within(0.000001));
Assert.That(engine.AverageTimingOffsetMilliseconds, Is.EqualTo(10.0 / 3.0).Within(0.000001));
Assert.That(engine.MissCount, Is.EqualTo(1));
}
[Test]
public void TimingFeedback_TreatsPlusOrMinusTwentyTwoMillisecondsAsNeutral()
{
var notes = new[]
{
new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0, EndTime = 1.0 },
new PlayableNote { Id = 2, Lane = 1, StartTime = 2.0, EndTime = 2.0 },
new PlayableNote { Id = 3, Lane = 2, StartTime = 3.0, EndTime = 3.0 },
new PlayableNote { Id = 4, Lane = 3, StartTime = 4.0, EndTime = 4.0 },
};
var engine = new RhythmJudgementEngine(notes);
engine.Press(0, 0.978);
engine.Press(1, 2.022);
engine.Press(2, 2.977999);
engine.Press(3, 4.022001);
Assert.That(engine.FastCount, Is.EqualTo(1));
Assert.That(engine.LateCount, Is.EqualTo(1));
Assert.That(engine.RecentTimingSamples, Has.Count.EqualTo(2));
Assert.That(engine.RecentTimingSamples[0].OffsetSeconds, Is.LessThan(-0.022));
Assert.That(engine.RecentTimingSamples[1].OffsetSeconds, Is.GreaterThan(0.022));
Assert.That(engine.TimingHistogram.Sum(), Is.EqualTo(4));
Assert.That(engine.PurePerfectCount, Is.EqualTo(2));
}
[Test]
public void JudgementOffset_ShiftsHitTimingButKeepsPhysicalFeedbackTime()
{
var note = new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0, EndTime = 1.0, SoundId = "01" };
var engine = new RhythmJudgementEngine(new[] { note });
var result = engine.Press(0, 0.98, 0.02);
Assert.That(result?.Grade, Is.EqualTo(JudgementGrade.Kool));
Assert.That(result?.OffsetSeconds, Is.Zero.Within(0.000001));
Assert.That(engine.LastResultTime, Is.EqualTo(0.98).Within(0.000001));
Assert.That(engine.FastCount, Is.Zero);
Assert.That(engine.LateCount, Is.Zero);
}
[Test]
public void LongNote_HeldPastTailCompletesWithoutReleaseResult()
{
var note = new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0, EndTime = 2.0, SoundId = "01" };
var engine = new RhythmJudgementEngine(new[] { note });
engine.Press(0, 1.0);
engine.Update(2.1);
var release = engine.Release(0, 2.2);
Assert.That(release, Is.Null);
Assert.That(engine.GetState(3), Is.EqualTo(RuntimeNoteState.Completed));
Assert.That(engine.MissCount, Is.Zero);
}
[Test]
public void LongNote_ReleaseWithinDjmaxLikeOneHundredMillisecondsOfTailCompletes()
{
var note = new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0, EndTime = 2.0 };
var engine = new RhythmJudgementEngine(new[] { note });
engine.Press(0, 1.0);
var release = engine.Release(0, 2.0 - RhythmJudgementEngine.LongNoteReleaseGraceSeconds);
Assert.That(release, Is.Null);
Assert.That(engine.GetState(3), Is.EqualTo(RuntimeNoteState.Completed));
Assert.That(engine.Combo, Is.EqualTo(11));
Assert.That(engine.MissCount, Is.Zero);
Assert.That(engine.AccuracyPercent, Is.EqualTo(100));
}
[Test]
public void LongNote_ReleaseBeyondDjmaxLikeGraceStillFailsPermanently()
{
var note = new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0, EndTime = 2.0 };
var engine = new RhythmJudgementEngine(new[] { note });
engine.Press(0, 1.0);
var release = engine.Release(
0,
2.0 - RhythmJudgementEngine.LongNoteReleaseGraceSeconds - 0.000002);
Assert.That(release?.LongNoteBreak, Is.True);
Assert.That(engine.GetState(3), Is.EqualTo(RuntimeNoteState.BrokenHolding));
Assert.That(engine.Combo, Is.Zero);
Assert.That(engine.MissCount, Is.EqualTo(2),
"The 900 ms and tail ticks remain unearned just beyond the 100 ms grace.");
}
[Test]
public void LongNote_HoldTicksIncreaseComboEveryOneHundredMilliseconds()
{
var note = new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0, EndTime = 2.05 };
var engine = new RhythmJudgementEngine(new[] { note });
var judgementResults = new List<JudgementResult>();
engine.Judged += judgementResults.Add;
engine.Press(0, 1.0);
Assert.That(engine.Score, Is.EqualTo(90910));
engine.Update(1.099);
Assert.That(engine.Combo, Is.EqualTo(1));
engine.Update(1.1);
Assert.That(engine.Combo, Is.EqualTo(2));
Assert.That(engine.Score, Is.EqualTo(181820));
engine.Update(1.49);
Assert.That(engine.Combo, Is.EqualTo(5));
engine.Update(2.05);
Assert.That(engine.Combo, Is.EqualTo(11));
Assert.That(engine.MaxCombo, Is.EqualTo(11));
Assert.That(engine.Score, Is.EqualTo(1_000_011));
Assert.That(engine.JudgedNoteCount, Is.EqualTo(11));
Assert.That(engine.KoolCount, Is.EqualTo(11));
Assert.That(engine.PurePerfectCount, Is.EqualTo(11));
Assert.That(engine.AccuracyPercent, Is.EqualTo(100));
Assert.That(judgementResults, Has.Count.EqualTo(11));
Assert.That(judgementResults[0].IsHoldTick, Is.False);
Assert.That(judgementResults.Skip(1).All(result =>
result.IsHoldTick && result.Grade == JudgementGrade.Kool), Is.True,
"Every tick after a COOL-or-better head must pop a KOOL judgement.");
}
[Test]
public void LongNote_HoldTicksRaiseAccuracyAndScoreAfterAnImperfectHead()
{
var note = new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0, EndTime = 2.0 };
var engine = new RhythmJudgementEngine(new[] { note });
engine.Press(0, 1.05);
var headScore = engine.Score;
Assert.That(engine.AccuracyPercent, Is.EqualTo(70.0));
engine.Update(1.1);
Assert.That(engine.AccuracyPercent, Is.EqualTo(85.0));
Assert.That(engine.Score, Is.GreaterThan(headScore));
Assert.That(engine.PurePerfectCount, Is.EqualTo(1),
"A COOL head promotes each successful long-note tick to KOOL Pure Perfect.");
Assert.That(engine.LastResult?.Grade, Is.EqualTo(JudgementGrade.Kool));
Assert.That(engine.LastResult?.IsHoldTick, Is.True);
}
[Test]
public void LongNote_GoodHeadKeepsEverySuccessfulTickGood()
{
var note = new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0, EndTime = 2.0 };
var engine = new RhythmJudgementEngine(new[] { note });
var head = engine.Press(0, 1.1);
engine.Update(1.1);
Assert.That(head?.Grade, Is.EqualTo(JudgementGrade.Good));
Assert.That(engine.LastResult?.Grade, Is.EqualTo(JudgementGrade.Good));
Assert.That(engine.LastResult?.IsHoldTick, Is.True);
Assert.That(engine.GoodCount, Is.EqualTo(2));
Assert.That(engine.KoolCount, Is.Zero);
Assert.That(engine.PurePerfectCount, Is.Zero);
}
[Test]
public void Judgement_SelectsNearestPendingNoteWithinTheLaneWindow()
{
var notes = new[]
{
new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0, EndTime = 1.0 },
new PlayableNote { Id = 2, Lane = 0, StartTime = 1.1, EndTime = 1.1 },
};
var engine = new RhythmJudgementEngine(notes);
var result = engine.Press(0, 1.08);
Assert.That(result?.NoteId, Is.EqualTo(2));
Assert.That(result?.OffsetSeconds, Is.EqualTo(-0.02).Within(0.000001));
Assert.That(engine.GetState(1), Is.EqualTo(RuntimeNoteState.Pending));
}
[Test]
public void Judgement_ResultDoesNotDependOnUpdateCadence()
{
var notes = new[]
{
new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0, EndTime = 1.0 },
new PlayableNote { Id = 2, Lane = 1, StartTime = 2.0, EndTime = 2.0 },
new PlayableNote { Id = 3, Lane = 2, StartTime = 3.0, EndTime = 4.0 },
};
var frequentUpdates = new RhythmJudgementEngine(notes);
var sparseUpdates = new RhythmJudgementEngine(notes);
for (var time = 0.0; time < 1.8; time += 0.01)
frequentUpdates.Update(time);
frequentUpdates.Press(1, 2.0);
for (var time = 2.01; time < 2.9; time += 0.01)
frequentUpdates.Update(time);
frequentUpdates.Press(2, 3.0);
frequentUpdates.Release(2, 3.5);
for (var time = 3.51; time <= 4.5; time += 0.01)
frequentUpdates.Update(time);
sparseUpdates.Update(1.8);
sparseUpdates.Press(1, 2.0);
sparseUpdates.Update(2.9);
sparseUpdates.Press(2, 3.0);
sparseUpdates.Release(2, 3.5);
sparseUpdates.Update(4.5);
Assert.That(sparseUpdates.Score, Is.EqualTo(frequentUpdates.Score));
Assert.That(sparseUpdates.MissCount, Is.EqualTo(frequentUpdates.MissCount));
Assert.That(sparseUpdates.Combo, Is.EqualTo(frequentUpdates.Combo));
Assert.That(sparseUpdates.AccuracyPercent, Is.EqualTo(frequentUpdates.AccuracyPercent));
Assert.That(sparseUpdates.GetState(1), Is.EqualTo(frequentUpdates.GetState(1)));
Assert.That(sparseUpdates.GetState(2), Is.EqualTo(frequentUpdates.GetState(2)));
Assert.That(sparseUpdates.GetState(3), Is.EqualTo(frequentUpdates.GetState(3)));
Assert.That(sparseUpdates.IsFinished, Is.True);
}
[Test]
public void LongNote_NextHeadCanBeJudgedAfterPreviousTailWithoutFrameUpdate()
{
var notes = new[]
{
new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0, EndTime = 2.0 },
new PlayableNote { Id = 2, Lane = 0, StartTime = 2.2, EndTime = 3.0 },
};
var engine = new RhythmJudgementEngine(notes);
engine.Press(0, 1.0);
var secondHead = engine.Press(0, 2.2);
Assert.That(secondHead?.NoteId, Is.EqualTo(2));
Assert.That(engine.GetState(1), Is.EqualTo(RuntimeNoteState.Completed));
Assert.That(engine.GetState(2), Is.EqualTo(RuntimeNoteState.Holding));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a0d49a1c82734afea812ffafb41ce7b6
@@ -105,26 +105,6 @@ namespace ProjectQuint.Tests
Assert.That(app, Does.Not.Contain("DrawHome(")); Assert.That(app, Does.Not.Contain("DrawHome("));
} }
[Test]
public void HomeActions_ReuseExistingTransitionQuitAndProfilePaths()
{
var app = Read("Unity/Bootstrap/ProjectQuintApp.cs");
var actionStart = app.IndexOf(
"private void ActivateHomeAction(HomeMenuAction action)",
System.StringComparison.Ordinal);
var actionEnd = app.IndexOf(
"private void HandleHomePresenterAction",
actionStart,
System.StringComparison.Ordinal);
var actionBody = app.Substring(actionStart, actionEnd - actionStart);
Assert.That(actionBody, Does.Contain("case HomeMenuAction.Play:"));
Assert.That(actionBody, Does.Contain("_mode = AppMode.PackSelect;"));
Assert.That(actionBody, Does.Contain("OpenOptions(AppMode.Home);"));
Assert.That(actionBody, Does.Contain("Application.Quit();"));
Assert.That(actionBody, Does.Contain("OpenProfileModal(0);"));
}
[Test] [Test]
public void MinimalHomeKeyboardAndMouseRoutesPlayAndEscapeQuit() public void MinimalHomeKeyboardAndMouseRoutesPlayAndEscapeQuit()
{ {
File diff suppressed because it is too large Load Diff
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 6613cb9e0883448468b1bfa7e788894e
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 551153b8a58bd4ddd8daf9dd5dcde978
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: ff984ac8f483b429aace8d868437ea96
@@ -0,0 +1,41 @@
using NUnit.Framework;
using ProjectQuint.Features.Flow;
namespace ProjectQuint.Tests
{
internal sealed class HomeNavigationTests
{
[Test]
public void PlayFromHome_TransitionsToPackSelection()
{
var flow = new AppFlowController();
flow.TransitionTo(AppMode.Home);
Assert.That(flow.TryBeginPlayFromHome(), Is.True);
Assert.That(flow.Mode, Is.EqualTo(AppMode.PackSelect));
Assert.That(flow.Status, Is.EqualTo("SELECT A FOLDER OR ZIP TO BEGIN"));
}
[Test]
public void PlayFromHome_PreservesTheSelectedPackAndExplainsRegisteredContent()
{
var flow = new AppFlowController();
flow.TransitionTo(AppMode.Home);
flow.TryBeginPlayFromHome(1, 3);
Assert.That(flow.PackSelection, Is.EqualTo(1));
Assert.That(flow.Status, Is.EqualTo("3 BMS PACKS REGISTERED"));
}
[TestCase(AppMode.Loading)]
[TestCase(AppMode.Gameplay)]
[TestCase(AppMode.PackSelect)]
public void HomePlayAction_DoesNotInterruptAnotherScreen(AppMode mode)
{
var flow = new AppFlowController();
flow.TransitionTo(mode);
Assert.That(flow.TryBeginPlayFromHome(), Is.False);
Assert.That(flow.Mode, Is.EqualTo(mode));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d5440faaa6cf473bb016ed8a641239b2
@@ -7,6 +7,7 @@ using ProjectQuint.Core.Progression;
using ProjectQuint.Library; using ProjectQuint.Library;
using ProjectQuint.Persistence; using ProjectQuint.Persistence;
using ProjectQuint.Bootstrap; using ProjectQuint.Bootstrap;
using ProjectQuint.Features.Profile;
using UnityEngine; using UnityEngine;
namespace ProjectQuint.Tests namespace ProjectQuint.Tests
@@ -230,7 +231,7 @@ namespace ProjectQuint.Tests
}) })
.ToArray(); .ToArray();
var contexts = ProjectQuintApp.BuildProfileRatingContexts( var contexts = ProfileRatingContextBuilder.Build(
new[] { recorded }, new[] { recorded },
tables, tables,
Array.Empty<SongLibraryEntry>(), Array.Empty<SongLibraryEntry>(),
@@ -0,0 +1,70 @@
using System;
using NUnit.Framework;
using ProjectQuint.Core.Progression;
using ProjectQuint.Features.Profile;
namespace ProjectQuint.Tests
{
internal sealed class ProfileSessionControllerTests
{
[Test]
public void RatingSelection_WrapsAndRefreshClampsToAvailableContexts()
{
var session = new ProfileSessionController();
var contexts = new[]
{
new RatingSummary("first", "FIRST", 4, false, 1, 1),
new RatingSummary("second", "SECOND", 7, true, 2, 1),
};
session.Refresh(default, null, null, contexts, default);
session.ShiftRatingContext(-1);
Assert.That(session.RatingSummary.ProviderId, Is.EqualTo("second"));
session.Refresh(default, null, null, Array.Empty<RatingSummary>(), contexts[0]);
Assert.That(session.RatingContextIndex, Is.Zero);
Assert.That(session.RatingSummary.ProviderId, Is.EqualTo("first"));
}
[Test]
public void CollectionSelection_ClampsAndSurvivesAnEmptyRefresh()
{
var session = new ProfileSessionController();
var groups = new[] { default(CollectionGroupSummary), default(CollectionGroupSummary) };
session.Refresh(default, new ProfileCollectionSnapshot(default, groups, null, null),
null, null, default);
session.SelectCollection(0, 100);
Assert.That(session.CollectionSelections[0], Is.EqualTo(1));
session.ShiftCollectionSelection(1);
Assert.That(session.CollectionSelections[0], Is.Zero);
session.ShiftCollectionColumn(-1);
Assert.That(session.CollectionColumn, Is.EqualTo(2));
session.Refresh(default, null, null, null, default);
session.SelectCollection(-1, -1);
Assert.That(session.CollectionColumn, Is.Zero);
Assert.That(session.CollectionSelections, Is.EqualTo(new[] { 0, 0, 0 }));
}
[Test]
public void Modal_ClampsTabsAndCloseIsIdempotent()
{
var session = new ProfileSessionController();
session.Open(100);
Assert.That(session.IsOpen, Is.True);
Assert.That(session.Tab, Is.EqualTo(3));
session.ShiftTab(1);
Assert.That(session.Tab, Is.Zero);
session.Close();
session.Close();
Assert.That(session.IsOpen, Is.False);
}
[Test]
public void EmptyBadgeCollection_DoesNotOfferAPersistableSelection()
{
var session = new ProfileSessionController();
session.SelectBadge(100);
session.ShiftBadge(-1);
Assert.That(session.BadgeSelection, Is.Zero);
Assert.That(session.SelectedBadgeCourseId, Is.Empty);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cc915c84e7104cebbd016972b39e1362
@@ -24,7 +24,7 @@ namespace ProjectQuint.Tests
Is.True, Is.True,
error); error);
Assert.That(manifest.buildNumber, Is.GreaterThan(ProjectQuintReleaseVersion.BuildNumber)); Assert.That(manifest.buildNumber, Is.GreaterThan(ProjectQuintReleaseVersion.BuildNumber));
Assert.That(ProjectQuintUpdateService.IsUpdateAvailable(manifest), Is.True); Assert.That(UpdatePolicy.IsUpdateAvailable(manifest), Is.True);
} }
[TestCase("\"schemaVersion\":1", "\"schemaVersion\":2")] [TestCase("\"schemaVersion\":1", "\"schemaVersion\":2")]
@@ -57,13 +57,13 @@ namespace ProjectQuint.Tests
out _); out _);
Assert.That( Assert.That(
ProjectQuintUpdateService.ShouldOffer( UpdatePolicy.ShouldOffer(
manifest, manifest,
manifest.version, manifest.version,
manual: false), manual: false),
Is.False); Is.False);
Assert.That( Assert.That(
ProjectQuintUpdateService.ShouldOffer( UpdatePolicy.ShouldOffer(
manifest, manifest,
manifest.version, manifest.version,
manual: true), manual: true),
@@ -76,17 +76,17 @@ namespace ProjectQuint.Tests
var now = new DateTime(2026, 7, 29, 12, 0, 0, DateTimeKind.Utc); var now = new DateTime(2026, 7, 29, 12, 0, 0, DateTimeKind.Utc);
Assert.That( Assert.That(
ProjectQuintUpdateService.ShouldCheckAutomatically( UpdatePolicy.ShouldCheckAutomatically(
now.AddHours(-23).Ticks, now.AddHours(-23).Ticks,
now.Ticks), now.Ticks),
Is.False); Is.False);
Assert.That( Assert.That(
ProjectQuintUpdateService.ShouldCheckAutomatically( UpdatePolicy.ShouldCheckAutomatically(
now.AddHours(-24).Ticks, now.AddHours(-24).Ticks,
now.Ticks), now.Ticks),
Is.True); Is.True);
Assert.That( Assert.That(
ProjectQuintUpdateService.ShouldCheckAutomatically(0, now.Ticks), UpdatePolicy.ShouldCheckAutomatically(0, now.Ticks),
Is.True); Is.True);
} }
@@ -101,7 +101,7 @@ namespace ProjectQuint.Tests
out var error), out var error),
Is.True, Is.True,
error); error);
Assert.That(ProjectQuintUpdateService.IsUpdateAvailable(manifest), Is.False); Assert.That(UpdatePolicy.IsUpdateAvailable(manifest), Is.False);
} }
[Test] [Test]
@@ -0,0 +1,118 @@
using NUnit.Framework;
using ProjectQuint.Core.Release;
using ProjectQuint.Features.Flow;
using ProjectQuint.Features.Updates;
namespace ProjectQuint.Tests
{
internal sealed class UpdateSessionControllerTests
{
private static ProjectQuintUpdateManifest NewerBuild() => new ProjectQuintUpdateManifest
{
available = true,
version = "0.1.0-alpha.99",
buildNumber = ProjectQuintReleaseVersion.BuildNumber + 1,
};
[Test]
public void DuplicateCheck_IsRejectedUntilTheCurrentCheckCompletes()
{
var session = new UpdateSessionController();
Assert.That(session.TryBeginCheck(true, out var id), Is.True);
Assert.That(session.TryBeginCheck(true, out _), Is.False);
Assert.That(session.CompleteCheck(id,
new ProjectQuintUpdateCheckResult(true, NewerBuild(), ""), "", 100), Is.True);
Assert.That(session.IsChecking, Is.False);
Assert.That(session.LastSuccessfulCheckUtcTicks, Is.EqualTo(100));
Assert.That(session.CompleteCheck(id,
new ProjectQuintUpdateCheckResult(false, null, "late failure"), "", 200), Is.False);
Assert.That(session.Error, Is.Empty);
}
[Test]
public void CancelledCheck_CannotOverwriteAReplacementCheck()
{
var session = new UpdateSessionController();
session.TryBeginCheck(false, out var oldId);
session.Cancel();
session.TryBeginCheck(true, out var newId);
Assert.That(session.CompleteCheck(oldId,
new ProjectQuintUpdateCheckResult(true, NewerBuild(), ""), "", 100), Is.False);
session.AbortCheck(oldId);
Assert.That(session.IsChecking, Is.True);
Assert.That(session.CompleteCheck(newId,
new ProjectQuintUpdateCheckResult(false, null, "network unavailable"), "", 200), Is.True);
Assert.That(session.Error, Is.EqualTo("network unavailable"));
Assert.That(session.LastSuccessfulCheckUtcTicks, Is.Zero);
}
[TestCase(AppMode.Gameplay, false, false)]
[TestCase(AppMode.Loading, false, false)]
[TestCase(AppMode.Home, true, false)]
[TestCase(AppMode.Options, true, false)]
[TestCase(AppMode.Home, false, true)]
[TestCase(AppMode.Options, false, true)]
public void Offer_WaitsForAnUnblockedHomeOrOptionsScreen(AppMode mode, bool blocked, bool expected)
{
var session = new UpdateSessionController();
session.TryBeginCheck(false, out var id);
session.CompleteCheck(id, new ProjectQuintUpdateCheckResult(true, NewerBuild(), ""), "", 100);
Assert.That(session.TryPresent(mode, blocked), Is.EqualTo(expected));
Assert.That(session.IsModalOpen, Is.EqualTo(expected));
Assert.That(session.TryPresent(mode, blocked), Is.False);
}
[Test]
public void SkippedVersion_IsSilentAutomaticallyButCanBeRequestedManually()
{
var session = new UpdateSessionController();
var manifest = NewerBuild();
session.TryBeginCheck(false, out var id);
session.CompleteCheck(id, new ProjectQuintUpdateCheckResult(true, manifest, ""), manifest.version, 100);
Assert.That(session.TryPresent(AppMode.Home, false), Is.False);
session.TryBeginCheck(true, out id);
session.CompleteCheck(id, new ProjectQuintUpdateCheckResult(true, manifest, ""), manifest.version, 200);
Assert.That(session.TryPresent(AppMode.Home, false), Is.True);
session.MoveSelection(-1);
Assert.That(session.Selection, Is.EqualTo(2));
session.Dismiss();
Assert.That(session.TryPresent(AppMode.Home, false), Is.False);
}
[Test]
public void AutomaticCheck_StartsOnceAtHomeAndNeverInTheEditor()
{
var session = new UpdateSessionController();
Assert.That(session.TryBeginAutomaticCheck(AppMode.Gameplay, false, 0, 100, out _), Is.False);
Assert.That(session.TryBeginAutomaticCheck(AppMode.Home, false, 0, 100, out _), Is.True);
session.Cancel();
Assert.That(session.TryBeginAutomaticCheck(AppMode.Home, false, 0, 200, out _), Is.False);
var editor = new UpdateSessionController();
Assert.That(editor.TryBeginAutomaticCheck(AppMode.Home, true, 0, 100, out _), Is.False);
}
[Test]
public void AutomaticCheck_RespectsTheLastSuccessfulCheckTime()
{
var session = new UpdateSessionController();
var now = System.DateTime.UtcNow;
Assert.That(session.TryBeginAutomaticCheck(AppMode.Home, false,
now.AddHours(-23).Ticks, now.Ticks, out _), Is.False);
Assert.That(session.IsChecking, Is.False);
}
[Test]
public void SuccessWithoutAManifest_IsAnObservableFailure()
{
var session = new UpdateSessionController();
session.TryBeginCheck(true, out var id);
session.CompleteCheck(id, new ProjectQuintUpdateCheckResult(true, null, ""), "", 100);
Assert.That(session.LastSuccessfulCheckUtcTicks, Is.Zero);
Assert.That(session.Error, Is.EqualTo("UPDATE MANIFEST IS MISSING"));
Assert.That(session.TryPresent(AppMode.Home, false), Is.False);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cd82750b60d84a0f980a0721221c1f9d
+62 -1
View File
@@ -5,6 +5,26 @@
[프로젝트 사이트](https://projectquint.duckdns.org/)에서 사용 안내와 배포 정보를 확인할 수 있습니다. [프로젝트 사이트](https://projectquint.duckdns.org/)에서 사용 안내와 배포 정보를 확인할 수 있습니다.
## 독립 테스트 실행
[.NET 10 SDK](https://learn.microsoft.com/en-us/dotnet/core/install/)가 있으면 유니티를 설치하지 않고
판정·타이밍·파서와 주요 화면 상태 테스트를 실행할 수 있습니다.
macOS와 Linux에서는 저장소 루트에서 다음 명령을 실행합니다.
```bash
Tools/agentctl test
Tools/agentctl replay --notes 1000 --seed 42 --fps 30
Tools/agentctl replay --notes 1000 --seed 42 --fps 240
```
재생 검증에는 생성한 노트와 입력 이벤트만 사용하며 음악 파일이나 외부 채보를 읽지 않습니다.
두 재생 결과의 `result.judgement`는 같아야 합니다. `--fps`는 가상 프레임 갱신 간격이며
실제 게임의 초당 프레임 수를 측정하는 옵션은 아닙니다.
`Verification/`의 프로젝트는 실제 게임 소스와 테스트 파일을 직접 컴파일합니다.
검증용으로 별도의 판정 엔진을 구현하거나 유니티 API를 모방하지 않습니다.
첫 실행에는 테스트 패키지를 복원할 인터넷 연결이 필요합니다.
## 주요 기능과 구성 ## 주요 기능과 구성
- **판정과 타이밍**: 렌더링 프레임과 분리한 판정 엔진, BPM·STOP을 처리하는 시간 변환, - **판정과 타이밍**: 렌더링 프레임과 분리한 판정 엔진, BPM·STOP을 처리하는 시간 변환,
@@ -13,6 +33,42 @@
- **라이브러리와 저장**: 팩별 곡 목록, 난이도표 연결, 검색과 정렬, 플레이 결과·프로필 저장을 관리합니다. - **라이브러리와 저장**: 팩별 곡 목록, 난이도표 연결, 검색과 정렬, 플레이 결과·프로필 저장을 관리합니다.
- **화면 구성**: UI Toolkit 화면과 프레젠터를 사용해 화면 표시와 세션 상태를 나눴습니다. - **화면 구성**: UI Toolkit 화면과 프레젠터를 사용해 화면 표시와 세션 상태를 나눴습니다.
## 설계
### 프레임 갱신 빈도와 판정 결과 분리
입력을 처리한 프레임의 시각으로 판정하면 프레임 지연이 점수에 영향을 줄 수 있습니다.
`RhythmJudgementEngine`은 전달받은 곡 시각으로 판정하고,
`TempoMap`과 `ScrollMap`은 음악의 시간 변환과 화면 위치 계산을 나눠 담당합니다.
[판정 테스트](Assets/ProjectQuint/Tests/Core/RhythmJudgementTests.cs)와
[합성 입력 재생](Verification/ProjectQuint.Tests/CoreReplayTests.cs)에서 서로 다른 갱신 간격의 결과를 비교합니다.
### 비동기 작업의 수명과 화면 상태 분리
곡 교체는 준비한 리소스를 한 묶음으로 넘기고, 취소된 후보는 해제합니다.
업데이트 알림은 `UpdateSessionController`가 요청 세대와 화면 상태를 관리합니다.
취소된 요청의 늦은 응답은 무시하며, 게임 중에는 알림을 표시하지 않습니다.
[업데이트 상태 테스트](Assets/ProjectQuint/Tests/EditMode/UpdateSessionControllerTests.cs)는
중복 요청·취소 후 재요청·표시 가능한 화면·같은 응답의 중복 전달을 검증합니다.
### 입력 장치와 프로필 화면 상태 분리
`ProfileSessionController`가 탭, 난이도표별 레이팅, 수집 기록과 배지의 선택 상태를 소유합니다.
키보드와 UI 이벤트는 같은 상태 변경 메서드를 호출합니다.
표시할 데이터가 줄거나 비어도 선택 인덱스가 유효하도록
[프로필 상태 테스트](Assets/ProjectQuint/Tests/EditMode/ProfileSessionControllerTests.cs)에서 확인합니다.
## 측정 결과
합성 노트 10,000개와 입력 이벤트 18,822개를 사용하는 판정 코어 측정에서,
전체 입력 재생 시간의 중앙값은 **4.831 ms**였습니다.
엔진 생성은 별도로 측정했으며 중앙값은 **0.995 ms**였습니다.
이 입력 재생 구간에서 현재 스레드의 관리 메모리 추가 할당은 20회 모두 0바이트였습니다.
이 값은 macOS ARM64·.NET 10 환경에서 측정한 코어의 기준값입니다.
유니티 플레이어의 프레임 시간, 실제 입력·오디오 지연, FMOD 메모리 사용량을 뜻하지 않습니다.
[측정 조건·원자료·재현 방법](docs/verification.md)을 함께 확인할 수 있습니다.
## 주요 경로 ## 주요 경로
| 경로 | 역할 | | 경로 | 역할 |
@@ -22,7 +78,9 @@
| `Assets/ProjectQuint/Runtime/Unity/Audio` | 오디오 백엔드와 재생·캐시 | | `Assets/ProjectQuint/Runtime/Unity/Audio` | 오디오 백엔드와 재생·캐시 |
| `Assets/ProjectQuint/Runtime/Unity/Persistence` | 플레이 결과와 프로필 저장 | | `Assets/ProjectQuint/Runtime/Unity/Persistence` | 플레이 결과와 프로필 저장 |
| `Assets/ProjectQuint/Runtime/Unity/UI` | 화면 프레젠터와 UI 리소스 | | `Assets/ProjectQuint/Runtime/Unity/UI` | 화면 프레젠터와 UI 리소스 |
| `Assets/ProjectQuint/Tests/EditMode` | 유니티 편집 모드 테스트 | | `Assets/ProjectQuint/Tests/Core` | 유니티와 .NET에서 공유하는 코어 테스트 |
| `Assets/ProjectQuint/Tests/EditMode` | 앱 상태·유니티 통합 테스트 |
| `Verification` | 독립 테스트 실행과 합성 입력·성능 측정 도구 |
| `Tools` | 빌드·배포 도구와 테스트 | | `Tools` | 빌드·배포 도구와 테스트 |
| `Website/projectquint-site` | 프로젝트 소개 사이트 | | `Website/projectquint-site` | 프로젝트 소개 사이트 |
@@ -37,6 +95,9 @@ BMS 음악과 채보는 포함되어 있지 않으므로, 플레이할 콘텐츠
## 검증 ## 검증
독립 검증의 실행 방법과 검증 범위는 [검증 안내](docs/verification.md)에 정리되어 있습니다.
독립 테스트 통과만으로 유니티 화면·오디오·네이티브 연동이 검증되었다고 보지 않습니다.
유니티 Test Runner에서 `EditMode` 테스트를 실행합니다. 명령줄 실행 시에는 유니티 Test Runner에서 `EditMode` 테스트를 실행합니다. 명령줄 실행 시에는
`-runTests -testPlatform EditMode -testResults <결과 파일> -logFile <로그 파일>`을 사용하고, `-runTests -testPlatform EditMode -testResults <결과 파일> -logFile <로그 파일>`을 사용하고,
테스트 시작 전에 종료되지 않도록 `-quit`는 함께 전달하지 않습니다. 테스트 시작 전에 종료되지 않도록 `-quit`는 함께 전달하지 않습니다.
Executable
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
set -eu
project_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
dotnet_command=${PROJECTQUINT_DOTNET:-dotnet}
if ! command -v "$dotnet_command" >/dev/null 2>&1; then
echo "Install the .NET 10 SDK or set PROJECTQUINT_DOTNET to its executable." >&2
exit 127
fi
export DOTNET_CLI_TELEMETRY_OPTOUT=1
export DOTNET_NOLOGO=1
cd "$project_root/Verification"
if [ "${1:-}" = test ]; then
shift
exec "$dotnet_command" test ProjectQuint.Tests/ProjectQuint.Tests.csproj \
--configuration Release -p:RestoreLockedMode=true "$@"
fi
# Keep build diagnostics on stderr so replay/benchmark stdout is exactly one JSON document.
"$dotnet_command" build ProjectQuint.Agent/ProjectQuint.Agent.csproj \
--configuration Release --verbosity quiet >&2
exec "$dotnet_command" ProjectQuint.Agent/bin/Release/net10.0/ProjectQuint.Agent.dll "$@"
@@ -0,0 +1,88 @@
using System.Text.Json;
using ProjectQuint.Probes;
var json = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
try
{
var action = args.Length == 0 ? "list" : args[0];
var options = new Dictionary<string, int>(StringComparer.Ordinal);
string? output = null;
for (var index = 1; index < args.Length; index += 2)
{
if (index + 1 >= args.Length)
throw new ArgumentException("Every option requires a value.");
if (args[index] == "--output" && output == null)
output = args[index + 1];
else if (args[index] is "--notes" or "--seed" or "--fps" or "--iterations" &&
int.TryParse(args[index + 1], out var value) && options.TryAdd(args[index], value))
continue;
else
throw new ArgumentException("Unknown, duplicated or invalid option.");
}
var notes = options.GetValueOrDefault("--notes", 10_000);
var seed = options.GetValueOrDefault("--seed", 42);
var fps = options.GetValueOrDefault("--fps", 240);
var iterations = options.GetValueOrDefault("--iterations", 20);
if (seed < 0)
throw new ArgumentException("Seed must be nonnegative.");
object report;
switch (action)
{
case "list":
if (options.Count != 0 || output != null)
throw new ArgumentException("The list command accepts no options.");
report = new
{
schemaVersion = 1,
component = "headless-core",
commands = new[] { "list", "replay", "benchmark", "test (wrapper)" },
input = "seeded synthetic four-lane notes; no music or external library",
options = new[] { "--notes", "--seed", "--fps", "--iterations (benchmark)", "--output" },
};
break;
case "replay":
if (options.ContainsKey("--iterations"))
throw new ArgumentException("Iterations applies only to benchmarks.");
var workload = SyntheticWorkload.Create(notes, (uint)seed);
report = new
{
schemaVersion = 1, complete = true, notes, seed, frameRate = fps,
eventCount = workload.Events.Length,
result = ReplayRunner.Run(workload, fps),
};
break;
case "benchmark":
report = CoreBenchmark.Run(notes, (uint)seed, fps, iterations, checkpoint =>
{
Console.Error.WriteLine($"benchmark {checkpoint.Samples.Length}/{iterations}");
if (output != null)
Save(output, checkpoint);
});
break;
default:
throw new ArgumentException("Unknown command. Use list, replay, benchmark or test.");
}
if (output != null)
Save(output, report);
Console.WriteLine(JsonSerializer.Serialize(report, json));
return 0;
}
catch (Exception error)
{
// Paths in I/O exceptions can identify the local machine; keep structured failures generic.
Console.Error.WriteLine(JsonSerializer.Serialize(new
{
error = error.GetType().Name,
detail = error is ArgumentException ? error.Message : "The probe failed; no complete result was produced.",
}));
return 1;
}
void Save(string path, object report)
{
var destination = Path.GetFullPath(path);
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
var temporary = destination + ".tmp";
File.WriteAllText(temporary, JsonSerializer.Serialize(report, json) + "\n");
File.Move(temporary, destination, overwrite: true);
}
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../ProjectQuint.Probes/ProjectQuint.Probes.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>9.0</LangVersion>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<!-- Compile the shipping sources, never a separate implementation of the rules. -->
<Compile Include="../../Assets/ProjectQuint/Runtime/Core/**/*.cs" />
</ItemGroup>
</Project>
@@ -0,0 +1,42 @@
using System.Runtime.InteropServices;
namespace ProjectQuint.Probes;
public sealed record CoreBenchmarkReport(int SchemaVersion, bool Complete, DateTimeOffset MeasuredAtUtc,
string Runtime, string OperatingSystem, string Architecture, int ProcessorCount,
int Notes, int Events, uint Seed, int FrameRate, int WarmupIterations, int RequestedIterations,
ReplayObservation[] Samples);
public static class CoreBenchmark
{
public static CoreBenchmarkReport Run(int notes, uint seed, int frameRate, int iterations,
Action<CoreBenchmarkReport> checkpoint)
{
if (iterations < 1 || iterations > 100)
throw new ArgumentOutOfRangeException(nameof(iterations), "Use 1 to 100 iterations.");
var workload = SyntheticWorkload.Create(notes, seed);
const int warmups = 3;
JudgementSnapshot expected = default;
for (var index = 0; index < warmups; index++)
expected = ReplayRunner.Run(workload, frameRate).Judgement;
var samples = new List<ReplayObservation>(iterations);
var measuredAt = DateTimeOffset.UtcNow;
for (var index = 0; index < iterations; index++)
{
var sample = ReplayRunner.Run(workload, frameRate);
if (sample.Judgement != expected || !sample.Judgement.Finished)
throw new InvalidOperationException("The benchmark replay diverged from its warmup result.");
samples.Add(sample);
checkpoint(Report(false));
}
return Report(true);
CoreBenchmarkReport Report(bool complete) => new(1, complete, measuredAt,
RuntimeInformation.FrameworkDescription,
System.OperatingSystem.IsMacOS() ? "macOS" :
System.OperatingSystem.IsLinux() ? "Linux" :
System.OperatingSystem.IsWindows() ? "Windows" : "Other",
RuntimeInformation.ProcessArchitecture.ToString(), Environment.ProcessorCount,
notes, workload.Events.Length, seed, frameRate, warmups, iterations, samples.ToArray());
}
}
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../ProjectQuint.Core/ProjectQuint.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,47 @@
using System.Diagnostics;
using ProjectQuint.Core.Judgement;
namespace ProjectQuint.Probes;
public readonly record struct JudgementSnapshot(int Score, int Combo, int MaxCombo,
int MissCount, int KoolCount, int CoolCount, int GoodCount, int JudgedUnits, bool Finished);
public sealed record ReplayObservation(JudgementSnapshot Judgement, double SetupMilliseconds,
double ReplayMilliseconds, long SetupAllocatedBytes, long ReplayAllocatedBytes);
public static class ReplayRunner
{
public static ReplayObservation Run(SyntheticWorkload workload, int frameRate)
{
if (frameRate < 1 || frameRate > 10_000)
throw new ArgumentOutOfRangeException(nameof(frameRate), "Use a frame rate from 1 to 10000.");
var allocationStart = GC.GetAllocatedBytesForCurrentThread();
var setupStart = Stopwatch.GetTimestamp();
var engine = new RhythmJudgementEngine(workload.Notes, laneCount: 4);
var setupEnd = Stopwatch.GetTimestamp();
var replayAllocationStart = GC.GetAllocatedBytesForCurrentThread();
long frame = 0;
foreach (var input in workload.Events)
{
// Merge two timestamped streams; no sleeps or real-time waiting affect this trace.
while ((double)frame / frameRate <= input.Time)
engine.Update((double)frame++ / frameRate);
if (input.Pressed)
engine.Press(input.Lane, input.Time);
else
engine.Release(input.Lane, input.Time);
}
engine.Update(workload.Notes[^1].EndTime + 1);
var replayEnd = Stopwatch.GetTimestamp();
var allocationEnd = GC.GetAllocatedBytesForCurrentThread();
return new ReplayObservation(new JudgementSnapshot(engine.Score, engine.Combo, engine.MaxCombo,
engine.MissCount, engine.KoolCount, engine.CoolCount, engine.GoodCount,
engine.JudgedNoteCount, engine.IsFinished),
Milliseconds(setupStart, setupEnd), Milliseconds(setupEnd, replayEnd),
replayAllocationStart - allocationStart, allocationEnd - replayAllocationStart);
}
private static double Milliseconds(long start, long end) =>
(end - start) * 1000.0 / Stopwatch.Frequency;
}
@@ -0,0 +1,42 @@
using ProjectQuint.Core.Charts;
namespace ProjectQuint.Probes;
public readonly record struct ReplayInput(double Time, int Lane, bool Pressed);
public sealed record SyntheticWorkload(PlayableNote[] Notes, ReplayInput[] Events, uint Seed)
{
public static SyntheticWorkload Create(int noteCount, uint seed)
{
if (noteCount < 1 || noteCount > 100_000)
throw new ArgumentOutOfRangeException(nameof(noteCount), "Use 1 to 100000 notes.");
var notes = new PlayableNote[noteCount];
var events = new List<ReplayInput>(noteCount * 2);
var state = seed;
for (var index = 0; index < noteCount; index++)
{
// Explicit integer PRNG makes the workload independent of System.Random versions.
state = unchecked(state * 1664525u + 1013904223u);
var time = 1.0 + index * 0.125;
var longNote = index % 11 == 0;
var note = new PlayableNote
{
Id = index + 1,
Lane = index % 4,
StartTime = time,
EndTime = time + (longNote ? 0.375 : 0),
};
notes[index] = note;
// Deliberate misses and early releases exercise failure as well as perfect play.
if (index % 17 == 0)
continue;
var pressTime = time + ((int)(state % 7) - 3) * 0.01;
events.Add(new ReplayInput(pressTime, note.Lane, true));
events.Add(new ReplayInput(longNote
? note.EndTime - (index % 23 == 0 ? 0.2 : 0)
: pressTime + 0.02, note.Lane, false));
}
return new SyntheticWorkload(notes, events.OrderBy(e => e.Time)
.ThenBy(e => e.Pressed).ThenBy(e => e.Lane).ToArray(), seed);
}
}
@@ -0,0 +1,31 @@
using NUnit.Framework;
using ProjectQuint.Probes;
namespace ProjectQuint.Tests
{
public sealed class CoreBenchmarkTests
{
[Test]
public void Benchmark_CheckpointsAreIncompleteUntilEverySampleFinishes()
{
var checkpoints = 0;
var report = CoreBenchmark.Run(100, 42, 60, 3, checkpoint =>
{
++checkpoints;
Assert.That(checkpoint.Complete, Is.False);
Assert.That(checkpoint.Samples.Length, Is.EqualTo(checkpoints));
Assert.That(checkpoint.Samples[0].Judgement.Finished, Is.True);
});
Assert.That(report.Complete, Is.True);
Assert.That(checkpoints, Is.EqualTo(3));
Assert.That(report.Samples.Length, Is.EqualTo(3));
Assert.That(report.WarmupIterations, Is.EqualTo(3));
}
[TestCase(0)]
[TestCase(101)]
public void Benchmark_RejectsInvalidIterationCount(int iterations) =>
Assert.Throws<System.ArgumentOutOfRangeException>(() =>
CoreBenchmark.Run(100, 42, 60, iterations, _ => { }));
}
}
@@ -0,0 +1,44 @@
using System.Linq;
using NUnit.Framework;
using ProjectQuint.Probes;
namespace ProjectQuint.Tests
{
public sealed class CoreReplayTests
{
[Test]
public void SyntheticWorkload_IsReproducibleWithoutMedia()
{
var first = SyntheticWorkload.Create(500, 42);
var second = SyntheticWorkload.Create(500, 42);
Assert.That(first.Events, Is.EqualTo(second.Events));
Assert.That(first.Notes.Select(n => (n.Id, n.Lane, n.StartTime, n.EndTime)),
Is.EqualTo(second.Notes.Select(n => (n.Id, n.Lane, n.StartTime, n.EndTime))));
}
[TestCase(30)]
[TestCase(60)]
[TestCase(144)]
[TestCase(240)]
public void Replay_KeepsTheSameJudgementsAcrossFrameSchedules(int frameRate)
{
var workload = SyntheticWorkload.Create(1000, 42);
var expected = ReplayRunner.Run(workload, 1000).Judgement;
var actual = ReplayRunner.Run(workload, frameRate).Judgement;
Assert.That(actual, Is.EqualTo(expected));
Assert.That(actual.Finished, Is.True);
Assert.That(actual.MissCount, Is.GreaterThan(0));
Assert.That(actual.JudgedUnits, Is.GreaterThan(workload.Notes.Length));
}
[TestCase(0)]
[TestCase(-1)]
public void InvalidWorkloadSize_IsRejected(int notes) =>
Assert.Throws<System.ArgumentOutOfRangeException>(() => SyntheticWorkload.Create(notes, 42));
[Test]
public void InvalidFrameRate_IsRejected() =>
Assert.Throws<System.ArgumentOutOfRangeException>(() =>
ReplayRunner.Run(SyntheticWorkload.Create(10, 42), 0));
}
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>9.0</LangVersion>
<IsTestProject>true</IsTestProject>
<IsPackable>false</IsPackable>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../ProjectQuint.Core/ProjectQuint.Core.csproj" />
<ProjectReference Include="../ProjectQuint.Probes/ProjectQuint.Probes.csproj" />
<!-- NUnit 3 matches the assertions supported by the Unity test runner. -->
<PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="6.3.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<Compile Include="../../Assets/ProjectQuint/Tests/Core/**/*.cs" />
<Compile Include="../../Assets/ProjectQuint/Runtime/Unity/Features/Flow/AppFlowController.cs" />
<Compile Include="../../Assets/ProjectQuint/Tests/EditMode/HomeNavigationTests.cs" />
<Compile Include="../../Assets/ProjectQuint/Runtime/Unity/Features/Updates/*.cs" />
<Compile Include="../../Assets/ProjectQuint/Tests/EditMode/UpdateSessionControllerTests.cs" />
<Compile Include="../../Assets/ProjectQuint/Runtime/Unity/Features/Profile/ProfileSessionController.cs" />
<Compile Include="../../Assets/ProjectQuint/Tests/EditMode/ProfileSessionControllerTests.cs" />
<Compile Include="CoreReplayTests.cs" />
<Compile Include="CoreBenchmarkTests.cs" />
</ItemGroup>
</Project>
@@ -0,0 +1,128 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[18.9.0, )",
"resolved": "18.9.0",
"contentHash": "xIzVXa/VpKXqDWzyC/5Hw8JbFY5u9k3Jc53CpcjBiOXvkQGio484LD9FNwOiGUSMDcYL3Rcw9sNgGi5WrswlPQ==",
"dependencies": {
"Microsoft.CodeCoverage": "18.9.0",
"Microsoft.TestPlatform.TestHost": "18.9.0"
}
},
"NUnit": {
"type": "Direct",
"requested": "[3.14.0, )",
"resolved": "3.14.0",
"contentHash": "R7iPwD7kbOaP3o2zldWJbWeMQAvDKD0uld27QvA3PAALl1unl7x0v2J7eGiJOYjimV/BuGT4VJmr45RjS7z4LA==",
"dependencies": {
"NETStandard.Library": "2.0.0"
}
},
"NUnit3TestAdapter": {
"type": "Direct",
"requested": "[6.3.0, )",
"resolved": "6.3.0",
"contentHash": "8ZD+0dIfDHsL4w4Gl/LPxWH0O4ky3hFXqTZjY+ry0ThI3cMVErr9Tjxal5O5wJxGXh50a9qkDEAWVca8KQYCEw==",
"dependencies": {
"Microsoft.Extensions.DependencyModel": "8.0.2",
"Microsoft.Testing.Extensions.VSTestBridge": "2.3.3",
"Microsoft.Testing.Platform.MSBuild": "2.3.3"
}
},
"Microsoft.ApplicationInsights": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw=="
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
"resolved": "18.9.0",
"contentHash": "MtegCIKGuG0r/LCdIjoR1HgzCGIUBhR3aNdbtQpts5vMXQuqBDZ2Jsd2uFKoHFM2XrQV6ZrDf3F5oLi3SLTp8w=="
},
"Microsoft.Extensions.DependencyModel": {
"type": "Transitive",
"resolved": "8.0.2",
"contentHash": "mUBDZZRgZrSyFOsJ2qJJ9fXfqd/kXJwf3AiDoqLD9m6TjY5OO/vLNOb9fb4juC0487eq4hcGN/M2Rh/CKS7QYw=="
},
"Microsoft.NETCore.Platforms": {
"type": "Transitive",
"resolved": "1.1.0",
"contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A=="
},
"Microsoft.Testing.Extensions.Telemetry": {
"type": "Transitive",
"resolved": "2.3.3",
"contentHash": "nY8ceQyPWB9TRE1WE5Oe/sks2e10SxgPv61vBHsFYpgCeDtNwhpMZwmL29GklO3/etTdOsLdrCmNr4zJWaR2fg==",
"dependencies": {
"Microsoft.ApplicationInsights": "2.23.0",
"Microsoft.Testing.Platform": "2.3.3"
}
},
"Microsoft.Testing.Extensions.TrxReport.Abstractions": {
"type": "Transitive",
"resolved": "2.3.3",
"contentHash": "dceVNxnfTEjKnrIreLcMfkgrzzBY8kgCCJX5NAv7Lq/6vPlTP4VB5zxKeuYy0yfCoBtWuPkLjUG6qtOBMfpypA==",
"dependencies": {
"Microsoft.Testing.Platform": "2.3.3"
}
},
"Microsoft.Testing.Extensions.VSTestBridge": {
"type": "Transitive",
"resolved": "2.3.3",
"contentHash": "JLovZjH6K10YiHO/BcidsaSHo/M2DfOe9FDjxjFdV6aoHO6VYTTYlLBNcyLJSo20q5gDTmNvmxYVH39A942aJA==",
"dependencies": {
"Microsoft.TestPlatform.ObjectModel": "18.4.0",
"Microsoft.Testing.Extensions.Telemetry": "2.3.3",
"Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.3.3",
"Microsoft.Testing.Platform": "2.3.3"
}
},
"Microsoft.Testing.Platform": {
"type": "Transitive",
"resolved": "2.3.3",
"contentHash": "ENbH4BQh9riXtOKc25KKITfiGGWhWMBJA7pZuNaF9zxzzSaVkp5AMeZxGQ6sXYaR6xb724NLz985Is6XIYqLSg=="
},
"Microsoft.Testing.Platform.MSBuild": {
"type": "Transitive",
"resolved": "2.3.3",
"contentHash": "iVAvNbZ5JPDZSrTcIrCDoCsAkzjDxwDEt2mDuMd8ng1P6e2P2NCd0g0BsoBVG+nJLSmK//V+yeEvjCa3E2fxHw==",
"dependencies": {
"Microsoft.Testing.Platform": "2.3.3"
}
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "18.9.0",
"contentHash": "Fz/qXC52VXXopzHj7v2reCKcCqSxkTDCkEDfkNAH0vni/7qK/KLMiEYtRmnUanxO2F2g2zZZ60qCpxF0AF7URA=="
},
"Microsoft.TestPlatform.TestHost": {
"type": "Transitive",
"resolved": "18.9.0",
"contentHash": "Oq+Pma/J86aZL72t71bcESvDszDsTjUfl6PIsuDdPoKTHZfNMhKamCfLD5fKx51W/u0KozXtJo2/3fnt0sgPxg==",
"dependencies": {
"Microsoft.TestPlatform.ObjectModel": "18.9.0"
}
},
"NETStandard.Library": {
"type": "Transitive",
"resolved": "2.0.0",
"contentHash": "7jnbRU+L08FXKMxqUflxEXtVymWvNOrS8yHgu9s6EM8Anr6T/wIX4nZ08j/u3Asz+tCufp3YVwFSEvFTPYmBPA==",
"dependencies": {
"Microsoft.NETCore.Platforms": "1.1.0"
}
},
"projectquint.core": {
"type": "Project"
},
"projectquint.probes": {
"type": "Project",
"dependencies": {
"ProjectQuint.Core": "[1.0.0, )"
}
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestFeature"
}
}
+357
View File
@@ -0,0 +1,357 @@
{
"schemaVersion": 1,
"complete": true,
"measuredAtUtc": "2026-09-06T09:03:11.722784+00:00",
"runtime": ".NET 10.0.11",
"operatingSystem": "macOS",
"architecture": "Arm64",
"processorCount": 18,
"notes": 10000,
"events": 18822,
"seed": 42,
"frameRate": 240,
"warmupIterations": 3,
"requestedIterations": 20,
"samples": [
{
"judgement": {
"score": 944376,
"combo": 5,
"maxCombo": 23,
"missCount": 825,
"koolCount": 11905,
"coolCount": 0,
"goodCount": 0,
"judgedUnits": 12730,
"finished": true
},
"setupMilliseconds": 1.082417,
"replayMilliseconds": 5.242875,
"setupAllocatedBytes": 1544688,
"replayAllocatedBytes": 0
},
{
"judgement": {
"score": 944376,
"combo": 5,
"maxCombo": 23,
"missCount": 825,
"koolCount": 11905,
"coolCount": 0,
"goodCount": 0,
"judgedUnits": 12730,
"finished": true
},
"setupMilliseconds": 1.023084,
"replayMilliseconds": 4.90025,
"setupAllocatedBytes": 1544688,
"replayAllocatedBytes": 0
},
{
"judgement": {
"score": 944376,
"combo": 5,
"maxCombo": 23,
"missCount": 825,
"koolCount": 11905,
"coolCount": 0,
"goodCount": 0,
"judgedUnits": 12730,
"finished": true
},
"setupMilliseconds": 2.797084,
"replayMilliseconds": 4.959,
"setupAllocatedBytes": 1544688,
"replayAllocatedBytes": 0
},
{
"judgement": {
"score": 944376,
"combo": 5,
"maxCombo": 23,
"missCount": 825,
"koolCount": 11905,
"coolCount": 0,
"goodCount": 0,
"judgedUnits": 12730,
"finished": true
},
"setupMilliseconds": 1.082833,
"replayMilliseconds": 5.050167,
"setupAllocatedBytes": 1544944,
"replayAllocatedBytes": 0
},
{
"judgement": {
"score": 944376,
"combo": 5,
"maxCombo": 23,
"missCount": 825,
"koolCount": 11905,
"coolCount": 0,
"goodCount": 0,
"judgedUnits": 12730,
"finished": true
},
"setupMilliseconds": 1.052375,
"replayMilliseconds": 5.015958,
"setupAllocatedBytes": 1544688,
"replayAllocatedBytes": 0
},
{
"judgement": {
"score": 944376,
"combo": 5,
"maxCombo": 23,
"missCount": 825,
"koolCount": 11905,
"coolCount": 0,
"goodCount": 0,
"judgedUnits": 12730,
"finished": true
},
"setupMilliseconds": 1.025,
"replayMilliseconds": 5.027041,
"setupAllocatedBytes": 1544688,
"replayAllocatedBytes": 0
},
{
"judgement": {
"score": 944376,
"combo": 5,
"maxCombo": 23,
"missCount": 825,
"koolCount": 11905,
"coolCount": 0,
"goodCount": 0,
"judgedUnits": 12730,
"finished": true
},
"setupMilliseconds": 2.213917,
"replayMilliseconds": 4.778833,
"setupAllocatedBytes": 1544688,
"replayAllocatedBytes": 0
},
{
"judgement": {
"score": 944376,
"combo": 5,
"maxCombo": 23,
"missCount": 825,
"koolCount": 11905,
"coolCount": 0,
"goodCount": 0,
"judgedUnits": 12730,
"finished": true
},
"setupMilliseconds": 0.857042,
"replayMilliseconds": 4.818875,
"setupAllocatedBytes": 1544944,
"replayAllocatedBytes": 0
},
{
"judgement": {
"score": 944376,
"combo": 5,
"maxCombo": 23,
"missCount": 825,
"koolCount": 11905,
"coolCount": 0,
"goodCount": 0,
"judgedUnits": 12730,
"finished": true
},
"setupMilliseconds": 1.114583,
"replayMilliseconds": 4.960875,
"setupAllocatedBytes": 1544688,
"replayAllocatedBytes": 0
},
{
"judgement": {
"score": 944376,
"combo": 5,
"maxCombo": 23,
"missCount": 825,
"koolCount": 11905,
"coolCount": 0,
"goodCount": 0,
"judgedUnits": 12730,
"finished": true
},
"setupMilliseconds": 0.967792,
"replayMilliseconds": 5.051125,
"setupAllocatedBytes": 1544688,
"replayAllocatedBytes": 0
},
{
"judgement": {
"score": 944376,
"combo": 5,
"maxCombo": 23,
"missCount": 825,
"koolCount": 11905,
"coolCount": 0,
"goodCount": 0,
"judgedUnits": 12730,
"finished": true
},
"setupMilliseconds": 0.947458,
"replayMilliseconds": 4.945833,
"setupAllocatedBytes": 1544688,
"replayAllocatedBytes": 0
},
{
"judgement": {
"score": 944376,
"combo": 5,
"maxCombo": 23,
"missCount": 825,
"koolCount": 11905,
"coolCount": 0,
"goodCount": 0,
"judgedUnits": 12730,
"finished": true
},
"setupMilliseconds": 0.920375,
"replayMilliseconds": 4.749292,
"setupAllocatedBytes": 1544688,
"replayAllocatedBytes": 0
},
{
"judgement": {
"score": 944376,
"combo": 5,
"maxCombo": 23,
"missCount": 825,
"koolCount": 11905,
"coolCount": 0,
"goodCount": 0,
"judgedUnits": 12730,
"finished": true
},
"setupMilliseconds": 0.959333,
"replayMilliseconds": 4.844083,
"setupAllocatedBytes": 1544688,
"replayAllocatedBytes": 0
},
{
"judgement": {
"score": 944376,
"combo": 5,
"maxCombo": 23,
"missCount": 825,
"koolCount": 11905,
"coolCount": 0,
"goodCount": 0,
"judgedUnits": 12730,
"finished": true
},
"setupMilliseconds": 2.568125,
"replayMilliseconds": 4.732708,
"setupAllocatedBytes": 1544688,
"replayAllocatedBytes": 0
},
{
"judgement": {
"score": 944376,
"combo": 5,
"maxCombo": 23,
"missCount": 825,
"koolCount": 11905,
"coolCount": 0,
"goodCount": 0,
"judgedUnits": 12730,
"finished": true
},
"setupMilliseconds": 0.869875,
"replayMilliseconds": 4.813791,
"setupAllocatedBytes": 1544944,
"replayAllocatedBytes": 0
},
{
"judgement": {
"score": 944376,
"combo": 5,
"maxCombo": 23,
"missCount": 825,
"koolCount": 11905,
"coolCount": 0,
"goodCount": 0,
"judgedUnits": 12730,
"finished": true
},
"setupMilliseconds": 0.891,
"replayMilliseconds": 4.777333,
"setupAllocatedBytes": 1544688,
"replayAllocatedBytes": 0
},
{
"judgement": {
"score": 944376,
"combo": 5,
"maxCombo": 23,
"missCount": 825,
"koolCount": 11905,
"coolCount": 0,
"goodCount": 0,
"judgedUnits": 12730,
"finished": true
},
"setupMilliseconds": 0.864792,
"replayMilliseconds": 4.674875,
"setupAllocatedBytes": 1544688,
"replayAllocatedBytes": 0
},
{
"judgement": {
"score": 944376,
"combo": 5,
"maxCombo": 23,
"missCount": 825,
"koolCount": 11905,
"coolCount": 0,
"goodCount": 0,
"judgedUnits": 12730,
"finished": true
},
"setupMilliseconds": 0.845208,
"replayMilliseconds": 4.7745,
"setupAllocatedBytes": 1544688,
"replayAllocatedBytes": 0
},
{
"judgement": {
"score": 944376,
"combo": 5,
"maxCombo": 23,
"missCount": 825,
"koolCount": 11905,
"coolCount": 0,
"goodCount": 0,
"judgedUnits": 12730,
"finished": true
},
"setupMilliseconds": 0.846,
"replayMilliseconds": 4.753083,
"setupAllocatedBytes": 1544688,
"replayAllocatedBytes": 0
},
{
"judgement": {
"score": 944376,
"combo": 5,
"maxCombo": 23,
"missCount": 825,
"koolCount": 11905,
"coolCount": 0,
"goodCount": 0,
"judgedUnits": 12730,
"finished": true
},
"setupMilliseconds": 2.3905,
"replayMilliseconds": 4.8165,
"setupAllocatedBytes": 1544688,
"replayAllocatedBytes": 0
}
]
}
+101
View File
@@ -0,0 +1,101 @@
# 검증과 성능 측정
## 독립 테스트
.NET 10 SDK를 설치하고 저장소 루트에서 실행합니다.
```bash
Tools/agentctl test
```
기본 `dotnet` 대신 특정 실행 파일을 사용하려면 `PROJECTQUINT_DOTNET`을 지정합니다.
Windows에서는 `Verification` 폴더에서 다음 명령을 직접 실행할 수 있습니다.
```bash
dotnet test ProjectQuint.Tests/ProjectQuint.Tests.csproj -c Release -p:RestoreLockedMode=true
```
패키지 버전과 간접 의존성은 `packages.lock.json`으로 고정합니다.
처음 패키지를 복원할 때는 인터넷 연결이 필요하며, 복원 후에는 `--no-restore`를 추가할 수 있습니다.
유니티 편집 모드와 .NET은 `Assets/ProjectQuint/Tests/Core`의 같은 테스트 파일을 사용합니다.
홈 화면 이동·프로필 선택·업데이트 상태 테스트 역시 유니티와 독립 실행에서 공유합니다.
## 합성 입력 탐색
```bash
Tools/agentctl list
Tools/agentctl replay --notes 1000 --seed 42 --fps 30
Tools/agentctl replay --notes 1000 --seed 42 --fps 240
```
`replay`는 합성 노트와 미리 시각을 정한 입력 이벤트를 시간순으로 병합해 실행합니다.
같은 노트 수와 시드에서 `result.judgement`를 비교하면 점수·콤보·판정 수·완료 여부를 확인할 수 있습니다.
입력에는 일반 노트, 롱노트, 고의적인 미입력과 조기 놓기가 포함됩니다.
가상 시간이므로 실제 곡 길이만큼 기다리지 않으며 음악이나 외부 BMS 폴더를 읽지 않습니다.
- `--notes`: 1부터 100,000까지의 노트 수입니다.
- `--seed`: 0 이상의 정수입니다. 노트별 입력 시각 변화를 재현합니다.
- `--fps`: 1부터 10,000까지의 가상 갱신 빈도입니다. 실제 FPS 측정값이 아닙니다.
- `--output`: 결과 JSON을 저장할 경로입니다. 상대 경로는 `Verification` 폴더를 기준으로 합니다.
`list`, `replay`, `benchmark`의 표준 출력은 JSON이며 빌드 진단과 진행률은 표준 오류로 분리합니다.
잘못된 인수와 실행 실패는 0이 아닌 종료 코드를 반환합니다.
검증 도구는 `Assets` 바깥에 있으므로 유니티 게임 빌드에 포함되지 않습니다.
이 도구는 코어 검증용이며 전체 게임 화면을 조작하는 인터페이스는 아닙니다.
## 성능 측정 재현
```bash
Tools/agentctl benchmark --notes 10000 --seed 42 --fps 240 --iterations 20 \
--output ../_workspace/core-benchmark.json
```
워밍업 3회 뒤 요청한 횟수만큼 실행합니다. `--iterations`의 허용 범위는 1부터 100입니다.
각 회차마다 진행률을 표시하고, 출력 경로를 지정하면 완료된 샘플을 중간 저장합니다.
모든 회차를 마친 결과에만 `complete: true`가 기록됩니다.
측정 중 중단된 결과를 최종 결과로 사용하지 않습니다.
원자료: [core-baseline.json](benchmarks/core-baseline.json).
| 항목 | 측정 조건 또는 결과 |
| --- | --- |
| 측정일 | 2026-09-06 |
| 실행 환경 | macOS ARM64, .NET 10.0.11, 프로세스에서 관측한 논리 프로세서 18개 |
| 입력 | 노트 10,000개, 입력 이벤트 18,822개, 시드 42 |
| 가상 갱신 빈도 | 초당 240회 |
| 반복 | 워밍업 3회, 본 측정 20회 |
| 엔진 생성 시간 | 중앙값 0.995 ms, 최솟값 0.845 ms, 최댓값 2.797 ms |
| 전체 입력 재생 시간 | 중앙값 4.831 ms, 최솟값 4.675 ms, 최댓값 5.243 ms |
| 엔진 생성 중 관리 메모리 할당 | 중앙값 1,544,688바이트 |
| 입력 재생 중 관리 메모리 추가 할당 | 20회 모두 0바이트 |
시간은 `Stopwatch`로, 할당량은 `GC.GetAllocatedBytesForCurrentThread`로 측정했습니다.
합성 입력 생성과 결과 JSON 직렬화는 측정 구간에서 제외합니다.
엔진 생성과 입력 재생을 나눠 기록하므로 준비 비용을 무시하지 않습니다.
각 회차의 판정 결과가 워밍업과 같은지 확인하고, 다르면 측정을 실패 처리합니다.
이는 현재 구현의 기준 측정이지 최적화 전후 비교가 아닙니다.
JIT 상태와 다른 프로세스의 부하에 따라 시간은 달라질 수 있으며 절대 시간으로 테스트를 실패시키지 않습니다.
관리 메모리 측정에는 다른 스레드·네이티브 할당이 포함되지 않습니다.
화면 렌더링·오디오 출력·앱의 판정 이벤트 구독자도 실행하지 않으므로,
이 결과로 실제 게임 전체의 무할당 동작이나 입력 지연을 주장할 수 없습니다.
## 유니티와 실제 플레이 검증
유니티 `6000.3.20f1`에서 `EditMode` 테스트를 실행합니다.
명령줄에서는 `-runTests -testPlatform EditMode -testResults <파일> -logFile <파일>`을 사용하며
`-quit`는 함께 전달하지 않습니다. 에디터 활성화와 프로젝트 의존성 복원이 필요합니다.
외부 BMS 자료가 필요한 테스트는 별도의 환경 변수로 경로를 받습니다.
독립 검증은 실제 FMOD 출력, 장치 전환, 화면 조작과 파일 선택 창을 검증하지 않습니다.
음악을 재배포하지 않는 범위에서 실제 플레이로 로딩 취소·다시 시작·장치 변경·프로필 화면을 확인해야 합니다.
자동화 서버에서의 연속 실행과 실제 플레이어 성능 측정은 별도 운영 범위입니다.
2026-09-06 검증에서는 독립 테스트 142개와 배포 도구 테스트 9개가 통과했습니다.
유니티 컴파일러를 이용한 어셈블리 11개의 컴파일도 확인했습니다.
유니티 Test Runner는 유효한 에디터 라이선스가 없어 테스트 시작 전에 종료되었으므로,
편집 모드 전체 테스트와 실제 플레이 검증은 완료된 것으로 기록하지 않습니다.
앱의 업데이트와 프로필 상태는 독립 컨트롤러가 소유하지만,
팩 관리·오디오 설정·화면 연결을 조정하는 코드는 아직 큰 앱 클래스에 남아 있습니다.
추가 분리 시에는 관련 동작 테스트를 확보하고 같은 검증 절차를 반복합니다.