1use std::collections::VecDeque;
6use std::fmt;
7use std::time::Duration;
8
9const FRAME_LOG_SIZE: usize = 240;
11pub const MAX_ISSUE_URL: usize = 6000;
15const USER_FOLDERS: [&str; 4] = ["/home/", "/Users/", "\\Users\\", "\\users\\"];
17
18#[derive(Clone, Debug, Default, PartialEq, Eq)]
20#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
21pub struct AdapterReport {
22 pub name: String,
24 pub backend: String,
26 pub device_type: String,
28 pub driver: String,
30 pub driver_info: String,
32 pub software: bool,
34}
35
36impl fmt::Display for AdapterReport {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 write!(f, "{} ({}, {}", self.name, self.backend, self.device_type)?;
39 if !self.driver.is_empty() {
40 write!(f, ", {}", self.driver)?;
41 }
42 if !self.driver_info.is_empty() {
43 write!(f, " {}", self.driver_info)?;
44 }
45 f.write_str(")")
46 }
47}
48
49#[derive(Clone, Debug, Default, PartialEq, Eq)]
51#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
52pub struct SurfaceReport {
53 pub format: String,
55 pub present_mode: String,
57 pub supported_present_modes: Vec<String>,
59}
60
61#[derive(Clone, Debug, Default, PartialEq, Eq)]
63#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
64pub struct GpuReport {
65 pub active: AdapterReport,
67 pub available: Vec<AdapterReport>,
69 pub hidden: Vec<AdapterReport>,
72 pub surface: Option<SurfaceReport>,
74}
75
76#[derive(Clone, Debug, Default, PartialEq, Eq)]
78#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
79pub struct SystemReport {
80 pub version: String,
82 pub os: String,
84 pub session: Option<String>,
86 pub desktop: Option<String>,
88 pub vulkan_drivers: Vec<String>,
90 pub overrides: Vec<String>,
92 pub optimized: bool,
94}
95
96#[derive(Clone, Debug, PartialEq, Eq)]
98#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
99pub enum DiagnosticFinding {
100 SoftwareRendering,
102 UnoptimizedBuild,
104 HiddenAdapters(Vec<String>),
106 NoGpu(String),
108}
109
110impl fmt::Display for DiagnosticFinding {
111 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112 match self {
113 Self::SoftwareRendering => f.write_str("frames are drawn on the CPU"),
114 Self::UnoptimizedBuild => f.write_str("built without optimizations"),
115 Self::HiddenAdapters(names) => write!(f, "non-conformant drivers left out: {}", names.join(", ")),
116 Self::NoGpu(reason) => write!(f, "no usable GPU: {reason}"),
117 }
118 }
119}
120
121#[derive(Clone, Debug, PartialEq, Eq)]
123#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
124pub struct DiagnosticReport {
125 pub gpu: Result<GpuReport, String>,
127 pub system: SystemReport,
129}
130
131impl Default for DiagnosticReport {
132 fn default() -> Self {
134 Self { gpu: Err("not checked".into()), system: SystemReport::default() }
135 }
136}
137
138#[cfg_attr(feature = "live", ravenrs_live::expose)]
139impl DiagnosticReport {
140 pub fn findings(&self) -> Vec<DiagnosticFinding> {
149 let mut findings = Vec::new();
150 match &self.gpu {
151 Err(reason) => findings.push(DiagnosticFinding::NoGpu(reason.clone())),
152 Ok(gpu) => {
153 if gpu.active.software {
154 findings.push(DiagnosticFinding::SoftwareRendering);
155 }
156 if !gpu.hidden.is_empty() {
157 let names = gpu.hidden.iter().map(ToString::to_string).collect();
158 findings.push(DiagnosticFinding::HiddenAdapters(names));
159 }
160 }
161 }
162 if !self.system.optimized {
163 findings.push(DiagnosticFinding::UnoptimizedBuild);
164 }
165 findings
166 }
167}
168
169impl fmt::Display for DiagnosticReport {
170 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171 let system = &self.system;
172 writeln!(f, "RavenRS: {}", system.version)?;
173 writeln!(f, "Build: {}", if system.optimized { "optimized" } else { "NOT optimized" })?;
174 writeln!(f, "System: {}", system.os)?;
175 if let Some(session) = &system.session {
176 writeln!(f, "Session: {session}")?;
177 }
178 if let Some(desktop) = &system.desktop {
179 writeln!(f, "Desktop: {desktop}")?;
180 }
181 match &self.gpu {
182 Err(reason) => writeln!(f, "GPU: none ({reason})")?,
183 Ok(gpu) => {
184 writeln!(f, "GPU in use: {}", gpu.active)?;
185 for adapter in &gpu.available {
186 writeln!(f, "Available: {adapter}")?;
187 }
188 for adapter in &gpu.hidden {
189 writeln!(f, "Hidden, non-conformant: {adapter}")?;
190 }
191 match &gpu.surface {
192 Some(surface) => writeln!(
193 f,
194 "Surface: {}, {} (supports {})",
195 surface.format,
196 surface.present_mode,
197 surface.supported_present_modes.join(", ")
198 )?,
199 None => writeln!(f, "Surface: none, no window could be opened")?,
200 }
201 }
202 }
203 if !system.vulkan_drivers.is_empty() {
204 writeln!(f, "Vulkan drivers installed: {}", system.vulkan_drivers.join(", "))?;
205 }
206 if !system.overrides.is_empty() {
207 writeln!(f, "Overrides: {}", system.overrides.join(" "))?;
208 }
209 for finding in self.findings() {
210 writeln!(f, "Warning: {finding}")?;
211 }
212 Ok(())
213 }
214}
215
216pub fn redact_user_paths(text: &str) -> String {
228 let mut redacted = String::with_capacity(text.len());
229 let mut rest = text;
230 while let Some(at) = USER_FOLDERS.iter().filter_map(|folder| rest.find(folder)).min() {
231 redacted.push_str(&rest[..at]);
232 redacted.push_str("<user folder>");
233 rest = &rest[at..];
234 let end = rest.find(['\n', '"', '\'', ',', ';']).unwrap_or(rest.len());
235 rest = &rest[end..];
236 }
237 redacted.push_str(rest);
238 redacted
239}
240
241fn is_unreserved(byte: u8) -> bool {
243 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
244}
245
246fn percent_encode(text: &str) -> String {
247 let mut encoded = String::with_capacity(text.len());
248 for byte in text.bytes() {
249 if is_unreserved(byte) {
250 encoded.push(char::from(byte));
251 } else {
252 encoded.push_str(&format!("%{byte:02X}"));
253 }
254 }
255 encoded
256}
257
258pub fn new_issue_url(new_issue: &str, intro: &str, report: &str, truncated_note: &str) -> String {
273 let joiner = if new_issue.contains('?') { '&' } else { '?' };
274 let url = |report: &str, note: &str| {
275 let body = format!("{intro}\n\n```text\n{report}\n```\n{note}");
276 format!("{new_issue}{joiner}body={}", percent_encode(&body))
277 };
278 let report = report.trim_end();
279 let whole = url(report, "");
280 if whole.len() <= MAX_ISSUE_URL {
281 return whole;
282 }
283 let room = MAX_ISSUE_URL.saturating_sub(url("", truncated_note).len());
284 let mut used = 0;
285 let mut end = 0;
286 for (at, c) in report.char_indices() {
287 used += percent_encode(c.encode_utf8(&mut [0; 4])).len();
288 if used > room {
289 break;
290 }
291 end = at + c.len_utf8();
292 }
293 let kept = &report[..end];
294 url(kept.rfind('\n').map_or(kept, |line| &kept[..line]), truncated_note)
295}
296
297#[derive(Clone, Copy, Debug, Default, PartialEq)]
299#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
300pub struct PhaseTimes {
301 pub build: Duration,
303 pub layout: Duration,
305 pub place: Duration,
307 pub paint: Duration,
309}
310
311#[cfg_attr(feature = "live", ravenrs_live::expose)]
312impl PhaseTimes {
313 pub fn total(&self) -> Duration {
315 self.build + self.layout + self.place + self.paint
316 }
317}
318
319#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
322#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
323pub enum BuildCause {
324 #[default]
326 First,
327 Data,
329 Hover,
331 Press,
333 Focus,
335 Scroll,
337 Viewport,
339 Clock,
341 Remote,
343 System,
345}
346
347#[cfg_attr(feature = "live", ravenrs_live::expose)]
348impl BuildCause {
349 pub const ALL: [Self; 10] = [
351 Self::First,
352 Self::Data,
353 Self::Hover,
354 Self::Press,
355 Self::Focus,
356 Self::Scroll,
357 Self::Viewport,
358 Self::Clock,
359 Self::Remote,
360 Self::System,
361 ];
362}
363
364#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
366#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
367pub struct BuildCauses {
368 counts: [u64; BuildCause::ALL.len()],
369}
370
371#[cfg_attr(feature = "live", ravenrs_live::expose)]
372impl BuildCauses {
373 pub fn add(&mut self, cause: BuildCause) {
375 self.counts[cause as usize] += 1;
376 }
377
378 pub fn count(&self, cause: BuildCause) -> u64 {
380 self.counts[cause as usize]
381 }
382}
383
384#[derive(Clone, Copy, Debug, Default, PartialEq)]
386#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
387pub struct FrameWork {
388 pub phases: PhaseTimes,
390 pub builds: u32,
392 pub cause: Option<BuildCause>,
394 pub paints: u32,
396 pub shapes: u32,
398 pub elements: u32,
400}
401
402#[derive(Clone, Debug, Default, PartialEq)]
404#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
405pub struct ViewCost {
406 pub name: &'static str,
408 pub count: u32,
410 pub time: Duration,
412 pub elements: u32,
414 pub shapes: u32,
416}
417
418#[cfg_attr(feature = "live", ravenrs_live::expose)]
419impl ViewCost {
420 pub fn short_name(&self) -> &'static str {
423 let name = self.name.split('<').next().unwrap_or(self.name);
424 name.rsplit("::").next().unwrap_or(name)
425 }
426}
427
428#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
430#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
431pub struct RenderWork {
432 pub breaks_kind: u32,
434 pub breaks_image: u32,
436 pub breaks_clip: u32,
438 pub uploaded_bytes: u64,
440 pub glyphs_rasterized: u64,
442 pub atlas_resets: u64,
444 pub atlas_used: u32,
446 pub atlas_size: u32,
448 pub cached_images: u32,
450 pub gpu: Option<Duration>,
452}
453
454#[derive(Clone, Copy, Debug, Default, PartialEq)]
457#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
458pub struct FrameSample {
459 pub update: Duration,
461 pub acquire: Duration,
463 pub render: Duration,
465 pub present: Duration,
467 pub quads: usize,
469 pub glyphs: usize,
471 pub draw_calls: usize,
473 pub size: (u32, u32),
475 pub scale: f32,
477 pub work: FrameWork,
479 pub render_work: RenderWork,
481}
482
483#[cfg_attr(feature = "live", ravenrs_live::expose)]
484impl FrameSample {
485 pub fn total(&self) -> Duration {
487 self.update + self.acquire + self.render + self.present
488 }
489}
490
491#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
493#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
494pub struct Percentiles {
495 pub median: Duration,
497 pub p95: Duration,
499}
500
501#[cfg_attr(feature = "live", ravenrs_live::expose)]
502impl Percentiles {
503 fn of(mut values: Vec<Duration>) -> Self {
504 values.sort_unstable();
505 let at = |fraction: f64| {
506 let last = values.len().saturating_sub(1);
507 values.get((last as f64 * fraction).round() as usize).copied().unwrap_or_default()
508 };
509 Self { median: at(0.5), p95: at(0.95) }
510 }
511}
512
513#[derive(Clone, Copy, Debug, Default, PartialEq)]
515#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
516pub struct FrameSummary {
517 pub frames: usize,
519 pub update: Percentiles,
521 pub acquire: Percentiles,
523 pub render: Percentiles,
525 pub present: Percentiles,
527 pub total: Percentiles,
529 pub latest: FrameSample,
531}
532
533impl fmt::Display for FrameSummary {
534 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
535 let latest = &self.latest;
536 writeln!(f, "Frames: {} (median / p95)", self.frames)?;
537 let stages = [
538 ("Total", self.total),
539 ("Update", self.update),
540 ("Acquire", self.acquire),
541 ("Render", self.render),
542 ("Present", self.present),
543 ];
544 for (name, stage) in stages {
545 writeln!(f, " {name}: {:.1?} / {:.1?}", stage.median, stage.p95)?;
546 }
547 writeln!(
548 f,
549 "Latest frame: {}x{} at scale {}, {} quads, {} glyphs, {} draw calls",
550 latest.size.0, latest.size.1, latest.scale, latest.quads, latest.glyphs, latest.draw_calls
551 )
552 }
553}
554
555const GOOD_FPS: f64 = 45.0;
557const FAIR_FPS: f64 = 24.0;
559
560#[derive(Clone, Copy, Debug, PartialEq, Eq)]
562#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
563pub enum PerformanceVerdict {
564 Good,
566 Fair,
568 Poor,
570}
571
572#[derive(Clone, Copy, Debug, PartialEq, Eq)]
574#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
575pub enum Bottleneck {
576 Gpu,
578 Interface,
580}
581
582#[derive(Clone, Copy, Debug, PartialEq)]
584#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
585pub struct PerformanceResult {
586 pub fps: f64,
589 pub frames: u64,
591 pub seconds: f64,
593 pub verdict: PerformanceVerdict,
595 pub bottleneck: Option<Bottleneck>,
597}
598
599impl fmt::Display for PerformanceResult {
600 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
601 write!(
602 f,
603 "Performance test: {:.0} frames per second, {} frames in {:.1} s, {:?}",
604 self.fps, self.frames, self.seconds, self.verdict
605 )?;
606 match self.bottleneck {
607 Some(bottleneck) => writeln!(f, ", limited by {bottleneck:?}"),
608 None => writeln!(f),
609 }
610 }
611}
612
613#[derive(Clone, Debug, Default)]
625#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
626pub struct FrameLog {
627 samples: VecDeque<FrameSample>,
628 recorded: u64,
629}
630
631#[cfg_attr(feature = "live", ravenrs_live::expose)]
632impl FrameLog {
633 pub fn record(&mut self, sample: FrameSample) {
635 if self.samples.len() == FRAME_LOG_SIZE {
636 self.samples.pop_front();
637 }
638 self.samples.push_back(sample);
639 self.recorded += 1;
640 }
641
642 pub fn clear(&mut self) {
644 self.samples.clear();
645 self.recorded = 0;
646 }
647
648 pub fn measure(&self, seconds: f64) -> PerformanceResult {
650 let fps = if seconds > 0.0 { self.recorded as f64 / seconds } else { 0.0 };
651 let verdict = if fps >= GOOD_FPS {
652 PerformanceVerdict::Good
653 } else if fps >= FAIR_FPS {
654 PerformanceVerdict::Fair
655 } else {
656 PerformanceVerdict::Poor
657 };
658 let summary = self.summary();
659 let drawing = summary.acquire.median + summary.render.median + summary.present.median;
660 let bottleneck = (verdict != PerformanceVerdict::Good)
661 .then_some(if summary.update.median > drawing { Bottleneck::Interface } else { Bottleneck::Gpu });
662 PerformanceResult { fps, frames: self.recorded, seconds, verdict, bottleneck }
663 }
664
665 pub fn samples(&self) -> impl Iterator<Item = &FrameSample> {
667 self.samples.iter()
668 }
669
670 pub fn trace_json(&self) -> String {
673 let mut events = Vec::new();
674 let mut at = 0.0;
675 for (number, sample) in self.samples.iter().enumerate() {
676 let phases = sample.work.phases;
677 let mut start = at;
678 let mut slice = |name: &str, length: Duration| {
679 let micros = length.as_secs_f64() * 1e6;
680 if micros > 0.0 {
681 events.push(format!(
682 "{{\"name\":\"{name}\",\"ph\":\"X\",\"pid\":1,\"tid\":1,\"ts\":{start:.1},\"dur\":{micros:.1},\"args\":{{\"frame\":{number}}}}}"
683 ));
684 }
685 start += micros;
686 };
687 slice("build", phases.build);
688 slice("layout", phases.layout);
689 slice("place", phases.place);
690 slice("paint", phases.paint);
691 slice("other update", sample.update.saturating_sub(phases.total()));
692 slice("acquire", sample.acquire);
693 slice("render", sample.render);
694 slice("present", sample.present);
695 at += sample.total().as_secs_f64() * 1e6;
696 }
697 format!("{{\"traceEvents\":[{}]}}", events.join(","))
698 }
699
700 pub fn summary(&self) -> FrameSummary {
702 let of =
703 |stage: fn(&FrameSample) -> Duration| Percentiles::of(self.samples.iter().map(stage).collect());
704 FrameSummary {
705 frames: self.samples.len(),
706 update: of(|s| s.update),
707 acquire: of(|s| s.acquire),
708 render: of(|s| s.render),
709 present: of(|s| s.present),
710 total: of(FrameSample::total),
711 latest: self.samples.back().copied().unwrap_or_default(),
712 }
713 }
714}
715
716#[cfg(test)]
717mod tests {
718 use super::*;
719
720 fn gpu(software: bool) -> GpuReport {
721 GpuReport {
722 active: AdapterReport { name: "llvmpipe".into(), software, ..Default::default() },
723 ..Default::default()
724 }
725 }
726
727 #[test]
728 fn a_healthy_machine_has_nothing_to_report() {
729 let report = DiagnosticReport {
730 gpu: Ok(gpu(false)),
731 system: SystemReport { optimized: true, ..Default::default() },
732 };
733 assert!(report.findings().is_empty());
734 assert!(!report.to_string().contains("Warning"));
735 }
736
737 #[test]
738 fn drawing_on_the_cpu_comes_first() {
739 let report = DiagnosticReport { gpu: Ok(gpu(true)), system: SystemReport::default() };
740 assert_eq!(
741 report.findings(),
742 vec![DiagnosticFinding::SoftwareRendering, DiagnosticFinding::UnoptimizedBuild]
743 );
744 assert!(report.to_string().contains("Warning: frames are drawn on the CPU"));
745 }
746
747 #[test]
748 fn an_adapter_hidden_for_its_driver_is_named() {
749 let mut gpu = gpu(true);
750 gpu.hidden.push(AdapterReport { name: "Intel Haswell".into(), ..Default::default() });
751 let report = DiagnosticReport { gpu: Ok(gpu), ..Default::default() };
752 assert!(report.to_string().contains("Hidden, non-conformant: Intel Haswell"));
753 assert!(
754 matches!(&report.findings()[1], DiagnosticFinding::HiddenAdapters(names) if names.len() == 1)
755 );
756 }
757
758 #[test]
759 fn percentiles_ignore_a_lone_slow_frame_in_the_median() {
760 let mut log = FrameLog::default();
761 for ms in 1..=99 {
762 log.record(FrameSample { update: Duration::from_millis(ms), ..Default::default() });
763 }
764 log.record(FrameSample { update: Duration::from_secs(5), ..Default::default() });
765 let summary = log.summary();
766 assert_eq!(summary.frames, 100);
767 assert_eq!(summary.update.median, Duration::from_millis(51));
768 assert_eq!(summary.update.p95, Duration::from_millis(95));
769 }
770
771 #[test]
772 fn the_log_forgets_the_oldest_frames() {
773 let mut log = FrameLog::default();
774 for _ in 0..FRAME_LOG_SIZE + 10 {
775 log.record(FrameSample::default());
776 }
777 assert_eq!(log.summary().frames, FRAME_LOG_SIZE);
778 }
779
780 fn log_of(frames: u64, sample: FrameSample) -> FrameLog {
781 let mut log = FrameLog::default();
782 for _ in 0..frames {
783 log.record(sample);
784 }
785 log
786 }
787
788 #[test]
789 fn a_fluid_window_has_nothing_to_blame() {
790 let result = log_of(300, FrameSample::default()).measure(5.0);
791 assert_eq!(result.fps, 60.0);
792 assert_eq!(result.verdict, PerformanceVerdict::Good);
793 assert_eq!(result.bottleneck, None);
794 }
795
796 #[test]
797 fn the_rate_counts_more_frames_than_the_log_keeps() {
798 let log = log_of(FRAME_LOG_SIZE as u64 + 60, FrameSample::default());
799 assert_eq!(log.measure(5.0).frames, FRAME_LOG_SIZE as u64 + 60);
800 }
801
802 #[test]
803 fn a_slow_window_blames_where_the_time_goes() {
804 let drawing = FrameSample { render: Duration::from_millis(40), ..Default::default() };
805 let result = log_of(50, drawing).measure(5.0);
806 assert_eq!(result.verdict, PerformanceVerdict::Poor);
807 assert_eq!(result.bottleneck, Some(Bottleneck::Gpu));
808
809 let building = FrameSample { update: Duration::from_millis(40), ..Default::default() };
810 let result = log_of(150, building).measure(5.0);
811 assert_eq!(result.verdict, PerformanceVerdict::Fair);
812 assert_eq!(result.bottleneck, Some(Bottleneck::Interface));
813 }
814
815 #[test]
816 fn nothing_drawn_is_a_poor_result_not_a_division_by_zero() {
817 assert_eq!(FrameLog::default().measure(0.0).verdict, PerformanceVerdict::Poor);
818 }
819
820 #[test]
821 fn clearing_starts_the_count_over() {
822 let mut log = log_of(10, FrameSample::default());
823 log.clear();
824 assert_eq!(log.measure(1.0).frames, 0);
825 }
826
827 #[test]
828 fn an_empty_log_sums_up_to_nothing() {
829 assert_eq!(FrameLog::default().summary(), FrameSummary::default());
830 }
831}