난이도표 로딩 통합과 팩 키 모드 우선순위 정리

This commit is contained in:
2026-09-06 23:56:38 +09:00
parent 002a922e1b
commit cdc9cb7316
43 changed files with 998 additions and 775 deletions
@@ -146,7 +146,7 @@ namespace ProjectQuint.Bootstrap
foreach (var entry in entries) foreach (var entry in entries)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var prepared = SongLoadCoordinator.ParseAndTransform(entry, LaneModifier.Off, 0); var prepared = SongChartPreparation.Prepare(entry, LaneModifier.Off, 0);
var key = _cache.BuildKey(prepared.Chart, outputSampleRate, cancellationToken); var key = _cache.BuildKey(prepared.Chart, outputSampleRate, cancellationToken);
if (unique.ContainsKey(key)) if (unique.ContainsKey(key))
continue; continue;
@@ -305,10 +305,10 @@ namespace ProjectQuint.Bootstrap
continue; continue;
} }
SongLoadCoordinator.PreparedChart prepared; PreparedChart prepared;
try try
{ {
prepared = SongLoadCoordinator.ParseAndTransform(item.Entry, LaneModifier.Off, 0); prepared = SongChartPreparation.Prepare(item.Entry, LaneModifier.Off, 0);
} }
catch (Exception exception) catch (Exception exception)
{ {
@@ -154,10 +154,10 @@ namespace ProjectQuint.Bootstrap
private SongLibraryEntry _loadingEntry; private SongLibraryEntry _loadingEntry;
private SongLibraryEntry _playingEntry; private SongLibraryEntry _playingEntry;
private AeryCourseIndex _courseIndex; private AeryCourseIndex _courseIndex;
private AeryTableIndex _aeryCatalog;
private IChartCatalog _activeCatalog; private IChartCatalog _activeCatalog;
private SearchAliasIndex _searchAliases = SearchAliasIndex.Empty; private SearchAliasIndex _searchAliases = SearchAliasIndex.Empty;
private int _aeryMatchedCharts;
private int _catalogMatchedCharts; private int _catalogMatchedCharts;
private int _libraryScanGeneration; private int _libraryScanGeneration;
private Texture2D _libraryThumbnail; private Texture2D _libraryThumbnail;
@@ -1554,57 +1554,14 @@ namespace ProjectQuint.Bootstrap
yield break; yield break;
} }
var isAeryTable = IsAeryTable(_activeDifficultyTable?.HeaderUrl); var tableSource = new DifficultyTableLibrarySource(_activeDifficultyTable, _difficultyTableRegistry);
var cachePath = _activeDifficultyTable == null var localData = tableSource.LoadLocal();
? string.Empty _activeCatalog = localData.Catalog.Data;
: _difficultyTableRegistry.DataCachePathFor(_activeDifficultyTable); _courseIndex = localData.Courses.Data;
var courseCachePath = _activeDifficultyTable == null var catalogOrigin = localData.Catalog.Origin;
? string.Empty var catalogStatus = localData.Catalog.Status;
: _difficultyTableRegistry.HeaderCachePathFor(_activeDifficultyTable); var courseLocalStatus = localData.Courses.Status;
var bundledTable = Resources.Load<TextAsset>("ProjectQuint/Data/AeryData"); _courseStatus = courseLocalStatus;
var bundledCourseHeader = Resources.Load<TextAsset>("ProjectQuint/Data/AeryHeader");
ExternalDataOrigin catalogOrigin;
string catalogStatus;
string courseLocalStatus;
if (isAeryTable)
{
var tableResult = AeryTableIndex.LoadLocal(
cachePath,
bundledTable != null ? bundledTable.text : string.Empty);
_aeryCatalog = tableResult.Data;
_activeCatalog = _aeryCatalog;
catalogOrigin = tableResult.Origin;
catalogStatus = tableResult.Status;
var courseResult = AeryCourseIndex.LoadLocal(
courseCachePath,
bundledCourseHeader != null ? bundledCourseHeader.text : string.Empty);
_courseIndex = courseResult.Data;
_courseStatus = courseResult.Status;
courseLocalStatus = courseResult.Status;
}
else
{
_aeryCatalog = null;
var localTable = _activeDifficultyTable == null
? null
: BmsDifficultyTableIndex.LoadLocal(
_activeDifficultyTable.Id,
courseCachePath,
cachePath);
_activeCatalog = localTable?.Data;
catalogOrigin = localTable?.Origin ?? ExternalDataOrigin.Unavailable;
catalogStatus = localTable?.Status ?? "No difficulty table configured";
var localCourses = _activeDifficultyTable == null
? null
: AeryCourseIndex.LoadTableLocal(
_activeDifficultyTable.Id,
localTable?.Data?.DisplayName ?? _activeDifficultyTable.DisplayName,
courseCachePath);
_courseIndex = localCourses?.Data;
_courseStatus = localCourses?.Status ?? "NO BMSTABLE COURSE DATA CONFIGURED";
courseLocalStatus = _courseStatus;
}
var bundledSearchAliases = Resources.Load<TextAsset>("ProjectQuint/Data/SearchAliases"); var bundledSearchAliases = Resources.Load<TextAsset>("ProjectQuint/Data/SearchAliases");
_searchAliases = SearchAliasIndex.Load( _searchAliases = SearchAliasIndex.Load(
bundledSearchAliases != null ? bundledSearchAliases.text : string.Empty, bundledSearchAliases != null ? bundledSearchAliases.text : string.Empty,
@@ -1619,7 +1576,8 @@ namespace ProjectQuint.Bootstrap
libraryIndexCachePath, libraryIndexCachePath,
legacyLibraryIndexCachePath, legacyLibraryIndexCachePath,
_activePack.Id, _activePack.Id,
ResolvePackScanKeyCount(_activePack, _activeDifficultyTable)); _activePack.ForcedKeyCount,
SongKeyModePolicy.TableDefault(_activeDifficultyTable?.Id, _activeDifficultyTable?.HeaderUrl));
_scanCancellation?.Cancel(); _scanCancellation?.Cancel();
_scanCancellation?.Dispose(); _scanCancellation?.Dispose();
_scanCancellation = new CancellationTokenSource(); _scanCancellation = new CancellationTokenSource();
@@ -1651,7 +1609,7 @@ namespace ProjectQuint.Bootstrap
var scannedSongs = scanTask.Result; var scannedSongs = scanTask.Result;
_catalogMatchedCharts = ChartCatalogAnnotator.Apply(scannedSongs, _activeCatalog); _catalogMatchedCharts = ChartCatalogAnnotator.Apply(scannedSongs, _activeCatalog);
_aeryMatchedCharts = isAeryTable ? _catalogMatchedCharts : 0;
SongSearchIndexer.Rebuild(scannedSongs, _searchAliases); SongSearchIndexer.Rebuild(scannedSongs, _searchAliases);
_favoriteRepository.Apply(scannedSongs); _favoriteRepository.Apply(scannedSongs);
foreach (var song in scannedSongs) foreach (var song in scannedSongs)
@@ -1691,94 +1649,25 @@ namespace ProjectQuint.Bootstrap
RefreshLibraryThumbnail(); RefreshLibraryThumbnail();
if (_openLibraryWhenScanCompletes) _mode = AppMode.Library; if (_openLibraryWhenScanCompletes) _mode = AppMode.Library;
_openLibraryWhenScanCompletes = false; _openLibraryWhenScanCompletes = false;
if (isAeryTable) if (tableSource.IsConfigured)
{ StartCoroutine(RefreshDifficultyTable(scanGeneration, _activeDifficultyTable, _activePack, tableSource));
StartCoroutine(RefreshAeryIntegration(
scanGeneration,
cachePath,
bundledTable != null ? bundledTable.text : string.Empty,
courseCachePath,
bundledCourseHeader != null ? bundledCourseHeader.text : string.Empty));
}
else if (_activeDifficultyTable != null)
{
StartCoroutine(RefreshGenericDifficultyTable(scanGeneration, _activeDifficultyTable, _activePack));
}
Debug.Log($"[ProjectQuint] Indexed {scannedSongs.Count} charts from {_libraryRoot}. Failed: {_scanner.Failed}. " + Debug.Log($"[ProjectQuint] Indexed {scannedSongs.Count} charts from {_libraryRoot}. Failed: {_scanner.Failed}. " +
$"Index cache: {_scanner.CacheStatus}. " + $"Index cache: {_scanner.CacheStatus}. " +
$"Difficulty table: {catalogStatus}; {_catalogMatchedCharts} matched from {catalogOrigin}. " + $"Difficulty table: {catalogStatus}; {_catalogMatchedCharts} matched from {catalogOrigin}. " +
$"Key mode conflicts: {scannedSongs.Count(song => song.KeyMode.HasConflict)} (pack setting preserved). " +
$"Default view: {SongLibraryView.AllLevelsFilter} " + $"Default view: {SongLibraryView.AllLevelsFilter} " +
$"({_libraryController.State.VisibleSongs.Count} charts)."); $"({_libraryController.State.VisibleSongs.Count} charts).");
} }
private IEnumerator RefreshAeryIntegration( private IEnumerator RefreshDifficultyTable(
int scanGeneration,
string tableCachePath,
string bundledTableJson,
string courseCachePath,
string bundledCourseJson)
{
ExternalDataResult<AeryTableIndex> tableResult = null;
yield return AeryTableIndex.LoadOnline(
tableCachePath,
bundledTableJson,
result => tableResult = result);
if (scanGeneration != _libraryScanGeneration || !_libraryReady)
yield break;
if (tableResult?.Data != null && tableResult.Data.Count > 0)
{
_aeryCatalog = tableResult.Data;
_activeCatalog = _aeryCatalog;
_catalogMatchedCharts = ChartCatalogAnnotator.Apply(
_libraryController.State.AllSongs,
_aeryCatalog);
_aeryMatchedCharts = _catalogMatchedCharts;
SongSearchIndexer.Rebuild(_libraryController.State.AllSongs, _searchAliases);
_libraryController.RefreshCatalogMetadata();
if (_mode == AppMode.Library || _mode == AppMode.QuickOptions)
RefreshLibraryThumbnail();
}
ExternalDataResult<AeryCourseIndex> courseResult = null;
yield return AeryCourseIndex.LoadOnline(
courseCachePath,
bundledCourseJson,
result => courseResult = result);
if (scanGeneration != _libraryScanGeneration || !_libraryReady)
yield break;
if (courseResult?.Data != null && courseResult.Data.Courses.Count > 0)
{
_courseIndex = courseResult.Data;
_courseIndex.ResolveSongs(_libraryController.State.AllSongs);
}
var availableCourses = _courseIndex?.Courses.Count(course => course.IsPlayable) ?? 0;
var totalCourses = _courseIndex?.Courses.Count ?? 0;
_courseStatus = $"{courseResult?.Status ?? "AERY courses: unavailable"} // " +
$"{availableCourses}/{totalCourses} COURSES READY";
_status = $"BMS {_libraryController.State.AllSongs.Count:N0} // " +
$"AERY {_aeryMatchedCharts:N0} matched ({_aeryCatalog?.Origin ?? ExternalDataOrigin.Unavailable}) // " +
$"{(_scanner?.CacheHits ?? 0):N0} cached // {(_scanner?.CacheMisses ?? 0):N0} refreshed";
Debug.Log(
$"[ProjectQuint] Optional AERY refresh complete: " +
$"{_aeryMatchedCharts} chart matches from {tableResult?.Origin ?? ExternalDataOrigin.Unavailable}; " +
$"{availableCourses}/{totalCourses} courses from {courseResult?.Origin ?? ExternalDataOrigin.Unavailable}.");
}
private IEnumerator RefreshGenericDifficultyTable(
int scanGeneration, int scanGeneration,
BmsDifficultyTableDefinition table, BmsDifficultyTableDefinition table,
BmsPackDefinition sourcePack) BmsPackDefinition sourcePack,
DifficultyTableLibrarySource source)
{ {
ExternalDataResult<BmsDifficultyTableIndex> result = null; DifficultyTableLibraryData refreshed = null;
yield return BmsDifficultyTableIndex.LoadOnline( yield return source.Refresh(loaded => refreshed = loaded);
table.Id, var result = refreshed?.Catalog;
table.HeaderUrl,
_difficultyTableRegistry.HeaderCachePathFor(table),
_difficultyTableRegistry.DataCachePathFor(table),
loaded => result = loaded);
if (scanGeneration != _libraryScanGeneration || !_libraryReady || if (scanGeneration != _libraryScanGeneration || !_libraryReady ||
_activePack == null || !string.Equals(_activePack.Id, sourcePack.Id, StringComparison.OrdinalIgnoreCase) || _activePack == null || !string.Equals(_activePack.Id, sourcePack.Id, StringComparison.OrdinalIgnoreCase) ||
!string.Equals( !string.Equals(
@@ -1801,10 +1690,7 @@ namespace ProjectQuint.Bootstrap
RefreshLibraryThumbnail(); RefreshLibraryThumbnail();
} }
var courseResult = AeryCourseIndex.LoadTableLocal( var courseResult = refreshed.Courses;
table.Id,
result?.Data?.DisplayName ?? table.DisplayName,
_difficultyTableRegistry.HeaderCachePathFor(table));
if (courseResult.Data != null) if (courseResult.Data != null)
{ {
_courseIndex = courseResult.Data; _courseIndex = courseResult.Data;
@@ -1819,13 +1705,10 @@ namespace ProjectQuint.Bootstrap
$"({result?.Origin ?? ExternalDataOrigin.Unavailable}) // " + $"({result?.Origin ?? ExternalDataOrigin.Unavailable}) // " +
$"{(_scanner?.CacheHits ?? 0):N0} cached // {(_scanner?.CacheMisses ?? 0):N0} refreshed"; $"{(_scanner?.CacheHits ?? 0):N0} cached // {(_scanner?.CacheMisses ?? 0):N0} refreshed";
Debug.Log($"[ProjectQuint] Difficulty table refresh: {result?.Status}; {_catalogMatchedCharts} chart matches; " + Debug.Log($"[ProjectQuint] Difficulty table refresh: {result?.Status}; {_catalogMatchedCharts} chart matches; " +
$"{_libraryController.State.AllSongs.Count(song => song.KeyMode.HasConflict)} key mode conflicts (pack setting preserved); " +
$"{availableCourses}/{totalCourses} courses from {courseResult.Origin}."); $"{availableCourses}/{totalCourses} courses from {courseResult.Origin}.");
} }
private static bool IsAeryTable(string url) =>
!string.IsNullOrWhiteSpace(url) &&
url.IndexOf("/aery/", StringComparison.OrdinalIgnoreCase) >= 0;
private void UpdateHomeInput() private void UpdateHomeInput()
{ {
var keyboard = Keyboard.current; var keyboard = Keyboard.current;
@@ -2448,20 +2331,6 @@ namespace ProjectQuint.Bootstrap
return -1; return -1;
} }
internal static int ResolvePackScanKeyCount(
BmsPackDefinition pack,
BmsDifficultyTableDefinition table)
{
if (pack?.ForcedKeyCount >= 4 && pack.ForcedKeyCount <= 7)
return pack.ForcedKeyCount;
var preset = BmsDifficultyTableIndex.FindPreset(table?.Id, table?.HeaderUrl);
return preset.HasValue &&
preset.Value.DefaultKeyCount >= 4 &&
preset.Value.DefaultKeyCount <= 7
? preset.Value.DefaultKeyCount
: 0;
}
private void RequestPackRemoval() private void RequestPackRemoval()
{ {
var packs = _packRegistry?.Packs; var packs = _packRegistry?.Packs;
@@ -2595,10 +2464,10 @@ namespace ProjectQuint.Bootstrap
_libraryRoot = string.Empty; _libraryRoot = string.Empty;
_libraryLoadError = string.Empty; _libraryLoadError = string.Empty;
_activeCatalog = null; _activeCatalog = null;
_aeryCatalog = null;
_courseIndex = null; _courseIndex = null;
_searchAliases = SearchAliasIndex.Empty; _searchAliases = SearchAliasIndex.Empty;
_aeryMatchedCharts = 0;
_catalogMatchedCharts = 0; _catalogMatchedCharts = 0;
ClearLoadedSongState(); ClearLoadedSongState();
_courseSession.ClearRun(); _courseSession.ClearRun();
@@ -3152,10 +3021,10 @@ namespace ProjectQuint.Bootstrap
keyboard.digit4Key.wasPressedThisFrame ? 3 : keyboard.digit4Key.wasPressedThisFrame ? 3 :
keyboard.digit5Key.wasPressedThisFrame ? 4 : keyboard.digit5Key.wasPressedThisFrame ? 4 :
keyboard.digit6Key.wasPressedThisFrame ? 5 : -1; keyboard.digit6Key.wasPressedThisFrame ? 5 : -1;
if (presetIndex >= 0 && presetIndex < BmsDifficultyTableIndex.Presets.Length) if (presetIndex >= 0 && presetIndex < BmsDifficultyTablePresets.All.Length)
{ {
_tableUrlDraft = BmsDifficultyTableIndex.Presets[presetIndex].HeaderUrl; _tableUrlDraft = BmsDifficultyTablePresets.All[presetIndex].HeaderUrl;
_tableSetupStatus = $"SELECTED // {BmsDifficultyTableIndex.Presets[presetIndex].DisplayName} // ENTER TO APPLY"; _tableSetupStatus = $"SELECTED // {BmsDifficultyTablePresets.All[presetIndex].DisplayName} // ENTER TO APPLY";
return; return;
} }
if (keyboard.cKey.wasPressedThisFrame) if (keyboard.cKey.wasPressedThisFrame)
@@ -3198,10 +3067,10 @@ namespace ProjectQuint.Bootstrap
_tableUrlDraft = selected?.HeaderUrl ?? string.Empty; _tableUrlDraft = selected?.HeaderUrl ?? string.Empty;
_tableSetupStatus = $"SELECTED // {selected?.DisplayName ?? "TABLE"} // ASSIGN TO PACK"; _tableSetupStatus = $"SELECTED // {selected?.DisplayName ?? "TABLE"} // ASSIGN TO PACK";
} }
if (result.PresetIndex >= 0 && result.PresetIndex < BmsDifficultyTableIndex.Presets.Length) if (result.PresetIndex >= 0 && result.PresetIndex < BmsDifficultyTablePresets.All.Length)
{ {
_tableUrlDraft = BmsDifficultyTableIndex.Presets[result.PresetIndex].HeaderUrl; _tableUrlDraft = BmsDifficultyTablePresets.All[result.PresetIndex].HeaderUrl;
_tableSetupStatus = $"SELECTED // {BmsDifficultyTableIndex.Presets[result.PresetIndex].DisplayName} // APPLY TO SAVE"; _tableSetupStatus = $"SELECTED // {BmsDifficultyTablePresets.All[result.PresetIndex].DisplayName} // APPLY TO SAVE";
} }
if (result.Clear) if (result.Clear)
{ {
@@ -4639,11 +4508,13 @@ namespace ProjectQuint.Bootstrap
} }
else else
{ {
var table = ResolvePackDifficultyTable(pack);
var scanner = new SongLibraryScanner( var scanner = new SongLibraryScanner(
_packRegistry.CachePathFor(pack), _packRegistry.CachePathFor(pack),
legacyCachePath: null, legacyCachePath: null,
pack.Id, pack.Id,
pack.ForcedKeyCount); pack.ForcedKeyCount,
SongKeyModePolicy.TableDefault(table?.Id, table?.HeaderUrl));
var scanTask = Task.Run(() => scanner.Scan(pack.ContentRoot, token), token); var scanTask = Task.Run(() => scanner.Scan(pack.ContentRoot, token), token);
while (!scanTask.IsCompleted) while (!scanTask.IsCompleted)
{ {
@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using ProjectQuint.Core.Bms;
using ProjectQuint.Core.Charts;
using ProjectQuint.Library;
namespace ProjectQuint.Features.Loading
{
internal sealed class PreparedChart
{
public PreparedChart(
PlayableChart chart,
IReadOnlyDictionary<int, PlayableNote> notesById,
int[] laneMap)
{
Chart = chart;
NotesById = notesById;
LaneMap = laneMap;
}
public PlayableChart Chart { get; }
public IReadOnlyDictionary<int, PlayableNote> NotesById { get; }
public int[] LaneMap { get; }
}
internal static class SongChartPreparation
{
public static PreparedChart Prepare(
SongLibraryEntry entry,
LaneModifier laneModifier,
int randomSeed)
{
if (entry == null)
throw new ArgumentNullException(nameof(entry));
var document = BmsParser.ParseFile(
entry.ChartPath,
forcedKeyCount: entry.EffectiveKeyCount);
var chart = BmsTimelineCompiler.Compile(document);
chart.Metadata.Md5 = entry.Metadata.Md5;
chart.Metadata.Sha256 = entry.Metadata.Sha256;
if (!string.IsNullOrWhiteSpace(entry.DisplayTitle))
chart.Metadata.Title = entry.DisplayTitle;
if (!string.IsNullOrWhiteSpace(entry.DisplayArtist))
chart.Metadata.Artist = entry.DisplayArtist;
var conversion = BmsPlayableChartConverter.Convert(chart, laneModifier, randomSeed);
var playableChart = conversion.Chart;
playableChart.Metadata.DifficultyLabel = entry.DifficultyLabel;
var notesById = new Dictionary<int, PlayableNote>(playableChart.Notes.Count);
foreach (var note in playableChart.Notes)
notesById[note.Id] = note;
return new PreparedChart(playableChart, notesById, conversion.LaneMap);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a9656d1abb6d49fc84c4fc3313fcb726
@@ -109,7 +109,7 @@ namespace ProjectQuint.Features.Loading
} }
progress?.Invoke(new SongLoadProgress("Parsing BMS timeline")); progress?.Invoke(new SongLoadProgress("Parsing BMS timeline"));
var parseTask = Task.Run(() => ParseAndTransform( var parseTask = Task.Run(() => SongChartPreparation.Prepare(
request.Entry, request.Entry,
request.LaneModifier, request.LaneModifier,
request.LaneRandomSeed)); request.LaneRandomSeed));
@@ -294,34 +294,6 @@ namespace ProjectQuint.Features.Loading
} }
} }
internal static PreparedChart ParseAndTransform(
SongLibraryEntry entry,
LaneModifier laneModifier,
int randomSeed)
{
if (entry == null)
throw new ArgumentNullException(nameof(entry));
var document = BmsParser.ParseFile(
entry.ChartPath,
forcedKeyCount: entry.EffectiveKeyCount);
var chart = BmsTimelineCompiler.Compile(document);
chart.Metadata.Md5 = entry.Metadata.Md5;
chart.Metadata.Sha256 = entry.Metadata.Sha256;
if (!string.IsNullOrWhiteSpace(entry.DisplayTitle))
chart.Metadata.Title = entry.DisplayTitle;
if (!string.IsNullOrWhiteSpace(entry.DisplayArtist))
chart.Metadata.Artist = entry.DisplayArtist;
var conversion = BmsPlayableChartConverter.Convert(chart, laneModifier, randomSeed);
var playableChart = conversion.Chart;
playableChart.Metadata.DifficultyLabel = entry.DifficultyLabel;
var notesById = new Dictionary<int, PlayableNote>(playableChart.Notes.Count);
foreach (var note in playableChart.Notes)
notesById[note.Id] = note;
return new PreparedChart(playableChart, notesById, conversion.LaneMap);
}
internal static bool ShouldCancel(int loadGeneration, int currentGeneration, bool isLoadingMode) => internal static bool ShouldCancel(int loadGeneration, int currentGeneration, bool isLoadingMode) =>
loadGeneration != currentGeneration || !isLoadingMode; loadGeneration != currentGeneration || !isLoadingMode;
@@ -333,6 +305,7 @@ namespace ProjectQuint.Features.Loading
return $"[ProjectQuint] Playing {chart.Metadata.Title}: {chart.Notes.Count} notes, " + return $"[ProjectQuint] Playing {chart.Metadata.Title}: {chart.Notes.Count} notes, " +
$"{chart.BackgroundAudioEvents.Count} BGM events, " + $"{chart.BackgroundAudioEvents.Count} BGM events, " +
$"{ChartKeyMode.FormatLabel(chart.Metadata.KeyCount, chart.Metadata.HasScratch)}, " + $"{ChartKeyMode.FormatLabel(chart.Metadata.KeyCount, chart.Metadata.HasScratch)}, " +
$"key mode source {request.Entry.KeyMode.Source}, table conflict {request.Entry.KeyMode.HasConflict}, " +
$"{chart.ScratchObjectCount} scratch objects, " + $"{chart.ScratchObjectCount} scratch objects, " +
$"{song.Audio.LoadedClipCount} clips loaded, {song.Audio.MissingClipCount} missing, " + $"{song.Audio.LoadedClipCount} clips loaded, {song.Audio.MissingClipCount} missing, " +
$"{song.Audio.DecodeFailedClipCount} decode failed, " + $"{song.Audio.DecodeFailedClipCount} decode failed, " +
@@ -386,22 +359,5 @@ namespace ProjectQuint.Features.Loading
completed => { _ = completed.Exception; }, completed => { _ = completed.Exception; },
TaskContinuationOptions.OnlyOnFaulted); TaskContinuationOptions.OnlyOnFaulted);
} }
internal sealed class PreparedChart
{
public PreparedChart(
PlayableChart chart,
IReadOnlyDictionary<int, PlayableNote> notesById,
int[] laneMap)
{
Chart = chart;
NotesById = notesById;
LaneMap = laneMap;
}
public PlayableChart Chart { get; }
public IReadOnlyDictionary<int, PlayableNote> NotesById { get; }
public int[] LaneMap { get; }
}
} }
} }
@@ -55,7 +55,7 @@ namespace ProjectQuint.Features.Profile
{ {
if (table == null) if (table == null)
continue; continue;
var preset = BmsDifficultyTableIndex.FindPreset(table.Id, table.HeaderUrl); var preset = BmsDifficultyTablePresets.Find(table.Id, table.HeaderUrl);
if (preset?.DefaultKeyCount >= 4) if (preset?.DefaultKeyCount >= 4)
AddResolved(table.Id, table.DisplayName, preset.Value.DefaultKeyCount, AddResolved(table.Id, table.DisplayName, preset.Value.DefaultKeyCount,
preset.Value.DefaultHasScratch); preset.Value.DefaultHasScratch);
@@ -1,5 +1,4 @@
using System; using System;
using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using ProjectQuint.Core.Gauge; using ProjectQuint.Core.Gauge;
@@ -9,7 +8,6 @@ namespace ProjectQuint.Library
{ {
public sealed class AeryCourseIndex public sealed class AeryCourseIndex
{ {
public const string HeaderUrl = "https://asumatoki.kr/table/aery/header.json";
private readonly List<AeryCourseDefinition> _courses; private readonly List<AeryCourseDefinition> _courses;
private AeryCourseIndex( private AeryCourseIndex(
@@ -70,20 +68,6 @@ namespace ProjectQuint.Library
} }
} }
internal static ExternalDataResult<AeryCourseIndex> LoadLocal(
string cachePath,
string bundledJson)
{
var result = ExternalJsonDataSource.LoadLocal(
"AERY courses",
cachePath,
bundledJson,
json => ParseValidated("aery", "AERY", json));
result.Data ??= Empty("aery", "AERY");
result.Data.Origin = result.Origin;
return result;
}
internal static ExternalDataResult<AeryCourseIndex> LoadTableLocal( internal static ExternalDataResult<AeryCourseIndex> LoadTableLocal(
string providerId, string providerId,
string providerDisplayName, string providerDisplayName,
@@ -99,25 +83,6 @@ namespace ProjectQuint.Library
return result; return result;
} }
internal static IEnumerator LoadOnline(
string cachePath,
string bundledJson,
Action<ExternalDataResult<AeryCourseIndex>> completed)
{
yield return ExternalJsonDataSource.LoadOnline(
"AERY courses",
HeaderUrl,
cachePath,
bundledJson,
json => ParseValidated("aery", "AERY", json),
result =>
{
result.Data ??= Empty("aery", "AERY");
result.Data.Origin = result.Origin;
completed?.Invoke(result);
});
}
internal static AeryCourseIndex Parse(string json) internal static AeryCourseIndex Parse(string json)
=> Parse("aery", "AERY", json); => Parse("aery", "AERY", json);
@@ -1,116 +0,0 @@
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace ProjectQuint.Library
{
public sealed class AeryTableIndex : IChartCatalog
{
public const string DataUrl = "https://asumatoki.kr/table/aery/data.json";
private static readonly string[] LevelOrder =
{
"LEVEL 1", "LEVEL 2", "LEVEL 3", "LEVEL 4", "LEVEL 5", "LEVEL 6", "LEVEL 7", "LEVEL 8",
"LEVEL 9", "LEVEL 10", "LEVEL 11", "LEVEL 12", "LEVEL 13", "LEVEL 14", "LEVEL 15", "LEVEL 15+",
"LEVEL 16", "LEVEL 16+", "LEVEL 17", "LEVEL 17+", "LEVEL 18", "LEVEL 18+", "LEVEL 19", "LEVEL 19+",
"LEVEL 20", "LEVEL 20+", "LEVEL 99", "LEVEL GIMMICK", "LEVEL SPECIAL", "LEVEL DUMMY", "OLD CHARTS",
};
private readonly Dictionary<string, ChartCatalogEntry> _entries;
private AeryTableIndex(Dictionary<string, ChartCatalogEntry> entries)
{
_entries = entries ?? new Dictionary<string, ChartCatalogEntry>(StringComparer.OrdinalIgnoreCase);
}
public string ProviderId => "aery";
public string DisplayName => "AERY";
public ExternalDataOrigin Origin { get; private set; }
public int Count => _entries.Count;
public bool TryGet(string chartHash, out ChartCatalogEntry entry) =>
_entries.TryGetValue((chartHash ?? string.Empty).Trim(), out entry);
internal static ExternalDataResult<AeryTableIndex> LoadLocal(string cachePath, string bundledJson)
{
var result = ExternalJsonDataSource.LoadLocal(
"AERY table",
cachePath,
bundledJson,
ParseValidated);
result.Data ??= Empty();
result.Data.Origin = result.Origin;
return result;
}
internal static IEnumerator LoadOnline(
string cachePath,
string bundledJson,
Action<ExternalDataResult<AeryTableIndex>> completed)
{
yield return ExternalJsonDataSource.LoadOnline(
"AERY table",
DataUrl,
cachePath,
bundledJson,
ParseValidated,
result =>
{
result.Data ??= Empty();
result.Data.Origin = result.Origin;
completed?.Invoke(result);
});
}
internal static AeryTableIndex Parse(string json) =>
ParseValidated(json) ?? Empty();
private static AeryTableIndex ParseValidated(string json)
{
var wrapper = JsonUtility.FromJson<AeryTableWrapper>($"{{\"items\":{json}}}");
if (wrapper?.items == null || wrapper.items.Length == 0)
return null;
var levelOrder = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (var index = 0; index < LevelOrder.Length; index++)
levelOrder[LevelOrder[index]] = index;
var entries = new Dictionary<string, ChartCatalogEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var item in wrapper.items)
{
if (item == null || string.IsNullOrWhiteSpace(item.md5))
continue;
var level = item.level ?? string.Empty;
entries[item.md5.Trim()] = new ChartCatalogEntry
{
DifficultyLabel = level,
DifficultyValue = level,
DifficultySortIndex = levelOrder.TryGetValue(level, out var sortIndex)
? sortIndex
: int.MaxValue,
TitleOverride = item.title ?? string.Empty,
ArtistOverride = item.artist ?? string.Empty,
};
}
return entries.Count > 0 ? new AeryTableIndex(entries) : null;
}
private static AeryTableIndex Empty() =>
new AeryTableIndex(new Dictionary<string, ChartCatalogEntry>(StringComparer.OrdinalIgnoreCase));
[Serializable]
private sealed class AeryTableWrapper
{
public AeryTableEntry[] items;
}
[Serializable]
private sealed class AeryTableEntry
{
public string level;
public string title;
public string artist;
public string md5;
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: af68e030dde784779b15919621a9d264
@@ -8,87 +8,8 @@ using UnityEngine.Networking;
namespace ProjectQuint.Library namespace ProjectQuint.Library
{ {
public readonly struct BmsDifficultyTablePreset
{
public BmsDifficultyTablePreset(
string id,
string displayName,
string headerUrl,
int defaultKeyCount = 0,
bool defaultHasScratch = false)
{
Id = id;
DisplayName = displayName;
HeaderUrl = headerUrl;
DefaultKeyCount = defaultKeyCount;
DefaultHasScratch = defaultHasScratch;
}
public string Id { get; }
public string DisplayName { get; }
public string HeaderUrl { get; }
public int DefaultKeyCount { get; }
public bool DefaultHasScratch { get; }
}
public sealed class BmsDifficultyTableIndex : IChartCatalog public sealed class BmsDifficultyTableIndex : IChartCatalog
{ {
public static readonly BmsDifficultyTablePreset[] Presets =
{
new BmsDifficultyTablePreset(
"aery",
"5KEYS AERY",
"https://asumatoki.kr/table/aery/header.json",
5),
new BmsDifficultyTablePreset(
"satellite",
"Satellite",
"https://stellabms.xyz/sl/header.json",
7,
true),
new BmsDifficultyTablePreset(
"stella",
"Stella",
"https://stellabms.xyz/st/header.json",
7,
true),
new BmsDifficultyTablePreset(
"genocide-insane",
"GENOCIDE Insane BMS (legacy mirror)",
"https://miraiscarlet.github.io/bms/table/genocide_insane/header_insane.json",
7,
true),
new BmsDifficultyTablePreset(
"4ue",
"4UE Difficulty Table",
"https://classmaterma.github.io/4UE/header.json",
4),
new BmsDifficultyTablePreset(
"6ue",
"6UE Difficulty Table",
"https://classmaterma.github.io/UE/table.html",
6),
};
public static BmsDifficultyTablePreset? FindPreset(string tableId, string headerUrl = null)
{
foreach (var preset in Presets)
{
if (!string.IsNullOrWhiteSpace(tableId) &&
string.Equals(preset.Id, tableId.Trim(), StringComparison.OrdinalIgnoreCase))
return preset;
if (!string.IsNullOrWhiteSpace(headerUrl) &&
string.Equals(preset.HeaderUrl, headerUrl.Trim(), StringComparison.OrdinalIgnoreCase))
return preset;
}
return null;
}
private static readonly Regex BmsTableMetaRegex = new Regex(
"<meta[^>]+name\\s*=\\s*[\\\"']bmstable[\\\"'][^>]+content\\s*=\\s*[\\\"'](?<url>[^\\\"']+)[\\\"'][^>]*>|" +
"<meta[^>]+content\\s*=\\s*[\\\"'](?<url>[^\\\"']+)[\\\"'][^>]+name\\s*=\\s*[\\\"']bmstable[\\\"'][^>]*>",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private readonly Dictionary<string, ChartCatalogEntry> _entries; private readonly Dictionary<string, ChartCatalogEntry> _entries;
private readonly int _chartCount; private readonly int _chartCount;
@@ -139,18 +60,13 @@ namespace ProjectQuint.Library
} }
internal static IEnumerator LoadOnline( internal static IEnumerator LoadOnline(
string providerId, DifficultyTableRequest source,
string configuredUrl,
string headerCachePath,
string dataCachePath,
Action<ExternalDataResult<BmsDifficultyTableIndex>> completed) Action<ExternalDataResult<BmsDifficultyTableIndex>> completed)
{ {
if (!Uri.TryCreate(configuredUrl, UriKind.Absolute, out var configuredUri) || var configuredUri = source.HeaderUri;
configuredUri.Scheme != Uri.UriSchemeHttps) var providerId = source.ProviderId;
{ var headerCachePath = source.HeaderCachePath;
completed?.Invoke(Unavailable("Difficulty table URL must use HTTPS")); var dataCachePath = source.DataCachePath;
yield break;
}
string firstDocument = null; string firstDocument = null;
string requestFailure = null; string requestFailure = null;
@@ -166,21 +82,17 @@ namespace ProjectQuint.Library
var headerUrl = configuredUri.AbsoluteUri; var headerUrl = configuredUri.AbsoluteUri;
var headerJson = firstDocument; var headerJson = firstDocument;
if (!string.IsNullOrWhiteSpace(firstDocument) && LooksLikeHtml(firstDocument)) if (!string.IsNullOrWhiteSpace(firstDocument) && DifficultyTableRequest.LooksLikeHtml(firstDocument))
{ {
var match = BmsTableMetaRegex.Match(firstDocument); try
if (!match.Success)
{ {
completed?.Invoke(Unavailable("Table page has no bmstable header metadata")); headerUrl = DifficultyTableRequest.ResolveHeaderPage(configuredUri, firstDocument).AbsoluteUri;
}
catch (Exception exception)
{
completed?.Invoke(Unavailable($"Difficulty table header URL invalid: {exception.Message}"));
yield break; yield break;
} }
var resolvedHeaderUri = new Uri(configuredUri, match.Groups["url"].Value);
if (resolvedHeaderUri.Scheme != Uri.UriSchemeHttps)
{
completed?.Invoke(Unavailable("Difficulty table header URL must use HTTPS"));
yield break;
}
headerUrl = resolvedHeaderUri.AbsoluteUri;
using var headerRequest = UnityWebRequest.Get(headerUrl); using var headerRequest = UnityWebRequest.Get(headerUrl);
headerRequest.timeout = 12; headerRequest.timeout = 12;
yield return headerRequest.SendWebRequest(); yield return headerRequest.SendWebRequest();
@@ -211,10 +123,14 @@ namespace ProjectQuint.Library
yield break; yield break;
} }
var dataUri = new Uri(new Uri(headerUrl), header.data_url); Uri dataUri;
if (dataUri.Scheme != Uri.UriSchemeHttps) try
{ {
completed?.Invoke(Unavailable("Difficulty table data URL must use HTTPS")); dataUri = DifficultyTableRequest.ResolveLink(new Uri(headerUrl), header.data_url);
}
catch (Exception exception)
{
completed?.Invoke(Unavailable($"Difficulty table data URL invalid: {exception.Message}"));
yield break; yield break;
} }
var dataUrl = dataUri.AbsoluteUri; var dataUrl = dataUri.AbsoluteUri;
@@ -346,9 +262,6 @@ namespace ProjectQuint.Library
return symbol + level; return symbol + level;
} }
private static bool LooksLikeHtml(string text) =>
TrimBom(text).TrimStart().StartsWith("<", StringComparison.Ordinal);
private static string TrimBom(string text) => (text ?? string.Empty).TrimStart('\uFEFF'); private static string TrimBom(string text) => (text ?? string.Empty).TrimStart('\uFEFF');
private static ExternalDataResult<BmsDifficultyTableIndex> Unavailable(string status) => private static ExternalDataResult<BmsDifficultyTableIndex> Unavailable(string status) =>
@@ -0,0 +1,82 @@
using System;
namespace ProjectQuint.Library
{
public readonly struct BmsDifficultyTablePreset
{
public BmsDifficultyTablePreset(
string id,
string displayName,
string headerUrl,
int defaultKeyCount = 0,
bool defaultHasScratch = false)
{
Id = id;
DisplayName = displayName;
HeaderUrl = headerUrl;
DefaultKeyCount = defaultKeyCount;
DefaultHasScratch = defaultHasScratch;
}
public string Id { get; }
public string DisplayName { get; }
public string HeaderUrl { get; }
public int DefaultKeyCount { get; }
public bool DefaultHasScratch { get; }
}
public static class BmsDifficultyTablePresets
{
public static readonly BmsDifficultyTablePreset[] All =
{
new BmsDifficultyTablePreset(
"aery",
"5KEYS AERY",
"https://asumatoki.kr/table/aery/header.json",
5),
new BmsDifficultyTablePreset(
"satellite",
"Satellite",
"https://stellabms.xyz/sl/header.json",
7,
true),
new BmsDifficultyTablePreset(
"stella",
"Stella",
"https://stellabms.xyz/st/header.json",
7,
true),
new BmsDifficultyTablePreset(
"genocide-insane",
"GENOCIDE Insane BMS (legacy mirror)",
"https://miraiscarlet.github.io/bms/table/genocide_insane/header_insane.json",
7,
true),
new BmsDifficultyTablePreset(
"4ue",
"4UE Difficulty Table",
"https://classmaterma.github.io/4UE/header.json",
4),
new BmsDifficultyTablePreset(
"6ue",
"6UE Difficulty Table",
"https://classmaterma.github.io/UE/table.html",
6),
};
public static BmsDifficultyTablePreset? Find(string tableId, string headerUrl = null)
{
foreach (var preset in All)
{
if (!string.IsNullOrWhiteSpace(tableId) &&
string.Equals(preset.Id, tableId.Trim(), StringComparison.OrdinalIgnoreCase))
return preset;
if (!string.IsNullOrWhiteSpace(headerUrl) &&
string.Equals(preset.HeaderUrl, headerUrl.Trim(), StringComparison.OrdinalIgnoreCase))
return preset;
}
return null;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4efa4bcb5efb47c6bc5c1115fc16903a
@@ -102,9 +102,9 @@ namespace ProjectQuint.Library
public BmsDifficultyTableDefinition RegisterPreset(int presetIndex, bool activate = true) public BmsDifficultyTableDefinition RegisterPreset(int presetIndex, bool activate = true)
{ {
if (presetIndex < 0 || presetIndex >= BmsDifficultyTableIndex.Presets.Length) if (presetIndex < 0 || presetIndex >= BmsDifficultyTablePresets.All.Length)
throw new ArgumentOutOfRangeException(nameof(presetIndex)); throw new ArgumentOutOfRangeException(nameof(presetIndex));
var preset = BmsDifficultyTableIndex.Presets[presetIndex]; var preset = BmsDifficultyTablePresets.All[presetIndex];
return Register(preset.HeaderUrl, preset.DisplayName, preset.Id, activate); return Register(preset.HeaderUrl, preset.DisplayName, preset.Id, activate);
} }
@@ -223,7 +223,7 @@ namespace ProjectQuint.Library
private void EnsurePresets() private void EnsurePresets()
{ {
var changed = false; var changed = false;
foreach (var preset in BmsDifficultyTableIndex.Presets) foreach (var preset in BmsDifficultyTablePresets.All)
{ {
if (FindByUrl(preset.HeaderUrl) != null) if (FindByUrl(preset.HeaderUrl) != null)
continue; continue;
@@ -308,7 +308,7 @@ namespace ProjectQuint.Library
private static BmsDifficultyTablePreset? FindPresetByUrl(string url) private static BmsDifficultyTablePreset? FindPresetByUrl(string url)
{ {
foreach (var preset in BmsDifficultyTableIndex.Presets) foreach (var preset in BmsDifficultyTablePresets.All)
if (string.Equals(preset.HeaderUrl, url, StringComparison.OrdinalIgnoreCase)) if (string.Equals(preset.HeaderUrl, url, StringComparison.OrdinalIgnoreCase))
return preset; return preset;
return null; return null;
@@ -41,7 +41,7 @@ namespace ProjectQuint.Library
public int DifficultySortIndex { get; set; } = int.MaxValue; public int DifficultySortIndex { get; set; } = int.MaxValue;
public string TitleOverride { get; set; } = string.Empty; public string TitleOverride { get; set; } = string.Empty;
public string ArtistOverride { get; set; } = string.Empty; public string ArtistOverride { get; set; } = string.Empty;
public int KeyCountOverride { get; set; } public int DefaultKeyCount { get; set; }
public bool HasDifficulty => !string.IsNullOrWhiteSpace(DifficultyLabel); public bool HasDifficulty => !string.IsNullOrWhiteSpace(DifficultyLabel);
} }
@@ -53,10 +53,9 @@ namespace ProjectQuint.Library
var matched = 0; var matched = 0;
var preset = catalog == null var preset = catalog == null
? null ? null
: BmsDifficultyTableIndex.FindPreset(catalog.ProviderId); : BmsDifficultyTablePresets.Find(catalog.ProviderId);
var keyCountOverride = preset.HasValue && var defaultKeyCount = preset.HasValue &&
preset.Value.DefaultKeyCount >= 4 && SongKeyModePolicy.IsSupported(preset.Value.DefaultKeyCount)
preset.Value.DefaultKeyCount <= 7
? preset.Value.DefaultKeyCount ? preset.Value.DefaultKeyCount
: 0; : 0;
foreach (var song in songs ?? Array.Empty<SongLibraryEntry>()) foreach (var song in songs ?? Array.Empty<SongLibraryEntry>())
@@ -82,7 +81,7 @@ namespace ProjectQuint.Library
DifficultySortIndex = entry.DifficultySortIndex, DifficultySortIndex = entry.DifficultySortIndex,
TitleOverride = entry.TitleOverride ?? string.Empty, TitleOverride = entry.TitleOverride ?? string.Empty,
ArtistOverride = entry.ArtistOverride ?? string.Empty, ArtistOverride = entry.ArtistOverride ?? string.Empty,
KeyCountOverride = keyCountOverride, DefaultKeyCount = defaultKeyCount,
}; };
matched++; matched++;
} }
@@ -0,0 +1,72 @@
using System;
using System.Collections;
namespace ProjectQuint.Library
{
internal sealed class DifficultyTableLibraryData
{
public ExternalDataResult<BmsDifficultyTableIndex> Catalog { get; set; }
public ExternalDataResult<AeryCourseIndex> Courses { get; set; }
}
internal sealed class DifficultyTableLibrarySource
{
private readonly string _providerId;
private readonly string _displayName;
private readonly string _headerUrl;
private readonly string _headerCachePath;
private readonly string _dataCachePath;
public DifficultyTableLibrarySource(BmsDifficultyTableDefinition table, BmsDifficultyTableRegistry registry)
{
// Snapshot registration values so a later selection cannot redirect an in-flight load.
_providerId = table?.Id ?? string.Empty;
_displayName = table?.DisplayName ?? string.Empty;
_headerUrl = table?.HeaderUrl ?? string.Empty;
_headerCachePath = table == null ? string.Empty : registry.HeaderCachePathFor(table);
_dataCachePath = table == null ? string.Empty : registry.DataCachePathFor(table);
}
public bool IsConfigured => !string.IsNullOrWhiteSpace(_headerUrl);
public DifficultyTableLibraryData LoadLocal()
{
var catalog = IsConfigured
? BmsDifficultyTableIndex.LoadLocal(_providerId, _headerCachePath, _dataCachePath)
: new ExternalDataResult<BmsDifficultyTableIndex> { Status = "No difficulty table configured" };
return WithCourses(catalog);
}
public IEnumerator Refresh(Action<DifficultyTableLibraryData> completed)
{
DifficultyTableRequest request;
try
{
request = new DifficultyTableRequest(_providerId, _headerUrl, _headerCachePath, _dataCachePath);
}
catch (ArgumentException exception)
{
completed?.Invoke(WithCourses(new ExternalDataResult<BmsDifficultyTableIndex> { Status = exception.Message }));
yield break;
}
ExternalDataResult<BmsDifficultyTableIndex> catalog = null;
yield return BmsDifficultyTableIndex.LoadOnline(request, result => catalog = result);
catalog ??= new ExternalDataResult<BmsDifficultyTableIndex>
{
Status = "Difficulty table request did not complete",
};
completed?.Invoke(WithCourses(catalog));
}
private DifficultyTableLibraryData WithCourses(ExternalDataResult<BmsDifficultyTableIndex> catalog)
{
return new DifficultyTableLibraryData
{
Catalog = catalog,
Courses = IsConfigured
? AeryCourseIndex.LoadTableLocal(_providerId, catalog.Data?.DisplayName ?? _displayName, _headerCachePath)
: new ExternalDataResult<AeryCourseIndex> { Status = "NO BMSTABLE COURSE DATA CONFIGURED" },
};
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8573e06b88064463a2d2ac0dd6f4bc9c
@@ -0,0 +1,53 @@
using System;
using System.Net;
using System.Text.RegularExpressions;
namespace ProjectQuint.Library
{
internal sealed class DifficultyTableRequest
{
private static readonly Regex HeaderMeta = new Regex(
"<meta[^>]+name\\s*=\\s*[\"']bmstable[\"'][^>]+content\\s*=\\s*[\"'](?<url>[^\"']+)[\"'][^>]*>|" +
"<meta[^>]+content\\s*=\\s*[\"'](?<url>[^\"']+)[\"'][^>]+name\\s*=\\s*[\"']bmstable[\"'][^>]*>",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public DifficultyTableRequest(string providerId, string headerUrl,
string headerCachePath, string dataCachePath)
{
ProviderId = providerId ?? string.Empty;
HeaderUri = RequireHttps(headerUrl);
HeaderCachePath = headerCachePath ?? string.Empty;
DataCachePath = dataCachePath ?? string.Empty;
}
public string ProviderId { get; }
public Uri HeaderUri { get; }
public string HeaderCachePath { get; }
public string DataCachePath { get; }
public static bool LooksLikeHtml(string text) =>
(text ?? string.Empty).TrimStart('\uFEFF').TrimStart().StartsWith("<", StringComparison.Ordinal);
public static Uri ResolveHeaderPage(Uri pageUri, string document)
{
var match = HeaderMeta.Match(document ?? string.Empty);
if (!match.Success)
throw new FormatException("Table page has no bmstable header metadata");
return ResolveLink(pageUri, WebUtility.HtmlDecode(match.Groups["url"].Value));
}
public static Uri ResolveLink(Uri documentUri, string relativeUrl)
{
if (string.IsNullOrWhiteSpace(relativeUrl))
throw new FormatException("Difficulty table document URL is missing");
return RequireHttps(new Uri(documentUri, relativeUrl).AbsoluteUri);
}
private static Uri RequireHttps(string url)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps)
throw new ArgumentException("Difficulty table URL must use HTTPS", nameof(url));
return uri;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6e7de5e563944400971cfcf8f54705b7
@@ -0,0 +1,48 @@
namespace ProjectQuint.Library
{
public enum SongKeyModeSource
{
Chart,
DifficultyTable,
Pack,
}
public readonly struct SongKeyModeResolution
{
public SongKeyModeResolution(int keyCount, SongKeyModeSource source, bool hasConflict)
{
KeyCount = keyCount;
Source = source;
HasConflict = hasConflict;
}
public int KeyCount { get; }
public SongKeyModeSource Source { get; }
public bool HasConflict { get; }
}
public static class SongKeyModePolicy
{
public static bool IsSupported(int keyCount) => keyCount >= 4 && keyCount <= 7;
public static SongKeyModeResolution Resolve(int forcedKeyCount, int tableKeyCount, int chartKeyCount)
{
var conflict = IsSupported(forcedKeyCount) && IsSupported(tableKeyCount) &&
forcedKeyCount != tableKeyCount;
// A table describes its expected layout; only the pack setting is an explicit user choice.
if (IsSupported(forcedKeyCount))
return new SongKeyModeResolution(forcedKeyCount, SongKeyModeSource.Pack, conflict);
if (IsSupported(tableKeyCount))
return new SongKeyModeResolution(tableKeyCount, SongKeyModeSource.DifficultyTable, conflict);
return new SongKeyModeResolution(chartKeyCount, SongKeyModeSource.Chart, conflict);
}
public static int TableDefault(string tableId, string headerUrl)
{
var preset = BmsDifficultyTablePresets.Find(tableId, headerUrl);
return preset.HasValue && IsSupported(preset.Value.DefaultKeyCount)
? preset.Value.DefaultKeyCount
: 0;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 49eebe4a4a024c509a8838c4bae27cee
@@ -0,0 +1,72 @@
using System;
using ProjectQuint.Core.Analysis;
using ProjectQuint.Core.Bms;
namespace ProjectQuint.Library
{
public enum SongClearLamp
{
None = 0,
NormalClear = 1,
HardClear = 2,
Failed = 3,
NormalFailed = 4,
ExHardClear = 5,
AutoScratchClear = 6,
}
public sealed class SongLibraryEntry
{
public string ChartPath { get; set; } = string.Empty;
public string PackId { get; set; } = string.Empty;
public int ForcedKeyCount { get; set; }
public int TableDefaultKeyCount { get; set; }
public string ThumbnailFile { get; set; } = string.Empty;
public BmsMetadata Metadata { get; set; } = new BmsMetadata();
public ChartCatalogAnnotation Catalog { get; set; } = new ChartCatalogAnnotation();
public SongClearLamp ClearLamp { get; set; }
public SongPlayRecord BestRecord { get; set; } = new SongPlayRecord();
public bool IsFavorite { get; set; }
public double DurationSeconds { get; set; }
public bool DurationIsEstimated { get; set; }
public ChartFeatureVector Features { get; set; }
public string FeatureAnalysisError { get; set; } = string.Empty;
public string SearchIndex { get; private set; } = string.Empty;
internal SongSearchDocument SearchDocument { get; private set; } = SongSearchDocument.Empty;
public string DisplayTitle => !string.IsNullOrWhiteSpace(Catalog?.TitleOverride)
? Catalog.TitleOverride
: Metadata?.Title ?? string.Empty;
public string DisplayArtist => !string.IsNullOrWhiteSpace(Catalog?.ArtistOverride)
? Catalog.ArtistOverride
: Metadata?.Artist ?? string.Empty;
public string DifficultyLabel => Catalog?.HasDifficulty == true
? Catalog.DifficultyLabel
: Metadata?.PlayLevel > 0 ? $"BMS LEVEL {Metadata.PlayLevel}" : string.Empty;
public string CompactDifficultyLabel => Catalog?.HasDifficulty == true
? !string.IsNullOrWhiteSpace(Catalog.DifficultyValue)
? Catalog.DifficultyValue
: Catalog.DifficultyLabel
: Metadata?.PlayLevel > 0 ? Metadata.PlayLevel.ToString() : string.Empty;
public int DifficultySortIndex => Catalog?.HasDifficulty == true
? Catalog.DifficultySortIndex
: Metadata?.PlayLevel ?? int.MaxValue;
public bool HasCatalogDifficulty => Catalog?.HasDifficulty == true;
public string DifficultySourceLabel => HasCatalogDifficulty
? Catalog.ProviderDisplayName
: Metadata?.PlayLevel > 0 ? "BMS PLAYLEVEL" : "UNRANKED";
public SongKeyModeResolution KeyMode => SongKeyModePolicy.Resolve(
ForcedKeyCount,
SongKeyModePolicy.IsSupported(Catalog?.DefaultKeyCount ?? 0)
? Catalog.DefaultKeyCount : TableDefaultKeyCount,
Metadata?.KeyCount ?? 5);
public int EffectiveKeyCount => KeyMode.KeyCount;
internal void UpdateSearchDocument(SongSearchDocument document)
{
SearchDocument = document ?? SongSearchDocument.Empty;
SearchIndex = SearchDocument.CombinedIndex;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ef4096d016784c3cb487f3a803ee5ee8
@@ -11,67 +11,6 @@ using UnityEngine;
namespace ProjectQuint.Library namespace ProjectQuint.Library
{ {
public enum SongClearLamp
{
None = 0,
NormalClear = 1,
HardClear = 2,
Failed = 3,
NormalFailed = 4,
ExHardClear = 5,
AutoScratchClear = 6,
}
public sealed class SongLibraryEntry
{
public string ChartPath { get; set; } = string.Empty;
public string PackId { get; set; } = string.Empty;
public string ThumbnailFile { get; set; } = string.Empty;
public BmsMetadata Metadata { get; set; } = new BmsMetadata();
public ChartCatalogAnnotation Catalog { get; set; } = new ChartCatalogAnnotation();
public SongClearLamp ClearLamp { get; set; }
public SongPlayRecord BestRecord { get; set; } = new SongPlayRecord();
public bool IsFavorite { get; set; }
public double DurationSeconds { get; set; }
public bool DurationIsEstimated { get; set; }
public ChartFeatureVector Features { get; set; }
public string FeatureAnalysisError { get; set; } = string.Empty;
public string SearchIndex { get; private set; } = string.Empty;
internal SongSearchDocument SearchDocument { get; private set; } = SongSearchDocument.Empty;
public string DisplayTitle => !string.IsNullOrWhiteSpace(Catalog?.TitleOverride)
? Catalog.TitleOverride
: Metadata?.Title ?? string.Empty;
public string DisplayArtist => !string.IsNullOrWhiteSpace(Catalog?.ArtistOverride)
? Catalog.ArtistOverride
: Metadata?.Artist ?? string.Empty;
public string DifficultyLabel => Catalog?.HasDifficulty == true
? Catalog.DifficultyLabel
: Metadata?.PlayLevel > 0 ? $"BMS LEVEL {Metadata.PlayLevel}" : string.Empty;
public string CompactDifficultyLabel => Catalog?.HasDifficulty == true
? !string.IsNullOrWhiteSpace(Catalog.DifficultyValue)
? Catalog.DifficultyValue
: Catalog.DifficultyLabel
: Metadata?.PlayLevel > 0 ? Metadata.PlayLevel.ToString() : string.Empty;
public int DifficultySortIndex => Catalog?.HasDifficulty == true
? Catalog.DifficultySortIndex
: Metadata?.PlayLevel ?? int.MaxValue;
public bool HasCatalogDifficulty => Catalog?.HasDifficulty == true;
public string DifficultySourceLabel => HasCatalogDifficulty
? Catalog.ProviderDisplayName
: Metadata?.PlayLevel > 0 ? "BMS PLAYLEVEL" : "UNRANKED";
public int EffectiveKeyCount =>
Catalog?.KeyCountOverride >= 4 && Catalog.KeyCountOverride <= 7
? Catalog.KeyCountOverride
: Metadata?.KeyCount ?? 5;
internal void RebuildSearchIndex(SearchAliasIndex aliases = null)
{
SearchDocument = SongSearchIndexer.Build(this, aliases);
SearchIndex = SearchDocument.CombinedIndex;
}
}
public sealed class SongLibraryScanner public sealed class SongLibraryScanner
{ {
private const int CacheVersion = 6; private const int CacheVersion = 6;
@@ -103,18 +42,23 @@ namespace ProjectQuint.Library
private readonly string _legacyCachePath; private readonly string _legacyCachePath;
private readonly string _packId; private readonly string _packId;
private readonly int _forcedKeyCount; private readonly int _forcedKeyCount;
private readonly int _tableDefaultKeyCount;
private readonly int _scanKeyCount;
private int _loadedCacheVersion; private int _loadedCacheVersion;
public SongLibraryScanner( public SongLibraryScanner(
string cachePath = null, string cachePath = null,
string legacyCachePath = null, string legacyCachePath = null,
string packId = null, string packId = null,
int forcedKeyCount = 0) int forcedKeyCount = 0,
int tableDefaultKeyCount = 0)
{ {
_cachePath = cachePath; _cachePath = cachePath;
_legacyCachePath = legacyCachePath; _legacyCachePath = legacyCachePath;
_packId = packId ?? string.Empty; _packId = packId ?? string.Empty;
_forcedKeyCount = forcedKeyCount; _forcedKeyCount = forcedKeyCount;
_tableDefaultKeyCount = tableDefaultKeyCount;
_scanKeyCount = SongKeyModePolicy.Resolve(forcedKeyCount, tableDefaultKeyCount, 0).KeyCount;
} }
public int Completed => Volatile.Read(ref _completed); public int Completed => Volatile.Read(ref _completed);
@@ -240,7 +184,7 @@ namespace ProjectQuint.Library
{ {
var thumbnailDocument = BmsParser.ParseFile( var thumbnailDocument = BmsParser.ParseFile(
fullPath, fullPath,
forcedKeyCount: _forcedKeyCount); forcedKeyCount: _scanKeyCount);
var thumbnailChart = BmsTimelineCompiler.Compile(thumbnailDocument); var thumbnailChart = BmsTimelineCompiler.Compile(thumbnailDocument);
thumbnailFile = SelectThumbnailFile( thumbnailFile = SelectThumbnailFile(
fullPath, fullPath,
@@ -273,7 +217,7 @@ namespace ProjectQuint.Library
} }
else else
{ {
var document = BmsParser.ParseFile(fullPath, forcedKeyCount: _forcedKeyCount); var document = BmsParser.ParseFile(fullPath, forcedKeyCount: _scanKeyCount);
rawMetadata = document.Metadata; rawMetadata = document.Metadata;
var hashes = ComputeHashes(fullPath); var hashes = ComputeHashes(fullPath);
rawMetadata.Md5 = hashes.Md5; rawMetadata.Md5 = hashes.Md5;
@@ -318,6 +262,8 @@ namespace ProjectQuint.Library
{ {
ChartPath = path, ChartPath = path,
PackId = _packId, PackId = _packId,
ForcedKeyCount = _forcedKeyCount,
TableDefaultKeyCount = _tableDefaultKeyCount,
ThumbnailFile = thumbnailFile, ThumbnailFile = thumbnailFile,
Metadata = CloneMetadata(rawMetadata), Metadata = CloneMetadata(rawMetadata),
DurationSeconds = durationSeconds, DurationSeconds = durationSeconds,
@@ -369,7 +315,7 @@ namespace ProjectQuint.Library
if (document == null || document.Version < 1 || document.Version > CacheVersion || if (document == null || document.Version < 1 || document.Version > CacheVersion ||
!string.Equals(Path.GetFullPath(document.LibraryRoot ?? string.Empty), libraryRoot, !string.Equals(Path.GetFullPath(document.LibraryRoot ?? string.Empty), libraryRoot,
StringComparison.OrdinalIgnoreCase) || StringComparison.OrdinalIgnoreCase) ||
document.ForcedKeyCount != _forcedKeyCount) document.ForcedKeyCount != _scanKeyCount)
{ {
CacheStatus = "cache version, library root or key mode changed"; CacheStatus = "cache version, library root or key mode changed";
return result; return result;
@@ -409,7 +355,7 @@ namespace ProjectQuint.Library
{ {
Version = CacheVersion, Version = CacheVersion,
LibraryRoot = libraryRoot, LibraryRoot = libraryRoot,
ForcedKeyCount = _forcedKeyCount, ForcedKeyCount = _scanKeyCount,
Entries = entries.ToArray(), Entries = entries.ToArray(),
}; };
var temporaryPath = _cachePath + ".tmp"; var temporaryPath = _cachePath + ".tmp";
@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
namespace ProjectQuint.Library
{
[Serializable]
public sealed class RatingPlayRecord
{
public bool HasRecord;
public string EventId = string.Empty;
public string ProviderId = string.Empty;
public string ProviderDisplayName = string.Empty;
public string DifficultyLabel = string.Empty;
public string DifficultyValue = string.Empty;
public string Title = string.Empty;
public string Artist = string.Empty;
public double ChartConstant;
public int KeyCount;
public bool HasScratch;
public string LaneModifier = string.Empty;
public bool HardClear;
public double AccuracyPercent;
public double PlayRating;
public int PlayCount;
public int RatingRulesVersion;
public long RecordedUtcTicks;
}
[Serializable]
public sealed class SongPlayRecord
{
public bool HasRecord;
public int BestScore;
public double BestAccuracyPercent;
public int BestMaxCombo;
public bool HasBestScoreJudgementBreakdown;
public int BestScoreKoolCount;
public int BestScoreCoolCount;
public int BestScoreGoodCount;
public int BestScoreMissCount;
public int BestScorePurePerfectCount;
public int PlayCount;
public bool HasJudgementBreakdown;
public int LastScore;
public double LastAccuracyPercent;
public int LastMaxCombo;
public int LastKoolCount;
public int LastCoolCount;
public int LastGoodCount;
public int LastMissCount;
public int LastFastCount;
public int LastLateCount;
public string LastAppliedEventId = string.Empty;
public int LastPurePerfectCount;
public int LastJudgedUnits;
public int LastTotalScoringUnits;
public bool LastCleared;
public bool LastHardClear;
public bool EverFullCombo;
public bool EverAllPure;
public RatingPlayRecord BestRating = new RatingPlayRecord();
public List<RatingPlayRecord> BestRatings = new List<RatingPlayRecord>();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6ea5022d2731468f8d67c43e8e13d95b
@@ -5,65 +5,6 @@ using ProjectQuint.Persistence;
namespace ProjectQuint.Library namespace ProjectQuint.Library
{ {
[Serializable]
public sealed class RatingPlayRecord
{
public bool HasRecord;
public string EventId = string.Empty;
public string ProviderId = string.Empty;
public string ProviderDisplayName = string.Empty;
public string DifficultyLabel = string.Empty;
public string DifficultyValue = string.Empty;
public string Title = string.Empty;
public string Artist = string.Empty;
public double ChartConstant;
public int KeyCount;
public bool HasScratch;
public string LaneModifier = string.Empty;
public bool HardClear;
public double AccuracyPercent;
public double PlayRating;
public int PlayCount;
public int RatingRulesVersion;
public long RecordedUtcTicks;
}
[Serializable]
public sealed class SongPlayRecord
{
public bool HasRecord;
public int BestScore;
public double BestAccuracyPercent;
public int BestMaxCombo;
public bool HasBestScoreJudgementBreakdown;
public int BestScoreKoolCount;
public int BestScoreCoolCount;
public int BestScoreGoodCount;
public int BestScoreMissCount;
public int BestScorePurePerfectCount;
public int PlayCount;
public bool HasJudgementBreakdown;
public int LastScore;
public double LastAccuracyPercent;
public int LastMaxCombo;
public int LastKoolCount;
public int LastCoolCount;
public int LastGoodCount;
public int LastMissCount;
public int LastFastCount;
public int LastLateCount;
public string LastAppliedEventId = string.Empty;
public int LastPurePerfectCount;
public int LastJudgedUnits;
public int LastTotalScoringUnits;
public bool LastCleared;
public bool LastHardClear;
public bool EverFullCombo;
public bool EverAllPure;
public RatingPlayRecord BestRating = new RatingPlayRecord();
public List<RatingPlayRecord> BestRatings = new List<RatingPlayRecord>();
}
internal readonly struct SongPlayResult internal readonly struct SongPlayResult
{ {
public SongPlayResult( public SongPlayResult(
@@ -0,0 +1,39 @@
using System;
using System.Linq;
namespace ProjectQuint.Library
{
internal sealed class SongSearchDocument
{
public static SongSearchDocument Empty { get; } = new SongSearchDocument(
Array.Empty<string>(),
Array.Empty<string>(),
Array.Empty<string>(),
Array.Empty<string>());
public SongSearchDocument(
string[] title,
string[] artist,
string[] fileName,
string[] aliases)
{
Title = title ?? Array.Empty<string>();
Artist = artist ?? Array.Empty<string>();
FileName = fileName ?? Array.Empty<string>();
Aliases = aliases ?? Array.Empty<string>();
CombinedIndex = string.Join("\n", Title
.Concat(Artist)
.Concat(FileName)
.Concat(Aliases)
.Distinct(StringComparer.Ordinal)
.OrderBy(token => token, StringComparer.Ordinal));
}
public string[] Title { get; }
public string[] Artist { get; }
public string[] FileName { get; }
public string[] Aliases { get; }
public string CombinedIndex { get; }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ad783a34277242ca96d0c370d76aeebe
@@ -6,39 +6,6 @@ using UnityEngine;
namespace ProjectQuint.Library namespace ProjectQuint.Library
{ {
internal sealed class SongSearchDocument
{
public static SongSearchDocument Empty { get; } = new SongSearchDocument(
Array.Empty<string>(),
Array.Empty<string>(),
Array.Empty<string>(),
Array.Empty<string>());
public SongSearchDocument(
string[] title,
string[] artist,
string[] fileName,
string[] aliases)
{
Title = title ?? Array.Empty<string>();
Artist = artist ?? Array.Empty<string>();
FileName = fileName ?? Array.Empty<string>();
Aliases = aliases ?? Array.Empty<string>();
CombinedIndex = string.Join("\n", Title
.Concat(Artist)
.Concat(FileName)
.Concat(Aliases)
.Distinct(StringComparer.Ordinal)
.OrderBy(token => token, StringComparer.Ordinal));
}
public string[] Title { get; }
public string[] Artist { get; }
public string[] FileName { get; }
public string[] Aliases { get; }
public string CombinedIndex { get; }
}
[Serializable] [Serializable]
internal sealed class SearchAliasDocument internal sealed class SearchAliasDocument
{ {
@@ -152,6 +119,11 @@ namespace ProjectQuint.Library
internal static class SongSearchIndexer internal static class SongSearchIndexer
{ {
public static void RebuildSearchIndex(this SongLibraryEntry song, SearchAliasIndex aliases = null)
{
song?.UpdateSearchDocument(Build(song, aliases));
}
public static void Rebuild(IReadOnlyList<SongLibraryEntry> songs, SearchAliasIndex aliases = null) public static void Rebuild(IReadOnlyList<SongLibraryEntry> songs, SearchAliasIndex aliases = null)
{ {
foreach (var song in songs ?? Array.Empty<SongLibraryEntry>()) foreach (var song in songs ?? Array.Empty<SongLibraryEntry>())
@@ -142,7 +142,7 @@ namespace ProjectQuint.UI
private readonly Label _tableStatus; private readonly Label _tableStatus;
private readonly TextField _tableUrlField; private readonly TextField _tableUrlField;
private readonly VisualElement[] _tablePresetButtons = private readonly VisualElement[] _tablePresetButtons =
new VisualElement[BmsDifficultyTableIndex.Presets.Length]; new VisualElement[BmsDifficultyTablePresets.All.Length];
private string _activeTableId = string.Empty; private string _activeTableId = string.Empty;
private string _tableUrl = string.Empty; private string _tableUrl = string.Empty;
private bool _tableUrlFocused; private bool _tableUrlFocused;
@@ -1437,7 +1437,7 @@ namespace ProjectQuint.UI
break; break;
} }
var preset = BmsDifficultyTableIndex.FindPreset(table?.Id, table?.HeaderUrl); var preset = BmsDifficultyTablePresets.Find(table?.Id, table?.HeaderUrl);
if (!preset.HasValue || if (!preset.HasValue ||
preset.Value.DefaultKeyCount < 4 || preset.Value.DefaultKeyCount < 4 ||
preset.Value.DefaultKeyCount > 7) preset.Value.DefaultKeyCount > 7)
@@ -1462,7 +1462,7 @@ namespace ProjectQuint.UI
break; break;
} }
var preset = BmsDifficultyTableIndex.FindPreset(table?.Id, table?.HeaderUrl); var preset = BmsDifficultyTablePresets.Find(table?.Id, table?.HeaderUrl);
return preset.HasValue && return preset.HasValue &&
preset.Value.DefaultKeyCount >= 4 && preset.Value.DefaultKeyCount >= 4 &&
preset.Value.DefaultKeyCount <= 7 preset.Value.DefaultKeyCount <= 7
@@ -1500,7 +1500,7 @@ namespace ProjectQuint.UI
}; };
return string.IsNullOrWhiteSpace(presetId) return string.IsNullOrWhiteSpace(presetId)
? null ? null
: BmsDifficultyTableIndex.FindPreset(presetId); : BmsDifficultyTablePresets.Find(presetId);
} }
private void ConfigureTableList() private void ConfigureTableList()
@@ -1594,8 +1594,8 @@ namespace ProjectQuint.UI
var capturedIndex = index; var capturedIndex = index;
var button = Require<VisualElement>(_tableRoot, $"table-preset-{index}"); var button = Require<VisualElement>(_tableRoot, $"table-preset-{index}");
_tablePresetButtons[index] = button; _tablePresetButtons[index] = button;
var preset = index < BmsDifficultyTableIndex.Presets.Length var preset = index < BmsDifficultyTablePresets.All.Length
? BmsDifficultyTableIndex.Presets[index] ? BmsDifficultyTablePresets.All[index]
: default; : default;
SetText( SetText(
Require<Label>(button, "preset-name"), Require<Label>(button, "preset-name"),
@@ -134,17 +134,6 @@ namespace ProjectQuint.Tests
Assert.That(result.Status, Does.Contain("unavailable")); Assert.That(result.Status, Does.Contain("unavailable"));
} }
[Test]
public void AeryParser_ProducesGenericCatalogEntries()
{
const string json = "[{\"level\":\"LEVEL 14\",\"title\":\"Table Title\",\"artist\":\"Table Artist\",\"md5\":\"ABC123\"}]";
var table = AeryTableIndex.Parse(json);
Assert.That(table.Count, Is.EqualTo(1));
Assert.That(table.TryGet("abc123", out var entry), Is.True);
Assert.That(entry.DifficultyLabel, Is.EqualTo("LEVEL 14"));
Assert.That(entry.DifficultySortIndex, Is.EqualTo(13));
}
[Test] [Test]
public void LibraryLocator_DiscoversArbitrarilyNamedSiblingPack() public void LibraryLocator_DiscoversArbitrarilyNamedSiblingPack()
@@ -1,5 +1,4 @@
using NUnit.Framework; using NUnit.Framework;
using ProjectQuint.Bootstrap;
using ProjectQuint.Core.Bms; using ProjectQuint.Core.Bms;
using ProjectQuint.Library; using ProjectQuint.Library;
@@ -31,19 +30,19 @@ namespace ProjectQuint.Tests
[Test] [Test]
public void Presets_ContainVerifiedCoreAndUeTables() public void Presets_ContainVerifiedCoreAndUeTables()
{ {
Assert.That(BmsDifficultyTableIndex.Presets, Has.Length.EqualTo(6)); Assert.That(BmsDifficultyTablePresets.All, Has.Length.EqualTo(6));
Assert.That(BmsDifficultyTableIndex.Presets[0].Id, Is.EqualTo("aery")); Assert.That(BmsDifficultyTablePresets.All[0].Id, Is.EqualTo("aery"));
Assert.That(BmsDifficultyTableIndex.Presets[1].Id, Is.EqualTo("satellite")); Assert.That(BmsDifficultyTablePresets.All[1].Id, Is.EqualTo("satellite"));
Assert.That(BmsDifficultyTableIndex.Presets[2].Id, Is.EqualTo("stella")); Assert.That(BmsDifficultyTablePresets.All[2].Id, Is.EqualTo("stella"));
Assert.That(BmsDifficultyTableIndex.Presets[3].Id, Is.EqualTo("genocide-insane")); Assert.That(BmsDifficultyTablePresets.All[3].Id, Is.EqualTo("genocide-insane"));
Assert.That(BmsDifficultyTableIndex.Presets[4].Id, Is.EqualTo("4ue")); Assert.That(BmsDifficultyTablePresets.All[4].Id, Is.EqualTo("4ue"));
Assert.That(BmsDifficultyTableIndex.Presets[5].Id, Is.EqualTo("6ue")); Assert.That(BmsDifficultyTablePresets.All[5].Id, Is.EqualTo("6ue"));
Assert.That(BmsDifficultyTableIndex.Presets[4].HeaderUrl, Assert.That(BmsDifficultyTablePresets.All[4].HeaderUrl,
Is.EqualTo("https://classmaterma.github.io/4UE/header.json")); Is.EqualTo("https://classmaterma.github.io/4UE/header.json"));
Assert.That(BmsDifficultyTableIndex.Presets[5].HeaderUrl, Assert.That(BmsDifficultyTablePresets.All[5].HeaderUrl,
Is.EqualTo("https://classmaterma.github.io/UE/table.html")); Is.EqualTo("https://classmaterma.github.io/UE/table.html"));
Assert.That(BmsDifficultyTableIndex.Presets[5].DefaultKeyCount, Is.EqualTo(6)); Assert.That(BmsDifficultyTablePresets.All[5].DefaultKeyCount, Is.EqualTo(6));
foreach (var preset in BmsDifficultyTableIndex.Presets) foreach (var preset in BmsDifficultyTablePresets.All)
Assert.That(preset.HeaderUrl, Does.StartWith("https://")); Assert.That(preset.HeaderUrl, Does.StartWith("https://"));
} }
@@ -58,12 +57,14 @@ namespace ProjectQuint.Tests
}; };
Assert.That( Assert.That(
ProjectQuintApp.ResolvePackScanKeyCount(pack, existingCustomTable), SongKeyModePolicy.Resolve(pack.ForcedKeyCount,
SongKeyModePolicy.TableDefault(existingCustomTable.Id, existingCustomTable.HeaderUrl), 0).KeyCount,
Is.EqualTo(6)); Is.EqualTo(6));
pack.ForcedKeyCount = 7; pack.ForcedKeyCount = 7;
Assert.That( Assert.That(
ProjectQuintApp.ResolvePackScanKeyCount(pack, existingCustomTable), SongKeyModePolicy.Resolve(pack.ForcedKeyCount,
SongKeyModePolicy.TableDefault(existingCustomTable.Id, existingCustomTable.HeaderUrl), 0).KeyCount,
Is.EqualTo(7)); Is.EqualTo(7));
} }
@@ -102,19 +103,19 @@ namespace ProjectQuint.Tests
} }
[Test] [Test]
public void FourUeTable_MatchedChartUsesAuthoritativeFourKeyLayoutWithoutMutatingRawMetadata() public void FourKeyPreset_MatchedChartUsesTableDefaultWithoutMutatingMetadata()
{ {
const string header = const string header =
"{\"name\":\"4UE Difficulty Table\",\"symbol\":\"4UE\",\"data_url\":\"data.json\"}"; "{\"name\":\"4UE Difficulty Table\",\"symbol\":\"4UE\",\"data_url\":\"data.json\"}";
const string data = const string data =
"[{\"md5\":\"862ea8f9a8f9463501b052c0c86cb4c1\",\"level\":\"20\"," + "[{\"md5\":\"synthetic-four-key-chart\",\"level\":\"20\"," +
"\"title\":\"GET ON WiTH FUNKOT [4K UE]\"}]"; "\"title\":\"Synthetic Four Key Chart\"}]";
var table = BmsDifficultyTableIndex.Parse("4ue", header, data); var table = BmsDifficultyTableIndex.Parse("4ue", header, data);
var song = new SongLibraryEntry var song = new SongLibraryEntry
{ {
Metadata = new BmsMetadata Metadata = new BmsMetadata
{ {
Md5 = "862ea8f9a8f9463501b052c0c86cb4c1", Md5 = "synthetic-four-key-chart",
KeyCount = 5, KeyCount = 5,
UsedKeyChannelMask = 31, UsedKeyChannelMask = 31,
}, },
@@ -124,7 +125,7 @@ namespace ProjectQuint.Tests
Assert.That(matched, Is.EqualTo(1)); Assert.That(matched, Is.EqualTo(1));
Assert.That(song.Metadata.KeyCount, Is.EqualTo(5)); Assert.That(song.Metadata.KeyCount, Is.EqualTo(5));
Assert.That(song.Catalog.KeyCountOverride, Is.EqualTo(4)); Assert.That(song.Catalog.DefaultKeyCount, Is.EqualTo(4));
Assert.That(song.EffectiveKeyCount, Is.EqualTo(4)); Assert.That(song.EffectiveKeyCount, Is.EqualTo(4));
} }
@@ -328,7 +328,7 @@ namespace ProjectQuint.Tests
var cacheRoot = Path.Combine(_temporaryDirectory, "difficulty-tables"); var cacheRoot = Path.Combine(_temporaryDirectory, "difficulty-tables");
var registry = new BmsDifficultyTableRegistry(registryPath, cacheRoot); var registry = new BmsDifficultyTableRegistry(registryPath, cacheRoot);
Assert.That(registry.Tables, Has.Count.EqualTo(BmsDifficultyTableIndex.Presets.Length)); Assert.That(registry.Tables, Has.Count.EqualTo(BmsDifficultyTablePresets.All.Length));
Assert.That(registry.ActiveTable, Is.Null); Assert.That(registry.ActiveTable, Is.Null);
var custom = registry.Register( var custom = registry.Register(
@@ -337,7 +337,7 @@ namespace ProjectQuint.Tests
registry.Select("satellite"); registry.Select("satellite");
var reloaded = new BmsDifficultyTableRegistry(registryPath, cacheRoot); var reloaded = new BmsDifficultyTableRegistry(registryPath, cacheRoot);
Assert.That(reloaded.Tables, Has.Count.EqualTo(BmsDifficultyTableIndex.Presets.Length + 1)); Assert.That(reloaded.Tables, Has.Count.EqualTo(BmsDifficultyTablePresets.All.Length + 1));
Assert.That(reloaded.ActiveTable.Id, Is.EqualTo("satellite")); Assert.That(reloaded.ActiveTable.Id, Is.EqualTo("satellite"));
Assert.That(reloaded.Find(custom.Id).DisplayName, Is.EqualTo("Custom Global Table")); Assert.That(reloaded.Find(custom.Id).DisplayName, Is.EqualTo("Custom Global Table"));
Assert.That(reloaded.HeaderCachePathFor(custom), Assert.That(reloaded.HeaderCachePathFor(custom),
@@ -352,7 +352,7 @@ namespace ProjectQuint.Tests
var packRegistryPath = Path.Combine(_temporaryDirectory, "packs.json"); var packRegistryPath = Path.Combine(_temporaryDirectory, "packs.json");
var packRegistry = new BmsPackRegistry(packRegistryPath); var packRegistry = new BmsPackRegistry(packRegistryPath);
var pack = packRegistry.RegisterFolder(CreatePackFolder("Legacy AERY")); var pack = packRegistry.RegisterFolder(CreatePackFolder("Legacy AERY"));
pack.DifficultyTableUrl = BmsDifficultyTableIndex.Presets[0].HeaderUrl; pack.DifficultyTableUrl = BmsDifficultyTablePresets.All[0].HeaderUrl;
var cacheRoot = Path.Combine(_temporaryDirectory, "difficulty-tables"); var cacheRoot = Path.Combine(_temporaryDirectory, "difficulty-tables");
var legacyCacheRoot = Path.Combine(cacheRoot, pack.Id); var legacyCacheRoot = Path.Combine(cacheRoot, pack.Id);
@@ -397,7 +397,7 @@ namespace ProjectQuint.Tests
new BmsPackDefinition new BmsPackDefinition
{ {
Id = pack.Id, Id = pack.Id,
DifficultyTableUrl = BmsDifficultyTableIndex.Presets[0].HeaderUrl, DifficultyTableUrl = BmsDifficultyTablePresets.All[0].HeaderUrl,
}, },
}, },
pack.Id); pack.Id);
@@ -0,0 +1,159 @@
using System;
using NUnit.Framework;
using ProjectQuint.Core.Bms;
using ProjectQuint.Library;
namespace ProjectQuint.Tests
{
public sealed class LibraryConfigurationTests
{
[TestCase(4)]
[TestCase(5)]
[TestCase(6)]
[TestCase(7)]
public void ExplicitPackMode_SurvivesCatalogMatchingAndRefresh(int keyCount)
{
var song = new SongLibraryEntry
{
ForcedKeyCount = keyCount,
Metadata = new BmsMetadata { Md5 = "synthetic-chart", KeyCount = 7 },
};
foreach (var preset in BmsDifficultyTablePresets.All)
{
ChartCatalogAnnotator.Apply(new[] { song }, new Catalog(preset.Id));
Assert.That(song.EffectiveKeyCount, Is.EqualTo(keyCount));
Assert.That(song.KeyMode.Source, Is.EqualTo(SongKeyModeSource.Pack));
Assert.That(song.KeyMode.HasConflict, Is.EqualTo(keyCount != preset.DefaultKeyCount));
Assert.That(song.Metadata.KeyCount, Is.EqualTo(7));
}
ChartCatalogAnnotator.Apply(new[] { song }, null);
Assert.That(song.EffectiveKeyCount, Is.EqualTo(keyCount));
Assert.That(song.KeyMode.HasConflict, Is.False);
}
[Test]
public void CustomRegistrationOfPresetUrl_UsesTheSameDefaultInScanAndCatalogStages()
{
foreach (var preset in BmsDifficultyTablePresets.All)
{
var tableDefault = SongKeyModePolicy.TableDefault("custom-id", preset.HeaderUrl);
var song = new SongLibraryEntry
{
TableDefaultKeyCount = tableDefault,
Metadata = new BmsMetadata { Md5 = "synthetic-chart", KeyCount = 7 },
};
ChartCatalogAnnotator.Apply(new[] { song }, new Catalog("custom-id"));
Assert.That(tableDefault, Is.EqualTo(preset.DefaultKeyCount));
Assert.That(song.EffectiveKeyCount, Is.EqualTo(preset.DefaultKeyCount));
Assert.That(song.KeyMode.Source, Is.EqualTo(SongKeyModeSource.DifficultyTable));
}
}
[Test]
public void UnknownTable_DoesNotOverrideAutomaticChartDetection()
{
var tableDefault = SongKeyModePolicy.TableDefault("custom", "https://tables.example/header.json");
Assert.That(tableDefault, Is.Zero);
Assert.That(SongKeyModePolicy.Resolve(0, tableDefault, 0).KeyCount, Is.Zero);
Assert.That(SongKeyModePolicy.Resolve(0, tableDefault, 6).KeyCount, Is.EqualTo(6));
}
[Test]
public void AutomaticMode_UsesTableDefaultAndThenDetectedChartMode()
{
var song = new SongLibraryEntry
{
Metadata = new BmsMetadata { Md5 = "synthetic-chart", KeyCount = 7 },
};
foreach (var preset in BmsDifficultyTablePresets.All)
{
ChartCatalogAnnotator.Apply(new[] { song }, new Catalog(preset.Id));
Assert.That(song.EffectiveKeyCount, Is.EqualTo(preset.DefaultKeyCount));
Assert.That(song.KeyMode.Source, Is.EqualTo(SongKeyModeSource.DifficultyTable));
}
ChartCatalogAnnotator.Apply(new[] { song }, null);
Assert.That(song.EffectiveKeyCount, Is.EqualTo(7));
Assert.That(song.KeyMode.Source, Is.EqualTo(SongKeyModeSource.Chart));
}
[Test]
public void UnmatchedChart_StillUsesExplicitPackMode()
{
var song = new SongLibraryEntry
{
ForcedKeyCount = 4,
TableDefaultKeyCount = 5,
Metadata = new BmsMetadata { Md5 = "unmatched", KeyCount = 7 },
};
Assert.That(ChartCatalogAnnotator.Apply(new[] { song }, new Catalog("example")), Is.Zero);
Assert.That(song.EffectiveKeyCount, Is.EqualTo(4));
Assert.That(song.KeyMode.HasConflict, Is.True);
}
[TestCase("https://mirror.example/aery/header.json?revision=2")]
[TestCase("https://other.example/path/aery/index.html")]
[TestCase("https://other.example/path/AERY/custom.json")]
[TestCase("https://tables.example/header.json")]
public void TableRequest_PreservesConfiguredHostPathQueryAndIdentity(string url)
{
var request = new DifficultyTableRequest("custom-id", url, "header-cache", "data-cache");
Assert.That(request.HeaderUri.AbsoluteUri, Is.EqualTo(url));
Assert.That(request.ProviderId, Is.EqualTo("custom-id"));
Assert.That(request.HeaderCachePath, Is.EqualTo("header-cache"));
Assert.That(request.DataCachePath, Is.EqualTo("data-cache"));
}
[Test]
public void PresetRequests_UseTheirRegisteredHeaderUrls()
{
foreach (var preset in BmsDifficultyTablePresets.All)
{
var request = new DifficultyTableRequest(preset.Id, preset.HeaderUrl, "h", "d");
Assert.That(request.HeaderUri.AbsoluteUri, Is.EqualTo(preset.HeaderUrl));
Assert.That(request.ProviderId, Is.EqualTo(preset.Id));
}
}
[Test]
public void HeaderAndDataLinks_AreResolvedRelativeToTheirOwnDocuments()
{
var page = new Uri("https://mirror.example/custom/page/index.html");
var header = DifficultyTableRequest.ResolveHeaderPage(page,
"<html><meta content='../metadata/header.json?one=1&amp;two=2' name='bmstable'></html>");
Assert.That(header.AbsoluteUri, Is.EqualTo("https://mirror.example/custom/metadata/header.json?one=1&two=2"));
Assert.That(DifficultyTableRequest.ResolveLink(header, "../charts/data.json").AbsoluteUri,
Is.EqualTo("https://mirror.example/custom/charts/data.json"));
}
[TestCase("http://tables.example/header.json")]
[TestCase("file:///header.json")]
[TestCase("header.json")]
public void InvalidConfiguredUrl_IsRejected(string url)
{
Assert.Throws<ArgumentException>(() => new DifficultyTableRequest("custom", url, "h", "d"));
}
[Test]
public void InsecureLinkedDocument_AndMissingMetadataAreRejected()
{
var origin = new Uri("https://tables.example/header.json");
Assert.Throws<ArgumentException>(() => DifficultyTableRequest.ResolveLink(origin, "http://tables.example/data.json"));
Assert.Throws<FormatException>(() => DifficultyTableRequest.ResolveHeaderPage(origin, "<html></html>"));
Assert.Throws<FormatException>(() => DifficultyTableRequest.ResolveLink(origin, ""));
}
private sealed class Catalog : IChartCatalog
{
public Catalog(string providerId) { ProviderId = providerId; }
public string ProviderId { get; }
public string DisplayName => "Synthetic table";
public ExternalDataOrigin Origin => ExternalDataOrigin.Network;
public int Count => 1;
public bool TryGet(string hash, out ChartCatalogEntry entry)
{
entry = hash == "synthetic-chart" ? new ChartCatalogEntry { DifficultyValue = "10" } : null;
return entry != null;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 83e72e9a135640a7a49125d360f07d0f
@@ -222,7 +222,7 @@ namespace ProjectQuint.Tests
var recorded = new RatingSummary( var recorded = new RatingSummary(
"aery", "AERY", 5, false, 12.5, 29, "aery", "AERY", 5, false, 12.5, 29,
activityPlayCount: 34); activityPlayCount: 34);
var tables = BmsDifficultyTableIndex.Presets var tables = BmsDifficultyTablePresets.All
.Select(preset => new BmsDifficultyTableDefinition .Select(preset => new BmsDifficultyTableDefinition
{ {
Id = preset.Id, Id = preset.Id,
@@ -248,7 +248,7 @@ namespace ProjectQuint.Tests
Does.Contain("GENOCIDE INSANE BMS (LEGACY MIRROR) 1+7K RATING")); Does.Contain("GENOCIDE INSANE BMS (LEGACY MIRROR) 1+7K RATING"));
Assert.That(contexts.Select(context => context.ContextLabel), Assert.That(contexts.Select(context => context.ContextLabel),
Does.Contain("6UE DIFFICULTY TABLE 6K RATING")); Does.Contain("6UE DIFFICULTY TABLE 6K RATING"));
Assert.That(contexts.Count, Is.EqualTo(BmsDifficultyTableIndex.Presets.Length)); Assert.That(contexts.Count, Is.EqualTo(BmsDifficultyTablePresets.All.Length));
} }
[Test] [Test]
@@ -0,0 +1,147 @@
using System.IO;
using System.Linq;
using NUnit.Framework;
using ProjectQuint.Core.Bms;
using ProjectQuint.Core.Charts;
using ProjectQuint.Features.Loading;
using ProjectQuint.Library;
namespace ProjectQuint.Tests
{
public sealed class SongChartPreparationTests
{
[TestCase(4, 5)]
[TestCase(5, 4)]
[TestCase(6, 7)]
[TestCase(7, 6)]
public void ExplicitPackMode_ReachesPlayableChartDespiteConflictingTable(int packKeys, int tableKeys)
{
var path = Path.Combine(_temporaryDirectory, "key-mode-conflict.bms");
File.WriteAllText(path,
"#TITLE Synthetic Layout\n#BPM 120\n#WAV01 note.wav\n" +
"#00111:01\n#00112:01\n#00113:01\n#00114:01\n#00115:01\n#00118:01\n#00119:01\n");
var entry = new SongLibraryEntry
{
ChartPath = path,
ForcedKeyCount = packKeys,
Metadata = new BmsMetadata { KeyCount = 7 },
Catalog = new ChartCatalogAnnotation { DefaultKeyCount = tableKeys },
};
var prepared = SongChartPreparation.Prepare(entry, LaneModifier.Off, 0);
Assert.That(prepared.Chart.Metadata.KeyCount, Is.EqualTo(packKeys));
Assert.That(prepared.Chart.Metadata.TotalLaneCount, Is.EqualTo(packKeys));
Assert.That(prepared.LaneMap.Length, Is.EqualTo(packKeys));
Assert.That(prepared.Chart.Notes.All(note => note.Lane >= 0 && note.Lane < packKeys), Is.True);
Assert.That(entry.KeyMode.HasConflict, Is.True);
}
private string _temporaryDirectory;
[SetUp]
public void SetUp()
{
_temporaryDirectory = Path.Combine(
Path.GetTempPath(),
"ProjectQuint-ChartPreparationTests",
Path.GetRandomFileName());
Directory.CreateDirectory(_temporaryDirectory);
}
[TearDown]
public void TearDown()
{
if (Directory.Exists(_temporaryDirectory))
Directory.Delete(_temporaryDirectory, true);
}
[Test]
public void ParseAndTransform_AppliesTableMetadataAndLaneModifier()
{
var chartPath = Path.Combine(_temporaryDirectory, "sample.bms");
File.WriteAllText(
chartPath,
"#TITLE Raw Title\n" +
"#ARTIST Raw Artist\n" +
"#BPM 120\n" +
"#PLAYLEVEL 3\n" +
"#WAV01 note.wav\n" +
"#00111:01\n");
var entry = new SongLibraryEntry
{
ChartPath = chartPath,
Metadata = new BmsMetadata
{
Md5 = "table-md5",
},
Catalog = new ChartCatalogAnnotation
{
ProviderId = "example",
ProviderDisplayName = "Example Table",
DifficultyLabel = "LEVEL 14",
DifficultySortIndex = 13,
TitleOverride = "Table Title",
ArtistOverride = "Table Artist",
},
};
var prepared = SongChartPreparation.Prepare(
entry,
LaneModifier.Mirror,
randomSeed: 1234);
Assert.That(prepared.Chart.Metadata.Title, Is.EqualTo("Table Title"));
Assert.That(prepared.Chart.Metadata.Artist, Is.EqualTo("Table Artist"));
Assert.That(prepared.Chart.Metadata.DifficultyLabel, Is.EqualTo("LEVEL 14"));
Assert.That(prepared.Chart.Metadata.SourceHash, Is.EqualTo("table-md5"));
Assert.That(prepared.Chart.Notes, Has.Count.EqualTo(1));
Assert.That(prepared.Chart.Notes[0].Lane, Is.EqualTo(4));
Assert.That(prepared.NotesById.ContainsKey(prepared.Chart.Notes[0].Id), Is.True);
Assert.That(prepared.LaneMap, Is.EqualTo(new[] { 4, 3, 2, 1, 0 }));
}
[Test]
public void ParseAndTransform_FourKeyTableIgnoresStrayCenterChannel()
{
var chartPath = Path.Combine(_temporaryDirectory, "four-key-layout.bms");
File.WriteAllText(
chartPath,
"#TITLE Synthetic Four Key Chart\n" +
"#BPM 190\n" +
"#WAV01 note.wav\n" +
"#00111:01\n" +
"#00112:01\n" +
"#00113:01\n" +
"#00114:01\n" +
"#00115:01\n");
var entry = new SongLibraryEntry
{
ChartPath = chartPath,
Metadata = new BmsMetadata
{
Md5 = "synthetic-chart-md5",
KeyCount = 5,
UsedKeyChannelMask = 31,
},
Catalog = new ChartCatalogAnnotation
{
ProviderId = "four-key-table",
DefaultKeyCount = 4,
DifficultyLabel = "LEVEL 20",
},
};
var prepared = SongChartPreparation.Prepare(
entry,
LaneModifier.Off,
randomSeed: 1);
Assert.That(prepared.Chart.Metadata.KeyCount, Is.EqualTo(4));
Assert.That(prepared.Chart.Metadata.TotalLaneCount, Is.EqualTo(4));
Assert.That(prepared.LaneMap, Is.EqualTo(new[] { 0, 1, 2, 3 }));
Assert.That(prepared.Chart.Notes, Has.Count.EqualTo(4));
Assert.That(prepared.Chart.Notes.Select(note => note.Lane),
Is.EquivalentTo(new[] { 0, 1, 2, 3 }));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 528e4bfcd36e46e983efe99d6b78b114
@@ -32,6 +32,38 @@ namespace ProjectQuint.Tests
Directory.Delete(_directory, true); Directory.Delete(_directory, true);
} }
[Test]
public void ColdAndCachedScan_PreserveExplicitModeWhenTableDefaultConflicts()
{
for (var pass = 0; pass < 2; pass++)
{
var scanner = new SongLibraryScanner(_cachePath, forcedKeyCount: 4, tableDefaultKeyCount: 5);
var songs = scanner.Scan(_directory, CancellationToken.None);
Assert.That(songs, Has.Count.EqualTo(1));
var song = songs[0];
song.Catalog = new ChartCatalogAnnotation { DefaultKeyCount = 5 };
Assert.That(song.ForcedKeyCount, Is.EqualTo(4));
Assert.That(song.TableDefaultKeyCount, Is.EqualTo(5));
Assert.That(song.EffectiveKeyCount, Is.EqualTo(4));
Assert.That(song.KeyMode.Source, Is.EqualTo(SongKeyModeSource.Pack));
Assert.That(song.KeyMode.HasConflict, Is.True);
Assert.That(scanner.CacheHits, Is.EqualTo(pass));
}
}
[Test]
public void SwitchingFromExplicitToAutomaticMode_DoesNotReuseDifferentLayoutCache()
{
new SongLibraryScanner(_cachePath, forcedKeyCount: 4, tableDefaultKeyCount: 5)
.Scan(_directory, CancellationToken.None);
var scanner = new SongLibraryScanner(_cachePath, forcedKeyCount: 0, tableDefaultKeyCount: 5);
var song = scanner.Scan(_directory, CancellationToken.None)[0];
Assert.That(song.ForcedKeyCount, Is.Zero);
Assert.That(song.EffectiveKeyCount, Is.EqualTo(5));
Assert.That(song.KeyMode.Source, Is.EqualTo(SongKeyModeSource.DifficultyTable));
Assert.That(scanner.CacheHits, Is.Zero);
}
[Test] [Test]
public void SecondScan_ReusesMetadataAndMd5FromPersistentCache() public void SecondScan_ReusesMetadataAndMd5FromPersistentCache()
{ {
@@ -34,94 +34,6 @@ namespace ProjectQuint.Tests
Directory.Delete(_temporaryDirectory, true); Directory.Delete(_temporaryDirectory, true);
} }
[Test]
public void ParseAndTransform_AppliesTableMetadataAndLaneModifier()
{
var chartPath = Path.Combine(_temporaryDirectory, "sample.bms");
File.WriteAllText(
chartPath,
"#TITLE Raw Title\n" +
"#ARTIST Raw Artist\n" +
"#BPM 120\n" +
"#PLAYLEVEL 3\n" +
"#WAV01 note.wav\n" +
"#00111:01\n");
var entry = new SongLibraryEntry
{
ChartPath = chartPath,
Metadata = new BmsMetadata
{
Md5 = "table-md5",
},
Catalog = new ChartCatalogAnnotation
{
ProviderId = "aery",
ProviderDisplayName = "AERY",
DifficultyLabel = "LEVEL 14",
DifficultySortIndex = 13,
TitleOverride = "Table Title",
ArtistOverride = "Table Artist",
},
};
var prepared = SongLoadCoordinator.ParseAndTransform(
entry,
LaneModifier.Mirror,
randomSeed: 1234);
Assert.That(prepared.Chart.Metadata.Title, Is.EqualTo("Table Title"));
Assert.That(prepared.Chart.Metadata.Artist, Is.EqualTo("Table Artist"));
Assert.That(prepared.Chart.Metadata.DifficultyLabel, Is.EqualTo("LEVEL 14"));
Assert.That(prepared.Chart.Metadata.SourceHash, Is.EqualTo("table-md5"));
Assert.That(prepared.Chart.Notes, Has.Count.EqualTo(1));
Assert.That(prepared.Chart.Notes[0].Lane, Is.EqualTo(4));
Assert.That(prepared.NotesById.ContainsKey(prepared.Chart.Notes[0].Id), Is.True);
Assert.That(prepared.LaneMap, Is.EqualTo(new[] { 4, 3, 2, 1, 0 }));
}
[Test]
public void ParseAndTransform_FourKeyTableIgnoresStrayCenterChannel()
{
var chartPath = Path.Combine(_temporaryDirectory, "get-on-with-funkot-regression.bms");
File.WriteAllText(
chartPath,
"#TITLE GET ON WiTH FUNKOT [4K UE]\n" +
"#BPM 190\n" +
"#WAV01 note.wav\n" +
"#00111:01\n" +
"#00112:01\n" +
"#00113:01\n" +
"#00114:01\n" +
"#00115:01\n");
var entry = new SongLibraryEntry
{
ChartPath = chartPath,
Metadata = new BmsMetadata
{
Md5 = "862ea8f9a8f9463501b052c0c86cb4c1",
KeyCount = 5,
UsedKeyChannelMask = 31,
},
Catalog = new ChartCatalogAnnotation
{
ProviderId = "4ue",
KeyCountOverride = 4,
DifficultyLabel = "4UE20",
},
};
var prepared = SongLoadCoordinator.ParseAndTransform(
entry,
LaneModifier.Off,
randomSeed: 1);
Assert.That(prepared.Chart.Metadata.KeyCount, Is.EqualTo(4));
Assert.That(prepared.Chart.Metadata.TotalLaneCount, Is.EqualTo(4));
Assert.That(prepared.LaneMap, Is.EqualTo(new[] { 0, 1, 2, 3 }));
Assert.That(prepared.Chart.Notes, Has.Count.EqualTo(4));
Assert.That(prepared.Chart.Notes.Select(note => note.Lane),
Is.EquivalentTo(new[] { 0, 1, 2, 3 }));
}
[UnityTest] [UnityTest]
public IEnumerator LoadCancellation_AfterCandidateCreationRollsBackResourceRoot() public IEnumerator LoadCancellation_AfterCandidateCreationRollsBackResourceRoot()
+1 -1
View File
@@ -8,7 +8,7 @@
## 독립 테스트 실행 ## 독립 테스트 실행
[.NET 10 SDK](https://learn.microsoft.com/en-us/dotnet/core/install/)가 있으면 유니티를 설치하지 않고 [.NET 10 SDK](https://learn.microsoft.com/en-us/dotnet/core/install/)가 있으면 유니티를 설치하지 않고
판정·타이밍·파서와 주요 화면 상태 테스트를 실행할 수 있습니다. 판정·타이밍·파서, 라이브러리 설정과 주요 화면 상태 테스트를 실행할 수 있습니다.
macOS와 Linux에서는 저장소 루트에서 다음 명령을 실행합니다. macOS와 Linux에서는 저장소 루트에서 다음 명령을 실행합니다.
```bash ```bash
@@ -24,5 +24,15 @@
<Compile Include="../../Assets/ProjectQuint/Tests/EditMode/ProfileSessionControllerTests.cs" /> <Compile Include="../../Assets/ProjectQuint/Tests/EditMode/ProfileSessionControllerTests.cs" />
<Compile Include="CoreReplayTests.cs" /> <Compile Include="CoreReplayTests.cs" />
<Compile Include="CoreBenchmarkTests.cs" /> <Compile Include="CoreBenchmarkTests.cs" />
<Compile Include="../../Assets/ProjectQuint/Runtime/Unity/Library/BmsDifficultyTablePresets.cs" />
<Compile Include="../../Assets/ProjectQuint/Runtime/Unity/Library/ChartCatalog.cs" />
<Compile Include="../../Assets/ProjectQuint/Runtime/Unity/Library/DifficultyTableRequest.cs" />
<Compile Include="../../Assets/ProjectQuint/Runtime/Unity/Library/SongKeyModePolicy.cs" />
<Compile Include="../../Assets/ProjectQuint/Runtime/Unity/Library/SongLibraryEntry.cs" />
<Compile Include="../../Assets/ProjectQuint/Runtime/Unity/Library/SongPlayRecord.cs" />
<Compile Include="../../Assets/ProjectQuint/Runtime/Unity/Library/SongSearchDocument.cs" />
<Compile Include="../../Assets/ProjectQuint/Tests/EditMode/LibraryConfigurationTests.cs" />
<Compile Include="../../Assets/ProjectQuint/Runtime/Unity/Features/Loading/SongChartPreparation.cs" />
<Compile Include="../../Assets/ProjectQuint/Tests/EditMode/SongChartPreparationTests.cs" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+24 -3
View File
@@ -20,6 +20,25 @@ dotnet test ProjectQuint.Tests/ProjectQuint.Tests.csproj -c Release -p:RestoreLo
유니티 편집 모드와 .NET은 `Assets/ProjectQuint/Tests/Core`의 같은 테스트 파일을 사용합니다. 유니티 편집 모드와 .NET은 `Assets/ProjectQuint/Tests/Core`의 같은 테스트 파일을 사용합니다.
홈 화면 이동·프로필 선택·업데이트 상태 테스트 역시 유니티와 독립 실행에서 공유합니다. 홈 화면 이동·프로필 선택·업데이트 상태 테스트 역시 유니티와 독립 실행에서 공유합니다.
## 라이브러리 설정과 채보 준비
키 모드는 명시적인 팩 설정, 난이도표 기본값, 채보 감지값 순서로 결정합니다.
`SongKeyModePolicy`가 이 우선순위를 소유하며 스캔, 곡 목록과 플레이용 채보 준비가
같은 규칙을 사용합니다. `SongLibraryEntry.KeyMode`에서 선택된 키 수와 결정 근거,
팩 설정과 난이도표 기본값의 충돌 여부를 확인할 수 있습니다.
충돌이 있으면 팩 설정을 유지하며, 인덱스·새로고침·플레이 진단 로그에 표시합니다.
`DifficultyTableRequest`는 등록한 제공자 ID와 HTTPS 주소를 보존합니다.
HTML 페이지의 헤더 링크는 그 페이지를 기준으로, 데이터 링크는 헤더 문서를 기준으로
해석합니다. `DifficultyTableLibrarySource`는 로컬 난이도표·코스 로딩과 온라인 갱신을 담당합니다.
`LibraryConfigurationTests`는 주소·상대 경로 해석, 키 모드 충돌과 반복적인 난이도표 적용을
검증합니다. `SongChartPreparationTests`는 합성 BMS 파일을 실제 게임의 파서·변환기로 읽어
최종 플레이용 채보의 키 수와 레인 배치까지 확인합니다. 음악 파일은 필요하지 않습니다.
두 테스트 묶음은 독립 테스트에서 실행하지만 실제 HTTP 요청이나 유니티 캐시 직렬화를
실행하지는 않습니다. 최초 스캔·캐시 재사용·키 모드 변경 시 캐시 무효화는
유니티 `SongLibraryScannerTests`의 검증 범위입니다.
## 합성 입력 탐색 ## 합성 입력 탐색
```bash ```bash
@@ -91,10 +110,12 @@ JIT 상태와 다른 프로세스의 부하에 따라 시간은 달라질 수
음악을 재배포하지 않는 범위에서 실제 플레이로 로딩 취소·다시 시작·장치 변경·프로필 화면을 확인해야 합니다. 음악을 재배포하지 않는 범위에서 실제 플레이로 로딩 취소·다시 시작·장치 변경·프로필 화면을 확인해야 합니다.
자동화 서버에서의 연속 실행과 실제 플레이어 성능 측정은 별도 운영 범위입니다. 자동화 서버에서의 연속 실행과 실제 플레이어 성능 측정은 별도 운영 범위입니다.
2026-09-06 검증에서는 독립 테스트 142개와 배포 도구 테스트 9개가 통과했습니다. 2026-09-06 검증에서는 독립 테스트 166개와 배포 도구 테스트 9개가 통과했습니다.
유니티 컴파일러를 이용한 어셈블리 11개의 컴파일도 확인했습니다. 유니티 컴파일러를 이용한 어셈블리 11개의 컴파일도 확인했습니다.
유니티 Test Runner는 유효한 에디터 라이선스가 없어 테스트 시작 전에 종료되었으므로, 유니티 Test Runner의 편집 모드 테스트는 891개 중 887개가 통과했고 실패는 없었습니다.
편집 모드 전체 테스트와 실제 플레이 검증은 완료된 것으로 기록하지 않습니다. 외부 BMS 자료가 필요한 통합 테스트 4개는 자료가 없어 건너뛰었습니다.
최초 스캔·캐시 재사용 시 명시적인 키 모드 보존과 키 모드 변경 시 캐시 무효화 테스트도
통과했습니다. 실제 화면·입력·오디오를 함께 확인하는 플레이 검증은 별도로 수행해야 합니다.
앱의 업데이트와 프로필 상태는 독립 컨트롤러가 소유하지만, 앱의 업데이트와 프로필 상태는 독립 컨트롤러가 소유하지만,
팩 관리·오디오 설정·화면 연결을 조정하는 코드는 아직 큰 앱 클래스에 남아 있습니다. 팩 관리·오디오 설정·화면 연결을 조정하는 코드는 아직 큰 앱 클래스에 남아 있습니다.