Skip to content

Advanced: Multi-Loader Template ​

ADOFAIMod.MultiLoader is the organization's advanced Mod template — a single project supporting Unity Mod Manager / MelonLoader / BepInEx / Doorstop simultaneously.

Differences from the Basic Template ​

ADOFAIModTemplateADOFAIMod.MultiLoader
LoadersUnityModManagerUMM / MelonLoader / BepInEx / Doorstop
Use caseQuick startCovering multiple loader ecosystems
ComplexityLowMedium

Project Structure ​

ProjectRoot/
├── core/                                -- Shared Mod logic
│   ├── AdofaiMod.MultiLoader.Core.csproj
│   ├── IHandler.cs                      -- Loader abstraction interface
│   ├── Main.cs                          -- Entry point (Initialize)
│   ├── Settings.cs                      -- Serializable settings
│   ├── Patches.cs                       -- Harmony patches
│   └── ResourceLoader.cs                -- File loading utilities
├── loaders/
│   ├── umm/                             -- UMM adapter
│   ├── melon/                           -- MelonLoader adapter
│   ├── bepinex/                         -- BepInEx adapter
│   └── doorstop/                        -- Doorstop standalone adapter
├── scripts/
│   ├── pack.csx                         -- Release package packer
│   ├── pack.cmd / pack.ps1 / pack.sh    -- Per-platform pack scripts
├── Resources/                           -- Mod resources (text, images, etc.)
├── ADOFAIMod.targets                    -- MSBuild targets (copy, deploy)
└── Info.json                            -- UMM manifest

Architecture ​

Each loader has its own adapter project that references the shared core/ project. The IHandler interface abstracts logging, settings, and lifecycle events, making the core Mod code independent of any specific loader:

Loader project (e.g. loaders/umm/)
  └── implements IHandler
      └── calls Main.Initialize(handler)
          └── core/ code runs loader-agnostic

How Each Loader Loads Mods ​

The four loaders discover and start Mods in completely different ways. The template has separate "entry" files for each loader — this section covers each one.

UMM: Convention Directory + Static Method ​

UMM scans the Mods/ directory, reads Info.json's EntryMethod, and uses reflection to call the static Load method:

Mods/{ModName}/Info.json  →  EntryMethod: ...UmmEntry.Load

Entry loaders/umm/UmmEntry.cs:

csharp
public static class UmmEntry
{
    public static bool Load(UnityModManager.ModEntry modEntry)
    {
        var handler = new UmmHandler(modEntry);
        return Main.Initialize(handler);
    }
}

UmmHandler forwards UMM's callbacks (OnToggle / OnGUI / OnSaveGUI / OnUpdate) to IHandler events in its constructor.

BepInEx: Inherit BaseUnityPlugin + Attributes ​

BepInEx scans BepInEx/plugins/, identifies Mods via the [BepInPlugin] attribute in the assembly, then instantiates the plugin class inheriting BaseUnityPlugin:

csharp
[BepInPlugin(ModId, "AdofaiMod.MultiLoader", "1.0.0")]
[BepInProcess("A Dance of Fire and Ice.exe")]
public class AdofaiBepInPlugin : BaseUnityPlugin
{
    private BepInHandler? _handler;

    private void Awake()
    {
        _handler = new BepInHandler(Logger);
        Main.Initialize(_handler);
        _handler.TriggerToggle(true);   // Equivalent to UMM's enable
    }

    private void Update() => _handler?.TriggerUpdate(UnityEngine.Time.deltaTime);
    private void OnGUI() => _handler?.TriggerGUI();
}

Key points:

  • [BepInPlugin(GUID, Name, Version)] is BepInEx's metadata source (see "Modifying Mod Metadata" below)
  • [BepInProcess("...exe")] limits which process the plugin works in
  • Awake() is equivalent to UMM's OnToggle(true); Unity's Update() / OnGUI() lifecycle is forwarded to core

MelonLoader: Inherit MelonMod + Assembly-Level Attributes (Most Complex) ​

MelonLoader loads from the Mods/ directory, but it identifies Mods without reading any JSON — instead it looks for two assembly-level attributes in the assembly:

csharp
[assembly: MelonInfo(typeof(AdofaiMelonMod), "AdofaiMod.MultiLoader", "1.0.0", "Your Name")]
[assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]

public class AdofaiMelonMod : MelonMod
{
    public override void OnInitializeMelon()
    {
        _handler = new MelonHandler(this);
        Main.Initialize(_handler);
        _handler.TriggerToggle(true);
    }

    public override void OnUpdate() => _handler?.TriggerUpdate(UnityEngine.Time.deltaTime);
    public override void OnGUI() => _handler?.TriggerGUI();
}

