Back to blog
iracingtelemetrydirector-appffi

The iRacing Telemetry Pipeline

How the Director App reads iRacing shared memory via koffi FFI to extract session data, telemetry variables, camera groups, and live race state — powering the AI's understanding of what's happening on track.

·Sim RaceCenter Team·8 min read
On this page

The iRacing extension is the Director App's most complex integration. It reads data directly from iRacing's shared memory using koffi (a Node.js FFI library) to access Windows system DLLs — no SDK wrapper, no REST API, no WebSocket. This post explains the full data pipeline: from memory-mapped files through YAML parsing and telemetry variable reading to the RaceState object that drives AI sequence generation.

Two Windows DLLs

The extension interfaces with two Windows system libraries via koffi:

DLLFunctionsPurpose
user32.dllFindWindowA, PostMessageA, RegisterWindowMessageADetect the sim window, send broadcast commands (camera switches, replay control)
kernel32.dllOpenFileMappingA, MapViewOfFile, UnmapViewOfFile, CloseHandleRead iRacing's shared memory for session metadata and live telemetry

The command layer (user32) sends messages to iRacing. The data layer (kernel32) reads from iRacing. These are independent — you can read telemetry without sending commands, and vice versa.

Shared Memory Layout

iRacing exposes all session and telemetry data via a memory-mapped file named Local\IRSDKMemMapFileName. The extension opens it read-only with FILE_MAP_READ:

┌─────────────────────────────────────────────────┐
│ Header (48 bytes — 12 × int32)                  │
│   [0] ver           SDK version                 │
│   [1] status        Connection status            │
│   [2] tickRate      Ticks per second             │
│   [3] sessionInfoUpdate  Change counter (YAML)  │
│   [4] sessionInfoLen     YAML buffer size        │
│   [5] sessionInfoOffset  Byte offset to YAML    │
│   [6] numVars       Number of telemetry vars     │
│   [7] varHeaderOffset    Offset to var headers   │
│   [8] numBuf        Number of data buffers       │
│   [9] bufLen        Bytes per data buffer        │
│   [10..11] padding                               │
├─────────────────────────────────────────────────┤
│ Buffer Headers (16 bytes each × numBuf)          │
│   { tickCount, bufOffset, pad[2] }               │
├─────────────────────────────────────────────────┤
│ Session Info YAML (at sessionInfoOffset)         │
│   CameraInfo, DriverInfo, WeekendInfo,           │
│   SessionInfo, etc.                              │
├─────────────────────────────────────────────────┤
│ Telemetry Variable Headers (144 bytes each)      │
│   { type, offset, count, name, desc, unit }      │
├─────────────────────────────────────────────────┤
│ Telemetry Data Buffers (triple-buffered)         │
│   Per-tick snapshots of all telemetry variables   │
└─────────────────────────────────────────────────┘

The entire structure is read through raw pointer arithmetic with koffi.decode(). There is no deserialization library — the extension reads int32 arrays, float32 arrays, and byte sequences directly from memory offsets.

Two Data Channels

The shared memory contains two fundamentally different kinds of data:

Channel 1: Session Info (YAML)

A large YAML string (~500KB) embedded in the memory-mapped file. It contains static or slowly-changing data:

  • CameraInfo.Groups[] — All camera groups (Nose, Gearbox, Cockpit, TV1, TV2, TV3, Scenic, Blimp, Chase, Pit Lane, etc.)
  • DriverInfo.Drivers[] — All drivers (carIdx, carNumber, userName, teamName, carName, carClassName)
  • WeekendInfo — Track name, track type, series name
  • SessionInfo.Sessions[] — Session types, lap counts, time limits

The YAML only changes when something structural happens: a new session loads, a driver joins or leaves, etc. The extension tracks the sessionInfoUpdate counter in the header and only re-parses when it changes:

function readSessionInfo(director: ExtensionAPI): SessionInfoResult | null {
  const header = readFullHeader();
  if (!header) return null;
 
  // Skip re-parse if session info hasn't changed
  if (header.sessionInfoUpdate === lastSessionInfoUpdate) {
    return null; // No change
  }
 
  // Read raw bytes at the YAML offset
  const rawBytes = koffi.decode(pBase, header.sessionInfoOffset, 'uint8', header.sessionInfoLen);
  // ... find null terminator, decode to string, parse with js-yaml ...
}

When the YAML changes, the extension extracts camera groups and drivers, caches them locally, and emits events:

director.emitEvent('iracing.cameraGroupsChanged', { groups: result.cameraGroups });
director.emitEvent('iracing.driversChanged', { drivers: result.drivers });

Channel 2: Telemetry Variables (Binary, Triple-Buffered)

