From fa464ba0088ef92c862e7fcf774754b279c14c5f Mon Sep 17 00:00:00 2001 From: Robert Gracey <70551819+rgracey@users.noreply.github.com> Date: Thu, 5 Mar 2026 22:36:33 +1100 Subject: [PATCH 1/2] feat: add iRacing support (untested) --- Cargo.toml | 2 +- src/plugins/ams2/ams2_plugin.rs | 1 - src/plugins/iracing/iracing_plugin.rs | 166 +++++++++++++++ src/plugins/iracing/mod.rs | 7 + src/plugins/iracing/shared_memory.rs | 279 ++++++++++++++++++++++++++ src/plugins/mod.rs | 1 + src/plugins/registry.rs | 6 +- src/plugins/trait_.rs | 2 + src/renderer/app.rs | 9 +- 9 files changed, 463 insertions(+), 10 deletions(-) create mode 100644 src/plugins/iracing/iracing_plugin.rs create mode 100644 src/plugins/iracing/mod.rs create mode 100644 src/plugins/iracing/shared_memory.rs diff --git a/Cargo.toml b/Cargo.toml index b59cdb0..95a6075 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,5 +28,5 @@ dirs = "5.0" # Shared memory (Windows) [target.'cfg(windows)'.dependencies] memmap2 = "0.9" -winapi = { version = "0.3", features = ["winnt", "handleapi", "memoryapi"] } +winapi = { version = "0.3", features = ["winnt", "handleapi", "memoryapi", "errhandlingapi"] } diff --git a/src/plugins/ams2/ams2_plugin.rs b/src/plugins/ams2/ams2_plugin.rs index e2a4316..2d0c9c8 100644 --- a/src/plugins/ams2/ams2_plugin.rs +++ b/src/plugins/ams2/ams2_plugin.rs @@ -19,7 +19,6 @@ mod game_state { pub const FRONT_END: u32 = 1; } - pub struct Ams2Plugin { #[cfg(windows)] mem: Option, diff --git a/src/plugins/iracing/iracing_plugin.rs b/src/plugins/iracing/iracing_plugin.rs new file mode 100644 index 0000000..31c9b4b --- /dev/null +++ b/src/plugins/iracing/iracing_plugin.rs @@ -0,0 +1,166 @@ +//! iRacing plugin — reads telemetry via the iRacing SDK shared memory API. + +use anyhow::Result; + +use crate::core::TelemetryData; +use crate::plugins::{GameConfig, GamePlugin}; + +#[cfg(windows)] +use super::shared_memory::IracingSharedMemory; +#[cfg(windows)] +use crate::core::VehicleTelemetry; +#[cfg(windows)] +use std::f32::consts::PI; +#[cfg(windows)] +use tracing::{info, warn}; + +pub struct IracingPlugin { + #[cfg(windows)] + mem: Option, +} + +impl IracingPlugin { + pub fn new() -> Self { + Self { + #[cfg(windows)] + mem: None, + } + } +} + +impl Default for IracingPlugin { + fn default() -> Self { + Self::new() + } +} + +impl GamePlugin for IracingPlugin { + fn name(&self) -> &str { + "iRacing" + } + + fn connect(&mut self) -> Result<()> { + #[cfg(windows)] + { + match IracingSharedMemory::open() { + Ok(mem) => { + info!("Connected to iRacing shared memory"); + self.mem = Some(mem); + Ok(()) + } + Err(e) => { + warn!("iRacing shared memory unavailable: {}", e); + Err(e) + } + } + } + #[cfg(not(windows))] + { + Err(anyhow::anyhow!( + "iRacing plugin requires Windows (shared memory is Windows-only)" + )) + } + } + + fn disconnect(&mut self) { + #[cfg(windows)] + { + self.mem = None; + info!("Disconnected from iRacing shared memory"); + } + } + + fn is_connected(&self) -> bool { + #[cfg(windows)] + { + // Verify the session is still active in case iRacing left the session. + self.mem + .as_ref() + .is_some_and(|m| unsafe { m.is_connected() }) + } + #[cfg(not(windows))] + { + false + } + } + + fn is_available(&self) -> bool { + #[cfg(windows)] + { + IracingSharedMemory::is_available() + } + #[cfg(not(windows))] + { + false + } + } + + fn read_telemetry(&mut self) -> Result> { + #[cfg(windows)] + { + let mem = match &self.mem { + Some(m) => m, + None => return Ok(None), + }; + + if !unsafe { mem.is_connected() } { + // Session ended — drop the mapping so connect() will rescan var headers. + self.mem = None; + return Ok(None); + } + + let buf = unsafe { mem.current_buf_offset() }; + + let throttle = unsafe { mem.throttle(buf) }.clamp(0.0, 1.0); + let brake = unsafe { mem.brake(buf) }.clamp(0.0, 1.0); + // iRacing Clutch: 0 = pedal pressed (disengaged), 1 = pedal released (engaged). + // Invert so that our model convention (1.0 = fully pressed) is respected. + let clutch = (1.0 - unsafe { mem.clutch_raw(buf) }).clamp(0.0, 1.0); + // SteeringWheelAngle: radians, positive = CCW (left). Convert to degrees, positive = right. + let steering_angle = unsafe { mem.steering_wheel_angle_rad(buf) } * -(180.0 / PI); + let speed = unsafe { mem.speed(buf) }; + let gear = unsafe { mem.gear(buf) }; + let rpm = unsafe { mem.rpm(buf) }; + let abs_active = unsafe { mem.abs_active(buf) }; + let track_position = unsafe { mem.lap_dist_pct(buf) }.clamp(0.0, 1.0); + + let vehicle = VehicleTelemetry { + throttle, + brake, + clutch, + steering_angle, + speed, + gear, + rpm, + abs_active, + tc_active: false, + track_position, + }; + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + Ok(Some(TelemetryData { + timestamp, + vehicle, + session: None, + })) + } + #[cfg(not(windows))] + { + Ok(None) + } + } + + fn get_config(&self) -> GameConfig { + GameConfig { + // iRacing typically uses ±PI radians full lock, but varies by car. + // 450° is a reasonable UI default (same as ACC/AMS2 convention). + max_steering_angle: 450.0, + pedal_deadzone: 0.01, + abs_threshold: 0.01, + } + } +} diff --git a/src/plugins/iracing/mod.rs b/src/plugins/iracing/mod.rs new file mode 100644 index 0000000..c586a13 --- /dev/null +++ b/src/plugins/iracing/mod.rs @@ -0,0 +1,7 @@ +//! iRacing plugin (iRacing SDK shared memory API) + +mod iracing_plugin; +#[cfg(windows)] +mod shared_memory; + +pub use iracing_plugin::IracingPlugin; diff --git a/src/plugins/iracing/shared_memory.rs b/src/plugins/iracing/shared_memory.rs new file mode 100644 index 0000000..8ab6f03 --- /dev/null +++ b/src/plugins/iracing/shared_memory.rs @@ -0,0 +1,279 @@ +//! iRacing shared memory accessor (Windows only). +//! +//! iRacing exposes telemetry via a Win32 file mapping named `Local\IRSDKMemMapFileName`. +//! Unlike pCars2/AMS2, iRacing uses a dynamic variable header system: variable offsets +//! within the data buffer are not fixed and must be resolved by scanning the var headers. +//! +//! Reference: iRacing SDK (irsdk_defines.h / irsdk.h) +#![cfg(windows)] +#![allow(dead_code)] + +use anyhow::{anyhow, Result}; +use winapi::um::errhandlingapi::GetLastError; +use winapi::um::handleapi::CloseHandle; +use winapi::um::memoryapi::{MapViewOfFile, OpenFileMappingW, UnmapViewOfFile, FILE_MAP_READ}; +use winapi::um::winnt::HANDLE; + +// ── irsdk_header layout ──────────────────────────────────────────────────── +// +// int ver; // offset 0 +// int status; // offset 4 ← irsdk_stConnected = 1 +// int tickRate; // offset 8 +// int sessionInfoUpdate; // offset 12 +// int sessionInfoLen; // offset 16 +// int sessionInfoOffset; // offset 20 +// int numVars; // offset 24 +// int varHeaderOffset; // offset 28 +// int numBuf; // offset 32 +// int bufLen; // offset 36 +// int pad1[2]; // offset 40 +// irsdk_varBuf varBuf[4]; // offset 48 (each 16 bytes) +// +// irsdk_varBuf: +// int tickCount; // +0 +// int bufOffset; // +4 ← byte offset from start of mapping for this buffer +// int pad[2]; // +8 + +const HDR_STATUS: usize = 4; +const HDR_NUM_VARS: usize = 24; +const HDR_VAR_HEADER_OFFSET: usize = 28; +const HDR_NUM_BUF: usize = 32; +const HDR_VAR_BUF_BASE: usize = 48; +const VAR_BUF_STRIDE: usize = 16; + +const STATUS_CONNECTED: i32 = 1; + +// ── irsdk_varHeader layout (144 bytes each) ──────────────────────────────── +// +// int type; // +0 +// int offset; // +4 ← byte offset within a data buffer row +// int count; // +8 +// bool countAsTime; // +12 +// char pad[3]; // +13 +// char name[32]; // +16 +// char desc[64]; // +48 +// char unit[32]; // +112 + +const VAR_HDR_STRIDE: usize = 144; +const VAR_HDR_OFFSET_FIELD: usize = 4; +const VAR_HDR_NAME_FIELD: usize = 16; + +fn to_wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +pub struct IracingSharedMemory { + handle: HANDLE, + ptr: *const u8, + num_buf: i32, + // Cached byte offsets within a data buffer for each variable we care about. + // None means the variable was not present in the var header list. + pub(super) throttle_off: Option, + pub(super) brake_off: Option, + /// iRacing Clutch: 0 = pedal released (clutch engaged), 1 = pedal fully pressed (disengaged) + pub(super) clutch_off: Option, + /// SteeringWheelAngle in radians. Positive = counter-clockwise (left). + pub(super) steering_off: Option, + /// Speed in m/s + pub(super) speed_off: Option, + /// Gear: -1 = reverse, 0 = neutral, 1+ = forward gears + pub(super) gear_off: Option, + /// Engine RPM + pub(super) rpm_off: Option, + /// ABS currently active (bool, 1 byte) + pub(super) abs_off: Option, + /// Lap distance percentage 0.0–1.0 + pub(super) track_pos_off: Option, +} + +unsafe impl Send for IracingSharedMemory {} +unsafe impl Sync for IracingSharedMemory {} + +impl IracingSharedMemory { + const MAPPING_NAME: &'static str = "Local\\IRSDKMemMapFileName"; + + pub fn open() -> Result { + unsafe { + let wide = to_wide(Self::MAPPING_NAME); + let handle = OpenFileMappingW(FILE_MAP_READ, 0, wide.as_ptr()); + if handle.is_null() { + let err = GetLastError(); + return Err(anyhow!( + "iRacing shared memory not found (Windows error {err}) — is iRacing running?" + )); + } + + let ptr = MapViewOfFile(handle, FILE_MAP_READ, 0, 0, 0) as *const u8; + if ptr.is_null() { + CloseHandle(handle); + return Err(anyhow!("Failed to map iRacing shared memory view")); + } + + let status = (ptr.add(HDR_STATUS) as *const i32).read_volatile(); + if status & STATUS_CONNECTED == 0 { + UnmapViewOfFile(ptr as *mut _); + CloseHandle(handle); + return Err(anyhow!( + "iRacing is running but not in an active session (status={})", + status + )); + } + + let num_vars = (ptr.add(HDR_NUM_VARS) as *const i32).read_volatile(); + let var_hdr_off = + (ptr.add(HDR_VAR_HEADER_OFFSET) as *const i32).read_volatile() as usize; + let num_buf = (ptr.add(HDR_NUM_BUF) as *const i32).read_volatile(); + + // Scan var headers, cache offsets for the fields we need. + let mut throttle_off = None; + let mut brake_off = None; + let mut clutch_off = None; + let mut steering_off = None; + let mut speed_off = None; + let mut gear_off = None; + let mut rpm_off = None; + let mut abs_off = None; + let mut track_pos_off = None; + + for i in 0..num_vars as usize { + let hdr = ptr.add(var_hdr_off + i * VAR_HDR_STRIDE); + let off = (hdr.add(VAR_HDR_OFFSET_FIELD) as *const i32).read_volatile(); + + // Read null-terminated name (max 32 bytes) + let name_start = hdr.add(VAR_HDR_NAME_FIELD); + let name_bytes: Vec = (0..32) + .map(|j| name_start.add(j).read()) + .take_while(|&b| b != 0) + .collect(); + let name = String::from_utf8_lossy(&name_bytes); + + match name.as_ref() { + "Throttle" => throttle_off = Some(off), + "Brake" => brake_off = Some(off), + "Clutch" => clutch_off = Some(off), + "SteeringWheelAngle" => steering_off = Some(off), + "Speed" => speed_off = Some(off), + "Gear" => gear_off = Some(off), + "RPM" => rpm_off = Some(off), + "ABSactive" => abs_off = Some(off), + "LapDistPct" => track_pos_off = Some(off), + _ => {} + } + } + + Ok(Self { + handle, + ptr, + num_buf, + throttle_off, + brake_off, + clutch_off, + steering_off, + speed_off, + gear_off, + rpm_off, + abs_off, + track_pos_off, + }) + } + } + + pub fn is_available() -> bool { + unsafe { + let wide = to_wide(Self::MAPPING_NAME); + let h = OpenFileMappingW(FILE_MAP_READ, 0, wide.as_ptr()); + if h.is_null() { + false + } else { + CloseHandle(h); + true + } + } + } + + pub unsafe fn is_connected(&self) -> bool { + let status = (self.ptr.add(HDR_STATUS) as *const i32).read_volatile(); + status & STATUS_CONNECTED != 0 + } + + /// Return the byte offset (from the start of the mapping) of the most recent data buffer. + pub unsafe fn current_buf_offset(&self) -> usize { + let mut best_tick = i32::MIN; + let mut best_off = 0usize; + for i in 0..self.num_buf as usize { + let base = HDR_VAR_BUF_BASE + i * VAR_BUF_STRIDE; + let tick = (self.ptr.add(base) as *const i32).read_volatile(); + let buf_off = (self.ptr.add(base + 4) as *const i32).read_volatile() as usize; + if tick > best_tick { + best_tick = tick; + best_off = buf_off; + } + } + best_off + } + + // ── Typed readers ────────────────────────────────────────────────────── + + pub unsafe fn f32_at(&self, buf_off: usize, var_off: i32) -> f32 { + (self.ptr.add(buf_off + var_off as usize) as *const f32).read_volatile() + } + + pub unsafe fn i32_at(&self, buf_off: usize, var_off: i32) -> i32 { + (self.ptr.add(buf_off + var_off as usize) as *const i32).read_volatile() + } + + pub unsafe fn bool_at(&self, buf_off: usize, var_off: i32) -> bool { + self.ptr.add(buf_off + var_off as usize).read_volatile() != 0 + } + + // ── Named field accessors (return defaults when var not present) ──────── + + pub unsafe fn throttle(&self, buf: usize) -> f32 { + self.throttle_off.map_or(0.0, |o| self.f32_at(buf, o)) + } + + pub unsafe fn brake(&self, buf: usize) -> f32 { + self.brake_off.map_or(0.0, |o| self.f32_at(buf, o)) + } + + /// Raw iRacing clutch: 0 = pedal pressed (disengaged), 1 = pedal released (engaged). + /// Invert before use if your model wants 1 = pedal fully pressed. + pub unsafe fn clutch_raw(&self, buf: usize) -> f32 { + self.clutch_off.map_or(0.0, |o| self.f32_at(buf, o)) + } + + /// Steering wheel angle in radians. Positive = counter-clockwise (left). + pub unsafe fn steering_wheel_angle_rad(&self, buf: usize) -> f32 { + self.steering_off.map_or(0.0, |o| self.f32_at(buf, o)) + } + + /// Speed in m/s. + pub unsafe fn speed(&self, buf: usize) -> f32 { + self.speed_off.map_or(0.0, |o| self.f32_at(buf, o)) + } + + pub unsafe fn gear(&self, buf: usize) -> i32 { + self.gear_off.map_or(0, |o| self.i32_at(buf, o)) + } + + pub unsafe fn rpm(&self, buf: usize) -> f32 { + self.rpm_off.map_or(0.0, |o| self.f32_at(buf, o)) + } + + pub unsafe fn abs_active(&self, buf: usize) -> bool { + self.abs_off.map_or(false, |o| self.bool_at(buf, o)) + } + + pub unsafe fn lap_dist_pct(&self, buf: usize) -> f32 { + self.track_pos_off.map_or(0.0, |o| self.f32_at(buf, o)) + } +} + +impl Drop for IracingSharedMemory { + fn drop(&mut self) { + unsafe { + UnmapViewOfFile(self.ptr as *mut _); + CloseHandle(self.handle); + } + } +} diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index 74b2d19..7d0ad33 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -2,6 +2,7 @@ pub mod ams2; pub mod assetto_competizione; +pub mod iracing; pub mod mock; pub mod registry; pub mod trait_; diff --git a/src/plugins/registry.rs b/src/plugins/registry.rs index 48c8349..ac4e186 100644 --- a/src/plugins/registry.rs +++ b/src/plugins/registry.rs @@ -26,7 +26,11 @@ impl PluginRegistry { /// Discover available plugins fn discover_plugins() -> Vec { #[cfg(windows)] - return vec!["assetto_competizione".to_string(), "ams2".to_string()]; + return vec![ + "assetto_competizione".to_string(), + "ams2".to_string(), + "iracing".to_string(), + ]; #[cfg(not(windows))] return vec!["test".to_string()]; diff --git a/src/plugins/trait_.rs b/src/plugins/trait_.rs index 83eb8cd..fc86152 100644 --- a/src/plugins/trait_.rs +++ b/src/plugins/trait_.rs @@ -52,6 +52,7 @@ pub fn plugin_entries() -> &'static [(&'static str, &'static str)] { &[ ("assetto_competizione", "Assetto Corsa Competizione"), ("ams2", "Automobilista 2"), + ("iracing", "iRacing"), ("mock", "Mock (Simulated Data)"), ] } @@ -74,6 +75,7 @@ pub fn create_plugin(name: &str) -> Option> { "ams2" | "automobilista 2" | "automobilista2" => { Some(Box::new(crate::plugins::ams2::Ams2Plugin::new())) } + "iracing" | "iracing_sdk" => Some(Box::new(crate::plugins::iracing::IracingPlugin::new())), "mock" | "test" => Some(Box::new(crate::plugins::mock::MockPlugin::new())), _ => None, } diff --git a/src/renderer/app.rs b/src/renderer/app.rs index a5ee161..248cbdb 100644 --- a/src/renderer/app.rs +++ b/src/renderer/app.rs @@ -630,13 +630,8 @@ fn draw_telemetry( ui.spacing_mut().item_spacing.x = 0.0; ui.horizontal(|ui| { // ── Trace graph ────────────────────────────────────────────────────── - crate::renderer::TraceGraph::new( - buffer.map(|v| &**v), - &settings.graph, - colors, - opacity, - ) - .show(ui, egui::vec2(graph_w, graph_h)); + crate::renderer::TraceGraph::new(buffer.map(|v| &**v), &settings.graph, colors, opacity) + .show(ui, egui::vec2(graph_w, graph_h)); // Gap between graph and bars ui.allocate_exact_size(egui::vec2(gap, available.height()), egui::Sense::hover()); From 064cff0f6814fd83b86fd8c9977609c28e5e2ee6 Mon Sep 17 00:00:00 2001 From: Robert Gracey <70551819+rgracey@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:01:24 +1100 Subject: [PATCH 2/2] chore: update README --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a7e7ec6..fbf671a 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,11 @@ A lightweight sim racing telemetry overlay. Displays pedal inputs, steering angl ## Supported games -| Game | Notes | -| ----------------------------- |------------------------------------------| -| Assetto Corsa Competizione | | -| Automobilista 2 | [Enable shared memory](#automobilista-2) | +| Game | Notes | +| -------------------------- | ---------------------------------------- | +| Assetto Corsa Competizione | | +| Automobilista 2 | [Enable shared memory](#automobilista-2) | +| iRacing | Support is currently experimental | ## Installation