Pro Engineer/How it works
The program computes. The panel draws.
Nothing here is required reading to use it. It is here because the shape of the thing explains most of its behaviour — why the in-game panel costs no frames, why a second simulator is an addition rather than a rewrite, and why a Linux install needs one extra process that Windows does not.
Three layers, and the arrows only go one way. A game is read, a frame is computed once, and every front end is handed the same frame.
# one folder per game, one Sink per place the frame goes,
# and any number of front ends
Assetto Corsa ──▸ shared memory ─┐
├─▸ the engineer ──▸ one frame
(your simulator) ────────────────┘ │
├─▸ shared memory ─▸ the CSP panel
├─▸ UDP as JSON ─▸ another machine
└─▸ the terminal
The split is not decoration. Whatever a front end needs has been worked out before it is asked to draw anything, which is what makes a Lua panel on a render thread affordable at all.
Assetto Corsa publishes physics, graphics and static session data in named shared memory. Reading it costs nothing and requires no hooks, no injected code and no modified game files — it is the interface the game offers for exactly this.
| Read until two reads agree | A page caught mid-write is internally inconsistent. Each is re-read until two consecutive reads match; a frame that never settles is discarded rather than drawn. One bad frame in a thousand is a spike on a graph and a wrong verdict in an engineer. |
| Sixty times a second | Fast enough that a lock-up lasting a fifth of a second is a dozen samples rather than one, and cheap enough to sit at about 0.1 % of one core. |
| Capabilities, stated honestly | Each game says what it can actually report. “Not measured” and “measured as zero” are different answers, and every wrong verdict this project has shipped came from confusing them — four tyres reading zero wear once became four destroyed tyres. |
Everything the front ends need, worked out once and written into one flat C-layout struct: speeds, pressures, temperatures, wear, fuel, the debrief, and up to eight lines of advice with their severity and confidence.

