Skip to main content

steel_core/server/
pregen.rs

1//! Startup pregeneration for the server default world.
2
3use std::collections::VecDeque;
4use std::env;
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use steel_utils::{ChunkPos, SectionPos};
9use tokio::time::sleep;
10use tokio_util::sync::CancellationToken;
11
12use crate::chunk::chunk_pyramid::GENERATION_PYRAMID;
13use crate::chunk::chunk_request::{
14    ChunkRequest, ChunkRequestHandle, ChunkRequestState, ChunkTicketKind,
15};
16use crate::chunk::status::ChunkStatus;
17use crate::server::Server;
18use crate::world::World;
19
20#[cfg(feature = "slow_chunk_gen")]
21use crate::chunk::chunk_holder::SLOW_CHUNK_GEN;
22#[cfg(feature = "slow_chunk_gen")]
23use std::sync::atomic::Ordering;
24
25const PREGEN_SIZE_ENV: &str = "PREGEN_SIZE";
26const PREGEN_WINDOW_SIZE_ENV: &str = "PREGEN_WINDOW_SIZE";
27const VANILLA_PLAYER_SPAWN_SIZE_CHUNKS: i32 = 7;
28const DEFAULT_PREGEN_WINDOW_SIZE: i32 = 32;
29const PREGEN_ACTIVE_WINDOWS: usize = 2;
30const PREGEN_UNLOAD_BACKPRESSURE_HIGH: usize = 8192;
31const PREGEN_UNLOAD_BACKPRESSURE_LOW: usize = 4096;
32const FULL_DEPENDENCY_RADIUS: i32 = GENERATION_PYRAMID
33    .get_step_to(ChunkStatus::Full)
34    .accumulated_dependencies
35    .get_radius_of(ChunkStatus::Empty) as i32;
36
37#[derive(Clone, Copy, Debug)]
38struct PregenWindow {
39    min_x: i32,
40    max_x: i32,
41    min_z: i32,
42    max_z: i32,
43}
44
45impl PregenWindow {
46    fn positions(self) -> Vec<ChunkPos> {
47        let mut positions = Vec::with_capacity(self.chunk_count());
48        for z in self.min_z..=self.max_z {
49            for x in self.min_x..=self.max_x {
50                positions.push(ChunkPos::new(x, z));
51            }
52        }
53        positions
54    }
55
56    const fn chunk_count(self) -> usize {
57        (self.width() * self.height()) as usize
58    }
59
60    const fn width(self) -> i32 {
61        self.max_x - self.min_x + 1
62    }
63
64    const fn height(self) -> i32 {
65        self.max_z - self.min_z + 1
66    }
67
68    const fn protected_rect(self) -> PregenRect {
69        PregenRect {
70            min_x: self.min_x - FULL_DEPENDENCY_RADIUS,
71            max_x: self.max_x + FULL_DEPENDENCY_RADIUS,
72            min_z: self.min_z - FULL_DEPENDENCY_RADIUS,
73            max_z: self.max_z + FULL_DEPENDENCY_RADIUS,
74        }
75    }
76}
77
78#[derive(Clone, Copy)]
79struct PregenRect {
80    min_x: i32,
81    max_x: i32,
82    min_z: i32,
83    max_z: i32,
84}
85
86impl PregenRect {
87    const fn overlaps(self, other: Self) -> bool {
88        self.min_x <= other.max_x
89            && self.max_x >= other.min_x
90            && self.min_z <= other.max_z
91            && self.max_z >= other.min_z
92    }
93}
94
95struct ActivePregenWindow {
96    window: PregenWindow,
97    handle: ChunkRequestHandle,
98    ready_chunks: usize,
99    ready: bool,
100    counted: bool,
101}
102
103impl ActivePregenWindow {
104    fn new(world: &Arc<World>, window: PregenWindow) -> Self {
105        let handle = world.chunk_map.request_chunks(ChunkRequest {
106            status: ChunkStatus::Full,
107            positions: window.positions(),
108            ticket_kind: ChunkTicketKind::Pregen,
109        });
110        Self {
111            window,
112            handle,
113            ready_chunks: 0,
114            ready: false,
115            counted: false,
116        }
117    }
118
119    fn poll(&mut self, world: &Arc<World>) {
120        match self.handle.poll() {
121            ChunkRequestState::Ready => {
122                self.ready_chunks = self.window.chunk_count();
123                self.ready = true;
124            }
125            ChunkRequestState::Pending { ready, .. } => {
126                self.ready_chunks = ready;
127            }
128            ChunkRequestState::Cancelled => {
129                self.handle = world.chunk_map.request_chunks(ChunkRequest {
130                    status: ChunkStatus::Full,
131                    positions: self.window.positions(),
132                    ticket_kind: ChunkTicketKind::Pregen,
133                });
134                self.ready_chunks = 0;
135                self.ready = false;
136                self.counted = false;
137            }
138        }
139    }
140}
141
142#[derive(Clone, Copy, Debug, PartialEq, Eq)]
143struct PregenSize {
144    side_length: i32,
145    radius: i32,
146}
147
148impl PregenSize {
149    fn from_side_length(side_length: i32) -> Result<Option<Self>, String> {
150        if side_length == 0 {
151            return Ok(None);
152        }
153        if side_length < 0 {
154            return Err(format!(
155                "{PREGEN_SIZE_ENV} must be 0 or a positive odd integer"
156            ));
157        }
158        if side_length % 2 == 0 {
159            return Err(format!(
160                "{PREGEN_SIZE_ENV} must be odd so the area has a single center chunk"
161            ));
162        }
163
164        Ok(Some(Self {
165            side_length,
166            radius: side_length / 2,
167        }))
168    }
169}
170
171impl Server {
172    /// Generates the startup spawn area for the server default world.
173    ///
174    /// Set `PREGEN_SIZE` to an odd chunk side length, or `0` to skip custom pregen.
175    /// Set `PREGEN_WINDOW_SIZE` to override the default 32-chunk window side length.
176    /// The configured size must fit the active-window unload-backpressure budget.
177    pub async fn prepare_spawn_area(&self) -> bool {
178        let overworld = self.overworld();
179        let pregen_size = match get_pregen_size() {
180            Ok(Some(size)) => size,
181            Ok(None) => {
182                log::info!("Skipping custom startup spawn-area pregeneration");
183                return true;
184            }
185            Err(error) => {
186                log::error!("{error}");
187                return false;
188            }
189        };
190        let window_size = match get_pregen_window_size() {
191            Ok(window_size) => window_size,
192            Err(error) => {
193                log::error!("{error}");
194                return false;
195            }
196        };
197        let center_chunk = if pregen_size.side_length > VANILLA_PLAYER_SPAWN_SIZE_CHUNKS {
198            ChunkPos::new(0, 0)
199        } else {
200            let spawn_pos = overworld.level_data.read().data().spawn_pos();
201            ChunkPos::new(
202                SectionPos::block_to_section_coord(spawn_pos.0.x),
203                SectionPos::block_to_section_coord(spawn_pos.0.z),
204            )
205        };
206
207        pregen_overworld(
208            overworld,
209            center_chunk,
210            pregen_size,
211            window_size,
212            &self.cancel_token,
213        )
214        .await
215    }
216}
217
218fn get_pregen_size() -> Result<Option<PregenSize>, String> {
219    let side_length = match env::var(PREGEN_SIZE_ENV) {
220        Ok(value) => value
221            .parse::<i32>()
222            .map_err(|e| format!("{PREGEN_SIZE_ENV} must be 0 or a positive odd integer: {e}"))?,
223        Err(env::VarError::NotPresent) => return Ok(None),
224        Err(env::VarError::NotUnicode(_)) => {
225            return Err(format!("{PREGEN_SIZE_ENV} must be valid unicode"));
226        }
227    };
228
229    PregenSize::from_side_length(side_length)
230}
231
232fn get_pregen_window_size() -> Result<i32, String> {
233    match env::var(PREGEN_WINDOW_SIZE_ENV) {
234        Ok(value) => parse_pregen_window_size(&value),
235        Err(env::VarError::NotPresent) => Ok(DEFAULT_PREGEN_WINDOW_SIZE),
236        Err(env::VarError::NotUnicode(_)) => {
237            Err(format!("{PREGEN_WINDOW_SIZE_ENV} must be valid unicode"))
238        }
239    }
240}
241
242fn parse_pregen_window_size(value: &str) -> Result<i32, String> {
243    let window_size = value
244        .parse::<i32>()
245        .map_err(|error| format!("{PREGEN_WINDOW_SIZE_ENV} must be a positive integer: {error}"))?;
246    if window_size <= 0 {
247        return Err(format!(
248            "{PREGEN_WINDOW_SIZE_ENV} must be a positive integer"
249        ));
250    }
251
252    let window_size_as_usize = window_size as usize;
253    let Some(active_target_chunks) = window_size_as_usize
254        .checked_mul(window_size_as_usize)
255        .and_then(|chunk_count| chunk_count.checked_mul(PREGEN_ACTIVE_WINDOWS))
256    else {
257        return Err(format!(
258            "{PREGEN_WINDOW_SIZE_ENV} is too large for the pregeneration window budget"
259        ));
260    };
261    if active_target_chunks > PREGEN_UNLOAD_BACKPRESSURE_HIGH {
262        return Err(format!(
263            "{PREGEN_WINDOW_SIZE_ENV} must keep {PREGEN_ACTIVE_WINDOWS} active windows within the {PREGEN_UNLOAD_BACKPRESSURE_HIGH}-chunk unload-backpressure budget"
264        ));
265    }
266
267    Ok(window_size)
268}
269
270async fn pregen_overworld(
271    world: &Arc<World>,
272    center_chunk: ChunkPos,
273    pregen_size: PregenSize,
274    window_size: i32,
275    cancel_token: &CancellationToken,
276) -> bool {
277    let total_chunks = total_chunks(pregen_size.side_length);
278
279    log::info!(
280        "Preparing spawn area: {} chunks ({}x{}) around chunk ({}, {})",
281        total_chunks,
282        pregen_size.side_length,
283        pregen_size.side_length,
284        center_chunk.0.x,
285        center_chunk.0.y,
286    );
287
288    #[cfg(feature = "slow_chunk_gen")]
289    SLOW_CHUNK_GEN.store(true, Ordering::Relaxed);
290
291    let elapsed = {
292        let start = Instant::now();
293        let completed =
294            generate_pregen(world, center_chunk, pregen_size, window_size, cancel_token).await;
295        (start.elapsed(), completed)
296    };
297
298    #[cfg(feature = "slow_chunk_gen")]
299    SLOW_CHUNK_GEN.store(false, Ordering::Relaxed);
300
301    let elapsed_secs = elapsed.0.as_secs_f64();
302    let chunks_per_second = if elapsed_secs > 0.0 {
303        total_chunks as f64 / elapsed_secs
304    } else {
305        0.0
306    };
307    if elapsed.1 {
308        log::info!(
309            "Spawn area prepared: {total_chunks} chunks in {elapsed_secs:.2}s ({chunks_per_second:.1} chunks/s)",
310        );
311    } else {
312        log::info!("Spawn area preparation cancelled after {elapsed_secs:.2}s");
313    }
314    elapsed.1
315}
316
317fn build_pregen_windows(
318    center_chunk: ChunkPos,
319    radius: i32,
320    window_size: i32,
321) -> VecDeque<PregenWindow> {
322    let min_x = center_chunk.0.x - radius;
323    let max_x = center_chunk.0.x + radius;
324    let min_z = center_chunk.0.y - radius;
325    let max_z = center_chunk.0.y + radius;
326    let x_ranges = pregen_window_ranges(min_x, max_x, window_size);
327    let z_ranges = pregen_window_ranges(min_z, max_z, window_size);
328    let mut windows = VecDeque::new();
329
330    // A two-row serpentine keeps the bounded active set spatially local while
331    // reusing one of every two dependency boundaries between window rows.
332    for (strip_index, z_pair) in z_ranges.chunks(2).enumerate() {
333        let mut push_column = |&(window_min_x, window_max_x): &(i32, i32)| {
334            for &(window_min_z, window_max_z) in z_pair {
335                windows.push_back(PregenWindow {
336                    min_x: window_min_x,
337                    max_x: window_max_x,
338                    min_z: window_min_z,
339                    max_z: window_max_z,
340                });
341            }
342        };
343
344        if strip_index % 2 == 0 {
345            for x_range in &x_ranges {
346                push_column(x_range);
347            }
348        } else {
349            for x_range in x_ranges.iter().rev() {
350                push_column(x_range);
351            }
352        }
353    }
354
355    windows
356}
357
358fn pregen_window_ranges(min: i32, max: i32, window_size: i32) -> Vec<(i32, i32)> {
359    let mut ranges = Vec::new();
360    let mut start = min;
361    while start <= max {
362        let end = start.saturating_add(window_size - 1).min(max);
363        ranges.push((start, end));
364        start = end + 1;
365    }
366    ranges
367}
368
369async fn generate_pregen(
370    world: &Arc<World>,
371    center_chunk: ChunkPos,
372    pregen_size: PregenSize,
373    window_size: i32,
374    cancel_token: &CancellationToken,
375) -> bool {
376    let total_chunks = total_chunks(pregen_size.side_length);
377    let mut pending_windows = build_pregen_windows(center_chunk, pregen_size.radius, window_size);
378    let mut active_windows = Vec::with_capacity(PREGEN_ACTIVE_WINDOWS + 1);
379    let mut last_report = Instant::now();
380    let mut last_completed = 0usize;
381    let mut completed = 0usize;
382    let mut unload_backpressure = false;
383    let mut peak_unloading_chunks = 0usize;
384    let start = Instant::now();
385
386    log::info!(
387        "Pregeneration windowing: {window_size}x{window_size} target chunks, {PREGEN_ACTIVE_WINDOWS} active windows, dependency halo {FULL_DEPENDENCY_RADIUS} chunks",
388    );
389
390    fill_active_windows(world, &mut pending_windows, &mut active_windows);
391
392    while completed < total_chunks {
393        if cancel_token.is_cancelled() {
394            release_all_windows(world, &mut active_windows);
395            return false;
396        }
397
398        drain_pregen_broadcasts(world);
399        world.chunk_map.advance_scheduling();
400        peak_unloading_chunks = peak_unloading_chunks.max(world.chunk_map.unloading_chunks.len());
401        update_unload_backpressure(world, &mut unload_backpressure);
402
403        for active in &mut active_windows {
404            active.poll(world);
405        }
406
407        if !unload_backpressure {
408            let newly_ready_count = active_windows
409                .iter()
410                .filter(|active| active.ready && !active.counted)
411                .count();
412            for _ in 0..newly_ready_count {
413                activate_next_window(world, &mut pending_windows, &mut active_windows);
414            }
415        }
416
417        for active in &mut active_windows {
418            if active.ready && !active.counted {
419                completed += active.window.chunk_count();
420                active.counted = true;
421            }
422        }
423        if !unload_backpressure {
424            fill_active_windows(world, &mut pending_windows, &mut active_windows);
425        }
426        drain_pregen_broadcasts(world);
427        world.chunk_map.advance_scheduling();
428        release_unneeded_completed_windows(world, &mut active_windows);
429        peak_unloading_chunks = peak_unloading_chunks.max(world.chunk_map.unloading_chunks.len());
430        update_unload_backpressure(world, &mut unload_backpressure);
431
432        if completed == total_chunks {
433            break;
434        }
435
436        if pregen_size.side_length > VANILLA_PLAYER_SPAWN_SIZE_CHUNKS
437            && last_report.elapsed() >= Duration::from_secs(5)
438        {
439            let report_elapsed = last_report.elapsed().as_secs_f64();
440            let ready_in_active = active_windows
441                .iter()
442                .filter(|active| !active.counted)
443                .map(|active| active.ready_chunks)
444                .sum::<usize>();
445            let current_completed = (completed + ready_in_active).min(total_chunks);
446            let elapsed = start.elapsed().as_secs_f64();
447            let chunks_per_sec = if elapsed > 0.0 {
448                (current_completed.saturating_sub(last_completed)) as f64 / report_elapsed
449            } else {
450                0.0
451            };
452            let percent = (current_completed as f64 / total_chunks as f64) * 100.0;
453            let remaining = total_chunks.saturating_sub(current_completed);
454            let eta = if chunks_per_sec > 0.0 && remaining > 0 {
455                remaining as f64 / chunks_per_sec
456            } else {
457                0.0
458            };
459            log::info!(
460                "Progress: {current_completed}/{total_chunks} ({percent:.1}%), {chunks_per_sec:.1} chunks/s, ETA: {eta:.0}s",
461            );
462            last_report = Instant::now();
463            last_completed = current_completed;
464        }
465
466        tokio::select! {
467            () = cancel_token.cancelled() => {
468                release_all_windows(world, &mut active_windows);
469                return false;
470            }
471            () = sleep(Duration::from_millis(10)) => {}
472        }
473    }
474
475    release_all_windows(world, &mut active_windows);
476    peak_unloading_chunks = peak_unloading_chunks.max(world.chunk_map.unloading_chunks.len());
477    log::info!("Pregeneration peak unload backlog: {peak_unloading_chunks} chunks");
478    true
479}
480
481fn update_unload_backpressure(world: &Arc<World>, unload_backpressure: &mut bool) {
482    let unloading_chunks = world.chunk_map.unloading_chunks.len();
483    if *unload_backpressure {
484        if unloading_chunks <= PREGEN_UNLOAD_BACKPRESSURE_LOW {
485            *unload_backpressure = false;
486            log::info!(
487                "Pregen unload backpressure released: unloading_chunks={unloading_chunks}, low_watermark={PREGEN_UNLOAD_BACKPRESSURE_LOW}",
488            );
489        }
490        return;
491    }
492
493    if unloading_chunks >= PREGEN_UNLOAD_BACKPRESSURE_HIGH {
494        *unload_backpressure = true;
495        log::info!(
496            "Pregen unload backpressure active: unloading_chunks={unloading_chunks}, high_watermark={PREGEN_UNLOAD_BACKPRESSURE_HIGH}, low_watermark={PREGEN_UNLOAD_BACKPRESSURE_LOW}",
497        );
498    }
499}
500
501fn drain_pregen_broadcasts(world: &Arc<World>) {
502    world.chunk_map.broadcast_changed_chunks();
503}
504
505fn total_chunks(side_length: i32) -> usize {
506    let side_length = i64::from(side_length);
507    (side_length * side_length) as usize
508}
509
510fn fill_active_windows(
511    world: &Arc<World>,
512    pending_windows: &mut VecDeque<PregenWindow>,
513    active_windows: &mut Vec<ActivePregenWindow>,
514) {
515    while active_windows.iter().filter(|active| !active.ready).count() < PREGEN_ACTIVE_WINDOWS {
516        if !activate_next_window(world, pending_windows, active_windows) {
517            break;
518        }
519    }
520}
521
522fn activate_next_window(
523    world: &Arc<World>,
524    pending_windows: &mut VecDeque<PregenWindow>,
525    active_windows: &mut Vec<ActivePregenWindow>,
526) -> bool {
527    let Some(window) = pending_windows.pop_front() else {
528        return false;
529    };
530
531    active_windows.push(ActivePregenWindow::new(world, window));
532    true
533}
534
535fn release_unneeded_completed_windows(
536    world: &Arc<World>,
537    active_windows: &mut Vec<ActivePregenWindow>,
538) {
539    let incomplete_windows = active_windows
540        .iter()
541        .filter(|active| !active.ready)
542        .map(|active| active.window)
543        .collect::<Vec<_>>();
544
545    active_windows.retain(|active| {
546        if !active.ready {
547            return true;
548        }
549
550        let protected = active.window.protected_rect();
551
552        incomplete_windows
553            .iter()
554            .any(|window| protected.overlaps(window.protected_rect()))
555    });
556
557    world.chunk_map.advance_scheduling();
558}
559
560fn release_all_windows(world: &Arc<World>, active_windows: &mut Vec<ActivePregenWindow>) {
561    active_windows.clear();
562    world.chunk_map.advance_scheduling();
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568
569    #[test]
570    fn pregen_size_accepts_zero_as_disabled() {
571        assert_eq!(PregenSize::from_side_length(0), Ok(None));
572    }
573
574    #[test]
575    fn pregen_size_accepts_odd_side_lengths() {
576        assert_eq!(
577            PregenSize::from_side_length(7),
578            Ok(Some(PregenSize {
579                side_length: 7,
580                radius: 3,
581            }))
582        );
583    }
584
585    #[test]
586    fn pregen_size_rejects_even_side_lengths() {
587        assert!(PregenSize::from_side_length(2).is_err());
588    }
589
590    #[test]
591    fn pregen_size_rejects_negative_side_lengths() {
592        assert!(PregenSize::from_side_length(-1).is_err());
593    }
594
595    #[test]
596    fn pregen_window_order_keeps_consecutive_dependency_areas_local() {
597        let radius = DEFAULT_PREGEN_WINDOW_SIZE * 2;
598        let windows = build_pregen_windows(ChunkPos::new(0, 0), radius, DEFAULT_PREGEN_WINDOW_SIZE);
599
600        assert_eq!(windows.len(), 25);
601        assert_eq!(
602            windows
603                .iter()
604                .map(|window| window.chunk_count())
605                .sum::<usize>(),
606            total_chunks(radius * 2 + 1)
607        );
608        assert!(
609            windows
610                .iter()
611                .zip(windows.iter().skip(1))
612                .all(|(current, next)| current.protected_rect().overlaps(next.protected_rect()))
613        );
614    }
615
616    #[test]
617    fn pregen_window_size_requires_a_positive_integer() {
618        assert_eq!(parse_pregen_window_size("1"), Ok(1));
619        assert_eq!(parse_pregen_window_size("64"), Ok(64));
620        assert!(parse_pregen_window_size("0").is_err());
621        assert!(parse_pregen_window_size("-1").is_err());
622        assert!(parse_pregen_window_size("wide").is_err());
623    }
624
625    #[test]
626    fn pregen_window_size_must_fit_unload_backpressure_budget() {
627        assert!(parse_pregen_window_size("65").is_err());
628        assert!(parse_pregen_window_size(&i32::MAX.to_string()).is_err());
629    }
630}