diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 382144c..98dccfc 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,4 +1,4 @@
-name: Build
+name: Build & Test
on:
push:
@@ -34,9 +34,12 @@ jobs:
- name: Build
run: dotnet build Osu.Patcher.Injector
- - name: Upload artifacts
+ - name: Upload Injector artifact
uses: actions/upload-artifact@v4
with:
name: osu!patcher-debug
if-no-files-found: error
path: .\Osu.Patcher.Injector\bin\Debug\net8.0\**
+
+ - name: Run stub tests
+ run: dotnet run --project Osu.Stubs.Tests
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 604086d..962b066 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ target/
*.idea
*.iml
*DotSettings.user
+**/TestResult.xml
diff --git a/Osu.Patcher.Hook/Patches/BeatmapMirror/EnableOsuDirect.cs b/Osu.Patcher.Hook/Patches/BeatmapMirror/EnableOsuDirect.cs
index 9e3c251..6bc73a2 100644
--- a/Osu.Patcher.Hook/Patches/BeatmapMirror/EnableOsuDirect.cs
+++ b/Osu.Patcher.Hook/Patches/BeatmapMirror/EnableOsuDirect.cs
@@ -2,7 +2,6 @@
using System.Reflection;
using HarmonyLib;
using JetBrains.Annotations;
-using Osu.Stubs;
using Osu.Stubs.Other;
using Osu.Utils.Extensions;
using static System.Reflection.Emit.OpCodes;
diff --git a/Osu.Patcher.Hook/Patches/LivePerformance/AddPerformanceToUi.cs b/Osu.Patcher.Hook/Patches/LivePerformance/AddPerformanceToUi.cs
index c00d71a..478a0e7 100644
--- a/Osu.Patcher.Hook/Patches/LivePerformance/AddPerformanceToUi.cs
+++ b/Osu.Patcher.Hook/Patches/LivePerformance/AddPerformanceToUi.cs
@@ -4,7 +4,6 @@
using System.Reflection;
using HarmonyLib;
using JetBrains.Annotations;
-using Osu.Stubs;
using Osu.Stubs.Framework;
using Osu.Stubs.GameModes;
using Osu.Stubs.Graphics;
diff --git a/Osu.Patcher.Hook/Patches/LivePerformance/PerformanceCalculator.cs b/Osu.Patcher.Hook/Patches/LivePerformance/PerformanceCalculator.cs
index 24e9597..97d1c61 100644
--- a/Osu.Patcher.Hook/Patches/LivePerformance/PerformanceCalculator.cs
+++ b/Osu.Patcher.Hook/Patches/LivePerformance/PerformanceCalculator.cs
@@ -1,7 +1,6 @@
using System;
using System.Diagnostics;
using Osu.Performance;
-using Osu.Stubs;
using Osu.Stubs.GameModes;
using Osu.Stubs.Other;
using Osu.Stubs.Scoring;
diff --git a/Osu.Patcher.Hook/Patches/LivePerformance/PerformanceDisplay.cs b/Osu.Patcher.Hook/Patches/LivePerformance/PerformanceDisplay.cs
index 96ebb0f..0306061 100644
--- a/Osu.Patcher.Hook/Patches/LivePerformance/PerformanceDisplay.cs
+++ b/Osu.Patcher.Hook/Patches/LivePerformance/PerformanceDisplay.cs
@@ -1,5 +1,4 @@
using System;
-using Osu.Stubs;
using Osu.Stubs.Framework;
namespace Osu.Patcher.Hook.Patches.LivePerformance;
diff --git a/Osu.Patcher.Hook/Patches/LivePerformance/TrackOnScoreHit.cs b/Osu.Patcher.Hook/Patches/LivePerformance/TrackOnScoreHit.cs
index 75b9fa2..eed9ded 100644
--- a/Osu.Patcher.Hook/Patches/LivePerformance/TrackOnScoreHit.cs
+++ b/Osu.Patcher.Hook/Patches/LivePerformance/TrackOnScoreHit.cs
@@ -5,7 +5,6 @@
using HarmonyLib;
using JetBrains.Annotations;
using Osu.Performance;
-using Osu.Stubs;
using Osu.Stubs.Rulesets;
using Osu.Stubs.Scoring;
diff --git a/Osu.Patcher.Hook/Patches/LivePerformance/TrackResetScore.cs b/Osu.Patcher.Hook/Patches/LivePerformance/TrackResetScore.cs
index 6b86fa7..97543f1 100644
--- a/Osu.Patcher.Hook/Patches/LivePerformance/TrackResetScore.cs
+++ b/Osu.Patcher.Hook/Patches/LivePerformance/TrackResetScore.cs
@@ -1,7 +1,6 @@
using System.Reflection;
using HarmonyLib;
using JetBrains.Annotations;
-using Osu.Stubs;
using Osu.Stubs.Rulesets;
namespace Osu.Patcher.Hook.Patches.LivePerformance;
diff --git a/Osu.Patcher.Hook/Patches/Misc/AllowPlayModeReload.cs b/Osu.Patcher.Hook/Patches/Misc/AllowPlayModeReload.cs
index 5e312a4..cf632ff 100644
--- a/Osu.Patcher.Hook/Patches/Misc/AllowPlayModeReload.cs
+++ b/Osu.Patcher.Hook/Patches/Misc/AllowPlayModeReload.cs
@@ -3,7 +3,6 @@
using HarmonyLib;
using JetBrains.Annotations;
using Osu.Patcher.Hook.Patches.UI;
-using Osu.Stubs;
using Osu.Stubs.Other;
using Osu.Utils.Extensions;
using static System.Reflection.Emit.OpCodes;
diff --git a/Osu.Patcher.Hook/Patches/Misc/DisableErrorReporting.cs b/Osu.Patcher.Hook/Patches/Misc/DisableErrorReporting.cs
index 6b9206e..86ef251 100644
--- a/Osu.Patcher.Hook/Patches/Misc/DisableErrorReporting.cs
+++ b/Osu.Patcher.Hook/Patches/Misc/DisableErrorReporting.cs
@@ -1,7 +1,6 @@
using System.Reflection;
using HarmonyLib;
using JetBrains.Annotations;
-using Osu.Stubs;
using Osu.Stubs.Helpers;
namespace Osu.Patcher.Hook.Patches.Misc;
diff --git a/Osu.Patcher.Hook/Patches/Misc/FixDoubleSkipping.cs b/Osu.Patcher.Hook/Patches/Misc/FixDoubleSkipping.cs
index a7766e7..312a6c9 100644
--- a/Osu.Patcher.Hook/Patches/Misc/FixDoubleSkipping.cs
+++ b/Osu.Patcher.Hook/Patches/Misc/FixDoubleSkipping.cs
@@ -3,7 +3,6 @@
using System.Reflection;
using HarmonyLib;
using JetBrains.Annotations;
-using Osu.Stubs;
using Osu.Stubs.GameModes;
using Osu.Stubs.Other;
diff --git a/Osu.Patcher.Hook/Patches/Misc/LogOsuLogger.cs b/Osu.Patcher.Hook/Patches/Misc/LogOsuLogger.cs
index dec58ec..a6c2fe3 100644
--- a/Osu.Patcher.Hook/Patches/Misc/LogOsuLogger.cs
+++ b/Osu.Patcher.Hook/Patches/Misc/LogOsuLogger.cs
@@ -5,7 +5,6 @@
using System.Reflection;
using HarmonyLib;
using JetBrains.Annotations;
-using Osu.Stubs;
using Osu.Stubs.Helpers;
namespace Osu.Patcher.Hook.Patches.Misc;
diff --git a/Osu.Patcher.Hook/Patches/Misc/LogSoftErrors.cs b/Osu.Patcher.Hook/Patches/Misc/LogSoftErrors.cs
index 5b06b88..918f8ec 100644
--- a/Osu.Patcher.Hook/Patches/Misc/LogSoftErrors.cs
+++ b/Osu.Patcher.Hook/Patches/Misc/LogSoftErrors.cs
@@ -2,7 +2,6 @@
using System.Reflection;
using HarmonyLib;
using JetBrains.Annotations;
-using Osu.Stubs;
using Osu.Stubs.Other;
namespace Osu.Patcher.Hook.Patches.Misc;
diff --git a/Osu.Patcher.Hook/Patches/Mods/PatchSuddenDeathAutoRetry.cs b/Osu.Patcher.Hook/Patches/Mods/PatchSuddenDeathAutoRetry.cs
index f3eb5ff..bf0b27e 100644
--- a/Osu.Patcher.Hook/Patches/Mods/PatchSuddenDeathAutoRetry.cs
+++ b/Osu.Patcher.Hook/Patches/Mods/PatchSuddenDeathAutoRetry.cs
@@ -2,7 +2,6 @@
using System.Reflection;
using HarmonyLib;
using JetBrains.Annotations;
-using Osu.Stubs;
using Osu.Stubs.Rulesets;
using static System.Reflection.Emit.OpCodes;
diff --git a/Osu.Patcher.Hook/Patches/UI/AllowOpenOptionsInGameplay.cs b/Osu.Patcher.Hook/Patches/UI/AllowOpenOptionsInGameplay.cs
index 4d5d224..124b814 100644
--- a/Osu.Patcher.Hook/Patches/UI/AllowOpenOptionsInGameplay.cs
+++ b/Osu.Patcher.Hook/Patches/UI/AllowOpenOptionsInGameplay.cs
@@ -2,7 +2,6 @@
using System.Reflection;
using HarmonyLib;
using JetBrains.Annotations;
-using Osu.Stubs;
using Osu.Stubs.Other;
using Osu.Utils.Extensions;
using static System.Reflection.Emit.OpCodes;
diff --git a/Osu.Patcher.Hook/Patches/UI/CustomSongSelectThumbnailAlpha.cs b/Osu.Patcher.Hook/Patches/UI/CustomSongSelectThumbnailAlpha.cs
index 65b923d..70e0d1e 100644
--- a/Osu.Patcher.Hook/Patches/UI/CustomSongSelectThumbnailAlpha.cs
+++ b/Osu.Patcher.Hook/Patches/UI/CustomSongSelectThumbnailAlpha.cs
@@ -3,7 +3,6 @@
using System.Reflection;
using HarmonyLib;
using JetBrains.Annotations;
-using Osu.Stubs;
using Osu.Stubs.SongSelect;
using Osu.Utils.Extensions;
using static System.Reflection.Emit.OpCodes;
diff --git a/Osu.Patcher.Hook/Patches/UI/RevertSortWhenNoGroup.cs b/Osu.Patcher.Hook/Patches/UI/RevertSortWhenNoGroup.cs
index 70273a9..4cc34de 100644
--- a/Osu.Patcher.Hook/Patches/UI/RevertSortWhenNoGroup.cs
+++ b/Osu.Patcher.Hook/Patches/UI/RevertSortWhenNoGroup.cs
@@ -3,7 +3,6 @@
using System.Reflection.Emit;
using HarmonyLib;
using JetBrains.Annotations;
-using Osu.Stubs;
using Osu.Stubs.Helpers;
using Osu.Stubs.SongSelect;
using Osu.Utils.Extensions;
diff --git a/Osu.Patcher.Hook/Patches/UI/ShowModsInGameplay.cs b/Osu.Patcher.Hook/Patches/UI/ShowModsInGameplay.cs
index 5b2b0d1..ecd48b7 100644
--- a/Osu.Patcher.Hook/Patches/UI/ShowModsInGameplay.cs
+++ b/Osu.Patcher.Hook/Patches/UI/ShowModsInGameplay.cs
@@ -3,7 +3,6 @@
using System.Reflection;
using HarmonyLib;
using JetBrains.Annotations;
-using Osu.Stubs;
using Osu.Stubs.GameModes;
using static System.Reflection.Emit.OpCodes;
diff --git a/Osu.Stubs.Tests/Osu.Stubs.Tests.csproj b/Osu.Stubs.Tests/Osu.Stubs.Tests.csproj
new file mode 100644
index 0000000..1ce1e03
--- /dev/null
+++ b/Osu.Stubs.Tests/Osu.Stubs.Tests.csproj
@@ -0,0 +1,45 @@
+
+
+
+ Exe
+ Osu.Stubs.Tests
+ net462
+ x86
+ 12
+ enable
+
+
+ true
+ full
+ false
+ DEBUG;TRACE
+ prompt
+ 4
+
+
+ none
+ true
+ TRACE
+ prompt
+ 4
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
diff --git a/Osu.Stubs.Tests/OsuApi.cs b/Osu.Stubs.Tests/OsuApi.cs
new file mode 100644
index 0000000..178cb83
--- /dev/null
+++ b/Osu.Stubs.Tests/OsuApi.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net.Http;
+using System.Threading.Tasks;
+using JetBrains.Annotations;
+using Newtonsoft.Json;
+
+namespace Osu.Stubs.Tests;
+
+[PublicAPI]
+public static class OsuApi
+{
+ [PublicAPI]
+ public enum ReleaseStream
+ {
+ CuttingEdge,
+ Stable40,
+ Beta40,
+ }
+
+ private static readonly HttpClient Http = new();
+
+ ///
+ /// Gets the latest release files for a specific release stream.
+ ///
+ public static async Task> GetReleaseFiles(ReleaseStream stream)
+ {
+ Console.WriteLine("Fetching latest osu! update info");
+
+ var url = $"https://osu.ppy.sh/web/check-updates.php" +
+ $"?action=check" +
+ $"&stream={stream.ToString().ToLower()}" +
+ $"&time={DateTime.Now.Ticks}";
+
+ using var response = await Http.GetAsync(url);
+ response.EnsureSuccessStatusCode();
+
+ var bodyText = await response.Content.ReadAsStringAsync();
+ if (bodyText == null) throw new Exception("Response returned no body");
+
+ var releaseFiles = JsonConvert.DeserializeObject>(bodyText);
+ if (releaseFiles == null) throw new Exception("Failed to deserialize update files");
+
+ return releaseFiles;
+ }
+
+ ///
+ /// Downloads the full osu! update file list to a specific directory.
+ ///
+ /// An empty directory.
+ /// The release stream to download.
+ public static async Task DownloadOsu(string dir, ReleaseStream stream = ReleaseStream.Stable40)
+ {
+ var updateFiles = await GetReleaseFiles(ReleaseStream.Stable40);
+
+ Parallel.ForEach(updateFiles, updateFile =>
+ {
+ Console.WriteLine($"Downloading {updateFile.FileName}");
+ DownloadFile(updateFile.DownloadUrl, Path.Combine(dir, updateFile.FileName)).Wait();
+ });
+
+ Console.WriteLine("Finished downloading osu!");
+ }
+
+ private static async Task DownloadFile(string url, string path)
+ {
+ using var response = await Http.GetAsync(url);
+ response.EnsureSuccessStatusCode();
+
+ var bodyStream = await response.Content.ReadAsStreamAsync();
+ if (bodyStream == null) throw new Exception("Response returned no body");
+
+ using var file = File.Create(path);
+ await bodyStream.CopyToAsync(file);
+ }
+}
\ No newline at end of file
diff --git a/Osu.Stubs.Tests/OsuUpdateFile.cs b/Osu.Stubs.Tests/OsuUpdateFile.cs
new file mode 100644
index 0000000..6804b17
--- /dev/null
+++ b/Osu.Stubs.Tests/OsuUpdateFile.cs
@@ -0,0 +1,30 @@
+using Newtonsoft.Json;
+
+namespace Osu.Stubs.Tests;
+
+public class OsuUpdateFile
+{
+ [JsonRequired]
+ [JsonProperty("file_version")]
+ public int FileVersion { get; set; }
+
+ [JsonRequired]
+ [JsonProperty("filesize")]
+ public int FileSize { get; set; }
+
+ [JsonRequired]
+ [JsonProperty("filename")]
+ public string FileName { get; set; } = null!;
+
+ [JsonRequired]
+ [JsonProperty("file_hash")]
+ public string FileHash { get; set; } = null!;
+
+ [JsonRequired]
+ [JsonProperty("timestamp")]
+ public string Timestamp { get; set; } = null!;
+
+ [JsonRequired]
+ [JsonProperty("url_full")]
+ public string DownloadUrl { get; set; } = null!;
+}
\ No newline at end of file
diff --git a/Osu.Stubs.Tests/Program.cs b/Osu.Stubs.Tests/Program.cs
new file mode 100644
index 0000000..8e470e7
--- /dev/null
+++ b/Osu.Stubs.Tests/Program.cs
@@ -0,0 +1,8 @@
+using NUnitLite;
+
+namespace Osu.Stubs.Tests;
+
+public static class Program
+{
+ public static int Main(string[] args) => new AutoRun().Execute(args);
+}
\ No newline at end of file
diff --git a/Osu.Stubs.Tests/TestStubs.cs b/Osu.Stubs.Tests/TestStubs.cs
new file mode 100644
index 0000000..c229428
--- /dev/null
+++ b/Osu.Stubs.Tests/TestStubs.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using NUnit.Framework;
+using Osu.Utils.Extensions;
+using Osu.Utils.Lazy;
+
+#pragma warning disable CS0618 // Type or member is obsolete
+
+namespace Osu.Stubs.Tests;
+
+[TestFixture]
+[Parallelizable]
+public class TestStubs
+{
+ [TestCaseSource(nameof(LoadStubs))]
+ public void TestStub(ILazy lazy) => Assert.DoesNotThrow(
+ () =>
+ {
+ try
+ {
+ lazy.Fill();
+ }
+ catch (AggregateException e)
+ {
+ if (e.InnerException is ReflectionTypeLoadException typeLoadException)
+ {
+ throw new AggregateException(typeLoadException.LoaderExceptions);
+ }
+
+ throw e.InnerException!;
+ }
+ }
+ );
+
+ [Test(Description = "Tests cannot be run in 64bit mode!", ExpectedResult = true)]
+ public static bool CheckIs32Bit() => !Environment.Is64BitProcess;
+
+ private static IEnumerable> LoadStubs()
+ {
+ if (!CheckIs32Bit()) return [];
+
+ var osuDir = Path.Combine(Assembly.GetExecutingAssembly().Location, "../osu!");
+ var osuExe = Path.Combine(osuDir, "osu!.exe");
+
+ if (!Directory.Exists(osuDir)) // TODO: check file hashes to force re-download
+ {
+ Directory.CreateDirectory(osuDir);
+ OsuApi.DownloadOsu(osuDir).Wait();
+ }
+
+ // Add osu! directory to assembly search path
+ AppDomain.CurrentDomain.AppendPrivatePath(osuDir);
+
+ // Load the osu! executable and it's dependencies as assemblies without executing
+ Assembly.LoadFile(osuExe);
+
+ var stubs = new List>(300);
+
+ foreach (var type in Assembly.GetAssembly(typeof(Stub)).GetTypes())
+ {
+ foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Static))
+ {
+ if (field.GetCustomAttribute(typeof(Stub)) == null)
+ continue;
+
+ var stub = field.GetValue>(null);
+ if (stub == null) throw new Exception();
+
+ stubs.Add(stub);
+ }
+ }
+
+ stubs.Sort((a, b) =>
+ string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
+
+ return stubs;
+ }
+}
\ No newline at end of file
diff --git a/Osu.Stubs/Graphics/SkinManager.cs b/Osu.Stubs/Graphics/SkinManager.cs
index 68c9d8c..a7964db 100644
--- a/Osu.Stubs/Graphics/SkinManager.cs
+++ b/Osu.Stubs/Graphics/SkinManager.cs
@@ -44,7 +44,7 @@ public static class SkinManager
///
[Stub]
public static readonly LazyField