Skip to content

Repository files navigation

Connected Rails

CI

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.

Build and run

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 one

Without 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).

Multiplayer

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.

Settings

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.

Mods

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.

Data and behaviour are separate

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.

Signals: state machine as data, script only where needed

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
end

Vehicles: declaration plus behaviour hook

A 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
end

Full field reference, sandbox rules and packaging: MODS.md; background and state: PLAN.md ch. 19, STATUS.md.

Importing a line

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.ron

Supported 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.

Workspace

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 display

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] hudFull, Reduced, Off), so it survives the run, and --hud <step> sets it for a screenshot without writing the settings file.

Key bindings

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
14 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
F1F4 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

Example line

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.

Terrain

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 25832

For 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 25833

Editors

There 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.

Language

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.

Vehicle editor

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 diagram

The 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.

Route editor

cargo run -p route-editor                              # example line
cargo run -p route-editor -- line.ron --imagery my_imagery.ron

The 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
10 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

Detecting from the aerial imagery

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.

Trees

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.

Models

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.ron

Scenarios

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.

Timetable runs

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.

Shunting

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.

Contributing

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-core stays 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.

Releases

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   # prerelease

Any 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.

About

OSS train simulator made with Bevy - Driving Dynamics, Air Brakes, PZB/LZB/Sifa, Signal Box and DGM Imports

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages