Skip to content
You're reading the docs for Railroader 0.6 (the MoW Update), currently available on the Experimental branch.

DLL Mods

Most mods are data (Definitions, asset bundles) and sandboxed Relay script. A DLL mod is different: it ships a compiled .NET assembly that Railroader loads directly into the game process. This gives a mod the full run of the game’s compiled code, rather than the curated surface Relay exposes.

That power is also the risk:

Because of that, DLL loading is off by default and marked experimental. This page covers what mod authors need to know to build one; if you only need data and scripting, prefer a Relay mod instead — it’s safer, simpler, and survives game updates better.

Railroader provides the mechanism to load DLL mods — so the community doesn’t need a third-party mod loader — plus the small Railroader.Modding contract described below. That’s the whole supported surface, and this page documents all of it.

Everything else a DLL mod does — reaching into the rest of the game’s compiled code — is unsupported. That code is not a documented, stable API: type and method signatures can change in any update without notice or a migration path, and a mod that depends on them may break. Declaring an rr/game version floor (below) is how you keep such a mod from loading against a game version it wasn’t built for.

DLL loading is controlled by a single setting:

Preferences → Features → Experimental → Allow Unsafe DLL Mods

It is off by default and takes effect on restart. While it is off, any pack that ships an assembly is treated like a mod with an unmet dependency — it does not load at all, and it appears in Manage Mods with the reason given. Turn the setting on (and restart) to load DLL mods.

A pack declares the assemblies it ships in Info.json, with the assemblies field:

{
"identifier": "acme/locos",
"version": "1.2.0",
"name": "Acme Locomotives",
"assemblies": ["AcmeLocos.dll"],
"dependencies": {
"required": ["rr/game >= 0.6.0"]
}
}
  • assemblies is a list of paths relative to the pack root. Absent or empty means the pack ships no code (every pack today).
  • Always declare an rr/game version floor. A DLL mod is compiled against a specific game version; the floor is what lets the game refuse to load a mod built for an incompatible release instead of crashing. See dependencies.

The game discovers your mod by reflecting over each declared assembly for a public, non-abstract class that implements IModEntryPoint, then constructs it via its parameterless constructor. There is no registration call — implementing the interface is the whole contract.

using Railroader.Modding;
public sealed class ModEntryPoint : IModEntryPoint, IWorldLifecycle
{
public void OnModLoad(IModContext context)
{
context.Log.Info($"Loaded from {context.ModDirectory}");
}
public void OnModUnload() { }
public void OnWorldLoad() { }
public void OnWorldUnload() { }
}

IModEntryPoint is required. IWorldLifecycle is optional — implement it as well if you need the per-session hooks.

IModContext (passed to OnModLoad) carries the mod’s identifier, its directory, a logger already tagged with your mod, and a way to check for other mods:

public interface IModContext
{
string ModIdentifier { get; }
string ModDirectory { get; }
IModLogger Log { get; }
// True if a mod matching the expression is loaded.
bool CheckDependency(string dependency);
}

CheckDependency takes the same expression an Info.json dependency uses — an identifier, optionally with version constraints — and returns whether a matching mod is currently loaded. Use it to light up integration with an optional mod only when it’s present:

if (context.CheckDependency("acme/signals >= 2.0.0"))
{
// ... integrate with the Acme Signals mod
}

A malformed expression throws; a missing or version-mismatched mod returns false. Required dependencies are better declared in Info.json (the game refuses to load your mod without them) — CheckDependency is for optional ones you handle at runtime.

Four hooks, named to mirror the Relay lifecycle so mod authors only need to learn one vocabulary:

HookWhen it fires
OnModLoadOnce, at game boot, when the mod is loaded. Receives the IModContext.
OnModUnloadWhen the mod set changes or the game shuts down.
OnWorldLoadEach time a game session (world) loads.
OnWorldUnloadWhen the session unloads (returning to the menu).

OnWorldLoad and OnWorldUnload come from IWorldLifecycle and only fire if your entry point implements it.

A loaded assembly is never unloaded — the runtime cannot reclaim it. Cleanup is cooperative: in OnModUnload (and OnWorldUnload for per-session work), undo what you set up — unsubscribe events, stop coroutines, destroy objects. A well-behaved mod that does this is fully disabled from the player’s point of view.

Two things follow from assemblies never unloading:

  • Static state survives a disable/re-enable. If you toggle a mod off and back on, a fresh entry point is constructed but your static fields keep their old values. Reset them in OnModLoad rather than assuming a clean slate.
  • Changing a DLL requires a restart. The game cannot load a second, different build of the same assembly into one session. Keep editing in place while you develop, and restart the game each time you want to test a rebuild.

A mod is an ordinary .NET class library. Target net472 with C# 9 to match what Unity 6000.3 emits, and reference:

  • Railroader.Modding.dll — the modding contract (IModEntryPoint, IModContext, IWorldLifecycle, IModLogger). A lifecycle-only mod needs nothing else from the game. It is a small, stable assembly with no other dependencies.
  • The game and Unity assemblies, if your mod needs more than the contract provides. As noted above, this is the unsupported surface: the assemblies exist, but what’s inside them is undocumented and subject to change.

To distribute a mod, place its .dll beside the pack’s Info.json and list it under assemblies.

A DLL mod is matched across peers by identifier and version like any other pack; the game trusts the manifest rather than verifying that every peer runs identical code. As with any mod, use world.isHost-style checks for logic that must run exactly once across a session — the lifecycle hooks fire on every peer that has the mod. See Main.rl → Multiplayer for the same reasoning applied to scripts.