High-frequency numerical data updated every tick. iRacing uses triple-buffering — three data buffers are rotated so the reader always has a consistent snapshot. The extension finds the latest buffer by comparing tickCount values:

function getLatestBuffer(): { offset: number; tickCount: number } | null {
  const BUF_HEADER_OFFSET = 48;
  let bestTick = -1;
  let bestOffset = -1;
 
  for (let i = 0; i < header.numBuf; i++) {
    const bhBase = BUF_HEADER_OFFSET + i * 16;
    const bh = koffi.decode(pBase, bhBase, 'int32', 2);
    if (bh[0] > bestTick) {
      bestTick = bh[0];
      bestOffset = bh[1];
    }
  }
  return bestOffset >= 0 ? { offset: bestOffset, tickCount: bestTick } : null;
}

Telemetry Variable Headers

Before reading telemetry data, the extension parses the variable header table. Each of iRacing's ~400 telemetry variables has a 144-byte descriptor:

Bytes 0-3:    type (int32) — BOOL=1, INT=2, BITFIELD=3, FLOAT=4, DOUBLE=5
Bytes 4-7:    offset (int32) — byte position within the data buffer
Bytes 8-11:   count (int32) — array length (1 for scalars, 64 for per-car arrays)
Bytes 12-15:  countAsTime (int32)
Bytes 16-47:  name (char[32]) — e.g., "CarIdxPosition"
Bytes 48-111: desc (char[64]) — human description
Bytes 112-143: unit (char[32]) — e.g., "m/s", "rad", "%"

The extension parses these once on connect and caches them in a Map<string, VarHeader>. It re-parses only if numVars changes.

Key Telemetry Variables Used

The extension reads these variables to build the race state:

VariableTypeCountDescription
CarIdxPositionint3264Overall race position per car index
CarIdxClassPositionint3264Class position per car index
CarIdxLapDistPctfloat3264Track position as 0.0–1.0 fraction
CarIdxF2Timefloat3264Gap to leader in seconds
CarIdxOnPitRoadbool64Whether each car is on pit road
CarIdxLapCompletedint3264Laps completed per car
CarIdxLastLapTimefloat3264Last lap time per car
CarIdxBestLapTimefloat3264Best lap time per car
CamCarIdxint321Which car iRacing's camera is focused on
SessionFlagsbitfield1Flag state (green, yellow, red, checkered, etc.)
SessionLapsRemainExint321Laps remaining
SessionTimeRemainfloat641Time remaining in seconds

All per-car variables are indexed by carIdx (0–63). The driver roster from the session YAML maps car numbers and names to these indices.

Reading Telemetry Data

The variable readers use koffi.decode() with the header's type and offset information:

function readVarFloat(varName: string, bufOffset: number): number[] | null {
  const vh = varHeaders.get(varName);
  if (!vh || !pBase) return null;
  const off = bufOffset + vh.offset;
  if (vh.type === IRSDK_FLOAT) {
    return Array.from(koffi.decode(pBase, off, 'float32', vh.count));
  } else if (vh.type === IRSDK_DOUBLE) {
    return Array.from(koffi.decode(pBase, off, 'float64', vh.count));
  }
  return null;
}
 
function readVarInt(varName: string, bufOffset: number): number[] | null {
  const vh = varHeaders.get(varName);
  if (!vh || !pBase) return null;
  const off = bufOffset + vh.offset;
  if (vh.type === IRSDK_INT || vh.type === IRSDK_BITFIELD) {
    return Array.from(koffi.decode(pBase, off, 'int32', vh.count));
  }
  return null;
}

These functions return raw arrays. A per-car variable like CarIdxPosition returns a 64-element int32 array where index N is the position of the car with carIdx N. A scalar like CamCarIdx returns a 1-element array.

Building RaceState

Every 250ms (4Hz), the telemetry polling loop calls buildRaceState(). This function merges cached driver info (from the session YAML) with live telemetry (from the data buffer):

interface RaceState {
  cars: RaceCarState[];
  focusedCarIdx: number;
  sessionFlags: number;
  sessionLapsRemain: number;
  sessionTimeRemain: number;
  leaderLap: number;
  totalSessionLaps: number;
  trackName: string;
}
 
interface RaceCarState {
  carIdx: number;
  carNumber: string;
  driverName: string;
  carClass: string;
  position: number;
  classPosition: number;
  lapDistPct: number;
  gapToLeader: number;
  gapToCarAhead: number;
  onPitRoad: boolean;
  lapsCompleted: number;
  lastLapTime: number;
  bestLapTime: number;
}

