kerb 0.2.0

Real-time telemetry from racing simulators — iRacing, AC Evo, Le Mans Ultimate
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
use std::collections::HashMap;
use std::thread::sleep;
use std::time::Duration;
use windows_sys::Win32::Foundation::{CloseHandle, FALSE, HANDLE, WAIT_OBJECT_0};
use windows_sys::Win32::System::Threading::{OpenEventW, WaitForSingleObject};

use crate::connection::ReadResult;
use crate::iracing::structs::{IRSDK_MAX_BUFS, VarType, irsdk_header, irsdk_varHeader};
use crate::types::TelemetryValue;

const SYNCHRONIZE: u32 = 0x00100000;

const SHM_MEM_MAP_FILE: &str = "Local\\IRSDKMemMapFileName";
const SHM_DATA_VALID_EVENT: &str = "Local\\IRSDKDataValidEventName";

/// Decode a null-terminated cp1252 byte array into a Rust `String`.
/// iRacing stores all strings in shared memory as fixed-size, null-padded cp1252 buffers.
fn parse_c_str(bytes: &[u8]) -> String {
    let len = bytes.iter().position(|&x| x == 0).unwrap_or(bytes.len());
    crate::decode_cp1252(&bytes[..len])
}

/// Live connection to the iRacing telemetry service via Win32 shared memory.
///
/// Holds the shared-memory region, an optional data-ready event used for
/// efficient frame-synchronised waiting, and the parsed variable header table.
///
/// # Threading
///
/// Not [`Send`] — and this is a deliberate API contract, not an oversight:
/// the struct holds raw shared-memory pointers, a Win32 event `HANDLE`, and a
/// `RefCell` session cache. Create and use the connection on a single thread;
/// for GUI apps spawn a dedicated telemetry `std::thread` that owns the
/// connection and sends normalized data out through channels or events.
pub struct IRsdkConnection {
    shm: crate::shm::SharedMemRegion,
    h_event: HANDLE,
    pub(crate) vars: HashMap<String, irsdk_varHeader>,
    cached_session: std::cell::RefCell<Option<(i32, crate::iracing::session::IracingSession)>>,
    pub(crate) offsets: crate::iracing::types::IracingOffsets,
}

impl IRsdkConnection {
    /// Create a mock connection for unit testing and benchmarking.
    #[doc(hidden)]
    pub unsafe fn new_mock(
        view_address: *mut std::ffi::c_void,
        vars: HashMap<String, irsdk_varHeader>,
    ) -> Self {
        let offsets = crate::iracing::types::IracingOffsets::resolve(&vars);
        Self {
            shm: unsafe { crate::shm::SharedMemRegion::new_mock(view_address) },
            h_event: 0 as _,
            vars,
            cached_session: std::cell::RefCell::new(None),
            offsets,
        }
    }

    /// Open the iRacing shared-memory region and parse the variable header table.
    /// Returns `Err` if the sim is not running or the header is invalid.
    #[doc(hidden)]
    pub fn connect() -> Result<Self, crate::error::SimError> {
        let shm = crate::shm::SharedMemRegion::open(SHM_MEM_MAP_FILE)
            .map_err(crate::error::SimError::NotConnected)?;

        let shared_mem = shm.as_ptr();

        unsafe {
            let header = std::ptr::read_unaligned(shared_mem as *const irsdk_header);

            let event_name: Vec<u16> = SHM_DATA_VALID_EVENT
                .encode_utf16()
                .chain(std::iter::once(0))
                .collect();

            let h_event = OpenEventW(SYNCHRONIZE, FALSE, event_name.as_ptr());

            if header.ver <= 0 || header.ver > 10 || header.num_vars <= 0 {
                if !h_event.is_null() {
                    CloseHandle(h_event);
                }

                return Err(crate::error::SimError::InvalidHeader(format!(
                    "Invalid iRacing telemetry header (ver={}, num_vars={})",
                    header.ver, header.num_vars
                )));
            }

            let shm_size = 32 * 1024 * 1024usize; // 32 MB — upper bound for iRacing SHM
            let var_offset = header.var_header_offset as usize;
            let element_size = std::mem::size_of::<irsdk_varHeader>();
            let num = header.num_vars as usize;
            if var_offset.saturating_add(num.saturating_mul(element_size)) > shm_size {
                if !h_event.is_null() {
                    CloseHandle(h_event);
                }
                return Err(crate::error::SimError::InvalidHeader(
                    "var_header_offset out of SHM bounds".into(),
                ));
            }

            let mut vars = HashMap::new();

            for i in 0..num {
                let offset = var_offset + i * element_size;
                let var_header_ptr = shared_mem.add(offset) as *const irsdk_varHeader;
                let var_header = std::ptr::read_unaligned(var_header_ptr);
                let name_str = parse_c_str(&var_header.name);
                vars.insert(name_str, var_header);
            }

            let offsets = crate::iracing::types::IracingOffsets::resolve(&vars);

            Ok(Self {
                shm,
                h_event,
                vars,
                cached_session: std::cell::RefCell::new(None),
                offsets,
            })
        }
    }

