From 0a8fd9a78f76c4f113ebe20fdfb3e080b917529d Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:24:03 +0200 Subject: [PATCH 1/8] WIP: unfinished loot-spawn parity work, stopped for the night Uncommitted changes the agent had in progress when it was stopped; may not build or pass its harnesses yet. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/biome.rs | 26 +-- crates/horizon-client/src/game.rs | 4 - crates/horizon-client/src/game/creatures.rs | 60 ++---- crates/horizon-client/src/game/structures.rs | 93 ++------- crates/horizon-client/src/mobs.rs | 1 + .../src/mobs/spawn_selection.rs | 193 ++++++++++++++++++ 6 files changed, 234 insertions(+), 143 deletions(-) create mode 100644 crates/horizon-client/src/mobs/spawn_selection.rs diff --git a/crates/horizon-client/src/biome.rs b/crates/horizon-client/src/biome.rs index 1be7a4ce9..338bec1f9 100644 --- a/crates/horizon-client/src/biome.rs +++ b/crates/horizon-client/src/biome.rs @@ -17,6 +17,8 @@ pub struct SpawnEntry { pub weight: u32, pub min: u32, pub max: u32, + /// A `ConstantInt` count (sampling draws nothing). + pub constant: bool, } #[derive(Clone, Debug)] pub struct BiomeInfo { @@ -105,29 +107,7 @@ impl Biomes { let mut spawns: HashMap> = HashMap::new(); let categories = &attributes["minecraft:gameplay/natural_mob_spawns"]["argument"]["spawns_by_category"]; for (category, entries) in categories.as_object().into_iter().flatten() { - let list = entries - .as_array() - .into_iter() - .flatten() - .filter_map(|e| { - let (min, max) = match &e["count"] { - Value::Number(n) => { - let n = n.as_u64().unwrap_or(1) as u32; - (n, n) - } - c => ( - c["min_inclusive"].as_u64().unwrap_or(1) as u32, - c["max_inclusive"].as_u64().unwrap_or(1) as u32, - ), - }; - Some(SpawnEntry { - entity: e["type"].as_str()?.to_owned(), - weight: e["weight"].as_u64().unwrap_or(1) as u32, - min, - max, - }) - }) - .collect(); + let list = crate::mobs::spawn_selection::parse_entries(entries); spawns.insert(category.clone(), list); } list.push(BiomeInfo { diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index b81c403fd..911ebfa04 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -752,9 +752,6 @@ struct Game<'a> { adv_ui: advancements_screen::AdvUi, camera_type: perspective::CameraType, fangs: fangs::FangState, - /// Structure `spawn_overrides`: structure -> category -> (piece only, spawns). - spawn_overrides: - HashMap)>>, /// The local player as a model (third person, the inventory). player_model: Option, } @@ -8628,7 +8625,6 @@ pub fn run() -> Result<()> { adv_ui: Default::default(), camera_type: Default::default(), fangs: Default::default(), - spawn_overrides: HashMap::new(), player_model: None, }; game.ui.vanilla_root = vanilla_root; diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 8441baaf1..95e4590e4 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -3,6 +3,7 @@ //! breeding, conversions, drops), projectiles, player interactions //! (feeding, shearing, milking, spawn eggs, throwing) and rendering. use super::{Dim, Game, view_vector}; +use horizon_client::mobs::spawn_selection; use horizon_client::{ Result, blocks::Fluid, @@ -355,23 +356,6 @@ impl Game<'_> { } horizon_client::chunk::MIN_Y } - fn weighted_entry( - &mut self, - entries: &[horizon_client::biome::SpawnEntry], - ) -> Option { - let total: u32 = entries.iter().map(|e| e.weight).sum(); - if total == 0 { - return None; - } - let mut r = self.world.random.next_int_bound(total as i32).ok()? as u32; - for e in entries { - if r < e.weight { - return Some(e.clone()); - } - r -= e.weight; - } - None - } /// NaturalSpawner.spawnMobsForChunkGeneration (creature packs at generation). pub(super) fn spawn_generation_creatures(&mut self, cx: i32, cz: i32) { let Some(chunk) = self.world.chunks.get(&(cx, cz)).cloned() else { @@ -383,15 +367,11 @@ impl Game<'_> { }; let (xo, zo) = (cx * 16, cz * 16); while self.world.random.next_float() < biome.creature_probability { - let Some(entry) = self.weighted_entry(&entries) else { + let Some(entry) = spawn_selection::weighted_pick(&entries, &mut self.world.random) + else { return; }; - let count = entry.min - + self - .world - .random - .next_int_bound((entry.max - entry.min + 1) as i32) - .unwrap_or(0) as u32; + let count = spawn_selection::sample_count(&entry, &mut self.world.random); let mut x = xo + self.world.random.next_int_bound(16).unwrap_or(0); let mut z = zo + self.world.random.next_int_bound(16).unwrap_or(0); let (sx, sz) = (x, z); @@ -592,26 +572,22 @@ impl Game<'_> { break; }; let biome = self.res.biomes.get(chunk.biome(x, y, z)).clone(); - let list = match self.structure_spawns(category, [x, y, z]) { - Some(list) => list, - None => match biome.spawns.get(category).cloned() { - Some(list) => list, - None => break, - }, - }; - let list: Vec<_> = list - .into_iter() - .filter(|e| self.res.mob_types.contains_key(&e.entity)) - .collect(); - let Some(e) = self.weighted_entry(&list) else { + let list = self.spawn_list(category, [x, y, z], &biome.spawns); + let reduced = self + .res + .mob_data + .biome_tags + .get("minecraft:reduced_water_ambient_spawns") + .is_some_and(|t| t.contains(&biome.name)); + let Some(e) = spawn_selection::random_spawn_mob_at( + &list, + category, + reduced, + &mut self.world.random, + ) else { break; }; - group = e.min - + self - .world - .random - .next_int_bound((e.max - e.min + 1) as i32) - .unwrap_or(0) as u32; + group = spawn_selection::sample_count(&e, &mut self.world.random); entry = Some(e); } let e = entry.clone().unwrap_or_else(|| unreachable!()); diff --git a/crates/horizon-client/src/game/structures.rs b/crates/horizon-client/src/game/structures.rs index 116f2100e..1b2199d4d 100644 --- a/crates/horizon-client/src/game/structures.rs +++ b/crates/horizon-client/src/game/structures.rs @@ -39,83 +39,28 @@ impl Game<'_> { .map(|(n, _)| n) .collect() } - /// `ChunkGenerator.getMobsAt` / `NaturalSpawner.mobsAt`: a structure's - /// `spawn_overrides` for the category replace the biome's list where its - /// box (or piece) holds `pos`; fortress spawns on nether bricks. - pub(super) fn structure_spawns( - &mut self, + /// `NaturalSpawner.mobsAt`: the fortress bounds on nether bricks, the + /// structure `spawn_overrides` whose box (or piece) holds `pos`, else the + /// biome's list. + pub(super) fn spawn_list( + &self, category: &str, pos: [i32; 3], - ) -> Option> { - let reaching = self.structures_reaching(pos); - if reaching.is_empty() { - return None; - } + biome: &std::collections::HashMap>, + ) -> Vec { + use horizon_client::mobs::spawn_selection::{StructureAt, StructureSpawns, mobs_at}; + let structures: Vec = self + .structures_reaching(pos) + .into_iter() + .map(|(id, in_piece)| StructureAt { + id, + in_piece, + in_bounds: true, + }) + .collect(); let below = self.world.block(pos[0], pos[1] - 1, pos[2]); let bricks = &*self.res.facts.get(below).name == "minecraft:nether_bricks"; - for (id, piece) in &reaching { - let Some(o) = self.spawn_override(id, category) else { - continue; - }; - if !o.0 || *piece { - return Some(o.1); - } - } - if category == "monster" - && bricks - && reaching.iter().any(|(n, _)| n == "minecraft:fortress") - { - return self - .spawn_override("minecraft:fortress", "monster") - .map(|o| o.1); - } - None - } - /// A structure's override for a category: (piece-only, spawns). - fn spawn_override( - &mut self, - structure: &str, - category: &str, - ) -> Option<(bool, Vec)> { - if !self.spawn_overrides.contains_key(structure) { - let (ns, path) = structure - .split_once(':') - .unwrap_or(("minecraft", structure)); - let file = self - .options - .resources - .join(format!("data/{ns}/worldgen/structure/{path}.json")); - let v: serde_json::Value = std::fs::read(file) - .ok() - .and_then(|b| serde_json::from_slice(&b).ok()) - .unwrap_or_default(); - let mut map = std::collections::HashMap::new(); - for (cat, o) in v["spawn_overrides"].as_object().into_iter().flatten() { - let piece = o["bounding_box"].as_str() == Some("piece"); - let spawns = o["spawns"] - .as_array() - .into_iter() - .flatten() - .map(|e| { - let count = &e["count"]; - horizon_client::biome::SpawnEntry { - entity: e["type"].as_str().unwrap_or_default().to_owned(), - weight: e["weight"].as_u64().unwrap_or(1) as u32, - min: count["min_inclusive"] - .as_u64() - .or_else(|| e["minCount"].as_u64()) - .unwrap_or(1) as u32, - max: count["max_inclusive"] - .as_u64() - .or_else(|| e["maxCount"].as_u64()) - .unwrap_or(1) as u32, - } - }) - .collect(); - map.insert(cat.clone(), (piece, spawns)); - } - self.spawn_overrides.insert(structure.to_owned(), map); - } - self.spawn_overrides.get(structure)?.get(category).cloned() + let spawns = StructureSpawns::shared(&self.options.resources); + mobs_at(spawns, biome, category, &structures, bricks) } } diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index b45da996c..1bc36183b 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -48,6 +48,7 @@ pub mod shulker; pub mod sniffer; pub mod spawning; pub mod spawn_rules; +pub mod spawn_selection; pub mod species; pub mod sulfur_cube; pub mod tame; diff --git a/crates/horizon-client/src/mobs/spawn_selection.rs b/crates/horizon-client/src/mobs/spawn_selection.rs new file mode 100644 index 000000000..fba1cb8fa --- /dev/null +++ b/crates/horizon-client/src/mobs/spawn_selection.rs @@ -0,0 +1,193 @@ +//! `NaturalSpawner.getRandomSpawnMobAt` and `mobsAt`: which spawn list a +//! position uses (the nether fortress bounds, then structure spawn overrides +//! by piece or bounding box, then the biome's natural mob spawns), the +//! reduced water ambient odds, the weighted pick and the group size sample. +use crate::biome::SpawnEntry; +use horizon_core::random::RandomSource; +use serde_json::Value; +use std::{collections::HashMap, fs, path::Path}; + +/// A `natural_mob_spawns` / `spawn_overrides` entry list. +pub fn parse_entries(list: &Value) -> Vec { + list.as_array() + .into_iter() + .flatten() + .filter_map(|e| { + let count = &e["count"]; + let (min, max, constant) = match count { + Value::Number(n) => { + let n = n.as_u64().unwrap_or(1) as u32; + (n, n, true) + } + c if c["type"].as_str() == Some("minecraft:constant") => { + let n = c["value"].as_u64().unwrap_or(1) as u32; + (n, n, true) + } + c => ( + c["min_inclusive"].as_u64().unwrap_or(1) as u32, + c["max_inclusive"].as_u64().unwrap_or(1) as u32, + false, + ), + }; + Some(SpawnEntry { + entity: e["type"].as_str()?.to_owned(), + weight: e["weight"].as_u64().unwrap_or(1) as u32, + min, + max, + constant, + }) + }) + .collect() +} + +/// A structure's spawn override for one category. +#[derive(Clone, Debug)] +pub struct Override { + /// `bounding_box: piece` (else the whole structure's box). + pub piece: bool, + pub spawns: Vec, +} +/// Every structure's `spawn_overrides`, by structure id then category. +#[derive(Clone, Debug, Default)] +pub struct StructureSpawns { + pub overrides: HashMap>, +} +impl StructureSpawns { + /// The overrides of the resource root first asked for, loaded once. + pub fn shared(root: &Path) -> &'static Self { + static SHARED: std::sync::OnceLock = std::sync::OnceLock::new(); + SHARED.get_or_init(|| Self::load(root)) + } + pub fn load(root: &Path) -> Self { + let mut overrides = HashMap::new(); + let dir = root.join("data/minecraft/worldgen/structure"); + for entry in fs::read_dir(dir).into_iter().flatten().flatten() { + let path = entry.path(); + let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + let Some(v) = fs::read(&path) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + else { + continue; + }; + let map: HashMap = v["spawn_overrides"] + .as_object() + .into_iter() + .flatten() + .map(|(category, o)| { + ( + category.clone(), + Override { + piece: o["bounding_box"].as_str() == Some("piece"), + spawns: parse_entries(&o["spawns"]), + }, + ) + }) + .collect(); + if !map.is_empty() { + overrides.insert(format!("minecraft:{stem}"), map); + } + } + Self { overrides } + } + pub fn get(&self, structure: &str, category: &str) -> Option<&Override> { + self.overrides.get(structure)?.get(category) + } +} + +/// A structure whose references reach a position. +#[derive(Clone, Debug)] +pub struct StructureAt { + pub id: String, + /// A piece of one of its starts holds the position. + pub in_piece: bool, + /// One of its starts' bounding boxes holds the position. + pub in_bounds: bool, +} + +/// `NetherFortressStructure.FORTRESS_ENEMIES`. +pub fn fortress_enemies() -> Vec { + let e = |entity: &str, weight, min, max, constant| SpawnEntry { + entity: entity.to_owned(), + weight, + min, + max, + constant, + }; + vec![ + e("minecraft:blaze", 10, 2, 3, false), + e("minecraft:zombified_piglin", 5, 4, 4, true), + e("minecraft:wither_skeleton", 8, 5, 5, true), + e("minecraft:skeleton", 2, 5, 5, true), + e("minecraft:magma_cube", 3, 4, 4, true), + ] +} + +/// `NaturalSpawner.mobsAt`. +pub fn mobs_at( + spawns: &StructureSpawns, + biome: &HashMap>, + category: &str, + structures: &[StructureAt], + below_nether_bricks: bool, +) -> Vec { + // isInNetherFortressBounds. + if category == "monster" + && below_nether_bricks + && structures + .iter() + .any(|s| s.id == "minecraft:fortress" && s.in_bounds) + { + return fortress_enemies(); + } + // ChunkGenerator.getMobsAt: the first structure whose override holds. + for s in structures { + if let Some(o) = spawns.get(&s.id, category) + && (if o.piece { s.in_piece } else { s.in_bounds }) + { + return o.spawns.clone(); + } + } + biome.get(category).cloned().unwrap_or_default() +} + +/// `WeightedList.getRandom`. +pub fn weighted_pick(list: &[SpawnEntry], random: &mut impl RandomSource) -> Option { + let total: i64 = list.iter().map(|e| i64::from(e.weight)).sum(); + if total <= 0 { + return None; + } + let mut selection = i64::from(random.next_int_bound(total as i32).ok()?); + for e in list { + selection -= i64::from(e.weight); + if selection < 0 { + return Some(e.clone()); + } + } + None +} + +/// `NaturalSpawner.getRandomSpawnMobAt`. +pub fn random_spawn_mob_at( + list: &[SpawnEntry], + category: &str, + reduced_water_ambient: bool, + random: &mut impl RandomSource, +) -> Option { + if category == "water_ambient" && reduced_water_ambient && random.next_float() < 0.98 { + return None; + } + weighted_pick(list, random) +} + +/// `SpawnerData.count().sample(random)`: a constant draws nothing. +pub fn sample_count(entry: &SpawnEntry, random: &mut impl RandomSource) -> u32 { + if entry.constant { + entry.min + } else { + let span = entry.max.saturating_sub(entry.min) as i32 + 1; + entry.min + random.next_int_bound(span).unwrap_or(0) as u32 + } +} From a21d268a41ae0d4bbd5a80fd487ad177a8016679 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:40:15 +0200 Subject: [PATCH 2/8] Spawn naturally through a NaturalSpawner port shared by the game mobs::spawn_selection ports NaturalSpawner over a NaturalLevel trait: the spawn state (category counts, spawn potential charges, local caps), the filtered categories, spawnForChunk, spawnCategoryForPosition with mobsAt, isValidSpawnPostitionForType and isValidPositionForMob, and spawnMobsForChunkGeneration. The game's spawning goes through it. Biomes now load their spawn costs and creature_world_gen_spawn_probability. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/biome.rs | 20 +- crates/horizon-client/src/collisions.rs | 17 + crates/horizon-client/src/game.rs | 1 + crates/horizon-client/src/game/creatures.rs | 245 ---- .../src/game/natural_spawning.rs | 364 ++++++ crates/horizon-client/src/game/structures.rs | 26 +- crates/horizon-client/src/mobs/data.rs | 16 + .../src/mobs/spawn_selection.rs | 1161 ++++++++++++++++- 8 files changed, 1557 insertions(+), 293 deletions(-) create mode 100644 crates/horizon-client/src/game/natural_spawning.rs diff --git a/crates/horizon-client/src/biome.rs b/crates/horizon-client/src/biome.rs index 338bec1f9..e0c6900cf 100644 --- a/crates/horizon-client/src/biome.rs +++ b/crates/horizon-client/src/biome.rs @@ -24,6 +24,9 @@ pub struct SpawnEntry { pub struct BiomeInfo { /// `natural_mob_spawns` by category (creature, monster, ...). pub spawns: HashMap>, + /// `natural_mob_spawns` spawn costs by entity type. + pub spawn_costs: HashMap, + /// The `gameplay/creature_world_gen_spawn_probability` attribute. pub creature_probability: f32, pub name: String, pub temperature: f32, @@ -105,15 +108,26 @@ impl Biomes { _ => GrassModifier::None, }; let mut spawns: HashMap> = HashMap::new(); - let categories = &attributes["minecraft:gameplay/natural_mob_spawns"]["argument"]["spawns_by_category"]; + let natural = &attributes["minecraft:gameplay/natural_mob_spawns"]; + // A modifier object ({argument, modifier: overlay}) or the plain value; + // the overlay over the dimension's empty default is the biome's value. + let natural = if natural["argument"].is_object() { + &natural["argument"] + } else { + natural + }; + let categories = &natural["spawns_by_category"]; for (category, entries) in categories.as_object().into_iter().flatten() { let list = crate::mobs::spawn_selection::parse_entries(entries); spawns.insert(category.clone(), list); } list.push(BiomeInfo { spawns, - creature_probability: v["creature_spawn_probability"].as_f64().unwrap_or(0.1) - as f32, + spawn_costs: crate::mobs::spawn_selection::parse_costs(&natural["spawn_costs"]), + creature_probability: + attributes["minecraft:gameplay/creature_world_gen_spawn_probability"] + .as_f64() + .unwrap_or(0.1) as f32, temperature, downfall, precipitation: v["has_precipitation"].as_bool().unwrap_or(true), diff --git a/crates/horizon-client/src/collisions.rs b/crates/horizon-client/src/collisions.rs index 6b06c5384..a7790c283 100644 --- a/crates/horizon-client/src/collisions.rs +++ b/crates/horizon-client/src/collisions.rs @@ -78,6 +78,23 @@ impl SceneCollisions<'_, '_> { )?) } } +/// `noCollision(null, box)`: block collisions under the empty collision +/// context, as entity-less queries such as spawn checks use. +pub fn no_collision_empty(world: &mut W, bounds: Aabb) -> bool +where + W: BlockCollisionWorld + EntityMovementWorld, +{ + no_world_collision( + world, + CollisionCheckRequest { + entity: EntityIdentity(0), + context: CollisionContext::empty(false), + bounds, + candidate_budget: 1_000_000, + }, + ) + .unwrap_or(false) +} impl BlockCollisionWorld for SceneCollisions<'_, '_> { type Error = io::Error; type Chunk = ChunkPos; diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 92847eb5f..667e7d911 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -52,6 +52,7 @@ mod mount_menu; mod mounts; mod music; mod names; +mod natural_spawning; mod nautili; mod options; mod packs; diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 062ce4443..0d7e34572 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -3,7 +3,6 @@ //! breeding, conversions, drops), projectiles, player interactions //! (feeding, shearing, milking, spawn eggs, throwing) and rendering. use super::{Dim, Game, view_vector}; -use horizon_client::mobs::spawn_selection; use horizon_client::{ Result, blocks::Fluid, @@ -38,17 +37,6 @@ pub(super) struct Creatures { /// The wandering trader spawner's clock. pub trader: super::wandering_trader::Spawner, } -/// MobCategory caps (maxInstancesPerChunk with the 17x17 spawnable chunk area). -const CATEGORIES: [(&str, usize); 7] = [ - ("monster", 70), - ("creature", 10), - ("ambient", 15), - ("axolotls", 5), - ("underground_water_creature", 5), - ("water_creature", 5), - ("water_ambient", 20), -]; - struct ProjWorld<'a, 'w> { w: &'a MobWorld<'w>, targets: Vec<(Who, Aabb)>, @@ -356,78 +344,6 @@ impl Game<'_> { } horizon_client::chunk::MIN_Y } - /// NaturalSpawner.spawnMobsForChunkGeneration (creature packs at generation). - pub(super) fn spawn_generation_creatures(&mut self, cx: i32, cz: i32) { - let Some(chunk) = self.world.chunks.get(&(cx, cz)).cloned() else { - return; - }; - let biome = self.res.biomes.get(chunk.biome(8, 64, 8)).clone(); - let Some(entries) = biome.spawns.get("creature").cloned() else { - return; - }; - let (xo, zo) = (cx * 16, cz * 16); - while self.world.random.next_float() < biome.creature_probability { - let Some(entry) = spawn_selection::weighted_pick(&entries, &mut self.world.random) - else { - return; - }; - let count = spawn_selection::sample_count(&entry, &mut self.world.random); - let mut x = xo + self.world.random.next_int_bound(16).unwrap_or(0); - let mut z = zo + self.world.random.next_int_bound(16).unwrap_or(0); - let (sx, sz) = (x, z); - let Some(kind) = self.res.mob_types.get(&entry.entity).cloned() else { - continue; - }; - for _ in 0..count { - let mut success = false; - let mut attempts = 0; - while !success && attempts < 4 { - attempts += 1; - let mut y = self.motion_top(x, z); - let placement = self.res.mob_data.placement(&entry.entity); - if placement == horizon_client::mobs::data::Placement::OnGround { - let below = self.world.block(x, y - 1, z); - if self.res.mob_data.state(below).flags & horizon_client::mobs::data::LAND - != 0 - { - y -= 1; - } - } - let ok = { - let w = self.mob_world_base(&[]); - spawning::placement_ok(kind.species, &w, x, y, z) && { - let mut r = self.world.random.clone(); - - spawning::spawn_rules_ok(kind.species, &w, &mut r, x, y, z) - } - }; - if ok { - let width = kind.width; - let fx = - f64::from(x).clamp(f64::from(xo) + width, f64::from(xo) + 16.0 - width); - let fz = - f64::from(z).clamp(f64::from(zo) + width, f64::from(zo) + 16.0 - width); - if self - .spawn_mob(&entry.entity, [fx, f64::from(y), fz], true) - .is_some() - { - success = true; - } - } - x += self.world.random.next_int_bound(5).unwrap_or(0) - - self.world.random.next_int_bound(5).unwrap_or(0); - z += self.world.random.next_int_bound(5).unwrap_or(0) - - self.world.random.next_int_bound(5).unwrap_or(0); - while x < xo || x >= xo + 16 || z < zo || z >= zo + 16 { - x = sx + self.world.random.next_int_bound(5).unwrap_or(0) - - self.world.random.next_int_bound(5).unwrap_or(0); - z = sz + self.world.random.next_int_bound(5).unwrap_or(0) - - self.world.random.next_int_bound(5).unwrap_or(0); - } - } - } - } - } pub(super) fn mob_world_base<'s>(&'s self, others: &'s [Seen]) -> MobWorld<'s> { let (rain, thunder) = self.world.weather.levels(1.0); MobWorld { @@ -496,167 +412,6 @@ impl Game<'_> { } } } - /// NaturalSpawner.spawnForChunk over the chunks within 8 of the player. - fn spawn_natural(&mut self) { - let Some(p) = self.player.as_ref().map(|p| p.position) else { - return; - }; - let (pcx, pcz) = (p.x.floor() as i32 >> 4, p.z.floor() as i32 >> 4); - let mut counts = [0usize; 7]; - for m in &self.mobs { - if let Some(i) = CATEGORIES.iter().position(|c| c.0 == m.kind.category) { - counts[i] += 1; - } - } - let creature_tick = self.world.game_time % 400 == 0; - let mut chunks: Vec<(i32, i32)> = (-8..=8) - .flat_map(|dx| (-8..=8).map(move |dz| (pcx + dx, pcz + dz))) - .filter(|c| self.world.chunks.contains_key(c)) - .collect(); - // ChunkMap: shuffled each tick. - for i in (1..chunks.len()).rev() { - let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize; - chunks.swap(i, j); - } - // ServerChunkCache.tickChunks: the chunks are shuffled every tick, but - // no category spawns without spawn_mobs, and only friendly ones - // unless the level is spawning monsters (ServerLevel.isSpawningMonsters). - let spawn_mobs = self.ui.rules.bool("spawn_mobs"); - let spawn_enemies = spawn_mobs && self.ui.rules.bool("spawn_monsters"); - for (cx, cz) in chunks { - for (ci, (category, cap)) in CATEGORIES.iter().enumerate() { - if !spawn_mobs - || counts[ci] >= *cap - || *category == "creature" && !creature_tick - || *category == "monster" && !spawn_enemies - { - continue; - } - counts[ci] += self.spawn_category_for_chunk(category, cx, cz, p); - } - } - } - /// NaturalSpawner.spawnCategoryForPosition at a random point in the chunk. - fn spawn_category_for_chunk( - &mut self, - category: &str, - cx: i32, - cz: i32, - player: Vec3, - ) -> usize { - let x0 = cx * 16 + self.world.random.next_int_bound(16).unwrap_or(0); - let z0 = cz * 16 + self.world.random.next_int_bound(16).unwrap_or(0); - let top = self.motion_top(x0, z0) + 1; - let min = horizon_client::chunk::MIN_Y; - let y = min + self.world.random.next_int_bound(top - min + 1).unwrap_or(0); - if y < min + 1 { - return 0; - } - let start = self.res.facts.get(self.world.block(x0, y, z0)); - if start.full_collision { - return 0; - } - let mut spawned = 0; - for _ in 0..3 { - let (mut x, mut z) = (x0, z0); - let mut entry: Option = None; - let mut group = (self.world.random.next_float() * 4.0).ceil() as u32; - let mut cluster = 0; - let mut i = 0; - while i < group { - i += 1; - x += self.world.random.next_int_bound(6).unwrap_or(0) - - self.world.random.next_int_bound(6).unwrap_or(0); - z += self.world.random.next_int_bound(6).unwrap_or(0) - - self.world.random.next_int_bound(6).unwrap_or(0); - let (fx, fz) = (f64::from(x) + 0.5, f64::from(z) + 0.5); - let d2 = (fx - player.x).powi(2) - + (f64::from(y) - player.y).powi(2) - + (fz - player.z).powi(2); - if d2 <= 576.0 { - continue; - } - if entry.is_none() { - let Some(chunk) = self.world.chunks.get(&(x >> 4, z >> 4)) else { - break; - }; - let biome = self.res.biomes.get(chunk.biome(x, y, z)).clone(); - let list = self.spawn_list(category, [x, y, z], &biome.spawns); - let reduced = self - .res - .mob_data - .biome_tags - .get("minecraft:reduced_water_ambient_spawns") - .is_some_and(|t| t.contains(&biome.name)); - let Some(e) = spawn_selection::random_spawn_mob_at( - &list, - category, - reduced, - &mut self.world.random, - ) else { - break; - }; - group = spawn_selection::sample_count(&e, &mut self.world.random); - entry = Some(e); - } - let e = entry.clone().unwrap_or_else(|| unreachable!()); - let despawn = if category == "water_ambient" { - 64.0 - } else { - 128.0 - }; - if d2 > despawn * despawn { - continue; - } - let Some(kind) = self.res.mob_types.get(&e.entity).cloned() else { - break; - }; - let ok = { - let w = self.mob_world_base(&[]); - let mut r = self.world.random.clone(); - let placed = spawning::placement_ok(kind.species, &w, x, y, z) - && spawning::spawn_rules_ok(kind.species, &w, &mut r, x, y, z); - let half = kind.width / 2.0; - let room = w.no_collision(Aabb::new( - Vec3::new(fx - half, f64::from(y), fz - half), - Vec3::new(fx + half, f64::from(y) + kind.height, fz + half), - )); - // PathfinderMob.checkSpawnRules: walk target value >= 0 (monsters need darkness). - let light_ok = !kind.category.eq("monster") - || matches!( - kind.species, - Species::Slime - | Species::SulfurCube - | Species::Ghast - | Species::MagmaCube - | Species::Guardian - | Species::ElderGuardian - ) - || w.brightness(x, y, z) <= 0.5; - let consumed = r; - (placed && room && light_ok, consumed) - }; - self.world.random = ok.1; - if !ok.0 { - continue; - } - if d2 > 16384.0 && kind.category != "creature" { - continue; - } - if self - .spawn_mob(&e.entity, [fx, f64::from(y), fz], false) - .is_some() - { - cluster += 1; - spawned += 1; - if spawned >= 4 || cluster >= group.max(1) { - return spawned; - } - } - } - } - spawned - } /// Advance every mob one tick, then apply their events. pub(super) fn tick_creatures(&mut self) -> Result<()> { self.attack_ticks = self.attack_ticks.saturating_add(1); diff --git a/crates/horizon-client/src/game/natural_spawning.rs b/crates/horizon-client/src/game/natural_spawning.rs new file mode 100644 index 000000000..34e346f33 --- /dev/null +++ b/crates/horizon-client/src/game/natural_spawning.rs @@ -0,0 +1,364 @@ +//! The game's side of `NaturalSpawner` (`mobs::spawn_selection`): the level +//! it reads, the spawning chunks and caps of `ServerChunkCache.tickChunks`, +//! and the mobs it adds (finalized with the game's `finalizeSpawn`). +use super::{Dim, Game}; +use horizon_client::{ + biome::{BiomeInfo, SpawnEntry}, + blocks::BlockFacts, + collisions::{SceneCollisions, no_collision_empty}, + mobs::{ + Mob, MobWorld, Species, + data::{self, MobData}, + spawn_rules::{Dimension, SpawnLevel}, + spawn_selection::{ + self, Category, Counted, Heightmap, NaturalLevel, SpawnCost, SpawnPlayer, SpawnRequest, + SpawnState, Spawned, StructureAt, StructureSpawns, + }, + }, +}; +use horizon_core::{ + geometry::{Aabb, Vec3}, + random::{LegacyRandom, RandomSource, WorldgenRandom}, +}; +use horizon_world::registry::BlockStateId; +use std::collections::HashMap; + +/// The game level as natural spawning reads it. +struct GameLevel<'g, 'a> { + game: &'g Game<'a>, + w: MobWorld<'g>, +} +impl GameLevel<'_, '_> { + fn biome(&self, x: i32, y: i32, z: i32) -> Option<&BiomeInfo> { + let chunk = self.game.world.chunks.get(&(x >> 4, z >> 4))?; + Some(self.game.res.biomes.get(chunk.biome(x, y, z))) + } +} +impl SpawnLevel for GameLevel<'_, '_> { + fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId { + self.w.state(x, y, z) + } + fn facts(&self) -> &BlockFacts { + self.w.facts + } + fn mob_data(&self) -> &MobData { + self.w.data + } + fn block_tag(&self, tag: &str, block: &str) -> bool { + self.w.block_tag(tag, block) + } + fn biome_tag(&self, tag: &str, x: i32, y: i32, z: i32) -> bool { + self.w.biome_tag(tag, x, y, z) + } + fn sky_light(&self, x: i32, y: i32, z: i32) -> i32 { + self.w.sky_light(x, y, z) + } + fn block_light(&self, x: i32, y: i32, z: i32) -> i32 { + self.w.block_light(x, y, z) + } + fn sky_darken(&self) -> i32 { + SpawnLevel::sky_darken(&self.w) + } + fn thundering(&self) -> bool { + self.w.thundering + } + fn sea_level(&self) -> i32 { + self.w.sea_level() + } + fn world_surface(&self, x: i32, z: i32) -> i32 { + self.w.world_surface(x, z) + } + fn peaceful(&self) -> bool { + self.w.peaceful() + } + fn dimension(&self) -> Dimension { + self.w.dimension() + } + fn surface_slime_chance(&self, x: i32, y: i32, z: i32) -> f32 { + self.w.surface_slime_chance(x, y, z) + } + fn world_seed(&self) -> i64 { + self.w.world_seed + } + fn player_near(&self, x: f64, y: f64, z: f64, distance: f64) -> bool { + self.w.player_near(x, y, z, distance) + } +} +impl NaturalLevel for GameLevel<'_, '_> { + fn min_y(&self) -> i32 { + horizon_client::chunk::MIN_Y + } + fn max_y(&self) -> i32 { + horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT - 1 + } + fn ambient_light(&self) -> f32 { + match self.game.dim { + Dim::Nether => 0.1, + Dim::End => 0.25, + _ => 0.0, + } + } + fn has_ceiling(&self) -> bool { + self.game.dim == Dim::Nether + } + fn mob_type(&self, entity: &str) -> Option<(Species, f32, f32, Category)> { + let t = self.game.res.mob_types.get(entity)?; + Some(( + t.species, + t.width as f32, + t.height as f32, + Category::from_name(&t.category), + )) + } + fn biome_spawns(&self, x: i32, y: i32, z: i32) -> Option<&HashMap>> { + self.biome(x, y, z).map(|b| &b.spawns) + } + fn spawn_cost(&self, entity: &str, x: i32, y: i32, z: i32) -> Option { + self.biome(x, y, z)?.spawn_costs.get(entity).copied() + } + fn creature_probability(&self, x: i32, y: i32, z: i32) -> f32 { + self.biome(x, y, z).map_or(0.1, |b| b.creature_probability) + } + fn structures_at(&self, x: i32, y: i32, z: i32) -> Vec { + self.game + .structures_reaching([x, y, z]) + .into_iter() + .map(|(id, in_piece)| StructureAt { + id, + in_piece, + in_bounds: true, + }) + .collect() + } + fn structure_spawns(&self) -> &StructureSpawns { + StructureSpawns::shared(&self.game.options.resources) + } + fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option { + let p = self.game.player.as_ref()?; + if self.game.spectator() { + return None; + } + let d = [p.position.x - x, p.position.y - y, p.position.z - z]; + Some(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]) + } + fn respawn(&self) -> Option<[i32; 3]> { + // The world spawn point lives in the overworld. + if self.game.dim != Dim::Overworld { + return None; + } + self.game.level.spawn.map(|s| { + [ + s[0].floor() as i32, + s[1].floor() as i32, + s[2].floor() as i32, + ] + }) + } + fn can_spawn_in_chunk(&self, cx: i32, cz: i32) -> bool { + self.game.world.chunks.contains_key(&(cx, cz)) + } + fn no_collision(&self, bounds: Aabb) -> bool { + let mut scene = SceneCollisions { + world: &self.game.world, + binding: self.w.collision, + }; + no_collision_empty(&mut scene, bounds) + } + fn unobstructed(&self, bounds: Aabb) -> bool { + let player = self + .game + .player_view() + .is_some_and(|p| p.alive && !p.spectator && p.bounds.intersects(bounds)); + !player + && !self + .game + .mobs + .iter() + .any(|m| m.alive() && !m.removed && m.bounds().intersects(bounds)) + } + fn spawn_mobs(&self) -> bool { + self.game.ui.rules.bool("spawn_mobs") + } + fn heightmap(&self, kind: Heightmap, x: i32, z: i32) -> i32 { + let top = horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT; + (horizon_client::chunk::MIN_Y..top) + .rev() + .find(|&y| heightmap_blocks(self.w.facts, self.w.data, kind, self.state(x, y, z))) + .map_or(horizon_client::chunk::MIN_Y, |y| y + 1) + } + fn pathfindable_land(&self, x: i32, y: i32, z: i32) -> bool { + self.w.data.state(self.state(x, y, z)).flags & data::LAND != 0 + } +} +/// `Heightmap.Types` predicates: WORLD_SURFACE (not air), OCEAN_FLOOR +/// (`blocksMotion`), MOTION_BLOCKING (or a fluid) and NO_LEAVES. +pub fn heightmap_blocks( + facts: &BlockFacts, + data: &MobData, + kind: Heightmap, + state: BlockStateId, +) -> bool { + let f = facts.get(state); + let blocks_motion = data.state(state).flags & data::SOLID != 0 + && &*f.name != "minecraft:cobweb" + && &*f.name != "minecraft:bamboo_sapling"; + let fluid = f.fluid != horizon_client::blocks::Fluid::None; + match kind { + Heightmap::WorldSurface => !facts.is_air(state), + Heightmap::OceanFloor => blocks_motion, + Heightmap::MotionBlocking => blocks_motion || fluid, + Heightmap::MotionBlockingNoLeaves => { + (blocks_motion || fluid) && !f.name.ends_with("_leaves") + } + } +} + +impl<'a> Game<'a> { + fn natural_level(&self) -> GameLevel<'_, 'a> { + GameLevel { + game: self, + w: self.mob_world_base(&[]), + } + } + /// Creates a mob the spawner accepted, finalized the game's way; the + /// caller adds it with [`Game::add_spawned`]. + fn finalize_spawned(&self, r: &SpawnRequest, pending: &mut Vec) -> Option { + let kind = self.res.mob_types.get(r.entity)?.clone(); + let [x, y, z] = r.position; + let mut mob = Mob::new(0, kind, Vec3::new(x, y, z), r.yaw); + let facts = self.biome_facts(x.floor() as i32, y.floor() as i32, z.floor() as i32); + let difficulty = self.local_difficulty(r.position); + horizon_client::mobs::spawning::finalize(&mut mob, &facts, &difficulty); + horizon_client::mobs::raider::finalize( + &mut mob, + horizon_client::mobs::raider::Reason::Natural, + ); + let bounds = mob.bounds(); + pending.push(mob); + Some(Spawned { + bounds, + school: true, + }) + } + /// Adds the mobs a spawn pass created: ids, enchanted gear and jockeys. + fn add_spawned(&mut self, pending: Vec, persistent: bool) { + for mut mob in pending { + self.next_entity += 1; + mob.id = self.next_entity; + mob.persistent = persistent; + let id = mob.id; + self.mobs.push(mob); + self.enchant_spawn_equipment(id); + if !persistent { + self.spawn_jockeys(id, true); + } + } + } + /// `ServerChunkCache.tickChunks`: the spawn state over the loaded mobs, + /// the categories under their global caps (creatures every 400 ticks), + /// then `spawnForChunk` in the shuffled chunks within 128 blocks of the + /// player. + pub(super) fn spawn_natural(&mut self) { + let Some(p) = self.player.as_ref().map(|p| p.position) else { + return; + }; + let (pcx, pcz) = (p.x.floor() as i32 >> 4, p.z.floor() as i32 >> 4); + // ChunkMap.collectSpawningChunks: tracked chunks whose centre is + // within 128 blocks of a non-spectator. + let spectator = self.spectator(); + let mut chunks: Vec<(i32, i32)> = (-8..=8) + .flat_map(|dx| (-8..=8).map(move |dz| (pcx + dx, pcz + dz))) + .filter(|&(cx, cz)| { + let (x, z) = (f64::from(cx * 16 + 8) - p.x, f64::from(cz * 16 + 8) - p.z); + !spectator && x * x + z * z < 16384.0 && self.world.chunks.contains_key(&(cx, cz)) + }) + .collect(); + let spawn_mobs = self.ui.rules.bool("spawn_mobs"); + let spawn_enemies = self.ui.rules.bool("spawn_monsters"); + let mut random = std::mem::replace(&mut self.world.random, LegacyRandom::new(0)); + let mut pending = Vec::new(); + { + let level = self.natural_level(); + let counted: Vec = self + .mobs + .iter() + .map(|m| Counted { + entity: &m.kind.id, + category: Category::from_name(&m.kind.category), + block: [ + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ], + is_mob: true, + persistent: m.persistent, + }) + .collect(); + let players = vec![SpawnPlayer { + position: [p.x, p.y, p.z], + spectator, + }]; + // A single player's natural spawn chunk tracker covers 17 x 17 chunks. + let mut state = SpawnState::new(&level, 289, players, &counted, |cx, cz| { + self.world.chunks.contains_key(&(cx, cz)) + }); + let categories = if spawn_mobs { + state.filtered_categories(spawn_enemies, self.world.game_time % 400 == 0) + } else { + Vec::new() + }; + // Util.shuffle. + for i in (1..chunks.len()).rev() { + let j = random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize; + chunks.swap(i, j); + } + if !categories.is_empty() { + for chunk in chunks { + let mut spawn = + |l: &GameLevel, r: &SpawnRequest| l.game.finalize_spawned(r, &mut pending); + spawn_selection::spawn_for_chunk( + &level, + &mut random, + &mut state, + &categories, + chunk, + &mut spawn, + &mut |_| {}, + ); + } + } + } + self.world.random = random; + self.add_spawned(pending, false); + } + /// `NoiseBasedChunkGenerator.spawnOriginalMobs`: creature packs for a + /// freshly generated chunk, from the biome at its corner column's top, + /// with the chunk's decoration-seeded random (mobs are not generated in + /// the End). + pub(super) fn spawn_generation_creatures(&mut self, cx: i32, cz: i32) { + if !self.world.chunks.contains_key(&(cx, cz)) || self.dim == Dim::End { + return; + } + let mut random = WorldgenRandom::new(LegacyRandom::new(0)); + random.set_decoration_seed(self.level.seed, cx * 16, cz * 16); + // The region's own random: no creature's spawn predicate draws from it. + let mut rules = LegacyRandom::new(self.level.seed ^ (i64::from(cx) << 32 | i64::from(cz))); + let mut pending = Vec::new(); + { + let level = self.natural_level(); + let source = [cx * 16, level.max_y(), cz * 16]; + let mut spawn = + |l: &GameLevel, r: &SpawnRequest| l.game.finalize_spawned(r, &mut pending); + spawn_selection::spawn_mobs_for_chunk_generation( + &level, + source, + (cx, cz), + &mut random, + &mut rules, + &mut spawn, + &mut |_| {}, + ); + } + self.add_spawned(pending, true); + } +} diff --git a/crates/horizon-client/src/game/structures.rs b/crates/horizon-client/src/game/structures.rs index 1b2199d4d..e941f2fd9 100644 --- a/crates/horizon-client/src/game/structures.rs +++ b/crates/horizon-client/src/game/structures.rs @@ -6,7 +6,7 @@ use super::Game; impl Game<'_> { /// Structures reaching `pos`: (id, inside a piece). `getStructureAt` /// tests the start's whole box, `getStructureWithPieceAt` a piece's. - fn structures_reaching(&self, pos: [i32; 3]) -> Vec<(String, bool)> { + pub(super) fn structures_reaching(&self, pos: [i32; 3]) -> Vec<(String, bool)> { let Some(chunk) = self.world.chunks.get(&(pos[0] >> 4, pos[2] >> 4)) else { return Vec::new(); }; @@ -39,28 +39,4 @@ impl Game<'_> { .map(|(n, _)| n) .collect() } - /// `NaturalSpawner.mobsAt`: the fortress bounds on nether bricks, the - /// structure `spawn_overrides` whose box (or piece) holds `pos`, else the - /// biome's list. - pub(super) fn spawn_list( - &self, - category: &str, - pos: [i32; 3], - biome: &std::collections::HashMap>, - ) -> Vec { - use horizon_client::mobs::spawn_selection::{StructureAt, StructureSpawns, mobs_at}; - let structures: Vec = self - .structures_reaching(pos) - .into_iter() - .map(|(id, in_piece)| StructureAt { - id, - in_piece, - in_bounds: true, - }) - .collect(); - let below = self.world.block(pos[0], pos[1] - 1, pos[2]); - let bricks = &*self.res.facts.get(below).name == "minecraft:nether_bricks"; - let spawns = StructureSpawns::shared(&self.options.resources); - mobs_at(spawns, biome, category, &structures, bricks) - } } diff --git a/crates/horizon-client/src/mobs/data.rs b/crates/horizon-client/src/mobs/data.rs index dc9e391aa..48fc4234f 100644 --- a/crates/horizon-client/src/mobs/data.rs +++ b/crates/horizon-client/src/mobs/data.rs @@ -43,6 +43,8 @@ pub enum Placement { pub struct MobData { pub states: Vec, pub placements: HashMap, + /// Each entity type's `SpawnPlacements.getHeightmapType`. + pub heightmaps: HashMap, pub animations: HashMap, /// Biome tags (spawns_warm_variant_farm_animals, is_ocean...) by tag id. pub biome_tags: HashMap>, @@ -109,6 +111,12 @@ impl MobData { (k.clone(), placement) }) .collect(); + let heightmaps = v["placements"] + .as_object() + .into_iter() + .flatten() + .filter_map(|(k, p)| Some((k.clone(), p["heightmap"].as_str()?.to_owned()))) + .collect(); let animations = v["animations"] .as_object() .into_iter() @@ -118,6 +126,7 @@ impl MobData { Self { states, placements, + heightmaps, animations, biome_tags: HashMap::new(), entity_tags: HashMap::new(), @@ -171,6 +180,7 @@ impl MobData { Self { states, placements: HashMap::new(), + heightmaps: HashMap::new(), animations: HashMap::new(), biome_tags: HashMap::new(), entity_tags: HashMap::new(), @@ -186,6 +196,12 @@ impl MobData { top: 1.0, }) } + /// `SpawnPlacements.getHeightmapType` (MOTION_BLOCKING_NO_LEAVES unless registered). + pub fn heightmap(&self, entity: &str) -> &str { + self.heightmaps + .get(entity) + .map_or("MOTION_BLOCKING_NO_LEAVES", String::as_str) + } pub fn placement(&self, entity: &str) -> Placement { self.placements .get(entity) diff --git a/crates/horizon-client/src/mobs/spawn_selection.rs b/crates/horizon-client/src/mobs/spawn_selection.rs index fba1cb8fa..0e6f777a0 100644 --- a/crates/horizon-client/src/mobs/spawn_selection.rs +++ b/crates/horizon-client/src/mobs/spawn_selection.rs @@ -1,9 +1,24 @@ -//! `NaturalSpawner.getRandomSpawnMobAt` and `mobsAt`: which spawn list a -//! position uses (the nether fortress bounds, then structure spawn overrides -//! by piece or bounding box, then the biome's natural mob spawns), the -//! reduced water ambient odds, the weighted pick and the group size sample. +//! `NaturalSpawner`: which mob spawns where and when. The spawn state +//! (`createState`: category counts, the spawn potential's charges, the +//! per-player local caps), the filtered categories and their global and local +//! caps, `spawnForChunk` and `spawnCategoryForPosition` (the three packs of up +//! to four attempts, the distance and respawn checks, `getRandomSpawnMobAt` +//! over `mobsAt`: the nether fortress bounds, structure spawn overrides by +//! piece or bounding box, then the biome's natural mob spawns), the per-type +//! checks of `isValidSpawnPostitionForType` and `isValidPositionForMob` +//! (each class's `checkSpawnRules` walk target value, `checkSpawnObstruction`, +//! `removeWhenFarAway`, cluster and group sizes), and +//! `spawnMobsForChunkGeneration`. Everything reads the level through +//! [`NaturalLevel`] so the game and the differential test world +//! (tools/test_natural_spawn_parity.py) run the same code; random draws follow +//! vanilla's order. +use super::Species; +use super::spawn_rules::{self, Reason, SpawnLevel}; use crate::biome::SpawnEntry; -use horizon_core::random::RandomSource; +use horizon_core::{ + geometry::{Aabb, Vec3}, + random::RandomSource, +}; use serde_json::Value; use std::{collections::HashMap, fs, path::Path}; @@ -39,6 +54,529 @@ pub fn parse_entries(list: &Value) -> Vec { }) .collect() } +/// `MobSpawnSettings.MobSpawnCost`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SpawnCost { + pub energy_budget: f64, + pub charge: f64, +} +/// The `spawn_costs` of a `natural_mob_spawns` value. +pub fn parse_costs(costs: &Value) -> HashMap { + costs + .as_object() + .into_iter() + .flatten() + .map(|(k, c)| { + ( + k.clone(), + SpawnCost { + energy_budget: c["energy_budget"].as_f64().unwrap_or(0.0), + charge: c["charge"].as_f64().unwrap_or(0.0), + }, + ) + }) + .collect() +} + +/// `MobCategory`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Category { + Monster, + Creature, + Ambient, + Axolotls, + UndergroundWaterCreature, + WaterCreature, + WaterAmbient, + Misc, +} +impl Category { + pub const ALL: [Self; 8] = [ + Self::Monster, + Self::Creature, + Self::Ambient, + Self::Axolotls, + Self::UndergroundWaterCreature, + Self::WaterCreature, + Self::WaterAmbient, + Self::Misc, + ]; + /// `NaturalSpawner.SPAWNING_CATEGORIES`: all but MISC, in enum order. + pub const SPAWNING: [Self; 7] = [ + Self::Monster, + Self::Creature, + Self::Ambient, + Self::Axolotls, + Self::UndergroundWaterCreature, + Self::WaterCreature, + Self::WaterAmbient, + ]; + pub fn name(self) -> &'static str { + match self { + Self::Monster => "monster", + Self::Creature => "creature", + Self::Ambient => "ambient", + Self::Axolotls => "axolotls", + Self::UndergroundWaterCreature => "underground_water_creature", + Self::WaterCreature => "water_creature", + Self::WaterAmbient => "water_ambient", + Self::Misc => "misc", + } + } + pub fn from_name(name: &str) -> Self { + Self::ALL + .into_iter() + .find(|c| c.name() == name) + .unwrap_or(Self::Misc) + } + fn index(self) -> usize { + self as usize + } + /// `getMaxInstancesPerChunk`. + pub fn max(self) -> i32 { + match self { + Self::Monster => 70, + Self::Creature => 10, + Self::Ambient => 15, + Self::Axolotls | Self::UndergroundWaterCreature | Self::WaterCreature => 5, + Self::WaterAmbient => 20, + Self::Misc => -1, + } + } + pub fn friendly(self) -> bool { + self != Self::Monster + } + pub fn persistent(self) -> bool { + matches!(self, Self::Creature | Self::Misc) + } + pub fn despawn_distance(self) -> i32 { + if self == Self::WaterAmbient { 64 } else { 128 } + } +} + +/// `PathfinderMob.checkSpawnRules`: the class's `getWalkTargetValue` at the +/// spawn position must not be negative (plain `Mob`s accept any position). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum WalkTarget { + NotPathfinder, + Zero, + Animal, + Mooshroom, + Turtle, + Monster, + Giant, + Guardian, + Silverfish, + Strider, + Hoglin, + Bee, + HappyGhast, +} +fn walk_target(s: Species) -> WalkTarget { + use Species as S; + use WalkTarget as W; + match s { + S::Bat | S::Slime | S::MagmaCube | S::SulfurCube | S::Ghast | S::Phantom => { + W::NotPathfinder + } + // Monster subclasses that override it with 0. + S::Enderman | S::Creaking | S::Pillager | S::Warden => W::Zero, + S::Giant => W::Giant, + S::Guardian | S::ElderGuardian => W::Guardian, + S::Silverfish => W::Silverfish, + S::Zombie + | S::Husk + | S::Drowned + | S::ZombieVillager + | S::ZombifiedPiglin + | S::Skeleton + | S::Stray + | S::Bogged + | S::Parched + | S::WitherSkeleton + | S::Creeper + | S::Spider + | S::CaveSpider + | S::Endermite + | S::Witch + | S::Vindicator + | S::Evoker + | S::Illusioner + | S::Ravager + | S::Piglin + | S::PiglinBrute + | S::Zoglin + | S::Blaze + | S::Vex + | S::Breeze + | S::Wither => W::Monster, + S::Mooshroom => W::Mooshroom, + S::Turtle => W::Turtle, + S::Strider => W::Strider, + S::Hoglin => W::Hoglin, + S::Bee => W::Bee, + S::HappyGhast => W::HappyGhast, + // Animal subclasses with their own value. + S::Axolotl | S::Nautilus | S::ZombieNautilus => W::Zero, + S::Pig + | S::Cow + | S::Sheep + | S::Chicken + | S::Rabbit + | S::Horse + | S::Donkey + | S::Mule + | S::Llama + | S::TraderLlama + | S::SkeletonHorse + | S::ZombieHorse + | S::Camel + | S::CamelHusk + | S::Goat + | S::Panda + | S::PolarBear + | S::Fox + | S::Wolf + | S::Cat + | S::Ocelot + | S::Parrot + | S::Armadillo + | S::Frog + | S::Sniffer => W::Animal, + // Other PathfinderMobs: water animals, golems, villagers, allays... + _ => W::Zero, + } +} +/// Each class's `checkSpawnObstruction`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Obstruction { + /// `Mob`: no liquid in the box and no entity blocking it. + NoLiquidUnobstructed, + Unobstructed, + Ocelot, + NoLiquid, +} +fn obstruction(s: Species) -> Obstruction { + use Species as S; + match s { + S::Squid + | S::GlowSquid + | S::Dolphin + | S::Axolotl + | S::Cod + | S::Salmon + | S::TropicalFish + | S::Pufferfish + | S::Tadpole + | S::Nautilus + | S::ZombieNautilus + | S::Guardian + | S::ElderGuardian + | S::Strider + | S::Drowned => Obstruction::Unobstructed, + S::Ocelot => Obstruction::Ocelot, + S::Ravager => Obstruction::NoLiquid, + // ZombifiedPiglin's override is the Mob default's two tests; iron + // golems and wardens never spawn naturally. + _ => Obstruction::NoLiquidUnobstructed, + } +} +/// `Mob.removeWhenFarAway` of a freshly created mob. +fn removed_when_far(s: Species) -> bool { + use Species as S; + match s { + // Animal, golems, villagers, traders, allays and wardens never; + // cats and ocelots only past 2400 ticks; chickens as jockeys. + S::Axolotl + | S::Cod + | S::Salmon + | S::TropicalFish + | S::Pufferfish + | S::Tadpole + | S::CamelHusk + | S::ZombieHorse + | S::Hoglin + | S::Nautilus + | S::ZombieNautilus => true, + S::Villager + | S::WanderingTrader + | S::IronGolem + | S::SnowGolem + | S::Shulker + | S::Allay + | S::Warden => false, + _ => !is_animal(s), + } +} +fn is_animal(s: Species) -> bool { + use Species as S; + matches!(walk_target(s), WalkTarget::Animal) + || matches!( + s, + S::Axolotl + | S::Nautilus + | S::ZombieNautilus + | S::Mooshroom + | S::Turtle + | S::Strider + | S::Hoglin + | S::Bee + | S::HappyGhast + ) +} +/// `Mob.getMaxSpawnClusterSize`. +pub fn max_cluster_size(s: Species) -> u32 { + use Species as S; + match s { + S::Horse + | S::Donkey + | S::Mule + | S::Llama + | S::TraderLlama + | S::SkeletonHorse + | S::ZombieHorse + | S::Camel + | S::CamelHusk => 6, + S::Cod | S::TropicalFish | S::Pufferfish | S::Tadpole | S::Wolf => 8, + S::Salmon => 5, + S::Ghast | S::HappyGhast | S::Pillager => 1, + _ => 4, + } +} +/// `EntityType.canSpawnFarFromPlayer`: creatures, misc, pillagers, shulkers. +fn spawns_far_from_player(s: Species, category: Category) -> bool { + matches!(category, Category::Creature | Category::Misc) + || matches!(s, Species::Pillager | Species::Shulker) +} + +/// What natural spawning reads from a level beyond the spawn rules. +pub trait NaturalLevel: SpawnLevel { + fn min_y(&self) -> i32; + /// `getMaxY()`: the highest buildable block. + fn max_y(&self) -> i32; + /// `DimensionType.ambientLight`. + fn ambient_light(&self) -> f32; + fn has_ceiling(&self) -> bool; + /// The entity type's species, width, height and category. + fn mob_type(&self, entity: &str) -> Option<(Species, f32, f32, Category)>; + /// The `natural_mob_spawns` lists by category at a position. + fn biome_spawns(&self, x: i32, y: i32, z: i32) -> Option<&HashMap>>; + /// The `natural_mob_spawns` spawn cost of a type at a position. + fn spawn_cost(&self, entity: &str, x: i32, y: i32, z: i32) -> Option; + /// The `creature_world_gen_spawn_probability` attribute at a position. + fn creature_probability(&self, x: i32, y: i32, z: i32) -> f32; + /// Every structure whose references reach the position's chunk, in the + /// chunk's reference order. + fn structures_at(&self, x: i32, y: i32, z: i32) -> Vec; + fn structure_spawns(&self) -> &StructureSpawns; + /// `getNearestPlayer(x, y, z, -1, false)`: the squared distance to the + /// nearest player that is not a spectator. + fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option; + /// The world spawn point, when it is in this dimension. + fn respawn(&self) -> Option<[i32; 3]>; + /// `ServerLevel.canSpawnEntitiesInChunk`. + fn can_spawn_in_chunk(&self, cx: i32, cz: i32) -> bool; + /// `noCollision(null, box)`: blocks under an empty collision context and + /// entities that can be collided with. + fn no_collision(&self, bounds: Aabb) -> bool; + /// `isUnobstructed(entity)`: no entity blocking building overlaps. + fn unobstructed(&self, bounds: Aabb) -> bool; + /// The `spawn_mobs` game rule. + fn spawn_mobs(&self) -> bool; + /// `getHeight(heightmap, x, z)`. + fn heightmap(&self, kind: Heightmap, x: i32, z: i32) -> i32; + /// `isPathfindable(PathComputationType.LAND)` of the block. + fn pathfindable_land(&self, x: i32, y: i32, z: i32) -> bool; +} + +/// The heightmap types `SpawnPlacements` registers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Heightmap { + WorldSurface, + OceanFloor, + MotionBlocking, + MotionBlockingNoLeaves, +} +impl Heightmap { + pub fn from_name(name: &str) -> Self { + match name { + "WORLD_SURFACE" => Self::WorldSurface, + "OCEAN_FLOOR" => Self::OceanFloor, + "MOTION_BLOCKING" => Self::MotionBlocking, + _ => Self::MotionBlockingNoLeaves, + } + } +} + +/// `LevelReader.getLightLevelDependentMagicValue`. +fn magic_light(l: &L, x: i32, y: i32, z: i32) -> f32 { + let raw = l + .block_light(x, y, z) + .max(l.sky_light(x, y, z) - l.sky_darken()); + let v = raw as f32 / 15.0; + let curved = v / (4.0 - 3.0 * v); + curved + l.ambient_light() * (1.0 - curved) +} +fn cost_from_light(l: &L, x: i32, y: i32, z: i32) -> f32 { + magic_light(l, x, y, z) - 0.5 +} +fn name_at(l: &L, x: i32, y: i32, z: i32) -> &str { + &l.facts().get(l.state(x, y, z)).name +} +fn fluid_at(l: &L, x: i32, y: i32, z: i32) -> crate::blocks::Fluid { + l.facts().get(l.state(x, y, z)).fluid +} +/// `checkSpawnRules(level, NATURAL)` of a freshly created mob at a block. +fn mob_spawn_rules(s: Species, l: &L, x: i32, y: i32, z: i32) -> bool { + use crate::blocks::Fluid; + let value = match walk_target(s) { + WalkTarget::NotPathfinder | WalkTarget::Zero => 0.0, + WalkTarget::Animal => { + if name_at(l, x, y - 1, z) == "minecraft:grass_block" { + 10.0 + } else { + cost_from_light(l, x, y, z) + } + } + WalkTarget::Mooshroom => { + if name_at(l, x, y - 1, z) == "minecraft:mycelium" { + 10.0 + } else { + cost_from_light(l, x, y, z) + } + } + WalkTarget::Turtle => { + if fluid_at(l, x, y, z) == Fluid::Water + || l.block_tag("minecraft:sand", name_at(l, x, y - 1, z)) + { + 10.0 + } else { + cost_from_light(l, x, y, z) + } + } + WalkTarget::Monster => -cost_from_light(l, x, y, z), + WalkTarget::Giant => cost_from_light(l, x, y, z), + WalkTarget::Guardian => { + if fluid_at(l, x, y, z) == Fluid::Water { + 10.0 + cost_from_light(l, x, y, z) + } else { + -cost_from_light(l, x, y, z) + } + } + WalkTarget::Silverfish => { + if infested_host(name_at(l, x, y - 1, z)) { + 10.0 + } else { + -cost_from_light(l, x, y, z) + } + } + // A new strider is not in lava yet. + WalkTarget::Strider => { + if fluid_at(l, x, y, z) == Fluid::Lava { + 10.0 + } else { + 0.0 + } + } + // A new hoglin remembers no repellent. + WalkTarget::Hoglin => { + if name_at(l, x, y - 1, z) == "minecraft:crimson_nylium" { + 10.0 + } else { + 0.0 + } + } + WalkTarget::Bee => { + if l.facts().is_air(l.state(x, y, z)) { + 10.0 + } else { + 0.0 + } + } + WalkTarget::HappyGhast => { + let air = |dy| l.facts().is_air(l.state(x, y + dy, z)); + if !air(0) { + 0.0 + } else if air(-1) && !air(-2) { + 10.0 + } else { + 5.0 + } + } + }; + value >= 0.0 +} +/// `InfestedBlock.isCompatibleHostBlock`. +fn infested_host(block: &str) -> bool { + matches!( + block, + "minecraft:stone" + | "minecraft:cobblestone" + | "minecraft:stone_bricks" + | "minecraft:mossy_stone_bricks" + | "minecraft:cracked_stone_bricks" + | "minecraft:chiseled_stone_bricks" + | "minecraft:deepslate" + ) +} +/// `LevelReader.containsAnyLiquid`: the blocks from the box's minimum corner +/// to the block holding its maximum corner. +pub fn contains_any_liquid(l: &L, b: Aabb) -> bool { + let (lo, hi) = (b.min_position(), b.max_position()); + let (x0, y0, z0) = ( + lo.x.floor() as i32, + lo.y.floor() as i32, + lo.z.floor() as i32, + ); + let (x1, y1, z1) = ( + hi.x.floor() as i32, + hi.y.floor() as i32, + hi.z.floor() as i32, + ); + (x0..=x1).any(|x| { + (y0..=y1).any(|y| { + (z0..=z1).any(|z| l.facts().get(l.state(x, y, z)).fluid != crate::blocks::Fluid::None) + }) + }) +} +/// `EntityDimensions.makeBoundingBox`: the half width is a float. +pub fn spawn_box(x: f64, y: f64, z: f64, width: f32, height: f32) -> Aabb { + let half = f64::from(width / 2.0); + Aabb::new( + Vec3::new(x - half, y, z - half), + Vec3::new(x + half, y + f64::from(height), z + half), + ) +} +/// `checkSpawnObstruction` of a freshly created mob with the given box. +fn spawn_obstruction( + s: Species, + l: &L, + bounds: Aabb, + spawned: &[Aabb], + x: i32, + y: i32, + z: i32, +) -> bool { + let unobstructed = + |l: &L| l.unobstructed(bounds) && !spawned.iter().any(|b| b.intersects(bounds)); + match obstruction(s) { + Obstruction::NoLiquidUnobstructed => !contains_any_liquid(l, bounds) && unobstructed(l), + Obstruction::Unobstructed => unobstructed(l), + Obstruction::NoLiquid => !contains_any_liquid(l, bounds), + Obstruction::Ocelot => { + if unobstructed(l) && !contains_any_liquid(l, bounds) { + if y < l.sea_level() { + return false; + } + let below = name_at(l, x, y - 1, z); + if below == "minecraft:grass_block" || l.block_tag("minecraft:leaves", below) { + return true; + } + } + false + } + } +} /// A structure's spawn override for one category. #[derive(Clone, Debug)] @@ -97,7 +635,7 @@ impl StructureSpawns { } } -/// A structure whose references reach a position. +/// A structure whose references reach a position's chunk. #[derive(Clone, Debug)] pub struct StructureAt { pub id: String, @@ -125,32 +663,36 @@ pub fn fortress_enemies() -> Vec { ] } -/// `NaturalSpawner.mobsAt`. -pub fn mobs_at( - spawns: &StructureSpawns, - biome: &HashMap>, - category: &str, - structures: &[StructureAt], - below_nether_bricks: bool, +/// `NaturalSpawner.mobsAt`: the fortress bounds on nether bricks, then +/// `ChunkGenerator.getMobsAt` (the first referenced structure whose override +/// for the category holds the position), else the biome's list. +pub fn mobs_at( + l: &L, + category: Category, + x: i32, + y: i32, + z: i32, ) -> Vec { - // isInNetherFortressBounds. - if category == "monster" - && below_nether_bricks + let structures = l.structures_at(x, y, z); + if category == Category::Monster + && name_at(l, x, y - 1, z) == "minecraft:nether_bricks" && structures .iter() .any(|s| s.id == "minecraft:fortress" && s.in_bounds) { return fortress_enemies(); } - // ChunkGenerator.getMobsAt: the first structure whose override holds. - for s in structures { - if let Some(o) = spawns.get(&s.id, category) + for s in &structures { + if let Some(o) = l.structure_spawns().get(&s.id, category.name()) && (if o.piece { s.in_piece } else { s.in_bounds }) { return o.spawns.clone(); } } - biome.get(category).cloned().unwrap_or_default() + l.biome_spawns(x, y, z) + .and_then(|b| b.get(category.name())) + .cloned() + .unwrap_or_default() } /// `WeightedList.getRandom`. @@ -191,3 +733,582 @@ pub fn sample_count(entry: &SpawnEntry, random: &mut impl RandomSource) -> u32 { entry.min + random.next_int_bound(span).unwrap_or(0) as u32 } } +/// `SpawnerData.equals`: the type and the count provider (the weight is not +/// part of it; a constant and a one-value uniform differ). +fn same_spawner(a: &SpawnEntry, b: &SpawnEntry) -> bool { + a.entity == b.entity && a.constant == b.constant && a.min == b.min && a.max == b.max +} + +/// An entity counted by `NaturalSpawner.createState`. +#[derive(Clone, Debug)] +pub struct Counted<'a> { + pub entity: &'a str, + pub category: Category, + /// `blockPosition()`. + pub block: [i32; 3], + pub is_mob: bool, + /// `isPersistenceRequired() || requiresCustomPersistence()`. + pub persistent: bool, +} +/// A player as the spawn caps see it. +#[derive(Clone, Copy, Debug)] +pub struct SpawnPlayer { + pub position: [f64; 3], + pub spectator: bool, +} + +/// `NaturalSpawner.SpawnState` with its `PotentialCalculator` and +/// `LocalMobCapCalculator`. +#[derive(Clone, Debug)] +pub struct SpawnState { + spawnable_chunks: i32, + counts: [i32; 8], + charges: Vec<([i32; 3], f64)>, + players: Vec, + players_near: HashMap<(i32, i32), Vec>, + player_counts: HashMap, + last_checked: Option<([i32; 3], String, f64)>, +} +impl SpawnState { + /// `NaturalSpawner.createState`: every non-persistent entity of a + /// spawning category in a loaded chunk adds its biome's spawn charge, its + /// category count and (for mobs) the local caps of the players near it. + pub fn new( + l: &L, + spawnable_chunks: i32, + players: Vec, + entities: &[Counted], + chunk_loaded: impl Fn(i32, i32) -> bool, + ) -> Self { + let mut state = Self { + spawnable_chunks, + counts: [0; 8], + charges: Vec::new(), + players, + players_near: HashMap::new(), + player_counts: HashMap::new(), + last_checked: None, + }; + for e in entities { + if e.is_mob && e.persistent || e.category == Category::Misc { + continue; + } + let [x, y, z] = e.block; + if !chunk_loaded(x >> 4, z >> 4) { + continue; + } + if let Some(cost) = l.spawn_cost(e.entity, x, y, z) { + state.add_charge(e.block, cost.charge); + } + if e.is_mob { + state.add_local(x >> 4, z >> 4, e.category); + } + state.counts[e.category.index()] += 1; + } + state + } + fn add_charge(&mut self, pos: [i32; 3], charge: f64) { + if charge != 0.0 { + self.charges.push((pos, charge)); + } + } + /// `ChunkMap.getPlayersCloseForSpawning`: none past the natural spawn + /// chunk tracker's range of 8 chunks from every player, else the + /// non-spectators within 128 blocks of the chunk's centre. + fn players_near(&mut self, cx: i32, cz: i32) -> &[usize] { + let players = &self.players; + self.players_near.entry((cx, cz)).or_insert_with(|| { + let tracked = players.iter().any(|p| { + let (px, pz) = ( + (p.position[0].floor() as i32) >> 4, + (p.position[2].floor() as i32) >> 4, + ); + (px - cx).abs().max((pz - cz).abs()) <= 8 + }); + if !tracked { + return Vec::new(); + } + let (x, z) = (f64::from(cx * 16 + 8), f64::from(cz * 16 + 8)); + players + .iter() + .enumerate() + .filter(|(_, p)| { + let (dx, dz) = (x - p.position[0], z - p.position[2]); + !p.spectator && dx * dx + dz * dz < 16384.0 + }) + .map(|(i, _)| i) + .collect() + }) + } + fn add_local(&mut self, cx: i32, cz: i32, category: Category) { + let near = self.players_near(cx, cz).to_vec(); + for p in near { + self.player_counts.entry(p).or_insert([0; 8])[category.index()] += 1; + } + } + /// `canSpawnForCategoryGlobal`. + pub fn can_spawn_global(&self, category: Category) -> bool { + let max = category.max() * self.spawnable_chunks / 289; + self.counts[category.index()] < max + } + /// `canSpawnForCategoryLocal` (`LocalMobCapCalculator.canSpawn`). + pub fn can_spawn_local(&mut self, category: Category, cx: i32, cz: i32) -> bool { + let near = self.players_near(cx, cz).to_vec(); + near.into_iter().any(|p| { + self.player_counts + .get(&p) + .is_none_or(|c| c[category.index()] < category.max()) + }) + } + /// `getFilteredSpawningCategories`. + pub fn filtered_categories( + &self, + spawn_enemies: bool, + spawn_persistent: bool, + ) -> Vec { + Category::SPAWNING + .into_iter() + .filter(|&c| { + (spawn_enemies || c.friendly()) + && (spawn_persistent || !c.persistent()) + && self.can_spawn_global(c) + }) + .collect() + } + /// `SpawnState.canSpawn`: the spawn potential's energy change must stay + /// within the type's budget where it has a spawn cost. + fn can_spawn(&mut self, l: &L, entity: &str, pos: [i32; 3]) -> bool { + match l.spawn_cost(entity, pos[0], pos[1], pos[2]) { + None => { + self.last_checked = Some((pos, entity.to_owned(), 0.0)); + true + } + Some(cost) => { + self.last_checked = Some((pos, entity.to_owned(), cost.charge)); + self.energy_change(pos, cost.charge) <= cost.energy_budget + } + } + } + /// `PotentialCalculator.getPotentialEnergyChange`. + fn energy_change(&self, pos: [i32; 3], charge: f64) -> f64 { + if charge == 0.0 { + return 0.0; + } + let mut change = 0.0; + for (p, c) in &self.charges { + let d = [ + f64::from(p[0] - pos[0]), + f64::from(p[1] - pos[1]), + f64::from(p[2] - pos[2]), + ]; + let dist = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; + change += if dist == 0.0 { + f64::INFINITY + } else { + c / dist.sqrt() + }; + } + change * charge + } + /// `SpawnState.afterSpawn`. + fn after_spawn( + &mut self, + l: &L, + entity: &str, + category: Category, + pos: [i32; 3], + ) { + let charge = match &self.last_checked { + Some((p, t, c)) if *p == pos && t == entity => *c, + _ => l + .spawn_cost(entity, pos[0], pos[1], pos[2]) + .map_or(0.0, |c| c.charge), + }; + self.add_charge(pos, charge); + self.counts[category.index()] += 1; + self.add_local(pos[0] >> 4, pos[2] >> 4, category); + } +} + +/// What a spawn pass did, in vanilla's order (for differential tests). +#[derive(Clone, Debug, PartialEq)] +pub enum Event { + /// `getFilteredSpawningCategories`. + Categories(Vec), + /// `canSpawnForCategoryLocal` for the chunk. + Local(Category, bool), + /// `getRandomPosWithin`'s start. + Start(Category, [i32; 3]), + /// An attempt at a position (the nearest player lookup). + Try(i32, i32), + /// The type passed `isValidSpawnPostitionForType`; the spawn state's + /// potential test answered. + Test(String, [i32; 3], bool), + /// A mob spawned. + Spawn(String, [f64; 3], f32), +} +/// A mob the spawner is about to add: the level runs `finalizeSpawn` and +/// adds it, returning its finalized box and whether a tropical fish schools. +#[derive(Clone, Debug)] +pub struct SpawnRequest<'a> { + pub entity: &'a str, + pub species: Species, + pub position: [f64; 3], + pub yaw: f32, + /// Mobs of this group spawned before (the `SpawnGroupData`'s size). + pub group_index: u32, + pub reason: Reason, +} +#[derive(Clone, Copy, Debug)] +pub struct Spawned { + pub bounds: Aabb, + /// `TropicalFish.isSchool`. + pub school: bool, +} + +/// `NaturalSpawner.spawnForChunk` for the categories already filtered. +#[allow(clippy::too_many_arguments)] +pub fn spawn_for_chunk( + l: &L, + random: &mut R, + state: &mut SpawnState, + categories: &[Category], + chunk: (i32, i32), + spawn: &mut dyn FnMut(&L, &SpawnRequest) -> Option, + trace: &mut dyn FnMut(Event), +) { + for &category in categories { + let local = state.can_spawn_local(category, chunk.0, chunk.1); + trace(Event::Local(category, local)); + if local { + spawn_category_for_chunk(l, random, state, category, chunk, spawn, trace); + } + } +} +/// `NaturalSpawner.spawnCategoryForChunk`: `getRandomPosWithin` (a column of +/// the chunk, a height up to one above the world surface). +pub fn spawn_category_for_chunk( + l: &L, + random: &mut R, + state: &mut SpawnState, + category: Category, + chunk: (i32, i32), + spawn: &mut dyn FnMut(&L, &SpawnRequest) -> Option, + trace: &mut dyn FnMut(Event), +) { + let x = chunk.0 * 16 + random.next_int_bound(16).unwrap_or(0); + let z = chunk.1 * 16 + random.next_int_bound(16).unwrap_or(0); + let top = l.heightmap(Heightmap::WorldSurface, x, z) + 1; + let min = l.min_y(); + let y = min + random.next_int_bound(top - min + 1).unwrap_or(0); + trace(Event::Start(category, [x, y, z])); + if y >= min + 1 { + spawn_category_for_position(l, random, state, category, chunk, [x, y, z], spawn, trace); + } +} +/// `NaturalSpawner.spawnCategoryForPosition` with the spawn state's +/// `canSpawn` and `afterSpawn`. +#[allow(clippy::too_many_arguments)] +pub fn spawn_category_for_position( + l: &L, + random: &mut R, + state: &mut SpawnState, + category: Category, + chunk: (i32, i32), + start: [i32; 3], + spawn: &mut dyn FnMut(&L, &SpawnRequest) -> Option, + trace: &mut dyn FnMut(Event), +) { + let [x0, y, z0] = start; + if l.mob_data().state(l.state(x0, y, z0)).flags & super::data::REDSTONE_CONDUCTOR != 0 { + return; + } + let mut spawned: Vec = Vec::new(); + let mut cluster = 0u32; + for _ in 0..3 { + let (mut x, mut z) = (x0, z0); + let mut current: Option = None; + let mut group_index = 0u32; + let mut max = (random.next_float() * 4.0).ceil() as i32; + let mut ll = 0; + while ll < max { + ll += 1; + x += random.next_int_bound(6).unwrap_or(0) - random.next_int_bound(6).unwrap_or(0); + z += random.next_int_bound(6).unwrap_or(0) - random.next_int_bound(6).unwrap_or(0); + let (xx, zz) = (f64::from(x) + 0.5, f64::from(z) + 0.5); + trace(Event::Try(x, z)); + let Some(distance) = l.nearest_player_distance_sqr(xx, f64::from(y), zz) else { + continue; + }; + if !right_distance(l, chunk, [x, y, z], distance) { + continue; + } + if current.is_none() { + let reduced = l.biome_tag("minecraft:reduced_water_ambient_spawns", x, y, z); + let list = if category == Category::WaterAmbient + && reduced + && random.next_float() < 0.98 + { + None + } else { + weighted_pick(&mobs_at(l, category, x, y, z), random) + }; + let Some(entry) = list else { + break; + }; + max = sample_count(&entry, random) as i32; + current = Some(entry); + } + let entry = current.as_ref().unwrap_or_else(|| unreachable!()); + if !valid_position_for_type(l, random, category, entry, [x, y, z], distance) { + continue; + } + let passed = state.can_spawn(l, &entry.entity, [x, y, z]); + trace(Event::Test(entry.entity.clone(), [x, y, z], passed)); + if !passed { + continue; + } + // getMobForSpawn: a type the game cannot create ends the pass. + let Some((species, width, height, type_category)) = l.mob_type(&entry.entity) else { + return; + }; + let yaw = random.next_float() * 360.0; + let bounds = spawn_box(xx, f64::from(y), zz, width, height); + let despawn = f64::from(type_category.despawn_distance().pow(2)); + let far = distance > despawn && removed_when_far(species); + if far + || !mob_spawn_rules(species, l, x, y, z) + || !spawn_obstruction(species, l, bounds, &spawned, x, y, z) + { + continue; + } + let request = SpawnRequest { + entity: &entry.entity, + species, + position: [xx, f64::from(y), zz], + yaw, + group_index, + reason: Reason::Natural, + }; + let Some(result) = spawn(l, &request) else { + continue; + }; + group_index += 1; + spawned.push(result.bounds); + cluster += 1; + state.after_spawn(l, &entry.entity, type_category, [x, y, z]); + trace(Event::Spawn( + entry.entity.clone(), + [xx, f64::from(y), zz], + yaw, + )); + if cluster >= max_cluster_size(species) { + return; + } + // TropicalFish.isMaxGroupSizeReached. + if species == Species::TropicalFish && !result.school { + break; + } + } + } +} +/// `isRightDistanceToPlayerAndSpawnPoint`. +fn right_distance(l: &L, chunk: (i32, i32), pos: [i32; 3], distance: f64) -> bool { + if distance <= 576.0 { + return false; + } + if let Some(r) = l.respawn() { + let d = [ + f64::from(r[0]) + 0.5 - (f64::from(pos[0]) + 0.5), + f64::from(r[1]) + 0.5 - f64::from(pos[1]), + f64::from(r[2]) + 0.5 - (f64::from(pos[2]) + 0.5), + ]; + if d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < 24.0 * 24.0 { + return false; + } + } + let (cx, cz) = (pos[0] >> 4, pos[2] >> 4); + (cx, cz) == chunk || l.can_spawn_in_chunk(cx, cz) +} +/// `isValidSpawnPostitionForType`. +fn valid_position_for_type( + l: &L, + random: &mut R, + category: Category, + entry: &SpawnEntry, + pos: [i32; 3], + distance: f64, +) -> bool { + let Some((species, width, height, type_category)) = l.mob_type(&entry.entity) else { + return false; + }; + if type_category == Category::Misc { + return false; + } + let despawn = f64::from(type_category.despawn_distance().pow(2)); + if !spawns_far_from_player(species, type_category) && distance > despawn { + return false; + } + let [x, y, z] = pos; + if !mobs_at(l, category, x, y, z) + .iter() + .any(|e| same_spawner(e, entry)) + { + return false; + } + if !spawn_rules::is_spawn_position_ok(species, l, x, y, z) { + return false; + } + if !spawn_rules::check_spawn_rules(species, l, Reason::Natural, random, x, y, z) { + return false; + } + l.no_collision(spawn_box( + f64::from(x) + 0.5, + f64::from(y), + f64::from(z) + 0.5, + width, + height, + )) +} + +/// What chunk generation spawning did (for differential tests). +#[derive(Clone, Debug, PartialEq)] +pub enum GenEvent { + /// A pack: its type and count. + Pack(String, u32), + /// An attempt at the top non-colliding position of a column. + Try([i32; 3]), + Spawn(String, [f64; 3], f32), +} +/// `NaturalSpawner.spawnMobsForChunkGeneration`: creature packs placed on +/// the chunk's surface. `rules` is the level's own random, which the spawn +/// predicates draw from. +#[allow(clippy::too_many_arguments)] +pub fn spawn_mobs_for_chunk_generation( + l: &L, + source: [i32; 3], + chunk: (i32, i32), + random: &mut R, + rules: &mut Q, + spawn: &mut dyn FnMut(&L, &SpawnRequest) -> Option, + trace: &mut dyn FnMut(GenEvent), +) { + if !l.spawn_mobs() { + return; + } + let [sx, sy, sz] = source; + let mobs = l + .biome_spawns(sx, sy, sz) + .and_then(|b| b.get("creature")) + .cloned() + .unwrap_or_default(); + if mobs.iter().map(|e| e.weight).sum::() == 0 { + return; + } + let probability = l.creature_probability(sx, sy, sz); + let (xo, zo) = (chunk.0 * 16, chunk.1 * 16); + while random.next_float() < probability { + let Some(entry) = weighted_pick(&mobs, random) else { + continue; + }; + let count = sample_count(&entry, random); + trace(GenEvent::Pack(entry.entity.clone(), count)); + let mut group_index = 0u32; + let mut spawned: Vec = Vec::new(); + let mut x = xo + random.next_int_bound(16).unwrap_or(0); + let mut z = zo + random.next_int_bound(16).unwrap_or(0); + let (start_x, start_z) = (x, z); + let kind = l.mob_type(&entry.entity); + for _ in 0..count { + let mut success = false; + let mut attempts = 0; + while !success && attempts < 4 { + attempts += 1; + let Some((species, width, height, _)) = kind else { + break; + }; + let pos = top_non_colliding(l, &entry.entity, x, z); + trace(GenEvent::Try(pos)); + if spawn_rules::is_spawn_position_ok(species, l, pos[0], pos[1], pos[2]) { + let fx = f64::from(x).clamp( + f64::from(xo) + f64::from(width), + f64::from(xo) + 16.0 - f64::from(width), + ); + let fz = f64::from(z).clamp( + f64::from(zo) + f64::from(width), + f64::from(zo) + 16.0 - f64::from(width), + ); + let fy = f64::from(pos[1]); + let (bx, bz) = (fx.floor() as i32, fz.floor() as i32); + if !l.no_collision(spawn_box(fx, fy, fz, width, height)) + || !spawn_rules::check_spawn_rules( + species, + l, + Reason::ChunkGeneration, + rules, + bx, + pos[1], + bz, + ) + { + continue; + } + let yaw = random.next_float() * 360.0; + let bounds = spawn_box(fx, fy, fz, width, height); + if mob_spawn_rules(species, l, bx, pos[1], bz) + && spawn_obstruction(species, l, bounds, &spawned, bx, pos[1], bz) + { + let request = SpawnRequest { + entity: &entry.entity, + species, + position: [fx, fy, fz], + yaw, + group_index, + reason: Reason::ChunkGeneration, + }; + if let Some(result) = spawn(l, &request) { + group_index += 1; + spawned.push(result.bounds); + trace(GenEvent::Spawn(entry.entity.clone(), [fx, fy, fz], yaw)); + success = true; + } + } + } + x += random.next_int_bound(5).unwrap_or(0) - random.next_int_bound(5).unwrap_or(0); + z += random.next_int_bound(5).unwrap_or(0) - random.next_int_bound(5).unwrap_or(0); + while x < xo || x >= xo + 16 || z < zo || z >= zo + 16 { + x = start_x + random.next_int_bound(5).unwrap_or(0) + - random.next_int_bound(5).unwrap_or(0); + z = start_z + random.next_int_bound(5).unwrap_or(0) + - random.next_int_bound(5).unwrap_or(0); + } + } + } + } +} +/// `getTopNonCollidingPos`: the type's heightmap, under the ceiling in +/// dimensions that have one, then the placement type's adjustment. +fn top_non_colliding(l: &L, entity: &str, x: i32, z: i32) -> [i32; 3] { + let heightmap = Heightmap::from_name(l.mob_data().heightmap(entity)); + let mut y = l.heightmap(heightmap, x, z); + if l.has_ceiling() { + loop { + y -= 1; + if l.facts().is_air(l.state(x, y, z)) { + break; + } + } + loop { + y -= 1; + if !(l.facts().is_air(l.state(x, y, z)) && y > l.min_y()) { + break; + } + } + } + if l.mob_data().placement(entity) == super::data::Placement::OnGround + && l.pathfindable_land(x, y - 1, z) + { + y -= 1; + } + [x, y, z] +} From e019d361d9fc266b0c2e075df6abfc14728b021b Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:43:57 +0200 Subject: [PATCH 3/8] WIP: natural spawn oracle and collision lookup (harness in progress) Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/collisions.rs | 88 +++ .../src/mobs/spawn_selection.rs | 9 +- .../parity/VanillaNaturalSpawnOracle.java | 520 ++++++++++++++++++ 3 files changed, 611 insertions(+), 6 deletions(-) create mode 100644 mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNaturalSpawnOracle.java diff --git a/crates/horizon-client/src/collisions.rs b/crates/horizon-client/src/collisions.rs index a7790c283..47b5d7981 100644 --- a/crates/horizon-client/src/collisions.rs +++ b/crates/horizon-client/src/collisions.rs @@ -183,3 +183,91 @@ impl PowderSnowWorld for SceneCollisions<'_, '_> { Ok(false) } } + +/// Collision queries over any block lookup (test worlds without chunks): +/// every chunk is present, nothing is suffocating, no entities collide. +pub struct LookupCollisions<'w, 'r> { + pub block: &'w dyn Fn(i32, i32, i32) -> horizon_world::registry::BlockStateId, + pub binding: &'w BlockCollisionRegistry<'r>, +} +impl BlockCollisionWorld for LookupCollisions<'_, '_> { + type Error = io::Error; + type Chunk = ChunkPos; + fn collision_chunk(&mut self, p: ChunkPos) -> io::Result> { + Ok(Some(p)) + } + fn collision_block(&mut self, _: ChunkPos, p: BlockPos) -> io::Result { + self.binding.collision_block((self.block)(p.x, p.y, p.z)) + } + fn is_suffocating(&mut self, _: ChunkPos, _: CollisionBlock, _: BlockPos) -> io::Result { + Ok(false) + } + fn context_shape( + &mut self, + block: CollisionBlock, + p: BlockPos, + context: &CollisionContext<'_>, + ) -> io::Result { + self.binding + .collision_shape_with_context(self, block.state, p, context) + .map_err(io::Error::other) + } +} +impl EntityMovementWorld for LookupCollisions<'_, '_> { + type Error = io::Error; + fn entity_collisions(&mut self, _: EntityIdentity, _: Aabb) -> io::Result> { + Ok(Vec::new()) + } + fn movement_border(&mut self, _: EntityIdentity, _: Aabb) -> io::Result> { + Ok(None) + } +} +impl DynamicSupportWorld for LookupCollisions<'_, '_> { + type Error = io::Error; + fn shulker_is_closed(&mut self, _: [i32; 3]) -> io::Result> { + Ok(None) + } + fn moving_piston_shape(&mut self, _: [i32; 3]) -> io::Result> { + Ok(None) + } +} +impl DynamicCollisionWorld for LookupCollisions<'_, '_> { + fn shulker_collision_state( + &mut self, + _: [i32; 3], + ) -> io::Result> { + Ok(None) + } +} +impl LiquidCollisionWorld for LookupCollisions<'_, '_> { + type Error = io::Error; + fn entity_liquid_shape(&mut self, _: EntityIdentity) -> io::Result { + Ok(VoxelShape::empty()) + } + fn fluid(&mut self, p: BlockPos) -> io::Result { + FluidState::from_block( + self.binding + .registry() + .state((self.block)(p.x, p.y, p.z)) + .ok_or_else(|| io::Error::other("fluid state outside registry"))?, + ) + } + fn entity_can_stand(&mut self, _: EntityIdentity, _: FluidState) -> io::Result { + Ok(false) + } +} +impl PowderSnowWorld for LookupCollisions<'_, '_> { + type Error = io::Error; + fn powder_snow_entity(&mut self, _: EntityIdentity) -> io::Result { + Ok(PowderSnowEntity { + fall_distance: 0.0, + falling_block: false, + }) + } + fn powder_snow_walkable_tag(&mut self, _: EntityIdentity) -> io::Result { + Ok(false) + } + fn feet_are_leather_boots(&mut self, _: EntityIdentity) -> io::Result { + Ok(false) + } +} diff --git a/crates/horizon-client/src/mobs/spawn_selection.rs b/crates/horizon-client/src/mobs/spawn_selection.rs index 0e6f777a0..d0ab2b67e 100644 --- a/crates/horizon-client/src/mobs/spawn_selection.rs +++ b/crates/horizon-client/src/mobs/spawn_selection.rs @@ -1174,10 +1174,8 @@ fn valid_position_for_type( /// What chunk generation spawning did (for differential tests). #[derive(Clone, Debug, PartialEq)] pub enum GenEvent { - /// A pack: its type and count. - Pack(String, u32), - /// An attempt at the top non-colliding position of a column. - Try([i32; 3]), + /// An attempt at a column (the heightmap lookup). + Column(i32, i32), Spawn(String, [f64; 3], f32), } /// `NaturalSpawner.spawnMobsForChunkGeneration`: creature packs placed on @@ -1212,7 +1210,6 @@ pub fn spawn_mobs_for_chunk_generation = Vec::new(); let mut x = xo + random.next_int_bound(16).unwrap_or(0); @@ -1228,7 +1225,7 @@ pub fn spawn_mobs_for_chunk_generation c) throws Exception { return allocate.invoke(unsafe, c); } + + static void put(Object target, Class owner, String field, Object value) throws Exception { + Field f = owner.getDeclaredField(field); + putObject.invoke(unsafe, target, (long) objectFieldOffset.invoke(unsafe, f), value); + } + + record Box(int x0, int y0, int z0, int x1, int y1, int z1) { + boolean has(int x, int y, int z) { return x >= x0 && x <= x1 && y >= y0 && y <= y1 && z >= z0 && z <= z1; } + static Box parse(String s) { + String[] p = s.split(","); + return new Box(Integer.parseInt(p[0]), Integer.parseInt(p[1]), Integer.parseInt(p[2]), + Integer.parseInt(p[3]), Integer.parseInt(p[4]), Integer.parseInt(p[5])); + } + } + record Start(Structure structure, StructureStart start) {} + + /** finalizeSpawn's random: the same answer every time. */ + static final class Neutral implements RandomSource { + @Override public RandomSource fork() { return this; } + @Override public net.minecraft.world.level.levelgen.PositionalRandomFactory forkPositional() { throw new UnsupportedOperationException(); } + @Override public void setSeed(long seed) {} + @Override public int nextInt() { return 0; } + @Override public int nextInt(int bound) { return 0; } + @Override public long nextLong() { return 0L; } + @Override public boolean nextBoolean() { return false; } + @Override public float nextFloat() { return 0.75F; } + @Override public double nextDouble() { return 0.75; } + @Override public double nextGaussian() { return 0.0; } + } + + static final class Lvl extends ServerLevel { + Lvl() { super(null, null, null, null, null, null, false, 0L, List.of(), false); } + RegistryAccess registries; + List> blocks = new ArrayList<>(); + List> lights = new ArrayList<>(); + List>> biomes = new ArrayList<>(); + Holder biome; + int sky, block, darken, sea; + boolean thunder; + long seed; + Difficulty difficulty; + DimensionType dimensionType; + ResourceKey dimensionKey; + LevelData.RespawnData respawn; + Set unloaded = new HashSet<>(); + List starts = new ArrayList<>(); + List entities = new ArrayList<>(); + List playerList = new ArrayList<>(); + net.minecraft.world.attribute.EnvironmentAttributeSystem attributes; + BiomeManager biomeManager; + WorldBorder border = new WorldBorder(); + GameRules rules; + ServerChunkCache chunkSource; + StructureManager structures; + RandomSource neutral = new Neutral(); + List log; + boolean logColumns; + int next = 1; + @Override public RegistryAccess registryAccess() { return registries; } + @Override public Difficulty getDifficulty() { return difficulty; } + @Override public long getGameTime() { return 0L; } + @Override public int getNextEntityId() { return next++; } + @Override public net.minecraft.world.flag.FeatureFlagSet enabledFeatures() { return net.minecraft.world.flag.FeatureFlags.REGISTRY.allFlags(); } + @Override public BlockState getBlockState(BlockPos pos) { + for (int i = blocks.size() - 1; i >= 0; i--) + if (blocks.get(i).getKey().has(pos.getX(), pos.getY(), pos.getZ())) return blocks.get(i).getValue(); + return Blocks.AIR.defaultBlockState(); + } + @Override public FluidState getFluidState(BlockPos pos) { return getBlockState(pos).getFluidState(); } + int[] light(BlockPos pos) { + for (int i = lights.size() - 1; i >= 0; i--) + if (lights.get(i).getKey().has(pos.getX(), pos.getY(), pos.getZ())) return lights.get(i).getValue(); + return new int[] {sky, block}; + } + @Override public int getBrightness(LightLayer layer, BlockPos pos) { int[] l = light(pos); return layer == LightLayer.SKY ? l[0] : l[1]; } + @Override public int getRawBrightness(BlockPos pos, int darkening) { int[] l = light(pos); return Math.max(l[1], l[0] - darkening); } + @Override public int getSkyDarken() { return darken; } + @Override public boolean isThundering() { return thunder; } + @Override public int getSeaLevel() { return sea; } + @Override public int getHeight(Heightmap.Types type, int x, int z) { + if (logColumns) log.add("col " + x + " " + z); + for (int y = getMaxY(); y >= getMinY(); y--) + if (type.isOpaque().test(getBlockState(new BlockPos(x, y, z)))) return y + 1; + return getMinY(); + } + @Override public long getSeed() { return seed; } + @Override public DimensionType dimensionType() { return dimensionType; } + @Override public ResourceKey dimension() { return dimensionKey; } + Holder biomeAt(int x, int y, int z) { + for (int i = biomes.size() - 1; i >= 0; i--) + if (biomes.get(i).getKey().has(x, y, z)) return biomes.get(i).getValue(); + return biome; + } + @Override public Holder getBiome(BlockPos pos) { return biomeAt(pos.getX(), pos.getY(), pos.getZ()); } + @Override public BiomeManager getBiomeManager() { return biomeManager; } + @Override public net.minecraft.world.attribute.EnvironmentAttributeSystem environmentAttributes() { return attributes; } + @Override public WorldBorder getWorldBorder() { return border; } + @Override public GameRules getGameRules() { return rules; } + @Override public RandomSource getRandom() { return neutral; } + @Override public ServerChunkCache getChunkSource() { return chunkSource; } + @Override public StructureManager structureManager() { return structures; } + @Override public LevelData.RespawnData getRespawnData() { return respawn; } + @Override public boolean canSpawnEntitiesInChunk(ChunkPos pos) { return !unloaded.contains(pos.pack()); } + @Override public DifficultyInstance getCurrentDifficultyAt(BlockPos pos) { return new DifficultyInstance(difficulty, 0L, 0L, 0.0F); } + @Override public BlockGetter getChunkForCollisions(int chunkX, int chunkZ) { return this; } + @Override public boolean containsAnyLiquid(AABB box) { + BlockPos lo = BlockPos.containing(box.minX, box.minY, box.minZ), hi = BlockPos.containing(box.maxX, box.maxY, box.maxZ); + for (BlockPos p : BlockPos.betweenClosed(lo, hi)) if (!getBlockState(p).getFluidState().isEmpty()) return true; + return false; + } + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override public List players() { return playerList; } + @Override public Iterable getAllEntities() { return entities; } + @Override public List getEntities(Entity except, AABB bb, Predicate selector) { + List out = new ArrayList<>(); + for (Entity e : entities) if (e != except && e.getBoundingBox().intersects(bb) && selector.test(e)) out.add(e); + return out; + } + @Override public List getEntities(EntityTypeTest type, AABB bb, Predicate selector) { + List out = new ArrayList<>(); + for (Entity e : entities) { + T t = type.tryCast(e); + if (t != null && e.getBoundingBox().intersects(bb) && selector.test(t)) out.add(t); + } + return out; + } + @Override public boolean addFreshEntity(Entity entity) { entities.add(entity); return true; } + @Override public void addFreshEntityWithPassengers(Entity entity) { entity.getSelfAndPassengers().forEach(this::addFreshEntity); } + @Override public Player getNearestPlayer(double x, double y, double z, double maxDist, boolean filter) { + if (maxDist == -1.0 && !filter) log.add("try " + (int) Math.floor(x) + " " + (int) Math.floor(z)); + return super.getNearestPlayer(x, y, z, maxDist, filter); + } + } + + static final class Chunk extends LevelChunk { + Chunk() { super((Level) null, (ChunkPos) null); } + Lvl level; + ChunkPos at; + @Override public ChunkPos getPos() { return at; } + @Override public BlockState getBlockState(BlockPos pos) { return level.getBlockState(pos); } + @Override public FluidState getFluidState(BlockPos pos) { return level.getFluidState(pos); } + @Override public int getHeight(Heightmap.Types type, int x, int z) { return level.getHeight(type, x, z); } + } + + static final class Source extends ServerChunkCache { + Source() { super(null, null, null, null, null, null, 0, 0, false, null); } + ChunkGenerator generator; + @Override public ChunkGenerator getGenerator() { return generator; } + } + + /** getPlayersCloseForSpawning: none past the natural spawn chunk tracker's 8 chunks, else non-spectators within 128 blocks of the chunk centre. */ + static final class Map2 extends ChunkMap { + Map2() { super(null, null, null, null, null, null, null, null, null, null, 0, false); } + Lvl level; + @Override public List getPlayersCloseForSpawning(ChunkPos pos) { + boolean tracked = false; + for (ServerPlayer p : level.playerList) { + int px = Mth.floor(p.getX()) >> 4, pz = Mth.floor(p.getZ()) >> 4; + if (Math.max(Math.abs(px - pos.x()), Math.abs(pz - pos.z())) <= 8) tracked = true; + } + if (!tracked) return List.of(); + List out = new ArrayList<>(); + for (ServerPlayer p : level.playerList) { + double xd = pos.x() * 16 + 8 - p.getX(), zd = pos.z() * 16 + 8 - p.getZ(); + if (!p.isSpectator() && xd * xd + zd * zd < 16384.0) out.add(p); + } + return out; + } + } + + static final class Structures extends StructureManager { + Structures() { super(null, null, null); } + Lvl level; + List referenced(int x, int z) { + int cx = x >> 4, cz = z >> 4; + List out = new ArrayList<>(); + for (Start s : level.starts) { + BoundingBox b = s.start().getBoundingBox(); + if (b.intersects(cx * 16, cz * 16, cx * 16 + 15, cz * 16 + 15)) out.add(s); + } + return out; + } + @Override public Map getAllStructuresAt(int x, int y, int z) { + Map out = new LinkedHashMap<>(); + for (Start s : referenced(x, z)) out.computeIfAbsent(s.structure(), k -> new LongOpenHashSet()).add(level.starts.indexOf(s)); + return out; + } + @Override public void fillStartsForStructure(Structure structure, LongSet references, Consumer consumer) { + for (long i : references) { + StructureStart start = level.starts.get((int) i).start(); + if (start.isValid()) consumer.accept(start); + } + } + @Override public List startsForStructure(int sectionX, int sectionZ, Structure structure) { + List out = new ArrayList<>(); + for (Start s : referenced(sectionX << 4, sectionZ << 4)) if (s.structure() == structure) out.add(s.start()); + return out; + } + @Override public RegistryAccess registryAccess() { return level.registries; } + } + + static final class Piece extends StructurePiece { + Piece(BoundingBox box) { super(null, 0, box); } + @Override protected void addAdditionalSaveData(StructurePieceSerializationContext context, CompoundTag tag) {} + @Override public void postProcess(WorldGenLevel level, StructureManager structureManager, ChunkGenerator generator, RandomSource random, + BoundingBox chunkBB, ChunkPos chunkPos, BlockPos referencePos) {} + } + + static final class OraclePlayer extends ServerPlayer { + OraclePlayer() { super(null, null, null, null); } + GameType mode; + @Override public boolean isSpectator() { return mode == GameType.SPECTATOR; } + @Override public boolean isCreative() { return mode == GameType.CREATIVE; } + @Override public GameType gameMode() { return mode; } + } + + static String bits(double v) { return Long.toHexString(Double.doubleToRawLongBits(v)); } + static String bits(float v) { return Integer.toHexString(Float.floatToRawIntBits(v)); } + + public static void main(String[] args) throws Exception { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + var unsafeField = Class.forName("sun.misc.Unsafe").getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + unsafe = unsafeField.get(null); + allocate = unsafe.getClass().getMethod("allocateInstance", Class.class); + putObject = unsafe.getClass().getMethod("putObject", Object.class, long.class, Object.class); + objectFieldOffset = unsafe.getClass().getMethod("objectFieldOffset", Field.class); + var repo = ServerPacksSource.createVanillaTrustedRepository(); + repo.reload(); + repo.setSelected(List.of("vanilla")); + var resources = new MultiPackResourceManager(PackType.SERVER_DATA, repo.openAllSelected()); + var layers = RegistryLayer.createRegistryAccess(); + var staticTags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC)); + var context = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLD), staticTags); + RegistryAccess.Frozen world = RegistryDataLoader.load(resources, context, RegistryDataLoader.WORLD_REGISTRIES, Runnable::run).join(); + staticTags.forEach(Registry.PendingTags::apply); + var access = layers.replaceFrom(RegistryLayer.WORLD, world).compositeAccess(); + var biomes = access.lookupOrThrow(Registries.BIOME); + var dimensions = access.lookupOrThrow(Registries.DIMENSION_TYPE); + var blockLookup = access.lookupOrThrow(Registries.BLOCK); + var structureLookup = access.lookupOrThrow(Registries.STRUCTURE); + + Lvl level = (Lvl) alloc(Lvl.class); + level.registries = access; + level.blocks = new ArrayList<>(); + level.lights = new ArrayList<>(); + level.biomes = new ArrayList<>(); + level.unloaded = new HashSet<>(); + level.starts = new ArrayList<>(); + level.entities = new ArrayList<>(); + level.playerList = new ArrayList<>(); + level.border = new WorldBorder(); + level.neutral = new Neutral(); + level.rules = new GameRules(net.minecraft.world.flag.FeatureFlags.REGISTRY.allFlags()); + level.biomeManager = new BiomeManager((x, y, z, sampler) -> level.biome, 0L) { + @Override public Holder getBiome(BlockPos pos) { return level.biomeAt(pos.getX(), pos.getY(), pos.getZ()); } + @Override public Holder getBiome(int x, int y, int z) { return level.biomeAt(x, y, z); } + }; + put(level, Level.class, "isClientSide", false); + Source source = (Source) alloc(Source.class); + source.generator = (ChunkGenerator) alloc(FlatLevelSource.class); + level.chunkSource = source; + Structures structures = (Structures) alloc(Structures.class); + structures.level = level; + level.structures = structures; + Map2 chunkMap = (Map2) alloc(Map2.class); + chunkMap.level = level; + + Method createState = NaturalSpawner.class.getMethod("createState", int.class, ServerLevel.class, NaturalSpawner.ChunkGetter.class, LocalMobCapCalculator.class); + Method canSpawn = NaturalSpawner.SpawnState.class.getDeclaredMethod("canSpawn", EntityType.class, Level.class, BlockPos.class, net.minecraft.world.level.chunk.ChunkAccess.class); + canSpawn.setAccessible(true); + Method afterSpawn = NaturalSpawner.SpawnState.class.getDeclaredMethod("afterSpawn", Mob.class, net.minecraft.world.level.chunk.ChunkAccess.class); + afterSpawn.setAccessible(true); + Method local = NaturalSpawner.SpawnState.class.getDeclaredMethod("canSpawnForCategoryLocal", MobCategory.class, ChunkPos.class); + local.setAccessible(true); + Method randomPos = NaturalSpawner.class.getDeclaredMethod("getRandomPosWithin", Level.class, LevelChunk.class); + randomPos.setAccessible(true); + Field position = Entity.class.getDeclaredField("position"); + + List lines = Files.readAllLines(Path.of(args[0])); + try (var w = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8))) { + for (String raw : lines) { + if (raw.isBlank()) continue; + Map c = new LinkedHashMap<>(); + for (String token : raw.trim().split("\\s+")) { + int eq = token.indexOf('='); + if (eq < 0) c.put("name", token); else c.put(token.substring(0, eq), token.substring(eq + 1)); + } + List log = new ArrayList<>(); + level.log = log; + level.logColumns = false; + try { + level.dimensionKey = ResourceKey.create(Registries.DIMENSION, Identifier.parse(c.getOrDefault("dim", "minecraft:overworld"))); + level.dimensionType = dimensions.getOrThrow(ResourceKey.create(Registries.DIMENSION_TYPE, level.dimensionKey.identifier())).value(); + level.blocks.clear(); + for (int i = 0; c.containsKey("k" + i); i++) { + String[] p = c.get("k" + i).split("/", 2); + level.blocks.add(Map.entry(Box.parse(p[0]), net.minecraft.commands.arguments.blocks.BlockStateParser.parseForBlock(blockLookup, p[1], false).blockState())); + } + level.lights.clear(); + for (int i = 0; c.containsKey("l" + i); i++) { + String[] p = c.get("l" + i).split("/", 2); + String[] v = p[1].split(","); + level.lights.add(Map.entry(Box.parse(p[0]), new int[] {Integer.parseInt(v[0]), Integer.parseInt(v[1])})); + } + level.biome = biomes.getOrThrow(ResourceKey.create(Registries.BIOME, Identifier.parse(c.getOrDefault("biome", "minecraft:plains")))); + level.biomes.clear(); + for (int i = 0; c.containsKey("b" + i); i++) { + String[] p = c.get("b" + i).split("/", 2); + level.biomes.add(Map.entry(Box.parse(p[0]), biomes.getOrThrow(ResourceKey.create(Registries.BIOME, Identifier.parse(p[1]))))); + } + level.sky = Integer.parseInt(c.getOrDefault("sky", "15")); + level.block = Integer.parseInt(c.getOrDefault("light", "0")); + level.darken = Integer.parseInt(c.getOrDefault("darken", "0")); + level.thunder = c.getOrDefault("thunder", "0").equals("1"); + level.sea = Integer.parseInt(c.getOrDefault("sea", "63")); + level.seed = Long.parseLong(c.getOrDefault("wseed", "0")); + level.difficulty = Difficulty.byName(c.getOrDefault("difficulty", "normal")); + level.rules.set(net.minecraft.world.level.gamerules.GameRules.SPAWN_MOBS, !c.getOrDefault("rules", "1").equals("0"), null); + level.attributes = net.minecraft.world.attribute.EnvironmentAttributeSystem.builder().addStaticLayers(level).build(); + level.unloaded.clear(); + if (c.containsKey("unloaded")) for (String u : c.get("unloaded").split(";")) { + String[] p = u.split(","); + level.unloaded.add(ChunkPos.pack(Integer.parseInt(p[0]), Integer.parseInt(p[1]))); + } + level.starts.clear(); + for (int i = 0; c.containsKey("s" + i); i++) { + String[] p = c.get("s" + i).split("/", 2); + Structure structure = structureLookup.getValue(Identifier.parse(p[0])); + List pieces = new ArrayList<>(); + for (String b : p[1].split(";")) { + Box bx = Box.parse(b); + pieces.add(new Piece(new BoundingBox(bx.x0(), bx.y0(), bx.z0(), bx.x1(), bx.y1(), bx.z1()))); + } + level.starts.add(new Start(structure, new StructureStart(structure, new ChunkPos(0, 0), 0, new PiecesContainer(pieces)))); + } + String[] r = c.getOrDefault("respawn", "0,-1000,0/minecraft:overworld").split("/"); + String[] rp = r[0].split(","); + level.respawn = new LevelData.RespawnData(GlobalPos.of(ResourceKey.create(Registries.DIMENSION, Identifier.parse(r[1])), + new BlockPos(Integer.parseInt(rp[0]), Integer.parseInt(rp[1]), Integer.parseInt(rp[2]))), 0.0F, 0.0F); + level.playerList.clear(); + for (int i = 0; c.containsKey("p" + i); i++) { + String[] p = c.get("p" + i).split("/"); + String[] v = p[0].split(","); + OraclePlayer player = (OraclePlayer) alloc(OraclePlayer.class); + player.mode = GameType.byName(p.length > 1 ? p[1] : "survival"); + put(player, Entity.class, "position", new Vec3(Double.parseDouble(v[0]), Double.parseDouble(v[1]), Double.parseDouble(v[2]))); + level.playerList.add(player); + } + level.entities.clear(); + for (int i = 0; c.containsKey("e" + i); i++) { + String[] p = c.get("e" + i).split("/"); + String[] v = p[1].split(","); + Entity e = BuiltInRegistries.ENTITY_TYPE.getValue(Identifier.parse(p[0])).create(level, EntitySpawnReason.LOAD); + e.setPos(Double.parseDouble(v[0]), Double.parseDouble(v[1]), Double.parseDouble(v[2])); + if (p.length > 2 && p[2].equals("p") && e instanceof Mob mob) mob.setPersistenceRequired(); + level.entities.add(e); + } + put(level, Level.class, "random", RandomSource.create(Long.parseLong(c.getOrDefault("seed", "0")))); + String mode = c.getOrDefault("mode", "chunk"); + String[] cp = c.getOrDefault("chunk", "0,0").split(","); + Chunk chunk = (Chunk) alloc(Chunk.class); + chunk.level = level; + chunk.at = new ChunkPos(Integer.parseInt(cp[0]), Integer.parseInt(cp[1])); + if (mode.equals("gen")) { + level.logColumns = true; + int n = level.entities.size(); + RandomSource random = RandomSource.create(Long.parseLong(c.getOrDefault("gseed", "0"))); + BlockPos sourcePos = new BlockPos(chunk.at.getMinBlockX(), level.getMaxY(), chunk.at.getMinBlockZ()); + NaturalSpawner.spawnMobsForChunkGeneration(level, sourcePos, chunk.at, random); + level.logColumns = false; + for (Entity e : level.entities.subList(n, level.entities.size())) + if (e.getVehicle() == null) + log.add("spawn " + BuiltInRegistries.ENTITY_TYPE.getKey(e.getType()) + " " + bits(e.getX()) + " " + bits(e.getY()) + " " + bits(e.getZ()) + " " + bits(e.getYRot())); + log.add("next " + random.nextInt(1000)); + } else { + Map chunks = new HashMap<>(); + NaturalSpawner.ChunkGetter getter = (key, consumer) -> { + if (level.unloaded.contains(key)) return; + Chunk k = chunks.computeIfAbsent(key, kk -> { + try { + Chunk made = (Chunk) alloc(Chunk.class); + made.level = level; + made.at = ChunkPos.unpack(kk); + return made; + } catch (Exception ex) { throw new RuntimeException(ex); } + }); + consumer.accept(k); + }; + var state = (NaturalSpawner.SpawnState) createState.invoke(null, Integer.parseInt(c.getOrDefault("chunkcount", "289")), level, getter, new LocalMobCapCalculator(chunkMap)); + NaturalSpawner.SpawnPredicate extra = (type, lvl, pos, ch) -> { + try { + boolean ok = (boolean) canSpawn.invoke(state, type, lvl, pos, ch); + log.add("test " + BuiltInRegistries.ENTITY_TYPE.getKey(type) + " " + pos.getX() + " " + pos.getY() + " " + pos.getZ() + " " + ok); + return ok; + } catch (Exception ex) { throw new RuntimeException(ex); } + }; + NaturalSpawner.AfterSpawnCallback after = (mob, ch) -> { + log.add("spawn " + BuiltInRegistries.ENTITY_TYPE.getKey(mob.getType()) + " " + bits(mob.getX()) + " " + bits(mob.getY()) + " " + bits(mob.getZ()) + " " + bits(mob.getYRot())); + try { afterSpawn.invoke(state, mob, ch); } catch (Exception ex) { throw new RuntimeException(ex); } + }; + if (mode.equals("pos")) { + MobCategory category = MobCategory.valueOf(c.get("cat").toUpperCase(Locale.ROOT)); + String[] sp = c.get("start").split(","); + BlockPos start = new BlockPos(Integer.parseInt(sp[0]), Integer.parseInt(sp[1]), Integer.parseInt(sp[2])); + NaturalSpawner.spawnCategoryForPosition(category, level, chunk, start, extra, after); + } else { + List categories = NaturalSpawner.getFilteredSpawningCategories(state, c.getOrDefault("enemies", "1").equals("1"), c.getOrDefault("persistent", "1").equals("1")); + StringBuilder names = new StringBuilder(); + for (MobCategory m : categories) names.append(names.isEmpty() ? "" : ",").append(m.getSerializedName()); + log.add("cats " + names); + // spawnForChunk, then spawnCategoryForChunk with its start logged. + for (MobCategory category : categories) { + boolean ok = (boolean) local.invoke(state, category, chunk.at); + log.add("local " + category.getSerializedName() + " " + ok); + if (!ok) continue; + BlockPos start = (BlockPos) randomPos.invoke(null, level, chunk); + log.add("start " + category.getSerializedName() + " " + start.getX() + " " + start.getY() + " " + start.getZ()); + if (start.getY() >= level.getMinY() + 1) NaturalSpawner.spawnCategoryForPosition(category, level, chunk, start, extra, after); + } + } + log.add("next " + level.random.nextInt(1000)); + } + w.println(c.get("name") + " " + String.join(" ; ", log)); + } catch (Throwable t) { + Throwable cause = t instanceof InvocationTargetException ite ? ite.getCause() : t; + StringBuilder line = new StringBuilder(c.get("name") + " error " + cause); + if (System.getenv("ORACLE_TRACE") != null) for (var frame : cause.getStackTrace()) line.append("\n at ").append(frame); + w.println(line); + } + } + } + System.exit(0); + } +} From a598255898236923a09b19908d4109e00661b42c Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:52:12 +0200 Subject: [PATCH 4/8] Add the natural spawn oracle and fixture VanillaNaturalSpawnOracle runs NaturalSpawner (createState, the filtered categories, spawnForChunk's loop, spawnCategoryForPosition and spawnMobsForChunkGeneration) on a stand-in level built from boxes of blocks, light and biomes with structure pieces, entities, players, the world spawn and unloaded chunks; natural_spawn_fixture runs mobs::spawn_selection on the same world. Co-Authored-By: Claude Opus 5.5 --- .../examples/natural_spawn_fixture.rs | 673 ++++++++++++++++++ .../src/game/natural_spawning.rs | 47 +- .../src/mobs/spawn_selection.rs | 18 + .../parity/VanillaNaturalSpawnOracle.java | 37 +- 4 files changed, 739 insertions(+), 36 deletions(-) create mode 100644 crates/horizon-client/examples/natural_spawn_fixture.rs diff --git a/crates/horizon-client/examples/natural_spawn_fixture.rs b/crates/horizon-client/examples/natural_spawn_fixture.rs new file mode 100644 index 000000000..84b84c121 --- /dev/null +++ b/crates/horizon-client/examples/natural_spawn_fixture.rs @@ -0,0 +1,673 @@ +//! Natural spawning decisions for differential tests against vanilla +//! (tools/test_natural_spawn_parity.py): reads the box-world cases of +//! `VanillaNaturalSpawnOracle` and prints what `mobs::spawn_selection` does in +//! the same order and format. +//! +//! The oracle runs `finalizeSpawn` with a fixed random (nextInt 0, nextFloat +//! 0.75); `neutral_box` is the box that leaves a mob with: polar bears, +//! ocelots and rabbits after the first of their group are babies (chance +//! 1.0), foxes and axolotls from the third, sulfur cubes have size 2, salmon +//! are small, slimes and magma cubes keep size 1, tropical fish school. +//! +//! usage: natural_spawn_fixture RESOURCES INPUT OUTPUT +use horizon_client::{ + assets::Assets, + biome::{Biomes, SpawnEntry}, + blocks::BlockFacts, + collisions::{self, LookupCollisions, no_collision_empty}, + items::Items, + mobs::{ + MobTypes, Species, + data::MobData, + spawn_rules::{Dimension, SpawnLevel}, + spawn_selection::{ + self, Category, Counted, Event, GenEvent, Heightmap, NaturalLevel, SpawnCost, + SpawnPlayer, SpawnRequest, SpawnState, Spawned, StructureAt, StructureSpawns, + spawn_box, + }, + species::{self, Baby}, + }, +}; +use horizon_core::{ + geometry::Aabb, + random::{LegacyRandom, RandomSource}, +}; +use horizon_world::{ + block_collision::BlockCollisionRegistry, chunk::BlockState, registry::BlockStateId, +}; +use serde_json::Value; +use std::{ + cell::RefCell, + collections::{BTreeMap, HashMap, HashSet}, + fmt::Write as _, + fs, + path::Path, +}; + +#[derive(Clone, Copy)] +struct Bx([i32; 6]); +impl Bx { + fn parse(s: &str) -> Self { + let v: Vec = s.split(',').map(|n| n.parse().unwrap_or(0)).collect(); + Self([v[0], v[1], v[2], v[3], v[4], v[5]]) + } + fn has(&self, x: i32, y: i32, z: i32) -> bool { + let b = self.0; + x >= b[0] && x <= b[3] && y >= b[1] && y <= b[4] && z >= b[2] && z <= b[5] + } +} +struct Start { + id: String, + pieces: Vec, + /// The start's bounding box (pieces encapsulated, inflated by 12 for + /// terrain adaptation). + bounds: Bx, +} + +struct Shared<'a> { + facts: &'a BlockFacts, + data: &'a MobData, + block_tags: &'a HashMap>, + types: &'a MobTypes, + biomes: &'a Biomes, + structures: &'a StructureSpawns, + collision: &'a BlockCollisionRegistry<'a>, + air: BlockStateId, +} +struct World<'a> { + s: &'a Shared<'a>, + blocks: Vec<(Bx, BlockStateId)>, + lights: Vec<(Bx, i32, i32)>, + biomes: Vec<(Bx, String)>, + biome: String, + sky: i32, + light: i32, + darken: i32, + thunder: bool, + sea: i32, + seed: i64, + peaceful: bool, + dimension: Dimension, + dimension_id: String, + slime: f32, + starts: Vec, + players: Vec<([f64; 3], String)>, + respawn: Option<[i32; 3]>, + unloaded: HashSet<(i32, i32)>, + spawn_mobs: bool, + /// Boxes of living entities (case entities, then spawned mobs). + entities: RefCell>, +} +impl World<'_> { + fn biome_at(&self, x: i32, y: i32, z: i32) -> &str { + self.biomes + .iter() + .rev() + .find(|(b, _)| b.has(x, y, z)) + .map_or(&self.biome, |(_, n)| n) + } + fn biome_info(&self, x: i32, y: i32, z: i32) -> Option<&horizon_client::biome::BiomeInfo> { + let i = *self.s.biomes.index.get(self.biome_at(x, y, z))?; + Some(self.s.biomes.get(i)) + } + fn lights_at(&self, x: i32, y: i32, z: i32) -> (i32, i32) { + self.lights + .iter() + .rev() + .find(|(b, ..)| b.has(x, y, z)) + .map_or((self.sky, self.light), |(_, s, b)| (*s, *b)) + } +} +impl SpawnLevel for World<'_> { + fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId { + self.blocks + .iter() + .rev() + .find(|(b, _)| b.has(x, y, z)) + .map_or(self.s.air, |(_, s)| *s) + } + fn facts(&self) -> &BlockFacts { + self.s.facts + } + fn mob_data(&self) -> &MobData { + self.s.data + } + fn block_tag(&self, tag: &str, block: &str) -> bool { + self.s + .block_tags + .get(tag) + .is_some_and(|s| s.contains(block)) + } + fn biome_tag(&self, tag: &str, x: i32, y: i32, z: i32) -> bool { + self.s + .data + .biome_tags + .get(tag) + .is_some_and(|s| s.contains(self.biome_at(x, y, z))) + } + fn sky_light(&self, x: i32, y: i32, z: i32) -> i32 { + self.lights_at(x, y, z).0 + } + fn block_light(&self, x: i32, y: i32, z: i32) -> i32 { + self.lights_at(x, y, z).1 + } + fn sky_darken(&self) -> i32 { + self.darken + } + fn thundering(&self) -> bool { + self.thunder + } + fn sea_level(&self) -> i32 { + self.sea + } + fn world_surface(&self, x: i32, z: i32) -> i32 { + self.heightmap(Heightmap::WorldSurface, x, z) + } + fn peaceful(&self) -> bool { + self.peaceful + } + fn dimension(&self) -> Dimension { + self.dimension + } + fn surface_slime_chance(&self, _: i32, _: i32, _: i32) -> f32 { + self.slime + } + fn world_seed(&self) -> i64 { + self.seed + } + fn player_near(&self, x: f64, y: f64, z: f64, distance: f64) -> bool { + self.players.iter().any(|(p, mode)| { + let d = (p[0] - x).powi(2) + (p[1] - y).powi(2) + (p[2] - z).powi(2); + (mode == "survival" || mode == "adventure") && d < distance * distance + }) + } +} +impl NaturalLevel for World<'_> { + fn min_y(&self) -> i32 { + if self.dimension == Dimension::Overworld { + -64 + } else { + 0 + } + } + fn max_y(&self) -> i32 { + if self.dimension == Dimension::Overworld { + 319 + } else { + 255 + } + } + fn ambient_light(&self) -> f32 { + match self.dimension { + Dimension::Overworld => 0.0, + Dimension::Nether => 0.1, + Dimension::End => 0.25, + } + } + fn has_ceiling(&self) -> bool { + self.dimension == Dimension::Nether + } + fn mob_type(&self, entity: &str) -> Option<(Species, f32, f32, Category)> { + let t = self.s.types.types.get(entity)?; + Some(( + t.species, + t.width as f32, + t.height as f32, + Category::from_name(&t.category), + )) + } + fn biome_spawns(&self, x: i32, y: i32, z: i32) -> Option<&HashMap>> { + self.biome_info(x, y, z).map(|b| &b.spawns) + } + fn spawn_cost(&self, entity: &str, x: i32, y: i32, z: i32) -> Option { + self.biome_info(x, y, z)?.spawn_costs.get(entity).copied() + } + fn creature_probability(&self, x: i32, y: i32, z: i32) -> f32 { + self.biome_info(x, y, z) + .map_or(0.1, |b| b.creature_probability) + } + fn structures_at(&self, x: i32, y: i32, z: i32) -> Vec { + let (cx, cz) = (x >> 4, z >> 4); + let mut out: Vec = Vec::new(); + for start in &self.starts { + let b = start.bounds.0; + // Referenced: the start's box reaches the chunk's columns. + if b[3] < cx * 16 || b[0] > cx * 16 + 15 || b[5] < cz * 16 || b[2] > cz * 16 + 15 { + continue; + } + let in_piece = start.pieces.iter().any(|p| p.has(x, y, z)); + let in_bounds = start.bounds.has(x, y, z); + match out.iter_mut().find(|s| s.id == start.id) { + Some(s) => { + s.in_piece |= in_piece; + s.in_bounds |= in_bounds; + } + None => out.push(StructureAt { + id: start.id.clone(), + in_piece, + in_bounds, + }), + } + } + out + } + fn structure_spawns(&self) -> &StructureSpawns { + self.s.structures + } + fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option { + let mut best: Option = None; + for (p, mode) in &self.players { + if mode == "spectator" { + continue; + } + let d = [p[0] - x, p[1] - y, p[2] - z]; + let d = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; + if best.is_none_or(|b| d < b) { + best = Some(d); + } + } + best + } + fn respawn(&self) -> Option<[i32; 3]> { + self.respawn + } + fn can_spawn_in_chunk(&self, cx: i32, cz: i32) -> bool { + !self.unloaded.contains(&(cx, cz)) + } + fn no_collision(&self, bounds: Aabb) -> bool { + let lookup = |x: i32, y: i32, z: i32| self.state(x, y, z); + let mut world = LookupCollisions { + block: &lookup, + binding: self.s.collision, + }; + no_collision_empty(&mut world, bounds) + } + fn unobstructed(&self, bounds: Aabb) -> bool { + !self.entities.borrow().iter().any(|b| b.intersects(bounds)) + } + fn spawn_mobs(&self) -> bool { + self.spawn_mobs + } + fn heightmap(&self, kind: Heightmap, x: i32, z: i32) -> i32 { + (self.min_y()..=self.max_y()) + .rev() + .find(|&y| spawn_selection::heightmap_blocks(self, kind, x, y, z)) + .map_or(self.min_y(), |y| y + 1) + } + fn pathfindable_land(&self, x: i32, y: i32, z: i32) -> bool { + self.s.data.state(self.state(x, y, z)).flags & horizon_client::mobs::data::LAND != 0 + } +} + +/// The box a mob is left with by `finalizeSpawn` under the oracle's fixed random. +fn neutral_box(l: &World, r: &SpawnRequest) -> Aabb { + let t = &l.s.types.types[r.entity]; + let (mut w, mut h) = (t.width as f32, t.height as f32); + let baby = match r.species { + Species::PolarBear | Species::Ocelot | Species::Rabbit => r.group_index > 0, + Species::Fox | Species::Axolotl => r.group_index >= 2, + _ => false, + }; + if baby { + match species::def(r.species).baby { + Baby::Abs(bw, bh, _) => (w, h) = (bw as f32, bh as f32), + Baby::Scale(s, _) => (w, h) = (w * s as f32, h * s as f32), + Baby::None => {} + } + } + match r.species { + Species::SulfurCube => (w, h) = (w * 2.0, h * 2.0), + Species::Salmon => (w, h) = (w * 0.5, h * 0.5), + _ => {} + } + let [x, y, z] = r.position; + spawn_box(x, y, z, w, h) +} + +fn parse_state(spec: &str) -> BlockState { + let (name, props) = match spec.split_once('[') { + Some((n, rest)) => (n, rest.trim_end_matches(']')), + None => (spec, ""), + }; + BlockState { + name: name.to_owned(), + properties: props + .split(',') + .filter(|p| !p.is_empty()) + .filter_map(|p| p.split_once('=')) + .map(|(k, v)| (k.to_owned(), v.to_owned())) + .collect(), + } +} +fn bits(v: f64) -> String { + format!("{:x}", v.to_bits()) +} +fn fbits(v: f32) -> String { + format!("{:x}", v.to_bits()) +} + +fn main() -> horizon_client::Result<()> { + let args: Vec = std::env::args().collect(); + let root = Path::new( + args.get(1) + .ok_or("usage: natural_spawn_fixture RESOURCES INPUT OUTPUT")?, + ); + let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?; + let assets = Assets::load(root)?; + let registry = assets.registry; + let facts = BlockFacts::load(root, registry)?; + let data = MobData::load(root, &facts); + let items = Items::tags_only(root)?; + let types = MobTypes::load(root)?; + let biomes = Biomes::load(root)?; + let structures = StructureSpawns::load(root); + let collision = collisions::load(root, registry)?; + let shared = Shared { + facts: &facts, + data: &data, + block_tags: &items.block_tags, + types: &types, + biomes: &biomes, + structures: &structures, + collision: &collision, + air: facts.air, + }; + let mut adaptation: HashMap = HashMap::new(); + let mut out = String::new(); + for raw in input.lines() { + if raw.trim().is_empty() { + continue; + } + let mut c = BTreeMap::new(); + let mut name = ""; + for token in raw.split_whitespace() { + match token.split_once('=') { + Some((k, v)) => { + c.insert(k, v); + } + None => name = token, + } + } + let get = + |k: &str, d: &'static str| -> String { c.get(k).copied().unwrap_or(d).to_owned() }; + let indexed = |prefix: &str| -> Vec<&str> { + (0..) + .map_while(|i| c.get(format!("{prefix}{i}").as_str()).copied()) + .collect() + }; + let mut blocks = Vec::new(); + for k in indexed("k") { + let (b, s) = k.split_once('/').ok_or("bad block box")?; + blocks.push((Bx::parse(b), registry.resolve(&parse_state(s))?)); + } + let lights = indexed("l") + .into_iter() + .filter_map(|l| { + let (b, v) = l.split_once('/')?; + let (s, k) = v.split_once(',')?; + Some((Bx::parse(b), s.parse().ok()?, k.parse().ok()?)) + }) + .collect(); + let biome_boxes = indexed("b") + .into_iter() + .filter_map(|l| { + let (b, n) = l.split_once('/')?; + Some((Bx::parse(b), n.to_owned())) + }) + .collect(); + let mut starts = Vec::new(); + for s in indexed("s") { + let (id, pieces) = s.split_once('/').ok_or("bad structure")?; + let pieces: Vec = pieces.split(';').map(Bx::parse).collect(); + let mut b = pieces[0].0; + for p in &pieces[1..] { + for i in 0..3 { + b[i] = b[i].min(p.0[i]); + b[i + 3] = b[i + 3].max(p.0[i + 3]); + } + } + let adapts = *adaptation.entry(id.to_owned()).or_insert_with(|| { + let path = root.join(format!( + "data/minecraft/worldgen/structure/{}.json", + id.trim_start_matches("minecraft:") + )); + fs::read(path) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .and_then(|v| v["terrain_adaptation"].as_str().map(|a| a != "none")) + .unwrap_or(false) + }); + if adapts { + for i in 0..3 { + b[i] -= 12; + b[i + 3] += 12; + } + } + starts.push(Start { + id: id.to_owned(), + pieces, + bounds: Bx(b), + }); + } + let players = indexed("p") + .into_iter() + .map(|p| { + let mut parts = p.split('/'); + let v: Vec = parts + .next() + .unwrap_or("") + .split(',') + .map(|n| n.parse().unwrap_or(0.0)) + .collect(); + ( + [v[0], v[1], v[2]], + parts.next().unwrap_or("survival").to_owned(), + ) + }) + .collect::>(); + let dimension_id = get("dim", "minecraft:overworld"); + let dimension = match dimension_id.as_str() { + "minecraft:the_nether" => Dimension::Nether, + "minecraft:the_end" => Dimension::End, + _ => Dimension::Overworld, + }; + let respawn = { + let r = get("respawn", "0,-1000,0/minecraft:overworld"); + let (p, d) = r.split_once('/').ok_or("bad respawn")?; + let v: Vec = p.split(',').map(|n| n.parse().unwrap_or(0)).collect(); + (d == dimension_id).then_some([v[0], v[1], v[2]]) + }; + let unloaded = c + .get("unloaded") + .map(|u| { + u.split(';') + .filter_map(|p| { + let (x, z) = p.split_once(',')?; + Some((x.parse().ok()?, z.parse().ok()?)) + }) + .collect() + }) + .unwrap_or_default(); + // Case entities: (type, position, persistent). + let mut case_entities = Vec::new(); + for e in indexed("e") { + let parts: Vec<&str> = e.split('/').collect(); + let v: Vec = parts[1] + .split(',') + .map(|n| n.parse().unwrap_or(0.0)) + .collect(); + case_entities.push(( + parts[0].to_owned(), + [v[0], v[1], v[2]], + parts.get(2) == Some(&"p"), + )); + } + let world = World { + s: &shared, + blocks, + lights, + biomes: biome_boxes, + biome: get("biome", "minecraft:plains"), + sky: get("sky", "15").parse()?, + light: get("light", "0").parse()?, + darken: get("darken", "0").parse()?, + thunder: get("thunder", "0") == "1", + sea: get("sea", "63").parse()?, + seed: get("wseed", "0").parse()?, + peaceful: get("difficulty", "normal") == "peaceful", + dimension, + dimension_id: dimension_id.clone(), + slime: get("slime", "0.0").parse()?, + starts, + players, + respawn, + unloaded, + spawn_mobs: get("rules", "1") != "0", + entities: RefCell::new( + case_entities + .iter() + .filter_map(|(t, p, _)| { + let t = types.types.get(t)?; + Some(spawn_box(p[0], p[1], p[2], t.width as f32, t.height as f32)) + }) + .collect(), + ), + }; + let _ = &world.dimension_id; + let chunk = { + let v: Vec = get("chunk", "0,0") + .split(',') + .map(|n| n.parse().unwrap_or(0)) + .collect(); + (v[0], v[1]) + }; + let mut random = LegacyRandom::new(get("seed", "0").parse()?); + let mut log: Vec = Vec::new(); + let mut spawn = |l: &World, r: &SpawnRequest| -> Option { + let bounds = neutral_box(l, r); + l.entities.borrow_mut().push(bounds); + Some(Spawned { + bounds, + school: true, + }) + }; + let mode = get("mode", "chunk"); + if mode == "gen" { + let mut gen_random = LegacyRandom::new(get("gseed", "0").parse()?); + // The level random of the spawn predicates: no creature's predicate + // draws from it (the oracle's answers fixed values). + let mut rules = LegacyRandom::new(0); + let mut spawns = Vec::new(); + let source = [chunk.0 * 16, world.max_y(), chunk.1 * 16]; + spawn_selection::spawn_mobs_for_chunk_generation( + &world, + source, + chunk, + &mut gen_random, + &mut rules, + &mut spawn, + &mut |e| match e { + GenEvent::Column(x, z) => log.push(format!("col {x} {z}")), + GenEvent::Spawn(t, p, yaw) => spawns.push(format!( + "spawn {t} {} {} {} {}", + bits(p[0]), + bits(p[1]), + bits(p[2]), + fbits(yaw) + )), + }, + ); + log.extend(spawns); + log.push(format!( + "next {}", + gen_random.next_int_bound(1000).unwrap_or(0) + )); + } else { + let counted: Vec = case_entities + .iter() + .filter_map(|(t, p, persistent)| { + let kind = types.types.get(t)?; + Some(Counted { + entity: t, + category: Category::from_name(&kind.category), + block: [ + p[0].floor() as i32, + p[1].floor() as i32, + p[2].floor() as i32, + ], + is_mob: true, + persistent: *persistent, + }) + }) + .collect(); + let players = world + .players + .iter() + .map(|(p, mode)| SpawnPlayer { + position: *p, + spectator: mode == "spectator", + }) + .collect(); + let mut state = SpawnState::new( + &world, + get("chunkcount", "289").parse()?, + players, + &counted, + |cx, cz| !world.unloaded.contains(&(cx, cz)), + ); + let mut trace = |e: Event| { + log.push(match e { + Event::Categories(list) => format!( + "cats {}", + list.iter().map(|c| c.name()).collect::>().join(",") + ), + Event::Local(c, ok) => format!("local {} {ok}", c.name()), + Event::Start(c, p) => format!("start {} {} {} {}", c.name(), p[0], p[1], p[2]), + Event::Try(x, z) => format!("try {x} {z}"), + Event::Test(t, p, ok) => format!("test {t} {} {} {} {ok}", p[0], p[1], p[2]), + Event::Spawn(t, p, yaw) => format!( + "spawn {t} {} {} {} {}", + bits(p[0]), + bits(p[1]), + bits(p[2]), + fbits(yaw) + ), + }) + }; + if mode == "pos" { + let category = Category::from_name(&get("cat", "monster")); + let v: Vec = get("start", "0,0,0") + .split(',') + .map(|n| n.parse().unwrap_or(0)) + .collect(); + spawn_selection::spawn_category_for_position( + &world, + &mut random, + &mut state, + category, + chunk, + [v[0], v[1], v[2]], + &mut spawn, + &mut trace, + ); + } else { + let categories = state + .filtered_categories(get("enemies", "1") == "1", get("persistent", "1") == "1"); + trace(Event::Categories(categories.clone())); + spawn_selection::spawn_for_chunk( + &world, + &mut random, + &mut state, + &categories, + chunk, + &mut spawn, + &mut trace, + ); + } + log.push(format!("next {}", random.next_int_bound(1000).unwrap_or(0))); + } + let _ = writeln!(out, "{name} {}", log.join(" ; ")); + } + fs::write(args.get(3).ok_or("missing OUTPUT")?, out)?; + Ok(()) +} diff --git a/crates/horizon-client/src/game/natural_spawning.rs b/crates/horizon-client/src/game/natural_spawning.rs index 34e346f33..7d07f148b 100644 --- a/crates/horizon-client/src/game/natural_spawning.rs +++ b/crates/horizon-client/src/game/natural_spawning.rs @@ -27,6 +27,8 @@ use std::collections::HashMap; struct GameLevel<'g, 'a> { game: &'g Game<'a>, w: MobWorld<'g>, + /// Boxes of the mobs this pass created, not yet in the game's list. + pending: std::cell::RefCell>, } impl GameLevel<'_, '_> { fn biome(&self, x: i32, y: i32, z: i32) -> Option<&BiomeInfo> { @@ -170,6 +172,7 @@ impl NaturalLevel for GameLevel<'_, '_> { .player_view() .is_some_and(|p| p.alive && !p.spectator && p.bounds.intersects(bounds)); !player + && !self.pending.borrow().iter().any(|b| b.intersects(bounds)) && !self .game .mobs @@ -183,41 +186,19 @@ impl NaturalLevel for GameLevel<'_, '_> { let top = horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT; (horizon_client::chunk::MIN_Y..top) .rev() - .find(|&y| heightmap_blocks(self.w.facts, self.w.data, kind, self.state(x, y, z))) + .find(|&y| spawn_selection::heightmap_blocks(self, kind, x, y, z)) .map_or(horizon_client::chunk::MIN_Y, |y| y + 1) } fn pathfindable_land(&self, x: i32, y: i32, z: i32) -> bool { self.w.data.state(self.state(x, y, z)).flags & data::LAND != 0 } } -/// `Heightmap.Types` predicates: WORLD_SURFACE (not air), OCEAN_FLOOR -/// (`blocksMotion`), MOTION_BLOCKING (or a fluid) and NO_LEAVES. -pub fn heightmap_blocks( - facts: &BlockFacts, - data: &MobData, - kind: Heightmap, - state: BlockStateId, -) -> bool { - let f = facts.get(state); - let blocks_motion = data.state(state).flags & data::SOLID != 0 - && &*f.name != "minecraft:cobweb" - && &*f.name != "minecraft:bamboo_sapling"; - let fluid = f.fluid != horizon_client::blocks::Fluid::None; - match kind { - Heightmap::WorldSurface => !facts.is_air(state), - Heightmap::OceanFloor => blocks_motion, - Heightmap::MotionBlocking => blocks_motion || fluid, - Heightmap::MotionBlockingNoLeaves => { - (blocks_motion || fluid) && !f.name.ends_with("_leaves") - } - } -} - impl<'a> Game<'a> { fn natural_level(&self) -> GameLevel<'_, 'a> { GameLevel { game: self, w: self.mob_world_base(&[]), + pending: Default::default(), } } /// Creates a mob the spawner accepted, finalized the game's way; the @@ -314,8 +295,13 @@ impl<'a> Game<'a> { } if !categories.is_empty() { for chunk in chunks { - let mut spawn = - |l: &GameLevel, r: &SpawnRequest| l.game.finalize_spawned(r, &mut pending); + let mut spawn = |l: &GameLevel, r: &SpawnRequest| { + let spawned = l.game.finalize_spawned(r, &mut pending); + if let Some(s) = &spawned { + l.pending.borrow_mut().push(s.bounds); + } + spawned + }; spawn_selection::spawn_for_chunk( &level, &mut random, @@ -347,8 +333,13 @@ impl<'a> Game<'a> { { let level = self.natural_level(); let source = [cx * 16, level.max_y(), cz * 16]; - let mut spawn = - |l: &GameLevel, r: &SpawnRequest| l.game.finalize_spawned(r, &mut pending); + let mut spawn = |l: &GameLevel, r: &SpawnRequest| { + let spawned = l.game.finalize_spawned(r, &mut pending); + if let Some(s) = &spawned { + l.pending.borrow_mut().push(s.bounds); + } + spawned + }; spawn_selection::spawn_mobs_for_chunk_generation( &level, source, diff --git a/crates/horizon-client/src/mobs/spawn_selection.rs b/crates/horizon-client/src/mobs/spawn_selection.rs index d0ab2b67e..8b1d6aed6 100644 --- a/crates/horizon-client/src/mobs/spawn_selection.rs +++ b/crates/horizon-client/src/mobs/spawn_selection.rs @@ -408,6 +408,24 @@ impl Heightmap { } } +/// `Heightmap.Types`' predicates: WORLD_SURFACE (not air), OCEAN_FLOOR +/// (`#blocks_motion_in_heightmap`), MOTION_BLOCKING (that or a fluid) and +/// MOTION_BLOCKING_NO_LEAVES (`#blocks_motion_in_heightmap_no_leaves` or a fluid). +pub fn heightmap_blocks(l: &L, kind: Heightmap, x: i32, y: i32, z: i32) -> bool { + let state = l.state(x, y, z); + let f = l.facts().get(state); + let fluid = f.fluid != crate::blocks::Fluid::None; + match kind { + Heightmap::WorldSurface => !l.facts().is_air(state), + Heightmap::OceanFloor => l.block_tag("minecraft:blocks_motion_in_heightmap", &f.name), + Heightmap::MotionBlocking => { + fluid || l.block_tag("minecraft:blocks_motion_in_heightmap", &f.name) + } + Heightmap::MotionBlockingNoLeaves => { + fluid || l.block_tag("minecraft:blocks_motion_in_heightmap_no_leaves", &f.name) + } + } +} /// `LevelReader.getLightLevelDependentMagicValue`. fn magic_light(l: &L, x: i32, y: i32, z: i32) -> f32 { let raw = l diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNaturalSpawnOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNaturalSpawnOracle.java index 15795f0fd..eb546c361 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNaturalSpawnOracle.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNaturalSpawnOracle.java @@ -44,7 +44,8 @@ import net.minecraft.world.level.dimension.DimensionType; import net.minecraft.world.level.entity.EntityTypeTest; import net.minecraft.world.level.gamerules.GameRules; import net.minecraft.world.level.levelgen.Heightmap; -import net.minecraft.world.level.levelgen.flat.FlatLevelSource; +import net.minecraft.world.level.levelgen.FlatLevelSource; +import net.minecraft.util.Mth; import net.minecraft.world.level.levelgen.structure.BoundingBox; import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.StructurePiece; @@ -115,6 +116,8 @@ public final class VanillaNaturalSpawnOracle { static final class Lvl extends ServerLevel { Lvl() { super(null, null, null, null, null, null, false, 0L, List.of(), false); } RegistryAccess registries; + net.minecraft.server.MinecraftServer fake; + @Override public net.minecraft.server.MinecraftServer getServer() { return fake; } List> blocks = new ArrayList<>(); List> lights = new ArrayList<>(); List>> biomes = new ArrayList<>(); @@ -318,14 +321,24 @@ public final class VanillaNaturalSpawnOracle { var staticTags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC)); var context = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLD), staticTags); RegistryAccess.Frozen world = RegistryDataLoader.load(resources, context, RegistryDataLoader.WORLD_REGISTRIES, Runnable::run).join(); + var worldLayers = layers.replaceFrom(RegistryLayer.WORLD, world); + var loaded = net.minecraft.server.ReloadableServerRegistries.reload(worldLayers, staticTags, resources, Runnable::run).join(); + HolderLookup.Provider lookup = loaded.lookupWithUpdatedTags(); + var components = BuiltInRegistries.DATA_COMPONENT_INITIALIZERS.build(lookup); staticTags.forEach(Registry.PendingTags::apply); - var access = layers.replaceFrom(RegistryLayer.WORLD, world).compositeAccess(); + components.forEach(net.minecraft.core.component.DataComponentInitializers.PendingComponents::apply); + RegistryAccess access = loaded.layers().compositeAccess(); var biomes = access.lookupOrThrow(Registries.BIOME); var dimensions = access.lookupOrThrow(Registries.DIMENSION_TYPE); var blockLookup = access.lookupOrThrow(Registries.BLOCK); var structureLookup = access.lookupOrThrow(Registries.STRUCTURE); + // A server that only answers debug subscribers (path navigation asks). + var server = (net.minecraft.server.MinecraftServer) alloc(net.minecraft.server.dedicated.DedicatedServer.class); + put(server, net.minecraft.server.MinecraftServer.class, "debugSubscribers", alloc(net.minecraft.util.debug.ServerDebugSubscribers.class)); Lvl level = (Lvl) alloc(Lvl.class); + level.fake = server; + put(level, ServerLevel.class, "server", server); level.registries = access; level.blocks = new ArrayList<>(); level.lights = new ArrayList<>(); @@ -337,7 +350,7 @@ public final class VanillaNaturalSpawnOracle { level.border = new WorldBorder(); level.neutral = new Neutral(); level.rules = new GameRules(net.minecraft.world.flag.FeatureFlags.REGISTRY.allFlags()); - level.biomeManager = new BiomeManager((x, y, z, sampler) -> level.biome, 0L) { + level.biomeManager = new BiomeManager((x, y, z) -> level.biome, 0L) { @Override public Holder getBiome(BlockPos pos) { return level.biomeAt(pos.getX(), pos.getY(), pos.getZ()); } @Override public Holder getBiome(int x, int y, int z) { return level.biomeAt(x, y, z); } }; @@ -360,7 +373,8 @@ public final class VanillaNaturalSpawnOracle { local.setAccessible(true); Method randomPos = NaturalSpawner.class.getDeclaredMethod("getRandomPosWithin", Level.class, LevelChunk.class); randomPos.setAccessible(true); - Field position = Entity.class.getDeclaredField("position"); + Field entityId = Entity.class.getDeclaredField("id"); + entityId.setAccessible(true); List lines = Files.readAllLines(Path.of(args[0])); try (var w = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8))) { @@ -402,7 +416,11 @@ public final class VanillaNaturalSpawnOracle { level.seed = Long.parseLong(c.getOrDefault("wseed", "0")); level.difficulty = Difficulty.byName(c.getOrDefault("difficulty", "normal")); level.rules.set(net.minecraft.world.level.gamerules.GameRules.SPAWN_MOBS, !c.getOrDefault("rules", "1").equals("0"), null); - level.attributes = net.minecraft.world.attribute.EnvironmentAttributeSystem.builder().addStaticLayers(level).build(); + // The dimension and biome layers; the moon timeline's surface slime chance as a constant. + level.attributes = net.minecraft.world.attribute.EnvironmentAttributeSystem.builder().addStaticLayers(level) + .addConstantLayer(net.minecraft.world.attribute.EnvironmentAttributeMap.builder() + .set(net.minecraft.world.attribute.EnvironmentAttributes.SURFACE_SLIME_SPAWN_CHANCE, Float.parseFloat(c.getOrDefault("slime", "0.0"))).build()) + .build(); level.unloaded.clear(); if (c.containsKey("unloaded")) for (String u : c.get("unloaded").split(";")) { String[] p = u.split(","); @@ -429,6 +447,7 @@ public final class VanillaNaturalSpawnOracle { String[] v = p[0].split(","); OraclePlayer player = (OraclePlayer) alloc(OraclePlayer.class); player.mode = GameType.byName(p.length > 1 ? p[1] : "survival"); + entityId.setInt(player, 100000 + i); put(player, Entity.class, "position", new Vec3(Double.parseDouble(v[0]), Double.parseDouble(v[1]), Double.parseDouble(v[2]))); level.playerList.add(player); } @@ -441,7 +460,8 @@ public final class VanillaNaturalSpawnOracle { if (p.length > 2 && p[2].equals("p") && e instanceof Mob mob) mob.setPersistenceRequired(); level.entities.add(e); } - put(level, Level.class, "random", RandomSource.create(Long.parseLong(c.getOrDefault("seed", "0")))); + RandomSource mainRandom = RandomSource.create(Long.parseLong(c.getOrDefault("seed", "0"))); + put(level, Level.class, "random", mainRandom); String mode = c.getOrDefault("mode", "chunk"); String[] cp = c.getOrDefault("chunk", "0,0").split(","); Chunk chunk = (Chunk) alloc(Chunk.class); @@ -504,11 +524,12 @@ public final class VanillaNaturalSpawnOracle { if (start.getY() >= level.getMinY() + 1) NaturalSpawner.spawnCategoryForPosition(category, level, chunk, start, extra, after); } } - log.add("next " + level.random.nextInt(1000)); + log.add("next " + mainRandom.nextInt(1000)); } w.println(c.get("name") + " " + String.join(" ; ", log)); } catch (Throwable t) { - Throwable cause = t instanceof InvocationTargetException ite ? ite.getCause() : t; + Throwable cause = t; + while ((cause instanceof InvocationTargetException || cause instanceof RuntimeException && cause.getCause() != null) && cause.getCause() != null) cause = cause.getCause(); StringBuilder line = new StringBuilder(c.get("name") + " error " + cause); if (System.getenv("ORACLE_TRACE") != null) for (var frame : cause.getStackTrace()) line.append("\n at ").append(frame); w.println(line); From b2812c55beeda9b71524e55a190b2f461f6c6d6d Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:10:12 +0200 Subject: [PATCH 5/8] Choose natural spawns exactly as vanilla's NaturalSpawner does tools/test_natural_spawn_parity.py compares whole chunk spawn passes, spawnCategoryForPosition from chosen sites and chunk generation packs against vanilla's NaturalSpawner on generated box worlds. Divergences fixed on the way: the reduced water ambient tag is reduce_water_ambient_spawns, and the room check uses each type's spawn dimensions scale (4 for slimes and magma cubes, 2 for sulfur cubes). Co-Authored-By: Claude Opus 5.5 --- .../examples/natural_spawn_fixture.rs | 8 + .../src/mobs/spawn_selection.rs | 25 +- .../parity/VanillaNaturalSpawnOracle.java | 2 +- tools/test_natural_spawn_parity.py | 534 ++++++++++++++++++ 4 files changed, 565 insertions(+), 4 deletions(-) create mode 100644 tools/test_natural_spawn_parity.py diff --git a/crates/horizon-client/examples/natural_spawn_fixture.rs b/crates/horizon-client/examples/natural_spawn_fixture.rs index 84b84c121..7cec0d067 100644 --- a/crates/horizon-client/examples/natural_spawn_fixture.rs +++ b/crates/horizon-client/examples/natural_spawn_fixture.rs @@ -496,6 +496,14 @@ fn main() -> horizon_client::Result<()> { .split(',') .map(|n| n.parse().unwrap_or(0.0)) .collect(); + // EntityType.create makes no monsters in peaceful. + if get("difficulty", "normal") == "peaceful" + && types.types.get(parts[0]).is_some_and(|t| { + !horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species) + }) + { + continue; + } case_entities.push(( parts[0].to_owned(), [v[0], v[1], v[2]], diff --git a/crates/horizon-client/src/mobs/spawn_selection.rs b/crates/horizon-client/src/mobs/spawn_selection.rs index 8b1d6aed6..c67fea46b 100644 --- a/crates/horizon-client/src/mobs/spawn_selection.rs +++ b/crates/horizon-client/src/mobs/spawn_selection.rs @@ -565,6 +565,24 @@ pub fn spawn_box(x: f64, y: f64, z: f64, width: f32, height: f32) -> Aabb { Vec3::new(x + half, y + f64::from(height), z + half), ) } +/// `EntityType.spawnDimensionsScale`: cube mobs are checked for room at +/// their largest spawn size. +fn spawn_dimensions_scale(s: Species) -> f32 { + match s { + Species::Slime | Species::MagmaCube => 4.0, + Species::SulfurCube => 2.0, + _ => 1.0, + } +} +/// `EntityType.getSpawnAABB`. +pub fn spawn_aabb(s: Species, x: f64, y: f64, z: f64, width: f32, height: f32) -> Aabb { + let scale = spawn_dimensions_scale(s); + let half = f64::from(scale * width / 2.0); + Aabb::new( + Vec3::new(x - half, y, z - half), + Vec3::new(x + half, y + f64::from(scale * height), z + half), + ) +} /// `checkSpawnObstruction` of a freshly created mob with the given box. fn spawn_obstruction( s: Species, @@ -1062,7 +1080,7 @@ pub fn spawn_category_for_position( continue; } if current.is_none() { - let reduced = l.biome_tag("minecraft:reduced_water_ambient_spawns", x, y, z); + let reduced = l.biome_tag("minecraft:reduce_water_ambient_spawns", x, y, z); let list = if category == Category::WaterAmbient && reduced && random.next_float() < 0.98 @@ -1180,7 +1198,8 @@ fn valid_position_for_type( if !spawn_rules::check_spawn_rules(species, l, Reason::Natural, random, x, y, z) { return false; } - l.no_collision(spawn_box( + l.no_collision(spawn_aabb( + species, f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5, @@ -1255,7 +1274,7 @@ pub fn spawn_mobs_for_chunk_generation getBiome(BlockPos pos) { return level.biomeAt(pos.getX(), pos.getY(), pos.getZ()); } @Override public Holder getBiome(int x, int y, int z) { return level.biomeAt(x, y, z); } }; - put(level, Level.class, "isClientSide", false); Source source = (Source) alloc(Source.class); source.generator = (ChunkGenerator) alloc(FlatLevelSource.class); level.chunkSource = source; @@ -456,6 +455,7 @@ public final class VanillaNaturalSpawnOracle { String[] p = c.get("e" + i).split("/"); String[] v = p[1].split(","); Entity e = BuiltInRegistries.ENTITY_TYPE.getValue(Identifier.parse(p[0])).create(level, EntitySpawnReason.LOAD); + if (e == null) continue; // monsters do not exist in peaceful e.setPos(Double.parseDouble(v[0]), Double.parseDouble(v[1]), Double.parseDouble(v[2])); if (p.length > 2 && p[2].equals("p") && e instanceof Mob mob) mob.setPersistenceRequired(); level.entities.add(e); diff --git a/tools/test_natural_spawn_parity.py b/tools/test_natural_spawn_parity.py new file mode 100644 index 000000000..7c3a6f472 --- /dev/null +++ b/tools/test_natural_spawn_parity.py @@ -0,0 +1,534 @@ +#!/usr/bin/env python3 +"""Compare natural spawning (NaturalSpawner) with vanilla. + +Java (VanillaNaturalSpawnOracle) runs vanilla's NaturalSpawner on a stand-in +ServerLevel built from each case: boxes of blocks, light and biomes, structure +starts with their pieces, existing mobs, players, the world spawn point and +unloaded chunks. Rust (natural_spawn_fixture) runs +horizon_client::mobs::spawn_selection on the same world. Three modes: + +- chunk: createState over the case's mobs (category counts, spawn potential + charges, local caps), getFilteredSpawningCategories under the global caps, + then spawnForChunk (local caps, getRandomPosWithin, spawnCategoryForPosition); +- pos: spawnCategoryForPosition from a chosen start (surfaces, caves, water, + lava, structures); +- gen: spawnMobsForChunkGeneration. + +Each case's log lists every attempt (the nearest player lookup), each type that +passed isValidSpawnPostitionForType with the spawn potential's answer, each +spawn with its exact position and yaw bits, the heightmap columns of +chunk-generation attempts, and the next draw of the spawner's random, which +pins every draw in between. Worlds cover the overworld, nether and end, their +biomes, caves, water and lava, block and sky light, day, night and thunder, +peaceful to hard, spawn costs (soul sand valley, warped forest), structure +spawn overrides (fortress, swamp hut, monument, outpost, trial chambers, +ancient city), mob caps, players near and far, spectators and the world spawn. +""" +import argparse +import json +import os +from pathlib import Path +import platform +import random +import subprocess +try: + from . import lab, test_rust_parity as base +except ImportError: + import lab + import test_rust_parity as base + +R = 48 # world boxes span [-R, 16 + R) around chunk (0, 0) + +OVERWORLD = { + # biome: (top block, filler) + 'minecraft:plains': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:forest': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:birch_forest': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:dark_forest': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:flower_forest': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:sunflower_plains': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:meadow': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:cherry_grove': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:taiga': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:old_growth_pine_taiga': ('minecraft:podzol', 'minecraft:dirt'), + 'minecraft:snowy_taiga': ('minecraft:grass_block[snowy=true]', 'minecraft:dirt'), + 'minecraft:snowy_plains': ('minecraft:snow_block', 'minecraft:dirt'), + 'minecraft:ice_spikes': ('minecraft:snow_block', 'minecraft:packed_ice'), + 'minecraft:frozen_peaks': ('minecraft:snow_block', 'minecraft:stone'), + 'minecraft:jagged_peaks': ('minecraft:snow_block', 'minecraft:stone'), + 'minecraft:stony_peaks': ('minecraft:stone', 'minecraft:stone'), + 'minecraft:windswept_hills': ('minecraft:grass_block', 'minecraft:stone'), + 'minecraft:desert': ('minecraft:sand', 'minecraft:sandstone'), + 'minecraft:badlands': ('minecraft:red_sand', 'minecraft:terracotta'), + 'minecraft:wooded_badlands': ('minecraft:coarse_dirt', 'minecraft:terracotta'), + 'minecraft:savanna': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:jungle': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:bamboo_jungle': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:sparse_jungle': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:swamp': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:mangrove_swamp': ('minecraft:mud', 'minecraft:mud'), + 'minecraft:mushroom_fields': ('minecraft:mycelium', 'minecraft:dirt'), + 'minecraft:beach': ('minecraft:sand', 'minecraft:sand'), + 'minecraft:snowy_beach': ('minecraft:sand', 'minecraft:sand'), + 'minecraft:stony_shore': ('minecraft:stone', 'minecraft:stone'), + 'minecraft:river': ('minecraft:sand', 'minecraft:dirt'), + 'minecraft:pale_garden': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:grove': ('minecraft:snow_block', 'minecraft:dirt'), + 'minecraft:snowy_slopes': ('minecraft:snow_block', 'minecraft:stone'), +} +OCEANS = ['minecraft:ocean', 'minecraft:deep_ocean', 'minecraft:warm_ocean', 'minecraft:lukewarm_ocean', + 'minecraft:deep_lukewarm_ocean', 'minecraft:cold_ocean', 'minecraft:deep_cold_ocean', + 'minecraft:frozen_ocean', 'minecraft:deep_frozen_ocean', 'minecraft:river', 'minecraft:frozen_river'] +CAVES = ['minecraft:lush_caves', 'minecraft:dripstone_caves', 'minecraft:deep_dark'] +NETHER = { + 'minecraft:nether_wastes': 'minecraft:netherrack', + 'minecraft:crimson_forest': 'minecraft:crimson_nylium', + 'minecraft:warped_forest': 'minecraft:warped_nylium', + 'minecraft:soul_sand_valley': 'minecraft:soul_sand', + 'minecraft:basalt_deltas': 'minecraft:basalt', +} +END = ['minecraft:the_end', 'minecraft:end_highlands', 'minecraft:end_midlands', 'minecraft:small_end_islands', + 'minecraft:end_barrens'] +MOBS = { + 'monster': ['zombie', 'skeleton', 'creeper', 'spider', 'enderman', 'witch', 'slime', 'drowned', 'husk', 'stray', + 'zombie_villager', 'cave_spider', 'bogged', 'parched'], + 'creature': ['cow', 'pig', 'sheep', 'chicken', 'horse', 'rabbit', 'wolf', 'fox', 'llama', 'polar_bear', 'goat', + 'strider'], + 'ambient': ['bat'], + 'water_creature': ['squid', 'dolphin'], + 'water_ambient': ['cod', 'salmon', 'tropical_fish', 'pufferfish'], + 'axolotls': ['axolotl'], + 'underground_water_creature': ['glow_squid'], + 'nether': ['zombified_piglin', 'piglin', 'hoglin', 'ghast', 'magma_cube', 'skeleton', 'enderman', 'strider', + 'blaze', 'wither_skeleton'], +} +CATEGORIES = ['monster', 'creature', 'ambient', 'axolotls', 'underground_water_creature', 'water_creature', + 'water_ambient'] +TORCH_LIGHT = [0, 0, 0, 1, 4, 7, 8, 9, 12, 14] + + +def box(x0, y0, z0, x1, y1, z1): + return f'{x0},{y0},{z0},{x1},{y1},{z1}' + + +class World: + """One case's world, written as the oracle's key=value tokens.""" + + def __init__(self, rng): + self.rng = rng + self.kv = {} + self.blocks, self.lights, self.biomes, self.starts, self.entities, self.players = [], [], [], [], [], [] + self.sites = [] # (x, y, z, kind): open positions above a floor, in water or in lava + self.dim = 'minecraft:overworld' + self.surface = 64 + self.min_y = -64 + + def block(self, b, state): + self.blocks.append(f'{b}/{state}') + + def light(self, b, sky, block): + self.lights.append(f'{b}/{sky},{block}') + + def tokens(self): + out = dict(self.kv) + for prefix, items in (('k', self.blocks), ('l', self.lights), ('b', self.biomes), ('s', self.starts), + ('e', self.entities), ('p', self.players)): + for i, v in enumerate(items): + out[f'{prefix}{i}'] = v + return ' '.join(f'{k}={v}' for k, v in out.items()) + + +def overworld(w, rng, biome=None, ocean=None): + lo, hi = -R, 16 + R - 1 + w.kv['dim'] = 'minecraft:overworld' + w.kv['sea'] = 63 + ocean = rng.random() < 0.2 if ocean is None else ocean + if ocean: + biome = biome or rng.choice(OCEANS) + floor = rng.randrange(28, 58) + w.kv['biome'] = biome + w.block(box(lo, -64, lo, hi, floor - 1, hi), 'minecraft:stone') + w.block(box(lo, floor - 1, lo, hi, floor - 1, hi), rng.choice(['minecraft:sand', 'minecraft:gravel', 'minecraft:clay'])) + top = 62 + water = 'minecraft:ice' if 'frozen' in biome and rng.random() < 0.5 else 'minecraft:water' + w.block(box(lo, floor, lo, hi, 62, hi), 'minecraft:water') + if water == 'minecraft:ice': + w.block(box(lo, 62, lo, hi, 62, hi), 'minecraft:ice') + for _ in range(6): + w.sites.append((rng.randrange(-4, 20), rng.randrange(floor, 62), rng.randrange(-4, 20), 'water')) + w.surface = 63 + depth = 62 - floor + w.light(box(lo, -64, lo, hi, 62, hi), max(0, 15 - depth // 2) if rng.random() < 0.5 else 15, 0) + w.light(box(lo, -64, lo, hi, floor - 1, hi), 0, 0) + # An island. + if rng.random() < 0.3: + x, z = rng.randrange(-8, 20), rng.randrange(-8, 20) + w.block(box(x, floor, z, x + rng.randrange(3, 12), 64, z + rng.randrange(3, 12)), 'minecraft:sand') + return + biome = biome or rng.choice(list(OVERWORLD)) + top, filler = OVERWORLD[biome] + surface = rng.randrange(58, 90) + w.surface = surface + 1 + w.kv['biome'] = biome + w.block(box(lo, -64, lo, hi, surface - 4, hi), rng.choice(['minecraft:stone', 'minecraft:stone', 'minecraft:deepslate'])) + w.block(box(lo, surface - 3, lo, hi, surface - 1, hi), filler) + w.block(box(lo, surface, lo, hi, surface, hi), top) + w.light(box(lo, -64, lo, hi, surface, hi), 0, 0) + for _ in range(4): + w.sites.append((rng.randrange(-2, 18), surface + 1, rng.randrange(-2, 18), 'surface')) + # Terraces and hills at other heights. + for _ in range(rng.randrange(0, 4)): + x, z, dy = rng.randrange(lo, hi), rng.randrange(lo, hi), rng.choice([-3, -2, -1, 1, 2, 3, 5]) + sx, sz = rng.randrange(3, 20), rng.randrange(3, 20) + if dy > 0: + w.block(box(x, surface, z, x + sx, surface + dy - 1, z + sz), filler) + w.block(box(x, surface + dy, z, x + sx, surface + dy, z + sz), top) + else: + w.block(box(x, surface + dy + 1, z, x + sx, surface, z + sz), 'minecraft:air') + w.block(box(x, surface + dy, z, x + sx, surface + dy, z + sz), top) + w.light(box(x, surface + dy + 1, z, x + sx, surface, z + sz), 15, 0) + # Ponds, lava, trees and features on the surface. + for _ in range(rng.randrange(0, 3)): + x, z = rng.randrange(lo, hi), rng.randrange(lo, hi) + fluid = 'minecraft:water' if rng.random() < 0.8 else 'minecraft:lava' + d = rng.randrange(1, 4) + w.block(box(x, surface - d + 1, z, x + rng.randrange(2, 9), surface, z + rng.randrange(2, 9)), fluid) + w.sites.append((x, surface, z, 'water' if fluid == 'minecraft:water' else 'lava')) + for _ in range(rng.randrange(0, 4)): + x, z = rng.randrange(lo, hi), rng.randrange(lo, hi) + h = rng.randrange(4, 7) + leaves = rng.choice(['minecraft:oak_leaves', 'minecraft:spruce_leaves', 'minecraft:jungle_leaves', 'minecraft:birch_leaves']) + w.block(box(x - 2, surface + h - 2, z - 2, x + 2, surface + h + 1, z + 2), leaves) + w.block(box(x, surface + 1, z, x, surface + h, z), 'minecraft:oak_log') + w.light(box(x - 2, surface + 1, z - 2, x + 2, surface + h - 3, z + 2), 14, 0) + for _ in range(rng.randrange(0, 6)): + x, z = rng.randrange(lo, hi), rng.randrange(lo, hi) + thing = rng.choice(['minecraft:short_grass', 'minecraft:tall_grass[half=lower]', 'minecraft:snow[layers=1]', + 'minecraft:oak_slab[type=bottom]', 'minecraft:glass', 'minecraft:rail', 'minecraft:torch', + 'minecraft:sweet_berry_bush', 'minecraft:powder_snow', 'minecraft:cobweb', + 'minecraft:white_carpet', 'minecraft:oak_fence', 'minecraft:redstone_block', 'minecraft:magma_block']) + w.block(box(x, surface + 1, z, x + rng.randrange(0, 5), surface + 1, z + rng.randrange(0, 5)), thing) + # Caves: lit or dark rooms underground, some with water, a cave biome. + for _ in range(rng.choice([0, 1, 2, 2, 3, 4, 5])): + x, z = rng.randrange(-20, 14), rng.randrange(-20, 14) + y = rng.randrange(-60, surface - 6) + sx, sy, sz = rng.randrange(4, 24), rng.randrange(2, 8), rng.randrange(4, 24) + y1 = min(y + sy, surface - 2) + w.block(box(x, y, z, x + sx, y1, z + sz), 'minecraft:cave_air' if rng.random() < 0.5 else 'minecraft:air') + w.light(box(x, y, z, x + sx, y1, z + sz), 0, rng.choice(TORCH_LIGHT)) + wet = rng.random() < 0.35 + if wet: + w.block(box(x, y, z, x + sx, y + rng.randrange(1, 4), z + sz), 'minecraft:water') + if rng.random() < 0.3: + w.block(box(x, y - 1, z, x + sx, y - 1, z + sz), rng.choice(['minecraft:clay', 'minecraft:moss_block', 'minecraft:sculk', 'minecraft:dripstone_block', 'minecraft:clay'])) + if rng.random() < 0.45: + w.biomes.append(f'{box(x - 2, y - 2, z - 2, x + sx + 2, y1 + 2, z + sz + 2)}/{rng.choice(CAVES)}') + for _ in range(3): + w.sites.append((rng.randrange(x, x + sx + 1), y + (rng.randrange(0, 2) if wet else 0), rng.randrange(z, z + sz + 1), 'water' if wet else 'cave')) + if rng.random() < 0.2: + # An opening to the sky. + w.block(box(x + 1, y1, z + 1, x + 3, surface, z + 3), 'minecraft:air') + w.light(box(x + 1, y, z + 1, x + 3, surface, z + 3), 15, 0) + + +def nether(w, rng, biome=None): + lo, hi = -R, 16 + R - 1 + biome = biome or rng.choice(list(NETHER)) + w.kv['dim'] = 'minecraft:the_nether' + w.kv['sea'] = 32 + w.kv['biome'] = biome + w.kv['sky'] = 0 + floor = rng.randrange(28, 80) + w.surface = 128 + w.block(box(lo, 0, lo, hi, 0, hi), 'minecraft:bedrock') + w.block(box(lo, 1, lo, hi, floor - 1, hi), 'minecraft:netherrack') + w.block(box(lo, floor, lo, hi, floor, hi), NETHER[biome]) + w.block(box(lo, rng.randrange(100, 120), lo, hi, 126, hi), 'minecraft:netherrack') + w.block(box(lo, 127, lo, hi, 127, hi), 'minecraft:bedrock') + w.light(box(lo, 0, lo, hi, 127, hi), 0, rng.choice([0, 0, 3, 7, 11])) + for _ in range(4): + w.sites.append((rng.randrange(-2, 18), floor + 1, rng.randrange(-2, 18), 'surface')) + if floor <= 34 or rng.random() < 0.4: + x, z = rng.randrange(lo, hi), rng.randrange(lo, hi) + w.block(box(x, min(floor, 20), z, x + rng.randrange(6, 30), 31, z + rng.randrange(6, 30)), 'minecraft:lava') + w.sites.append((x + 2, 31, z + 2, 'lava')) + w.light(box(x, 20, z, x + 30, 40, z + 30), 0, 15) + for _ in range(rng.randrange(0, 4)): + x, z = rng.randrange(lo, hi), rng.randrange(lo, hi) + y = rng.randrange(floor - 10, floor + 20) + w.block(box(x, y, z, x + rng.randrange(3, 14), y, z + rng.randrange(3, 14)), + rng.choice(['minecraft:netherrack', 'minecraft:nether_wart_block', 'minecraft:soul_soil', 'minecraft:magma_block', + 'minecraft:glowstone', 'minecraft:blackstone', 'minecraft:nether_bricks', 'minecraft:fire'])) + if rng.random() < 0.3: + x, z = rng.randrange(lo, hi), rng.randrange(lo, hi) + w.biomes.append(f'{box(x, 0, z, x + rng.randrange(8, 40), 127, z + rng.randrange(8, 40))}/{rng.choice(list(NETHER))}') + return floor + + +def end(w, rng): + lo, hi = -R, 16 + R - 1 + w.kv['dim'] = 'minecraft:the_end' + w.kv['sea'] = 0 + w.kv['sky'] = 0 + w.kv['biome'] = rng.choice(END) + top = rng.randrange(40, 70) + w.surface = top + 1 + w.block(box(lo, top - rng.randrange(5, 30), lo, hi, top, hi), 'minecraft:end_stone') + w.light(box(lo, 0, lo, hi, 255, hi), 0, rng.choice([0, 0, 5, 14])) + for _ in range(4): + w.sites.append((rng.randrange(-2, 18), top + 1, rng.randrange(-2, 18), 'surface')) + + +def structures(w, rng): + dim = w.kv['dim'] + s = w.surface + if dim == 'minecraft:the_nether': + if rng.random() < 0.6: + # Fortress corridors on nether bricks (FORTRESS_ENEMIES and the piece override). + y = rng.randrange(40, 80) + pieces = [] + for _ in range(rng.randrange(1, 4)): + x, z = rng.randrange(-30, 30), rng.randrange(-30, 30) + along = rng.random() < 0.5 + x1, z1 = (x + rng.randrange(10, 40), z + 4) if along else (x + 4, z + rng.randrange(10, 40)) + pieces.append(box(x, y, z, x1, y + 6, z1)) + w.block(box(x, y, z, x1, y, z1), 'minecraft:nether_bricks') + w.block(box(x, y + 1, z, x1, y + 5, z1), 'minecraft:air') + w.light(box(x, y + 1, z, x1, y + 5, z1), 0, rng.choice([0, 7, 11])) + w.block(box(x, y + 6, z, x1, y + 6, z1), 'minecraft:nether_bricks') + w.sites.append((rng.randrange(x, x1 + 1), y + 1, rng.randrange(z, z1 + 1), 'structure')) + w.starts.append('minecraft:fortress/' + ';'.join(pieces)) + if rng.random() < 0.2: + x, z = rng.randrange(-20, 20), rng.randrange(-20, 20) + w.starts.append(f'minecraft:bastion_remnant/{box(x, 30, z, x + 30, 80, z + 30)}') + return + if dim != 'minecraft:overworld': + return + biome = w.kv['biome'] + roll = rng.random() + x, z = rng.randrange(-24, 24), rng.randrange(-24, 24) + if biome in ('minecraft:swamp', 'minecraft:mangrove_swamp') and roll < 0.7: + # Swamp hut: witches and cats only in its piece. + y = s + 1 + w.block(box(x, y, z, x + 6, y, z + 8), 'minecraft:spruce_planks') + w.block(box(x + 1, y + 1, z + 1, x + 5, y + 4, z + 7), 'minecraft:air') + w.light(box(x + 1, y + 1, z + 1, x + 5, y + 4, z + 7), rng.choice([0, 3, 9]), 0) + w.block(box(x, y + 5, z, x + 6, y + 5, z + 8), 'minecraft:spruce_planks') + w.starts.append(f'minecraft:swamp_hut/{box(x, y, z, x + 6, y + 6, z + 8)}') + w.sites.append((x + 3, y + 1, z + 4, 'structure')) + elif 'ocean' in biome and roll < 0.6: + w.starts.append(f'minecraft:monument/{box(x, 39, z, x + 57, 61, z + 57)}') + w.sites.append((x + 8, rng.randrange(40, 61), z + 8, 'structure')) + elif roll < 0.15: + w.starts.append(f'minecraft:pillager_outpost/{box(x, s, z, x + 8, s + 20, z + 8)};{box(x + 10, s, z + 2, x + 14, s + 5, z + 6)}') + w.sites.append((x + 4, s, z + 4, 'structure')) + elif roll < 0.25: + y = rng.randrange(-40, -10) + w.block(box(x, y, z, x + 20, y + 8, z + 20), 'minecraft:air') + w.block(box(x, y - 1, z, x + 20, y - 1, z + 20), 'minecraft:tuff_bricks') + w.light(box(x, y, z, x + 20, y + 8, z + 20), 0, 0) + w.starts.append(f'minecraft:trial_chambers/{box(x, y - 1, z, x + 20, y + 9, z + 20)}') + w.sites.append((x + 5, y, z + 5, 'structure')) + elif roll < 0.32: + y = rng.randrange(-52, -30) + w.block(box(x, y, z, x + 30, y + 10, z + 30), 'minecraft:air') + w.block(box(x, y - 1, z, x + 30, y - 1, z + 30), 'minecraft:deepslate_tiles') + w.light(box(x, y, z, x + 30, y + 10, z + 30), 0, 0) + w.biomes.append(f'{box(x - 4, y - 4, z - 4, x + 34, y + 14, z + 34)}/minecraft:deep_dark') + w.starts.append(f'minecraft:ancient_city/{box(x, y - 1, z, x + 30, y + 11, z + 30)}') + w.sites.append((x + 5, y, z + 5, 'structure')) + elif roll < 0.4: + w.starts.append(f'minecraft:village_plains/{box(x, s - 2, z, x + 12, s + 8, z + 12)}') + + +def mob_entities(w, rng): + dim = w.kv['dim'] + pool = MOBS['nether'] if dim == 'minecraft:the_nether' else sum((MOBS[c] for c in CATEGORIES if c in MOBS), []) + n = rng.choice([0, 0, 1, 3, 6, 12, 25, 80]) + for _ in range(n): + t = rng.choice(pool) + x, z = rng.uniform(-R + 2, 16 + R - 2), rng.uniform(-R + 2, 16 + R - 2) + y = rng.choice([w.surface, w.surface, rng.randrange(w.min_y + 2, w.surface + 1)]) + persistent = '/p' if rng.random() < 0.2 else '' + w.entities.append(f'minecraft:{t}/{x:.3f},{y},{z:.3f}{persistent}') + if w.kv.get('biome') in ('minecraft:soul_sand_valley', 'minecraft:warped_forest') or any('soul_sand_valley' in b or 'warped' in b for b in w.biomes): + for _ in range(rng.randrange(0, 10)): + t = rng.choice(['skeleton', 'ghast', 'enderman', 'strider']) + w.entities.append(f'minecraft:{t}/{rng.uniform(-10, 26):.3f},{w.sites[0][1]},{rng.uniform(-10, 26):.3f}') + # Clusters right where spawns happen (obstruction and spawn potential). + if rng.random() < 0.3: + t = rng.choice(pool) + for _ in range(rng.randrange(1, 8)): + w.entities.append(f'minecraft:{t}/{rng.randrange(0, 16) + 0.5},{w.surface},{rng.randrange(0, 16) + 0.5}') + + +def place_players(w, rng): + count = rng.choice([1, 1, 1, 1, 2, 3]) + for _ in range(count): + kind = rng.random() + if kind < 0.75: + # 24 to ~100 blocks from the chunk: most attempts are allowed. + ang, dist = rng.uniform(0, 6.283), rng.uniform(26, 100) + elif kind < 0.87: + ang, dist = rng.uniform(0, 6.283), rng.uniform(0, 30) + else: + ang, dist = rng.uniform(0, 6.283), rng.uniform(100, 200) + import math + x, z = 8 + dist * math.cos(ang), 8 + dist * math.sin(ang) + y = w.surface + rng.choice([0, 0, 5, 30, -20]) + mode = rng.choice(['survival'] * 8 + ['creative', 'adventure', 'spectator']) + w.players.append(f'{x:.3f},{y},{z:.3f}/{mode}') + + +def common(w, rng, seed): + w.kv['seed'] = seed + w.kv['wseed'] = rng.randrange(-2**63, 2**63) + if w.kv['dim'] == 'minecraft:overworld': + w.kv['darken'] = rng.choice([0, 0, 2, 4, 7, 11, 11, 11, 11, 11]) + if rng.random() < 0.08: + w.kv['thunder'] = 1 + if rng.random() < 0.3: + w.kv['slime'] = rng.choice([0.0, 0.125, 0.25, 0.5]) + w.kv['difficulty'] = rng.choice(['normal', 'normal', 'hard', 'easy', 'peaceful']) + if rng.random() < 0.15: + w.kv['respawn'] = f'{rng.randrange(-20, 36)},{w.surface},{rng.randrange(-20, 36)}/{w.kv["dim"]}' + if rng.random() < 0.2: + w.kv['unloaded'] = ';'.join(f'{rng.choice([-1, 0, 1])},{rng.choice([-1, 0, 1])}' for _ in range(rng.randrange(1, 5))) + + +def make_world(rng, seed, dim=None): + w = World(rng) + dim = dim or rng.choices(['overworld', 'nether', 'end'], [70, 22, 8])[0] + if dim == 'overworld': + overworld(w, rng) + elif dim == 'nether': + w.min_y = 0 + nether(w, rng) + else: + w.min_y = 0 + end(w, rng) + structures(w, rng) + mob_entities(w, rng) + place_players(w, rng) + common(w, rng, seed) + return w + + +def corpus(): + rng = random.Random(0x4E5350) + cases = [] + # Whole chunk ticks. + for k in range(3000): + w = make_world(rng, rng.randrange(-2**47, 2**47)) + if rng.random() < 0.2: + w.kv['chunkcount'] = rng.choice([0, 1, 4, 17, 40, 100, 200, 289, 578]) + w.kv['enemies'] = 1 if rng.random() < 0.9 else 0 + w.kv['persistent'] = 1 if rng.random() < 0.5 else 0 + cases.append(f'chunk/{k} mode=chunk {w.tokens()}') + # Chosen starts at sites: surfaces, cave floors, water, lava, structure floors. + for k in range(7000): + w = make_world(rng, rng.randrange(-2**47, 2**47)) + dim = w.kv['dim'] + x, y, z, kind = rng.choice(w.sites) + if kind == 'structure' and rng.random() < 0.5: + x, z = x + rng.randrange(-3, 4), z + rng.randrange(-3, 4) + if rng.random() < 0.1: + y = rng.randrange(w.min_y + 1, w.surface + 2) + if dim == 'minecraft:the_nether': + category = 'creature' if kind == 'lava' or rng.random() < 0.2 else 'monster' + elif dim == 'minecraft:the_end': + category = 'monster' + elif kind == 'water': + category = rng.choice(['water_creature', 'water_ambient', 'axolotls', 'underground_water_creature', 'monster']) + elif kind == 'cave': + category = rng.choice(['monster', 'monster', 'ambient', 'ambient', 'creature']) + elif kind == 'structure': + category = rng.choice(['monster', 'monster', 'creature', 'ambient', 'water_ambient']) + else: + category = rng.choice(['monster', 'creature', 'creature', 'ambient']) + if rng.random() < 0.05: + category = rng.choice(CATEGORIES) + cases.append(f'pos/{k} mode=pos cat={category} start={x},{y},{z} {w.tokens()}') + # Chunk generation: creature packs placed on the surface. + for k in range(1500): + w = World(rng) + dim = rng.choices(['overworld', 'nether'], [85, 15])[0] + if dim == 'overworld': + biome = rng.choice(list(OVERWORLD) + ['minecraft:frozen_ocean', 'minecraft:beach'] * 2) + if biome in OVERWORLD: + overworld(w, rng, biome=biome, ocean=False) + else: + overworld(w, rng, biome=biome, ocean=True) + else: + w.min_y = 0 + nether(w, rng) + common(w, rng, rng.randrange(-2**47, 2**47)) + w.kv['gseed'] = rng.randrange(-2**47, 2**47) + if rng.random() < 0.05: + w.kv['rules'] = 0 + w.kv['chunk'] = f'{rng.randrange(-3, 3)},{rng.randrange(-3, 3)}' + cases.append(f'gen/{k} mode=gen {w.tokens()}') + return cases + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--prepare', action='store_true') + args = parser.parse_args() + build = lab.LOCAL / 'rust-natural-spawn-parity' + build.mkdir(parents=True, exist_ok=True) + if args.prepare: + lab.prepare() + lock, jars, libraries = base.verify_artifacts() + resources = lab.LOCAL / 'horizon-client/26.3' + oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNaturalSpawnOracle.java' + sources = [oracle, Path(__file__), lab.ROOT / 'crates/horizon-client/examples/natural_spawn_fixture.rs', + lab.ROOT / 'crates/horizon-client/src/mobs/spawn_selection.rs', + lab.ROOT / 'crates/horizon-client/src/mobs/spawn_rules.rs'] + tested = base.source_hashes(sources) + inputs = build / 'inputs.txt' + cases = corpus() + inputs.write_text(''.join(line + '\n' for line in cases), encoding='utf-8') + classes = build / 'classes' + classes.mkdir(exist_ok=True) + deps = os.pathsep.join(map(str, libraries)) + + def run(command, name): + with (build / (name + '.log')).open('w', encoding='utf-8') as log: + base.run(list(map(str, command)), stdout=log, stderr=subprocess.STDOUT) + run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-sourcepath', str(lab.ROOT / 'mods/horizon/src/test/java'), + '-cp', str(jars['server']) + os.pathsep + deps, '-d', classes, oracle], 'compile-java') + run(['java', '-Xmx3G', '-cp', os.pathsep.join([str(classes), str(jars['server']), deps]), + 'dev.horizonlab.parity.VanillaNaturalSpawnOracle', inputs, build / 'server.txt'], 'server') + run(['cargo', 'build', '--release', '-p', 'horizon-client', '--example', 'natural_spawn_fixture'], 'compile-rust') + metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'], cwd=lab.ROOT, text=True)) + binary = Path(metadata['target_directory']) / 'release/examples/natural_spawn_fixture' + run([binary, resources, inputs, build / 'rust.txt'], 'rust') + vanilla = (build / 'server.txt').read_text().splitlines() + errors = [l for l in vanilla if ' error ' in l] + if errors: + raise SystemExit(f'oracle errors ({len(errors)}): {errors[:5]}') + rust = (build / 'rust.txt').read_text().splitlines() + bad = [(a, b) for a, b in zip(vanilla, rust) if a != b] + if bad or len(vanilla) != len(rust): + report = '\n'.join(f' vanilla {a[:1500]}\n rust {b[:1500]}' for a, b in bad[:20]) + raise SystemExit(f'{len(bad)} of {len(vanilla)} cases differ (rust {len(rust)} lines):\n{report}') + base.ensure_sources_unchanged(sources, tested) + spawns = sum(line.count(' ; spawn ') for line in vanilla) + attempts = sum(line.count(' ; try ') + line.count(' ; col ') for line in vanilla) + tests = sum(line.count(' ; test ') for line in vanilla) + lab.write_json(build / 'report.json', { + 'schema': 1, 'minecraft': lock['minecraft']['version'], 'platform': platform.platform(), 'cases': len(cases), + 'attempts': attempts, 'type_checks_passed': tests, 'spawns': spawns, 'sources_sha256': tested, + 'scope': 'NaturalSpawner: createState (counts, spawn potential, local caps), getFilteredSpawningCategories, ' + 'spawnForChunk, spawnCategoryForPosition (distance and respawn checks, getRandomSpawnMobAt over mobsAt: ' + 'fortress bounds, structure spawn overrides, biome spawns; isValidSpawnPostitionForType with ' + 'SpawnPlacements; isValidPositionForMob with each class\'s checkSpawnRules, checkSpawnObstruction and ' + 'removeWhenFarAway; cluster and group sizes) and spawnMobsForChunkGeneration, with every random draw. ' + 'finalizeSpawn runs with a fixed random in the oracle; its own outcomes are not compared.'}) + print(f'{len(cases):,} natural spawn cases match vanilla ({attempts:,} attempts, {tests:,} type checks passed, ' + f'{spawns:,} spawns)') + + +if __name__ == '__main__': + main() From 6949ca445504db7e24d37ec220fd6735a261f754 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:14:22 +0200 Subject: [PATCH 6/8] Target rarer natural spawns in the parity harness Desert, snowy, swamp and jungle nights, lush, sulfur and dripstone caves, warm and frozen oceans, crimson forests and slime chunks. A cube mob grown by finalizeSpawn takes its constructor's head rotation from the entity's unseeded random (AbstractCubeMob.onSyncedDataUpdated), so that yaw alone is not compared. Co-Authored-By: Claude Opus 5.5 --- .../examples/natural_spawn_fixture.rs | 15 +++- .../parity/VanillaNaturalSpawnOracle.java | 13 +++- tools/test_natural_spawn_parity.py | 76 ++++++++++++++++++- 3 files changed, 94 insertions(+), 10 deletions(-) diff --git a/crates/horizon-client/examples/natural_spawn_fixture.rs b/crates/horizon-client/examples/natural_spawn_fixture.rs index 7cec0d067..b2f0a8900 100644 --- a/crates/horizon-client/examples/natural_spawn_fixture.rs +++ b/crates/horizon-client/examples/natural_spawn_fixture.rs @@ -342,8 +342,15 @@ fn parse_state(spec: &str) -> BlockState { fn bits(v: f64) -> String { format!("{:x}", v.to_bits()) } -fn fbits(v: f32) -> String { - format!("{:x}", v.to_bits()) +/// The yaw as the oracle prints it: a cube mob grown by `finalizeSpawn` +/// (sulfur cubes, size 2 under the fixed random) takes its constructor's +/// head rotation from the entity's unseeded random. +fn yaw_text(entity: &str, v: f32) -> String { + if entity == "minecraft:sulfur_cube" { + "head".to_owned() + } else { + format!("{:x}", v.to_bits()) + } } fn main() -> horizon_client::Result<()> { @@ -581,7 +588,7 @@ fn main() -> horizon_client::Result<()> { bits(p[0]), bits(p[1]), bits(p[2]), - fbits(yaw) + yaw_text(&t, yaw) )), }, ); @@ -638,7 +645,7 @@ fn main() -> horizon_client::Result<()> { bits(p[0]), bits(p[1]), bits(p[2]), - fbits(yaw) + yaw_text(&t, yaw) ), }) }; diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNaturalSpawnOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNaturalSpawnOracle.java index d77a7f000..7f1420eba 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNaturalSpawnOracle.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNaturalSpawnOracle.java @@ -302,6 +302,15 @@ public final class VanillaNaturalSpawnOracle { } static String bits(double v) { return Long.toHexString(Double.doubleToRawLongBits(v)); } + /** + * A spawned mob's yaw. A cube mob whose size finalizeSpawn changed took its + * constructor's head rotation (AbstractCubeMob.onSyncedDataUpdated), drawn + * from the entity's own unseeded random: not reproducible, so not compared. + */ + static String yaw(Entity e) { + if (e instanceof net.minecraft.world.entity.monster.cubemob.AbstractCubeMob cube && cube.getSize() != 1) return "head"; + return bits(e.getYRot()); + } static String bits(float v) { return Integer.toHexString(Float.floatToRawIntBits(v)); } public static void main(String[] args) throws Exception { @@ -476,7 +485,7 @@ public final class VanillaNaturalSpawnOracle { level.logColumns = false; for (Entity e : level.entities.subList(n, level.entities.size())) if (e.getVehicle() == null) - log.add("spawn " + BuiltInRegistries.ENTITY_TYPE.getKey(e.getType()) + " " + bits(e.getX()) + " " + bits(e.getY()) + " " + bits(e.getZ()) + " " + bits(e.getYRot())); + log.add("spawn " + BuiltInRegistries.ENTITY_TYPE.getKey(e.getType()) + " " + bits(e.getX()) + " " + bits(e.getY()) + " " + bits(e.getZ()) + " " + yaw(e)); log.add("next " + random.nextInt(1000)); } else { Map chunks = new HashMap<>(); @@ -501,7 +510,7 @@ public final class VanillaNaturalSpawnOracle { } catch (Exception ex) { throw new RuntimeException(ex); } }; NaturalSpawner.AfterSpawnCallback after = (mob, ch) -> { - log.add("spawn " + BuiltInRegistries.ENTITY_TYPE.getKey(mob.getType()) + " " + bits(mob.getX()) + " " + bits(mob.getY()) + " " + bits(mob.getZ()) + " " + bits(mob.getYRot())); + log.add("spawn " + BuiltInRegistries.ENTITY_TYPE.getKey(mob.getType()) + " " + bits(mob.getX()) + " " + bits(mob.getY()) + " " + bits(mob.getZ()) + " " + yaw(mob)); try { afterSpawn.invoke(state, mob, ch); } catch (Exception ex) { throw new RuntimeException(ex); } }; if (mode.equals("pos")) { diff --git a/tools/test_natural_spawn_parity.py b/tools/test_natural_spawn_parity.py index 7c3a6f472..d6db51fba 100644 --- a/tools/test_natural_spawn_parity.py +++ b/tools/test_natural_spawn_parity.py @@ -75,11 +75,13 @@ OVERWORLD = { 'minecraft:pale_garden': ('minecraft:grass_block', 'minecraft:dirt'), 'minecraft:grove': ('minecraft:snow_block', 'minecraft:dirt'), 'minecraft:snowy_slopes': ('minecraft:snow_block', 'minecraft:stone'), + 'minecraft:savanna_plateau': ('minecraft:grass_block', 'minecraft:dirt'), + 'minecraft:eroded_badlands': ('minecraft:red_sand', 'minecraft:terracotta'), } OCEANS = ['minecraft:ocean', 'minecraft:deep_ocean', 'minecraft:warm_ocean', 'minecraft:lukewarm_ocean', 'minecraft:deep_lukewarm_ocean', 'minecraft:cold_ocean', 'minecraft:deep_cold_ocean', 'minecraft:frozen_ocean', 'minecraft:deep_frozen_ocean', 'minecraft:river', 'minecraft:frozen_river'] -CAVES = ['minecraft:lush_caves', 'minecraft:dripstone_caves', 'minecraft:deep_dark'] +CAVES = ['minecraft:lush_caves', 'minecraft:dripstone_caves', 'minecraft:deep_dark', 'minecraft:sulfur_caves'] NETHER = { 'minecraft:nether_wastes': 'minecraft:netherrack', 'minecraft:crimson_forest': 'minecraft:crimson_nylium', @@ -107,6 +109,26 @@ CATEGORIES = ['monster', 'creature', 'ambient', 'axolotls', 'underground_water_c TORCH_LIGHT = [0, 0, 0, 1, 4, 7, 8, 9, 12, 14] +def java_next_int(seed, bound): + """java.util.Random(seed).nextInt(bound).""" + mask = (1 << 48) - 1 + state = (seed ^ 0x5DEECE66D) & mask + while True: + state = (state * 0x5DEECE66D + 0xB) & mask + bits = state >> 17 + value = bits % bound + if bits - value + bound - 1 < 2**31: + return value + + +def slime_chunk(world_seed, cx, cz): + """WorldgenRandom.seedSlimeChunk(cx, cz, seed, 987234911).nextInt(10) == 0.""" + def i32(v): + return (v + 2**31) % 2**32 - 2**31 + seed = (world_seed + i32(cx * cx * 4987142) + i32(cx * 5947611) + i32(cz * cz) * 4392871 + i32(cz * 389711)) ^ 987234911 + return java_next_int(seed & (2**64 - 1), 10) == 0 + + def box(x0, y0, z0, x1, y1, z1): return f'{x0},{y0},{z0},{x1},{y1},{z1}' @@ -396,14 +418,14 @@ def common(w, rng, seed): w.kv['unloaded'] = ';'.join(f'{rng.choice([-1, 0, 1])},{rng.choice([-1, 0, 1])}' for _ in range(rng.randrange(1, 5))) -def make_world(rng, seed, dim=None): +def make_world(rng, seed, dim=None, biome=None, ocean=None): w = World(rng) dim = dim or rng.choices(['overworld', 'nether', 'end'], [70, 22, 8])[0] if dim == 'overworld': - overworld(w, rng) + overworld(w, rng, biome=biome, ocean=ocean) elif dim == 'nether': w.min_y = 0 - nether(w, rng) + nether(w, rng, biome=biome) else: w.min_y = 0 end(w, rng) @@ -449,6 +471,52 @@ def corpus(): if rng.random() < 0.05: category = rng.choice(CATEGORIES) cases.append(f'pos/{k} mode=pos cat={category} start={x},{y},{z} {w.tokens()}') + # Targeted nights and places for the rarer types. + targets = [ + ('minecraft:desert', False, 'surface', 'monster'), ('minecraft:desert', False, 'surface', 'creature'), + ('minecraft:snowy_plains', False, 'surface', 'monster'), ('minecraft:ice_spikes', False, 'surface', 'creature'), + ('minecraft:swamp', False, 'surface', 'monster'), ('minecraft:mangrove_swamp', False, 'surface', 'monster'), + ('minecraft:jungle', False, 'surface', 'monster'), ('minecraft:bamboo_jungle', False, 'surface', 'monster'), + ('minecraft:plains', False, 'surface', 'monster'), ('minecraft:savanna', False, 'surface', 'creature'), + ('minecraft:badlands', False, 'surface', 'creature'), ('minecraft:warm_ocean', True, 'water', 'water_ambient'), + ('minecraft:lukewarm_ocean', True, 'water', 'water_creature'), ('minecraft:deep_ocean', True, 'water', 'monster'), + ('minecraft:frozen_ocean', True, 'water', 'creature'), ('minecraft:lush_caves', None, 'cave', 'axolotls'), + ('minecraft:sulfur_caves', None, 'cave', 'monster'), ('minecraft:dripstone_caves', None, 'cave', 'monster'), + ('minecraft:crimson_forest', 'nether', 'surface', 'monster'), ('slime', None, 'cave', 'monster'), + ('minecraft:river', True, 'water', 'monster'), + ] + for k in range(4000): + biome, ocean, kind, category = rng.choice(targets) + if ocean == 'nether': + w = make_world(rng, rng.randrange(-2**47, 2**47), dim='nether', biome=biome) + elif kind == 'cave': + w = make_world(rng, rng.randrange(-2**47, 2**47), dim='overworld', ocean=False) + else: + w = make_world(rng, rng.randrange(-2**47, 2**47), dim='overworld', biome=biome, ocean=ocean) + w.kv['darken'] = 11 if category == 'monster' else rng.choice([0, 0, 4, 11]) + w.kv.pop('thunder', None) + sites = [t for t in w.sites if t[3] == kind] or w.sites + x, y, z, _ = rng.choice(sites) + if kind == 'cave': + # A dark room under the start, in the cave biome (or a slime chunk). + w.block(box(x - 6, y, z - 6, x + 6, y + 4, z + 6), 'minecraft:cave_air') + floor = 'minecraft:clay' if category == 'axolotls' else rng.choice(['minecraft:stone', 'minecraft:deepslate']) + w.block(box(x - 6, y - 1, z - 6, x + 6, y - 1, z + 6), floor) + if category == 'axolotls' or biome == 'minecraft:dripstone_caves' and rng.random() < 0.5: + w.block(box(x - 6, y, z - 6, x + 6, y + 2, z + 6), 'minecraft:water') + w.light(box(x - 6, y, z - 6, x + 6, y + 4, z + 6), 0, rng.choice([0, 0, 0, 3])) + if biome == 'slime': + y = min(y, rng.randrange(-50, 38)) + w.block(box(x - 6, y, z - 6, x + 6, y + 4, z + 6), 'minecraft:cave_air') + w.block(box(x - 6, y - 1, z - 6, x + 6, y - 1, z + 6), 'minecraft:stone') + w.light(box(x - 6, y, z - 6, x + 6, y + 4, z + 6), 0, 0) + while not slime_chunk(w.kv['wseed'], 0, 0): + w.kv['wseed'] = rng.randrange(-2**63, 2**63) + else: + w.biomes.append(f'{box(x - 8, y - 3, z - 8, x + 8, y + 6, z + 8)}/{biome}') + if biome in ('minecraft:swamp', 'minecraft:mangrove_swamp'): + w.kv['slime'] = rng.choice([0.0, 0.25, 0.5]) + cases.append(f'target/{k} mode=pos cat={category} start={x},{y},{z} {w.tokens()}') # Chunk generation: creature packs placed on the surface. for k in range(1500): w = World(rng) From 7994c1e8c5b35b3f46426bf8cbf00a170cc15a15 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:19:49 +0200 Subject: [PATCH 7/8] Count natural spawning among the entity checks that match vanilla Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- crates/horizon-client/src/mobs/spawn_selection.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 863822f8f..9d20a6f5c 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ round-trip harness has not been rerun. | Lighting | 1 | None: saved and relit light matches vanilla, slabs and stairs included | | World generation | 1 | None: 2,352 whole chunks over 225 structure sites, in all three dimensions, identical to vanilla | | Region files and saves | 0.75 | Save round-trip harness fails; older DataVersions are not upgraded | -| Entities and physics | 0.75 | Attributes and dimensions match; AI, loot and spawn rules not compared; drowned, villagers, traders and the wither unverified | +| Entities and physics | 0.75 | Attributes, dimensions, death loot, spawn rules and natural spawning (15,500 NaturalSpawner cases) match; AI not compared; drowned, villagers, traders and the wither unverified | | Interaction and inventory | 1 | None: menu clicks match over 6,000 click sequences | | Menus, HUD and chat | 0.75 | Commands parse as vanilla over 39,685 inputs, but /item modify, /spectate, /waypoint and /dialog do not run; no tab list or world border rendering | | Audio | 0.75 | Sound engine, music and ambience not compared with vanilla | @@ -49,7 +49,7 @@ round-trip harness has not been rerun. 1. Rerun the save round-trip harness and upgrade saves from older DataVersions. 2. Draw every item through the item model pipeline, and add the resource pack screen. 3. An audio harness: sound engine attenuation, weighted sound events, music and ambience. -4. Mob AI, loot and spawn-rule harnesses, and an oracle for drowned, villagers, traders and the wither. +4. A mob AI harness, and an oracle for drowned, villagers, traders and the wither. 5. The missing commands and dispenser behaviours, the tab list and world border rendering. 6. Test LAN hosting with a vanilla client, and add a dedicated server mode. diff --git a/crates/horizon-client/src/mobs/spawn_selection.rs b/crates/horizon-client/src/mobs/spawn_selection.rs index c67fea46b..74b371230 100644 --- a/crates/horizon-client/src/mobs/spawn_selection.rs +++ b/crates/horizon-client/src/mobs/spawn_selection.rs @@ -1038,7 +1038,7 @@ pub fn spawn_category_for_chunk( let min = l.min_y(); let y = min + random.next_int_bound(top - min + 1).unwrap_or(0); trace(Event::Start(category, [x, y, z])); - if y >= min + 1 { + if y > min { spawn_category_for_position(l, random, state, category, chunk, [x, y, z], spawn, trace); } } From 2eb688cc6a12c508aee4d30764f6a22584e83bc3 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:36:20 +0200 Subject: [PATCH 8/8] Decrypt bytes already read when a connection enables encryption Encrypted bytes that arrived in the same read as the last plain packet stayed in the buffer undecrypted, and the connection then waited forever for a frame it could not parse. The loopback test hit this under load. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-protocol/src/connection.rs | 28 ++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/horizon-protocol/src/connection.rs b/crates/horizon-protocol/src/connection.rs index 5c0bba188..cac0ab60f 100644 --- a/crates/horizon-protocol/src/connection.rs +++ b/crates/horizon-protocol/src/connection.rs @@ -82,7 +82,10 @@ impl Connection { } /// Connection.setEncryptionKey: both directions keyed with the shared secret. pub fn enable_encryption(&mut self, secret: &[u8; 16]) { - self.reader.decrypt = Some(Cfb8::new(secret, secret)); + let mut decrypt = Cfb8::new(secret, secret); + // Bytes already read past the last packet were sent encrypted. + decrypt.decrypt(&mut self.reader.buffer); + self.reader.decrypt = Some(decrypt); self.writer.encrypt = Some(Cfb8::new(secret, secret)); } pub fn send(&mut self, body: &[u8]) -> Result<()> { @@ -180,4 +183,27 @@ mod tests { assert_eq!((id, payload.as_slice()), (9, &b"reply"[..])); server.join().unwrap(); } + + /// Encrypted bytes that arrive in the same read as the last plain packet + /// are decrypted once encryption is enabled. + #[test] + fn encrypted_bytes_read_before_enabling_encryption() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let (sent, both_sent) = std::sync::mpsc::channel(); + let client = std::thread::spawn(move || { + let mut c = Connection::connect("127.0.0.1", port, Duration::from_secs(5)).unwrap(); + c.send(&[0, 1, 2]).unwrap(); + c.enable_encryption(&[7; 16]); + c.send(&[5, 4, 3]).unwrap(); + sent.send(c).unwrap(); + }); + let (stream, _) = listener.accept().unwrap(); + let mut c = Connection::from_stream(stream).unwrap(); + let _keep_open = both_sent.recv().unwrap(); + assert_eq!(c.recv().unwrap(), (0, vec![1, 2])); + c.enable_encryption(&[7; 16]); + assert_eq!(c.recv().unwrap(), (5, vec![4, 3])); + client.join().unwrap(); + } }