From 43a421d2248ee108ca92cbd7200ac92632190089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=94=EB=83=90?= Date: Mon, 5 Oct 2026 14:02:51 +0900 Subject: [PATCH] Release BlockModLoader 1.0.0 with loader whitelist and recommendations --- ...elonLoader.csproj => BlockModLoader.csproj | 10 +- LoaderGuard.cs | 55 ++++++- README.md | 111 ++++++++++--- Tests/BlockMelonLoaderTests.cs | 44 ----- ...sts.csproj => BlockModLoader.Tests.csproj} | 4 +- Tests/BlockModLoaderTests.cs | 152 ++++++++++++++++++ Tests/Program.cs | 4 +- build.ps1 | 10 +- 8 files changed, 301 insertions(+), 89 deletions(-) rename BlockMelonLoader.csproj => BlockModLoader.csproj (81%) delete mode 100644 Tests/BlockMelonLoaderTests.cs rename Tests/{BlockMelonLoader.Tests.csproj => BlockModLoader.Tests.csproj} (85%) create mode 100644 Tests/BlockModLoaderTests.cs diff --git a/BlockMelonLoader.csproj b/BlockModLoader.csproj similarity index 81% rename from BlockMelonLoader.csproj rename to BlockModLoader.csproj index d93d55a..cf5f3fc 100644 --- a/BlockMelonLoader.csproj +++ b/BlockModLoader.csproj @@ -4,12 +4,12 @@ Debug {173C8DD2-70E0-4CA2-BC2B-9B839CE464A9} Libraryv4.8latest - BlockMelonLoaderBlockMelonLoader - BlockMelonLoader1.0.0 - imnyangBlocks mod initialization when MelonLoader is active. + BlockModLoaderBlockModLoader + BlockModLoader1.0.0 + imnyangBlocks mod initialization under MelonLoader, BepInEx, or Unity Mod Manager unless explicitly whitelisted. GPL-3.0-only - https://git.mizuki.guru/imnyang/BlockMelonLoader - https://git.mizuki.guru/imnyang/BlockMelonLoader.git + https://git.mizuki.guru/imnyang/BlockModLoader + https://git.mizuki.guru/imnyang/BlockModLoader.git git README.md $(MSBuildProjectDirectory)/artifacts diff --git a/LoaderGuard.cs b/LoaderGuard.cs index 1bc941a..761303c 100644 --- a/LoaderGuard.cs +++ b/LoaderGuard.cs @@ -4,25 +4,56 @@ using System.Collections.Generic; using System.Reflection; using System.Runtime.InteropServices; -namespace BlockMelonLoader +namespace BlockModLoader { + [Flags] + public enum WhiteListLoader + { + None = 0, + MelonLoader = 1, + BepInEx = 2, + UMM = 4, + All = MelonLoader | BepInEx | UMM + } + public static class LoaderGuard { public static void EnsureAllowed(string modName) { - EnsureAllowed(modName, AppDomain.CurrentDomain.GetAssemblies(), ShowAlert, Environment.FailFast); + EnsureAllowed(modName, null); + } + + public static void EnsureAllowed(string modName, string recommendedLoader) + { + EnsureAllowed(modName, WhiteListLoader.None, recommendedLoader); + } + + public static void EnsureAllowed(string modName, WhiteListLoader whiteListLoader, + string recommendedLoader = null) + { + EnsureAllowed(modName, AppDomain.CurrentDomain.GetAssemblies(), ShowAlert, Environment.FailFast, + recommendedLoader, whiteListLoader); } internal static void EnsureAllowed(string modName, IEnumerable assemblies, - Action showAlert, Action terminate) + Action showAlert, Action terminate, string recommendedLoader = null, + WhiteListLoader whiteListLoader = WhiteListLoader.None) { + if ((whiteListLoader & ~WhiteListLoader.All) != 0) + throw new ArgumentOutOfRangeException(nameof(whiteListLoader)); + foreach (var assembly in assemblies) { - if (!string.Equals(assembly.GetName().Name, "MelonLoader", StringComparison.OrdinalIgnoreCase)) + var loader = GetLoader(assembly.GetName().Name); + if (loader == WhiteListLoader.None || (whiteListLoader & loader) != 0) continue; - var message = modName + " cannot run while MelonLoader is active.\n\n" - + "Remove MelonLoader and install this mod using Unity Mod Manager.\n\n" + var loaderName = loader == WhiteListLoader.UMM ? "Unity Mod Manager (UMM)" : loader.ToString(); + var message = modName + " cannot run while " + loaderName + " is active.\n\n" + + "Remove the unsupported mod loader and follow this mod's installation instructions.\n\n" + + (string.IsNullOrWhiteSpace(recommendedLoader) + ? string.Empty + : "Recommended mod loader: " + recommendedLoader.Trim() + ".\n\n") + "The game will close after this message."; // Loader exception handlers can swallow a thrown exception, so termination // must happen even if the native dialog fails. @@ -32,6 +63,18 @@ namespace BlockMelonLoader } } + private static WhiteListLoader GetLoader(string assemblyName) + { + if (string.Equals(assemblyName, "MelonLoader", StringComparison.OrdinalIgnoreCase)) + return WhiteListLoader.MelonLoader; + if (string.Equals(assemblyName, "BepInEx", StringComparison.OrdinalIgnoreCase) + || string.Equals(assemblyName, "BepInEx.Core", StringComparison.OrdinalIgnoreCase)) + return WhiteListLoader.BepInEx; + if (string.Equals(assemblyName, "UnityModManager", StringComparison.OrdinalIgnoreCase)) + return WhiteListLoader.UMM; + return WhiteListLoader.None; + } + private static void ShowAlert(string message) { Console.Error.WriteLine(message); diff --git a/README.md b/README.md index 8e45723..c4b6199 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,96 @@ -# BlockMelonLoader +# BlockModLoader -A .NET Framework 4.8 library with no Unity or MelonLoader dependency. -Call `BlockMelonLoader.LoaderGuard.EnsureAllowed("YourMod")` before initializing -a mod. Building this library does not require a game installation. +A .NET Framework 4.8 library with no Unity or mod loader dependency. +Call `BlockModLoader.LoaderGuard.EnsureAllowed("YourMod")` before initializing +a mod or game integration. Building this library does not require a game installation. -If an assembly named `MelonLoader` is loaded in the current AppDomain, the guard -shows a Windows error dialog and calls `Environment.FailFast` after dismissal. -Termination also happens if showing the dialog fails. On other platforms the -message is written to standard error before termination; there is no native dialog. -The guard does not inspect installation folders, modify loader files, or monitor -assemblies loaded after the check. UMM and MelonLoader running together are blocked. +By default, the guard blocks any of these loaded assemblies in the current AppDomain +(exact names, case-insensitive): -Add a `PackageReference` dependency on `BlockMelonLoader` version `1.0.0`. -Ship `BlockMelonLoader.dll` alongside your mod DLL; NuGet copies the runtime asset -to the consuming project's build output. +| Loader | Assembly names | +| --- | --- | +| MelonLoader | `MelonLoader` | +| BepInEx | `BepInEx`, `BepInEx.Core` | +| Unity Mod Manager (UMM) | `UnityModManager` | -This is an initialization guard, not a MelonLoader plugin. Simply placing the -UMM-only mod DLL in MelonLoader's Mods folder will not invoke the guard. -A bridge that invokes the UMM entry point is checked; direct assembly loading -without invoking that entry point is not. Modified binaries can bypass this check. +The BepInEx assembly names follow the [BepInEx 5 API](https://docs.bepinex.dev/api/index.html) +and [BepInEx 6 API](https://docs.bepinex.dev/master/api/index.html); UMM uses +[`UnityModManager`](https://github.com/newman55/unity-mod-manager/blob/master/UnityModManager/UnityModManager.csproj). + +When a non-whitelisted supported loader is detected, the guard names it in a Windows error +dialog and calls `Environment.FailFast` after dismissal, terminating the entire +game process. Termination also happens if showing the dialog fails. On other +platforms the message is written to standard error before termination. +When multiple loaders are present, the first detected non-whitelisted loader triggers +one dialog and termination. Without a whitelist, all three loaders are blocked. + +The guard checks only assemblies already loaded when `EnsureAllowed` is called. +It does not inspect installation folders, modify loader files, or monitor later +assembly loads. Names such as `MelonLoader.Helper` or `BepInEx.Helper` are not +matched. Renamed or modified loader binaries can bypass the check. + +This is an initialization guard. Merely placing its DLL in a game or loader +folder will not invoke it: the consuming mod or game must call `EnsureAllowed`. + +## Allowed loaders + +Use `WhiteListLoader` to allow selected loaders for each guard call: + +```csharp +using BlockModLoader; + +// Allow BepInEx; block MelonLoader and UMM. +LoaderGuard.EnsureAllowed("YourMod", whiteListLoader: WhiteListLoader.BepInEx, + recommendedLoader: "BepInEx"); + +// Allow both BepInEx and UMM; block MelonLoader. +LoaderGuard.EnsureAllowed("YourMod", WhiteListLoader.BepInEx | WhiteListLoader.UMM); +``` + +Available values are `None`, `MelonLoader`, `BepInEx`, `UMM`, and `All`. +Combine values with `|`. `None` (the default for existing calls) blocks all three; +`All` allows all three. `BepInEx` allows both `BepInEx` and `BepInEx.Core`. +An allowed loader does not prevent detection of another blocked loader in the +same AppDomain. Unknown flag values throw `ArgumentOutOfRangeException`. +The whitelist applies only to these three known loaders; other assemblies are +still ignored. Each call supplies its own whitelist, with no shared global setting. + +## Recommended loader in the alert + +Pass an optional display name as the second argument to include a recommendation +in the alert: + +```csharp +BlockModLoader.LoaderGuard.EnsureAllowed("YourMod", recommendedLoader: "YourPreferredLoader"); +``` + +The alert adds `Recommended mod loader: YourPreferredLoader.` before the game +closure notice. Omit the second argument, or pass null, an empty string, or +whitespace, to keep the original generic guidance. Leading and trailing +whitespace is trimmed. This setting changes only the alert text; it does not +allow the recommended loader or change which loaders are blocked. + +## Migration from BlockMelonLoader + +Replace the `BlockMelonLoader` package reference with `BlockModLoader` version +`1.0.0`, update imports to `using BlockModLoader;`, and ship `BlockModLoader.dll` +alongside your mod DLL. NuGet copies the runtime asset to the consuming project's +build output. The `LoaderGuard.EnsureAllowed("YourMod")` method remains the same, +but now blocks BepInEx and UMM as well as MelonLoader. ## Build and test On Windows, run `./build.ps1 -Test`. Visual Studio or Rider MSBuild is required; use `-MSBuildPath` to select it explicitly. The script downloads .NET Framework 4.8 reference assemblies into the ignored `packages` folder when needed. -The DLL is written to `bin/Release/BlockMelonLoader.dll` and the NuGet package to -`artifacts/BlockMelonLoader.1.0.0.nupkg`. Packaging uses `NuGet.Build.Tasks.Pack`. +The DLL is written to `bin/Release/BlockModLoader.dll` and the NuGet package to +`artifacts/BlockModLoader.1.0.0.nupkg`. Packaging uses `NuGet.Build.Tasks.Pack`. -The standalone tests check detection, notification ordering, and dialog failure -handling. They substitute the dialog and termination callbacks and do not crash -the game. Run them directly with `Tests/bin/Release/BlockMelonLoader.Tests.exe`. +The standalone tests check every loader name, case-insensitive detection, +unrelated assemblies, multiple loaders, notification ordering, and dialog failure +handling, plus every whitelist combination and invalid flags. +They substitute the dialog and termination callbacks and do not crash +the game. Run them directly with `Tests/bin/Release/BlockModLoader.Tests.exe`. ## Installation @@ -40,16 +100,17 @@ The NuGet feed for this project is: https://git.mizuki.guru/api/packages/imnyang/nuget/index.json ``` -Add that feed to the consuming project's `NuGet.Config`, then add the dependency: +After the new package has been published to that feed, add it to the consuming +project's `NuGet.Config`, then add the dependency: ```xml - + ``` For local development, build this repository and use the generated `.nupkg` in a local NuGet feed. The source repository is not required when building a consumer. New library versions must use a new package version and update the consumer's -dependency version. +dependency version. The source repository is https://git.mizuki.guru/imnyang/BlockModLoader. ## License diff --git a/Tests/BlockMelonLoaderTests.cs b/Tests/BlockMelonLoaderTests.cs deleted file mode 100644 index 69995d5..0000000 --- a/Tests/BlockMelonLoaderTests.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Reflection; -using System.Reflection.Emit; -using BlockMelonLoader; - -internal static class BlockMelonLoaderTests -{ - internal static void Run() - { - var alerts = 0; - var terminations = 0; - Action alert = message => - { - if (!message.Contains("SampleMod") || !message.Contains("Unity Mod Manager")) - throw new Exception("Missing mod name or installation guidance."); - alerts++; - }; - Action terminate = message => - { - if (alerts == 0) throw new Exception("Termination preceded notification."); - terminations++; - }; - - var unrelated = AppDomain.CurrentDomain.DefineDynamicAssembly( - new AssemblyName("MelonLoader.Helper"), AssemblyBuilderAccess.Run); - LoaderGuard.EnsureAllowed("SampleMod", new[] { typeof(LoaderGuard).Assembly, unrelated }, alert, terminate); - if (alerts != 0 || terminations != 0) throw new Exception("Blocked an unrelated assembly."); - - var loader = AppDomain.CurrentDomain.DefineDynamicAssembly( - new AssemblyName("melonloader"), AssemblyBuilderAccess.Run); - LoaderGuard.EnsureAllowed("SampleMod", new[] { loader }, alert, terminate); - if (alerts != 1 || terminations != 1) throw new Exception("MelonLoader was not blocked."); - - try - { - LoaderGuard.EnsureAllowed("SampleMod", new[] { loader }, - message => { throw new InvalidOperationException("Dialog unavailable."); }, - message => terminations++); - throw new Exception("Expected the simulated dialog failure."); - } - catch (InvalidOperationException) { } - if (terminations != 2) throw new Exception("Dialog failure bypassed termination."); - } -} diff --git a/Tests/BlockMelonLoader.Tests.csproj b/Tests/BlockModLoader.Tests.csproj similarity index 85% rename from Tests/BlockMelonLoader.Tests.csproj rename to Tests/BlockModLoader.Tests.csproj index 26b654d..2927190 100644 --- a/Tests/BlockMelonLoader.Tests.csproj +++ b/Tests/BlockModLoader.Tests.csproj @@ -1,7 +1,7 @@ - ExeBlockMelonLoader.Testsv4.8 + ExeBlockModLoader.Testsv4.8 latestbin/Release/ $(MSBuildProjectDirectory)/../packages/net48/build/ $(MSBuildProjectDirectory)/../packages/net48/build/.NETFramework/v4.8 @@ -11,7 +11,7 @@ false false - + diff --git a/Tests/BlockModLoaderTests.cs b/Tests/BlockModLoaderTests.cs new file mode 100644 index 0000000..c40f165 --- /dev/null +++ b/Tests/BlockModLoaderTests.cs @@ -0,0 +1,152 @@ +using System; +using System.Reflection; +using System.Reflection.Emit; +using BlockModLoader; + +internal static class BlockModLoaderTests +{ + internal static void Run() + { + var unrelated = new[] + { + typeof(LoaderGuard).Assembly, CreateAssembly("MelonLoader.Helper"), + CreateAssembly("BepInEx.Helper"), CreateAssembly("BepInEx.Core.Helper"), + CreateAssembly("UnityModManager.Helper"), CreateAssembly("Harmony") + }; + Action unexpected = message => { throw new Exception("Blocked an unrelated assembly."); }; + LoaderGuard.EnsureAllowed("SampleMod", new Assembly[0], unexpected, unexpected); + LoaderGuard.EnsureAllowed("SampleMod", unrelated, unexpected, unexpected); + + CheckLoader("MelonLoader", "MelonLoader"); + CheckLoader("melonloader", "MelonLoader"); + CheckLoader("BepInEx", "BepInEx"); + CheckLoader("bepinex", "BepInEx"); + CheckLoader("BepInEx.Core", "BepInEx"); + CheckLoader("bepinex.core", "BepInEx"); + CheckLoader("UnityModManager", "Unity Mod Manager (UMM)"); + CheckLoader("unitymodmanager", "Unity Mod Manager (UMM)"); + CheckWhiteList(); + + foreach (var loaderName in new[] { "MelonLoader", "BepInEx", "BepInEx.Core", "UnityModManager" }) + { + foreach (var recommendation in new[] { null, "", " \t ", "CustomLoader", " UMM " }) + CheckRecommendation(loaderName, recommendation); + } + LoaderGuard.EnsureAllowed("SampleMod", unrelated, unexpected, unexpected, "CustomLoader"); + + var alerts = 0; + var terminations = 0; + LoaderGuard.EnsureAllowed("SampleMod", new[] + { unrelated[1], CreateAssembly("MelonLoader"), CreateAssembly("BepInEx.Core"), CreateAssembly("UnityModManager") }, + message => alerts++, message => terminations++); + if (alerts != 1 || terminations != 1) + throw new Exception("Multiple loaders should trigger only one notification and termination."); + } + + private static void CheckWhiteList() + { + var names = new[] { "melonloader", "bepinex", "bepinex.core", "unitymodmanager" }; + var types = new[] { WhiteListLoader.MelonLoader, WhiteListLoader.BepInEx, + WhiteListLoader.BepInEx, WhiteListLoader.UMM }; + for (var flags = 0; flags <= (int)WhiteListLoader.All; flags++) + { + var whiteList = (WhiteListLoader)flags; + for (var i = 0; i < names.Length; i++) + { + var alerts = 0; + var terminations = 0; + LoaderGuard.EnsureAllowed("SampleMod", new[] { CreateAssembly(names[i]) }, + message => alerts++, message => terminations++, "CustomLoader", whiteList); + var expected = (whiteList & types[i]) != 0 ? 0 : 1; + if (alerts != expected || terminations != expected) + throw new Exception("Whitelist incorrectly handled " + names[i] + " with " + whiteList); + } + + var mixedAlerts = 0; + var mixedTerminations = 0; + LoaderGuard.EnsureAllowed("SampleMod", new[] { CreateAssembly("BepInEx.Core"), + CreateAssembly("UnityModManager"), CreateAssembly("MelonLoader") }, + message => mixedAlerts++, message => mixedTerminations++, null, whiteList); + var mixedExpected = whiteList == WhiteListLoader.All ? 0 : 1; + if (mixedAlerts != mixedExpected || mixedTerminations != mixedExpected) + throw new Exception("An allowed loader bypassed detection of a blocked loader."); + } + + foreach (var invalid in new[] { (WhiteListLoader)8, (WhiteListLoader)(-1) }) + { + try + { + LoaderGuard.EnsureAllowed("SampleMod", new Assembly[0], + message => { }, message => { }, null, invalid); + throw new Exception("Expected invalid whitelist flags to be rejected."); + } + catch (ArgumentOutOfRangeException) { } + } + } + + private static void CheckRecommendation(string loaderName, string recommendation) + { + string alertMessage = null; + var terminations = 0; + LoaderGuard.EnsureAllowed("SampleMod", new[] { CreateAssembly(loaderName) }, + message => + { + alertMessage = message; + if (string.IsNullOrWhiteSpace(recommendation)) + { + if (message.Contains("Recommended mod loader:")) + throw new Exception("An empty recommendation should not be displayed."); + } + else if (!message.Contains("Recommended mod loader: " + recommendation.Trim() + ".\n\n")) + throw new Exception("The configured recommendation was not displayed."); + }, + message => + { + if (alertMessage == null || message != alertMessage) + throw new Exception("Recommended loader message was lost before termination."); + terminations++; + }, recommendation); + if (terminations != 1) + throw new Exception("A recommendation changed loader blocking behavior."); + } + + private static void CheckLoader(string assemblyName, string loaderName) + { + var loader = CreateAssembly(assemblyName); + var alerts = 0; + var terminations = 0; + string alertMessage = null; + LoaderGuard.EnsureAllowed("SampleMod", new[] { typeof(LoaderGuard).Assembly, loader }, + message => + { + if (!message.Contains("SampleMod cannot run while " + loaderName + " is active.") + || !message.Contains("follow this mod's installation instructions.") + || !message.Contains("The game will close")) + throw new Exception("Missing mod name, detected loader, or installation guidance."); + alertMessage = message; + alerts++; + }, + message => + { + if (alerts != 1 || message != alertMessage) + throw new Exception("Termination preceded notification or used a different message."); + terminations++; + }); + if (alerts != 1 || terminations != 1) throw new Exception(assemblyName + " was not blocked."); + + try + { + LoaderGuard.EnsureAllowed("SampleMod", new[] { loader }, + message => { throw new InvalidOperationException("Dialog unavailable."); }, + message => terminations++); + throw new Exception("Expected the simulated dialog failure."); + } + catch (InvalidOperationException) { } + if (terminations != 2) throw new Exception("Dialog failure bypassed termination for " + assemblyName); + } + + private static Assembly CreateAssembly(string name) + { + return AppDomain.CurrentDomain.DefineDynamicAssembly(new AssemblyName(name), AssemblyBuilderAccess.Run); + } +} diff --git a/Tests/Program.cs b/Tests/Program.cs index 5fdd3a0..da86b0e 100644 --- a/Tests/Program.cs +++ b/Tests/Program.cs @@ -6,8 +6,8 @@ internal static class Program { try { - BlockMelonLoaderTests.Run(); - Console.WriteLine("BlockMelonLoader tests passed."); + BlockModLoaderTests.Run(); + Console.WriteLine("BlockModLoader tests passed."); return 0; } catch (Exception exception) diff --git a/build.ps1 b/build.ps1 index 988ed71..86a89f6 100644 --- a/build.ps1 +++ b/build.ps1 @@ -28,15 +28,15 @@ try { Remove-Item -LiteralPath $archive } - & $MSBuildPath BlockMelonLoader.csproj /restore '/t:Rebuild,Pack' /p:Configuration=Release /v:minimal /nologo + & $MSBuildPath BlockModLoader.csproj /restore '/t:Rebuild,Pack' /p:Configuration=Release /v:minimal /nologo if ($LASTEXITCODE -ne 0) { throw 'Library build failed.' } if ($Test) { - & $MSBuildPath Tests/BlockMelonLoader.Tests.csproj /t:Rebuild /v:minimal /nologo + & $MSBuildPath Tests/BlockModLoader.Tests.csproj /t:Rebuild /v:minimal /nologo if ($LASTEXITCODE -ne 0) { throw 'Test build failed.' } - & ./Tests/bin/Release/BlockMelonLoader.Tests.exe + & ./Tests/bin/Release/BlockModLoader.Tests.exe if ($LASTEXITCODE -ne 0) { throw 'Tests failed.' } } - Write-Host "Library output: $PSScriptRoot/bin/Release/BlockMelonLoader.dll" - Write-Host "NuGet output: $PSScriptRoot/artifacts/BlockMelonLoader.1.0.0.nupkg" + Write-Host "Library output: $PSScriptRoot/bin/Release/BlockModLoader.dll" + Write-Host "NuGet output: $PSScriptRoot/artifacts/BlockModLoader.1.0.0.nupkg" } finally { Pop-Location }