mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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]>
1244 lines
46 KiB
Rust
1244 lines
46 KiB
Rust
//! The debug keys in the game (26.3 `KeyboardHandler.keyPress`,
|
|
//! `handleDebugKeys` and `tick`): F3 combinations, F3+Escape, the held
|
|
//! debug-crash keys, and the debug renderers they switch on.
|
|
use super::{Game, Screen};
|
|
use horizon_client::{
|
|
Result,
|
|
command::args::GameMode,
|
|
debug_keys::{CrashNotice, DebugAction, DebugHost, KeyAction, OpenScreen},
|
|
debug_overlay,
|
|
gizmos::Gizmos,
|
|
hud::HudBatch,
|
|
hud_layout, keybinds,
|
|
save::VERSION_NAME,
|
|
text::Text,
|
|
};
|
|
use horizon_core::geometry::{Axis, Vec3};
|
|
use horizon_protocol::packets::play::{BlockEntityTagQuery, EntityTagQuery, Serverbound};
|
|
use horizon_world::nbt::Tag;
|
|
use winit::keyboard::KeyCode;
|
|
|
|
/// `ChatFormatting.YELLOW` and `RED`.
|
|
const YELLOW: u32 = 0xFF_FF55;
|
|
const RED: u32 = 0xFF_5555;
|
|
|
|
/// The game as `KeyboardHandler` sees it while it runs one key event.
|
|
struct Host<'g, 'a> {
|
|
game: &'g mut Game<'a>,
|
|
}
|
|
|
|
impl DebugHost for Host<'_, '_> {
|
|
fn screen(&self) -> OpenScreen {
|
|
self.game.open_screen()
|
|
}
|
|
fn toggle_overlay(&mut self) {
|
|
self.game.debug = !self.game.debug;
|
|
}
|
|
fn pause_game(&mut self, without_menu: bool) {
|
|
// Gui.setPauseScreen: PauseScreen(false) only when the game really
|
|
// pauses (single player, not open to the network). Escape alone opens
|
|
// the menu as it always has (key_pressed).
|
|
let g = &mut *self.game;
|
|
if without_menu && g.screen == Screen::Playing {
|
|
g.pause_menu = !g.has_singleplayer_server() || g.is_published();
|
|
g.screen = Screen::Paused;
|
|
g.capture(false);
|
|
g.mining = None;
|
|
}
|
|
}
|
|
fn debug_action(&mut self, action: DebugAction, shift: bool) -> bool {
|
|
self.game.run_debug_action(action, shift)
|
|
}
|
|
}
|
|
|
|
impl Game<'_> {
|
|
fn open_screen(&self) -> OpenScreen {
|
|
if self.title.is_some() {
|
|
return OpenScreen::Other;
|
|
}
|
|
match self.screen {
|
|
Screen::GameModeSwitcher => OpenScreen::GameModeSwitcher,
|
|
Screen::Playing if self.book.is_none() && self.sign_edit.is_none() => OpenScreen::None,
|
|
Screen::Paused if !self.pause_menu => OpenScreen::PauseWithoutMenu,
|
|
_ => OpenScreen::Other,
|
|
}
|
|
}
|
|
/// A physical key event (already in `physical_keys`) before the game's
|
|
/// own handling. Returns true when it is used up (a debug combination,
|
|
/// F3 itself, F3+Escape).
|
|
pub(super) fn debug_key(&mut self, key: KeyCode, action: KeyAction) -> bool {
|
|
if self.title.is_some() {
|
|
return false;
|
|
}
|
|
let Some(name) = keybinds::key_name(key) else {
|
|
return false;
|
|
};
|
|
let pressed = action != KeyAction::Release;
|
|
let switcher_open = self.screen == Screen::GameModeSwitcher;
|
|
if switcher_open && pressed {
|
|
if name == self.settings.keys.get("key.debug.switchGameMode") {
|
|
if let Some(switcher) = &mut self.gamemode_switcher {
|
|
switcher.cycle();
|
|
}
|
|
} else if key == KeyCode::Escape {
|
|
self.close_gamemode_switcher(false);
|
|
} else if matches!(key, KeyCode::F2 | KeyCode::F11) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
// Vanilla types into a focused EditBox through charTyped, which no
|
|
// debug key can swallow. Here the character comes with the key press,
|
|
// so a screen with a focused text box takes every press first;
|
|
// releases still reach KeyboardHandler.
|
|
let typing = matches!(
|
|
self.screen,
|
|
Screen::Chat
|
|
| Screen::Options
|
|
| Screen::WorldOptions
|
|
| Screen::CommandBlock
|
|
| Screen::BlockEditor
|
|
) || self.sign_edit.is_some()
|
|
|| self.book.is_some()
|
|
|| (self.screen == Screen::Creative && self.creative_screen.tab().search)
|
|
|| self.recipe_search_focused()
|
|
|| self.station_typing()
|
|
|| self
|
|
.title
|
|
.as_mut()
|
|
.is_some_and(|t| t.focused_field().is_some());
|
|
// Container screens use Escape, the inventory key and the hotbar and
|
|
// drop keys first; any other screen closes on Escape.
|
|
let screen_key = match self.screen {
|
|
Screen::Inventory | Screen::Creative => matches!(
|
|
key,
|
|
KeyCode::Escape
|
|
| KeyCode::KeyE
|
|
| KeyCode::KeyF
|
|
| KeyCode::KeyQ
|
|
| KeyCode::Digit1
|
|
| KeyCode::Digit2
|
|
| KeyCode::Digit3
|
|
| KeyCode::Digit4
|
|
| KeyCode::Digit5
|
|
| KeyCode::Digit6
|
|
| KeyCode::Digit7
|
|
| KeyCode::Digit8
|
|
| KeyCode::Digit9
|
|
),
|
|
Screen::Playing => false,
|
|
_ => key == KeyCode::Escape,
|
|
};
|
|
if pressed && (typing || screen_key) {
|
|
return false;
|
|
}
|
|
let crash_down = keybinds::key_code(self.settings.keys.get("key.debug.crash"))
|
|
.is_some_and(|k| self.physical_keys.contains(&k));
|
|
let shift = self.physical_keys.contains(&KeyCode::ShiftLeft)
|
|
|| self.physical_keys.contains(&KeyCode::ShiftRight);
|
|
let now = self.debug_millis();
|
|
let binds = self.settings.keys.clone();
|
|
let mut keys = std::mem::take(&mut self.debug_keys);
|
|
let modifier_was_down = keys.modifier_down;
|
|
let to_game = keys.key_press(
|
|
&mut Host { game: self },
|
|
&binds,
|
|
name,
|
|
action,
|
|
shift,
|
|
crash_down,
|
|
now,
|
|
);
|
|
self.debug_keys = keys;
|
|
if switcher_open {
|
|
if name == binds.get("key.debug.modifier") {
|
|
self.close_gamemode_switcher(true);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
if !pressed {
|
|
return false;
|
|
}
|
|
// The debug modifier does nothing else.
|
|
if name == binds.get("key.debug.modifier") || name == binds.get("key.debug.overlay") {
|
|
return true;
|
|
}
|
|
if self.open_screen() == OpenScreen::None {
|
|
!to_game
|
|
} else {
|
|
modifier_was_down
|
|
}
|
|
}
|
|
/// `Util.getMillis` (positive: KeyboardHandler treats 0 as unset).
|
|
pub(super) fn debug_millis(&self) -> i64 {
|
|
self.started.elapsed().as_millis() as i64 + 1_000_000
|
|
}
|
|
/// `KeyboardHandler.tick`: the debug crash countdown.
|
|
pub(super) fn tick_debug_keys(&mut self) -> Result<()> {
|
|
let now = self.debug_millis();
|
|
match self.debug_keys.tick(now) {
|
|
None => {}
|
|
Some(CrashNotice::Message) => {
|
|
let args = [
|
|
self.key_text("key.debug.modifier"),
|
|
self.key_text("key.debug.crash"),
|
|
];
|
|
self.debug_feedback(Text::translate("debug.crash.message", &args));
|
|
}
|
|
Some(CrashNotice::Warning(seconds)) => self.debug_warning(Text::translate(
|
|
"debug.crash.warning",
|
|
&[Text::literal(seconds.to_string())],
|
|
)),
|
|
Some(CrashNotice::Crash) => {
|
|
// Minecraft.emergencySaveAndCrash: the world is saved, then the
|
|
// game stops; main writes the crash report.
|
|
let crash = horizon_client::crash::DebugCrash::new();
|
|
if let Err(e) = self.save_game() {
|
|
log::error!("the emergency save before the crash failed: {e}");
|
|
}
|
|
return Err(crash.into());
|
|
}
|
|
}
|
|
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| {
|
|
let order = std::sync::atomic::Ordering::Relaxed;
|
|
[
|
|
c.bytes_in.load(order),
|
|
c.sent.load(order),
|
|
c.received.load(order),
|
|
]
|
|
});
|
|
if let Some([bytes, sent, received]) = received {
|
|
self.debug_charts.log_bandwidth(bytes);
|
|
self.debug_charts.log_packets([sent, received]);
|
|
}
|
|
if shown {
|
|
use horizon_protocol::packets::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" }
|
|
)
|
|
};
|
|
// `DebugScreenOverlay`: the single-player variants for a local server.
|
|
let remote = !self.has_singleplayer_server();
|
|
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", c.lightmap),
|
|
),
|
|
format!("To edit: press {}", key("key.debug.debugOptions")),
|
|
]
|
|
}
|
|
/// `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;
|
|
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,
|
|
batch.scale as i32,
|
|
self.settings.file.int("debugGuiScale"),
|
|
self.settings.force_unicode,
|
|
[batch.width as i32, batch.height as i32],
|
|
);
|
|
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
|
|
.as_ref()
|
|
.map_or("", |r| match r.config.present_mode {
|
|
wgpu::PresentMode::Immediate => " (immediate)",
|
|
wgpu::PresentMode::Mailbox => " (mailbox)",
|
|
wgpu::PresentMode::Fifo => " (fifo)",
|
|
wgpu::PresentMode::FifoRelaxed => " (fifo relaxed)",
|
|
_ => "",
|
|
});
|
|
let refresh = self
|
|
.window
|
|
.as_ref()
|
|
.and_then(|w| w.current_monitor())
|
|
.and_then(|m| m.refresh_rate_millihertz())
|
|
.map_or("0".into(), |mhz| {
|
|
debug_overlay::refresh_rate_label(f64::from(mhz) / 1000.0)
|
|
});
|
|
d.priority(format!(
|
|
"{} fps T: {}{present} @{refresh}Hz",
|
|
self.fps.round() as i32,
|
|
if limit == 260 {
|
|
"inf".into()
|
|
} else {
|
|
limit.to_string()
|
|
},
|
|
));
|
|
}
|
|
/// `DebugEntrySimplePerformanceImpactors`.
|
|
fn debug_performance_lines(&self, d: &mut debug_overlay::Displayer) {
|
|
let s = &self.settings;
|
|
d.line(format!(
|
|
"{}{}B: {}",
|
|
if s.improved_transparency {
|
|
"improved-transparency "
|
|
} else {
|
|
""
|
|
},
|
|
match s.clouds.as_str() {
|
|
"false" => "",
|
|
"fast" => "fast-clouds ",
|
|
_ => "fancy-clouds ",
|
|
},
|
|
s.biome_blend
|
|
));
|
|
d.line("Filtering: None");
|
|
d.line(format!(
|
|
"Terrain Rendering: {}",
|
|
if self.renderer.as_ref().is_some_and(|r| r.multi_draw) {
|
|
"multidrawindirect"
|
|
} else {
|
|
"naive"
|
|
}
|
|
));
|
|
}
|
|
/// `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(
|
|
"system",
|
|
[format!(
|
|
"CPU: {}",
|
|
CPU.get_or_init(horizon_client::crash::cpu)
|
|
)],
|
|
);
|
|
let info = &r.info;
|
|
d.group(
|
|
"system",
|
|
[format!(
|
|
"Display: {}x{} ({})",
|
|
framebuffer[0],
|
|
framebuffer[1],
|
|
// Metal adapters report no PCI vendor.
|
|
if info.vendor == 0 && info.backend == wgpu::Backend::Metal {
|
|
"Apple".into()
|
|
} else {
|
|
debug_overlay::vendor_name(info.vendor)
|
|
}
|
|
)],
|
|
);
|
|
if let Some(w) = self.window.as_ref() {
|
|
let density = w.scale_factor();
|
|
let logical = w.inner_size().to_logical::<f64>(density);
|
|
d.group(
|
|
"system",
|
|
[format!(
|
|
"Window: {}x{} ({density:.2}x pixel density)",
|
|
logical.width.round() as i32,
|
|
logical.height.round() as i32
|
|
)],
|
|
);
|
|
}
|
|
let kind = match info.device_type {
|
|
wgpu::DeviceType::IntegratedGpu => " (iGPU)",
|
|
wgpu::DeviceType::DiscreteGpu => " (dGPU)",
|
|
wgpu::DeviceType::VirtualGpu => " (vGPU)",
|
|
wgpu::DeviceType::Cpu => " (software)",
|
|
wgpu::DeviceType::Other => "",
|
|
};
|
|
let driver = format!("{} {}", info.driver, info.driver_info);
|
|
d.group(
|
|
"system",
|
|
[
|
|
format!("{}{kind}", info.name),
|
|
format!(
|
|
"{:?} {}",
|
|
info.backend,
|
|
driver.trim().lines().next().unwrap_or("")
|
|
)
|
|
.trim_end()
|
|
.to_owned(),
|
|
],
|
|
);
|
|
}
|
|
// Horizon's own: HDR's output while the setting is on, under the GPU (a group of its
|
|
// own would move others to the other column).
|
|
if self.settings.horizon.hdr
|
|
&& let Some(r) = self.renderer.as_ref()
|
|
{
|
|
d.group("system", [r.hdr_line()]);
|
|
}
|
|
}
|
|
/// `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() {
|
|
("-".to_owned(), " (sprinting)")
|
|
} else if self.clock.stepping() {
|
|
(
|
|
format!("{:.1}", 1000.0 / self.clock.rate()),
|
|
" (frozen - stepping)",
|
|
)
|
|
} else if self.clock.frozen() {
|
|
(format!("{:.1}", 1000.0 / self.clock.rate()), " (frozen)")
|
|
} else {
|
|
(format!("{:.1}", 1000.0 / self.clock.rate()), "")
|
|
};
|
|
if self.remote.is_none() || self.integrated.is_some() {
|
|
let tick_ms = self
|
|
.integrated
|
|
.as_ref()
|
|
.map_or(self.debug_charts.smoothed_tick_ms, |server| {
|
|
server.smoothed_tick_ms()
|
|
});
|
|
d.line(format!(
|
|
"Integrated server @ {tick_ms:.1}/{target} ms{status}, {tx} tx, {rx} rx",
|
|
));
|
|
} else if let Some(remote) = &self.remote {
|
|
d.line(format!(
|
|
"\"{}\" server{status}, {tx} tx, {rx} rx",
|
|
remote.brand
|
|
));
|
|
}
|
|
}
|
|
}
|
|
/// `KeyMapping.getTranslatedKeyMessage`.
|
|
fn key_text(&self, mapping: &str) -> Text {
|
|
keybinds::display_name(self.settings.keys.get(mapping))
|
|
}
|
|
/// `KeyboardHandler.decorateDebugComponent`: `[Debug]:` in bold, a space,
|
|
/// the message, shown with `Minecraft.showDebugChat`.
|
|
fn debug_decorated(&mut self, color: u32, message: Text) {
|
|
let prefix = Text::translate("debug.prefix", &[])
|
|
.with_color(color)
|
|
.bold();
|
|
self.system_message(
|
|
Text::empty()
|
|
.append(prefix)
|
|
.append(Text::literal(" "))
|
|
.append(message),
|
|
);
|
|
}
|
|
fn debug_feedback(&mut self, message: Text) {
|
|
self.debug_decorated(YELLOW, message);
|
|
}
|
|
fn debug_warning(&mut self, message: Text) {
|
|
self.debug_decorated(RED, message);
|
|
}
|
|
fn debug_translated(&mut self, key: &str) {
|
|
self.debug_feedback(Text::translate(key, &[]));
|
|
}
|
|
/// `LocalPlayer.isReducedDebugInfo`: the server's rule or the option.
|
|
pub(super) fn reduced_debug_info(&self) -> bool {
|
|
self.settings.reduced_debug_info || self.ui.rules.bool("reduced_debug_info")
|
|
}
|
|
/// `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_entry("3d_crosshair")
|
|
}
|
|
/// `Permissions.COMMANDS_GAMEMASTER` (`GameModeCommand.PERMISSION_CHECK`).
|
|
pub(super) fn gamemaster(&self) -> bool {
|
|
self.ui.registries.permission >= 2
|
|
}
|
|
fn toggle_option(&mut self, key: &str) -> bool {
|
|
let on = !self.settings.file.bool(key);
|
|
self.settings
|
|
.file
|
|
.set(key, horizon_client::options_file::V::Bool(on));
|
|
self.settings.save(&Self::options_path());
|
|
on
|
|
}
|
|
/// One matching mapping of `handleDebugKeys`; whether it counts.
|
|
fn run_debug_action(&mut self, action: DebugAction, shift: bool) -> bool {
|
|
let player = self.player.is_some() && !self.reduced_debug_info();
|
|
match action {
|
|
DebugAction::ReloadChunks => {
|
|
self.all_sections_changed();
|
|
self.debug_translated("debug.reload_chunks.message");
|
|
true
|
|
}
|
|
DebugAction::ShowHitboxes if player => {
|
|
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"
|
|
});
|
|
true
|
|
}
|
|
DebugAction::ClearChat => {
|
|
// ChatComponent.clearMessages(false): sent history stays.
|
|
self.ui.chat.clear();
|
|
true
|
|
}
|
|
DebugAction::ShowChunkBorders if player => {
|
|
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"
|
|
});
|
|
true
|
|
}
|
|
DebugAction::AdvancedTooltips => {
|
|
// The message comes before Options.save here.
|
|
let on = !self.settings.file.bool("advancedItemTooltips");
|
|
self.debug_translated(if on {
|
|
"debug.advanced_tooltips.on"
|
|
} else {
|
|
"debug.advanced_tooltips.off"
|
|
});
|
|
self.toggle_option("advancedItemTooltips");
|
|
true
|
|
}
|
|
DebugAction::CopyRecreateCommand => {
|
|
if player {
|
|
self.copy_recreate_command(self.gamemaster(), !shift);
|
|
}
|
|
true
|
|
}
|
|
DebugAction::Spectate => {
|
|
if self.player.is_none() || !self.gamemaster() {
|
|
self.debug_translated("debug.creative_spectator.error");
|
|
} else if self.mode != GameMode::Spectator {
|
|
self.change_game_mode(GameMode::Spectator);
|
|
} else {
|
|
let back = self.previous_mode.unwrap_or(GameMode::Creative);
|
|
self.change_game_mode(back);
|
|
}
|
|
true
|
|
}
|
|
DebugAction::SwitchGameMode if self.open_screen() == OpenScreen::None => {
|
|
if self.player.is_none() || !self.gamemaster() {
|
|
self.debug_translated("debug.gamemodes.error");
|
|
} else {
|
|
self.open_gamemode_switcher();
|
|
}
|
|
true
|
|
}
|
|
DebugAction::FocusPause => {
|
|
let on = self.toggle_option("pauseOnLostFocus");
|
|
self.debug_translated(if on {
|
|
"debug.pause_focus.on"
|
|
} else {
|
|
"debug.pause_focus.off"
|
|
});
|
|
true
|
|
}
|
|
DebugAction::ReloadResourcePacks => {
|
|
self.debug_translated("debug.reload_resourcepacks.message");
|
|
self.reload_packs();
|
|
true
|
|
}
|
|
DebugAction::CopyLocation if player => {
|
|
self.debug_translated("debug.copy_location.message");
|
|
if let Some(p) = &self.player {
|
|
use horizon_client::java_text::format_fixed2 as f2;
|
|
let command = format!(
|
|
"/execute in {} run tp @s {} {} {} {} {}",
|
|
self.dim.id(),
|
|
f2(p.position.x),
|
|
f2(p.position.y),
|
|
f2(p.position.z),
|
|
f2(f64::from(self.yaw)),
|
|
f2(f64::from(self.pitch)),
|
|
);
|
|
self.set_clipboard(&command);
|
|
}
|
|
true
|
|
}
|
|
DebugAction::DumpVersion => {
|
|
self.debug_translated("debug.version.header");
|
|
for line in self.version_lines() {
|
|
self.system_message(line);
|
|
}
|
|
true
|
|
}
|
|
DebugAction::ImprovedTransparency => {
|
|
self.settings.improved_transparency = !self.settings.improved_transparency;
|
|
self.settings.save(&Self::options_path());
|
|
self.apply_settings();
|
|
self.debug_translated(if self.settings.improved_transparency {
|
|
"debug.improvedTransparency.on"
|
|
} else {
|
|
"debug.improvedTransparency.off"
|
|
});
|
|
true
|
|
}
|
|
DebugAction::FpsCharts => {
|
|
self.debug_charts.toggle_fps(&mut self.debug);
|
|
true
|
|
}
|
|
DebugAction::NetworkCharts => {
|
|
self.debug_charts.toggle_network(&mut self.debug);
|
|
true
|
|
}
|
|
DebugAction::LightmapTexture => {
|
|
self.debug_charts.toggle_lightmap(&mut self.debug);
|
|
true
|
|
}
|
|
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
|
|
| DebugAction::ShowChunkBorders
|
|
| DebugAction::SwitchGameMode
|
|
| DebugAction::CopyLocation => false,
|
|
}
|
|
}
|
|
/// `ServerboundChangeGameModePacket` handled by the integrated server:
|
|
/// `GameModeCommand.setGameMode` with its feedback.
|
|
pub(super) fn change_game_mode(&mut self, mode: GameMode) {
|
|
if self.remote.is_some() {
|
|
use horizon_protocol::packets::play::ChangeGameMode;
|
|
let mode = match mode {
|
|
GameMode::Survival => 0,
|
|
GameMode::Creative => 1,
|
|
GameMode::Adventure => 2,
|
|
GameMode::Spectator => 3,
|
|
};
|
|
self.send(Serverbound::ChangeGameMode(ChangeGameMode { mode }));
|
|
return;
|
|
}
|
|
if self.set_game_mode(mode) {
|
|
let name = Text::translate(&format!("gameMode.{}", mode.name()), &[]);
|
|
self.feedback(Text::translate("commands.gamemode.success.self", &[name]));
|
|
}
|
|
}
|
|
/// `KeyboardHandler.copyRecreateCommand`: the targeted block or mob as a
|
|
/// `/setblock` or `/summon` command. The server's data (no Shift) comes
|
|
/// from the server the game is connected to, the integrated one included
|
|
/// (`DebugQueryHandler`); without one, the game's own data is the server's.
|
|
fn copy_recreate_command(&mut self, add_nbt: bool, from_server: bool) {
|
|
let query = add_nbt && from_server && self.remote.is_some();
|
|
let side = if add_nbt && from_server {
|
|
"server"
|
|
} else {
|
|
"client"
|
|
};
|
|
if self.entity_targeted()
|
|
&& let Some((index, _)) = self.mob_hit()
|
|
{
|
|
let (kind, position, id) = {
|
|
let m = &self.mobs[index];
|
|
(m.kind.id.clone(), m.position, m.id)
|
|
};
|
|
if query {
|
|
// DebugQueryHandler.queryEntityTag: the answer copies it.
|
|
let reply = QueryReply::Entity { id, kind, position };
|
|
if let Some(transaction) = self.remote.as_mut().map(|r| r.debug_query.start(reply))
|
|
{
|
|
self.send(Serverbound::EntityTagQuery(EntityTagQuery {
|
|
transaction,
|
|
entity: id as i32,
|
|
}));
|
|
}
|
|
return;
|
|
}
|
|
// Entity.saveWithoutId.
|
|
let tag = if add_nbt {
|
|
self.entity_nbt(super::commands::Target::Mob(id))
|
|
.map(|mut tag| {
|
|
if let Tag::Compound(c) = &mut tag {
|
|
c.remove(&"id".into());
|
|
}
|
|
tag
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
self.copy_create_entity_command(&kind, position, tag);
|
|
self.debug_translated(&format!("debug.inspect.{side}.entity"));
|
|
return;
|
|
}
|
|
let Some(hit) = self.pick() else {
|
|
return;
|
|
};
|
|
let pos = hit.pos;
|
|
let state = self.state_string(self.world.block(pos[0], pos[1], pos[2]));
|
|
if query {
|
|
// DebugQueryHandler.queryBlockEntityTag: the answer copies it.
|
|
let reply = QueryReply::Block { pos, state };
|
|
if let Some(transaction) = self.remote.as_mut().map(|r| r.debug_query.start(reply)) {
|
|
self.send(Serverbound::BlockEntityTagQuery(BlockEntityTagQuery {
|
|
transaction,
|
|
pos,
|
|
}));
|
|
}
|
|
return;
|
|
}
|
|
let tag = if add_nbt {
|
|
self.block_entity_tag(pos)
|
|
} else {
|
|
None
|
|
};
|
|
self.copy_create_block_command(pos, &state, tag.as_ref());
|
|
self.debug_translated(&format!("debug.inspect.{side}.block"));
|
|
}
|
|
/// `BlockEntity.saveWithoutMetadata` of the block entity at `pos`, if
|
|
/// there is one: its data without the id or the position.
|
|
pub(super) fn block_entity_tag(&self, pos: [i32; 3]) -> Option<Tag> {
|
|
let e = self.world.block_entities.get(&(pos[0], pos[1], pos[2]))?;
|
|
let name = self.data_block_name(pos);
|
|
let mut tag = horizon_client::save::encode_block_entity((pos[0], pos[1], pos[2]), e, &name);
|
|
if let Tag::Compound(c) = &mut tag {
|
|
for key in ["id", "x", "y", "z", "keepPacked"] {
|
|
c.remove(&key.into());
|
|
}
|
|
}
|
|
Some(tag)
|
|
}
|
|
/// `ClientPacketListener.handleTagQueryPacket`: the server's answer to
|
|
/// the last debug query (`DebugQueryHandler.handleResponse`) completes
|
|
/// it; an answer to an older query is ignored.
|
|
pub(super) fn answer_tag_query(&mut self, transaction: i32, tag: Tag) {
|
|
let Some(reply) = self
|
|
.remote
|
|
.as_mut()
|
|
.and_then(|r| r.debug_query.answer(transaction))
|
|
else {
|
|
return;
|
|
};
|
|
// FriendlyByteBuf.readNbt: an End tag is no compound.
|
|
let tag = match tag {
|
|
Tag::End => None,
|
|
tag => Some(tag),
|
|
};
|
|
match reply {
|
|
QueryReply::Block { pos, state } => {
|
|
self.copy_create_block_command(pos, &state, tag.as_ref());
|
|
self.debug_translated("debug.inspect.server.block");
|
|
}
|
|
QueryReply::Entity { id, kind, position } => {
|
|
// Entity.position() when the answer comes: where it is now,
|
|
// else where it was last.
|
|
let position = self
|
|
.mobs
|
|
.iter()
|
|
.find(|m| m.id == id)
|
|
.map_or(position, |m| m.position);
|
|
self.copy_create_entity_command(&kind, position, tag);
|
|
self.debug_translated("debug.inspect.server.entity");
|
|
}
|
|
}
|
|
}
|
|
/// `KeyboardHandler.copyCreateBlockCommand`.
|
|
fn copy_create_block_command(&mut self, pos: [i32; 3], state: &str, tag: Option<&Tag>) {
|
|
let mut description = state.to_owned();
|
|
if let Some(tag) = tag {
|
|
description.push_str(&horizon_client::snbt::to_snbt(tag));
|
|
}
|
|
self.set_clipboard(&format!(
|
|
"/setblock {} {} {} {description}",
|
|
pos[0], pos[1], pos[2]
|
|
));
|
|
}
|
|
/// `KeyboardHandler.copyCreateEntityCommand`: the data without its UUID
|
|
/// or position.
|
|
fn copy_create_entity_command(&mut self, kind: &str, pos: Vec3, tag: Option<Tag>) {
|
|
use horizon_client::java_text::format_fixed2 as f2;
|
|
let mut command = format!("/summon {kind} {} {} {}", f2(pos.x), f2(pos.y), f2(pos.z));
|
|
if let Some(mut tag) = tag {
|
|
if let Tag::Compound(c) = &mut tag {
|
|
for key in ["UUID", "Pos"] {
|
|
c.remove(&key.into());
|
|
}
|
|
}
|
|
command.push(' ');
|
|
command.push_str(&horizon_client::snbt::pretty(&tag).plain());
|
|
}
|
|
self.set_clipboard(&command);
|
|
}
|
|
/// `KeyboardHandler.setClipboard`.
|
|
fn set_clipboard(&mut self, text: &str) {
|
|
use horizon_client::clipboard::Clipboard;
|
|
self.clipboard.set(text);
|
|
}
|
|
/// The debug renderers' gizmos for a frame (`DebugRenderer.emitGizmos`).
|
|
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.debug_entry("chunk_borders")
|
|
&& let Some(p) = &self.player
|
|
{
|
|
let block = [
|
|
p.position.x.floor() as i32,
|
|
p.position.y.floor() as i32,
|
|
p.position.z.floor() as i32,
|
|
];
|
|
let min_y = self.world.bottom_y();
|
|
g.chunk_borders(block, min_y, min_y + self.world.layout().height() - 1);
|
|
}
|
|
if !self.debug_entry("entity_hitboxes") {
|
|
return g;
|
|
}
|
|
let lerp = |a: f64, b: f64| a + f64::from(alpha) * (b - a);
|
|
let lerpf = |a: f32, b: f32| a + alpha * (b - a);
|
|
let guests = self.lan.iter().flat_map(|h| h.models.values());
|
|
for m in self.mobs.iter().chain(guests) {
|
|
if m.removed || m.invisible || m.has_effect("minecraft:invisibility") {
|
|
continue;
|
|
}
|
|
let pos = [
|
|
lerp(m.previous.x, m.position.x),
|
|
lerp(m.previous.y, m.position.y),
|
|
lerp(m.previous.z, m.position.z),
|
|
];
|
|
let view = (
|
|
lerpf(m.previous_head_pitch, m.head_pitch),
|
|
lerpf(m.previous_head_yaw, m.head_yaw),
|
|
);
|
|
g.hitbox(
|
|
pos,
|
|
(m.width(), m.height()),
|
|
m.eye_height() as f32,
|
|
true,
|
|
view,
|
|
);
|
|
}
|
|
for item in &self.dropped {
|
|
let pos = [
|
|
lerp(item.previous.x, item.position.x),
|
|
lerp(item.previous.y, item.position.y),
|
|
lerp(item.previous.z, item.position.z),
|
|
];
|
|
// EntityType.ITEM: 0.25 by 0.25, eyes at 0.2125.
|
|
g.hitbox(pos, (0.25, 0.25), 0.2125, false, (0.0, 0.0));
|
|
}
|
|
// The other entities (EntityTypes sizes; eyes at 0.85 of the height
|
|
// unless the type sets one). `at` is the interpolated position.
|
|
let at = |previous: Vec3, now: Vec3| {
|
|
[
|
|
lerp(previous.x, now.x),
|
|
lerp(previous.y, now.y),
|
|
lerp(previous.z, now.z),
|
|
]
|
|
};
|
|
for p in &self.paintings {
|
|
let (min, max) = p.bounds();
|
|
let centre = std::array::from_fn(|i| (min[i] + max[i]) / 2.0);
|
|
g.entity_box(min, max, centre, 0.425, false, step_view(p.step()));
|
|
}
|
|
for f in self.item_frames.iter().filter(|f| !f.invisible) {
|
|
let (min, max) = f.bounds();
|
|
let centre = std::array::from_fn(|i| (min[i] + max[i]) / 2.0);
|
|
g.entity_box(min, max, centre, 0.0, false, step_view(f.step()));
|
|
}
|
|
for c in &self.cushions {
|
|
let (min, max) = c.bounds();
|
|
let p = c.position;
|
|
g.entity_box(min, max, [p.x, p.y, p.z], 0.2125, false, (0.0, c.yaw));
|
|
}
|
|
for a in self.armor_stands.iter().filter(|a| !a.invisible) {
|
|
// ArmorStand.getDimensions: a marker has none, a small one is
|
|
// half scale with its eyes at 0.9875.
|
|
let (size, eye) = match (a.marker, a.small) {
|
|
(true, _) => ((0.0, 0.0), 0.0),
|
|
(_, true) => ((0.25, 0.9875), 0.9875),
|
|
_ => ((0.5, 1.975), 1.7775),
|
|
};
|
|
let p = a.position;
|
|
g.hitbox([p.x, p.y, p.z], size, eye, true, (0.0, a.yaw));
|
|
}
|
|
for b in &self.boats {
|
|
let h = super::boats::HEIGHT as f32;
|
|
g.hitbox(
|
|
at(b.previous, b.position),
|
|
(super::boats::WIDTH as f32, h),
|
|
h,
|
|
false,
|
|
(0.0, b.yaw),
|
|
);
|
|
}
|
|
for c in &self.carts {
|
|
let (w, h) = (
|
|
super::minecarts::WIDTH as f32,
|
|
super::minecarts::HEIGHT as f32,
|
|
);
|
|
g.hitbox(
|
|
at(c.previous, c.position),
|
|
(w, h),
|
|
h * 0.85,
|
|
false,
|
|
(0.0, c.yaw),
|
|
);
|
|
}
|
|
for o in &self.orbs {
|
|
g.hitbox(
|
|
at(o.previous, o.position),
|
|
(0.5, 0.5),
|
|
0.425,
|
|
false,
|
|
(0.0, 0.0),
|
|
);
|
|
}
|
|
for f in &self.falling {
|
|
g.hitbox(
|
|
at(f.previous, f.position),
|
|
(0.98, 0.98),
|
|
0.833,
|
|
false,
|
|
(0.0, 0.0),
|
|
);
|
|
}
|
|
for t in &self.tnts {
|
|
g.hitbox(
|
|
at(t.previous, t.position),
|
|
(0.98, 0.98),
|
|
0.15,
|
|
false,
|
|
(0.0, 0.0),
|
|
);
|
|
}
|
|
for c in &self.crystals {
|
|
let p = c.position;
|
|
g.hitbox([p.x, p.y, p.z], (2.0, 2.0), 1.7, false, (0.0, 0.0));
|
|
}
|
|
for p in &self.creatures.projectiles {
|
|
use horizon_client::projectiles::ProjectileKind as K;
|
|
let (size, eye) = match p.kind {
|
|
K::Arrow { .. } | K::Trident => (0.5, 0.13),
|
|
K::Snowball | K::Egg | K::EnderPearl | K::LlamaSpit | K::Potion { .. } => {
|
|
(0.25, 0.2125)
|
|
}
|
|
K::SmallFireball | K::ShulkerBullet { .. } | K::WitherSkull { .. } => {
|
|
(0.3125, 0.265_625)
|
|
}
|
|
K::WindCharge | K::BreezeWindCharge => (0.3125, 0.0),
|
|
K::LargeFireball { .. } => (1.0, 0.85),
|
|
};
|
|
g.hitbox(
|
|
at(p.previous, p.position),
|
|
(size, size),
|
|
eye,
|
|
false,
|
|
(p.pitch, p.yaw),
|
|
);
|
|
}
|
|
for t in &self.tridents {
|
|
g.hitbox(
|
|
at(t.previous, t.position),
|
|
(0.5, 0.5),
|
|
0.13,
|
|
false,
|
|
(t.pitch, t.yaw),
|
|
);
|
|
}
|
|
// EntityType.EYE_OF_ENDER: 0.25 by 0.25, never turned.
|
|
for (previous, now) in self.end.eye_motions() {
|
|
let v = |p: glam::DVec3| Vec3::new(p.x, p.y, p.z);
|
|
g.hitbox(
|
|
at(v(previous), v(now)),
|
|
(0.25, 0.25),
|
|
0.2125,
|
|
false,
|
|
(0.0, 0.0),
|
|
);
|
|
}
|
|
for r in &self.rockets {
|
|
// Projectile.updateRotation: the look follows the motion.
|
|
let v = r.velocity;
|
|
let yaw = v.x.atan2(v.z).to_degrees() as f32;
|
|
let pitch = v.y.atan2(v.x.hypot(v.z)).to_degrees() as f32;
|
|
g.hitbox(
|
|
at(r.previous, r.position),
|
|
(0.25, 0.25),
|
|
0.2125,
|
|
false,
|
|
(pitch, yaw),
|
|
);
|
|
}
|
|
for h in self
|
|
.hook
|
|
.iter()
|
|
.map(|h| (h.previous, h.position))
|
|
.chain(self.remote_hooks.iter().map(|h| (h.previous, h.position)))
|
|
{
|
|
g.hitbox(at(h.0, h.1), (0.25, 0.25), 0.2125, false, (0.0, 0.0));
|
|
}
|
|
for b in &self.bolts {
|
|
g.hitbox(b.at, (0.0, 0.0), 0.0, false, (0.0, 0.0));
|
|
}
|
|
// The dragon: 16 by 8, a living entity; its parts follow.
|
|
if let Some(d) = &self.dragon {
|
|
g.hitbox(
|
|
at(d.previous, d.position),
|
|
(16.0, 8.0),
|
|
6.8,
|
|
true,
|
|
(0.0, d.yaw),
|
|
);
|
|
}
|
|
// The dragon's parts.
|
|
for (id, b) in self.special_targets() {
|
|
if (super::dragon::SPECIAL..super::dragon::SPECIAL + 8).contains(&id) {
|
|
g.cuboid(
|
|
[b.min(Axis::X), b.min(Axis::Y), b.min(Axis::Z)],
|
|
[b.max(Axis::X), b.max(Axis::Y), b.max(Axis::Z)],
|
|
horizon_client::gizmos::DRAGON_PART,
|
|
horizon_client::gizmos::STROKE_WIDTH,
|
|
);
|
|
}
|
|
}
|
|
// The player's own, unless the camera is in its head.
|
|
if !self.camera_type.first_person()
|
|
&& let Some(p) = &self.player
|
|
{
|
|
let d = p.dimensions;
|
|
let pos = [
|
|
lerp(self.previous_position.x, p.position.x),
|
|
lerp(self.previous_position.y, p.position.y),
|
|
lerp(self.previous_position.z, p.position.z),
|
|
];
|
|
g.hitbox(
|
|
pos,
|
|
(d.width, d.height),
|
|
d.eye_height,
|
|
true,
|
|
(self.pitch, self.yaw),
|
|
);
|
|
}
|
|
g
|
|
}
|
|
}
|
|
|
|
/// The (x, y) rotations whose view vector is the step of a direction.
|
|
fn step_view(step: [i32; 3]) -> (f32, f32) {
|
|
let [x, y, z] = step.map(f64::from);
|
|
(
|
|
(-y).asin().to_degrees() as f32,
|
|
(-x.atan2(z)).to_degrees() as f32,
|
|
)
|
|
}
|
|
|
|
/// `DebugQueryHandler`: the last debug NBT query (F3+I) sent to the server
|
|
/// and what its answer completes. Only the newest query is answered.
|
|
#[derive(Debug)]
|
|
pub(super) struct DebugQuery {
|
|
transaction: i32,
|
|
pending: Option<QueryReply>,
|
|
}
|
|
impl Default for DebugQuery {
|
|
fn default() -> Self {
|
|
Self {
|
|
transaction: -1,
|
|
pending: None,
|
|
}
|
|
}
|
|
}
|
|
/// What a debug query's answer is copied into: the block command
|
|
/// (`copyCreateBlockCommand`) or the entity's (`copyCreateEntityCommand`).
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(super) enum QueryReply {
|
|
Block {
|
|
pos: [i32; 3],
|
|
state: String,
|
|
},
|
|
Entity {
|
|
id: u32,
|
|
kind: String,
|
|
position: Vec3,
|
|
},
|
|
}
|
|
impl DebugQuery {
|
|
/// `DebugQueryHandler.startTransaction`: the next transaction id, which
|
|
/// replaces any query still waiting.
|
|
fn start(&mut self, reply: QueryReply) -> i32 {
|
|
self.pending = Some(reply);
|
|
self.transaction = self.transaction.wrapping_add(1);
|
|
self.transaction
|
|
}
|
|
/// `DebugQueryHandler.handleResponse`: what the answer to `transaction`
|
|
/// completes, once, when it answers the newest query.
|
|
fn answer(&mut self, transaction: i32) -> Option<QueryReply> {
|
|
if transaction == self.transaction {
|
|
self.pending.take()
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn block(x: i32) -> QueryReply {
|
|
QueryReply::Block {
|
|
pos: [x, 64, 0],
|
|
state: "minecraft:chest[facing=north,type=single,waterlogged=false]".into(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_debug_query_is_answered_once_and_only_the_newest() {
|
|
let mut q = DebugQuery::default();
|
|
// The first transaction is 0 (the handler starts at -1).
|
|
assert_eq!(q.start(block(1)), 0);
|
|
assert_eq!(q.answer(0), Some(block(1)));
|
|
assert_eq!(q.answer(0), None);
|
|
// A second press before the first answer: the older answer is ignored
|
|
// and the newer one copies the newer target.
|
|
assert_eq!(q.start(block(2)), 1);
|
|
assert_eq!(q.start(block(3)), 2);
|
|
assert_eq!(q.answer(1), None);
|
|
assert_eq!(q.answer(2), Some(block(3)));
|
|
}
|
|
}
|