From 60729e288003ca25d3e1eb5e08fc9a81dbd4dce5 Mon Sep 17 00:00:00 2001 From: Piotrekol <4990365+Piotrekol@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:19:51 +0200 Subject: [PATCH 01/10] Fix: live token update race; switch to DeferredToken --- Directory.Packages.props | 2 +- .../LiveTokens/BaseLiveToken.cs | 2 +- .../{LiveToken.cs => DeferredLiveToken.cs} | 15 +++++--- .../LiveTokens/LazyLiveToken.cs | 28 -------------- .../MemoryDataProcessor.cs | 37 +++++++++---------- 5 files changed, 30 insertions(+), 54 deletions(-) rename plugins/OsuMemoryEventSource/LiveTokens/{LiveToken.cs => DeferredLiveToken.cs} (55%) delete mode 100644 plugins/OsuMemoryEventSource/LiveTokens/LazyLiveToken.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 1423d1c4..c50fb98e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,7 @@ - + diff --git a/plugins/OsuMemoryEventSource/LiveTokens/BaseLiveToken.cs b/plugins/OsuMemoryEventSource/LiveTokens/BaseLiveToken.cs index 7fae9253..fd8f74d2 100644 --- a/plugins/OsuMemoryEventSource/LiveTokens/BaseLiveToken.cs +++ b/plugins/OsuMemoryEventSource/LiveTokens/BaseLiveToken.cs @@ -11,7 +11,7 @@ public abstract class BaseLiveToken public BaseLiveToken(IToken token, Func updater) { Token = token; - Updater = updater; + Updater = updater ?? throw new ArgumentNullException(nameof(updater)); } protected bool CanUpdate(OsuStatus status) => Token.CanSave(status) && Updater != null; diff --git a/plugins/OsuMemoryEventSource/LiveTokens/LiveToken.cs b/plugins/OsuMemoryEventSource/LiveTokens/DeferredLiveToken.cs similarity index 55% rename from plugins/OsuMemoryEventSource/LiveTokens/LiveToken.cs rename to plugins/OsuMemoryEventSource/LiveTokens/DeferredLiveToken.cs index 69917fe2..24ef279b 100644 --- a/plugins/OsuMemoryEventSource/LiveTokens/LiveToken.cs +++ b/plugins/OsuMemoryEventSource/LiveTokens/DeferredLiveToken.cs @@ -4,14 +4,19 @@ namespace OsuMemoryEventSource.LiveTokens { - public class LiveToken : BaseLiveToken + public class DeferredLiveToken : BaseLiveToken { - public LiveToken(IToken token, Func updater) : base(token, updater) - { - } + public DeferredLiveToken(IToken token, Func updater) + : base(token, updater) { } public override void Update(OsuStatus status = OsuStatus.All) { + if (Token is DeferredToken deferred) + { + deferred.Recompute(Updater, status); + return; + } + if (!CanUpdate(status)) { Token.Reset(); @@ -21,4 +26,4 @@ public override void Update(OsuStatus status = OsuStatus.All) Token.Value = Updater(); } } -} \ No newline at end of file +} diff --git a/plugins/OsuMemoryEventSource/LiveTokens/LazyLiveToken.cs b/plugins/OsuMemoryEventSource/LiveTokens/LazyLiveToken.cs deleted file mode 100644 index cd6fdfd8..00000000 --- a/plugins/OsuMemoryEventSource/LiveTokens/LazyLiveToken.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; -using StreamCompanionTypes.DataTypes; -using StreamCompanionTypes.Enums; - -namespace OsuMemoryEventSource.LiveTokens -{ - public class LazyLiveToken : BaseLiveToken - { - private readonly LazyToken lazyToken; - - public LazyLiveToken(IToken token, Func updater) : base(token, updater) - { - lazyToken = (LazyToken) token; - } - - public override void Update(OsuStatus status = OsuStatus.All) - { - if (!Token.CanSave(status) || Updater == null) - { - lazyToken.Reset(); - return; - } - - if (lazyToken.IsValueCreated) - Token.Value = new Lazy(Updater); - } - } -} \ No newline at end of file diff --git a/plugins/OsuMemoryEventSource/MemoryDataProcessor.cs b/plugins/OsuMemoryEventSource/MemoryDataProcessor.cs index f1a0b373..ec4ea521 100644 --- a/plugins/OsuMemoryEventSource/MemoryDataProcessor.cs +++ b/plugins/OsuMemoryEventSource/MemoryDataProcessor.cs @@ -38,7 +38,6 @@ public class MemoryDataProcessor : IDisposable private readonly Dictionary _liveTokens = new(); private readonly Dictionary _tokenJsonSerializers = new(); - private Tokens.TokenSetter _liveTokenSetter => OsuMemoryEventSourceBase.LiveTokenSetter; private Tokens.TokenSetter _tokenSetter => OsuMemoryEventSourceBase.TokenSetter; public static ConfigEntry MultiplayerLeaderBoardUpdateRate = new ConfigEntry("MultiplayerLeaderBoardUpdateRate", 250); public static ConfigEntry SongSelectionScoresUpdateRate = new ConfigEntry("SongSelectionScoresUpdateRate", 250); @@ -319,10 +318,10 @@ private string GetTokenName(string baseName) return name; } - private void CreateLiveToken(string name, object value, TokenType tokenType, string format, - object defaultValue, OsuStatus statusWhitelist, Func updater) + private void CreateLiveToken(string name, object value, TokenType tokenType, string format, object defaultValue, OsuStatus statusWhitelist, Func updater) { - var newToken = _liveTokenSetter(GetTokenName(name), new Lazy(() => value), tokenType, format, new Lazy(() => defaultValue), statusWhitelist); + IToken newToken = Tokens.CreateDeferredToken(OsuMemoryEventSourceBase.Name, GetTokenName(name), value, tokenType, format, defaultValue, statusWhitelist); + CreateLiveToken(newToken, updater); } @@ -332,10 +331,7 @@ private void CreateLiveToken(IToken token, Func updater) { _notUpdatingTokens.WaitOne(); _notAddingNewTokens.Reset(); - if (token is LazyToken) - _liveTokens[token.Name] = new LazyLiveToken(token, updater); - else - _liveTokens[token.Name] = new LiveToken(token, updater); + _liveTokens[token.Name] = new DeferredLiveToken(token, updater); } finally { @@ -457,7 +453,7 @@ private void InitLiveTokens() }); CreateLiveToken("convertedUnstableRate", InterpolatedValues[InterpolatedValueName.UnstableRate].Current, TokenType.Live, "{0:0.000}", 0d, playingWatchingResults, () => ConvertedUnstableRate((double)_liveTokens[GetTokenName("unstableRate")].Token.Value, _mods)); - CreateLiveToken("hitErrors", new List(), TokenType.Live, ",", new List(), playingWatchingResults, () => _rawData.Play is Player p ? p.HitErrors : null); + CreateLiveToken("hitErrors", new List(), TokenType.Live, ",", new List(), playingWatchingResults, () => _rawData.Play is Player p && p.HitErrors != null ? new List(p.HitErrors) : null); CreateLiveToken("localTimeISO", DateTime.UtcNow.ToString("o"), TokenType.Live, "", DateTime.UtcNow, OsuStatus.All, () => DateTime.UtcNow.ToString("o")); CreateLiveToken("localTime", DateTime.Now.TimeOfDay, TokenType.Live, "{0:hh}:{0:mm}:{0:ss}", DateTime.Now.TimeOfDay, OsuStatus.All, () => DateTime.Now.TimeOfDay); CreateLiveToken("sliderBreaks", 0, TokenType.Live, "{0}", 0, playingWatchingResults, () => @@ -545,18 +541,21 @@ private void UpdateLiveTokens(OsuStatus status) if (IsMainProcessor) bulkTokenUpdateContext = TokensBulkUpdate.StartBulkUpdate(BulkTokenUpdateType.LiveTokens); - foreach (var liveToken in _liveTokens) + lock (_lockingObject) { - try - { - liveToken.Value.Update(status); - } - catch (TaskCanceledException) { } - catch (OperationCanceledException) { } - catch (Exception ex) + foreach (var liveToken in _liveTokens) { - ex.Data["liveTokenName"] = liveToken.Key; - throw; + try + { + liveToken.Value.Update(status); + } + catch (TaskCanceledException) { } + catch (OperationCanceledException) { } + catch (Exception ex) + { + ex.Data["liveTokenName"] = liveToken.Key; + throw; + } } } _tokensUpdated(); From aff7e6f1d85b4baee9737d4d7e7b8ddb9df75a25 Mon Sep 17 00:00:00 2001 From: Piotrekol <4990365+Piotrekol@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:32:54 +0200 Subject: [PATCH 02/10] Misc: replace live token sync events with a single lock --- .../OsuMemoryEventSource/MemoryDataProcessor.cs | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/plugins/OsuMemoryEventSource/MemoryDataProcessor.cs b/plugins/OsuMemoryEventSource/MemoryDataProcessor.cs index ec4ea521..9eaec5fa 100644 --- a/plugins/OsuMemoryEventSource/MemoryDataProcessor.cs +++ b/plugins/OsuMemoryEventSource/MemoryDataProcessor.cs @@ -78,8 +78,6 @@ private enum InterpolatedValueName UnstableRate, liveStarRating, } - private ManualResetEvent _notAddingNewTokens = new ManualResetEvent(true); - private ManualResetEvent _notUpdatingTokens = new ManualResetEvent(true); private ManualResetEvent _notUpdatingMemoryValues = new ManualResetEvent(true); private ManualResetEvent _newPlayStarted = new ManualResetEvent(true); @@ -146,9 +144,7 @@ public async Task TokenThreadWork() if (_notUpdatingMemoryValues.WaitOne(0)) { - _notUpdatingTokens.Reset(); UpdateLiveTokens(_lastStatus); - _notUpdatingTokens.Set(); } if (_newPlayStarted.WaitOne(0)) @@ -189,7 +185,6 @@ public async Task SetNewMap(IMapSearchResult map, CancellationToken cancellation private DateTime _nextSongSelectionScoresUpdate = DateTime.MinValue; public void Tick(OsuStatus status, OsuMemoryStatus rawStatus, StructuredOsuMemoryReader reader) { - _notUpdatingTokens.WaitOne(); _notUpdatingMemoryValues.Reset(); lock (_lockingObject) { @@ -327,16 +322,10 @@ private void CreateLiveToken(string name, object value, TokenType tokenType, str private void CreateLiveToken(IToken token, Func updater) { - try + lock (_lockingObject) { - _notUpdatingTokens.WaitOne(); - _notAddingNewTokens.Reset(); _liveTokens[token.Name] = new DeferredLiveToken(token, updater); } - finally - { - _notAddingNewTokens.Set(); - } } private void InitLiveTokens() @@ -536,7 +525,6 @@ private JsonSerializerSettings createJsonSerializerSettings(string tokenName) private void UpdateLiveTokens(OsuStatus status) { - _notAddingNewTokens.WaitOne(); BulkTokenUpdateContext bulkTokenUpdateContext = null; if (IsMainProcessor) bulkTokenUpdateContext = TokensBulkUpdate.StartBulkUpdate(BulkTokenUpdateType.LiveTokens); From 9e6e6e1c64aaf951679421bcd9e8fc337b532e2d Mon Sep 17 00:00:00 2001 From: Piotrekol <4990365+Piotrekol@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:26:53 +0200 Subject: [PATCH 03/10] Misc: replace post-build copy scripts with declarative plugin deployment --- .../BackgroundImageProvider.csproj | 9 ------- .../BeatmapPpReplacements.csproj | 9 ------- plugins/ClickCounter/ClickCounter.csproj | 9 ------- plugins/Directory.Build.props | 5 ++++ plugins/Directory.Build.targets | 27 +++++++++++++++++++ plugins/Gamma/Gamma.csproj | 9 ------- .../BrowserIngameOverlay.csproj | 20 +++++++------- .../Overlay.Common/Overlay.Common.csproj | 1 + .../TextOverlay/TextIngameOverlay.csproj | 26 +++++++++--------- plugins/LiveVisualizer/LiveVisualizer.csproj | 14 ++++------ .../ModsHandlerTests/ModsHandlerTests.csproj | 1 + plugins/OsuMapLoader/OsuMapLoader.csproj | 18 ++++++------- .../OsuMemoryEventSource.csproj | 13 +++------ plugins/ScGui/ScGui.csproj | 9 ------- plugins/TestPlugin/TestPlugin.csproj | 1 + .../WebSocketDataSender.csproj | 14 ++++------ plugins/osuPost/osuPost.csproj | 9 ------- 17 files changed, 82 insertions(+), 112 deletions(-) create mode 100644 plugins/Directory.Build.props create mode 100644 plugins/Directory.Build.targets 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/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/ClickCounter/ClickCounter.csproj b/plugins/ClickCounter/ClickCounter.csproj index 97d091f5..3f912997 100644 --- a/plugins/ClickCounter/ClickCounter.csproj +++ b/plugins/ClickCounter/ClickCounter.csproj @@ -10,12 +10,6 @@ true 11.0 - - bin\Debug_temp\ - - - bin\Release_temp\ - UserControl @@ -27,7 +21,4 @@ - - - \ No newline at end of file diff --git a/plugins/Directory.Build.props b/plugins/Directory.Build.props new file mode 100644 index 00000000..3110cfa8 --- /dev/null +++ b/plugins/Directory.Build.props @@ -0,0 +1,5 @@ + + + true + + diff --git a/plugins/Directory.Build.targets b/plugins/Directory.Build.targets new file mode 100644 index 00000000..f4a9e240 --- /dev/null +++ b/plugins/Directory.Build.targets @@ -0,0 +1,27 @@ + + + + + $(SolutionDir)$(SCPluginDeployDir) + $(SolutionDir)build\$(ConfigurationName)\ + + + + + + + + + + + + diff --git a/plugins/Gamma/Gamma.csproj b/plugins/Gamma/Gamma.csproj index 40654551..61e68961 100644 --- a/plugins/Gamma/Gamma.csproj +++ b/plugins/Gamma/Gamma.csproj @@ -8,16 +8,7 @@ x86;AnyCPU true - - bin\Debug_temp\ - - - bin\Release_temp\ - - - - \ No newline at end of file diff --git a/plugins/IngameOverlays/BrowserOverlay/BrowserIngameOverlay.csproj b/plugins/IngameOverlays/BrowserOverlay/BrowserIngameOverlay.csproj index 1845ef15..0cee2300 100644 --- a/plugins/IngameOverlays/BrowserOverlay/BrowserIngameOverlay.csproj +++ b/plugins/IngameOverlays/BrowserOverlay/BrowserIngameOverlay.csproj @@ -6,19 +6,21 @@ false x86;AnyCPU true + false BrowserIngameOverlay - - bin\Debug_temp\ - - - bin\Release_temp\ - - - - + + build\Release_browserOverlay\ + + + + + + + + \ No newline at end of file diff --git a/plugins/IngameOverlays/Overlay.Common/Overlay.Common.csproj b/plugins/IngameOverlays/Overlay.Common/Overlay.Common.csproj index 4cccac25..9fc71ba9 100644 --- a/plugins/IngameOverlays/Overlay.Common/Overlay.Common.csproj +++ b/plugins/IngameOverlays/Overlay.Common/Overlay.Common.csproj @@ -2,6 +2,7 @@ net8.0-windows enable + false diff --git a/plugins/IngameOverlays/TextOverlay/TextIngameOverlay.csproj b/plugins/IngameOverlays/TextOverlay/TextIngameOverlay.csproj index 60b61315..a038d82f 100644 --- a/plugins/IngameOverlays/TextOverlay/TextIngameOverlay.csproj +++ b/plugins/IngameOverlays/TextOverlay/TextIngameOverlay.csproj @@ -8,28 +8,28 @@ false false true + false TextIngameOverlay - - bin\Debug_temp\ - - - bin\Release_temp\ - UserControl - - - - - - - + + build\Release_unsafe\ + + + + + + + + + + \ No newline at end of file diff --git a/plugins/LiveVisualizer/LiveVisualizer.csproj b/plugins/LiveVisualizer/LiveVisualizer.csproj index b5787b7b..d12ddd38 100644 --- a/plugins/LiveVisualizer/LiveVisualizer.csproj +++ b/plugins/LiveVisualizer/LiveVisualizer.csproj @@ -9,13 +9,8 @@ true false true + false - - bin\Debug_temp\ - - - bin\Release_temp\ - UserControl @@ -34,7 +29,8 @@ - - - + + + + \ No newline at end of file diff --git a/plugins/ModsHandlerTests/ModsHandlerTests.csproj b/plugins/ModsHandlerTests/ModsHandlerTests.csproj index ab350c93..7f4a7833 100644 --- a/plugins/ModsHandlerTests/ModsHandlerTests.csproj +++ b/plugins/ModsHandlerTests/ModsHandlerTests.csproj @@ -7,6 +7,7 @@ false true + false diff --git a/plugins/OsuMapLoader/OsuMapLoader.csproj b/plugins/OsuMapLoader/OsuMapLoader.csproj index 366b85f9..67930df2 100644 --- a/plugins/OsuMapLoader/OsuMapLoader.csproj +++ b/plugins/OsuMapLoader/OsuMapLoader.csproj @@ -8,12 +8,6 @@ true true - - bin\Debug_temp\ - - - bin\Release_temp\ - @@ -24,7 +18,13 @@ - - - + + + + + + + + + \ No newline at end of file diff --git a/plugins/OsuMemoryEventSource/OsuMemoryEventSource.csproj b/plugins/OsuMemoryEventSource/OsuMemoryEventSource.csproj index da157765..e4c020a8 100644 --- a/plugins/OsuMemoryEventSource/OsuMemoryEventSource.csproj +++ b/plugins/OsuMemoryEventSource/OsuMemoryEventSource.csproj @@ -11,12 +11,6 @@ x86;AnyCPU true - - bin\Debug_temp\ - - - bin\Release_temp\ - UserControl @@ -33,7 +27,8 @@ - - - + + + + \ No newline at end of file diff --git a/plugins/ScGui/ScGui.csproj b/plugins/ScGui/ScGui.csproj index 9961e6f0..9b3cc9d6 100644 --- a/plugins/ScGui/ScGui.csproj +++ b/plugins/ScGui/ScGui.csproj @@ -10,12 +10,6 @@ true true - - bin\Debug_temp\ - - - bin\Release_temp\ - True @@ -38,7 +32,4 @@ Resources.Designer.cs - - - \ No newline at end of file diff --git a/plugins/TestPlugin/TestPlugin.csproj b/plugins/TestPlugin/TestPlugin.csproj index e5c46ee0..f597acd8 100644 --- a/plugins/TestPlugin/TestPlugin.csproj +++ b/plugins/TestPlugin/TestPlugin.csproj @@ -3,6 +3,7 @@ net8.0 enable enable + false ..\..\build\Debug\Plugins\ diff --git a/plugins/WebSocketDataSender/WebSocketDataSender.csproj b/plugins/WebSocketDataSender/WebSocketDataSender.csproj index 13b93720..7cb2742c 100644 --- a/plugins/WebSocketDataSender/WebSocketDataSender.csproj +++ b/plugins/WebSocketDataSender/WebSocketDataSender.csproj @@ -9,12 +9,7 @@ false true 11 - - - bin\Debug_temp\ - - - bin\Release_temp\ + false @@ -30,7 +25,8 @@ - - - + + + + \ No newline at end of file diff --git a/plugins/osuPost/osuPost.csproj b/plugins/osuPost/osuPost.csproj index 78c3873a..579b1f32 100644 --- a/plugins/osuPost/osuPost.csproj +++ b/plugins/osuPost/osuPost.csproj @@ -8,12 +8,6 @@ false true - - bin\Debug_temp\ - - - bin\Release_temp\ - UserControl @@ -22,7 +16,4 @@ - - - \ No newline at end of file From 0bde14a0c336f5f859edf0b55d8c8e51513efbb8 Mon Sep 17 00:00:00 2001 From: Piotrekol <4990365+Piotrekol@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:01:24 +0200 Subject: [PATCH 04/10] Misc: replace AppVeyor CI/CD with GitHub Actions --- .github/workflows/ci.yml | 61 ++++++++++++++++ .github/workflows/release.yml | 107 +++++++++++++++++++++++++++++ Directory.Build.props | 4 +- buildRelease-CI.cmd | 11 ++- innoSetup/browserOverlayScript.iss | 9 ++- innoSetup/osuOverlayScript.iss | 9 ++- innoSetup/setupScript.iss | 9 ++- plugins/Directory.Build.props | 1 + 8 files changed, 204 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml 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/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..a08fa268 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} diff --git a/plugins/Directory.Build.props b/plugins/Directory.Build.props index 3110cfa8..893ea943 100644 --- a/plugins/Directory.Build.props +++ b/plugins/Directory.Build.props @@ -1,4 +1,5 @@ + true From 98f2bf618b34124e761869d3f7f71bcfef62bff1 Mon Sep 17 00:00:00 2001 From: Piotrekol <4990365+Piotrekol@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:31:47 +0200 Subject: [PATCH 05/10] Fix: broken element sizes on custom DPIs will result in blurry text, where it would otherwise be more or less unusable. --- osu!StreamCompanion/Program.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/osu!StreamCompanion/Program.cs b/osu!StreamCompanion/Program.cs index 1563a9df..0fb2e45c 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 From 2b5b286e44919e98a9efcde5f9e2eb66473c4cf9 Mon Sep 17 00:00:00 2001 From: Piotrekol <4990365+Piotrekol@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:16:49 +0200 Subject: [PATCH 06/10] Add: persistent timed-difficulty attributes cache --- Directory.Packages.props | 2 + PpCalculator/CachedTimedAttributesProvider.cs | 91 ++++ .../CalculatingTimedAttributesProvider.cs | 17 + PpCalculator/CtbCalculator.cs | 3 + PpCalculator/DifficultyCache.cs | 450 ++++++++++++++++++ PpCalculator/ITimedAttributesProvider.cs | 14 + PpCalculator/ManiaCalculator.cs | 3 + PpCalculator/OsuCalculator.cs | 3 + PpCalculator/PpCalculator.cs | 14 +- PpCalculator/PpCalculator.csproj | 13 +- PpCalculator/PpCalculatorHelpers.cs | 26 +- PpCalculator/TaikoCalculator.cs | 3 + .../TimedAttributesCacheTests.cs | 226 +++++++++ plugins/OsuMapLoader/LazerMapLoader.cs | 12 +- plugins/OsuMapLoader/Models/Configuration.cs | 8 + plugins/OsuMapLoader/OsuMapLoader.csproj | 3 + plugins/OsuMapLoader/OsuMapLoaderPlugin.cs | 21 +- 17 files changed, 879 insertions(+), 30 deletions(-) create mode 100644 PpCalculator/CachedTimedAttributesProvider.cs create mode 100644 PpCalculator/CalculatingTimedAttributesProvider.cs create mode 100644 PpCalculator/DifficultyCache.cs create mode 100644 PpCalculator/ITimedAttributesProvider.cs create mode 100644 PpCalculatorTests/TimedAttributesCacheTests.cs create mode 100644 plugins/OsuMapLoader/Models/Configuration.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index c50fb98e..a49fa1a1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -24,6 +24,8 @@ + + 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..2d75f187 100644 --- a/PpCalculator/PpCalculator.cs +++ b/PpCalculator/PpCalculator.cs @@ -85,6 +85,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 +101,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 +114,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 +231,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; } @@ -362,6 +366,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/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/plugins/OsuMapLoader/LazerMapLoader.cs b/plugins/OsuMapLoader/LazerMapLoader.cs index 28b695f7..6f639bc2 100644 --- a/plugins/OsuMapLoader/LazerMapLoader.cs +++ b/plugins/OsuMapLoader/LazerMapLoader.cs @@ -19,7 +19,7 @@ namespace OsuSongsFolderWatcher { public static class LazerMapLoader { - public static async Task<(Beatmap Beatmap, CancelableAsyncLazy CreatePpCalculatorLazyTask)> LoadLazerBeatmapWithPerformanceCalculator(string osuFilePath, PlayMode? desiredPlayMode, IModsEx mods, IContextAwareLogger logger, CancellationToken cancellationToken) + public static async Task<(Beatmap Beatmap, CancelableAsyncLazy CreatePpCalculatorLazyTask)> LoadLazerBeatmapWithPerformanceCalculator(string osuFilePath, PlayMode? desiredPlayMode, IModsEx mods, ITimedAttributesProvider timedAttributesProvider, IContextAwareLogger logger, CancellationToken cancellationToken) { const int retryLimit = 5; var retryCount = 0; @@ -27,7 +27,7 @@ public static class LazerMapLoader { try { - var result = await loadLazerBeatmapWithPerformanceCalculator(osuFilePath, desiredPlayMode, mods, logger, cancellationToken); + var result = await loadLazerBeatmapWithPerformanceCalculator(osuFilePath, desiredPlayMode, mods, timedAttributesProvider, logger, cancellationToken); if ((result.Beatmap != null && result.CreatePpCalculatorLazyTask != null) || retryCount >= retryLimit) return result; } @@ -55,9 +55,9 @@ public static class LazerMapLoader } } - private static async Task<(Beatmap Beatmap, CancelableAsyncLazy CreatePpCalculatorLazyTask)> loadLazerBeatmapWithPerformanceCalculator(string osuFilePath, PlayMode? desiredPlayMode, IModsEx mods, ILogger logger, CancellationToken cancellationToken) + private static async Task<(Beatmap Beatmap, CancelableAsyncLazy CreatePpCalculatorLazyTask)> loadLazerBeatmapWithPerformanceCalculator(string osuFilePath, PlayMode? desiredPlayMode, IModsEx mods, ITimedAttributesProvider timedAttributesProvider, ILogger logger, CancellationToken cancellationToken) { - var createPpCalculatorTask = CreatePpCalculatorTask(osuFilePath, desiredPlayMode, mods, logger); + var createPpCalculatorTask = CreatePpCalculatorTask(osuFilePath, desiredPlayMode, mods, timedAttributesProvider, logger); var iPpCalculator = await createPpCalculatorTask.GetValueAsync(cancellationToken); if (iPpCalculator == null) return (null, null); @@ -187,13 +187,13 @@ private static double CalculateLength(IBeatmap b, bool drain = false) return endTime - startTime; } - private static CancelableAsyncLazy CreatePpCalculatorTask(string osuFilePath, PlayMode? desiredPlayMode, IModsEx mods, ILogger logger) => + private static CancelableAsyncLazy CreatePpCalculatorTask(string osuFilePath, PlayMode? desiredPlayMode, IModsEx mods, ITimedAttributesProvider timedAttributesProvider, ILogger logger) => new CancelableAsyncLazy((cancellationToken) => { if (string.IsNullOrEmpty(osuFilePath)) return Task.FromResult(null); - var ppCalculator = PpCalculatorHelpers.GetPpCalculator((int)(desiredPlayMode ?? PlayMode.Osu), osuFilePath, null); + var ppCalculator = PpCalculatorHelpers.GetPpCalculator((int)(desiredPlayMode ?? PlayMode.Osu), osuFilePath, null, timedAttributesProvider); ppCalculator.Mods = (mods?.WorkingMods ?? "").Split(new[] { "," }, StringSplitOptions.RemoveEmptyEntries); try { diff --git a/plugins/OsuMapLoader/Models/Configuration.cs b/plugins/OsuMapLoader/Models/Configuration.cs new file mode 100644 index 00000000..c9bedcab --- /dev/null +++ b/plugins/OsuMapLoader/Models/Configuration.cs @@ -0,0 +1,8 @@ +namespace OsuSongsFolderWatcher.Models +{ + public class Configuration + { + public int MaxCacheEntries { get; set; } = 1000; + public int MinimumGenerationMilliseconds { get; set; } = 250; + } +} diff --git a/plugins/OsuMapLoader/OsuMapLoader.csproj b/plugins/OsuMapLoader/OsuMapLoader.csproj index 67930df2..980d4bc9 100644 --- a/plugins/OsuMapLoader/OsuMapLoader.csproj +++ b/plugins/OsuMapLoader/OsuMapLoader.csproj @@ -25,6 +25,9 @@ + + + \ No newline at end of file diff --git a/plugins/OsuMapLoader/OsuMapLoaderPlugin.cs b/plugins/OsuMapLoader/OsuMapLoaderPlugin.cs index 4bcca618..807b2da3 100644 --- a/plugins/OsuMapLoader/OsuMapLoaderPlugin.cs +++ b/plugins/OsuMapLoader/OsuMapLoaderPlugin.cs @@ -10,19 +10,32 @@ using StreamCompanionTypes.Interfaces; using StreamCompanionTypes.Interfaces.Services; +using OsuSongsFolderWatcher.Models; + namespace OsuSongsFolderWatcher { [SCPlugin("Osu map loader", "Reads and processes local .osu difficulty files", Consts.SCPLUGIN_AUTHOR, Consts.SCPLUGIN_BASEURL)] - public class OsuMapLoaderPlugin : IPlugin, IMapDataFinder + public class OsuMapLoaderPlugin : IPlugin, IMapDataFinder, IDisposable { + public static readonly ConfigEntry PpCacheConfiguration = new ConfigEntry("PpCacheConfiguration", null); + private readonly IContextAwareLogger _logger; private readonly IModParser _modParser; + private readonly PpCalculator.CachedTimedAttributesProvider timedAttributesProvider; + private readonly Configuration configuration; - public OsuMapLoaderPlugin(IContextAwareLogger logger, IModParser modParser) + public OsuMapLoaderPlugin(IContextAwareLogger logger, IModParser modParser, ISaver saver, ISettings settings) { _logger = logger; _modParser = modParser; + configuration = settings.GetConfiguration(PpCacheConfiguration); + timedAttributesProvider = new PpCalculator.CachedTimedAttributesProvider( + Path.Combine(saver.SaveDirectory, "attributes.sqlite"), + configuration.MaxCacheEntries, + logger: logger, + minimumGenerationMilliseconds: configuration.MinimumGenerationMilliseconds); } + public async Task FindBeatmap(IMapSearchArgs args, CancellationToken cancellationToken) { if (args == null || string.IsNullOrEmpty(args.OsuFilePath)) @@ -38,7 +51,7 @@ public async Task FindBeatmap(IMapSearchArgs args, Cancellatio try { result = await LazerMapLoader.LoadLazerBeatmapWithPerformanceCalculator(args.OsuFilePath, args.PlayMode, - _modParser.GetModsFromEnum((int)args.Mods), _logger, cancellationToken); + _modParser.GetModsFromEnum((int)args.Mods), timedAttributesProvider, _logger, cancellationToken); } catch (OperationCanceledException) { @@ -73,6 +86,8 @@ public async Task FindBeatmap(IMapSearchArgs args, Cancellatio public OsuStatus SearchModes { get; } = OsuStatus.All; public string SearcherName { get; } = "osu!lazer"; + + public void Dispose() => timedAttributesProvider.Dispose(); public int Priority { get; set; } = 90; } } \ No newline at end of file From 967c828360420b75ff1b6933d98c30416c83b5f0 Mon Sep 17 00:00:00 2001 From: Piotrekol <4990365+Piotrekol@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:54:47 +0200 Subject: [PATCH 07/10] Misc: skill-based strain graphs for all modes --- PpCalculator/PpCalculator.cs | 6 +- ...ator.CapturingCatchDifficultyCalculator.cs | 26 +++++ ...ator.CapturingManiaDifficultyCalculator.cs | 27 +++++ ...ulator.CapturingOsuDifficultyCalculator.cs | 26 +++++ ...ator.CapturingTaikoDifficultyCalculator.cs | 26 +++++ PpCalculator/Strain/StrainValuesCalculator.cs | 101 ++++++++++++++++++ PpCalculatorTypes/IPpCalculator.cs | 4 +- .../Extensions/PpCalculatorExtensions.cs | 77 ------------- .../BeatmapPpReplacements/PpReplacements.cs | 43 ++++++-- 9 files changed, 251 insertions(+), 85 deletions(-) create mode 100644 PpCalculator/Strain/StrainValuesCalculator.CapturingCatchDifficultyCalculator.cs create mode 100644 PpCalculator/Strain/StrainValuesCalculator.CapturingManiaDifficultyCalculator.cs create mode 100644 PpCalculator/Strain/StrainValuesCalculator.CapturingOsuDifficultyCalculator.cs create mode 100644 PpCalculator/Strain/StrainValuesCalculator.CapturingTaikoDifficultyCalculator.cs create mode 100644 PpCalculator/Strain/StrainValuesCalculator.cs delete mode 100644 StreamCompanion.Common/Extensions/PpCalculatorExtensions.cs diff --git a/PpCalculator/PpCalculator.cs b/PpCalculator/PpCalculator.cs index 2d75f187..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 { @@ -283,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(); 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/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/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(() => new Dictionary()), TokenType.Normal, ",", new Lazy(() => new Dictionary())); @@ -96,16 +100,19 @@ public async Task CreateTokensAsync(IMapSearchResult map, CancellationToken canc return; } - _strainsToken.Value = new Lazy(() => + _ = Task.Run(() => { var ppCalculator = (IPpCalculator)_ppCalculator?.Clone(); try { - return ppCalculator?.CalculateStrains(cancellationToken, _settings.Get(StrainsAmount)); + var strains = SmoothStrains(ppCalculator.GetStrainValues(_settings.Get(StrainsAmount) ?? 100), 1); + _strainsToken.Value = new Lazy(() => strains); } - catch (OperationCanceledException) + catch (Exception exception) { - return null; + _logger.Log("mapStrains calculation failed", LogLevel.Warning); + _logger.Log(exception, LogLevel.Warning); + _strainsToken.Value = new Lazy(() => null); } }); var playMode = (PlayMode)_ppCalculator.RulesetId; @@ -134,6 +141,30 @@ public async Task CreateTokensAsync(IMapSearchResult map, CancellationToken canc ResetTokens(tokenMode == TokenMode.Osu ? TokenMode.Mania : TokenMode.Osu); } + private static Dictionary SmoothStrains(Dictionary strains, int windowRadius) + { + KeyValuePair[] orderedStrains = strains.OrderBy(strain => strain.Key).ToArray(); + Dictionary smoothedStrains = new(orderedStrains.Length); + for (int strainIndex = 0; strainIndex < orderedStrains.Length; strainIndex++) + { + double sum = 0; + int count = 0; + for (int offset = -windowRadius; offset <= windowRadius; offset++) + { + int neighborIndex = strainIndex + offset; + if (neighborIndex < 0 || neighborIndex >= orderedStrains.Length) + continue; + + sum += orderedStrains[neighborIndex].Value; + count++; + } + + smoothedStrains[orderedStrains[strainIndex].Key] = Math.Max(orderedStrains[strainIndex].Value, sum / count); + } + + return smoothedStrains; + } + private double GetPp(CancellationToken cancellationToken, IPpCalculator ppCalculator, double acc, string mods = "") { ppCalculator.Mods = mods.Split(new[] { "," }, StringSplitOptions.RemoveEmptyEntries); From fdbd64fd4a71a562c29376102dd2e51450e3279f Mon Sep 17 00:00:00 2001 From: Piotrekol <4990365+Piotrekol@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:22:30 +0200 Subject: [PATCH 08/10] Misc: remove obsolete MSN plugin --- innoSetup/setupScript.iss | 2 + osu!StreamCompanion.sln | 12 - .../MSNEventSource/FirstRunMsn.Designer.cs | 109 -------- plugins/MSNEventSource/FirstRunMsn.cs | 24 -- plugins/MSNEventSource/FirstRunMsn.resx | 128 --------- plugins/MSNEventSource/MSNEventSource.csproj | 25 -- plugins/MSNEventSource/Msn.cs | 256 ------------------ .../MSNEventSource/Properties/AssemblyInfo.cs | 35 --- plugins/ScGui/MainWindowPlugin.cs | 17 +- 9 files changed, 9 insertions(+), 599 deletions(-) delete mode 100644 plugins/MSNEventSource/FirstRunMsn.Designer.cs delete mode 100644 plugins/MSNEventSource/FirstRunMsn.cs delete mode 100644 plugins/MSNEventSource/FirstRunMsn.resx delete mode 100644 plugins/MSNEventSource/MSNEventSource.csproj delete mode 100644 plugins/MSNEventSource/Msn.cs delete mode 100644 plugins/MSNEventSource/Properties/AssemblyInfo.cs diff --git a/innoSetup/setupScript.iss b/innoSetup/setupScript.iss index a08fa268..98da6ee1 100644 --- a/innoSetup/setupScript.iss +++ b/innoSetup/setupScript.iss @@ -73,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/plugins/MSNEventSource/FirstRunMsn.Designer.cs b/plugins/MSNEventSource/FirstRunMsn.Designer.cs deleted file mode 100644 index 03867bbc..00000000 --- a/plugins/MSNEventSource/FirstRunMsn.Designer.cs +++ /dev/null @@ -1,109 +0,0 @@ -namespace MSNEventSource -{ - partial class FirstRunMsn - { - /// - /// Required designer variable. - /// - private System.ComponentModel.IContainer components = null; - - /// - /// Clean up any resources being used. - /// - /// true if managed resources should be disposed; otherwise, false. - protected override void Dispose(bool disposing) - { - if (disposing && (components != null)) - { - components.Dispose(); - } - base.Dispose(disposing); - } - - #region Component Designer generated code - - /// - /// Required method for Designer support - do not modify - /// the contents of this method with the code editor. - /// - private void InitializeComponent() - { - System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(FirstRunMsn)); - this.panel1 = new System.Windows.Forms.Panel(); - this.label_Description2 = new System.Windows.Forms.Label(); - this.label_Description1 = new System.Windows.Forms.Label(); - this.label_Title = new System.Windows.Forms.Label(); - this.pictureBox1 = new System.Windows.Forms.PictureBox(); - this.panel1.SuspendLayout(); - ((System.ComponentModel.ISupportInitialize)(this.pictureBox1)).BeginInit(); - this.SuspendLayout(); - // - // panel1 - // - this.panel1.Controls.Add(this.label_Description2); - this.panel1.Controls.Add(this.label_Description1); - this.panel1.Controls.Add(this.label_Title); - this.panel1.Controls.Add(this.pictureBox1); - this.panel1.Dock = System.Windows.Forms.DockStyle.Fill; - this.panel1.Location = new System.Drawing.Point(0, 0); - this.panel1.Name = "panel1"; - this.panel1.Size = new System.Drawing.Size(399, 242); - this.panel1.TabIndex = 1; - // - // label_Description2 - // - this.label_Description2.Location = new System.Drawing.Point(3, 134); - this.label_Description2.Name = "label_Description2"; - this.label_Description2.Size = new System.Drawing.Size(388, 76); - this.label_Description2.TabIndex = 4; - this.label_Description2.Text = resources.GetString("label_Description2.Text"); - // - // label_Description1 - // - this.label_Description1.Location = new System.Drawing.Point(137, 78); - this.label_Description1.Name = "label_Description1"; - this.label_Description1.Size = new System.Drawing.Size(254, 53); - this.label_Description1.TabIndex = 3; - this.label_Description1.Text = "First, We will need you to start your osu! and enable one specific option that al" + - "lows StreamCompanion to work"; - // - // label_Title - // - this.label_Title.Location = new System.Drawing.Point(137, 3); - this.label_Title.Name = "label_Title"; - this.label_Title.Size = new System.Drawing.Size(254, 67); - this.label_Title.TabIndex = 2; - this.label_Title.Text = "Welcome!\r\n\r\nAs this is first time you\'re running StreamCompanion we\'ll setup some" + - " basic options"; - // - // pictureBox1 - // - this.pictureBox1.Location = new System.Drawing.Point(3, 3); - this.pictureBox1.Name = "pictureBox1"; - this.pictureBox1.Size = new System.Drawing.Size(128, 128); - this.pictureBox1.SizeMode = System.Windows.Forms.PictureBoxSizeMode.StretchImage; - this.pictureBox1.TabIndex = 0; - this.pictureBox1.TabStop = false; - // - // Phase1 - // - this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); - this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.Controls.Add(this.panel1); - this.Name = "FirstRunMsn"; - this.Size = new System.Drawing.Size(399, 242); - this.panel1.ResumeLayout(false); - ((System.ComponentModel.ISupportInitialize)(this.pictureBox1)).EndInit(); - this.ResumeLayout(false); - - } - - #endregion - - private System.Windows.Forms.PictureBox pictureBox1; - private System.Windows.Forms.Label label_Title; - private System.Windows.Forms.Label label_Description1; - private System.Windows.Forms.Label label_Description2; - private System.Windows.Forms.Panel panel1; - } -} diff --git a/plugins/MSNEventSource/FirstRunMsn.cs b/plugins/MSNEventSource/FirstRunMsn.cs deleted file mode 100644 index d1bdcdc3..00000000 --- a/plugins/MSNEventSource/FirstRunMsn.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Windows.Forms; -using StreamCompanionTypes.DataTypes; -using StreamCompanionTypes.Enums; -using StreamCompanionTypes.Interfaces; - -namespace MSNEventSource -{ - public partial class FirstRunMsn : UserControl, IFirstRunControl - { - public FirstRunMsn() - { - InitializeComponent(); - this.pictureBox1.Image = StreamCompanionHelper.StreamCompanionLogo(); - } - - public void GotMsn(string msnString) - { - Completed?.Invoke(this, new FirstRunCompletedEventArgs { ControlCompletionStatus = FirstRunStatus.Ok }); - } - - public event EventHandler Completed; - } -} diff --git a/plugins/MSNEventSource/FirstRunMsn.resx b/plugins/MSNEventSource/FirstRunMsn.resx deleted file mode 100644 index 242867bf..00000000 --- a/plugins/MSNEventSource/FirstRunMsn.resx +++ /dev/null @@ -1,128 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - If you haven't already - start your osu!, open options then write "MSN". -That should leave you with one option with you'll need to enable. -After that you have to change song that is playing once. - -This setup will procced when you do this automatically. - - - \ No newline at end of file diff --git a/plugins/MSNEventSource/MSNEventSource.csproj b/plugins/MSNEventSource/MSNEventSource.csproj deleted file mode 100644 index 80fb46f2..00000000 --- a/plugins/MSNEventSource/MSNEventSource.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - net8.0-windows - Library - false - true - false - false - true - - - ..\..\build\Debug\Plugins\ - - - ..\..\build\Release\Plugins\ - - - - UserControl - - - - - - \ No newline at end of file diff --git a/plugins/MSNEventSource/Msn.cs b/plugins/MSNEventSource/Msn.cs deleted file mode 100644 index 6f41b72e..00000000 --- a/plugins/MSNEventSource/Msn.cs +++ /dev/null @@ -1,256 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Runtime.InteropServices; -using System.Threading.Tasks; -using StreamCompanion.Common; -using StreamCompanionTypes.DataTypes; -using StreamCompanionTypes.Enums; -using StreamCompanionTypes.Attributes; -using StreamCompanionTypes.Interfaces; -using StreamCompanionTypes.Interfaces.Services; -using StreamCompanionTypes.Interfaces.Sources; - -namespace MSNEventSource -{ - [SCPlugin("MSN", "[Obsolete] Provides beatmap events using osu!'s MSN output", Consts.SCPLUGIN_AUTHOR, Consts.SCPLUGIN_BASEURL)] - public class Msn : IDisposable, IPlugin, IOsuEventSource, IFirstRunControlProvider - { - public static ConfigEntry Enabled = new ConfigEntry("MsnEnabled", false); - private IntPtr m_hwnd; - private Dictionary _osuStatus = new Dictionary(); - private static WNDCLASS lpWndClass; - private readonly ISettings _settings; - private ILogger _logger; - private const string lpClassName = "MsnMsgrUIManager"; - public bool Suspend { get; set; } - private string _lastMsnString = ""; - - public string Description { get; } = "Provides basic osu! events using old MSN communication that still exists in osu!"; - public string Name { get; } = nameof(Msn); - public string Author { get; } = "Piotrekol"; - public string Url { get; } = ""; - public string UpdateUrl { get; } = ""; - public EventHandler NewOsuEvent { get; set; } - - private static WndProc WndProcc; - - public Msn(ISettings settings, ILogger logger) - { - _settings = settings; - _logger = logger; - - var enabled = settings.Get(Enabled); - _logger.Log($"MSN plugin is {(enabled ? "enabled" : "disabled")}", LogLevel.Information); - if (!enabled || WndProcc != null) - return; - - WndProcc = CustomWndProc; - lpWndClass = new WNDCLASS - { - lpszClassName = lpClassName, - lpfnWndProc = WndProcc - }; - - ushort num = RegisterClassW(ref lpWndClass); - int num2 = Marshal.GetLastWin32Error(); - if ((num == 0) && (num2 != 0x582)) - { - throw new Exception("Could not register window class"); - } - this.m_hwnd = CreateWindowExW(0, lpClassName, string.Empty, 0, 0, 0, 0, 0, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero); - } - - protected virtual void OnMsnStringChanged() - { - if (Suspend) - return; - Task.Factory.StartNew(() => - { - _firstRunUserControl?.GotMsn(string.Format("{0} - {1}", _osuStatus["title"], _osuStatus["artist"])); - - var args = CreateArgs(_osuStatus); - - if (args != null) - NewOsuEvent?.Invoke(this, args); - - return 1; - }); - } - - private MapSearchArgs CreateArgs(Dictionary osuStatus) - { - OsuStatus status = osuStatus["status"] == "Listening" ? OsuStatus.Listening - : osuStatus["status"] == "Playing" ? OsuStatus.Playing - : osuStatus["status"] == "Watching" ? OsuStatus.Watching - : osuStatus["status"] == "Editing" ? OsuStatus.Editing - : OsuStatus.Null; - - osuStatus["raw"] = string.Format("{0} - {1}", osuStatus["title"], osuStatus["artist"]); - bool isFalsePlay; - lock (this) - { - isFalsePlay = IsFalsePlay(osuStatus["raw"], status, _lastMsnString); - } - if (isFalsePlay) - { - _logger?.Log(">ignoring second MSN string...", LogLevel.Debug); - } - else - { - _lastMsnString = osuStatus["raw"]; - _logger?.Log("", LogLevel.Debug); - string result = ">Got "; - foreach (var v in osuStatus) - { - if (v.Key != "raw") result = result + $"{v.Key}: \"{v.Value}\" "; - } - _logger?.Log(result, LogLevel.Information); - - var searchArgs = new MapSearchArgs("LegacyMsn", OsuEventType.MapChange) - { - Artist = osuStatus["artist"] ?? "", - Title = osuStatus["title"] ?? "", - Diff = osuStatus["diff"] ?? "", - Raw = osuStatus["raw"] ?? "", - Status = status, - - }; - return searchArgs; - } - return null; - } - - [DllImport("user32.dll", SetLastError = true)] - private static extern IntPtr CreateWindowExW(uint dwExStyle, [MarshalAs(UnmanagedType.LPWStr)] string lpClassName, [MarshalAs(UnmanagedType.LPWStr)] string lpWindowName, uint dwStyle, int x, int y, int nWidth, int nHeight, IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam); - private IntPtr CustomWndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam) - { - if (msg == 0x4a) - { - COPYDATASTRUCT copydatastruct = - (COPYDATASTRUCT)Marshal.PtrToStructure(lParam, typeof(COPYDATASTRUCT)); - - var ptr = copydatastruct.lpData; - if (ptr != IntPtr.Zero) - { - string str = Marshal.PtrToStringUni(ptr, copydatastruct.cbData / 2); - string[] separator = new string[] { @"\0" }; - string[] sourceArray = str.Split(separator, StringSplitOptions.None); - if (sourceArray.Length > 8) - { - _osuStatus["artist"] = sourceArray[5]; - _osuStatus["title"] = sourceArray[4]; - _osuStatus["diff"] = sourceArray[7]; - _osuStatus["status"] = sourceArray[3].Split(new[] { ' ' }, 2)[0]; - - OnMsnStringChanged(); - } - } - } - return DefWindowProcW(hWnd, msg, wParam, lParam); - - } - - [DllImport("user32.dll", SetLastError = true)] - private static extern IntPtr DefWindowProcW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); - [DllImport("user32.dll", SetLastError = true)] - private static extern bool DestroyWindow(IntPtr hWnd); - public void Dispose() - { - this.Dispose(true); - GC.SuppressFinalize(this); - } - - private void Dispose(bool disposing) - { - if (this.m_hwnd != IntPtr.Zero) - { - DestroyWindow(this.m_hwnd); - this.m_hwnd = IntPtr.Zero; - } - - } - - [DllImport("user32.dll", SetLastError = true)] - private static extern ushort RegisterClassW([In] ref WNDCLASS lpWndClass); - - [StructLayout(LayoutKind.Sequential)] - private struct COPYDATASTRUCT - { - public IntPtr dwData; - public int cbData; - public IntPtr lpData; - } - - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] - private struct WNDCLASS - { - public uint style; - public Msn.WndProc lpfnWndProc; - public int cbClsExtra; - public int cbWndExtra; - public IntPtr hInstance; - public IntPtr hIcon; - public IntPtr hCursor; - public IntPtr hbrBackground; - [MarshalAs(UnmanagedType.LPWStr)] - public string lpszMenuName; - [MarshalAs(UnmanagedType.LPWStr)] - public string lpszClassName; - } - - private delegate IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); - - - #region MSN double-send fix - public class MapArgs - { - public string MapName; - public OsuStatus MapAction; - } - //osu! MSN double-send detection - private readonly string[] _lastListened = new string[2]; - bool IsFalsePlay(string msnString, OsuStatus msnStatus, string lastMapString) - { - lock (_lastListened) - { - // if we're listening to a song AND it's not already in the first place of our Queue - if (msnStatus == OsuStatus.Listening && msnString != _lastListened[0]) - { - //first process our last listened song "Queue" - _lastListened[1] = _lastListened[0]; - _lastListened[0] = msnString; - } - //we have to be playing for bug to occour... - if (msnStatus != OsuStatus.Playing) - return false; - //if same string is sent 2 times in a row - if (msnString == lastMapString) - { - //this is where it gets checked for actual bug- Map gets duplicated only when we just switched from another song - //so check if we switched by checking if last listened song has changed - if (_lastListened[0] != _lastListened[1]) - { - //to avoid marking another plays(Retrys) as False- we "break" our Queue until we change song. - _lastListened[1] = _lastListened[0]; - return true; - } - } - return false; - } - } - - #endregion //MSN FIX - - private FirstRunMsn _firstRunUserControl = null; - public List GetFirstRunUserControls() - { - var firstRunControls = new List(); - if (!_settings.Get(Enabled)) - return firstRunControls; - - firstRunControls.Add(_firstRunUserControl = new FirstRunMsn()); - return firstRunControls; - } - - } -} diff --git a/plugins/MSNEventSource/Properties/AssemblyInfo.cs b/plugins/MSNEventSource/Properties/AssemblyInfo.cs deleted file mode 100644 index bdee95f6..00000000 --- a/plugins/MSNEventSource/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("MSNEventSource")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("MSNEventSource")] -[assembly: AssemblyCopyright("Copyright © 2018")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("0be726db-d370-4d9d-988a-2b79af562283")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/plugins/ScGui/MainWindowPlugin.cs b/plugins/ScGui/MainWindowPlugin.cs index 55c9bf02..58ee8e6e 100644 --- a/plugins/ScGui/MainWindowPlugin.cs +++ b/plugins/ScGui/MainWindowPlugin.cs @@ -192,18 +192,15 @@ public Task SetNewMapAsync(IMapSearchResult map, CancellationToken cancellationT { var foundMap = map.BeatmapsFound[0]; var nowPlaying = string.Format("{0} - {1}", foundMap.ArtistRoman, foundMap.TitleRoman); - if (map.Action == OsuStatus.Playing || map.Action == OsuStatus.Watching || map.MapSource != "Msn") - { - nowPlaying += $" [{foundMap.DiffName}] {map.Mods?.ShownMods ?? ""}"; - nowPlaying += $"{Environment.NewLine}NoMod:{foundMap.StarsNomod:##.###} "; + nowPlaying += $" [{foundMap.DiffName}] {map.Mods?.ShownMods ?? ""}"; + nowPlaying += $"{Environment.NewLine}NoMod:{foundMap.StarsNomod:##.###} "; - var mods = map.Mods?.Mods ?? Mods.Omod; - var token = Tokens.AllTokens.FirstOrDefault(t => t.Key.ToLower() == "mstars").Value; - if (mods != Mods.Omod && token != null) - nowPlaying += $"Modded: {token.Value:##.###} "; + var mods = map.Mods?.Mods ?? Mods.Omod; + var token = Tokens.AllTokens.FirstOrDefault(t => t.Key.ToLower() == "mstars").Value; + if (mods != Mods.Omod && token != null) + nowPlaying += $"Modded: {token.Value:##.###} "; - nowPlaying += $"{map.Action}"; - } + nowPlaying += $"{map.Action}"; _mainWindowModel.NowPlaying = nowPlaying; } else From b1ff444b31b055ed125589fbdf150fb38a39b353 Mon Sep 17 00:00:00 2001 From: Piotrekol <4990365+Piotrekol@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:30:36 +0200 Subject: [PATCH 09/10] Misc: replace custom logging with Serilog behind existing ILogger interfaces --- Directory.Packages.props | 3 + osu!StreamCompanion/Code/Core/Initializer.cs | 16 +- .../Code/Core/Loggers/ConsoleLogger.cs | 62 ----- .../Code/Core/Loggers/EmptyLogger.cs | 13 - .../Code/Core/Loggers/FileLogger.cs | 105 -------- .../Code/Core/Loggers/MainLogger.cs | 244 ++++++++++++++---- .../Code/Core/Loggers/SentryLogger.cs | 46 ---- .../Core/Maps/Processing/OsuEventHandler.cs | 4 +- .../Code/Core/Plugins/LocalPluginManager.cs | 5 +- .../Code/Modules/Logger/LoggerSettings.cs | 18 -- .../Logger/LoggerSettingsUserControl.cs | 2 +- osu!StreamCompanion/Program.cs | 1 - .../osu!StreamCompanion.csproj | 5 +- 13 files changed, 201 insertions(+), 323 deletions(-) delete mode 100644 osu!StreamCompanion/Code/Core/Loggers/ConsoleLogger.cs delete mode 100644 osu!StreamCompanion/Code/Core/Loggers/EmptyLogger.cs delete mode 100644 osu!StreamCompanion/Code/Core/Loggers/FileLogger.cs delete mode 100644 osu!StreamCompanion/Code/Core/Loggers/SentryLogger.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index a49fa1a1..5dd3237e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,6 +23,9 @@ + + + diff --git a/osu!StreamCompanion/Code/Core/Initializer.cs b/osu!StreamCompanion/Code/Core/Initializer.cs index b9db72d2..19cde5cb 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()) @@ -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..577341c9 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 StreamCompanionTypes.DataTypes; +using osu_StreamCompanion.Code.Misc; +using Serilog; +using Serilog.Core; +using Serilog.Events; +using Serilog.Parsing; +using StreamCompanionTypes; 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)) + { + EnableConsole(); + } + else { - prefix += "\t"; - message = message.Substring(1); + 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, params string[] vals) + 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; + + 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) { - for (int i = 0; i < _loggers.Count; i++) + MessageTemplate parsedTemplate = messageTemplateParser.Parse(messagePrefix + message); + LogEventProperty[] positionalProperties = new LogEventProperty[vals.Length]; + + for (int valueIndex = 0; valueIndex < vals.Length; valueIndex++) { - if (_loggers[i] is IContextAwareLogger logger) - logger.SetContextData(key, value); + 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) + { + string text = exception.ToString(); + + if (!contextData.IsEmpty) + { + 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/OsuEventHandler.cs b/osu!StreamCompanion/Code/Core/Maps/Processing/OsuEventHandler.cs index 6eca9ace..0e03e70a 100644 --- a/osu!StreamCompanion/Code/Core/Maps/Processing/OsuEventHandler.cs +++ b/osu!StreamCompanion/Code/Core/Maps/Processing/OsuEventHandler.cs @@ -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..b3e754ec 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); 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/Program.cs b/osu!StreamCompanion/Program.cs index 0fb2e45c..aee3c41b 100644 --- a/osu!StreamCompanion/Program.cs +++ b/osu!StreamCompanion/Program.cs @@ -254,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 @@ - + + + + From 99cefaa9e2fd74a713f97363e4662b02ca295cd8 Mon Sep 17 00:00:00 2001 From: Piotrekol <4990365+Piotrekol@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:25:08 +0200 Subject: [PATCH 10/10] Misc: fixup log messages to use positional parameters --- osu!StreamCompanion/Code/Core/Initializer.cs | 4 ++-- osu!StreamCompanion/Code/Core/Loggers/MainLogger.cs | 6 +++--- .../Code/Core/Maps/Processing/MapDataGetter.cs | 2 +- .../Code/Core/Maps/Processing/OsuEventHandler.cs | 2 +- .../Code/Core/Plugins/LocalPluginManager.cs | 2 +- osu!StreamCompanion/Code/Helpers/Helpers.cs | 8 ++++---- osu!StreamCompanion/Code/Modules/Updater/Updater.cs | 2 +- .../BackgroundImageProviderPlugin.cs | 2 +- .../Overlay.Common/Loader/LoaderWatchdog.cs | 6 +++--- plugins/LiveVisualizer/LiveVisualizerPlugin.cs | 2 +- plugins/OsuMapLoader/LazerMapLoader.cs | 4 ++-- plugins/OsuMapLoader/OsuMapLoaderPlugin.cs | 4 ++-- plugins/OsuMemoryEventSource/MemoryDataProcessor.cs | 2 +- plugins/OsuMemoryEventSource/MemoryListener.cs | 2 +- plugins/OsuMemoryEventSource/OsuMemoryEventSource.cs | 2 +- plugins/OsuMemoryEventSource/OsuMemoryEventSourceBase.cs | 4 ++-- plugins/ScGui/MainWindowPlugin.cs | 2 +- 17 files changed, 28 insertions(+), 28 deletions(-) diff --git a/osu!StreamCompanion/Code/Core/Initializer.cs b/osu!StreamCompanion/Code/Core/Initializer.cs index 19cde5cb..7d23db15 100644 --- a/osu!StreamCompanion/Code/Core/Initializer.cs +++ b/osu!StreamCompanion/Code/Core/Initializer.cs @@ -65,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(); diff --git a/osu!StreamCompanion/Code/Core/Loggers/MainLogger.cs b/osu!StreamCompanion/Code/Core/Loggers/MainLogger.cs index 577341c9..5e9f6b5f 100644 --- a/osu!StreamCompanion/Code/Core/Loggers/MainLogger.cs +++ b/osu!StreamCompanion/Code/Core/Loggers/MainLogger.cs @@ -1,10 +1,10 @@ -using StreamCompanionTypes.DataTypes; 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; @@ -192,7 +192,7 @@ protected void InternalLog(object logMessage, LogLevel logLevel, string messageP { 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])); @@ -203,7 +203,7 @@ protected void InternalLog(object logMessage, LogLevel logLevel, string messageP else if (logMessage is Exception exception) { string text = exception.ToString(); - + if (!contextData.IsEmpty) { text += Environment.NewLine + string.Join(Environment.NewLine, 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 0e03e70a..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(); diff --git a/osu!StreamCompanion/Code/Core/Plugins/LocalPluginManager.cs b/osu!StreamCompanion/Code/Core/Plugins/LocalPluginManager.cs index b3e754ec..30642607 100644 --- a/osu!StreamCompanion/Code/Core/Plugins/LocalPluginManager.cs +++ b/osu!StreamCompanion/Code/Core/Plugins/LocalPluginManager.cs @@ -184,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/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/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/IngameOverlays/Overlay.Common/Loader/LoaderWatchdog.cs b/plugins/IngameOverlays/Overlay.Common/Loader/LoaderWatchdog.cs index d444793d..43a2796f 100644 --- a/plugins/IngameOverlays/Overlay.Common/Loader/LoaderWatchdog.cs +++ b/plugins/IngameOverlays/Overlay.Common/Loader/LoaderWatchdog.cs @@ -45,7 +45,7 @@ private void OnBeforeInjection(object sender, EventArgs e) _lastUnknownModules = moduleList.Except(KnownOsuModules.Modules).ToList(); _lastTroublesomeModules = KnownOsuModules.TroubleMakers.Select(m => m.Key).Intersect(moduleList).ToList(); if (_lastUnknownModules.Any()) - _logger.Log($"This is a list of unknown files loaded in osu!. If you are experiencing startup osu! crashes or overlay just not appearing ingame, these will help with finding conflicting application:{Environment.NewLine}{string.Join(Environment.NewLine, _lastUnknownModules)}", LogLevel.Debug); + _logger.Log("This is a list of unknown files loaded in osu!. If you are experiencing startup osu! crashes or overlay just not appearing ingame, these will help with finding conflicting application:{0}", LogLevel.Debug, Environment.NewLine + string.Join(Environment.NewLine, _lastUnknownModules)); else _logger.Log("osu! module list is clean", LogLevel.Debug); @@ -191,8 +191,8 @@ private void HandleInjectionResult(InjectionResult helperProcessResult, bool sho return; } - _logger.Log($"Injection failed: {message}", LogLevel.Information); - _logger.Log($"{helperProcessResult}", LogLevel.Debug); + _logger.Log("Injection failed: {0}", LogLevel.Information, message ?? string.Empty); + _logger.Log("{0}", LogLevel.Debug, helperProcessResult.ToString()); if (showErrors && helperProcessResult.ResultCode != DllInjectionResult.GameProcessNotFound) { _statusReporter.Report(new(ReportType.Error, message + Environment.NewLine + $"Raw error data: {helperProcessResult}")); diff --git a/plugins/LiveVisualizer/LiveVisualizerPlugin.cs b/plugins/LiveVisualizer/LiveVisualizerPlugin.cs index d5f12889..1a0ca559 100644 --- a/plugins/LiveVisualizer/LiveVisualizerPlugin.cs +++ b/plugins/LiveVisualizer/LiveVisualizerPlugin.cs @@ -175,7 +175,7 @@ protected override void ProcessNewMap(IMapSearchResult mapSearchResult) ) { if (!isValidBeatmap) - Logger.Log($"IsValidBeatmap check failed with mapLocation: \"{mapLocation ?? ""}\"", LogLevel.Trace); + Logger.Log("IsValidBeatmap check failed with mapLocation: \"{0}\"", LogLevel.Trace, mapLocation ?? string.Empty); if (!mapSearchResult.BeatmapsFound.Any() && VisualizerData != null) { diff --git a/plugins/OsuMapLoader/LazerMapLoader.cs b/plugins/OsuMapLoader/LazerMapLoader.cs index 6f639bc2..7dcbd67d 100644 --- a/plugins/OsuMapLoader/LazerMapLoader.cs +++ b/plugins/OsuMapLoader/LazerMapLoader.cs @@ -44,11 +44,11 @@ public static class LazerMapLoader if (retryCount >= retryLimit) { ex.Data["retryCount"] = retryCount; - logger.Log($"Failed to load beatmap located at \"{osuFilePath}\" after {retryLimit} retries", LogLevel.Warning); + logger.Log("Failed to load beatmap located at \"{0}\" after {1} retries", LogLevel.Warning, osuFilePath, retryLimit.ToString()); throw; } - logger.Log($"Retrying failed beatmap load - retry {retryCount + 1}", LogLevel.Warning); + logger.Log("Retrying failed beatmap load - retry {0}", LogLevel.Warning, (retryCount + 1).ToString()); } await Task.Delay(150 * ++retryCount); diff --git a/plugins/OsuMapLoader/OsuMapLoaderPlugin.cs b/plugins/OsuMapLoader/OsuMapLoaderPlugin.cs index 807b2da3..65eb84b7 100644 --- a/plugins/OsuMapLoader/OsuMapLoaderPlugin.cs +++ b/plugins/OsuMapLoader/OsuMapLoaderPlugin.cs @@ -43,7 +43,7 @@ public async Task FindBeatmap(IMapSearchArgs args, Cancellatio if (!File.Exists(args.OsuFilePath)) { - _logger.Log($"Osu file supplied in search args was not found on disk! ({args.OsuFilePath})", LogLevel.Error); + _logger.Log("Osu file supplied in search args was not found on disk! ({0})", LogLevel.Error, args.OsuFilePath); return null; } @@ -73,7 +73,7 @@ public async Task FindBeatmap(IMapSearchArgs args, Cancellatio var ex = new BeatmapLoadFailedException(); ex.Data["location"] = args.OsuFilePath; _logger.Log(ex, LogLevel.Critical); - _logger.Log($"Failed to load beatmap located at {args.OsuFilePath}", LogLevel.Warning); + _logger.Log("Failed to load beatmap located at {0}", LogLevel.Warning, args.OsuFilePath); return null; } diff --git a/plugins/OsuMemoryEventSource/MemoryDataProcessor.cs b/plugins/OsuMemoryEventSource/MemoryDataProcessor.cs index 9eaec5fa..1608112a 100644 --- a/plugins/OsuMemoryEventSource/MemoryDataProcessor.cs +++ b/plugins/OsuMemoryEventSource/MemoryDataProcessor.cs @@ -517,7 +517,7 @@ private JsonSerializerSettings createJsonSerializerSettings(string tokenName) { Error = (sender, args) => { - _logger.Log($"Failed to serialize {tokenName} token data.", LogLevel.Debug); + _logger.Log("Failed to serialize {0} token data.", LogLevel.Debug, tokenName); _logger.Log(args, LogLevel.Trace); } }; diff --git a/plugins/OsuMemoryEventSource/MemoryListener.cs b/plugins/OsuMemoryEventSource/MemoryListener.cs index 3ce873bc..270534ee 100644 --- a/plugins/OsuMemoryEventSource/MemoryListener.cs +++ b/plugins/OsuMemoryEventSource/MemoryListener.cs @@ -84,7 +84,7 @@ public void Tick(List clientReaders, bool sendEvents) if (_currentStatus == OsuMemoryStatus.Unknown) { - _logger.Log($"Unknown memory status: {osuData.GeneralData.RawStatus}", LogLevel.Warning); + _logger.Log("Unknown memory status: {0}", LogLevel.Warning, osuData.GeneralData.RawStatus.ToString()); return; } diff --git a/plugins/OsuMemoryEventSource/OsuMemoryEventSource.cs b/plugins/OsuMemoryEventSource/OsuMemoryEventSource.cs index 5a9e053d..177a858d 100644 --- a/plugins/OsuMemoryEventSource/OsuMemoryEventSource.cs +++ b/plugins/OsuMemoryEventSource/OsuMemoryEventSource.cs @@ -48,7 +48,7 @@ public Task FindBeatmap(IMapSearchArgs searchArgs, Cancellatio var result = new MapSearchResult(searchArgs); var mods = (int)searchArgs.Mods; result.Mods = GetModsEx(mods); - Logger?.Log($">Got mods from memory: {result.Mods.ShownMods}({mods})", LogLevel.Debug); + Logger?.Log(">Got mods from memory: {0}({1})", LogLevel.Debug, result.Mods.ShownMods, mods.ToString()); Mods eMods = result.Mods?.Mods ?? Mods.Omod; if (Helpers.IsInvalidCombination(eMods)) diff --git a/plugins/OsuMemoryEventSource/OsuMemoryEventSourceBase.cs b/plugins/OsuMemoryEventSource/OsuMemoryEventSourceBase.cs index af9bc2de..8a20f162 100644 --- a/plugins/OsuMemoryEventSource/OsuMemoryEventSourceBase.cs +++ b/plugins/OsuMemoryEventSource/OsuMemoryEventSourceBase.cs @@ -103,7 +103,7 @@ public OsuMemoryEventSourceBase(IContextAwareLogger logger, ISettings settings, })); //TODO: provide tournament-manager specific data via tokens - Logger.Log($"{_clientMemoryReaders.Count} client readers prepared", LogLevel.Information); + Logger.Log("{0} client readers prepared", LogLevel.Information, _clientMemoryReaders.Count.ToString()); } else { @@ -131,7 +131,7 @@ public OsuMemoryEventSourceBase(IContextAwareLogger logger, ISettings settings, private void OnInvalidMemoryRead(object sender, (object readObject, string propPath) e) { - Logger.Log($"Failed to read \"{e.propPath}\" memory value", LogLevel.Warning); + Logger.Log("Failed to read \"{0}\" memory value", LogLevel.Warning, e.propPath); } public Task CreateTokensAsync(IMapSearchResult map, CancellationToken cancellationToken) diff --git a/plugins/ScGui/MainWindowPlugin.cs b/plugins/ScGui/MainWindowPlugin.cs index 58ee8e6e..b90f729b 100644 --- a/plugins/ScGui/MainWindowPlugin.cs +++ b/plugins/ScGui/MainWindowPlugin.cs @@ -132,7 +132,7 @@ private void ShowWindow() } catch (Exception) { - _logger.Log($"Failed to load settings for one of setting tabs, this is most likely accompanied by plugin load error at startup.", LogLevel.Error); + _logger.Log("Failed to load settings for one of setting tabs, this is most likely accompanied by plugin load error at startup.", LogLevel.Error); } }