MelonLoader's biggest differences from other loaders:

  • Assembly-level attributes ([assembly: ...]) are written outside the class, belonging to the entire DLL, not a specific class
  • [assembly: MelonInfo(type, name, version, author)] — metadata, startup class, and version are all here
  • [assembly: MelonGame("developer", "game name")] — declares which game this is for; wrong names cause Melon to refuse loading
  • MelonMod's lifecycle methods (OnInitializeMelon / OnUpdate / OnGUI) are called by MelonLoader
  • MelonHandler's logging goes through MelonLogger, and settings are stored in UserData/ (working directory) — different paths from UMM/BepInEx
  • MelonLoader bundles HarmonyX (not original Harmony 2), so patch code must follow HarmonyX Compatibility

MelonLoader Directory Structure (Mods / Plugins / UserLibs) ​

MelonLoader creates multiple directories under the game root, each with its own purpose — don't mix them up:

<game root>/
├── Mods/            ← MelonMod (game mods) go here
│   └── {ModName}.dll
├── Plugins/         ← MelonPlugin (plugins) go here
│   └── {PluginName}.dll
├── UserLibs/        ← Third-party DLLs your Mod depends on go here
│   └── Newtonsoft.Json.dll
├── UserData/        ← Config files (e.g. the template's settings.json)
├── Libraries/       ← MelonLoader's official libraries (don't touch)
└── MelonLoader/     ← MelonLoader core, Logs/ directory
DirectoryWhat goes thereWhen loaded
Mods/DLLs with MelonMod subclassesGame mods: loaded on game start, unloaded on game exit
Plugins/DLLs with MelonPlugin subclassesPlugins: loaded earlier than Mods, independent lifecycle
UserLibs/Third-party dependency libraries (non-game, non-Melon)Auto-resolved at startup, available to all Mods/Plugins
Libraries/MelonLoader official librariesManaged internally by MelonLoader
UserData/Runtime data, configsYour code reads/writes

Difference between MelonMod and MelonPlugin (most confusing for beginners):

MelonModMelonPlugin
InheritsMelonModMelonPlugin
LocationMods/Plugins/
SemanticsA game mod, starts/stops with the gameA framework-level plugin, extends MelonLoader itself
Common usesModify gameplay, UI, add featuresProvide shared libraries, register tools, handle framework events

Our MultiLoader template is a "game mod", so it inherits MelonMod and goes in Mods/. Only consider MelonPlugin when you need to provide cross-Mod shared libraries or hook MelonLoader's own lifecycle.

UserLibs is a "shared repository" for dependencies: if multiple Melons depend on the same third-party DLL (like Newtonsoft.Json), put one copy in UserLibs/ and all Mods/Plugins can automatically reference it — no need for each Mod to bundle its own copy. This is why the Melon loader's dependencies in the multi-loader template follow the UserLibs approach.

Where does the template's Melon loader deploy to?

When building with -p:Loader=ML, the output is deployed to GameDir/Mods/ (see Build & Deploy). If dependencies need to be shared, additionally copy third-party DLLs to GameDir/UserLibs/.

Doorstop: No Loader, Self-Starting ​

Doorstop is not a "Mod loader" — it's a native proxy: it first loads the assembly and entry point specified in doorstop_config.ini, then we manually attach a component to the Unity scene:

csharp
public static class DoorstopEntry
{
    public static void EntryPoint()
    {
        var go = new GameObject("AdofaiMod_Doorstop");
        Object.DontDestroyOnLoad(go);
        go.AddComponent<DoorstopComponent>();
    }
}

doorstop_config.ini handles "finding the right entry point":

ini
[General]
assembly=AdofaiMod.MultiLoader.Loader.Doorstop.dll
entry_point=AdofaiMod.MultiLoader.Loaders.DoorstopEntry.EntryPoint
  • No "enable/disable" concept — loading takes effect immediately (Awake calls TriggerToggle(true) directly)
  • Logging goes directly through UnityEngine.Debug.Log

How the Template Handles It ​

The template's core approach is the IHandler abstraction: each loader writes an XxxHandler implementing IHandler, and core code only knows IHandler — completely unaware of which loader it's running on.

