Skip to main content

steel_core/worldgen/surface/
mod.rs

1//! Surface system for biome-specific block placement.
2//!
3//! Translates vanilla's `SurfaceSystem` — holds noise generators, clay band
4//! data, and positional random sources needed by transpiled surface rules.
5
6use rustc_hash::FxHashMap;
7use steel_registry::biome::TemperatureModifier;
8use steel_registry::blocks::block_state_ext::BlockStateExt;
9use steel_registry::vanilla_blocks;
10use steel_registry::{REGISTRY, RegistryExt};
11use steel_utils::BlockStateId;
12use steel_utils::random::legacy_random::LegacyRandom;
13use steel_utils::random::name_hash::NameHash;
14use steel_utils::random::{PositionalRandom, Random, RandomSource, RandomSplitter};
15use steel_worldgen::density::NoiseParameters;
16use steel_worldgen::noise::{NormalNoise, PerlinSimplexNoise};
17use steel_worldgen::surface::SurfaceNoiseProvider;
18
19use crate::worldgen::generator::{GenerationChunk, SurfacePhase};
20
21const CLAY_BAND_LENGTH: usize = 192;
22
23/// Lazy XZ-only noise cache for `SurfaceSystem::get_temperature`.
24///
25/// All three noise samples used by `get_temperature` (frozen large/edge/small,
26/// height-based temperature) are 2D — they only depend on `(block_x, block_z)`.
27/// The `build_surface` column scan calls `cold_enough_to_snow` once per Y, so
28/// reusing these noise values across the whole column saves one sample per
29/// rare-modifier hit and one per height-adjusted block.
30///
31/// `NAN` is the "not yet computed" sentinel: the noise functions only ever
32/// return finite values, so any non-NaN read is a valid cached value. This
33/// keeps the struct 16 bytes smaller than the equivalent `Option<f64>` layout
34/// (no niche for `f64`).
35pub struct TemperatureXzCache {
36    block_x: i32,
37    block_z: i32,
38    frozen_large_x7: f64,
39    frozen_edge: f64,
40    frozen_small: f64,
41    height_temp_noise_x8: f64,
42}
43
44impl TemperatureXzCache {
45    /// Create a fresh (empty) cache for a column at `(block_x, block_z)`.
46    #[must_use]
47    pub const fn new(block_x: i32, block_z: i32) -> Self {
48        Self {
49            block_x,
50            block_z,
51            frozen_large_x7: f64::NAN,
52            frozen_edge: f64::NAN,
53            frozen_small: f64::NAN,
54            height_temp_noise_x8: f64::NAN,
55        }
56    }
57}
58
59/// Runtime surface system holding noises and clay band data.
60///
61/// Matches vanilla's `SurfaceSystem`. Constructed once per generator and
62/// shared across all chunk generation calls.
63pub struct SurfaceSystem {
64    /// Surface depth noise (`minecraft:surface`).
65    surface_noise: NormalNoise,
66    /// Surface secondary noise (`minecraft:surface_secondary`).
67    surface_secondary_noise: NormalNoise,
68    /// Clay bands offset noise (`minecraft:clay_bands_offset`).
69    clay_bands_offset_noise: NormalNoise,
70    /// Pre-generated terracotta band pattern (192 entries).
71    clay_bands: [BlockStateId; CLAY_BAND_LENGTH],
72    /// Positional random factory for surface depth jitter and frozen ocean.
73    noise_random: RandomSplitter,
74    /// Condition noises used by `NoiseThreshold` surface rules.
75    /// Indexed in the same order as `DimensionNoises::surface_noise_ids()`.
76    condition_noises: Vec<NormalNoise>,
77    /// Positional random factories used by `VerticalGradient` surface rules.
78    vertical_gradient_randoms: Vec<RandomSplitter>,
79
80    // ── Extension noises (eroded badlands + frozen ocean) ──
81    badlands_pillar_noise: NormalNoise,
82    badlands_pillar_roof_noise: NormalNoise,
83    badlands_surface_noise: NormalNoise,
84    iceberg_pillar_noise: NormalNoise,
85    iceberg_pillar_roof_noise: NormalNoise,
86    iceberg_surface_noise: NormalNoise,
87
88    // ── Temperature noises (static in vanilla Biome class) ──
89    /// Temperature noise for height-based adjustments (seed 1234, octave 0).
90    temperature_noise: PerlinSimplexNoise,
91    /// Frozen biome temperature noise (seed 3456, octaves [-2,-1,0]).
92    frozen_temperature_noise: PerlinSimplexNoise,
93    /// Biome info noise for frozen patches (seed 2345, octave 0).
94    biome_info_noise: PerlinSimplexNoise,
95
96    /// Default block state for this dimension.
97    pub default_block: BlockStateId,
98    /// Sea level for this dimension.
99    pub sea_level: i32,
100}
101
102impl SurfaceSystem {
103    /// Create a new surface system.
104    ///
105    /// `condition_noise_ids` lists the noise IDs referenced by `NoiseThreshold`
106    /// conditions in the transpiled surface rules.
107    #[must_use]
108    pub fn new(
109        splitter: &RandomSplitter,
110        noise_params: &FxHashMap<String, NoiseParameters>,
111        condition_noise_ids: &[&str],
112        vertical_gradient_ids: &[&str],
113        default_block: BlockStateId,
114        sea_level: i32,
115    ) -> Self {
116        // Clay band generation: vanilla does noiseRandom.fromHashOf("minecraft:clay_bands")
117        const CLAY_BANDS_HASH: NameHash = NameHash::new("minecraft:clay_bands");
118
119        // Vanilla passes the base PositionalRandomFactory (RandomState.this.random)
120        // directly to SurfaceSystem as noiseRandom — no extra fromHashOf wrapping.
121        let noise_random = splitter.clone();
122        let mut band_random = noise_random.with_hash_of(&CLAY_BANDS_HASH);
123        let clay_bands = Self::generate_bands(&mut band_random);
124
125        // Create condition noises referenced by NoiseThreshold rules.
126        // Order matches the indices emitted by the transpiler.
127        let condition_noises: Vec<NormalNoise> = condition_noise_ids
128            .iter()
129            .map(|&id| create_noise(splitter, id, noise_params))
130            .collect();
131        let vertical_gradient_randoms: Vec<RandomSplitter> = vertical_gradient_ids
132            .iter()
133            .map(|&id| {
134                let hash = NameHash::new(id);
135                let mut random = splitter.with_hash_of(&hash);
136                random.next_positional()
137            })
138            .collect();
139
140        Self {
141            surface_noise: create_noise(splitter, "minecraft:surface", noise_params),
142            surface_secondary_noise: create_noise(
143                splitter,
144                "minecraft:surface_secondary",
145                noise_params,
146            ),
147            clay_bands_offset_noise: create_noise(
148                splitter,
149                "minecraft:clay_bands_offset",
150                noise_params,
151            ),
152            clay_bands,
153            noise_random,
154            condition_noises,
155            vertical_gradient_randoms,
156            badlands_pillar_noise: create_noise(
157                splitter,
158                "minecraft:badlands_pillar",
159                noise_params,
160            ),
161            badlands_pillar_roof_noise: create_noise(
162                splitter,
163                "minecraft:badlands_pillar_roof",
164                noise_params,
165            ),
166            badlands_surface_noise: create_noise(
167                splitter,
168                "minecraft:badlands_surface",
169                noise_params,
170            ),
171            iceberg_pillar_noise: create_noise(splitter, "minecraft:iceberg_pillar", noise_params),
172            iceberg_pillar_roof_noise: create_noise(
173                splitter,
174                "minecraft:iceberg_pillar_roof",
175                noise_params,
176            ),
177            iceberg_surface_noise: create_noise(
178                splitter,
179                "minecraft:iceberg_surface",
180                noise_params,
181            ),
182            // Temperature noises — fixed seeds matching vanilla's Biome static initializer
183            temperature_noise: {
184                let mut rng = RandomSource::Legacy(LegacyRandom::from_seed(1234));
185                PerlinSimplexNoise::new(&mut rng, &[0])
186            },
187            frozen_temperature_noise: {
188                let mut rng = RandomSource::Legacy(LegacyRandom::from_seed(3456));
189                PerlinSimplexNoise::new(&mut rng, &[-2, -1, 0])
190            },
191            biome_info_noise: {
192                let mut rng = RandomSource::Legacy(LegacyRandom::from_seed(2345));
193                PerlinSimplexNoise::new(&mut rng, &[0])
194            },
195            default_block,
196            sea_level,
197        }
198    }
199
200    /// Compute the surface depth at a column position.
201    ///
202    /// Matches vanilla's `SurfaceSystem.getSurfaceDepth()`:
203    /// `(int)(noise * 2.75 + 3.0 + random.at(x, 0, z).nextDouble() * 0.25)`
204    #[must_use]
205    pub fn get_surface_depth(&self, x: i32, z: i32) -> i32 {
206        let noise_value = self
207            .surface_noise
208            .get_value(f64::from(x), 0.0, f64::from(z));
209        let jitter = self.noise_random.at(x, 0, z).next_f64() * 0.25;
210        (noise_value * 2.75 + 3.0 + jitter) as i32
211    }
212
213    /// Sample the surface secondary noise at a column position.
214    #[must_use]
215    pub fn get_surface_secondary(&self, x: i32, z: i32) -> f64 {
216        self.surface_secondary_noise
217            .get_value(f64::from(x), 0.0, f64::from(z))
218    }
219
220    // ── Temperature XZ-cache helpers (column-scoped) ────────────────────────
221
222    /// `frozen_temperature_noise.get_value(x*0.05, z*0.05) * 7.0`, lazily
223    /// cached on first access. NaN sentinel = not yet computed.
224    #[inline]
225    fn frozen_large_x7(&self, xz: &mut TemperatureXzCache) -> f64 {
226        if !xz.frozen_large_x7.is_nan() {
227            return xz.frozen_large_x7;
228        }
229        let v = self
230            .frozen_temperature_noise
231            .get_value(f64::from(xz.block_x) * 0.05, f64::from(xz.block_z) * 0.05)
232            * 7.0;
233        xz.frozen_large_x7 = v;
234        v
235    }
236
237    /// `biome_info_noise.get_value(x*0.2, z*0.2)`, lazily cached.
238    #[inline]
239    fn frozen_edge(&self, xz: &mut TemperatureXzCache) -> f64 {
240        if !xz.frozen_edge.is_nan() {
241            return xz.frozen_edge;
242        }
243        let v = self
244            .biome_info_noise
245            .get_value(f64::from(xz.block_x) * 0.2, f64::from(xz.block_z) * 0.2);
246        xz.frozen_edge = v;
247        v
248    }
249
250    /// `biome_info_noise.get_value(x*0.09, z*0.09)`, lazily cached.
251    #[inline]
252    fn frozen_small(&self, xz: &mut TemperatureXzCache) -> f64 {
253        if !xz.frozen_small.is_nan() {
254            return xz.frozen_small;
255        }
256        let v = self
257            .biome_info_noise
258            .get_value(f64::from(xz.block_x) * 0.09, f64::from(xz.block_z) * 0.09);
259        xz.frozen_small = v;
260        v
261    }
262
263    /// `temperature_noise.get_value(x/8, z/8) * 8.0`, lazily cached.
264    #[inline]
265    fn height_temp_noise_x8(&self, xz: &mut TemperatureXzCache) -> f64 {
266        if !xz.height_temp_noise_x8.is_nan() {
267            return xz.height_temp_noise_x8;
268        }
269        let v = self
270            .temperature_noise
271            .get_value(f64::from(xz.block_x) / 8.0, f64::from(xz.block_z) / 8.0)
272            * 8.0;
273        xz.height_temp_noise_x8 = v;
274        v
275    }
276
277    /// Compute the effective temperature at a position, using a column-local
278    /// XZ cache.
279    ///
280    /// Matches vanilla's `Biome.getTemperature()` with the temperature modifier
281    /// and height-based adjustment above `sea_level + 17`. All three contributing
282    /// noise samples are 2D (XZ-only), so reusing them across every Y in a column
283    /// is determinism-preserving.
284    ///
285    /// # Panics
286    /// Panics if `biome_id` does not correspond to a registered biome.
287    fn get_temperature(&self, biome_id: u16, block_y: i32, xz: &mut TemperatureXzCache) -> f32 {
288        let biome = REGISTRY
289            .biomes
290            .by_id(biome_id as usize)
291            .expect("invalid biome id");
292        let base_temp = biome.temperature;
293
294        // Apply temperature modifier (FROZEN biomes have special noise-based patches)
295        let modified_temp = match biome.temperature_modifier {
296            TemperatureModifier::None => base_temp,
297            TemperatureModifier::Frozen => {
298                let combined = self.frozen_large_x7(xz) + self.frozen_edge(xz);
299                if combined < 0.3 {
300                    if self.frozen_small(xz) < 0.8 {
301                        0.2 // Force warm
302                    } else {
303                        base_temp
304                    }
305                } else {
306                    base_temp
307                }
308            }
309        };
310
311        // Height-based temperature adjustment above seaLevel + 17
312        let snow_level = self.sea_level + 17;
313        if block_y > snow_level {
314            let v = self.height_temp_noise_x8(xz) as f32;
315            modified_temp - (v + block_y as f32 - snow_level as f32) * 0.05 / 40.0
316        } else {
317            modified_temp
318        }
319    }
320
321    /// Check if a position is cold enough to snow.
322    ///
323    /// Matches vanilla's `Biome.coldEnoughToSnow()` → `!warmEnoughToRain()` →
324    /// `getTemperature() >= 0.15`. Takes a column-local `xz` cache so the
325    /// XZ-only noise samples are reused across every Y in the column scan.
326    #[must_use]
327    pub fn cold_enough_to_snow(
328        &self,
329        biome_id: u16,
330        block_y: i32,
331        xz: &mut TemperatureXzCache,
332    ) -> bool {
333        self.get_temperature(biome_id, block_y, xz) < 0.15
334    }
335
336    /// Check if an iceberg at this position should melt slightly.
337    ///
338    /// Matches vanilla's `Biome.shouldMeltFrozenOceanIcebergSlightly()`.
339    /// Temperature is evaluated at sea level.
340    fn should_melt_frozen_ocean_iceberg_slightly(
341        &self,
342        biome_id: u16,
343        block_x: i32,
344        block_z: i32,
345    ) -> bool {
346        let mut xz = TemperatureXzCache::new(block_x, block_z);
347        self.get_temperature(biome_id, self.sea_level, &mut xz) > 0.1
348    }
349
350    // ── Clay band generation ────────────────────────────────────────────────
351
352    /// Generate the 192-element terracotta band pattern.
353    ///
354    /// Matches vanilla's `SurfaceSystem.generateBands()`.
355    fn generate_bands(random: &mut RandomSource) -> [BlockStateId; CLAY_BAND_LENGTH] {
356        let terracotta = vanilla_blocks::TERRACOTTA.default_state();
357        let orange = vanilla_blocks::ORANGE_TERRACOTTA.default_state();
358        let yellow = vanilla_blocks::YELLOW_TERRACOTTA.default_state();
359        let brown = vanilla_blocks::BROWN_TERRACOTTA.default_state();
360        let red = vanilla_blocks::RED_TERRACOTTA.default_state();
361        let white = vanilla_blocks::WHITE_TERRACOTTA.default_state();
362        let light_gray = vanilla_blocks::LIGHT_GRAY_TERRACOTTA.default_state();
363
364        let mut bands = [terracotta; CLAY_BAND_LENGTH];
365
366        // Orange terracotta bands — vanilla loop increments i in both the
367        // for-header and body: `for(int i = 0; i < len; ++i) { i += rand(5)+1; ... }`
368        let mut i = 0usize;
369        while i < CLAY_BAND_LENGTH {
370            i += random.next_i32_bounded(5) as usize + 1;
371            if i < CLAY_BAND_LENGTH {
372                bands[i] = orange;
373            }
374            i += 1;
375        }
376
377        Self::make_bands(random, &mut bands, 1, yellow);
378        Self::make_bands(random, &mut bands, 2, brown);
379        Self::make_bands(random, &mut bands, 1, red);
380
381        // White + light gray terracotta bands
382        let white_count = random.next_i32_between(9, 15);
383        let mut placed = 0;
384        let mut start = 0usize;
385        while placed < white_count && start < CLAY_BAND_LENGTH {
386            bands[start] = white;
387            if start > 1 && random.next_bool() {
388                bands[start - 1] = light_gray;
389            }
390            if start + 1 < CLAY_BAND_LENGTH && random.next_bool() {
391                bands[start + 1] = light_gray;
392            }
393            placed += 1;
394            start += random.next_i32_bounded(16) as usize + 4;
395        }
396
397        bands
398    }
399
400    /// Place random bands of a single color.
401    ///
402    /// Matches vanilla's `SurfaceSystem.makeBands()`.
403    fn make_bands(
404        random: &mut RandomSource,
405        bands: &mut [BlockStateId; CLAY_BAND_LENGTH],
406        base_width: i32,
407        state: BlockStateId,
408    ) {
409        let band_count = random.next_i32_between(6, 15);
410        for _ in 0..band_count {
411            let width = (base_width + random.next_i32_bounded(3)) as usize;
412            let start = random.next_i32_bounded(CLAY_BAND_LENGTH as i32) as usize;
413            for p in 0..width {
414                if start + p >= CLAY_BAND_LENGTH {
415                    break;
416                }
417                bands[start + p] = state;
418            }
419        }
420    }
421}
422
423impl SurfaceSystem {
424    /// Eroded badlands extension — adds terracotta pillars above the surface.
425    ///
426    /// Matches vanilla's `SurfaceSystem.erodedBadlandsExtension()`.
427    /// Returns the new `start_height` if blocks were added above the original surface.
428    #[expect(
429        clippy::too_many_arguments,
430        reason = "matches vanilla SurfaceSystem.erodedBadlandsExtension signature"
431    )]
432    #[must_use]
433    pub fn eroded_badlands_extension(
434        &self,
435        chunk: GenerationChunk<'_, SurfacePhase>,
436        local_x: usize,
437        local_z: usize,
438        block_x: i32,
439        block_z: i32,
440        height: i32,
441        min_y: i32,
442    ) -> i32 {
443        let pillar_buffer = f64::min(
444            (self
445                .badlands_surface_noise
446                .get_value(f64::from(block_x), 0.0, f64::from(block_z))
447                * 8.25)
448                .abs(),
449            self.badlands_pillar_noise.get_value(
450                f64::from(block_x) * 0.2,
451                0.0,
452                f64::from(block_z) * 0.2,
453            ) * 15.0,
454        );
455
456        if pillar_buffer <= 0.0 {
457            return height;
458        }
459
460        let pillar_floor = (self.badlands_pillar_roof_noise.get_value(
461            f64::from(block_x) * 0.75,
462            0.0,
463            f64::from(block_z) * 0.75,
464        ) * 1.5)
465            .abs();
466
467        let extension_top = 64.0
468            + f64::min(
469                pillar_buffer * pillar_buffer * 2.5,
470                (pillar_floor * 50.0).ceil() + 24.0,
471            );
472        let start_y = extension_top.floor() as i32;
473
474        if height > start_y {
475            return height;
476        }
477
478        // Scan down from start_y: break on defaultBlock, return on water
479        for y in (min_y..=start_y).rev() {
480            let rel_y = (y - min_y) as usize;
481            let state = chunk
482                .get_relative_block(local_x, rel_y, local_z)
483                .unwrap_or(BlockStateId(0));
484            if state == self.default_block {
485                break;
486            }
487            if state.get_block().config.liquid {
488                return height; // Water found — no extension
489            }
490        }
491
492        // Fill air from start_y downward with defaultBlock
493        for y in (min_y..=start_y).rev() {
494            let rel_y = (y - min_y) as usize;
495            let state = chunk
496                .get_relative_block(local_x, rel_y, local_z)
497                .unwrap_or(BlockStateId(0));
498            if !state.is_air() {
499                break;
500            }
501            chunk.set_relative_block(local_x, rel_y, local_z, self.default_block);
502        }
503
504        // Return updated start height (one above the extension top)
505        start_y + 1
506    }
507
508    /// Frozen ocean iceberg extension — adds packed ice and snow blocks.
509    ///
510    /// Collects the same writes as vanilla's `SurfaceSystem.frozenOceanExtension()`.
511    /// Called after surface rules for frozen ocean / deep frozen ocean biomes.
512    #[expect(
513        clippy::too_many_arguments,
514        reason = "keeps the vanilla frozenOceanExtension inputs explicit"
515    )]
516    pub fn collect_frozen_ocean_extension_writes(
517        &self,
518        biome_id: u16,
519        block_x: i32,
520        block_z: i32,
521        height: i32,
522        min_surface_level: i32,
523        min_y: i32,
524        column: &[BlockStateId],
525        writes: &mut Vec<(usize, BlockStateId)>,
526    ) {
527        let iceberg = f64::min(
528            (self
529                .iceberg_surface_noise
530                .get_value(f64::from(block_x), 0.0, f64::from(block_z))
531                * 8.25)
532                .abs(),
533            self.iceberg_pillar_noise.get_value(
534                f64::from(block_x) * 1.28,
535                0.0,
536                f64::from(block_z) * 1.28,
537            ) * 15.0,
538        );
539
540        if iceberg <= 1.8 {
541            return;
542        }
543
544        let iceberg_roof = (self.iceberg_pillar_roof_noise.get_value(
545            f64::from(block_x) * 1.17,
546            0.0,
547            f64::from(block_z) * 1.17,
548        ) * 1.5)
549            .abs();
550
551        let mut top = f64::min(iceberg * iceberg * 1.2, (iceberg_roof * 40.0).ceil() + 14.0);
552
553        if self.should_melt_frozen_ocean_iceberg_slightly(biome_id, block_x, block_z) {
554            top -= 2.0;
555        }
556
557        let extension_bottom;
558        if top > 2.0 {
559            extension_bottom = f64::from(self.sea_level) - top - 7.0;
560            top += f64::from(self.sea_level);
561        } else {
562            top = 0.0;
563            extension_bottom = 0.0;
564        }
565
566        let extension_top = top;
567        let mut random = self.noise_random.at(block_x, 0, block_z);
568        let max_snow_depth = 2 + random.next_i32_bounded(4);
569        let min_snow_height = self.sea_level + 18 + random.next_i32_bounded(10);
570        let mut snow_depth = 0;
571
572        let snow_block = vanilla_blocks::SNOW_BLOCK.default_state();
573        let packed_ice = vanilla_blocks::PACKED_ICE.default_state();
574        let air = vanilla_blocks::AIR.default_state();
575
576        let start_y = i32::max(height, top as i32 + 1);
577        for y in (min_surface_level..=start_y).rev() {
578            let rel_y = (y - min_y) as usize;
579            let state = column.get(rel_y).copied().unwrap_or(air);
580
581            let is_air = state.is_air();
582            let is_water = state.get_block() == &vanilla_blocks::WATER;
583
584            if (is_air && y < extension_top as i32 && random.next_f64() > 0.01)
585                || (is_water
586                    && y > extension_bottom as i32
587                    && y < self.sea_level
588                    && extension_bottom != 0.0
589                    && random.next_f64() > 0.15)
590            {
591                if snow_depth <= max_snow_depth && y > min_snow_height {
592                    writes.push((rel_y, snow_block));
593                    snow_depth += 1;
594                } else {
595                    writes.push((rel_y, packed_ice));
596                }
597            }
598        }
599    }
600}
601
602impl SurfaceNoiseProvider for SurfaceSystem {
603    fn condition_noise(&self, noise_index: usize, x: i32, z: i32) -> f64 {
604        self.condition_noises[noise_index].get_value(f64::from(x), 0.0, f64::from(z))
605    }
606
607    fn condition_noise_3d(&self, noise_index: usize, x: i32, y: i32, z: i32) -> f64 {
608        self.condition_noises[noise_index].get_value(f64::from(x), f64::from(y), f64::from(z))
609    }
610
611    fn get_band(&self, x: i32, y: i32, z: i32) -> BlockStateId {
612        // Java: (int)Math.round(noise * 4.0)
613        let offset = (self
614            .clay_bands_offset_noise
615            .get_value(f64::from(x), 0.0, f64::from(z))
616            * 4.0
617            + 0.5)
618            .floor() as i32;
619        let index = ((y + offset) % CLAY_BAND_LENGTH as i32 + CLAY_BAND_LENGTH as i32) as usize
620            % CLAY_BAND_LENGTH;
621        self.clay_bands[index]
622    }
623
624    fn cold_enough_to_snow(&self, biome_id: u16, block_x: i32, block_y: i32, block_z: i32) -> bool {
625        let mut xz = TemperatureXzCache::new(block_x, block_z);
626        SurfaceSystem::cold_enough_to_snow(self, biome_id, block_y, &mut xz)
627    }
628
629    fn vertical_gradient(
630        &self,
631        gradient_index: usize,
632        block_x: i32,
633        block_y: i32,
634        block_z: i32,
635        true_at_and_below: i32,
636        false_at_and_above: i32,
637    ) -> bool {
638        if block_y <= true_at_and_below {
639            return true;
640        }
641        if block_y >= false_at_and_above {
642            return false;
643        }
644        // Linear probability: 1.0 at true_at_and_below, 0.0 at false_at_and_above
645        let probability = f64::from(false_at_and_above - block_y)
646            / f64::from(false_at_and_above - true_at_and_below);
647
648        // vanilla: randomState.getOrCreateRandomFactory(name) =
649        //   this.random.fromHashOf(name).forkPositional()
650        let factory = &self.vertical_gradient_randoms[gradient_index];
651        let random_value = f64::from(factory.at(block_x, block_y, block_z).next_f32());
652        random_value < probability
653    }
654}
655
656/// Helper to create a `NormalNoise` from the parameter registry.
657fn create_noise(
658    splitter: &RandomSplitter,
659    id: &str,
660    params: &FxHashMap<String, NoiseParameters>,
661) -> NormalNoise {
662    let p = params
663        .get(id)
664        .unwrap_or_else(|| panic!("Missing noise parameters for {id}"));
665    NormalNoise::create(splitter, id, p.first_octave, &p.amplitudes)
666}