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