Railroads Online Modding Wiki
Documentation for modding Railroads Online using RROML.
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.
- Download the latest release (right side) of RROML or grab it directly from Releases
- Extract / unzip the .zip file
- Put everything in it, inside the Railroads Online directory / path
- If you did that correctly, you should have a RROML folder inside your Railroads Online folder
- Create a Mods folder
- Download any mod you want
- Unzip the mod
- Put the Mods folder in the unzipped mod folder, into your Railroads Online folder (it will auto-merge with the existing folder)
- 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.
- Install Moxi (a mod manager) via the latest release's installer (The .exe and NOT THE SOURCE CODE)
- Launch Moxi
- Find Railroads Online either in Mod Database (top right) or scrolling down to the "Detected Supported Games" section, and clicking on Railroads Online
- Click install on any mod and it will prompt you to install the mod loader
- Click install on the mod loader when prompted, then it will install it and the mod you wanted for you
- 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
IRromlModfromRROML.Abstractions. RROML scans your dll for any non-abstract class implementing it and instantiates it. That is the whole discovery mechanism - Reference
RROML.Abstractions.dllto compile, it is tiny, lives inRROML/RROMLafter 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 insideRROML/Configs/<YourModId>/, created automatically. Your mod's own settings/state go here, do not hand-roll a pathGetUserGameConfigPath(fileName)- path inside the game's own config folder (Saved/Config/Windowsin 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
OnLoadbefore 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 folderEntryDll- 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:
- global
OnLoad - global
onLoad(lowercase fallback) - returned table
OnLoad/onLoadif file ends withreturn { 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
| Lua | C# | |
|---|---|---|
| Setup | Notepad and save, no build | Need .NET Framework, csc or Visual Studio |
| Speed | Fast iteration, restart game to reload | Compile step, but faster runtime |
| Best for | Config edits, simple QoL, prototyping, file helpers | Hotkeys, threading, memory reads, native exports |
| Logging | rroml.log.info / print | context.Logger.Info |
| Config paths | rroml.getConfigPath | context.GetConfigPath |
| Dependencies | Same mod.json system | Same mod.json system |
| Advanced | Cannot call native exports directly yet | Full access to XINPUT1_3.dll exports |
Extra notes for both:
- Always use
getConfigPathfor 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. CheckRROML/Logs/rroml.logfirst when anything fails. - Give the game a few seconds after
OnLoadbefore touching saves or assuming objects exist, mods boot alongside the game, not after it. - UTF-8 for Lua files, ensure
EntryLuais relative and exists. For C#, target .NET Framework and referenceRROML.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:52scansMods/*.dll,Mods/*.lua, then eachMods/*/. - Ordering: topological sort by
Dependencies, warns on cycles/duplicates (OrderCandidates:191). - Disabled: entries in
RROML/RROML.config.jsonDisabledModsskip by candidate id, filename, orrroml.mod.id(LoadOne:255,LoadLuaOne:331). - Errors:
InterpreterExceptionlogged asLua error in mod <id>: <decorated>and counts as not loaded, does not crash other mods. - Install:
RROML/tools/install.bat:34copiesMoonSharp.Interpreter.dllto<game>/RROML/;RROML/tools/build-managed.bat:30buildsMoonSharp+Lua/*.cs.
Troubleshooting (Lua)
- Check
RROML/Logs/rroml.logforLoading Lua mod <id> from <path>and error stacks. - Ensure
EntryLuais relative, file exists, UTF-8 encoding. - Missing
MoonSharp.Interpreter.dllatRROML/causesRROML.Coreload failure, rebuild copies it toRROML/build/managed/. - Dependency not found logs
declares missing dependencyand 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.
| Export | Does |
|---|---|
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_FindWideStringW | substring search, ASCII or UTF-16 |
RROML_FindAddressValueRefs(ulong target, ulong[] out, uint max) | finds pointer slots holding a given address |
RROML_FindRipRelativeCodeRefs / ...InRange | finds instructions referencing an address via RIP-relative operand |
RROML_ResolveRipRelativeTarget | turns instruction + displacement into the absolute address it points at |
RROML_FindRipRelativeLeaXref | first code ref only, shortcut over the above |
RROML_FindLikelyFunctionStart | walks backward from mid-function to guess where it actually starts |
RROML_ReadQword / RROML_ReadMemory | bounds-checked reads, bad address returns false instead of crashing |
RROML_SetOverlayStatusW / RROML_SetOverlayPosition | native side of the boot overlay, ignore this one |
RROML_SetTimeScale / RROML_GetTimeScale | what 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 messiestuebridge-watchlist.json- same strings sorted into buckets (RollingStock, Track, Industry, Physics, Save, Asset, plus a small Focus bucket for the highest-signal ones). Start hereuebridge-state.json- live, updates about once a second. Whether the engine globals resolved, currentStatus(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.jsonbefore writing your own string scanner, it might already be in there
Join our Discord server for help or questions!
mod.json, hotkey, UEBridge).