Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
37d0c61
feat!: terrain regeneration, refered feature/nicer-terrain
NanCunChild Jun 3, 2026
3ab1ebf
feat: biome system, added plain, ocean, windswept_hills; fix: heightm…
NanCunChild Jun 4, 2026
d0e1b6a
feat: tree generation; chore: terrain generation optimized
NanCunChild Jun 4, 2026
2d583d0
fix: tree generation cross chunks
NanCunChild Jun 4, 2026
173674b
refactor!: lib.rs pipeline refactored to satisfy low frequency terrai…
NanCunChild Jun 4, 2026
cdad685
docs: update terrain for suit modern situations; fix: added strong co…
NanCunChild Jun 4, 2026
0aa0f1b
feat: flowers and grass; fix: cave break trees and snows fixed; test:…
NanCunChild Jun 4, 2026
aca28c3
fix: introducting a new strature column to make sure all arguments match
NanCunChild Jun 4, 2026
520648b
fix: obsidian test locked up. test: HANG_GUARD up to 100,000 ticks
NanCunChild Jun 5, 2026
918e373
perf: tree overscan reuse to reduce the RAM usage; feat: terrain allo…
NanCunChild Jun 5, 2026
ea3a9cb
perf: fluid cave-seeking algorithm optimized
NanCunChild Jun 5, 2026
d17092d
feat: fluid frontier scan to locate hanging fluids for settle-on-load…
NanCunChild Jun 5, 2026
8e9ef55
feat: settle hanging fluids in newly loaded chunks near players (sett…
NanCunChild Jun 5, 2026
8b949ab
docs: document static water flood and lazy settle-on-load of hanging …
NanCunChild Jun 5, 2026
74be03a
perf: skip fluid-free sections in settle scan via palette check, ~100…
NanCunChild Jun 5, 2026
52c4c16
perf: skip deep-water section interiors in settle scan; only the bott…
NanCunChild Jun 5, 2026
090ec4e
feat: randomise the world generation seed each launch and log it
NanCunChild Jun 5, 2026
48b66c7
feat: broadcast measured server TPS to clients via vanilla Set Tickin…
NanCunChild Jun 5, 2026
d559bc4
perf: fluid ticks operate on loaded chunks only, never generating ter…
NanCunChild Jun 5, 2026
3311886
fix: world sync now clears the dirty flag after saving a chunk
NanCunChild Jun 5, 2026
8cb8458
fix: send real (monotonic) world age in time updates so TPS HUDs can …
NanCunChild Jun 5, 2026
77e2534
perf: cap fluid ticks processed per game tick so large cascades sprea…
NanCunChild Jun 5, 2026
12a43ff
perf: settle generated fluids at generation time on the chunk worker …
NanCunChild Jun 5, 2026
a05ebe4
perf: remove global lock around LMDB env, cache db handles, batch wor…
NanCunChild Jun 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion assets/data/configs/main-config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,23 @@ chunks_per_tick_min = 16
# Matches vanilla behaviour at a higher CPU cost.
# "simplified" - Cheaper approximation: uniform spread with no hole steering.
algorithm = "vanilla"
# Settle "hanging" fluids (a cave that breached an ocean, a perched spring) while a chunk is generated,
# on the chunk worker thread, so it arrives already flowed and the game-tick thread does no fluid work
# for it. This is the primary, off-tick settle path and resolves all flow contained within a chunk.
settle_on_generate = true
# Max block changes the generation-time settle makes per chunk before stopping (0 = unbounded). Bounds
# worker-thread cost on pathological chunks; any remainder is finished by the on-load pass.
max_settle_changes = 65536
# Also settle hanging fluids on the game-tick thread the first time a chunk loads near a player. With
# settle_on_generate on this only mops up cross-chunk seams (cheap). Set false to keep all fluid work
# off the tick thread; chunk-interior fluids are still settled at generation time.
settle_on_load = true
# Maximum number of fluid ticks processed per game tick (0 = unbounded). Spreads a large cascade over
# several ticks instead of freezing one. Lower this if fluid activity still causes tick overruns.
max_ticks_per_tick = 2048

[dashboard]
# The port the dashboard will run on.
port = 9000
# Randomly generated secret for accessing the dashboard.
secret = "This will be replaced with a random string on first run, do not change this text. If you see this outside of the default config, something is wrong."
secret = "This will be replaced with a random string on first run, do not change this text. If you see this outside of the default config, something is wrong."
277 changes: 277 additions & 0 deletions docs/world-generation/terrain.md

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions src/bin/src/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,14 @@ use tracing::{error, info};

/// Creates the initial server state with all required components.
pub fn create_state(start_time: Instant) -> Result<ServerState, BinaryError> {
// Fixed seed for world generation. This seed ensures you spawn above land at the default spawn point.
const SEED: u64 = 380;
// A fresh random world seed each launch. The chosen value is logged so a world worth keeping can
// be reproduced by pinning the seed. (Persisted chunks are not regenerated, so a new seed only
// shapes terrain that has not been generated yet.)
let seed: u64 = rand::random();
info!("World generation seed: {seed}");
Ok(ServerState {
world: World::new(&get_global_config().database.db_path),
terrain_generator: WorldGenerator::new(SEED),
terrain_generator: WorldGenerator::new(seed),
shut_down: false.into(),
players: PlayerList::default(),
thread_pool: ThreadPool::new(),
Expand Down
5 changes: 4 additions & 1 deletion src/bin/src/register_resources.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use crate::systems::fluids::{ActiveDimension, FluidScheduler, FluidTickControl};
use crate::systems::fluids::{
ActiveDimension, FluidScheduler, FluidSettleTracker, FluidTickControl,
};
use crate::systems::new_connections::NewConnectionRecv;
use bevy_ecs::prelude::World;
use crossbeam_channel::Receiver;
Expand Down Expand Up @@ -26,6 +28,7 @@ pub fn register_resources(
world.insert_resource(FluidScheduler::default());
world.insert_resource(ActiveDimension::default());
world.insert_resource(FluidTickControl::default());
world.insert_resource(FluidSettleTracker::default());
world.insert_resource(ServerPerformance::new(get_global_config().tps));
world.insert_resource(PhysicalRegistry::new());
}
5 changes: 5 additions & 0 deletions src/bin/src/systems/chunk_calculator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,13 @@ pub fn handle(
for x in player_chunk.x() - radius..=player_chunk.x() + radius {
for z in player_chunk.z() - radius..=player_chunk.z() + radius {
let chunk_coords = (x, z);
// Skip chunks already sent (`loaded`), already queued this session
// (`already_queued`), or currently being generated off-thread (`pending`). Without
// the `pending` check, an in-flight chunk — popped from `loading` but not yet sent —
// would be re-queued on every move and resubmitted to the thread pool.
if !chunk_receiver.loaded.contains(&chunk_coords)
&& !already_queued.contains(&chunk_coords)
&& !chunk_receiver.pending.contains(&chunk_coords)
{
queued_chunks.push(chunk_coords);
}
Expand Down
262 changes: 155 additions & 107 deletions src/bin/src/systems/chunk_sending.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,26 @@ use ferrumc_state::GlobalStateResource;
use ferrumc_world::pos::ChunkPos;
use std::cmp::max;
use std::sync::atomic::Ordering;

// Just take the needed chunks from the ChunkReceiver and send them
// calculating which chunks are required is figured out elsewhere
// TODO: Respect chunks_per_tick limit
use tracing::error;

/// Sends chunks to players without ever blocking the tick thread on generation or compression.
///
/// The work is split into two non-blocking phases per connected player:
///
/// 1. **Submit** — pop queued/dirty chunk coordinates (up to the per-tick budget) and hand each to
/// the thread pool as a fire-and-forget job. The job loads or generates the chunk, builds the
/// `ChunkAndLightData` packet, compresses it, and pushes the encoded bytes onto the receiver's
/// lock-free `results` queue when finished (possibly several ticks later). In-flight coordinates
/// are recorded in `pending` so they are neither resubmitted here nor re-queued by the chunk
/// calculator.
/// 2. **Drain** — collect whatever encoded chunks finished since the last tick and send them to the
/// client wrapped in a single chunk batch.
///
/// Previously this system blocked the tick on `batch.wait()` until every submitted chunk had been
/// generated and compressed, so a burst of new chunks (a player joining, or moving quickly) would
/// overrun the tick budget. Moving the wait off the tick thread keeps each tick cheap regardless of
/// how much terrain is in flight; the trade-off is that freshly queued chunks arrive a few ticks
/// later instead of within the same tick.
pub fn handle(
mut query: Query<(
Entity,
Expand All @@ -29,11 +45,23 @@ pub fn handle(
)>,
state: Res<GlobalStateResource>,
) {
for (eid, conn, mut chunk_receiver, pos, client_info) in query.iter_mut() {
'entity: for (eid, conn, mut chunk_receiver, pos, client_info) in query.iter_mut() {
if !state.0.players.is_connected(eid) {
continue; // Skip if the player is not connected
continue 'entity; // Skip if the player is not connected
}

let chunk_receiver = &mut *chunk_receiver;

let player_chunk = IVec2::new(
pos.coords.x.floor() as i32 >> 4,
pos.coords.z.floor() as i32 >> 4,
);
let radius = effective_view_radius(
get_global_config().chunk_render_distance as i32,
client_info.view_distance as i32,
);

// ── Phase 1: submit new chunk jobs to the thread pool (fire-and-forget) ──────────────
let chunk_per_tick = match get_global_config().performance.chunks_per_tick {
0 => max(
chunk_receiver.loading.len() / 3,
Expand All @@ -43,124 +71,144 @@ pub fn handle(
hard_limit => hard_limit as usize,
};

if chunk_receiver.dirty.is_empty() && chunk_receiver.loading.is_empty() {
continue;
}

let chunk_receiver = &mut *chunk_receiver;

let mut dirty_chunks = Vec::new();
let mut sent_chunks = 0;

// First handle dirty chunks
while let Some(coords) = &chunk_receiver.dirty.pop_front() {
dirty_chunks.push(*coords);
sent_chunks += 1;
if sent_chunks >= chunk_per_tick {
let is_compressed = conn.compress.load(Ordering::Relaxed);
let mut submitted = 0;
while submitted < chunk_per_tick {
// Dirty chunks (already sent once, needing a resend) take priority over first-time
// loads, matching the previous ordering.
let Some(coords) = chunk_receiver
.dirty
.pop_front()
.or_else(|| chunk_receiver.loading.pop_front())
else {
break;
};

// Skip anything already in flight or already sent.
if chunk_receiver.pending.contains(&coords) || chunk_receiver.loaded.contains(&coords) {
continue;
}
}

let mut needed_chunks: Vec<(i32, i32)> = Vec::new();
chunk_receiver.pending.insert(coords);
submitted += 1;

let state_arc = state.0.clone();
let results = chunk_receiver.results.clone();
// `oneshot` runs the job on the thread pool and the returned handle is dropped, leaving
// the job to complete in the background and report back through `results`.
drop(state.0.thread_pool.oneshot(move || {
let pos = ChunkPos::new(coords.0, coords.1);
if let Err(e) =
ferrumc_utils::world::load_or_generate_chunk(&state_arc, pos, "overworld")
{
error!("Failed to load or generate chunk {:?}: {}", coords, e);
results.push((coords, None));
return;
}

// Settle generated "hanging" fluids here on the worker thread, before the chunk is
// encoded and sent, so the chunk arrives already flowed and the game-tick thread does
// no fluid simulation for it. Flow contained within the chunk is fully resolved;
// cross-chunk seams are left to the on-load settle pass.
let fluids = &get_global_config().fluids;
if fluids.settle_on_generate {
if let Some(mut chunk) = state_arc.world.cached_chunk_mut(pos, "overworld") {
ferrumc_world::fluid::settle::settle_chunk(
&mut chunk,
pos,
ferrumc_world::dimension::Dimension::Overworld,
fluids.algorithm,
fluids.max_settle_changes as usize,
);
}
}

if sent_chunks < chunk_per_tick {
// Then handle loading chunks
while let Some(coords) = chunk_receiver.loading.pop_front() {
needed_chunks.push(coords);
sent_chunks += 1;
if sent_chunks >= chunk_per_tick {
break;
let Some(chunk) = state_arc.world.cached_chunk(pos, "overworld") else {
error!("Chunk {:?} vanished from cache after generation", coords);
results.push((coords, None));
return;
};
let packet = match ChunkAndLightData::from_chunk(pos, &chunk) {
Ok(packet) => packet,
Err(e) => {
error!("Failed to build chunk packet for {:?}: {}", coords, e);
results.push((coords, None));
return;
}
};
match compress_packet(
&packet,
is_compressed,
&NetEncodeOpts::WithLength,
get_global_config().network_compression_threshold as usize,
) {
Ok(bytes) => results.push((coords, Some(bytes))),
Err(e) => {
error!("Failed to compress chunk packet for {:?}: {}", coords, e);
results.push((coords, None));
}
}
}));
}

// ── Phase 2: drain finished chunks and send them in a single batch ───────────────────
let mut ready: Vec<Vec<u8>> = Vec::new();
while let Some((coords, maybe_bytes)) = chunk_receiver.results.pop() {
chunk_receiver.pending.remove(&coords);
let Some(bytes) = maybe_bytes else {
continue; // Job failed; leave it unloaded so the calculator can re-queue it.
};
// Drop chunks the player has since moved away from; they will be re-queued if they come
// back into view. Uses the same Chebyshev metric the calculator queues with.
if IVec2::new(coords.0, coords.1).chebyshev_distance(player_chunk) > radius as u32 {
continue;
}
chunk_receiver.loaded.insert(coords);
ready.push(bytes);
}

needed_chunks.extend(dirty_chunks);
if !ready.is_empty() {
if conn.send_packet(ChunkBatchStart {}).is_err() {
continue 'entity;
}

if needed_chunks.is_empty() {
continue;
};
let center_chunk: IVec3 = pos.coords.floor().as_ivec3() >> 4;
if conn
.send_packet(SetCenterChunk {
x: center_chunk.x.into(),
z: center_chunk.z.into(),
})
.is_err()
{
continue 'entity;
}

let mut batch = state.0.thread_pool.batch();

conn.send_packet(ChunkBatchStart {})
.expect("Failed to send ChunkBatchStart");

let center_chunk: IVec3 = pos.coords.floor().as_ivec3() >> 4;

conn.send_packet(SetCenterChunk {
x: center_chunk.x.into(),
z: center_chunk.z.into(),
})
.expect("Failed to send SetCenterChunk");

for coordinates in needed_chunks
.into_iter()
.filter(|coord| {
// Keep only chunks within the player's effective view radius, using the SAME
// metric (Chebyshev / square) and the SAME radius the calculator queued with.
// Previously this used a Euclidean circle (distance_squared <= r^2) while the
// calculator queued a square, so the square's corner chunks were popped here,
// failed this filter, were dropped without ever entering `loaded`, and got
// re-queued forever — wasting a tick's send budget and leaving the corners void.
let player_chunk_pos = IVec2::new(
pos.coords.x.floor() as i32 >> 4,
pos.coords.z.floor() as i32 >> 4,
);
let chunk_pos = IVec2::new(coord.0, coord.1);
let radius = effective_view_radius(
get_global_config().chunk_render_distance as i32,
client_info.view_distance as i32,
);
chunk_pos.chebyshev_distance(player_chunk_pos) <= radius as u32
})
.map(|c| ChunkPos::new(c.0, c.1))
{
chunk_receiver
.loaded
.insert((coordinates.x(), coordinates.z()));
let state = state.clone();
let is_compressed = conn.compress.load(Ordering::Relaxed);
batch.execute({
move || {
let chunk = ferrumc_utils::world::load_or_generate_chunk(
&state.0,
coordinates,
"overworld",
)
.expect("Failed to load or generate chunk");
let packet = ChunkAndLightData::from_chunk(coordinates, &chunk)
.expect("Failed to create ChunkAndLightData");
compress_packet(
&packet,
is_compressed,
&NetEncodeOpts::WithLength,
get_global_config().network_compression_threshold as usize,
)
.expect("Failed to compress ChunkAndLightData packet")
let batch_size = ready.len();
for bytes in ready {
if conn.send_raw_packet(bytes).is_err() {
continue 'entity;
}
});
}
let packets = batch.wait();
let packets_len = packets.len();
for packet in packets {
conn.send_raw_packet(packet)
.expect("Failed to send ChunkAndLightData");
}

conn.send_packet(ChunkBatchFinish {
batch_size: packets_len.into(),
})
.expect("Failed to send ChunkBatchFinish");
}

// Tell the client to unload chunks that are no longer needed
if conn
.send_packet(ChunkBatchFinish {
batch_size: batch_size.into(),
})
.is_err()
{
continue 'entity;
}
}

while let Some(coords) = &chunk_receiver.unloading.pop_front() {
// ── Phase 3: tell the client to unload chunks that are no longer needed ──────────────
while let Some(coords) = chunk_receiver.unloading.pop_front() {
let packet = ferrumc_net::packets::outgoing::unload_chunk::UnloadChunk {
x: coords.0,
z: coords.1,
};
conn.send_packet(packet)
.expect("Failed to send UnloadChunk packet");
if conn.send_packet(packet).is_err() {
continue 'entity;
}
}
}
}
Loading