diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..687f6401
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,61 @@
+name: CI
+
+on:
+ pull_request:
+ branches: [master, dev, 'v2*']
+ push:
+ branches: [master, dev, 'v2*']
+
+env:
+ DOTNET_VERSION: '8.0.x'
+ CONFIGURATION: Release
+
+jobs:
+ build-and-test:
+ runs-on: windows-latest
+
+ env:
+ NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Cache NuGet packages
+ uses: actions/cache@v4
+ with:
+ path: ${{ github.workspace }}/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: ${{ env.DOTNET_VERSION }}
+
+ - name: Restore dependencies
+ run: dotnet restore
+
+ - name: Build solution
+ run: dotnet build "osu!StreamCompanion.sln" --configuration ${{ env.CONFIGURATION }} --no-restore
+
+ - name: Run tests
+ run: dotnet test "osu!StreamCompanion.sln" --configuration ${{ env.CONFIGURATION }} --no-build --verbosity normal
+
+ - name: Package portable zips
+ shell: cmd
+ run: |
+ rem rm/cp used by buildRelease-CI.cmd live in Git's usr/bin
+ set "PATH=C:\Program Files\Git\usr\bin;%PATH%"
+ buildRelease-CI.cmd zips-only
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: portable-zips
+ path: |
+ build/StreamCompanion-portable.zip
+ build/StreamCompanion-portable-browserOverlay.zip
+ build/StreamCompanion-portable-textOverlay.zip
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..141711e6
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,107 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - '**'
+
+env:
+ DOTNET_VERSION: '8.0.x'
+ CONFIGURATION: Release
+
+jobs:
+ build-and-release:
+ runs-on: windows-latest
+
+ env:
+ NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Cache NuGet packages
+ uses: actions/cache@v4
+ with:
+ path: ${{ github.workspace }}/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: ${{ env.DOTNET_VERSION }}
+
+ - name: Extract version from tag
+ id: version
+ shell: pwsh
+ run: |
+ # tag v260724.21 -> AssemblyFileVersion 260724.21
+ $fileVersion = "${{ github.ref_name }}" -replace '^v', ''
+ "FILEVERSION=$fileVersion" >> $env:GITHUB_OUTPUT
+ Write-Host "FileVersion: $fileVersion"
+
+ - name: Set assembly file versions from tag
+ shell: pwsh
+ run: |
+ $fileVersion = "${{ steps.version.outputs.FILEVERSION }}"
+ $files = Get-ChildItem -Recurse -Filter AssemblyInfo.cs
+ foreach ($file in $files) {
+ $content = Get-Content $file.FullName -Raw
+ $patched = $content -replace 'AssemblyFileVersion\(".*?"\)', "AssemblyFileVersion(`"$fileVersion`")"
+ if ($patched -ne $content) {
+ Set-Content $file.FullName $patched -NoNewline
+ Write-Host "patched $($file.FullName)"
+ }
+ }
+
+ - name: Restore dependencies
+ run: dotnet restore
+
+ - name: Build solution
+ run: dotnet build "osu!StreamCompanion.sln" --configuration ${{ env.CONFIGURATION }} --no-restore
+
+ - name: Run tests
+ run: dotnet test "osu!StreamCompanion.sln" --configuration ${{ env.CONFIGURATION }} --no-build --verbosity normal
+
+ - name: Install InnoSetup
+ run: choco install innosetup inno-download-plugin -y
+
+ - name: Package release files
+ shell: cmd
+ env:
+ FILEVERSION: ${{ steps.version.outputs.FILEVERSION }}
+ run: |
+ rem rm/cp used by buildRelease-CI.cmd live in Git's usr/bin
+ set "PATH=C:\Program Files\Git\usr\bin;%PATH%"
+ buildRelease-CI.cmd %FILEVERSION%
+
+ - name: Collect artifacts
+ shell: pwsh
+ run: |
+ New-Item -ItemType Directory -Path ./artifacts -Force
+ Move-Item "InnoSetup\Output\StreamCompanion Setup.exe" ./artifacts/
+ Move-Item "InnoSetup\Output\StreamCompanion-textOverlay.exe" ./artifacts/
+ Move-Item "InnoSetup\Output\StreamCompanion-browserOverlay.exe" ./artifacts/
+ Move-Item "build\StreamCompanion-portable.zip" ./artifacts/
+ Move-Item "build\StreamCompanion-portable-browserOverlay.zip" ./artifacts/
+ Move-Item "build\StreamCompanion-portable-textOverlay.zip" ./artifacts/
+ Get-ChildItem ./artifacts
+
+ - name: Create draft release
+ uses: softprops/action-gh-release@v2
+ with:
+ draft: true
+ generate_release_notes: true
+ # in-app updater matches asset name "StreamCompanion.Setup.exe" (GitHub replaces spaces with dots on upload)
+ files: |
+ artifacts/StreamCompanion Setup.exe
+ artifacts/StreamCompanion-textOverlay.exe
+ artifacts/StreamCompanion-browserOverlay.exe
+ artifacts/StreamCompanion-portable.zip
+ artifacts/StreamCompanion-portable-browserOverlay.zip
+ artifacts/StreamCompanion-portable-textOverlay.zip
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/Directory.Build.props b/Directory.Build.props
index 04322f6e..8900325c 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -6,6 +6,8 @@
+
+ $(NoWarn);CS7035
-
\ No newline at end of file
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 1423d1c4..5dd3237e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -8,7 +8,7 @@
-
+
@@ -23,7 +23,12 @@
+
+
+
+
+
diff --git a/PpCalculator/CachedTimedAttributesProvider.cs b/PpCalculator/CachedTimedAttributesProvider.cs
new file mode 100644
index 00000000..51f61c4e
--- /dev/null
+++ b/PpCalculator/CachedTimedAttributesProvider.cs
@@ -0,0 +1,91 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Diagnostics;
+using System.Threading;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Difficulty;
+using osu.Game.Rulesets.Mods;
+using StreamCompanionTypes.Enums;
+using StreamCompanionTypes.Interfaces.Services;
+
+namespace PpCalculator
+{
+ public sealed class CachedTimedAttributesProvider : ITimedAttributesProvider, IDisposable
+ {
+ public const int DefaultMinimumGenerationMilliseconds = 250;
+
+ private readonly int minimumGenerationMilliseconds;
+
+ private readonly DifficultyCache cache;
+ private readonly ITimedAttributesProvider timedAttributesProvider;
+ private readonly ILogger? logger;
+
+ public bool Enabled => cache.Enabled;
+ public long CacheHits => cache.CacheHits;
+ public long CacheStores => cache.CacheStores;
+ public CachedTimedAttributesProvider(string databaseFilePath, int maxCacheEntries, string cacheVersion = DifficultyCache.DefaultCacheVersion,
+ ILogger? logger = null, ITimedAttributesProvider? timedAttributesProvider = null,
+ int minimumGenerationMilliseconds = DefaultMinimumGenerationMilliseconds)
+ {
+ this.logger = logger;
+ this.timedAttributesProvider = timedAttributesProvider ?? CalculatingTimedAttributesProvider.Instance;
+ this.minimumGenerationMilliseconds = minimumGenerationMilliseconds;
+ cache = new DifficultyCache(databaseFilePath, maxCacheEntries, cacheVersion, logger);
+ }
+
+ public List GetTimedAttributes(IWorkingBeatmap workingBeatmap, Ruleset ruleset, IReadOnlyList mods, bool isFullBeatmap, CancellationToken cancellationToken)
+ {
+ string? beatmapMd5 = workingBeatmap.BeatmapInfo.MD5Hash;
+ string modsKey = string.Join("|", mods.Select(mod => mod.Acronym));
+ bool isCachableRequest = isFullBeatmap && cache.Enabled;
+
+ if (isCachableRequest && beatmapMd5 != null)
+ {
+ try
+ {
+ byte[]? blob = cache.TryGetRaw(beatmapMd5, ruleset.RulesetInfo.OnlineID, modsKey);
+ if (blob != null)
+ {
+ logger?.Log($"difficulty cache hit: {beatmapMd5} ruleset {ruleset.RulesetInfo.OnlineID} mods [{modsKey}]", LogLevel.Debug);
+ return DifficultyCache.Deserialize(blob);
+ }
+ }
+ catch (Exception exception)
+ {
+ logger?.Log($"difficulty cache hit failed, computing instead (map {beatmapMd5} ruleset {ruleset.RulesetInfo.OnlineID} mods [{modsKey}])", LogLevel.Warning);
+ logger?.Log(exception, LogLevel.Warning);
+ }
+ }
+
+ Stopwatch computeStopwatch = Stopwatch.StartNew();
+ List computed =
+ timedAttributesProvider.GetTimedAttributes(workingBeatmap, ruleset, mods, isFullBeatmap, cancellationToken);
+ computeStopwatch.Stop();
+
+ if (isCachableRequest && beatmapMd5 != null && computeStopwatch.ElapsedMilliseconds >= minimumGenerationMilliseconds)
+ {
+ try
+ {
+ byte[] compressedAttributes = DifficultyCache.Serialize(computed);
+ cache.Store(new DifficultyCache.CacheEntry(
+ beatmapMd5, ruleset.RulesetInfo.OnlineID, modsKey, compressedAttributes, computeStopwatch.ElapsedMilliseconds));
+ logger?.Log($"difficulty cache store: {beatmapMd5} ruleset {ruleset.RulesetInfo.OnlineID} mods [{modsKey}] ({compressedAttributes.Length / 1024} KB)", LogLevel.Debug);
+ }
+ catch (Exception exception)
+ {
+ logger?.Log($"difficulty cache store failed, result unaffected (map {beatmapMd5} ruleset {ruleset.RulesetInfo.OnlineID} mods [{modsKey}])", LogLevel.Warning);
+ logger?.Log(exception, LogLevel.Warning);
+ }
+ }
+
+ return computed;
+ }
+
+ internal void FlushPendingEntries() => cache.FlushPendingEntries();
+
+ public void Dispose() => cache.Dispose();
+ }
+}
diff --git a/PpCalculator/CalculatingTimedAttributesProvider.cs b/PpCalculator/CalculatingTimedAttributesProvider.cs
new file mode 100644
index 00000000..e15e5b4e
--- /dev/null
+++ b/PpCalculator/CalculatingTimedAttributesProvider.cs
@@ -0,0 +1,17 @@
+using System.Collections.Generic;
+using System.Threading;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Difficulty;
+using osu.Game.Rulesets.Mods;
+
+namespace PpCalculator
+{
+ public sealed class CalculatingTimedAttributesProvider : ITimedAttributesProvider
+ {
+ public static readonly CalculatingTimedAttributesProvider Instance = new();
+
+ public List GetTimedAttributes(IWorkingBeatmap workingBeatmap, Ruleset ruleset, IReadOnlyList mods, bool isFullBeatmap, CancellationToken cancellationToken)
+ => ruleset.CreateDifficultyCalculator(workingBeatmap).CalculateTimed(mods, cancellationToken);
+ }
+}
diff --git a/PpCalculator/CtbCalculator.cs b/PpCalculator/CtbCalculator.cs
index 944c4261..ee776417 100644
--- a/PpCalculator/CtbCalculator.cs
+++ b/PpCalculator/CtbCalculator.cs
@@ -14,6 +14,9 @@ namespace PpCalculator
public class CtbCalculator : PpCalculator
{
protected override Ruleset Ruleset { get; } = new CatchRuleset();
+ public CtbCalculator() { }
+ public CtbCalculator(ITimedAttributesProvider timedAttributesProvider = null) : base(timedAttributesProvider) { }
+
protected override int GetMaxCombo(IReadOnlyList hitObjects) =>
hitObjects.Count(h => h is Fruit)
diff --git a/PpCalculator/DifficultyCache.cs b/PpCalculator/DifficultyCache.cs
new file mode 100644
index 00000000..84e5af54
--- /dev/null
+++ b/PpCalculator/DifficultyCache.cs
@@ -0,0 +1,450 @@
+#nullable enable
+using Dapper;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using Newtonsoft.Json.Serialization;
+using osu.Game.Rulesets.Difficulty;
+using StreamCompanionTypes.Enums;
+using StreamCompanionTypes.Interfaces.Services;
+using System;
+using System.Collections.Generic;
+using System.Data.SQLite;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Text;
+using System.Threading;
+namespace PpCalculator;
+
+///
+/// Persistent on-disk cache of .
+///
+internal sealed class DifficultyCache : IDisposable
+{
+ ///
+ /// Cache version. Bump on osu submodule update or format change.
+ ///
+ public const string DefaultCacheVersion = "1";
+
+ private const int FlushEntryThreshold = 10;
+
+ internal sealed record CacheEntry(string BeatmapMd5, int RulesetId, string Mods, byte[] CompressedAttributes, long GenerationMilliseconds);
+ private sealed record CacheEntryKey(string BeatmapMd5, int RulesetId, string Mods);
+
+ public bool Enabled { get; private set; } = true;
+
+ public long CacheHits => cacheHits;
+ public long CacheStores => cacheStores;
+
+ private long cacheHits;
+ private long cacheStores;
+
+ private readonly string cacheVersion;
+ private readonly int maxCacheEntries;
+ private readonly SQLiteConnection? connection;
+ private readonly object databaseSyncRoot = new();
+ private readonly Queue pendingWrites = new();
+ private readonly Dictionary unflushedEntries = [];
+ private readonly object pendingWritesSyncRoot = new();
+ private readonly ILogger? logger;
+
+ public DifficultyCache(string databaseFilePath, int maxCacheEntries, string cacheVersion = DefaultCacheVersion, ILogger? logger = null)
+ {
+ this.cacheVersion = cacheVersion;
+ this.maxCacheEntries = maxCacheEntries;
+ this.logger = logger;
+
+ if (maxCacheEntries <= 0)
+ {
+ Enabled = false;
+ return;
+ }
+
+ try
+ {
+ string? databaseDirectory = Path.GetDirectoryName(Path.GetFullPath(databaseFilePath));
+ if (databaseDirectory != null)
+ {
+ _ = Directory.CreateDirectory(databaseDirectory);
+ }
+
+ connection = new SQLiteConnection(new SQLiteConnectionStringBuilder
+ {
+ DataSource = databaseFilePath,
+ Version = 3,
+ JournalMode = SQLiteJournalModeEnum.Wal,
+ SyncMode = SynchronizationModes.Normal,
+ BusyTimeout = 5000,
+ }.ConnectionString);
+
+ connection.Open();
+ _ = connection.Execute(@"
+CREATE TABLE IF NOT EXISTS difficulty_cache (
+ beatmap_md5 TEXT NOT NULL,
+ ruleset_id INTEGER NOT NULL,
+ mods TEXT NOT NULL,
+ algorithm_version TEXT NOT NULL,
+ timed_attributes BLOB NOT NULL,
+ generation_milliseconds INTEGER NOT NULL,
+ calculated_at INTEGER NOT NULL,
+ last_accessed_at INTEGER NOT NULL,
+ PRIMARY KEY (beatmap_md5, ruleset_id, mods, algorithm_version)
+);
+CREATE INDEX IF NOT EXISTS idx_difficulty_cache_last_accessed_at ON difficulty_cache(last_accessed_at);");
+ }
+ catch (Exception exception)
+ {
+ Enabled = false;
+ logger?.Log($"difficulty cache disabled: could not open \"{databaseFilePath}\"", LogLevel.Warning);
+ logger?.Log(exception, LogLevel.Warning);
+ }
+ }
+ public byte[]? TryGetRaw(string beatmapMd5, int rulesetId, string mods)
+ {
+ if (!Enabled)
+ {
+ return null;
+ }
+
+ lock (pendingWritesSyncRoot)
+ {
+ if (unflushedEntries.TryGetValue(new CacheEntryKey(beatmapMd5, rulesetId, mods), out byte[]? unflushedBlob))
+ {
+ _ = Interlocked.Increment(ref cacheHits);
+ return unflushedBlob;
+ }
+ }
+
+ try
+ {
+ byte[]? blob = connection?.ExecuteScalar(
+ "SELECT timed_attributes FROM difficulty_cache WHERE beatmap_md5 = @beatmapMd5 AND ruleset_id = @rulesetId AND mods = @mods AND algorithm_version = @algorithmVersion",
+ new { beatmapMd5, rulesetId, mods, algorithmVersion = cacheVersion });
+
+ if (blob != null)
+ {
+ _ = Interlocked.Increment(ref cacheHits);
+ _ = (connection?.Execute(
+ "UPDATE difficulty_cache SET last_accessed_at = @LastAccessedAt WHERE beatmap_md5 = @beatmapMd5 AND ruleset_id = @rulesetId AND mods = @mods AND algorithm_version = @algorithmVersion",
+ new { LastAccessedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), beatmapMd5, rulesetId, mods, algorithmVersion = cacheVersion }));
+ }
+
+ return blob;
+ }
+ catch (Exception exception)
+ {
+ logger?.Log($"difficulty cache read failed for map {beatmapMd5} ruleset {rulesetId} mods [{mods}]", LogLevel.Warning);
+ logger?.Log(exception, LogLevel.Warning);
+ return null;
+ }
+ }
+
+ public void Store(CacheEntry entry)
+ {
+ if (!Enabled)
+ {
+ return;
+ }
+
+ _ = Interlocked.Increment(ref cacheStores);
+ bool reachedFlushThreshold;
+ lock (pendingWritesSyncRoot)
+ {
+ pendingWrites.Enqueue(entry);
+ unflushedEntries[new CacheEntryKey(entry.BeatmapMd5, entry.RulesetId, entry.Mods)] = entry.CompressedAttributes;
+ reachedFlushThreshold = pendingWrites.Count >= FlushEntryThreshold;
+ }
+
+ if (reachedFlushThreshold)
+ {
+ FlushPendingEntries();
+ }
+ }
+
+ internal void FlushPendingEntries()
+ {
+ List pendingEntries = [];
+ lock (pendingWritesSyncRoot)
+ {
+ while (pendingWrites.Count > 0)
+ {
+ pendingEntries.Add(pendingWrites.Dequeue());
+ }
+ }
+
+ if (pendingEntries.Count == 0 || connection == null)
+ {
+ return;
+ }
+
+ try
+ {
+ lock (databaseSyncRoot)
+ {
+ using SQLiteTransaction transaction = connection.BeginTransaction();
+
+ foreach (CacheEntry entry in pendingEntries)
+ {
+ _ = connection.Execute(
+ "INSERT OR REPLACE INTO difficulty_cache (beatmap_md5, ruleset_id, mods, algorithm_version, timed_attributes, generation_milliseconds, calculated_at, last_accessed_at) VALUES (@BeatmapMd5, @RulesetId, @Mods, @AlgorithmVersion, @CompressedAttributes, @GenerationMilliseconds, @CalculatedAt, @CalculatedAt)",
+ new { entry.BeatmapMd5, entry.RulesetId, entry.Mods, AlgorithmVersion = cacheVersion, entry.CompressedAttributes, entry.GenerationMilliseconds, CalculatedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds() },
+ transaction);
+ }
+
+ _ = connection.Execute(
+ "DELETE FROM difficulty_cache WHERE rowid IN (SELECT rowid FROM difficulty_cache ORDER BY last_accessed_at DESC, rowid DESC LIMIT -1 OFFSET @MaxCacheEntries)",
+ new { MaxCacheEntries = maxCacheEntries },
+ transaction);
+
+ transaction.Commit();
+ }
+
+ lock (pendingWritesSyncRoot)
+ {
+ foreach (CacheEntry entry in pendingEntries)
+ {
+ _ = unflushedEntries.Remove(new CacheEntryKey(entry.BeatmapMd5, entry.RulesetId, entry.Mods));
+ }
+ }
+ }
+ catch (Exception exception)
+ {
+ logger?.Log($"difficulty cache flush failed ({pendingEntries.Count} entries lost)", LogLevel.Warning);
+ logger?.Log(exception, LogLevel.Warning);
+ }
+ }
+
+ public void Dispose()
+ {
+ FlushPendingEntries();
+
+ lock (databaseSyncRoot)
+ {
+ connection?.Dispose();
+ }
+ }
+
+ public static byte[] Serialize(List timedAttributes)
+ {
+ JsonSerializer serializer = JsonSerializer.Create(Settings);
+
+ JObject firstAttributes = JObject.FromObject(timedAttributes[0].Attributes, serializer);
+ Dictionary> columns = firstAttributes.Properties()
+ .ToDictionary(property => property.Name, property => new List(timedAttributes.Count));
+
+ foreach (TimedDifficultyAttributes timedAttribute in timedAttributes)
+ {
+ foreach (JProperty property in JObject.FromObject(timedAttribute.Attributes, serializer).Properties())
+ {
+ columns[property.Name].Add(property.Value);
+ }
+ }
+
+ StoredHeader header = new(
+ TypeName(timedAttributes[0].Attributes.GetType()),
+ timedAttributes.Count,
+ columns.Keys.ToList(),
+ columns.Where(column => column.Value[0].Type == JTokenType.Integer).Select(column => column.Key).ToList());
+ byte[] headerBytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(header));
+
+ using MemoryStream compressed = new();
+ using (BrotliStream brotliStream = new(compressed, CompressionLevel.Optimal))
+ {
+ brotliStream.Write(BitConverter.GetBytes(headerBytes.Length));
+ brotliStream.Write(headerBytes);
+
+ WriteDoublePlanes(brotliStream, timedAttributes.Select(timedAttribute => timedAttribute.Time).ToArray());
+ foreach (KeyValuePair> column in columns)
+ {
+ if (header.IntegerKeys.Contains(column.Key))
+ {
+ WriteIntegers(brotliStream, column.Value);
+ }
+ else
+ {
+ WriteDoublePlanes(brotliStream, column.Value);
+ }
+ }
+ }
+
+ return compressed.ToArray();
+ }
+
+ public static List Deserialize(byte[] compressedAttributes)
+ {
+ using MemoryStream compressed = new(compressedAttributes);
+ using BrotliStream brotliStream = new(compressed, CompressionMode.Decompress);
+ using MemoryStream decompressed = new();
+ brotliStream.CopyTo(decompressed);
+ byte[] payload = decompressed.ToArray();
+
+ StoredHeader? header = JsonConvert.DeserializeObject(
+ Encoding.UTF8.GetString(payload, sizeof(int), BitConverter.ToInt32(payload, 0)))
+ ?? throw new InvalidDataException("difficulty cache blob is empty");
+
+ Type concreteType = Type.GetType(header.Type) ?? throw new InvalidDataException($"difficulty cache blob references unknown type \"{header.Type}\"");
+ JsonSerializer serializer = JsonSerializer.Create(Settings);
+
+ int offset = sizeof(int) + BitConverter.ToInt32(payload, 0);
+ double[] times = readDoublePlanes(payload, ref offset, header.EntryCount);
+ Dictionary columns = new(header.Keys.Count);
+ foreach (string key in header.Keys)
+ {
+ JToken[] column = new JToken[times.Length];
+ if (header.IntegerKeys.Contains(key))
+ {
+ for (int entryIndex = 0; entryIndex < times.Length; entryIndex++)
+ {
+ column[entryIndex] = new JValue(BitConverter.ToInt32(payload, offset + (entryIndex * sizeof(int))));
+ }
+
+ offset += times.Length * sizeof(int);
+ }
+ else
+ {
+ double[] values = readDoublePlanes(payload, ref offset, times.Length);
+ for (int entryIndex = 0; entryIndex < times.Length; entryIndex++)
+ {
+ column[entryIndex] = new JValue(values[entryIndex]);
+ }
+ }
+
+ columns[key] = column;
+ }
+
+ List timedAttributes = new(times.Length);
+ for (int entryIndex = 0; entryIndex < times.Length; entryIndex++)
+ {
+ JObject attributes = [];
+ foreach (KeyValuePair column in columns)
+ {
+ attributes[column.Key] = column.Value[entryIndex];
+ }
+
+ timedAttributes.Add(new TimedDifficultyAttributes(
+ times[entryIndex],
+ (DifficultyAttributes)serializer.Deserialize(attributes.CreateReader(), concreteType)!));
+ }
+
+ return timedAttributes;
+ }
+
+ ///
+ /// Double columns are stored as 8 byte-planes: all first bytes, all second bytes.
+ /// High bytes compress nicely, while low bytes are pretty much incompressible.
+ ///
+ private static void WriteDoublePlanes(Stream stream, IReadOnlyList values)
+ {
+ double[] doubles = new double[values.Count];
+ for (int valueIndex = 0; valueIndex < values.Count; valueIndex++)
+ {
+ if (values[valueIndex].Type is not JTokenType.Float and not JTokenType.Integer)
+ {
+ throw new InvalidOperationException($"difficulty cache cannot store non-numeric attribute value ({values[valueIndex].Type})");
+ }
+
+ doubles[valueIndex] = values[valueIndex].Value();
+ }
+
+ WriteDoublePlanes(stream, doubles);
+ }
+
+ private static void WriteDoublePlanes(Stream stream, double[] doubles)
+ {
+ byte[][] planes = new byte[8][];
+ for (int planeIndex = 0; planeIndex < 8; planeIndex++)
+ {
+ planes[planeIndex] = new byte[doubles.Length];
+ }
+
+ for (int valueIndex = 0; valueIndex < doubles.Length; valueIndex++)
+ {
+ byte[] valueBytes = BitConverter.GetBytes(doubles[valueIndex]);
+ for (int planeIndex = 0; planeIndex < 8; planeIndex++)
+ {
+ planes[planeIndex][valueIndex] = valueBytes[planeIndex];
+ }
+ }
+
+ for (int planeIndex = 0; planeIndex < 8; planeIndex++)
+ {
+ stream.Write(planes[planeIndex], 0, planes[planeIndex].Length);
+ }
+ }
+
+ private static void WriteIntegers(Stream stream, IReadOnlyList values)
+ {
+ foreach (JToken value in values)
+ {
+ if (value.Type != JTokenType.Integer)
+ {
+ throw new InvalidOperationException($"difficulty cache expected integer attribute value but got {value.Type}");
+ }
+
+ stream.Write(BitConverter.GetBytes(value.Value()));
+ }
+ }
+
+ private static double[] readDoublePlanes(byte[] payload, ref int offset, int count)
+ {
+ double[] doubles = new double[count];
+ byte[] valueBytes = new byte[8];
+ for (int valueIndex = 0; valueIndex < count; valueIndex++)
+ {
+ for (int planeIndex = 0; planeIndex < 8; planeIndex++)
+ {
+ valueBytes[planeIndex] = payload[offset + (planeIndex * count) + valueIndex];
+ }
+
+ doubles[valueIndex] = BitConverter.ToDouble(valueBytes, 0);
+ }
+
+ offset += count * 8;
+ return doubles;
+ }
+
+ private static string TypeName(Type type) => $"{type.FullName}, {type.Assembly.GetName().Name}";
+
+ private sealed record StoredHeader(string Type, int EntryCount, List Keys, List IntegerKeys);
+
+ private static readonly JsonSerializerSettings Settings = new()
+ {
+ ContractResolver = new DifficultyAttributesContractResolver(),
+ };
+
+ ///
+ /// Forces on all osu-side -derived types
+ /// Also ignores (performance calculators read score.Mods)
+ /// and clears ShouldSerialize* predicates (e.g. ShouldSerializeFlashlightDifficulty).
+ ///
+ private sealed class DifficultyAttributesContractResolver : DefaultContractResolver
+ {
+ protected override JsonObjectContract CreateObjectContract(Type objectType)
+ {
+ JsonObjectContract contract = base.CreateObjectContract(objectType);
+
+ if (typeof(DifficultyAttributes).IsAssignableFrom(objectType) && contract.MemberSerialization != MemberSerialization.OptOut)
+ {
+ contract.MemberSerialization = MemberSerialization.OptOut;
+ contract.Properties.Clear();
+ foreach (JsonProperty property in CreateProperties(objectType, MemberSerialization.OptOut))
+ {
+ contract.Properties.Add(property);
+ }
+ }
+
+ if (typeof(DifficultyAttributes).IsAssignableFrom(objectType))
+ {
+ foreach (JsonProperty property in contract.Properties)
+ {
+ property.ShouldSerialize = null;
+ }
+
+ JsonProperty? modsProperty = contract.Properties["Mods"];
+ modsProperty?.Ignored = true;
+ }
+
+ return contract;
+ }
+ }
+}
diff --git a/PpCalculator/ITimedAttributesProvider.cs b/PpCalculator/ITimedAttributesProvider.cs
new file mode 100644
index 00000000..b6ecfe40
--- /dev/null
+++ b/PpCalculator/ITimedAttributesProvider.cs
@@ -0,0 +1,14 @@
+using System.Collections.Generic;
+using System.Threading;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Difficulty;
+using osu.Game.Rulesets.Mods;
+
+namespace PpCalculator
+{
+ public interface ITimedAttributesProvider
+ {
+ List GetTimedAttributes(IWorkingBeatmap workingBeatmap, Ruleset ruleset, IReadOnlyList mods, bool isFullBeatmap, CancellationToken cancellationToken);
+ }
+}
diff --git a/PpCalculator/ManiaCalculator.cs b/PpCalculator/ManiaCalculator.cs
index c9896a1f..4988c2ed 100644
--- a/PpCalculator/ManiaCalculator.cs
+++ b/PpCalculator/ManiaCalculator.cs
@@ -12,6 +12,9 @@ namespace PpCalculator
public class ManiaCalculator : PpCalculator
{
protected override Ruleset Ruleset { get; } = new ManiaRuleset();
+ public ManiaCalculator() { }
+ public ManiaCalculator(ITimedAttributesProvider timedAttributesProvider = null) : base(timedAttributesProvider) { }
+
protected override int GetMaxCombo(IReadOnlyList hitObjects) => 0;
diff --git a/PpCalculator/OsuCalculator.cs b/PpCalculator/OsuCalculator.cs
index 82cc2631..6ae80e80 100644
--- a/PpCalculator/OsuCalculator.cs
+++ b/PpCalculator/OsuCalculator.cs
@@ -11,6 +11,9 @@ namespace PpCalculator;
public class OsuCalculator : PpCalculator
{
protected override Ruleset Ruleset { get; } = new osu.Game.Rulesets.Osu.OsuRuleset();
+ public OsuCalculator() { }
+ public OsuCalculator(ITimedAttributesProvider timedAttributesProvider = null) : base(timedAttributesProvider) { }
+
protected override int GetMaxCombo(IReadOnlyList hitObjects) =>
hitObjects.Count + hitObjects.OfType().Sum(s => s.NestedHitObjects.Count - 1);
diff --git a/PpCalculator/PpCalculator.cs b/PpCalculator/PpCalculator.cs
index 181ef634..8d304894 100644
--- a/PpCalculator/PpCalculator.cs
+++ b/PpCalculator/PpCalculator.cs
@@ -1,4 +1,4 @@
-using osu.Game.Beatmaps;
+using osu.Game.Beatmaps;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Mods;
@@ -18,6 +18,7 @@
using osu.Game.Rulesets.Taiko;
using osu.Game.Rulesets.Catch;
using osu.Game.Rulesets.Mania;
+using osu.Game.Rulesets.Osu.Difficulty;
namespace PpCalculator
{
@@ -85,6 +86,10 @@ public virtual string[] Mods
private Lazy scoreMultiplier = new Lazy(() => 1d);
public double ScoreMultiplier => scoreMultiplier.Value;
public bool UseScoreMultiplier { get; set; } = true;
+
+
+
+ protected ITimedAttributesProvider TimedAttributesProvider { get; set; }
public bool HasFullBeatmap { get; private set; } = false;
static PpCalculator()
@@ -97,9 +102,10 @@ static PpCalculator()
_ = new ILegacyRuleset[] { new OsuRuleset(), new TaikoRuleset(), new CatchRuleset(), new ManiaRuleset() };
}
- protected PpCalculator()
+ protected PpCalculator(ITimedAttributesProvider timedAttributesProvider = null)
{
ScoreInfo = new ScoreInfo(ruleset: Ruleset.RulesetInfo);
+ TimedAttributesProvider = timedAttributesProvider ?? CalculatingTimedAttributesProvider.Instance;
}
public object Clone()
@@ -109,7 +115,6 @@ public object Clone()
ppCalculator._playableBeatmap = _playableBeatmap;
ppCalculator._Mods = _Mods;
ppCalculator.LastMods = LastMods;
- ppCalculator.scoreMultiplier = scoreMultiplier;
ppCalculator.UseScoreMultiplier = UseScoreMultiplier;
ppCalculator.HasFullBeatmap = HasFullBeatmap;
if (PerformanceCalculator != null)
@@ -227,8 +232,8 @@ private PpCalculatorTypes.PerformanceAttributes InternalCalculate(CancellationTo
if (createPerformanceCalculator)
{
- var difficultyCalculator = Ruleset.CreateDifficultyCalculator(workingBeatmap);
- TimedDifficultyAttributes = difficultyCalculator.CalculateTimed(ScoreInfo.Mods, cancellationToken).ToList();
+ TimedDifficultyAttributes = TimedAttributesProvider.GetTimedAttributes(
+ workingBeatmap, Ruleset, ScoreInfo.Mods, isFullBeatmap: HasFullBeatmap, cancellationToken);
PerformanceCalculator = Ruleset.CreatePerformanceCalculator();
ResetPerformanceCalculator = false;
}
@@ -279,6 +284,9 @@ public int[] CalculateProgressGraphValues(CancellationToken cancellationToken, i
return values;
}
+ public Dictionary GetStrainValues(int targetAmount)
+ => StrainValuesCalculator.GetStrains(Ruleset, WorkingBeatmap, ScoreInfo.Mods, targetAmount);
+
private List GetOsuMods(Ruleset ruleset)
{
var mods = new List();
@@ -362,6 +370,6 @@ protected int GetComboToTime(IBeatmap beatmap, int toTime) =>
protected abstract double GetAccuracy(Dictionary statistics);
- protected PpCalculator CreateInstance() => PpCalculatorHelpers.GetPpCalculator(RulesetId);
+ protected PpCalculator CreateInstance() => PpCalculatorHelpers.GetPpCalculator(RulesetId, TimedAttributesProvider);
}
}
diff --git a/PpCalculator/PpCalculator.csproj b/PpCalculator/PpCalculator.csproj
index 65030c5e..917fb56e 100644
--- a/PpCalculator/PpCalculator.csproj
+++ b/PpCalculator/PpCalculator.csproj
@@ -1,9 +1,9 @@
-
+
net8.0
Library
true
- 11.0
+ latest
@@ -13,4 +13,11 @@
-
\ No newline at end of file
+
+
+
+
+
+
+
+
diff --git a/PpCalculator/PpCalculatorHelpers.cs b/PpCalculator/PpCalculatorHelpers.cs
index 704c7f13..1019aa1d 100644
--- a/PpCalculator/PpCalculatorHelpers.cs
+++ b/PpCalculator/PpCalculatorHelpers.cs
@@ -19,20 +19,20 @@ private PpCalculatorHelpers() { }
/// 3 = Mania
///
///
- public static PpCalculator GetPpCalculator(int rulesetId)
+ public static PpCalculator GetPpCalculator(int rulesetId, ITimedAttributesProvider timedAttributesProvider = null)
{
switch (rulesetId)
{
default:
throw new ArgumentException("Invalid ruleset ID provided.");
case 0:
- return new OsuCalculator();
+ return new OsuCalculator(timedAttributesProvider);
case 1:
- return new TaikoCalculator();
+ return new TaikoCalculator(timedAttributesProvider);
case 2:
- return new CtbCalculator();
+ return new CtbCalculator(timedAttributesProvider);
case 3:
- return new ManiaCalculator();
+ return new ManiaCalculator(timedAttributesProvider);
}
}
@@ -43,12 +43,12 @@ public static PpCalculator GetPpCalculator(int rulesetId)
///
///
///
- public static PpCalculator GetPpCalculator(int rulesetId, PpCalculator ppCalculator)
+ public static PpCalculator GetPpCalculator(int rulesetId, PpCalculator ppCalculator, ITimedAttributesProvider timedAttributesProvider = null)
{
if (rulesetId == ppCalculator?.RulesetId)
return ppCalculator;
- return GetPpCalculator(rulesetId);
+ return GetPpCalculator(rulesetId, timedAttributesProvider);
}
///
@@ -59,20 +59,20 @@ public static PpCalculator GetPpCalculator(int rulesetId, PpCalculator ppCalcula
/// .osu file to read
/// Existing instance, if any.
///
- public static PpCalculator GetPpCalculator(int rulesetId, string file, PpCalculator ppCalculator)
- => InternalGetPpCalculator(rulesetId, file, ppCalculator, 0);
+ public static PpCalculator GetPpCalculator(int rulesetId, string file, PpCalculator ppCalculator, ITimedAttributesProvider timedAttributesProvider = null)
+ => InternalGetPpCalculator(rulesetId, file, ppCalculator, 0, timedAttributesProvider: timedAttributesProvider);
private static PpCalculator InternalGetPpCalculator(int rulesetId, string file, PpCalculator ppCalculator,
- int retryCount, ProcessorWorkingBeatmap workingBeatmap = null)
+ int retryCount, ITimedAttributesProvider timedAttributesProvider = null, ProcessorWorkingBeatmap workingBeatmap = null)
{
if (rulesetId != ppCalculator?.RulesetId)
- ppCalculator = GetPpCalculator(rulesetId);
+ ppCalculator = GetPpCalculator(rulesetId, timedAttributesProvider);
try
{
workingBeatmap ??= new ProcessorWorkingBeatmap(file);
//Check if picked ruleset is valid for loaded beatmap
if (GetRulesetId(workingBeatmap.BeatmapInfo.Ruleset.OnlineID, rulesetId) != rulesetId)
- return InternalGetPpCalculator(workingBeatmap.BeatmapInfo.Ruleset.OnlineID, file, ppCalculator, retryCount, workingBeatmap);
+ return InternalGetPpCalculator(workingBeatmap.BeatmapInfo.Ruleset.OnlineID, file, ppCalculator, retryCount, timedAttributesProvider: timedAttributesProvider, workingBeatmap: workingBeatmap);
ppCalculator?.PreProcess(workingBeatmap);
}
@@ -80,7 +80,7 @@ private static PpCalculator InternalGetPpCalculator(int rulesetId, string file,
{
//file is being used by another process..
if (retryCount < 5)
- return InternalGetPpCalculator(rulesetId, file, ppCalculator, ++retryCount);
+ return InternalGetPpCalculator(rulesetId, file, ppCalculator, ++retryCount, timedAttributesProvider: timedAttributesProvider);
}
return ppCalculator;
diff --git a/PpCalculator/Strain/StrainValuesCalculator.CapturingCatchDifficultyCalculator.cs b/PpCalculator/Strain/StrainValuesCalculator.CapturingCatchDifficultyCalculator.cs
new file mode 100644
index 00000000..a36bf1fb
--- /dev/null
+++ b/PpCalculator/Strain/StrainValuesCalculator.CapturingCatchDifficultyCalculator.cs
@@ -0,0 +1,26 @@
+#nullable enable
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Catch.Difficulty;
+using osu.Game.Rulesets.Difficulty.Skills;
+using osu.Game.Rulesets.Mods;
+
+namespace PpCalculator;
+
+public static partial class StrainValuesCalculator
+{
+ private sealed class CapturingCatchDifficultyCalculator : CatchDifficultyCalculator
+ {
+ public Skill[] CapturedSkills { get; private set; } = [];
+
+ public CapturingCatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap)
+ {
+ }
+
+ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods)
+ {
+ CapturedSkills = base.CreateSkills(beatmap, mods);
+ return CapturedSkills;
+ }
+ }
+}
diff --git a/PpCalculator/Strain/StrainValuesCalculator.CapturingManiaDifficultyCalculator.cs b/PpCalculator/Strain/StrainValuesCalculator.CapturingManiaDifficultyCalculator.cs
new file mode 100644
index 00000000..50ea5b1b
--- /dev/null
+++ b/PpCalculator/Strain/StrainValuesCalculator.CapturingManiaDifficultyCalculator.cs
@@ -0,0 +1,27 @@
+#nullable enable
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Difficulty.Skills;
+using osu.Game.Rulesets.Mania.Difficulty;
+using osu.Game.Rulesets.Mods;
+using System;
+
+namespace PpCalculator;
+
+public static partial class StrainValuesCalculator
+{
+ private sealed class CapturingManiaDifficultyCalculator : ManiaDifficultyCalculator
+ {
+ public Skill[] CapturedSkills { get; private set; } = [];
+
+ public CapturingManiaDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap)
+ {
+ }
+
+ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods)
+ {
+ CapturedSkills = base.CreateSkills(beatmap, mods);
+ return CapturedSkills;
+ }
+ }
+}
diff --git a/PpCalculator/Strain/StrainValuesCalculator.CapturingOsuDifficultyCalculator.cs b/PpCalculator/Strain/StrainValuesCalculator.CapturingOsuDifficultyCalculator.cs
new file mode 100644
index 00000000..e6d7626a
--- /dev/null
+++ b/PpCalculator/Strain/StrainValuesCalculator.CapturingOsuDifficultyCalculator.cs
@@ -0,0 +1,26 @@
+#nullable enable
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Difficulty.Skills;
+using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.Osu.Difficulty;
+
+namespace PpCalculator;
+
+public static partial class StrainValuesCalculator
+{
+ private sealed class CapturingOsuDifficultyCalculator : OsuDifficultyCalculator
+ {
+ public Skill[] CapturedSkills { get; private set; } = [];
+
+ public CapturingOsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap)
+ {
+ }
+
+ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods)
+ {
+ CapturedSkills = base.CreateSkills(beatmap, mods);
+ return CapturedSkills;
+ }
+ }
+}
diff --git a/PpCalculator/Strain/StrainValuesCalculator.CapturingTaikoDifficultyCalculator.cs b/PpCalculator/Strain/StrainValuesCalculator.CapturingTaikoDifficultyCalculator.cs
new file mode 100644
index 00000000..a505f35b
--- /dev/null
+++ b/PpCalculator/Strain/StrainValuesCalculator.CapturingTaikoDifficultyCalculator.cs
@@ -0,0 +1,26 @@
+#nullable enable
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Difficulty.Skills;
+using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.Taiko.Difficulty;
+
+namespace PpCalculator;
+
+public static partial class StrainValuesCalculator
+{
+ private sealed class CapturingTaikoDifficultyCalculator : TaikoDifficultyCalculator
+ {
+ public Skill[] CapturedSkills { get; private set; } = [];
+
+ public CapturingTaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap)
+ {
+ }
+
+ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods)
+ {
+ CapturedSkills = base.CreateSkills(beatmap, mods);
+ return CapturedSkills;
+ }
+ }
+}
diff --git a/PpCalculator/Strain/StrainValuesCalculator.cs b/PpCalculator/Strain/StrainValuesCalculator.cs
new file mode 100644
index 00000000..068049a5
--- /dev/null
+++ b/PpCalculator/Strain/StrainValuesCalculator.cs
@@ -0,0 +1,101 @@
+#nullable enable
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Difficulty;
+using osu.Game.Rulesets.Difficulty.Skills;
+using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.Objects;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace PpCalculator;
+
+public static partial class StrainValuesCalculator
+{
+ public static Dictionary GetStrains(Ruleset ruleset, IWorkingBeatmap workingBeatmap, IReadOnlyList mods, int targetAmount)
+ {
+ DifficultyCalculator calculator = ruleset.RulesetInfo.OnlineID switch
+ {
+ 0 => new CapturingOsuDifficultyCalculator(ruleset.RulesetInfo, workingBeatmap),
+ 1 => new CapturingTaikoDifficultyCalculator(ruleset.RulesetInfo, workingBeatmap),
+ 2 => new CapturingCatchDifficultyCalculator(ruleset.RulesetInfo, workingBeatmap),
+ 3 => new CapturingManiaDifficultyCalculator(ruleset.RulesetInfo, workingBeatmap),
+ _ => throw new ArgumentException($"No strain graph available for ruleset \"{ruleset.RulesetInfo.OnlineID}\""),
+ };
+
+ Func capturedSkillsProvider = calculator switch
+ {
+ CapturingOsuDifficultyCalculator osuCalculator => () => osuCalculator.CapturedSkills,
+ CapturingTaikoDifficultyCalculator taikoCalculator => () => taikoCalculator.CapturedSkills,
+ CapturingCatchDifficultyCalculator catchCalculator => () => catchCalculator.CapturedSkills,
+ CapturingManiaDifficultyCalculator maniaCalculator => () => maniaCalculator.CapturedSkills,
+ _ => throw new InvalidOperationException("calculator is missing skill capture"),
+ };
+
+ _ = calculator.Calculate(mods.ToArray());
+ return BuildStrains(capturedSkillsProvider(), workingBeatmap.Beatmap.HitObjects, targetAmount);
+ }
+
+ private static Dictionary BuildStrains(Skill[] capturedSkills, IReadOnlyList hitObjects, int targetAmount)
+ {
+ if (capturedSkills.Length == 0)
+ {
+ return [];
+ }
+
+ double[][] normalizedSkills = capturedSkills
+ .Select(Normalize)
+ .Where(normalized => normalized != null)
+ .ToArray()!;
+
+ if (normalizedSkills.Length == 0)
+ {
+ return [];
+ }
+
+ double[] times = [.. hitObjects.Select(hitObject => hitObject.GetEndTime())];
+ double interval = (times[^1] + 1) / targetAmount;
+ double[] bucketSums = new double[targetAmount];
+ int[] bucketCounts = new int[targetAmount];
+ int objectCount = Math.Min(times.Length, normalizedSkills.Min(normalizedSkill => normalizedSkill.Length));
+
+ for (int objectIndex = 0; objectIndex < objectCount; objectIndex++)
+ {
+ double value = 0;
+ foreach (double[] normalizedSkill in normalizedSkills)
+ {
+ value = Math.Max(value, normalizedSkill[objectIndex]);
+ }
+
+ int bucket = Math.Min(targetAmount - 1, (int)(times[objectIndex] / interval));
+ bucketSums[bucket] += value;
+ bucketCounts[bucket]++;
+ }
+
+ Dictionary strains = new(targetAmount);
+ for (int bucket = 0; bucket < targetAmount; bucket++)
+ {
+ strains[(int)(bucket * interval)] = bucketCounts[bucket] > 0 ? bucketSums[bucket] / bucketCounts[bucket] : 0;
+ }
+
+ return strains;
+ }
+
+ private static double[]? Normalize(Skill skill)
+ {
+ IReadOnlyList values = skill.GetObjectDifficulties();
+ if (values.Count == 0)
+ {
+ return null;
+ }
+
+ double max = values.Max();
+ if (max <= 0)
+ {
+ return null;
+ }
+
+ return [.. values.Select(value => value / max)];
+ }
+}
diff --git a/PpCalculator/TaikoCalculator.cs b/PpCalculator/TaikoCalculator.cs
index b79c3b09..8caf7e77 100644
--- a/PpCalculator/TaikoCalculator.cs
+++ b/PpCalculator/TaikoCalculator.cs
@@ -12,6 +12,9 @@ namespace PpCalculator
public class TaikoCalculator : PpCalculator
{
protected override Ruleset Ruleset { get; } = new TaikoRuleset();
+ public TaikoCalculator() { }
+ public TaikoCalculator(ITimedAttributesProvider timedAttributesProvider = null) : base(timedAttributesProvider) { }
+
protected override int GetMaxCombo(IReadOnlyList hitObjects) =>
hitObjects.OfType().Count();
diff --git a/PpCalculatorTests/TimedAttributesCacheTests.cs b/PpCalculatorTests/TimedAttributesCacheTests.cs
new file mode 100644
index 00000000..5e37094c
--- /dev/null
+++ b/PpCalculatorTests/TimedAttributesCacheTests.cs
@@ -0,0 +1,226 @@
+using NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Difficulty;
+using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.Osu;
+using osu.Game.Rulesets.Osu.Difficulty;
+using osu.Game.Rulesets.Taiko;
+using PpCalculator;
+using System.Collections.Concurrent;
+using BasePpCalculator = PpCalculator.PpCalculator;
+
+namespace PpCalculatorTests;
+
+[TestFixture]
+public class TimedAttributesCacheTests
+{
+ private static string tempDatabasePath() => Path.Combine(Path.GetTempPath(), $"pp_cache_test_{Guid.NewGuid():N}.sqlite");
+
+ private static readonly OsuRuleset sharedRuleset = new();
+ private static readonly ProcessorWorkingBeatmap sharedWorkingBeatmap = new(PpCalculatorTests.GetMapPath(5494538));
+
+ private static BasePpCalculator CreateCalculator(int rulesetId, ITimedAttributesProvider? timedAttributesProvider) => rulesetId switch
+ {
+ 0 => new OsuCalculator(timedAttributesProvider),
+ 1 => new TaikoCalculator(timedAttributesProvider),
+ 2 => new CtbCalculator(timedAttributesProvider),
+ 3 => new ManiaCalculator(timedAttributesProvider),
+ _ => throw new ArgumentException($"Invalid ruleset ID {rulesetId}"),
+ };
+
+ private static Ruleset CreateRuleset(int rulesetId) => rulesetId switch
+ {
+ 0 => new OsuRuleset(),
+ 1 => new TaikoRuleset(),
+ _ => throw new ArgumentException($"Invalid ruleset ID {rulesetId}"),
+ };
+
+ private static double CalculatePp(BasePpCalculator ppCalculator, int mapId, string mods)
+ {
+ ppCalculator.PreProcess(PpCalculatorTests.GetMapPath(mapId));
+ ppCalculator.Mods = mods.Split(new[] { "," }, StringSplitOptions.RemoveEmptyEntries);
+ ppCalculator.UseScoreMultiplier = false;
+ return ppCalculator.Calculate(CancellationToken.None).Total;
+ }
+
+ private sealed class FakeTimedAttributesProvider : ITimedAttributesProvider
+ {
+ public int ComputeCount { get; private set; }
+
+ public List GetTimedAttributes(IWorkingBeatmap workingBeatmap, Ruleset ruleset,
+ IReadOnlyList mods, bool isFullBeatmap, CancellationToken cancellationToken)
+ {
+ ComputeCount++;
+ return [new(0, new OsuDifficultyAttributes { StarRating = expectedStarRating(mods) })];
+ }
+
+ public static double expectedStarRating(IReadOnlyList mods)
+ => mods.Sum(mod => mod.Acronym.Sum(character => character));
+ }
+
+ private static Mod[] ModsFor(params string[] acronyms)
+ => acronyms
+ .Select(acronym => sharedRuleset.AllMods.First(mod => string.Equals(mod.Acronym, acronym, StringComparison.OrdinalIgnoreCase)).CreateInstance())
+ .ToArray();
+
+ private static List GetTimedAttributes(CachedTimedAttributesProvider provider, params string[] mods)
+ => provider.GetTimedAttributes(
+ sharedWorkingBeatmap,
+ sharedRuleset,
+ ModsFor(mods.SelectMany(combination => combination.Split(',', StringSplitOptions.RemoveEmptyEntries)).ToArray()),
+ true,
+ CancellationToken.None);
+
+ [Test]
+ [TestCase(5494538, "", 0)]
+ [TestCase(5494538, "HD", 0)]
+ public void CachedPpEqualsUncachedPp(int mapId, string mods, int rulesetId)
+ {
+ double uncachedPp = CalculatePp(CreateCalculator(rulesetId, null), mapId, mods);
+ Assert.That(uncachedPp, Is.GreaterThan(0), "reference calculation failed");
+
+ using CachedTimedAttributesProvider provider = new(tempDatabasePath(), 2000);
+
+ double firstCachedPp = CalculatePp(CreateCalculator(rulesetId, provider), mapId, mods);
+ Assert.That(provider.CacheStores, Is.EqualTo(1), "first calculation must store");
+ Assert.That(provider.CacheHits, Is.EqualTo(0));
+
+ provider.FlushPendingEntries();
+
+ double secondCachedPp = CalculatePp(CreateCalculator(rulesetId, provider), mapId, mods);
+ Assert.That(provider.CacheHits, Is.EqualTo(1), "second calculation must hit the cache");
+ Assert.That(provider.CacheStores, Is.EqualTo(1), "cache hit must not store again");
+
+ Assert.That(firstCachedPp, Is.EqualTo(uncachedPp).Within(0.000000001), "cached (miss) pp must equal uncached pp");
+ Assert.That(secondCachedPp, Is.EqualTo(uncachedPp).Within(0.000000001), "cached (hit) pp must equal uncached pp");
+ }
+
+ [Test]
+ [TestCase(5494538, 0, "HD")]
+ [TestCase(5494538, 0, "FL")]
+ [TestCase(5526783, 1, "")]
+ public void SerializationRoundtripIsByteStable(int mapId, int rulesetId, string mods)
+ {
+ Ruleset ruleset = CreateRuleset(rulesetId);
+ ProcessorWorkingBeatmap workingBeatmap = new(PpCalculatorTests.GetMapPath(mapId));
+
+ Mod[] enabledMods = mods.Split(new[] { "," }, StringSplitOptions.RemoveEmptyEntries)
+ .Select(modAcronym => ruleset.AllMods.First(mod => string.Equals(mod.Acronym, modAcronym, StringComparison.OrdinalIgnoreCase)).CreateInstance())
+ .ToArray();
+
+ List computed = CalculatingTimedAttributesProvider.Instance
+ .GetTimedAttributes(workingBeatmap, ruleset, enabledMods, true, CancellationToken.None);
+
+ byte[] firstBlob = DifficultyCache.Serialize(computed);
+ List roundtripped = DifficultyCache.Deserialize(firstBlob);
+ byte[] secondBlob = DifficultyCache.Serialize(roundtripped);
+
+ Assert.That(secondBlob, Is.EqualTo(firstBlob), "serialize→deserialize→serialize must be byte-identical");
+ Assert.That(roundtripped.Last().Attributes.GetType(), Is.EqualTo(computed.Last().Attributes.GetType()), "concrete attributes type must survive the roundtrip");
+ }
+
+ [Test]
+ public void DifferentAlgorithmVersionMissesAndRecomputes()
+ {
+ string databaseFilePath = tempDatabasePath();
+ FakeTimedAttributesProvider versionOneFake = new();
+ FakeTimedAttributesProvider versionTwoFake = new();
+
+ using CachedTimedAttributesProvider versionOneProvider = new(databaseFilePath, 2000, "test-v1", timedAttributesProvider: versionOneFake, minimumGenerationMilliseconds: 0);
+ using CachedTimedAttributesProvider versionTwoProvider = new(databaseFilePath, 2000, "test-v2", timedAttributesProvider: versionTwoFake, minimumGenerationMilliseconds: 0);
+
+ _ = GetTimedAttributes(versionOneProvider, "HD");
+ versionOneProvider.FlushPendingEntries();
+
+ _ = GetTimedAttributes(versionTwoProvider, "HD");
+ versionTwoProvider.FlushPendingEntries();
+
+ Assert.That(versionOneProvider.CacheStores, Is.EqualTo(1));
+ Assert.That(versionOneProvider.CacheHits, Is.EqualTo(0));
+ Assert.That(versionTwoProvider.CacheStores, Is.EqualTo(1), "new algorithm version must recompute, not reuse v1 attributes");
+ Assert.That(versionTwoProvider.CacheHits, Is.EqualTo(0));
+ Assert.That(versionOneFake.ComputeCount, Is.EqualTo(1));
+ Assert.That(versionTwoFake.ComputeCount, Is.EqualTo(1));
+
+ _ = GetTimedAttributes(versionOneProvider, "HD");
+ Assert.That(versionOneProvider.CacheHits, Is.EqualTo(1));
+ Assert.That(versionOneFake.ComputeCount, Is.EqualTo(1), "hit must not recompute");
+ }
+
+ [Test]
+ public void DifferentModsProduceDistinctCacheEntries()
+ {
+ FakeTimedAttributesProvider fake = new();
+ using CachedTimedAttributesProvider provider = new(tempDatabasePath(), 2000, timedAttributesProvider: fake, minimumGenerationMilliseconds: 0);
+
+ double hiddenStarRating = GetTimedAttributes(provider, "HD").Last().Attributes.StarRating;
+ provider.FlushPendingEntries();
+ double hardRockStarRating = GetTimedAttributes(provider, "HR").Last().Attributes.StarRating;
+ provider.FlushPendingEntries();
+ double hiddenAgainStarRating = GetTimedAttributes(provider, "HD").Last().Attributes.StarRating;
+
+ Assert.That(provider.CacheStores, Is.EqualTo(2), "two mod combinations must produce two entries");
+ Assert.That(provider.CacheHits, Is.EqualTo(1), "repeated mod combination must hit");
+ Assert.That(fake.ComputeCount, Is.EqualTo(2));
+
+ Assert.That(hiddenStarRating, Is.EqualTo(FakeTimedAttributesProvider.expectedStarRating(ModsFor("HD"))));
+ Assert.That(hiddenAgainStarRating, Is.EqualTo(hiddenStarRating));
+ Assert.That(hardRockStarRating, Is.EqualTo(FakeTimedAttributesProvider.expectedStarRating(ModsFor("HR"))));
+ Assert.That(hardRockStarRating, Is.Not.EqualTo(hiddenStarRating));
+ }
+
+ [Test]
+ public void ConcurrentCalculationsOnSharedProviderSucceed()
+ {
+ string[] modCombinations = ["HD", "HR", "DT", "HT", "EZ", "NF"];
+ FakeTimedAttributesProvider fake = new();
+ using CachedTimedAttributesProvider provider = new(tempDatabasePath(), 2000, timedAttributesProvider: fake, minimumGenerationMilliseconds: 0);
+
+ ConcurrentDictionary starRatingByMods = new();
+ ConcurrentDictionary exceptionsByMods = new();
+ Task.WaitAll(modCombinations.Select(combination => Task.Run(() =>
+ {
+ try
+ {
+ starRatingByMods[combination] = GetTimedAttributes(provider, combination).Last().Attributes.StarRating;
+ }
+ catch (Exception exception)
+ {
+ exceptionsByMods[combination] = exception;
+ }
+ })).ToArray());
+
+ Assert.That(exceptionsByMods, Is.Empty, "concurrent calculations must not throw");
+ foreach (string combination in modCombinations)
+ {
+ Assert.That(starRatingByMods[combination], Is.EqualTo(FakeTimedAttributesProvider.expectedStarRating(ModsFor(combination))), $"cached star rating for mods '{combination}' must equal computed");
+ }
+
+ Assert.That(provider.CacheStores, Is.EqualTo(modCombinations.Length));
+ }
+
+ [Test]
+ public void StoredEntriesAreReadableWithoutExplicitFlush()
+ {
+ string databaseFilePath = tempDatabasePath();
+ FakeTimedAttributesProvider fake = new();
+ using CachedTimedAttributesProvider storingProvider = new(databaseFilePath, 2000, timedAttributesProvider: fake, minimumGenerationMilliseconds: 0);
+
+ _ = GetTimedAttributes(storingProvider, "HD");
+ _ = GetTimedAttributes(storingProvider, "HD");
+ Assert.That(storingProvider.CacheHits, Is.EqualTo(1), "stored entry must be readable before any flush");
+ string[] modCombinations = { "HR", "DT", "HT", "EZ", "NF", "FL", "SD", "PF", "HD,HR" };
+ foreach (string combination in modCombinations)
+ {
+ _ = GetTimedAttributes(storingProvider, combination);
+ }
+
+ FakeTimedAttributesProvider freshFake = new();
+ using CachedTimedAttributesProvider freshProvider = new(databaseFilePath, 2000, timedAttributesProvider: freshFake, minimumGenerationMilliseconds: 0);
+ double persistedStarRating = GetTimedAttributes(freshProvider, "HD").Last().Attributes.StarRating;
+ Assert.That(freshProvider.CacheHits, Is.EqualTo(1), "threshold flush must persist entries without an explicit flush call");
+ Assert.That(freshFake.ComputeCount, Is.EqualTo(0));
+ Assert.That(persistedStarRating, Is.EqualTo(FakeTimedAttributesProvider.expectedStarRating(ModsFor("HD"))));
+ }
+}
diff --git a/PpCalculatorTypes/IPpCalculator.cs b/PpCalculatorTypes/IPpCalculator.cs
index be5c8526..c8812158 100644
--- a/PpCalculatorTypes/IPpCalculator.cs
+++ b/PpCalculatorTypes/IPpCalculator.cs
@@ -1,4 +1,4 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Threading;
namespace PpCalculatorTypes
@@ -19,6 +19,8 @@ public interface IPpCalculator
int? Gekis { get; set; }
int RulesetId { get; }
double BeatmapLength { get; }
+
+ Dictionary GetStrainValues(int targetAmount);
bool UseScoreMultiplier { get; set; }
///
/// Whenever last call was used with limited start/end times, therefore affected Difficulty results
diff --git a/StreamCompanion.Common/Extensions/PpCalculatorExtensions.cs b/StreamCompanion.Common/Extensions/PpCalculatorExtensions.cs
deleted file mode 100644
index 34a979e4..00000000
--- a/StreamCompanion.Common/Extensions/PpCalculatorExtensions.cs
+++ /dev/null
@@ -1,77 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Threading;
-using CollectionManager.Enums;
-using PpCalculatorTypes;
-
-namespace StreamCompanion.Common
-{
- public static class PpCalculatorExtensions
- {
- public static Dictionary CalculateStrains(this IPpCalculator ppCalculator,
- CancellationToken cancellationToken, int? targetAmount = null)
- {
- if (ppCalculator.RulesetId == (int)PlayMode.OsuMania)
- return CalculateHitObjectDensity(ppCalculator, cancellationToken, targetAmount);
-
- var strains = new Dictionary(targetAmount ?? 200);
- if (ppCalculator == null)
- return strains;
-
- ppCalculator.Score = 1_000_000;
- var mapLength = ppCalculator.BeatmapLength;
- var (interval, strainLength) = CalculateStrainParameters(ppCalculator, targetAmount);
- var time = 0;
-
- while (time + strainLength / 2 < mapLength)
- {
- cancellationToken.ThrowIfCancellationRequested();
- var ppValue = ppCalculator.Calculate(cancellationToken, time, time + strainLength).Total;
- if (double.IsNaN(ppValue) || ppValue < 0)
- ppValue = 0;
- else if (ppValue > 2000)
- ppValue = 2000; //lets not freeze everything with aspire/fancy 100* maps
-
- strains.Add(time, ppValue);
- time += interval;
- }
-
- return strains;
- }
-
- public static Dictionary CalculateHitObjectDensity(this IPpCalculator ppCalculator,
- CancellationToken cancellationToken, int? targetAmount = null)
- {
- if (ppCalculator == null)
- return null;
-
- var strains = new Dictionary(targetAmount ?? 200);
- var (interval, _) = CalculateStrainParameters(ppCalculator, targetAmount);
- var graphValues = ppCalculator.CalculateProgressGraphValues(cancellationToken, targetAmount ?? 200);
- var time = 0;
- foreach (var graphValue in graphValues)
- {
- strains[time] = graphValue;
- time += interval;
- }
-
- return strains;
- }
-
- private static (int Interval, int StrainLength) CalculateStrainParameters(IPpCalculator ppCalculator, int? targetAmount = null)
- {
- var mapLength = ppCalculator.BeatmapLength;
- //data for 2min map
- var strainLength = 5000;
- var interval = 1500;
-
- if (targetAmount.HasValue)
- {
- strainLength = Convert.ToInt32(Math.Floor(strainLength * (mapLength / 120_000d)));
- interval = Convert.ToInt32(Math.Ceiling(mapLength - (strainLength / 2d)) / targetAmount) + 1;
- }
-
- return (interval, strainLength);
- }
- }
-}
\ No newline at end of file
diff --git a/buildRelease-CI.cmd b/buildRelease-CI.cmd
index 948edd19..f2e36aed 100644
--- a/buildRelease-CI.cmd
+++ b/buildRelease-CI.cmd
@@ -27,14 +27,19 @@ rm ./Output/Plugins/CollectionManager.dll
rm ./Output/Plugins/System.*
cd ..
+if /i "%~1"=="zips-only" goto :zips
+REM optional version (yyMMdd.HH, e.g. 260724.21) forwarded to InnoSetup as AppVersion
+set "isccVersionArg="
+if not "%~1"=="" set "isccVersionArg=/DAppVersion=%~1"
REM clean installer folder
rm ./innoSetup/Output/*
REM create installer (Inno Setup 6)
-"C:\Program Files (x86)\Inno Setup 6\ISCC.exe" ".\innoSetup\setupScript.iss"
+"C:\Program Files (x86)\Inno Setup 6\ISCC.exe" %isccVersionArg% ".\innoSetup\setupScript.iss"
7z a .\build\ingameOverlay.zip .\build\Release_unsafe\*
-"C:\Program Files (x86)\Inno Setup 6\ISCC.exe" ".\innoSetup\osuOverlayScript.iss"
-"C:\Program Files (x86)\Inno Setup 6\ISCC.exe" ".\innoSetup\browserOverlayScript.iss"
+"C:\Program Files (x86)\Inno Setup 6\ISCC.exe" %isccVersionArg% ".\innoSetup\osuOverlayScript.iss"
+"C:\Program Files (x86)\Inno Setup 6\ISCC.exe" %isccVersionArg% ".\innoSetup\browserOverlayScript.iss"
+:zips
type nul > .\build\Output\.portableMode
7z a .\build\StreamCompanion-portable.zip .\build\Output\*
7z a .\build\StreamCompanion-portable-browserOverlay.zip .\build\Release_browserOverlay\*
diff --git a/innoSetup/browserOverlayScript.iss b/innoSetup/browserOverlayScript.iss
index 820e7107..6e7ac6a3 100644
--- a/innoSetup/browserOverlayScript.iss
+++ b/innoSetup/browserOverlayScript.iss
@@ -8,7 +8,13 @@
#define AppId "{F6C83F00-59ED-493E-8310-181BB5B37A03}"
#define FilesRoot "..\build\Release_browserOverlay\"
-#define ApplicationVersion GetFileVersion(FilesRoot +'Plugins\BrowserIngameOverlay.dll')
+#ifdef AppVersion
+ #define ApplicationVersion AppVersion
+ #define BinaryVersion Copy(AppVersion,1,2)+"."+Copy(AppVersion,3,2)+"."+Copy(AppVersion,5,2)+"."+Copy(AppVersion,8,2)
+#else
+ #define ApplicationVersion GetFileVersion(FilesRoot +'Plugins\BrowserIngameOverlay.dll')
+ #define BinaryVersion ApplicationVersion
+#endif
[Setup]
; NOTE: The value of AppId uniquely identifies this application.
; Do not use the same AppId value in installers for other applications.
@@ -18,6 +24,7 @@
AppId={{#AppId}
AppName={#MyAppName}
AppVersion={#ApplicationVersion}
+VersionInfoVersion={#BinaryVersion}
;AppVerName={#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
diff --git a/innoSetup/osuOverlayScript.iss b/innoSetup/osuOverlayScript.iss
index 750f9132..4ea45467 100644
--- a/innoSetup/osuOverlayScript.iss
+++ b/innoSetup/osuOverlayScript.iss
@@ -8,7 +8,13 @@
#define AppId "{F6C83F00-59ED-493E-8310-181BB5B37A03}"
#define FilesRoot "..\build\Release_unsafe\"
-#define ApplicationVersion GetFileVersion(FilesRoot +'Plugins\TextIngameOverlay.dll')
+#ifdef AppVersion
+ #define ApplicationVersion AppVersion
+ #define BinaryVersion Copy(AppVersion,1,2)+"."+Copy(AppVersion,3,2)+"."+Copy(AppVersion,5,2)+"."+Copy(AppVersion,8,2)
+#else
+ #define ApplicationVersion GetFileVersion(FilesRoot +'Plugins\TextIngameOverlay.dll')
+ #define BinaryVersion ApplicationVersion
+#endif
[Setup]
; NOTE: The value of AppId uniquely identifies this application.
; Do not use the same AppId value in installers for other applications.
@@ -16,6 +22,7 @@
AppId={{#AppId}
AppName={#MyAppName}
AppVersion={#ApplicationVersion}
+VersionInfoVersion={#BinaryVersion}
;AppVerName={#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
diff --git a/innoSetup/setupScript.iss b/innoSetup/setupScript.iss
index a2998c8a..98da6ee1 100644
--- a/innoSetup/setupScript.iss
+++ b/innoSetup/setupScript.iss
@@ -8,7 +8,13 @@
#define MyAppExeName "osu!StreamCompanion.exe"
#define FilesRoot "..\build\Output\"
-#define ApplicationVersion GetFileVersion(FilesRoot +'osu!StreamCompanion.exe')
+#ifdef AppVersion
+ #define ApplicationVersion AppVersion
+ #define BinaryVersion Copy(AppVersion,1,2)+"."+Copy(AppVersion,3,2)+"."+Copy(AppVersion,5,2)+"."+Copy(AppVersion,8,2)
+#else
+ #define ApplicationVersion GetFileVersion(FilesRoot +'osu!StreamCompanion.exe')
+ #define BinaryVersion ApplicationVersion
+#endif
[Setup]
; NOTE: The value of AppId uniquely identifies this application.
@@ -17,6 +23,7 @@
AppId={{F6C83F00-59ED-493E-8310-181BB5B37A03}
AppName={#MyAppName}
AppVersion={#ApplicationVersion}
+VersionInfoVersion={#BinaryVersion}
;AppVerName={#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
@@ -66,6 +73,8 @@ Type: files; Name: "{app}\Plugins\CollectionManager.dll"
Type: files; Name: "{app}\Plugins\StreamCompanionTypes.dll"
Type: files; Name: "{app}\Plugins\WindowDataGetter.dll"
Type: files; Name: "{app}\Plugins\OsuSongsFolderWatcher.dll"
+Type: files; Name: "{app}\Plugins\MSNEventSource.dll"
+Type: files; Name: "{app}\Plugins\MSNEventSource.pdb"
[UninstallDelete]
Type: files; Name: "{app}\StreamCompanionCache.db"
diff --git a/osu!StreamCompanion.sln b/osu!StreamCompanion.sln
index 6041bf91..2c32930b 100644
--- a/osu!StreamCompanion.sln
+++ b/osu!StreamCompanion.sln
@@ -5,7 +5,6 @@ MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "osu!StreamCompanion", "osu!StreamCompanion\osu!StreamCompanion.csproj", "{86251162-A899-4498-BF7F-6F18D6C624EA}"
ProjectSection(ProjectDependencies) = postProject
{09464784-372D-495F-9FF3-351DA55B4FD9} = {09464784-372D-495F-9FF3-351DA55B4FD9}
- {0BE726DB-D370-4D9D-988A-2B79AF562283} = {0BE726DB-D370-4D9D-988A-2B79AF562283}
{0C50FF12-C298-424F-8B56-E65BCDDAB159} = {0C50FF12-C298-424F-8B56-E65BCDDAB159}
{16BB58FA-897B-4A75-BEDC-D42428EB8312} = {16BB58FA-897B-4A75-BEDC-D42428EB8312}
{1A6D2DC6-163E-4BF3-8CE3-51ACC38A4A67} = {1A6D2DC6-163E-4BF3-8CE3-51ACC38A4A67}
@@ -56,8 +55,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TcpSocketDataSender", "plug
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PlaysReplacements", "plugins\PlaysReplacements\PlaysReplacements.csproj", "{3D20C5F7-1CFC-4313-8B21-D3FA298F82A9}"
EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MSNEventSource", "plugins\MSNEventSource\MSNEventSource.csproj", "{0BE726DB-D370-4D9D-988A-2B79AF562283}"
-EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OsuMemoryEventSource", "plugins\OsuMemoryEventSource\OsuMemoryEventSource.csproj", "{1A6D2DC6-163E-4BF3-8CE3-51ACC38A4A67}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PpCalculator", "PpCalculator\PpCalculator.csproj", "{3D97E9C4-A884-4664-A40C-F19C4533AA00}"
@@ -221,14 +218,6 @@ Global
{3D20C5F7-1CFC-4313-8B21-D3FA298F82A9}.Release|Any CPU.Build.0 = Release|Any CPU
{3D20C5F7-1CFC-4313-8B21-D3FA298F82A9}.Release|x86.ActiveCfg = Release|Any CPU
{3D20C5F7-1CFC-4313-8B21-D3FA298F82A9}.Release|x86.Build.0 = Release|Any CPU
- {0BE726DB-D370-4D9D-988A-2B79AF562283}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {0BE726DB-D370-4D9D-988A-2B79AF562283}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {0BE726DB-D370-4D9D-988A-2B79AF562283}.Debug|x86.ActiveCfg = Debug|Any CPU
- {0BE726DB-D370-4D9D-988A-2B79AF562283}.Debug|x86.Build.0 = Debug|Any CPU
- {0BE726DB-D370-4D9D-988A-2B79AF562283}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {0BE726DB-D370-4D9D-988A-2B79AF562283}.Release|Any CPU.Build.0 = Release|Any CPU
- {0BE726DB-D370-4D9D-988A-2B79AF562283}.Release|x86.ActiveCfg = Release|Any CPU
- {0BE726DB-D370-4D9D-988A-2B79AF562283}.Release|x86.Build.0 = Release|Any CPU
{1A6D2DC6-163E-4BF3-8CE3-51ACC38A4A67}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1A6D2DC6-163E-4BF3-8CE3-51ACC38A4A67}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1A6D2DC6-163E-4BF3-8CE3-51ACC38A4A67}.Debug|x86.ActiveCfg = Debug|x86
@@ -400,7 +389,6 @@ Global
{32A61CD7-FA30-40CB-8A23-788BBD65D4A7} = {7357CB17-E9D7-493F-BC1B-354F717D7AA3}
{50E3D207-3812-433A-8F9A-757FF55C991A} = {7357CB17-E9D7-493F-BC1B-354F717D7AA3}
{3D20C5F7-1CFC-4313-8B21-D3FA298F82A9} = {7357CB17-E9D7-493F-BC1B-354F717D7AA3}
- {0BE726DB-D370-4D9D-988A-2B79AF562283} = {7357CB17-E9D7-493F-BC1B-354F717D7AA3}
{1A6D2DC6-163E-4BF3-8CE3-51ACC38A4A67} = {7357CB17-E9D7-493F-BC1B-354F717D7AA3}
{3D97E9C4-A884-4664-A40C-F19C4533AA00} = {B0951F6C-E0DC-4242-A32E-0B48711AF12D}
{DDE5F7B5-68C3-4143-8726-0BB91B83EED3} = {B0951F6C-E0DC-4242-A32E-0B48711AF12D}
diff --git a/osu!StreamCompanion/Code/Core/Initializer.cs b/osu!StreamCompanion/Code/Core/Initializer.cs
index b9db72d2..7d23db15 100644
--- a/osu!StreamCompanion/Code/Core/Initializer.cs
+++ b/osu!StreamCompanion/Code/Core/Initializer.cs
@@ -46,20 +46,7 @@ public Initializer(string settingsProfileName)
}
var saver = di.Locate();
-
- if (Settings.Get(_names.Console) && OperatingSystem.IsWindows())
- {
- mainLogger.AddLogger(new ConsoleLogger(Settings));
- }
- else
- {
- mainLogger.AddLogger(new EmptyLogger());
- }
-
- mainLogger.AddLogger(new FileLogger(saver, Settings, mainLogger));
-#if !DEBUG
- mainLogger.AddLogger(new SentryLogger());
-#endif
+ mainLogger.Initialize(Settings, saver);
_pluginManager = new LocalPluginManager(Settings, mainLogger);
foreach (var moduleType in _pluginManager.GetModules())
@@ -78,8 +65,8 @@ public Initializer(string settingsProfileName)
public void Start()
{
_logger.Log("Booting up...", LogLevel.Information);
- _logger.Log($"Stream Companion Version: {Program.ScVersion}", LogLevel.Information);
- _logger.Log($"Running as {(Environment.Is64BitProcess ? "x64" : "x86")} process on .NET {Environment.Version}", LogLevel.Information);
+ _logger.Log("Stream Companion Version: {0}", LogLevel.Information, Program.ScVersion);
+ _logger.Log("Running as {0} process on .NET {1}", LogLevel.Information, Environment.Is64BitProcess ? "x64" : "x86", Environment.Version.ToString());
DiContainer.Container.Locate();
DiContainer.Container.Locate();
@@ -101,6 +88,7 @@ public void Exit()
{
DiContainer.Container.Dispose();
Settings.Save();
+ MainLogger.Instance.Shutdown();
}
}
}
\ No newline at end of file
diff --git a/osu!StreamCompanion/Code/Core/Loggers/ConsoleLogger.cs b/osu!StreamCompanion/Code/Core/Loggers/ConsoleLogger.cs
deleted file mode 100644
index 8e2ae8c7..00000000
--- a/osu!StreamCompanion/Code/Core/Loggers/ConsoleLogger.cs
+++ /dev/null
@@ -1,62 +0,0 @@
-using System;
-using System.CodeDom.Compiler;
-using System.Collections.Generic;
-using System.Diagnostics;
-using System.IO;
-using System.Runtime.Versioning;
-using System.Text;
-using osu_StreamCompanion.Code.Helpers;
-using osu_StreamCompanion.Code.Misc;
-using StreamCompanionTypes;
-using StreamCompanionTypes.Enums;
-using StreamCompanionTypes.Interfaces.Services;
-
-namespace osu_StreamCompanion.Code.Core.Loggers
-{
- [SupportedOSPlatform("windows")]
- class ConsoleLogger : IContextAwareLogger, IDisposable
- {
- private readonly SettingNames _names = SettingNames.Instance;
-
- private readonly ISettings _settings;
- public static Dictionary ContextData { get; } = new();
- private object _lockingObject = new();
-
- public ConsoleLogger(ISettings settings)
- {
- _settings = settings;
- NativeMethods.AllocConsole();
- Console.Title = "StreamCompanion logs";
- Console.SetOut(TextWriter.Synchronized(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }));
-
- if (Console.LargestWindowWidth > 0)
- Console.WindowWidth = Console.LargestWindowWidth - Convert.ToInt32(Console.LargestWindowWidth / 3);
- }
-
-
- public void Dispose()
- {
- NativeMethods.FreeConsole();
- }
-
- public void Log(object logMessage, LogLevel loglvevel, params string[] vals)
- {
- if (_settings.Get(_names.LogLevel) <= loglvevel.GetHashCode())
- {
- if (logMessage is Exception)
- {
- lock (_lockingObject)
- Console.WriteLine(logMessage + Environment.NewLine + string.Join(Environment.NewLine, ContextData));
- }
- else
- Console.WriteLine(logMessage);
- }
- }
-
- public void SetContextData(string key, string value)
- {
- lock (_lockingObject)
- ContextData[key] = value;
- }
- }
-}
diff --git a/osu!StreamCompanion/Code/Core/Loggers/EmptyLogger.cs b/osu!StreamCompanion/Code/Core/Loggers/EmptyLogger.cs
deleted file mode 100644
index c11fb10f..00000000
--- a/osu!StreamCompanion/Code/Core/Loggers/EmptyLogger.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-using StreamCompanionTypes.Enums;
-using StreamCompanionTypes.Interfaces.Services;
-
-namespace osu_StreamCompanion.Code.Core.Loggers
-{
- class EmptyLogger : ILogger
- {
- public void Log(object logMessage, LogLevel loglvevel, params string[] vals)
- {
-
- }
- }
-}
diff --git a/osu!StreamCompanion/Code/Core/Loggers/FileLogger.cs b/osu!StreamCompanion/Code/Core/Loggers/FileLogger.cs
deleted file mode 100644
index 92e472ba..00000000
--- a/osu!StreamCompanion/Code/Core/Loggers/FileLogger.cs
+++ /dev/null
@@ -1,105 +0,0 @@
-using System;
-using System.IO;
-using System.Linq;
-using StreamCompanionTypes;
-using StreamCompanionTypes.DataTypes;
-using StreamCompanionTypes.Enums;
-using StreamCompanionTypes.Interfaces.Services;
-
-namespace osu_StreamCompanion.Code.Core.Loggers
-{
- public class FileLogger : ILogger
- {
- private readonly SettingNames _names = SettingNames.Instance;
- public static ConfigEntry LogsRetentionDays = new ConfigEntry("LogsRetentionDays", 14);
- private ISaver _saver;
- private readonly Settings _settings;
- private readonly ILogger _parentLogger;
- DateTime startTime = DateTime.Today;
- private string _logsSaveLocation = String.Empty;
- private string _saveDirectory = String.Empty;
- private object _lockingObject = new object();
-
- private readonly string _logsSaveFolderName = @"Logs\";
-
- internal FileLogger(ISaver saver, Settings settings, ILogger parentLogger = null)
- {
- _saver = saver;
- _settings = settings;
- _parentLogger = parentLogger;
-
- CreateLogsDirectory();
- CleanupLogs();
- }
-
- private void CleanupLogs()
- {
- try
- {
- var deleteDateThreshold = DateTime.UtcNow.AddDays(-_settings.Get(LogsRetentionDays));
- var logFilePaths = Directory.GetFiles(_saveDirectory, "*.txt", SearchOption.TopDirectoryOnly);
- var logFiles = logFilePaths.Select(l => new FileInfo(l))
- .Where(f => f.LastWriteTimeUtc < deleteDateThreshold);
-
- foreach (var logFile in logFiles)
- {
- logFile.Delete();
- }
- }
- catch (Exception ex)
- {
- _parentLogger?.Log(ex, LogLevel.Error);
- }
- }
-
- private void CreateLogsDirectory()
- {
- _saveDirectory = Path.Combine(_saver.SaveDirectory, _logsSaveFolderName);
-
- if (!Directory.Exists(_saveDirectory))
- Directory.CreateDirectory(_saveDirectory);
-
- _logsSaveLocation = Path.Combine(_saveDirectory, $"{startTime:yyyy-MM-dd}.txt");
- }
-
- public void Log(object logMessage, LogLevel loglvevel, params string[] vals)
- => InternalLog(logMessage, loglvevel, 0, vals);
-
- private void InternalLog(object logMessage, LogLevel loglvevel, int attemptCount, params string[] vals)
- {
- try
- {
- if (logMessage is Exception ex && ex.Data.Contains("Logger") &&
- ex.Data["Logger"].ToString() == nameof(FileLogger))
- return;
-
- if (_settings.Get(_names.LogLevel) <= loglvevel.GetHashCode())
- {
- lock (_lockingObject)
- {
- File.AppendAllText(_logsSaveLocation, logMessage + Environment.NewLine);
- }
- }
- }
- catch (Exception exception)
- {
- if (attemptCount >= 3)
- {
- exception.Data["Logger"] = nameof(FileLogger);
- _parentLogger?.Log(exception, LogLevel.Error);
- return;
- }
-
- lock (_lockingObject)
- CreateLogsDirectory();
-
- InternalLog(logMessage, loglvevel, ++attemptCount, vals);
- }
- }
-
- public void SetSaveHandle(ISaver saver)
- {
- _saver = saver;
- }
- }
-}
\ No newline at end of file
diff --git a/osu!StreamCompanion/Code/Core/Loggers/MainLogger.cs b/osu!StreamCompanion/Code/Core/Loggers/MainLogger.cs
index 35468a55..5e9f6b5f 100644
--- a/osu!StreamCompanion/Code/Core/Loggers/MainLogger.cs
+++ b/osu!StreamCompanion/Code/Core/Loggers/MainLogger.cs
@@ -1,94 +1,228 @@
-using System;
-using System.Collections.Generic;
-using osu_StreamCompanion.Code.Helpers;
+using osu_StreamCompanion.Code.Misc;
+using Serilog;
+using Serilog.Core;
+using Serilog.Events;
+using Serilog.Parsing;
+using StreamCompanionTypes;
+using StreamCompanionTypes.DataTypes;
using StreamCompanionTypes.Enums;
using StreamCompanionTypes.Interfaces.Services;
+using System;
+using System.Collections.Concurrent;
+using System.IO;
+using System.Linq;
+using System.Runtime.Versioning;
-namespace osu_StreamCompanion.Code.Core.Loggers
+namespace osu_StreamCompanion.Code.Core.Loggers;
+
+internal class MainLogger : IContextAwareLogger
{
- internal class MainLogger : IContextAwareLogger
+ public static MainLogger Instance = new();
+
+ public static ConfigEntry LogsRetentionDays = new("LogsRetentionDays", 14);
+
+ private readonly ConcurrentDictionary contextData = new();
+ private Settings settings;
+ private string logsDirectory;
+ private static readonly MessageTemplateParser messageTemplateParser = new();
+ private LoggingLevelSwitch levelSwitch;
+ private volatile Logger serilogLogger;
+ private bool consoleAllocated;
+ private readonly object pipelineLock = new();
+
+ private MainLogger()
+ { }
+
+ public void Initialize(Settings settings, ISaver saver)
{
- public static MainLogger Instance = new MainLogger();
- private List _loggers = new List();
- public IReadOnlyList Loggers => _loggers.AsReadOnly();
+ this.settings = settings;
+ logsDirectory = Path.Combine(saver.SaveDirectory, "Logs");
+ _ = Directory.CreateDirectory(logsDirectory);
+ CleanupLegacyLogs();
- private MainLogger()
- { }
+ levelSwitch = new LoggingLevelSwitch((LogEventLevel)settings.Get(SettingNames.Instance.LogLevel));
- public void AddLogger(ILogger logger)
+ if (settings.Get(SettingNames.Instance.Console) && OperatingSystem.IsWindows())
{
- _loggers.Add(logger);
+ AllocateConsole();
+ consoleAllocated = true;
}
- public void RemoveLogger(ILogger logger)
- {
- if (_loggers.Contains(logger))
- {
- if (logger is IDisposable disposableLogger)
- disposableLogger.Dispose();
+ serilogLogger = BuildLogger(consoleAllocated);
+ settings.SettingUpdated += OnSettingUpdated;
+ Serilog.Debugging.SelfLog.Enable(message => Console.Error.WriteLine(message));
+ }
- _loggers.Remove(logger);
- }
- }
+ private Logger BuildLogger(bool withConsole)
+ {
+ LoggerConfiguration configuration = new LoggerConfiguration()
+ .MinimumLevel.ControlledBy(levelSwitch)
+ .WriteTo.File(
+ Path.Combine(logsDirectory, "sc.txt"),
+ outputTemplate: "{Message:lj}{NewLine}",
+ rollingInterval: RollingInterval.Day,
+ retainedFileCountLimit: null,
+ retainedFileTimeLimit: TimeSpan.FromDays(settings.Get(LogsRetentionDays)),
+ buffered: true,
+ flushToDiskInterval: TimeSpan.FromSeconds(1));
- public void Log(object logMessage, LogLevel logLevel, string pluginName, params string[] vals)
+ if (withConsole)
{
- var (message, prefix) = GetPrefix(logMessage.ToString(), logLevel, pluginName);
- InternalLog(logMessage is Exception ? logMessage : message, logLevel, prefix, vals);
+ _ = configuration.WriteTo.Console(outputTemplate: "{Message:lj}{NewLine}");
}
- private (string NewMessage, string Prefix) GetPrefix(string logMessage, LogLevel logLevel, string pluginName)
- {
- string message = logMessage.ToString();
- string prefix = string.Empty;
+ return configuration.CreateLogger();
+ }
- while (message.StartsWith(">"))
+ private void OnSettingUpdated(object sender, SettingUpdated eventArgs)
+ {
+ if (eventArgs.Name == SettingNames.Instance.LogLevel.Name)
+ {
+ levelSwitch.MinimumLevel = (LogEventLevel)settings.Get(SettingNames.Instance.LogLevel);
+ }
+ else if (eventArgs.Name == SettingNames.Instance.Console.Name)
+ {
+ if (settings.Get(SettingNames.Instance.Console))
{
- prefix += "\t";
- message = message.Substring(1);
+ EnableConsole();
}
+ else
+ {
+ DisableConsole();
+ }
+ }
+ }
- pluginName = pluginName != null ? $" [{pluginName,20}]" : string.Empty;
- prefix = string.Format(@"[{0}] {1:T}{2} - {3}", logLevel.ToString().Substring(0, 3), DateTime.Now, pluginName, prefix);
+ public void EnableConsole()
+ {
+ if (consoleAllocated || !OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ AllocateConsole();
+ RebuildLogger(withConsole: true);
+ consoleAllocated = true;
+ }
- return (message, prefix);
+ public void DisableConsole()
+ {
+ if (!consoleAllocated)
+ {
+ return;
}
- protected void InternalLog(object logMessage, LogLevel logLevel, string messagePrefix, params string[] vals)
+
+ RebuildLogger(withConsole: false);
+ NativeMethods.FreeConsole();
+ consoleAllocated = false;
+ }
+
+ [SupportedOSPlatform("windows")]
+ private void AllocateConsole()
+ {
+ _ = NativeMethods.AllocConsole();
+ Console.Title = "StreamCompanion logs";
+ if (Console.LargestWindowWidth > 0)
{
- if (logMessage is string message)
- {
- if (message.TryFormat(out var result, vals))
- message = result;
+ Console.WindowWidth = Console.LargestWindowWidth - Convert.ToInt32(Console.LargestWindowWidth / 3);
+ }
+ }
- message = $@"{messagePrefix}{message}";
+ private void RebuildLogger(bool withConsole)
+ {
+ lock (pipelineLock)
+ {
+ Logger replacement = BuildLogger(withConsole);
+ Logger retired = serilogLogger;
+ serilogLogger = replacement;
+ retired?.Dispose();
+ }
+ }
- foreach (var logger in _loggers)
- {
- logger.Log(message, logLevel, vals);
- }
- }
- else
+ // TODO(remove after release or two): only cleans pre-Serilog logs.
+ private void CleanupLegacyLogs()
+ {
+ try
+ {
+ DateTime deleteDateThreshold = DateTime.UtcNow.AddDays(-settings.Get(LogsRetentionDays));
+ foreach (string logFilePath in Directory.GetFiles(logsDirectory, "????-??-??.txt"))
{
- foreach (var logger in _loggers)
+ if (File.GetLastWriteTimeUtc(logFilePath) < deleteDateThreshold)
{
- logger.Log(logMessage, logLevel, vals);
+ File.Delete(logFilePath);
}
}
}
+ catch { /* best-effort */ }
+ }
+
+ public void Shutdown()
+ {
+ settings.SettingUpdated -= OnSettingUpdated;
+ serilogLogger?.Dispose();
+ serilogLogger = null;
+ }
+
+ public void Log(object logMessage, LogLevel logLevel, string pluginName, params string[] vals)
+ {
+ (string message, string prefix) = GetPrefix(logMessage.ToString(), logLevel, pluginName);
+ InternalLog(logMessage is Exception ? logMessage : message, logLevel, prefix, vals);
+ }
+
+ private (string NewMessage, string Prefix) GetPrefix(string logMessage, LogLevel logLevel, string pluginName)
+ {
+ string message = logMessage.ToString();
+ string prefix = string.Empty;
- public void Log(object logMessage, LogLevel logLevel, params string[] vals)
+ while (message.StartsWith(">"))
{
- var (message, prefix) = GetPrefix(logMessage.ToString(), logLevel, null);
- InternalLog(logMessage is Exception ? logMessage : message, logLevel, prefix, vals);
+ prefix += "\t";
+ message = message.Substring(1);
}
- public void SetContextData(string key, string value)
+ pluginName = pluginName != null ? $" [{pluginName,20}]" : string.Empty;
+ prefix = string.Format(@"[{0}] {1:T}{2} - {3}", logLevel.ToString().Substring(0, 3), DateTime.Now, pluginName, prefix);
+
+ return (message, prefix);
+ }
+
+ protected void InternalLog(object logMessage, LogLevel logLevel, string messagePrefix, params string[] vals)
+ {
+ if (logMessage is string message)
+ {
+ MessageTemplate parsedTemplate = messageTemplateParser.Parse(messagePrefix + message);
+ LogEventProperty[] positionalProperties = new LogEventProperty[vals.Length];
+
+ for (int valueIndex = 0; valueIndex < vals.Length; valueIndex++)
+ {
+ positionalProperties[valueIndex] = new LogEventProperty(valueIndex.ToString(), new ScalarValue(vals[valueIndex]));
+ }
+
+ serilogLogger?.Write(new LogEvent(DateTimeOffset.Now, (LogEventLevel)(int)logLevel, null, parsedTemplate, positionalProperties));
+ }
+ else if (logMessage is Exception exception)
{
- for (int i = 0; i < _loggers.Count; i++)
+ string text = exception.ToString();
+
+ if (!contextData.IsEmpty)
{
- if (_loggers[i] is IContextAwareLogger logger)
- logger.SetContextData(key, value);
+ text += Environment.NewLine + string.Join(Environment.NewLine,
+ contextData.Select(pair => $"{pair.Key}: {pair.Value}"));
}
+
+ serilogLogger?.Write((LogEventLevel)(int)logLevel, exception, "{Message}", text);
+ }
+ else
+ {
+ serilogLogger?.Write((LogEventLevel)(int)logLevel, "{Message}", logMessage.ToString());
}
}
+
+ public void Log(object logMessage, LogLevel logLevel, params string[] vals)
+ {
+ (string message, string prefix) = GetPrefix(logMessage.ToString(), logLevel, null);
+ InternalLog(logMessage is Exception ? logMessage : message, logLevel, prefix, vals);
+ }
+
+ public void SetContextData(string key, string value) => contextData[key] = value;
}
diff --git a/osu!StreamCompanion/Code/Core/Loggers/SentryLogger.cs b/osu!StreamCompanion/Code/Core/Loggers/SentryLogger.cs
deleted file mode 100644
index f041bdf4..00000000
--- a/osu!StreamCompanion/Code/Core/Loggers/SentryLogger.cs
+++ /dev/null
@@ -1,46 +0,0 @@
-using System;
-using System.Collections.Generic;
-using osu_StreamCompanion.Code.Helpers;
-using Sentry;
-using StreamCompanionTypes.Enums;
-using StreamCompanionTypes.Interfaces.Services;
-
-namespace osu_StreamCompanion.Code.Core.Loggers
-{
- public class SentryLogger : IContextAwareLogger
- {
- public static string SentryDsn =
- "https://61b0ba522c24450a87fba347918f6364@glitchtip.pioo.space/1";
- public static SentryClient SentryClient { get; } = new SentryClient(new SentryOptions
- {
- Dsn = SentryDsn,
- Release = Program.ScVersion,
- });
-
- public static Dictionary ContextData { get; } = new Dictionary();
- private object _lockingObject = new object();
-
- public void Log(object logMessage, LogLevel logLevel, params string[] vals)
- {
- if (logLevel == LogLevel.Critical && logMessage is Exception exception && !(exception is NonLoggableException))
- {
- var sentryEvent = new SentryEvent(exception);
-
- lock (_lockingObject)
- {
- foreach (var contextKeyValue in ContextData)
- {
- sentryEvent.SetExtra(contextKeyValue.Key, contextKeyValue.Value);
- }
- SentryClient.CaptureEvent(sentryEvent);
- }
- }
- }
-
- public void SetContextData(string key, string value)
- {
- lock (_lockingObject)
- ContextData[key] = value;
- }
- }
-}
\ No newline at end of file
diff --git a/osu!StreamCompanion/Code/Core/Maps/Processing/MapDataGetter.cs b/osu!StreamCompanion/Code/Core/Maps/Processing/MapDataGetter.cs
index 76d7e4b8..28715537 100644
--- a/osu!StreamCompanion/Code/Core/Maps/Processing/MapDataGetter.cs
+++ b/osu!StreamCompanion/Code/Core/Maps/Processing/MapDataGetter.cs
@@ -71,7 +71,7 @@ public async Task FindMapData(IMapSearchArgs searchArgs, Cance
{
if (mapSearchResult.Mods == null && foundMods != null)
mapSearchResult.Mods = foundMods;
- _logger.Log($">Found data using \"{_mapDataFinders[i].SearcherName}\" ID: {mapSearchResult.BeatmapsFound[0]?.MapId}", LogLevel.Debug);
+ _logger.Log(">Found data using \"{0}\" ID: {1}", LogLevel.Debug, _mapDataFinders[i].SearcherName, mapSearchResult.BeatmapsFound[0]?.MapId.ToString() ?? string.Empty);
break;
}
if (mapSearchResult?.Mods != null)
diff --git a/osu!StreamCompanion/Code/Core/Maps/Processing/OsuEventHandler.cs b/osu!StreamCompanion/Code/Core/Maps/Processing/OsuEventHandler.cs
index 6eca9ace..a64b034f 100644
--- a/osu!StreamCompanion/Code/Core/Maps/Processing/OsuEventHandler.cs
+++ b/osu!StreamCompanion/Code/Core/Maps/Processing/OsuEventHandler.cs
@@ -64,7 +64,7 @@ private void NewOsuEvent(object sender, IMapSearchArgs mapSearchArgs)
sourceName = mapSearchArgs.SourceName
}.ToString();
- _logger.Log($"Received event: {eventData}", LogLevel.Debug);
+ _logger.Log("Received event: {0}", LogLevel.Debug, eventData);
if (mapSearchArgs.SourceName.Contains("Legacy"))
{
LegacyOsuTasks.Clear();
@@ -104,16 +104,14 @@ private async Task OsuEventWorkerLoop()
}
catch (MissingMethodException ex)
{
- ex.Data["PreventedCrash"] = 1;
_logger.Log(ex, LogLevel.Critical);
MessageBox.Show($"Looks like one or more files required to run StreamCompanion are corrupted. Run StreamCompanion setup again to repair. Closing now.", "StreamCompanion", MessageBoxButtons.OK, MessageBoxIcon.Error);
_exiter("MissingMethodException");
}
catch (Exception ex)
{
- ex.Data["PreventedCrash"] = 1;
_logger.Log(ex, LogLevel.Critical);
- _logger.Log("Prevented crash in event worker, token data for last event might be incorrect! This exception has been automatically reported.", LogLevel.Warning);
+ _logger.Log("Prevented crash in event worker, token data for last event might be incorrect!", LogLevel.Warning);
}
await Task.Delay(5);
diff --git a/osu!StreamCompanion/Code/Core/Plugins/LocalPluginManager.cs b/osu!StreamCompanion/Code/Core/Plugins/LocalPluginManager.cs
index 087b800f..30642607 100644
--- a/osu!StreamCompanion/Code/Core/Plugins/LocalPluginManager.cs
+++ b/osu!StreamCompanion/Code/Core/Plugins/LocalPluginManager.cs
@@ -169,10 +169,7 @@ internal void StartPlugins(DependencyInjectionContainer di)
}
catch (LocateException)
{
- if (MainLogger.Instance.Loggers.All(x => x is not ConsoleLogger) && OperatingSystem.IsWindows())
- {
- MainLogger.Instance.AddLogger(new ConsoleLogger(_settings));
- }
+ MainLogger.Instance.EnableConsole();
_logger.Log("************", LogLevel.Critical);
_logger.Log("************", LogLevel.Critical);
@@ -187,7 +184,7 @@ internal void StartPlugins(DependencyInjectionContainer di)
foreach (var lazyPluginMeta in lazyPluginMetas)
{
var pluginType = lazyPluginMeta.Metadata.ActivationType;
- _logger.Log($">loading \"{pluginType.FullName}\" v: {pluginType.Assembly.GetName().Version}", LogLevel.Trace);
+ _logger.Log(">loading \"{0}\" v: {1}", LogLevel.Trace, pluginType.FullName, pluginType.Assembly.GetName().Version.ToString());
IPlugin plugin = null;
try
diff --git a/osu!StreamCompanion/Code/Helpers/Helpers.cs b/osu!StreamCompanion/Code/Helpers/Helpers.cs
index ac8dc6d6..fe2a4e11 100644
--- a/osu!StreamCompanion/Code/Helpers/Helpers.cs
+++ b/osu!StreamCompanion/Code/Helpers/Helpers.cs
@@ -132,7 +132,7 @@ public static bool SafeHasExited(this Process process)
public static void WaitForOsuFileLock(FileInfo file, ILogger logger = null, int Id = 0)
{
//If we acquire lock before osu it'll force "soft" beatmap reprocessing(no data loss, but time consuming).
- logger?.Log($"{Id}: osu release: wait start", LogLevel.Debug);
+ logger?.Log("{0}: osu release: wait start", LogLevel.Debug, Id.ToString());
var startTime = DateTime.Now;
var isLocked = ExecWithTimeout(token =>
{
@@ -145,9 +145,9 @@ public static void WaitForOsuFileLock(FileInfo file, ILogger logger = null, int
return true;
}, 500, logger);
var diff = (DateTime.Now - startTime).TotalMilliseconds;
- logger?.Log($"{Id}: osu release: wait end - {diff}ms", LogLevel.Debug);
+ logger?.Log("{0}: osu release: wait end - {1}ms", LogLevel.Debug, Id.ToString(), diff.ToString());
- logger?.Log($"{Id}: isLocked:{isLocked}", LogLevel.Debug);
+ logger?.Log("{0}: isLocked:{1}", LogLevel.Debug, Id.ToString(), isLocked.ToString());
if (isLocked)
{
@@ -159,7 +159,7 @@ public static void WaitForOsuFileLock(FileInfo file, ILogger logger = null, int
Thread.Sleep(1);
}
diff = (DateTime.Now - startTime).TotalMilliseconds;
- logger?.Log($"{Id}: osu lock: released after {diff}ms, {cycles}loops", LogLevel.Debug);
+ logger?.Log("{0}: osu lock: released after {1}ms, {2}loops", LogLevel.Debug, Id.ToString(), diff.ToString(), cycles.ToString());
}
}
diff --git a/osu!StreamCompanion/Code/Modules/Logger/LoggerSettings.cs b/osu!StreamCompanion/Code/Modules/Logger/LoggerSettings.cs
index 71b0d50f..36a2c8d0 100644
--- a/osu!StreamCompanion/Code/Modules/Logger/LoggerSettings.cs
+++ b/osu!StreamCompanion/Code/Modules/Logger/LoggerSettings.cs
@@ -1,5 +1,4 @@
using System;
-using System.Linq;
using osu_StreamCompanion.Code.Core.Loggers;
using osu_StreamCompanion.Code.Misc;
using StreamCompanionTypes;
@@ -27,25 +26,8 @@ public LoggerSettings(MainLogger mainLogger, ISettings settings)
{
this.mainLogger = mainLogger;
this.settings = settings;
- settings.SettingUpdated+=SettingUpdated;
}
- private void SettingUpdated(object sender, SettingUpdated e)
- {
- if (e.Name == _names.Console.Name && OperatingSystem.IsWindows())
- {
- if (settings.Get(_names.Console))
- {
- mainLogger.AddLogger(new ConsoleLogger(settings));
- }
- else
- {
- var logger = mainLogger.Loggers.FirstOrDefault(x => x is ConsoleLogger);
- if(logger!=null)
- mainLogger.RemoveLogger(logger);
- }
- }
- }
public void Free() => loggerSettingsControl.Dispose();
diff --git a/osu!StreamCompanion/Code/Modules/Logger/LoggerSettingsUserControl.cs b/osu!StreamCompanion/Code/Modules/Logger/LoggerSettingsUserControl.cs
index 4c009476..f63a32b4 100644
--- a/osu!StreamCompanion/Code/Modules/Logger/LoggerSettingsUserControl.cs
+++ b/osu!StreamCompanion/Code/Modules/Logger/LoggerSettingsUserControl.cs
@@ -47,7 +47,7 @@ private void CheckBox_consoleLoggerOnCheckedChanged(object sender, EventArgs e)
private void ComboBox_logVerbosityOnSelectedIndexChanged(object sender, EventArgs e)
{
- settings.Add(_names.LogLevel.Name, LogLevels.First(x => x.Key == comboBox_logVerbosity.SelectedItem.ToString()).Value.GetHashCode());
+ settings.Add(_names.LogLevel.Name, LogLevels.First(x => x.Key == comboBox_logVerbosity.SelectedItem.ToString()).Value.GetHashCode(), true);
}
}
}
diff --git a/osu!StreamCompanion/Code/Modules/Updater/Updater.cs b/osu!StreamCompanion/Code/Modules/Updater/Updater.cs
index 10fb671c..e5e298c7 100644
--- a/osu!StreamCompanion/Code/Modules/Updater/Updater.cs
+++ b/osu!StreamCompanion/Code/Modules/Updater/Updater.cs
@@ -63,7 +63,7 @@ private async Task SetErrorMessage(string baseMsg)
string ret = baseMsg + " ";
if (exception != null)
ret += exception.Message;
- _logger.Log($"Error: {ret}", LogLevel.Debug);
+ _logger.Log("Error: {0}", LogLevel.Debug, ret);
await SetStatus(ret);
}
diff --git a/osu!StreamCompanion/Program.cs b/osu!StreamCompanion/Program.cs
index 1563a9df..aee3c41b 100644
--- a/osu!StreamCompanion/Program.cs
+++ b/osu!StreamCompanion/Program.cs
@@ -33,6 +33,7 @@ static class Program
[STAThread]
static void Main(string[] args)
{
+ Application.SetHighDpiMode(HighDpiMode.DpiUnaware);
#if False
AllowMultiInstance = true;
#endif
@@ -253,7 +254,6 @@ public static void HandleException(Exception ex)
ex.Data[d.Key] = d.Value;
}
- //also reports to sentry if enabled
MainLogger.Instance.Log(ex, LogLevel.Critical);
if (UserCanBeNotified())
diff --git a/osu!StreamCompanion/osu!StreamCompanion.csproj b/osu!StreamCompanion/osu!StreamCompanion.csproj
index 63ffb662..bdcbe6e4 100644
--- a/osu!StreamCompanion/osu!StreamCompanion.csproj
+++ b/osu!StreamCompanion/osu!StreamCompanion.csproj
@@ -80,7 +80,10 @@
-
+
+
+
+
diff --git a/plugins/BackgroundImageProvider/BackgroundImageProvider.csproj b/plugins/BackgroundImageProvider/BackgroundImageProvider.csproj
index 81d07fd6..84e0d945 100644
--- a/plugins/BackgroundImageProvider/BackgroundImageProvider.csproj
+++ b/plugins/BackgroundImageProvider/BackgroundImageProvider.csproj
@@ -7,16 +7,7 @@
false
true
-
- bin\Debug_temp\
-
-
- bin\Release_temp\
-
-
-
-
\ No newline at end of file
diff --git a/plugins/BackgroundImageProvider/BackgroundImageProviderPlugin.cs b/plugins/BackgroundImageProvider/BackgroundImageProviderPlugin.cs
index c2a6fd79..3c6069fb 100644
--- a/plugins/BackgroundImageProvider/BackgroundImageProviderPlugin.cs
+++ b/plugins/BackgroundImageProvider/BackgroundImageProviderPlugin.cs
@@ -81,7 +81,7 @@ protected Task InternalCreateTokens(IMapSearchResult map, CancellationToken canc
}
catch (UnauthorizedAccessException)
{
- _logger.Log($"Could not save background image at \"{_saveLocation}\" (UnauthorizedAccessException)", LogLevel.Warning);
+ _logger.Log("Could not save background image at \"{0}\" (UnauthorizedAccessException)", LogLevel.Warning, _saveLocation);
}
return Task.CompletedTask;
diff --git a/plugins/BeatmapPpReplacements/BeatmapPpReplacements.csproj b/plugins/BeatmapPpReplacements/BeatmapPpReplacements.csproj
index f0ba1eb1..d73f8531 100644
--- a/plugins/BeatmapPpReplacements/BeatmapPpReplacements.csproj
+++ b/plugins/BeatmapPpReplacements/BeatmapPpReplacements.csproj
@@ -8,17 +8,8 @@
false
true
-
- bin\Debug_temp\
-
-
- bin\Release_temp\
-
-
-
-
\ No newline at end of file
diff --git a/plugins/BeatmapPpReplacements/PpReplacements.cs b/plugins/BeatmapPpReplacements/PpReplacements.cs
index eee34b5f..f7c1ad7a 100644
--- a/plugins/BeatmapPpReplacements/PpReplacements.cs
+++ b/plugins/BeatmapPpReplacements/PpReplacements.cs
@@ -1,8 +1,10 @@
-using CollectionManager.Enums;
+using CollectionManager.Enums;
using StreamCompanionTypes.DataTypes;
using StreamCompanionTypes.Interfaces;
using System;
using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using PpCalculatorTypes;
@@ -22,6 +24,7 @@ public class PpReplacements : IPlugin, ITokensSource
private Tokens.TokenSetter _tokenSetter;
private ISettings _settings;
private IPpCalculator _ppCalculator = null;
+ private readonly ILogger _logger;
private readonly IToken _strainsToken;
public static ConfigEntry StrainsAmount = new ConfigEntry("StrainsAmount", (int?)100);
private readonly Dictionary> ppTokenDefinitions;
@@ -33,8 +36,9 @@ enum TokenMode
Mania
}
- public PpReplacements(ISettings settings)
+ public PpReplacements(ISettings settings, ILogger logger)
{
+ _logger = logger;
_settings = settings;
_tokenSetter = Tokens.CreateTokenSetter(Name);
_strainsToken = _tokenSetter("mapStrains", new Lazy