The build process:

  1. Get latest buffer — Find the triple-buffer with the highest tick count
  2. Read per-car arrays — Position, class position, lap %, gap to leader, pit status, lap times
  3. Read scalars — Focused car, flags, laps remaining, time remaining
  4. Merge with drivers — Loop through cached drivers, skip pace car and invalid entries, build RaceCarState per driver
  5. Sort by position — Cars sorted by overall race position
  6. Compute deltas — Gap-to-car-ahead calculated from sorted leader gaps

The resulting RaceState is emitted as iracing.raceStateChanged. The DirectorOrchestrator forwards this to Race Control as part of the raceContext in sequence poll requests, where the AI uses it to make camera selection decisions.

Two Polling Loops

The extension runs two independent polling loops:

Session Data Poll (2Hz)

checkInterval = setInterval(() => {
  checkConnection(director);
}, 2000);

Checks whether iRacing is running (via FindWindowA), opens/closes shared memory as needed, and reads the session info YAML when the sessionInfoUpdate counter changes. This is the "slow" loop that detects session loads, driver changes, and camera group updates.

Telemetry Poll (4Hz)

telemetryInterval = setInterval(() => {
  pollTelemetry(director);
}, 250);

Reads live telemetry variables from the data buffer and builds the RaceState. Started when iRacing connects, stopped when it disconnects. At 250ms intervals, this is fast enough for race tower updates without being wasteful.

Camera Groups

iRacing exposes dozens of camera groups per track. The extension parses these from the session YAML:

interface CameraGroup {
  groupNum: number;
  groupName: string;
  isScenic?: boolean;
}

Camera groups vary by track but follow consistent naming patterns:

CategoryGroupsDescription
On-carNose, Gearbox, Cockpit, Roll Bar, LF/LR/RF/RR Susp, GyroMounted on the car, follow the focused driver
BroadcastTV1, TV2, TV3, TV4, TV MixedTrack-side cameras, the bread and butter of race broadcasts
ScenicScenicHelicopter/aerial scenic shots
SpecialBlimp, Chopper, Chase, Far Chase, Rear ChaseSpecialized tracking cameras
PitPit Lane, Pit Lane 2Fixed pit road cameras

When the broadcast.showLiveCam intent fires, the iRacing extension translates the camera group name from the sequence step into the numeric groupNum, finds the target car's carIdx from the car number, and sends the switch command via broadcastMessage() through user32.dll's PostMessageA.

The Command Layer

Camera switches and replay control use Windows broadcast messages:

function broadcastMessage(cmd: number, var1: number, var2: number, var3: number = 0) {
  if (!msgId || !PostMessageA) return;
  const wParam = (cmd & 0xffff) | ((var1 & 0xffff) << 16);
  const lParam = (var2 & 0xffff) | ((var3 & 0xffff) << 16);
  PostMessageA(HWND_BROADCAST, msgId, wParam, lParam);
}

The msgId is obtained by calling RegisterWindowMessageA('IRSDK_BROADCASTMSG') at startup. Commands are packed into wParam and lParam as 16-bit fields. The key commands:

CommandConstantPurpose
Camera switch by positionIRSDK_CAM_SWITCHPOS = 0Switch to car at position N
Camera switch by numberIRSDK_CAM_SWITCHNUM = 1Switch to car number N
Set replay speedIRSDK_REPLAY_SETSPEED = 3Change playback speed
Set replay positionIRSDK_REPLAY_SETPOS = 4Jump to frame
Search replayIRSDK_REPLAY_SEARCH = 5Search for events
Set replay stateIRSDK_REPLAY_SETSTATE = 6Play/pause

Session Flags Bitfield

The SessionFlags telemetry variable is a bitfield. The extension and Race Control both decode it:

BitFlagMeaning
0x00000001CheckeredRace is over
0x00000002WhiteFinal lap
0x00000004GreenRacing
0x00000008YellowCaution
0x00000010RedSession stopped

Race Control uses these flags in the raceContext to influence sequence generation. A yellow flag triggers caution-specific sequences (pit lane cameras, replay of the incident). A green flag returns to battle-focused sequences.

Why This Matters for AI Context

The iRacing telemetry pipeline is the source of truth for everything the AI knows about the live race:

  • RaceState.cars tells the AI who's in what position, who's pitting, who has the fastest lap
  • sessionFlags tells the AI whether it's green, yellow, or checkered
  • focusedCarIdx tells the AI which car the broadcast is currently watching
  • gapToCarAhead reveals battles — cars within 1-2 seconds of each other
  • Camera groups constrain what camera angles the AI can request

Without this pipeline, the AI would have no live race data. Every sequence it generates — every camera switch, every announcement, every timing decision — is grounded in the RaceState that flows through this path.