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