The frame carries its layout version before anything else, so a reader checks it before trusting a single offset. A panel and an application that disagree say so instead of drawing eight bytes of one field as another.
The panel's copy of the layout is emitted from the struct itself rather than written twice, and a test fails if the two ever disagree about the order of the fields — which size and count matching would not catch.
Fixed, named, and counted from the list rather than from a literal, so growing it is a line per slot in three places and a test that says which one was forgotten.
So the panel can notice that the game loaded an older copy of the application than the one now on disk — a case invisible from every other angle, because the files are current and the panel keeps drawing.
A sink is a place the computed frame is delivered. Shared memory for the in-game panel, UDP as JSON for anything else — a second front end, a friend watching from another machine, a relay for a championship.
| The tick never waits | Publishing hands the frame over and returns. A sink may be slow, may fail, may vanish, and the loop that reads the game must not notice. |
| A failing sink is dropped | After sixty consecutive failures — one second at tick rate — rather than logged sixty times a second for the rest of the session. |
| Remote sinks run slower on purpose | A spectator cannot tell above about ten frames a second, and twenty cars at tick rate is megabytes a second arriving at a relay. |
| The UDP feed is plain JSON | Documented by its own shape. Writing your own front end against it is about fifteen lines of Python. |
| Another copy of this program gets the whole reading instead | Since v0.4.5 there are two things on the wire and neither replaces the other. A frame is a panel's worth of finished numbers; a reading is everything one tick of the simulator said, so the machine receiving it runs its own analysis. The difference is not size — there are no coordinates in a frame, so no map; no throttle, so no traces; no capabilities, so an unmeasured zero and a measured one read alike. |
Nobody types an address unless they want to. Copies announce themselves on a multicast group and appear in each other's list — a couple of hundred bytes every two seconds, carrying a name, a role, a port, the car and the track, and nothing about the driving. Multicast to find, unicast to stream: a group is broadcast to a switch, and thirty readings a second on one is everybody's problem. It is all off unless you switch it on, because it is telemetry about a person.
On one network there is nothing to set up: open LAN, one of you presses
S and the other W, and you pick each other out of a list.
Across the internet, two houses behind two routers is NAT and neither machine can
dial the other — so the usual answer is a forwarded port, or a relay in the middle
that somebody has to run.
There is a third answer, and it needs no code and no server. A mesh VPN — Tailscale or ZeroTier, both free for a handful of machines — gives each computer an address on one private network wherever it happens to be. Four steps, and nothing about the program changes:
| 1 · Both machines on one mesh | Install it on each and sign both into the same network. Tailscale calls it a tailnet; ZeroTier calls it a network you join by its id. |
| 2 · Read the address it gives each | Tailscale's are 100.x.y.z; ZeroTier's are on the range you chose. |
3 · The watcher presses W | And leaves the listening address as 0.0.0.0:9001 — every interface this machine has, which includes the mesh one. Nothing else to set. |
4 · The driver presses S | And types the watcher's mesh address into sending to. It will not appear in the list, and that is expected — see below. |
The list stays empty across a mesh, and that is not a fault. Finding each other is multicast, and a mesh VPN does not carry it — neither does most guest Wi-Fi. Typing the address is exactly the case that box has always existed for, and everything after it works identically. Announcing is only how you are found; it has nothing to do with how a session travels.
On one network, nothing: open LAN, press S to share or
W to watch, and pick each other out of the list. Across a mesh VPN
there is no multicast, so the list stays empty and the address is typed — which is
what that box has always been for.
# the driver, in config.json — send to your friend's mesh address "overlay": { "broadcast_to": "100.64.0.2:9001" } # the friend watching — listen on the mesh interface "overlay": { "receive_from": "0.0.0.0:9001" }
Their screens then draw your session — the dashboard, the traces, the map, the corner table and the engineer. Not a copy of your sentences for them to read: the whole reading travels, so their machine finds the laps, builds the traces, draws the map's line and runs its own engineer over your driving.
| Everything one tick said | Speed, pedals, steering, four wheels of temperature, pressure, wear, load and slip, the session, the car and the track — the same struct a game's reader produces, about two kilobytes, thirty times a second. |
| The analysis is theirs, not yours | Which is what makes every screen work rather than one, and what puts the advice in their units and their language. Before v0.4.5 a spectator got eight finished sentences written on somebody else's machine. |
| Which measurements the game published | Carried with the numbers, so a field their game does not measure is withheld on their screen too rather than drawn as a zero. |
| Not your records | A watcher never writes somebody else's lap into their own personal bests. It is the one thing that would outlive the session, and it is the same rule the demo obeys. |
| Not anything about you, on the group | An announcement carries a name you chose, whether you are driving or watching, a port, the car and the track. A test keeps it that way: a machine with no business here can read every packet and learn a name. |
UDP has no retries, so the question that decides whether this is pleasant or flaky is how big each message is. Over about 1472 bytes and IP splits it into fragments; lose any one of them and the whole message is discarded. A tunnel has less room still — Tailscale and ZeroTier both default to 1280.
| A whole reading, which is what another copy of this program gets | 1917 bytes — two fragments. Measured, and a test holds it there. Larger than one Ethernet frame, so IP splits it and losing either half discards the whole reading. At thirty a second that is about half a megabit per second, one way, per watcher — less than a video call, and far less than streaming a picture of your screen. |
| The summary frame, which is what a panel or a relay gets | About 1200 bytes — one packet, on Ethernet and through a tunnel. A test pins that too, because a field added carelessly would push it over and the symptom would be a stuttering spectator blamed on the network. |
| On a local network | Loss on a switched LAN is close enough to zero that both are simply reliable. Over a mesh VPN the fragmenting is worth knowing about, which is why the rate is a setting and why the screen counts the readings that never came. |
Two probes ship with the source. The first says which of the four halves of the network is at fault — this machine's addresses, the port a session arrives on, the group copies find each other on, and whether anything is actually arriving:
# on the machine that will watch cargo run -p ac_core --example lan_probe # on the other one, aimed back at it cargo run -p ac_core --example lan_probe -- send 192.168.1.42:9001
The second runs the real engineer, sends the finished frame through the real socket and prints what came out the far end — the older, summary path, which is still what a panel or a relay reads:
# both ends on this machine — proves everything but the network cargo run -p ac_core --example share_probe # or aim it at the far end's mesh address, and run it there with # no argument to watch it arrive cargo run -p ac_core --example share_probe -- 100.64.0.2:9001
What a complete online telemetry broadcaster would be, and which parts of it exist today. Everything marked done has been run, not reasoned about — the caveats are as interesting as the ticks, so both are here.
✓ works, and has been run ~ works, with something worth knowing · not built, and why
MAGIC and a schema number in front, so a datagram on a shared port is recognised or refused rather than mis-parsed.share_probe runs the real engineer, sends through the real socket and prints what arrived and how many bytes it was.A mesh VPN is somebody else's service, and that is the trade. Nothing about your telemetry is stored by it — it carries packets between two machines you control — but it is an account with a company, which is a different answer from the rest of this program. On one network you need none of it.
The in-game panel is a Custom Shaders Patch Lua app, and Lua runs on Assetto Corsa's render thread. At 165 Hz a millisecond is a sixth of the frame budget, and LuaJIT collects garbage mid-frame — so a panel that parsed text or built tables every frame would show up as a stutter rather than as a lower average frame rate.
| It computes nothing | Every value it draws was calculated on the desktop side. The panel reads fields out of a struct and hands them to the UI. |
| Nothing is allocated per frame | That can be allocated once. Colours come from preallocated constants rather than being built inside the draw path. |
| Text is formatted on arrival | Once per settled frame, not once per draw — and only the fields actually on screen are copied. |
| Drawn at any size | The platform offers five fixed font tiers and none of them is readable at 4K, so the panel draws text at a chosen size instead. |
On Windows the application creates the shared mapping itself and there is nothing else to run. On Linux the game runs under Proton, so its shared memory lives inside the Wine prefix — and only a Windows process in there can create a mapping under the name the game is allowed to open.
That is the whole job of shm-bridge.exe: it runs inside the prefix and
passes the mapping out to the native Linux side. It began as
Damir Jelić's work and is used
and extended here under its own MIT licence.
| It can be identified while stopped | The version is compiled into the binary, so a bridge sitting on disk and not running can still be asked what it is — there is no running a Windows binary from Linux to ask it. |
| It says what it mapped | A note naming its version, the protocol, how many bytes it mapped and under what name, removed on a clean exit. |
| A mismatch is reported rather than silent | A bridge older than the frame maps too few bytes and CSP quietly refuses to open the mapping — no error anywhere. The application checks instead, and the panel has one screen that names which of the three pieces does not fit. |
| Fetching one refuses a downgrade | The updater will not install a bridge that predates overlay support, because that bridge would map the game's own pages and never create the overlay mapping at all. |
A game is a folder under core/src/games/ implementing one small
interface. It is not conditionals through the middle of the engineer, and that is
deliberate: the engineer's rules should never have to know which simulator produced
a number.
| A source of frames | Whatever the game publishes — shared memory, a UDP feed, a log — read into the same shape the rest of the program works in. |
| An honest capability list | What it can actually report. This is the part that matters most: a game that cannot measure tyre wear must say so, not report zeros, or the engineer will announce four destroyed tyres on lap one. |
| Its own paths | Where the game lives, where its setups are, where a front end would install itself. |
| Nothing else | The engineer, the corner analysis, the confidence model, the debrief, the terminal and the frame are all shared, and none of them changes. |
One implementation is not an abstraction, it is a guess. The second game is deliberately not started until the first is finished — the guesses are far cheaper to correct while there is one consumer of them. If you want a particular simulator, say so on the issues page; what it publishes decides how much of the above is even possible.
AGPL v3. Use it, race with it, read it, fork it. Change it for yourself and nothing at all is asked of you — the licence conditions passing a copy on, not what runs on your own machine. Publish something built on it and the one condition is that your source is open too, with this project credited.
Keeping your own source closed, or selling a product with this code inside it, needs written permission first: rgoshbbb@gmail.com. LICENSING.md is the plain-language version, including what counts as using the code — a translation into another language, an AI's included, is a derivative work; ideas and algorithms are not.