Configuration¶
Two layers. Environment variables cover what must be known before Romule
starts. Everything else lives in the interface and is stored in
_romule-config.json inside the service data folder.
Romule keeps two folders apart, and the distinction runs through this whole page:
- the data folder belongs to the service — settings, accounts, cover art, logs, backups. It is fixed by your deployment and has no reason to move.
- the library belongs to you — your games. It usually lives on another disk, and you pick it from the interface, not from a compose file.
By default the library is the data folder, so a single-folder install keeps working exactly as before.
Environment variables¶
| Variable | Default | What it does |
|---|---|---|
ROMULE_ROOT |
~/.local/share/romule |
Service data folder: settings, accounts, artwork, logs |
ROMULE_LIBRARY |
— | Pins the games folder and locks it — the interface can no longer change it |
ROMULE_BASES |
— | Folders the interface may browse, separated like a PATH. Unset means everything the process can see. |
ROMULE_WEB_PORT |
8787 |
Port to listen on |
ROMULE_BIND |
see below | Interface to bind to |
ROMULE_PUBLIC_HOST |
— | The address other machines reach Romule at, with a port when the published one differs (192.168.1.20, nas.local:9000). A container cannot discover its host's address — it only sees its own on Docker's network, which nothing routes — so without this it offers no network address rather than an unreachable one. See In a container. |
ROMULE_TOKEN |
— | Access token; overrides the generated one |
ROMULE_LAN |
— | 1 opens network access without a password |
ROMULE_KEYS |
~/.romule/prod.keys |
Path to the decryption keys |
ROMULE_TRUSTED_PROXIES |
— | Addresses whose forwarded headers are honoured, comma-separated. CIDR is accepted (172.16.0.0/12), which is what you want under Docker where the proxy's address is assigned dynamically. |
ROMULE_UPLOAD_MAX |
64 GiB | Largest accepted upload, bytes |
ROMULE_DISK_MARGIN |
2 GiB | Free space kept in reserve, bytes |
ROMULE_NO_BROWSER |
— | 1 stops Romule opening a browser at startup |
ROMULE_TIMEOUT |
300 |
Socket timeout, seconds |
ROMULE_MAX_CONN |
64 |
Simultaneous connections |
ROMULE_RATE |
600 |
Requests per minute per client |
ROMULE_CHROME |
— | Chrome binary for the browser test suite |
ROMULE_SCRYPT_PARALLELE |
2 |
How many password hashes may run at once. scrypt deliberately costs ~128 MiB each; without a cap, a handful of parallel sign-in attempts would exhaust the server's memory and turn a protection into a lever. |
ROMULE_LOG |
normal |
How much Romule writes to the terminal — quiet, normal, verbose, debug, json. Unrelated to the Log panel in the interface: this is what docker logs and a systemd journal show. See Reading the logs. |
ROMULE_LANG |
en |
The language of the terminal: the startup banner, the log, and the romule commands. English whatever the interface speaks — ROMULE_LANG=fr switches the terminal to French. See Reading the logs. |
NO_COLOR |
— | Any value turns off colour, per the no-color.org convention. Colour is also off automatically when the output is not a terminal. |
ROMULE_ADB |
adb on the PATH |
Path to the adb binary. A path that does not exist means “no console”, which is how the test suite stays independent of what is plugged in. |
ROMULE_BIND defaults to 127.0.0.1, except in a container or once network
access is enabled — otherwise a published port would reach nothing.
ROMULE_BASES is not a sandbox and is not set by default. In a container the
real boundary is the volumes: list, applied by the kernel; on a bare install
it is the Unix account the service runs as. Jellyfin, Sonarr and qBittorrent
all work this way. Set ROMULE_BASES when you run natively under a broad
account and want to narrow the browser anyway. When it is set, it bounds both
what you can browse and what you can select — typing a path is not a way
around it.
Old names still work
SWITCH_* variables are still read, and Romule prints their replacement at
startup. They will be dropped in a later release.
In a container¶
A container cannot discover the address its host is reached at. The socket it
opens answers with its own address on Docker's network — 172.18.0.2 — which
is correct for the container and routed from nowhere else: not from the machine
running it under Docker Desktop or Colima, and not from your console.
Romule used to print that address as the one to open at first start. The first thing a container install showed was therefore an address that does not answer.
It no longer guesses. Without ROMULE_PUBLIC_HOST it says the published port
and stops there, and the button that opens the interface on the console's
screen stays off with the reason given. From the machine hosting the container,
http://localhost:8787 works — it is the published port.
To have Romule know the address, state it:
environment:
ROMULE_PUBLIC_HOST: "192.168.1.20" # your machine, not the container
# ROMULE_PUBLIC_HOST: "nas.local:9000" # with a port when it differs
Reading the logs¶
Two logs answer two different questions, and they are not the same log.
The Log panel in the interface tells whoever is looking at their library
what it is currently doing. The terminal is where you find out why a
service will not start, on a machine where nobody can open a browser — a
container, a NAS, an ssh session. ROMULE_LOG controls the second one only.
The terminal speaks English, whatever the interface speaks. Following the
interface's language setting sounded right and produced worse: the banner
printed in English at startup, then the first person to pick French in the
wizard flipped every later line — one log, two languages, unreadable to both
readers. A log is a technical document with a stable audience; the interface is
not. ROMULE_LANG=fr switches the terminal to French, deliberately and for
good.
| Value | What you get |
|---|---|
quiet |
Errors only |
normal (default) |
The startup banner, the facts it lists, warnings and errors |
verbose |
Plus every task event, timestamped |
debug |
Plus each HTTP request with its status and duration, the module, the thread, and seconds since startup |
json |
One JSON object per line, for a log collector |
docker compose logs -f romule # whatever the style is set to
ROMULE_LOG=debug python3 -m romule serve # when something is wrong
verbose deliberately hides debug
The interface polls /api/job continuously while a task runs, and those
requests are logged at debug. A verbose that showed them would bury the
task events under dozens of lines a second — that is, make unreadable
exactly what you opened it to read.
The startup banner is not decoration. It answers, before you go looking: which version is actually running, where it keeps your settings as opposed to your games, who may connect, which external tools it found, and where the log file is. Each of those lines is a question that otherwise costs half an hour.
Settings¶
All of these are edited from the interface. The names are the keys stored in
_romule-config.json; you should not normally need to edit that file by hand.
Access¶
| Key | Default | Meaning |
|---|---|---|
auth_mode |
aucun |
aucun, interne (accounts), or oidc (beta) |
lan_access |
false |
Allow the network in without a password |
maj_check |
true |
Ask GitHub once a day whether a newer version exists. This is the only time Romule reaches the internet without being asked; turn it off and it never does. |
maj_vue |
— | The version whose release note has been acknowledged from the “Update” pill. Server-side rather than in the browser: a reminder dismissed on the laptop that comes back on the phone is the same reminder. The pill returns on its own at the next version. |
notif_destinations |
[] |
Outgoing notification destinations. Set from Settings → Access, not here: the address is checked and the count is capped. |
auth_secret |
generated | Signing key for session cookies. Never leaves the server. |
jeton_auto |
— | A token set by romule token reset, kept so it survives restarts. Nothing generates one any more: an unclaimed installation opens on its assistant instead — see A token, if you want one. |
oidc_issuer |
— | Provider URL |
oidc_client_id / oidc_client_secret |
— | Client credentials |
oidc_redirect |
— | Redirect URI registered with the provider |
oidc_scopes |
openid profile email |
Scopes requested |
oidc_emails / oidc_groupes |
— | Restrict who may log in |
oidc_admin_groupes |
— | Groups whose members may administer. Empty: nobody does. |
Two different questions
oidc_groupes decides who gets in. oidc_admin_groupes decides who may
open Settings and manage the tool. Confusing them would hand administration
to everyone who can log in.
The role is read from the token at sign-in, so removing someone from a group demotes them at their next session, not in the middle of the current one. See Roles and access.
About¶
The last settings tab: the installed version and whether a newer one exists,
its release notes, the source repository and its releases — the AGPL wants a
reachable instance's code accessible — and the tools the server found (adb,
nsz, prod.keys).
Your console¶
| Key | Default | Meaning |
|---|---|---|
emulateur |
eden |
Profile deciding all paths on the console |
emulateur_paquet |
— | Android package, detected from the console |
device_dir |
/storage/emulated/0/Switch |
Switch games folder on the console |
roms_root |
— | Parent folder of the other platforms. Empty: derived from device_dir. |
wifi_addr |
— | Console address, remembered after pairing |
push_layout |
type |
type sorts into GAMES/UPDATE/DLC; plat keeps it flat |
saves_dir |
— | Where game saves are backed up |
auto_nand |
false |
Install into the emulator's NAND automatically |
Backups¶
Set from Settings → Backups, through the dialog. See Backups.
| Key | Default | Meaning |
|---|---|---|
backup_sources |
["sauvegardes"] |
What gets copied: sauvegardes, jeux, maj, dlc, jaquettes, config |
backup_dest |
— | Where the batches land. Empty means nothing is backed up anywhere. Must be ABSOLUTE, and is refused if it falls outside ROMULE_BASES. |
backup_keep |
5 |
How many batches are kept. Past that, the oldest goes — an interrupted one before a complete one. |
Library¶
| Key | Default | Meaning |
|---|---|---|
library_path |
— | The folder scanned for games. Empty means the data folder. Set from Settings → Your library → Location, not by hand. |
local_layout |
type |
Same idea, on the server side |
systemes_perso |
[] |
Extra platforms you define yourself |
system_dirs |
{} |
Folder overrides per platform |
trash_days |
0 |
Days before the trash empties itself. 0 never. |
verify_mode |
size |
size compares size and date; hash fingerprints the contents |
incremental |
true |
Only re-read what changed |
jobs |
3 |
Parallel conversions |
versions_urls |
titledb | Mirrors for the Switch version database |
On its own¶
Romule does nothing unasked until you tell it to. These two keys are what change that, and only the first is yours to set.
| Key | Default | What it does |
|---|---|---|
schedule |
{} |
What runs on its own: {task: preset}. The tasks are scan, import, convert, push, meta; the presets are never, startup, hourly, 6h and nightly:HH. Set from Settings → Maintenance → On its own. |
schedule_state |
{} |
When each scheduled task last ran. Written by Romule, never by hand: without it a restart makes every nightly task due again. |
Several consoles¶
“Ajouter une console” asks for no name: it opens the connection straight away — cable or Wi-Fi — on a blank entry, and the console takes the name it announces (“AYN Thor”) on first contact. Each console keeps its own settings: selecting one under “Console pilotée” shows its own.
An Odin and a Retroid are two consoles, not one setting overwritten twice.
| Key | Default | What it does |
|---|---|---|
devices |
one entry | The consoles you own. Each carries its own serial, wifi_addr, emulateur, emulateur_paquet, device_dir, roms_root, push_layout and auto_nand. |
active_device |
the first | Which one Romule is driving. |
Those eight settings also stay at the top level of the file, mirroring the
active console. That is deliberate: it is what lets a version that predates
this feature read your pairing where it left it, and it is what kept the
seventy-odd places in the code that read device_dir working unchanged.
The first console is built from your existing settings, once, without asking.
Only reversible tasks can be scheduled. Emptying the trash, clearing the log and revoking a key are absent from the list, and that is not an oversight: an unattended action must be one whose result you can still look at in the morning.
If a task is already running when another falls due, the due one is skipped and said so in the log — it is not queued. Romule does one thing at a time.
Covers and details¶
| Key | Default | Meaning |
|---|---|---|
cover_provider |
nlib |
nlib, steamgriddb, or custom |
cover_url |
nlib template | Used when cover_provider is custom. {tid} is substituted. |
steamgriddb_key |
— | SteamGridDB API key |
igdb_client_id / igdb_client_secret |
— | IGDB credentials. Used for summaries and, when the chosen provider has no artwork, as a second cover source. |
meta_lang |
en |
Language for titles and summaries |
emuready |
false |
Community compatibility ratings (beta) |
emuready_device |
— | Which device to match ratings against |
emuready_device_nom |
— | Its display name, remembered so the list need not be fetched again |
Why covers have two sources
SteamGridDB is a community artwork database — rich on what gets played with a keyboard, thin on handheld console catalogues. IGDB is a game database, and it publishes cover art too. Romule already asked it for summaries; it now asks for artwork as well, but only after the chosen provider has failed to return an image — not merely an address, since a URL that answers 404 is still a URL.
Both sources go through the same matching rule: a candidate has to cover two thirds of the distinctive words of the title. A cover that belongs to another game is worse than no cover at all.
Interface¶
| Key | Default | Meaning |
|---|---|---|
ui_lang |
en |
en or fr. Adding a language is a JSON file — see Contributing. |
notify |
true |
Notify when a job finishes |
Who can open this page¶
The setting lan_access is no longer a switch in the interface. Access is
settled once, on the wizard's access step — an account, or no password — and
the settings screen now READS that decision back rather than offering a second
place to change it. Two places deciding the same thing is how an installation
ends up open by accident.
To change it afterwards: the wizard reopens from the same row, and the terminal always works — see The first access.
Notifications¶
Romule could already tell you a task had finished — but only the person sitting in front of it, with a desktop notification. What it does takes time: a thirty-file conversion, a multi-gigabyte transfer. Those are precisely the moments when you are not in front of the screen.
Settings → Access → Notifications. Paste a webhook address, give it a name, done. The service is worked out from the address:
| Service | What to paste |
|---|---|
| Discord | https://discord.com/api/webhooks/… — Server settings → Integrations → Webhooks |
| Slack | https://hooks.slack.com/services/… — an Incoming Webhook |
| Telegram | https://api.telegram.org/bot<token>/sendMessage?chat_id=<id> |
| ntfy | https://ntfy.sh/your-topic, or your own instance |
| Gotify | https://gotify.example.com/message?token=… |
| Anything else | Any URL — Romule POSTs a plain JSON object |
Each destination gets its own block: the service Romule recognised, a switch to silence it without removing it, its events, a Test the connection button whose answer stays in that block, and a link to that service's own instructions. A verdict shown as a toast could not say which of four channels had answered — and it faded before you had read it.
Each destination can be tested before or after saving, and the result says which side refused: a wrong address and a service that is down do not look the same.
Which events, per destination¶
Every destination has its own boxes. A family channel can be told only when a transfer is over; an ops channel can be told about everything.
| Event | Fires when |
|---|---|
| A task finished | any task at all ended well |
| A task failed | any task at all failed or was interrupted |
| A transfer to the console finished | a push, a deploy or a pull ended — either way |
| A conversion finished | an NSZ conversion ended, either way |
| The drop folder was filed | files from _import were sorted into place |
| The game entries were refreshed | titles, summaries and covers were fetched |
| An integrity check finished | a fingerprint pass ended |
| The console connected or disconnected | the link changed, and only when it changes |
| A newer version exists | once per released version, not once per page load |
The first two are catch-alls: tick a task finished and you hear about every kind of task, without ticking six boxes. The specific ones fire whatever the outcome, because someone watching for the end of a 12 GB transfer wants to know either way — the message's colour says which.
A destination that has ticked both a specific event and its catch-all is told once, not twice. A destination with nothing ticked receives everything, which is what pasting an address without touching the boxes should mean.
A webhook address is a bearer secret
Whoever holds it can post in your channel. Romule therefore never sends
it back — the interface shows only the host, which is enough to tell two
destinations apart. The address is not in the API responses, not in the
log, and not in romule doctor output. Managing notifications is
administrator-only, and so is testing one: an endpoint that fetches an
arbitrary URL on demand is a port scanner by proxy.
Nothing is sent when nothing is configured. A self-hosted service that reaches outward on its own is a problem, not a feature.
Debugging from the terminal¶
These commands exist for the moment when the interface is not the answer:
no password left, no second factor, a service that will not start, or a
container with no browser. Until they existed, the only way out was editing
_romule-comptes.json by hand — pasting an scrypt hash computed elsewhere,
which nobody gets right the first time.
They grant nothing new. Whoever can run romule already has the service's
rights, and therefore its files. They only make doable, without mistakes, what
the filesystem already allowed.
romule doctor # everything a bug report should contain
romule user list
romule user passwd you@example.com # asks twice, no echo
romule user admin you@example.com # grant administration
romule user admin you@example.com --retirer
romule user totp-off you@example.com # lost phone
romule user rm someone@example.com --oui
romule config list # secrets shown as "(n characters, masked)"
romule config get auth_mode
romule config set trash_days 7
Under Docker, prefix with docker compose exec romule python3 -m romule.
romule user passwd resets a password without knowing the old one. That is
why it exists only here and never as an HTTP route: a reset without proof of
identity is exactly what an attacker wants. It also invalidates every open
session for that account, and clears the failure counter — a lockout from
repeated wrong attempts would otherwise survive the reset and make the new
password look broken.
romule doctor is what to paste into an issue
Version, paths and their permissions, which port is taken, which external
tools are on PATH, which remote services are configured, how many
accounts and administrators, and the library breakdown per platform. It
contains no password, no key, and no webhook address — that is checked by
a test, not by intention.
Every one of these commands exits non-zero when it refuses. That sounds obvious; it was not true when they were written, and a test caught six perfectly worded refusals all reported as success.
Where the files live¶
Everything Romule writes is prefixed with _, and lands in one of the two
folders.
In the data folder (ROMULE_ROOT):
| File | What it holds |
|---|---|
_romule-config.json |
The settings above. chmod 600. |
_romule-comptes.json |
Accounts: scrypt hashes and TOTP secrets |
_romule-lib.log |
Activity log, rotated at 2 MiB, 3 files kept |
_romule-acces.log |
Access log |
_covers/ |
Cached cover art |
_sauvegardes/ |
Automatic config and account backups |
Next to your games (library_path):
| File | What it holds |
|---|---|
_import/ |
Drop files here to import them |
_corbeille/ |
Trash |
These two follow the games rather than the service on purpose. Setting a game
aside has to stay a rename: across two filesystems shutil.move copies
instead, which turns discarding one title into several gigabytes of I/O.
Back these up
_romule-config.json and _romule-comptes.json are your settings and your
accounts. They are not recoverable from anywhere else.