diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index dd111038b..31e5bb7cf 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -11031,6 +11031,21 @@ impl Game<'_> { } return Ok(false); } + // A server pack's reload covers the frame with its progress + // (`LoadingOverlay`) until `tick_server_packs` applies it. + if let Some(reload) = &self.server_packs.reload { + if let Some(renderer) = &mut self.renderer { + let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]); + horizon_client::level_loading::draw_progress( + &mut hud, + &self.gui.atlas, + reload.progress.line(), + reload.progress.fraction(), + ); + return renderer.render(&loading_frame(&hud)); + } + return Ok(false); + } let Some(size) = self .renderer .as_ref() @@ -12118,6 +12133,19 @@ impl ApplicationHandler for Game<'_> { { return; } + // The pack reload's progress overlay takes no input + // (`LoadingOverlay`): `start_pack_reload` released what was held. + if self.server_packs.reload.is_some() + && !matches!( + &event, + WindowEvent::CloseRequested + | WindowEvent::Focused(_) + | WindowEvent::Resized(_) + | WindowEvent::RedrawRequested + ) + { + return; + } if self.pending_save.is_some() { match event { WindowEvent::CloseRequested => { @@ -13625,7 +13653,6 @@ const NUMERIC_KEYS: [KeyCode; 11] = [ KeyCode::Numpad9, ]; -const STAGES: u32 = 10; const STAGE_LINES: [&str; 10] = [ "Loading menu", "Loading assets", @@ -13641,19 +13668,28 @@ const STAGE_LINES: [&str; 10] = [ /// The background loader's checkpoint, read by the loading screen each frame. #[derive(Clone)] -struct Progress(Arc); +struct Progress { + value: Arc, + lines: &'static [&'static str], +} impl Progress { fn new() -> Self { - Self(Arc::new(AtomicU32::new(0))) + Self::with(&STAGE_LINES) + } + fn with(lines: &'static [&'static str]) -> Self { + Self { + value: Arc::new(AtomicU32::new(0)), + lines, + } } fn set(&self, stage: u32) { - self.0.store(stage, Ordering::Relaxed); + self.value.store(stage, Ordering::Relaxed); } fn fraction(&self) -> f32 { - self.0.load(Ordering::Relaxed) as f32 / (STAGES - 1) as f32 + self.value.load(Ordering::Relaxed) as f32 / (self.lines.len() - 1) as f32 } fn line(&self) -> &'static str { - STAGE_LINES[(self.0.load(Ordering::Relaxed) as usize).min(STAGE_LINES.len() - 1)] + self.lines[(self.value.load(Ordering::Relaxed) as usize).min(self.lines.len() - 1)] } } diff --git a/crates/horizon-client/src/game/packs.rs b/crates/horizon-client/src/game/packs.rs index 89d7738f8..013b94de2 100644 --- a/crates/horizon-client/src/game/packs.rs +++ b/crates/horizon-client/src/game/packs.rs @@ -1,14 +1,23 @@ //! Resource pack selection bound to the game: the pack screen lists, and a //! full resource reload after a change, while playing. -use super::Game; +use super::{ + Game, PackResources, Resources, UiResources, build_renderer, prepare_renderer_textures, +}; use horizon_client::{ + assets::Assets, + gfx::PreparedTextures, hud::HudAtlas, lang, pack_screen::{self, Compat, PackInfo, Source as PackSource}, packs::{self, Pack, Position}, + streaming::WorkerContext, text::{Style, Text}, }; -use std::path::{Path, PathBuf}; +use std::{ + collections::BTreeSet, + path::{Path, PathBuf}, + sync::Arc, +}; /// Where overlay roots are built (beside options.txt). pub fn cache_dir() -> PathBuf { @@ -98,7 +107,7 @@ pub(super) fn pack_infos(vanilla: &Path) -> Vec { infos } impl Game<'_> { - fn vanilla_root(&self) -> PathBuf { + pub(super) fn vanilla_root(&self) -> PathBuf { if self.ui.vanilla_root.as_os_str().is_empty() { self.options.resources.clone() } else { @@ -138,35 +147,36 @@ impl Game<'_> { self.ui.options.resource_error = Some(e.to_string()); } } - /// Reload from the selected packs and the server's. - pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> { + /// The CPU half of the reload, safe on a worker (`PackReload` runs it): + /// `apply_staged_pack` finishes the GPU half and the swap. + pub(super) fn stage_selected_packs(&mut self) -> horizon_client::Result { let vanilla = self.vanilla_root(); if self.ui.vanilla_root.as_os_str().is_empty() { self.ui.vanilla_root = vanilla.clone(); } - let server = &self.server_packs.active; - let root = startup_root(&vanilla, &self.settings.resource_packs, server)?; - let root = - super::mods::content_root(&root, &self.settings.mods.enabled, &self.options.dev_mods); - self.reload_resources(&root) + stage_pack( + &vanilla, + &self.settings.resource_packs, + &self.server_packs.active, + &self.settings.mods.enabled, + &self.options.dev_mods, + &self.pack.assets, + &self.res, + &self.pack.context, + self.settings.force_unicode, + self.settings.japanese_glyphs, + &super::Progress::new(), + ) } - fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> { - let render = std::sync::Arc::new(self.pack.assets.reload(root)?); - let mut atlas = HudAtlas::build_over(root, &self.vanilla_root())?; - atlas.font.uniform = self.settings.force_unicode; - atlas.font.japanese = self.settings.japanese_glyphs; - let gui = super::UiResources { - atlas, - panorama: std::sync::Arc::new(horizon_client::gfx::panorama::Faces::load(root)), - }; - let pack = super::PackResources::load( + /// The render-thread half: the renderer on the new pack's textures, then + /// the swap (`ReloadInstance`'s application listeners). + pub(super) fn apply_staged_pack(&mut self, staged: StagedPack) -> horizon_client::Result<()> { + let StagedPack { root, - self.pack.assets.clone(), - render, - self.res.facts(), - self.pack.context.biomes.clone(), - self.pack.context.light.clone(), - )?; + gui, + pack, + textures, + } = staged; // Keep the working GPU resources, and hand its window surface over. if let Some(renderer) = &mut self.renderer { renderer.release_surface(); @@ -174,7 +184,17 @@ impl Game<'_> { let renderer = match self .window .clone() - .map(|window| self.renderer_for(window, &pack, &gui)) + .map(|window| { + build_renderer( + window, + self.options.offscreen, + &pack, + &gui, + self.renderer.as_ref(), + Some(textures), + self.settings.horizon.api, + ) + }) .transpose() { Ok(renderer) => renderer, @@ -190,18 +210,18 @@ impl Game<'_> { } }; // Everything loaded: switch over. - self.waypoint_styles = horizon_client::locator_bar::WaypointStyles::load(root); - self.armor_stand_assets.reload_trims(root); + self.waypoint_styles = horizon_client::locator_bar::WaypointStyles::load(&root); + self.armor_stand_assets.reload_trims(&root); self.random_tick_sections.clear(); self.portals.sections.clear(); - self.options.resources = root.to_path_buf(); + self.options.resources = root.clone(); let context = pack.context.clone(); self.pack = pack; self.gui = gui; // The atlas's sprites moved: the particle engine re-reads every rect // and the packs' sprite sets, or every particle draws a stale region. self.particles - .reload(root, &self.gui.atlas.sprites, &self.gui.atlas.translucent); + .reload(&root, &self.gui.atlas.sprites, &self.gui.atlas.translucent); horizon_client::ui_theme::reset_atlas(); // Villager hats come from the pack's texture metadata (shared data, swapped in place). *self @@ -210,11 +230,11 @@ impl Game<'_> { .villager_hats .write() .unwrap_or_else(std::sync::PoisonError::into_inner) = - horizon_client::mobs::villager_hats::VillagerHats::load(root); - horizon_client::ui::load_metadata(root); - lang::set(lang::Language::load(&[&root.to_path_buf()], &lang::code())); + horizon_client::mobs::villager_hats::VillagerHats::load(&root); + horizon_client::ui::load_metadata(&root); + lang::set(lang::Language::load(&[&root], &lang::code())); self.refresh_remote_language(); - self.audio.reload(root); + self.audio.reload(&root); self.clear_lod(); self.streaming.reload_resources(&mut self.world, context); self.pending_uploads.clear(); @@ -229,4 +249,66 @@ impl Game<'_> { self.creative_screen.forget_items(); Ok(()) } + /// `Minecraft.reloadResourcePacks`: the reload staged on the spot, then + /// applied (`PackReload` runs the same two phases across frames). + pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> { + let staged = self.stage_selected_packs()?; + self.apply_staged_pack(staged) + } +} + +/// The CPU half of `reload_selected_packs` (`PackReload` runs it on a +/// worker; `apply_staged_pack` does the GPU half and the swap): the overlay +/// root, the models and sprites, the menu bundle, the pack's tables and its +/// CPU mip chains. +pub(super) fn stage_pack( + vanilla_root: &Path, + selected: &[String], + server: &[Pack], + mods_enabled: &BTreeSet, + dev_mods: &[PathBuf], + assets: &Arc, + res: &Resources, + context: &WorkerContext, + force_unicode: bool, + japanese: bool, + progress: &super::Progress, +) -> horizon_client::Result { + let root = startup_root(vanilla_root, selected, server)?; + let root = super::mods::content_root(&root, mods_enabled, dev_mods); + progress.set(1); + let render = Arc::new(assets.reload(&root)?); + let mut atlas = HudAtlas::build_over(&root, vanilla_root)?; + atlas.font.uniform = force_unicode; + atlas.font.japanese = japanese; + let gui = UiResources { + atlas, + panorama: Arc::new(horizon_client::gfx::panorama::Faces::load(&root)), + }; + progress.set(2); + let pack = PackResources::load( + &root, + assets.clone(), + render, + res.facts(), + context.biomes.clone(), + context.light.clone(), + )?; + let textures = prepare_renderer_textures(&pack); + progress.set(3); + Ok(StagedPack { + root, + gui, + pack, + textures, + }) +} + +/// Everything a pack reload builds on the CPU (`apply_staged_pack` finishes +/// it on the render thread). +pub(super) struct StagedPack { + pub root: PathBuf, + pub gui: UiResources, + pub pack: PackResources, + pub textures: PreparedTextures, } diff --git a/crates/horizon-client/src/game/server_packs.rs b/crates/horizon-client/src/game/server_packs.rs index 58d25de0e..d0769b894 100644 --- a/crates/horizon-client/src/game/server_packs.rs +++ b/crates/horizon-client/src/game/server_packs.rs @@ -3,7 +3,7 @@ //! and `DownloadedPackSource` as `Minecraft` drives it): the prompt, the //! server list's setting, downloads into `downloads/`, and a resource reload //! with the server's packs on top of the player's own. -use super::Game; +use super::{Game, Progress}; use horizon_client::{ Result, confirm_screen::Confirm, @@ -82,6 +82,9 @@ pub(super) struct ServerPackState { pub(super) active: Vec, /// The download batches' notifiers, oldest first. notifiers: Vec, + /// A `tick`-asked reload staged on a worker (`LoadingOverlay`): the + /// frame draws its progress until `tick_server_packs` applies it. + pub(super) reload: Option, } /// `DownloadedPackSource.createDownloadNotifier`: one batch's progress in its /// own `SystemToast`. @@ -191,6 +194,21 @@ fn saves() -> PathBuf { std::env::current_dir().unwrap_or_default().join("saves") } +/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`. +const RELOAD_LINES: [&str; 4] = [ + "Preparing resources", + "Loading assets", + "Loading pack tables", + "Applying resources", +]; +/// A pack reload `ServerPacks::tick` asked for: the CPU half runs on a +/// worker while `progress` feeds the loading screen, then the apply lands +/// on the render thread. +pub(super) struct PackReload { + pub(super) progress: Progress, + handle: std::thread::JoinHandle>, +} + impl Game<'_> { /// `ConnectScreen`: the server's setting decides what its packs do /// (`configureForServerControl`); single player's own server takes them @@ -314,6 +332,7 @@ impl Game<'_> { if self.rest_loader.is_some() || self.world_loader.is_some() { return; } + self.drive_pack_reload(); let finished = self .server_packs .queue @@ -328,13 +347,8 @@ impl Game<'_> { if !tick.download.is_empty() { self.start_pack_downloads(tick.download); } - if let Some(mut load) = tick.reload { - while let Some(recovery) = { - let ok = self.reload_server_packs(&load); - self.server_packs.packs.reloaded(ok) - } { - load = recovery; - } + if let Some(load) = tick.reload { + self.start_pack_reload(&load); } } for (id, status) in self.server_packs.packs.take_replies() { @@ -379,24 +393,90 @@ impl Game<'_> { } } /// `DownloadedPackSource.startReload`: the packs on top of the player's - /// own, then `Minecraft.reloadResourcePacks`. False when a pack has no - /// readable `pack.mcmeta` or the reload failed. - fn reload_server_packs(&mut self, load: &[(u128, PathBuf)]) -> bool { - let packs: Option> = load + /// own, staged on a worker (`Minecraft.reloadResourcePacks` under a + /// `LoadingOverlay`); `drive_pack_reload` applies it. A pack with no + /// readable `pack.mcmeta` fails the reload at once, as `onFailure`. + fn start_pack_reload(&mut self, load: &[(u128, PathBuf)]) { + let server: Option> = load .iter() .map(|(id, path)| packs::server_pack(*id, path)) .collect(); - let Some(packs) = packs else { + let Some(server) = server else { log::warn!("Horizon: invalid pack metadata in a server pack, ignoring all"); - return false; + self.finish_pack_reload(false); + return; }; - self.server_packs.active = packs; - match self.reload_selected_packs() { + self.server_packs.active = server.clone(); + // The overlay swallows input until the apply (`LoadingOverlay`): + // release what is held so nothing keeps acting while it covers the + // frame (`KeyMapping.releaseAll` when a screen opens). + self.keys.clear(); + self.physical_keys.clear(); + self.left = false; + self.right = false; + let vanilla = self.vanilla_root(); + if self.ui.vanilla_root.as_os_str().is_empty() { + self.ui.vanilla_root = vanilla.clone(); + } + let (selected, mods, dev_mods) = ( + self.settings.resource_packs.clone(), + self.settings.mods.enabled.clone(), + self.options.dev_mods.clone(), + ); + let (assets, res, context) = ( + self.pack.assets.clone(), + self.res.clone(), + self.pack.context.clone(), + ); + let (unicode, japanese) = (self.settings.force_unicode, self.settings.japanese_glyphs); + let progress = Progress::with(&RELOAD_LINES); + let worker = progress.clone(); + let handle = std::thread::Builder::new() + .name("server-pack-reload".into()) + .spawn(move || { + super::packs::stage_pack( + &vanilla, &selected, &server, &mods, &dev_mods, &assets, &res, &context, + unicode, japanese, &worker, + ) + .map_err(|e| e.to_string()) + }); + match handle { + Ok(handle) => self.server_packs.reload = Some(PackReload { progress, handle }), + Err(e) => { + log::error!("Horizon: could not start the pack reload worker: {e}"); + self.finish_pack_reload(false); + } + } + } + /// `PackReloadConfig.Callbacks`: applies the staged reload once its + /// worker is done; on failure the packs active before reload again. + fn drive_pack_reload(&mut self) { + let Some(reload) = &self.server_packs.reload else { + return; + }; + if !reload.handle.is_finished() { + return; + } + let reload = self.server_packs.reload.take().unwrap(); + let staged = reload + .handle + .join() + .unwrap_or_else(|_| Err("the pack reload worker panicked".into())); + let ok = match staged + .and_then(|staged| self.apply_staged_pack(staged).map_err(|e| e.to_string())) + { Ok(()) => true, Err(e) => { log::warn!("Horizon: server resource pack reload failed: {e}"); false } + }; + self.finish_pack_reload(ok); + } + /// `ServerPacks::reloaded` and the recovery reload it can ask for. + fn finish_pack_reload(&mut self, ok: bool) { + if let Some(recovery) = self.server_packs.packs.reloaded(ok) { + self.start_pack_reload(&recovery); } } diff --git a/docs/resource-packs.md b/docs/resource-packs.md index 783c381dd..69eee3393 100644 --- a/docs/resource-packs.md +++ b/docs/resource-packs.md @@ -33,7 +33,7 @@ harness named checks that loader against vanilla). | Overlays per format | `pack.mcmeta` `overlays` | works | `test_resource_pack_parity` (an overlay for 26.3 applied, an older one not) | | Filters | `pack.mcmeta` `filter` | works | `test_resource_pack_parity` (`packs/pattern.rs` regex subset) | | Folder and zip packs, order, built-in packs | `resourcepacks/` | works | `test_resource_pack_parity`, `test_pack_selection_parity` | -| Server resource packs: the prompt, the server list's Server Resource Packs setting, download (250 MiB, SHA-1, `downloads/` keeps 20), applied on top of the player's packs, removed on leaving | the server's `resource_pack_push`/`_pop` | works, no download toasts | `tools/server_pack_e2e.py` (against vanilla's server and Horizon's), unit tests in `server_packs.rs`; `test_server_resource_pack_parity` for Horizon's server | +| Server resource packs: the prompt, the server list's Server Resource Packs setting, download (250 MiB, SHA-1, `downloads/` keeps 20), download toasts, a progress screen while the packs reload, applied on top of the player's packs, removed on leaving | the server's `resource_pack_push`/`_pop` | works | `tools/server_pack_e2e.py` (against vanilla's server and Horizon's), unit tests in `server_packs.rs`; `test_server_resource_pack_parity` for Horizon's server | | Other namespaces | `assets//` | works | `test_resource_pack_parity` | | Block states: variants, multipart, weights, rotations, uvlock | `blockstates/` | works | `test_blockstate_model_parity`, `test_model_choice_parity`, `test_blockstate_transform_parity` | | Block models: parents, elements, rotations, faces, cullface, tint, shade, light emission, ambient occlusion | `models/block/` | works | `test_model_resolution_parity`, `test_model_baking_parity`, `test_section_mesh_parity` | @@ -84,7 +84,6 @@ shaders, the missing pieces are missing vanilla features too, and each vanilla p | Gap | Size | Where | | --- | --- | --- | | Animation `interpolate` | small | fix PR (`gfx.rs` `animate_sprites`) | -| Server pack download toasts (`download.pack.*`) | small | `game/server_packs.rs` | | GUI scaling `width`/`height`, `stretch_inner`, `tile`, `stretch` | small | next fix PR (`ui.rs`) | | Villager hats | small | [#334](https://github.com/ludovic111/horizon/issues/334) | | `blur`/`clamp` from packs | small | [#335](https://github.com/ludovic111/horizon/issues/335) |