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/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 0000000..52d779f Binary files /dev/null and b/assets/fonts/SairaSemiCondensed-SemiBold.ttf differ diff --git a/docs/F1_UDP.md b/docs/F1_UDP.md new file mode 100644 index 0000000..57a5c4f --- /dev/null +++ b/docs/F1_UDP.md @@ -0,0 +1,49 @@ +# 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. + +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 + +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. + +### 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/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 f044554..31e02cf 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -16,11 +16,26 @@ 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. #[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, @@ -54,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 } @@ -157,8 +189,11 @@ 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 { + layout_mode: UiLayoutMode::Classic, window_seconds: 10.0, show_grid: true, show_legend: true, @@ -270,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] @@ -332,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] @@ -359,4 +396,30 @@ mod tests { egui::Color32::WHITE ); } + + #[test] + 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")); + } + + #[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/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..fe0b900 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, RevLights, SourceMetadata, TelemetryCapabilities, TelemetryData, + TelemetryPoint, VehicleTelemetry, +}; diff --git a/src/core/model.rs b/src/core/model.rs index 1408adb..9c44a42 100644 --- a/src/core/model.rs +++ b/src/core/model.rs @@ -4,6 +4,59 @@ 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, +} + +/// 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)] +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 +66,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,20 +79,33 @@ 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) 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 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 +134,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 +164,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 +199,37 @@ 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); + } + + #[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/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..7d4ed09 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,29 @@ 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 + rev_lights: None, 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 +161,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..5735ca6 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,32 @@ 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, + rev_lights: None, 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 +122,7 @@ impl GamePlugin for AccPlugin { timestamp, vehicle, session: None, + source: Default::default(), })) } diff --git a/src/plugins/f1/diagnostics.rs b/src/plugins/f1/diagnostics.rs new file mode 100644 index 0000000..897babe --- /dev/null +++ b/src/plugins/f1/diagnostics.rs @@ -0,0 +1,333 @@ +use std::collections::HashSet; +use std::net::SocketAddr; +use std::time::{Duration, Instant}; + +use anyhow::Error; +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(30); + +#[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, + last_summary_normalized: 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, + last_summary_normalized: 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) + { + debug!( + 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 { + debug!( + 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 + { + 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; + } + } +} + +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/f1_2025.rs b/src/plugins/f1/f1_2025.rs new file mode 100644 index 0000000..1efd14f --- /dev/null +++ b/src/plugins/f1/f1_2025.rs @@ -0,0 +1,178 @@ +//! 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_YEARS: &[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_YEARS, 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)?, + // 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)?, + }, + ) + } + _ => 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[base + 19] = 73; + bytes[base + 20..base + 22].copy_from_slice(&0x03ff_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_YEARS[0]; + 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.vehicle.rev_lights.unwrap().percent, 73); + assert_eq!(data.vehicle.rev_lights.unwrap().bit_value, 0x03ff); + 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..c86c3c9 --- /dev/null +++ b/src/plugins/f1/f1_2026.rs @@ -0,0 +1,218 @@ +//! 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; +// 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; +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_YEARS, 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)?, + // 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)?, + }, + ) + } + _ => 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[base + 19] = 73; + bytes[base + 20..base + 22].copy_from_slice(&0x03ff_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] = 26; + 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.vehicle.rev_lights.unwrap().percent, 73); + assert_eq!(data.vehicle.rev_lights.unwrap().bit_value, 0x03ff); + 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] = 24; + 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 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); + 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..b1eef4d --- /dev/null +++ b/src/plugins/f1/mod.rs @@ -0,0 +1,161 @@ +//! EA SPORTS F1 UDP provider. +//! +//! 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; + +use std::io::ErrorKind; +use std::net::UdpSocket; + +use anyhow::{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 => Err(protocol::reject( + protocol::RejectReason::PacketFormat, + format!("unsupported F1 UDP format {format}"), + )), + } + } +} + +pub struct F1Plugin { + bind_address: String, + port: u16, + socket: Option, + router: ProtocolRouter, + diagnostics: diagnostics::Diagnostics, +} + +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(), + diagnostics: diagnostics::Diagnostics::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"); + info!( + distinct_header_limit = 12, + summary_interval_seconds = 30, + "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 { + 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)) => { + 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()), + } + } + 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..d0238ef --- /dev/null +++ b/src/plugins/f1/protocol.rs @@ -0,0 +1,362 @@ +use std::error::Error; +use std::fmt; + +use anyhow::Result; + +use crate::core::{ + DriverAssistStatus, RevLights, SourceMetadata, TelemetryCapabilities, TelemetryData, + VehicleTelemetry, +}; + +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, + 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, + pub rev_lights_percent: u8, + pub rev_lights_bit_value: 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 { + return Err(reject( + RejectReason::InvalidValue, + format!("invalid F1 assist status: TC={tc}, ABS={abs}"), + )); + } + 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() { + 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 => { + 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, + 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), + 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 { + 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. +/// Variant decoders still supply and validate their own identity and car limit. +pub fn parse_header( + bytes: &[u8], + expected_format: u16, + accepted_game_years: &[u8], + max_cars: usize, +) -> Result
{ + if bytes.len() < HEADER_LEN { + 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 { + return Err(reject( + RejectReason::PacketFormat, + format!("wrong F1 UDP format {format}; expected {expected_format}"), + )); + } + let game_year = bytes[2]; + if game_year != 0 && !accepted_game_years.contains(&game_year) { + return Err(reject( + RejectReason::GameYear, + format!("wrong F1 game year {game_year}; expected one of {accepted_game_years:?}"), + )); + } + let player_car_index = bytes[27] as usize; + if player_car_index >= max_cars { + 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], + 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 { + return Err(reject( + RejectReason::PacketVersion, + format!("unsupported F1 packet version {version}; expected 1"), + )); + } + if bytes.len() != expected_len { + return Err(reject( + RejectReason::PacketLength, + format!( + "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)) + .ok_or_else(|| reject(RejectReason::PlayerIndex, "F1 player data offset overflow")) +} + +pub fn read_u8(bytes: &[u8], offset: usize) -> Result { + bytes + .get(offset) + .copied() + .ok_or_else(|| reject(RejectReason::PayloadField, "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 { + 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 { + 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 { + 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 { + 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]])) +} diff --git a/src/plugins/iracing/iracing_plugin.rs b/src/plugins/iracing/iracing_plugin.rs index 31c9b4b..901e268 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,31 @@ 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, + rev_lights: None, 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 +165,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..50e80ce 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,32 @@ 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, + rev_lights: None, 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 +191,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..04e8637 100644 --- a/src/renderer/app.rs +++ b/src/renderer/app.rs @@ -1,10 +1,12 @@ //! Main application -use crate::config::{AppSettings, ParsedColors}; -use crate::core::{DataCollector, TelemetryBuffer}; +use crate::config::{AppSettings, ParsedColors, UiLayoutMode}; +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}; +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. @@ -18,11 +20,50 @@ 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); +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 ───────────────────────────────────────────────────────── enum PollerCmd { - ActivatePlugin(String), + ActivatePlugin(String, ProviderConfig), } /// Owns the background polling thread. Dropping this stops the thread. @@ -59,12 +100,26 @@ 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, + 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>, + f1_open_hud_cache: Arc>, + f1_glow_available: bool, } 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, window_fill: egui::Color32::TRANSPARENT, @@ -74,9 +129,13 @@ 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); + 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( @@ -92,7 +151,19 @@ 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: 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())), + f1_open_hud_cache: Arc::new(Mutex::new(Default::default())), + f1_glow_available, } } @@ -100,20 +171,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 +206,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,14 +233,13 @@ impl eframe::App for SimTraceApp { } fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - // 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() - }); - + 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() + .min(0.25); + self.last_update_at = now; // 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(); @@ -183,30 +260,46 @@ 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); - } + // 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) + }); + 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 reference_lap = self.lap_store.reference_lap.clone(); - - ctx.send_viewport_cmd(egui::ViewportCommand::MinInnerSize(egui::vec2( - MIN_WIDTH, MIN_HEIGHT, - ))); + 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() .frame(egui::Frame::NONE) .show(ctx, |ui| { @@ -215,6 +308,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| { @@ -229,11 +324,10 @@ 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; + 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 ─────────────────── @@ -276,7 +370,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), ); @@ -418,18 +516,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). @@ -440,17 +540,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, - buffer.as_ref(), - self.current_steering, - self.max_steering_angle, - a, - cap_r, - reference_lap.as_deref(), - ); + if f1_open_hud { + crate::renderer::F1OpenHud::new( + &visualization.points, + visualization.latest.as_ref(), + &self.settings.graph, + self.settings.overlay.opacity, + self.max_steering_angle, + ) + .show( + &mut content_ui, + content_rect.shrink(2.0).size(), + visualization_due, + self.f1_glow_available, + &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( @@ -523,8 +642,11 @@ impl eframe::App for SimTraceApp { &mut self.settings, &mut self.running, &mut self.save_toast, - buffer.as_ref(), - &mut self.lap_store, + 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. @@ -537,11 +659,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(); @@ -560,13 +683,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); } @@ -597,14 +725,13 @@ 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 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; @@ -625,8 +752,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, @@ -655,6 +782,44 @@ impl eframe::App for SimTraceApp { let _ = self.settings.save_to_config_path(); } } + 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 { + 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, + 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(), + 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; + } } } @@ -665,27 +830,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 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 tc_on = latest - .as_ref() - .map(|p| p.telemetry.tc_active) - .unwrap_or(false); - 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 latest = visualization.latest.as_ref(); + let capabilities = latest + .map(|point| point.telemetry.capabilities) + .unwrap_or_default(); + 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; @@ -709,16 +876,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()); @@ -743,15 +913,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 +931,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 +963,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 +1006,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,18 +1053,19 @@ 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 { - let current_track_pos = latest - .as_ref() - .map(|p| p.telemetry.track_position) - .unwrap_or(0.0); + if settings.graph.show_track_strip && capabilities.track_position { + 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), @@ -897,12 +1073,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(), ); } @@ -923,14 +1101,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, @@ -943,6 +1122,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. @@ -970,7 +1158,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), @@ -979,14 +1167,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), @@ -995,23 +1183,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(); @@ -1027,13 +1210,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 @@ -1043,23 +1226,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 ───────────────────────────────────────────────────────────── @@ -1069,18 +1253,15 @@ fn draw_config( settings: &mut AppSettings, running: &mut bool, save_toast: &mut Option, - buffer: Option<&Arc>, - lap_store: &mut crate::core::LapStore, + 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 { @@ -1130,7 +1311,6 @@ fn draw_config( .monospace() .color(egui::Color32::from_rgba_unmultiplied(100, 220, 100, ta)), ); - ui.ctx().request_repaint(); } else { *save_toast = None; } @@ -1146,9 +1326,64 @@ 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.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| { @@ -1288,17 +1523,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_snapshot.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 +1546,43 @@ 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 is_f1_plugin(plugin: &str) -> bool { + matches!(plugin, "f1" | "f1_25") +} + +#[cfg(test)] +mod scheduling_tests { + use super::*; + + #[test] + 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))); + } +} + // ── Helpers ─────────────────────────────────────────────────────────────────── fn with_alpha(c: egui::Color32, a: u8) -> egui::Color32 { 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 new file mode 100644 index 0000000..3bf69f0 --- /dev/null +++ b/src/renderer/f1_open_hud.rs @@ -0,0 +1,1264 @@ +//! 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; +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, +}; +use std::sync::Arc; + +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 = 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 STEERING_COLOR: Color32 = Color32::from_rgb(238, 245, 250); +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) { + 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); +} + +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 { + rect: Option, + sharp_shapes: Vec, + fallback_emission_shapes: Vec, + glow_batch: GlowBatch, + generation: u64, + brake_head: Option, + throttle_head: Option, + speed_head: Option, + values: OpenHudValues, +} + +#[derive(Default)] +struct OpenHudValues { + brake: String, + throttle: String, + steering: String, + rpm: String, + gear: String, + speed: String, + speed_unit: &'static str, + rev_lights: Option, + brake_level: Option, + throttle_level: Option, + steering_level: Option, + abs_active: bool, + tc_active: bool, +} + +pub struct F1OpenHud<'a> { + points: &'a [TelemetryPoint], + latest: Option<&'a TelemetryPoint>, + settings: &'a GraphSettings, + opacity: f32, + max_steering_angle: f32, +} + +impl<'a> F1OpenHud<'a> { + pub fn new( + points: &'a [TelemetryPoint], + latest: Option<&'a TelemetryPoint>, + settings: &'a GraphSettings, + opacity: f32, + max_steering_angle: f32, + ) -> Self { + Self { + points, + latest, + settings, + opacity, + max_steering_angle, + } + } + + pub fn show( + &self, + 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()); + let painter = ui.painter().with_clip_rect(rect); + let rect_changed = cache.rect != Some(rect); + + if rebuild_visualization || rect_changed { + self.rebuild(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.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.sharp_shapes); + + 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 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; + + // 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(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, speed_y(layout.trace_rect, point.telemetry.speed)), + ]; + let band = ((recency * HISTORY_BANDS as f32) as usize).min(HISTORY_BANDS - 1); + + 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]); + speed_bands[band].push(previous_positions[2]); + } + } + brake_bands[band].push(positions[0]); + throttle_bands[band].push(positions[1]); + speed_bands[band].push(positions[2]); + previous = Some((band, positions)); + } + + 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, 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]); + speed_bands[HISTORY_BANDS - 1].push(last[2]); + } + } + brake_bands[HISTORY_BANDS - 1].push(heads[0]); + throttle_bands[HISTORY_BANDS - 1].push(heads[1]); + speed_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.speed) { + cache.speed_head = Some(heads[2]); + } + } + + if capabilities.is_some_and(|value| value.brake) { + self.build_trace_layers( + brake_bands, + BRAKE, + 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, + 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, + false, + false, + &mut cache.sharp_shapes, + &mut cache.fallback_emission_shapes, + &mut glow, + ); + } + + for (head, palette) in [(cache.brake_head, BRAKE), (cache.throttle_head, THROTTLE)] { + if let Some(head) = head { + glow.radial(head, 15.0, palette.emission, self.opacity * 0.34); + } + } + + self.build_pedal_meter( + layout.brake_meter_rect, + cache.values.brake_level, + BRAKE, + &mut cache.sharp_shapes, + ); + self.build_pedal_meter( + layout.throttle_meter_rect, + cache.values.throttle_level, + THROTTLE, + &mut cache.sharp_shapes, + ); + self.build_steering_arc( + layout.steering_rect, + cache.values.steering_level, + &mut cache.sharp_shapes, + ); + + self.build_rev_lights( + layout.center_rect, + cache.values.rev_lights, + &mut cache.sharp_shapes, + &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], + palette: ChannelPalette, + primary: bool, + 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 { + continue; + } + let recency = (band + 1) as f32 / HISTORY_BANDS as f32; + let energy = (0.08 + 0.92 * recency.powf(1.6)) * self.opacity; + 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) + }; + 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(palette.emission, energy * outer_alpha), + ), + )); + emission_shapes.push(Shape::line( + points.clone(), + 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, + palette.emission, + energy * 0.30, + ); + } + } + sharp_shapes.push(Shape::line( + points.clone(), + Stroke::new(body_width, with_opacity(palette.body, energy * body_alpha)), + )); + sharp_shapes.push(Shape::line( + points, + Stroke::new(core_width, with_opacity(palette.hot, energy * core_alpha)), + )); + } + } + + 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_pedal_meter( + &self, + rect: Rect, + level: Option, + palette: ChannelPalette, + sharp_shapes: &mut Vec, + ) { + let Some(level) = level else { + return; + }; + 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 { + 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 color = if index < active_count { + with_opacity(palette.body, self.opacity * 0.92) + } else { + with_opacity(Color32::from_rgb(128, 132, 148), self.opacity * 0.28) + }; + sharp_shapes.push(Shape::rect_filled(segment, 1.0, color)); + } + } + + 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.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.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) + } 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, + sharp_shapes: &mut Vec, + fallback_emission_shapes: &mut Vec, + glow: &mut GlowBatchBuilder, + ) { + let Some(rev_lights) = rev_lights else { + return; + }; + 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; + + 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), 5.5)); + let active = rev_lights.bit_value & (1_u16 << index) != 0; + let palette = if index < 10 { RPM_LOW } else { RPM_HIGH }; + let state = if active_edge == Some(index) { + SegmentState::Edge + } else if active { + SegmentState::Active + } else { + SegmentState::Inactive + }; + push_additive_segment( + sharp_shapes, + fallback_emission_shapes, + glow, + SegmentGeometry::Rect(segment, 0.9), + palette.emission, + palette.body, + palette.hot, + state, + self.opacity, + ); + } + } + + 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 + }; + let steering_degrees = telemetry.effective_steering_degrees(self.max_steering_angle); + 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, || { + 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), + speed: supported(capabilities.speed, || format!("{speed:.0}")), + speed_unit: if self.settings.speed_mph { + "MPH" + } else { + "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, + } + } + + 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, 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, palette, alpha * strength); + } else { + draw_head(painter, head, palette, alpha * strength); + } + } + } + + let channels = [ + ("BRAKE", &cache.values.brake, BRAKE), + ("THROTTLE", &cache.values.throttle, THROTTLE), + ("SPEED", &cache.values.speed, SPEED), + ]; + + for (index, (label, value, palette)) in channels.into_iter().enumerate() { + let y = label_top + index as f32 * label_step; + luminous_text( + painter, + Pos2::new(layout.left_rect.min.x, y), + Align2::LEFT_CENTER, + label, + label_font.clone(), + palette.text_face, + palette.text_glow, + alpha * 0.98, + TextClass::Channel, + ); + luminous_text( + painter, + Pos2::new(layout.left_rect.max.x, y), + Align2::RIGHT_CENTER, + value, + value_font.clone(), + palette.text_face, + palette.text_glow, + alpha, + TextClass::Channel, + ); + } + + 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, + ); + luminous_text( + painter, + 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::Proportional), + TEXT_SECONDARY, + TEXT_SECONDARY, + alpha * 0.46, + TextClass::Secondary, + ); + } + + for (meter, value, label, palette) in [ + (layout.brake_meter_rect, &cache.values.brake, "BRAKE", BRAKE), + ( + layout.throttle_meter_rect, + &cache.values.throttle, + "THROTTLE", + THROTTLE, + ), + ] { + luminous_text( + painter, + Pos2::new(meter.center().x, meter.min.y - 9.0), + Align2::CENTER_BOTTOM, + value, + display_font(13.0), + palette.text_face, + palette.text_glow, + alpha, + TextClass::Channel, + ); + luminous_text( + painter, + Pos2::new(meter.center().x, meter.max.y + 8.0), + Align2::CENTER_TOP, + label, + display_font(8.5), + palette.text_face, + palette.text_glow, + alpha * 0.78, + TextClass::Channel, + ); + } + + luminous_text( + painter, + steering_value_position(&layout), + Align2::CENTER_CENTER, + &cache.values.steering, + display_font(15.0), + TEXT_PRIMARY, + STEERING_COLOR, + alpha, + TextClass::Channel, + ); + luminous_text( + painter, + steering_label_position(&layout), + Align2::CENTER_CENTER, + "STEERING", + display_font(8.0), + TEXT_SECONDARY, + STEERING_COLOR, + alpha * 0.58, + TextClass::Secondary, + ); + + let center = layout.center_rect.center(); + luminous_text( + painter, + Pos2::new( + center.x, + layout.center_rect.min.y + layout.center_rect.height() * 0.27, + ), + Align2::CENTER_CENTER, + format!("{} RPM", cache.values.rpm), + display_font((layout.center_rect.width() * 0.075).clamp(12.0, 18.0)), + TEXT_SECONDARY, + TEXT_PRIMARY, + alpha * 0.88, + TextClass::Secondary, + ); + luminous_text( + painter, + 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::Secondary, + ); + luminous_text( + painter, + 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::Secondary, + ); + luminous_text( + painter, + Pos2::new( + center.x, + layout.center_rect.min.y + layout.center_rect.height() * 0.82, + ), + Align2::CENTER_CENTER, + cache.values.speed_unit, + display_font(10.0), + TEXT_SECONDARY, + TEXT_SECONDARY, + alpha * 0.78, + TextClass::Secondary, + ); + + let state_y = layout.center_rect.max.y - 19.0; + if cache.values.abs_active { + luminous_text( + painter, + Pos2::new(center.x - 28.0, state_y), + Align2::CENTER_CENTER, + "ABS", + display_font(10.5), + ABS_COLOR, + ABS_COLOR, + alpha, + TextClass::Channel, + ); + } + if cache.values.tc_active { + luminous_text( + painter, + Pos2::new(center.x + 28.0, state_y), + Align2::CENTER_CENTER, + "TC", + display_font(10.5), + THROTTLE.text_face, + THROTTLE.text_glow, + alpha, + TextClass::Channel, + ); + } + } +} + +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.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); + let trace_rect = Rect::from_min_max( + Pos2::new( + left_rect.min.x + label_width, + left_rect.min.y + outer.height() * 0.12, + ), + Pos2::new( + left_rect.max.x - value_width, + left_rect.max.y - outer.height() * 0.12, + ), + ); + 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.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( + 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, + } + } +} + +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) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum SegmentState { + Inactive, + Center, + Active, + Edge, +} + +enum SegmentGeometry { + Rect(Rect, f32), + Polygon(Vec), +} + +fn push_additive_segment( + sharp_shapes: &mut Vec, + fallback_emission_shapes: &mut Vec, + glow: &mut GlowBatchBuilder, + geometry: SegmentGeometry, + emission_color: Color32, + body_color: Color32, + _hot_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.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); + if outer_alpha > 0.0 { + fallback_emission_shapes.push(Shape::rect_filled( + rect.expand(spread), + rounding + 2.0, + with_opacity(emission_color, opacity * outer_alpha), + )); + fallback_emission_shapes.push(Shape::rect_filled( + rect.expand(spread * 0.42), + rounding + 1.0, + with_opacity(emission_color, opacity * inner_alpha), + )); + } + sharp_shapes.push(Shape::rect_filled( + rect, + rounding, + with_opacity( + if active { + body_color + } else { + Color32::from_rgb(128, 132, 148) + }, + opacity * body_alpha, + ), + )); + if active { + let intensity = if state == SegmentState::Edge { + 0.38 + } else { + 0.26 + }; + 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), + )); + } +} + +fn push_luminous_segment( + shapes: &mut Vec, + geometry: SegmentGeometry, + color: Color32, + state: SegmentState, + opacity: f32, +) { + let (body_alpha, inner_alpha, outer_alpha, spread) = match state { + 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), + }; + let active = matches!(state, SegmentState::Active | SegmentState::Edge); + let hot = hot_face(color, 0.76); + + match geometry { + SegmentGeometry::Rect(rect, rounding) => { + if outer_alpha > 0.0 { + shapes.push(Shape::rect_filled( + 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 * inner_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.8_f32, + with_opacity( + hot, + opacity + * if state == SegmentState::Edge { + 0.92 + } else { + 0.68 + }, + ), + ), + )); + } + } + SegmentGeometry::Polygon(points) => { + if outer_alpha > 0.0 { + shapes.push(Shape::convex_polygon( + points.clone(), + 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.75_f32 } else { 0.0_f32 }, + with_opacity(hot, opacity * if active { 0.72 } 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 { + 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 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, 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, 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, + Secondary, +} + +#[allow(clippy::too_many_arguments)] +fn luminous_text( + painter: &Painter, + position: Pos2, + anchor: Align2, + text: impl ToString, + font: FontId, + core_color: Color32, + glow_color: Color32, + opacity: f32, + class: TextClass, +) { + let text = text.to_string(); + painter.text( + position + Vec2::new(0.9, 1.15), + anchor, + &text, + font.clone(), + 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.05, + (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), + }; + 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(-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 * inner_alpha), + ); + } + } + painter.text( + position, + anchor, + text, + font, + 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)); + } +} diff --git a/src/renderer/mod.rs b/src/renderer/mod.rs index 128168e..5e1c8e2 100644 --- a/src/renderer/mod.rs +++ b/src/renderer/mod.rs @@ -1,12 +1,15 @@ //! 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; 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; 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 a7f3429..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,27 +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() { + 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 { - let max_speed = points + if self.settings.show_speed && capabilities.speed { + 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, @@ -93,35 +107,44 @@ 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 { - self.draw_trace( - &painter, + if self.settings.show_clutch && capabilities.clutch { + self.build_trace( + &mut cache.shapes, rect, - &points, + self.points, now, window_dur, |p| p.telemetry.clutch, self.colors.clutch, ); } - if self.settings.show_throttle { - self.draw_throttle_trace(&painter, rect, &points, now, window_dur); + if self.settings.show_throttle && capabilities.throttle { + self.build_throttle_trace( + &mut cache.shapes, + rect, + self.points, + now, + window_dur, + ); } - if self.settings.show_brake { - self.draw_brake_trace(&painter, rect, &points, now, window_dur); + if self.settings.show_brake && capabilities.brake { + 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); @@ -149,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, @@ -170,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, @@ -197,7 +220,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 @@ -229,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)), )); @@ -237,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, @@ -266,7 +292,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 { @@ -330,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)), )); 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)