A mod-first German train simulator built on Bevy — implementation of PLAN.md. Current state and open points: STATUS.md.
This project is designed from the ground up for modding — your own locomotives, your own signals, your own lines. See Mods for the guide. The main menu offers a clickable interface to choose your line, vehicle and scenario from the loaded mods, to switch mods on and off, and to change settings that are kept between runs.
cargo test --workspace # all acceptance tests (headless, no GPU)
cargo run -p app # start the simulator (main menu: drive, mods, settings, quit)
cargo run -p app -- --frames 120 # rendering smoke test (CI)
cargo run -p app -- --screenshot screenshots/hud.png # capture an image and exit
cargo run -p app -- --screenshot shot.png --overlays # …with the F5 and F6 overlays open
cargo run -p app -- --screenshot shot.png --hud reduced # …with the display on one of its three steps
cargo run -p app -- --menu --screenshot screenshots/menu.png # …of the menu instead
cargo run -p app -- --screenshot night.png --time 22:30 --date 2026-01-15 # …at another hour of another day
cargo run -p app -- --line example:beispielstrecke --loco example:br101_afb # from a mod
cargo run -p app -- --line example:beispielstrecke --scenario example:probefahrt
cargo run -p app -- --loco example:br101_afb --camera outside # look at the vehicle model
cargo run -p app -- --loco example:br101_afb --camera walk # start on foot (F4) instead of on the seat
cargo run -p app -- --camera walk --character example/models/driver.glb # with a body (seen from F2/F3)
cargo run -p app -- --dedicated 27015 # dedicated server, no window
cargo run -p app -- --connect 127.0.0.1:27015 # join oneWithout arguments the simulator opens on a title screen: wordmark over the backdrop, and four
verbs — Drive, Mods, Settings, Quit. Drive walks line → vehicle → scenario in
three steps, shown as a numbered rail across the top with what was picked under each. Beside
the list a detail pane reads the highlighted entry out of the loaded content: length, permitted
speed and signals of a line; mass, running-gear limit, drive and brake of a vehicle; start time,
timetable and events of a scenario. ↑/↓ or the mouse select, Enter or a left click
confirms, ←/→ dial a setting, Esc goes one step back and leaves at the title screen; F9
opens the mod manager in-game. Any run flag (--line, --loco, --scenario, --frames,
--screenshot, …) skips the menu entirely, so the invocations above stay non-interactive —
--menu puts it back in front, optionally on a named page (--menu settings, also root,
line, loco, scenario, mods), which is the only way to photograph the menu itself.
The picture behind the menu lives in crates/app/images/ and is compiled into the binary. The
one checked in today is a placeholder that is not ours to distribute — see the README there.
For a faster edit-compile-run loop, add --features dev to any of the four binaries
(app, route-editor, vehicle-editor, signal-editor). It links Bevy as a shared library, which cuts the
relink after a code change. The first build with the flag recompiles Bevy, and the resulting
binary needs the Bevy DLL next to it — so use it for development only, never for a release.
Builds also use the toolchain's own rust-lld linker on Windows (see .cargo/config.toml), dependencies compile at opt-level = 3 while the workspace itself stays at 1, and --release adds thin LTO with a single codegen unit.
Train protection and door control are vehicle equipment, not command line options: the
safety and doors fields of a VehicleSpec state which Indusi/PZB build, which Sifa and
which door control a vehicle carries (see Mods). Whether the equipment can do
anything also depends on the line — the LZB needs a conductor cable, the PZB needs magnets.
Switching the battery off and on again (1) restarts the function test of every system on
board.
--screenshot is available in the editors as well; --frames N sets after how many frames
the capture happens (60 frames ≈ 1 s of simulation time).
The same binary is the dedicated server. --dedicated <port> (or <address>:<port>) builds
the world, opens a UDP socket and runs the simulation without a window, a renderer or a sound
card; --connect <host:port> joins one. Without either flag nothing of it runs — single
player never opens a socket.
Both sides have to build the same world, so start them with the same --line/--scenario;
a fingerprint over line name and consists is exchanged on joining and complained about in the
log when it differs. On joining, a client asks for the train its own scenario put it in and
gets it while it is still free — otherwise the first train nobody has taken. A train a player
has taken over is no longer driven by the AI, and goes back to it when that player leaves.
What travels is the driver's levers and, ten times a second, the position of each train on
the track — (edge, s, dir, v, a), about 17 bytes, not a transform. Every peer runs the same
deterministic simulation on those levers; the positions only correct the drift, and they do it
through the speed rather than by setting anything, so nothing ever jumps. Trains further than
3 km away are corrected once a second, past 20 km not at all. The HUD line Server shows
the connection, the train, the round trip time and the correction still pending — in normal
running it stays under a handful of centimetres. See PLAN.md ch. 20 for the why.
Still open: choosing the server from the menu (today it is the command line), a lobby listing the free trains, and authentication beyond netcode's shared key.
The Settings section of the main menu writes a TOML file into the operating system's
settings directory for the current user (%LOCALAPPDATA%\dev.vanlueck.connected-rails\settings.toml
on Windows, ~/.config/dev.vanlueck.connected-rails/settings.toml on Linux). It is Bevy's own
bevy::settings, so the file is plain text and can be edited by hand; an unknown or malformed
key falls back to the built-in default instead of taking the program down.
Every setting applies the moment it is changed — none of them waits for a restart or for the next run. View distance moves the streamer's load radius while tiles are in the air, bloom is added to and taken off the live camera, and the rest is re-read where it is used.
Esc during a run raises the pause overlay — the world stands still under it — with
Resume, Settings, Back to the main menu and Quit. Its settings page is the
same one, minus the language (not a driving decision) and the reset (too blunt to have under
the cursor while a train is standing on a gradient); everything on it takes effect while you
watch. Esc on the overlay resumes; going back to the main menu ends the run and takes the
built world down with it, so the next one starts from an empty world.
| Section | Setting | Effect |
|---|---|---|
[graphics] |
view_distance |
How far terrain is built and drawn [m], 1000 … 12000. The biggest single cost. |
shadows |
Shadow maps of the sun. | |
bloom |
Glow around lamps and signals after dark. | |
shadow_quality |
Edge length of the sun's shadow map: Low 1024, Medium 2048, High 4096 texels. |
|
mist |
Ground mist as a volume, with the sun's shafts through it. | |
mist_quality |
Steps of the raymarch through it: Low 16, Medium 32, High 64. |
|
texture_quality |
Size and filtering of the generated ground textures: Low 128², Medium 256², High 512². |
|
anti_aliasing |
How the edges are smoothed: Off, Fxaa, Smaa or Msaa. |
|
aa_quality |
How hard that works: Low, Medium or High — 2×/4×/8× for MSAA, the preset for the other two. |
|
window |
Windowed, Borderless over the whole monitor, or exclusive Fullscreen. |
|
vsync |
Caps the frame rate at the monitor's. | |
max_fps |
Frames a second the simulator holds itself to, 30 … 240; the top step (250) is no cap at all. | |
[audio] |
master |
Linear master volume, 0 … 1. |
[gameplay] |
language |
en, de, or empty for the system's. |
hud |
How much of the display is drawn: Full, Reduced or Off (F7 walks the three). |
|
look_speed |
Factor on the mouse look speed, 0.2 … 3.0. | |
[controls] |
binds |
The bindings, one line per rebound row — throttle-up KeyW DPadUp for a button, lever-brake-valve RightTrigger2 for a lever, - for nothing. Only what differs from the default is written, so a new default reaches everyone who never touched that row. |
TRAINSIM_LANG stays the outermost override: where it is set, the stored language is
ignored, so scripted and CI runs are not steered by whatever was last picked in the menu.
Everything is meant to be moddable: your own locomotives, your own signals, your own lines.
A mod is a directory below mods/; mods/example/ is the reference to copy from.
mods/<id>/mod.ron id, name, version, author, depends, enabled
/vehicles/*.ron locomotives and coaches
/lines/*.ron track, equipment, signals, electrification, track areas — a line, or a module with boundaries
/compositions/*.ron modules chained into one line (georeferenced, auto-snapping)
/scenarios/*.ron triggers and actions
/timetable/*.ron timetables (stop scoring, referenced by a scenario)
/signals/*.ron signal types (aspect table + optional script)
/signal_models/*.ron signal models: glTF parts on mount points, lamp bindings
/blocks/*.ron block presets for the vehicle editor's palette
/track_types/*.ron superstructure classes: texture, speed limit, roughness, reverb, LZB flag
/objects/*.ron track objects: a 3D model plus its pose relative to the track
/displays/*.html cab displays as an HTML/CSS/JS page
/scripts/*.lua behaviour
/assets/… models, textures, sounds — as `mods://<id>/assets/…`
Everything is addressed as "<mod>:<file stem>", e.g. example:br101_afb, so two mods may use
the same file names. Nothing is fatal: a broken file is a warning, everything else still loads.
Mods are loaded in dependency order (depends), alphabetically within that.
Lines are built from modules (Zusi-style): a module declares named boundaries at its
open ends, and a composition chains modules into one line — boundaries that lie at the
same geo position connect automatically. Several versions of a module (other epochs) are
simply several files; the composition picks one. See MODS.md.
The bulk of a locomotive is declaration, not script — masses, running resistance, brake equipment, tractive effort curve. That is RON, validated on load and editable without programming. Lua only covers real behaviour: tap changer logic, AFB, the choice of a signal aspect. That keeps roughly 80 % of every mod declarative, checkable and safe.
The Lua sandbox has table, string and math — no io, no os, no require, no
filesystem. A script sees a context table of numbers and booleans and answers with a table of
overrides; it never gets a handle on the simulation. A script that raises an error is switched
off, and the run continues.
The interlocking supplies the situation of a signal — guarded sections clear, route locked,
diverging route, aspect of the following signal. The signal type maps that to an aspect; the
first matching rule wins (mods/example/signals/ks_main.ron):
(
system: Ks,
rules: [
(when: (clear: Some(false)), show: (main: Some(Stop)), lamps: ["red"]),
(when: (diverging: Some(true)),
show: (main: Some(ProceedSlow), distant: Some(ExpectStop), speed: Some(40.0)),
lamps: ["yellow", "zs3_4"]),
(when: (next_stop: Some(true)),
show: (main: Some(Proceed), distant: Some(ExpectStop)), lamps: ["yellow"]),
(when: (), show: (main: Some(Proceed), distant: Some(ExpectProceed)), lamps: ["green"]),
],
script: None,
)lamps are free-form strings — your own presentation decides what they look like. A line points
at the type by name: signal_type: Some("example:ks_main").
What a table cannot express — anything with memory or a timer — goes into script. The hook
runs after the table, sees its result in ctx.main and returns nil to keep it
(mods/example/scripts/zs1.lua gives Zs1 after three minutes at stop):
-- ctx: signal, time, clear, route, diverging, next_stop, next_slow, main, distant, speed
function M.aspect(ctx)
if ctx.time - since >= 180.0 then
return { main = "substitute", speed = 40.0, lamps = { "red", "zs1" } }
end
endA vehicles/*.ron is the plain vehicle description; script is the only addition. The hook is
called once per frame for the train whose leading vehicle names it and writes cab controls —
here an AFB variant that replaces the built-in one (mods/example/scripts/afb.lua):
-- ctx: dt, time, v_kmh, speed_limit_kmh, mass_t, throttle, reverser, afb, afb_target, …
function M.update(ctx)
if not ctx.afb or ctx.reverser == 0 then
return nil
end
local target = math.min(ctx.afb_target, ctx.speed_limit_kmh)
local notch = (target - ctx.v_kmh) / 10.0
return { throttle = math.max(-1.0, math.min(1.0, notch)) } -- also: direct_brake, sanding
endFull field reference, sandbox rules and packaging: MODS.md; background and state: PLAN.md ch. 19, STATUS.md.
Export track data from Overpass Turbo as JSON:
[out:json];
way["railway"="rail"](50.90,10.00,51.00,10.30);
(._;>;);
out body;Taken from OSM are the geometry of the railway=rail ways, maxspeed and name.
Switches, signals, platforms and level crossings are not carried over (yet) — the line is
created as a single strand and is then equipped in the RON file.
The point sequence does not become a smoothed curve but an alignment: straight sections
and curves are separated, the radius is averaged over the whole curve (point noise cancels
out with √n) and rounded to the nearest standard radius if it is close enough. Transition
curves and cant cannot be measured from OSM and therefore come from the rulebook:
c = 11.8 · v²/R minus the permitted cant deficiency, capped at 160 mm, ramp length 1:10·v.
The result is a chain of straight – clothoid – circular arc – clothoid – straight.
Limits worth knowing: OSM is accurate to ±2…5 m from aerial imagery, and the start and end of a curve can only be determined to about ten metres from a point sequence. Radius, turn angle and cant, on the other hand, are hit precisely — exactly the quantities you feel while driving. The import report lists radii, cant and the deviation from the OSM line.
Elevations come from the state DGM data. --dgm takes a file or an entire directory
of tile sheets (subdirectories included):
cargo run -p content --bin import-line -- line.json --dgm ./dgm1_niedersachsen --epsg 25832 --name "Musterbahn" --out line.ronSupported are XYZ (x y z, UTM) and ESRI ASCII Grid (.asc). Sheet boundaries are read from
the file name (dgm1_32_389_5711_1_ni.xyz), so nothing is loaded at startup; each tile only
enters memory once a query falls into it, and at most eight stay loaded at a time. This makes
even a DGM1 of an entire federal state (several thousand tiles) usable.
The tool reports length, edge count, elevation coverage and the largest deviation of the alignment from the OSM points. Heights can also be pulled in later from inside the route editor: the DGM tile tool shows the elevation tile grid and imports either the picked tiles or the whole corridor.
| Crate | Contents |
|---|---|
i18n |
Translations of everything the user reads (Fluent .ftl, English and German) |
world-coords |
ECEF f64 world coordinates, floating origin, geodesy (plan ch. 4) |
track-model |
Track geometry (straight/curve/clothoid), topology, switches, lineside equipment (ch. 5) |
sim-core |
Driving dynamics (adhesion axle by axle), air and vacuum brake, electrics, steam, train protection, interlocking, timetable, scenario and scoring — without Bevy, deterministic (ch. 6–11) |
content |
Vehicle database, line source format (RON) + compiler, scenarios, OSM/DGM importer (ch. 15) |
mod-runtime |
Mod discovery, declarative content, Lua behaviour hooks (ch. 19) |
html-display |
HTML/CSS/JS cab displays: parser, layout, script engine — in-engine, no browser (ch. 12) |
ai-driver |
AI train driver, look-ahead (ch. 11) |
imagery |
Aerial imagery tiles: providers, Web Mercator maths, cache, fetching (ch. 15) |
world-render |
Rendering shared by app and route editor: terrain tiles and splatting, vegetation, track objects, floating-origin anchoring |
app |
Bevy app: rendering, cameras, input, HUD (ch. 12), sound on kira's mixer — spatial tracks, distance and cab-wall filtering, Doppler, reverb (ch. 13); multiplayer and the dedicated server on lightyear (ch. 20); text in Fira Sans and Fira Mono (fonts/, SIL OFL 1.1) |
editor-ui |
Shared look and feel of the desktop editors: colors, typography (Inter), spacing, form widgets |
route-editor |
Route editor: flown 3D view over aerial imagery — track, equipment, objects, vegetation, terrain (ch. 15) |
vehicle-editor |
Vehicle editor: base data, block diagram (drive, brake, equipment), glTF import, LOD, moving parts (ch. 15) |
signal-editor |
Signal editor: modular signal models — glTF parts on mount points, lamp bindings (ch. 15) |
sim-core is a pure Rust library with a fixed time step (200 Hz). The Bevy app ticks it and
mirrors the state into ECS components — simulation logic does not belong there.
The HUD says what a driver could read off the desk without leaning forward, plus the two things no desk shows: what the run is supposed to do, and what the line ahead is about to ask for.
Everything on it is either hardware or overlay, and the two never look alike. Hardware is the instrument panel at the bottom and the lamp housing beside it: a lighter surface with a lit top edge and a shadow under it, round instruments with needles that turn. Overlay is the run and the systems at the top: type on the world under one wash across the width of the screen, with no frame at all.
| Zone | What stands there |
|---|---|
| Bottom centre | The desk — the speedometer with the line's permitted speed marked on its rim and the supervised speed over it, the Doppelmanometer carrying brake pipe (pale needle) and main reservoir (red needle) as in the cab, the brake cylinder on its own gauge, and beside them the levers: power controller, brake valve, effort, reverser, AFB, distance run |
| Bottom left | Train protection — the round lamps of a German desk, 1000 Hz, 500 Hz, Befehl, the train category and Sifa, with the LZB row under them and the MFA's v-soll, v-target and target distance while the LZB is guiding. Glass and legend light together in the lamp's own colour |
| Bottom right | Look-ahead — signed the way the line signs it: the triangle of an Lf 7 board with the speed on it, or the disc of Hp 0, and how far off it is |
| Top left | The run — clock, punctuality, service, and the timetable as a route ribbon: the stops in order down a rail, the wedge marking where the train stands with the distance to the next stop beside it, the next stop the only line set large. The score sits under a rule at the foot |
| Top right | Systems — ten annunciators of the desk (battery, pantograph, main switch, compressor, spring brake, sanding, doors, lights, wheel slip, hot motors) and three rows the drive labels itself: wire and motor current on an electric, engine and fill on a diesel, boiler, water glass and fire on a steam locomotive |
| Top centre | Scenario messages, and over the desk the banner that says the train protection has taken over |
The timetable is a route ribbon, not a list of fields. A "next stop / platform / departure" block says where the train goes next; a ribbon says where it is. The rail carries the stops in order — the one behind dimmed, the next one large, the two after it between the two — and the wedge sits between the stop behind and the stop ahead with the remaining distance beside it. The rows have fixed roles, so their weight is built once rather than switched every frame.
Punctuality is worked out, not printed. The delay a train left the last stop with is carried, and on top of that it is late by however long the scheduled arrival at the next stop has been and gone. A train that has not reached a stop yet is never early: without that rule every run would open by announcing itself seven minutes ahead of a stop it has not moved towards.
The graphics are drawn, not fetched (crates/app/src/glyphs.rs): dial faces, needles,
rim markers, the Lf 7 board, the Hp 0 disc and the ten pictograms are a few lines of
geometry each, rasterised by a small signed-distance rasteriser when the run starts. There
is no asset directory, no icon set, and no third-party licence to carry — and a pantograph
that should read better at 20 px is a coordinate in that file rather than a new download.
The speedometer's scale comes from the vehicle's maximum speed and is drawn once, so the
figures on the face stay put while the line's limit changes.
Nothing that does not apply is drawn: the AFB row exists on a vehicle fitted with one, the
LZB lamps where an LZB is, the look-ahead when something is actually coming. F5 opens the
keyboard as a sheet — with a legend of what the ten annunciators mean — and F6 the
diagnostics: frame time and entity count, terrain, air detail, axles, temperatures, signals
and the network, which is where everything a driver has no use for lives.
F7 walks three steps — full, reduced, off — and rounds back. The reduced step keeps
what the train is driven by (the desk and the protection lamps) and everything that
interrupts (the banner, scenario messages), and drops what it is planned by: the run, the
systems and the look-ahead. It is the step for driving by the cab's own instruments without
giving up the train protection; off is the one for a photograph. The step is a setting like
any other ([gameplay] hud — Full, Reduced, Off), so it survives the run, and
--hud <step> sets it for a screenshot without writing the settings file.
Every one of them can be changed: Settings → Key bindings opens a page with one row per
control, showing the key on the left of the value column and the controller input on the
right. Enter on a row takes the next key or controller button pressed, Backspace takes
the binding away, Esc leaves it as it was, and one key only ever works one control —
binding it somewhere else takes it off whoever had it. The page is reachable from the pause
overlay as well, so a binding can be changed with the train standing on the line, and the
key sheet (F5) behind it is rewritten as soon as it is. The choice is kept in the settings
file under [controls].
A controller is a first-class input: any connected pad answers to the same bindings. Out of
the box the D-pad is the power controller, the triggers are the brakes, A is the horn, B
the Sifa and Y the PZB acknowledge — the buttons are named by the letters Xbox pads print
on them rather than by Bevy's compass points.
Levers on an axis. The last group of the page is the three controls that have a
position rather than a direction — power controller, driver's brake valve, direct brake.
A key can only nudge one; a stick or a trigger holds it. Enter on such a row takes the
next axis moved past half travel, and from then on that axis drives the lever absolutely:
the stick or trigger is where the lever stands, and the keys for it are no longer read.
The power controller runs the full −1 … 1, so a stick pushed down is the electric brake and
a trigger gives the positive half; the brake valve maps 0 … 1 onto the full 1.5 bar of pipe
drop. Lap, fill and emergency stay on their keys — an axis has no detent for them, and
emergency latches until a key leaves it. Nothing is bound here out of the box: a bound lever
writes its control every frame, which would otherwise hold the brake valve at Release for
everyone who has a pad plugged in and never touches it.
Looking around and walking are the one deliberate exception: the right stick looks and the left stick walks, always, and neither is bindable. Those are not levers of the desk.
The table below is what everything ships with.
| Key | Function |
|---|---|
W / S |
Power controller up/down (negative = electric brake), X = zero |
R / F / T |
Reverser forward / reverse / neutral |
A / D |
Driver's brake valve release / brake |
Q / E / Z |
Lap / emergency brake / fill |
C / V |
Direct (additional) brake apply / release |
L |
Release button of the loco brake |
P / O |
Parking brake / pre-controlled (ep) brake on-off |
G |
Sanding |
J / K / I |
Door release left / right, close the doors |
Space |
Sifa (driver's safety device) |
Page Down / End / Delete |
PZB acknowledge / release / override |
N / M / B |
LZB takeover / end / function test |
Y |
Wipers: off → interval → slow → fast (cycles) |
U |
Train type switch (Zugartschalter): O → M → U, at standstill |
^ |
Range selector of a two-range gearbox: shunting gear ↔ road gear, takes at a stand |
H |
Horn |
1–4 |
Battery / pantograph / main switch / compressor |
5 |
Start the diesel engine |
6 / 7 / 8 |
AFB on/off / dial down / dial up (in 10 km/h steps) |
9 / 0 |
Headlights / cab light |
, / . |
Instrument backlighting dimmer down / up |
Esc |
Pause: resume, settings, back to the main menu, quit — the world stands still under the overlay |
F1–F4 |
Camera: driver's seat / external / lineside / first person |
F5 / F6 |
Keyboard sheet / diagnostics overlay |
F7 |
Display: full → reduced → off, and round again |
F9 |
Mod manager (↑/↓ select, Enter toggles; in-game it applies on the next restart, on the main menu it applies on start, rows are clickable) |
| Arrow keys | View direction, Numpad +/- camera distance |
WASD / Shift |
First person (F4): walk (1.5 m/s) and run (5 m/s) through the train and over the ground. The walker falls where the ground drops away, climbs what is no higher than a step, is stopped by what stands at chest height and walks on through the train from vehicle to vehicle. The mouse looks around on its own, the cursor is caught on the crosshair and the driving keys rest until F1 puts the driver back on the seat |
E |
First person: through the door — out beside the train, and back in at any vehicle standing next to you. A passenger door has to be open for it; a traction unit's own cab door opens itself, both only at a stand |
| Mouse | Left button operates the controls of the 3D cab (click, drag, wheel), right button looks around |
content::musterbahn() — 7 km: 3 km straight (160 km/h), 1 km curve R = 1200 m with cant
ramp (130 km/h), 3 km at 8 ‰ gradient. Block signal at km 2.0 with distant signal,
1000/500/2000 Hz magnets, and over the last 4 km an LZB loop cable with block markers of its
own, so the LZB area runs in full block mode.
From the same DGM, content::terrain builds the terrain meshes — only within the corridor
around the line and at graded resolution:
| Distance from track | Grid spacing | Triangles per km² |
|---|---|---|
| up to 96 m | 4 m | 125,000 |
| up to 384 m | 8 m | 31,000 |
| up to 768 m | 16 m | 8,000 |
| beyond | 32 m | 2,000 |
For comparison: unmodified DGM1 would be 2,000,000 triangles per km². On top of that come 512 m tiles (one entity per tile → frustum culling, plus a view distance limit per LOD level), skirts at the tile edges against cracks between levels, and a cutting/embankment profile that pulls the terrain near the track up to rail level.
The ground is textured by splatting: per-vertex weights from slope and track distance blend
grass, rock and gravel. Trees and scenery objects are line content — every one its own entry,
placed by the terrain tile it stands on and spawned as a child of it, so they stream with the
ground. A tree is not a scene instance but one entity per mesh part of its model, sharing the
part's mesh and material, so Bevy batches a wood into instanced draws; _LOD nodes become
distance bands and every tree is culled past 2.5 km. Tiles are built several at a time — the
builder is shared read-only, the DGM sheets keep their own short lock. Terrain, splatting,
vegetation and track objects live in world-render and therefore look the same in the
simulator and in the route editor.
The app shows the terrain automatically (flat without DGM):
cargo run -p app -- --dgm ./dgm1_niedersachsen --epsg 25832For a line across the 12° UTM zone boundary, repeat the pair — one elevation source per
zone; the n-th --epsg belongs to the n-th --dgm:
cargo run -p app -- --dgm ./dgm1_west --epsg 25832 --dgm ./dgm1_ost --epsg 25833There are three separate programs, because the jobs have nothing to do with each other: a route is geodata, a vehicle is a model with a data sheet, a signal model is an assembly of shared parts.
| Program | Purpose |
|---|---|
cargo run -p route-editor |
line: track, equipment, switches, marked track areas, objects, vegetation, terrain, aerial imagery overlay |
cargo run -p vehicle-editor |
vehicle: base data, block diagram (drive, brake, equipment), glTF model, LOD, moving parts, 3D cab, displays, sounds |
cargo run -p signal-editor |
signal model: tags, glTF parts on mount points, lamp bindings, lamp test |
All are desktop applications, not game screens: menu bar, docked panels, the operating
system's own file dialogs. --frames N and --screenshot file.png work in all of them.
The route editor draws the module under the simulator's own sky. Its Time of day section sets the date, the clock, the time zone and the cloud cover, and a slider runs a whole day past in one drag — which is how you find out that the platform lies in the shadow of its own canopy all morning. Latitude and longitude are not edited there: they are the module's anchor, the same pair a run reads, so both programs put the sun over the same hillside. Underneath, the panel reads out where the sun and the moon actually stand. The date and that slider sit in the status bar as well — a calendar behind the date and the sun itself as the handle of the day rail — because the light is judged on the map, not in a panel.
Simulator and editors speak English and German. The language comes from the operating
system; TRAINSIM_LANG=en (or de) overrides it, and both editors switch it at runtime
under View → Language.
The strings live in crates/i18n/locales/<lang>/main.ftl (Fluent) and are
translated on Crowdin (crowdin.yml). A new language is a new directory next to en
plus one line in i18n::LANGUAGES — the source language is English.
cargo run -p vehicle-editor # new vehicle
cargo run -p vehicle-editor -- mods/example/vehicles/br101_afb.ron
cargo run -p vehicle-editor -- mods/example/vehicles/br101_afb.ron --graph # open on the block diagramThe left panel holds the vehicle's base data, the right one the model, the middle shows the
3D viewport with the track and a reference body of the length over buffers — so it is
immediately visible whether the model matches the LÜP. Right mouse button rotates, the
wheel zooms. Chips at the top left of the centre switch it between the 3D model and the
block diagram; the --graph flag starts on the diagram.
Base data (everything that is declaration, not script):
| Field | Meaning |
|---|---|
| Length over buffers | the official LÜP — spacing of the following vehicle. Draw the buffers 1–2 cm compressed in the model so they do not intersect in curves |
| Gauge | checked against the infrastructure, and used for the curve resistance |
| v max | highest permitted running speed, independent of the traction characteristic |
| Mass | tare mass; payload separately |
| Rotating mass | allowance for rotating parts of running gear and drive — acts on the inertia, not on the weight. Diesel-hydraulic 10–15 %, diesel-electric and electric loco 15–25 %, freight wagon 8–10 %, coach 6–9 % |
| Axle base sum | sum over all bogies (two bogies of 2.5 m → 5.0 m), not the vehicle length — the larger the value, the higher the curve resistance |
| Rolling resistance | bearing friction and rolling of the wheel; "Suggest" derives a standard value from the mass |
| Air resistance | cw·A [m²]; F = ½·ρ·cw·A·v². Without it the quadratic Davis term applies |
| Curve resistance | factor on Röckl — 1 = as the axle base sum gives it; lower it for radial steering bogies |
| Tilt angle | 0 for conventional vehicles, ~8° for German tilting units |
| Hunting | −1 no hunting, 0 standard (tuned for bogie vehicles), up to 1 more — raise it slightly for single-axle running gear |
| Max payload | e.g. about 5 t for a passenger coach, per the anscriptions for freight |
Drive, brake, equipment and behaviour are the block diagram — a blueprint-style node
editor: the vehicle is a circuit of components, and the physics follows from what is wired
to what. The palette on the left (searchable, grouped by category) carries every physical
component as a block — pantograph, transformer, tap changer, starting resistors, chopper
and series/parallel switch, traction converter, series-wound and induction motors, diesel
engine with hydraulic transmission and retarder, with a mechanical gearbox and its clutch,
with a hydrostatic drive or with generator and load regulator,
boiler, firebox, cylinders, injector and tender of a steam locomotive, the complete air
or vacuum brake from compressor to brake rigging including EP brake, angle cocks,
limiting and retaining valve, cooling systems, wheelset with its bogies and axles, cab, AFB, the
logic blocks (reading, characteristic, PID, notching, rate of change, switch, output),
Sifa, PZB, LZB, doors and the Lua script hook — plus the presets installed mods bring
(blocks/*.ron, e.g. a Voith L 620 as a preset of the hydraulic transmission). A block is
dragged out of the palette onto the canvas and lands where the pointer lets go; a click on
it appends it below the diagram instead. A right click on the canvas adds a
block, a right click on a node removes it, a drag from pin to pin wires them; pins are
colour- and shape-coded by domain (shaft, force, electrical, pneumatic, signal, fuel,
steam, water, heat), and only like connects to like. Clicking a node puts its data sheet
below the palette —
control valve and friction pairing, engine map, converter circuits, motor data; axle count
and adhesive mass sit on the wheelset block — together with the live bake findings:
the diagram is stored in the vehicle file (graph) and baked into the runtime fields
(traction, brake, safety, doors, …) on save and on load, and every error or
warning of that bake is listed, a click selecting the offending block. A vehicle file
without a diagram opens with one synthesised from its spec. The palette reference, the
wire rules and the preset format: MODS.md.
Models are glTF, and the glTF's own features are used. Levels of detail and moving parts are found in the file; the binding is stored in the vehicle RON, so nothing has to be prepared in Blender — but a prepared file needs no clicking:
| In Blender | Result |
|---|---|
Object name body_LOD0, body_LOD1, … |
"Read from node names" fills the LOD table; the distances stay editable |
Object name door_left, pant_front, sw_throttle, gauge_speed, lamp_left, wheel_1 |
suggested function plus a sensible motion |
Custom property ts_function (plus ts_motion = rotate/translate/visibility/emissive, ts_axis = "0 0 1", ts_amount) |
exported into glTF extras and beats the name |
Node name ending in _NIGHT (lit windows, glowing signs) |
switched on at dusk in every model, no binding needed |
The simulator uses the same data: a vehicle with a model gets its glTF instead of the
placeholder body, the level of detail follows the camera distance, and the bound parts follow
the simulation state (pantograph, gauges, switches, lamps). Models live in the mod and are
addressed as mods://<mod>/assets/<file> — the same string in the editor and in the game.
Cab, displays and sounds are edited in the same program. The cab panel sets the eye point
and binds glTF nodes to cab controls — each with the gesture that suits it, so a lever is
dragged, a button pressed and a rotary switch stepped with the wheel. Instruments are bound the
same way: gauge: pointers, lamp: indicators and digit: seven-segment counters. A screen is
a display rendered to texture, written either as a declarative widget list in RON, as a Lua
display(ctx) hook with menus and softkeys, or as an HTML/CSS/JS page under displays/ —
parsed, laid out and scripted in-engine by html-display, with no browser embedded. The sound
table maps quantities (speed, traction, air, roughness, rain, control clicks) to the mod's own
samples, normally as crossfaded layers — three loops over overlapping speed windows rather
than one stretched by its playback rate. A ▶ per entry plays it through the editor's own
output device with a slider for every quantity it depends on, so the crossfade can be dragged
through by hand instead of guessed at from a sparkline.
Details: MODS.md.
cargo run -p route-editor # example line
cargo run -p route-editor -- line.ron --imagery my_imagery.ronThe line is edited in a flown 3D view over the aerial imagery. The tool palette sits on the left, the selected element's fields on the right; the middle mouse button pans, the wheel zooms. Every edit goes through undo/redo (Ctrl+Z, Ctrl+Y or Ctrl+Shift+Z), the rule check flags what the compiler will reject, and saving guards against discarding unsaved work.
The palette is grouped by what the work is about — the track itself, what is mounted along it, and the landscape it runs through — and every tool carries a drawn icon beside its name. A bar sits above the viewport with the controls that belong to looking rather than to the document: gizmo mode, aerial imagery on or off, and the camera speed. The terrain is always drawn; the imagery is a layer draped over its shape.
Content drawer (Ctrl+Space, or the button at the left of the status bar): a panel
that comes up from the bottom edge with everything the installed mods brought — scenery
objects, signal types, signal models and track types, each with the mod it came from. It is
the one place that answers whether the editor found a newly installed mod at all; the tool
pickers only ever show one kind. Four filters narrow the list: a substring over name, key and tags, the mod an entry came
from, the tag its author gave it (epoch-4, catenary — see
MODS.md; the tag combo only appears where a category has any), and — for the
signal types — the signal system. A filtered list states n of m, and a filter that matches
nothing offers to reset itself. Picking arms the tool the
entry belongs to: an object the object tool, a signal type or a signal model the
place-device tool set to signals — the next click on a track then places a signal that
already carries them. A signal type brings its own default model, so picking one drops a
model override picked before it. Track types are a listing only; they do not react to a
click. Picking closes the drawer, because what it armed is used on the map the drawer
covers.
Everything that has a model carries a rendered preview of it. The editor renders each
one once, off to the side on its own render layer, and reads the picture back off the
render target into an ordinary image — a target only holds its contents while an active
camera points at it. One at a time, and only for what the drawer is actually drawing, so a
catalogue nobody opens costs nothing. Track types show their colour instead, which is what
a track type is. The viewport is flown the way an Unreal one is: hold the right mouse
button to look and fly with WASD (Q/E down and up, Shift slower, the wheel turns the
camera speed dial), Alt+left orbits the view point, the middle button pans, F frames the
selection. Selecting is a question about pixels: whatever is under the cursor, near or far.
The camera speed dial is Unreal's, down to the numbers: eight steps, each one doubling the
flight speed, and a free multiplier of 1 to 128 on top of them for the distances eight steps do
not cover. It sits on the viewport bar as the step with a caret, and the same value is under the
wheel while the right mouse button is held — one notch, one step, so the speed is set with the
hand that is already flying. Shift is the precision modifier and halves the speed, as it does
in Unreal: the dial is what makes the camera fast, Shift is for the last metres up to a signal. The speed is metres per second and nothing else: it is deliberately
not scaled by how far the view point is away, because a speed that changes underfoot as the
builder zooms is a speed they do not control, and controlling it is what the dial is for.
The selection carries a transform gizmo, W for the arrows and E for the rotation ring
(the letters are free the moment the right mouse button is let go). Its axes are the fields the item actually has,
not world X/Y/Z: dragging the red arrow slides a signal along the track (s), the green one
across it (lateral_offset) and the blue one up (height) — so the saved file still reads
like a placement. Trees, markers and terrain strokes are free of the track and get east/north
instead. There is no scale handle, because nothing in the file format has a scale.
A module starts as a place, not as a blank sheet. File → New module (Ctrl+N) asks for
a name and the module's anchor — latitude and longitude as fields, and beneath them a small
OpenStreetMap map with a place search: type "Göttingen Bahnhof", pick the hit, click the exact
spot, and the coordinates fill themselves in. The dialog also asks what the module portrays —
the year and whether it is a rebuild of a real place or invented — and both are written into
the module file. Around that anchor the new module gets its first
envelope, a square of the initial size the dialog asks for (4 km by default), whose corners
are dragged into shape afterwards (see the Envelope tool below, and MODS.md → Modules). The anchor decides which elevation tiles, which aerial
imagery and which neighbours the module will meet, so typing it blind is the one thing worth
a dialog.
The palette is grouped, and the number keys count down it — the key and the button always agree, because both read the same list.
| Tool | What a click does |
|---|---|
| Track | |
1 Select |
Pick a track, device or object and edit its fields; Delete removes it |
2 Draw track |
Every click appends the arc that leaves the alignment tangentially and hits the point — G1-continuous by construction. Enter or right-click finishes, Esc cancels |
3 Place switch |
Splits the track and draws the branch; facing or trailing and the throw time follow in the panel |
4 Mark area |
Press on a track and drag along it: the tool paints a wide coloured stroke over the rails, and that stretch is the area. With an area selected the next stroke joins it. A marked area carries speed, cant, gradient, track type and electrification — set the stretch once instead of editing a step profile per property per track |
| Lineside equipment | |
5 Place device |
Puts the chosen device kind (signal, magnet, LZB, platform, …) on the clicked track |
6 Place object |
Drops a mod's 3D object at its predefined offset and rotation |
7 Place marker |
A reference marker in a named layer — a drawing aid, nothing in the simulation reads it |
| Landscape | |
8 / 9 Tree / forest |
One tree per click, or an outlined area baked into single trees — each one stays editable |
0 Marking brush |
Sweep to mark trees and objects in bulk and delete them together |
| Terrain brush | One stroke per click: raise, lower or level. The track keeps its height, cutting and embankment are laid over it afterwards |
| DGM tiles | Shows the elevation tile grid and picks single tiles for the height import; without a pick the whole corridor is imported |
| Module | |
| Envelope | Reshapes the module boundary: drag a corner, a click on a side adds one there, Delete removes the selected one. Everything the module owns has to lie inside it — the landscape strictly, the track up to the boundary itself |
The scene is rendered into the whole window and the panels are drawn on top of it —
bevy_egui hangs its context on the same camera, so a camera viewport of its own would
squeeze the UI into that rect as well. The camera is therefore shifted sideways instead, by
exactly what the panels cover: the pivot sits in the middle of what is visible, and the
imagery tiles are fetched around that point rather than around a spot behind the side panel.
T swaps the aerial imagery for the module's terrain, built exactly as the run builds it —
so track types, objects and vegetation can be judged against the ground they sit on. The
interlocking (signals, sections, routes), the track areas and the module boundaries are
forms of their own, with a ghost of the neighbouring module at the boundary. Every painted
area lies on the map in its own colour, all the time — a marking that only shows while it
is selected is a marking nobody trusts.
The overlay configuration (imagery.ron) is created on first start and is fully editable:
provider, opacity, zoom level or target resolution, load radius, tile limit, image offset
against the track position, overlay height, cache (location, budget, memory tiles, offline
mode, maximum age) and fetch behaviour (user agent, timeout, concurrency, retries). Changes
can be reloaded at runtime with F5 and written back with F2.
Providers are data, not a hard-wired list. Shipped are Esri World Imagery, BKG
TopPlusOpen, OpenStreetMap and a WMS template for the orthophotos of the state surveying
offices. Your own services are added as an entry — either as a tile template with the
placeholders {z} {x} {y} {-y} {s} {key} or as WMS, whose BBOX is formed from
the tile in EPSG:3857:
(
id: "dop_nrw",
name: "DOP Nordrhein-Westfalen",
url: Wms(
endpoint: "https://www.wms.nrw.de/geobasis/wms_nw_dop",
layers: "nw_dop_rgb",
version: "1.3.0",
styles: "",
extra: [("TRANSPARENT", "FALSE")],
),
max_zoom: 20,
tile_size: 512,
format: Jpeg,
attribution: "Geobasis NRW",
attribution_url: Some("https://www.geoportal.nrw/nutzungsbedingungen"),
)attribution is the credit the service requires; attribution_url is optional and turns it
into a link to the licence, which OpenStreetMap's attribution guidelines ask for.
Availability and terms of use of each service must be checked before use; for bulk fetching, put your own access keys into the configuration.
Cache: tiles end up under <cache>/<provider>/<z>/<x>/<y>.<ext>, with an in-memory cache
in front of it. Once loaded, the line can be edited offline (L toggles offline mode). Disk
space is capped; when the budget is full, the oldest tiles go first. The HUD shows hits,
loads, evictions and usage.
| Key | Function |
|---|---|
Right mouse + WASD Q E |
Look and fly, Shift faster |
| Middle mouse / wheel | Pan the view point / zoom |
Alt + left mouse |
Orbit the view point |
F |
Frame the selection |
W / E |
Move or rotate handles of the gizmo |
1–0 |
Pick a tool (see the table above) |
Ctrl+Space |
Content drawer: everything the installed mods bring |
O |
Aerial imagery on/off — it drapes over the terrain, which is always drawn |
P |
Switch provider |
[ ] |
Opacity |
, . |
Zoom level, Z back to target resolution |
Numpad 4/6/8/2 |
Image offset (with Shift in 5 m steps), 5 to reset |
L |
Offline mode |
C / R |
Clear cache / reset failed attempts |
F5 / F2 |
Load / save configuration |
A scenario is a RON file of events — triggers plus actions:
(
name: "Regionalbahn nach Musterstadt",
start: (year: 2026, month: 8, day: 15, hour: 6, minute: 45, utc_offset: 2.0),
player_train: 0,
events: [
(name: "abfahrt", trigger: Time(5.0),
actions: [Announcement("RE 4711, Abfahrt frei.")]),
(name: "regen", trigger: After(event: "abfahrt", delay: 60.0),
actions: [SetWeather(Rain), Message("Regen setzt ein.")]),
(name: "ziel", trigger: TrainStopped(train: 0, edge: EdgeId(2), s: 2600.0, radius: 50.0),
actions: [Finish(success: true, reason: "Musterstadt erreicht")]),
],
)Scored are timetable adherence, stopping accuracy, emergency brake applications, speed
limit violations and traction energy; the HUD shows messages and the score. A scenario
gets its timetable by reference (timetable: Some("<mod>:<name>"), a timetable/*.ron
in the mod) — without one, only the scenario's own points count. A timetable is either
kind: Scenario (times from the start of the run, runs once) or kind: Daily (times as
seconds since midnight, wrapping around every 24 h). start: sets date and local time
of the run (default: midsummer noon) — it anchors Daily timetables, puts the sun
and moon where they belong for the georeferenced line, and paints the season: meadows
turn through October, ground and trees go under snow from November to March. The sky
those two stand in is a scattering model, not a gradient: Rayleigh and Mie through the
look-up tables of Hillaire's technique, so noon is blue, sunset is red and a valley
twenty kilometres off lies in haze, all of it out of the sun's elevation alone. Behind
them stand the real stars — the naked-eye HYG catalogue, held in equatorial coordinates
and turned by the latitude and the sidereal time, so the pole star sits at the latitude's
altitude and everything rises four minutes earlier each night. The moon is a disk half a
degree wide, lit from where the sun really is, which is where its phase comes from.
--time 22:30 and --date 2026-01-15 move the clock of a run for a screenshot. A mod's
track object may bring its own autumn_model/winter_model glTF — optional, and
whatever it leaves out keeps the year-round model.
SetWeather(Clear | Cloudy | Overcast | Fog | Drizzle | Rain | Storm | Thunderstorm | Sleet | Snow | Blizzard | Hail | Frost) moves the weather there over five minutes — a
front, not a switch. Every preset is a set of physical numbers (cover, cloud base,
precipitation rate, wind, sight, temperature, thunder), and everything downstream reads
those rather than the name: volumetric clouds and their shadows, the haze in the
atmosphere, the rain and snow around the camera, and the water and snow that gather on
the ground and decide what the wheels find on the rail. A scenario can also start in a
weather (weather: Rain beside its start), and --weather snow places one for a
screenshot; in a normal run the same flag lets the front move in over five minutes —
a first drizzle, single drops on the glass, the rail greasy before wet. --wipers 2
starts with them running. SetRail(Dry | Wet | Slippery) still
sets the rail by hand — leaves and sanded rail have no weather to come from — and holds
until the weather next changes. From the driver's seat the rain is on the glass too: a
vehicle names its panes in cab: (windscreen: [...]), and the wiper clears the strip its
blade has just crossed.
Rust stable, edition 2024. Before opening a pull request:
cargo fmt --all
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace- Everything in English — code, comments, documentation, commit messages (see CLAUDE.md).
sim-corestays free of Bevy and deterministic: fixed time step, seeded RNG, no wall clock. Simulation logic belongs there, not in the app.- New behaviour comes with a headless test in the owning crate. Rulebook logic (PZB/LZB, brake) is table-driven — add a case, not a new test harness.
- Deliberate simplifications get a
ponytail:comment naming the ceiling and the upgrade path. - Pick up open points from STATUS.md; larger topics are outlined in PLAN.md. For anything sizeable, open an issue first so the direction is agreed before the work.
Licensed under the EUPL v. 1.2 — contributions are accepted under the same licence. Mods are exempt: RON data, assets and Lua scripts are not derivative works and may be sold under any licence, see the mod exception in LICENSE. Material from other projects that is checked into the repository is listed in THIRD_PARTY_LICENSES.md.
main is the only long-lived branch. Work happens on short-lived feat/… or fix/…
branches (or forks) and lands via pull request; CI runs
fmt, clippy and the test suite on Linux, Windows and macOS.
A release is a tag. Bump workspace.package.version in Cargo.toml, then:
git tag v0.2.0 && git push origin v0.2.0 # release
git tag v0.2.0-rc.1 && git push origin v0.2.0-rc.1 # prereleaseAny tag containing a - is published as a prerelease — the version part must still
match Cargo.toml, otherwise the workflow stops before anything is published.
The release workflow builds the simulator, the route
editor and the vehicle editor for Linux, Windows and macOS (Intel and Apple Silicon) —
the signal editor is built from source for now — packs each together with
mods/ and the licence, and attaches the archives to a GitHub release whose notes are
generated from the merged pull requests.