Merge origin/main into refactor/entity-walk-wind

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
This commit is contained in:
HrafnaValkyrjaandClaude Sonnet 5.5 committed 2026-11-02 04:07:17 -05:00
commit 18d82b7fa5
11 files changed
+616 -20

No files matched your search

+1
View File
@@ -82,6 +82,7 @@ pub mod screen_draw;
pub mod screen_effects;
pub mod sculk;
pub mod section_mask;
pub mod server_terrain;
pub mod settings;
pub mod shader_options;
pub mod shader_pack_list;
+3 -4
View File
@@ -1220,10 +1220,9 @@ impl Lod {
else {
continue;
};
Done::Observed(
key,
ReducedColumns::new(summarize(&chunk, kinds)),
)
let columns = summarize(&chunk, kinds);
sampler.observed(key, &columns);
Done::Observed(key, ReducedColumns::new(columns))
}
Job::RequestDetail { chunks, center } => {
sampler.request_detail(&chunks, center);
@@ -0,0 +1,531 @@
//! Far terrain on servers: every chunk a server sends is kept as its
//! far-terrain summary (`lod::Column`s), on disk per server, world and
//! dimension, and drawn beyond the server's view distance on this visit and
//! the next ones. A server never sends its seed, so its
//! distant terrain can't be generated as a local world's is.
//!
//! The summaries live in Anvil region files (`r.X.Z.mca`, one NBT record a
//! chunk), under `server-terrain/<server>/<hashed seed>/<namespace>/<level>/`.
use crate::{
fast_hash::FxHashMap,
lod::{Column, Span, Spans, Surface},
streaming::{LodDetail, LodSampler, LodSource},
};
use horizon_core::position::ChunkPos;
use horizon_world::{
DATA_VERSION,
compression::Compression,
nbt::{Limits, NamedTag, Tag},
region::{ChunkRecord, RegionFile},
registry::BlockStateId,
};
use std::{
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
/// Bumped when the summary layout changes; older records are ignored.
const FORMAT: i32 = 1;
/// Decoded chunks kept in memory, and open region files.
const MAX_CHUNKS: usize = 8192;
const MAX_REGIONS: usize = 64;
/// The widest cell, in chunks, drawn from the chunks kept under it
/// (`Sampler::reduced_column`): its regions all stay open.
const MAX_KEPT_SIDE: i32 = 128;
type Columns = Arc<[Column; 256]>;
/// The folder of a server's terrain: its address as typed, lowercased, with
/// the default port left out, and every character a file name can't hold
/// replaced (`play.example.net:25566` → `play.example.net_25566`).
pub fn server_folder(address: &str) -> String {
let address = address.trim().to_ascii_lowercase();
let address = address.strip_suffix(":25565").unwrap_or(&address);
let name: String = address
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
c
} else {
'_'
}
})
.collect();
let name = name.trim_matches('.');
if name.is_empty() {
"_".into()
} else {
name.into()
}
}
/// `root/<server>/<seed>/<namespace>/<path>` for a level key
/// (`minecraft:overworld`) and the login's hashed seed, which keeps
/// the worlds behind one address (a proxy's servers, a reset) apart.
pub fn level_dir(root: &Path, address: &str, hashed_seed: i64, level: &str) -> PathBuf {
root.join(server_folder(address))
.join(hashed_seed.to_string())
.join(level_path(level))
}
/// `<namespace>/<path>` of a level key.
fn level_path(level: &str) -> PathBuf {
let (namespace, path) = level.split_once(':').unwrap_or(("minecraft", level));
let clean = |s: &str| -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') {
c
} else {
'_'
}
})
.collect::<String>()
.trim_matches('.')
.to_owned()
};
let mut dir = PathBuf::from(clean(namespace));
for part in path.split('/').map(clean).filter(|p| !p.is_empty()) {
dir.push(part);
}
dir
}
struct Region {
file: RegionFile,
present: [bool; 1024],
}
/// Open region files by region; `None`: the file doesn't exist.
type Regions = FxHashMap<(i32, i32), Option<Region>>;
#[derive(Default)]
struct State {
regions: Regions,
/// `None`: the chunk isn't stored.
chunks: FxHashMap<(i32, i32), Option<Columns>>,
}
/// One level's stored terrain, shared by the far-terrain threads.
pub struct ServerTerrain {
dir: PathBuf,
state: Mutex<State>,
warned: std::sync::atomic::AtomicBool,
}
impl ServerTerrain {
pub fn new(dir: PathBuf) -> Arc<Self> {
Arc::new(Self {
dir,
state: Mutex::default(),
warned: Default::default(),
})
}
/// The stored chunk nearest the middle of the `side` × `side` chunks
/// from `(x, z)`, found in the region headers without reading any.
fn nearest(&self, x: i32, z: i32, side: i32) -> Option<(i32, i32)> {
let mut state = self.state.lock().unwrap();
let middle = (x * 2 + side, z * 2 + side);
let mut best: Option<(i64, (i32, i32))> = None;
for rz in z >> 5..=(z + side - 1) >> 5 {
for rx in x >> 5..=(x + side - 1) >> 5 {
let present = region(&mut state.regions, &self.dir, (rx, rz))
.map_or([false; 1024], |r| r.present);
for (i, _) in present.iter().enumerate().filter(|(_, p)| **p) {
let (cx, cz) = (rx * 32 + (i % 32) as i32, rz * 32 + (i / 32) as i32);
if cx < x || cz < z || cx >= x + side || cz >= z + side {
continue;
}
let (dx, dz) = (
i64::from(cx * 2 + 1 - middle.0),
i64::from(cz * 2 + 1 - middle.1),
);
let distance = dx * dx + dz * dz;
if best.is_none_or(|(d, _)| distance < d) {
best = Some((distance, (cx, cz)));
}
}
}
}
best.map(|(_, chunk)| chunk)
}
/// A stored chunk's columns.
pub fn chunk(&self, x: i32, z: i32) -> Option<Columns> {
let key = (x >> 5, z >> 5);
let here = {
let mut state = self.state.lock().unwrap();
if let Some(known) = state.chunks.get(&(x, z)) {
return known.clone();
}
read(region(&mut state.regions, &self.dir, key), x, z)
};
// A record of another game version or layout is none.
let columns = here.and_then(|record| {
record
.nbt(Limits::default())
.ok()
.and_then(|root| decode(&root.tag))
});
let mut state = self.state.lock().unwrap();
// Stored by another thread meanwhile (`write`): that, newer than
// what was read here.
if let Some(Some(stored)) = state.chunks.get(&(x, z)) {
return Some(stored.clone());
}
remember(&mut state, (x, z), columns.clone());
columns
}
/// A chunk couldn't be kept (disk full, a folder it can't write): said
/// once a level, not for every chunk after.
fn warn_once(&self, error: &std::io::Error) {
if !self.warned.swap(true, std::sync::atomic::Ordering::Relaxed) {
log::warn!("Horizon: far terrain can't keep the server's chunks: {error}");
}
}
/// Keep a chunk's columns, unless they are what is stored already.
pub fn store(&self, key: (i32, i32), columns: &Columns) -> std::io::Result<()> {
if self
.chunk(key.0, key.1)
.is_some_and(|old| *old == **columns)
{
return Ok(());
}
self.write(key, columns)
}
fn write(&self, (x, z): (i32, i32), columns: &Columns) -> std::io::Result<()> {
let root = NamedTag {
name: "".into(),
tag: encode(x, z, columns),
};
let record = ChunkRecord::from_nbt(&root, Compression::Zlib, now(), Limits::default())?;
let mut state = self.state.lock().unwrap();
let key = (x >> 5, z >> 5);
if region(&mut state.regions, &self.dir, key).is_none() {
std::fs::create_dir_all(&self.dir)?;
let file = RegionFile::open_writable(region_path(&self.dir, key), true)?;
state.regions.insert(key, Some(Region::new(file)));
}
let region = region(&mut state.regions, &self.dir, key).expect("region just opened");
region.file.write(ChunkPos::new(x, z), &record)?;
region.present[slot(x, z)] = true;
remember(&mut state, (x, z), Some(columns.clone()));
Ok(())
}
}
fn region_path(dir: &Path, (rx, rz): (i32, i32)) -> PathBuf {
dir.join(format!("r.{rx}.{rz}.mca"))
}
/// A region of `dir`, opened on first use.
fn region<'a>(regions: &'a mut Regions, dir: &Path, key: (i32, i32)) -> Option<&'a mut Region> {
if !regions.contains_key(&key) {
if regions.len() >= MAX_REGIONS {
regions.clear();
}
let file = RegionFile::open_writable(region_path(dir, key), false);
regions.insert(key, file.ok().map(Region::new));
}
regions.get_mut(&key)?.as_mut()
}
fn read(region: Option<&mut Region>, x: i32, z: i32) -> Option<ChunkRecord> {
let region = region.filter(|r| r.present[slot(x, z)])?;
region.file.read(ChunkPos::new(x, z)).ok().flatten()
}
impl Region {
fn new(file: RegionFile) -> Self {
let mut present = [false; 1024];
for (pos, _) in file.chunks() {
present[slot(pos.x, pos.z)] = true;
}
Self { file, present }
}
}
fn slot(x: i32, z: i32) -> usize {
(x & 31) as usize + (z & 31) as usize * 32
}
fn remember(state: &mut State, key: (i32, i32), columns: Option<Columns>) {
if state.chunks.len() >= MAX_CHUNKS {
state.chunks.clear();
}
state.chunks.insert(key, columns);
}
fn now() -> u32 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as u32)
}
/// The columns as one int array: per column a surface (`0`, or `1`, height,
/// state, water flag, water height, water state, biome), the span count, and
/// three ints a span (min and max, state, biome and light).
fn encode(x: i32, z: i32, columns: &[Column; 256]) -> Tag {
let mut ints = Vec::with_capacity(256 * 8);
for column in columns {
match column.surface {
None => ints.push(0),
Some(s) => {
let (water, water_height, water_state) = s
.water
.map_or((0, 0, 0), |(h, state)| (1, h, state.0 as i32));
ints.extend([
1,
s.height,
s.state.0 as i32,
water,
water_height,
water_state,
i32::from(s.biome),
]);
}
}
ints.push(column.spans.len() as i32);
for span in &column.spans {
ints.extend([
i32::from(span.min as u16) << 16 | i32::from(span.max as u16),
span.state.0 as i32,
i32::from(span.biome) << 16 | i32::from(span.light),
]);
}
}
Tag::compound([
("DataVersion", Tag::Int(DATA_VERSION)),
("HorizonFarTerrain", Tag::Int(FORMAT)),
("xPos", Tag::Int(x)),
("zPos", Tag::Int(z)),
("columns", Tag::IntArray(ints)),
])
}
/// `encode`'s columns, or `None` for a record of another game version or
/// layout (their block states and biomes are numbered differently).
fn decode(tag: &Tag) -> Option<Columns> {
if tag.get("DataVersion") != Some(&Tag::Int(DATA_VERSION))
|| tag.get("HorizonFarTerrain") != Some(&Tag::Int(FORMAT))
{
return None;
}
let Some(Tag::IntArray(ints)) = tag.get("columns") else {
return None;
};
let mut ints = ints.iter().copied();
let mut next = || ints.next();
let mut columns: [Column; 256] = std::array::from_fn(|_| Column::default());
for column in &mut columns {
column.surface = match next()? {
0 => None,
1 => {
let height = next()?;
let state = BlockStateId(next()? as u32);
let water = next()?;
let water_height = next()?;
let water_state = BlockStateId(next()? as u32);
let biome = next()? as u16;
Some(Surface {
height,
state,
water: (water != 0).then_some((water_height, water_state)),
biome,
})
}
_ => return None,
};
let count = usize::try_from(next()?).ok().filter(|&n| n <= 4096)?;
column.spans = Spans::with_capacity(count);
for _ in 0..count {
let range = next()?;
let state = BlockStateId(next()? as u32);
let material = next()?;
column.spans.push(Span {
min: (range >> 16) as i16,
max: range as i16,
state,
biome: (material >> 16) as u16,
light: material as u16,
});
}
}
next().is_none().then(|| Arc::new(columns))
}
impl LodSource for ServerTerrain {
fn sampler(self: Arc<Self>) -> Box<dyn LodSampler> {
Box::new(Sampler {
terrain: self,
details: Vec::new(),
})
}
}
struct Sampler {
terrain: Arc<ServerTerrain>,
details: Vec<LodDetail>,
}
impl LodSampler for Sampler {
fn fork(&self) -> Option<Box<dyn LodSampler>> {
Some(Box::new(Self {
terrain: self.terrain.clone(),
details: Vec::new(),
}))
}
/// Stored chunks come back whole; the others are known empty.
fn request_detail(&mut self, chunks: &[(i32, i32)], _center: [i32; 2]) {
for &(x, z) in chunks {
let columns = self
.terrain
.chunk(x, z)
.unwrap_or_else(|| Arc::new(std::array::from_fn(|_| Column::default())));
self.details.push(((x, z), columns));
}
}
fn drain_details(&mut self) -> Vec<LodDetail> {
std::mem::take(&mut self.details)
}
fn observed(&mut self, key: (i32, i32), columns: &Columns) {
if let Err(e) = self.terrain.store(key, columns) {
self.terrain.warn_once(&e);
}
}
fn sample(&mut self, x: i32, z: i32) -> Option<Surface> {
self.terrain.chunk(x >> 4, z >> 4)?[((z & 15) * 16 + (x & 15)) as usize].surface
}
fn column(&mut self, x: i32, z: i32) -> Column {
self.terrain
.chunk(x >> 4, z >> 4)
.map(|c| c[((z & 15) * 16 + (x & 15)) as usize].clone())
.unwrap_or_default()
}
/// A cell of `width` blocks from one stored chunk: its own area up to a
/// chunk, else the stored chunk nearest the cell's middle (the server
/// sent only the chunks a player came near).
fn reduced_column(&mut self, x: i32, z: i32, width: i32) -> Column {
if width <= 16 {
let Some(chunk) = self.terrain.chunk(x >> 4, z >> 4) else {
return Column::default();
};
let width = width.max(1) as usize;
let lx = (x & 15) as usize & !(width - 1);
let lz = (z & 15) as usize & !(width - 1);
return Column::reduced(&chunk, lx, lz, width);
}
// Searching wider cells would open thousands of region files.
if width >> 4 > MAX_KEPT_SIDE {
return Column::default();
}
self.terrain
.nearest(x >> 4, z >> 4, width >> 4)
.and_then(|(cx, cz)| self.terrain.chunk(cx, cz))
.map_or_else(Column::default, |chunk| Column::reduced(&chunk, 0, 0, 16))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn columns() -> Columns {
Arc::new(std::array::from_fn(|i| {
if i % 3 == 0 {
return Column::default();
}
Column {
surface: Some(Surface {
height: 63 + i as i32 % 7,
state: BlockStateId(9 + i as u32),
water: (i % 2 == 0).then_some((62, BlockStateId(86))),
biome: (i % 5) as u16,
}),
spans: vec![
Span {
min: -64,
max: 40,
state: BlockStateId(1),
biome: 3,
light: 0,
},
Span {
min: 40,
max: 63 + i as i16 % 7,
state: BlockStateId(9),
biome: 7,
light: 0xF0,
},
]
.into(),
}
}))
}
#[test]
fn columns_survive_their_encoding() {
let columns = columns();
assert_eq!(decode(&encode(3, -4, &columns)), Some(columns));
}
#[test]
fn records_of_another_version_are_ignored() {
let mut tag = encode(0, 0, &columns());
if let Tag::Compound(map) = &mut tag {
map.insert("DataVersion".into(), Tag::Int(DATA_VERSION - 1));
}
assert_eq!(decode(&tag), None);
}
#[test]
fn server_folders_are_plain_names() {
assert_eq!(server_folder("Play.Example.net"), "play.example.net");
assert_eq!(server_folder("play.example.net:25565"), "play.example.net");
assert_eq!(
server_folder("play.example.net:25566"),
"play.example.net_25566"
);
assert_eq!(server_folder("[::1]:25570"), "___1__25570");
assert_eq!(server_folder("../.."), "_");
assert_eq!(
level_dir(Path::new("/c"), "a", -7, "minecraft:the_nether"),
Path::new("/c/a/-7/minecraft/the_nether")
);
assert_eq!(
level_dir(Path::new("/c"), "a", 0, "mod:../../x"),
Path::new("/c/a/0/mod/x")
);
}
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("horizon-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
#[test]
fn stored_chunks_come_back_after_a_restart() {
let dir = scratch("server-terrain");
let columns = columns();
{
let terrain = ServerTerrain::new(dir.clone());
assert_eq!(terrain.chunk(-33, 5), None);
terrain.store((-33, 5), &columns).unwrap();
}
let terrain = ServerTerrain::new(dir.clone());
assert_eq!(terrain.chunk(-33, 5), Some(columns.clone()));
assert_eq!(terrain.nearest(-64, 0, 64), Some((-33, 5)));
assert_eq!(terrain.nearest(-32, 0, 64), None);
let mut sampler = terrain.clone().sampler();
assert_eq!(sampler.sample(-33 * 16 + 1, 5 * 16), columns[1].surface);
assert_eq!(sampler.column(0, 0), Column::default());
// A far cell finds the stored chunk anywhere in it.
assert_ne!(sampler.reduced_column(-64 * 16, 0, 1024), Column::default());
std::fs::remove_dir_all(&dir).unwrap();
}
}
@@ -173,6 +173,8 @@ pub trait LodSampler: Send {
fn drain_details(&mut self) -> Vec<LodDetail> {
Vec::new()
}
/// A loaded chunk's summary, made on the far-terrain worker.
fn observed(&mut self, _key: (i32, i32), _columns: &Arc<[crate::lod::Column; 256]>) {}
/// Reduced far detail (`graphics::FarDetail::Reduced`): `grid` may then
/// approximate wide cells.
fn set_approximate(&mut self, _approximate: bool) {}
+5 -4
View File
@@ -17,10 +17,11 @@ pub use horizon_client_render::{
menu_blur, mesher, minimap, mipmap, mod_list, model_library, models, multiline_edit,
native_emissive, options_file, options_fixes, options_ui, overlay, pack_screen, packs, par,
particles, poi_sections, post_chain, potent_sulfur, preedit, providers, screen_draw,
screen_effects, sculk, section_mask, settings, shader_options, shader_pack_list, signs, skins,
sound_engine, sound_events, sound_preview, spans, split_layout, split_map, split_profiles,
sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block,
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview, spans, split_layout,
split_map, split_profiles, sprites, streaming, structure_block, tab_list, telemetry,
test_block, test_instance_block, testgen, title_edition, trims, ui, vault, weather, widgets,
world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
+40 -4
View File
@@ -2298,6 +2298,8 @@ struct Game<'a> {
lod: Option<horizon_client::lod::Lod>,
lod_source_attempted: bool,
lod_observe: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
/// The server level far terrain keeps the chunks of.
lod_level: Option<String>,
/// Beacon beam sections kept after their chunk unloads, so far terrain
/// LOD still draws their beams (`beacon_geometry` refreshes them).
far_beacons: HashMap<(i32, i32, i32), Vec<(u32, i32)>>,
@@ -16607,20 +16609,28 @@ impl Game<'_> {
let enabled = self.far_terrain_choice().0 > 0
&& self.renderer.is_some()
&& self.player.is_some()
&& self.title.is_none()
&& (self.remote.is_none()
|| self.integrated.is_some()
|| self.server_far_terrain().is_some());
&& self.title.is_none();
if !enabled {
if self.lod.is_some() || self.lod_source_attempted {
self.clear_lod();
}
return;
}
// Another level: its own kept chunks. (Another world at the same
// address, a proxy's next server, comes through a new login; a seed
// changed at a respawn of the same level is a plugin hiding it.)
if self
.lod_level
.as_ref()
.is_some_and(|level| self.remote.as_ref().is_some_and(|r| r.level != *level))
{
self.clear_lod();
}
if self.lod_source_attempted {
return;
}
self.lod_source_attempted = true;
self.lod_level = None;
let dim = self.dim;
let source = match (&self.integrated, self.server_far_terrain(), &self.remote) {
(Some(server), ..) => server.call(move |game| game.lod_source_in(dim)).flatten(),
@@ -16631,6 +16641,31 @@ impl Game<'_> {
remote.session.far_terrain(offer, biomes, states);
Some(source)
}
// A friend's world (a new code at every share) or a splitscreen
// guest's (the player's own world, its other guests writing the
// same folder): nothing to keep.
(None, None, Some(remote))
if self.options.split_guest
|| remote
.session
.address
.starts_with(horizon_client::net::FRIEND) =>
{
None
}
// No offer from a server: draw what it sent, on this visit and before.
(None, None, Some(remote)) => std::env::current_dir().ok().map(|home| {
use horizon_client::server_terrain as terrain;
let address = &remote.session.address;
let dir = terrain::level_dir(
&home.join("server-terrain"),
address,
remote.hashed_seed,
&remote.level,
);
self.lod_level = Some(remote.level.clone());
terrain::ServerTerrain::new(dir) as _
}),
_ => Some(self.streaming.lod_source()),
};
if let Some(source) = source {
@@ -18612,6 +18647,7 @@ impl<'a> Game<'a> {
lod_source_attempted: false,
far_beacons: HashMap::new(),
lod_observe: Default::default(),
lod_level: None,
lod_covered_at: None,
view_arrival: Default::default(),
options,
+1 -1
View File
@@ -1236,7 +1236,7 @@ impl Game<'_> {
"distance": self.settings.horizon.lod_distance,
"effective_distance": self.lod_distance(),
"terrain_distance": self.terrain_distance(),
"source": if self.lod.is_none() { "none" } else if self.integrated.is_some() { "integrated" } else if self.server_far_terrain().is_some() { "server" } else { "local" },
"source": if self.lod.is_none() { "none" } else if self.integrated.is_some() { "integrated" } else if self.server_far_terrain().is_some() { "server" } else if self.remote.is_some() { "server-terrain" } else { "local" },
"source_attempted": self.lod_source_attempted,
"active_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().0),
"cached_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().1),
+23 -1
View File
@@ -450,6 +450,11 @@ pub(super) struct Remote {
/// The item the player's use began with (`LivingEntity.useItem`).
pub(super) use_item: Option<Arc<str>>,
pub(super) dimension: DimensionType,
/// The level's key (`minecraft:overworld`): where its far terrain is kept.
pub(super) level: String,
/// The level's hashed seed (login, respawn): the folder of its kept chunks
/// (`server_terrain::level_dir`).
pub(super) hashed_seed: i64,
/// What chunk packets decode with (the dimension's height and sky, the
/// biome registry); replaced whenever one of them changes.
pub(super) chunk_format: Arc<ChunkFormat>,
@@ -960,6 +965,8 @@ impl Game<'_> {
camera: None,
use_item: None,
dimension: DimensionType::vanilla("minecraft:overworld"),
level: "minecraft:overworld".into(),
hashed_seed: 0,
chunk_format: Arc::new(ChunkFormat::new(
&DimensionType::vanilla("minecraft:overworld"),
Vec::new(),
@@ -2032,6 +2039,8 @@ impl Game<'_> {
.in_column(shape.column()),
);
r.dimension = dimension;
r.level = spawn.dimension.clone();
r.hashed_seed = spawn.seed;
r.game_mode = spawn.game_type;
r.flat = spawn.flat;
r.loaded_sent = false;
@@ -2139,6 +2148,19 @@ impl Game<'_> {
(x - r.chunk_center.0).abs() <= range && (z - r.chunk_center.1).abs() <= range
})
}
/// A column the client no longer holds; far terrain keeps it as it was
/// last (`server_terrain`).
fn forget_remote_chunk(
&mut self,
x: i32,
z: i32,
) -> Option<std::sync::Arc<horizon_client::chunk::Chunk>> {
let chunk = self.streaming.forget(&mut self.world, x, z)?;
if let Some(lod) = &mut self.lod {
lod.observe(chunk.clone(), self.res.facts());
}
Some(chunk)
}
/// The columns a moved or resized storage no longer holds.
fn drop_remote_chunks_out_of_range(&mut self) {
let mut gone: horizon_client::fast_hash::FxHashSet<(i32, i32)> = self
@@ -2162,7 +2184,7 @@ impl Game<'_> {
}
let mut freed = Vec::with_capacity(gone.len());
for &(x, z) in gone.iter() {
freed.extend(self.streaming.forget(&mut self.world, x, z));
freed.extend(self.forget_remote_chunk(x, z));
}
if let Some(renderer) = self.renderer.as_mut() {
renderer.remove_columns(gone.iter().copied());
+3 -1
View File
@@ -63,11 +63,13 @@ A full opaque cube means `RenderShape::Model`, `can_occlude`, `solid_render` and
Distant terrain uses progressively coarser voxel columns. Columns retain separate material layers and air gaps; matching exposed faces are merged, with block-scale texture repeats. Complete chunk data is reduced once on the terrain worker and cached at each supported scale, across each cell’s area instead of enlarging its corner column. Material votes combine different light levels of the same block; reduced cells retain the brightest sampled sky and block light separately. Finer tiles replace only their own footprints in a loading fallback, and a movement margin limits rapid resolution changes at tile boundaries. When Full Detail Distance shrinks, the chunks it no longer covers stay drawn until the tiles planned there are ready, so the coarsest loaded tiles never stand in for them. Local Overworld, Nether and End worlds and a LAN world you host can supply their generator. Remote servers do not supply the client's world-generation seed or generator configuration.
On a server that doesn't sample far terrain for you (any vanilla, Paper or Spigot server; Horizon servers that do are below), distant terrain is what the server has sent: every chunk you load is kept as its reduced columns in `server-terrain/<server>/<seed>/<namespace>/<dimension>/` (Anvil region files, about a sector a chunk; `<seed>` is the hashed seed the server sends, which keeps the worlds behind one address apart) in the game folder, and drawn beyond the server's view distance on this visit and later ones. Nothing is pruned: delete `server-terrain/<server>` to forget a server (a server whose plugin sends another hashed seed at every login starts a new folder each time; the worlds of a proxy whose servers all hide their seed as 0 share one). A friend's world is not kept (its invite code changes at every share), nor a splitscreen guest's (it is the player's own world). Areas you have never loaded stay empty; far off, where a cell of far terrain is wider than a chunk, it is drawn from the kept chunk nearest its middle. A chunk the server sends again replaces the kept one; changes made while you are away show on your next visit. The kept columns are tied to the game version; after an update a server's terrain fills in again as you play.
Unknown areas are sampled directly from the dimension's seed-bound density and surface rules, with shared per-tile scratch and material evaluation. A small pool samples tiles independently of meshing. Refinement prioritizes visible screen error; high aerial views use the distance to the world height range to avoid unnecessary close detail below the camera. Initial vegetation uses the world's configured tree types, placement densities and biome assignments at every distance level, and chorus plants on the End's end stone in the same way. The End's small islands are estimated above the void from the `end_island_decorated` feature. Tree, chorus and island positions, shapes and large-cell coverage are visual approximations, not a replay of native feature placement; chorus is drawn only while its cells stay small enough to read as a plant.
A separate bounded worker refines the nearest LOD levels with disposable ordinary chunk generation, including supported carvers, structures and decoration. Groups of 4×4 chunks share their generation halos, and each chunk is published once every neighbouring feature center has completed. Farther tiles use direct sampling rather than waiting for every intervening ordinary chunk. Saved and visited blocks take precedence over both paths. These temporary chunks never enter gameplay, simulation or normal save queues. Generated-column caches and request queues are bounded; far data is regenerated after reopening a world.
On a dedicated server, the client has no generator of the world: the server samples the far tiles for it over Horizon's own `horizon:lod` channel, out to the distance its owner allows (`horizon-lod-distance`, off unless the owner sets it: see [hosting](hosting.md)), and the client's LOD Distance stops there. Those tiles are sampled from the generator, without the decoration refinement above; the chunks the server sent are summarized into far terrain as in single player. A friend's open-to-LAN world doesn't offer far terrain yet: it has no setting for that distance.
On a Horizon dedicated server that offers it, the client has no generator of the world: the server samples the far tiles for it over Horizon's own `horizon:lod` channel, out to the distance its owner allows (`horizon-lod-distance`, off unless the owner sets it: see [hosting](hosting.md)), and the client's LOD Distance stops there. Those tiles are sampled from the generator, without the decoration refinement above; the chunks the server sent are summarized into far terrain as in single player. A friend's open-to-LAN world doesn't offer far terrain yet: it has no setting for that distance.
Coverage and detail improve progressively; a large selected radius does not mean all its target detail is already resident. Far sampling runs behind the game's frame and tick threads, on half the machine's threads (at most 8) from 8 threads up and one below that; it queues its full share of tiles only once the nearby chunks have loaded. On machines with four threads or fewer it samples one tile every 500 ms and meshes nothing while nearby chunks are generated, lit or meshed for the first time; remeshing chunks already drawn doesn't hold it back. The generated detail uses a third of the threads, rounded up, at idle priority. The control status reports target and sampled target tile counts separately (`lod.target_tiles`, `lod.sampled_target_tiles`), and the far columns that take generated detail and how many have it (`lod.detail_target_columns`, `lod.refined_target_columns`). Direct far sampling approximates vertical noise interpolation, fluid boundaries and lighting; surface slopes and column extensions, small features and partial block shapes can differ. Unexplored distant structures are not synthesized by the direct path, and native decoration order can differ from subsequent gameplay generation. Horizon-supported data-driven noise and tree configurations can be reused; this does not execute arbitrary Fabric/NeoForge worldgen or structure mods.
+2
View File
@@ -445,6 +445,8 @@ const NOT_COPIED: &[&str] = &[
"telemetry",
"mod-browser",
".horizon-cache",
// Far terrain's copies of what servers sent, rebuilt as the instance plays.
"server-terrain",
];
/// Files of the player, not of one instance, that a new instance starts with: the keys and video
+5 -5
View File
@@ -6,8 +6,8 @@
A real Horizon client (horizon-ctl, offscreen) with LOD Distance 32 joins a dedicated server:
- `horizon-lod-distance=16`: the server samples the client's far terrain (`status`'s lod.source is "server"), no
farther than 16 chunks (effective_distance 16), and far tiles are drawn (active_tiles > 0);
- no `horizon-lod-distance` (off by default): the server offers none and the client has no far terrain (source
"none").
- no `horizon-lod-distance` (off by default): the server offers none, so the client draws only the chunks it was
sent (source "server-terrain", `server_terrain.rs`), nothing the server sampled.
Each server and game runs in fresh folders with fresh HORIZON_HOME and XDG folders (lab.game_env), on a free
port. Build first:
@@ -94,9 +94,9 @@ def check(bins, scratch, out):
finally:
s.stop()
print(f'off: {lod}')
if lod['source'] != 'none' or lod['active_tiles'] != 0:
raise Failed(f'off: the client has far terrain from a server that offers none: {lod}')
print('off: no far terrain')
if lod['source'] != 'server-terrain':
raise Failed(f'off: expected only the chunks the server sent (server-terrain): {lod}')
print('off: only the chunks the server sent')
def main():