Railroads Online Modding Wiki

Documentation for modding Railroads Online using RROML.

Caution: RROML and mods may cause unexpected bugs or issues in multiplayer. Feel free to report said issues in the RROML GitHub repository Issues section. Please be sure to back up your saves before modding in multiplayer.
RROML stands for Railroads Online Mod Loader. It provides the foundation for loading community-made modifications into the game.

This wiki is meant to teach anyone how to install mods, as well as how to make them. Keep in mind, RRO modding is in its infancy and not much is known on game mechanics and anything else.

Use the sidebar to jump between sections, or search above to filter.

Legend

This is what all shorthands mean or stand for here. They will be used throughout every mention of RROML and RRO modding regardless of repository or community.

RROML
Railroads Online Mod Loader
RO / RRO
Railroads Online
QoL
Quality of Life
ROMC / RROMC
Railroads Online Modding Community
Repo
Repository
PR(s)
Pull Request(s)

Contributing

If you find something out about RRO and modding it, please contribute it to this wiki via Pull Requests, or if you do not want to do that or don't know how, just DM me (KerbalMissile) or ping me in the modding Discord server.

Join our Discord server for help or questions!

Manual Installation

This is how you install mods and the mod loader manually, step by step.

  1. Download the latest release (right side) of RROML or grab it directly from Releases
  2. Extract / unzip the .zip file
  3. Put everything in it, inside the Railroads Online directory / path
  4. If you did that correctly, you should have a RROML folder inside your Railroads Online folder
  5. Create a Mods folder
  6. Download any mod you want
  7. Unzip the mod
  8. Put the Mods folder in the unzipped mod folder, into your Railroads Online folder (it will auto-merge with the existing folder)
  9. Launch the game and everything should work.

Here is a repo with a few mods you can start out with.

Example folder layout

Railroads Online/
└── Mods/
    └── YourMod/

Automatic Installation

This is how you can install the mod loader and mods automatically.

  1. Install Moxi (a mod manager) via the latest release's installer (The .exe and NOT THE SOURCE CODE)
  2. Launch Moxi
  3. Find Railroads Online either in Mod Database (top right) or scrolling down to the "Detected Supported Games" section, and clicking on Railroads Online
  4. Click install on any mod and it will prompt you to install the mod loader
  5. Click install on the mod loader when prompted, then it will install it and the mod you wanted for you
  6. Click play straight from Moxi or go to Steam and click play, and RRO should boot up with your selected mods

Join our Discord server for help or questions!

Basic Information

This is what has been found out about modding RRO and how to actually make mods. Meant for people who already know C# or Lua, this is not a programming tutorial. Ask on the Discord if you get stuck (link at the bottom).

RROML now supports two mod types: C# (.dll) and Lua (.lua). Both load the same way, both go in Mods/, both can use mod.json and dependencies. C# is for heavy lifting and memory work, Lua is for fast iteration and simple tweaks. Pick whichever fits the mod.

This section covers the normal-mod stuff, config edits, hotkeys, gameplay tweaks. Memory reading and engine internals are split off into Advanced because it was drowning out everything else. Most people do not need that page.