IHandler MemberUMM SourceBepInEx SourceMelonLoader SourceDoorstop Source
ModId / ModVersionInfo.json[BepInPlugin]MelonInfoHardcoded constant
ModPathModEntry.PathPaths.PluginPathCurrent working directoryDLL's directory
Log / Warning / ErrorLoggerManualLogSourceMelonLoggerDebug.Log*
Settings I/Oentry.Path/Config/*.jsonPaths.ConfigPath/*.jsonUserData/*.jsonConfig/*.json next to DLL
Lifecycle eventsOnToggle/OnGUI/OnSaveGUI/OnUpdateAwake/Update/OnGUIOnInitializeMelon/OnUpdate/OnGUIAwake/Update/OnGUI

The entry flow is always the same: loader → new XxxHandler → Main.Initialize(handler) → forward lifecycle → core written once. When adding a new feature, only modify core/ and Patches.cs — all four loaders get it.

Settings are all stored as JSON: IHandler.LoadSettings<T>() / SaveSettings<T>() are implemented by each Handler using its own loader's path, and core's Settings class is defined once.

Modifying Mod Metadata ​

Info.json is only read by UMM. BepInEx, MelonLoader, and Doorstop don't recognize it. So "changing the name, version, or author" must be handled per loader:

MetadataUMMBepInExMelonLoader
Name / Version / AuthorInfo.json[BepInPlugin] params[assembly: MelonInfo] params
Target process / gameNone[BepInProcess][assembly: MelonGame]
Project versioncsproj <Version>csproj <Version>csproj <Version>

The template's "one change" magic

When creating a project with dotnet new adofaiml -n MyMod -a "YourName" -v 2.0.0, the template automatically replaces placeholders (Your Name, 1.0.0) across Info.json, [BepInPlugin], [assembly: MelonInfo], and csproj <Version> — so setting the name and version at creation time is the easiest approach.

After modifying metadata, verify separately:

  • UMM: check the name / version in the Mod list
  • BepInEx: check BepInEx/LogOutput.log or the plugins directory
  • MelonLoader: check MelonLoader's loading log (at MelonLoader/Latest.log in the game directory)

Prerequisites ​

  • .NET SDK 6.0 or higher
  • Steam version of ADOFAI

TIP

Loader dependencies (MelonLoader, BepInEx, UnityModManager, Harmony) are bundled in the template's lib/ModManager/ — no need to install any loader for building. You only need the game's GameExePath for referencing Unity engine and game assemblies.

Installing the Template ​

bash
# Install from a local repo copy (recommended)
dotnet new install path/to/ADOFAIMod.MultiLoader

# Or install from a packaged NuGet package
dotnet pack path/to/AdofaiMod.MultiLoader.Template.csproj -o path/to/dist
dotnet new install path/to/dist/AdofaiMod.MultiLoader.1.0.0.nupkg

After installation, the template is named adofaiml. To uninstall:

bash
dotnet new uninstall ADOFAIMod.MultiLoader

Visual Studio / JetBrains Rider ​

After installing the template, create a new project and search for ADOFAI or adofaiml — the wizard provides checkboxes for each loader.

Creating a Project ​

Command Line ​

bash
# Include all four loaders (default)
dotnet new adofaiml -n MyMod

# Select loaders (turn off the ones you don't need)
dotnet new adofaiml -n MyMod --bepinex false --doorstop false

# Specify author and description
dotnet new adofaiml -n MyMod -a "YourName" -d "My first ADOFAI mod"

Parameter Reference ​

ShortLongDescription
-n--nameProject name (i.e. Mod name)
-a--authorAuthor name
-d--descriptionMod description
-v--versionInitial version (default 1.0.0)
-um--ummInclude UMM loader (default true)
-ml--melonInclude MelonLoader (default true)
-bx--bepinexInclude BepInEx (default true)
-ds--doorstopInclude Doorstop (default true)

Pointing to Your Game (.env) ​

The game path is read from the .env file in the project root (git-ignored), so local install paths never enter version control:

bash
# Copy the example and fill in your path
cp .env.example .env

# .env
ADOFAI_GAME_PATH=C:\Games\ADOFAI\ADanceOfFireAndIce.exe   # Default (all loaders)
ADOFAI_GAME_PATH_UMM=...\ADanceOfFireAndIce.exe            # UMM only
ADOFAI_GAME_PATH_ML=...\ADanceOfFireAndIce.exe             # MelonLoader only
ADOFAI_GAME_PATH_BEPINEX=...\ADanceOfFireAndIce.exe        # BepInEx only
ADOFAI_GAME_PATH_DOORSTOP=...\ADanceOfFireAndIce.exe       # Doorstop only

Each loader falls back to ADOFAI_GAME_PATH when its own key is empty — a single default value suffices.

Path Resolution Order ​

Each loader tries in order: its own .env key → ADOFAI_GAME_PATH → ADOFAI_GAME_PATH_<LOADER> environment variable → -p:GameExePath=.... This lets different loaders point to different game installs (e.g. different ADOFAI versions).

Build and Deploy ​

bash
# Build and deploy to the specified loader's game directory, then launch the game
dotnet build -p:Loader=UMM          # UMM      → GameDir/Mods/{ModName}/
dotnet build -p:Loader=ML           # Melon    → GameDir/Mods/
dotnet build -p:Loader=BepInEx      # BepInEx  → GameDir/BepInEx/plugins/{ModName}/
dotnet build -p:Loader=Doorstop     # Doorstop → GameDir/

# Build and deploy, but don't launch the game
dotnet build -p:Loader=UMM -p:AutoLaunchGame=false

# Release: build only (no deploy/launch), output flat to out/
dotnet build -c Release

Per-Loader Deploy Directories ​

LoaderTarget directory
UMMGameDir/Mods/{ModName}/
MLGameDir/Mods/
BepInExGameDir/BepInEx/plugins/{ModName}/
DoorstopGameDir/ (root, alongside doorstop_config.ini)

Output Directory ​

Regardless of which loader you build, out/ collects all artifacts in a flat layout:

out/
├── {ModName}.Core.dll
├── {ModName}.Loader.UMM.dll        (if UMM was built)
├── {ModName}.Loader.Melon.dll      (if Melon was built)
├── {ModName}.Loader.BepInEx.dll    (if BepInEx was built)
├── {ModName}.Loader.Doorstop.dll   (if Doorstop was built)
├── Info.json                        (only when UMM is built)
├── doorstop_config.ini              (only when Doorstop is built)
└── Resources/

Creating Release Packages ​

Requires dotnet script (install: dotnet tool install -g dotnet-script).

bash
# First build Release artifacts, then pack
scripts/pack.cmd      # Windows
scripts/pack.sh       # Linux/macOS
scripts/pack.ps1      # PowerShell

Generates per-loader ZIPs in dist/:

FileStructure (relative to game root)
{ModName}_umm.zipMods/{ModName}/ flat
{ModName}_melon.zipMods/ flat
{ModName}_bepinex.zipBepInEx/plugins/{ModName}/ flat
{ModName}_doorstop.zipRoot flat (includes doorstop_config.ini)

HarmonyX Compatibility ​

The multi-loader ecosystem has two Harmony versions: Harmony 2 (commonly used by UMM) and HarmonyX (bundled with MelonLoader / BepInEx). For a single Mod to run on all four loaders, patch code must work on both.

Principle: Only use shared APIs

HarmonyX added some APIs on top of Harmony 2, and also removed some APIs (e.g. HarmonyCategory). Therefore, multi-loader Mod patch code should only use the API subset shared between Harmony 2 and HarmonyX.

Safe Usage ​

csharp
// ✅ Both support: regular patches
[HarmonyPatch(typeof(SomeGameClass), nameof(SomeGameClass.SomeMethod))]
public static class MyPatch
{
    public static void Prefix() { /* ... */ }
    public static void Postfix() { /* ... */ }
}