    /// Returns `true` when the iRacing sim is actively broadcasting telemetry (status bit 0).
    pub(crate) fn is_connected(&self) -> bool {
        unsafe {
            let offset = std::mem::offset_of!(irsdk_header, status);
            let status = std::ptr::read_unaligned(self.shm.as_ptr().add(offset) as *const i32);
            (status & 1) != 0
        }
    }

    /// Block until the sim signals new data, or until `timeout_ms` elapses.
    ///
    /// Returns `true` when data is (likely) available, `false` on timeout or disconnect.
    /// Falls back to a 16 ms sleep when the event handle is unavailable, then checks
    /// `is_connected()` — callers receive `false` as soon as the sim closes even without
    /// a Win32 event to wake them.
    pub(crate) fn wait_for_data(&self, timeout_ms: u32) -> bool {
        unsafe {
            if self.h_event.is_null() {
                if timeout_ms > 0 {
                    sleep(Duration::from_millis(16));
                }

                self.is_connected()
            } else {
                let wait_result = WaitForSingleObject(self.h_event, timeout_ms);

                wait_result == WAIT_OBJECT_0
            }
        }
    }

    /// Return the session info update version counter.
    /// This counter increments whenever the session info YAML block changes,
    /// so callers can cheaply detect session changes without re-reading the YAML.
    pub fn session_info_update(&self) -> i32 {
        unsafe {
            let shared_mem = self.shm.as_ptr();
            if shared_mem.is_null() {
                return -1;
            }
            let offset = std::mem::offset_of!(irsdk_header, session_info_update);
            std::ptr::read_unaligned(shared_mem.add(offset) as *const i32)
        }
    }

    // Find the double-buffer with the highest tick count and return a pointer to its data.
    fn get_latest_data_ptr(&self) -> Option<*const u8> {
        unsafe {
            let shared_mem = self.shm.as_ptr();

            let header = std::ptr::read_unaligned(shared_mem as *const irsdk_header);

            if header.num_buf <= 0 || header.num_buf as usize > IRSDK_MAX_BUFS {
                return None;
            }

            let mut latest_buf_idx = 0;
            let mut max_tick_count = -1;

            for i in 0..header.num_buf as usize {
                let tick_count = header.var_buf[i].tick_count;

                if tick_count > max_tick_count {
                    max_tick_count = tick_count;

                    latest_buf_idx = i;
                }
            }

            if max_tick_count < 0 {
                return None;
            }

            let buf_offset = header.var_buf[latest_buf_idx].buf_offset as usize;

            Some(shared_mem.add(buf_offset))
        }
    }

