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/// Matches vanilla's `RuinedPortalStructure.findGenerationPoint`.
115#[expect(
116    clippy::too_many_lines,
117    reason = "inlines vanilla's setup → size → rotation → mirror → placement pipeline"
118)]
119pub fn find_generation_point(
120    rng: &mut LegacyRandom,
121    chunk_x: i32,
122    chunk_z: i32,
123    setups: &[RuinedPortalSetupData],
124    min_y: i32,
125    templates: &mut dyn FnMut(&Identifier) -> Option<[i32; 3]>,
126    terrain: &mut dyn FnMut(TerrainQuery) -> TerrainResult,
127) -> Option<PortalResult> {
128    if setups.is_empty() {
129        return None;
130    }
131
132    let base_x = chunk_x * 16;
133    let base_z = chunk_z * 16;
134
135    let setup = if setups.len() > 1 {
136        let total: f32 = setups.iter().map(|s| s.weight).sum();
137        let mut pick = rng.next_f32();
138        let mut chosen_idx = setups.len() - 1;
139        for (i, s) in setups.iter().enumerate() {
140            pick -= s.weight / total;
141            if pick < 0.0 {
142                chosen_idx = i;
143                break;
144            }
145        }
146        &setups[chosen_idx]
147    } else {
148        &setups[0]
149    };
150
151    let air_pocket = if setup.air_pocket_probability <= 0.0 {
152        false
153    } else if setup.air_pocket_probability >= 1.0 {
154        true
155    } else {
156        rng.next_f32() < setup.air_pocket_probability
157    };
158
159    let template_id = if rng.next_f32() < 0.05 {
160        let index = rng.next_i32_bounded(GIANT_PORTAL_TEMPLATES.len() as i32) as usize;
161        Identifier::vanilla_static(GIANT_PORTAL_TEMPLATES[index])
162    } else {
163        let index = rng.next_i32_bounded(PORTAL_TEMPLATES.len() as i32) as usize;
164        Identifier::vanilla_static(PORTAL_TEMPLATES[index])
165    };
166    let template_size_arr = templates(&template_id)?;
167    let [sx, sy, sz] = template_size_arr;
168
169    let rotation = Rotation::get_random(rng);
170    let mirror = if rng.next_f32() < 0.5 {
171        StructureMirror::None
172    } else {
173        StructureMirror::FrontBack
174    };
175    let mirror_front_back = mirror == StructureMirror::FrontBack;
176    let pivot_x = sx / 2;
177    let pivot_z = sz / 2;
178    let bb = rotation.get_bounding_box_full(
179        IVec3::new(base_x, 0, base_z),
180        IVec3::new(sx, sy, sz),
181        IVec3::new(pivot_x, 0, pivot_z),
182        mirror_front_back,
183    );
184
185    let bb_center_x = bb.min_x() + (bb.max_x() - bb.min_x() + 1) / 2;
186    let bb_center_z = bb.min_z() + (bb.max_z() - bb.min_z() + 1) / 2;
187    let ocean_floor = matches!(setup.placement, RuinedPortalPlacementData::OnOceanFloor);
188    let surface_y = match terrain(TerrainQuery::SurfaceHeight {
189        x: bb_center_x,
190        z: bb_center_z,
191        ocean_floor,
192    }) {
193        TerrainResult::Height(h) => h,
194        TerrainResult::Opaque(_) => unreachable!(),
195    } - 1;
196
197    let min_y_threshold = min_y + 15;
198    let new_y = match setup.placement {
199        RuinedPortalPlacementData::OnLandSurface | RuinedPortalPlacementData::OnOceanFloor => {
200            surface_y
201        }
202        RuinedPortalPlacementData::Underground => {
203            let max_y = surface_y - sy;
204            if min_y_threshold < max_y {
205                rng.next_i32_between(min_y_threshold, max_y)
206            } else {
207                max_y
208            }
209        }
210        RuinedPortalPlacementData::InMountain => {
211            let max_y = surface_y - sy;
212            if 70 < max_y {
213                rng.next_i32_between(70, max_y)
214            } else {
215                max_y
216            }
217        }
218        RuinedPortalPlacementData::PartlyBuried => surface_y - sy + rng.next_i32_between(2, 8),
219        RuinedPortalPlacementData::InNether => {
220            if air_pocket {
221                rng.next_i32_between(32, 100)
222            } else if rng.next_f32() < 0.5 {
223                rng.next_i32_between(27, 29)
224            } else {
225                rng.next_i32_between(29, 100)
226            }
227        }
228    };
229
230    let corners = [
231        (bb.min_x(), bb.min_z()),
232        (bb.max_x(), bb.min_z()),
233        (bb.min_x(), bb.max_z()),
234        (bb.max_x(), bb.max_z()),
235    ];
236    let mut projected_y = new_y;
237    'scan: while projected_y > min_y_threshold {
238        let mut solid_count = 0;
239        for &(cx, cz) in &corners {
240            if matches!(
241                terrain(TerrainQuery::IsOpaque {
242                    x: cx,
243                    y: projected_y,
244                    z: cz,
245                    ocean_floor,
246                }),
247                TerrainResult::Opaque(true)
248            ) {
249                solid_count += 1;
250                if solid_count == 3 {
251                    break 'scan;
252                }
253            }
254        }
255        projected_y -= 1;
256    }
257
258    Some(PortalResult {
259        biome_check_pos: (base_x, projected_y, base_z),
260        bounding_box: rotation.get_bounding_box_full(
261            IVec3::new(base_x, projected_y, base_z),
262            IVec3::new(sx, sy, sz),
263            IVec3::new(pivot_x, 0, pivot_z),
264            mirror_front_back,
265        ),
266        template_id,
267        template_size: IVec3::new(sx, sy, sz),
268        rotation,
269        mirror,
270        rotation_pivot: IVec3::new(pivot_x, 0, pivot_z),
271        vertical_placement: setup.placement,
272        properties: RuinedPortalProperties {
273            cold: false,
274            mossiness: setup.mossiness,
275            air_pocket,
276            overgrown: setup.overgrown,
277            vines: setup.vines,
278            replace_with_blackstone: setup.replace_with_blackstone,
279        },
280        can_be_cold: setup.can_be_cold,
281    })
282}
283
284#[expect(
285    clippy::too_many_arguments,
286    reason = "ruined portal piece construction mirrors vanilla template-piece fields"
287)]
288fn make_ruined_portal_piece(
289    template_id: Identifier,
290    position: IVec3,
291    rotation: Rotation,
292    mirror: StructureMirror,
293    rotation_pivot: IVec3,
294    size: IVec3,
295    vertical_placement: RuinedPortalPlacementData,
296    properties: RuinedPortalProperties,
297) -> StructurePiece {
298    let mirror_front_back = mirror == StructureMirror::FrontBack;
299    let bounding_box =
300        rotation.get_bounding_box_full(position, size, rotation_pivot, mirror_front_back);
301    StructurePiece {
302        piece_type: Identifier::new_static("minecraft", "rupo"),
303        bounding_box,
304        gen_depth: 0,
305        orientation: Some(Direction::North),
306        payload: StructurePiecePayload::Template(TemplatePieceData {
307            template_id,
308            template_position: position,
309            rotation,
310            mirror,
311            rotation_pivot,
312            block_ignore: if properties.air_pocket {
313                StructureBlockIgnore::StructureBlock
314            } else {
315                StructureBlockIgnore::StructureAndAir
316            },
317            late_block_ignore: StructureBlockIgnore::None,
318            processors: TemplateProcessorList::RuinedPortal {
319                vertical_placement,
320                properties,
321            },
322            liquid_settings: LiquidSettingsData::ApplyWaterlogging,
323            marker_handling: TemplateMarkerHandling::Ignore,
324            placement_adjustment: TemplatePlacementAdjustment::None,
325            placement_clip:
326                TemplatePlacementClip::CenterChunkContainsTemplateCenterExpandedToTemplate,
327            post_process: TemplatePostProcess::RuinedPortal,
328        }),
329        ground_level_delta: 0,
330        junctions: Vec::new(),
331        projection: None,
332    }
333}
334
335fn cold_enough_to_snow(biome: BiomeRef, sea_level: i32, pos: (i32, i32, i32)) -> bool {
336    biome_temperature(biome, sea_level, pos) < 0.15
337}
338
339fn biome_temperature(biome: BiomeRef, sea_level: i32, pos: (i32, i32, i32)) -> f32 {
340    let modified_temperature = match biome.temperature_modifier {
341        TemperatureModifier::None => biome.temperature,
342        TemperatureModifier::Frozen => {
343            let large = FROZEN_TEMPERATURE_NOISE
344                .get_value(f64::from(pos.0) * 0.05, f64::from(pos.2) * 0.05)
345                * 7.0;
346            let edge = BIOME_INFO_NOISE.get_value(f64::from(pos.0) * 0.2, f64::from(pos.2) * 0.2);
347            if large + edge < 0.3 {
348                let small =
349                    BIOME_INFO_NOISE.get_value(f64::from(pos.0) * 0.09, f64::from(pos.2) * 0.09);
350                if small < 0.8 { 0.2 } else { biome.temperature }
351            } else {
352                biome.temperature
353            }
354        }
355    };
356
357    let snow_level = sea_level + 17;
358    if pos.1 <= snow_level {
359        return modified_temperature;
360    }
361
362    let value =
363        TEMPERATURE_NOISE.get_value(f64::from(pos.0) / 8.0, f64::from(pos.2) / 8.0) as f32 * 8.0;
364    modified_temperature - (value + pos.1 as f32 - snow_level as f32) * 0.05 / 40.0
365}
366
367/// Registered under `"minecraft:ruined_portal"` and its biome variants
368/// (desert / jungle / mountain / ocean / swamp / nether). The terrain closure
369/// creates a fresh aquifer + column cache per query since piece gen can probe
370/// outside this chunk.
371pub struct RuinedPortalStructure;
372
373impl Structure for RuinedPortalStructure {
374    fn find_generation_point(
375        &self,
376        ctx: &mut dyn StructureGenerationContext,
377        structure: &StructureData,
378        rng: &mut LegacyRandom,
379    ) -> Option<GenerationStub> {
380        let mut template_choices = Vec::with_capacity(
381            PORTAL_TEMPLATES
382                .len()
383                .saturating_add(GIANT_PORTAL_TEMPLATES.len()),
384        );
385        for path in PORTAL_TEMPLATES.iter().chain(GIANT_PORTAL_TEMPLATES.iter()) {
386            let id = Identifier::vanilla_static(path);
387            let size = ctx.templates().get(&id)?.size;
388            template_choices.push((id, size));
389        }
390
391        let mut terrain = |q: TerrainQuery| -> TerrainResult {
392            match q {
393                TerrainQuery::SurfaceHeight { x, z, ocean_floor } => {
394                    TerrainResult::Height(ctx.terrain_surface_height(x, z, ocean_floor))
395                }
396                TerrainQuery::IsOpaque {
397                    x,
398                    y,
399                    z,
400                    ocean_floor,
401                } => TerrainResult::Opaque(ctx.terrain_is_opaque(x, y, z, ocean_floor)),
402            }
403        };
404
405        let StructureConfigData::RuinedPortal { setups } = &structure.config else {
406            return None;
407        };
408        if setups.is_empty() {
409            return None;
410        }
411
412        let result = find_generation_point(
413            rng,
414            ctx.chunk_x(),
415            ctx.chunk_z(),
416            setups,
417            ctx.min_y(),
418            &mut |id| {
419                template_choices
420                    .iter()
421                    .find(|(template_id, _)| template_id == id)
422                    .map(|(_, size)| *size)
423            },
424            &mut terrain,
425        )?;
426
427        let (bx, by, bz) = result.biome_check_pos;
428        let biome = ctx.biome_at(bx, by, bz);
429        if !structure.allowed_biomes.contains(&biome.key) {
430            return None;
431        }
432
433        let mut properties = result.properties;
434        if result.can_be_cold {
435            properties.cold = cold_enough_to_snow(biome, ctx.sea_level(), result.biome_check_pos);
436        }
437
438        Some(GenerationStub {
439            position: result.biome_check_pos,
440            pieces: vec![make_ruined_portal_piece(
441                result.template_id,
442                IVec3::new(
443                    result.biome_check_pos.0,
444                    result.biome_check_pos.1,
445                    result.biome_check_pos.2,
446                ),
447                result.rotation,
448                result.mirror,
449                result.rotation_pivot,
450                result.template_size,
451                result.vertical_placement,
452                properties,
453            )],
454        })
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    #[test]
463    fn ruined_portal_piece_uses_template_payload_with_processors_and_postprocess() {
464        let position = IVec3::new(64, 72, -32);
465        let size = IVec3::new(11, 17, 16);
466        let properties = RuinedPortalProperties {
467            cold: true,
468            mossiness: 0.8,
469            air_pocket: false,
470            overgrown: true,
471            vines: true,
472            replace_with_blackstone: false,
473        };
474
475        let piece = make_ruined_portal_piece(
476            Identifier::vanilla_static("ruined_portal/giant_portal_1"),
477            position,
478            Rotation::Clockwise90,
479            StructureMirror::FrontBack,
480            IVec3::new(size.x / 2, 0, size.z / 2),
481            size,
482            RuinedPortalPlacementData::OnOceanFloor,
483            properties,
484        );
485
486        assert_eq!(
487            piece.piece_type,
488            Identifier::new_static("minecraft", "rupo")
489        );
490        assert_eq!(piece.gen_depth, 0);
491        assert_eq!(piece.orientation, Some(Direction::North));
492        assert_eq!(
493            piece.bounding_box,
494            Rotation::Clockwise90.get_bounding_box_full(
495                position,
496                size,
497                IVec3::new(size.x / 2, 0, size.z / 2),
498                true,
499            ),
500        );
501
502        let StructurePiecePayload::Template(data) = piece.payload else {
503            panic!("ruined portal piece should be template-backed");
504        };
505        assert_eq!(
506            data.template_id,
507            Identifier::vanilla_static("ruined_portal/giant_portal_1")
508        );
509        assert_eq!(data.template_position, position);
510        assert_eq!(data.rotation, Rotation::Clockwise90);
511        assert_eq!(data.mirror, StructureMirror::FrontBack);
512        assert_eq!(data.rotation_pivot, IVec3::new(size.x / 2, 0, size.z / 2));
513        assert_eq!(data.block_ignore, StructureBlockIgnore::StructureAndAir);
514        assert_eq!(data.late_block_ignore, StructureBlockIgnore::None);
515        assert_eq!(
516            data.processors,
517            TemplateProcessorList::RuinedPortal {
518                vertical_placement: RuinedPortalPlacementData::OnOceanFloor,
519                properties,
520            }
521        );
522        assert_eq!(data.liquid_settings, LiquidSettingsData::ApplyWaterlogging);
523        assert_eq!(data.marker_handling, TemplateMarkerHandling::Ignore);
524        assert_eq!(data.placement_adjustment, TemplatePlacementAdjustment::None);
525        assert_eq!(
526            data.placement_clip,
527            TemplatePlacementClip::CenterChunkContainsTemplateCenterExpandedToTemplate
528        );
529        assert_eq!(data.post_process, TemplatePostProcess::RuinedPortal);
530    }
531}