diff --git a/.gitattributes b/.gitattributes index dfe0770..ebca377 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ # Auto detect text files and perform LF normalization * text=auto + +# Binary files +*.dll binary diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..979c02b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,8 @@ +## What changed? + + +## Why? + + +## How was it tested? + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6c39f84..fc8dfca 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,9 +4,11 @@ on: push: branches: - dev + - main pull_request: branches: - dev + - main jobs: build: @@ -14,7 +16,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 - name: Setup .NET uses: actions/setup-dotnet@v4 diff --git a/.gitignore b/.gitignore index fba4e61..f4934fe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,17 @@ -/obj -/bin +# Build results +bin/ +obj/ +TestResults/ + +# User / IDE files +*.user +*.suo +*.userosscache +*.sln.docstates +.vs/ +.idea/ +.vscode/ + +# OS files +.DS_Store +Thumbs.db diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d63ee03 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.0-dev-alpha] - 2026-09-28 + +### Added +- `EventManager.cs` for events. +- `LevelCompleteScreen.cs` for modifying `Deadshot.UI.Menus.LevelCompletedMenu`. +- `Harmony v2.4.2` for patching game instances. + +### Changed +- Updated `Plugin.cs` version to `v1.0.0-dev-alpha`. +- Updated `Plugin.Load()` to add `EventManager` as component. +- Added null checks and `try/catch` exception handling to EventManager. + +### Fixed +- Fixed runtime loading for `GameAssembly.dll` in `DeadshotModAPI.Tests.csproj`. +- Fixed git merge conflict errors with `origin/dev`. +- Fixed `LevelCompletedScreen.SetTime()` having a hardcoded text. +- Fixed spelling mistake in `Plugin.cs` + +## [1.1.0] - 2026-09-28 + +### Added +- `Input.RemoveKeyPressed(Key, Action)` to unregister key callbacks. +- `examples/ExampleMod/` reference project featuring Subaka's `SpeedrunMod`. +- `DeadshotModAPI.Tests/` automated test suite covering mod discovery, reflection, and edge cases. +- `CONTRIBUTING.md` and `.github/pull_request_template.md`. + +### Changed +- Updated `Plugin.cs` version to `1.1.0`. +- Converted all assembly references in `DeadshotModAPI.csproj` to relative paths (`lib\*.dll`). +- Renamed `Class1.cs` to `Plugin.cs`. +- Renamed `Logging/logger.cs` to `Logging/Logger.cs`. +- Upgraded GitHub Actions CI workflow to `actions/checkout@v4`. +- Rewrote `README.md` with clear setup instructions and API reference. + +### Fixed +- Fixed hardcoded machine paths (`C:\Code\...`) and missing path separators in `DeadshotModAPI.csproj`. +- Added defensive null checks and `try/catch` isolation in `ModLoader` so corrupted or invalid mod DLLs log errors without crashing the game or stopping other mods. +- Added input validation to `SceneManager.Load` for null, whitespace, and negative indices. +- Added null guards in `GameManager.RestartLevel` before accessing game singletons and save data dictionaries. +- Fixed key collection mutation issue in `Input.CheckKeys` by snapshotting keys before dispatching callbacks. + +## [1.0.0] - 2026-09-27 + +### Added +- Initial modding runtime for Deadshot on BepInEx 6 IL2CPP and .NET 6. +- `IDeadshotMod` interface. +- Dynamic mod loader scanning `BepInEx/mods/`. +- Keyboard input listening via Unity Input System. +- Level restart helper in `GameManager`. +- Scene loading helpers in `SceneManager`. +- Console logging via `Logger`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ae12f89 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing to Deadshot Mod API + +Thanks for wanting to help improve DeadshotModAPI. + +## Getting Started + +1. Fork and clone the repository: + ```bash + git clone https://github.com/DemonZ-Development/DeadshotModAPI.git + cd DeadshotModAPI + ``` +2. Build the project: + ```bash + dotnet build DeadshotModAPI.csproj + ``` +3. Run the tests: + ```bash + dotnet test DeadshotModAPI.Tests/DeadshotModAPI.Tests.csproj + ``` + +## Development Guidelines + +* **Branches**: Create feature branches off `dev`, not `main`. +* **Dependencies**: Reference assemblies live in `lib/`. Keep references relative so the project builds without machine-specific paths. +* **Error Handling**: Use defensive null checks and exception handling around mod loading and IL2CPP interop. A broken mod should log the issue rather than crash the game or block other mods. +* **Code Style**: Follow standard C# conventions. Keep comments practical and direct. + +## Submitting Pull Requests + +1. Keep PRs focused on a single feature, bug fix, or improvement. +2. Verify the project builds with 0 errors and all tests pass. +3. Open your PR against the `dev` branch with a clear summary of what you changed. diff --git a/DeadshotModAPI.Tests/AdversarialM2Tests.cs b/DeadshotModAPI.Tests/AdversarialM2Tests.cs new file mode 100644 index 0000000..d7214a5 --- /dev/null +++ b/DeadshotModAPI.Tests/AdversarialM2Tests.cs @@ -0,0 +1,536 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Runtime.Serialization; +using DeadshotModAPI.Tests.TestHelpers; +using HarmonyLib; +using Xunit; + +namespace DeadshotModAPI.Tests; + +public class AdversarialM2Tests : IDisposable +{ + private readonly string _tempDir; + private static Harmony _harmonyKeyboardMock; + + public AdversarialM2Tests() + { + TestLoggerMock.Initialize(); + TestLoggerMock.ResetLogs(); + ReflectionHelper.ResetInputState(); + ReflectionHelper.ResetModLoaderState(); + + SetupKeyboardMock(); + + _tempDir = Path.Combine(Path.GetTempPath(), "DeadshotModAPI_AdvM2_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + private static void SetupKeyboardMock() + { + if (_harmonyKeyboardMock != null) return; + + try + { + _harmonyKeyboardMock = new Harmony("DeadshotModAPI.Tests.KeyboardMock"); + var getCurrentMethod = typeof(UnityEngine.InputSystem.Keyboard).GetMethod("get_current", BindingFlags.Public | BindingFlags.Static); + if (getCurrentMethod != null) + { + var prefix = typeof(AdversarialM2Tests).GetMethod(nameof(PrefixKeyboardCurrent), BindingFlags.NonPublic | BindingFlags.Static); + _harmonyKeyboardMock.Patch(getCurrentMethod, new HarmonyMethod(prefix)); + } + } + catch (Exception ex) + { + Console.WriteLine($"[KeyboardMock Warning] Could not patch Keyboard.get_current: {ex.Message}"); + } + } + + private static bool PrefixKeyboardCurrent(ref UnityEngine.InputSystem.Keyboard __result) + { + __result = null; + return false; // Skip original method + } + + public void Dispose() + { + ReflectionHelper.ResetInputState(); + ReflectionHelper.ResetModLoaderState(); + + try + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + catch { } + } + + private static ModLoader CreateModLoaderInstance() + { + return (ModLoader)FormatterServices.GetUninitializedObject(typeof(ModLoader)); + } + + private static bool InvokeIsPressed(Key key) + { + var method = typeof(Input).GetMethod("IsPressed", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(method); + return (bool)method!.Invoke(null, new object[] { key })!; + } + + // ========================================================================= + // Edge Case 1: Keyboard.current is null when calling Input.IsPressed or Input.CheckKeys + // ========================================================================= + + [Fact] + public void EdgeCase1_KeyboardNull_IsPressed_ReturnsFalseSafelyWithoutTargetException() + { + // When Keyboard.current is null + var ex = Record.Exception(() => + { + bool pressed = InvokeIsPressed(Key.A); + Assert.False(pressed, "IsPressed must return false when Keyboard.current is null."); + }); + + Assert.Null(ex); + } + + [Fact] + public void EdgeCase1_KeyboardNull_CheckKeys_ReturnsSafelyWithoutException() + { + // Register key actions to ensure CheckKeys has work to inspect + bool callbackFired = false; + Input.OnKeyPressed(Key.Space, () => callbackFired = true); + + var ex = Record.Exception(() => + { + ReflectionHelper.InvokeCheckKeys(); + }); + + Assert.Null(ex); + Assert.False(callbackFired, "Callbacks should not fire when Keyboard.current is null."); + } + + // ========================================================================= + // Edge Case 2: Listener on Key.A throws during Input.TriggerKey + // Does the next listener still get called? + // ========================================================================= + + [Fact] + public void EdgeCase2_TriggerKey_ThrowingListener_NextListenerStillExecutes() + { + bool listener1Executed = false; + bool listener2Executed = false; + bool listener3Executed = false; + bool listener4Executed = false; + + Action listener1 = () => + { + listener1Executed = true; + throw new InvalidOperationException("Adversarial crash from Listener 1"); + }; + + Action listener2 = () => + { + listener2Executed = true; + }; + + Action listener3 = () => + { + listener3Executed = true; + throw new ApplicationException("Adversarial crash from Listener 3"); + }; + + Action listener4 = () => + { + listener4Executed = true; + }; + + Input.OnKeyPressed(Key.A, listener1); + Input.OnKeyPressed(Key.A, listener2); + Input.OnKeyPressed(Key.A, listener3); + Input.OnKeyPressed(Key.A, listener4); + + var ex = Record.Exception(() => + { + ReflectionHelper.InvokeTriggerKey(Key.A); + }); + + Assert.Null(ex); + Assert.True(listener1Executed, "Listener 1 should have been executed."); + Assert.True(listener2Executed, "Listener 2 MUST be executed despite Listener 1 throwing."); + Assert.True(listener3Executed, "Listener 3 should have been executed."); + Assert.True(listener4Executed, "Listener 4 MUST be executed despite Listener 3 throwing."); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Adversarial crash from Listener 1")); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Adversarial crash from Listener 3")); + } + + // ========================================================================= + // Edge Case 3: Listener registers or removes keys inside callback during invocation + // Does it throw InvalidOperationException (collection modified) or survive? + // ========================================================================= + + [Fact] + public void EdgeCase3_TriggerKey_MutateRegistrationsInsideCallback_SurvivesWithoutException() + { + bool callback2Fired = false; + bool dynamicCallbackFired = false; + + Action dynamicCallback = () => dynamicCallbackFired = true; + + Action callback1 = null!; + callback1 = () => + { + // Adversarial mutation: remove self, add another key listener + Input.RemoveKeyPressed(Key.B, callback1); + Input.OnKeyPressed(Key.C, dynamicCallback); + Input.OnKeyPressed(Key.B, () => { }); + }; + + Action callback2 = () => + { + callback2Fired = true; + // Remove a different key altogether + Input.RemoveKeyPressed(Key.C, dynamicCallback); + }; + + Input.OnKeyPressed(Key.B, callback1); + Input.OnKeyPressed(Key.B, callback2); + + var ex = Record.Exception(() => + { + ReflectionHelper.InvokeTriggerKey(Key.B); + }); + + Assert.Null(ex); + Assert.True(callback2Fired, "Callback 2 should have fired."); + Assert.False(dynamicCallbackFired, "Dynamic callback was unregistered before being triggered."); + } + + [Fact] + public void EdgeCase3_CheckKeys_SnapshotGuaranteesNoCollectionModifiedException() + { + // Populate multiple keys + for (int i = 0; i < 20; i++) + { + var key = (Key)((int)Key.Digit0 + (i % 10)); + Input.OnKeyPressed(key, () => + { + // Mutate dictionary during iteration + Input.OnKeyPressed(Key.Escape, () => { }); + Input.RemoveKeyPressed(Key.Digit0, () => { }); + }); + } + + var ex = Record.Exception(() => + { + ReflectionHelper.InvokeCheckKeys(); + }); + + Assert.Null(ex); + } + + // ========================================================================= + // Edge Case 4: OnKeyPressed called with null Action + // ========================================================================= + + [Fact] + public void EdgeCase4_OnKeyPressed_NullAction_SafelyIgnoredWithoutException() + { + var ex = Record.Exception(() => + { + Input.OnKeyPressed(Key.F1, null!); + Input.RemoveKeyPressed(Key.F1, null!); + }); + + Assert.Null(ex); + + // Verify TriggerKey on Key.F1 does nothing and does not throw + var triggerEx = Record.Exception(() => + { + ReflectionHelper.InvokeTriggerKey(Key.F1); + }); + + Assert.Null(triggerEx); + } + + // ========================================================================= + // Edge Case 5: ModLoader corrupted assembly and throwing mod classes + // Does it continue loading subsequent mods? + // ========================================================================= + + [Fact] + public void EdgeCase5_CorruptAssembly_LoadMod_DoesNotCrashAndLogsError() + { + string corruptPath = Path.Combine(_tempDir, "CorruptAssembly.dll"); + File.WriteAllBytes(corruptPath, new byte[] { 0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22, 0x33 }); + + var loader = CreateModLoaderInstance(); + + var ex = Record.Exception(() => + { + ReflectionHelper.InvokeLoadMod(loader, corruptPath); + }); + + Assert.Null(ex); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Corrupt or invalid mod assembly")); + } + + [Fact] + public void EdgeCase5_ThrowingModLoadMethod_DoesNotHaltSubsequentModsInSameAssembly() + { + string source = @" +using System; +using DeadshotModAPI; + +public class FailingMod : IDeadshotMod +{ + public string Name => ""FailingMod""; + public string Description => ""Throws in Load""; + public string Creator => ""Adversary""; + public string Version => ""0.0.1""; + + public void Load() + { + throw new InvalidOperationException(""Critical mod init crash""); + } +} + +public class SurvivingMod : IDeadshotMod +{ + public string Name => ""SurvivingMod""; + public string Description => ""Should survive and load""; + public string Creator => ""Author""; + public string Version => ""1.0.0""; + + public bool HasLoaded { get; private set; } + + public void Load() + { + HasLoaded = true; + } +} +"; + string dllPath = Path.Combine(_tempDir, "DualModAssembly.dll"); + TestAssemblyBuilder.CompileAssembly(source, dllPath, "DualModAssembly"); + + var loader = CreateModLoaderInstance(); + + var ex = Record.Exception(() => + { + ReflectionHelper.InvokeLoadMod(loader, dllPath); + }); + + Assert.Null(ex); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Critical mod init crash")); + + // Verify SurvivingMod is in LoadedMods + var loaded = ModLoader.LoadedMods.Find(m => m.Name == "SurvivingMod"); + Assert.NotNull(loaded); + Assert.Equal("SurvivingMod", loaded.Name); + + // Verify FailingMod is NOT in LoadedMods + var failed = ModLoader.LoadedMods.Find(m => m.Name == "FailingMod"); + Assert.Null(failed); + } + + [Fact] + public void EdgeCase5_ThrowingConstructor_DoesNotHaltSubsequentMods() + { + string source = @" +using System; +using DeadshotModAPI; + +public class ThrowingCtorMod : IDeadshotMod +{ + public ThrowingCtorMod() + { + throw new TypeInitializationException(""ThrowingCtorMod"", new Exception(""Constructor failure"")); + } + + public string Name => ""ThrowingCtorMod""; + public string Description => ""Desc""; + public string Creator => ""Author""; + public string Version => ""1.0""; + public void Load() {} +} + +public class SecondGoodMod : IDeadshotMod +{ + public string Name => ""SecondGoodMod""; + public string Description => ""Good Desc""; + public string Creator => ""Good Author""; + public string Version => ""1.0""; + public void Load() {} +} +"; + string dllPath = Path.Combine(_tempDir, "ThrowingCtorAssembly.dll"); + TestAssemblyBuilder.CompileAssembly(source, dllPath, "ThrowingCtorAssembly"); + + var loader = CreateModLoaderInstance(); + + var ex = Record.Exception(() => + { + ReflectionHelper.InvokeLoadMod(loader, dllPath); + }); + + Assert.Null(ex); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Failed to instantiate or load mod")); + + var good = ModLoader.LoadedMods.Find(m => m.Name == "SecondGoodMod"); + Assert.NotNull(good); + } + + [Fact] + public void EdgeCase5_ThrowingPropertyGetters_DefaultsSafelyAndLoadsMod() + { + string source = @" +using System; +using DeadshotModAPI; + +public class ThrowingPropertiesMod : IDeadshotMod +{ + public string Name => throw new ApplicationException(""Name getter explosion""); + public string Description => throw new ApplicationException(""Desc explosion""); + public string Creator => throw new ApplicationException(""Creator explosion""); + public string Version => throw new ApplicationException(""Version explosion""); + + public bool LoadedSuccessfully { get; private set; } + + public void Load() + { + LoadedSuccessfully = true; + } +} +"; + string dllPath = Path.Combine(_tempDir, "ThrowingPropertiesAssembly.dll"); + TestAssemblyBuilder.CompileAssembly(source, dllPath, "ThrowingPropertiesAssembly"); + + var loader = CreateModLoaderInstance(); + + var ex = Record.Exception(() => + { + ReflectionHelper.InvokeLoadMod(loader, dllPath); + }); + + Assert.Null(ex); + Assert.Single(ModLoader.LoadedMods); + Assert.Contains(TestLoggerMock.LoggedWarnings, w => w.Contains("Failed to read mod name")); + } + + [Fact] + public void EdgeCase_LoadMod_NullOrWhitespaceOrNonExistentPath_HandledDefensively() + { + var loader = CreateModLoaderInstance(); + + var exNull = Record.Exception(() => ReflectionHelper.InvokeLoadMod(loader, null!)); + var exEmpty = Record.Exception(() => ReflectionHelper.InvokeLoadMod(loader, " ")); + var exNonExistent = Record.Exception(() => ReflectionHelper.InvokeLoadMod(loader, Path.Combine(_tempDir, "DoesNotExist.dll"))); + + Assert.Null(exNull); + Assert.Null(exEmpty); + Assert.Null(exNonExistent); + Assert.Contains(TestLoggerMock.LoggedWarnings, w => w.Contains("Mod file path is null or empty")); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Mod file does not exist")); + } + + [Fact] + public void StressTest_HighVolumeListeners_AlternatingExceptions_AllValidListenersExecute() + { + int totalListeners = 500; + int successfulInvocations = 0; + + for (int i = 0; i < totalListeners; i++) + { + int index = i; + if (index % 2 == 0) + { + Input.OnKeyPressed(Key.F8, () => + { + successfulInvocations++; + }); + } + else + { + Input.OnKeyPressed(Key.F8, () => + { + throw new Exception($"Stress exception at index {index}"); + }); + } + } + + var ex = Record.Exception(() => + { + ReflectionHelper.InvokeTriggerKey(Key.F8); + }); + + Assert.Null(ex); + Assert.Equal(250, successfulInvocations); + Assert.Equal(250, TestLoggerMock.LoggedErrors.FindAll(e => e.Contains("Stress exception")).Count); + } + + [Fact] + public void StressTest_ReentrantTriggerKey_ExecutesSafely() + { + bool f10Executed = false; + bool f9SecondExecuted = false; + + Input.OnKeyPressed(Key.F9, () => + { + // Reentrant trigger of another key + ReflectionHelper.InvokeTriggerKey(Key.F10); + }); + + Input.OnKeyPressed(Key.F9, () => + { + f9SecondExecuted = true; + }); + + Input.OnKeyPressed(Key.F10, () => + { + f10Executed = true; + // Also mutate during reentrant call + Input.OnKeyPressed(Key.F11, () => { }); + }); + + var ex = Record.Exception(() => + { + ReflectionHelper.InvokeTriggerKey(Key.F9); + }); + + Assert.Null(ex); + Assert.True(f10Executed, "Reentrant F10 should have executed."); + Assert.True(f9SecondExecuted, "Subsequent F9 listener should have executed."); + } + + [Fact] + public void EdgeCase_SceneManager_Load_InvalidInputs_DoNotThrow() + { + var exNull = Record.Exception(() => SceneManager.Load((string)null!)); + var exEmpty = Record.Exception(() => SceneManager.Load(" ")); + var exNegative = Record.Exception(() => SceneManager.Load(-1)); + var exNegLarge = Record.Exception(() => SceneManager.Load(-999)); + + Assert.Null(exNull); + Assert.Null(exEmpty); + Assert.Null(exNegative); + Assert.Null(exNegLarge); + + Assert.Contains(TestLoggerMock.LoggedWarnings, w => w.Contains("sceneName is null, empty, or whitespace")); + Assert.Contains(TestLoggerMock.LoggedWarnings, w => w.Contains("invalid sceneIndex '-1'")); + } + + [Fact] + public void EdgeCase_GameManager_RestartLevel_DefensivelyReturnsWithoutUnhandledException() + { + // Calling RestartLevel in uninitialized environment + var ex = Record.Exception(() => + { + GameManager.RestartLevel(true); + GameManager.RestartLevel(false); + }); + + Assert.Null(ex); + } +} + diff --git a/DeadshotModAPI.Tests/DeadshotModAPI.Tests.csproj b/DeadshotModAPI.Tests/DeadshotModAPI.Tests.csproj new file mode 100644 index 0000000..06b13bd --- /dev/null +++ b/DeadshotModAPI.Tests/DeadshotModAPI.Tests.csproj @@ -0,0 +1,80 @@ + + + + net6.0 + disable + disable + false + + + + + PreserveNewest + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + DefaultItemExcludes=$(DefaultItemExcludes);DeadshotModAPI.Tests/**;examples/** + + + + + + ..\lib\BepInEx.Core.dll + + + ..\lib\BepInEx.Unity.IL2CPP.dll + + + ..\lib\Il2CppInterop.Runtime.dll + + + ..\lib\Il2Cppmscorlib.dll + + + ..\lib\Assembly-CSharp.dll + + + ..\lib\UnityEngine.CoreModule.dll + + + ..\lib\Unity.InputSystem.dll + + + ..\lib\Unity.TextMeshPro.dll + + + ..\lib\UnityEngine.UI.dll + + + ..\lib\UnityEngine.AudioModule.dll + + + ..\lib\UnityEngine.DirectorModule.dll + + + ..\lib\Deadshot.Data.dll + + + ..\lib\UnityEngine.AssetBundleModule.dll + + + ..\lib\Il2CppSystem.dll + + + + diff --git a/DeadshotModAPI.Tests/Native/GameAssembly.dll b/DeadshotModAPI.Tests/Native/GameAssembly.dll new file mode 100644 index 0000000..846def7 Binary files /dev/null and b/DeadshotModAPI.Tests/Native/GameAssembly.dll differ diff --git a/DeadshotModAPI.Tests/TestHelpers/ReflectionHelper.cs b/DeadshotModAPI.Tests/TestHelpers/ReflectionHelper.cs new file mode 100644 index 0000000..7a260ce --- /dev/null +++ b/DeadshotModAPI.Tests/TestHelpers/ReflectionHelper.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace DeadshotModAPI.Tests.TestHelpers; + +/// +/// Helper utilities to invoke internal and private members for defensive and isolated testing. +/// +public static class ReflectionHelper +{ + static ReflectionHelper() + { + TestLoggerMock.Initialize(); + } + + private static readonly MethodInfo TriggerKeyMethod = + typeof(Input).GetMethod("TriggerKey", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("Could not find Input.TriggerKey method."); + + private static readonly MethodInfo CheckKeysMethod = + typeof(Input).GetMethod("CheckKeys", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("Could not find Input.CheckKeys method."); + + private static readonly MethodInfo LoadModMethod = + typeof(ModLoader).GetMethod("LoadMod", BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("Could not find ModLoader.LoadMod method."); + + private static readonly MethodInfo LoadModsMethod = + typeof(ModLoader).GetMethod("LoadMods", BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("Could not find ModLoader.LoadMods method."); + + private static readonly MethodInfo LoadModsBundleMethod = + typeof(SceneManager).GetMethod("LoadModsBundle", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("Could not find SceneManager.LoadModsBundle method."); + + private static readonly FieldInfo KeyActionsField = + typeof(Input).GetField("_keyActions", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("Could not find Input._keyActions field."); + + /// + /// Invokes internal Input.TriggerKey(Key). + /// + public static void InvokeTriggerKey(Key key) + { + TriggerKeyMethod.Invoke(null, new object[] { key }); + } + + /// + /// Invokes internal Input.CheckKeys(). + /// + public static void InvokeCheckKeys() + { + CheckKeysMethod.Invoke(null, null); + } + + /// + /// Invokes private ModLoader.LoadMod(path). + /// + public static void InvokeLoadMod(ModLoader loader, string path) + { + LoadModMethod.Invoke(loader, new object[] { path }); + } + + /// + /// Invokes private ModLoader.LoadMods(). + /// + public static void InvokeLoadMods(ModLoader loader) + { + LoadModsMethod.Invoke(loader, null); + } + + /// + /// Invokes internal SceneManager.LoadModsBundle(). + /// + public static void InvokeLoadModsBundle() + { + LoadModsBundleMethod.Invoke(null, null); + } + + /// + /// Clears registered key actions between tests to ensure test isolation. + /// + public static void ResetInputState() + { + if (KeyActionsField.GetValue(null) is Dictionary dict) + { + dict.Clear(); + } + } + + /// + /// Clears ModLoader.LoadedMods between tests to ensure test isolation. + /// + public static void ResetModLoaderState() + { + ModLoader.LoadedMods.Clear(); + } +} diff --git a/DeadshotModAPI.Tests/TestHelpers/TestAssemblyBuilder.cs b/DeadshotModAPI.Tests/TestHelpers/TestAssemblyBuilder.cs new file mode 100644 index 0000000..94abb1c --- /dev/null +++ b/DeadshotModAPI.Tests/TestHelpers/TestAssemblyBuilder.cs @@ -0,0 +1,57 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace DeadshotModAPI.Tests.TestHelpers; + +/// +/// Dynamically compiles test C# code into genuine .NET assembly DLLs for ModLoader testing. +/// +public static class TestAssemblyBuilder +{ + /// + /// Compiles the provided C# source code into a DLL assembly at the specified path. + /// + public static string CompileAssembly(string sourceCode, string outputPath, string assemblyName = "DynamicTestMod") + { + var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode); + + var references = new MetadataReference[] + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(IDeadshotMod).Assembly.Location), + MetadataReference.CreateFromFile(Assembly.Load("System.Runtime").Location), + MetadataReference.CreateFromFile(typeof(Console).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Action).Assembly.Location), + }; + + var compilation = CSharpCompilation.Create( + assemblyName, + new[] { syntaxTree }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Release) + ); + + var dir = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + + using var peStream = File.Create(outputPath); + var emitResult = compilation.Emit(peStream); + + if (!emitResult.Success) + { + var errors = string.Join(Environment.NewLine, emitResult.Diagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => d.GetMessage())); + throw new InvalidOperationException($"Dynamic assembly compilation failed: {errors}"); + } + + return outputPath; + } +} diff --git a/DeadshotModAPI.Tests/TestHelpers/TestLoggerMock.cs b/DeadshotModAPI.Tests/TestHelpers/TestLoggerMock.cs new file mode 100644 index 0000000..7f75a7c --- /dev/null +++ b/DeadshotModAPI.Tests/TestHelpers/TestLoggerMock.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using HarmonyLib; + +namespace DeadshotModAPI.Tests.TestHelpers; + +public static class TestLoggerMock +{ + private static Harmony _harmony; + private static readonly object _sync = new(); + + private static readonly List _loggedInfo = new(); + private static readonly List _loggedWarnings = new(); + private static readonly List _loggedErrors = new(); + private static readonly List _loggedDebug = new(); + + public static List LoggedInfo + { + get { lock (_sync) return _loggedInfo.ToList(); } + } + + public static List LoggedWarnings + { + get { lock (_sync) return _loggedWarnings.ToList(); } + } + + public static List LoggedErrors + { + get { lock (_sync) return _loggedErrors.ToList(); } + } + + public static List LoggedDebug + { + get { lock (_sync) return _loggedDebug.ToList(); } + } + + public static string OverrideBepInExRootPath = null; + + public static void Initialize() + { + if (_harmony != null) return; + + _harmony = new Harmony("DeadshotModAPI.Tests.Mock"); + + // 1. Patch Logger.Info, Warning, Error, Debug + var prefixInfo = typeof(TestLoggerMock).GetMethod(nameof(PrefixInfo), BindingFlags.NonPublic | BindingFlags.Static); + var prefixWarn = typeof(TestLoggerMock).GetMethod(nameof(PrefixWarning), BindingFlags.NonPublic | BindingFlags.Static); + var prefixErr = typeof(TestLoggerMock).GetMethod(nameof(PrefixError), BindingFlags.NonPublic | BindingFlags.Static); + var prefixDbg = typeof(TestLoggerMock).GetMethod(nameof(PrefixDebug), BindingFlags.NonPublic | BindingFlags.Static); + + _harmony.Patch(typeof(Logger).GetMethod("Info", new[] { typeof(string) }), new HarmonyMethod(prefixInfo)); + _harmony.Patch(typeof(Logger).GetMethod("Warning", new[] { typeof(string) }), new HarmonyMethod(prefixWarn)); + _harmony.Patch(typeof(Logger).GetMethod("Error", new[] { typeof(string) }), new HarmonyMethod(prefixErr)); + _harmony.Patch(typeof(Logger).GetMethod("Debug", new[] { typeof(string) }), new HarmonyMethod(prefixDbg)); + + // 2. Patch BepInEx.Paths.BepInExRootPath getter + var rootGetter = typeof(BepInEx.Paths).GetProperty("BepInExRootPath", BindingFlags.Public | BindingFlags.Static)?.GetGetMethod(); + if (rootGetter != null) + { + var prefixRoot = typeof(TestLoggerMock).GetMethod(nameof(PrefixGetBepInExRootPath), BindingFlags.NonPublic | BindingFlags.Static); + try { _harmony.Patch(rootGetter, new HarmonyMethod(prefixRoot)); } catch { } + } + + // 3. Patch UnityEngine.Object.op_Inequality + var opInequality = typeof(UnityEngine.Object).GetMethod("op_Inequality", BindingFlags.Public | BindingFlags.Static); + if (opInequality != null) + { + var prefixOpInEq = typeof(TestLoggerMock).GetMethod(nameof(PrefixOpInequality), BindingFlags.NonPublic | BindingFlags.Static); + try { _harmony.Patch(opInequality, new HarmonyMethod(prefixOpInEq)); } catch { } + } + + // 4. Patch Il2CppSystem.String.op_Implicit(string) + var prefixOpImplicitString = typeof(TestLoggerMock).GetMethod(nameof(PrefixOpImplicitString), BindingFlags.NonPublic | BindingFlags.Static); + foreach (var m in typeof(Il2CppSystem.String).GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (m.Name == "op_Implicit" && m.ReturnType == typeof(Il2CppSystem.String)) + { + var pars = m.GetParameters(); + if (pars.Length == 1 && pars[0].ParameterType == typeof(string)) + { + try { _harmony.Patch(m, new HarmonyMethod(prefixOpImplicitString)); } catch { } + } + } + } + + // 5. Patch Il2CppSystem.Object.op_Implicit(string) + var prefixOpImplicitObject = typeof(TestLoggerMock).GetMethod(nameof(PrefixOpImplicitObject), BindingFlags.NonPublic | BindingFlags.Static); + foreach (var m in typeof(Il2CppSystem.Object).GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (m.Name == "op_Implicit" && m.ReturnType == typeof(Il2CppSystem.Object)) + { + var pars = m.GetParameters(); + if (pars.Length == 1 && pars[0].ParameterType == typeof(string)) + { + try { _harmony.Patch(m, new HarmonyMethod(prefixOpImplicitObject)); } catch { } + } + } + } + + // 6. Patch UnityEngine.Debug.Log, LogWarning, LogError + var debugType = typeof(UnityEngine.Debug); + var prefixDebugSkip = typeof(TestLoggerMock).GetMethod(nameof(PrefixDebugSkip), BindingFlags.NonPublic | BindingFlags.Static); + + foreach (var m in debugType.GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (m.Name is "Log" or "LogWarning" or "LogError") + { + try { _harmony.Patch(m, new HarmonyMethod(prefixDebugSkip)); } catch { } + } + } + } + + public static void ResetLogs() + { + lock (_sync) + { + _loggedInfo.Clear(); + _loggedWarnings.Clear(); + _loggedErrors.Clear(); + _loggedDebug.Clear(); + } + } + + private static bool PrefixInfo(string message) + { + lock (_sync) _loggedInfo.Add(message); + return false; + } + + private static bool PrefixWarning(string message) + { + lock (_sync) _loggedWarnings.Add(message); + return false; + } + + private static bool PrefixError(string message) + { + lock (_sync) _loggedErrors.Add(message); + return false; + } + + private static bool PrefixDebug(string message) + { + lock (_sync) _loggedDebug.Add(message); + return false; + } + + private static bool PrefixGetBepInExRootPath(ref string __result) + { + if (OverrideBepInExRootPath != null) + { + __result = OverrideBepInExRootPath; + return false; + } + return true; + } + + private static bool PrefixOpInequality(UnityEngine.Object x, UnityEngine.Object y, ref bool __result) + { + bool xNull = ReferenceEquals(x, null); + bool yNull = ReferenceEquals(y, null); + __result = (xNull != yNull) || (!xNull && !yNull && !ReferenceEquals(x, y)); + return false; + } + + private static bool PrefixOpImplicitString(string s, ref Il2CppSystem.String __result) + { + __result = null; + return false; + } + + private static bool PrefixOpImplicitObject(string s, ref Il2CppSystem.Object __result) + { + __result = null; + return false; + } + + private static bool PrefixDebugSkip() + { + return false; + } +} diff --git a/DeadshotModAPI.Tests/TestInitializer.cs b/DeadshotModAPI.Tests/TestInitializer.cs new file mode 100644 index 0000000..193590d --- /dev/null +++ b/DeadshotModAPI.Tests/TestInitializer.cs @@ -0,0 +1,16 @@ +using System.Runtime.CompilerServices; +using DeadshotModAPI.Tests.TestHelpers; +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] + +namespace DeadshotModAPI.Tests; + +public static class TestInitializer +{ + [ModuleInitializer] + public static void Initialize() + { + TestLoggerMock.Initialize(); + } +} diff --git a/DeadshotModAPI.Tests/Tier1_FeatureCoverage/DeadshotModFeatureTests.cs b/DeadshotModAPI.Tests/Tier1_FeatureCoverage/DeadshotModFeatureTests.cs new file mode 100644 index 0000000..1150f65 --- /dev/null +++ b/DeadshotModAPI.Tests/Tier1_FeatureCoverage/DeadshotModFeatureTests.cs @@ -0,0 +1,84 @@ +using System; +using Xunit; + +namespace DeadshotModAPI.Tests.Tier1_FeatureCoverage; + +public class DeadshotModFeatureTests +{ + private class ConcreteTestMod : IDeadshotMod + { + public string Name => "Test Mod"; + public string Description => "Test Mod Description"; + public string Creator => "Author"; + public string Version => "1.0.0"; + + public bool IsLoaded { get; private set; } + + public void Load() + { + IsLoaded = true; + } + } + + [Fact] + public void DeadshotMod_Interface_DefinesExpectedContract() + { + var iface = typeof(IDeadshotMod); + Assert.True(iface.IsInterface); + + var nameProp = iface.GetProperty("Name"); + var descProp = iface.GetProperty("Description"); + var creatorProp = iface.GetProperty("Creator"); + var verProp = iface.GetProperty("Version"); + var loadMethod = iface.GetMethod("Load"); + + Assert.NotNull(nameProp); + Assert.NotNull(descProp); + Assert.NotNull(creatorProp); + Assert.NotNull(verProp); + Assert.NotNull(loadMethod); + + Assert.True(nameProp.CanRead); + Assert.False(nameProp.CanWrite); + } + + [Fact] + public void DeadshotMod_Implementation_ReturnsMetadataCorrectly() + { + IDeadshotMod mod = new ConcreteTestMod(); + + Assert.Equal("Test Mod", mod.Name); + Assert.Equal("Test Mod Description", mod.Description); + Assert.Equal("Author", mod.Creator); + Assert.Equal("1.0.0", mod.Version); + } + + [Fact] + public void DeadshotMod_Load_ExecutesModSpecificLogic() + { + var mod = new ConcreteTestMod(); + Assert.False(mod.IsLoaded); + + mod.Load(); + + Assert.True(mod.IsLoaded); + } + + [Fact] + public void DeadshotMod_Polymorphism_AssignableToIDeadshotMod() + { + Assert.True(typeof(IDeadshotMod).IsAssignableFrom(typeof(ConcreteTestMod))); + } + + [Fact] + public void DeadshotMod_MultipleInstances_MaintainIndependentState() + { + var mod1 = new ConcreteTestMod(); + var mod2 = new ConcreteTestMod(); + + mod1.Load(); + + Assert.True(mod1.IsLoaded); + Assert.False(mod2.IsLoaded); + } +} diff --git a/DeadshotModAPI.Tests/Tier1_FeatureCoverage/GameManagerFeatureTests.cs b/DeadshotModAPI.Tests/Tier1_FeatureCoverage/GameManagerFeatureTests.cs new file mode 100644 index 0000000..ed265c3 --- /dev/null +++ b/DeadshotModAPI.Tests/Tier1_FeatureCoverage/GameManagerFeatureTests.cs @@ -0,0 +1,65 @@ +using System; +using System.Reflection; +using Xunit; + +namespace DeadshotModAPI.Tests.Tier1_FeatureCoverage; + +public class GameManagerFeatureTests +{ + [Fact] + public void GameManager_RestartLevel_DefaultParameter_ExecutesDefensivelyWithoutThrowing() + { + // Act & Assert + // In unit test environment, Unity GameManager singletons are uninitialized, + // but RestartLevel wraps execution defensively with null checks and try-catch. + var ex = Record.Exception(() => GameManager.RestartLevel()); + Assert.Null(ex); + } + + [Fact] + public void GameManager_RestartLevel_ExplicitPlayCutsceneFalse_ExecutesDefensivelyWithoutThrowing() + { + // Act & Assert + var ex = Record.Exception(() => GameManager.RestartLevel(false)); + Assert.Null(ex); + } + + [Fact] + public void GameManager_RestartLevel_ExplicitPlayCutsceneTrue_ExecutesDefensivelyWithoutThrowing() + { + // Act & Assert + var ex = Record.Exception(() => GameManager.RestartLevel(true)); + Assert.Null(ex); + } + + [Fact] + public void GameManager_Class_IsPublicAndHasExpectedMethods() + { + var type = typeof(GameManager); + Assert.True(type.IsPublic); + + var restartMethod = type.GetMethod("RestartLevel", BindingFlags.Public | BindingFlags.Static); + Assert.NotNull(restartMethod); + + var parameters = restartMethod.GetParameters(); + Assert.Single(parameters); + Assert.Equal(typeof(bool), parameters[0].ParameterType); + Assert.True(parameters[0].HasDefaultValue); + Assert.Equal(true, parameters[0].DefaultValue); + } + + [Fact] + public void GameManager_Constants_VerifyInternalKeys() + { + var type = typeof(GameManager); + + var keyField = type.GetField("gameManagerKey", BindingFlags.NonPublic | BindingFlags.Static); + var idField = type.GetField("gameManagerId", BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(keyField); + Assert.NotNull(idField); + + Assert.Equal("7069791d-77d0-45d8-8895-91b9fae46892", keyField.GetValue(null)); + Assert.Equal("Deadshot.GameManager", idField.GetValue(null)); + } +} diff --git a/DeadshotModAPI.Tests/Tier1_FeatureCoverage/InputFeatureTests.cs b/DeadshotModAPI.Tests/Tier1_FeatureCoverage/InputFeatureTests.cs new file mode 100644 index 0000000..796ddf1 --- /dev/null +++ b/DeadshotModAPI.Tests/Tier1_FeatureCoverage/InputFeatureTests.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using DeadshotModAPI.Tests.TestHelpers; +using Xunit; + +namespace DeadshotModAPI.Tests.Tier1_FeatureCoverage; + +public class InputFeatureTests : IDisposable +{ + public InputFeatureTests() + { + ReflectionHelper.ResetInputState(); + } + + public void Dispose() + { + ReflectionHelper.ResetInputState(); + } + + [Fact] + public void OnKeyPressed_RegistersSingleAction_SuccessfullyInvokedViaTriggerKey() + { + // Arrange + bool invoked = false; + Action callback = () => invoked = true; + + // Act + Input.OnKeyPressed(Key.F1, callback); + ReflectionHelper.InvokeTriggerKey(Key.F1); + + // Assert + Assert.True(invoked, "Registered callback on Key.F1 was expected to be invoked."); + } + + [Fact] + public void OnKeyPressed_MultipleActionsSameKey_AllInvokedInOrder() + { + // Arrange + var executionLog = new List(); + Action action1 = () => executionLog.Add("first"); + Action action2 = () => executionLog.Add("second"); + Action action3 = () => executionLog.Add("third"); + + // Act + Input.OnKeyPressed(Key.F2, action1); + Input.OnKeyPressed(Key.F2, action2); + Input.OnKeyPressed(Key.F2, action3); + ReflectionHelper.InvokeTriggerKey(Key.F2); + + // Assert + Assert.Equal(3, executionLog.Count); + Assert.Equal(new[] { "first", "second", "third" }, executionLog); + } + + [Fact] + public void RemoveKeyPressed_RemovesAction_NoLongerInvoked() + { + // Arrange + int callCount = 0; + Action callback = () => callCount++; + + Input.OnKeyPressed(Key.F3, callback); + ReflectionHelper.InvokeTriggerKey(Key.F3); + Assert.Equal(1, callCount); + + // Act + Input.RemoveKeyPressed(Key.F3, callback); + ReflectionHelper.InvokeTriggerKey(Key.F3); + + // Assert + Assert.Equal(1, callCount); + } + + [Theory] + [InlineData("Space", "space")] + [InlineData("Digit0", "digit0")] + [InlineData("LeftShift", "leftShift")] + [InlineData("F12", "f12")] + [InlineData("A", "a")] + [InlineData("NumpadEnter", "numpadEnter")] + [InlineData("camelCase", "camelCase")] + public void ToCamelCase_StandardPascalCase_ConvertsFirstCharToLower(string input, string expected) + { + // Act + string result = input.ToCamelCase(); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void KeyEnum_ContainsAllStandardKeyCategories() + { + // Verify key enum values exist and can be cast + var values = Enum.GetValues(); + + Assert.True(values.Length >= 90, $"Expected at least 90 key definitions, found {values.Length}."); + Assert.Contains(Key.A, values); + Assert.Contains(Key.Z, values); + Assert.Contains(Key.Digit0, values); + Assert.Contains(Key.F1, values); + Assert.Contains(Key.F12, values); + Assert.Contains(Key.Space, values); + Assert.Contains(Key.Escape, values); + Assert.Contains(Key.LeftShift, values); + Assert.Contains(Key.RightAlt, values); + Assert.Contains(Key.Numpad0, values); + } + + [Fact] + public void OnKeyPressed_MultipleDifferentKeys_AreIndependent() + { + // Arrange + bool key1Invoked = false; + bool key2Invoked = false; + + Input.OnKeyPressed(Key.F4, () => key1Invoked = true); + Input.OnKeyPressed(Key.F5, () => key2Invoked = true); + + // Act + ReflectionHelper.InvokeTriggerKey(Key.F4); + + // Assert + Assert.True(key1Invoked); + Assert.False(key2Invoked); + } +} diff --git a/DeadshotModAPI.Tests/Tier1_FeatureCoverage/LoggerFeatureTests.cs b/DeadshotModAPI.Tests/Tier1_FeatureCoverage/LoggerFeatureTests.cs new file mode 100644 index 0000000..f420846 --- /dev/null +++ b/DeadshotModAPI.Tests/Tier1_FeatureCoverage/LoggerFeatureTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Reflection; +using DeadshotModAPI.Tests.TestHelpers; +using Xunit; + +namespace DeadshotModAPI.Tests.Tier1_FeatureCoverage; + +public class LoggerFeatureTests : IDisposable +{ + public LoggerFeatureTests() + { + TestLoggerMock.Initialize(); + TestLoggerMock.ResetLogs(); + } + + public void Dispose() + { + TestLoggerMock.ResetLogs(); + } + + [Fact] + public void Logger_Info_ExecutesWithoutExceptionAndCapturesMessage() + { + Logger.Info("Test informational message"); + + Assert.Contains("Test informational message", TestLoggerMock.LoggedInfo); + } + + [Fact] + public void Logger_Warning_ExecutesWithoutExceptionAndCapturesMessage() + { + Logger.Warning("Test warning message"); + + Assert.Contains("Test warning message", TestLoggerMock.LoggedWarnings); + } + + [Fact] + public void Logger_Error_ExecutesWithoutExceptionAndCapturesMessage() + { + Logger.Error("Test error message"); + + Assert.Contains("Test error message", TestLoggerMock.LoggedErrors); + } + + [Fact] + public void Logger_Debug_ExecutesWithoutExceptionAndCapturesMessage() + { + Logger.Debug("Test debug message"); + + Assert.Contains("Test debug message", TestLoggerMock.LoggedDebug); + } + + [Fact] + public void Logger_Log_Alias_ExecutesWithoutExceptionAndCapturesMessage() + { + Logger.Log("Test standard log alias message"); + + Assert.Contains("Test standard log alias message", TestLoggerMock.LoggedInfo); + } + + [Fact] + public void Logger_Class_IsStaticAndHasExpectedMethods() + { + var type = typeof(Logger); + Assert.True(type.IsAbstract && type.IsSealed, "Logger must be a static class."); + Assert.True(type.IsPublic, "Logger must be public."); + + var logMethod = type.GetMethod("Log", new[] { typeof(string) }); + var infoMethod = type.GetMethod("Info", new[] { typeof(string) }); + var warnMethod = type.GetMethod("Warning", new[] { typeof(string) }); + var errorMethod = type.GetMethod("Error", new[] { typeof(string) }); + var debugMethod = type.GetMethod("Debug", new[] { typeof(string) }); + + Assert.NotNull(logMethod); + Assert.NotNull(infoMethod); + Assert.NotNull(warnMethod); + Assert.NotNull(errorMethod); + Assert.NotNull(debugMethod); + } +} diff --git a/DeadshotModAPI.Tests/Tier1_FeatureCoverage/ModLoaderFeatureTests.cs b/DeadshotModAPI.Tests/Tier1_FeatureCoverage/ModLoaderFeatureTests.cs new file mode 100644 index 0000000..d01446f --- /dev/null +++ b/DeadshotModAPI.Tests/Tier1_FeatureCoverage/ModLoaderFeatureTests.cs @@ -0,0 +1,124 @@ +using System; +using System.IO; +using System.Runtime.Serialization; +using DeadshotModAPI.Tests.TestHelpers; +using Xunit; + +namespace DeadshotModAPI.Tests.Tier1_FeatureCoverage; + +public class ModLoaderFeatureTests : IDisposable +{ + private readonly string _tempDir; + + public ModLoaderFeatureTests() + { + ReflectionHelper.ResetModLoaderState(); + _tempDir = Path.Combine(Path.GetTempPath(), "DeadshotModAPI_ModLoaderTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + ReflectionHelper.ResetModLoaderState(); + try + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + catch { } + } + + private static ModLoader CreateModLoaderInstance() + { + return (ModLoader)FormatterServices.GetUninitializedObject(typeof(ModLoader)); + } + + [Fact] + public void ModLoader_Instantiates_Successfully() + { + // Act + var loader = CreateModLoaderInstance(); + + // Assert + Assert.NotNull(loader); + } + + [Fact] + public void ModLoader_Awake_ExecutesWithoutException() + { + // Arrange + var loader = CreateModLoaderInstance(); + + // Act & Assert (Awake sets up singleton and should not throw) + var exception = Record.Exception(() => loader.Awake()); + Assert.Null(exception); + } + + [Fact] + public void ModLoader_Start_ExecutesWithoutException() + { + // Arrange + var loader = CreateModLoaderInstance(); + + // Act & Assert (Start triggers mod discovery; handles empty path defensively) + var exception = Record.Exception(() => loader.Start()); + Assert.Null(exception); + } + + [Fact] + public void ModLoader_Update_CallsCheckKeysWithoutException() + { + // Arrange + var loader = CreateModLoaderInstance(); + + // Act & Assert (Update invokes Input.CheckKeys()) + var exception = Record.Exception(() => loader.Update()); + Assert.Null(exception); + } + + [Fact] + public void ModLoader_LoadedModsCollection_IsInitializedAndAccessible() + { + // Assert + Assert.NotNull(ModLoader.LoadedMods); + } + + [Fact] + public void ModLoader_LoadMod_DiscoversAndInstantiates_ConcreteIDeadshotMod() + { + // Arrange + string source = @" +using DeadshotModAPI; + +public class SampleTestMod : IDeadshotMod +{ + public string Name => ""SampleMod""; + public string Description => ""A test mod description""; + public string Creator => ""TestAuthor""; + public string Version => ""1.0.0""; + + public bool Loaded { get; private set; } + + public void Load() + { + Loaded = true; + } +} +"; + string dllPath = Path.Combine(_tempDir, "SampleMod.dll"); + TestAssemblyBuilder.CompileAssembly(source, dllPath, "SampleModAssembly"); + + var loader = CreateModLoaderInstance(); + + // Act + ReflectionHelper.InvokeLoadMod(loader, dllPath); + + // Assert + Assert.NotEmpty(ModLoader.LoadedMods); + var loaded = ModLoader.LoadedMods.Find(m => m.Name == "SampleMod"); + Assert.NotNull(loaded); + Assert.Equal("A test mod description", loaded.Description); + Assert.Equal("TestAuthor", loaded.Creator); + Assert.Equal("1.0.0", loaded.Version); + } +} diff --git a/DeadshotModAPI.Tests/Tier1_FeatureCoverage/SceneManagerFeatureTests.cs b/DeadshotModAPI.Tests/Tier1_FeatureCoverage/SceneManagerFeatureTests.cs new file mode 100644 index 0000000..5e568df --- /dev/null +++ b/DeadshotModAPI.Tests/Tier1_FeatureCoverage/SceneManagerFeatureTests.cs @@ -0,0 +1,68 @@ +using System; +using System.Reflection; +using DeadshotModAPI.Tests.TestHelpers; +using Xunit; + +namespace DeadshotModAPI.Tests.Tier1_FeatureCoverage; + +public class SceneManagerFeatureTests +{ + [Fact] + public void SceneManager_LoadByName_ExecutesDefensivelyWithoutCrashing() + { + // Act & Assert + // In headless test mode, LoadingScreen is not backed by Unity IL2CPP runtime, + // but SceneManager.Load wraps the call in defensive try-catch so it never throws out. + var ex = Record.Exception(() => SceneManager.Load("Level01")); + Assert.Null(ex); + } + + [Fact] + public void SceneManager_LoadByIndex_ExecutesDefensivelyWithoutCrashing() + { + // Act & Assert + var ex = Record.Exception(() => SceneManager.Load(1)); + Assert.Null(ex); + } + + [Fact] + public void SceneManager_LoadZeroIndex_ExecutesDefensivelyWithoutCrashing() + { + // Act & Assert + var ex = Record.Exception(() => SceneManager.Load(0)); + Assert.Null(ex); + } + + [Fact] + public void SceneManager_LoadModsBundle_HandlesGracefullyOrIsolatesNativeBinding() + { + // Act & Assert + // LoadModsBundle checks _modsBundle != null. In native IL2CPP runtime, this safely checks bundle cache. + // In headless runner without GameAssembly.dll, any IL2CPP static initialization is caught. + var ex = Record.Exception(() => ReflectionHelper.InvokeLoadModsBundle()); + if (ex != null) + { + Assert.True( + ex is TypeInitializationException || + ex is TargetInvocationException tie && tie.InnerException is TypeInitializationException, + $"Unexpected exception: {ex}" + ); + } + } + + [Fact] + public void SceneManager_Class_IsStaticAndAccessible() + { + // Assert + var type = typeof(SceneManager); + Assert.True(type.IsAbstract && type.IsSealed, "SceneManager must be a static class."); + Assert.True(type.IsPublic, "SceneManager must be public."); + + // Check required public methods exist + var loadByName = type.GetMethod("Load", new[] { typeof(string) }); + var loadByIndex = type.GetMethod("Load", new[] { typeof(int) }); + + Assert.NotNull(loadByName); + Assert.NotNull(loadByIndex); + } +} diff --git a/DeadshotModAPI.Tests/Tier2_BoundaryCornerCases/DeadshotModBoundaryTests.cs b/DeadshotModAPI.Tests/Tier2_BoundaryCornerCases/DeadshotModBoundaryTests.cs new file mode 100644 index 0000000..6ee38ae --- /dev/null +++ b/DeadshotModAPI.Tests/Tier2_BoundaryCornerCases/DeadshotModBoundaryTests.cs @@ -0,0 +1,139 @@ +using System; +using System.IO; +using System.Runtime.Serialization; +using DeadshotModAPI.Tests.TestHelpers; +using Xunit; + +namespace DeadshotModAPI.Tests.Tier2_BoundaryCornerCases; + +public class DeadshotModBoundaryTests : IDisposable +{ + private readonly string _tempDir; + private readonly ModLoader _loader; + + public DeadshotModBoundaryTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "Deadshot_Mod_Boundary_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + _loader = (ModLoader)FormatterServices.GetUninitializedObject(typeof(ModLoader)); + ReflectionHelper.ResetModLoaderState(); + TestLoggerMock.ResetLogs(); + } + + public void Dispose() + { + ReflectionHelper.ResetModLoaderState(); + TestLoggerMock.ResetLogs(); + try + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + catch { } + } + + private class NullMetadataMod : IDeadshotMod + { + public string Name => null!; + public string Description => null!; + public string Creator => null!; + public string Version => null!; + public bool Loaded { get; private set; } + public void Load() => Loaded = true; + } + + [Fact] + public void DeadshotMod_NullMetadata_DoesNotThrowOnInterfaceAccess() + { + IDeadshotMod mod = new NullMetadataMod(); + + Assert.Null(mod.Name); + Assert.Null(mod.Description); + Assert.Null(mod.Creator); + Assert.Null(mod.Version); + + mod.Load(); + Assert.True(((NullMetadataMod)mod).Loaded); + } + + private class EmptyMetadataMod : IDeadshotMod + { + public string Name => ""; + public string Description => " "; + public string Creator => "\t"; + public string Version => "\n"; + public void Load() { } + } + + [Fact] + public void DeadshotMod_EmptyOrWhitespaceMetadata_PreservedWithoutException() + { + IDeadshotMod mod = new EmptyMetadataMod(); + + Assert.Equal("", mod.Name); + Assert.Equal(" ", mod.Description); + Assert.Equal("\t", mod.Creator); + Assert.Equal("\n", mod.Version); + } + + [Fact] + public void DeadshotMod_ThrowingPropertyGetters_HandledGracefullyByModLoader() + { + string code = @" +using System; +using DeadshotModAPI; +public class ThrowingPropsMod : IDeadshotMod +{ + public string Name => throw new InvalidOperationException(""Name exploded!""); + public string Description => throw new InvalidOperationException(""Desc exploded!""); + public string Creator => throw new InvalidOperationException(""Creator exploded!""); + public string Version => throw new InvalidOperationException(""Version exploded!""); + public bool DidLoad = false; + public void Load() { DidLoad = true; } +}"; + string dllPath = Path.Combine(_tempDir, "ThrowingPropsMod.dll"); + TestAssemblyBuilder.CompileAssembly(code, dllPath, "ThrowingPropsAssembly"); + + var ex = Record.Exception(() => ReflectionHelper.InvokeLoadMod(_loader, dllPath)); + + Assert.Null(ex); + Assert.Single(ModLoader.LoadedMods); + Assert.Contains(TestLoggerMock.LoggedWarnings, w => w.Contains("Failed to read mod name")); + } + + [Fact] + public void DeadshotMod_MultipleInstancesOfModClass_CanBeInstantiatedIndependently() + { + var mod1 = new NullMetadataMod(); + var mod2 = new NullMetadataMod(); + + mod1.Load(); + + Assert.True(mod1.Loaded); + Assert.False(mod2.Loaded); + } + + [Fact] + public void DeadshotMod_UnicodeAndSpecialMetadata_PreservedAccurately() + { + string code = @" +using DeadshotModAPI; +public class UnicodeMetadataMod : IDeadshotMod +{ + public string Name => ""Mod ๐Ÿš€ ๆ—ฅๆœฌ่ชž""; + public string Description => ""Desc with emojis ๐ŸŽฎ""; + public string Creator => ""Author: Renรฉ / Josรฉ""; + public string Version => ""2.0.0-beta.1+build.123""; + public void Load() { } +}"; + string dllPath = Path.Combine(_tempDir, "UnicodeMetadataMod.dll"); + TestAssemblyBuilder.CompileAssembly(code, dllPath, "UnicodeMetadataAssembly"); + + ReflectionHelper.InvokeLoadMod(_loader, dllPath); + + Assert.Single(ModLoader.LoadedMods); + var loaded = ModLoader.LoadedMods[0]; + Assert.Equal("Mod ๐Ÿš€ ๆ—ฅๆœฌ่ชž", loaded.Name); + Assert.Equal("2.0.0-beta.1+build.123", loaded.Version); + } +} diff --git a/DeadshotModAPI.Tests/Tier2_BoundaryCornerCases/GameManagerBoundaryTests.cs b/DeadshotModAPI.Tests/Tier2_BoundaryCornerCases/GameManagerBoundaryTests.cs new file mode 100644 index 0000000..422a957 --- /dev/null +++ b/DeadshotModAPI.Tests/Tier2_BoundaryCornerCases/GameManagerBoundaryTests.cs @@ -0,0 +1,76 @@ +using System; +using System.Reflection; +using DeadshotModAPI.Tests.TestHelpers; +using Xunit; + +namespace DeadshotModAPI.Tests.Tier2_BoundaryCornerCases; + +public class GameManagerBoundaryTests : IDisposable +{ + public GameManagerBoundaryTests() + { + TestLoggerMock.ResetLogs(); + } + + public void Dispose() + { + TestLoggerMock.ResetLogs(); + } + + [Fact] + public void GameManager_RestartLevel_FalseCutscene_WhenSingletonNull_CatchesAndLogsError() + { + var ex = Record.Exception(() => GameManager.RestartLevel(false)); + + Assert.Null(ex); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Failed to restart level")); + } + + [Fact] + public void GameManager_RestartLevel_TrueCutscene_WhenSingletonNull_CatchesAndLogsError() + { + var ex = Record.Exception(() => GameManager.RestartLevel(true)); + + Assert.Null(ex); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Failed to restart level")); + } + + [Fact] + public void GameManager_RestartLevel_RapidConsecutiveInvocations_DoesNotThrow() + { + for (int i = 0; i < 10; i++) + { + var ex = Record.Exception(() => GameManager.RestartLevel(i % 2 == 0)); + Assert.Null(ex); + } + + Assert.True(TestLoggerMock.LoggedErrors.Count >= 10); + } + + [Fact] + public void GameManager_MethodSignature_RestartLevel_HasOptionalBooleanDefaultingToFalse() + { + var method = typeof(GameManager).GetMethod("RestartLevel", new[] { typeof(bool) }); + Assert.NotNull(method); + + var param = method.GetParameters()[0]; + Assert.True(param.IsOptional, "Parameter playCutscene should be optional."); + Assert.Equal(true, param.DefaultValue); + } + + [Fact] + public void GameManager_InternalLevelGuids_ConstantsAreNonEmpty() + { + var fields = typeof(GameManager).GetFields(BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotEmpty(fields); + + foreach (var f in fields) + { + if (f.FieldType == typeof(string) && f.IsLiteral) + { + var val = (string)f.GetValue(null); + Assert.False(string.IsNullOrWhiteSpace(val), $"Constant field '{f.Name}' should not be empty."); + } + } + } +} diff --git a/DeadshotModAPI.Tests/Tier2_BoundaryCornerCases/InputBoundaryTests.cs b/DeadshotModAPI.Tests/Tier2_BoundaryCornerCases/InputBoundaryTests.cs new file mode 100644 index 0000000..4ec41b2 --- /dev/null +++ b/DeadshotModAPI.Tests/Tier2_BoundaryCornerCases/InputBoundaryTests.cs @@ -0,0 +1,99 @@ +using System; +using DeadshotModAPI.Tests.TestHelpers; +using Xunit; + +namespace DeadshotModAPI.Tests.Tier2_BoundaryCornerCases; + +public class InputBoundaryTests : IDisposable +{ + public InputBoundaryTests() + { + ReflectionHelper.ResetInputState(); + TestLoggerMock.ResetLogs(); + } + + public void Dispose() + { + ReflectionHelper.ResetInputState(); + TestLoggerMock.ResetLogs(); + } + + [Fact] + public void Input_OnKeyPressed_NullAction_DoesNotThrowAndDoesNotCrashOnTrigger() + { + // Registering a null action should be handled gracefully or not crash TriggerKey + var ex = Record.Exception(() => + { + Input.OnKeyPressed(Key.Space, null!); + ReflectionHelper.InvokeTriggerKey(Key.Space); + }); + + Assert.Null(ex); + } + + [Fact] + public void Input_RemoveKeyPressed_UnregisteredKey_DoesNotThrow() + { + var ex = Record.Exception(() => + { + Input.RemoveKeyPressed(Key.F1, () => { }); + }); + + Assert.Null(ex); + } + + [Fact] + public void Input_RemoveKeyPressed_NullAction_DoesNotThrow() + { + Input.OnKeyPressed(Key.F2, () => { }); + + var ex = Record.Exception(() => + { + Input.RemoveKeyPressed(Key.F2, null!); + }); + + Assert.Null(ex); + } + + [Fact] + public void Input_TriggerKey_UnregisteredKey_ExecutesNoOpWithoutException() + { + var ex = Record.Exception(() => + { + ReflectionHelper.InvokeTriggerKey(Key.F12); + }); + + Assert.Null(ex); + } + + [Fact] + public void Input_TriggerKey_ThrowingAction_CatchesExceptionAndLogsError() + { + bool secondCallbackRan = false; + + Input.OnKeyPressed(Key.E, () => throw new InvalidOperationException("Explosion in key callback")); + Input.OnKeyPressed(Key.E, () => secondCallbackRan = true); + + var ex = Record.Exception(() => + { + ReflectionHelper.InvokeTriggerKey(Key.E); + }); + + Assert.Null(ex); + Assert.True(secondCallbackRan, "Subsequent callbacks must still run even if earlier callback throws."); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Explosion in key callback")); + } + + [Theory] + [InlineData((Key)(-1))] + [InlineData((Key)9999)] + public void Input_OnKeyPressed_InvalidKeyEnum_RegistersAndTriggersDefensively(Key invalidKey) + { + bool called = false; + Input.OnKeyPressed(invalidKey, () => called = true); + + ReflectionHelper.InvokeTriggerKey(invalidKey); + + Assert.True(called); + } +} diff --git a/DeadshotModAPI.Tests/Tier2_BoundaryCornerCases/LoggerBoundaryTests.cs b/DeadshotModAPI.Tests/Tier2_BoundaryCornerCases/LoggerBoundaryTests.cs new file mode 100644 index 0000000..6dd5337 --- /dev/null +++ b/DeadshotModAPI.Tests/Tier2_BoundaryCornerCases/LoggerBoundaryTests.cs @@ -0,0 +1,84 @@ +using System; +using DeadshotModAPI.Tests.TestHelpers; +using Xunit; + +namespace DeadshotModAPI.Tests.Tier2_BoundaryCornerCases; + +public class LoggerBoundaryTests : IDisposable +{ + public LoggerBoundaryTests() + { + TestLoggerMock.ResetLogs(); + } + + public void Dispose() + { + TestLoggerMock.ResetLogs(); + } + + [Fact] + public void Logger_NullMessage_ExecutesDefensivelyAcrossAllLevels() + { + var exLog = Record.Exception(() => Logger.Log(null!)); + var exInfo = Record.Exception(() => Logger.Info(null!)); + var exWarn = Record.Exception(() => Logger.Warning(null!)); + var exErr = Record.Exception(() => Logger.Error(null!)); + var exDbg = Record.Exception(() => Logger.Debug(null!)); + + Assert.Null(exLog); + Assert.Null(exInfo); + Assert.Null(exWarn); + Assert.Null(exErr); + Assert.Null(exDbg); + } + + [Fact] + public void Logger_EmptyAndWhitespaceStrings_PreservedAndLogged() + { + Logger.Info(""); + Logger.Warning(" "); + Logger.Error("\t\r\n"); + + Assert.Contains("", TestLoggerMock.LoggedInfo); + Assert.Contains(" ", TestLoggerMock.LoggedWarnings); + Assert.Contains("\t\r\n", TestLoggerMock.LoggedErrors); + } + + [Fact] + public void Logger_SpecialAndFormatCharacters_DoNotThrowFormatException() + { + string tricky = "Format string {0} and curly braces {} and %s %d \0 null-char"; + + var ex = Record.Exception(() => + { + Logger.Info(tricky); + Logger.Warning(tricky); + Logger.Error(tricky); + Logger.Debug(tricky); + }); + + Assert.Null(ex); + Assert.Contains(tricky, TestLoggerMock.LoggedInfo); + } + + [Fact] + public void Logger_ExtremelyLargeString_LogsWithoutBufferOverflow() + { + string largeString = new string('X', 100_000); + + var ex = Record.Exception(() => Logger.Info(largeString)); + + Assert.Null(ex); + Assert.Contains(largeString, TestLoggerMock.LoggedInfo); + } + + [Fact] + public void Logger_UnicodeAndEmojiCharacters_PreservedFaithfully() + { + string unicodeString = "Hello ๐ŸŒ! Modding: ๆ—ฅๆœฌ่ชž, Espaรฑol, ะ ัƒััะบะธะน, ๐ŸŽฎ๐Ÿ”ฅ"; + + Logger.Info(unicodeString); + + Assert.Contains(unicodeString, TestLoggerMock.LoggedInfo); + } +} diff --git a/DeadshotModAPI.Tests/Tier2_BoundaryCornerCases/ModLoaderBoundaryTests.cs b/DeadshotModAPI.Tests/Tier2_BoundaryCornerCases/ModLoaderBoundaryTests.cs new file mode 100644 index 0000000..b910c01 --- /dev/null +++ b/DeadshotModAPI.Tests/Tier2_BoundaryCornerCases/ModLoaderBoundaryTests.cs @@ -0,0 +1,133 @@ +using System; +using System.IO; +using System.Runtime.Serialization; +using DeadshotModAPI.Tests.TestHelpers; +using Xunit; + +namespace DeadshotModAPI.Tests.Tier2_BoundaryCornerCases; + +public class ModLoaderBoundaryTests : IDisposable +{ + private readonly string _tempDir; + private readonly ModLoader _loader; + + public ModLoaderBoundaryTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "Deadshot_ML_Boundary_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + _loader = (ModLoader)FormatterServices.GetUninitializedObject(typeof(ModLoader)); + ReflectionHelper.ResetModLoaderState(); + TestLoggerMock.ResetLogs(); + } + + public void Dispose() + { + ReflectionHelper.ResetModLoaderState(); + TestLoggerMock.ResetLogs(); + try + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + catch { } + } + + [Fact] + public void ModLoader_LoadMod_NullPath_LogsWarningAndDoesNotThrow() + { + var ex = Record.Exception(() => ReflectionHelper.InvokeLoadMod(_loader, null!)); + + Assert.Null(ex); + Assert.Contains(TestLoggerMock.LoggedWarnings, w => w.Contains("null or empty")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t\r\n")] + public void ModLoader_LoadMod_WhitespacePath_LogsWarningAndDoesNotThrow(string whitespacePath) + { + var ex = Record.Exception(() => ReflectionHelper.InvokeLoadMod(_loader, whitespacePath)); + + Assert.Null(ex); + Assert.Contains(TestLoggerMock.LoggedWarnings, w => w.Contains("null or empty")); + } + + [Fact] + public void ModLoader_LoadMod_NonexistentFile_LogsErrorAndDoesNotThrow() + { + string fakePath = Path.Combine(_tempDir, "DoesNotExist_12345.dll"); + var ex = Record.Exception(() => ReflectionHelper.InvokeLoadMod(_loader, fakePath)); + + Assert.Null(ex); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("does not exist")); + } + + [Fact] + public void ModLoader_LoadMod_ZeroByteDll_LogsErrorAndDoesNotThrow() + { + string zeroByteDll = Path.Combine(_tempDir, "ZeroByte.dll"); + File.WriteAllBytes(zeroByteDll, Array.Empty()); + + var ex = Record.Exception(() => ReflectionHelper.InvokeLoadMod(_loader, zeroByteDll)); + + Assert.Null(ex); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Corrupt or invalid mod assembly")); + } + + [Fact] + public void ModLoader_LoadMod_CorruptDllBytes_LogsErrorAndDoesNotThrow() + { + string corruptDll = Path.Combine(_tempDir, "Corrupted.dll"); + File.WriteAllBytes(corruptDll, new byte[] { 0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22, 0x33 }); + + var ex = Record.Exception(() => ReflectionHelper.InvokeLoadMod(_loader, corruptDll)); + + Assert.Null(ex); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Corrupt or invalid mod assembly")); + } + + [Fact] + public void ModLoader_LoadMod_AbstractClassImplementingInterface_IsIgnored() + { + string code = @" +using DeadshotModAPI; +public abstract class AbstractTestMod : IDeadshotMod +{ + public string Name => ""AbstractMod""; + public string Description => ""Desc""; + public string Creator => ""Author""; + public string Version => ""1.0""; + public abstract void Load(); +}"; + string dllPath = Path.Combine(_tempDir, "AbstractMod.dll"); + TestAssemblyBuilder.CompileAssembly(code, dllPath, "AbstractModAssembly"); + + ReflectionHelper.InvokeLoadMod(_loader, dllPath); + + Assert.Empty(ModLoader.LoadedMods); + } + + [Fact] + public void ModLoader_LoadMod_ThrowingLoadMethod_CatchesExceptionAndLogsError() + { + string code = @" +using System; +using DeadshotModAPI; +public class CrashOnLoadMod : IDeadshotMod +{ + public string Name => ""CrashMod""; + public string Description => ""Desc""; + public string Creator => ""Author""; + public string Version => ""1.0""; + public void Load() => throw new ApplicationException(""Crashing during load!""); +}"; + string dllPath = Path.Combine(_tempDir, "CrashOnLoadMod.dll"); + TestAssemblyBuilder.CompileAssembly(code, dllPath, "CrashOnLoadModAssembly"); + + var ex = Record.Exception(() => ReflectionHelper.InvokeLoadMod(_loader, dllPath)); + + Assert.Null(ex); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Crashing during load")); + } +} diff --git a/DeadshotModAPI.Tests/Tier2_BoundaryCornerCases/SceneManagerBoundaryTests.cs b/DeadshotModAPI.Tests/Tier2_BoundaryCornerCases/SceneManagerBoundaryTests.cs new file mode 100644 index 0000000..e6053ce --- /dev/null +++ b/DeadshotModAPI.Tests/Tier2_BoundaryCornerCases/SceneManagerBoundaryTests.cs @@ -0,0 +1,85 @@ +using System; +using DeadshotModAPI.Tests.TestHelpers; +using Xunit; + +namespace DeadshotModAPI.Tests.Tier2_BoundaryCornerCases; + +public class SceneManagerBoundaryTests : IDisposable +{ + public SceneManagerBoundaryTests() + { + TestLoggerMock.ResetLogs(); + TestLoggerMock.OverrideBepInExRootPath = null; + } + + public void Dispose() + { + TestLoggerMock.ResetLogs(); + TestLoggerMock.OverrideBepInExRootPath = null; + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\r\n\t")] + public void SceneManager_LoadString_NullOrWhitespace_LogsWarningAndDoesNotThrow(string sceneName) + { + var ex = Record.Exception(() => SceneManager.Load(sceneName)); + + Assert.Null(ex); + Assert.Contains(TestLoggerMock.LoggedWarnings, w => w.Contains("null, empty, or whitespace")); + } + + [Theory] + [InlineData(-1)] + [InlineData(-100)] + [InlineData(int.MinValue)] + public void SceneManager_LoadInt_NegativeIndex_LogsWarningAndDoesNotThrow(int negativeIndex) + { + var ex = Record.Exception(() => SceneManager.Load(negativeIndex)); + + Assert.Null(ex); + Assert.Contains(TestLoggerMock.LoggedWarnings, w => w.Contains("non-negative")); + } + + [Fact] + public void SceneManager_LoadString_NonexistentScene_CatchesExceptionAndLogsError() + { + var ex = Record.Exception(() => SceneManager.Load("UnknownScene_XYZ")); + + Assert.Null(ex); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Failed to load scene 'UnknownScene_XYZ'")); + } + + [Fact] + public void SceneManager_LoadInt_IndexWhenUnderlyingSystemThrows_CatchesAndLogsError() + { + var ex = Record.Exception(() => SceneManager.Load(9999)); + + Assert.Null(ex); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Failed to load scene at index 9999")); + } + + [Fact] + public void SceneManager_LoadModsBundle_EmptyBepInExRoot_LogsErrorAndDoesNotThrow() + { + TestLoggerMock.OverrideBepInExRootPath = ""; + + var ex = Record.Exception(() => ReflectionHelper.InvokeLoadModsBundle()); + + Assert.Null(ex); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("BepInEx root path is null or empty")); + } + + [Fact] + public void SceneManager_LoadModsBundle_MissingBundleFile_LogsErrorAndDoesNotThrow() + { + TestLoggerMock.OverrideBepInExRootPath = @"C:\NonExistentPath_TestBundle"; + + var ex = Record.Exception(() => ReflectionHelper.InvokeLoadModsBundle()); + + Assert.Null(ex); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Mod bundle file does not exist")); + } +} diff --git a/DeadshotModAPI.Tests/Tier2_Challenger/SceneManagerGameManagerChallengerTests.cs b/DeadshotModAPI.Tests/Tier2_Challenger/SceneManagerGameManagerChallengerTests.cs new file mode 100644 index 0000000..a75bd97 --- /dev/null +++ b/DeadshotModAPI.Tests/Tier2_Challenger/SceneManagerGameManagerChallengerTests.cs @@ -0,0 +1,268 @@ +using System; +using System.Reflection; +using DeadshotModAPI.Tests.TestHelpers; +using HarmonyLib; +using Xunit; + +namespace DeadshotModAPI.Tests.Tier2_Challenger; + +/// +/// Adversarial edge case tests for SceneManager and GameManager error handling and null-safety. +/// Executed by Challenger 2 for Milestone 2. +/// +public class SceneManagerGameManagerChallengerTests +{ + private static bool _patchesInitialized; + + public SceneManagerGameManagerChallengerTests() + { + TestLoggerMock.Initialize(); + TestLoggerMock.ResetLogs(); + InitializePatches(); + } + + private static void InitializePatches() + { + if (_patchesInitialized) return; + _patchesInitialized = true; + + var harmony = new Harmony("DeadshotModAPI.Tests.Challenger2"); + + // Patch UnityEngine.Object.op_Inequality + var opInequality = typeof(UnityEngine.Object).GetMethod("op_Inequality", BindingFlags.Public | BindingFlags.Static); + if (opInequality != null) + { + var prefix = typeof(SceneManagerGameManagerChallengerTests).GetMethod(nameof(PrefixOpInequality), BindingFlags.NonPublic | BindingFlags.Static); + try + { + harmony.Patch(opInequality, new HarmonyMethod(prefix)); + } + catch { } + } + + // Patch BepInEx.Paths.BepInExRootPath to avoid missing SemanticVersioning.dll in headless tests + var bepPathProp = typeof(BepInEx.Paths).GetProperty("BepInExRootPath", BindingFlags.Public | BindingFlags.Static); + if (bepPathProp?.GetGetMethod() != null) + { + var prefixPath = typeof(SceneManagerGameManagerChallengerTests).GetMethod(nameof(PrefixBepInExRootPath), BindingFlags.NonPublic | BindingFlags.Static); + try + { + harmony.Patch(bepPathProp.GetGetMethod(), new HarmonyMethod(prefixPath)); + } + catch { } + } + } + + private static bool PrefixOpInequality(UnityEngine.Object x, UnityEngine.Object y, ref bool __result) + { + bool xNull = ReferenceEquals(x, null); + bool yNull = ReferenceEquals(y, null); + __result = (xNull != yNull) || (!xNull && !yNull && !ReferenceEquals(x, y)); + return false; + } + + private static bool PrefixBepInExRootPath(ref string __result) + { + if (TestLoggerMock.OverrideBepInExRootPath != null) + { + __result = TestLoggerMock.OverrideBepInExRootPath; + return false; + } + __result = @"C:\MockBepInEx"; + return false; + } + + #region 1. SceneManager.Load with Null, Empty, Whitespace Strings + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t\n \r")] + public void SceneManager_Load_NullOrWhitespaceSceneName_LogsWarningAndDoesNotThrow(string invalidSceneName) + { + // Act + var exception = Record.Exception(() => SceneManager.Load(invalidSceneName)); + + // Assert + Assert.Null(exception); + Assert.Contains(TestLoggerMock.LoggedWarnings, msg => + msg.Contains("sceneName is null, empty, or whitespace", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void SceneManager_Load_WhenUnderlyingLoadingScreenThrows_CatchesAndLogsError() + { + // Act: Loading a named scene in headless mode causes LoadingScreen proxy to throw + var exception = Record.Exception(() => SceneManager.Load("NonExistentScene_12345")); + + // Assert: Exception is caught internally, never leaks to caller + Assert.Null(exception); + Assert.Contains(TestLoggerMock.LoggedErrors, msg => + msg.Contains("Failed to load scene 'NonExistentScene_12345'", StringComparison.OrdinalIgnoreCase)); + } + + #endregion + + #region 2. SceneManager.Load with Negative Indices + + [Theory] + [InlineData(-1)] + [InlineData(-999)] + [InlineData(int.MinValue)] + public void SceneManager_Load_NegativeSceneIndex_LogsWarningAndDoesNotThrow(int negativeIndex) + { + // Act + var exception = Record.Exception(() => SceneManager.Load(negativeIndex)); + + // Assert + Assert.Null(exception); + Assert.Contains(TestLoggerMock.LoggedWarnings, msg => + msg.Contains($"invalid sceneIndex '{negativeIndex}'", StringComparison.OrdinalIgnoreCase) && + msg.Contains("Index must be non-negative", StringComparison.OrdinalIgnoreCase)); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(999)] + public void SceneManager_Load_ValidIndex_WhenLoadingScreenThrows_CatchesAndLogsError(int index) + { + // Act: Invoking with non-negative index in headless mode + var exception = Record.Exception(() => SceneManager.Load(index)); + + // Assert: Exception is caught internally, never leaks to caller + Assert.Null(exception); + Assert.Contains(TestLoggerMock.LoggedErrors, msg => + msg.Contains($"Failed to load scene at index {index}", StringComparison.OrdinalIgnoreCase)); + } + + #endregion + + #region 3. SceneManager.LoadModsBundle Double Invocation and Idempotency + + [Fact] + public void SceneManager_LoadModsBundle_CalledTwiceInARow_DoesNotThrowOrCrash() + { + // Act - Call twice in a row + var ex1 = Record.Exception(() => ReflectionHelper.InvokeLoadModsBundle()); + var ex2 = Record.Exception(() => ReflectionHelper.InvokeLoadModsBundle()); + + // Assert + // Neither call throws an unhandled exception out of LoadModsBundle + Assert.Null(ex1); + Assert.Null(ex2); + + // Verify that diagnostic error was safely logged indicating bundle file does not exist at mock path + Assert.Contains(TestLoggerMock.LoggedErrors, msg => + msg.Contains("Mod bundle file does not exist at path", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void SceneManager_LoadModsBundle_WhenAlreadyLoaded_LogsWarningAndSafelyNoOps() + { + var bundleField = typeof(SceneManager).GetField("_modsBundle", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(bundleField); + + var originalBundle = bundleField.GetValue(null); + try + { + // Set dummy non-null object via reflection to simulate bundle already loaded + // We use FormatterServices or an uninitialized AssetBundle if possible + var dummyBundle = System.Runtime.Serialization.FormatterServices.GetUninitializedObject(typeof(UnityEngine.AssetBundle)); + GC.SuppressFinalize(dummyBundle); + bundleField.SetValue(null, dummyBundle); + + TestLoggerMock.ResetLogs(); + + // Act + ReflectionHelper.InvokeLoadModsBundle(); + + // Assert + Assert.Contains(TestLoggerMock.LoggedWarnings, msg => + msg.Contains("Mods Menu bundle is already loaded", StringComparison.OrdinalIgnoreCase)); + } + finally + { + // Restore original state + bundleField.SetValue(null, originalBundle); + } + } + + #endregion + + #region 4. GameManager.RestartLevel When Scene Name Is Null, Empty, or MenuScene + + [Fact] + public void GameManager_RestartLevel_WhenInMenuOrLoadingScene_ReturnsSafelyWithoutThrowing() + { + // In headless runner, Unity SceneManager returns default Scene struct where name is null/empty. + // GameManager.RestartLevel must guard against null/empty scene name, MenuScene, and LoadingScene. + var ex = Record.Exception(() => GameManager.RestartLevel()); + + Assert.Null(ex); + // Should return cleanly without throwing unhandled exceptions + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void GameManager_RestartLevel_AcceptsPlayCutsceneParameter_WithoutThrowing(bool playCutscene) + { + var ex = Record.Exception(() => GameManager.RestartLevel(playCutscene)); + Assert.Null(ex); + } + + #endregion + + #region 5. GameManager.RestartLevel DataManager and Key Validation Checks + + [Fact] + public void GameManager_RestartLevel_MethodHasDefensiveTryCatchAndGuards() + { + var method = typeof(GameManager).GetMethod("RestartLevel", BindingFlags.Public | BindingFlags.Static); + Assert.NotNull(method); + + // Verify method can be invoked multiple times without throwing + for (int i = 0; i < 5; i++) + { + var ex = Record.Exception(() => GameManager.RestartLevel(i % 2 == 0)); + Assert.Null(ex); + } + } + + [Fact] + public void GameManager_Constants_ValidateKeysUsedForSaveDataLookup() + { + var type = typeof(GameManager); + var keyField = type.GetField("gameManagerKey", BindingFlags.NonPublic | BindingFlags.Static); + var idField = type.GetField("gameManagerId", BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(keyField); + Assert.NotNull(idField); + + string keyVal = (string)keyField.GetValue(null)!; + string idVal = (string)idField.GetValue(null)!; + + // Verify non-empty valid GUID / identifier strings + Assert.False(string.IsNullOrWhiteSpace(keyVal)); + Assert.False(string.IsNullOrWhiteSpace(idVal)); + Assert.True(Guid.TryParse(keyVal, out _), "gameManagerKey must be a valid GUID."); + Assert.Equal("Deadshot.GameManager", idVal); + } + + #endregion + + #region 6. GameManager.RestartLevel Null Singletons Guarding + + [Fact] + public void GameManager_RestartLevel_SafeAgainstNullSingletons() + { + // When running in headless mode, DeadshotGameManager.INSTANCE and pauseMenu are null. + // RestartLevel must not produce unhandled NullReferenceException. + var ex = Record.Exception(() => GameManager.RestartLevel(true)); + Assert.Null(ex); + } + + #endregion +} diff --git a/DeadshotModAPI.Tests/Tier3_CrossFeatureInteractions/CrossFeatureInteractionTests.cs b/DeadshotModAPI.Tests/Tier3_CrossFeatureInteractions/CrossFeatureInteractionTests.cs new file mode 100644 index 0000000..bb5adc2 --- /dev/null +++ b/DeadshotModAPI.Tests/Tier3_CrossFeatureInteractions/CrossFeatureInteractionTests.cs @@ -0,0 +1,229 @@ +using System; +using System.IO; +using System.Runtime.Serialization; +using DeadshotModAPI.Tests.TestHelpers; +using Xunit; + +namespace DeadshotModAPI.Tests.Tier3_CrossFeatureInteractions; + +public class CrossFeatureInteractionTests : IDisposable +{ + private readonly string _tempDir; + private readonly ModLoader _loader; + + public CrossFeatureInteractionTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "Deadshot_Tier3_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + _loader = (ModLoader)FormatterServices.GetUninitializedObject(typeof(ModLoader)); + ReflectionHelper.ResetInputState(); + ReflectionHelper.ResetModLoaderState(); + TestLoggerMock.ResetLogs(); + } + + public void Dispose() + { + ReflectionHelper.ResetInputState(); + ReflectionHelper.ResetModLoaderState(); + TestLoggerMock.ResetLogs(); + try + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + catch { } + } + + [Fact] + public void Interaction1_KeyCallback_RegistersNewKeyInsideCallback_NewKeyFunctionsProperly() + { + bool secondaryFired = false; + + // Callback for F1 registers F2 + Input.OnKeyPressed(Key.F1, () => + { + Input.OnKeyPressed(Key.F2, () => secondaryFired = true); + }); + + // Trigger F1 + ReflectionHelper.InvokeTriggerKey(Key.F1); + Assert.False(secondaryFired, "F2 should not have fired yet."); + + // Now trigger F2 + ReflectionHelper.InvokeTriggerKey(Key.F2); + Assert.True(secondaryFired, "F2 should fire now after dynamic registration."); + } + + [Fact] + public void Interaction2_KeyCallback_UnregistersSelfDuringCallback_SafelyCompletesInvocation() + { + int invocationCount = 0; + Action callback = null!; + + callback = () => + { + invocationCount++; + Input.RemoveKeyPressed(Key.X, callback); + }; + + Input.OnKeyPressed(Key.X, callback); + + // First trigger: should run and remove itself + ReflectionHelper.InvokeTriggerKey(Key.X); + Assert.Equal(1, invocationCount); + + // Second trigger: should not run again + ReflectionHelper.InvokeTriggerKey(Key.X); + Assert.Equal(1, invocationCount); + } + + [Fact] + public void Interaction3_ModLoading_RegistersInputListener_WhichCanBeTriggered() + { + string modSource = @" +using DeadshotModAPI; +public class InputRegisteringMod : IDeadshotMod +{ + public string Name => ""InputRegMod""; + public string Description => ""Registers hotkey""; + public string Creator => ""Author""; + public string Version => ""1.0""; + public static bool KeyActionExecuted = false; + + public void Load() + { + Input.OnKeyPressed(Key.F7, OnF7Pressed); + } + + private void OnF7Pressed() + { + KeyActionExecuted = true; + } +}"; + string dllPath = Path.Combine(_tempDir, "InputRegMod.dll"); + TestAssemblyBuilder.CompileAssembly(modSource, dllPath, "InputRegModAssembly"); + + // Load the mod through ModLoader + ReflectionHelper.InvokeLoadMod(_loader, dllPath); + + Assert.Single(ModLoader.LoadedMods); + var modInstance = ModLoader.LoadedMods[0]; + Assert.Equal("InputRegMod", modInstance.Name); + + // Verify key action was not yet executed + var flagField = modInstance.GetType().GetField("KeyActionExecuted"); + Assert.NotNull(flagField); + Assert.False((bool)flagField!.GetValue(null)!); + + // Trigger key + ReflectionHelper.InvokeTriggerKey(Key.F7); + + // Verify key action executed through the mod + Assert.True((bool)flagField.GetValue(null)!); + } + + [Fact] + public void Interaction4_MultipleModsInSingleDll_AllAreDiscoveredAndLoaded() + { + string multiModSource = @" +using DeadshotModAPI; +public class ModAlpha : IDeadshotMod +{ + public string Name => ""ModAlpha""; + public string Description => ""First Mod""; + public string Creator => ""Dev A""; + public string Version => ""1.0""; + public static bool Loaded = false; + public void Load() { Loaded = true; } +} + +public class ModBeta : IDeadshotMod +{ + public string Name => ""ModBeta""; + public string Description => ""Second Mod""; + public string Creator => ""Dev B""; + public string Version => ""2.0""; + public static bool Loaded = false; + public void Load() { Loaded = true; } +} +"; + string dllPath = Path.Combine(_tempDir, "MultiMod.dll"); + TestAssemblyBuilder.CompileAssembly(multiModSource, dllPath, "MultiModAssembly"); + + ReflectionHelper.InvokeLoadMod(_loader, dllPath); + + Assert.Equal(2, ModLoader.LoadedMods.Count); + var alphaMod = ModLoader.LoadedMods.Find(m => m.Name == "ModAlpha"); + var betaMod = ModLoader.LoadedMods.Find(m => m.Name == "ModBeta"); + + Assert.NotNull(alphaMod); + Assert.NotNull(betaMod); + + var alphaFlag = alphaMod.GetType().GetField("Loaded"); + var betaFlag = betaMod.GetType().GetField("Loaded"); + Assert.True((bool)alphaFlag!.GetValue(null)!); + Assert.True((bool)betaFlag!.GetValue(null)!); + } + + [Fact] + public void Interaction5_FailingModIsolation_HealthyModLoadsSuccessfullyAlongsideFailingMod() + { + string mixedSource = @" +using System; +using DeadshotModAPI; +public class GoodMod : IDeadshotMod +{ + public string Name => ""GoodMod""; + public string Description => ""Works""; + public string Creator => ""Dev""; + public string Version => ""1.0""; + public static bool Loaded = false; + public void Load() { Loaded = true; } +} + +public class FailingMod : IDeadshotMod +{ + public string Name => ""FailingMod""; + public string Description => ""Fails""; + public string Creator => ""Dev""; + public string Version => ""1.0""; + public void Load() => throw new ApplicationException(""Fatal mod initialization error""); +} +"; + string dllPath = Path.Combine(_tempDir, "MixedMod.dll"); + TestAssemblyBuilder.CompileAssembly(mixedSource, dllPath, "MixedModAssembly"); + + ReflectionHelper.InvokeLoadMod(_loader, dllPath); + + // GoodMod should be successfully loaded + Assert.Single(ModLoader.LoadedMods); + var goodMod = ModLoader.LoadedMods[0]; + Assert.Equal("GoodMod", goodMod.Name); + + var goodFlag = goodMod.GetType().GetField("Loaded"); + Assert.True((bool)goodFlag!.GetValue(null)!); + + // FailingMod error should have been caught and logged + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Fatal mod initialization error")); + } + + [Fact] + public void Interaction6_KeyCallback_InvokingSceneManagerAndGameManager_SafelyExecutes() + { + bool callbackExecuted = false; + + Input.OnKeyPressed(Key.F11, () => + { + callbackExecuted = true; + // Mod hotkey invoking both SceneManager and GameManager + SceneManager.Load("Arena_Level"); + GameManager.RestartLevel(true); + }); + + ReflectionHelper.InvokeTriggerKey(Key.F11); + + Assert.True(callbackExecuted); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Failed to load scene 'Arena_Level'")); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Failed to restart level")); + } +} diff --git a/DeadshotModAPI.Tests/Tier4_RealWorldScenarios/EndToEndModSimulationTests.cs b/DeadshotModAPI.Tests/Tier4_RealWorldScenarios/EndToEndModSimulationTests.cs new file mode 100644 index 0000000..c2a0c62 --- /dev/null +++ b/DeadshotModAPI.Tests/Tier4_RealWorldScenarios/EndToEndModSimulationTests.cs @@ -0,0 +1,316 @@ +using System; +using System.IO; +using System.Reflection; +using System.Runtime.Serialization; +using DeadshotModAPI.Tests.TestHelpers; +using Xunit; + +namespace DeadshotModAPI.Tests.Tier4_RealWorldScenarios; + +public class EndToEndModSimulationTests : IDisposable +{ + private readonly string _tempDir; + private readonly ModLoader _loader; + + public EndToEndModSimulationTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "Deadshot_Tier4_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + _loader = (ModLoader)FormatterServices.GetUninitializedObject(typeof(ModLoader)); + ReflectionHelper.ResetInputState(); + ReflectionHelper.ResetModLoaderState(); + TestLoggerMock.ResetLogs(); + } + + public void Dispose() + { + ReflectionHelper.ResetInputState(); + ReflectionHelper.ResetModLoaderState(); + TestLoggerMock.ResetLogs(); + try + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + catch { } + } + + [Fact] + public void Scenario1_FullProductionModLifecycle_RegistersHotkeys_TriggersSubsystems_TracksState() + { + string cheatModSource = @" +using System; +using DeadshotModAPI; + +public class DeadshotCheatMenuMod : IDeadshotMod +{ + public string Name => ""Deadshot Cheat Menu""; + public string Description => ""In-game trainer for Deadshot: toggles godmode, teleport, and restart.""; + public string Creator => ""ModderDev""; + public string Version => ""1.2.0""; + + public static bool GodMode = false; + public static int TeleportCount = 0; + public static int RestartCount = 0; + + public void Load() + { + Logger.Info(""Initializing Deadshot Cheat Menu v1.2.0...""); + Input.OnKeyPressed(Key.F8, OnToggleGodMode); + Input.OnKeyPressed(Key.F9, OnTeleport); + Input.OnKeyPressed(Key.F10, OnQuickRestart); + Logger.Info(""Deadshot Cheat Menu hotkeys registered: F8 (GodMode), F9 (Teleport), F10 (Restart).""); + } + + private void OnToggleGodMode() + { + GodMode = !GodMode; + Logger.Info($""GodMode toggled: {GodMode}""); + } + + private void OnTeleport() + { + TeleportCount++; + SceneManager.Load(""Level_03_BossArena""); + } + + private void OnQuickRestart() + { + RestartCount++; + GameManager.RestartLevel(false); + } +}"; + string dllPath = Path.Combine(_tempDir, "DeadshotCheatMenu.dll"); + TestAssemblyBuilder.CompileAssembly(cheatModSource, dllPath, "CheatMenuAssembly"); + + // 1. ModLoader discovers and loads mod + ReflectionHelper.InvokeLoadMod(_loader, dllPath); + + // Verify mod registration + Assert.Single(ModLoader.LoadedMods); + var modInstance = ModLoader.LoadedMods[0]; + Assert.Equal("Deadshot Cheat Menu", modInstance.Name); + Assert.Equal("1.2.0", modInstance.Version); + Assert.Contains(TestLoggerMock.LoggedInfo, msg => msg.Contains("Initializing Deadshot Cheat Menu")); + + var modType = modInstance.GetType(); + var godModeField = modType.GetField("GodMode", BindingFlags.Public | BindingFlags.Static)!; + var teleportField = modType.GetField("TeleportCount", BindingFlags.Public | BindingFlags.Static)!; + var restartField = modType.GetField("RestartCount", BindingFlags.Public | BindingFlags.Static)!; + + // 2. Simulate player pressing F8 (Toggle GodMode to True) + ReflectionHelper.InvokeTriggerKey(Key.F8); + Assert.True((bool)godModeField.GetValue(null)!); + Assert.Contains(TestLoggerMock.LoggedInfo, msg => msg.Contains("GodMode toggled: True")); + + // 3. Simulate player pressing F8 again (Toggle GodMode to False) + ReflectionHelper.InvokeTriggerKey(Key.F8); + Assert.False((bool)godModeField.GetValue(null)!); + Assert.Contains(TestLoggerMock.LoggedInfo, msg => msg.Contains("GodMode toggled: False")); + + // 4. Simulate player pressing F9 (Teleport -> SceneManager.Load) + ReflectionHelper.InvokeTriggerKey(Key.F9); + Assert.Equal(1, (int)teleportField.GetValue(null)!); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Failed to load scene 'Level_03_BossArena'")); + + // 5. Simulate player pressing F10 (Quick Restart -> GameManager.RestartLevel) + ReflectionHelper.InvokeTriggerKey(Key.F10); + Assert.Equal(1, (int)restartField.GetValue(null)!); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Failed to restart level")); + } + + [Fact] + public void Scenario2_MultiModEcosystem_CoexistingModsWithSharedState() + { + string coreSource = @" +using DeadshotModAPI; +public class CoreLibMod : IDeadshotMod +{ + public string Name => ""CoreLib""; + public string Description => ""Provides shared state""; + public string Creator => ""Team""; + public string Version => ""1.0""; + public static int SharedCounter = 0; + + public void Load() + { + Input.OnKeyPressed(Key.F1, () => SharedCounter += 10); + } +}"; + string featureSource = @" +using DeadshotModAPI; +public class FeatureMod : IDeadshotMod +{ + public string Name => ""FeatureMod""; + public string Description => ""Uses shared state""; + public string Creator => ""Team""; + public string Version => ""1.0""; + public static int FeatureCounter = 0; + + public void Load() + { + Input.OnKeyPressed(Key.F2, () => FeatureCounter += 1); + } +}"; + string coreDll = Path.Combine(_tempDir, "01_CoreLib.dll"); + string featureDll = Path.Combine(_tempDir, "02_Feature.dll"); + + TestAssemblyBuilder.CompileAssembly(coreSource, coreDll, "CoreLibAssembly"); + TestAssemblyBuilder.CompileAssembly(featureSource, featureDll, "FeatureAssembly"); + + ReflectionHelper.InvokeLoadMod(_loader, coreDll); + ReflectionHelper.InvokeLoadMod(_loader, featureDll); + + Assert.Equal(2, ModLoader.LoadedMods.Count); + + var coreMod = ModLoader.LoadedMods.Find(m => m.Name == "CoreLib")!; + var featMod = ModLoader.LoadedMods.Find(m => m.Name == "FeatureMod")!; + + var sharedField = coreMod.GetType().GetField("SharedCounter", BindingFlags.Public | BindingFlags.Static)!; + var featField = featMod.GetType().GetField("FeatureCounter", BindingFlags.Public | BindingFlags.Static)!; + + // Trigger F1 twice + ReflectionHelper.InvokeTriggerKey(Key.F1); + ReflectionHelper.InvokeTriggerKey(Key.F1); + + // Trigger F2 three times + ReflectionHelper.InvokeTriggerKey(Key.F2); + ReflectionHelper.InvokeTriggerKey(Key.F2); + ReflectionHelper.InvokeTriggerKey(Key.F2); + + Assert.Equal(20, (int)sharedField.GetValue(null)!); + Assert.Equal(3, (int)featField.GetValue(null)!); + } + + [Fact] + public void Scenario3_ResilientProductionEnvironment_FaultyModsDoNotImpactHealthyMods() + { + // 1. Healthy Mod 1 + string good1Source = @" +using DeadshotModAPI; +public class GoodMod1 : IDeadshotMod +{ + public string Name => ""GoodMod1""; + public string Description => ""D""; + public string Creator => ""C""; + public string Version => ""1.0""; + public static bool Triggered = false; + public void Load() { Input.OnKeyPressed(Key.Home, () => Triggered = true); } +}"; + string good1Dll = Path.Combine(_tempDir, "Mod1_Good.dll"); + TestAssemblyBuilder.CompileAssembly(good1Source, good1Dll, "Good1Assembly"); + + // 2. Corrupt DLL (invalid header) + string corruptDll = Path.Combine(_tempDir, "Mod2_Corrupt.dll"); + File.WriteAllBytes(corruptDll, new byte[] { 0x00, 0x01, 0x02, 0x03 }); + + // 3. Crashing Mod (throws in Load) + string crashSource = @" +using System; +using DeadshotModAPI; +public class BadMod : IDeadshotMod +{ + public string Name => ""BadMod""; + public string Description => ""D""; + public string Creator => ""C""; + public string Version => ""1.0""; + public void Load() => throw new InvalidOperationException(""Critical initialization fault""); +}"; + string crashDll = Path.Combine(_tempDir, "Mod3_Crash.dll"); + TestAssemblyBuilder.CompileAssembly(crashSource, crashDll, "BadAssembly"); + + // 4. Healthy Mod 2 + string good2Source = @" +using DeadshotModAPI; +public class GoodMod2 : IDeadshotMod +{ + public string Name => ""GoodMod2""; + public string Description => ""D""; + public string Creator => ""C""; + public string Version => ""1.0""; + public static bool Triggered = false; + public void Load() { Input.OnKeyPressed(Key.End, () => Triggered = true); } +}"; + string good2Dll = Path.Combine(_tempDir, "Mod4_Good.dll"); + TestAssemblyBuilder.CompileAssembly(good2Source, good2Dll, "Good2Assembly"); + + // Load all 4 in order + ReflectionHelper.InvokeLoadMod(_loader, good1Dll); + ReflectionHelper.InvokeLoadMod(_loader, corruptDll); + ReflectionHelper.InvokeLoadMod(_loader, crashDll); + ReflectionHelper.InvokeLoadMod(_loader, good2Dll); + + // Verify only GoodMod1 and GoodMod2 are loaded + Assert.Equal(2, ModLoader.LoadedMods.Count); + var loadedGood1 = ModLoader.LoadedMods.Find(m => m.Name == "GoodMod1")!; + var loadedGood2 = ModLoader.LoadedMods.Find(m => m.Name == "GoodMod2")!; + Assert.NotNull(loadedGood1); + Assert.NotNull(loadedGood2); + + // Verify key triggers operate properly on surviving mods + var flag1 = loadedGood1.GetType().GetField("Triggered", BindingFlags.Public | BindingFlags.Static)!; + var flag2 = loadedGood2.GetType().GetField("Triggered", BindingFlags.Public | BindingFlags.Static)!; + + ReflectionHelper.InvokeTriggerKey(Key.Home); + ReflectionHelper.InvokeTriggerKey(Key.End); + + Assert.True((bool)flag1.GetValue(null)!); + Assert.True((bool)flag2.GetValue(null)!); + + // Verify errors were logged for faulty mods + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Corrupt or invalid mod assembly")); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Critical initialization fault")); + } + + [Fact] + public void Scenario4_DynamicKeyRebinding_UnregistersOldKeyAndRegistersNewKeyAtRuntime() + { + int actionCount = 0; + Action action = () => actionCount++; + + // Default binding: F5 + Key currentKey = Key.F5; + Input.OnKeyPressed(currentKey, action); + + // Trigger F5 + ReflectionHelper.InvokeTriggerKey(Key.F5); + Assert.Equal(1, actionCount); + + // Runtime rebind: user changes key from F5 to F6 + Input.RemoveKeyPressed(currentKey, action); + currentKey = Key.F6; + Input.OnKeyPressed(currentKey, action); + + // Pressing F5 now should do nothing + ReflectionHelper.InvokeTriggerKey(Key.F5); + Assert.Equal(1, actionCount); + + // Pressing F6 now triggers the action + ReflectionHelper.InvokeTriggerKey(Key.F6); + Assert.Equal(2, actionCount); + } + + [Fact] + public void Scenario5_ModUnloadCleanup_RemovingAllListenersLeavesCleanState() + { + bool callback1Fired = false; + bool callback2Fired = false; + + Action action1 = () => callback1Fired = true; + Action action2 = () => callback2Fired = true; + + Input.OnKeyPressed(Key.PageUp, action1); + Input.OnKeyPressed(Key.PageDown, action2); + + // Remove both + Input.RemoveKeyPressed(Key.PageUp, action1); + Input.RemoveKeyPressed(Key.PageDown, action2); + + // Trigger both + ReflectionHelper.InvokeTriggerKey(Key.PageUp); + ReflectionHelper.InvokeTriggerKey(Key.PageDown); + + Assert.False(callback1Fired); + Assert.False(callback2Fired); + } +} diff --git a/DeadshotModAPI.Tests/Tier5_AdversarialCoverageTests.cs b/DeadshotModAPI.Tests/Tier5_AdversarialCoverageTests.cs new file mode 100644 index 0000000..bb5bde2 --- /dev/null +++ b/DeadshotModAPI.Tests/Tier5_AdversarialCoverageTests.cs @@ -0,0 +1,585 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Serialization; +using BepInEx; +using BepInEx.Unity.IL2CPP; +using DeadshotModAPI.Tests.TestHelpers; +using HarmonyLib; +using Mono.Cecil; +using Xunit; + +namespace DeadshotModAPI.Tests; + +/// +/// Tier 5 Adversarial Coverage Hardening Suite. +/// White-box stress tests verifying all corner cases, exception catches, directory discovery, +/// multicast delegate mutations, and plugin contracts across DeadshotModAPI production code. +/// +public class Tier5_AdversarialCoverageTests : IDisposable +{ + private readonly string _tempDir; + private readonly ModLoader _loader; + + public Tier5_AdversarialCoverageTests() + { + TestLoggerMock.Initialize(); + TestLoggerMock.ResetLogs(); + ReflectionHelper.ResetInputState(); + ReflectionHelper.ResetModLoaderState(); + + _tempDir = Path.Combine(Path.GetTempPath(), "Deadshot_Tier5_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + _loader = (ModLoader)FormatterServices.GetUninitializedObject(typeof(ModLoader)); + } + + public void Dispose() + { + TestLoggerMock.OverrideBepInExRootPath = null; + TestLoggerMock.ResetLogs(); + ReflectionHelper.ResetInputState(); + ReflectionHelper.ResetModLoaderState(); + + try + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + catch { } + } + + #region 1. Plugin Entrypoint Contract Tests (Class1.cs) + + [Fact] + public void Plugin_BepInPluginAttribute_HasAccurateMetadata() + { + var asmDef = AssemblyDefinition.ReadAssembly(typeof(Plugin).Assembly.Location); + var pluginType = asmDef.MainModule.GetType("DeadshotModAPI.Plugin"); + + Assert.NotNull(pluginType); + var attr = pluginType.CustomAttributes.FirstOrDefault(a => a.AttributeType.Name == "BepInPlugin"); + + Assert.NotNull(attr); + Assert.True(attr.ConstructorArguments.Count >= 3); + Assert.Equal("com.subaka.deadshotmodapi", attr.ConstructorArguments[0].Value); + Assert.Equal("Deadshot Mod Api", attr.ConstructorArguments[1].Value); + Assert.Equal("0.0.1", attr.ConstructorArguments[2].Value); + } + + [Fact] + public void Plugin_InheritsFromBasePlugin_AndDeclaresPublicLoad() + { + var type = typeof(Plugin); + Assert.True(typeof(BasePlugin).IsAssignableFrom(type), "Plugin must inherit from BasePlugin."); + + var loadMethod = type.GetMethod("Load", BindingFlags.Public | BindingFlags.Instance); + Assert.NotNull(loadMethod); + Assert.Equal(typeof(void), loadMethod.ReturnType); + Assert.Empty(loadMethod.GetParameters()); + } + + [Fact] + public void Plugin_Load_EmitsStartupInfoLog() + { + var plugin = (Plugin)FormatterServices.GetUninitializedObject(typeof(Plugin)); + + // When Load() is invoked in headless mode, AddComponent() may fail due to uninitialized IL2CPP GameObject, + // but the startup log must have been emitted first. + try + { + plugin.Load(); + } + catch + { + // Expected in headless environment if Unity AddComponent fails + } + + Assert.Contains(TestLoggerMock.LoggedInfo, msg => msg.Contains("Deadshot Mod API loaded.")); + } + + #endregion + + #region 2. Logger Standard Log Alias & Edge Cases (Logging/logger.cs) + + [Fact] + public void Logger_Log_StandardAlias_RoutesToInfoAndCapturesMessage() + { + Logger.Log("Tier 5 adversarial logging message verification."); + + Assert.Contains(TestLoggerMock.LoggedInfo, msg => msg.Contains("Tier 5 adversarial logging message verification.")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("Special characters: {0} {foo} %d %s \0 \t \r\n")] + public void Logger_Log_HandlesNullEmptyAndSpecialStringsSafely(string message) + { + var ex = Record.Exception(() => Logger.Log(message!)); + Assert.Null(ex); + } + + #endregion + + #region 3. ModLoader Directory Discovery & Resilient Lifecycle (Runtime/ModLoader.cs) + + [Fact] + public void ModLoader_LoadMods_WhenBepInExRootPathThrows_LogsErrorAndReturnsSafely() + { + var harmony = new Harmony("DeadshotModAPI.Tests.ThrowBepPath"); + var getter = typeof(BepInEx.Paths).GetProperty("BepInExRootPath", BindingFlags.Public | BindingFlags.Static)?.GetGetMethod(); + Assert.NotNull(getter); + + var prefix = typeof(Tier5_AdversarialCoverageTests).GetMethod(nameof(PrefixThrowBepPath), BindingFlags.NonPublic | BindingFlags.Static); + harmony.Patch(getter, new HarmonyMethod(prefix) { priority = Priority.First }); + try + { + var ex = Record.Exception(() => ReflectionHelper.InvokeLoadMods(_loader)); + + Assert.Null(ex); + Assert.Contains(TestLoggerMock.LoggedErrors, err => + err.Contains("Failed to determine BepInEx root path", StringComparison.OrdinalIgnoreCase) && + err.Contains("Simulated failure accessing BepInExRootPath", StringComparison.OrdinalIgnoreCase)); + } + finally + { + harmony.Unpatch(getter, HarmonyPatchType.Prefix, "DeadshotModAPI.Tests.ThrowBepPath"); + } + } + + private static bool PrefixThrowBepPath() + { + throw new InvalidOperationException("Simulated failure accessing BepInExRootPath"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t\r\n")] + public void ModLoader_LoadMods_WhenBepInExRootPathEmptyOrWhitespace_LogsErrorAndReturnsSafely(string rootPath) + { + TestLoggerMock.OverrideBepInExRootPath = rootPath; + + var ex = Record.Exception(() => ReflectionHelper.InvokeLoadMods(_loader)); + + Assert.Null(ex); + Assert.Contains(TestLoggerMock.LoggedErrors, err => + err.Contains("BepInEx root path is null or empty. Cannot discover mods.", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void ModLoader_LoadMods_DirectoryDoesNotExist_CreatesModsFolderAndLogsInfo() + { + string fakeRoot = Path.Combine(_tempDir, "FakeBepInEx"); + Directory.CreateDirectory(fakeRoot); + string expectedModsDir = Path.Combine(fakeRoot, "mods"); + + Assert.False(Directory.Exists(expectedModsDir)); + TestLoggerMock.OverrideBepInExRootPath = fakeRoot; + + var ex = Record.Exception(() => ReflectionHelper.InvokeLoadMods(_loader)); + + Assert.Null(ex); + Assert.True(Directory.Exists(expectedModsDir), "ModLoader.LoadMods must create the 'mods' directory if it does not exist."); + Assert.Contains(TestLoggerMock.LoggedInfo, msg => + msg.Contains("Created mod directory", StringComparison.OrdinalIgnoreCase) && + msg.Contains(expectedModsDir, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void ModLoader_LoadMods_RecursiveNestedDiscovery_DiscoversAndLoadsAllMods() + { + string fakeRoot = Path.Combine(_tempDir, "RecursiveBepInEx"); + string modsDir = Path.Combine(fakeRoot, "mods"); + string nestedFolderA = Path.Combine(modsDir, "FolderA", "SubA"); + string nestedFolderB = Path.Combine(modsDir, "FolderB"); + + Directory.CreateDirectory(nestedFolderA); + Directory.CreateDirectory(nestedFolderB); + + string modSourceA = @" +using DeadshotModAPI; +public class RecursiveModA : IDeadshotMod +{ + public string Name => ""RecursiveModA""; + public string Description => ""Desc A""; + public string Creator => ""Dev A""; + public string Version => ""1.0.0""; + public static bool Loaded = false; + public void Load() { Loaded = true; } +}"; + string modSourceB = @" +using DeadshotModAPI; +public class RecursiveModB : IDeadshotMod +{ + public string Name => ""RecursiveModB""; + public string Description => ""Desc B""; + public string Creator => ""Dev B""; + public string Version => ""2.0.0""; + public static bool Loaded = false; + public void Load() { Loaded = true; } +}"; + + string dllPathA = Path.Combine(nestedFolderA, "ModA.dll"); + string dllPathB = Path.Combine(nestedFolderB, "ModB.dll"); + + TestAssemblyBuilder.CompileAssembly(modSourceA, dllPathA, "RecursiveModAAssembly"); + TestAssemblyBuilder.CompileAssembly(modSourceB, dllPathB, "RecursiveModBAssembly"); + + TestLoggerMock.OverrideBepInExRootPath = fakeRoot; + + // Act + ReflectionHelper.InvokeLoadMods(_loader); + + // Assert + Assert.Equal(2, ModLoader.LoadedMods.Count); + var modA = ModLoader.LoadedMods.Find(m => m.Name == "RecursiveModA"); + var modB = ModLoader.LoadedMods.Find(m => m.Name == "RecursiveModB"); + + Assert.NotNull(modA); + Assert.NotNull(modB); + + var flagA = modA.GetType().GetField("Loaded", BindingFlags.Public | BindingFlags.Static); + var flagB = modB.GetType().GetField("Loaded", BindingFlags.Public | BindingFlags.Static); + + Assert.True((bool)flagA!.GetValue(null)!); + Assert.True((bool)flagB!.GetValue(null)!); + } + + [Fact] + public void ModLoader_Start_CatchesAndLogsUnexpectedExceptionSafely() + { + // When Start is called, even if internal LoadMods fails with an exception, Start does not leak it + TestLoggerMock.OverrideBepInExRootPath = Path.Combine(_tempDir, "StartRoot"); + Directory.CreateDirectory(TestLoggerMock.OverrideBepInExRootPath); + + var ex = Record.Exception(() => _loader.Start()); + Assert.Null(ex); + Assert.Contains(TestLoggerMock.LoggedInfo, msg => msg.Contains("Deadshot Mod API ModLoader Start.")); + } + + [Fact] + public void ModLoader_Update_CatchesAndLogsUnexpectedExceptionSafely() + { + // Update calls Input.CheckKeys() inside defensive try-catch + var ex = Record.Exception(() => _loader.Update()); + Assert.Null(ex); + } + + #endregion + + #region 4. ModLoader Type Filtering & Reflection Resilience (Runtime/ModLoader.cs) + + [Fact] + public void ModLoader_LoadMod_InterfaceExtendingIDeadshotMod_IsIgnored() + { + string source = @" +using DeadshotModAPI; +public interface IExtendedModInterface : IDeadshotMod +{ + void ExtendedMethod(); +}"; + string dllPath = Path.Combine(_tempDir, "InterfaceMod.dll"); + TestAssemblyBuilder.CompileAssembly(source, dllPath, "InterfaceModAssembly"); + + var ex = Record.Exception(() => ReflectionHelper.InvokeLoadMod(_loader, dllPath)); + + Assert.Null(ex); + Assert.Empty(ModLoader.LoadedMods); + } + + [Fact] + public void ModLoader_LoadMod_ClassNotImplementingIDeadshotMod_IsIgnored() + { + string source = @" +public class PlainStandaloneClass +{ + public void SomeMethod() {} +}"; + string dllPath = Path.Combine(_tempDir, "PlainClass.dll"); + TestAssemblyBuilder.CompileAssembly(source, dllPath, "PlainClassAssembly"); + + var ex = Record.Exception(() => ReflectionHelper.InvokeLoadMod(_loader, dllPath)); + + Assert.Null(ex); + Assert.Empty(ModLoader.LoadedMods); + } + + [Fact] + public void ModLoader_LoadMod_MissingParameterlessConstructor_LogsErrorAndDoesNotCrash() + { + string source = @" +using DeadshotModAPI; +public class NoParameterlessCtorMod : IDeadshotMod +{ + public NoParameterlessCtorMod(string requiredArgument) + { + } + + public string Name => ""NoDefaultCtorMod""; + public string Description => ""Desc""; + public string Creator => ""Author""; + public string Version => ""1.0.0""; + public void Load() {} +}"; + string dllPath = Path.Combine(_tempDir, "NoDefaultCtorMod.dll"); + TestAssemblyBuilder.CompileAssembly(source, dllPath, "NoDefaultCtorModAssembly"); + + var ex = Record.Exception(() => ReflectionHelper.InvokeLoadMod(_loader, dllPath)); + + Assert.Null(ex); + Assert.Empty(ModLoader.LoadedMods); + Assert.Contains(TestLoggerMock.LoggedErrors, err => + err.Contains("Failed to instantiate or load mod", StringComparison.OrdinalIgnoreCase) && + err.Contains("NoParameterlessCtorMod", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void ModLoader_LoadMod_ReflectionTypeLoadException_ProcessesRemainingTypesAndLogsWarnings() + { + string source = @" +using DeadshotModAPI; +public class ResilientModAlpha : IDeadshotMod +{ + public string Name => ""ResilientModAlpha""; + public string Description => ""Desc Alpha""; + public string Creator => ""Dev Alpha""; + public string Version => ""1.0""; + public static bool Loaded = false; + public void Load() { Loaded = true; } +} + +public class ResilientModBeta : IDeadshotMod +{ + public string Name => ""ResilientModBeta""; + public string Description => ""Desc Beta""; + public string Creator => ""Dev Beta""; + public string Version => ""1.0""; + public static bool Loaded = false; + public void Load() { Loaded = true; } +}"; + string dllPath = Path.Combine(_tempDir, "RtleMod.dll"); + TestAssemblyBuilder.CompileAssembly(source, dllPath, "RtleModAssembly"); + + // Load the assembly to inspect its types + var asm = Assembly.LoadFrom(dllPath); + var types = asm.GetTypes(); + var alphaType = Array.Find(types, t => t.Name == "ResilientModAlpha")!; + var betaType = Array.Find(types, t => t.Name == "ResilientModBeta")!; + + // Simulate ReflectionTypeLoadException where ex.Types contains [alphaType, null, betaType] + // and ex.LoaderExceptions contains an exception + var harmony = new Harmony("DeadshotModAPI.Tests.RtleTest"); + var getTypesMethod = typeof(Assembly).GetMethod("GetTypes", Type.EmptyTypes); + + var prefixMethod = typeof(Tier5_AdversarialCoverageTests).GetMethod(nameof(PrefixThrowRtle), BindingFlags.NonPublic | BindingFlags.Static); + _targetRtleAssembly = asm; + _simulatedAlphaType = alphaType; + _simulatedBetaType = betaType; + + harmony.Patch(getTypesMethod, new HarmonyMethod(prefixMethod)); + try + { + var ex = Record.Exception(() => ReflectionHelper.InvokeLoadMod(_loader, dllPath)); + + Assert.Null(ex); + + // Verify ReflectionTypeLoadException was caught and logged as warning + Assert.Contains(TestLoggerMock.LoggedWarnings, w => + w.Contains("Type load warnings encountered while inspecting", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(TestLoggerMock.LoggedWarnings, w => + w.Contains("Loader exception: Simulated type load exception for adversarial test", StringComparison.OrdinalIgnoreCase)); + + // Verify both valid types survived despite the exception and null entries in ex.Types! + Assert.Equal(2, ModLoader.LoadedMods.Count); + var modAlpha = ModLoader.LoadedMods.Find(m => m.Name == "ResilientModAlpha"); + var modBeta = ModLoader.LoadedMods.Find(m => m.Name == "ResilientModBeta"); + + Assert.NotNull(modAlpha); + Assert.NotNull(modBeta); + + var flagAlpha = modAlpha.GetType().GetField("Loaded", BindingFlags.Public | BindingFlags.Static); + var flagBeta = modBeta.GetType().GetField("Loaded", BindingFlags.Public | BindingFlags.Static); + + Assert.True((bool)flagAlpha!.GetValue(null)!); + Assert.True((bool)flagBeta!.GetValue(null)!); + } + finally + { + harmony.Unpatch(getTypesMethod, HarmonyPatchType.Prefix, "DeadshotModAPI.Tests.RtleTest"); + _targetRtleAssembly = null; + _simulatedAlphaType = null; + _simulatedBetaType = null; + } + } + + private static Assembly _targetRtleAssembly; + private static Type _simulatedAlphaType; + private static Type _simulatedBetaType; + + private static bool PrefixThrowRtle(Assembly __instance, ref Type[] __result) + { + if (ReferenceEquals(__instance, _targetRtleAssembly)) + { + var types = new Type[] { _simulatedAlphaType, null!, _simulatedBetaType }; + var exceptions = new Exception[] { new TypeLoadException("Simulated type load exception for adversarial test.") }; + throw new ReflectionTypeLoadException(types, exceptions); + } + return true; + } + + #endregion + + #region 5. Input Multicast Removal & String Formatting Edge Cases (Input/InputManager.cs) + + [Fact] + public void Input_RemoveKeyPressed_UnregisteredDelegateOnKeyWithExistingListeners_PreservesExistingListeners() + { + bool listener1Fired = false; + Action action1 = () => listener1Fired = true; + Action unregisteredAction = () => { }; + + Input.OnKeyPressed(Key.F6, action1); + + // Remove an action that was NEVER registered on Key.F6 + Input.RemoveKeyPressed(Key.F6, unregisteredAction); + + // Trigger Key.F6 + ReflectionHelper.InvokeTriggerKey(Key.F6); + + Assert.True(listener1Fired, "Existing registered listener must remain functional after removing an unregistered delegate."); + } + + [Fact] + public void Input_RemoveKeyPressed_MultipleDelegates_RemovesOnlyTargetDelegate() + { + bool listener1Fired = false; + bool listener2Fired = false; + Action action1 = () => listener1Fired = true; + Action action2 = () => listener2Fired = true; + + Input.OnKeyPressed(Key.F7, action1); + Input.OnKeyPressed(Key.F7, action2); + + // Remove only action1 + Input.RemoveKeyPressed(Key.F7, action1); + + // Trigger Key.F7 + ReflectionHelper.InvokeTriggerKey(Key.F7); + + Assert.False(listener1Fired, "action1 should have been detached."); + Assert.True(listener2Fired, "action2 must still execute."); + } + + [Fact] + public void Input_RemoveKeyPressed_DoubleRemovalOfSameDelegate_IsIdempotent() + { + int invocationCount = 0; + Action action = () => invocationCount++; + + Input.OnKeyPressed(Key.F8, action); + + // Remove twice + Input.RemoveKeyPressed(Key.F8, action); + var ex = Record.Exception(() => Input.RemoveKeyPressed(Key.F8, action)); + + Assert.Null(ex); + + // Trigger Key.F8 + ReflectionHelper.InvokeTriggerKey(Key.F8); + + Assert.Equal(0, invocationCount); + } + + [Theory] + [InlineData(null, null)] + [InlineData("", "")] + [InlineData(" ", " ")] + [InlineData("\t\r\n", "\t\r\n")] + [InlineData("1Digit", "1Digit")] + [InlineData("!Bang", "!Bang")] + [InlineData("X", "x")] + [InlineData("AlphaBetaGamma", "alphaBetaGamma")] + public void Input_ToCamelCase_EdgeCases_TransformsSafelyWithoutIndexOutOfRange(string input, string expected) + { + string result = input.ToCamelCase(); + Assert.Equal(expected, result); + } + + [Fact] + public void Input_TriggerKey_MultipleThrowingListeners_IsolatesEachAndLogsAllErrors() + { + bool listener2Fired = false; + + Action action1 = () => throw new InvalidOperationException("Adversarial error from Action 1"); + Action action2 = () => listener2Fired = true; + Action action3 = () => throw new ArgumentException("Adversarial error from Action 3"); + + Input.OnKeyPressed(Key.F12, action1); + Input.OnKeyPressed(Key.F12, action2); + Input.OnKeyPressed(Key.F12, action3); + + ReflectionHelper.InvokeTriggerKey(Key.F12); + + Assert.True(listener2Fired, "Action 2 must execute even when Action 1 throws."); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Adversarial error from Action 1")); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Adversarial error from Action 3")); + } + + #endregion + + #region 6. SceneManager & GameManager Boundary Isolation (SceneManager & GameManager) + + [Theory] + [InlineData(" ")] + [InlineData("\t\r\n")] + public void SceneManager_LoadModsBundle_WhitespaceBepInExRoot_LogsErrorAndReturnsSafely(string whitespacePath) + { + TestLoggerMock.OverrideBepInExRootPath = whitespacePath; + + var ex = Record.Exception(() => ReflectionHelper.InvokeLoadModsBundle()); + + Assert.Null(ex); + Assert.Contains(TestLoggerMock.LoggedErrors, err => + err.Contains("BepInEx root path is null or empty", StringComparison.OrdinalIgnoreCase)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t\r\n")] + [InlineData("SceneWithSpaces 123")] + public void SceneManager_Load_StringVariations_ExecutesDefensively(string sceneName) + { + var ex = Record.Exception(() => SceneManager.Load(sceneName)); + Assert.Null(ex); + } + + [Theory] + [InlineData(int.MinValue)] + [InlineData(-1)] + [InlineData(0)] + [InlineData(int.MaxValue)] + public void SceneManager_Load_IntExtremeBoundaries_ExecutesDefensively(int sceneIndex) + { + var ex = Record.Exception(() => SceneManager.Load(sceneIndex)); + Assert.Null(ex); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void GameManager_RestartLevel_CutsceneFlagVariations_DefensivelyHandlesUninitializedRuntime(bool playCutscene) + { + for (int i = 0; i < 3; i++) + { + var ex = Record.Exception(() => GameManager.RestartLevel(playCutscene)); + Assert.Null(ex); + } + } + + #endregion +} diff --git a/DeadshotModAPI.Tests/Tier5_AdversarialStressAndReentrancyTests.cs b/DeadshotModAPI.Tests/Tier5_AdversarialStressAndReentrancyTests.cs new file mode 100644 index 0000000..393741a --- /dev/null +++ b/DeadshotModAPI.Tests/Tier5_AdversarialStressAndReentrancyTests.cs @@ -0,0 +1,579 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.Serialization; +using System.Threading.Tasks; +using DeadshotModAPI.Tests.TestHelpers; +using Xunit; + +namespace DeadshotModAPI.Tests; + +/// +/// Tier 5 Adversarial Stress & Reentrancy Test Suite. +/// Authored by Challenger 2 for the Final Milestone (Adversarial Coverage Hardening). +/// Focus areas: Integration stress, concurrency, reentrancy cascades, memory cleanup (Unload/RemoveKeyPressed), +/// and corrupt filesystem states. +/// +public class Tier5_AdversarialStressAndReentrancyTests : IDisposable +{ + private readonly string _tempDir; + private readonly ModLoader _loader; + + public Tier5_AdversarialStressAndReentrancyTests() + { + TestLoggerMock.Initialize(); + TestLoggerMock.ResetLogs(); + ReflectionHelper.ResetInputState(); + ReflectionHelper.ResetModLoaderState(); + + _tempDir = Path.Combine(Path.GetTempPath(), "Deadshot_Tier5_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + _loader = (ModLoader)FormatterServices.GetUninitializedObject(typeof(ModLoader)); + } + + public void Dispose() + { + ReflectionHelper.ResetInputState(); + ReflectionHelper.ResetModLoaderState(); + TestLoggerMock.ResetLogs(); + TestLoggerMock.OverrideBepInExRootPath = null; + + try + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + catch { } + } + + private static Dictionary GetInternalKeyActions() + { + var field = typeof(Input).GetField("_keyActions", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(field); + return (Dictionary)field!.GetValue(null)!; + } + + #region Suite 1: ExampleMod Integration & Lifecycle Memory Cleanup + + [Fact] + public void ExampleMod_FullLifecycle_Load_TriggerHotkeys_Unload_LeavesZeroLingeringCallbacks() + { + // 1. Locate and compile or load ExampleMod + string exampleSource = @" +using System; +using DeadshotModAPI; + +namespace ExampleMod; + +public class ExampleMod : IDeadshotMod +{ + private bool _isCustomStateActive; + public string Name => ""ExampleMod""; + public string Description => ""Reference demonstration mod for DeadshotModAPI""; + public string Creator => ""DemonZ-Development""; + public string Version => ""1.0.0""; + public bool IsCustomStateActive => _isCustomStateActive; + + public void Load() + { + Logger.Info($""[ExampleMod] Loading {Name} v{Version} by {Creator}...""); + Input.OnKeyPressed(Key.F5, OnToggleCustomState); + Input.OnKeyPressed(Key.F9, OnLoadSampleScene); + Input.OnKeyPressed(Key.F10, OnRestartCurrentLevel); + Logger.Info(""[ExampleMod] Loaded successfully.""); + } + + public void Unload() + { + Input.RemoveKeyPressed(Key.F5, OnToggleCustomState); + Input.RemoveKeyPressed(Key.F9, OnLoadSampleScene); + Input.RemoveKeyPressed(Key.F10, OnRestartCurrentLevel); + Logger.Info(""[ExampleMod] Unloaded successfully.""); + } + + private void OnToggleCustomState() + { + _isCustomStateActive = !_isCustomStateActive; + Logger.Info($""[ExampleMod] Custom mod state toggled: {_isCustomStateActive}""); + } + + private void OnLoadSampleScene() + { + SceneManager.Load(""SampleScene""); + } + + private void OnRestartCurrentLevel() + { + GameManager.RestartLevel(false); + } +} +"; + string dllPath = Path.Combine(_tempDir, "ExampleMod_Dynamic.dll"); + TestAssemblyBuilder.CompileAssembly(exampleSource, dllPath, "ExampleModAssembly"); + + // 2. Load mod via ModLoader + ReflectionHelper.InvokeLoadMod(_loader, dllPath); + + Assert.Single(ModLoader.LoadedMods); + var modInstance = ModLoader.LoadedMods[0]; + Assert.Equal("ExampleMod", modInstance.Name); + Assert.Equal("1.0.0", modInstance.Version); + Assert.Equal("DemonZ-Development", modInstance.Creator); + + var modType = modInstance.GetType(); + var stateProp = modType.GetProperty("IsCustomStateActive")!; + Assert.False((bool)stateProp.GetValue(modInstance)!); + + var keyActions = GetInternalKeyActions(); + Assert.True(keyActions.ContainsKey(Key.F5)); + Assert.True(keyActions.ContainsKey(Key.F9)); + Assert.True(keyActions.ContainsKey(Key.F10)); + + // 3. Trigger F5 -> Toggle state to true + ReflectionHelper.InvokeTriggerKey(Key.F5); + Assert.True((bool)stateProp.GetValue(modInstance)!); + + // Trigger F5 again -> Toggle state back to false + ReflectionHelper.InvokeTriggerKey(Key.F5); + Assert.False((bool)stateProp.GetValue(modInstance)!); + + // Trigger F9 and F10 -> Handled safely by SceneManager and GameManager + ReflectionHelper.InvokeTriggerKey(Key.F9); + ReflectionHelper.InvokeTriggerKey(Key.F10); + + // 4. Invoke Unload() on ExampleMod + var unloadMethod = modType.GetMethod("Unload")!; + Assert.NotNull(unloadMethod); + unloadMethod.Invoke(modInstance, null); + + // 5. Verify all listeners have been purged from _keyActions + Assert.False(keyActions.ContainsKey(Key.F5), "Key.F5 action should be completely removed from dictionary."); + Assert.False(keyActions.ContainsKey(Key.F9), "Key.F9 action should be completely removed from dictionary."); + Assert.False(keyActions.ContainsKey(Key.F10), "Key.F10 action should be completely removed from dictionary."); + + // 6. Trigger keys again to confirm mod no longer responds + ReflectionHelper.InvokeTriggerKey(Key.F5); + Assert.False((bool)stateProp.GetValue(modInstance)!, "State must not change after Unload()."); + + // 7. Verify calling Unload() a second time is idempotent and safe + var doubleUnloadEx = Record.Exception(() => unloadMethod.Invoke(modInstance, null)); + Assert.Null(doubleUnloadEx); + } + + [Fact] + public void ExampleMod_RapidConsecutiveLoadUnloadCycles_MaintainsDictionaryPurity() + { + string exampleSource = @" +using DeadshotModAPI; +public class CycleMod : IDeadshotMod +{ + public string Name => ""CycleMod""; + public string Description => ""Cycles""; + public string Creator => ""Author""; + public string Version => ""1.0""; + public void Load() + { + Input.OnKeyPressed(Key.F3, Handler); + Input.OnKeyPressed(Key.F4, Handler); + } + public void Unload() + { + Input.RemoveKeyPressed(Key.F3, Handler); + Input.RemoveKeyPressed(Key.F4, Handler); + } + private void Handler() { } +} +"; + string dllPath = Path.Combine(_tempDir, "CycleMod.dll"); + TestAssemblyBuilder.CompileAssembly(exampleSource, dllPath, "CycleModAssembly"); + + var assembly = Assembly.LoadFrom(dllPath); + var modType = assembly.GetType("CycleMod")!; + var mod = (IDeadshotMod)Activator.CreateInstance(modType)!; + var unloadMethod = modType.GetMethod("Unload")!; + + var keyActions = GetInternalKeyActions(); + + for (int i = 0; i < 50; i++) + { + mod.Load(); + Assert.True(keyActions.ContainsKey(Key.F3)); + Assert.True(keyActions.ContainsKey(Key.F4)); + + unloadMethod.Invoke(mod, null); + Assert.False(keyActions.ContainsKey(Key.F3)); + Assert.False(keyActions.ContainsKey(Key.F4)); + } + + Assert.Empty(keyActions); + } + + [Fact] + public void MemoryCleanup_DeregisteredCallback_ReleasesModTargetForGarbageCollection() + { + WeakReference weakRef = RegisterAndDeregisterInstanceCallback(); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + Assert.False(weakRef.IsAlive, "Instance should have been garbage collected after callback deregistration."); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference RegisterAndDeregisterInstanceCallback() + { + var target = new ModInstanceTarget(); + Input.OnKeyPressed(Key.F1, target.Handler); + var weakRef = new WeakReference(target); + Input.RemoveKeyPressed(Key.F1, target.Handler); + return weakRef; + } + + private class ModInstanceTarget + { + public int Count { get; set; } + public void Handler() => Count++; + } + + [Fact] + public void Input_RemoveKeyPressed_EdgeCases_DoNotThrowOrCorruptState() + { + // 1. Remove when dictionary is empty + var ex1 = Record.Exception(() => Input.RemoveKeyPressed(Key.F12, () => { })); + Assert.Null(ex1); + + // 2. Remove null delegate + var ex2 = Record.Exception(() => Input.RemoveKeyPressed(Key.F12, null!)); + Assert.Null(ex2); + + // 3. Remove non-registered delegate from an existing key + Input.OnKeyPressed(Key.F12, () => { }); + var ex3 = Record.Exception(() => Input.RemoveKeyPressed(Key.F12, () => { })); + Assert.Null(ex3); + + var keyActions = GetInternalKeyActions(); + Assert.True(keyActions.ContainsKey(Key.F12)); + } + + #endregion + + #region Suite 2: Reentrancy & Nested Invocation Cascades + + [Fact] + public void Reentrancy_DeepCascadingKeyInvocations_ExecutesInStrictHierarchicalOrder() + { + var executionLog = new List(); + + Input.OnKeyPressed(Key.A, () => + { + executionLog.Add("Start A"); + ReflectionHelper.InvokeTriggerKey(Key.B); + executionLog.Add("End A"); + }); + + Input.OnKeyPressed(Key.B, () => + { + executionLog.Add("Start B"); + ReflectionHelper.InvokeTriggerKey(Key.C); + executionLog.Add("End B"); + }); + + Input.OnKeyPressed(Key.C, () => + { + executionLog.Add("Start C"); + ReflectionHelper.InvokeTriggerKey(Key.D); + executionLog.Add("End C"); + }); + + Input.OnKeyPressed(Key.D, () => + { + executionLog.Add("Executing D"); + }); + + var ex = Record.Exception(() => ReflectionHelper.InvokeTriggerKey(Key.A)); + + Assert.Null(ex); + Assert.Equal(new[] + { + "Start A", + "Start B", + "Start C", + "Executing D", + "End C", + "End B", + "End A" + }, executionLog); + } + + [Fact] + public void Reentrancy_CallbackRemovesSelfAndAddsReplacementKey_ExecutesCorrectly() + { + int invocationCount = 0; + bool replacementExecuted = false; + Action callback = null!; + + callback = () => + { + invocationCount++; + Input.RemoveKeyPressed(Key.Q, callback); + Input.OnKeyPressed(Key.W, () => replacementExecuted = true); + }; + + Input.OnKeyPressed(Key.Q, callback); + + // Trigger Q -> Unregisters Q, registers W + ReflectionHelper.InvokeTriggerKey(Key.Q); + Assert.Equal(1, invocationCount); + Assert.False(replacementExecuted); + + // Trigger Q again -> Should not run + ReflectionHelper.InvokeTriggerKey(Key.Q); + Assert.Equal(1, invocationCount); + + // Trigger W -> Should run replacement + ReflectionHelper.InvokeTriggerKey(Key.W); + Assert.True(replacementExecuted); + } + + [Fact] + public void Reentrancy_NestedExceptionInCascade_DoesNotAbortOuterExecution() + { + bool outerStep1 = false; + bool outerStep2 = false; + + Input.OnKeyPressed(Key.F6, () => + { + outerStep1 = true; + // Reentrantly trigger F7 which throws + ReflectionHelper.InvokeTriggerKey(Key.F7); + outerStep2 = true; + }); + + Input.OnKeyPressed(Key.F7, () => + { + throw new InvalidOperationException("Cascaded child explosion"); + }); + + var ex = Record.Exception(() => ReflectionHelper.InvokeTriggerKey(Key.F6)); + + Assert.Null(ex); + Assert.True(outerStep1); + Assert.True(outerStep2, "Outer execution must resume and complete despite child cascade exception."); + Assert.Contains(TestLoggerMock.LoggedErrors, err => err.Contains("Cascaded child explosion")); + } + + #endregion + + #region Suite 3: Concurrency & Multi-Threaded Stress + + [Fact] + public async Task Concurrency_ParallelSceneManagerLoad_ExecutesSafelyWithoutDeadlock() + { + int taskCount = 40; + var tasks = new Task[taskCount]; + + for (int i = 0; i < taskCount; i++) + { + int index = i; + tasks[i] = Task.Run(() => + { + if (index % 2 == 0) + SceneManager.Load($"Scene_Stress_{index}"); + else + SceneManager.Load(index); + }); + } + + await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task Concurrency_ParallelGameManagerRestartLevel_ExecutesSafelyWithoutDeadlock() + { + int taskCount = 30; + var tasks = new Task[taskCount]; + + for (int i = 0; i < taskCount; i++) + { + int index = i; + tasks[i] = Task.Run(() => + { + GameManager.RestartLevel(index % 2 == 0); + }); + } + + await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task Concurrency_ParallelLogger_DispatchesSafelyAcrossAllLevels() + { + int taskCount = 40; + var tasks = new Task[taskCount]; + + for (int i = 0; i < taskCount; i++) + { + int index = i; + tasks[i] = Task.Run(() => + { + Logger.Info($"Parallel Info {index}"); + Logger.Warning($"Parallel Warning {index}"); + Logger.Error($"Parallel Error {index}"); + Logger.Debug($"Parallel Debug {index}"); + Logger.Log($"Parallel Log {index}"); + }); + } + + await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(5)); + Assert.NotEmpty(TestLoggerMock.LoggedInfo); + Assert.NotEmpty(TestLoggerMock.LoggedWarnings); + Assert.NotEmpty(TestLoggerMock.LoggedErrors); + } + + #endregion + + #region Suite 4: Rapid Repeated Invocations (Burst / Stress) + + [Fact] + public void Stress_RapidTriggerKeyBurst_10000Invocations_ExecutesReliably() + { + int totalExecutions = 0; + Input.OnKeyPressed(Key.T, () => totalExecutions++); + + for (int i = 0; i < 10000; i++) + { + ReflectionHelper.InvokeTriggerKey(Key.T); + } + + Assert.Equal(10000, totalExecutions); + } + + [Fact] + public void Stress_RapidSceneManagerLoadBurst_1000Invocations_NoMemoryLeakOrCrash() + { + for (int i = 0; i < 1000; i++) + { + if (i % 3 == 0) + SceneManager.Load((string)null!); + else if (i % 3 == 1) + SceneManager.Load(-i); + else + SceneManager.Load(i); + } + } + + [Fact] + public void Stress_RapidGameManagerRestartLevelBurst_500Invocations_NoCrash() + { + for (int i = 0; i < 500; i++) + { + GameManager.RestartLevel(i % 2 == 0); + } + } + + #endregion + + #region Suite 5: Corrupt Filesystem States & Extreme ModLoader Stress + + [Fact] + public void CorruptFilesystem_ModsFolderIsAFile_HandledDefensivelyWithoutCrash() + { + // Setup mock BepInEx root where "mods" is a regular file instead of a directory + string fakeRoot = Path.Combine(_tempDir, "FakeBepInEx"); + Directory.CreateDirectory(fakeRoot); + string modsFile = Path.Combine(fakeRoot, "mods"); + File.WriteAllText(modsFile, "This is a file blocking directory creation."); + + TestLoggerMock.OverrideBepInExRootPath = fakeRoot; + + var ex = Record.Exception(() => ReflectionHelper.InvokeLoadMods(_loader)); + + Assert.Null(ex); + Assert.Contains(TestLoggerMock.LoggedErrors, err => + err.Contains("Failed to verify or create mod directory") || + err.Contains("Failed to enumerate mod files")); + } + + [Fact] + public void CorruptFilesystem_ExclusivelyLockedModDll_HandledDefensivelyWithoutCrash() + { + string lockedPath = Path.Combine(_tempDir, "LockedMod.dll"); + File.WriteAllBytes(lockedPath, new byte[] { 0x4D, 0x5A, 0x90, 0x00 }); // MZ header + + // Open with FileShare.None to simulate another process locking the file exclusively + using var stream = new FileStream(lockedPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + + var ex = Record.Exception(() => ReflectionHelper.InvokeLoadMod(_loader, lockedPath)); + + Assert.Null(ex); + Assert.Contains(TestLoggerMock.LoggedErrors, err => + err.Contains("Failed to load assembly 'LockedMod.dll'")); + } + + [Fact] + public void CorruptFilesystem_DirectoryWithDllExtension_HandledDefensivelyWithoutCrash() + { + string fakeDllDir = Path.Combine(_tempDir, "FakeFolder.dll"); + Directory.CreateDirectory(fakeDllDir); + + var ex = Record.Exception(() => ReflectionHelper.InvokeLoadMod(_loader, fakeDllDir)); + + Assert.Null(ex); + Assert.Contains(TestLoggerMock.LoggedErrors, err => + err.Contains("Mod file does not exist")); + } + + [Fact] + public void CorruptFilesystem_AssemblyWithGenericIDeadshotMod_CaughtAndLoggedDefensively() + { + string genericSource = @" +using DeadshotModAPI; +public class GenericMod : IDeadshotMod +{ + public string Name => ""GenericMod""; + public string Description => ""Generic""; + public string Creator => ""Author""; + public string Version => ""1.0""; + public void Load() { } +} +"; + string dllPath = Path.Combine(_tempDir, "GenericModAssembly.dll"); + TestAssemblyBuilder.CompileAssembly(genericSource, dllPath, "GenericModAssembly"); + + var ex = Record.Exception(() => ReflectionHelper.InvokeLoadMod(_loader, dllPath)); + + Assert.Null(ex); + Assert.Empty(ModLoader.LoadedMods); + Assert.Contains(TestLoggerMock.LoggedErrors, err => + err.Contains("Failed to instantiate or load mod 'GenericMod`1'")); + } + + [Fact] + public void CorruptFilesystem_AssemblyWithOnlyStaticClasses_DiscoversZeroModsWithoutError() + { + string staticSource = @" +public static class StaticUtilityClass +{ + public static int Add(int a, int b) => a + b; +} +public struct PointStruct +{ + public int X; + public int Y; +} +"; + string dllPath = Path.Combine(_tempDir, "StaticAssembly.dll"); + TestAssemblyBuilder.CompileAssembly(staticSource, dllPath, "StaticAssembly"); + + var ex = Record.Exception(() => ReflectionHelper.InvokeLoadMod(_loader, dllPath)); + + Assert.Null(ex); + Assert.Empty(ModLoader.LoadedMods); + Assert.Empty(TestLoggerMock.LoggedErrors); + } + + #endregion +} diff --git a/DeadshotModAPI.csproj b/DeadshotModAPI.csproj index c35a369..84083d7 100644 --- a/DeadshotModAPI.csproj +++ b/DeadshotModAPI.csproj @@ -1,73 +1,74 @@ -๏ปฟ + net6.0 disable disable + $(DefaultItemExcludes);DeadshotModAPI.Tests/**;examples/** - + - C:\Code\DeadshotMods\DeadshotModAPI\lib\BepInEx.Core.dll + lib\BepInEx.Core.dll - C:\Code\DeadshotMods\DeadshotModAPI\lib\BepInEx.Unity.IL2CPP.dll + lib\BepInEx.Unity.IL2CPP.dll - C:\Code\DeadshotMods\DeadshotModAPI\lib\Il2CppInterop.Runtime.dll + lib\Il2CppInterop.Runtime.dll - C:\Code\DeadshotMods\DeadshotModAPI\lib\Il2Cppmscorlib.dll + lib\Il2Cppmscorlib.dll - C:\Code\DeadshotMods\DeadshotModAPI\lib\Assembly-CSharp.dll + lib\Assembly-CSharp.dll - C:\Code\DeadshotMods\DeadshotModAPI\lib\UnityEngine.CoreModule.dll + lib\UnityEngine.CoreModule.dll - C:\Code\DeadshotMods\DeadshotModAPI\lib\Unity.InputSystem.dll + lib\Unity.InputSystem.dll - C:\Code\DeadshotMods\DeadshotModAPI\lib\Unity.TextMeshPro.dll + lib\Unity.TextMeshPro.dll - C:\Code\DeadshotMods\DeadshotModAPI\lib\UnityEngine.UI.dll + lib\UnityEngine.UI.dll - C:\Code\DeadshotMods\DeadshotModAPI\lib\UnityEngine.AudioModule.dll + lib\UnityEngine.AudioModule.dll - C:\Code\DeadshotMods\DeadshotModAPI\lib\UnityEngine.DirectorModule.dll + lib\UnityEngine.DirectorModule.dll - C:\Code\DeadshotMods\DeadshotModAPI\lib\Deadshot.Data.dll + lib\Deadshot.Data.dll - C:\Code\DeadshotMods\DeadshotModAPI\lib\UnityEngine.AssetBundleModule.dll + lib\UnityEngine.AssetBundleModule.dll - C:\Code\DeadshotMods\DeadshotModAPI\lib\Il2CppSystem.dll + lib\Il2CppSystem.dll - - - + + + diff --git a/Events/EventManager.cs b/Events/EventManager.cs index cba884d..e11bcb5 100644 --- a/Events/EventManager.cs +++ b/Events/EventManager.cs @@ -2,14 +2,12 @@ using DeadshotGameManager = Deadshot.GameManager; using Deadshot.UI.Menus; using HarmonyLib; +using UnityEngine; namespace DeadshotModAPI; -public static class EventManager +public class EventManager : MonoBehaviour { - /// - /// Updates all events that require per-frame processing. - /// public static void Update() { LevelEvent.Update(); @@ -19,41 +17,41 @@ public static class LevelEvent { private static float _lastLevelTime; - /// - /// Occurs whenever the current level playtime changes. - /// public static event Action LevelTimeChanged; - /// - /// Occurs when the level completion menu is enabled. - /// public static event Action LevelCompleted; - /// - /// Updates level-related events that require per-frame processing. - /// internal static void Update() { LevelPlaytimeEvent(); } - /// - /// Checks the current level playtime and invokes - /// when the playtime has changed. - /// internal static void LevelPlaytimeEvent() { - if (DeadshotGameManager.INSTANCE == null) - return; + try + { + + if (DeadshotGameManager.INSTANCE == null) + { + Logger.Error("Cannot restart level: DeadshotGameManager.INSTANCE is null."); + return; + } - float levelTime = DeadshotGameManager.INSTANCE.CompletionTime; - if (levelTime == _lastLevelTime) - return; + float levelTime = DeadshotGameManager.INSTANCE.CompletionTime; + if (levelTime == _lastLevelTime) + { + return; + } - _lastLevelTime = levelTime; + _lastLevelTime = levelTime; - LevelTimeChanged?.Invoke(levelTime); + LevelTimeChanged?.Invoke(levelTime); + } + catch(Exception ex) + { + Logger.Error($"Error in LevelPlaytimeEvent(): {ex}"); + } } /// @@ -62,35 +60,58 @@ internal static void LevelPlaytimeEvent() /// The playtime as a float. public static float GetLevelPlaytime() { - return DeadshotGameManager.INSTANCE.CompletionTime; + try + { + if (DeadshotGameManager.INSTANCE == null) + { + Logger.Error($"DeadshotGameManager.INSTANCE is null."); + } + + return DeadshotGameManager.INSTANCE.CompletionTime; + } + catch(Exception ex) + { + Logger.Error($"Error in GetLevelPlaytime(): {ex}"); + return 0; + } } - /// - /// Invokes when the game's - /// level completion menu is enabled. - /// internal static void LevelCompletedEvent(LevelCompleteScreen menu) { - LevelCompleted?.Invoke(menu); + try + { + LevelCompleted?.Invoke(menu); + } + catch(Exception ex) + { + Logger.Error($"Error in LevelCompleted?.Invoke(): {ex}"); + } } } - /// - /// Harmony patches used by the event manager. - /// [HarmonyPatch(typeof(LevelCompleteMenu))] private static class LevelCompleteMenuPatch { - /// - /// Detects when the level completion menu is enabled. - /// [HarmonyPostfix] [HarmonyPatch(nameof(LevelCompleteMenu.OnEnable))] private static void OnEnable(LevelCompleteMenu __instance) { - var screen = new LevelCompleteScreen(__instance); + try + { + if (__instance == null) + { + Logger.Error($"LevelCompleteMenu instance is null."); + return; + } + + var screen = new LevelCompleteScreen(__instance); - LevelEvent.LevelCompletedEvent(screen); + LevelEvent.LevelCompletedEvent(screen); + } + catch(Exception ex) + { + Logger.Error($"Error in LevelCompleteMenu.OnEnable postfix: {ex}"); + } } } } \ No newline at end of file diff --git a/Events/LevelCompleteScreen.cs b/Events/LevelCompleteScreen.cs index 020ed2e..6919388 100644 --- a/Events/LevelCompleteScreen.cs +++ b/Events/LevelCompleteScreen.cs @@ -10,7 +10,7 @@ internal LevelCompleteScreen( public void SetTime(string text) { - _menu.timeText.text = $"Time: {text}"; + _menu.timeText.text = $"{text}"; } // TODO: Add the rest diff --git a/GameManager/GameManager.cs b/GameManager/GameManager.cs index 5d26702..8e92e98 100644 --- a/GameManager/GameManager.cs +++ b/GameManager/GameManager.cs @@ -1,57 +1,116 @@ +using System; +using System.IO; +using UnityEngine; using UnitySceneManager = UnityEngine.SceneManagement; using Deadshot.Data; using DeadshotGameManager = Deadshot.GameManager; -using System.IO; namespace DeadshotModAPI; +/// +/// Helpers for interacting with Deadshot's game state and level lifecycle. +/// public class GameManager { private const string gameManagerKey = "7069791d-77d0-45d8-8895-91b9fae46892"; private const string gameManagerId = "Deadshot.GameManager"; /// - /// Restarts the currently active level. + /// Restarts the active level. /// - /// - /// Determines whether the level's introductory cutscene should play after restarting. - /// - /// - /// The level will not be restarted if the current scene is the main menu - /// or loading scene. - /// + /// Whether to replay the opening cutscene upon restarting. public static void RestartLevel(bool playCutscene = true) { - var scene = UnitySceneManager.SceneManager.GetActiveScene(); - - if (scene.name == "MenuScene" || scene.name == "LoadingScene") + try { - return; - } + var scene = UnitySceneManager.SceneManager.GetActiveScene(); + if (string.IsNullOrEmpty(scene.name) || scene.name == "MenuScene" || scene.name == "LoadingScene") + { + return; + } + + var file = DataManager.BACKUP_GAME_FILE_NAME; + if (string.IsNullOrEmpty(file)) + { + Logger.Error("Backup game file name is null or empty."); + return; + } + + var playerData = DataManager.LoadFile(file); + if (playerData == null) + { + Logger.Error($"Failed to load player data from '{file}'."); + return; + } + + if (!playerData.ContainsKey(gameManagerKey)) + { + Logger.Error($"Player data does not contain key '{gameManagerKey}'."); + return; + } - var file = DataManager.BACKUP_GAME_FILE_NAME; - var playerData = DataManager.LoadFile(file); + var rawInner = playerData[gameManagerKey]; + if (rawInner == null) + { + Logger.Error($"Game manager dictionary is null for key '{gameManagerKey}'."); + return; + } - var innerDict = - playerData[gameManagerKey] - .Cast>(); + var innerDict = rawInner.Cast>(); + if (innerDict == null) + { + Logger.Error("Failed to cast game manager data to dictionary."); + return; + } - DeadshotGameManager.GameData data = - innerDict[gameManagerId].Cast(); + if (!innerDict.ContainsKey(gameManagerId)) + { + Logger.Error($"Inner dictionary does not contain key '{gameManagerId}'."); + return; + } - data.isBeginingLevel = playCutscene; - innerDict[gameManagerId] = data; + var rawData = innerDict[gameManagerId]; + if (rawData == null) + { + Logger.Error($"Game data is null for id '{gameManagerId}'."); + return; + } - playerData[gameManagerKey] = innerDict; + DeadshotGameManager.GameData data = rawData.Cast(); + if (data == null) + { + Logger.Error("Failed to cast game data to DeadshotGameManager.GameData."); + return; + } - DataManager.SaveFile( - playerData, - Path.Combine( + data.isBeginingLevel = playCutscene; + innerDict[gameManagerId] = data; + + playerData[gameManagerKey] = innerDict; + + string savePath = Path.Combine( DataManager.FILE_PATH, DataManager.BACKUP_GAME_FILE_NAME - ) - ); + ); + DataManager.SaveFile(playerData, savePath); + + if (DeadshotGameManager.INSTANCE == null) + { + Logger.Error("Cannot restart level: DeadshotGameManager.INSTANCE is null."); + return; + } + + if (DeadshotGameManager.INSTANCE.pauseMenu == null) + { + Logger.Error("Cannot restart level: DeadshotGameManager.INSTANCE.pauseMenu is null."); + return; + } - DeadshotGameManager.INSTANCE.pauseMenu.RestartLevel(); + DeadshotGameManager.INSTANCE.pauseMenu.RestartLevel(); + } + catch (Exception ex) + { + Logger.Error($"Failed to restart level: {ex}"); + } } } \ No newline at end of file diff --git a/Input/InputManager.cs b/Input/InputManager.cs index 0a62b45..dc2cd98 100644 --- a/Input/InputManager.cs +++ b/Input/InputManager.cs @@ -1,23 +1,28 @@ -using System.Collections.Generic; using System; +using System.Collections.Generic; +using System.Linq; using UnityEngine.InputSystem; using UnityEngine.InputSystem.Controls; -using System.Globalization; namespace DeadshotModAPI; +/// +/// Provides keyboard input listening and event dispatch for Deadshot mods. +/// public static class Input { private static readonly Dictionary _keyActions = new(); /// - /// Registers a method to be called when the specified key is pressed. - /// Multiple methods can be registered to the same key. + /// Registers a callback to be invoked when the specified key is pressed. /// /// The key to listen for. - /// The method to invoke when the key is pressed. + /// The callback to invoke when the key is pressed. public static void OnKeyPressed(Key key, Action method) { + if (method == null) + return; + if (_keyActions.TryGetValue(key, out var existing)) _keyActions[key] = existing + method; else @@ -25,27 +30,57 @@ public static void OnKeyPressed(Key key, Action method) } /// - /// Invokes all methods registered to the specified key. - /// This method is called internally by the input system when a key press is detected. + /// Unregisters a previously registered callback from a key. /// - /// The key whose registered methods should be invoked. + /// The key being monitored. + /// The callback to remove. + public static void RemoveKeyPressed(Key key, Action method) + { + if (method == null) + return; + + if (_keyActions.TryGetValue(key, out var existing)) + { + var updated = existing - method; + if (updated == null) + _keyActions.Remove(key); + else + _keyActions[key] = updated; + } + } + internal static void TriggerKey(Key key) { - if (_keyActions.TryGetValue(key, out var action)) + if (_keyActions.TryGetValue(key, out var action) && action != null) { - action?.Invoke(); + Delegate[] delegates; + try + { + delegates = action.GetInvocationList(); + } + catch (Exception ex) + { + Logger.Error($"Error getting invocation list for key {key}: {ex}"); + return; + } + + foreach (var handler in delegates) + { + if (handler is Action callback) + { + try + { + callback(); + } + catch (Exception ex) + { + Logger.Error($"Error executing callback for key {key}: {ex}"); + } + } + } } } - /// - /// Converts the first character of a string to lowercase. - /// This is used to convert PascalCase names into camelCase names. - /// - /// The string to convert. - /// - /// The string with its first character converted to lowercase, - /// or the original string if it is null, empty, or whitespace. - /// public static string ToCamelCase(this string text) { if (string.IsNullOrWhiteSpace(text)) @@ -54,38 +89,47 @@ public static string ToCamelCase(this string text) return char.ToLowerInvariant(text[0]) + text.Substring(1); } - /// - /// Determines whether the specified key was pressed during the current frame. - /// - /// The key to check. - /// - /// true if the key was pressed during the current frame; - /// otherwise, false. - /// private static bool IsPressed(Key key) { - string keyName = $"{key.ToString().ToCamelCase()}Key"; - - var property = typeof(Keyboard).GetProperty(keyName); - - if (property == null) + if (Keyboard.current == null) return false; - var keyControl = property.GetValue(Keyboard.current) as KeyControl; + try + { + string keyName = $"{key.ToString().ToCamelCase()}Key"; + var property = typeof(Keyboard).GetProperty(keyName); - return keyControl?.wasPressedThisFrame ?? false; + if (property == null) + return false; + + var keyControl = property.GetValue(Keyboard.current) as KeyControl; + return keyControl?.wasPressedThisFrame ?? false; + } + catch (Exception ex) + { + Logger.Error($"Error checking key state for {key}: {ex}"); + return false; + } } - /// - /// Checks all keys currently registered with the input system - /// and triggers their associated methods when pressed. - /// internal static void CheckKeys() { - foreach (var key in _keyActions.Keys) + try + { + if (Keyboard.current == null) + return; + + // Snapshot keys to allow modifying bindings during callback execution + var keys = _keyActions.Keys.ToArray(); + foreach (var key in keys) + { + if (IsPressed(key)) + TriggerKey(key); + } + } + catch (Exception ex) { - if (IsPressed(key)) - TriggerKey(key); + Logger.Error($"Error checking keys: {ex}"); } } } \ No newline at end of file diff --git a/Input/KeyEnum.cs b/Input/KeyEnum.cs index 26d8fdf..1346c61 100644 --- a/Input/KeyEnum.cs +++ b/Input/KeyEnum.cs @@ -1,7 +1,7 @@ namespace DeadshotModAPI; /// -/// Represents a keyboard key that can be monitored through the Deadshot Mod API. +/// Supported keyboard keys for input callbacks. /// public enum Key { @@ -17,74 +17,31 @@ public enum Key F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12, - // Navigation - UpArrow, - DownArrow, - LeftArrow, - RightArrow, - - Home, - End, - PageUp, - PageDown, - - Insert, - Delete, + // Navigation & editing + UpArrow, DownArrow, LeftArrow, RightArrow, + Home, End, PageUp, PageDown, + Insert, Delete, // Modifiers - LeftShift, - RightShift, - LeftCtrl, - RightCtrl, - LeftAlt, - RightAlt, + LeftShift, RightShift, + LeftCtrl, RightCtrl, + LeftAlt, RightAlt, - // Special - Space, - Enter, - Escape, - Tab, - Backspace, + // Control & text editing + Space, Enter, Escape, Tab, Backspace, - // Punctuation - Minus, - Equals, - LeftBracket, - RightBracket, - Backslash, - Semicolon, - Apostrophe, - Comma, - Period, - Slash, - Backquote, + // Symbols + Minus, Equals, LeftBracket, RightBracket, + Backslash, Semicolon, Apostrophe, Comma, + Period, Slash, Backquote, // Numpad - Numpad0, - Numpad1, - Numpad2, - Numpad3, - Numpad4, - Numpad5, - Numpad6, - Numpad7, - Numpad8, - Numpad9, - - NumpadPlus, - NumpadMinus, - NumpadMultiply, - NumpadDivide, - NumpadPeriod, - NumpadEnter, - - // Locks - CapsLock, - NumLock, - ScrollLock, - - // Misc - PrintScreen, - Pause, - Menu + Numpad0, Numpad1, Numpad2, Numpad3, Numpad4, + Numpad5, Numpad6, Numpad7, Numpad8, Numpad9, + NumpadPlus, NumpadMinus, NumpadMultiply, + NumpadDivide, NumpadPeriod, NumpadEnter, + + // Locks & system + CapsLock, NumLock, ScrollLock, + PrintScreen, Pause, Menu } \ No newline at end of file diff --git a/Logging/Logger.cs b/Logging/Logger.cs new file mode 100644 index 0000000..9c47672 --- /dev/null +++ b/Logging/Logger.cs @@ -0,0 +1,29 @@ +namespace DeadshotModAPI; + +/// +/// Logging utility for Deadshot mods that routes messages through Unity and BepInEx. +/// +public static class Logger +{ + public static void Log(string message) => Info(message); + + public static void Info(string message) + { + UnityEngine.Debug.Log($"[DeadshotModAPI] {message}"); + } + + public static void Warning(string message) + { + UnityEngine.Debug.LogWarning($"[DeadshotModAPI] {message}"); + } + + public static void Error(string message) + { + UnityEngine.Debug.LogError($"[DeadshotModAPI] {message}"); + } + + public static void Debug(string message) + { + UnityEngine.Debug.Log($"[DeadshotModAPI] {message}"); + } +} \ No newline at end of file diff --git a/Logging/logger.cs b/Logging/logger.cs deleted file mode 100644 index 5a3e73c..0000000 --- a/Logging/logger.cs +++ /dev/null @@ -1,44 +0,0 @@ -namespace DeadshotModAPI; - -/// -/// Provides logging functionality for Deadshot mods. -/// Messages are written through Unity's logging system and are displayed by BepInEx. -/// -public static class Logger -{ - /// - /// Logs an informational message. - /// - /// The message to log. - public static void Info(string message) - { - UnityEngine.Debug.Log($"[DeadshotModAPI] {message}"); - } - - /// - /// Logs a warning message. - /// - /// The warning message to log. - public static void Warning(string message) - { - UnityEngine.Debug.LogWarning($"[DeadshotModAPI] {message}"); - } - - /// - /// Logs an error message. - /// - /// The error message to log. - public static void Error(string message) - { - UnityEngine.Debug.LogError($"[DeadshotModAPI] {message}"); - } - - /// - /// Logs a debug message. - /// - /// The debug message to log. - public static void Debug(string message) - { - UnityEngine.Debug.Log($"[DeadshotModAPI] {message}"); - } -} \ No newline at end of file diff --git a/Mods/IDeadshotMod.cs b/Mods/IDeadshotMod.cs index bf31312..c404bfd 100644 --- a/Mods/IDeadshotMod.cs +++ b/Mods/IDeadshotMod.cs @@ -1,32 +1,32 @@ namespace DeadshotModAPI; /// -/// Defines the required structure and metadata for a Deadshot mod. +/// Interface implemented by Deadshot mods. /// public interface IDeadshotMod { /// - /// Gets the display name of the mod. + /// Display name of the mod. /// string Name { get; } /// - /// Gets a description of what the mod does. + /// Short description of what the mod does. /// string Description { get; } /// - /// Gets the name of the mod's creator. + /// Name or handle of the author. /// string Creator { get; } /// - /// Gets the version of the mod. + /// Version of the mod (e.g. 1.0.0). /// string Version { get; } /// - /// Called when the mod is loaded by the Deadshot Mod API. + /// Called when the mod is loaded by the API. /// void Load(); } \ No newline at end of file diff --git a/Class1.cs b/Plugin.cs similarity index 62% rename from Class1.cs rename to Plugin.cs index f508027..b7c82bb 100644 --- a/Class1.cs +++ b/Plugin.cs @@ -1,25 +1,20 @@ -๏ปฟusing BepInEx; +using BepInEx; using BepInEx.Unity.IL2CPP; using HarmonyLib; -using UnityEngine; namespace DeadshotModAPI; -[BepInPlugin( - "com.subaka.deadshotmodapi", - "Deadshot Mod Api", - "0.0.1" -)] +[BepInPlugin("com.subaka.deadshotmodapi", "Deadshot Mod API", "v1.0.0-dev-alpha")] public class Plugin : BasePlugin { private static Harmony _harmony; public override void Load() { - Debug.Log("Deadshot Mod API loaded."); + Logger.Info("Deadshot Mod API loaded."); _harmony = new Harmony("DeadshotModAPI"); _harmony.PatchAll(); - AddComponent(); + AddComponent(); } } \ No newline at end of file diff --git a/README.md b/README.md index aa74959..d6e20a4 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,142 @@ # Deadshot Mod API -[![Build](https://github.com/SniffBakaSniff/DeadshotModAPI/actions/workflows/build.yml/badge.svg)](https://github.com/SniffBakaSniff/DeadshotModAPI/actions/workflows/build.yml) +[![Build](https://github.com/DemonZ-Development/DeadshotModAPI/actions/workflows/build.yml/badge.svg)](https://github.com/DemonZ-Development/DeadshotModAPI/actions/workflows/build.yml) +[![Framework](https://img.shields.io/badge/.NET-6.0-purple.svg)](https://dotnet.microsoft.com/download/dotnet/6.0) +[![Platform](https://img.shields.io/badge/BepInEx-6%20IL2CPP-blue.svg)](https://builds.bepinex.dev/projects/bepinex_be) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -A modding API for **Deadshot** that is currently in development. +A modding library for **Deadshot** built on BepInEx 6 IL2CPP and .NET 6. -The goal of this project is to provide a common foundation for Deadshot mods, handling the BepInEx and Unity-side setup so individual mods can focus on their own functionality. +DeadshotModAPI handles mod discovery, assembly loading, key bindings, and scene loading so you can write mods without setting up BepInEx plugins or IL2CPP interop manually. -# Requirements +--- -### For Players +## Requirements +### Players * **Deadshot** -* **BepInEx 6 (Bleeding Edge)** installed and configured for Deadshot -* **Deadshot Mod API** installed in the game's `BepInEx/plugins` directory +* **BepInEx 6 (Bleeding Edge IL2CPP)** +* **`DeadshotModAPI.dll`** in `/BepInEx/plugins/` -### For Mod Developers +### Mod Developers +* [.NET 6 SDK](https://dotnet.microsoft.com/download/dotnet/6.0) +* A C# editor (Visual Studio, Rider, or VS Code) +* A reference to `DeadshotModAPI.dll` in your project -* **.NET 6** -* A C# development environment -* A reference to **`DeadshotModAPI.dll`** in the mod project +--- -Mods do **not** need to be registered as BepInEx plugins. The API handles mod discovery and loading. - -[BepInEx Bleeding Edge](https://builds.bepinex.dev/projects/bepinex_be) - -## Mod Structure - -Mods are loaded from: +## Directory Layout ```text -BepInEx/ -โ”œโ”€โ”€ plugins/ -โ”‚ โ””โ”€โ”€ DeadshotModAPI.dll -โ”‚ -โ””โ”€โ”€ mods/ - โ””โ”€โ”€ MyMod.dll +Deadshot/ +โ”œโ”€โ”€ BepInEx/ +โ”‚ โ”œโ”€โ”€ plugins/ +โ”‚ โ”‚ โ””โ”€โ”€ DeadshotModAPI.dll <-- API plugin +โ”‚ โ””โ”€โ”€ mods/ +โ”‚ โ”œโ”€โ”€ SpeedrunMod.dll <-- Your mod DLLs +โ”‚ โ””โ”€โ”€ CustomMod/ +โ”‚ โ””โ”€โ”€ CustomMod.dll <-- Subdirectories are scanned too ``` -Mods must reference **`DeadshotModAPI.dll`** when being built. The API provides the interfaces and functionality that mods use at runtime. - -A mod implements `IDeadshotMod` and provides its metadata and `Load()` method. - -# Example Mod - -A simple example demonstrating how to create a Deadshot mod using the Deadshot Mod API. - -```csharp -using DeadshotModAPI; - -public class ExampleMod : IDeadshotMod -{ - public string Name => "Example Mod"; - public string Description => "A simple example mod."; - public string Creator => "Your Name"; - public string Version => "1.0.0"; - - public void Load() - { - Input.OnKeyPressed(Key.F12, OnF12Pressed); - - Logger.Info("Example Mod loaded."); - } - - private void OnF12Pressed() - { - Logger.Info("F12 was pressed!"); - } -} +At startup, the API scans `BepInEx/mods/` recursively for DLLs, finds classes implementing `IDeadshotMod`, and calls their `Load()` method. + +--- + +## Quickstart: Creating a Mod + +1. Create a class library targeting .NET 6: + ```bash + dotnet new classlib -n SpeedrunMod -f net6.0 + ``` +2. Reference `DeadshotModAPI.dll` in your `.csproj`: + ```xml + + + path\to\DeadshotModAPI.dll + + + ``` +3. Implement `IDeadshotMod`: + ```csharp + using DeadshotModAPI; + + public class SpeedrunMod : IDeadshotMod + { + public string Name => "Deadshot Speedrunning"; + public string Description => "A Mod made for speedrunning Deadshot."; + public string Creator => "Subaka"; + public string Version => "0.2.0"; + + public void Load() + { + Logger.Debug("Deadshot Speedrun loaded through Mod API."); + + Input.OnKeyPressed(Key.F1, () => RestartLevelMethod(false)); + Input.OnKeyPressed(Key.F2, () => RestartLevelMethod(true)); + } + + private void RestartLevelMethod(bool playCutscene) + { + GameManager.RestartLevel(playCutscene); + } + } + ``` +4. Build your mod: + ```bash + dotnet build -c Release + ``` +5. Copy the output DLL to `/BepInEx/mods/`. + +See [`examples/ExampleMod/`](examples/ExampleMod/) for a working project template. + +--- + +## API Reference + +### `IDeadshotMod` +Interface implemented by mods: +* `Name` - Mod display name. +* `Description` - Summary of mod behavior. +* `Creator` - Author name. +* `Version` - Version string (e.g. `"0.2.0"`). +* `Load()` - Runs once at game startup. + +### `Input` +Keyboard listener: +* `Input.OnKeyPressed(Key key, Action callback)` - Registers a callback triggered when `key` is pressed. +* `Input.RemoveKeyPressed(Key key, Action callback)` - Unregisters a callback. + +### `SceneManager` +Scene utilities: +* `SceneManager.Load(string sceneName)` - Loads a scene by name. +* `SceneManager.Load(int sceneIndex)` - Loads a scene by build index. + +### `GameManager` +Level lifecycle utilities: +* `GameManager.RestartLevel(bool playCutscene = true)` - Restarts the active level. Set `playCutscene: false` to skip the intro cutscene. + +### `Logger` +Unity and BepInEx console logger: +* `Logger.Info(string message)` / `Logger.Log(string message)` +* `Logger.Warning(string message)` +* `Logger.Error(string message)` +* `Logger.Debug(string message)` + +--- + +## Building from Source + +```bash +git clone https://github.com/DemonZ-Development/DeadshotModAPI.git +cd DeadshotModAPI +dotnet build DeadshotModAPI.csproj -c Release +dotnet test DeadshotModAPI.Tests/DeadshotModAPI.Tests.csproj ``` -The mod registers a callback for **F12** when it loads. When F12 is pressed, the API invokes `OnF12Pressed()`, which logs a message. +References point to `lib/` with relative paths, so the solution builds on any machine with the .NET 6 SDK. -## Project Goals +--- -The long-term goal is to make creating Deadshot mods as straightforward as possible while keeping game-specific and BepInEx-specific implementation details inside the API. +## License -This project is experimental and should not currently be considered a stable modding API. +MIT. See [LICENSE](LICENSE). diff --git a/Runtime/ModLoader.cs b/Runtime/ModLoader.cs index 95514ee..16a8611 100644 --- a/Runtime/ModLoader.cs +++ b/Runtime/ModLoader.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Reflection; using BepInEx; @@ -7,80 +8,175 @@ namespace DeadshotModAPI; /// -/// Responsible for discovering and loading Deadshot mods from the -/// /BepInEx/mods/ directory and processing the API's input system. +/// Discovers and loads mods implementing from BepInEx/mods. /// public class ModLoader : MonoBehaviour { - /// - /// Called when the ModLoader component is initialized. - /// + public static readonly List LoadedMods = new(); + public void Awake() { - Debug.Log("Deadshot Mod API ModLoader Awake."); + Logger.Info("Deadshot Mod API ModLoader Awake."); } - /// - /// Called when the ModLoader component starts. - /// Loads all mods found in the /BepInEx/mods/ directory. - /// public void Start() { - Debug.Log("Deadshot Mod API ModLoader Start."); - - // Commented out as it breaks stuff - // SceneManager.LoadModsBundle(); - - LoadMods(); + Logger.Info("Deadshot Mod API ModLoader Start."); + try + { + LoadMods(); + } + catch (Exception ex) + { + Logger.Error($"Unexpected error during Start mod loading: {ex}"); + } } - /// - /// Called once per frame. - /// public void Update() { - Input.CheckKeys(); // Checks keyboard inputs - EventManager.Update(); // Handles updating events + try + { + Input.CheckKeys(); + } + catch (Exception ex) + { + Logger.Error($"Unexpected error in ModLoader.Update: {ex}"); + } } - /// - /// Scans the /BepInEx/mods/ directory for DLL files - /// and attempts to load each one as a Deadshot mod. - /// private void LoadMods() { - string modsPath = Path.Combine(Paths.BepInExRootPath, "mods"); + string rootPath; + try + { + rootPath = Paths.BepInExRootPath; + } + catch (Exception ex) + { + Logger.Error($"Failed to determine BepInEx root path: {ex}"); + return; + } - // Checks if the `/BepInEx/mods/` directory exists and if not then creates it. - if (!Directory.Exists(modsPath)) + if (string.IsNullOrWhiteSpace(rootPath)) { - Directory.CreateDirectory(modsPath); - Debug.Log($"Created mod directory: {modsPath}"); + Logger.Error("BepInEx root path is null or empty. Cannot discover mods."); return; } - foreach (string file in Directory.GetFiles(modsPath, "*.dll")) + string modsPath; + try + { + modsPath = Path.Combine(rootPath, "mods"); + } + catch (Exception ex) + { + Logger.Error($"Failed to construct mods directory path: {ex}"); + return; + } + + try + { + if (!Directory.Exists(modsPath)) + { + Directory.CreateDirectory(modsPath); + Logger.Info($"Created mod directory: {modsPath}"); + return; + } + } + catch (Exception ex) + { + Logger.Error($"Failed to verify or create mod directory '{modsPath}': {ex}"); + return; + } + + string[] modFiles; + try + { + modFiles = Directory.GetFiles(modsPath, "*.dll", SearchOption.AllDirectories); + } + catch (Exception ex) + { + Logger.Error($"Failed to enumerate mod files in '{modsPath}': {ex}"); + return; + } + + foreach (string file in modFiles) { LoadMod(file); } } - /// - /// Loads a mod assembly from the specified DLL and searches it for - /// concrete implementations of . - /// - /// The file path of the mod DLL to load. private void LoadMod(string path) { + if (string.IsNullOrWhiteSpace(path)) + { + Logger.Warning("Mod file path is null or empty."); + return; + } + + if (!File.Exists(path)) + { + Logger.Error($"Mod file does not exist: {path}"); + return; + } + + Assembly assembly; try { - // Loads the dll file - Assembly assembly = Assembly.LoadFrom(path); + assembly = Assembly.LoadFrom(path); + } + catch (BadImageFormatException ex) + { + Logger.Error($"Corrupt or invalid mod assembly '{Path.GetFileName(path)}': {ex.Message}"); + return; + } + catch (FileNotFoundException ex) + { + Logger.Error($"Mod assembly file not found '{path}': {ex.Message}"); + return; + } + catch (Exception ex) + { + Logger.Error($"Failed to load assembly '{Path.GetFileName(path)}': {ex}"); + return; + } - foreach (Type type in assembly.GetTypes()) + Type[] types; + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + Logger.Warning($"Type load warnings encountered while inspecting '{Path.GetFileName(path)}':"); + if (ex.LoaderExceptions != null) + { + foreach (var loaderEx in ex.LoaderExceptions) + { + if (loaderEx != null) + { + Logger.Warning($" Loader exception: {loaderEx.Message}"); + } + } + } + types = ex.Types; + } + catch (Exception ex) + { + Logger.Error($"Failed to retrieve types from '{Path.GetFileName(path)}': {ex}"); + return; + } + + if (types == null) + return; + + foreach (Type type in types) + { + if (type == null) + continue; + + try { - // Checks whether the loaded class implements IDeadshotMod - // and isn't an interface or abstract class. if (!typeof(IDeadshotMod).IsAssignableFrom(type) || type.IsInterface || type.IsAbstract) @@ -89,18 +185,34 @@ private void LoadMod(string path) } IDeadshotMod mod = (IDeadshotMod)Activator.CreateInstance(type)!; + if (mod == null) + { + Logger.Warning($"Failed to instantiate mod '{type.FullName}' from '{path}'."); + continue; + } + + string modName = "Unknown"; + try + { + modName = mod.Name; + } + catch (Exception nameEx) + { + Logger.Warning($"Failed to read mod name from '{type.FullName}': {nameEx.Message}"); + } - Debug.Log($"Loading mod: {mod.Name}"); - Debug.Log($" Description: {mod.Description}"); - Debug.Log($" Creator: {mod.Creator}"); - Debug.Log($" Version: {mod.Version}"); + Logger.Info($"Loading mod: {modName}"); + try { Logger.Info($" Description: {mod.Description}"); } catch { } + try { Logger.Info($" Creator: {mod.Creator}"); } catch { } + try { Logger.Info($" Version: {mod.Version}"); } catch { } mod.Load(); + LoadedMods.Add(mod); + } + catch (Exception ex) + { + Logger.Error($"Failed to instantiate or load mod '{type?.FullName}' from '{Path.GetFileName(path)}': {ex}"); } - } - catch (Exception ex) - { - Debug.LogError($"Failed to load mod '{Path.GetFileName(path)}': {ex}"); } } } \ No newline at end of file diff --git a/SceneManager/SceneManager.cs b/SceneManager/SceneManager.cs index e288a10..f3473bb 100644 --- a/SceneManager/SceneManager.cs +++ b/SceneManager/SceneManager.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using UnityEngine; using Deadshot; @@ -5,56 +6,105 @@ namespace DeadshotModAPI; /// -/// Provides scene loading functionality for Deadshot mods. +/// Scene loading and asset bundle management for Deadshot mods. /// public static class SceneManager { private static AssetBundle _modsBundle; /// - /// Loads a scene by its name using Deadshot's built-in loading system. + /// Loads a scene by its name using Deadshot's loading system. /// /// The name of the scene to load. public static void Load(string sceneName) { - LoadingScreen.LoadScene(sceneName); + if (string.IsNullOrWhiteSpace(sceneName)) + { + Logger.Warning("Cannot load scene: sceneName is null, empty, or whitespace."); + return; + } + + try + { + LoadingScreen.LoadScene(sceneName); + } + catch (Exception ex) + { + Logger.Error($"Failed to load scene '{sceneName}': {ex}"); + } } /// - /// Loads a scene by its build index using Deadshot's built-in loading system. + /// Loads a scene by its build index using Deadshot's loading system. /// /// The build index of the scene to load. public static void Load(int sceneIndex) { - LoadingScreen.LoadScene(sceneIndex); + if (sceneIndex < 0) + { + Logger.Warning($"Cannot load scene: invalid sceneIndex '{sceneIndex}'. Index must be non-negative."); + return; + } + + try + { + LoadingScreen.LoadScene(sceneIndex); + } + catch (Exception ex) + { + Logger.Error($"Failed to load scene at index {sceneIndex}: {ex}"); + } } /// - /// Loads the Deadshot Mod API's custom scene bundle. Broken Right now. + /// Loads the Deadshot Mod API UI asset bundle from BepInEx/mods/DeadshotModAPI. /// internal static void LoadModsBundle() { - string path = Path.Combine( - BepInEx.Paths.BepInExRootPath, - "mods", - "DeadshotModAPI", - "deadshotmodapi" - ); + if (_modsBundle != null) + { + Logger.Warning("Mods Menu bundle is already loaded."); + return; + } - if (!File.Exists(path)) + string rootPath; + try { - Logger.Error($"Mod bundle file does not exist at path: {path}"); + rootPath = BepInEx.Paths.BepInExRootPath; + } + catch (Exception ex) + { + Logger.Error($"Failed to determine BepInEx root path: {ex}"); return; } - _modsBundle = AssetBundle.LoadFromFile(path); + if (string.IsNullOrWhiteSpace(rootPath)) + { + Logger.Error("BepInEx root path is null or empty. Cannot discover mods."); + return; + } - if (_modsBundle == null) + string path = Path.Combine(rootPath, "mods", "DeadshotModAPI", "deadshotmodapi"); + if (!File.Exists(path)) { - Logger.Error($"Failed to load Mods Menu bundle: {path}"); + Logger.Error($"Mod bundle file does not exist at path: {path}"); return; } - Logger.Info("Mods Menu bundle loaded successfully."); + try + { + _modsBundle = AssetBundle.LoadFromFile(path); + if (_modsBundle == null) + { + Logger.Error($"Failed to load Mods Menu bundle: {path}"); + return; + } + + Logger.Info("Mods Menu bundle loaded successfully."); + } + catch (Exception ex) + { + Logger.Error($"Failed to load Mods Menu bundle from '{path}': {ex}"); + } } } \ No newline at end of file diff --git a/examples/ExampleMod/ExampleMod.cs b/examples/ExampleMod/ExampleMod.cs new file mode 100644 index 0000000..c721871 --- /dev/null +++ b/examples/ExampleMod/ExampleMod.cs @@ -0,0 +1,22 @@ +using DeadshotModAPI; + +public class SpeedrunMod : IDeadshotMod +{ + public string Name => "Deadshot Speedrunning"; + public string Description => "A Mod made for speedrunning Deadshot."; + public string Creator => "Subaka"; + public string Version => "0.2.0"; + + public void Load() + { + Logger.Debug("Deadshot Speedrun loaded through Mod API."); + + Input.OnKeyPressed(Key.F1, () => RestartLevelMethod(false)); + Input.OnKeyPressed(Key.F2, () => RestartLevelMethod(true)); + } + + private void RestartLevelMethod(bool playCutscene) + { + GameManager.RestartLevel(playCutscene); + } +} diff --git a/examples/ExampleMod/ExampleMod.csproj b/examples/ExampleMod/ExampleMod.csproj new file mode 100644 index 0000000..4ae877c --- /dev/null +++ b/examples/ExampleMod/ExampleMod.csproj @@ -0,0 +1,14 @@ + + + + net6.0 + Library + disable + disable + + + + + + + diff --git a/examples/ExampleMod/README.md b/examples/ExampleMod/README.md new file mode 100644 index 0000000..f0905c3 --- /dev/null +++ b/examples/ExampleMod/README.md @@ -0,0 +1,66 @@ +# SpeedrunMod (Example Mod) + +An example mod for Deadshot built with **DeadshotModAPI**. + +It adds hotkeys to restart levels and skip cutscenes during speedrun attempts. + +--- + +## Code + +```csharp +using DeadshotModAPI; + +public class SpeedrunMod : IDeadshotMod +{ + public string Name => "Deadshot Speedrunning"; + public string Description => "A Mod made for speedrunning Deadshot."; + public string Creator => "Subaka"; + public string Version => "0.2.0"; + + public void Load() + { + Logger.Debug("Deadshot Speedrun loaded through Mod API."); + + // F1: restart and skip cutscene + // F2: restart with cutscene + Input.OnKeyPressed(Key.F1, () => RestartLevelMethod(false)); + Input.OnKeyPressed(Key.F2, () => RestartLevelMethod(true)); + } + + private void RestartLevelMethod(bool playCutscene) + { + GameManager.RestartLevel(playCutscene); + } +} +``` + +--- + +## Build + +```bash +dotnet build ExampleMod.csproj -c Release +``` + +Output: +```text +bin/Release/net6.0/ExampleMod.dll +``` + +--- + +## Install + +Copy `ExampleMod.dll` into `/BepInEx/mods/`. + +Launch Deadshot. The API loads the mod automatically. + +Check `BepInEx/LogOutput.log` to confirm: +```text +[DeadshotModAPI] Loading mod: Deadshot Speedrunning +[DeadshotModAPI] Description: A Mod made for speedrunning Deadshot. +[DeadshotModAPI] Creator: Subaka +[DeadshotModAPI] Version: 0.2.0 +[DeadshotModAPI] Deadshot Speedrun loaded through Mod API. +```