    /// Read a single telemetry variable by name from the latest data buffer.
    /// Uses `read_unaligned` because shared-memory offsets are not guaranteed
    /// to satisfy Rust's alignment requirements.
    #[doc(hidden)]
    pub fn read_variable(&self, name: &str) -> Option<TelemetryValue> {
        let var = self.vars.get(name)?;
        let data_ptr = self.get_latest_data_ptr()?;
        let offset = var.offset as usize;

        unsafe {
            let ptr = data_ptr.add(offset);
            let count = var.count as usize;

            match VarType::from_i32(var.type_)? {
                VarType::Char => {
                    if var.count_as_char != 0 {
                        let slice = std::slice::from_raw_parts(ptr, count);

                        Some(TelemetryValue::String(parse_c_str(slice)))
                    } else if count == 1 {
                        Some(TelemetryValue::Char(std::ptr::read_unaligned(ptr)))
                    } else {
                        let mut vec = Vec::with_capacity(count);

                        for idx in 0..count {
                            vec.push(std::ptr::read_unaligned(ptr.add(idx)));
                        }

                        Some(TelemetryValue::String(crate::decode_cp1252(&vec)))
                    }
                }

                VarType::Bool => {
                    if count == 1 {
                        Some(TelemetryValue::Bool(std::ptr::read_unaligned(ptr) != 0))
                    } else {
                        let mut vec = Vec::with_capacity(count);

                        for idx in 0..count {
                            vec.push(std::ptr::read_unaligned(ptr.add(idx)) != 0);
                        }

                        Some(TelemetryValue::BoolArray(vec))
                    }
                }

                VarType::Int => {
                    let int_ptr = ptr as *const i32;

                    if count == 1 {
                        Some(TelemetryValue::Int(std::ptr::read_unaligned(int_ptr)))
                    } else {
                        let mut vec = Vec::with_capacity(count);

                        for idx in 0..count {
                            vec.push(std::ptr::read_unaligned(int_ptr.add(idx)));
                        }

                        Some(TelemetryValue::IntArray(vec))
                    }
                }

                VarType::BitField => {
                    let uint_ptr = ptr as *const u32;

                    if count == 1 {
                        Some(TelemetryValue::BitField(std::ptr::read_unaligned(uint_ptr)))
                    } else {
                        let mut vec = Vec::with_capacity(count);

                        for idx in 0..count {
                            let u32_val = std::ptr::read_unaligned(uint_ptr.add(idx));

                            vec.push(u32_val as i32);
                        }

                        Some(TelemetryValue::IntArray(vec))
                    }
                }

                VarType::Float => {
                    let float_ptr = ptr as *const f32;

                    if count == 1 {
                        Some(TelemetryValue::Float(std::ptr::read_unaligned(float_ptr)))
                    } else {
                        let mut vec = Vec::with_capacity(count);

                        for idx in 0..count {
                            vec.push(std::ptr::read_unaligned(float_ptr.add(idx)));
                        }

                        Some(TelemetryValue::FloatArray(vec))
                    }
                }

                VarType::Double => {
                    let double_ptr = ptr as *const f64;

                    if count == 1 {
                        Some(TelemetryValue::Double(std::ptr::read_unaligned(double_ptr)))
                    } else {
                        let mut vec = Vec::with_capacity(count);

                        for idx in 0..count {
                            vec.push(std::ptr::read_unaligned(double_ptr.add(idx)));
                        }

                        Some(TelemetryValue::DoubleArray(vec))
                    }
                }
            }
        }
    }

    /// Snapshot every known variable from the latest data buffer into a map.
    pub(crate) fn read_all_variables(&self) -> HashMap<String, TelemetryValue> {
        let mut map = HashMap::with_capacity(self.vars.len());

        for name in self.vars.keys() {
            if let Some(val) = self.read_variable(name) {
                map.insert(name.clone(), val);
            }
        }
        map
    }

    /// Raw YAML session string from iRacing shared memory.
    pub fn session_yaml(&self) -> Option<String> {
        unsafe {
            let shared_mem = self.shm.as_ptr();
            let header = std::ptr::read_unaligned(shared_mem as *const irsdk_header);

            if header.session_info_len <= 0 {
                return None;
            }

            let info_ptr = shared_mem.add(header.session_info_offset as usize);
            let bytes = std::slice::from_raw_parts(info_ptr, header.session_info_len as usize);

            let len = bytes.iter().position(|&x| x == 0).unwrap_or(bytes.len());

            Some(crate::decode_cp1252(&bytes[..len]))
        }
    }

    /// All current telemetry variables as a map with sim-native names and units.
    pub fn telemetry_snapshot(
        &self,
    ) -> std::collections::HashMap<String, crate::types::TelemetryValue> {
        self.read_all_variables()
    }

    /// Metadata for every telemetry variable iRacing currently exposes.
    pub fn var_list_snapshot(&self) -> Vec<crate::types::VarMeta> {
        use crate::iracing::structs::VarType;

        self.vars
            .iter()
            .map(|(name, hdr)| {
                let type_name = match VarType::from_i32(hdr.type_) {
                    Some(VarType::Char) => "char",
                    Some(VarType::Bool) => "bool",
                    Some(VarType::Int) => "int",
                    Some(VarType::BitField) => "bitfield",
                    Some(VarType::Float) => "float",
                    Some(VarType::Double) => "double",
                    None => "unknown",
                };

                let unit = {
                    let len = hdr
                        .unit
                        .iter()
                        .position(|&x| x == 0)
                        .unwrap_or(hdr.unit.len());

                    crate::decode_cp1252(&hdr.unit[..len])
                };

                let desc = {
                    let len = hdr
                        .desc
                        .iter()
                        .position(|&x| x == 0)
                        .unwrap_or(hdr.desc.len());

                    crate::decode_cp1252(&hdr.desc[..len])
                };

                crate::types::VarMeta {
                    name: name.clone(),
                    type_name,
                    unit,
                    desc,
                    count: hdr.count as u32,
                }
            })
            .collect()
    }

