Skip to content

The Module System and Assembly Loading

Unturned's module system is the code-level extension mechanism that allows the game to load external assemblies at runtime, resolve their dependencies, initialize them through a standardized lifecycle interface, and shut them down cleanly on exit. It is the same system used to load the game's own core assemblies — UnturnedNexus and FrameworkNexus are discovered and initialized through exactly the same IModuleNexus interface that third-party plugin frameworks use.

This article documents every component of the module system: the .module configuration file format, the ModuleConfig / ModuleAssembly data model, the Module wrapper class, the ModuleHook MonoBehaviour that orchestrates discovery and initialization, the dependency resolution algorithm, the custom AssemblyResolve pipeline, and the IModuleNexus lifecycle interface.

Source code location: Framework/Modules/*.cs

Architecture Overview

The module system lives entirely in the SDG.Framework.Modules namespace. It is designed as a framework-level subsystem that runs before any game-specific code. The core types form a three-layer architecture:

LayerClassResponsibility
ConfigModuleConfigData model for .module JSON file
AssemblyModuleAssemblySingle assembly entry within a module config
WrapperModuleRuntime wrapper managing loading, initialization, shutdown
DiscoveryModuleHookMonoBehaviour that orchestrates the full pipeline
InterfaceIModuleNexusLifecycle contract for entry-point classes

The .module Configuration File

Each module is defined by a .module file on disk. The files are JSON-formatted and parsed by Newtonsoft.Json via IOUtility.jsonDeserializer. The search path is Modules/ relative to the game's root directory, with recursion into subdirectories.

A minimal module file:

json
{
  "Name": "MyModule",
  "Version": "1.0.0.0",
  "IsEnabled": true,
  "Assemblies": [
    {
      "Path": "MyModule.dll",
      "Role": "Both"
    }
  ],
  "Dependencies": []
}

ModuleConfig Fields

FieldTypeJSON TypeDefaultPurpose
IsEnabledboolbooleantrueIf false, the module's assemblies are not loaded
DirectoryPathstring(set by loader)Absolute path to the directory containing the .module file. Set by ModuleHook during loading.
FilePathstring(set by loader)Absolute path to the .module file
Namestringstring""Module name used for dependency matching
Versionstringstring"1.0.0.0"Display version string
Version_Internaluint(computed)Version converted to a uint32 via Parser.getUInt32FromIP (IP-format version encoding)
DependenciesarrayModuleDependency[][]Modules that must be loaded before this one
AssembliesarrayModuleAssembly[][]Relative paths to .dll files to load

ModuleDependency

csharp
public class ModuleDependency
{
    public string Name;       // Module name to depend on
    public string Version;    // Minimum required version
    public uint Version_Internal; // Computed from Version
}

Dependencies are checked during sortModules. If a dependency module is not present in the sorted list at a version equal to or higher than the required version, the dependent module is discarded with a warning logged.

ModuleAssembly

csharp
public class ModuleAssembly
{
    public string Path;              // Relative path to .dll file
    public EModuleRole Role;         // Client, Server, or Both_Required
    public bool Load_As_Byte_Array;  // Workaround for self-updating plugins
}

The Role enumeration controls which platform loads the assembly:

Enum valueClientDedicated Server
NoneLoadedLoaded
ClientLoadedRemoved from assembly list
ServerRemoved from assembly listLoaded
Both_RequiredLoadedLoaded; also discoverable via getRequiredModules

The Load_As_Byte_Array flag exists because Assembly.LoadFile locks the file on disk, preventing self-updating plugin frameworks from replacing the DLL while the game is running. When true, Assembly.Load(byte[]) is used instead, which reads the bytes into memory and releases the file handle.

Module Discovery and Loading

Module discovery is orchestrated by ModuleHook, a MonoBehaviour that goes through three phases:

Phase 1: awake() — Discovery

ModuleHook.awake()

      ├─ AppDomain.CurrentDomain.AssemblyResolve += handleAssemblyResolve
      ├─ AppDomain.CurrentDomain.TypeResolve += OnTypeResolve

      ├─ coreAssembly = Assembly.GetExecutingAssembly()
      ├─ coreTypes = coreAssembly.GetTypes() // with ReflectionTypeLoadException catch

      └─ loadModules()

            ├─ DiscoverAssemblies()
            │    └─ Recursively scan Modules/ for *.dll files
            │       Record AssemblyName→path mapping in discoveredNameToPath

            ├─ findModules()
            │    └─ Recursively scan Modules/ for *.module files
            │       Deserialize each .module → ModuleConfig
            │       Set DirectoryPath, FilePath, Version_Internal
            │       Remove stale "Framework" and "Unturned" dependencies

            ├─ sortModules(configs)
            │    ├─ Sort by dependency using ModuleComparer
            │    ├─ Remove configs missing assemblies or dependencies
            │    └─ Remove assemblies with role mismatches

            └─ For each config:
                 module = new Module(config)  ← registers assembly paths
                 modules.Add(module)

The DiscoverAssemblies() method scans all *.dll files in the Modules/ directory tree and stores their AssemblyName objects. This pre-discovery step enables the custom AssemblyResolve handler to find dependency assemblies that are not explicitly listed in any .module file's assembly list — a critical feature for plugin frameworks that need to resolve their own transitive dependencies.

Phase 2: start() — Core Nexus and Module Initialization

ModuleHook.start()

      ├─ coreNexii = new List<IModuleNexus>()
      ├─ For each type in coreTypes:
      │    └─ if (type.IsAssignableFrom(IModuleNexus) && !type.IsAbstract)
      │         nexus = Activator.CreateInstance(type)
      │         nexus.initialize()
      │         coreNexii.Add(nexus)

      └─ initializeModules()
            └─ For each module in modules:
                 ├─ Check IsEnabled
                 ├─ Check dependency enablement
                 ├─ Check command-line disable flag
                 └─ module.isEnabled = true
                      ├─ module.load()  → resolve assemblies, get types
                      └─ module.initialize() → find IModuleNexus, call initialize()

The core assembly scan in ModuleHook.start() is how UnturnedNexus and FrameworkNexus are discovered. They are not loaded from .module files — they live in the game's main Assembly-CSharp.dll and are found via Assembly.GetExecutingAssembly().GetTypes().

After core nexii are initialized, initializeModules() iterates through the discovered modules in order. Each module's isEnabled setter triggers load() followed by initialize().

Phase 3: OnDestroy() — Shutdown

ModuleHook.OnDestroy()

      └─ shutdownModules()
            └─ For each module in reverse order:
                 module.isEnabled = false
                   └─ module.shutdown()
                        └─ Call shutdown() on each IModuleNexus

      └─ For each coreNexus:
           nexus.shutdown()

      └─ AppDomain.CurrentDomain.AssemblyResolve -= handleAssemblyResolve
      └─ AppDomain.CurrentDomain.TypeResolve -= OnTypeResolve

Shutdown happens in reverse dependency order (modules that depend on others are shut down first). Core nexii are shut down after all module nexii. The AssemblyResolve handler is unregistered last.

The Module Class

Module wraps a ModuleConfig and manages the runtime lifecycle of its assemblies. The key fields are:

csharp
public class Module
{
    public bool isEnabled;       // Config enabled AND dependencies enabled
    public ModuleConfig config;  // The parsed .module data
    public Assembly[] assemblies; // Loaded assembly objects
    public Type[] types;          // All types from all assemblies
    public EModuleStatus status;  // None → Initialized → Shutdown

    private List<IModuleNexus> nexii; // Holds references to prevent GC
}

Module.register()

Called in the constructor. Iterates over config.Assemblies and calls ModuleHook.registerAssemblyPath(path, loadAsByteArray) for each one. This step populates the global assembly path registry that the AssemblyResolve handler uses.

Module.load()

Called when isEnabled is set to true. For each assembly in the config, it calls ModuleHook.resolveAssemblyPath() which returns a loaded Assembly object. It collects all types across all assemblies (catching ReflectionTypeLoadException to handle assemblies with missing dependencies), and fires the onModuleLoaded event.

Module.initialize()

Searches all types in the module for classes that implement IModuleNexus and are not abstract. For each matching type, it instantiates via Activator.CreateInstance and calls nexus.initialize(). The nexus instances are stored in the nexii list to prevent garbage collection. Sets status = EModuleStatus.Initialized.

Module.shutdown()

Iterates backward through the nexii list, calling shutdown() on each. Clears the list and sets status = EModuleStatus.Shutdown.

The Custom AssemblyResolve Pipeline

The ModuleHook.handleAssemblyResolve method is registered with AppDomain.CurrentDomain.AssemblyResolve and intercepts all assembly resolution requests during the game's lifetime. The pipeline has six stages, with three plugin-settable events for third-party frameworks:

handleAssemblyResolve(args)

      ├─ Stage 1: PreVanillaAssemblyResolve
      │    └─ Plugin framework override (e.g., Rocket handles its own deps)

      ├─ Stage 2: Backward-compatible redirects
      │    ├─ "Assembly-CSharp-firstpass" → com.rlabrecque.steamworks.net
      │    └─ "Steamworks.NET" → com.rlabrecque.steamworks.net

      ├─ Stage 3: PreVanillaAssemblyResolvePostRedirects
      │    └─ Second plugin override (after redirects but before vanilla search)

      ├─ Stage 4: resolveAssemblyName(name)
      │    └─ Look up pre-registered assemblies by full name

      ├─ Stage 5: LoadAssemblyFromDiscoveredPaths (if enabled)
      │    └─ Search Modules/ for matching DLLs by name + version

      └─ Stage 6: PostVanillaAssemblyResolve
           └─ Final plugin override

The pipeline is designed to give plugin frameworks like RocketMod the ability to intercept resolution at any stage. If PreVanillaAssemblyResolve returns an assembly, the vanilla pipeline is skipped entirely. The resolveAssemblyName method checks the nameToAssembly dictionary (already-loaded assemblies) and then the nameToPath dictionary (registered assembly file paths). For file lookups, it reads the bytes with File.ReadAllBytes and loads via Assembly.Load(byte[]) if loadAsByteArray is set, or Assembly.LoadFile otherwise.

The LoadAssemblyFromDiscoveredPaths method is the fallback that searches all *.dll files discovered during DiscoverAssemblies(). It matches by assembly name and minimum version. This step is enabled by default but can be disabled with the -NoVanillaAssemblySearch command-line flag (useful when a third-party framework has its own resolution and the vanilla search conflicts with it).

The IModuleNexus Interface

csharp
public interface IModuleNexus
{
    void initialize();
    void shutdown();
}

Despite its simplicity, this interface is the entire extension surface for Unturned's module system. Any class in any loaded assembly that implements IModuleNexus will have its initialize() called when the module is enabled, and shutdown() called when the module is disabled or the game exits.

The UnturnedNexus class is the primary implementation. Its initialize() method registers every built-in asset type and useable type:

csharp
public void initialize()
{
    Assets.assetTypes.addType("Hat", typeof(ItemHatAsset));
    Assets.assetTypes.addType("Gun", typeof(ItemGunAsset));
    // ... 50+ more asset type registrations
    Assets.assetTypes.addType("Road", typeof(RoadAsset));

    Assets.useableTypes.addType("Gun", typeof(UseableGun));
    Assets.useableTypes.addType("Melee", typeof(UseableMelee));
    // ... 20+ more useable type registrations
}

The FrameworkNexus class is the second implementation. Its initialize() and shutdown() are currently empty — it exists as a placeholder for framework-level initialization that may be needed in future versions.

Dependency Resolution Algorithm

The sortModules method in ModuleHook implements a topological sort over module dependencies. The algorithm has three stages:

Stage 1: Sort by ModuleComparer
  — Uses custom comparer that orders by dependency depth
  — Produces initial ordering

Stage 2: Validate assemblies
  For each config in sorted order:
    For each assembly in config.Assemblies:
      — Remove if Role mismatch (Client assembly on server, etc.)
      — Check for directory escape attacks (".." in path)
      — Check that the .dll file exists on disk
    If any assembly fails or list becomes empty:
      — Remove config from list
      — Log "discard module ... because it has no assemblies"

Stage 3: Validate dependencies
  For each dependency in config.Dependencies:
    — Search earlier configs (lower index) for matching Name
    — If found, check that Version_Internal >= dependency.Version_Internal
    — If not found or version insufficient:
      — Remove config from list
      — Log "discard module ... because dependency ... wasn't met"

The dependency check uses Version_Internal, which is computed by Parser.getUInt32FromIP. This converts a dotted version string (e.g., "1.2.3.4") into a single uint32 that can be compared numerically. The conversion treats each octet as a byte in a uint32:

"1.2.3.4" → 0x01020304 → 16909060 (uint32)

Only earlier-indexed configs are searched for dependencies, which enforces a strict partial ordering: a module cannot depend on a module that appears later in the sorted list. The sort order is not guaranteed to be unique for cyclic dependencies — the ModuleComparer does not detect cycles.

Module Enablement Cascading

When a module is toggled on or off at runtime (via the in-game mod menu or the toggleModuleEnabled API), the system cascades the change to dependent modules:

toggleModuleEnabled(index, enable=true)

      └─ modules[index].isEnabled = true

            └─ For each module after index:
                 └─ If module depends on enabled module:
                      └─ updateModuleEnabled(moduleIndex, true)

toggleModuleEnabled(index, enable=false)

      └─ For each module after index in reverse:
           └─ If module depends on disabled module:
                └─ updateModuleEnabled(moduleIndex, false)

      └─ modules[index].isEnabled = false

The cascading ensures that turning off a utility module automatically turns off all modules that depend on it, and turning one on re-enables any dependents whose other dependencies are also satisfied. The areModuleDependenciesEnabled check validates that all dependencies are still enabled before enabling a dependent.

Command-Line Module Control

Modules can be disabled at launch time without editing the .module file using the command-line flag format:

-disableModule/ModuleName

The isModuleDisabledByCommandLine method searches the command line string for the module name preceded by -disableModule/. This allows server operators to selectively disable modules without filesystem write access to the Modules directory.

Module Path Override

The -ModulesPath command-line flag redirects module discovery to a different directory:

-ModulesPath D:\CustomModules

When set, module discovery uses the specified path instead of the default Unturned/Modules/. This is used by plugin frameworks that maintain their own module directories.

Architecture Diagram

Modules/
└── MyMod/
    ├── MyMod.module
    └── MyMod.dll

ModuleHook (MonoBehaviour)

      ├── awake()
      │    ├── DiscoverAssemblies() — scan for all *.dll
      │    ├── findModules() — scan for *.module
      │    ├── sortModules() — topological sort + validation
      │    └── For each config → new Module(config)
      │         └── Module.register() → registerAssemblyPath()

      ├── start()
      │    ├── Find IModuleNexus in core assembly
      │    │    ├── UnturnedNexus.initialize() — register asset types
      │    │    └── FrameworkNexus.initialize() — (empty)
      │    │
      │    └── initializeModules()
      │         └── For each Module:
      │              └── isEnabled = true
      │                   ├── Module.load() — resolve assemblies
      │                   │    ├── ModuleHook.resolveAssemblyPath()
      │                   │    └── Assembly.Load / Assembly.LoadFile
      │                   │
      │                   ├── Module.initialize()
      │                   │    └── Find IModuleNexus in types
      │                   │         └── Activator.CreateInstance
      │                   │              └── nexus.initialize()
      │                   │
      │                   └── onModuleInitialized event

      └── OnDestroy()
           └── shutdownModules() — reverse order
                └── Module.isEnabled = false
                     └── Module.shutdown()
                          └── nexus.shutdown() for each nexus

IModuleNexus Lifecycle Summary

                              ┌──────────────────────────────┐
                              │  AppDomain.CurrentDomain     │
                              │  AssemblyResolve             │
                              │  + TypeResolve               │
                              │                              │
                              │  ModuleHook registers these  │
                              │  in awake()                  │
                              └──────────┬───────────────────┘

                  ┌──────────────────────┴──────────────────────┐
                  │               ModuleHook.start()            │
                  │                                              │
                  │  ┌─────────────────┐  ┌──────────────────┐  │
                  │  │ Core assembly    │  │ Module modules[] │  │
                  │  │ IModuleNexus    │  │                  │  │
                  │  │ scan            │  │ Sorted by dep   │  │
                  │  └────────┬────────┘  └────────┬─────────┘  │
                  │           │                     │            │
                  │           ▼                     ▼            │
                  │  nexus.initialize()   module.isEnabled=true  │
                  │                           │                 │
                  │                    Module.load()            │
                  │                    Module.initialize()      │
                  │                           │                 │
                  │                    module.nexus              │
                  │                    .initialize()            │
                  └──────────────────────────────────────────────┘

                  ┌──────────────────────┴──────────────────────┐
                  │               ModuleHook.OnDestroy()        │
                  │                                              │
                  │  ┌─────────────────┐  ┌──────────────────┐  │
                  │  │ Modules reverse  │  │ Core nexus       │  │
                  │  │ shutdown        │  │ shutdown          │  │
                  │  └────────┬────────┘  └────────┬─────────┘  │
                  │           │                     │            │
                  │           ▼                     ▼            │
                  │  nexus.shutdown()    nexus.shutdown()       │
                  │                           │                 │
                  │  Unhook AssemblyResolve                    │
                  │  Unhook TypeResolve                         │
                  └──────────────────────────────────────────────┘

Plugin Framework Integration

The module system provides three deliberate extension points for plugin frameworks:

  1. Assembly resolution events (PreVanillaAssemblyResolve, PreVanillaAssemblyResolvePostRedirects, PostVanillaAssemblyResolve) — allow frameworks to intercept dependency resolution before or after the vanilla pipeline.

  2. Assembly discovery — the DiscoveredAssemblies() step can be supplemented with the -NoVanillaAssemblySearch flag if a framework wants complete control over DLL resolution.

  3. The IModuleNexus interface — any framework can define its own IModuleNexus class in a module assembly and use its initialize() to set up framework-level hooks.

The Load_As_Byte_Array flag on ModuleAssembly exists specifically to support self-updating plugin frameworks. When a framework distributes updates via workshop content, the old DLL must be replaceable while the game is running. Byte-array loading releases the file handle, allowing the update to overwrite the file.

Module System Thread Safety

The module system is not thread-safe. All module loading, initialization, and shutdown occurs on the main Unity thread. The ModuleHook.Awake, ModuleHook.Start, and ModuleHook.OnDestroy callbacks are all called by Unity on the main thread. The Module.isEnabled setter, which triggers assembly loading and nexus initialization, is also designed for main-thread use.

The AssemblyResolve callback, however, can be called on any thread. The resolve handler is thread-safe because:

  • nameToPath is populated during Module.Register() (main thread, before any module loading)
  • nameToAssembly is populated during resolveAssemblyName (called from AssemblyResolve which can be any thread)
  • discoveredNameToPath is populated during DiscoverAssemblies() (main thread, before any resolution requests)

Module Architecture Best Practices

When designing a module for Unturned, the following patterns are recommended based on the SDK source:

  1. Single IModuleNexus per module: Each module should have exactly one class implementing IModuleNexus. Having multiple entry points makes the initialization order unpredictable.

  2. Explicit assembly list: List all required DLLs in the Assemblies array. The auto-discovery fallback (DiscoveredAssemblies) is intended for framework transitive dependencies, not for primary module assemblies.

  3. Load_As_Byte_Array for updatable modules: If the module or its dependencies may need to be updated while the game is running (workshop content updates), set Load_As_Byte_Array: true to avoid file locking.

  4. Version your dependencies: Specify minimum version numbers in Dependencies entries. The version comparison uses Version_Internal which compares numerically, not as strings.

  5. Use Server or Client role for platform-specific assemblies: A module with a server-only assembly and a client-only assembly should use two separate ModuleAssembly entries with different roles.

  6. Dependency order: Modules are sorted by their dependency graph before initialization. If module A depends on module B, B is guaranteed to be initialized before A and shut down after A.

Module Version Encoding

Module versions are encoded using IP-format conversion:

csharp
config.Version_Internal = Parser.getUInt32FromIP(config.Version);

This converts a dotted decimal string (e.g., "1.2.3.4") into a uint32 by treating each octet as a byte:

"0.0.0.0"   → 0x00000000 → 0
"1.0.0.0"   → 0x01000000 → 16777216
"1.2.0.0"   → 0x01020000 → 16908288
"1.2.3.0"   → 0x01020300 → 16909056
"1.2.3.4"   → 0x01020304 → 16909060
"255.255.255.255" → 0xFFFFFFFF → 4294967295

The dependency version check uses >= comparison on the uint32 values, meaning version 1.2.3.4 satisfies a dependency requirement of 1.2.3.0 but not 1.2.4.0. Version 2.0.0.0 satisfies 1.9.9.9 because 0x02000000 ≥ 0x01090909.

The conversion only handles four-octet version strings. Modules using non-standard version strings (e.g., "1.0-beta") will fail to parse and result in Version_Internal = 0, which means they will not satisfy any minimum version dependency.

Module Directory Structure Conventions

The standard directory structure for a module:

Modules/
└── MyModule/
    ├── MyModule.module          ← Required: Module configuration
    ├── MyModule.dll             ← Required: Module assembly
    ├── MyModule.pdb             ← Optional: Debug symbols
    └── Dependencies/
        └── SomeLib.dll          ← Optional: Dependency assemblies

The .module file path references are relative to the directory containing the .module file:

json
{
    "Name": "MyModule",
    "Version": "1.0.0.0",
    "IsEnabled": true,
    "Assemblies": [
        {
            "Path": "MyModule.dll",
            "Role": "Both"
        }
    ],
    "Dependencies": [
        {
            "Name": "SomeOtherModule",
            "Version": "2.0.0.0"
        }
    ]
}

The ModuleConfig.DirectoryPath is set by ModuleHook.findModules() to the directory containing the .module file. Assembly paths are concatenated with this directory: config.DirectoryPath + assembly.Path.

The path validation in sortModules checks for .. directory escape attacks:

csharp
bool escapeDirectory = false;
for (int charIndex = 1; charIndex < assembly.Path.Length; charIndex++)
{
    if (assembly.Path[charIndex] == '.' && assembly.Path[charIndex - 1] == '.')
    {
        escapeDirectory = true;
        break;
    }
}

if (escapeDirectory)
{
    hasAssemblies = false;  // Discard module
}

This check prevents modules from loading assemblies outside their directory tree.

Debugging Module Loading Problems

When a module fails to load or initialize, the following diagnostic information is available:

  1. Module file parse errors — Caught in findModules():

    csharp
    catch (Exception exception)
    {
        UnturnedLog.exception(exception, $"Caught exception parsing .module file: {moduleFile}");
    }
  2. Assembly load errors — Caught in Module.load():

    csharp
    catch (ReflectionTypeLoadException exception)
    {
        assemblyTypes = exception.Types;  // Partial type list
    }
  3. Missing dependency errors — Logged in sortModules():

    csharp
    UnturnedLog.warn($"Discard module \"{config.Name}\" because dependency \"{dependency.Name}\" wasn't met");
  4. Missing assembly errors — Logged in sortModules():

    csharp
    UnturnedLog.warn($"Module \"{config.Name}\" missing assembly: {assemblyPath}");
  5. Nexus initialization errors — Caught in Module.initialize():

    csharp
    catch (Exception ex)
    {
        UnturnedLog.error($"Caught exception while initializing module \"{config.Name}\" entry point \"{type.Name}\":");
        UnturnedLog.exception(ex);
    }

All diagnostic output goes through UnturnedLog, which writes to both the Unity console and the game's log file.

FAQ

Can a module load assemblies from outside the Modules directory?

Yes, but only through the -ModulesPath command-line flag, which redirects the entire module discovery root to a different directory. There is no per-module mechanism for loading from arbitrary paths. The AssemblyResolve pipeline can resolve individual DLLs by name from any discovered path.

What happens if two modules have the same name?

Module names are used as unique identifiers in the dependency system. If two modules have the same Name field, only the first one encountered during discovery is kept. The duplicate is discarded with a log warning.

Can a module be enabled/disabled without restarting the game?

Yes. The ModuleHook.toggleModuleEnabled(int index) method toggles the IsEnabled flag in the .module file, then calls updateModuleEnabled which sets module.isEnabled = true/false. The enablement cascades to dependent modules. This is used by the in-game mod configuration UI.

How does the module system interact with third-party anti-cheat?

When third-party anti-cheat is active on the client, shouldLoadModules returns false, which prevents any module assemblies from being loaded. The ModuleHook.loadModules method checks this flag and skips all module loading if anti-cheat is enabled. On the dedicated server, modules are always loaded regardless of anti-cheat status.

Can a module reference the core game assembly?

Yes. The core assembly (Assembly-CSharp.dll) is already loaded when modules are initialized. Module assemblies can reference any public type in the core assembly. The AssemblyResolve handler does not need to resolve the core assembly because it is already in the AppDomain.

What happens if a module's IModuleNexus.initialize() throws an exception?

The exception is caught by Module.initialize():

csharp
catch (Exception ex)
{
    UnturnedLog.error($"Caught exception while initializing module \"{config.Name}\" entry point \"{type.Name}\":");
    UnturnedLog.exception(ex);
}

The module continues loading (other nexus classes in the same assembly are still initialized). The failed nexus is not added to the nexii list, so shutdown() will not be called on it.

Does the module system support hot-reloading?

No. Modules are loaded once during startup and shut down during game exit. There is no mechanism to unload and reload assemblies at runtime without restarting the game. The Load_As_Byte_Array flag exists for self-updating plugin frameworks that replace DLLs between restarts, not for hot-reloading within a session.

How do I debug module loading issues?

Use these command-line flags:

FlagInformation logged
-LogAssemblyResolveEvery assembly resolution request and its result
-NoVanillaAssemblySearchDisable auto-discovery to test explicit assembly lists

The module system logs all discovery, sorting, and initialization steps via UnturnedLog.info. Check the game's log file for messages starting with:

  • "Looking for module files in:"
  • "Discovered assembly..."
  • "Found N module(s):"
  • "Discard module ... because ..."
  • "Initialized module ..."

Appendix: Module System Class Reference

ClassNamespaceSource fileRole
ModuleConfigSDG.Framework.ModulesModuleConfig.csParsed .module config data
ModuleAssemblySDG.Framework.ModulesModuleAssembly.csSingle assembly entry
ModuleDependencySDG.Framework.ModulesModuleDependency.csDependency requirement
ModuleSDG.Framework.ModulesModule.csRuntime module wrapper
ModuleHookSDG.Framework.ModulesModuleHook.csMonoBehaviour orchestrator
ModuleComparerSDG.Framework.ModulesModuleComparer.csDependency-based sorter
IModuleNexusSDG.Framework.ModulesIModuleNexus.csLifecycle interface
EModuleRoleSDG.Framework.ModulesEModuleRole.csAssembly role enum
EModuleStatusSDG.Framework.ModulesEModuleStatus.csModule lifecycle state

Document history

VersionDateAuthorNotes
1.02026-07-2757 StudiosInitial publication. Module system architecture, assembly resolution, dependency ordering, IModuleNexus lifecycle.