Native mods: a server's levels loading and unloading (LevelEvent)

native_level_load when a level opens, before its chunks; native_level_unload
once it has closed, after its last save: a level idle for 300 ticks, and every
level still open when the server stops or fails. Fixes #6154.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
levzzzandClaude Opus 5.5 committed 2026-11-01 22:37:20 +05:00
1 parent 975c07d915
commit c60b4c8d46
5 files changed
+85

No files matched your search

+2
View File
@@ -11768,6 +11768,8 @@ impl Game<'_> {
self.streaming.sky_light = dim != Dim::Nether;
self.streaming.nether_shading = dim == Dim::Nether;
self.load_world_state();
// The level the world opens in, before any of its chunks.
self.native_level_opened(dim);
Ok(())
}
/// Streaming and region storage for one dimension of a world.
+36
View File
@@ -118,6 +118,9 @@ impl Default for Pass {
#[derive(Default)]
pub(super) struct Levels {
parked: HashMap<Dim, Parked>,
/// The levels whose open the mods heard (`native_level_load`), each to
/// be told closed once.
heard_open: Vec<Dim>,
}
impl Levels {
@@ -316,6 +319,7 @@ impl Game<'_> {
if opened {
// The new streaming's radius and meshing options.
self.apply_settings();
self.native_level_opened(target);
}
}
let host = match host {
@@ -617,10 +621,42 @@ impl Game<'_> {
self.save_level(true)?;
self.enter_level(None, back)?;
self.levels.parked.remove(&dim);
self.native_level_closed(dim);
}
Ok(())
}
/// The mods hear `dim`'s level open (`LevelEvent.Load`), on a server.
pub(super) fn native_level_opened(&mut self, dim: Dim) {
if self.remote.is_some()
|| self.options.execution_role.is_client()
|| self.levels.heard_open.contains(&dim)
{
return;
}
self.levels.heard_open.push(dim);
self.native_level_load(dim.id());
}
/// The mods hear `dim`'s level close (`LevelEvent.Unload`), once for
/// each open they heard.
fn native_level_closed(&mut self, dim: Dim) {
let Some(i) = self.levels.heard_open.iter().position(|d| *d == dim) else {
return;
};
self.levels.heard_open.remove(i);
self.native_level_unload(dim.id());
}
/// Every level still open closes with the server, however it ends
/// (`MinecraftServer.stopServer`'s `level.close()` loop), in vanilla's
/// order of its levels: the Overworld, the Nether, the End.
pub(super) fn native_levels_closing(&mut self) {
for dim in [Dim::Overworld, Dim::Nether, Dim::End] {
self.native_level_closed(dim);
}
}
/// Save every parked level (`MinecraftServer.saveAllChunks`); the level
/// in place is the caller's to save.
pub(super) fn save_parked_levels(&mut self, wait: bool) -> Result<()> {
@@ -2583,6 +2583,28 @@ impl Game<'_> {
pub(super) fn native_net_from_server(&mut self, channel: &str, data: &[u8]) {
let _ = (channel, data);
}
/// `dimension`'s level opened on the server (NeoForge's `LevelEvent.Load`,
/// posted in `MinecraftServer.createLevels`): before any of its chunks
/// (`native_chunk_load`), so a mod sets up what it keeps for the level
/// here. The level the world opens in comes before `WorldOpenReady`; the
/// others open when someone first goes there (a player, a portal, an
/// `execute in` command), with the level in place. Once for each open:
/// `native_level_unload` follows. Server-side. API @API:level_events@.
pub(super) fn native_level_load(&mut self, dimension: &str) {
let _ = dimension;
}
/// `dimension`'s level closed (NeoForge's `LevelEvent.Unload`, posted after
/// `level.close()` in `MinecraftServer.stopServer`), after its last save:
/// drop what the mod kept for it. A level other than the Overworld closes
/// once nobody has been in it for 300 ticks, after its chunks' unloads;
/// every level still open closes when the server stops, before
/// `WorldCloseFinal`, and when its open or its loop fails, before
/// `WorldOpenFailed` or `WorldCloseFailed`. Once for each
/// `native_level_load`. Don't read its blocks. Server-side. API
/// @API:level_events@.
pub(super) fn native_level_unload(&mut self, dimension: &str) {
let _ = dimension;
}
/// Chunk `chunk` (`[x, z]`, in chunks) of `dimension` came into the world
/// (NeoForge's `ChunkEvent.Load`): `new` when it was just generated, false
/// when read from the save. A flat world's chunk that leaves before it's
@@ -239,6 +239,8 @@ impl Game<'_> {
/// `error_text` is the engine's own error text, unchanged.
pub(super) fn native_lifecycle_world_failed(&mut self, error_text: &str) {
// The levels that opened before the failure close first.
self.native_levels_closing();
if let Some(context) = self.native_lifecycle.world_open_failed() {
self.native_world_lifecycle(&context, WorldPhase::WorldOpenFailed, error_text);
}
@@ -246,6 +248,7 @@ impl Game<'_> {
/// Report a server-loop or shutdown failure once and retire its epoch.
pub(super) fn native_lifecycle_close_failed(&mut self, error_text: &str) {
self.native_levels_closing();
if let Some(context) = self.native_lifecycle.world_close_failed() {
self.native_world_lifecycle(&context, WorldPhase::WorldCloseFailed, error_text);
}
@@ -256,6 +259,8 @@ impl Game<'_> {
if self.remote.is_some() {
return;
}
// Each level closes after the final save (`LevelEvent.Unload`).
self.native_levels_closing();
if let Some(context) = self.native_lifecycle.world_close_final() {
self.native_world_lifecycle(&context, WorldPhase::WorldCloseFinal, "");
}
@@ -0,0 +1,20 @@
{
"hooks": [
{
"name": "level_load",
"target": "horizon_client::game::native_hooks::Game<'_>::native_level_load",
"neoforge": "LevelEvent.Load",
"side": "server",
"cancellable": false
},
{
"name": "level_unload",
"target": "horizon_client::game::native_hooks::Game<'_>::native_level_unload",
"neoforge": "LevelEvent.Unload",
"side": "server",
"cancellable": false
}
],
"rust_summary": "a server's levels opening and closing (`native_level_load`, `native_level_unload`).",
"doc_summary": "a server's levels opening and closing, on the single player server thread and on a dedicated server: `native_level_load(dimension)` when a level opens, before any of its chunks (NeoForge's `LevelEvent.Load`), and `native_level_unload(dimension)` once it has closed, after its last save (`LevelEvent.Unload`). The level the world opens in comes before `WorldOpenReady`; the Nether and the End open when someone first goes there and close after 300 ticks with nobody in them. Every level still open closes when the server stops, before `WorldCloseFinal`, or when its open or its loop fails, so each load has one unload. Bundles that use them need \"hooks\": @API:level_events@."
}