Merge pull request #10197 from IanWorley/native-api/client-world-snapshots

Native mods: client world snapshots a mod reads on its worker threads
This commit is contained in:
rdbt authored and GitHub committed 2026-11-06 21:26:18 +00:00
commit c30775263f
5 files changed
+381 -6

No files matched your search

+2
View File
@@ -226,6 +226,7 @@ mod native_claimed_items;
mod native_client_particles;
mod native_client_particles_fixture;
pub mod native_client_services;
mod native_client_world;
mod native_components;
mod native_configs;
mod native_criteria;
@@ -457,6 +458,7 @@ pub use machines::native_fluids::NativeFluidTank;
pub use maps::{NativeMapDecoration, NativeMapInfo};
pub use native_block_entity_draw::BlockEntityDraw;
pub use native_claimed_items::{NativeHeldItem, NativeHit, NativeModEvent};
pub use native_client_world::{NativeBiomeId, NativeClientBiomes, NativeClientChunks};
pub use native_components::NativeComponentError;
pub use native_configs::{NativeConfig, NativeConfigFile, NativeConfigKind};
pub use native_criteria::NativeCriterion;
@@ -0,0 +1,327 @@
//! The client world read off the game thread, for a client mod that bakes from it on its own workers (a minimap's
//! tiles, a world map export, a shader pack's CPU side): the loaded chunks of an area, shared with the world rather
//! than copied, and the biome table that names and tints their biomes.
use super::Game;
use horizon_client::{
biome::{BiomeColors, Biomes},
chunk::Chunk,
options_file::MAX_RENDER_DISTANCE,
world::World,
};
use horizon_world::registry::BlockStateId;
use std::{ops::Range, sync::Arc};
/// The most chunks a snapshot holds: the square the longest render distance loads, more than a client keeps.
const MAX_CHUNKS: i64 = (2 * MAX_RENDER_DISTANCE as i64 + 1).pow(2);
const CHUNK_SHIFT: i32 = 4;
const CHUNK_MASK: i32 = 15;
const SECTION_HEIGHT: i32 = 16;
/// A biome by the client's own id (vanilla's biomes first, then the world's data packs'), as
/// `NativeClientChunks::biome` reads it. `NativeClientBiomes` names and tints it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct NativeBiomeId(pub u16);
/// The client world's loaded chunks in an area, as they were when `native_client_chunks` took them: `Send` and
/// `Sync`, to read on a worker thread, by block position. It shares the chunks with the world, so taking and
/// cloning one copies no blocks; holding it keeps those chunks as they were, so the world copies the section it
/// edits in a held chunk. Drop the snapshots you no longer read.
#[derive(Clone)]
pub struct NativeClientChunks {
/// The area's north-west chunk.
origin: [i32; 2],
/// The area's width in chunks, along x.
width: i32,
/// The area's chunks, a row of `width` after another from north to south; none where not loaded.
chunks: Vec<Option<Arc<Chunk>>>,
air: BlockStateId,
height: Range<i32>,
}
impl NativeClientChunks {
fn of(world: &World, from: [i32; 2], to: [i32; 2]) -> Result<Self, String> {
let origin = [from[0].min(to[0]), from[1].min(to[1])];
let span = |axis: usize| i64::from(from[axis].max(to[axis])) - i64::from(origin[axis]) + 1;
let (width, depth) = (span(0), span(1));
let count = width.saturating_mul(depth);
if count > MAX_CHUNKS {
return Err(format!("{count} chunks, more than {MAX_CHUNKS} a snapshot"));
}
let chunks = (0..depth)
.flat_map(|dz| (0..width).map(move |dx| (dx, dz)))
.map(|(dx, dz)| {
let key = (origin[0] + dx as i32, origin[1] + dz as i32);
world.chunks.get(&key).cloned()
})
.collect();
Ok(Self {
origin,
width: width as i32,
chunks,
air: world.air,
height: world.shape.min_y..world.shape.max_y(),
})
}
#[inline]
fn chunk(&self, x: i32, z: i32) -> Option<&Chunk> {
let width = i64::from(self.width);
let dx = i64::from(x >> CHUNK_SHIFT) - i64::from(self.origin[0]);
let dz = i64::from(z >> CHUNK_SHIFT) - i64::from(self.origin[1]);
if !(0..width).contains(&dx) {
return None;
}
self.chunks
.get(usize::try_from(dz * width + dx).ok()?)?
.as_deref()
}
/// The block state at `pos`: air outside the world's height, and in a column outside the area or not loaded.
#[inline]
pub fn block(&self, [x, y, z]: [i32; 3]) -> BlockStateId {
match self.chunk(x, z) {
Some(chunk) if self.height.contains(&y) => {
chunk.block(x & CHUNK_MASK, y, z & CHUNK_MASK)
}
_ => self.air,
}
}
/// The biome at `pos`, as the client keeps biomes: one a 4 by 4 by 4 cell, `y` clamped to the world's height.
/// None in a column outside the area or not loaded.
#[inline]
pub fn biome(&self, [x, y, z]: [i32; 3]) -> Option<NativeBiomeId> {
// The chunks store more than the build height (the Nether's widened to the Overworld's): clamp to it first.
let y = y.clamp(self.height.start, self.height.end - 1);
self.chunk(x, z)
.map(|chunk| NativeBiomeId(chunk.biome(x, y, z)))
}
/// Whether the chunk holding column `[x, z]` was loaded (false outside the area).
#[inline]
pub fn loaded(&self, [x, z]: [i32; 2]) -> bool {
self.chunk(x, z).is_some()
}
/// One above the highest 16-block section of column `[x, z]`'s chunk that holds anything but air: the column
/// is air from there up. The world's lowest y where that chunk is all air, or isn't loaded.
pub fn section_top(&self, [x, z]: [i32; 2]) -> i32 {
let bottom = self.height.start;
let Some(chunk) = self.chunk(x, z) else {
return bottom;
};
chunk
.sections
.iter()
.rposition(|section| !section.is_uniform(self.air.0))
.map_or(bottom, |s| chunk.min_y() + (s as i32 + 1) * SECTION_HEIGHT)
}
/// The world's build height: its lowest y, and one above its highest (`native_world_height` gives the lowest y
/// and the height instead).
pub fn height(&self) -> Range<i32> {
self.height.clone()
}
fn changed_in(&self, world: &World) -> Vec<[i32; 2]> {
let width = self.width as usize;
self.chunks
.iter()
.enumerate()
.filter_map(|(i, held)| {
let pos = [
self.origin[0] + (i % width) as i32,
self.origin[1] + (i / width) as i32,
];
// A held chunk is shared, so the world copies it before any edit: the same allocation is the same
// chunk, unchanged.
let same = match (held, world.chunks.get(&(pos[0], pos[1]))) {
(Some(held), Some(now)) => Arc::ptr_eq(held, now),
(None, None) => true,
_ => false,
};
(!same).then_some(pos)
})
.collect()
}
}
/// The client world's biome table, `Send` and `Sync` for a worker thread: each biome's name and tints by
/// `NativeBiomeId`, as the terrain was drawn with them when `native_client_biomes` gave it (a mod's
/// `native_biome_colors` included). An id from another world's snapshot names one of this table's biomes, not
/// necessarily its own.
#[derive(Clone)]
pub struct NativeClientBiomes(Arc<Biomes>);
impl NativeClientBiomes {
/// The biome's name, `minecraft:plains`.
pub fn name(&self, biome: NativeBiomeId) -> &str {
&self.0.get(biome.0).name
}
/// The biome's tints at column `[x, z]`, each 0xRRGGBB, before the biome blend the terrain smooths them with:
/// grass as drawn there (the dark forest's darkening, the swamp's noise), foliage, dry foliage and water.
pub fn colors(&self, biome: NativeBiomeId, [x, z]: [i32; 2]) -> BiomeColors {
let info = self.0.get(biome.0);
BiomeColors {
grass: self.0.grass(biome.0, f64::from(x), f64::from(z)),
foliage: info.foliage,
dry_foliage: info.dry_foliage,
water: info.water,
}
}
}
#[allow(dead_code)]
impl Game<'_> {
/// For a client mod: the client world's chunks from chunk `from` to chunk `to` (`[x, z]`, both included, in
/// either order) as they are now, to read on any thread, shared with the world rather than copied. On a client
/// of a server it is what the server sent. `Err`, taking nothing, on a server, which has no client world, or for
/// an area of more than 66,049 chunks (the square a render distance of 128 loads). API
/// @API:client_world_snapshots@.
pub(super) fn native_client_chunks(
&self,
from: [i32; 2],
to: [i32; 2],
) -> Result<NativeClientChunks, String> {
if !self.native_window() {
return Err("a server has no client world".into());
}
NativeClientChunks::of(&self.world, from, to)
}
/// For a client mod: the chunks of `snapshot`'s area (`[x, z]`, in no set order) whose state in the client
/// world differs from the snapshot's now: written to since it was taken (blocks, biomes or light, even a write
/// that left them the same), or loaded or unloaded where the snapshot had them the other way. Empty when the
/// snapshot's blocks and biomes are the world's; their tints are `native_client_biomes_changed`'s. API
/// @API:client_world_snapshots@.
pub(super) fn native_client_chunks_changed(
&self,
snapshot: &NativeClientChunks,
) -> Vec<[i32; 2]> {
snapshot.changed_in(&self.world)
}
/// For a client mod: the biome table the client world's terrain is drawn with, for the ids its snapshots read.
/// None on a server. API @API:client_world_snapshots@.
pub(super) fn native_client_biomes(&self) -> Option<NativeClientBiomes> {
self.native_window()
.then(|| NativeClientBiomes(self.worker_context().biomes.clone()))
}
/// For a client mod: whether the terrain is now drawn with another biome table than `biomes`: a mod recoloured
/// the biomes (`native_refresh_biome_colors`, a season), the resources loaded again or another world opened.
/// Then what was tinted from `biomes` is out of date, even where no chunk changed. API
/// @API:client_world_snapshots@.
pub(super) fn native_client_biomes_changed(&self, biomes: &NativeClientBiomes) -> bool {
// `biomes` holds its table, so the game replaces it rather than edit it: the same allocation is the same table.
!Arc::ptr_eq(&biomes.0, &self.worker_context().biomes)
}
}
#[cfg(test)]
mod tests {
use super::*;
const STONE: BlockStateId = BlockStateId(1);
const GLASS: BlockStateId = BlockStateId(2);
const BIOME: u16 = 7;
/// An overworld with chunks (0, 0) and (1, 0) loaded, a stone block at y 70 in the first.
fn world() -> World {
let mut world = World::new(BlockStateId(0), 0);
for key in [(0, 0), (1, 0)] {
world
.chunks
.insert(key, Arc::new(Chunk::empty(key.0, key.1)));
}
world.set(3, 70, 5, STONE).unwrap();
world
}
#[test]
fn a_snapshot_reads_the_world_on_another_thread() {
let mut world = world();
let chunk = world.chunks.get_mut(&(1, 0)).unwrap();
Arc::make_mut(chunk).set_biome_cell(0, 0, 0, BIOME);
let snapshot = NativeClientChunks::of(&world, [1, 0], [-1, 0]).unwrap();
let read = std::thread::spawn(move || {
(
snapshot.block([3, 70, 5]),
snapshot.block([3, 71, 5]),
snapshot.loaded([-1, 0]),
snapshot.loaded([3, 5]),
snapshot.biome([16, -64, 0]),
snapshot.biome([-1, 70, 0]),
snapshot.section_top([3, 5]),
snapshot.section_top([16, 0]),
snapshot.height(),
)
})
.join()
.unwrap();
assert_eq!(read.0, STONE);
assert_eq!(read.1, world.air);
assert!(!read.2, "chunk (-1, 0) is in the area but not loaded");
assert!(read.3);
assert_eq!(read.4, Some(NativeBiomeId(BIOME)));
assert_eq!(read.5, None);
assert_eq!(read.6, 80, "y 70 is in the section from 64 to 79");
assert_eq!(read.7, -64, "chunk (1, 0) is all air");
assert_eq!(read.8, -64..320);
}
#[test]
fn an_edit_changes_only_its_chunk_and_the_snapshot_keeps_its_blocks() {
let mut world = world();
let snapshot = NativeClientChunks::of(&world, [0, 0], [1, 0]).unwrap();
assert!(snapshot.changed_in(&world).is_empty());
world.set(20, 70, 5, GLASS).unwrap();
assert_eq!(snapshot.changed_in(&world), vec![[1, 0]]);
assert_eq!(snapshot.block([20, 70, 5]), world.air);
}
#[test]
fn loading_and_unloading_count_as_changes() {
let mut world = world();
let snapshot = NativeClientChunks::of(&world, [-1, 0], [1, 0]).unwrap();
world.chunks.remove(&(0, 0));
world.chunks.insert((-1, 0), Arc::new(Chunk::empty(-1, 0)));
let mut changed = snapshot.changed_in(&world);
changed.sort();
assert_eq!(changed, vec![[-1, 0], [0, 0]]);
}
#[test]
fn biomes_read_within_the_build_height() {
let mut nether = World::new(BlockStateId(0), 0);
nether.shape = horizon_client::chunk::LevelShape::NETHER;
let mut chunk = Chunk::empty(0, 0);
// The Nether's chunks keep the Overworld's 24 sections, from y -64: cell 16 is y 0, cell 79 is y 255.
for (cell, biome) in [(15, 1), (16, 2), (79, 3), (80, 4)] {
chunk.set_biome_cell(0, cell, 0, biome);
}
nether.chunks.insert((0, 0), Arc::new(chunk));
let snapshot = NativeClientChunks::of(&nether, [0, 0], [0, 0]).unwrap();
assert_eq!(snapshot.biome([0, -1, 0]), Some(NativeBiomeId(2)));
assert_eq!(snapshot.biome([0, i32::MIN, 0]), Some(NativeBiomeId(2)));
assert_eq!(snapshot.biome([0, 256, 0]), Some(NativeBiomeId(3)));
assert_eq!(snapshot.biome([0, i32::MAX, 0]), Some(NativeBiomeId(3)));
}
#[test]
fn an_area_past_what_a_client_loads_takes_nothing() {
let world = world();
assert!(
NativeClientChunks::of(&world, [i32::MIN, i32::MIN], [i32::MAX, i32::MAX]).is_err()
);
assert!(NativeClientChunks::of(&world, [-128, -128], [128, 128]).is_ok());
}
#[test]
fn a_snapshot_is_send_and_sync() {
fn shareable<T: Send + Sync>() {}
shareable::<NativeClientChunks>();
shareable::<NativeClientBiomes>();
}
}
+7 -6
View File
@@ -24,12 +24,13 @@ pub use crate::game::native_client_services::{
NativeClientEntity, NativeClientHead, NativeClientProfile, NativeClientState,
};
pub use crate::game::{
ItemEnergyBuffer, NativeBlockEdit, NativeBlockReadError, NativeBlockRun, NativeClock,
NativeColumnBlocks, NativeComponentError, NativeConfig, NativeConfigFile, NativeConfigKind,
NativeCriterion, NativeDataMapType, NativeExperienceOrb, NativeFalling, NativeHeightmap,
NativeHeldItem, NativeHit, NativeItemEntity, NativeMapDecoration, NativeMapInfo,
NativeMobRender, NativeModEvent, NativePlayer, NativePlayerRender, NativeReloadListener,
NativeShake, NativeStructureBlock, NativeStructureTemplate, NativeWorldDraw,
ItemEnergyBuffer, NativeBiomeId, NativeBlockEdit, NativeBlockReadError, NativeBlockRun,
NativeClientBiomes, NativeClientChunks, NativeClock, NativeColumnBlocks, NativeComponentError,
NativeConfig, NativeConfigFile, NativeConfigKind, NativeCriterion, NativeDataMapType,
NativeExperienceOrb, NativeFalling, NativeHeightmap, NativeHeldItem, NativeHit,
NativeItemEntity, NativeMapDecoration, NativeMapInfo, NativeMobRender, NativeModEvent,
NativePlayer, NativePlayerRender, NativeReloadListener, NativeShake, NativeStructureBlock,
NativeStructureTemplate, NativeWorldDraw,
};
pub use crate::gfx::native_render::{
BlockSpriteArrays, RenderHooks, RenderNeeds, RenderNeedsEvent, WorldDrawn, WorldDrawnEvent,
+31
View File
@@ -3193,6 +3193,37 @@ pieces.
The tints are the biome's own at the column's top, not blended over the biome blend radius as the game draws them, so
near a biome border they can differ from what players see by a few blocks.
### Client world snapshots
`crates/horizon-client/src/game/native_client_world.rs`: helpers for client mods that read the world in bulk on their
own threads (a minimap baking tiles, a world map export, a shader pack's CPU side). They read the client world, what
the window shows: on a client of a server, the chunks the server sent. API @API:client_world_snapshots@.
- `native_client_chunks(from, to)` takes the loaded chunks from chunk `from` to chunk `to` (`[x, z]`, both included, in
either order) as `NativeClientChunks`, which is `Send` and `Sync`. It shares the chunks with the world, so taking or
cloning one copies no blocks. It reads by block position: `block(pos)` (a `BlockStateId`; air outside the world's
height and in columns not loaded), `biome(pos)` (a `NativeBiomeId`, one a 4 by 4 by 4 cell, `y` clamped to the build
height; none where not loaded), `loaded([x, z])`, `section_top([x, z])` (one above the highest 16-block section of the
column's chunk that isn't all air: the column is air from there up) and `height()` (the world's build height, from its
lowest y to one above its highest; `native_world_height` gives the lowest y and the height instead). `Err`, taking
nothing, on a server or for an area of more than 66,049 chunks (the square a render distance of 128 loads).
- `native_client_chunks_changed(&snapshot)` names the chunks of the snapshot's area, in no set order, whose state in the
world differs from the snapshot's now: written to since it was taken (blocks, biomes or light, even a write that left
them the same), or loaded or unloaded where the snapshot had them the other way. Empty means the snapshot's blocks and
biomes are still the world's. A snapshot keeps its chunks as they were, so the world copies the section it edits in a
held chunk: drop snapshots you no longer read.
- `native_client_biomes()` gives the biome table the terrain is drawn with (`NativeClientBiomes`, `Send` and `Sync`), a
mod's `native_biome_colors` included, none on a server. `name(id)` is the biome's name (`minecraft:plains`) and
`colors(id, [x, z])` its `BiomeColors` at a column: grass as drawn there (the dark forest's darkening, the swamp's
noise), foliage, dry foliage and water, before the biome blend the terrain smooths them with.
`native_client_biomes_changed(&biomes)` says when the terrain is drawn with another table (a mod recoloured the biomes
with `native_refresh_biome_colors`, the resources loaded again, another world opened): what was tinted from the old
one is out of date even where no chunk changed.
A minimap takes a snapshot of the chunks a tile can see, bakes the tile from it on its worker, and keeps the snapshot
and the biome table with the tile. Every so often it asks `native_client_chunks_changed` for the tiles in view and
bakes again those with a changed chunk, and all of them when `native_client_biomes_changed` says so.
### Structure templates
`crates/horizon-client/src/game/native_structures.rs`: a helper for server mods that reads a structure template without
@@ -0,0 +1,14 @@
{
"hooks": [],
"helpers": [
{"name": "client_chunks", "target": "horizon_client::game::native_client_world::Game<'_>::native_client_chunks"},
{"name": "client_chunks_changed", "target": "horizon_client::game::native_client_world::Game<'_>::native_client_chunks_changed"},
{"name": "client_biomes", "target": "horizon_client::game::native_client_world::Game<'_>::native_client_biomes"},
{"name": "client_biomes_changed", "target": "horizon_client::game::native_client_world::Game<'_>::native_client_biomes_changed"}
],
"rust_summary": "client world snapshots (`native_client_chunks`, `native_client_chunks_changed`, `native_client_biomes`, `native_client_biomes_changed`): the client's loaded chunks of an area and its biome table, shared with the world and read on a mod's own threads.",
"doc_summary": "client world snapshots: `native_client_chunks(from, to)` shares the client world's loaded chunks of an area with a mod's worker threads (`NativeClientChunks`: block states, biome ids, which columns are loaded, each column's top non-empty section, the world's height), `native_client_chunks_changed(&snapshot)` names the chunks that differ from the snapshot now, `native_client_biomes()` gives the biome table that names and tints the ids (`NativeClientBiomes`) and `native_client_biomes_changed(&biomes)` says when the terrain is drawn with another ([Client world snapshots](#client-world-snapshots)). Bundles need `\"hooks\": @API:client_world_snapshots@`.",
"comments": [
"crates/horizon-client/src/game/native_client_world.rs"
]
}