Files
Starlight_Lancher/apps/app/src/seed_map/ores.rs
T

874 lines
27 KiB
Rust

//! Seed-based ore prediction for Java 1.18+.
//!
//! Predicts scattered-ore placement attempts from the world seed by
//! replaying Minecraft's chunk population RNG (decoration seed, feature seed,
//! placement modifiers, and the ore blob's own RNG consumption). Terrain is
//! not simulated, so results are estimates: caves and surface exposure can
//! shift or remove blobs. Attempts are cross-checked against cubiomes'
//! approximate surface height to grade confidence.
use std::io;
use serde::{Deserialize, Serialize};
use super::rng::{Xoroshiro, decoration_seed, feature_rng, mc_sin};
use super::{Dimension, parse_seed, resolve_java_version};
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum OreKind {
Diamond,
Iron,
IronVein,
Copper,
CopperVein,
Gold,
Redstone,
Lapis,
Coal,
Netherite,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OreScanRequest {
pub seed: String,
pub version: String,
pub dimension: Dimension,
pub ores: Vec<OreKind>,
/// Interleaved chunk coordinates: `[cx0, cz0, cx1, cz1, ...]`.
pub chunks: Vec<i32>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OreHit {
pub ore: OreKind,
pub x: i32,
pub y: i32,
pub z: i32,
pub verified: bool,
pub y_min: i32,
pub y_max: i32,
pub precision: u8,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OreChunkResult {
pub cx: i32,
pub cz: i32,
pub hits: Vec<OreHit>,
}
#[derive(Clone, Copy, Debug)]
enum CountModifier {
Fixed(u32),
Uniform { min: u32, max: u32 },
Rarity(u32),
}
#[derive(Clone, Copy, Debug)]
enum HeightProvider {
Uniform { min: i32, max: i32 },
Trapezoid { min: i32, max: i32, plateau: i32 },
}
impl HeightProvider {
fn sample(self, rng: &mut Xoroshiro) -> i32 {
match self {
Self::Uniform { min, max } => {
rng.next_int_between_inclusive(min, max)
}
Self::Trapezoid { min, max, plateau } => {
let span = max - min;
if plateau >= span {
return rng.next_int_between_inclusive(min, max);
}
let lower = (span - plateau) / 2;
let upper = span - lower;
min + rng.next_int_between_inclusive(0, upper)
+ rng.next_int_between_inclusive(0, lower)
}
}
}
}
#[derive(Clone, Copy, Debug)]
struct PlacedOre {
feature_index: u32,
step: u32,
count: CountModifier,
height: HeightProvider,
blob_size: u32,
discard_on_air: f32,
precision: u8,
scattered: bool,
}
const UNDERGROUND_ORES_STEP: u32 = 6;
const UNDERGROUND_DECORATION_STEP: u32 = 7;
/*
* Global feature indices for the UNDERGROUND_ORES step, computed with
* Minecraft's FeatureSorter over the vanilla biome sets (validated against
* official generated data for 1.18.2 through 1.21.3): the base sequence
* starts at ore_coal_upper = 9 after the nine stone-patch features, and
* ore_copper sorts AFTER dripstone's ore_copper_large. `ore_diamond_medium`
* joined the base list in 1.20.5, shifting every later entry by one.
* Placement counts, height providers, blob sizes, and discard chances come
* from the vanilla OrePlacements/OreFeatures definitions.
*/
fn overworld_ore_features(kind: OreKind, mc_code: i32) -> Vec<PlacedOre> {
let has_medium_diamond = mc_code >= 12000;
let shift = u32::from(has_medium_diamond);
let ore = |feature_index: u32,
count: CountModifier,
height: HeightProvider,
blob_size: u32,
discard_on_air: f32,
precision: u8| PlacedOre {
feature_index,
step: UNDERGROUND_ORES_STEP,
count,
height,
blob_size,
discard_on_air,
precision,
scattered: false,
};
match kind {
OreKind::Coal => vec![
ore(
9,
CountModifier::Fixed(30),
HeightProvider::Uniform { min: 136, max: 319 },
17,
0.0,
88,
),
ore(
10,
CountModifier::Fixed(20),
HeightProvider::Trapezoid {
min: 0,
max: 192,
plateau: 0,
},
17,
0.5,
84,
),
],
OreKind::Iron => vec![
ore(
11,
CountModifier::Fixed(90),
HeightProvider::Trapezoid {
min: 80,
max: 384,
plateau: 0,
},
9,
0.0,
88,
),
ore(
12,
CountModifier::Fixed(10),
HeightProvider::Trapezoid {
min: -24,
max: 56,
plateau: 0,
},
9,
0.0,
90,
),
ore(
13,
CountModifier::Fixed(10),
HeightProvider::Uniform { min: -64, max: 72 },
4,
0.0,
90,
),
],
OreKind::Gold => vec![
ore(
14,
CountModifier::Fixed(4),
HeightProvider::Trapezoid {
min: -64,
max: 32,
plateau: 0,
},
9,
0.5,
87,
),
ore(
15,
CountModifier::Uniform { min: 0, max: 1 },
HeightProvider::Uniform { min: -64, max: -48 },
9,
0.5,
87,
),
],
OreKind::Redstone => vec![
ore(
16,
CountModifier::Fixed(4),
HeightProvider::Uniform { min: -64, max: 15 },
8,
0.0,
90,
),
ore(
17,
CountModifier::Fixed(8),
HeightProvider::Trapezoid {
min: -96,
max: -32,
plateau: 0,
},
8,
0.0,
90,
),
],
OreKind::Diamond => {
let mut features = vec![
ore(
18,
CountModifier::Fixed(7),
HeightProvider::Trapezoid {
min: -144,
max: 16,
plateau: 0,
},
4,
0.5,
88,
),
ore(
19 + shift,
CountModifier::Rarity(9),
HeightProvider::Trapezoid {
min: -144,
max: 16,
plateau: 0,
},
12,
0.7,
82,
),
ore(
20 + shift,
CountModifier::Fixed(4),
HeightProvider::Trapezoid {
min: -144,
max: 16,
plateau: 0,
},
8,
1.0,
92,
),
];
if has_medium_diamond {
features.push(ore(
19,
CountModifier::Fixed(2),
HeightProvider::Uniform { min: -64, max: -4 },
8,
0.5,
88,
));
}
features
}
OreKind::Lapis => vec![
ore(
21 + shift,
CountModifier::Fixed(2),
HeightProvider::Trapezoid {
min: -32,
max: 32,
plateau: 0,
},
7,
0.0,
88,
),
ore(
22 + shift,
CountModifier::Fixed(4),
HeightProvider::Uniform { min: -64, max: 64 },
7,
1.0,
92,
),
],
OreKind::Copper => vec![ore(
24 + shift,
CountModifier::Fixed(16),
HeightProvider::Trapezoid {
min: -16,
max: 112,
plateau: 0,
},
10,
0.0,
88,
)],
OreKind::IronVein | OreKind::CopperVein | OreKind::Netherite => vec![],
}
}
/*
* Large veins come from the Overworld vein noises rather than the population
* RNG, so they are scanned natively per chunk. Copper veins live at Y 0..50
* and iron veins at Y -60..-8.
*/
fn scan_vein_hits(
kind: OreKind,
world_seed: u64,
cx: i32,
cz: i32,
surface: Option<&SurfaceGrid>,
) -> Vec<OreHit> {
let vein_code = i32::from(kind != OreKind::CopperVein);
let mut buffer = [0_i32; 24];
let count = unsafe {
super::axolotl_seed_map_scan_vein(
world_seed,
cx,
cz,
vein_code,
buffer.as_mut_ptr(),
buffer.len() / 3,
)
};
buffer[..count * 3]
.chunks_exact(3)
.map(|triple| {
let (x, y, z) = (triple[0], triple[1], triple[2]);
let verified = surface
.map(|grid| (y as f32) < grid.height_at(x, z) - 6.0)
.unwrap_or(false);
OreHit {
ore: kind,
x,
y,
z,
verified,
y_min: y,
y_max: y,
precision: if verified { 93 } else { 78 },
}
})
.collect()
}
/*
* Ancient debris indices in the UNDERGROUND_DECORATION step are 21 and 22
* across every supported version (stable under all nether biome orderings).
* Both are single-attempt scattered-ore features that are always buried.
*/
fn nether_ore_features(kind: OreKind, _mc_code: i32) -> Vec<PlacedOre> {
if kind != OreKind::Netherite {
return vec![];
}
let ore = |feature_index: u32, height: HeightProvider, blob_size: u32| {
PlacedOre {
feature_index,
step: UNDERGROUND_DECORATION_STEP,
count: CountModifier::Fixed(1),
height,
blob_size,
discard_on_air: 1.0,
precision: 95,
scattered: true,
}
};
vec![
ore(
21,
HeightProvider::Trapezoid {
min: 8,
max: 24,
plateau: 0,
},
3,
),
ore(22, HeightProvider::Uniform { min: 8, max: 119 }, 2),
]
}
pub fn supports_ores(version_code: i32, dimension: Dimension) -> bool {
version_code >= 11800 && !matches!(dimension, Dimension::End)
}
pub fn scan_ores(request: OreScanRequest) -> io::Result<Vec<OreChunkResult>> {
let (mc_version, version_code) = resolve_java_version(&request.version)?;
if !supports_ores(version_code, request.dimension) {
return Err(io::Error::other(
"Ore prediction supports Java 1.18+ Overworld and Nether maps.",
));
}
if !request.chunks.len().is_multiple_of(2) || request.chunks.len() > 4_096 {
return Err(io::Error::other("The ore scan chunk list is invalid."));
}
let world_seed = parse_seed(&request.seed) as i64;
let chunk_pairs: Vec<(i32, i32)> = request
.chunks
.chunks_exact(2)
.map(|pair| (pair[0], pair[1]))
.collect();
let surface = match request.dimension {
Dimension::Overworld => {
SurfaceGrid::sample(world_seed as u64, mc_version, &chunk_pairs)
}
_ => None,
};
let (world_bottom, world_top) = match request.dimension {
Dimension::Overworld => (-64, 320),
_ => (0, 128),
};
let requested_kinds = dedupe_kinds(&request.ores);
let vein_kinds: Vec<OreKind> =
if matches!(request.dimension, Dimension::Overworld) {
requested_kinds
.iter()
.copied()
.filter(|kind| {
matches!(kind, OreKind::IronVein | OreKind::CopperVein)
})
.collect()
} else {
Vec::new()
};
let mut features: Vec<(OreKind, PlacedOre)> = Vec::new();
for kind in requested_kinds {
let kind_features = match request.dimension {
Dimension::Overworld => overworld_ore_features(kind, version_code),
Dimension::Nether => nether_ore_features(kind, version_code),
Dimension::End => vec![],
};
features
.extend(kind_features.into_iter().map(|feature| (kind, feature)));
}
let mut results = Vec::with_capacity(chunk_pairs.len());
for (cx, cz) in chunk_pairs {
let chunk_decoration_seed =
decoration_seed(world_seed, cx * 16, cz * 16);
let mut hits = Vec::new();
for &kind in &vein_kinds {
hits.extend(scan_vein_hits(
kind,
world_seed as u64,
cx,
cz,
surface.as_ref(),
));
}
for &(kind, feature) in &features {
let mut rng = feature_rng(
chunk_decoration_seed,
feature.feature_index,
feature.step,
);
let attempts = match feature.count {
CountModifier::Fixed(count) => count,
CountModifier::Uniform { min, max } => {
min + rng.next_int(max - min + 1)
}
CountModifier::Rarity(chance) => {
u32::from(rng.next_f32() < 1.0 / chance as f32)
}
};
for _ in 0..attempts {
let x = cx * 16 + rng.next_int(16) as i32;
let z = cz * 16 + rng.next_int(16) as i32;
let y = feature.height.sample(&mut rng);
let surface_height =
surface.as_ref().map(|grid| grid.height_at(x, z));
let blob = if feature.scattered {
Some((y, y))
} else {
simulate_blob(
&mut rng,
x,
y,
z,
feature.blob_size,
feature.discard_on_air,
surface_height,
world_bottom,
world_top,
)
};
let Some((blob_min_y, blob_max_y)) = blob else {
continue;
};
let verified = match surface_height {
Some(surface_y) => (blob_max_y as f32) < surface_y - 6.0,
None => feature.discard_on_air >= 1.0,
};
let precision = if verified {
feature.precision
} else {
feature.precision.saturating_sub(18).max(50)
};
hits.push(OreHit {
ore: kind,
x,
y,
z,
verified,
y_min: blob_min_y,
y_max: blob_max_y,
precision,
});
}
}
results.push(OreChunkResult { cx, cz, hits });
}
Ok(results)
}
fn dedupe_kinds(kinds: &[OreKind]) -> Vec<OreKind> {
let mut seen = Vec::new();
for &kind in kinds {
if !seen.contains(&kind) {
seen.push(kind);
}
}
seen
}
/*
* Replays the RNG consumption of `OreFeature.place`, assuming solid terrain:
* the blob angle, the two Y jitters, one radius roll per segment, and — for
* partially air-sensitive ores — one roll per candidate cell. Returns the
* simulated blob's Y extent, or None when the surface pre-check would skip
* placement entirely.
*/
#[allow(clippy::too_many_arguments)]
fn simulate_blob(
rng: &mut Xoroshiro,
x: i32,
y: i32,
z: i32,
size: u32,
discard_on_air: f32,
surface_height: Option<f32>,
world_bottom: i32,
world_top: i32,
) -> Option<(i32, i32)> {
let angle = rng.next_f32() * std::f32::consts::PI;
let spread = size as f32 / 8.0;
let margin = (((size as f32 / 16.0) * 2.0 + 1.0) / 2.0).ceil() as i32;
let x0 = f64::from(x) + f64::from(angle).sin() * f64::from(spread);
let x1 = f64::from(x) - f64::from(angle).sin() * f64::from(spread);
let z0 = f64::from(z) + f64::from(angle).cos() * f64::from(spread);
let z1 = f64::from(z) - f64::from(angle).cos() * f64::from(spread);
let y0 = f64::from(y + rng.next_int(3) as i32 - 2);
let y1 = f64::from(y + rng.next_int(3) as i32 - 2);
let box_min_x = x - (spread.ceil() as i32) - margin;
let box_min_y = y - 2 - margin;
let box_min_z = z - (spread.ceil() as i32) - margin;
if let Some(surface_y) = surface_height
&& box_min_y as f32 > surface_y
{
return None;
}
let segment_count = size as usize;
let mut segments = vec![[0.0_f64; 4]; segment_count];
for (index, segment) in segments.iter_mut().enumerate() {
let progress = index as f32 / segment_count as f32;
let center_x = lerp(f64::from(progress), x0, x1);
let center_y = lerp(f64::from(progress), y0, y1);
let center_z = lerp(f64::from(progress), z0, z1);
let radius_roll = rng.next_f64() * f64::from(size) / 16.0;
let radius = ((f64::from(mc_sin(std::f32::consts::PI * progress))
+ 1.0)
* radius_roll
+ 1.0)
/ 2.0;
*segment = [center_x, center_y, center_z, radius];
}
for first in 0..segment_count.saturating_sub(1) {
if segments[first][3] <= 0.0 {
continue;
}
for second in first + 1..segment_count {
if segments[second][3] <= 0.0 {
continue;
}
let dx = segments[first][0] - segments[second][0];
let dy = segments[first][1] - segments[second][1];
let dz = segments[first][2] - segments[second][2];
let dr = segments[first][3] - segments[second][3];
if dr * dr > dx * dx + dy * dy + dz * dz {
if dr > 0.0 {
segments[second][3] = -1.0;
} else {
segments[first][3] = -1.0;
}
}
}
}
let needs_cell_rolls = discard_on_air > 0.0 && discard_on_air < 1.0;
let mut placed = std::collections::HashSet::new();
let mut blob_min_y = i32::MAX;
let mut blob_max_y = i32::MIN;
for segment in &segments {
let radius = segment[3];
if radius < 0.0 {
continue;
}
let min_cell_x = ((segment[0] - radius).floor() as i32).max(box_min_x);
let min_cell_y = ((segment[1] - radius).floor() as i32).max(box_min_y);
let min_cell_z = ((segment[2] - radius).floor() as i32).max(box_min_z);
let max_cell_x = ((segment[0] + radius).floor() as i32).max(min_cell_x);
let max_cell_y = ((segment[1] + radius).floor() as i32).max(min_cell_y);
let max_cell_z = ((segment[2] + radius).floor() as i32).max(min_cell_z);
for cell_x in min_cell_x..=max_cell_x {
let dx = (f64::from(cell_x) + 0.5 - segment[0]) / radius;
if dx * dx >= 1.0 {
continue;
}
for cell_y in min_cell_y..=max_cell_y {
let dy = (f64::from(cell_y) + 0.5 - segment[1]) / radius;
if dx * dx + dy * dy >= 1.0 {
continue;
}
for cell_z in min_cell_z..=max_cell_z {
let dz = (f64::from(cell_z) + 0.5 - segment[2]) / radius;
if dx * dx + dy * dy + dz * dz >= 1.0 {
continue;
}
if cell_y < world_bottom || cell_y >= world_top {
continue;
}
if !placed.insert((cell_x, cell_y, cell_z)) {
continue;
}
if needs_cell_rolls {
rng.next_f32();
}
blob_min_y = blob_min_y.min(cell_y);
blob_max_y = blob_max_y.max(cell_y);
}
}
}
}
if blob_min_y > blob_max_y {
return Some((y, y));
}
Some((blob_min_y, blob_max_y))
}
fn lerp(progress: f64, from: f64, to: f64) -> f64 {
from + progress * (to - from)
}
/*
* A cached approximate surface-height grid at 1:4 scale covering the bounding
* box of the scanned chunks.
*/
struct SurfaceGrid {
origin_x: i32,
origin_z: i32,
width: i32,
height: i32,
values: Vec<f32>,
}
impl SurfaceGrid {
fn sample(
seed: u64,
mc_version: i32,
chunks: &[(i32, i32)],
) -> Option<Self> {
if chunks.is_empty() {
return None;
}
let min_cx = chunks.iter().map(|chunk| chunk.0).min()?;
let max_cx = chunks.iter().map(|chunk| chunk.0).max()?;
let min_cz = chunks.iter().map(|chunk| chunk.1).min()?;
let max_cz = chunks.iter().map(|chunk| chunk.1).max()?;
let origin_x = min_cx * 4;
let origin_z = min_cz * 4;
let width = (max_cx - min_cx + 1) * 4;
let height = (max_cz - min_cz + 1) * 4;
if width <= 0 || height <= 0 || width as i64 * height as i64 > 1 << 20 {
return None;
}
let mut values = vec![0.0_f32; (width * height) as usize];
let result = unsafe {
super::axolotl_seed_map_surface_heights(
seed,
mc_version,
0,
origin_x,
origin_z,
width,
height,
values.as_mut_ptr(),
)
};
if result != 0 {
return None;
}
Some(Self {
origin_x,
origin_z,
width,
height,
values,
})
}
fn height_at(&self, block_x: i32, block_z: i32) -> f32 {
let column =
(block_x.div_euclid(4) - self.origin_x).clamp(0, self.width - 1);
let row =
(block_z.div_euclid(4) - self.origin_z).clamp(0, self.height - 1);
self.values[(row * self.width + column) as usize]
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scan(
seed: &str,
version: &str,
dimension: Dimension,
ores: Vec<OreKind>,
) -> Vec<OreChunkResult> {
scan_ores(OreScanRequest {
seed: seed.to_owned(),
version: version.to_owned(),
dimension,
ores,
chunks: vec![0, 0, 1, 0, -1, -1],
})
.expect("scan should succeed")
}
#[test]
fn ore_scans_are_deterministic() {
let first = scan(
"10292992",
"1.21.3",
Dimension::Overworld,
vec![OreKind::Diamond],
);
let second = scan(
"10292992",
"1.21.3",
Dimension::Overworld,
vec![OreKind::Diamond],
);
assert_eq!(first.len(), 3);
for (a, b) in first.iter().zip(second.iter()) {
assert_eq!(a.hits.len(), b.hits.len());
for (left, right) in a.hits.iter().zip(b.hits.iter()) {
assert_eq!(
(left.x, left.y, left.z),
(right.x, right.y, right.z)
);
}
}
}
#[test]
fn diamond_attempts_stay_in_distribution_range() {
for chunk in scan(
"axolotl",
"1.21.3",
Dimension::Overworld,
vec![OreKind::Diamond],
) {
for hit in chunk.hits {
assert!(
hit.y >= -144 && hit.y <= 16,
"y={} out of range",
hit.y
);
assert!(hit.x >= chunk.cx * 16 && hit.x < chunk.cx * 16 + 16);
assert!(hit.z >= chunk.cz * 16 && hit.z < chunk.cz * 16 + 16);
}
}
}
#[test]
fn netherite_scans_use_the_nether_layout() {
let results = scan(
"10292992",
"1.21.3",
Dimension::Nether,
vec![OreKind::Netherite],
);
let hits: Vec<_> =
results.iter().flat_map(|chunk| chunk.hits.iter()).collect();
assert!(!hits.is_empty());
for hit in hits {
assert!(hit.y >= 8 && hit.y <= 119);
assert!(hit.verified);
}
}
#[test]
fn vein_hits_stay_inside_their_bands_and_are_deterministic() {
let chunks: Vec<i32> = (0..8_i32)
.flat_map(|cx| (0..8_i32).flat_map(move |cz| [cx, cz]))
.collect();
let request = OreScanRequest {
seed: "10292992".to_owned(),
version: "26.2".to_owned(),
dimension: Dimension::Overworld,
ores: vec![OreKind::IronVein, OreKind::CopperVein],
chunks,
};
let first =
scan_ores(request.clone()).expect("vein scan should succeed");
let second = scan_ores(request).expect("vein scan should succeed");
let hits: Vec<_> =
first.iter().flat_map(|chunk| chunk.hits.iter()).collect();
for hit in &hits {
match hit.ore {
OreKind::IronVein => assert!(hit.y >= -60 && hit.y <= -8),
OreKind::CopperVein => assert!(hit.y >= 0 && hit.y <= 50),
other => panic!("unexpected ore kind {other:?}"),
}
}
let second_hits: Vec<_> =
second.iter().flat_map(|chunk| chunk.hits.iter()).collect();
assert_eq!(hits.len(), second_hits.len());
}
#[test]
fn old_versions_reject_ore_scans() {
let error = scan_ores(OreScanRequest {
seed: "1".to_owned(),
version: "1.16".to_owned(),
dimension: Dimension::Overworld,
ores: vec![OreKind::Iron],
chunks: vec![0, 0],
});
assert!(error.is_err());
}
}