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
-[](https://github.com/SniffBakaSniff/DeadshotModAPI/actions/workflows/build.yml)
+[](https://github.com/DemonZ-Development/DeadshotModAPI/actions/workflows/build.yml)
+[](https://dotnet.microsoft.com/download/dotnet/6.0)
+[](https://builds.bepinex.dev/projects/bepinex_be)
+[](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.
+```