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.
The binary assets of the mods — the character models in mods/people/assets/, the tree
sheets and buffers in mods/trees/assets/, sounds — are stored with Git LFS (see .gitattributes). Install
it once per machine before cloning (git lfs install; Arch: pacman -S git-lfs, or
mise use -g git-lfs), and a clone fetches them by itself; a clone made without it holds
pointer files instead, which git lfs pull replaces. The mod loader recognises a pointer
where a model should be and says so on the mod manager page rather than failing the glTF
parse.
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 --console # …with the F8 console 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 -- --line example:beispielstrecke --day example:beispieltag --service 0 # a service out of a 24 h timetable
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 -- --loco example:br101_afb --camera fly # free dev camera — also togglable with `fly` in the F8 console
cargo run -p app -- --camera walk --character people:f01_lena # a body of your choice (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 begins with the run, and
everything else follows from it. The list offers two kinds of run: a scenario, and every
playable service of an operating day — a whole 24-hour timetable that starts over at
midnight (see Timetable runs below). Both stand under the route they take, and picking
one picks that route with them: a scenario names the line it plays on, a service the line of
its plan, so the route is derived rather than asked for. Only what the run leaves open is a
step of its own — the line where the run names none (which is what the free run is), the
vehicle where the run does not bring one, and for a service the date and the weather
before it starts. The steps stand in a numbered rail across the top with what was picked under
each, so a prepared scenario is one question and the free run is three. 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; train number, departure and arrival of a service.
↑/↓ 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, --day,
--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, run, line, loco, setup, 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.
On Linux the four programs run natively on Wayland: winit picks Wayland whenever WAYLAND_DISPLAY or WAYLAND_SOCKET is set and falls back to X11 (or XWayland) otherwise.
Several worktrees on one machine can share their intermediate artifacts, so dependencies
compile once per machine: a .cargo/config.toml in a directory above the worktrees (nothing
lands in the repo) sets build.build-dir to one shared directory and [unstable] checksum-freshness = true, and a rust-toolchain.toml next to it pins nightly. The checksum
fingerprints are what makes sharing safe — with mtimes, stable Cargo takes a worktree whose files
are older than another worktree's build for fresh and runs the wrong binary. Measured: a fresh
worktree builds and runs app --features dev in 54 s instead of 366 s, recompiling only the
workspace crates; builds in different worktrees take turns on the build-dir lock. sccache as
build.rustc-wrapper also works across worktrees but gains little here (366 s → 351 s):
serde_core and thiserror include! build-script output from OUT_DIR, so their crate hash
carries the build-dir path and every crate above them — most of Bevy — misses in another directory.
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, or the same --day/--service for a timetable run;
a fingerprint over line name and consists is exchanged on joining and complained about in the
log when it differs. An operating day needs nothing beyond that: which of its services are out
is a pure function of the clock and they claim their units in departure order, so server and
client keep dispatching the same trains at the same indices for as long as the run lasts,
without a message about any of it. The AI that drives them stays the server's. 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. |
|
volumetric_clouds |
Marches the cloud deck as a volume — billows, a lit interior, a silver lining. Off draws the same clouds as one lit sheet at the same resolution, for about a twentieth of the cost. | |
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. |
|
upscaling |
Temporal upscaling of the picture: Off, Fsr (any GPU) or Dlss (an NVIDIA RTX card on Vulkan — offered only where it can run, and only in a simulator built with the dlss cargo feature, which the released Windows and Linux builds are). The page offers a technique only where it can run. An upscaler smooths the edges itself, so it takes MSAA off the camera while it is on. |
|
upscaling_quality |
How much of the picture is really drawn: Low half the edge in each axis, Medium 59 %, High two thirds. |
|
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), ESRI ASCII Grid (.asc) and GeoTIFF (.tif, the
single-band float tiles NRW delivers). 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) |
app-icon |
The window icon of all four programs: the drawing itself, the Windows resource beside it |
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) |
fields |
Farmland from the state agricultural registers (InVeKoS): which state a place is in, the WFS clients, crop code mapping, geometry clean-up, phenology |
vision |
Reading the aerial imagery with a local model: the model registry, a pure-Rust ONNX runtime, the walk over the imagery, what a tree crown is made of, and the car parks a crowd of cars implies (ch. 15) |
world-render |
Rendering shared by app and route editor: terrain tiles and splatting, vegetation, farmland, 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, fields, 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 |
Insert / Home |
Couple to what stands ahead / uncouple behind the occupied vehicle |
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 |
F8 |
Console: weather and time move the world (Tab completes, ↑/↓ history, Enter runs, Esc closes). Against a server the weather is asked of it, the clock stays single player |
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 |
Space |
First person: jump — a push off the ground of about half a metre, wherever he is standing (on the ground, on a platform, in the train) |
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. A portal at each end says where trains come
from and where they go.
mods/example/lines/beispielstrecke.ron is the hand-written one, and the one to shunt on:
4 km of running road with a Ks main signal and a semaphore, then a turnout at km 4.0 into a
stabling siding, with a portal at each end of the line.
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. It is sized like the real thing: the
formation (Planum) of a single track reaches 4 m to each side, the embankment or cutting
runs from its edge down to the natural ground within 12 m, and edges can be laid without
a formation at all (formation: false in the line file) — bare rails on bridges, platforms
or ground the builder shaped themselves, with no ballast bed, embankment or gravel strip.
The ground is textured by splatting: per-vertex weights from slope and track distance blend
grass, rock and gravel — gravel full on the formation, fading out by 7 m so the embankment
slopes stay grass. 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 vegetation names its own (lod_distances in the object) scaled to how
big the plant is — a 30 m spruce hands from leaf geometry to its whole-tree image at 105 m
and is drawn to 2.5 km, while a small blackthorn keeps full detail to at least 45 m and is
gone at 700 m.
mods/trees fills them: forty-six trees, shrubs and understorey plants of Central Europe,
three individually shaped individuals of each, four levels of detail, and seasonal models. They are
optimised from Midge “Mantissa” Sinnaeve's hand-modelled
CC0 tree packs and Poly Haven's CC0
fir, pine
and plant models; tools/trees/import_mantissa.py
retains source branches and foliage for the first two levels and bakes multi-view
whole-tree impostors only for distances of at least 400 m —
see tools/trees/README.md. The route
editor's forest brush mixes them by stand: any tree object tagged stand-mischwald,
stand-nadelwald, stand-bahndamm and so on is a member, and a painted wood draws from all
of them. tools/trees/bench_forest.mjs measures what a wood costs — the checked 20,000-tree
forest over an 8 km line holds 140 fps in a debug build. 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.
Parametric buildings use the same streamed terrain path without replacing static scenery.
Their route entry keeps an editable recipe (residential, commercial or industrial use,
width, length, floor count and height, five roof shapes, facade/roof material, colour,
window rhythm, balconies and a stable variation seed). The terrain compiler normalises and
bakes that recipe into a compact placement; world-render caches one three-level LOD mesh
set per distinct recipe and shares a global PBR library of plaster, red/yellow brick,
concrete, metal panels, clay tile, slate, standing seam and bitumen. Window illumination is
chosen deterministically from the seed and appears only at night. Copy/paste preserves the
whole recipe and seed, while all fields remain editable in the module editor.
Fields lie on that ground: the outline and the crop are line content, and what a field looks like on the day is a function of the crop, the date and the field's own seed — winter wheat is blue-green in April, gold in the last week of July and stubble in August, and no two neighbouring fields are cut on the same afternoon. Each terrain tile carries one surface per crop on it, draped on the tile's own height grid and cut to the track's formation, so a tile costs one draw call per crop and the whole line costs thirteen materials. Which way each field was worked comes out of its own long axis, and the furrows and the sprayer's tramlines run along it — the single biggest thing the eye picks up about a field.
The fields themselves come from the state agricultural registers: every EU member state
has to publish what its farmers declared, and File ▸ Import fields… in the route editor
asks them for the module's envelope. Six states give the parcel with its crop; six give the
field block alone, and the crop is then drawn from the regional cropping statistics, seeded
by the parcel's id — the single field is wrong about as often as the statistics say, and the
landscape is right. Rhineland-Palatinate publishes nothing at all, and a module abroad has
no German register under it at all; both fall back to OpenStreetMap's landuse=farmland,
which is thinner than a register and share-alike, and the import says so. See Fields in
MODS.md for the crop groups, the licences and how to correct a mapping — and cargo run -p app -- --scenario example:boerdefahrt for five kilometres across the Soester Börde with 134 real parcels
beside the track, its roads out of OpenStreetMap and the state DGM1 under it. All three
imports re-run headless in one command (cargo run -p content --bin import-module -- …),
which is how the module is regenerated.
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 tools sit in a toolbox on the left edge, after the World Editor of Train Simulator
Classic: the top box holds the categories — track, lineside equipment, vegetation, terrain,
module — the middle box the tools of the one that is up — led by the select tool, which
belongs to every category: picking something is wanted whatever box is up, so 1 is
always it and the category's own tools count from 2. The key and the button always
agree, because both read the same list. A bottom box carries the active tool's own
switches — the radius snap, easements and terrain snap while laying, the stake-out's
easements while joining, snap to terrain while placing objects — where the World Editor
keeps its context
options. The form panel, docked on the right edge, carries the active tool's remaining
options in its Tool section — the World Editor's properties panel for the piece about to
be laid, which never touches one already lying there.
| Tool | What a click does |
|---|---|
| Every category | |
1 Select |
In every category: pick whatever stands on the map and edit its fields; Delete removes it. Ctrl+click gathers devices, objects, parametric buildings, trees and markers into a multi-selection (a second Ctrl+click takes one out again); a press on empty ground dragged open selects everything inside the circle — Ctrl adds it to what is gathered, Delete removes the lot in one step |
| Track | |
2 Lay track |
Press and drag sets the standing end and its heading — on an open end it continues that track, on a track's middle it starts the branch of a turnout (drag along the track = facing, against it = trailing). Every further click appends the arc that leaves the alignment tangentially and hits the point — G1-continuous by construction — or a straight while Ctrl is held; the running end snaps onto open ends and closes the gap with two tangent arcs. The status bar reads out length and radius. Enter or right-click finishes, Esc cancels. The Tool section sets what the piece is laid as: track type, speed, gradient, electrification, parallel tracks at a spacing; the toolbox's toggle box snaps radii onto the standard series, lays easements — a curve then goes down as clothoid – arc – clothoid with the rulebook's cant for the piece's speed, ramped over the transitions — and snaps the piece to the terrain: sampled ground heights become its gradient profile, a free start drops onto the surface, an end joined onto other track keeps that track's height |
3 Split track |
Cuts the track at the click — two tracks on one joint |
4 Join ends |
First click one open end, then another: ends on the same spot are welded into one node, ends apart are staked out like Zusi's Absteckrechner — transitions, arc and one compensating straight (the radius on automatic grows until exactly one remains), or a double arc with an intermediate straight where no single arc reaches. The Tool section carries the staking parameters: design speed, radius (0 = automatic), transition length, cant, and the least intermediate straight; the transitions themselves are the toolbox's easement toggle |
5 Parallel track |
Lays the clicked track's parallel at the set spacing, on the side of the click — exact offsets for straights and arcs |
6 Crossover |
First click cuts the track it leaves, the second names the parallel track it reaches: both are cut and wired into the two turnouts of a crossover, built from arcs of the set turnout radius |
7 Gradient |
Puts a gradient break point on the track; the selection panel edits the per mille between the points and reads out the climb |
8 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 | |
2 Place device |
Puts the chosen device kind (signal, magnet, LZB, platform, …) on the clicked track |
3 Place object |
Drops a mod's 3D object at its predefined offset and rotation; the toolbox's terrain-snap toggle bases it on the ground instead of the rail plane |
4 Parametric building |
Places an editable residential, commercial or industrial building. Its properties panel controls dimensions, floors and total height, facade colour/material, five roof shapes, windows, stable night lighting and balconies; Ctrl+C / Ctrl+V preserves the complete recipe |
5 Place marker |
A reference marker in a named layer — a drawing aid, nothing in the simulation reads it |
| Vegetation | |
2 / 3 Tree / forest |
One tree per click, or an outlined area baked into single trees — each one stays editable |
4 Field |
Clicks outline a piece of farmland, Enter or right-click closes it, the crop comes from the tool options. The usual way to get fields is File ▸ Import fields…, which fetches them from the state agricultural registers |
6 Marking brush |
Sweep to mark trees, objects and parametric buildings in bulk and delete them together |
| Terrain | |
2 Raise ground |
One lifting stroke per click, by the set amount and radius. The track keeps its height, cutting and embankment are laid over the strokes afterwards |
3 Lower ground |
The same stroke downward — a hollow, a pond bed, a pit |
4 Flatten |
Pulls the circle to the ground height under the click — the plateau gesture |
5 Level to rail |
Pulls the ground to the height of the nearest rail — forecourts, depots, level yards |
6 DGM tiles |
Shows the elevation tile grid and picks single tiles for the height import; without a pick the whole corridor is imported |
| People | |
2 Footpath |
Clicks draw a way people walk up and down; Enter or right-click finishes, Esc cancels. On a drawn way: drag a vertex, click a side of the selected one to add a vertex there, Delete removes the held vertex (or the way) |
3 Walk area |
The same for a polygon people are about on — some wander between spots inside it, the rest stand. The panel sets how many, the share that walks, and the height above the ground |
| Module | |
2 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 |
| Imagery | |
2 AI area |
Clicks outline the patch of ground a model is let loose on, Enter or right-click closes it. The other way to say it is a corridor along the track, which needs no gesture — see File ▸ Detect from imagery… below |
A turnout no longer needs a tool of its own: laying from the middle of a track is the switch, split and wired on finish, and whether it is faced or trailed is in the first drag. Picking a track type in the content drawer arms the lay tool with it, the browser-first order of the World Editor.
While the track category is up, the map wears the World Editor's own markings: the spline line over every edge (over aerial imagery the grey rails vanish at height — the line is what keeps the alignment readable), grey squares at rail joints and switches, red squares at loose ends — the thing to continue from or to fix — and, on the selected track, the red/blue direction arrows out of its start and end, which say which way the metre figures of the panel run. While laying or joining, the end the cursor would take turns accent and the join tool's first pick is filled.
Placement is previewed before it happens: the object and tree tools carry a ghost of
the model at the cursor, standing on its track snap with the spec's own offset and
rotation — the World Editor's loose preview — and the device tool marks the snap point
and track direction the stamp would take. A double click on anything selectable
sends the properties panel to its selection section, and with the gradient tool the map
wears slope chevrons: a V every 60 m pointing uphill on every graded stretch. The
editor also remembers language, window size and panel width between runs
(settings.rs, %APPDATA%\Connected Rails\route-editor.ron), the vehicle editor's
pattern — --window and TRAINSIM_LANG override the stored values for one run.
The viewport bar carries the orientation controls a free camera over an aerial picture needs: a compass whose needle shows where north lies (click to face north) and a top-down toggle that tips the camera vertical — the World Editor's 2D-map gesture. Beside the camera speed dial sits the panel fold: the properties panel gives the map the whole window and comes back by itself the moment something jumps into it (the findings badge, an area row). All of it is under View as well, and the File and Edit menus name their keyboard shortcuts beside the entries.
The form panel — tools on the left edge, properties on the right — follows the toolbox: beside the fixed Tool and Selection sections it shows only the active category's own — marked stretches and the interlocking with the track, the interlocking and marker layers with the equipment, the height data (DGM import) with the terrain, and everything the module is (boundaries, time of day, checks, imagery, cache) under the module category. The jump bar reads the same list, so the panel stays as short as the work in hand.
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 |
The photograph the editor drapes over the ground is a survey of the real place, and most of what a builder does is transcribe it: every car in the station car park, one click each. File ▸ Detect from imagery… has a local model do it.
Everything runs on this machine. The runtime is tract, a pure-Rust ONNX implementation compiled into the editor — there is no service to sign up to, nothing is uploaded, and a module can be built on a train with no signal.
Where it looks is the point. A model let loose on a whole module would fetch a square of imagery a hundred times bigger than the line and take an hour about it. So the dialog asks for one of two areas:
- Along the track — a corridor of a stated width, which reaches the station forecourt and the goods yard without walking the fields behind them, or
- In the drawn area — the ring drawn with the AI area tool, or the circle the select tool last grew round something.
Both carry the same second condition, Keep clear of track: whichever area was chosen, nothing is placed within that many metres of a rail. A car standing in the four-foot is worse than no car at all.
And nothing is placed on a carriageway. A photograph shows the cars that were driving
when it was taken as readily as the ones that were parked, and nothing in the picture tells
them apart — but a parked car left in a running lane is a road blocked for the simulator's
own traffic, which is worse than a car park a few cars short. So every find whose middle
falls inside a road's width is dropped. Measured to the kerb and no further: a car at the
roadside has its centre just beyond it, and kerbside parking is most of the traffic beside a
street. A car park this dialog paved on an earlier run carries parking and is not a street
— otherwise a second run could not fill the bays the first one found. The rule can only hold
to roads that are in the file, so a module whose roads have not been imported yet is told
so in the report rather than quietly given a lorry in the fast lane.
The run happens on a thread of its own with a progress bar and a Stop that means it, and nothing is written until Commit — the report says what was found and what is installed to place it with, and the whole run is one undo step.
For a module being rebuilt from its sources by a script there is --detect-run, beside
import-module:
trainsim-route-editor mods/example/lines/boerde.ron --detect-run \
[--corridor 80] [--keep-clear 8] [--model dota-obb]It runs the same detection along the track, commits it and writes the line file back, printing the progress and the report to the log. The dialog stays the way a person should do this — reading the report before a few hundred objects go into a module is the point of it — and the flag is for the case where nobody is sitting in front of it.
Car parks are not detected. They are the cars: a cluster of them is fitted with a rectangle along the rows they stand in and paved with an unmarked asphalt area, which is an ordinary road afterwards and can be dragged about like one.
A crown detector run over the same imagery plants the wood that is actually there. A class
of a model can say it is a tree (kind: Tree in ai.ron), and then a find does not
become an object bolted to the track graph — it becomes an ordinary row in the module's tree
list, at the place the crown was, in a species from the installed tree mods, and grown to
the size the crown was measured at. A twelve-metre crown gets a twelve-metre tree: the
species is drawn from those whose own crown is within half again either way (mods/trees
ships every species as three individuals — young, grown, old) and the one that is drawn is
then scaled the rest of the way, within a band, because a spruce squeezed to a third of
itself is not a young spruce but a spruce seen through the wrong end of a telescope.
The crown detector ships with the game: models/deepforest-tree.onnx, which is
DeepForest — the crown model of the field, a
RetinaNet trained on the NEON airborne survey and MIT licensed. It reads a window at five
centimetres a pixel, which is not the resolution it was trained on but the scale at which a
crown arrives the number of pixels across it expects; the finer the imagery a provider
gives, the better the crowns come out.
What kind of tree comes from the model where the model knows. Every crown detector
published today is single-class — DeepForest included, because tree is what aerial
training sets are labelled with — so where a class names a conifer tag as well, the crown
itself is asked: needles are dark, blue-green and hard-shadowed on one side, because a
conifer is a cone; broadleaf foliage is a flatter, yellower dome, and in autumn frankly
orange. Both tests have to agree before a crown is called a fir, so the guess leans to the
commoner tree. It is a guess and the editor says so — a model with species classes of its
own leaves conifer empty and is never second-guessed.
Which trees, in the end, is a question the photograph cannot always answer, and the
dialog says so. Species offers "as detected" — the model's class, and the crown's own
look where the model knows only one — or any stand the installed mods describe
(stand-nadelwald, stand-laubwald, stand-mischwald, …, the same stands the forest brush
plants from). Naming one overrules the guess for the whole run, and the size goes with it:
the stand is then planted the way the forest brush plants it — any of its members, at its
own size give or take a third — because a crown detector reading a provider's imagery
reports the sunlit top of a young conifer rather than its spread, and a spruce wood planted
at that measurement would be a wood of saplings. Where the species come from the model, the
crown decides both the member and the size, which is the point of measuring it.
Everything that follows is what follows for any other tree. An AI-planted wood is drawn by the same vegetation instancer, is picked by the select tool like a tree planted by hand, joins a multi-selection through Ctrl-click or the select circle, and goes with one Delete — single trees or a whole marked stand. A run is one undo step, and nothing about a wood the model found is harder to take back than a wood a person drew. A second run over the same ground changes nothing rather than doubling it: Keep trees apart is the distance at which a crown counts as already planted.
(
id: "deepforest",
name: "DeepForest (Baumkronen)",
file: "models/deepforest-tree.onnx",
input: (width: 768, height: 768, mean: (0.485, 0.456, 0.406), std: (0.229, 0.224, 0.225)),
head: Retina(confidence: 0.3, iou: 0.1),
classes: [
(
name: "Tree",
kind: Tree,
place: "laubbaum", // the tag a broadleaf crown is planted from
conifer: "nadelbaum", // …and the one a dark, shadowed cone gets
span: Some((2.5, 26.0)),// crowns this class covers [m]
),
],
ground_sample: 0.05,
)Retina is the second output layout the editor reads, and it is there because every crown
model worth having is one: a torchvision RetinaNet emits logits per anchor and offsets per
anchor, with no boxes at all — an offset is measured from an anchor, and the anchors are
not in the file. So the shipped model contains the backbone and the head and nothing else,
and the editor rebuilds the anchor grid from the input size. tools/vision/README.md says
why that is the better half of the bargain, and Boxes remains what an Ultralytics-exported
crown detector of your own would use.
The models are data, not code: ai.ron, written next to imagery.ron the first time
the dialog is opened, lists what the editor knows how to talk to — the file, the input size,
the head, the ground resolution the model was trained at, and what each of its classes is
worth on a module:
(
id: "dota-obb",
name: "YOLOv8 OBB (DOTA v1)",
file: "models/yolov8n-obb.onnx",
input: (width: 1024, height: 1024),
head: Oriented(confidence: 0.25, iou: 0.45),
classes: [
// …the model's own list, in the model's own order.
(name: "large vehicle", place: "lorry", size: (9.0, 2.5)),
(name: "small vehicle", place: "car", size: (4.4, 1.8)),
],
ground_sample: 0.3, // m per pixel the model was trained on
)place is a tag, and the editor places an object carrying it from whatever mods are
installed — mods/cars brings seven European vehicles, from a Kleinwagen to a Kastenwagen,
built by tools/cars/ out of generated FBX models: plinth removed, interior removed,
windows turned into dark glass where that is the better picture, and four levels of detail
each. A detector for level crossings, containers or solar farms is therefore an entry in
this file and a mod with objects tagged for it, and no Rust at all. size is the real
footprint of the class: a "car" eleven metres long is two cars the model ran together, and
it is dropped rather than placed. Where a factor of two around one size is the wrong rule —
a crown is anything from a three-metre thorn to a twenty-five-metre oak and both are right —
the class states the range outright as span instead.
Which object, of the ones carrying the tag, is decided by how long the find is. One tag
holds more than one size of thing — lorry here is a 4.82 m Transporter and a 6.30 m
Kastenwagen — and taking either at random stands half the vans a metre and a half out of
their bays. So an object states its footprint (MODS.md, Scenery objects) and nothing
longer than the space goes into it: below about six metres the Kastenwagen is simply not a
candidate. Among what does fit the choice stays what it always was, decided by the place
itself, so the same imagery always draws the same car and a row of bays is still a row of
different ones rather than five copies of the largest estate.
Two detectors ship with the game, in models/, so a fresh clone can read its own
imagery with nothing fetched and nothing signed up to:
| file | finds | from | licence |
|---|---|---|---|
models/yolov8n-obb.onnx |
cars, lorries | Ultralytics YOLOv8n-OBB on DOTA v1 | AGPL-3.0 |
models/deepforest-tree.onnx |
tree crowns | DeepForest (Weecology), NEON survey | MIT |
They are Git LFS objects — git lfs pull on an old clone — and they are converted from the
published pre-trained models by tools/vision/export_models.py, which is the whole of what
was done to them.
The two licences are not the same, and the car one is the loud one. Shipping
yolov8n-obb.onnx means redistributing an AGPL-3.0 work; the EUPL this repository is under
names AGPL-3.0 among its compatible licences, so the combination is provided for, but the
combined work then travels under the AGPL. DOTA itself is released for academic research.
If that does not suit how you intend to distribute this, delete the file — the editor
then reports the weights as not installed and everything else, the tree detector included,
carries on. models/LICENSES.md has the full record and
tools/vision/README.md how to put another detector in its place.
Bringing your own is the same two steps it always was — an entry in ai.ron, and the file
where the entry says:
pip install ultralytics
yolo export model=yolov8n-obb.pt format=onnx imgsz=1024
mv yolov8n-obb.onnx models/ # next to ai.ronA 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.
Besides the scenarios a line can be driven out of its operating day: the whole timetable of a day, looping every 24 hours, out of which the player takes one service.
(
name: "Beispieltag",
line: Some("example:beispielstrecke"),
date: (year: 2026, month: 8, day: 15),
weather: Dynamic, // or Fixed(Rain)
services: [
(
number: "RB 30001", category: "RB",
vehicle: Some("example:br101_afb"), cars: 3,
origin: At(edge: EdgeId(0), s: 200.0, dir: 1), // or Yard("Portal Ost")
stable_at: Some("Abstellgleis 1"), // where its stock goes after
stops: [ /* times in seconds since local midnight */ ],
),
],
)The times are wall clock and wrap at midnight — a service leaving at 23:50 and arriving at
00:12 needs nothing said about days. The run starts two minutes before the service departs,
and the whole plan runs around it: as each other service's hour comes the simulator puts its
train on the line and the AI drives it, and when it is over the unit is put away — onto the
stabling road the service names (stable_at, one of the line's yards:) where it stands
braked in its siding, out through a portal where that is what the road is, or simply out
of service at the terminus where the plan names none. The AI drives it there: a working
with a road gets a shunt move on top of its timetable, and its window stays open ten minutes
instead of three to give it time. Whatever the driver managed, the unit is placed on the road
when that window closes — the driven move is for the look of it, the placement is the
guarantee, and it is what keeps the whole thing a function of the clock so that a dedicated
server and every client put the same trains on the line without sending anything about it.
The next service that needs the same stock takes that unit rather than a new one.
Where trains come from. A spawn point is either a place on the line
(At(edge: …, s: …, dir: …)) or one of its roads by name (Yard("Portal Ost")). A
portal is the edge of the modelled railway: a service whose stock comes out of one
started on a piece of line nobody has built, and one that ends at a portal carries on over
it and is gone. Both a timetable and a scenario may also declare consists: — trains
that simply stand there from the first minute, each naming its vehicles head first, driven
to a timetable where they have one and left braked where they have not. For a scenario that
list is also what its events address: its order is the order the train indices run in, and
player_train picks which of them is the player's.
mods/example/scenarios/rangierfahrt.ron is a shunting scenario built out of exactly that —
a light engine in the siding, three machines to collect from the platform, and Insert to
couple.
Zugfahrt and Rangierfahrt are two different movements, and the simulator draws the line where German practice draws it (Ril 408 / 301). A train movement carries a number, is signalled by the main signals, is only let onto track that has been proved clear, and runs at the line speed. A shunting movement carries none, is let past by Sh 1 and by nothing else — Hp 1 has said nothing to it — may be let into an occupied track, which is the whole point of shunting, and runs on sight at 25 km/h; the 2000 Hz magnet of a signal showing Sh 1 is switched off, because otherwise every shunting movement past a signal at stop would be tripped. A Rangierstraße locks the points and clears Sh 1 while leaving the main signal at stop, and it belongs to the movement that ran over it: it is given back when that train has cleared it, so a second shunt past the same signal takes nothing away from the first, and a route over a road that was occupied to begin with is released all the same.
A movement changes kind by passing a signal. Under Sh 1 it becomes a shunting movement, under a main proceed aspect a train — so a shunt draws up to the starting signal, is given a train route, and leaves as a train, with nothing switching a mode anywhere. A movement standing in front of a Sperrsignal is given the first free shunting route out of it by itself, which is how a unit gets out of a siding without a script.
You can get out. A driver is responsible for one train at a time, or for none — and that,
not where they happen to be standing, is what decides who is on the levers: the AI drives
every train it has a driver for except that one. Walk out of the door and the working is
handed over — the AI takes it on from the stop that is actually next, and a train with no
working at all is secured where it stands rather than driven away. Walk along the platform,
through the door of another train, and sit down at its desk: Tab takes it over, and Tab
again gives it back. The refusals are said out loud rather than swallowed — not from the
platform, not a train that is out of service, not a rake with nothing in it that pulls, not
one somebody else is driving, and in a scenario not any train but the scenario's own, because
its events and its ending name that one. Whatever working is taken is the one that is scored
from then on. Over the network it is a wish the server grants or refuses; nobody takes a train
from under another driver.
Two things are the player's, and they are the reason picking a service opens one more page:
- The date. The plan's own to begin with, dialled a day at a time. It decides the season the ground and the trees wear and where the sun stands at the hour the service leaves — the same 06:15 out of Beispielbach is a summer morning in August and a night run in January.
- The weather. Either dynamic, where the day makes its own out of the date and the
plan's name — two octaves of noise walk a ladder of presets, so fronts move in, the rain
stops and the sky clears again over the hours, and a 24-hour timetable has weather in it
rather than one weather — or set by hand, where one of the thirteen presets is named,
placed at the start with its wet or snowed-on ground, and held. The generated day is a pure
function of
(seed, clock), seeded out of the content, so the same service on the same date brings the same sky on every machine.
--day <mod>:<name> --service <n> takes a service without the menu, --date moves the date
with it, and --weather still overrides the sky for a screenshot. MODS.md documents the
file format; mods/example/days/beispieltag.ron is a complete one, and the built-in Musterbahn
ships an hourly day of its own so the picker has something to offer with no mods installed.
A train is not only driven, it is made up. Insert calls the shunter to couple to
whatever the train stands up against; Home uncouples behind the vehicle the driver
sits in, which is a locomotive running round its train. Both are held down while he works:
the order goes on the lever, and the answer — coupled, uncoupled, or which condition was
not met — comes back on the line the train protection interrupts on.
The refusals are the point. Nothing is ever half done: both parts have to be standing still, the two ends have to be within reach of the gear along the track (a turnout lying the other way puts them out of reach however near they are through the air), and the coupling gear has to match — a Scharfenberg head does not meet a screw coupling, and a bar inside a fixed unit is undone in the works, not on the ground. What parts is the brake pipe too: the part that keeps the driver keeps its air, the other part's hose is left hanging, its pipe drops and its control valves apply. Nobody rolls away.
Somewhere to shunt to is line content. A line names its stabling roads — sidings a
unit can be left on — and its portals, the edge of the modelled world where trains
appear and disappear (a fiddle yard, a junction beyond the last signal). The route editor's
rule check refuses a portal that is not actually at the edge of the line, because a train
appearing in the middle of a running road is not a train, it is a collision. MODS.md
documents the format; mods/example/lines/beispielstrecke.ron has a turnout, a stabling
siding and a portal at each end.
The AI shunts too. A driver can be given a shunt job instead of, or after, a timetable: draw forward to a point, set back onto a road, couple to what stands there, uncouple at a coupler, finish at a stand. It is the same driver as ever — it writes the cab controls and nothing else — and it holds itself to the German shunting speed of 25 km/h, creeping the last few metres onto the buffers. A move ends when the buffers are met, whatever the mark said.
Over the network none of this needs a message of its own: the order is a setpoint in the cab like every other lever, and every peer works out the same consists from it.
Rust stable, edition 2024 — rust-toolchain.toml pins the toolchain to the latest stable, so a
rustup-managed install picks it up on its own. 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.