diff --git a/crates/pumpkin/src/command/commands/debug.rs b/crates/pumpkin/src/command/commands/debug.rs new file mode 100644 index 000000000..74c39728b --- /dev/null +++ b/crates/pumpkin/src/command/commands/debug.rs @@ -0,0 +1,112 @@ +use std::sync::atomic::Ordering; + +use pumpkin_data::translation; +use pumpkin_util::PermissionLvl; +use pumpkin_util::permission::{Permission, PermissionDefault, PermissionRegistry}; +use pumpkin_util::text::TextComponent; + +use crate::command::CommandSender; +use crate::command::argument_builder::{ArgumentBuilder, command, literal}; +use crate::command::context::command_context::CommandContext; +use crate::command::errors::error_types::CommandErrorType; +use crate::command::node::dispatcher::CommandDispatcher; +use crate::command::node::{CommandExecutor, CommandExecutorResult}; +use crate::server::debug_profiler::{StartDebugProfileError, StopDebugProfileError}; + +const DESCRIPTION: &str = "Starts or stops a tick profiling session."; +const PERMISSION: &str = "minecraft:command.debug"; + +const ALREADY_RUNNING_ERROR_TYPE: CommandErrorType<0> = CommandErrorType::new( + translation::java::COMMANDS_DEBUG_ALREADYRUNNING, + translation::java::COMMANDS_DEBUG_ALREADYRUNNING, +); + +const NOT_RUNNING_ERROR_TYPE: CommandErrorType<0> = CommandErrorType::new( + translation::java::COMMANDS_DEBUG_NOTRUNNING, + translation::bedrock::COMMANDS_DEBUG_NOTSTARTED, +); + +struct DebugStartExecutor; + +impl CommandExecutor for DebugStartExecutor { + fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> { + Box::pin(async move { + let server = context.server(); + let current_tick = server.tick_count.load(Ordering::Relaxed); + server.debug_profiler.start(current_tick).map_err( + |StartDebugProfileError::AlreadyRunning| { + ALREADY_RUNNING_ERROR_TYPE.create_without_context() + }, + )?; + + context + .source + .send_feedback( + TextComponent::translate_cross( + translation::java::COMMANDS_DEBUG_STARTED, + translation::bedrock::COMMANDS_DEBUG_START, + [], + ), + true, + ) + .await; + + Ok(1) + }) + } +} + +struct DebugStopExecutor; + +impl CommandExecutor for DebugStopExecutor { + fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> { + Box::pin(async move { + let server = context.server(); + let current_tick = server.tick_count.load(Ordering::Relaxed); + let result = server.debug_profiler.stop(current_tick).map_err( + |StopDebugProfileError::NotRunning| NOT_RUNNING_ERROR_TYPE.create_without_context(), + )?; + + let seconds = result.duration.as_secs_f64(); + let tps = result.ticks_per_second(); + let arguments = [ + TextComponent::text(format!("{seconds:.2}")), + TextComponent::text(result.ticks.to_string()), + TextComponent::text(format!("{tps:.2}")), + ]; + let feedback = if matches!(context.source.output, CommandSender::Player(_)) { + TextComponent::translate_cross( + translation::java::COMMANDS_DEBUG_STOPPED, + translation::bedrock::COMMANDS_DEBUG_STOP, + arguments, + ) + } else { + // Bedrock's debug-stop translation uses printf-style placeholders that the + // server-side console renderer cannot resolve. Non-player command sources need + // an already-rendered message; players still receive their native translation. + TextComponent::text(format!( + "Stopped tick profiling after {seconds:.2} second(s) and {} tick(s) ({tps:.2} tick(s) per second)", + result.ticks + )) + }; + context.source.send_feedback(feedback, true).await; + + Ok(result.command_result()) + }) + } +} + +pub fn register(dispatcher: &mut CommandDispatcher, registry: &PermissionRegistry) { + registry.register_permission_or_panic(Permission::new( + PERMISSION, + DESCRIPTION, + PermissionDefault::Op(PermissionLvl::Three), + )); + + dispatcher.register( + command("debug", DESCRIPTION) + .requires(PERMISSION) + .then(literal("start").executes(DebugStartExecutor)) + .then(literal("stop").executes(DebugStopExecutor)), + ); +} diff --git a/crates/pumpkin/src/command/commands/mod.rs b/crates/pumpkin/src/command/commands/mod.rs index 526020a28..d721d8ef7 100644 --- a/crates/pumpkin/src/command/commands/mod.rs +++ b/crates/pumpkin/src/command/commands/mod.rs @@ -17,6 +17,7 @@ mod clear; mod clone; mod damage; mod data; +mod debug; pub mod defaultgamemode; mod deop; mod dialog; @@ -177,6 +178,7 @@ pub fn default_dispatcher( say::register(&mut dispatcher, registry); banlist::register(&mut dispatcher, registry); difficulty::register(&mut dispatcher, registry); + debug::register(&mut dispatcher, registry); dialog::register(&mut dispatcher, registry); execute::register(&mut dispatcher, registry); fillbiome::register(&mut dispatcher, registry); diff --git a/crates/pumpkin/src/server/debug_profiler.rs b/crates/pumpkin/src/server/debug_profiler.rs new file mode 100644 index 000000000..c19f00ae5 --- /dev/null +++ b/crates/pumpkin/src/server/debug_profiler.rs @@ -0,0 +1,141 @@ +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +#[derive(Clone, Copy)] +struct DebugProfileSession { + started_at: Instant, + started_tick: u32, +} + +/// Measurements collected by a completed `/debug start` profiling session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DebugProfileResult { + pub duration: Duration, + pub ticks: u32, +} + +impl DebugProfileResult { + #[must_use] + pub fn ticks_per_second(self) -> f64 { + let seconds = self.duration.as_secs_f64(); + if seconds == 0.0 { + return 0.0; + } + + f64::from(self.ticks) / seconds + } + + #[must_use] + pub fn command_result(self) -> i32 { + let floored_tps = self + .ticks_per_second() + .floor() + .clamp(0.0, f64::from(i32::MAX)); + + #[expect(clippy::cast_possible_truncation)] + { + floored_tps as i32 + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StartDebugProfileError { + AlreadyRunning, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StopDebugProfileError { + NotRunning, +} + +/// Owns the single server-wide tick profiling session used by `/debug`. +#[derive(Default)] +pub struct DebugProfiler { + active_session: Mutex>, +} + +impl DebugProfiler { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn start(&self, current_tick: i32) -> Result<(), StartDebugProfileError> { + self.start_at(current_tick as u32, Instant::now()) + } + + pub fn stop(&self, current_tick: i32) -> Result { + self.stop_at(current_tick as u32, Instant::now()) + } + + fn start_at(&self, current_tick: u32, now: Instant) -> Result<(), StartDebugProfileError> { + let mut active_session = self + .active_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + if active_session.is_some() { + return Err(StartDebugProfileError::AlreadyRunning); + } + + *active_session = Some(DebugProfileSession { + started_at: now, + started_tick: current_tick, + }); + Ok(()) + } + + fn stop_at( + &self, + current_tick: u32, + now: Instant, + ) -> Result { + let session = self + .active_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .ok_or(StopDebugProfileError::NotRunning)?; + + Ok(DebugProfileResult { + duration: now.saturating_duration_since(session.started_at), + ticks: current_tick.wrapping_sub(session.started_tick), + }) + } +} + +#[cfg(test)] +mod tests { + use super::{DebugProfiler, StartDebugProfileError, StopDebugProfileError}; + use std::time::{Duration, Instant}; + + #[test] + fn profile_lifecycle_enforces_state_and_reports_measurements() { + let profiler = DebugProfiler::new(); + let start = Instant::now(); + + assert_eq!( + profiler.stop_at(42, start), + Err(StopDebugProfileError::NotRunning) + ); + assert_eq!(profiler.start_at(42, start), Ok(())); + assert_eq!( + profiler.start_at(100, start + Duration::from_secs(1)), + Err(StartDebugProfileError::AlreadyRunning) + ); + + let result = profiler + .stop_at(62, start + Duration::from_secs(2)) + .expect("the running profile should stop"); + + assert_eq!(result.duration, Duration::from_secs(2)); + assert_eq!(result.ticks, 20); + assert_eq!(result.ticks_per_second(), 10.0); + assert_eq!(result.command_result(), 10); + assert_eq!( + profiler.stop_at(62, start + Duration::from_secs(2)), + Err(StopDebugProfileError::NotRunning) + ); + } +} diff --git a/crates/pumpkin/src/server/mod.rs b/crates/pumpkin/src/server/mod.rs index f03a682e9..59bbedb6b 100644 --- a/crates/pumpkin/src/server/mod.rs +++ b/crates/pumpkin/src/server/mod.rs @@ -52,6 +52,7 @@ use tokio::task::{JoinHandle, JoinSet}; use tokio_util::task::TaskTracker; mod connection_cache; +pub(crate) mod debug_profiler; pub mod enchantment; mod key_store; pub mod recipe; @@ -130,6 +131,8 @@ pub struct Server { pub aggregated_tick_times_nanos: AtomicI64, /// Total number of ticks processed by the server pub tick_count: AtomicI32, + /// Owns the server-wide tick profiling session used by `/debug`. + pub(crate) debug_profiler: debug_profiler::DebugProfiler, /// Random unique Server ID used by Bedrock Edition pub server_guid: u64, /// Player idle timeout in minutes (0 = disabled) @@ -284,6 +287,7 @@ impl Server { tick_times_nanos: Mutex::new([0; 100]), aggregated_tick_times_nanos: AtomicI64::new(0), tick_count: AtomicI32::new(0), + debug_profiler: debug_profiler::DebugProfiler::new(), tasks: TaskTracker::new(), runtime: tokio::runtime::Handle::current(), task_scheduler: Arc::new(TaskScheduler::new()),