Move local world initialization off the UI thread

This commit is contained in:
rdbtCVS
2026-10-19 18:26:38 +01:00
parent defaa7cd2a
commit 7f4193f88e
4 changed files with 463 additions and 162 deletions
+118 -143
View File
@@ -152,6 +152,7 @@ mod villagers;
mod wandering_trader;
mod wind;
mod wither;
mod world_loading;
mod zombie_villagers;
use horizon_client::{
Result,
@@ -999,6 +1000,7 @@ struct Game<'a> {
res: Arc<Resources>,
rest_loader: Option<WorldRestLoader>,
pending_entry: Option<PendingWorldEntry>,
world_loader: Option<world_loading::WorldLoader>,
pending_pack_reload: bool,
world_renderer_ready: bool,
collision: &'a BlockCollisionRegistry<'a>,
@@ -6764,80 +6766,60 @@ impl Game<'_> {
return self.open_integrated(folder, name, seed, creative, create);
}
self.perf.world_opening();
horizon_client::save::upgrade_older_world(&save_root)?;
horizon_client::save::migrate_legacy(&save_root)?;
self.session_lock = None;
self.session_lock = Some(horizon_client::save::SessionLock::acquire(&save_root)?);
let request = world_loading::WorldRequest {
save_root,
folder: folder.to_owned(),
name: name.map(str::to_owned),
seed,
creative,
create: create.cloned(),
};
if self.window.is_some() {
if self.title.is_none() {
self.title = Some(Title::new(
std::env::current_dir()?.join("saves"),
&self.options.resources,
));
}
self.world_loader = Some(world_loading::WorldLoader::start(
request,
self.res.clone(),
self.pack.clone(),
self.options.clone(),
self.settings.clone(),
)?);
return Ok(());
}
let prepared = request.prepare(&self.res, &self.pack, &self.options, &self.settings)?;
self.finish_open_world(prepared)
}
fn finish_open_world(&mut self, prepared: world_loading::PreparedWorld) -> Result<()> {
let world_loading::PreparedWorld {
save_root,
level,
world,
streaming,
save,
session_lock,
rand_sequences,
locator,
} = prepared;
let dim = Dim::from_id(&level.dimension);
horizon_client::crash::set_detail(
"World",
format!("{} ({})", level.name, save_root.display()),
);
self.session_lock = Some(session_lock);
self.end.set_locator(locator);
self.score = Default::default();
self.fns = Default::default();
self.bosses = Default::default();
reset_world_progress(&mut self.adv, &mut self.stats);
self.prefetch_advancement_tree();
self.restore_player_state = true;
// RandomSequences: the world's data/minecraft/random_sequences.dat.
self.rand_sequences =
horizon_client::save::read_dat(&save_root.join("data/minecraft/random_sequences.dat"))
.and_then(|t| t.get("data").cloned())
.map(|d| horizon_client::random_sequences::RandomSequences::from_nbt(&d))
.unwrap_or_default();
self.rand_sequences = rand_sequences;
self.border = Default::default();
let existing = Level::read(&save_root);
let created = existing.is_none();
let level = existing.unwrap_or_else(|| Level {
name: name.unwrap_or(folder).to_owned(),
// WorldOptions.randomSeed when no seed was given.
seed: seed.unwrap_or_else(|| {
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as u64);
(t ^ (t >> 29)).wrapping_mul(0x9E37_79B9_7F4A_7C15) as i64
}),
creative,
day_time: 0,
health: 20.0,
food: 20,
saturation: 5.0,
..Level::default()
});
let mut level = level;
// CreateWorldScreen's choices, before streaming reads the generator.
if created && let Some(o) = create {
level.hardcore = o.hardcore;
level.difficulty = horizon_client::title::DIFFICULTIES[o.difficulty()].into();
level.allow_commands = Some(o.commands(creative));
horizon_client::save::write_world_gen_settings(&save_root, level.seed, o.world_gen())?;
}
// Worlds made without the Create New World screen (--world, the
// control socket) take its default: commands on for Creative only.
if created {
level.allow_commands.get_or_insert(creative);
}
horizon_client::crash::set_detail("World", format!("{} (saves/{folder})", level.name));
if let Some(d) = &self.options.dimension {
level.dimension = d.clone();
level.position = None;
}
let dim = Dim::from_id(&level.dimension);
let (mut streaming, save) = self.make_streaming(&save_root, dim, level.seed)?;
streaming.radius = self.settings.render_distance;
let mut world = World::new(self.res.facts().air, level.seed);
world.fast_lava = dim == Dim::Nether;
world.game_time = level.time;
world.day_time = self.options.time.unwrap_or(level.day_time);
world.weather = level.weather;
if let Some(w) = self.options.weather.as_deref() {
// /weather: clear, rain or thunder for a long duration.
let (rain, thunder) = (w != "clear", w == "thunder");
world.weather = horizon_client::world::Weather {
clear_time: if w == "clear" { 6000 } else { 0 },
rain_time: 6000,
thunder_time: 6000,
raining: rain,
thundering: thunder,
rain_level: if rain { (1.0, 1.0) } else { (0.0, 0.0) },
thunder_level: if thunder { (1.0, 1.0) } else { (0.0, 0.0) },
};
}
self.set_mode_id(level.mode());
let mut inventory = vec![None; 36];
self.armor = [None, None, None, None];
@@ -6905,7 +6887,6 @@ impl Game<'_> {
self.dim = dim;
self.world_root = save_root.clone();
self.block_ticks = Ticks::new(self.level.seed as i32);
self.install_live_features();
self.tick_chunks.clear();
self.clear_entities();
self.travelling = None;
@@ -6954,10 +6935,6 @@ impl Game<'_> {
self.apply_settings();
self.streaming.sky_light = dim != Dim::Nether;
self.streaming.nether_shading = dim == Dim::Nether;
// New worlds get their level.dat right away.
if !save_root.join("level.dat").exists() {
self.level.write(&save_root)?;
}
self.load_world_state();
Ok(())
}
@@ -6968,74 +6945,16 @@ impl Game<'_> {
dim: Dim,
seed: i64,
) -> Result<(Streaming, Arc<SaveDir>)> {
let save = Arc::new(SaveDir::new(horizon_client::save::dimension_dir(
let (streaming, save, locator) = world_loading::make_streaming(
&self.res,
&self.pack,
&self.options,
&self.settings,
world_root,
dim.id(),
)));
let (res, pack) = (&*self.res, &self.pack);
let flat = (dim == Dim::Overworld)
.then(|| horizon_client::save::world_gen_settings(world_root))
.flatten()
.and_then(|s| {
horizon_client::flatgen::FlatTerrain::from_settings(
&s,
pack.assets.registry,
res.biomes(),
)
});
let mut streaming = if let Some(inner) = flat {
Streaming::new(
Arc::new(Persistent {
inner: Arc::new(inner),
save: save.clone(),
registry: pack.assets.clone(),
biomes: res.biomes().clone(),
}),
pack.context.clone(),
res.threads(),
)
} else if self.options.test_terrain && dim == Dim::Overworld {
let inner = Arc::new(TestTerrain::new(pack.assets.registry, res.biomes())?);
Streaming::new(
Arc::new(Persistent {
inner,
save: save.clone(),
registry: pack.assets.clone(),
biomes: res.biomes().clone(),
}),
pack.context.clone(),
res.threads(),
)
} else {
// WorldOptions.generateStructures, from the world's settings.
let structures =
horizon_client::save::WorldGen::read(world_root).is_none_or(|w| w.structures);
let inner = Arc::new(VanillaTerrain::for_world(&self.options.resources.join("worldgen"), pack.assets.registry, res.biomes(), seed, dim.id(), structures).map_err(|e| {
format!("{e}\nPrepare the worldgen bundle with the launcher, or: horizon-prepare --prepare")
})?);
self.end.set_locator(Some(inner.clone()));
Streaming::new(
Arc::new(Persistent {
inner,
save: save.clone(),
registry: pack.assets.clone(),
biomes: res.biomes().clone(),
}),
pack.context.clone(),
res.threads(),
)
};
streaming.radius = self.settings.render_distance;
streaming.biome_blend = self.settings.biome_blend;
streaming.smooth_lighting = self.settings.smooth_lighting;
streaming.cutout_leaves = self.settings.cutout_leaves;
streaming.cull_leaves = self.settings.horizon.cull_leaves;
streaming.biome_zoom = BiomeZoom::new(obfuscate_seed(seed));
streaming.sky_light = dim != Dim::Nether;
streaming.nether_shading = dim == Dim::Nether;
// A server draws nothing: neither the integrated server nor the
// dedicated one meshes sections.
streaming.mesh = !self.options.integrated && self.options.dedicated.is_none();
dim,
seed,
)?;
self.end.set_locator(locator);
Ok((streaming, save))
}
/// Change dimension through a portal (Entity.changeDimension with
@@ -9070,6 +8989,23 @@ impl Game<'_> {
Ok(())
}
fn render(&mut self) -> Result<bool> {
if let Some(loader) = &self.world_loader {
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,
if loader.cancelled {
"Cancelling world"
} else {
"Opening world"
},
0.0,
);
return renderer.render(&loading_frame(&hud));
}
return Ok(false);
}
if self.pending_entry.is_some() {
if let (Some(renderer), Some(loader)) = (&mut self.renderer, &self.rest_loader) {
let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]);
@@ -10060,16 +9996,23 @@ impl ApplicationHandler for Game<'_> {
// The press state of Horizon-style buttons.
horizon_client::ui_theme::set_pressed(*state == ElementState::Pressed);
}
if self.pending_entry.is_some()
if (self.pending_entry.is_some() || self.world_loader.is_some())
&& matches!(&event, WindowEvent::KeyboardInput { event, .. }
if event.state == ElementState::Pressed
&& event.physical_key == PhysicalKey::Code(KeyCode::Escape))
{
self.pending_entry = None;
if let Some(loader) = self.world_loader.take() {
self.world_loader = Some(if loader.cancelled {
loader
} else {
loader.cancel()
});
}
self.perf.cancel_world_opening();
return;
}
if self.pending_entry.is_some()
if (self.pending_entry.is_some() || self.world_loader.is_some())
&& !matches!(
&event,
WindowEvent::CloseRequested
@@ -10834,6 +10777,7 @@ impl<'a> Game<'a> {
res: res.clone(),
rest_loader: None,
pending_entry: None,
world_loader: None,
pending_pack_reload: false,
world_renderer_ready: false,
collision: res.collision(),
@@ -11183,7 +11127,7 @@ impl WorldRestLoader {
impl Game<'_> {
fn defer_world_entry(&mut self, entry: PendingWorldEntry) -> Result<bool> {
self.poll_world_rest()?;
if self.rest_loader.is_none() {
if self.rest_loader.is_none() && self.world_loader.is_none() {
return Ok(false);
}
self.perf.world_opening();
@@ -11200,6 +11144,20 @@ impl Game<'_> {
fn resume_world_entry(&mut self) -> Result<()> {
self.poll_world_rest()?;
if let Some(loader) = &self.world_loader {
if !loader.ready() {
return Ok(());
}
let loader = self
.world_loader
.take()
.ok_or("missing world initialization worker")?;
let cancelled = loader.cancelled;
let prepared = loader.finish();
if !cancelled {
self.finish_open_world(prepared?)?;
}
}
if self.rest_loader.is_some() {
return Ok(());
}
@@ -11817,7 +11775,24 @@ fn start_game<'a>(
}
Some(folder) => {
let (seed, creative) = (game.options.seed, game.options.creative);
game.open_world(&folder, None, Some(seed), creative, None)?;
if std::env::var_os("HORIZON_COMMAND_SCRIPT").is_some() {
// The command harness runs before entering the window loop.
game.open_world(&folder, None, Some(seed), creative, None)?;
} else {
// Start after the window is handed over, so --world uses the
// same responsive initialization path as the title screen.
game.title = Some(Title::new(
std::env::current_dir()?.join("saves"),
&game.options.resources,
));
game.pending_entry = Some(PendingWorldEntry::Local {
folder,
name: None,
seed: Some(seed),
creative,
create: None,
});
}
}
None => {
let mut title = Title::new(
+27 -18
View File
@@ -10,24 +10,7 @@ impl Game<'_> {
/// Load the configured/placed feature registry used by saplings, fungi
/// and bone meal (once per process; shares the generator's bundle).
pub(super) fn install_live_features(&self) {
if self.res.behaviours().live_features().is_some() {
return;
}
let dir = self.options.resources.join("worldgen");
let started = std::time::Instant::now();
let features = vanillagen::shared_data(&dir, self.pack.assets.registry)
.map_err(|e| e.to_string())
.and_then(|data| LiveFeatures::new(&data).map_err(|e| e.to_string()));
match features {
Ok(Some(features)) => {
self.res.behaviours().set_live_features(Arc::new(features));
log::info!("Horizon: live features ready in {:?}", started.elapsed());
}
Ok(None) => {
log::warn!("Horizon: the worldgen bundle has no features; saplings will not grow")
}
Err(e) => log::warn!("Horizon: live features unavailable: {e}"),
}
install_live_features(&self.res, &self.pack, &self.options);
}
/// `BoneMealItem.useOn` for the targeted block.
pub(super) fn use_bone_meal(&mut self, stack: &ItemStack) -> Result<bool> {
@@ -66,3 +49,29 @@ impl Game<'_> {
Ok(used)
}
}
/// Shared by the initialization worker and fixture setup.
pub(super) fn install_live_features(
res: &super::Resources,
pack: &super::PackResources,
options: &super::Options,
) {
if res.behaviours().live_features().is_some() {
return;
}
let dir = options.resources.join("worldgen");
let started = std::time::Instant::now();
let features = vanillagen::shared_data(&dir, pack.assets.registry)
.map_err(|e| e.to_string())
.and_then(|data| LiveFeatures::new(&data).map_err(|e| e.to_string()));
match features {
Ok(Some(features)) => {
res.behaviours().set_live_features(Arc::new(features));
log::info!("Horizon: live features ready in {:?}", started.elapsed());
}
Ok(None) => {
log::warn!("Horizon: the worldgen bundle has no features; saplings will not grow")
}
Err(e) => log::warn!("Horizon: live features unavailable: {e}"),
}
}
+1 -1
View File
@@ -109,7 +109,7 @@ impl Game<'_> {
pub(super) fn reload_packs(&mut self) {
// Pack reload updates tail-owned mob metadata. Keep menus responsive
// until those resources exist, then apply the latest selection once.
if self.rest_loader.is_some() {
if self.rest_loader.is_some() || self.world_loader.is_some() {
self.pending_pack_reload = true;
return;
}
@@ -0,0 +1,317 @@
//! Prepare local worlds without blocking the window's event loop.
use super::*;
pub(super) struct WorldRequest {
pub save_root: PathBuf,
pub folder: String,
pub name: Option<String>,
pub seed: Option<i64>,
pub creative: bool,
pub create: Option<horizon_client::title::WorldOptions>,
}
pub(super) struct PreparedWorld {
pub save_root: PathBuf,
pub level: Level,
pub world: World,
pub streaming: Streaming,
pub save: Arc<SaveDir>,
pub session_lock: horizon_client::save::SessionLock,
pub rand_sequences: horizon_client::random_sequences::RandomSequences,
pub locator: Option<Arc<VanillaTerrain>>,
}
impl WorldRequest {
pub fn prepare(
self,
res: &Resources,
pack: &PackResources,
options: &Options,
settings: &Settings,
) -> Result<PreparedWorld> {
let Self {
save_root,
folder,
name,
seed,
creative,
create,
} = self;
let name = name.as_deref();
let create = create.as_ref();
horizon_client::save::upgrade_older_world(&save_root)?;
horizon_client::save::migrate_legacy(&save_root)?;
let session_lock = horizon_client::save::SessionLock::acquire(&save_root)?;
// RandomSequences: the world's data/minecraft/random_sequences.dat.
let rand_sequences =
horizon_client::save::read_dat(&save_root.join("data/minecraft/random_sequences.dat"))
.and_then(|t| t.get("data").cloned())
.map(|d| horizon_client::random_sequences::RandomSequences::from_nbt(&d))
.unwrap_or_default();
let existing = Level::read(&save_root);
let created = existing.is_none();
let level = existing.unwrap_or_else(|| Level {
name: name.unwrap_or(&folder).to_owned(),
// WorldOptions.randomSeed when no seed was given.
seed: seed.unwrap_or_else(|| {
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as u64);
(t ^ (t >> 29)).wrapping_mul(0x9E37_79B9_7F4A_7C15) as i64
}),
creative,
day_time: 0,
health: 20.0,
food: 20,
saturation: 5.0,
..Level::default()
});
let mut level = level;
// CreateWorldScreen's choices, before streaming reads the generator.
if created && let Some(o) = create {
level.hardcore = o.hardcore;
level.difficulty = horizon_client::title::DIFFICULTIES[o.difficulty()].into();
level.allow_commands = Some(o.commands(creative));
horizon_client::save::write_world_gen_settings(&save_root, level.seed, o.world_gen())?;
}
// Worlds made without the Create New World screen (--world, the
// control socket) take its default: commands on for Creative only.
if created {
level.allow_commands.get_or_insert(creative);
}
if let Some(d) = &options.dimension {
level.dimension = d.clone();
level.position = None;
}
let dim = Dim::from_id(&level.dimension);
let (mut streaming, save, locator) =
make_streaming(res, pack, options, settings, &save_root, dim, level.seed)?;
streaming.radius = settings.render_distance;
let mut world = World::new(res.facts().air, level.seed);
world.fast_lava = dim == Dim::Nether;
world.game_time = level.time;
world.day_time = options.time.unwrap_or(level.day_time);
world.weather = level.weather;
if let Some(w) = options.weather.as_deref() {
// /weather: clear, rain or thunder for a long duration.
let (rain, thunder) = (w != "clear", w == "thunder");
world.weather = horizon_client::world::Weather {
clear_time: if w == "clear" { 6000 } else { 0 },
rain_time: 6000,
thunder_time: 6000,
raining: rain,
thundering: thunder,
rain_level: if rain { (1.0, 1.0) } else { (0.0, 0.0) },
thunder_level: if thunder { (1.0, 1.0) } else { (0.0, 0.0) },
};
}
growth::install_live_features(res, pack, options);
// Persist new metadata on the worker too, before publishing the world.
if !save_root.join("level.dat").exists() {
level.write(&save_root)?;
}
Ok(PreparedWorld {
save_root,
level,
world,
streaming,
save,
session_lock,
rand_sequences,
locator,
})
}
}
pub(super) struct WorldLoader {
pub cancelled: bool,
handle: JoinHandle<std::result::Result<PreparedWorld, String>>,
}
impl WorldLoader {
pub fn start(
request: WorldRequest,
res: Arc<Resources>,
pack: PackResources,
options: Options,
settings: Settings,
) -> Result<Self> {
let handle = std::thread::Builder::new()
.name("World initialization".into())
.spawn(move || {
request
.prepare(&res, &pack, &options, &settings)
.map_err(|e| e.to_string())
})?;
Ok(Self {
cancelled: false,
handle,
})
}
// Wait for construction and disposal off the window thread. Keeping this
// handle prevents a queued reopen racing the old session lock.
pub fn cancel(self) -> Self {
Self {
cancelled: true,
handle: std::thread::spawn(move || {
drop(self.finish());
Err("world initialization cancelled".into())
}),
}
}
pub fn ready(&self) -> bool {
self.handle.is_finished()
}
pub fn finish(self) -> Result<PreparedWorld> {
self.handle
.join()
.map_err(|_| "world initialization worker panicked")?
.map_err(Into::into)
}
}
pub(super) fn make_streaming(
res: &Resources,
pack: &PackResources,
options: &Options,
settings: &Settings,
world_root: &std::path::Path,
dim: Dim,
seed: i64,
) -> Result<(Streaming, Arc<SaveDir>, Option<Arc<VanillaTerrain>>)> {
let save = Arc::new(SaveDir::new(horizon_client::save::dimension_dir(
world_root,
dim.id(),
)));
let mut locator = None;
let flat = (dim == Dim::Overworld)
.then(|| horizon_client::save::world_gen_settings(world_root))
.flatten()
.and_then(|s| {
horizon_client::flatgen::FlatTerrain::from_settings(
&s,
pack.assets.registry,
res.biomes(),
)
});
let mut streaming = if let Some(inner) = flat {
Streaming::new(
Arc::new(Persistent {
inner: Arc::new(inner),
save: save.clone(),
registry: pack.assets.clone(),
biomes: res.biomes().clone(),
}),
pack.context.clone(),
res.threads(),
)
} else if options.test_terrain && dim == Dim::Overworld {
let inner = Arc::new(TestTerrain::new(pack.assets.registry, res.biomes())?);
Streaming::new(
Arc::new(Persistent {
inner,
save: save.clone(),
registry: pack.assets.clone(),
biomes: res.biomes().clone(),
}),
pack.context.clone(),
res.threads(),
)
} else {
// WorldOptions.generateStructures, from the world's settings.
let structures =
horizon_client::save::WorldGen::read(world_root).is_none_or(|w| w.structures);
let inner = Arc::new(VanillaTerrain::for_world(&options.resources.join("worldgen"), pack.assets.registry, res.biomes(), seed, dim.id(), structures).map_err(|e| {
format!("{e}\nPrepare the worldgen bundle with the launcher, or: horizon-prepare --prepare")
})?);
locator = Some(inner.clone());
Streaming::new(
Arc::new(Persistent {
inner,
save: save.clone(),
registry: pack.assets.clone(),
biomes: res.biomes().clone(),
}),
pack.context.clone(),
res.threads(),
)
};
streaming.radius = settings.render_distance;
streaming.biome_blend = settings.biome_blend;
streaming.smooth_lighting = settings.smooth_lighting;
streaming.cutout_leaves = settings.cutout_leaves;
streaming.cull_leaves = settings.horizon.cull_leaves;
streaming.biome_zoom = BiomeZoom::new(obfuscate_seed(seed));
streaming.sky_light = dim != Dim::Nether;
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();
Ok((streaming, save, locator))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn polling_does_not_wait_for_world_initialization() {
let (release, wait) = std::sync::mpsc::channel();
let loader = WorldLoader {
cancelled: false,
handle: std::thread::spawn(move || {
wait.recv().unwrap();
Err("test world failure".into())
}),
};
assert!(!loader.ready());
release.send(()).unwrap();
assert_eq!(
loader.finish().err().unwrap().to_string(),
"test world failure"
);
}
#[test]
fn cancellation_keeps_initialization_tracked_until_it_has_stopped() {
let (release, wait) = std::sync::mpsc::channel();
let loader = WorldLoader {
cancelled: false,
handle: std::thread::spawn(move || {
wait.recv().unwrap();
Err("cancelled test world".into())
}),
}
.cancel();
assert!(loader.cancelled);
assert!(!loader.ready());
release.send(()).unwrap();
assert_eq!(
loader.finish().err().unwrap().to_string(),
"world initialization cancelled"
);
}
#[test]
fn worker_panics_reach_the_client() {
let loader = WorldLoader {
cancelled: false,
handle: std::thread::spawn(|| panic!("broken world initialization")),
};
assert_eq!(
loader.finish().err().unwrap().to_string(),
"world initialization worker panicked"
);
}
#[test]
fn prepared_world_can_cross_threads() {
fn send<T: Send>() {}
send::<PreparedWorld>();
}
}