|
| 1 | +# CLAUDE.md — handoff for the next session |
| 2 | + |
| 3 | +OnTrack is a PyQt6 telemetry dashboard for Assetto Corsa. Speaks AC's two |
| 4 | +first-party transports directly; no in-game plugin. Read this once before |
| 5 | +touching code so you don't relearn the gotchas the hard way. |
| 6 | + |
| 7 | +## Run / test / lint |
| 8 | + |
| 9 | +```bash |
| 10 | +pip install -e ".[dev]" # one-time |
| 11 | +ontrack # launches the dashboard (or: python -m ontrack_dashboard) |
| 12 | +pytest # 24 tests, all should be green |
| 13 | +python -m ruff check ontrack_dashboard tests |
| 14 | +python scripts/monitor.py # one-shot data audit; --watch for continuous |
| 15 | +``` |
| 16 | + |
| 17 | +The dashboard logs to **stderr**. The launch helper used during this |
| 18 | +session redirects to `dashboard.log` (gitignored). View → Console or |
| 19 | +**Ctrl+Shift+C** for the in-app live view. |
| 20 | + |
| 21 | +## Architecture in one screen |
| 22 | + |
| 23 | +``` |
| 24 | + Assetto Corsa (Windows) |
| 25 | + │ |
| 26 | + ├── UDP port 9996 ────► UDPReceiver (QThread) |
| 27 | + │ RTCarInfo 328 B │ |
| 28 | + │ ▼ |
| 29 | + │ TelemetryPacket.from_bytes |
| 30 | + │ |
| 31 | + └── Local\acpmf_physics ──► SharedMemoryReader (QThread) |
| 32 | + SPageFilePhysics │ |
| 33 | + ▼ |
| 34 | + PhysicsPacket.from_struct |
| 35 | +
|
| 36 | + MainWindow._dispatch_merged() |
| 37 | + ── dataclasses.replace(udp, fuel=…, tyre_temps_c=…) ──► |
| 38 | + widgets paintEvent |
| 39 | +``` |
| 40 | + |
| 41 | +Two independent transports run in parallel `QThread`s. `MainWindow` |
| 42 | +keeps the latest of each and overlays physics-only fields (fuel, tyre |
| 43 | +core temps) onto the latest UDP packet before pushing to widgets via |
| 44 | +`dataclasses.replace`. Widgets stay oblivious to which transport |
| 45 | +supplied which field. |
| 46 | + |
| 47 | +## Critical AC behaviours (each one bit us at least once) |
| 48 | + |
| 49 | +1. **AC's embedded Python 3.3 has no `_socket`.** Means no plugin can |
| 50 | + `import socket` without bundling `_socket.pyd` from a long-EOL build. |
| 51 | + We dropped the plugin entirely in favour of AC's native transports. |
| 52 | + Don't rebuild the plugin path. |
| 53 | + |
| 54 | +2. **UDP handshake is required.** AC won't push anything until the |
| 55 | + client sends `struct.pack('<3i', 0, 1, 0)` (operation 0 = HANDSHAKE) |
| 56 | + to port 9996. After the 408/808-byte response, send op 1 |
| 57 | + (SUBSCRIBE_UPDATE). See `network/udp_receiver.py`. |
| 58 | + |
| 59 | +3. **AC handshake strings use `%` as end-of-string marker**, not the C |
| 60 | + null. They're also padded with uninitialised stack memory after the |
| 61 | + terminator. `_decode_wchar` in `telemetry.py` cuts at whichever |
| 62 | + marker comes first (`%` or `\x00`). Regression tests cover both. |
| 63 | + |
| 64 | +4. **Windows raises `ConnectionResetError` on UDP recvfrom** when the |
| 65 | + destination has no listener (ICMP "port unreachable"). We disable |
| 66 | + it with `SIO_UDP_CONNRESET=False` at socket creation AND defensively |
| 67 | + catch `ConnectionResetError` in the recv loop. Without this the |
| 68 | + receiver loop dies the moment AC isn't in a session. |
| 69 | + |
| 70 | +5. **AC allows only one UDP subscriber at a time.** Running the |
| 71 | + dashboard and `scripts/monitor.py` simultaneously means the monitor |
| 72 | + gets zero frames. Stop the dashboard first or use the in-app |
| 73 | + console for live monitoring. |
| 74 | + |
| 75 | +6. **Shared memory persists briefly after AC exits.** Don't assume |
| 76 | + "SHM available" means AC is in a session — `acs.exe` may have |
| 77 | + already quit. Check the process list if confused. |
| 78 | + |
| 79 | +7. **AC sends (0, 0, 0) coordinates during loading screens.** The |
| 80 | + circuit map widget filters these out so the trace doesn't anchor on |
| 81 | + an origin point that never gets driven. |
| 82 | + |
| 83 | +## Repo layout |
| 84 | + |
| 85 | +``` |
| 86 | +ontrack_dashboard/ |
| 87 | +├── main.py entry point + logging config + LogBus install |
| 88 | +├── app.py MainWindow, the three-column layout, signal wiring |
| 89 | +├── telemetry.py wire-format types: TelemetryPacket, SessionInfo, |
| 90 | +│ the handshake protocol, RTCarInfo struct fmt |
| 91 | +├── theme.py colors, fonts, GLOBAL_QSS, build_app_palette |
| 92 | +├── logging_bridge.py singleton LogBus + handler for the in-app console |
| 93 | +├── network/ |
| 94 | +│ ├── udp_receiver.py UDP client (handshake, subscribe, re-handshake) |
| 95 | +│ └── shared_memory.py SPageFilePhysics ctypes struct + reader thread |
| 96 | +├── settings/ |
| 97 | +│ ├── config_manager.py ~/.config/ontrack/settings.json |
| 98 | +│ └── settings_dialog.py |
| 99 | +└── widgets/ |
| 100 | + ├── card.py NeumorphCard base (dual paint-time shadows) |
| 101 | + ├── shift_indicator.py RPM LED strip + gear character |
| 102 | + ├── speed_display.py big cyan number + half-arc |
| 103 | + ├── acceleration_display.py G ball with magenta trail |
| 104 | + ├── fuel_display.py vertical bar, amber number |
| 105 | + ├── pedals_card.py throttle + brake bars |
| 106 | + ├── input_graph.py rolling 7s throttle/brake trace |
| 107 | + ├── assists_card.py ABS + TC pills (off / on / ACTIVE) |
| 108 | + ├── wheel_temps_card.py 2x2 corners, color-coded by temp |
| 109 | + ├── car_info_panel.py car/driver/track from handshake |
| 110 | + ├── circuit_map_card.py auto-recorded track outline + position dot |
| 111 | + ├── race_stats_panel.py lap counter, current/best/last, delta |
| 112 | + └── console_window.py in-app log + live values, with filters |
| 113 | +
|
| 114 | +tests/ 24 pytest cases (run `pytest -v` for the list) |
| 115 | +scripts/monitor.py standalone CLI audit tool |
| 116 | +``` |
| 117 | + |
| 118 | +## Conventions |
| 119 | + |
| 120 | +- **Frozen dataclasses with slots** for all wire-format types |
| 121 | + (`TelemetryPacket`, `PhysicsPacket`, `SessionInfo`). Never mutate; |
| 122 | + always construct new ones (use `dataclasses.replace` for overlay). |
| 123 | +- **`from __future__ import annotations`** in every module. |
| 124 | +- **`logging.getLogger(__name__)`**, never `print`. |
| 125 | +- **No type checking framework**, but every public function gets type |
| 126 | + hints. Use `X | None` (PEP 604), `tuple[...]` generics, etc. |
| 127 | + Project is Python 3.10+. |
| 128 | +- **Ruff config** in `pyproject.toml` enforces E/F/W/I/B/UP/SIM. Run it |
| 129 | + after any non-trivial change. |
| 130 | +- **Widget update signature** is always `def update_data(self, packet: |
| 131 | + TelemetryPacket)`. If a widget needs session metadata |
| 132 | + (`car_name`/`track_name`/etc.), add a separate `set_session(session)` |
| 133 | + method and wire it in `MainWindow.on_session_info`. |
| 134 | +- **Painting**: each widget extends `NeumorphCard` and overrides |
| 135 | + `paint_content(painter)`. Use `self.content_rect()` for the drawable |
| 136 | + area inside padding+shadow margins. Use theme constants |
| 137 | + (`ACCENT_CYAN`, `FG_MUTED`, `FONT_LABEL_CAPS`, etc.), never hardcoded |
| 138 | + colors or fonts. |
| 139 | + |
| 140 | +## Data the dashboard receives but doesn't show yet |
| 141 | + |
| 142 | +Available from `TelemetryPacket` but not consumed by any widget: |
| 143 | +- `g_vert` (vertical G) |
| 144 | +- Most of the RTCarInfo wheel arrays (`slipAngle[4]`, `slipRatio[4]`, |
| 145 | + `tyreSlip[4]`, `ndSlip[4]`, `load[4]`, `Dy[4]`, `Mz[4]`, |
| 146 | + `tyreDirtyLevel[4]`, `camberRAD[4]`, `tyreRadius[4]`, |
| 147 | + `tyreLoadedRadius[4]`, `suspensionHeight[4]`) — we don't unpack them |
| 148 | + past the float slots we use. To consume any of them, extend |
| 149 | + `TelemetryPacket` and update the positional unpack in `from_bytes`. |
| 150 | + |
| 151 | +Available from `PhysicsPacket`: |
| 152 | +- `tyre_pressures`, `tyre_wear` — captured, ready to be displayed |
| 153 | +- AC's shared memory carries ~80 more physics fields we don't parse |
| 154 | + (suspension travel, damage, heading/pitch/roll, DRS, TC level, |
| 155 | + carDamage[5], etc.). Add to the `SPageFilePhysics` ctypes struct in |
| 156 | + declaration order, then expose via `PhysicsPacket.from_struct`. |
| 157 | + |
| 158 | +## Open work the user mentioned |
| 159 | + |
| 160 | +In rough priority order: |
| 161 | + |
| 162 | +1. **InputGraph button + filters.** The user asked for an action on |
| 163 | + the input-trace card that opens a detail window showing full |
| 164 | + session / last race / last lap, with filters to "improve driving on |
| 165 | + a specific circuit". Probably needs a `SessionRecorder` that |
| 166 | + accumulates samples per lap (detect via `packet.lap` increment), |
| 167 | + stored in `MainWindow`. Then a detail QDialog driven from a "…" |
| 168 | + button on the InputGraph card. |
| 169 | + |
| 170 | +2. **Display tyre pressures / wear.** Both already in |
| 171 | + `PhysicsPacket`. Either extend `WheelTempsCard` to show secondary |
| 172 | + readings on hover/click, or add a separate tyre-detail card. |
| 173 | + |
| 174 | +## How to verify a change works end-to-end |
| 175 | + |
| 176 | +1. `pytest` — must stay green. |
| 177 | +2. `python -m ruff check ontrack_dashboard tests` — must say "All |
| 178 | + checks passed!". |
| 179 | +3. Launch the app (`ontrack`), open the in-app console |
| 180 | + (Ctrl+Shift+C), watch the Live Values tab + Log tab with AC running |
| 181 | + in a session. Field updates and packet counters prove the change is |
| 182 | + live, not just compiled. |
| 183 | +4. If you broke the wire format, `scripts/monitor.py` (with the |
| 184 | + dashboard stopped) shows what the protocol layer actually decodes. |
| 185 | + |
| 186 | +## Don't do these |
| 187 | + |
| 188 | +- **Don't rebuild the plugin.** It can't work without vendoring |
| 189 | + `_socket.pyd`, and we deliberately went a different route. See |
| 190 | + `feedback-ac-python-plugins` memory. |
| 191 | +- **Don't add per-widget visibility toggles to settings.** The curated |
| 192 | + layout is intentional; the old `show_speed` / `show_rpm` flags were |
| 193 | + removed when we restructured. |
| 194 | +- **Don't print to stdout/stderr.** Use `logging.getLogger(__name__)` |
| 195 | + so the in-app console captures it. |
| 196 | +- **Don't hardcode colors or fonts.** Use the theme constants. |
| 197 | +- **Don't force-push without `--force-with-lease`.** When pushing to |
| 198 | + a divergent main, see the `feedback-ontrack-push` memory for the |
| 199 | + established workflow. |
| 200 | + |
| 201 | +## Useful one-liners |
| 202 | + |
| 203 | +```powershell |
| 204 | +# What's actually in dashboard.log right now |
| 205 | +Get-Content C:\Users\chris\Documents\Github\OnTrack\dashboard.log |
| 206 | +
|
| 207 | +# Force-read AC's log even when it shows 0 bytes (Windows open-file lock) |
| 208 | +$log = "$env:USERPROFILE\Documents\Assetto Corsa\logs\py_log.txt" |
| 209 | +$fs = [System.IO.File]::Open($log,'Open','Read','ReadWrite') |
| 210 | +(New-Object System.IO.StreamReader($fs)).ReadToEnd(); $fs.Close() |
| 211 | +
|
| 212 | +# Is AC in a session right now? |
| 213 | +Get-Process | ? { $_.ProcessName -in @('acs','AssettoCorsa') } |
| 214 | +
|
| 215 | +# What's on UDP 20777/9996? |
| 216 | +netstat -ano -p UDP | Select-String "9996|20777" |
| 217 | +``` |
0 commit comments