diff --git a/crates/horizon-client/src/debug_chart.rs b/crates/horizon-client/src/debug_chart.rs new file mode 100644 index 000000000..43d30d349 --- /dev/null +++ b/crates/horizon-client/src/debug_chart.rs @@ -0,0 +1,536 @@ +//! The debug charts (26.3 `debugchart`): F3+2 shows the frame times and, in +//! single player, the integrated server's tick times; F3+3 the ping and, on a +//! remote server, the bandwidth. Each chart keeps the last 240 samples +//! (`LocalSampleLogger`) and draws one bar per sample over a 60 pixel high +//! box (`AbstractDebugChart`). +use crate::{ + font::Font, + hud_layout::{GuiDraw, argb, fill}, + text::Text, +}; +use std::{ + collections::VecDeque, + time::{Duration, Instant}, +}; + +/// `LocalSampleLogger.CAPACITY`. +pub const CAPACITY: usize = 240; +/// `AbstractDebugChart.CHART_HEIGHT`. +pub const CHART_HEIGHT: i32 = 60; +/// `TpsDebugDimensions`: a tick sample's values. +pub const FULL_TICK: usize = 0; +pub const TICK_SERVER_METHOD: usize = 1; +pub const SCHEDULED_TASKS: usize = 2; +pub const IDLE: usize = 3; + +/// `LocalSampleLogger`: the last [`CAPACITY`] samples, oldest first; a +/// sample has up to four values (`TpsDebugDimensions`), the first the main. +#[derive(Clone, Debug, Default)] +pub struct SampleLogger { + samples: VecDeque<[i64; 4]>, +} +impl SampleLogger { + /// `logSample`. + pub fn log(&mut self, value: i64) { + self.log_full([value, 0, 0, 0]); + } + /// `logFullSample`. + pub fn log_full(&mut self, sample: [i64; 4]) { + if self.samples.len() == CAPACITY { + self.samples.pop_front(); + } + self.samples.push_back(sample); + } + pub fn len(&self) -> usize { + self.samples.len() + } + pub fn is_empty(&self) -> bool { + self.samples.is_empty() + } + pub fn reset(&mut self) { + self.samples.clear(); + } + fn get(&self, index: usize, dimension: usize) -> i64 { + self.samples[index][dimension] + } +} + +/// `DebugScreenOverlay`'s chart switches and the samples behind them. +#[derive(Clone, Debug, Default)] +pub struct DebugCharts { + /// `renderFpsCharts` (F3+2). + pub fps: bool, + /// `renderNetworkCharts` (F3+3). + pub network: bool, + /// `frameTimeLogger`: nanoseconds between frames. + pub frame_times: SampleLogger, + /// `tickTimeLogger`: the integrated server's ticks. + pub tick_times: SampleLogger, + /// `pingLogger`: milliseconds. + pub ping: SampleLogger, + /// `bandwidthLogger`: bytes received per tick. + pub bandwidth: SampleLogger, + previous_tick: Option, + bytes_seen: Option, +} +impl DebugCharts { + /// `toggleFpsCharts`; `overlay` is the debug overlay's visibility. + pub fn toggle_fps(&mut self, overlay: &mut bool) { + self.fps = !*overlay || !self.fps; + if self.fps { + *overlay = true; + self.network = false; + } + } + /// `toggleNetworkCharts`. + pub fn toggle_network(&mut self, overlay: &mut bool) { + self.network = !*overlay || !self.network; + if self.network { + *overlay = true; + self.fps = false; + } + } + /// `reset`: leaving a world. + pub fn reset(&mut self) { + self.tick_times.reset(); + self.ping.reset(); + self.bandwidth.reset(); + self.previous_tick = None; + self.bytes_seen = None; + } + /// One integrated server tick that started at `started` and ran its + /// tick method for `method` (`MinecraftServer.logFullTickTime` and + /// `logTickMethodTime`): the full tick runs from one start to the next, + /// and what the tick method leaves of it is idle. + pub fn log_tick(&mut self, started: Instant, method: Duration) { + let method = method.as_nanos() as i64; + let full = self + .previous_tick + .map_or(method, |p| (started - p).as_nanos() as i64) + .max(method); + self.previous_tick = Some(started); + self.tick_times.log_full([full, method, 0, full - method]); + } + /// `BandwidthDebugMonitor.tick`: the bytes received since the last + /// tick, from the connection's running total. + pub fn log_bandwidth(&mut self, total: i64) { + if let Some(seen) = self.bytes_seen { + self.bandwidth.log(total - seen); + } + self.bytes_seen = Some(total); + } + /// The charts along the bottom of the screen (`extractRenderState`): + /// frame times left and tick times right, or bandwidth left (remote + /// servers only) and ping right. `mspt` is the level's milliseconds + /// per tick. + pub fn draw( + &self, + font: &Font, + width: i32, + height: i32, + framerate_limit: u32, + mspt: f32, + remote: bool, + ) -> Vec { + let mut out = Vec::new(); + let max_width = width / 2; + let chart_width = Chart::width(max_width); + if self.fps { + let fps = Chart::Fps { framerate_limit }; + out.extend(fps.draw(font, &self.frame_times, 0, chart_width, height)); + if !self.tick_times.is_empty() { + let tps = Chart::Tps { mspt }; + out.extend(tps.draw( + font, + &self.tick_times, + width - chart_width, + chart_width, + height, + )); + } + } + if self.network { + if remote { + out.extend(Chart::Bandwidth.draw(font, &self.bandwidth, 0, chart_width, height)); + } + out.extend(Chart::Ping.draw( + font, + &self.ping, + width - chart_width, + chart_width, + height, + )); + } + out + } +} + +/// The four charts and what each needs to scale its bars. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Chart { + /// `FpsDebugChart`: frame durations in nanoseconds; the frame rate limit + /// option (`maxFps`). + Fps { framerate_limit: u32 }, + /// `TpsDebugChart`: tick samples in nanoseconds; the level's + /// milliseconds per tick. + Tps { mspt: f32 }, + /// `PingDebugChart`: round trips in milliseconds. + Ping, + /// `BandwidthDebugChart`: bytes received per tick. + Bandwidth, +} + +/// The labels' colour and the backdrop of the box and shaded labels. +const LABEL: u32 = 0xFFE0_E0E0; +const BACKDROP: u32 = 0x9050_5050; +const WHITE: u32 = 0xFFFF_FFFF; +const GREEN: u32 = 0xFF00_FF00; +const YELLOW: u32 = 0xFFFF_FF00; +const RED: u32 = 0xFFFF_0000; +const CYAN: u32 = 0xFF00_FFFF; +/// `BandwidthDebugChart.MID_COLOR`. +const LAVENDER: u32 = 0xFFA0_A0FF; +/// `TpsDebugChart`'s bar parts. +const TICK_METHOD_COLOR: u32 = 0xFF99_1111; +const TASK_COLOR: u32 = 0xFFBA_995F; +const OTHER_COLOR: u32 = 0xFF5F_0E8C; +const MEGABYTE: f64 = 1_048_576.0; + +impl Chart { + /// `AbstractDebugChart.getWidth`. + pub fn width(max_width: i32) -> i32 { + (CAPACITY as i32 + 2).min(max_width) + } + /// `getFullHeight`: the box and the min/avg/max line above it. + pub fn full_height() -> i32 { + CHART_HEIGHT + 9 + } + /// `AbstractDebugChart.extractRenderState`. + pub fn draw( + self, + font: &Font, + samples: &SampleLogger, + left: i32, + width: i32, + bottom: i32, + ) -> Vec { + let mut out = vec![fill( + left, + bottom - CHART_HEIGHT, + left + width, + bottom, + BACKDROP, + )]; + let (mut sum, mut min, mut max) = (0i64, i64::from(i32::MAX), i64::from(i32::MIN)); + let start = CAPACITY.saturating_sub((width - 2).max(0) as usize); + let count = samples.len().saturating_sub(start); + for i in 0..count { + let x = left + i as i32 + 1; + let index = start + i; + let value = self.aggregated(samples, index); + min = min.min(value); + max = max.max(value); + sum += value; + self.bars(&mut out, samples, bottom, x, index); + } + horizontal_line( + &mut out, + left, + left + width - 1, + bottom - CHART_HEIGHT, + WHITE, + ); + horizontal_line(&mut out, left, left + width - 1, bottom - 1, WHITE); + vertical_line(&mut out, left, bottom - CHART_HEIGHT, bottom, WHITE); + vertical_line( + &mut out, + left + width - 1, + bottom - CHART_HEIGHT, + bottom, + WHITE, + ); + if count > 0 { + let y = bottom - CHART_HEIGHT - 9; + let min_text = format!("{} min", self.display(min as f64)); + let avg_text = format!("{} avg", self.display(sum as f64 / count as f64)); + let max_text = format!("{} max", self.display(max as f64)); + text(&mut out, min_text, left + 2, y, LABEL, true); + let avg_x = left + width / 2 - font.width(&avg_text) as i32 / 2; + text(&mut out, avg_text, avg_x, y, LABEL, true); + let max_x = left + width - font.width(&max_text) as i32 - 2; + text(&mut out, max_text, max_x, y, LABEL, true); + } + self.lines_and_labels(&mut out, font, left, width, bottom); + out + } + fn bars( + self, + out: &mut Vec, + samples: &SampleLogger, + bottom: i32, + x: i32, + index: usize, + ) { + let value = samples.get(index, 0); + let height = self.height(value as f64); + out.push(fill(x, bottom - height, x + 1, bottom, self.color(value))); + if let Chart::Tps { .. } = self { + let method = samples.get(index, TICK_SERVER_METHOD); + let method_h = self.height(method as f64); + out.push(fill(x, bottom - method_h, x + 1, bottom, TICK_METHOD_COLOR)); + let tasks = samples.get(index, SCHEDULED_TASKS); + let tasks_h = self.height(tasks as f64); + out.push(fill( + x, + bottom - method_h - tasks_h, + x + 1, + bottom - method_h, + TASK_COLOR, + )); + let other = value - samples.get(index, IDLE) - method - tasks; + let other_h = self.height(other as f64); + out.push(fill( + x, + bottom - other_h - tasks_h - method_h, + x + 1, + bottom - tasks_h - method_h, + OTHER_COLOR, + )); + } + } + /// `getValueForAggregation`: a tick's time without its idle part. + fn aggregated(self, samples: &SampleLogger, index: usize) -> i64 { + match self { + Chart::Tps { .. } => samples.get(index, 0) - samples.get(index, IDLE), + _ => samples.get(index, 0), + } + } + /// `extractAdditionalLinesAndLabels`. + fn lines_and_labels( + self, + out: &mut Vec, + font: &Font, + left: i32, + width: i32, + bottom: i32, + ) { + match self { + Chart::Fps { framerate_limit } => { + shaded(out, font, "30 fps", left + 1, bottom - CHART_HEIGHT + 1); + shaded(out, font, "60 fps", left + 1, bottom - 30 + 1); + horizontal_line(out, left, left + width - 1, bottom - 30, WHITE); + if framerate_limit > 0 && framerate_limit <= 250 { + let y = bottom - self.height(1.0e9 / f64::from(framerate_limit)) - 1; + horizontal_line(out, left, left + width - 1, y, CYAN); + } + } + Chart::Tps { mspt } => { + let tps = 1000.0 / mspt; + shaded( + out, + font, + &format!("{tps:.1} tps"), + left + 1, + bottom - CHART_HEIGHT + 1, + ); + } + Chart::Ping => shaded(out, font, "500 ms", left + 1, bottom - CHART_HEIGHT + 1), + Chart::Bandwidth => { + for bytes in [64.0, 1024.0, 16384.0] { + let y = bottom - bandwidth_height(bytes); + shaded(out, font, &bandwidth_display(bytes), left + 1, y + 1); + horizontal_line(out, left, left + width - 1, y, WHITE); + } + let y = bottom - bandwidth_height(MEGABYTE) + 1; + shaded(out, font, &bandwidth_display(MEGABYTE), left + 1, y); + } + } + } + /// `toDisplayString`. + fn display(self, sample: f64) -> String { + match self { + Chart::Fps { .. } | Chart::Tps { .. } => format!("{} ms", java_round(sample / 1.0e6)), + Chart::Ping => format!("{} ms", java_round(sample)), + Chart::Bandwidth => bandwidth_display(sample * 20.0), + } + } + /// `getSampleHeight`. + fn height(self, sample: f64) -> i32 { + match self { + Chart::Fps { .. } => java_round(sample / 1.0e6 * 60.0 / 33.333333333333336) as i32, + Chart::Tps { mspt } => java_round(sample / 1.0e6 * 60.0 / f64::from(mspt)) as i32, + Chart::Ping => java_round(sample * 60.0 / 500.0) as i32, + Chart::Bandwidth => bandwidth_height(sample * 20.0), + } + } + /// `getSampleColor`. + fn color(self, sample: i64) -> u32 { + let s = sample as f64; + match self { + Chart::Fps { .. } => gradient(s / 1.0e6, 0.0, GREEN, 28.0, YELLOW, 56.0, RED), + Chart::Tps { mspt } => { + let m = f64::from(mspt); + gradient(s / 1.0e6, m, GREEN, m * 1.125, YELLOW, m * 1.25, RED) + } + Chart::Ping => gradient(s, 0.0, GREEN, 250.0, YELLOW, 500.0, RED), + Chart::Bandwidth => gradient(s * 20.0, 0.0, CYAN, 8192.0, LAVENDER, 1.048576e7, RED), + } + } +} + +/// `BandwidthDebugChart.toDisplayStringInternal`. +fn bandwidth_display(bytes_per_second: f64) -> String { + if bytes_per_second >= MEGABYTE { + format!("{:.1} MiB/s", bytes_per_second / MEGABYTE) + } else if bytes_per_second >= 1024.0 { + format!("{:.1} KiB/s", bytes_per_second / 1024.0) + } else { + format!("{} B/s", bytes_per_second.floor() as i64) + } +} +/// `BandwidthDebugChart.getSampleHeightInternal`: logarithmic, 1 MiB/s at +/// the top. +fn bandwidth_height(bytes_per_second: f64) -> i32 { + java_round((bytes_per_second + 1.0).ln() * 60.0 / MEGABYTE.ln()) as i32 +} +/// `Math.round(double)`: halves round up. +fn java_round(v: f64) -> i64 { + (v + 0.5).floor() as i64 +} +/// `AbstractDebugChart.getSampleColor(sample, min, minColor, mid, ...)`. +fn gradient( + sample: f64, + min: f64, + min_color: u32, + mid: f64, + mid_color: u32, + max: f64, + max_color: u32, +) -> u32 { + let sample = sample.clamp(min, max); + if sample < mid { + srgb_lerp(((sample - min) / (mid - min)) as f32, min_color, mid_color) + } else { + srgb_lerp(((sample - mid) / (max - mid)) as f32, mid_color, max_color) + } +} +/// `ARGB.srgbLerp`: each channel by `Mth.lerpInt`. +fn srgb_lerp(alpha: f32, p0: u32, p1: u32) -> u32 { + let channel = |shift: u32| { + let (a, b) = (((p0 >> shift) & 255) as i32, ((p1 >> shift) & 255) as i32); + (a + (alpha * (b - a) as f32).floor() as i32) as u32 + }; + argb(channel(24), channel(16), channel(8), channel(0)) +} +/// `GuiGraphicsExtractor.horizontalLine`. +fn horizontal_line(out: &mut Vec, x0: i32, x1: i32, y: i32, color: u32) { + out.push(fill(x0.min(x1), y, x0.max(x1) + 1, y + 1, color)); +} +/// `GuiGraphicsExtractor.verticalLine`. +fn vertical_line(out: &mut Vec, x: i32, y0: i32, y1: i32, color: u32) { + out.push(fill(x, y0.min(y1) + 1, x + 1, y0.max(y1), color)); +} +fn text(out: &mut Vec, s: String, x: i32, y: i32, color: u32, shadow: bool) { + out.push(GuiDraw::Text { + text: Text::literal(s), + x, + y, + color, + shadow, + }); +} +/// `extractStringWithShade`: a label on a dark backdrop, without shadow. +fn shaded(out: &mut Vec, font: &Font, s: &str, x: i32, y: i32) { + out.push(fill(x, y, x + font.width(s) as i32 + 1, y + 9, BACKDROP)); + text(out, s.to_owned(), x + 1, y + 1, LABEL, false); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_logger_keeps_the_last_240_samples() { + let mut l = SampleLogger::default(); + for i in 0..300 { + l.log(i); + } + assert_eq!(l.len(), CAPACITY); + assert_eq!(l.get(0, 0), 60); + assert_eq!(l.get(CAPACITY - 1, 0), 299); + } + + #[test] + fn chart_toggles_show_the_overlay_and_exclude_each_other() { + let mut c = DebugCharts::default(); + let mut overlay = false; + c.toggle_fps(&mut overlay); + assert!(overlay && c.fps); + c.toggle_network(&mut overlay); + assert!(c.network && !c.fps); + c.toggle_network(&mut overlay); + assert!(!c.network && overlay); + // With the overlay hidden, the key shows it again with the chart on. + c.toggle_fps(&mut overlay); + overlay = false; + c.toggle_fps(&mut overlay); + assert!(overlay && c.fps); + } + + #[test] + fn ticks_and_bandwidth_log_their_differences() { + let mut c = DebugCharts::default(); + let t0 = Instant::now(); + c.log_tick(t0, Duration::from_millis(5)); + c.log_tick(t0 + Duration::from_millis(50), Duration::from_millis(7)); + assert_eq!( + c.tick_times.samples[1], + [50_000_000, 7_000_000, 0, 43_000_000] + ); + c.log_bandwidth(1000); + c.log_bandwidth(1600); + assert_eq!(c.bandwidth.len(), 1); + assert_eq!(c.bandwidth.get(0, 0), 600); + } + + #[test] + fn heights_match_vanilla_scales() { + let fps = Chart::Fps { + framerate_limit: 260, + }; + // 33.3 ms (30 fps) fills the chart; 16.7 ms half of it. + assert_eq!(fps.height(33_333_333.0), 60); + assert_eq!(fps.height(16_666_667.0), 30); + let tps = Chart::Tps { mspt: 50.0 }; + assert_eq!(tps.height(25e6), 30); + assert_eq!(Chart::Ping.height(250.0), 30); + // 1 MiB/s at the top; 52 bytes per tick is 1 KiB/s. + assert_eq!(bandwidth_height(MEGABYTE), 60); + assert_eq!(Chart::Bandwidth.height(51.2), 30); + } + + #[test] + fn labels_match_vanilla_formats() { + assert_eq!( + Chart::Fps { framerate_limit: 0 }.display(16_500_000.0), + "17 ms" + ); + assert_eq!(Chart::Ping.display(12.4), "12 ms"); + assert_eq!(Chart::Bandwidth.display(3.0), "60 B/s"); + assert_eq!(Chart::Bandwidth.display(100.0), "2.0 KiB/s"); + assert_eq!(bandwidth_display(MEGABYTE), "1.0 MiB/s"); + } + + #[test] + fn colours_go_green_yellow_red() { + let fps = Chart::Fps { framerate_limit: 0 }; + assert_eq!(fps.color(0), GREEN); + assert_eq!(fps.color(28_000_000), YELLOW); + assert_eq!(fps.color(100_000_000), RED); + // Halfway between green and yellow: Mth.lerpInt floors. + assert_eq!(fps.color(14_000_000), 0xFF7F_FF00); + let tps = Chart::Tps { mspt: 50.0 }; + assert_eq!(tps.color(10_000_000), GREEN); + assert_eq!(Chart::Bandwidth.color(0), CYAN); + } +} diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index b406c5766..9d7c24cda 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -948,6 +948,8 @@ struct Game<'a> { /// KeyboardHandler's debug key state (game/debug_keys.rs), the physical /// keys held, and the debug renderers F3+B and F3+G switch on. debug_keys: horizon_client::debug_keys::DebugKeys, + /// The F3 overlay's charts (F3+2, F3+3). + debug_charts: horizon_client::debug_chart::DebugCharts, show_hitboxes: bool, show_chunk_borders: bool, /// PauseScreen.showPauseMenu (false after F3+Escape). @@ -2855,6 +2857,7 @@ impl Game<'_> { } fn tick(&mut self) -> Result<()> { self.tick_debug_keys()?; + self.tick_debug_charts(); if self.remote.is_some() { return self.tick_remote(); } @@ -3645,7 +3648,7 @@ impl Game<'_> { 2 => "north (Towards negative Z)", _ => "east (Towards positive X)", }; - let lines = [ + let mut lines = vec![ format!("Horizon 26.3 (Rust) {:.0} fps", self.fps), format!( "C: {} chunks, {} sections drawn / {}", @@ -3688,7 +3691,9 @@ impl Game<'_> { self.world.day_time / 24000, self.world.day_time % 24000 ), + String::new(), ]; + lines.extend(self.debug_chart_lines()); for (i, line) in lines.iter().enumerate() { if line.is_empty() { continue; @@ -3711,6 +3716,15 @@ impl Game<'_> { false, ); } + let charts = self.debug_charts.draw( + &atlas.font, + w as i32, + h as i32, + self.settings.max_fps, + 1000.0 / self.clock.rate(), + self.remote.is_some(), + ); + hud_layout::paint(&mut batch, atlas, &charts); } if !self.ui.hide_gui && let Some((text, at)) = &self.message @@ -6287,6 +6301,7 @@ impl Game<'_> { self.save.flush(); self.session_lock = None; horizon_client::crash::clear_detail("World"); + self.debug_charts.reset(); self.streaming.paused = true; self.world = World::new(self.res.facts.air, 0); self.player = None; @@ -9056,6 +9071,7 @@ impl ApplicationHandler for Game<'_> { // In single player the tick is the integrated server's. if self.remote.is_none() { self.perf.tick(tick.elapsed().as_secs_f32() * 1000.0); + self.debug_charts.log_tick(tick, tick.elapsed()); } self.tick_ambience(); self.tick_fov(); @@ -9142,6 +9158,7 @@ impl ApplicationHandler for Game<'_> { } if rendered { self.frame_stats.record(elapsed.as_secs_f32() * 1000.0); + self.debug_charts.frame_times.log(elapsed.as_nanos() as i64); if self.frames == 0 { // The launcher times Play to this line (launcher/src/perf.rs). log::info!( @@ -9620,6 +9637,7 @@ pub fn run() -> Result<()> { world_options: None, friends: None, debug_keys: Default::default(), + debug_charts: Default::default(), show_hitboxes: false, show_chunk_borders: false, pause_menu: true, diff --git a/crates/horizon-client/src/game/debug_keys.rs b/crates/horizon-client/src/game/debug_keys.rs index 0f49cd459..bc3bb82c4 100644 --- a/crates/horizon-client/src/game/debug_keys.rs +++ b/crates/horizon-client/src/game/debug_keys.rs @@ -151,7 +151,7 @@ impl Game<'_> { } } /// `Util.getMillis` (positive: KeyboardHandler treats 0 as unset). - fn debug_millis(&self) -> i64 { + pub(super) fn debug_millis(&self) -> i64 { self.started.elapsed().as_millis() as i64 + 1_000_000 } /// `KeyboardHandler.tick`: the debug crash countdown. @@ -180,6 +180,79 @@ impl Game<'_> { } Ok(()) } + /// The network charts' samples each tick: `PingDebugMonitor.tick` while + /// they show, `BandwidthDebugMonitor.tick` always. + pub(super) fn tick_debug_charts(&mut self) { + let shown = self.debug && self.debug_charts.network; + let Some(r) = &self.remote else { + // The integrated server answers within the tick. + if shown { + self.debug_charts.ping.log(0); + } + return; + }; + let received = r + .session + .counters + .lock() + .unwrap() + .as_ref() + .map(|c| c.bytes_in.load(std::sync::atomic::Ordering::Relaxed)); + if let Some(total) = received { + self.debug_charts.log_bandwidth(total); + } + if shown { + use horizon_protocol::packets::{play::Serverbound, status::Ping}; + let time = self.debug_millis(); + self.send(Serverbound::PingRequest(Ping { time })); + } + } + /// The overlay's chart lines (`DebugScreenOverlay`'s "Debug charts"). + pub(super) fn debug_chart_lines(&self) -> [String; 3] { + let keys = &self.settings.keys; + let modifier = keys.get("key.debug.modifier"); + let key = |mapping: &str| { + let prefix = if modifier == "key.keyboard.unknown" { + String::new() + } else { + format!("{}+", keybinds::display_name(modifier).plain()) + }; + format!( + "[{prefix}{}]", + keybinds::display_name(keys.get(mapping)).plain() + ) + }; + let chart = |mapping: &str, name: &str, on: bool| { + format!( + "{} {name} {}", + key(mapping), + if on { "visible" } else { "hidden" } + ) + }; + let remote = self.remote.is_some(); + let c = &self.debug_charts; + [ + format!( + "Debug charts: {}; {};", + chart("key.debug.profilingChart", "Profiler", false), + chart( + "key.debug.fpsCharts", + if remote { "fps" } else { "fps + tps" }, + c.fps + ), + ), + format!( + "{}; {}", + chart( + "key.debug.networkCharts", + if remote { "Bandwidth + Ping" } else { "Ping" }, + c.network + ), + chart("key.debug.lightmapTexture", "Lightmap", false), + ), + format!("To edit: press {}", key("key.debug.debugOptions")), + ] + } /// `KeyMapping.getTranslatedKeyMessage`. fn key_text(&self, mapping: &str) -> Text { keybinds::display_name(self.settings.keys.get(mapping)) @@ -339,15 +412,21 @@ impl Game<'_> { }); true } + DebugAction::FpsCharts => { + self.debug_charts.toggle_fps(&mut self.debug); + true + } + DebugAction::NetworkCharts => { + self.debug_charts.toggle_network(&mut self.debug); + true + } // Screens and overlays Horizon does not draw yet (the debug - // options screen, the profiler and its charts, the lightmap, the + // options screen, the profiler and its chart, the lightmap, the // texture dump): the keys are used up as in vanilla. DebugAction::DebugOptions | DebugAction::DumpDynamicTextures | DebugAction::Profiling | DebugAction::ProfilingChart - | DebugAction::FpsCharts - | DebugAction::NetworkCharts | DebugAction::LightmapTexture => true, DebugAction::ShowHitboxes | DebugAction::ShowChunkBorders diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 3ec2b9924..6ad981151 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -272,6 +272,7 @@ impl Game<'_> { r.session.disconnect(); } horizon_client::crash::clear_detail("Server"); + self.debug_charts.reset(); self.tab_list.reset(); self.score.board = Default::default(); self.streaming = Streaming::new(Arc::new(NoTerrain), self.res.context.clone(), 1); @@ -736,6 +737,11 @@ impl Game<'_> { fn handle_packet(&mut self, packet: C) -> Result<()> { match packet { + // PingDebugMonitor.onPongReceived. + C::PongResponse(p) => { + let delay = self.debug_millis() - p.time; + self.debug_charts.ping.log(delay); + } C::Login(p) => { if let Some(r) = self.remote.as_mut() { r.online_mode = p.online_mode; diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs index f8dd9d503..ebcdc47b2 100644 --- a/crates/horizon-client/src/lib.rs +++ b/crates/horizon-client/src/lib.rs @@ -44,6 +44,7 @@ pub mod crash; pub mod creative; pub mod data_cmd; pub mod datapack; +pub mod debug_chart; pub mod debug_keys; pub mod effects; pub mod enchant; diff --git a/crates/horizon-protocol/src/packets/play.rs b/crates/horizon-protocol/src/packets/play.rs index 49f75f7f3..1459f7d9f 100644 --- a/crates/horizon-protocol/src/packets/play.rs +++ b/crates/horizon-protocol/src/packets/play.rs @@ -1012,6 +1012,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound { MerchantOffers(MerchantOffers) = MERCHANT_OFFERS, OpenScreen(OpenScreen) = OPEN_SCREEN, Ping(Ping) = PING, + PongResponse(crate::packets::status::Ping) = PONG_RESPONSE, PlayerAbilities(PlayerAbilities) = PLAYER_ABILITIES, PlayerChat(PlayerChat) = PLAYER_CHAT, PlayerCombatKill(PlayerCombatKill) = PLAYER_COMBAT_KILL, @@ -1199,6 +1200,7 @@ packet_enum!(Serverbound, crate::ids::play::serverbound { MovePlayerRot(MovePlayerRot) = MOVE_PLAYER_ROT, MovePlayerStatus(MovePlayerStatus) = MOVE_PLAYER_STATUS_ONLY, PickItemFromBlock(PickItemFromBlock) = PICK_ITEM_FROM_BLOCK, + PingRequest(crate::packets::status::Ping) = PING_REQUEST, PlayerAction(PlayerAction) = PLAYER_ACTION, PlayerCommand(PlayerCommand) = PLAYER_COMMAND, PlayerInput(PlayerInput) = PLAYER_INPUT, @@ -1240,6 +1242,7 @@ mod tests { fn clientbound_round_trips() { let packets = vec![ Clientbound::BundleDelimiter(Empty), + Clientbound::PongResponse(crate::packets::status::Ping { time: 1_234_567 }), Clientbound::SetBorderCenter(SetBorderCenter { x: 1.5, z: -2.0 }), Clientbound::SetBorderLerpSize(SetBorderLerpSize { old_size: 10.0, @@ -1529,6 +1532,7 @@ mod tests { #[test] fn serverbound_round_trips() { let packets = vec![ + Serverbound::PingRequest(crate::packets::status::Ping { time: -7 }), Serverbound::AcceptTeleportation(AcceptTeleportation { id: 1, x: 0.5,