Debug options screen (F3+F6): choose what the F3 overlay shows

Ports DebugOptionsScreen, DebugScreenEntries and DebugScreenEntryList
from 26.3: every entry can be off, in the overlay or always shown, the
default and performance profiles, debug-profile.json beside options.txt.
The overlay, F3+B, F3+G and the 3D crosshair follow the entries, and the
screen's GUI Scale button edits debugGuiScale.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
some-du6eandClaude Opus 5.5 committed 2026-10-31 09:44:24 -06:00
1 parent 19021623be
commit e47213a981
10 files changed
+1061 -79

No files matched your search

+1
View File
@@ -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
+430
View File
@@ -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<Self> {
[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<Self> {
[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<String, Status>,
profile: Option<Profile>,
file: Option<PathBuf>,
}
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<Profile>, BTreeMap<String, Status>)> {
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<String, serde_json::Value> = 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);
}
}
+24 -4
View File
@@ -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<debug_options::DebugOptionsUi>,
/// 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,
+1
View File
@@ -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<String, i64> {
+4 -2
View File
@@ -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"),
+127 -72
View File
@@ -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<String> = 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);
@@ -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<Row> {
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::<f32>() + 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));
}
}
+5
View File
@@ -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() => {
+2 -1
View File
@@ -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
+1
View File
@@ -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;