From 198818cc014a45b6281f8a17b7d9efcc4d0fe7bb Mon Sep 17 00:00:00 2001 From: Swizzjack Date: Thu, 11 Jun 2026 19:02:07 +0000 Subject: [PATCH 01/10] fix(bridge): correctness hardening across SDK reader, WS handler, engineer task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reader: decode frame values via from_le_bytes instead of casting &[u8] to &[T] (unaligned reads were UB; Vec guarantees no alignment) - piper install: zip-slip protection via enclosed_name(), throttle download progress broadcasts to 250ms intervals - ws handler: a closed watch/broadcast channel disables that branch instead of killing the connection (fixes reconnect loop on non-Windows and removes the need for the update task's pending() keep-alive hack) - engineer task: piper/voice installs run in their own task instead of blocking the select loop; state backlog collapses to the newest snapshot so rules never fire on stale data in bursts - tts engine: drop dead keep-alive fields (piper_process was never spawned persistently); document per-request spawn trade-off - flags: per-car blue/debris bits corrected to official irsdk_defines.h values (0x0020/0x0040; previous 0x0400/0x0800 are greenHeld/tenToGo) — needs a live-session sanity check - protocol: box Telemetry/SessionInfo variants (enum was ~720B by value) Co-Authored-By: Claude Fable 5 --- bridge/src/iracing_sdk/reader.rs | 106 +++++++---------- bridge/src/main.rs | 4 +- bridge/src/race_engineer/mod.rs | 143 +++++++++++++---------- bridge/src/race_engineer/piper_binary.rs | 47 +++++--- bridge/src/race_engineer/state.rs | 9 +- bridge/src/race_engineer/tts_engine.rs | 41 ++----- bridge/src/telemetry/snapshot.rs | 6 +- bridge/src/ws/handler.rs | 62 ++++++---- bridge/src/ws/protocol.rs | 7 +- 9 files changed, 224 insertions(+), 201 deletions(-) diff --git a/bridge/src/iracing_sdk/reader.rs b/bridge/src/iracing_sdk/reader.rs index c8aa36d..c04d95c 100644 --- a/bridge/src/iracing_sdk/reader.rs +++ b/bridge/src/iracing_sdk/reader.rs @@ -277,55 +277,60 @@ impl IRacingClient { } // ── Typed getters ──────────────────────────────────────────────────────── + // + // All getters decode via `from_le_bytes` on the raw frame bytes. The frame + // is a plain `Vec`, so no alignment can be assumed — a `&[u8] → &[T]` + // cast would be UB whenever the allocation or the SDK offset isn't aligned + // for `T`. Scalars decode allocation-free; arrays collect into a `Vec` + // (≤ 64 elements, negligible at our tick rates). pub fn get_f32(&self, name: &str) -> Result { - self.read::(name, VarType::Float).map(|s| s[0]) + self.var_bytes(name, VarType::Float) + .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])) } pub fn get_f64(&self, name: &str) -> Result { - self.read::(name, VarType::Double).map(|s| s[0]) + self.var_bytes(name, VarType::Double).map(|b| { + f64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]) + }) } pub fn get_i32(&self, name: &str) -> Result { - self.read::(name, VarType::Int).map(|s| s[0]) + self.var_bytes(name, VarType::Int) + .map(|b| i32::from_le_bytes([b[0], b[1], b[2], b[3]])) } pub fn get_bool(&self, name: &str) -> Result { - let desc = self - .var_index - .get(name) - .ok_or_else(|| BridgeError::SdkRead(format!("unknown var '{name}'")))?; - if desc.var_type != VarType::Bool { - return Err(BridgeError::SdkRead(format!( - "var '{name}': expected Bool, got {:?}", - desc.var_type - ))); - } - let end = desc.offset + 1; - if end > self.frame.len() { - return Err(BridgeError::SdkRead(format!( - "var '{name}': out of bounds (offset={} frame={})", - desc.offset, - self.frame.len() - ))); - } - Ok(self.frame[desc.offset] != 0) + self.var_bytes(name, VarType::Bool).map(|b| b[0] != 0) } pub fn get_bitfield(&self, name: &str) -> Result { - self.read::(name, VarType::BitField).map(|s| s[0]) + self.var_bytes(name, VarType::BitField) + .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) } - pub fn get_f32_array(&self, name: &str) -> Result<&[f32]> { - self.read::(name, VarType::Float) + pub fn get_f32_array(&self, name: &str) -> Result> { + self.var_bytes(name, VarType::Float).map(|b| { + b.chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect() + }) } - pub fn get_i32_array(&self, name: &str) -> Result<&[i32]> { - self.read::(name, VarType::Int) + pub fn get_i32_array(&self, name: &str) -> Result> { + self.var_bytes(name, VarType::Int).map(|b| { + b.chunks_exact(4) + .map(|c| i32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect() + }) } - pub fn get_bitfield_array(&self, name: &str) -> Result<&[u32]> { - self.read::(name, VarType::BitField) + pub fn get_bitfield_array(&self, name: &str) -> Result> { + self.var_bytes(name, VarType::BitField).map(|b| { + b.chunks_exact(4) + .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect() + }) } /// Dumps every variable known to the SDK with its current value(s), formatted @@ -398,36 +403,16 @@ impl IRacingClient { } pub fn get_bool_array(&self, name: &str) -> Result> { - let desc = self - .var_index - .get(name) - .ok_or_else(|| BridgeError::SdkRead(format!("unknown var '{name}'")))?; - if desc.var_type != VarType::Bool { - return Err(BridgeError::SdkRead(format!( - "var '{name}': expected Bool, got {:?}", - desc.var_type - ))); - } - let end = desc.offset + desc.count; - if end > self.frame.len() { - return Err(BridgeError::SdkRead(format!( - "var '{name}': out of bounds (offset={} count={} frame={})", - desc.offset, - desc.count, - self.frame.len() - ))); - } - Ok(self.frame[desc.offset..end] - .iter() - .map(|&b| b != 0) - .collect()) + self.var_bytes(name, VarType::Bool) + .map(|b| b.iter().map(|&v| v != 0).collect()) } // ── Private helper ─────────────────────────────────────────────────────── - /// Returns a `&[T]` view into `self.frame` for the named variable. - /// Verifies the stored `VarType` matches `expected`. - fn read(&self, name: &str, expected: VarType) -> Result<&[T]> { + /// Returns the raw frame bytes for the named variable (all array elements). + /// Verifies the stored `VarType` matches `expected` and that the variable + /// (including a scalar's single element) lies within the frame buffer. + fn var_bytes(&self, name: &str, expected: VarType) -> Result<&[u8]> { let desc = self .var_index .get(name) @@ -438,9 +423,10 @@ impl IRacingClient { desc.var_type ))); } - let elem_size = std::mem::size_of::(); + let elem_size = expected.size_bytes(); let byte_len = desc .count + .max(1) .checked_mul(elem_size) .ok_or_else(|| BridgeError::SdkRead(format!("var '{name}': byte length overflow")))?; let end = desc @@ -455,13 +441,7 @@ impl IRacingClient { self.frame.len() ))); } - let bytes = &self.frame[desc.offset..end]; - // SAFETY: The iRacing SDK lays out all variables at naturally aligned offsets - // (multiples of the type's size). Vec on Windows x86_64 is allocated with - // ≥16-byte alignment by the global allocator, so bytes.as_ptr() is properly - // aligned for any T with align ≤ 8 (all iRacing types). The slice covers - // exactly `count` elements of size `elem_size`. - Ok(unsafe { std::slice::from_raw_parts(bytes.as_ptr() as *const T, desc.count) }) + Ok(&self.frame[desc.offset..end]) } } diff --git a/bridge/src/main.rs b/bridge/src/main.rs index 1471212..abfd902 100644 --- a/bridge/src/main.rs +++ b/bridge/src/main.rs @@ -90,7 +90,9 @@ async fn main() -> Result<()> { log::info!("update available: v{}", i.latest_version); let _ = upd_tx.send(Some(i)); } - std::future::pending::<()>().await; + // Task ends here. The WS handler tolerates the closed channel, and + // late-connecting clients still see the last value via the initial + // `borrow_and_update()` replay. }); let lan_url = detect_lan_ip().map(|ip| format!("http://{}:{}/", ip, cfg.ws_port)); diff --git a/bridge/src/race_engineer/mod.rs b/bridge/src/race_engineer/mod.rs index 46370b7..8da8c0b 100644 --- a/bridge/src/race_engineer/mod.rs +++ b/bridge/src/race_engineer/mod.rs @@ -64,7 +64,14 @@ pub async fn run_engineer_task( tokio::select! { // --- incoming telemetry state --- maybe_state = state_rx.recv() => { - let Some(current) = maybe_state else { break; }; + let Some(mut current) = maybe_state else { break; }; + // Collapse any backlog to the newest state. While a synthesis + // runs (or an install used to block this loop), 10 Hz states + // queue up — ticking each of them afterwards would fire rules + // against stale data in a burst. + while let Ok(newer) = state_rx.try_recv() { + current = newer; + } let events = dispatcher.tick(¤t, previous_state.as_ref()); previous_state = Some(current.clone()); @@ -139,83 +146,91 @@ async fn handle_command( let _ = audio_tx.send(msg); } + // Installs run in their own task: a download takes tens of seconds and + // must not block the select loop (state ticks, other commands). ClientMessage::EngineerInstallPiper => { - let (prog_tx, mut prog_rx) = mpsc::unbounded_channel::(); - let audio_tx2 = audio_tx.clone(); - - // Forward progress messages concurrently while install runs - let progress_fwd = tokio::spawn(async move { - while let Some(p) = prog_rx.recv().await { - let _ = audio_tx2.send(ServerMessage::EngineerInstallProgress { - target: p.target, - target_id: p.target_id, - bytes_downloaded: p.bytes_downloaded as u32, - bytes_total: p.bytes_total.map(|b| b as u32), - stage: p.stage, - }); - } - }); + let audio_tx = audio_tx.clone(); + tokio::spawn(async move { + let (prog_tx, mut prog_rx) = mpsc::unbounded_channel::(); + let audio_tx2 = audio_tx.clone(); + + // Forward progress messages concurrently while install runs + let progress_fwd = tokio::spawn(async move { + while let Some(p) = prog_rx.recv().await { + let _ = audio_tx2.send(ServerMessage::EngineerInstallProgress { + target: p.target, + target_id: p.target_id, + bytes_downloaded: p.bytes_downloaded as u32, + bytes_total: p.bytes_total.map(|b| b as u32), + stage: p.stage, + }); + } + }); - let install_result = tokio::task::spawn_blocking(move || piper_binary::install(prog_tx)) - .await - .unwrap_or_else(|e| Err(anyhow::anyhow!("task panic: {e}"))); + let install_result = tokio::task::spawn_blocking(move || piper_binary::install(prog_tx)) + .await + .unwrap_or_else(|e| Err(anyhow::anyhow!("task panic: {e}"))); - // Drain any remaining progress messages before reporting completion - let _ = progress_fwd.await; + // Drain any remaining progress messages before reporting completion + let _ = progress_fwd.await; - if let Err(ref e) = install_result { - log::error!("Piper install failed: {e:#}"); - } + if let Err(ref e) = install_result { + log::error!("Piper install failed: {e:#}"); + } - let success = install_result.is_ok() && piper_binary::is_installed(); - let error_msg = install_result.err().map(|e| format!("{e:#}")); - let _ = audio_tx.send(ServerMessage::EngineerInstallComplete { - target: "piper".into(), - target_id: None, - success, - error: if success { None } else { error_msg.or(Some("Installation failed".into())) }, + let success = install_result.is_ok() && piper_binary::is_installed(); + let error_msg = install_result.err().map(|e| format!("{e:#}")); + let _ = audio_tx.send(ServerMessage::EngineerInstallComplete { + target: "piper".into(), + target_id: None, + success, + error: if success { None } else { error_msg.or(Some("Installation failed".into())) }, + }); + // Refresh status + let _ = audio_tx.send(build_status_msg()); }); - // Refresh status - let _ = audio_tx.send(build_status_msg()); } ClientMessage::EngineerInstallVoice { voice_id } => { - let (prog_tx, mut prog_rx) = - mpsc::unbounded_channel::(); - let audio_tx2 = audio_tx.clone(); - let vid = voice_id.clone(); - - let progress_fwd = tokio::spawn(async move { - while let Some(p) = prog_rx.recv().await { - let _ = audio_tx2.send(ServerMessage::EngineerInstallProgress { - target: p.target, - target_id: p.target_id, - bytes_downloaded: p.bytes_downloaded as u32, - bytes_total: p.bytes_total.map(|b| b as u32), - stage: p.stage, - }); - } - }); + let audio_tx = audio_tx.clone(); + tokio::spawn(async move { + let (prog_tx, mut prog_rx) = + mpsc::unbounded_channel::(); + let audio_tx2 = audio_tx.clone(); + let vid = voice_id.clone(); + + let progress_fwd = tokio::spawn(async move { + while let Some(p) = prog_rx.recv().await { + let _ = audio_tx2.send(ServerMessage::EngineerInstallProgress { + target: p.target, + target_id: p.target_id, + bytes_downloaded: p.bytes_downloaded as u32, + bytes_total: p.bytes_total.map(|b| b as u32), + stage: p.stage, + }); + } + }); - let install_result = tokio::task::spawn_blocking(move || voice_manager::install_voice(&vid, prog_tx)) - .await - .unwrap_or_else(|e| Err(anyhow::anyhow!("task panic: {e}"))); + let install_result = tokio::task::spawn_blocking(move || voice_manager::install_voice(&vid, prog_tx)) + .await + .unwrap_or_else(|e| Err(anyhow::anyhow!("task panic: {e}"))); - let _ = progress_fwd.await; + let _ = progress_fwd.await; - if let Err(ref e) = install_result { - log::error!("Voice install failed ({voice_id}): {e:#}"); - } + if let Err(ref e) = install_result { + log::error!("Voice install failed ({voice_id}): {e:#}"); + } - let success = install_result.is_ok() && voice_manager::is_installed(&voice_id); - let error_msg = install_result.err().map(|e| format!("{e:#}")); - let _ = audio_tx.send(ServerMessage::EngineerInstallComplete { - target: "voice".into(), - target_id: Some(voice_id), - success, - error: if success { None } else { error_msg.or(Some("Voice installation failed".into())) }, + let success = install_result.is_ok() && voice_manager::is_installed(&voice_id); + let error_msg = install_result.err().map(|e| format!("{e:#}")); + let _ = audio_tx.send(ServerMessage::EngineerInstallComplete { + target: "voice".into(), + target_id: Some(voice_id), + success, + error: if success { None } else { error_msg.or(Some("Voice installation failed".into())) }, + }); + let _ = audio_tx.send(build_status_msg()); }); - let _ = audio_tx.send(build_status_msg()); } ClientMessage::EngineerUninstallVoice { voice_id } => { diff --git a/bridge/src/race_engineer/piper_binary.rs b/bridge/src/race_engineer/piper_binary.rs index 2a62a21..f315f6e 100644 --- a/bridge/src/race_engineer/piper_binary.rs +++ b/bridge/src/race_engineer/piper_binary.rs @@ -84,15 +84,20 @@ fn extract_zip(zip_bytes: &[u8], dest: &Path) -> Result<()> { for i in 0..archive.len() { let mut file = archive.by_index(i)?; - let raw_name = file.name().to_owned(); + + // enclosed_name() rejects entries that would escape the target dir + // (absolute paths, `..` components) — zip-slip protection. + let Some(safe_path) = file.enclosed_name() else { + return Err(anyhow!("zip entry escapes target dir: {}", file.name())); + }; // Strip leading "piper/" prefix if present - let rel = raw_name - .strip_prefix("piper/") - .or_else(|| raw_name.strip_prefix("piper\\")) - .unwrap_or(&raw_name); + let rel = safe_path + .strip_prefix("piper") + .map(|p| p.to_path_buf()) + .unwrap_or(safe_path); - if rel.is_empty() || rel.ends_with('/') || rel.ends_with('\\') { + if rel.as_os_str().is_empty() || file.is_dir() { continue; } @@ -133,6 +138,12 @@ pub fn download_file( let mut data = Vec::new(); let mut buf = vec![0u8; CHUNK]; + // Throttle progress messages: every chunk (64 KiB) would mean thousands of + // WS broadcasts per voice download. One update per interval is plenty for a + // progress bar; a final message after the loop reports the exact total. + const PROGRESS_INTERVAL: Duration = Duration::from_millis(250); + let mut last_progress: Option = None; + loop { use std::io::Read; let n = reader.read(&mut buf).context("read response body")?; @@ -140,14 +151,24 @@ pub fn download_file( break; } data.extend_from_slice(&buf[..n]); - let _ = progress_tx.send(DownloadProgress { - bytes_downloaded: data.len() as u64, - bytes_total: content_length, - stage: "downloading".into(), - target: target.into(), - target_id: target_id.map(|s| s.to_string()), - }); + if last_progress.map_or(true, |t| t.elapsed() >= PROGRESS_INTERVAL) { + last_progress = Some(std::time::Instant::now()); + let _ = progress_tx.send(DownloadProgress { + bytes_downloaded: data.len() as u64, + bytes_total: content_length, + stage: "downloading".into(), + target: target.into(), + target_id: target_id.map(|s| s.to_string()), + }); + } } + let _ = progress_tx.send(DownloadProgress { + bytes_downloaded: data.len() as u64, + bytes_total: content_length, + stage: "downloading".into(), + target: target.into(), + target_id: target_id.map(|s| s.to_string()), + }); if data.is_empty() { return Err(anyhow!("empty response from {url}")); diff --git a/bridge/src/race_engineer/state.rs b/bridge/src/race_engineer/state.rs index 6e13f31..4c0c485 100644 --- a/bridge/src/race_engineer/state.rs +++ b/bridge/src/race_engineer/state.rs @@ -111,9 +111,12 @@ impl FlagState { const CAUTION_WAVING: u32 = 0x8000; // "Driver's black flags" — set in global SessionFlags for the local player only const REPAIR: u32 = 0x00100000; // meatball flag - // CarIdxSessionFlags bits (per-car) - const CAR_BLUE: u32 = 0x0400; - const CAR_DEBRIS: u32 = 0x0800; + // CarIdxSessionFlags carries the same irsdk_Flags bit layout as the + // global field (irsdk_defines.h: blue=0x0020, debris=0x0040). The + // previous values 0x0400/0x0800 were greenHeld/tenToGo and could + // never represent blue/debris. + const CAR_BLUE: u32 = 0x0020; + const CAR_DEBRIS: u32 = 0x0040; let any_yellow = (session_flags & (YELLOW | YELLOW_WAVING | CAUTION | CAUTION_WAVING)) != 0; let any_red = (session_flags & RED) != 0; diff --git a/bridge/src/race_engineer/tts_engine.rs b/bridge/src/race_engineer/tts_engine.rs index 6c85aca..d7061be 100644 --- a/bridge/src/race_engineer/tts_engine.rs +++ b/bridge/src/race_engineer/tts_engine.rs @@ -1,14 +1,14 @@ //! TTS synthesis via a local Piper subprocess. //! -//! One `TtsEngine` per engineer task. Keeps the piper process alive -//! between calls while the same voice is in use; kills it after 30 s of -//! idleness or on a voice change. - -use std::time::Instant; +//! One `TtsEngine` per engineer task. Each request spawns a fresh piper +//! process (text on stdin, EOF triggers synthesis, raw PCM on stdout). +//! The spawn + ONNX model load adds noticeable latency per callout; if that +//! ever matters, the fix is a persistent piper process with delimited +//! output — until then, per-request spawning keeps the lifecycle trivial. use anyhow::{anyhow, Context, Result}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::process::{Child, Command}; +use tokio::process::Command; use super::paths::{piper_exe, voice_config, voice_model}; use super::piper_binary::is_installed as piper_installed; @@ -21,11 +21,8 @@ fn read_sample_rate(voice_id: &str) -> Option { json["audio"]["sample_rate"].as_u64().map(|v| v as u32) } -pub struct TtsEngine { - current_voice: Option, - piper_process: Option, - last_used: Instant, -} +#[derive(Default)] +pub struct TtsEngine; pub struct SynthesisRequest { pub text: String, @@ -63,11 +60,7 @@ impl From for TtsError { impl TtsEngine { pub fn new() -> Self { - Self { - current_voice: None, - piper_process: None, - last_used: Instant::now(), - } + Self } pub async fn synthesize(&mut self, req: SynthesisRequest) -> Result { @@ -78,16 +71,7 @@ impl TtsEngine { return Err(TtsError::VoiceNotInstalled(req.voice_id.clone())); } - // Kill existing process if voice changed or idle >30s - let voice_changed = self.current_voice.as_deref() != Some(&req.voice_id); - let idle_too_long = self.last_used.elapsed().as_secs() > 30; - if voice_changed || idle_too_long { - self.shutdown(); - } - let pcm = self.run_synthesis(&req.voice_id, &req.text).await?; - self.current_voice = Some(req.voice_id.clone()); - self.last_used = Instant::now(); let sample_rate = read_sample_rate(&req.voice_id).unwrap_or(22050); let num_samples = pcm.len() / 2; @@ -166,11 +150,4 @@ impl TtsEngine { ); Ok(pcm) } - - pub fn shutdown(&mut self) { - if let Some(mut child) = self.piper_process.take() { - let _ = child.start_kill(); - } - self.current_voice = None; - } } diff --git a/bridge/src/telemetry/snapshot.rs b/bridge/src/telemetry/snapshot.rs index cffa223..e3a636b 100644 --- a/bridge/src/telemetry/snapshot.rs +++ b/bridge/src/telemetry/snapshot.rs @@ -51,8 +51,10 @@ pub struct TelemetrySnapshot { // Per-car session flags for the player (blue flag, drive-through penalty, etc.) pub player_car_session_flags: Option, - /// True when iRacing has issued a drive-through penalty for the player's car - /// (CarIdxSessionFlags bit 0x10000000 = irsdk_hasDriveThrough). + /// True when iRacing has issued a drive-through penalty for the player's car. + /// Bit 0x10000000 of CarIdxSessionFlags — note this is NOT part of the + /// public irsdk_defines.h enum (which lists 0x10000000 as irsdk_startHidden); + /// determined empirically via the SDK debug overlay. pub player_has_drivethrough_penalty: bool, // Tires — 4 corners × (carcass temp L/M/R, pressure, wear L/M/R) diff --git a/bridge/src/ws/handler.rs b/bridge/src/ws/handler.rs index 396d24b..b78cc65 100644 --- a/bridge/src/ws/handler.rs +++ b/bridge/src/ws/handler.rs @@ -21,7 +21,7 @@ use rust_embed::RustEmbed; use tokio::net::TcpListener; use tokio::sync::{broadcast, oneshot, watch}; -use crate::error::{BridgeError, Result}; +use crate::error::Result; use crate::iracing_sdk::types::SessionInfoYaml; use crate::race_engineer::{EngineerAudioTx, EngineerCmdTx}; use crate::telemetry::{SdkDebugSnapshot, StandingsSnapshot, TelemetrySnapshot, TrackMapSnapshot}; @@ -131,13 +131,13 @@ async fn handle_socket_inner(socket: WebSocket, state: BridgeState) -> Result<() let init_upd = upd_rx.borrow_and_update().clone(); if let Some(s) = init_tel { - send_msg(&mut sink, &ServerMessage::Telemetry { snapshot: s }).await?; + send_msg(&mut sink, &ServerMessage::Telemetry { snapshot: Box::new(s) }).await?; } if let Some(s) = init_std { send_msg(&mut sink, &ServerMessage::Standings { snapshot: s }).await?; } if let Some(i) = init_si { - send_msg(&mut sink, &ServerMessage::SessionInfo { info: i }).await?; + send_msg(&mut sink, &ServerMessage::SessionInfo { info: Box::new(i) }).await?; } if let Some(s) = init_tm { send_msg(&mut sink, &ServerMessage::TrackMap { snapshot: s }).await?; @@ -152,6 +152,17 @@ async fn handle_socket_inner(socket: WebSocket, state: BridgeState) -> Result<() // Request engineer status on connection let _ = state.engineer_cmd.send(ClientMessage::EngineerGetStatus); + // A closed channel only means its producer is gone (e.g. the update task + // after its one-shot check, or the SDK loop on non-Windows builds). That + // must not kill the connection — disable the branch and keep serving. + let mut tel_open = true; + let mut std_open = true; + let mut si_open = true; + let mut tm_open = true; + let mut dbg_open = true; + let mut upd_open = true; + let mut eng_open = true; + loop { tokio::select! { msg = stream.next() => { @@ -181,43 +192,43 @@ async fn handle_socket_inner(socket: WebSocket, state: BridgeState) -> Result<() Some(Ok(_)) => {} // ping/pong/binary/close } } - r = tel_rx.changed() => { - r.map_err(|_| BridgeError::WebSocket("telemetry channel closed".into()))?; + r = tel_rx.changed(), if tel_open => { + if r.is_err() { tel_open = false; continue; } let snap = tel_rx.borrow_and_update().clone(); if let Some(s) = snap { - send_msg(&mut sink, &ServerMessage::Telemetry { snapshot: s }).await?; + send_msg(&mut sink, &ServerMessage::Telemetry { snapshot: Box::new(s) }).await?; } } - r = std_rx.changed() => { - r.map_err(|_| BridgeError::WebSocket("standings channel closed".into()))?; + r = std_rx.changed(), if std_open => { + if r.is_err() { std_open = false; continue; } let snap = std_rx.borrow_and_update().clone(); if let Some(s) = snap { send_msg(&mut sink, &ServerMessage::Standings { snapshot: s }).await?; } } - r = si_rx.changed() => { - r.map_err(|_| BridgeError::WebSocket("session_info channel closed".into()))?; + r = si_rx.changed(), if si_open => { + if r.is_err() { si_open = false; continue; } let info = si_rx.borrow_and_update().clone(); if let Some(i) = info { - send_msg(&mut sink, &ServerMessage::SessionInfo { info: i }).await?; + send_msg(&mut sink, &ServerMessage::SessionInfo { info: Box::new(i) }).await?; } } - r = tm_rx.changed() => { - r.map_err(|_| BridgeError::WebSocket("track_map channel closed".into()))?; + r = tm_rx.changed(), if tm_open => { + if r.is_err() { tm_open = false; continue; } let snap = tm_rx.borrow_and_update().clone(); if let Some(s) = snap { send_msg(&mut sink, &ServerMessage::TrackMap { snapshot: s }).await?; } } - r = dbg_rx.changed() => { - r.map_err(|_| BridgeError::WebSocket("sdk_debug channel closed".into()))?; + r = dbg_rx.changed(), if dbg_open => { + if r.is_err() { dbg_open = false; continue; } let snap = dbg_rx.borrow_and_update().clone(); if let Some(s) = snap { send_msg(&mut sink, &ServerMessage::SdkDebug { snapshot: s }).await?; } } - r = upd_rx.changed() => { - r.map_err(|_| BridgeError::WebSocket("update channel closed".into()))?; + r = upd_rx.changed(), if upd_open => { + if r.is_err() { upd_open = false; continue; } let upd = upd_rx.borrow_and_update().clone(); if let Some(u) = upd { send_msg(&mut sink, &ServerMessage::UpdateAvailable { @@ -226,7 +237,7 @@ async fn handle_socket_inner(socket: WebSocket, state: BridgeState) -> Result<() }).await?; } } - r = eng_rx.recv() => { + r = eng_rx.recv(), if eng_open => { match r { Ok(msg) => { send_msg(&mut sink, &msg).await?; @@ -236,7 +247,7 @@ async fn handle_socket_inner(socket: WebSocket, state: BridgeState) -> Result<() // Non-fatal: skip lagged audio messages } Err(broadcast::error::RecvError::Closed) => { - return Err(BridgeError::WebSocket("engineer channel closed".into())); + eng_open = false; } } } @@ -250,20 +261,29 @@ async fn send_msg(sink: &mut WsSink, msg: &ServerMessage) -> Result<()> { Ok(()) } +/// Converts embedded asset data to a response body without copying: +/// release builds embed assets as `Cow::Borrowed(&'static [u8])`. +fn asset_body(data: std::borrow::Cow<'static, [u8]>) -> axum::body::Bytes { + match data { + std::borrow::Cow::Borrowed(b) => axum::body::Bytes::from_static(b), + std::borrow::Cow::Owned(v) => axum::body::Bytes::from(v), + } +} + async fn static_handler(uri: Uri) -> Response { let path = uri.path().trim_start_matches('/'); let path = if path.is_empty() { "index.html" } else { path }; if let Some(file) = DashboardAssets::get(path) { let mime = file.metadata.mimetype().to_owned(); - return ([(header::CONTENT_TYPE, mime)], file.data.into_owned()).into_response(); + return ([(header::CONTENT_TYPE, mime)], asset_body(file.data)).into_response(); } // SPA fallback: any unknown path serves index.html if let Some(file) = DashboardAssets::get("index.html") { return ( [(header::CONTENT_TYPE, "text/html; charset=utf-8")], - file.data.into_owned(), + asset_body(file.data), ) .into_response(); } diff --git a/bridge/src/ws/protocol.rs b/bridge/src/ws/protocol.rs index b4281f2..3fad291 100644 --- a/bridge/src/ws/protocol.rs +++ b/bridge/src/ws/protocol.rs @@ -24,14 +24,17 @@ pub enum ServerMessage { #[serde(rename_all = "camelCase")] SdkDebug { snapshot: SdkDebugSnapshot }, + // Telemetry/SessionInfo are boxed: their inline size dominates the enum + // (≈720 B) and every send would copy it by value. serde and ts-rs treat + // Box transparently, so the wire format is unchanged. #[serde(rename_all = "camelCase")] - Telemetry { snapshot: TelemetrySnapshot }, + Telemetry { snapshot: Box }, #[serde(rename_all = "camelCase")] Standings { snapshot: StandingsSnapshot }, #[serde(rename_all = "camelCase")] - SessionInfo { info: SessionInfoYaml }, + SessionInfo { info: Box }, #[serde(rename_all = "camelCase")] TrackMap { snapshot: TrackMapSnapshot }, From 07aaa5ad48a02c157f69ae45d7436522735d8450 Mon Sep 17 00:00:00 2001 From: Swizzjack Date: Thu, 11 Jun 2026 19:10:01 +0000 Subject: [PATCH 02/10] refactor(bridge): remove debug leftovers, dead modules, and per-frame allocations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - standings: drop the incident-source diagnostic block (built ~64 format! strings per 4 Hz tick guarded by two global mutexes) - snapshot: tire var names as literal arrays instead of per-frame format!() — the 60 Hz build path is now allocation-free per lookup - delete events.rs (todo!() stub, never called) and broadcast.rs (placeholder enum), drop unused Config::{telemetry_hz,standings_hz} - scope #![allow(dead_code)] to non-Windows so the shipped Windows build keeps real dead-code analysis - dedupe track_key derivation (track_recorder + track_map) - clippy: is_some_and/sort_by_key/needless-ref fixes, ?-operator Co-Authored-By: Claude Fable 5 --- bridge/src/config.rs | 4 -- bridge/src/iracing_sdk/broadcast.rs | 16 ----- bridge/src/iracing_sdk/events.rs | 19 ----- bridge/src/iracing_sdk/mod.rs | 9 +-- bridge/src/iracing_sdk/yaml.rs | 2 +- bridge/src/main.rs | 5 +- bridge/src/race_engineer/rules/dispatcher.rs | 2 +- bridge/src/race_engineer/rules/pace_rules.rs | 4 +- bridge/src/race_engineer/rules/pit_rules.rs | 2 +- bridge/src/race_engineer/rules/tire_rules.rs | 2 +- bridge/src/race_engineer/state.rs | 8 +-- bridge/src/telemetry/snapshot.rs | 73 +++++++++----------- bridge/src/telemetry/standings.rs | 41 ----------- bridge/src/telemetry/track_map.rs | 8 +-- bridge/src/telemetry/track_recorder.rs | 18 +++-- 15 files changed, 61 insertions(+), 152 deletions(-) delete mode 100644 bridge/src/iracing_sdk/broadcast.rs delete mode 100644 bridge/src/iracing_sdk/events.rs diff --git a/bridge/src/config.rs b/bridge/src/config.rs index 866910e..069e796 100644 --- a/bridge/src/config.rs +++ b/bridge/src/config.rs @@ -3,8 +3,6 @@ #[derive(Debug, Clone)] pub struct Config { pub ws_port: u16, - pub telemetry_hz: u32, - pub standings_hz: u32, /// Disables auto-shutdown (BRIDGE_KEEP_ALIVE=1). pub keep_alive: bool, /// Seconds without a client after the first connect before shutdown (BRIDGE_SHUTDOWN_GRACE_SEC). @@ -20,8 +18,6 @@ impl Config { .ok() .and_then(|s| s.parse().ok()) .unwrap_or(8765), - telemetry_hz: 60, - standings_hz: 4, keep_alive: std::env::var("BRIDGE_KEEP_ALIVE") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false), diff --git a/bridge/src/iracing_sdk/broadcast.rs b/bridge/src/iracing_sdk/broadcast.rs deleted file mode 100644 index b84da50..0000000 --- a/bridge/src/iracing_sdk/broadcast.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! Broadcast-Messages: Pit, Camera, Replay Commands. -//! -//! Implementation deferred. Wenn Phase-2 kommt: -//! - RegisterWindowMessageW(r"IRSDK_BROADCASTMSG") → message id -//! - PostMessageW(HWND_BROADCAST, msg_id, wParam, lParam) -//! wParam = (BroadcastType & 0xFFFF) | ((var1 as u32) << 16) -//! lParam = (var2 as u32) | ((var3 as u32) << 16) - -#[derive(Debug, Clone, Copy)] -#[repr(i32)] -pub enum BroadcastMsg { - CamSwitchPos = 0, - CamSwitchNum = 1, - PitCommand = 9, - ReplaySearchSessionTime = 12, -} diff --git a/bridge/src/iracing_sdk/events.rs b/bridge/src/iracing_sdk/events.rs deleted file mode 100644 index e1a47ea..0000000 --- a/bridge/src/iracing_sdk/events.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! Windows event primitives for DataValidEvent. -//! -//! Wraps `WaitForSingleObject` + timeout semantics so that `reader.rs` -//! has a platform-agnostic signature. - -use crate::error::Result; - -#[cfg(windows)] -pub fn wait_signaled(_event_handle: isize, _timeout_ms: u32) -> Result { - todo!("WaitForSingleObject, map WAIT_OBJECT_0 / WAIT_TIMEOUT / WAIT_FAILED") -} - -#[cfg(not(windows))] -pub fn wait_signaled(_event_handle: isize, _timeout_ms: u32) -> Result { - use crate::error::BridgeError; - Err(BridgeError::SdkNotConnected( - "Windows events unavailable on this platform".into(), - )) -} diff --git a/bridge/src/iracing_sdk/mod.rs b/bridge/src/iracing_sdk/mod.rs index ed67f46..431217f 100644 --- a/bridge/src/iracing_sdk/mod.rs +++ b/bridge/src/iracing_sdk/mod.rs @@ -3,14 +3,10 @@ //! Architecture: //! - `header`: irsdk_header layout and parser //! - `var_header`: variable-descriptor array, indexing -//! - `reader`: open the MMF, triple-buffer read loop -//! - `events`: WaitForSingleObject on Local\IRSDKDataValidEvent +//! - `reader`: open the MMF, event wait, triple-buffer read loop //! - `yaml`: ISO-8859-1 decode + serde_yaml parse //! - `types`: SessionInfo, DriverInfo, ResultsPositions (ts-rs derive) -//! - `broadcast`: PostMessageW wrapper for Pit/Camera/Replay (later) -pub mod broadcast; -pub mod events; pub mod header; pub mod reader; pub mod synthetic_id; @@ -18,7 +14,4 @@ pub mod types; pub mod var_header; pub mod yaml; -// Re-export for the public API. `allow(unused_imports)` because -// main.rs only uses the client once the implementation is in place. -#[allow(unused_imports)] pub use reader::IRacingClient; diff --git a/bridge/src/iracing_sdk/yaml.rs b/bridge/src/iracing_sdk/yaml.rs index 89f67a2..c9baeda 100644 --- a/bridge/src/iracing_sdk/yaml.rs +++ b/bridge/src/iracing_sdk/yaml.rs @@ -26,7 +26,7 @@ fn keep_sections(yaml: &str, keep: &[&str]) -> String { let is_top_level = matches!(first, Some(c) if !c.is_whitespace() && c != '#' && c != '-' && c != '.'); if is_top_level { let section_name = line.split(':').next().unwrap_or("").trim(); - in_section = keep.iter().any(|k| *k == section_name); + in_section = keep.contains(§ion_name); } if in_section { out.push_str(line); diff --git a/bridge/src/main.rs b/bridge/src/main.rs index abfd902..4d1a705 100644 --- a/bridge/src/main.rs +++ b/bridge/src/main.rs @@ -3,7 +3,10 @@ //! Tokio runtime, HTTP+WebSocket server, iRacing SDK reader. #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] -#![allow(dead_code)] +// On non-Windows hosts the whole SDK consumer chain hangs off the +// cfg(windows) sdk_loop, so dead-code analysis would flag it all. +// Windows builds (the shipped artifact) still get real dead-code checks. +#![cfg_attr(not(windows), allow(dead_code))] mod config; mod error; diff --git a/bridge/src/race_engineer/rules/dispatcher.rs b/bridge/src/race_engineer/rules/dispatcher.rs index 60ff8c2..58845af 100644 --- a/bridge/src/race_engineer/rules/dispatcher.rs +++ b/bridge/src/race_engineer/rules/dispatcher.rs @@ -144,7 +144,7 @@ impl RuleDispatcher { let mut events = events.unwrap_or_default(); // Critical first, then High, then Info; stable within tier (FIFO) - events.sort_by(|a, b| b.priority.cmp(&a.priority)); + events.sort_by_key(|e| std::cmp::Reverse(e.priority)); events } diff --git a/bridge/src/race_engineer/rules/pace_rules.rs b/bridge/src/race_engineer/rules/pace_rules.rs index 24ca731..40b5678 100644 --- a/bridge/src/race_engineer/rules/pace_rules.rs +++ b/bridge/src/race_engineer/rules/pace_rules.rs @@ -112,9 +112,7 @@ impl Rule for SectorDeltaRule { fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::HIGH } fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { - if prev.is_none() { - return None; - } + prev?; // Find the first sector with a significant delta (>0.3 s) for (i, &delta) in current.last_sector_deltas.iter().enumerate() { if let Some(d) = delta { diff --git a/bridge/src/race_engineer/rules/pit_rules.rs b/bridge/src/race_engineer/rules/pit_rules.rs index 8ddf37d..c656e71 100644 --- a/bridge/src/race_engineer/rules/pit_rules.rs +++ b/bridge/src/race_engineer/rules/pit_rules.rs @@ -106,7 +106,7 @@ impl Rule for PitlaneExitBriefingRule { let tyre_status = if avg_temp > 0.1 && avg_temp < 60.0 { "cold" - } else if avg_temp >= 60.0 && avg_temp < 80.0 { + } else if (60.0..80.0).contains(&avg_temp) { "warming" } else { "ready" diff --git a/bridge/src/race_engineer/rules/tire_rules.rs b/bridge/src/race_engineer/rules/tire_rules.rs index 4b5ecf6..5ff0f81 100644 --- a/bridge/src/race_engineer/rules/tire_rules.rs +++ b/bridge/src/race_engineer/rules/tire_rules.rs @@ -77,7 +77,7 @@ impl Rule for TireTempsInRangeRule { fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let prev = prev?; let now_ok = all_in_range(current); - let was_out = any_out_of_range(&prev); + let was_out = any_out_of_range(prev); if now_ok && was_out { Some(RuleEvent { rule_id: self.id(), diff --git a/bridge/src/race_engineer/state.rs b/bridge/src/race_engineer/state.rs index 4c0c485..4c38549 100644 --- a/bridge/src/race_engineer/state.rs +++ b/bridge/src/race_engineer/state.rs @@ -121,8 +121,8 @@ impl FlagState { let any_yellow = (session_flags & (YELLOW | YELLOW_WAVING | CAUTION | CAUTION_WAVING)) != 0; let any_red = (session_flags & RED) != 0; let any_debris = (session_flags & DEBRIS) != 0 - || player_car_flags.map_or(false, |f| (f & CAR_DEBRIS) != 0); - let blue = player_car_flags.map_or(false, |f| (f & CAR_BLUE) != 0); + || player_car_flags.is_some_and(|f| (f & CAR_DEBRIS) != 0); + let blue = player_car_flags.is_some_and(|f| (f & CAR_BLUE) != 0); let meatball = (session_flags & REPAIR) != 0; FlagState { @@ -299,7 +299,7 @@ impl StateAggregator { let time_remaining = tel .session_time_remain .filter(|&t| t > 0.0 && t < 604800.0) // < 1 week - .map(|t| Duration::from_secs_f64(t)); + .map(Duration::from_secs_f64); // --- Player --- let player_position = tel.player_position.max(0) as u32; @@ -369,7 +369,7 @@ impl StateAggregator { }) }) .filter(|&t| t < f32::MAX && t > 0.0) - .map(|t| Duration::from_secs_f32(t)); + .map(Duration::from_secs_f32); // Sector deltas from last 3 sectors vs personal best let sector_deltas = [None, None, None]; // populated from sector_tracker externally if needed diff --git a/bridge/src/telemetry/snapshot.rs b/bridge/src/telemetry/snapshot.rs index e3a636b..e01d348 100644 --- a/bridge/src/telemetry/snapshot.rs +++ b/bridge/src/telemetry/snapshot.rs @@ -199,30 +199,31 @@ impl TelemetrySnapshot { player_car_my_incident_count: client.get_i32("PlayerCarMyIncidentCount").ok(), player_car_session_flags, player_has_drivethrough_penalty: player_car_session_flags - .map_or(false, |f| (f & 0x10000000) != 0), - - // Tires - tire_temp_lf: corner_temps(client, "LF")?, - tire_temp_rf: corner_temps(client, "RF")?, - tire_temp_lr: corner_temps(client, "LR")?, - tire_temp_rr: corner_temps(client, "RR")?, + .is_some_and(|f| (f & 0x10000000) != 0), + + // Tires — var names spelled out as literals: this runs at 60 Hz, + // so no per-frame format!() allocations, and the names stay greppable. + tire_temp_lf: read3(client, ["LFtempCL", "LFtempCM", "LFtempCR"])?, + tire_temp_rf: read3(client, ["RFtempCL", "RFtempCM", "RFtempCR"])?, + tire_temp_lr: read3(client, ["LRtempCL", "LRtempCM", "LRtempCR"])?, + tire_temp_rr: read3(client, ["RRtempCL", "RRtempCM", "RRtempCR"])?, tire_cold_pressure: [ client.get_f32("LFcoldPressure")?, client.get_f32("RFcoldPressure")?, client.get_f32("LRcoldPressure")?, client.get_f32("RRcoldPressure")?, ], - tire_wear_lf: corner_wear(client, "LF")?, - tire_wear_rf: corner_wear(client, "RF")?, - tire_wear_lr: corner_wear(client, "LR")?, - tire_wear_rr: corner_wear(client, "RR")?, - tire_pressure: four_corners_opt(client, "pressure"), - tire_temp_surface_lf: corner_surface_temps_opt(client, "LF"), - tire_temp_surface_rf: corner_surface_temps_opt(client, "RF"), - tire_temp_surface_lr: corner_surface_temps_opt(client, "LR"), - tire_temp_surface_rr: corner_surface_temps_opt(client, "RR"), - tire_speed: four_corners_opt(client, "speed"), - tire_ride_height: four_corners_opt(client, "rideHeight"), + tire_wear_lf: read3(client, ["LFwearL", "LFwearM", "LFwearR"])?, + tire_wear_rf: read3(client, ["RFwearL", "RFwearM", "RFwearR"])?, + tire_wear_lr: read3(client, ["LRwearL", "LRwearM", "LRwearR"])?, + tire_wear_rr: read3(client, ["RRwearL", "RRwearM", "RRwearR"])?, + tire_pressure: read4_opt(client, ["LFpressure", "RFpressure", "LRpressure", "RRpressure"]), + tire_temp_surface_lf: read3_opt(client, ["LFtempL", "LFtempM", "LFtempR"]), + tire_temp_surface_rf: read3_opt(client, ["RFtempL", "RFtempM", "RFtempR"]), + tire_temp_surface_lr: read3_opt(client, ["LRtempL", "LRtempM", "LRtempR"]), + tire_temp_surface_rr: read3_opt(client, ["RRtempL", "RRtempM", "RRtempR"]), + tire_speed: read4_opt(client, ["LFspeed", "RFspeed", "LRspeed", "RRspeed"]), + tire_ride_height: read4_opt(client, ["LFrideHeight", "RFrideHeight", "LRrideHeight", "RRrideHeight"]), // Engine water_temp: client.get_f32("WaterTemp")?, @@ -298,35 +299,29 @@ impl TelemetrySnapshot { } } -fn corner_temps(client: &IRacingClient, prefix: &str) -> Result<[f32; 3]> { - Ok([ - client.get_f32(&format!("{prefix}tempCL"))?, - client.get_f32(&format!("{prefix}tempCM"))?, - client.get_f32(&format!("{prefix}tempCR"))?, - ]) -} - -fn corner_wear(client: &IRacingClient, prefix: &str) -> Result<[f32; 3]> { +fn read3(client: &IRacingClient, names: [&str; 3]) -> Result<[f32; 3]> { Ok([ - client.get_f32(&format!("{prefix}wearL"))?, - client.get_f32(&format!("{prefix}wearM"))?, - client.get_f32(&format!("{prefix}wearR"))?, + client.get_f32(names[0])?, + client.get_f32(names[1])?, + client.get_f32(names[2])?, ]) } -fn corner_surface_temps_opt(client: &IRacingClient, prefix: &str) -> Option<[f32; 3]> { +/// `None` if any of the vars is missing (car class doesn't expose them). +fn read3_opt(client: &IRacingClient, names: [&str; 3]) -> Option<[f32; 3]> { Some([ - client.get_f32(&format!("{prefix}tempL")).ok()?, - client.get_f32(&format!("{prefix}tempM")).ok()?, - client.get_f32(&format!("{prefix}tempR")).ok()?, + client.get_f32(names[0]).ok()?, + client.get_f32(names[1]).ok()?, + client.get_f32(names[2]).ok()?, ]) } -fn four_corners_opt(client: &IRacingClient, suffix: &str) -> Option<[f32; 4]> { +/// `None` if any of the vars is missing (car class doesn't expose them). +fn read4_opt(client: &IRacingClient, names: [&str; 4]) -> Option<[f32; 4]> { Some([ - client.get_f32(&format!("LF{suffix}")).ok()?, - client.get_f32(&format!("RF{suffix}")).ok()?, - client.get_f32(&format!("LR{suffix}")).ok()?, - client.get_f32(&format!("RR{suffix}")).ok()?, + client.get_f32(names[0]).ok()?, + client.get_f32(names[1]).ok()?, + client.get_f32(names[2]).ok()?, + client.get_f32(names[3]).ok()?, ]) } diff --git a/bridge/src/telemetry/standings.rs b/bridge/src/telemetry/standings.rs index ad86883..1c3d6ff 100644 --- a/bridge/src/telemetry/standings.rs +++ b/bridge/src/telemetry/standings.rs @@ -9,12 +9,8 @@ use crate::telemetry::pit_tracker::PitTracker; use crate::telemetry::sector_tracker::SectorTracker; use serde::Serialize; use std::collections::HashMap; -use std::sync::Mutex; use ts_rs::TS; -static LAST_INC_SRC: Mutex> = Mutex::new(None); -static LAST_INC_RAW: Mutex> = Mutex::new(None); - #[derive(Debug, Clone, Serialize, TS)] #[ts(export, export_to = "../shared/")] #[serde(rename_all = "camelCase")] @@ -109,8 +105,6 @@ impl StandingsSnapshot { let f2_times = client.get_f32_array("CarIdxF2Time").ok(); // Tire compound index per car; absent in some builds/sessions. let tire_compounds = client.get_i32_array("CarIdxTireCompound").ok(); - // Kept for diagnostic logging only — not used for display. - let team_inc = client.get_i32_array("CarIdxTeamIncidentCount").ok(); // P2P: CarIdxP2P_Count is declared as Int but actually carries raw Float32 // bits (× 10 = seconds remaining) — see project_p2p_encoding memory. // EXCEPT for the player's own slot, which iRacing populates with a plain @@ -288,41 +282,6 @@ impl StandingsSnapshot { }) .collect(); - { - let src_sig = format!( - "CarIdxTeamIncidentCount={} results_map_len={} results_positions_present={}", - team_inc.as_ref().map(|a| format!("Some(len={})", a.len())).unwrap_or_else(|| "None".into()), - results_map.len(), - current_session.and_then(|s| s.results_positions.as_ref()).is_some(), - ); - let mut last = LAST_INC_SRC.lock().unwrap(); - if last.as_deref() != Some(&src_sig) { - log::info!("standings inc sources: {}", src_sig); - *last = Some(src_sig); - } - - let ego_idx = yaml.driver_info.driver_car_idx; - let raw_sig: String = entries - .iter() - .map(|e| { - let idx = e.car_idx as usize; - let live_inc = team_inc.as_ref().and_then(|a| a.get(idx).copied()).unwrap_or(0); - let res_inc = results_map.get(&e.car_idx).map(|r| r.incidents).unwrap_or(0); - let driver_entry = yaml.driver_info.drivers.iter().find(|d| d.car_idx == e.car_idx); - let cur = driver_entry.map(|d| d.cur_driver_incident_count).unwrap_or(0); - let team_cnt = driver_entry.map(|d| d.team_incident_count).unwrap_or(0); - let ego_marker = if e.car_idx == ego_idx { "*" } else { "" }; - format!("{}{}(live={} res={} cur={} team={})", ego_marker, e.car_idx, live_inc, res_inc, cur, team_cnt) - }) - .collect::>() - .join(" "); - let mut last_raw = LAST_INC_RAW.lock().unwrap(); - if last_raw.as_deref() != Some(&raw_sig) { - log::info!("standings inc raw: {}", raw_sig); - *last_raw = Some(raw_sig); - } - } - entries.sort_unstable_by(|a, b| { let a_unclass = a.position == 0; let b_unclass = b.position == 0; diff --git a/bridge/src/telemetry/track_map.rs b/bridge/src/telemetry/track_map.rs index 5e4bfd8..93b8a88 100644 --- a/bridge/src/telemetry/track_map.rs +++ b/bridge/src/telemetry/track_map.rs @@ -43,13 +43,7 @@ impl TrackMapSnapshot { recorder: &TrackRecorder, player_car_idx: i32, ) -> Result { - let wi = &yaml.weekend_info; - let config = wi.track_config_name.trim(); - let track_key = if config.is_empty() { - wi.track_name.clone() - } else { - format!("{}_{}", wi.track_name, config.replace(' ', "_")) - }; + let track_key = crate::telemetry::track_recorder::track_key(&yaml.weekend_info); let lap_dist_pcts = client.get_f32_array("CarIdxLapDistPct")?; let class_positions = client.get_i32_array("CarIdxClassPosition")?; diff --git a/bridge/src/telemetry/track_recorder.rs b/bridge/src/telemetry/track_recorder.rs index bf5b8e1..801a761 100644 --- a/bridge/src/telemetry/track_recorder.rs +++ b/bridge/src/telemetry/track_recorder.rs @@ -184,6 +184,17 @@ fn interpolate(samples: &[(f32, f64, f64, f64)], target_p: f32) -> (f64, f64, f6 // ─── Public Recorder ───────────────────────────────────────────────────────── +/// Stable identifier for a track+config combination, shared by the recorder's +/// disk cache and the TrackMapSnapshot sent to the dashboard. +pub fn track_key(wi: &crate::iracing_sdk::types::WeekendInfo) -> String { + let config = wi.track_config_name.trim(); + if config.is_empty() { + wi.track_name.clone() + } else { + format!("{}_{}", wi.track_name, config.replace(' ', "_")) + } +} + pub struct TrackRecorder { cache_dir: PathBuf, current_key: Option, @@ -210,12 +221,7 @@ impl TrackRecorder { /// Called every 60-Hz frame. Manages track-change detection, recording, and finalization. pub fn update(&mut self, client: &IRacingClient, yaml: &SessionInfoYaml) -> Result<()> { let wi = &yaml.weekend_info; - let config = wi.track_config_name.trim(); - let track_key = if config.is_empty() { - wi.track_name.clone() - } else { - format!("{}_{}", wi.track_name, config.replace(' ', "_")) - }; + let track_key = track_key(wi); // Track changed → reset and try to load from disk. if self.current_key.as_deref() != Some(&track_key) { From fc148ec9cad792dba3c2f9b5276a83ee86976975 Mon Sep 17 00:00:00 2001 From: Swizzjack Date: Thu, 11 Jun 2026 19:20:37 +0000 Subject: [PATCH 03/10] refactor(bridge): single source of truth for sector logic, simpler rule trait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sector_tracker: extract step_car() — update() and the unit tests now drive the identical production code path (the test helper used to mirror ~80 lines of logic that could silently diverge) - Rule trait: evaluate(&mut self) — rules are owned exclusively by the dispatcher inside one task, so the AtomicU32/AtomicBool interior- mutability workaround in the temp-change rules becomes plain fields; Sync bound dropped - engineer state: wire last_sector_deltas from the standings sector data (current lap vs personal best) — SectorDeltaRule could never fire before because the array was hardcoded to [None; 3] Co-Authored-By: Claude Fable 5 --- .../src/race_engineer/rules/damage_rules.rs | 2 +- bridge/src/race_engineer/rules/dispatcher.rs | 2 +- bridge/src/race_engineer/rules/flag_rules.rs | 12 +- bridge/src/race_engineer/rules/fuel_rules.rs | 4 +- bridge/src/race_engineer/rules/mod.rs | 8 +- bridge/src/race_engineer/rules/pace_rules.rs | 22 +- bridge/src/race_engineer/rules/pit_rules.rs | 12 +- .../src/race_engineer/rules/position_rules.rs | 12 +- .../src/race_engineer/rules/session_rules.rs | 6 +- bridge/src/race_engineer/rules/tire_rules.rs | 10 +- .../src/race_engineer/rules/weather_rules.rs | 60 ++- bridge/src/race_engineer/state.rs | 31 +- bridge/src/telemetry/sector_tracker.rs | 342 ++++++++---------- 13 files changed, 258 insertions(+), 265 deletions(-) diff --git a/bridge/src/race_engineer/rules/damage_rules.rs b/bridge/src/race_engineer/rules/damage_rules.rs index 4aa99c5..c2a53e9 100644 --- a/bridge/src/race_engineer/rules/damage_rules.rs +++ b/bridge/src/race_engineer/rules/damage_rules.rs @@ -18,7 +18,7 @@ impl Rule for DamageReportedRule { fn session_mask(&self) -> SessionMask { SessionMask::ALL } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::ALL } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let prev = prev?; let new_aero = current.damage.has_aero && !prev.damage.has_aero; let new_susp = current.damage.has_suspension && !prev.damage.has_suspension; diff --git a/bridge/src/race_engineer/rules/dispatcher.rs b/bridge/src/race_engineer/rules/dispatcher.rs index 58845af..373eb85 100644 --- a/bridge/src/race_engineer/rules/dispatcher.rs +++ b/bridge/src/race_engineer/rules/dispatcher.rs @@ -95,7 +95,7 @@ impl RuleDispatcher { let now = Instant::now(); let mut events: Option> = None; - for rule in &self.rules { + for rule in &mut self.rules { // Session filter if !rule.session_mask().intersects(effective_session_mask) { continue; diff --git a/bridge/src/race_engineer/rules/flag_rules.rs b/bridge/src/race_engineer/rules/flag_rules.rs index 5619d6f..e4bd3f1 100644 --- a/bridge/src/race_engineer/rules/flag_rules.rs +++ b/bridge/src/race_engineer/rules/flag_rules.rs @@ -19,7 +19,7 @@ impl Rule for RedFlagRule { fn session_mask(&self) -> SessionMask { SessionMask::ALL } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::ALL } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { // Red flag is stored in FlagState.red (derived from session_flags bit 0x10) // Also fire on SessionPhase::RedFlag (same source, belt-and-suspenders) let is_red = current.active_flags.red @@ -48,7 +48,7 @@ impl Rule for YellowFlagOwnSectorRule { fn session_mask(&self) -> SessionMask { SessionMask::ALL } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::ALL } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let now_under = current.active_flags.player_in_yellow_sector; let prev_under = prev.map(|p| p.active_flags.player_in_yellow_sector).unwrap_or(false); if now_under && !prev_under { @@ -72,7 +72,7 @@ impl Rule for BlueFlagRule { fn session_mask(&self) -> SessionMask { SessionMask::ALL } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::MEDIUM_AND_UP } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let now_blue = current.active_flags.blue; let prev_blue = prev.map(|p| p.active_flags.blue).unwrap_or(false); if now_blue && !prev_blue { @@ -95,7 +95,7 @@ impl Rule for DebrisFlagRule { fn session_mask(&self) -> SessionMask { SessionMask::ALL } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::MEDIUM_AND_UP } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let now_debris = current.active_flags.debris; let was_debris = prev.map(|p| p.active_flags.debris).unwrap_or(false); if now_debris && !was_debris { @@ -118,7 +118,7 @@ impl Rule for MeatballFlagRule { fn session_mask(&self) -> SessionMask { SessionMask::ALL } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::ALL } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let now_meatball = current.active_flags.meatball; let was_meatball = prev.map(|p| p.active_flags.meatball).unwrap_or(false); if now_meatball && !was_meatball { @@ -141,7 +141,7 @@ impl Rule for GreenFlagRule { fn session_mask(&self) -> SessionMask { SessionMask::ALL } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::MEDIUM_AND_UP } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let prev = prev?; let prev_had_yellow = prev.active_flags.player_in_yellow_sector || prev.active_flags.red; diff --git a/bridge/src/race_engineer/rules/fuel_rules.rs b/bridge/src/race_engineer/rules/fuel_rules.rs index dcaa71a..c572ce1 100644 --- a/bridge/src/race_engineer/rules/fuel_rules.rs +++ b/bridge/src/race_engineer/rules/fuel_rules.rs @@ -17,7 +17,7 @@ impl Rule for FuelCriticalRule { fn session_mask(&self) -> SessionMask { SessionMask::RACE } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::ALL } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let fl = current.fuel_laps_left; if fl <= 0.0 { return None; // no valid fuel data @@ -47,7 +47,7 @@ impl Rule for FuelLowRule { fn session_mask(&self) -> SessionMask { SessionMask::RACE } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::ALL } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let fl = current.fuel_laps_left; if fl <= 0.0 || current.total_laps_driven < 1 { return None; diff --git a/bridge/src/race_engineer/rules/mod.rs b/bridge/src/race_engineer/rules/mod.rs index 416d556..5863db9 100644 --- a/bridge/src/race_engineer/rules/mod.rs +++ b/bridge/src/race_engineer/rules/mod.rs @@ -140,7 +140,11 @@ pub struct RuleEvent { // --------------------------------------------------------------------------- /// A single firing rule evaluated per 10 Hz tick. -pub trait Rule: Send + Sync { +/// +/// `Send` because the dispatcher lives inside a Tokio task; rules are owned +/// exclusively by the dispatcher, so stateful rules keep plain fields and +/// take `&mut self` — no interior mutability needed. +pub trait Rule: Send { fn id(&self) -> &'static str; fn priority(&self) -> Priority; fn cooldown(&self) -> Duration; @@ -150,7 +154,7 @@ pub trait Rule: Send + Sync { /// Called per tick when all gate conditions pass. /// Returns `Some(RuleEvent)` if the rule fires, `None` otherwise. fn evaluate( - &self, + &mut self, current: &EngineerState, previous: Option<&EngineerState>, ) -> Option; diff --git a/bridge/src/race_engineer/rules/pace_rules.rs b/bridge/src/race_engineer/rules/pace_rules.rs index 40b5678..9402192 100644 --- a/bridge/src/race_engineer/rules/pace_rules.rs +++ b/bridge/src/race_engineer/rules/pace_rules.rs @@ -39,7 +39,7 @@ impl Rule for PersonalBestRule { fn session_mask(&self) -> SessionMask { SessionMask::PRACTICE | SessionMask::QUALIFYING | SessionMask::RACE } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::MEDIUM_AND_UP } - fn evaluate(&self, current: &EngineerState, _prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, _prev: Option<&EngineerState>) -> Option { if !current.personal_best_this_lap { return None; } @@ -63,7 +63,7 @@ impl Rule for PaceDroppingRule { fn session_mask(&self) -> SessionMask { SessionMask::RACE } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::HIGH } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { if !lap_just_completed(current, prev) { return None; } @@ -111,7 +111,7 @@ impl Rule for SectorDeltaRule { fn session_mask(&self) -> SessionMask { SessionMask::QUALIFYING } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::HIGH } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { prev?; // Find the first sector with a significant delta (>0.3 s) for (i, &delta) in current.last_sector_deltas.iter().enumerate() { @@ -139,7 +139,7 @@ impl Rule for SessionBestOvertakenRule { fn session_mask(&self) -> SessionMask { SessionMask::RACE | SessionMask::QUALIFYING } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::HIGH } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { if !lap_just_completed(current, prev) { return None; } @@ -169,7 +169,7 @@ impl Rule for ClassBestLapRule { fn session_mask(&self) -> SessionMask { SessionMask::RACE | SessionMask::QUALIFYING } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::MEDIUM_AND_UP } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { if !lap_just_completed(current, prev) { return None; } @@ -226,7 +226,7 @@ impl Rule for ClassAheadSlowerRule { fn session_mask(&self) -> SessionMask { SessionMask::PRACTICE | SessionMask::QUALIFYING | SessionMask::RACE } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::HIGH } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { if !lap_just_completed(current, prev) { return None; } class_pace_event( self.id(), @@ -245,7 +245,7 @@ impl Rule for ClassAheadFasterRule { fn session_mask(&self) -> SessionMask { SessionMask::PRACTICE | SessionMask::QUALIFYING | SessionMask::RACE } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::HIGH } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { if !lap_just_completed(current, prev) { return None; } class_pace_event( self.id(), @@ -264,7 +264,7 @@ impl Rule for ClassBehindFasterRule { fn session_mask(&self) -> SessionMask { SessionMask::PRACTICE | SessionMask::QUALIFYING | SessionMask::RACE } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::HIGH } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { if !lap_just_completed(current, prev) { return None; } class_pace_event( self.id(), @@ -283,7 +283,7 @@ impl Rule for ClassBehindSlowerRule { fn session_mask(&self) -> SessionMask { SessionMask::PRACTICE | SessionMask::QUALIFYING | SessionMask::RACE } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::HIGH } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { if !lap_just_completed(current, prev) { return None; } class_pace_event( self.id(), @@ -302,7 +302,7 @@ impl Rule for ClassPaceBriefRule { fn session_mask(&self) -> SessionMask { SessionMask::PRACTICE | SessionMask::QUALIFYING | SessionMask::RACE } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::MEDIUM_AND_UP } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { if !lap_just_completed(current, prev) { return None; } @@ -337,7 +337,7 @@ impl Rule for SessionBestPaceRule { fn session_mask(&self) -> SessionMask { SessionMask::PRACTICE | SessionMask::QUALIFYING | SessionMask::RACE } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::HIGH } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { if !lap_just_completed(current, prev) { return None; } diff --git a/bridge/src/race_engineer/rules/pit_rules.rs b/bridge/src/race_engineer/rules/pit_rules.rs index c656e71..f6db001 100644 --- a/bridge/src/race_engineer/rules/pit_rules.rs +++ b/bridge/src/race_engineer/rules/pit_rules.rs @@ -27,7 +27,7 @@ impl Rule for ConsiderPitRule { fn session_mask(&self) -> SessionMask { SessionMask::RACE } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::MEDIUM_AND_UP } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let fl = current.fuel_laps_left; if fl <= 0.0 || fl < 1.5 { return None; // FuelCritical handles the critical end @@ -60,7 +60,7 @@ impl Rule for DrivethroughPenaltyRule { fn session_mask(&self) -> SessionMask { SessionMask::ALL } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::ALL } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let prev = prev?; // Fire on rising edge of the actual iRacing drive-through penalty flag // (CarIdxSessionFlags bit 0x10000000). Incident-count changes are NOT penalties. @@ -84,7 +84,7 @@ impl Rule for PitlaneExitBriefingRule { fn session_mask(&self) -> SessionMask { SessionMask::PRACTICE | SessionMask::QUALIFYING | SessionMask::RACE } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::MEDIUM_AND_UP } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let prev = prev?; // Fire when exiting pit lane if !prev.in_pit || current.in_pit { @@ -151,7 +151,7 @@ impl Rule for IncidentHighRule { fn session_mask(&self) -> SessionMask { SessionMask::ALL } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::HIGH } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let prev = prev?; if current.incident_count <= prev.incident_count { return None; @@ -195,7 +195,7 @@ impl Rule for IncidentMediumRule { fn session_mask(&self) -> SessionMask { SessionMask::ALL } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::MEDIUM_AND_UP } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let prev = prev?; if current.incident_count <= prev.incident_count { return None; @@ -229,7 +229,7 @@ impl Rule for IncidentLowRule { fn session_mask(&self) -> SessionMask { SessionMask::ALL } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::ALL } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let prev = prev?; if current.incident_count <= prev.incident_count { return None; diff --git a/bridge/src/race_engineer/rules/position_rules.rs b/bridge/src/race_engineer/rules/position_rules.rs index a176814..e36fe05 100644 --- a/bridge/src/race_engineer/rules/position_rules.rs +++ b/bridge/src/race_engineer/rules/position_rules.rs @@ -23,7 +23,7 @@ impl Rule for PositionGainedRule { fn session_mask(&self) -> SessionMask { SessionMask::RACE } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::MEDIUM_AND_UP } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let prev = prev?; if current.player_position > 0 && prev.player_position > 0 @@ -49,7 +49,7 @@ impl Rule for PositionLostRule { fn session_mask(&self) -> SessionMask { SessionMask::RACE } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::MEDIUM_AND_UP } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let prev = prev?; if current.player_position > 0 && prev.player_position > 0 @@ -139,7 +139,7 @@ impl Rule for GapAheadMediumRule { fn session_mask(&self) -> SessionMask { SessionMask::RACE } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::MEDIUM } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { gap_ahead_event(self.id(), current, prev) } } @@ -151,7 +151,7 @@ impl Rule for GapAheadHighRule { fn session_mask(&self) -> SessionMask { SessionMask::RACE } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::HIGH } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { gap_ahead_event(self.id(), current, prev) } } @@ -163,7 +163,7 @@ impl Rule for GapBehindMediumRule { fn session_mask(&self) -> SessionMask { SessionMask::RACE } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::MEDIUM } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { gap_behind_event(self.id(), current, prev) } } @@ -175,7 +175,7 @@ impl Rule for GapBehindHighRule { fn session_mask(&self) -> SessionMask { SessionMask::RACE } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::HIGH } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { gap_behind_event(self.id(), current, prev) } } diff --git a/bridge/src/race_engineer/rules/session_rules.rs b/bridge/src/race_engineer/rules/session_rules.rs index a265590..ee6d21c 100644 --- a/bridge/src/race_engineer/rules/session_rules.rs +++ b/bridge/src/race_engineer/rules/session_rules.rs @@ -16,7 +16,7 @@ impl Rule for FiveMinutesRemainingRule { fn session_mask(&self) -> SessionMask { SessionMask::RACE | SessionMask::QUALIFYING } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::ALL } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let remaining = current.time_remaining?.as_secs_f64(); let prev_remaining = prev.and_then(|p| p.time_remaining).map(|d| d.as_secs_f64()); @@ -43,7 +43,7 @@ impl Rule for LastLapRule { fn session_mask(&self) -> SessionMask { SessionMask::RACE | SessionMask::QUALIFYING } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::ALL } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { match current.session_type { SessionType::Race => { let laps = current.laps_remaining?; @@ -90,7 +90,7 @@ impl Rule for RaceFinishedRule { fn session_mask(&self) -> SessionMask { SessionMask::RACE } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::ALL } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let prev = prev?; let now_done = matches!( current.session_phase, diff --git a/bridge/src/race_engineer/rules/tire_rules.rs b/bridge/src/race_engineer/rules/tire_rules.rs index 5ff0f81..761e26c 100644 --- a/bridge/src/race_engineer/rules/tire_rules.rs +++ b/bridge/src/race_engineer/rules/tire_rules.rs @@ -46,7 +46,7 @@ impl Rule for TireTempsOutOfRangeRule { fn session_mask(&self) -> SessionMask { SessionMask::PRACTICE | SessionMask::RACE } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::MEDIUM_AND_UP } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let now_out = any_out_of_range(current); let was_out = prev.map(any_out_of_range).unwrap_or(false); if now_out && !was_out { @@ -74,7 +74,7 @@ impl Rule for TireTempsInRangeRule { fn session_mask(&self) -> SessionMask { SessionMask::PRACTICE | SessionMask::RACE } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::HIGH } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let prev = prev?; let now_ok = all_in_range(current); let was_out = any_out_of_range(prev); @@ -98,7 +98,7 @@ impl Rule for TireWear50Rule { fn session_mask(&self) -> SessionMask { SessionMask::RACE } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::MEDIUM_AND_UP } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let wear = max_wear(current); let prev_wear = prev.map(max_wear).unwrap_or(0.0); if wear >= 0.50 && prev_wear < 0.50 { @@ -121,7 +121,7 @@ impl Rule for TireWear75Rule { fn session_mask(&self) -> SessionMask { SessionMask::RACE } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::ALL } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let wear = max_wear(current); let prev_wear = prev.map(max_wear).unwrap_or(0.0); if wear >= 0.75 && prev_wear < 0.75 { @@ -144,7 +144,7 @@ impl Rule for TireWear90Rule { fn session_mask(&self) -> SessionMask { SessionMask::RACE } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::ALL } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let wear = max_wear(current); let prev_wear = prev.map(max_wear).unwrap_or(0.0); if wear >= 0.90 && prev_wear < 0.90 { diff --git a/bridge/src/race_engineer/rules/weather_rules.rs b/bridge/src/race_engineer/rules/weather_rules.rs index 622266e..473083f 100644 --- a/bridge/src/race_engineer/rules/weather_rules.rs +++ b/bridge/src/race_engineer/rules/weather_rules.rs @@ -1,6 +1,5 @@ //! Weather rules — rain, temperature changes, and proactive weather briefings. -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::time::Duration; use super::{FrequencyMask, Priority, Rule, RuleEvent, SessionMask, TemplateParams}; @@ -22,13 +21,13 @@ pub struct TrackDryingRule; pub struct RainEscalationRule; pub struct AmbientTempChangeRule { - baseline: AtomicU32, - initialized: AtomicBool, + baseline: f32, + initialized: bool, } pub struct TrackTempChangeRule { - baseline: AtomicU32, - initialized: AtomicBool, + baseline: f32, + initialized: bool, } /// Fires N minutes before rain arrives (if forecast data available). @@ -44,25 +43,16 @@ impl RainForecastWarningRule { impl AmbientTempChangeRule { pub fn new() -> Self { - Self { - baseline: AtomicU32::new(0), - initialized: AtomicBool::new(false), - } + Self { baseline: 0.0, initialized: false } } } impl TrackTempChangeRule { pub fn new() -> Self { - Self { - baseline: AtomicU32::new(0), - initialized: AtomicBool::new(false), - } + Self { baseline: 0.0, initialized: false } } } -fn f32_to_bits(v: f32) -> u32 { v.to_bits() } -fn bits_to_f32(v: u32) -> f32 { f32::from_bits(v) } - impl Rule for RainStartingRule { fn id(&self) -> &'static str { "rain_starting" } fn priority(&self) -> Priority { Priority::High } @@ -70,7 +60,7 @@ impl Rule for RainStartingRule { fn session_mask(&self) -> SessionMask { SessionMask::ALL } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::ALL } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let prev = prev?; let now_wet = current.rain_intensity >= RAIN_THRESHOLD; let was_wet = prev.rain_intensity >= RAIN_THRESHOLD; @@ -94,7 +84,7 @@ impl Rule for RainClearingRule { fn session_mask(&self) -> SessionMask { SessionMask::ALL } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::ALL } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let prev = prev?; let now_dry = current.rain_intensity < RAIN_THRESHOLD; let was_wet = prev.rain_intensity >= RAIN_THRESHOLD; @@ -118,7 +108,7 @@ impl Rule for TrackDryingRule { fn session_mask(&self) -> SessionMask { SessionMask::ALL } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::MEDIUM_AND_UP } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let prev = prev?; let in_drying = current.rain_intensity > DRYING_RANGE_LO && current.rain_intensity < DRYING_RANGE_HI; @@ -143,7 +133,7 @@ impl Rule for RainEscalationRule { fn session_mask(&self) -> SessionMask { SessionMask::ALL } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::ALL } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let prev = prev?; if current.rain_intensity >= HEAVY_RAIN && prev.rain_intensity < HEAVY_RAIN { Some(RuleEvent { @@ -165,16 +155,15 @@ impl Rule for AmbientTempChangeRule { fn session_mask(&self) -> SessionMask { SessionMask::ALL } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::MEDIUM_AND_UP } - fn evaluate(&self, current: &EngineerState, _prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, _prev: Option<&EngineerState>) -> Option { let temp = current.ambient_temp_c; - if !self.initialized.load(Ordering::Relaxed) { - self.baseline.store(f32_to_bits(temp), Ordering::Relaxed); - self.initialized.store(true, Ordering::Relaxed); + if !self.initialized { + self.baseline = temp; + self.initialized = true; return None; } - let baseline = bits_to_f32(self.baseline.load(Ordering::Relaxed)); - if (temp - baseline).abs() >= TEMP_CHANGE_C { - self.baseline.store(f32_to_bits(temp), Ordering::Relaxed); + if (temp - self.baseline).abs() >= TEMP_CHANGE_C { + self.baseline = temp; Some(RuleEvent { rule_id: self.id(), priority: self.priority(), @@ -194,16 +183,15 @@ impl Rule for TrackTempChangeRule { fn session_mask(&self) -> SessionMask { SessionMask::ALL } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::MEDIUM_AND_UP } - fn evaluate(&self, current: &EngineerState, _prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, _prev: Option<&EngineerState>) -> Option { let temp = current.track_temp_c; - if !self.initialized.load(Ordering::Relaxed) { - self.baseline.store(f32_to_bits(temp), Ordering::Relaxed); - self.initialized.store(true, Ordering::Relaxed); + if !self.initialized { + self.baseline = temp; + self.initialized = true; return None; } - let baseline = bits_to_f32(self.baseline.load(Ordering::Relaxed)); - if (temp - baseline).abs() >= TEMP_CHANGE_C { - self.baseline.store(f32_to_bits(temp), Ordering::Relaxed); + if (temp - self.baseline).abs() >= TEMP_CHANGE_C { + self.baseline = temp; Some(RuleEvent { rule_id: self.id(), priority: self.priority(), @@ -223,7 +211,7 @@ impl Rule for RainForecastWarningRule { fn session_mask(&self) -> SessionMask { SessionMask::ALL } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::MEDIUM_AND_UP } - fn evaluate(&self, _current: &EngineerState, _prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, _current: &EngineerState, _prev: Option<&EngineerState>) -> Option { // iRacing V1: no forecast data available; rule is a no-op placeholder None } @@ -236,7 +224,7 @@ impl Rule for WeatherBriefingRule { fn session_mask(&self) -> SessionMask { SessionMask::ALL } fn frequency_mask(&self) -> FrequencyMask { FrequencyMask::MEDIUM_AND_UP } - fn evaluate(&self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { + fn evaluate(&mut self, current: &EngineerState, prev: Option<&EngineerState>) -> Option { let prev = prev?; // Fire on car-entry rising edge or pit-lane exit rising edge. diff --git a/bridge/src/race_engineer/state.rs b/bridge/src/race_engineer/state.rs index 4c38549..10782cd 100644 --- a/bridge/src/race_engineer/state.rs +++ b/bridge/src/race_engineer/state.rs @@ -371,8 +371,9 @@ impl StateAggregator { .filter(|&t| t < f32::MAX && t > 0.0) .map(Duration::from_secs_f32); - // Sector deltas from last 3 sectors vs personal best - let sector_deltas = [None, None, None]; // populated from sector_tracker externally if needed + // Sector deltas: sectors completed so far this lap vs personal best + // (positive = slower). Sourced from the standings' sector tracker data. + let sector_deltas = compute_sector_deltas(player_car_idx, standings); // --- Flags --- let active_flags = FlagState::from_iracing( @@ -503,6 +504,32 @@ fn avg3(arr: [f32; 3]) -> f32 { (arr[0] + arr[1] + arr[2]) / 3.0 } +/// Delta per completed sector of the current lap vs the driver's personal +/// best for that sector (positive = slower). Covers the first three sectors; +/// `None` where the sector hasn't been completed or no PB exists yet. +fn compute_sector_deltas( + player_car_idx: i32, + standings: Option<&StandingsSnapshot>, +) -> [Option; 3] { + let mut out = [None, None, None]; + let Some(entry) = standings + .and_then(|s| s.entries.iter().find(|e| e.car_idx == player_car_idx)) + else { + return out; + }; + for (i, slot) in out.iter_mut().enumerate() { + if let (Some(&cur), Some(Some(pb))) = ( + entry.current_lap_sectors.get(i), + entry.best_sector_times.get(i), + ) { + if cur > 0.0 && *pb > 0.0 { + *slot = Some(cur - *pb); + } + } + } + out +} + fn compute_gaps( is_race: bool, player_car_idx: i32, diff --git a/bridge/src/telemetry/sector_tracker.rs b/bridge/src/telemetry/sector_tracker.rs index c684d7a..584602d 100644 --- a/bridge/src/telemetry/sector_tracker.rs +++ b/bridge/src/telemetry/sector_tracker.rs @@ -109,6 +109,142 @@ pub struct SectorTracker { pending_completions: Vec, } +/// One car's observation for a single 60-Hz frame, as fed into `step_car`. +struct Frame { + t: f64, + p: f32, + on_pit: bool, + surface: i32, + /// iRacing's authoritative last-lap time for this car; -1.0 if unknown. + sdk_last_lap: f32, + air_temp: Option, + track_temp: Option, +} + +/// Advances one car's sector state by one frame. This is the complete +/// per-car logic of `update()`; the tests drive exactly this function. +fn step_car( + car_idx: i32, + state: &mut CarState, + frame: &Frame, + sector_starts: &[f32], + pending: &mut Vec, +) { + let n = sector_starts.len(); + + // Invalidate running lap if car goes to pit or leaves track. + if frame.on_pit || frame.surface == 1 || frame.surface == 2 { + state.invalidate_lap(frame.on_pit); + state.last_p = frame.p; + state.last_t = frame.t; + return; + } + + let last_p = state.last_p; + let last_t = state.last_t; + + // First ever sample — just arm. + if last_p < 0.0 { + state.last_p = frame.p; + state.last_t = frame.t; + return; + } + + // Detect S/F crossing (p wraps from >0.9 back to <0.1). + let sf_cross = last_p > 0.9 && frame.p < 0.1; + + if sf_cross { + let t_sf = interpolate_time(last_p, last_t, frame.p, frame.t, 0.0); + + // Finish the last sector of the previous lap. + if state.lap_valid && state.sector_idx == n { + let dur = (t_sf - state.sector_started_at) as f32; + if dur > 0.0 { + state.current_lap_sectors.push(dur); + } + } + // Commit the completed lap if we have exactly n+1 sector times. + if state.lap_valid && state.current_lap_sectors.len() == n + 1 { + let times = state.current_lap_sectors.clone(); + let pb = &mut state.output.personal_best; + if pb.len() < n + 1 { + pb.resize(n + 1, None); + } + for (i, &t) in times.iter().enumerate() { + pb[i] = Some(match pb[i] { + Some(best) if best <= t => best, + _ => t, + }); + } + state.output.last_sectors = times; + } + + // Emit a LapCompletion for every car that had a lap started. + if state.current_lap_num > 0 { + // Prefer iRacing's authoritative CarIdxLastLapTime over our + // self-measured interpolation (which has ~60-Hz noise of ±4-7 ms). + // Fall back to interpolated time only when iRacing reports no value + // (e.g. off-track invalidation → SDK returns -1). + let lap_time = if frame.sdk_last_lap > 0.0 { + Some(frame.sdk_last_lap) + } else if state.lap_started_at > 0.0 { + let t = (t_sf - state.lap_started_at) as f32; + if t > 0.0 { Some(t) } else { None } + } else { + None + }; + let valid = state.lap_valid && state.current_lap_sectors.len() == n + 1; + pending.push(LapCompletion { + car_idx, + lap_num: state.current_lap_num, + lap_time_sec: lap_time, + sectors: state.current_lap_sectors.clone(), + valid, + in_lap: state.pit_in_during_lap, + session_time: t_sf, + air_temp: frame.air_temp, + track_temp: frame.track_temp, + }); + } + + // Start fresh lap. + state.reset_lap(t_sf, n + 1); + state.output.current_lap_sectors.clear(); + state.last_p = frame.p; + state.last_t = frame.t; + return; + } + + // Not on an active, valid lap — skip sector crossing logic. + if !state.lap_valid { + state.last_p = frame.p; + state.last_t = frame.t; + return; + } + + // Check if we crossed any sector boundaries between last_p and p. + let mut si = state.sector_idx; + while si < n { + let threshold = sector_starts[si]; + let crossed = last_p < threshold && frame.p >= threshold; + if !crossed { + break; + } + let t_cross = interpolate_time(last_p, last_t, frame.p, frame.t, threshold); + let dur = (t_cross - state.sector_started_at) as f32; + if dur > 0.0 { + state.current_lap_sectors.push(dur); + state.output.current_lap_sectors = state.current_lap_sectors.clone(); + } + state.sector_started_at = t_cross; + state.sector_idx = si + 1; + si += 1; + } + + state.last_p = frame.p; + state.last_t = frame.t; +} + impl SectorTracker { /// Update sector tracking for all cars. Called every 60-Hz frame. pub fn update(&mut self, client: &IRacingClient, yaml: &SessionInfoYaml) -> Result<()> { @@ -151,119 +287,16 @@ impl SectorTracker { } let state = self.cars.entry(car_idx).or_insert_with(|| CarState::new(n + 1)); - - // Invalidate running lap if car goes to pit or leaves track. - if pit || surface == 1 || surface == 2 { - state.invalidate_lap(pit); - state.last_p = p; - state.last_t = session_time; - continue; - } - - let last_p = state.last_p; - let last_t = state.last_t; - - // First ever sample — just arm. - if last_p < 0.0 { - state.last_p = p; - state.last_t = session_time; - continue; - } - - // Detect S/F crossing (p wraps from >0.9 back to <0.1). - let sf_cross = last_p > 0.9 && p < 0.1; - - if sf_cross { - let t_sf = interpolate_time(last_p, last_t, p, session_time, 0.0); - - // Finish the last sector of the previous lap. - if state.lap_valid && state.sector_idx == n { - let dur = (t_sf - state.sector_started_at) as f32; - if dur > 0.0 { - state.current_lap_sectors.push(dur); - } - } - // Commit the completed lap if we have exactly n+1 sector times. - if state.lap_valid && state.current_lap_sectors.len() == n + 1 { - let times = state.current_lap_sectors.clone(); - let pb = &mut state.output.personal_best; - if pb.len() < n + 1 { - pb.resize(n + 1, None); - } - for (i, &t) in times.iter().enumerate() { - pb[i] = Some(match pb[i] { - Some(best) if best <= t => best, - _ => t, - }); - } - state.output.last_sectors = times; - } - - // Emit a LapCompletion for every car that had a lap started. - if state.current_lap_num > 0 { - // Prefer iRacing's authoritative CarIdxLastLapTime over our - // self-measured interpolation (which has ~60-Hz noise of ±4-7 ms). - // Fall back to interpolated time only when iRacing reports no value - // (e.g. off-track invalidation → SDK returns -1). - let sdk_last = sdk_last_lap_times.get(idx).copied().unwrap_or(-1.0); - let lap_time = if sdk_last > 0.0 { - Some(sdk_last) - } else if state.lap_started_at > 0.0 { - let t = (t_sf - state.lap_started_at) as f32; - if t > 0.0 { Some(t) } else { None } - } else { - None - }; - let valid = state.lap_valid && state.current_lap_sectors.len() == n + 1; - self.pending_completions.push(LapCompletion { - car_idx, - lap_num: state.current_lap_num, - lap_time_sec: lap_time, - sectors: state.current_lap_sectors.clone(), - valid, - in_lap: state.pit_in_during_lap, - session_time: t_sf, - air_temp, - track_temp, - }); - } - - // Start fresh lap. - state.reset_lap(t_sf, n + 1); - state.output.current_lap_sectors.clear(); - state.last_p = p; - state.last_t = session_time; - continue; - } - - // Not on an active, valid lap — skip sector crossing logic. - if !state.lap_valid { - state.last_p = p; - state.last_t = session_time; - continue; - } - - // Check if we crossed any sector boundaries between last_p and p. - let mut si = state.sector_idx; - while si < n { - let threshold = self.sector_starts[si]; - let crossed = last_p < threshold && p >= threshold; - if !crossed { - break; - } - let t_cross = interpolate_time(last_p, last_t, p, session_time, threshold); - let dur = (t_cross - state.sector_started_at) as f32; - if dur > 0.0 { - state.current_lap_sectors.push(dur); - state.output.current_lap_sectors = state.current_lap_sectors.clone(); - } - state.sector_started_at = t_cross; - state.sector_idx = si + 1; - si += 1; - } - - state.last_p = p; - state.last_t = session_time; + let frame = Frame { + t: session_time, + p, + on_pit: pit, + surface, + sdk_last_lap: sdk_last_lap_times.get(idx).copied().unwrap_or(-1.0), + air_temp, + track_temp, + }; + step_car(car_idx, state, &frame, &self.sector_starts, &mut self.pending_completions); } Ok(()) @@ -322,8 +355,8 @@ mod tests { } } - /// Feed a sequence of (time, lapDistPct, onPit, surface) frames into state for car 0. - /// Mirrors the S/F-crossing and sector logic from `update()`, including completion emission. + /// Feed a sequence of (time, lapDistPct, onPit, surface) frames into state + /// for car 0 — drives the production `step_car()` directly. fn feed(st: &mut SectorTracker, frames: &[(f64, f32, bool, i32)]) { let n = st.sector_starts.len(); for &(t, p, pit, surface) in frames { @@ -331,75 +364,16 @@ mod tests { continue; } let state = st.cars.entry(0).or_insert_with(|| CarState::new(n + 1)); - if pit || surface == 1 || surface == 2 { - state.invalidate_lap(pit); - state.last_p = p; - state.last_t = t; - continue; - } - let last_p = state.last_p; - let last_t = state.last_t; - if last_p < 0.0 { - state.last_p = p; - state.last_t = t; - continue; - } - let sf_cross = last_p > 0.9 && p < 0.1; - if sf_cross { - let t_sf = interpolate_time(last_p, last_t, p, t, 0.0); - if state.lap_valid && state.sector_idx == n { - let dur = (t_sf - state.sector_started_at) as f32; - if dur > 0.0 { state.current_lap_sectors.push(dur); } - } - if state.lap_valid && state.current_lap_sectors.len() == n + 1 { - let times = state.current_lap_sectors.clone(); - let pb = &mut state.output.personal_best; - if pb.len() < n + 1 { pb.resize(n + 1, None); } - for (i, &sv) in times.iter().enumerate() { - pb[i] = Some(match pb[i] { Some(b) if b <= sv => b, _ => sv }); - } - state.output.last_sectors = times; - } - // Emit completion (mirrors update()). - if state.current_lap_num > 0 { - let lap_time = if state.lap_started_at > 0.0 { - let elapsed = (t_sf - state.lap_started_at) as f32; - if elapsed > 0.0 { Some(elapsed) } else { None } - } else { - None - }; - let valid = state.lap_valid && state.current_lap_sectors.len() == n + 1; - st.pending_completions.push(LapCompletion { - car_idx: 0, - lap_num: state.current_lap_num, - lap_time_sec: lap_time, - sectors: state.current_lap_sectors.clone(), - valid, - in_lap: state.pit_in_during_lap, - session_time: t_sf, - air_temp: None, - track_temp: None, - }); - } - state.reset_lap(t_sf, n + 1); - state.last_p = p; - state.last_t = t; - continue; - } - if !state.lap_valid { state.last_p = p; state.last_t = t; continue; } - let mut si = state.sector_idx; - while si < n { - let threshold = st.sector_starts[si]; - if !(last_p < threshold && p >= threshold) { break; } - let t_cross = interpolate_time(last_p, last_t, p, t, threshold); - let dur = (t_cross - state.sector_started_at) as f32; - if dur > 0.0 { state.current_lap_sectors.push(dur); } - state.sector_started_at = t_cross; - state.sector_idx = si + 1; - si += 1; - } - state.last_p = p; - state.last_t = t; + let frame = Frame { + t, + p, + on_pit: pit, + surface, + sdk_last_lap: -1.0, + air_temp: None, + track_temp: None, + }; + step_car(0, state, &frame, &st.sector_starts, &mut st.pending_completions); } } From 67486a9dde5fb826640e020c405cbbda8b416805 Mon Sep 17 00:00:00 2001 From: Swizzjack Date: Thu, 11 Jun 2026 19:39:10 +0000 Subject: [PATCH 04/10] perf(dashboard): stop re-rendering every widget at 60 Hz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registry: memoize all widgets at the single render boundary — App re-renders per telemetry frame, but standings/track-map/session-info widgets only consume 4-15 Hz snapshots; with stable prop identities memo() skips them entirely - App: useMemo'd WidgetData object, useCallback'd onDeleteTrackMap; engineer-settings updates compute outside the setState updater (StrictMode runs updaters twice — side effects were doubled) - Dashboard: memoize the GridLayout layout array (was rebuilt per frame) - extract sofFromRatings() — the SoF formula lived in both Standings and SoF; translate leftover German UI string Co-Authored-By: Claude Fable 5 --- dashboard/src/App.tsx | 56 ++++++++++++++++------------- dashboard/src/format.ts | 10 ++++++ dashboard/src/layout/Dashboard.tsx | 12 +++++-- dashboard/src/layout/registry.tsx | 41 ++++++++++++++------- dashboard/src/widgets/SoF.tsx | 5 +-- dashboard/src/widgets/Standings.tsx | 7 ++-- 6 files changed, 87 insertions(+), 44 deletions(-) diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index b3b8359..45a85e1 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { LayoutItem } from 'react-grid-layout' import type { ServerMessage } from '@shared/ServerMessage' import type { TelemetrySnapshot } from '@shared/TelemetrySnapshot' @@ -228,32 +228,40 @@ function App() { setStored(defaults) } - function handleDeleteTrackMap(trackKey: string) { + const handleDeleteTrackMap = useCallback((trackKey: string) => { clientRef.current?.send({ type: 'deleteTrackMap', trackKey } as never) - } + }, []) + + // Stable object identity so memoized widgets only re-render when the + // snapshot they consume actually changed (standings at 4 Hz, not 60 Hz). + const widgetData = useMemo( + () => ({ tel, standings, info, trackMap, onDeleteTrackMap: handleDeleteTrackMap }), + [tel, standings, info, trackMap, handleDeleteTrackMap], + ) function handleEngineerSettingsChange(partial: Partial) { - setEngineerSettings((prev) => { - const next = { ...prev, ...partial } - engineerSettingsRef.current = next - saveEngineerSettings(next) - // Sync changed settings to the service / backend - if ('enabled' in partial) engineerService.setEnabled(next.enabled) - if ('volume' in partial) engineerService.setVolume(next.volume) - if ('radioEffect' in partial) engineerService.setRadioEffect(next.radioEffect) - if ('outputDeviceId' in partial) void engineerService.setOutputDevice(next.outputDeviceId) - // Send behaviour update for backend-relevant settings - engineerService.sendBehaviorUpdate({ - enabled: next.enabled, - frequency: next.frequency, - muteInQualifying: next.muteInQualifying, - debugAllRulesInPractice: next.debugAllRulesInPractice, - activeVoiceId: next.activeVoiceId, - pilotName: next.pilotName || null, - muteNameInCallouts: next.muteNameInCallouts, - }) - return next + // Compute outside the setState updater: updaters must stay pure + // (StrictMode runs them twice, which would double every side effect). + // engineerSettingsRef is the always-current source of truth. + const next = { ...engineerSettingsRef.current, ...partial } + engineerSettingsRef.current = next + saveEngineerSettings(next) + // Sync changed settings to the service / backend + if ('enabled' in partial) engineerService.setEnabled(next.enabled) + if ('volume' in partial) engineerService.setVolume(next.volume) + if ('radioEffect' in partial) engineerService.setRadioEffect(next.radioEffect) + if ('outputDeviceId' in partial) void engineerService.setOutputDevice(next.outputDeviceId) + // Send behaviour update for backend-relevant settings + engineerService.sendBehaviorUpdate({ + enabled: next.enabled, + frequency: next.frequency, + muteInQualifying: next.muteInQualifying, + debugAllRulesInPractice: next.debugAllRulesInPractice, + activeVoiceId: next.activeVoiceId, + pilotName: next.pilotName || null, + muteNameInCallouts: next.muteNameInCallouts, }) + setEngineerSettings(next) } return ( @@ -408,7 +416,7 @@ function App() {
= { W: { bg: 'rgba(40, 34, 0, 0.82)', fg: '#ffd966' }, } +/** + * iRacing Strength-of-Field over a set of iRatings: + * SoF = (1600/ln 2) · ln( n / Σ 2^(−r/1600) ). Null for an empty set. + */ +export function sofFromRatings(ratings: number[]): number | null { + if (ratings.length === 0) return null + const BR = 1600 / Math.LN2 + return BR * Math.log(ratings.length / ratings.reduce((s, r) => s + Math.pow(2, -r / 1600), 0)) +} + export function fmtDuration(sec: number): string { if (!(sec >= 0)) return '—' const h = Math.floor(sec / 3600) diff --git a/dashboard/src/layout/Dashboard.tsx b/dashboard/src/layout/Dashboard.tsx index 3a15f55..b983fd3 100644 --- a/dashboard/src/layout/Dashboard.tsx +++ b/dashboard/src/layout/Dashboard.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react' import GridLayout, { useContainerWidth } from 'react-grid-layout' import type { LayoutItem } from 'react-grid-layout' import { REGISTRY, type WidgetData } from './registry' @@ -22,12 +23,19 @@ function getItemLayout(id: string, layout: LayoutItem[]): LayoutItem { export function Dashboard({ data, visible, layout, editing, onLayoutChange, onRemove }: Props) { const { width, containerRef } = useContainerWidth() + // Stable array identity: rebuilding the layout per render would make + // GridLayout reconcile its grid on every 60 Hz telemetry frame. + const gridLayout = useMemo( + () => visible.map((id) => getItemLayout(id, layout)), + [visible, layout], + ) + return (
}> getItemLayout(id, layout))} + layout={gridLayout} gridConfig={{ cols: 12, rowHeight: 36, margin: [12, 12], containerPadding: [12, 12], maxRows: Infinity }} dragConfig={{ enabled: editing, handle: '.card > h2' }} resizeConfig={{ enabled: editing, handles: ['se' as const] }} @@ -43,7 +51,7 @@ export function Dashboard({ data, visible, layout, editing, onLayoutChange, onRe diff --git a/dashboard/src/layout/registry.tsx b/dashboard/src/layout/registry.tsx index 4170b54..793a623 100644 --- a/dashboard/src/layout/registry.tsx +++ b/dashboard/src/layout/registry.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react' +import { memo, type ReactNode } from 'react' import type { TelemetrySnapshot } from '@shared/TelemetrySnapshot' import type { StandingsSnapshot } from '@shared/StandingsSnapshot' import type { SessionInfoYaml } from '@shared/SessionInfoYaml' @@ -30,71 +30,88 @@ export type WidgetDef = { default: { w: number; h: number; minW: number; minH: number } } +// Every widget is memoized at this single render boundary: App re-renders at +// telemetry rate (rAF-throttled ~60 Hz), but most widgets consume snapshots +// that only change at 4–15 Hz. With the stable prop identities provided by +// App (useMemo'd WidgetData, useCallback'd onDeleteTrackMap), memo() skips +// the re-render whenever a widget's own inputs are unchanged. +const TelemetryInputsM = memo(TelemetryInputs) +const StandingsM = memo(Standings) +const SoFM = memo(SoF) +const TrackMapM = memo(TrackMap) +const WeatherM = memo(Weather) +const FuelM = memo(Fuel) +const ElectronicsM = memo(Electronics) +const TireM = memo(Tire) +const LapHistoryM = memo(LapHistory) +const WindM = memo(Wind) +const EngineerTranscriptM = memo(EngineerTranscript) + export const REGISTRY: WidgetDef[] = [ { id: 'telemetryInputs', title: 'Telemetry Inputs', - render: (d) => , + render: (d) => , default: { w: 4, h: 7, minW: 3, minH: 3 }, }, { id: 'standings', title: 'Standings', - render: (d) => , + render: (d) => , default: { w: 12, h: 9, minW: 3, minH: 3 }, }, { id: 'sof', title: 'Strength of Field', - render: (d) => , + render: (d) => , default: { w: 4, h: 7, minW: 3, minH: 3 }, }, { id: 'trackMap', title: 'Track Map', - render: (d) => , + render: (d) => , default: { w: 6, h: 12, minW: 3, minH: 3 }, }, { id: 'weather', title: 'Weather', - render: (d) => , + render: (d) => , default: { w: 4, h: 8, minW: 3, minH: 3 }, }, { id: 'fuel', title: 'Fuel', - render: (d) => , + render: (d) => , default: { w: 4, h: 9, minW: 3, minH: 3 }, }, { id: 'electronics', title: 'Electronics', - render: (d) => , + render: (d) => , default: { w: 6, h: 10, minW: 3, minH: 3 }, }, { id: 'tire', title: 'Tires', - render: (d) => , + render: (d) => , default: { w: 6, h: 11, minW: 3, minH: 3 }, }, { id: 'lapHistory', title: 'Lap History', - render: (d) => , + render: (d) => , default: { w: 5, h: 9, minW: 3, minH: 3 }, }, { id: 'wind', title: 'Wind', - render: (d) => , + render: (d) => , default: { w: 4, h: 8, minW: 2, minH: 5 }, }, { id: 'engineerTranscript', title: 'Engineer Transcript', - render: () => , + render: () => , default: { w: 4, h: 9, minW: 3, minH: 4 }, }, ] diff --git a/dashboard/src/widgets/SoF.tsx b/dashboard/src/widgets/SoF.tsx index 2ff0a22..96da7b1 100644 --- a/dashboard/src/widgets/SoF.tsx +++ b/dashboard/src/widgets/SoF.tsx @@ -1,9 +1,9 @@ import { useMemo } from 'react' import type { StandingsSnapshot } from '@shared/StandingsSnapshot' +import { sofFromRatings } from '../format' type Props = { snap: StandingsSnapshot | null; playerCarIdx: number | null } -const BR = 1600 / Math.LN2 const BIN_SIZE = 500 const SVG_W = 200 const SVG_H = 44 @@ -56,7 +56,8 @@ function buildCard( return '#3a3a3a' }) - const sof = BR * Math.log(count / ratings.reduce((s, r) => s + Math.pow(2, -r / 1600), 0)) + // count > 0 is guaranteed above, so sofFromRatings can't return null here. + const sof = sofFromRatings(ratings) ?? 0 const markerX = binCount > 0 ? ((sof - rangeStart) / (rangeEnd - rangeStart)) * SVG_W diff --git a/dashboard/src/widgets/Standings.tsx b/dashboard/src/widgets/Standings.tsx index 0826e24..5a3bc87 100644 --- a/dashboard/src/widgets/Standings.tsx +++ b/dashboard/src/widgets/Standings.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useMemo, useRef, useCallback } from 'react' import type { StandingsSnapshot } from '@shared/StandingsSnapshot' import type { StandingEntry } from '@shared/StandingEntry' -import { fmtLapTime, hexFromClassColor, readableTextOn, LIC_COLORS } from '../format' +import { fmtLapTime, hexFromClassColor, readableTextOn, sofFromRatings, LIC_COLORS } from '../format' import { SettingsDrawer } from '../components/SettingsDrawer' import { StandingsSettings } from './StandingsSettings' import type { ColId } from './StandingsSettings' @@ -236,7 +236,6 @@ export function Standings({ snap, playerCarIdx }: Props) { const sofByClass = useMemo(() => { if (!snap) return [] - const BR = 1600 / Math.LN2 const byClass = new Map() for (const e of snap.entries) { if (!byClass.has(e.carClassId)) @@ -248,8 +247,8 @@ export function Standings({ snap, playerCarIdx }: Props) { : null return [...byClass.entries()] .map(([classId, { name, color, ratings }]) => { - if (ratings.length === 0) return null - const sof = BR * Math.log(ratings.length / ratings.reduce((s, r) => s + Math.pow(2, -r / 1600), 0)) + const sof = sofFromRatings(ratings) + if (sof == null) return null return { classId, name, color, sof: Math.round(sof) } }) .filter((x): x is { classId: number; name: string; color: string | null; sof: number } => x != null) From 23698ffce39cb1b45427f352ce65fea08f282930 Mon Sep 17 00:00:00 2001 From: Swizzjack Date: Thu, 11 Jun 2026 19:55:07 +0000 Subject: [PATCH 05/10] fix(dashboard): make hooks render-pure, eslint clean (was 131 errors) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useFuelTracker/useLapHistory: frame processing moves into effects; refs are bookkeeping only, render-visible results live in useState and commit only on real events (lap completed, session change, pit exit) — no per-frame renders, React-Compiler-compatible - TrackMap: settings/interp/data refs are now written in effects, not during render - Wind: previous-render angle unwrapping via the documented guarded setState-in-render pattern instead of ref mutation - SettingsPanel: voice list derived from props + optimistic overrides (replaces the prop→state mirror effect) - TrackMapSettings: confirm-delete armed state derived from hasMap - eslint: disable react-refresh/only-export-components (HMR granularity only; widgets co-locate settings constants by design); fix no-empty/ unused-vars - drop unused uplot dependency Co-Authored-By: Claude Fable 5 --- dashboard/eslint.config.js | 8 ++ dashboard/package-lock.json | 9 +- dashboard/package.json | 3 +- .../components/SettingsPanel.tsx | 19 ++-- dashboard/src/widgets/Standings.tsx | 7 +- dashboard/src/widgets/TrackMap.tsx | 38 ++++--- dashboard/src/widgets/TrackMapSettings.tsx | 11 +-- dashboard/src/widgets/Wind.tsx | 23 +++-- dashboard/src/widgets/useFuelTracker.ts | 99 ++++++++++++------- dashboard/src/widgets/useLapHistory.ts | 51 +++++----- 10 files changed, 158 insertions(+), 110 deletions(-) diff --git a/dashboard/eslint.config.js b/dashboard/eslint.config.js index ef614d2..fdad22f 100644 --- a/dashboard/eslint.config.js +++ b/dashboard/eslint.config.js @@ -18,5 +18,13 @@ export default defineConfig([ languageOptions: { globals: globals.browser, }, + rules: { + // Widgets deliberately co-locate their settings constants/types with + // the component (Tire/Electronics/… + *Settings), and registry.tsx + // wraps all widgets in memo() next to the REGISTRY table. The rule + // only affects Vite HMR granularity (full reload instead of fast + // refresh for those modules), not correctness. + 'react-refresh/only-export-components': 'off', + }, }, ]) diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 03fdeb1..c9c8893 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -14,8 +14,7 @@ "lucide-react": "^1.14.0", "react": "^19.2.5", "react-dom": "^19.2.5", - "react-grid-layout": "^2.2.3", - "uplot": "^1.6.32" + "react-grid-layout": "^2.2.3" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -2751,12 +2750,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/uplot": { - "version": "1.6.32", - "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz", - "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==", - "license": "MIT" - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 5cdcb7e..ca347d3 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -16,8 +16,7 @@ "lucide-react": "^1.14.0", "react": "^19.2.5", "react-dom": "^19.2.5", - "react-grid-layout": "^2.2.3", - "uplot": "^1.6.32" + "react-grid-layout": "^2.2.3" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/dashboard/src/features/race-engineer/components/SettingsPanel.tsx b/dashboard/src/features/race-engineer/components/SettingsPanel.tsx index f1c6c08..2265b10 100644 --- a/dashboard/src/features/race-engineer/components/SettingsPanel.tsx +++ b/dashboard/src/features/race-engineer/components/SettingsPanel.tsx @@ -12,7 +12,16 @@ interface Props { } export function SettingsPanel({ settings, voices, onSettingsChange, onResetSetup }: Props) { - const [voiceList, setVoiceList] = useState(voices) + // Optimistic install-state overrides: VoiceCard reports install/uninstall + // completion slightly before the next EngineerStatus refresh arrives. The + // server list (`voices`) stays the source of truth; deriving instead of + // mirroring it into state avoids the prop→state sync effect. + const [installedOverrides, setInstalledOverrides] = useState>({}) + const voiceList = voices.map((v) => + installedOverrides[v.id] != null && installedOverrides[v.id] !== v.installed + ? { ...v, installed: installedOverrides[v.id] } + : v, + ) // Audio output device enumeration ------------------------------------------------ const [audioDevices, setAudioDevices] = useState([]) @@ -52,19 +61,15 @@ export function SettingsPanel({ settings, voices, onSettingsChange, onResetSetup }, [deviceSupported]) // --------------------------------------------------------------------------------- - useEffect(() => { - setVoiceList(voices) - }, [voices]) - function handleUninstalled(voiceId: string) { - setVoiceList((prev) => prev.map((v) => (v.id === voiceId ? { ...v, installed: false } : v))) + setInstalledOverrides((prev) => ({ ...prev, [voiceId]: false })) if (settings.activeVoiceId === voiceId) { onSettingsChange({ activeVoiceId: null, enabled: false }) } } function handleInstalled(voiceId: string) { - setVoiceList((prev) => prev.map((v) => (v.id === voiceId ? { ...v, installed: true } : v))) + setInstalledOverrides((prev) => ({ ...prev, [voiceId]: true })) onSettingsChange({ activeVoiceId: voiceId }) } diff --git a/dashboard/src/widgets/Standings.tsx b/dashboard/src/widgets/Standings.tsx index 5a3bc87..ac0dbc5 100644 --- a/dashboard/src/widgets/Standings.tsx +++ b/dashboard/src/widgets/Standings.tsx @@ -207,7 +207,7 @@ export function Standings({ snap, playerCarIdx }: Props) { useEffect(() => { try { localStorage.setItem(LS_KEY, JSON.stringify(config)) - } catch {} + } catch { /* localStorage unavailable */ } }, [config]) const sessionBestLap = useMemo(() => { @@ -434,8 +434,9 @@ export function Standings({ snap, playerCarIdx }: Props) { return { ...prev, hidden } })} onResetWidth={id => setConfig(prev => { - const { [id]: _, ...rest } = prev.widths - return { ...prev, widths: rest as Partial> } + const widths = { ...prev.widths } + delete widths[id] + return { ...prev, widths } })} onResetAllWidths={() => setConfig(prev => ({ ...prev, widths: {} }))} onFontScaleChange={fontScale => setConfig(prev => ({ ...prev, fontScale }))} diff --git a/dashboard/src/widgets/TrackMap.tsx b/dashboard/src/widgets/TrackMap.tsx index cb5eabd..3269928 100644 --- a/dashboard/src/widgets/TrackMap.tsx +++ b/dashboard/src/widgets/TrackMap.tsx @@ -33,7 +33,7 @@ function loadNum(key: string, def: number, min: number, max: number): number { try { const v = parseFloat(localStorage.getItem(key) ?? '') if (Number.isFinite(v) && v >= min && v <= max) return v - } catch {} + } catch { /* localStorage unavailable — fall back to default */ } return def } @@ -74,7 +74,7 @@ const SECTOR_GRAY_DARK = '#444444' const SECTOR_GRAY_LIGHT = '#888888' const SECTOR_LABEL_GRAY = '#cccccc' -function staticSectorColor(idx: number, _n: number): string { +function staticSectorColor(idx: number): string { return idx % 2 === 0 ? SECTOR_GRAY_DARK : SECTOR_GRAY_LIGHT } @@ -96,9 +96,9 @@ function livePerfColor(idx: number, live: LiveSectorPalette): string | null { return null } -function sectorColorAt(idx: number, live: LiveSectorPalette | null, n: number): string { +function sectorColorAt(idx: number, live: LiveSectorPalette | null): string { if (live) { const c = livePerfColor(idx, live); if (c) return c } - return staticSectorColor(idx, n) + return staticSectorColor(idx) } function sectorLabelColor(idx: number, live: LiveSectorPalette | null): string { @@ -418,17 +418,24 @@ export function TrackMap({ snap, playerCarIdx, info, standings, onDelete }: Prop const [showSettings, setShowSettings] = useState(false) const settingsRef = useRef({ trackWidth, carRadius, sfLength, sectorShow, sectorLength, sectorLiveColors, sectorFontSize, mode3d, tiltDeg, zExag }) - settingsRef.current = { trackWidth, carRadius, sfLength, sectorShow, sectorLength, sectorLiveColors, sectorFontSize, mode3d, tiltDeg, zExag } + // Mirror the current settings into the ref the rAF loop reads. + // Runs after every render (no dep array) — refs must not be written + // during render itself. + useEffect(() => { + settingsRef.current = { trackWidth, carRadius, sfLength, sectorShow, sectorLength, sectorLiveColors, sectorFontSize, mode3d, tiltDeg, zExag } + }) // Keep latest props accessible in the rAF loop without re-scheduling. // Detect new snapshots (reference change) and update interpolation state. - if (snap !== interpRef.current.currSnap) { - interpRef.current.prevSnap = interpRef.current.currSnap - interpRef.current.prevTs = interpRef.current.currTs - interpRef.current.currSnap = snap - interpRef.current.currTs = performance.now() - } - dataRef.current = { snap, playerCarIdx, standings } + useEffect(() => { + if (snap !== interpRef.current.currSnap) { + interpRef.current.prevSnap = interpRef.current.currSnap + interpRef.current.prevTs = interpRef.current.currTs + interpRef.current.currSnap = snap + interpRef.current.currTs = performance.now() + } + dataRef.current = { snap, playerCarIdx, standings } + }, [snap, playerCarIdx, standings]) useEffect(() => { function draw() { @@ -508,7 +515,7 @@ export function TrackMap({ snap, playerCarIdx, info, standings, onDelete }: Prop }, []) function persist(key: string, v: number | boolean) { - try { localStorage.setItem(key, String(v)) } catch {} + try { localStorage.setItem(key, String(v)) } catch { /* localStorage unavailable */ } } function handleResetAll() { @@ -654,12 +661,11 @@ function drawTrackShape(ctx: CanvasRenderingContext2D, cache: ShapeCache, s: Dra ctx.lineWidth = s.trackWidth const pd = cache.pts2d const si = cache.sectorIdxPerPoint - const ns = cache.nSectors const np = pd.length const activeLive = s.sectorLiveColors ? live : null for (let i = 0; i < np; i++) { const j = (i + 1) % np - ctx.strokeStyle = sectorColorAt(si[i], activeLive, ns) + ctx.strokeStyle = sectorColorAt(si[i], activeLive) ctx.beginPath() ctx.moveTo(pd[i][0], pd[i][1]) ctx.lineTo(pd[j][0], pd[j][1]) @@ -703,7 +709,7 @@ function drawTrackShape(ctx: CanvasRenderingContext2D, cache: ShapeCache, s: Dra ctx.lineCap = 'butt' ctx.lineWidth = 2 for (const m of cache.sectorMarkers) { - ctx.strokeStyle = sectorColorAt(m.num - 1, activeLive, cache.nSectors) + ctx.strokeStyle = sectorColorAt(m.num - 1, activeLive) ctx.beginPath() ctx.moveTo(m.x - m.nx * sh, m.y - m.ny * sh) ctx.lineTo(m.x + m.nx * sh, m.y + m.ny * sh) diff --git a/dashboard/src/widgets/TrackMapSettings.tsx b/dashboard/src/widgets/TrackMapSettings.tsx index 2d5e452..fa4cb58 100644 --- a/dashboard/src/widgets/TrackMapSettings.tsx +++ b/dashboard/src/widgets/TrackMapSettings.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState } from 'react' interface Props { trackWidth: number @@ -35,10 +35,9 @@ export function TrackMapSettings({ }: Props) { const [confirmDelete, setConfirmDelete] = useState(false) - // Reset confirm state when the map disappears (delete succeeded or track changed). - useEffect(() => { - if (!hasMap) setConfirmDelete(false) - }, [hasMap]) + // The armed state is only meaningful while a map exists — derived instead + // of being reset via an effect when the map disappears. + const showConfirm = confirmDelete && hasMap return ( <> @@ -130,7 +129,7 @@ export function TrackMapSettings({
{hasMap && ( - confirmDelete ? ( + showConfirm ? (