    /// Capture a full telemetry frame. Reads all variables from shared memory in one pass.
    pub(crate) fn frame(
        &self,
    ) -> Result<crate::iracing::types::IracingFrame, crate::error::SimError> {
        let data_ptr = self
            .get_latest_data_ptr()
            .ok_or_else(|| crate::error::SimError::InvalidHeader("No valid data buffer".into()))?;
        Ok(crate::iracing::types::IracingFrame::from_raw(
            data_ptr,
            &self.offsets,
        ))
    }

    /// Read the next telemetry frame, blocking up to `timeout_ms`.
    ///
    /// iRacing is **event-driven**: the call blocks on a Win32 data-ready
    /// event and returns as soon as new data arrives (often <1 ms at 60 Hz).
    /// Pass `0` for a non-blocking read.
    ///
    /// - [`ReadResult::Frame`] — new data arrived and was read successfully.
    /// - [`ReadResult::NotReady`] — `timeout_ms` expired without new data
    ///   (sim may be paused or loading).
    /// - [`ReadResult::Disconnected`] — iRacing stopped broadcasting.
    pub fn read_frame(&self, timeout_ms: u32) -> ReadResult<crate::iracing::types::IracingFrame> {
        if !self.wait_for_data(timeout_ms) {
            if !self.is_connected() {
                return ReadResult::Disconnected;
            }

            return ReadResult::NotReady;
        }

        if !self.is_connected() {
            return ReadResult::Disconnected;
        }

        match self.frame() {
            Ok(frame) => ReadResult::Frame(frame),
            Err(_) => ReadResult::NotReady,
        }
    }

    /// Parse the current session-info YAML into an `IracingSession`.
    ///
    /// Automatically caches the parsed representation and only re-parses the large
    /// YAML block if iRacing reports that the session info has changed.
    pub fn session_info(&self) -> Option<crate::iracing::session::IracingSession> {
        let current_version = self.session_info_update();

        if let Some((_, session)) = self
            .cached_session
            .borrow()
            .as_ref()
            .filter(|(v, _)| *v == current_version)
        {
            return Some(session.clone());
        }

        let yaml = self.session_yaml()?;

        let session = crate::iracing::session::IracingSession::from_yaml(&yaml)?;

        *self.cached_session.borrow_mut() = Some((current_version, session.clone()));

        Some(session)
    }
}

/// Closes the data-valid event handle on drop. The shared-memory region is
/// managed by `SharedMemRegion` and cleaned up automatically.
impl Drop for IRsdkConnection {
    fn drop(&mut self) {
        unsafe {
            if !self.h_event.is_null() {
                CloseHandle(self.h_event);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::iracing::structs::irsdk_header;
    use std::collections::HashMap;
    use std::mem;

    fn make_header(status: i32) -> Vec<u8> {
        let mut hdr = unsafe { mem::zeroed::<irsdk_header>() };
        hdr.ver = 2;
        hdr.status = status;
        hdr.num_vars = 0;
        hdr.var_header_offset = mem::size_of::<irsdk_header>() as i32;
        let ptr = &hdr as *const irsdk_header as *const u8;
        unsafe { std::slice::from_raw_parts(ptr, mem::size_of::<irsdk_header>()) }.to_vec()
    }

    /// When status bit 0 is set (sim active), `wait_for_data` with no event handle
    /// must sleep ~16 ms and return `true`.
    #[test]
    fn wait_for_data_no_event_connected_returns_true() {
        let mut buf = make_header(1);
        let conn = unsafe { IRsdkConnection::new_mock(buf.as_mut_ptr() as _, HashMap::new()) };
        assert!(conn.wait_for_data(100));
    }

    /// When status bit 0 is clear (sim closed), `wait_for_data` with no event handle
    /// must return `false` — not `true` as the old code did unconditionally.
    #[test]
    fn wait_for_data_no_event_disconnected_returns_false() {
        let mut buf = make_header(0);
        let conn = unsafe { IRsdkConnection::new_mock(buf.as_mut_ptr() as _, HashMap::new()) };
        assert!(!conn.wait_for_data(100));
    }

    /// `is_connected` reflects the status bit independently of the event handle.
    #[test]
    fn is_connected_reads_status_bit() {
        let mut buf_on = make_header(1);
        let conn_on =
            unsafe { IRsdkConnection::new_mock(buf_on.as_mut_ptr() as _, HashMap::new()) };
        assert!(conn_on.is_connected());

        let mut buf_off = make_header(0);
        let conn_off =
            unsafe { IRsdkConnection::new_mock(buf_off.as_mut_ptr() as _, HashMap::new()) };
        assert!(!conn_off.is_connected());
    }
}