Modding Information (C#)

  • Mods are C#, compiled to a .dll
  • Your main class implements IRromlMod from RROML.Abstractions. RROML scans your dll for any non-abstract class implementing it and instantiates it. That is the whole discovery mechanism
  • Reference RROML.Abstractions.dll to compile, it is tiny, lives in RROML/RROML after install
  • Usings you will basically always want:
using System;
using System.IO;
using RROML.Abstractions;

IRromlMod

public interface IRromlMod
{
    string Id { get; }
    string Name { get; }
    string Version { get; }
    void OnLoad(IModContext context);
}

Id should be short and stable, no spaces, other mods reference it as a dependency and it is what goes in DisabledMods. Do not change it later, you will orphan anyone's saved config for your mod. Name is just for logs. Version is a free string, RROML does not parse it. OnLoad runs once, that is your entry point.

IModContext

public interface IModContext
{
    string GameRootPath { get; }
    string LoaderPath { get; }
    string ModsPath { get; }
    string UserGameConfigRootPath { get; }
    IModLogger Logger { get; }
    string GetConfigPath(string fileName);
    string GetUserGameConfigPath(string fileName);
}

GameRootPath/LoaderPath/ModsPath are the folders you would expect. The two you will actually use:

  • GetConfigPath(fileName) - path inside RROML/Configs/<YourModId>/, created automatically. Your mod's own settings/state go here, do not hand-roll a path
  • GetUserGameConfigPath(fileName) - path inside the game's own config folder (Saved/Config/Windows in AppData). Use for editing the game's actual settings

IModLogger

public interface IModLogger
{
    void Info(string message);
    void Warn(string message);
    void Error(string message);
    void Error(string message, Exception exception);
}

All logging from every mod plus the loader itself lands in one file, RROML/Logs/rroml.log. Not per-mod. Prefix your messages or you will get lost in everyone else's lines. Timestamped and level-tagged automatically. Do not use Console.WriteLine, there is no console attached, it goes nowhere.

mod.json (C#)

Needs to sit next to your dll. Skip it and your mod loads anyway if it is the only dll in the folder, but no dependency resolution and nobody can disable it by name. Might make this a hard error eventually.

{
  "Id": "TimeWarpMod",
  "Name": "Time Warp Mod",
  "EntryDll": "TimeWarpMod.dll",
  "Version": "1.0.0",
  "Dependencies": []
}

EntryDll can be omitted if there is only one dll in the folder anyway. Dependencies is other mods' Ids that need to load first, RROML sorts by it, but a missing dependency only logs a warning, does not stop your mod from loading, so check for what you need yourself too. Lua mods use EntryLua instead, see Lua Layout.

Folder with a mod.json is the normal way to ship. A loose dll in Mods with no manifest also works if you really do not care about any of the above.

Example mod folder layout

Mods/
└── ExampleMod/
    ├── ExampleMod.dll
    └── ...

Loading order and disabling mods

Dependency order, topological sort. Cycle in the dependency graph just gets a warning, not a hard failure.

DisabledMods in RROML.config.json takes folder name, mod.json Id, or class Id/Name, all three work. Works for both C# and Lua. If everything a mod depends on is disabled, it gets skipped too.

RROML.config.json

Next to RROML.Core.dll. Written fresh with these defaults if missing:

{"Enabled":true,"VerboseLogging":true,"ShowOverlay":true,"OverlayPosition":"TopLeft","DisabledMods":[]}

Enabled is the master switch, false and nothing loads, RROML logs it is off and stops before scanning mods. Check this first if your mod "is not loading." VerboseLogging does nothing currently, it is declared but nothing reads it. ShowOverlay/OverlayPosition control the little boot-status box in the corner. DisabledMods is covered above.

Talking to the game

Most common thing a mod does: edit one of the game's ini files.

var settingsPath = context.GetUserGameConfigPath("GameUserSettings.ini");
var text = File.ReadAllText(settingsPath);
const string oldLine = "fBrightness=2.200000";
const string newLine = "fBrightness=2.000000";
if (text.Contains(oldLine))
{
    File.WriteAllText(settingsPath, text.Replace(oldLine, newLine));
}

Check the old line exists before writing, do not assume. Game updates change defaults, and if oldLine does not match you just silently do nothing instead of writing garbage. Same pattern works in Lua with io.open and rroml.getUserGameConfigPath.

Memory reads and time-scale stuff live on the Advanced page.

Hotkeys (C#)

No hotkey system built in, mods poll key state on their own background thread. TimeWarpMod's version:

using System.Runtime.InteropServices;
using System.Threading;

var thread = new Thread(delegate() { RunLoop(context); });
thread.IsBackground = true;
thread.Name = "YourModName";
thread.Start();

private static void RunLoop(IModContext context)
{
    var wasDown = false;
    while (true)
    {
        try
        {
            var isDown = IsKeyDown(0x70); // F1
            if (isDown && !wasDown)
            {
                // do the thing
            }
            wasDown = isDown;
        }
        catch (Exception exception)
        {
            context.Logger.Error("YourModName loop failed.", exception);
        }

        Thread.Sleep(50);
    }
}

private static bool IsKeyDown(int virtualKey)
{
    return (GetAsyncKeyState(virtualKey) & 0x8000) != 0;
}

[DllImport("user32.dll")]
private static extern short GetAsyncKeyState(int virtualKey);

The wasDown/isDown check matters, without it your action fires every tick the key is held instead of once per press. Also check window focus before acting on a hotkey, or it will fire while someone is alt-tabbed into Discord. In Lua, hotkeys are not yet exposed directly, poll via similar logic if you bridge to C# or use file watches.

Compiling (C#)

Plain .NET Framework class library. Build script uses csc.exe directly:

csc.exe /target:library /out:YourMod.dll /reference:RROML.Abstractions.dll YourMod.cs

JavaScriptSerializer for config needs System.Web.Extensions.dll too, this is the recommended way to do JSON since RROML itself uses it internally, so it is guaranteed available. .csproj + Visual Studio/Rider works exactly as well, csc.exe is just what the repo's build script happens to use.

(Random find: build-managed.bat references ExampleMod, ModsMenuMod, MenuPauseMod, RealRailroading, and BackgroundAudioMod, none of which have public source. RealRailroading has a release zip but the README says it is broken and was never pushed. Do not go hunting for that code.)

Best Practices (C#)

Log properly, not just "MOD NAME Failed":

catch (Exception exception)
{
    context.Logger.Error("MOD_NAME_HERE failed.", exception);
}

Wrap loop iterations individually, one bad exception should not kill the loop for the rest of the session:

while (true)
{
    try
    {
        // per-tick logic
    }
    catch (Exception exception)
    {
        context.Logger.Error("MOD_NAME_HERE loop failed.", exception);
    }

    Thread.Sleep(50);
}

Use context.GetConfigPath for settings, write sane defaults on first run instead of crashing on a missing file.

Other stuff:

  • Focus-check before acting on input
  • Name your background threads, IsBackground = true
  • Do not trust that a listed dependency actually loaded, check yourself
  • Give the game a few seconds after OnLoad before assuming anything is ready, mods boot alongside the game itself, not after it

Join our Discord server for help or questions!

Lua Modding

Lua mods run on MoonSharp 2.0.0 (Lua 5.2, about 99 percent compatible). Each mod gets an isolated Script with CoreModules.Preset_Default. No compile step, just drop a .lua file and restart the game. Perfect for quick tweaks, config edits, and prototyping.

If you already know C# modding, Lua uses the same loader, same Mods/ folder, same mod.json dependencies and DisabledMods. You can even ship both a .dll and a .lua from one folder.

RROML version for Lua is 1.2.0, engine file is RROML/lib/MoonSharp.Interpreter.dll (vendored, MIT). If that dll is missing at RROML/, the loader fails, rebuild copies it to RROML/build/managed/.

Layout and mod.json

Folder mod (recommended):

Mods/MyLuaMod/
  mod.json
  main.lua
  helper.lua  (optional, loaded via require)
{
  "Id": "MyLuaMod",
  "Name": "My Lua Mod",
  "Version": "1.0.0",
  "EntryLua": "main.lua",
  "Dependencies": []
}

Loose file:

Mods/MyLoose.lua

Auto-detected, no mod.json required.

Folder without mod.json is guessed in order: main.lua, init.lua, mod.lua, then first *.lua at top level. Generic names (main/init/mod) resolve the mod id from the folder name to avoid collisions.

Manifest fields in RROML/src/RROML.Core/ModManifest.cs:5:

  • EntryLua - path relative to mod folder
  • EntryDll - C# entry (still supported)
  • Id, Name, Version, Dependencies - shared with C# mods

A folder may declare both EntryLua and EntryDll, both are loaded as separate candidates (RROML/src/RROML.Core/ModLoader.cs:94).

Lifecycle and OnLoad

File is executed via Script.DoString(code, null, filename) (RROML/src/RROML.Core/Lua/LuaModRunner.cs:32). Then OnLoad is dispatched:

  1. global OnLoad
  2. global onLoad (lowercase fallback)
  3. returned table OnLoad / onLoad if file ends with return { OnLoad = function(...) end }

If none found, the top-level execution still counts as loaded.

OnLoad receives the rroml table:

function OnLoad(rroml)
    rroml.log.info("loaded " .. rroml.mod.id)
end

-- alternative
local mod = {}
function mod.OnLoad(ctx) ctx.log.info("table style") end
return mod

Reference: RROML/src/RROML.Core/Lua/LuaModRunner.cs:127. Same try/catch discipline as C# applies, an unhandled error is logged as Lua error in mod <id> and does not crash other mods, but your mod will not load.

rroml API

Injected before execution (RROML/src/RROML.Core/Lua/LuaModRunner.cs:81). RROML is an alias of rroml.

rroml.log.info("msg")
rroml.log.warn("msg")
rroml.log.error("msg")
print("msg") -- alias to rroml.log.info

rroml.paths.gameRoot    -- string, folder containing arr.exe
rroml.paths.loaderPath  -- <gameRoot>/RROML
rroml.paths.modsPath    -- <gameRoot>/Mods
rroml.paths.modRoot     -- this mod's folder
rroml.paths.configRoot  -- GetConfigPath("") trimmed

rroml.mod.id            -- from mod.json or file/folder name
rroml.mod.name
rroml.mod.version       -- default "1.0.0"
rroml.mod.root          -- absolute mod folder
rroml.mod.entry         -- absolute entry file

rroml.getConfigPath("settings.json")         -- -> RROML/Configs/<Id>/settings.json
rroml.getUserGameConfigPath("Engine.ini")    -- -> %LocalAppData%/arr/Saved/Config/Windows[NoEditor] or fallback
rroml.version           -- "1.2.0" (RROML/src/RROML.Core/RROMLVersion.cs:3)
RROML -- alias of rroml

All logging goes to RROML/Logs/rroml.log, same single file as C#. Prefix messages with your mod name.

Standard library

Preset_Default includes basic, string, table, math, coroutine, os, io, debug, etc. print is overridden to log. File IO via io.open works for configs, same as C# File.ReadAllText pattern.

require and Helpers

require is enabled via FileSystemScriptLoader. Module paths (RROML/src/RROML.Core/Lua/LuaModRunner.cs:63):

<modRoot>/?.lua
<modRoot>/?/init.lua
<modRoot>/?/.lua
?.lua
?/init.lua

Example (RROML/src/ExampleLuaMod/helper.lua:1):

-- helper.lua
local helper = {}
helper.greeting = "Hello from helper.lua!"
helper.version = "1.0.0"
function helper.sayHello(name) return "Hello, " .. tostring(name) .. " from helper!" end
return helper
-- main.lua
local helper = require("helper")
rroml.log.info(helper.sayHello("world"))

Keep helpers in the same mod folder, use relative requires, do not rely on parent paths outside modRoot.

Working Example

RROML/src/ExampleLuaMod/main.lua:1 (comments stripped):

local MOD_NAME = "Example Lua Mod"
local MOD_VERSION = "1.0.0"

function OnLoad(context)
    rroml.log.info("[" .. MOD_NAME .. " v" .. MOD_VERSION .. "] OnLoad called")
    rroml.log.info("  Game root : " .. rroml.paths.gameRoot)
    rroml.log.info("  Loader path: " .. rroml.paths.loaderPath)
    rroml.log.info("  Mods path  : " .. rroml.paths.modsPath)
    rroml.log.info("  Mod root   : " .. rroml.paths.modRoot)
    rroml.log.info("  Config root: " .. rroml.paths.configRoot)
    rroml.log.info("  Mod id     : " .. rroml.mod.id)
    rroml.log.info("  Mod version: " .. rroml.mod.version)

    local myConfig = rroml.getConfigPath("settings.json")
    local userConfig = rroml.getUserGameConfigPath("Engine.ini")
    rroml.log.info("  My config path  : " .. myConfig)
    rroml.log.info("  User game config: " .. userConfig)

    do
        local f = io.open(myConfig, "r")
        if f == nil then
            rroml.log.info("  No existing config, creating default at " .. myConfig)
            local out = io.open(myConfig, "w")
            if out ~= nil then
                out:write('{\n  "enabled": true,\n  "message": "Hello from Lua!"\n}\n')
                out:close()
                rroml.log.info("  Default config written")
            else
                rroml.log.warn("  Could not write config file")
            end
        else
            f:close()
            rroml.log.info("  Config already exists, skipping creation")
        end
    end

    local sum = 0
    for i = 1, 5 do sum = sum + i end
    rroml.log.info("  Lua loop test sum 1..5 = " .. sum)

    local greeting = string.format("Hello from %s! RROML %s is running Lua %s", rroml.mod.id, rroml.version, _VERSION)
    rroml.log.info("  " .. greeting)
    print(greeting)

    rroml.log.info("[" .. MOD_NAME .. "] OnLoad finished")
end

C# counterpart: RROML/src/ExampleMod/ExampleMod.cs:1. Same structure, Lua just skips the interface and compile step.

Lua vs C# - Which to Pick

LuaC#
SetupNotepad and save, no buildNeed .NET Framework, csc or Visual Studio
SpeedFast iteration, restart game to reloadCompile step, but faster runtime
Best forConfig edits, simple QoL, prototyping, file helpersHotkeys, threading, memory reads, native exports
Loggingrroml.log.info / printcontext.Logger.Info
Config pathsrroml.getConfigPathcontext.GetConfigPath
DependenciesSame mod.json systemSame mod.json system
AdvancedCannot call native exports directly yetFull access to XINPUT1_3.dll exports

Extra notes for both:

  • Always use getConfigPath for your own settings, write sane defaults on first run instead of crashing on a missing file. Works the same in Lua (rroml.getConfigPath) and C#.
  • Prefix log lines with your mod name, all mods share rroml.log. Check RROML/Logs/rroml.log first when anything fails.
  • Give the game a few seconds after OnLoad before touching saves or assuming objects exist, mods boot alongside the game, not after it.
  • UTF-8 for Lua files, ensure EntryLua is relative and exists. For C#, target .NET Framework and reference RROML.Abstractions.dll.
  • Do not hardcode absolute paths, always use the provided path helpers so it works for other install locations.

Loader Behavior

  • Discovery: RROML/src/RROML.Core/ModLoader.cs:52 scans Mods/*.dll, Mods/*.lua, then each Mods/*/.
  • Ordering: topological sort by Dependencies, warns on cycles/duplicates (OrderCandidates:191).
  • Disabled: entries in RROML/RROML.config.json DisabledMods skip by candidate id, filename, or rroml.mod.id (LoadOne:255, LoadLuaOne:331).
  • Errors: InterpreterException logged as Lua error in mod <id>: <decorated> and counts as not loaded, does not crash other mods.
  • Install: RROML/tools/install.bat:34 copies MoonSharp.Interpreter.dll to <game>/RROML/; RROML/tools/build-managed.bat:30 builds MoonSharp + Lua/*.cs.

Troubleshooting (Lua)

  • Check RROML/Logs/rroml.log for Loading Lua mod <id> from <path> and error stacks.
  • Ensure EntryLua is relative, file exists, UTF-8 encoding.
  • Missing MoonSharp.Interpreter.dll at RROML/ causes RROML.Core load failure, rebuild copies it to RROML/build/managed/.
  • Dependency not found logs declares missing dependency and still attempts load after available deps.
  • Lua cannot yet P/Invoke native exports, use C# for memory scanning and time scale.

Advanced Modding

For the mods that need to go past config files. Memory reads, engine internals. If you have not read Making Mods yet, go do that first, this assumes you already know IRromlMod/IModContext.

Nothing here is required. TimeWarpMod uses two of these functions. UEBridgeMod uses almost all of them. SlightlyBetterVisuals uses none of it.

How RROML gets into the game

The native half is a DLL literally named XINPUT1_3.dll, dropped into arr/Binaries/Win64. Windows loads DLLs from a program's own folder before System32, so the game picks up RROML's fake one. Standard proxying trick, nothing exotic.

Every real XInput call the game makes gets forwarded to the actual System32 dll after RROML's proxy is done with it, so controller support works exactly like normal and nothing looks different from outside. First XInput call triggers the boot: loads mscoree.dll, spins up the CLR via CLRCreateInstance/ICLRRuntimeHost, calls RROML.Core.Bootstrap.InitializeForProxy. Same boot path as everything else, just triggered from native code. Only runs once, guarded flag on the C# side.

Point is, your mod runs inside the actual game process. Not a helper, not a sandbox. That is why the reads below work with zero injection effort on your part, and also why an unhandled exception on your mod's thread can take the whole game down with it. OnLoad needs the same try/catch discipline as a loop body.

Native exports

Small memory-scanning toolkit exported from the same XINPUT1_3.dll, on top of the XInput forwarding. Any mod can P/Invoke it. UEBridgeMod is the reference implementation, worth reading its source next to this table.

[DllImport("XINPUT1_3.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Ansi, EntryPoint = "RROML_FindPatternA")]
private static extern bool FindPatternNative(string idaPattern, bool textSectionOnly, out ulong address);

public static bool FindPattern(string idaPattern, bool textSectionOnly, out ulong address)
{
    try { return FindPatternNative(idaPattern, textSectionOnly, out address); }
    catch { address = 0; return false; }
}

TimeWarpMod declares its two exports plainer, Winapi/ExactSpelling, no wrapper. Both work. UEBridgeMod's version just holds up better once you are calling a dozen of these instead of two.

ExportDoes
RROML_GetMainModuleInfo(out ulong baseAddress, out uint imageSize)base + size of the exe module
RROML_FindPatternA(string idaPattern, bool textSectionOnly, out ulong address)byte pattern scan, IDA-style, ?/?? wildcards, e.g. "48 8D 05 ? ? ? ? C6 05"
RROML_FindAsciiString / RROML_FindWideStringWsubstring search, ASCII or UTF-16
RROML_FindAddressValueRefs(ulong target, ulong[] out, uint max)finds pointer slots holding a given address
RROML_FindRipRelativeCodeRefs / ...InRangefinds instructions referencing an address via RIP-relative operand
RROML_ResolveRipRelativeTargetturns instruction + displacement into the absolute address it points at
RROML_FindRipRelativeLeaXreffirst code ref only, shortcut over the above
RROML_FindLikelyFunctionStartwalks backward from mid-function to guess where it actually starts
RROML_ReadQword / RROML_ReadMemorybounds-checked reads, bad address returns false instead of crashing
RROML_SetOverlayStatusW / RROML_SetOverlayPositionnative side of the boot overlay, ignore this one
RROML_SetTimeScale / RROML_GetTimeScalewhat TimeWarpMod uses

Also, heads up: UEBridgeMod P/Invokes something called RROML_FindAllAsciiStrings that does not actually exist in the dll. Not in the .def, no implementation in the proxy source. Fails silently through the try/catch, just does nothing. Have not fixed it yet.

Bad addresses come back empty, not a crash. Does not mean the data you get back is meaningful though.

UEBridgeMod

Standalone mod, install it and read its output, do not write against it directly.

On load it scans every pak/ini/exe/dll in the game folder for gameplay-looking strings, /Game/ paths, .uasset refs, keywords like Train/Traction/Sander, SM_/BP_ prefixes. Not disassembly, just string fishing.

Separately, and this is the part that actually matters, it pattern-scans the exe for two Unreal Engine globals, FNamePool and GUObjectArray. Find both and you can read live object state out of memory instead of static file contents. It also searches for a fixed list of known-relevant symbol names (OnMainMenuOpened, slomo, CheatManager, etc), and for each hit walks two hops: what code references the string directly, and separately whether there is a pointer variable holding the string's address, and what references that pointer. Hits get scored, OnMainMenuOpened/OpenMainMenuAction score highest, and the winner becomes PrimaryHookHint in the output.

Once GUObjectArray resolves, a second thread walks it live looking for object names matching a shorter list (MainMenu, Train, FrameCar...). First pass waits 15 seconds to let the game actually finish loading.

Three files, all under RROML/Configs/UEBridgeMod/:

  • uebridge-paks.json - raw dump, everything it found, biggest and messiest
  • uebridge-watchlist.json - same strings sorted into buckets (RollingStock, Track, Industry, Physics, Save, Asset, plus a small Focus bucket for the highest-signal ones). Start here
  • uebridge-state.json - live, updates about once a second. Whether the engine globals resolved, current Status (TargetsMissing/TargetsDiscovered/HookCandidatesReady/EngineGlobalsDiscovered), PrimaryHookHint, current menu, live object matches

Workflow: install it, let the game run a bit, grep the watchlist for whatever you are chasing, cross-check against the state file while the game is actually running to confirm it is live and not just a dead string in a pak somewhere.

Notes

  • Resolved addresses are not stable across updates or even relaunches, do not cache them to disk and trust them forever
  • In-process means in-process, a crash here takes the game with it, no safety net
  • Check uebridge-watchlist.json before writing your own string scanner, it might already be in there

Join our Discord server for help or questions!

No results for that search. Try different keywords (e.g. mod.json, hotkey, UEBridge).