diff --git a/.gitignore b/.gitignore index 6bdae649aa..469760ca63 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ .DS_Store # What the client writes to its working directory when run from the repo root. /options.txt +/debug-profile.json /horizon.txt /horizon-graphics.txt /horizon-controller.txt diff --git a/crates/horizon-client/src/debug_entries.rs b/crates/horizon-client/src/debug_entries.rs new file mode 100644 index 0000000000..8a2b6bd1d9 --- /dev/null +++ b/crates/horizon-client/src/debug_entries.rs @@ -0,0 +1,430 @@ +//! The debug screen's entries (26.3 `DebugScreenEntries`, `DebugScreenEntryList`): +//! what each F3 line block and debug renderer is, whether it shows always, +//! only in the overlay or never, the two built-in profiles, and +//! `debug-profile.json` beside `options.txt`, which keeps them. +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// `DebugScreenEntryStatus`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Status { + AlwaysOn, + InOverlay, + Never, +} + +impl Status { + /// `getSerializedName`. + pub fn name(self) -> &'static str { + match self { + Status::AlwaysOn => "alwaysOn", + Status::InOverlay => "inOverlay", + Status::Never => "never", + } + } + fn parse(name: &str) -> Option { + [Status::AlwaysOn, Status::InOverlay, Status::Never] + .into_iter() + .find(|s| s.name() == name) + } +} + +/// `DebugScreenProfile`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Profile { + Default, + Performance, +} + +impl Profile { + pub fn name(self) -> &'static str { + match self { + Profile::Default => "default", + Profile::Performance => "performance", + } + } + pub fn translation_key(self) -> &'static str { + match self { + Profile::Default => "debug.options.profile.default", + Profile::Performance => "debug.options.profile.performance", + } + } + fn parse(name: &str) -> Option { + [Profile::Default, Profile::Performance] + .into_iter() + .find(|p| p.name() == name) + } + /// `DebugScreenEntries.PROFILES`. + fn statuses(self) -> &'static [(&'static str, Status)] { + use Status::*; + match self { + Profile::Default => &[ + ("3d_crosshair", InOverlay), + ("game_version", InOverlay), + ("tps", InOverlay), + ("fps", InOverlay), + ("memory", InOverlay), + ("system_specs", InOverlay), + ("player_position", InOverlay), + ("player_section_position", InOverlay), + ("simple_performance_impactors", InOverlay), + ], + Profile::Performance => &[ + ("tps", InOverlay), + ("fps", AlwaysOn), + ("gpu_utilization", InOverlay), + ("memory", InOverlay), + ("simple_performance_impactors", InOverlay), + ], + } + } +} + +/// `DebugEntryCategory`: the screen's two sections, by `sortKey`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Category { + /// `SCREEN_TEXT`, `debug.options.category.text`. + Text, + /// `RENDERER` (`DebugEntryNoop`), `debug.options.category.renderer`. + Renderer, +} + +impl Category { + pub fn translation_key(self) -> &'static str { + match self { + Category::Text => "debug.options.category.text", + Category::Renderer => "debug.options.category.renderer", + } + } +} + +/// One registered entry: its path in the `minecraft` namespace, its section, +/// and whether it shows while debug info is reduced (`isAllowed(true)`). +#[derive(Clone, Copy, Debug)] +pub struct Entry { + pub path: &'static str, + pub category: Category, + pub reduced: bool, +} + +const fn text(path: &'static str, reduced: bool) -> Entry { + Entry { + path, + category: Category::Text, + reduced, + } +} +const fn renderer(path: &'static str) -> Entry { + Entry { + path, + category: Category::Renderer, + reduced: false, + } +} + +/// `DebugScreenEntries`' registrations, in their order. +pub const ENTRIES: [Entry; 46] = [ + text("game_version", true), + text("fps", true), + text("tps", true), + text("memory", true), + text("detailed_memory", true), + text("system_specs", true), + text("looking_at_block_state", false), + text("looking_at_block_tags", false), + text("looking_at_fluid_state", false), + text("looking_at_fluid_tags", false), + text("looking_at_entity", false), + text("looking_at_entity_tags", false), + text("chunk_render_stats", true), + text("chunk_generation_stats", false), + text("entity_render_stats", true), + text("particle_render_stats", false), + text("chunk_source_stats", true), + text("player_position", false), + text("player_section_position", true), + text("player_speed", false), + text("light_levels", false), + text("heightmap", false), + text("biome", false), + text("local_difficulty", false), + text("day_count", false), + text("entity_spawn_counts", false), + text("sound_mood", false), + text("sound_cache", true), + text("post_effects", false), + renderer("entity_hitboxes"), + renderer("chunk_borders"), + renderer("3d_crosshair"), + renderer("chunk_section_paths"), + text("gpu_utilization", true), + text("simple_performance_impactors", true), + renderer("chunk_section_octree"), + renderer("visualize_water_levels"), + renderer("visualize_heightmap"), + renderer("visualize_collision_boxes"), + renderer("visualize_entity_supporting_blocks"), + renderer("visualize_block_light_levels"), + renderer("visualize_sky_light_levels"), + renderer("visualize_solid_faces"), + renderer("visualize_chunks_on_server"), + renderer("visualize_sky_light_sections"), + renderer("chunk_section_visibility"), +]; + +/// `DebugScreenEntries.getEntry` by path. +pub fn entry(path: &str) -> Option<&'static Entry> { + ENTRIES.iter().find(|e| e.path == path) +} + +/// `DebugOptionsScreen.OptionList.updateSearch`: the entries whose path +/// contains `search`, by category then identifier. +pub fn search(search: &str) -> Vec<&'static Entry> { + let mut all: Vec<&Entry> = ENTRIES.iter().filter(|e| e.path.contains(search)).collect(); + all.sort_by(|a, b| a.category.cmp(&b.category).then(a.path.cmp(b.path))); + all +} + +/// `DebugScreenEntryList`: every entry's status (by full identifier, as the +/// file keeps them) and the profile they came from if unchanged. Whether the +/// F3 overlay is open (`isOverlayVisible`) is the game's `debug`. +#[derive(Clone, Debug)] +pub struct DebugEntries { + statuses: BTreeMap, + profile: Option, + file: Option, +} + +impl Default for DebugEntries { + fn default() -> Self { + let mut entries = Self { + statuses: BTreeMap::new(), + profile: None, + file: None, + }; + entries.reset_to_profile(Profile::Default); + entries + } +} + +fn identifier(path: &str) -> String { + format!("minecraft:{path}") +} + +impl DebugEntries { + /// `DebugScreenEntryList.load` from `debug-profile.json` in `dir`: the + /// default profile without a file; a file that does not parse is reset + /// to it and saved again. + pub fn load(dir: &Path) -> Self { + let file = dir.join("debug-profile.json"); + let mut entries = Self { + file: Some(file.clone()), + ..Self::default() + }; + let Ok(text) = std::fs::read_to_string(&file) else { + return entries; + }; + match Self::parse(&text) { + Some((Some(profile), _)) => entries.reset_to_profile(profile), + Some((None, custom)) => { + entries.statuses = custom; + entries.profile = None; + } + None => { + log::error!( + "Couldn't read debug profile file {}, resetting to default", + file.display() + ); + entries.save(); + } + } + entries + } + /// `SerializedOptions.CODEC`: an optional `profile` name and an optional + /// `custom` map of identifiers to statuses. + fn parse(text: &str) -> Option<(Option, BTreeMap)> { + let json: serde_json::Value = serde_json::from_str(text).ok()?; + let object = json.as_object()?; + let profile = match object.get("profile") { + Some(name) => Some(Profile::parse(name.as_str()?)?), + None => None, + }; + let mut custom = BTreeMap::new(); + if let Some(map) = object.get("custom") { + for (key, status) in map.as_object()? { + // Identifier.CODEC: a bare path is in the minecraft namespace. + let key = if key.contains(':') { + key.clone() + } else { + identifier(key) + }; + custom.insert(key, Status::parse(status.as_str()?)?); + } + } + Some((profile, custom)) + } + /// `DebugScreenEntryList.save`: the profile's name while it is unchanged, + /// else every status, with the data version the codec wraps it in. + pub fn save(&self) { + let Some(file) = &self.file else { + return; + }; + let mut json = serde_json::Map::new(); + json.insert( + "DataVersion".into(), + horizon_core::version::DATA_VERSION.into(), + ); + match self.profile { + Some(profile) => { + json.insert("profile".into(), profile.name().into()); + } + None => { + let custom: serde_json::Map = self + .statuses + .iter() + .map(|(k, s)| (k.clone(), s.name().into())) + .collect(); + json.insert("custom".into(), custom.into()); + } + } + if let Err(e) = std::fs::write(file, serde_json::Value::Object(json).to_string()) { + log::error!("Failed to save debug profile file {}: {e}", file.display()); + } + } + fn reset_to_profile(&mut self, profile: Profile) { + self.profile = Some(profile); + self.statuses = profile + .statuses() + .iter() + .map(|(path, status)| (identifier(path), *status)) + .collect(); + } + /// `loadProfile` then `save` (the screen's profile buttons). + pub fn load_profile(&mut self, profile: Profile) { + self.reset_to_profile(profile); + self.save(); + } + pub fn is_using_profile(&self, profile: Profile) -> bool { + self.profile == Some(profile) + } + /// `getStatus`: never for an entry the statuses leave out. + pub fn status(&self, path: &str) -> Status { + self.statuses + .get(&identifier(path)) + .copied() + .unwrap_or(Status::Never) + } + /// `setStatus`: the statuses are custom from now on, and saved. + pub fn set_status(&mut self, path: &str, status: Status) { + self.profile = None; + self.statuses.insert(identifier(path), status); + self.save(); + } + /// `toggleStatus` (F3+B, F3+G) with the overlay open or not: whether the + /// entry is on afterwards. + pub fn toggle_status(&mut self, path: &str, overlay: bool) -> bool { + let (status, on) = match self.statuses.get(&identifier(path)) { + Some(Status::AlwaysOn) => (Status::Never, false), + Some(Status::InOverlay) if overlay => (Status::Never, false), + Some(Status::Never) if overlay => (Status::InOverlay, true), + _ => (Status::AlwaysOn, true), + }; + self.set_status(path, status); + on + } + /// `isCurrentlyEnabled` (`rebuildCurrentList`): on always, or in the + /// `overlay` while it is open, and allowed with the debug info `reduced`. + pub fn enabled(&self, path: &str, overlay: bool, reduced: bool) -> bool { + let shown = match self.status(path) { + Status::AlwaysOn => true, + Status::InOverlay => overlay, + Status::Never => false, + }; + shown && entry(path).is_some_and(|e| e.reduced || !reduced) + } + /// `getCurrentlyEnabled().isEmpty()` negated: something to draw. + pub fn any_enabled(&self, overlay: bool, reduced: bool) -> bool { + ENTRIES + .iter() + .any(|e| self.enabled(e.path, overlay, reduced)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn toggling_follows_the_overlay() { + let mut e = DebugEntries::default(); + // Not in the default profile: on always while F3 is closed. + assert!(e.toggle_status("entity_hitboxes", false)); + assert_eq!(e.status("entity_hitboxes"), Status::AlwaysOn); + assert!(!e.toggle_status("entity_hitboxes", false)); + assert_eq!(e.status("entity_hitboxes"), Status::Never); + // With F3 open, a hidden entry goes in the overlay only. + assert!(e.toggle_status("chunk_borders", true)); + assert_eq!(e.status("chunk_borders"), Status::AlwaysOn); + assert!(!e.toggle_status("chunk_borders", true)); + assert_eq!(e.status("chunk_borders"), Status::Never); + assert!(e.toggle_status("chunk_borders", true)); + assert_eq!(e.status("chunk_borders"), Status::InOverlay); + assert!(!e.toggle_status("chunk_borders", true)); + assert!(!e.is_using_profile(Profile::Default)); + } + + #[test] + fn enabled_needs_the_overlay_and_full_debug_info() { + let mut e = DebugEntries::default(); + assert!(!e.any_enabled(false, false)); + assert!(e.enabled("player_position", true, false)); + // Reduced debug info hides the position, not the section. + assert!(!e.enabled("player_position", true, true)); + assert!(e.enabled("player_section_position", true, true)); + e.load_profile(Profile::Performance); + assert!(e.enabled("fps", false, false)); + assert!(!e.enabled("memory", false, false)); + } + + #[test] + fn the_file_keeps_a_profile_or_the_statuses() { + let dir = + std::env::temp_dir().join(format!("horizon-debug-profile-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut e = DebugEntries::load(&dir); + assert!(e.is_using_profile(Profile::Default)); + e.load_profile(Profile::Performance); + let saved: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(dir.join("debug-profile.json")).unwrap()) + .unwrap(); + assert_eq!(saved["profile"], "performance"); + assert!(DebugEntries::load(&dir).is_using_profile(Profile::Performance)); + e.set_status("biome", Status::AlwaysOn); + let loaded = DebugEntries::load(&dir); + assert_eq!(loaded.status("biome"), Status::AlwaysOn); + assert_eq!(loaded.status("fps"), Status::AlwaysOn); + assert!(!loaded.is_using_profile(Profile::Performance)); + // A broken file is reset to the default profile. + std::fs::write(dir.join("debug-profile.json"), "{\"profile\":\"nope\"}").unwrap(); + assert!(DebugEntries::load(&dir).is_using_profile(Profile::Default)); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn the_list_sorts_text_before_renderers() { + let all = search(""); + assert_eq!(all.len(), 46); + assert_eq!(all[0].path, "biome"); + let first_renderer = all + .iter() + .position(|e| e.category == Category::Renderer) + .unwrap(); + assert_eq!(all[first_renderer].path, "3d_crosshair"); + assert!( + all[first_renderer..] + .iter() + .all(|e| e.category == Category::Renderer) + ); + assert_eq!(search("chunk_b").len(), 1); + } +} diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 8eee48d6c1..8dbbb2ccd3 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -87,6 +87,7 @@ mod creatures; mod crossbow; pub(crate) mod cushions; mod debug_keys; +mod debug_options; mod debug_stick; mod decorated_pot; mod dedicated; @@ -703,6 +704,8 @@ enum Screen { DialogWarning, /// WinScreen: the end poem and the credits (game/win.rs). Win, + /// DebugOptionsScreen, F3+F6 (game/debug_options.rs). + DebugOptions, } struct Mining { pos: [i32; 3], @@ -2284,8 +2287,11 @@ struct Game<'a> { 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, + /// `Minecraft.debugEntries`: what the overlay and the debug renderers + /// show (`debug-profile.json`, F3+F6). + debug_entries: horizon_client::debug_entries::DebugEntries, + /// `DebugOptionsScreen` while it is open (game/debug_options.rs). + debug_options: Option, /// PauseScreen.showPauseMenu (false after F3+Escape). pause_menu: bool, /// MultiPlayerGameMode.previousLocalPlayerMode (F3+N goes back to it). @@ -6707,6 +6713,7 @@ impl Game<'_> { | Screen::ConfirmLink | Screen::DialogWarning | Screen::CommandBlock + | Screen::DebugOptions ) || self.book.is_some() || self.win_screen_pauses()) } @@ -7363,6 +7370,7 @@ impl Game<'_> { Screen::DialogWarning => self.draw_dialog_warning(&mut batch), Screen::CommandBlock => self.draw_command_block(&mut batch), Screen::BlockEditor => self.draw_block_editor(&mut batch), + Screen::DebugOptions => self.draw_debug_options(&mut batch), Screen::Inventory => { self.draw_inventory(&mut batch); self.draw_mod_browser(&mut batch); @@ -13870,6 +13878,7 @@ impl Game<'_> { Screen::BlockEditor => self.close_block_editor(), Screen::Chat => self.close_chat(chat::ChatExit::Intentional), Screen::Win => self.close_win_screen()?, + Screen::DebugOptions => self.close_debug_options(), }, KeyCode::Digit1 | KeyCode::Digit2 @@ -14743,6 +14752,9 @@ impl ApplicationHandler for Game<'_> { if self.screen == Screen::ServerLinks && d != 0.0 { self.server_links_scroll(d); } + if self.screen == Screen::DebugOptions && d != 0.0 { + self.debug_options_scroll(d); + } if self.screen == Screen::Advancements { self.advancements_scroll(scaled(raw_x) as f32, d); } @@ -14940,6 +14952,9 @@ impl ApplicationHandler for Game<'_> { Screen::ServerLinks if pressed && button == MouseButton::Left => { self.server_links_click()? } + Screen::DebugOptions if pressed && button == MouseButton::Left => { + self.debug_options_click() + } Screen::ConfirmLink if pressed && button == MouseButton::Left => { self.confirm_link_click() } @@ -15110,6 +15125,9 @@ impl ApplicationHandler for Game<'_> { WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::CommandBlock => { self.command_block_key(&event) } + WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::DebugOptions => { + self.debug_options_key(&event) + } WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::BlockEditor => { self.block_editor_key(&event) } @@ -15937,8 +15955,10 @@ impl<'a> Game<'a> { friends: None, debug_keys: Default::default(), debug_charts: Default::default(), - show_hitboxes: false, - show_chunk_borders: false, + debug_entries: horizon_client::debug_entries::DebugEntries::load( + &std::env::current_dir().unwrap_or_default(), + ), + debug_options: None, pause_menu: true, previous_mode: None, gamemode_switcher: None, diff --git a/crates/horizon-client/src/game/agent.rs b/crates/horizon-client/src/game/agent.rs index a62c76cd9d..94194f0bb7 100644 --- a/crates/horizon-client/src/game/agent.rs +++ b/crates/horizon-client/src/game/agent.rs @@ -424,6 +424,7 @@ impl Game<'_> { Screen::CommandBlock => "command-block", Screen::BlockEditor => "block-editor", Screen::Win => "win", + Screen::DebugOptions => "debug-options", } } pub(super) fn inventory_counts(&self) -> BTreeMap { diff --git a/crates/horizon-client/src/game/control.rs b/crates/horizon-client/src/game/control.rs index 06616f5dbb..2ccac7ddf1 100644 --- a/crates/horizon-client/src/game/control.rs +++ b/crates/horizon-client/src/game/control.rs @@ -630,6 +630,7 @@ impl Game<'_> { Screen::Chat => self.chat_type(text), Screen::CommandBlock => self.command_block_type(text), Screen::BlockEditor => self.block_editor_type(text), + Screen::DebugOptions => self.debug_options_type(text), Screen::Options => self.ui.options.type_text(&self.gui.atlas, text), // The port, or the game rules' search and number boxes. Screen::WorldOptions if text == "\\b" => self.world_options_text(None), @@ -909,6 +910,7 @@ impl Game<'_> { Screen::CommandBlock => "command-block", Screen::BlockEditor => "block-editor", Screen::Win => "win", + Screen::DebugOptions => "debug-options", } } @@ -1040,8 +1042,8 @@ impl Game<'_> { "screen": self.screen_name(), "debug": { "overlay": self.debug, - "hitboxes": self.show_hitboxes, - "chunk_borders": self.show_chunk_borders, + "hitboxes": self.debug_entry("entity_hitboxes"), + "chunk_borders": self.debug_entry("chunk_borders"), "pause_menu": self.pause_menu, "advanced_tooltips": self.settings.file.bool("advancedItemTooltips"), "pause_on_lost_focus": self.settings.file.bool("pauseOnLostFocus"), diff --git a/crates/horizon-client/src/game/debug_keys.rs b/crates/horizon-client/src/game/debug_keys.rs index 5be8f01a09..fd5c0cea87 100644 --- a/crates/horizon-client/src/game/debug_keys.rs +++ b/crates/horizon-client/src/game/debug_keys.rs @@ -278,14 +278,26 @@ impl Game<'_> { format!("To edit: press {}", key("key.debug.debugOptions")), ] } - /// `DebugScreenOverlay.extractRenderState` with the default profile's - /// entries, over everything the frame drew (menus too), at the debug GUI - /// scale. F1 hides it unless a screen is open. + /// `Gui.extractRenderState`'s debug overlay, over everything the frame + /// drew (menus too); the debug options screen draws it under itself. pub(super) fn draw_debug_overlay(&self, batch: &mut HudBatch, size: [u32; 2]) { + if self.screen != Screen::DebugOptions { + self.draw_debug_overlay_under(batch, size); + } + } + /// `DebugScreenOverlay.extractRenderState`: the enabled entries' lines + /// (`debug-profile.json`), at the debug GUI scale, and the charts while + /// the overlay is open. F1 hides it unless a screen is open. + pub(super) fn draw_debug_overlay_under(&self, batch: &mut HudBatch, size: [u32; 2]) { let screen_open = self.screen != Screen::Playing; - if self.title.is_some() || !self.debug || (self.ui.hide_gui && !screen_open) { + let reduced = self.reduced_debug_info(); + if self.title.is_some() + || !self.debug_entries.any_enabled(self.debug, reduced) + || (self.ui.hide_gui && !screen_open) + { return; } + let on = |path: &str| self.debug_entries.enabled(path, self.debug, reduced); let framebuffer = size.map(|v| v as i32); let (scale, [width, height]) = debug_overlay::scale( framebuffer, @@ -296,6 +308,83 @@ impl Game<'_> { ); let mut d = debug_overlay::Displayer::default(); // fps: DebugEntryFps. + if on("fps") { + self.debug_fps_line(&mut d); + } + // game_version: DebugEntryVersion (the launched version and brand). + if on("game_version") { + d.priority(format!("Minecraft {VERSION_NAME} ({VERSION_NAME}/horizon)")); + } + // player_position, player_section_position. + if let Some(p) = self.player.as_ref() { + let mut lines = debug_overlay::position( + [p.position.x, p.position.y, p.position.z], + self.yaw, + self.pitch, + self.dim.id(), + self.streaming.forced.len(), + ); + let section = lines.pop().unwrap_or_default(); + if on("player_position") { + d.group("position", lines); + } + if on("player_section_position") { + d.group("position", [section]); + } + } + if on("simple_performance_impactors") { + self.debug_performance_lines(&mut d); + } + if on("system_specs") { + self.debug_system_lines(&mut d, framebuffer); + } + // Horizon's own: the shader pack's GPU time per pass (an empty group would move others). + let pass_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| { + debug_overlay::shader_pass_lines(&r.shader_pass_times()) + }); + if self.debug && !pass_lines.is_empty() { + d.group("shader pack", pass_lines); + } + if on("tps") { + self.debug_tps_line(&mut d); + } + // The chart keys and the charts only while the overlay is open. + let footer = if self.debug { + self.debug_chart_lines().to_vec() + } else { + Vec::new() + }; + let (left, right) = d.columns(&footer); + let atlas = &self.gui.atlas; + let mut draws = debug_overlay::draw(&atlas.font, [&left, &right], scale, width); + if self.debug { + let charts = self.debug_charts.draw( + &atlas.font, + width, + height, + self.settings.max_fps, + 1000.0 / self.clock.rate(), + !self.has_singleplayer_server(), + ); + draws.push(hud_layout::GuiDraw::Push(vec![hud_layout::PoseOp::Scale( + scale, scale, + )])); + draws.extend(charts); + if self.debug_charts.lightmap + && let Some(r) = &self.renderer + { + draws.extend(horizon_client::debug_chart::lightmap( + &r.lightmap_pixels, + width, + height, + )); + } + draws.push(hud_layout::GuiDraw::Pop); + } + hud_layout::paint(batch, atlas, &draws); + } + /// `DebugEntryFps`. + fn debug_fps_line(&self, d: &mut debug_overlay::Displayer) { let limit = self.framerate_limit(); let present = self .renderer @@ -324,24 +413,9 @@ impl Game<'_> { limit.to_string() }, )); - // game_version: DebugEntryVersion (the launched version and brand). - d.priority(format!("Minecraft {VERSION_NAME} ({VERSION_NAME}/horizon)")); - // player_position, player_section_position. - if let Some(p) = self.player.as_ref() { - let mut lines = debug_overlay::position( - [p.position.x, p.position.y, p.position.z], - self.yaw, - self.pitch, - self.dim.id(), - self.streaming.forced.len(), - ); - let section = lines.pop().unwrap_or_default(); - if !self.reduced_debug_info() { - d.group("position", lines); - } - d.group("position", [section]); - } - // simple_performance_impactors. + } + /// `DebugEntrySimplePerformanceImpactors`. + fn debug_performance_lines(&self, d: &mut debug_overlay::Displayer) { let s = &self.settings; d.line(format!( "{}{}B: {}", @@ -366,7 +440,9 @@ impl Game<'_> { "naive" } )); - // system_specs (no Java line: Horizon has no JVM). + } + /// `DebugEntrySystemSpecs` (no Java line: Horizon has no JVM). + fn debug_system_lines(&self, d: &mut debug_overlay::Displayer, framebuffer: [i32; 2]) { if let Some(r) = self.renderer.as_ref() { static CPU: std::sync::OnceLock = std::sync::OnceLock::new(); d.group( @@ -432,14 +508,9 @@ impl Game<'_> { { d.group("system", [r.hdr_line()]); } - // Horizon's own: the shader pack's GPU time per pass (an empty group would move others). - let pass_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| { - debug_overlay::shader_pass_lines(&r.shader_pass_times()) - }); - if !pass_lines.is_empty() { - d.group("shader pack", pass_lines); - } - // tps: DebugEntryTps for local and remote connections. + } + /// `DebugEntryTps` for local and remote connections. + fn debug_tps_line(&self, d: &mut debug_overlay::Displayer) { if self.player.is_some() { let [tx, rx] = self.debug_charts.packet_rates.map(|n| n.round() as i64); let (target, status) = if self.clock.sprinting() { @@ -471,32 +542,6 @@ impl Game<'_> { )); } } - let (left, right) = d.columns(&self.debug_chart_lines()); - let atlas = &self.gui.atlas; - let mut draws = debug_overlay::draw(&atlas.font, [&left, &right], scale, width); - let charts = self.debug_charts.draw( - &atlas.font, - width, - height, - self.settings.max_fps, - 1000.0 / self.clock.rate(), - !self.has_singleplayer_server(), - ); - draws.push(hud_layout::GuiDraw::Push(vec![hud_layout::PoseOp::Scale( - scale, scale, - )])); - draws.extend(charts); - if self.debug_charts.lightmap - && let Some(r) = &self.renderer - { - draws.extend(horizon_client::debug_chart::lightmap( - &r.lightmap_pixels, - width, - height, - )); - } - draws.push(hud_layout::GuiDraw::Pop); - hud_layout::paint(batch, atlas, &draws); } /// `KeyMapping.getTranslatedKeyMessage`. fn key_text(&self, mapping: &str) -> Text { @@ -525,13 +570,17 @@ impl Game<'_> { self.debug_feedback(Text::translate(key, &[])); } /// `LocalPlayer.isReducedDebugInfo`: the server's rule or the option. - fn reduced_debug_info(&self) -> bool { + pub(super) fn reduced_debug_info(&self) -> bool { self.settings.reduced_debug_info || self.ui.rules.bool("reduced_debug_info") } - /// The default profile's `3d_crosshair` entry: on while the overlay is - /// open, unless debug info is reduced. + /// `DebugScreenEntryList.isCurrentlyEnabled` for an entry's path. + pub(super) fn debug_entry(&self, path: &str) -> bool { + self.debug_entries + .enabled(path, self.debug, self.reduced_debug_info()) + } + /// The `3d_crosshair` entry. pub(super) fn debug_crosshair(&self) -> bool { - self.debug && !self.reduced_debug_info() + self.debug_entry("3d_crosshair") } /// `Permissions.COMMANDS_GAMEMASTER` (`GameModeCommand.PERMISSION_CHECK`). pub(super) fn gamemaster(&self) -> bool { @@ -555,8 +604,10 @@ impl Game<'_> { true } DebugAction::ShowHitboxes if player => { - self.show_hitboxes = !self.show_hitboxes; - self.debug_translated(if self.show_hitboxes { + let on = self + .debug_entries + .toggle_status("entity_hitboxes", self.debug); + self.debug_translated(if on { "debug.show_hitboxes.on" } else { "debug.show_hitboxes.off" @@ -569,8 +620,10 @@ impl Game<'_> { true } DebugAction::ShowChunkBorders if player => { - self.show_chunk_borders = !self.show_chunk_borders; - self.debug_translated(if self.show_chunk_borders { + let on = self + .debug_entries + .toggle_status("chunk_borders", self.debug); + self.debug_translated(if on { "debug.chunk_boundaries.on" } else { "debug.chunk_boundaries.off" @@ -674,11 +727,13 @@ impl Game<'_> { self.debug_charts.toggle_lightmap(&mut self.debug); true } - // Screens and overlays Horizon does not draw yet (the debug - // options screen, the profiler and its chart, the texture dump): - // the keys are used up as in vanilla. - DebugAction::DebugOptions - | DebugAction::DumpDynamicTextures + DebugAction::DebugOptions => { + self.toggle_debug_options(); + true + } + // Screens and overlays Horizon does not draw yet (the profiler and + // its chart, the texture dump): the keys are used up as in vanilla. + DebugAction::DumpDynamicTextures | DebugAction::Profiling | DebugAction::ProfilingChart => true, DebugAction::ShowHitboxes @@ -859,7 +914,7 @@ impl Game<'_> { pub(super) fn debug_gizmos(&self, alpha: f32, camera: [f64; 3]) -> Gizmos { let mut g = Gizmos::default(); self.bounding_box_gizmos(camera, &mut g); - if self.show_chunk_borders + if self.debug_entry("chunk_borders") && let Some(p) = &self.player { let block = [ @@ -870,7 +925,7 @@ impl Game<'_> { let min_y = self.world.bottom_y(); g.chunk_borders(block, min_y, min_y + self.world.layout().height() - 1); } - if !self.show_hitboxes { + if !self.debug_entry("entity_hitboxes") { return g; } let lerp = |a: f64, b: f64| a + f64::from(alpha) * (b - a); diff --git a/crates/horizon-client/src/game/debug_options.rs b/crates/horizon-client/src/game/debug_options.rs new file mode 100644 index 0000000000..061514ac14 --- /dev/null +++ b/crates/horizon-client/src/game/debug_options.rs @@ -0,0 +1,466 @@ +//! `DebugOptionsScreen` (F3+F6): every debug entry with Off / In Overlay / +//! Always buttons, a search box over them, the two built-in profiles and the +//! debug overlay's GUI scale. The statuses live in `debug-profile.json` +//! (`horizon_client::debug_entries`). +//! https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/debug/DebugOptionsScreen +use super::{Game, Screen}; +use horizon_client::{ + debug_entries::{Category, Entry, Profile, Status, search}, + hud::HudBatch, + options_file::V, + text::Text, + ui::{self, EditBox, Scroll}, +}; +use winit::event::{ElementState, KeyEvent}; +use winit::keyboard::{KeyCode, PhysicalKey}; + +/// `HeaderAndFooterLayout(this, 61, 33)`. +const HEADER: f32 = 61.0; +const FOOTER: f32 = 33.0; +/// `OptionList.getRowWidth` and `ITEM_HEIGHT`. +const ROW_WIDTH: f32 = 350.0; +const ITEM: f32 = 20.0; +/// `OptionEntry.BUTTON_WIDTH`, at the CycleButtons' 16 pixels high. +const BUTTON: f32 = 60.0; +/// The footer: two profile buttons, the GUI scale and Done, 4 pixels apart. +const FOOTER_BUTTONS: [f32; 4] = [120.0, 120.0, 110.0, 60.0]; +/// `debug.options.warning`'s colour (-2142128) and the buttons' text colours. +const WARNING: u32 = 0xDF_5050; +const UNSELECTED: u32 = 0xBA_BABA; +const OVERLAY: u32 = 0xFF_FF55; +const NOT_ALLOWED: u32 = 0x80_8080; +/// `EditBox.SEARCH_HINT_STYLE`: `ChatFormatting.GRAY`. +const HINT: u32 = 0xAA_AAAA; + +/// The open screen: its search box and the list's scroll. +pub(super) struct DebugOptionsUi { + search: EditBox, + scroll: Scroll, +} + +impl DebugOptionsUi { + pub(super) fn search_focused(&self) -> bool { + self.search.focused + } +} + +/// One list row (`AbstractOptionEntry`). +enum Row { + Category(Category), + Option(&'static Entry), +} + +/// `OptionList.updateSearch`: a category row before each category's entries. +fn rows(value: &str) -> Vec { + let mut rows = Vec::new(); + let mut current = None; + for entry in search(value) { + if current != Some(entry.category) { + rows.push(Row::Category(entry.category)); + current = Some(entry.category); + } + rows.push(Row::Option(entry)); + } + rows +} + +/// `AbstractSelectionList.contentHeight`. +fn content_height(n: usize) -> f32 { + n as f32 * ITEM + 4.0 +} +/// `getRowLeft`: the list spans the screen, its rows centred. +fn row_left(w: f32) -> f32 { + (w / 2.0).floor() - ROW_WIDTH / 2.0 +} +/// The three buttons' left edges (Off, In Overlay, Always) for a row whose +/// content starts at `x` (`extractContent`). +fn button_xs(content_x: f32) -> [f32; 3] { + let start = content_x + ROW_WIDTH - 4.0 - 3.0 * BUTTON; + [start, start + BUTTON, start + 2.0 * BUTTON] +} +/// The footer buttons' left edges and their row's top. +fn footer(w: f32, h: f32) -> ([f32; 4], f32) { + let total: f32 = FOOTER_BUTTONS.iter().sum::() + 3.0 * 4.0; + let mut x = ((w - total) / 2.0).floor(); + let mut xs = [0.0; 4]; + for (slot, width) in xs.iter_mut().zip(FOOTER_BUTTONS) { + *slot = x; + x += width + 4.0; + } + (xs, h - FOOTER + ((FOOTER - 20.0) / 2.0).floor()) +} +/// The three buttons' statuses and texts, in their order on the row. +const BUTTONS: [(Status, &str, u32); 3] = [ + (Status::Never, "options.off", 0xFF_FFFF), + (Status::InOverlay, "debug.entry.overlay", OVERLAY), + (Status::AlwaysOn, "debug.entry.always", WARNING), +]; + +impl Game<'_> { + /// `KeyboardHandler`'s F3+F6: closes the screen if it is open, else opens + /// it over the game or the pause menu (`canInterruptScreen`). + pub(super) fn toggle_debug_options(&mut self) { + match self.screen { + Screen::DebugOptions => self.close_debug_options(), + Screen::Playing | Screen::Paused if !self.screen_over_playing() => { + let mut search = EditBox::new(0.0, 0.0, (ROW_WIDTH / 3.0).floor(), 20.0); + search.max_length = 32; + self.debug_options = Some(DebugOptionsUi { + search, + scroll: Scroll::default(), + }); + self.screen = Screen::DebugOptions; + self.pause_menu = true; + self.capture(false); + self.mining = None; + } + _ => {} + } + } + /// `Screen.onClose`: back to the game. + pub(super) fn close_debug_options(&mut self) { + self.debug_options = None; + self.screen = Screen::Playing; + self.capture(true); + } + /// The header's title row and warning (`init`'s vertical layout, + /// centred in the header): the title's x, the row's top, the warning's + /// lines with their y. + fn debug_options_header(&self, w: f32) -> (f32, f32, Vec<(Text, f32)>) { + let font = &self.gui.atlas.font; + let title = Text::translate("debug.options.title", &[]); + let warning = Text::translate("debug.options.warning", &[]).with_color(WARNING); + let lines = font.split(&warning, ROW_WIDTH); + let height = 20.0 + 8.0 + lines.len() as f32 * 9.0; + let top = ((HEADER - height) / 2.0).floor(); + let third = (ROW_WIDTH / 3.0).floor(); + let tw = font.width_text(&title); + let row = third + 8.0 + tw + 8.0 + third; + let title_x = ((w - row) / 2.0).floor() + third + 8.0; + let lines = lines + .into_iter() + .enumerate() + .map(|(i, line)| (line, top + 28.0 + i as f32 * 9.0)) + .collect(); + (title_x, top, lines) + } + /// `debugGuiScale`'s button: `GUI Scale: Unchanged`, `Auto` or the scale. + fn debug_gui_scale_label(&self) -> Text { + let value = match self.settings.file.int("debugGuiScale") { + -1 => Text::translate("options.debugGuiScale.unchanged", &[]), + 0 => Text::translate("options.guiScale.auto", &[]), + n => Text::literal(n.to_string()), + }; + Text::translate( + "options.generic_value", + &[Text::translate("options.guiScale", &[]), value], + ) + } + /// `ClampingLazyMaxIntRange(-1, window.calculateScale(0, unicode))` as a + /// CycleButton: the next value, or the previous one with Shift. + fn cycle_debug_gui_scale(&mut self, back: bool) { + let size = self.renderer.as_ref().map_or([1280, 720], |r| { + [r.config.width as i32, r.config.height as i32] + }); + let max = horizon_client::window::calculate_scale(size, 0, self.settings.force_unicode); + let current = self.settings.file.int("debugGuiScale").clamp(-1, max); + let next = match (back, current) { + (false, c) if c >= max => -1, + (false, c) => c + 1, + (true, -1) => max, + (true, c) => c - 1, + }; + self.settings.file.set("debugGuiScale", V::Int(next)); + self.settings.save(&Self::options_path()); + } + pub(super) fn draw_debug_options(&mut self, batch: &mut HudBatch) { + let Some(mut s) = self.debug_options.take() else { + return; + }; + let (w, h) = (batch.width, batch.height); + // extractBlurredBackground: the debug overlay goes under the screen. + let size = self + .renderer + .as_ref() + .map_or([w as u32, h as u32], |r| [r.config.width, r.config.height]); + self.draw_debug_overlay_under(batch, size); + let atlas = &self.gui.atlas; + ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true); + let reduced = self.reduced_debug_info(); + let rows = rows(&s.search.value); + let (top, bottom) = (HEADER, h - FOOTER); + let content = content_height(rows.len()); + s.scroll.clamp(top, bottom, content); + // The list: background, rows clipped to it, separators, scrollbar. + ui::list_background(batch, atlas, top, bottom, true); + let first = batch.vertices.len(); + let left = row_left(w); + let inside = self.cursor.1 >= top && self.cursor.1 < bottom; + let mut tooltip = None; + for (i, row) in rows.iter().enumerate() { + let y = top + 2.0 - s.scroll.amount.trunc() + i as f32 * ITEM; + if y + ITEM < top || y > bottom { + continue; + } + let (cx, cy) = (left + 2.0, y + 2.0); + match row { + Row::Category(category) => { + let label = Text::translate(category.translation_key(), &[]) + .bold() + .underlined(); + let lw = atlas.font.width_text(&label); + let lx = (cx + (ROW_WIDTH - 4.0) / 2.0 - (lw + 8.0) / 2.0).floor() + 4.0; + batch.text_styled(atlas, &label, lx, cy + 4.0, u32::MAX, true); + } + Row::Option(entry) => { + let allowed = entry.reduced || !reduced; + let name = if allowed { + Text::literal(entry.path) + } else { + Text::literal(entry.path).italic().with_color(NOT_ALLOWED) + }; + batch.text_styled(atlas, &name, cx, cy + 5.0, u32::MAX, true); + let xs = button_xs(cx); + let status = self.debug_entries.status(entry.path); + for ((status_of, key, color), x) in BUTTONS.into_iter().zip(xs) { + let selected = status == status_of; + let label = Text::translate(key, &[]).with_color(if selected { + color + } else { + UNSELECTED + }); + let hover = inside && ui::hovered(self.cursor, x, cy, BUTTON, 16.0); + ui::button(batch, atlas, x, cy, BUTTON, 16.0, &label, !selected, hover); + } + if !allowed && inside && ui::hovered(self.cursor, cx, y, xs[0] - cx, ITEM) { + tooltip = Some(Text::translate("debug.options.notAllowed.tooltip", &[])); + } + } + } + } + batch.clip_since(first, 0.0, top, w, bottom); + ui::separators(batch, atlas, top, bottom, true); + ui::scrollbar( + batch, + atlas, + left + ROW_WIDTH + 8.0, + top, + bottom, + content, + s.scroll.amount.trunc(), + ); + // The header: title, search box, warning. + let (title_x, header_top, lines) = self.debug_options_header(w); + let title = Text::translate("debug.options.title", &[]); + batch.text_styled(atlas, &title, title_x, header_top + 5.0, u32::MAX, true); + s.search.x = title_x + atlas.font.width_text(&title) + 8.0; + s.search.y = header_top; + s.search.draw(batch, atlas, None); + if s.search.value.is_empty() { + let hint = Text::translate("debug.options.search", &[]) + .with_color(HINT) + .italic(); + batch.text_styled( + atlas, + &hint, + s.search.text_x(), + s.search.text_y(), + u32::MAX, + true, + ); + } + for (line, y) in &lines { + let lw = atlas.font.width_text(line); + batch.text_styled(atlas, line, ((w - lw) / 2.0).floor(), *y, u32::MAX, true); + } + // The footer. + let (xs, fy) = footer(w, h); + let labels = [ + Text::translate(Profile::Default.translation_key(), &[]), + Text::translate(Profile::Performance.translation_key(), &[]), + self.debug_gui_scale_label(), + Text::translate("gui.done", &[]), + ]; + let active = [ + !self.debug_entries.is_using_profile(Profile::Default), + !self.debug_entries.is_using_profile(Profile::Performance), + true, + true, + ]; + for (i, label) in labels.iter().enumerate() { + let hover = ui::hovered(self.cursor, xs[i], fy, FOOTER_BUTTONS[i], 20.0); + ui::button( + batch, + atlas, + xs[i], + fy, + FOOTER_BUTTONS[i], + 20.0, + label, + active[i], + hover, + ); + if i == 2 && hover { + tooltip = Some(Text::translate("options.debugGuiScale.tooltip", &[])); + } + } + if let Some(tooltip) = tooltip { + let lines = atlas.font.split(&tooltip, 200.0); + ui::tooltip(batch, atlas, &lines, self.cursor); + } + self.debug_options = Some(s); + } + /// A left click: a status button, the search box, a footer button. + pub(super) fn debug_options_click(&mut self) { + let Some(mut s) = self.debug_options.take() else { + return; + }; + let (w, h) = self.gui_size(); + let (top, bottom) = (HEADER, h - FOOTER); + let shift = + self.keys.contains(&KeyCode::ShiftLeft) || self.keys.contains(&KeyCode::ShiftRight); + s.search.focused = ui::hovered( + self.cursor, + s.search.x, + s.search.y, + s.search.width, + s.search.height, + ); + if s.search.focused { + s.search.click(&self.gui.atlas, self.cursor.0, shift, false); + } + let inside = self.cursor.1 >= top && self.cursor.1 < bottom; + let rows = rows(&s.search.value); + let left = row_left(w); + let mut clicked = None; + if inside { + for (i, row) in rows.iter().enumerate() { + let Row::Option(entry) = row else { continue }; + let cy = top + 2.0 - s.scroll.amount.trunc() + i as f32 * ITEM + 2.0; + let status = self.debug_entries.status(entry.path); + for ((status_of, _, _), x) in BUTTONS.into_iter().zip(button_xs(left + 2.0)) { + if status != status_of && ui::hovered(self.cursor, x, cy, BUTTON, 16.0) { + clicked = Some((entry.path, status_of)); + } + } + } + } + self.debug_options = Some(s); + if let Some((path, status)) = clicked { + // OptionEntry.setValue. + self.audio.play_button_click(); + self.debug_entries.set_status(path, status); + return; + } + let (xs, fy) = footer(w, h); + let Some(i) = + (0..4).find(|&i| ui::hovered(self.cursor, xs[i], fy, FOOTER_BUTTONS[i], 20.0)) + else { + return; + }; + match i { + 0 | 1 => { + let profile = if i == 0 { + Profile::Default + } else { + Profile::Performance + }; + if self.debug_entries.is_using_profile(profile) { + return; + } + self.audio.play_button_click(); + self.debug_entries.load_profile(profile); + } + 2 => { + self.audio.play_button_click(); + self.cycle_debug_gui_scale(shift); + } + _ => { + self.audio.play_button_click(); + self.close_debug_options(); + } + } + } + /// The mouse wheel over the list (`scrollRate`: half a row). + pub(super) fn debug_options_scroll(&mut self, delta: f32) { + let (_, h) = self.gui_size(); + if let Some(s) = &mut self.debug_options { + let content = content_height(rows(&s.search.value).len()); + s.scroll + .wheel(delta, ITEM / 2.0, HEADER, h - FOOTER, content); + } + } + /// A key the debug keys left: Escape closes, the rest edits the search. + pub(super) fn debug_options_key(&mut self, event: &KeyEvent) { + if event.state != ElementState::Pressed { + return; + } + let PhysicalKey::Code(key) = event.physical_key else { + return; + }; + if key == KeyCode::Escape { + self.close_debug_options(); + return; + } + let shortcut = self.edit_shortcut(event); + let ctrl = self.keys.contains(&KeyCode::ControlLeft); + let shift = self.keys.contains(&KeyCode::ShiftLeft); + let types_text = self.types_text(); + let Some(s) = &mut self.debug_options else { + return; + }; + if !s.search.focused { + return; + } + if let Some(shortcut) = shortcut { + s.search.shortcut(shortcut, &mut self.clipboard); + } else if !s.search.key(key, ctrl, shift) + && types_text + && let Some(text) = event.text.as_deref() + { + self.debug_options_type(text); + } + } + /// Characters typed into the search box (`EditBox.charTyped`). + pub(super) fn debug_options_type(&mut self, typed: &str) { + let allowed: String = typed + .chars() + .filter(|&c| c != '\u{a7}' && c >= ' ' && c != '\u{7f}') + .collect(); + if let Some(s) = &mut self.debug_options + && s.search.focused + && !allowed.is_empty() + { + s.search.insert_text(&allowed); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rows_put_a_category_before_each_section() { + let all = rows(""); + assert_eq!(all.len(), 48); + assert!(matches!(all[0], Row::Category(Category::Text))); + let renderer = all + .iter() + .position(|r| matches!(r, Row::Category(Category::Renderer))) + .unwrap(); + assert!(matches!(all[renderer + 1], Row::Option(e) if e.path == "3d_crosshair")); + // A search inside one section shows only its heading. + let hitboxes = rows("hitbox"); + assert_eq!(hitboxes.len(), 2); + assert!(matches!(hitboxes[0], Row::Category(Category::Renderer))); + } + + #[test] + fn the_layout_matches_vanilla_at_854_by_480() { + // 854x480 at GUI scale 2 is 427x240. + assert_eq!(row_left(427.0), 38.0); + assert_eq!(button_xs(40.0), [206.0, 266.0, 326.0]); + assert_eq!(footer(427.0, 240.0), ([2.0, 126.0, 250.0, 364.0], 213.0)); + } +} diff --git a/crates/horizon-client/src/game/ime.rs b/crates/horizon-client/src/game/ime.rs index c31e66867b..e28df89bd7 100644 --- a/crates/horizon-client/src/game/ime.rs +++ b/crates/horizon-client/src/game/ime.rs @@ -22,6 +22,10 @@ impl Game<'_> { } match self.screen { Screen::Chat | Screen::CommandBlock => true, + Screen::DebugOptions => self + .debug_options + .as_ref() + .is_some_and(|s| s.search_focused()), Screen::WorldOptions => self.world_options_typing(), Screen::Creative => self.creative_screen.tab().search, Screen::Inventory => { @@ -106,6 +110,7 @@ impl Game<'_> { match self.screen { Screen::Chat => self.chat_type(&text), Screen::CommandBlock => self.command_block_type(&text), + Screen::DebugOptions => self.debug_options_type(&text), Screen::WorldOptions => self.world_options_text(Some(&text)), Screen::Creative => self.creative_search_edit(Some(&text)), Screen::Inventory if self.mod_browser_typing() => { diff --git a/crates/horizon-client/src/game/screens.rs b/crates/horizon-client/src/game/screens.rs index c4ba1e573f..4ed09e460a 100644 --- a/crates/horizon-client/src/game/screens.rs +++ b/crates/horizon-client/src/game/screens.rs @@ -22,7 +22,8 @@ impl Screen { | Self::Friends | Self::ServerLinks | Self::ConfirmLink - | Self::DialogWarning => true, + | Self::DialogWarning + | Self::DebugOptions => true, Self::Playing | Self::GameModeSwitcher | Self::Chat diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs index fc3162bef4..b7f9e8f6bc 100644 --- a/crates/horizon-client/src/lib.rs +++ b/crates/horizon-client/src/lib.rs @@ -42,6 +42,7 @@ pub mod creative; pub mod cushion; pub mod data_cmd; pub mod debug_chart; +pub mod debug_entries; pub mod debug_keys; pub mod debug_overlay; pub mod dialog;