Skip to content

Instantly share code, notes, and snippets.

@clarkb7
Created June 1, 2024 21:29
Show Gist options
  • Save clarkb7/911721ede4f8c9bb1d82abbce3dce7bd to your computer and use it in GitHub Desktop.
Save clarkb7/911721ede4f8c9bb1d82abbce3dce7bd to your computer and use it in GitHub Desktop.
Proof of Concept for parsing Assetto Corsa tc files from the ctelemetry directory. Can output JSON or display a graph with matplotlib.
'''
This script is a proof of concept for parsing the Assetto Corsa tc files found in the ctelemetry directory ("%USERPROFILE%\Documents\Assetto Corsa\ctelemetry\player")
Usage: python ac_tc_reader.py [--json] [--graph] filename
--json Output the telemetry data in a JSON format
--graph Graph the telemetry data. Requires matplotlib to be installed.
File format:
+-------------------+-------------------+-----------------------+----------------------------+------------------------+
| 4 bytes | 4 bytes | sizeOfName bytes | 4 bytes | sizeOfTrack bytes |
| unknown | sizeOfName | Name string | sizeOfTrackName | TrackName string |
| | (integer) | (UTF-8 string) | (integer) | (UTF-8 string) |
+-------------------+-------------------+-----------------------+----------------------------+------------------------+
+-------------------+-------------------+-----------------------+----------------------------+------------------------+-------------------+
| 4 bytes | sizeOfCar bytes | 4 bytes | sizeOfTrackVariation bytes | 4 bytes | 4 bytes |
| sizeOfCar | CarName string | sizeOfTrackVariation | TrackVariation string | LapTime (milliseconds) | numDataPoints |
| (integer) | (UTF-8 string) | (integer) | (UTF-8 string) | (integer) | (integer) |
+-------------------+-------------------+-----------------------+----------------------------+------------------------+-------------------+
+-------------------+---------------------+------------------+-------------------+-----------------+
| 4 bytes | 4 bytes | 4 bytes | 4 bytes | 4 bytes |
| Gear | Position | Speed (km/h) | Throttle | Brake |
| (integer) | (float) | (float) | (float) | (float) |
+-------------------+---------------------+------------------+-------------------+-----------------+
| ... | ... | ... | ... | ... |
| Repeat for each datapoint (total numDataPoints times) |
+-------------------+---------------------+------------------+-------------------+-----------------+
floats are in IEEE 754 format.
Gear:
- offset by one, AC displays gear 4 for a value of 5
- I don't know how neutral or reverse are represented.
Position:
- monotonically increases from 0 to 1, used as x axis in graph
- graphs linear when compared to numDataPoints, so other graphs look the same, too, if you use numDataPoints as x axis instead
- not sure if it's actually related to your position in the lap or not, what happens if you drive backwards for a bit?
- seems like AC might combine this value with the track length to show the `Km:` value in the bottom right of screen
Speed:
- unit is km/h
Throttle:
- value 0 (no throttle input) to 1.0 (max throttle input)
Brake:
- value 0 (no brake input) to 1.0 (max brake input)
Every .tc file seems to be the same size and have the same number of datapoints (3999), regardless of track length or lap time.
I'm not sure if this means that the sampling is normalized (longer tracks/laptime less samples, shorter tracks/laptime more samples),
or if the sample frequency is static and stored in a circular buffer, so that old data is lost.
Assetto Corsa displays a `Km` value that goes from 0 to track length, which leads me to believe that it is normalized.
The output JSON format is:
{
'user': string,
'track': string,
'track_variation': string,
'car': string,
'lap_time_ms': integer,
'lap_time': string,
'num_datapoints': integer,
'position': [float],
'gear': [float],
'speed': [float],
'throttle': [float],
'brake': [float],
}
'''
import struct
import sys
import datetime
import argparse
import json
from collections import namedtuple
def read_var_bytes(fstream):
"""
Unpacks a byte string in form
+-------------------+------------------------+
| 4 bytes | $size bytes |
| size | string |
| (integer) | (UTF-8 string) |
+-------------------+------------------------+
string is not null terminated
"""
size, = struct.unpack("<I", fstream.read(4))
if size == 0:
return b""
s = fstream.read(size)
return s
DataPoint = namedtuple('DataPoint', ['gear', 'position', 'speed', 'throttle', 'brake'])
def read_datapoint(fstream):
"""
Unpacks 20 byte datapoint with layout
+-------------------+---------------------+------------------+-------------------+-----------------+
| 4 bytes | 4 bytes | 4 bytes | 4 bytes | 4 bytes |
| Gear | Position | Speed | Throttle | Brake |
| (integer) | (float) | (float) | (float) | (float) |
+-------------------+---------------------+------------------+-------------------+-----------------+
floats are in IEEE 754 format.
"""
datapoint = struct.unpack('Iffff', fstream.read(20))
return DataPoint(*datapoint)
def read_tc_header(fstream):
"""
Unpacks the header of the tc file, which contains player name, track name, car name, track variation, and lap time.
+-------------------+-------------------+-----------------------+----------------------------+------------------------+
| 4 bytes | 4 bytes | sizeOfName bytes | 4 bytes | sizeOfTrack bytes |
| unknown | sizeOfName | Name string | sizeOfTrackName | TrackName string |
| | (integer) | (UTF-8 string) | (integer) | (UTF-8 string) |
+-------------------+-------------------+-----------------------+----------------------------+------------------------+
+-------------------+-------------------+-----------------------+----------------------------+------------------------+-------------------+
| 4 bytes | sizeOfCar bytes | 4 bytes | sizeOfTrackVariation bytes | 4 bytes | 4 bytes |
| sizeOfCar | CarName string | sizeOfTrackVariation | TrackVariation string | LapTime (milliseconds) | numDataPoints |
| (integer) | (UTF-8 string) | (integer) | (UTF-8 string) | (integer) | (integer) |
+-------------------+-------------------+-----------------------+----------------------------+------------------------+-------------------+
"""
# unknown header
_ = fstream.read(4)
user = read_var_bytes(fstream).decode('utf-8')
track = read_var_bytes(fstream).decode('utf-8')
car = read_var_bytes(fstream).decode('utf-8')
track_variation = read_var_bytes(fstream).decode('utf-8')
lap_time_ms = struct.unpack('I', fstream.read(4))[0]
num_datapoints = struct.unpack('I', fstream.read(4))[0]
return {
'user': user,
'track': track,
'track_variation': track_variation,
'car': car,
'lap_time_ms': lap_time_ms,
'num_datapoints': num_datapoints,
}
def read_datapoints(fstream, num_datapoints):
for _ in range(num_datapoints):
datapoint = read_datapoint(fstream)
yield datapoint
def as_dict(header, datapoints):
lap_time = str(datetime.timedelta(milliseconds=header['lap_time_ms']))[:-3]
return {
'user': header['user'],
'track': header['track'],
'track_variation': header['track_variation'],
'car': header['car'],
'lap_time_ms': header['lap_time_ms'],
'lap_time': lap_time,
'num_datapoints': header['num_datapoints'],
'position': [dp.position for dp in datapoints],
'gear': [dp.gear for dp in datapoints],
'speed': [dp.speed for dp in datapoints],
'throttle': [dp.throttle for dp in datapoints],
'brake': [dp.brake for dp in datapoints],
}
def show_json(jso):
s = json.dumps(jso)
print(s)
def graph_data(data):
import matplotlib.pyplot as plt
# Extract data points
lap_time = data['lap_time']
num_data_points = data['num_datapoints']
position = data['position']
# AC shows gear at an offset
gear = [gear-1 for gear in data['gear']]
speed = data['speed']
throttle = data['throttle']
brake = data['brake']
# Create time points for the x-axis
time_points = list(range(num_data_points))
x_axis = time_points
graphs = [
{
'label': 'Brake',
'data': brake,
'ylabel': 'Brake',
'title': 'Brake over time',
'color': 'c',
},
{
'label': 'Speed',
'data': speed,
'ylabel': 'Speed',
'title': 'Speed over time',
'color': 'r',
},
{
'label': 'Throttle',
'data': throttle,
'ylabel': 'Throttle',
'title': 'Throttle over time',
'color': 'm',
},
{
'label': 'Gear',
'data': gear,
'ylabel': 'Gear',
'title': 'Gear over time',
'color': 'b',
},
# {
# 'label': 'Position',
# 'data': position,
# 'ylabel': 'Position',
# 'title': 'Position over time',
# 'color': 'g',
# },
]
fig, axs = plt.subplots(len(graphs), 1, figsize=(10, 20), sharex=True)
for i in range(len(graphs)):
g = graphs[i]
ax = axs[i]
ax.plot(x_axis, g['data'], label=g['label'], color=g['color'])
ax.set_ylabel(g['ylabel'])
ax.set_title(g['title'])
ax.grid(True)
plt.tight_layout()
plt.subplots_adjust(top=0.95, bottom=0.15, left=0.1, right=0.95, hspace=0.5)
# Add label for user, car, track, and track variation
info = fig.text(0.5, .03, f"User: {data['user']} | Car: {data['car']} | Track: {data['track']} | Track Variation: {data['track_variation']}",
ha='center',
bbox=dict(facecolor='lightblue', alpha=0.5))
info.set_fontfamily(['monospace'])
# Add label for displaying the values on mouseover
mouseoverValues = fig.text(0.5, .06, "",
ha='center',
bbox=dict(facecolor='lightblue', alpha=0.5))
mouseoverValues.set_fontfamily(['monospace'])
mouseoverValues.set_visible(False)
# Add vertical line on each graph to track mouse
vlines = []
for ax in axs:
vline = ax.axvline(x=0, color='gray', linestyle='--', alpha=0.5)
vlines.append(vline)
# Update label and vertical line on hover
def update_hover(event):
if not event.inaxes:
return
x, y = event.xdata, event.ydata
index = int(x)
if not (0 <= index < num_data_points):
return
# Fomat values for each graph for display in mouseoverValues box
index_str = f'{index:<6}'
lap_time_str = f'{lap_time:<6}'
speed_str = f"{speed[index]:>6.2f} Km/h"
throttle_str = f"{throttle[index]:<4.0%}"
brake_str = f"{brake[index]:<4.0%}"
gear_str = f"{gear[index]:<3}"
position_str = f"{position[index]:<12.3f}"
text = f"Index: {index_str} Lap Time: {lap_time_str} Speed: {speed_str} Throttle: {throttle_str} Brake: {brake_str} Gear: {gear_str} Position: {position_str} "
mouseoverValues.set_text(text)
mouseoverValues.set_visible(True)
# Set x value of vertical line
for vline in vlines:
vline.set_xdata([x])
fig.canvas.draw_idle()
# When mouse is in a graph, display mouseoverValues and vertical lines
# clear when mouse not in graph
def hover(event):
if event.inaxes:
update_hover(event)
else:
mouseoverValues.set_visible(False)
for vline in vlines:
vline.set_xdata([0])
fig.canvas.draw_idle()
fig.canvas.mpl_connect("motion_notify_event", hover)
plt.show()
def main(argv):
parser = argparse.ArgumentParser()
parser.add_argument('filename')
parser.add_argument('--json', action='store_true',
help="Output the telemetry data in a JSON format")
parser.add_argument('--graph', action='store_true',
help="Graph the telemetry data. Requires matplotlib to be installed.")
args = parser.parse_args(argv)
f = open(args.filename, 'rb')
header = read_tc_header(f)
datapoints = list(read_datapoints(f, header['num_datapoints']))
data = as_dict(header, datapoints)
if args.json:
show_json(data)
if args.graph:
graph_data(data)
if __name__ == "__main__":
main(sys.argv[1:])
@clarkb7
Copy link
Author

clarkb7 commented Jun 1, 2024

Sample matplotlib graph

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment