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

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)
{
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);
if (unique.ContainsKey(key))
continue;
@@ -305,10 +305,10 @@ namespace ProjectQuint.Bootstrap
continue;
}
SongLoadCoordinator.PreparedChart prepared;
PreparedChart prepared;
try
{
prepared = SongLoadCoordinator.ParseAndTransform(item.Entry, LaneModifier.Off, 0);
prepared = SongChartPreparation.Prepare(item.Entry, LaneModifier.Off, 0);
}
catch (Exception exception)
{
@@ -154,10 +154,10 @@ namespace ProjectQuint.Bootstrap
private SongLibraryEntry _loadingEntry;
private SongLibraryEntry _playingEntry;
private AeryCourseIndex _courseIndex;
private AeryTableIndex _aeryCatalog;
private IChartCatalog _activeCatalog;
private SearchAliasIndex _searchAliases = SearchAliasIndex.Empty;
private int _aeryMatchedCharts;
private int _catalogMatchedCharts;
private int _libraryScanGeneration;
private Texture2D _libraryThumbnail;
@@ -1554,57 +1554,14 @@ namespace ProjectQuint.Bootstrap
yield break;
}
var isAeryTable = IsAeryTable(_activeDifficultyTable?.HeaderUrl);
var cachePath = _activeDifficultyTable == null
? string.Empty
: _difficultyTableRegistry.DataCachePathFor(_activeDifficultyTable);
var courseCachePath = _activeDifficultyTable == null
? string.Empty
: _difficultyTableRegistry.HeaderCachePathFor(_activeDifficultyTable);
var bundledTable = Resources.Load<TextAsset>("ProjectQuint/Data/AeryData");
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 tableSource = new DifficultyTableLibrarySource(_activeDifficultyTable, _difficultyTableRegistry);
var localData = tableSource.LoadLocal();
_activeCatalog = localData.Catalog.Data;
_courseIndex = localData.Courses.Data;
var catalogOrigin = localData.Catalog.Origin;
var catalogStatus = localData.Catalog.Status;
var courseLocalStatus = localData.Courses.Status;
_courseStatus = courseLocalStatus;
var bundledSearchAliases = Resources.Load<TextAsset>("ProjectQuint/Data/SearchAliases");
_searchAliases = SearchAliasIndex.Load(
bundledSearchAliases != null ? bundledSearchAliases.text : string.Empty,
@@ -1619,7 +1576,8 @@ namespace ProjectQuint.Bootstrap
libraryIndexCachePath,
legacyLibraryIndexCachePath,
_activePack.Id,
ResolvePackScanKeyCount(_activePack, _activeDifficultyTable));
_activePack.ForcedKeyCount,
SongKeyModePolicy.TableDefault(_activeDifficultyTable?.Id, _activeDifficultyTable?.HeaderUrl));
_scanCancellation?.Cancel();
_scanCancellation?.Dispose();
_scanCancellation = new CancellationTokenSource();
@@ -1651,7 +1609,7 @@ namespace ProjectQuint.Bootstrap
var scannedSongs = scanTask.Result;
_catalogMatchedCharts = ChartCatalogAnnotator.Apply(scannedSongs, _activeCatalog);
_aeryMatchedCharts = isAeryTable ? _catalogMatchedCharts : 0;
SongSearchIndexer.Rebuild(scannedSongs, _searchAliases);
_favoriteRepository.Apply(scannedSongs);
foreach (var song in scannedSongs)
@@ -1691,94 +1649,25 @@ namespace ProjectQuint.Bootstrap
RefreshLibraryThumbnail();
if (_openLibraryWhenScanCompletes) _mode = AppMode.Library;
_openLibraryWhenScanCompletes = false;
if (isAeryTable)
{
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));
}
if (tableSource.IsConfigured)
StartCoroutine(RefreshDifficultyTable(scanGeneration, _activeDifficultyTable, _activePack, tableSource));
Debug.Log($"[ProjectQuint] Indexed {scannedSongs.Count} charts from {_libraryRoot}. Failed: {_scanner.Failed}. " +
$"Index cache: {_scanner.CacheStatus}. " +
$"Difficulty table: {catalogStatus}; {_catalogMatchedCharts} matched from {catalogOrigin}. " +
$"Key mode conflicts: {scannedSongs.Count(song => song.KeyMode.HasConflict)} (pack setting preserved). " +
$"Default view: {SongLibraryView.AllLevelsFilter} " +
$"({_libraryController.State.VisibleSongs.Count} charts).");
}
private IEnumerator RefreshAeryIntegration(
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(
private IEnumerator RefreshDifficultyTable(
int scanGeneration,
BmsDifficultyTableDefinition table,
BmsPackDefinition sourcePack)
BmsPackDefinition sourcePack,
DifficultyTableLibrarySource source)
{
ExternalDataResult<BmsDifficultyTableIndex> result = null;
yield return BmsDifficultyTableIndex.LoadOnline(
table.Id,
table.HeaderUrl,
_difficultyTableRegistry.HeaderCachePathFor(table),
_difficultyTableRegistry.DataCachePathFor(table),
loaded => result = loaded);
DifficultyTableLibraryData refreshed = null;
yield return source.Refresh(loaded => refreshed = loaded);
var result = refreshed?.Catalog;
if (scanGeneration != _libraryScanGeneration || !_libraryReady ||
_activePack == null || !string.Equals(_activePack.Id, sourcePack.Id, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(
@@ -1801,10 +1690,7 @@ namespace ProjectQuint.Bootstrap
RefreshLibraryThumbnail();
}
var courseResult = AeryCourseIndex.LoadTableLocal(
table.Id,
result?.Data?.DisplayName ?? table.DisplayName,
_difficultyTableRegistry.HeaderCachePathFor(table));
var courseResult = refreshed.Courses;
if (courseResult.Data != null)
{
_courseIndex = courseResult.Data;
@@ -1819,13 +1705,10 @@ namespace ProjectQuint.Bootstrap
$"({result?.Origin ?? ExternalDataOrigin.Unavailable}) // " +
$"{(_scanner?.CacheHits ?? 0):N0} cached // {(_scanner?.CacheMisses ?? 0):N0} refreshed";
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}.");
}
private static bool IsAeryTable(string url) =>
!string.IsNullOrWhiteSpace(url) &&
url.IndexOf("/aery/", StringComparison.OrdinalIgnoreCase) >= 0;
private void UpdateHomeInput()
{
var keyboard = Keyboard.current;
@@ -2448,20 +2331,6 @@ namespace ProjectQuint.Bootstrap
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()
{
var packs = _packRegistry?.Packs;
@@ -2595,10 +2464,10 @@ namespace ProjectQuint.Bootstrap
_libraryRoot = string.Empty;
_libraryLoadError = string.Empty;
_activeCatalog = null;
_aeryCatalog = null;
_courseIndex = null;
_searchAliases = SearchAliasIndex.Empty;
_aeryMatchedCharts = 0;
_catalogMatchedCharts = 0;
ClearLoadedSongState();
_courseSession.ClearRun();
@@ -3152,10 +3021,10 @@ namespace ProjectQuint.Bootstrap
keyboard.digit4Key.wasPressedThisFrame ? 3 :
keyboard.digit5Key.wasPressedThisFrame ? 4 :
keyboard.digit6Key.wasPressedThisFrame ? 5 : -1;
if (presetIndex >= 0 && presetIndex < BmsDifficultyTableIndex.Presets.Length)
if (presetIndex >= 0 && presetIndex < BmsDifficultyTablePresets.All.Length)
{
_tableUrlDraft = BmsDifficultyTableIndex.Presets[presetIndex].HeaderUrl;
_tableSetupStatus = $"SELECTED // {BmsDifficultyTableIndex.Presets[presetIndex].DisplayName} // ENTER TO APPLY";
_tableUrlDraft = BmsDifficultyTablePresets.All[presetIndex].HeaderUrl;
_tableSetupStatus = $"SELECTED // {BmsDifficultyTablePresets.All[presetIndex].DisplayName} // ENTER TO APPLY";
return;
}
if (keyboard.cKey.wasPressedThisFrame)
@@ -3198,10 +3067,10 @@ namespace ProjectQuint.Bootstrap
_tableUrlDraft = selected?.HeaderUrl ?? string.Empty;
_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;
_tableSetupStatus = $"SELECTED // {BmsDifficultyTableIndex.Presets[result.PresetIndex].DisplayName} // APPLY TO SAVE";
_tableUrlDraft = BmsDifficultyTablePresets.All[result.PresetIndex].HeaderUrl;
_tableSetupStatus = $"SELECTED // {BmsDifficultyTablePresets.All[result.PresetIndex].DisplayName} // APPLY TO SAVE";
}
if (result.Clear)
{
@@ -4639,11 +4508,13 @@ namespace ProjectQuint.Bootstrap
}
else
{
var table = ResolvePackDifficultyTable(pack);
var scanner = new SongLibraryScanner(
_packRegistry.CachePathFor(pack),
legacyCachePath: null,
pack.Id,
pack.ForcedKeyCount);
pack.ForcedKeyCount,
SongKeyModePolicy.TableDefault(table?.Id, table?.HeaderUrl));
var scanTask = Task.Run(() => scanner.Scan(pack.ContentRoot, token), token);
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"));
var parseTask = Task.Run(() => ParseAndTransform(
var parseTask = Task.Run(() => SongChartPreparation.Prepare(
request.Entry,
request.LaneModifier,
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) =>
loadGeneration != currentGeneration || !isLoadingMode;
@@ -333,6 +305,7 @@ namespace ProjectQuint.Features.Loading
return $"[ProjectQuint] Playing {chart.Metadata.Title}: {chart.Notes.Count} notes, " +
$"{chart.BackgroundAudioEvents.Count} BGM events, " +
$"{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, " +
$"{song.Audio.LoadedClipCount} clips loaded, {song.Audio.MissingClipCount} missing, " +
$"{song.Audio.DecodeFailedClipCount} decode failed, " +
@@ -386,22 +359,5 @@ namespace ProjectQuint.Features.Loading
completed => { _ = completed.Exception; },
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)
continue;
var preset = BmsDifficultyTableIndex.FindPreset(table.Id, table.HeaderUrl);
var preset = BmsDifficultyTablePresets.Find(table.Id, table.HeaderUrl);
if (preset?.DefaultKeyCount >= 4)
AddResolved(table.Id, table.DisplayName, preset.Value.DefaultKeyCount,
preset.Value.DefaultHasScratch);
@@ -1,5 +1,4 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using ProjectQuint.Core.Gauge;
@@ -9,7 +8,6 @@ namespace ProjectQuint.Library
{
public sealed class AeryCourseIndex
{
public const string HeaderUrl = "https://asumatoki.kr/table/aery/header.json";
private readonly List<AeryCourseDefinition> _courses;
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(
string providerId,
string providerDisplayName,
@@ -99,25 +83,6 @@ namespace ProjectQuint.Library
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)
=> 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
{
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 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 int _chartCount;
@@ -139,18 +60,13 @@ namespace ProjectQuint.Library
}
internal static IEnumerator LoadOnline(
string providerId,
string configuredUrl,
string headerCachePath,
string dataCachePath,
DifficultyTableRequest source,
Action<ExternalDataResult<BmsDifficultyTableIndex>> completed)
{
if (!Uri.TryCreate(configuredUrl, UriKind.Absolute, out var configuredUri) ||
configuredUri.Scheme != Uri.UriSchemeHttps)
{
completed?.Invoke(Unavailable("Difficulty table URL must use HTTPS"));
yield break;
}
var configuredUri = source.HeaderUri;
var providerId = source.ProviderId;
var headerCachePath = source.HeaderCachePath;
var dataCachePath = source.DataCachePath;
string firstDocument = null;
string requestFailure = null;
@@ -166,21 +82,17 @@ namespace ProjectQuint.Library
var headerUrl = configuredUri.AbsoluteUri;
var headerJson = firstDocument;
if (!string.IsNullOrWhiteSpace(firstDocument) && LooksLikeHtml(firstDocument))
if (!string.IsNullOrWhiteSpace(firstDocument) && DifficultyTableRequest.LooksLikeHtml(firstDocument))
{
var match = BmsTableMetaRegex.Match(firstDocument);
if (!match.Success)
try
{
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;
}
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);
headerRequest.timeout = 12;
yield return headerRequest.SendWebRequest();
@@ -211,10 +123,14 @@ namespace ProjectQuint.Library
yield break;
}
var dataUri = new Uri(new Uri(headerUrl), header.data_url);
if (dataUri.Scheme != Uri.UriSchemeHttps)
Uri dataUri;
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;
}
var dataUrl = dataUri.AbsoluteUri;
@@ -346,9 +262,6 @@ namespace ProjectQuint.Library
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 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)
{
if (presetIndex < 0 || presetIndex >= BmsDifficultyTableIndex.Presets.Length)
if (presetIndex < 0 || presetIndex >= BmsDifficultyTablePresets.All.Length)
throw new ArgumentOutOfRangeException(nameof(presetIndex));
var preset = BmsDifficultyTableIndex.Presets[presetIndex];
var preset = BmsDifficultyTablePresets.All[presetIndex];
return Register(preset.HeaderUrl, preset.DisplayName, preset.Id, activate);
}
@@ -223,7 +223,7 @@ namespace ProjectQuint.Library
private void EnsurePresets()
{
var changed = false;
foreach (var preset in BmsDifficultyTableIndex.Presets)
foreach (var preset in BmsDifficultyTablePresets.All)
{
if (FindByUrl(preset.HeaderUrl) != null)
continue;
@@ -308,7 +308,7 @@ namespace ProjectQuint.Library
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))
return preset;
return null;
@@ -41,7 +41,7 @@ namespace ProjectQuint.Library
public int DifficultySortIndex { get; set; } = int.MaxValue;
public string TitleOverride { 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);
}
@@ -53,10 +53,9 @@ namespace ProjectQuint.Library
var matched = 0;
var preset = catalog == null
? null
: BmsDifficultyTableIndex.FindPreset(catalog.ProviderId);
var keyCountOverride = preset.HasValue &&
preset.Value.DefaultKeyCount >= 4 &&
preset.Value.DefaultKeyCount <= 7
: BmsDifficultyTablePresets.Find(catalog.ProviderId);
var defaultKeyCount = preset.HasValue &&
SongKeyModePolicy.IsSupported(preset.Value.DefaultKeyCount)
? preset.Value.DefaultKeyCount
: 0;
foreach (var song in songs ?? Array.Empty<SongLibraryEntry>())
@@ -82,7 +81,7 @@ namespace ProjectQuint.Library
DifficultySortIndex = entry.DifficultySortIndex,
TitleOverride = entry.TitleOverride ?? string.Empty,
ArtistOverride = entry.ArtistOverride ?? string.Empty,
KeyCountOverride = keyCountOverride,
DefaultKeyCount = defaultKeyCount,
};
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
{
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
{
private const int CacheVersion = 6;
@@ -103,18 +42,23 @@ namespace ProjectQuint.Library
private readonly string _legacyCachePath;
private readonly string _packId;
private readonly int _forcedKeyCount;
private readonly int _tableDefaultKeyCount;
private readonly int _scanKeyCount;
private int _loadedCacheVersion;
public SongLibraryScanner(
string cachePath = null,
string legacyCachePath = null,
string packId = null,
int forcedKeyCount = 0)
int forcedKeyCount = 0,
int tableDefaultKeyCount = 0)
{
_cachePath = cachePath;
_legacyCachePath = legacyCachePath;
_packId = packId ?? string.Empty;
_forcedKeyCount = forcedKeyCount;
_tableDefaultKeyCount = tableDefaultKeyCount;
_scanKeyCount = SongKeyModePolicy.Resolve(forcedKeyCount, tableDefaultKeyCount, 0).KeyCount;
}
public int Completed => Volatile.Read(ref _completed);
@@ -240,7 +184,7 @@ namespace ProjectQuint.Library
{
var thumbnailDocument = BmsParser.ParseFile(
fullPath,
forcedKeyCount: _forcedKeyCount);
forcedKeyCount: _scanKeyCount);
var thumbnailChart = BmsTimelineCompiler.Compile(thumbnailDocument);
thumbnailFile = SelectThumbnailFile(
fullPath,
@@ -273,7 +217,7 @@ namespace ProjectQuint.Library
}
else
{
var document = BmsParser.ParseFile(fullPath, forcedKeyCount: _forcedKeyCount);
var document = BmsParser.ParseFile(fullPath, forcedKeyCount: _scanKeyCount);
rawMetadata = document.Metadata;
var hashes = ComputeHashes(fullPath);
rawMetadata.Md5 = hashes.Md5;
@@ -318,6 +262,8 @@ namespace ProjectQuint.Library
{
ChartPath = path,
PackId = _packId,
ForcedKeyCount = _forcedKeyCount,
TableDefaultKeyCount = _tableDefaultKeyCount,
ThumbnailFile = thumbnailFile,
Metadata = CloneMetadata(rawMetadata),
DurationSeconds = durationSeconds,
@@ -369,7 +315,7 @@ namespace ProjectQuint.Library
if (document == null || document.Version < 1 || document.Version > CacheVersion ||
!string.Equals(Path.GetFullPath(document.LibraryRoot ?? string.Empty), libraryRoot,
StringComparison.OrdinalIgnoreCase) ||
document.ForcedKeyCount != _forcedKeyCount)
document.ForcedKeyCount != _scanKeyCount)
{
CacheStatus = "cache version, library root or key mode changed";
return result;
@@ -409,7 +355,7 @@ namespace ProjectQuint.Library
{
Version = CacheVersion,
LibraryRoot = libraryRoot,
ForcedKeyCount = _forcedKeyCount,
ForcedKeyCount = _scanKeyCount,
Entries = entries.ToArray(),
};
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
{
[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
{
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
{
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]
internal sealed class SearchAliasDocument
{
@@ -152,6 +119,11 @@ namespace ProjectQuint.Library
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)
{
foreach (var song in songs ?? Array.Empty<SongLibraryEntry>())
@@ -142,7 +142,7 @@ namespace ProjectQuint.UI
private readonly Label _tableStatus;
private readonly TextField _tableUrlField;
private readonly VisualElement[] _tablePresetButtons =
new VisualElement[BmsDifficultyTableIndex.Presets.Length];
new VisualElement[BmsDifficultyTablePresets.All.Length];
private string _activeTableId = string.Empty;
private string _tableUrl = string.Empty;
private bool _tableUrlFocused;
@@ -1437,7 +1437,7 @@ namespace ProjectQuint.UI
break;
}
var preset = BmsDifficultyTableIndex.FindPreset(table?.Id, table?.HeaderUrl);
var preset = BmsDifficultyTablePresets.Find(table?.Id, table?.HeaderUrl);
if (!preset.HasValue ||
preset.Value.DefaultKeyCount < 4 ||
preset.Value.DefaultKeyCount > 7)
@@ -1462,7 +1462,7 @@ namespace ProjectQuint.UI
break;
}
var preset = BmsDifficultyTableIndex.FindPreset(table?.Id, table?.HeaderUrl);
var preset = BmsDifficultyTablePresets.Find(table?.Id, table?.HeaderUrl);
return preset.HasValue &&
preset.Value.DefaultKeyCount >= 4 &&
preset.Value.DefaultKeyCount <= 7
@@ -1500,7 +1500,7 @@ namespace ProjectQuint.UI
};
return string.IsNullOrWhiteSpace(presetId)
? null
: BmsDifficultyTableIndex.FindPreset(presetId);
: BmsDifficultyTablePresets.Find(presetId);
}
private void ConfigureTableList()
@@ -1594,8 +1594,8 @@ namespace ProjectQuint.UI
var capturedIndex = index;
var button = Require<VisualElement>(_tableRoot, $"table-preset-{index}");
_tablePresetButtons[index] = button;
var preset = index < BmsDifficultyTableIndex.Presets.Length
? BmsDifficultyTableIndex.Presets[index]
var preset = index < BmsDifficultyTablePresets.All.Length
? BmsDifficultyTablePresets.All[index]
: default;
SetText(
Require<Label>(button, "preset-name"),
@@ -134,17 +134,6 @@ namespace ProjectQuint.Tests
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]
public void LibraryLocator_DiscoversArbitrarilyNamedSiblingPack()
@@ -1,5 +1,4 @@
using NUnit.Framework;
using ProjectQuint.Bootstrap;
using ProjectQuint.Core.Bms;
using ProjectQuint.Library;
@@ -31,19 +30,19 @@ namespace ProjectQuint.Tests
[Test]
public void Presets_ContainVerifiedCoreAndUeTables()
{
Assert.That(BmsDifficultyTableIndex.Presets, Has.Length.EqualTo(6));
Assert.That(BmsDifficultyTableIndex.Presets[0].Id, Is.EqualTo("aery"));
Assert.That(BmsDifficultyTableIndex.Presets[1].Id, Is.EqualTo("satellite"));
Assert.That(BmsDifficultyTableIndex.Presets[2].Id, Is.EqualTo("stella"));
Assert.That(BmsDifficultyTableIndex.Presets[3].Id, Is.EqualTo("genocide-insane"));
Assert.That(BmsDifficultyTableIndex.Presets[4].Id, Is.EqualTo("4ue"));
Assert.That(BmsDifficultyTableIndex.Presets[5].Id, Is.EqualTo("6ue"));
Assert.That(BmsDifficultyTableIndex.Presets[4].HeaderUrl,
Assert.That(BmsDifficultyTablePresets.All, Has.Length.EqualTo(6));
Assert.That(BmsDifficultyTablePresets.All[0].Id, Is.EqualTo("aery"));
Assert.That(BmsDifficultyTablePresets.All[1].Id, Is.EqualTo("satellite"));
Assert.That(BmsDifficultyTablePresets.All[2].Id, Is.EqualTo("stella"));
Assert.That(BmsDifficultyTablePresets.All[3].Id, Is.EqualTo("genocide-insane"));
Assert.That(BmsDifficultyTablePresets.All[4].Id, Is.EqualTo("4ue"));
Assert.That(BmsDifficultyTablePresets.All[5].Id, Is.EqualTo("6ue"));
Assert.That(BmsDifficultyTablePresets.All[4].HeaderUrl,
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"));
Assert.That(BmsDifficultyTableIndex.Presets[5].DefaultKeyCount, Is.EqualTo(6));
foreach (var preset in BmsDifficultyTableIndex.Presets)
Assert.That(BmsDifficultyTablePresets.All[5].DefaultKeyCount, Is.EqualTo(6));
foreach (var preset in BmsDifficultyTablePresets.All)
Assert.That(preset.HeaderUrl, Does.StartWith("https://"));
}
@@ -58,12 +57,14 @@ namespace ProjectQuint.Tests
};
Assert.That(
ProjectQuintApp.ResolvePackScanKeyCount(pack, existingCustomTable),
SongKeyModePolicy.Resolve(pack.ForcedKeyCount,
SongKeyModePolicy.TableDefault(existingCustomTable.Id, existingCustomTable.HeaderUrl), 0).KeyCount,
Is.EqualTo(6));
pack.ForcedKeyCount = 7;
Assert.That(
ProjectQuintApp.ResolvePackScanKeyCount(pack, existingCustomTable),
SongKeyModePolicy.Resolve(pack.ForcedKeyCount,
SongKeyModePolicy.TableDefault(existingCustomTable.Id, existingCustomTable.HeaderUrl), 0).KeyCount,
Is.EqualTo(7));
}
@@ -102,19 +103,19 @@ namespace ProjectQuint.Tests
}
[Test]
public void FourUeTable_MatchedChartUsesAuthoritativeFourKeyLayoutWithoutMutatingRawMetadata()
public void FourKeyPreset_MatchedChartUsesTableDefaultWithoutMutatingMetadata()
{
const string header =
"{\"name\":\"4UE Difficulty Table\",\"symbol\":\"4UE\",\"data_url\":\"data.json\"}";
const string data =
"[{\"md5\":\"862ea8f9a8f9463501b052c0c86cb4c1\",\"level\":\"20\"," +
"\"title\":\"GET ON WiTH FUNKOT [4K UE]\"}]";
"[{\"md5\":\"synthetic-four-key-chart\",\"level\":\"20\"," +
"\"title\":\"Synthetic Four Key Chart\"}]";
var table = BmsDifficultyTableIndex.Parse("4ue", header, data);
var song = new SongLibraryEntry
{
Metadata = new BmsMetadata
{
Md5 = "862ea8f9a8f9463501b052c0c86cb4c1",
Md5 = "synthetic-four-key-chart",
KeyCount = 5,
UsedKeyChannelMask = 31,
},
@@ -124,7 +125,7 @@ namespace ProjectQuint.Tests
Assert.That(matched, Is.EqualTo(1));
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));
}
@@ -328,7 +328,7 @@ namespace ProjectQuint.Tests
var cacheRoot = Path.Combine(_temporaryDirectory, "difficulty-tables");
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);
var custom = registry.Register(
@@ -337,7 +337,7 @@ namespace ProjectQuint.Tests
registry.Select("satellite");
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.Find(custom.Id).DisplayName, Is.EqualTo("Custom Global Table"));
Assert.That(reloaded.HeaderCachePathFor(custom),
@@ -352,7 +352,7 @@ namespace ProjectQuint.Tests
var packRegistryPath = Path.Combine(_temporaryDirectory, "packs.json");
var packRegistry = new BmsPackRegistry(packRegistryPath);
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 legacyCacheRoot = Path.Combine(cacheRoot, pack.Id);
@@ -397,7 +397,7 @@ namespace ProjectQuint.Tests
new BmsPackDefinition
{
Id = pack.Id,
DifficultyTableUrl = BmsDifficultyTableIndex.Presets[0].HeaderUrl,
DifficultyTableUrl = BmsDifficultyTablePresets.All[0].HeaderUrl,
},
},
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(
"aery", "AERY", 5, false, 12.5, 29,
activityPlayCount: 34);
var tables = BmsDifficultyTableIndex.Presets
var tables = BmsDifficultyTablePresets.All
.Select(preset => new BmsDifficultyTableDefinition
{
Id = preset.Id,
@@ -248,7 +248,7 @@ namespace ProjectQuint.Tests
Does.Contain("GENOCIDE INSANE BMS (LEGACY MIRROR) 1+7K RATING"));
Assert.That(contexts.Select(context => context.ContextLabel),
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]
@@ -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);
}
[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]
public void SecondScan_ReusesMetadataAndMd5FromPersistentCache()
{
@@ -34,94 +34,6 @@ namespace ProjectQuint.Tests
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]
public IEnumerator LoadCancellation_AfterCandidateCreationRollsBackResourceRoot()