OnTrack is a PyQt6 telemetry dashboard for Assetto Corsa. Speaks AC's two first-party transports directly; no in-game plugin. Read this once before touching code so you don't relearn the gotchas the hard way.
pip install -e ".[dev]" # one-time
ontrack # launches the dashboard (or: python -m ontrack_dashboard)
pytest # 25 tests, all should be green
python -m ruff check ontrack_dashboard tests
python scripts/monitor.py # one-shot data audit; --watch for continuousThe dashboard logs to stderr. The launch helper used during this
session redirects to dashboard.log (gitignored). View → Console or
Ctrl+Shift+C for the in-app live view.
Assetto Corsa (Windows)
│
├── UDP port 9996 ────► UDPReceiver (QThread)
│ RTCarInfo 328 B │
│ ▼
│ TelemetryPacket.from_bytes
│
└── Local\acpmf_physics ──► SharedMemoryReader (QThread)
SPageFilePhysics │
▼
PhysicsPacket.from_struct
MainWindow._dispatch_merged()
── dataclasses.replace(udp, fuel=…, tyre_temps_c=…) ──►
widgets paintEvent
Two independent transports run in parallel QThreads. MainWindow
keeps the latest of each and overlays physics-only fields (fuel, tyre
core temps) onto the latest UDP packet before pushing to widgets via
dataclasses.replace. Widgets stay oblivious to which transport
supplied which field.
-
AC's embedded Python 3.3 has no
_socket. Means no plugin canimport socketwithout bundling_socket.pydfrom a long-EOL build. We dropped the plugin entirely in favour of AC's native transports. Don't rebuild the plugin path. -
UDP handshake is required. AC won't push anything until the client sends
struct.pack('<3i', 0, 1, 0)(operation 0 = HANDSHAKE) to port 9996. After the 408/808-byte response, send op 1 (SUBSCRIBE_UPDATE). Seenetwork/udp_receiver.py. -
AC handshake strings use
%as end-of-string marker, not the C null. They're also padded with uninitialised stack memory after the terminator._decode_wcharintelemetry.pycuts at whichever marker comes first (%or\x00). Regression tests cover both. -
Windows raises
ConnectionResetErroron UDP recvfrom when the destination has no listener (ICMP "port unreachable"). We disable it withSIO_UDP_CONNRESET=Falseat socket creation AND defensively catchConnectionResetErrorin the recv loop. Without this the receiver loop dies the moment AC isn't in a session. -
AC allows only one UDP subscriber at a time. Running the dashboard and
scripts/monitor.pysimultaneously means the monitor gets zero frames. Stop the dashboard first or use the in-app console for live monitoring. -
Shared memory persists briefly after AC exits. Don't assume "SHM available" means AC is in a session —
acs.exemay have already quit. Check the process list if confused. -
AC sends (0, 0, 0) coordinates during loading screens. The circuit map widget filters these out so the trace doesn't anchor on an origin point that never gets driven.
ontrack_dashboard/
├── main.py entry point + logging config + LogBus install
├── app.py MainWindow, the three-column layout, signal wiring
├── telemetry.py wire-format types: TelemetryPacket, SessionInfo,
│ the handshake protocol, RTCarInfo struct fmt
├── theme.py colors, fonts, GLOBAL_QSS, build_app_palette
├── logging_bridge.py singleton LogBus + handler for the in-app console
├── network/
│ ├── udp_receiver.py UDP client (handshake, subscribe, re-handshake)
│ └── shared_memory.py SPageFilePhysics ctypes struct + reader thread
├── settings/
│ ├── config_manager.py ~/.config/ontrack/settings.json
│ └── settings_dialog.py
└── widgets/
├── card.py NeumorphCard base (dual paint-time shadows)
├── shift_indicator.py RPM LED strip + gear character
├── speed_display.py big cyan number + half-arc
├── acceleration_display.py G ball with magenta trail
├── fuel_display.py vertical bar, amber number
├── pedals_card.py throttle + brake bars
├── input_graph.py rolling 7s throttle/brake trace
├── assists_card.py ABS + TC pills (off / on / ACTIVE)
├── wheel_temps_card.py 2x2 corners, color-coded by temp
├── car_info_panel.py car/driver/track from handshake
├── circuit_map_card.py auto-recorded track outline + position dot
├── race_stats_panel.py lap counter, current/best/last, delta
└── console_window.py in-app log + live values, with filters
tests/ 25 pytest cases (run `pytest -v` for the list)
scripts/monitor.py standalone CLI audit tool
- Frozen dataclasses with slots for all wire-format types
(
TelemetryPacket,PhysicsPacket,SessionInfo). Never mutate; always construct new ones (usedataclasses.replacefor overlay). from __future__ import annotationsin every module.logging.getLogger(__name__), neverprint.- No type checking framework, but every public function gets type
hints. Use
X | None(PEP 604),tuple[...]generics, etc. Project is Python 3.10+. - Ruff config in
pyproject.tomlenforces E/F/W/I/B/UP/SIM. Run it after any non-trivial change. - Widget update signature is always
def update_data(self, packet: TelemetryPacket). If a widget needs session metadata (car_name/track_name/etc.), add a separateset_session(session)method and wire it inMainWindow.on_session_info. - Painting: each widget extends
NeumorphCardand overridespaint_content(painter). Useself.content_rect()for the drawable area inside padding+shadow margins. Use theme constants (ACCENT_CYAN,FG_MUTED,FONT_LABEL_CAPS, etc.), never hardcoded colors or fonts.
Available from TelemetryPacket but not consumed by any widget:
g_vert(vertical G)- Most of the RTCarInfo wheel arrays (
slipAngle[4],slipRatio[4],tyreSlip[4],ndSlip[4],load[4],Dy[4],Mz[4],tyreDirtyLevel[4],camberRAD[4],tyreRadius[4],tyreLoadedRadius[4],suspensionHeight[4]) — we don't unpack them past the float slots we use. To consume any of them, extendTelemetryPacketand update the positional unpack infrom_bytes.
Available from PhysicsPacket:
tyre_pressures,tyre_wear— captured, ready to be displayed- AC's shared memory carries ~80 more physics fields we don't parse
(suspension travel, damage, heading/pitch/roll, DRS, TC level,
carDamage[5], etc.). Add to the
SPageFilePhysicsctypes struct in declaration order, then expose viaPhysicsPacket.from_struct.
In rough priority order:
-
InputGraph button + filters. The user asked for an action on the input-trace card that opens a detail window showing full session / last race / last lap, with filters to "improve driving on a specific circuit". Probably needs a
SessionRecorderthat accumulates samples per lap (detect viapacket.lapincrement), stored inMainWindow. Then a detail QDialog driven from a "…" button on the InputGraph card. -
Display tyre pressures / wear. Both already in
PhysicsPacket. Either extendWheelTempsCardto show secondary readings on hover/click, or add a separate tyre-detail card.
pytest— must stay green.python -m ruff check ontrack_dashboard tests— must say "All checks passed!".- Launch the app (
ontrack), open the in-app console (Ctrl+Shift+C), watch the Live Values tab + Log tab with AC running in a session. Field updates and packet counters prove the change is live, not just compiled. - If you broke the wire format,
scripts/monitor.py(with the dashboard stopped) shows what the protocol layer actually decodes.
- Don't rebuild the plugin. It can't work without vendoring
_socket.pyd, and we deliberately went a different route. Seefeedback-ac-python-pluginsmemory. - Don't add per-widget visibility toggles to settings. The curated
layout is intentional; the old
show_speed/show_rpmflags were removed when we restructured. - Don't print to stdout/stderr. Use
logging.getLogger(__name__)so the in-app console captures it. - Don't hardcode colors or fonts. Use the theme constants.
- Don't force-push without
--force-with-lease. When pushing to a divergent main, see thefeedback-ontrack-pushmemory for the established workflow. - Don't let an exception escape a
QThread.run(). An unhandled exception in a reader thread'srun()aborts the whole process — the window just vanishes with a traceback on stderr, no dialog. The SHM reader's_sleeponce handed a negative duration totime.sleep(clock slipped past the deadline mid-loop) and took the app down on a track reload (mugello → ks_vallelunga). Now fixed by clamping the remaining time; regression test intests/test_shared_memory.py(test_sleep_never_passes_negative_to_time_sleep). Guard loop bodies and clamp any computed sleep/timeout you pass into stdlib calls.
# What's actually in dashboard.log right now
Get-Content C:\Users\chris\Documents\Github\OnTrack\dashboard.log
# Force-read AC's log even when it shows 0 bytes (Windows open-file lock)
$log = "$env:USERPROFILE\Documents\Assetto Corsa\logs\py_log.txt"
$fs = [System.IO.File]::Open($log,'Open','Read','ReadWrite')
(New-Object System.IO.StreamReader($fs)).ReadToEnd(); $fs.Close()
# Is AC in a session right now?
Get-Process | ? { $_.ProcessName -in @('acs','AssettoCorsa') }
# What's on UDP 20777/9996?
netstat -ano -p UDP | Select-String "9996|20777"