// ✅ Both support: PatchAll / UnpatchAll
harmony.PatchAll(Assembly.GetExecutingAssembly());
harmony.UnpatchAll(entry.Info.Id);

// ✅ Both support: magic parameters (__instance / __result / __state / ___field)
public static void Postfix(ref float __result) { /* ... */ }

Patterns to Avoid ​

  • HarmonyCategory / category-related APIs: removed in HarmonyX, using them causes compilation failure
  • HarmonyX-specific APIs (e.g. HarmonyX-exclusive patch options): don't exist in Harmony 2 (UMM)
  • JIT-detail-dependent Transpilers: the two Harmony versions have subtle IL generation differences — Transpiler matching should be as loose as possible (see Transpiler Practice)

How to Verify ​

  1. Run on both UMM (Harmony 2) and MelonLoader / BepInEx (HarmonyX)
  2. Use GetPatchInfo to confirm patches are applied correctly (see Patch Lifecycle)
  3. Build for each loader separately in CI — see GitHub Actions Auto Build

The Harmony version bundled in the template's lib/ModManager/ is the compatibility target — no need to decide the version yourself.

What You Learned ​

  • The multi-loader template's directory structure and architecture (core + per-loader adapters)
  • The IHandler abstraction decouples core code from loaders
  • Creating projects with dotnet new adofaiml and configuring game paths via .env
  • How the four loaders load Mods: UMM (Info.json + static method), BepInEx (BaseUnityPlugin + [BepInPlugin]), MelonLoader (MelonMod + assembly-level [MelonInfo]/[MelonGame]), Doorstop (doorstop_config.ini + manual component attachment)
  • MelonLoader directory structure: Mods/ (MelonMod), Plugins/ (MelonPlugin), UserLibs/ (shared dependency libraries), UserData/ (configs), Libraries/ (official libraries)
  • Build/deploy methods and release packaging for each loader
  • Mod metadata differences: Info.json is only read by UMM; BepInEx/MelonLoader use attributes
  • HarmonyX compatibility: only use APIs shared between Harmony 2 and HarmonyX

Next Step ​

Extend capabilities with the Mod API → Advanced: Mod API

An organization that researches and expands the functions of ADOFAI