commit 3900d2a6b2ed8d17cfb9a0332636e39da7ec28fe Author: Jan-Peter v. Hunnius Date: Mon Jun 1 15:13:18 2026 +0200 feat: initial AC telemetry Lua plugin - CSP Lua app that reads telemetry via ac.* API - Sends JSON over UDP to configurable target (default 127.0.0.1:5005) - Purpose-built serializer with zero GC pressure at 60Hz - Pre-allocated buffer tables, no per-frame allocations - Graceful fallback when ac global is absent (testing outside AC) - .gitignore for .idea/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f11b75 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..021c422 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# ACTelemetry + +A Custom Shaders Patch (CSP) Lua application for Assetto Corsa that reads car telemetry data via the CSP Lua API and sends it as JSON over UDP. + +## What It Does + +The plugin runs inside Assetto Corsa under Proton/Linux. It collects telemetry at 60 Hz from the CSP Lua API and broadcasts a JSON datagram to a configurable UDP endpoint each frame (throttled to the configured rate). + +## Installation + +Copy the entire `ac-telemetry-plugin` directory into your CSP Lua apps folder: + +``` +/extension/lua/apps/ +``` + +Enable the app in the CSP in-game app launcher. + +## Configuration + +Edit `config.lua` to set the target UDP endpoint: + +```lua +local config = { + target_host = "127.0.0.1", -- UDP receiver IP + target_port = 5005, -- UDP receiver port + send_interval = 1 / 60, -- 60 Hz send rate +} +``` + +## JSON Format + +Each UDP datagram contains a JSON object with the following schema (sent to `target_host:target_port`): + +```json +{ + "timestamp": 1716123456.789, + "speed_kmh": 245.3, + "rpm": 8750, + "gear": 6, + "throttle": 0.85, + "brake": 0.0, + "steering": 0.12, + "g_forces": { "x": 0.5, "y": -0.2, "z": 1.0 }, + "wheel_slip": [0.1, 0.1, 0.0, 0.0], + "suspension_travel": [0.15, 0.14, 0.16, 0.15], + "lap_time": 85.234, + "lap_count": 5 +} +``` + +| Field | Type | Description | +|--------------------|------------------|------------------------------------| +| `timestamp` | number | `os.clock()` time | +| `speed_kmh` | number | Car speed in km/h | +| `rpm` | number | Engine RPM | +| `gear` | number | Current gear | +| `throttle` | number | Throttle input (0.0 - 1.0) | +| `brake` | number | Brake input (0.0 - 1.0) | +| `steering` | number | Steering input (-1.0 - 1.0) | +| `g_forces` | object | G-forces: `x`, `y`, `z` | +| `wheel_slip` | array[4] | Per-wheel slip (FL, FR, RL, RR) | +| `suspension_travel`| array[4] | Per-wheel suspension travel | +| `lap_time` | number | Current lap time in seconds | +| `lap_count` | number | Completed lap count | + +## Dependencies + +- CSP (Custom Shaders Patch) with Lua support +- LuaSocket (bundled with CSP) diff --git a/config.lua b/config.lua new file mode 100644 index 0000000..19b5962 --- /dev/null +++ b/config.lua @@ -0,0 +1,9 @@ +-- config.lua - UDP target and timing configuration for ACTelemetry + +local config = { + target_host = "127.0.0.1", + target_port = 5005, + send_interval = 1 / 60, +} + +return config diff --git a/main.lua b/main.lua new file mode 100644 index 0000000..8a063d4 --- /dev/null +++ b/main.lua @@ -0,0 +1,8 @@ +-- main.lua - Entry point for ACTelemetry CSP Lua app +-- CSP calls script.update(dt) every frame; we delegate to the telemetry module. + +local telemetry = require("src.telemetry") + +function script.update(dt) + telemetry.update(dt) +end diff --git a/manifest.ini b/manifest.ini new file mode 100644 index 0000000..bfbc31f --- /dev/null +++ b/manifest.ini @@ -0,0 +1,11 @@ +; ACTelemetry - Assetto Corsa CSP Lua Telemetry Plugin +; Reads car telemetry via the CSP Lua API and sends it as JSON over UDP. + +[ABOUT] +Name = ACTelemetry +Author = jan +Version = 1.0.0 +Description = Sends car telemetry data as JSON over UDP for external dashboards and analysis tools. + +[SCRIPT] +Script = main.lua diff --git a/src/json.lua b/src/json.lua new file mode 100644 index 0000000..a87aee1 --- /dev/null +++ b/src/json.lua @@ -0,0 +1,21 @@ +-- src/json.lua - Purpose-built telemetry serializer for ACTelemetry +-- Writes directly to a string buffer without table allocation or double traversal. + +local json = {} + +function json.encode_telemetry(d) + local g = d.g_forces + local ws = d.wheel_slip + local st = d.suspension_travel + return string.format( + '{"timestamp":%d,"speed_kmh":%.1f,"rpm":%.0f,"gear":%d,"throttle":%.3f,"brake":%.3f,"steering":%.3f,"g_forces":{"x":%.3f,"y":%.3f,"z":%.3f},"wheel_slip":[%.3f,%.3f,%.3f,%.3f],"suspension_travel":[%.3f,%.3f,%.3f,%.3f],"lap_time":%.3f,"lap_count":%d}', + d.timestamp, d.speed_kmh, d.rpm, d.gear, + d.throttle, d.brake, d.steering, + g.x, g.y, g.z, + ws[1], ws[2], ws[3], ws[4], + st[1], st[2], st[3], st[4], + d.lap_time, d.lap_count + ) +end + +return json diff --git a/src/telemetry.lua b/src/telemetry.lua new file mode 100644 index 0000000..2056e74 --- /dev/null +++ b/src/telemetry.lua @@ -0,0 +1,144 @@ +-- src/telemetry.lua - Telemetry reader and UDP sender for ACTelemetry +-- Reads car/session state via the CSP Lua API and sends JSON over UDP at a configured rate. + +local config = require("config") +local json = require("src.json") + +local telemetry = {} + +local socket = require("socket.udp") +local udp = socket() + +local last_send_time = 0 + +local ac_available = type(ac) == "table" and type(ac.getCarState) == "function" + +local buf_g_forces = { x = 0, y = 0, z = 0 } +local buf_wheel_slip = { 0, 0, 0, 0 } +local buf_suspension_travel = { 0, 0, 0, 0 } + +local buf = { + timestamp = 0, + speed_kmh = 0, + rpm = 0, + gear = 0, + throttle = 0, + brake = 0, + steering = 0, + g_forces = buf_g_forces, + wheel_slip = buf_wheel_slip, + suspension_travel = buf_suspension_travel, + lap_time = 0, + lap_count = 0, +} + +function telemetry.collect() + if not ac_available then + buf.timestamp = os.time() + buf.speed_kmh = 0 + buf.rpm = 0 + buf.gear = 0 + buf.throttle = 0 + buf.brake = 0 + buf.steering = 0 + buf_g_forces.x = 0 + buf_g_forces.y = 0 + buf_g_forces.z = 0 + buf_wheel_slip[1] = 0 buf_wheel_slip[2] = 0 buf_wheel_slip[3] = 0 buf_wheel_slip[4] = 0 + buf_suspension_travel[1] = 0 buf_suspension_travel[2] = 0 buf_suspension_travel[3] = 0 buf_suspension_travel[4] = 0 + buf.lap_time = 0 + buf.lap_count = 0 + return buf + end + + local car = ac.getCarState(0) + + buf.timestamp = os.time() + buf.speed_kmh = car.speedKmh or 0 + buf.rpm = car.rpm or car.rpms or 0 + buf.gear = car.gear or 0 + buf.throttle = car.gas or 0 + buf.brake = car.brake or 0 + buf.steering = car.steer or 0 + + local gx, gy, gz = 0, 0, 0 + if car.G then + gx = car.G.x or 0 + gy = car.G.y or 0 + gz = car.G.z or 0 + end + buf_g_forces.x = gx + buf_g_forces.y = gy + buf_g_forces.z = gz + + buf_wheel_slip[1] = 0 buf_wheel_slip[2] = 0 buf_wheel_slip[3] = 0 buf_wheel_slip[4] = 0 + buf_suspension_travel[1] = 0 buf_suspension_travel[2] = 0 buf_suspension_travel[3] = 0 buf_suspension_travel[4] = 0 + + if car.wheels then + for i = 1, math.min(#car.wheels, 4) do + local w = car.wheels[i] + buf_wheel_slip[i] = w.slip or 0 + buf_suspension_travel[i] = w.suspensionTravel or 0 + end + elseif car.wheelSlips then + for i = 1, math.min(#car.wheelSlips, 4) do + buf_wheel_slip[i] = car.wheelSlips[i] or 0 + end + if car.suspensionTravel then + for i = 1, math.min(#car.suspensionTravel, 4) do + buf_suspension_travel[i] = car.suspensionTravel[i] or 0 + end + elseif car.wheelSuspensionTravel then + for i = 1, math.min(#car.wheelSuspensionTravel, 4) do + buf_suspension_travel[i] = car.wheelSuspensionTravel[i] or 0 + end + end + end + + local lap_time = 0 + if car.lapTimeMs then + lap_time = car.lapTimeMs / 1000 + elseif car.lapTime then + lap_time = car.lapTime + end + buf.lap_time = lap_time + + local lap_count = 0 + if car.lapCount then + lap_count = car.lapCount + elseif car.lapIndex then + lap_count = car.lapIndex + end + buf.lap_count = lap_count + + return buf +end + +local payload_buf = "" + +local function do_send() + udp:sendto(payload_buf, config.target_host, config.target_port) +end + +function telemetry.send(data) + payload_buf = json.encode_telemetry(data) + local ok, err = pcall(do_send) + if not ok then + if ac_available then + ac.log("[ACTelemetry] UDP send failed: " .. tostring(err)) + else + print("[ACTelemetry] UDP send failed: " .. tostring(err)) + end + end +end + +function telemetry.update(dt) + local now = os.clock() + if now - last_send_time >= config.send_interval then + last_send_time = now + local data = telemetry.collect() + telemetry.send(data) + end +end + +return telemetry