Skip to main content

steel_worldgen/structure/
placement.rs

1//! Structure placement. Determines which chunks are valid for structure
2//! generation — vanilla's `StructurePlacement` hierarchy.
3
4use glam::IVec3;
5use rayon::prelude::*;
6use steel_utils::BlockPos;
7use steel_utils::ChunkPos;
8use steel_utils::Identifier;
9use steel_utils::random::Random;
10use steel_utils::random::legacy_random::LegacyRandom;
11
12const PARALLEL_RING_SNAP_THRESHOLD: usize = 8;
13
14/// How structures are spread within their grid cell. Vanilla's `RandomSpreadType`.
15#[derive(Debug, Clone, Copy)]
16pub enum SpreadType {
17    /// Uniform random within range.
18    Linear,
19    /// Average of two uniform samples (center-biased).
20    Triangular,
21}
22
23impl SpreadType {
24    /// Offset in `[0, limit)`.
25    pub fn evaluate(self, rng: &mut LegacyRandom, limit: i32) -> i32 {
26        match self {
27            Self::Linear => rng.next_i32_bounded(limit),
28            #[expect(
29                clippy::manual_midpoint,
30                reason = "midpoint would change overflow vs vanilla"
31            )]
32            Self::Triangular => (rng.next_i32_bounded(limit) + rng.next_i32_bounded(limit)) / 2,
33        }
34    }
35}
36
37/// Vanilla's `StructurePlacement.FrequencyReductionMethod`. Variants differ in
38/// seeding/RNG strategy for historical compatibility.
39#[derive(Debug, Clone, Copy, Default)]
40pub enum FrequencyReductionMethod {
41    /// Seeds with salt, uses `next_f32`.
42    #[default]
43    Default,
44    /// Pillager outpost legacy.
45    LegacyType1,
46    /// Hardcoded salt `10_387_320`.
47    LegacyType2,
48    /// Uses `next_f64` instead of `next_f32`.
49    LegacyType3,
50}
51
52impl FrequencyReductionMethod {
53    /// Args match vanilla's `FrequencyReducer`:
54    /// `(levelSeed, placementSalt, chunkX, chunkZ, frequency)`.
55    #[must_use]
56    pub fn should_generate(
57        self,
58        seed: i64,
59        salt: i32,
60        source_x: i32,
61        source_z: i32,
62        probability: f32,
63    ) -> bool {
64        let mut rng = LegacyRandom::from_seed(0);
65        match self {
66            Self::Default => {
67                rng.set_large_feature_with_salt(seed, salt, source_x, source_z);
68                rng.next_f32() < probability
69            }
70            Self::LegacyType1 => {
71                let cx = source_x >> 4;
72                let cz = source_z >> 4;
73                rng.set_seed(i64::from(cx ^ (cz << 4)) ^ seed);
74                rng.next_i32();
75                #[expect(
76                    clippy::cast_possible_truncation,
77                    reason = "vanilla: truncates reciprocal probability to i32 bound"
78                )]
79                let bound = (1.0_f32 / probability) as i32;
80                rng.next_i32_bounded(bound) == 0
81            }
82            Self::LegacyType2 => {
83                rng.set_large_feature_with_salt(seed, source_x, source_z, 10_387_320);
84                rng.next_f32() < probability
85            }
86            Self::LegacyType3 => {
87                rng.set_large_feature_seed(seed, source_x, source_z);
88                rng.next_f64() < f64::from(probability)
89            }
90        }
91    }
92}
93
94/// Vanilla's `StructurePlacement.ExclusionZone`.
95#[derive(Debug, Clone)]
96pub struct ExclusionZone {
97    /// Structure set to check against.
98    pub other_set: Identifier,
99    /// Radius in chunks.
100    pub chunk_count: i32,
101}
102
103/// Java's `Math.round(double)` — half-up toward +∞. Rust's `f64::round()` rounds
104/// -0.5 → -1; Java rounds -0.5 → 0.
105fn java_round(v: f64) -> i32 {
106    (v + 0.5).floor() as i32
107}
108
109/// Vanilla's `ChunkGeneratorStructureState.generateRingPositions`. Positions in
110/// chunk coords. If `snap_biome` is provided it gets `(block_x, block_z, &mut rng)`
111/// and returns `Some((snapped_block_x, snapped_block_z))` to snap, or `None` to keep raw.
112#[must_use]
113pub fn generate_ring_positions<F>(
114    seed: i64,
115    distance: i32,
116    spread: i32,
117    count: i32,
118    snap_biome: Option<&F>,
119    thread_pool: &rayon::ThreadPool,
120) -> Vec<ChunkPos>
121where
122    F: Fn(i32, i32, &mut LegacyRandom) -> Option<(i32, i32)> + Sync,
123{
124    use std::f64::consts::TAU;
125
126    struct RingCandidate {
127        initial_x: i32,
128        initial_z: i32,
129        forked: LegacyRandom,
130    }
131
132    fn map_candidate<F>(candidate: RingCandidate, snap: &F) -> ChunkPos
133    where
134        F: Fn(i32, i32, &mut LegacyRandom) -> Option<(i32, i32)> + Sync,
135    {
136        let RingCandidate {
137            initial_x,
138            initial_z,
139            mut forked,
140        } = candidate;
141
142        // sectionToBlockCoord(x, 8) = x * 16 + 8; snap result is blocks → >> 4.
143        if let Some((sx, sz)) = snap(initial_x * 16 + 8, initial_z * 16 + 8, &mut forked) {
144            ChunkPos::new(sx >> 4, sz >> 4)
145        } else {
146            ChunkPos::new(initial_x, initial_z)
147        }
148    }
149
150    if count == 0 {
151        return vec![];
152    }
153
154    let mut rng = LegacyRandom::from_seed(seed as u64);
155    let mut candidates = Vec::with_capacity(count as usize);
156    let mut angle = rng.next_f64() * TAU;
157    let mut spread = spread;
158    let mut position_in_circle = 0;
159    let mut circle = 0;
160
161    let distance_f = f64::from(distance);
162
163    for i in 0..count {
164        let dist = (4.0 + 6.0 * f64::from(circle)) * distance_f
165            + (rng.next_f64() - 0.5) * distance_f * 2.5;
166        let initial_x = java_round(angle.cos() * dist);
167        let initial_z = java_round(angle.sin() * dist);
168
169        // Vanilla forks the RNG for async biome search; preserve the exact fork order,
170        // then parallelize only the snap work over those precomputed forked states.
171        candidates.push(RingCandidate {
172            initial_x,
173            initial_z,
174            forked: rng.fork(),
175        });
176
177        angle += TAU / f64::from(spread);
178        position_in_circle += 1;
179        if position_in_circle == spread {
180            circle += 1;
181            position_in_circle = 0;
182            spread += 2 * spread / (circle + 1);
183            spread = spread.min(count - i);
184            angle += rng.next_f64() * TAU;
185        }
186    }
187
188    match snap_biome {
189        Some(snap) if candidates.len() >= PARALLEL_RING_SNAP_THRESHOLD => {
190            thread_pool.install(|| {
191                candidates
192                    .into_par_iter()
193                    .map(|candidate| map_candidate(candidate, snap))
194                    .collect()
195            })
196        }
197        Some(snap) => candidates
198            .into_iter()
199            .map(|candidate| map_candidate(candidate, snap))
200            .collect(),
201        None => candidates
202            .into_iter()
203            .map(|candidate| ChunkPos::new(candidate.initial_x, candidate.initial_z))
204            .collect(),
205    }
206}
207
208/// Kind-specific placement parameters.
209#[derive(Debug, Clone)]
210pub enum PlacementKind {
211    /// Vanilla's `RandomSpreadStructurePlacement`.
212    RandomSpread {
213        /// Chunk spacing between grid-cell centers.
214        spacing: i32,
215        /// Minimum chunk separation within a cell.
216        separation: i32,
217        /// Offset computation within the cell.
218        spread_type: SpreadType,
219    },
220    /// Vanilla's `ConcentricRingsStructurePlacement` (strongholds).
221    ConcentricRings {
222        /// Base distance between rings (chunks).
223        distance: i32,
224        /// Positions per ring.
225        spread: i32,
226        /// Total structure positions.
227        count: i32,
228        /// Preferred snap biomes.
229        preferred_biomes: Vec<Identifier>,
230    },
231}
232
233/// Structure placement configuration.
234#[derive(Debug, Clone)]
235pub struct StructurePlacement {
236    /// Unique seed modifier.
237    pub salt: i32,
238    /// Probability of generating on a placement-chunk. 1.0 = always.
239    pub frequency: f32,
240    /// Frequency-reduction method.
241    pub frequency_reduction_method: FrequencyReductionMethod,
242    /// Optional exclusion zone against another structure set.
243    pub exclusion_zone: Option<ExclusionZone>,
244    /// Block offset from the placement chunk used by `/locate`.
245    pub locate_offset: IVec3,
246    /// Kind-specific parameters.
247    pub kind: PlacementKind,
248}
249
250impl StructurePlacement {
251    /// Locate result block position for a valid placement chunk.
252    #[must_use]
253    pub const fn locate_pos(&self, chunk_pos: ChunkPos) -> BlockPos {
254        BlockPos::new(
255            chunk_pos.0.x * 16 + self.locate_offset.x,
256            self.locate_offset.y,
257            chunk_pos.0.y * 16 + self.locate_offset.z,
258        )
259    }
260
261    /// Valid placement chunk + frequency check. For `ConcentricRings`,
262    /// `ring_positions` must be pre-computed.
263    #[must_use]
264    pub fn is_structure_chunk(
265        &self,
266        seed: i64,
267        source_x: i32,
268        source_z: i32,
269        ring_positions: Option<&[ChunkPos]>,
270    ) -> bool {
271        if !self.is_placement_chunk(seed, source_x, source_z, ring_positions) {
272            return false;
273        }
274        if self.frequency < 1.0
275            && !self.frequency_reduction_method.should_generate(
276                seed,
277                self.salt,
278                source_x,
279                source_z,
280                self.frequency,
281            )
282        {
283            return false;
284        }
285        true
286    }
287
288    fn is_placement_chunk(
289        &self,
290        seed: i64,
291        source_x: i32,
292        source_z: i32,
293        ring_positions: Option<&[ChunkPos]>,
294    ) -> bool {
295        match &self.kind {
296            PlacementKind::RandomSpread {
297                spacing,
298                separation,
299                spread_type,
300            } => {
301                let potential = Self::get_potential_structure_chunk(
302                    seed,
303                    self.salt,
304                    source_x,
305                    source_z,
306                    *spacing,
307                    *separation,
308                    *spread_type,
309                );
310                potential.0.x == source_x && potential.0.y == source_z
311            }
312            PlacementKind::ConcentricRings { .. } => ring_positions
313                .is_some_and(|positions| positions.contains(&ChunkPos::new(source_x, source_z))),
314        }
315    }
316
317    /// Deterministic structure chunk for the grid cell containing `(source_x, source_z)`.
318    #[must_use]
319    pub fn get_potential_structure_chunk(
320        seed: i64,
321        salt: i32,
322        source_x: i32,
323        source_z: i32,
324        spacing: i32,
325        separation: i32,
326        spread_type: SpreadType,
327    ) -> ChunkPos {
328        let grid_x = source_x.div_euclid(spacing);
329        let grid_z = source_z.div_euclid(spacing);
330
331        let mut rng = LegacyRandom::from_seed(0);
332        rng.set_large_feature_with_salt(seed, grid_x, grid_z, salt);
333
334        let limit = spacing - separation;
335        let spread_x = spread_type.evaluate(&mut rng, limit);
336        let spread_z = spread_type.evaluate(&mut rng, limit);
337        ChunkPos::new(grid_x * spacing + spread_x, grid_z * spacing + spread_z)
338    }
339}
340
341/// A weighted entry in a structure set.
342#[derive(Debug, Clone)]
343pub struct StructureSelectionEntry {
344    /// Structure id (e.g., `minecraft:village_plains`).
345    pub structure: Identifier,
346    /// Weight.
347    pub weight: i32,
348}
349
350/// Vanilla's `StructureSet`: weighted structures + one placement.
351#[derive(Debug, Clone)]
352pub struct StructureSet {
353    /// Weighted list of structures.
354    pub structures: Vec<StructureSelectionEntry>,
355    /// Placement strategy.
356    pub placement: StructurePlacement,
357}
358
359use steel_registry::structure_set::{
360    FrequencyMethodData, PlacementData, SpreadTypeData, StructureSetData,
361};
362
363impl From<SpreadTypeData> for SpreadType {
364    fn from(data: SpreadTypeData) -> Self {
365        match data {
366            SpreadTypeData::Linear => Self::Linear,
367            SpreadTypeData::Triangular => Self::Triangular,
368        }
369    }
370}
371
372impl From<FrequencyMethodData> for FrequencyReductionMethod {
373    fn from(data: FrequencyMethodData) -> Self {
374        match data {
375            FrequencyMethodData::Default => Self::Default,
376            FrequencyMethodData::LegacyType1 => Self::LegacyType1,
377            FrequencyMethodData::LegacyType2 => Self::LegacyType2,
378            FrequencyMethodData::LegacyType3 => Self::LegacyType3,
379        }
380    }
381}
382
383fn convert_structure_set(data: StructureSetData) -> (Identifier, StructureSet) {
384    let structures = data
385        .structures
386        .into_iter()
387        .map(|e| StructureSelectionEntry {
388            structure: e.structure,
389            weight: e.weight,
390        })
391        .collect();
392
393    let placement = match data.placement {
394        PlacementData::RandomSpread {
395            spacing,
396            separation,
397            spread_type,
398            salt,
399            frequency,
400            frequency_reduction_method,
401            exclusion_zone,
402            locate_offset,
403        } => StructurePlacement {
404            salt,
405            frequency,
406            frequency_reduction_method: frequency_reduction_method.into(),
407            exclusion_zone: exclusion_zone.map(|ez| ExclusionZone {
408                other_set: ez.other_set,
409                chunk_count: ez.chunk_count,
410            }),
411            locate_offset,
412            kind: PlacementKind::RandomSpread {
413                spacing,
414                separation,
415                spread_type: spread_type.into(),
416            },
417        },
418        PlacementData::ConcentricRings {
419            distance,
420            spread,
421            count,
422            preferred_biomes,
423            salt,
424            frequency,
425            frequency_reduction_method,
426            locate_offset,
427        } => StructurePlacement {
428            salt,
429            frequency,
430            frequency_reduction_method: frequency_reduction_method.into(),
431            exclusion_zone: None,
432            locate_offset,
433            kind: PlacementKind::ConcentricRings {
434                distance,
435                spread,
436                count,
437                preferred_biomes,
438            },
439        },
440    };
441
442    (
443        data.key,
444        StructureSet {
445            structures,
446            placement,
447        },
448    )
449}
450
451/// Loads all vanilla structure sets from the generated registry data.
452#[must_use]
453pub fn load_vanilla_structure_sets() -> Vec<(Identifier, StructureSet)> {
454    use steel_registry::vanilla_structure_sets;
455    vanilla_structure_sets::vanilla_structure_sets()
456        .into_iter()
457        .map(convert_structure_set)
458        .collect()
459}
460
461#[cfg(test)]
462mod tests {
463    use steel_utils::random::Random;
464
465    use super::*;
466
467    #[test]
468    fn test_spread_type_linear() {
469        let mut rng = LegacyRandom::from_seed(42);
470        let result = SpreadType::Linear.evaluate(&mut rng, 26);
471        // Just verify it's in range
472        assert!((0..26).contains(&result));
473    }
474
475    #[test]
476    fn test_spread_type_triangular() {
477        let mut rng = LegacyRandom::from_seed(42);
478        let result = SpreadType::Triangular.evaluate(&mut rng, 26);
479        assert!((0..26).contains(&result));
480    }
481
482    #[test]
483    fn test_village_placement_seed_0() {
484        // Villages: spacing=34, separation=8, salt=10387312, LINEAR
485        let placement = StructurePlacement {
486            salt: 10_387_312,
487            frequency: 1.0,
488            frequency_reduction_method: FrequencyReductionMethod::Default,
489            exclusion_zone: None,
490            locate_offset: IVec3::ZERO,
491            kind: PlacementKind::RandomSpread {
492                spacing: 34,
493                separation: 8,
494                spread_type: SpreadType::Linear,
495            },
496        };
497
498        // For seed=0, chunk (0,0): grid cell is (0,0)
499        // setLargeFeatureWithSalt(0, 0, 0, 10387312)
500        // result = 0 + 0 + 0 + 10387312 = 10387312
501        let potential = StructurePlacement::get_potential_structure_chunk(
502            0,
503            10_387_312,
504            0,
505            0,
506            34,
507            8,
508            SpreadType::Linear,
509        );
510
511        // Verify the potential chunk by computing manually
512        let mut rng = LegacyRandom::from_seed(0);
513        rng.set_large_feature_with_salt(0, 0, 0, 10_387_312);
514        let spread_x = rng.next_i32_bounded(26); // 34 - 8
515        let spread_z = rng.next_i32_bounded(26);
516        assert_eq!(potential, ChunkPos::new(spread_x, spread_z));
517
518        // The structure chunk for (0,0) should only match if we query the
519        // exact potential position
520        assert!(placement.is_structure_chunk(0, potential.0.x, potential.0.y, None));
521        // Some other chunk in the same grid cell should NOT match (unless it
522        // happens to be the potential chunk)
523        if potential != ChunkPos::new(0, 0) {
524            assert!(!placement.is_structure_chunk(0, 0, 0, None));
525        }
526    }
527
528    #[test]
529    fn test_negative_chunk_coords() {
530        // Verify grid cell computation works for negative coordinates
531        let potential_pos = StructurePlacement::get_potential_structure_chunk(
532            0,
533            10_387_312,
534            -1,
535            -1,
536            34,
537            8,
538            SpreadType::Linear,
539        );
540
541        // Grid cell for -1 with spacing 34: div_euclid(-1, 34) = -1
542        // So the grid cell starts at -1 * 34 = -34
543        assert!(potential_pos.0.x >= -34 && potential_pos.0.x < -34 + 26);
544        assert!(potential_pos.0.y >= -34 && potential_pos.0.y < -34 + 26);
545    }
546
547    #[test]
548    fn test_frequency_reduction_skips_at_1() {
549        let placement = StructurePlacement {
550            salt: 12345,
551            frequency: 1.0,
552            frequency_reduction_method: FrequencyReductionMethod::Default,
553            exclusion_zone: None,
554            locate_offset: IVec3::ZERO,
555            kind: PlacementKind::RandomSpread {
556                spacing: 32,
557                separation: 8,
558                spread_type: SpreadType::Linear,
559            },
560        };
561
562        // Find the potential chunk
563        let potential = StructurePlacement::get_potential_structure_chunk(
564            0,
565            12345,
566            0,
567            0,
568            32,
569            8,
570            SpreadType::Linear,
571        );
572        // With frequency=1.0, should always pass
573        assert!(placement.is_structure_chunk(0, potential.0.x, potential.0.y, None));
574    }
575
576    #[test]
577    fn test_load_vanilla_structure_sets() {
578        let sets = load_vanilla_structure_sets();
579        assert_eq!(sets.len(), 21);
580
581        // Verify villages loaded correctly from datapack
582        let (key, villages) = sets
583            .iter()
584            .find(|(k, _)| &*k.path == "villages")
585            .expect("villages structure set must be present");
586        assert_eq!(&*key.namespace, "minecraft");
587        assert_eq!(villages.structures.len(), 5);
588        if let PlacementKind::RandomSpread {
589            spacing,
590            separation,
591            spread_type: _,
592        } = &villages.placement.kind
593        {
594            assert_eq!(*spacing, 34);
595            assert_eq!(*separation, 8);
596        } else {
597            panic!("Expected RandomSpread for villages");
598        }
599        assert_eq!(villages.placement.salt, 10_387_312);
600
601        // Buried treasure is the vanilla placement that uses a non-zero locate offset.
602        let (_, buried_treasures) = sets
603            .iter()
604            .find(|(k, _)| &*k.path == "buried_treasures")
605            .expect("buried_treasures structure set must be present");
606        assert_eq!(
607            buried_treasures.placement.locate_offset,
608            IVec3::new(9, 0, 9)
609        );
610
611        // Verify strongholds use ConcentricRings
612        let (_, strongholds) = sets
613            .iter()
614            .find(|(k, _)| &*k.path == "strongholds")
615            .expect("strongholds structure set must be present");
616        assert!(matches!(
617            strongholds.placement.kind,
618            PlacementKind::ConcentricRings { .. }
619        ));
620
621        // Verify pillager outposts have exclusion zone
622        let (_, outposts) = sets
623            .iter()
624            .find(|(k, _)| &*k.path == "pillager_outposts")
625            .expect("pillager_outposts structure set must be present");
626        let ez = outposts
627            .placement
628            .exclusion_zone
629            .as_ref()
630            .expect("pillager_outposts has an exclusion zone");
631        assert_eq!(&*ez.other_set.path, "villages");
632        assert_eq!(ez.chunk_count, 10);
633    }
634
635    #[test]
636    fn test_concentric_rings_with_positions() {
637        let placement = StructurePlacement {
638            salt: 0,
639            frequency: 1.0,
640            frequency_reduction_method: FrequencyReductionMethod::Default,
641            exclusion_zone: None,
642            locate_offset: IVec3::ZERO,
643            kind: PlacementKind::ConcentricRings {
644                distance: 32,
645                spread: 3,
646                count: 128,
647                preferred_biomes: vec![],
648            },
649        };
650
651        let positions = vec![ChunkPos::new(10, 20), ChunkPos::new(-5, 15)];
652
653        assert!(placement.is_structure_chunk(0, 10, 20, Some(&positions)));
654        assert!(placement.is_structure_chunk(0, -5, 15, Some(&positions)));
655        assert!(!placement.is_structure_chunk(0, 0, 0, Some(&positions)));
656
657        // Without positions, always false
658        assert!(!placement.is_structure_chunk(0, 10, 20, None));
659    }
660
661    #[test]
662    fn test_generate_ring_positions_strongholds() {
663        // Strongholds: distance=32, spread=3, count=128
664        let thread_pool = rayon::ThreadPoolBuilder::default()
665            .build()
666            .expect("Couldn't create a new thread pool.");
667        let positions = generate_ring_positions::<
668            fn(i32, i32, &mut LegacyRandom) -> Option<(i32, i32)>,
669        >(0, 32, 3, 128, None, &thread_pool);
670        assert_eq!(positions.len(), 128);
671
672        // First ring should be roughly 4*32 = 128 chunks from origin
673        // (with some jitter)
674        let first = positions[0];
675        let dist = (f64::from(first.0.x).powi(2) + f64::from(first.0.y).powi(2)).sqrt();
676        assert!(
677            dist > 80.0 && dist < 200.0,
678            "First stronghold at distance {dist}, expected ~128"
679        );
680
681        // All positions should be unique
682        let mut unique = positions.clone();
683        unique.sort_by_key(|p| (p.0.x, p.0.y));
684        unique.dedup_by_key(|p| (p.0.x, p.0.y));
685        assert_eq!(
686            unique.len(),
687            positions.len(),
688            "Ring positions should be unique"
689        );
690
691        // Deterministic: same seed produces same positions
692        let positions2 = generate_ring_positions::<
693            fn(i32, i32, &mut LegacyRandom) -> Option<(i32, i32)>,
694        >(0, 32, 3, 128, None, &thread_pool);
695        assert_eq!(positions, positions2);
696    }
697
698    #[test]
699    fn test_generate_ring_positions_zero_count() {
700        let thread_pool = rayon::ThreadPoolBuilder::default()
701            .build()
702            .expect("Couldn't create a new thread pool.");
703        let positions = generate_ring_positions::<
704            fn(i32, i32, &mut LegacyRandom) -> Option<(i32, i32)>,
705        >(0, 32, 3, 0, None, &thread_pool);
706        assert_eq!(positions.len(), 0);
707    }
708
709    #[test]
710    fn test_java_round() {
711        assert_eq!(java_round(0.5), 1);
712        assert_eq!(java_round(-0.5), 0);
713        assert_eq!(java_round(1.5), 2);
714        assert_eq!(java_round(-1.5), -1);
715        assert_eq!(java_round(2.3), 2);
716        assert_eq!(java_round(-2.3), -2);
717    }
718}