mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
66
Commits
No files matched your search
@@ -46,7 +46,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
|
||||
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation (vanilla's heights and biomes for now).
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** Horizon has its own mods, installed from the launcher. The official ones (Zoom, FPS Counter, Keystrokes and Minimap) are native Rust that the launcher builds into your game, with no Rust to install; older WebAssembly mods still run while they move. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
|
||||
@@ -266,6 +266,13 @@ mod tests {
|
||||
assert_eq!(en.get("horizon.options.controller"), Some("Controller"));
|
||||
let ru = Language::load(&[&empty], "ru_ru");
|
||||
assert_eq!(ru.get("horizon.options.controller"), Some("Геймпад"));
|
||||
// Every bundled string carries both languages: a key added to one file
|
||||
// and forgotten in the other shows as a key name on screen.
|
||||
for key in en.map().keys() {
|
||||
assert!(ru.get(key).is_some(), "{key} has no ru_ru value");
|
||||
}
|
||||
assert_eq!(en.get("horizon.pages.mods"), Some("Mods"));
|
||||
assert_eq!(ru.get("horizon.pages.mods"), Some("Моды"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -256,6 +256,10 @@ impl Biomes {
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
/// Whether the game has the biome `name`.
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.index.contains_key(name)
|
||||
}
|
||||
pub fn get(&self, id: u16) -> &BiomeInfo {
|
||||
&self.list[usize::from(id).min(self.list.len() - 1)]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
//! the parts of them commands use: `.mcfunction` functions (with macros and
|
||||
//! line continuation), function tags and predicates.
|
||||
use crate::zip::Zip;
|
||||
use horizon_world::nbt::Tag;
|
||||
use horizon_world::{
|
||||
nbt::Tag,
|
||||
worldgen::overrides::{self, PackFile, WorldgenOverrides},
|
||||
};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::{Path, PathBuf},
|
||||
@@ -125,6 +128,114 @@ pub fn discover(world: &Path) -> Vec<DataPack> {
|
||||
packs
|
||||
}
|
||||
|
||||
// ---- Create World ------------------------------------------------------------
|
||||
|
||||
/// Create World's Data Packs screen (`PackSelectionScreen` on vanilla's
|
||||
/// `mcworld-` folder): the folder's packs (`PackSource.WORLD`) and the
|
||||
/// built-in one (`resources` holds its `pack.png`), which can be unselected
|
||||
/// and stays at the bottom. Horizon has no experiments, so their feature
|
||||
/// packs are not offered.
|
||||
pub fn screen_packs(dir: &Path, resources: &Path) -> Vec<crate::pack_screen::PackInfo> {
|
||||
use crate::{
|
||||
pack_screen::{Compat, PackInfo, Source, load_icon},
|
||||
packs::Position,
|
||||
text::{Style, Text},
|
||||
};
|
||||
let format = horizon_core::version::DATA_PACK_FORMAT;
|
||||
let mut infos: Vec<PackInfo> = crate::packs::folder(dir, format)
|
||||
.into_iter()
|
||||
.map(|p| PackInfo {
|
||||
icon: p.icon_png().as_deref().and_then(load_icon),
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
description: p.raw_description,
|
||||
description_style: p.description_style,
|
||||
source: Source::World,
|
||||
compat: p.compatibility,
|
||||
required: false,
|
||||
position: Position::Top,
|
||||
fixed: false,
|
||||
})
|
||||
.collect();
|
||||
infos.push(PackInfo {
|
||||
id: "vanilla".into(),
|
||||
title: Text::translate("dataPack.vanilla.name", &[]),
|
||||
description: Text::translate("dataPack.vanilla.description", &[]),
|
||||
description_style: Style::default(),
|
||||
source: Source::BuiltIn,
|
||||
compat: Compat::Compatible,
|
||||
icon: std::fs::read(resources.join("pack.png"))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(load_icon),
|
||||
required: false,
|
||||
position: Position::Bottom,
|
||||
fixed: false,
|
||||
});
|
||||
infos
|
||||
}
|
||||
/// A new empty folder for Create World's packs
|
||||
/// (`Files.createTempDirectory("mcworld-")`).
|
||||
pub fn temp_folder() -> std::io::Result<PathBuf> {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("mcworld-{}-{nanos}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
/// What's in a folder of packs, to notice packs added or removed while the
|
||||
/// screen shows it (`PackSelectionScreen.Watcher`).
|
||||
pub fn folder_stamp(dir: &Path) -> Vec<(String, Option<std::time::SystemTime>)> {
|
||||
let mut stamp: Vec<_> = std::fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| {
|
||||
let modified = e.metadata().and_then(|m| m.modified()).ok();
|
||||
(e.file_name().to_string_lossy().into_owned(), modified)
|
||||
})
|
||||
.collect();
|
||||
stamp.sort();
|
||||
stamp
|
||||
}
|
||||
/// Create World's packs into the new world before it opens
|
||||
/// (`copyTempDataPackDirToNewWorld`): every pack of `dir` goes to
|
||||
/// `<world>/datapacks`, and `datapacks.json` enables those `selected`
|
||||
/// (lowest priority first) and disables the rest.
|
||||
pub fn install(dir: &Path, world: &Path, selected: &[String]) -> Result<(), String> {
|
||||
let err = |p: &Path, e: std::io::Error| format!("{}: {e}", p.display());
|
||||
let target = world.join("datapacks");
|
||||
std::fs::create_dir_all(&target).map_err(|e| err(&target, e))?;
|
||||
let mut disabled = Vec::new();
|
||||
for pack in crate::packs::folder(dir, horizon_core::version::DATA_PACK_FORMAT) {
|
||||
let (crate::packs::Source::Dir(from) | crate::packs::Source::Zip(from)) = &pack.source;
|
||||
copy(from, &target.join(pack.id.trim_start_matches("file/")))?;
|
||||
if !selected.contains(&pack.id) {
|
||||
disabled.push(pack.id);
|
||||
}
|
||||
}
|
||||
if !selected.iter().any(|id| id == "vanilla") {
|
||||
disabled.push("vanilla".into());
|
||||
}
|
||||
let json = serde_json::json!({ "enabled": selected, "disabled": disabled });
|
||||
let file = world.join("datapacks.json");
|
||||
std::fs::write(&file, json.to_string()).map_err(|e| err(&file, e))
|
||||
}
|
||||
/// A file, or a folder and everything in it.
|
||||
fn copy(from: &Path, to: &Path) -> Result<(), String> {
|
||||
let err = |e: std::io::Error| format!("{} -> {}: {e}", from.display(), to.display());
|
||||
if !from.is_dir() {
|
||||
return std::fs::copy(from, to).map(drop).map_err(err);
|
||||
}
|
||||
std::fs::create_dir_all(to).map_err(err)?;
|
||||
for e in std::fs::read_dir(from).map_err(err)?.flatten() {
|
||||
copy(&e.path(), &to.join(e.file_name()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- functions ---------------------------------------------------------------
|
||||
|
||||
/// A `.mcfunction`: plain command lines and macro lines.
|
||||
@@ -421,9 +532,138 @@ fn hash_normalize(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The ids of `packs` (a world's, from `discover`) that are enabled, lowest
|
||||
/// priority first: those `<world>/datapacks.json` lists as enabled, in its
|
||||
/// order, then every pack it lists neither way, in name order.
|
||||
pub fn enabled_ids(world: &Path, packs: &[DataPack]) -> Vec<String> {
|
||||
let saved: Option<serde_json::Value> = std::fs::read(world.join("datapacks.json"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok());
|
||||
let list = |key: &str| -> Vec<String> {
|
||||
saved
|
||||
.as_ref()
|
||||
.and_then(|v| v[key].as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|s| s.as_str().map(str::to_owned))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let disabled = list("disabled");
|
||||
let mut enabled: Vec<String> = list("enabled")
|
||||
.into_iter()
|
||||
.filter(|id| packs.iter().any(|p| &p.id == id))
|
||||
.collect();
|
||||
for p in packs {
|
||||
if !enabled.contains(&p.id) && !disabled.contains(&p.id) {
|
||||
enabled.push(p.id.clone());
|
||||
}
|
||||
}
|
||||
enabled
|
||||
}
|
||||
|
||||
// ---- worldgen ----------------------------------------------------------------
|
||||
|
||||
/// The worldgen entries of `packs` (lowest priority first, the built-in pack
|
||||
/// left out: the prepared bundles are it) for the chunk generator, and what
|
||||
/// it leaves out, for the log. `biomes` says which biomes the game has: a
|
||||
/// pack's new biome is left out (the game has no colours, sounds or id for it).
|
||||
pub fn worldgen_overrides(
|
||||
packs: &[&DataPack],
|
||||
biomes: impl Fn(&str) -> bool,
|
||||
) -> (WorldgenOverrides, Vec<String>) {
|
||||
let mut out = WorldgenOverrides::default();
|
||||
let mut not_read: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut warnings = Vec::new();
|
||||
for pack in packs {
|
||||
for file in pack.list_kinds(&overrides::PACK_KINDS) {
|
||||
let read = || String::from_utf8(pack.read(&file)?).ok();
|
||||
match out.add_file(&pack.id, &file, read) {
|
||||
PackFile::Read | PackFile::Other => {}
|
||||
PackFile::DimensionType { id, min_y, height } => warnings.push(format!(
|
||||
"{}: dimension type {id} (min_y {min_y}, height {height}) is not used: \
|
||||
the dimension keeps its vanilla build height",
|
||||
pack.id
|
||||
)),
|
||||
PackFile::NotRead(kind) => {
|
||||
*not_read.entry(format!("{}: {kind}", pack.id)).or_default() += 1
|
||||
}
|
||||
PackFile::Unreadable => warnings.push(format!("{}: {file}: unreadable", pack.id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
warnings.extend(out.finish(biomes));
|
||||
for (kind, files) in not_read {
|
||||
warnings.push(format!("{kind}: {files} files the generator does not read"));
|
||||
}
|
||||
(out, warnings)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn enabled_packs_override_worldgen_in_order() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let write = |pack: &str, p: &str, t: &str| {
|
||||
let path = dir.join("datapacks").join(pack).join(p);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, t).unwrap();
|
||||
};
|
||||
for pack in ["a", "b", "off"] {
|
||||
write(pack, "pack.mcmeta", "{}");
|
||||
write(pack, "data/minecraft/worldgen/noise/cave.json", pack);
|
||||
write(
|
||||
pack,
|
||||
"data/minecraft/tags/worldgen/biome/is_hill.json",
|
||||
"{\"values\":[]}",
|
||||
);
|
||||
}
|
||||
write("a", "data/minecraft/worldgen/biome/new_peaks.json", "{}");
|
||||
write("a", "data/minecraft/worldgen/biome/plains.json", "{}");
|
||||
write(
|
||||
"a",
|
||||
"data/minecraft/worldgen/world_preset/normal.json",
|
||||
"{}",
|
||||
);
|
||||
write(
|
||||
"b",
|
||||
"data/minecraft/dimension_type/overworld.json",
|
||||
"{\"min_y\":-64,\"height\":2096}",
|
||||
);
|
||||
// `b` is enabled first, so `a` (enabled after it) wins.
|
||||
std::fs::write(
|
||||
dir.join("datapacks.json"),
|
||||
"{\"enabled\":[\"file/b\",\"file/a\"],\"disabled\":[\"file/off\"]}",
|
||||
)
|
||||
.unwrap();
|
||||
let packs = discover(&dir);
|
||||
let enabled = enabled_ids(&dir, &packs);
|
||||
assert_eq!(enabled, ["file/b", "file/a"]);
|
||||
let enabled: Vec<&DataPack> = enabled
|
||||
.iter()
|
||||
.map(|id| packs.iter().find(|p| &p.id == id).unwrap())
|
||||
.collect();
|
||||
let (o, warnings) = worldgen_overrides(&enabled, |b| b == "minecraft:plains");
|
||||
assert_eq!(
|
||||
o.entries("noises").collect::<Vec<_>>(),
|
||||
[("minecraft:cave", "a")]
|
||||
);
|
||||
assert_eq!(
|
||||
o.entries("biomes").collect::<Vec<_>>(),
|
||||
[("minecraft:plains", "{}")]
|
||||
);
|
||||
let tag_packs: Vec<&str> = o.tags("biome_tags").map(|(_, p, _)| p).collect();
|
||||
assert_eq!(tag_packs, ["file/b", "file/a"]);
|
||||
assert_eq!(warnings.len(), 3, "{warnings:?}");
|
||||
assert!(warnings[0].contains("height 2096"));
|
||||
assert!(warnings[1].contains("minecraft:new_peaks"));
|
||||
assert!(warnings[2].contains("worldgen/world_preset"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
use horizon_world::nbt::parse_snbt;
|
||||
|
||||
#[test]
|
||||
@@ -566,4 +806,51 @@ mod tests {
|
||||
assert_eq!(p.list_kinds(&kinds), wanted);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_world_installs_its_packs() {
|
||||
let root = std::env::temp_dir().join(format!("hz-datapack-install-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let (dir, world) = (root.join("mcworld"), root.join("world"));
|
||||
let write = |pack: &str, p: &str, t: &str| {
|
||||
let path = dir.join(pack).join(p);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, t).unwrap();
|
||||
};
|
||||
write("fns", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
|
||||
write("fns", "data/x/function/f.mcfunction", "say hi");
|
||||
write("off", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(dir.join("old.zip")).unwrap());
|
||||
zip.start_file("pack.mcmeta", zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
std::io::Write::write_all(&mut zip, br#"{"pack":{"pack_format":48}}"#).unwrap();
|
||||
zip.finish().unwrap();
|
||||
let infos = screen_packs(&dir, &root);
|
||||
let ids: Vec<&str> = infos.iter().map(|p| p.id.as_str()).collect();
|
||||
assert_eq!(ids, ["file/fns", "file/off", "file/old.zip", "vanilla"]);
|
||||
let compat: Vec<_> = infos.iter().map(|p| p.compat).collect();
|
||||
use crate::pack_screen::Compat;
|
||||
assert_eq!(
|
||||
compat,
|
||||
[
|
||||
Compat::Compatible,
|
||||
Compat::Compatible,
|
||||
Compat::TooOld,
|
||||
Compat::Compatible
|
||||
]
|
||||
);
|
||||
let selected = ["vanilla", "file/old.zip", "file/fns"].map(String::from);
|
||||
install(&dir, &world, &selected).unwrap();
|
||||
let packs = discover(&world);
|
||||
let ids: Vec<&str> = packs.iter().map(|p| p.id.as_str()).collect();
|
||||
assert_eq!(ids, ["file/fns", "file/off", "file/old.zip"]);
|
||||
let saved: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(world.join("datapacks.json")).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
saved,
|
||||
serde_json::json!({"enabled": selected, "disabled": ["file/off"]})
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -534,6 +534,8 @@ struct Shared {
|
||||
kinds: std::sync::OnceLock<Vec<u8>>,
|
||||
wake: Condvar,
|
||||
stop: AtomicBool,
|
||||
/// Reduced far detail: samplers approximate wide cells.
|
||||
approximate: AtomicBool,
|
||||
}
|
||||
/// One background worker and at most two outstanding sample jobs. Only the
|
||||
/// current view's quadtree and a bounded set of visited summaries are retained.
|
||||
@@ -589,6 +591,7 @@ impl Lod {
|
||||
kinds: std::sync::OnceLock::new(),
|
||||
wake: Condvar::new(),
|
||||
stop: AtomicBool::new(false),
|
||||
approximate: AtomicBool::new(false),
|
||||
});
|
||||
let worker = shared.clone();
|
||||
let mesh_context = context.clone();
|
||||
@@ -633,6 +636,9 @@ impl Lod {
|
||||
}
|
||||
};
|
||||
let [x, _, z] = key.origin();
|
||||
sample.set_approximate(
|
||||
shared.approximate.load(Ordering::Relaxed),
|
||||
);
|
||||
let samples =
|
||||
sample.grid(x, z, key.size() / CELLS as i32, CELLS);
|
||||
if shared.stop.load(Ordering::Relaxed) {
|
||||
@@ -695,6 +701,7 @@ impl Lod {
|
||||
}
|
||||
Job::Sample(key) => {
|
||||
let [x, _, z] = key.origin();
|
||||
sampler.set_approximate(worker.approximate.load(Ordering::Relaxed));
|
||||
let samples = sampler.grid(x, z, key.size() / CELLS as i32, CELLS);
|
||||
Done::Sample { key, samples }
|
||||
}
|
||||
@@ -991,9 +998,11 @@ impl Lod {
|
||||
self.near_loading = loading;
|
||||
}
|
||||
/// Reduced far detail (`graphics::FarDetail::Reduced`): the next update
|
||||
/// replans when it changes.
|
||||
/// replans when it changes, and tiles sampled from then on approximate
|
||||
/// wide cells (`GeneratorWorker::lod_grid`).
|
||||
pub fn set_reduced_detail(&mut self, reduced: bool) {
|
||||
self.reduced_detail = reduced;
|
||||
self.shared.approximate.store(reduced, Ordering::Relaxed);
|
||||
}
|
||||
fn held(&self) -> bool {
|
||||
self.small && self.near_loading
|
||||
|
||||
@@ -217,7 +217,7 @@ impl Opt {
|
||||
Opt::NotificationDisplayTime => "options.notifications.display_time",
|
||||
Opt::Narrator => "options.narrator",
|
||||
Opt::TextBackground => "options.accessibility.text_background",
|
||||
Opt::ChatDelay => "Chat Delay",
|
||||
Opt::ChatDelay => "horizon.options.chat_delay",
|
||||
Opt::DistortionEffects => "options.screenEffectScale",
|
||||
Opt::FovEffects => "options.fovEffectScale",
|
||||
Opt::DarknessPulsing => "options.darknessEffectScale",
|
||||
@@ -603,25 +603,25 @@ impl Opt {
|
||||
} else {
|
||||
"options.key.hold"
|
||||
}),
|
||||
Opt::PadLightbar => Text::literal(match s.controller.lightbar {
|
||||
gamepad::Lightbar::Health => "Health",
|
||||
gamepad::Lightbar::Dimension => "Dimension",
|
||||
gamepad::Lightbar::Off => "Off",
|
||||
Opt::PadLightbar => tr(match s.controller.lightbar {
|
||||
gamepad::Lightbar::Health => "horizon.options.lightbar.health",
|
||||
gamepad::Lightbar::Dimension => "horizon.options.lightbar.dimension",
|
||||
gamepad::Lightbar::Off => "options.off",
|
||||
}),
|
||||
Opt::PadGlyphs => Text::literal(match s.controller.glyphs {
|
||||
GlyphStyle::Auto => "Auto",
|
||||
GlyphStyle::Xbox => "Xbox",
|
||||
GlyphStyle::PlayStation => "PlayStation",
|
||||
GlyphStyle::Nintendo => "Nintendo",
|
||||
Opt::PadGlyphs => tr(match s.controller.glyphs {
|
||||
GlyphStyle::Auto => "horizon.options.glyphs.auto",
|
||||
GlyphStyle::Xbox => "horizon.options.glyphs.xbox",
|
||||
GlyphStyle::PlayStation => "horizon.options.glyphs.playstation",
|
||||
GlyphStyle::Nintendo => "horizon.options.glyphs.nintendo",
|
||||
}),
|
||||
Opt::PadMenuNav => Text::literal(match s.controller.menu_nav {
|
||||
gamepad::MenuNav::Focus => "Focus",
|
||||
gamepad::MenuNav::Pointer => "Pointer",
|
||||
Opt::PadMenuNav => tr(match s.controller.menu_nav {
|
||||
gamepad::MenuNav::Focus => "horizon.options.menu_focus",
|
||||
gamepad::MenuNav::Pointer => "horizon.options.menu_pointer",
|
||||
}),
|
||||
Opt::PadKeyboard => Text::literal(match s.controller.keyboard {
|
||||
gamepad::TextKeyboard::Auto => "Auto",
|
||||
gamepad::TextKeyboard::Horizon => "Horizon",
|
||||
gamepad::TextKeyboard::Steam => "Steam",
|
||||
Opt::PadKeyboard => tr(match s.controller.keyboard {
|
||||
gamepad::TextKeyboard::Auto => "horizon.options.keyboard.auto",
|
||||
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
|
||||
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
|
||||
}),
|
||||
_ => Text::empty(),
|
||||
}
|
||||
@@ -1012,7 +1012,11 @@ pub enum Event {
|
||||
Language(String),
|
||||
/// The pack selection changed (reload resources).
|
||||
Packs(Vec<String>),
|
||||
/// Create World's Data Packs screen closed with a new selection.
|
||||
DataPacks(Vec<String>),
|
||||
OpenPackFolder,
|
||||
/// Data Packs' Open Pack Folder: create the folder if needed and open it.
|
||||
OpenDataPackFolder(std::path::PathBuf),
|
||||
OpenModFolder,
|
||||
/// The Shader Packs page opened: list `shaderpacks/` into `shader_packs`.
|
||||
ListShaderPacks,
|
||||
@@ -1078,6 +1082,13 @@ pub struct OptionsUi {
|
||||
pub pack_selection: Vec<String>,
|
||||
/// The open pack screen, made at its first frame.
|
||||
pub pack_screen: Option<crate::pack_screen::PackScreen>,
|
||||
/// The Packs page is Create World's Data Packs screen on this folder.
|
||||
data_pack_dir: Option<std::path::PathBuf>,
|
||||
/// The game's resources, for the built-in data pack's icon.
|
||||
data_pack_resources: std::path::PathBuf,
|
||||
/// The folder's entries when its packs were listed, and when they were
|
||||
/// last compared with it.
|
||||
data_pack_stamp: (Vec<(String, Option<std::time::SystemTime>)>, Instant),
|
||||
/// A reload failure, dismissed before returning to the current page.
|
||||
pub resource_error: Option<String>,
|
||||
/// When the pack screen's search box took the focus (its cursor blink).
|
||||
@@ -1112,6 +1123,9 @@ impl Default for OptionsUi {
|
||||
pack_infos: Vec::new(),
|
||||
pack_selection: Vec::new(),
|
||||
pack_screen: None,
|
||||
data_pack_dir: None,
|
||||
data_pack_resources: Default::default(),
|
||||
data_pack_stamp: (Vec::new(), Instant::now()),
|
||||
resource_error: None,
|
||||
pack_focus: Instant::now(),
|
||||
in_world: false,
|
||||
@@ -1164,22 +1178,14 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
|
||||
/// What a button opening one of Horizon's pages says on hover.
|
||||
fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
|
||||
Some(match page {
|
||||
Page::Horizon => "Horizon's own settings: graphics, controllers, extras and mods.",
|
||||
Page::Graphics => "Graphics, simply: each setting says what it costs.",
|
||||
Page::GraphicsAdvanced => {
|
||||
"Every graphics setting, Horizon's included, with what each costs."
|
||||
}
|
||||
Page::Controller => "Controllers: their buttons, sticks, vibration and menu navigation.",
|
||||
Page::Extras => {
|
||||
"Optional additions, all off by default: the game stays vanilla until you turn one on."
|
||||
}
|
||||
Page::Agent => "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
|
||||
Page::ShaderPacks => {
|
||||
"Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time."
|
||||
}
|
||||
Page::Mods => {
|
||||
"Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on."
|
||||
}
|
||||
Page::Horizon => "horizon.pages.settings.tooltip",
|
||||
Page::Graphics => "horizon.pages.graphics.tooltip",
|
||||
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
|
||||
Page::Controller => "horizon.pages.controller.tooltip",
|
||||
Page::Extras => "horizon.pages.extras.tooltip",
|
||||
Page::Agent => "horizon.pages.agent.tooltip",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
|
||||
Page::Mods => "horizon.pages.mods.tooltip",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -1219,9 +1225,48 @@ impl OptionsUi {
|
||||
pub fn open(&mut self, in_world: bool) {
|
||||
self.open_at(in_world, Page::Main);
|
||||
}
|
||||
/// Create World's Data Packs (`openDataPackSelectionScreen`): the Packs
|
||||
/// page on the new world's pack folder `dir`, `selected` chosen;
|
||||
/// `resources` holds the built-in pack's icon.
|
||||
pub fn open_data_packs(
|
||||
&mut self,
|
||||
dir: std::path::PathBuf,
|
||||
selected: Vec<String>,
|
||||
resources: std::path::PathBuf,
|
||||
) {
|
||||
self.open_at(false, Page::Packs);
|
||||
self.pack_infos = crate::datapack::screen_packs(&dir, &resources);
|
||||
self.data_pack_resources = resources;
|
||||
self.pack_selection = selected;
|
||||
self.data_pack_stamp = (crate::datapack::folder_stamp(&dir), Instant::now());
|
||||
self.data_pack_dir = Some(dir);
|
||||
}
|
||||
/// `PackSelectionScreen.tick`'s watcher: once a second, the Data Packs
|
||||
/// folder's packs are listed again if it changed, the selection kept
|
||||
/// (the search text and scroll start over).
|
||||
fn watch_data_packs(&mut self, atlas: &HudAtlas, size: (f32, f32)) {
|
||||
let Some(dir) = &self.data_pack_dir else {
|
||||
return;
|
||||
};
|
||||
if self.data_pack_stamp.1.elapsed().as_secs() < 1 {
|
||||
return;
|
||||
}
|
||||
let stamp = crate::datapack::folder_stamp(dir);
|
||||
let changed = stamp != self.data_pack_stamp.0;
|
||||
self.data_pack_stamp = (stamp, Instant::now());
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
self.pack_infos = crate::datapack::screen_packs(dir, &self.data_pack_resources);
|
||||
if let Some(p) = self.pack_screen.take() {
|
||||
self.pack_selection = p.commit();
|
||||
self.packs(atlas, size).changed = p.changed;
|
||||
}
|
||||
}
|
||||
/// Open a page whose Done/Escape returns directly to the calling screen.
|
||||
pub fn open_at(&mut self, in_world: bool, page: Page) {
|
||||
self.stack.clear();
|
||||
self.data_pack_dir = None;
|
||||
self.push(page);
|
||||
self.in_world = in_world;
|
||||
self.selected_key = None;
|
||||
@@ -1269,16 +1314,16 @@ impl OptionsUi {
|
||||
Page::Language => "options.language.title",
|
||||
Page::Font => "options.font.title",
|
||||
Page::Packs => "resourcePack.title",
|
||||
Page::Mods => "Mods",
|
||||
Page::Mods => "horizon.pages.mods",
|
||||
Page::NativeMod(_) => "Mod Settings",
|
||||
Page::ShaderPacks => "Shader Packs",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs",
|
||||
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
|
||||
Page::Controller => "Controller Settings",
|
||||
Page::PadBinds => "Controller Binds",
|
||||
Page::Extras => "Horizon Extras",
|
||||
Page::Controller => "horizon.pages.controller",
|
||||
Page::PadBinds => "horizon.pages.controller_binds",
|
||||
Page::Extras => "horizon.pages.extras",
|
||||
Page::Credits => "credits_and_attribution.screen.title",
|
||||
Page::Horizon => "Horizon Settings",
|
||||
Page::Agent => "AI Agent",
|
||||
Page::Horizon => "horizon.pages.settings",
|
||||
Page::Agent => "horizon.pages.agent",
|
||||
}
|
||||
}
|
||||
fn scrollbar_x(&self, width: f32) -> f32 {
|
||||
@@ -1416,21 +1461,23 @@ impl OptionsUi {
|
||||
Page::Horizon => {
|
||||
// Two to a row, as vanilla's OptionsList.addSmall lays out
|
||||
// buttons; each opens a page built from vanilla's widgets.
|
||||
let open = |label: &str, page: Page| {
|
||||
button(0.0, 0.0, 150.0, Text::literal(label), Act::Push(page))
|
||||
};
|
||||
let open =
|
||||
|key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
|
||||
vec![
|
||||
Row::Buttons(
|
||||
open("Graphics...", Page::Graphics),
|
||||
Some(open("Advanced Graphics...", Page::GraphicsAdvanced)),
|
||||
open("horizon.pages.open.graphics", Page::Graphics),
|
||||
Some(open(
|
||||
"horizon.pages.open.graphics_advanced",
|
||||
Page::GraphicsAdvanced,
|
||||
)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open("Shader Packs...", Page::ShaderPacks),
|
||||
Some(open("Controller Settings...", Page::Controller)),
|
||||
open("horizon.pages.open.shader_packs", Page::ShaderPacks),
|
||||
Some(open("horizon.pages.open.controller", Page::Controller)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open("Extras...", Page::Extras),
|
||||
Some(open("Mods...", Page::Mods)),
|
||||
open("horizon.pages.open.extras", Page::Extras),
|
||||
Some(open("horizon.pages.open.mods", Page::Mods)),
|
||||
),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
Row::Buttons(
|
||||
@@ -1438,7 +1485,7 @@ impl OptionsUi {
|
||||
Some(if s.extras.no_ai {
|
||||
self.extra_button(s, crate::extras::Item::NoAi)
|
||||
} else {
|
||||
open("AI Agent...", Page::Agent)
|
||||
open("horizon.pages.open.agent", Page::Agent)
|
||||
}),
|
||||
),
|
||||
]
|
||||
@@ -1457,7 +1504,7 @@ impl OptionsUi {
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Copy Connection Details"),
|
||||
tr("horizon.agent.copy_details"),
|
||||
Act::CopyAgentDetails,
|
||||
)),
|
||||
)];
|
||||
@@ -1471,14 +1518,14 @@ impl OptionsUi {
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Horizon Graphics..."),
|
||||
tr("horizon.pages.open.horizon_graphics"),
|
||||
Act::Push(Page::Graphics),
|
||||
),
|
||||
Some(button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Advanced Graphics..."),
|
||||
tr("horizon.pages.open.graphics_advanced"),
|
||||
Act::Push(Page::GraphicsAdvanced),
|
||||
)),
|
||||
)];
|
||||
@@ -1573,7 +1620,7 @@ impl OptionsUi {
|
||||
0.0,
|
||||
0.0,
|
||||
310.0,
|
||||
Text::literal("Controller Settings..."),
|
||||
tr("horizon.pages.open.controller"),
|
||||
Act::Push(Page::Controller),
|
||||
),
|
||||
None,
|
||||
@@ -1586,7 +1633,7 @@ impl OptionsUi {
|
||||
0.0,
|
||||
0.0,
|
||||
310.0,
|
||||
Text::literal("Controller Binds..."),
|
||||
tr("horizon.pages.open.controller_binds"),
|
||||
Act::Push(Page::PadBinds),
|
||||
),
|
||||
None,
|
||||
@@ -1644,9 +1691,7 @@ impl OptionsUi {
|
||||
Page::Font => pairs(&[Opt::ForceUnicode, Opt::JapaneseGlyphs]),
|
||||
Page::Mods => {
|
||||
if s.mods.installed.is_empty() {
|
||||
return vec![Row::Header(
|
||||
"No mods: put .hmod files in the horizon-mods folder",
|
||||
)];
|
||||
return vec![Row::Header("horizon.mods.empty")];
|
||||
}
|
||||
s.mods
|
||||
.installed
|
||||
@@ -1656,9 +1701,9 @@ impl OptionsUi {
|
||||
let mut label = Text::literal(format!("{}: ", installed.title))
|
||||
.append(on_off(s.mods.is_on(i)));
|
||||
if installed.problem.is_some() {
|
||||
label = label.append(" (Stopped)");
|
||||
label = label.append(tr("horizon.mods.stopped"));
|
||||
} else if installed.dev {
|
||||
label = label.append(" (Dev)");
|
||||
label = label.append(tr("horizon.mods.dev"));
|
||||
}
|
||||
let dead = installed.dev || installed.id.is_empty();
|
||||
// A running mod with a settings page (capability
|
||||
@@ -1669,7 +1714,7 @@ impl OptionsUi {
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Settings"),
|
||||
tr("horizon.mods.settings"),
|
||||
Act::ModSettings(i),
|
||||
);
|
||||
Row::Buttons(
|
||||
@@ -1686,17 +1731,17 @@ impl OptionsUi {
|
||||
Page::NativeMod(i) => self.native_rows(s, i),
|
||||
Page::ShaderPacks => {
|
||||
if s.shader_packs.is_empty() {
|
||||
return vec![Row::Header(
|
||||
"No shader packs: put them in the shaderpacks folder",
|
||||
)];
|
||||
return vec![Row::Header("horizon.shader_packs.empty")];
|
||||
}
|
||||
s.shader_packs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, pack)| {
|
||||
let kind = match pack.kind {
|
||||
crate::shader_pack_list::Kind::Horizon => " (Horizon)",
|
||||
crate::shader_pack_list::Kind::Classic => "",
|
||||
crate::shader_pack_list::Kind::Horizon => {
|
||||
lang::get("horizon.shader_packs.kind_horizon")
|
||||
}
|
||||
crate::shader_pack_list::Kind::Classic => String::new(),
|
||||
};
|
||||
let label = Text::literal(format!("{}{kind}: ", pack.title))
|
||||
.append(on_off(s.horizon.shader_pack == pack.file));
|
||||
@@ -1756,8 +1801,8 @@ impl OptionsUi {
|
||||
// Vanilla's Telemetry Data and Credits slots: Horizon's
|
||||
// settings and its mods, as a mod adds its button here.
|
||||
// Credits & Attribution (WinScreen's credits) follows them.
|
||||
("Horizon Settings...", Some(Page::Horizon)),
|
||||
("Mods...", Some(Page::Mods)),
|
||||
("horizon.pages.open.horizon_settings", Some(Page::Horizon)),
|
||||
("horizon.pages.open.mods", Some(Page::Mods)),
|
||||
("options.credits_and_attribution", Some(Page::Credits)),
|
||||
];
|
||||
let rows = entries.len().div_ceil(2) as f32;
|
||||
@@ -2190,18 +2235,12 @@ impl OptionsUi {
|
||||
content = y + self.scroll - top + 2.0;
|
||||
out[list_start..].iter_mut().for_each(|w| w.list = true);
|
||||
let folder = match page {
|
||||
Page::Mods => Some(("Open Mod Folder", Act::OpenModFolder)),
|
||||
Page::ShaderPacks => Some(("Open Pack Folder", Act::OpenShaderPackFolder)),
|
||||
Page::Mods => Some(("horizon.mods.open_folder", Act::OpenModFolder)),
|
||||
Page::ShaderPacks => Some(("pack.openFolder", Act::OpenShaderPackFolder)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((label, act)) = folder {
|
||||
out.push(button(
|
||||
cx - 154.0,
|
||||
h - 27.0,
|
||||
150.0,
|
||||
Text::literal(label),
|
||||
act,
|
||||
));
|
||||
out.push(button(cx - 154.0, h - 27.0, 150.0, tr(label), act));
|
||||
out.push(button(cx + 4.0, h - 27.0, 150.0, tr("gui.done"), Act::Done));
|
||||
} else {
|
||||
out.push(done(h - 27.0));
|
||||
@@ -2273,6 +2312,7 @@ impl OptionsUi {
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::Packs {
|
||||
self.watch_data_packs(atlas, (w, h));
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
|
||||
let blink = self.pack_focus.elapsed().as_millis() as u64;
|
||||
let m = crate::screen_draw::Measure(&atlas.font);
|
||||
@@ -2313,7 +2353,7 @@ impl OptionsUi {
|
||||
} = &wd.kind
|
||||
&& let Some(text) = horizon_page_tooltip(*page)
|
||||
{
|
||||
tooltip = Some(atlas.font.split(&Text::literal(text), 170.0));
|
||||
tooltip = Some(atlas.font.split(&tr(text), 170.0));
|
||||
}
|
||||
let key = match &wd.kind {
|
||||
WKind::Button {
|
||||
@@ -2340,10 +2380,7 @@ impl OptionsUi {
|
||||
} = &wd.kind
|
||||
&& !opt.available()
|
||||
{
|
||||
tooltip = Some(atlas.font.split(
|
||||
&Text::literal("This feature is not yet available in Horizon."),
|
||||
170.0,
|
||||
));
|
||||
tooltip = Some(atlas.font.split(&tr("horizon.options.unavailable"), 170.0));
|
||||
}
|
||||
if let WKind::Button {
|
||||
act: Act::ToggleMod(i),
|
||||
@@ -2484,9 +2521,7 @@ impl OptionsUi {
|
||||
if search.value.is_empty() && !search.focused {
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&Text::literal("Search settings...")
|
||||
.with_color(text::GRAY)
|
||||
.italic(),
|
||||
&tr("horizon.options.search").with_color(text::GRAY).italic(),
|
||||
search.text_x(),
|
||||
search.text_y(),
|
||||
u32::MAX,
|
||||
@@ -2568,7 +2603,10 @@ impl OptionsUi {
|
||||
self.pack_focus = Instant::now();
|
||||
}
|
||||
match action {
|
||||
crate::pack_screen::Action::OpenFolder => events.push(Event::OpenPackFolder),
|
||||
crate::pack_screen::Action::OpenFolder => events.push(match &self.data_pack_dir {
|
||||
Some(dir) => Event::OpenDataPackFolder(dir.clone()),
|
||||
None => Event::OpenPackFolder,
|
||||
}),
|
||||
crate::pack_screen::Action::Done => events.extend(self.back(s)),
|
||||
crate::pack_screen::Action::None => {}
|
||||
}
|
||||
@@ -2820,8 +2858,14 @@ impl OptionsUi {
|
||||
}
|
||||
None => {
|
||||
self.pack_focus = Instant::now();
|
||||
let title = if self.data_pack_dir.is_some() {
|
||||
"dataPack.title"
|
||||
} else {
|
||||
"resourcePack.title"
|
||||
};
|
||||
self.pack_screen = Some(crate::pack_screen::PackScreen::new(
|
||||
&m,
|
||||
crate::text::Text::translate(title, &[]),
|
||||
w,
|
||||
h,
|
||||
self.in_world,
|
||||
@@ -2853,13 +2897,18 @@ impl OptionsUi {
|
||||
}
|
||||
Page::Packs => {
|
||||
// PackSelectionScreen.onClose: PackSelectionModel.commit.
|
||||
let data = self.data_pack_dir.take();
|
||||
if let Some(p) = self.pack_screen.take()
|
||||
&& p.changed
|
||||
{
|
||||
let ids = p.commit();
|
||||
s.resource_packs = ids.clone();
|
||||
self.pack_selection = ids.clone();
|
||||
events.push(Event::Packs(ids));
|
||||
if data.is_some() {
|
||||
events.push(Event::DataPacks(ids));
|
||||
} else {
|
||||
s.resource_packs = ids.clone();
|
||||
self.pack_selection = ids.clone();
|
||||
events.push(Event::Packs(ids));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -3246,8 +3295,15 @@ mod tests {
|
||||
ui.show(Page::Mods);
|
||||
let empty = ui.layout(&s, size.0, size.1).0;
|
||||
assert!(empty.iter().any(|w| {
|
||||
matches!(&w.kind, WKind::Text { text, .. } if text.plain().contains(".hmod"))
|
||||
matches!(&w.kind, WKind::Text { text, .. } if text.plain() == "horizon.mods.empty")
|
||||
}));
|
||||
// The empty page points at the bundled key; the English line behind it
|
||||
// is what a player reads, so both halves are checked here.
|
||||
let en = crate::lang::Language::load(&[], "en_us");
|
||||
assert!(
|
||||
en.get("horizon.mods.empty")
|
||||
.is_some_and(|line| line.contains(".hmod"))
|
||||
);
|
||||
let open_folder = empty
|
||||
.iter()
|
||||
.find(|w| {
|
||||
|
||||
@@ -318,6 +318,8 @@ pub enum Action {
|
||||
/// The pack screen and its state.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PackScreen {
|
||||
/// `resourcePack.title`, or `dataPack.title` from Create World.
|
||||
pub title: Text,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub in_world: bool,
|
||||
@@ -342,6 +344,7 @@ impl PackScreen {
|
||||
/// `PackSelectionScreen.init`: the lists filled, the search box focused.
|
||||
pub fn new(
|
||||
m: &Measure,
|
||||
title: Text,
|
||||
width: i32,
|
||||
height: i32,
|
||||
in_world: bool,
|
||||
@@ -360,6 +363,7 @@ impl PackScreen {
|
||||
.filter_map(|p| Some((icon_texture(&p.id), p.icon.clone()?)))
|
||||
.collect();
|
||||
let mut s = Self {
|
||||
title,
|
||||
width,
|
||||
height,
|
||||
in_world,
|
||||
@@ -382,7 +386,7 @@ impl PackScreen {
|
||||
}
|
||||
/// The header layout: title, drop info and search box, centred.
|
||||
fn header(&self, m: &Measure) -> [(i32, i32); 3] {
|
||||
let tw = m.width(&tr("resourcePack.title"));
|
||||
let tw = m.width(&self.title);
|
||||
let dw = m.width(&tr("pack.dropInfo"));
|
||||
let col = tw.max(dw).max(200);
|
||||
let frame = self.width.max(col);
|
||||
@@ -837,7 +841,7 @@ impl PackScreen {
|
||||
return out;
|
||||
}
|
||||
let [(tx, ty), (dx, dy), _] = self.header(m);
|
||||
d::string_widget(&mut out, m, &tr("resourcePack.title"), tx, ty, 0);
|
||||
d::string_widget(&mut out, m, &self.title, tx, ty, 0);
|
||||
d::string_widget(
|
||||
&mut out,
|
||||
m,
|
||||
@@ -1105,7 +1109,7 @@ mod icon_tests {
|
||||
pack("high_contrast", Some(contrast.clone())),
|
||||
pack("file/Iconless", None),
|
||||
];
|
||||
let screen = PackScreen::new(&m, 480, 320, false, packs, &[]);
|
||||
let screen = PackScreen::new(&m, tr("resourcePack.title"), 480, 320, false, packs, &[]);
|
||||
let mut draws = screen.draw(&m, (0, 0), 0);
|
||||
let mut patches = Vec::new();
|
||||
screen.bind_icons(&mut draws, &atlas, &mut patches);
|
||||
@@ -1152,7 +1156,8 @@ mod click_sound_tests {
|
||||
crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"))
|
||||
.unwrap();
|
||||
let m = Measure(&atlas.font);
|
||||
let mut screen = PackScreen::new(&m, 480, 320, false, Vec::new(), &[]);
|
||||
let title = tr("resourcePack.title");
|
||||
let mut screen = PackScreen::new(&m, title, 480, 320, false, Vec::new(), &[]);
|
||||
assert!(!screen.button_at(&m, 0.0, 0.0));
|
||||
assert!(!screen.button_at(
|
||||
&m,
|
||||
|
||||
@@ -181,9 +181,15 @@ pub fn compatible(meta: &serde_json::Value) -> bool {
|
||||
/// `PackCompatibility.forVersion`: too old, too new, compatible, or unknown
|
||||
/// when the metadata names no format.
|
||||
pub fn compatibility(meta: &serde_json::Value) -> crate::pack_screen::Compat {
|
||||
compatibility_for(meta, RESOURCE_FORMAT)
|
||||
}
|
||||
/// `compatibility` against `current`, the resource or the data pack format.
|
||||
pub fn compatibility_for(
|
||||
meta: &serde_json::Value,
|
||||
current: (i64, i64),
|
||||
) -> crate::pack_screen::Compat {
|
||||
use crate::pack_screen::Compat;
|
||||
let pack = &meta["pack"];
|
||||
let current = RESOURCE_FORMAT;
|
||||
let range = if let (Some(min), Some(max)) = (
|
||||
format_of(&pack["min_format"]),
|
||||
format_of(&pack["max_format"]),
|
||||
@@ -275,6 +281,10 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
|
||||
pack_for(id, title, source, RESOURCE_FORMAT)
|
||||
}
|
||||
/// A pack whose compatibility is against `format`.
|
||||
fn pack_for(id: String, title: Text, source: Source, format: (i64, i64)) -> Option<Pack> {
|
||||
let meta = read_meta(&source)?;
|
||||
let (raw_description, description_style) = component_root(&meta["pack"]["description"]);
|
||||
Some(Pack {
|
||||
@@ -284,7 +294,7 @@ fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
|
||||
raw_description,
|
||||
description_style,
|
||||
compatible: compatible(&meta),
|
||||
compatibility: compatibility(&meta),
|
||||
compatibility: compatibility_for(&meta, format),
|
||||
overlays: overlays_of(&meta),
|
||||
filter: filter_of(&meta),
|
||||
source,
|
||||
@@ -305,7 +315,13 @@ pub fn discover(resources: &Path, packs_dir: &Path) -> Vec<Pack> {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
let mut user: Vec<Pack> = fs::read_dir(packs_dir)
|
||||
out.extend(folder(packs_dir, RESOURCE_FORMAT));
|
||||
out
|
||||
}
|
||||
/// The packs (folders and zips) in `dir`, by name, as `FolderRepositorySource`
|
||||
/// finds them; their compatibility is against `format`.
|
||||
pub fn folder(dir: &Path, format: (i64, i64)) -> Vec<Pack> {
|
||||
let mut user: Vec<Pack> = fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
@@ -319,12 +335,11 @@ pub fn discover(resources: &Path, packs_dir: &Path) -> Vec<Pack> {
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
pack(format!("file/{name}"), Text::literal(name), source)
|
||||
pack_for(format!("file/{name}"), Text::literal(name), source, format)
|
||||
})
|
||||
.collect();
|
||||
user.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
out.extend(user);
|
||||
out
|
||||
user
|
||||
}
|
||||
/// A file a pack provides: its path (folder packs) and its name inside the pack.
|
||||
type Provided = (Option<PathBuf>, String);
|
||||
|
||||
@@ -99,6 +99,9 @@ pub trait LodSampler: Send {
|
||||
fn drain_details(&mut self) -> Vec<LodDetail> {
|
||||
Vec::new()
|
||||
}
|
||||
/// Reduced far detail (`graphics::FarDetail::Reduced`): `grid` may then
|
||||
/// approximate wide cells.
|
||||
fn set_approximate(&mut self, _approximate: bool) {}
|
||||
fn sample(&mut self, x: i32, z: i32) -> Option<crate::lod::Surface>;
|
||||
fn column(&mut self, x: i32, z: i32) -> crate::lod::Column {
|
||||
crate::lod::Column::from_surface(self.sample(x, z))
|
||||
|
||||
@@ -11,5 +11,57 @@
|
||||
"horizon.options.look_deadzone": "Look Deadzone",
|
||||
"horizon.options.button_glyphs": "Button Glyphs",
|
||||
"horizon.options.menu_navigation": "Menu Navigation",
|
||||
"horizon.options.on_screen_keyboard": "On-Screen Keyboard"
|
||||
"horizon.options.on_screen_keyboard": "On-Screen Keyboard",
|
||||
"horizon.options.chat_delay": "Chat Delay",
|
||||
"horizon.options.lightbar.health": "Health",
|
||||
"horizon.options.lightbar.dimension": "Dimension",
|
||||
"horizon.options.glyphs.auto": "Auto",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Focus",
|
||||
"horizon.options.menu_pointer": "Pointer",
|
||||
"horizon.options.keyboard.auto": "Auto",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "This feature is not yet available in Horizon.",
|
||||
"horizon.options.search": "Search settings...",
|
||||
"horizon.pages.mods": "Mods",
|
||||
"horizon.pages.shader_packs": "Shader Packs",
|
||||
"horizon.pages.controller": "Controller Settings",
|
||||
"horizon.pages.controller_binds": "Controller Binds",
|
||||
"horizon.pages.extras": "Horizon Extras",
|
||||
"horizon.pages.settings": "Horizon Settings",
|
||||
"horizon.pages.agent": "AI Agent",
|
||||
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
|
||||
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Every graphics setting, Horizon's included, with what each costs.",
|
||||
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
|
||||
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
|
||||
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
|
||||
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
|
||||
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
|
||||
"horizon.pages.open.graphics": "Graphics...",
|
||||
"horizon.pages.open.graphics_advanced": "Advanced Graphics...",
|
||||
"horizon.pages.open.shader_packs": "Shader Packs...",
|
||||
"horizon.pages.open.controller": "Controller Settings...",
|
||||
"horizon.pages.open.extras": "Extras...",
|
||||
"horizon.pages.open.mods": "Mods...",
|
||||
"horizon.pages.open.agent": "AI Agent...",
|
||||
"horizon.pages.open.horizon_graphics": "Horizon Graphics...",
|
||||
"horizon.pages.open.controller_binds": "Controller Binds...",
|
||||
"horizon.pages.open.horizon_settings": "Horizon Settings...",
|
||||
"horizon.mods.empty": "No mods: put .hmod files in the horizon-mods folder",
|
||||
"horizon.mods.stopped": " (Stopped)",
|
||||
"horizon.mods.dev": " (Dev)",
|
||||
"horizon.mods.settings": "Settings",
|
||||
"horizon.mods.open_folder": "Open Mod Folder",
|
||||
"horizon.shader_packs.empty": "No shader packs: put them in the shaderpacks folder",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.agent.copy_details": "Copy Connection Details",
|
||||
"horizon.multiplayer.join": "Join",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Cancel Reconnecting",
|
||||
"horizon.multiplayer.join_friend": "Join a Friend",
|
||||
"horizon.multiplayer.invite_code": "Invite Code",
|
||||
"horizon.multiplayer.relay": "Relay (optional)"
|
||||
}
|
||||
@@ -11,5 +11,57 @@
|
||||
"horizon.options.look_deadzone": "Мёртвая зона обзора",
|
||||
"horizon.options.button_glyphs": "Символы кнопок",
|
||||
"horizon.options.menu_navigation": "Навигация по меню",
|
||||
"horizon.options.on_screen_keyboard": "Экранная клавиатура"
|
||||
"horizon.options.on_screen_keyboard": "Экранная клавиатура",
|
||||
"horizon.options.chat_delay": "Задержка чата",
|
||||
"horizon.options.lightbar.health": "Здоровье",
|
||||
"horizon.options.lightbar.dimension": "Измерение",
|
||||
"horizon.options.glyphs.auto": "Авто",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Фокус",
|
||||
"horizon.options.menu_pointer": "Указатель",
|
||||
"horizon.options.keyboard.auto": "Авто",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "Эта возможность в Horizon пока недоступна.",
|
||||
"horizon.options.search": "Поиск настроек...",
|
||||
"horizon.pages.mods": "Моды",
|
||||
"horizon.pages.shader_packs": "Шейдерпаки",
|
||||
"horizon.pages.controller": "Настройки контроллера",
|
||||
"horizon.pages.controller_binds": "Назначения кнопок контроллера",
|
||||
"horizon.pages.extras": "Дополнения Horizon",
|
||||
"horizon.pages.settings": "Настройки Horizon",
|
||||
"horizon.pages.agent": "ИИ-агент",
|
||||
"horizon.pages.settings.tooltip": "Собственные настройки Horizon: графика, контроллеры, дополнения и моды.",
|
||||
"horizon.pages.graphics.tooltip": "Графика без лишнего: у каждой настройки написано, чего она стоит.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Все графические настройки, включая собственные настройки Horizon, и их цена.",
|
||||
"horizon.pages.controller.tooltip": "Контроллеры: кнопки, стики, вибрация и навигация по меню.",
|
||||
"horizon.pages.extras.tooltip": "Дополнительные возможности, все выключены по умолчанию: игра остаётся ванильной, пока вы не включите что-то.",
|
||||
"horizon.pages.agent.tooltip": "Пусть играет ваш ИИ-агент: horizon-ctl и MCP-клиенты на этом компьютере.",
|
||||
"horizon.pages.shader_packs.tooltip": "Шейдерпаки из папки shaderpacks: собственные паки Horizon и паки в формате shaders/. Одновременно работает один.",
|
||||
"horizon.pages.mods.tooltip": "Моды для Horizon из папки horizon-mods. Каждый работает в песочнице и выключен, пока вы его не включите.",
|
||||
"horizon.pages.open.graphics": "Графика...",
|
||||
"horizon.pages.open.graphics_advanced": "Продвинутая графика...",
|
||||
"horizon.pages.open.shader_packs": "Шейдерпаки...",
|
||||
"horizon.pages.open.controller": "Настройки контроллера...",
|
||||
"horizon.pages.open.extras": "Дополнения...",
|
||||
"horizon.pages.open.mods": "Моды...",
|
||||
"horizon.pages.open.agent": "ИИ-агент...",
|
||||
"horizon.pages.open.horizon_graphics": "Графика Horizon...",
|
||||
"horizon.pages.open.controller_binds": "Назначения кнопок...",
|
||||
"horizon.pages.open.horizon_settings": "Настройки Horizon...",
|
||||
"horizon.mods.empty": "Модов нет: положите файлы .hmod в папку horizon-mods",
|
||||
"horizon.mods.stopped": " (остановлен)",
|
||||
"horizon.mods.dev": " (разработка)",
|
||||
"horizon.mods.settings": "Настройки",
|
||||
"horizon.mods.open_folder": "Открыть папку модов",
|
||||
"horizon.shader_packs.empty": "Шейдерпаков нет: положите их в папку shaderpacks",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.agent.copy_details": "Скопировать данные подключения",
|
||||
"horizon.multiplayer.join": "Подключиться",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Отменить переподключение",
|
||||
"horizon.multiplayer.join_friend": "Играть с другом",
|
||||
"horizon.multiplayer.invite_code": "Код приглашения",
|
||||
"horizon.multiplayer.relay": "Ретранслятор (необязательно)"
|
||||
}
|
||||
@@ -126,6 +126,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.collect();
|
||||
let mut screen = PackScreen::new(
|
||||
&m,
|
||||
Text::translate(c["title"].as_str().unwrap_or("resourcePack.title"), &[]),
|
||||
int(&c["width"]),
|
||||
int(&c["height"]),
|
||||
c["inWorld"] == true,
|
||||
|
||||
@@ -9136,6 +9136,15 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
self.join_world_rest()?;
|
||||
// Create World's data packs, in the world before it opens.
|
||||
if let Some(dir) = create.and_then(|o| o.pack_dir.as_deref()) {
|
||||
let world = std::env::current_dir()?.join("saves").join(folder);
|
||||
let packs = create.map_or(&[][..], |o| &o.data_packs[..]);
|
||||
if let Err(e) = horizon_client::datapack::install(dir, &world, packs) {
|
||||
log::warn!("Horizon: data packs for {folder}: {e}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
if self
|
||||
.options
|
||||
.execution_role
|
||||
@@ -9267,7 +9276,8 @@ impl Game<'_> {
|
||||
book.to_be_displayed.retain(|r| index.contains(r));
|
||||
self.dim = dim;
|
||||
self.world_root = save_root.clone();
|
||||
self.world_flat = horizon_client::save::WorldGen::read(&save_root).is_some_and(|w| w.flat);
|
||||
self.world_flat = horizon_client::save::WorldGen::read(&save_root)
|
||||
.is_some_and(|w| w.preset == horizon_client::save::WorldPreset::Flat);
|
||||
self.block_ticks = Ticks::new(self.level.seed as i32);
|
||||
self.tick_chunks.clear();
|
||||
self.clear_entities();
|
||||
@@ -13242,6 +13252,13 @@ impl ApplicationHandler for Game<'_> {
|
||||
Action::Connect { address } => {
|
||||
self.connect_server(&address, &self.options.player_name())?
|
||||
}
|
||||
Action::DataPacks { dir, selected } => {
|
||||
self.keys.clear();
|
||||
self.screen = Screen::Options;
|
||||
self.options_from_title = true;
|
||||
let resources = self.vanilla_root();
|
||||
self.ui.options.open_data_packs(dir, selected, resources);
|
||||
}
|
||||
Action::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,7 +564,7 @@ impl Game<'_> {
|
||||
"hardcore": options.hardcore,
|
||||
"difficulty": horizon_client::title::DIFFICULTIES[options.difficulty()],
|
||||
"allow_commands": options.commands(*creative),
|
||||
"superflat": options.flat,
|
||||
"world_type": options.world_type.id(),
|
||||
"generate_structures": options.structures,
|
||||
"bonus_chest": options.bonus_chest(),
|
||||
}),
|
||||
@@ -577,7 +577,7 @@ impl Game<'_> {
|
||||
} => json!({
|
||||
"mode": "customize-flat",
|
||||
"fields": { "name": name, "seed": seed },
|
||||
"superflat": options.flat,
|
||||
"superflat": options.world_type == horizon_client::save::WorldPreset::Flat,
|
||||
"biome": flat.settings().biome,
|
||||
"layers": flat.settings().layers.iter().map(|layer| json!({
|
||||
"block": layer.block,
|
||||
|
||||
@@ -298,15 +298,14 @@ impl Lists {
|
||||
}
|
||||
}
|
||||
|
||||
/// The world presets of 26.3's `worldgen/world_preset`.
|
||||
const WORLD_PRESETS: [&str; 7] = [
|
||||
"minecraft:normal",
|
||||
"minecraft:flat",
|
||||
"minecraft:large_biomes",
|
||||
"minecraft:amplified",
|
||||
"minecraft:single_biome_surface",
|
||||
"minecraft:debug_all_block_states",
|
||||
"minecraft:flat_all_dimensions",
|
||||
/// DedicatedServerProperties.LEGACY_PRESET_NAMES: the old `level-type`
|
||||
/// names, before the presets had their own ids.
|
||||
const LEGACY_PRESET_NAMES: [(&str, horizon_client::save::WorldPreset); 2] = [
|
||||
("default", horizon_client::save::WorldPreset::Normal),
|
||||
(
|
||||
"largebiomes",
|
||||
horizon_client::save::WorldPreset::LargeBiomes,
|
||||
),
|
||||
];
|
||||
|
||||
/// Identifier.tryParse: `namespace:path`, or a path in `minecraft`.
|
||||
@@ -323,11 +322,18 @@ impl Game<'_> {
|
||||
/// DedicatedServerProperties.createDimensions for a world that doesn't
|
||||
/// exist yet: `level-type`'s preset, a flat world's `generator-settings`
|
||||
/// and `generate-structures`, written before the world opens so that
|
||||
/// streaming reads them. Horizon generates the normal and flat presets.
|
||||
/// streaming reads them.
|
||||
fn write_world_gen(&self, dir: &std::path::Path, props: &ServerProperties) -> Result<()> {
|
||||
let level_type = &props.level_type;
|
||||
let preset = parse_identifier(level_type)
|
||||
.filter(|id| WORLD_PRESETS.contains(&id.as_str()))
|
||||
.as_deref()
|
||||
.and_then(horizon_client::save::WorldPreset::of_id)
|
||||
.or_else(|| {
|
||||
LEGACY_PRESET_NAMES
|
||||
.iter()
|
||||
.find(|(name, _)| *name == level_type)
|
||||
.map(|(_, preset)| *preset)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
log_from(
|
||||
"Worker-Main-1",
|
||||
@@ -336,18 +342,9 @@ impl Game<'_> {
|
||||
"Failed to parse level-type {level_type}, defaulting to minecraft:normal"
|
||||
),
|
||||
);
|
||||
"minecraft:normal".to_owned()
|
||||
horizon_client::save::WorldPreset::Normal
|
||||
});
|
||||
let flat = preset == "minecraft:flat";
|
||||
if !flat && preset != "minecraft:normal" {
|
||||
log_from(
|
||||
"Worker-Main-1",
|
||||
"WARN",
|
||||
&format!(
|
||||
"Horizon doesn't generate the {preset} world preset yet: using minecraft:normal"
|
||||
),
|
||||
);
|
||||
}
|
||||
let flat = preset == horizon_client::save::WorldPreset::Flat;
|
||||
let settings = if flat {
|
||||
let biomes = self.res.biomes();
|
||||
let (settings, errors) = horizon_client::flatgen::settings_from_json(
|
||||
@@ -363,7 +360,7 @@ impl Game<'_> {
|
||||
None
|
||||
};
|
||||
let options = horizon_client::save::WorldGen {
|
||||
flat,
|
||||
preset,
|
||||
structures: props.generate_structures,
|
||||
bonus_chest: false,
|
||||
};
|
||||
|
||||
@@ -675,9 +675,7 @@ impl Game<'_> {
|
||||
let new_mode = self.mode_id();
|
||||
self.mode = mode;
|
||||
self.creative = creative;
|
||||
self.selected = std::mem::replace(&mut g.selected, selected);
|
||||
// `g.selected` now holds the host's value: swap the inventories back.
|
||||
std::mem::swap(&mut self.selected, &mut g.selected);
|
||||
g.selected = std::mem::replace(&mut self.selected, selected);
|
||||
std::mem::swap(&mut self.inventory, &mut g.inventory);
|
||||
std::mem::swap(&mut self.armor, &mut g.armor);
|
||||
std::mem::swap(&mut self.offhand, &mut g.offhand);
|
||||
|
||||
@@ -232,6 +232,15 @@ impl Game<'_> {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::DataPacks(ids) => {
|
||||
if let Some(title) = &mut self.title {
|
||||
title.set_data_packs(ids);
|
||||
}
|
||||
}
|
||||
Event::OpenDataPackFolder(dir) => {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::OpenModFolder => {
|
||||
let dir = Self::options_path().with_file_name(horizon_client::mod_list::DIR);
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
|
||||
@@ -365,6 +365,15 @@ impl Loading {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
packs::open_folder(&dir);
|
||||
}
|
||||
Event::DataPacks(ids) => {
|
||||
if let Some(title) = &mut self.menu.title {
|
||||
title.set_data_packs(ids);
|
||||
}
|
||||
}
|
||||
Event::OpenDataPackFolder(dir) => {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
packs::open_folder(&dir);
|
||||
}
|
||||
Event::OpenModFolder => {
|
||||
let dir = Game::options_path().with_file_name(horizon_client::mod_list::DIR);
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
@@ -426,6 +435,11 @@ impl Loading {
|
||||
Action::Options => self.open_menu_options(Page::Main),
|
||||
Action::Accessibility => self.open_menu_options(Page::Accessibility),
|
||||
Action::Language => self.open_menu_options(Page::Language),
|
||||
Action::DataPacks { dir, selected } => {
|
||||
let resources = self.vanilla_root.clone();
|
||||
self.menu.options.open_data_packs(dir, selected, resources);
|
||||
self.menu.options_open = true;
|
||||
}
|
||||
action => {
|
||||
let name = self
|
||||
.options
|
||||
|
||||
@@ -304,6 +304,26 @@ pub(super) fn preload_server_loot(resources: &std::path::Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The worldgen entries of the world's enabled data packs, logging what
|
||||
/// the generator leaves out of them.
|
||||
fn world_worldgen_overrides(
|
||||
world_root: &std::path::Path,
|
||||
biomes: &horizon_client::biome::Biomes,
|
||||
) -> horizon_world::worldgen::overrides::WorldgenOverrides {
|
||||
let packs = horizon_client::datapack::discover(world_root);
|
||||
let enabled = horizon_client::datapack::enabled_ids(world_root, &packs);
|
||||
let enabled: Vec<_> = enabled
|
||||
.iter()
|
||||
.filter_map(|id| packs.iter().find(|p| &p.id == id))
|
||||
.collect();
|
||||
let (overrides, warnings) =
|
||||
horizon_client::datapack::worldgen_overrides(&enabled, |b| biomes.contains(b));
|
||||
for warning in warnings {
|
||||
log::warn!("Horizon: data pack worldgen: {warning}");
|
||||
}
|
||||
overrides
|
||||
}
|
||||
|
||||
pub(super) fn make_streaming(
|
||||
res: &Resources,
|
||||
pack: &PackResources,
|
||||
@@ -356,8 +376,13 @@ pub(super) fn make_streaming(
|
||||
let structures =
|
||||
horizon_client::save::WorldGen::read(world_root).is_none_or(|w| w.structures);
|
||||
let preset = horizon_client::save::WorldGen::preset(world_root);
|
||||
let inner = Arc::new(VanillaTerrain::for_world(&options.resources.join("worldgen"), pack.assets.registry, res.biomes(), seed, preset, dim.id(), structures).map_err(|e| {
|
||||
format!("{e}\nPrepare the worldgen bundle with the launcher, or: horizon-prepare --prepare")
|
||||
let overrides = world_worldgen_overrides(world_root, res.biomes());
|
||||
let inner = Arc::new(VanillaTerrain::for_world_with(&options.resources.join("worldgen"), &overrides, pack.assets.registry, res.biomes(), seed, preset, dim.id(), structures).map_err(|e| {
|
||||
if overrides.is_empty() {
|
||||
format!("{e}\nPrepare the worldgen bundle with the launcher, or: horizon-prepare --prepare")
|
||||
} else {
|
||||
format!("{e}\nThe world's data packs change its world generation: one of them may not be made for this version")
|
||||
}
|
||||
})?);
|
||||
if dim == Dim::Overworld && structures {
|
||||
inner.search_rings_behind();
|
||||
|
||||
@@ -618,7 +618,7 @@ impl Screen {
|
||||
w / 2.0 - 100.0,
|
||||
h / 4.0 + 96.0 + 18.0,
|
||||
200.0,
|
||||
"Join",
|
||||
&crate::lang::get_or("horizon.multiplayer.join", "Join"),
|
||||
horizon_friends::invite::parse(code).is_some(),
|
||||
56,
|
||||
),
|
||||
@@ -645,7 +645,10 @@ impl Screen {
|
||||
w / 2.0 - 100.0,
|
||||
h / 2.0 + 54.0,
|
||||
200.0,
|
||||
"Cancel Reconnecting",
|
||||
&crate::lang::get_or(
|
||||
"horizon.multiplayer.cancel_reconnecting",
|
||||
"Cancel Reconnecting",
|
||||
),
|
||||
true,
|
||||
55,
|
||||
));
|
||||
@@ -1012,13 +1015,18 @@ impl Screen {
|
||||
}
|
||||
}
|
||||
Self::Friend { code, relay, focus } => {
|
||||
centered(batch, "Join a Friend", 20.0, u32::MAX);
|
||||
centered(
|
||||
batch,
|
||||
&crate::lang::get_or("horizon.multiplayer.join_friend", "Join a Friend"),
|
||||
20.0,
|
||||
u32::MAX,
|
||||
);
|
||||
Self::field(
|
||||
atlas,
|
||||
batch,
|
||||
caret,
|
||||
focused_at,
|
||||
"Invite Code",
|
||||
&crate::lang::get_or("horizon.multiplayer.invite_code", "Invite Code"),
|
||||
code,
|
||||
FRIEND_CODE,
|
||||
max,
|
||||
@@ -1030,7 +1038,7 @@ impl Screen {
|
||||
batch,
|
||||
caret,
|
||||
focused_at,
|
||||
"Relay (optional)",
|
||||
&crate::lang::get_or("horizon.multiplayer.relay", "Relay (optional)"),
|
||||
relay,
|
||||
FRIEND_RELAY,
|
||||
max,
|
||||
|
||||
@@ -1905,10 +1905,33 @@ pub struct SaveDir {
|
||||
pub tables: OnceLock<Arc<SaveTables>>,
|
||||
/// The dimension's skylight and height, from its folder name.
|
||||
pub shape: LevelShape,
|
||||
/// Which chunks each region file holds, for far terrain (`lod_saved`).
|
||||
lod_regions: Mutex<crate::fast_hash::FxHashMap<(i32, i32), LodRegion>>,
|
||||
/// Saved chunks decoded for far terrain, shared by its sampling threads
|
||||
/// (`lod_surfaces_shared`), oldest first.
|
||||
lod_decoded: Mutex<LodDecoded>,
|
||||
}
|
||||
fn is_forced_ticket(ticket: &Tag) -> bool {
|
||||
matches!(ticket.get("type"), Some(Tag::String(t)) if *t == JavaString::from("minecraft:forced"))
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct LodDecoded {
|
||||
chunks: crate::fast_hash::FxHashMap<(i32, i32), (std::time::Instant, LodSurfaces)>,
|
||||
order: std::collections::VecDeque<(i32, i32)>,
|
||||
}
|
||||
/// Saved chunks kept decoded for far terrain (each tens of kilobytes), and
|
||||
/// for how long: edits saved later show after it.
|
||||
const LOD_DECODED: usize = 1024;
|
||||
const LOD_DECODED_TTL: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
/// A region file's saved chunks (bit `x + z * 32`), `None` without the file,
|
||||
/// as of `checked`.
|
||||
struct LodRegion {
|
||||
checked: std::time::Instant,
|
||||
chunks: Option<[u64; 16]>,
|
||||
}
|
||||
/// How long far terrain trusts a region file's chunk list: chunks saved after
|
||||
/// it was read show in the far view this long after.
|
||||
const LOD_REGION_TTL: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
impl SaveDir {
|
||||
pub fn new(root: PathBuf) -> Self {
|
||||
let shape = LevelShape::for_dir(&root);
|
||||
@@ -1923,6 +1946,8 @@ impl SaveDir {
|
||||
outstanding: Default::default(),
|
||||
tables: OnceLock::new(),
|
||||
shape,
|
||||
lod_regions: Default::default(),
|
||||
lod_decoded: Default::default(),
|
||||
}
|
||||
}
|
||||
/// 26.3's TicketStorage: persistent tickets live in each dimension's
|
||||
@@ -1969,6 +1994,40 @@ impl SaveDir {
|
||||
put(&mut data, "tickets", Tag::List(tickets));
|
||||
write_saved_data(&self.root, "chunk_tickets", data)
|
||||
}
|
||||
/// Whether chunk `x z` is in its region file, from a chunk list kept
|
||||
/// `LOD_REGION_TTL`. Far terrain asks for every column of the view: it
|
||||
/// opened the region file each time, also where there is none, which
|
||||
/// held the far view back for minutes.
|
||||
fn lod_saved(&self, x: i32, z: i32) -> bool {
|
||||
let key = (x >> 5, z >> 5);
|
||||
let mut regions = self.lod_regions.lock().unwrap();
|
||||
let fresh = regions
|
||||
.get(&key)
|
||||
.is_some_and(|region| region.checked.elapsed() < LOD_REGION_TTL);
|
||||
if !fresh {
|
||||
let chunks = RegionFile::open(self.region_path("region", key.0, key.1))
|
||||
.ok()
|
||||
.map(|region| {
|
||||
let mut bits = [0u64; 16];
|
||||
for (pos, _) in region.chunks() {
|
||||
let index = ((pos.x & 31) + (pos.z & 31) * 32) as usize;
|
||||
bits[index / 64] |= 1 << (index % 64);
|
||||
}
|
||||
bits
|
||||
});
|
||||
regions.insert(
|
||||
key,
|
||||
LodRegion {
|
||||
checked: std::time::Instant::now(),
|
||||
chunks,
|
||||
},
|
||||
);
|
||||
}
|
||||
let index = ((x & 31) + (z & 31) * 32) as usize;
|
||||
regions[&key]
|
||||
.chunks
|
||||
.is_some_and(|bits| bits[index / 64] & (1 << (index % 64)) != 0)
|
||||
}
|
||||
fn region_path(&self, folder: &str, rx: i32, rz: i32) -> PathBuf {
|
||||
region_path(&self.root, folder, rx, rz)
|
||||
}
|
||||
@@ -2503,14 +2562,83 @@ const INT_RULES: [&str; 11] = [
|
||||
"respawn_radius",
|
||||
];
|
||||
|
||||
/// The world presets of 26.3's `worldgen/world_preset` that the create
|
||||
/// screen offers, in the order of `WorldPresetTags.NORMAL`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WorldPreset {
|
||||
Normal,
|
||||
Flat,
|
||||
LargeBiomes,
|
||||
Amplified,
|
||||
SingleBiomeSurface,
|
||||
}
|
||||
impl WorldPreset {
|
||||
/// The presets, in the order the world-type button cycles through them.
|
||||
pub const ALL: [Self; 5] = [
|
||||
Self::Normal,
|
||||
Self::Flat,
|
||||
Self::LargeBiomes,
|
||||
Self::Amplified,
|
||||
Self::SingleBiomeSurface,
|
||||
];
|
||||
/// The preset's registry id (`minecraft:normal`).
|
||||
pub const fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Normal => "minecraft:normal",
|
||||
Self::Flat => "minecraft:flat",
|
||||
Self::LargeBiomes => "minecraft:large_biomes",
|
||||
Self::Amplified => "minecraft:amplified",
|
||||
Self::SingleBiomeSurface => "minecraft:single_biome_surface",
|
||||
}
|
||||
}
|
||||
/// The preset an id names, if it is one of them.
|
||||
pub fn of_id(id: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|preset| preset.id() == id)
|
||||
}
|
||||
/// The language key the world-type button shows
|
||||
/// (`Identifier.toLanguageKey("generator")`).
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Normal => "generator.minecraft.normal",
|
||||
Self::Flat => "generator.minecraft.flat",
|
||||
Self::LargeBiomes => "generator.minecraft.large_biomes",
|
||||
Self::Amplified => "generator.minecraft.amplified",
|
||||
Self::SingleBiomeSurface => "generator.minecraft.single_biome_surface",
|
||||
}
|
||||
}
|
||||
/// The preset the world-type button cycles to next.
|
||||
pub fn next(self) -> Self {
|
||||
let index = Self::ALL
|
||||
.iter()
|
||||
.position(|preset| *preset == self)
|
||||
.unwrap_or(0);
|
||||
Self::ALL[(index + 1) % Self::ALL.len()]
|
||||
}
|
||||
/// The preset a saved overworld generator is: the noise settings name
|
||||
/// the preset, and the single-biome preset shares the overworld's noise
|
||||
/// settings, so its fixed biome source tells the two apart.
|
||||
pub fn of_generator(generator: &Tag) -> Self {
|
||||
if text(generator.get("type")).as_deref() == Some("minecraft:flat") {
|
||||
return Self::Flat;
|
||||
}
|
||||
let source = generator.get("biome_source");
|
||||
let fixed = text(source.and_then(|s| s.get("type"))).as_deref() == Some("minecraft:fixed");
|
||||
match text(generator.get("settings")).as_deref() {
|
||||
Some("minecraft:large_biomes") => Self::LargeBiomes,
|
||||
Some("minecraft:amplified") => Self::Amplified,
|
||||
Some("minecraft:overworld") if fixed => Self::SingleBiomeSurface,
|
||||
_ => Self::Normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// World and player state stored in `level.dat`, the saved-data files and
|
||||
/// the player file.
|
||||
/// The world options CreateWorldScreen sets (`WorldOptions` and the world
|
||||
/// preset) that `world_gen_settings` keeps.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct WorldGen {
|
||||
/// The `minecraft:flat` preset (Superflat) instead of `normal`.
|
||||
pub flat: bool,
|
||||
/// The world preset the world is created with.
|
||||
pub preset: WorldPreset,
|
||||
pub structures: bool,
|
||||
pub bonus_chest: bool,
|
||||
}
|
||||
@@ -2524,8 +2652,7 @@ impl WorldGen {
|
||||
.and_then(|d| d.get("minecraft:overworld"))
|
||||
.and_then(|o| o.get("generator"));
|
||||
Some(Self {
|
||||
flat: text(generator.and_then(|g| g.get("type")))
|
||||
.is_some_and(|t| t.trim_start_matches("minecraft:") == "flat"),
|
||||
preset: generator.map_or(WorldPreset::Normal, WorldPreset::of_generator),
|
||||
structures: flag("generate_structures", true),
|
||||
bonus_chest: flag("bonus_chest", false),
|
||||
})
|
||||
@@ -2558,7 +2685,7 @@ impl WorldGen {
|
||||
impl Default for WorldGen {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
flat: false,
|
||||
preset: WorldPreset::Normal,
|
||||
structures: true,
|
||||
bonus_chest: false,
|
||||
}
|
||||
@@ -2633,6 +2760,34 @@ pub fn write_world_gen_settings_with(
|
||||
("preset", string(p)),
|
||||
])
|
||||
};
|
||||
// The overworld generator of worldgen/world_preset/<preset>.json: the
|
||||
// noise settings and the biome source each preset uses.
|
||||
let overworld = match options.preset {
|
||||
WorldPreset::Flat => flat,
|
||||
WorldPreset::LargeBiomes => dimension(
|
||||
"minecraft:overworld",
|
||||
"minecraft:large_biomes",
|
||||
preset("minecraft:overworld"),
|
||||
),
|
||||
WorldPreset::Amplified => dimension(
|
||||
"minecraft:overworld",
|
||||
"minecraft:amplified",
|
||||
preset("minecraft:overworld"),
|
||||
),
|
||||
WorldPreset::SingleBiomeSurface => dimension(
|
||||
"minecraft:overworld",
|
||||
"minecraft:overworld",
|
||||
Tag::compound([
|
||||
("type", string("minecraft:fixed")),
|
||||
("biome", string("minecraft:plains")),
|
||||
]),
|
||||
),
|
||||
WorldPreset::Normal => dimension(
|
||||
"minecraft:overworld",
|
||||
"minecraft:overworld",
|
||||
preset("minecraft:overworld"),
|
||||
),
|
||||
};
|
||||
write_saved_data(
|
||||
dir,
|
||||
"world_gen_settings",
|
||||
@@ -2646,18 +2801,7 @@ pub fn write_world_gen_settings_with(
|
||||
(
|
||||
"dimensions",
|
||||
Tag::compound([
|
||||
(
|
||||
"minecraft:overworld",
|
||||
if options.flat {
|
||||
flat
|
||||
} else {
|
||||
dimension(
|
||||
"minecraft:overworld",
|
||||
"minecraft:overworld",
|
||||
preset("minecraft:overworld"),
|
||||
)
|
||||
},
|
||||
),
|
||||
("minecraft:overworld", overworld),
|
||||
(
|
||||
"minecraft:the_nether",
|
||||
dimension(
|
||||
@@ -3454,11 +3598,18 @@ pub struct Persistent<G> {
|
||||
pub biomes: Arc<Biomes>,
|
||||
}
|
||||
type LodSurfaces = Arc<[crate::lod::Column; 256]>;
|
||||
/// The coarsest far tile spacing whose columns read saved chunks: 16 blocks
|
||||
/// a column, and 4 with Reduced far detail (`LodSampler::set_approximate`).
|
||||
const SAVED_LOD_STRIDE: i32 = 16;
|
||||
const SAVED_LOD_STRIDE_REDUCED: i32 = 4;
|
||||
struct SavedLodSampler<G: ChunkGenerator> {
|
||||
generator: Arc<Persistent<G>>,
|
||||
inner: Box<dyn crate::streaming::LodSampler>,
|
||||
cache: std::collections::VecDeque<((i32, i32), Option<LodSurfaces>)>,
|
||||
details: Vec<crate::streaming::LodDetail>,
|
||||
/// Reduced far detail: saved chunks only in tiles up to
|
||||
/// `SAVED_LOD_STRIDE_REDUCED` blocks a column.
|
||||
approximate: bool,
|
||||
}
|
||||
impl<G: ChunkGenerator> SavedLodSampler<G> {
|
||||
fn saved_columns(&mut self, key: (i32, i32)) -> Option<LodSurfaces> {
|
||||
@@ -3466,7 +3617,7 @@ impl<G: ChunkGenerator> SavedLodSampler<G> {
|
||||
Some(index) => self.cache.remove(index).unwrap(),
|
||||
None => (
|
||||
key,
|
||||
self.generator.save.lod_surfaces(
|
||||
self.generator.save.lod_surfaces_shared(
|
||||
key.0,
|
||||
key.1,
|
||||
&self.generator.registry,
|
||||
@@ -3487,8 +3638,13 @@ impl<G: ChunkGenerator> crate::streaming::LodSampler for SavedLodSampler<G> {
|
||||
inner: self.inner.fork()?,
|
||||
cache: Default::default(),
|
||||
details: Vec::new(),
|
||||
approximate: self.approximate,
|
||||
}))
|
||||
}
|
||||
fn set_approximate(&mut self, approximate: bool) {
|
||||
self.approximate = approximate;
|
||||
self.inner.set_approximate(approximate);
|
||||
}
|
||||
fn request_detail(&mut self, chunks: &[(i32, i32)], center: [i32; 2]) {
|
||||
let mut missing = Vec::new();
|
||||
for &key in chunks {
|
||||
@@ -3511,7 +3667,18 @@ impl<G: ChunkGenerator> crate::streaming::LodSampler for SavedLodSampler<G> {
|
||||
}
|
||||
fn grid(&mut self, x: i32, z: i32, stride: i32, side: usize) -> Vec<crate::lod::Column> {
|
||||
let mut columns = self.inner.grid(x, z, stride, side);
|
||||
if stride <= 16 {
|
||||
// Saved chunks show in tiles up to 16 blocks a column. With Reduced far
|
||||
// detail only up to 4 (within about a kilometre): coarser tiles keep
|
||||
// the generator's terrain, which saved chunks were made from, since
|
||||
// decoding up to 256 saved chunks for each of them held the whole far
|
||||
// view back for minutes in large saves. Edits that far away show once
|
||||
// their tiles refine on approach.
|
||||
let limit = if self.approximate {
|
||||
SAVED_LOD_STRIDE_REDUCED
|
||||
} else {
|
||||
SAVED_LOD_STRIDE
|
||||
};
|
||||
if stride <= limit {
|
||||
for (index, column) in columns.iter_mut().enumerate() {
|
||||
let (wx, wz) = (
|
||||
x + (index % side) as i32 * stride,
|
||||
@@ -3770,6 +3937,33 @@ fn decode_lod_surfaces(
|
||||
}
|
||||
|
||||
impl SaveDir {
|
||||
/// `lod_surfaces`, decoded once for all far sampling threads: each saved
|
||||
/// chunk lies under far tiles of several levels, sampled on any thread.
|
||||
fn lod_surfaces_shared(
|
||||
&self,
|
||||
x: i32,
|
||||
z: i32,
|
||||
assets: &crate::assets::Assets,
|
||||
biomes: &Biomes,
|
||||
) -> Option<LodSurfaces> {
|
||||
if let Some((at, columns)) = self.lod_decoded.lock().unwrap().chunks.get(&(x, z))
|
||||
&& at.elapsed() < LOD_DECODED_TTL
|
||||
{
|
||||
return Some(columns.clone());
|
||||
}
|
||||
let columns = self.lod_surfaces(x, z, assets, biomes)?;
|
||||
let mut decoded = self.lod_decoded.lock().unwrap();
|
||||
let fresh = (std::time::Instant::now(), columns.clone());
|
||||
if decoded.chunks.insert((x, z), fresh).is_none() {
|
||||
decoded.order.push_back((x, z));
|
||||
while decoded.order.len() > LOD_DECODED {
|
||||
if let Some(old) = decoded.order.pop_front() {
|
||||
decoded.chunks.remove(&old);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(columns)
|
||||
}
|
||||
fn lod_surfaces(
|
||||
&self,
|
||||
x: i32,
|
||||
@@ -3786,6 +3980,9 @@ impl SaveDir {
|
||||
let root = if let Some(record) = pending {
|
||||
record.tag().map(|tag| (*tag).clone())?
|
||||
} else {
|
||||
if !self.lod_saved(x, z) {
|
||||
return None;
|
||||
}
|
||||
// Far-only reads must not populate the gameplay region cache across the whole view.
|
||||
let mut region = RegionFile::open(self.region_path("region", x >> 5, z >> 5)).ok()?;
|
||||
region
|
||||
@@ -3816,6 +4013,7 @@ impl<G: ChunkGenerator> ChunkGenerator for Persistent<G> {
|
||||
inner,
|
||||
cache: Default::default(),
|
||||
details: Vec::new(),
|
||||
approximate: false,
|
||||
})
|
||||
}
|
||||
fn load(&self, x: i32, z: i32) -> Option<Chunk> {
|
||||
@@ -4355,6 +4553,7 @@ mod tests {
|
||||
inner: Box::new(SavedOnly(Vec::new())),
|
||||
cache: Default::default(),
|
||||
details: Vec::new(),
|
||||
approximate: false,
|
||||
};
|
||||
use crate::streaming::LodSampler;
|
||||
sampler.request_detail(&[(-2, 3)], [-32, 48]);
|
||||
|
||||
@@ -148,6 +148,11 @@ pub enum Action {
|
||||
Connect {
|
||||
address: String,
|
||||
},
|
||||
/// Create World's Data Packs screen on the new world's pack folder.
|
||||
DataPacks {
|
||||
dir: PathBuf,
|
||||
selected: Vec<String>,
|
||||
},
|
||||
}
|
||||
pub struct Title {
|
||||
pub mode: Mode,
|
||||
@@ -356,6 +361,12 @@ impl Title {
|
||||
row
|
||||
}
|
||||
fn cancel_create(&mut self) {
|
||||
// removeTempDataPackDir.
|
||||
if let Mode::Create { options, .. } = &self.mode
|
||||
&& let Some(dir) = &options.pack_dir
|
||||
{
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
if self.create_return_to_main {
|
||||
self.mode = Mode::Main;
|
||||
} else {
|
||||
@@ -408,7 +419,7 @@ impl Title {
|
||||
} = mode
|
||||
{
|
||||
if apply {
|
||||
options.flat = true;
|
||||
options.world_type = crate::save::WorldPreset::Flat;
|
||||
options.flat_settings = Some(flat.settings);
|
||||
}
|
||||
self.mode = Mode::Create {
|
||||
@@ -420,6 +431,13 @@ impl Title {
|
||||
};
|
||||
}
|
||||
}
|
||||
/// The Data Packs screen's Done (`applyNewPackConfig`): its selection for
|
||||
/// the new world.
|
||||
pub fn set_data_packs(&mut self, ids: Vec<String>) {
|
||||
if let Mode::Create { options, .. } = &mut self.mode {
|
||||
options.data_packs = ids;
|
||||
}
|
||||
}
|
||||
fn matches_search(&self, world: &WorldEntry) -> bool {
|
||||
let filter = self.search.to_lowercase();
|
||||
world.name.to_lowercase().contains(&filter) || world.folder.to_lowercase().contains(&filter)
|
||||
@@ -1655,11 +1673,26 @@ impl Title {
|
||||
creative, options, ..
|
||||
},
|
||||
Some(id),
|
||||
) if id != 21 && id != 22 => {
|
||||
) if !matches!(id, 21 | 22 | 31) => {
|
||||
options.press(id, creative);
|
||||
Action::None
|
||||
}
|
||||
(Mode::Create { .. }, Some(21)) => self.create_action(),
|
||||
(Mode::Create { options, .. }, Some(31)) => {
|
||||
if options.pack_dir.is_none() {
|
||||
match crate::datapack::temp_folder() {
|
||||
Ok(dir) => options.pack_dir = Some(dir),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: no folder for data packs: {e}");
|
||||
return Action::None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Action::DataPacks {
|
||||
dir: options.pack_dir.clone().unwrap_or_default(),
|
||||
selected: options.data_packs.clone(),
|
||||
}
|
||||
}
|
||||
(Mode::Create { .. }, Some(22)) => {
|
||||
self.cancel_create();
|
||||
Action::None
|
||||
|
||||
@@ -30,14 +30,19 @@ pub struct WorldOptions {
|
||||
pub difficulty: usize,
|
||||
/// Allow Cheats as toggled; unset follows the game mode.
|
||||
pub commands: Option<bool>,
|
||||
/// The Superflat world preset instead of Default.
|
||||
pub flat: bool,
|
||||
/// The world preset the world is created with.
|
||||
pub world_type: crate::save::WorldPreset,
|
||||
/// CreateFlatWorldScreen's layers and biome, kept with the world type.
|
||||
pub flat_settings: Option<super::flat::FlatSettings>,
|
||||
pub structures: bool,
|
||||
pub bonus_chest: bool,
|
||||
/// `getGameRules`: the Game Rules screen's values, saved with the world.
|
||||
pub rules: crate::gamerules::GameRules,
|
||||
/// The Data Packs screen's selection, lowest priority first.
|
||||
pub data_packs: Vec<String>,
|
||||
/// The folder of packs for the new world (vanilla's `mcworld-`
|
||||
/// temporary folder), made when Data Packs first opens.
|
||||
pub pack_dir: Option<std::path::PathBuf>,
|
||||
}
|
||||
impl Default for WorldOptions {
|
||||
fn default() -> Self {
|
||||
@@ -46,11 +51,13 @@ impl Default for WorldOptions {
|
||||
hardcore: false,
|
||||
difficulty: 2,
|
||||
commands: None,
|
||||
flat: false,
|
||||
world_type: crate::save::WorldPreset::Normal,
|
||||
flat_settings: None,
|
||||
structures: true,
|
||||
bonus_chest: false,
|
||||
rules: crate::gamerules::GameRules::default(),
|
||||
data_packs: vec!["vanilla".into()],
|
||||
pack_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,9 +76,8 @@ impl WorldOptions {
|
||||
.position(|d| *d == level.difficulty)
|
||||
.unwrap_or(2),
|
||||
commands: Some(commands),
|
||||
flat: generation.flat,
|
||||
flat_settings: generation
|
||||
.flat
|
||||
world_type: generation.preset,
|
||||
flat_settings: (generation.preset == crate::save::WorldPreset::Flat)
|
||||
.then(|| super::flat::FlatSettings::from_world(dir))
|
||||
.flatten(),
|
||||
structures: generation.structures,
|
||||
@@ -94,7 +100,7 @@ impl WorldOptions {
|
||||
}
|
||||
pub fn world_gen(&self) -> crate::save::WorldGen {
|
||||
crate::save::WorldGen {
|
||||
flat: self.flat,
|
||||
preset: self.world_type,
|
||||
structures: self.structures,
|
||||
bonus_chest: self.bonus_chest(),
|
||||
}
|
||||
@@ -119,8 +125,8 @@ impl WorldOptions {
|
||||
23 => self.difficulty = (self.difficulty + 1) % DIFFICULTIES.len(),
|
||||
24 => self.commands = Some(!self.commands(*creative)),
|
||||
25 => {
|
||||
self.flat = !self.flat;
|
||||
if self.flat {
|
||||
self.world_type = self.world_type.next();
|
||||
if self.world_type == crate::save::WorldPreset::Flat {
|
||||
// setWorldType starts the generator from the flat preset.
|
||||
self.flat_settings = Some(super::flat::FlatSettings::default());
|
||||
}
|
||||
@@ -255,14 +261,7 @@ pub(super) fn buttons(w: f32, h: f32, creative: bool, o: &WorldOptions) -> Vec<B
|
||||
gx,
|
||||
gy,
|
||||
150.0,
|
||||
labelled(
|
||||
"selectWorld.mapType",
|
||||
tr(if o.flat {
|
||||
"generator.minecraft.flat"
|
||||
} else {
|
||||
"generator.minecraft.normal"
|
||||
}),
|
||||
),
|
||||
labelled("selectWorld.mapType", tr(o.world_type.label())),
|
||||
true,
|
||||
25,
|
||||
),
|
||||
@@ -272,7 +271,7 @@ pub(super) fn buttons(w: f32, h: f32, creative: bool, o: &WorldOptions) -> Vec<B
|
||||
gy,
|
||||
150.0,
|
||||
tr("selectWorld.customizeType"),
|
||||
o.flat,
|
||||
o.world_type == crate::save::WorldPreset::Flat,
|
||||
26,
|
||||
),
|
||||
// SwitchGrid: 44-wide value-only buttons at the grid's right.
|
||||
@@ -286,7 +285,7 @@ pub(super) fn buttons(w: f32, h: f32, creative: bool, o: &WorldOptions) -> Vec<B
|
||||
28,
|
||||
),
|
||||
],
|
||||
// Experiments and data packs have no screens yet.
|
||||
// Experiments have no screen yet.
|
||||
_ => [
|
||||
"selectWorld.gameRules",
|
||||
"selectWorld.experiments",
|
||||
@@ -300,7 +299,7 @@ pub(super) fn buttons(w: f32, h: f32, creative: bool, o: &WorldOptions) -> Vec<B
|
||||
gy + 28.0 * i as f32,
|
||||
210.0,
|
||||
tr(key),
|
||||
i == 0,
|
||||
i != 1,
|
||||
29 + i as u8,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -825,37 +825,76 @@ fn create_world_tabs_follow_vanilla_and_their_choices_reach_the_action() {
|
||||
};
|
||||
assert!(*creative && !options.hardcore && options.commands(true));
|
||||
// The World tab (second of three 124-wide tabs from x 54): its seed box
|
||||
// takes the focus, Superflat and the switches toggle.
|
||||
// takes the focus, the world type cycles and the switches toggle.
|
||||
let (action, sound) = f.title.click(54.0 + 124.0 + 10.0, 10.0, size);
|
||||
assert!(sound && action == Action::None);
|
||||
let world = labels(&f);
|
||||
assert_eq!((world[0].1, world[0].2), (85.0, 38.0));
|
||||
assert_eq!((world[2].1, world[2].2), (85.0 + 266.0, 38.0 + 73.0));
|
||||
f.title.click(world[0].1 + 10.0, world[0].2 + 10.0, size);
|
||||
// The world-type button cycles through vanilla's world presets.
|
||||
let world_type = |f: &Fixture| -> crate::save::WorldPreset {
|
||||
let Mode::Create { options, .. } = &f.title.mode else {
|
||||
panic!()
|
||||
};
|
||||
options.world_type
|
||||
};
|
||||
assert_eq!(world_type(&f), crate::save::WorldPreset::Normal);
|
||||
for expected in [
|
||||
crate::save::WorldPreset::Flat,
|
||||
crate::save::WorldPreset::LargeBiomes,
|
||||
crate::save::WorldPreset::Amplified,
|
||||
crate::save::WorldPreset::SingleBiomeSurface,
|
||||
crate::save::WorldPreset::Normal,
|
||||
] {
|
||||
f.title.click(world[0].1 + 10.0, world[0].2 + 10.0, size);
|
||||
assert_eq!(world_type(&f), expected);
|
||||
}
|
||||
// The amplified preset is the third one.
|
||||
for _ in 0..3 {
|
||||
f.title.click(world[0].1 + 10.0, world[0].2 + 10.0, size);
|
||||
}
|
||||
f.title.click(world[3].1 + 10.0, world[3].2 + 10.0, size);
|
||||
f.title.click(100.0, 38.0 + 41.0 + 10.0, size);
|
||||
let Mode::Create { focus, options, .. } = &f.title.mode else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(*focus, 1);
|
||||
assert!(options.flat && options.bonus_chest());
|
||||
assert_eq!(options.world_type, crate::save::WorldPreset::Amplified);
|
||||
assert!(options.bonus_chest());
|
||||
let Action::Create { options, .. } = f.title.create_action() else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
options.world_gen(),
|
||||
crate::save::WorldGen {
|
||||
flat: true,
|
||||
preset: crate::save::WorldPreset::Amplified,
|
||||
structures: true,
|
||||
bonus_chest: true
|
||||
}
|
||||
);
|
||||
// The More tab's Experiments and Data Packs screens don't exist yet.
|
||||
// The More tab: Experiments has no screen yet; Data
|
||||
// Packs opens on a new pack folder, and its selection reaches the action.
|
||||
f.title.click(54.0 + 248.0 + 10.0, 10.0, size);
|
||||
let more = labels(&f);
|
||||
assert_eq!(
|
||||
labels(&f)[..3].iter().map(|b| b.3).collect::<Vec<_>>(),
|
||||
[true, false, false]
|
||||
more[..3].iter().map(|b| b.3).collect::<Vec<_>>(),
|
||||
[true, false, true]
|
||||
);
|
||||
let (action, sound) = f.title.click(more[2].1 + 10.0, more[2].2 + 10.0, size);
|
||||
let Action::DataPacks { dir, selected } = action else {
|
||||
panic!("{action:?}")
|
||||
};
|
||||
assert!(sound && dir.is_dir() && selected == ["vanilla"]);
|
||||
f.title
|
||||
.set_data_packs(vec!["vanilla".into(), "file/ttm.zip".into()]);
|
||||
let Action::Create { options, .. } = f.title.create_action() else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(options.data_packs, ["vanilla", "file/ttm.zip"]);
|
||||
assert_eq!(options.pack_dir.as_ref(), Some(&dir));
|
||||
// Cancel removes the folder (removeTempDataPackDir).
|
||||
f.title.escape();
|
||||
assert!(!dir.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -863,26 +902,86 @@ fn new_world_generation_settings_round_trip() {
|
||||
let f = Fixture::new();
|
||||
let dir = f.root.join("flat");
|
||||
let options = crate::save::WorldGen {
|
||||
flat: true,
|
||||
preset: crate::save::WorldPreset::Flat,
|
||||
structures: false,
|
||||
bonus_chest: true,
|
||||
};
|
||||
crate::save::write_world_gen_settings(&dir, 42, options).unwrap();
|
||||
assert_eq!(crate::save::WorldGen::read(&dir), Some(options));
|
||||
let settings = crate::save::world_gen_settings(&dir).unwrap();
|
||||
let generator = settings
|
||||
.get("dimensions")
|
||||
.and_then(|d| d.get("minecraft:overworld"))
|
||||
.and_then(|o| o.get("generator"));
|
||||
assert_eq!(
|
||||
crate::save::text(
|
||||
settings
|
||||
.get("dimensions")
|
||||
.and_then(|d| d.get("minecraft:overworld"))
|
||||
.and_then(|o| o.get("generator"))
|
||||
.and_then(|g| g.get("type"))
|
||||
)
|
||||
.as_deref(),
|
||||
crate::save::text(generator.and_then(|g| g.get("type"))).as_deref(),
|
||||
Some("minecraft:flat")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_world_preset_writes_its_own_generator() {
|
||||
let f = Fixture::new();
|
||||
let generator = |preset: crate::save::WorldPreset| {
|
||||
let dir = f.root.join(preset.id());
|
||||
let options = crate::save::WorldGen {
|
||||
preset,
|
||||
structures: true,
|
||||
bonus_chest: false,
|
||||
};
|
||||
crate::save::write_world_gen_settings(&dir, 42, options).unwrap();
|
||||
assert_eq!(crate::save::WorldGen::read(&dir), Some(options));
|
||||
let settings = crate::save::world_gen_settings(&dir).unwrap();
|
||||
let generator = settings
|
||||
.get("dimensions")
|
||||
.and_then(|d| d.get("minecraft:overworld"))
|
||||
.and_then(|o| o.get("generator"))
|
||||
.cloned()
|
||||
.unwrap();
|
||||
(
|
||||
crate::save::text(generator.get("type")),
|
||||
crate::save::text(generator.get("settings")),
|
||||
crate::save::text(generator.get("biome_source").and_then(|s| s.get("type"))),
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
generator(crate::save::WorldPreset::Normal),
|
||||
(
|
||||
Some("minecraft:noise".to_owned()),
|
||||
Some("minecraft:overworld".to_owned()),
|
||||
Some("minecraft:multi_noise".to_owned()),
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
generator(crate::save::WorldPreset::Amplified),
|
||||
(
|
||||
Some("minecraft:noise".to_owned()),
|
||||
Some("minecraft:amplified".to_owned()),
|
||||
Some("minecraft:multi_noise".to_owned()),
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
generator(crate::save::WorldPreset::LargeBiomes),
|
||||
(
|
||||
Some("minecraft:noise".to_owned()),
|
||||
Some("minecraft:large_biomes".to_owned()),
|
||||
Some("minecraft:multi_noise".to_owned()),
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
generator(crate::save::WorldPreset::SingleBiomeSurface),
|
||||
(
|
||||
Some("minecraft:noise".to_owned()),
|
||||
Some("minecraft:overworld".to_owned()),
|
||||
Some("minecraft:fixed".to_owned()),
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
generator(crate::save::WorldPreset::Flat).0,
|
||||
Some("minecraft:flat".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_fields_edit_at_their_cursor() {
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
@@ -19,6 +19,7 @@ use horizon_world::{
|
||||
generator::{
|
||||
GeneratorData, GeneratorWorker, OverworldGenerator, ProtoChunk, SurfaceSample,
|
||||
},
|
||||
overrides::WorldgenOverrides,
|
||||
terrain::PreLightChunkStage,
|
||||
},
|
||||
};
|
||||
@@ -154,13 +155,40 @@ impl VanillaTerrain {
|
||||
dimension: &str,
|
||||
structures: bool,
|
||||
) -> crate::Result<Self> {
|
||||
let data: Arc<GeneratorData> = shared_data(dir, registry)?;
|
||||
let none = WorldgenOverrides::default();
|
||||
Self::for_world_with(
|
||||
dir, &none, registry, biomes, seed, preset, dimension, structures,
|
||||
)
|
||||
}
|
||||
/// `for_world` with the world's data packs over the bundle at `dir`
|
||||
/// (`datapack::worldgen_overrides`).
|
||||
pub fn for_world_with(
|
||||
dir: &Path,
|
||||
overrides: &WorldgenOverrides,
|
||||
registry: &'static BlockRegistry,
|
||||
biomes: &Biomes,
|
||||
seed: i64,
|
||||
preset: &str,
|
||||
dimension: &str,
|
||||
structures: bool,
|
||||
) -> crate::Result<Self> {
|
||||
let data: Arc<GeneratorData> = shared_data_with(dir, overrides, registry)?;
|
||||
let biome_map = data
|
||||
.biome_names()
|
||||
.iter()
|
||||
.map(|name| biomes.id(name))
|
||||
.collect();
|
||||
let generator = OverworldGenerator::for_world(data, seed, preset, dimension, structures)?;
|
||||
if let Some((asked, kept)) = generator.noise_clamped() {
|
||||
log::warn!(
|
||||
"Horizon: {dimension}'s noise settings reach y {}..{}, past its build height; \
|
||||
only y {}..{} generates",
|
||||
asked.min_y,
|
||||
asked.min_y + asked.height,
|
||||
kept.min_y,
|
||||
kept.min_y + kept.height
|
||||
);
|
||||
}
|
||||
let unsupported = generator.unsupported_features();
|
||||
if !unsupported.is_empty() {
|
||||
log::warn!(
|
||||
@@ -407,8 +435,12 @@ impl VanillaTerrain {
|
||||
u64::try_from(self.created.elapsed().as_millis()).unwrap_or(u64::MAX)
|
||||
}
|
||||
}
|
||||
static SHARED_DATA: std::sync::Mutex<Vec<(std::path::PathBuf, Arc<GeneratorData>)>> =
|
||||
std::sync::Mutex::new(Vec::new());
|
||||
/// A bundle directory, the data packs over it, and what loading them gave.
|
||||
type SharedEntry = (std::path::PathBuf, WorldgenOverrides, Arc<GeneratorData>);
|
||||
static SHARED_DATA: std::sync::Mutex<Vec<SharedEntry>> = std::sync::Mutex::new(Vec::new());
|
||||
/// Bundles with data packs kept besides the plain ones: a world's dimensions
|
||||
/// share one, and returning to a world finds it.
|
||||
const SHARED_WITH_PACKS: usize = 2;
|
||||
/// Let go of the bundles `shared_data` loaded: for a preload no world used
|
||||
/// (game/world_loading.rs). A generator that holds one keeps it.
|
||||
pub fn forget_shared_data() {
|
||||
@@ -422,15 +454,36 @@ pub fn forget_shared_data() {
|
||||
pub fn shared_data(
|
||||
dir: &Path,
|
||||
registry: &'static BlockRegistry,
|
||||
) -> crate::Result<Arc<GeneratorData>> {
|
||||
shared_data_with(dir, &WorldgenOverrides::default(), registry)
|
||||
}
|
||||
/// `shared_data` with a world's data packs over the bundle: loaded once for
|
||||
/// each set of packs' entries, the most recent few kept.
|
||||
pub fn shared_data_with(
|
||||
dir: &Path,
|
||||
overrides: &WorldgenOverrides,
|
||||
registry: &'static BlockRegistry,
|
||||
) -> crate::Result<Arc<GeneratorData>> {
|
||||
let mut cache = SHARED_DATA.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((_, data)) = cache.iter().find(|(path, _)| path == dir) {
|
||||
if let Some((_, _, data)) = cache
|
||||
.iter()
|
||||
.find(|(path, o, _)| path == dir && o == overrides)
|
||||
{
|
||||
return Ok(data.clone());
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
let data = GeneratorData::load(dir, registry)?;
|
||||
let data = GeneratorData::load_with(dir, registry, overrides)?;
|
||||
log::info!("Horizon: worldgen data loaded in {:?}", started.elapsed());
|
||||
cache.push((dir.to_owned(), data.clone()));
|
||||
for warning in data.warnings() {
|
||||
log::warn!("Horizon: data pack worldgen: {warning}");
|
||||
}
|
||||
if !overrides.is_empty()
|
||||
&& cache.iter().filter(|e| !e.1.is_empty()).count() >= SHARED_WITH_PACKS
|
||||
{
|
||||
let oldest = cache.iter().position(|e| !e.1.is_empty());
|
||||
cache.remove(oldest.expect("counted"));
|
||||
}
|
||||
cache.push((dir.to_owned(), overrides.clone(), data.clone()));
|
||||
Ok(data)
|
||||
}
|
||||
impl ChunkGenerator for VanillaTerrain {
|
||||
|
||||
@@ -305,6 +305,8 @@ pub(super) struct VanillaLodSampler {
|
||||
cache: FxHashMap<(i32, i32), Arc<[Column; 256]>>,
|
||||
order: VecDeque<(i32, i32)>,
|
||||
details: Vec<LodDetail>,
|
||||
/// Reduced far detail: `lod_grid` approximates wide cells.
|
||||
approximate: bool,
|
||||
}
|
||||
impl VanillaLodSampler {
|
||||
pub(super) fn new(generator: Arc<VanillaTerrain>) -> Self {
|
||||
@@ -325,6 +327,7 @@ impl VanillaLodSampler {
|
||||
cache: FxHashMap::default(),
|
||||
order: VecDeque::new(),
|
||||
details: Vec::new(),
|
||||
approximate: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -339,8 +342,12 @@ impl LodSampler for VanillaLodSampler {
|
||||
cache: FxHashMap::default(),
|
||||
order: VecDeque::new(),
|
||||
details: Vec::new(),
|
||||
approximate: self.approximate,
|
||||
}))
|
||||
}
|
||||
fn set_approximate(&mut self, approximate: bool) {
|
||||
self.approximate = approximate;
|
||||
}
|
||||
fn request_detail(&mut self, chunks: &[(i32, i32)], center: [i32; 2]) {
|
||||
let mut missing = Vec::new();
|
||||
for &key in chunks {
|
||||
@@ -399,16 +406,17 @@ impl LodSampler for VanillaLodSampler {
|
||||
self.grid(x, z, width, 1).pop().unwrap_or_default()
|
||||
}
|
||||
fn grid(&mut self, x: i32, z: i32, width: i32, side: usize) -> Vec<Column> {
|
||||
let samples = match self
|
||||
.worker
|
||||
.lod_grid(x + width / 2, z + width / 2, width, side)
|
||||
{
|
||||
Ok(samples) => samples,
|
||||
Err(error) => {
|
||||
log::warn!("Horizon: direct far sampling failed: {error}");
|
||||
return vec![Column::default(); side * side];
|
||||
}
|
||||
};
|
||||
let samples =
|
||||
match self
|
||||
.worker
|
||||
.lod_grid(x + width / 2, z + width / 2, width, side, self.approximate)
|
||||
{
|
||||
Ok(samples) => samples,
|
||||
Err(error) => {
|
||||
log::warn!("Horizon: direct far sampling failed: {error}");
|
||||
return vec![Column::default(); side * side];
|
||||
}
|
||||
};
|
||||
samples
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
|
||||
@@ -106,6 +106,50 @@ impl BiomeZoom {
|
||||
}
|
||||
selected
|
||||
}
|
||||
/// The jitter `quart_at_block` adds to each of the eight corners of the
|
||||
/// quart cell `base` (the block position minus 2, shifted right by 2), as
|
||||
/// `[x, y, z]` per corner in its corner order (x, then y, then z offset).
|
||||
/// With `select_corner` this gives `quart_at_block`'s choice for every
|
||||
/// block of the cell from one mix.
|
||||
pub fn corner_jitter(self, base: [i32; 3]) -> [[f64; 3]; 8] {
|
||||
let coordinate = base.map(i64::from);
|
||||
let seed = self.seed;
|
||||
const A: i64 = 6364136223846793005;
|
||||
const B: i64 = 1442695040888963407;
|
||||
let g = |m: i64| m.wrapping_mul(m.wrapping_mul(A).wrapping_add(B));
|
||||
let root = g(seed);
|
||||
std::array::from_fn(|corner| {
|
||||
let [x, y, z] = [(corner >> 2) & 1, (corner >> 1) & 1, corner & 1].map(|v| v as i64);
|
||||
let m0 = root.wrapping_add(coordinate[0].wrapping_add(x));
|
||||
let m1 = g(m0).wrapping_add(coordinate[1].wrapping_add(y));
|
||||
let m2 = g(m1).wrapping_add(coordinate[2].wrapping_add(z));
|
||||
let m3 = g(m2).wrapping_add(coordinate[0].wrapping_add(x));
|
||||
let m4 = g(m3).wrapping_add(coordinate[1].wrapping_add(y));
|
||||
let m5 = g(m4).wrapping_add(coordinate[2].wrapping_add(z));
|
||||
let m6 = g(m5).wrapping_add(seed);
|
||||
let m7 = g(m6).wrapping_add(seed);
|
||||
[fiddle(m5), fiddle(m6), fiddle(m7)]
|
||||
})
|
||||
}
|
||||
/// The corner (index into `corner_jitter`) `quart_at_block` picks for
|
||||
/// `block` inside the cell whose jitter is `jitter`.
|
||||
#[inline]
|
||||
pub fn select_corner(block: BlockPos, jitter: &[[f64; 3]; 8]) -> usize {
|
||||
let fractions = [block.x, block.y, block.z].map(|v| f64::from(v.wrapping_sub(2) & 3) / 4.);
|
||||
let mut selected = 0;
|
||||
let mut best = f64::INFINITY;
|
||||
for (corner, jitter) in jitter.iter().enumerate() {
|
||||
let dx = fractions[0] - ((corner >> 2) & 1) as f64 + jitter[0];
|
||||
let dy = fractions[1] - ((corner >> 1) & 1) as f64 + jitter[1];
|
||||
let dz = fractions[2] - (corner & 1) as f64 + jitter[2];
|
||||
let distance = dz * dz + dy * dy + dx * dx;
|
||||
if best > distance {
|
||||
best = distance;
|
||||
selected = corner;
|
||||
}
|
||||
}
|
||||
selected
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct noise-biome lookup bypasses the eight-corner zoom.
|
||||
@@ -224,4 +268,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_jitter_matches_quart_at_block() {
|
||||
for seed in [0i64, -1, i64::MIN, 0x0123_4567_89ab_cdef] {
|
||||
let zoom = BiomeZoom::new(seed);
|
||||
let check = |block: BlockPos| {
|
||||
let base = [block.x, block.y, block.z].map(|v| v.wrapping_sub(2) >> 2);
|
||||
let corner = BiomeZoom::select_corner(block, &zoom.corner_jitter(base));
|
||||
let offsets = [(corner >> 2) & 1, (corner >> 1) & 1, corner & 1];
|
||||
let quart: [i32; 3] =
|
||||
std::array::from_fn(|i| base[i].wrapping_add(offsets[i] as i32));
|
||||
assert_eq!(
|
||||
quart,
|
||||
zoom.quart_at_block(block),
|
||||
"seed {seed} at {block:?}"
|
||||
);
|
||||
};
|
||||
for x in -20..20 {
|
||||
for y in -20..20 {
|
||||
for z in -20..20 {
|
||||
check(BlockPos::new(x, y, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
for v in [i32::MIN, i32::MIN + 1, -1, 0, i32::MAX - 1, i32::MAX] {
|
||||
check(BlockPos::new(v, v, v));
|
||||
check(BlockPos::new(v, v.wrapping_neg(), 3));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,14 @@ impl SamplerContext {
|
||||
..Self::cached()
|
||||
}
|
||||
}
|
||||
/// Keeps each y-fixed slice's plane (a function of x and z only), so that
|
||||
/// sampling the same x/z lattice again, at other heights or another
|
||||
/// vertical step, reuses it. For one evaluation over one graph: far
|
||||
/// previews sample a tile coarsely, then again around its surface.
|
||||
pub fn with_planes(mut self) -> Self {
|
||||
self.planes = Some(Default::default());
|
||||
self
|
||||
}
|
||||
pub fn clear_caches(&mut self) {
|
||||
if let Some(caches) = &mut self.caches {
|
||||
caches.clear();
|
||||
|
||||
@@ -94,6 +94,8 @@ pub struct SamplerContext {
|
||||
caches: Option<Vec<cache::CacheCell>>,
|
||||
fields: WorldFields,
|
||||
sparse: bool,
|
||||
/// Planes of y-fixed slices already sampled (`with_planes`).
|
||||
planes: Option<std::collections::HashMap<slice::PlaneKey, Vec<f32>>>,
|
||||
}
|
||||
impl SamplerContext {
|
||||
fn with_buffer<T>(
|
||||
@@ -113,6 +115,9 @@ impl SamplerContext {
|
||||
pub fn reset(&mut self) {
|
||||
self.caches = self.caches.as_ref().map(|_| Vec::new());
|
||||
self.fields = WorldFields::default();
|
||||
if let Some(planes) = &mut self.planes {
|
||||
planes.clear();
|
||||
}
|
||||
}
|
||||
/// Release retained storage between worlds or after unusually large work.
|
||||
pub fn clear_scratch(&mut self) {
|
||||
|
||||
@@ -19,6 +19,10 @@ impl SliceAxes {
|
||||
}
|
||||
}
|
||||
|
||||
/// A y-fixed slice's plane: the slice node, the plane's corner (y fixed),
|
||||
/// its x/z sizes and steps (`SamplerContext::with_planes`).
|
||||
pub(super) type PlaneKey = (usize, [i32; 3], [usize; 2], [i32; 2]);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SliceSampler {
|
||||
pub input: Arc<dyn DensitySampler>,
|
||||
@@ -61,25 +65,54 @@ impl DensitySampler for SliceSampler {
|
||||
if reduced == volume {
|
||||
return self.input.sample_volume(context, buffer, volume);
|
||||
}
|
||||
let plane = (fixed[1].is_some() && context.planes.is_some()).then(|| {
|
||||
let [rx, _, rz] = reduced.sizes();
|
||||
let [step_x, _, step_z] = volume.steps();
|
||||
(
|
||||
self as *const Self as usize,
|
||||
reduced.minimum(),
|
||||
[rx, rz],
|
||||
[step_x, step_z],
|
||||
)
|
||||
});
|
||||
if let Some(key) = &plane
|
||||
&& let Some(samples) = context.planes.as_ref().and_then(|p| p.get(key))
|
||||
{
|
||||
spread(fixed, volume, reduced, samples, buffer);
|
||||
return Ok(());
|
||||
}
|
||||
context.with_buffer(reduced.size(), |context, samples| {
|
||||
self.input.sample_volume(context, samples, reduced)?;
|
||||
let [sx, sy, sz] = volume.sizes();
|
||||
let [rx, ry, _] = reduced.sizes();
|
||||
for z in 0..sz {
|
||||
let source_z = if fixed[2].is_some() { 0 } else { z };
|
||||
for x in 0..sx {
|
||||
let source_x = if fixed[0].is_some() { 0 } else { x };
|
||||
let source = (source_x + source_z * rx) * ry;
|
||||
let target = (x + z * sx) * sy;
|
||||
let column = &mut buffer[target..target + sy];
|
||||
if fixed[1].is_some() {
|
||||
column.fill(samples[source]);
|
||||
} else {
|
||||
column.copy_from_slice(&samples[source..source + sy]);
|
||||
}
|
||||
}
|
||||
spread(fixed, volume, reduced, samples, buffer);
|
||||
if let (Some(key), Some(planes)) = (plane, &mut context.planes) {
|
||||
planes.insert(key, samples[..reduced.size()].to_vec());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
/// Copies the `reduced` samples along the fixed axes of `volume`.
|
||||
fn spread(
|
||||
fixed: [Option<i32>; 3],
|
||||
volume: DensityVolume,
|
||||
reduced: DensityVolume,
|
||||
samples: &[f32],
|
||||
buffer: &mut [f32],
|
||||
) {
|
||||
let [sx, sy, sz] = volume.sizes();
|
||||
let [rx, ry, _] = reduced.sizes();
|
||||
for z in 0..sz {
|
||||
let source_z = if fixed[2].is_some() { 0 } else { z };
|
||||
for x in 0..sx {
|
||||
let source_x = if fixed[0].is_some() { 0 } else { x };
|
||||
let source = (source_x + source_z * rx) * ry;
|
||||
let target = (x + z * sx) * sy;
|
||||
let column = &mut buffer[target..target + sy];
|
||||
if fixed[1].is_some() {
|
||||
column.fill(samples[source]);
|
||||
} else {
|
||||
column.copy_from_slice(&samples[source..source + sy]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -959,3 +959,54 @@ fn binary_bulk_preserves_child_failure_order_and_output() {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// A y-fixed slice's plane, kept by `SamplerContext::with_planes`, gives the
|
||||
/// values fresh sampling gives at other heights and vertical steps, and is
|
||||
/// sampled once for one x/z lattice.
|
||||
#[test]
|
||||
fn kept_planes_match_fresh_slices_across_vertical_lattices() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
#[derive(Debug)]
|
||||
struct Counted(NoiseSampler, AtomicUsize);
|
||||
impl DensitySampler for Counted {
|
||||
fn sample_value(&self, c: &mut SamplerContext, p: [i32; 3]) -> Result<f32, SampleError> {
|
||||
self.0.sample_value(c, p)
|
||||
}
|
||||
fn sample_volume(
|
||||
&self,
|
||||
c: &mut SamplerContext,
|
||||
out: &mut [f32],
|
||||
v: DensityVolume,
|
||||
) -> Result<(), SampleError> {
|
||||
self.1.fetch_add(1, Ordering::Relaxed);
|
||||
self.0.sample_volume(c, out, v)
|
||||
}
|
||||
}
|
||||
let input = Arc::new(Counted(
|
||||
NoiseSampler {
|
||||
noise: Arc::new(PerlinNoise::new(&mut LegacyRandom::new(5))),
|
||||
xz_scale: 0.3,
|
||||
y_scale: 0.7,
|
||||
},
|
||||
AtomicUsize::new(0),
|
||||
));
|
||||
let slice = slice::SliceSampler {
|
||||
input: input.clone(),
|
||||
axes: slice::SliceAxes::Y(0),
|
||||
};
|
||||
let lattices = [
|
||||
DensityVolume::new([5, 9, 4], [-8, -64, 12], [4, 64, 4]).unwrap(),
|
||||
DensityVolume::new([5, 17, 4], [-8, 0, 12], [4, 8, 4]).unwrap(),
|
||||
];
|
||||
let mut kept = SamplerContext::sparse().with_planes();
|
||||
for (i, volume) in lattices.into_iter().enumerate() {
|
||||
let mut fresh = vec![0.0; volume.size()];
|
||||
slice
|
||||
.sample_volume(&mut SamplerContext::sparse(), &mut fresh, volume)
|
||||
.unwrap();
|
||||
let mut reused = vec![1.0; volume.size()];
|
||||
slice.sample_volume(&mut kept, &mut reused, volume).unwrap();
|
||||
assert_eq!(fresh, reused, "lattice {i}");
|
||||
}
|
||||
// Two fresh evaluations and one kept plane.
|
||||
assert_eq!(input.1.load(Ordering::Relaxed), 3);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Far terrain sampling cost (`GeneratorWorker::lod_grid`): milliseconds per
|
||||
//! far tile at each tile level, as the client's far terrain requests them
|
||||
//! (16 x 16 columns), with checksums of what they hold.
|
||||
//!
|
||||
//! lod_grid_bench <client resource dir> [--seed S] [--tiles N] [--levels 0,2,4,6] [--dump PATH]
|
||||
//! [--approximate]
|
||||
//!
|
||||
//! `--approximate` samples as Reduced far detail does.
|
||||
use horizon_world::worldgen::generator::{GeneratorData, OverworldGenerator};
|
||||
use std::{path::PathBuf, time::Instant};
|
||||
|
||||
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
fn main() -> Result<(), Error> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let resources = PathBuf::from(args.next().ok_or("usage: lod_grid_bench <resources>")?);
|
||||
let (mut seed, mut tiles) = (0i64, 25);
|
||||
let mut levels = vec![0u8, 2, 4, 6];
|
||||
let mut dump: Option<std::fs::File> = None;
|
||||
let mut approximate = false;
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--seed" => seed = args.next().ok_or("--seed S")?.parse()?,
|
||||
"--approximate" => approximate = true,
|
||||
"--tiles" => tiles = args.next().ok_or("--tiles N")?.parse()?,
|
||||
"--dump" => dump = Some(std::fs::File::create(args.next().ok_or("--dump PATH")?)?),
|
||||
"--levels" => {
|
||||
levels = args
|
||||
.next()
|
||||
.ok_or("--levels 0,2,4")?
|
||||
.split(',')
|
||||
.map(str::parse)
|
||||
.collect::<Result<_, _>>()?
|
||||
}
|
||||
_ => return Err(format!("unknown argument {arg}").into()),
|
||||
}
|
||||
}
|
||||
let registry = Box::leak(Box::new(horizon_world::level::data::load_registry(
|
||||
&resources,
|
||||
)?));
|
||||
let start = Instant::now();
|
||||
let data = GeneratorData::load(&resources.join("worldgen"), registry)?;
|
||||
let generator =
|
||||
OverworldGenerator::for_dimension(data, seed, "minecraft:normal", "minecraft:overworld")?;
|
||||
let mut worker = generator.worker();
|
||||
println!(
|
||||
"seed {seed}, generator ready in {:.1}s",
|
||||
start.elapsed().as_secs_f64()
|
||||
);
|
||||
// Warm the worker's caches once.
|
||||
worker.lod_grid(0, 0, 16, 16, approximate)?;
|
||||
for level in levels {
|
||||
let size = 16i32 << level;
|
||||
let width = size / 16;
|
||||
let start = Instant::now();
|
||||
let mut spans = 0;
|
||||
// Everything the tiles hold: an exact speedup keeps it.
|
||||
let mut hash = std::hash::DefaultHasher::new();
|
||||
// The terrain's shape alone (surface and water heights): an
|
||||
// approximation of materials or biomes keeps this one.
|
||||
let mut heights = std::hash::DefaultHasher::new();
|
||||
for i in 0..tiles {
|
||||
// Tiles around the origin, as a view would request them.
|
||||
let (tx, tz) = (i % 5 - 2, i / 5 - 2);
|
||||
let grid = worker.lod_grid(
|
||||
tx * size + width / 2,
|
||||
tz * size + width / 2,
|
||||
width,
|
||||
16,
|
||||
approximate,
|
||||
)?;
|
||||
spans += grid.iter().map(|(_, s)| s.len()).sum::<usize>();
|
||||
std::hash::Hash::hash(&format!("{grid:?}"), &mut hash);
|
||||
for (surface, _) in &grid {
|
||||
let shape = surface.as_ref().map(|s| (s.height, s.water.map(|w| w.0)));
|
||||
std::hash::Hash::hash(&shape, &mut heights);
|
||||
if let Some(file) = &mut dump {
|
||||
use std::io::Write;
|
||||
let s = surface.as_ref();
|
||||
writeln!(
|
||||
file,
|
||||
"{level} {:?} {:?} {:?}",
|
||||
s.map(|s| s.height),
|
||||
s.map(|s| s.state.0),
|
||||
s.map(|s| s.biome.0)
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let ms = start.elapsed().as_secs_f64() * 1000. / tiles as f64;
|
||||
println!(
|
||||
"level {level} ({size} blocks, stride {width}): {ms:.1} ms/tile, {:.1} spans/column, output {:016x}, heights {:016x}",
|
||||
spans as f64 / (tiles * 256) as f64,
|
||||
std::hash::Hasher::finish(&hash),
|
||||
std::hash::Hasher::finish(&heights)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
//!
|
||||
//! chunk_dump <client resource dir> <seed> <dimension> <x0,z0,x1,z1>...
|
||||
//! With HORIZON_SCAN set, one rectangle lists the structure starts in it.
|
||||
//! HORIZON_DATAPACKS names a world's datapacks folder of folder packs, each
|
||||
//! enabled after vanilla in name order (tools/test_datapack_worldgen_parity.py).
|
||||
//! HORIZON_SAVED_STARTS names a JSON file of saved starts (`[{"id", "chunk":
|
||||
//! [x, z], "pieces": [...]}]`, jigsaw pieces as a save lists them) that take
|
||||
//! the place of the starts generation builds in their chunks.
|
||||
@@ -20,7 +22,10 @@ use horizon_world::{
|
||||
DATA_VERSION,
|
||||
nbt::Tag,
|
||||
registry::BlockRegistry,
|
||||
worldgen::generator::{GeneratorData, OverworldGenerator, ProtoChunk},
|
||||
worldgen::{
|
||||
generator::{GeneratorData, OverworldGenerator, ProtoChunk},
|
||||
overrides::{PackFile, WorldgenOverrides},
|
||||
},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fmt::Write as _, fs, io::Write as _, path::Path};
|
||||
@@ -99,6 +104,50 @@ fn canonical(tag: &Tag, out: &mut String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// HORIZON_DATAPACKS: a world's datapacks folder of folder packs, each enabled
|
||||
/// after vanilla in name order, as the oracle selects them.
|
||||
fn datapacks(dir: &Path) -> Result<WorldgenOverrides, Error> {
|
||||
fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
let mut entries: Vec<_> = entries.flatten().map(|e| e.path()).collect();
|
||||
entries.sort();
|
||||
for path in entries {
|
||||
if path.is_dir() {
|
||||
walk(root, &path, out);
|
||||
} else if let Ok(rel) = path.strip_prefix(root) {
|
||||
out.push(rel.to_string_lossy().replace('\\', "/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut packs: Vec<_> = fs::read_dir(dir)?
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.join("pack.mcmeta").exists())
|
||||
.collect();
|
||||
packs.sort();
|
||||
let mut overrides = WorldgenOverrides::default();
|
||||
for pack in packs {
|
||||
let id = format!(
|
||||
"file/{}",
|
||||
pack.file_name().unwrap_or_default().to_string_lossy()
|
||||
);
|
||||
let mut files = Vec::new();
|
||||
walk(&pack, &pack.join("data"), &mut files);
|
||||
for file in files {
|
||||
let read = || fs::read_to_string(pack.join(&file)).ok();
|
||||
if let PackFile::Unreadable = overrides.add_file(&id, &file, read) {
|
||||
return Err(format!("{id}: {file}: unreadable").into());
|
||||
}
|
||||
}
|
||||
}
|
||||
for warning in overrides.finish(|_| true) {
|
||||
eprintln!("data pack worldgen: {warning}");
|
||||
}
|
||||
Ok(overrides)
|
||||
}
|
||||
|
||||
pub fn main() -> Result<(), Error> {
|
||||
let mut args = crate::args().skip(1);
|
||||
let resources = std::path::PathBuf::from(args.next().ok_or("client resource directory")?);
|
||||
@@ -126,7 +175,14 @@ pub fn main() -> Result<(), Error> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let data = GeneratorData::load(&resources.join("worldgen"), blocks)?;
|
||||
let overrides = match std::env::var_os("HORIZON_DATAPACKS") {
|
||||
Some(dir) => datapacks(Path::new(&dir))?,
|
||||
None => WorldgenOverrides::default(),
|
||||
};
|
||||
let data = GeneratorData::load_with(&resources.join("worldgen"), blocks, &overrides)?;
|
||||
for warning in data.warnings() {
|
||||
eprintln!("data pack worldgen: {warning}");
|
||||
}
|
||||
let generator = OverworldGenerator::for_dimension(data, seed, "minecraft:normal", &dimension)?;
|
||||
let mut worker = generator.worker();
|
||||
// HORIZON_PHASE: terrain (no decoration), structures (pieces only) or full.
|
||||
|
||||
@@ -364,6 +364,10 @@ fn local_ids(state: BlockStateId) -> LocalIds {
|
||||
ids.insert(state, 0);
|
||||
ids
|
||||
}
|
||||
#[cold]
|
||||
fn out_of_bounds() -> io::Error {
|
||||
invalid("section index out of bounds")
|
||||
}
|
||||
pub struct BlockSection<'r> {
|
||||
palette: Vec<BlockStateId>,
|
||||
local_ids: LocalIds,
|
||||
@@ -421,12 +425,19 @@ impl<'r> BlockSection<'r> {
|
||||
pub fn bits(&self) -> u8 {
|
||||
self.storage.as_ref().map_or(0, PackedStorage::bits)
|
||||
}
|
||||
#[inline]
|
||||
pub fn get(&self, index: usize) -> io::Result<BlockStateId> {
|
||||
if index >= 4096 {
|
||||
return Err(invalid("section index out of bounds"));
|
||||
return Err(out_of_bounds());
|
||||
}
|
||||
Ok(self.state(index))
|
||||
}
|
||||
/// The state at `index`, which must be below 4096 (a `local_index`).
|
||||
#[inline]
|
||||
pub(crate) fn state(&self, index: usize) -> BlockStateId {
|
||||
debug_assert!(index < 4096);
|
||||
let id = self.storage.as_ref().map_or(0, |s| s.read(index));
|
||||
Ok(self.palette[id as usize])
|
||||
self.palette[id as usize]
|
||||
}
|
||||
pub fn set(&mut self, index: usize, state: BlockStateId) -> io::Result<BlockStateId> {
|
||||
if index >= 4096 {
|
||||
|
||||
@@ -137,6 +137,21 @@ impl Heightmap {
|
||||
Ok((self.data.get(Self::index(x, z))? as i32).wrapping_add(self.min_y))
|
||||
}
|
||||
|
||||
/// Whether any column of the local square `x0..=x1` by `z0..=z1` (each
|
||||
/// within 0..16) has its first available y at or above `y`.
|
||||
pub fn any_first_available_at_least(
|
||||
&self,
|
||||
[x0, x1]: [i32; 2],
|
||||
[z0, z1]: [i32; 2],
|
||||
y: i32,
|
||||
) -> bool {
|
||||
debug_assert!(0 <= x0 && x1 < 16 && 0 <= z0 && z1 < 16);
|
||||
(z0..=z1).any(|z| {
|
||||
(x0..=x1)
|
||||
.any(|x| (self.data.read(Self::index(x, z)) as i32).wrapping_add(self.min_y) >= y)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn highest_taken(&self, x: i32, z: i32) -> Result<i32, HeightmapError> {
|
||||
Ok(self.first_available(x, z)?.wrapping_sub(1))
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ pub mod material_extensions;
|
||||
pub mod material_stage;
|
||||
pub mod material_surface;
|
||||
pub mod ore_feature;
|
||||
pub mod overrides;
|
||||
pub mod placement;
|
||||
pub mod projected;
|
||||
pub mod replace_blobs;
|
||||
|
||||
@@ -57,6 +57,11 @@ impl<T: Clone> BiomeChunk<T> {
|
||||
let section = ((y - bottom) >> 2) as usize;
|
||||
&self.sections[section][index(quart[0], y, quart[2])]
|
||||
}
|
||||
/// The quart Ys `noise_biome` clamps to: the lowest and the highest.
|
||||
pub fn quart_range(&self) -> (i32, i32) {
|
||||
let bottom = self.min_y >> 2;
|
||||
(bottom, bottom + (self.height >> 2) - 1)
|
||||
}
|
||||
/// Fill the sections intersecting inclusive generation bounds. Sections are
|
||||
/// visited bottom to top; each resolver sees X/Y/Z loop order. A failed
|
||||
/// section retains its previous values while earlier sections stay committed.
|
||||
@@ -183,6 +188,9 @@ impl<T: Clone> BiomeRegion<T> {
|
||||
pub fn quart_at_block(&self, block: BlockPos) -> [i32; 3] {
|
||||
self.zoom.quart_at_block(block)
|
||||
}
|
||||
pub fn zoom(&self) -> BiomeZoom {
|
||||
self.zoom
|
||||
}
|
||||
/// Distinct values currently stored anywhere in the chunk. The result is
|
||||
/// sorted by biome identity; callers must not use this order as registry order.
|
||||
pub fn present_in_chunk(&self, p: ChunkPos) -> Result<Vec<T>, BiomeRegionError>
|
||||
|
||||
@@ -8,7 +8,7 @@ use super::{
|
||||
int_range, number_text, optional,
|
||||
placement::{PlacementContext, PlacementQueryError},
|
||||
required,
|
||||
tree::{TreeDecoder, TreeResult, kind, read, unit_float, write},
|
||||
tree::{TreeDecoder, TreeResult, kind, read, scan_column, unit_float, write},
|
||||
tree_blocks::{self as tb, TreeBlocks},
|
||||
value_provider::{FloatProvider, IntProvider},
|
||||
};
|
||||
@@ -85,14 +85,11 @@ impl Speleothem {
|
||||
if !self.empty_or_water(read(world, pos)?) {
|
||||
return Ok(None);
|
||||
}
|
||||
// Column.scan: up to range - 1 empty blocks from pos, then the edge.
|
||||
let mut scan = |step: i32| -> TreeResult<Option<i32>, W::Error> {
|
||||
let mut p = pos;
|
||||
let mut i = 1;
|
||||
while i < range && self.empty_or_water(read(world, p)?) {
|
||||
p[1] = p[1].wrapping_add(step);
|
||||
i += 1;
|
||||
}
|
||||
Ok(edge(self, read(world, p)?).then_some(p[1]))
|
||||
let y = scan_column(world, pos, step, range - 1, |s| self.empty_or_water(s))?;
|
||||
let p = [pos[0], y, pos[2]];
|
||||
Ok(edge(self, read(world, p)?).then_some(y))
|
||||
};
|
||||
let ceiling = scan(1)?;
|
||||
let floor = scan(-1)?;
|
||||
|
||||
@@ -30,6 +30,30 @@ impl FeatureBlocks {
|
||||
pub trait FeatureWorld {
|
||||
type Error;
|
||||
fn block(&mut self, position: [i32; 3]) -> Result<BlockStateId, Self::Error>;
|
||||
/// Walk the column from `position` by `step` (1 or -1) while `keep`
|
||||
/// holds for the block there, reading at most `limit` blocks. Returns the
|
||||
/// y it stopped at: the first refused block, or the one after the last
|
||||
/// read. `keep` must depend only on the state (it may be skipped for a
|
||||
/// run of blocks known to be identical).
|
||||
fn scan_column(
|
||||
&mut self,
|
||||
position: [i32; 3],
|
||||
step: i32,
|
||||
limit: i32,
|
||||
mut keep: impl FnMut(BlockStateId) -> bool,
|
||||
) -> Result<i32, Self::Error>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let mut p = position;
|
||||
for _ in 0..limit {
|
||||
if !keep(self.block(p)?) {
|
||||
break;
|
||||
}
|
||||
p[1] = p[1].wrapping_add(step);
|
||||
}
|
||||
Ok(p[1])
|
||||
}
|
||||
fn set_block(
|
||||
&mut self,
|
||||
position: [i32; 3],
|
||||
|
||||
@@ -118,6 +118,7 @@ impl ChunkIndex {
|
||||
cells,
|
||||
})
|
||||
}
|
||||
#[inline]
|
||||
fn get(&self, x: i32, z: i32) -> Option<usize> {
|
||||
match self {
|
||||
Self::Grid {
|
||||
@@ -246,9 +247,11 @@ impl<'r, B: TerrainBlockStates> TerrainRegion<'r, B> {
|
||||
pub fn chunks(&self) -> impl Iterator<Item = &TerrainChunk<'r, B>> {
|
||||
self.chunks.iter()
|
||||
}
|
||||
#[inline]
|
||||
fn index(&self, x: i32, z: i32) -> Option<usize> {
|
||||
self.index.get(x, z)
|
||||
}
|
||||
#[inline]
|
||||
pub fn chunk(&self, x: i32, z: i32) -> Result<&TerrainChunk<'r, B>, RegionError> {
|
||||
match self.index(x, z) {
|
||||
Some(i) => Ok(&self.chunks[i]),
|
||||
@@ -261,9 +264,22 @@ impl<'r, B: TerrainBlockStates> TerrainRegion<'r, B> {
|
||||
None => Err(RegionError::MissingChunk([x, z])),
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
pub fn block(&self, [x, y, z]: [i32; 3]) -> Result<BlockStateId, RegionError> {
|
||||
Ok(self.chunk(x >> 4, z >> 4)?.block(x, y, z))
|
||||
}
|
||||
/// `FeatureWorld::scan_column` within one chunk's column.
|
||||
pub fn scan_column(
|
||||
&self,
|
||||
[x, y, z]: [i32; 3],
|
||||
step: i32,
|
||||
limit: i32,
|
||||
keep: impl FnMut(BlockStateId) -> bool,
|
||||
) -> Result<i32, RegionError> {
|
||||
Ok(self
|
||||
.chunk(x >> 4, z >> 4)?
|
||||
.scan_column([x, y, z], step, limit, keep))
|
||||
}
|
||||
/// Level-style biome lookup checks the selected chunk's generation status
|
||||
/// before reading its palette. Raw biome storage remains independently
|
||||
/// accessible for generation itself. Retained upgrade status permits reading
|
||||
@@ -650,6 +666,15 @@ where
|
||||
fn block(&mut self, position: [i32; 3]) -> Result<BlockStateId, Self::Error> {
|
||||
self.region.block(position)
|
||||
}
|
||||
fn scan_column(
|
||||
&mut self,
|
||||
position: [i32; 3],
|
||||
step: i32,
|
||||
limit: i32,
|
||||
keep: impl FnMut(BlockStateId) -> bool,
|
||||
) -> Result<i32, Self::Error> {
|
||||
self.region.scan_column(position, step, limit, keep)
|
||||
}
|
||||
fn set_block(
|
||||
&mut self,
|
||||
position: [i32; 3],
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -341,10 +341,13 @@ impl Decoration {
|
||||
let mut biomes = Vec::with_capacity(9);
|
||||
let (min_y, height) = (region[4].terrain.min_y(), region[4].terrain.height());
|
||||
for chunk in region.iter_mut() {
|
||||
// A one-section stand-in while the region holds the terrain: it
|
||||
// is swapped back below, and allocating the full height for nine
|
||||
// stand-ins per region cost more than some features.
|
||||
let empty = super::terrain::TerrainChunk::new(
|
||||
chunk.position(),
|
||||
min_y,
|
||||
height,
|
||||
16,
|
||||
data.registry(),
|
||||
data.block_states().clone(),
|
||||
)
|
||||
@@ -380,11 +383,15 @@ impl Decoration {
|
||||
biomes: BiomeRegion::new(biomes, obfuscate_seed(seed))
|
||||
.map_err(|e| resource(e.to_string()))?,
|
||||
zone: GenerationWriteZone::new(center, 1),
|
||||
center,
|
||||
uniform_biomes: vec![UNKNOWN_BIOME; 9 * (height / 16).max(0) as usize],
|
||||
biome_quarts: region[4].biomes().quart_range(),
|
||||
decoration: self,
|
||||
temperatures: &mut worker.temperatures,
|
||||
behaviours: data.behaviours.as_deref(),
|
||||
// WorldGenRegion.isOutsideBuildHeight: the level's range, not the noise's.
|
||||
build: (min_y, min_y + height),
|
||||
shape_ticks: std::cell::RefCell::new(crate::level::Ticks::new(0)),
|
||||
};
|
||||
let mut stats = DecorateStats::default();
|
||||
let blocks = self.blocks;
|
||||
@@ -504,6 +511,72 @@ impl Decoration {
|
||||
}
|
||||
}
|
||||
|
||||
/// `a`, and `b` when it differs.
|
||||
fn span<T: PartialEq>(a: T, b: T) -> (T, Option<T>) {
|
||||
let b = (a != b).then_some(b);
|
||||
(a, b)
|
||||
}
|
||||
const UNKNOWN_BIOME: u32 = u32::MAX;
|
||||
const MIXED_BIOMES: u32 = u32::MAX - 1;
|
||||
impl World<'_> {
|
||||
/// The biome of a biome section when all its quarts hold the same one
|
||||
/// (and its chunk has biomes); `None` when mixed or outside the region.
|
||||
fn uniform_section(&mut self, cx: i32, cz: i32, section: usize) -> Option<BiomeId> {
|
||||
let (dx, dz) = (
|
||||
i64::from(cx) - i64::from(self.center.x) + 1,
|
||||
i64::from(cz) - i64::from(self.center.z) + 1,
|
||||
);
|
||||
if !(0..3).contains(&dx) || !(0..3).contains(&dz) {
|
||||
return None;
|
||||
}
|
||||
let sections = self.uniform_biomes.len() / 9;
|
||||
if section >= sections {
|
||||
return None;
|
||||
}
|
||||
let slot = (dz * 3 + dx) as usize * sections + section;
|
||||
if self.uniform_biomes[slot] == UNKNOWN_BIOME {
|
||||
let chunk = ChunkPos::new(cx, cz);
|
||||
let ready = self.region.chunk(cx, cz).is_ok_and(|c| {
|
||||
c.highest_generated_status() >= crate::chunk_status::ChunkStatus::Biomes
|
||||
});
|
||||
let values = self
|
||||
.biomes
|
||||
.chunk(chunk)
|
||||
.ok()
|
||||
.and_then(|c| c.section(section));
|
||||
self.uniform_biomes[slot] = match values {
|
||||
Some(values) if ready && values.iter().all(|b| *b == values[0]) => values[0].0,
|
||||
_ => MIXED_BIOMES,
|
||||
};
|
||||
}
|
||||
match self.uniform_biomes[slot] {
|
||||
MIXED_BIOMES => None,
|
||||
id => Some(BiomeId(id)),
|
||||
}
|
||||
}
|
||||
/// BiomeManager.getBiome without the zoom when every quart it can pick
|
||||
/// lies in biome sections of one and the same biome.
|
||||
fn uniform_biome(&mut self, [x, y, z]: [i32; 3]) -> Option<BiomeId> {
|
||||
let base = [x, y, z].map(|v| v.wrapping_sub(2) >> 2);
|
||||
let (bottom, top) = self.biome_quarts;
|
||||
let section = |q: i32| ((q.clamp(bottom, top) - bottom) >> 2) as usize;
|
||||
// The chunks and sections holding the quarts base..=base + 1.
|
||||
let (x0, x1) = span(base[0] >> 2, base[0].wrapping_add(1) >> 2);
|
||||
let (z0, z1) = span(base[2] >> 2, base[2].wrapping_add(1) >> 2);
|
||||
let (s0, s1) = span(section(base[1]), section(base[1].wrapping_add(1)));
|
||||
let found = self.uniform_section(x0, z0, s0)?;
|
||||
for cx in std::iter::once(x0).chain(x1) {
|
||||
for cz in std::iter::once(z0).chain(z1) {
|
||||
for s in std::iter::once(s0).chain(s1) {
|
||||
if self.uniform_section(cx, cz, s)? != found {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(found)
|
||||
}
|
||||
}
|
||||
/// Advances a feature's random stream with no block storage; every write is
|
||||
/// discarded. Placement modifiers still see the real biome and build bounds.
|
||||
struct OriginWorld<'a> {
|
||||
@@ -555,11 +628,19 @@ struct World<'a> {
|
||||
region: TerrainRegion<'static, GenBlockStates>,
|
||||
biomes: BiomeRegion<BiomeId>,
|
||||
zone: GenerationWriteZone,
|
||||
center: ChunkPos,
|
||||
/// Per biome section of the nine chunks (chunk-major): its one biome id,
|
||||
/// MIXED_BIOMES, or UNKNOWN_BIOME until first asked.
|
||||
uniform_biomes: Vec<u32>,
|
||||
/// The quart Ys biome lookups clamp to (`BiomeChunk::quart_range`).
|
||||
biome_quarts: (i32, i32),
|
||||
decoration: &'a Decoration,
|
||||
temperatures: &'a mut [BiomeTemperature],
|
||||
behaviours: Option<&'a crate::level::Behaviours>,
|
||||
/// Inclusive minimum and exclusive maximum build height.
|
||||
build: (i32, i32),
|
||||
/// The tick capture `shape_update` passes to `updateShape`.
|
||||
shape_ticks: std::cell::RefCell<crate::level::Ticks>,
|
||||
}
|
||||
/// Read-only `LevelAccess` view of the decoration region for
|
||||
/// `BlockState.updateShape` (writes go through `World::set_block`).
|
||||
@@ -614,8 +695,10 @@ impl World<'_> {
|
||||
random: horizon_core::random::LegacyRandom::new(0),
|
||||
};
|
||||
// The ticks updateShape schedules (LeavesBlock re-measuring its
|
||||
// distance, waterlogged blocks' fluid) go to the proto chunks.
|
||||
let mut ticks = crate::level::Ticks::new(0);
|
||||
// distance, waterlogged blocks' fluid) go to the proto chunks. While
|
||||
// capturing, scheduling leaves the rest of `Ticks` untouched, so the
|
||||
// region keeps one instead of building one per update.
|
||||
let mut ticks = self.shape_ticks.borrow_mut();
|
||||
ticks.capture = Some(Vec::new());
|
||||
let updated = behaviours.update_shape(
|
||||
&mut level,
|
||||
@@ -658,10 +741,36 @@ impl DecorationWorld for World<'_> {
|
||||
.map_err(|_| RegionError::InvalidChunks)?
|
||||
>> 4)
|
||||
}
|
||||
/// `BiomeRegion::present_in_chunk`: the distinct ids, ascending, from a
|
||||
/// bitset instead of sorting every quart of the column.
|
||||
fn chunk_biomes(&mut self, chunk: ChunkPos) -> Result<Vec<BiomeId>, RegionError> {
|
||||
self.biomes
|
||||
.present_in_chunk(chunk)
|
||||
.map_err(RegionError::Biome)
|
||||
let biomes = self.biomes.chunk(chunk).map_err(RegionError::Biome)?;
|
||||
let mut seen: Vec<u64> = Vec::new();
|
||||
let mut last = None;
|
||||
let mut section = 0;
|
||||
while let Some(values) = biomes.section(section) {
|
||||
for &id in values {
|
||||
if last == Some(id) {
|
||||
continue;
|
||||
}
|
||||
last = Some(id);
|
||||
let word = id.0 as usize / 64;
|
||||
if seen.len() <= word {
|
||||
seen.resize(word + 1, 0);
|
||||
}
|
||||
seen[word] |= 1 << (id.0 % 64);
|
||||
}
|
||||
section += 1;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for (word, &bits) in seen.iter().enumerate() {
|
||||
let mut bits = bits;
|
||||
while bits != 0 {
|
||||
out.push(BiomeId(word as u32 * 64 + bits.trailing_zeros()));
|
||||
bits &= bits - 1;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
fn set_currently_generating(&mut self, _: Option<FeatureIdentity>) -> Result<(), RegionError> {
|
||||
Ok(())
|
||||
@@ -669,9 +778,19 @@ impl DecorationWorld for World<'_> {
|
||||
}
|
||||
impl FeatureWorld for World<'_> {
|
||||
type Error = RegionError;
|
||||
#[inline]
|
||||
fn block(&mut self, position: [i32; 3]) -> Result<BlockStateId, RegionError> {
|
||||
self.region.block(position)
|
||||
}
|
||||
fn scan_column(
|
||||
&mut self,
|
||||
position: [i32; 3],
|
||||
step: i32,
|
||||
limit: i32,
|
||||
keep: impl FnMut(BlockStateId) -> bool,
|
||||
) -> Result<i32, RegionError> {
|
||||
self.region.scan_column(position, step, limit, keep)
|
||||
}
|
||||
fn set_block(
|
||||
&mut self,
|
||||
position: [i32; 3],
|
||||
@@ -1232,6 +1351,9 @@ impl PlacementContext for World<'_> {
|
||||
self.region.schedule_block_tick(position, block, delay)
|
||||
}
|
||||
fn biome_identity(&mut self, position: [i32; 3]) -> Result<BiomeId, PlacementQueryError> {
|
||||
if let Some(biome) = self.uniform_biome(position) {
|
||||
return Ok(biome);
|
||||
}
|
||||
self.region
|
||||
.biome_at(
|
||||
&self.biomes,
|
||||
@@ -1297,13 +1419,14 @@ impl PlacementContext for World<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
for x in x0..=x1 {
|
||||
for z in z0..=z1 {
|
||||
let map = self.region.chunk(x >> 4, z >> 4)?.heightmap(kind).unwrap();
|
||||
let floor = map
|
||||
.first_available(x & 15, z & 15)
|
||||
.map_err(|e| RegionError::from(super::terrain::TerrainError::from(e)))?;
|
||||
if y <= floor {
|
||||
// Any column will do, so each chunk's part of the square is read
|
||||
// from its heightmap in one go.
|
||||
for cx in x0 >> 4..=x1 >> 4 {
|
||||
let xs = [x0.max(cx << 4) & 15, x1.min((cx << 4) + 15) & 15];
|
||||
for cz in z0 >> 4..=z1 >> 4 {
|
||||
let zs = [z0.max(cz << 4) & 15, z1.min((cz << 4) + 15) & 15];
|
||||
let map = self.region.chunk(cx, cz)?.heightmap(kind).unwrap();
|
||||
if map.any_first_available_at_least(xs, zs, y) {
|
||||
return Ok(Some(true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,16 @@ pub trait MaterialSurfaceQueries {
|
||||
fn biome(&mut self, _position: BlockPos) -> Result<Option<Arc<str>>, MaterialRuleError> {
|
||||
Err(MaterialRuleError::MissingBiomeProvider)
|
||||
}
|
||||
/// `biome` as the index `biome_index` gives its name, for queries that
|
||||
/// number their biomes: biome conditions then compare numbers.
|
||||
fn biome_id(&mut self, _position: BlockPos) -> Result<Option<u32>, MaterialRuleError> {
|
||||
Err(MaterialRuleError::MissingBiomeProvider)
|
||||
}
|
||||
/// `Some` when `biome_id` answers: the index of the biome `name` (`None`
|
||||
/// inside when no biome has that name).
|
||||
fn biome_index(&self, _name: &str) -> Option<Option<u32>> {
|
||||
None
|
||||
}
|
||||
fn melts_frozen_ocean(
|
||||
&mut self,
|
||||
_biome: &str,
|
||||
@@ -98,6 +108,12 @@ impl<T: MaterialSurfaceQueries + ?Sized> MaterialSurfaceQueries for &mut T {
|
||||
fn biome(&mut self, position: BlockPos) -> Result<Option<Arc<str>>, MaterialRuleError> {
|
||||
(**self).biome(position)
|
||||
}
|
||||
fn biome_id(&mut self, position: BlockPos) -> Result<Option<u32>, MaterialRuleError> {
|
||||
(**self).biome_id(position)
|
||||
}
|
||||
fn biome_index(&self, name: &str) -> Option<Option<u32>> {
|
||||
(**self).biome_index(name)
|
||||
}
|
||||
fn cold_enough_to_snow(
|
||||
&mut self,
|
||||
biome: &str,
|
||||
@@ -198,6 +214,7 @@ struct Context<S> {
|
||||
noise_indices: BTreeMap<(String, bool), usize>,
|
||||
possible_biomes: Option<BTreeSet<String>>,
|
||||
biome: Option<Arc<str>>,
|
||||
biome_id: Option<u32>,
|
||||
}
|
||||
struct CachedNoise {
|
||||
sampler: Arc<dyn Noise>,
|
||||
@@ -227,6 +244,12 @@ impl<S: MaterialSurfaceQueries> Context<S> {
|
||||
}
|
||||
Ok(self.biome.clone())
|
||||
}
|
||||
fn biome_id(&mut self) -> Result<Option<u32>, MaterialRuleError> {
|
||||
if self.biome_id.is_none() {
|
||||
self.biome_id = self.surface.biome_id(self.position)?;
|
||||
}
|
||||
Ok(self.biome_id)
|
||||
}
|
||||
fn bind_noise(&mut self, name: &str, is_3d: bool) -> Result<usize, DecodeError> {
|
||||
let key = (name.to_owned(), is_3d);
|
||||
if let Some(index) = self.noise_indices.get(&key) {
|
||||
@@ -287,6 +310,8 @@ struct Compiled<R: RandomSource> {
|
||||
enum Kind<R: RandomSource> {
|
||||
Simple(MaterialCondition),
|
||||
Biome(BTreeSet<String>),
|
||||
/// `Biome` for queries that number their biomes: a bit per index.
|
||||
BiomeIds(Vec<u64>),
|
||||
Constant(bool),
|
||||
Noise {
|
||||
sampler: usize,
|
||||
@@ -317,6 +342,7 @@ where
|
||||
match self {
|
||||
Self::Simple(condition) => Self::Simple(condition.clone()),
|
||||
Self::Biome(names) => Self::Biome(names.clone()),
|
||||
Self::BiomeIds(bits) => Self::BiomeIds(bits.clone()),
|
||||
Self::Constant(value) => Self::Constant(*value),
|
||||
Self::Noise { sampler, min, max } => Self::Noise {
|
||||
sampler: *sampler,
|
||||
@@ -416,7 +442,25 @@ impl<R: RandomSource> Compiled<R> {
|
||||
match &context.possible_biomes {
|
||||
Some(possible) if names.is_disjoint(possible) => Kind::Constant(false),
|
||||
Some(possible) if possible.is_subset(&names) => Kind::Constant(true),
|
||||
_ => Kind::Biome(names),
|
||||
_ => match names
|
||||
.iter()
|
||||
.map(|name| context.surface.biome_index(name))
|
||||
.collect::<Option<Vec<_>>>()
|
||||
{
|
||||
// Names no biome has never match the block's biome.
|
||||
Some(ids) => {
|
||||
let mut bits = Vec::new();
|
||||
for id in ids.into_iter().flatten() {
|
||||
let word = id as usize / 64;
|
||||
if bits.len() <= word {
|
||||
bits.resize(word + 1, 0);
|
||||
}
|
||||
bits[word] |= 1 << (id % 64);
|
||||
}
|
||||
Kind::BiomeIds(bits)
|
||||
}
|
||||
None => Kind::Biome(names),
|
||||
},
|
||||
}
|
||||
}
|
||||
MaterialCondition::NoiseThreshold {
|
||||
@@ -510,6 +554,11 @@ impl<R: RandomSource> Compiled<R> {
|
||||
.ok_or(MaterialRuleError::MissingBiome)?
|
||||
.as_ref(),
|
||||
),
|
||||
Kind::BiomeIds(bits) => {
|
||||
let id = context.biome_id()?.ok_or(MaterialRuleError::MissingBiome)?;
|
||||
bits.get(id as usize / 64)
|
||||
.is_some_and(|word| word >> (id % 64) & 1 == 1)
|
||||
}
|
||||
Kind::Simple(MaterialCondition::Hole) => context.depth <= 0,
|
||||
Kind::Simple(MaterialCondition::Steep) => {
|
||||
context.gradient[0] <= -4 || context.gradient[1] >= 4
|
||||
@@ -627,6 +676,7 @@ impl<R: RandomSource, S: MaterialSurfaceQueries> MaterialConditions<R, S> {
|
||||
noise_indices: BTreeMap::new(),
|
||||
possible_biomes,
|
||||
biome: None,
|
||||
biome_id: None,
|
||||
},
|
||||
conditions: Vec::new(),
|
||||
rules: Vec::new(),
|
||||
@@ -654,6 +704,7 @@ impl<R: RandomSource, S: MaterialSurfaceQueries> MaterialConditions<R, S> {
|
||||
pub fn update_y(&mut self, stone_above: i32, stone_below: i32, water_height: i32, y: i32) {
|
||||
self.context.block_revision = self.context.block_revision.wrapping_add(1);
|
||||
self.context.biome = None;
|
||||
self.context.biome_id = None;
|
||||
self.context.position.y = y;
|
||||
self.context.stone_above = stone_above;
|
||||
self.context.stone_below = stone_below;
|
||||
|
||||
@@ -129,6 +129,12 @@ impl<S: MaterialSurfaceQueries> MaterialSurfaceQueries for MaterialDensitySource
|
||||
fn biome(&mut self, position: BlockPos) -> Result<Option<Arc<str>>, MaterialRuleError> {
|
||||
self.surface.biome(position)
|
||||
}
|
||||
fn biome_id(&mut self, position: BlockPos) -> Result<Option<u32>, MaterialRuleError> {
|
||||
self.surface.biome_id(position)
|
||||
}
|
||||
fn biome_index(&self, name: &str) -> Option<Option<u32>> {
|
||||
self.surface.biome_index(name)
|
||||
}
|
||||
fn melts_frozen_ocean(
|
||||
&mut self,
|
||||
biome: &str,
|
||||
|
||||
@@ -70,6 +70,12 @@ impl<P: PositionalRandom, S: MaterialSurfaceQueries> MaterialSurfaceQueries
|
||||
fn biome(&mut self, position: BlockPos) -> Result<Option<Arc<str>>, MaterialRuleError> {
|
||||
self.surface.biome(position)
|
||||
}
|
||||
fn biome_id(&mut self, position: BlockPos) -> Result<Option<u32>, MaterialRuleError> {
|
||||
self.surface.biome_id(position)
|
||||
}
|
||||
fn biome_index(&self, name: &str) -> Option<Option<u32>> {
|
||||
self.surface.biome_index(name)
|
||||
}
|
||||
fn melts_frozen_ocean(
|
||||
&mut self,
|
||||
biome: &str,
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
//! A world's data packs over the prepared worldgen bundles.
|
||||
//!
|
||||
//! Vanilla builds its worldgen registries from the enabled packs in order:
|
||||
//! the built-in pack first, then each enabled pack, a later pack's file for
|
||||
//! an id replacing an earlier one's. Tags are the exception: every pack's
|
||||
//! file for a tag adds its entries, and `replace` drops what came before.
|
||||
//! The prepared bundles are the built-in pack, so the packs above it are
|
||||
//! entries that replace or join a bundle's (`GeneratorData::load_with`).
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// The bundle of `dimension/<id>.json` level stems. Nothing prepared has
|
||||
/// it: a pack's level stem stands in for the world preset's.
|
||||
pub const DIMENSIONS: &str = "dimensions";
|
||||
|
||||
/// Pack folders (`data/<ns>/<kind>/`) the chunk generator reads, and the
|
||||
/// bundle each joins.
|
||||
const KINDS: [(&str, &str); 14] = [
|
||||
("dimension", DIMENSIONS),
|
||||
("worldgen/biome", "biomes"),
|
||||
// 26.3's names: 26.2 called them configured_carver and configured_feature.
|
||||
("worldgen/carver", "carvers"),
|
||||
("worldgen/feature", "features"),
|
||||
("worldgen/density_function", "densities"),
|
||||
("worldgen/material_condition", "conditions"),
|
||||
("worldgen/material_rule", "rules"),
|
||||
("worldgen/noise", "noises"),
|
||||
("worldgen/noise_settings", "settings"),
|
||||
("worldgen/placed_feature", "placed_features"),
|
||||
("worldgen/processor_list", "processor_lists"),
|
||||
("worldgen/structure", "structures"),
|
||||
("worldgen/structure_set", "structure_sets"),
|
||||
("worldgen/template_pool", "template_pools"),
|
||||
];
|
||||
/// Tag folders the chunk generator reads, and the tag bundle each joins.
|
||||
const TAG_KINDS: [(&str, &str); 3] = [
|
||||
("tags/block", "block_tags"),
|
||||
("tags/fluid", "fluid_tags"),
|
||||
("tags/worldgen/biome", "biome_tags"),
|
||||
];
|
||||
/// The pack folders `add_file` looks at, in walk order.
|
||||
pub const PACK_KINDS: [&str; 4] = ["dimension", "dimension_type", "tags", "worldgen"];
|
||||
|
||||
/// `ns:path` for a pack file `data/<ns>/<kind>/<path>.json`.
|
||||
fn resource_id(file: &str, kind: &str) -> Option<String> {
|
||||
let (ns, rest) = file.strip_prefix("data/")?.split_once('/')?;
|
||||
let path = rest
|
||||
.strip_prefix(kind)?
|
||||
.strip_prefix('/')?
|
||||
.strip_suffix(".json")?;
|
||||
Some(format!("{ns}:{path}"))
|
||||
}
|
||||
|
||||
/// What `add_file` made of a pack file.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum PackFile {
|
||||
/// An entry or tag file the generator reads.
|
||||
Read,
|
||||
/// A dimension type: Horizon keeps each dimension's vanilla build
|
||||
/// height, so it is not read.
|
||||
DimensionType { id: String, min_y: i64, height: i64 },
|
||||
/// A worldgen file of a kind the generator does not read (its folder).
|
||||
NotRead(String),
|
||||
/// A generator file whose text could not be read.
|
||||
Unreadable,
|
||||
/// Not worldgen.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Replacement and added bundle entries, and tag files, from data packs.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub struct WorldgenOverrides {
|
||||
/// Bundle name → id → the highest pack's JSON text for it.
|
||||
entries: BTreeMap<String, BTreeMap<String, String>>,
|
||||
/// Tag bundle name → (tag id, pack id, tag file text), lowest pack first.
|
||||
tags: BTreeMap<String, Vec<(String, String, String)>>,
|
||||
}
|
||||
impl WorldgenOverrides {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty() && self.tags.is_empty()
|
||||
}
|
||||
/// A pack's file for `id` in `bundle`: called for packs from the lowest
|
||||
/// to the highest, so the last call for an id wins.
|
||||
pub fn set(&mut self, bundle: &str, id: &str, json: String) {
|
||||
self.entries
|
||||
.entry(bundle.to_owned())
|
||||
.or_default()
|
||||
.insert(id.to_owned(), json);
|
||||
}
|
||||
/// A pack's tag file for the tag `id` in the tag bundle `bundle`, in
|
||||
/// pack order.
|
||||
pub fn add_tag(&mut self, bundle: &str, id: &str, pack: &str, json: String) {
|
||||
self.tags.entry(bundle.to_owned()).or_default().push((
|
||||
id.to_owned(),
|
||||
pack.to_owned(),
|
||||
json,
|
||||
));
|
||||
}
|
||||
/// The file `file` (`data/<ns>/...`, relative to the pack root) of the
|
||||
/// pack `pack`, whose text `read` gives. Packs go from the lowest to the
|
||||
/// highest, each in its own file order.
|
||||
pub fn add_file(
|
||||
&mut self,
|
||||
pack: &str,
|
||||
file: &str,
|
||||
read: impl FnOnce() -> Option<String>,
|
||||
) -> PackFile {
|
||||
let Some((_, rest)) = file.strip_prefix("data/").and_then(|r| r.split_once('/')) else {
|
||||
return PackFile::Other;
|
||||
};
|
||||
if let Some((bundle, id)) = KINDS
|
||||
.iter()
|
||||
.find_map(|(kind, bundle)| Some((bundle, resource_id(file, kind)?)))
|
||||
{
|
||||
let Some(json) = read() else {
|
||||
return PackFile::Unreadable;
|
||||
};
|
||||
self.set(bundle, &id, json);
|
||||
PackFile::Read
|
||||
} else if let Some((bundle, id)) = TAG_KINDS
|
||||
.iter()
|
||||
.find_map(|(kind, bundle)| Some((bundle, resource_id(file, kind)?)))
|
||||
{
|
||||
let Some(json) = read() else {
|
||||
return PackFile::Unreadable;
|
||||
};
|
||||
self.add_tag(bundle, &id, pack, json);
|
||||
PackFile::Read
|
||||
} else if let Some(id) = resource_id(file, "dimension_type") {
|
||||
let v: Value = read()
|
||||
.and_then(|t| serde_json::from_str(&t).ok())
|
||||
.unwrap_or_default();
|
||||
PackFile::DimensionType {
|
||||
id,
|
||||
min_y: v["min_y"].as_i64().unwrap_or_default(),
|
||||
height: v["height"].as_i64().unwrap_or_default(),
|
||||
}
|
||||
} else if rest.starts_with("worldgen/") || rest.starts_with("tags/worldgen/") {
|
||||
PackFile::NotRead(rest.rsplit_once('/').map_or(rest, |(k, _)| k).to_owned())
|
||||
} else {
|
||||
PackFile::Other
|
||||
}
|
||||
}
|
||||
/// After the last pack: leaves out the packs' new biomes (`known` says
|
||||
/// which the game has), and what names them names the rest without
|
||||
/// them: their biome tags' other biomes, and the climate points of the
|
||||
/// other biomes in a level stem's multi-noise list (the left-out
|
||||
/// biome's climate goes to its nearest neighbours). Returns what it
|
||||
/// left out, for the log.
|
||||
pub fn finish(&mut self, known: impl Fn(&str) -> bool) -> Vec<String> {
|
||||
let left_out = self.retain("biomes", &known);
|
||||
let mut warnings: Vec<String> = left_out
|
||||
.iter()
|
||||
.map(|id| {
|
||||
format!("biome {id} is new: Horizon cannot add biomes yet, so it is left out")
|
||||
})
|
||||
.collect();
|
||||
if left_out.is_empty() {
|
||||
return warnings;
|
||||
}
|
||||
let gone = |v: &Value| {
|
||||
let id = v.as_str().or_else(|| v["id"].as_str()).unwrap_or_default();
|
||||
let id = if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
};
|
||||
left_out.contains(&id)
|
||||
};
|
||||
for (_, _, json) in self.tags.get_mut("biome_tags").into_iter().flatten() {
|
||||
if let Ok(mut v) = serde_json::from_str::<Value>(json)
|
||||
&& let Some(values) = v["values"].as_array_mut()
|
||||
{
|
||||
let before = values.len();
|
||||
values.retain(|e| !gone(e));
|
||||
if values.len() != before {
|
||||
*json = v.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, json) in self.entries.get_mut(DIMENSIONS).into_iter().flatten() {
|
||||
let Ok(mut v) = serde_json::from_str::<Value>(json) else {
|
||||
continue;
|
||||
};
|
||||
let Some(points) = v["generator"]["biome_source"]["biomes"].as_array_mut() else {
|
||||
continue;
|
||||
};
|
||||
let before = points.len();
|
||||
points.retain(|p| !gone(&p["biome"]));
|
||||
if points.len() != before {
|
||||
warnings.push(format!(
|
||||
"dimension {id}: {} of its {before} biome climate points name left-out \
|
||||
biomes and are removed",
|
||||
before - points.len()
|
||||
));
|
||||
*json = v.to_string();
|
||||
}
|
||||
}
|
||||
warnings
|
||||
}
|
||||
/// The entries packs give `bundle`.
|
||||
pub fn entries(&self, bundle: &str) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.entries
|
||||
.get(bundle)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(id, json)| (id.as_str(), json.as_str()))
|
||||
}
|
||||
/// The tag files packs give the tag bundle `bundle`, in pack order.
|
||||
pub fn tags(&self, bundle: &str) -> impl Iterator<Item = (&str, &str, &str)> {
|
||||
self.tags
|
||||
.get(bundle)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(id, pack, json)| (id.as_str(), pack.as_str(), json.as_str()))
|
||||
}
|
||||
/// Removes the entries of `bundle` that `keep` refuses, returning their ids.
|
||||
pub fn retain(&mut self, bundle: &str, mut keep: impl FnMut(&str) -> bool) -> Vec<String> {
|
||||
let Some(entries) = self.entries.get_mut(bundle) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let dropped: Vec<String> = entries.keys().filter(|id| !keep(id)).cloned().collect();
|
||||
for id in &dropped {
|
||||
entries.remove(id);
|
||||
}
|
||||
if entries.is_empty() {
|
||||
self.entries.remove(bundle);
|
||||
}
|
||||
dropped
|
||||
}
|
||||
/// `bundle` with the packs' entries over it.
|
||||
pub fn apply(
|
||||
&self,
|
||||
bundle: &str,
|
||||
mut base: BTreeMap<String, String>,
|
||||
) -> BTreeMap<String, String> {
|
||||
for (id, json) in self.entries(bundle) {
|
||||
base.insert(id.to_owned(), json.to_owned());
|
||||
}
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_highest_pack_wins_and_others_join() {
|
||||
let mut o = WorldgenOverrides::default();
|
||||
assert!(o.is_empty());
|
||||
o.set("noises", "minecraft:cave", "low".into());
|
||||
o.set("noises", "minecraft:cave", "high".into());
|
||||
o.set("noises", "pack:new", "new".into());
|
||||
let base = BTreeMap::from([
|
||||
("minecraft:cave".to_owned(), "vanilla".to_owned()),
|
||||
("minecraft:other".to_owned(), "kept".to_owned()),
|
||||
]);
|
||||
let merged = o.apply("noises", base);
|
||||
assert_eq!(merged["minecraft:cave"], "high");
|
||||
assert_eq!(merged["minecraft:other"], "kept");
|
||||
assert_eq!(merged["pack:new"], "new");
|
||||
assert_eq!(o.apply("densities", BTreeMap::new()).len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_files_go_to_their_bundles() {
|
||||
let mut o = WorldgenOverrides::default();
|
||||
let text = |t: &str| {
|
||||
let t = t.to_owned();
|
||||
move || Some(t)
|
||||
};
|
||||
let mut add = |file: &str, json: &str| o.add_file("file/p", file, text(json));
|
||||
assert_eq!(
|
||||
add("data/minecraft/worldgen/noise/cave.json", "1"),
|
||||
PackFile::Read
|
||||
);
|
||||
assert_eq!(
|
||||
add("data/p/worldgen/density_function/a/b.json", "2"),
|
||||
PackFile::Read
|
||||
);
|
||||
assert_eq!(
|
||||
add("data/minecraft/worldgen/noise_settings/overworld.json", "3"),
|
||||
PackFile::Read
|
||||
);
|
||||
assert_eq!(
|
||||
add("data/minecraft/worldgen/structure_set/mineshafts.json", "4"),
|
||||
PackFile::Read
|
||||
);
|
||||
assert_eq!(
|
||||
add("data/minecraft/dimension/overworld.json", "5"),
|
||||
PackFile::Read
|
||||
);
|
||||
assert_eq!(
|
||||
add("data/minecraft/tags/worldgen/biome/is_hill.json", "{}"),
|
||||
PackFile::Read
|
||||
);
|
||||
assert_eq!(
|
||||
add(
|
||||
"data/minecraft/dimension_type/overworld.json",
|
||||
r#"{"min_y":-64,"height":2096}"#
|
||||
),
|
||||
PackFile::DimensionType {
|
||||
id: "minecraft:overworld".into(),
|
||||
min_y: -64,
|
||||
height: 2096
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
add("data/minecraft/worldgen/carver/cave.json", "6"),
|
||||
PackFile::Read
|
||||
);
|
||||
assert_eq!(
|
||||
add("data/minecraft/worldgen/feature/water_delta.json", "7"),
|
||||
PackFile::Read
|
||||
);
|
||||
assert_eq!(
|
||||
add("data/minecraft/worldgen/configured_carver/cave.json", "{}"),
|
||||
PackFile::NotRead("worldgen/configured_carver".into())
|
||||
);
|
||||
assert_eq!(
|
||||
add("data/minecraft/worldgen/world_preset/normal.json", "{}"),
|
||||
PackFile::NotRead("worldgen/world_preset".into())
|
||||
);
|
||||
assert_eq!(add("data/p/function/f.mcfunction", ""), PackFile::Other);
|
||||
assert_eq!(
|
||||
o.add_file("file/p", "data/p/worldgen/noise/x.json", || None),
|
||||
PackFile::Unreadable
|
||||
);
|
||||
let get = |bundle: &str| o.entries(bundle).collect::<Vec<_>>();
|
||||
assert_eq!(get("noises"), [("minecraft:cave", "1")]);
|
||||
assert_eq!(get("densities"), [("p:a/b", "2")]);
|
||||
assert_eq!(get("settings"), [("minecraft:overworld", "3")]);
|
||||
assert_eq!(get("structure_sets"), [("minecraft:mineshafts", "4")]);
|
||||
assert_eq!(get(DIMENSIONS), [("minecraft:overworld", "5")]);
|
||||
assert_eq!(get("carvers"), [("minecraft:cave", "6")]);
|
||||
assert_eq!(get("features"), [("minecraft:water_delta", "7")]);
|
||||
assert_eq!(get("structures"), []);
|
||||
assert_eq!(o.tags("biome_tags").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_biomes_are_left_out_of_tags_and_climate_points() {
|
||||
let mut o = WorldgenOverrides::default();
|
||||
o.set("biomes", "minecraft:plains", "{}".into());
|
||||
o.set("biomes", "minecraft:molten_peaks", "{}".into());
|
||||
o.add_tag(
|
||||
"biome_tags",
|
||||
"minecraft:is_hill",
|
||||
"file/p",
|
||||
r#"{"values":["minecraft:plains","molten_peaks",{"id":"minecraft:molten_peaks","required":false}]}"#.into(),
|
||||
);
|
||||
o.set(
|
||||
DIMENSIONS,
|
||||
"minecraft:overworld",
|
||||
r#"{"generator":{"biome_source":{"biomes":[{"biome":"minecraft:plains"},{"biome":"minecraft:molten_peaks"}]}}}"#.into(),
|
||||
);
|
||||
let warnings = o.finish(|id| id == "minecraft:plains");
|
||||
assert_eq!(warnings.len(), 2, "{warnings:?}");
|
||||
assert_eq!(o.entries("biomes").count(), 1);
|
||||
let (_, _, tag) = o.tags("biome_tags").next().unwrap();
|
||||
assert_eq!(tag, r#"{"values":["minecraft:plains"]}"#);
|
||||
let (_, stem) = o.entries(DIMENSIONS).next().unwrap();
|
||||
assert!(!stem.contains("molten_peaks") && stem.contains("minecraft:plains"));
|
||||
}
|
||||
}
|
||||
@@ -172,10 +172,12 @@ impl<B: TerrainBlockStates> HeightmapChunk for TerrainBlocks<'_, B> {
|
||||
}
|
||||
}
|
||||
impl<B: TerrainBlockStates> TerrainBlocks<'_, B> {
|
||||
#[inline]
|
||||
fn section_index(&self, y: i32) -> Option<usize> {
|
||||
let dy = i64::from(y) - i64::from(self.min_y);
|
||||
(dy >= 0 && dy < i64::from(self.height)).then_some((dy >> 4) as usize)
|
||||
}
|
||||
#[inline]
|
||||
fn block(&self, x: i32, y: i32, z: i32) -> BlockStateId {
|
||||
let Some(section) = self.section_index(y) else {
|
||||
return self.void_air;
|
||||
@@ -183,11 +185,10 @@ impl<B: TerrainBlockStates> TerrainBlocks<'_, B> {
|
||||
if self.non_air[section] == 0 {
|
||||
return self.air;
|
||||
}
|
||||
self.sections[section]
|
||||
.get(local_index(x, y, z))
|
||||
.expect("valid section coordinates")
|
||||
self.sections[section].state(local_index(x, y, z))
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn local_index(x: i32, y: i32, z: i32) -> usize {
|
||||
(((y & 15) << 8) | ((z & 15) << 4) | (x & 15)) as usize
|
||||
}
|
||||
@@ -334,9 +335,51 @@ impl<'r, B: TerrainBlockStates> TerrainChunk<'r, B> {
|
||||
self.blocks.highest_section_y()
|
||||
}
|
||||
/// ProtoChunk reads collapse an all-air section to its default AIR state.
|
||||
#[inline]
|
||||
pub fn block(&self, x: i32, y: i32, z: i32) -> BlockStateId {
|
||||
self.blocks.block(x, y, z)
|
||||
}
|
||||
/// `FeatureWorld::scan_column` in this chunk: an all-air section is
|
||||
/// tested once, a mixed one read without looking the section up per block.
|
||||
pub fn scan_column(
|
||||
&self,
|
||||
[x, mut y, z]: [i32; 3],
|
||||
step: i32,
|
||||
mut limit: i32,
|
||||
mut keep: impl FnMut(BlockStateId) -> bool,
|
||||
) -> i32 {
|
||||
debug_assert!(step == 1 || step == -1);
|
||||
let blocks = &self.blocks;
|
||||
while limit > 0 {
|
||||
let Some(section) = blocks.section_index(y) else {
|
||||
if !keep(blocks.void_air) {
|
||||
return y;
|
||||
}
|
||||
y = y.wrapping_add(step);
|
||||
limit -= 1;
|
||||
continue;
|
||||
};
|
||||
// Blocks from y to this section's end in the walk's direction.
|
||||
let within = (y - blocks.min_y) & 15;
|
||||
let run = if step > 0 { 16 - within } else { within + 1 }.min(limit);
|
||||
limit -= run;
|
||||
if blocks.non_air[section] == 0 {
|
||||
if !keep(blocks.air) {
|
||||
return y;
|
||||
}
|
||||
y = y.wrapping_add(step * run);
|
||||
continue;
|
||||
}
|
||||
let storage = &blocks.sections[section];
|
||||
for _ in 0..run {
|
||||
if !keep(storage.state(local_index(x, y, z))) {
|
||||
return y;
|
||||
}
|
||||
y = y.wrapping_add(step);
|
||||
}
|
||||
}
|
||||
y
|
||||
}
|
||||
pub fn section(&self, index: usize) -> Option<&BlockSection<'r>> {
|
||||
self.blocks.sections.get(index)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,18 @@ pub(crate) fn read<W: FeatureWorld>(
|
||||
) -> TreeResult<BlockStateId, W::Error> {
|
||||
world.block(position).map_err(world_error)
|
||||
}
|
||||
/// `FeatureWorld::scan_column`.
|
||||
pub(crate) fn scan_column<W: FeatureWorld>(
|
||||
world: &mut W,
|
||||
position: [i32; 3],
|
||||
step: i32,
|
||||
limit: i32,
|
||||
keep: impl FnMut(BlockStateId) -> bool,
|
||||
) -> TreeResult<i32, W::Error> {
|
||||
world
|
||||
.scan_column(position, step, limit, keep)
|
||||
.map_err(world_error)
|
||||
}
|
||||
pub(crate) fn write<W: FeatureWorld>(
|
||||
world: &mut W,
|
||||
position: [i32; 3],
|
||||
|
||||
@@ -129,6 +129,51 @@ fn rejects_incompatible_regions_and_routes_negative_chunk_coordinates() {
|
||||
));
|
||||
assert_eq!(region.block([0, 7, 0]).unwrap(), BlockStateId(0));
|
||||
}
|
||||
/// The column scan matches reading block by block: from inside and outside
|
||||
/// the build range, both ways, across all-air and mixed sections.
|
||||
#[test]
|
||||
fn column_scan_matches_block_by_block_reads() {
|
||||
let registry = registry();
|
||||
let chunk = TerrainChunk::new(ChunkPos::new(-1, 0), -32, 64, ®istry, Properties).unwrap();
|
||||
let mut region =
|
||||
TerrainRegion::new(vec![chunk], WorldGenerationContext::new(-32, 64, 8)).unwrap();
|
||||
for (y, state) in [
|
||||
(-30, 2),
|
||||
(-17, 3),
|
||||
(-16, 2),
|
||||
(5, 4),
|
||||
(6, 0),
|
||||
(20, 2),
|
||||
(31, 3),
|
||||
] {
|
||||
region
|
||||
.set_block_worldgen([-13, y, 5], BlockStateId(state))
|
||||
.unwrap();
|
||||
}
|
||||
let keeps: [fn(BlockStateId) -> bool; 3] = [|s| s.0 < 2, |s| s.0 == 0, |s| s.0 != 2];
|
||||
for keep in keeps {
|
||||
for start in -40..40 {
|
||||
for step in [1, -1] {
|
||||
for limit in [0, 1, 3, 15, 16, 17, 40, 90] {
|
||||
let mut y = start;
|
||||
for _ in 0..limit {
|
||||
if !keep(region.block([-13, y, 5]).unwrap()) {
|
||||
break;
|
||||
}
|
||||
y += step;
|
||||
}
|
||||
assert_eq!(
|
||||
region
|
||||
.scan_column([-13, start, 5], step, limit, keep)
|
||||
.unwrap(),
|
||||
y,
|
||||
"start {start} step {step} limit {limit}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn write_callback_controls_mutation_and_receives_all_flags() {
|
||||
let registry = registry();
|
||||
|
||||
@@ -201,6 +201,27 @@ across the overworld, Nether and End, plus rectangles around every structure kin
|
||||
isolates terrain plus structure pieces, `--phase terrain` stops before features. `HORIZON_ORDER` (a file of `x z` lines) makes both sides
|
||||
decorate in that order instead of row-major.
|
||||
|
||||
### Data packs
|
||||
|
||||
A world's enabled data packs (`<world>/datapacks`, in `datapacks.json`'s order) change its generation as vanilla's registries do:
|
||||
`worldgen::overrides` maps each pack file to the bundle it joins (noises, density functions, noise settings, material rules and
|
||||
conditions, carvers, configured and placed features, structures, structure sets, template pools, processor lists, biomes, level stems), a
|
||||
higher pack's file for an id replacing a lower one's, and its block, fluid and biome tag files to the tag bundles in pack order.
|
||||
`GeneratorData::load_with` reads the bundles with them; the client caches the result per set of pack entries
|
||||
(`vanillagen::shared_data_with`). What Horizon leaves out is logged: a pack's new biomes (the game has no id, colours or sounds for them;
|
||||
their tag entries and climate points go with them), dimension types (every dimension keeps its vanilla build height, and noise settings
|
||||
taller than it generate only inside it, as `clampToHeightAccessor`), and folders the generator does not read. Sapling growth and bone
|
||||
meal still place vanilla's features.
|
||||
|
||||
`tools/test_datapack_worldgen_parity.py` runs the whole-chunk harness with `HORIZON_DATAPACKS=tools/fixtures/datapacks` on both sides
|
||||
(the oracle selects every pack of the folder after vanilla through vanilla's own pack repository): its pack overrides a noise, a density
|
||||
function, the overworld noise settings (sea level 70, no aquifers, a new final density with a new noise added), a placed feature and a
|
||||
biome tag. Every site must match vanilla with the pack and differ from Horizon without it.
|
||||
|
||||
Packs must be written for 26.3: its worldgen formats differ from 26.2's (density functions take `left`/`right`, `gradient` replaces
|
||||
`y_clamped_gradient`, noises take `base_octave`/`octave_count`, block states are strings, surface rules became the `material_rule` and
|
||||
`material_condition` registries, configured features lost `config`). Vanilla 26.3 refuses a 26.2 worldgen pack as Horizon does.
|
||||
|
||||
### Decoration order
|
||||
|
||||
Biome decoration is order dependent: a chunk's features write into its eight neighbours, so a tree, ore blob, sculk patch or geode placed
|
||||
|
||||
@@ -204,9 +204,15 @@ public final class VanillaChunkGenerationOracle {
|
||||
Path out = Path.of(args[1]);
|
||||
long seed = Long.parseLong(args[2]);
|
||||
String dimension = args[3];
|
||||
var repo = ServerPacksSource.createVanillaTrustedRepository();
|
||||
// HORIZON_DATAPACKS: a world's datapacks folder, every pack in it enabled after vanilla in name order.
|
||||
String datapacks = System.getenv("HORIZON_DATAPACKS");
|
||||
var repo = datapacks == null ? ServerPacksSource.createVanillaTrustedRepository()
|
||||
: ServerPacksSource.createPackRepository(Path.of(datapacks), new net.minecraft.world.level.validation.DirectoryValidator(path -> true));
|
||||
repo.reload();
|
||||
repo.setSelected(List.of("vanilla"));
|
||||
var selected = new ArrayList<String>(List.of("vanilla"));
|
||||
repo.getAvailableIds().stream().filter(id -> id.startsWith("file/")).sorted().forEach(selected::add);
|
||||
repo.setSelected(selected);
|
||||
if (!repo.getSelectedIds().containsAll(selected)) throw new IllegalStateException("packs not selected: " + selected + " vs " + repo.getSelectedIds());
|
||||
var resources = new MultiPackResourceManager(PackType.SERVER_DATA, repo.openAllSelected());
|
||||
var layers = RegistryLayer.createRegistryAccess();
|
||||
var staticTags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
|
||||
|
||||
@@ -309,7 +309,7 @@ public final class VanillaPackScreenOracle {
|
||||
List<String> initial = new ArrayList<>();
|
||||
for (var s : c.getAsJsonArray("selected")) initial.add(s.getAsString());
|
||||
repository.setSelected(initial);
|
||||
var screen = new PackSelectionScreen(repository, r -> { }, Path.of(c.get("dir").getAsString()), Component.translatable("resourcePack.title"));
|
||||
var screen = new PackSelectionScreen(repository, r -> { }, Path.of(c.get("dir").getAsString()), Component.translatable(c.get("title").getAsString()));
|
||||
gui.setScreen(screen);
|
||||
// PackSelectionScreen.loadPackIcon: a pack with a pack.png gets its own texture.
|
||||
@SuppressWarnings("unchecked")
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"type": "minecraft:add",
|
||||
"left": "minecraft:overworld/final_density",
|
||||
"right": "horizon_test:hills"
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"type": "minecraft:mul",
|
||||
"left": 0.25,
|
||||
"right": {
|
||||
"type": "minecraft:noise",
|
||||
"noise": "horizon_test:hills",
|
||||
"xz_scale": 0.5,
|
||||
"y_scale": 0.0
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"base_amplitude": 1.0,
|
||||
"base_octave": -7,
|
||||
"octave_count": 2
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"values": ["minecraft:plains"]
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"type": "minecraft:old_blended_noise",
|
||||
"smear_scale_multiplier": 8.0,
|
||||
"xz_factor": 40.0,
|
||||
"xz_scale": 0.25,
|
||||
"y_factor": 120.0,
|
||||
"y_scale": 0.125
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"amplitude_modifiers": [1.0, 2.0, 1.0],
|
||||
"base_amplitude": 0.9,
|
||||
"base_octave": -8,
|
||||
"octave_count": 3
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"noise": {"min_y": -64, "height": 384},
|
||||
"default_block": "minecraft:stone",
|
||||
"default_fluid": "minecraft:water",
|
||||
"noise_router": {
|
||||
"chunk_surface_level": "minecraft:overworld/chunk_surface_level",
|
||||
"continents": "minecraft:overworld/continents",
|
||||
"depth": "minecraft:overworld/depth",
|
||||
"erosion": "minecraft:overworld/erosion",
|
||||
"final_density": "horizon_test:final_density",
|
||||
"ridges": "minecraft:overworld/ridges",
|
||||
"temperature": "minecraft:overworld/temperature",
|
||||
"vegetation": "minecraft:overworld/vegetation"
|
||||
},
|
||||
"material_rule": "minecraft:overworld",
|
||||
"spawn_target": [],
|
||||
"sea_level": 70,
|
||||
"disable_mob_generation": false,
|
||||
"legacy_random_source": false
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"feature": "minecraft:ore_diamond_small",
|
||||
"placement": [
|
||||
{"type": "minecraft:count", "count": 30},
|
||||
{"type": "minecraft:in_square"},
|
||||
{
|
||||
"type": "minecraft:height_range",
|
||||
"height": {
|
||||
"type": "minecraft:uniform",
|
||||
"min_inclusive": {"above_bottom": 0},
|
||||
"max_inclusive": {"absolute": 40}
|
||||
}
|
||||
},
|
||||
{"type": "minecraft:biome"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"pack": {
|
||||
"description": "Horizon's data pack worldgen harness: overrides and new entries",
|
||||
"min_format": 121,
|
||||
"max_format": 121
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -510,7 +510,8 @@ def build_bench_mod(jars):
|
||||
shutil.rmtree(classes, ignore_errors=True)
|
||||
classes.mkdir(parents=True)
|
||||
sources = sorted(map(str, (source / "java").rglob("*.java")))
|
||||
subprocess.run(["javac", "--release", "25", "-proc:none", "-encoding", "UTF-8", "-d", str(classes),
|
||||
javac = Path(os.environ["JAVA_HOME"]) / "bin/javac" if os.environ.get("JAVA_HOME") else "javac"
|
||||
subprocess.run([str(javac), "--release", "25", "-proc:none", "-encoding", "UTF-8", "-d", str(classes),
|
||||
"-classpath", os.pathsep.join(map(str, jars)), *sources], check=True)
|
||||
output = build / "horizon-bench.jar"
|
||||
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare chunks generated with a world's data packs block-for-block with vanilla.
|
||||
|
||||
The packs of tools/fixtures/datapacks (Horizon's own, MIT) override and add
|
||||
noises, density functions, the overworld noise settings, a placed feature and
|
||||
a biome tag. Vanilla side: VanillaChunkGenerationOracle with HORIZON_DATAPACKS,
|
||||
which selects every pack of the folder after vanilla, as a world does, and
|
||||
loads the registries through vanilla's own pack repository. Horizon side:
|
||||
chunk_dump with the same variable, reading the packs through
|
||||
worldgen::overrides into GeneratorData::load_with. The same terrain sites are
|
||||
compared for every block state, quart biome and block entity, first after
|
||||
TERRAIN, then with decoration. Horizon without the packs must differ from
|
||||
vanilla with them, so the packs are known to take effect.
|
||||
|
||||
usage: test_datapack_worldgen_parity.py [--seed N] [--sites K] [--phase terrain|full]
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from . import lab, test_chunk_generation_parity as chunks
|
||||
except ImportError:
|
||||
import lab
|
||||
import test_chunk_generation_parity as chunks
|
||||
|
||||
# HORIZON_PARITY_DATAPACKS: another folder of packs to compare.
|
||||
DATAPACKS = Path(os.environ.get('HORIZON_PARITY_DATAPACKS', lab.ROOT / 'tools/fixtures/datapacks'))
|
||||
SEEDS = [12345, 4242]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--seed', type=int, action='append')
|
||||
parser.add_argument('--sites', type=int, default=3, help='terrain sites per seed besides 0,0')
|
||||
parser.add_argument('--phase', choices=['terrain', 'full'], action='append')
|
||||
args = parser.parse_args()
|
||||
oracle = chunks.Oracle()
|
||||
binary = chunks.rust_binary()
|
||||
names = chunks.state_names()
|
||||
build = chunks.build_dir() / 'datapacks'
|
||||
build.mkdir(exist_ok=True)
|
||||
differing = 0
|
||||
pack_effect = 0
|
||||
for phase in args.phase or ['terrain', 'full']:
|
||||
os.environ['HORIZON_PHASE'] = phase
|
||||
for seed in args.seed or SEEDS:
|
||||
rng = random.Random(seed)
|
||||
sites = [(0, 0)] + [(rng.randint(-300, 300), rng.randint(-300, 300)) for _ in range(args.sites)]
|
||||
rects = [f'{x},{z},{x},{z}' for x, z in sites]
|
||||
tag = f'{seed}-{phase}'
|
||||
vanilla_path, rust_path, plain_path = (build / f'{side}-{tag}.txt' for side in ('vanilla', 'rust', 'plain'))
|
||||
os.environ['HORIZON_DATAPACKS'] = str(DATAPACKS)
|
||||
oracle.run(vanilla_path, ['dump', seed, 'overworld', *rects])
|
||||
stderr = chunks.run_rust(binary, rust_path, seed, 'overworld', rects)
|
||||
del os.environ['HORIZON_DATAPACKS']
|
||||
for line in stderr.splitlines():
|
||||
if 'data pack' in line:
|
||||
print(' horizon:', line)
|
||||
vanilla, rust = chunks.parse(vanilla_path), chunks.parse(rust_path)
|
||||
counts = chunks.compare(vanilla, rust, names, f'{tag} packs')
|
||||
differing += counts[0]
|
||||
print(f'{tag}: {len(vanilla)} chunks with the packs, differing chunks {counts[0]} '
|
||||
f'(blocks {counts[1]}, biome cells {counts[2]}, block entities {counts[3]})', flush=True)
|
||||
chunks.run_rust(binary, plain_path, seed, 'overworld', rects)
|
||||
plain = chunks.compare(vanilla, chunks.parse(plain_path), names, f'{tag} without packs', quiet=True)
|
||||
pack_effect += plain[0]
|
||||
print(f'{tag}: without the packs Horizon differs from them in {plain[0]} of {len(vanilla)} chunks ({plain[1]} blocks)')
|
||||
if not pack_effect:
|
||||
sys.exit('the packs changed nothing: the harness would not see them')
|
||||
if differing:
|
||||
sys.exit(f'{differing} chunks differ from vanilla with the packs')
|
||||
print('Data pack worldgen parity passed')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare the resource pack screen's drawing with the vanilla client's.
|
||||
|
||||
Each case is a pack repository (titles and descriptions as text components,
|
||||
Each case is a title (the resource pack screen's, or the data pack screen's
|
||||
Create World opens), a pack repository (titles and descriptions as text components,
|
||||
with legacy formatting codes, colours and styles; pack sources; compatible,
|
||||
too old, too new or unknown packs; required, fixed and positioned packs;
|
||||
icons), an initial selection, a GUI size, in a world or not, and what the
|
||||
@@ -119,7 +120,9 @@ def cases(count):
|
||||
(rng.randint(cx - 154, cx + 154), height - 26 + rng.randint(0, 19)),
|
||||
(rng.randint(cx - 160, cx + 160), rng.randint(height // 2 - 30, height // 2 + 60))])
|
||||
out.append({'width': width, 'height': height, 'inWorld': rng.random() < 0.3, 'packs': packs, 'selected': selected,
|
||||
'actions': actions, 'mouse': list(mouse), 'blink': rng.choice([0, 100, 400, 650])})
|
||||
'actions': actions, 'mouse': list(mouse), 'blink': rng.choice([0, 100, 400, 650]),
|
||||
# Create World's Data Packs screen is the same screen under its own title.
|
||||
'title': 'dataPack.title' if n % 4 == 3 else 'resourcePack.title'})
|
||||
return out
|
||||
|
||||
|
||||
@@ -161,7 +164,7 @@ def main():
|
||||
for e in sorted(set(errors))[:5]:
|
||||
print('ORACLE ERROR', e[:1500])
|
||||
for c, v, r in bad[:args.detail]:
|
||||
brief = {k: c[k] for k in ('width', 'height', 'inWorld', 'selected', 'actions', 'mouse', 'blink')}
|
||||
brief = {k: c[k] for k in ('title', 'width', 'height', 'inWorld', 'selected', 'actions', 'mouse', 'blink')}
|
||||
print('MISMATCH', json.dumps(brief)[:1500])
|
||||
if v.get('screen') != r.get('screen'):
|
||||
print(f' screen: vanilla {v.get("screen")} | horizon {r.get("screen")}')
|
||||
|
||||
@@ -15,8 +15,8 @@ the worker's number is left out). In a world without structures (`generate-struc
|
||||
finds none) and how many of the saved chunks hold structure starts or references (none:
|
||||
ChunkStatusTasks.generateStructureStarts does nothing).
|
||||
|
||||
Horizon generates the normal and flat presets only: `large_biomes`, `amplified`, `single_biome_surface` and
|
||||
`debug_all_block_states` aren't tested.
|
||||
Horizon generates the normal, flat, amplified, large-biome and single-biome presets:
|
||||
`debug_all_block_states` isn't tested.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
@@ -61,6 +61,9 @@ CASES = {
|
||||
'generator-settings': '{"layers":[{"block":"minecraft:glass","height":5}],"biome":"nowhere"}',
|
||||
},
|
||||
'normal': {'level-type': 'minecraft:normal', 'level-seed': 'abc', 'generate-structures': 'false'},
|
||||
'amplified': {'level-type': 'minecraft:amplified', 'level-seed': '1'},
|
||||
'large-biomes': {'level-type': 'minecraft:large_biomes', 'level-seed': '2'},
|
||||
'single-biome': {'level-type': 'minecraft:single_biome_surface', 'level-seed': '3'},
|
||||
'unknown': {'level-type': 'Some Type', 'level-seed': '42'},
|
||||
}
|
||||
WORKER = re.compile(r'\[Worker-Main-\d+/(WARN|ERROR)\]: (.*)')
|
||||
@@ -155,9 +158,10 @@ def main():
|
||||
schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(),
|
||||
java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0],
|
||||
cases=list(CASES), lines=len(vanilla), output_sha256=lab.digest(build / 'horizon.txt', 'sha256'),
|
||||
scope='a new dedicated-server world: level-type (flat, normal, unknown), a flat world\'s '
|
||||
'generator-settings, generate-structures, as world_gen_settings.dat and the lines logged; without '
|
||||
'structures, locate structure\'s replies and the saved chunks\' structure starts and references.'))
|
||||
scope='a new dedicated-server world: level-type (flat, normal, amplified, large_biomes, '
|
||||
'single_biome_surface, unknown), a flat world\'s generator-settings, generate-structures, as '
|
||||
'world_gen_settings.dat and the lines logged; without structures, locate structure\'s replies and '
|
||||
'the saved chunks\' structure starts and references.'))
|
||||
print(f'server world gen: {len(CASES)} cases, {len(vanilla)} lines identical')
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user