From 0ba28717b6108b28888d0c11b1584f6165620165 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 17 Sep 2026 11:05:36 +0800 Subject: [PATCH 01/12] feat: establish F1 telemetry foundation --- src/config/settings.rs | 46 ++++ src/core/buffer.rs | 96 ++++--- src/core/collector.rs | 49 +++- src/core/lap_store.rs | 10 + src/core/mod.rs | 5 +- src/core/model.rs | 89 ++++++- src/main.rs | 1 + src/plugins/ams2/ams2_plugin.rs | 19 +- .../assetto_competizione/acc_plugin.rs | 22 +- src/plugins/f1/f1_2025.rs | 170 ++++++++++++ src/plugins/f1/f1_2026.rs | 198 ++++++++++++++ src/plugins/f1/mod.rs | 143 ++++++++++ src/plugins/f1/protocol.rs | 244 ++++++++++++++++++ src/plugins/iracing/iracing_plugin.rs | 21 +- src/plugins/mock/mod.rs | 22 +- src/plugins/mod.rs | 3 +- src/plugins/registry.rs | 23 +- src/plugins/trait_.rs | 31 ++- src/renderer/app.rs | 202 +++++++++++---- src/renderer/trace_graph.rs | 19 +- 20 files changed, 1292 insertions(+), 121 deletions(-) create mode 100644 src/plugins/f1/f1_2025.rs create mode 100644 src/plugins/f1/f1_2026.rs create mode 100644 src/plugins/f1/mod.rs create mode 100644 src/plugins/f1/protocol.rs diff --git a/src/config/settings.rs b/src/config/settings.rs index f044554..519ff60 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -16,6 +16,18 @@ pub struct AppSettings { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CollectorConfig { pub plugin: String, + #[serde(default = "default_f1_bind_address")] + pub f1_bind_address: String, + #[serde(default = "default_f1_udp_port")] + pub f1_udp_port: u16, +} + +fn default_f1_bind_address() -> String { + "0.0.0.0".to_owned() +} + +fn default_f1_udp_port() -> u16 { + 20777 } /// Graph visualization settings. @@ -52,6 +64,24 @@ pub struct GraphSettings { pub lap_comparison_open: bool, pub show_tc: bool, pub show_speed: bool, + #[serde(default = "default_ui_fps")] + pub ui_fps: u32, +} + +fn default_ui_fps() -> u32 { + 60 +} + +impl GraphSettings { + pub const VALID_UI_FPS: [u32; 4] = [30, 60, 90, 120]; + + pub fn validated_ui_fps(&self) -> u32 { + if Self::VALID_UI_FPS.contains(&self.ui_fps) { + self.ui_fps + } else { + default_ui_fps() + } + } } fn default_true() -> bool { @@ -157,6 +187,8 @@ impl Default for AppSettings { Self { collector: CollectorConfig { plugin: "mock".to_string(), + f1_bind_address: default_f1_bind_address(), + f1_udp_port: default_f1_udp_port(), }, graph: GraphSettings { window_seconds: 10.0, @@ -176,6 +208,7 @@ impl Default for AppSettings { lap_comparison_open: false, show_tc: true, show_speed: true, + ui_fps: default_ui_fps(), }, colors: ColorScheme { throttle: "#00FF00".to_string(), @@ -270,6 +303,7 @@ mod tests { assert_eq!(s.collector.plugin, "mock"); assert_eq!(s.overlay.opacity, 1.0); assert_eq!(s.graph.window_seconds, 10.0); + assert_eq!(s.graph.validated_ui_fps(), 60); } #[test] @@ -332,6 +366,7 @@ mod tests { let s: AppSettings = toml::from_str(toml_str).unwrap(); assert_eq!(s.colors.clutch, default_clutch_color()); assert!(!s.graph.speed_mph); + assert_eq!(s.graph.validated_ui_fps(), 60); } #[test] @@ -359,4 +394,15 @@ mod tests { egui::Color32::WHITE ); } + + #[test] + fn render_rate_is_validated() { + let mut settings = AppSettings::default(); + for fps in GraphSettings::VALID_UI_FPS { + settings.graph.ui_fps = fps; + assert_eq!(settings.graph.validated_ui_fps(), fps); + } + settings.graph.ui_fps = 75; + assert_eq!(settings.graph.validated_ui_fps(), 60); + } } diff --git a/src/core/buffer.rs b/src/core/buffer.rs index cd986f2..8bdc972 100644 --- a/src/core/buffer.rs +++ b/src/core/buffer.rs @@ -1,43 +1,60 @@ //! Telemetry buffer - stores telemetry points with a sliding time window #![allow(dead_code)] +use std::collections::VecDeque; use std::sync::RwLock; use std::time::Duration; -use crate::core::{TelemetryPoint, VehicleTelemetry}; +use crate::core::{TelemetryData, TelemetryPoint}; + +const EXPECTED_MAX_SAMPLE_RATE_HZ: usize = 240; +const MIN_POINTS: usize = 10; /// Buffer storing telemetry points with a configurable time window pub struct TelemetryBuffer { /// Maximum time window to keep window_duration: Duration, /// Stored telemetry points - data: RwLock>, + data: RwLock>, /// Minimum points to keep (prevents empty buffer) min_points: usize, + max_points: usize, } impl TelemetryBuffer { /// Create a new buffer with the specified time window pub fn new(window_duration: Duration) -> Self { + let max_points = ((window_duration.as_secs_f64() * EXPECTED_MAX_SAMPLE_RATE_HZ as f64) + .ceil() as usize) + .max(MIN_POINTS); + Self::with_capacity(window_duration, max_points) + } + + fn with_capacity(window_duration: Duration, max_points: usize) -> Self { Self { window_duration, - data: RwLock::new(Vec::with_capacity(1000)), - min_points: 10, + data: RwLock::new(VecDeque::with_capacity(max_points)), + min_points: MIN_POINTS.min(max_points), + max_points, } } /// Push a new telemetry point - pub fn push(&self, telemetry: VehicleTelemetry, abs_active: bool) { - let point = TelemetryPoint::new(telemetry, abs_active); + pub fn push(&self, telemetry: TelemetryData) -> TelemetryPoint { + let point = TelemetryPoint::new(telemetry); let mut data = self.data.write().unwrap(); - data.push(point); + data.push_back(point.clone()); self.prune_old_points(&mut data); + while data.len() > self.max_points { + data.pop_front(); + } + point } /// Get all points within the current time window pub fn get_points(&self) -> Vec { let data = self.data.read().unwrap(); - data.clone() + data.iter().cloned().collect() } /// Get points for a specific time range @@ -55,7 +72,7 @@ impl TelemetryBuffer { /// Get the latest point pub fn latest(&self) -> Option { let data = self.data.read().unwrap(); - data.last().cloned() + data.back().cloned() } /// Clear all data @@ -79,21 +96,14 @@ impl TelemetryBuffer { } /// Remove old points outside the time window - fn prune_old_points(&self, data: &mut Vec) { + fn prune_old_points(&self, data: &mut VecDeque) { let now = std::time::Instant::now(); let cutoff = now - self.window_duration; - // Find the first point within the window - let keep_from = data - .iter() - .position(|p| p.captured_at >= cutoff) - .unwrap_or(data.len()); - - // Keep at least min_points even if they're outside the window - let keep_from = keep_from.min(data.len().saturating_sub(self.min_points)); - - if keep_from > 0 { - data.drain(..keep_from); + while data.len() > self.min_points + && data.front().is_some_and(|point| point.captured_at < cutoff) + { + data.pop_front(); } } } @@ -109,16 +119,22 @@ mod tests { use super::*; use std::thread; + fn data(throttle: f32) -> TelemetryData { + TelemetryData { + timestamp: 0, + vehicle: crate::core::VehicleTelemetry { + throttle, + ..Default::default() + }, + session: None, + source: Default::default(), + } + } + #[test] fn test_push_and_get() { let buffer = TelemetryBuffer::new(Duration::from_secs(10)); - buffer.push( - VehicleTelemetry { - throttle: 0.5, - ..Default::default() - }, - false, - ); + buffer.push(data(0.5)); assert_eq!(buffer.len(), 1); assert_eq!(buffer.get_points()[0].telemetry.throttle, 0.5); } @@ -127,7 +143,7 @@ mod tests { fn test_latest() { let buffer = TelemetryBuffer::new(Duration::from_secs(10)); assert!(buffer.latest().is_none()); - buffer.push(VehicleTelemetry::default(), false); + buffer.push(data(0.0)); assert!(buffer.latest().is_some()); } @@ -135,7 +151,7 @@ mod tests { fn test_clear_empties_buffer() { let buffer = TelemetryBuffer::new(Duration::from_secs(10)); for _ in 0..5 { - buffer.push(VehicleTelemetry::default(), false); + buffer.push(data(0.0)); } assert_eq!(buffer.len(), 5); buffer.clear(); @@ -158,15 +174,15 @@ mod tests { let min_points = 10; for _ in 0..(min_points + 5) { - buffer.push(VehicleTelemetry::default(), false); + buffer.push(data(0.0)); } - assert_eq!(buffer.len(), min_points + 5); + assert!((min_points..=min_points + 5).contains(&buffer.len())); // Wait for all existing points to fall outside the window. thread::sleep(Duration::from_millis(100)); // A new push triggers pruning; the min_points floor should keep old entries. - buffer.push(VehicleTelemetry::default(), false); + buffer.push(data(0.0)); assert!( buffer.len() >= min_points, "expected at least {} points, got {}", @@ -182,12 +198,22 @@ mod tests { // Overfill the buffer, then wait for points to age out. for _ in 0..(min_points * 3) { - buffer.push(VehicleTelemetry::default(), false); + buffer.push(data(0.0)); } thread::sleep(Duration::from_millis(100)); // After a push, expired entries should be pruned down to min_points. - buffer.push(VehicleTelemetry::default(), false); + buffer.push(data(0.0)); assert!(buffer.len() <= min_points + 1); // +1 for the point just pushed } + + #[test] + fn capacity_is_strictly_bounded() { + let buffer = TelemetryBuffer::with_capacity(Duration::from_secs(60), 16); + for i in 0..100 { + buffer.push(data(i as f32)); + } + assert_eq!(buffer.len(), 16); + assert_eq!(buffer.get_points()[0].telemetry.throttle, 84.0); + } } diff --git a/src/core/collector.rs b/src/core/collector.rs index 054cc5a..7a17ca1 100644 --- a/src/core/collector.rs +++ b/src/core/collector.rs @@ -1,15 +1,16 @@ //! Polls the active game plugin and feeds telemetry into the shared buffer. -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tracing::{error, info}; -use crate::core::TelemetryBuffer; -use crate::plugins::PluginRegistry; +use crate::core::{LapStore, TelemetryBuffer}; +use crate::plugins::{PluginRegistry, ProviderConfig}; /// How long to wait between reconnection attempts. const RECONNECT_INTERVAL: Duration = Duration::from_secs(1); +const MAX_SAMPLES_PER_POLL: usize = 256; /// Owns the plugin registry and the telemetry buffer. pub struct DataCollector { @@ -17,6 +18,7 @@ pub struct DataCollector { plugin_registry: PluginRegistry, /// Tracks when we last attempted a reconnect so we don't spam the game. last_connect_attempt: Option, + analysis: Arc>, } impl DataCollector { @@ -27,16 +29,21 @@ impl DataCollector { ))), plugin_registry: PluginRegistry::new(), last_connect_attempt: None, + analysis: Arc::new(Mutex::new(LapStore::new())), } } + pub fn analysis(&self) -> Arc> { + self.analysis.clone() + } + pub fn buffer(&self) -> Arc { self.buffer.clone() } - pub fn activate_plugin(&mut self, name: &str) -> anyhow::Result<()> { + pub fn activate_plugin(&mut self, name: &str, config: &ProviderConfig) -> anyhow::Result<()> { self.last_connect_attempt = None; - self.plugin_registry.activate(name)?; + self.plugin_registry.activate(name, config)?; info!("Activated plugin: {}", name); Ok(()) } @@ -70,12 +77,13 @@ impl DataCollector { // Connected — read telemetry. self.last_connect_attempt = None; if let Some(plugin) = self.plugin_registry.active_plugin_mut() { - match plugin.read_telemetry() { - Ok(Some(data)) => { - let abs_active = data.vehicle.abs_active; - self.buffer.push(data.vehicle, abs_active); + match plugin.read_telemetry_batch(MAX_SAMPLES_PER_POLL) { + Ok(samples) => { + for data in samples { + let point = self.buffer.push(data); + self.analysis.lock().unwrap().push(&point); + } } - Ok(None) => {} Err(e) => error!("Error reading telemetry: {}", e), } } @@ -95,15 +103,32 @@ mod tests { #[test] fn test_activate_mock_plugin() { let mut collector = DataCollector::new(10); - collector.activate_plugin("mock").unwrap(); + collector + .activate_plugin("mock", &ProviderConfig::default()) + .unwrap(); } #[test] fn test_poll_populates_buffer() { let mut collector = DataCollector::new(10); - collector.activate_plugin("mock").unwrap(); + collector + .activate_plugin("mock", &ProviderConfig::default()) + .unwrap(); assert!(collector.buffer().is_empty()); collector.poll(); assert_eq!(collector.buffer().len(), 1); } + + #[test] + fn analysis_ingests_without_ui_render() { + let mut collector = DataCollector::new(10); + collector + .activate_plugin("mock", &ProviderConfig::default()) + .unwrap(); + for _ in 0..25 { + collector.poll(); + } + assert_eq!(collector.buffer().len(), 25); + assert_eq!(collector.analysis().lock().unwrap().sample_count(), 25); + } } diff --git a/src/core/lap_store.rs b/src/core/lap_store.rs index f904789..718a0ee 100644 --- a/src/core/lap_store.rs +++ b/src/core/lap_store.rs @@ -34,11 +34,21 @@ impl LapStore { Self::default() } + pub fn sample_count(&self) -> usize { + self.current_lap.len() + } + /// Push the latest telemetry point. /// /// When `track_position` crosses the start/finish line (wraps from near /// 1.0 back to near 0.0), the completed lap is saved as the reference. pub fn push(&mut self, pt: &TelemetryPoint) { + if pt.source.discontinuity { + self.clear(); + } + if !pt.telemetry.capabilities.track_position { + return; + } // `buffer.latest()` returns the same point every frame until a new one // arrives, so we skip duplicates by comparing captured_at instants. if self.last_captured_at == Some(pt.captured_at) { diff --git a/src/core/mod.rs b/src/core/mod.rs index ada1ac2..9871236 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -8,4 +8,7 @@ pub mod model; pub use buffer::TelemetryBuffer; pub use collector::DataCollector; pub use lap_store::LapStore; -pub use model::{TelemetryData, TelemetryPoint, VehicleTelemetry}; +pub use model::{ + DriverAssistStatus, SourceMetadata, TelemetryCapabilities, TelemetryData, TelemetryPoint, + VehicleTelemetry, +}; diff --git a/src/core/model.rs b/src/core/model.rs index 1408adb..5ff499a 100644 --- a/src/core/model.rs +++ b/src/core/model.rs @@ -4,6 +4,48 @@ use serde::{Deserialize, Serialize}; use std::time::Instant; +/// Declares which normalized channels contain real provider data. +/// +/// Numeric fields remain available for existing renderers, while this mask makes +/// an unsupported channel distinct from a legitimate zero/false value. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct TelemetryCapabilities { + pub throttle: bool, + pub brake: bool, + pub clutch: bool, + pub steering_input: bool, + pub wheel_angle_degrees: bool, + pub speed: bool, + pub gear: bool, + pub rpm: bool, + pub abs_activity: bool, + pub tc_activity: bool, + pub handbrake: bool, + pub track_position: bool, + pub wheel_slip: bool, +} + +/// Status reported by the game, distinct from momentary ABS/TC intervention. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct DriverAssistStatus { + pub abs_enabled: Option, + /// F1 convention: 0 = off, 1 = medium, 2 = full. + pub traction_control_level: Option, +} + +/// Optional packet-origin metadata. `captured_at` on [`TelemetryPoint`] remains +/// the authoritative local monotonic receipt time. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq)] +pub struct SourceMetadata { + #[serde(default)] + pub protocol_format: Option, + pub session_uid: Option, + pub frame_identifier: Option, + pub overall_frame_identifier: Option, + pub packet_time_seconds: Option, + pub discontinuity: bool, +} + /// Main telemetry data structure returned by game plugins #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TelemetryData { @@ -13,6 +55,8 @@ pub struct TelemetryData { pub vehicle: VehicleTelemetry, /// Session information (optional, for future features) pub session: Option, + #[serde(default)] + pub source: SourceMetadata, } /// Normalized vehicle telemetry @@ -24,8 +68,13 @@ pub struct VehicleTelemetry { pub brake: f32, /// Clutch position: 0.0 (no clutch) to 1.0 (full clutch) pub clutch: f32, - /// Steering angle in degrees (negative = left, positive = right) + /// Legacy renderer steering angle. Check `wheel_angle_degrees` and its + /// capability before treating this as a physical wheel angle. pub steering_angle: f32, + /// Normalized steering input: -1.0 (left) to 1.0 (right). + pub steering_input: f32, + /// Physical wheel angle when a provider exposes it directly. + pub wheel_angle_degrees: Option, /// Vehicle speed in m/s pub speed: f32, /// Current gear (1-7, 0 = neutral, -1 = reverse) @@ -38,6 +87,12 @@ pub struct VehicleTelemetry { pub tc_active: bool, /// Track position: 0.0 to 1.0 along the track pub track_position: f32, + /// Handbrake input: 0.0 to 1.0. + pub handbrake: f32, + /// Provider-defined normalized wheel slip, RL/RR/FL/FR when available. + pub wheel_slip: Option<[f32; 4]>, + pub assists: DriverAssistStatus, + pub capabilities: TelemetryCapabilities, } /// Session information (optional) @@ -66,14 +121,17 @@ pub struct TelemetryPoint { pub telemetry: VehicleTelemetry, /// ABS state at capture time (persisted for coloring) pub abs_active: bool, + pub source: SourceMetadata, } impl TelemetryPoint { - pub fn new(telemetry: VehicleTelemetry, abs_active: bool) -> Self { + pub fn new(data: TelemetryData) -> Self { + let abs_active = data.vehicle.abs_active; Self { captured_at: Instant::now(), - telemetry, + telemetry: data.vehicle, abs_active, + source: data.source, } } } @@ -93,6 +151,13 @@ impl VehicleTelemetry { pub fn max_pedal(&self) -> f32 { self.throttle.max(self.brake) } + + /// Steering angle for existing visualizations. Providers with a physical + /// angle use it; normalized-only providers use the configured half-lock. + pub fn effective_steering_degrees(&self, half_lock_degrees: f32) -> f32 { + self.wheel_angle_degrees + .unwrap_or(self.steering_input.clamp(-1.0, 1.0) * half_lock_degrees) + } } #[cfg(test)] @@ -121,4 +186,22 @@ mod tests { }; assert_eq!(telemetry.max_pedal(), 0.7); } + + #[test] + fn unsupported_is_distinct_from_zero() { + let unsupported = VehicleTelemetry::default(); + let supported_zero = VehicleTelemetry { + capabilities: TelemetryCapabilities { + throttle: true, + abs_activity: true, + ..Default::default() + }, + ..Default::default() + }; + assert_eq!(unsupported.throttle, supported_zero.throttle); + assert!(!unsupported.capabilities.throttle); + assert!(supported_zero.capabilities.throttle); + assert!(!unsupported.capabilities.abs_activity); + assert!(supported_zero.capabilities.abs_activity); + } } diff --git a/src/main.rs b/src/main.rs index 8126b54..826c95e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,7 @@ fn main() -> eframe::Result<()> { let viewport = egui::ViewportBuilder::default() .with_inner_size([saved.overlay.width, saved.overlay.height]) + .with_min_inner_size([300.0, 130.0]) .with_position(egui::pos2( saved.overlay.position_x, saved.overlay.position_y, diff --git a/src/plugins/ams2/ams2_plugin.rs b/src/plugins/ams2/ams2_plugin.rs index 2d0c9c8..90be842 100644 --- a/src/plugins/ams2/ams2_plugin.rs +++ b/src/plugins/ams2/ams2_plugin.rs @@ -8,7 +8,7 @@ use crate::plugins::{GameConfig, GamePlugin}; #[cfg(windows)] use super::shared_memory::Ams2SharedMemory; #[cfg(windows)] -use crate::core::VehicleTelemetry; +use crate::core::{TelemetryCapabilities, VehicleTelemetry}; #[cfg(windows)] use tracing::{info, warn}; @@ -127,12 +127,28 @@ impl GamePlugin for Ams2Plugin { brake, clutch, steering_angle, + steering_input: (steering_angle / 450.0).clamp(-1.0, 1.0), + wheel_angle_degrees: Some(steering_angle), speed, gear, rpm: 0.0, // not read to keep offsets simple abs_active, tc_active: false, track_position: 0.0, + handbrake: 0.0, + wheel_slip: None, + assists: Default::default(), + capabilities: TelemetryCapabilities { + throttle: true, + brake: true, + clutch: true, + steering_input: true, + wheel_angle_degrees: true, + speed: true, + gear: true, + abs_activity: true, + ..Default::default() + }, }; let timestamp = std::time::SystemTime::now() @@ -144,6 +160,7 @@ impl GamePlugin for Ams2Plugin { timestamp, vehicle, session: None, + source: Default::default(), })) } #[cfg(not(windows))] diff --git a/src/plugins/assetto_competizione/acc_plugin.rs b/src/plugins/assetto_competizione/acc_plugin.rs index 497ab72..8f25eca 100644 --- a/src/plugins/assetto_competizione/acc_plugin.rs +++ b/src/plugins/assetto_competizione/acc_plugin.rs @@ -5,7 +5,7 @@ use std::f32::consts::PI; use anyhow::Result; use tracing::{info, warn}; -use crate::core::{TelemetryData, VehicleTelemetry}; +use crate::core::{TelemetryCapabilities, TelemetryData, VehicleTelemetry}; use crate::plugins::{GameConfig, GamePlugin}; use super::mapping::{decode_wstring, status}; @@ -85,12 +85,31 @@ impl GamePlugin for AccPlugin { brake: physics.brake.clamp(0.0, 1.0), clutch: physics.clutch.clamp(0.0, 1.0), steering_angle, + steering_input: (steering_angle / 450.0).clamp(-1.0, 1.0), + wheel_angle_degrees: Some(steering_angle), speed: physics.speed_kmh / 3.6, // km/h → m/s gear, rpm: physics.rpms as f32, abs_active: physics.abs > 0.01, tc_active: physics.tc > 0.01, track_position: graphics.normalized_car_position, + handbrake: 0.0, + wheel_slip: None, + assists: Default::default(), + capabilities: TelemetryCapabilities { + throttle: true, + brake: true, + clutch: true, + steering_input: true, + wheel_angle_degrees: true, + speed: true, + gear: true, + rpm: true, + abs_activity: true, + tc_activity: true, + track_position: true, + ..Default::default() + }, }; let timestamp = std::time::SystemTime::now() @@ -102,6 +121,7 @@ impl GamePlugin for AccPlugin { timestamp, vehicle, session: None, + source: Default::default(), })) } diff --git a/src/plugins/f1/f1_2025.rs b/src/plugins/f1/f1_2025.rs new file mode 100644 index 0000000..1879e60 --- /dev/null +++ b/src/plugins/f1/f1_2025.rs @@ -0,0 +1,170 @@ +//! Decoder for the official F1 25 UDP layout (specification v3). + +use anyhow::Result; + +use crate::core::TelemetryData; + +use super::protocol::{self, DecoderState, VehicleFields, HEADER_LEN}; + +pub const PACKET_FORMAT: u16 = 2025; +const GAME_YEAR: u8 = 25; +const MAX_CARS: usize = 22; +const SESSION_PACKET_LEN: usize = 753; +const LAP_PACKET_LEN: usize = 1285; +const CAR_TELEMETRY_PACKET_LEN: usize = 1352; +const CAR_STATUS_PACKET_LEN: usize = 1239; +const LAP_DATA_LEN: usize = 57; +const CAR_TELEMETRY_DATA_LEN: usize = 60; +const CAR_STATUS_DATA_LEN: usize = 55; +const PACKET_SESSION: u8 = 1; +const PACKET_LAP_DATA: u8 = 2; +const PACKET_CAR_TELEMETRY: u8 = 6; +const PACKET_CAR_STATUS: u8 = 7; + +#[derive(Default)] +pub struct Decoder { + state: DecoderState, +} + +impl Decoder { + pub fn decode(&mut self, bytes: &[u8]) -> Result> { + let header = protocol::parse_header(bytes, PACKET_FORMAT, GAME_YEAR, MAX_CARS)?; + self.state.observe_session(header.session_uid); + match header.packet_id { + PACKET_SESSION => { + protocol::require_packet(bytes, SESSION_PACKET_LEN, header.packet_version)?; + self.state + .set_track_length(protocol::read_u16(bytes, HEADER_LEN + 4)? as f32); + Ok(None) + } + PACKET_LAP_DATA => { + protocol::require_packet(bytes, LAP_PACKET_LEN, header.packet_version)?; + let base = protocol::player_offset(header.player_car_index, LAP_DATA_LEN)?; + self.state + .set_lap_distance(protocol::read_f32(bytes, base + 20)?); + Ok(None) + } + PACKET_CAR_STATUS => { + protocol::require_packet(bytes, CAR_STATUS_PACKET_LEN, header.packet_version)?; + let base = protocol::player_offset(header.player_car_index, CAR_STATUS_DATA_LEN)?; + self.state.set_assists( + protocol::read_u8(bytes, base)?, + protocol::read_u8(bytes, base + 1)?, + )?; + Ok(None) + } + PACKET_CAR_TELEMETRY => { + protocol::require_packet(bytes, CAR_TELEMETRY_PACKET_LEN, header.packet_version)?; + let base = + protocol::player_offset(header.player_car_index, CAR_TELEMETRY_DATA_LEN)?; + self.state.make_telemetry( + PACKET_FORMAT, + header, + VehicleFields { + speed_kph: protocol::read_u16(bytes, base)?, + throttle: protocol::read_f32(bytes, base + 2)?, + steer: protocol::read_f32(bytes, base + 6)?, + brake: protocol::read_f32(bytes, base + 10)?, + clutch: protocol::read_u8(bytes, base + 14)?, + gear: protocol::read_i8(bytes, base + 15)?, + rpm: protocol::read_u16(bytes, base + 16)?, + }, + ) + } + _ => Ok(None), + } + } +} + +#[cfg(test)] +pub(super) fn test_telemetry_packet(player: usize, frame: u32) -> Vec { + let mut bytes = test_packet( + PACKET_CAR_TELEMETRY, + CAR_TELEMETRY_PACKET_LEN, + player, + frame, + ); + let base = HEADER_LEN + player * CAR_TELEMETRY_DATA_LEN; + bytes[base..base + 2].copy_from_slice(&288_u16.to_le_bytes()); + bytes[base + 2..base + 6].copy_from_slice(&0.75_f32.to_le_bytes()); + bytes[base + 6..base + 10].copy_from_slice(&(-0.5_f32).to_le_bytes()); + bytes[base + 10..base + 14].copy_from_slice(&0.25_f32.to_le_bytes()); + bytes[base + 14] = 40; + bytes[base + 15] = 7; + bytes[base + 16..base + 18].copy_from_slice(&12_345_u16.to_le_bytes()); + bytes +} + +#[cfg(test)] +fn test_packet(id: u8, len: usize, player: usize, frame: u32) -> Vec { + let mut bytes = vec![0_u8; len]; + bytes[0..2].copy_from_slice(&PACKET_FORMAT.to_le_bytes()); + bytes[2] = GAME_YEAR; + bytes[5] = 1; + bytes[6] = id; + bytes[7..15].copy_from_slice(&1234_u64.to_le_bytes()); + bytes[15..19].copy_from_slice(&12.5_f32.to_le_bytes()); + bytes[19..23].copy_from_slice(&frame.to_le_bytes()); + bytes[23..27].copy_from_slice(&frame.to_le_bytes()); + bytes[27] = player as u8; + bytes[28] = 255; + bytes +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_2025_player_values_and_identity() { + let data = Decoder::default() + .decode(&test_telemetry_packet(3, 7)) + .unwrap() + .unwrap(); + assert_eq!(data.vehicle.speed, 80.0); + assert_eq!(data.vehicle.throttle, 0.75); + assert_eq!(data.vehicle.brake, 0.25); + assert_eq!(data.vehicle.clutch, 0.4); + assert_eq!(data.vehicle.steering_input, -0.5); + assert_eq!(data.vehicle.gear, 7); + assert_eq!(data.vehicle.rpm, 12_345.0); + assert_eq!(data.source.protocol_format, Some(2025)); + } + + #[test] + fn validates_2025_version_length_and_car_limit() { + let mut wrong_version = test_telemetry_packet(0, 1); + wrong_version[5] = 2; + assert!(Decoder::default().decode(&wrong_version).is_err()); + assert!(Decoder::default() + .decode(&test_telemetry_packet(0, 1)[..100]) + .is_err()); + let mut wrong_car = test_telemetry_packet(0, 1); + wrong_car[27] = 22; + assert!(Decoder::default().decode(&wrong_car).is_err()); + } + + #[test] + fn preserves_2025_assists_and_track_position() { + let mut decoder = Decoder::default(); + let mut session = test_packet(PACKET_SESSION, SESSION_PACKET_LEN, 2, 1); + session[HEADER_LEN + 4..HEADER_LEN + 6].copy_from_slice(&5000_u16.to_le_bytes()); + decoder.decode(&session).unwrap(); + let mut lap = test_packet(PACKET_LAP_DATA, LAP_PACKET_LEN, 2, 2); + let lap_base = HEADER_LEN + 2 * LAP_DATA_LEN; + lap[lap_base + 20..lap_base + 24].copy_from_slice(&1250.0_f32.to_le_bytes()); + decoder.decode(&lap).unwrap(); + let mut status = test_packet(PACKET_CAR_STATUS, CAR_STATUS_PACKET_LEN, 2, 3); + let status_base = HEADER_LEN + 2 * CAR_STATUS_DATA_LEN; + status[status_base] = 2; + status[status_base + 1] = 1; + decoder.decode(&status).unwrap(); + let data = decoder + .decode(&test_telemetry_packet(2, 4)) + .unwrap() + .unwrap(); + assert_eq!(data.vehicle.track_position, 0.25); + assert_eq!(data.vehicle.assists.abs_enabled, Some(true)); + assert_eq!(data.vehicle.assists.traction_control_level, Some(2)); + } +} diff --git a/src/plugins/f1/f1_2026.rs b/src/plugins/f1/f1_2026.rs new file mode 100644 index 0000000..e5354d6 --- /dev/null +++ b/src/plugins/f1/f1_2026.rs @@ -0,0 +1,198 @@ +//! Decoder for the official F1 25: 2026 Season Pack UDP layout. + +use anyhow::Result; + +use crate::core::TelemetryData; + +use super::protocol::{self, DecoderState, VehicleFields, HEADER_LEN}; + +pub const PACKET_FORMAT: u16 = 2026; +const GAME_YEAR: u8 = 26; +const MAX_CARS: usize = 24; +const SESSION_PACKET_LEN: usize = 926; +const LAP_PACKET_LEN: usize = 1399; +const CAR_TELEMETRY_PACKET_LEN: usize = 1448; +const CAR_STATUS_PACKET_LEN: usize = 1445; +const LAP_DATA_LEN: usize = 57; +const CAR_TELEMETRY_DATA_LEN: usize = 59; +const CAR_STATUS_DATA_LEN: usize = 59; +const PACKET_SESSION: u8 = 1; +const PACKET_LAP_DATA: u8 = 2; +const PACKET_CAR_TELEMETRY: u8 = 6; +const PACKET_CAR_STATUS: u8 = 7; + +#[derive(Default)] +pub struct Decoder { + state: DecoderState, +} + +impl Decoder { + pub fn decode(&mut self, bytes: &[u8]) -> Result> { + let header = protocol::parse_header(bytes, PACKET_FORMAT, GAME_YEAR, MAX_CARS)?; + self.state.observe_session(header.session_uid); + match header.packet_id { + PACKET_SESSION => { + protocol::require_packet(bytes, SESSION_PACKET_LEN, header.packet_version)?; + self.state + .set_track_length(protocol::read_u16(bytes, HEADER_LEN + 4)? as f32); + Ok(None) + } + PACKET_LAP_DATA => { + protocol::require_packet(bytes, LAP_PACKET_LEN, header.packet_version)?; + let base = protocol::player_offset(header.player_car_index, LAP_DATA_LEN)?; + self.state + .set_lap_distance(protocol::read_f32(bytes, base + 20)?); + Ok(None) + } + PACKET_CAR_STATUS => { + protocol::require_packet(bytes, CAR_STATUS_PACKET_LEN, header.packet_version)?; + let base = protocol::player_offset(header.player_car_index, CAR_STATUS_DATA_LEN)?; + self.state.set_assists( + protocol::read_u8(bytes, base)?, + protocol::read_u8(bytes, base + 1)?, + )?; + Ok(None) + } + PACKET_CAR_TELEMETRY => { + protocol::require_packet(bytes, CAR_TELEMETRY_PACKET_LEN, header.packet_version)?; + let base = + protocol::player_offset(header.player_car_index, CAR_TELEMETRY_DATA_LEN)?; + self.state.make_telemetry( + PACKET_FORMAT, + header, + VehicleFields { + speed_kph: protocol::read_u16(bytes, base)?, + throttle: protocol::read_f32(bytes, base + 2)?, + steer: protocol::read_f32(bytes, base + 6)?, + brake: protocol::read_f32(bytes, base + 10)?, + clutch: protocol::read_u8(bytes, base + 14)?, + gear: protocol::read_i8(bytes, base + 15)?, + rpm: protocol::read_u16(bytes, base + 16)?, + }, + ) + } + _ => Ok(None), + } + } +} + +#[cfg(test)] +pub(super) fn test_telemetry_packet(player: usize, frame: u32) -> Vec { + let mut bytes = test_packet( + PACKET_CAR_TELEMETRY, + CAR_TELEMETRY_PACKET_LEN, + player, + frame, + ); + let base = HEADER_LEN + player * CAR_TELEMETRY_DATA_LEN; + bytes[base..base + 2].copy_from_slice(&288_u16.to_le_bytes()); + bytes[base + 2..base + 6].copy_from_slice(&0.75_f32.to_le_bytes()); + bytes[base + 6..base + 10].copy_from_slice(&(-0.5_f32).to_le_bytes()); + bytes[base + 10..base + 14].copy_from_slice(&0.25_f32.to_le_bytes()); + bytes[base + 14] = 40; + bytes[base + 15] = 7; + bytes[base + 16..base + 18].copy_from_slice(&12_345_u16.to_le_bytes()); + bytes +} + +#[cfg(test)] +fn test_packet(id: u8, len: usize, player: usize, frame: u32) -> Vec { + let mut bytes = vec![0_u8; len]; + bytes[0..2].copy_from_slice(&PACKET_FORMAT.to_le_bytes()); + bytes[2] = GAME_YEAR; + bytes[5] = 1; + bytes[6] = id; + bytes[7..15].copy_from_slice(&5678_u64.to_le_bytes()); + bytes[15..19].copy_from_slice(&12.5_f32.to_le_bytes()); + bytes[19..23].copy_from_slice(&frame.to_le_bytes()); + bytes[23..27].copy_from_slice(&frame.to_le_bytes()); + bytes[27] = player as u8; + bytes[28] = 255; + bytes +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_native_2026_player_values_and_identity() { + let data = Decoder::default() + .decode(&test_telemetry_packet(23, 7)) + .unwrap() + .unwrap(); + assert_eq!(data.vehicle.speed, 80.0); + assert_eq!(data.vehicle.throttle, 0.75); + assert_eq!(data.vehicle.brake, 0.25); + assert_eq!(data.vehicle.clutch, 0.4); + assert_eq!(data.vehicle.steering_input, -0.5); + assert_eq!(data.vehicle.gear, 7); + assert_eq!(data.vehicle.rpm, 12_345.0); + assert_eq!(data.source.protocol_format, Some(2026)); + } + + #[test] + fn validates_2026_version_length_year_and_car_limit() { + let mut wrong_version = test_telemetry_packet(0, 1); + wrong_version[5] = 2; + assert!(Decoder::default().decode(&wrong_version).is_err()); + let mut wrong_year = test_telemetry_packet(0, 1); + wrong_year[2] = 25; + assert!(Decoder::default().decode(&wrong_year).is_err()); + assert!(Decoder::default() + .decode(&test_telemetry_packet(0, 1)[..100]) + .is_err()); + let mut wrong_car = test_telemetry_packet(0, 1); + wrong_car[27] = 24; + assert!(Decoder::default().decode(&wrong_car).is_err()); + } + + #[test] + fn uses_2026_specific_item_sizes_for_player_selection() { + let mut bytes = test_telemetry_packet(5, 8); + bytes[HEADER_LEN + 2..HEADER_LEN + 6].copy_from_slice(&0.1_f32.to_le_bytes()); + let data = Decoder::default().decode(&bytes).unwrap().unwrap(); + assert_eq!(data.vehicle.throttle, 0.75); + } + + #[test] + fn ignores_stale_telemetry_frames() { + let mut decoder = Decoder::default(); + assert!(decoder + .decode(&test_telemetry_packet(0, 10)) + .unwrap() + .is_some()); + assert!(decoder + .decode(&test_telemetry_packet(0, 10)) + .unwrap() + .is_none()); + assert!(decoder + .decode(&test_telemetry_packet(0, 9)) + .unwrap() + .is_none()); + } + + #[test] + fn preserves_2026_assists_and_track_position() { + let mut decoder = Decoder::default(); + let mut session = test_packet(PACKET_SESSION, SESSION_PACKET_LEN, 2, 1); + session[HEADER_LEN + 4..HEADER_LEN + 6].copy_from_slice(&5000_u16.to_le_bytes()); + decoder.decode(&session).unwrap(); + let mut lap = test_packet(PACKET_LAP_DATA, LAP_PACKET_LEN, 2, 2); + let lap_base = HEADER_LEN + 2 * LAP_DATA_LEN; + lap[lap_base + 20..lap_base + 24].copy_from_slice(&1250.0_f32.to_le_bytes()); + decoder.decode(&lap).unwrap(); + let mut status = test_packet(PACKET_CAR_STATUS, CAR_STATUS_PACKET_LEN, 2, 3); + let status_base = HEADER_LEN + 2 * CAR_STATUS_DATA_LEN; + status[status_base] = 2; + status[status_base + 1] = 1; + decoder.decode(&status).unwrap(); + let data = decoder + .decode(&test_telemetry_packet(2, 4)) + .unwrap() + .unwrap(); + assert_eq!(data.vehicle.track_position, 0.25); + assert_eq!(data.vehicle.assists.abs_enabled, Some(true)); + assert_eq!(data.vehicle.assists.traction_control_level, Some(2)); + } +} diff --git a/src/plugins/f1/mod.rs b/src/plugins/f1/mod.rs new file mode 100644 index 0000000..3291030 --- /dev/null +++ b/src/plugins/f1/mod.rs @@ -0,0 +1,143 @@ +//! EA SPORTS F1 UDP provider. +//! +//! The socket transport is shared. Packet identity selects a protocol-specific +//! decoder before any packet layout is interpreted. + +mod f1_2025; +mod f1_2026; +mod protocol; + +use std::io::ErrorKind; +use std::net::UdpSocket; + +use anyhow::{bail, Context, Result}; +use tracing::{debug, info}; + +use crate::core::TelemetryData; +use crate::plugins::{GameConfig, GamePlugin, ProviderConfig}; + +const MAX_DATAGRAMS_PER_DRAIN: usize = 1024; + +#[derive(Default)] +struct ProtocolRouter { + f1_2025: f1_2025::Decoder, + f1_2026: f1_2026::Decoder, +} + +impl ProtocolRouter { + fn decode(&mut self, bytes: &[u8]) -> Result> { + match protocol::packet_format(bytes)? { + f1_2025::PACKET_FORMAT => self.f1_2025.decode(bytes), + f1_2026::PACKET_FORMAT => self.f1_2026.decode(bytes), + format => bail!("unsupported F1 UDP format {format}"), + } + } +} + +pub struct F1Plugin { + bind_address: String, + port: u16, + socket: Option, + router: ProtocolRouter, +} + +impl F1Plugin { + pub fn new(config: &ProviderConfig) -> Self { + Self { + bind_address: config.f1_bind_address.clone(), + port: config.f1_udp_port, + socket: None, + router: ProtocolRouter::default(), + } + } +} + +impl GamePlugin for F1Plugin { + fn name(&self) -> &str { + "EA SPORTS F1 (UDP 2025/2026)" + } + + fn connect(&mut self) -> Result<()> { + let socket = + UdpSocket::bind((self.bind_address.as_str(), self.port)).with_context(|| { + format!( + "cannot bind F1 UDP listener to {}:{}", + self.bind_address, self.port + ) + })?; + socket.set_nonblocking(true)?; + info!(address = %self.bind_address, port = self.port, "F1 UDP listener ready"); + self.socket = Some(socket); + Ok(()) + } + + fn disconnect(&mut self) { + self.socket = None; + self.router = ProtocolRouter::default(); + } + + fn is_connected(&self) -> bool { + self.socket.is_some() + } + + fn read_telemetry(&mut self) -> Result> { + Ok(self.read_telemetry_batch(1)?.into_iter().next()) + } + + fn read_telemetry_batch(&mut self, max_samples: usize) -> Result> { + let Some(socket) = self.socket.as_ref() else { + return Ok(Vec::new()); + }; + let mut datagram = [0_u8; 2048]; + let mut samples = Vec::new(); + for _ in 0..MAX_DATAGRAMS_PER_DRAIN { + if samples.len() >= max_samples { + break; + } + match socket.recv_from(&mut datagram) { + Ok((len, _source)) => match self.router.decode(&datagram[..len]) { + Ok(Some(sample)) => samples.push(sample), + Ok(None) => {} + Err(error) => { + debug!(%error, packet_len = len, "discarded invalid F1 UDP packet") + } + }, + Err(error) if error.kind() == ErrorKind::WouldBlock => break, + Err(error) => return Err(error.into()), + } + } + Ok(samples) + } + + fn get_config(&self) -> GameConfig { + GameConfig { + max_steering_angle: 180.0, + pedal_deadzone: 0.0, + abs_threshold: 0.0, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn router_selects_each_protocol_by_packet_format() { + let mut router = ProtocolRouter::default(); + let p25 = f1_2025::test_telemetry_packet(2, 10); + let p26 = f1_2026::test_telemetry_packet(3, 10); + let d25 = router.decode(&p25).unwrap().unwrap(); + let d26 = router.decode(&p26).unwrap().unwrap(); + assert_eq!(d25.source.protocol_format, Some(2025)); + assert_eq!(d26.source.protocol_format, Some(2026)); + assert_eq!(d25.vehicle.throttle, d26.vehicle.throttle); + } + + #[test] + fn router_rejects_unknown_protocol_before_layout_decode() { + let mut bytes = vec![0_u8; 29]; + bytes[0..2].copy_from_slice(&2024_u16.to_le_bytes()); + assert!(ProtocolRouter::default().decode(&bytes).is_err()); + } +} diff --git a/src/plugins/f1/protocol.rs b/src/plugins/f1/protocol.rs new file mode 100644 index 0000000..d898719 --- /dev/null +++ b/src/plugins/f1/protocol.rs @@ -0,0 +1,244 @@ +use anyhow::{bail, Context, Result}; + +use crate::core::{ + DriverAssistStatus, SourceMetadata, TelemetryCapabilities, TelemetryData, VehicleTelemetry, +}; + +pub const HEADER_LEN: usize = 29; + +#[derive(Debug, Clone, Copy)] +pub struct Header { + pub packet_version: u8, + pub packet_id: u8, + pub session_uid: u64, + pub session_time: f32, + pub frame_identifier: u32, + pub overall_frame_identifier: u32, + pub player_car_index: usize, +} + +#[derive(Default)] +pub struct DecoderState { + session_uid: Option, + pending_discontinuity: bool, + track_length_m: Option, + player_lap_distance_m: Option, + assists: DriverAssistStatus, + assists_known: bool, + last_telemetry_frame: Option, +} + +pub struct VehicleFields { + pub speed_kph: u16, + pub throttle: f32, + pub steer: f32, + pub brake: f32, + pub clutch: u8, + pub gear: i8, + pub rpm: u16, +} + +impl DecoderState { + pub fn observe_session(&mut self, uid: u64) { + if self.session_uid.is_some_and(|previous| previous != uid) { + self.pending_discontinuity = true; + self.track_length_m = None; + self.player_lap_distance_m = None; + self.assists = DriverAssistStatus::default(); + self.assists_known = false; + self.last_telemetry_frame = None; + } + self.session_uid = Some(uid); + } + + pub fn set_track_length(&mut self, length: f32) { + self.track_length_m = (length > 0.0).then_some(length); + } + + pub fn set_lap_distance(&mut self, distance: f32) { + self.player_lap_distance_m = distance.is_finite().then_some(distance); + } + + pub fn set_assists(&mut self, tc: u8, abs: u8) -> Result<()> { + if tc > 2 || abs > 1 { + bail!("invalid F1 assist status"); + } + self.assists = DriverAssistStatus { + abs_enabled: Some(abs == 1), + traction_control_level: Some(tc), + }; + self.assists_known = true; + Ok(()) + } + + pub fn make_telemetry( + &mut self, + format: u16, + header: Header, + fields: VehicleFields, + ) -> Result> { + if self + .last_telemetry_frame + .is_some_and(|last| header.overall_frame_identifier <= last) + { + return Ok(None); + } + if !fields.throttle.is_finite() || !fields.steer.is_finite() || !fields.brake.is_finite() { + bail!("non-finite F1 telemetry input"); + } + let track_position = match (self.player_lap_distance_m, self.track_length_m) { + (Some(distance), Some(length)) if length > 0.0 => { + Some((distance / length).rem_euclid(1.0)) + } + _ => None, + }; + self.last_telemetry_frame = Some(header.overall_frame_identifier); + let discontinuity = std::mem::take(&mut self.pending_discontinuity); + + Ok(Some(TelemetryData { + timestamp: (header.session_time.max(0.0) * 1000.0) as u64, + vehicle: VehicleTelemetry { + throttle: fields.throttle.clamp(0.0, 1.0), + brake: fields.brake.clamp(0.0, 1.0), + clutch: (fields.clutch as f32 / 100.0).clamp(0.0, 1.0), + steering_angle: fields.steer.clamp(-1.0, 1.0) * 180.0, + steering_input: fields.steer.clamp(-1.0, 1.0), + wheel_angle_degrees: None, + speed: fields.speed_kph as f32 / 3.6, + gear: fields.gear as i32, + rpm: fields.rpm as f32, + abs_active: false, + tc_active: false, + track_position: track_position.unwrap_or(0.0), + handbrake: 0.0, + wheel_slip: None, + assists: self + .assists_known + .then_some(self.assists) + .unwrap_or_default(), + capabilities: TelemetryCapabilities { + throttle: true, + brake: true, + clutch: true, + steering_input: true, + speed: true, + gear: true, + rpm: true, + track_position: track_position.is_some(), + ..Default::default() + }, + }, + session: None, + source: SourceMetadata { + protocol_format: Some(format), + session_uid: Some(header.session_uid), + frame_identifier: Some(header.frame_identifier), + overall_frame_identifier: Some(header.overall_frame_identifier), + packet_time_seconds: Some(header.session_time), + discontinuity, + }, + })) + } +} + +pub fn packet_format(bytes: &[u8]) -> Result { + read_u16(bytes, 0).context("F1 packet missing format identifier") +} + +/// Both current official specifications define this exact 29-byte header. +/// Variant decoders still supply and validate their own identity and car limit. +pub fn parse_header( + bytes: &[u8], + expected_format: u16, + expected_game_year: u8, + max_cars: usize, +) -> Result
{ + if bytes.len() < HEADER_LEN { + bail!("F1 packet shorter than header"); + } + let format = packet_format(bytes)?; + if format != expected_format { + bail!("wrong F1 UDP format {format}; expected {expected_format}"); + } + let game_year = bytes[2]; + if game_year != 0 && game_year != expected_game_year { + bail!("wrong F1 game year {game_year}; expected {expected_game_year}"); + } + let player_car_index = bytes[27] as usize; + if player_car_index >= max_cars { + bail!("invalid F1 player car index {player_car_index}"); + } + Ok(Header { + packet_version: bytes[5], + packet_id: bytes[6], + session_uid: read_u64(bytes, 7)?, + session_time: read_f32(bytes, 15)?, + frame_identifier: read_u32(bytes, 19)?, + overall_frame_identifier: read_u32(bytes, 23)?, + player_car_index, + }) +} + +pub fn require_packet(bytes: &[u8], expected_len: usize, version: u8) -> Result<()> { + if version != 1 { + bail!("unsupported F1 packet version {version}"); + } + if bytes.len() != expected_len { + bail!( + "wrong F1 packet length {}; expected {expected_len}", + bytes.len() + ); + } + Ok(()) +} + +pub fn player_offset(index: usize, item_len: usize) -> Result { + index + .checked_mul(item_len) + .and_then(|offset| HEADER_LEN.checked_add(offset)) + .context("F1 player data offset overflow") +} + +pub fn read_u8(bytes: &[u8], offset: usize) -> Result { + bytes.get(offset).copied().context("truncated F1 u8") +} + +pub fn read_i8(bytes: &[u8], offset: usize) -> Result { + Ok(read_u8(bytes, offset)? as i8) +} + +pub fn read_u16(bytes: &[u8], offset: usize) -> Result { + Ok(u16::from_le_bytes( + bytes + .get(offset..offset + 2) + .context("truncated F1 u16")? + .try_into()?, + )) +} + +pub fn read_u32(bytes: &[u8], offset: usize) -> Result { + Ok(u32::from_le_bytes( + bytes + .get(offset..offset + 4) + .context("truncated F1 u32")? + .try_into()?, + )) +} + +pub fn read_u64(bytes: &[u8], offset: usize) -> Result { + Ok(u64::from_le_bytes( + bytes + .get(offset..offset + 8) + .context("truncated F1 u64")? + .try_into()?, + )) +} + +pub fn read_f32(bytes: &[u8], offset: usize) -> Result { + Ok(f32::from_le_bytes( + bytes + .get(offset..offset + 4) + .context("truncated F1 f32")? + .try_into()?, + )) +} diff --git a/src/plugins/iracing/iracing_plugin.rs b/src/plugins/iracing/iracing_plugin.rs index 31c9b4b..f37ec44 100644 --- a/src/plugins/iracing/iracing_plugin.rs +++ b/src/plugins/iracing/iracing_plugin.rs @@ -8,7 +8,7 @@ use crate::plugins::{GameConfig, GamePlugin}; #[cfg(windows)] use super::shared_memory::IracingSharedMemory; #[cfg(windows)] -use crate::core::VehicleTelemetry; +use crate::core::{TelemetryCapabilities, VehicleTelemetry}; #[cfg(windows)] use std::f32::consts::PI; #[cfg(windows)] @@ -129,12 +129,30 @@ impl GamePlugin for IracingPlugin { brake, clutch, steering_angle, + steering_input: (steering_angle / 450.0).clamp(-1.0, 1.0), + wheel_angle_degrees: Some(steering_angle), speed, gear, rpm, abs_active, tc_active: false, track_position, + handbrake: 0.0, + wheel_slip: None, + assists: Default::default(), + capabilities: TelemetryCapabilities { + throttle: true, + brake: true, + clutch: true, + steering_input: true, + wheel_angle_degrees: true, + speed: true, + gear: true, + rpm: true, + abs_activity: true, + track_position: true, + ..Default::default() + }, }; let timestamp = std::time::SystemTime::now() @@ -146,6 +164,7 @@ impl GamePlugin for IracingPlugin { timestamp, vehicle, session: None, + source: Default::default(), })) } #[cfg(not(windows))] diff --git a/src/plugins/mock/mod.rs b/src/plugins/mock/mod.rs index 4902712..f3e28cd 100644 --- a/src/plugins/mock/mod.rs +++ b/src/plugins/mock/mod.rs @@ -2,7 +2,7 @@ use anyhow::Result; -use crate::core::{TelemetryData, VehicleTelemetry}; +use crate::core::{TelemetryCapabilities, TelemetryData, VehicleTelemetry}; use crate::plugins::{GameConfig, GamePlugin}; /// Simulated telemetry plugin (always available, no game required) @@ -122,12 +122,31 @@ impl MockPlugin { brake: brake.clamp(0.0, 1.0), clutch: clutch.clamp(0.0, 1.0), steering_angle, + steering_input: (steering_angle / 900.0).clamp(-1.0, 1.0), + wheel_angle_degrees: Some(steering_angle), speed: speed / 3.6, gear, rpm, abs_active, tc_active: false, track_position: (t * 0.01).fract(), + handbrake: 0.0, + wheel_slip: None, + assists: Default::default(), + capabilities: TelemetryCapabilities { + throttle: true, + brake: true, + clutch: true, + steering_input: true, + wheel_angle_degrees: true, + speed: true, + gear: true, + rpm: true, + abs_activity: true, + tc_activity: true, + track_position: true, + ..Default::default() + }, } } } @@ -171,6 +190,7 @@ impl GamePlugin for MockPlugin { timestamp: (self.simulation_time * 1000.0) as u64, vehicle: telemetry, session: None, + source: Default::default(), })) } diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index 7d0ad33..4241f67 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -2,10 +2,11 @@ pub mod ams2; pub mod assetto_competizione; +pub mod f1; pub mod iracing; pub mod mock; pub mod registry; pub mod trait_; pub use registry::PluginRegistry; -pub use trait_::{create_plugin, plugin_entries, GameConfig, GamePlugin}; +pub use trait_::{create_plugin, plugin_entries, GameConfig, GamePlugin, ProviderConfig}; diff --git a/src/plugins/registry.rs b/src/plugins/registry.rs index ac4e186..5ef1d64 100644 --- a/src/plugins/registry.rs +++ b/src/plugins/registry.rs @@ -1,7 +1,7 @@ //! Plugin registry for managing available game plugins. #![allow(dead_code)] -use crate::plugins::GamePlugin; +use crate::plugins::{GamePlugin, ProviderConfig}; use anyhow::Result; use tracing::info; @@ -30,6 +30,7 @@ impl PluginRegistry { "assetto_competizione".to_string(), "ams2".to_string(), "iracing".to_string(), + "f1".to_string(), ]; #[cfg(not(windows))] @@ -43,13 +44,13 @@ impl PluginRegistry { /// Activate a plugin by name. Registers the plugin even if the initial /// connection fails — the collector will retry automatically. - pub fn activate(&mut self, name: &str) -> Result<()> { + pub fn activate(&mut self, name: &str, config: &ProviderConfig) -> Result<()> { if let Some(ref mut plugin) = self.active_plugin { plugin.disconnect(); } self.active_plugin = None; - let mut plugin = crate::plugins::create_plugin(name) + let mut plugin = crate::plugins::create_plugin(name, config) .ok_or_else(|| anyhow::anyhow!("Plugin '{}' not found", name))?; if let Err(e) = plugin.connect() { @@ -109,23 +110,31 @@ mod tests { fn test_activate_mock_plugin_connects() { let mut registry = PluginRegistry::new(); assert!(!registry.is_connected()); - registry.activate("mock").unwrap(); + registry + .activate("mock", &ProviderConfig::default()) + .unwrap(); assert!(registry.is_connected()); } #[test] fn test_activate_unknown_plugin_errors() { let mut registry = PluginRegistry::new(); - assert!(registry.activate("does_not_exist").is_err()); + assert!(registry + .activate("does_not_exist", &ProviderConfig::default()) + .is_err()); } #[test] fn test_activate_replaces_previous_plugin() { let mut registry = PluginRegistry::new(); - registry.activate("mock").unwrap(); + registry + .activate("mock", &ProviderConfig::default()) + .unwrap(); assert!(registry.is_connected()); // Activating again replaces the existing plugin. - registry.activate("mock").unwrap(); + registry + .activate("mock", &ProviderConfig::default()) + .unwrap(); assert!(registry.is_connected()); } } diff --git a/src/plugins/trait_.rs b/src/plugins/trait_.rs index fc86152..01b622c 100644 --- a/src/plugins/trait_.rs +++ b/src/plugins/trait_.rs @@ -16,6 +16,22 @@ pub struct GameConfig { pub abs_threshold: f32, } +/// Runtime settings shared by plugins that require user configuration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderConfig { + pub f1_bind_address: String, + pub f1_udp_port: u16, +} + +impl Default for ProviderConfig { + fn default() -> Self { + Self { + f1_bind_address: "0.0.0.0".to_owned(), + f1_udp_port: 20777, + } + } +} + /// Trait that all game plugins must implement pub trait GamePlugin: Send + Sync { /// Get the plugin name @@ -34,6 +50,15 @@ pub trait GamePlugin: Send + Sync { /// Returns None if no data available yet fn read_telemetry(&mut self) -> Result>; + /// Drain a bounded batch. UDP providers override this so collection is not + /// limited to one datagram per collector tick. + fn read_telemetry_batch(&mut self, max_samples: usize) -> Result> { + if max_samples == 0 { + return Ok(Vec::new()); + } + Ok(self.read_telemetry()?.into_iter().collect()) + } + /// Get game-specific configuration fn get_config(&self) -> GameConfig { GameConfig::default() @@ -53,12 +78,13 @@ pub fn plugin_entries() -> &'static [(&'static str, &'static str)] { ("assetto_competizione", "Assetto Corsa Competizione"), ("ams2", "Automobilista 2"), ("iracing", "iRacing"), + ("f1", "EA SPORTS F1 (UDP 2025/2026)"), ("mock", "Mock (Simulated Data)"), ] } /// Helper function to create a plugin by name -pub fn create_plugin(name: &str) -> Option> { +pub fn create_plugin(name: &str, _config: &ProviderConfig) -> Option> { match name.to_lowercase().as_str() { "assetto_competizione" | "assetto corsa competizione" | "acc" => { #[cfg(windows)] @@ -76,6 +102,9 @@ pub fn create_plugin(name: &str) -> Option> { Some(Box::new(crate::plugins::ams2::Ams2Plugin::new())) } "iracing" | "iracing_sdk" => Some(Box::new(crate::plugins::iracing::IracingPlugin::new())), + "f1" | "f1_25" | "f1 25" | "f1 2026" => { + Some(Box::new(crate::plugins::f1::F1Plugin::new(_config))) + } "mock" | "test" => Some(Box::new(crate::plugins::mock::MockPlugin::new())), _ => None, } diff --git a/src/renderer/app.rs b/src/renderer/app.rs index 4304fa4..67a4ab4 100644 --- a/src/renderer/app.rs +++ b/src/renderer/app.rs @@ -1,10 +1,11 @@ //! Main application use crate::config::{AppSettings, ParsedColors}; -use crate::core::{DataCollector, TelemetryBuffer}; +use crate::core::{DataCollector, LapStore, TelemetryBuffer}; +use crate::plugins::ProviderConfig; use eframe::egui; use egui::color_picker::{color_edit_button_srgba, Alpha}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; // The buffer is kept larger than the maximum display window so the slider can // show the full range without data disappearing at the top. @@ -22,7 +23,7 @@ const STRIP_GAP: f32 = 3.0; // ── Background poller ───────────────────────────────────────────────────────── enum PollerCmd { - ActivatePlugin(String), + ActivatePlugin(String, ProviderConfig), } /// Owns the background polling thread. Dropping this stops the thread. @@ -59,8 +60,10 @@ pub struct SimTraceApp { save_toast: Option, /// Colors pre-parsed from `settings.colors`; re-derived when config changes them. parsed_colors: ParsedColors, - /// Lap boundary detection and per-lap telemetry for comparison. - lap_store: crate::core::LapStore, + /// Analysis state is fed by the collector thread for every accepted sample. + analysis: Arc>, + active_provider_config: ProviderConfig, + last_update_at: std::time::Instant, } impl SimTraceApp { @@ -74,7 +77,8 @@ impl SimTraceApp { let settings = crate::config::AppSettings::load_or_default(); let active_plugin = settings.collector.plugin.clone(); let parsed_colors = ParsedColors::from_scheme(&settings.colors); - let max_steering_angle = crate::plugins::create_plugin(&active_plugin) + let provider_config = provider_config(&settings); + let max_steering_angle = crate::plugins::create_plugin(&active_plugin, &provider_config) .map(|p| p.get_config().max_steering_angle) .unwrap_or(450.0); Self { @@ -92,7 +96,9 @@ impl SimTraceApp { active_plugin, save_toast: None, parsed_colors, - lap_store: crate::core::LapStore::new(), + analysis: Arc::new(Mutex::new(LapStore::new())), + active_provider_config: provider_config, + last_update_at: std::time::Instant::now(), } } @@ -100,20 +106,23 @@ impl SimTraceApp { let mut collector = DataCollector::new(BUFFER_CAPACITY_SECS); // Share the collector's buffer with the UI before moving the collector. self.buffer = collector.buffer(); + self.analysis = collector.analysis(); let (cmd_tx, cmd_rx) = std::sync::mpsc::channel::(); let plugin_name = self.settings.collector.plugin.clone(); + let plugin_config = provider_config(&self.settings); let poll_interval = std::time::Duration::from_micros(1_000_000 / POLL_RATE_HZ); let thread = std::thread::spawn(move || { - let _ = collector.activate_plugin(&plugin_name); + let _ = collector.activate_plugin(&plugin_name, &plugin_config); loop { collector.poll(); loop { match cmd_rx.try_recv() { - Ok(PollerCmd::ActivatePlugin(name)) => { + Ok(PollerCmd::ActivatePlugin(name, config)) => { collector.buffer().clear(); - let _ = collector.activate_plugin(&name); + collector.analysis().lock().unwrap().clear(); + let _ = collector.activate_plugin(&name, &config); } Err(std::sync::mpsc::TryRecvError::Empty) => break, Err(std::sync::mpsc::TryRecvError::Disconnected) => return, @@ -132,16 +141,20 @@ impl SimTraceApp { fn activate_plugin(&mut self) { let plugin = self.settings.collector.plugin.clone(); + let config = provider_config(&self.settings); if let Some(h) = &self.poller { - let _ = h.cmd_tx.send(PollerCmd::ActivatePlugin(plugin.clone())); + let _ = h + .cmd_tx + .send(PollerCmd::ActivatePlugin(plugin.clone(), config.clone())); } self.buffer.clear(); self.current_steering = 0.0; - self.max_steering_angle = crate::plugins::create_plugin(&plugin) + self.max_steering_angle = crate::plugins::create_plugin(&plugin, &config) .map(|p| p.get_config().max_steering_angle) .unwrap_or(450.0); self.active_plugin = plugin; - self.lap_store.clear(); + self.active_provider_config = config; + self.analysis.lock().unwrap().clear(); } } @@ -155,6 +168,12 @@ impl eframe::App for SimTraceApp { } fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + let now = std::time::Instant::now(); + let frame_dt = now + .duration_since(self.last_update_at) + .as_secs_f32() + .min(0.25); + self.last_update_at = now; // Force dark visuals every frame — prevents the OS light theme from // overriding our settings (observed on Windows 11 with light mode). ctx.set_visuals(egui::Visuals { @@ -183,15 +202,18 @@ impl eframe::App for SimTraceApp { if self.running && self.poller.is_none() { self.start(); } - if self.settings.collector.plugin != self.active_plugin { + if self.settings.collector.plugin != self.active_plugin + || provider_config(&self.settings) != self.active_provider_config + { self.activate_plugin(); } // ── Read latest telemetry ──────────────────────────────────────────── if self.running { if let Some(pt) = self.buffer.latest() { - self.current_steering = pt.telemetry.steering_angle; - self.lap_store.push(&pt); + self.current_steering = pt + .telemetry + .effective_steering_degrees(self.max_steering_angle); } } // Clone the Arc so the closure below can take &mut self freely. @@ -201,11 +223,8 @@ impl eframe::App for SimTraceApp { None }; // Clone the reference lap so the closure can read it without borrowing self. - let reference_lap = self.lap_store.reference_lap.clone(); - - ctx.send_viewport_cmd(egui::ViewportCommand::MinInnerSize(egui::vec2( - MIN_WIDTH, MIN_HEIGHT, - ))); + let analysis_snapshot = self.analysis.lock().unwrap().clone(); + let reference_lap = analysis_snapshot.reference_lap.clone(); egui::CentralPanel::default() .frame(egui::Frame::NONE) @@ -229,11 +248,9 @@ impl eframe::App for SimTraceApp { 0.0_f32 }; // Fast in, slow out - let speed = if target > self.bar_alpha { 0.18 } else { 0.06 }; - self.bar_alpha += (target - self.bar_alpha) * speed; - if (self.bar_alpha - target).abs() > 0.005 { - ctx.request_repaint(); // keep animating - } + let rate = if target > self.bar_alpha { 16.0 } else { 5.0 }; + let blend = 1.0 - (-rate * frame_dt).exp(); + self.bar_alpha += (target - self.bar_alpha) * blend; let ba = (a as f32 * self.bar_alpha) as u8; // ── Title bar — same width as content card ─────────────────── @@ -524,7 +541,7 @@ impl eframe::App for SimTraceApp { &mut self.running, &mut self.save_toast, buffer.as_ref(), - &mut self.lap_store, + &self.analysis, ); }); // Re-derive parsed colors in case the color pickers changed them. @@ -597,10 +614,9 @@ impl eframe::App for SimTraceApp { // ── Lap comparison viewport ─────────────────────────────────────────── if self.settings.graph.lap_comparison_open { - let reference = self.lap_store.reference_lap.clone(); - let current = self.lap_store.current_lap().to_vec(); - let current_track_pos = self - .lap_store + let reference = analysis_snapshot.reference_lap.clone(); + let current = analysis_snapshot.current_lap().to_vec(); + let current_track_pos = analysis_snapshot .current_lap() .last() .map(|p| p.track_position) @@ -655,6 +671,7 @@ impl eframe::App for SimTraceApp { let _ = self.settings.save_to_config_path(); } } + ctx.request_repaint_after(render_interval(&self.settings)); } } @@ -676,14 +693,20 @@ fn draw_telemetry( let available = ui.available_rect_before_wrap(); let latest = buffer.and_then(|b| b.latest()); + let capabilities = latest + .as_ref() + .map(|point| point.telemetry.capabilities) + .unwrap_or_default(); let throttle = latest.as_ref().map(|p| p.telemetry.throttle).unwrap_or(0.0); let brake = latest.as_ref().map(|p| p.telemetry.brake).unwrap_or(0.0); let clutch = latest.as_ref().map(|p| p.telemetry.clutch).unwrap_or(0.0); - let abs_on = latest.as_ref().map(|p| p.abs_active).unwrap_or(false); + let abs_on = + capabilities.abs_activity && latest.as_ref().map(|p| p.abs_active).unwrap_or(false); let tc_on = latest .as_ref() .map(|p| p.telemetry.tc_active) - .unwrap_or(false); + .unwrap_or(false) + && capabilities.tc_activity; let gear = latest.as_ref().map(|p| p.telemetry.gear).unwrap_or(0); let speed_ms = latest.as_ref().map(|p| p.telemetry.speed).unwrap_or(0.0); @@ -743,15 +766,15 @@ fn draw_telemetry( colors.throttle }; - let specs: &[(f32, egui::Color32)] = &[ - (clutch, colors.clutch), - (brake, brake_color), - (throttle, throttle_color), + let specs: &[(f32, egui::Color32, bool)] = &[ + (clutch, colors.clutch, capabilities.clutch), + (brake, brake_color, capabilities.brake), + (throttle, throttle_color, capabilities.throttle), ]; let label_h = 16.0_f32; let bar_labels = ["C", "B", "T"]; - for (i, (value, color)) in specs.iter().enumerate() { + for (i, (value, color, supported)) in specs.iter().enumerate() { let x = bars_rect.min.x + i as f32 * (bar_w + bar_gap); let top = bars_rect.min.y + label_h + 2.0; let bottom = bars_rect.max.y - 4.0; @@ -761,7 +784,11 @@ fn draw_telemetry( p.text( egui::pos2(x + bar_w / 2.0, bars_rect.min.y + label_h / 2.0), egui::Align2::CENTER_CENTER, - format!("{:.0}%", value * 100.0), + if *supported { + format!("{:.0}%", value * 100.0) + } else { + "—".to_owned() + }, egui::FontId::monospace(10.0), with_alpha(LABEL_MID, a), ); @@ -789,7 +816,7 @@ fn draw_telemetry( ); // Fill - if *value > 0.005 { + if *supported && *value > 0.005 { let fill_h = (h * value).max(2.0); p.rect_filled( egui::Rect::from_min_size( @@ -832,10 +859,11 @@ fn draw_telemetry( ); // Gear — large, centred inside the ring - let gear_str = match gear { - -1 => "R".to_string(), - 0 => "N".to_string(), - g => g.to_string(), + let gear_str = match (capabilities.gear, gear) { + (false, _) => "—".to_string(), + (true, -1) => "R".to_string(), + (true, 0) => "N".to_string(), + (true, g) => g.to_string(), }; ui.painter().text( egui::pos2(center.x, center.y - wheel_radius * 0.32), @@ -878,14 +906,18 @@ fn draw_telemetry( ui.painter().text( speed_pos, egui::Align2::CENTER_CENTER, - format!("{:.0}", speed_val), + if capabilities.speed { + format!("{:.0}", speed_val) + } else { + "—".to_owned() + }, egui::FontId::monospace(speed_font_size), with_alpha(LABEL_MID, a), ); }); // ── Track position strip ───────────────────────────────────────────────── - if settings.graph.show_track_strip { + if settings.graph.show_track_strip && capabilities.track_position { let current_track_pos = latest .as_ref() .map(|p| p.telemetry.track_position) @@ -1070,7 +1102,7 @@ fn draw_config( running: &mut bool, save_toast: &mut Option, buffer: Option<&Arc>, - lap_store: &mut crate::core::LapStore, + analysis: &Arc>, ) { // Ensure all widgets (sliders, dropdowns, colour pickers) use dark styling // regardless of the OS theme reported by the platform layer. @@ -1130,7 +1162,6 @@ fn draw_config( .monospace() .color(egui::Color32::from_rgba_unmultiplied(100, 220, 100, ta)), ); - ui.ctx().request_repaint(); } else { *save_toast = None; } @@ -1146,11 +1177,54 @@ fn draw_config( ui.selectable_value(&mut settings.collector.plugin, id.to_string(), *name); } }); + if settings.collector.plugin == "f1" || settings.collector.plugin == "f1_25" { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("Listen address") + .size(11.0) + .color(LABEL_MID), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add( + egui::TextEdit::singleline(&mut settings.collector.f1_bind_address) + .desired_width(110.0), + ); + }); + }); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("UDP port").size(11.0).color(LABEL_MID)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add(egui::DragValue::new(&mut settings.collector.f1_udp_port).range(1..=65535)); + }); + }); + ui.label( + egui::RichText::new("Auto-detects native 2025/2026, direct or forwarded") + .size(9.0) + .color(LABEL_DIM), + ); + } // ── Display ────────────────────────────────────────────────────────────── section_header(ui, "DISPLAY"); ui.checkbox(&mut settings.graph.show_legend, "Show legend"); ui.checkbox(&mut settings.graph.show_track_strip, "Show track strip"); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("UI refresh") + .size(11.0) + .color(LABEL_MID), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + egui::ComboBox::from_id_salt("ui_fps") + .selected_text(format!("{} FPS", settings.graph.validated_ui_fps())) + .show_ui(ui, |ui| { + for fps in crate::config::GraphSettings::VALID_UI_FPS { + ui.selectable_value(&mut settings.graph.ui_fps, fps, format!("{fps} FPS")); + } + }); + }); + }); ui.horizontal(|ui| { ui.label( egui::RichText::new("Speed unit") @@ -1288,17 +1362,17 @@ fn draw_config( settings.graph.lap_comparison_open = !settings.graph.lap_comparison_open; } ui.add_space(4.0); - let ref_status = match &lap_store.reference_lap { + let ref_status = match &analysis.lock().unwrap().reference_lap { Some(pts) => format!("Ref: {} pts", pts.len()), None => "No reference lap".to_string(), }; ui.label(egui::RichText::new(ref_status).size(10.0).color(LABEL_DIM)); ui.horizontal(|ui| { if ui.add(styled_button("Set Ref")).clicked() { - lap_store.set_current_as_reference(); + analysis.lock().unwrap().set_current_as_reference(); } if ui.add(styled_button("Clear")).clicked() { - lap_store.clear_reference(); + analysis.lock().unwrap().clear_reference(); } }); @@ -1311,6 +1385,32 @@ fn draw_config( } } +fn provider_config(settings: &AppSettings) -> ProviderConfig { + ProviderConfig { + f1_bind_address: settings.collector.f1_bind_address.trim().to_owned(), + f1_udp_port: settings.collector.f1_udp_port, + } +} + +fn render_interval(settings: &AppSettings) -> std::time::Duration { + std::time::Duration::from_secs_f64(1.0 / settings.graph.validated_ui_fps() as f64) +} + +#[cfg(test)] +mod scheduling_tests { + use super::*; + + #[test] + fn configured_refresh_rates_produce_timed_intervals() { + let mut settings = AppSettings::default(); + for fps in crate::config::GraphSettings::VALID_UI_FPS { + settings.graph.ui_fps = fps; + let expected = 1.0 / fps as f64; + assert!((render_interval(&settings).as_secs_f64() - expected).abs() < 1e-9); + } + } +} + // ── Helpers ─────────────────────────────────────────────────────────────────── fn with_alpha(c: egui::Color32, a: u8) -> egui::Color32 { diff --git a/src/renderer/trace_graph.rs b/src/renderer/trace_graph.rs index a7f3429..799b97b 100644 --- a/src/renderer/trace_graph.rs +++ b/src/renderer/trace_graph.rs @@ -66,8 +66,9 @@ impl<'a> TraceGraph<'a> { .collect(); if !points.is_empty() { + let capabilities = points.last().unwrap().telemetry.capabilities; // Draw order: speed → clutch → throttle → brake/ABS (top, always visible) - if self.settings.show_speed { + if self.settings.show_speed && capabilities.speed { let max_speed = points .iter() .map(|p| p.telemetry.speed) @@ -103,7 +104,7 @@ impl<'a> TraceGraph<'a> { ); } } - if self.settings.show_clutch { + if self.settings.show_clutch && capabilities.clutch { self.draw_trace( &painter, rect, @@ -114,10 +115,10 @@ impl<'a> TraceGraph<'a> { self.colors.clutch, ); } - if self.settings.show_throttle { + if self.settings.show_throttle && capabilities.throttle { self.draw_throttle_trace(&painter, rect, &points, now, window_dur); } - if self.settings.show_brake { + if self.settings.show_brake && capabilities.brake { self.draw_brake_trace(&painter, rect, &points, now, window_dur); } } @@ -197,7 +198,10 @@ impl<'a> TraceGraph<'a> { self.y_position(rect, point.telemetry.throttle), ); - let state = if point.telemetry.tc_active && self.settings.show_tc { + let state = if point.telemetry.capabilities.tc_activity + && point.telemetry.tc_active + && self.settings.show_tc + { ThrottleState::TcActive } else { ThrottleState::Normal @@ -266,7 +270,10 @@ impl<'a> TraceGraph<'a> { let state = if !is_braking { BrakeState::Normal } else { - match (point.abs_active, is_turning) { + match ( + point.telemetry.capabilities.abs_activity && point.abs_active, + is_turning, + ) { (false, false) => BrakeState::Normal, (false, true) => { if self.settings.show_trail_brake { From 278aa3953b0166c03c977391aef41fbb75c08eda Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 17 Sep 2026 11:05:36 +0800 Subject: [PATCH 02/12] docs: add F1 UDP setup and forwarding guide --- README.md | 5 +++++ docs/F1_UDP.md | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 docs/F1_UDP.md diff --git a/README.md b/README.md index e5a4a84..d06a66b 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ A demo of SimTrace in action showing the (toggleable) braking x steering phase p | Assetto Corsa Competizione | | | Automobilista 2 | [Enable shared memory](#automobilista-2) | | iRacing | Support is currently experimental | +| EA SPORTS F1 25 | [UDP 2025 and 2026 Season Pack](docs/F1_UDP.md) | ## Installation @@ -42,6 +43,10 @@ The overlay fades out when your cursor leaves it and reappears on hover. Enable shared memory: (from the main menu) `Options (top right) > System > Shared Memory` and setting it to `Project CARS 2` +## EA SPORTS F1 25 + +SimTrace accepts native UDP from both F1 25 and the 2026 Season Pack. The listen address and port are saved with the other settings. See the [F1 UDP setup guide](docs/F1_UDP.md) for direct and forwarded configurations. + ## Building from source Requires [Rust](https://rustup.rs/) (stable). diff --git a/docs/F1_UDP.md b/docs/F1_UDP.md new file mode 100644 index 0000000..d788cd0 --- /dev/null +++ b/docs/F1_UDP.md @@ -0,0 +1,41 @@ +# EA SPORTS F1 UDP setup + +SimTrace uses one UDP listener and automatically selects a decoder from the packet format identifier. F1 25 UDP Format 2025 and the F1 25: 2026 Season Pack UDP Format 2026 are separate decoders with their own packet sizes and vehicle-array layouts. Native 2026 Season Pack output is supported; the game does not need to use its legacy F1 25 format. + +The implementation follows EA's current official [F1 25 and 2026 Season Pack UDP specification page](https://forums.ea.com/blog/f1-games-game-info-hub-en/ea-sports%E2%84%A2-f1%C2%AE25-2026-season-pack-udp-specification/12187347), [F1 25 v3 specification](https://forums.ea.com/t5/s/tghpe58374/attachments/tghpe58374/f1-games-game-info-hub-en/61/4/Data%20Output%20from%20F1%2025%20v3.pdf), [F1 25 structures](https://forums.ea.com/t5/s/tghpe58374/attachments/tghpe58374/f1-games-game-info-hub-en/61/5/F1%2025%20Telemetry%20Output%20Structures.txt), and [2026 Season Pack structures](https://forums.ea.com/t5/s/tghpe58374/attachments/tghpe58374/f1-games-game-info-hub-en/61/8/2026%20Season%20Pack%20Telemetry%20Output%20Structures%20(1).txt). + +## Direct game output + +1. In SimTrace, open settings and select **EA SPORTS F1 (UDP 2025/2026)**. +2. Set **Listen address** to `127.0.0.1` when the game and SimTrace run on this PC. Use `0.0.0.0` when SimTrace must accept packets on any network interface. +3. Choose an unused **UDP port**. `20777` is EA's usual default, but SimTrace does not require it. +4. Save the SimTrace settings. +5. In F1 25 telemetry settings, turn UDP telemetry on, set the UDP IP address to `127.0.0.1`, and enter the same port. +6. For the first 2026 Season Pack test, select its native UDP Format 2026. A 60 Hz send rate is suitable. + +SimTrace accepts unicast or broadcast datagrams. The sender address is not used to distinguish direct game packets from forwarded packets. + +## Sharing telemetry with SimHub or Project Aeternum + +Two applications on the same Windows PC should not both depend on owning the same unicast UDP endpoint. Give one application the game-facing port and forward a copy to a different SimTrace port. + +Example: + +1. Let SimHub or Project Aeternum receive the game's UDP stream on `20777`. +2. Enable that application's UDP forwarding to `127.0.0.1:20778`. +3. Set SimTrace to listen on `127.0.0.1` and port `20778`, then save. +4. Keep the forwarded packet payload unchanged. SimTrace handles it exactly like a direct game datagram. + +SimHub documents its forwarding setup in [Sharing UDP data with other applications](https://github.com/SHWotever/SimHub/wiki/Sharing-UDP-data-with-other-applications/3d41406eaa9474eed8e10dec14ebd3d66a9d2fee). SimTrace does not forward packets in this phase. + +## First live test + +With the 2026 Season Pack sending native Format 2026, verify that: + +- speed, RPM, gear, throttle, brake, clutch, and normalized steering respond for the player's car; +- trace history continues moving without mouse or window events; +- the selected 10-second or 30-second trace window remains bounded; +- opening and closing the Phase Plot does not interrupt collection; +- restarting SimTrace retains the listen address, port, trace window, and display refresh rate. + +F1 car status packets expose the configured ABS and traction-control assistance modes. They do not identify moment-by-moment intervention, so SimTrace does not invent ABS-active or TC-active samples. The initial decoder also leaves physical wheel angle and wheel slip unsupported until a later packet integration provides those channels correctly. From 4031a27acba39a4e6cbc943a0c2181b64a16da0d Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 17 Sep 2026 12:07:36 +0800 Subject: [PATCH 03/12] fix: instrument F1 UDP live validation --- docs/F1_UDP.md | 6 + src/plugins/f1/diagnostics.rs | 317 ++++++++++++++++++++++++++++++++++ src/plugins/f1/mod.rs | 34 +++- src/plugins/f1/protocol.rs | 189 +++++++++++++++----- 4 files changed, 499 insertions(+), 47 deletions(-) create mode 100644 src/plugins/f1/diagnostics.rs diff --git a/docs/F1_UDP.md b/docs/F1_UDP.md index d788cd0..b5e63a6 100644 --- a/docs/F1_UDP.md +++ b/docs/F1_UDP.md @@ -38,4 +38,10 @@ With the 2026 Season Pack sending native Format 2026, verify that: - opening and closing the Phase Plot does not interrupt collection; - restarting SimTrace retains the listen address, port, trace window, and display refresh rate. +### Live diagnostic log + +On Windows, receive and decoder diagnostics are written to `%APPDATA%\simtrace\simtrace.log.YYYY-MM-DD`. The first few distinct packet headers include the sender, datagram length, format/year, game major/minor version, packet version, packet ID/type, and player-car index. A summary is emitted every five seconds while packets arrive, with counters for protocol detection, validation, telemetry/status decoding, normalized frames, and categorized rejections. Each rejection category logs at most its first three examples. + +For a failed live test, preserve the lines containing `F1 UDP datagram header observed`, `F1 UDP datagram rejected`, and `F1 UDP diagnostic summary`. These values should be compared with the current EA specification before any decoder layout is changed. + F1 car status packets expose the configured ABS and traction-control assistance modes. They do not identify moment-by-moment intervention, so SimTrace does not invent ABS-active or TC-active samples. The initial decoder also leaves physical wheel angle and wheel slip unsupported until a later packet integration provides those channels correctly. diff --git a/src/plugins/f1/diagnostics.rs b/src/plugins/f1/diagnostics.rs new file mode 100644 index 0000000..f590dd3 --- /dev/null +++ b/src/plugins/f1/diagnostics.rs @@ -0,0 +1,317 @@ +use std::collections::HashSet; +use std::net::SocketAddr; +use std::time::{Duration, Instant}; + +use anyhow::Error; +use tracing::{info, warn}; + +use super::protocol::{self, RejectReason, HEADER_LEN}; +use crate::core::TelemetryData; + +const DISTINCT_HEADER_LOG_LIMIT: usize = 12; +const REJECTION_LOG_LIMIT_PER_REASON: u64 = 3; +const SUMMARY_INTERVAL: Duration = Duration::from_secs(5); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct HeaderPreview { + pub packet_format: u16, + pub game_year: u8, + pub game_major_version: u8, + pub game_minor_version: u8, + pub packet_version: u8, + pub packet_id: u8, + pub player_car_index: u8, + pub packet_len: usize, +} + +impl HeaderPreview { + fn read(bytes: &[u8]) -> Option { + if bytes.len() < HEADER_LEN { + return None; + } + Some(Self { + packet_format: u16::from_le_bytes([bytes[0], bytes[1]]), + game_year: bytes[2], + game_major_version: bytes[3], + game_minor_version: bytes[4], + packet_version: bytes[5], + packet_id: bytes[6], + player_car_index: bytes[27], + packet_len: bytes.len(), + }) + } +} + +pub struct Diagnostics { + datagrams_received: u64, + headers_recognized: u64, + format_2025_detected: u64, + format_2026_detected: u64, + other_formats: u64, + packets_validated: u64, + player_indices_validated: u64, + telemetry_decoded: u64, + status_decoded: u64, + normalized_frames: u64, + stale_telemetry_frames: u64, + ignored_packet_ids: u64, + packet_ids: [u64; 256], + rejected: [u64; RejectReason::COUNT], + distinct_headers: HashSet, + last_summary_at: Instant, + last_summary_received: u64, +} + +impl Default for Diagnostics { + fn default() -> Self { + Self { + datagrams_received: 0, + headers_recognized: 0, + format_2025_detected: 0, + format_2026_detected: 0, + other_formats: 0, + packets_validated: 0, + player_indices_validated: 0, + telemetry_decoded: 0, + status_decoded: 0, + normalized_frames: 0, + stale_telemetry_frames: 0, + ignored_packet_ids: 0, + packet_ids: [0; 256], + rejected: [0; RejectReason::COUNT], + distinct_headers: HashSet::new(), + last_summary_at: Instant::now(), + last_summary_received: 0, + } + } +} + +impl Diagnostics { + pub fn observe_datagram(&mut self, bytes: &[u8], source: SocketAddr) -> Option { + self.datagrams_received += 1; + let Some(header) = HeaderPreview::read(bytes) else { + return None; + }; + self.headers_recognized += 1; + self.packet_ids[header.packet_id as usize] += 1; + match header.packet_format { + 2025 => self.format_2025_detected += 1, + 2026 => self.format_2026_detected += 1, + _ => self.other_formats += 1, + } + + if self.distinct_headers.len() < DISTINCT_HEADER_LOG_LIMIT + && self.distinct_headers.insert(header) + { + info!( + sender = %source, + packet_len = header.packet_len, + format = header.packet_format, + game_year = header.game_year, + game_major = header.game_major_version, + game_minor = header.game_minor_version, + packet_version = header.packet_version, + packet_id = header.packet_id, + packet_type = packet_type_name(header.packet_id), + player_car_index = header.player_car_index, + distinct_header = self.distinct_headers.len(), + "F1 UDP datagram header observed" + ); + } + Some(header) + } + + pub fn observe_result( + &mut self, + header: Option, + result: &anyhow::Result>, + ) { + match result { + Ok(sample) => { + if let Some(header) = header { + match header.packet_id { + 1 | 2 | 6 | 7 => { + self.packets_validated += 1; + self.player_indices_validated += 1; + } + _ => self.ignored_packet_ids += 1, + } + if header.packet_id == 6 { + self.telemetry_decoded += 1; + if sample.is_none() { + self.stale_telemetry_frames += 1; + } + } else if header.packet_id == 7 { + self.status_decoded += 1; + } + } + if let Some(sample) = sample { + self.normalized_frames += 1; + if self.normalized_frames <= 3 { + info!( + format = sample.source.protocol_format, + frame = sample.source.frame_identifier, + overall_frame = sample.source.overall_frame_identifier, + speed_mps = sample.vehicle.speed, + gear = sample.vehicle.gear, + throttle = sample.vehicle.throttle, + brake = sample.vehicle.brake, + "F1 normalized telemetry frame accepted" + ); + } + } + } + Err(error) => self.observe_rejection(error, header), + } + self.maybe_log_summary(); + } + + pub fn log_summary(&self, event: &'static str) { + if self.datagrams_received == 0 { + return; + } + let packet_ids: Vec<(usize, u64)> = self + .packet_ids + .iter() + .enumerate() + .filter_map(|(id, count)| (*count > 0).then_some((id, *count))) + .collect(); + let rejections: Vec<(&'static str, u64)> = RejectReason::ALL + .iter() + .filter_map(|reason| { + let count = self.rejected[reason.index()]; + (count > 0).then_some((reason.as_str(), count)) + }) + .collect(); + info!( + event, + datagrams_received = self.datagrams_received, + headers_recognized = self.headers_recognized, + format_2025_detected = self.format_2025_detected, + format_2026_detected = self.format_2026_detected, + other_formats = self.other_formats, + packets_validated = self.packets_validated, + player_indices_validated = self.player_indices_validated, + telemetry_decoded = self.telemetry_decoded, + status_decoded = self.status_decoded, + normalized_frames = self.normalized_frames, + stale_telemetry_frames = self.stale_telemetry_frames, + ignored_packet_ids = self.ignored_packet_ids, + packet_ids = ?packet_ids, + rejections = ?rejections, + "F1 UDP diagnostic summary" + ); + } + + fn observe_rejection(&mut self, error: &Error, header: Option) { + let reason = protocol::reject_reason(error); + let index = reason.index(); + self.rejected[index] += 1; + if self.rejected[index] <= REJECTION_LOG_LIMIT_PER_REASON { + warn!( + reason = reason.as_str(), + occurrence = self.rejected[index], + format = header.map(|value| value.packet_format), + game_year = header.map(|value| value.game_year), + packet_version = header.map(|value| value.packet_version), + packet_id = header.map(|value| value.packet_id), + packet_type = header.map(|value| packet_type_name(value.packet_id)), + packet_len = header.map(|value| value.packet_len), + player_car_index = header.map(|value| value.player_car_index), + error = %error, + "F1 UDP datagram rejected" + ); + } + } + + fn maybe_log_summary(&mut self) { + if self.last_summary_at.elapsed() >= SUMMARY_INTERVAL + && self.datagrams_received != self.last_summary_received + { + self.log_summary("periodic"); + self.last_summary_at = Instant::now(); + self.last_summary_received = self.datagrams_received; + } + } +} + +fn packet_type_name(packet_id: u8) -> &'static str { + match packet_id { + 0 => "Motion", + 1 => "Session", + 2 => "LapData", + 3 => "Event", + 4 => "Participants", + 5 => "CarSetups", + 6 => "CarTelemetry", + 7 => "CarStatus", + 8 => "FinalClassification", + 9 => "LobbyInfo", + 10 => "CarDamage", + 11 => "SessionHistory", + 12 => "TyreSets", + 13 => "MotionEx", + 14 => "TimeTrial", + 15 => "LapPositions", + 16 => "CarTelemetry2", + _ => "Unknown", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugins::f1::{f1_2026, ProtocolRouter}; + + #[test] + fn previews_current_header_fields_without_decoding_layout() { + let mut bytes = vec![0_u8; HEADER_LEN]; + bytes[0..2].copy_from_slice(&2026_u16.to_le_bytes()); + bytes[2..7].copy_from_slice(&[26, 1, 3, 2, 16]); + bytes[27] = 23; + let preview = HeaderPreview::read(&bytes).unwrap(); + assert_eq!(preview.packet_format, 2026); + assert_eq!(preview.game_year, 26); + assert_eq!(preview.game_major_version, 1); + assert_eq!(preview.game_minor_version, 3); + assert_eq!(preview.packet_version, 2); + assert_eq!(preview.packet_id, 16); + assert_eq!(preview.player_car_index, 23); + } + + #[test] + fn counts_received_2026_telemetry_through_normalized_acceptance() { + let source = "127.0.0.1:20777".parse().unwrap(); + let bytes = f1_2026::test_telemetry_packet(3, 7); + let mut diagnostics = Diagnostics::default(); + let header = diagnostics.observe_datagram(&bytes, source); + let result = ProtocolRouter::default().decode(&bytes); + diagnostics.observe_result(header, &result); + + assert_eq!(diagnostics.datagrams_received, 1); + assert_eq!(diagnostics.headers_recognized, 1); + assert_eq!(diagnostics.format_2026_detected, 1); + assert_eq!(diagnostics.packet_ids[6], 1); + assert_eq!(diagnostics.packets_validated, 1); + assert_eq!(diagnostics.player_indices_validated, 1); + assert_eq!(diagnostics.telemetry_decoded, 1); + assert_eq!(diagnostics.normalized_frames, 1); + } + + #[test] + fn classifies_length_rejection_without_changing_layout() { + let source = "127.0.0.1:20777".parse().unwrap(); + let mut bytes = f1_2026::test_telemetry_packet(0, 1); + bytes.pop(); + let mut diagnostics = Diagnostics::default(); + let header = diagnostics.observe_datagram(&bytes, source); + let result = ProtocolRouter::default().decode(&bytes); + diagnostics.observe_result(header, &result); + + assert!(result.is_err()); + assert_eq!(diagnostics.datagrams_received, 1); + assert_eq!(diagnostics.format_2026_detected, 1); + assert_eq!(diagnostics.rejected[RejectReason::PacketLength.index()], 1); + assert_eq!(diagnostics.normalized_frames, 0); + } +} diff --git a/src/plugins/f1/mod.rs b/src/plugins/f1/mod.rs index 3291030..24098c7 100644 --- a/src/plugins/f1/mod.rs +++ b/src/plugins/f1/mod.rs @@ -3,6 +3,7 @@ //! The socket transport is shared. Packet identity selects a protocol-specific //! decoder before any packet layout is interpreted. +mod diagnostics; mod f1_2025; mod f1_2026; mod protocol; @@ -10,7 +11,7 @@ mod protocol; use std::io::ErrorKind; use std::net::UdpSocket; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result}; use tracing::{debug, info}; use crate::core::TelemetryData; @@ -29,7 +30,10 @@ impl ProtocolRouter { match protocol::packet_format(bytes)? { f1_2025::PACKET_FORMAT => self.f1_2025.decode(bytes), f1_2026::PACKET_FORMAT => self.f1_2026.decode(bytes), - format => bail!("unsupported F1 UDP format {format}"), + format => Err(protocol::reject( + protocol::RejectReason::PacketFormat, + format!("unsupported F1 UDP format {format}"), + )), } } } @@ -39,6 +43,7 @@ pub struct F1Plugin { port: u16, socket: Option, router: ProtocolRouter, + diagnostics: diagnostics::Diagnostics, } impl F1Plugin { @@ -48,6 +53,7 @@ impl F1Plugin { port: config.f1_udp_port, socket: None, router: ProtocolRouter::default(), + diagnostics: diagnostics::Diagnostics::default(), } } } @@ -67,13 +73,20 @@ impl GamePlugin for F1Plugin { })?; socket.set_nonblocking(true)?; info!(address = %self.bind_address, port = self.port, "F1 UDP listener ready"); + info!( + distinct_header_limit = 12, + summary_interval_seconds = 5, + "F1 UDP live diagnostics active; awaiting datagrams" + ); self.socket = Some(socket); Ok(()) } fn disconnect(&mut self) { + self.diagnostics.log_summary("disconnect"); self.socket = None; self.router = ProtocolRouter::default(); + self.diagnostics = diagnostics::Diagnostics::default(); } fn is_connected(&self) -> bool { @@ -95,13 +108,18 @@ impl GamePlugin for F1Plugin { break; } match socket.recv_from(&mut datagram) { - Ok((len, _source)) => match self.router.decode(&datagram[..len]) { - Ok(Some(sample)) => samples.push(sample), - Ok(None) => {} - Err(error) => { - debug!(%error, packet_len = len, "discarded invalid F1 UDP packet") + Ok((len, source)) => { + let header = self.diagnostics.observe_datagram(&datagram[..len], source); + let result = self.router.decode(&datagram[..len]); + self.diagnostics.observe_result(header, &result); + match result { + Ok(Some(sample)) => samples.push(sample), + Ok(None) => {} + Err(error) => { + debug!(%error, packet_len = len, "discarded invalid F1 UDP packet") + } } - }, + } Err(error) if error.kind() == ErrorKind::WouldBlock => break, Err(error) => return Err(error.into()), } diff --git a/src/plugins/f1/protocol.rs b/src/plugins/f1/protocol.rs index d898719..f4bb7d1 100644 --- a/src/plugins/f1/protocol.rs +++ b/src/plugins/f1/protocol.rs @@ -1,4 +1,7 @@ -use anyhow::{bail, Context, Result}; +use std::error::Error; +use std::fmt; + +use anyhow::Result; use crate::core::{ DriverAssistStatus, SourceMetadata, TelemetryCapabilities, TelemetryData, VehicleTelemetry, @@ -6,6 +9,84 @@ use crate::core::{ pub const HEADER_LEN: usize = 29; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RejectReason { + HeaderLength, + HeaderField, + PacketFormat, + GameYear, + PacketVersion, + PacketLength, + PlayerIndex, + PayloadField, + InvalidValue, + Other, +} + +impl RejectReason { + pub const ALL: [Self; 10] = [ + Self::HeaderLength, + Self::HeaderField, + Self::PacketFormat, + Self::GameYear, + Self::PacketVersion, + Self::PacketLength, + Self::PlayerIndex, + Self::PayloadField, + Self::InvalidValue, + Self::Other, + ]; + pub const COUNT: usize = Self::ALL.len(); + + pub const fn index(self) -> usize { + self as usize + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::HeaderLength => "header_length", + Self::HeaderField => "header_field", + Self::PacketFormat => "packet_format", + Self::GameYear => "game_year", + Self::PacketVersion => "packet_version", + Self::PacketLength => "packet_length", + Self::PlayerIndex => "player_index", + Self::PayloadField => "payload_field", + Self::InvalidValue => "invalid_value", + Self::Other => "other", + } + } +} + +#[derive(Debug)] +struct DecodeRejection { + reason: RejectReason, + detail: String, +} + +impl fmt::Display for DecodeRejection { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.detail) + } +} + +impl Error for DecodeRejection {} + +pub fn reject(reason: RejectReason, detail: impl Into) -> anyhow::Error { + DecodeRejection { + reason, + detail: detail.into(), + } + .into() +} + +pub fn reject_reason(error: &anyhow::Error) -> RejectReason { + error + .downcast_ref::() + .map(|rejection| rejection.reason) + .unwrap_or(RejectReason::Other) +} + #[derive(Debug, Clone, Copy)] pub struct Header { pub packet_version: u8, @@ -61,7 +142,10 @@ impl DecoderState { pub fn set_assists(&mut self, tc: u8, abs: u8) -> Result<()> { if tc > 2 || abs > 1 { - bail!("invalid F1 assist status"); + return Err(reject( + RejectReason::InvalidValue, + format!("invalid F1 assist status: TC={tc}, ABS={abs}"), + )); } self.assists = DriverAssistStatus { abs_enabled: Some(abs == 1), @@ -84,7 +168,10 @@ impl DecoderState { return Ok(None); } if !fields.throttle.is_finite() || !fields.steer.is_finite() || !fields.brake.is_finite() { - bail!("non-finite F1 telemetry input"); + return Err(reject( + RejectReason::InvalidValue, + "non-finite F1 telemetry input", + )); } let track_position = match (self.player_lap_distance_m, self.track_length_m) { (Some(distance), Some(length)) if length > 0.0 => { @@ -142,7 +229,13 @@ impl DecoderState { } pub fn packet_format(bytes: &[u8]) -> Result { - read_u16(bytes, 0).context("F1 packet missing format identifier") + if bytes.len() < 2 { + return Err(reject( + RejectReason::HeaderField, + "F1 packet missing format identifier", + )); + } + Ok(u16::from_le_bytes([bytes[0], bytes[1]])) } /// Both current official specifications define this exact 29-byte header. @@ -154,19 +247,34 @@ pub fn parse_header( max_cars: usize, ) -> Result
{ if bytes.len() < HEADER_LEN { - bail!("F1 packet shorter than header"); + return Err(reject( + RejectReason::HeaderLength, + format!( + "F1 packet shorter than header: {}; expected at least {HEADER_LEN}", + bytes.len() + ), + )); } let format = packet_format(bytes)?; if format != expected_format { - bail!("wrong F1 UDP format {format}; expected {expected_format}"); + return Err(reject( + RejectReason::PacketFormat, + format!("wrong F1 UDP format {format}; expected {expected_format}"), + )); } let game_year = bytes[2]; if game_year != 0 && game_year != expected_game_year { - bail!("wrong F1 game year {game_year}; expected {expected_game_year}"); + return Err(reject( + RejectReason::GameYear, + format!("wrong F1 game year {game_year}; expected {expected_game_year}"), + )); } let player_car_index = bytes[27] as usize; if player_car_index >= max_cars { - bail!("invalid F1 player car index {player_car_index}"); + return Err(reject( + RejectReason::PlayerIndex, + format!("invalid F1 player car index {player_car_index}; car count is {max_cars}"), + )); } Ok(Header { packet_version: bytes[5], @@ -181,13 +289,19 @@ pub fn parse_header( pub fn require_packet(bytes: &[u8], expected_len: usize, version: u8) -> Result<()> { if version != 1 { - bail!("unsupported F1 packet version {version}"); + return Err(reject( + RejectReason::PacketVersion, + format!("unsupported F1 packet version {version}; expected 1"), + )); } if bytes.len() != expected_len { - bail!( - "wrong F1 packet length {}; expected {expected_len}", - bytes.len() - ); + return Err(reject( + RejectReason::PacketLength, + format!( + "wrong F1 packet length {}; expected {expected_len}", + bytes.len() + ), + )); } Ok(()) } @@ -196,11 +310,14 @@ pub fn player_offset(index: usize, item_len: usize) -> Result { index .checked_mul(item_len) .and_then(|offset| HEADER_LEN.checked_add(offset)) - .context("F1 player data offset overflow") + .ok_or_else(|| reject(RejectReason::PlayerIndex, "F1 player data offset overflow")) } pub fn read_u8(bytes: &[u8], offset: usize) -> Result { - bytes.get(offset).copied().context("truncated F1 u8") + bytes + .get(offset) + .copied() + .ok_or_else(|| reject(RejectReason::PayloadField, "truncated F1 u8")) } pub fn read_i8(bytes: &[u8], offset: usize) -> Result { @@ -208,37 +325,31 @@ pub fn read_i8(bytes: &[u8], offset: usize) -> Result { } pub fn read_u16(bytes: &[u8], offset: usize) -> Result { - Ok(u16::from_le_bytes( - bytes - .get(offset..offset + 2) - .context("truncated F1 u16")? - .try_into()?, - )) + let value = bytes + .get(offset..offset + 2) + .ok_or_else(|| reject(RejectReason::PayloadField, "truncated F1 u16"))?; + Ok(u16::from_le_bytes([value[0], value[1]])) } pub fn read_u32(bytes: &[u8], offset: usize) -> Result { - Ok(u32::from_le_bytes( - bytes - .get(offset..offset + 4) - .context("truncated F1 u32")? - .try_into()?, - )) + let value = bytes + .get(offset..offset + 4) + .ok_or_else(|| reject(RejectReason::PayloadField, "truncated F1 u32"))?; + Ok(u32::from_le_bytes([value[0], value[1], value[2], value[3]])) } pub fn read_u64(bytes: &[u8], offset: usize) -> Result { - Ok(u64::from_le_bytes( - bytes - .get(offset..offset + 8) - .context("truncated F1 u64")? - .try_into()?, - )) + let value = bytes + .get(offset..offset + 8) + .ok_or_else(|| reject(RejectReason::PayloadField, "truncated F1 u64"))?; + Ok(u64::from_le_bytes([ + value[0], value[1], value[2], value[3], value[4], value[5], value[6], value[7], + ])) } pub fn read_f32(bytes: &[u8], offset: usize) -> Result { - Ok(f32::from_le_bytes( - bytes - .get(offset..offset + 4) - .context("truncated F1 f32")? - .try_into()?, - )) + let value = bytes + .get(offset..offset + 4) + .ok_or_else(|| reject(RejectReason::PayloadField, "truncated F1 f32"))?; + Ok(f32::from_le_bytes([value[0], value[1], value[2], value[3]])) } From 6f7113bb3be99c10222c002227e42ebc46fe22c0 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 17 Sep 2026 12:20:08 +0800 Subject: [PATCH 04/12] fix: accept live Season Pack game-year identifier --- docs/F1_UDP.md | 2 ++ src/plugins/f1/f1_2025.rs | 6 +++--- src/plugins/f1/f1_2026.rs | 20 ++++++++++++++++---- src/plugins/f1/protocol.rs | 6 +++--- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/docs/F1_UDP.md b/docs/F1_UDP.md index b5e63a6..57a5c4f 100644 --- a/docs/F1_UDP.md +++ b/docs/F1_UDP.md @@ -2,6 +2,8 @@ SimTrace uses one UDP listener and automatically selects a decoder from the packet format identifier. F1 25 UDP Format 2025 and the F1 25: 2026 Season Pack UDP Format 2026 are separate decoders with their own packet sizes and vehicle-array layouts. Native 2026 Season Pack output is supported; the game does not need to use its legacy F1 25 format. +EA's 2026 structure document labels the 2026 header game-year value as `26`. A live F1 25: 2026 Season Pack build was observed sending packet format `2026` with game year `25` and game version `1.26`, while its packet IDs and lengths matched the documented 2026 layouts. The 2026 decoder therefore accepts game year `25` or `26` only after packet format `2026` selects that decoder. This compatibility allowance does not alter any packet layout or offset. + The implementation follows EA's current official [F1 25 and 2026 Season Pack UDP specification page](https://forums.ea.com/blog/f1-games-game-info-hub-en/ea-sports%E2%84%A2-f1%C2%AE25-2026-season-pack-udp-specification/12187347), [F1 25 v3 specification](https://forums.ea.com/t5/s/tghpe58374/attachments/tghpe58374/f1-games-game-info-hub-en/61/4/Data%20Output%20from%20F1%2025%20v3.pdf), [F1 25 structures](https://forums.ea.com/t5/s/tghpe58374/attachments/tghpe58374/f1-games-game-info-hub-en/61/5/F1%2025%20Telemetry%20Output%20Structures.txt), and [2026 Season Pack structures](https://forums.ea.com/t5/s/tghpe58374/attachments/tghpe58374/f1-games-game-info-hub-en/61/8/2026%20Season%20Pack%20Telemetry%20Output%20Structures%20(1).txt). ## Direct game output diff --git a/src/plugins/f1/f1_2025.rs b/src/plugins/f1/f1_2025.rs index 1879e60..c4aaefd 100644 --- a/src/plugins/f1/f1_2025.rs +++ b/src/plugins/f1/f1_2025.rs @@ -7,7 +7,7 @@ use crate::core::TelemetryData; use super::protocol::{self, DecoderState, VehicleFields, HEADER_LEN}; pub const PACKET_FORMAT: u16 = 2025; -const GAME_YEAR: u8 = 25; +const GAME_YEARS: &[u8] = &[25]; const MAX_CARS: usize = 22; const SESSION_PACKET_LEN: usize = 753; const LAP_PACKET_LEN: usize = 1285; @@ -28,7 +28,7 @@ pub struct Decoder { impl Decoder { pub fn decode(&mut self, bytes: &[u8]) -> Result> { - let header = protocol::parse_header(bytes, PACKET_FORMAT, GAME_YEAR, MAX_CARS)?; + let header = protocol::parse_header(bytes, PACKET_FORMAT, GAME_YEARS, MAX_CARS)?; self.state.observe_session(header.session_uid); match header.packet_id { PACKET_SESSION => { @@ -99,7 +99,7 @@ pub(super) fn test_telemetry_packet(player: usize, frame: u32) -> Vec { fn test_packet(id: u8, len: usize, player: usize, frame: u32) -> Vec { let mut bytes = vec![0_u8; len]; bytes[0..2].copy_from_slice(&PACKET_FORMAT.to_le_bytes()); - bytes[2] = GAME_YEAR; + bytes[2] = GAME_YEARS[0]; bytes[5] = 1; bytes[6] = id; bytes[7..15].copy_from_slice(&1234_u64.to_le_bytes()); diff --git a/src/plugins/f1/f1_2026.rs b/src/plugins/f1/f1_2026.rs index e5354d6..eeec49f 100644 --- a/src/plugins/f1/f1_2026.rs +++ b/src/plugins/f1/f1_2026.rs @@ -7,7 +7,10 @@ use crate::core::TelemetryData; use super::protocol::{self, DecoderState, VehicleFields, HEADER_LEN}; pub const PACKET_FORMAT: u16 = 2026; -const GAME_YEAR: u8 = 26; +// The official structure document describes 26. Live F1 25: 2026 Season Pack +// builds also identify the base executable as game year 25 while using format +// 2026 and its full 2026 packet layouts. +const GAME_YEARS: &[u8] = &[25, 26]; const MAX_CARS: usize = 24; const SESSION_PACKET_LEN: usize = 926; const LAP_PACKET_LEN: usize = 1399; @@ -28,7 +31,7 @@ pub struct Decoder { impl Decoder { pub fn decode(&mut self, bytes: &[u8]) -> Result> { - let header = protocol::parse_header(bytes, PACKET_FORMAT, GAME_YEAR, MAX_CARS)?; + let header = protocol::parse_header(bytes, PACKET_FORMAT, GAME_YEARS, MAX_CARS)?; self.state.observe_session(header.session_uid); match header.packet_id { PACKET_SESSION => { @@ -99,7 +102,7 @@ pub(super) fn test_telemetry_packet(player: usize, frame: u32) -> Vec { fn test_packet(id: u8, len: usize, player: usize, frame: u32) -> Vec { let mut bytes = vec![0_u8; len]; bytes[0..2].copy_from_slice(&PACKET_FORMAT.to_le_bytes()); - bytes[2] = GAME_YEAR; + bytes[2] = 26; bytes[5] = 1; bytes[6] = id; bytes[7..15].copy_from_slice(&5678_u64.to_le_bytes()); @@ -137,7 +140,7 @@ mod tests { wrong_version[5] = 2; assert!(Decoder::default().decode(&wrong_version).is_err()); let mut wrong_year = test_telemetry_packet(0, 1); - wrong_year[2] = 25; + wrong_year[2] = 24; assert!(Decoder::default().decode(&wrong_year).is_err()); assert!(Decoder::default() .decode(&test_telemetry_packet(0, 1)[..100]) @@ -147,6 +150,15 @@ mod tests { assert!(Decoder::default().decode(&wrong_car).is_err()); } + #[test] + fn accepts_live_season_pack_base_game_year() { + let mut bytes = test_telemetry_packet(0, 1); + bytes[2] = 25; + bytes[3] = 1; + bytes[4] = 26; + assert!(Decoder::default().decode(&bytes).unwrap().is_some()); + } + #[test] fn uses_2026_specific_item_sizes_for_player_selection() { let mut bytes = test_telemetry_packet(5, 8); diff --git a/src/plugins/f1/protocol.rs b/src/plugins/f1/protocol.rs index f4bb7d1..2e4e49d 100644 --- a/src/plugins/f1/protocol.rs +++ b/src/plugins/f1/protocol.rs @@ -243,7 +243,7 @@ pub fn packet_format(bytes: &[u8]) -> Result { pub fn parse_header( bytes: &[u8], expected_format: u16, - expected_game_year: u8, + accepted_game_years: &[u8], max_cars: usize, ) -> Result
{ if bytes.len() < HEADER_LEN { @@ -263,10 +263,10 @@ pub fn parse_header( )); } let game_year = bytes[2]; - if game_year != 0 && game_year != expected_game_year { + if game_year != 0 && !accepted_game_years.contains(&game_year) { return Err(reject( RejectReason::GameYear, - format!("wrong F1 game year {game_year}; expected {expected_game_year}"), + format!("wrong F1 game year {game_year}; expected one of {accepted_game_years:?}"), )); } let player_car_index = bytes[27] as usize; From fc4d299b84128d1642d23780822db6a3ed059a53 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 17 Sep 2026 12:39:12 +0800 Subject: [PATCH 05/12] chore: add Phase 1 baseline instrumentation --- src/plugins/f1/diagnostics.rs | 26 ++++++-- src/plugins/f1/mod.rs | 2 +- src/renderer/app.rs | 25 ++++++++ tools/measure_phase1.ps1 | 115 ++++++++++++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 6 deletions(-) create mode 100644 tools/measure_phase1.ps1 diff --git a/src/plugins/f1/diagnostics.rs b/src/plugins/f1/diagnostics.rs index f590dd3..897babe 100644 --- a/src/plugins/f1/diagnostics.rs +++ b/src/plugins/f1/diagnostics.rs @@ -3,14 +3,14 @@ use std::net::SocketAddr; use std::time::{Duration, Instant}; use anyhow::Error; -use tracing::{info, warn}; +use tracing::{debug, info, warn}; use super::protocol::{self, RejectReason, HEADER_LEN}; use crate::core::TelemetryData; const DISTINCT_HEADER_LOG_LIMIT: usize = 12; const REJECTION_LOG_LIMIT_PER_REASON: u64 = 3; -const SUMMARY_INTERVAL: Duration = Duration::from_secs(5); +const SUMMARY_INTERVAL: Duration = Duration::from_secs(30); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct HeaderPreview { @@ -60,6 +60,7 @@ pub struct Diagnostics { distinct_headers: HashSet, last_summary_at: Instant, last_summary_received: u64, + last_summary_normalized: u64, } impl Default for Diagnostics { @@ -82,6 +83,7 @@ impl Default for Diagnostics { distinct_headers: HashSet::new(), last_summary_at: Instant::now(), last_summary_received: 0, + last_summary_normalized: 0, } } } @@ -103,7 +105,7 @@ impl Diagnostics { if self.distinct_headers.len() < DISTINCT_HEADER_LOG_LIMIT && self.distinct_headers.insert(header) { - info!( + debug!( sender = %source, packet_len = header.packet_len, format = header.packet_format, @@ -148,7 +150,7 @@ impl Diagnostics { if let Some(sample) = sample { self.normalized_frames += 1; if self.normalized_frames <= 3 { - info!( + debug!( format = sample.source.protocol_format, frame = sample.source.frame_identifier, overall_frame = sample.source.overall_frame_identifier, @@ -228,9 +230,23 @@ impl Diagnostics { if self.last_summary_at.elapsed() >= SUMMARY_INTERVAL && self.datagrams_received != self.last_summary_received { - self.log_summary("periodic"); + let elapsed_seconds = self.last_summary_at.elapsed().as_secs_f64(); + let datagram_delta = self.datagrams_received - self.last_summary_received; + let normalized_delta = self.normalized_frames - self.last_summary_normalized; + info!( + interval_seconds = elapsed_seconds, + datagram_rate_hz = datagram_delta as f64 / elapsed_seconds, + normalized_rate_hz = normalized_delta as f64 / elapsed_seconds, + datagrams_received = self.datagrams_received, + telemetry_decoded = self.telemetry_decoded, + status_decoded = self.status_decoded, + normalized_frames = self.normalized_frames, + stale_telemetry_frames = self.stale_telemetry_frames, + "F1 runtime counters" + ); self.last_summary_at = Instant::now(); self.last_summary_received = self.datagrams_received; + self.last_summary_normalized = self.normalized_frames; } } } diff --git a/src/plugins/f1/mod.rs b/src/plugins/f1/mod.rs index 24098c7..b1eef4d 100644 --- a/src/plugins/f1/mod.rs +++ b/src/plugins/f1/mod.rs @@ -75,7 +75,7 @@ impl GamePlugin for F1Plugin { info!(address = %self.bind_address, port = self.port, "F1 UDP listener ready"); info!( distinct_header_limit = 12, - summary_interval_seconds = 5, + summary_interval_seconds = 30, "F1 UDP live diagnostics active; awaiting datagrams" ); self.socket = Some(socket); diff --git a/src/renderer/app.rs b/src/renderer/app.rs index 67a4ab4..fc6e345 100644 --- a/src/renderer/app.rs +++ b/src/renderer/app.rs @@ -19,6 +19,7 @@ const MIN_HEIGHT: f32 = 130.0; /// Track position strip height and gap below the main content. const STRIP_H: f32 = 10.0; const STRIP_GAP: f32 = 3.0; +const RUNTIME_COUNTER_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); // ── Background poller ───────────────────────────────────────────────────────── @@ -64,6 +65,8 @@ pub struct SimTraceApp { analysis: Arc>, active_provider_config: ProviderConfig, last_update_at: std::time::Instant, + runtime_counter_started_at: std::time::Instant, + ui_updates_since_report: u64, } impl SimTraceApp { @@ -99,6 +102,8 @@ impl SimTraceApp { analysis: Arc::new(Mutex::new(LapStore::new())), active_provider_config: provider_config, last_update_at: std::time::Instant::now(), + runtime_counter_started_at: std::time::Instant::now(), + ui_updates_since_report: 0, } } @@ -169,6 +174,7 @@ impl eframe::App for SimTraceApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let now = std::time::Instant::now(); + self.ui_updates_since_report += 1; let frame_dt = now .duration_since(self.last_update_at) .as_secs_f32() @@ -672,6 +678,25 @@ impl eframe::App for SimTraceApp { } } ctx.request_repaint_after(render_interval(&self.settings)); + + let counter_elapsed = self.runtime_counter_started_at.elapsed(); + if counter_elapsed >= RUNTIME_COUNTER_INTERVAL { + let elapsed_seconds = counter_elapsed.as_secs_f64(); + tracing::info!( + interval_seconds = elapsed_seconds, + ui_update_rate_hz = self.ui_updates_since_report as f64 / elapsed_seconds, + configured_ui_fps = self.settings.graph.validated_ui_fps(), + window_seconds = self.settings.graph.window_seconds, + phase_plot_open = self.settings.graph.phase_plot_open, + history_samples = self.buffer.len(), + current_lap_samples = analysis_snapshot.sample_count(), + reference_lap_samples = + analysis_snapshot.reference_lap.as_ref().map_or(0, Vec::len), + "SimTrace runtime counters" + ); + self.runtime_counter_started_at = std::time::Instant::now(); + self.ui_updates_since_report = 0; + } } } diff --git a/tools/measure_phase1.ps1 b/tools/measure_phase1.ps1 new file mode 100644 index 0000000..e761494 --- /dev/null +++ b/tools/measure_phase1.ps1 @@ -0,0 +1,115 @@ +param( + [Parameter(Mandatory = $true)] + [ValidateSet('A', 'B', 'C', 'D', 'LONG')] + [string]$Scenario, + + [ValidateRange(0, 600)] + [int]$WarmupSeconds = 10, + + [ValidateRange(10, 1800)] + [int]$DurationSeconds = 60, + + [ValidateRange(200, 5000)] + [int]$SampleIntervalMilliseconds = 1000, + + [string]$OutputDirectory = 'benchmark-results' +) + +$ErrorActionPreference = 'Stop' + +$scenarioSettings = @{ + A = @{ WindowSeconds = 10; PhasePlot = $false } + B = @{ WindowSeconds = 30; PhasePlot = $false } + C = @{ WindowSeconds = 10; PhasePlot = $true } + D = @{ WindowSeconds = 30; PhasePlot = $true } + LONG = @{ WindowSeconds = $null; PhasePlot = $null } +} + +$process = Get-Process -Name 'simtrace' -ErrorAction Stop | Select-Object -First 1 +$logicalProcessors = [Environment]::ProcessorCount +$settings = $scenarioSettings[$Scenario] +$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$root = [IO.Path]::GetFullPath((Join-Path (Get-Location) $OutputDirectory)) +New-Item -ItemType Directory -Force -Path $root | Out-Null +$csvPath = Join-Path $root ("phase1-{0}-{1}.csv" -f $Scenario.ToLowerInvariant(), $timestamp) +$summaryPath = Join-Path $root ("phase1-{0}-{1}-summary.json" -f $Scenario.ToLowerInvariant(), $timestamp) + +Write-Host ("Scenario {0}: window={1}, phase_plot={2}" -f $Scenario, $settings.WindowSeconds, $settings.PhasePlot) +Write-Host ("Process {0}, PID {1}; warm-up {2}s, measurement {3}s" -f $process.ProcessName, $process.Id, $WarmupSeconds, $DurationSeconds) + +if ($WarmupSeconds -gt 0) { + Start-Sleep -Seconds $WarmupSeconds +} + +$rows = [Collections.Generic.List[object]]::new() +$process.Refresh() +$previousWall = [DateTime]::UtcNow +$previousCpuSeconds = $process.TotalProcessorTime.TotalSeconds +$measurementStarted = $previousWall +$startWorkingSet = $process.WorkingSet64 +$startPrivate = $process.PrivateMemorySize64 + +while (([DateTime]::UtcNow - $measurementStarted).TotalSeconds -lt $DurationSeconds) { + Start-Sleep -Milliseconds $SampleIntervalMilliseconds + $process.Refresh() + if ($process.HasExited) { + throw 'SimTrace exited during measurement.' + } + + $now = [DateTime]::UtcNow + $cpuSeconds = $process.TotalProcessorTime.TotalSeconds + $wallSeconds = ($now - $previousWall).TotalSeconds + $singleCorePercent = (($cpuSeconds - $previousCpuSeconds) / $wallSeconds) * 100.0 + $machinePercent = $singleCorePercent / $logicalProcessors + + $rows.Add([pscustomobject]@{ + timestamp_utc = $now.ToString('o') + elapsed_seconds = ($now - $measurementStarted).TotalSeconds + cpu_single_core_percent = $singleCorePercent + cpu_machine_percent = $machinePercent + working_set_mib = $process.WorkingSet64 / 1MB + private_memory_mib = $process.PrivateMemorySize64 / 1MB + thread_count = $process.Threads.Count + handle_count = $process.HandleCount + }) + + $previousWall = $now + $previousCpuSeconds = $cpuSeconds +} + +$rows | Export-Csv -LiteralPath $csvPath -NoTypeInformation -Encoding UTF8 +$endWorkingSet = $process.WorkingSet64 +$endPrivate = $process.PrivateMemorySize64 +$cpuSingle = @($rows | ForEach-Object { $_.cpu_single_core_percent }) +$cpuMachine = @($rows | ForEach-Object { $_.cpu_machine_percent }) +$workingSets = @($rows | ForEach-Object { $_.working_set_mib }) +$privateValues = @($rows | ForEach-Object { $_.private_memory_mib }) + +$summary = [ordered]@{ + scenario = $Scenario + expected_window_seconds = $settings.WindowSeconds + expected_phase_plot_open = $settings.PhasePlot + process_id = $process.Id + logical_processors = $logicalProcessors + warmup_seconds = $WarmupSeconds + measured_seconds = ([DateTime]::UtcNow - $measurementStarted).TotalSeconds + samples = $rows.Count + cpu_single_core_percent_mean = ($cpuSingle | Measure-Object -Average).Average + cpu_single_core_percent_max = ($cpuSingle | Measure-Object -Maximum).Maximum + cpu_machine_percent_mean = ($cpuMachine | Measure-Object -Average).Average + cpu_machine_percent_max = ($cpuMachine | Measure-Object -Maximum).Maximum + working_set_mib_start = $startWorkingSet / 1MB + working_set_mib_end = $endWorkingSet / 1MB + working_set_mib_delta = ($endWorkingSet - $startWorkingSet) / 1MB + working_set_mib_max = ($workingSets | Measure-Object -Maximum).Maximum + private_memory_mib_start = $startPrivate / 1MB + private_memory_mib_end = $endPrivate / 1MB + private_memory_mib_delta = ($endPrivate - $startPrivate) / 1MB + private_memory_mib_max = ($privateValues | Measure-Object -Maximum).Maximum + csv_path = $csvPath +} + +$summary | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $summaryPath -Encoding UTF8 +$summary | ConvertTo-Json -Depth 4 +Write-Host ("CSV: {0}" -f $csvPath) +Write-Host ("Summary: {0}" -f $summaryPath) From ed6fb9af8542f2e171657212ceaab7a09324cecf Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 17 Sep 2026 16:37:27 +0800 Subject: [PATCH 06/12] fix: cap telemetry visualization cadence --- src/config/settings.rs | 40 ++--- src/renderer/app.rs | 321 ++++++++++++++++++++++-------------- src/renderer/phase_plot.rs | 89 ++++++---- src/renderer/trace_graph.rs | 102 +++++++----- 4 files changed, 328 insertions(+), 224 deletions(-) diff --git a/src/config/settings.rs b/src/config/settings.rs index 519ff60..4f6c940 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -64,24 +64,6 @@ pub struct GraphSettings { pub lap_comparison_open: bool, pub show_tc: bool, pub show_speed: bool, - #[serde(default = "default_ui_fps")] - pub ui_fps: u32, -} - -fn default_ui_fps() -> u32 { - 60 -} - -impl GraphSettings { - pub const VALID_UI_FPS: [u32; 4] = [30, 60, 90, 120]; - - pub fn validated_ui_fps(&self) -> u32 { - if Self::VALID_UI_FPS.contains(&self.ui_fps) { - self.ui_fps - } else { - default_ui_fps() - } - } } fn default_true() -> bool { @@ -208,7 +190,6 @@ impl Default for AppSettings { lap_comparison_open: false, show_tc: true, show_speed: true, - ui_fps: default_ui_fps(), }, colors: ColorScheme { throttle: "#00FF00".to_string(), @@ -303,7 +284,6 @@ mod tests { assert_eq!(s.collector.plugin, "mock"); assert_eq!(s.overlay.opacity, 1.0); assert_eq!(s.graph.window_seconds, 10.0); - assert_eq!(s.graph.validated_ui_fps(), 60); } #[test] @@ -366,7 +346,6 @@ mod tests { let s: AppSettings = toml::from_str(toml_str).unwrap(); assert_eq!(s.colors.clutch, default_clutch_color()); assert!(!s.graph.speed_mph); - assert_eq!(s.graph.validated_ui_fps(), 60); } #[test] @@ -396,13 +375,16 @@ mod tests { } #[test] - fn render_rate_is_validated() { - let mut settings = AppSettings::default(); - for fps in GraphSettings::VALID_UI_FPS { - settings.graph.ui_fps = fps; - assert_eq!(settings.graph.validated_ui_fps(), fps); - } - settings.graph.ui_fps = 75; - assert_eq!(settings.graph.validated_ui_fps(), 60); + fn legacy_ui_fps_field_is_ignored() { + let mut value = toml::Value::try_from(AppSettings::default()).unwrap(); + value + .get_mut("graph") + .and_then(toml::Value::as_table_mut) + .unwrap() + .insert("ui_fps".to_owned(), toml::Value::Integer(120)); + + let restored: AppSettings = value.try_into().unwrap(); + let serialized = toml::to_string_pretty(&restored).unwrap(); + assert!(!serialized.contains("ui_fps")); } } diff --git a/src/renderer/app.rs b/src/renderer/app.rs index fc6e345..62224b9 100644 --- a/src/renderer/app.rs +++ b/src/renderer/app.rs @@ -6,6 +6,7 @@ use crate::plugins::ProviderConfig; use eframe::egui; use egui::color_picker::{color_edit_button_srgba, Alpha}; use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; // The buffer is kept larger than the maximum display window so the slider can // show the full range without data disappearing at the top. @@ -20,6 +21,44 @@ const MIN_HEIGHT: f32 = 130.0; const STRIP_H: f32 = 10.0; const STRIP_GAP: f32 = 3.0; const RUNTIME_COUNTER_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); +const VISUALIZATION_CAP_HZ: u64 = 60; +const VISUALIZATION_INTERVAL: Duration = Duration::from_nanos(1_000_000_000 / VISUALIZATION_CAP_HZ); +const IDLE_REPAINT_INTERVAL: Duration = Duration::from_millis(250); + +struct VisualizationGate { + next_due: Instant, +} + +impl VisualizationGate { + fn new(now: Instant) -> Self { + Self { next_due: now } + } + + fn take_due(&mut self, now: Instant) -> bool { + if now < self.next_due { + return false; + } + self.next_due = now + VISUALIZATION_INTERVAL; + true + } + + fn remaining(&self, now: Instant) -> Duration { + self.next_due.saturating_duration_since(now) + } +} + +#[derive(Default)] +struct VisualizationSnapshot { + latest: Option, + points: Vec, + analysis: LapStore, +} + +#[derive(Default)] +struct TrackStripCache { + rect: Option, + shapes: Vec, +} // ── Background poller ───────────────────────────────────────────────────────── @@ -67,10 +106,17 @@ pub struct SimTraceApp { last_update_at: std::time::Instant, runtime_counter_started_at: std::time::Instant, ui_updates_since_report: u64, + visualization_gate: VisualizationGate, + visualization_snapshot: Arc, + visualization_work_since_report: u64, + trace_graph_cache: Arc>, + phase_plot_cache: Arc>, + track_strip_cache: Arc>, } impl SimTraceApp { pub fn new(cc: &eframe::CreationContext<'_>) -> Self { + cc.egui_ctx.set_theme(egui::Theme::Dark); cc.egui_ctx.set_visuals(egui::Visuals { panel_fill: egui::Color32::TRANSPARENT, window_fill: egui::Color32::TRANSPARENT, @@ -84,6 +130,7 @@ impl SimTraceApp { let max_steering_angle = crate::plugins::create_plugin(&active_plugin, &provider_config) .map(|p| p.get_config().max_steering_angle) .unwrap_or(450.0); + let now = Instant::now(); Self { settings, buffer: Arc::new(TelemetryBuffer::new(std::time::Duration::from_secs( @@ -101,9 +148,15 @@ impl SimTraceApp { parsed_colors, analysis: Arc::new(Mutex::new(LapStore::new())), active_provider_config: provider_config, - last_update_at: std::time::Instant::now(), - runtime_counter_started_at: std::time::Instant::now(), + last_update_at: now, + runtime_counter_started_at: now, ui_updates_since_report: 0, + visualization_gate: VisualizationGate::new(now), + visualization_snapshot: Arc::new(VisualizationSnapshot::default()), + visualization_work_since_report: 0, + trace_graph_cache: Arc::new(Mutex::new(Default::default())), + phase_plot_cache: Arc::new(Mutex::new(Default::default())), + track_strip_cache: Arc::new(Mutex::new(Default::default())), } } @@ -180,14 +233,6 @@ impl eframe::App for SimTraceApp { .as_secs_f32() .min(0.25); self.last_update_at = now; - // Force dark visuals every frame — prevents the OS light theme from - // overriding our settings (observed on Windows 11 with light mode). - ctx.set_visuals(egui::Visuals { - panel_fill: egui::Color32::TRANSPARENT, - window_fill: egui::Color32::TRANSPARENT, - ..egui::Visuals::dark() - }); - // Track window geometry for persistence (skip height when minimized) if let Some(inner) = ctx.input(|i| i.viewport().inner_rect) { self.settings.overlay.width = inner.width(); @@ -214,24 +259,39 @@ impl eframe::App for SimTraceApp { self.activate_plugin(); } - // ── Read latest telemetry ──────────────────────────────────────────── - if self.running { - if let Some(pt) = self.buffer.latest() { - self.current_steering = pt + // Window/compositor events may call App::update earlier than requested. + // Refresh telemetry-derived visualization state only at the fixed cap. + let visualization_due = self.visualization_gate.take_due(now); + if visualization_due { + let latest = self.running.then(|| self.buffer.latest()).flatten(); + let window = Duration::from_secs_f64(self.settings.graph.window_seconds); + let points = if self.running { + self.buffer + .get_points() + .into_iter() + .filter(|point| now.duration_since(point.captured_at) <= window) + .collect() + } else { + Vec::new() + }; + let analysis = self.analysis.lock().unwrap().clone(); + self.current_steering = latest.as_ref().map_or(0.0, |point| { + point .telemetry - .effective_steering_degrees(self.max_steering_angle); - } + .effective_steering_degrees(self.max_steering_angle) + }); + self.visualization_snapshot = Arc::new(VisualizationSnapshot { + latest, + points, + analysis, + }); + self.visualization_work_since_report += 1; } - // Clone the Arc so the closure below can take &mut self freely. - let buffer = if self.running { - Some(self.buffer.clone()) - } else { - None - }; - // Clone the reference lap so the closure can read it without borrowing self. - let analysis_snapshot = self.analysis.lock().unwrap().clone(); - let reference_lap = analysis_snapshot.reference_lap.clone(); + let visualization = Arc::clone(&self.visualization_snapshot); + let trace_graph_cache = Arc::clone(&self.trace_graph_cache); + let track_strip_cache = Arc::clone(&self.track_strip_cache); + let mut bar_animation_active = false; egui::CentralPanel::default() .frame(egui::Frame::NONE) .show(ctx, |ui| { @@ -257,6 +317,7 @@ impl eframe::App for SimTraceApp { let rate = if target > self.bar_alpha { 16.0 } else { 5.0 }; let blend = 1.0 - (-rate * frame_dt).exp(); self.bar_alpha += (target - self.bar_alpha) * blend; + bar_animation_active = (target - self.bar_alpha).abs() > 0.001; let ba = (a as f32 * self.bar_alpha) as u8; // ── Title bar — same width as content card ─────────────────── @@ -467,12 +528,14 @@ impl eframe::App for SimTraceApp { &mut content_ui, &mut self.settings, &self.parsed_colors, - buffer.as_ref(), + &visualization, + visualization_due, + &trace_graph_cache, + &track_strip_cache, self.current_steering, self.max_steering_angle, a, cap_r, - reference_lap.as_deref(), ); } else { let font_size = (content_rect.height() * 0.28).clamp(14.0, 42.0); @@ -546,8 +609,11 @@ impl eframe::App for SimTraceApp { &mut self.settings, &mut self.running, &mut self.save_toast, - buffer.as_ref(), + visualization.latest.as_ref().is_some_and(|point| { + point.captured_at.elapsed().as_secs_f32() <= 2.0 + }), &self.analysis, + &visualization.analysis, ); }); // Re-derive parsed colors in case the color pickers changed them. @@ -560,11 +626,12 @@ impl eframe::App for SimTraceApp { // ── Phase plot viewport ─────────────────────────────────────────────── if self.settings.graph.phase_plot_open { - let buffer_arc = self.buffer.clone(); let graph_settings = self.settings.graph.clone(); let colors = self.parsed_colors.clone(); let opacity = self.settings.overlay.opacity; let max_steering_angle = self.max_steering_angle; + let phase_plot_cache = Arc::clone(&self.phase_plot_cache); + let visualization = Arc::clone(&visualization); let close_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let close_arc = close_flag.clone(); @@ -583,13 +650,18 @@ impl eframe::App for SimTraceApp { .show(ctx, |ui| { let size = ui.available_size(); let closed = crate::renderer::PhasePlot::new( - Some(&*buffer_arc), + &visualization.points, &graph_settings, &colors, opacity, max_steering_angle, ) - .show(ui, size); + .show( + ui, + size, + visualization_due, + &mut phase_plot_cache.lock().unwrap(), + ); if closed { close_arc.store(true, std::sync::atomic::Ordering::Relaxed); } @@ -620,13 +692,13 @@ impl eframe::App for SimTraceApp { // ── Lap comparison viewport ─────────────────────────────────────────── if self.settings.graph.lap_comparison_open { - let reference = analysis_snapshot.reference_lap.clone(); - let current = analysis_snapshot.current_lap().to_vec(); - let current_track_pos = analysis_snapshot + let current_track_pos = visualization + .analysis .current_lap() .last() .map(|p| p.track_position) .unwrap_or(0.0); + let visualization = Arc::clone(&visualization); let colors = self.parsed_colors.clone(); let opacity = self.settings.overlay.opacity; @@ -647,8 +719,8 @@ impl eframe::App for SimTraceApp { .show(ctx, |ui| { let size = ui.available_size(); let closed = crate::renderer::LapComparison::new( - reference.as_ref(), - ¤t, + visualization.analysis.reference_lap.as_ref(), + visualization.analysis.current_lap(), current_track_pos, &colors, opacity, @@ -677,7 +749,18 @@ impl eframe::App for SimTraceApp { let _ = self.settings.save_to_config_path(); } } - ctx.request_repaint_after(render_interval(&self.settings)); + let visualization_is_live = self.running + && visualization + .latest + .as_ref() + .is_some_and(|point| point.captured_at.elapsed() <= Duration::from_secs(2)); + if visualization_is_live || bar_animation_active { + ctx.request_repaint_after(self.visualization_gate.remaining(Instant::now())); + } else if self.running { + // Stay responsive to a newly connected game without keeping an idle + // overlay in a forced 60 Hz loop. + ctx.request_repaint_after(IDLE_REPAINT_INTERVAL); + } let counter_elapsed = self.runtime_counter_started_at.elapsed(); if counter_elapsed >= RUNTIME_COUNTER_INTERVAL { @@ -685,17 +768,23 @@ impl eframe::App for SimTraceApp { tracing::info!( interval_seconds = elapsed_seconds, ui_update_rate_hz = self.ui_updates_since_report as f64 / elapsed_seconds, - configured_ui_fps = self.settings.graph.validated_ui_fps(), + visualization_work_rate_hz = + self.visualization_work_since_report as f64 / elapsed_seconds, + visualization_cap_hz = VISUALIZATION_CAP_HZ, window_seconds = self.settings.graph.window_seconds, phase_plot_open = self.settings.graph.phase_plot_open, history_samples = self.buffer.len(), - current_lap_samples = analysis_snapshot.sample_count(), - reference_lap_samples = - analysis_snapshot.reference_lap.as_ref().map_or(0, Vec::len), + current_lap_samples = visualization.analysis.sample_count(), + reference_lap_samples = visualization + .analysis + .reference_lap + .as_ref() + .map_or(0, Vec::len), "SimTrace runtime counters" ); self.runtime_counter_started_at = std::time::Instant::now(); self.ui_updates_since_report = 0; + self.visualization_work_since_report = 0; } } } @@ -707,33 +796,29 @@ fn draw_telemetry( ui: &mut egui::Ui, settings: &mut AppSettings, colors: &ParsedColors, - buffer: Option<&Arc>, + visualization: &VisualizationSnapshot, + rebuild_visualization: bool, + trace_graph_cache: &Arc>, + track_strip_cache: &Arc>, current_steering: f32, max_steering_angle: f32, a: u8, cap_r: f32, - reference_lap: Option<&[crate::core::lap_store::LapPoint]>, ) { let opacity = settings.overlay.opacity; let available = ui.available_rect_before_wrap(); - let latest = buffer.and_then(|b| b.latest()); + let latest = visualization.latest.as_ref(); let capabilities = latest - .as_ref() .map(|point| point.telemetry.capabilities) .unwrap_or_default(); - let throttle = latest.as_ref().map(|p| p.telemetry.throttle).unwrap_or(0.0); - let brake = latest.as_ref().map(|p| p.telemetry.brake).unwrap_or(0.0); - let clutch = latest.as_ref().map(|p| p.telemetry.clutch).unwrap_or(0.0); - let abs_on = - capabilities.abs_activity && latest.as_ref().map(|p| p.abs_active).unwrap_or(false); - let tc_on = latest - .as_ref() - .map(|p| p.telemetry.tc_active) - .unwrap_or(false) - && capabilities.tc_activity; - let gear = latest.as_ref().map(|p| p.telemetry.gear).unwrap_or(0); - let speed_ms = latest.as_ref().map(|p| p.telemetry.speed).unwrap_or(0.0); + let throttle = latest.map(|p| p.telemetry.throttle).unwrap_or(0.0); + let brake = latest.map(|p| p.telemetry.brake).unwrap_or(0.0); + let clutch = latest.map(|p| p.telemetry.clutch).unwrap_or(0.0); + let abs_on = capabilities.abs_activity && latest.map(|p| p.abs_active).unwrap_or(false); + let tc_on = latest.map(|p| p.telemetry.tc_active).unwrap_or(false) && capabilities.tc_activity; + let gear = latest.map(|p| p.telemetry.gear).unwrap_or(0); + let speed_ms = latest.map(|p| p.telemetry.speed).unwrap_or(0.0); let bar_gap = 4.0_f32; let gap = 8.0_f32; @@ -757,16 +842,19 @@ fn draw_telemetry( let graph_h = content_h; // No data arriving? Show overlay on graph area. - let is_waiting = latest - .as_ref() - .is_none_or(|p| p.captured_at.elapsed().as_secs_f32() > 2.0); + let is_waiting = latest.is_none_or(|p| p.captured_at.elapsed().as_secs_f32() > 2.0); let graph_rect = egui::Rect::from_min_size(available.min, egui::vec2(graph_w, graph_h)); ui.spacing_mut().item_spacing.x = 0.0; ui.horizontal(|ui| { // ── Trace graph ────────────────────────────────────────────────────── - crate::renderer::TraceGraph::new(buffer.map(|v| &**v), &settings.graph, colors, opacity) - .show(ui, egui::vec2(graph_w, graph_h)); + crate::renderer::TraceGraph::new(&visualization.points, &settings.graph, colors, opacity) + .show( + ui, + egui::vec2(graph_w, graph_h), + rebuild_visualization, + &mut trace_graph_cache.lock().unwrap(), + ); // Gap between graph and bars ui.allocate_exact_size(egui::vec2(gap, content_h), egui::Sense::hover()); @@ -943,10 +1031,7 @@ fn draw_telemetry( // ── Track position strip ───────────────────────────────────────────────── if settings.graph.show_track_strip && capabilities.track_position { - let current_track_pos = latest - .as_ref() - .map(|p| p.telemetry.track_position) - .unwrap_or(0.0); + let current_track_pos = latest.map(|p| p.telemetry.track_position).unwrap_or(0.0); let strip_rect = egui::Rect::from_min_size( egui::pos2(available.min.x, available.max.y - STRIP_H), egui::vec2(available.width(), STRIP_H), @@ -954,12 +1039,14 @@ fn draw_telemetry( draw_track_strip( ui.painter(), strip_rect, - buffer, + &visualization.points, current_track_pos, &settings.graph, colors, a, - reference_lap, + visualization.analysis.reference_lap.as_deref(), + rebuild_visualization, + &mut track_strip_cache.lock().unwrap(), ); } @@ -980,14 +1067,15 @@ fn draw_telemetry( fn draw_track_strip( painter: &egui::Painter, rect: egui::Rect, - buffer: Option<&Arc>, + points: &[crate::core::TelemetryPoint], current_pos: f32, settings: &crate::config::GraphSettings, colors: &ParsedColors, a: u8, reference_lap: Option<&[crate::core::lap_store::LapPoint]>, + rebuild_visualization: bool, + cache: &mut TrackStripCache, ) { - // Background painter.rect_filled( rect, 2.0, @@ -1000,6 +1088,15 @@ fn draw_track_strip( egui::StrokeKind::Middle, ); + let rect_changed = cache.rect != Some(rect); + if !rebuild_visualization && !rect_changed { + painter.extend(cache.shapes.iter().cloned()); + return; + } + + cache.rect = Some(rect); + cache.shapes.clear(); + // ── Reference lap ghost ───────────────────────────────────────────────── // Bin reference points by pixel column and take the max brake/throttle // per column, which avoids aliasing when many points map to the same pixel. @@ -1027,7 +1124,7 @@ fn draw_track_strip( if brake_bins[col] > 0.02 { let h = (brake_bins[col] * inner_h).max(1.0); let alpha = (ghost_opacity * brake_bins[col]).min(255.0) as u8; - painter.line_segment( + cache.shapes.push(egui::Shape::line_segment( [ egui::pos2(x, rect.max.y - 1.0), egui::pos2(x, rect.max.y - 1.0 - h), @@ -1036,14 +1133,14 @@ fn draw_track_strip( 1.0, egui::Color32::from_rgba_unmultiplied(br, bg, bb, alpha), ), - ); + )); } // Throttle: from top down if throttle_bins[col] > 0.02 { let h = (throttle_bins[col] * inner_h).max(1.0); let alpha = (ghost_opacity * throttle_bins[col]).min(255.0) as u8; - painter.line_segment( + cache.shapes.push(egui::Shape::line_segment( [ egui::pos2(x, rect.min.y + 1.0), egui::pos2(x, rect.min.y + 1.0 + h), @@ -1052,23 +1149,18 @@ fn draw_track_strip( 1.0, egui::Color32::from_rgba_unmultiplied(tr, tg, tb, alpha), ), - ); + )); } } } // Live brake and throttle blips from recent history. - if let Some(buf) = buffer { - let points = buf.get_points(); + if !points.is_empty() { let now = std::time::Instant::now(); let window_secs = settings.window_seconds as f32; - let window_dur = std::time::Duration::from_secs_f64(settings.window_seconds); let inner_h = rect.height() - 2.0; - for pt in points - .iter() - .filter(|p| now.duration_since(p.captured_at) <= window_dur) - { + for pt in points { let age = now.duration_since(pt.captured_at).as_secs_f32(); let freshness = (1.0 - age / window_secs).clamp(0.0, 1.0); let x = rect.min.x + pt.telemetry.track_position * rect.width(); @@ -1084,13 +1176,13 @@ fn draw_track_strip( let [r, g, b, ca] = color.to_array(); let alpha = ((ca as f32) * (a as f32 / 255.0) * brake_intensity).min(255.0) as u8; let h = (pt.telemetry.brake * inner_h).max(1.0); - painter.line_segment( + cache.shapes.push(egui::Shape::line_segment( [ egui::pos2(x, rect.max.y - 1.0), egui::pos2(x, rect.max.y - 1.0 - h), ], egui::Stroke::new(1.5, egui::Color32::from_rgba_unmultiplied(r, g, b, alpha)), - ); + )); } // Throttle: from top down @@ -1100,23 +1192,24 @@ fn draw_track_strip( let alpha = ((ca as f32) * (a as f32 / 255.0) * throttle_intensity).min(255.0) as u8; let h = (pt.telemetry.throttle * inner_h).max(1.0); - painter.line_segment( + cache.shapes.push(egui::Shape::line_segment( [ egui::pos2(x, rect.min.y + 1.0), egui::pos2(x, rect.min.y + 1.0 + h), ], egui::Stroke::new(1.5, egui::Color32::from_rgba_unmultiplied(r, g, b, alpha)), - ); + )); } } } // Current position cursor — bright white vertical line. let cx = rect.min.x + current_pos.clamp(0.0, 1.0) * rect.width(); - painter.line_segment( + cache.shapes.push(egui::Shape::line_segment( [egui::pos2(cx, rect.min.y), egui::pos2(cx, rect.max.y)], egui::Stroke::new(2.0, with_alpha(egui::Color32::WHITE, a)), - ); + )); + painter.extend(cache.shapes.iter().cloned()); } // ── Config panel ───────────────────────────────────────────────────────────── @@ -1126,18 +1219,15 @@ fn draw_config( settings: &mut AppSettings, running: &mut bool, save_toast: &mut Option, - buffer: Option<&Arc>, + is_live: bool, analysis: &Arc>, + analysis_snapshot: &crate::core::LapStore, ) { // Ensure all widgets (sliders, dropdowns, colour pickers) use dark styling // regardless of the OS theme reported by the platform layer. *ui.visuals_mut() = egui::Visuals::dark(); ui.visuals_mut().override_text_color = Some(egui::Color32::from_gray(210)); - let is_live = buffer - .and_then(|b| b.latest()) - .is_some_and(|p| p.captured_at.elapsed().as_secs_f32() <= 2.0); - // Status dot + label + stop/start + save (right-aligned) — all inline ui.horizontal(|ui| { let (dot_color, status_text): (egui::Color32, &str) = if !*running { @@ -1234,22 +1324,6 @@ fn draw_config( section_header(ui, "DISPLAY"); ui.checkbox(&mut settings.graph.show_legend, "Show legend"); ui.checkbox(&mut settings.graph.show_track_strip, "Show track strip"); - ui.horizontal(|ui| { - ui.label( - egui::RichText::new("UI refresh") - .size(11.0) - .color(LABEL_MID), - ); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - egui::ComboBox::from_id_salt("ui_fps") - .selected_text(format!("{} FPS", settings.graph.validated_ui_fps())) - .show_ui(ui, |ui| { - for fps in crate::config::GraphSettings::VALID_UI_FPS { - ui.selectable_value(&mut settings.graph.ui_fps, fps, format!("{fps} FPS")); - } - }); - }); - }); ui.horizontal(|ui| { ui.label( egui::RichText::new("Speed unit") @@ -1387,7 +1461,7 @@ fn draw_config( settings.graph.lap_comparison_open = !settings.graph.lap_comparison_open; } ui.add_space(4.0); - let ref_status = match &analysis.lock().unwrap().reference_lap { + let ref_status = match &analysis_snapshot.reference_lap { Some(pts) => format!("Ref: {} pts", pts.len()), None => "No reference lap".to_string(), }; @@ -1417,22 +1491,29 @@ fn provider_config(settings: &AppSettings) -> ProviderConfig { } } -fn render_interval(settings: &AppSettings) -> std::time::Duration { - std::time::Duration::from_secs_f64(1.0 / settings.graph.validated_ui_fps() as f64) -} - #[cfg(test)] mod scheduling_tests { use super::*; #[test] - fn configured_refresh_rates_produce_timed_intervals() { - let mut settings = AppSettings::default(); - for fps in crate::config::GraphSettings::VALID_UI_FPS { - settings.graph.ui_fps = fps; - let expected = 1.0 / fps as f64; - assert!((render_interval(&settings).as_secs_f64() - expected).abs() < 1e-9); - } + fn visualization_gate_never_runs_twice_inside_interval() { + let start = Instant::now(); + let mut gate = VisualizationGate::new(start); + + assert!(gate.take_due(start)); + assert!(!gate.take_due(start + VISUALIZATION_INTERVAL / 2)); + assert!(gate.take_due(start + VISUALIZATION_INTERVAL)); + } + + #[test] + fn late_visualization_does_not_schedule_catch_up_bursts() { + let start = Instant::now(); + let mut gate = VisualizationGate::new(start); + assert!(gate.take_due(start)); + + let late = start + VISUALIZATION_INTERVAL * 5; + assert!(gate.take_due(late)); + assert!(!gate.take_due(late + Duration::from_millis(1))); } } diff --git a/src/renderer/phase_plot.rs b/src/renderer/phase_plot.rs index bfc1284..3e0d874 100644 --- a/src/renderer/phase_plot.rs +++ b/src/renderer/phase_plot.rs @@ -1,13 +1,19 @@ //! Brake vs steering phase plot — shows trail braking shape as an X-Y scatter. +use crate::config::{GraphSettings, ParsedColors}; +use crate::core::TelemetryPoint; use egui::{Color32, Painter, Pos2, Rect, Stroke, Vec2}; -use std::time::Duration; -use crate::config::{GraphSettings, ParsedColors}; -use crate::core::TelemetryBuffer; +#[derive(Default)] +pub struct PhasePlotCache { + rect: Option, + shapes: Vec, + effective_steer_max: f32, + has_data: bool, +} pub struct PhasePlot<'a> { - buffer: Option<&'a TelemetryBuffer>, + points: &'a [TelemetryPoint], settings: &'a GraphSettings, colors: &'a ParsedColors, opacity: f32, @@ -16,14 +22,14 @@ pub struct PhasePlot<'a> { impl<'a> PhasePlot<'a> { pub fn new( - buffer: Option<&'a TelemetryBuffer>, + points: &'a [TelemetryPoint], settings: &'a GraphSettings, colors: &'a ParsedColors, opacity: f32, max_steering_angle: f32, ) -> Self { Self { - buffer, + points, settings, colors, opacity, @@ -32,10 +38,16 @@ impl<'a> PhasePlot<'a> { } /// Returns `true` if the close button was clicked. - pub fn show(&self, ui: &mut egui::Ui, size: Vec2) -> bool { + pub fn show( + &self, + ui: &mut egui::Ui, + size: Vec2, + rebuild_visualization: bool, + cache: &mut PhasePlotCache, + ) -> bool { let (rect, _) = ui.allocate_exact_size(size, egui::Sense::empty()); let painter = ui.painter().with_clip_rect(rect); - self.draw(&painter, rect); + self.draw(&painter, rect, rebuild_visualization, cache); // Close button — handled here so we can tint on hover. let close_center = Pos2::new(rect.max.x - 14.0, rect.min.y + 12.0); @@ -60,24 +72,30 @@ impl<'a> PhasePlot<'a> { close_resp.clicked() } - fn draw(&self, painter: &Painter, rect: Rect) { + fn draw( + &self, + painter: &Painter, + rect: Rect, + rebuild_visualization: bool, + cache: &mut PhasePlotCache, + ) { // Compute the effective steering range from recent data so the X axis // auto-scales to what the driver is actually using. A floor of 5 % of // hardware max prevents the axis from over-zooming on tiny/noisy inputs. let steer_floor = (self.max_steering_angle * 0.05).max(5.0); - let effective_steer_max = self - .buffer - .map(|buf| { - let now = std::time::Instant::now(); - let window_dur = Duration::from_secs_f64(self.settings.window_seconds); - buf.get_points() - .into_iter() - .filter(|p| now.duration_since(p.captured_at) <= window_dur) - .map(|p| p.telemetry.steering_angle.abs()) - .fold(0.0_f32, f32::max) - .max(steer_floor) - }) - .unwrap_or(steer_floor); + let rect_changed = cache.rect != Some(rect); + if rebuild_visualization || rect_changed { + cache.rect = Some(rect); + cache.effective_steer_max = self + .points + .iter() + .map(|p| p.telemetry.steering_angle.abs()) + .fold(0.0_f32, f32::max) + .max(steer_floor); + cache.shapes.clear(); + cache.has_data = false; + } + let effective_steer_max = cache.effective_steer_max.max(steer_floor); let bg = self.apply_opacity(self.colors.background); @@ -197,18 +215,16 @@ impl<'a> PhasePlot<'a> { text_color, ); - // Data. - let Some(buffer) = self.buffer else { - self.draw_no_data(painter, plot); + if !rebuild_visualization && !rect_changed { + painter.extend(cache.shapes.iter().cloned()); + if !cache.has_data { + self.draw_no_data(painter, plot); + } return; - }; + } + + // Data. let now = std::time::Instant::now(); - let window_dur = Duration::from_secs_f64(self.settings.window_seconds); - let points: Vec<_> = buffer - .get_points() - .into_iter() - .filter(|p| now.duration_since(p.captured_at) <= window_dur) - .collect(); let steering_threshold = self.settings.trail_brake_threshold; @@ -220,7 +236,8 @@ impl<'a> PhasePlot<'a> { // Build a flat list of (screen pos, base colour, age-based freshness) // for braking points only. Non-braking points are skipped to avoid // drawing a red line along the bottom of the plot. - let brake_pts: Vec<(Pos2, Color32, f32)> = points + let brake_pts: Vec<(Pos2, Color32, f32)> = self + .points .iter() .filter(|p| p.telemetry.brake > 0.01) .map(|point| { @@ -249,6 +266,7 @@ impl<'a> PhasePlot<'a> { self.draw_no_data(painter, plot); return; } + cache.has_data = true; // Draw each consecutive pair with alpha = freshness^1.5 so the tail // fades smoothly to nothing. After brake release the points age out @@ -260,11 +278,12 @@ impl<'a> PhasePlot<'a> { let fade = freshness.powf(1.5); let [r, g, b, a] = color.to_array(); let alpha = ((a as f32) * self.opacity * fade) as u8; - painter.line_segment( + cache.shapes.push(egui::Shape::line_segment( [p0, p1], Stroke::new(lw, Color32::from_rgba_unmultiplied(r, g, b, alpha)), - ); + )); } + painter.extend(cache.shapes.iter().cloned()); } fn draw_no_data(&self, painter: &Painter, plot: Rect) { diff --git a/src/renderer/trace_graph.rs b/src/renderer/trace_graph.rs index 799b97b..b70ead4 100644 --- a/src/renderer/trace_graph.rs +++ b/src/renderer/trace_graph.rs @@ -7,7 +7,14 @@ use egui::{Color32, Pos2, Rect, Response, Stroke, Ui, Vec2}; use crate::config::{GraphSettings, ParsedColors}; -use crate::core::{TelemetryBuffer, TelemetryPoint}; +use crate::core::TelemetryPoint; + +#[derive(Default)] +pub struct TraceGraphCache { + rect: Option, + shapes: Vec, + speed_label: Option, +} #[derive(Clone, Copy, PartialEq)] enum BrakeState { @@ -25,7 +32,7 @@ enum ThrottleState { /// Renders a scrolling trace of pedal inputs against time. pub struct TraceGraph<'a> { - buffer: Option<&'a TelemetryBuffer>, + points: &'a [TelemetryPoint], settings: &'a GraphSettings, colors: &'a ParsedColors, opacity: f32, @@ -33,20 +40,26 @@ pub struct TraceGraph<'a> { impl<'a> TraceGraph<'a> { pub fn new( - buffer: Option<&'a TelemetryBuffer>, + points: &'a [TelemetryPoint], settings: &'a GraphSettings, colors: &'a ParsedColors, opacity: f32, ) -> Self { Self { - buffer, + points, settings, colors, opacity, } } - pub fn show(&self, ui: &mut Ui, size: Vec2) -> Response { + pub fn show( + &self, + ui: &mut Ui, + size: Vec2, + rebuild_visualization: bool, + cache: &mut TraceGraphCache, + ) -> Response { let (rect, response) = ui.allocate_exact_size(size, egui::Sense::empty()); let painter = ui.painter().with_clip_rect(rect); @@ -56,28 +69,28 @@ impl<'a> TraceGraph<'a> { self.draw_grid(&painter, rect); } - if let Some(buffer) = self.buffer { + let rect_changed = cache.rect != Some(rect); + if rebuild_visualization || rect_changed { + cache.rect = Some(rect); + cache.shapes.clear(); + cache.speed_label = None; let now = std::time::Instant::now(); let window_dur = std::time::Duration::from_secs_f64(self.settings.window_seconds); - let points: Vec = buffer - .get_points() - .into_iter() - .filter(|p| now.duration_since(p.captured_at) <= window_dur) - .collect(); - - if !points.is_empty() { - let capabilities = points.last().unwrap().telemetry.capabilities; + + if !self.points.is_empty() { + let capabilities = self.points.last().unwrap().telemetry.capabilities; // Draw order: speed → clutch → throttle → brake/ABS (top, always visible) if self.settings.show_speed && capabilities.speed { - let max_speed = points + let max_speed = self + .points .iter() .map(|p| p.telemetry.speed) .fold(0.0_f32, f32::max); if max_speed > 0.5 { - self.draw_trace( - &painter, + self.build_trace( + &mut cache.shapes, rect, - &points, + self.points, now, window_dur, |p| p.telemetry.speed / max_speed, @@ -94,21 +107,14 @@ impl<'a> TraceGraph<'a> { } else { "kph" }; - let label_color = self.apply_opacity(self.colors.speed); - painter.text( - Pos2::new(rect.max.x - 4.0, rect.min.y + 4.0), - egui::Align2::RIGHT_TOP, - format!("{:.0} {}", speed_val, unit), - egui::FontId::proportional(9.0), - label_color, - ); + cache.speed_label = Some(format!("{:.0} {}", speed_val, unit)); } } if self.settings.show_clutch && capabilities.clutch { - self.draw_trace( - &painter, + self.build_trace( + &mut cache.shapes, rect, - &points, + self.points, now, window_dur, |p| p.telemetry.clutch, @@ -116,13 +122,29 @@ impl<'a> TraceGraph<'a> { ); } if self.settings.show_throttle && capabilities.throttle { - self.draw_throttle_trace(&painter, rect, &points, now, window_dur); + self.build_throttle_trace( + &mut cache.shapes, + rect, + self.points, + now, + window_dur, + ); } if self.settings.show_brake && capabilities.brake { - self.draw_brake_trace(&painter, rect, &points, now, window_dur); + self.build_brake_trace(&mut cache.shapes, rect, self.points, now, window_dur); } } } + painter.extend(cache.shapes.iter().cloned()); + if let Some(label) = &cache.speed_label { + painter.text( + Pos2::new(rect.max.x - 4.0, rect.min.y + 4.0), + egui::Align2::RIGHT_TOP, + label, + egui::FontId::proportional(9.0), + self.apply_opacity(self.colors.speed), + ); + } if self.settings.show_legend { self.draw_legend(&painter, rect); @@ -150,9 +172,9 @@ impl<'a> TraceGraph<'a> { /// Draw a single-colour trace for any scalar telemetry value. #[allow(clippy::too_many_arguments)] - fn draw_trace( + fn build_trace( &self, - painter: &egui::Painter, + shapes: &mut Vec, rect: Rect, points: &[TelemetryPoint], now: std::time::Instant, @@ -171,14 +193,14 @@ impl<'a> TraceGraph<'a> { }) .collect(); if line_points.len() > 1 { - painter.add(egui::Shape::line(line_points, stroke)); + shapes.push(egui::Shape::line(line_points, stroke)); } } /// Draw the throttle trace, colouring segments by TC state. - fn draw_throttle_trace( + fn build_throttle_trace( &self, - painter: &egui::Painter, + shapes: &mut Vec, rect: Rect, points: &[TelemetryPoint], now: std::time::Instant, @@ -233,7 +255,7 @@ impl<'a> TraceGraph<'a> { ThrottleState::Normal => self.colors.throttle, ThrottleState::TcActive => self.colors.tc_active, }; - painter.add(egui::Shape::line( + shapes.push(egui::Shape::line( seg_pts, Stroke::new(self.settings.line_width, self.apply_opacity(color)), )); @@ -241,9 +263,9 @@ impl<'a> TraceGraph<'a> { } /// Draw the brake trace, colouring segments by trail braking / ABS state. - fn draw_brake_trace( + fn build_brake_trace( &self, - painter: &egui::Painter, + shapes: &mut Vec, rect: Rect, points: &[TelemetryPoint], now: std::time::Instant, @@ -337,7 +359,7 @@ impl<'a> TraceGraph<'a> { } } }; - painter.add(egui::Shape::line( + shapes.push(egui::Shape::line( seg_pts, Stroke::new(self.settings.line_width, self.apply_opacity(color)), )); From b9bb67388f4d83708096a81ea76cf423646b3d58 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 17 Sep 2026 19:44:55 +0800 Subject: [PATCH 07/12] feat: add F1 Open HUD beta prototype --- src/config/mod.rs | 2 +- src/config/settings.rs | 35 +++ src/renderer/app.rs | 105 +++++-- src/renderer/f1_open_hud.rs | 565 ++++++++++++++++++++++++++++++++++++ src/renderer/mod.rs | 2 + 5 files changed, 686 insertions(+), 23 deletions(-) create mode 100644 src/renderer/f1_open_hud.rs diff --git a/src/config/mod.rs b/src/config/mod.rs index 0683d90..46f2263 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2,4 +2,4 @@ pub mod settings; -pub use settings::{AppSettings, GraphSettings, ParsedColors}; +pub use settings::{AppSettings, GraphSettings, ParsedColors, UiLayoutMode}; diff --git a/src/config/settings.rs b/src/config/settings.rs index 4f6c940..31e02cf 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -33,6 +33,9 @@ fn default_f1_udp_port() -> u16 { /// Graph visualization settings. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GraphSettings { + /// Selects the stable renderer or an experimental presentation layer. + #[serde(default)] + pub layout_mode: UiLayoutMode, /// Seconds of history shown in the trace graph. pub window_seconds: f64, pub show_grid: bool, @@ -66,6 +69,23 @@ pub struct GraphSettings { pub show_speed: bool, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum UiLayoutMode { + #[default] + Classic, + F1OpenHudBeta, +} + +impl UiLayoutMode { + pub const fn display_name(self) -> &'static str { + match self { + Self::Classic => "Classic", + Self::F1OpenHudBeta => "F1 Open HUD (Beta)", + } + } +} + fn default_true() -> bool { true } @@ -173,6 +193,7 @@ impl Default for AppSettings { f1_udp_port: default_f1_udp_port(), }, graph: GraphSettings { + layout_mode: UiLayoutMode::Classic, window_seconds: 10.0, show_grid: true, show_legend: true, @@ -284,6 +305,7 @@ mod tests { assert_eq!(s.collector.plugin, "mock"); assert_eq!(s.overlay.opacity, 1.0); assert_eq!(s.graph.window_seconds, 10.0); + assert_eq!(s.graph.layout_mode, UiLayoutMode::Classic); } #[test] @@ -346,6 +368,7 @@ mod tests { let s: AppSettings = toml::from_str(toml_str).unwrap(); assert_eq!(s.colors.clutch, default_clutch_color()); assert!(!s.graph.speed_mph); + assert_eq!(s.graph.layout_mode, UiLayoutMode::Classic); } #[test] @@ -387,4 +410,16 @@ mod tests { let serialized = toml::to_string_pretty(&restored).unwrap(); assert!(!serialized.contains("ui_fps")); } + + #[test] + fn f1_open_hud_layout_round_trips() { + let mut settings = AppSettings::default(); + settings.graph.layout_mode = UiLayoutMode::F1OpenHudBeta; + + let serialized = toml::to_string_pretty(&settings).unwrap(); + let restored: AppSettings = toml::from_str(&serialized).unwrap(); + + assert_eq!(restored.graph.layout_mode, UiLayoutMode::F1OpenHudBeta); + assert!(serialized.contains("layout_mode = \"f1_open_hud_beta\"")); + } } diff --git a/src/renderer/app.rs b/src/renderer/app.rs index 62224b9..c17c583 100644 --- a/src/renderer/app.rs +++ b/src/renderer/app.rs @@ -1,6 +1,6 @@ //! Main application -use crate::config::{AppSettings, ParsedColors}; +use crate::config::{AppSettings, ParsedColors, UiLayoutMode}; use crate::core::{DataCollector, LapStore, TelemetryBuffer}; use crate::plugins::ProviderConfig; use eframe::egui; @@ -112,6 +112,7 @@ pub struct SimTraceApp { trace_graph_cache: Arc>, phase_plot_cache: Arc>, track_strip_cache: Arc>, + f1_open_hud_cache: Arc>, } impl SimTraceApp { @@ -157,6 +158,7 @@ impl SimTraceApp { trace_graph_cache: Arc::new(Mutex::new(Default::default())), phase_plot_cache: Arc::new(Mutex::new(Default::default())), track_strip_cache: Arc::new(Mutex::new(Default::default())), + f1_open_hud_cache: Arc::new(Mutex::new(Default::default())), } } @@ -290,6 +292,7 @@ impl eframe::App for SimTraceApp { let visualization = Arc::clone(&self.visualization_snapshot); let trace_graph_cache = Arc::clone(&self.trace_graph_cache); let track_strip_cache = Arc::clone(&self.track_strip_cache); + let f1_open_hud_cache = Arc::clone(&self.f1_open_hud_cache); let mut bar_animation_active = false; egui::CentralPanel::default() @@ -300,6 +303,8 @@ impl eframe::App for SimTraceApp { let a = (opacity * 255.0) as u8; let bar_h = 26.0_f32; let pad = 2.0_f32; + let f1_open_hud = self.settings.graph.layout_mode == UiLayoutMode::F1OpenHudBeta + && is_f1_plugin(&self.settings.collector.plugin); // ── Hover detection + bar fade ─────────────────────────────── let hovered = ctx.input(|i| { @@ -360,7 +365,11 @@ impl eframe::App for SimTraceApp { ui.painter().text( egui::pos2(bar_rect.min.x + 10.0, bar_rect.center().y), egui::Align2::LEFT_CENTER, - "SIMTRACE", + if f1_open_hud { + "SIMTRACE / F1 OPEN HUD · BETA" + } else { + "SIMTRACE" + }, egui::FontId::monospace(10.0), with_alpha(LABEL_MID, ba), ); @@ -502,18 +511,20 @@ impl eframe::App for SimTraceApp { } if !self.minimized { - // ── Content card — stadium shape (rounded right cap) ───────── + // ── Main content ───────────────────────────────────────────── let content_rect = egui::Rect::from_min_max( egui::pos2(screen.min.x + pad, screen.min.y + bar_h), egui::pos2(screen.max.x - pad, screen.max.y - pad), ); - // Cap radius: half the card height → perfect semicircle on the right let cap_r = content_rect.height() / 2.0; - ui.painter().add(egui::Shape::convex_polygon( - stadium_path(content_rect, 5.0, cap_r), - with_alpha(CARD_BG, a), - egui::Stroke::new(1.0, with_alpha(BORDER, a)), - )); + if !f1_open_hud { + // Classic keeps its stadium card; the Beta composition is open. + ui.painter().add(egui::Shape::convex_polygon( + stadium_path(content_rect, 5.0, cap_r), + with_alpha(CARD_BG, a), + egui::Stroke::new(1.0, with_alpha(BORDER, a)), + )); + } // Guard against the transition frame where the window hasn't // resized yet (content_rect would have near-zero height). @@ -524,19 +535,36 @@ impl eframe::App for SimTraceApp { .max_rect(content_rect.shrink(2.0)) .layout(egui::Layout::top_down(egui::Align::LEFT)), ); - draw_telemetry( - &mut content_ui, - &mut self.settings, - &self.parsed_colors, - &visualization, - visualization_due, - &trace_graph_cache, - &track_strip_cache, - self.current_steering, - self.max_steering_angle, - a, - cap_r, - ); + if f1_open_hud { + crate::renderer::F1OpenHud::new( + &visualization.points, + visualization.latest.as_ref(), + &self.settings.graph, + &self.parsed_colors, + self.settings.overlay.opacity, + self.max_steering_angle, + ) + .show( + &mut content_ui, + content_rect.shrink(2.0).size(), + visualization_due, + &mut f1_open_hud_cache.lock().unwrap(), + ); + } else { + draw_telemetry( + &mut content_ui, + &mut self.settings, + &self.parsed_colors, + &visualization, + visualization_due, + &trace_graph_cache, + &track_strip_cache, + self.current_steering, + self.max_steering_angle, + a, + cap_r, + ); + } } else { let font_size = (content_rect.height() * 0.28).clamp(14.0, 42.0); ui.painter().text( @@ -771,6 +799,7 @@ impl eframe::App for SimTraceApp { visualization_work_rate_hz = self.visualization_work_since_report as f64 / elapsed_seconds, visualization_cap_hz = VISUALIZATION_CAP_HZ, + layout_mode = ?self.settings.graph.layout_mode, window_seconds = self.settings.graph.window_seconds, phase_plot_open = self.settings.graph.phase_plot_open, history_samples = self.buffer.len(), @@ -1322,6 +1351,34 @@ fn draw_config( // ── Display ────────────────────────────────────────────────────────────── section_header(ui, "DISPLAY"); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Interface").size(11.0).color(LABEL_MID)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + egui::ComboBox::from_id_salt("layout_mode") + .selected_text(settings.graph.layout_mode.display_name()) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut settings.graph.layout_mode, + UiLayoutMode::Classic, + UiLayoutMode::Classic.display_name(), + ); + ui.add_enabled_ui(is_f1_plugin(&settings.collector.plugin), |ui| { + ui.selectable_value( + &mut settings.graph.layout_mode, + UiLayoutMode::F1OpenHudBeta, + UiLayoutMode::F1OpenHudBeta.display_name(), + ); + }); + }); + }); + }); + if !is_f1_plugin(&settings.collector.plugin) { + ui.label( + egui::RichText::new("F1 Open HUD becomes available with the F1 provider") + .size(9.0) + .color(LABEL_DIM), + ); + } ui.checkbox(&mut settings.graph.show_legend, "Show legend"); ui.checkbox(&mut settings.graph.show_track_strip, "Show track strip"); ui.horizontal(|ui| { @@ -1491,6 +1548,10 @@ fn provider_config(settings: &AppSettings) -> ProviderConfig { } } +fn is_f1_plugin(plugin: &str) -> bool { + matches!(plugin, "f1" | "f1_25") +} + #[cfg(test)] mod scheduling_tests { use super::*; diff --git a/src/renderer/f1_open_hud.rs b/src/renderer/f1_open_hud.rs new file mode 100644 index 0000000..0ba04c6 --- /dev/null +++ b/src/renderer/f1_open_hud.rs @@ -0,0 +1,565 @@ +//! Experimental flat F1 HUD presentation. +//! +//! This renderer consumes the shared, already-windowed visualization snapshot. +//! It never reads or filters the telemetry buffer itself. + +use crate::config::{GraphSettings, ParsedColors}; +use crate::core::TelemetryPoint; +use egui::{Align2, Color32, FontFamily, FontId, Painter, Pos2, Rect, Shape, Stroke, Vec2}; + +const GRID_DIVISIONS: usize = 4; +const HISTORY_BANDS: usize = 4; +const RPM_SEGMENTS: usize = 18; +const F1_RPM_SCALE: f32 = 15_000.0; + +const TEXT_PRIMARY: Color32 = Color32::from_rgb(242, 246, 252); +const TEXT_SECONDARY: Color32 = Color32::from_rgb(170, 181, 196); +const GRID_COLOR: Color32 = Color32::from_rgb(118, 139, 164); +const STEERING_COLOR: Color32 = Color32::from_rgb(75, 178, 255); +const RPM_LOW: Color32 = Color32::from_rgb(61, 166, 255); +const RPM_HIGH: Color32 = Color32::from_rgb(255, 61, 87); + +#[derive(Default)] +pub struct F1OpenHudCache { + rect: Option, + shapes: Vec, + brake_head: Option, + throttle_head: Option, + steering_head: Option, + values: OpenHudValues, +} + +#[derive(Default)] +struct OpenHudValues { + brake: String, + throttle: String, + steering: String, + rpm: String, + gear: String, + speed: String, + speed_unit: &'static str, + abs_active: bool, + tc_active: bool, +} + +pub struct F1OpenHud<'a> { + points: &'a [TelemetryPoint], + latest: Option<&'a TelemetryPoint>, + settings: &'a GraphSettings, + colors: &'a ParsedColors, + opacity: f32, + max_steering_angle: f32, +} + +impl<'a> F1OpenHud<'a> { + pub fn new( + points: &'a [TelemetryPoint], + latest: Option<&'a TelemetryPoint>, + settings: &'a GraphSettings, + colors: &'a ParsedColors, + opacity: f32, + max_steering_angle: f32, + ) -> Self { + Self { + points, + latest, + settings, + colors, + opacity, + max_steering_angle, + } + } + + pub fn show( + &self, + ui: &mut egui::Ui, + size: Vec2, + rebuild_visualization: bool, + cache: &mut F1OpenHudCache, + ) { + let (rect, _) = ui.allocate_exact_size(size, egui::Sense::empty()); + let painter = ui.painter().with_clip_rect(rect); + let rect_changed = cache.rect != Some(rect); + + if rebuild_visualization || rect_changed { + self.rebuild(rect, cache); + } + + painter.extend(cache.shapes.iter().cloned()); + self.draw_labels(&painter, rect, cache); + } + + fn rebuild(&self, rect: Rect, cache: &mut F1OpenHudCache) { + cache.rect = Some(rect); + cache.shapes.clear(); + cache.brake_head = None; + cache.throttle_head = None; + cache.steering_head = None; + cache.values = self.formatted_values(); + + let layout = OpenHudLayout::new(rect); + self.build_grid(layout.trace_rect, &mut cache.shapes); + + let capabilities = self.latest.map(|p| p.telemetry.capabilities); + if capabilities.is_some_and(|c| c.brake) { + cache.brake_head = self.build_channel( + layout.trace_rect, + layout.lane_y(0), + |point| point.telemetry.brake, + ChannelScale::Pedal, + self.colors.brake, + &mut cache.shapes, + ); + } + if capabilities.is_some_and(|c| c.throttle) { + cache.throttle_head = self.build_channel( + layout.trace_rect, + layout.lane_y(1), + |point| point.telemetry.throttle, + ChannelScale::Pedal, + self.colors.throttle, + &mut cache.shapes, + ); + } + if capabilities.is_some_and(|c| c.steering_input) { + cache.steering_head = self.build_channel( + layout.trace_rect, + layout.lane_y(2), + |point| point.telemetry.steering_input, + ChannelScale::Steering, + STEERING_COLOR, + &mut cache.shapes, + ); + } + + self.build_rpm_segments(layout.center_rect, &mut cache.shapes); + } + + fn build_grid(&self, rect: Rect, shapes: &mut Vec) { + let grid = with_opacity(GRID_COLOR, self.opacity * 0.11); + for division in 0..=GRID_DIVISIONS { + let x = egui::lerp(rect.x_range(), division as f32 / GRID_DIVISIONS as f32); + shapes.push(Shape::line_segment( + [Pos2::new(x, rect.min.y), Pos2::new(x, rect.max.y)], + Stroke::new(0.75_f32, grid), + )); + } + for lane in 0..3 { + let y = OpenHudLayout::lane_y_in(rect, lane); + shapes.push(Shape::line_segment( + [Pos2::new(rect.min.x, y), Pos2::new(rect.max.x, y)], + Stroke::new(0.5_f32, grid), + )); + } + } + + #[allow(clippy::too_many_arguments)] + fn build_channel( + &self, + rect: Rect, + lane_y: f32, + value: impl Fn(&TelemetryPoint) -> f32, + scale: ChannelScale, + color: Color32, + shapes: &mut Vec, + ) -> Option { + let now = std::time::Instant::now(); + let window_seconds = self.settings.window_seconds.max(0.001) as f32; + let lane_height = rect.height() / 3.0; + let mut bands: [Vec; HISTORY_BANDS] = std::array::from_fn(|_| Vec::new()); + let mut previous: Option<(usize, Pos2)> = None; + + for point in self.points { + let age = now.duration_since(point.captured_at).as_secs_f32(); + let recency = (1.0 - age / window_seconds).clamp(0.0, 1.0); + let x = egui::lerp(rect.x_range(), recency); + let y = scale.y(lane_y, lane_height, value(point)); + let pos = Pos2::new(x, y); + let band = ((recency * HISTORY_BANDS as f32) as usize).min(HISTORY_BANDS - 1); + + if let Some((previous_band, previous_pos)) = previous { + if previous_band != band && bands[band].is_empty() { + bands[band].push(previous_pos); + } + } + bands[band].push(pos); + previous = Some((band, pos)); + } + + let current = self + .latest + .map(|point| Pos2::new(rect.max.x, scale.y(lane_y, lane_height, value(point)))); + if let Some(head) = current { + if let Some((band, last)) = previous { + if band != HISTORY_BANDS - 1 { + bands[HISTORY_BANDS - 1].push(last); + } + bands[HISTORY_BANDS - 1].push(head); + } else { + bands[HISTORY_BANDS - 1].push(head); + } + } + + let alpha = [0.20_f32, 0.34, 0.54, 0.82]; + for (band, points) in bands.into_iter().enumerate() { + if points.len() < 2 { + continue; + } + let energy = alpha[band] * self.opacity; + shapes.push(Shape::line( + points.clone(), + Stroke::new(6.0_f32, with_opacity(color, energy * 0.16)), + )); + shapes.push(Shape::line( + points, + Stroke::new(1.65_f32, with_opacity(color, energy)), + )); + } + current + } + + fn build_rpm_segments(&self, rect: Rect, shapes: &mut Vec) { + let Some(latest) = self.latest else { + return; + }; + if !latest.telemetry.capabilities.rpm { + return; + } + let ratio = (latest.telemetry.rpm / F1_RPM_SCALE).clamp(0.0, 1.0); + let active = (ratio * RPM_SEGMENTS as f32).ceil() as usize; + let gap = 3.0; + let width = (rect.width() - gap * (RPM_SEGMENTS - 1) as f32) / RPM_SEGMENTS as f32; + let y = rect.min.y + rect.height() * 0.18; + for index in 0..RPM_SEGMENTS { + let x = rect.min.x + index as f32 * (width + gap); + let segment = Rect::from_min_size(Pos2::new(x, y), Vec2::new(width.max(1.0), 5.0)); + let high = index >= RPM_SEGMENTS * 3 / 4; + let base = if high { RPM_HIGH } else { RPM_LOW }; + let alpha = if index < active { 0.9 } else { 0.12 }; + if index < active { + shapes.push(Shape::rect_filled( + segment.expand(2.0), + 1.0, + with_opacity(base, self.opacity * 0.10), + )); + } + shapes.push(Shape::rect_filled( + segment, + 0.75, + with_opacity(base, self.opacity * alpha), + )); + } + } + + fn formatted_values(&self) -> OpenHudValues { + let Some(point) = self.latest else { + return OpenHudValues { + brake: "—".to_owned(), + throttle: "—".to_owned(), + steering: "—".to_owned(), + rpm: "—".to_owned(), + gear: "—".to_owned(), + speed: "—".to_owned(), + speed_unit: if self.settings.speed_mph { + "MPH" + } else { + "KM/H" + }, + ..Default::default() + }; + }; + let telemetry = &point.telemetry; + let capabilities = telemetry.capabilities; + let gear = match telemetry.gear { + -1 => "R".to_owned(), + 0 => "N".to_owned(), + value => value.to_string(), + }; + let speed = if self.settings.speed_mph { + telemetry.speed * 2.237 + } else { + telemetry.speed * 3.6 + }; + OpenHudValues { + brake: supported(capabilities.brake, || { + format!("{:.0}%", telemetry.brake * 100.0) + }), + throttle: supported(capabilities.throttle, || { + format!("{:.0}%", telemetry.throttle * 100.0) + }), + steering: supported(capabilities.steering_input, || { + format!( + "{:.0}°", + telemetry.effective_steering_degrees(self.max_steering_angle) + ) + }), + rpm: supported(capabilities.rpm, || format!("{:.0}", telemetry.rpm)), + gear: supported(capabilities.gear, || gear), + speed: supported(capabilities.speed, || format!("{speed:.0}")), + speed_unit: if self.settings.speed_mph { + "MPH" + } else { + "KM/H" + }, + abs_active: capabilities.abs_activity && telemetry.abs_active, + tc_active: capabilities.tc_activity && telemetry.tc_active, + } + } + + fn draw_labels(&self, painter: &Painter, rect: Rect, cache: &F1OpenHudCache) { + let layout = OpenHudLayout::new(rect); + let alpha = self.opacity; + let label_font = FontId::new(12.0, FontFamily::Monospace); + let value_font = FontId::new(13.0, FontFamily::Monospace); + + shadowed_text( + painter, + Pos2::new(layout.left_rect.min.x, layout.left_rect.min.y + 4.0), + Align2::LEFT_TOP, + "DRIVER INPUT / F1 OPEN HUD · BETA", + FontId::new(10.0, FontFamily::Monospace), + with_opacity(TEXT_SECONDARY, alpha * 0.72), + ); + + let channels = [ + ( + "BRAKE", + &cache.values.brake, + self.colors.brake, + cache.brake_head, + ), + ( + "THROTTLE", + &cache.values.throttle, + self.colors.throttle, + cache.throttle_head, + ), + ( + "STEERING", + &cache.values.steering, + STEERING_COLOR, + cache.steering_head, + ), + ]; + for (lane, (label, value, color, head)) in channels.into_iter().enumerate() { + let y = layout.lane_y(lane); + shadowed_text( + painter, + Pos2::new(layout.left_rect.min.x, y), + Align2::LEFT_CENTER, + label, + label_font.clone(), + with_opacity(color, alpha), + ); + shadowed_text( + painter, + Pos2::new(layout.left_rect.max.x, y), + Align2::RIGHT_CENTER, + value, + value_font.clone(), + with_opacity(color, alpha), + ); + if let Some(head) = head { + draw_head(painter, head, color, alpha); + } + } + + for division in 0..=GRID_DIVISIONS { + let seconds = -self.settings.window_seconds + + self.settings.window_seconds * division as f64 / GRID_DIVISIONS as f64; + let x = egui::lerp( + layout.trace_rect.x_range(), + division as f32 / GRID_DIVISIONS as f32, + ); + shadowed_text( + painter, + Pos2::new(x, layout.trace_rect.max.y + 10.0), + Align2::CENTER_TOP, + if division == GRID_DIVISIONS { + "0".to_owned() + } else { + format!("{seconds:.0}s") + }, + FontId::new(8.0, FontFamily::Monospace), + with_opacity(TEXT_SECONDARY, alpha * 0.55), + ); + } + + let center = layout.center_rect.center(); + shadowed_text( + painter, + Pos2::new( + center.x, + layout.center_rect.min.y + layout.center_rect.height() * 0.29, + ), + Align2::CENTER_CENTER, + format!("{} RPM", cache.values.rpm), + FontId::new( + (layout.center_rect.width() * 0.07).clamp(12.0, 18.0), + FontFamily::Monospace, + ), + with_opacity(TEXT_PRIMARY, alpha), + ); + shadowed_text( + painter, + Pos2::new( + center.x, + layout.center_rect.min.y + layout.center_rect.height() * 0.50, + ), + Align2::CENTER_CENTER, + &cache.values.gear, + FontId::new( + (layout.center_rect.height() * 0.28).clamp(48.0, 92.0), + FontFamily::Proportional, + ), + with_opacity(TEXT_PRIMARY, alpha), + ); + shadowed_text( + painter, + Pos2::new( + center.x, + layout.center_rect.min.y + layout.center_rect.height() * 0.72, + ), + Align2::CENTER_CENTER, + &cache.values.speed, + FontId::new( + (layout.center_rect.height() * 0.14).clamp(27.0, 48.0), + FontFamily::Monospace, + ), + with_opacity(TEXT_PRIMARY, alpha), + ); + shadowed_text( + painter, + Pos2::new( + center.x, + layout.center_rect.min.y + layout.center_rect.height() * 0.82, + ), + Align2::CENTER_CENTER, + cache.values.speed_unit, + FontId::new(11.0, FontFamily::Monospace), + with_opacity(TEXT_SECONDARY, alpha), + ); + + let state_y = layout.center_rect.max.y - 20.0; + if cache.values.abs_active { + shadowed_text( + painter, + Pos2::new(center.x - 32.0, state_y), + Align2::CENTER_CENTER, + "ABS", + FontId::new(11.0, FontFamily::Monospace), + with_opacity(Color32::from_rgb(255, 190, 35), alpha), + ); + } + if cache.values.tc_active { + shadowed_text( + painter, + Pos2::new(center.x + 32.0, state_y), + Align2::CENTER_CENTER, + "TC", + FontId::new(11.0, FontFamily::Monospace), + with_opacity(self.colors.tc_active, alpha), + ); + } + } +} + +struct OpenHudLayout { + left_rect: Rect, + trace_rect: Rect, + center_rect: Rect, +} + +impl OpenHudLayout { + fn new(rect: Rect) -> Self { + let outer = rect.shrink2(Vec2::new(14.0, 10.0)); + let left_width = outer.width() * 0.66; + let gap = 22.0_f32.min(outer.width() * 0.04); + let left_rect = Rect::from_min_size(outer.min, Vec2::new(left_width, outer.height())); + let label_width = (left_width * 0.20).clamp(76.0, 112.0); + let value_width = (left_width * 0.13).clamp(46.0, 72.0); + let trace_rect = Rect::from_min_max( + Pos2::new( + left_rect.min.x + label_width, + left_rect.min.y + outer.height() * 0.16, + ), + Pos2::new( + left_rect.max.x - value_width, + left_rect.max.y - outer.height() * 0.12, + ), + ); + let center_rect = + Rect::from_min_max(Pos2::new(left_rect.max.x + gap, outer.min.y), outer.max); + Self { + left_rect, + trace_rect, + center_rect, + } + } + + fn lane_y(&self, lane: usize) -> f32 { + Self::lane_y_in(self.trace_rect, lane) + } + + fn lane_y_in(rect: Rect, lane: usize) -> f32 { + rect.min.y + rect.height() * (lane as f32 + 0.5) / 3.0 + } +} + +#[derive(Clone, Copy)] +enum ChannelScale { + Pedal, + Steering, +} + +impl ChannelScale { + fn y(self, center: f32, lane_height: f32, value: f32) -> f32 { + match self { + Self::Pedal => center + (0.5 - value.clamp(0.0, 1.0)) * lane_height * 0.64, + Self::Steering => center - value.clamp(-1.0, 1.0) * lane_height * 0.32, + } + } +} + +fn supported(available: bool, value: impl FnOnce() -> String) -> String { + if available { + value() + } else { + "—".to_owned() + } +} + +fn with_opacity(color: Color32, opacity: f32) -> Color32 { + let [r, g, b, a] = color.to_array(); + Color32::from_rgba_unmultiplied(r, g, b, (a as f32 * opacity.clamp(0.0, 1.0)) as u8) +} + +fn draw_head(painter: &Painter, center: Pos2, color: Color32, opacity: f32) { + painter.circle_filled(center, 9.0, with_opacity(color, opacity * 0.08)); + painter.circle_filled(center, 5.5, with_opacity(color, opacity * 0.20)); + painter.circle_filled(center, 2.8, with_opacity(color, opacity)); + painter.circle_stroke( + center, + 3.8, + Stroke::new(0.8_f32, with_opacity(Color32::WHITE, opacity * 0.8)), + ); +} + +fn shadowed_text( + painter: &Painter, + position: Pos2, + anchor: Align2, + text: impl ToString, + font: FontId, + color: Color32, +) { + let text = text.to_string(); + painter.text( + position + Vec2::new(1.25, 1.5), + anchor, + &text, + font.clone(), + Color32::from_black_alpha(165), + ); + painter.text(position, anchor, text, font, color); +} diff --git a/src/renderer/mod.rs b/src/renderer/mod.rs index 128168e..58aa44b 100644 --- a/src/renderer/mod.rs +++ b/src/renderer/mod.rs @@ -1,12 +1,14 @@ //! egui renderer for telemetry visualization pub mod app; +pub mod f1_open_hud; pub mod lap_comparison; pub mod phase_plot; pub mod steering_wheel; pub mod trace_graph; pub use app::SimTraceApp; +pub use f1_open_hud::F1OpenHud; pub use lap_comparison::LapComparison; pub use phase_plot::PhasePlot; pub use steering_wheel::SteeringWheel; From 2e599d34a375a6c98a94a62666c67ed638b78653 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 17 Sep 2026 20:40:29 +0800 Subject: [PATCH 08/12] feat: refine F1 Open HUD visuals --- THIRD_PARTY_LICENSES/README.md | 12 + .../SairaSemiCondensed-OFL.txt | 93 ++++ assets/fonts/SairaSemiCondensed-SemiBold.ttf | Bin 0 -> 96160 bytes src/core/mod.rs | 4 +- src/core/model.rs | 28 ++ src/plugins/ams2/ams2_plugin.rs | 1 + .../assetto_competizione/acc_plugin.rs | 1 + src/plugins/f1/f1_2025.rs | 8 + src/plugins/f1/f1_2026.rs | 8 + src/plugins/f1/protocol.rs | 9 +- src/plugins/iracing/iracing_plugin.rs | 1 + src/plugins/mock/mod.rs | 1 + src/renderer/app.rs | 2 +- src/renderer/f1_open_hud.rs | 425 +++++++++--------- 14 files changed, 376 insertions(+), 217 deletions(-) create mode 100644 THIRD_PARTY_LICENSES/README.md create mode 100644 THIRD_PARTY_LICENSES/SairaSemiCondensed-OFL.txt create mode 100644 assets/fonts/SairaSemiCondensed-SemiBold.ttf diff --git a/THIRD_PARTY_LICENSES/README.md b/THIRD_PARTY_LICENSES/README.md new file mode 100644 index 0000000..3e1c922 --- /dev/null +++ b/THIRD_PARTY_LICENSES/README.md @@ -0,0 +1,12 @@ +# Third-party font assets + +## Saira Semi Condensed SemiBold + +- File: `assets/fonts/SairaSemiCondensed-SemiBold.ttf` +- Copyright: 2016 The Saira Project Authors (omnibus.type@gmail.com) +- Source: https://github.com/google/fonts/tree/main/ofl/sairasemicondensed +- License: SIL Open Font License 1.1 +- License text: `THIRD_PARTY_LICENSES/SairaSemiCondensed-OFL.txt` + +The OFL permits the unmodified font to be bundled and redistributed with this +application when its copyright notice and license accompany it. diff --git a/THIRD_PARTY_LICENSES/SairaSemiCondensed-OFL.txt b/THIRD_PARTY_LICENSES/SairaSemiCondensed-OFL.txt new file mode 100644 index 0000000..970a69f --- /dev/null +++ b/THIRD_PARTY_LICENSES/SairaSemiCondensed-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Saira Project Authors (omnibus.type@gmail.com), with reserved font name "Saira". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/SairaSemiCondensed-SemiBold.ttf b/assets/fonts/SairaSemiCondensed-SemiBold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..52d779ff8a93f56d3ecfb78a8074d8c93570c311 GIT binary patch literal 96160 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 z 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 literal 0 HcmV?d00001 diff --git a/src/core/mod.rs b/src/core/mod.rs index 9871236..fe0b900 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -9,6 +9,6 @@ pub use buffer::TelemetryBuffer; pub use collector::DataCollector; pub use lap_store::LapStore; pub use model::{ - DriverAssistStatus, SourceMetadata, TelemetryCapabilities, TelemetryData, TelemetryPoint, - VehicleTelemetry, + DriverAssistStatus, RevLights, SourceMetadata, TelemetryCapabilities, TelemetryData, + TelemetryPoint, VehicleTelemetry, }; diff --git a/src/core/model.rs b/src/core/model.rs index 5ff499a..9c44a42 100644 --- a/src/core/model.rs +++ b/src/core/model.rs @@ -33,6 +33,17 @@ pub struct DriverAssistStatus { pub traction_control_level: Option, } +/// Shift-light state supplied by a telemetry provider. +/// +/// F1 exposes both the percentage and the exact 15-light bitfield. Keeping the +/// pair optional distinguishes unsupported providers from a legitimate all-off +/// state without adding a capability bit for one provider-specific detail. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct RevLights { + pub percent: u8, + pub bit_value: u16, +} + /// Optional packet-origin metadata. `captured_at` on [`TelemetryPoint`] remains /// the authoritative local monotonic receipt time. #[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq)] @@ -81,6 +92,8 @@ pub struct VehicleTelemetry { pub gear: i32, /// Engine RPM pub rpm: f32, + /// Provider-supplied shift-light state, when available. + pub rev_lights: Option, /// ABS is currently active pub abs_active: bool, /// Traction control is currently active @@ -204,4 +217,19 @@ mod tests { assert!(!unsupported.capabilities.abs_activity); assert!(supported_zero.capabilities.abs_activity); } + + #[test] + fn unsupported_rev_lights_are_distinct_from_all_off() { + let unsupported = VehicleTelemetry::default(); + let all_off = VehicleTelemetry { + rev_lights: Some(RevLights { + percent: 0, + bit_value: 0, + }), + ..Default::default() + }; + + assert_eq!(unsupported.rev_lights, None); + assert_eq!(all_off.rev_lights, Some(RevLights::default())); + } } diff --git a/src/plugins/ams2/ams2_plugin.rs b/src/plugins/ams2/ams2_plugin.rs index 90be842..7d4ed09 100644 --- a/src/plugins/ams2/ams2_plugin.rs +++ b/src/plugins/ams2/ams2_plugin.rs @@ -132,6 +132,7 @@ impl GamePlugin for Ams2Plugin { speed, gear, rpm: 0.0, // not read to keep offsets simple + rev_lights: None, abs_active, tc_active: false, track_position: 0.0, diff --git a/src/plugins/assetto_competizione/acc_plugin.rs b/src/plugins/assetto_competizione/acc_plugin.rs index 8f25eca..5735ca6 100644 --- a/src/plugins/assetto_competizione/acc_plugin.rs +++ b/src/plugins/assetto_competizione/acc_plugin.rs @@ -90,6 +90,7 @@ impl GamePlugin for AccPlugin { speed: physics.speed_kmh / 3.6, // km/h → m/s gear, rpm: physics.rpms as f32, + rev_lights: None, abs_active: physics.abs > 0.01, tc_active: physics.tc > 0.01, track_position: graphics.normalized_car_position, diff --git a/src/plugins/f1/f1_2025.rs b/src/plugins/f1/f1_2025.rs index c4aaefd..1efd14f 100644 --- a/src/plugins/f1/f1_2025.rs +++ b/src/plugins/f1/f1_2025.rs @@ -68,6 +68,10 @@ impl Decoder { clutch: protocol::read_u8(bytes, base + 14)?, gear: protocol::read_i8(bytes, base + 15)?, rpm: protocol::read_u16(bytes, base + 16)?, + // Official CarTelemetryData: DRS at +18, then the rev-light + // percentage at +19 and exact 15-light bitfield at +20. + rev_lights_percent: protocol::read_u8(bytes, base + 19)?, + rev_lights_bit_value: protocol::read_u16(bytes, base + 20)?, }, ) } @@ -92,6 +96,8 @@ pub(super) fn test_telemetry_packet(player: usize, frame: u32) -> Vec { bytes[base + 14] = 40; bytes[base + 15] = 7; bytes[base + 16..base + 18].copy_from_slice(&12_345_u16.to_le_bytes()); + bytes[base + 19] = 73; + bytes[base + 20..base + 22].copy_from_slice(&0x03ff_u16.to_le_bytes()); bytes } @@ -128,6 +134,8 @@ mod tests { assert_eq!(data.vehicle.steering_input, -0.5); assert_eq!(data.vehicle.gear, 7); assert_eq!(data.vehicle.rpm, 12_345.0); + assert_eq!(data.vehicle.rev_lights.unwrap().percent, 73); + assert_eq!(data.vehicle.rev_lights.unwrap().bit_value, 0x03ff); assert_eq!(data.source.protocol_format, Some(2025)); } diff --git a/src/plugins/f1/f1_2026.rs b/src/plugins/f1/f1_2026.rs index eeec49f..c86c3c9 100644 --- a/src/plugins/f1/f1_2026.rs +++ b/src/plugins/f1/f1_2026.rs @@ -71,6 +71,10 @@ impl Decoder { clutch: protocol::read_u8(bytes, base + 14)?, gear: protocol::read_i8(bytes, base + 15)?, rpm: protocol::read_u16(bytes, base + 16)?, + // The 2026 variant retains the official rev-light fields + // at +19/+20 even though later fields and item size differ. + rev_lights_percent: protocol::read_u8(bytes, base + 19)?, + rev_lights_bit_value: protocol::read_u16(bytes, base + 20)?, }, ) } @@ -95,6 +99,8 @@ pub(super) fn test_telemetry_packet(player: usize, frame: u32) -> Vec { bytes[base + 14] = 40; bytes[base + 15] = 7; bytes[base + 16..base + 18].copy_from_slice(&12_345_u16.to_le_bytes()); + bytes[base + 19] = 73; + bytes[base + 20..base + 22].copy_from_slice(&0x03ff_u16.to_le_bytes()); bytes } @@ -131,6 +137,8 @@ mod tests { assert_eq!(data.vehicle.steering_input, -0.5); assert_eq!(data.vehicle.gear, 7); assert_eq!(data.vehicle.rpm, 12_345.0); + assert_eq!(data.vehicle.rev_lights.unwrap().percent, 73); + assert_eq!(data.vehicle.rev_lights.unwrap().bit_value, 0x03ff); assert_eq!(data.source.protocol_format, Some(2026)); } diff --git a/src/plugins/f1/protocol.rs b/src/plugins/f1/protocol.rs index 2e4e49d..d0238ef 100644 --- a/src/plugins/f1/protocol.rs +++ b/src/plugins/f1/protocol.rs @@ -4,7 +4,8 @@ use std::fmt; use anyhow::Result; use crate::core::{ - DriverAssistStatus, SourceMetadata, TelemetryCapabilities, TelemetryData, VehicleTelemetry, + DriverAssistStatus, RevLights, SourceMetadata, TelemetryCapabilities, TelemetryData, + VehicleTelemetry, }; pub const HEADER_LEN: usize = 29; @@ -117,6 +118,8 @@ pub struct VehicleFields { pub clutch: u8, pub gear: i8, pub rpm: u16, + pub rev_lights_percent: u8, + pub rev_lights_bit_value: u16, } impl DecoderState { @@ -194,6 +197,10 @@ impl DecoderState { speed: fields.speed_kph as f32 / 3.6, gear: fields.gear as i32, rpm: fields.rpm as f32, + rev_lights: Some(RevLights { + percent: fields.rev_lights_percent, + bit_value: fields.rev_lights_bit_value, + }), abs_active: false, tc_active: false, track_position: track_position.unwrap_or(0.0), diff --git a/src/plugins/iracing/iracing_plugin.rs b/src/plugins/iracing/iracing_plugin.rs index f37ec44..901e268 100644 --- a/src/plugins/iracing/iracing_plugin.rs +++ b/src/plugins/iracing/iracing_plugin.rs @@ -134,6 +134,7 @@ impl GamePlugin for IracingPlugin { speed, gear, rpm, + rev_lights: None, abs_active, tc_active: false, track_position, diff --git a/src/plugins/mock/mod.rs b/src/plugins/mock/mod.rs index f3e28cd..50e80ce 100644 --- a/src/plugins/mock/mod.rs +++ b/src/plugins/mock/mod.rs @@ -127,6 +127,7 @@ impl MockPlugin { speed: speed / 3.6, gear, rpm, + rev_lights: None, abs_active, tc_active: false, track_position: (t * 0.01).fract(), diff --git a/src/renderer/app.rs b/src/renderer/app.rs index c17c583..70c0603 100644 --- a/src/renderer/app.rs +++ b/src/renderer/app.rs @@ -117,6 +117,7 @@ pub struct SimTraceApp { impl SimTraceApp { pub fn new(cc: &eframe::CreationContext<'_>) -> Self { + crate::renderer::f1_open_hud::install_font(&cc.egui_ctx); cc.egui_ctx.set_theme(egui::Theme::Dark); cc.egui_ctx.set_visuals(egui::Visuals { panel_fill: egui::Color32::TRANSPARENT, @@ -540,7 +541,6 @@ impl eframe::App for SimTraceApp { &visualization.points, visualization.latest.as_ref(), &self.settings.graph, - &self.parsed_colors, self.settings.overlay.opacity, self.max_steering_angle, ) diff --git a/src/renderer/f1_open_hud.rs b/src/renderer/f1_open_hud.rs index 0ba04c6..b916b9a 100644 --- a/src/renderer/f1_open_hud.rs +++ b/src/renderer/f1_open_hud.rs @@ -3,21 +3,52 @@ //! This renderer consumes the shared, already-windowed visualization snapshot. //! It never reads or filters the telemetry buffer itself. -use crate::config::{GraphSettings, ParsedColors}; -use crate::core::TelemetryPoint; -use egui::{Align2, Color32, FontFamily, FontId, Painter, Pos2, Rect, Shape, Stroke, Vec2}; - +use crate::config::GraphSettings; +use crate::core::{RevLights, TelemetryPoint}; +use egui::{ + Align2, Color32, FontData, FontDefinitions, FontFamily, FontId, Painter, Pos2, Rect, Shape, + Stroke, Vec2, +}; +use std::sync::Arc; + +const DISPLAY_FONT_NAME: &str = "saira_semi_condensed_semibold"; const GRID_DIVISIONS: usize = 4; -const HISTORY_BANDS: usize = 4; -const RPM_SEGMENTS: usize = 18; -const F1_RPM_SCALE: f32 = 15_000.0; +const HISTORY_BANDS: usize = 8; +const RPM_SEGMENTS: usize = 15; + +const TEXT_PRIMARY: Color32 = Color32::from_rgb(244, 247, 250); +const TEXT_SECONDARY: Color32 = Color32::from_rgb(166, 178, 193); +const GRID_COLOR: Color32 = Color32::from_rgb(112, 132, 153); +const BRAKE_COLOR: Color32 = Color32::from_rgb(255, 91, 96); +const THROTTLE_COLOR: Color32 = Color32::from_rgb(74, 234, 158); +const STEERING_COLOR: Color32 = Color32::from_rgb(89, 184, 255); +const RPM_LOW: Color32 = Color32::from_rgb(74, 167, 235); +const RPM_HIGH: Color32 = Color32::from_rgb(255, 78, 99); +const ABS_COLOR: Color32 = Color32::from_rgb(255, 194, 55); + +/// Install the single bundled display face without changing the font used by +/// Classic or by settings widgets. +pub fn install_font(ctx: &egui::Context) { + let mut fonts = FontDefinitions::default(); + fonts.font_data.insert( + DISPLAY_FONT_NAME.to_owned(), + Arc::new(FontData::from_static(include_bytes!( + "../../assets/fonts/SairaSemiCondensed-SemiBold.ttf" + ))), + ); + fonts + .families + .insert(display_family(), vec![DISPLAY_FONT_NAME.to_owned()]); + ctx.set_fonts(fonts); +} -const TEXT_PRIMARY: Color32 = Color32::from_rgb(242, 246, 252); -const TEXT_SECONDARY: Color32 = Color32::from_rgb(170, 181, 196); -const GRID_COLOR: Color32 = Color32::from_rgb(118, 139, 164); -const STEERING_COLOR: Color32 = Color32::from_rgb(75, 178, 255); -const RPM_LOW: Color32 = Color32::from_rgb(61, 166, 255); -const RPM_HIGH: Color32 = Color32::from_rgb(255, 61, 87); +fn display_family() -> FontFamily { + FontFamily::Name(Arc::::from(DISPLAY_FONT_NAME)) +} + +fn display_font(size: f32) -> FontId { + FontId::new(size, display_family()) +} #[derive(Default)] pub struct F1OpenHudCache { @@ -38,6 +69,7 @@ struct OpenHudValues { gear: String, speed: String, speed_unit: &'static str, + rev_lights: Option, abs_active: bool, tc_active: bool, } @@ -46,7 +78,6 @@ pub struct F1OpenHud<'a> { points: &'a [TelemetryPoint], latest: Option<&'a TelemetryPoint>, settings: &'a GraphSettings, - colors: &'a ParsedColors, opacity: f32, max_steering_angle: f32, } @@ -56,7 +87,6 @@ impl<'a> F1OpenHud<'a> { points: &'a [TelemetryPoint], latest: Option<&'a TelemetryPoint>, settings: &'a GraphSettings, - colors: &'a ParsedColors, opacity: f32, max_steering_angle: f32, ) -> Self { @@ -64,7 +94,6 @@ impl<'a> F1OpenHud<'a> { points, latest, settings, - colors, opacity, max_steering_angle, } @@ -100,143 +129,155 @@ impl<'a> F1OpenHud<'a> { let layout = OpenHudLayout::new(rect); self.build_grid(layout.trace_rect, &mut cache.shapes); - let capabilities = self.latest.map(|p| p.telemetry.capabilities); - if capabilities.is_some_and(|c| c.brake) { - cache.brake_head = self.build_channel( - layout.trace_rect, - layout.lane_y(0), - |point| point.telemetry.brake, - ChannelScale::Pedal, - self.colors.brake, - &mut cache.shapes, - ); - } - if capabilities.is_some_and(|c| c.throttle) { - cache.throttle_head = self.build_channel( - layout.trace_rect, - layout.lane_y(1), - |point| point.telemetry.throttle, - ChannelScale::Pedal, - self.colors.throttle, - &mut cache.shapes, - ); - } - if capabilities.is_some_and(|c| c.steering_input) { - cache.steering_head = self.build_channel( - layout.trace_rect, - layout.lane_y(2), - |point| point.telemetry.steering_input, - ChannelScale::Steering, - STEERING_COLOR, - &mut cache.shapes, - ); - } - - self.build_rpm_segments(layout.center_rect, &mut cache.shapes); - } - - fn build_grid(&self, rect: Rect, shapes: &mut Vec) { - let grid = with_opacity(GRID_COLOR, self.opacity * 0.11); - for division in 0..=GRID_DIVISIONS { - let x = egui::lerp(rect.x_range(), division as f32 / GRID_DIVISIONS as f32); - shapes.push(Shape::line_segment( - [Pos2::new(x, rect.min.y), Pos2::new(x, rect.max.y)], - Stroke::new(0.75_f32, grid), - )); - } - for lane in 0..3 { - let y = OpenHudLayout::lane_y_in(rect, lane); - shapes.push(Shape::line_segment( - [Pos2::new(rect.min.x, y), Pos2::new(rect.max.x, y)], - Stroke::new(0.5_f32, grid), - )); - } - } - - #[allow(clippy::too_many_arguments)] - fn build_channel( - &self, - rect: Rect, - lane_y: f32, - value: impl Fn(&TelemetryPoint) -> f32, - scale: ChannelScale, - color: Color32, - shapes: &mut Vec, - ) -> Option { + let capabilities = self.latest.map(|point| point.telemetry.capabilities); + let mut brake_bands: [Vec; HISTORY_BANDS] = std::array::from_fn(|_| Vec::new()); + let mut throttle_bands: [Vec; HISTORY_BANDS] = std::array::from_fn(|_| Vec::new()); + let mut steering_bands: [Vec; HISTORY_BANDS] = std::array::from_fn(|_| Vec::new()); + let mut previous: Option<(usize, [Pos2; 3])> = None; let now = std::time::Instant::now(); let window_seconds = self.settings.window_seconds.max(0.001) as f32; - let lane_height = rect.height() / 3.0; - let mut bands: [Vec; HISTORY_BANDS] = std::array::from_fn(|_| Vec::new()); - let mut previous: Option<(usize, Pos2)> = None; + // One shared history pass prepares all three channels in the same plot. for point in self.points { let age = now.duration_since(point.captured_at).as_secs_f32(); let recency = (1.0 - age / window_seconds).clamp(0.0, 1.0); - let x = egui::lerp(rect.x_range(), recency); - let y = scale.y(lane_y, lane_height, value(point)); - let pos = Pos2::new(x, y); + let x = egui::lerp(layout.trace_rect.x_range(), recency); + let positions = [ + Pos2::new(x, pedal_y(layout.trace_rect, point.telemetry.brake)), + Pos2::new(x, pedal_y(layout.trace_rect, point.telemetry.throttle)), + Pos2::new( + x, + steering_y(layout.trace_rect, point.telemetry.steering_input), + ), + ]; let band = ((recency * HISTORY_BANDS as f32) as usize).min(HISTORY_BANDS - 1); - if let Some((previous_band, previous_pos)) = previous { - if previous_band != band && bands[band].is_empty() { - bands[band].push(previous_pos); + if let Some((previous_band, previous_positions)) = previous { + if previous_band != band && brake_bands[band].is_empty() { + brake_bands[band].push(previous_positions[0]); + throttle_bands[band].push(previous_positions[1]); + steering_bands[band].push(previous_positions[2]); } } - bands[band].push(pos); - previous = Some((band, pos)); + brake_bands[band].push(positions[0]); + throttle_bands[band].push(positions[1]); + steering_bands[band].push(positions[2]); + previous = Some((band, positions)); } - let current = self - .latest - .map(|point| Pos2::new(rect.max.x, scale.y(lane_y, lane_height, value(point)))); - if let Some(head) = current { + if let Some(latest) = self.latest { + let x = layout.trace_rect.max.x; + let heads = [ + Pos2::new(x, pedal_y(layout.trace_rect, latest.telemetry.brake)), + Pos2::new(x, pedal_y(layout.trace_rect, latest.telemetry.throttle)), + Pos2::new( + x, + steering_y(layout.trace_rect, latest.telemetry.steering_input), + ), + ]; if let Some((band, last)) = previous { if band != HISTORY_BANDS - 1 { - bands[HISTORY_BANDS - 1].push(last); + brake_bands[HISTORY_BANDS - 1].push(last[0]); + throttle_bands[HISTORY_BANDS - 1].push(last[1]); + steering_bands[HISTORY_BANDS - 1].push(last[2]); } - bands[HISTORY_BANDS - 1].push(head); - } else { - bands[HISTORY_BANDS - 1].push(head); } + brake_bands[HISTORY_BANDS - 1].push(heads[0]); + throttle_bands[HISTORY_BANDS - 1].push(heads[1]); + steering_bands[HISTORY_BANDS - 1].push(heads[2]); + + if capabilities.is_some_and(|value| value.brake) { + cache.brake_head = Some(heads[0]); + } + if capabilities.is_some_and(|value| value.throttle) { + cache.throttle_head = Some(heads[1]); + } + if capabilities.is_some_and(|value| value.steering_input) { + cache.steering_head = Some(heads[2]); + } + } + + if capabilities.is_some_and(|value| value.brake) { + self.build_trace_layers(brake_bands, BRAKE_COLOR, &mut cache.shapes); + } + if capabilities.is_some_and(|value| value.throttle) { + self.build_trace_layers(throttle_bands, THROTTLE_COLOR, &mut cache.shapes); + } + if capabilities.is_some_and(|value| value.steering_input) { + self.build_trace_layers(steering_bands, STEERING_COLOR, &mut cache.shapes); } - let alpha = [0.20_f32, 0.34, 0.54, 0.82]; + self.build_rev_lights( + layout.center_rect, + cache.values.rev_lights, + &mut cache.shapes, + ); + } + + fn build_trace_layers( + &self, + bands: [Vec; HISTORY_BANDS], + color: Color32, + shapes: &mut Vec, + ) { for (band, points) in bands.into_iter().enumerate() { if points.len() < 2 { continue; } - let energy = alpha[band] * self.opacity; + let recency = (band + 1) as f32 / HISTORY_BANDS as f32; + let energy = (0.14 + 0.86 * recency.powf(1.35)) * self.opacity; + shapes.push(Shape::line( + points.clone(), + Stroke::new(6.0_f32, with_opacity(color, energy * 0.10)), + )); shapes.push(Shape::line( points.clone(), - Stroke::new(6.0_f32, with_opacity(color, energy * 0.16)), + Stroke::new(3.2_f32, with_opacity(color, energy * 0.13)), )); shapes.push(Shape::line( points, - Stroke::new(1.65_f32, with_opacity(color, energy)), + Stroke::new(1.55_f32, with_opacity(color, energy * 0.94)), )); } - current } - fn build_rpm_segments(&self, rect: Rect, shapes: &mut Vec) { - let Some(latest) = self.latest else { + fn build_grid(&self, rect: Rect, shapes: &mut Vec) { + for division in 0..=GRID_DIVISIONS { + let recency = division as f32 / GRID_DIVISIONS as f32; + let x = egui::lerp(rect.x_range(), recency); + let strength = if division == GRID_DIVISIONS { + 0.14 + } else { + 0.035 + recency * 0.035 + }; + shapes.push(Shape::line_segment( + [Pos2::new(x, rect.min.y), Pos2::new(x, rect.max.y)], + Stroke::new(0.65_f32, with_opacity(GRID_COLOR, self.opacity * strength)), + )); + } + for division in 1..4 { + let y = egui::lerp(rect.y_range(), division as f32 / 4.0); + shapes.push(Shape::line_segment( + [Pos2::new(rect.min.x, y), Pos2::new(rect.max.x, y)], + Stroke::new(0.5_f32, with_opacity(GRID_COLOR, self.opacity * 0.025)), + )); + } + } + + fn build_rev_lights(&self, rect: Rect, rev_lights: Option, shapes: &mut Vec) { + let Some(rev_lights) = rev_lights else { return; }; - if !latest.telemetry.capabilities.rpm { - return; - } - let ratio = (latest.telemetry.rpm / F1_RPM_SCALE).clamp(0.0, 1.0); - let active = (ratio * RPM_SEGMENTS as f32).ceil() as usize; - let gap = 3.0; + let gap = 3.0_f32; let width = (rect.width() - gap * (RPM_SEGMENTS - 1) as f32) / RPM_SEGMENTS as f32; - let y = rect.min.y + rect.height() * 0.18; + let y = rect.min.y + rect.height() * 0.16; + for index in 0..RPM_SEGMENTS { let x = rect.min.x + index as f32 * (width + gap); - let segment = Rect::from_min_size(Pos2::new(x, y), Vec2::new(width.max(1.0), 5.0)); - let high = index >= RPM_SEGMENTS * 3 / 4; - let base = if high { RPM_HIGH } else { RPM_LOW }; - let alpha = if index < active { 0.9 } else { 0.12 }; - if index < active { + let segment = Rect::from_min_size(Pos2::new(x, y), Vec2::new(width.max(1.0), 4.5)); + let active = rev_lights.bit_value & (1_u16 << index) != 0; + let base = if index < 10 { RPM_LOW } else { RPM_HIGH }; + if active { shapes.push(Shape::rect_filled( segment.expand(2.0), 1.0, @@ -246,7 +287,7 @@ impl<'a> F1OpenHud<'a> { shapes.push(Shape::rect_filled( segment, 0.75, - with_opacity(base, self.opacity * alpha), + with_opacity(base, self.opacity * if active { 0.92 } else { 0.055 }), )); } } @@ -301,6 +342,7 @@ impl<'a> F1OpenHud<'a> { } else { "KM/H" }, + rev_lights: telemetry.rev_lights, abs_active: capabilities.abs_activity && telemetry.abs_active, tc_active: capabilities.tc_activity && telemetry.tc_active, } @@ -309,47 +351,25 @@ impl<'a> F1OpenHud<'a> { fn draw_labels(&self, painter: &Painter, rect: Rect, cache: &F1OpenHudCache) { let layout = OpenHudLayout::new(rect); let alpha = self.opacity; - let label_font = FontId::new(12.0, FontFamily::Monospace); - let value_font = FontId::new(13.0, FontFamily::Monospace); - - shadowed_text( - painter, - Pos2::new(layout.left_rect.min.x, layout.left_rect.min.y + 4.0), - Align2::LEFT_TOP, - "DRIVER INPUT / F1 OPEN HUD · BETA", - FontId::new(10.0, FontFamily::Monospace), - with_opacity(TEXT_SECONDARY, alpha * 0.72), - ); - + let label_font = display_font(12.5); + let value_font = display_font(14.0); + let label_top = layout.trace_rect.min.y + 13.0; + let label_step = 24.0; let channels = [ - ( - "BRAKE", - &cache.values.brake, - self.colors.brake, - cache.brake_head, - ), - ( - "THROTTLE", - &cache.values.throttle, - self.colors.throttle, - cache.throttle_head, - ), - ( - "STEERING", - &cache.values.steering, - STEERING_COLOR, - cache.steering_head, - ), + ("BRAKE", &cache.values.brake, BRAKE_COLOR), + ("THROTTLE", &cache.values.throttle, THROTTLE_COLOR), + ("STEERING", &cache.values.steering, STEERING_COLOR), ]; - for (lane, (label, value, color, head)) in channels.into_iter().enumerate() { - let y = layout.lane_y(lane); + + for (index, (label, value, color)) in channels.into_iter().enumerate() { + let y = label_top + index as f32 * label_step; shadowed_text( painter, Pos2::new(layout.left_rect.min.x, y), Align2::LEFT_CENTER, label, label_font.clone(), - with_opacity(color, alpha), + with_opacity(color, alpha * 0.92), ); shadowed_text( painter, @@ -359,6 +379,13 @@ impl<'a> F1OpenHud<'a> { value_font.clone(), with_opacity(color, alpha), ); + } + + for (head, color) in [ + (cache.brake_head, BRAKE_COLOR), + (cache.throttle_head, THROTTLE_COLOR), + (cache.steering_head, STEERING_COLOR), + ] { if let Some(head) = head { draw_head(painter, head, color, alpha); } @@ -373,15 +400,15 @@ impl<'a> F1OpenHud<'a> { ); shadowed_text( painter, - Pos2::new(x, layout.trace_rect.max.y + 10.0), + Pos2::new(x, layout.trace_rect.max.y + 9.0), Align2::CENTER_TOP, if division == GRID_DIVISIONS { "0".to_owned() } else { format!("{seconds:.0}s") }, - FontId::new(8.0, FontFamily::Monospace), - with_opacity(TEXT_SECONDARY, alpha * 0.55), + FontId::new(8.0, FontFamily::Proportional), + with_opacity(TEXT_SECONDARY, alpha * 0.46), ); } @@ -390,28 +417,22 @@ impl<'a> F1OpenHud<'a> { painter, Pos2::new( center.x, - layout.center_rect.min.y + layout.center_rect.height() * 0.29, + layout.center_rect.min.y + layout.center_rect.height() * 0.27, ), Align2::CENTER_CENTER, - format!("{} RPM", cache.values.rpm), - FontId::new( - (layout.center_rect.width() * 0.07).clamp(12.0, 18.0), - FontFamily::Monospace, - ), - with_opacity(TEXT_PRIMARY, alpha), + format!("{} RPM", cache.values.rpm), + display_font((layout.center_rect.width() * 0.075).clamp(12.0, 18.0)), + with_opacity(TEXT_SECONDARY, alpha * 0.94), ); shadowed_text( painter, Pos2::new( center.x, - layout.center_rect.min.y + layout.center_rect.height() * 0.50, + layout.center_rect.min.y + layout.center_rect.height() * 0.49, ), Align2::CENTER_CENTER, &cache.values.gear, - FontId::new( - (layout.center_rect.height() * 0.28).clamp(48.0, 92.0), - FontFamily::Proportional, - ), + display_font((layout.center_rect.height() * 0.29).clamp(50.0, 94.0)), with_opacity(TEXT_PRIMARY, alpha), ); shadowed_text( @@ -422,11 +443,8 @@ impl<'a> F1OpenHud<'a> { ), Align2::CENTER_CENTER, &cache.values.speed, - FontId::new( - (layout.center_rect.height() * 0.14).clamp(27.0, 48.0), - FontFamily::Monospace, - ), - with_opacity(TEXT_PRIMARY, alpha), + display_font((layout.center_rect.height() * 0.14).clamp(27.0, 48.0)), + with_opacity(TEXT_PRIMARY, alpha * 0.96), ); shadowed_text( painter, @@ -436,29 +454,29 @@ impl<'a> F1OpenHud<'a> { ), Align2::CENTER_CENTER, cache.values.speed_unit, - FontId::new(11.0, FontFamily::Monospace), - with_opacity(TEXT_SECONDARY, alpha), + display_font(10.0), + with_opacity(TEXT_SECONDARY, alpha * 0.82), ); - let state_y = layout.center_rect.max.y - 20.0; + let state_y = layout.center_rect.max.y - 19.0; if cache.values.abs_active { shadowed_text( painter, - Pos2::new(center.x - 32.0, state_y), + Pos2::new(center.x - 28.0, state_y), Align2::CENTER_CENTER, "ABS", - FontId::new(11.0, FontFamily::Monospace), - with_opacity(Color32::from_rgb(255, 190, 35), alpha), + display_font(10.5), + with_opacity(ABS_COLOR, alpha), ); } if cache.values.tc_active { shadowed_text( painter, - Pos2::new(center.x + 32.0, state_y), + Pos2::new(center.x + 28.0, state_y), Align2::CENTER_CENTER, "TC", - FontId::new(11.0, FontFamily::Monospace), - with_opacity(self.colors.tc_active, alpha), + display_font(10.5), + with_opacity(THROTTLE_COLOR, alpha), ); } } @@ -473,15 +491,15 @@ struct OpenHudLayout { impl OpenHudLayout { fn new(rect: Rect) -> Self { let outer = rect.shrink2(Vec2::new(14.0, 10.0)); - let left_width = outer.width() * 0.66; + let left_width = outer.width() * 0.67; let gap = 22.0_f32.min(outer.width() * 0.04); let left_rect = Rect::from_min_size(outer.min, Vec2::new(left_width, outer.height())); - let label_width = (left_width * 0.20).clamp(76.0, 112.0); - let value_width = (left_width * 0.13).clamp(46.0, 72.0); + let label_width = (left_width * 0.20).clamp(78.0, 116.0); + let value_width = (left_width * 0.13).clamp(48.0, 74.0); let trace_rect = Rect::from_min_max( Pos2::new( left_rect.min.x + label_width, - left_rect.min.y + outer.height() * 0.16, + left_rect.min.y + outer.height() * 0.12, ), Pos2::new( left_rect.max.x - value_width, @@ -496,29 +514,15 @@ impl OpenHudLayout { center_rect, } } - - fn lane_y(&self, lane: usize) -> f32 { - Self::lane_y_in(self.trace_rect, lane) - } - - fn lane_y_in(rect: Rect, lane: usize) -> f32 { - rect.min.y + rect.height() * (lane as f32 + 0.5) / 3.0 - } } -#[derive(Clone, Copy)] -enum ChannelScale { - Pedal, - Steering, +fn pedal_y(rect: Rect, value: f32) -> f32 { + let pad = rect.height() * 0.06; + rect.max.y - pad - value.clamp(0.0, 1.0) * (rect.height() - 2.0 * pad) } -impl ChannelScale { - fn y(self, center: f32, lane_height: f32, value: f32) -> f32 { - match self { - Self::Pedal => center + (0.5 - value.clamp(0.0, 1.0)) * lane_height * 0.64, - Self::Steering => center - value.clamp(-1.0, 1.0) * lane_height * 0.32, - } - } +fn steering_y(rect: Rect, value: f32) -> f32 { + rect.center().y - value.clamp(-1.0, 1.0) * rect.height() * 0.44 } fn supported(available: bool, value: impl FnOnce() -> String) -> String { @@ -535,14 +539,9 @@ fn with_opacity(color: Color32, opacity: f32) -> Color32 { } fn draw_head(painter: &Painter, center: Pos2, color: Color32, opacity: f32) { - painter.circle_filled(center, 9.0, with_opacity(color, opacity * 0.08)); - painter.circle_filled(center, 5.5, with_opacity(color, opacity * 0.20)); - painter.circle_filled(center, 2.8, with_opacity(color, opacity)); - painter.circle_stroke( - center, - 3.8, - Stroke::new(0.8_f32, with_opacity(Color32::WHITE, opacity * 0.8)), - ); + painter.circle_filled(center, 5.5, with_opacity(color, opacity * 0.09)); + painter.circle_filled(center, 3.2, with_opacity(color, opacity * 0.26)); + painter.circle_filled(center, 1.75, with_opacity(Color32::WHITE, opacity)); } fn shadowed_text( @@ -555,11 +554,11 @@ fn shadowed_text( ) { let text = text.to_string(); painter.text( - position + Vec2::new(1.25, 1.5), + position + Vec2::new(1.0, 1.25), anchor, &text, font.clone(), - Color32::from_black_alpha(165), + Color32::from_black_alpha(145), ); painter.text(position, anchor, text, font, color); } From 1d7c4fe4fc4e7b615255aad22a6124907166bb21 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 17 Sep 2026 21:25:18 +0800 Subject: [PATCH 09/12] feat: add Open HUD instrument components --- src/renderer/f1_open_hud.rs | 549 +++++++++++++++++++++++++++++++----- 1 file changed, 475 insertions(+), 74 deletions(-) diff --git a/src/renderer/f1_open_hud.rs b/src/renderer/f1_open_hud.rs index b916b9a..664eb69 100644 --- a/src/renderer/f1_open_hud.rs +++ b/src/renderer/f1_open_hud.rs @@ -15,13 +15,18 @@ const DISPLAY_FONT_NAME: &str = "saira_semi_condensed_semibold"; const GRID_DIVISIONS: usize = 4; const HISTORY_BANDS: usize = 8; const RPM_SEGMENTS: usize = 15; +const PEDAL_SEGMENTS: usize = 12; +const STEERING_SEGMENTS_PER_SIDE: usize = 12; +const STEERING_ARC_DEGREES: f32 = 240.0; +const F1_SPEED_SCALE_MS: f32 = 100.0; const TEXT_PRIMARY: Color32 = Color32::from_rgb(244, 247, 250); const TEXT_SECONDARY: Color32 = Color32::from_rgb(166, 178, 193); const GRID_COLOR: Color32 = Color32::from_rgb(112, 132, 153); const BRAKE_COLOR: Color32 = Color32::from_rgb(255, 91, 96); const THROTTLE_COLOR: Color32 = Color32::from_rgb(74, 234, 158); -const STEERING_COLOR: Color32 = Color32::from_rgb(89, 184, 255); +const SPEED_COLOR: Color32 = Color32::from_rgb(126, 196, 225); +const STEERING_COLOR: Color32 = Color32::from_rgb(238, 245, 250); const RPM_LOW: Color32 = Color32::from_rgb(74, 167, 235); const RPM_HIGH: Color32 = Color32::from_rgb(255, 78, 99); const ABS_COLOR: Color32 = Color32::from_rgb(255, 194, 55); @@ -56,7 +61,7 @@ pub struct F1OpenHudCache { shapes: Vec, brake_head: Option, throttle_head: Option, - steering_head: Option, + speed_head: Option, values: OpenHudValues, } @@ -70,6 +75,9 @@ struct OpenHudValues { speed: String, speed_unit: &'static str, rev_lights: Option, + brake_level: Option, + throttle_level: Option, + steering_level: Option, abs_active: bool, tc_active: bool, } @@ -123,7 +131,7 @@ impl<'a> F1OpenHud<'a> { cache.shapes.clear(); cache.brake_head = None; cache.throttle_head = None; - cache.steering_head = None; + cache.speed_head = None; cache.values = self.formatted_values(); let layout = OpenHudLayout::new(rect); @@ -132,7 +140,7 @@ impl<'a> F1OpenHud<'a> { let capabilities = self.latest.map(|point| point.telemetry.capabilities); let mut brake_bands: [Vec; HISTORY_BANDS] = std::array::from_fn(|_| Vec::new()); let mut throttle_bands: [Vec; HISTORY_BANDS] = std::array::from_fn(|_| Vec::new()); - let mut steering_bands: [Vec; HISTORY_BANDS] = std::array::from_fn(|_| Vec::new()); + let mut speed_bands: [Vec; HISTORY_BANDS] = std::array::from_fn(|_| Vec::new()); let mut previous: Option<(usize, [Pos2; 3])> = None; let now = std::time::Instant::now(); let window_seconds = self.settings.window_seconds.max(0.001) as f32; @@ -145,10 +153,7 @@ impl<'a> F1OpenHud<'a> { let positions = [ Pos2::new(x, pedal_y(layout.trace_rect, point.telemetry.brake)), Pos2::new(x, pedal_y(layout.trace_rect, point.telemetry.throttle)), - Pos2::new( - x, - steering_y(layout.trace_rect, point.telemetry.steering_input), - ), + Pos2::new(x, speed_y(layout.trace_rect, point.telemetry.speed)), ]; let band = ((recency * HISTORY_BANDS as f32) as usize).min(HISTORY_BANDS - 1); @@ -156,12 +161,12 @@ impl<'a> F1OpenHud<'a> { if previous_band != band && brake_bands[band].is_empty() { brake_bands[band].push(previous_positions[0]); throttle_bands[band].push(previous_positions[1]); - steering_bands[band].push(previous_positions[2]); + speed_bands[band].push(previous_positions[2]); } } brake_bands[band].push(positions[0]); throttle_bands[band].push(positions[1]); - steering_bands[band].push(positions[2]); + speed_bands[band].push(positions[2]); previous = Some((band, positions)); } @@ -170,21 +175,18 @@ impl<'a> F1OpenHud<'a> { let heads = [ Pos2::new(x, pedal_y(layout.trace_rect, latest.telemetry.brake)), Pos2::new(x, pedal_y(layout.trace_rect, latest.telemetry.throttle)), - Pos2::new( - x, - steering_y(layout.trace_rect, latest.telemetry.steering_input), - ), + Pos2::new(x, speed_y(layout.trace_rect, latest.telemetry.speed)), ]; if let Some((band, last)) = previous { if band != HISTORY_BANDS - 1 { brake_bands[HISTORY_BANDS - 1].push(last[0]); throttle_bands[HISTORY_BANDS - 1].push(last[1]); - steering_bands[HISTORY_BANDS - 1].push(last[2]); + speed_bands[HISTORY_BANDS - 1].push(last[2]); } } brake_bands[HISTORY_BANDS - 1].push(heads[0]); throttle_bands[HISTORY_BANDS - 1].push(heads[1]); - steering_bands[HISTORY_BANDS - 1].push(heads[2]); + speed_bands[HISTORY_BANDS - 1].push(heads[2]); if capabilities.is_some_and(|value| value.brake) { cache.brake_head = Some(heads[0]); @@ -192,21 +194,39 @@ impl<'a> F1OpenHud<'a> { if capabilities.is_some_and(|value| value.throttle) { cache.throttle_head = Some(heads[1]); } - if capabilities.is_some_and(|value| value.steering_input) { - cache.steering_head = Some(heads[2]); + if capabilities.is_some_and(|value| value.speed) { + cache.speed_head = Some(heads[2]); } } if capabilities.is_some_and(|value| value.brake) { - self.build_trace_layers(brake_bands, BRAKE_COLOR, &mut cache.shapes); + self.build_trace_layers(brake_bands, BRAKE_COLOR, 1.0, &mut cache.shapes); } if capabilities.is_some_and(|value| value.throttle) { - self.build_trace_layers(throttle_bands, THROTTLE_COLOR, &mut cache.shapes); + self.build_trace_layers(throttle_bands, THROTTLE_COLOR, 1.0, &mut cache.shapes); } - if capabilities.is_some_and(|value| value.steering_input) { - self.build_trace_layers(steering_bands, STEERING_COLOR, &mut cache.shapes); + if capabilities.is_some_and(|value| value.speed) { + self.build_trace_layers(speed_bands, SPEED_COLOR, 0.68, &mut cache.shapes); } + self.build_pedal_meter( + layout.brake_meter_rect, + cache.values.brake_level, + BRAKE_COLOR, + &mut cache.shapes, + ); + self.build_pedal_meter( + layout.throttle_meter_rect, + cache.values.throttle_level, + THROTTLE_COLOR, + &mut cache.shapes, + ); + self.build_steering_arc( + layout.steering_rect, + cache.values.steering_level, + &mut cache.shapes, + ); + self.build_rev_lights( layout.center_rect, cache.values.rev_lights, @@ -218,6 +238,7 @@ impl<'a> F1OpenHud<'a> { &self, bands: [Vec; HISTORY_BANDS], color: Color32, + strength: f32, shapes: &mut Vec, ) { for (band, points) in bands.into_iter().enumerate() { @@ -225,7 +246,7 @@ impl<'a> F1OpenHud<'a> { continue; } let recency = (band + 1) as f32 / HISTORY_BANDS as f32; - let energy = (0.14 + 0.86 * recency.powf(1.35)) * self.opacity; + let energy = (0.14 + 0.86 * recency.powf(1.35)) * self.opacity * strength; shapes.push(Shape::line( points.clone(), Stroke::new(6.0_f32, with_opacity(color, energy * 0.10)), @@ -264,6 +285,103 @@ impl<'a> F1OpenHud<'a> { } } + fn build_pedal_meter( + &self, + rect: Rect, + level: Option, + color: Color32, + shapes: &mut Vec, + ) { + let Some(level) = level else { + return; + }; + let gap = (rect.height() * 0.018).clamp(1.5, 3.0); + let segment_height = + ((rect.height() - gap * (PEDAL_SEGMENTS - 1) as f32) / PEDAL_SEGMENTS as f32).max(2.0); + let active_count = (level.clamp(0.0, 1.0) * PEDAL_SEGMENTS as f32).ceil() as usize; + + for index in 0..PEDAL_SEGMENTS { + let y = rect.max.y - (index + 1) as f32 * segment_height - index as f32 * gap; + let segment = Rect::from_min_size( + Pos2::new(rect.min.x, y), + Vec2::new(rect.width(), segment_height), + ); + let state = if index + 1 == active_count && active_count > 0 { + SegmentState::Edge + } else if index < active_count { + SegmentState::Active + } else { + SegmentState::Inactive + }; + push_luminous_segment( + shapes, + SegmentGeometry::Rect(segment, 1.2), + color, + state, + self.opacity, + ); + } + } + + fn build_steering_arc(&self, rect: Rect, steering: Option, shapes: &mut Vec) { + let Some(steering) = steering else { + return; + }; + let side = STEERING_SEGMENTS_PER_SIDE; + let segment_count = side * 2 + 1; + let center_index = side; + let center = Pos2::new(rect.center().x, rect.center().y + rect.height() * 0.12); + let outer_radius = (rect.width().min(rect.height()) * 0.48).max(8.0); + let inner_radius = (outer_radius - (outer_radius * 0.13).clamp(3.5, 6.0)).max(2.0); + let total_arc = STEERING_ARC_DEGREES.to_radians(); + let center_angle = -std::f32::consts::FRAC_PI_2; + let start = center_angle - total_arc * 0.5; + let step = total_arc / segment_count as f32; + let gap = step * 0.28; + let active_count = (steering.abs().clamp(0.0, 1.0) * side as f32).ceil() as usize; + let edge_index = if steering < 0.0 && active_count > 0 { + Some(center_index - active_count) + } else if steering > 0.0 && active_count > 0 { + Some(center_index + active_count) + } else { + None + }; + + for index in 0..segment_count { + let state = if index == center_index { + SegmentState::Center + } else { + let active = if steering < 0.0 { + index < center_index && center_index - index <= active_count + } else { + index > center_index && index - center_index <= active_count + }; + if Some(index) == edge_index { + SegmentState::Edge + } else if active { + SegmentState::Active + } else { + SegmentState::Inactive + } + }; + let angle_min = start + index as f32 * step + gap * 0.5; + let angle_max = start + (index + 1) as f32 * step - gap * 0.5; + push_luminous_segment( + shapes, + SegmentGeometry::Polygon(arc_segment( + center, + inner_radius, + outer_radius, + angle_min, + angle_max, + )), + STEERING_COLOR, + state, + self.opacity, + ); + } + } + fn build_rev_lights(&self, rect: Rect, rev_lights: Option, shapes: &mut Vec) { let Some(rev_lights) = rev_lights else { return; @@ -272,23 +390,28 @@ impl<'a> F1OpenHud<'a> { let width = (rect.width() - gap * (RPM_SEGMENTS - 1) as f32) / RPM_SEGMENTS as f32; let y = rect.min.y + rect.height() * 0.16; + let active_edge = (0..RPM_SEGMENTS) + .rev() + .find(|index| rev_lights.bit_value & (1_u16 << index) != 0); for index in 0..RPM_SEGMENTS { let x = rect.min.x + index as f32 * (width + gap); let segment = Rect::from_min_size(Pos2::new(x, y), Vec2::new(width.max(1.0), 4.5)); let active = rev_lights.bit_value & (1_u16 << index) != 0; let base = if index < 10 { RPM_LOW } else { RPM_HIGH }; - if active { - shapes.push(Shape::rect_filled( - segment.expand(2.0), - 1.0, - with_opacity(base, self.opacity * 0.10), - )); - } - shapes.push(Shape::rect_filled( - segment, - 0.75, - with_opacity(base, self.opacity * if active { 0.92 } else { 0.055 }), - )); + let state = if active_edge == Some(index) { + SegmentState::Edge + } else if active { + SegmentState::Active + } else { + SegmentState::Inactive + }; + push_luminous_segment( + shapes, + SegmentGeometry::Rect(segment, 0.9), + base, + state, + self.opacity, + ); } } @@ -321,6 +444,7 @@ impl<'a> F1OpenHud<'a> { } else { telemetry.speed * 3.6 }; + let steering_degrees = telemetry.effective_steering_degrees(self.max_steering_angle); OpenHudValues { brake: supported(capabilities.brake, || { format!("{:.0}%", telemetry.brake * 100.0) @@ -329,10 +453,11 @@ impl<'a> F1OpenHud<'a> { format!("{:.0}%", telemetry.throttle * 100.0) }), steering: supported(capabilities.steering_input, || { - format!( - "{:.0}°", - telemetry.effective_steering_degrees(self.max_steering_angle) - ) + if steering_degrees.abs() < 0.5 { + "0°".to_owned() + } else { + format!("{steering_degrees:+.0}°") + } }), rpm: supported(capabilities.rpm, || format!("{:.0}", telemetry.rpm)), gear: supported(capabilities.gear, || gear), @@ -343,6 +468,11 @@ impl<'a> F1OpenHud<'a> { "KM/H" }, rev_lights: telemetry.rev_lights, + brake_level: capabilities.brake.then_some(telemetry.brake), + throttle_level: capabilities.throttle.then_some(telemetry.throttle), + steering_level: capabilities + .steering_input + .then_some(telemetry.steering_input.clamp(-1.0, 1.0)), abs_active: capabilities.abs_activity && telemetry.abs_active, tc_active: capabilities.tc_activity && telemetry.tc_active, } @@ -358,36 +488,42 @@ impl<'a> F1OpenHud<'a> { let channels = [ ("BRAKE", &cache.values.brake, BRAKE_COLOR), ("THROTTLE", &cache.values.throttle, THROTTLE_COLOR), - ("STEERING", &cache.values.steering, STEERING_COLOR), + ("SPEED", &cache.values.speed, SPEED_COLOR), ]; for (index, (label, value, color)) in channels.into_iter().enumerate() { let y = label_top + index as f32 * label_step; - shadowed_text( + luminous_text( painter, Pos2::new(layout.left_rect.min.x, y), Align2::LEFT_CENTER, label, label_font.clone(), - with_opacity(color, alpha * 0.92), + color, + color, + alpha * if index == 2 { 0.76 } else { 0.94 }, + TextClass::Channel, ); - shadowed_text( + luminous_text( painter, Pos2::new(layout.left_rect.max.x, y), Align2::RIGHT_CENTER, value, value_font.clone(), - with_opacity(color, alpha), + color, + color, + alpha * if index == 2 { 0.78 } else { 1.0 }, + TextClass::Channel, ); } - for (head, color) in [ - (cache.brake_head, BRAKE_COLOR), - (cache.throttle_head, THROTTLE_COLOR), - (cache.steering_head, STEERING_COLOR), + for (head, color, strength) in [ + (cache.brake_head, BRAKE_COLOR, 1.0), + (cache.throttle_head, THROTTLE_COLOR, 1.0), + (cache.speed_head, SPEED_COLOR, 0.68), ] { if let Some(head) = head { - draw_head(painter, head, color, alpha); + draw_head(painter, head, color, alpha * strength); } } @@ -398,7 +534,7 @@ impl<'a> F1OpenHud<'a> { layout.trace_rect.x_range(), division as f32 / GRID_DIVISIONS as f32, ); - shadowed_text( + luminous_text( painter, Pos2::new(x, layout.trace_rect.max.y + 9.0), Align2::CENTER_TOP, @@ -408,12 +544,82 @@ impl<'a> F1OpenHud<'a> { format!("{seconds:.0}s") }, FontId::new(8.0, FontFamily::Proportional), - with_opacity(TEXT_SECONDARY, alpha * 0.46), + TEXT_SECONDARY, + TEXT_SECONDARY, + alpha * 0.46, + TextClass::Secondary, ); } + for (meter, value, label, color) in [ + ( + layout.brake_meter_rect, + &cache.values.brake, + "BRAKE", + BRAKE_COLOR, + ), + ( + layout.throttle_meter_rect, + &cache.values.throttle, + "THROTTLE", + THROTTLE_COLOR, + ), + ] { + luminous_text( + painter, + Pos2::new(meter.center().x, meter.min.y - 9.0), + Align2::CENTER_BOTTOM, + value, + display_font(13.0), + color, + color, + alpha, + TextClass::Channel, + ); + luminous_text( + painter, + Pos2::new(meter.center().x, meter.max.y + 8.0), + Align2::CENTER_TOP, + label, + display_font(8.5), + color, + color, + alpha * 0.78, + TextClass::Channel, + ); + } + + luminous_text( + painter, + Pos2::new( + layout.steering_rect.center().x, + layout.steering_rect.center().y + layout.steering_rect.height() * 0.14, + ), + Align2::CENTER_CENTER, + &cache.values.steering, + display_font(15.0), + TEXT_PRIMARY, + STEERING_COLOR, + alpha, + TextClass::Channel, + ); + luminous_text( + painter, + Pos2::new( + layout.steering_rect.center().x, + layout.steering_rect.max.y - 2.0, + ), + Align2::CENTER_BOTTOM, + "STEERING", + display_font(8.0), + TEXT_SECONDARY, + STEERING_COLOR, + alpha * 0.58, + TextClass::Secondary, + ); + let center = layout.center_rect.center(); - shadowed_text( + luminous_text( painter, Pos2::new( center.x, @@ -422,9 +628,12 @@ impl<'a> F1OpenHud<'a> { Align2::CENTER_CENTER, format!("{} RPM", cache.values.rpm), display_font((layout.center_rect.width() * 0.075).clamp(12.0, 18.0)), - with_opacity(TEXT_SECONDARY, alpha * 0.94), + TEXT_SECONDARY, + TEXT_PRIMARY, + alpha * 0.88, + TextClass::Secondary, ); - shadowed_text( + luminous_text( painter, Pos2::new( center.x, @@ -433,9 +642,12 @@ impl<'a> F1OpenHud<'a> { Align2::CENTER_CENTER, &cache.values.gear, display_font((layout.center_rect.height() * 0.29).clamp(50.0, 94.0)), - with_opacity(TEXT_PRIMARY, alpha), + TEXT_PRIMARY, + STEERING_COLOR, + alpha, + TextClass::Major, ); - shadowed_text( + luminous_text( painter, Pos2::new( center.x, @@ -444,9 +656,12 @@ impl<'a> F1OpenHud<'a> { Align2::CENTER_CENTER, &cache.values.speed, display_font((layout.center_rect.height() * 0.14).clamp(27.0, 48.0)), - with_opacity(TEXT_PRIMARY, alpha * 0.96), + TEXT_PRIMARY, + STEERING_COLOR, + alpha * 0.94, + TextClass::Major, ); - shadowed_text( + luminous_text( painter, Pos2::new( center.x, @@ -455,28 +670,37 @@ impl<'a> F1OpenHud<'a> { Align2::CENTER_CENTER, cache.values.speed_unit, display_font(10.0), - with_opacity(TEXT_SECONDARY, alpha * 0.82), + TEXT_SECONDARY, + TEXT_SECONDARY, + alpha * 0.78, + TextClass::Secondary, ); let state_y = layout.center_rect.max.y - 19.0; if cache.values.abs_active { - shadowed_text( + luminous_text( painter, Pos2::new(center.x - 28.0, state_y), Align2::CENTER_CENTER, "ABS", display_font(10.5), - with_opacity(ABS_COLOR, alpha), + ABS_COLOR, + ABS_COLOR, + alpha, + TextClass::Channel, ); } if cache.values.tc_active { - shadowed_text( + luminous_text( painter, Pos2::new(center.x + 28.0, state_y), Align2::CENTER_CENTER, "TC", display_font(10.5), - with_opacity(THROTTLE_COLOR, alpha), + THROTTLE_COLOR, + THROTTLE_COLOR, + alpha, + TextClass::Channel, ); } } @@ -485,14 +709,18 @@ impl<'a> F1OpenHud<'a> { struct OpenHudLayout { left_rect: Rect, trace_rect: Rect, + brake_meter_rect: Rect, + throttle_meter_rect: Rect, + steering_rect: Rect, center_rect: Rect, } impl OpenHudLayout { fn new(rect: Rect) -> Self { let outer = rect.shrink2(Vec2::new(14.0, 10.0)); - let left_width = outer.width() * 0.67; - let gap = 22.0_f32.min(outer.width() * 0.04); + let left_width = outer.width() * 0.55; + let instrument_width = (outer.width() * 0.15).clamp(96.0, 160.0); + let gap = 14.0_f32.min(outer.width() * 0.025); let left_rect = Rect::from_min_size(outer.min, Vec2::new(left_width, outer.height())); let label_width = (left_width * 0.20).clamp(78.0, 116.0); let value_width = (left_width * 0.13).clamp(48.0, 74.0); @@ -506,11 +734,40 @@ impl OpenHudLayout { left_rect.max.y - outer.height() * 0.12, ), ); - let center_rect = - Rect::from_min_max(Pos2::new(left_rect.max.x + gap, outer.min.y), outer.max); + let instrument_rect = Rect::from_min_size( + Pos2::new(left_rect.max.x + gap, outer.min.y), + Vec2::new(instrument_width, outer.height()), + ); + let meter_top = instrument_rect.min.y + outer.height() * 0.15; + let meter_bottom = instrument_rect.min.y + outer.height() * 0.53; + let meter_width = (instrument_rect.width() * 0.12).clamp(8.0, 14.0); + let brake_x = instrument_rect.min.x + instrument_rect.width() * 0.28; + let throttle_x = instrument_rect.min.x + instrument_rect.width() * 0.72; + let brake_meter_rect = Rect::from_min_max( + Pos2::new(brake_x - meter_width * 0.5, meter_top), + Pos2::new(brake_x + meter_width * 0.5, meter_bottom), + ); + let throttle_meter_rect = Rect::from_min_max( + Pos2::new(throttle_x - meter_width * 0.5, meter_top), + Pos2::new(throttle_x + meter_width * 0.5, meter_bottom), + ); + let steering_rect = Rect::from_min_max( + Pos2::new( + instrument_rect.min.x, + instrument_rect.min.y + outer.height() * 0.57, + ), + instrument_rect.max, + ); + let center_rect = Rect::from_min_max( + Pos2::new(instrument_rect.max.x + gap, outer.min.y), + outer.max, + ); Self { left_rect, trace_rect, + brake_meter_rect, + throttle_meter_rect, + steering_rect, center_rect, } } @@ -521,8 +778,113 @@ fn pedal_y(rect: Rect, value: f32) -> f32 { rect.max.y - pad - value.clamp(0.0, 1.0) * (rect.height() - 2.0 * pad) } -fn steering_y(rect: Rect, value: f32) -> f32 { - rect.center().y - value.clamp(-1.0, 1.0) * rect.height() * 0.44 +fn speed_y(rect: Rect, speed_ms: f32) -> f32 { + pedal_y(rect, speed_ms / F1_SPEED_SCALE_MS) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum SegmentState { + Inactive, + Center, + Active, + Edge, +} + +enum SegmentGeometry { + Rect(Rect, f32), + Polygon(Vec), +} + +fn push_luminous_segment( + shapes: &mut Vec, + geometry: SegmentGeometry, + color: Color32, + state: SegmentState, + opacity: f32, +) { + let (body_alpha, glow_alpha, glow_width) = match state { + SegmentState::Inactive => (0.045, 0.0, 0.0), + SegmentState::Center => (0.30, 0.035, 2.0), + SegmentState::Active => (0.82, 0.10, 3.0), + SegmentState::Edge => (1.0, 0.15, 4.0), + }; + let active = matches!(state, SegmentState::Active | SegmentState::Edge); + + match geometry { + SegmentGeometry::Rect(rect, rounding) => { + if glow_alpha > 0.0 { + shapes.push(Shape::rect_filled( + rect.expand(glow_width * 0.65), + rounding + 1.0, + with_opacity(color, opacity * glow_alpha), + )); + } + shapes.push(Shape::rect_filled( + rect, + rounding, + with_opacity(color, opacity * body_alpha), + )); + if active { + let highlight_y = rect.min.y + (rect.height() * 0.25).max(0.7); + shapes.push(Shape::line_segment( + [ + Pos2::new(rect.min.x + 1.0, highlight_y), + Pos2::new(rect.max.x - 1.0, highlight_y), + ], + Stroke::new( + 0.65_f32, + with_opacity( + Color32::WHITE, + opacity + * if state == SegmentState::Edge { + 0.58 + } else { + 0.36 + }, + ), + ), + )); + } + } + SegmentGeometry::Polygon(points) => { + if glow_alpha > 0.0 { + shapes.push(Shape::convex_polygon( + points.clone(), + with_opacity(color, opacity * glow_alpha * 0.55), + Stroke::new(glow_width, with_opacity(color, opacity * glow_alpha)), + )); + } + shapes.push(Shape::convex_polygon( + points, + with_opacity(color, opacity * body_alpha), + Stroke::new( + if active { 0.65_f32 } else { 0.0_f32 }, + with_opacity(Color32::WHITE, opacity * if active { 0.30 } else { 0.0 }), + ), + )); + } + } +} + +fn arc_segment( + center: Pos2, + inner_radius: f32, + outer_radius: f32, + angle_min: f32, + angle_max: f32, +) -> Vec { + let polar = |radius: f32, angle: f32| { + Pos2::new( + center.x + radius * angle.cos(), + center.y + radius * angle.sin(), + ) + }; + vec![ + polar(inner_radius, angle_min), + polar(outer_radius, angle_min), + polar(outer_radius, angle_max), + polar(inner_radius, angle_max), + ] } fn supported(available: bool, value: impl FnOnce() -> String) -> String { @@ -544,21 +906,60 @@ fn draw_head(painter: &Painter, center: Pos2, color: Color32, opacity: f32) { painter.circle_filled(center, 1.75, with_opacity(Color32::WHITE, opacity)); } -fn shadowed_text( +#[derive(Clone, Copy)] +enum TextClass { + Channel, + Major, + Secondary, +} + +#[allow(clippy::too_many_arguments)] +fn luminous_text( painter: &Painter, position: Pos2, anchor: Align2, text: impl ToString, font: FontId, - color: Color32, + core_color: Color32, + glow_color: Color32, + opacity: f32, + class: TextClass, ) { let text = text.to_string(); painter.text( - position + Vec2::new(1.0, 1.25), + position + Vec2::new(0.9, 1.15), anchor, &text, font.clone(), - Color32::from_black_alpha(145), + with_opacity(Color32::from_black_alpha(150), opacity * 0.62), + ); + + let (spread, glow_alpha) = match class { + TextClass::Channel => ((font.size * 0.11).clamp(1.25, 2.2), 0.16), + TextClass::Major => ((font.size * 0.04).clamp(1.8, 3.2), 0.075), + TextClass::Secondary => (0.0, 0.0), + }; + if glow_alpha > 0.0 { + for offset in [ + Vec2::new(-spread, 0.0), + Vec2::new(spread, 0.0), + Vec2::new(0.0, -spread), + Vec2::new(0.0, spread), + ] { + painter.text( + position + offset, + anchor, + &text, + font.clone(), + with_opacity(glow_color, opacity * glow_alpha), + ); + } + } + painter.text( + position, + anchor, + text, + font, + with_opacity(core_color, opacity), ); - painter.text(position, anchor, text, font, color); } From ed818075ae0ad2eb13d8e324748e2df4495c7419 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 17 Sep 2026 23:10:47 +0800 Subject: [PATCH 10/12] feat: refine Open HUD luminous materials --- src/renderer/f1_open_hud.rs | 146 ++++++++++++++++++++++++++---------- 1 file changed, 105 insertions(+), 41 deletions(-) diff --git a/src/renderer/f1_open_hud.rs b/src/renderer/f1_open_hud.rs index 664eb69..f49de19 100644 --- a/src/renderer/f1_open_hud.rs +++ b/src/renderer/f1_open_hud.rs @@ -200,13 +200,13 @@ impl<'a> F1OpenHud<'a> { } if capabilities.is_some_and(|value| value.brake) { - self.build_trace_layers(brake_bands, BRAKE_COLOR, 1.0, &mut cache.shapes); + self.build_trace_layers(brake_bands, BRAKE_COLOR, true, &mut cache.shapes); } if capabilities.is_some_and(|value| value.throttle) { - self.build_trace_layers(throttle_bands, THROTTLE_COLOR, 1.0, &mut cache.shapes); + self.build_trace_layers(throttle_bands, THROTTLE_COLOR, true, &mut cache.shapes); } if capabilities.is_some_and(|value| value.speed) { - self.build_trace_layers(speed_bands, SPEED_COLOR, 0.68, &mut cache.shapes); + self.build_trace_layers(speed_bands, SPEED_COLOR, false, &mut cache.shapes); } self.build_pedal_meter( @@ -238,7 +238,7 @@ impl<'a> F1OpenHud<'a> { &self, bands: [Vec; HISTORY_BANDS], color: Color32, - strength: f32, + primary: bool, shapes: &mut Vec, ) { for (band, points) in bands.into_iter().enumerate() { @@ -246,18 +246,37 @@ impl<'a> F1OpenHud<'a> { continue; } let recency = (band + 1) as f32 / HISTORY_BANDS as f32; - let energy = (0.14 + 0.86 * recency.powf(1.35)) * self.opacity * strength; + let energy = (0.08 + 0.92 * recency.powf(1.6)) * self.opacity; + let hot = hot_face(color, if primary { 0.76 } else { 0.48 }); + let ( + outer_width, + outer_alpha, + inner_width, + inner_alpha, + body_width, + body_alpha, + core_width, + core_alpha, + ): (f32, f32, f32, f32, f32, f32, f32, f32) = if primary { + (10.5, 0.055, 5.5, 0.17, 2.45, 0.88, 0.9, 0.96) + } else { + (6.0, 0.035, 3.2, 0.09, 1.55, 0.62, 0.6, 0.58) + }; + shapes.push(Shape::line( + points.clone(), + Stroke::new(outer_width, with_opacity(color, energy * outer_alpha)), + )); shapes.push(Shape::line( points.clone(), - Stroke::new(6.0_f32, with_opacity(color, energy * 0.10)), + Stroke::new(inner_width, with_opacity(color, energy * inner_alpha)), )); shapes.push(Shape::line( points.clone(), - Stroke::new(3.2_f32, with_opacity(color, energy * 0.13)), + Stroke::new(body_width, with_opacity(color, energy * body_alpha)), )); shapes.push(Shape::line( points, - Stroke::new(1.55_f32, with_opacity(color, energy * 0.94)), + Stroke::new(core_width, with_opacity(hot, energy * core_alpha)), )); } } @@ -740,7 +759,7 @@ impl OpenHudLayout { ); let meter_top = instrument_rect.min.y + outer.height() * 0.15; let meter_bottom = instrument_rect.min.y + outer.height() * 0.53; - let meter_width = (instrument_rect.width() * 0.12).clamp(8.0, 14.0); + let meter_width = (instrument_rect.width() * 0.23).clamp(18.0, 28.0); let brake_x = instrument_rect.min.x + instrument_rect.width() * 0.28; let throttle_x = instrument_rect.min.x + instrument_rect.width() * 0.72; let brake_meter_rect = Rect::from_min_max( @@ -802,21 +821,27 @@ fn push_luminous_segment( state: SegmentState, opacity: f32, ) { - let (body_alpha, glow_alpha, glow_width) = match state { - SegmentState::Inactive => (0.045, 0.0, 0.0), - SegmentState::Center => (0.30, 0.035, 2.0), - SegmentState::Active => (0.82, 0.10, 3.0), - SegmentState::Edge => (1.0, 0.15, 4.0), + let (body_alpha, inner_alpha, outer_alpha, spread) = match state { + SegmentState::Inactive => (0.035, 0.0, 0.0, 0.0), + SegmentState::Center => (0.34, 0.07, 0.025, 2.0), + SegmentState::Active => (0.88, 0.15, 0.045, 4.0), + SegmentState::Edge => (1.0, 0.22, 0.075, 5.0), }; let active = matches!(state, SegmentState::Active | SegmentState::Edge); + let hot = hot_face(color, 0.76); match geometry { SegmentGeometry::Rect(rect, rounding) => { - if glow_alpha > 0.0 { + if outer_alpha > 0.0 { shapes.push(Shape::rect_filled( - rect.expand(glow_width * 0.65), + rect.expand(spread), + rounding + 2.0, + with_opacity(color, opacity * outer_alpha), + )); + shapes.push(Shape::rect_filled( + rect.expand(spread * 0.42), rounding + 1.0, - with_opacity(color, opacity * glow_alpha), + with_opacity(color, opacity * inner_alpha), )); } shapes.push(Shape::rect_filled( @@ -832,14 +857,14 @@ fn push_luminous_segment( Pos2::new(rect.max.x - 1.0, highlight_y), ], Stroke::new( - 0.65_f32, + 0.8_f32, with_opacity( - Color32::WHITE, + hot, opacity * if state == SegmentState::Edge { - 0.58 + 0.92 } else { - 0.36 + 0.68 }, ), ), @@ -847,19 +872,24 @@ fn push_luminous_segment( } } SegmentGeometry::Polygon(points) => { - if glow_alpha > 0.0 { + if outer_alpha > 0.0 { shapes.push(Shape::convex_polygon( points.clone(), - with_opacity(color, opacity * glow_alpha * 0.55), - Stroke::new(glow_width, with_opacity(color, opacity * glow_alpha)), + with_opacity(color, opacity * outer_alpha * 0.45), + Stroke::new(spread, with_opacity(color, opacity * outer_alpha)), + )); + shapes.push(Shape::convex_polygon( + points.clone(), + with_opacity(color, opacity * inner_alpha * 0.55), + Stroke::new(spread * 0.42, with_opacity(color, opacity * inner_alpha)), )); } shapes.push(Shape::convex_polygon( points, with_opacity(color, opacity * body_alpha), Stroke::new( - if active { 0.65_f32 } else { 0.0_f32 }, - with_opacity(Color32::WHITE, opacity * if active { 0.30 } else { 0.0 }), + if active { 0.75_f32 } else { 0.0_f32 }, + with_opacity(hot, opacity * if active { 0.72 } else { 0.0 }), ), )); } @@ -900,10 +930,18 @@ fn with_opacity(color: Color32, opacity: f32) -> Color32 { Color32::from_rgba_unmultiplied(r, g, b, (a as f32 * opacity.clamp(0.0, 1.0)) as u8) } +fn hot_face(color: Color32, white_mix: f32) -> Color32 { + let [r, g, b, a] = color.to_array(); + let mix = white_mix.clamp(0.0, 1.0); + let heat = |channel: u8| channel as f32 + (255.0 - channel as f32) * mix; + Color32::from_rgba_unmultiplied(heat(r) as u8, heat(g) as u8, heat(b) as u8, a) +} + fn draw_head(painter: &Painter, center: Pos2, color: Color32, opacity: f32) { - painter.circle_filled(center, 5.5, with_opacity(color, opacity * 0.09)); - painter.circle_filled(center, 3.2, with_opacity(color, opacity * 0.26)); - painter.circle_filled(center, 1.75, with_opacity(Color32::WHITE, opacity)); + painter.circle_filled(center, 15.0, with_opacity(color, opacity * 0.045)); + painter.circle_filled(center, 8.0, with_opacity(color, opacity * 0.15)); + painter.circle_filled(center, 4.2, with_opacity(color, opacity * 0.88)); + painter.circle_filled(center, 1.8, with_opacity(hot_face(color, 0.84), opacity)); } #[derive(Clone, Copy)] @@ -931,27 +969,53 @@ fn luminous_text( anchor, &text, font.clone(), - with_opacity(Color32::from_black_alpha(150), opacity * 0.62), + with_opacity(Color32::from_black_alpha(150), opacity * 0.42), ); - let (spread, glow_alpha) = match class { - TextClass::Channel => ((font.size * 0.11).clamp(1.25, 2.2), 0.16), - TextClass::Major => ((font.size * 0.04).clamp(1.8, 3.2), 0.075), - TextClass::Secondary => (0.0, 0.0), + let (outer_spread, outer_alpha, inner_spread, inner_alpha, face_mix) = match class { + TextClass::Channel => ( + (font.size * 0.20).clamp(2.4, 4.0), + 0.065, + (font.size * 0.09).clamp(1.0, 2.0), + 0.19, + 0.64, + ), + TextClass::Major => ( + (font.size * 0.055).clamp(2.0, 3.8), + 0.05, + (font.size * 0.024).clamp(0.9, 1.6), + 0.09, + 0.18, + ), + TextClass::Secondary => (0.0, 0.0, 0.0, 0.0, 0.0), }; - if glow_alpha > 0.0 { + if outer_alpha > 0.0 { + for offset in [ + Vec2::new(-outer_spread, 0.0), + Vec2::new(outer_spread, 0.0), + Vec2::new(0.0, -outer_spread), + Vec2::new(0.0, outer_spread), + ] { + painter.text( + position + offset, + anchor, + &text, + font.clone(), + with_opacity(glow_color, opacity * outer_alpha), + ); + } for offset in [ - Vec2::new(-spread, 0.0), - Vec2::new(spread, 0.0), - Vec2::new(0.0, -spread), - Vec2::new(0.0, spread), + Vec2::new(-inner_spread, 0.0), + Vec2::new(inner_spread, 0.0), + Vec2::new(0.0, -inner_spread), + Vec2::new(0.0, inner_spread), ] { painter.text( position + offset, anchor, &text, font.clone(), - with_opacity(glow_color, opacity * glow_alpha), + with_opacity(glow_color, opacity * inner_alpha), ); } } @@ -960,6 +1024,6 @@ fn luminous_text( anchor, text, font, - with_opacity(core_color, opacity), + with_opacity(hot_face(core_color, face_mix), opacity), ); } From b49078881b281310b1a2d7a31069a55e2947f130 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 18 Sep 2026 00:22:52 +0800 Subject: [PATCH 11/12] feat: add additive Open HUD emission renderer --- src/renderer/app.rs | 5 + src/renderer/f1_glow_wgpu.rs | 312 +++++++++++++++++++++++++++++++++ src/renderer/f1_glow_wgpu.wgsl | 80 +++++++++ src/renderer/f1_open_hud.rs | 231 ++++++++++++++++++++---- src/renderer/mod.rs | 1 + 5 files changed, 595 insertions(+), 34 deletions(-) create mode 100644 src/renderer/f1_glow_wgpu.rs create mode 100644 src/renderer/f1_glow_wgpu.wgsl diff --git a/src/renderer/app.rs b/src/renderer/app.rs index 70c0603..04e8637 100644 --- a/src/renderer/app.rs +++ b/src/renderer/app.rs @@ -113,6 +113,7 @@ pub struct SimTraceApp { phase_plot_cache: Arc>, track_strip_cache: Arc>, f1_open_hud_cache: Arc>, + f1_glow_available: bool, } impl SimTraceApp { @@ -133,6 +134,8 @@ impl SimTraceApp { .map(|p| p.get_config().max_steering_angle) .unwrap_or(450.0); let now = Instant::now(); + let f1_glow_available = + crate::renderer::f1_glow_wgpu::install(cc.wgpu_render_state.as_ref()); Self { settings, buffer: Arc::new(TelemetryBuffer::new(std::time::Duration::from_secs( @@ -160,6 +163,7 @@ impl SimTraceApp { phase_plot_cache: Arc::new(Mutex::new(Default::default())), track_strip_cache: Arc::new(Mutex::new(Default::default())), f1_open_hud_cache: Arc::new(Mutex::new(Default::default())), + f1_glow_available, } } @@ -548,6 +552,7 @@ impl eframe::App for SimTraceApp { &mut content_ui, content_rect.shrink(2.0).size(), visualization_due, + self.f1_glow_available, &mut f1_open_hud_cache.lock().unwrap(), ); } else { diff --git a/src/renderer/f1_glow_wgpu.rs b/src/renderer/f1_glow_wgpu.rs new file mode 100644 index 0000000..c151ff0 --- /dev/null +++ b/src/renderer/f1_glow_wgpu.rs @@ -0,0 +1,312 @@ +//! Beta-only additive emission renderer for the F1 Open HUD. + +use eframe::{ + egui, + egui_wgpu::{self, wgpu}, +}; +use std::sync::Arc; + +const INSTANCE_FLOATS: usize = 16; +const INSTANCE_SIZE: wgpu::BufferAddress = + (INSTANCE_FLOATS * std::mem::size_of::()) as wgpu::BufferAddress; + +#[derive(Clone, Default)] +pub struct GlowBatch { + generation: u64, + instances: Arc<[[f32; INSTANCE_FLOATS]]>, +} + +impl GlowBatch { + pub fn is_empty(&self) -> bool { + self.instances.is_empty() + } +} + +pub struct GlowBatchBuilder { + origin: egui::Pos2, + viewport_size: egui::Vec2, + instances: Vec<[f32; INSTANCE_FLOATS]>, +} + +impl GlowBatchBuilder { + pub fn new(viewport: egui::Rect) -> Self { + Self { + origin: viewport.min, + viewport_size: viewport.size(), + instances: Vec::new(), + } + } + + pub fn ribbon_segment( + &mut self, + start: egui::Pos2, + end: egui::Pos2, + radius: f32, + color: egui::Color32, + intensity: f32, + ) { + self.push(start, end, radius, color, intensity, 0.0); + } + + pub fn radial( + &mut self, + center: egui::Pos2, + radius: f32, + color: egui::Color32, + intensity: f32, + ) { + self.push(center, center, radius, color, intensity, 2.0); + } + + pub fn rounded_rect( + &mut self, + rect: egui::Rect, + spread: f32, + color: egui::Color32, + intensity: f32, + ) { + self.push(rect.min, rect.max, spread, color, intensity, 1.0); + } + + fn push( + &mut self, + a: egui::Pos2, + b: egui::Pos2, + radius: f32, + color: egui::Color32, + intensity: f32, + kind: f32, + ) { + let a = a - self.origin.to_vec2(); + let b = b - self.origin.to_vec2(); + let [r, g, b_color, _] = color.to_array(); + self.instances.push([ + a.x, + a.y, + b.x, + b.y, + radius.max(0.5), + intensity.clamp(0.0, 1.0), + kind, + 0.0, + r as f32 / 255.0, + g as f32 / 255.0, + b_color as f32 / 255.0, + 0.0, + self.viewport_size.x.max(1.0), + self.viewport_size.y.max(1.0), + 0.0, + 0.0, + ]); + } + + pub fn finish(self, generation: u64) -> GlowBatch { + GlowBatch { + generation, + instances: self.instances.into(), + } + } +} + +pub fn install(render_state: Option<&egui_wgpu::RenderState>) -> bool { + let Some(render_state) = render_state else { + tracing::warn!("F1 additive glow unavailable: no wgpu render state"); + return false; + }; + + let shader = render_state + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("f1_open_hud_glow_shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("f1_glow_wgpu.wgsl").into()), + }); + let pipeline_layout = + render_state + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("f1_open_hud_glow_pipeline_layout"), + bind_group_layouts: &[], + push_constant_ranges: &[], + }); + let pipeline = render_state + .device + .create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("f1_open_hud_glow_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[wgpu::VertexBufferLayout { + array_stride: INSTANCE_SIZE, + step_mode: wgpu::VertexStepMode::Instance, + attributes: &[ + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x4, + offset: 0, + shader_location: 0, + }, + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x4, + offset: 16, + shader_location: 1, + }, + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x4, + offset: 32, + shader_location: 2, + }, + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x4, + offset: 48, + shader_location: 3, + }, + ], + }], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: render_state.target_format, + // Bounded additive (screen) RGB keeps premultiplied RGB <= alpha. + // Alpha uses coverage union, matching premultiplied composition. + blend: Some(wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrc, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::OneMinusDstAlpha, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + }), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + let instance_buffer = render_state.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("f1_open_hud_glow_instances"), + size: INSTANCE_SIZE, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + render_state + .renderer + .write() + .callback_resources + .insert(GlowRenderResources { + pipeline, + instance_buffer, + capacity: 1, + uploaded_generation: None, + }); + tracing::info!("F1 Open HUD additive glow renderer installed"); + true +} + +pub fn callback(rect: egui::Rect, batch: GlowBatch) -> egui::PaintCallback { + egui_wgpu::Callback::new_paint_callback(rect, GlowCallback { batch }) +} + +struct GlowCallback { + batch: GlowBatch, +} + +impl egui_wgpu::CallbackTrait for GlowCallback { + fn prepare( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + _screen_descriptor: &egui_wgpu::ScreenDescriptor, + _egui_encoder: &mut wgpu::CommandEncoder, + resources: &mut egui_wgpu::CallbackResources, + ) -> Vec { + let Some(resources) = resources.get_mut::() else { + return Vec::new(); + }; + if resources.uploaded_generation == Some(self.batch.generation) { + return Vec::new(); + } + + let instance_count = self.batch.instances.len().max(1); + if instance_count > resources.capacity { + resources.capacity = instance_count.next_power_of_two(); + resources.instance_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("f1_open_hud_glow_instances"), + size: INSTANCE_SIZE * resources.capacity as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + } + + if !self.batch.instances.is_empty() { + let mut bytes = Vec::with_capacity(self.batch.instances.len() * INSTANCE_SIZE as usize); + for instance in self.batch.instances.iter() { + for value in instance { + bytes.extend_from_slice(&value.to_ne_bytes()); + } + } + queue.write_buffer(&resources.instance_buffer, 0, &bytes); + } + resources.uploaded_generation = Some(self.batch.generation); + Vec::new() + } + + fn paint( + &self, + _info: egui::PaintCallbackInfo, + render_pass: &mut wgpu::RenderPass<'static>, + resources: &egui_wgpu::CallbackResources, + ) { + if self.batch.instances.is_empty() { + return; + } + let Some(resources) = resources.get::() else { + return; + }; + render_pass.set_pipeline(&resources.pipeline); + render_pass.set_vertex_buffer(0, resources.instance_buffer.slice(..)); + render_pass.draw(0..6, 0..self.batch.instances.len() as u32); + } +} + +struct GlowRenderResources { + pipeline: wgpu::RenderPipeline, + instance_buffer: wgpu::Buffer, + capacity: usize, + uploaded_generation: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builder_converts_to_viewport_local_coordinates() { + let viewport = egui::Rect::from_min_size(egui::pos2(50.0, 20.0), egui::vec2(300.0, 100.0)); + let mut builder = GlowBatchBuilder::new(viewport); + builder.ribbon_segment( + egui::pos2(55.0, 24.0), + egui::pos2(65.0, 28.0), + 7.0, + egui::Color32::RED, + 0.25, + ); + let batch = builder.finish(9); + + assert_eq!(batch.generation, 9); + assert_eq!(batch.instances.len(), 1); + assert_eq!(&batch.instances[0][0..4], &[5.0, 4.0, 15.0, 8.0]); + assert_eq!(&batch.instances[0][12..14], &[300.0, 100.0]); + } +} diff --git a/src/renderer/f1_glow_wgpu.wgsl b/src/renderer/f1_glow_wgpu.wgsl new file mode 100644 index 0000000..d358643 --- /dev/null +++ b/src/renderer/f1_glow_wgpu.wgsl @@ -0,0 +1,80 @@ +struct VertexInput { + @builtin(vertex_index) vertex_index: u32, + @location(0) endpoints: vec4, + @location(1) params: vec4, + @location(2) color: vec4, + @location(3) viewport: vec4, +}; + +struct VertexOutput { + @builtin(position) clip_position: vec4, + @location(0) point: vec2, + @location(1) start: vec2, + @location(2) end: vec2, + @location(3) params: vec4, + @location(4) color: vec3, +}; + +@vertex +fn vs_main(input: VertexInput) -> VertexOutput { + let corners = array, 6>( + vec2(0.0, 0.0), + vec2(1.0, 0.0), + vec2(0.0, 1.0), + vec2(0.0, 1.0), + vec2(1.0, 0.0), + vec2(1.0, 1.0), + ); + let start = input.endpoints.xy; + let end = input.endpoints.zw; + let radius = input.params.x; + let bounds_min = min(start, end) - vec2(radius); + let bounds_max = max(start, end) + vec2(radius); + let point = mix(bounds_min, bounds_max, corners[input.vertex_index]); + let viewport_size = input.viewport.xy; + + var output: VertexOutput; + output.clip_position = vec4( + point.x / viewport_size.x * 2.0 - 1.0, + 1.0 - point.y / viewport_size.y * 2.0, + 0.0, + 1.0, + ); + output.point = point; + output.start = start; + output.end = end; + output.params = input.params; + output.color = input.color.rgb; + return output; +} + +@fragment +fn fs_main(input: VertexOutput) -> @location(0) vec4 { + let radius = max(input.params.x, 0.5); + var normalized_distance: f32; + if input.params.z < 0.5 { + let axis = input.end - input.start; + let axis_length_squared = max(dot(axis, axis), 0.0001); + let along = dot(input.point - input.start, axis) / axis_length_squared; + let nearest = input.start + along * axis; + if along < 0.0 || along > 1.0 { + normalized_distance = 2.0; + } else { + normalized_distance = length(input.point - nearest) / radius; + } + } else if input.params.z < 1.5 { + let center = (input.start + input.end) * 0.5; + let half_size = abs(input.end - input.start) * 0.5; + let outside = max(abs(input.point - center) - half_size, vec2(0.0)); + normalized_distance = length(outside) / radius; + } else { + normalized_distance = length(input.point - input.start) / radius; + } + + let soft_cutoff = 1.0 - smoothstep(0.72, 1.0, normalized_distance); + let falloff = exp(-3.5 * normalized_distance * normalized_distance) * soft_cutoff; + let alpha = clamp(input.params.y * falloff, 0.0, 0.65); + + // Premultiplied output is required by the transparent window surface. + return vec4(input.color * alpha, alpha); +} diff --git a/src/renderer/f1_open_hud.rs b/src/renderer/f1_open_hud.rs index f49de19..d330d34 100644 --- a/src/renderer/f1_open_hud.rs +++ b/src/renderer/f1_open_hud.rs @@ -5,6 +5,7 @@ use crate::config::GraphSettings; use crate::core::{RevLights, TelemetryPoint}; +use crate::renderer::f1_glow_wgpu::{self, GlowBatch, GlowBatchBuilder}; use egui::{ Align2, Color32, FontData, FontDefinitions, FontFamily, FontId, Painter, Pos2, Rect, Shape, Stroke, Vec2, @@ -58,7 +59,10 @@ fn display_font(size: f32) -> FontId { #[derive(Default)] pub struct F1OpenHudCache { rect: Option, - shapes: Vec, + sharp_shapes: Vec, + fallback_emission_shapes: Vec, + glow_batch: GlowBatch, + generation: u64, brake_head: Option, throttle_head: Option, speed_head: Option, @@ -112,6 +116,7 @@ impl<'a> F1OpenHud<'a> { ui: &mut egui::Ui, size: Vec2, rebuild_visualization: bool, + additive_glow_available: bool, cache: &mut F1OpenHudCache, ) { let (rect, _) = ui.allocate_exact_size(size, egui::Sense::empty()); @@ -122,20 +127,29 @@ impl<'a> F1OpenHud<'a> { self.rebuild(rect, cache); } - painter.extend(cache.shapes.iter().cloned()); - self.draw_labels(&painter, rect, cache); + let use_additive = additive_glow_available && !cache.glow_batch.is_empty(); + if use_additive { + painter.add(f1_glow_wgpu::callback(rect, cache.glow_batch.clone())); + } else { + painter.extend(cache.fallback_emission_shapes.iter().cloned()); + } + painter.extend(cache.sharp_shapes.iter().cloned()); + self.draw_labels(&painter, rect, cache, use_additive); } fn rebuild(&self, rect: Rect, cache: &mut F1OpenHudCache) { cache.rect = Some(rect); - cache.shapes.clear(); + cache.sharp_shapes.clear(); + cache.fallback_emission_shapes.clear(); cache.brake_head = None; cache.throttle_head = None; cache.speed_head = None; cache.values = self.formatted_values(); + cache.generation = cache.generation.wrapping_add(1); + let mut glow = GlowBatchBuilder::new(rect); let layout = OpenHudLayout::new(rect); - self.build_grid(layout.trace_rect, &mut cache.shapes); + self.build_grid(layout.trace_rect, &mut cache.sharp_shapes); let capabilities = self.latest.map(|point| point.telemetry.capabilities); let mut brake_bands: [Vec; HISTORY_BANDS] = std::array::from_fn(|_| Vec::new()); @@ -200,38 +214,78 @@ impl<'a> F1OpenHud<'a> { } if capabilities.is_some_and(|value| value.brake) { - self.build_trace_layers(brake_bands, BRAKE_COLOR, true, &mut cache.shapes); + self.build_trace_layers( + brake_bands, + BRAKE_COLOR, + true, + true, + &mut cache.sharp_shapes, + &mut cache.fallback_emission_shapes, + &mut glow, + ); } if capabilities.is_some_and(|value| value.throttle) { - self.build_trace_layers(throttle_bands, THROTTLE_COLOR, true, &mut cache.shapes); + self.build_trace_layers( + throttle_bands, + THROTTLE_COLOR, + true, + true, + &mut cache.sharp_shapes, + &mut cache.fallback_emission_shapes, + &mut glow, + ); } if capabilities.is_some_and(|value| value.speed) { - self.build_trace_layers(speed_bands, SPEED_COLOR, false, &mut cache.shapes); + self.build_trace_layers( + speed_bands, + SPEED_COLOR, + false, + false, + &mut cache.sharp_shapes, + &mut cache.fallback_emission_shapes, + &mut glow, + ); + } + + for (head, color) in [ + (cache.brake_head, BRAKE_COLOR), + (cache.throttle_head, THROTTLE_COLOR), + ] { + if let Some(head) = head { + glow.radial(head, 15.0, color, self.opacity * 0.34); + } } self.build_pedal_meter( layout.brake_meter_rect, cache.values.brake_level, BRAKE_COLOR, - &mut cache.shapes, + &mut cache.sharp_shapes, + &mut cache.fallback_emission_shapes, + &mut glow, ); self.build_pedal_meter( layout.throttle_meter_rect, cache.values.throttle_level, THROTTLE_COLOR, - &mut cache.shapes, + &mut cache.sharp_shapes, + &mut cache.fallback_emission_shapes, + &mut glow, ); self.build_steering_arc( layout.steering_rect, cache.values.steering_level, - &mut cache.shapes, + &mut cache.sharp_shapes, ); self.build_rev_lights( layout.center_rect, cache.values.rev_lights, - &mut cache.shapes, + &mut cache.sharp_shapes, + &mut cache.fallback_emission_shapes, + &mut glow, ); + cache.glow_batch = glow.finish(cache.generation); } fn build_trace_layers( @@ -239,7 +293,10 @@ impl<'a> F1OpenHud<'a> { bands: [Vec; HISTORY_BANDS], color: Color32, primary: bool, - shapes: &mut Vec, + additive_emission: bool, + sharp_shapes: &mut Vec, + fallback_emission_shapes: &mut Vec, + glow: &mut GlowBatchBuilder, ) { for (band, points) in bands.into_iter().enumerate() { if points.len() < 2 { @@ -262,19 +319,29 @@ impl<'a> F1OpenHud<'a> { } else { (6.0, 0.035, 3.2, 0.09, 1.55, 0.62, 0.6, 0.58) }; - shapes.push(Shape::line( + let emission_shapes = if additive_emission { + &mut *fallback_emission_shapes + } else { + &mut *sharp_shapes + }; + emission_shapes.push(Shape::line( points.clone(), Stroke::new(outer_width, with_opacity(color, energy * outer_alpha)), )); - shapes.push(Shape::line( + emission_shapes.push(Shape::line( points.clone(), Stroke::new(inner_width, with_opacity(color, energy * inner_alpha)), )); - shapes.push(Shape::line( + if additive_emission { + for segment in points.windows(2) { + glow.ribbon_segment(segment[0], segment[1], 7.5, color, energy * 0.30); + } + } + sharp_shapes.push(Shape::line( points.clone(), Stroke::new(body_width, with_opacity(color, energy * body_alpha)), )); - shapes.push(Shape::line( + sharp_shapes.push(Shape::line( points, Stroke::new(core_width, with_opacity(hot, energy * core_alpha)), )); @@ -309,7 +376,9 @@ impl<'a> F1OpenHud<'a> { rect: Rect, level: Option, color: Color32, - shapes: &mut Vec, + sharp_shapes: &mut Vec, + fallback_emission_shapes: &mut Vec, + glow: &mut GlowBatchBuilder, ) { let Some(level) = level else { return; @@ -332,8 +401,10 @@ impl<'a> F1OpenHud<'a> { } else { SegmentState::Inactive }; - push_luminous_segment( - shapes, + push_additive_segment( + sharp_shapes, + fallback_emission_shapes, + glow, SegmentGeometry::Rect(segment, 1.2), color, state, @@ -401,7 +472,14 @@ impl<'a> F1OpenHud<'a> { } } - fn build_rev_lights(&self, rect: Rect, rev_lights: Option, shapes: &mut Vec) { + fn build_rev_lights( + &self, + rect: Rect, + rev_lights: Option, + sharp_shapes: &mut Vec, + fallback_emission_shapes: &mut Vec, + glow: &mut GlowBatchBuilder, + ) { let Some(rev_lights) = rev_lights else { return; }; @@ -424,8 +502,10 @@ impl<'a> F1OpenHud<'a> { } else { SegmentState::Inactive }; - push_luminous_segment( - shapes, + push_additive_segment( + sharp_shapes, + fallback_emission_shapes, + glow, SegmentGeometry::Rect(segment, 0.9), base, state, @@ -497,13 +577,34 @@ impl<'a> F1OpenHud<'a> { } } - fn draw_labels(&self, painter: &Painter, rect: Rect, cache: &F1OpenHudCache) { + fn draw_labels( + &self, + painter: &Painter, + rect: Rect, + cache: &F1OpenHudCache, + additive_glow_active: bool, + ) { let layout = OpenHudLayout::new(rect); let alpha = self.opacity; let label_font = display_font(12.5); let value_font = display_font(14.0); let label_top = layout.trace_rect.min.y + 13.0; let label_step = 24.0; + + for (head, color, strength, migrated) in [ + (cache.brake_head, BRAKE_COLOR, 1.0, true), + (cache.throttle_head, THROTTLE_COLOR, 1.0, true), + (cache.speed_head, SPEED_COLOR, 0.68, false), + ] { + if let Some(head) = head { + if additive_glow_active && migrated { + draw_head_core(painter, head, color, alpha * strength); + } else { + draw_head(painter, head, color, alpha * strength); + } + } + } + let channels = [ ("BRAKE", &cache.values.brake, BRAKE_COLOR), ("THROTTLE", &cache.values.throttle, THROTTLE_COLOR), @@ -536,16 +637,6 @@ impl<'a> F1OpenHud<'a> { ); } - for (head, color, strength) in [ - (cache.brake_head, BRAKE_COLOR, 1.0), - (cache.throttle_head, THROTTLE_COLOR, 1.0), - (cache.speed_head, SPEED_COLOR, 0.68), - ] { - if let Some(head) = head { - draw_head(painter, head, color, alpha * strength); - } - } - for division in 0..=GRID_DIVISIONS { let seconds = -self.settings.window_seconds + self.settings.window_seconds * division as f64 / GRID_DIVISIONS as f64; @@ -814,6 +905,74 @@ enum SegmentGeometry { Polygon(Vec), } +fn push_additive_segment( + sharp_shapes: &mut Vec, + fallback_emission_shapes: &mut Vec, + glow: &mut GlowBatchBuilder, + geometry: SegmentGeometry, + color: Color32, + state: SegmentState, + opacity: f32, +) { + let SegmentGeometry::Rect(rect, rounding) = geometry else { + unreachable!("the additive proof-of-concept only migrates rectangular segments"); + }; + let (body_alpha, inner_alpha, outer_alpha, spread) = match state { + SegmentState::Inactive => (0.035, 0.0, 0.0, 0.0), + SegmentState::Center => (0.34, 0.07, 0.025, 2.0), + SegmentState::Active => (0.88, 0.15, 0.045, 4.0), + SegmentState::Edge => (1.0, 0.22, 0.075, 5.0), + }; + let active = matches!(state, SegmentState::Active | SegmentState::Edge); + let hot = hot_face(color, 0.76); + + if outer_alpha > 0.0 { + fallback_emission_shapes.push(Shape::rect_filled( + rect.expand(spread), + rounding + 2.0, + with_opacity(color, opacity * outer_alpha), + )); + fallback_emission_shapes.push(Shape::rect_filled( + rect.expand(spread * 0.42), + rounding + 1.0, + with_opacity(color, opacity * inner_alpha), + )); + } + sharp_shapes.push(Shape::rect_filled( + rect, + rounding, + with_opacity(color, opacity * body_alpha), + )); + if active { + let intensity = if state == SegmentState::Edge { + 0.38 + } else { + 0.26 + }; + glow.rounded_rect(rect, 7.0, color, opacity * intensity); + + let highlight_y = rect.min.y + (rect.height() * 0.25).max(0.7); + sharp_shapes.push(Shape::line_segment( + [ + Pos2::new(rect.min.x + 1.0, highlight_y), + Pos2::new(rect.max.x - 1.0, highlight_y), + ], + Stroke::new( + 0.8_f32, + with_opacity( + hot, + opacity + * if state == SegmentState::Edge { + 0.92 + } else { + 0.68 + }, + ), + ), + )); + } +} + fn push_luminous_segment( shapes: &mut Vec, geometry: SegmentGeometry, @@ -940,6 +1099,10 @@ fn hot_face(color: Color32, white_mix: f32) -> Color32 { fn draw_head(painter: &Painter, center: Pos2, color: Color32, opacity: f32) { painter.circle_filled(center, 15.0, with_opacity(color, opacity * 0.045)); painter.circle_filled(center, 8.0, with_opacity(color, opacity * 0.15)); + draw_head_core(painter, center, color, opacity); +} + +fn draw_head_core(painter: &Painter, center: Pos2, color: Color32, opacity: f32) { painter.circle_filled(center, 4.2, with_opacity(color, opacity * 0.88)); painter.circle_filled(center, 1.8, with_opacity(hot_face(color, 0.84), opacity)); } diff --git a/src/renderer/mod.rs b/src/renderer/mod.rs index 58aa44b..5e1c8e2 100644 --- a/src/renderer/mod.rs +++ b/src/renderer/mod.rs @@ -1,6 +1,7 @@ //! egui renderer for telemetry visualization pub mod app; +pub mod f1_glow_wgpu; pub mod f1_open_hud; pub mod lap_comparison; pub mod phase_plot; From 61587723ab200abfed6a61774b633df6d0474044 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 18 Sep 2026 17:08:11 +0800 Subject: [PATCH 12/12] feat: refine Open HUD glow, color, and pedal density Introduce independent Brake/Throttle/Speed/RPM channel palettes, expand the pedal meter to 15 solid-color segments, restyle shift lights and the steering arc (37 fine ticks, 150 degrees), and add wgpu radial underglow behind the gear and speed readouts. History curve geometry remains the Phase 2A.4 additive ribbon; only the new palette is applied. --- src/renderer/f1_open_hud.rs | 382 +++++++++++++++++++++--------------- 1 file changed, 227 insertions(+), 155 deletions(-) diff --git a/src/renderer/f1_open_hud.rs b/src/renderer/f1_open_hud.rs index d330d34..3bf69f0 100644 --- a/src/renderer/f1_open_hud.rs +++ b/src/renderer/f1_open_hud.rs @@ -16,22 +16,62 @@ const DISPLAY_FONT_NAME: &str = "saira_semi_condensed_semibold"; const GRID_DIVISIONS: usize = 4; const HISTORY_BANDS: usize = 8; const RPM_SEGMENTS: usize = 15; -const PEDAL_SEGMENTS: usize = 12; -const STEERING_SEGMENTS_PER_SIDE: usize = 12; -const STEERING_ARC_DEGREES: f32 = 240.0; +const PEDAL_SEGMENTS: usize = 15; +const STEERING_SEGMENTS_PER_SIDE: usize = 18; +const STEERING_ARC_DEGREES: f32 = 150.0; const F1_SPEED_SCALE_MS: f32 = 100.0; const TEXT_PRIMARY: Color32 = Color32::from_rgb(244, 247, 250); const TEXT_SECONDARY: Color32 = Color32::from_rgb(166, 178, 193); const GRID_COLOR: Color32 = Color32::from_rgb(112, 132, 153); -const BRAKE_COLOR: Color32 = Color32::from_rgb(255, 91, 96); -const THROTTLE_COLOR: Color32 = Color32::from_rgb(74, 234, 158); -const SPEED_COLOR: Color32 = Color32::from_rgb(126, 196, 225); const STEERING_COLOR: Color32 = Color32::from_rgb(238, 245, 250); -const RPM_LOW: Color32 = Color32::from_rgb(74, 167, 235); -const RPM_HIGH: Color32 = Color32::from_rgb(255, 78, 99); const ABS_COLOR: Color32 = Color32::from_rgb(255, 194, 55); +#[derive(Clone, Copy)] +struct ChannelPalette { + emission: Color32, + body: Color32, + hot: Color32, + text_face: Color32, + text_glow: Color32, +} + +const BRAKE: ChannelPalette = ChannelPalette { + emission: Color32::from_rgb(255, 45, 52), + body: Color32::from_rgb(255, 67, 72), + hot: Color32::from_rgb(255, 184, 170), + text_face: Color32::from_rgb(255, 86, 89), + text_glow: Color32::from_rgb(255, 48, 56), +}; +const THROTTLE: ChannelPalette = ChannelPalette { + emission: Color32::from_rgb(0, 255, 85), + body: Color32::from_rgb(30, 255, 106), + hot: Color32::from_rgb(185, 255, 211), + text_face: Color32::from_rgb(66, 244, 130), + text_glow: Color32::from_rgb(0, 238, 99), +}; +const SPEED: ChannelPalette = ChannelPalette { + emission: Color32::from_rgb(0, 166, 255), + body: Color32::from_rgb(40, 190, 255), + hot: Color32::from_rgb(194, 241, 255), + text_face: Color32::from_rgb(95, 190, 242), + text_glow: Color32::from_rgb(0, 166, 255), +}; +const RPM_LOW: ChannelPalette = ChannelPalette { + emission: Color32::from_rgb(43, 151, 239), + body: Color32::from_rgb(67, 170, 241), + hot: Color32::from_rgb(171, 221, 255), + text_face: Color32::from_rgb(67, 170, 241), + text_glow: Color32::from_rgb(43, 151, 239), +}; +const RPM_HIGH: ChannelPalette = ChannelPalette { + emission: Color32::from_rgb(255, 45, 70), + body: Color32::from_rgb(255, 66, 87), + hot: Color32::from_rgb(255, 178, 180), + text_face: Color32::from_rgb(255, 66, 87), + text_glow: Color32::from_rgb(255, 45, 70), +}; + /// Install the single bundled display face without changing the font used by /// Classic or by settings widgets. pub fn install_font(ctx: &egui::Context) { @@ -216,7 +256,7 @@ impl<'a> F1OpenHud<'a> { if capabilities.is_some_and(|value| value.brake) { self.build_trace_layers( brake_bands, - BRAKE_COLOR, + BRAKE, true, true, &mut cache.sharp_shapes, @@ -227,7 +267,7 @@ impl<'a> F1OpenHud<'a> { if capabilities.is_some_and(|value| value.throttle) { self.build_trace_layers( throttle_bands, - THROTTLE_COLOR, + THROTTLE, true, true, &mut cache.sharp_shapes, @@ -238,7 +278,7 @@ impl<'a> F1OpenHud<'a> { if capabilities.is_some_and(|value| value.speed) { self.build_trace_layers( speed_bands, - SPEED_COLOR, + SPEED, false, false, &mut cache.sharp_shapes, @@ -247,30 +287,23 @@ impl<'a> F1OpenHud<'a> { ); } - for (head, color) in [ - (cache.brake_head, BRAKE_COLOR), - (cache.throttle_head, THROTTLE_COLOR), - ] { + for (head, palette) in [(cache.brake_head, BRAKE), (cache.throttle_head, THROTTLE)] { if let Some(head) = head { - glow.radial(head, 15.0, color, self.opacity * 0.34); + glow.radial(head, 15.0, palette.emission, self.opacity * 0.34); } } self.build_pedal_meter( layout.brake_meter_rect, cache.values.brake_level, - BRAKE_COLOR, + BRAKE, &mut cache.sharp_shapes, - &mut cache.fallback_emission_shapes, - &mut glow, ); self.build_pedal_meter( layout.throttle_meter_rect, cache.values.throttle_level, - THROTTLE_COLOR, + THROTTLE, &mut cache.sharp_shapes, - &mut cache.fallback_emission_shapes, - &mut glow, ); self.build_steering_arc( layout.steering_rect, @@ -285,13 +318,27 @@ impl<'a> F1OpenHud<'a> { &mut cache.fallback_emission_shapes, &mut glow, ); + // A low-energy radial emitter gives the speed readout a continuous halo + // without stacking offset glyph copies around its edges. + glow.radial( + gear_value_position(&layout), + 44.0, + STEERING_COLOR, + self.opacity * 0.11, + ); + glow.radial( + speed_value_position(&layout), + 34.0, + STEERING_COLOR, + self.opacity * 0.10, + ); cache.glow_batch = glow.finish(cache.generation); } fn build_trace_layers( &self, bands: [Vec; HISTORY_BANDS], - color: Color32, + palette: ChannelPalette, primary: bool, additive_emission: bool, sharp_shapes: &mut Vec, @@ -304,7 +351,6 @@ impl<'a> F1OpenHud<'a> { } let recency = (band + 1) as f32 / HISTORY_BANDS as f32; let energy = (0.08 + 0.92 * recency.powf(1.6)) * self.opacity; - let hot = hot_face(color, if primary { 0.76 } else { 0.48 }); let ( outer_width, outer_alpha, @@ -326,24 +372,36 @@ impl<'a> F1OpenHud<'a> { }; emission_shapes.push(Shape::line( points.clone(), - Stroke::new(outer_width, with_opacity(color, energy * outer_alpha)), + Stroke::new( + outer_width, + with_opacity(palette.emission, energy * outer_alpha), + ), )); emission_shapes.push(Shape::line( points.clone(), - Stroke::new(inner_width, with_opacity(color, energy * inner_alpha)), + Stroke::new( + inner_width, + with_opacity(palette.emission, energy * inner_alpha), + ), )); if additive_emission { for segment in points.windows(2) { - glow.ribbon_segment(segment[0], segment[1], 7.5, color, energy * 0.30); + glow.ribbon_segment( + segment[0], + segment[1], + 7.5, + palette.emission, + energy * 0.30, + ); } } sharp_shapes.push(Shape::line( points.clone(), - Stroke::new(body_width, with_opacity(color, energy * body_alpha)), + Stroke::new(body_width, with_opacity(palette.body, energy * body_alpha)), )); sharp_shapes.push(Shape::line( points, - Stroke::new(core_width, with_opacity(hot, energy * core_alpha)), + Stroke::new(core_width, with_opacity(palette.hot, energy * core_alpha)), )); } } @@ -375,17 +433,13 @@ impl<'a> F1OpenHud<'a> { &self, rect: Rect, level: Option, - color: Color32, + palette: ChannelPalette, sharp_shapes: &mut Vec, - fallback_emission_shapes: &mut Vec, - glow: &mut GlowBatchBuilder, ) { let Some(level) = level else { return; }; - let gap = (rect.height() * 0.018).clamp(1.5, 3.0); - let segment_height = - ((rect.height() - gap * (PEDAL_SEGMENTS - 1) as f32) / PEDAL_SEGMENTS as f32).max(2.0); + let (gap, segment_height) = pedal_segment_geometry(rect.height()); let active_count = (level.clamp(0.0, 1.0) * PEDAL_SEGMENTS as f32).ceil() as usize; for index in 0..PEDAL_SEGMENTS { @@ -394,22 +448,12 @@ impl<'a> F1OpenHud<'a> { Pos2::new(rect.min.x, y), Vec2::new(rect.width(), segment_height), ); - let state = if index + 1 == active_count && active_count > 0 { - SegmentState::Edge - } else if index < active_count { - SegmentState::Active + let color = if index < active_count { + with_opacity(palette.body, self.opacity * 0.92) } else { - SegmentState::Inactive + with_opacity(Color32::from_rgb(128, 132, 148), self.opacity * 0.28) }; - push_additive_segment( - sharp_shapes, - fallback_emission_shapes, - glow, - SegmentGeometry::Rect(segment, 1.2), - color, - state, - self.opacity, - ); + sharp_shapes.push(Shape::rect_filled(segment, 1.0, color)); } } @@ -420,14 +464,14 @@ impl<'a> F1OpenHud<'a> { let side = STEERING_SEGMENTS_PER_SIDE; let segment_count = side * 2 + 1; let center_index = side; - let center = Pos2::new(rect.center().x, rect.center().y + rect.height() * 0.12); - let outer_radius = (rect.width().min(rect.height()) * 0.48).max(8.0); - let inner_radius = (outer_radius - (outer_radius * 0.13).clamp(3.5, 6.0)).max(2.0); + let center = Pos2::new(rect.center().x, rect.min.y + rect.height() * 0.70); + let outer_radius = (rect.width() * 0.49).max(8.0); + let inner_radius = (outer_radius - (outer_radius * 0.16).clamp(7.0, 12.0)).max(2.0); let total_arc = STEERING_ARC_DEGREES.to_radians(); let center_angle = -std::f32::consts::FRAC_PI_2; let start = center_angle - total_arc * 0.5; let step = total_arc / segment_count as f32; - let gap = step * 0.28; + let gap = step * 0.60; let active_count = (steering.abs().clamp(0.0, 1.0) * side as f32).ceil() as usize; let edge_index = if steering < 0.0 && active_count > 0 { Some(center_index - active_count) @@ -483,7 +527,7 @@ impl<'a> F1OpenHud<'a> { let Some(rev_lights) = rev_lights else { return; }; - let gap = 3.0_f32; + let gap = 4.0_f32; let width = (rect.width() - gap * (RPM_SEGMENTS - 1) as f32) / RPM_SEGMENTS as f32; let y = rect.min.y + rect.height() * 0.16; @@ -492,9 +536,9 @@ impl<'a> F1OpenHud<'a> { .find(|index| rev_lights.bit_value & (1_u16 << index) != 0); for index in 0..RPM_SEGMENTS { let x = rect.min.x + index as f32 * (width + gap); - let segment = Rect::from_min_size(Pos2::new(x, y), Vec2::new(width.max(1.0), 4.5)); + let segment = Rect::from_min_size(Pos2::new(x, y), Vec2::new(width.max(1.0), 5.5)); let active = rev_lights.bit_value & (1_u16 << index) != 0; - let base = if index < 10 { RPM_LOW } else { RPM_HIGH }; + let palette = if index < 10 { RPM_LOW } else { RPM_HIGH }; let state = if active_edge == Some(index) { SegmentState::Edge } else if active { @@ -507,7 +551,9 @@ impl<'a> F1OpenHud<'a> { fallback_emission_shapes, glow, SegmentGeometry::Rect(segment, 0.9), - base, + palette.emission, + palette.body, + palette.hot, state, self.opacity, ); @@ -591,27 +637,27 @@ impl<'a> F1OpenHud<'a> { let label_top = layout.trace_rect.min.y + 13.0; let label_step = 24.0; - for (head, color, strength, migrated) in [ - (cache.brake_head, BRAKE_COLOR, 1.0, true), - (cache.throttle_head, THROTTLE_COLOR, 1.0, true), - (cache.speed_head, SPEED_COLOR, 0.68, false), + for (head, palette, strength, migrated) in [ + (cache.brake_head, BRAKE, 1.0, true), + (cache.throttle_head, THROTTLE, 1.0, true), + (cache.speed_head, SPEED, 0.68, false), ] { if let Some(head) = head { if additive_glow_active && migrated { - draw_head_core(painter, head, color, alpha * strength); + draw_head_core(painter, head, palette, alpha * strength); } else { - draw_head(painter, head, color, alpha * strength); + draw_head(painter, head, palette, alpha * strength); } } } let channels = [ - ("BRAKE", &cache.values.brake, BRAKE_COLOR), - ("THROTTLE", &cache.values.throttle, THROTTLE_COLOR), - ("SPEED", &cache.values.speed, SPEED_COLOR), + ("BRAKE", &cache.values.brake, BRAKE), + ("THROTTLE", &cache.values.throttle, THROTTLE), + ("SPEED", &cache.values.speed, SPEED), ]; - for (index, (label, value, color)) in channels.into_iter().enumerate() { + for (index, (label, value, palette)) in channels.into_iter().enumerate() { let y = label_top + index as f32 * label_step; luminous_text( painter, @@ -619,9 +665,9 @@ impl<'a> F1OpenHud<'a> { Align2::LEFT_CENTER, label, label_font.clone(), - color, - color, - alpha * if index == 2 { 0.76 } else { 0.94 }, + palette.text_face, + palette.text_glow, + alpha * 0.98, TextClass::Channel, ); luminous_text( @@ -630,9 +676,9 @@ impl<'a> F1OpenHud<'a> { Align2::RIGHT_CENTER, value, value_font.clone(), - color, - color, - alpha * if index == 2 { 0.78 } else { 1.0 }, + palette.text_face, + palette.text_glow, + alpha, TextClass::Channel, ); } @@ -661,18 +707,13 @@ impl<'a> F1OpenHud<'a> { ); } - for (meter, value, label, color) in [ - ( - layout.brake_meter_rect, - &cache.values.brake, - "BRAKE", - BRAKE_COLOR, - ), + for (meter, value, label, palette) in [ + (layout.brake_meter_rect, &cache.values.brake, "BRAKE", BRAKE), ( layout.throttle_meter_rect, &cache.values.throttle, "THROTTLE", - THROTTLE_COLOR, + THROTTLE, ), ] { luminous_text( @@ -681,8 +722,8 @@ impl<'a> F1OpenHud<'a> { Align2::CENTER_BOTTOM, value, display_font(13.0), - color, - color, + palette.text_face, + palette.text_glow, alpha, TextClass::Channel, ); @@ -692,8 +733,8 @@ impl<'a> F1OpenHud<'a> { Align2::CENTER_TOP, label, display_font(8.5), - color, - color, + palette.text_face, + palette.text_glow, alpha * 0.78, TextClass::Channel, ); @@ -701,10 +742,7 @@ impl<'a> F1OpenHud<'a> { luminous_text( painter, - Pos2::new( - layout.steering_rect.center().x, - layout.steering_rect.center().y + layout.steering_rect.height() * 0.14, - ), + steering_value_position(&layout), Align2::CENTER_CENTER, &cache.values.steering, display_font(15.0), @@ -715,11 +753,8 @@ impl<'a> F1OpenHud<'a> { ); luminous_text( painter, - Pos2::new( - layout.steering_rect.center().x, - layout.steering_rect.max.y - 2.0, - ), - Align2::CENTER_BOTTOM, + steering_label_position(&layout), + Align2::CENTER_CENTER, "STEERING", display_font(8.0), TEXT_SECONDARY, @@ -745,31 +780,25 @@ impl<'a> F1OpenHud<'a> { ); luminous_text( painter, - Pos2::new( - center.x, - layout.center_rect.min.y + layout.center_rect.height() * 0.49, - ), + gear_value_position(&layout), Align2::CENTER_CENTER, &cache.values.gear, display_font((layout.center_rect.height() * 0.29).clamp(50.0, 94.0)), TEXT_PRIMARY, STEERING_COLOR, alpha, - TextClass::Major, + TextClass::Secondary, ); luminous_text( painter, - Pos2::new( - center.x, - layout.center_rect.min.y + layout.center_rect.height() * 0.72, - ), + speed_value_position(&layout), Align2::CENTER_CENTER, &cache.values.speed, display_font((layout.center_rect.height() * 0.14).clamp(27.0, 48.0)), TEXT_PRIMARY, STEERING_COLOR, alpha * 0.94, - TextClass::Major, + TextClass::Secondary, ); luminous_text( painter, @@ -807,8 +836,8 @@ impl<'a> F1OpenHud<'a> { Align2::CENTER_CENTER, "TC", display_font(10.5), - THROTTLE_COLOR, - THROTTLE_COLOR, + THROTTLE.text_face, + THROTTLE.text_glow, alpha, TextClass::Channel, ); @@ -883,11 +912,47 @@ impl OpenHudLayout { } } +fn speed_value_position(layout: &OpenHudLayout) -> Pos2 { + Pos2::new( + layout.center_rect.center().x, + layout.center_rect.min.y + layout.center_rect.height() * 0.72, + ) +} + +fn gear_value_position(layout: &OpenHudLayout) -> Pos2 { + Pos2::new( + layout.center_rect.center().x, + layout.center_rect.min.y + layout.center_rect.height() * 0.49, + ) +} + +fn steering_value_position(layout: &OpenHudLayout) -> Pos2 { + Pos2::new( + layout.steering_rect.center().x, + layout.steering_rect.min.y + layout.steering_rect.height() * 0.70, + ) +} + +fn steering_label_position(layout: &OpenHudLayout) -> Pos2 { + let value = steering_value_position(layout); + Pos2::new( + value.x, + (value.y + 23.0).min(layout.steering_rect.max.y - 4.0), + ) +} + fn pedal_y(rect: Rect, value: f32) -> f32 { let pad = rect.height() * 0.06; rect.max.y - pad - value.clamp(0.0, 1.0) * (rect.height() - 2.0 * pad) } +fn pedal_segment_geometry(total_height: f32) -> (f32, f32) { + let gap = (total_height * 0.030).clamp(2.0, 4.0); + let segment_height = + ((total_height - gap * (PEDAL_SEGMENTS - 1) as f32) / PEDAL_SEGMENTS as f32).max(1.0); + (gap, segment_height) +} + fn speed_y(rect: Rect, speed_ms: f32) -> f32 { pedal_y(rect, speed_ms / F1_SPEED_SCALE_MS) } @@ -910,7 +975,9 @@ fn push_additive_segment( fallback_emission_shapes: &mut Vec, glow: &mut GlowBatchBuilder, geometry: SegmentGeometry, - color: Color32, + emission_color: Color32, + body_color: Color32, + _hot_color: Color32, state: SegmentState, opacity: f32, ) { @@ -918,30 +985,35 @@ fn push_additive_segment( unreachable!("the additive proof-of-concept only migrates rectangular segments"); }; let (body_alpha, inner_alpha, outer_alpha, spread) = match state { - SegmentState::Inactive => (0.035, 0.0, 0.0, 0.0), - SegmentState::Center => (0.34, 0.07, 0.025, 2.0), + SegmentState::Inactive => (0.30, 0.0, 0.0, 0.0), + SegmentState::Center => (0.95, 0.10, 0.035, 2.0), SegmentState::Active => (0.88, 0.15, 0.045, 4.0), SegmentState::Edge => (1.0, 0.22, 0.075, 5.0), }; let active = matches!(state, SegmentState::Active | SegmentState::Edge); - let hot = hot_face(color, 0.76); - if outer_alpha > 0.0 { fallback_emission_shapes.push(Shape::rect_filled( rect.expand(spread), rounding + 2.0, - with_opacity(color, opacity * outer_alpha), + with_opacity(emission_color, opacity * outer_alpha), )); fallback_emission_shapes.push(Shape::rect_filled( rect.expand(spread * 0.42), rounding + 1.0, - with_opacity(color, opacity * inner_alpha), + with_opacity(emission_color, opacity * inner_alpha), )); } sharp_shapes.push(Shape::rect_filled( rect, rounding, - with_opacity(color, opacity * body_alpha), + with_opacity( + if active { + body_color + } else { + Color32::from_rgb(128, 132, 148) + }, + opacity * body_alpha, + ), )); if active { let intensity = if state == SegmentState::Edge { @@ -949,26 +1021,13 @@ fn push_additive_segment( } else { 0.26 }; - glow.rounded_rect(rect, 7.0, color, opacity * intensity); - - let highlight_y = rect.min.y + (rect.height() * 0.25).max(0.7); - sharp_shapes.push(Shape::line_segment( - [ - Pos2::new(rect.min.x + 1.0, highlight_y), - Pos2::new(rect.max.x - 1.0, highlight_y), - ], - Stroke::new( - 0.8_f32, - with_opacity( - hot, - opacity - * if state == SegmentState::Edge { - 0.92 - } else { - 0.68 - }, - ), - ), + glow.rounded_rect(rect, 3.5, emission_color, opacity * intensity * 1.5); + + // Uniform LED face without a directional bevel highlight. + sharp_shapes.push(Shape::rect_filled( + rect.shrink(0.15), + rounding, + with_opacity(hot_face(body_color, 0.46), opacity * 0.92), )); } } @@ -981,8 +1040,8 @@ fn push_luminous_segment( opacity: f32, ) { let (body_alpha, inner_alpha, outer_alpha, spread) = match state { - SegmentState::Inactive => (0.035, 0.0, 0.0, 0.0), - SegmentState::Center => (0.34, 0.07, 0.025, 2.0), + SegmentState::Inactive => (0.10, 0.0, 0.0, 0.0), + SegmentState::Center => (0.95, 0.10, 0.035, 2.0), SegmentState::Active => (0.88, 0.15, 0.045, 4.0), SegmentState::Edge => (1.0, 0.22, 0.075, 5.0), }; @@ -1096,21 +1155,24 @@ fn hot_face(color: Color32, white_mix: f32) -> Color32 { Color32::from_rgba_unmultiplied(heat(r) as u8, heat(g) as u8, heat(b) as u8, a) } -fn draw_head(painter: &Painter, center: Pos2, color: Color32, opacity: f32) { - painter.circle_filled(center, 15.0, with_opacity(color, opacity * 0.045)); - painter.circle_filled(center, 8.0, with_opacity(color, opacity * 0.15)); - draw_head_core(painter, center, color, opacity); +fn draw_head(painter: &Painter, center: Pos2, palette: ChannelPalette, opacity: f32) { + painter.circle_filled( + center, + 15.0, + with_opacity(palette.emission, opacity * 0.045), + ); + painter.circle_filled(center, 8.0, with_opacity(palette.emission, opacity * 0.15)); + draw_head_core(painter, center, palette, opacity); } -fn draw_head_core(painter: &Painter, center: Pos2, color: Color32, opacity: f32) { - painter.circle_filled(center, 4.2, with_opacity(color, opacity * 0.88)); - painter.circle_filled(center, 1.8, with_opacity(hot_face(color, 0.84), opacity)); +fn draw_head_core(painter: &Painter, center: Pos2, palette: ChannelPalette, opacity: f32) { + painter.circle_filled(center, 4.2, with_opacity(palette.body, opacity * 0.88)); + painter.circle_filled(center, 1.8, with_opacity(palette.hot, opacity)); } #[derive(Clone, Copy)] enum TextClass { Channel, - Major, Secondary, } @@ -1132,23 +1194,16 @@ fn luminous_text( anchor, &text, font.clone(), - with_opacity(Color32::from_black_alpha(150), opacity * 0.42), + with_opacity(Color32::from_black_alpha(145), opacity * 0.30), ); let (outer_spread, outer_alpha, inner_spread, inner_alpha, face_mix) = match class { TextClass::Channel => ( (font.size * 0.20).clamp(2.4, 4.0), - 0.065, - (font.size * 0.09).clamp(1.0, 2.0), - 0.19, - 0.64, - ), - TextClass::Major => ( - (font.size * 0.055).clamp(2.0, 3.8), 0.05, - (font.size * 0.024).clamp(0.9, 1.6), - 0.09, - 0.18, + (font.size * 0.09).clamp(1.0, 2.0), + 0.14, + 0.16, ), TextClass::Secondary => (0.0, 0.0, 0.0, 0.0, 0.0), }; @@ -1190,3 +1245,20 @@ fn luminous_text( with_opacity(hot_face(core_color, face_mix), opacity), ); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fifteen_pedal_segments_preserve_meter_height() { + let total_height = 180.0; + let (gap, segment_height) = pedal_segment_geometry(total_height); + let reconstructed = + segment_height * PEDAL_SEGMENTS as f32 + gap * (PEDAL_SEGMENTS - 1) as f32; + + assert_eq!(PEDAL_SEGMENTS, 15); + assert!((reconstructed - total_height).abs() < 0.001); + assert!((2.0..=4.0).contains(&gap)); + } +}