Skip to main content

steel_worldgen/structure/
ruined_portal.rs

1//! Ruined portal. Mirrors vanilla's `RuinedPortalStructure.findGenerationPoint`
2//! RNG consumption and persists the selected template-backed piece.
3
4use std::sync::LazyLock;
5
6use glam::IVec3;
7use steel_registry::biome::{BiomeRef, TemperatureModifier};
8use steel_registry::structure::{
9    LiquidSettingsData, RuinedPortalPlacementData, RuinedPortalSetupData, StructureConfigData,
10    StructureData,
11};
12use steel_utils::random::legacy_random::LegacyRandom;
13use steel_utils::random::{Random, RandomSource};
14use steel_utils::{BoundingBox, Direction, Identifier, Rotation};
15use steel_worldgen::noise::PerlinSimplexNoise;
16
17use crate::structure::{
18    GenerationStub, RuinedPortalProperties, Structure, StructureBlockIgnore,
19    StructureGenerationContext, StructureMirror, StructurePiece, StructurePiecePayload,
20    TemplateMarkerHandling, TemplatePieceData, TemplatePlacementAdjustment, TemplatePlacementClip,
21    TemplatePostProcess, TemplateProcessorList,
22};
23
24static TEMPERATURE_NOISE: LazyLock<PerlinSimplexNoise> = LazyLock::new(|| {
25    let mut random = RandomSource::Legacy(LegacyRandom::from_seed(1234));
26    PerlinSimplexNoise::new(&mut random, &[0])
27});
28
29static FROZEN_TEMPERATURE_NOISE: LazyLock<PerlinSimplexNoise> = LazyLock::new(|| {
30    let mut random = RandomSource::Legacy(LegacyRandom::from_seed(3456));
31    PerlinSimplexNoise::new(&mut random, &[-2, -1, 0])
32});
33
34static BIOME_INFO_NOISE: LazyLock<PerlinSimplexNoise> = LazyLock::new(|| {
35    let mut random = RandomSource::Legacy(LegacyRandom::from_seed(2345));
36    PerlinSimplexNoise::new(&mut random, &[0])
37});
38
39const PORTAL_TEMPLATES: [&str; 10] = [
40    "ruined_portal/portal_1",
41    "ruined_portal/portal_2",
42    "ruined_portal/portal_3",
43    "ruined_portal/portal_4",
44    "ruined_portal/portal_5",
45    "ruined_portal/portal_6",
46    "ruined_portal/portal_7",
47    "ruined_portal/portal_8",
48    "ruined_portal/portal_9",
49    "ruined_portal/portal_10",
50];
51
52const GIANT_PORTAL_TEMPLATES: [&str; 3] = [
53    "ruined_portal/giant_portal_1",
54    "ruined_portal/giant_portal_2",
55    "ruined_portal/giant_portal_3",
56];
57
58/// Terrain query operations needed by the ruined portal generation.
59pub enum TerrainQuery {
60    /// Get surface height at (x, z). Returns first solid Y from top.
61    SurfaceHeight {
62        /// Block X.
63        x: i32,
64        /// Block Z.
65        z: i32,
66        /// `true` for `OCEAN_FLOOR_WG`, `false` for `WORLD_SURFACE_WG`.
67        ocean_floor: bool,
68    },
69    /// Check if block at (x, y, z) is opaque for the selected heightmap.
70    IsOpaque {
71        /// Block X.
72        x: i32,
73        /// Block Y.
74        y: i32,
75        /// Block Z.
76        z: i32,
77        /// `true` for `OCEAN_FLOOR_WG`, `false` for `WORLD_SURFACE_WG`.
78        ocean_floor: bool,
79    },
80}
81
82/// Result of a terrain query.
83pub enum TerrainResult {
84    /// Surface height result.
85    Height(i32),
86    /// Block opacity result.
87    Opaque(bool),
88}
89
90/// Result of ruined portal generation point computation.
91pub struct PortalResult {
92    /// Biome check position `(block_x, block_y, block_z)`.
93    pub biome_check_pos: (i32, i32, i32),
94    /// Bounding box of the placed portal piece.
95    pub bounding_box: BoundingBox,
96    /// Selected template id.
97    pub template_id: Identifier,
98    /// Template size in blocks.
99    pub template_size: IVec3,
100    /// Template rotation.
101    pub rotation: Rotation,
102    /// Template mirror.
103    pub mirror: StructureMirror,
104    /// Template rotation pivot in local coordinates.
105    pub rotation_pivot: IVec3,
106    /// Vertical placement selected by the setup.
107    pub vertical_placement: RuinedPortalPlacementData,
108    /// Ruined portal properties before the cold biome adjustment.
109    pub properties: RuinedPortalProperties,
110    /// Whether vanilla evaluates coldness for this setup.
111    pub can_be_cold: bool,
112}
113
114/// Picks a ruined portal setup, size, rotation, mirror, and placement
115/// position near the target chunk, consuming RNG in vanilla's exact order.
116#[expect(
117    clippy::too_many_lines,
118    reason = "inlines vanilla's setup → size → rotation → mirror → placement pipeline"
119)]
120pub fn find_generation_point(
121    rng: &mut LegacyRandom,
122    chunk_x: i32,
123    chunk_z: i32,
124    setups: &[RuinedPortalSetupData],
125    min_y: i32,
126    templates: &mut (impl FnMut(&Identifier) -> Option<[i32; 3]> + ?Sized),
127    terrain: &mut (impl FnMut(TerrainQuery) -> TerrainResult + ?Sized),
128) -> Option<PortalResult> {
129    if setups.is_empty() {
130        return None;
131    }
132
133    let base_x = chunk_x * 16;
134    let base_z = chunk_z * 16;
135
136    let setup = if setups.len() > 1 {
137        let total: f32 = setups.iter().map(|s| s.weight).sum();
138        let mut pick = rng.next_f32();
139        let mut chosen_idx = setups.len() - 1;
140        for (i, s) in setups.iter().enumerate() {
141            pick -= s.weight / total;
142            if pick < 0.0 {
143                chosen_idx = i;
144                break;
145            }
146        }
147        &setups[chosen_idx]
148    } else {
149        &setups[0]
150    };
151
152    let air_pocket = if setup.air_pocket_probability <= 0.0 {
153        false
154    } else if setup.air_pocket_probability >= 1.0 {
155        true
156    } else {
157        rng.next_f32() < setup.air_pocket_probability
158    };
159
160    let template_id = if rng.next_f32() < 0.05 {
161        let index = rng.next_i32_bounded(GIANT_PORTAL_TEMPLATES.len() as i32) as usize;
162        Identifier::vanilla_static(GIANT_PORTAL_TEMPLATES[index])
163    } else {
164        let index = rng.next_i32_bounded(PORTAL_TEMPLATES.len() as i32) as usize;
165        Identifier::vanilla_static(PORTAL_TEMPLATES[index])
166    };
167    let template_size_arr = templates(&template_id)?;
168    let [sx, sy, sz] = template_size_arr;
169
170    let rotation = Rotation::get_random(rng);
171    let mirror = if rng.next_f32() < 0.5 {
172        StructureMirror::None
173    } else {
174        StructureMirror::FrontBack
175    };
176    let mirror_front_back = mirror == StructureMirror::FrontBack;
177    let pivot_x = sx / 2;
178    let pivot_z = sz / 2;
179    let bb = rotation.get_bounding_box_full(
180        IVec3::new(base_x, 0, base_z),
181        IVec3::new(sx, sy, sz),
182        IVec3::new(pivot_x, 0, pivot_z),
183        mirror_front_back,
184    );
185
186    let bb_center_x = bb.min_x() + (bb.max_x() - bb.min_x() + 1) / 2;
187    let bb_center_z = bb.min_z() + (bb.max_z() - bb.min_z() + 1) / 2;
188    let ocean_floor = matches!(setup.placement, RuinedPortalPlacementData::OnOceanFloor);
189    let surface_y = match terrain(TerrainQuery::SurfaceHeight {
190        x: bb_center_x,
191        z: bb_center_z,
192        ocean_floor,
193    }) {
194        TerrainResult::Height(h) => h,
195        TerrainResult::Opaque(_) => unreachable!(),
196    } - 1;
197
198    let min_y_threshold = min_y + 15;
199    let new_y = match setup.placement {
200        RuinedPortalPlacementData::OnLandSurface | RuinedPortalPlacementData::OnOceanFloor => {
201            surface_y
202        }
203        RuinedPortalPlacementData::Underground => {
204            let max_y = surface_y - sy;
205            if min_y_threshold < max_y {
206                rng.next_i32_between(min_y_threshold, max_y)
207            } else {
208                max_y
209            }
210        }
211        RuinedPortalPlacementData::InMountain => {
212            let max_y = surface_y - sy;
213            if 70 < max_y {
214                rng.next_i32_between(70, max_y)
215            } else {
216                max_y
217            }
218        }
219        RuinedPortalPlacementData::PartlyBuried => surface_y - sy + rng.next_i32_between(2, 8),
220        RuinedPortalPlacementData::InNether => {
221            if air_pocket {
222                rng.next_i32_between(32, 100)
223            } else if rng.next_f32() < 0.5 {
224                rng.next_i32_between(27, 29)
225            } else {
226                rng.next_i32_between(29, 100)
227            }
228        }
229    };
230
231    let corners = [
232        (bb.min_x(), bb.min_z()),
233        (bb.max_x(), bb.min_z()),
234        (bb.min_x(), bb.max_z()),
235        (bb.max_x(), bb.max_z()),
236    ];
237    let mut projected_y = new_y;
238    'scan: while projected_y > min_y_threshold {
239        let mut solid_count = 0;
240        for &(cx, cz) in &corners {
241            if matches!(
242                terrain(TerrainQuery::IsOpaque {
243                    x: cx,
244                    y: projected_y,
245                    z: cz,
246                    ocean_floor,
247                }),
248                TerrainResult::Opaque(true)
249            ) {
250                solid_count += 1;
251                if solid_count == 3 {
252                    break 'scan;
253                }
254            }
255        }
256        projected_y -= 1;
257    }
258
259    Some(PortalResult {
260        biome_check_pos: (base_x, projected_y, base_z),
261        bounding_box: rotation.get_bounding_box_full(
262            IVec3::new(base_x, projected_y, base_z),
263            IVec3::new(sx, sy, sz),
264            IVec3::new(pivot_x, 0, pivot_z),
265            mirror_front_back,
266        ),
267        template_id,
268        template_size: IVec3::new(sx, sy, sz),
269        rotation,
270        mirror,
271        rotation_pivot: IVec3::new(pivot_x, 0, pivot_z),
272        vertical_placement: setup.placement,
273        properties: RuinedPortalProperties {
274            cold: false,
275            mossiness: setup.mossiness,
276            air_pocket,
277            overgrown: setup.overgrown,
278            vines: setup.vines,
279            replace_with_blackstone: setup.replace_with_blackstone,
280        },
281        can_be_cold: setup.can_be_cold,
282    })
283}
284
285#[expect(
286    clippy::too_many_arguments,
287    reason = "ruined portal piece construction mirrors vanilla template-piece fields"
288)]
289fn make_ruined_portal_piece(
290    template_id: Identifier,
291    position: IVec3,
292    rotation: Rotation,
293    mirror: StructureMirror,
294    rotation_pivot: IVec3,
295    size: IVec3,
296    vertical_placement: RuinedPortalPlacementData,
297    properties: RuinedPortalProperties,
298) -> StructurePiece {
299    let mirror_front_back = mirror == StructureMirror::FrontBack;
300    let bounding_box =
301        rotation.get_bounding_box_full(position, size, rotation_pivot, mirror_front_back);
302    StructurePiece {
303        piece_type: Identifier::new_static("minecraft", "rupo"),
304        bounding_box,
305        gen_depth: 0,
306        orientation: Some(Direction::North),
307        payload: StructurePiecePayload::Template(TemplatePieceData {
308            template_id,
309            template_position: position,
310            rotation,
311            mirror,
312            rotation_pivot,
313            block_ignore: if properties.air_pocket {
314                StructureBlockIgnore::StructureBlock
315            } else {
316                StructureBlockIgnore::StructureAndAir
317            },
318            late_block_ignore: StructureBlockIgnore::None,
319            processors: TemplateProcessorList::RuinedPortal {
320                vertical_placement,
321                properties,
322            },
323            liquid_settings: LiquidSettingsData::ApplyWaterlogging,
324            marker_handling: TemplateMarkerHandling::Ignore,
325            placement_adjustment: TemplatePlacementAdjustment::None,
326            placement_clip:
327                TemplatePlacementClip::CenterChunkContainsTemplateCenterExpandedToTemplate,
328            post_process: TemplatePostProcess::RuinedPortal,
329        }),
330        ground_level_delta: 0,
331        junctions: Vec::new(),
332        projection: None,
333    }
334}
335
336fn cold_enough_to_snow(biome: BiomeRef, sea_level: i32, pos: (i32, i32, i32)) -> bool {
337    biome_temperature(biome, sea_level, pos) < 0.15
338}
339
340fn biome_temperature(biome: BiomeRef, sea_level: i32, pos: (i32, i32, i32)) -> f32 {
341    let modified_temperature = match biome.temperature_modifier {
342        TemperatureModifier::None => biome.temperature,
343        TemperatureModifier::Frozen => {
344            let large = f64::from(
345                FROZEN_TEMPERATURE_NOISE
346                    .get_value(f64::from(pos.0) * 0.05, f64::from(pos.2) * 0.05)
347                    * 7.0,
348            );
349            let edge = f64::from(
350                BIOME_INFO_NOISE.get_value(f64::from(pos.0) * 0.2, f64::from(pos.2) * 0.2),
351            );
352            if large + edge < 0.3 {
353                let small = f64::from(
354                    BIOME_INFO_NOISE.get_value(f64::from(pos.0) * 0.09, f64::from(pos.2) * 0.09),
355                );
356                if small < 0.8 { 0.2 } else { biome.temperature }
357            } else {
358                biome.temperature
359            }
360        }
361    };
362
363    let snow_level = sea_level + 17;
364    if pos.1 <= snow_level {
365        return modified_temperature;
366    }
367
368    let value = TEMPERATURE_NOISE
369        .get_value(f64::from(pos.0 as f32 / 8.0), f64::from(pos.2 as f32 / 8.0))
370        * 8.0;
371    modified_temperature - (value + pos.1 as f32 - snow_level as f32) * 0.05 / 40.0
372}
373
374/// Registered under `"minecraft:ruined_portal"` and its biome variants
375/// (desert / jungle / mountain / ocean / swamp / nether). The terrain closure
376/// creates a fresh aquifer + column cache per query since piece gen can probe
377/// outside this chunk.
378pub struct RuinedPortalStructure;
379
380impl Structure for RuinedPortalStructure {
381    fn find_generation_point(
382        &self,
383        ctx: &mut dyn StructureGenerationContext,
384        structure: &StructureData,
385        rng: &mut LegacyRandom,
386    ) -> Option<GenerationStub> {
387        let mut template_choices = Vec::with_capacity(
388            PORTAL_TEMPLATES
389                .len()
390                .saturating_add(GIANT_PORTAL_TEMPLATES.len()),
391        );
392        for path in PORTAL_TEMPLATES.iter().chain(GIANT_PORTAL_TEMPLATES.iter()) {
393            let id = Identifier::vanilla_static(path);
394            let size = ctx.templates().get(&id)?.size;
395            template_choices.push((id, size));
396        }
397
398        let mut terrain = |q: TerrainQuery| -> TerrainResult {
399            match q {
400                TerrainQuery::SurfaceHeight { x, z, ocean_floor } => {
401                    TerrainResult::Height(ctx.terrain_surface_height(x, z, ocean_floor))
402                }
403                TerrainQuery::IsOpaque {
404                    x,
405                    y,
406                    z,
407                    ocean_floor,
408                } => TerrainResult::Opaque(ctx.terrain_is_opaque(x, y, z, ocean_floor)),
409            }
410        };
411
412        let StructureConfigData::RuinedPortal { setups } = &structure.config else {
413            return None;
414        };
415        if setups.is_empty() {
416            return None;
417        }
418
419        let result = find_generation_point(
420            rng,
421            ctx.chunk_x(),
422            ctx.chunk_z(),
423            setups,
424            ctx.min_y(),
425            &mut |id| {
426                template_choices
427                    .iter()
428                    .find(|(template_id, _)| template_id == id)
429                    .map(|(_, size)| *size)
430            },
431            &mut terrain,
432        )?;
433
434        let (bx, by, bz) = result.biome_check_pos;
435        let biome = ctx.biome_at(bx, by, bz);
436        if !structure.allowed_biomes.contains(&biome.key) {
437            return None;
438        }
439
440        let mut properties = result.properties;
441        if result.can_be_cold {
442            properties.cold = cold_enough_to_snow(biome, ctx.sea_level(), result.biome_check_pos);
443        }
444
445        Some(GenerationStub {
446            position: result.biome_check_pos,
447            pieces: vec![make_ruined_portal_piece(
448                result.template_id,
449                IVec3::new(
450                    result.biome_check_pos.0,
451                    result.biome_check_pos.1,
452                    result.biome_check_pos.2,
453                ),
454                result.rotation,
455                result.mirror,
456                result.rotation_pivot,
457                result.template_size,
458                result.vertical_placement,
459                properties,
460            )],
461        })
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn ruined_portal_piece_uses_template_payload_with_processors_and_postprocess() {
471        let position = IVec3::new(64, 72, -32);
472        let size = IVec3::new(11, 17, 16);
473        let properties = RuinedPortalProperties {
474            cold: true,
475            mossiness: 0.8,
476            air_pocket: false,
477            overgrown: true,
478            vines: true,
479            replace_with_blackstone: false,
480        };
481
482        let piece = make_ruined_portal_piece(
483            Identifier::vanilla_static("ruined_portal/giant_portal_1"),
484            position,
485            Rotation::Clockwise90,
486            StructureMirror::FrontBack,
487            IVec3::new(size.x / 2, 0, size.z / 2),
488            size,
489            RuinedPortalPlacementData::OnOceanFloor,
490            properties,
491        );
492
493        assert_eq!(
494            piece.piece_type,
495            Identifier::new_static("minecraft", "rupo")
496        );
497        assert_eq!(piece.gen_depth, 0);
498        assert_eq!(piece.orientation, Some(Direction::North));
499        assert_eq!(
500            piece.bounding_box,
501            Rotation::Clockwise90.get_bounding_box_full(
502                position,
503                size,
504                IVec3::new(size.x / 2, 0, size.z / 2),
505                true,
506            ),
507        );
508
509        let StructurePiecePayload::Template(data) = piece.payload else {
510            panic!("ruined portal piece should be template-backed");
511        };
512        assert_eq!(
513            data.template_id,
514            Identifier::vanilla_static("ruined_portal/giant_portal_1")
515        );
516        assert_eq!(data.template_position, position);
517        assert_eq!(data.rotation, Rotation::Clockwise90);
518        assert_eq!(data.mirror, StructureMirror::FrontBack);
519        assert_eq!(data.rotation_pivot, IVec3::new(size.x / 2, 0, size.z / 2));
520        assert_eq!(data.block_ignore, StructureBlockIgnore::StructureAndAir);
521        assert_eq!(data.late_block_ignore, StructureBlockIgnore::None);
522        assert_eq!(
523            data.processors,
524            TemplateProcessorList::RuinedPortal {
525                vertical_placement: RuinedPortalPlacementData::OnOceanFloor,
526                properties,
527            }
528        );
529        assert_eq!(data.liquid_settings, LiquidSettingsData::ApplyWaterlogging);
530        assert_eq!(data.marker_handling, TemplateMarkerHandling::Ignore);
531        assert_eq!(data.placement_adjustment, TemplatePlacementAdjustment::None);
532        assert_eq!(
533            data.placement_clip,
534            TemplatePlacementClip::CenterChunkContainsTemplateCenterExpandedToTemplate
535        );
536        assert_eq!(data.post_process, TemplatePostProcess::RuinedPortal);
537    }
538}