From 0d1736da8ffd016b4e66624d4ea87c34015cf1a7 Mon Sep 17 00:00:00 2001 From: rdbtCVS <87152661+rdbtCVS@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:53:00 +0100 Subject: [PATCH] Make startup and world-loading execution roles explicit Separate client, integrated-server and dedicated-server roles from connection state. Use roles for startup, presentation work and server dimension loading while preserving in-frame client simulation. Co-Authored-By: OpenAI Codex (GPT-6.1) --- crates/horizon-client/src/game.rs | 32 +++++++++------ .../horizon-client/src/game/execution_role.rs | 39 +++++++++++++++++++ crates/horizon-client/src/game/integrated.rs | 10 ++--- crates/horizon-client/src/game/levels.rs | 4 +- .../horizon-client/src/game/world_loading.rs | 2 +- docs/server-levels.md | 16 +++++--- 6 files changed, 76 insertions(+), 27 deletions(-) create mode 100644 crates/horizon-client/src/game/execution_role.rs diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index c46b86032..fbf5d17ab 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -43,6 +43,7 @@ mod enchant_effects; mod end; mod entity_culling; mod events; +mod execution_role; mod explosion; mod extras; mod falling_damage; @@ -289,8 +290,8 @@ struct Options { /// as vanilla's does: the default. `--in-frame` (or `--no-server-thread`, /// or `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead. server_thread: bool, - /// Set on the integrated server's own options: it draws nothing, so it skips meshing. - integrated: bool, + /// What this Game executes; independent of its current server connection. + execution_role: execution_role::ExecutionRole, } impl Options { /// The name to join servers with: the one given, else the offline name kept @@ -347,7 +348,7 @@ impl Options { // itself, without the event loop: it stays in the frame. server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| v != "0") && std::env::var_os("HORIZON_COMMAND_SCRIPT").is_none(), - integrated: false, + execution_role: execution_role::ExecutionRole::Client, }; let mut session_file = std::env::var("HORIZON_SESSION_FILE").ok(); let mut args = std::env::args().skip(1); @@ -361,6 +362,7 @@ impl Options { return Ok(None); } "--dedicated" => { + o.execution_role = execution_role::ExecutionRole::DedicatedServer; o.dedicated = Some(args.by_ref().collect()); break; } @@ -7521,7 +7523,11 @@ impl Game<'_> { } self.join_world_rest()?; let save_root = std::env::current_dir()?.join("saves").join(folder); - if integrated::wanted(&self.options, &save_root) { + if self + .options + .execution_role + .starts_integrated_server(self.options.server_thread) + { return self.open_integrated(folder, name, seed, creative, create); } self.perf.world_opening(); @@ -10556,7 +10562,7 @@ impl Game<'_> { } // The loading screen's chunk map fills ring by ring, as vanilla's. self.streaming.loading = - self.options.dedicated.is_none() && self.loading_reason().is_some(); + self.options.execution_role.is_client() && self.loading_reason().is_some(); let (uploads, unloaded) = horizon_client::spans::time("streaming", || self.streaming.update(&mut self.world)); if let Some(lod) = &mut self.lod { @@ -12243,7 +12249,7 @@ fn synchronous_startup(options: &Options) -> bool { || options.world_frames.is_some() || options.benchmark.is_some() || options.control.is_some() - || options.dedicated.is_some() + || !options.execution_role.is_client() || std::env::var_os("HORIZON_DISPENSE_FIXTURE").is_some() || std::env::var_os("HORIZON_SCULK_FIXTURE").is_some() || std::env::var_os("HORIZON_COMMAND_SCRIPT").is_some() @@ -12784,7 +12790,7 @@ fn start_game<'a>( game.sculk_fixture(&spec)?; return Ok(None); } - if game.options.dedicated.is_some() { + if game.options.execution_role == execution_role::ExecutionRole::DedicatedServer { game.run_dedicated()?; return Ok(None); } @@ -13061,7 +13067,7 @@ impl Loading { ); let renderer_ms = renderer_at.elapsed().as_millis(); // Audio is created on the main thread, after the core is ready. - let audio = if options.dedicated.is_some() { + let audio = if !options.execution_role.is_client() { Audio::silent(&options.resources) } else { Audio::new(&options.resources) @@ -13236,13 +13242,15 @@ pub fn run() -> Result<()> { let Some(mut options) = Options::read()? else { return Ok(()); }; - if options.dedicated.is_some() && !dedicated::preflight(&mut options)? { + if options.execution_role == execution_role::ExecutionRole::DedicatedServer + && !dedicated::preflight(&mut options)? + { return Ok(()); } // Before any worker starts: on Linux they go behind the server thread // and the render thread. horizon_threads::put_helpers_behind(); - if options.dedicated.is_none() { + if options.execution_role.is_client() { // The render thread, like the server thread, ahead of the workers on // macOS and Windows. While terrain loads, the worker pools (two with // single player's server thread) and other programs outnumber the @@ -13252,7 +13260,7 @@ pub fn run() -> Result<()> { } // Someone is playing: the anonymous ping behind the launcher's "N playing now". Not for a // server, a benchmark or a run that quits by itself. - if options.dedicated.is_none() + if options.execution_role.is_client() && options.benchmark.is_none() && !options.offscreen && options.frames.is_none() @@ -13293,7 +13301,7 @@ pub fn run() -> Result<()> { )?; let gui = loaded.gui.ok_or("missing menu bundle")?; options.resources = loaded.root.clone(); - let audio = if options.dedicated.is_some() { + let audio = if !options.execution_role.is_client() { Audio::silent(&options.resources) } else { Audio::new(&options.resources) diff --git a/crates/horizon-client/src/game/execution_role.rs b/crates/horizon-client/src/game/execution_role.rs new file mode 100644 index 000000000..e9054576d --- /dev/null +++ b/crates/horizon-client/src/game/execution_role.rs @@ -0,0 +1,39 @@ +//! A Game's execution role is fixed at construction. Connecting or disconnecting +//! changes `remote`, not this role. A client may simulate a world in-frame. + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ExecutionRole { + Client, + IntegratedServer, + DedicatedServer, +} + +impl ExecutionRole { + /// Only a client owns presentation work (audio, loading UI, chunk meshes). + pub fn is_client(self) -> bool { + self == Self::Client + } + + /// The optional in-frame simulation remains a client execution mode. + pub fn starts_integrated_server(self, server_thread: bool) -> bool { + self.is_client() && server_thread + } +} + +#[cfg(test)] +mod tests { + use super::ExecutionRole::*; + + #[test] + fn world_loading_keeps_in_frame_clients_and_server_roles_separate() { + for (role, client, threaded) in [ + (Client, true, true), + (IntegratedServer, false, false), + (DedicatedServer, false, false), + ] { + assert_eq!(role.is_client(), client); + assert_eq!(role.starts_integrated_server(true), threaded); + assert!(!role.starts_integrated_server(false)); + } + } +} diff --git a/crates/horizon-client/src/game/integrated.rs b/crates/horizon-client/src/game/integrated.rs index 44df5fe20..f70c90432 100644 --- a/crates/horizon-client/src/game/integrated.rs +++ b/crates/horizon-client/src/game/integrated.rs @@ -12,6 +12,7 @@ //! way. use super::{ Game, Options, PackResources, Resources, UiResources, + execution_role::ExecutionRole, lan::{LanSettings, Owner}, }; use horizon_client::{ @@ -26,7 +27,6 @@ use horizon_protocol::connection::Pipe; use horizon_protocol::{crypto::offline_uuid, text::translatable}; use std::{ net::TcpListener, - path::Path, sync::{ Arc, Mutex, atomic::{AtomicBool, AtomicI32, AtomicU16, AtomicU32, Ordering}, @@ -170,11 +170,6 @@ struct Launch { player: String, } -/// Whether this world opens on a server thread: asked for. -pub(super) fn wanted(options: &Options, _world: &Path) -> bool { - options.server_thread && options.dedicated.is_none() -} - impl Game<'_> { /// `Minecraft.hasSingleplayerServer`: a world of this window's own, run /// in the frame or on its server thread. @@ -215,9 +210,10 @@ impl Game<'_> { }; let mut options = self.options.clone(); // A server without a console: no local player, no window, no control socket. + // Compatibility for gameplay checks outside startup/world loading. options.dedicated = Some(Vec::new()); options.server_thread = false; - options.integrated = true; + options.execution_role = ExecutionRole::IntegratedServer; options.control = None; options.benchmark = None; options.frames = None; diff --git a/crates/horizon-client/src/game/levels.rs b/crates/horizon-client/src/game/levels.rs index 895cda08c..d5b9c8d90 100644 --- a/crates/horizon-client/src/game/levels.rs +++ b/crates/horizon-client/src/game/levels.rs @@ -221,7 +221,7 @@ impl Game<'_> { /// holds it (`self.lan` is taken during the network tick). pub(super) fn enter_level(&mut self, host: Option<&mut LanHost>, target: Dim) -> Result<()> { // The in-frame game keeps its one level (`enter_dimension`). - if self.options.dedicated.is_none() { + if self.options.execution_role.is_client() { return Ok(()); } if target != self.dim { @@ -261,7 +261,7 @@ impl Game<'_> { /// nobody is in it; returns the level to put back afterwards. Only a /// server holds other levels. pub(super) fn enter_command_level(&mut self, dim: Dim) -> Option { - if self.options.dedicated.is_none() || dim == self.dim { + if self.options.execution_role.is_client() || dim == self.dim { return None; } let back = self.dim; diff --git a/crates/horizon-client/src/game/world_loading.rs b/crates/horizon-client/src/game/world_loading.rs index bd9ae7caa..91c375322 100644 --- a/crates/horizon-client/src/game/world_loading.rs +++ b/crates/horizon-client/src/game/world_loading.rs @@ -250,7 +250,7 @@ pub(super) fn make_streaming( streaming.nether_shading = dim == Dim::Nether; // A server draws nothing: neither the integrated server nor the // dedicated one meshes sections. - streaming.mesh = !options.integrated && options.dedicated.is_none(); + streaming.mesh = options.execution_role.is_client(); Ok((streaming, save, locator)) } diff --git a/docs/server-levels.md b/docs/server-levels.md index df45efc1f..36571d6b6 100644 --- a/docs/server-levels.md +++ b/docs/server-levels.md @@ -32,11 +32,17 @@ stay in the host's dimension. That mode is the escape hatch, so it gets no multi ## One code path for both -The dedicated server and the server thread are the same code. The server thread runs a `Game` with -`options.dedicated` set and a hub without a host player. The window joins it through memory as one -more player. Everything above serves both, and `server_dimension_players` (vanilla clients on Horizon's -dedicated server) and `lan_e2e --server-thread` (Horizon clients on the server thread) check it from -the two sides. +The dedicated server and the server thread are the same code. Each `Game` has an explicit +`ExecutionRole`: `Client`, `IntegratedServer` or `DedicatedServer`, selected before construction in +`Options.execution_role`. Startup, world loading and opening other dimensions use that role. Only +clients mesh chunks and show loading progress; a client can still run its simulation in-frame. +Connecting or disconnecting changes `remote`, independently of the execution role. + +The server thread has a hub without a host player. The window joins it through memory as one +more player. It still sets `options.dedicated` for gameplay checks outside the role migration's +startup/world-loading scope. Everything above serves both servers, and `server_dimension_players` +(vanilla clients on Horizon's dedicated server) and `lan_e2e --server-thread` (Horizon clients on the +server thread) check it from the two sides. ## Cost of an extra level