Merge upstream main into build-time native API generation

This commit is contained in:
arismoko committed 2026-11-03 03:29:20 +00:00
commit 72396f7ecb
224 files changed
+24255 -1592

No files matched your search

Generated
+1
View File
@@ -1273,6 +1273,7 @@ name = "horizon-assets"
version = "0.1.0"
dependencies = [
"horizon-core",
"log",
"png",
"serde",
"serde_json",
+1
View File
@@ -8,6 +8,7 @@ description = "Horizon version-specific model and resource decoding"
[dependencies]
horizon-core = { path = "../horizon-core" }
log = "0.4"
serde = "1.0.229"
serde_json = { version = "1.0.151", features = ["raw_value"] }
+296 -2
View File
@@ -1,5 +1,10 @@
//! Cuboid geometry in block units, including blockstate rotation and UV locking.
use crate::{json::*, model::*, resolve::ResolvedCuboidModel};
use crate::{
json::*,
model::*,
obj::{MtlTexture, ObjFace, ObjModel},
resolve::{ResolvedCuboidModel, ResolvedLoader},
};
mod transform;
pub use transform::BlockModelTransform;
@@ -22,6 +27,8 @@ pub struct ModelQuad {
/// `neoforge_data` override of the model's ambient occlusion for this quad.
pub ambient_occlusion: Option<bool>,
pub render_type: Option<RenderType>,
/// False for a loader quad that takes no directional shading.
pub shade: bool,
}
/// NeoForge's `render_type` names.
@@ -217,6 +224,16 @@ fn bake_elements(
skip_flat: bool,
) -> Result<Vec<ModelQuad>> {
let mut output = Vec::new();
bake_into(model, transform, skip_flat, &mut output)?;
Ok(output)
}
/// The elements, then what the model's loader adds.
fn bake_into(
model: &ResolvedCuboidModel,
transform: BlockModelTransform,
skip_flat: bool,
output: &mut Vec<ModelQuad>,
) -> Result<()> {
let render_type = model.render_type.as_deref().and_then(RenderType::by_name);
for element in model.elements() {
let element = element
@@ -249,7 +266,98 @@ fn bake_elements(
)?);
}
}
Ok(output)
match model.loader() {
Some(ResolvedLoader::Composite(children)) => {
for child in children {
bake_into(child, transform, skip_flat, output)?;
}
}
Some(ResolvedLoader::Obj {
model: Some(obj),
flip_v,
shade,
hidden,
}) => bake_obj(model, obj, *flip_v, *shade, hidden, transform, output)?,
Some(ResolvedLoader::Obj { model: None, .. }) | None => {}
}
Ok(())
}
/// Obj faces are unculled quads in block units; the blockstate transform moves them whole.
fn bake_obj(
model: &ResolvedCuboidModel,
obj: &ObjModel,
flip_v: bool,
shade: bool,
hidden: &std::collections::BTreeSet<String>,
transform: BlockModelTransform,
output: &mut Vec<ModelQuad>,
) -> Result<()> {
let render_type = model.render_type.as_deref().and_then(RenderType::by_name);
for face in obj.faces.iter().filter(|f| !hidden.contains(&f.object)) {
let slot = match obj.materials.get(&face.material) {
Some(MtlTexture::Sprite(sprite)) => {
output.push(obj_quad(
face,
Material {
sprite: identifier(sprite)?,
force_translucent: false,
},
flip_v,
shade,
render_type,
transform,
));
continue;
}
Some(MtlTexture::Slot(slot)) => slot,
None => &face.material,
};
let material = if slot.is_empty() {
None
} else {
model.material(slot)?
};
let material = material.cloned().unwrap_or(Material {
sprite: "minecraft:missingno".to_owned(),
force_translucent: false,
});
output.push(obj_quad(
face,
material,
flip_v,
shade,
render_type,
transform,
));
}
Ok(())
}
fn obj_quad(
face: &ObjFace,
material: Material,
flip_v: bool,
shade: bool,
render_type: Option<RenderType>,
transform: BlockModelTransform,
) -> ModelQuad {
let positions = face.vertices.map(|v| transform.position(v.position));
ModelQuad {
positions,
uv: face
.vertices
.map(|v| [v.uv[0], if flip_v { 1.0 - v.uv[1] } else { v.uv[1] }]),
facing: facing(&positions).unwrap_or(Direction::Up),
cull: None,
material,
tint_index: -1,
shade_direction_override: None,
light_emission: 0,
light: [0; 2],
color: None,
ambient_occlusion: None,
render_type,
shade,
}
}
/// `FaceBakery.bakeQuad` for a quad with explicit corners and unit-atlas UVs
/// (`uv` in 0..16 texels), as `ItemModelGenerator` builds its layers.
@@ -364,5 +472,191 @@ fn bake_face(
color: data.color,
ambient_occlusion: data.ambient_occlusion,
render_type,
shade: true,
})
}
#[cfg(test)]
mod loader_tests {
use super::*;
use crate::resolve::{AssetReader, ModelLibrary};
use std::{collections::BTreeMap, sync::Arc};
struct Files(BTreeMap<&'static str, &'static str>);
impl AssetReader for Files {
fn read_text(&self, location: &str) -> Option<String> {
self.0.get(location).map(|text| (*text).to_owned())
}
}
fn library(models: &[(&str, &str)], files: &[(&'static str, &'static str)]) -> ModelLibrary {
let models = models
.iter()
.map(|(name, json)| ((*name).to_owned(), CuboidModel::from_json(json).unwrap()))
.collect();
ModelLibrary::new(models)
.unwrap()
.with_assets(Arc::new(Files(files.iter().copied().collect())))
}
fn bake(library: &ModelLibrary, name: &str) -> Vec<ModelQuad> {
bake_block_model(
&library.resolve(name).unwrap(),
BlockModelTransform::new([0; 3], false),
)
.unwrap()
}
fn sprites(quads: &[ModelQuad]) -> Vec<&str> {
quads.iter().map(|q| q.material.sprite.as_str()).collect()
}
const CUBE: &str =
r##"{"elements":[{"from":[0,0,0],"to":[16,16,16],"faces":{"up":{"texture":"#all"}}}]}"##;
const COMPOSITE: &str = r#"{"loader":"neoforge:composite","textures":{"all":"block/shared"},
"children":{"zeta":{"parent":"t:cube","textures":{"all":"block/own"}},"alpha":{"parent":"t:cube"}}}"#;
#[test]
fn composite_bakes_children_in_document_order_with_slot_fallback() {
let library = library(&[("t:cube", CUBE), ("t:comp", COMPOSITE)], &[]);
let quads = bake(&library, "t:comp");
assert_eq!(
sprites(&quads),
["minecraft:block/own", "minecraft:block/shared"]
);
}
#[test]
fn composite_visibility_hides_a_child() {
let hidden =
COMPOSITE.replace(r#""children""#, r#""visibility":{"zeta":false},"children""#);
let library = library(&[("t:cube", CUBE), ("t:comp", &hidden)], &[]);
assert_eq!(
sprites(&bake(&library, "t:comp")),
["minecraft:block/shared"]
);
}
#[test]
fn an_inheriting_model_overrides_which_children_are_visible() {
let hidden =
COMPOSITE.replace(r#""children""#, r#""visibility":{"zeta":false},"children""#);
let shown = r#"{"parent":"t:hidden","visibility":{"zeta":true,"alpha":false}}"#;
let library = library(
&[("t:cube", CUBE), ("t:hidden", &hidden), ("t:shown", shown)],
&[],
);
assert_eq!(sprites(&bake(&library, "t:shown")), ["minecraft:block/own"]);
}
#[test]
fn loader_is_inherited_by_parent_after_own_elements() {
let derived = r##"{"parent":"t:comp","textures":{"all":"block/derived"},
"elements":[{"from":[0,0,0],"to":[16,16,16],"faces":{"down":{"texture":"#all"}}}]}"##;
let library = library(
&[
("t:cube", CUBE),
("t:comp", COMPOSITE),
("t:derived", derived),
],
&[],
);
let quads = bake(&library, "t:derived");
assert_eq!(quads[0].facing, Direction::Down);
assert_eq!(
sprites(&quads),
[
"minecraft:block/derived",
"minecraft:block/own",
"minecraft:block/derived"
]
);
}
#[test]
fn own_loader_wins_and_unknown_loader_adds_nothing() {
let other = r#"{"parent":"t:comp","loader":"somemod:thing"}"#;
let library = library(
&[("t:cube", CUBE), ("t:comp", COMPOSITE), ("t:other", other)],
&[],
);
assert!(bake(&library, "t:other").is_empty());
}
#[test]
fn composites_that_nest_through_themselves_are_an_error() {
let looping = r#"{"loader":"neoforge:composite","children":{"a":{"parent":"t:loop"}}}"#;
let library = library(&[("t:loop", looping)], &[]);
assert!(library.resolve("t:loop").is_err());
}
const OBJ: &str = "mtllib m.mtl\nv 0 0 0\nv 1 0 0\nv 1 0 1\nv 0 0 1\nv 0 1 0\n\
vt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\n\
usemtl top\nf 1/1 4/4 3/3 2/2\nusemtl side\nf 1/1 2/2 5/4\nusemtl bare\nf 1/1 2/2 5/4\n";
const MTL: &str = "newmtl top\nmap_Kd #lid\nnewmtl side\nmap_Kd #wall\nnewmtl bare\nKd 1 1 1\n";
const OBJ_FILES: [(&str, &str); 2] = [("t:models/x.obj", OBJ), ("t:models/m.mtl", MTL)];
const OBJ_MODEL: &str = r#"{"loader":"neoforge:obj","model":"t:models/x.obj","flip_v":true,
"textures":{"lid":"block/lid","wall":"block/wall"}}"#;
#[test]
fn obj_faces_become_quads_with_materials_and_flipped_v() {
let flat = OBJ_MODEL.replace(r#""flip_v":true,"#, "");
let library = library(&[("t:o", OBJ_MODEL), ("t:flat", &flat)], &OBJ_FILES);
let quads = bake(&library, "t:o");
assert_eq!(
sprites(&quads),
[
"minecraft:block/lid",
"minecraft:block/wall",
"minecraft:missingno"
]
);
assert_eq!(quads[0].facing, Direction::Up);
assert_eq!(quads[1].facing, Direction::South);
assert_eq!(quads[0].positions[1], [0.0, 0.0, 1.0]);
assert_eq!(quads[0].uv[0], [0.0, 1.0]);
assert_eq!(quads[0].uv[2], [1.0, 0.0]);
// A triangle repeats its last vertex.
assert_eq!(quads[1].positions[3], quads[1].positions[2]);
assert_eq!(quads[1].uv[3], quads[1].uv[2]);
assert!(quads.iter().all(|q| q.cull.is_none() && q.shade));
assert_eq!(bake(&library, "t:flat")[0].uv[0], [0.0, 0.0]);
}
#[test]
fn obj_shade_quads_false_and_mtl_override() {
let unshaded = r#"{"loader":"neoforge:obj","model":"t:models/x.obj","shade_quads":false,
"mtl_override":"t:models/o.mtl","textures":{"s":"block/s"}}"#;
let files = [
("t:models/x.obj", OBJ),
("t:models/o.mtl", "newmtl top\nmap_Kd #s\n"),
];
let library = library(&[("t:u", unshaded)], &files);
let quads = bake(&library, "t:u");
assert!(quads.iter().all(|q| !q.shade));
assert_eq!(quads[0].material.sprite, "minecraft:block/s");
}
#[test]
fn missing_or_broken_obj_bakes_nothing() {
let missing = library(&[("t:o", OBJ_MODEL)], &[]);
assert!(bake(&missing, "t:o").is_empty());
let broken = library(
&[("t:o", OBJ_MODEL)],
&[("t:models/x.obj", "v 0 0 0\nf 1 2 3\n")],
);
assert!(bake(&broken, "t:o").is_empty());
}
#[test]
fn obj_visibility_skips_the_objects_a_model_hides() {
let two = "v 0 0 0\nv 1 0 0\nv 1 0 1\nv 0 0 1\no a\nusemtl m\nf 1 2 3 4\no b\nf 4 3 2 1\n";
let files = [("t:models/two.obj", two)];
let base =
r#"{"loader":"neoforge:obj","model":"t:models/two.obj","textures":{"m":"block/m"}}"#;
let hiding = r#"{"parent":"t:base","visibility":{"b":false}}"#;
let restoring = r#"{"parent":"t:hiding","visibility":{"b":true}}"#;
let library = library(
&[
("t:base", base),
("t:hiding", hiding),
("t:restoring", restoring),
],
&files,
);
assert_eq!(bake(&library, "t:base").len(), 2);
let quads = bake(&library, "t:hiding");
assert_eq!(quads.len(), 1);
assert_eq!(quads[0].facing, Direction::Down);
assert_eq!(bake(&library, "t:restoring").len(), 2);
}
}
+19
View File
@@ -432,6 +432,17 @@ impl ItemModelNode {
pub fn decode(v: &Value) -> Result<Self> {
let m = object(v)?;
let kind = ident(required(m, "type")?)?;
if kind == "minecraft:condition" {
let property = ident(required(m, "property")?)?;
if !property.starts_with("minecraft:") {
return Ok(Self::Condition {
transformation: optional_transformation(m)?,
property: ConditionProperty::Custom(property),
on_true: Box::new(Self::decode(required(m, "on_true")?)?),
on_false: Box::new(Self::decode(required(m, "on_false")?)?),
});
}
}
Ok(match kind.as_str() {
"minecraft:empty" => Self::Empty,
"minecraft:model" => Self::Model(ModelLeaf {
@@ -647,6 +658,7 @@ impl TintSource {
#[derive(Clone, Debug, PartialEq)]
pub enum ConditionProperty {
Custom(String),
CustomModelData(i32),
UsingItem,
Broken,
@@ -979,6 +991,10 @@ pub struct Context<'a> {
/// Host services the resolver cannot derive from data alone.
pub trait ItemEnv {
/// A mod's namespaced conditional property, false when no mod supplies it.
fn item_model_property(&self, _property: &str, _stack: &StackView) -> bool {
false
}
/// `PotionContents.getColorOr` for the potion/effects part: the mixed
/// effect colour, or `None` when the contents have no visible effects.
fn potion_effect_color(&self, _potion_contents: &Value) -> Option<i32> {
@@ -1332,6 +1348,9 @@ impl Resolve<'_> {
fn condition(&self, property: &ConditionProperty) -> bool {
let owner = self.ctx.owner;
match property {
ConditionProperty::Custom(property) => {
self.env.item_model_property(property, self.stack)
}
ConditionProperty::CustomModelData(i) => {
self.component("minecraft:custom_model_data")
.and_then(|d| custom_data_at(d, "flags", *i))
@@ -93,7 +93,18 @@ fn condition_property_is_false() {
"on_false":{"type":"minecraft:model","model":"a:no"}}"#,
)
.unwrap();
assert_eq!(model_id(&n), "a:no");
// A mod's property stays a condition its mod answers per stack
// (`item_model_property`); with no mod answering it, `on_false` is drawn.
let ItemModelNode::Condition {
property: ConditionProperty::Custom(property),
on_false,
..
} = &n
else {
panic!("not a custom condition: {n:?}")
};
assert_eq!(property, "mod:flag");
assert_eq!(model_id(on_false), "a:no");
}
#[test]
@@ -28,3 +28,86 @@ fn a_mods_tint_source_is_untinted_and_an_unknown_vanilla_one_fails() {
);
assert!(tint(r#"{"type":"minecraft:nonsense"}"#).is_err());
}
#[test]
fn custom_condition_is_evaluated_per_stack_at_branch_selection() {
struct Env;
impl ItemEnv for Env {
fn item_model_property(&self, property: &str, stack: &StackView) -> bool {
assert_eq!(property, "example:charged");
stack.count > 1 && stack.patch.get("example:charged") == Some(&Some(Value::Bool(true)))
}
}
let definition = ClientItem::from_json(
r#"{"model":{
"type":"minecraft:condition", "property":"example:charged",
"on_true":{"type":"minecraft:model","model":"example:item/charged"},
"on_false":{"type":"minecraft:model","model":"example:item/idle"}
}}"#,
)
.unwrap();
let mut models = ItemModels::default();
models.definitions.insert("example:tool".into(), definition);
models.prototypes.insert(
"example:tool".into(),
BTreeMap::from([(
"minecraft:item_model".into(),
Value::String("example:tool".into()),
)]),
);
let context = Context {
display: DisplayContext::Gui,
owner: None,
level: None,
seed: 0,
};
let mut stack = StackView::new("example:tool", 1);
let chosen = |stack: &StackView, env: &dyn ItemEnv| {
let resolved = models.resolve(stack, &context, env);
assert_eq!(resolved.layers.len(), 1);
let LayerContent::Model { model } = &resolved.layers[0].content else {
panic!("expected model leaf")
};
model.clone()
};
assert_eq!(chosen(&stack, &NoEnv), "example:item/idle");
assert_eq!(chosen(&stack, &Env), "example:item/idle");
stack
.patch
.insert("example:charged".into(), Some(Value::Bool(true)));
assert_eq!(chosen(&stack, &Env), "example:item/idle");
stack.count = 2;
assert_eq!(chosen(&stack, &Env), "example:item/charged");
assert_eq!(chosen(&stack, &NoEnv), "example:item/idle");
stack.patch.insert("example:charged".into(), None);
assert_eq!(chosen(&stack, &Env), "example:item/idle");
}
#[test]
fn custom_condition_validates_its_name_and_both_branches() {
let node = |property: &str, on_true: Value| {
ItemModelNode::decode(&serde_json::json!({
"type": "minecraft:condition", "property": property,
"on_true": on_true, "on_false": {"type": "minecraft:empty"}
}))
};
let empty = serde_json::json!({"type": "minecraft:empty"});
for name in [
"minecraft:unknown",
"unknown",
"example:Bad",
"bad name:charged",
] {
assert!(node(name, empty.clone()).is_err(), "{name}");
}
assert!(node("example:charged", Value::Null).is_err());
assert!(matches!(
node("minecraft:damaged", empty.clone()).unwrap(),
ItemModelNode::Condition {
property: ConditionProperty::Damaged,
..
}
));
assert!(matches!(node("example:charged", empty).unwrap(),
ItemModelNode::Condition { property: ConditionProperty::Custom(name), .. } if name == "example:charged"));
}
+33
View File
@@ -87,6 +87,39 @@ pub(crate) fn object(raw: &RawValue) -> Result<Object<'_>> {
}
Ok(serde_json::from_str(raw.get())?)
}
/// The members of an object in document order, which `object` loses.
pub(crate) fn ordered_object(raw: &RawValue) -> Result<Vec<(String, &RawValue)>> {
struct Members<'a>(Vec<(String, &'a RawValue)>);
impl<'de> serde::Deserialize<'de> for Members<'de> {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = Members<'de>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("an object")
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
mut map: A,
) -> std::result::Result<Self::Value, A::Error> {
let mut members: Vec<(String, &'de RawValue)> = Vec::new();
while let Some(entry) = map.next_entry::<String, &'de RawValue>()? {
members.retain(|(name, _)| *name != entry.0);
members.push(entry);
}
Ok(Members(members))
}
}
deserializer.deserialize_map(Visitor)
}
}
if !raw.get().starts_with('{') {
return Err(error("expected object"));
}
Ok(serde_json::from_str::<Members>(raw.get())?.0)
}
pub(crate) fn array(raw: &RawValue) -> Result<Vec<&RawValue>> {
Ok(serde_json::from_str(raw.get())?)
}
+1
View File
@@ -9,6 +9,7 @@ pub mod item_model;
mod json;
pub mod math;
pub mod model;
pub mod obj;
pub mod palette;
pub mod resolve;
+89 -1
View File
@@ -5,7 +5,7 @@
//! does; render preparation must diagnose these before baking.
use crate::json::*;
use serde_json::value::RawValue;
use std::collections::BTreeMap;
use std::{collections::BTreeMap, sync::Arc};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Direction {
@@ -59,6 +59,7 @@ pub enum TextureSlot {
#[derive(Clone, Debug, PartialEq)]
pub struct CuboidModel {
pub parent: Option<String>,
pub loader: Option<Loader>,
pub ambient_occlusion: Option<bool>,
pub gui_light: Option<GuiLight>,
pub textures: BTreeMap<String, TextureSlot>,
@@ -66,6 +67,27 @@ pub struct CuboidModel {
pub display: Option<ItemTransforms>,
/// NeoForge's `render_type`, without a `minecraft:` prefix.
pub render_type: Option<String>,
/// A model's `visibility`: an obj object or composite child name to shown; absent is shown.
pub visibility: BTreeMap<String, bool>,
}
/// A custom model loader's payload; a model inherits its parent's loader unless it names its own.
#[derive(Clone, Debug, PartialEq)]
pub enum Loader {
/// `neoforge:composite`: children in document order.
Composite {
children: Vec<(String, Arc<CuboidModel>)>,
/// Child name to shown; a child absent from the map is shown.
visibility: BTreeMap<String, bool>,
},
/// `neoforge:obj`: `model` and `mtl_override` are asset locations.
Obj {
model: String,
flip_v: bool,
shade_quads: Option<bool>,
mtl_override: Option<String>,
},
/// A loader this decoder does not know: it adds no geometry.
Unknown(String),
}
/// NeoForge's `neoforge_data` of an element or a face; a face's keys win.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -217,6 +239,11 @@ impl CuboidModel {
} else {
Some(identifier(&parent)?)
};
let loader = obj
.get("loader")
.filter(|raw| raw.get() != "null")
.map(|raw| Loader::decode(raw, &obj))
.transpose()?;
let ambient_occlusion = obj
.get("ambientocclusion")
.map(|r| boolean(r))
@@ -256,14 +283,75 @@ impl CuboidModel {
.map(|name| name.strip_prefix("minecraft:").unwrap_or(&name).to_owned());
Ok(Self {
parent,
loader,
ambient_occlusion,
gui_light,
textures,
elements,
display,
render_type,
visibility: visibility(&obj)?,
})
}
/// The models this one needs besides itself: its parent and its composite children's parents.
pub fn referenced_models(&self) -> Vec<String> {
let mut names: Vec<String> = self.parent.iter().cloned().collect();
if let Some(Loader::Composite { children, .. }) = &self.loader {
for (_, child) in children {
names.extend(child.referenced_models());
}
}
names
}
}
fn visibility(obj: &Object<'_>) -> Result<BTreeMap<String, bool>> {
let mut visibility = BTreeMap::new();
if let Some(raw) = obj.get("visibility") {
for (key, raw) in object(raw)? {
visibility.insert(key, boolean(raw)?);
}
}
Ok(visibility)
}
impl Loader {
fn decode(raw: &RawValue, obj: &Object<'_>) -> Result<Self> {
let name = primitive_string(raw)?;
match name.as_str() {
"neoforge:composite" => {
let mut children = Vec::new();
if let Some(raw) = obj.get("children") {
for (name, raw) in ordered_object(raw)? {
children.push((name, Arc::new(CuboidModel::decode(raw)?)));
}
}
Ok(Self::Composite {
children,
visibility: visibility(obj)?,
})
}
"neoforge:obj" => {
let optional = |key: &str| {
obj.get(key)
.filter(|raw| raw.get() != "null")
.map(|raw| primitive_string(raw))
.transpose()
};
Ok(Self::Obj {
model: identifier(&optional("model")?.ok_or_else(|| error("missing model"))?)?,
flip_v: obj
.get("flip_v")
.map(|r| boolean(r))
.transpose()?
.unwrap_or(false),
shade_quads: obj.get("shade_quads").map(|r| boolean(r)).transpose()?,
mtl_override: optional("mtl_override")?
.map(|name| identifier(&name))
.transpose()?,
})
}
_ => Ok(Self::Unknown(name)),
}
}
}
fn nullable<T>(raw: &RawValue, decode: impl FnOnce(&RawValue) -> Result<T>) -> Result<Option<T>> {
if raw.get() == "null" {
+183
View File
@@ -0,0 +1,183 @@
//! Wavefront OBJ and MTL text, as obj-loader models use them.
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ObjVertex {
/// Block units.
pub position: [f32; 3],
/// Texture coordinates as written, 0..1 across the sprite.
pub uv: [f32; 2],
}
/// One quad; a triangle repeats its last vertex.
#[derive(Clone, Debug, PartialEq)]
pub struct ObjFace {
/// Empty before any `usemtl`.
pub material: String,
/// The `o` (else `g`) it belongs to, which a model's `visibility` names.
pub object: String,
pub vertices: [ObjVertex; 4],
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ParsedObj {
pub mtllib: Option<String>,
pub faces: Vec<ObjFace>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MtlTexture {
/// `map_Kd #name`: a texture slot of the model.
Slot(String),
/// `map_Kd namespace:path`: a sprite.
Sprite(String),
}
/// A parsed OBJ with its materials.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ObjModel {
pub faces: Vec<ObjFace>,
pub materials: BTreeMap<String, MtlTexture>,
}
pub fn parse_obj(text: &str) -> Result<ParsedObj, String> {
let mut positions: Vec<[f32; 3]> = Vec::new();
let mut uvs: Vec<[f32; 2]> = Vec::new();
let mut parsed = ParsedObj::default();
let mut material = String::new();
let mut object = String::new();
for (number, line) in text.lines().enumerate() {
let mut words = line.split_whitespace();
let Some(keyword) = words.next() else {
continue;
};
let at = |message: &str| format!("line {}: {message}", number + 1);
match keyword {
_ if keyword.starts_with('#') => {}
"v" => positions.push(floats::<3>(words).ok_or_else(|| at("bad vertex"))?),
"vt" => {
let mut words = words;
let u = words.next().and_then(|w| w.parse().ok());
let v = words.next().map_or(Some(0.0), |w| w.parse().ok());
let (Some(u), Some(v)) = (u, v) else {
return Err(at("bad texture coordinate"));
};
uvs.push([u, v]);
}
"mtllib" => parsed.mtllib = words.next().map(str::to_owned),
"usemtl" => material = words.next().unwrap_or("").to_owned(),
"o" | "g" => object = words.next().unwrap_or("").to_owned(),
"f" => {
let mut corners = Vec::new();
for word in words {
let mut parts = word.split('/');
let position = index(parts.next(), positions.len())
.ok_or_else(|| at("bad vertex index"))?;
let uv = match parts.next() {
None | Some("") => [0.0; 2],
some => {
uvs[index(some, uvs.len()).ok_or_else(|| at("bad texture index"))?]
}
};
corners.push(ObjVertex {
position: positions[position],
uv,
});
}
if corners.len() < 3 {
return Err(at("face needs three vertices"));
}
let mut push = |vertices: [ObjVertex; 4]| {
parsed.faces.push(ObjFace {
material: material.clone(),
object: object.clone(),
vertices,
});
};
if corners.len() <= 4 {
let last = corners[corners.len() - 1];
push(std::array::from_fn(|i| {
corners.get(i).copied().unwrap_or(last)
}));
} else {
for i in 1..corners.len() - 1 {
push([corners[0], corners[i], corners[i + 1], corners[i + 1]]);
}
}
}
_ => {}
}
}
Ok(parsed)
}
fn floats<'a, const N: usize>(mut words: impl Iterator<Item = &'a str>) -> Option<[f32; N]> {
let mut result = [0.0; N];
for value in &mut result {
*value = words.next()?.parse().ok()?;
}
Some(result)
}
/// A 1-based index, or a negative one counted from the end.
fn index(word: Option<&str>, len: usize) -> Option<usize> {
let value: i64 = word?.parse().ok()?;
let index = if value < 0 {
len as i64 + value
} else {
value - 1
};
(0..len as i64).contains(&index).then_some(index as usize)
}
/// Material name to its `map_Kd`; other statements are ignored.
pub fn parse_mtl(text: &str) -> BTreeMap<String, MtlTexture> {
let mut materials = BTreeMap::new();
let mut current = None;
for line in text.lines() {
let mut words = line.split_whitespace();
match words.next() {
Some("newmtl") => current = words.next().map(str::to_owned),
Some("map_Kd") => {
let (Some(name), Some(value)) = (&current, words.last()) else {
continue;
};
let texture = match value.strip_prefix('#') {
Some(slot) => MtlTexture::Slot(slot.to_owned()),
None => MtlTexture::Sprite(value.to_owned()),
};
materials.insert(name.clone(), texture);
}
_ => {}
}
}
materials
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quads_triangles_and_negative_indices() {
let text = "mtllib a.mtl\nv 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nvt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\n\
usemtl first\nf 1/1/1 2/2/1 3/3/1 4/4/1\nusemtl second\nf -4//1 -3//1 -2//1\n";
let parsed = parse_obj(text).unwrap();
assert_eq!(parsed.mtllib.as_deref(), Some("a.mtl"));
assert_eq!(parsed.faces.len(), 2);
assert_eq!(parsed.faces[0].material, "first");
assert_eq!(parsed.faces[0].vertices[2].uv, [1.0, 1.0]);
let triangle = &parsed.faces[1];
assert_eq!(triangle.material, "second");
assert_eq!(triangle.vertices[0].position, [0.0, 0.0, 0.0]);
assert_eq!(triangle.vertices[3], triangle.vertices[2]);
assert_eq!(triangle.vertices[2].position, [1.0, 1.0, 0.0]);
}
#[test]
fn bad_indices_are_errors() {
assert!(parse_obj("v 0 0 0\nf 1 2 3\n").is_err());
assert!(parse_obj("v 0 0 0\nv 1 0 0\nf 1 2\n").is_err());
}
#[test]
fn mtl_maps_slots_and_sprites() {
let materials =
parse_mtl("newmtl a\nKd 1 1 1\nmap_Kd #top\nnewmtl b\nmap_Kd mod:block/x\nnewmtl c\n");
assert_eq!(materials["a"], MtlTexture::Slot("top".into()));
assert_eq!(materials["b"], MtlTexture::Sprite("mod:block/x".into()));
assert!(!materials.contains_key("c"));
}
}
+179 -22
View File
@@ -1,18 +1,45 @@
//! Cuboid parent inheritance and texture-slot resolution.
use crate::{json::*, model::*};
use crate::{
json::*,
model::*,
obj::{ObjModel, parse_mtl, parse_obj},
};
use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
sync::{Arc, Mutex},
};
pub const MISSING_MODEL: &str = "minecraft:builtin/missing";
/// `ItemModelGenerator.GENERATED_ITEM_MODEL_ID`.
pub const GENERATED_MODEL: &str = "minecraft:builtin/generated";
/// Composite children nest through parents that may name the composite again.
const MAX_LOADER_DEPTH: u8 = 8;
/// Reads asset files that models point at (`namespace:path` is `assets/namespace/path`).
pub trait AssetReader: Send + Sync {
fn read_text(&self, location: &str) -> Option<String>;
}
/// Decoded models are immutable and their inherited geometry is shared.
pub struct ModelLibrary {
models: BTreeMap<String, Arc<CuboidModel>>,
missing: Arc<CuboidModel>,
assets: Option<Arc<dyn AssetReader>>,
/// Keyed by obj and mtl override; `None` is a file that failed, already warned about.
objs: Mutex<BTreeMap<(String, Option<String>), Option<Arc<ObjModel>>>>,
}
/// What a model's loader adds to its own elements.
#[derive(Clone, Debug)]
pub enum ResolvedLoader {
/// The shown children, resolved on their own, in order.
Composite(Vec<ResolvedCuboidModel>),
Obj {
/// `None` when the file failed to load.
model: Option<Arc<ObjModel>>,
flip_v: bool,
shade: bool,
/// Objects the model chain's `visibility` hides.
hidden: BTreeSet<String>,
},
}
#[derive(Clone, Debug)]
pub struct ResolvedCuboidModel {
@@ -26,8 +53,13 @@ pub struct ResolvedCuboidModel {
pub render_type: Option<String>,
geometry: Option<Arc<CuboidModel>>,
generated: bool,
loader: Option<ResolvedLoader>,
}
impl ResolvedCuboidModel {
/// Geometry from a custom loader, baked after the elements.
pub fn loader(&self) -> Option<&ResolvedLoader> {
self.loader.as_ref()
}
/// Whether the top geometry is `builtin/generated`'s layer extrusion
/// (no model closer to the leaf has elements of its own).
pub fn uses_item_generator(&self) -> bool {
@@ -68,6 +100,7 @@ impl ModelLibrary {
fn item_generator_model() -> CuboidModel {
CuboidModel {
parent: None,
loader: None,
ambient_occlusion: None,
gui_light: Some(GuiLight::Front),
textures: BTreeMap::from([(
@@ -77,6 +110,7 @@ impl ModelLibrary {
elements: None,
display: None,
render_type: None,
visibility: BTreeMap::new(),
}
}
pub fn new(models: BTreeMap<String, CuboidModel>) -> Result<Self> {
@@ -103,8 +137,15 @@ impl ModelLibrary {
Ok(Self {
models: decoded,
missing,
assets: None,
objs: Mutex::default(),
})
}
/// Where `neoforge:obj` models find their files.
pub fn with_assets(mut self, assets: Arc<dyn AssetReader>) -> Self {
self.assets = Some(assets);
self
}
/// This library plus `extra` models; an `extra` model replaces a
/// same-named one, but not the built-in missing and generated models.
/// Every existing model is shared, not copied.
@@ -125,13 +166,15 @@ impl ModelLibrary {
Ok(Self {
models,
missing: Arc::clone(&self.missing),
assets: self.assets.clone(),
objs: Mutex::default(),
})
}
/// Follow the complete parent chain. A missing resource uses the built-in
/// model; a cyclic chain is rejected, as native discovery excludes it.
/// The returned model can outlive this library without copying its elements.
pub fn resolve(&self, name: &str) -> Result<ResolvedCuboidModel> {
self.resolve_chain(name, None)
self.resolve_name(name, None)
}
/// Whether `name` and every parent it reaches are models of this library,
/// with no cycle: `resolve` puts the missing model in for any other.
@@ -166,28 +209,40 @@ impl ModelLibrary {
name: &str,
child: &BTreeMap<String, TextureSlot>,
) -> Result<ResolvedCuboidModel> {
self.resolve_chain(name, Some(child))
self.resolve_name(name, Some(child))
}
fn resolve_chain(
fn resolve_name(
&self,
name: &str,
child: Option<&BTreeMap<String, TextureSlot>>,
) -> Result<ResolvedCuboidModel> {
let mut name = identifier(name)?;
let next = Some(identifier(name)?);
self.resolve_chain(Vec::new(), Vec::new(), next, &BTreeMap::new(), child, 0)
}
/// `chain` and `names` start with models that are not in the library (a
/// composite child); `next` is the parent to follow. `fallback` slots lose
/// to any slot the chain defines.
fn resolve_chain(
&self,
mut chain: Vec<Arc<CuboidModel>>,
mut names: Vec<String>,
mut next: Option<String>,
fallback: &BTreeMap<String, TextureSlot>,
child: Option<&BTreeMap<String, TextureSlot>>,
depth: u8,
) -> Result<ResolvedCuboidModel> {
if depth > MAX_LOADER_DEPTH {
return Err(error("composite models nest too deeply"));
}
let mut visited = BTreeSet::new();
let mut chain = Vec::new();
let mut names = Vec::new();
loop {
while let Some(name) = next.take() {
if !visited.insert(name.clone()) {
return Err(error("cyclic model parent"));
}
let model = self.models.get(&name).unwrap_or(&self.missing);
chain.push(model);
names.push(name.clone());
match &model.parent {
Some(parent) => name = parent.clone(),
None => break,
}
chain.push(Arc::clone(model));
names.push(name);
next = model.parent.clone();
}
let ambient_occlusion = chain
.iter()
@@ -198,10 +253,7 @@ impl ModelLibrary {
.find_map(|m| m.gui_light)
.unwrap_or(GuiLight::Side);
let render_type = chain.iter().find_map(|m| m.render_type.clone());
let geometry = chain
.iter()
.find(|m| m.elements.is_some())
.map(|m| Arc::clone(m));
let geometry = chain.iter().find(|m| m.elements.is_some()).map(Arc::clone);
let generated = chain
.iter()
.zip(&names)
@@ -220,13 +272,68 @@ impl ModelLibrary {
};
// Resolve aliases only after applying every child override. A reference
// inherited from a parent binds to the child's final slot definition.
let mut slots = BTreeMap::new();
for model in chain.into_iter().rev() {
let mut slots: BTreeMap<String, &TextureSlot> =
fallback.iter().map(|(k, v)| (k.clone(), v)).collect();
for model in chain.iter().rev() {
slots.extend(model.textures.iter().map(|(k, v)| (k.clone(), v)));
}
if let Some(child) = child {
slots.extend(child.iter().map(|(k, v)| (k.clone(), v)));
}
let loader = match chain.iter().find_map(|m| m.loader.as_ref()) {
Some(Loader::Composite {
children,
visibility,
}) => {
let inherited: BTreeMap<String, TextureSlot> = slots
.iter()
.map(|(k, v)| (k.clone(), (*v).clone()))
.collect();
// A model closer to the leaf overrides the loader's own map.
let mut visibility = visibility.clone();
for model in chain.iter().rev() {
visibility.extend(model.visibility.iter().map(|(k, v)| (k.clone(), *v)));
}
let mut resolved = Vec::new();
for (child_name, child_model) in children {
if visibility.get(child_name) == Some(&false) {
continue;
}
resolved.push(self.resolve_chain(
vec![Arc::clone(child_model)],
vec![format!("{}#{child_name}", names[0])],
child_model.parent.clone(),
&inherited,
None,
depth + 1,
)?);
}
Some(ResolvedLoader::Composite(resolved))
}
Some(Loader::Obj {
model,
flip_v,
shade_quads,
mtl_override,
}) => {
// Leaf wins: a model closer to the leaf overrides its parents' entries.
let mut visibility = BTreeMap::new();
for model in chain.iter().rev() {
visibility.extend(model.visibility.iter().map(|(k, v)| (k.clone(), *v)));
}
Some(ResolvedLoader::Obj {
model: self.load_obj(&names[0], model, mtl_override.as_deref()),
flip_v: *flip_v,
shade: shade_quads.unwrap_or(true),
hidden: visibility
.into_iter()
.filter(|(_, shown)| !shown)
.map(|(name, _)| name)
.collect(),
})
}
Some(Loader::Unknown(_)) | None => None,
};
let mut textures = BTreeMap::<String, Material>::new();
let mut unresolved_textures = BTreeSet::<String>::new();
for start in slots.keys() {
@@ -271,10 +378,58 @@ impl ModelLibrary {
render_type,
geometry,
generated,
loader,
})
}
/// Parse an obj and its materials once; a failure warns once and yields no geometry.
fn load_obj(&self, label: &str, model: &str, mtl: Option<&str>) -> Option<Arc<ObjModel>> {
let key = (model.to_owned(), mtl.map(str::to_owned));
let mut cache = self.objs.lock().unwrap_or_else(|e| e.into_inner());
if let Some(found) = cache.get(&key) {
return found.clone();
}
let loaded = self.read_obj(model, mtl);
if let Err(message) = &loaded {
log::warn!("Horizon: obj model {label} ({model}): {message}");
}
let loaded = loaded.ok().map(Arc::new);
cache.insert(key, loaded.clone());
loaded
}
fn read_obj(&self, model: &str, mtl: Option<&str>) -> std::result::Result<ObjModel, String> {
let assets = self.assets.as_ref().ok_or("no asset source")?;
let text = assets.read_text(model).ok_or("file not found")?;
let parsed = parse_obj(&text)?;
let mtl = match (mtl, &parsed.mtllib) {
(Some(location), _) => Some(location.to_owned()),
(None, Some(file)) if file.contains(':') => Some(file.clone()),
(None, Some(file)) => {
let (namespace, path) = model.split_once(':').unwrap_or(("minecraft", model));
let directory = path.rsplit_once('/').map_or("", |(dir, _)| dir);
Some(if directory.is_empty() {
format!("{namespace}:{file}")
} else {
format!("{namespace}:{directory}/{file}")
})
}
(None, None) => None,
};
let materials = match mtl {
Some(location) => match assets.read_text(&location) {
Some(text) => parse_mtl(&text),
None => {
log::warn!("Horizon: obj material file {location} not found");
BTreeMap::new()
}
},
None => BTreeMap::new(),
};
Ok(ObjModel {
faces: parsed.faces,
materials,
})
}
}
/// The native missing-model cube uses slot names without a leading '#'.
pub fn missing_model() -> CuboidModel {
let faces = Direction::ALL
@@ -296,6 +451,7 @@ pub fn missing_model() -> CuboidModel {
.collect();
CuboidModel {
parent: None,
loader: None,
ambient_occlusion: None,
gui_light: None,
elements: Some(vec![Some(Element {
@@ -324,6 +480,7 @@ pub fn missing_model() -> CuboidModel {
}),
),
]),
visibility: BTreeMap::new(),
}
}
+18
View File
@@ -79,9 +79,27 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/uk_ua.json" => {
include_str!("../../horizon-client/assets/horizon/lang/uk_ua.json")
}
"assets/horizon/lang/hi_in.json" => {
include_str!("../../horizon-client/assets/horizon/lang/hi_in.json")
}
"assets/horizon/lang/id_id.json" => {
include_str!("../../horizon-client/assets/horizon/lang/id_id.json")
}
"assets/horizon/lang/tr_tr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
}
"assets/horizon/lang/ko_kr.json" | "assets/horizon/lang/ko.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ko_kr.json")
}
"assets/horizon/lang/hr_hr.json" | "assets/horizon/lang/hr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/hr_hr.json")
}
"assets/horizon/lang/pt_pt.json" => {
include_str!("../../horizon-client/assets/horizon/lang/pt_pt.json")
}
"assets/horizon/lang/ur_pk.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ur_pk.json")
}
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
}
+37 -2
View File
@@ -12,13 +12,48 @@ pub fn uuid_text(u: [i32; 4]) -> String {
)
}
pub fn uuid_from_text(s: &str) -> Option<[i32; 4]> {
let hex: String = s.chars().filter(|c| *c != '-').collect();
if hex.len() != 32 {
let mut hex = [0u8; 32];
let mut len = 0;
for b in s.bytes() {
if b == b'-' {
continue;
}
if !b.is_ascii_hexdigit() || len >= 32 {
return None;
}
hex[len] = b;
len += 1;
}
if len != 32 {
return None;
}
let hex = std::str::from_utf8(&hex).ok()?;
let mut out = [0i32; 4];
for (i, v) in out.iter_mut().enumerate() {
*v = u32::from_str_radix(&hex[i * 8..i * 8 + 8], 16).ok()? as i32;
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uuid_from_text_rejects_multibyte_and_non_hex() {
assert_eq!(uuid_from_text("€€€€€€€€€€aa"), None);
assert_eq!(uuid_from_text("€€€€€€€€€€aa----"), None);
assert_eq!(uuid_from_text("+0000001-0000-0000-0000-000000000000"), None);
assert_eq!(uuid_from_text("00000000-0000-0000-0000-00000000000g"), None);
assert_eq!(uuid_from_text(""), None);
}
#[test]
fn uuid_from_text_parses_hex_and_dashes() {
let u = [0x01234567, -0x76543211, 0x01234567, -0x76543211];
let text = uuid_text(u);
assert_eq!(uuid_from_text(&text), Some(u));
let undashed: String = text.chars().filter(|c| *c != '-').collect();
assert_eq!(uuid_from_text(&undashed), Some(u));
}
}
+4
View File
@@ -225,3 +225,7 @@ path = "../horizon-client/tests/tall_column.rs"
[[test]]
name = "wide_states"
path = "../horizon-client/tests/wide_states.rs"
[[example]]
name = "chunk_encoding_bench"
path = "../horizon-client/examples/chunk_encoding_bench.rs"
@@ -0,0 +1,397 @@
//! Original asset-free standard-pack entity fixture. Run with throwaway HORIZON_HOME,
//! HORIZON_LAUNCHER_HOME, XDG_DATA_HOME and XDG_CONFIG_HOME (AGENTS.md).
//! `cargo run -p horizon-client-render --example standard_pack_gpu_entities -- <output-dir>`
use client::{
gfx::{
Frame, Renderer, ScreenshotRequest,
entity_skin::{SkinFrame, SkinJob, SkinPart, SkinVertex},
native_render,
pack_draw::EntitySpan,
},
graphics::GraphicsApi,
hud::HudBatch,
};
use horizon_client_render as client;
use std::{
path::Path,
sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
},
};
use winit::{
application::ApplicationHandler,
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoop},
window::{Window, WindowId},
};
static NEEDS_SPANS: AtomicBool = AtomicBool::new(false);
static OBSERVED: AtomicUsize = AtomicUsize::new(0);
struct Observer;
impl native_render::RenderHooks for Observer {
fn render_needs(&self, event: &mut native_render::RenderNeedsEvent<'_>) {
event.needs.entity_spans |= NEEDS_SPANS.load(Ordering::Relaxed);
}
fn world_drawn(&self, event: &mut native_render::WorldDrawnEvent<'_, '_>) {
if NEEDS_SPANS.load(Ordering::Relaxed) {
assert_eq!(event.world.entities.len(), 12);
assert_eq!(
event.world.spans.iter().map(|s| s.id).collect::<Vec<_>>(),
[77, 88, 99]
);
OBSERVED.fetch_add(1, Ordering::Relaxed);
}
}
}
fn pack(renderer: &mut Renderer, folder: &Path, valid: bool) {
let shaders = folder.join("shaders");
std::fs::create_dir_all(&shaders).unwrap();
let vertex = "#version 120\nvarying vec2 uv; void main(){gl_Position=ftransform();uv=gl_MultiTexCoord0.xy;}\n";
let fragment = "#version 120\nuniform int entityId; uniform int blockEntityId; uniform vec4 entityColor; void main(){gl_FragData[0]=vec4(float(entityId)/255.0,entityColor.a,float(blockEntityId)/255.0,1.0);}\n";
for program in [
"gbuffers_entities",
"gbuffers_block",
"gbuffers_entities_translucent",
] {
std::fs::write(shaders.join(format!("{program}.vsh")), vertex).unwrap();
std::fs::write(
shaders.join(format!("{program}.fsh")),
if valid {
fragment
} else {
"#version 120\nvoid main(){ invalid shader; }"
},
)
.unwrap();
}
std::fs::write(shaders.join("final.vsh"), vertex).unwrap();
std::fs::write(shaders.join("final.fsh"), "#version 120\nvarying vec2 uv; uniform sampler2D colortex0; void main(){gl_FragColor=texture2D(colortex0,uv);}\n").unwrap();
std::fs::write(
shaders.join("shaders.properties"),
"oldLighting=false\nshadow.enabled=false\n",
)
.unwrap();
let pack = horizon_shaders::Pack::open(folder).unwrap();
let mut macros = horizon_shaders::Macros::standard();
renderer.define_pack_macros(&mut macros);
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &renderer.pack_inputs());
assert_eq!(post.notes.is_empty(), valid, "{:?}", post.notes);
let (_, notes) = renderer.set_shader_pack(0, Some(post), &[]).unwrap();
assert!(
!valid || notes.iter().all(|n| !n.contains("skipped")),
"{notes:?}"
);
}
fn read(path: &Path) -> Vec<u8> {
let decoder = png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path).unwrap()));
let mut reader = decoder.read_info().unwrap();
let mut bytes = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut bytes).unwrap();
assert_eq!(info.color_type, png::ColorType::Rgba);
bytes.truncate(info.buffer_size());
bytes
}
fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
let out = std::env::args().nth(1).ok_or("output directory required")?;
let out = Path::new(&out);
std::fs::create_dir_all(out)?;
native_render::install(Box::new([&Observer])).map_err(|_| "observer already installed")?;
let white = [255; 16 * 16 * 4];
let zero = [0; 4];
let mut renderer = pollster::block_on(Renderer::new(
window,
true,
&white,
16,
16,
1,
&[],
&[],
(&white, 16, 16),
(&zero, 1, 1),
(&zero, 1, 1),
(&zero, 1),
Arc::new(client::gfx::panorama::Faces::colour([0, 0, 0, 255])),
None,
None,
GraphicsApi::Vulkan,
false,
true,
))?;
renderer.resize(320, 180);
assert!(
renderer.skins_entities(),
"fixture requires compute skinning"
);
let unmodified_tagged = renderer.skins_tagged();
pack(&mut renderer, &out.join("valid-pack"), true);
assert!(
!renderer.skins_tagged(),
"eye/tagged layers keep the CPU path with a pack"
);
let spans = [77, 88, 99]
.into_iter()
.enumerate()
.map(|(i, id)| EntitySpan {
start: i as u32 * 4,
stage: horizon_shaders::render_stage("ENTITIES"),
id,
hidden: i == 2,
..Default::default()
})
.collect::<Vec<_>>();
let mut skin = SkinFrame {
reset: true,
vertices: 12,
spans: spans.clone(),
parts: vec![SkinPart {
matrix: glam::Mat4::IDENTITY.to_cols_array(),
normal: glam::Mat3::IDENTITY.to_cols_array(),
}],
..Default::default()
};
for (i, x) in [-1.8, 0.3, -0.7].into_iter().enumerate() {
for (pos, uv) in [
[x, -0.8, 5.],
[x, 0.8, 5.],
[x + 1.4, 0.8, 5.],
[x + 1.4, -0.8, 5.],
]
.into_iter()
.zip([[0., 0.], [0., 1.], [1., 1.], [1., 0.]])
{
skin.new_model.push(SkinVertex {
pos,
uv,
axis: 5,
part: 0,
});
}
skin.jobs.push(SkinJob {
first: i as u32 * 4,
dst: i as u32 * 4,
count: 4,
rect: [0., 0., 1., 1.],
color: u32::MAX,
light: 0xf0f0,
overlay: if i == 0 { 1 } else { 5 },
..Default::default()
});
}
let cpu = client::gfx::entity_skin::expand(&skin.new_model, &skin);
let hud = HudBatch::new([320, 180]);
let mut frame = fixture_frame(&hud);
frame.title = false;
frame.camera = glam::DVec3::ZERO;
frame.eye = glam::DVec3::ZERO;
frame.pitch = 0.;
frame.entities = &cpu;
frame.entity_spans = &spans;
renderer.screenshot = Some(ScreenshotRequest {
path: out.join("cpu.png"),
size: None,
scaled: false,
});
renderer.render(&frame)?;
let expected = read(&out.join("cpu.png"));
let matches = expected
.as_chunks::<4>()
.0
.iter()
.filter(|p| p[..3] == [77, 77, 0])
.count();
assert!(
matches > 100,
"original entity-ID/hurt output, got {matches} pixels"
);
let white = expected
.as_chunks::<4>()
.0
.iter()
.filter(|p| p[..3] == [88, 192, 0])
.count();
assert!(
white > 100,
"original entity-ID/white-flash output, got {white} pixels"
);
assert_eq!(
expected
.as_chunks::<4>()
.0
.iter()
.filter(|p| p[0] == 99)
.count(),
0,
"hidden caster is not camera-visible"
);
assert!(
renderer.skins_entities(),
"compiled standard-pack stages must accept GPU posing"
);
frame.entities = &[];
frame.entity_spans = &[];
frame.skin = Some(&skin);
renderer.screenshot = Some(ScreenshotRequest {
path: out.join("gpu.png"),
size: None,
scaled: false,
});
renderer.render(&frame)?;
assert_eq!(
read(&out.join("gpu.png")),
expected,
"CPU and GPU standard-pack pixels"
);
NEEDS_SPANS.store(true, Ordering::Relaxed);
assert!(
!renderer.skins_entities(),
"native CPU-stream observer keeps its fallback"
);
frame.skin = None;
frame.entities = &cpu;
frame.entity_spans = &spans;
renderer.render(&frame)?;
assert_eq!(OBSERVED.load(Ordering::Relaxed), 1);
NEEDS_SPANS.store(false, Ordering::Relaxed);
assert!(renderer.skins_entities());
pack(&mut renderer, &out.join("failed-pack"), false);
assert!(
!renderer.skins_entities(),
"failed entity programs retain CPU posing"
);
renderer.drop_shader_packs();
assert!(
renderer.skins_entities(),
"ordinary path returns when the pack is dropped"
);
assert_eq!(renderer.skins_tagged(), unmodified_tagged);
std::fs::write(
out.join("result.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"cpu_gpu_pixels_equal": true, "entity_id_hurt_pixels": matches, "hidden_subject_pixels": 0,
"entity_id_white_flash_pixels": white,
"graphics_adapter": client::crash::system_details("fixture").into_iter()
.find(|(name, _)| name == "Graphics Adapter").map(|(_, detail)| detail),
"native_observer_frames": OBSERVED.load(Ordering::Relaxed), "player_game_started": false,
"fixture_only_not_gameplay_or_fps_proof": true
}))?,
)?;
Ok(())
}
struct App;
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let window = Arc::new(
event_loop
.create_window(Window::default_attributes().with_visible(false))
.unwrap(),
);
if let Err(error) = fixture(window) {
eprintln!("fixture failed: {error}");
std::process::exit(1);
}
event_loop.exit();
}
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
}
fn main() {
for key in [
"HORIZON_HOME",
"HORIZON_LAUNCHER_HOME",
"XDG_DATA_HOME",
"XDG_CONFIG_HOME",
] {
assert!(
std::env::var_os(key).is_some(),
"set throwaway {key} (AGENTS.md)"
);
}
EventLoop::new().unwrap().run_app(&mut App).unwrap();
}
fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
Frame {
weather: &[],
weather_time: client::weather::Time::new(0, 0.0),
camera: glam::DVec3::new(0.5, 100.0, 0.5),
yaw: 0.0,
pitch: 10.0,
fov_degrees: 85.0,
eye: glam::DVec3::new(0.5, 100.0, 0.5),
eye_rotation: [0.0, 10.0],
first_person: true,
bob: glam::Mat4::IDENTITY,
spin: glam::Mat4::IDENTITY,
sky: client::environment::Sky::default(),
shadow_scene_revision: (0, 0),
shadow_cache: false,
distance: 12.0,
detail_distance: 12.0,
depth_far: 1000.0,
underwater: false,
water_plane: None,
cave_culling: false,
title: true,
sky_blocked: false,
skybox: false,
end_sky: false,
end_sky_sprite: None,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
dark_disc: false,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
hud,
glint_alpha: 0.75,
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
static_block_entities: &[],
visible_static_block_entity_sections: &[],
static_block_entity_stats: Default::default(),
entity_motion: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
colored_lights: &[],
emissive_layers: &[],
clouds: None,
lightning: Default::default(),
additive: &[],
see_through: &[],
shadow_only: &[],
celestial: &[],
stars: None,
hand: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
border: None,
game_time: 0.0,
shader_game_time: 0.0,
end_portal_sprites: [[0.0; 4]; 2],
crumbling_sprite: [0.0; 4],
ticks: 0,
darkness: (0.0, 0.0),
night_vision: 0.0,
block_flicker: 0.0,
boss_darken: 0.0,
chunk_fade: 0.0,
post_effects: client::post_chain::Request::none(),
menu_blur: None,
}
}
@@ -107,7 +107,12 @@ pub struct RenderAssets {
pub connected: crate::connected::ConnectedTextures,
/// Fusion connected textures (empty without Fusion's connecting textures and models).
pub fusion: crate::fusion::FusionTextures,
/// Counts up each time resources load, from 1: a native mod asks again where blocks'
/// sprites are when it changes (`native_block_sprites_version`).
pub generation: u64,
}
/// The last `RenderAssets::generation` handed out.
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
}
@@ -311,6 +316,7 @@ impl RenderAssets {
pbr_size: sprites.pbr_size,
connected,
fusion,
generation: GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1,
})
}
}
@@ -0,0 +1,432 @@
//! Where a block's textures are in the block sprite arrays, side by side, for drawing the block
//! somewhere the mesher does not: a native mod's own pass drawing blocks as voxels
//! (`native_block_sprites`, `WorldDrawn::block_sprites`; docs/native-mods.md). The answer is the
//! model's own faces, as the mesher bakes them: each face's sprite (its array and layer), where on
//! the block's side it lies and how its texture runs across it, and its tint source.
//!
//! Each sprite is a whole layer of a square texture array (`sprites::Sprites`), so a face's UVs run
//! 0 to 1 within its layer; the renderer's split (`Renderer::sprite_split`) layers to an array.
use crate::{mesher::TintSource, models::catalog::BakedFace, streaming::WorkerContext};
use horizon_assets::model::Direction;
use horizon_core::position::BlockPos;
use horizon_world::registry::{BlockRegistry, BlockStateId};
/// A sprite: its array (0 for every vanilla texture on most GPUs; past the first array's layers in
/// 1 to 7) and its layer in that array.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SpriteRef {
pub array: u32,
pub layer: u32,
/// It animates (water, lava, fire, sea lanterns...): the arrays always hold the frame being shown.
pub animated: bool,
/// Some of its pixels are clear (alpha 0): leaves, glass, flowers. Draw those as holes.
pub cutout: bool,
}
/// One face of a block's model on one of its sides.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FaceSprite {
pub sprite: SpriteRef,
/// Where the face lies on the side, in the side's two axes ([`side_axes`]), from 0 to 1 across the block:
/// `[a0, b0, a1, b1]`. `[0, 0, 1, 1]` for a whole face.
pub rect: [f32; 4],
/// How deep into the block the face lies from the side's own plane (0 for a cube's face, 0.5 for a slab's top).
pub inset: f32,
/// The texture across the side: the point (a, b) on it shows `uv = [u0, v0] + a * [du_u, du_v] + b * [dv_u,
/// dv_v]` of the sprite, `[u0, v0, du_u, du_v, dv_u, dv_v]`. Rotations and mirrors (a log on its side, a
/// model's `rotation` and `uvlock`) are in it.
pub uv: [f32; 6],
/// Its colour source, none for an untinted face: multiply the texture by the source's colour.
pub tint: Option<TintSource>,
/// It has partly clear pixels: drawn sorted and blended.
pub translucent: bool,
}
/// What a block draws on each side, in [`Direction::ALL`]'s order (down, up, north, south, west, east).
#[derive(Clone, Debug, PartialEq)]
pub struct BlockSprites {
/// The faces on each side, in the model's order: the one drawn first first (a grass block's side: its dirt
/// and grass, then the grass overlay tinted over it). A side with none is empty. Faces that do not lie flat
/// on a side (a flower's crossed planes, a rotated element) are left out.
pub sides: [Vec<FaceSprite>; 6],
/// The model's particle sprite (what breaking it scatters, a flower's whole picture), if it has one.
pub particle: Option<SpriteRef>,
/// The model is a plain cube: one whole face on each side, at its plane.
pub cube: bool,
}
/// The two axes a side's `rect` and `uv` count along, as indices into (x, y, z): down and up across x then z; north
/// and south across x then y; west and east across z then y. (The block's corner at 0, 0, 0 is 0 on each.)
pub fn side_axes(side: Direction) -> (usize, usize) {
match side {
Direction::Down | Direction::Up => (0, 2),
Direction::North | Direction::South => (0, 1),
Direction::West | Direction::East => (2, 1),
}
}
/// The axis a side faces along, and where its plane is (0 or 1).
fn side_plane(side: Direction) -> (usize, f32) {
match side {
Direction::Down => (1, 0.0),
Direction::Up => (1, 1.0),
Direction::North => (2, 0.0),
Direction::South => (2, 1.0),
Direction::West => (0, 0.0),
Direction::East => (0, 1.0),
}
}
/// A block state from `name[property=value,...]`, the namespace `minecraft:` when it has none; properties left
/// out take the block's default. None for a block or a property the game does not know.
pub fn parse_state(registry: &BlockRegistry, text: &str) -> Option<BlockStateId> {
let (name, props) = match text.trim().split_once('[') {
Some((n, rest)) => (n, rest.strip_suffix(']')?),
None => (text.trim(), ""),
};
let name = if name.contains(':') {
name.to_owned()
} else {
format!("minecraft:{name}")
};
let default = registry.default_state(&name)?;
let mut state = registry.state(default)?.clone();
for pair in props.split(',').map(str::trim).filter(|p| !p.is_empty()) {
let (k, v) = pair.split_once('=')?;
let slot = state.properties.get_mut(k.trim())?;
*slot = v.trim().to_owned();
}
registry.resolve(&state).ok()
}
/// The sprites `context`'s resources hold, `split` layers to an array (`Renderer::sprite_split`).
pub struct Lookup<'a> {
context: &'a WorkerContext,
split: u32,
}
impl<'a> Lookup<'a> {
pub fn new(context: &'a WorkerContext, split: u32) -> Self {
Self {
context,
split: split.max(1),
}
}
/// The sprite at `layer` of all the layers.
pub fn sprite(&self, layer: u32) -> SpriteRef {
let render = &self.context.render;
SpriteRef {
array: layer / self.split,
layer: layer % self.split,
animated: render.animations.iter().any(|a| a.layer == layer),
cutout: render
.sprite_mips
.get(layer as usize)
.is_some_and(|m| m.has_transparent),
}
}
/// A texture by name (`minecraft:block/stone`, `block/stone`); none for one no model uses (the arrays hold only
/// the textures models and the game's own drawing name).
pub fn texture(&self, name: &str) -> Option<SpriteRef> {
let layers = &self.context.render.sprite_layers;
let layer = layers
.get(name)
.or_else(|| layers.get(&format!("minecraft:{name}")))?;
Some(self.sprite(*layer))
}
/// What block `name` (`minecraft:oak_log[axis=x]`; properties left out take the default) draws on each side;
/// none for a block the game does not know.
pub fn block(&self, name: &str) -> Option<BlockSprites> {
let state = parse_state(self.context.assets.registry, name)?;
Some(self.state(state))
}
/// What `state` draws on each side. A fluid has its still texture on all six; a block drawn only as a block
/// entity (a chest, a sign) or not at all (air) has empty sides, and its particle sprite if any.
pub fn state(&self, state: BlockStateId) -> BlockSprites {
let render = &self.context.render;
let catalog = &render.model_catalog;
let mut sides: [Vec<FaceSprite>; 6] = Default::default();
let block = self
.context
.assets
.registry
.state(state)
.map_or("", |s| s.name.as_str());
let fluid = match block {
"minecraft:water" | "minecraft:bubble_column" => {
Some(("block/water_still", Some(TintSource::Water), true))
}
"minecraft:lava" => Some(("block/lava_still", None, false)),
_ => None,
};
if let Some((texture, tint, translucent)) = fluid {
let sprite = self.texture(texture);
if let Some(sprite) = sprite {
for side in &mut sides {
side.push(FaceSprite {
sprite,
rect: [0.0, 0.0, 1.0, 1.0],
inset: 0.0,
uv: [0.0, 0.0, 1.0, 0.0, 0.0, 1.0],
tint,
translucent,
});
}
}
return BlockSprites {
sides,
particle: sprite,
cube: sprite.is_some(),
};
}
let models = catalog
.collect(state, BlockPos::new(0, 0, 0))
.unwrap_or_default();
let mut all = 0;
for face in models.iter().flat_map(|(_, m)| &m.faces) {
all += 1;
let Some((side, sprite)) = self.face(state, face) else {
continue;
};
sides[side as usize].push(sprite);
}
let particle = catalog
.particle(state)
.or_else(|| {
models
.iter()
.flat_map(|(_, m)| &m.faces)
.map(|f| f.layer)
.next()
})
.map(|layer| self.sprite(layer));
// a cube: every side has a whole face at its plane (and perhaps overlays laid on it), and the model has no
// other faces
let whole = |f: &FaceSprite| f.rect == [0.0, 0.0, 1.0, 1.0] && f.inset == 0.0;
let cube = all == sides.iter().map(Vec::len).sum::<usize>()
&& sides.iter().all(|s| !s.is_empty() && s.iter().all(whole));
BlockSprites {
sides,
particle,
cube,
}
}
/// A model face as it lies on its side; none when it does not lie flat on one.
fn face(&self, state: BlockStateId, face: &BakedFace) -> Option<(Direction, FaceSprite)> {
let q = &face.quad;
let side = q.facing;
let (normal, plane) = side_plane(side);
let depth = q.positions[0][normal];
if q.positions.iter().any(|p| (p[normal] - depth).abs() > 1e-4) {
return None;
}
let (a, b) = side_axes(side);
let ab: [[f32; 2]; 4] = q.positions.map(|p| [p[a], p[b]]);
let uv = affine(&ab, &q.uv)?;
let (mut a0, mut b0, mut a1, mut b1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for [x, y] in ab {
a0 = a0.min(x);
b0 = b0.min(y);
a1 = a1.max(x);
b1 = b1.max(y);
}
let snap = |v: f32| {
let r = (v * 4096.0).round() / 4096.0;
if r == 0.0 { 0.0 } else { r }
};
Some((
side,
FaceSprite {
sprite: self.sprite(face.layer),
rect: [a0, b0, a1, b1].map(snap),
inset: snap((depth - plane).abs()),
uv: uv.map(snap),
tint: self.context.tables.tint_source(state, q.tint_index),
translucent: face.translucent,
},
))
}
}
/// The affine map from a face's points on its side to its UVs, `[u0, v0, du_u, du_v, dv_u, dv_v]`, from three of
/// its corners that are not in a line (a model's faces are parallelograms on their side).
fn affine(ab: &[[f32; 2]; 4], uv: &[[f32; 2]; 4]) -> Option<[f32; 6]> {
for [i, j, k] in [[0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3]] {
let d1 = [ab[j][0] - ab[i][0], ab[j][1] - ab[i][1]];
let d2 = [ab[k][0] - ab[i][0], ab[k][1] - ab[i][1]];
let det = d1[0] * d2[1] - d1[1] * d2[0];
if det.abs() < 1e-6 {
continue;
}
let e1 = [uv[j][0] - uv[i][0], uv[j][1] - uv[i][1]];
let e2 = [uv[k][0] - uv[i][0], uv[k][1] - uv[i][1]];
// e1 = d1.a * du + d1.b * dv, e2 = d2.a * du + d2.b * dv
let du = [
(e1[0] * d2[1] - e2[0] * d1[1]) / det,
(e1[1] * d2[1] - e2[1] * d1[1]) / det,
];
let dv = [
(e2[0] * d1[0] - e1[0] * d2[0]) / det,
(e2[1] * d1[0] - e1[1] * d2[0]) / det,
];
let o = [
uv[i][0] - ab[i][0] * du[0] - ab[i][1] * dv[0],
uv[i][1] - ab[i][0] * du[1] - ab[i][1] * dv[1],
];
return Some([o[0], o[1], du[0], du[1], dv[0], dv[1]]);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_faces_texture_runs_across_its_side() {
// a whole face, the texture upright: u along a, v along b
let ab = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
assert_eq!(
affine(&ab, &[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]),
Some([0.0, 0.0, 1.0, 0.0, 0.0, 1.0])
);
// turned a quarter (a log on its side): u runs down b, v back along a
let turned = affine(&ab, &[[0.0, 1.0], [0.0, 0.0], [1.0, 0.0], [1.0, 1.0]]).unwrap();
assert_eq!(turned, [0.0, 1.0, 0.0, -1.0, 1.0, 0.0]);
// a slab's side: the lower half of the block, the lower half of the texture (v counts down)
let half = [[0.0, 0.0], [1.0, 0.0], [1.0, 0.5], [0.0, 0.5]];
let m = affine(&half, &[[0.0, 1.0], [1.0, 1.0], [1.0, 0.5], [0.0, 0.5]]).unwrap();
assert_eq!(m, [0.0, 1.0, 1.0, 0.0, 0.0, -1.0]);
// a face seen edge on has no map
assert_eq!(affine(&[[0.0, 0.0]; 4], &[[0.0, 0.0]; 4]), None);
}
/// The game's own blocks from real prepared resources: a cube, a grass block's tinted top and overlaid sides, a
/// log on its side (its texture turned), leaves (cut out, foliage), water, a slab and a flower.
#[test]
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
fn real_blocks_name_their_sprites() -> crate::Result<()> {
use crate::{
assets::Assets, biome::Biomes, blocks::BlockFacts, light::LightTables,
mesher::MeshTables,
};
use std::sync::Arc;
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
);
let assets = Arc::new(Assets::load(&root)?);
let render = Arc::new(assets.reload(&root)?);
let facts = BlockFacts::load(&root, assets.registry)?;
let tables = Arc::new(MeshTables::new(
&assets,
&render,
&facts,
crate::models::load_visibility(&root, assets.registry)?,
)?);
let context = WorkerContext {
light: Arc::new(LightTables::load(&root, assets.registry, &facts)?),
tables,
assets: assets.clone(),
render,
biomes: Arc::new(Biomes::load(&root)?),
};
let lookup = Lookup::new(&context, 2048);
let whole = [0.0, 0.0, 1.0, 1.0];
let upright = [0.0, 0.0, 1.0, 0.0, 0.0, 1.0];
let stone = lookup.block("minecraft:stone").unwrap();
assert!(stone.cube);
let top = stone.sides[Direction::Up as usize][0];
assert_eq!(
(top.rect, top.inset, top.tint, top.sprite.array),
(whole, 0.0, None, 0)
);
assert_eq!(lookup.texture("minecraft:block/stone"), Some(top.sprite));
assert_eq!(lookup.texture("block/stone"), Some(top.sprite));
assert!(!top.sprite.animated && !top.sprite.cutout);
let grass = lookup.block("grass_block").unwrap();
assert!(grass.cube);
assert_eq!(
grass.sides[Direction::Up as usize][0].tint,
Some(TintSource::Grass)
);
assert_eq!(grass.sides[Direction::Down as usize][0].tint, None);
let side = &grass.sides[Direction::North as usize];
assert_eq!(side.len(), 2, "the side and its overlay");
assert_eq!(
(side[0].tint, side[1].tint),
(None, Some(TintSource::Grass))
);
assert_eq!(
lookup.texture("block/grass_block_side_overlay"),
Some(side[1].sprite)
);
// seen from outside, a side's texture runs from its top left: on the north side (looking south, east on
// the left) u falls with x and v with y; on top, u and v run with x and z (stone's first variant is turned)
let planks = lookup.block("oak_planks").unwrap();
let uv = |b: &BlockSprites, d: Direction| b.sides[d as usize][0].uv;
let north = uv(&planks, Direction::North);
assert_eq!(north, [1.0, 1.0, -1.0, 0.0, 0.0, -1.0]);
assert_eq!(uv(&planks, Direction::Up), upright);
assert_eq!(
uv(&planks, Direction::South),
[0.0, 1.0, 1.0, 0.0, 0.0, -1.0]
);
assert_eq!(
uv(&planks, Direction::Down),
[0.0, 1.0, 1.0, 0.0, 0.0, -1.0]
);
// an upright log's side runs as stone's; on its side (axis x) the texture is turned a quarter
let log = lookup.block("oak_log").unwrap();
let log_x = lookup.block("minecraft:oak_log[axis=x]").unwrap();
assert_eq!(log.sides[Direction::North as usize][0].uv, north);
let turned = log_x.sides[Direction::North as usize][0].uv;
assert_eq!(
(turned[2], turned[5]),
(0.0, 0.0),
"u runs along y, v along x: {turned:?}"
);
assert_eq!(
log_x.sides[Direction::West as usize][0].sprite,
log.sides[Direction::Up as usize][0].sprite,
"its end faces west"
);
let leaves = lookup.block("oak_leaves").unwrap();
let leaf = leaves.sides[Direction::Up as usize][0];
assert!(leaf.sprite.cutout);
assert_eq!(leaf.tint, Some(TintSource::Foliage));
assert!(matches!(
lookup.block("spruce_leaves").unwrap().sides[0][0].tint,
Some(TintSource::Fixed(0x61_9961))
));
let water = lookup.block("water").unwrap();
let w = water.sides[Direction::Up as usize][0];
assert!(w.sprite.animated && w.translucent);
assert_eq!(w.tint, Some(TintSource::Water));
let slab = lookup.block("stone_slab").unwrap();
assert!(!slab.cube);
assert_eq!(slab.sides[Direction::Up as usize][0].inset, 0.5);
assert_eq!(
slab.sides[Direction::North as usize][0].rect,
[0.0, 0.0, 1.0, 0.5]
);
let flower = lookup.block("dandelion").unwrap();
assert!(!flower.cube && flower.sides.iter().all(Vec::is_empty));
assert!(flower.particle.is_some_and(|p| p.cutout));
assert_eq!(lookup.block("minecraft:no_such_block"), None);
assert_eq!(lookup.block("stone[no_such=1]"), None);
assert!(lookup.block("air").unwrap().sides.iter().all(Vec::is_empty));
Ok(())
}
}
@@ -140,6 +140,7 @@ mod tests {
color: None,
ambient_occlusion: None,
render_type: None,
shade: true,
},
}],
}
+19 -9
View File
@@ -14,12 +14,14 @@ pub const MIN_Y: i32 = -64;
pub const HEIGHT: i32 = 384;
pub const SECTIONS: usize = (HEIGHT / 16) as usize;
/// A level's build range and skylight, from its dimension type (`min_y`,
/// `height`, `logical_height`, `has_skylight`), as `LevelHeightAccessor` and
/// `SerializableChunkData.write` read them.
/// A level's build range, skylight and ceiling, from its dimension type
/// (`min_y`, `height`, `logical_height`, `has_skylight`, `has_ceiling`), as
/// `LevelHeightAccessor` and `SerializableChunkData.write` read them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LevelShape {
pub sky_light: bool,
/// `DimensionType.hasCeiling`: weather, spawning and maps read it.
pub has_ceiling: bool,
pub min_y: i32,
pub height: i32,
/// `DimensionType.logicalHeight`: how high portals and chorus fruit reach.
@@ -28,18 +30,21 @@ pub struct LevelShape {
impl LevelShape {
pub const OVERWORLD: Self = Self {
sky_light: true,
has_ceiling: false,
min_y: MIN_Y,
height: HEIGHT,
logical_height: HEIGHT,
};
pub const NETHER: Self = Self {
sky_light: false,
has_ceiling: true,
min_y: 0,
height: 256,
logical_height: 128,
};
pub const END: Self = Self {
sky_light: true,
has_ceiling: false,
min_y: 0,
height: 256,
logical_height: 256,
@@ -50,6 +55,7 @@ impl LevelShape {
height: i32,
logical_height: i32,
sky_light: bool,
has_ceiling: bool,
) -> Result<Self, String> {
ColumnLayout::new(min_y, height)?;
if !(0..=height).contains(&logical_height) {
@@ -59,6 +65,7 @@ impl LevelShape {
}
Ok(Self {
sky_light,
has_ceiling,
min_y,
height,
logical_height,
@@ -1019,12 +1026,15 @@ mod tests {
assert_eq!(tall.section_index(2032), None);
assert_eq!(tall.section_y(130), 2016);
assert_eq!(
LevelShape::new(-64, 384, 384, true),
LevelShape::new(-64, 384, 384, true, false),
Ok(LevelShape::OVERWORLD)
);
assert_eq!(LevelShape::new(0, 256, 128, false), Ok(LevelShape::NETHER));
assert!(LevelShape::new(0, 256, 257, true).is_err());
assert!(LevelShape::new(-60, 384, 384, true).is_err());
assert_eq!(
LevelShape::new(0, 256, 128, false, true),
Ok(LevelShape::NETHER)
);
assert!(LevelShape::new(0, 256, 257, true, false).is_err());
assert!(LevelShape::new(-60, 384, 384, true, false).is_err());
}
#[test]
@@ -1032,9 +1042,9 @@ mod tests {
for shape in [LevelShape::OVERWORLD, LevelShape::NETHER, LevelShape::END] {
assert_eq!(shape.column(), ColumnLayout::VANILLA, "{shape:?}");
}
let tall = LevelShape::new(-64, 2096, 2096, true).unwrap();
let tall = LevelShape::new(-64, 2096, 2096, true, false).unwrap();
assert_eq!(tall.column(), ColumnLayout::new(-64, 2096).unwrap());
let deep = LevelShape::new(-512, 256, 256, false).unwrap();
let deep = LevelShape::new(-512, 256, 256, false, true).unwrap();
assert_eq!(deep.column(), ColumnLayout::new(-512, 832).unwrap());
assert!(LevelShape::NETHER.has_section(15) && !LevelShape::NETHER.has_section(16));
assert!(!LevelShape::NETHER.has_section(-1));
+421 -62
View File
@@ -79,12 +79,12 @@ pub fn outline_vertices(edges: &[[f64; 6]], camera: glam::DVec3) -> Vec<[f32; 6]
.collect()
}
/// Whether the renderer should initialize its GPU occlusion pass.
/// Whether the renderer should initialize its occlusion culling pass (`cull`).
///
/// Software adapters spend more time running the culling pass than drawing
/// the sections it removes. Keep the pass available for explicit A/B checks,
/// while leaving hardware adapters on the existing default.
fn gpu_culling_requested(info: &wgpu::AdapterInfo, env: Option<&str>) -> bool {
fn occlusion_culling_requested(info: &wgpu::AdapterInfo, env: Option<&str>) -> bool {
env != Some("0") && (env.is_some() || info.device_type != wgpu::DeviceType::Cpu)
}
@@ -126,6 +126,7 @@ mod end_sky;
pub mod entity_skin;
mod flat;
mod frame_gen;
mod gpu_culling;
mod hdr;
mod hdr_icc;
mod hdr_stats;
@@ -148,6 +149,7 @@ pub mod pack_draw;
mod pack_entities;
mod pack_hand;
mod pack_images;
mod pack_lines;
mod pack_post;
mod pack_shadow;
mod pack_sky;
@@ -385,6 +387,9 @@ struct SectionGpu {
sorted_indices: Option<(u32, u64)>,
/// Its id for flat draws (`flat::Flat::add`), when they are on.
flat_id: Option<u32>,
/// Its record's id in GPU culling's table (`gpu_culling::Records`), while
/// that is on and it has solid quads.
gpu_id: Option<u32>,
/// When it began to fade in over the far terrain it replaces, which
/// stays whole behind it until then: a section new where far tiles are
/// drawn. Meshed again, it stays as it was.
@@ -435,6 +440,12 @@ impl SectionGpu {
(self.translucent > 0).then(|| (page, start, u64::from(self.translucent) * 4)),
)
}
/// Whether GPU culling keeps a record of it at `key` while it is on (`gpu_id`):
/// it has solid quads, in one of the level's `sections`, which the pass's
/// tables span.
fn gpu_recorded(&self, key: SectionKey, sections: usize) -> bool {
self.solid > 0 && (0..sections as i32).contains(&key.1)
}
}
/// Which of a mesh's ranges `move_off_page` moves: what it holds in the section
/// arena (`split`: its solid quads alone, `SectionGpu::vertex_ranges`), its
@@ -475,17 +486,23 @@ impl Part {
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
/// until the next range would pass `budget` bytes (it keeps what is left; the
/// first range moves whatever its size, so none keeps the page closed), the
/// copies into `encoder` and, in the section arena, the ranges' flat draw ids
/// (`flat`) along. False when the other pages have no room left.
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
/// section arena, GPU culling's records (`records`) along. False when the other
/// pages have no room left.
fn move_off_page<'a>(
arena: &mut arena::Arena,
encoder: &mut wgpu::CommandEncoder,
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
mut records: Option<&mut gpu_culling::Records>,
meshes: impl Iterator<Item = &'a mut SectionGpu>,
page: u32,
part: Part,
budget: &mut u64,
) -> bool {
debug_assert!(
records.is_none() || matches!(part, Part::Section { .. }),
"records follow the section arena's ranges"
);
let mut moved = false;
for s in meshes {
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
@@ -510,6 +527,12 @@ fn move_off_page<'a>(
flat.place(device, encoder, uploads, id, key, to.1, vertices);
}
part.set(s, to);
// Its solid quads start the range in either layout.
if let (Part::Section { .. }, Some(records), Some(id)) =
(part, records.as_deref_mut(), s.gpu_id)
{
records.relocate(id, to.0, to.1);
}
*budget = budget.saturating_sub(bytes);
moved = true;
}
@@ -827,6 +850,16 @@ pub struct Renderer {
cull_on: bool,
/// Solid quads facing away from the eye are left out (`mesher::Sides`).
sides_on: bool,
/// The near solid sections' draws listed on the GPU (`gpu_culling`): its pass
/// and records while it is on, where the device can (`gpu_culling_supported`),
/// and whether the last frame drew them so (`gpu_frame`).
gpu_culling: Option<gpu_culling::GpuCulling>,
gpu_culling_supported: bool,
gpu_frame: bool,
/// GPU frames since GPU culling was switched on: the first and every 64th after it
/// check its records (`check_gpu_records`).
#[cfg(debug_assertions)]
gpu_checks: u32,
sky: wgpu::RenderPipeline,
/// The main sky without the sunrise fan, which `sunrise_pipeline` draws as
/// geometry over it. The mirror keeps `sky`: its per-pixel fan is cheap at
@@ -930,6 +963,12 @@ pub struct Renderer {
sprite_split: u32,
/// A 1-layer array bound where a pack needs fewer arrays.
sprite_unused: wgpu::TextureView,
/// The sprite arrays as the terrain samples them (`SPRITE_ARRAYS` views, `sprite_unused`
/// past the ones in use, the Mipmap Levels option's levels), for native mods
/// (`WorldDrawn::block_sprites`).
sprite_views: Vec<wgpu::TextureView>,
/// Nearest, repeating, nearest mip level: for drawing a sprite's texels sharp elsewhere.
sprite_nearest: wgpu::Sampler,
mip_levels: u32,
/// The mipmap levels sampled (the Mipmap Levels option), at most `mip_levels - 1`.
mipmaps: u32,
@@ -1208,6 +1247,22 @@ fn sprite_views(
})
.collect()
}
/// The block sprites sampled sharp, for native mods (`WorldDrawn::block_sprites`): nearest,
/// repeating, the nearest mip level.
const SPRITE_NEAREST: wgpu::SamplerDescriptor<'static> = wgpu::SamplerDescriptor {
label: Some("Block sprites (nearest)"),
address_mode_u: wgpu::AddressMode::Repeat,
address_mode_v: wgpu::AddressMode::Repeat,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Nearest,
min_filter: wgpu::FilterMode::Nearest,
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
lod_min_clamp: 0.0,
lod_max_clamp: 32.0,
compare: None,
anisotropy_clamp: 1,
border_color: None,
};
/// The terrain sprites' sampler: LevelRenderer.chunkLayerSampler's linear one (clamped to the
/// edge) in every Texture Filtering mode, with `anisotropy` samples when it is above 1 (wgpu
/// then needs a linear mipmap filter too).
@@ -1429,15 +1484,18 @@ fn mip_chain(
/// The wgpu instance options, without wgpu's validation of indirect draws
/// in release builds. That validation (meant for untrusted WebGPU content)
/// copies every indirect draw through a compute pass before the render pass
/// and costs about a tenth of `render`'s CPU time on Metal. The only indirect
/// draws are the section draws `render` writes from resident sections, in
/// and costs about a tenth of `render`'s CPU time on Metal. The draws it checks
/// are the section draws `render` writes from resident sections, in
/// bounds by construction: index counts within `indices`, vertices within
/// the section's arena range, first instances below the origins written that
/// frame. Debug builds keep validating them; `WGPU_VALIDATION_INDIRECT_CALL=1`
/// turns it back on. DirectX 12 keeps it too: there wgpu-core's validation
/// pass is also what passes each draw's first instance to shaders reading
/// `instance_index`, and DirectX 12 is the fallback, where working matters
/// more than CPU time.
/// more than CPU time. GPU culling's count draws (`gpu_culling`) are written on
/// the GPU and never validated, in any build or backend: they are in bounds by
/// construction too (that module's doc), and no shader they run reads
/// `instance_index`.
/// `allow_nonconformant` offers the adapters wgpu hides for reporting no Vulkan
/// conformance version: Mesa's hasvk, which drives Ivy Bridge and Haswell,
/// reports 0.0.0.0 while it works. `open_device` asks for them only on a second
@@ -1777,12 +1835,13 @@ async fn open_backend(
// stand-in neither clamps nor drops NaN. Subgroups: shader packs' compute programs
// (`MC_GL_KHR_shader_subgroup_*`); storage buffers in vertex shaders, clearing and
// atomics on images: their buffers and images. Filtering 32-bit float textures: shader
// packs' RGBA32F buffers, which GL filters and mipmaps.
// packs' RGBA32F buffers, which GL filters and mipmaps. Count draws: GPU culling's (`gpu_culling`).
let features = timing::GpuTimer::features(&adapter)
| cull::Cull::features(&adapter)
| oit::features(&adapter)
| (adapter.features()
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
| wgpu::Features::MULTI_DRAW_INDIRECT_COUNT
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
| wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES
@@ -2651,6 +2710,7 @@ impl Renderer {
});
let sprite_views = sprite_views(&sprite_textures, &sprite_unused, None);
let sprite_sampler = device.create_sampler(&sprite_sampler_descriptor(1));
let sprite_nearest = device.create_sampler(&SPRITE_NEAREST);
let lightmap = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Lightmap"),
size: wgpu::Extent3d {
@@ -3823,6 +3883,14 @@ impl Renderer {
let batchable = translucent_batchable(&adapter.get_info(), multi_draw);
crate::graphics::offer_translucent_batching(batchable);
let translucent_batching = batchable && translucent_batching;
let gpu_culling_supported = gpu_culling::supported(
&adapter.get_info(),
device.features(),
adapter.get_downlevel_capabilities().flags,
&device.limits(),
multi_draw,
flat.as_ref().map(|f| f.min_sections),
);
// A new renderer runs no shader pack: the game loads the picked one on its
// first world frame (`shader_pack`: the picked pack draws flat, a `shaders/`
// pack or one of Horizon's own with `[shadow]`), and the frames before it, the
@@ -3859,7 +3927,7 @@ impl Renderer {
let cull_env = std::env::var("HORIZON_GPU_CULL");
let adapter_info = adapter.get_info();
let cull = cull::Cull::supported(&adapter, &device)
.filter(|_| gpu_culling_requested(&adapter_info, cull_env.as_deref().ok()))
.filter(|_| occlusion_culling_requested(&adapter_info, cull_env.as_deref().ok()))
.map(|depth_test| match cull_env.as_deref() {
Ok("shader") => cull::DepthTest::InShader,
_ => depth_test,
@@ -3895,9 +3963,14 @@ impl Renderer {
}
if previous.is_none() {
startup_step(format_args!(
"pipelines ready (multi-draw {}, occlusion culling {}, GPU timer {})",
"pipelines ready (multi-draw {}, occlusion culling {}, GPU culling {}, GPU timer {})",
on_off(multi_draw),
on_off(cull.is_some()),
if gpu_culling_supported {
"available"
} else {
"unavailable"
},
on_off(gpu_timer.is_some()),
));
}
@@ -3936,6 +4009,11 @@ impl Renderer {
flat_on: flat.is_some(),
cull_on: cull.is_some(),
sides_on: true,
gpu_culling: None,
gpu_culling_supported,
gpu_frame: false,
#[cfg(debug_assertions)]
gpu_checks: 0,
cull,
flat,
sky,
@@ -4015,12 +4093,14 @@ impl Renderer {
lightmap,
sprite_textures,
sprite_unused,
sprite_views,
mip_levels,
mipmaps: mip_levels - 1,
terrain_layout: bindings,
terrain_sprites: sprite_constants.map(|(_, v)| v),
sprite_split: split,
sprite_sampler,
sprite_nearest,
texture_filtering: 0,
max_anisotropy: 1,
lightmap_view,
@@ -4386,9 +4466,87 @@ impl Renderer {
pub fn culled_sections(&self) -> Option<([usize; 2], f32)> {
self.cull
.as_ref()
.filter(|_| self.cull_on)
.filter(|_| self.cull_on && !self.gpu_frame)
.map(|c| c.hidden)
}
/// Whether the near solid sections' draws are listed on the GPU where frames
/// allow it (`gpu_culling`).
pub fn gpu_culling(&self) -> bool {
self.gpu_culling.is_some()
}
/// Whether the last frame drew them so.
pub fn gpu_frame(&self) -> bool {
self.gpu_frame
}
/// Switches GPU culling, for A/B checks. Its pass and its records of the
/// resident sections are made when it is switched on and dropped when it is
/// switched off: nothing is kept for it while it is off.
pub fn set_gpu_culling(&mut self, on: bool) -> Result<()> {
if !on {
if self.gpu_culling.take().is_some() {
for s in self.sections.values_mut() {
s.gpu_id = None;
}
}
self.gpu_frame = false;
return Ok(());
}
if self.gpu_culling.is_some() {
return Ok(());
}
if !self.gpu_culling_supported {
return Err("GPU culling is unavailable on this device".into());
}
let Some(mut gpu) = gpu_culling::GpuCulling::new(&self.device) else {
// Not offered from now on.
self.gpu_culling_supported = false;
return Err("GPU culling could not be set up on this device".into());
};
let sections = self.layout.sections();
for (key, s) in self.sections.iter_mut() {
if s.gpu_recorded(key, sections) {
s.gpu_id = Some(gpu.records.insert(key, s.page, s.start, s.solid));
}
}
self.gpu_culling = Some(gpu);
#[cfg(debug_assertions)]
{
self.gpu_checks = 0;
}
Ok(())
}
/// Debug builds: GPU culling's records are the sections' as they are now,
/// compaction's moves included: each section it keeps a record of
/// (`SectionGpu::gpu_recorded`) has its own, no record is left without its
/// section, and each page they count is the arena's. A record left behind
/// would draw another section's quads, or from a freed page.
#[cfg(debug_assertions)]
fn check_gpu_records(&self) {
let Some(gpu) = &self.gpu_culling else {
return;
};
let (records, sections) = (&gpu.records, self.layout.sections());
let mut pages = vec![0; records.page_sections().len()];
let mut live = 0;
for (key, s) in self.sections.iter() {
let recorded = s.gpu_recorded(key, sections);
assert_eq!(s.gpu_id.is_some(), recorded, "{key:?}: a record");
if let Some(id) = s.gpu_id {
let record = gpu_culling::Record::new(key, s.page, s.start, s.solid);
assert_eq!(records.at(id), Some(&record), "{key:?}: its record {id}");
pages[s.page as usize] += 1;
live += 1;
}
}
assert_eq!(records.live(), live, "records of sections gone");
assert_eq!(records.page_sections(), pages, "records per page");
for (page, &n) in pages.iter().enumerate() {
assert!(
n == 0 || self.arena.has_page(page as u32),
"records on freed page {page}"
);
}
}
pub fn section_count(&self) -> usize {
self.sections.len()
}
@@ -4505,7 +4663,13 @@ impl Renderer {
key,
section.is_some() || (mesh.solid.is_empty() && mesh.translucent.is_empty()),
);
if let Some(section) = section {
if let Some(mut section) = section {
if let Some(gpu) = &mut self.gpu_culling
&& section.gpu_recorded(key, self.layout.sections())
{
let (page, start, quads) = (section.page, section.start, section.solid);
section.gpu_id = Some(gpu.records.insert(key, page, start, quads));
}
self.sections.insert_with_bounds(key, section, mesh.bounds);
}
}
@@ -4637,6 +4801,7 @@ impl Renderer {
),
sorted_indices,
flat_id,
gpu_id: None,
fading: None,
leaving: None,
animated_layers,
@@ -4709,6 +4874,9 @@ impl Renderer {
if let (Some(flat), Some(id)) = (&mut self.flat, s.flat_id) {
flat.remove(id);
}
if let (Some(gpu), Some(id)) = (&mut self.gpu_culling, s.gpu_id) {
gpu.records.remove(id);
}
if let Some((page, start)) = s.sorted_indices {
self.sorted_indices
.release(page, start, u64::from(s.translucent) * 6);
@@ -4755,9 +4923,11 @@ impl Renderer {
.flat
.as_mut()
.map(|f| (f, &self.device, &mut self.section_uploads));
// GPU culling's records move with the sections.
let gpu = self.gpu_culling.as_mut().map(|g| &mut g.records);
let split = self.translucent_arena.is_some();
let (arena, part) = (&mut self.arena, Part::Section { split });
if !move_off_page(arena, encoder, flat, meshes, page, part, &mut budget) {
if !move_off_page(arena, encoder, flat, gpu, meshes, page, part, &mut budget) {
// The other pages have no room after all.
arena.reopen();
}
@@ -4828,7 +4998,8 @@ impl Renderer {
_ => None,
}
.map(|f| (f, &self.device, &mut self.section_uploads));
if !move_off_page(arena, encoder, flat, meshes, page, part, &mut budget) {
// GPU culling's records follow the section arena alone.
if !move_off_page(arena, encoder, flat, None, meshes, page, part, &mut budget) {
// New ranges took the room: both pages stay, drawn, until a later try.
arena.reopen();
self.shrink_gate.wait(now, SHRINK_RETRY);
@@ -4968,6 +5139,9 @@ impl Renderer {
self.detail_revision += 1;
// A dimension change drops every page at once: freed later, off this thread.
self.graveyard.add(self.arena.clear());
if let Some(gpu) = &mut self.gpu_culling {
gpu.records.clear();
}
if let Some(arena) = &mut self.translucent_arena {
self.graveyard.add(arena.clear());
}
@@ -5184,6 +5358,10 @@ impl Renderer {
flat.remove(id);
}
}
if let Some(gpu) = &mut self.gpu_culling {
gpu.records
.remove_all(released.iter().filter_map(|s| s.gpu_id));
}
self.sorted_indices
.release_all(released.iter().filter_map(|s| {
let (page, start) = s.sorted_indices?;
@@ -5387,9 +5565,6 @@ impl Renderer {
}
/// The share of real frames frame generation shows a made frame before (1 while it
/// keeps up), `None` while it isn't running.
/// Whether the game may pose ordinary entity models on the GPU (`Frame::skin`): not
/// while a shader pack or a native pack draws entities from their vertices, nor while
/// frame generation follows them.
/// The posed models' store size (`SkinFrame::base`), with entity skinning.
pub fn skinned_models(&self) -> Option<u32> {
self.entity_skin
@@ -5397,10 +5572,16 @@ impl Renderer {
.map(entity_skin::EntitySkin::stored)
}
/// Whether the game may pose ordinary entity models on the GPU (`Frame::skin`).
/// Standard packs need every bridge stage; native packs, frame generation and
/// native observers of the CPU entity stream retain their existing fallback.
pub fn skins_entities(&self) -> bool {
self.entity_skin.is_some()
&& !self.frame_gen.on
&& self.pack_post.is_none()
&& self.pack_post.as_ref().is_none_or(|(_, post)| {
post.as_ref().is_some_and(|post| post.skins_entities())
&& !native_render::render_needs().entity_spans
})
&& self.native.is_none()
}
@@ -5410,7 +5591,7 @@ impl Renderer {
/// where they are ordinary entity quads. On, `EntityBatch` sorts them into
/// its stages by the CPU vertices' `OIT_ENTITY` tag.
pub fn skins_tagged(&self) -> bool {
self.skins_entities() && !self.improved_transparency
self.skins_entities() && !self.improved_transparency && self.pack_post.is_none()
}
pub fn frame_generation_share(&self) -> Option<f32> {
@@ -5588,6 +5769,7 @@ impl Renderer {
entities: Some(pack_entities::inputs()),
clouds: Some(pack_entities::cloud_inputs()),
weather: Some(pack_weather::inputs()),
lines: Some(pack_lines::inputs()),
hand: Some(pack_hand::inputs()),
}
}
@@ -5703,7 +5885,7 @@ impl Renderer {
self.rebuild_terrain_group();
}
fn rebuild_terrain_group(&mut self) {
let views = sprite_views(
self.sprite_views = sprite_views(
&self.sprite_textures,
&self.sprite_unused,
Some(self.mipmaps + 1),
@@ -5713,13 +5895,18 @@ impl Renderer {
"Horizon terrain resources",
&self.terrain_layout,
&self.globals,
&views,
&self.sprite_views,
&self.sprite_sampler,
&self.lightmap_view,
&self.lightmap_sampler,
&self.emission_stats.view,
);
}
/// Block sprite layers per array on this device: a sprite's array is its layer over it, its
/// layer in that array the rest (`block_sprites::Lookup`).
pub fn sprite_split(&self) -> u32 {
self.sprite_split
}
/// The Mipmap Levels option: block textures sample at most `levels`
/// reduced copies (0: none).
pub fn set_mipmap_levels(&mut self, levels: u32) {
@@ -6381,6 +6568,65 @@ impl Renderer {
self.start_occlusion(camera, frame.detail_distance)
});
}
// A shader pack's shadow pass draws the sections in its camera's box, all of them (the
// occlusion graph follows the eye, not the light), within shadowDistanceRenderMul.
let shadow_view = self
.pack_post
.as_ref()
.and_then(|p| p.1.as_ref())
.filter(|_| !frame.title)
.and_then(|p| p.shadow_view(frame))
.or_else(|| {
// One of Horizon's own packs with `[shadow]` uses the same lists.
let native = self.native.as_ref().filter(|_| !frame.title)?;
native.shadow.as_ref()?;
native.frame.shadow_view(frame)
});
let pack_terrain = !frame.title
&& self
.pack_post
.as_ref()
.and_then(|p| p.1.as_ref())
.is_some_and(|p| p.has_terrain());
// The near solid sections' draws listed on the GPU (`gpu_culling`): never where a
// shader pack's terrain, a shadow pass or the water mirror draws the CPU's lists.
// Decided before the walk, so that a pass whose buffers no longer fit can still
// switch GPU culling off.
let radius = (frame.detail_distance / 16.0) as i32;
let mut gpu_frame = self.gpu_culling.is_some()
&& !frame.title
&& !pack_terrain
&& shadow_view.is_none()
&& mirror_plane.is_none();
#[cfg(debug_assertions)]
if gpu_frame {
if self.gpu_checks.is_multiple_of(64) {
self.check_gpu_records();
}
self.gpu_checks = self.gpu_checks.wrapping_add(1);
}
if gpu_frame && let Some(gpu) = &mut self.gpu_culling {
let input = gpu_culling::FrameInputs {
camera,
min_y: self.sections.min_y(),
sections: self.layout.sections() as i32,
radius,
};
gpu_frame = crate::spans::time("gpu culling", || {
gpu.prepare(
&self.device,
&self.queue,
&mut self.uploads,
&mut encoder,
&input,
)
});
if !gpu_frame {
log::warn!("Horizon renderer: GPU culling off: its buffers do not fit");
let _ = self.set_gpu_culling(false);
}
}
self.gpu_frame = gpu_frame;
let reachable = &self.occlusion;
let cutout = &self.lod_cutout;
// Sections fading out (`leave_section`) are past the render distance:
@@ -6395,7 +6641,7 @@ impl Renderer {
crate::spans::time("visibility", || {
self.sections.visible(
camera,
(frame.detail_distance / 16.0) as i32,
radius,
&Frusta(view, mirror),
|k| !culling || reachable.contains(k),
|x, z| {
@@ -6418,20 +6664,6 @@ impl Renderer {
);
visible_lods.sort_unstable_by_key(|v| (v.distance, v.key));
}
// A shader pack's shadow pass draws the sections in its camera's box, all of them (the
// occlusion graph follows the eye, not the light), within shadowDistanceRenderMul.
let shadow_view = self
.pack_post
.as_ref()
.and_then(|p| p.1.as_ref())
.filter(|_| !frame.title)
.and_then(|p| p.shadow_view(frame))
.or_else(|| {
// One of Horizon's own packs with `[shadow]` uses the same lists.
let native = self.native.as_ref().filter(|_| !frame.title)?;
native.shadow.as_ref()?;
native.frame.shadow_view(frame)
});
let mut shadow_visible = std::mem::take(&mut self.shadow_visible);
shadow_visible.clear();
if let Some(v) = &shadow_view {
@@ -6580,12 +6812,6 @@ impl Renderer {
// the frame has draws enough goes by the last one's.
// A shader pack drawing terrain draws all of it: occlusion culling reads the depth of
// Horizon's own terrain draw.
let pack_terrain = !frame.title
&& self
.pack_post
.as_ref()
.and_then(|p| p.1.as_ref())
.is_some_and(|p| p.has_terrain());
let mut cull = self
.cull
.as_mut()
@@ -6596,6 +6822,8 @@ impl Renderer {
&& self.multi_draw
// Occlusion is found from the camera, not the mirror's eye.
&& mirror.is_none()
// It tests the CPU's lists, which leave out GPU culling's sections.
&& !gpu_frame
&& visible.len() >= c.min_sections
})
.and_then(|cull| cull.begin(&self.device).then_some(cull));
@@ -6615,8 +6843,27 @@ impl Renderer {
}
};
let mut opaque_draws = 0;
debug_assert!(
!(split_solid && gpu_frame),
"GPU culling is never offered on Metal"
);
// In a GPU frame the GPU lists the draws of the whole sections within the render
// distance that have records (`gpu_culling`). Here stay those fading in or out, whose
// instances carry their fade, and those past the distance (far terrain's cutout, and
// columns leaving).
let from = resident::camera_column(camera);
let whole_bits = section_instance([0.0; 3], 0, 1.0)[3].to_bits();
for (i, v) in visible.iter().enumerate() {
let s = self.sections.get(v.slot);
if gpu_frame
&& let Some(id) = s.gpu_id
&& self.origins[i][3].to_bits() == whole_bits
&& crate::streaming::in_view_distance(from, radius, v.key.0, v.key.2)
&& let Some(gpu) = &mut self.gpu_culling
{
gpu.choose(id);
continue;
}
let first_phase = cull.as_ref().is_none_or(|c| c.first(v.slot));
let (draws, pages) = if first_phase {
(&mut self.draws, &mut self.draw_pages)
@@ -6664,6 +6911,9 @@ impl Renderer {
c.drawn(i, v.slot, indices);
}
}
if gpu_frame && let Some(gpu) = &self.gpu_culling {
gpu.upload_choices(&mut self.uploads, &mut encoder);
}
// Phase 2's draws are tested where grouping left them.
let runs = self
.phase2_runs
@@ -6918,6 +7168,11 @@ impl Renderer {
} else {
self.section_draws
};
// GPU culling's draws: one per section, a call per page.
if gpu_frame && let Some(gpu) = &self.gpu_culling {
self.section_draws += gpu.chosen() as usize;
self.section_draw_calls += gpu.draw_calls();
}
let debug_depth = frame.debug_lines.len() as u32;
let debug_top = frame.debug_lines_on_top.len() as u32;
let crosshair = frame.debug_crosshair.len() as u32;
@@ -6978,6 +7233,17 @@ impl Renderer {
self.uploads
.buffer(&mut encoder, &self.outline, 0, bytemuck::cast_slice(&lines));
}
let skin = frame.skin.filter(|skin| match &mut self.entity_skin {
Some(skinner) => skinner.register(&self.device, &mut self.uploads, &mut encoder, skin),
None => false,
});
let pack_skin = !frame.title
&& skin.is_some()
&& self
.pack_post
.as_ref()
.and_then(|(_, post)| post.as_ref())
.is_some_and(|post| post.skins_entities());
// While a shader pack draws entities, the runs of `entity_spans` its programs draw are
// left out of Horizon's own entity draw.
let mut entities = frame.entities;
@@ -6992,6 +7258,7 @@ impl Renderer {
frame.entity_spans,
frame.crumbling_sprite,
&mut self.pack_entity_rest,
skin.filter(|_| pack_skin),
);
entities = &self.pack_entity_rest;
}
@@ -7028,11 +7295,11 @@ impl Renderer {
);
self.entity_batch.append_shadow_only(frame.shadow_only);
// The posed models' vertices come first: the ordinary entities' draw takes them in.
let skin = frame.skin.filter(|skin| match &mut self.entity_skin {
Some(skinner) => skinner.register(&self.device, &mut self.uploads, &mut encoder, skin),
None => false,
});
let posed = skin.map_or(0, |s| s.vertices);
let posed = if pack_skin {
0
} else {
skin.map_or(0, |s| s.vertices)
};
let entity_quads = entity_quads + posed / 4;
let weather_quads = frame.weather.len() as u32 / 4;
let world_quads = entity_quads
@@ -7129,15 +7396,33 @@ impl Renderer {
);
}
if let (Some(skinner), Some(skin)) = (&mut self.entity_skin, skin) {
skinner.dispatch(
&self.device,
&mut self.uploads,
&mut encoder,
skin,
&self.entity_buffer,
0,
outline_base,
);
if pack_skin {
let target = self
.pack_post
.as_ref()
.and_then(|(_, post)| post.as_ref())
.and_then(|post| post.skin_target())
.expect("GPU pack entity target");
skinner.dispatch_pack(
&self.device,
&mut self.uploads,
&mut encoder,
skin,
target,
&self.entity_buffer,
outline_base,
);
} else {
skinner.dispatch(
&self.device,
&mut self.uploads,
&mut encoder,
skin,
&self.entity_buffer,
0,
outline_base,
);
}
}
let static_sections = frame.static_block_entities;
let mut static_bytes_uploaded = 0;
@@ -7370,6 +7655,9 @@ impl Renderer {
let flat_frame = match &mut self.flat {
Some(flat)
if self.flat_on
// They copy the CPU's lists alone (GPU culling is never offered
// where vanilla frames draw flat).
&& !gpu_frame
&& !self.draws.is_empty()
&& (self.drawn_sections >= flat.min_sections
|| pack_terrain
@@ -7440,6 +7728,9 @@ impl Renderer {
if let Some(timer) = &mut self.gpu_timer {
timer.begin(&mut encoder);
}
if gpu_frame && let Some(gpu) = &self.gpu_culling {
gpu.dispatch(&mut encoder, self.own_timing.as_mut());
}
// The world's targets: the screen, or the scaled target of `post`.
let pack_post = !frame.title && self.pack_post.as_ref().is_some_and(|p| p.1.is_some());
// One of Horizon's own packs reads the same world target.
@@ -7488,6 +7779,12 @@ impl Renderer {
.as_ref()
.and_then(|p| p.1.as_ref())
.is_some_and(|p| p.has_clouds());
let pack_draws_outline = pack_post
&& self
.pack_post
.as_ref()
.and_then(|p| p.1.as_ref())
.is_some_and(|p| p.has_lines());
let pack_draws_hand = pack_post
&& self
.pack_post
@@ -8148,6 +8445,11 @@ impl Renderer {
None => {
for (pipeline, draws) in passes {
pass.set_pipeline(pipeline);
// GPU culling's draws first, all within the render distance.
if gpu_frame && let Some(gpu) = &self.gpu_culling {
gpu.draw(&mut pass, &self.arena);
pass.set_vertex_buffer(1, self.instances.slice(..));
}
self.draw_sections(
&mut pass,
&self.indirect,
@@ -8318,7 +8620,7 @@ impl Renderer {
}
pass.set_bind_group(0, &self.group, &[]);
}
if outline_edges > 0 && !frame.outline_after_terrain {
if outline_edges > 0 && !frame.outline_after_terrain && !pack_draws_outline {
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
}
if debug_depth > 0 {
@@ -8622,7 +8924,7 @@ impl Renderer {
}
// LevelRenderer.executeClassicTransparency: translucent targets'
// outlines follow terrain, so its blending cannot wash them out.
if outline_edges > 0 && frame.outline_after_terrain {
if outline_edges > 0 && frame.outline_after_terrain && !pack_draws_outline {
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
}
if translucent_crumbling_quads > 0 && !oit_on {
@@ -8780,6 +9082,11 @@ impl Renderer {
}
}
};
// Both strokes of a high contrast outline, the black one first.
post.set_lines(pack_draws_outline.then(|| pack_post::Lines {
edges: self.outline.clone(),
count: outline_edges as u32 * if frame.outline_high_contrast { 2 } else { 1 },
}));
post.set_clouds(pack_draws_clouds.then(|| pack_post::Clouds {
vertices: self.cloud_buffer.clone(),
group: self.cloud_group.clone(),
@@ -8891,6 +9198,13 @@ impl Renderer {
motion: frame.entity_motion,
atlas: self.hud.atlas_texture.as_ref(),
quad_indices: &self.indices,
block_sprites: native_render::BlockSpriteArrays {
views: &self.sprite_views,
split: self.sprite_split,
size: self.sprite_textures[0].width(),
nearest: &self.sprite_nearest,
filtered: &self.sprite_sampler,
},
});
}
let overlay = Overlay {
@@ -9863,10 +10177,10 @@ mod tests {
fn software_occlusion_culling_is_opt_in() {
let cpu = adapter(wgpu::DeviceType::Cpu);
let discrete = adapter(wgpu::DeviceType::DiscreteGpu);
assert!(!super::gpu_culling_requested(&cpu, None));
assert!(super::gpu_culling_requested(&discrete, None));
assert!(super::gpu_culling_requested(&cpu, Some("all")));
assert!(!super::gpu_culling_requested(&discrete, Some("0")));
assert!(!super::occlusion_culling_requested(&cpu, None));
assert!(super::occlusion_culling_requested(&discrete, None));
assert!(super::occlusion_culling_requested(&cpu, Some("all")));
assert!(!super::occlusion_culling_requested(&discrete, Some("0")));
}
/// Translucent Batching is offered with indirect multi-draws that aren't OpenGL's, flat
@@ -9885,6 +10199,51 @@ mod tests {
assert!(!batchable(&gl, true));
}
/// GPU culling is offered where the device draws counts the GPU wrote and runs compute
/// shaders with storage buffers enough, where Translucent Batching is (indirect
/// multi-draws that aren't OpenGL's) and vanilla frames are never drawn flat, and never
/// on Intel Gen11/Gen12's Vulkan driver, whose draws read instance-rate origins from the
/// wrong first instance, even with flat draws off there.
#[test]
fn gpu_culling_is_offered_where_counts_are_drawn_and_vanilla_never_draws_flat() {
let vulkan = adapter(wgpu::DeviceType::DiscreteGpu);
let mut gl = adapter(wgpu::DeviceType::DiscreteGpu);
gl.backend = wgpu::Backend::Gl;
let mut intel = adapter(wgpu::DeviceType::IntegratedGpu);
(intel.vendor, intel.device) = (0x8086, 0x9a49); // Tiger Lake
let counts = wgpu::Features::MULTI_DRAW_INDIRECT_COUNT;
let compute = wgpu::DownlevelFlags::COMPUTE_SHADERS;
let storage = |buffers| wgpu::Limits {
max_storage_buffers_per_shader_stage: buffers,
..Default::default()
};
let offered = |info, features, downlevel, buffers, multi_draw, flat| {
super::gpu_culling::supported(
info,
features,
downlevel,
&storage(buffers),
multi_draw,
flat,
)
};
// No flat draws, or flat draws for a shader pack's terrain alone.
assert!(offered(&vulkan, counts, compute, 8, true, None));
assert!(offered(&vulkan, counts, compute, 8, true, Some(usize::MAX)));
// Vanilla frames drawn flat past MIN_SECTIONS (Metal), or all of them.
let min = super::flat::MIN_SECTIONS;
assert!(!offered(&vulkan, counts, compute, 8, true, Some(min)));
assert!(!offered(&vulkan, counts, compute, 8, true, Some(0)));
let none = wgpu::Features::empty();
assert!(!offered(&vulkan, none, compute, 8, true, None));
let no_compute = wgpu::DownlevelFlags::empty();
assert!(!offered(&vulkan, counts, no_compute, 8, true, None));
assert!(!offered(&vulkan, counts, compute, 7, true, None));
assert!(!offered(&vulkan, counts, compute, 8, false, None));
assert!(!offered(&gl, counts, compute, 8, true, None));
assert!(!offered(&intel, counts, compute, 8, true, None));
}
/// The pack-flat rule: with Translucent Batching translucent quads have an arena of
/// their own, unless a shader pack's terrain programs or its shadow pass alone can
/// draw flat (a pack picked and not loaded yet counts as drawing terrain).
+39 -13
View File
@@ -215,12 +215,13 @@ impl Arena {
spill: bool,
) -> Option<(wgpu::Buffer, bool)> {
let usage = self.usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC;
let (label, bytes) = ("Section buffer arena", capacity * self.stride);
#[cfg(test)]
let local = (!self.video_memory_full)
.then(|| try_create(device, capacity * self.stride, usage))
.then(|| try_create(device, label, bytes, usage))
.flatten();
#[cfg(not(test))]
let local = try_create(device, capacity * self.stride, usage);
let local = try_create(device, label, bytes, usage);
let local = local.map(|buffer| (buffer, false));
local.or_else(|| {
// Mappable buffers are placed in host-visible memory, which the
@@ -232,11 +233,7 @@ impl Arena {
{
return None;
}
let buffer = try_create(
device,
capacity * self.stride,
usage | wgpu::BufferUsages::MAP_WRITE,
)?;
let buffer = try_create(device, label, bytes, usage | wgpu::BufferUsages::MAP_WRITE)?;
if !std::mem::replace(&mut self.spilled, true) {
log::warn!(
"Horizon: video memory is full; more terrain is kept in system memory \
@@ -636,10 +633,15 @@ pub fn free_later<T: Send + 'static>(items: Vec<T>) {
/// `create_buffer` with an out-of-memory failure returned instead of raised
/// (raised errors are fatal).
fn try_create(device: &wgpu::Device, size: u64, usage: wgpu::BufferUsages) -> Option<wgpu::Buffer> {
pub(super) fn try_create(
device: &wgpu::Device,
label: &str,
size: u64,
usage: wgpu::BufferUsages,
) -> Option<wgpu::Buffer> {
let scope = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Section buffer arena"),
label: Some(label),
size,
usage,
mapped_at_creation: false,
@@ -860,10 +862,11 @@ mod tests {
/// section holds in the section arena, data and all. Split, that is its
/// solid quads alone, and nothing of a section without any, whose (0, 0)
/// names no range of page 0; else solid and translucent quads together,
/// `translucent_at` along.
/// `translucent_at` along. GPU culling's records of the sections with solid
/// quads move with them.
#[test]
fn compaction_moves_what_sections_hold_in_the_section_arena() {
use super::super::{Part, SectionGpu, move_off_page};
use super::super::{Part, SectionGpu, gpu_culling, move_off_page};
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
@@ -879,6 +882,7 @@ mod tests {
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: None,
flat_id: None,
gpu_id: None,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -926,6 +930,15 @@ mod tests {
.iter()
.map(|s| (s.page, s.start, s.translucent_at))
.collect();
// The sections with solid quads have records, on page 0.
let mut records = gpu_culling::Records::default();
for (i, s) in sections.iter_mut().enumerate() {
if s.solid > 0 {
s.gpu_id = Some(records.insert((i as i32, 0, 0), s.page, s.start, s.solid));
}
}
let recorded = records.live() as u32;
assert_eq!(records.page_sections(), [recorded], "split {split}");
arena.close(0);
let mut encoder = device.create_command_encoder(&Default::default());
let meshes = sections.iter_mut();
@@ -934,6 +947,7 @@ mod tests {
&mut arena,
&mut encoder,
None,
Some(&mut records),
meshes,
0,
Part::Section { split },
@@ -943,6 +957,11 @@ mod tests {
let word =
|(page, at): (u32, u64)| read(&device, &queue, arena.buffer(page), at * stride);
for (i, s) in sections.iter().enumerate() {
// Its record names its solid quads where they are now.
let record = (s.solid > 0)
.then(|| gpu_culling::Record::new((i as i32, 0, 0), s.page, s.start, s.solid));
let held = s.gpu_id.and_then(|id| records.at(id));
assert_eq!(held, record.as_ref(), "split {split}, section {i}");
let Some((page, start, len)) = s.vertex_ranges(split).0 else {
assert_eq!(
(s.page, s.start, s.translucent_at),
@@ -961,6 +980,8 @@ mod tests {
}
}
}
// Page 0's count went to page 1 with them.
assert_eq!(records.page_sections(), [0, recorded], "split {split}");
// Page 0 empties without a range given twice, and page 1 took a grain
// per section moved: one is left.
arena.trim();
@@ -1055,6 +1076,7 @@ mod tests {
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: None,
flat_id: Some(id),
gpu_id: None,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -1065,6 +1087,7 @@ mod tests {
&mut translucent,
&mut encoder,
Some((&mut flat, &device, &mut uploads)),
None,
std::iter::once(&mut section),
0,
Part::Translucent,
@@ -1277,6 +1300,7 @@ mod tests {
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: (translucent > 0).then_some(if indices { at } else { (7, 7) }),
flat_id: None,
gpu_id: None,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -1329,7 +1353,9 @@ mod tests {
meshes: &mut [(u32, SectionGpu)],
budget: &mut u64| {
let held = meshes.iter_mut().map(|(_, s)| s);
assert!(move_off_page(arena, encoder, None, held, 0, part, budget));
assert!(move_off_page(
arena, encoder, None, None, held, 0, part, budget
));
let on = |s: &SectionGpu| part.of(s).is_some_and(|r| r.0 == 1);
meshes.iter().filter(|(_, s)| on(s)).count()
};
@@ -1389,7 +1415,7 @@ mod tests {
arena.page_limit = LARGE_PAGE_BYTES / 24 / GRAIN * GRAIN;
let (limit, step) = (arena.page_limit, PAGE_BYTES / 24);
// `oversized` looks at the counts only: a small buffer stands in.
let buffer = try_create(&device, 4, wgpu::BufferUsages::VERTEX).unwrap();
let buffer = try_create(&device, "Stand-in page", 4, wgpu::BufferUsages::VERTEX).unwrap();
let mut shrinks = |capacity: u64, used: u64, host: bool| {
let free = FreeList(BTreeMap::from([(used, capacity - used)]));
let buffer = buffer.clone();
@@ -105,6 +105,8 @@ pub struct SkinFrame {
pub new_model: Vec<SkinVertex>,
pub parts: Vec<SkinPart>,
pub jobs: Vec<SkinJob>,
/// Shader identities in the GPU output vertex space; empty without a pack.
pub spans: Vec<super::pack_draw::EntitySpan>,
/// Vertices the jobs write: a whole number of quads.
pub vertices: u32,
/// Glowing entities' silhouettes posed here (`OutlineBufferSource`'s copy of
@@ -119,6 +121,7 @@ impl SkinFrame {
pub fn clear_draws(&mut self) {
self.parts.clear();
self.jobs.clear();
self.spans.clear();
self.vertices = 0;
self.outline_jobs.clear();
self.outline_vertices = 0;
@@ -157,6 +160,7 @@ impl SkinFrame {
self.jobs.truncate(jobs);
self.parts.truncate(parts);
self.vertices = vertices;
self.spans.retain(|s| s.start <= vertices);
}
/// Whether the renderer has anything to take.
@@ -332,43 +336,102 @@ impl EntitySkin {
base: u32,
outline_base: u32,
) {
if frame.jobs.is_empty() && frame.outline_jobs.is_empty() {
self.dispatch_jobs(
device,
uploads,
encoder,
&frame.parts,
frame
.jobs
.iter()
.map(|job| SkinJob {
dst: job.dst + base,
..*job
})
.chain(frame.outline_jobs.iter().map(|job| SkinJob {
dst: job.dst + outline_base,
..*job
})),
out,
);
}
/// A standard pack owns the body output; glowing silhouettes keep the ordinary
/// entity buffer. Both routes use the same model/normal/back-face decoder.
#[allow(clippy::too_many_arguments)]
pub(super) fn dispatch_pack(
&mut self,
device: &wgpu::Device,
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
frame: &SkinFrame,
out: &wgpu::Buffer,
outline_out: &wgpu::Buffer,
outline_base: u32,
) {
self.dispatch_jobs(
device,
uploads,
encoder,
&frame.parts,
frame.jobs.iter().copied(),
out,
);
self.dispatch_jobs(
device,
uploads,
encoder,
&frame.parts,
frame.outline_jobs.iter().map(|job| SkinJob {
dst: job.dst + outline_base,
..*job
}),
outline_out,
);
}
#[allow(clippy::too_many_arguments)]
fn dispatch_jobs(
&mut self,
device: &wgpu::Device,
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
parts: &[SkinPart],
jobs: impl Iterator<Item = SkinJob>,
out: &wgpu::Buffer,
) {
self.staged.clear();
self.staged.extend(jobs);
if self.staged.is_empty() {
return;
}
let Some(model) = &self.model_buffer else {
return;
};
// Whole rows of workgroups past `ROW` jobs: the padding jobs write nothing.
let count = (frame.jobs.len() + frame.outline_jobs.len()) as u32;
let count = self.staged.len() as u32;
let rows = count.div_ceil(ROW);
let padded = if rows > 1 {
(rows * ROW) as usize
} else {
count as usize
};
self.staged.clear();
let moved = |at: u32| {
move |job: &SkinJob| SkinJob {
dst: job.dst + at,
..*job
}
};
self.staged.extend(frame.jobs.iter().map(moved(base)));
self.staged
.extend(frame.outline_jobs.iter().map(moved(outline_base)));
self.staged.resize(padded, SkinJob::default());
self.parts.ensure(
device,
(frame.parts.len() * std::mem::size_of::<SkinPart>()) as u64,
);
self.parts
.ensure(device, std::mem::size_of_val(parts) as u64);
self.jobs
.ensure(device, (padded * std::mem::size_of::<SkinJob>()) as u64);
let (Some(parts), Some(jobs)) = (&self.parts.buffer, &self.jobs.buffer) else {
let (Some(parts_buffer), Some(jobs)) = (&self.parts.buffer, &self.jobs.buffer) else {
return;
};
uploads.buffer(encoder, parts, 0, bytemuck::cast_slice(&frame.parts));
uploads.buffer(encoder, parts_buffer, 0, bytemuck::cast_slice(parts));
uploads.buffer(encoder, jobs, 0, bytemuck::cast_slice(&self.staged));
let key = [model.clone(), parts.clone(), jobs.clone(), out.clone()];
let key = [
model.clone(),
parts_buffer.clone(),
jobs.clone(),
out.clone(),
];
if self.group.as_ref().is_none_or(|(_, k)| *k != key) {
let entries: Vec<_> = key
.iter()
@@ -526,6 +589,7 @@ mod tests {
normals.z_axis.normalize_or_zero(),
);
let frame = SkinFrame {
spans: Vec::new(),
reset: false,
base: 0,
new_model: model.clone(),
@@ -686,5 +750,62 @@ mod tests {
};
assert_eq!(bytemuck::bytes_of(copy), bytemuck::bytes_of(&marked));
}
// Standard packs own the model buffer, while outlines follow unrelated CPU
// geometry in the ordinary buffer. Neither output may overwrite the other's gap.
let buffers = [frame.vertices + 3, before + frame.outline_vertices + 2].map(|vertices| {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(vertices) * 44,
usage: EntitySkin::usage(true) | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
queue.write_buffer(&buffer, 0, &vec![0xab; buffer.size() as usize]);
buffer
});
let reads = buffers.each_ref().map(|buffer| {
device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: buffer.size(),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
});
let mut uploads = Uploads::new(&device);
let mut encoder = device.create_command_encoder(&Default::default());
skin.dispatch_pack(
&device,
&mut uploads,
&mut encoder,
&frame,
&buffers[0],
&buffers[1],
before,
);
for (buffer, read) in buffers.iter().zip(&reads) {
encoder.copy_buffer_to_buffer(buffer, 0, read, 0, buffer.size());
}
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
for read in &reads {
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
}
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
for (i, (expected, start)) in [(&expected, 0usize), (&outline, before as usize)]
.into_iter()
.enumerate()
{
let data = reads[i].slice(..).get_mapped_range().unwrap();
let first = start * 44;
let last = first + expected.len() * 44;
assert!(data[..first].iter().all(|b| *b == 0xab), "prefix {i}");
assert_eq!(
&data[first..last],
bytemuck::cast_slice::<_, u8>(expected),
"output {i}"
);
assert!(data[last..].iter().all(|b| *b == 0xab), "suffix {i}");
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,203 @@
// GPU culling (gfx/gpu_culling.rs). `apply` writes the records that changed.
// `cull`: one invocation per section id keeps the sections the CPU's walk
// chose, writes each one's origin and counts it in its page, per block of 64
// ids. `scan`: one workgroup per page turns the blocks' counts into where
// each block's draws start, and the page's count. `scatter`: each block
// writes its sections' draws there, in id order, so a page's draws come in
// the same order every frame (atomics alone would order them as the GPU
// schedules, and where faces of two sections meet at the same depth the later
// draw keeps the pixel). Nothing here computes with floats: the origins are
// the CPU's bits, copied.
struct Params {
// Ids to test, records `apply` writes, the arena pages the counts and
// regions cover, and where the regions and the chosen ids' bits start in
// `tables`.
ids: u32,
updates: u32,
pages: u32,
regions: u32,
mask: u32,
pad0: u32,
pad1: u32,
pad2: u32,
// The tables' first column: x, 0, z, 0.
first: vec4<i32>,
// Their entries along x, y and z, and 0.
cells: vec4<u32>,
}
// gpu_culling.rs `Record`.
struct Section {
// Section x, y (from the level's bottom), z, and its arena page (-1: a
// free id).
key: vec4<i32>,
// The first vertex of its solid quads, its solid quads, then two words
// unused here.
a: vec4<u32>,
}
// gpu_culling.rs `Update`: the record for id `id.x`.
struct Update {
id: vec4<u32>,
section: Section,
}
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<storage, read_write> sections: array<Section>;
// gpu_culling.rs `GpuCulling::prepare`: the origins' f32 bits along x, y and
// z, each page's region of `draws` (its first draw and its size), then a bit
// per id the CPU's walk chose.
@group(0) @binding(2) var<storage, read> tables: array<u32>;
@group(0) @binding(3) var<storage, read> updates: array<Update>;
// Sections kept, draws, each page's draws, then per block of ids and page its
// draws (`cull`), turned into where they start (`scan`).
@group(0) @binding(4) var<storage, read_write> counts: array<atomic<u32>>;
// DrawIndexedIndirectArgs, five words each.
@group(0) @binding(5) var<storage, read_write> draws: array<u32>;
// Per id the instance it is drawn with: origin and UV step, as f32 bits.
@group(0) @binding(6) var<storage, read_write> origins: array<vec4<u32>>;
// Per id its page + 1 when it is drawn this frame, else 0.
@group(0) @binding(7) var<storage, read_write> runs: array<u32>;
// gfx.rs `section_instance`'s w for a whole section: 1 / mesher::UV_STEPS.
const STEP_BITS: u32 = 0x38000000u;
// Workgroups along x when the ids need more than one row of them.
const ROW: u32 = 65535u;
// Ids per block.
const BLOCK: u32 = 64u;
// Counts before the pages'.
const STATS: u32 = 2u;
// The count of `block`'s draws in `page` (`cull`), then where they start (`scan`).
fn block_count(block: u32, page: u32) -> u32 {
return STATS + params.pages + block * params.pages + page;
}
// `page`'s region of `draws`: its first draw and its size.
fn region(page: u32) -> vec2<u32> {
let at = params.regions + 2u * page;
return vec2<u32>(tables[at], tables[at + 1u]);
}
// The CPU's walk chose it (gpu_culling.rs `GpuCulling::choose`).
fn chosen(id: u32) -> bool {
return ((tables[params.mask + id / 32u] >> (id % 32u)) & 1u) != 0u;
}
// One invocation per changed record; the CPU lists each id once.
@compute @workgroup_size(64)
fn apply(@builtin(global_invocation_id) gid: vec3<u32>) {
if gid.x < params.updates {
let u = updates[gid.x];
sections[u.id.x] = u.section;
}
}
@compute @workgroup_size(64)
fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
let id = gid.y * (ROW * BLOCK) + gid.x;
if id >= params.ids {
return;
}
runs[id] = 0u;
let s = sections[id];
if s.key.w < 0 || s.a.y == 0u || !chosen(id) {
return;
}
let page = u32(s.key.w);
// The tables hold every column within the distance; a key before their
// first wraps past their end.
let c = vec3<u32>(s.key.xyz - params.first.xyz);
if page >= params.pages || any(c >= params.cells.xyz) {
return;
}
let nx = params.cells.x;
let ny = params.cells.y;
origins[id] = vec4<u32>(tables[c.x], tables[nx + c.y], tables[nx + ny + c.z], STEP_BITS);
runs[id] = page + 1u;
atomicAdd(&counts[block_count(id / BLOCK, page)], 1u);
}
var<workgroup> partial: array<u32, 256>;
// One workgroup per page: each lane sums a stretch of blocks, the lanes'
// sums are scanned, and each block's sum becomes where its draws start.
@compute @workgroup_size(256)
fn scan(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
let page = group.x;
let blocks = (params.ids + BLOCK - 1u) / BLOCK;
let stretch = (blocks + 255u) / 256u;
let first = min(lane * stretch, blocks);
let last = min(first + stretch, blocks);
var sum = 0u;
for (var b = first; b < last; b++) {
sum += atomicLoad(&counts[block_count(b, page)]);
}
partial[lane] = sum;
workgroupBarrier();
for (var step = 1u; step < 256u; step <<= 1u) {
var before = 0u;
if lane >= step {
before = partial[lane - step];
}
workgroupBarrier();
partial[lane] += before;
workgroupBarrier();
}
var start = 0u;
if lane > 0u {
start = partial[lane - 1u];
}
for (var b = first; b < last; b++) {
let count = atomicLoad(&counts[block_count(b, page)]);
atomicStore(&counts[block_count(b, page)], start);
start += count;
}
if lane == 255u {
// A region has room for every record of its page; were one short,
// its draws past it go.
let total = min(partial[255], region(page).y);
atomicStore(&counts[STATS + page], total);
atomicAdd(&counts[0], partial[255]);
atomicAdd(&counts[1], total);
}
}
var<workgroup> held: array<u32, 64>;
// One workgroup per block of ids: each section's draw goes after those of the
// block's earlier sections in its page.
@compute @workgroup_size(64)
fn scatter(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
let block = group.y * ROW + group.x;
let id = block * BLOCK + lane;
var mine = 0u;
if id < params.ids {
mine = runs[id];
}
held[lane] = mine;
workgroupBarrier();
if mine == 0u {
return;
}
let page = mine - 1u;
var slot = atomicLoad(&counts[block_count(block, page)]);
for (var other = 0u; other < lane; other++) {
if held[other] == mine {
slot += 1u;
}
}
let r = region(page);
if slot >= r.y {
return;
}
// All of its solid quads, from its first vertex, with its origin.
let s = sections[id];
let d = (r.x + slot) * 5u;
draws[d] = s.a.y * 6u;
draws[d + 1u] = 1u;
draws[d + 2u] = 0u;
draws[d + 3u] = s.a.x;
draws[d + 4u] = id;
}
@@ -82,6 +82,28 @@ pub struct WorldDrawn<'a> {
pub atlas: Option<&'a wgpu::Texture>,
/// Quad indices (0 1 2 0 2 3 for each 4 vertices), for as many quads as `entities` holds.
pub quad_indices: &'a wgpu::Buffer,
/// The block textures the terrain samples, for drawing blocks with the player's own
/// textures (`native_block_sprites` says where a block's are). API @API:native_block_sprites@.
pub block_sprites: BlockSpriteArrays<'a>,
}
/// The block sprite arrays ([`WorldDrawn::block_sprites`]): each texture a whole square layer,
/// its UVs 0 to 1 across it, as the world uses them (gamma-encoded, animated layers at the frame
/// shown).
#[non_exhaustive]
pub struct BlockSpriteArrays<'a> {
/// The arrays as `D2Array` views, terrain.wgsl's eight (`sprites`, `sprites_1`...): a 1-layer
/// stand-in past the ones in use, each with the mip levels the Mipmap Levels option leaves.
pub views: &'a [wgpu::TextureView],
/// Layers per array on this device (2048 on most; fewer where the GPU holds fewer, and
/// OpenGL pads each array past it): a sprite's `array` and `layer` are cut by it.
pub split: u32,
/// A layer's width and height in texels.
pub size: u32,
/// Nearest, repeating, the nearest mip level: texels drawn sharp.
pub nearest: &'a wgpu::Sampler,
/// The terrain's own (linear, clamped, anisotropic under that Texture Filtering).
pub filtered: &'a wgpu::Sampler,
}
/// What a mod can draw with once the first-person hand is drawn ([`native_hand_drawn`]): after
@@ -168,6 +190,189 @@ mod tests {
}
}
/// A mod samples `BlockSpriteArrays` as the renderer hands them over: a layer of the second
/// array through its view, texels sharp and repeating with the nearest sampler.
#[test]
fn a_pass_samples_a_sprite_layer_sharp() {
let instance = crate::gfx::test_gpu::instance();
let Some((device, queue)) =
pollster::block_on(instance.request_adapter(&Default::default()))
.ok()
.and_then(|adapter| {
pollster::block_on(adapter.request_device(&Default::default())).ok()
})
else {
return;
};
let gl = device.adapter_info().backend == wgpu::Backend::Gl;
// two arrays of two 2 by 2 layers; array 1's layer 1 red, green / blue, white
let layers = super::super::array_layers(2, gl);
let quad = [
[255, 0, 0, 255],
[0, 255, 0, 255],
[0, 0, 255, 255],
[255; 4],
];
let textures: Vec<wgpu::Texture> = (0..2)
.map(|array| {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 2,
height: 2,
depth_or_array_layers: layers,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let mut texels = vec![0u8; 16 * layers as usize];
if array == 1 {
texels[16..32].copy_from_slice(quad.as_flattened());
}
queue.write_texture(
texture.as_image_copy(),
&texels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(8),
rows_per_image: Some(2),
},
texture.size(),
);
texture
})
.collect();
let unused = textures[0].create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
});
let views = super::super::sprite_views(&textures, &unused, Some(1));
let nearest = device.create_sampler(&super::super::SPRITE_NEAREST);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
"@group(0) @binding(0) var sprites: texture_2d_array<f32>;
@group(0) @binding(1) var sprites_1: texture_2d_array<f32>;
@group(0) @binding(2) var nearest: sampler;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4(f32(i & 1u) * 4.0 - 1.0, f32(i >> 1u) * 4.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs(@builtin(position) at: vec4<f32>) -> @location(0) vec4<f32> {
// inside each texel, and past the edge (repeating); the last reads array 0
let uv = array(vec2(1.3, 0.2), vec2(0.7, 0.45), vec2(-0.8, 0.9), vec2(0.6, 0.6));
let x = u32(at.x);
if x == 4u {
return textureSampleLevel(sprites, nearest, vec2(0.3), 1, 0.0);
}
return textureSampleLevel(sprites_1, nearest, uv[x], 1, 0.0);
}"
.into(),
),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
}),
multiview_mask: None,
cache: None,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&views[0]),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&views[1]),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&nearest),
},
],
});
let target = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 5,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let out = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let view = target.create_view(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
encoder.copy_texture_to_buffer(
target.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &out,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
rows_per_image: None,
},
},
target.size(),
);
queue.submit([encoder.finish()]);
out.slice(..).map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let texels = out.slice(..).get_mapped_range().unwrap()[..20].to_vec();
let mut expected = quad.as_flattened().to_vec();
expected.extend([0, 0, 0, 0]);
assert_eq!(texels, expected);
}
#[test]
fn needs_accumulate_in_mod_order() {
let mut needs = RenderNeeds::default();
@@ -297,6 +297,10 @@ impl PackEntities {
(buffer, group)
}
pub fn skin_target(&self) -> &wgpu::Buffer {
&self.vertices
}
pub fn vertices(&self) -> wgpu::BufferSlice<'_> {
self.vertices.slice(..)
}
@@ -322,11 +326,32 @@ impl PackEntities {
crack: ([f32; 4], [u32; 2]),
shadow: Casters,
rest: &mut Vec<Vertex>,
skin: Option<&super::entity_skin::SkinFrame>,
) {
self.draws.clear();
self.scratch.clear();
self.uniforms.clear();
rest.clear();
let posed = skin.map_or(0, |s| s.vertices);
if let Some(skin) = skin {
for (span, range, overlay) in skin_runs(skin) {
let program = program_of(&span).expect("tagged skin stage");
let pack = drawn[program] && !span.hidden;
let casts = shadow.cast(&span, program);
if !pack && !casts {
continue;
}
self.push_draw(
span,
range.start,
range.len() as u32,
program,
pack,
casts,
overlay,
);
}
}
let first = spans.first().map_or(entities.len(), |s| s.start as usize);
rest.extend_from_slice(&entities[..first.min(entities.len())]);
for (span, range) in runs(entities.len(), spans) {
@@ -347,35 +372,24 @@ impl PackEntities {
if program == DESTROY {
project_cracks(&mut self.scratch[start as usize..], crack.0, crack.1);
}
let (entity_id, block_id) = match program {
BLOCK => (0, span.id),
_ => (span.id, 0),
};
let mut bytes = [0u8; SLOT as usize];
bytes[0..4].copy_from_slice(&(span.stage as i32).to_le_bytes());
bytes[4..8].copy_from_slice(&entity_id.to_le_bytes());
bytes[8..12].copy_from_slice(&block_id.to_le_bytes());
// The overlay: the layer's low 16 bits (HDR output's tier rides in the high ones).
for (i, c) in entity_color(run[0].layer & 0xffff).iter().enumerate() {
bytes[16 + 4 * i..20 + 4 * i].copy_from_slice(&c.to_le_bytes());
}
self.uniforms.extend_from_slice(&bytes);
self.uniforms
.resize(self.uniforms.len() + (self.stride - SLOT) as usize, 0);
self.draws.push(Draw {
base: start,
quads: run.len() as u32 / 4,
self.push_draw(
span,
posed + start,
run.len() as u32,
program,
pack,
shadow: casts,
slot: self.draws.len() as u32,
});
casts,
run[0].layer & 0xffff,
);
}
if self.draws.is_empty() {
if self.draws.is_empty() && posed == 0 {
return;
}
let vertex_size = std::mem::size_of::<Vertex>() as u64;
let (need_vertices, need_slots) = (self.scratch.len() as u64, self.draws.len() as u64);
let (need_vertices, need_slots) = (
u64::from(posed) + self.scratch.len() as u64,
self.draws.len() as u64,
);
let have_vertices = self.vertices.size() / vertex_size;
let have_slots = self.slots.size() / self.stride;
if need_vertices > have_vertices {
@@ -391,8 +405,52 @@ impl PackEntities {
self.quads = quads.next_power_of_two();
self.indices = quad_indices(device, self.quads);
}
queue.write_buffer(&self.vertices, 0, bytemuck::cast_slice(&self.scratch));
queue.write_buffer(&self.slots, 0, &self.uniforms);
if !self.scratch.is_empty() {
queue.write_buffer(
&self.vertices,
u64::from(posed) * vertex_size,
bytemuck::cast_slice(&self.scratch),
);
}
if !self.uniforms.is_empty() {
queue.write_buffer(&self.slots, 0, &self.uniforms);
}
}
#[allow(clippy::too_many_arguments)]
fn push_draw(
&mut self,
span: EntitySpan,
start: u32,
vertices: u32,
program: usize,
pack: bool,
casts: bool,
overlay: u32,
) {
let (entity_id, block_id) = match program {
BLOCK => (0, span.id),
_ => (span.id, 0),
};
let mut bytes = [0u8; SLOT as usize];
bytes[0..4].copy_from_slice(&(span.stage as i32).to_le_bytes());
bytes[4..8].copy_from_slice(&entity_id.to_le_bytes());
bytes[8..12].copy_from_slice(&block_id.to_le_bytes());
// The overlay: the layer's low 16 bits (HDR output's tier rides in the high ones).
for (i, c) in entity_color(overlay & 0xffff).iter().enumerate() {
bytes[16 + 4 * i..20 + 4 * i].copy_from_slice(&c.to_le_bytes());
}
self.uniforms.extend_from_slice(&bytes);
self.uniforms
.resize(self.uniforms.len() + (self.stride - SLOT) as usize, 0);
self.draws.push(Draw {
base: start,
quads: vertices / 4,
program,
pack,
shadow: casts,
slot: self.draws.len() as u32,
});
}
}
@@ -598,3 +656,258 @@ mod crack_projection_tests {
assert!((vertices[1].uv[1] - vertices[0].uv[1]) > 15.0 / 9216.0);
}
}
/// Intersects shader identities with overlay runs, without reading posed vertices back.
fn skin_runs(skin: &super::entity_skin::SkinFrame) -> Vec<(EntitySpan, Range<u32>, u32)> {
let mut out: Vec<(EntitySpan, Range<u32>, u32)> = Vec::new();
let mut span_index = 0;
for job in &skin.jobs {
let mut at = job.dst;
let end = at + job.count;
while at < end {
while span_index + 1 < skin.spans.len() && skin.spans[span_index + 1].start <= at {
span_index += 1;
}
let Some(&span) = skin.spans.get(span_index) else {
break;
};
let stop = end.min(skin.spans.get(span_index + 1).map_or(end, |s| s.start));
if stop <= at {
break;
}
if let Some((old, range, overlay)) = out.last_mut()
&& (EntitySpan { start: 0, ..*old }) == (EntitySpan { start: 0, ..span })
&& *overlay == job.overlay
&& range.end == at
{
range.end = stop;
} else {
out.push((span, at..stop, job.overlay));
}
at = stop;
}
}
out
}
#[cfg(test)]
mod gpu_span_tests {
use super::*;
use crate::gfx::entity_skin::{SkinFrame, SkinJob};
#[test]
fn posed_draws_preserve_subject_ids_overlays_and_hidden_shadow_casters() {
let mut skin = SkinFrame {
vertices: 24,
..Default::default()
};
skin.spans = vec![
EntitySpan {
start: 0,
stage: render_stage("ENTITIES"),
id: 40,
..Default::default()
},
EntitySpan {
start: 12,
stage: render_stage("ENTITIES"),
id: 41,
hidden: true,
..Default::default()
},
];
skin.jobs = vec![
SkinJob {
dst: 0,
count: 4,
overlay: 1,
..Default::default()
},
SkinJob {
dst: 4,
count: 4,
overlay: 1,
..Default::default()
},
SkinJob {
dst: 8,
count: 12,
overlay: 5,
..Default::default()
},
SkinJob {
dst: 20,
count: 4,
overlay: 5,
..Default::default()
},
];
let runs = skin_runs(&skin);
assert_eq!(
runs.iter()
.map(|(s, r, o)| (s.id, r.clone(), *o, s.hidden))
.collect::<Vec<_>>(),
[
(40, 0..8, 1, false),
(40, 8..12, 5, false),
(41, 12..24, 5, true)
]
);
assert_eq!(entity_color(runs[0].2)[3], 0.30196077);
assert!(
Casters {
entities: true,
..Default::default()
}
.cast(&runs[2].0, ENTITY)
);
skin.truncate((2, 0, 8));
assert_eq!(skin.vertices, 8);
assert_eq!(skin.spans.len(), 1);
}
#[test]
fn posed_and_cpu_draws_keep_offsets_uniforms_and_special_fallbacks() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let layout = super::super::pack_draw::DrawUniforms::new(&device);
let mut geometry = PackEntities::new(&device, &layout.layout);
let entity = |start, id| EntitySpan {
start,
id,
stage: render_stage("ENTITIES"),
..Default::default()
};
let mut skin = SkinFrame {
vertices: 12,
spans: vec![
entity(0, 40),
EntitySpan {
hidden: true,
..entity(4, 41)
},
EntitySpan {
start: 8,
id: 99,
stage: render_stage("BLOCK_ENTITIES"),
emissive: true,
..Default::default()
},
],
jobs: [1 | (2 << 16), 5, 0]
.into_iter()
.enumerate()
.map(|(i, overlay)| SkinJob {
dst: i as u32 * 4,
count: 4,
overlay,
..Default::default()
})
.collect(),
..Default::default()
};
let cpu = vec![
Vertex {
layer: 0,
..bytemuck::Zeroable::zeroed()
};
8
];
let spans = [entity(4, 50)];
let mut rest = Vec::new();
let shadows = Casters {
entities: true,
blocks: true,
..Default::default()
};
geometry.prepare(
&device,
&queue,
&layout.layout,
&cpu,
&spans,
[true; PROGRAMS],
([0.; 4], [1; 2]),
shadows,
&mut rest,
Some(&skin),
);
assert_eq!(rest.len(), 4, "untagged CPU prefix stays in Horizon's draw");
assert_eq!(geometry.scratch.len(), 4, "only CPU geometry is uploaded");
assert_eq!(
geometry
.draws
.iter()
.map(|d| (d.base, d.quads, d.pack, d.shadow))
.collect::<Vec<_>>(),
[
(0, 1, true, true),
(4, 1, false, true),
(8, 1, true, true),
(12, 1, true, true)
]
);
let slot = |i: usize| &geometry.uniforms[i * geometry.stride as usize..][..SLOT as usize];
let word = |bytes: &[u8], at| i32::from_le_bytes(bytes[at..at + 4].try_into().unwrap());
assert_eq!((word(slot(0), 4), word(slot(0), 8)), (40, 0));
assert_eq!((word(slot(1), 4), word(slot(1), 8)), (41, 0));
assert_eq!((word(slot(2), 4), word(slot(2), 8)), (0, 99));
assert_eq!((word(slot(3), 4), word(slot(3), 8)), (50, 0));
let hurt: &[f32] = bytemuck::cast_slice(&slot(0)[16..32]);
assert_eq!(&hurt[..3], &[1.0, 0.0, 0.0]);
assert!((hurt[3] - 77.0 / 255.0).abs() < 1e-6);
let flash: &[f32] = bytemuck::cast_slice(&slot(1)[16..32]);
assert_eq!(&flash[..3], &[1.0, 1.0, 1.0]);
assert!((flash[3] - 192.0 / 255.0).abs() < 1e-6);
assert_eq!(word(slot(2), 0), render_stage("BLOCK_ENTITIES") as i32);
let mut special = cpu.clone();
special[4..].iter_mut().for_each(|v| v.layer = 2);
geometry.prepare(
&device,
&queue,
&layout.layout,
&special,
&spans,
[true; PROGRAMS],
([0.; 4], [1; 2]),
shadows,
&mut rest,
None,
);
assert!(geometry.draws.is_empty());
assert_eq!(
bytemuck::cast_slice::<_, u8>(&rest),
bytemuck::cast_slice::<_, u8>(&special)
);
// Culling can retain hidden jobs for a pack whose entity caster is absent.
// The compute output must still fit, even though no visible/shadow draw uses it.
skin.vertices = 5000;
skin.jobs.truncate(1);
skin.jobs[0].count = 5000;
skin.spans = vec![EntitySpan {
hidden: true,
..entity(0, 60)
}];
geometry.prepare(
&device,
&queue,
&layout.layout,
&[],
&[],
[false; PROGRAMS],
([0.; 4], [1; 2]),
Casters::default(),
&mut rest,
Some(&skin),
);
assert!(geometry.draws.is_empty());
assert!(geometry.skin_target().size() >= 5000 * 44);
}
}
@@ -0,0 +1,65 @@
//! The block outline drawn by a shader pack's `gbuffers_line` (or `gbuffers_basic`, as in Iris),
//! so packs that style it (Complementary Reimagined, Super Duper Vanilla) draw it their way, and
//! packs that render the world into part of their buffers (SEUS PTGI HRR) move it with the rest.
//! The edges are the outline buffer Horizon draws itself, one instance per edge, widened to a
//! strip as `outline_clip` in terrain.wgsl does.
/// What `gbuffers_line` reads from an outline edge: its camera-relative ends, ARGB colour and
/// width in pixels. The strip's corners 0 and 1 are at the start, 2 and 3 at the end (six
/// vertices per edge: pack pipelines draw triangle lists); the
/// width is applied in screen space after the pack's transform.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
let declarations =
"layout(location = 0) in vec3 hz_l_start;\nlayout(location = 1) in vec3 hz_l_end;\n\
layout(location = 2) in vec4 hz_l_argb;\nlayout(location = 3) in float hz_l_width;\n\
int hz_l_c;\n"
.to_owned();
// Pack pipelines draw triangle lists: six vertices per edge, the strip's corners
// 0 1 2 and 2 1 3.
let body = " int hz_l_v = int(gl_VertexIndex);\n \
hz_l_c = hz_l_v < 3 ? hz_l_v : hz_l_v == 3 ? 2 : hz_l_v == 4 ? 1 : 3;\n \
hz_Vertex = vec4(hz_l_c >= 2 ? hz_l_end : hz_l_start, 1.0);\n \
hz_Color = hz_l_argb.zyxw;\n \
hz_Normal = normalize(hz_l_end - hz_l_start);\n \
hz_MultiTexCoord0 = vec4(0.0, 0.0, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(240.0, 240.0, 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n"
.to_owned();
// ARGB in a little-endian u32 reads as B, G, R, A.
let attributes = [
("vaPosition", "hz_Vertex.xyz"),
("vaColor", "hz_Color"),
("vaNormal", "hz_Normal"),
];
let after = " {\n \
vec4 hz_l_a = hz_ModelViewProjectionMatrix * vec4(hz_l_start, 1.0);\n \
vec4 hz_l_b = hz_ModelViewProjectionMatrix * vec4(hz_l_end, 1.0);\n \
vec2 hz_l_d = (hz_l_b.xy / hz_l_b.w - hz_l_a.xy / hz_l_a.w) * hz_target.xy;\n \
hz_l_d = dot(hz_l_d, hz_l_d) > 1e-12 ? normalize(hz_l_d) : vec2(1.0, 0.0);\n \
vec2 hz_l_o = vec2(-hz_l_d.y, hz_l_d.x) * hz_l_width / hz_target.xy;\n \
if (hz_l_o.x < 0.0) hz_l_o = -hz_l_o;\n \
if ((hz_l_c & 1) != 0) hz_l_o = -hz_l_o;\n \
gl_Position.xy += hz_l_o * gl_Position.w;\n }\n"
.to_owned();
horizon_shaders::GbuffersInputs {
declarations,
body,
attributes: attributes
.iter()
.map(|&(n, v)| (n.to_owned(), v.to_owned()))
.collect(),
after,
alpha_test: None,
}
}
/// The outline edges as the pack's lines program reads them: one 32-byte instance per edge.
pub fn layout() -> wgpu::VertexBufferLayout<'static> {
const ATTRIBUTES: [wgpu::VertexAttribute; 4] =
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Unorm8x4, 3 => Float32];
wgpu::VertexBufferLayout {
array_stride: 32,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &ATTRIBUTES,
}
}
+152 -39
View File
@@ -411,7 +411,7 @@ struct Targets {
color: [Option<[wgpu::TextureView; 2]>; BUFFERS],
depth: wgpu::TextureView,
depth_texture: wgpu::Texture,
/// `depthtex1`, when a program after translucent terrain reads it.
/// Pre-translucent depth for `depthtex1`, also serving `depthtex2` without a shader hand.
depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
/// `depthtex2`, when a program after the hand reads it without the hand.
depth2: Option<(wgpu::Texture, wgpu::TextureView)>,
@@ -446,6 +446,13 @@ pub(super) struct World<'a> {
pub hand: bool,
}
/// This frame's block outline, when the pack draws it: the outline buffer (one instance per
/// edge, pack_lines.rs) and the edges in it.
pub(super) struct Lines {
pub edges: wgpu::Buffer,
pub count: u32,
}
/// This frame's clouds, when the pack draws them: their vertices, uniform, quad indices and
/// quad count.
pub(super) struct Clouds {
@@ -503,6 +510,9 @@ pub struct PackPost {
entities: Option<Entities>,
/// What draws the clouds, after entities, and this frame's clouds.
clouds: Option<Pass>,
/// What draws the block outline (`gbuffers_line`, else `gbuffers_basic`), and this frame's.
lines: Option<Pass>,
line_draw: Option<Lines>,
cloud_draw: Option<Clouds>,
/// The pack has `gbuffers_clouds`, even one that writes nothing (a pack drawing its own
/// clouds discards vanilla's), or `clouds=off`: the game's clouds are not drawn.
@@ -521,7 +531,7 @@ pub struct PackPost {
/// What draws the first-person hand (`gbuffers_hand` or its fallback), after the opaque
/// world, and its vertices.
hand: Option<(Pass, super::pack_hand::PackHand)>,
/// A program after translucent terrain reads `depthtex1`: it is copied before water.
/// The pre-translucent copy: `depthtex1`, or `depthtex2` without a shader hand.
depth1: bool,
/// A program after the hand reads `depthtex2`: it is copied before the hand.
depth2: bool,
@@ -2098,6 +2108,16 @@ impl PackPost {
.clouds
.zip(cloud_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)));
let line_buffers = [Some(super::pack_lines::layout())];
let line_geometry = Geometry {
buffers: &line_buffers,
table: None,
depth_write: false,
atlas: None,
ids: None,
cull: false,
};
let lines = post.lines.and_then(|p| build(p, Some(&line_geometry)));
// Developers' aid: `HORIZON_SHADER_PACK_SKIP=composite,final` leaves programs out.
let skip = std::env::var("HORIZON_SHADER_PACK_SKIP").unwrap_or_default();
let passes: Vec<Pass> = post
@@ -2146,6 +2166,9 @@ impl PackPost {
"translucent entities",
"particles",
];
if let Some(l) = &lines {
notes.push(format!("the block outline draws with {}", l.name));
}
if let Some(c) = &clouds {
notes.push(format!("clouds draw with {}", c.name));
}
@@ -2176,20 +2199,20 @@ impl PackPost {
}
}
}
let reads_depth = |source| {
passes
.iter()
.filter(|p| matches!(p.group, Group::Deferred | Group::Composite | Group::Final))
.chain(&water)
.chain(weather.iter().take(1).flatten())
.flat_map(|p| std::iter::once(p).chain(&p.compute))
.any(|p| p.samplers.iter().any(|s| s.source == source))
};
let reads_depth2 = reads_depth(Source::Depth2);
// Without a shader hand, depth1 also serves depth2: both exclude only translucency.
let depth1 = (water.is_some() || hand.is_some())
&& passes
.iter()
.filter(|p| matches!(p.group, Group::Composite | Group::Final))
.chain(&water)
.chain(weather.iter().take(1).flatten())
.any(|p| p.samplers.iter().any(|s| s.source == Source::Depth1));
let depth2 = hand.is_some()
&& passes
.iter()
.filter(|p| matches!(p.group, Group::Composite | Group::Final))
.chain(&water)
.chain(weather.iter().take(1).flatten())
.any(|p| p.samplers.iter().any(|s| s.source == Source::Depth2));
&& (reads_depth(Source::Depth1) || (hand.is_none() && reads_depth2));
let depth2 = hand.is_some() && reads_depth2;
let every_pass = || {
(passes
.iter()
@@ -2201,6 +2224,7 @@ impl PackPost {
.chain(entities.iter().flat_map(|e| e.passes.iter().flatten()))
.chain(entities.iter().flat_map(|e| e.shadow.iter().flatten()))
.chain(&clouds)
.chain(&lines)
.chain(weather.iter().flatten())
.chain(hand.iter().map(|h| &h.0))
};
@@ -2240,6 +2264,7 @@ impl PackPost {
.chain(&terrain)
.chain(entities.iter().flat_map(|e| e.passes.iter().flatten()))
.chain(&clouds)
.chain(&lines)
.chain(&water)
.chain(weather.iter().take(1).flatten())
.chain(hand.iter().map(|h| &h.0))
@@ -2256,13 +2281,17 @@ impl PackPost {
|| sky.is_some()
|| entities.as_ref().is_some_and(Entities::drawn)
|| clouds.is_some()
|| lines.is_some()
|| weather.is_some()
|| hand.is_some()
|| passes.iter().any(|p| {
p.samplers
.iter()
.any(|s| matches!(s.source, Source::Depth | Source::Depth1 | Source::Depth2))
});
|| passes
.iter()
.flat_map(|p| std::iter::once(p).chain(&p.compute))
.any(|p| {
p.samplers.iter().any(|s| {
matches!(s.source, Source::Depth | Source::Depth1 | Source::Depth2)
})
});
// Buffers of a size of their own clear apart from the blit, which writes colortex0.
let (mut cleared, mut other_clears): (Vec<usize>, Vec<usize>) =
cleared.into_iter().partition(|&b| sizes[b].is_none());
@@ -2376,6 +2405,8 @@ impl PackPost {
entities,
clouds,
cloud_draw: None,
lines,
line_draw: None,
hides_clouds,
hides_sky,
hides_celestial,
@@ -2634,7 +2665,10 @@ impl PackPost {
Some(d)
if matches!(
pass.group,
Group::Composite | Group::Final | Group::Translucent
Group::Deferred
| Group::Composite
| Group::Final
| Group::Translucent
) =>
{
&d.1
@@ -2645,7 +2679,10 @@ impl PackPost {
Some(d)
if matches!(
pass.group,
Group::Composite | Group::Final | Group::Translucent
Group::Deferred
| Group::Composite
| Group::Final
| Group::Translucent
) =>
{
&d.1
@@ -2654,7 +2691,10 @@ impl PackPost {
Some(d)
if matches!(
pass.group,
Group::Composite | Group::Final | Group::Translucent
Group::Deferred
| Group::Composite
| Group::Final
| Group::Translucent
) =>
{
&d.1
@@ -3057,6 +3097,7 @@ impl PackPost {
spans: &[super::pack_draw::EntitySpan],
crumbling: [f32; 4],
rest: &mut Vec<super::Vertex>,
skin: Option<&super::entity_skin::SkinFrame>,
) {
let shadow = self.shadow_casters();
let Some(e) = &mut self.entities else {
@@ -3075,9 +3116,22 @@ impl PackPost {
(crumbling, self.gui_size),
shadow,
rest,
skin,
);
}
/// Every stage a posable subject can use must have a compiled fallback.
/// Special models, player and eye/tagged layers retain their CPU paths.
pub fn skins_entities(&self) -> bool {
self.entities
.as_ref()
.is_some_and(|e| e.passes[..3].iter().all(Option::is_some))
}
pub fn skin_target(&self) -> Option<&wgpu::Buffer> {
self.entities.as_ref().map(|e| e.geometry.skin_target())
}
/// Which entity runs the shadow pass draws: none without it.
pub fn shadow_casters(&self) -> super::pack_entities::Casters {
let Some(e) = self
@@ -3122,24 +3176,29 @@ impl PackPost {
}
self.draw_terrain(device, queue, encoder, size, TerrainList::Solid, terrain);
self.draw_entities(device, queue, encoder, size, false);
self.draw_lines(device, queue, encoder, size);
self.draw_clouds(device, queue, encoder, size);
if let Some(t) = &self.targets {
if let Some((copy, _)) = &t.depth1 {
encoder.copy_texture_to_texture(
t.depth_texture.as_image_copy(),
copy.as_image_copy(),
t.depth_texture.size(),
);
}
if let Some((copy, _)) = &t.depth2 {
encoder.copy_texture_to_texture(
t.depth_texture.as_image_copy(),
copy.as_image_copy(),
t.depth_texture.size(),
);
}
// Only depth2 excludes the opaque first-person hand.
if let Some(t) = &self.targets
&& let Some((copy, _)) = &t.depth2
{
encoder.copy_texture_to_texture(
t.depth_texture.as_image_copy(),
copy.as_image_copy(),
t.depth_texture.size(),
);
}
self.draw_hand(device, queue, encoder, size, frame);
// Depth1 includes that hand, but still precedes translucent terrain.
if let Some(t) = &self.targets
&& let Some((copy, _)) = &t.depth1
{
encoder.copy_texture_to_texture(
t.depth_texture.as_image_copy(),
copy.as_image_copy(),
t.depth_texture.size(),
);
}
}
/// The first-person hand and held items, with `gbuffers_hand` at the hand's projection.
@@ -3229,6 +3288,51 @@ impl PackPost {
self.clouds = Some(pass);
}
/// The block outline `set_lines` gave for this frame, with the pack's lines program.
fn draw_lines(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
size: [u32; 2],
) {
// The pass stays for later frames when this one has no outline.
let Some(lines) = self.line_draw.take() else {
return;
};
let Some(mut pass) = self.lines.take() else {
return;
};
let group = self.bind(device, queue, encoder, &mut pass, size);
{
let mut rp = self.begin(encoder, &pass);
rp.set_pipeline(pass.render());
rp.set_bind_group(0, &group, &[]);
if pass.buffers
&& let Some(g) = &self.buffers.group
{
rp.set_bind_group(4, g, &[]);
}
if pass.draw {
self.draws.bind(&mut rp, "OUTLINE");
}
rp.set_vertex_buffer(0, lines.edges.slice(..));
rp.draw(0..6, 0..lines.count);
}
self.mips_valid &= !pass.written;
self.lines = Some(pass);
}
/// Whether the pack draws the block outline itself.
pub fn has_lines(&self) -> bool {
self.lines.is_some()
}
/// The block outline to draw this frame (`None`: none).
pub fn set_lines(&mut self, lines: Option<Lines>) {
self.line_draw = lines.filter(|l| l.count > 0);
}
/// Whether the pack draws the clouds itself (or hides them).
pub fn has_clouds(&self) -> bool {
self.hides_clouds
@@ -3419,6 +3523,7 @@ impl PackPost {
.chain(sky)
.chain(entities)
.chain(&mut self.clouds)
.chain(&mut self.lines)
.chain(self.weather.iter_mut().flatten())
.chain(self.hand.iter_mut().map(|h| &mut h.0))
.chain(&mut self.shadow_terrain)
@@ -4005,11 +4110,15 @@ impl PackPost {
let mut passes = std::mem::take(&mut self.passes);
let mut seen = (false, false);
let mut shown = false;
// A water-only pack still needs the opaque phase to populate requested snapshots.
let mut terrain_drawn = self.terrain.is_none()
&& self.sky.is_none()
&& !self.entities.as_ref().is_some_and(Entities::drawn)
&& self.clouds.is_none()
&& self.hand.is_none();
&& self.lines.is_none()
&& self.hand.is_none()
&& !self.depth1
&& !self.depth2;
let late_entities = self.entities.as_ref().is_some_and(|e| {
e.passes
.iter()
@@ -4169,6 +4278,10 @@ impl PackPost {
}
}
#[cfg(test)]
#[path = "pack_post/depth_test.rs"]
mod depth_test;
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,637 @@
//! Original depth-buffer GPU regressions from public format documentation; no pack assets.
use super::*;
use crate::{gfx::Frame, hud::HudBatch};
use wgpu::{TextureFormat as T, util::DeviceExt};
const SIZE: [u32; 2] = [16, 16];
struct Gpu {
adapter: wgpu::Adapter,
device: wgpu::Device,
queue: wgpu::Queue,
}
impl Gpu {
fn new() -> Option<Self> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
required_features: adapter.features() & wgpu::Features::FLOAT32_FILTERABLE,
..Default::default()
}))
.expect("GPU device required: setup failure is not a contract pass");
eprintln!("DEPTH_FIXTURE_ADAPTER {:?}", adapter.get_info());
Some(Self {
adapter,
device,
queue,
})
}
fn texture(&self, format: T) -> wgpu::TextureView {
self.device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Original depth fixture input"),
size: wgpu::Extent3d {
width: SIZE[0],
height: SIZE[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
.create_view(&Default::default())
}
fn load(
&self,
files: Vec<(String, String)>,
world: World<'_>,
inputs: horizon_shaders::GameInputs,
) -> PackPost {
use std::io::{Cursor, Write};
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, contents) in files {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(contents.as_bytes()).unwrap();
}
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-depth-fixture",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &inputs);
assert!(
post.programs.iter().any(|p| p.name == "final"),
"{:?}",
post.notes
);
let built = PackPost::new(
&self.device,
&self.queue,
&self.adapter,
post,
T::Rgba8Unorm,
world,
);
assert!(
built.passes.iter().any(|p| p.group == Group::Final),
"{:?}",
built.notes
);
built
}
fn clear(&self, color: &wgpu::TextureView, depth: &wgpu::TextureView, reverse_depth: f32) {
let mut encoder = self.device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Independent original fixture clear"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: color,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.0625,
g: 0.0625,
b: 0.0625,
a: 1.0,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(reverse_depth),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
self.queue.submit([encoder.finish()]);
}
fn render(
&self,
post: &mut PackPost,
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
frame: &Frame<'_>,
terrain: &mut TerrainDraw<'_>,
) -> Vec<[u8; 4]> {
let screen = self.texture(T::Rgba8Unorm);
let stride = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let read = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Original fixture readback"),
size: u64::from(stride * SIZE[1]),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = self.device.create_command_encoder(&Default::default());
post.run(
&self.device,
&self.queue,
&mut encoder,
color,
depth,
&screen,
frame,
&Default::default(),
SIZE,
SIZE,
&[],
terrain,
);
encoder.copy_texture_to_buffer(
screen.texture().as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(stride),
rows_per_image: None,
},
},
screen.texture().size(),
);
self.queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
self.device
.poll(wgpu::PollType::wait_indefinitely())
.unwrap();
read.slice(..)
.get_mapped_range()
.unwrap()
.chunks_exact(stride as usize)
.flat_map(|row| {
row[..SIZE[0] as usize * 4]
.as_chunks::<4>()
.0
.iter()
.copied()
})
.collect()
}
}
fn all_pixels(pixels: &[[u8; 4]], expected: [u8; 4], name: &str) {
let wrong = pixels
.iter()
.filter(|p| p.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > 1))
.count();
eprintln!(
"DEPTH_FIXTURE_RESULT {name} expected={expected:?} actual_first={:?} wrong={wrong}/{}",
pixels[0],
pixels.len()
);
assert_eq!(wrong, 0, "{name}: independent expected pixels {expected:?}");
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Stage {
Fragment,
Deferred,
Attached,
ComputeOnly,
EarlierCompute,
}
#[derive(Clone, Copy, Debug)]
enum Scene {
World,
Terrain { hand: bool, water: bool },
WaterOnly,
}
const VERTEX: &str =
"#version 120\nvarying vec2 uv;void main(){uv=gl_MultiTexCoord0.xy;gl_Position=ftransform();}";
fn shaders(sampler: &str, stage: Stage, scene: Scene) -> Vec<(String, String)> {
let names = if sampler == "all" {
vec!["depthtex0", "depthtex1", "depthtex2"]
} else {
vec![sampler]
};
let declarations = names
.iter()
.map(|s| format!("uniform sampler2D {s};\n"))
.collect::<String>();
let sample = |s| {
if matches!(stage, Stage::Fragment | Stage::Deferred) {
format!("texture2D({s},uv).r")
} else {
format!("texelFetch({s},p,0).r")
}
};
let result = if sampler == "all" {
format!(
"vec4({},{},{},1.0)",
sample(names[0]),
sample(names[1]),
sample(names[2])
)
} else {
format!("vec4({},0.25,0.5,1.0)", sample(names[0]))
};
let mut files = vec![
("final.vsh".into(), VERTEX.into()),
(
"shaders.properties".into(),
"blend.gbuffers_terrain=off\nblend.gbuffers_water=off\nblend.gbuffers_hand=off\n"
.into(),
),
];
if stage == Stage::Fragment {
files.push((
"final.fsh".into(),
format!(
"#version 120\n{declarations}varying vec2 uv;void main(){{gl_FragColor={result};}}"
),
));
} else if stage == Stage::Deferred {
files.push(("final.fsh".into(), "#version 120\nuniform sampler2D colortex1;varying vec2 uv;void main(){gl_FragColor=texture2D(colortex1,uv);}".into()));
files.push(("deferred.vsh".into(), VERTEX.into()));
files.push(("deferred.fsh".into(), format!("#version 120\n/* DRAWBUFFERS:1 */\n{declarations}varying vec2 uv;void main(){{gl_FragData[0]={result};}}")));
} else {
files.push(("final.fsh".into(),"#version 120\nuniform sampler2D colortex1;varying vec2 uv;void main(){gl_FragColor=texture2D(colortex1,uv);}".into()));
files.push(("composite.csh".into(),format!("#version 430\nlayout(local_size_x=1,local_size_y=1)in;const ivec3 workGroups=ivec3(16,16,1);layout(rgba8) writeonly uniform image2D colorimg1;\n{declarations}\nvoid main(){{ivec2 p=ivec2(gl_GlobalInvocationID.xy);imageStore(colorimg1,p,{result});}}")));
match stage {
Stage::Attached => {
files.push(("composite.vsh".into(),VERTEX.into()));
files.push(("composite.fsh".into(),"#version 120\n/* DRAWBUFFERS:0 */\nvoid main(){gl_FragData[0]=vec4(0.0);}".into()));
}
Stage::EarlierCompute => files.push(("composite_a.csh".into(),"#version 430\nlayout(local_size_x=1)in;const ivec3 workGroups=ivec3(1);layout(rgba8) writeonly uniform image2D colorimg2;void main(){imageStore(colorimg2,ivec2(0),vec4(0.125));}".into())),
_ => {}
}
}
if !matches!(scene, Scene::World) {
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
for (name, clip) in [
("gbuffers_terrain", 0.4),
("gbuffers_water", -0.2),
("gbuffers_hand", -0.6),
] {
if name == "gbuffers_hand" && !hand {
continue;
}
if name == "gbuffers_terrain" && matches!(scene, Scene::WaterOnly) {
continue;
}
files.push((
format!("{name}.vsh"),
format!("#version 120\nvoid main(){{gl_Position=vec4(gl_Vertex.xy,{clip},1.0);}}"),
));
files.push((format!("{name}.fsh"),"#version 120\n/* DRAWBUFFERS:0 */\nvoid main(){gl_FragData[0]=vec4(0.0,0.0,0.0,1.0);}".into()));
}
}
files
}
fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
let Some(gpu) = Gpu::new() else {
eprintln!("DEPTH_FIXTURE_NO_ADAPTER");
return;
};
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
// A seeded world depth of reverse-Z .3 is GL window depth .7.
gpu.clear(&color, &depth, 0.3);
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
let layouts = [Some(wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &ATTRS,
})];
let terrain = !matches!(scene, Scene::World);
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
let inputs=horizon_shaders::GameInputs {
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None }),
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
};
let mut post = gpu.load(
shaders(sampler, stage, scene),
World {
atlas: None,
lightmap: color.clone(),
terrain: terrain.then_some(Geometry {
buffers: &layouts,
table: None,
depth_write: true,
atlas: None,
ids: None,
cull: false,
}),
block_ids: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand,
},
inputs,
);
if terrain {
assert!(
post.terrain.is_some() != matches!(scene, Scene::WaterOnly) && post.water.is_some(),
"{:?}",
post.notes
);
}
if hand {
assert!(post.hand.is_some(), "{:?}", post.notes);
}
if stage == Stage::Deferred {
assert!(
post.passes.iter().any(|p| p.group == Group::Deferred),
"{:?}",
post.notes
);
} else if stage != Stage::Fragment {
let count = post
.passes
.iter()
.flat_map(|p| std::iter::once(p).chain(&p.compute))
.filter(|p| p.name.starts_with("composite"))
.count();
let expected = if matches!(stage, Stage::Attached | Stage::EarlierCompute) {
2
} else {
1
};
assert_eq!(
count, expected,
"all original compute/draw stages must build: {:?}",
post.notes
);
}
let positions: [[f32; 3]; 6] = [
[-1., -1., 0.],
[1., -1., 0.],
[1., 1., 0.],
[-1., -1., 0.],
[1., 1., 0.],
[-1., 1., 0.],
];
let vertices = gpu
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Original fixed depth geometry"),
contents: bytemuck::cast_slice(&positions),
usage: wgpu::BufferUsages::VERTEX,
});
let quad = [[-1., -1., 0.], [1., -1., 0.], [1., 1., 0.], [-1., 1., 0.]].map(|pos| {
crate::mesher::Vertex {
pos,
uv: [0.; 2],
layer: 0,
color: u32::MAX,
light: 0,
normal: [0, 0, 127, 0],
glint_uv: [0.; 2],
}
});
let hud = HudBatch::new(SIZE);
let mut frame = fixture_frame(&hud);
if hand {
frame.hand = &quad;
}
let actual = gpu.render(&mut post, &color, &depth, &frame, &mut |rp, list, _, _| {
if list == TerrainList::Solid || (water && list == TerrainList::Translucent) {
rp.set_vertex_buffer(0, vertices.slice(..));
rp.draw(0..6, 0..1);
}
});
all_pixels(&actual, expected, &format!("{sampler} {stage:?} {scene:?}"));
}
#[test]
fn hand_is_in_depth1_and_excluded_from_depth2() {
check(
"all",
Stage::Fragment,
Scene::Terrain {
hand: true,
water: true,
},
[51, 51, 179, 255],
);
}
#[test]
fn depth2_only_without_hand_excludes_water() {
check(
"depthtex2",
Stage::Fragment,
Scene::Terrain {
hand: false,
water: true,
},
[179, 64, 128, 255],
);
}
#[test]
fn depth1_only_without_hand_excludes_water_control() {
check(
"depthtex1",
Stage::Fragment,
Scene::Terrain {
hand: false,
water: true,
},
[179, 64, 128, 255],
);
}
#[test]
fn water_only_program_populates_both_opaque_snapshots() {
check(
"all",
Stage::Fragment,
Scene::WaterOnly,
[102, 179, 179, 255],
);
}
#[test]
fn deferred_depth2_only_excludes_hand() {
check(
"depthtex2",
Stage::Deferred,
Scene::Terrain {
hand: true,
water: false,
},
[179, 64, 128, 255],
);
}
#[test]
fn deferred_depth1_contains_hand_control() {
check(
"depthtex1",
Stage::Deferred,
Scene::Terrain {
hand: true,
water: false,
},
[51, 64, 128, 255],
);
}
#[test]
fn direct_fragment_gets_world_depth_control() {
check(
"depthtex0",
Stage::Fragment,
Scene::World,
[179, 64, 128, 255],
);
}
#[test]
fn attached_compute_gets_world_depth() {
check(
"depthtex0",
Stage::Attached,
Scene::World,
[179, 64, 128, 255],
);
}
#[test]
fn compute_only_gets_world_depth_control() {
check(
"depthtex0",
Stage::ComputeOnly,
Scene::World,
[179, 64, 128, 255],
);
}
#[test]
fn earlier_compute_child_gets_world_depth() {
check(
"depthtex0",
Stage::EarlierCompute,
Scene::World,
[179, 64, 128, 255],
);
}
#[test]
fn attached_compute_depth1_without_hand_excludes_water() {
check(
"depthtex1",
Stage::Attached,
Scene::Terrain {
hand: false,
water: true,
},
[179, 64, 128, 255],
);
}
#[test]
fn attached_compute_depth2_without_hand_excludes_water() {
check(
"depthtex2",
Stage::Attached,
Scene::Terrain {
hand: false,
water: true,
},
[179, 64, 128, 255],
);
}
#[test]
fn attached_compute_preserves_distinct_hand_snapshots() {
check(
"all",
Stage::Attached,
Scene::Terrain {
hand: true,
water: true,
},
[51, 51, 179, 255],
);
}
// Frame literal adapted from MIT Horizon f700231 pack_uniforms.rs test API setup.
fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
Frame {
eye: glam::DVec3::ZERO,
eye_rotation: [0.0; 2],
first_person: true,
shadow_scene_revision: (0, 0),
shadow_cache: false,
water_plane: None,
weather: &[],
weather_time: crate::weather::Time::new(0, 0.0),
camera: glam::DVec3::new(0.5, 100.0, 0.5),
yaw: 0.0,
pitch: 0.0,
fov_degrees: 90.0,
bob: glam::Mat4::IDENTITY,
spin: glam::Mat4::IDENTITY,
sky: crate::environment::Sky::default(),
distance: 12.0,
detail_distance: 12.0,
depth_far: 1000.0,
underwater: false,
cave_culling: false,
title: true,
sky_blocked: false,
skybox: false,
end_sky: false,
end_sky_sprite: None,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
dark_disc: false,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
hud,
glint_alpha: 0.75,
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
static_block_entities: &[],
visible_static_block_entity_sections: &[],
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
entity_motion: &[],
colored_lights: &[],
emissive_layers: &[],
shadow_only: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
clouds: None,
lightning: Default::default(),
additive: &[],
see_through: &[],
celestial: &[],
stars: None,
hand: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
border: None,
game_time: 0.0,
shader_game_time: 0.0,
end_portal_sprites: [[0.0; 4]; 2],
crumbling_sprite: [0.0; 4],
ticks: 0,
darkness: (0.0, 0.0),
night_vision: 0.0,
block_flicker: 0.0,
boss_darken: 0.0,
menu_blur: None,
chunk_fade: 0.0,
post_effects: crate::post_chain::Request::none(),
}
}
@@ -83,6 +83,15 @@ impl<T> Resident<T> {
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut T> {
self.items.iter_mut().map(|(_, v)| v)
}
/// Every section's key and value.
#[cfg(debug_assertions)]
pub fn iter(&self) -> impl Iterator<Item = (SectionKey, &T)> {
self.items.iter().map(|(key, value)| (*key, value))
}
/// Every section's key and value.
pub fn iter_mut(&mut self) -> impl Iterator<Item = (SectionKey, &mut T)> {
self.items.iter_mut().map(|(key, value)| (*key, value))
}
/// `key`'s value, when it is resident.
pub fn find(&self, key: SectionKey) -> Option<&T> {
self.slots.get(&key).map(|&slot| self.get(slot))
@@ -102,6 +111,10 @@ impl<T> Resident<T> {
pub fn set_min_y(&mut self, min_y: i32) {
self.min_y = min_y;
}
/// The minimum Y of section index 0.
pub fn min_y(&self) -> i32 {
self.min_y
}
pub fn clear(&mut self) {
self.items.clear();
self.bounds.clear();
@@ -223,7 +236,7 @@ pub struct Visible {
}
/// The camera-relative origin of the section at `key`.
fn origin(key: SectionKey, min_y: i32, camera: DVec3) -> DVec3 {
pub(super) fn origin(key: SectionKey, min_y: i32, camera: DVec3) -> DVec3 {
DVec3::new(
f64::from(key.0 * 16),
f64::from(min_y + key.1 * 16),
@@ -232,7 +245,7 @@ fn origin(key: SectionKey, min_y: i32, camera: DVec3) -> DVec3 {
}
/// The camera's chunk column.
fn camera_column(camera: DVec3) -> (i32, i32) {
pub(super) fn camera_column(camera: DVec3) -> (i32, i32) {
(
(camera.x.floor() as i32) >> 4,
(camera.z.floor() as i32) >> 4,
@@ -384,7 +397,7 @@ impl<T> Resident<T> {
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
#[test]
fn column_walk_finds_exactly_the_sections_in_view() {
@@ -574,6 +587,19 @@ mod tests {
model.insert(key, step);
}
check(&r, &model);
// `iter_mut` yields every key once, with its value.
let mut seen = HashSet::new();
for (key, value) in r.iter_mut() {
assert_eq!(*value, model[&key], "{key:?}");
assert!(seen.insert(key), "{key:?} twice");
}
assert_eq!(seen.len(), model.len());
}
for (_, value) in r.iter_mut() {
*value += 1;
}
for (key, value) in &model {
assert_eq!(r.find(*key), Some(&(value + 1)));
}
r.clear();
assert_eq!(r.len(), 0);
+1
View File
@@ -13,6 +13,7 @@ pub mod block_entities;
pub mod block_entity_anim;
pub mod block_entity_cem;
pub mod block_entity_render;
pub mod block_sprites;
pub mod bluemap;
pub mod bluemap_detect;
pub mod border_render;
+35 -1
View File
@@ -483,6 +483,17 @@ fn visibility_rows(open: &[u16; 256]) -> u64 {
mod particle_tests;
#[cfg(test)]
mod visibility_tests;
/// A face's tint source as the game colours it (`BlockColors.createDefault`), for drawing a block
/// elsewhere ([`crate::block_sprites`]): the biome's grass, foliage, dry foliage or water colour
/// where the block is, or a fixed colour (0xRRGGBB).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TintSource {
Grass,
Foliage,
DryFoliage,
Water,
Fixed(u32),
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Tint {
None,
@@ -801,6 +812,29 @@ impl MeshTables {
Tint::Stem(age) => stem_color(age),
})
}
/// What tint source `index` (a face's `tintindex`) of `state` colours a face with in the
/// world, for drawing the block elsewhere ([`crate::block_sprites`]): `None` for an untinted
/// face. Grass blocks, tall grass and sugar cane are `Grass`; redstone and stems are their
/// colour at the state's power and age.
pub fn tint_source(&self, state: BlockStateId, index: i32) -> Option<TintSource> {
let info = self.info.get(state.0 as usize)?;
if index < 0 || index >= i32::from(info.tint_count) {
return None;
}
Some(match info.tints[index as usize] {
Tint::None => return None,
Tint::Grass | Tint::GrassBlock | Tint::TallGrassUpper | Tint::SugarCane => {
TintSource::Grass
}
Tint::Foliage => TintSource::Foliage,
Tint::DryFoliage => TintSource::DryFoliage,
Tint::Water => TintSource::Water,
Tint::LilyPad => TintSource::Fixed(0x20_8030),
Tint::Constant(c) => TintSource::Fixed(c),
Tint::Redstone(p) => TintSource::Fixed(redstone_color(p)),
Tint::Stem(age) => TintSource::Fixed(stem_color(age)),
})
}
/// `TerrainParticle`'s colour: tint source 0's `colorAsTerrainParticle`,
/// which is the in-world (biome-blended) colour except on grass blocks,
/// whose dirt crumbs stay untinted. `None` when the state has no source.
@@ -1591,7 +1625,7 @@ impl Mesher<'_> {
None => ao,
};
let shade_face = q.shade_direction_override.unwrap_or(q.facing);
let directional = if self.shader_pack_lighting {
let directional = if self.shader_pack_lighting || !q.shade {
1.0
} else {
lighting::cardinal(self.nether_shading, shade_face)
@@ -1,17 +1,51 @@
//! Parsed resource models for one startup or resource-pack reload.
use crate::Result;
use horizon_assets::{model::CuboidModel, resolve::ModelLibrary};
use std::{collections::BTreeMap, fs, path::Path, sync::Arc};
use horizon_assets::{
model::CuboidModel,
resolve::{AssetReader, ModelLibrary},
};
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
sync::Arc,
};
#[derive(Default)]
pub struct ParsedModels {
models: BTreeMap<String, Arc<CuboidModel>>,
root: PathBuf,
}
/// Asset files under a content root, for models that name a file (obj).
struct RootAssets(PathBuf);
impl AssetReader for RootAssets {
fn read_text(&self, location: &str) -> Option<String> {
let (namespace, path) = location.split_once(':')?;
// Only a resource location's characters, and no `.`/`..` step: an
// .obj's `mtllib` names its .mtl as raw text, so it must not reach a
// file outside the content root (`../..:x` as a namespace would).
let valid = |c: u8| c.is_ascii_lowercase() || c.is_ascii_digit() || b"_.-".contains(&c);
if !namespace.bytes().all(valid)
|| matches!(namespace, "" | "." | "..")
|| !path.bytes().all(|c| valid(c) || c == b'/')
|| path.split('/').any(|part| matches!(part, "" | "." | ".."))
{
return None;
}
horizon_core::prepared_files::read_to_string(
&self.0.join(format!("assets/{namespace}/{path}")),
)
.ok()
}
}
impl ParsedModels {
/// Every `models/**.json` of a namespace directory, by identifier.
pub fn load(root: &Path) -> Self {
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
return Self::default();
return Self {
root: root.to_owned(),
..Self::default()
};
};
// Listed first, then read and parsed at once: a content pack has tens
// of thousands of models.
@@ -52,7 +86,10 @@ impl ParsedModels {
.zip(parsed)
.filter_map(|((id, _), model)| Some((id, model?)))
.collect();
Self { models }
Self {
models,
root: root.to_owned(),
}
}
/// Blocks require every referenced resource, including parents. Items instead
@@ -105,16 +142,15 @@ impl ParsedModels {
}
}
};
if let Some(parent) = &model.parent {
pending.push(parent.clone());
}
pending.extend(model.referenced_models());
models.insert(name, model);
}
Ok(ModelLibrary::from_shared(models)?)
Ok(ModelLibrary::from_shared(models)?.with_assets(Arc::new(RootAssets(root.to_owned()))))
}
pub fn into_item_library(self) -> std::result::Result<Arc<ModelLibrary>, String> {
let root = self.root;
ModelLibrary::with_shared_item_generator(self.models)
.map(Arc::new)
.map(|library| Arc::new(library.with_assets(Arc::new(RootAssets(root)))))
.map_err(|e| format!("item models: {e:?}"))
}
}
@@ -235,4 +271,31 @@ mod tests {
assert!(parsed.into_item_library().is_err());
Ok(())
}
/// An .obj's `mtllib` is raw text: no namespace or path step reaches a
/// file outside the content root.
#[test]
fn root_assets_stay_inside_the_content_root() {
let root = std::env::temp_dir().join(format!("horizon-root-assets-{}", std::process::id()));
let inside = root.join("content");
fs::create_dir_all(inside.join("assets/m/models")).unwrap();
fs::write(inside.join("assets/m/models/a.mtl"), "newmtl a").unwrap();
fs::write(root.join("secret.mtl"), "newmtl secret").unwrap();
let assets = RootAssets(inside);
assert_eq!(
assets.read_text("m:models/a.mtl").as_deref(),
Some("newmtl a")
);
for escape in [
"../..:secret.mtl",
"..:../secret.mtl",
"m:../../../secret.mtl",
"m:models/./a.mtl",
"M:models/a.mtl",
":models/a.mtl",
] {
assert_eq!(assets.read_text(escape), None, "{escape}");
}
fs::remove_dir_all(&root).unwrap();
}
}
+187 -14
View File
@@ -2426,7 +2426,7 @@ struct Ranges<'a> {
radius: i32,
extra: &'a [((i32, i32), i32)],
forced: &'a HashSet<(i32, i32)>,
/// `extra` as a grid, when one was made for it: `in_extra` without a
/// `extra` indexed by a grid or buckets: coverage queries without a
/// scan of every area.
cover: Option<&'a ExtraCover>,
band: Option<&'a Band>,
@@ -2481,8 +2481,22 @@ impl Band {
/// nearest one's square (Chebyshev distance less its radius; 0 inside). A server has an area per player, and streaming asks of every
/// loaded and wanted column whether one reaches it, several times a tick:
/// with fifty players, a scan of fifty squares per question.
///
/// Areas spread too far apart for that grid (players thousands of blocks from
/// each other) are found by the square of [`Self::BUCKET`] columns their centre
/// is in instead, which also answers which centre is nearest a column
/// (`Ranges::distance`, asked of every wanted column to rank it).
struct ExtraCover {
extra: Vec<((i32, i32), i32)>,
grid: Option<CoverGrid>,
/// The indices in `extra` of the areas centred in each bucket.
buckets: FxHashMap<(i32, i32), Vec<u32>>,
/// The buckets' bounding box, inclusive: (x0, z0, x1, z1).
bucket_box: (i32, i32, i32, i32),
/// The largest radius in `extra`.
radius: i32,
}
struct CoverGrid {
x0: i32,
z0: i32,
width: u32,
@@ -2495,14 +2509,18 @@ impl ExtraCover {
const REACH: i32 = 4;
/// The largest grid, in columns (a mebibyte).
const CELLS: u64 = 1 << 20;
/// The side of a bucket, in columns.
const BUCKET: i32 = 64;
/// The farthest centre kept in buckets: far enough for any world border.
const LIMIT: i32 = 1 << 26;
fn matching<'a>(
cover: &'a Option<ExtraCover>,
extra: &[((i32, i32), i32)],
) -> Option<&'a ExtraCover> {
cover.as_ref().filter(|c| c.extra == extra)
}
/// Make the grid again if `extra` changed; none for one area or none
/// (a scan is as quick), or areas too far apart.
/// Rebuild the coverage index if `extra` changed; none for one area or none
/// (a scan is as quick).
fn refresh(cover: &mut Option<ExtraCover>, extra: &[((i32, i32), i32)]) {
if cover.as_ref().is_some_and(|c| c.extra == extra) {
return;
@@ -2510,27 +2528,134 @@ impl ExtraCover {
*cover = Self::new(extra);
}
fn new(extra: &[((i32, i32), i32)]) -> Option<Self> {
if extra.len() < 2 || extra.iter().any(|&(_, r)| !(0..=64).contains(&r)) {
if extra.len() < 2
|| extra.iter().any(|&((x, z), r)| {
!(0..=64).contains(&r) || x.abs() > Self::LIMIT || z.abs() > Self::LIMIT
})
{
return None;
}
let k = i64::from(Self::REACH);
let mut buckets: FxHashMap<(i32, i32), Vec<u32>> = FxHashMap::default();
let mut bucket_box = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
for (i, &((x, z), _)) in extra.iter().enumerate() {
let key = (x.div_euclid(Self::BUCKET), z.div_euclid(Self::BUCKET));
buckets.entry(key).or_default().push(i as u32);
bucket_box = (
bucket_box.0.min(key.0),
bucket_box.1.min(key.1),
bucket_box.2.max(key.0),
bucket_box.3.max(key.1),
);
}
Some(Self {
extra: extra.to_vec(),
grid: CoverGrid::new(extra),
buckets,
bucket_box,
radius: extra.iter().map(|&(_, r)| r).max().unwrap_or(0),
})
}
/// Whether (x, z) is within `margin` of an area's square, for a
/// `margin` up to `REACH`.
fn within(&self, x: i32, z: i32, margin: i32) -> bool {
if let Some(grid) = &self.grid {
return grid.within(x, z, margin);
}
let reach = self.radius + margin;
let (kx0, kx1) = (
(x.saturating_sub(reach)).div_euclid(Self::BUCKET),
(x.saturating_add(reach)).div_euclid(Self::BUCKET),
);
let (kz0, kz1) = (
(z.saturating_sub(reach)).div_euclid(Self::BUCKET),
(z.saturating_add(reach)).div_euclid(Self::BUCKET),
);
(kz0..=kz1).any(|kz| {
(kx0..=kx1).any(|kx| {
self.buckets.get(&(kx, kz)).is_some_and(|areas| {
areas.iter().any(|&i| {
let ((cx, cz), r) = self.extra[i as usize];
(i64::from(x) - i64::from(cx))
.abs()
.max((i64::from(z) - i64::from(cz)).abs())
<= i64::from(r + margin)
})
})
})
})
}
/// The squared distance from (x, z) to the nearest area's centre.
/// Buckets are searched ring by ring outwards: once the nearest found is
/// no further than the next ring can be, the search stops.
fn nearest_sq(&self, x: i32, z: i32) -> i64 {
let d = |i: u32| {
let (c, _) = self.extra[i as usize];
let (dx, dz) = (i64::from(x) - i64::from(c.0), i64::from(z) - i64::from(c.1));
dx * dx + dz * dz
};
let (qx, qz) = (x.div_euclid(Self::BUCKET), z.div_euclid(Self::BUCKET));
let (x0, z0, x1, z1) = self.bucket_box;
let last = (qx - x0)
.abs()
.max((qx - x1).abs())
.max((qz - z0).abs())
.max((qz - z1).abs());
let mut best = i64::MAX;
let mut looked = 0usize;
for ring in 0..=last {
let mut look = |kx: i32, kz: i32| {
looked += 1;
if let Some(areas) = self.buckets.get(&(kx, kz)) {
best = areas.iter().map(|&i| d(i)).fold(best, i64::min);
}
};
if ring == 0 {
look(qx, qz);
} else {
for k in -ring..=ring {
look(qx + k, qz - ring);
look(qx + k, qz + ring);
}
for k in 1 - ring..ring {
look(qx - ring, qz + k);
look(qx + ring, qz + k);
}
}
// Every centre in a further ring is more than `ring` buckets away
// on one axis.
let bound = i64::from(ring) * i64::from(Self::BUCKET);
if best <= bound * bound {
break;
}
// Far from every area: a scan costs less than more empty rings.
if looked > self.extra.len() * 4 {
return (0..self.extra.len() as u32).map(d).fold(best, i64::min);
}
}
best
}
}
impl CoverGrid {
/// None for areas too far apart for [`ExtraCover::CELLS`].
fn new(extra: &[((i32, i32), i32)]) -> Option<Self> {
let k = i64::from(ExtraCover::REACH);
let (mut x0, mut z0, mut x1, mut z1) = (i64::MAX, i64::MAX, i64::MIN, i64::MIN);
for &((x, z), r) in extra {
let (x, z, r) = (i64::from(x), i64::from(z), i64::from(r) + k);
(x0, z0, x1, z1) = (x0.min(x - r), z0.min(z - r), x1.max(x + r), z1.max(z + r));
}
let (width, depth) = ((x1 - x0 + 1) as u64, (z1 - z0 + 1) as u64);
let fits = |lo: i64, hi: i64| lo >= i64::from(i32::MIN) && hi <= i64::from(i32::MAX);
if width * depth > Self::CELLS || !fits(x0, x1) || !fits(z0, z1) {
if width * depth > ExtraCover::CELLS {
return None;
}
let (x0, z0, width, depth) = (x0 as i32, z0 as i32, width as u32, depth as u32);
let mut cells = vec![(Self::REACH + 1) as i8; (width * depth) as usize].into_boxed_slice();
let mut cells =
vec![(ExtraCover::REACH + 1) as i8; (width * depth) as usize].into_boxed_slice();
// Inside a square counts as 0: no margin asked for is below it. Each
// row of a square is a run of one value with a slope at either end.
let lower = |run: &mut [i8], v: i8| run.iter_mut().for_each(|c| *c = (*c).min(v));
for &((cx, cz), r) in extra {
let reach = r + Self::REACH;
let reach = r + ExtraCover::REACH;
for dz in -reach..=reach {
let start = (cz + dz - z0) as usize * width as usize + (cx - x0) as usize;
let row = &mut cells[start - reach as usize..=start + reach as usize];
@@ -2548,7 +2673,6 @@ impl ExtraCover {
}
}
Some(Self {
extra: extra.to_vec(),
x0,
z0,
width,
@@ -2556,8 +2680,6 @@ impl ExtraCover {
cells,
})
}
/// Whether (x, z) is within `margin` of an area's square, for a
/// `margin` up to `REACH`.
fn within(&self, x: i32, z: i32, margin: i32) -> bool {
let (dx, dz) = (
x.wrapping_sub(self.x0) as u32,
@@ -2582,6 +2704,9 @@ impl Ranges<'_> {
let (dx, dz) = (i64::from(x - c.0), i64::from(z - c.1));
dx * dx + dz * dz
};
if let Some(cover) = self.cover {
return d(self.center).min(cover.nearest_sq(x, z));
}
self.extra
.iter()
.map(|(c, _)| d(*c))
@@ -3960,14 +4085,62 @@ mod cover_tests {
}
}
/// Players thousands of blocks apart: no grid fits them, and the buckets
/// answer what a scan of every area does, the nearest centre included.
#[test]
fn the_buckets_answer_as_the_scan() {
let mut seed = 0x51_7CC1_B727_220Au64;
let forced: HashSet<(i32, i32)> = HashSet::new();
for case in 0..12 {
// Spread over 6000 columns (96 000 blocks), some side by side.
let mut extra = areas(&mut seed, 2 + case * 9, 3000);
extra.push((extra[0].0, 10));
extra.push(((extra[1].0.0 + 5, extra[1].0.1 - 70), 4));
let cover = ExtraCover::new(&extra).expect("buckets");
let ranges = |cover| Ranges {
center: (3, -2),
radius: 10,
extra: &extra,
forced: &forced,
cover,
band: None,
};
let (scan, buckets) = (ranges(None), ranges(Some(&cover)));
// Around each area, and on a coarse lattice over the whole spread.
let near = extra.iter().flat_map(|&((cx, cz), _)| {
(-80..=80)
.step_by(7)
.flat_map(move |dz| (-80..=80).step_by(7).map(move |dx| (cx + dx, cz + dz)))
});
let lattice = (-3200..=3200)
.step_by(97)
.flat_map(|z| (-3200..=3200).step_by(89).map(move |x| (x, z)));
for (x, z) in near.chain(lattice) {
assert_eq!(
scan.distance(x, z),
buckets.distance(x, z),
"case {case} ({x}, {z})"
);
for m in 0..=ExtraCover::REACH {
assert_eq!(
scan.in_extra(x, z, m),
buckets.in_extra(x, z, m),
"case {case} ({x}, {z}) {m}"
);
}
}
}
}
#[test]
fn a_stale_or_impossible_grid_is_not_used() {
let mut cover = None;
ExtraCover::refresh(&mut cover, &[((0, 0), 11), ((5, 5), 11)]);
assert!(ExtraCover::matching(&cover, &[((0, 0), 11), ((5, 5), 11)]).is_some());
assert!(ExtraCover::matching(&cover, &[((0, 0), 11), ((5, 6), 11)]).is_none());
// Too far apart for a mebibyte of grid, and one area alone: scans.
assert!(ExtraCover::new(&[((0, 0), 11), ((100_000, 0), 11)]).is_none());
// Too far apart for a mebibyte of grid: buckets alone. One area alone: scans.
let far = ExtraCover::new(&[((0, 0), 11), ((100_000, 0), 11)]).expect("buckets");
assert!(far.grid.is_none());
assert!(ExtraCover::new(&[((0, 0), 11)]).is_none());
assert!(ExtraCover::new(&[((i32::MAX - 1, 0), 11), ((i32::MAX - 3, 0), 11)]).is_none());
assert!(ExtraCover::new(&[((0, i32::MAX - 1), 11), ((0, i32::MAX - 3), 11)]).is_none());
@@ -14,19 +14,9 @@
"horizon.options.on_screen_keyboard": "Clavier à l'écran",
"horizon.options.chat_delay": "Délai du tchat",
"horizon.options.lightbar.health": "Santé",
"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": "Pointeur",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Cette fonction n'est pas encore disponible dans Horizon.",
"horizon.options.search": "Rechercher une option...",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Packs de shaders",
"horizon.pages.shader_options": "Options des shaders",
"horizon.pages.controller": "Options de la manette",
@@ -48,8 +38,6 @@
"horizon.pages.open.graphics_advanced": "Graphismes avancés...",
"horizon.pages.open.shader_packs": "Packs de shaders...",
"horizon.pages.open.controller": "Options de la manette...",
"horizon.pages.open.extras": "Extras...",
"horizon.pages.open.mods": "Mods...",
"horizon.pages.open.agent": "Agent IA...",
"horizon.pages.open.horizon_graphics": "Graphismes Horizon...",
"horizon.pages.open.controller_binds": "Boutons manette...",
@@ -61,7 +49,6 @@
"horizon.mods.settings": "Options",
"horizon.mods.open_folder": "Ouvrir le dossier des mods",
"horizon.shader_packs.empty": "Aucun pack de shaders : placez-les dans le dossier shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.shader_options.open": "Options des shaders...",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Lecture des options du pack de shaders...",
@@ -87,7 +74,6 @@
"Worker Threads": "Threads",
"Resolution Scale": "Échelle de résolution",
"Anti-aliasing": "Anticrénelage",
"Upscaling": "Upscaling",
"Paper White": "Point blanc",
"Peak Brightness": "Luminosité max.",
"Black Level": "Niveau de noir",
@@ -100,29 +86,16 @@
"On": "Oui",
"Off": "Non",
"Unlimited": "Illimité",
"Auto": "Auto",
"Custom": "Personnalisé",
"Performance": "Performances",
"Balanced": "Équilibré",
"Quality": "Qualité",
"Ultra": "Ultra",
"Ultra Performance": "Ultra perfs",
"Native": "Native",
"Full": "Complet",
"Reduced": "Réduit",
"Efficient": "Économe",
"Precise": "Précis",
"Auto (VSync)": "Auto (VSync)",
"FIFO": "FIFO",
"Mailbox": "Mailbox",
"Immediate": "Immediate",
"Vulkan": "Vulkan",
"DirectX 12": "DirectX 12",
"OpenGL": "OpenGL",
"FPS and Graph": "FPS et graphique",
"FPS": "FPS",
"TAA": "TAA",
"FXAA": "FXAA",
"No cost": "Aucun coût",
"Low cost": "Coût faible",
"Medium cost": "Coût moyen",
@@ -130,18 +103,15 @@
"Saves time": "Fait gagner du temps",
"Display": "Affichage",
"Overlay": "Surimpression",
"HDR": "HDR",
"Graphics": "Graphismes",
"Done": "Terminé",
"This feature is not yet available in Horizon.": "Cette fonction n'est pas encore disponible dans Horizon.",
"Your display or system doesn't offer HDR output.": "Votre écran ou votre système ne propose pas de sortie HDR.",
"Turn HDR on to use it.": "Activez le HDR pour l'utiliser.",
"Your GPU or graphics API doesn't support it.": "Votre GPU ou votre API graphique ne le prend pas en charge.",
"%s chunks": "%s chunks",
"%s per frame": "%s par image",
"%s MiB/frame": "%s MiB/image",
"%s KiB/frame": "%s KiB/image",
"Auto (%s)": "Auto (%s)",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Terrain en détail complet. La distance LOD va plus loin.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Terrain lointain simplifié, mondes locaux. GPU intégrés : 128 max.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Réduit : terrain lointain simplifié, plus rapide sur ce GPU.",
@@ -215,7 +185,6 @@
"horizon.extras.sort_key": "Touche de tri",
"horizon.extras.no_ai": "Sans IA",
"horizon.extras.agent_control": "Contrôle par agent",
"horizon.extras.discord_presence": "Discord Rich Presence",
"horizon.extras.fullbright.tooltip": "Affiche les blocs et les entités en pleine lumière, même dans les zones sombres.",
"horizon.extras.dynamic_lights.tooltip": "Les torches et autres sources de lumière tenues en main, jetées ou en feu éclairent les blocs autour d'elles.",
"horizon.extras.own_name_tag.tooltip": "Affiche votre nom au-dessus de votre tête à la troisième personne, comme les autres joueurs le voient. Masqué quand le serveur masque les noms de votre équipe ou que vous êtes invisible.",
@@ -323,7 +292,6 @@
"horizon.splitscreen.setup.identify": "Identifier",
"horizon.splitscreen.setup.layout.two": "2 par fenêtre : %s",
"horizon.splitscreen.setup.layout.three": "3 par fenêtre : %s",
"horizon.splitscreen.setup.layout.two.0": "Auto",
"horizon.splitscreen.setup.layout.two.1": "Côte à côte",
"horizon.splitscreen.setup.layout.two.2": "Haut et bas",
"horizon.splitscreen.setup.layout.three.0": "Un large en haut",
@@ -392,7 +360,6 @@
"horizon.extras.chat_animation.tooltip": "Une nouvelle ligne de chat glisse depuis la gauche en apparaissant progressivement, au lieu d'apparaître d'un coup.",
"horizon.extras.chat_indicator": "Marqueur de message",
"horizon.extras.chat_indicator.tooltip": "La barre colorée à gauche d'une ligne de chat.",
"horizon.extras.header.chat": "Chat",
"horizon.extras.header.particles": "Particules",
"horizon.extras.header.sound": "Son",
"horizon.extras.hide_block_particles": "Masquer particules",
@@ -404,5 +371,59 @@
"horizon.extras.cosmetics_mine": "Mes cosmétiques",
"horizon.extras.cosmetics_mine.tooltip": "Affiche les cosmétiques que vous portez sur votre joueur, à la troisième personne et dans l'inventaire.",
"horizon.extras.cosmetics_others": "Cosmétiques des autres",
"horizon.extras.cosmetics_others.tooltip": "Affiche les cosmétiques que portent les autres joueurs Horizon. Désactivé, le jeu ne demande jamais ce qu'ils portent."
"horizon.extras.cosmetics_others.tooltip": "Affiche les cosmétiques que portent les autres joueurs Horizon. Désactivé, le jeu ne demande jamais ce qu'ils portent.",
"horizon.split.leave": "Quitter",
"Upscaling": "Mise à l'échelle",
"Native": "Natif",
"Immediate": "Immédiat",
"%s chunks": "%s tronçons",
"General": "Général",
"Advanced": "Avancé",
"Undo": "Annuler",
"Search options": "Rechercher des options",
"Shortcut": "Raccourci",
"Sky and weather": "Ciel et météo",
"Lighting and entities": "Éclairage et entités",
"Chunk loading": "Chargement des tronçons",
"Power": "Alimentation",
"Frames": "Images",
"Output": "Sortie",
"Brightness": "Luminosité",
"How far you see, the window and the HUD.": "Votre distance de vision, la fenêtre et le HUD.",
"The sky and weather, lighting, textures and the image.": "Le ciel, la météo, l'éclairage, les textures et l'image.",
"Chunk loading, culling and power use.": "Chargement des tronçons, occlusion et consommation d'énergie.",
"How frames reach the screen, and the performance overlay.": "Comment les images parviennent à l'écran, et superposition de performance.",
"Brighter-than-white output on an HDR display.": "Sortie plus lumineuse que le blanc sur un écran HDR.",
"Results for \"%s\"": "Résultats pour \"%s\"",
"No settings match.": "Aucun paramètre ne correspond.",
"horizon.extras.discord_presence": "Présence Discord",
"horizon.extras.wheel_cycles_settings": "La molette modifie les paramètres",
"horizon.extras.wheel_cycles_settings.tooltip": "La molette de défilement sur un bouton des paramètres modifie sa valeur, comme dans le jeu d'origine. Désactivé, la molette fait seulement défiler la liste.",
"Block Entities": "Entités de bloc",
"Shulker Boxes": "Boîtes de shulker",
"Reuses settled boxes; lid movement stays animated.": "Réutilise les boîtes immobiles ; le mouvement du couvercle reste animé.",
"Signs": "Panneaux",
"Reuses sign text until text, light, or font changes.": "Réutilise le texte des panneaux jusqu'à ce que le texte, la lumière ou la police change.",
"Decorated Pots": "Pots décorés",
"Reuses still pots; break wobble and sherd changes redraw.": "Réutilise les pots immobiles ; le vacillement de casse et les changements de tessons redessinent l'objet.",
"Bells": "Cloches",
"Reuses still bells; ringing stays animated.": "Réutilise les cloches immobiles ; la sonnerie reste animée.",
"Chests": "Coffres",
"Reuses closed chests; moving lids stay animated.": "Réutilise les coffres fermés ; les couvercles mobiles restent animés.",
"Banners": "Bannières",
"Reuses poles; flags and pattern layers still update.": "Réutilise les mâts ; les drapeaux et calques de motifs continuent de se mettre à jour.",
"Copper Golem Statues": "Statues de golem de cuivre",
"Reuses statue geometry until its block state changes.": "Réutilise la géométrie de la statue jusqu'à ce que son état de bloc change.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Masque les modèles derrière les blocs opaques. Les entités se mettent à jour. Désactivé avec les packs personnalisés.",
"horizon.extras.repro_keys": "Raccourcis d'enregistrement",
"horizon.extras.repro_keys.tooltip": "F3+R démarre ou arrête un enregistrement de reproduction et F3+E écrit son archive. Désactivé par défaut : un enregistrement copie le monde.",
"horizon.agent.record_session": "Démarrer la session d'enregistrement",
"horizon.agent.record_stop": "Arrêter la session d'enregistrement",
"horizon.agent.export_repro": "Exporter l'archive de reproduction",
"horizon.repro.started": "Enregistrement démarré ; F3+R l'arrête et écrit l'archive.",
"horizon.repro.stopped": "Enregistrement arrêté : %s",
"horizon.repro.bundle": "Archive de reproduction : %s",
"horizon.repro.error": "Reproduction : %s",
"horizon.repro.no_recording": "Il n'y a pas encore d'enregistrement à exporter.",
"horizon.repro.replay_started": "Lecture démarrée : %s"
}
@@ -0,0 +1,447 @@
{
"horizon.options.controller": "कंट्रोलर",
"horizon.options.vibration": "वाइब्रेशन",
"horizon.options.adaptive_triggers": "अडाप्टिव ट्रिगर्स",
"horizon.options.lightbar": "लाइटबार",
"horizon.options.look_sensitivity": "लुक सेंसिटिविटी",
"horizon.options.invert_x": "लुक एक्स इनवर्ट करें",
"horizon.options.invert_y": "लुक वाई इनवर्ट करें",
"horizon.options.sneak_button": "स्नीक बटन",
"horizon.options.move_deadzone": "मूव डेडज़ोन",
"horizon.options.look_deadzone": "लुक डेडज़ोन",
"horizon.options.button_glyphs": "बटन ग्लाइफ्स",
"horizon.options.menu_navigation": "मेनू नेविगेशन",
"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": "एक्सबॉक्स",
"horizon.options.glyphs.playstation": "प्लेस्टेशन",
"horizon.options.glyphs.nintendo": "निनटेंडो",
"horizon.options.menu_focus": "फोकस",
"horizon.options.menu_pointer": "पॉइंटर",
"horizon.options.keyboard.auto": "ऑटो",
"horizon.options.keyboard.horizon": "क्षितिज",
"horizon.options.keyboard.steam": "स्टीम",
"horizon.options.unavailable": "यह सुविधा अभी क्षितिज में उपलब्ध नहीं है।",
"horizon.options.search": "सेटिंग्स खोजें...",
"horizon.pages.mods": "मॉड्स",
"horizon.pages.shader_packs": "शेडर पैक्स",
"horizon.pages.shader_options": "शेडर विकल्प",
"horizon.pages.controller": "कंट्रोलर सेटिंग्स",
"horizon.pages.controller_binds": "कंट्रोलर बाइंड्स",
"horizon.pages.extras": "क्षितिज एक्स्ट्राज़",
"horizon.pages.settings": "क्षितिज सेटिंग्स",
"horizon.pages.agent": "AI एजेंट",
"horizon.split.leave": "छोड़ें",
"horizon.pages.split_settings": "स्प्लिटस्क्रीन सेटिंग्स",
"horizon.pages.split_profiles": "कौन खेल रहा है?",
"horizon.pages.split_setup": "स्प्लिटस्क्रीन सेटअप",
"horizon.pages.settings.tooltip": "क्षितिज की अपनी सेटिंग्स: ग्राफिक्स, कंट्रोलर्स, एक्स्ट्राज़ और मॉड्स।",
"horizon.pages.graphics.tooltip": "ग्राफिक्स, सरल शब्दों में: प्रत्येक सेटिंग अपनी लागत बताती है।",
"horizon.pages.graphics_advanced.tooltip": "हर ग्राफिक्स सेटिंग, क्षितिज की सहित, प्रत्येक की लागत के साथ।",
"horizon.pages.controller.tooltip": "कंट्रोलर्स: उनके बटन, स्टिक्स, वाइब्रेशन और मेनू नेविगेशन।",
"horizon.pages.extras.tooltip": "वैकल्पिक चीज़ें, डिफ़ॉल्ट रूप से सभी बंद: जब तक आप किसी को चालू नहीं करते, गेम वैिला रहता है।",
"horizon.pages.agent.tooltip": "अपने खुद के AI एजेंट को खेलने दें: इस कंप्यूटर पर horizon-ctl और MCP क्लाइंट्स।",
"horizon.pages.split_profiles.tooltip": "कौन खेलता है: इस गेम फ़ोल्डर में रखा गया एक प्रोफ़ाइल।",
"horizon.pages.split_setup.tooltip": "विंडोज़ और मॉनिटर, और कौन कहाँ खेलता है।",
"horizon.pages.shader_packs.tooltip": "shaderpacks फ़ोल्डर से शेडर पैक्स: क्षितिज के अपने और shaders/ फ़ॉर्मेट वाले। एक बार में एक चलता है।",
"horizon.pages.mods.tooltip": "क्षितिज के लिए बनाए गए मॉड्स, horizon-mods फ़ोल्डर से। प्रत्येक सैंडबॉक्स में चलता है और चालू करने तक बंद रहता है।",
"horizon.pages.open.graphics": "ग्राफिक्स...",
"horizon.pages.open.graphics_advanced": "उन्नत ग्राफिक्स...",
"horizon.pages.open.shader_packs": "शेडर पैक्स...",
"horizon.pages.open.controller": "कंट्रोलर सेटिंग्स...",
"horizon.pages.open.split_setup": "स्प्लिटस्क्रीन...",
"horizon.pages.open.extras": "एक्स्ट्राज़...",
"horizon.pages.open.mods": "मॉड्स...",
"horizon.pages.open.agent": "AI एजेंट...",
"horizon.pages.open.horizon_graphics": "क्षितिज ग्राफिक्स...",
"horizon.pages.open.controller_binds": "कंट्रोलर बाइंड्स...",
"horizon.pages.open.horizon_settings": "क्षितिज सेटिंग्स...",
"horizon.mods.empty": "कोई मॉड नहीं: horizon-mods फ़ोल्डर में .hmod फ़ाइलें रखें",
"horizon.mods.stopped": " (रुका हुआ)",
"horizon.mods.not_built": " (निर्मित नहीं)",
"horizon.mods.dev": " (देव)",
"horizon.mods.settings": "सेटिंग्स",
"horizon.mods.open_folder": "मॉड फ़ोल्डर खोलें",
"horizon.shader_packs.empty": "कोई शेडर पैक नहीं: उन्हें shaderpacks फ़ोल्डर में रखें",
"horizon.shader_packs.kind_horizon": " (क्षितिज)",
"horizon.shader_options.open": "शेडर विकल्प...",
"horizon.shader_options.profile": "प्रोफ़ाइल",
"horizon.shader_options.loading": "शेडर पैक के विकल्प पढ़े जा रहे हैं...",
"horizon.agent.copy_details": "कनेक्शन विवरण कॉपी करें",
"horizon.multiplayer.join": "शामिल हों",
"horizon.multiplayer.cancel_reconnecting": "पुनः कनेक्ट करना रद्द करें",
"horizon.multiplayer.join_friend": "मित्र से जुड़ें",
"horizon.multiplayer.invite_code": "आमंत्रण कोड",
"horizon.multiplayer.relay": "रिले (वैकल्पिक)",
"Preset": "प्रीसेट",
"LOD Distance": "LOD दूरी",
"Far Detail": "दूर के विवरण",
"Cave Culling": "गुफा कलिंग",
"Entity Culling": "एंटिटी कलिंग",
"Chunk Updates": "चंक् अपडेट्स",
"Upload Budget": "अपलोड बजट",
"Sky": "आकाश",
"Fog": "कोहरा",
"Weather": "मौसम",
"Animated Textures": "एनिमेटेड टेक्सचर्स",
"Present Mode": "प्रेज़ेंट मोड",
"Frame Pacing": "फ्रेम पेसिंग",
"Worker Threads": "वर्कर थ्रेड्स",
"Resolution Scale": "रिज़ॉल्यूशन स्केल",
"Anti-aliasing": "एंटी-एलियासिंग",
"Upscaling": "अपस्केलिंग",
"Paper White": "पेपर व्हाइट",
"Peak Brightness": "शिखर चमक",
"Black Level": "ब्लैक लेवल",
"UI Brightness": "UI चमक",
"Battery Mode": "बैटरी मोड",
"Graphics API": "ग्राफिक्स API",
"Translucent Batching": "पारभासी बैचिंग",
"Performance Overlay": "प्रदर्शन ओवरले",
"Full Detail Distance": "पूर्ण विवरण दूरी",
"On": "चालू",
"Off": "बंद",
"Unlimited": "असीमित",
"Auto": "ऑटो",
"Custom": "कस्टम",
"Performance": "प्रदर्शन",
"Balanced": "संतुलित",
"Quality": "गुणवत्ता",
"Ultra": "अल्ट्रा",
"Ultra Performance": "अल्ट्रा प्रदर्शन",
"Native": "मूल",
"Full": "पूर्ण",
"Reduced": "कम किया गया",
"Efficient": "दक्ष",
"Precise": "सटीक",
"Auto (VSync)": "ऑटो (VSync)",
"Mailbox": "मेलबॉक्स",
"Immediate": "तत्काल",
"FPS and Graph": "FPS और ग्राफ",
"No cost": "कोई लागत नहीं",
"Low cost": "कम लागत",
"Medium cost": "मध्यम लागत",
"High cost": "उच्च लागत",
"Saves time": "समय बचाता है",
"Graphics": "ग्राफिक्स",
"Done": "हो गया",
"This feature is not yet available in Horizon.": "यह सुविधा अभी क्षितिज में उपलब्ध नहीं है।",
"Your display or system doesn't offer HDR output.": "आपका डिस्प्ले या सिस्टम HDR आउटपुट प्रदान नहीं करता है।",
"Turn HDR on to use it.": "इसे उपयोग करने के लिए HDR चालू करें।",
"Your GPU or graphics API doesn't support it.": "आपका GPU या ग्राफिक्स API इसे सपोर्ट नहीं करता है।",
"%s chunks": "%s चंक्स",
"%s per frame": "प्रति फ्रेम %s",
"%s MiB/frame": "%s MiB/फ्रेम",
"%s KiB/frame": "%s KiB/फ्रेम",
"Auto (%s)": "ऑटो (%s)",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "पूर्ण-विवरण भू-भाग सीमा। LOD दूरी स्थानीय दुनिया को और आगे बढ़ा सकती है।",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "स्थानीय दुनिया में मोटा दूर का भू-भाग। एकीकृत GPU 128 चंक्स पर रुक जाते हैं।",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "घटाया गया मोड दूर के भू-भाग को मोटा बनाता है: इस एकीकृत GPU पर तेज़।",
"Reduced draws far terrain coarser, for GPUs it slows down.": "घटाया गया मोड दूर के भू-भाग को मोटा बनाता है, कुछ GPU के लिए यह धीमा हो जाता है।",
"Field of view. Wider shows more terrain at once.": "फ़ील्ड ऑफ़ व्यू। चौड़ा होने पर एक साथ अधिक भू-भाग दिखाई देता है।",
"Caps frames per second; a cap saves power and heat.": "प्रति सेकंड फ्रेम सीमा तय करता है; एक सीमा बिजली और गर्मी बचाती है।",
"Waits for the display's refresh: no tearing, a little latency.": "डिस्प्ले के रिफ्रेश का इंतज़ार करता है: कोई टियरिंग नहीं, थोड़ी सी लेटेंसी।",
"Size of menus and the HUD.": "मेनू और HUD का आकार।",
"Sets the options below together. Changing one makes it Custom.": "नीचे दिए गए विकल्पों को एक साथ सेट करता है। किसी एक को बदलने से यह कस्टम हो जाता है।",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "लावा, आग और धूप सफेद से अधिक चमकीले चमकते हैं। HDR डिस्प्ले की आवश्यकता है।",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "आपके डिस्प्ले का सबसे गहरा काला। OLED के लिए 0; LCD थोड़ा चमकते हैं।",
"How bright highlights get. Auto: the peak your system reports.": "हाइटलाइट्स कितने चमकीले होते हैं। ऑटो: आपका सिस्टम जो शिखर रिपोर्ट करता है।",
"How bright white paper and menus look. Auto: your desktop's white.": "सफेद पेपर और मेनू कितने चमकीले दिखते हैं। ऑटो: आपके डेस्कटॉप का सफेद।",
"How bright the HUD and menus are, against paper white.": "पेपर व्हाइट की तुलना में HUD और मेनू कितने चमकीले हैं।",
"How far mobs, crops and machines keep working. CPU time.": "मॉब्स, फसलें और मशीनें कितनी दूर तक काम करती रहती हैं। CPU समय।",
"How far away mobs and items are drawn, as a share of normal.": "सामान्य के हिस्से के रूप में, मॉब्स और आइटम कितनी दूर खींचे जाते हैं।",
"Skips terrain hidden behind closed sections. Off costs a lot.": "बंद खंडों के पीछे छिपा भू-भाग छोड़ देता है। बंद करने पर अधिक लागत आती है।",
"Hides entities behind opaque blocks.": "अपारदर्शी ब्लॉकों के पीछे की एंटिटीज़ को छुपाता है।",
"No entity occlusion culling.": "कोई एंटिटी ऑक्लूजन कलिंग नहीं।",
"CPU occlusion test runs this frame.": "इस फ्रेम CPU ऑक्लूजन टेस्ट चलता है।",
"Occlusion checks run on a worker thread.": "ऑक्लूजन जाँच एक वर्कर थ्रेड पर चलती है।",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "प्रति फ्रेम अपलोड किए गए मेश्ड खंड। लोडिंग के दौरान कम मान अधिक सुचारू होता है।",
"Soft shadows in corners. Costs chunk building time only.": "कोनों में सॉफ्ट शैडो। इसमें केवल चंक निर्माण का समय लगता है।",
"Smaller copies of block textures for distant terrain: less shimmer.": "दूर के भू-भाग के लिए ब्लॉक टेक्सचर की छोटी प्रतियां: कम शिमर।",
"Blends grass and water colours across biomes when building chunks.": "चंक बनाते समय बायोम्स में घास और पानी के रंगों को मिलाता है।",
"Cloud layer: off, flat (fast) or 3D (fancy).": "क्लाउड लेयर: बंद, फ्लैट (तेज़) या 3D (फैंसी)।",
"How far clouds reach, in chunks.": "बादल कितनी दूर तक पहुँचते हैं, चंक्स में।",
"Draws the sky, sun, moon and stars.": "आकाश, सूर्य, चंद्रमा और तारे खींचता है।",
"Distance fog. Off shows the edge of the loaded world.": "दूरी का कोहरा। बंद होने पर लोड की गई दुनिया का किनारा दिखता है।",
"Draws rain and snow.": "बारिश और बर्फ खींचता है।",
"How far rain and snow fall around you.": "आपके आस-पास बारिश और बर्फ कितनी दूर तक गिरती है।",
"How many particles effects spawn.": "कितने पार्टिकल इफेक्ट्स उत्पन्न होते हैं।",
"Round shadows under mobs and items.": "मॉब्स और आइटम्स के नीचे गोल परछाइयाँ।",
"Leaves with see-through gaps. Off draws them solid.": "आर-पार दिखने वाले अंतराल वाली पत्तियाँ। बंद होने पर ठोस दिखती हैं।",
"Sorts water and glass more exactly behind each other.": "पानी और कांच को एक-दूसरे के पीछे अधिक सटीक रूप से क्रमित करता है।",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "कौन से चंक रीबिल्ड फ्रेम का इंतज़ार करते हैं: कोई नहीं, पास के या आपके।",
"Animates water, lava, fire and other moving textures.": "पानी, लावा, आग और अन्य गतिमान टेक्सचर को एनिमेट करता है।",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "स्क्रीन पिक्सेल प्रति विश्व पिक्सेल। 100% से नीचे GPU पर तेज़ होता है।",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "टेढ़े-मेढ़े किनारों को चिकना करता है। FXAA: नरम। TAA: सबसे चिकना, गतिमान चीज़ों के पीछे निशान हो सकते हैं।",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "दुनिया को छोटा रेंडर करता है और TAA के साथ पुनर्निर्माण करता है। तेज़, नरम।",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR डिस्प्ले पर HDR आउटपुट: लावा, आग और धूप अधिक चमकीले चमकते हैं।",
"How frames reach the screen while VSync is off.": "VSync बंद होने पर फ्रेम स्क्रीन तक कैसे पहुँचते हैं।",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "दक्ष मोड अगले फ्रेम तक सोता है; सटीक मोड वैनिला की तरह घूमता है।",
"Fills the screen (borderless).": "स्क्रीन भरता है (बॉर्डरलेस)।",
"Lowers the frame rate when minimised, or idle too.": "न्यूनतम किए जाने पर या निष्क्रिय होने पर फ्रेम दर कम कर देता है।",
"Sways the view while walking.": "चलते समय दृश्य को झुकाता है।",
"Fades newly loaded chunks in.": "नए लोड किए गए चंक्स को फेड इन करता है।",
"Darkens the screen's edges.": "स्क्रीन के किनारों को गहरा करता है।",
"Shows when the world saves.": "दिखाता है कि दुनिया कब सेव होती है।",
"Shows attack cooldown.": "हमला कूलडाउन दिखाता है।",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS सीमा (मेनू या अनफ़ोकस्ड में 30), आधे वर्कर्स: कम बिजली।",
"Threads that generate, light and mesh chunks. Next start.": "थ्रेड्स जो चंक्स उत्पन्न करते, रोशन करते और मेश बनाते हैं। अगली शुरुआत पर।",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, फिर DirectX 12 (Windows) या OpenGL (Linux) जहाँ वह विफल हो। अगली शुरुआत पर।",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "एक बफर में पानी और कांच: कम ड्रॉ कॉल, थोड़ा अधिक VRAM। अगली शुरुआत पर।",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "प्रति फ्रेम GPU को भेजा गया मेश डेटा। लोडिंग के दौरान कम मान अधिक सुचारू होता है।",
"Frame rate and frame times over the game, with a graph.": "ग्राफ के साथ गेम के दौरान फ्रेम दर और फ्रेम समय।",
"horizon.multiplayer.finding_friend": "आपके मित्र की दुनिया खोजी जा रही है...",
"horizon.multiplayer.not_signed_in": "यह सर्वर ऑनलाइन मोड में है। इसमें शामिल होने के लिए क्षितिज लॉन्चर में अपने माइक्रोसॉफ्ट खाते से साइन इन करें।",
"horizon.multiplayer.invalid_address": "अमान्य सर्वर पता: %s",
"horizon.multiplayer.connected_friend_direct": "सीधे आपके मित्र से कनेक्ट हो गया...",
"horizon.multiplayer.connected_friend_relay": "रिले के माध्यम से आपके मित्र से कनेक्ट हो गया...",
"General": "सामान्य",
"Advanced": "उन्नत",
"Undo": "पूर्ववत करें",
"Search options": "विकल्प खोजें",
"Shortcut": "शॉर्टकट",
"Distance": "दूरी",
"Display": "डिस्प्ले",
"Interface": "इंटरफ़ेस",
"Sky and weather": "आकाश और मौसम",
"Lighting and entities": "प्रकाश व्यवस्था और एंटिटीज",
"Textures": "टेक्सचर्स",
"Image": "छवि",
"Chunk loading": "चंक लोडिंग",
"Culling": "कलिंग",
"Power": "पावर",
"Frames": "फ्रेम्स",
"Overlay": "ओवरले",
"Output": "आउटपुट",
"Brightness": "चमक",
"How far you see, the window and the HUD.": "आप कितनी दूर तक देखते हैं, विंडो और HUD।",
"The sky and weather, lighting, textures and the image.": "आकाश और मौसम, प्रकाश व्यवस्था, टेक्सचर और छवि।",
"Chunk loading, culling and power use.": "चंक लोडिंग, कलिंग और पावर का उपयोग।",
"How frames reach the screen, and the performance overlay.": "फ्रेम स्क्रीन तक कैसे पहुँचते हैं, और प्रदर्शन ओवरले।",
"Brighter-than-white output on an HDR display.": "HDR डिस्प्ले पर सफेद से अधिक चमकीला आउटपुट।",
"Results for \"%s\"": "\"%s\" के लिए परिणाम",
"No settings match.": "कोई सेटिंग मेल नहीं खाती।",
"horizon.title.not_affiliated": "Mojang या Microsoft से संबद्ध नहीं",
"horizon.extras.header.lighting": "प्रकाश व्यवस्था",
"horizon.extras.header.third_person": "तीसरा व्यक्ति",
"horizon.extras.header.particles": "पार्टिकल्स",
"horizon.extras.header.inventory": "इन्वेंट्री",
"horizon.extras.header.chat": "चैट",
"horizon.extras.header.multiplayer": "मल्टीप्लेयर",
"horizon.extras.header.sound": "ध्वनि",
"horizon.extras.header.menus": "मेनू",
"horizon.extras.fullbright": "फुलब्राइट",
"horizon.extras.dynamic_lights": "डायनेमिक लाइट्स",
"horizon.extras.own_name_tag": "स्वयं का नाम टैग",
"horizon.extras.hide_block_particles": "ब्लॉक-ब्रेक पार्टिकल्स छुपाएं",
"horizon.extras.auto_reconnect": "ऑटो-रीकनेक्ट",
"horizon.extras.ui_sound_reserve": "UI ध्वनियों के लिए चैनल सुरक्षित रखें",
"horizon.extras.mouse_tweaks": "माउस ट्वािक्स नियंत्रण",
"horizon.extras.mouse_tweaks_wheel": "स्क्रॉल व्हील आइटम स्थानांतरित करता है",
"horizon.extras.sort": "बटन सॉर्ट करें",
"horizon.extras.sort_key": "सॉर्ट कुंजी",
"horizon.extras.no_ai": "कोई AI नहीं",
"horizon.extras.agent_control": "एजेंट नियंत्रण",
"horizon.extras.discord_presence": "डिस्कॉर्ड रिच प्रेजेंस",
"horizon.extras.splitscreen": "स्प्लिटस्क्रीन",
"horizon.extras.chat_animation": "सुचारू चैट",
"horizon.extras.chat_indicator": "संदेश संकेतक",
"horizon.extras.wheel_cycles_settings": "स्क्रॉल व्हील सेटिंग्स बदलता है",
"horizon.extras.fullbright.tooltip": "अंधेरे क्षेत्रों में भी, ब्लॉकों और एंटिटीज़ को पूर्ण प्रकाश में दिखाएं।",
"horizon.extras.dynamic_lights.tooltip": "पकड़ी गई, गिराई गई या आग पर मौजूद मशालें और अन्य प्रकाश स्रोत अपने आस-पास के ब्लॉकों को रोशन करते हैं।",
"horizon.extras.own_name_tag.tooltip": "तीसरे व्यक्ति में आपके सिर के ऊपर आपका नाम दिखाता है, जैसा कि अन्य खिलाड़ी इसे देखते हैं। सर्वर द्वारा आपकी टीम के नाम छुपाए जाने या आपके अदृश्य होने पर छुपा रहता है।",
"horizon.extras.hide_block_particles.tooltip": "टूटे हुए ब्लॉक से निकलने वाले मलबे के कणों और आपके द्वारा माइन किए जा रहे ब्लॉक से झड़ने वाले कणों को छुपाता है। अन्य कण बने रहते हैं।",
"horizon.extras.auto_reconnect.tooltip": "कनेक्शन टूटने या रीस्टार्ट के बाद सर्वर से दोबारा जुड़ता है, किक या बैन होने के बाद कभी नहीं।",
"horizon.extras.ui_sound_reserve.tooltip": "दुनिया की ध्वनियाँ 247 ध्वनि चैनलों में से 16 को खाली छोड़ती हैं, ताकि उनमें से बाढ़ (एक बड़ा विस्फोट) मेनू क्लिक को चुप न करा सके।",
"horizon.extras.mouse_tweaks.tooltip": "राइट-ड्रैग प्रति स्लॉट एक रखता है, लेफ्ट-ड्रैग पकड़े गए आइटम को इकट्ठा करता है, Shift + लेफ्ट-ड्रैग हर स्लॉट को ले जाता है।",
"horizon.extras.mouse_tweaks_wheel.tooltip": "नीचे स्क्रॉल करने पर स्लॉट से एक आइटम दूसरी तरफ जाता है, ऊपर करने पर वापस आता है।",
"horizon.extras.sort.tooltip": "रचनात्मक टैब क्रम में इन्वेंट्री, चेस्ट, बैरल, एंडर चेस्ट और शल्कर बॉक्स को सॉर्ट करता है, स्टैक को मर्ज करता है।",
"horizon.extras.sort_key.tooltip": "कर्सर के नीचे के हिस्से को सॉर्ट करता है। क्लिक करें, फिर कोई कुंजी दबाएं; Escape इसे अनबाउंड करता है।",
"horizon.extras.no_ai.tooltip": "प्रत्येक AI सुविधा को छुपाता और बंद करता है: एजेंट नियंत्रण, horizon-ctl और MCP सर्वर। लॉन्चर का स्विच भी।",
"horizon.extras.agent_control.tooltip": "आपका अपना AI एजेंट horizon-ctl या MCP क्लाइंट के माध्यम से, केवल इस कंप्यूटर पर इस गेम को खेल सकता है।",
"horizon.extras.discord_presence.tooltip": "डिस्कॉर्ड चलने पर आपकी डिस्कॉर्ड प्रोफ़ाइल पर Playing Horizon दिखाता है: मेनू, सिंगल प्लेयर या मल्टीप्लेयर, डाइमेंशन और खेला गया समय। किसी दुनिया का नाम या सर्वर का पता कभी नहीं।",
"Calibrate...": "कैलिब्रेट करें...",
"Calibrate Anyway...": "वैसे भी कैलिब्रेट करें...",
"Use Automatic Setup": "स्वचालित सेटअप का उपयोग करें",
"Shortcut: %s": "शॉर्टकट: %s",
"Next": "अगला",
"HDR Calibration %s/%s: %s": "HDR कैलिब्रेशन %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "एक समय में एक परीक्षण चित्र के साथ, नीचे दिए गए तीनों को पूर्ण स्क्रीन पर सेट करता है।",
"A key that opens this screen from the game. Not bound at first.": "एक कुंजी जो गेम से इस स्क्रीन को खोलती है। पहले से बाउंड नहीं है।",
"Your display didn't say how bright it gets: calibrate to set it up.": "आपके डिस्प्ले ने यह नहीं बताया कि यह कितना चमकीला होता है: इसे सेट अप करने के लिए कैलिब्रेट करें।",
"Already set up for your display. Only calibrate if HDR looks wrong.": "आपके डिस्प्ले के लिए पहले से सेट अप है। केवल तभी कैलिब्रेट करें जब HDR गलत लगे।",
"Puts Peak Brightness and Paper White back to your display's setup.": "पीक ब्राइटनेस और पेपर व्हाइट को आपके डिस्प्ले के सेटअप पर वापस रखता है।",
"Set up for your display. Leave on Auto unless it looks wrong.": "आपके डिस्प्ले के लिए सेट अप है। जब तक यह गलत न लगे, ऑटो पर छोड़ दें।",
"Lower it until the logo just disappears into the black.": "इसे तब तक कम करें जब तक लोगो काले रंग में गायब न हो जाए।",
"Raise it until the logo just disappears into the bright square.": "इसे तब तक बढ़ाएं जब तक लोगो चमकीले वर्ग में गायब न हो जाए।",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "कार्ड को एक ऐसे सफेद रंग में बनाएं जिसे आप जिस प्रकाश में खेलते हैं, उसमें लंबे समय तक देखना आरामदायक हो।",
"horizon.splitscreen.profiles.forget": "भूल जाएं",
"horizon.splitscreen.profiles.create": "बनाएं",
"horizon.splitscreen.profiles.you": " - आप",
"horizon.splitscreen.profiles.forget_named": "%s को भूल जाएं",
"horizon.splitscreen.profiles.previous": "पिछला",
"horizon.splitscreen.profiles.next": "अगला",
"horizon.splitscreen.profiles.new_button": "नया प्रोफ़ाइल...",
"horizon.splitscreen.profiles.forget_title": "प्रोफ़ाइल भूल जाएं?",
"horizon.splitscreen.profiles.forget_leaves": "यह इस इंस्टेंस की सूची छोड़ देता है।",
"horizon.splitscreen.profiles.forget_kept": "दुनिया की प्रगति और सेटिंग्स रखी जाती हैं।",
"horizon.splitscreen.profiles.new_title": "नया प्रोफ़ाइल",
"horizon.splitscreen.profiles.name_hint": "नाम (3 से 16 अक्षर, अंक, _)",
"horizon.splitscreen.profiles.switch_title": "प्रोफ़ाइल बदलें",
"horizon.splitscreen.profiles.offline_note": "ऑफ़लाइन प्रोफ़ाइल इस इंस्टेंस के हैं",
"horizon.splitscreen.profiles.move": "स्थानांतरित करें जहाँ आप खेलते हैं",
"horizon.splitscreen.profiles.move_place": "स्थानांतरित करें जहाँ आप खेलते हैं: %s",
"horizon.friends.title": "मित्रों के साथ खेलें",
"horizon.friends.hint": "मित्र शीर्षक स्क्रीन के फ्रेंड्स बटन के बाद, डायरेक्ट कनेक्शन में, या लॉन्चर के प्ले पेज पर कोड पेस्ट करते हैं।",
"horizon.friends.copied": "कॉपी किया गया",
"horizon.friends.copy_code": "कोड कॉपी करें",
"horizon.friends.stop_sharing": "साझा करना बंद करें",
"horizon.friends.none_yet": "यहाँ अभी कोई मित्र नहीं है।",
"horizon.friends.here_now": "अभी यहाँ हैं: %s",
"horizon.friends.share_intro": "इस दुनिया को आमंत्रण कोड के पीछे साझा करें: यह LAN के लिए खुलता है, और इंटरनेट पर मित्र कोड के साथ जुड़ते हैं। इसमें हमारा कोई सर्वर शामिल नहीं है।",
"horizon.friends.start_sharing": "साझा करना शुरू करें",
"horizon.friends.troubleshooting": "समस्या निवारण...",
"horizon.friends.stopped": "आमंत्रण कोड साझा करना बंद कर दिया।",
"horizon.friends.short_code_changed": "किसी ने बार-बार गलत शॉर्ट कोड आजमाया, इसलिए यह बदल गया।",
"horizon.friends.wrong_code": "किसी ने गलत कोड आजमाया।",
"horizon.friends.code_ready": "आपका आमंत्रण कोड तैयार है: गेम मेनू, प्ले विद फ्रेंड्स। इसे कॉपी करें और अपने मित्रों को भेजें।",
"horizon.friends.share_failed": "दुनिया साझा नहीं की जा सकी: %s",
"horizon.friends.finding_route": "ढूंढ रहे हैं कि मित्र आप तक कैसे पहुँच सकते हैं…",
"horizon.osk.type": "टाइप करें",
"horizon.osk.symbols": "प्रतीक",
"horizon.osk.close": "बंद करें",
"horizon.extras.chat_animation.tooltip": "एक नई चैट लाइन पूरी तरह से प्रकट होने के बजाय फीका पड़ने के दौरान बाईं ओर से स्लाइड करती है।",
"horizon.extras.chat_indicator.tooltip": "चैट लाइन के बाईं ओर रंगीन पट्टी।",
"horizon.extras.wheel_cycles_settings.tooltip": "सेटिंग्स में किसी बटन पर स्क्रॉल व्हील उसका मान बदलता है, जैसा कि वैनिला में होता है। बंद होने पर, व्हील केवल सूची को स्क्रॉल करता है।",
"horizon.extras.splitscreen.tooltip": "एक दुनिया में, एक अन्य कंट्रोलर नए खिलाड़ी के रूप में जुड़ सकता है: पूछने पर A दबाएं, फिर एक प्रोफ़ाइल चुनें और कहाँ खेलना है।",
"horizon.splitscreen.setup.monitor": "मॉनिटर %s",
"horizon.splitscreen.setup.monitor_unknown": "एक मॉनिटर",
"horizon.splitscreen.setup.window": "विंडो %s",
"horizon.splitscreen.setup.new_window": "नई विंडो",
"horizon.splitscreen.setup.fill_monitor": "%s भरें",
"horizon.splitscreen.setup.done": "हो गया",
"horizon.splitscreen.setup.on": "चालू",
"horizon.splitscreen.setup.off": "बंद",
"horizon.splitscreen.setup.player": "खिलाड़ी %s",
"horizon.splitscreen.setup.revert": "पूर्ववत करें",
"horizon.splitscreen.setup.apply": "लागू करें",
"horizon.splitscreen.setup.warning.empty_main": "विंडो 1 में कोई नहीं खेलता: यह छुप जाती है",
"horizon.splitscreen.setup.warning.empty_close": "विंडो %s में कोई नहीं खेलता: यह लागू करने पर बंद हो जाती है",
"horizon.splitscreen.setup.warning.empty_new": "विंडो %s में कोई नहीं खेलता: यह नहीं खुलेगी",
"horizon.splitscreen.setup.warning.on_main": "विंडो %s, विंडो 1 के मॉनिटर को भरती है: इसके खिलाड़ी विंडो 1 में खेलते हैं",
"horizon.splitscreen.setup.warning.unplugged": "विंडो %s का मॉनिटर कनेक्ट नहीं है: इसके खिलाड़ी विंडो 1 में खेलते हैं",
"horizon.splitscreen.setup.status.off": "स्प्लिटस्क्रीन बंद है।",
"horizon.splitscreen.setup.status.enable_hint": "इसे चालू करें, फिर दुनिया में किसी अन्य कंट्रोलर पर एक बटन दबाएं।",
"horizon.splitscreen.setup.status.join_hint": "शामिल होने और प्रोफ़ाइल चुनने के लिए A दबाएं।",
"horizon.splitscreen.setup.status.idle": "स्प्लिटस्क्रीन निष्क्रिय",
"horizon.splitscreen.setup.status.alone": "कोई अन्य स्थानीय खिलाड़ी नहीं।",
"horizon.splitscreen.setup.status.solo_controls": "सोलो नियंत्रण और कंट्रोलर फीडबैक अपरिवर्तित हैं।",
"horizon.splitscreen.setup.window_empty": "विंडो %s: खाली",
"horizon.splitscreen.setup.note.select": "किसी खिलाड़ी पर क्लिक करें, या उसे किसी विंडो या मॉनिटर पर खींचें।",
"horizon.splitscreen.setup.note.apply": "इस तरह खेलने के लिए लागू करें, पूर्ववत करने के लिए रीवर्ट करें।",
"horizon.splitscreen.setup.note.place": "%s को कहाँ खेलना चाहिए?",
"horizon.splitscreen.setup.this_player": "यह खिलाड़ी",
"horizon.splitscreen.setup.switch_disabled": "अन्य लोगों के जाने के बाद (उनकाpause मेनू) स्प्लिटस्क्रीन बंद हो जाती है।",
"horizon.splitscreen.setup.window_place": "विंडो %s (%s का %s)",
"horizon.splitscreen.setup.names": "नाम",
"horizon.splitscreen.setup.identify": "पहचानें",
"horizon.splitscreen.setup.layout.two": "2 साझा कर रहे हैं: %s",
"horizon.splitscreen.setup.layout.three": "3 साझा कर रहे हैं: %s",
"horizon.splitscreen.setup.layout.two.0": "ऑटो",
"horizon.splitscreen.setup.layout.two.1": "आमने-सामने (साइड बाय साइड)",
"horizon.splitscreen.setup.layout.two.2": "ऊपर और नीचे",
"horizon.splitscreen.setup.layout.three.0": "शीर्ष पर एक चौड़ा",
"horizon.splitscreen.setup.layout.three.1": "बाईं ओर एक लंबा",
"horizon.splitscreen.setup.layout.three.2": "आमने-सामने",
"horizon.splitscreen.setup.help.player": "उन्हें स्थानांतरित करने के लिए %s पर क्लिक करें या खींचें।",
"horizon.splitscreen.setup.help.layout_auto": "%s (विंडो चौड़ी होने पर साइड बाय साइड)। बदलने के लिए दबाएं।",
"horizon.splitscreen.setup.help.layout": "%s। बदलने के लिए दबाएं।",
"horizon.splitscreen.setup.help.switch": "इस कंप्यूटर के लिए स्प्लिटस्क्रीन, चालू या बंद।",
"horizon.splitscreen.setup.help.identify": "प्रत्येक खिलाड़ी का दृश्य दिखाता है कि यह किस विंडो में है।",
"horizon.splitscreen.setup.help.names": "प्रत्येक खिलाड़ी का नाम उनके दृश्य के कोने में।",
"horizon.splitscreen.setup.help.revert": "अभी तक लागू न किए गए परिवर्तनों को पूर्ववत करें।",
"horizon.splitscreen.setup.help.apply": "मैप जिस तरह दिखाता है उस तरह खेलें।",
"horizon.splitscreen.setup.help.unchanged": "अभी तक कुछ नहीं बदला है।",
"horizon.splitscreen.setup.give_up_pad": "गिव अप पैड",
"horizon.splitscreen.prompt.controller_connected": "कंट्रोलर कनेक्ट हो गया",
"horizon.splitscreen.prompt.controller_disconnected": "कंट्रोलर डिस्कनेक्ट हो गया",
"horizon.splitscreen.prompt.controller_disconnected.hint": "इसे फिर से कनेक्ट करें, या किसी अन्य पर एक बटन दबाएं",
"horizon.splitscreen.prompt.join.one": "%s शामिल होना चाहता है",
"horizon.splitscreen.prompt.join.many": "%s कंट्रोलर शामिल होना चाहते हैं",
"horizon.splitscreen.prompt.join.yes": "शामिल हों",
"horizon.splitscreen.prompt.join.as_p1": "P1 के रूप में खेलें",
"horizon.splitscreen.prompt.join.no": "नहीं",
"horizon.splitscreen.prompt.lost": "%s: कंट्रोलर डिस्कनेक्ट हो गया",
"horizon.splitscreen.prompt.lost.use_another": "दूसरा उपयोग करें",
"horizon.splitscreen.prompt.lost.leave": "छोड़ें",
"horizon.splitscreen.setup.help.give_up_pad": "आपका कंट्रोलर आपको चलाना बंद कर देता है और शामिल होने के लिए पेश किया जाता है (A)।",
"horizon.splitscreen.setup.note.held_at": "%s से %s: A इसे यहाँ गिराता है, B इसे वापस रखता है।",
"horizon.splitscreen.setup.note.held": "%s उठाया गया: बाएँ/दाएँ चुनता है कहाँ, A गिराता है, B वापस रखता है।",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "अपारदर्शी ब्लॉकों के पीछे मॉडल छुपाता है। एंसिंक जाँचें ऑफ-थ्रेड होती हैं; एंटिटीज टिक करती हैं।",
"Block Entities": "ब्लॉक एंटिटीज",
"Shulker Boxes": "शल्कर बॉक्सेस",
"Reuses settled boxes; lid movement stays animated.": "स्थिर बॉक्स का पुन उपयोग करता है; ढक्कन की गति एनिमेटेड रहती है।",
"Signs": "साइनबोर्ड्स",
"Reuses sign text until text, light, or font changes.": "टेक्स्ट, लाइट या फ़ॉन्ट बदलने तक साइन टेक्स्ट का पुन उपयोग करता है।",
"Decorated Pots": "सजाए गए बर्तन",
"Reuses still pots; break wobble and sherd changes redraw.": "स्थिर बर्तनों का पुन उपयोग करता है; टूटने का डगमगाना और शर्ड परिवर्तन फिर से बनाते हैं।",
"Bells": "घंटियाँ",
"Reuses still bells; ringing stays animated.": "स्थिर घंटियों का पुन उपयोग करता है; बजना एनिमेटेड रहता है।",
"Chests": "चेस्ट्स",
"Reuses closed chests; moving lids stay animated.": "बंद चेस्ट का पुन उपयोग करता है; हिलते हुए ढक्कन एनिमेटेड रहते हैं।",
"Banners": "बैनर्स",
"Reuses poles; flags and pattern layers still update.": "पोल का पुन उपयोग करता है; झंडे और पैटर्न परतें अभी भी अपडेट होती हैं।",
"Copper Golem Statues": "कॉपर गोलम मूर्तियां",
"Reuses statue geometry until its block state changes.": "ब्लाक स्थिति बदलने तक मूर्ति ज्यामिति का पुन उपयोग करता है।",
"Leaf Culling": "लीफ कलिंग",
"Every leaf face. Vanilla's fancy leaves.": "प्रत्येक पत्ती का चेहरा। वैनिला की फैंसी पत्तियां।",
"Distant Plant Dither": "दूर के पौधे का डिथर",
"Keeps fine plants in high-res packs from fading far away.": "हाई-रेज़ पैक में सुंदर पौधों को दूर फीका होने से बचाता है।",
"Screenshot Quality": "स्क्रीनशॉट गुणवत्ता",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "खेल के रिज़ॉल्यूशन के इतने गुना पर F2 स्क्रीनशॉट। उच्च मान में अधिक समय लगता है।",
"Frame Generation": "फ्रेम जनरेशन",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "प्रत्येक दो वास्तविक फ़्रेमों के बीच एक काल्पनिक फ़्रेम दिखाता है: अधिक सुचारू, थोड़ा अधिक लैग।",
"Shadow Cache": "शैडो कैश",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "जब सूरज, दृश्य और दुनिया मेल खाते हैं तो पैक शैडो मैप का पुन उपयोग करता है। डिफ़ॉल्ट रूप से बंद।",
"Find BlueMap": "ब्लूमेप खोजें",
"On servers, finds their BlueMap web map for far terrain. Next join.": "सर्वर पर, दूर के भू-भाग के लिए उनका ब्लूमेप वेब मैप खोजता है। अगली शुरुआत पर।",
"BlueMap Radius": "ब्लूमेप त्रिज्या",
"How far around you BlueMap tiles are downloaded. Next join.": "आपके आस-पास ब्लूमेप टाइलें कितनी दूर तक डाउनलोड की जाती हैं। अगली शुरुआत पर।",
"Synchronous": "सिंक्रोनस",
"Asynchronous": "असिंक्रोनस",
"Solid": "ठोस",
"Solid Aggressive": "ठोस आक्रामक",
"Hollow": "खोखला",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "कैनोपी के पूरी तरह से अंदर की पत्तियां ठोस रूप से खींची जाती हैं: कम आर-पार दिखने वाली परतें।",
"Leaves with one open side draw solid too: faster, chunkier edges.": "एक खुले तरफ वाली पत्तियां भी ठोस खींची जाती हैं: तेज़, अधिक मोटे किनारे।",
"Drops faces between leaves: faster, but gaps look through.": "पत्तियों के बीच के चेहरों को छोड़ देता है: तेज़, लेकिन अंतराल से आर-पार दिखता है।",
"Hotbar: Previous Slot": "हॉटबार: पिछला स्लॉट",
"Hotbar: Next Slot": "हॉटबार: अगला स्लॉट",
"horizon.agent.connected.one": "कनेक्टेड: 1 एजेंट कनेक्शन",
"horizon.agent.connected.many": "कनेक्टेड: %s एजेंट कनेक्शन",
"horizon.agent.connected.one_ago": "कनेक्टेड: 1 एजेंट कनेक्शन, अंतिम कमांड %s सेकंड पहले",
"horizon.agent.connected.many_ago": "कनेक्टेड: %s एजेंट कनेक्शन, अंतिम कमांड %s सेकंड पहले",
"horizon.agent.waiting": "एक एजेंट की प्रतीक्षा है।",
"horizon.agent.off": "बंद: एजेंट इस गेम तक नहीं पहुँच सकते।",
"horizon.agent.session_opened": "गेम शुरू करने वाले प्रोग्राम द्वारा इस सत्र के लिए खोला गया।",
"horizon.agent.socket": "सॉकेट: %s (इसके पूर्ण पथ के लिए विवरण कॉपी करें)",
"horizon.agent.local_only": "केवल स्थानीय: इस कंप्यूटर पर एजेंट, नेटवर्क पर कभी नहीं।",
"%s nits": "%s निट्स",
"Auto (%s nits)": "ऑटो (%s निट्स)",
"%.2f nits": "%.2f निट्स",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "अपारदर्शी ब्लॉकों के पीछे मॉडल छुपाता है। एंटिटीज अपडेट होती हैं। कस्टम पैक के साथ बंद।",
"horizon.extras.repro_keys": "रिकॉर्डिंग शॉर्टकट",
"horizon.extras.repro_keys.tooltip": "F3+R रीप्रो रिकॉर्डिंग शुरू या बंद करता है और F3+E इसका बंडल लिखता है। डिफ़ॉल्ट रूप से बंद: रिकॉर्डिंग दुनिया की नकल करती है।",
"horizon.agent.record_session": "रिकॉर्डिंग सत्र शुरू करें",
"horizon.agent.record_stop": "रिकॉर्डिंग सत्र बंद करें",
"horizon.agent.export_repro": "रीप्रो बंडल निर्यात करें",
"horizon.repro.started": "रिकॉर्डिंग शुरू हुई; F3+R इसे बंद करता है और बंडल लिखता है।",
"horizon.repro.stopped": "रिकॉर्डिंग बंद हुई: %s",
"horizon.repro.bundle": "रीप्रो बंडल: %s",
"horizon.repro.error": "रीप्रो: %s",
"horizon.repro.no_recording": "निर्यात करने के लिए अभी तक कोई रिकॉर्डिंग नहीं है।",
"horizon.repro.replay_started": "रीप्ले शुरू हुआ: %s"
}
@@ -0,0 +1,440 @@
{
"horizon.options.controller": "Upravljač",
"horizon.options.vibration": "Vibracija",
"horizon.options.adaptive_triggers": "Prilagodljivi okidači",
"horizon.options.lightbar": "Svjetlosna traka",
"horizon.options.look_sensitivity": "Osjetljivost pogleda",
"horizon.options.invert_x": "Obrni pogled X",
"horizon.options.invert_y": "Obrni pogled Y",
"horizon.options.sneak_button": "Gumb za šuljanje",
"horizon.options.move_deadzone": "Mrtva zona kretanja",
"horizon.options.look_deadzone": "Mrtva zona pogleda",
"horizon.options.button_glyphs": "Simboli gumba",
"horizon.options.menu_navigation": "Navigacija izbornikom",
"horizon.options.on_screen_keyboard": "Zaslonska tipkovnica",
"horizon.options.chat_delay": "Odgoda chata",
"horizon.options.lightbar.health": "Zdravlje",
"horizon.options.lightbar.dimension": "Dimenzija",
"horizon.options.glyphs.auto": "Automatski",
"horizon.options.menu_focus": "Fokus",
"horizon.options.menu_pointer": "Pokazivač",
"horizon.options.keyboard.auto": "Automatski",
"horizon.options.unavailable": "Ova značajka još nije dostupna u Horizonu.",
"horizon.options.search": "Pretraži postavke...",
"horizon.pages.mods": "Modovi",
"horizon.pages.shader_packs": "Paketi shadera",
"horizon.pages.shader_options": "Postavke shadera",
"horizon.pages.controller": "Postavke upravljača",
"horizon.pages.controller_binds": "Veze tipki upravljača",
"horizon.pages.extras": "Horizon dodatci",
"horizon.pages.settings": "Horizon postavke",
"horizon.pages.agent": "AI agent",
"horizon.split.leave": "Napusti",
"horizon.pages.split_settings": "Postavke podijeljenog zaslona",
"horizon.pages.split_profiles": "Tko igra?",
"horizon.pages.split_setup": "Postava podijeljenog zaslona",
"horizon.pages.settings.tooltip": "Horizonove vlastite postavke: grafika, upravljači, dodatci i modovi.",
"horizon.pages.graphics.tooltip": "Grafika, jednostavno: svaka postavka navodi svoju cijenu/opterećenje.",
"horizon.pages.graphics_advanced.tooltip": "Svaka grafička postavka, uključujući Horizonove, s cijenom svake od njih.",
"horizon.pages.controller.tooltip": "Upravljači: njihovi gumbi, palice, vibracija i navigacija izbornikom.",
"horizon.pages.extras.tooltip": "Neobavezni dodatci, zadano isključeni: igra ostaje vanila dok ne uključite neki.",
"horizon.pages.agent.tooltip": "Dopustite svom AI agentu da igra: horizon-ctl i MCP klijenti na ovom računalu.",
"horizon.pages.split_profiles.tooltip": "Tko igra: profil spremljen u ovoj mapi igre.",
"horizon.pages.split_setup.tooltip": "Prozori i monitori te tko igra gdje.",
"horizon.pages.shader_packs.tooltip": "Paketi shadera iz mape shaderpacks: Horizonovi vlastiti i oni u shaders/ formatu. Radi jedan po jedan.",
"horizon.pages.mods.tooltip": "Modovi izrađeni za Horizon, iz mape horizon-mods. Svaki radi u sandčiću i isključen je dok ga ne uključite.",
"horizon.pages.open.graphics": "Grafika...",
"horizon.pages.open.graphics_advanced": "Napredna grafika...",
"horizon.pages.open.shader_packs": "Paketi shadera...",
"horizon.pages.open.controller": "Postavke upravljača...",
"horizon.pages.open.split_setup": "Podijeljeni zaslon...",
"horizon.pages.open.extras": "Dodatci...",
"horizon.pages.open.mods": "Modovi...",
"horizon.pages.open.agent": "AI agent...",
"horizon.pages.open.horizon_graphics": "Horizon grafika...",
"horizon.pages.open.controller_binds": "Veze tipki upravljača...",
"horizon.pages.open.horizon_settings": "Horizon postavke...",
"horizon.mods.empty": "Nema modova: stavite .hmod datoteke u mapu horizon-mods",
"horizon.mods.stopped": " (Zaustavljeno)",
"horizon.mods.not_built": " (Nije izgrađeno)",
"horizon.mods.dev": " (Razvojno)",
"horizon.mods.settings": "Postavke",
"horizon.mods.open_folder": "Otvori mapu modova",
"horizon.shader_packs.empty": "Nema paketa shadera: stavite ih u mapu shaderpacks",
"horizon.shader_options.open": "Postavke shadera...",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Čitanje postavki paketa shadera...",
"horizon.agent.copy_details": "Kopiraj pojedinosti veze",
"horizon.multiplayer.join": "Pridruži se",
"horizon.multiplayer.cancel_reconnecting": "Otkaži ponovno povezivanje",
"horizon.multiplayer.join_friend": "Pridruži se prijatelju",
"horizon.multiplayer.invite_code": "Pozivni kod",
"horizon.multiplayer.relay": "Prijenos (neobavezno)",
"Preset": "Predložak",
"LOD Distance": "LOD udaljenost",
"Far Detail": "Udaljeni detalji",
"Cave Culling": "Određivanje vidljivosti špilja",
"Entity Culling": "Određivanje vidljivosti entiteta",
"Chunk Updates": "Ažuriranja komadića (chunkova)",
"Upload Budget": "Proračun prijenosa",
"Sky": "Nebo",
"Fog": "Magla",
"Weather": "Vrijeme",
"Animated Textures": "Animirane teksture",
"Present Mode": "Način prikazivanja",
"Frame Pacing": "Usklađivanje sličica",
"Worker Threads": "Radne dretve",
"Resolution Scale": "Skala rezolucije",
"Anti-aliasing": "Ublažavanje rubova",
"Upscaling": "Povećanje razlučivosti",
"Paper White": "Papirnato bijela",
"Peak Brightness": "Najveća svjetlina",
"Black Level": "Razina crne",
"UI Brightness": "Svjetlina sučelja",
"Battery Mode": "Način rada baterije",
"Graphics API": "Grafički API",
"Translucent Batching": "Grupiranje prozirnih elemenata",
"Performance Overlay": "Prikaz performansi",
"Full Detail Distance": "Udaljenost punih detalja",
"On": "Uključeno",
"Off": "Isključeno",
"Unlimited": "Neograničeno",
"Auto": "Automatski",
"Custom": "Prilagođeno",
"Performance": "Performanse",
"Balanced": "Uravnoteženo",
"Quality": "Kvaliteta",
"Ultra Performance": "Ultra performanse",
"Native": "Izvorno",
"Full": "Potpuno",
"Reduced": "Smanjeno",
"Efficient": "Učinkovito",
"Precise": "Precizno",
"Auto (VSync)": "Automatski (VSync)",
"Immediate": "Trenutačno",
"FPS and Graph": "FPS i graf",
"No cost": "Bez troška",
"Low cost": "Mali trošak",
"Medium cost": "Srednji trošak",
"High cost": "Veliki trošak",
"Saves time": "Štedi vrijeme",
"Graphics": "Grafika",
"Done": "Gotovo",
"This feature is not yet available in Horizon.": "Ova značajka još nije dostupna u Horizonu.",
"Your display or system doesn't offer HDR output.": "Vaš zaslon ili sustav ne podržava HDR izlaz.",
"Turn HDR on to use it.": "Uključite HDR da biste ga koristili.",
"Your GPU or graphics API doesn't support it.": "Vaš GPU ili grafički API to ne podržava.",
"%s chunks": "%s komadića",
"%s per frame": "%s po sličici",
"%s MiB/frame": "%s MiB/sličici",
"%s KiB/frame": "%s KiB/sličici",
"Auto (%s)": "Automatski (%s)",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Raspon terena s punim detaljima. LOD udaljenost može proširiti lokalne svjetove dalje.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Grubi udaljeni teren u lokalnim svjetovima. Integrirani GPU-ovi staju na 128 komadića.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Smanjeno crtanje prikazuje udaljeni teren grubljim: brže na ovom integriranom GPU-u.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Smanjeno crtanje prikazuje udaljeni teren grubljim, za neke GPU-ove to usporava rad.",
"Field of view. Wider shows more terrain at once.": "Vidno polje. Šire vidno polje prikazuje više terena odjednom.",
"Caps frames per second; a cap saves power and heat.": "Ograničava broj sličica u sekundi; ograničenje štedi energiju i smanjuje zagrijavanje.",
"Waits for the display's refresh: no tearing, a little latency.": "Čeka osvježavanje zaslona: nema trganja slike, ali uzrokuje malu latenciju.",
"Size of menus and the HUD.": "Veličina izbornika i HUD-a.",
"Sets the options below together. Changing one makes it Custom.": "Postavlja opcije u nastavku zajedno. Promjena jedne čini postavku prilagođenom.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, vatra i sunčeva svjetlost sjaje jače od bijele boje. Potreban je HDR zaslon.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Najtamnija crna boja vašeg zaslona. 0 za OLED; LCD zasloni malo svijetle.",
"How bright highlights get. Auto: the peak your system reports.": "Koliko svijetli postaju svijetli dijelovi. Automatski: vršna vrijednost koju prijavljuje vaš sustav.",
"How bright white paper and menus look. Auto: your desktop's white.": "Koliko svijetlo izgledaju bijeli papir i izbornici. Automatski: bijela boja vaše radne površine.",
"How bright the HUD and menus are, against paper white.": "Koliko su svijetli HUD i izbornici u odnosu na papirnato bijelu.",
"How far mobs, crops and machines keep working. CPU time.": "Koliko daleko čudovišta, usjevi i strojevi nastavljaju raditi. Vrijeme CPU-a.",
"How far away mobs and items are drawn, as a share of normal.": "Koliko se daleko crtaju čudovišta i predmeti, kao udio normalnog.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Preskače teren skriven iza zatvorenih dijelova. Isključeno opterećuje sustav.",
"Hides entities behind opaque blocks.": "Sakriva entitete iza neprozirnih blokova.",
"No entity occlusion culling.": "Nema odbacivanja vidljivosti entiteta.",
"CPU occlusion test runs this frame.": "Test vidljivosti CPU-a izvodi se u ovoj sličici.",
"Occlusion checks run on a worker thread.": "Provjere vidljivosti izvode se na radnoj dretvi.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Mrežni odjeljci učitani po sličici. Niže je glađe tijekom učitavanja.",
"Soft shadows in corners. Costs chunk building time only.": "Meke sjene u kutovima. Troši samo vrijeme izgradnje komadića.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Manje kopije tekstura blokova za udaljeni teren: manje treperenja.",
"Blends grass and water colours across biomes when building chunks.": "Miješa boje trave i vode preko bioma pri izgradnji komadića.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Sloj oblaka: isključeno, ravno (brzo) ili 3D (detaljno).",
"How far clouds reach, in chunks.": "Koliko daleko dosežu oblaci, u komadićima.",
"Draws the sky, sun, moon and stars.": "Crta nebo, sunce, mjesec i zvijezde.",
"Distance fog. Off shows the edge of the loaded world.": "Magla u daljini. Isključeno prikazuje rub učitanog svijeta.",
"Draws rain and snow.": "Crta kišu i snijeg.",
"How far rain and snow fall around you.": "Koliko daleko oko vas padaju kiša i snijeg.",
"How many particles effects spawn.": "Koliko se efekata čestica stvara.",
"Round shadows under mobs and items.": "Okrugle sjene ispod čudovišta i predmeta.",
"Leaves with see-through gaps. Off draws them solid.": "Lišće s prozirnim prazninama. Isključeno ih crta čvrstima.",
"Sorts water and glass more exactly behind each other.": "Preciznije razvrstava vodu i staklo jedno iza drugog.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Koja ponovna izgradnja komadića čeka sličicu: nijedna, obližnja ili vaša.",
"Animates water, lava, fire and other moving textures.": "Animira vodu, lavu, vatru i druge pokretne teksture.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Pikseli svijeta po pikselu zaslona. Ispod 100% brže je na GPU-u.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Ublažava nazubljene rubove. FXAA: mekše. TAA: najmekše, pokretni objekti mogu ostavljati trag.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Prikazuje svijet manjim i ponovno ga gradi pomoću TAA. Brže, mekše.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR izlaz na HDR zaslonu: lava, vatra i sunčeva svjetlost sjaje jače.",
"How frames reach the screen while VSync is off.": "Kako sličice dospijevaju na zaslon dok je VSync isključen.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Učinkovito spava do sljedeće sličice; Precizno se vrti, kao u vanila verziji.",
"Fills the screen (borderless).": "Ispunjava zaslon (bez obruba).",
"Brightens dark places.": "Osvjetljava tamna mjesta.",
"Lowers the frame rate when minimised, or idle too.": "Smanjuje broj sličica u sekundi kada je prozor minimiziran ili neaktivan.",
"Sways the view while walking.": "Ziba pogled tijekom hodanja.",
"Fades newly loaded chunks in.": "Postupno prikazuje novoučitane komadiće.",
"Darkens the screen's edges.": "Zatamnjuje rubove zaslona.",
"Shows when the world saves.": "Prikazuje kada se svijet sprema.",
"Shows attack cooldown.": "Prikazuje vrijeme hlađenja napada.",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "Ograničenje na 60 FPS (30 u izbornicima ili kad prozor nije u fokusu), upola manje radnika: manja potrošnja energije.",
"Threads that generate, light and mesh chunks. Next start.": "Dretve koje stvaraju, osvjetljavaju i mrežasto obrađuju komadiće. Sljedeće pokretanje.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, zatim DirectX 12 (Windows) ili OpenGL (Linux) gdje ne uspije. Sljedeće pokretanje.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Voda i staklo u jednom međuspremniku: manje poziva crtanja, malo više VRAM-a. Sljedeće pokretanje.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Mrežni podatci poslani na GPU po sličici. Niže je glađe tijekom učitavanja.",
"Frame rate and frame times over the game, with a graph.": "Broj sličica u sekundi i vrijeme izvođenja tijekom igre, s grafom.",
"horizon.multiplayer.finding_friend": "Pronalaženje svijeta vašeg prijatelja...",
"horizon.multiplayer.not_signed_in": "Ovaj poslužitelj je u mrežnom načinu rada. Prijavite se svojim Microsoft računom u Horizon pokretaču da biste se pridružili.",
"horizon.multiplayer.invalid_address": "Nevažeća adresa poslužitelja: %s",
"horizon.multiplayer.connected_friend_direct": "Izravno povezano s vašim prijateljem...",
"horizon.multiplayer.connected_friend_relay": "Povezano s vašim prijateljem putem prijenosa...",
"General": "Općenito",
"Advanced": "Napredno",
"Undo": "Poništi",
"Search options": "Opcije pretraživanja",
"Shortcut": "Prečac",
"Distance": "Udaljenost",
"Display": "Zaslon",
"Interface": "Sučelje",
"Sky and weather": "Nebo i vrijeme",
"Lighting and entities": "Osvjetljenje i entiteti",
"Textures": "Teksture",
"Image": "Slika",
"Chunk loading": "Učitavanje komadića",
"Culling": "Određivanje vidljivosti",
"Power": "Napajanje",
"Frames": "Sličice",
"Overlay": "Prekrivanje",
"Output": "Izlaz",
"Brightness": "Svjetlina",
"How far you see, the window and the HUD.": "Koliko daleko vidite, prozor i HUD.",
"The sky and weather, lighting, textures and the image.": "Nebo i vrijeme, osvjetljenje, teksture i slika.",
"Chunk loading, culling and power use.": "Učitavanje komadića, određivanje vidljivosti i potrošnja energije.",
"How frames reach the screen, and the performance overlay.": "Kako sličice dospijevaju na zaslon i prikaz performansi.",
"Brighter-than-white output on an HDR display.": "Izlaz svjetliji od bijele na HDR zaslonu.",
"Results for \"%s\"": "Rezultati za \"%s\"",
"No settings match.": "Nema odgovarajućih postavki.",
"horizon.title.not_affiliated": "Nije povezano s Mojangom ni Microsoftom",
"horizon.extras.header.lighting": "Osvjetljenje",
"horizon.extras.header.third_person": "Treće lice",
"horizon.extras.header.particles": "Čestice",
"horizon.extras.header.inventory": "Inventar",
"horizon.extras.header.multiplayer": "Flere igrači",
"horizon.extras.header.sound": "Zvuk",
"horizon.extras.header.menus": "Izbornici",
"horizon.extras.fullbright": "Puna svjetlina",
"horizon.extras.dynamic_lights": "Dinamička svjetla",
"horizon.extras.own_name_tag": "Vlastita oznaka imena",
"horizon.extras.hide_block_particles": "Sakrij čestice lomljenja bloka",
"horizon.extras.auto_reconnect": "Automatsko ponovno povezivanje",
"horizon.extras.ui_sound_reserve": "Zadrži kanale za zvukove sučelja",
"horizon.extras.mouse_tweaks": "Kontrole prilagodbe miša",
"horizon.extras.mouse_tweaks_wheel": "Kotačić miša pomiče predmete",
"horizon.extras.sort": "Gumbi za razvrstavanje",
"horizon.extras.sort_key": "Tipka za razvrstavanje",
"horizon.extras.no_ai": "Bez AI-ja",
"horizon.extras.agent_control": "Upravljanje agentom",
"horizon.extras.discord_presence": "Discord prisutnost",
"horizon.extras.splitscreen": "Podijeljeni zaslon",
"horizon.extras.chat_animation": "Glatki chat",
"horizon.extras.chat_indicator": "Indikator poruke",
"horizon.extras.wheel_cycles_settings": "Kotačić miša mijenja postavke",
"horizon.extras.fullbright.tooltip": "Prikazuje blokove i entitete pri punom svjetlu, čak i u tamnim područjima.",
"horizon.extras.dynamic_lights.tooltip": "Baklje i drugi izvori svjetlosti koje nosite, ispustite ili gore osvjetljavaju blokove oko sebe.",
"horizon.extras.own_name_tag.tooltip": "Prikazuje vaše ime iznad vaše glave u trećem licu, kako ga vide drugi igrači. Skriveno kada poslužitelj sakrije imena vašeg tima ili ste nevidljivi.",
"horizon.extras.hide_block_particles.tooltip": "Sakriva čestice krhotina koje pršte iz razbijenog bloka i otpadaju s onoga kojeg rudarite. Ostale čestice ostaju.",
"horizon.extras.auto_reconnect.tooltip": "Ponovno se pridružuje poslužitelju nakon izgubljene veze ili ponovnog pokretanja, nikada nakon izbacivanja ili zabrane.",
"horizon.extras.ui_sound_reserve.tooltip": "Zvukovi u svijetu ostavljaju 16 od 247 zvučnih kanala slobodnima, tako da ih poplava (velika eksplozija) ne može utišati klikove izbornika.",
"horizon.extras.mouse_tweaks.tooltip": "Desni klik i povlačenje stavlja jedan po utoru, lijevi klik i povlačenje skuplja zadržani predmet, Shift + lijevi klik pomiče svaki utor.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Dolje šalje jedan predmet iz utora na drugu stranu, gore vraća jedan natrag.",
"horizon.extras.sort.tooltip": "Razvrstava inventar, škrinje, bačve, ender škrinje i shulker kutije redoslijedom kreativne kartice, spajajući gomile.",
"horizon.extras.sort_key.tooltip": "Razvrstava dio ispod pokazivača. Kliknite, zatim pritisnite tipku; Escape poništava vezu.",
"horizon.extras.no_ai.tooltip": "Sakriva i isključuje svaku AI značajku: upravljanje agentom, horizon-ctl i MCP poslužitelje. Također i prekidač pokretača.",
"horizon.extras.agent_control.tooltip": "Vaš vlastiti AI agent može igrati ovu igru putem horizon-ctl ili MCP klijenta, samo na ovom računalu.",
"horizon.extras.discord_presence.tooltip": "Prikazuje 'Playing Horizon' na vašem Discord profilu dok Discord radi: izbornici, samostalna igra ili više igrača, dimenzija i vrijeme igranja. Nikada naziv svijeta ili adresu poslužitelja.",
"Calibrate...": "Kalibriraj...",
"Calibrate Anyway...": "Svejedno kalibriraj...",
"Use Automatic Setup": "Koristi automatsku postavu",
"Shortcut: %s": "Prečac: %s",
"Next": "Sljedeće",
"HDR Calibration %s/%s: %s": "HDR kalibracija %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Postavlja tri donja na cijeli zaslon, jednu testnu sliku po jednu.",
"A key that opens this screen from the game. Not bound at first.": "Tipka koja otvara ovaj zaslon iz igre. U početku nije povezana.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Vaš zaslon nije javio koliko svijetli: kalibrirajte ga za postavljanje.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Već postavljeno za vaš zaslon. Kalibrirajte samo ako HDR izgleda pogrešno.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Vraća najveću svjetlinu i papirnato bijelu na postavke vašeg zaslona.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Postavljeno za vaš zaslon. Ostavite na automatski osim ako ne izgleda pogrešno.",
"Lower it until the logo just disappears into the black.": "Smanjujte dok logotip ne nestane u crnilu.",
"Raise it until the logo just disappears into the bright square.": "Povećavajte dok logotip ne nestane u svijetlom kvadratu.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Učinite karticu bijelom bojom koja je ugodna za gledanje dugo vremena, pri svjetlu u kojem igrate.",
"horizon.splitscreen.profiles.forget": "Zaboravi",
"horizon.splitscreen.profiles.create": "Stvori",
"horizon.splitscreen.profiles.you": " - ti",
"horizon.splitscreen.profiles.forget_named": "Zaboravi %s",
"horizon.splitscreen.profiles.previous": "Prethodno",
"horizon.splitscreen.profiles.next": "Sljedeće",
"horizon.splitscreen.profiles.new_button": "Novi profil...",
"horizon.splitscreen.profiles.forget_title": "Zaboraviti profil?",
"horizon.splitscreen.profiles.forget_leaves": "Napušta popis ove instance.",
"horizon.splitscreen.profiles.forget_kept": "Napredak u svijetu i postavke su sačuvani.",
"horizon.splitscreen.profiles.new_title": "Novi profil",
"horizon.splitscreen.profiles.name_hint": "Ime (3 do 16 slova, znamenki, _)",
"horizon.splitscreen.profiles.switch_title": "Promijeni profil",
"horizon.splitscreen.profiles.offline_note": "Izvanmrežni profili pripadaju ovoj instanci",
"horizon.splitscreen.profiles.move": "Premjesti mjesto igranja",
"horizon.splitscreen.profiles.move_place": "Premjesti mjesto igranja: %s",
"horizon.friends.title": "Igraj s prijateljima",
"horizon.friends.hint": "Prijatelji lijepe kod nakon gumba Prijatelji na naslovnom zaslonu, u izravnom povezivanju ili na stranici za igranje pokretača.",
"horizon.friends.copied": "Kopirano",
"horizon.friends.copy_code": "Kopiraj kod",
"horizon.friends.stop_sharing": "Zaustavi dijeljenje",
"horizon.friends.none_yet": "Ovdje još nema prijatelja.",
"horizon.friends.here_now": "Ovdje sada: %s",
"horizon.friends.share_intro": "Podijelite ovaj svijet iza pozivnog koda: otvara se za LAN, a prijatelji na internetu pridružuju se s kodom. Nijedan naš poslužitelj nije uključen.",
"horizon.friends.start_sharing": "Započni dijeljenje",
"horizon.friends.troubleshooting": "Rješavanje problema...",
"horizon.friends.stopped": "Zaustavljeno dijeljenje pozivnog koda.",
"horizon.friends.short_code_changed": "Netko je stalno pokušavao unijeti pogrešan kratki kod, pa se on promijenio.",
"horizon.friends.wrong_code": "Netko je pokušao unijeti pogrešan kod.",
"horizon.friends.code_ready": "Vaš pozivni kod je spreman: Izbornik igre, Igraj s prijateljima. Kopirajte ga i pošaljite svojim prijateljima.",
"horizon.friends.share_failed": "Nije moguće dijeliti svijet: %s",
"horizon.friends.finding_route": "Pronalaženje načina na koji vas prijatelji mogu dosegnuti…",
"horizon.osk.space": "Razmaknica",
"horizon.osk.delete": "Izbriši",
"horizon.osk.type": "Tipkaj",
"horizon.osk.symbols": "Simboli",
"horizon.osk.close": "Zatvori",
"horizon.extras.chat_animation.tooltip": "Nova linija chata klizi s lijeve strane dok blijedi, umjesto da se pojavi cijela odjednom.",
"horizon.extras.chat_indicator.tooltip": "Traka u boji s lijeve strane linije chata.",
"horizon.extras.wheel_cycles_settings.tooltip": "Kotačić miša preko gumba u postavkama mijenja njegovu vrijednost, kao u vanila verziji. Kada je isključeno, kotačić samo pomiče popis.",
"horizon.extras.splitscreen.tooltip": "U svijetu se drugi upravljač može pridružiti kao novi igrač: pritisnite A kada se zatraži, zatim odaberite profil i mjesto igranja.",
"horizon.splitscreen.setup.monitor_unknown": "monitor",
"horizon.splitscreen.setup.window": "Prozor %s",
"horizon.splitscreen.setup.new_window": "Novi prozor",
"horizon.splitscreen.setup.fill_monitor": "Ispuni %s",
"horizon.splitscreen.setup.done": "Gotovo",
"horizon.splitscreen.setup.on": "Uključeno",
"horizon.splitscreen.setup.off": "Isključeno",
"horizon.splitscreen.setup.player": "Igrač %s",
"horizon.splitscreen.setup.revert": "Vrati",
"horizon.splitscreen.setup.apply": "Primijeni",
"horizon.splitscreen.setup.warning.empty_main": "Nitko ne igra u prozoru 1: sakrit će se",
"horizon.splitscreen.setup.warning.empty_close": "Nitko ne igra u prozoru %s: zatvorit će se pri primjeni",
"horizon.splitscreen.setup.warning.empty_new": "Nitko ne igra u prozoru %s: neće se otvoriti",
"horizon.splitscreen.setup.warning.on_main": "Prozor %s ispunjava monitor prozora 1: njegovi igrači igraju u prozoru 1",
"horizon.splitscreen.setup.warning.unplugged": "Monitor prozora %s nije spojen: njegovi igrači igraju u prozoru 1",
"horizon.splitscreen.setup.status.off": "Podijeljeni zaslon je isključen.",
"horizon.splitscreen.setup.status.enable_hint": "Uključite ga, zatim pritisnite gumb na drugom upravljaču u svijetu.",
"horizon.splitscreen.setup.status.join_hint": "Pritisnite A za pridruživanje i odabir profila.",
"horizon.splitscreen.setup.status.idle": "Podijeljeni zaslon nije aktivan",
"horizon.splitscreen.setup.status.alone": "Nema drugih lokalnih igrača.",
"horizon.splitscreen.setup.status.solo_controls": "Solo kontrole i povratne informacije upravljača ostaju nepromijenjene.",
"horizon.splitscreen.setup.window_empty": "Prozor %s: prazno",
"horizon.splitscreen.setup.note.select": "Kliknite igrača ili ga odvucite na prozor ili monitor.",
"horizon.splitscreen.setup.note.apply": "Primijeni za igranje na ovaj način, Vrati za poništavanje.",
"horizon.splitscreen.setup.note.place": "Gdje bi %s trebao igrati?",
"horizon.splitscreen.setup.this_player": "ovaj igrač",
"horizon.splitscreen.setup.switch_disabled": "Podijeljeni zaslon se isključuje kada drugi odu (izbornik pauze).",
"horizon.splitscreen.setup.window_place": "Prozor %s (%s od %s)",
"horizon.splitscreen.setup.names": "Imena",
"horizon.splitscreen.setup.identify": "Identificiraj",
"horizon.splitscreen.setup.layout.two": "2 dijeljenja: %s",
"horizon.splitscreen.setup.layout.three": "3 dijeljenja: %s",
"horizon.splitscreen.setup.layout.two.0": "Automatski",
"horizon.splitscreen.setup.layout.two.1": "Jedan pored drugog",
"horizon.splitscreen.setup.layout.two.2": "Iznad i ispod",
"horizon.splitscreen.setup.layout.three.0": "Jedan široki gore",
"horizon.splitscreen.setup.layout.three.1": "Jedan visoki lijevo",
"horizon.splitscreen.setup.layout.three.2": "Jedan pored drugog",
"horizon.splitscreen.setup.help.player": "Kliknite ili povucite %s za pomicanje.",
"horizon.splitscreen.setup.help.layout_auto": "%s (jedan pored drugog kada je prozor širok). Pritisnite za promjenu.",
"horizon.splitscreen.setup.help.layout": "%s. Pritisnite za promjenu.",
"horizon.splitscreen.setup.help.switch": "Podijeljeni zaslon za ovo računalo, uključeno ili isključeno.",
"horizon.splitscreen.setup.help.identify": "Pogled svakog igrača prikazuje u kojem se prozoru nalazi.",
"horizon.splitscreen.setup.help.names": "Ime svakog igrača u kutu njihovog pogleda.",
"horizon.splitscreen.setup.help.revert": "Poništite promjene koje još nisu primijenjene.",
"horizon.splitscreen.setup.help.apply": "Igrajte onako kako karta prikazuje.",
"horizon.splitscreen.setup.help.unchanged": "Još ništa nije promijenjeno.",
"horizon.splitscreen.setup.give_up_pad": "Odustani od pločice/upravljača",
"horizon.splitscreen.prompt.controller_connected": "Upravljač je spojen",
"horizon.splitscreen.prompt.controller_disconnected": "Upravljač je odspojen",
"horizon.splitscreen.prompt.controller_disconnected.hint": "Ponovno ga spojite ili pritisnite gumb na drugom",
"horizon.splitscreen.prompt.join.one": "%s se želi pridružiti",
"horizon.splitscreen.prompt.join.many": "%s upravljača se želi pridružiti",
"horizon.splitscreen.prompt.join.yes": "Pridruži se",
"horizon.splitscreen.prompt.join.as_p1": "Igraj kao P1",
"horizon.splitscreen.prompt.join.no": "Ne",
"horizon.splitscreen.prompt.lost": "%s: upravljač je odspojen",
"horizon.splitscreen.prompt.lost.use_another": "Koristi drugi",
"horizon.splitscreen.prompt.lost.leave": "Napusti",
"horizon.splitscreen.setup.help.give_up_pad": "Vaš upravljač vas više ne upravlja i nudi se za pridruživanje (A).",
"horizon.splitscreen.setup.note.held_at": "%s do %s: A ga ispušta ovdje, B ga vraća natrag.",
"horizon.splitscreen.setup.note.held": "%s podignuto: lijevo/desno bira gdje, A ispušta, B vraća natrag.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Sakriva modele iza neprozirnih blokova. Asinkrone provjere izvan dretve; entiteti obavljaju takt.",
"Block Entities": "Blokovski entiteti",
"Shulker Boxes": "Shulker kutije",
"Reuses settled boxes; lid movement stays animated.": "Ponovno koristi smirene kutije; kretanje poklopca ostaje animirano.",
"Signs": "Znakovi",
"Reuses sign text until text, light, or font changes.": "Ponovno koristi tekst znaka dok se tekst, svjetlo ili font ne promijene.",
"Decorated Pots": "Ukrašeni lonci",
"Reuses still pots; break wobble and sherd changes redraw.": "Ponovno koristi mirujuće lonce; podrhtavanje pri razbijanju i promjene krhotina ponovno crtaju.",
"Bells": "Zvona",
"Reuses still bells; ringing stays animated.": "Ponovno koristi mirujuća zvona; zvonjava ostaje animirana.",
"Chests": "Škrinje",
"Reuses closed chests; moving lids stay animated.": "Ponovno koristi zatvorene škrinje; pokretni poklopci ostaju animirani.",
"Banners": "Zastave",
"Reuses poles; flags and pattern layers still update.": "Ponovno koristi stupove; zastave i slojevi uzoraka se i dalje ažuriraju.",
"Copper Golem Statues": "Kipovi bakrenog golemat",
"Reuses statue geometry until its block state changes.": "Ponovno koristi geometriju kipa dok se stanje njegovog bloka ne promijeni.",
"Leaf Culling": "Odbacivanje lišća",
"Every leaf face. Vanilla's fancy leaves.": "Svaka ploha lišća. Vanilino ukrašeno lišće.",
"Distant Plant Dither": "Protresanje udaljenih biljaka",
"Keeps fine plants in high-res packs from fading far away.": "Sprečava da sitne biljke u paketima visoke razlučivosti izblijede u daljini.",
"Screenshot Quality": "Kvaliteta snimke zaslona",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2 snimke zaslona u ovoliko puta većoj rezoluciji od igre. Više traje dulje.",
"Frame Generation": "Generiranje sličica",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Prikazuje izmišljenu sličicu između svake dvije stvarne: glađe, malo veća latencija.",
"Shadow Cache": "Predmemorija sjena",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Ponovno koristi mapu sjena paketa kada se sunce, pogled i svijet podudaraju. Zadano isključeno.",
"Find BlueMap": "Pronađi BlueMap",
"On servers, finds their BlueMap web map for far terrain. Next join.": "Na poslužiteljima pronalazi njihov BlueMap web preglednik karata za udaljeni teren. Sljedeće pridruživanje.",
"BlueMap Radius": "BlueMap polumjer",
"How far around you BlueMap tiles are downloaded. Next join.": "Koliko se daleko oko vas preuzimaju BlueMap pločice. Sljedeće pridruživanje.",
"Synchronous": "Sinkrono",
"Asynchronous": "Asinkrono",
"Solid": "Čvrsto",
"Solid Aggressive": "Agresivno čvrsto",
"Hollow": "Šuplje",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Lišće koje je u potpunosti unutar krošnje crta se čvrsto: manje prozirnih slojeva.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "Lišće s jednom otvorenom stranom također se crta čvrsto: brži, grublji rubovi.",
"Drops faces between leaves: faster, but gaps look through.": "Uklanja plohe između lišća: brže, ali praznine propuštaju pogled.",
"Hotbar: Previous Slot": "Vrpca predmeta: Prethodni utor",
"Hotbar: Next Slot": "Vrpca predmeta: Sljedeći utor",
"horizon.agent.connected.one": "Povezano: 1 veza agenta",
"horizon.agent.connected.many": "Povezano: %s veza agenta",
"horizon.agent.connected.one_ago": "Povezano: 1 veza agenta, posljednja naredba prije %s s",
"horizon.agent.connected.many_ago": "Povezano: %s veza agenta, posljednja naredba prije %s s",
"horizon.agent.waiting": "Čeka se agent.",
"horizon.agent.off": "Isključeno: agenti ne mogu pristupiti ovoj igri.",
"horizon.agent.session_opened": "Ovu sesiju je otvorio program koji je pokrenuo igru.",
"horizon.agent.socket": "Utičnica: %s (kopirajte pojedinosti za njezinu punu stazu)",
"horizon.agent.local_only": "Samo lokalno: agenti na ovom računalu, nikada mreža.",
"%s nits": "%s nita",
"Auto (%s nits)": "Automatski (%s nita)",
"%.2f nits": "%.2f nita",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Sakriva modele iza neprozirnih blokova. Entiteti se ažuriraju. Isključeno s prilagođenim paketima.",
"horizon.extras.repro_keys": "Prečaci za snimanje",
"horizon.extras.repro_keys.tooltip": "F3+R pokreće ili zaustavlja snimanje reprodukcije, a F3+E zapisuje paket. Zadano isključeno: snimanje kopira svijet.",
"horizon.agent.record_session": "Započni sesiju snimanja",
"horizon.agent.record_stop": "Zaustavi sesiju snimanja",
"horizon.agent.export_repro": "Izvezi paket reprodukcije",
"horizon.repro.started": "Snimanje započeto; F3+R ga zaustavlja i zapisuje paket.",
"horizon.repro.stopped": "Snimanje zaustavljeno: %s",
"horizon.repro.bundle": "Paket reprodukcije: %s",
"horizon.repro.error": "Reprodukcija: %s",
"horizon.repro.no_recording": "Još nema snimke za izvoz.",
"horizon.repro.replay_started": "Reprodukcija započeta: %s"
}
@@ -0,0 +1,433 @@
{
"horizon.options.controller": "Kontroler",
"horizon.options.vibration": "Getaran",
"horizon.options.adaptive_triggers": "Pemicu Adaptif",
"horizon.options.lightbar": "Bilah Lampu",
"horizon.options.look_sensitivity": "Sensitivitas Pandangan",
"horizon.options.invert_x": "Balikkan Pandangan X",
"horizon.options.invert_y": "Balikkan Pandangan Y",
"horizon.options.sneak_button": "Tombol Mengendap-endap",
"horizon.options.move_deadzone": "Zona Mati Gerakan",
"horizon.options.look_deadzone": "Zona Mati Pandangan",
"horizon.options.button_glyphs": "Simbol Tombol",
"horizon.options.menu_navigation": "Navigasi Menu",
"horizon.options.on_screen_keyboard": "Papan Ketik Layar",
"horizon.options.chat_delay": "Penundaan Obrolan",
"horizon.options.lightbar.health": "Nyawa",
"horizon.options.lightbar.dimension": "Dimensi",
"horizon.options.glyphs.auto": "Otomatis",
"horizon.options.menu_focus": "Fokus",
"horizon.options.menu_pointer": "Penunjuk",
"horizon.options.keyboard.auto": "Otomatis",
"horizon.options.unavailable": "Fitur ini belum tersedia di Horizon.",
"horizon.options.search": "Cari pengaturan...",
"horizon.pages.mods": "Mod",
"horizon.pages.shader_packs": "Paket Shader",
"horizon.pages.shader_options": "Opsi Shader",
"horizon.pages.controller": "Pengaturan Kontroler",
"horizon.pages.controller_binds": "Pintasan Kontroler",
"horizon.pages.extras": "Ekstra Horizon",
"horizon.pages.settings": "Pengaturan Horizon",
"horizon.pages.agent": "Agen AI",
"horizon.split.leave": "Keluar",
"horizon.pages.split_settings": "Pengaturan Layar Terpisah",
"horizon.pages.split_profiles": "Siapa yang bermain?",
"horizon.pages.split_setup": "Pengaturan Layar Terpisah",
"horizon.pages.settings.tooltip": "Pengaturan khusus Horizon: grafis, kontroler, ekstra, dan mod.",
"horizon.pages.graphics.tooltip": "Grafis secara ringkas: setiap pengaturan menjelaskan konsumsinya.",
"horizon.pages.graphics_advanced.tooltip": "Semua pengaturan grafis, termasuk milik Horizon, beserta konsumsi masing-masing.",
"horizon.pages.controller.tooltip": "Kontroler: tombol, stik, getaran, dan navigasi menu.",
"horizon.pages.extras.tooltip": "Tambahan opsional, semuanya nonaktif secara default: game tetap vanilla hingga Anda mengaktifkannya.",
"horizon.pages.agent.tooltip": "Biarkan agen AI Anda sendiri yang bermain: horizon-ctl dan klien MCP di komputer ini.",
"horizon.pages.split_profiles.tooltip": "Siapa yang bermain: profil yang disimpan di folder game ini.",
"horizon.pages.split_setup.tooltip": "Jendela dan monitor, serta siapa yang bermain di mana.",
"horizon.pages.shader_packs.tooltip": "Paket shader dari folder shaderpacks: milik Horizon sendiri dan yang berformat shaders/. Hanya satu yang berjalan dalam satu waktu.",
"horizon.pages.mods.tooltip": "Mod yang dibuat untuk Horizon, dari folder horizon-mods. Masing-masing berjalan dalam sandbox dan nonaktif hingga Anda mengaktifkannya.",
"horizon.pages.open.graphics": "Grafis...",
"horizon.pages.open.graphics_advanced": "Grafis Lanjutan...",
"horizon.pages.open.shader_packs": "Paket Shader...",
"horizon.pages.open.controller": "Pengaturan Kontroler...",
"horizon.pages.open.split_setup": "Layar Terpisah...",
"horizon.pages.open.extras": "Ekstra...",
"horizon.pages.open.mods": "Mod...",
"horizon.pages.open.agent": "Agen AI...",
"horizon.pages.open.horizon_graphics": "Grafis Horizon...",
"horizon.pages.open.controller_binds": "Pintasan Kontroler...",
"horizon.pages.open.horizon_settings": "Pengaturan Horizon...",
"horizon.mods.empty": "Tidak ada mod: masukkan file .hmod ke dalam folder horizon-mods",
"horizon.mods.stopped": " (Berhenti)",
"horizon.mods.not_built": " (Belum dibangun)",
"horizon.mods.dev": " (Pengembang)",
"horizon.mods.settings": "Pengaturan",
"horizon.mods.open_folder": "Buka Folder Mod",
"horizon.shader_packs.empty": "Tidak ada paket shader: masukkan ke dalam folder shaderpacks",
"horizon.shader_options.open": "Opsi Shader...",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Membaca opsi paket shader...",
"horizon.agent.copy_details": "Salin Detail Koneksi",
"horizon.multiplayer.join": "Gabung",
"horizon.multiplayer.cancel_reconnecting": "Batalkan Menyambungkan Ulang",
"horizon.multiplayer.join_friend": "Gabung dengan Teman",
"horizon.multiplayer.invite_code": "Kode Undangan",
"horizon.multiplayer.relay": "Relay (opsional)",
"Preset": "Prasetel",
"LOD Distance": "Jarak LOD",
"Far Detail": "Detail Jauh",
"Cave Culling": "Penyortiran Gua (Cave Culling)",
"Entity Culling": "Penyortiran Entitas (Entity Culling)",
"Chunk Updates": "Pembaruan Chunk",
"Upload Budget": "Anggaran Unggah",
"Sky": "Langit",
"Fog": "Kabut",
"Weather": "Cuaca",
"Animated Textures": "Tekstur Animasi",
"Present Mode": "Mode Presentasi",
"Frame Pacing": "Pengaturan Waktu Bingkai (Frame Pacing)",
"Worker Threads": "Thread Pekerja",
"Resolution Scale": "Skala Resolusi",
"Upscaling": "Peningkatan Skala (Upscaling)",
"Paper White": "Putih Kertas",
"Peak Brightness": "Kecerahan Puncak",
"Black Level": "Level Hitam",
"UI Brightness": "Kecerahan UI",
"Battery Mode": "Mode Baterai",
"Graphics API": "API Grafis",
"Translucent Batching": "Pemrosesan Kelompok Tembus Cahaya",
"Performance Overlay": "Overlay Performa",
"Full Detail Distance": "Jarak Detail Penuh",
"On": "Aktif",
"Off": "Nonaktif",
"Unlimited": "Tanpa batas",
"Auto": "Otomatis",
"Custom": "Kustom",
"Performance": "Performa",
"Balanced": "Seimbang",
"Quality": "Kualitas",
"Ultra Performance": "Performa Ultra",
"Native": "Natif",
"Full": "Penuh",
"Reduced": "Dikurangi",
"Efficient": "Efisien",
"Precise": "Tepat",
"Auto (VSync)": "Otomatis (VSync)",
"Immediate": "Langsung (Immediate)",
"FPS and Graph": "FPS dan Grafik",
"No cost": "Tanpa beban",
"Low cost": "Beban rendah",
"Medium cost": "Beban sedang",
"High cost": "Beban tinggi",
"Saves time": "Menghemat waktu",
"Graphics": "Grafis",
"Done": "Selesai",
"This feature is not yet available in Horizon.": "Fitur ini belum tersedia di Horizon.",
"Your display or system doesn't offer HDR output.": "Layar atau sistem Anda tidak mendukung output HDR.",
"Turn HDR on to use it.": "Aktifkan HDR untuk menggunakannya.",
"Your GPU or graphics API doesn't support it.": "GPU atau API grafis Anda tidak mendukungnya.",
"%s chunks": "%s chunk",
"%s per frame": "%s per bingkai",
"%s MiB/frame": "%s MiB/bingkai",
"%s KiB/frame": "%s KiB/bingkai",
"Auto (%s)": "Otomatis (%s)",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Jangkauan medan detail penuh. Jarak LOD dapat memperluas dunia lokal lebih jauh lagi.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Medan jauh kasar di dunia lokal. GPU terintegrasi berhenti pada 128 chunk.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Mode dikurangi menggambar medan jauh lebih kasar: lebih cepat pada GPU terintegrasi ini.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Mode dikurangi menggambar medan jauh lebih kasar, untuk GPU yang mungkin melambat.",
"Field of view. Wider shows more terrain at once.": "Bidang pandang (FOV). Semakin luas, semakin banyak medan yang terlihat sekaligus.",
"Caps frames per second; a cap saves power and heat.": "Membatasi bingkai per detik; batasan ini menghemat daya dan panas.",
"Waits for the display's refresh: no tearing, a little latency.": "Menunggu penyegaran layar: tanpa robekan gambar (tearing), namun ada sedikit latensi.",
"Size of menus and the HUD.": "Ukuran menu dan HUD.",
"Sets the options below together. Changing one makes it Custom.": "Mengatur opsi di bawah ini secara bersamaan. Mengubah salah satunya akan menjadikannya Kustom.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, api, dan cahaya matahari bersinar lebih terang dari warna putih. Memerlukan layar HDR.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Warna hitam paling gelap pada layar Anda. 0 untuk OLED; layar LCD sedikit memancarkan cahaya.",
"How bright highlights get. Auto: the peak your system reports.": "Seberapa terang sorotan cahaya. Otomatis: puncak yang dilaporkan oleh sistem Anda.",
"How bright white paper and menus look. Auto: your desktop's white.": "Seberapa terang tampilan kertas putih dan menu. Otomatis: warna putih desktop Anda.",
"How bright the HUD and menus are, against paper white.": "Seberapa terang HUD dan menu dibandingkan dengan putih kertas.",
"How far mobs, crops and machines keep working. CPU time.": "Seberapa jauh mob, tanaman, dan mesin tetap bekerja. Waktu CPU.",
"How far away mobs and items are drawn, as a share of normal.": "Seberapa jauh mob dan item digambar, sebagai persentase dari normal.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Lewati medan yang tersembunyi di balik bagian tertutup. Nonaktif memakan banyak sumber daya.",
"Hides entities behind opaque blocks.": "Sembunyikan entitas di balik blok opak.",
"No entity occlusion culling.": "Tidak ada penyortiran oklusi entitas.",
"CPU occlusion test runs this frame.": "Uji oklusi CPU berjalan pada bingkai ini.",
"Occlusion checks run on a worker thread.": "Pemeriksaan oklusi berjalan pada thread pekerja.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Bagian mesh diunggah per bingkai. Nilai lebih rendah membuat pemuatan lebih mulus.",
"Soft shadows in corners. Costs chunk building time only.": "Bayangan lembut di sudut-sudut. Hanya memakan waktu pembuatan chunk.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Salinan tekstur blok yang lebih kecil untuk medan jauh: mengurangi kedipan.",
"Blends grass and water colours across biomes when building chunks.": "Memadukan warna rumput dan air di berbagai bioma saat membangun chunk.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Lapisan awan: nonaktif, datar (cepat), atau 3D (keren).",
"How far clouds reach, in chunks.": "Seberapa jauh jangkauan awan, dalam satuan chunk.",
"Draws the sky, sun, moon and stars.": "Menggambar langit, matahari, bulan, dan bintang.",
"Distance fog. Off shows the edge of the loaded world.": "Kabut jarak jauh. Nonaktif akan menampilkan tepi dunia yang dimuat.",
"Draws rain and snow.": "Menggambar hujan dan salju.",
"How far rain and snow fall around you.": "Seberapa jauh hujan dan salju turun di sekitar Anda.",
"How many particles effects spawn.": "Berapa banyak efek partikel yang muncul.",
"Round shadows under mobs and items.": "Bayangan bulat di bawah mob dan item.",
"Leaves with see-through gaps. Off draws them solid.": "Daun dengan celah transparan. Nonaktif akan menggambarnya secara padat.",
"Sorts water and glass more exactly behind each other.": "Mengurutkan air dan kaca secara lebih presisi di belakang satu sama lain.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Pembangunan ulang chunk mana yang menunggu bingkai: tidak ada, di sekitar, atau milik Anda.",
"Animates water, lava, fire and other moving textures.": "Menghidupkan animasi air, lava, api, dan tekstur bergerak lainnya.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Pixel dunia per pixel layar. Di bawah 100% lebih cepat pada GPU.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Menghaluskan tepi bergerigi. FXAA: lebih lembut. TAA: paling halus, objek bergerak dapat meninggalkan jejak.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Merender dunia lebih kecil dan membangunnya ulang dengan TAA. Lebih cepat dan lembut.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Output HDR pada layar HDR: lava, api, dan cahaya matahari bersinar lebih terang.",
"How frames reach the screen while VSync is off.": "Bagaimana bingkai mencapai layar saat VSync nonaktif.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Efisien: tidur hingga bingkai berikutnya; Tepat: berputar aktif, seperti versi vanilla.",
"Fills the screen (borderless).": "Memenuhi layar (tanpa batas/borderless).",
"Brightens dark places.": "Mencerahkan tempat gelap.",
"Lowers the frame rate when minimised, or idle too.": "Menurunkan kecepatan bingkai saat diminimalkan atau sedang menganggur (idle).",
"Sways the view while walking.": "Mengayunkan pandangan saat berjalan.",
"Fades newly loaded chunks in.": "Memunculkan chunk yang baru dimuat secara perlahan (fade-in).",
"Darkens the screen's edges.": "Menggelapkan bagian tepi layar.",
"Shows when the world saves.": "Menunjukkan saat dunia disimpan.",
"Shows attack cooldown.": "Menunjukkan waktu pemulihan serangan (cooldown).",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "Batasan 60 FPS (30 di menu atau saat tidak fokus), setengah dari pekerja: lebih hemat daya.",
"Threads that generate, light and mesh chunks. Next start.": "Thread yang menghasilkan, memberi pencahayaan, dan memetakan mesh chunk. Mulai peluncuran berikutnya.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, lalu DirectX 12 (Windows) atau OpenGL (Linux) jika gagal. Mulai peluncuran berikutnya.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Air dan kaca dalam satu buffer: panggilan gambar lebih sedikit, sedikit lebih banyak VRAM. Mulai peluncuran berikutnya.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Data mesh yang dikirim ke GPU per bingkai. Nilai lebih rendah membuat pemuatan lebih mulus.",
"Frame rate and frame times over the game, with a graph.": "Kecepatan bingkai dan waktu bingkai selama game berjalan, beserta grafiknya.",
"horizon.multiplayer.finding_friend": "Mencari dunia teman Anda...",
"horizon.multiplayer.not_signed_in": "Server ini berada dalam mode online. Masuk dengan akun Microsoft Anda di peluncur Horizon untuk bergabung.",
"horizon.multiplayer.invalid_address": "Alamat server tidak valid: %s",
"horizon.multiplayer.connected_friend_direct": "Terhubung langsung ke teman Anda...",
"horizon.multiplayer.connected_friend_relay": "Terhubung ke teman Anda melalui relay...",
"General": "Umum",
"Advanced": "Lanjutan",
"Undo": "Urungkan",
"Search options": "Cari opsi",
"Shortcut": "Pintasan",
"Distance": "Jarak",
"Display": "Tampilan",
"Interface": "Antarmuka",
"Sky and weather": "Langit dan cuaca",
"Lighting and entities": "Pencahayaan dan entitas",
"Textures": "Tekstur",
"Image": "Gambar",
"Chunk loading": "Pemuatan chunk",
"Power": "Daya",
"Frames": "Bingkai",
"Brightness": "Kecerahan",
"How far you see, the window and the HUD.": "Jarak pandang, jendela, dan HUD.",
"The sky and weather, lighting, textures and the image.": "Langit dan cuaca, pencahayaan, tekstur, serta gambar.",
"Chunk loading, culling and power use.": "Pemuatan chunk, culling, dan penggunaan daya.",
"How frames reach the screen, and the performance overlay.": "Bagaimana bingkai mencapai layar, serta overlay performa.",
"Brighter-than-white output on an HDR display.": "Output yang lebih terang dari warna putih pada layar HDR.",
"Results for \"%s\"": "Hasil untuk \"%s\"",
"No settings match.": "Tidak ada pengaturan yang cocok.",
"horizon.title.not_affiliated": "Tidak berafiliasi dengan Mojang atau Microsoft",
"horizon.extras.header.lighting": "Pencahayaan",
"horizon.extras.header.third_person": "Sudut pandang orang ketiga",
"horizon.extras.header.particles": "Partikel",
"horizon.extras.header.inventory": "Inventaris",
"horizon.extras.header.chat": "Obrolan",
"horizon.extras.header.sound": "Suara",
"horizon.extras.header.menus": "Menu",
"horizon.extras.dynamic_lights": "Lampu Dinamis",
"horizon.extras.own_name_tag": "Tanda nama sendiri",
"horizon.extras.hide_block_particles": "Sembunyikan partikel pecahan blok",
"horizon.extras.auto_reconnect": "Sambung ulang otomatis",
"horizon.extras.ui_sound_reserve": "Cadangkan channel untuk suara UI",
"horizon.extras.mouse_tweaks": "Kontrol Mouse Tweaks",
"horizon.extras.mouse_tweaks_wheel": "Roda gulir memindahkan item",
"horizon.extras.sort": "Tombol urutkan",
"horizon.extras.sort_key": "Tombol urutkan",
"horizon.extras.no_ai": "Tanpa AI",
"horizon.extras.agent_control": "Kontrol Agen",
"horizon.extras.splitscreen": "Layar Terpisah",
"horizon.extras.chat_animation": "Obrolan mulus",
"horizon.extras.chat_indicator": "Indikator pesan",
"horizon.extras.wheel_cycles_settings": "Roda gulir mengubah pengaturan",
"horizon.extras.fullbright.tooltip": "Menampilkan blok dan entitas dengan pencahayaan penuh, bahkan di area gelap.",
"horizon.extras.dynamic_lights.tooltip": "Obor dan sumber cahaya lain yang dipegang, dijatuhkan, atau terbakar akan menerangi blok di sekitarnya.",
"horizon.extras.own_name_tag.tooltip": "Menampilkan nama Anda di atas kepala dalam sudut pandang orang ketiga, seperti yang dilihat pemain lain. Disembunyikan saat server menyembunyikan nama tim Anda atau saat Anda tak terlihat.",
"horizon.extras.hide_block_particles.tooltip": "Menyembunyikan partikel serpihan yang meledak dari blok yang hancur dan berjatuhan dari blok yang sedang Anda tambang. Partikel lainnya tetap ada.",
"horizon.extras.auto_reconnect.tooltip": "Bergabung kembali ke server setelah kehilangan koneksi atau muat ulang, tidak pernah setelah dikeluarkan (kick) atau diblokir (ban).",
"horizon.extras.ui_sound_reserve.tooltip": "Suara di dunia menyisakan 16 dari 247 channel suara tetap bebas, sehingga ledakan besar tidak akan meredam suara klik menu.",
"horizon.extras.mouse_tweaks.tooltip": "Seret dengan klik kanan menaruh satu per slot, seret dengan klik kiri mengumpulkan item yang dipegang, Shift + seret klik kiri memindahkan setiap slot.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Gulir ke bawah mengirim satu item dari slot ke sisi lain, gulir ke atas mengembalikannya.",
"horizon.extras.sort.tooltip": "Mengurutkan inventaris, peti (chest), barel, ender chest, dan shulker box sesuai urutan tab kreatif, sekaligus menggabungkan tumpukan.",
"horizon.extras.sort_key.tooltip": "Mengurutkan bagian di bawah kursor. Klik, lalu tekan tombol; Escape untuk melepasnya.",
"horizon.extras.no_ai.tooltip": "Menyembunyikan dan menonaktifkan setiap fitur AI: kontrol agen, horizon-ctl, dan server MCP. Termasuk tombol pada peluncur.",
"horizon.extras.agent_control.tooltip": "Agen AI Anda sendiri dapat memainkan game ini melalui horizon-ctl atau klien MCP, khusus di komputer ini.",
"horizon.extras.discord_presence.tooltip": "Menampilkan 'Playing Horizon' di profil Discord Anda saat Discord berjalan: menu, pemain tunggal atau multiplayer, dimensi, dan durasi bermain. Tidak pernah menampilkan nama dunia atau alamat server.",
"Calibrate...": "Kalibrasi...",
"Calibrate Anyway...": "Tetap Kalibrasi...",
"Use Automatic Setup": "Gunakan Pengaturan Otomatis",
"Shortcut: %s": "Pintasan: %s",
"Next": "Berikutnya",
"HDR Calibration %s/%s: %s": "Kalibrasi HDR %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Mengatur ketiga hal di bawah ini ke mode layar penuh, satu gambar uji dalam satu waktu.",
"A key that opens this screen from the game. Not bound at first.": "Tombol untuk membuka layar ini dari dalam game. Belum diatur sebelumnya.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Layar Anda tidak melaporkan tingkat kecerahannya: lakukan kalibrasi untuk mengaturnya.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Sudah diatur untuk layar Anda. Kalibrasi ulang hanya jika tampilan HDR terlihat keliru.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Mengembalikan Kecerahan Puncak dan Putih Kertas ke pengaturan awal layar Anda.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Diatur untuk layar Anda. Biarkan pada posisi Otomatis kecuali tampilannya kurang pas.",
"Lower it until the logo just disappears into the black.": "Turunkan hingga logo hampir tidak terlihat di dalam warna hitam.",
"Raise it until the logo just disappears into the bright square.": "Naikkan hingga logo hampir tidak terlihat di dalam kotak yang terang.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Atur kartu ke warna putih yang nyaman dilihat dalam waktu lama, sesuai pencahayaan tempat Anda bermain.",
"horizon.splitscreen.profiles.forget": "Lupakan",
"horizon.splitscreen.profiles.create": "Buat",
"horizon.splitscreen.profiles.you": " - Anda",
"horizon.splitscreen.profiles.forget_named": "Lupakan %s",
"horizon.splitscreen.profiles.previous": "Sebelumnya",
"horizon.splitscreen.profiles.next": "Berikutnya",
"horizon.splitscreen.profiles.new_button": "Profil Baru...",
"horizon.splitscreen.profiles.forget_title": "Lupakan profil?",
"horizon.splitscreen.profiles.forget_leaves": "Profil ini akan dihapus dari daftar instans ini.",
"horizon.splitscreen.profiles.forget_kept": "Progres dunia dan pengaturan tetap disimpan.",
"horizon.splitscreen.profiles.new_title": "Profil Baru",
"horizon.splitscreen.profiles.name_hint": "Nama (3 hingga 16 huruf, angka, _)",
"horizon.splitscreen.profiles.switch_title": "Ganti profil",
"horizon.splitscreen.profiles.offline_note": "Profil offline milik instans ini",
"horizon.splitscreen.profiles.move": "Pindahkan posisi bermain",
"horizon.splitscreen.profiles.move_place": "Pindahkan posisi bermain: %s",
"horizon.friends.title": "Bermain dengan Teman",
"horizon.friends.hint": "Teman menempelkan kode setelah mengeklik tombol Teman di layar judul, melalui Koneksi Langsung (Direct Connection), atau di halaman Main peluncur.",
"horizon.friends.copied": "Disalin",
"horizon.friends.copy_code": "Salin Kode",
"horizon.friends.stop_sharing": "Berhenti Berbagi",
"horizon.friends.none_yet": "Belum ada teman di sini.",
"horizon.friends.here_now": "Di sini sekarang: %s",
"horizon.friends.share_intro": "Bagikan dunia ini di balik kode undangan: dunia akan terbuka untuk LAN, dan teman di internet dapat bergabung menggunakan kode tersebut. Tidak ada server kami yang dilibatkan.",
"horizon.friends.start_sharing": "Mulai Berbagi",
"horizon.friends.troubleshooting": "Pemecahan Masalah...",
"horizon.friends.stopped": "Berhenti berbagi kode undangan.",
"horizon.friends.short_code_changed": "Seseorang terus mencoba kode pendek yang salah, sehingga kode telah diubah.",
"horizon.friends.wrong_code": "Seseorang mencoba memasukkan kode yang salah.",
"horizon.friends.code_ready": "Kode undangan Anda sudah siap: Menu Game -> Bermain dengan Teman. Salin dan kirimkan ke teman-teman Anda.",
"horizon.friends.share_failed": "Tidak dapat membagikan dunia: %s",
"horizon.friends.finding_route": "Mencari cara agar teman dapat menjangkau Anda…",
"horizon.osk.space": "Spasi",
"horizon.osk.delete": "Hapus",
"horizon.osk.type": "Ketik",
"horizon.osk.symbols": "Simbol",
"horizon.osk.close": "Tutup",
"horizon.extras.chat_animation.tooltip": "Baris obrolan baru meluncur dari kiri sambil memudar, alih-alih langsung muncul seutuhnya.",
"horizon.extras.chat_indicator.tooltip": "Bilah berwarna di sebelah kiri baris obrolan.",
"horizon.extras.wheel_cycles_settings.tooltip": "Roda gulir di atas tombol pengaturan akan mengubah nilainya, seperti pada versi vanilla. Jika nonaktif, roda gulir hanya menggulir daftar.",
"horizon.extras.splitscreen.tooltip": "Di dalam dunia, kontroler lain dapat bergabung sebagai pemain baru: tekan A saat diminta, lalu pilih profil dan tempat bermain.",
"horizon.splitscreen.setup.monitor_unknown": "sebuah monitor",
"horizon.splitscreen.setup.window": "Jendela %s",
"horizon.splitscreen.setup.new_window": "Jendela baru",
"horizon.splitscreen.setup.fill_monitor": "Isi %s",
"horizon.splitscreen.setup.done": "Selesai",
"horizon.splitscreen.setup.on": "Aktif",
"horizon.splitscreen.setup.off": "Nonaktif",
"horizon.splitscreen.setup.player": "Pemain %s",
"horizon.splitscreen.setup.revert": "Kembalikan",
"horizon.splitscreen.setup.apply": "Terapkan",
"horizon.splitscreen.setup.warning.empty_main": "Tidak ada yang bermain di Jendela 1: jendela akan disembunyikan",
"horizon.splitscreen.setup.warning.empty_close": "Tidak ada yang bermain di Jendela %s: jendela akan ditutup saat Terapkan diklik",
"horizon.splitscreen.setup.warning.empty_new": "Tidak ada yang bermain di Jendela %s: jendela tidak akan dibuka",
"horizon.splitscreen.setup.warning.on_main": "Jendela %s mengisi monitor Jendela 1: para pemainnya bermain di Jendela 1",
"horizon.splitscreen.setup.warning.unplugged": "Monitor Jendela %s tidak terhubung: para pemainnya bermain di Jendela 1",
"horizon.splitscreen.setup.status.off": "Layar terpisah nonaktif.",
"horizon.splitscreen.setup.status.enable_hint": "Aktifkan, lalu tekan tombol pada kontroler lain di dalam dunia.",
"horizon.splitscreen.setup.status.join_hint": "Tekan A untuk bergabung dan memilih profil.",
"horizon.splitscreen.setup.status.idle": "Layar terpisah menganggur",
"horizon.splitscreen.setup.status.alone": "Tidak ada pemain lokal lainnya.",
"horizon.splitscreen.setup.status.solo_controls": "Kontrol solo dan umpan balik kontroler tidak berubah.",
"horizon.splitscreen.setup.window_empty": "Jendela %s: kosong",
"horizon.splitscreen.setup.note.select": "Klik pemain, atau seret ke jendela atau monitor.",
"horizon.splitscreen.setup.note.apply": "Terapkan untuk bermain dengan cara ini, Kembalikan untuk membatalkan.",
"horizon.splitscreen.setup.note.place": "Di mana %s sebaiknya bermain?",
"horizon.splitscreen.setup.this_player": "pemain ini",
"horizon.splitscreen.setup.switch_disabled": "Layar terpisah nonaktif setelah pemain lain keluar (melalui menu jeda mereka).",
"horizon.splitscreen.setup.window_place": "Jendela %s (%s dari %s)",
"horizon.splitscreen.setup.names": "Nama",
"horizon.splitscreen.setup.identify": "Identifikasi",
"horizon.splitscreen.setup.layout.two": "Berbagi berdua: %s",
"horizon.splitscreen.setup.layout.three": "Berbagi bertiga: %s",
"horizon.splitscreen.setup.layout.two.0": "Otomatis",
"horizon.splitscreen.setup.layout.two.1": "Berdampingan",
"horizon.splitscreen.setup.layout.two.2": "Atas dan bawah",
"horizon.splitscreen.setup.layout.three.0": "Satu lebar di atas",
"horizon.splitscreen.setup.layout.three.1": "Satu tinggi di kiri",
"horizon.splitscreen.setup.layout.three.2": "Berdampingan",
"horizon.splitscreen.setup.help.player": "Klik atau seret %s untuk memindahkannya.",
"horizon.splitscreen.setup.help.layout_auto": "%s (berdampingan saat jendela lebar). Tekan untuk mengubah.",
"horizon.splitscreen.setup.help.layout": "%s. Tekan untuk mengubah.",
"horizon.splitscreen.setup.help.switch": "Layar terpisah untuk komputer ini, aktif atau nonaktif.",
"horizon.splitscreen.setup.help.identify": "Tampilan setiap pemain menunjukkan di jendela mana mereka berada.",
"horizon.splitscreen.setup.help.names": "Nama setiap pemain di sudut tampilan mereka.",
"horizon.splitscreen.setup.help.revert": "Batalkan perubahan yang belum diterapkan.",
"horizon.splitscreen.setup.help.apply": "Mainkan sesuai tata letak di peta.",
"horizon.splitscreen.setup.help.unchanged": "Belum ada yang diubah.",
"horizon.splitscreen.setup.give_up_pad": "Lepas Kontroler",
"horizon.splitscreen.prompt.controller_connected": "Kontroler terhubung",
"horizon.splitscreen.prompt.controller_disconnected": "Kontroler terputus",
"horizon.splitscreen.prompt.controller_disconnected.hint": "Sambungkan kembali, atau tekan tombol pada kontroler lain",
"horizon.splitscreen.prompt.join.one": "%s ingin bergabung",
"horizon.splitscreen.prompt.join.many": "%s kontroler ingin bergabung",
"horizon.splitscreen.prompt.join.yes": "Gabung",
"horizon.splitscreen.prompt.join.as_p1": "Bermain sebagai P1",
"horizon.splitscreen.prompt.join.no": "Tidak",
"horizon.splitscreen.prompt.lost": "%s: kontroler terputus",
"horizon.splitscreen.prompt.lost.use_another": "Gunakan yang lain",
"horizon.splitscreen.prompt.lost.leave": "Keluar",
"horizon.splitscreen.setup.help.give_up_pad": "Kontroler Anda berhenti mengontrol Anda dan ditawarkan untuk bergabung (A).",
"horizon.splitscreen.setup.note.held_at": "%s ke %s: A menjatuhkannya di sini, B mengembalikannya.",
"horizon.splitscreen.setup.note.held": "%s diambil: kiri/kanan memilih tempat, A menjatuhkan, B mengembalikan.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Sembunyikan model di balik blok opak. Entitas tetap diperbarui. Nonaktif saat menggunakan paket kustom.",
"Block Entities": "Entitas Blok",
"Shulker Boxes": "Shulker Box",
"Reuses settled boxes; lid movement stays animated.": "Menggunakan kembali box yang diam; gerakan tutupnya tetap dianimasikan.",
"Signs": "Papan",
"Reuses sign text until text, light, or font changes.": "Menggunakan kembali teks papan hingga teks, cahaya, atau font berubah.",
"Decorated Pots": "Pot Terdekorasi",
"Reuses still pots; break wobble and sherd changes redraw.": "Menggunakan kembali pot diam; goyangan saat pecah dan perubahan pecahan digambar ulang.",
"Bells": "Lonceng",
"Reuses still bells; ringing stays animated.": "Menggunakan kembali lonceng diam; deringannya tetap dianimasikan.",
"Chests": "Peti",
"Reuses closed chests; moving lids stay animated.": "Menggunakan kembali peti tertutup; tutup yang bergerak tetap dianimasikan.",
"Banners": "Spanduk",
"Reuses poles; flags and pattern layers still update.": "Menggunakan kembali tiang; bendera dan lapisan pola tetap diperbarui.",
"Copper Golem Statues": "Patung Golem Tembaga",
"Reuses statue geometry until its block state changes.": "Menggunakan kembali geometri patung hingga status bloknya berubah.",
"Leaf Culling": "Penyortiran Daun",
"Every leaf face. Vanilla's fancy leaves.": "Setiap sisi daun. Daun detail ala vanilla.",
"Distant Plant Dither": "Dither Tanaman Jauh",
"Keeps fine plants in high-res packs from fading far away.": "Mencegah tanaman detail pada paket beresolusi tinggi agar tidak memudar dari jarak jauh.",
"Screenshot Quality": "Kualitas Tangkapan Layar",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Tangkapan layar F2 pada kelipatan resolusi game ini. Nilai lebih tinggi memakan waktu lebih lama.",
"Frame Generation": "Pembuatan Bingkai (Frame Generation)",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Menampilkan bingkai buatan di antara setiap dua bingkai nyata: lebih mulus, dengan sedikit tambahan lag.",
"Shadow Cache": "Cache Bayangan",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Menggunakan kembali peta bayangan paket saat posisi matahari, pandangan, dan dunia cocok. Nonaktif secara default.",
"Find BlueMap": "Cari BlueMap",
"On servers, finds their BlueMap web map for far terrain. Next join.": "Pada server, mencari peta web BlueMap mereka untuk medan jauh. Pada saat bergabung berikutnya.",
"BlueMap Radius": "Radius BlueMap",
"How far around you BlueMap tiles are downloaded. Next join.": "Seberapa jauh tile BlueMap di sekitar Anda diunduh. Pada saat bergabung berikutnya.",
"Synchronous": "Sinkron",
"Asynchronous": "Asinkron",
"Solid": "Padat",
"Solid Aggressive": "Padat Agresif",
"Hollow": "Berongga",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Daun yang sepenuhnya berada di dalam kanopi digambar padat: lebih sedikit lapisan tembus cahaya.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "Daun dengan satu sisi terbuka juga digambar padat: lebih cepat, tepi lebih tebal.",
"Drops faces between leaves: faster, but gaps look through.": "Menghapus sisi antar daun: lebih cepat, namun celahnya tembus pandang.",
"Hotbar: Previous Slot": "Hotbar: Slot Sebelumnya",
"Hotbar: Next Slot": "Hotbar: Slot Berikutnya",
"horizon.agent.connected.one": "Terhubung: 1 koneksi agen",
"horizon.agent.connected.many": "Terhubung: %s koneksi agen",
"horizon.agent.connected.one_ago": "Terhubung: 1 koneksi agen, perintah terakhir %s d lalu",
"horizon.agent.connected.many_ago": "Terhubung: %s koneksi agen, perintah terakhir %s d lalu",
"horizon.agent.waiting": "Menunggu agen.",
"horizon.agent.off": "Nonaktif: agen tidak dapat menjangkau game ini.",
"horizon.agent.session_opened": "Dibuka untuk sesi ini oleh program yang memulai game.",
"horizon.agent.socket": "Socket: %s (salin detail untuk jalur lengkapnya)",
"horizon.agent.local_only": "Khusus lokal: agen di komputer ini, tidak pernah melalui jaringan.",
"%s nits": "%s nit",
"Auto (%s nits)": "Otomatis (%s nit)",
"%.2f nits": "%.2f nit",
"horizon.extras.repro_keys": "Pintasan Rekaman",
"horizon.extras.repro_keys.tooltip": "F3+R untuk memulai atau menghentikan rekaman reproduksi dan F3+E untuk menulis bundelnya. Nonaktif secara default: rekaman akan menyalin dunia.",
"horizon.agent.record_session": "Mulai Sesi Rekaman",
"horizon.agent.record_stop": "Hentikan Sesi Rekaman",
"horizon.agent.export_repro": "Ekspor Bundel Reproduksi",
"horizon.repro.started": "Rekaman dimulai; F3+R untuk menghentikannya dan menulis bundel.",
"horizon.repro.stopped": "Rekaman dihentikan: %s",
"horizon.repro.bundle": "Bundel reproduksi: %s",
"horizon.repro.error": "Reproduksi: %s",
"horizon.repro.no_recording": "Belum ada rekaman untuk diekspor.",
"horizon.repro.replay_started": "Pemutaran ulang dimulai: %s"
}
@@ -0,0 +1,443 @@
{
"horizon.options.controller": "컨트롤러",
"horizon.options.vibration": "진동",
"horizon.options.adaptive_triggers": "어댑티브 트리거",
"horizon.options.lightbar": "라이트바",
"horizon.options.look_sensitivity": "시점 감도",
"horizon.options.invert_x": "시점 X축 반전",
"horizon.options.invert_y": "시점 Y축 반전",
"horizon.options.sneak_button": "숨기 웅크리기 버튼",
"horizon.options.move_deadzone": "이동 데드존",
"horizon.options.look_deadzone": "시점 데드존",
"horizon.options.button_glyphs": "버튼 아이콘",
"horizon.options.menu_navigation": "메뉴 탐색",
"horizon.options.on_screen_keyboard": "화면 키보드",
"horizon.options.chat_delay": "채팅 지연",
"horizon.options.lightbar.health": "체력",
"horizon.options.lightbar.dimension": "차원",
"horizon.options.glyphs.auto": "자동",
"horizon.options.menu_focus": "포커스",
"horizon.options.menu_pointer": "포인터",
"horizon.options.keyboard.auto": "자동",
"horizon.options.unavailable": "이 기능은 아직 Horizon에서 사용할 수 없습니다.",
"horizon.options.search": "설정 검색...",
"horizon.pages.mods": "모드",
"horizon.pages.shader_packs": "셰이더 팩",
"horizon.pages.shader_options": "셰이더 옵션",
"horizon.pages.controller": "컨트롤러 설정",
"horizon.pages.controller_binds": "컨트롤러 키 설정",
"horizon.pages.extras": "Horizon 추가 기능",
"horizon.pages.settings": "Horizon 설정",
"horizon.pages.agent": "AI 에이전트",
"horizon.split.leave": "나가기",
"horizon.pages.split_settings": "화면 분할 설정",
"horizon.pages.split_profiles": "플레이어 선택",
"horizon.pages.split_setup": "화면 분할 구성",
"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 클라이언트를 통해 자체 AI 에이전트가 플레이하도록 허용합니다.",
"horizon.pages.split_profiles.tooltip": "플레이어: 이 게임 폴더에 보관되는 프로필입니다.",
"horizon.pages.split_setup.tooltip": "창과 모니터, 그리고 플레이어가 플레이할 위치입니다.",
"horizon.pages.shader_packs.tooltip": "shaderpacks 폴더의 셰이더 팩(Horizon 전용 및 shaders/ 형식). 한 번에 하나씩 실행됩니다.",
"horizon.pages.mods.tooltip": "horizon-mods 폴더의 Horizon용 모드. 각 모드는 샌드박스에서 실행되며 켤 때까지 꺼져 있습니다.",
"horizon.pages.open.graphics": "그래픽...",
"horizon.pages.open.graphics_advanced": "고급 그래픽...",
"horizon.pages.open.shader_packs": "셰이더 팩...",
"horizon.pages.open.controller": "컨트롤러 설정...",
"horizon.pages.open.split_setup": "화면 분할...",
"horizon.pages.open.extras": "추가 기능...",
"horizon.pages.open.mods": "모드...",
"horizon.pages.open.agent": "AI 에이전트...",
"horizon.pages.open.horizon_graphics": "Horizon 그래픽...",
"horizon.pages.open.controller_binds": "컨트롤러 키 설정...",
"horizon.pages.open.horizon_settings": "Horizon 설정...",
"horizon.mods.empty": "모드 없음: horizon-mods 폴더에 .hmod 파일을 넣으세요.",
"horizon.mods.stopped": " (중지됨)",
"horizon.mods.not_built": " (빌드되지 않음)",
"horizon.mods.dev": " (개발용)",
"horizon.mods.settings": "설정",
"horizon.mods.open_folder": "모드 폴더 열기",
"horizon.shader_packs.empty": "셰이더 팩 없음: shaderpacks 폴더에 넣으세요.",
"horizon.shader_options.open": "셰이더 옵션...",
"horizon.shader_options.profile": "프로필",
"horizon.shader_options.loading": "셰이더 팩 옵션을 읽는 중...",
"horizon.agent.copy_details": "연결 세부 정보 복사",
"horizon.multiplayer.join": "참가",
"horizon.multiplayer.cancel_reconnecting": "재연결 취소",
"horizon.multiplayer.join_friend": "친구 참가",
"horizon.multiplayer.invite_code": "초대 코드",
"horizon.multiplayer.relay": "릴레이 (선택 사항)",
"Preset": "프리셋",
"LOD Distance": "LOD 거리",
"Far Detail": "원거리 디테일",
"Cave Culling": "동굴 컬링",
"Entity Culling": "엔티티 컬링",
"Chunk Updates": "청크 업데이트",
"Upload Budget": "업로드 예산",
"Sky": "하늘",
"Fog": "안개",
"Weather": "날씨",
"Animated Textures": "애니메이션 텍스처",
"Present Mode": "출력 모드",
"Frame Pacing": "프레임 페이싱",
"Worker Threads": "작업자 스레드",
"Resolution Scale": "해상도 배율",
"Anti-aliasing": "안티에일리어싱",
"Upscaling": "업스케일링",
"Paper White": "페이퍼 화이트",
"Peak Brightness": "최대 밝기",
"Black Level": "블랙 레벨",
"UI Brightness": "UI 밝기",
"Battery Mode": "배터리 모드",
"Graphics API": "그래픽 API",
"Translucent Batching": "반투명 배칭",
"Performance Overlay": "성능 오버레이",
"Full Detail Distance": "최고 디테일 거리",
"On": "켬",
"Off": "끔",
"Unlimited": "제한 없음",
"Auto": "자동",
"Custom": "사용자 지정",
"Performance": "성능",
"Balanced": "균형",
"Quality": "품질",
"Ultra": "울트라",
"Ultra Performance": "최고 성능",
"Native": "네이티브",
"Full": "전체",
"Reduced": "감소됨",
"Efficient": "효율적",
"Precise": "정밀함",
"Auto (VSync)": "자동 (수직 동기화)",
"Mailbox": "메일박스",
"Immediate": "즉시",
"FPS and Graph": "FPS 및 그래프",
"No cost": "비용 없음",
"Low cost": "낮은 비용",
"Medium cost": "중간 비용",
"High cost": "높은 비용",
"Saves time": "시간 절약",
"Graphics": "그래픽",
"Done": "완료",
"This feature is not yet available in Horizon.": "이 기능은 아직 Horizon에서 사용할 수 없습니다.",
"Your display or system doesn't offer HDR output.": "디스플레이 또는 시스템이 HDR 출력을 지원하지 않습니다.",
"Turn HDR on to use it.": "사용하려면 HDR을 켜세요.",
"Your GPU or graphics API doesn't support it.": "GPU 또는 그래픽 API가 지원하지 않습니다.",
"%s chunks": "%s개 청크",
"%s per frame": "프레임당 %s",
"%s MiB/frame": "%s MiB/프레임",
"%s KiB/frame": "%s KiB/프레임",
"Auto (%s)": "자동 (%s)",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "전체 디테일 지형 범위. LOD 거리를 통해 로컬 월드를 더 멀리 확장할 수 있습니다.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "로컬 월드의 거친 원거리 지형. 내장 GPU는 128청크에서 제한됩니다.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "감소 설정 시 원거리 지형을 더 거칠게 그려 이 내장 GPU에서 더 빠르게 작동합니다.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "감소 설정 시 원거리 지형을 더 거칠게 그려 GPU 속도가 느려질 수 있습니다.",
"Field of view. Wider shows more terrain at once.": "시야각. 넓을수록 한 번에 더 많은 지형이 표시됩니다.",
"Caps frames per second; a cap saves power and heat.": "초당 최대 프레임을 제한합니다. 제한하면 전력과 발열을 줄일 수 있습니다.",
"Waits for the display's refresh: no tearing, a little latency.": "디스플레이 새로 고침을 기다립니다: 화면 찢김이 없지만 약간의 지연 시간이 발생합니다.",
"Size of menus and the HUD.": "메뉴 및 HUD의 크기입니다.",
"Sets the options below together. Changing one makes it Custom.": "아래 옵션들을 함께 설정합니다. 하나라도 변경하면 사용자 지정으로 전환됩니다.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "용암, 불, 햇빛이 흰색보다 더 밝게 빛납니다. HDR 디스플레이가 필요합니다.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "디스플레이의 가장 어두운 검은색입니다. OLED는 0이며, LCD는 약간 빛납니다.",
"How bright highlights get. Auto: the peak your system reports.": "하라이트의 최대 밝기입니다. 자동: 시스템이 보고하는 최고 값입니다.",
"How bright white paper and menus look. Auto: your desktop's white.": "하얀 종이와 메뉴의 밝기입니다. 자동: 데스크톱의 흰색을 따릅니다.",
"How bright the HUD and menus are, against paper white.": "페이퍼 화이트 대비 HUD 및 메뉴의 밝기입니다.",
"How far mobs, crops and machines keep working. CPU time.": "몹, 농작물, 기계가 계속 작동하는 거리입니다. CPU 시간이 소모됩니다.",
"How far away mobs and items are drawn, as a share of normal.": "정상 대비 몹과 아이템이 그려지는 거리 비율입니다.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "닫힌 구역 뒤에 숨겨진 지형을 건너뜁니다. 끔으로 설정 시 성능 비용이 큽니다.",
"Hides entities behind opaque blocks.": "불투명 블록 뒤의 엔티티를 숨깁니다.",
"No entity occlusion culling.": "엔티티 오클루전 컬링을 사용하지 않습니다.",
"CPU occlusion test runs this frame.": "이번 프레임에 CPU 오클루전 테스트가 실행됩니다.",
"Occlusion checks run on a worker thread.": "오클루전 검사가 작업자 스레드에서 실행됩니다.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "프레임당 업로드되는 메시 구역 수입니다. 낮을수록 로딩 시 더 부드럽습니다.",
"Soft shadows in corners. Costs chunk building time only.": "모서리의 부드러운 그림자입니다. 청크 빌드 시간 소모만 발생합니다.",
"Smaller copies of block textures for distant terrain: less shimmer.": "원거리 지형을 위한 블록 텍스처의 축소본입니다: 깜빡임이 줄어듭니다.",
"Blends grass and water colours across biomes when building chunks.": "청크 빌드 시 생물 군계 전반에 걸쳐 잔디와 물 색상을 혼합합니다.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "구름 레이어: 끔, 평면(빠름) 또는 3D(화려함).",
"How far clouds reach, in chunks.": "구름이 도달하는 거리(청크 단위).",
"Draws the sky, sun, moon and stars.": "하늘, 태양, 달, 별을 그립니다.",
"Distance fog. Off shows the edge of the loaded world.": "거리 안개. 끔으로 설정하면 로드된 월드의 가장자리가 보입니다.",
"Draws rain and snow.": "비와 눈을 그립니다.",
"How far rain and snow fall around you.": "플레이어 주변으로 비와 눈이 내리는 거리입니다.",
"How many particles effects spawn.": "생성될 파티클 효과의 수입니다.",
"Round shadows under mobs and items.": "몹과 아이템 아래의 둥근 그림자입니다.",
"Leaves with see-through gaps. Off draws them solid.": "투명한 틈이 있는 나뭇잎입니다. 끔으로 설정하면 불투명하게 그려집니다.",
"Sorts water and glass more exactly behind each other.": "물과 유리가 서로 뒤에 배치되는 순서를 더 정확하게 정렬합니다.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "어떤 청크 재구성이 프레임을 기다릴지 설정합니다: 없음, 주변 또는 내 청크.",
"Animates water, lava, fire and other moving textures.": "물, 용암, 불 및 기타 움직이는 텍스처를 애니메이션화합니다.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "화면 픽셀당 월드 픽셀 수입니다. 100% 미만으로 설정하면 GPU에서 더 빨라집니다.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "거친 모서리를 부드럽게 합니다. FXAA: 부드러움. TAA: 가장 부드러움, 움직이는 물체에 잔상이 남을 수 있음.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "월드를 작게 렌더링한 후 TAA로 재구성합니다. 더 빠르고 부드러움.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR 디스플레이에서 HDR 출력: 용암, 불, 햇빛이 더 밝게 빛납니다.",
"How frames reach the screen while VSync is off.": "수직 동기화가 꺼져있을 때 프레임이 화면에 도달하는 방식입니다.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "효율적 모드는 다음 프레임까지 대기(sleep)하고, 정밀 모드는 바닐라처럼 회전(spin)합니다.",
"Fills the screen (borderless).": "화면을 채웁니다(테두리 없는 창 모드).",
"Brightens dark places.": "어두운 곳을 밝게 합니다.",
"Lowers the frame rate when minimised, or idle too.": "창이 최소화되거나 유휴 상태일 때 프레임 레이트를 낮춥니다.",
"Sways the view while walking.": "걸을 때 시야가 흔들립니다.",
"Fades newly loaded chunks in.": "새로 로드된 청크가 서서히 나타나도록 합니다.",
"Darkens the screen's edges.": "화면 가장자리를 어둡게 합니다.",
"Shows when the world saves.": "월드가 저장되는 시점을 표시합니다.",
"Shows attack cooldown.": "공격 재사용 대기시간을 표시합니다.",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS 제한(메뉴 또는 비포커스 시 30), 작업자 절반 사용: 전력 소모 감소.",
"Threads that generate, light and mesh chunks. Next start.": "청크를 생성, 조명 및 메시화하는 스레드입니다. 다음 시작 시 적용됩니다.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, 실패 시 DirectX 12(Windows) 또는 OpenGL(Linux). 다음 시작 시 적용됩니다.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "물과 유리를 하나의 버퍼에 통합: 드로우 콜 감소, VRAM 소모 소폭 증가. 다음 시작 시 적용됩니다.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "프레임당 GPU로 전송되는 메시 데이터입니다. 낮을수록 로딩 시 더 부드럽습니다.",
"Frame rate and frame times over the game, with a graph.": "게임 플레이 중 프레임 레이트 및 프레임 타임을 그래프로 표시합니다.",
"horizon.multiplayer.finding_friend": "친구의 월드를 찾는 중...",
"horizon.multiplayer.not_signed_in": "이 서버는 온라인 모드입니다. 참여하려면 Horizon 런처에서 Microsoft 계정으로 로그인하세요.",
"horizon.multiplayer.invalid_address": "잘못된 서버 주소: %s",
"horizon.multiplayer.connected_friend_direct": "친구에게 직접 연결되었습니다...",
"horizon.multiplayer.connected_friend_relay": "릴레이를 통해 친구에게 연결되었습니다...",
"General": "일반",
"Advanced": "고급",
"Undo": "실행 취소",
"Search options": "옵션 검색",
"Shortcut": "단축키",
"Distance": "거리",
"Display": "디스플레이",
"Interface": "인터페이스",
"Sky and weather": "하늘 및 날씨",
"Lighting and entities": "조명 및 엔티티",
"Textures": "텍스처",
"Image": "이미지",
"Chunk loading": "청크 로딩",
"Culling": "컬링",
"Power": "전원",
"Frames": "프레임",
"Overlay": "오버레이",
"Output": "출력",
"Brightness": "밝기",
"How far you see, the window and the HUD.": "시야 거리, 창 및 HUD 설정입니다.",
"The sky and weather, lighting, textures and the image.": "하늘 및 날씨, 조명, 텍스처, 이미지 설정입니다.",
"Chunk loading, culling and power use.": "청크 로딩, 컬링 및 전력 사용량 설정입니다.",
"How frames reach the screen, and the performance overlay.": "프레임이 화면에 도달하는 방식 및 성능 오버레이 설정입니다.",
"Brighter-than-white output on an HDR display.": "HDR 디스플레이에서 흰색보다 더 밝은 출력입니다.",
"Results for \"%s\"": "\"%s\"에 대한 검색 결과",
"No settings match.": "일치하는 설정이 없습니다.",
"horizon.title.not_affiliated": "Mojang 또는 Microsoft와 제휴 관계가 아님",
"horizon.extras.header.lighting": "조명",
"horizon.extras.header.third_person": "3인칭",
"horizon.extras.header.particles": "파티클",
"horizon.extras.header.inventory": "인벤토리",
"horizon.extras.header.chat": "채팅",
"horizon.extras.header.multiplayer": "멀티플레이어",
"horizon.extras.header.sound": "사운드",
"horizon.extras.header.menus": "메뉴",
"horizon.extras.fullbright": "풀브라이트",
"horizon.extras.dynamic_lights": "동적 조명",
"horizon.extras.own_name_tag": "내 이름표 표시",
"horizon.extras.hide_block_particles": "블록 파괴 파티클 숨기기",
"horizon.extras.auto_reconnect": "자동 재연결",
"horizon.extras.ui_sound_reserve": "UI 사운드 채널 예약",
"horizon.extras.mouse_tweaks": "마우스 트윅 컨트롤",
"horizon.extras.mouse_tweaks_wheel": "스크롤 휠로 아이템 이동",
"horizon.extras.sort": "정렬 버튼",
"horizon.extras.sort_key": "정렬 키",
"horizon.extras.no_ai": "AI 없음",
"horizon.extras.agent_control": "에이전트 제어",
"horizon.extras.splitscreen": "화면 분할",
"horizon.extras.chat_animation": "부드러운 채팅",
"horizon.extras.chat_indicator": "메시지 표시기",
"horizon.extras.wheel_cycles_settings": "스크롤 휠로 설정 변경",
"horizon.extras.fullbright.tooltip": "어두운 지역에서도 블록과 엔티티를 최대 밝기로 표시합니다.",
"horizon.extras.dynamic_lights.tooltip": "들고 있거나 버려두거나 불이 붙은 횃불 및 기타 광원이 주변 블록을 밝힙니다.",
"horizon.extras.own_name_tag.tooltip": "다른 플레이어가 보는 것처럼 3인칭 시점에서 머리 위에 자신의 이름을 표시합니다. 서버가 팀 이름을 숨기거나 투명 상태일 때는 숨겨집니다.",
"horizon.extras.hide_block_particles.tooltip": "부서진 블록에서 튀어나오고 캐는 블록에서 떨어지는 부스러기 파티클을 숨깁니다. 다른 파티클은 유지됩니다.",
"horizon.extras.auto_reconnect.tooltip": "추방이나 밴이 아닌, 연결 끊김 또는 재시작 후 서버에 자동으로 다시 참여합니다.",
"horizon.extras.ui_sound_reserve.tooltip": "월드의 사운드가 247개의 사운드 채널 중 16개를 비워두어, 폭발 등 사운드가 폭풍처럼 몰아쳐도 메뉴 클릭 소리가 묻히지 않도록 합니다.",
"horizon.extras.mouse_tweaks.tooltip": "우클릭 드래그로 슬롯당 하나씩 배치, 좌클릭 드래그로 들고 있는 아이템 수집, Shift + 좌클릭으로 모든 슬롯 이동.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "아래로 스크롤하면 슬롯의 아이템 하나를 반대편으로 보내고, 위로 스크롤하면 하나를 가져옵니다.",
"horizon.extras.sort.tooltip": "크리에이티브 탭 순서대로 인벤토리, 상자, 통, 엔더 상자, 셜커 상자를 정렬하고 스택을 병합합니다.",
"horizon.extras.sort_key.tooltip": "커서가 위치한 부분을 정렬합니다. 클릭 후 키를 누르세요. Esc로 바인딩을 해제할 수 있습니다.",
"horizon.extras.no_ai.tooltip": "에이전트 제어, horizon-ctl, MCP 서버 등 모든 AI 기능을 숨기고 끕니다. 런처의 스위치도 포함됩니다.",
"horizon.extras.agent_control.tooltip": "이 컴퓨터에서만 horizon-ctl 또는 MCP 클라이언트를 통해 자체 AI 에이전트가 이 게임을 플레이할 수 있습니다.",
"horizon.extras.discord_presence.tooltip": "Discord가 실행되는 동안 Discord 프로필에 'Horizon 플레이 중'을 표시합니다: 메뉴, 싱글 플레이어 또는 멀티플레이어, 차원 및 플레이 시간. 월드 이름이나 서버 주소는 절대 표시되지 않습니다.",
"Calibrate...": "보정...",
"Calibrate Anyway...": "그래도 보정...",
"Use Automatic Setup": "자동 설정 사용",
"Shortcut: %s": "단축키: %s",
"Next": "다음",
"HDR Calibration %s/%s: %s": "HDR 보정 %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "테스트 사진을 한 장씩 사용하여 아래 세 가지를 전체 화면으로 설정합니다.",
"A key that opens this screen from the game. Not bound at first.": "게임 내에서 이 화면을 여는 키입니다. 기본적으로 바인딩되어 있지 않습니다.",
"Your display didn't say how bright it gets: calibrate to set it up.": "디스플레이의 최대 밝기 정보가 없습니다. 설정하려면 보정하세요.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "디스플레이에 맞게 이미 설정되어 있습니다. HDR이 잘못 표시되는 경우에만 보정하세요.",
"Puts Peak Brightness and Paper White back to your display's setup.": "최대 밝기와 페이퍼 화이트를 디스플레이의 기본 설정으로 되돌립니다.",
"Set up for your display. Leave on Auto unless it looks wrong.": "디스플레이에 맞게 설정되어 있습니다. 잘못 보이지 않는다면 자동 상태로 두세요.",
"Lower it until the logo just disappears into the black.": "로고가 검은색 배경 속으로 완전히 사라질 때까지 낮추세요.",
"Raise it until the logo just disappears into the bright square.": "로고가 밝은 사각형 속으로 완전히 사라질 때까지 높이세요.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "플레이하는 환경의 조명 속에서 오랜 시간 보기 편안한 흰색이 되도록 카드를 조정하세요.",
"horizon.splitscreen.profiles.forget": "잊기",
"horizon.splitscreen.profiles.create": "생성",
"horizon.splitscreen.profiles.you": " - 나",
"horizon.splitscreen.profiles.forget_named": "%s 잊기",
"horizon.splitscreen.profiles.previous": "이전",
"horizon.splitscreen.profiles.next": "다음",
"horizon.splitscreen.profiles.new_button": "새 프로필...",
"horizon.splitscreen.profiles.forget_title": "프로필을 잊으시겠습니까?",
"horizon.splitscreen.profiles.forget_leaves": "이 인스턴스의 목록에서 제거됩니다.",
"horizon.splitscreen.profiles.forget_kept": "월드 진행 상황과 설정은 유지됩니다.",
"horizon.splitscreen.profiles.new_title": "새 프로필",
"horizon.splitscreen.profiles.name_hint": "이름 (3~16자의 문자, 숫자, _)",
"horizon.splitscreen.profiles.switch_title": "프로필 전환",
"horizon.splitscreen.profiles.offline_note": "오프라인 프로필은 이 인스턴스에 속합니다.",
"horizon.splitscreen.profiles.move": "플레이 위치 이동",
"horizon.splitscreen.profiles.move_place": "플레이 위치 이동: %s",
"horizon.friends.title": "친구와 함께 플레이",
"horizon.friends.hint": "친구가 타이틀 화면의 친구 버튼, 직접 연결, 또는 런처의 플레이 페이지에서 코드를 입력합니다.",
"horizon.friends.copied": "복사됨",
"horizon.friends.copy_code": "코드 복사",
"horizon.friends.stop_sharing": "공유 중지",
"horizon.friends.none_yet": "아직 친구가 없습니다.",
"horizon.friends.here_now": "현재 접속 중: %s",
"horizon.friends.share_intro": "초대 코드를 통해 이 월드를 공유하세요: LAN으로 개방되며 인터넷상의 친구들이 코드로 참여할 수 있습니다. 당사의 서버는 개입하지 않습니다.",
"horizon.friends.start_sharing": "공유 시작",
"horizon.friends.troubleshooting": "문제 해결...",
"horizon.friends.stopped": "초대 코드 공유가 중지되었습니다.",
"horizon.friends.short_code_changed": "누군가 잘못된 짧은 코드를 계속 시도하여 코드가 변경되었습니다.",
"horizon.friends.wrong_code": "누군가 잘못된 코드를 입력했습니다.",
"horizon.friends.code_ready": "초대 코드가 준비되었습니다: 게임 메뉴 - 친구와 함께 플레이. 코드를 복사하여 친구에게 보내세요.",
"horizon.friends.share_failed": "월드를 공유할 수 없습니다: %s",
"horizon.friends.finding_route": "친구가 접속할 수 있는 경로를 찾는 중...",
"horizon.osk.space": "스페이스바",
"horizon.osk.delete": "삭제",
"horizon.osk.type": "입력",
"horizon.osk.symbols": "기호",
"horizon.osk.close": "닫기",
"horizon.extras.chat_animation.tooltip": "새 채팅 줄이 통째로 나타나는 대신 왼쪽에서 미끄러지듯 나타나며 서서히 페이드 인됩니다.",
"horizon.extras.chat_indicator.tooltip": "채팅 줄 왼쪽에 있는 색상 바입니다.",
"horizon.extras.wheel_cycles_settings.tooltip": "설정에서 버튼 위에 스크롤 휠을 올리면 바닐라처럼 값이 변경됩니다. 끄면 휠이 목록 스크롤만 수행합니다.",
"horizon.extras.splitscreen.tooltip": "월드 내에서 다른 컨트롤러로 새 플레이어로 참여할 수 있습니다: 메시지가 나타날 때 A를 누르고 프로필과 플레이할 위치를 선택하세요.",
"horizon.splitscreen.setup.monitor": "모니터 %s",
"horizon.splitscreen.setup.monitor_unknown": "모니터",
"horizon.splitscreen.setup.window": "창 %s",
"horizon.splitscreen.setup.new_window": "새 창",
"horizon.splitscreen.setup.fill_monitor": "%s 채우기",
"horizon.splitscreen.setup.done": "완료",
"horizon.splitscreen.setup.on": "켬",
"horizon.splitscreen.setup.off": "끔",
"horizon.splitscreen.setup.player": "플레이어 %s",
"horizon.splitscreen.setup.revert": "되돌리기",
"horizon.splitscreen.setup.apply": "적용",
"horizon.splitscreen.setup.warning.empty_main": "창 1에서 플레이하는 사람이 없습니다: 숨겨집니다.",
"horizon.splitscreen.setup.warning.empty_close": "창 %s에서 플레이하는 사람이 없습니다: 적용 시 닫힙니다.",
"horizon.splitscreen.setup.warning.empty_new": "창 %s에서 플레이하는 사람이 없습니다: 열리지 않습니다.",
"horizon.splitscreen.setup.warning.on_main": "창 %s이(가) 창 1의 모니터를 채웁니다: 해당 플레이어들은 창 1에서 플레이합니다.",
"horizon.splitscreen.setup.warning.unplugged": "창 %s의 모니터가 연결되어 있지 않습니다: 해당 플레이어들은 창 1에서 플레이합니다.",
"horizon.splitscreen.setup.status.off": "화면 분할이 꺼져 있습니다.",
"horizon.splitscreen.setup.status.enable_hint": "켠 다음, 월드에서 다른 컨트롤러의 버튼을 누르세요.",
"horizon.splitscreen.setup.status.join_hint": "A를 눌러 참여하고 프로필을 선택하세요.",
"horizon.splitscreen.setup.status.idle": "화면 분할 유휴 상태",
"horizon.splitscreen.setup.status.alone": "다른 로컬 플레이어가 없습니다.",
"horizon.splitscreen.setup.status.solo_controls": "솔로 컨트롤 및 컨트롤러 피드백은 변경되지 않습니다.",
"horizon.splitscreen.setup.window_empty": "창 %s: 비어 있음",
"horizon.splitscreen.setup.note.select": "플레이어를 클릭하거나 창 또는 모니터로 드래그하세요.",
"horizon.splitscreen.setup.note.apply": "이 방식으로 플레이하려면 적용을, 취소하려면 되돌리기를 누르세요.",
"horizon.splitscreen.setup.note.place": "%s이(가) 어디서 플레이해야 할까요?",
"horizon.splitscreen.setup.this_player": "이 플레이어",
"horizon.splitscreen.setup.switch_disabled": "다른 플레이어가 나가면(일시정지 메뉴) 화면 분할이 꺼집니다.",
"horizon.splitscreen.setup.window_place": "창 %s (%s / %s)",
"horizon.splitscreen.setup.names": "이름",
"horizon.splitscreen.setup.identify": "식별",
"horizon.splitscreen.setup.layout.two": "2분할 공유: %s",
"horizon.splitscreen.setup.layout.three": "3분할 공유: %s",
"horizon.splitscreen.setup.layout.two.0": "자동",
"horizon.splitscreen.setup.layout.two.1": "좌우 분할",
"horizon.splitscreen.setup.layout.two.2": "상하 분할",
"horizon.splitscreen.setup.layout.three.0": "상단 넓은 화면",
"horizon.splitscreen.setup.layout.three.1": "좌측 긴 화면",
"horizon.splitscreen.setup.layout.three.2": "좌우 분할",
"horizon.splitscreen.setup.help.player": "클릭하거나 드래그하여 %s을(를) 이동하세요.",
"horizon.splitscreen.setup.help.layout_auto": "%s (창이 넓을 때 좌우 분할). 변경하려면 누르세요.",
"horizon.splitscreen.setup.help.layout": "%s. 변경하려면 누르세요.",
"horizon.splitscreen.setup.help.switch": "이 컴퓨터의 화면 분할 켜기/끄기.",
"horizon.splitscreen.setup.help.identify": "각 플레이어의 화면에 어떤 창에 있는지 표시됩니다.",
"horizon.splitscreen.setup.help.names": "각 플레이어 화면 구석에 표시되는 이름.",
"horizon.splitscreen.setup.help.revert": "아직 적용되지 않은 변경 사항을 취소합니다.",
"horizon.splitscreen.setup.help.apply": "맵에 표시된 대로 플레이합니다.",
"horizon.splitscreen.setup.help.unchanged": "변경된 사항이 없습니다.",
"horizon.splitscreen.setup.give_up_pad": "패드 포기",
"horizon.splitscreen.prompt.controller_connected": "컨트롤러가 연결되었습니다.",
"horizon.splitscreen.prompt.controller_disconnected": "컨트롤러 연결이 해제되었습니다.",
"horizon.splitscreen.prompt.controller_disconnected.hint": "다시 연결하거나 다른 컨트롤러의 버튼을 누르세요.",
"horizon.splitscreen.prompt.join.one": "%s이(가) 참여하려고 합니다.",
"horizon.splitscreen.prompt.join.many": "%s개의 컨트롤러가 참여하려고 합니다.",
"horizon.splitscreen.prompt.join.yes": "참여",
"horizon.splitscreen.prompt.join.as_p1": "P1로 플레이",
"horizon.splitscreen.prompt.join.no": "아니요",
"horizon.splitscreen.prompt.lost": "%s: 컨트롤러 연결이 해제됨",
"horizon.splitscreen.prompt.lost.use_another": "다른 것 사용",
"horizon.splitscreen.prompt.lost.leave": "나가기",
"horizon.splitscreen.setup.help.give_up_pad": "컨트롤러 제어가 해제되며 참여(A)용으로 제공됩니다.",
"horizon.splitscreen.setup.note.held_at": "%s(으)로 %s: A를 누르면 여기에 놓고, B를 누르면 제자리에 돌려놓습니다.",
"horizon.splitscreen.setup.note.held": "%s 집어듦: 좌/우로 위치 선택, A로 놓기, B로 되돌리기.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "불투명 블록 뒤의 모델을 숨깁니다. 비동기 검사는 오프스레드에서 실행되며 엔티티는 틱을 진행합니다.",
"Block Entities": "블록 엔티티",
"Shulker Boxes": "셜커 상자",
"Reuses settled boxes; lid movement stays animated.": "고정된 상자를 재사용하며 뚜껑 움직임은 애니메이션이 유지됩니다.",
"Signs": "표지판",
"Reuses sign text until text, light, or font changes.": "텍스트, 조명, 글꼴이 변경될 때까지 표지판 텍스트를 재사용합니다.",
"Decorated Pots": "장식된 항아리",
"Reuses still pots; break wobble and sherd changes redraw.": "가만히 있는 항아리를 재사용합니다. 파괴 흔들림 및 조각 변경 시 다시 그려집니다.",
"Bells": "종",
"Reuses still bells; ringing stays animated.": "가만히 있는 종을 재사용하며 울림 애니메이션은 유지됩니다.",
"Chests": "상자",
"Reuses closed chests; moving lids stay animated.": "닫힌 상자를 재사용하며 움직이는 뚜껑은 애니메이션이 유지됩니다.",
"Banners": "현수막",
"Reuses poles; flags and pattern layers still update.": "깃대는 재사용하며 깃발과 패턴 레이어는 계속 업데이트됩니다.",
"Copper Golem Statues": "구리 골렘 석상",
"Reuses statue geometry until its block state changes.": "블록 상태가 변경될 때까지 석상 지오메트리를 재사용합니다.",
"Leaf Culling": "나뭇잎 컬링",
"Every leaf face. Vanilla's fancy leaves.": "모든 나뭇잎 면. 바닐라의 화려한 나뭇잎입니다.",
"Distant Plant Dither": "원거리 식물 디더링",
"Keeps fine plants in high-res packs from fading far away.": "고해상도 팩의 세밀한 식물이 멀리서 흐려지지 않도록 유지합니다.",
"Screenshot Quality": "스크린샷 품질",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "게임 해상도의 지정된 배율로 F2 스크린샷을 촬영합니다. 높을수록 시간이 더 걸립니다.",
"Frame Generation": "프레임 생성",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "실제 프레임 사이에 가상의 프레임을 삽입합니다: 더 부드러워지지만 지연이 약간 증가합니다.",
"Shadow Cache": "그림자 캐시",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "태양, 시점, 월드가 일치할 때 팩 그림자 맵을 재사용합니다. 기본값은 끔입니다.",
"Find BlueMap": "BlueMap 찾기",
"On servers, finds their BlueMap web map for far terrain. Next join.": "서버 접속 시 원거리 지형을 위한 BlueMap 웹 맵을 찾습니다. 다음 참여 시 적용됩니다.",
"BlueMap Radius": "BlueMap 반경",
"How far around you BlueMap tiles are downloaded. Next join.": "플레이어 주변으로 BlueMap 타일이 다운로드되는 거리입니다. 다음 참여 시 적용됩니다.",
"Synchronous": "동기식",
"Asynchronous": "비동기식",
"Solid": "단단함",
"Solid Aggressive": "공격적 단단함",
"Hollow": "속 빈 형태",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "캐노피 내부에 완전히 포함된 나뭇잎을 단단하게 그려 투명 레이어를 줄입니다.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "한쪽 면이 열린 나뭇잎도 단단하게 그려 더 빠르고 투박한 모서리를 만듭니다.",
"Drops faces between leaves: faster, but gaps look through.": "나뭇잎 사이의 면을 생략하여 속도를 높이지만 틈새로 내부가 보입니다.",
"Hotbar: Previous Slot": "핫바: 이전 슬롯",
"Hotbar: Next Slot": "핫바: 다음 슬롯",
"horizon.agent.connected.one": "연결됨: 에이전트 연결 1개",
"horizon.agent.connected.many": "연결됨: 에이전트 연결 %s개",
"horizon.agent.connected.one_ago": "연결됨: 에이전트 연결 1개, 마지막 명령어 %s초 전",
"horizon.agent.connected.many_ago": "연결됨: 에이전트 연결 %s개, 마지막 명령어 %s초 전",
"horizon.agent.waiting": "에이전트를 기다리는 중입니다.",
"horizon.agent.off": "끔: 에이전트가 이 게임에 접근할 수 없습니다.",
"horizon.agent.session_opened": "게임을 시작한 프로그램에 의해 이 세션이 열렸습니다.",
"horizon.agent.socket": "소켓: %s (전체 경로는 세부 정보 복사 사용)",
"horizon.agent.local_only": "로컬 전용: 이 컴퓨터의 에이전트만 허용하며 네트워크는 허용하지 않습니다.",
"%s nits": "%s 니트",
"Auto (%s nits)": "자동 (%s 니트)",
"%.2f nits": "%.2f 니트",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "불투명 블록 뒤의 모델을 숨깁니다. 엔티티는 업데이트됩니다. 커스텀 팩 사용 시 꺼집니다.",
"horizon.extras.repro_keys": "기록 단축키",
"horizon.extras.repro_keys.tooltip": "F3+R로 재현(repro) 기록을 시작 또는 중지하고 F3+E로 번들을 작성합니다. 기본값은 끔입니다: 기록 시 월드가 복사됩니다.",
"horizon.agent.record_session": "기록 세션 시작",
"horizon.agent.record_stop": "기록 세션 중지",
"horizon.agent.export_repro": "재현 번들 내보내기",
"horizon.repro.started": "기록이 시작되었습니다. F3+R로 중지하고 번들을 작성할 수 있습니다.",
"horizon.repro.stopped": "기록이 중지되었습니다: %s",
"horizon.repro.bundle": "재현 번들: %s",
"horizon.repro.error": "재현 오류: %s",
"horizon.repro.no_recording": "아직 내보낼 기록이 없습니다.",
"horizon.repro.replay_started": "재생이 시작되었습니다: %s"
}
@@ -0,0 +1,424 @@
{
"horizon.options.controller": "Comando",
"horizon.options.vibration": "Vibração",
"horizon.options.adaptive_triggers": "Gatilhos adaptativos",
"horizon.options.lightbar": "Barra de luz",
"horizon.options.look_sensitivity": "Sensibilidade de visualização",
"horizon.options.invert_x": "Inverter visão X",
"horizon.options.invert_y": "Inverter visão Y",
"horizon.options.sneak_button": "Botão de esgueirar",
"horizon.options.move_deadzone": "Zona morta de movimento",
"horizon.options.look_deadzone": "Zona morta de visão",
"horizon.options.button_glyphs": "Símbolos dos botões",
"horizon.options.menu_navigation": "Navegação nos menus",
"horizon.options.on_screen_keyboard": "Teclado no ecrã",
"horizon.options.chat_delay": "Atraso do chat",
"horizon.options.lightbar.health": "Vida",
"horizon.options.lightbar.dimension": "Dimensão",
"horizon.options.glyphs.auto": "Automático",
"horizon.options.menu_focus": "Foco",
"horizon.options.menu_pointer": "Ponteiro",
"horizon.options.keyboard.auto": "Automático",
"horizon.options.unavailable": "Esta funcionalidade ainda não está disponível no Horizon.",
"horizon.options.search": "Pesquisar definições...",
"horizon.pages.shader_packs": "Pacotes de shaders",
"horizon.pages.shader_options": "Opções de shaders",
"horizon.pages.controller": "Definições do comando",
"horizon.pages.controller_binds": "Atalhos do comando",
"horizon.pages.extras": "Extras do Horizon",
"horizon.pages.settings": "Definições do Horizon",
"horizon.pages.agent": "Agente de IA",
"horizon.split.leave": "Sair",
"horizon.pages.split_settings": "Definições de ecrã dividido",
"horizon.pages.split_profiles": "Quem está a jogar?",
"horizon.pages.split_setup": "Configuração de ecrã dividido",
"horizon.pages.settings.tooltip": "Definições próprias do Horizon: gráficos, comandos, extras e mods.",
"horizon.pages.graphics.tooltip": "Gráficos, de forma simples: cada definição indica o seu custo.",
"horizon.pages.graphics_advanced.tooltip": "Todas as definições gráficas, incluindo as do Horizon, com o custo de cada uma.",
"horizon.pages.controller.tooltip": "Comandos: botões, analógicos, vibração e navegação nos menus.",
"horizon.pages.extras.tooltip": "Adições opcionais, todas desligadas por predefinição: o jogo permanece vanilla até ligares uma.",
"horizon.pages.agent.tooltip": "Deixa o teu próprio agente de IA jogar: horizon-ctl e clientes MCP neste computador.",
"horizon.pages.split_profiles.tooltip": "Quem joga: um perfil guardado nesta pasta de jogo.",
"horizon.pages.split_setup.tooltip": "As janelas e os monitores, e quem joga onde.",
"horizon.pages.shader_packs.tooltip": "Pacotes de shaders da pasta shaderpacks: os do próprio Horizon e os no formato shaders/. Apenas um corre de cada vez.",
"horizon.pages.mods.tooltip": "Mods feitos para o Horizon, a partir da pasta horizon-mods. Cada um corre numa sandbox e permanece desligado até o ativares.",
"horizon.pages.open.graphics": "Gráficos...",
"horizon.pages.open.graphics_advanced": "Gráficos avançados...",
"horizon.pages.open.shader_packs": "Pacotes de shaders...",
"horizon.pages.open.controller": "Definições do comando...",
"horizon.pages.open.split_setup": "Ecrã dividido...",
"horizon.pages.open.agent": "Agente de IA...",
"horizon.pages.open.horizon_graphics": "Gráficos do Horizon...",
"horizon.pages.open.controller_binds": "Atalhos do comando...",
"horizon.pages.open.horizon_settings": "Definições do Horizon...",
"horizon.mods.empty": "Sem mods: coloca ficheiros .hmod na pasta horizon-mods",
"horizon.mods.stopped": " (Parado)",
"horizon.mods.not_built": " (Não construído)",
"horizon.mods.settings": "Definições",
"horizon.mods.open_folder": "Abrir pasta de mods",
"horizon.shader_packs.empty": "Sem pacotes de shaders: coloca-os na pasta shaderpacks",
"horizon.shader_options.open": "Opções de shaders...",
"horizon.shader_options.profile": "Perfil",
"horizon.shader_options.loading": "A ler as opções do pacote de shaders...",
"horizon.agent.copy_details": "Copiar detalhes de ligação",
"horizon.multiplayer.join": "Entrar",
"horizon.multiplayer.cancel_reconnecting": "Cancelar religação",
"horizon.multiplayer.join_friend": "Juntar-se a um amigo",
"horizon.multiplayer.invite_code": "Código de convite",
"horizon.multiplayer.relay": "Relay (opcional)",
"Preset": "Predefinição",
"LOD Distance": "Distância LOD",
"Far Detail": "Detalhe distante",
"Cave Culling": "Culling de grutas",
"Entity Culling": "Culling de entidades",
"Chunk Updates": "Atualizações de chunks",
"Upload Budget": "Orçamento de upload",
"Sky": "Céu",
"Fog": "Nevoeiro",
"Weather": "Condições meteorológicas",
"Animated Textures": "Texturas animadas",
"Present Mode": "Modo de apresentação",
"Frame Pacing": "Ritmo de fotogramas",
"Worker Threads": "Threads de trabalho",
"Resolution Scale": "Escala de resolução",
"Paper White": "Branco de papel",
"Peak Brightness": "Brilho máximo",
"Black Level": "Nível de preto",
"UI Brightness": "Brilho da interface",
"Battery Mode": "Modo de bateria",
"Graphics API": "API gráfica",
"Translucent Batching": "Agrupamento translúcido",
"Performance Overlay": "Sobreposição de desempenho",
"Full Detail Distance": "Distância de detalhe completo",
"On": "Ativo",
"Off": "Desligado",
"Unlimited": "Ilimitado",
"Auto": "Automático",
"Custom": "Personalizado",
"Performance": "Desempenho",
"Balanced": "Equilibrado",
"Quality": "Qualidade",
"Ultra Performance": "Desempenho ultra",
"Native": "Nativo",
"Full": "Total",
"Reduced": "Reduzido",
"Efficient": "Eficiente",
"Precise": "Preciso",
"Auto (VSync)": "Automático (VSync)",
"Immediate": "Imediato",
"FPS and Graph": "FPS e gráfico",
"No cost": "Sem custo",
"Low cost": "Baixo custo",
"Medium cost": "Custo médio",
"High cost": "Alto custo",
"Saves time": "Poupa tempo",
"Graphics": "Gráficos",
"Done": "Concluído",
"This feature is not yet available in Horizon.": "Esta funcionalidade ainda não está disponível no Horizon.",
"Your display or system doesn't offer HDR output.": "O teu ecrã ou sistema não oferece saída HDR.",
"Turn HDR on to use it.": "Ativa o HDR para o usar.",
"Your GPU or graphics API doesn't support it.": "A tua GPU ou API gráfica não suporta esta funcionalidade.",
"%s per frame": "%s por fotograma",
"%s MiB/frame": "%s MiB/fotograma",
"%s KiB/frame": "%s KiB/fotograma",
"Auto (%s)": "Automático (%s)",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Alcance de terreno com detalhe total. A distância LOD pode estender os mundos locais ainda mais longe.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Terreno distante grosseiro em mundos locais. As GPUs integradas param nos 128 chunks.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "O modo reduzido desenha o terreno distante mais grosseiramente: mais rápido nesta GPU integrada.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "O modo reduzido desenha o terreno distante mais grosseiramente, para GPUs que se tornam mais lentas.",
"Field of view. Wider shows more terrain at once.": "Campo de visão. Um valor mais amplo mostra mais terreno de uma vez.",
"Caps frames per second; a cap saves power and heat.": "Limita os fotogramas por segundo; um limite poupa energia e reduz o aquecimento.",
"Waits for the display's refresh: no tearing, a little latency.": "Aguarda pela atualização do ecrã: sem rasgamento de imagem, com um pouco mais de latência.",
"Size of menus and the HUD.": "Tamanho dos menus e do HUD.",
"Sets the options below together. Changing one makes it Custom.": "Define as opções abaixo em conjunto. Alterar uma torna-as personalizadas.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "A lava, o fogo e a luz solar brilham mais do que o branco. Requer um ecrã HDR.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "O preto mais escuro do teu ecrã. 0 para OLED; os ecrãs LCD brilham ligeiramente.",
"How bright highlights get. Auto: the peak your system reports.": "A intensidade do brilho máximo. Automático: o pico reportado pelo teu sistema.",
"How bright white paper and menus look. Auto: your desktop's white.": "A luminosidade do papel branco e dos menus. Automático: o branco do teu ambiente de trabalho.",
"How bright the HUD and menus are, against paper white.": "A luminosidade do HUD e dos menus em comparação com o branco do papel.",
"How far mobs, crops and machines keep working. CPU time.": "A distância a que os mobs, colheitas e máquinas continuam a funcionar. Tempo de CPU.",
"How far away mobs and items are drawn, as a share of normal.": "A distância a que os mobs e itens são desenhados, como uma percentagem do normal.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Ignora terreno oculto atrás de secções fechadas. Desligado consome muitos recursos.",
"Hides entities behind opaque blocks.": "Oculta entidades atrás de blocos opacos.",
"No entity occlusion culling.": "Sem culling de oclusão de entidades.",
"CPU occlusion test runs this frame.": "O teste de oclusão por CPU é executado neste fotograma.",
"Occlusion checks run on a worker thread.": "As verificações de oclusão são executadas numa thread de trabalho.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Secções malhadas enviadas por fotograma. Um valor mais baixo torna o carregamento mais fluido.",
"Soft shadows in corners. Costs chunk building time only.": "Sombras suaves nos cantos. Custa apenas tempo de construção de chunks.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Cópias mais pequenas de texturas de blocos para terreno distante: menor efeito de trémulo.",
"Blends grass and water colours across biomes when building chunks.": "Mistura as cores da relva e da água entre biomas ao construir chunks.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Camada de nuvens: desligado, plana (rápido) ou 3D (detalhado).",
"How far clouds reach, in chunks.": "A distância que as nuvens alcançam, em chunks.",
"Draws the sky, sun, moon and stars.": "Desenha o céu, o sol, a lua e as estrelas.",
"Distance fog. Off shows the edge of the loaded world.": "Nevoeiro de distância. Desligado mostra o limite do mundo carregado.",
"Draws rain and snow.": "Desenha chuva e neve.",
"How far rain and snow fall around you.": "A distância à tua volta em que a chuva e a neve caem.",
"How many particles effects spawn.": "A quantidade de efeitos de partículas gerados.",
"Round shadows under mobs and items.": "Sombras arredondadas sob mobs e itens.",
"Leaves with see-through gaps. Off draws them solid.": "Folhas com aberturas transparentes. Desligado desenha-as de forma sólida.",
"Sorts water and glass more exactly behind each other.": "Ordena a água e o vidro com maior precisão uns atrás dos outros.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Quais as reconstruções de chunks que aguardam pelo fotograma: nenhuma, próximas ou as tuas.",
"Animates water, lava, fire and other moving textures.": "Anima a água, a lava, o fogo e outras texturas em movimento.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Pixeis do mundo por píxel de ecrã. Abaixo de 100% é mais rápido na GPU.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Suaviza extremidades serrilhadas. FXAA: mais suave. TAA: o mais suave, os elementos em movimento podem deixar rasto.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Renderiza o mundo mais pequeno e reconstrói-o com TAA. Mais rápido e suave.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Saída HDR num ecrã HDR: a lava, o fogo e a luz solar brilham mais.",
"How frames reach the screen while VSync is off.": "A forma como os fotogramas chegam ao ecrã com o VSync desligado.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Eficiente: entra em suspensão até ao próximo fotograma; Preciso: ciclos ativos, como no vanilla.",
"Fills the screen (borderless).": "Preenche o ecrã (sem moldura).",
"Brightens dark places.": "Ilumina locais escuros.",
"Lowers the frame rate when minimised, or idle too.": "Reduz a taxa de fotogramas quando minimizado ou inativo.",
"Sways the view while walking.": "Balança a visão ao caminhar.",
"Fades newly loaded chunks in.": "Faz aparecer gradualmente os chunks recém-carregados.",
"Darkens the screen's edges.": "Escurece as margens do ecrã.",
"Shows when the world saves.": "Mostra quando o mundo é guardado.",
"Shows attack cooldown.": "Mostra o tempo de recarga de ataque.",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "Limite de 60 FPS (30 nos menus ou em segundo plano), metade das threads: menor consumo de energia.",
"Threads that generate, light and mesh chunks. Next start.": "Threads que geram, iluminam e criam a malha dos chunks. No próximo arranque.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Água e vidro num único buffer: menos chamadas de desenho, um pouco mais de VRAM. No próximo arranque.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Dados de malha enviados para a GPU por fotograma. Um valor mais baixo torna o carregamento mais fluido.",
"Frame rate and frame times over the game, with a graph.": "Taxa de fotogramas e tempos de fotograma ao longo do jogo, com um gráfico.",
"horizon.multiplayer.finding_friend": "A procurar o mundo do teu amigo...",
"horizon.multiplayer.not_signed_in": "Este servidor está no modo online. Inicia sessão com a tua conta Microsoft no launcher do Horizon para entrar.",
"horizon.multiplayer.invalid_address": "Endereço de servidor inválido: %s",
"horizon.multiplayer.connected_friend_direct": "Ligado diretamente ao teu amigo...",
"horizon.multiplayer.connected_friend_relay": "Ligado ao teu amigo através do relay...",
"General": "Geral",
"Advanced": "Avançado",
"Undo": "Desfazer",
"Search options": "Pesquisar opções",
"Shortcut": "Atalho",
"Distance": "Distância",
"Display": "Ecrã",
"Sky and weather": "Céu e meteorologia",
"Lighting and entities": "Iluminação e entidades",
"Textures": "Texturas",
"Image": "Imagem",
"Chunk loading": "Carregamento de chunks",
"Power": "Energia",
"Frames": "Fotogramas",
"Overlay": "Sobreposição",
"Output": "Saída",
"Brightness": "Brilho",
"How far you see, the window and the HUD.": "A tua distância de visão, a janela e o HUD.",
"The sky and weather, lighting, textures and the image.": "O céu e a meteorologia, iluminação, texturas e imagem.",
"Chunk loading, culling and power use.": "Carregamento de chunks, culling e consumo de energia.",
"How frames reach the screen, and the performance overlay.": "Como os fotogramas chegam ao ecrã e a sobreposição de desempenho.",
"Brighter-than-white output on an HDR display.": "Saída mais brilhante que o branco num ecrã HDR.",
"Results for \"%s\"": "Resultados para \"%s\"",
"No settings match.": "Nenhuma definição corresponde.",
"horizon.title.not_affiliated": "Não associado à Mojang ou à Microsoft",
"horizon.extras.header.lighting": "Iluminação",
"horizon.extras.header.third_person": "Terceira pessoa",
"horizon.extras.header.particles": "Partículas",
"horizon.extras.header.inventory": "Inventário",
"horizon.extras.header.multiplayer": "Multijogador",
"horizon.extras.header.sound": "Som",
"horizon.extras.dynamic_lights": "Luzes dinâmicas",
"horizon.extras.own_name_tag": "Etiqueta com o próprio nome",
"horizon.extras.hide_block_particles": "Ocultar partículas de rutura de blocos",
"horizon.extras.auto_reconnect": "Religação automática",
"horizon.extras.ui_sound_reserve": "Manter canais livres para sons de UI",
"horizon.extras.mouse_tweaks": "Controlos Mouse Tweaks",
"horizon.extras.mouse_tweaks_wheel": "A roda do rato move itens",
"horizon.extras.sort": "Botões de ordenação",
"horizon.extras.sort_key": "Tecla de ordenação",
"horizon.extras.no_ai": "Sem IA",
"horizon.extras.agent_control": "Controlo por agente",
"horizon.extras.splitscreen": "Ecrã dividido",
"horizon.extras.chat_animation": "Chat fluido",
"horizon.extras.chat_indicator": "Indicador de mensagem",
"horizon.extras.wheel_cycles_settings": "A roda do rato altera definições",
"horizon.extras.fullbright.tooltip": "Mostra blocos e entidades com iluminação total, mesmo em áreas escuras.",
"horizon.extras.dynamic_lights.tooltip": "Tochas e outras fontes de luz seguras, largadas ou a arder iluminam os blocos à sua volta.",
"horizon.extras.own_name_tag.tooltip": "Mostra o teu nome acima da tua cabeça em terceira pessoa, tal como os outros jogadores o veem. Oculto quando o servidor oculta os nomes da tua equipa ou estás invisível.",
"horizon.extras.hide_block_particles.tooltip": "Oculta as partículas de detritos que irrompem de um bloco partido e esfarelam-se do que estás a minerar. As outras partículas permanecem.",
"horizon.extras.auto_reconnect.tooltip": "Reentra num servidor após uma perda de ligação ou reinício, nunca após uma expulsão ou banimento.",
"horizon.extras.ui_sound_reserve.tooltip": "Os sons no mundo deixam 16 dos 247 canais de som livres, para que uma inundação deles (uma grande explosão) não consiga silenciar os cliques dos menus.",
"horizon.extras.mouse_tweaks.tooltip": "Arrastar com o botão direito coloca um por ranhura, arrastar com o esquerdo apanha o item seguro, Shift + arrastar com o esquerdo move todas as ranhuras.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Para baixo envia um item da ranhura para o outro lado, para cima traz um de volta.",
"horizon.extras.sort.tooltip": "Ordena o inventário, baús, barris, baús do fim e shulker boxes na ordem do separador criativo, fundindo pilhas.",
"horizon.extras.sort_key.tooltip": "Ordena a parte sob o cursor. Clica e depoisprime uma tecla; Escape desassocia-a.",
"horizon.extras.no_ai.tooltip": "Oculta e desliga todas as funcionalidades de IA: controlo por agente, horizon-ctl e os servidores MCP. O interruptor do launcher também.",
"horizon.extras.agent_control.tooltip": "O teu próprio agente de IA pode jogar este jogo através do horizon-ctl ou de um cliente MCP, apenas neste computador.",
"horizon.extras.discord_presence.tooltip": "Mostra A jogar Horizon no teu perfil do Discord enquanto o Discord está a correr: menus, jogador singular ou multijogador, a dimensão e o tempo jogado. Nunca o nome de um mundo ou o endereço de um servidor.",
"Calibrate...": "Calibrar...",
"Calibrate Anyway...": "Calibrar na mesma...",
"Use Automatic Setup": "Usar configuração automática",
"Shortcut: %s": "Atalho: %s",
"Next": "Seguinte",
"HDR Calibration %s/%s: %s": "Calibração HDR %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Define os três abaixo em ecrã completo, uma imagem de teste de cada vez.",
"A key that opens this screen from the game. Not bound at first.": "Uma tecla que abre este ecrã a partir do jogo. Sem atalho atribuído inicialmente.",
"Your display didn't say how bright it gets: calibrate to set it up.": "O teu ecrã não indicou a sua luminosidade máxima: calibra para configurá-lo.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Já configurado para o teu ecrã. Calibra apenas se o HDR parecer incorreto.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Repõe o Brilho Máximo e o Branco de Papel para a configuração predefinida do teu ecrã.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Configurado para o teu ecrã. Deixa em Automático, a menos que pareça incorreto.",
"Lower it until the logo just disappears into the black.": "Reduz até o logótipo desaparecer no preto.",
"Raise it until the logo just disappears into the bright square.": "Aumenta até o logótipo desaparecer no quadrado brilhante.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Ajusta o cartão para um branco que seja confortável de ver durante muito tempo, com a luz do ambiente em que jogas.",
"horizon.splitscreen.profiles.forget": "Esquecer",
"horizon.splitscreen.profiles.create": "Criar",
"horizon.splitscreen.profiles.you": " - tu",
"horizon.splitscreen.profiles.forget_named": "Esquecer %s",
"horizon.splitscreen.profiles.previous": "Anterior",
"horizon.splitscreen.profiles.next": "Seguinte",
"horizon.splitscreen.profiles.new_button": "Novo perfil...",
"horizon.splitscreen.profiles.forget_title": "Esquecer perfil?",
"horizon.splitscreen.profiles.forget_leaves": "Sai da lista desta instância.",
"horizon.splitscreen.profiles.forget_kept": "O progresso do mundo e as definições são mantidos.",
"horizon.splitscreen.profiles.new_title": "Novo perfil",
"horizon.splitscreen.profiles.name_hint": "Nome (3 a 16 letras, dígitos, _)",
"horizon.splitscreen.profiles.switch_title": "Mudar de perfil",
"horizon.splitscreen.profiles.offline_note": "Os perfis offline pertencem a esta instância",
"horizon.splitscreen.profiles.move": "Mover local de jogo",
"horizon.splitscreen.profiles.move_place": "Mover local de jogo: %s",
"horizon.friends.title": "Jogar com amigos",
"horizon.friends.hint": "Os amigos colam o código após o botão Amigos do ecrã de título, em Ligação Direta, ou na página Jogar do launcher.",
"horizon.friends.copied": "Copiado",
"horizon.friends.copy_code": "Copiar código",
"horizon.friends.stop_sharing": "Parar partilha",
"horizon.friends.none_yet": "Ainda sem amigos aqui.",
"horizon.friends.here_now": "Aqui agora: %s",
"horizon.friends.share_intro": "Partilha este mundo através de um código de convite: abre para LAN e os amigos na internet juntam-se com o código. Nenhum servidor nosso está envolvido.",
"horizon.friends.start_sharing": "Iniciar partilha",
"horizon.friends.troubleshooting": "Resolução de problemas...",
"horizon.friends.stopped": "Partilha do código de convite interrompida.",
"horizon.friends.short_code_changed": "Alguém tentou repetidamente um código curto incorreto, por isso este foi alterado.",
"horizon.friends.wrong_code": "Alguém tentou um código incorreto.",
"horizon.friends.code_ready": "O teu código de convite está pronto: Menu do Jogo, Jogar com Amigos. Copia-o e envia-o aos teus amigos.",
"horizon.friends.share_failed": "Não foi possível partilhar o mundo: %s",
"horizon.friends.finding_route": "A determinar como os amigos podem aceder...",
"horizon.osk.space": "Espaço",
"horizon.osk.delete": "Eliminar",
"horizon.osk.type": "Escrever",
"horizon.osk.symbols": "Símbolos",
"horizon.osk.close": "Fechar",
"horizon.extras.chat_animation.tooltip": "Uma nova linha de chat desliza a partir da esquerda enquanto aparece gradualmente, em vez de surgir por inteiro.",
"horizon.extras.chat_indicator.tooltip": "A barra colorida à esquerda de uma linha de chat.",
"horizon.extras.wheel_cycles_settings.tooltip": "A roda do rato sobre um botão nas definições altera o seu valor, tal como no vanilla. Se desligado, a roda apenas percorre a lista.",
"horizon.extras.splitscreen.tooltip": "Num mundo, outro comando pode juntar-se como novo jogador:prime A quando for pedido, depois escolhe um perfil e onde jogar.",
"horizon.splitscreen.setup.monitor_unknown": "um monitor",
"horizon.splitscreen.setup.window": "Janela %s",
"horizon.splitscreen.setup.new_window": "Nova janela",
"horizon.splitscreen.setup.fill_monitor": "Preencher %s",
"horizon.splitscreen.setup.done": "Concluído",
"horizon.splitscreen.setup.on": "Ativo",
"horizon.splitscreen.setup.off": "Desligado",
"horizon.splitscreen.setup.player": "Jogador %s",
"horizon.splitscreen.setup.revert": "Reverter",
"horizon.splitscreen.setup.apply": "Aplicar",
"horizon.splitscreen.setup.warning.empty_main": "Ninguém joga na Janela 1: esta será ocultada",
"horizon.splitscreen.setup.warning.empty_close": "Ninguém joga na Janela %s: esta fechar-se-á ao Aplicar",
"horizon.splitscreen.setup.warning.empty_new": "Ninguém joga na Janela %s: esta não será aberta",
"horizon.splitscreen.setup.warning.on_main": "A Janela %s preenche o monitor da Janela 1: os seus jogadores jogam na Janela 1",
"horizon.splitscreen.setup.warning.unplugged": "O monitor da Janela %s não está ligado: os seus jogadores jogam na Janela 1",
"horizon.splitscreen.setup.status.off": "O ecrã dividido está desligado.",
"horizon.splitscreen.setup.status.enable_hint": "Ativa-o e depoisprime um botão noutro comando dentro de um mundo.",
"horizon.splitscreen.setup.status.join_hint": "Prime A para entrar e escolher um perfil.",
"horizon.splitscreen.setup.status.idle": "Ecrã dividido inativo",
"horizon.splitscreen.setup.status.alone": "Sem outros jogadores locais.",
"horizon.splitscreen.setup.status.solo_controls": "Os controlos a solo e o feedback do comando permanecem inalterados.",
"horizon.splitscreen.setup.window_empty": "Janela %s: vazia",
"horizon.splitscreen.setup.note.select": "Clica num jogador ou arrasta-o para uma janela ou monitor.",
"horizon.splitscreen.setup.note.apply": "Aplica para jogar desta forma, ou reverte para desfazer.",
"horizon.splitscreen.setup.note.place": "Onde deve jogar %s?",
"horizon.splitscreen.setup.this_player": "este jogador",
"horizon.splitscreen.setup.switch_disabled": "O ecrã dividido desativa-se assim que os outros saírem (pelo menu de pausa).",
"horizon.splitscreen.setup.window_place": "Janela %s (%s de %s)",
"horizon.splitscreen.setup.names": "Nomes",
"horizon.splitscreen.setup.identify": "Identificar",
"horizon.splitscreen.setup.layout.two": "2 a partilhar: %s",
"horizon.splitscreen.setup.layout.three": "3 a partilhar: %s",
"horizon.splitscreen.setup.layout.two.0": "Automático",
"horizon.splitscreen.setup.layout.two.1": "Lado a lado",
"horizon.splitscreen.setup.layout.two.2": "Superior e inferior",
"horizon.splitscreen.setup.layout.three.0": "Um largo em cima",
"horizon.splitscreen.setup.layout.three.1": "Um alto à esquerda",
"horizon.splitscreen.setup.layout.three.2": "Lado a lado",
"horizon.splitscreen.setup.help.player": "Clica ou arrasta %s para o mover.",
"horizon.splitscreen.setup.help.layout_auto": "%s (lado a lado quando a janela é larga). Prime para alterar.",
"horizon.splitscreen.setup.help.layout": "%s. Prime para alterar.",
"horizon.splitscreen.setup.help.switch": "Ecrã dividido para este computador, ligado ou desligado.",
"horizon.splitscreen.setup.help.identify": "A vista de cada jogador mostra em que janela se encontra.",
"horizon.splitscreen.setup.help.names": "O nome de cada jogador no canto da respetiva vista.",
"horizon.splitscreen.setup.help.revert": "Desfaz as alterações ainda não aplicadas.",
"horizon.splitscreen.setup.help.apply": "Joga da forma apresentada no mapa.",
"horizon.splitscreen.setup.help.unchanged": "Ainda nada foi alterado.",
"horizon.splitscreen.setup.give_up_pad": "Abandonar comando",
"horizon.splitscreen.prompt.controller_connected": "Comando ligado",
"horizon.splitscreen.prompt.controller_disconnected": "Comando desligado",
"horizon.splitscreen.prompt.controller_disconnected.hint": "Volta a ligá-lo ouprime um botão noutro comando",
"horizon.splitscreen.prompt.join.one": "%s quer juntar-se",
"horizon.splitscreen.prompt.join.many": "%s comandos querem juntar-se",
"horizon.splitscreen.prompt.join.yes": "Entrar",
"horizon.splitscreen.prompt.join.as_p1": "Jogar como P1",
"horizon.splitscreen.prompt.join.no": "Não",
"horizon.splitscreen.prompt.lost": "%s: comando desligado",
"horizon.splitscreen.prompt.lost.use_another": "Usar outro",
"horizon.splitscreen.prompt.lost.leave": "Sair",
"horizon.splitscreen.setup.help.give_up_pad": "O teu comando deixa de te controlar e é disponibilizado para entrada (A).",
"horizon.splitscreen.setup.note.held_at": "%s para %s: A larga aqui, B devolve.",
"horizon.splitscreen.setup.note.held": "%s apanhado: esquerda/direita escolhe o local, A larga, B devolve.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Oculta modelos atrás de blocos opacos. Verificações assíncronas fora da thread principal; as entidades continuam a atualizar.",
"Block Entities": "Entidades de blocos",
"Reuses settled boxes; lid movement stays animated.": "Reutiliza caixas paradas; o movimento da tampa permanece animado.",
"Signs": "Placas",
"Reuses sign text until text, light, or font changes.": "Reutiliza o texto das placas até o texto, a luz ou a fonte mudarem.",
"Decorated Pots": "Potes decorados",
"Reuses still pots; break wobble and sherd changes redraw.": "Reutiliza potes parados; a oscilação ao partir e as alterações de fragmentos redesenham.",
"Bells": "Sinos",
"Reuses still bells; ringing stays animated.": "Reutiliza sinos parados; o toque permanece animado.",
"Chests": "Baús",
"Reuses closed chests; moving lids stay animated.": "Reutiliza baús fechados; as tampas em movimento continuam animadas.",
"Banners": "Estandartes",
"Reuses poles; flags and pattern layers still update.": "Reutiliza varas; as bandeiras e camadas de padrões continuam a atualizar.",
"Copper Golem Statues": "Estátuas de Golem de Cobre",
"Reuses statue geometry until its block state changes.": "Reutiliza a geometria da estátua até o estado do bloco mudar.",
"Leaf Culling": "Culling de folhas",
"Every leaf face. Vanilla's fancy leaves.": "Todas as faces de folhas. Folhas detalhadas do vanilla.",
"Distant Plant Dither": "Efeito dither em plantas distantes",
"Keeps fine plants in high-res packs from fading far away.": "Impede que plantas detalhadas em pacotes de alta resolução desapareçam à distância.",
"Screenshot Quality": "Qualidade das capturas de ecrã",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Capturas de ecrã F2 multiplicadas por este fator em relação à resolução do jogo. Valores mais altos demoram mais tempo.",
"Frame Generation": "Geração de fotogramas",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Mostra um fotograma gerado entre cada dois reais: maior fluidez, com uma ligeira latência adicional.",
"Shadow Cache": "Cache de sombras",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Reutiliza o mapa de sombras do pacote quando o sol, a visão e o mundo coincidem. Desligado por predefinição.",
"Find BlueMap": "Encontrar BlueMap",
"On servers, finds their BlueMap web map for far terrain. Next join.": "Em servidores, encontra o mapa web BlueMap para terreno distante. Na próxima entrada.",
"BlueMap Radius": "Raio do BlueMap",
"How far around you BlueMap tiles are downloaded. Next join.": "A distância à tua volta em que os tiles do BlueMap são descarregados. Na próxima entrada.",
"Synchronous": "Síncrono",
"Asynchronous": "Assíncrono",
"Solid": "Sólido",
"Solid Aggressive": "Sólido agressivo",
"Hollow": "Oco",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Folhas totalmente inseridas numa copa desenham-se de forma sólida: menos camadas transparentes.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "Folhas com um lado aberto também se desenham de forma sólida: margens mais rápidas e volumosas.",
"Drops faces between leaves: faster, but gaps look through.": "Remove faces entre folhas: mais rápido, mas permite ver através das aberturas.",
"Hotbar: Previous Slot": "Barra de atalhos: Ranhura anterior",
"Hotbar: Next Slot": "Barra de atalhos: Ranhura seguinte",
"horizon.agent.connected.one": "Ligado: 1 ligação de agente",
"horizon.agent.connected.many": "Ligado: %s ligações de agentes",
"horizon.agent.connected.one_ago": "Ligado: 1 ligação de agente, último comando há %s s",
"horizon.agent.connected.many_ago": "Ligado: %s ligações de agentes, último comando há %s s",
"horizon.agent.waiting": "À espera de um agente.",
"horizon.agent.off": "Desligado: os agentes não conseguem aceder a este jogo.",
"horizon.agent.session_opened": "Aberto para esta sessão pelo programa que iniciou o jogo.",
"horizon.agent.socket": "Socket: %s (copia os detalhes para ver o caminho completo)",
"horizon.agent.local_only": "Apenas local: agentes neste computador, nunca na rede.",
"Auto (%s nits)": "Automático (%s nits)",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Oculta modelos atrás de blocos opacos. As entidades atualizam-se. Desligado com pacotes personalizados.",
"horizon.extras.repro_keys": "Atalhos de gravação",
"horizon.extras.repro_keys.tooltip": "F3+R inicia ou para uma gravação de reprodução e F3+E grava o respetivo pacote. Desligado por predefinição: uma gravação copia o mundo.",
"horizon.agent.record_session": "Iniciar sessão de gravação",
"horizon.agent.record_stop": "Parar sessão de gravação",
"horizon.agent.export_repro": "Exportar pacote de reprodução",
"horizon.repro.started": "Gravação iniciada; F3+R para-a e grava o pacote.",
"horizon.repro.stopped": "Gravação parada: %s",
"horizon.repro.bundle": "Pacote de reprodução: %s",
"horizon.repro.error": "Reprodução: %s",
"horizon.repro.no_recording": "Ainda não existe nenhuma gravação para exportar.",
"horizon.repro.replay_started": "Reprodução iniciada: %s"
}
@@ -0,0 +1,405 @@
{
"horizon.options.controller": "کنٹرولر",
"horizon.options.vibration": "وائبریشن",
"horizon.options.lightbar": "لائٹ بار",
"horizon.options.look_sensitivity": "دیکھنے کی حساسیت",
"horizon.options.invert_x": "انورٹ لک ایکس",
"horizon.options.invert_y": "انورٹ لک وائی",
"horizon.options.sneak_button": "سنیک بٹن",
"horizon.options.move_deadzone": "موو ڈیڈ زون",
"horizon.options.look_deadzone": "لک ڈیڈ زون",
"horizon.options.button_glyphs": "بٹن گلفس",
"horizon.options.on_screen_keyboard": "آن سکرین کی بورڈ",
"horizon.options.chat_delay": "چیٹ میں تاخیر",
"horizon.options.lightbar.health": "صحت",
"horizon.options.glyphs.auto": "آٹو",
"horizon.options.glyphs.xbox": "ایکس باکس",
"horizon.options.glyphs.playstation": "پلے اسٹیشن",
"horizon.options.glyphs.nintendo": "ننٹینڈو",
"horizon.options.menu_focus": "فوکس",
"horizon.options.menu_pointer": "پوائنٹر",
"horizon.options.keyboard.auto": "آٹو",
"horizon.options.keyboard.steam": "اسٹیم",
"horizon.options.search": "ترتیبات تلاش کریں...",
"horizon.pages.mods": "ماڈز",
"horizon.pages.shader_packs": "شیڈر پیکس",
"horizon.pages.shader_options": "شیڈر کے اختیارات",
"horizon.pages.controller": "کنٹرولر کی ترتیبات",
"horizon.pages.controller_binds": "کنٹرولر بائنڈز",
"horizon.pages.agent": "AI ایجنٹ",
"horizon.split.leave": "چھوڑیں",
"horizon.pages.split_settings": "اسپلٹ سکرین کی ترتیبات",
"horizon.pages.split_profiles": "کون کھیل رہا ہے؟",
"horizon.pages.split_setup": "اسپلٹ سکرین سیٹ اپ",
"horizon.pages.graphics.tooltip": "گرافکس، سادہ الفاظ میں: ہر ترتیبات اپنی لاگت بتاتی ہے۔",
"horizon.pages.extras.tooltip": "اختیاری اضافے، سب پہلے سے بند: جب تک آپ کسی کو آن نہیں کرتے گیم ونیلا رہتا ہے۔",
"horizon.pages.agent.tooltip": "اپنے خود کے AI ایجنٹ کو کھیلنے دیں: اس کمپیوٹر پر horizon-ctl اور MCP کلائنٹس۔",
"horizon.pages.split_profiles.tooltip": "کون کھیلتا ہے: اس گیم فولڈر میں رکھا گیا ایک پروفائل۔",
"horizon.pages.split_setup.tooltip": "ونڈوز اور مانیٹر، اور کون کہاں کھیلتا ہے۔",
"horizon.pages.open.graphics": "گرافکس...",
"horizon.pages.open.graphics_advanced": "اعلی درجے کے گرافکس...",
"horizon.pages.open.shader_packs": "شیڈر پیکس...",
"horizon.pages.open.controller": "کنٹرولر کی ترتیبات...",
"horizon.pages.open.split_setup": "اسپلٹ سکرین...",
"horizon.pages.open.extras": "ایکسٹراز...",
"horizon.pages.open.mods": "ماڈز...",
"horizon.pages.open.agent": "AI ایجنٹ...",
"horizon.pages.open.controller_binds": "کنٹرولر بائنڈز...",
"horizon.mods.empty": "کوئی ماڈ نہیں: horizon-mods فولڈر میں .hmod فائلیں رکھیں",
"horizon.mods.stopped": " (روکا گیا)",
"horizon.mods.not_built": " (بلٹ نہیں کیا گیا)",
"horizon.mods.dev": " (ڈیو)",
"horizon.mods.settings": "ترتیبات",
"horizon.mods.open_folder": "ماڈ فولڈر کھولیں",
"horizon.shader_packs.empty": "کوئی شیڈر پیک نہیں: انہیں shaderpacks فولڈر میں رکھیں",
"horizon.shader_options.open": "شیڈر کے اختیارات...",
"horizon.shader_options.profile": "پروفائل",
"horizon.shader_options.loading": "شیڈر پیک کے اختیارات پڑھے جا رہے ہیں...",
"horizon.agent.copy_details": "کنکشن کی تفصیلات کاپی کریں",
"horizon.multiplayer.join": "شامل ہوں",
"horizon.multiplayer.cancel_reconnecting": "دوبارہ جڑنا منسوخ کریں",
"horizon.multiplayer.join_friend": "کسی دوست کے ساتھ جڑیں",
"horizon.multiplayer.invite_code": "دعوت نامہ کوڈ",
"horizon.multiplayer.relay": "رلے (اختیاری)",
"Preset": "پری سیٹ",
"LOD Distance": "LOD فاصلہ",
"Far Detail": "دور کی تفصیل",
"Cave Culling": "غار کلنگ",
"Entity Culling": "اینٹیٹی کلنگ",
"Upload Budget": "اپ لوڈ بجٹ",
"Sky": "آسمان",
"Fog": "دہند",
"Weather": "موسم",
"Animated Textures": "اینیمیٹڈ ٹیکسچرز",
"Present Mode": "پریزنٹ موڈ",
"Frame Pacing": "فریم پیسنگ",
"Worker Threads": "ورکر تھریڈز",
"Resolution Scale": "ریزولوشن سکیل",
"Anti-aliasing": "اینٹی ایلیاسنگ",
"Upscaling": "اپ سکیلنگ",
"Paper White": "پیپر وائٹ",
"Peak Brightness": "زیادہ سے زیادہ چمک",
"Black Level": "بلیک لیول",
"UI Brightness": "UI کی چمک",
"Battery Mode": "بیٹری موڈ",
"Graphics API": "گرافکس API",
"Translucent Batching": "ٹرانسلوسنٹ بیچنگ",
"Performance Overlay": "کارکردگی کا اوورلے",
"Full Detail Distance": "مکمل تفصیل کا فاصلہ",
"On": "آن",
"Off": "آف",
"Unlimited": "لامحدود",
"Auto": "آٹو",
"Custom": "حسب ضرورت",
"Performance": "کارکردگی",
"Balanced": "متوازن",
"Quality": "معیار",
"Ultra": "الٹرا",
"Ultra Performance": "الٹرا کارکردگی",
"Native": "نیٹو",
"Full": "مکمل",
"Reduced": "کم کردہ",
"Efficient": "کارآمد",
"Precise": "درست",
"Auto (VSync)": "آٹو (VSync)",
"Mailbox": "میل باکس",
"Immediate": "فوری",
"FPS and Graph": "FPS اور گراف",
"No cost": "کوئی لاگت نہیں",
"Low cost": "کم لاگت",
"Medium cost": "درمیانی لاگت",
"High cost": "زیادہ لاگت",
"Saves time": "وقت بچاتا ہے",
"Graphics": "گرافکس",
"Done": "مکمل",
"Your display or system doesn't offer HDR output.": "آپ کا ڈسپلے یا سسٹم HDR آؤٹ پٹ فراہم نہیں کرتا ہے۔",
"Turn HDR on to use it.": "اسے استعمال کرنے کے لیے HDR آن کریں۔",
"Your GPU or graphics API doesn't support it.": "آپ کا GPU یا گرافکس API اس کی حمایت نہیں کرتا ہے۔",
"%s chunks": "%s چنکس",
"%s per frame": "فی فریم %s",
"%s MiB/frame": "%s MiB/فریم",
"%s KiB/frame": "%s KiB/فریم",
"Auto (%s)": "آٹو (%s)",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "مکمل تفصیل والا خطہ۔ LOD فاصلہ مقامی دنیا کو مزید آگے بڑھا سکتا ہے۔",
"Reduced draws far terrain coarser, for GPUs it slows down.": "کم شدہ موڈ دور کے خطے کو زیادہ کھردرا بناتا ہے، کچھ GPUs کے لیے یہ سست ہو جاتا ہے۔",
"Field of view. Wider shows more terrain at once.": "فیلڈ آف ویو۔ چوڑا ہونے پر ایک ساتھ زیادہ خطہ دکھائی دیتا ہے۔",
"Caps frames per second; a cap saves power and heat.": "فی سیکنڈ فریموں کی حد طے کرتا ہے؛ حد بجلی اور حرارت بچاتی ہے۔",
"Waits for the display's refresh: no tearing, a little latency.": "ڈسپلے کے ریفریش کا انتظار کرتا ہے: کوئی ٹیرنگ نہیں، تھوڑی سی لیٹینسی۔",
"Size of menus and the HUD.": "مینو اور HUD کا سائز۔",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "لاوا، آگ اور سورج کی روشنی سفید سے زیادہ چمکتی ہے۔ HDR ڈسپلے کی ضرورت ہے۔",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "آپ کے ڈسپلے کا سب سے گہرا کالا۔ OLED کے لیے 0؛ LCDs تھوڑا چمکتے ہیں۔",
"How bright highlights get. Auto: the peak your system reports.": "ہائی لائٹس کتنی روشن ہوتی ہیں۔ آٹو: آپ کا سسٹم جو چوٹی رپورٹ کرتا ہے۔",
"How bright white paper and menus look. Auto: your desktop's white.": "سفید پیپر اور مینو کتنے روشن نظر آتے ہیں۔ آٹو: آپ کے ڈیسک ٹاپ کا سفید۔",
"How bright the HUD and menus are, against paper white.": "پیپر وائٹ کے مقابلے HUD اور مینو کتنے روشن ہیں۔",
"How far mobs, crops and machines keep working. CPU time.": "موبس، فصلیں اور مشینیں کتنی دور تک کام کرتی رہتی ہیں۔ CPU وقت۔",
"How far away mobs and items are drawn, as a share of normal.": "عام کے حصے کے طور پر، موبس اور آئٹمز کتنی دور کھینچے جاتے ہیں۔",
"Skips terrain hidden behind closed sections. Off costs a lot.": "بند حصوں کے پیچھے چھپے خطے کو چھوڑ دیتا ہے۔ آف کرنے پر زیادہ لاگت آتی ہے۔",
"Hides entities behind opaque blocks.": "غیر شفاف بلاکس کے پیچھے اینٹیٹیز کو چھپاتا ہے۔",
"No entity occlusion culling.": "کوئی اینٹیٹی اوکلوژن کلنگ نہیں۔",
"CPU occlusion test runs this frame.": "اس فریم CPU اوکلوژن ٹیسٹ چلتا ہے۔",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "فی فریم اپ لوڈ کیے گئے میشڈ سیکشنز۔ لوڈنگ کے دوران کم مان زیادہ ہموار ہوتا ہے۔",
"Soft shadows in corners. Costs chunk building time only.": "کوٹوں میں نرم سائے۔ اس میں صرف چنک بنانے کا وقت لگتا ہے۔",
"Smaller copies of block textures for distant terrain: less shimmer.": "دور کے خطے کے لیے بلاک ٹیکسچر کی چھوٹی نقلیں: کم چمک۔",
"Blends grass and water colours across biomes when building chunks.": "چنکس بناتے وقت بائیومز میں گھاس اور پانی کے رنگوں کو ملاتا ہے۔",
"Cloud layer: off, flat (fast) or 3D (fancy).": "بادل کی تہہ: آف، فلیٹ (تیز) یا 3D (فینسی)۔",
"How far clouds reach, in chunks.": "بادل کتنی دور تک پہنچتے ہیں، چنکس میں۔",
"Draws the sky, sun, moon and stars.": "آسمان، سورج، چاند اور ستارے بناتا ہے۔",
"Distance fog. Off shows the edge of the loaded world.": "فاصلے کی دھند۔ آف ہونے پر لوڈ شدہ دنیا کا کنارہ دکھتا ہے۔",
"Draws rain and snow.": "بارش اور برف بناتا ہے۔",
"How far rain and snow fall around you.": "آپ کے ارد گرد بارش اور برف کتنی دور تک گرتی ہے۔",
"How many particles effects spawn.": "کتنے پارٹیکل ایفیکٹس پیدا ہوتے ہیں۔",
"Round shadows under mobs and items.": "موبس اور آئٹمز کے نیچے گول سائے۔",
"Leaves with see-through gaps. Off draws them solid.": "آر پار نظر آنے والے وقفوں والے پتے۔ آف ہونے پر ٹھوس دکھتے ہیں۔",
"Sorts water and glass more exactly behind each other.": "پانی اور گلاس کو ایک دوسرے کے پیچھے زیادہ درستگی سے ترتیب دیتا ہے۔",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "کون سے چنک ری بلڈز فریم کا انتظار کرتے ہیں: کوئی نہیں، قریبی یا آپ کے۔",
"Animates water, lava, fire and other moving textures.": "پانی، لاوا، آگ اور دیگر متحرک ٹیکسچرز کو اینیمیٹ کرتا ہے۔",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "سکرین پکسل کے لحاظ سے دنیا کے پکسلز۔ 100% سے نیچے GPU پر تیز ہوتا ہے۔",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "کٹی پھٹی کناروں کو ہموار کرتا ہے۔ FXAA: نرم۔ TAA: سب سے ہموار, حرکت کرنے والی چیزوں کے پیچھے نشان ہو سکتے ہیں۔",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "دنیا کو چھوٹا رینڈر کرتا ہے اور TAA کے ساتھ دوبارہ بناتا ہے۔ تیز، نرم۔",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR ڈسپلے پر HDR آؤٹ پٹ: لاوا، آگ اور سورج کی روشنی زیادہ چمکتی ہے۔",
"How frames reach the screen while VSync is off.": "VSync آف ہونے پر فریم سکرین تک کیسے پہنچتے ہیں۔",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "کارآمد موڈ اگلے فریم تک سوتا ہے؛ درست موڈ ونیلا کی طرح گھومتا ہے۔",
"Fills the screen (borderless).": "سکرین کو بھرتا ہے (بارڈر لیس)۔",
"Brightens dark places.": "اندھیری جگہوں کو روشن کرتا ہے۔",
"Lowers the frame rate when minimised, or idle too.": "کم سے کم کیے جانے پر یا غیر فعال ہونے پر فریم ریٹ کم کر دیتا ہے۔",
"Sways the view while walking.": "چلتے وقت منظر کو ہلاتا ہے۔",
"Fades newly loaded chunks in.": "نئے لوڈ کردہ چنکس کو مدہم انداز میں ظاہر کرتا ہے۔",
"Darkens the screen's edges.": "سکرین کے کناروں کو تاریک کرتا ہے۔",
"Shows when the world saves.": "ظاہر کرتا ہے کہ دنیا کب محفوظ ہوتی ہے۔",
"Shows attack cooldown.": "حملے کا کول ڈاؤن دکھاتا ہے۔",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS حد (مینو یا ان فوکسڈ میں 30)، نصف ورکرز: کم بجلی۔",
"Threads that generate, light and mesh chunks. Next start.": "تھریڈز جو چنکس بناتے، روشن کرتے اور میش کرتے ہیں۔ اگلی شروعات پر۔",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan، پھر DirectX 12 (Windows) یا OpenGL (Linux) جہاں یہ ناکام ہو جائے۔ اگلی شروعات پر۔",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "ایک بفر میں پانی اور گلاس: کم ڈرا کالز، تھوڑا سا زیادہ VRAM۔ اگلی شروعات پر۔",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "فی فریم GPU کو بھیجا گیا میش ڈیٹا۔ لوڈنگ کے دوران کم مان زیادہ ہموار ہوتا ہے۔",
"Frame rate and frame times over the game, with a graph.": "گراف کے ساتھ گیم کے دوران فریم ریٹ اور فریم کے اوقات۔",
"horizon.multiplayer.finding_friend": "آپ کے دوست کی دنیا تلاش کی جا رہی ہے...",
"horizon.multiplayer.invalid_address": "غلط سرور ایڈریس: %s",
"horizon.multiplayer.connected_friend_direct": "سیدھے آپ کے دوست سے جڑ گیا...",
"horizon.multiplayer.connected_friend_relay": "رلے کے ذریعے آپ کے دوست سے جڑ گیا...",
"General": "عام",
"Advanced": "اعلی درجے کا",
"Undo": "واپس لیں",
"Search options": "اختیارات تلاش کریں",
"Shortcut": "شارٹ کٹ",
"Distance": "فاصلہ",
"Display": "ڈسپلے",
"Interface": "انٹرفیس",
"Sky and weather": "آسمان اور موسم",
"Lighting and entities": "لائٹنگ اور اینٹیٹیز",
"Textures": "ٹیکسچرز",
"Image": "تصویر",
"Chunk loading": "چنک لوڈنگ",
"Culling": "کلنگ",
"Power": "بجلی",
"Frames": "فریمز",
"Overlay": "اوورلے",
"Output": "آؤٹ پٹ",
"Brightness": "چمک",
"How far you see, the window and the HUD.": "آپ کتنی دور دیکھتے ہیں، ونڈو اور HUD۔",
"The sky and weather, lighting, textures and the image.": "آسمان اور موسم، لائٹنگ، ٹیکسچرز اور تصویر۔",
"Chunk loading, culling and power use.": "چنک لوڈنگ، کلنگ اور بجلی کا استعمال۔",
"How frames reach the screen, and the performance overlay.": "فریم سکرین تک کیسے پہنچتے ہیں، اور کارکردگی کا اوورلے۔",
"Brighter-than-white output on an HDR display.": "HDR ڈسپلے پر سفید سے زیادہ روشن آؤٹ پٹ۔",
"Results for \"%s\"": "\"%s\" کے لیے نتائج",
"No settings match.": "کوئی ترتیبات مطابقت نہیں رکھتی۔",
"horizon.title.not_affiliated": "موجانگ یا مائیکروسافٹ سے وابستہ نہیں",
"horizon.extras.header.lighting": "لائٹنگ",
"horizon.extras.header.third_person": "تیسرا شخص",
"horizon.extras.header.particles": "پارٹیکلز",
"horizon.extras.header.inventory": "انوینٹری",
"horizon.extras.header.chat": "چیٹ",
"horizon.extras.header.multiplayer": "ملٹی پلیئر",
"horizon.extras.header.sound": "آواز",
"horizon.extras.header.menus": "مینو",
"horizon.extras.fullbright": "فل برائٹ",
"horizon.extras.dynamic_lights": "ڈائنامک لائٹس",
"horizon.extras.own_name_tag": "اپنا نام ٹیگ",
"horizon.extras.hide_block_particles": "بلاگ ٹوٹنے کے ذرات چھپائیں",
"horizon.extras.ui_sound_reserve": "UI کی آوازوں کے لیے چینلز محفوظ رکھیں",
"horizon.extras.mouse_tweaks": "ماؤس ٹویکس کنٹرولز",
"horizon.extras.mouse_tweaks_wheel": "سکرول وہیل آئٹمز کو منتقل کرتا ہے",
"horizon.extras.sort": "بٹن ترتیب دیں",
"horizon.extras.sort_key": "ترتیب دینے کی کلید",
"horizon.extras.no_ai": "کوئی AI نہیں",
"horizon.extras.agent_control": "ایجنٹ کنٹرول",
"horizon.extras.discord_presence": "ڈسکارڈ رچ پریزینس",
"horizon.extras.splitscreen": "اسپلٹ سکرین",
"horizon.extras.chat_animation": "ہموار چیٹ",
"horizon.extras.chat_indicator": "پیغام کا اشارہ",
"horizon.extras.wheel_cycles_settings": "سکرول وہیل ترتیبات بدلتا ہے",
"horizon.extras.fullbright.tooltip": "اندھیری جگہوں پر بھی بلاکس اور اینٹیٹیز کو پوری روشنی میں دکھائیں۔",
"horizon.extras.dynamic_lights.tooltip": "مشاریں اور دیگر روشنی کے ذرائع جو پکڑے گئے ہیں، گرائے گئے ہیں یا آگ پر ہیں، اپنے اردگرد کے بلاکس کو روشن کرتے ہیں۔",
"horizon.extras.own_name_tag.tooltip": "تیسرے شخص میں آپ کے سر کے اوپر آپ کا نام دکھاتا ہے، جیسا کہ دوسرے کھلاڑی اسے دیکھتے ہیں۔ سرور کی طرف سے آپ کی ٹیم کے نام چھپانے یا آپ کے غائب ہونے پر چھپا رہتا ہے۔",
"horizon.extras.hide_block_particles.tooltip": "ٹوٹے ہوئے بلاک سے نکلنے والے ملبے کے ذرات اور آپ کے مائن کیے جانے والے بلاک سے گرنے والے ذرات کو چھپاتا ہے۔ دوسرے ذرات باقی رہتے ہیں۔",
"horizon.extras.auto_reconnect.tooltip": "کنکشن ٹوٹنے یا ری سٹارٹ کے بعد سرور سے دوبارہ جڑتا ہے، کک یا بین ہونے کے بعد کبھی نہیں۔",
"horizon.extras.ui_sound_reserve.tooltip": "دنیا کی آوازیں 247 آواز کے چینلز میں سے 16 کو خالی چھوڑتی ہیں، تاکہ ان کا سیلاب (ایک بڑا دھماکہ) مینو کلکس کو خاموش نہ کر سکے۔",
"horizon.extras.mouse_tweaks.tooltip": "رائٹ ڈریگ فی سلاٹ ایک رکھتا ہے، لیفٹ ڈریگ پکڑے گئے آئٹم کو اکٹھا کرتا ہے، Shift + لیفٹ ڈریگ ہر سلاٹ کو منتقل کرتا ہے۔",
"horizon.extras.mouse_tweaks_wheel.tooltip": "نیچے کرنے پر سلاٹ کا ایک آئٹم دوسری طرف جاتا ہے، اوپر کرنے پر واپس آتا ہے۔",
"horizon.extras.sort.tooltip": "تخلیقی ٹیب آرتیب میں انوینٹری، چیسٹ، بیرل، اینڈر چیسٹ اور شلکر باکس کو ترتیب دیتا ہے، سٹیکس کو ضم کرتا ہے۔",
"horizon.extras.sort_key.tooltip": "کرسر کے نیچے والے حصے کو ترتیب دیتا ہے۔ کلک کریں، پھر کوئی کلید دبائیں؛ Escape اسے غیر مقفل کرتا ہے۔",
"horizon.extras.no_ai.tooltip": "ہر AI فیچر کو چھپاتا اور بند کرتا ہے: ایجنٹ کنٹرول، horizon-ctl اور MCP سرورز۔ لانچر کا سوئچ بھی۔",
"horizon.extras.agent_control.tooltip": "آپ کا اپنا AI agent horizon-ctl یا MCP کلائنٹ کے ذریعے، صرف اس کمپیوٹر پر یہ گیم کھیل سکتا ہے۔",
"Use Automatic Setup": "خودکار سیٹ اپ استعمال کریں",
"Shortcut: %s": "شارٹ کٹ: %s",
"Next": "اگلا",
"HDR Calibration %s/%s: %s": "HDR کیلیبریشن %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "ایک وقت میں ایک ٹیسٹ تصویر کے ساتھ، نیچے دیے گئے تینوں کو فل سکرین پر سیٹ کرتا ہے۔",
"Set up for your display. Leave on Auto unless it looks wrong.": "آپ کے ڈسپلے کے لیے سیٹ اپ ہے۔ جب تک یہ غلط نہ لگے، آٹو پر چھوڑ دیں۔",
"Lower it until the logo just disappears into the black.": "اسے تب تک کم کریں جب تک لوگو کالے رنگ میں غائب نہ ہو جائے۔",
"Raise it until the logo just disappears into the bright square.": "اسے تب تک بڑھائیں جب تک لوگو روشن مربع میں غائب نہ ہو جائے۔",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "کارڈ کو ایک ایسے سفید رنگ میں بنائیں جسے آپ جس روشنی میں کھیلتے ہیں، اس میں طویل عرصے تک دیکھنا آرام دہ ہو۔",
"horizon.splitscreen.profiles.forget": "بھول جائیں",
"horizon.splitscreen.profiles.create": "بنائیں",
"horizon.splitscreen.profiles.you": " - آپ",
"horizon.splitscreen.profiles.forget_named": "%s کو بھول جائیں",
"horizon.splitscreen.profiles.previous": "پچھلا",
"horizon.splitscreen.profiles.next": "اگلا",
"horizon.splitscreen.profiles.new_button": "نیا پروفائل...",
"horizon.splitscreen.profiles.forget_title": "پروفائل بھول جائیں؟",
"horizon.splitscreen.profiles.forget_kept": "دنیا کی پیشرفت اور ترتیبات رکھی جاتی ہیں۔",
"horizon.splitscreen.profiles.new_title": "نیا پروفائل",
"horizon.splitscreen.profiles.name_hint": "نام (3 سے 16 حروف، ہندسے، _)",
"horizon.splitscreen.profiles.switch_title": "پروفائل تبدیل کریں",
"horizon.splitscreen.profiles.move": "منتقل کریں جہاں آپ کھیلتے ہیں",
"horizon.splitscreen.profiles.move_place": "منتقل کریں جہاں آپ کھیلتے ہیں: %s",
"horizon.friends.title": "دوستوں کے ساتھ کھیلیں",
"horizon.friends.hint": "دوست ٹائٹل سکرین کے فرینڈز بٹن کے بعد، ڈائریکٹ کنکشن میں، یا لانچر کے پلے پیج پر کوڈ پیسٹ کرتے ہیں۔",
"horizon.friends.copied": "کاپی ہو گیا",
"horizon.friends.copy_code": "کوڈ کاپی کریں",
"horizon.friends.stop_sharing": "شیئر کرنا بند کریں",
"horizon.friends.none_yet": "یہاں ابھی کوئی دوست نہیں ہے۔",
"horizon.friends.here_now": "یہاں موجود ہیں: %s",
"horizon.friends.share_intro": "اس دنیا کو دعوت نامہ کوڈ کے پیچھے شیئر کریں: یہ LAN کے لیے کھلتا ہے، اور انٹرنیٹ پر دوست کوڈ کے ساتھ شامل ہوتے ہیں۔ اس میں ہمارا کوئی سرور شامل نہیں ہے۔",
"horizon.friends.start_sharing": "شیئر کرنا شروع کریں",
"horizon.friends.troubleshooting": "خرابی کا سراغ لگانا...",
"horizon.friends.stopped": "دعوت نامہ کوڈ شیئر کرنا بند کر دیا۔",
"horizon.friends.short_code_changed": "کسی نے بار بار غلط شارٹ کوڈ آزمایا، اس لیے یہ بدل گیا۔",
"horizon.friends.wrong_code": "کسی نے غلط کوڈ آزمایا۔",
"horizon.friends.code_ready": "آپ کا دعوت نامہ کوڈ تیار ہے: گیم مینو، پلے ود فرینڈز۔ اسے کاپی کریں اور اپنے دوستوں کو بھیجیں۔",
"horizon.friends.share_failed": "دنیا شیئر نہیں کی جا سکی: %s",
"horizon.friends.finding_route": "تلاش کر رہے ہیں کہ دوست آپ تک کیسے پہنچ سکتے ہیں…",
"horizon.osk.type": "ٹائپ کریں",
"horizon.osk.symbols": "علامات",
"horizon.osk.close": "بند کریں",
"horizon.extras.chat_animation.tooltip": "ایک نئی چیٹ لائن پوری طرح ظاہر ہونے کے بجائے مدہم ہوتے ہوئے بائیں طرف سے سلائیڈ کرتی ہے۔",
"horizon.extras.chat_indicator.tooltip": "چیٹ لائن کے بائیں طرف رنگین پٹی۔",
"horizon.extras.wheel_cycles_settings.tooltip": "ترتیبات میں کسی بٹن پر سکرول وہیل اس کی قدر بدلتا ہے، جیسا کہ ونیلا میں ہوتا ہے۔ آف ہونے پر، وہیل صرف لسٹ کو سکرول کرتا ہے۔",
"horizon.extras.splitscreen.tooltip": "ایک دنیا میں، دوسرا کنٹرولر ایک نئے کھلاڑی کے طور پر شامل ہو سکتا ہے: پوچھنے پر A دبائیں، پھر ایک پروفائل اور کھیلنے کی جگہ منتخب کریں۔",
"horizon.splitscreen.setup.monitor": "مانیٹر %s",
"horizon.splitscreen.setup.monitor_unknown": "ایک مانیٹر",
"horizon.splitscreen.setup.window": "ونڈو %s",
"horizon.splitscreen.setup.new_window": "نئی ونڈو",
"horizon.splitscreen.setup.fill_monitor": "%s بھریں",
"horizon.splitscreen.setup.done": "مکمل",
"horizon.splitscreen.setup.on": "آن",
"horizon.splitscreen.setup.off": "آف",
"horizon.splitscreen.setup.player": "کھلاڑی %s",
"horizon.splitscreen.setup.revert": "واپس لیں",
"horizon.splitscreen.setup.apply": "لاگو کریں",
"horizon.splitscreen.setup.warning.empty_close": "ونڈو %s میں کوئی نہیں کھیلتا: یہ اپلائی کرنے پر بند ہو جاتی ہے",
"horizon.splitscreen.setup.warning.on_main": "ونڈو %s، ونڈو 1 کے مانیٹر کو بھرتی ہے: اس کے کھلاڑی ونڈو 1 میں کھیلتے ہیں",
"horizon.splitscreen.setup.warning.unplugged": "ونڈو %s کا مانیٹر کنیکٹ نہیں ہے: اس کے کھلاڑی ونڈو 1 میں کھیلتے ہیں",
"horizon.splitscreen.setup.status.off": "اسپلٹ سکرین بند ہے۔",
"horizon.splitscreen.setup.status.enable_hint": "اسے آن کریں، پھر دنیا میں کسی دوسرے کنٹرولر پر کوئی بٹن دبائیں۔",
"horizon.splitscreen.setup.status.join_hint": "شامل ہونے اور پروفائل منتخب کرنے کے لیے A دبائیں۔",
"horizon.splitscreen.setup.status.idle": "اسپلٹ سکرین غیر فعال",
"horizon.splitscreen.setup.status.alone": "کوئی دوسرا مقامی کھلاڑی نہیں۔",
"horizon.splitscreen.setup.status.solo_controls": "سولو کنٹرولز اور کنٹرولر فیڈبیک تبدیل نہیں ہوئے ہیں۔",
"horizon.splitscreen.setup.window_empty": "ونڈو %s: خالی",
"horizon.splitscreen.setup.note.select": "کسی کھلاڑی پر کلک کریں، یا اسے کسی ونڈو یا مانیٹر پر ڈریگ کریں۔",
"horizon.splitscreen.setup.note.apply": "اس طرح کھیلنے کے لیے اپلائی کریں، ریورٹ کرنے کے لیے واپس لیں۔",
"horizon.splitscreen.setup.note.place": "%s کو کہاں کھیلنا چاہیے؟",
"horizon.splitscreen.setup.this_player": "یہ کھلاڑی",
"horizon.splitscreen.setup.switch_disabled": "دوسروں کے جانے کے بعد اسپلٹ سکرین بند ہو جاتی ہے (ان کا پاز مینو)۔",
"horizon.splitscreen.setup.window_place": "ونڈو %s (%s کا %s)",
"horizon.splitscreen.setup.names": "نام",
"horizon.splitscreen.setup.identify": "پہچانیں",
"horizon.splitscreen.setup.layout.two": "2 اشتراک کر رہے ہیں: %s",
"horizon.splitscreen.setup.layout.three": "3 اشتراک کر رہے ہیں: %s",
"horizon.splitscreen.setup.layout.two.0": "آٹو",
"horizon.splitscreen.setup.layout.two.1": "آمنے سامنے (سائیڈ بائی سائیڈ)",
"horizon.splitscreen.setup.layout.two.2": "اوپر اور نیچے",
"horizon.splitscreen.setup.layout.three.0": "اوپر ایک چوڑا",
"horizon.splitscreen.setup.layout.three.1": "بائیں طرف ایک لمبا",
"horizon.splitscreen.setup.layout.three.2": "آمنے سامنے",
"horizon.splitscreen.setup.help.player": "انہیں منتقل کرنے کے لیے %s پر کلک کریں یا ڈریگ کریں۔",
"horizon.splitscreen.setup.help.layout_auto": "%s (ونڈو چوڑی ہونے پر سائیڈ بائی سائیڈ)۔ تبدیل کرنے کے لیے دبائیں۔",
"horizon.splitscreen.setup.help.layout": "%s۔ تبدیل کرنے کے لیے دبائیں۔",
"horizon.splitscreen.setup.help.switch": "اس کمپیوٹر کے لیے اسپلٹ سکرین، آن یا آف۔",
"horizon.splitscreen.setup.help.identify": "ہر کھلاڑی کا منظر دکھاتا ہے کہ یہ کس ونڈو میں ہے۔",
"horizon.splitscreen.setup.help.names": "ہر کھلاڑی کا نام ان کے منظر کے کونے میں۔",
"horizon.splitscreen.setup.help.revert": "ابھی تک لاگو نہ کی گئی تبدیلیوں کو واپس لیں۔",
"horizon.splitscreen.setup.help.apply": "نقشہ جس طرح دکھاتا ہے اس طرح کھیلیں۔",
"horizon.splitscreen.setup.help.unchanged": "ابھی تک کچھ نہیں بدلا۔",
"horizon.splitscreen.setup.give_up_pad": "گیو اپ پیڈ",
"horizon.splitscreen.prompt.controller_connected": "کنٹرولر جڑ گیا",
"horizon.splitscreen.prompt.controller_disconnected": "کنٹرولر منقطع ہو گیا",
"horizon.splitscreen.prompt.controller_disconnected.hint": "اسے دوبارہ جوڑیں، یا کسی دوسرے پر کوئی بٹن دبائیں",
"horizon.splitscreen.prompt.join.one": "%s شامل ہونا چاہتا ہے",
"horizon.splitscreen.prompt.join.many": "%s کنٹرولرز شامل ہونا چاہتے ہیں",
"horizon.splitscreen.prompt.join.yes": "شامل ہوں",
"horizon.splitscreen.prompt.join.as_p1": "P1 کے طور پر کھیلیں",
"horizon.splitscreen.prompt.join.no": "نہیں",
"horizon.splitscreen.prompt.lost": "%s: کنٹرولر منقطع ہو گیا",
"horizon.splitscreen.prompt.lost.use_another": "دوسرا استعمال کریں",
"horizon.splitscreen.prompt.lost.leave": "چھوڑیں",
"horizon.splitscreen.setup.help.give_up_pad": "آپ کا کنٹرولر آپ کو چلانا بند کر دیتا ہے اور شامل ہونے کے لیے پیش کیا جاتا ہے (A)۔",
"horizon.splitscreen.setup.note.held_at": "%s سے %s: A اسے یہاں گراتا ہے، B اسے واپس رکھتا ہے۔",
"Block Entities": "بلاگ اینٹیٹیز",
"Shulker Boxes": "شلکر باکسز",
"Reuses settled boxes; lid movement stays animated.": "مستحکم باکس کا دوبارہ استعمال کرتا ہے؛ ڈھکن کی حرکت متحرک رہتی ہے۔",
"Signs": "سائن بورڈز",
"Reuses sign text until text, light, or font changes.": "جب تک متن، روشنی یا فونٹ نہ بدلے، سائن کے متن کا دوبارہ استعمال کرتا ہے۔",
"Decorated Pots": "سجاے گئے برتن",
"Reuses still pots; break wobble and sherd changes redraw.": "مستحکم برتنوں کا دوبارہ استعمال کرتا ہے؛ ٹوٹنے کا ڈگمگانا اور شیرڈ کی تبدیلیاں دوبارہ بناتی ہیں۔",
"Bells": "گھنٹیاں",
"Reuses still bells; ringing stays animated.": "مستحکم گھنٹیوں کا دوبارہ استعمال کرتا ہے؛ بجنا متحرک رہتا ہے۔",
"Chests": "چیسٹس",
"Reuses closed chests; moving lids stay animated.": "بند چیسٹس کا دوبارہ استعمال کرتا ہے؛ ہلتے ہوئے ڈھکن متحرک رہتے ہیں۔",
"Banners": "بینرز",
"Reuses poles; flags and pattern layers still update.": "پول کا دوبارہ استعمال کرتا ہے؛ جھنڈے اور پیٹرن تہیں اب بھی اپ ڈیٹ ہوتی ہیں۔",
"Copper Golem Statues": "کاپر گولم مجسمے",
"Reuses statue geometry until its block state changes.": "بلاک کی حالت بدلنے تک مجسمے کی جیومیٹری کا دوبارہ استعمال کرتا ہے۔",
"Leaf Culling": "لیف کلنگ",
"Every leaf face. Vanilla's fancy leaves.": "ہر پتے کا چہرہ۔ ونیلا کے فینسی پتے۔",
"Distant Plant Dither": "دور کے پودے کا دیتھر",
"Keeps fine plants in high-res packs from fading far away.": "ہائی ریز پیک میں اچھے پودوں کو دور مدہم ہونے سے بچاتا ہے۔",
"Screenshot Quality": "سکرین شاٹ کا معیار",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "گیم کے ریزولوشن کے اتنے گنا پر F2 سکرین شاٹس۔ زیادہ مان میں زیادہ وقت لگتا ہے۔",
"Frame Generation": "فریم جنریشن",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "ہر دو اصلی فریموں کے درمیان ایک فرضی فریم دکھاتا ہے: زیادہ ہموار، تھوڑا سا زیادہ لیگ۔",
"Find BlueMap": "بلیو میپ تلاش کریں",
"On servers, finds their BlueMap web map for far terrain. Next join.": "سرورز پر، دور کے خطے کے لیے ان کا بلیو میپ ویب میپ تلاش کرتا ہے۔ اگلی شروعات پر۔",
"BlueMap Radius": "بلیو میپ رداس",
"Synchronous": "ہم وقت ساز",
"Asynchronous": "غیر مطابقت پذیر",
"Hollow": "خالی",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "کینوپی کے پوری طرح اندر کے پتے ٹھوس بنتے ہیں: کم آر پار نظر آنے والی تہیںـ",
"Leaves with one open side draw solid too: faster, chunkier edges.": "ایک کھلی سائیڈ والے پتے بھی ٹھوس بنتے ہیں: تیز، زیادہ موٹے کنارے۔",
"Drops faces between leaves: faster, but gaps look through.": "پتوں کے درمیان چہروں کو ہٹا دیتا ہے: تیز، لیکن وقفوں سے آر پار دکھتا ہے۔",
"Hotbar: Previous Slot": "ہاٹ بار: پچھلا سلاٹ",
"horizon.agent.connected.one": "منسلک: 1 ایجنٹ کنکشن",
"horizon.agent.connected.many": "منسلک: %s ایجنٹ کنکشنز",
"horizon.agent.connected.one_ago": "منسلک: 1 ایجنٹ کنکشن، آخری کمانڈ %s سیکنڈ پہلے",
"horizon.agent.connected.many_ago": "منسلک: %s ایجنٹ کنکشنز، آخری کمانڈ %s سیکنڈ پہلے",
"horizon.agent.waiting": "ایک ایجنٹ کا انتظار ہے۔",
"horizon.agent.off": "آف: ایجنٹس اس گیم تک نہیں پہنچ سکتے۔",
"horizon.agent.session_opened": "گیم شروع کرنے والے پروگرام کی طرف سے اس سیشن کے لیے کھولا گیا۔",
"horizon.agent.socket": "ساکٹ: %s (اس کے مکمل راستے کے لیے تفصیلات کاپی کریں)",
"horizon.agent.local_only": "صرف مقامی: اس کمپیوٹر پر ایجنٹس، نیٹ ورک پر کبھی نہیں۔",
"%s nits": "%s نٹس",
"Auto (%s nits)": "آٹو (%s نٹس)",
"%.2f nits": "%.2f نٹس",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "غیر شفاف بلاکس کے پیچھے ماڈلز چھپاتا ہے۔ اینٹیٹیز اپ ڈیٹ ہوتی ہیں۔ کسٹم پیک کے ساتھ آف۔",
"horizon.extras.repro_keys": "ریکارڈنگ شارٹ کٹس",
"horizon.extras.repro_keys.tooltip": "F3+R ریپرو ریکارڈنگ شروع یا بند کرتا ہے اور F3+E اس کا بنڈل لکھتا ہے۔ پہلے سے آف: ریکارڈنگ دنیا کی نقل کرتی ہے۔",
"horizon.agent.record_session": "ریکارڈنگ سیشن شروع کریں",
"horizon.agent.record_stop": "ریکارڈنگ سیشن بند کریں",
"horizon.agent.export_repro": "ریپرو بنڈل برآمد کریں",
"horizon.repro.stopped": "ریکارڈنگ بند ہو گئی: %s",
"horizon.repro.bundle": "ریپرو بنڈل: %s",
"horizon.repro.error": "ریپرو: %s",
"horizon.repro.no_recording": "برآمد کرنے کے لیے ابھی تک کوئی ریکارڈنگ نہیں ہے۔",
"horizon.repro.replay_started": "ری پلے شروع ہو گیا: %s"
}
@@ -0,0 +1,175 @@
//! Chunk-send batches for separated players, without a window or sockets.
//! cargo run --profile fast -p horizon-client-lib --example chunk_encoding_bench -- RESOURCES
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
assets::Assets,
chunk::{Chunk, Section},
lan::chunks::ChunkEncoder,
save::SaveTables,
spans::thread_cpu,
world::World,
};
use std::{
hash::{DefaultHasher, Hash, Hasher},
hint::black_box,
path::Path,
sync::Arc,
time::Instant,
};
fn bodies(encoder: &ChunkEncoder, columns: &[&Chunk], world: &World) -> Vec<Vec<u8>> {
columns.iter().map(|c| encoder.body_in(c, world)).collect()
}
fn fixture(
players: i32,
side: i32,
filled: usize,
stone: u32,
water: u32,
) -> (World, Vec<(i32, i32)>) {
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
let mut keys = Vec::new();
for p in 0..players {
let (cx, cz) = ((p % 10) * 150, (p / 10) * 150);
for dz in -1..=side {
for dx in -1..=side {
let (x, z) = (cx + dx, cz + dz);
let mut c = Chunk::empty(x, z);
c.lit = true;
for sy in 0..filled {
let values = std::array::from_fn(|i| {
if (i + sy * 3 + x.unsigned_abs() as usize) % 37 < 17 {
stone
} else {
water
}
});
Arc::make_mut(&mut c.sections[sy]).blocks = Section::from_blocks(&values);
}
world.chunks.insert((x, z), Arc::new(c));
if (0..side).contains(&dx) && (0..side).contains(&dz) {
keys.push((x, z));
}
}
}
}
(world, keys)
}
fn main() -> horizon_client::Result<()> {
let args: Vec<_> = std::env::args().collect();
let root = Path::new(args.get(1).ok_or("expected prepared resources")?);
let assets = Assets::load(root)?;
let stone = assets.registry.default_state("minecraft:stone").unwrap().0;
let water = assets.registry.default_state("minecraft:water").unwrap().0;
let tables = Arc::new(SaveTables::new(&assets));
let biomes = horizon_client::biome::Biomes::load(root)?;
let biome_names: Vec<String> = biomes.list.iter().map(|b| b.name.clone()).collect();
let sync =
horizon_client::lan::data::SyncData::load(root, &horizon_world::loot::PackOverlay::NONE)?;
let biome_registry: Vec<String> = sync
.dynamic
.registries
.get("minecraft:worldgen/biome")
.ok_or("missing biome registry")?
.iter()
.map(|(name, _)| name.clone())
.collect();
let make = || {
let states = (0..assets.registry.len()).map(|i| {
let s = assets
.registry
.state(horizon_world::registry::BlockStateId(i as u32))
.unwrap();
(
Arc::<str>::from(s.name.as_str()),
s.name == "minecraft:water"
|| s.name == "minecraft:lava"
|| s.properties.get("waterlogged").is_some_and(|v| v == "true"),
)
});
let mut e = ChunkEncoder::new(states, &sync.builtin, &biome_names, &biome_registry);
e.tables = Some(tables.clone());
e
};
for (players, side, filled) in [
(1, 1, 8),
(100, 1, 8),
(100, 2, 8),
(100, 4, 8),
(100, 4, 0),
] {
let (world, keys) = fixture(players, side, filled, stone, water);
let columns: Vec<_> = keys
.iter()
.map(|k| &**world.chunks.get(k).unwrap())
.collect();
let reference = make();
let expected = bodies(&reference, &columns, &world);
let mut hasher = DefaultHasher::new();
expected.hash(&mut hasher);
let hash = hasher.finish();
let bytes: usize = expected.iter().map(Vec::len).sum();
if players == 100 && side == 1 {
for sample in 0..8 {
let encoder = make();
let mut h = DefaultHasher::new();
let mut biome_bytes = 0;
for c in &columns {
let b = encoder.biome_data(c);
b.buffer.hash(&mut h);
biome_bytes += b.buffer.len();
}
let start = Instant::now();
let cpu = thread_cpu();
for _ in 0..50 {
for c in &columns {
black_box(encoder.biome_data(c));
}
}
let wall_us = start.elapsed().as_secs_f64() * 1e6 / 50.0;
let cpu_us = thread_cpu()
.zip(cpu)
.map(|(b, a)| (b - a).as_secs_f64() * 1e6 / 50.0);
if sample > 0 {
println!(
"{}",
serde_json::json!({"phase":"biomes","players":players,"side":side,"filled":filled,"cold":false,"sample":sample,"cpu_us":cpu_us,"wall_us":wall_us,"bytes":biome_bytes,"hash":format!("{:016x}",h.finish())})
);
}
}
}
for cold in [true, false] {
let e = make();
e.warm(&columns);
assert_eq!(bodies(&e, &columns, &world), expected);
for sample in 0..8 {
let encoder = if cold { make() } else { e.detached() };
if !cold {
encoder.warm(&columns);
black_box(bodies(&encoder, &columns, &world));
}
let wall = Instant::now();
let cpu = thread_cpu();
let rounds = if cold { 1 } else { 10 };
for _ in 0..rounds {
if cold {
encoder.warm(&columns);
}
black_box(bodies(&encoder, &columns, &world));
}
let cpu_us = thread_cpu()
.zip(cpu)
.map(|(b, a)| (b - a).as_secs_f64() * 1e6 / rounds as f64);
let wall_us = wall.elapsed().as_secs_f64() * 1e6 / rounds as f64;
if sample > 0 {
println!(
"{}",
serde_json::json!({"phase":"columns","players":players,"side":side,"filled":filled,"cold":cold,"sample":sample,"cpu_us":cpu_us,"wall_us":wall_us,"bytes":bytes,"hash":format!("{hash:016x}")})
);
}
}
}
}
Ok(())
}
@@ -1015,7 +1015,8 @@ pub fn main() -> horizon_client::Result<()> {
spec.yaw,
);
if spec.baby {
m.extra.age = -24000;
// AgeableMob.setBaby: the species' getBabyStartAge (a sniffer's is -48000).
m.extra.age = horizon_client::mobs::finalize::baby_start_age(m.species());
}
for flag in &spec.flags {
match flag.as_str() {
+6 -6
View File
@@ -9,12 +9,12 @@ pub mod arrows;
pub mod block_contact;
pub use horizon_client_render::{
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_edit, block_entities,
block_entity_anim, block_entity_render, bluemap, bluemap_detect, border_render, breaking,
brewing, camera, chat, chunk, chunk_map, cit, cit_armor, cit_assets, cit_glint, clouds,
collisions, command_block, containers, crash, datapack, dynamic_light, effects, environment,
extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud,
hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block, json_folder,
level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_list,
block_entity_anim, block_entity_render, block_sprites, bluemap, bluemap_detect, border_render,
breaking, brewing, camera, chat, chunk, chunk_map, cit, cit_armor, cit_assets, cit_glint,
clouds, collisions, command_block, containers, crash, datapack, dynamic_light, effects,
environment, extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui,
hdr_calibration, hud, hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block,
json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_list,
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
options_ui, overlay, pack_screen, packs, par, particles, poi_sections, post_chain,
potent_sulfur, preedit, providers, screen_draw, screen_effects, sculk, section_mask,
+362 -64
View File
@@ -105,7 +105,8 @@ pub struct ItemEntity {
pub velocity: Vec3,
pub on_ground: bool,
pub age: i32,
pub pickup_delay: u32,
/// Signed NBT counter; widened to retain the legacy internal u32::MAX sentinel.
pub pickup_delay: i64,
pub bob_offset: f32,
/// `Entity.tickCount`: ticks since the item entered the level, which
/// paces `ItemEntity.mergeWithNeighbours`.
@@ -158,8 +159,65 @@ impl crate::entity_rules::EntityRules for ExperienceOrb {
0.03
}
}
/// An armor stand keeps no `NoGravity` flag yet, so it takes the defaults.
impl crate::entity_rules::EntityRules for ArmorStand {}
impl crate::entity_rules::EntityRules for ArmorStand {
fn is_no_gravity(&self) -> bool {
self.no_gravity
}
/// `LivingEntity.getDefaultGravity`.
fn get_default_gravity(&self) -> f64 {
0.08
}
/// The code's reading of `Entity.isPushedByFluid` for a marker stand: it
/// takes no fluid push and is never in water.
fn is_pushed_by_fluid(&self) -> bool {
!self.marker
}
}
/// What an item's tick carries from [`ItemEntity::tick_move`] to
/// [`ItemEntity::tick_settle`], across the game's bubble-column push.
#[derive(Clone, Copy)]
pub struct ItemStep {
old_velocity: Vec3,
moved: bool,
}
#[cfg(test)]
impl ItemEntity {
/// A whole `ItemEntity.tick` for the unit tests, in the order the game
/// runs it: fluid push, move, bubble column, settle, fluid push.
pub(crate) fn tick(
&mut self,
world: &World,
registry: &horizon_world::registry::BlockRegistry,
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
no_physics: bool,
fluids: &crate::motion::FluidResources,
entity_boxes: &[Aabb],
border: Option<&BorderGeometry>,
) -> bool {
let heights = self.update_fluids(world, fluids);
let step = self.tick_move(
world,
facts,
collision,
no_physics,
heights,
entity_boxes,
border,
);
crate::motion::bubble_column_push(
world,
registry,
facts,
collision,
self.bounds(),
&mut self.velocity,
);
let alive = self.tick_settle(world, facts, no_physics, step);
self.update_fluids(world, fluids);
alive
}
}
impl ItemEntity {
pub const WIDTH: f64 = 0.25;
pub fn bounds(&self) -> Aabb {
@@ -239,11 +297,13 @@ impl ItemEntity {
if self.health > 0 {
return None;
}
// `ItemStack.onDestroyed` runs the item's own arm. A bundle's
// (`BundleItem.onDestroyed`) empties its `bundle_contents` and hands
// them back; only a bundle holds that component, so the component is
// the dispatch (a mod's own bundle carries one too).
Some(crate::bundle::destroyed(&mut self.stack))
// `ItemStack.onDestroyed` runs the item's own arm, and the component
// the stack holds is the dispatch: a bundle's (`BundleItem.onDestroyed`)
// `bundle_contents` and a container item's (`BlockItem.onDestroyed`)
// `container`, which a mod's own item carries in the same way.
let mut spilled = crate::bundle::destroyed(&mut self.stack);
spilled.extend(crate::item_data::destroyed_container(&mut self.stack));
Some(spilled)
}
/// `ItemEntity.tick` on a server: an item whose box is inside a block
/// (one placed where it lay) has no physics this tick
@@ -271,25 +331,26 @@ impl ItemEntity {
speed,
);
}
/// Returns false when the item should be removed. `no_physics`: the item
/// moves through blocks this tick ([`ItemEntity::inside_block`]).
pub fn tick(
/// `ItemEntity.tick` up to the blocks' push. The game has just run the
/// fluid push (`Entity::push_by_fluid`) and passes the heights it found;
/// it pushes by a bubble column next (`Entity::push_by_bubble_column`) and
/// then calls [`ItemEntity::tick_settle`]. `no_physics`: the item moves
/// through blocks this tick ([`ItemEntity::inside_block`]).
pub fn tick_move(
&mut self,
world: &World,
registry: &horizon_world::registry::BlockRegistry,
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
no_physics: bool,
fluids: &crate::motion::FluidResources,
(water_height, lava_height): (f64, f64),
entity_boxes: &[Aabb],
border: Option<&BorderGeometry>,
) -> bool {
) -> ItemStep {
self.ticks += 1;
self.previous = self.position;
if self.pickup_delay > 0 && self.pickup_delay != u32::MAX {
if self.pickup_delay > 0 && !self.has_infinite_pickup_delay() {
self.pickup_delay -= 1;
}
let (water_height, lava_height) = self.update_fluids(world, fluids);
let old_velocity = self.velocity;
let in_water = water_height > f64::from(0.1f32);
if in_water {
@@ -385,14 +446,32 @@ impl ItemEntity {
// (before air drag), matching ItemEntity.tick.
self.apply_honey_slide(world, facts);
self.stuck = self.stuck_multiplier(world, facts).unwrap_or(Vec3::ZERO);
crate::motion::bubble_column_push(
world,
registry,
facts,
collision,
self.bounds(),
&mut self.velocity,
);
return ItemStep {
old_velocity,
moved: true,
};
}
// applyEffectsFromBlocksForLastMovements: still lift/drag while idle.
if !no_physics {
self.slime_step_on(world, facts);
}
ItemStep {
old_velocity,
moved: false,
}
}
/// The rest of `ItemEntity.tick`, after the bubble column's push: slime,
/// drag, block contact, age. Returns false when the item should be
/// removed. The game then runs the fluid push again (`ItemEntity.tick`
/// refreshes immersion after moving).
pub fn tick_settle(
&mut self,
world: &World,
facts: &BlockFacts,
no_physics: bool,
step: ItemStep,
) -> bool {
if step.moved {
// Entity.applyEffectsFromBlocks -> SlimeBlock.stepOn, with the
// vertical speed the bounce just left (before the air drag).
if !no_physics {
@@ -415,19 +494,6 @@ impl ItemEntity {
if self.on_ground && self.velocity.y < 0.0 {
self.velocity.y *= -0.5;
}
} else {
// applyEffectsFromBlocksForLastMovements: still lift/drag while idle.
if !no_physics {
self.slime_step_on(world, facts);
}
crate::motion::bubble_column_push(
world,
registry,
facts,
collision,
self.bounds(),
&mut self.velocity,
);
}
if !no_physics {
// Entity.applyEffectsFromBlocks: stepOn, then checkInsideBlocks.
@@ -460,14 +526,12 @@ impl ItemEntity {
},
);
}
if self.velocity.subtract(old_velocity).length_squared() > 0.01 {
if self.velocity.subtract(step.old_velocity).length_squared() > 0.01 {
self.needs_sync = true;
}
if self.age != i16::MIN as i32 {
self.age += 1;
}
// ItemEntity.tick refreshes immersion after moving.
self.update_fluids(world, fluids);
self.age < 6000 && self.position.y > -128.0
}
/// `Entity.makeStuckInBlock` as `WebBlock.entityInside` and
@@ -570,10 +634,13 @@ impl ItemEntity {
Vec3::new(v.x, new_delta_y(-0.05), v.z)
};
}
fn has_infinite_pickup_delay(&self) -> bool {
self.pickup_delay == i64::from(i16::MAX) || self.pickup_delay == i64::from(u32::MAX)
}
/// `ItemEntity.isMergable` at `age`: a stack below its size whose pickup
/// delay isn't infinite, before its lifetime ends.
fn mergeable(&self, age: i32, max_stack: u32) -> bool {
self.pickup_delay != u32::MAX
!self.has_infinite_pickup_delay()
&& age != i16::MIN as i32
&& age < 6000
&& self.stack.count < max_stack
@@ -1315,6 +1382,56 @@ pub struct ArmorStand {
/// `Entity.mainSupportingBlockPos` after the last move.
pub support: horizon_world::ground_support::GroundState,
}
/// Armor stands share LivingEntity's block/tag and trapdoor climb predicate.
struct StandClimbing<'a> {
world: &'a World,
position: BlockPos,
}
impl horizon_world::climbing::ClimbingWorld for StandClimbing<'_> {
type Error = std::convert::Infallible;
fn is_spectator(&mut self) -> Result<bool, Self::Error> {
Ok(false)
}
fn is_fall_flying(&mut self) -> Result<bool, Self::Error> {
Ok(false)
}
fn block_position(&mut self) -> Result<BlockPos, Self::Error> {
Ok(self.position)
}
fn in_block_state(&mut self) -> Result<horizon_world::registry::BlockStateId, Self::Error> {
self.block_state(self.position)
}
fn block_state(
&mut self,
p: BlockPos,
) -> Result<horizon_world::registry::BlockStateId, Self::Error> {
Ok(self.world.block(p.x, p.y, p.z))
}
fn set_last_climbable_position(&mut self, _: BlockPos) {}
}
/// What a stand's travel carries from [`ArmorStand::tick_move`] to
/// [`ArmorStand::tick_settle`], across the game's bubble-column push.
#[derive(Clone, Copy)]
pub struct StandStep {
falling: bool,
friction: f32,
}
#[cfg(test)]
impl ArmorStand {
/// A whole travel for the unit tests, with no bubble column.
pub(crate) fn tick(
&mut self,
scene: &mut crate::collisions::SceneCollisions,
facts: &BlockFacts,
climbing: &horizon_world::climbing::ClimbingRegistry<'_>,
in_water: bool,
border: Option<&BorderGeometry>,
) {
if let Some(step) = self.tick_move(scene, facts, climbing, in_water, border) {
self.tick_settle(in_water, step);
}
}
}
impl ArmorStand {
/// `NameTagItem.interactLivingEntity`: a custom name handles the use;
/// only a living target changes. Stack consumption belongs to the caller.
@@ -1514,17 +1631,23 @@ impl ArmorStand {
/// `getFluidFallingAdjustedMovement`, in air gravity 0.08, ground/air
/// horizontal drag (`friction * 0.91`), and the 0.98 vertical drag, so a
/// stand at rest on the ground keeps -0.0784 as vanilla's.
pub fn tick(
///
/// The game runs the fluid push first (`Entity::push_by_fluid`), then
/// this up to the blocks' push, then the bubble column
/// (`Entity::push_by_bubble_column`), then [`ArmorStand::tick_settle`].
/// None: a marker or a stand with `NoGravity` does not travel.
pub fn tick_move(
&mut self,
scene: &mut crate::collisions::SceneCollisions,
facts: &BlockFacts,
climbing: &horizon_world::climbing::ClimbingRegistry<'_>,
in_water: bool,
push: &mut dyn FnMut(Aabb, &mut Vec3),
border: Option<&BorderGeometry>,
) {
) -> Option<StandStep> {
use crate::entity_rules::EntityRules;
self.previous = self.position;
if self.marker {
return;
return None;
}
scene.subject =
self.feet_are_leather_boots()
@@ -1533,15 +1656,15 @@ impl ArmorStand {
..Default::default()
});
let v = self.velocity;
let wanted = Vec3::new(
let mut wanted = Vec3::new(
if v.x.abs() < 0.003 { 0.0 } else { v.x },
if v.y.abs() < 0.003 { 0.0 } else { v.y },
if v.z.abs() < 0.003 { 0.0 } else { v.z },
);
self.velocity = wanted;
// ArmorStand.travel: no physics with NoGravity, the velocity stays.
if self.no_gravity {
return;
if self.is_no_gravity() {
return None;
}
// `travelInFluid` reads `isFalling` before the move and any push;
// `travelInAir` reads ground friction before the move.
@@ -1556,7 +1679,28 @@ impl ArmorStand {
} else {
1.0
};
let on_climbable = |position| {
horizon_world::climbing::on_climbable(
&mut StandClimbing {
world: scene.world,
position: BlockPos::containing(position),
},
climbing,
)
.unwrap_or(false)
};
if !in_water && on_climbable(self.position) {
self.enchantment_fall_distance = 0.0;
// LivingEntity.handleOnClimbable; suppressing slide is Player-only.
let limit = f64::from(0.15f32);
wanted.x = wanted.x.clamp(-limit, limit);
wanted.z = wanted.z.clamp(-limit, limit);
if !wanted.y.is_nan() {
wanted.y = wanted.y.max(-limit);
}
}
let mut velocity = wanted;
let mut horizontal_collision = false;
// Entity.collide: the world border's walls stop the stand too.
let moved = SceneCollisions {
world: scene.world,
@@ -1596,34 +1740,47 @@ impl ArmorStand {
)
});
if (wanted.x - moved.x).abs() >= f64::from(1.0e-5_f32) {
horizontal_collision = true;
velocity.x = 0.0;
}
if moved.y != wanted.y {
velocity.y = 0.0;
}
if (wanted.z - moved.z).abs() >= f64::from(1.0e-5_f32) {
horizontal_collision = true;
velocity.z = 0.0;
}
}
push(self.bounds(), &mut velocity);
if !in_water && horizontal_collision && on_climbable(self.position) {
velocity.y = 0.2;
}
self.velocity = velocity;
Some(StandStep { falling, friction })
}
/// The rest of the stand's travel after the bubble column's push: the
/// water slowdown or the air drag and gravity.
pub fn tick_settle(&mut self, in_water: bool, step: StandStep) {
use crate::entity_rules::EntityRules;
let StandStep { falling, friction } = step;
let velocity = self.velocity;
self.velocity = if in_water {
// travelInWater: slowDown 0.8, then `getFluidFallingAdjustedMovement`
// with gravity / 16 (the stand is falling while its fall is not
// already nearly stopped).
let slow = f64::from(0.8f32);
let y = velocity.y * slow;
let adjusted =
if falling && (y - 0.005).abs() >= 0.003 && (y - 0.08 / 16.0).abs() < 0.003 {
-0.003
} else {
y - 0.08 / 16.0
};
let sink = self.get_gravity() / 16.0;
let adjusted = if falling && (y - 0.005).abs() >= 0.003 && (y - sink).abs() < 0.003 {
-0.003
} else {
y - sink
};
Vec3::new(velocity.x * slow, adjusted, velocity.z * slow)
} else {
let drag = f64::from(friction * 0.91f32);
Vec3::new(
velocity.x * drag,
(velocity.y - 0.08) * f64::from(0.98f32),
(velocity.y - self.get_gravity()) * f64::from(0.98f32),
velocity.z * drag,
)
};
@@ -2039,12 +2196,14 @@ mod merge_tests {
#[test]
fn unmergeable_items_are_skipped() {
let mut items = [
item("minecraft:sand", 1, 0.5, 1),
item("minecraft:sand", 1, 0.5, 1),
];
items[1].pickup_delay = u32::MAX;
assert_eq!(merge(&mut items, 0), [Some(1), Some(1)]);
for delay in [i64::from(i16::MAX), i64::from(u32::MAX)] {
let mut items = [
item("minecraft:sand", 1, 0.5, 1),
item("minecraft:sand", 1, 0.5, 1),
];
items[1].pickup_delay = delay;
assert_eq!(merge(&mut items, 0), [Some(1), Some(1)]);
}
// At age 6000 the other item's lifetime is over; this one's tick
// started at 5999, so it may still merge into a live item.
let mut items = [
@@ -2862,7 +3021,109 @@ mod armor_stand_motion_tests {
binding: collision,
subject: None,
};
stand.tick(&mut scene, facts, false, &mut |_, _| {}, None);
let empty = Default::default();
let climbing = horizon_world::climbing::ClimbingRegistry::new(
collision.registry(),
&empty,
&empty,
&empty,
)
.unwrap();
stand.tick(&mut scene, facts, &climbing, false, None);
}
#[test]
fn climbing_uses_actual_blocks_and_post_collision_position() {
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
});
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let motion = crate::motion::MotionResources::load(&root, &assets).unwrap();
let registry = assets.registry;
let state = |name, properties: &[(&str, &str)]| {
let mut state = registry
.state(registry.default_state(name).unwrap())
.unwrap()
.clone();
for (key, value) in properties {
state.properties.insert((*key).into(), (*value).into());
}
registry.id(&state).unwrap()
};
let new_world = || {
let mut world = World::new(registry.default_state("minecraft:air").unwrap(), 3);
world.chunks.insert(
(1, 1),
std::sync::Arc::new(crate::chunk::Chunk::empty(1, 1)),
);
world
};
for (name, properties, ladder_below, climbable) in [
("minecraft:air", vec![], false, false),
("minecraft:ladder", vec![("facing", "west")], false, true),
("minecraft:vine", vec![("east", "true")], false, true),
("minecraft:twisting_vines_plant", vec![], false, true),
("minecraft:weeping_vines_plant", vec![], false, true),
(
"minecraft:oak_trapdoor",
vec![("open", "true"), ("facing", "west")],
true,
true,
),
(
"minecraft:oak_trapdoor",
vec![("open", "true"), ("facing", "east")],
true,
false,
),
] {
let mut world = new_world();
world.set(20, 10, 20, state(name, &properties)).unwrap();
if ladder_below {
world
.set(20, 9, 20, state("minecraft:ladder", &[("facing", "west")]))
.unwrap();
}
let mut stand = ArmorStand::new(1, Vec3::new(20.5, 10.0, 20.5), 0.0);
stand.velocity.y = -0.5;
let mut scene = SceneCollisions {
world: &world,
binding: &collision,
subject: None,
};
stand.tick(&mut scene, &facts, &motion.climbing, false, None);
let fall = if climbable { f64::from(0.15f32) } else { 0.5 };
assert!(
(stand.position.y - (10.0 - fall)).abs() < 1e-9,
"{name} {properties:?}"
);
}
// A wall collision inside a ladder lifts before gravity and drag.
let mut world = new_world();
world
.set(20, 10, 20, state("minecraft:ladder", &[("facing", "west")]))
.unwrap();
for y in 10..13 {
world.set(21, y, 20, state("minecraft:stone", &[])).unwrap();
}
let mut stand = ArmorStand::new(1, Vec3::new(20.74, 10.0, 20.5), 0.0);
stand.velocity = Vec3::new(1.0, 0.0, 0.0);
let mut scene = SceneCollisions {
world: &world,
binding: &collision,
subject: None,
};
stand.tick(&mut scene, &facts, &motion.climbing, false, None);
assert!((stand.velocity.y - (0.2 - 0.08) * f64::from(0.98f32)).abs() < 1e-9);
}
/// `Entity.collectColliders` (#7080): the world border's wall stops an
@@ -2892,11 +3153,19 @@ mod armor_stand_motion_tests {
binding: &collision,
subject: None,
};
let empty = Default::default();
let climbing = horizon_world::climbing::ClimbingRegistry::new(
collision.registry(),
&empty,
&empty,
&empty,
)
.unwrap();
stand.tick(
&mut scene,
&facts,
&climbing,
false,
&mut |_, _| {},
bordered.then_some(&border),
);
let east = stand.bounds().max_position().x;
@@ -3091,4 +3360,33 @@ mod item_destroyed_tests {
assert_eq!(bundle.hurt(1), Some(Vec::new()));
assert_eq!(item("minecraft:stone", &[]).hurt(5), Some(Vec::new()));
}
/// `BlockItem.onDestroyed`: the same death path spills a container item's
/// `container` — a shulker box's stacks — in slot order, and the box keeps
/// no slot filled.
#[test]
fn a_destroyed_shulker_box_spills_its_container() {
let mut box_item = item("minecraft:shulker_box", &[]);
box_item.stack.data_mut().container = vec![
(20, ItemStack::new("minecraft:diamond", 4)),
(0, ItemStack::new("minecraft:oak_log", 8)),
(3, ItemStack::new("minecraft:cobblestone", 64)),
];
let spent = box_item.hurt(5).expect("the hit that destroys it");
assert_eq!(
spent
.iter()
.map(|s| (&*s.item, s.count))
.collect::<Vec<_>>(),
[
("minecraft:oak_log", 8),
("minecraft:cobblestone", 64),
("minecraft:diamond", 4),
]
);
assert!(
box_item.stack.data().is_none_or(|d| d.container.is_empty()),
"the destroyed box keeps no slot filled"
);
}
}
+31 -28
View File
@@ -66,15 +66,16 @@ pub fn update_entity_fluids(
(water_height, lava_height)
}
impl ItemEntity {
/// `Entity.getEyeY` of an item (eye height 0.2125), where
/// `updateFluidHeightAndDoFluidPushing` reads the fluid.
pub fn fluid_eye_y(&self) -> f64 {
self.position.y + f64::from(0.2125f32)
}
/// The fluid push as the game runs it, for the unit tests.
#[cfg(test)]
pub(super) fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (f64, f64) {
update_entity_fluids(
self.bounds(),
self.position,
self.position.y + f64::from(0.2125f32),
&mut self.velocity,
world,
fluids,
)
let (bounds, at, eye) = (self.bounds(), self.position, self.fluid_eye_y());
update_entity_fluids(bounds, at, eye, &mut self.velocity, world, fluids)
}
}
impl ExperienceOrb {
@@ -92,27 +93,25 @@ impl ExperienceOrb {
}
}
impl ArmorStand {
/// `Entity.updateFluidHeightAndDoFluidPushing`: refreshes water and lava
/// immersion over the stand's deflated box and pushes its velocity by any
/// water or lava current. Returns whether the stand is in water (`isInWater`).
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> bool {
if self.marker {
return false;
}
/// `Entity.getEyeY` of a stand: its eye height by size.
pub fn fluid_eye_y(&self) -> f64 {
let eye = if self.small {
f64::from(0.9875f32 * 0.9)
} else {
f64::from(1.7775f32)
};
let (water_height, _) = update_entity_fluids(
self.bounds(),
self.position,
self.position.y + eye,
&mut self.velocity,
world,
fluids,
);
water_height > 0.0
self.position.y + eye
}
/// The fluid push as the game runs it, for the unit tests: whether the
/// stand is in water (`isInWater`).
#[cfg(test)]
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> bool {
use crate::entity_rules::EntityRules;
if !self.is_pushed_by_fluid() {
return false;
}
let (bounds, at, eye) = (self.bounds(), self.position, self.fluid_eye_y());
update_entity_fluids(bounds, at, eye, &mut self.velocity, world, fluids).0 > 0.0
}
}
impl FluidMembership for EntityFluidWorld<'_> {
@@ -480,7 +479,11 @@ mod tests {
binding: &collision,
subject: None,
};
still.tick(&mut scene, &facts, in_water, &mut |_, _| {}, None);
let empty = Default::default();
let climbing =
horizon_world::climbing::ClimbingRegistry::new(assets.registry, &empty, &empty, &empty)
.unwrap();
still.tick(&mut scene, &facts, &climbing, in_water, None);
assert_eq!(still.position.x, at.x);
assert_eq!(still.velocity.x, 0.0);
@@ -494,7 +497,7 @@ mod tests {
binding: &collision,
subject: None,
};
marker.tick(&mut scene, &facts, in_water, &mut |_, _| {}, None);
marker.tick(&mut scene, &facts, &climbing, in_water, None);
assert_eq!(marker.position, at);
assert_eq!(marker.velocity, Vec3::ZERO);
@@ -506,7 +509,7 @@ mod tests {
binding: &collision,
subject: None,
};
carried.tick(&mut scene, &facts, in_water, &mut |_, _| {}, None);
carried.tick(&mut scene, &facts, &climbing, in_water, None);
assert!((carried.position.x - (at.x + 0.014)).abs() < 1e-12);
assert!((carried.velocity.x - 0.014 * f64::from(0.8f32)).abs() < 1e-12);
for _ in 0..20 {
@@ -516,7 +519,7 @@ mod tests {
binding: &collision,
subject: None,
};
carried.tick(&mut scene, &facts, in_water, &mut |_, _| {}, None);
carried.tick(&mut scene, &facts, &climbing, in_water, None);
}
assert!(carried.position.x > at.x + 0.5);
}
+213 -3
View File
@@ -523,7 +523,7 @@ pub fn mob_to_nbt(
}
// Animal.addAdditionalSaveData: the love ticks left, whatever the save had.
if is_animal(id) {
map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0)));
map.insert("InLove".into(), Tag::Int(m.extra.in_love));
// Animal.addAdditionalSaveData: the player who fed it.
if let Some(cause) = m.extra.love_cause {
map.insert("LoveCause".into(), Tag::IntArray(cause.to_vec()));
@@ -2099,7 +2099,10 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
),
(
"PickupDelay",
Tag::Short(e.pickup_delay.min(i16::MAX as u32) as i16),
Tag::Short(
e.pickup_delay
.clamp(i64::from(i16::MIN), i64::from(i16::MAX)) as i16,
),
),
] {
put(&mut tag, key, value);
@@ -2174,7 +2177,7 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
vertical_collision: false,
horizontal_collision: false,
age: number(tag.get("Age")).unwrap_or(0) as i32,
pickup_delay: number(tag.get("PickupDelay")).unwrap_or(0).clamp(0, 32767) as u32,
pickup_delay: number(tag.get("PickupDelay")).unwrap_or(0),
bob_offset,
ticks: 0,
needs_sync: false,
@@ -3508,6 +3511,72 @@ mod tests {
}
}
#[test]
fn loaded_villager_command_names_follow_profession_and_custom_name() {
use crate::mobs::Species;
let mut kind = crate::mobs::sensor_scan_tests::kind();
let def = Arc::make_mut(&mut kind);
def.id = "minecraft:villager".into();
def.species = Species::Villager;
let types = [("minecraft:villager".into(), kind)].into();
for (profession, key) in [
("none", "entity.minecraft.villager.none"),
("nitwit", "entity.minecraft.villager.nitwit"),
("cleric", "entity.minecraft.villager.cleric"),
("farmer", "entity.minecraft.villager.farmer"),
("librarian", "entity.minecraft.villager.librarian"),
] {
let data = Tag::compound([
("profession", string(&format!("minecraft:{profession}"))),
("type", string("minecraft:plains")),
("level", Tag::Int(1)),
]);
let mut tag = Tag::compound([
("id", string("minecraft:villager")),
("Pos", doubles(&[4.5, 220.0, 4.5])),
("NoAI", Tag::Byte(1)),
("VillagerData", data.clone()),
]);
let mob = mob_from_nbt(&tag, &types, 7, None).unwrap();
assert_eq!(villager_data(&mob), data);
assert_eq!(mob.command_name().translation_key(), Some(key));
put(&mut tag, "CustomName", string("Ada"));
let named = mob_from_nbt(&tag, &types, 8, None).unwrap();
assert_eq!(
named.command_name().component(),
crate::text::Text::literal("Ada").component()
);
}
}
#[test]
fn other_mob_command_names_keep_their_type_and_custom_name() {
use crate::mobs::Species;
for (id, species, key) in [
(
"minecraft:zombie_villager",
Species::ZombieVillager,
"entity.minecraft.zombie_villager",
),
(
"minecraft:wandering_trader",
Species::WanderingTrader,
"entity.minecraft.wandering_trader",
),
("audit:helper", Species::Pig, "entity.audit.helper"),
] {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let def = Arc::make_mut(&mut kind);
def.id = id.into();
def.species = species;
let mut mob = Mob::new(1, kind, Vec3::ZERO, 0.0);
mob.extra.variant2 = 4;
assert_eq!(mob.command_name().translation_key(), Some(key));
mob.custom_name = Some("Ada".into());
assert_eq!(mob.command_name().plain(), "Ada");
}
}
#[test]
fn mob_loading_accepts_default_namespace_and_preserves_custom_namespaces() {
let cow = crate::mobs::sensor_scan_tests::kind();
@@ -3706,6 +3775,103 @@ mod tests {
assert_eq!(uuid(&tag), Some([1, 2, 3, 4]));
}
#[test]
fn loaded_item_lifetime_counters_tick_with_signed_sentinels() {
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
});
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let registry = assets.registry;
let facts = crate::blocks::BlockFacts::load(&root, registry).unwrap();
let collision = crate::collisions::load(&root, registry).unwrap();
let fluids = crate::motion::FluidResources::new(&assets, &facts).unwrap();
let mut world =
crate::world::World::new(registry.default_state("minecraft:air").unwrap(), 0);
world
.chunks
.insert((0, 0), Arc::new(crate::chunk::Chunk::empty(0, 0)));
let tick = |item: &mut ItemEntity| {
item.tick(
&world,
registry,
&facts,
&collision,
false,
&fluids,
&[],
None,
)
};
let fresh = Tag::compound([
("id", string("minecraft:item")),
("Pos", doubles(&[4.5, 210.0, 4.5])),
("NoGravity", Tag::Byte(1)),
(
"Item",
Tag::compound([("id", string("minecraft:stone")), ("count", Tag::Int(1))]),
),
]);
for delay in [32767_i16, -1, 0, 1, 2] {
for age in [-32768_i16, -1, 0, 5998, 5999, 6000, 32767] {
let mut item = item_entity_from_nbt(&fresh, 1, 0.0).unwrap();
for _ in 0..3 {
assert!(tick(&mut item));
}
assert_eq!(item.age, 3);
let mut data = item_entity_to_nbt(&item, [0, 0, 0, 1], None);
put(&mut data, "Age", Tag::Short(age));
put(&mut data, "PickupDelay", Tag::Short(delay));
let mut item = item_entity_from_nbt(&data, 1, 0.0).unwrap();
let mut alive = true;
for step in 0..=3 {
if step > 0 && alive {
alive = tick(&mut item);
}
let expected_age = if age == i16::MIN {
i32::from(age)
} else {
i32::from(age) + step
};
let expected_alive = step == 0 || expected_age < 6000;
assert_eq!(
alive, expected_alive,
"age={age}, delay={delay}, tick={step}"
);
if !alive {
continue;
}
let data = item_entity_to_nbt(&item, [0, 0, 0, 1], None);
let expected_delay = if delay > 0 && delay != i16::MAX {
(i32::from(delay) - step).max(0)
} else {
i32::from(delay)
};
assert_eq!(
number(data.get("Age")),
Some(i64::from(expected_age)),
"age={age}, tick={step}"
);
assert_eq!(
number(data.get("PickupDelay")),
Some(i64::from(expected_delay)),
"age={age}, delay={delay}, tick={step}"
);
}
}
}
let mut legacy = item_entity_from_nbt(&fresh, 1, 0.0).unwrap();
legacy.pickup_delay = i64::from(u32::MAX);
assert!(tick(&mut legacy));
assert_eq!(legacy.pickup_delay, i64::from(u32::MAX));
}
#[test]
fn item_entities_round_trip() {
let e = ItemEntity {
@@ -3751,6 +3917,50 @@ mod tests {
let cleared = item_entity_to_nbt(&cleared, [1, 2, 3, 4], Some(&tag));
assert!(cleared.get("Thrower").is_none());
}
#[test]
fn animal_nbt_preserves_signed_integer_in_love_and_defaults_after_removal() {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let def = Arc::make_mut(&mut kind);
def.id = "minecraft:pig".into();
def.species = crate::mobs::Species::Pig;
let types = [("minecraft:pig".into(), kind)].into();
for (input, expected) in [
(Tag::Int(-32769), -32769),
(Tag::Int(-32768), -32768),
(Tag::Int(-129), -129),
(Tag::Int(-128), -128),
(Tag::Int(-1), -1),
(Tag::Byte(-1), -1),
(Tag::Byte(-128), -128),
(Tag::Int(0), 0),
(Tag::Int(1), 1),
(Tag::Int(37), 37),
] {
let raw = Tag::compound([
("id", string("minecraft:pig")),
("Pos", doubles(&[4.0, 200.0, 4.0])),
("NoAI", Tag::Byte(1)),
("NoGravity", Tag::Byte(1)),
("InLove", input),
]);
let mob = mob_from_nbt(&raw, &types, 1, None).unwrap();
assert_eq!(mob.extra.in_love, expected);
// Query snapshots and saves both write the live field as an int,
// regardless of whether there is an old compound to preserve.
for old in [None, Some(&raw)] {
let mut saved = mob_to_nbt(&mob, [0, 0, 0, 1], old, None);
assert_eq!(saved.get("InLove"), Some(&Tag::Int(expected)));
if let Tag::Compound(map) = &mut saved {
map.remove(&"InLove".into());
}
let cleared = mob_from_nbt(&saved, &types, 2, None).unwrap();
assert_eq!(cleared.extra.in_love, 0);
let saved = mob_to_nbt(&cleared, [0, 0, 0, 1], Some(&saved), None);
assert_eq!(saved.get("InLove"), Some(&Tag::Int(0)));
}
}
}
#[test]
fn unknown_fields_survive() {
let raw = Tag::compound([
+162 -50
View File
@@ -201,11 +201,13 @@ mod names;
mod narrator;
mod native_biome_colors;
pub mod native_block_entity_draw;
mod native_block_sprites;
mod native_claimed_items;
mod native_components;
mod native_criteria;
mod native_data_maps;
mod native_entities;
mod native_entity_data;
mod native_entity_motion;
mod native_entity_refs;
mod native_explosions;
@@ -215,7 +217,9 @@ mod native_inventories;
mod native_item_container;
mod native_item_energy;
mod native_item_fluids;
mod native_level_refs;
mod native_lifecycle;
mod native_living_refs;
mod native_menus;
mod native_mob_draw;
mod native_mobs;
@@ -1220,6 +1224,25 @@ impl LevelAccess for GameLevel<'_> {
}
!cancel
}
fn note_block_change(
&self,
pos: BlockPos,
instrument: &str,
old: i32,
mut new: i32,
) -> Option<i32> {
let mut cancel = false;
if self.native_authoritative {
let context = horizon_client::mod_api::NativeNoteBlockChange {
dimension: self.dimension,
pos,
instrument,
old_note: old,
};
self.native_note_block_change(&context, &mut new, &mut cancel);
}
(!cancel).then_some(new)
}
fn allow_note_play(&self, pos: BlockPos, instrument: &str, note: i32) -> bool {
let mut cancel = false;
if self.native_authoritative {
@@ -2400,6 +2423,9 @@ struct Game<'a> {
/// The cursor icon the window shows (`widgets::take_cursor` each frame).
cursor_icon: horizon_client::widgets::Cursor,
captured: bool,
/// Native grab mode used by winit. Linux window managers can drop the
/// grab without a focus event, leaving `captured` true but stale.
capture_mode: Option<CursorGrabMode>,
/// The window held the pointer when it lost focus: the system lets a
/// grabbed pointer go with the focus, and a window that comes back still
/// wanting it takes it again (`MouseHandler.grabMouse`, whose grab
@@ -2509,6 +2535,8 @@ struct Game<'a> {
message: Option<(String, Instant)>,
save: Arc<SaveDir>,
level: Level,
/// Must precede session_lock: dropping the writer drains writes while the world is locked.
player_autosaves: horizon_client::lan::playerdata::Autosaves,
session_lock: Option<horizon_client::save::SessionLock>,
audio: Audio,
music: horizon_client::music::MusicManager,
@@ -3041,6 +3069,9 @@ struct Game<'a> {
/// have each chest open.
lid_openers: HashMap<(i32, i32, i32), i32>,
lan_acting: Option<u32>,
/// A guest's `/save-all`: saved once their turn ends (`run_guest_command`),
/// when their own state is back in their record.
save_after_guest_turn: bool,
player_attachments: horizon_world::attachments::Attachments,
/// The joined guests' attachments, by UUID: here rather than in their
/// records, so a hook in one guest's turn (when the hub is out of
@@ -3904,7 +3935,7 @@ impl Game<'_> {
vertical_collision: false,
horizontal_collision: false,
age: 0,
pickup_delay,
pickup_delay: i64::from(pickup_delay),
bob_offset: bob,
ticks: 0,
needs_sync: false,
@@ -3938,8 +3969,9 @@ impl Game<'_> {
block_entity: Option<horizon_world::nbt::Tag>,
) -> horizon_world::loot::NativeLootPlace {
let (rain, thunder) = self.world.weather.levels(1.0);
// Level.canHaveWeather: the Nether has a ceiling, the End is the End.
let weather = self.dim == Dim::Overworld;
// Level.canHaveWeather: sky light, no ceiling and not the End.
let weather =
self.world.shape.sky_light && !self.world.shape.has_ceiling && self.dim != Dim::End;
horizon_world::loot::NativeLootPlace {
origin: Some(origin),
block_entity,
@@ -4135,13 +4167,14 @@ impl Game<'_> {
item.velocity.z.abs() + 1.0,
);
obstacles.near(item.bounds().inflate(reach), &mut nearby);
let alive = item.tick(
let alive = entity::tick_item(
item,
&self.world,
self.pack.assets.registry,
self.res.facts(),
self.collision,
no_physics,
&self.res.motion().fluids,
no_physics,
&nearby,
Some(self.border.geometry()),
);
@@ -7416,6 +7449,7 @@ impl Game<'_> {
self.world.weather.tick_levels();
self.tick_weather_effects();
}
let _ = self.finish_player_autosaves(false);
// MinecraftServer.tickServer's countdown, then autoSave: three
// hundred seconds of ticks at the rate the server runs at now.
self.ticks_until_autosave -= 1;
@@ -7915,6 +7949,7 @@ impl Game<'_> {
// Splitscreen: another player has the pointer; this one's menus leave it alone.
if !self.keyboard_mouse {
self.captured = false;
self.capture_mode = None;
return;
}
if capture && !self.captured && !cfg!(target_os = "macos") {
@@ -7945,6 +7980,7 @@ impl Game<'_> {
self.mouse.set_ignore_first_move();
}
self.captured = capture;
self.capture_mode = None;
} else if capture {
self.mouse.set_ignore_first_move();
// macOS warps by re-associating the mouse with the pointer,
@@ -7960,10 +7996,14 @@ impl Game<'_> {
if self.focused {
warp(window);
}
self.captured = window
.set_cursor_grab(CursorGrabMode::Locked)
.or_else(|_| window.set_cursor_grab(CursorGrabMode::Confined))
.is_ok();
self.capture_mode = if window.set_cursor_grab(CursorGrabMode::Locked).is_ok() {
Some(CursorGrabMode::Locked)
} else if window.set_cursor_grab(CursorGrabMode::Confined).is_ok() {
Some(CursorGrabMode::Confined)
} else {
None
};
self.captured = self.capture_mode.is_some();
// Windows already warped above, inside the clip's own position.
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
if self.captured && self.focused {
@@ -7987,6 +8027,7 @@ impl Game<'_> {
let _ = window.set_cursor_grab(CursorGrabMode::None);
}
self.captured = false;
self.capture_mode = None;
}
// MouseHandler.grabMouse and releaseMouse: the mouse position is
// the window's centre whenever the pointer's grab state changes, so
@@ -8004,6 +8045,33 @@ impl Game<'_> {
window.set_cursor_visible(!self.captured);
}
}
/// A Linux window manager can release the native pointer grab while the
/// window remains focused. Reapply the recorded mode on the next gameplay
/// click or wheel event, without warping the pointer or consuming input.
fn reassert_pointer_capture(&mut self) {
#[cfg(target_os = "linux")]
if self.screen == Screen::Playing
&& self.focused
&& self.captured
&& !self.options.offscreen
&& let (Some(window), Some(mode)) = (&self.window, self.capture_mode)
{
let other = match mode {
CursorGrabMode::Locked => CursorGrabMode::Confined,
CursorGrabMode::Confined => CursorGrabMode::Locked,
CursorGrabMode::None => return,
};
self.capture_mode = if window.set_cursor_grab(mode).is_ok() {
Some(mode)
} else if window.set_cursor_grab(other).is_ok() {
Some(other)
} else {
None
};
self.captured = self.capture_mode.is_some();
window.set_cursor_visible(!self.captured);
}
}
/// `GuiGraphicsExtractor.applyCursor` -> `Window.selectCursor` at the end
/// of a frame: the cursor the frame's widgets asked for, set on the window
/// only when it changes.
@@ -11534,6 +11602,7 @@ impl Game<'_> {
/// `Player.attack` on a mob: the damage, knockback, sweep, particles and
/// sounds.
pub(super) fn attack_mob(&mut self, index: usize) {
let _attacker = self.knockback_scope(self.player.as_ref().map_or(0, |p| p.entity_id));
let target_kind = self.mobs[index].kind.id.clone();
let target_kind = format!("minecraft:{}", target_kind.trim_start_matches("minecraft:"));
let (amount, scale, crit, magic) = self.melee_damage(&target_kind, true);
@@ -13574,9 +13643,13 @@ impl Game<'_> {
/// `Player.giveExperiencePoints`, and `giveExperienceLevels`' level-up
/// sound every fifth level (at most every 100 ticks).
pub(super) fn give_orb_points(&mut self, gained: u32) {
let Some(gained) = self.xp_change(gained as i32) else {
return;
};
let gained = gained.max(0);
let at = self.eye_position();
let old_level = self.experience.level;
let gave = self.experience.give(gained as i32);
let gave = self.experience.give(gained);
self.native_player_level_changed(old_level);
// Player.giveExperienceLevels runs per level, so the first multiple of 5
// crossed plays the sound (and starts the cooldown for the rest).
@@ -14265,37 +14338,30 @@ impl Game<'_> {
&& (position.y - f64::from(bed[1])).abs() <= 2.0
&& (position.z - f64::from(bed[2]) - 0.5).abs() <= 3.0
};
if !reachable(head) && !reachable(foot) {
self.tell_sleeper(
"block.minecraft.bed.too_far_away",
"You may not rest now; the bed is too far away",
true,
);
return;
}
let mut problem: Option<&'static str> = if !reachable(head) && !reachable(foot) {
Some("too_far_away")
} else {
None
};
// ServerPlayer.bedBlocked: Player.freeAt (not suffocating) for the
// blocks above the head and the foot, as `in_wall` reads suffocating.
let blocked = [head, foot].iter().any(|bed| {
let f = self
.res
.facts()
.get(self.world.block(bed[0], bed[1] + 1, bed[2]));
f.full_collision && f.solid_render
});
let blocked = problem.is_none()
&& [head, foot].iter().any(|bed| {
let f = self
.res
.facts()
.get(self.world.block(bed[0], bed[1] + 1, bed[2]));
f.full_collision && f.solid_render
});
if blocked {
self.tell_sleeper(
"block.minecraft.bed.obstructed",
"This bed is obstructed",
true,
);
return;
problem = Some("obstructed");
}
// In reach (bedInRange), day or night: setRespawnPosition(
// RespawnConfig(the head, the player's yaw), not forced). It replaces
// an anchor or a spawnpoint; respawning checks the bed is still there
// and finds where to stand up (findRespawnAndUseSpawnBlock).
// Straw beds never set it (`straw_bed_rule`).
if self.bed_sets_spawn(state) {
if problem.is_none() && self.bed_sets_spawn(state) {
let config = respawn_anchor::RespawnConfig::wrapped(self.dim, head, yaw, false);
// ServerPlayer.setRespawnPosition: the chat message only when the
// point moved (`isSamePosition`).
@@ -14335,16 +14401,12 @@ impl Game<'_> {
};
}
}
if !(self.dim == Dim::Overworld && self.sky_darken() >= 4) {
self.tell_sleeper(
"block.minecraft.bed.no_sleep",
"You can sleep only at night or during thunderstorms",
true,
);
return;
if problem.is_none() && !(self.dim == Dim::Overworld && self.sky_darken() >= 4) {
problem = Some("not_possible_now");
}
// Creative players skip the monster check (`!this.isCreative()`).
let monsters = !self.creative
if problem.is_none()
&& !self.creative
&& self.mobs.iter().any(|m| {
// `Monster.class` entities that `isPreventingPlayerRest`: a
// zombified piglin only while it is angry.
@@ -14355,13 +14417,41 @@ impl Game<'_> {
&& (m.position.x - position.x).abs() < 8.0
&& (m.position.y - position.y).abs() < 5.0
&& (m.position.z - position.z).abs() < 8.0
});
if monsters {
self.tell_sleeper(
"block.minecraft.bed.not_safe",
"You may not rest now; there are monsters nearby",
true,
);
})
{
problem = Some("not_safe");
}
// NeoForge's CanPlayerSleepEvent: a mod may clear or replace the
// problem vanilla found, or add one.
let mut problem = problem.map(str::to_owned);
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
self.native_can_player_sleep(player, &name, head, &mut problem);
}
if let Some(problem) = problem {
match problem.as_str() {
"too_far_away" => self.tell_sleeper(
"block.minecraft.bed.too_far_away",
"You may not rest now; the bed is too far away",
true,
),
"obstructed" => self.tell_sleeper(
"block.minecraft.bed.obstructed",
"This bed is obstructed",
true,
),
"not_safe" => self.tell_sleeper(
"block.minecraft.bed.not_safe",
"You may not rest now; there are monsters nearby",
true,
),
"not_possible_now" => self.tell_sleeper(
"block.minecraft.bed.no_sleep",
"You can sleep only at night or during thunderstorms",
true,
),
// `other_problem` and any name a mod invents: refused without a message.
_ => {}
}
return;
}
// ServerPlayer.startSleepInBed: a percentage above 100 cannot skip the night.
@@ -15096,12 +15186,23 @@ impl Game<'_> {
self.save_level(wait)?;
self.save_parked_levels(wait)?;
self.save_game_metadata()?;
let _ = self.finish_player_autosaves(wait);
self.native_world_saved(wait);
if dedicated {
dedicated::events::record_server("saved", serde_json::json!({}));
}
Ok(())
}
fn finish_player_autosaves(&mut self, wait: bool) -> Result<bool> {
let saved = self.player_autosaves.completed(wait);
if !saved.is_empty() {
let directory = horizon_client::lan::playerdata::directory(&self.world_root);
for player in saved {
self.native_player_save_to_file(player.uuid, &player.name, &directory);
}
}
self.player_autosaves.writes_finished()
}
fn save_game_metadata(&mut self) -> Result<()> {
self.save_maps();
self.save_random_sequences();
@@ -15115,6 +15216,8 @@ impl Game<'_> {
self.save_schedule();
self.save_warden_tracker();
self.sync_level();
// Level::write can synchronously replace the integrated owner file.
let _ = self.finish_player_autosaves(true);
let stored_owner = self.store_owner();
write_level_for_role(
&mut self.level,
@@ -15137,11 +15240,9 @@ impl Game<'_> {
// The vehicle tree goes into a player's file when they leave
// (`take_parent_vehicle`); here it stays in the chunk saves.
let mut host = host;
let directory = horizon_client::lan::playerdata::directory(&self.world_root);
for g in host.hub.guests.iter_mut().filter(|g| g.placed) {
self.guest_attachments_into(g);
horizon_client::lan::playerdata::save(g, &self.world_root);
self.native_player_save_to_file(g.uuid, &g.name, &directory);
self.player_autosaves.enqueue(g, &self.world_root);
}
self.lan = Some(host);
}
@@ -16980,6 +17081,14 @@ impl Game<'_> {
) {
self.last_input = Instant::now();
}
if !self.pad_synthetic_input()
&& matches!(
event,
WindowEvent::MouseInput { .. } | WindowEvent::MouseWheel { .. }
)
{
self.reassert_pointer_capture();
}
// The real mouse takes the pointer back from the keyboard's focus.
self.key_focus_mouse(event_loop, &event);
// The screen's narration hears of the input before the screen takes
@@ -18523,6 +18632,7 @@ impl<'a> Game<'a> {
cursor: (0.0, 0.0),
cursor_icon: horizon_client::widgets::Cursor::Arrow,
captured: false,
capture_mode: None,
regrab_on_refocus: false,
keyboard_mouse: true,
split_window: false,
@@ -18579,6 +18689,7 @@ impl<'a> Game<'a> {
message: None,
save,
level,
player_autosaves: Default::default(),
session_lock: None,
audio,
music: {
@@ -18882,6 +18993,7 @@ impl<'a> Game<'a> {
lan_global_events: Vec::new(),
lid_openers: HashMap::new(),
lan_acting: None,
save_after_guest_turn: false,
player_attachments: Default::default(),
guest_attachments: HashMap::new(),
lan_break_pos: None,
@@ -746,7 +746,8 @@ impl Game<'_> {
// AdvancementRewards.grant
if a.rewards.experience > 0 {
let old_level = self.experience.level;
self.experience.give(a.rewards.experience);
let points = self.xp_change(a.rewards.experience).map_or(0, |p| p.max(0));
self.experience.give(points);
self.native_player_level_changed(old_level);
}
// ServerPlayer.awardRecipesByKey: the rewards that name a recipe.
+10 -14
View File
@@ -2,6 +2,7 @@
//! falling, `interactAt` equipment swaps by clicked height, the two-hit break
//! of `hurtServer` (creative breaks at once), and `ArmorStandRenderer` with
//! the default pose, the hit wobble and humanoid armor layers.
use super::entity::Entity;
use super::{Game, InteractTarget};
use horizon_client::{
Result,
@@ -813,7 +814,7 @@ impl Game<'_> {
let a = &mut self.armor_stands[i];
a.previous = a.position;
a.previous_yaw = a.yaw;
let in_water = a.update_fluids(&self.world, &self.res.motion().fluids);
let in_water = a.push_by_fluid(&self.world, &self.res.motion().fluids).0 > 0.0;
let (world, registry, facts, collision) = (
&self.world,
self.pack.assets.registry,
@@ -826,23 +827,18 @@ impl Game<'_> {
subject: None,
};
let mut tops: Vec<BlockPos> = Vec::new();
a.tick(
if let Some(step) = a.tick_move(
&mut scene,
facts,
&self.res.motion().climbing,
in_water,
&mut |bounds, velocity| {
horizon_client::motion::bubble_column_push_over(
world,
registry,
facts,
collision,
bounds,
velocity,
|pos| tops.push(pos),
);
},
Some(self.border.geometry()),
);
) {
a.push_by_bubble_column_over(world, registry, facts, collision, |pos| {
tops.push(pos)
});
a.tick_settle(in_water, step);
}
for pos in tops {
self.bubble_column_particles(pos);
}
+6
View File
@@ -158,6 +158,12 @@ pub(super) struct CombatTracker {
fall_before: f64,
}
impl CombatTracker {
/// The last blow taken: its damage type and the causing entity's id (0
/// for none), for `LivingDeathEvent`.
pub(super) fn last_blow(&self) -> Option<(&str, u32)> {
let e = self.entries.last()?;
Some((&e.damage_type, e.attacker.as_ref().map_or(0, |a| a.0)))
}
fn mark(&mut self) {
let marks = (self.pending.len(), self.pending_direct.len());
self.noted_since.get_or_insert(marks);
+63 -24
View File
@@ -7,7 +7,7 @@ pub(super) use data::place_summoned;
mod effects;
pub(super) mod execute;
pub(super) mod extra;
mod items;
pub(in crate::game) mod items;
mod misc;
mod place;
mod players;
@@ -73,6 +73,8 @@ pub(super) enum Target {
Trident(u32),
/// An ominous item spawner, by its entity id (`minecraft:ominous_item_spawner`).
Spawner(u32),
/// A simulated lingering, creeper or dragon area-effect cloud.
Cloud(u32),
}
/// An entity of a type Horizon does not simulate: its compound lives in the
/// entity memory (`persist.raw`), its position and rotation here.
@@ -155,7 +157,8 @@ fn non_player_entity_id(t: Target) -> Option<i32> {
| Target::Falling(id)
| Target::Rocket(id)
| Target::Trident(id)
| Target::Spawner(id)) = t
| Target::Spawner(id)
| Target::Cloud(id)) = t
else {
return None;
};
@@ -173,6 +176,21 @@ impl Game<'_> {
}
}
/// `PlayerXpEvent.XpChange` for a `Player.giveExperiencePoints` of `amount`
/// points: the amount to give, or `None` when a mod cancelled it.
pub(super) fn xp_change(&mut self, amount: i32) -> Option<i32> {
if horizon_client::mod_api::mods().is_empty() {
return Some(amount);
}
let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
else {
return Some(amount);
};
let (mut amount, mut cancel) = (amount, false);
self.native_player_xp_change(player, &name, &mut amount, &mut cancel);
(!cancel).then_some(amount)
}
/// Commands.performCommand for a line without its slash.
pub(super) fn run_command(&mut self, command: &str) -> bool {
if self.client_mod_command(command) {
@@ -552,6 +570,7 @@ impl Game<'_> {
.iter()
.find(|r| r.id == id)
.map(|r| [r.position.x, r.position.y, r.position.z]),
Target::Cloud(id) => self.breath_clouds.iter().find(|c| c.id == id).map(|c| c.at),
Target::Trident(id) => self
.tridents
.iter()
@@ -613,6 +632,7 @@ impl Game<'_> {
Target::Rocket(_) => "minecraft:firework_rocket".into(),
Target::Trident(_) => "minecraft:trident".into(),
Target::Spawner(_) => "minecraft:ominous_item_spawner".into(),
Target::Cloud(_) => "minecraft:area_effect_cloud".into(),
}
}
/// The name of the player the game acts for: a guest's own while their
@@ -638,14 +658,22 @@ impl Game<'_> {
Some(g) => self.player_text(&g.name, g.uuid),
None => Text::empty(),
},
// ItemEntity.getName: its CustomName, else the stack's item name
// (`getItemName`: `minecraft:item_name`, else the item's own).
Target::Item(id) => {
let item = self
.dropped
.iter()
.find(|i| i.id == id)
.map(|i| i.stack.item.to_string())
.unwrap_or_default();
Text::literal(self.pack.names.display_item(&item))
if let Some(c) = self.persist_raw(id).and_then(|r| r.get("CustomName")) {
return horizon_client::scoreboard::comp_text(
&horizon_client::scoreboard::tag_to_json(c),
);
}
let stack = self.dropped.iter().find(|i| i.id == id).map(|i| &i.stack);
match stack.and_then(|s| s.item_name()) {
Some(name) => Text::literal(name),
None => {
let item = stack.map(|s| s.item.to_string()).unwrap_or_default();
Text::literal(self.pack.names.display_item(&item))
}
}
}
// FallingBlockEntity.getTypeName: `entity.minecraft.falling_block_type`
// with the block's name.
@@ -659,19 +687,11 @@ impl Game<'_> {
let block = Text::translate(&format!("block.{block}"), &[]);
Text::translate("entity.minecraft.falling_block_type", &[block])
}
Target::Mob(id)
if self
.mobs
.iter()
.any(|m| m.id == id && m.custom_name.is_some()) =>
{
let name = self
.mobs
.iter()
.find(|m| m.id == id)
.and_then(|m| m.custom_name.clone());
Text::literal(name.unwrap_or_default())
}
Target::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id)
.map_or_else(|| Text::translate("entity.", &[]), |m| m.command_name()),
other => {
// Entity.getDisplayName: the CustomName component, else the type.
let custom = match other {
@@ -680,7 +700,8 @@ impl Game<'_> {
| Target::Painting(id)
| Target::Boat(id)
| Target::Cart(id)
| Target::Other(id) => self.persist_raw(id).and_then(|r| r.get("CustomName")),
| Target::Other(id)
| Target::Cloud(id) => self.persist_raw(id).and_then(|r| r.get("CustomName")),
_ => None,
};
if let Some(c) = custom {
@@ -880,6 +901,12 @@ impl Game<'_> {
candidates.push(Target::Dragon(d.id));
}
candidates.extend(self.ominous_spawners.iter().map(|s| Target::Spawner(s.id)));
candidates.extend(
self.breath_clouds
.iter()
.filter(|c| c.id != 0)
.map(|c| Target::Cloud(c.id)),
);
candidates
}
/// The loaded levels, in the order the server holds them: the
@@ -916,6 +943,7 @@ impl Game<'_> {
Target::Rocket(id),
Target::Trident(id),
Target::Spawner(id),
Target::Cloud(id),
]
.into_iter()
.find(|t| self.level_holds(*t))
@@ -942,6 +970,7 @@ impl Game<'_> {
Target::Rocket(id) => self.rockets.iter().any(|r| r.id == id),
Target::Trident(id) => self.tridents.iter().any(|t| t.id == id),
Target::Spawner(id) => self.ominous_spawners.iter().any(|s| s.id == id),
Target::Cloud(id) => self.breath_clouds.iter().any(|c| c.id == id),
}
}
/// Run `f` with the level that holds the entity a target names in place
@@ -1070,6 +1099,9 @@ impl Game<'_> {
"ban-ip" | "pardon-ip" => self.cmd_ip_bans(id, ctx),
id if id.starts_with("banlist") => self.cmd_banlist(id),
id if id.starts_with("whitelist.") => self.cmd_whitelist(id, ctx),
"stop" => self.cmd_stop(),
"save-all" => self.cmd_save_all(),
"save-off" | "save-on" => self.cmd_auto_save(id == "save-on"),
"transfer" => self.cmd_transfer(ctx),
"fetchprofile" => self.cmd_fetchprofile(ctx),
"publish" => self.cmd_publish(ctx),
@@ -1623,6 +1655,11 @@ impl Game<'_> {
s.position = [v.x, v.y, v.z];
}
}
Target::Cloud(id) => {
if let Some(c) = self.breath_clouds.iter_mut().find(|c| c.id == id) {
c.at = pos;
}
}
// Wall-mounted entities do not move.
Target::Frame(_) | Target::Painting(_) => {}
}
@@ -2122,6 +2159,7 @@ impl Game<'_> {
Target::Rocket(id) => self.rockets.retain(|r| r.id != id),
Target::Trident(id) => self.tridents.retain(|t| t.id != id),
Target::Spawner(id) => self.ominous_spawners.retain(|s| s.id != id),
Target::Cloud(id) => self.breath_clouds.retain(|c| c.id != id),
}
// The orbs marked for removal are gone (Entity.kill removes them).
self.orbs.retain(|o| o.age < u32::MAX / 2);
@@ -3017,7 +3055,7 @@ impl Game<'_> {
// boundary and emits the level-change hook itself.
self.give_levels(amount);
return true;
} else {
} else if let Some(amount) = self.xp_change(amount) {
self.give_points(amount);
}
} else if levels {
@@ -3921,6 +3959,7 @@ mod look_at_tests {
Target::Rocket(7),
Target::Trident(7),
Target::Spawner(7),
Target::Cloud(7),
] {
assert_eq!(non_player_entity_id(t), Some(7), "{t:?}");
}
+45 -11
View File
@@ -178,13 +178,15 @@ impl Game<'_> {
}
Target::Crystal(id) => {
let c = self.crystals.iter().find(|c| c.id == id)?;
let (uuid, raw) = self.persist_anon("minecraft:end_crystal", c.position);
let raw = self.persist_anon("minecraft:end_crystal", c.position);
let uuid = self.persist.uuids.get(&id).copied();
let tag = c.to_nbt(uuid.unwrap_or_else(random_uuid), raw);
crate::game::persist::with_raw(tag, self.persist.raw.get(&id))
}
Target::Dragon(id) => {
let d = self.dragon.as_ref().filter(|d| d.id == id)?;
let (uuid, raw) = self.persist_anon("minecraft:ender_dragon", d.position);
let raw = self.persist_anon("minecraft:ender_dragon", d.position);
let uuid = self.persist.uuids.get(&id).copied();
let tag = d.to_nbt(uuid.unwrap_or_else(random_uuid), raw);
crate::game::persist::with_raw(tag, self.persist.raw.get(&id))
}
@@ -224,6 +226,33 @@ impl Game<'_> {
let r = self.rockets.iter().find(|r| r.id == id)?;
r.to_nbt(uuid, player_uuid, self.persist.raw.get(&id))
}
Target::Cloud(id) => {
let uuid = self.uuid_for(id);
let c = self.breath_clouds.iter().find(|c| c.id == id)?;
let [x, y, z] = c.at;
// Read the live cloud, including its current radius and age;
// the raw compound alone does not contain generated clouds.
let mut tag = self.other_to_nbt(
&super::OtherEntity {
id,
kind: "minecraft:area_effect_cloud".into(),
position: horizon_core::geometry::Vec3::new(x, y, z),
yaw: 0.0,
pitch: 0.0,
},
uuid,
);
set_key(&mut tag, "Age", Tag::Int(c.age));
set_key(&mut tag, "Duration", Tag::Int(c.duration));
set_key(&mut tag, "Radius", Tag::Float(c.radius.to_bits()));
let wait = if c.potion.is_some() {
crate::game::potions::LINGERING_WAIT
} else {
20
};
set_key(&mut tag, "WaitTime", Tag::Int(wait));
tag
}
Target::Trident(id) => {
let uuid = self.uuid_for(id);
let t = self.tridents.iter().find(|t| t.id == id)?;
@@ -336,18 +365,23 @@ impl Game<'_> {
| Target::Projectile(id)
| Target::Crystal(id)
| Target::Dragon(id)
| Target::Other(id) => Some(self.persist.raw.entry(id).or_insert_with(empty)),
| Target::Other(id)
| Target::Cloud(id) => Some(self.persist.raw.entry(id).or_insert_with(empty)),
_ => None,
}
}
fn set_entity_tags(&mut self, t: Target, tags: &[String]) {
if let Some(raw) = self.raw_mut(t) {
let list = tags
.iter()
.map(|s| Tag::String(s.as_str().into()))
.collect();
set_key(raw, "Tags", Tag::List(list));
}
/// Writes `t`'s `Tags`; false when its kind keeps no saved compound to
/// write them to.
pub(in crate::game) fn set_entity_tags(&mut self, t: Target, tags: &[String]) -> bool {
let Some(raw) = self.raw_mut(t) else {
return false;
};
let list = tags
.iter()
.map(|s| Tag::String(s.as_str().into()))
.collect();
set_key(raw, "Tags", Tag::List(list));
true
}
// ---- accessors -----------------------------------------------------
@@ -282,6 +282,19 @@ impl Game<'_> {
return false;
};
let item = stack.as_ref().map(|s| s.item.clone());
// The worn armour stack goes with the id: a kept stack of the
// previous piece would otherwise still be what the mob wears.
let body = horizon_client::mobs::drops::head_is_body(m.species());
let worn = match *key {
"head" if !body => Some(0),
"chest" => Some(1),
"legs" => Some(2),
"feet" => Some(3),
_ => None,
};
if let Some(i) = worn {
m.extra.armor[i] = stack.clone();
}
match *key {
"mainhand" => m.extra.set_main_hand_stack(stack),
"offhand" => m.extra.off_hand = item,
@@ -3,7 +3,9 @@
//! PardonCommand, BanIpCommands, PardonIpCommand, BanListCommands,
//! TransferCommand, WhitelistCommand): commands of a dedicated server, run on
//! its lists by an operator in game (the console has its own handlers in
//! game/dedicated.rs).
//! game/dedicated.rs). Also `/stop`, `/save-all`, `/save-off` and `/save-on`
//! (StopCommand, SaveAllCommand, SaveOffCommand, SaveOnCommand), on the
//! switches the lists share.
use super::{Arg, Context, Game, R, Target, err};
use horizon_client::{
server::user_lists::{BanEntry, NameAndId, OpEntry, WhiteEntry},
@@ -23,6 +25,62 @@ impl Game<'_> {
}
}
/// StopCommand: the server loop (game/dedicated.rs) ends after this tick.
pub(super) fn cmd_stop(&mut self) -> R<i32> {
let lists = self.dedicated_lists()?;
self.feedback(Text::translate("commands.stop.stopping", &[]));
lists.lock().unwrap().stop = true;
Ok(1)
}
/// SaveAllCommand. Every save waits for the chunks to be written, so
/// `flush` changes nothing.
pub(super) fn cmd_save_all(&mut self) -> R<i32> {
self.dedicated_lists()?;
self.feedback(Text::translate("commands.save.saving", &[]));
if self.lan_acting.is_some() {
// The acting guest's record holds the game's state until their
// turn ends: saving now would write it to their file.
// shortcut: the other operators aren't told of a guest's save, add it if anyone asks.
self.save_after_guest_turn = true;
return Ok(1);
}
let reply = self.save_all_reply();
self.feedback(reply);
Ok(1)
}
/// Save everything; `commands.save.success`, or `commands.save.failed` in red.
pub(in crate::game) fn save_all_reply(&mut self) -> Text {
match self.save_game() {
Ok(()) => Text::translate("commands.save.success", &[]),
Err(e) => {
log::error!("Horizon: /save-all: {e}");
Text::translate("commands.save.failed", &[]).with_color(horizon_client::text::RED)
}
}
}
/// SaveOffCommand and SaveOnCommand.
pub(super) fn cmd_auto_save(&mut self, enabled: bool) -> R<i32> {
let lists = self.dedicated_lists()?;
if !super::super::dedicated::set_auto_save(&lists, enabled) {
let already = if enabled {
"commands.save.alreadyOn"
} else {
"commands.save.alreadyOff"
};
return Err(err(already, &[]));
}
let done = if enabled {
"commands.save.enabled"
} else {
"commands.save.disabled"
};
self.feedback(Text::translate(done, &[]));
Ok(1)
}
/// `NameAndId` of a player target.
fn target_profile(&self, t: Target) -> Option<NameAndId> {
match t {
@@ -513,15 +513,18 @@ impl Game<'_> {
let Some(Arg::Resource(table)) = ctx.get("loot_table").cloned() else {
return Ok(0);
};
// An inline table (ResourceOrIdArgument): no random sequence.
// An inline table (ResourceOrIdArgument) keeps its random sequence.
if table.starts_with('{') {
let (tag, _) = horizon_world::nbt::parse_snbt(&table)
.map_err(|e| err("argument.nbt.invalid", &[&e]))?;
let json = horizon_client::scoreboard::tag_to_json(&tag);
let drops = self
.res
.items()
.inline_loot_table(&json, &mut self.world.random);
let drops = match json["random_sequence"].as_str() {
Some(sequence) => {
let random = self.rand_sequences.get(sequence, self.level.seed);
world_items!(self).inline_loot_table(&json, random)
}
None => world_items!(self).inline_loot_table(&json, &mut self.world.random),
};
(drops, None)
} else {
if !self.items().has_loot_table(&table) {
+12
View File
@@ -406,6 +406,16 @@ impl Game<'_> {
}
Ok(Some(json!({ "ok": true, "sides": r.section_sides() })))
}
"gpu-culling" => {
let r = self.renderer.as_mut().ok_or("no renderer")?;
match words.get(1).copied() {
None => {}
Some("on") => r.set_gpu_culling(true)?,
Some("off") => r.set_gpu_culling(false)?,
Some(other) => return Err(format!("unknown GPU culling mode {other}").into()),
}
Ok(Some(json!({ "ok": true, "gpu_culling": r.gpu_culling() })))
}
"sculk" => Ok(Some(self.sculk_command(&words)?)),
"hello" => Ok(Some(hello())),
"status" => Ok(Some(self.control_status())),
@@ -1225,6 +1235,8 @@ impl Game<'_> {
"section_draws": r.section_draws,
"section_draw_calls": r.section_draw_calls,
"culled_sections": r.culled_sections(),
"gpu_culling": r.gpu_culling(),
"gpu_frame": r.gpu_frame(),
"uploaded_bytes": r.uploaded_bytes,
"resident_sections": r.resident_sections(),
"arena_bytes": arena_bytes,
+2 -1
View File
@@ -83,12 +83,13 @@ fn slot_image(item: &Item) -> Vec<u8> {
}
/// Which slots an avatar's gear covers: a helmet or head item hides a hat, an elytra or a cape
/// what is worn on the back.
/// what is worn on the back. A name tag style is never drawn on the body.
fn covered(m: &Mob, slot: Slot) -> bool {
match slot {
Slot::Head => m.extra.head.is_some(),
Slot::Back => m.cape.is_some() || m.extra.chest.as_deref() == Some("minecraft:elytra"),
Slot::Shoulder => false,
Slot::NameTag => true,
}
}
+118 -11
View File
@@ -145,6 +145,15 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
fn targets(&self) -> &[(Who, Aabb)] {
&self.targets
}
/// `BaseFireBlock.entityInside` for a thrown projectile: fire and soul
/// fire set it alight for 160 ticks.
fn ignites(&self, p: [i32; 3]) -> bool {
let state = self.w.world.block(p[0], p[1], p[2]);
matches!(
&*self.w.facts.get(state).name,
"minecraft:fire" | "minecraft:soul_fire"
)
}
fn empty(&self, p: [i32; 3]) -> bool {
self.w.facts.is_air(self.w.world.block(p[0], p[1], p[2]))
}
@@ -626,6 +635,22 @@ impl Game<'_> {
params: horizon_client::mobs::conversion::Params,
) -> Option<u32> {
let t = self.res.mob_types().get(kind).cloned()?;
// LivingConversionEvent.Pre: the caller already marked the mob removed,
// so a canceled conversion puts it back as it was.
let mut cancel = false;
let from_kind = super::native_hooks::mob_kind(from);
self.native_living_conversion_pre(from.id, &from_kind, kind, &mut cancel);
if cancel {
match self.mobs.iter_mut().find(|m| m.id == from.id) {
Some(kept) => kept.removed = false,
None => {
let mut kept = from.clone();
kept.removed = false;
self.mobs.push(kept);
}
}
return None;
}
self.next_entity += 1;
let mut mob = Mob::new(self.next_entity, t, from.position, from.yaw);
horizon_client::mobs::conversion::single(from, &mut mob, params);
@@ -655,6 +680,7 @@ impl Game<'_> {
.id
.clone();
self.native_entity_transform(from.id, &from.kind.id, id, &transformed_kind);
self.native_living_conversion_post(from.id, &from_kind, id, &transformed_kind);
Some(id)
}
/// The `summon` command: spawn a mob in front of the player or at a position.
@@ -942,6 +968,7 @@ impl Game<'_> {
// With guests on, each mob sees its nearest player (game/lan_mobs.rs).
let guests = self.lan_guest_views();
let players = self.level_players();
let nearest = super::natural_spawning::NearestPlayers::new(&players);
let mut bindings = match self.lan.as_mut() {
Some(host) if !guests.is_empty() => std::mem::take(&mut host.mob_players),
_ => Default::default(),
@@ -1001,13 +1028,9 @@ impl Game<'_> {
// getCurrentDifficultyAt(blockPosition()): the mob's own chunk,
// not the local player's (a server thread has no player).
w.difficulty = self.local_difficulty([m.position.x, m.position.y, m.position.z]);
w.nearest_player = super::natural_spawning::nearest_player(
&players,
m.position.x,
m.position.y,
m.position.z,
)
.map(|p| p.position);
w.nearest_player = nearest
.nearest(m.position.x, m.position.y, m.position.z)
.map(|p| p.position);
let seen_guest = if guests.is_empty() {
None
} else {
@@ -1193,8 +1216,43 @@ impl Game<'_> {
self.particles.add(&options, [x, y, z], [xa, ya, za]);
}
}
/// `LivingDeathEvent` for a mob whose health ran out: whether a native
/// mod canceled the death. A canceled mob stays alive at 1 health.
fn mob_death_canceled(&mut self, m: &Mob) -> bool {
if horizon_client::mod_api::mods().is_empty() {
return false;
}
let (damage_type, attacker) = match m.last_damage {
Some((d, _)) => (source_damage_type(d), d.attacker),
None => ("minecraft:generic", None),
};
let attacker = match attacker {
Some(Who::Mob(id)) => id,
Some(Who::Player) => {
let conn = self.last_hurt_by_player.get(&m.id).copied().flatten();
let guests = self.lan.as_ref().map(|host| host.hub.guests.as_slice());
let guest = guests.and_then(|all| all.iter().find(|g| Some(g.conn) == conn));
guest.map_or(0, |g| g.entity_id as u32)
}
None => 0,
};
let kind = super::native_hooks::mob_kind(m);
let mut cancel = false;
self.native_living_death(m.id, &kind, damage_type, attacker, &mut cancel);
if cancel {
self.died.remove(&m.id);
if let Some(alive) = self.mobs.iter_mut().find(|o| o.id == m.id) {
alive.health = 1.0;
alive.death_time = 0;
}
}
cancel
}
/// LivingEntity.die / dropAllDeathLoot.
fn mob_died(&mut self, m: &Mob) {
if self.mob_death_canceled(m) {
return;
}
// LivingEntity.die: a sleeper gets up, and its bed is free again.
if let Some(bed) = horizon_client::mobs::villager::sleeping_pos(m) {
self.wake_villager(m.id);
@@ -1569,6 +1627,8 @@ impl Game<'_> {
let mut hurt = false;
let mut infested = false;
let aim = self.attacker_aim(source.attacker);
// So `living_knockback_v2` sees who hit and what they hold.
let _attacker = self.knockback_scope_for(source.attacker);
if let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) {
let hit = t.damage_aimed(amount, source, aim);
hurt = hit;
@@ -1982,6 +2042,10 @@ impl Game<'_> {
} => match target {
// A player's source names no melee attacker, as its damage's doesn't.
Who::Player => {
let _attacker = self.knockback_scope(match source.attacker {
Some(Who::Mob(id)) => id as i32,
_ => 0,
});
let source = HurtSource {
damage_type: source_damage_type(source),
causing: 0,
@@ -3411,6 +3475,11 @@ impl Game<'_> {
};
// The causing entity for the death message.
let owner = self.combat.projectile_owner.take();
let _attacker = self.knockback_scope(
attacker
.or(owner)
.map_or_else(horizon_client::health::knockback_attacker, |id| id as i32),
);
if let Some(a) = attacker.or(owner) {
self.note_attacker(damage_type, a);
}
@@ -3529,7 +3598,7 @@ impl Game<'_> {
/// `LivingEntity.knockback` on the player: pushed `power` away from the
/// direction (`xd`, `zd`), less its knockback resistance, and up only from
/// the ground.
/// The native knockback hook runs once the resistance is applied, with
/// The native knockback hook runs before resistance is applied, with
/// `source`, the hit's.
pub(super) fn knock_back_player(
&mut self,
@@ -3538,6 +3607,14 @@ impl Game<'_> {
zd: f64,
source: HurtSource<'_>,
) {
let credit = match self.player_hurt_by() {
Some((Who::Mob(id), _)) => id as i32,
_ => 0,
};
let _attacker = self.knockback_scope(match horizon_client::health::knockback_attacker() {
0 => credit,
attacker => attacker,
});
if let Some(p) = self.player.as_mut() {
knock_back(p, &mut self.world.random, power, (xd, zd), source);
}
@@ -4285,6 +4362,7 @@ impl Game<'_> {
}
}
pub(super) fn projectile_hit(&mut self, p: &Projectile, hit: Hit) -> Result<()> {
let _attacker = self.knockback_scope_for(p.owner);
// Projectile.onHitBlock: BlockState.onProjectileHit, for every
// projectile (a wind charge out of the world's top has no face).
if let Hit::Block { pos, at, face } = hit
@@ -6504,13 +6582,34 @@ impl Game<'_> {
}));
}
let len = out.len();
self.cull_entity_vertices(out, entity_start);
let hidden = self.cull_entity_vertices(out, entity_start);
if out.len() < len {
if let Some((mark, _)) = skinned {
self.skin_frame.borrow_mut().truncate(mark);
}
} else if corners.is_some() {
if let Some((mark, _)) = skinned {
// Previous GPU-only entities can leave zero-length CPU spans at
// this same offset. Use this query's result, not their metadata.
if hidden {
let mut skin = self.skin_frame.borrow_mut();
let end = skin.vertices as usize;
horizon_client::gfx::pack_draw::EntitySpan::hide(
&mut skin.spans,
mark.2 as usize,
end,
);
}
}
out.truncate(len - 2);
// The temporary bounds are not real geometry. Keep the culling end marker
// at the true CPU end before the next entity appends a span there.
for span in self.entity_spans.borrow_mut().iter_mut().rev() {
if span.start as usize <= out.len() {
break;
}
span.start = out.len() as u32;
}
}
}
@@ -7025,6 +7124,14 @@ impl Game<'_> {
self.submit_item_world(&state, pose, packed, out);
continue;
}
// `Entity.displayFireAnimation`: a thrown item that flew through a
// fire block burns (`ThrowableProjectile.tick`'s
// `applyEffectsFromBlocks`), and ThrownItemRenderer draws the flame
// over its box as any other entity renderer does.
if a.on_fire() {
let size = a.kind.size() as f32;
flame_geometry(atlas, c, size, size, self.yaw, light, out);
}
let Some(name) = a.item_sprite() else {
continue;
};
@@ -7663,8 +7770,8 @@ mod knockback_tests {
false
}
/// `LivingEntity.knockback` on a player: the hook runs after the
/// knockback resistance and before the motion changes; a mod's strength or
/// `LivingEntity.knockback` on a player: the hook runs before the
/// knockback resistance and the motion changes; a mod's strength or
/// direction pushes, a cancel pushes nothing, and without a mod in the way
/// the push is vanilla's. Needs the prepared data in
/// `.local/horizon-client/26.3`; skipped without it.
+32 -16
View File
@@ -422,7 +422,8 @@ fn ban_fields<T>(ban: &BanEntry<T>) -> serde_json::Value {
}
/// The operator, whitelist and ban lists (DedicatedPlayerList), shared with
/// the connection threads that check logins.
/// the connection threads that check logins, and the server's own switches
/// that an operator's command changes.
pub(super) struct Lists {
pub ops: OpList,
pub white: WhiteList,
@@ -432,6 +433,10 @@ pub(super) struct Lists {
pub enforce_whitelist: bool,
/// `op-permission-level`: what `/op` grants (operatorUserPermissions).
pub op_level: i32,
/// `/stop` (MinecraftServer.halt): the server loop ends after this tick.
pub stop: bool,
/// `MinecraftServer.isAutoSave`: all of Horizon's server levels share this save flag.
pub autosave: bool,
}
impl Lists {
fn load() -> Self {
@@ -443,6 +448,8 @@ impl Lists {
use_whitelist: false,
enforce_whitelist: false,
op_level: 4,
stop: false,
autosave: true,
};
// Each list tells the event stream what changes in it, as vanilla's tell their `NotificationService`.
l.ops.watch(|change| {
@@ -655,9 +662,9 @@ fn append(to: &mut Tag, sibling: Tag) {
struct ServerState {
lists: Arc<Mutex<Lists>>,
console: mpsc::Receiver<String>,
stop: bool,
autosave: bool,
empty_ticks: i32,
/// The mutable `DedicatedServer.pauseWhenEmptySeconds` setting.
pause_when_empty_seconds: i32,
/// The mutable `DedicatedServer.playerIdleTimeout` setting, in minutes.
player_idle_timeout: i32,
/// When the last passes of the loop ended, for the tick rate horizon-server shows.
@@ -696,13 +703,14 @@ fn save_all_reply(saved: bool) -> Text {
/// `MinecraftServer.isAutoSave`: all of Horizon's server levels share this save flag.
fn auto_save(state: &ServerState) -> bool {
state.autosave
state.lists.lock().unwrap().autosave
}
/// `MinecraftServer.setAutoSave`: enable or disable periodic saves and report whether it changed.
fn set_auto_save(state: &mut ServerState, enabled: bool) -> bool {
let changed = state.autosave != enabled;
state.autosave = enabled;
pub(super) fn set_auto_save(lists: &Mutex<Lists>, enabled: bool) -> bool {
let mut l = lists.lock().unwrap();
let changed = l.autosave != enabled;
l.autosave = enabled;
changed
}
@@ -1065,9 +1073,8 @@ impl Game<'_> {
let mut state = ServerState {
lists,
console,
stop: false,
autosave: true,
empty_ticks: 0,
pause_when_empty_seconds: props.pause_when_empty_seconds,
player_idle_timeout: props.player_idle_timeout,
ticks: Default::default(),
paused: false,
@@ -1083,7 +1090,6 @@ impl Game<'_> {
),
max_players: props.max_players.max(0) as usize,
};
let pause_after = props.pause_when_empty_seconds * 20;
let mut next = Instant::now();
// The panic that ended the tick loop, when one did.
let mut crashed: Option<String> = None;
@@ -1091,7 +1097,7 @@ impl Game<'_> {
let watchdog = Watchdog::start(props.max_tick_time);
let mut braked = Instant::now();
let mut overload_warned: Option<Instant> = None;
while !state.stop {
while !state.lists.lock().unwrap().stop {
let tick_start = Instant::now();
if let Some(watchdog) = &watchdog {
watchdog.tick_due(next, self.clock.tick_duration());
@@ -1109,6 +1115,9 @@ impl Game<'_> {
break;
}
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
// Vanilla reads pauseWhenEmptySeconds on every tick, so management changes take effect
// without restarting the server.
let pause_after = state.pause_when_empty_seconds.wrapping_mul(20);
if pause_after > 0 {
// A `/pregen` task keeps the server busy as a player would:
// paused, nothing would post-process its chunks.
@@ -1125,12 +1134,12 @@ impl Game<'_> {
"INFO",
&format!(
"Server empty for {} seconds, pausing",
props.pause_when_empty_seconds
state.pause_when_empty_seconds
),
);
self.save_game()?;
}
if !paused && !state.autosave {
if !paused && !auto_save(&state) {
// `MinecraftServer.autoSave` keeps its schedule while every level's
// `noSave` flag is set, but writes nothing. The tick below counts down
// once, so reset one tick early when this save would have been due.
@@ -1407,7 +1416,7 @@ impl Game<'_> {
match command {
"stop" => {
self.console_reply(Text::translate("commands.stop.stopping", &[]));
state.stop = true;
state.lists.lock().unwrap().stop = true;
}
// Horizon's own, on the console only: a performance report (report.rs).
"horizon-report" => {
@@ -1448,14 +1457,14 @@ impl Game<'_> {
self.console_reply(save_all_reply(saved.is_ok()));
}
"save-off" => {
if set_auto_save(state, false) {
if set_auto_save(&state.lists, false) {
self.console_reply(Text::translate("commands.save.disabled", &[]));
} else {
self.console_reply(Text::translate("commands.save.alreadyOff", &[]));
}
}
"save-on" => {
if set_auto_save(state, true) {
if set_auto_save(&state.lists, true) {
self.console_reply(Text::translate("commands.save.enabled", &[]));
} else {
self.console_reply(Text::translate("commands.save.alreadyOn", &[]));
@@ -1885,6 +1894,13 @@ impl Game<'_> {
self.store_property("enforce-whitelist", on.to_string());
}
/// `DedicatedServer.setPauseWhenEmptySeconds`: the live pause timer, and
/// `pause-when-empty-seconds` in server.properties.
fn set_pause_when_empty_seconds(&mut self, state: &mut ServerState, seconds: i32) {
state.pause_when_empty_seconds = seconds;
self.store_property("pause-when-empty-seconds", seconds.to_string());
}
/// DedicatedServerSettings.update: change one key and store the file.
pub(super) fn store_property(&mut self, key: &str, value: String) {
let path = PathBuf::from("server.properties");
@@ -127,9 +127,26 @@ impl Game<'_> {
auto_save(state),
enabled
));
set_auto_save(state, enabled);
set_auto_save(&state.lists, enabled);
return json!({ "value": auto_save(state) });
}
"serversettings/pause_when_empty_seconds" => {
return json!({ "value": state.pause_when_empty_seconds });
}
"serversettings/pause_when_empty_seconds/set" => {
let Some(seconds) = body["value"]
.as_i64()
.and_then(|value| i32::try_from(value).ok())
else {
return json!({ "error": "pause when empty seconds must be an integer" });
};
rpc(format!(
"Update pause when empty from {} seconds to {} seconds",
state.pause_when_empty_seconds, seconds
));
self.set_pause_when_empty_seconds(state, seconds);
return json!({ "value": state.pause_when_empty_seconds });
}
"serversettings/motd" => {
return json!({
"value": self.lan.as_ref().map(|lan| lan.hub.lan.shared.motd()).unwrap_or_default()
+28 -14
View File
@@ -1814,6 +1814,16 @@ impl Game<'_> {
/// is discarded.
pub(super) fn convert_mob(&mut self, index: usize, kind: &str) -> Option<u32> {
let t = self.res.mob_types().get(kind).cloned()?;
let (old_id, old_kind) = (
self.mobs[index].id,
super::native_hooks::mob_kind(&self.mobs[index]),
);
// LivingConversionEvent.Pre: a canceled conversion leaves the mob.
let mut cancel = false;
self.native_living_conversion_pre(old_id, &old_kind, kind, &mut cancel);
if cancel {
return None;
}
let old = &self.mobs[index];
self.next_entity += 1;
let mut mob = Mob::new(self.next_entity, t, old.position, old.yaw);
@@ -1845,6 +1855,8 @@ impl Game<'_> {
self.mobs[index].removed = true;
self.mobs.push(mob);
self.native_mobs_joined(self.mobs.len() - 1, false);
self.native_entity_transform(old_id, &old_kind, id, kind);
self.native_living_conversion_post(old_id, &old_kind, id, kind);
Some(id)
}
/// `ShearsDispenseItemBehavior.tryShearEntity`: the first live, ready
@@ -1964,20 +1976,22 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.mooshroom.shear", sound_at, 1.0, 1.0);
// convertTo(COW): the cow takes its place, then the mushrooms drop.
self.convert_mob(i, "minecraft:cow");
self.mooshroom_shear_puff([at.x, at.y, at.z], height);
let drops = self.sequence_loot(
"minecraft:shearing/mooshroom",
&format!("minecraft:shearing/mooshroom/{variant}"),
Some(&shears),
);
for stack in drops {
for _ in 0..stack.count {
let one = ItemStack {
count: 1,
..stack.clone()
};
self.spawn_at_location(at, height, one);
// A canceled conversion (LivingConversionEvent.Pre) drops nothing.
if self.convert_mob(i, "minecraft:cow").is_some() {
self.mooshroom_shear_puff([at.x, at.y, at.z], height);
let drops = self.sequence_loot(
"minecraft:shearing/mooshroom",
&format!("minecraft:shearing/mooshroom/{variant}"),
Some(&shears),
);
for stack in drops {
for _ in 0..stack.count {
let one = ItemStack {
count: 1,
..stack.clone()
};
self.spawn_at_location(at, height, one);
}
}
}
}
+313 -1
View File
@@ -74,6 +74,17 @@ pub(super) trait Entity: EntityRules {
index: usize,
keys: &HashSet<ChunkKey>,
) -> Option<(ChunkKey, Tag)>;
/// Loads a saved entity whose type id is `kind` as this kind and joins it to
/// the world, or hands `tag` back when `kind` is not one this kind loads.
/// Required, so a new kind states how it loads: it cannot be saved and then
/// dropped silently on the way back in.
fn load_nbt(
game: &mut Game<'_>,
kind: &str,
uuid: Option<[i32; 4]>,
player_uuid: Option<[i32; 4]>,
tag: Tag,
) -> Result<(), Tag>;
fn on_ground(&self) -> bool {
false
@@ -185,15 +196,28 @@ pub(super) trait Entity: EntityRules {
registry: &horizon_world::registry::BlockRegistry,
facts: &horizon_client::blocks::BlockFacts,
collision: &horizon_world::block_collision::BlockCollisionRegistry<'_>,
) -> bool {
self.push_by_bubble_column_over(world, registry, facts, collision, |_| {})
}
/// [`Entity::push_by_bubble_column`], telling `surface` the position of
/// each column block it lies in (for the bubbles at the top).
fn push_by_bubble_column_over(
&mut self,
world: &horizon_client::world::World,
registry: &horizon_world::registry::BlockRegistry,
facts: &horizon_client::blocks::BlockFacts,
collision: &horizon_world::block_collision::BlockCollisionRegistry<'_>,
surface: impl FnMut(BlockPos),
) -> bool {
let bounds = self.bounds();
horizon_client::motion::bubble_column_push(
horizon_client::motion::bubble_column_push_over(
world,
registry,
facts,
collision,
bounds,
self.velocity_mut(),
surface,
)
}
/// `Entity.getPistonPushReaction`.
@@ -261,6 +285,36 @@ macro_rules! stored_motion {
};
}
/// One `ItemEntity.tick`, with the fluid push and the bubble column decided by
/// the `Entity` defaults every kind shares: fluids, move, column, settle,
/// fluids again. Returns false when the item should be removed.
pub(super) fn tick_item(
item: &mut ItemEntity,
world: &horizon_client::world::World,
registry: &horizon_world::registry::BlockRegistry,
facts: &horizon_client::blocks::BlockFacts,
collision: &horizon_world::block_collision::BlockCollisionRegistry<'_>,
fluids: &horizon_client::motion::FluidResources,
no_physics: bool,
entity_boxes: &[Aabb],
border: Option<&horizon_core::geometry::border::BorderGeometry>,
) -> bool {
let heights = item.push_by_fluid(world, fluids);
let step = item.tick_move(
world,
facts,
collision,
no_physics,
heights,
entity_boxes,
border,
);
item.push_by_bubble_column(world, registry, facts, collision);
let alive = item.tick_settle(world, facts, no_physics, step);
item.push_by_fluid(world, fluids);
alive
}
/// The game's lists of entities, in the order systems walk them. Hands them to
/// `$then`, so one list drives the handles and every typed dispatch.
macro_rules! entity_lists {
@@ -457,7 +511,55 @@ macro_rules! save_entities_of_every_kind {
};
}
macro_rules! load_lists {
(($game:expr, $kind:expr, $uuid:expr, $player:expr, $tag:expr)
$($kind_name:ident($ty:ty) $field:ident: $($list:ident).+,)*) => {{
let mut tag = $tag;
$(match <$ty as $crate::game::entity::Entity>::load_nbt($game, $kind, $uuid, $player, tag) {
Ok(()) => return Ok(()),
Err(back) => tag = back,
})*
Err(tag)
}};
}
pub(crate) use load_lists;
/// Loads a saved entity as the first kind in `entity_lists!` that claims its
/// type id, through that kind's own `Entity::load_nbt`: one test per kind,
/// static dispatch, nothing allocated (the compound moves through). Evaluates
/// to `Err(tag)` when no kind claims it, and `return`s `Ok` from the caller
/// otherwise. A kind added to the lists cannot be left out, since `load_nbt`
/// has no default.
macro_rules! load_entity_of_any_kind {
($game:expr, $kind:expr, $uuid:expr, $player:expr, $tag:expr) => {
$crate::game::entity::entity_lists!(load_lists, $game, $kind, $uuid, $player, $tag)
};
}
impl Game<'_> {
/// Loads a saved entity as the first kind in `entity_lists!` that claims
/// its type id: `Err` hands the compound back when none does.
pub(super) fn load_listed(
&mut self,
kind: &str,
uuid: Option<[i32; 4]>,
player_uuid: Option<[i32; 4]>,
tag: Tag,
) -> Result<(), Tag> {
load_entity_of_any_kind!(self, kind, uuid, player_uuid, tag)
}
/// The ids of the entities that are not on the walk of `entity_lists!`.
pub(super) fn entity_ids_off_walk(&self) -> impl Iterator<Item = u32> + '_ {
self.cushions
.iter()
.map(|e| e.id)
.chain(self.item_frames.iter().map(|e| e.id))
.chain(self.paintings.iter().map(|e| e.id))
.chain(self.others.iter().map(|e| e.id))
.chain(self.crystals.iter().map(|e| e.id))
.chain(self.dragon.iter().map(|e| e.id))
.chain(self.ominous_spawners.iter().map(|e| e.id))
}
/// Saves every entity of every kind that lies in `keys` into `out`, by chunk.
pub(super) fn save_every_entity(
&mut self,
@@ -479,6 +581,19 @@ impl Entity for Mob {
) -> Option<(ChunkKey, Tag)> {
game.save_mob(index, keys)
}
fn load_nbt(
game: &mut Game<'_>,
kind: &str,
_uuid: Option<[i32; 4]>,
_player_uuid: Option<[i32; 4]>,
tag: Tag,
) -> Result<(), Tag> {
if !(game.res.mob_types().contains_key(kind)) {
return Err(tag);
}
game.load_mob(tag, None);
Ok(())
}
stored_motion!();
fn portal_size(&self) -> Option<(f32, f32)> {
Some((self.width(), self.height()))
@@ -554,6 +669,9 @@ impl Entity for Mob {
}
impl Entity for ItemEntity {
fn fluid_eye_y(&self) -> f64 {
ItemEntity::fluid_eye_y(self)
}
fn native_type(&self) -> Cow<'static, str> {
"minecraft:item".into()
}
@@ -564,6 +682,19 @@ impl Entity for ItemEntity {
) -> Option<(ChunkKey, Tag)> {
game.save_item(index, keys)
}
fn load_nbt(
game: &mut Game<'_>,
kind: &str,
uuid: Option<[i32; 4]>,
_player_uuid: Option<[i32; 4]>,
tag: Tag,
) -> Result<(), Tag> {
if !(kind == "minecraft:item") {
return Err(tag);
}
game.load_item(tag, uuid);
Ok(())
}
stored_motion!();
fn portal_size(&self) -> Option<(f32, f32)> {
Some((0.25, 0.25))
@@ -609,6 +740,19 @@ impl Entity for ExperienceOrb {
) -> Option<(ChunkKey, Tag)> {
game.save_orb(index, keys)
}
fn load_nbt(
game: &mut Game<'_>,
kind: &str,
uuid: Option<[i32; 4]>,
_player_uuid: Option<[i32; 4]>,
tag: Tag,
) -> Result<(), Tag> {
if !(kind == "minecraft:experience_orb") {
return Err(tag);
}
game.load_orb(tag, uuid);
Ok(())
}
stored_motion!();
fn portal_size(&self) -> Option<(f32, f32)> {
Some((0.5, 0.5))
@@ -651,6 +795,21 @@ impl Entity for Boat {
) -> Option<(ChunkKey, Tag)> {
game.save_boat(index, keys)
}
fn load_nbt(
game: &mut Game<'_>,
kind: &str,
uuid: Option<[i32; 4]>,
_player_uuid: Option<[i32; 4]>,
tag: Tag,
) -> Result<(), Tag> {
if !(super::boats::any_boat_wood(kind)
.is_some_and(|(wood, chest)| game.boat_models.has(wood, chest)))
{
return Err(tag);
}
game.load_boat(tag, uuid, kind);
Ok(())
}
stored_motion!();
fn is_vehicle(&self) -> bool {
true
@@ -697,6 +856,19 @@ impl Entity for Minecart {
) -> Option<(ChunkKey, Tag)> {
game.save_cart(index, keys)
}
fn load_nbt(
game: &mut Game<'_>,
kind: &str,
uuid: Option<[i32; 4]>,
_player_uuid: Option<[i32; 4]>,
tag: Tag,
) -> Result<(), Tag> {
if !(super::minecarts::CartKind::from_id(kind).is_some()) {
return Err(tag);
}
game.load_cart(tag, uuid, kind);
Ok(())
}
stored_motion!();
fn is_vehicle(&self) -> bool {
true
@@ -743,6 +915,9 @@ fn armor_stand_size(stand: &ArmorStand) -> (f64, f64) {
}
impl Entity for ArmorStand {
fn fluid_eye_y(&self) -> f64 {
ArmorStand::fluid_eye_y(self)
}
fn native_type(&self) -> Cow<'static, str> {
"minecraft:armor_stand".into()
}
@@ -753,6 +928,19 @@ impl Entity for ArmorStand {
) -> Option<(ChunkKey, Tag)> {
game.save_armor_stand(index, keys)
}
fn load_nbt(
game: &mut Game<'_>,
kind: &str,
uuid: Option<[i32; 4]>,
_player_uuid: Option<[i32; 4]>,
tag: Tag,
) -> Result<(), Tag> {
if !(kind == "minecraft:armor_stand") {
return Err(tag);
}
game.load_armor_stand(tag, uuid);
Ok(())
}
stored_motion!();
fn selector_target(&self) -> Option<Target> {
Some(Target::Stand(self.id))
@@ -828,6 +1016,19 @@ impl Entity for PrimedTnt {
) -> Option<(ChunkKey, Tag)> {
game.save_tnt(index, keys)
}
fn load_nbt(
game: &mut Game<'_>,
kind: &str,
uuid: Option<[i32; 4]>,
_player_uuid: Option<[i32; 4]>,
tag: Tag,
) -> Result<(), Tag> {
if !(kind == "minecraft:tnt") {
return Err(tag);
}
game.load_tnt(tag, uuid);
Ok(())
}
stored_motion!();
/// `PrimedTnt` eye height: `0.98 * 0.85`.
fn fluid_eye_y(&self) -> f64 {
@@ -872,6 +1073,19 @@ impl Entity for FallingBlock {
) -> Option<(ChunkKey, Tag)> {
game.save_falling(index, keys)
}
fn load_nbt(
game: &mut Game<'_>,
kind: &str,
uuid: Option<[i32; 4]>,
_player_uuid: Option<[i32; 4]>,
tag: Tag,
) -> Result<(), Tag> {
if !(kind == "minecraft:falling_block") {
return Err(tag);
}
game.load_falling(tag, uuid);
Ok(())
}
stored_motion!();
fn selector_target(&self) -> Option<Target> {
Some(Target::Falling(self.id))
@@ -892,6 +1106,31 @@ impl Entity for Projectile {
) -> Option<(ChunkKey, Tag)> {
game.save_projectile(index, keys)
}
fn load_nbt(
game: &mut Game<'_>,
kind: &str,
uuid: Option<[i32; 4]>,
player_uuid: Option<[i32; 4]>,
tag: Tag,
) -> Result<(), Tag> {
match kind {
"minecraft:arrow" | "minecraft:spectral_arrow" => {
game.load_arrow(tag, uuid, player_uuid)
}
"minecraft:llama_spit" | "minecraft:shulker_bullet" => {
game.load_spit_or_bullet(tag, player_uuid)
}
"minecraft:fireball"
| "minecraft:small_fireball"
| "minecraft:wind_charge"
| "minecraft:breeze_wind_charge" => game.load_fireball(tag, uuid, player_uuid),
id if horizon_client::entity_nbt::throwable_kind(id).is_some() => {
game.load_throwable(tag, uuid, player_uuid)
}
_ => return Err(tag),
}
Ok(())
}
stored_motion!();
/// Arrows and the thrown items and fireballs a chunk saves.
fn portal_size(&self) -> Option<(f32, f32)> {
@@ -955,6 +1194,19 @@ impl Entity for Rocket {
) -> Option<(ChunkKey, Tag)> {
game.save_rocket(index, keys)
}
fn load_nbt(
game: &mut Game<'_>,
kind: &str,
uuid: Option<[i32; 4]>,
player_uuid: Option<[i32; 4]>,
tag: Tag,
) -> Result<(), Tag> {
if !(kind == "minecraft:firework_rocket") {
return Err(tag);
}
game.load_rocket(tag, uuid, player_uuid);
Ok(())
}
stored_motion!();
/// A rocket that carries its owner stays.
fn portal_size(&self) -> Option<(f32, f32)> {
@@ -990,6 +1242,19 @@ impl Entity for ThrownTrident {
) -> Option<(ChunkKey, Tag)> {
game.save_trident(index, keys)
}
fn load_nbt(
game: &mut Game<'_>,
kind: &str,
uuid: Option<[i32; 4]>,
_player_uuid: Option<[i32; 4]>,
tag: Tag,
) -> Result<(), Tag> {
if !(kind == "minecraft:trident") {
return Err(tag);
}
game.load_trident(tag, uuid);
Ok(())
}
stored_motion!();
fn portal_size(&self) -> Option<(f32, f32)> {
Some((0.5, 0.5))
@@ -1083,6 +1348,53 @@ mod no_gravity_stand_tests {
}
}
#[cfg(test)]
mod load_claim_tests {
/// `Mob` is first in `entity_lists!` and claims every id in `mob_types`;
/// the old load tried every other kind first. Both agree only while no
/// mob species shares an id with another kind, which this checks.
#[test]
fn no_mob_species_is_an_id_another_kind_loads() {
let others = [
"minecraft:item",
"minecraft:experience_orb",
"minecraft:arrow",
"minecraft:spectral_arrow",
"minecraft:llama_spit",
"minecraft:shulker_bullet",
"minecraft:fireball",
"minecraft:small_fireball",
"minecraft:wind_charge",
"minecraft:breeze_wind_charge",
"minecraft:falling_block",
"minecraft:trident",
"minecraft:firework_rocket",
"minecraft:tnt",
"minecraft:armor_stand",
"minecraft:ominous_item_spawner",
"minecraft:ender_dragon",
"minecraft:end_crystal",
"minecraft:item_frame",
"minecraft:glow_item_frame",
"minecraft:cushion",
"minecraft:painting",
];
for def in horizon_client::mobs::species::ALL {
let id = format!("minecraft:{}", def.name);
assert!(!others.contains(&id.as_str()), "{id}");
assert!(
horizon_client::entity_nbt::throwable_kind(&id).is_none(),
"{id}"
);
assert!(super::super::boats::any_boat_wood(&id).is_none(), "{id}");
assert!(
super::super::minecarts::CartKind::from_id(&id).is_none(),
"{id}"
);
}
}
}
#[cfg(test)]
mod trident_selector_tests {
use super::Entity as _;
@@ -435,7 +435,8 @@ impl Game<'_> {
/// Use complete generated geometry so equipment, animation and custom
/// models cannot stick out of an assumed collision-sized culling box.
pub(super) fn cull_entity_vertices(&self, vertices: &mut Vec<Vertex>, start: usize) {
/// Returns this entity's occlusion result, including retained shadow casters.
pub(super) fn cull_entity_vertices(&self, vertices: &mut Vec<Vertex>, start: usize) -> bool {
if let Some(culler) = &self.entity_culler {
let hidden = culler.hidden(vertices, start, |pos| self.occluder_ready(pos));
// A shader pack's shadow pass still draws what the camera cannot see.
@@ -444,14 +445,14 @@ impl Game<'_> {
{
vertices.truncate(start);
}
return;
return hidden;
}
let Some(culler) = &self.async_entity_culler else {
return;
return false;
};
let Some(query) = culler.query(vertices, start) else {
return;
return false;
};
let hidden = self
.async_entity_culler
@@ -464,6 +465,7 @@ impl Game<'_> {
if let Some(culler) = &self.async_entity_culler {
culler.queue(query);
}
hidden
}
fn async_proof_is_current(&self, proof: &OcclusionProof) -> bool {
@@ -525,6 +525,11 @@ impl Game<'_> {
self.native_firework_explode(r.id, at, r.explosions.len());
}
fn rocket_hurt_mobs(&mut self, hits: &[(u32, f32)], attacker: Option<Who>, at: [f64; 3]) {
let _attacker = self.knockback_scope(if attacker == Some(Who::Player) {
self.player.as_ref().map_or(0, |p| p.entity_id)
} else {
0
});
for &(id, amount) in hits {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
let hurt = m.damage(amount, Damage::fireworks(attacker, at));
@@ -28,6 +28,9 @@ struct Columns<'a, 'r> {
world: &'a World,
properties: &'a Properties,
binding: &'a BlockCollisionRegistry<'r>,
has_ceiling: bool,
/// `ChunkGenerator.getSpawnHeight` (64 for the vanilla generators).
spawn_height: i32,
}
impl SpawnLevel for Columns<'_, '_> {
fn min_y(&self) -> i32 {
@@ -66,6 +69,12 @@ impl SpawnLevel for Columns<'_, '_> {
.is_ok_and(|differs| !differs)
})
}
fn has_ceiling(&self) -> bool {
self.has_ceiling
}
fn spawn_height(&self) -> i32 {
self.spawn_height
}
}
impl Game<'_> {
@@ -74,6 +83,10 @@ impl Game<'_> {
world: &self.world,
properties: &self.pack.assets.properties,
binding: self.collision,
// The spawn searches are the overworld's (PlayerSpawnFinder); a
// ceiling starts them at the generator's spawn height.
has_ceiling: self.level_shapes.overworld.has_ceiling,
spawn_height: self.spawn_origin.map_or(64, |(_, height)| height),
}
}
/// ChunkGenerator.getOrigin and getSpawnHeight of the overworld.
@@ -23,6 +23,15 @@ pub(super) struct Env<'a, 'g> {
game: &'a Game<'g>,
}
impl ItemEnv for Env<'_, '_> {
fn item_model_property(&self, property: &str, stack: &StackView) -> bool {
if horizon_client::mod_api::mods().is_empty() {
return false;
}
let mut value = false;
self.game
.native_item_model_property(property, stack, &mut value);
value
}
fn enchantment_in_tag(&self, enchantment: &str, tag: &str) -> bool {
self.game.stations().enchantments.in_tag(tag, enchantment)
}
+5 -1
View File
@@ -1427,10 +1427,11 @@ impl Game<'_> {
) {
self.guest_attachments_into(guest);
if guest.placed {
let _ = self.finish_player_autosaves(true);
if host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) {
playerdata::save_owner(guest, &mut self.level, &self.world_root);
} else {
playerdata::save(guest, &self.world_root);
self.player_autosaves.save_after(guest, &self.world_root);
}
let directory = playerdata::directory(&self.world_root);
self.native_player_save_to_file(guest.uuid, &guest.name, &directory);
@@ -2231,6 +2232,9 @@ impl Game<'_> {
// drops remain absent from the snapshot until the following tick.
if !paused {
self.tick_guests(&mut host);
// The vehicles the guests just moved enter their portals now.
let ridden: Vec<u32> = host.hub.guests.iter().filter_map(|g| g.vehicle).collect();
self.find_ridden_vehicle_portals(&ridden);
}
{
let has_guests = !host.hub.guests.is_empty();
+34 -1
View File
@@ -3286,9 +3286,14 @@ impl Game<'_> {
self.run_command(&c.command)
};
self.ui.registries.permission = level;
let lines = self.chat_capture.take().unwrap_or_default();
let mut lines = self.chat_capture.take().unwrap_or_default();
let mut host = self.lan.take().expect("a command puts the hub back");
self.leave_guest(&mut host, swap);
if std::mem::take(&mut self.save_after_guest_turn) {
self.lan = Some(host);
lines.push(self.save_all_reply());
host = self.lan.take().expect("saving puts the hub back");
}
host.hub.sync_all_effects();
if !c.integrated {
// A teleport into another level happens before its feedback is sent.
@@ -3792,6 +3797,34 @@ impl Game<'_> {
/// `ServerPlayer.die` for a guest whose health has run out: the death
/// message, the loot and experience, the death screen.
fn guest_die(&mut self, host: &mut LanHost, gi: usize, conn: u32) {
if host.hub.guests[gi].health <= 0.0
&& !host.hub.guests[gi].dead
&& !horizon_client::mod_api::mods().is_empty()
{
// LivingDeathEvent: a canceled death keeps the player at 1 health.
let (damage_type, attacker) =
match host.play.get(&conn).and_then(|p| p.death_cause.as_deref()) {
Some("death.attack.genericKill") => ("minecraft:generic_kill", 0),
_ => self
.guest_combat
.get(&conn)
.and_then(|tracker| tracker.last_blow())
.unwrap_or(("minecraft:generic", 0)),
};
let (damage_type, entity) = (damage_type.to_owned(), host.hub.guests[gi].entity_id);
let mut cancel = false;
self.native_living_death(
entity as u32,
"minecraft:player",
&damage_type,
attacker,
&mut cancel,
);
if cancel {
host.hub.guests[gi].health = 1.0;
return;
}
}
if host.hub.guests[gi].health <= 0.0 && !host.hub.guests[gi].dead {
self.guest_forget_digging(host, conn);
// FishingHook.shouldStopFishing: its owner is dead.
@@ -284,7 +284,10 @@ impl Game<'_> {
.guest(conn)
.map(|a| (a.entity_id, self.player_text(&a.name, a.uuid)))
});
let attacker_id = self.player.as_ref().map_or(0, |p| p.entity_id);
let land = |g: &mut Self| {
let _attacker =
g.knockback_scope(attacker_note.as_ref().map_or(attacker_id, |(id, _)| *id));
g.combat.player_attacker = Some((s.from, s.disable_blocking));
if let Some((id, name)) = attacker_note.clone() {
g.note_player_attacker("minecraft:player_attack", id as u32, name, s.item.clone());
@@ -468,7 +471,10 @@ impl Game<'_> {
}
let (xd, zd) = sweep.knockback;
let attacker = self.player_attacker(host, sweep.attacker);
let attacker_id = self.player.as_ref().map_or(0, |p| p.entity_id);
let land = |g: &mut Self| {
let _attacker =
g.knockback_scope(attacker.as_ref().map_or(attacker_id, |(id, _)| *id as i32));
if let Some((id, name)) = &attacker {
g.note_named_attacker("minecraft:player_attack", *id, name.clone());
}
+23 -4
View File
@@ -65,9 +65,13 @@ impl Game<'_> {
/// spawning chunk (within 128 blocks of a player) that is in entity
/// ticking range.
pub(super) fn tick_thunder(&mut self) {
// isRaining (rain level > 0.2) and isThundering (> 0.9, sky light, no ceiling).
// isRaining (rain level > 0.2) and isThundering (> 0.9) both need
// Level.canHaveWeather: sky light, no ceiling and not the End.
let (rain, thunder) = self.world.weather.levels(1.0);
if rain <= 0.2 || thunder <= 0.9 || self.dim != super::Dim::Overworld || self.world_debug {
let weather = self.world.shape.sky_light
&& !self.world.shape.has_ceiling
&& self.dim != super::Dim::End;
if rain <= 0.2 || thunder <= 0.9 || !weather || self.world_debug {
return;
}
let mut scratch = std::mem::take(&mut self.spawn_scratch);
@@ -559,12 +563,27 @@ impl Game<'_> {
}
left -= 1;
struck.push(self.mob_entity(&self.mobs[i]));
// LivingConversionEvent.Pre: a canceled conversion leaves the
// mob to the lightning's fire and damage, as `super.thunderHit`.
let outcome = match self.mobs[i].species() {
Species::Pig if !peaceful => Some("minecraft:zombified_piglin"),
Species::Villager if !peaceful => Some("minecraft:witch"),
_ => None,
};
let mut keep = false;
if let Some(outcome) = outcome {
let (id, kind) = (
self.mobs[i].id,
super::native_hooks::mob_kind(&self.mobs[i]),
);
self.native_living_conversion_pre(id, &kind, outcome, &mut keep);
}
let m = &mut self.mobs[i];
b.hit.push(m.id);
let setup = thunder_mob_spawn_setup;
let at = [m.position.x, m.position.y, m.position.z];
match m.species() {
Species::Pig if !peaceful => {
Species::Pig if !peaceful && !keep => {
m.removed = true;
events.push(MobEvent::SpawnKind {
kind: "minecraft:zombified_piglin".into(),
@@ -573,7 +592,7 @@ impl Game<'_> {
});
continue;
}
Species::Villager if !peaceful => {
Species::Villager if !peaceful && !keep => {
m.removed = true;
events.push(MobEvent::SpawnKind {
kind: "minecraft:witch".into(),
+46 -4
View File
@@ -182,9 +182,33 @@ impl Game<'_> {
/// `LivingEntity.hurtServer` of the mob at `index` by the player's hit:
/// the weapon's `armor_effectiveness` enchantments (Breach) thin its armor.
pub(super) fn hurt_by_player(&mut self, index: usize, amount: f32, source: Damage) -> bool {
let _attacker = self.knockback_scope(self.player.as_ref().map_or(0, |p| p.entity_id));
let held = self.held().cloned();
let aim = self.attacker_aim(source.attacker);
let kind = self.mobs[index].kind.id.clone();
let mut amount = amount;
// After the invulnerability guard, as `LivingIncomingDamageEvent` fires.
if !crate::mod_api::mods().is_empty() && !self.mobs[index].refuses_damage(&source) {
let victim = &self.mobs[index];
let position = victim.position;
let view = super::view_vector(victim.head_yaw, victim.head_pitch);
let mut canceled = false;
self.native_living_incoming_damage_v2(
victim.id as i32,
&kind,
super::creatures::source_damage_type(source),
self.player.as_ref().map_or(0, |player| player.entity_id),
source.from,
[position.x, position.y, position.z],
view,
held.as_ref(),
&mut amount,
&mut canceled,
);
if canceled {
return false;
}
}
let ctx = EffectContext {
this: Some(&kind),
attacker: Some("minecraft:player"),
@@ -456,6 +480,7 @@ impl Game<'_> {
let Some(player) = self.player.as_ref() else {
return;
};
let _attacker = self.knockback_scope(player.entity_id);
let from = [player.position.x, player.position.y, player.position.z];
let area = target.inflate(Vec3::new(1.0, 0.25, 1.0));
let near = |at: Vec3| {
@@ -530,7 +555,24 @@ impl Game<'_> {
if !self.punch_armor_stand(i) {
continue;
}
let (mut kx, mut kz) = (xd, zd);
let mut knock = horizon_client::health::Knock {
strength: 0.4,
x: xd,
z: zd,
};
let source = horizon_client::health::HurtSource {
damage_type: "minecraft:player_attack",
causing: horizon_client::health::knockback_attacker(),
direct: horizon_client::health::knockback_attacker(),
};
if horizon_client::health::knockback_canceled(
horizon_client::health::Hurting::ArmorStand(id as i32, source),
&mut knock,
) || knock.strength <= 0.0
{
continue;
}
let (mut kx, mut kz) = (knock.x, knock.z);
while kx * kx + kz * kz < f64::from(1.0e-5_f32) {
let r = &mut self.world.random;
kx = (r.next_double() - r.next_double()) * 0.01;
@@ -539,13 +581,13 @@ impl Game<'_> {
let len = (kx * kx + kz * kz).sqrt();
if let Some(s) = self.armor_stands.iter_mut().find(|s| s.id == id) {
s.velocity = Vec3::new(
s.velocity.x / 2.0 - kx / len * 0.4,
s.velocity.x / 2.0 - kx / len * knock.strength,
if s.on_ground {
(s.velocity.y / 2.0 + 0.4).min(0.4)
(s.velocity.y / 2.0 + knock.strength).min(0.4)
} else {
s.velocity.y
},
s.velocity.z / 2.0 - kz / len * 0.4,
s.velocity.z / 2.0 - kz / len * knock.strength,
);
}
continue;
+3 -3
View File
@@ -1497,7 +1497,7 @@ impl Game<'_> {
let Some(p) = self.player.as_ref().map(|p| p.position) else {
return;
};
let nether = self.dim == super::Dim::Nether;
let has_ceiling = self.world.shape.has_ceiling;
let Some(m) = self.maps.data.get_mut(&id) else {
return;
};
@@ -1509,7 +1509,7 @@ impl Game<'_> {
let px = (p.x - f64::from(cx)).floor() as i32 / scale + 64;
let pz = (p.z - f64::from(cz)).floor() as i32 / scale + 64;
let mut radius = 128 / scale;
if nether {
if has_ceiling {
radius /= 2;
}
let h = m.holder(self.lan_acting.unwrap_or(HOST));
@@ -1541,7 +1541,7 @@ impl Game<'_> {
};
let mut water_depth = 0i32;
let mut height = 0.0f64;
if nether {
if has_ceiling {
let mut noise = min_x.wrapping_add(min_z.wrapping_mul(231_871));
noise = noise
.wrapping_mul(noise)
+6
View File
@@ -7,6 +7,7 @@ use super::Game;
use horizon_client::chunk::ColumnCensus;
use horizon_client::fast_hash::FxHashSet;
use horizon_client::world::World;
use horizon_threads::capped;
use serde_json::{Value, json};
use std::sync::Arc;
@@ -154,6 +155,11 @@ impl Game<'_> {
"resident_mb": horizon_threads::memory_limit::resident_bytes().map(mb),
"allocator_commit_mb": mb(commit),
"allocator_peak_commit_mb": mb(peak_commit),
// Bytes the game asked for, against the hard ceiling if one is set.
"held_mb": mb(capped::live_bytes()),
"held_peak_mb": mb(capped::peak_bytes()),
"hard_limit_mb": capped::limit().map(mb),
"refused_allocations": capped::refused(),
},
"render_distance": self.streaming.radius,
"client_columns": census_json(&client),
+39 -13
View File
@@ -174,11 +174,18 @@ pub(super) const HEIGHT: f64 = 0.7_f32 as f64;
fn display_offset(kind: CartKind) -> f64 {
if kind == CartKind::Hopper { 1.0 } else { 6.0 }
}
/// `MinecartFurnace.calculateNewPushAlong`: the push turned onto the
/// movement's direction, at the push's own length (3D, so it holds from tick
/// to tick while the movement has a vertical part).
/// `AbstractMinecart.push(entity)` for a non-cart `entity` at `feet`: the
/// push the cart at `cart` loses (the entity gets a quarter of it back).
/// Where the cart `id` is in `carts`: at `hint` (where it was a moment ago,
/// the usual case while carts tick in order) or found by a scan. A world
/// with hundreds of mineshaft chest carts asked this of every cart several
/// times a tick.
fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
if carts.get(hint).is_some_and(|c| c.id == id) {
return Some(hint);
}
carts.iter().position(|c| c.id == id)
}
/// `AbstractMinecart.push(entity)` for a non-cart `entity` at `feet`:
/// the push the cart loses (the entity gets a quarter of it back).
fn cart_push_from(feet: Vec3, cart: Vec3) -> Option<(f64, f64)> {
let (xa, za) = (feet.x - cart.x, feet.z - cart.z);
let dd = xa * xa + za * za;
@@ -190,6 +197,7 @@ fn cart_push_from(feet: Vec3, cart: Vec3) -> Option<(f64, f64)> {
let k = f64::from(0.1f32) * 0.5;
Some((xa / d * pow * k, za / d * pow * k))
}
/// `MinecartFurnace.calculateNewPushAlong`: project the push onto movement.
fn push_along(push: Vec3, movement: Vec3) -> Vec3 {
if push.horizontal_distance_squared() > 1.0e-4 && movement.horizontal_distance_squared() > 0.001
{
@@ -1394,8 +1402,16 @@ impl Game<'_> {
pushers.push((Vec3::new(x, y, z), g.bounds()));
}
}
let cells =
horizon_client::mobs::seen_grid::SeenGrid::new(self.carts.iter().map(Minecart::bounds));
let mut near = Vec::new();
for (feet, bb) in pushers {
for c in &mut self.carts {
if !cells.candidates(bb, &mut near) {
near.extend(0..self.carts.len() as u32);
}
for &k in &near {
let k = k as usize;
let c = &mut self.carts[k];
if !bb.intersects(c.bounds()) {
continue;
}
@@ -1412,13 +1428,13 @@ impl Game<'_> {
c.velocity.z -= za;
}
}
self.push_carts_from_mobs();
self.push_carts_from_mobs(&cells);
}
/// `AbstractMinecart.push(mob)` from each mob walking into a cart
/// (`LivingEntity.pushEntities` -> `doPush`): the cart gets the push and
/// the mob a quarter of it back (`Entity.push`, which needs the mob to be
/// pushable and to carry no one). Bats push nothing (`Bat.pushEntities`).
fn push_carts_from_mobs(&mut self) {
fn push_carts_from_mobs(&mut self, cells: &horizon_client::mobs::seen_grid::SeenGrid) {
let teams = self.mob_teams();
// (mob index, feet, box)
let pushers: Vec<(usize, Vec3, Aabb)> = self
@@ -1434,8 +1450,14 @@ impl Game<'_> {
.map(|(j, m)| (j, m.position, m.bounds()))
.collect();
drop(teams);
let mut near = Vec::new();
for (j, feet, bb) in pushers {
for c in &mut self.carts {
if !cells.candidates(bb, &mut near) {
near.extend(0..self.carts.len() as u32);
}
for &k in &near {
let k = k as usize;
let c = &mut self.carts[k];
// hasPassenger(mob): a mob in this cart never pushes it.
if self.mobs[j].extra.mount == Some(c.id) || !bb.intersects(c.bounds()) {
continue;
@@ -1641,10 +1663,12 @@ impl Game<'_> {
let ridden = self.guest_vehicles();
// By id: an exploding cart can destroy or prime others mid-loop.
let ids: Vec<u32> = self.carts.iter().map(|c| c.id).collect();
let mut hint = 0;
for &id in &ids {
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
let Some(i) = cart_at(&self.carts, id, hint) else {
continue;
};
hint = i + 1;
let p = self.carts[i].position;
if Some(id) == self.riding
|| ridden.contains(&id)
@@ -1656,7 +1680,7 @@ impl Game<'_> {
if !self.native_tick_pre_one(&mut round, id, kind) {
continue;
}
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
let Some(i) = cart_at(&self.carts, id, i) else {
continue;
};
let mut c = self.carts[i].clone();
@@ -1664,7 +1688,7 @@ impl Game<'_> {
if c.kind == CartKind::Hopper && c.enabled {
self.hopper_cart_suck(&mut c);
}
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
let Some(i) = cart_at(&self.carts, id, i) else {
self.native_tick_post_one(&round, id, kind);
continue;
};
@@ -1683,11 +1707,13 @@ impl Game<'_> {
self.native_tick_post_one(&round, id, kind);
}
// On the server for every cart, the ridden ones too.
let mut hint = 0;
for id in ids {
if round.canceled(id) {
continue;
}
if let Some(i) = self.carts.iter().position(|c| c.id == id) {
if let Some(i) = cart_at(&self.carts, id, hint) {
hint = i + 1;
let p = self.carts[i].position;
if ticking(p.x.floor() as i32, p.z.floor() as i32) {
self.cart_lava_tick(id);
+9 -15
View File
@@ -57,22 +57,16 @@ impl Game<'_> {
.unwrap_or_default();
}
/// Whether entity `id` is in the level, as a holder of attachments.
/// Whether entity `id` is in the level, as a holder of attachments: one walk
/// over the entity lists (a kind added there holds attachments too), then the
/// kinds that are not on it.
fn attachment_entity_exists(&self, id: u32) -> bool {
use super::commands::Target;
match i32::try_from(id).ok().and_then(|id| self.level_entity(id)) {
Some(Target::Mob(_)) => self.mobs.iter().any(|e| e.id == id && !e.removed),
Some(
Target::Item(_)
| Target::Boat(_)
| Target::Cart(_)
| Target::Stand(_)
| Target::Frame(_)
| Target::Painting(_)
| Target::Other(_),
) => true,
_ => self.cushions.iter().any(|e| e.id == id),
}
use super::entity::{Entity as _, entity_lists_of, for_each_entity};
let mut found = false;
for_each_entity!(entity_lists_of!(self), entity => {
found |= entity.id() == id && !entity.is_removed();
});
found || self.entity_ids_off_walk().any(|e| e == id)
}
/// The attachments of entities that left the level, dropped: each tick
@@ -0,0 +1,40 @@
//! Where blocks' textures are in the block sprite arrays, for a client mod drawing blocks with the player's own
//! textures (resource packs included) in its own pass: with `WorldDrawn::block_sprites`, the arrays themselves.
use super::Game;
use horizon_client::block_sprites::{BlockSprites, Lookup, SpriteRef};
#[allow(dead_code)]
impl Game<'_> {
/// For a client mod: what each block (`minecraft:grass_block`, `oak_log[axis=x]`; properties left out take the
/// block's default) draws on each of its six sides, as the mesher bakes its model: each face's sprite (array and
/// layer, as `WorldDrawn::block_sprites` cuts them), where it lies on the side, how its texture runs across it,
/// its tint source and whether it is cut out, translucent or animated; its particle sprite; whether it is a plain
/// cube. None for a block the game does not know. The model is the state's first random variant. API
/// @API:native_block_sprites@.
pub(super) fn native_block_sprites(&self, blocks: &[&str]) -> Vec<Option<BlockSprites>> {
let lookup = self.native_sprite_lookup();
blocks.iter().map(|block| lookup.block(block)).collect()
}
/// For a client mod: each texture's sprite by name (`minecraft:block/stone`, `block/stone`), none for one no
/// model uses. API @API:native_block_sprites@.
pub(super) fn native_texture_sprites(&self, textures: &[&str]) -> Vec<Option<SpriteRef>> {
let lookup = self.native_sprite_lookup();
textures
.iter()
.map(|texture| lookup.texture(texture))
.collect()
}
/// For a client mod: changes each time the resources load (a resource pack turned on or off can move every
/// sprite): ask `native_block_sprites` again then. API @API:native_block_sprites@.
pub(super) fn native_block_sprites_version(&self) -> u64 {
self.pack.context.render.generation
}
fn native_sprite_lookup(&self) -> Lookup<'_> {
// (no renderer, no arrays: every layer counts as array 0's)
let split = self
.renderer
.as_ref()
.map_or(u32::MAX, |r| r.sprite_split());
Lookup::new(&self.pack.context, split)
}
}
@@ -118,7 +118,8 @@ fn target_id(t: Target) -> Option<u32> {
| Target::Falling(id)
| Target::Rocket(id)
| Target::Trident(id)
| Target::Spawner(id) => Some(id),
| Target::Spawner(id)
| Target::Cloud(id) => Some(id),
Target::Player | Target::Guest(_) => None,
}
}
@@ -0,0 +1,82 @@
//! An entity's saved data, tags and name for native server mods, through an
//! `EntityRef` (docs/native-mods.md, "Entity and level handles", #7387): what
//! `/data` and `/tag` read and change. They return `None` (or `false`) for a
//! stale handle or on a client.
use super::Game;
use horizon_client::mod_api::EntityRef;
use horizon_world::nbt::{Compound, Tag};
/// The most tags an entity holds (`Entity.addTag`).
const MAX_TAGS: usize = 1024;
impl Game<'_> {
/// The entity's data as `/data get entity` shows it
/// (`Entity.saveWithoutId`): its compound without `id`. An entity with no
/// UUID yet gets the one it will be saved with. API @API:entity_data@.
pub(super) fn native_entity_nbt(&mut self, entity: EntityRef) -> Option<Compound> {
let t = self.native_entity_target(entity)?;
match self.entity_nbt(t)? {
Tag::Compound(mut c) => {
c.remove(&"id".into());
Some(c)
}
_ => None,
}
}
/// Merges `nbt` into the entity's data and loads the entity from it, as
/// `/data merge entity` does (`EntityDataAccessor.setData`): true when it
/// changed. False for a player (vanilla refuses them too), a kind Horizon
/// can't reload yet (only mobs, dropped items, armor stands and item
/// frames), data nested too deep, or data that changes nothing. API
/// @API:entity_data@.
pub(super) fn native_entity_merge_nbt(&mut self, entity: EntityRef, nbt: Compound) -> bool {
let Some(t) = self.native_entity_target(entity) else {
return false;
};
let Some(old) = self.native_entity_nbt(entity).map(Tag::Compound) else {
return false;
};
let nbt = Tag::Compound(nbt);
if horizon_client::nbt_path::too_deep(&nbt, 0) {
return false;
}
let merged = horizon_client::data_cmd::merged(&old, &nbt);
merged != old && self.apply_entity_nbt(t, merged).is_ok()
}
/// The entity's tags (`Entity.getTags`), as `/tag <entity> list` shows
/// them. API @API:entity_data@.
pub(super) fn native_entity_tags(&self, entity: EntityRef) -> Option<Vec<String>> {
Some(self.tags_of(self.native_entity_target(entity)?))
}
/// Adds tag `tag` (`Entity.addTag`): false when it has it already, holds
/// 1024, its kind can't store tags yet, or the handle is stale. API @API:entity_data@.
pub(super) fn native_entity_add_tag(&mut self, entity: EntityRef, tag: &str) -> bool {
let Some(t) = self.native_entity_target(entity) else {
return false;
};
let mut tags = self.tags_of(t);
if tags.len() >= MAX_TAGS || tags.iter().any(|x| x == tag) {
return false;
}
tags.push(tag.to_owned());
// False, not a silent no-op, for kinds whose tags aren't stored yet
// (ominous item spawners, falling blocks, rockets).
self.set_entity_tags(t, &tags)
}
/// Removes tag `tag` (`Entity.removeTag`): false when it didn't have it.
/// API @API:entity_data@.
pub(super) fn native_entity_remove_tag(&mut self, entity: EntityRef, tag: &str) -> bool {
let Some(t) = self.native_entity_target(entity) else {
return false;
};
let mut tags = self.tags_of(t);
let before = tags.len();
tags.retain(|x| x != tag);
tags.len() != before && self.set_entity_tags(t, &tags)
}
/// The entity's name as plain text (`Entity.getName`): its custom name,
/// a player's name, else its type's name. API @API:entity_data@.
pub(super) fn native_entity_name(&self, entity: EntityRef) -> Option<String> {
Some(self.target_name(self.native_entity_target(entity)?).plain())
}
}
+342 -13
View File
@@ -880,9 +880,10 @@ use super::{
native_mob_draw::{NativeMobRender, NativePlayerRender},
native_panel_recipes::RecipePage,
};
use horizon_assets::item_model::StackView;
use horizon_client::mod_api::{
self, NativeCamera, NativeCommand, NativeDispensed, NativeLifecycleContext, NativeServerPlayer,
RecipeBookCategory, ServerTickPhase, WorldPhase,
self, NativeCamera, NativeCommand, NativeDispensed, NativeLifecycleContext,
NativeNoteBlockChange, NativeServerPlayer, RecipeBookCategory, ServerTickPhase, WorldPhase,
};
use horizon_client::mod_api::{AttachmentResult, Attachments, Holder, Suggestion};
pub(crate) use horizon_client::mod_api::{
@@ -891,7 +892,7 @@ pub(crate) use horizon_client::mod_api::{
};
use horizon_client::{
atlas_sources::qualified,
biome::BiomeColors,
biome::{BiomeColors, SpawnEntry},
block_entities::Pos,
chat::ChatSource,
containers::{
@@ -1272,6 +1273,21 @@ impl GameLevel<'_> {
mod_api::dispatch_block_burn(self, mod_api::mods(), dimension, pos, cancel);
}
/// A player is tuning a note block on the authoritative server, not in
/// client prediction. `context` gives its dimension, position, instrument
/// and old note; `new_note` starts as vanilla's cycled note. Set it to 0
/// through 24; an out-of-range value keeps vanilla's. Cancel to leave the
/// note unchanged and prevent playback. API @API:native_note_block_change@.
#[inline(always)]
pub(super) fn native_note_block_change(
&self,
context: &NativeNoteBlockChange<'_>,
new_note: &mut i32,
cancel: &mut bool,
) {
mod_api::dispatch_note_block_change(self, mod_api::mods(), context, new_note, cancel);
}
/// On an authoritative server, a note block at `pos` is about to sound.
/// `player` is the acting server player when there is one. Set `cancel` to silence it.
/// 248.
@@ -2035,6 +2051,17 @@ impl Game<'_> {
pub(super) fn native_frame(&mut self, elapsed: Duration) {
mod_api::dispatch_frame(self, mod_api::mods(), elapsed);
}
/// Client-side evaluation of a mod's namespaced conditional item model property.
/// Set value only for a property your mod owns; unhandled properties stay false.
/// The borrowed stack is the one being resolved, including nested bundle items.
pub(super) fn native_item_model_property(
&self,
property: &str,
stack: &StackView,
value: &mut bool,
) {
mod_api::dispatch_item_model_property(self, mod_api::mods(), property, stack, value);
}
/// The frame rate this window is about to be held to: Max Framerate after the
/// inactivity throttle and Battery Mode (260 is unlimited). `focused` is whether the
/// window has focus, `minimized` whether it is minimised and `in_world` whether a world
@@ -4198,6 +4225,25 @@ impl Game<'_> {
cancel,
);
}
/// A server player tries to sleep in the bed whose head is at `pos`
/// (NeoForge's `CanPlayerSleepEvent`, vanilla's `ServerPlayer.startSleepInBed`).
/// `problem` is what vanilla found, first problem only: `too_far_away`,
/// `obstructed`, `not_possible_now` (not night or thunder, the bed rule),
/// `not_safe` (monsters near), or `None`. A mod may set `None` to let the
/// player sleep anyway, or one of those names (`other_problem` refuses
/// without a message) to refuse. The spawn point is set before it fires,
/// as in vanilla, only when vanilla found no problem. After it,
/// `native_player_bed_enter` still fires and may cancel. Server-side.
/// API @API:can_player_sleep@.
pub(super) fn native_can_player_sleep(
&mut self,
player: u128,
name: &str,
pos: [i32; 3],
problem: &mut Option<String>,
) {
mod_api::dispatch_can_player_sleep(self, mod_api::mods(), player, name, pos, problem);
}
/// A server player is about to enter a bed after the sleep checks pass.
/// Set `cancel` to keep them out. The player's state is swapped in, but
/// helpers that look them up through `self.lan` may find nothing during a
@@ -4329,6 +4375,23 @@ impl Game<'_> {
pub(super) fn native_player_exp_change(&mut self, player: u128, name: &str, amount: &mut i32) {
mod_api::dispatch_player_exp_change(self, mod_api::mods(), player, name, amount);
}
/// A server player's experience points are about to change through
/// `Player.giveExperiencePoints` (NeoForge's `PlayerXpEvent.XpChange`): an
/// orb pickup after mending, an advancement reward, or `/xp add ... points`.
/// Change `amount` (in points) or set `cancel` to give nothing. An orb or
/// an advancement reward gives nothing for a negative amount. Experience
/// bottles and furnaces make orbs, so they come through the orb path.
/// `native_player_exp_change` is separate and still fires first for an orb
/// pickup. Server-side. API @API:player_xp_change@.
pub(super) fn native_player_xp_change(
&mut self,
player: u128,
name: &str,
amount: &mut i32,
cancel: &mut bool,
) {
mod_api::dispatch_player_xp_change(self, mod_api::mods(), player, name, amount, cancel);
}
/// An item cooldown is about to start for a server player. `item` is the
/// item id and `ticks` its duration. The player's state is not swapped in,
/// so helpers that look them up through `self.lan` may find nothing while a
@@ -5408,6 +5471,29 @@ impl Game<'_> {
mod_api::dispatch_time_skip(self, mod_api::mods(), dimension, from, to, cause, cancel);
}
/// Possible natural spawns at a server position (NeoForge's
/// `LevelEvent.PotentialSpawns`, `NaturalSpawner.mobsAt`). Mods may add or
/// remove entries, or cancel to make the list empty.
/// @API:native_potential_spawns@.
pub(super) fn native_potential_spawns(
&self,
dimension: &str,
category: &str,
pos: [i32; 3],
entries: &mut Vec<SpawnEntry>,
canceled: &mut bool,
) {
mod_api::dispatch_potential_spawns(
self,
mod_api::mods(),
dimension,
category,
pos,
entries,
canceled,
);
}
/// The world's spawn point changed to `pos` and `angle`. 244.
pub(super) fn native_spawn_change(&mut self, pos: [i32; 3], angle: [f32; 2]) {
mod_api::dispatch_spawn_change(self, mod_api::mods(), pos, angle);
@@ -5443,6 +5529,26 @@ impl Game<'_> {
mod_api::dispatch_portal_create(self, mod_api::mods(), dimension, pos);
}
/// A village siege found a spawn position on the server. Set `canceled`
/// to stop the siege. There is one siege per dimension.
/// @API:native_village_siege@.
pub(super) fn native_village_siege(
&mut self,
dimension: &str,
player: u128,
attempted_pos: [f64; 3],
canceled: &mut bool,
) {
mod_api::dispatch_village_siege(
self,
mod_api::mods(),
dimension,
player,
attempted_pos,
canceled,
);
}
/// A raid wave has been spawned at `pos`; `wave` is its one-based wave
/// number and `raiders` counts the raiders, not the mobs they ride. 294.
pub(super) fn native_raid_spawn_wave(
@@ -5551,6 +5657,81 @@ impl Game<'_> {
);
}
/// A mob or server player is about to die (NeoForge's `LivingDeathEvent`,
/// vanilla's `LivingEntity.die`): its health has run out, and the loot,
/// experience and death message have not run. `entity` is its id and
/// `kind` its type (`minecraft:zombie`, `minecraft:player`); `damage_type`
/// names the last blow (`minecraft:player_attack`) and `attacker` is the
/// mob that dealt it, 0 for none or a player. Set `cancel` to keep it alive:
/// Horizon decides death by health, so it stays at 1 health. The hook
/// fires once per death attempt. Armor stands, boats and other non-mob
/// entities are not told. API @API:living_death@.
pub(super) fn native_living_death(
&mut self,
entity: u32,
kind: &str,
damage_type: &str,
attacker: u32,
cancel: &mut bool,
) {
mod_api::dispatch_living_death(
self,
mod_api::mods(),
entity,
kind,
damage_type,
attacker,
cancel,
);
}
/// A mob is about to convert into another type (NeoForge's
/// `LivingConversionEvent.Pre`, vanilla's `Mob.convertTo`): a zombie
/// drowning, a piglin or hoglin in the Overworld, a tadpole growing, a
/// mooshroom sheared by a dispenser, a pig or villager struck by
/// lightning. `outcome` is the new type. Set `cancel` to keep the mob as
/// it is; a drowning zombie starts its timers again. Not told: a
/// mooshroom a player shears, a zombie villager's cure, a villager turned
/// by a zombie. API @API:living_conversion_pre@.
pub(super) fn native_living_conversion_pre(
&mut self,
entity: u32,
kind: &str,
outcome: &str,
cancel: &mut bool,
) {
mod_api::dispatch_living_conversion_pre(
self,
mod_api::mods(),
entity,
kind,
outcome,
cancel,
);
}
/// A mob converted into another type (NeoForge's
/// `LivingConversionEvent.Post`): `entity` is gone, `converted` took its
/// place. Fires with `native_entity_transform`, after the new mob joined
/// the level, for the conversions `native_living_conversion_pre` tells.
/// API @API:living_conversion_post@.
pub(super) fn native_living_conversion_post(
&mut self,
entity: u32,
kind: &str,
converted: u32,
converted_kind: &str,
) {
mod_api::dispatch_living_conversion_post(
self,
mod_api::mods(),
entity,
kind,
converted,
converted_kind,
);
}
/// A thrown splash or lingering potion applied its hit effects.
/// 287.
pub(super) fn native_potion_splash(&mut self, item: &str, pos: [f64; 3]) {
@@ -6397,12 +6578,16 @@ impl Game<'_> {
}
/// A player was saved to the world's store (NeoForge's
/// `PlayerEvent.SaveToFile`, `PlayerList.save`): when they leave or the
/// world closes, when the dedicated server saves everyone, and for an
/// integrated server's owner when the world saves them. LAN guests are
/// not heard on integrated autosaves. Once for each save. `directory`
/// world closes, when a dedicated or integrated server saves its guests,
/// and for an integrated server's owner when the world saves them.
/// Once for each save. `directory`
/// is the world's player directory (`players/data`): write your own file
/// there, named `<uuid>.<suffix>`, never the player's `.dat`. The player
/// may be leaving, so no player's state is swapped in. Not cancellable.
/// Autosave snapshots are written asynchronously: this notification runs
/// on a later server tick after a successful write, possibly after WorldSaved.
/// Live state may have advanced beyond the saved snapshot; a process exit
/// before completions are drained can prevent notification delivery.
pub(super) fn native_player_save_to_file(
&mut self,
player: u128,
@@ -7151,6 +7336,41 @@ impl Game<'_> {
canceled,
);
}
/// A player's weapon hit on a mob, before `Mob::damage_with` borrows the
/// victim: melee, sweep and spear hits, including LAN guests. Unlike v1,
/// this has a host and snapshots of the weapon, victim position/view and
/// DamageSource position. The acting player's network id is `causing`
/// (also the direct entity). Weapon enchantments include their levels.
/// Runs after the victim's invulnerability check (an invulnerable or dead victim fires nothing) and before v1; cancellation refuses
/// delivery. Other damage routes still only fire v1. API @API:living_incoming_damage_v2@.
pub(super) fn native_living_incoming_damage_v2(
&mut self,
entity: i32,
kind: &str,
damage_type: &str,
causing: i32,
source_position: Option<[f64; 3]>,
victim_position: [f64; 3],
victim_view: [f64; 3],
weapon: Option<&ItemStack>,
amount: &mut f32,
canceled: &mut bool,
) {
mod_api::dispatch_living_incoming_damage_v2(
self,
mod_api::mods(),
entity,
kind,
damage_type,
causing,
source_position,
victim_position,
victim_view,
weapon,
amount,
canceled,
);
}
/// `LivingEntity.knockback` is about to push entity `entity` of type `kind`
/// (`minecraft:zombie`, `minecraft:player`) `strength` away from the direction (`x`, `z`),
/// on the raw strength, before the entity's knockback resistance takes its share and its motion
@@ -7198,6 +7418,38 @@ impl Game<'_> {
canceled,
);
}
/// LivingEntity.knockback before resistance and motion, after the unchanged v1 hook.
/// Cancellation from either hook wins. Attacker is the attacking entity's network id,
/// falling back to the victim's last hurt-by mob, or 0 when unknown; `attacker_main` and
/// `attacker_off` are what it holds (a player's selected and off-hand stacks, a mob's main hand), none when unknown. Player attackers
/// include the guest whose action runs. Includes mob melee and armor-stand sweeps, not
/// direct velocity pushes (explosions, Punch arrows, hoglin throws). Non-finite changes
/// are restored before resistance. Server-side, without self: damage borrows the victim.
/// API @API:living_knockback_v2@.
pub(super) fn native_living_knockback_v2(
entity: i32,
kind: &str,
attacker: i32,
attacker_main: Option<&ItemStack>,
attacker_off: Option<&ItemStack>,
strength: &mut f64,
x: &mut f64,
z: &mut f64,
canceled: &mut bool,
) {
mod_api::dispatch_living_knockback_v2(
mod_api::mods(),
entity,
kind,
attacker,
attacker_main,
attacker_off,
strength,
x,
z,
canceled,
);
}
/// Whether a mod hears projectiles' impacts (`native_projectile_impact`):
/// set `hears`. Asked once a server tick before the projectiles fly; only
/// then does the tick keep the copy of each flying projectile that a
@@ -7309,10 +7561,70 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// `health::knockback_from` that, while a native mod listens, also records what the attacker
/// holds (the player's selected and off-hand stacks, a mob's main hand) for
/// `living_knockback_v2`. A nested scope for the same attacker keeps what the outer recorded.
pub(super) fn knockback_scope(&self, attacker: i32) -> impl Drop + use<> {
use horizon_client::health::{knockback_attacker, knockback_from_holding, knockback_held};
if mod_api::mods().is_empty() {
return knockback_from_holding(attacker, [None, None]);
}
let held = if attacker != 0 && attacker == knockback_attacker() {
knockback_held()
} else if self
.player
.as_ref()
.is_some_and(|p| p.entity_id == attacker)
{
[self.stack_in(self.selected).cloned(), self.offhand.clone()]
} else if let Some(mob) = self.mobs.iter().find(|m| m.id as i32 == attacker) {
[mob.extra.main_hand_stack().cloned(), None]
} else {
[None, None]
};
knockback_from_holding(attacker, held)
}
/// [`Self::knockback_scope`] for the attacker a damage source names: the player, a mob, or
/// (none) whoever the enclosing scope already names.
pub(super) fn knockback_scope_for(&self, attacker: Option<Who>) -> impl Drop + use<> {
self.knockback_scope(match attacker {
Some(Who::Player) => self.player.as_ref().map_or(0, |p| p.entity_id),
Some(Who::Mob(id)) => id as i32,
None => horizon_client::health::knockback_attacker(),
})
}
}
/// `native_living_knockback` for the knockback code (`health::KNOCKBACK`): whether it
/// canceled the push. Its one caller. The pushed entity and the source are named as the
/// damage hook names them.
pub(super) fn knockback(pushed: Hurting<'_>, knock: &mut Knock) -> bool {
if mod_api::mods().is_empty() {
return false;
}
let attacker = match &pushed {
Hurting::Mob(mob, damage) => match damage.attacker.or(mob.ai.last_hurt_by) {
Some(Who::Mob(id)) => id as i32,
_ => horizon_client::health::knockback_attacker(),
},
Hurting::Player(_, source)
| Hurting::Dragon(_, source)
| Hurting::ArmorStand(_, source) => {
if source.causing != 0 {
source.causing
} else {
horizon_client::health::knockback_attacker()
}
}
};
// No `matches!`: horizon-mixins refuses macros in a hook point's body.
#[allow(clippy::match_like_matches_macro)]
let stand = if let Hurting::ArmorStand(..) = &pushed {
true
} else {
false
};
let (entity, kind, source) = hurt_server_args(pushed);
let HurtSource {
damage_type,
@@ -7320,18 +7632,33 @@ pub(super) fn knockback(pushed: Hurting<'_>, knock: &mut Knock) -> bool {
direct,
} = source;
let mut canceled = false;
Game::native_living_knockback(
if !stand {
Game::native_living_knockback(
entity,
kind,
damage_type,
causing,
direct,
&mut knock.strength,
&mut knock.x,
&mut knock.z,
&mut canceled,
);
}
let mut canceled_v2 = false;
let held = horizon_client::health::knockback_held();
Game::native_living_knockback_v2(
entity,
kind,
damage_type,
causing,
direct,
attacker,
held[0].as_ref(),
held[1].as_ref(),
&mut knock.strength,
&mut knock.x,
&mut knock.z,
&mut canceled,
&mut canceled_v2,
);
canceled
canceled || canceled_v2
}
pub(super) fn block_contact(
@@ -7394,6 +7721,7 @@ fn hurt_server_args(hurting: Hurting<'_>) -> (i32, &str, HurtSource<'_>) {
Hurting::Player(entity, source) => (entity, "minecraft:player", source),
Hurting::Mob(mob, damage) => (mob.id as i32, mob.kind.id.as_str(), mob_source(damage)),
Hurting::Dragon(entity, source) => (entity, "minecraft:ender_dragon", source),
Hurting::ArmorStand(entity, source) => (entity, "minecraft:armor_stand", source),
}
}
/// A mob's damage as its `DamageSource`: the causing entity is known when it
@@ -7882,7 +8210,8 @@ impl Game<'_> {
id: e.id,
position: [e.position.x, e.position.y, e.position.z],
stack: e.stack.clone(),
pickup_delay: e.pickup_delay,
// API 42 exposes an unsigned delay; keep its existing projection.
pickup_delay: e.pickup_delay.clamp(0, i64::from(u32::MAX)) as u32,
})
.collect()
}
@@ -225,6 +225,9 @@ impl Game<'_> {
if from_uuid == to_uuid && from_kind == to_kind {
return Err("the same inventory".into());
}
// A just-disconnected player can already be absent from the hub.
// Drain before reading or replacing their offline file.
let _ = self.finish_player_autosaves(true);
let a = self.native_inventory_side(from_uuid)?;
let b = if from_uuid == to_uuid {
None
@@ -0,0 +1,100 @@
//! A level's blocks and time for native server mods, through a `LevelRef`
//! (docs/native-mods.md, "Entity and level handles", #7387). They read any
//! level the server has loaded, the one in place or another, and return
//! `None` (or `false`) on a client, for a handle of an earlier open, or for a
//! block in a chunk that isn't loaded.
use super::Game;
use horizon_client::mod_api::LevelRef;
use horizon_client::world::World;
impl Game<'_> {
/// The world a handle names now, or None for a stale or foreign one.
fn native_level_world(&self, level: LevelRef) -> Option<&World> {
if self.remote.is_some() || self.native_lifecycle.epoch() != Some(level.epoch()) {
return None;
}
if level.dimension() == self.dim.id() {
return Some(&self.world);
}
let dim = self
.levels
.parked_dims()
.into_iter()
.find(|d| d.id() == level.dimension())?;
self.levels.parked_world(dim)
}
/// The state at `pos`, if its chunk is loaded and `y` is inside the world.
fn native_level_state(
&self,
level: LevelRef,
pos: [i32; 3],
) -> Option<horizon_world::registry::BlockStateId> {
let world = self.native_level_world(level)?;
let [x, y, z] = pos;
let inside = (world.bottom_y()..world.top_y()).contains(&y);
(inside && world.chunks.contains_key(&(x >> 4, z >> 4))).then(|| world.block(x, y, z))
}
/// The block state at `pos` (`Level.getBlockState`) as `/setblock` takes
/// it: `minecraft:oak_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]`.
/// None for a chunk that isn't loaded or a `y` outside the world. API
/// @API:level_reads@.
pub(super) fn native_level_block_state(
&self,
level: LevelRef,
pos: [i32; 3],
) -> Option<String> {
self.native_level_state(level, pos)
.map(|state| self.state_string(state))
}
/// The block at `pos` without its properties: `minecraft:oak_stairs`.
/// None as for `block_state`. API @API:level_reads@.
pub(super) fn native_level_block(&self, level: LevelRef, pos: [i32; 3]) -> Option<String> {
self.native_level_state(level, pos)
.map(|state| self.res.facts().get(state).name.to_string())
}
/// Property `key` of the block state at `pos` (`BlockState.getValue`):
/// `east` for `facing`. None for a block without it, or as for
/// `block_state`. API @API:level_reads@.
pub(super) fn native_level_block_property(
&self,
level: LevelRef,
pos: [i32; 3],
key: &str,
) -> Option<String> {
let state = self.native_level_state(level, pos)?;
self.pack
.assets
.registry
.state(state)?
.properties
.get(key)
.cloned()
}
/// Whether `pos` is inside the world's height and its chunk is loaded
/// (`Level.isLoaded`, which is false outside `isInValidBounds`): a block
/// read or write there finds the world. API @API:level_reads@.
pub(super) fn native_level_is_loaded(&self, level: LevelRef, pos: [i32; 3]) -> bool {
self.native_level_world(level).is_some_and(|w| {
(w.bottom_y()..w.top_y()).contains(&pos[1])
&& w.chunks.contains_key(&(pos[0] >> 4, pos[2] >> 4))
})
}
/// The lowest block `y` and the height of the level
/// (`LevelHeightAccessor.getMinY`, `getHeight`). API @API:level_reads@.
pub(super) fn native_level_height(&self, level: LevelRef) -> Option<(i32, i32)> {
self.native_level_world(level)
.map(|w| (w.shape.min_y, w.shape.height))
}
/// The ticks the server has run (`Level.getGameTime`), shared by every
/// level as in vanilla. API @API:level_reads@.
pub(super) fn native_level_game_time(&self, level: LevelRef) -> Option<i64> {
self.native_level_world(level)?;
Some(self.world.game_time)
}
/// The time of day in ticks (`Level.getOverworldClockTime`), shared by
/// every level; it keeps counting past 24000. API @API:level_reads@.
pub(super) fn native_level_day_time(&self, level: LevelRef) -> Option<i64> {
self.native_level_world(level)?;
Some(self.world.day_time)
}
}
@@ -0,0 +1,200 @@
//! Living entities for native server mods, through a `LivingRef`
//! (docs/native-mods.md, "Entity and level handles", #7387): the equipment
//! of mobs, players and armor stands. Reads return `None`, writes false, for
//! a stale handle or on a client.
use super::Game;
use super::commands::{Target, items::Owner};
use horizon_client::first_person::Hand;
use horizon_client::items::ItemStack;
use horizon_client::mod_api::{EntityRef, EquipmentSlot, LivingRef};
/// A player's armour slots (`armor`, head first) by equipment slot.
fn armor_index(slot: EquipmentSlot) -> Option<usize> {
match slot {
EquipmentSlot::Head => Some(0),
EquipmentSlot::Chest => Some(1),
EquipmentSlot::Legs => Some(2),
EquipmentSlot::Feet => Some(3),
_ => None,
}
}
/// A player's stack in `slot`, from its inventory, armour and off hand.
fn player_slot(
inventory: &[Option<ItemStack>],
selected: usize,
armor: &[Option<ItemStack>; 4],
offhand: &Option<ItemStack>,
slot: EquipmentSlot,
) -> Option<ItemStack> {
match slot {
EquipmentSlot::MainHand => inventory.get(selected).cloned().flatten(),
EquipmentSlot::OffHand => offhand.clone(),
_ => armor[armor_index(slot)?].clone(),
}
}
/// The `/item` slot name of an equipment slot.
fn slot_name(slot: EquipmentSlot) -> &'static str {
match slot {
EquipmentSlot::MainHand => "weapon.mainhand",
EquipmentSlot::OffHand => "weapon.offhand",
EquipmentSlot::Head => "armor.head",
EquipmentSlot::Chest => "armor.chest",
EquipmentSlot::Legs => "armor.legs",
EquipmentSlot::Feet => "armor.feet",
EquipmentSlot::Body => "armor.body",
EquipmentSlot::Saddle => "saddle",
}
}
impl Game<'_> {
/// What a living handle names now: a player, a mob, an armor stand or the dragon.
fn native_living_target(&self, living: LivingRef) -> Option<Target> {
match self.native_entity_target(living.entity())? {
t @ (Target::Player
| Target::Guest(_)
| Target::Mob(_)
| Target::Stand(_)
| Target::Dragon(_)) => Some(t),
_ => None,
}
}
/// The entity as a living entity (vanilla's `LivingEntity`: a mob, a
/// player, an armor stand or the dragon), or None for another kind or a
/// stale handle. API @API:living_refs@.
pub(super) fn native_living(&self, entity: EntityRef) -> Option<LivingRef> {
let living = LivingRef::new(entity);
self.native_living_target(living).map(|_| living)
}
/// The stack in equipment slot `slot` (`LivingEntity.getItemBySlot`):
/// None when it is empty, the entity has no such slot (a player has no
/// body or saddle slot), or the handle is stale. API @API:living_refs@.
pub(super) fn native_living_equipment(
&self,
living: LivingRef,
slot: EquipmentSlot,
) -> Option<ItemStack> {
match self.native_living_target(living)? {
Target::Player => player_slot(
&self.inventory,
self.selected,
&self.armor,
&self.offhand,
slot,
),
Target::Guest(conn) => {
let g = self.command_guest(conn)?;
player_slot(&g.inventory, g.selected, &g.armor, &g.offhand, slot)
}
Target::Mob(id) => {
self.mobs.iter().find(|m| m.id == id)?.equipment_stacks()[slot as usize].clone()
}
Target::Stand(id) => self
.armor_stands
.iter()
.find(|a| a.id == id)?
.equipment
.get(slot as usize)?
.clone(),
_ => None,
}
}
/// The stack in hand `hand` (`LivingEntity.getItemInHand`). API
/// @API:living_refs@.
pub(super) fn native_living_hand_item(
&self,
living: LivingRef,
hand: Hand,
) -> Option<ItemStack> {
let slot = match hand {
Hand::Main => EquipmentSlot::MainHand,
Hand::Off => EquipmentSlot::OffHand,
};
self.native_living_equipment(living, slot)
}
/// Puts `stack` (None: empties it) in equipment slot `slot`
/// (`LivingEntity.setItemSlot`), as `/item replace entity` does. False
/// when the entity has no such slot: a player has no body or saddle
/// slot, a horse no head slot, a zombie no body slot, the dragon none.
/// Horizon keeps no mob saddle here yet, and a mob holds one item outside
/// its main hand, as `/item` does. API @API:living_refs@.
pub(super) fn native_living_set_equipment(
&mut self,
living: LivingRef,
slot: EquipmentSlot,
stack: Option<ItemStack>,
) -> bool {
let Some(t) = self.native_living_target(living) else {
return false;
};
// A mob keeps one of its head and body slots: the body for a horse's
// armour or a llama's carpet, the head for the rest.
if let Target::Mob(id) = t {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return false;
};
let body = horizon_client::mobs::drops::head_is_body(m.species());
if matches!(
(slot, body),
(EquipmentSlot::Head, true) | (EquipmentSlot::Body, false)
) {
return false;
}
}
// ItemStack.isEmpty: air or a zero count empties the slot, as `/item` does.
let stack = stack.filter(|s| &*s.item != "minecraft:air" && s.count > 0);
match self
.slots_named(&Owner::Entity(t), slot_name(slot))
.as_slice()
{
[s] => self.slot_set(s, stack),
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_players_slots_come_from_its_inventory_armour_and_off_hand() {
let stack = |id: &str| Some(ItemStack::new(id, 1));
let inventory = vec![stack("minecraft:stone"), stack("minecraft:dirt")];
let armor = [
stack("minecraft:iron_helmet"),
None,
None,
stack("minecraft:iron_boots"),
];
let offhand = stack("minecraft:shield");
let at = |slot| player_slot(&inventory, 1, &armor, &offhand, slot).map(|s| s.item);
assert_eq!(
at(EquipmentSlot::MainHand).as_deref(),
Some("minecraft:dirt")
);
assert_eq!(
at(EquipmentSlot::OffHand).as_deref(),
Some("minecraft:shield")
);
assert_eq!(
at(EquipmentSlot::Head).as_deref(),
Some("minecraft:iron_helmet")
);
assert_eq!(
at(EquipmentSlot::Feet).as_deref(),
Some("minecraft:iron_boots")
);
assert_eq!(at(EquipmentSlot::Chest), None);
assert_eq!(at(EquipmentSlot::Body), None);
}
#[test]
fn every_equipment_slot_has_its_own_item_command_name() {
let names = EquipmentSlot::ALL.map(slot_name);
for (i, n) in names.iter().enumerate() {
assert!(!names[..i].contains(n), "{n} named twice");
}
}
}
@@ -34,11 +34,13 @@ use std::collections::HashMap;
#[derive(Default)]
pub(super) struct SpawnScratch {
pub(super) candidates: Vec<(i32, i32)>,
pub(super) seen: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
pub(super) seen: horizon_client::fast_hash::FxHashMap<(i32, i32), usize>,
pub(super) close: Vec<bool>,
}
/// A player in the level in place, as spawning and despawning see it.
pub(super) struct LevelPlayer {
pub(super) uuid: u128,
pub(super) position: Vec3,
pub(super) bounds: Aabb,
pub(super) alive: bool,
@@ -84,6 +86,108 @@ pub(super) fn nearest_player(
best.map(|(p, _)| p)
}
/// [`nearest_player`] for many positions: the non-spectators by the 64-block
/// square they stand in, searched ring by ring outwards from the position.
/// Every mob asks each tick, and with players spread over the map that was a
/// look at every player for every mob.
pub(super) struct NearestPlayers<'p> {
players: &'p [LevelPlayer],
cells: horizon_client::fast_hash::FxHashMap<(i64, i64), Vec<usize>>,
/// The cells' bounding box: (x0, z0, x1, z1).
bounds: (i64, i64, i64, i64),
/// Few players, or one too far out for cells: every question scans.
scan: bool,
}
impl<'p> NearestPlayers<'p> {
const CELL: f64 = 64.0;
/// Further out than any world border: such positions are answered by a scan.
const FAR: f64 = 1.0e9;
pub(super) fn new(players: &'p [LevelPlayer]) -> Self {
let mut cells = horizon_client::fast_hash::FxHashMap::<(i64, i64), Vec<usize>>::default();
let mut bounds = (i64::MAX, i64::MAX, i64::MIN, i64::MIN);
let mut scan = players.len() <= 8;
for (i, p) in players.iter().enumerate().filter(|(_, p)| !p.spectator) {
if !(p.position.x.abs() < Self::FAR && p.position.z.abs() < Self::FAR) {
// Past any world border (or not a number): scans answer.
scan = true;
break;
}
let key = Self::cell(p.position.x, p.position.z);
cells.entry(key).or_default().push(i);
bounds = (
bounds.0.min(key.0),
bounds.1.min(key.1),
bounds.2.max(key.0),
bounds.3.max(key.1),
);
}
Self {
players,
cells,
bounds,
scan,
}
}
fn cell(x: f64, z: f64) -> (i64, i64) {
(
(x / Self::CELL).floor() as i64,
(z / Self::CELL).floor() as i64,
)
}
/// The same player [`nearest_player`] finds: the nearest, the first in
/// the list among equals.
pub(super) fn nearest(&self, x: f64, y: f64, z: f64) -> Option<&'p LevelPlayer> {
if self.scan || !(x.abs() < Self::FAR && z.abs() < Self::FAR) {
return nearest_player(self.players, x, y, z);
}
if self.cells.is_empty() {
return None;
}
let (qx, qz) = Self::cell(x, z);
let (x0, z0, x1, z1) = self.bounds;
let last = (qx - x0)
.abs()
.max((qx - x1).abs())
.max((qz - z0).abs())
.max((qz - z1).abs());
let mut best: Option<(usize, f64)> = None;
let mut looked = 0usize;
for ring in 0..=last {
let mut look = |kx: i64, kz: i64| {
looked += 1;
for &i in self.cells.get(&(kx, kz)).into_iter().flatten() {
let d = self.players[i].distance_sqr(x, y, z);
if best.is_none_or(|(b, bd)| d < bd || d == bd && i < b) {
best = Some((i, d));
}
}
};
if ring == 0 {
look(qx, qz);
} else {
for k in -ring..=ring {
look(qx + k, qz - ring);
look(qx + k, qz + ring);
}
for k in 1 - ring..ring {
look(qx - ring, qz + k);
look(qx + ring, qz + k);
}
}
// Everyone in a further ring is more than `ring` cells away on one
// axis, so further than the one found.
let bound = ring as f64 * Self::CELL;
if best.is_some_and(|(_, d)| d <= bound * bound) {
break;
}
if looked > self.players.len() * 4 {
return nearest_player(self.players, x, y, z);
}
}
best.map(|(i, _)| &self.players[i])
}
}
/// The game level as natural spawning reads it.
pub(super) struct GameLevel<'g, 'a> {
pub(super) game: &'g Game<'a>,
@@ -233,6 +337,22 @@ impl SpawnLevel for GameLevel<'_, '_> {
}
}
impl NaturalLevel for GameLevel<'_, '_> {
fn spawn_hooks(&self) -> bool {
!horizon_client::mod_api::mods().is_empty()
}
fn potential_spawns(&self, category: Category, pos: [i32; 3], entries: &mut Vec<SpawnEntry>) {
let mut canceled = false;
self.game.native_potential_spawns(
self.game.dim.id(),
category.name(),
pos,
entries,
&mut canceled,
);
if canceled {
entries.clear();
}
}
fn min_y(&self) -> i32 {
self.w.world.bottom_y()
}
@@ -247,7 +367,7 @@ impl NaturalLevel for GameLevel<'_, '_> {
}
}
fn has_ceiling(&self) -> bool {
self.game.dim == Dim::Nether
self.w.world.shape.has_ceiling
}
fn mob_type(&self, entity: &str) -> Option<(Species, f32, f32, Category)> {
let t = self.game.res.mob_types().get(entity)?;
@@ -450,6 +570,10 @@ impl<'a> Game<'a> {
/// `level_players` one at a time, without collecting them.
fn level_players_iter(&self) -> impl Iterator<Item = LevelPlayer> + '_ {
let local = self.player_view().map(|p| LevelPlayer {
uuid: self
.level
.player_uuid
.map_or(0, horizon_client::lan::playerdata::ints_uuid),
position: p.position,
bounds: p.bounds,
alive: p.alive,
@@ -462,6 +586,7 @@ impl<'a> Game<'a> {
.iter()
.filter(|g| h.hub.here(g))
.map(|g| LevelPlayer {
uuid: g.uuid,
position: Vec3::new(g.position[0], g.position[1], g.position[2]),
bounds: super::lan_mobs::guest_bounds(g),
alive: !g.dead,
@@ -492,6 +617,8 @@ impl<'a> Game<'a> {
let seen = &mut scratch.seen;
seen.clear();
seen.reserve(289 * players.len());
let close = &mut scratch.close;
close.clear();
for p in players {
let (pcx, pcz) = (
p.position.x.floor() as i32 >> 4,
@@ -500,20 +627,25 @@ impl<'a> Game<'a> {
for dx in -8..=8 {
for dz in -8..=8 {
let key = (pcx + dx, pcz + dz);
if seen.insert(key) {
let i = *seen.entry(key).or_insert_with(|| {
let i = candidates.len();
candidates.push(key);
}
close.push(false);
i
});
close[i] |= close_enough_for_spawning(p, key);
}
}
}
let count = candidates.len() as i32;
let levels = self.tickets.levels();
let mut i = 0;
candidates.retain(|&(cx, cz)| {
levels.loaded_ticking(cx, cz)
&& super::ticking_chunk(&self.world, cx, cz)
&& players
.iter()
.any(|p| close_enough_for_spawning(p, (cx, cz)))
let keep = close[i]
&& levels.loaded_ticking(cx, cz)
&& super::ticking_chunk(&self.world, cx, cz);
i += 1;
keep
});
count
}
@@ -677,3 +809,69 @@ impl<'a> Game<'a> {
added
}
}
#[cfg(test)]
mod tests {
use super::*;
fn player(x: f64, y: f64, z: f64, spectator: bool) -> LevelPlayer {
LevelPlayer {
uuid: 0,
position: Vec3::new(x, y, z),
bounds: Aabb::new(Vec3::new(x, y, z), Vec3::new(x, y, z)),
alive: true,
creative: false,
spectator,
}
}
/// The grid finds the same player a look at every one finds, the first of
/// two at the same distance included, and never a spectator.
#[test]
fn nearest_players_find_what_a_scan_finds() {
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
let mut next = |m: f64| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed % 1_000_000) as f64 / 1_000_000.0 * 2.0 * m - m
};
let mut players: Vec<LevelPlayer> = (0..150)
.map(|i| player(next(3_000.0), 64.0 + next(60.0), next(3_000.0), i % 11 == 0))
.collect();
// Two at the same distance from the origin, the second one first in no cell order.
players.push(player(300.0, 64.0, 0.0, false));
players.push(player(-300.0, 64.0, 0.0, false));
players.push(player(0.0, 64.0, 300.0, false));
let grid = NearestPlayers::new(&players);
let index = |p: Option<&LevelPlayer>| p.map(|p| std::ptr::from_ref(p) as usize);
let mut probes: Vec<(f64, f64, f64)> = (0..400)
.map(|_| (next(3_000.0), next(100.0), next(3_000.0)))
.collect();
probes.extend([(0.0, 64.0, 0.0), (1.0e12, 0.0, 0.0), (f64::NAN, 0.0, 0.0)]);
for (x, y, z) in probes {
assert_eq!(
index(grid.nearest(x, y, z)),
index(nearest_player(&players, x, y, z)),
"({x}, {y}, {z})"
);
}
players.push(player(NearestPlayers::FAR * 2.0, 64.0, 0.0, false));
let grid = NearestPlayers::new(&players);
assert!(grid.scan);
for x in [0.0, NearestPlayers::FAR * 2.0] {
assert_eq!(
index(grid.nearest(x, 64.0, 0.0)),
index(nearest_player(&players, x, 64.0, 0.0))
);
}
let spectators: Vec<LevelPlayer> = (0..20)
.map(|i| player(f64::from(i) * 100.0, 0.0, 0.0, true))
.collect();
assert!(
NearestPlayers::new(&spectators)
.nearest(0.0, 0.0, 0.0)
.is_none()
);
}
}
@@ -91,6 +91,13 @@ pub(super) struct Portals {
/// tick, in their level, with their entry: they carry their riders
/// (`tick_server_travel`).
carrying: Vec<(Dim, u32, BlockPos)>,
/// What a carrying minecart moved by when its portal was handled, at the
/// start of its tick: its motion is what crosses (`arrive_vehicle`).
pub(super) carried_motion: HashMap<u32, Vec3>,
/// Where a carrying vehicle stood then: its plan is taken from there
/// (`vehicle_plan`), as vanilla's is, not from where its rider's own tick
/// has moved it on to since.
pub(super) carried_at: HashMap<u32, Vec3>,
loaded: bool,
dirty: bool,
/// Per-chunk portal palette masks, kept across ticks (portal_sections).
@@ -647,6 +654,14 @@ impl Game<'_> {
// `Entity.teleportCrossDimension` carries the passengers: a
// vehicle server players ride takes them along.
if self.guest_rides(id) {
if let Some(c) = self.carts.iter().find(|c| c.id == id) {
self.portals.carried_motion.insert(id, c.velocity);
self.portals.carried_at.insert(id, c.position);
} else if let Some(b) = self.boats.iter().find(|b| b.id == id) {
self.portals.carried_at.insert(id, b.position);
} else if let Some(m) = self.mobs.iter().find(|m| m.id == id) {
self.portals.carried_at.insert(id, m.position);
}
self.portals.carrying.push((self.dim, id, entry));
} else {
self.send_through_portal(id, entry);
@@ -734,40 +749,58 @@ impl Game<'_> {
/// minecart, like a mob, lands in the middle of the exit portal's depth
/// (`AbstractBoat` and `AbstractMinecart.getRelativePortalPosition`).
pub(super) fn vehicle_plan(&self, id: u32, entry: BlockPos) -> Option<Plan> {
let at = |own: Vec3| self.portals.carried_at.get(&id).copied().unwrap_or(own);
if let Some(m) = self.mobs.iter().find(|m| m.id == id) {
return Some(self.entity_plan(entry, m.position, m.width(), m.height(), true));
return Some(self.entity_plan(entry, at(m.position), m.width(), m.height(), true));
}
if let Some(b) = self.boats.iter().find(|b| b.id == id) {
let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32);
return Some(self.entity_plan(entry, b.position, width, height, true));
return Some(self.entity_plan(entry, at(b.position), width, height, true));
}
let c = self.carts.iter().find(|c| c.id == id)?;
let (width, height) = (
super::minecarts::WIDTH as f32,
super::minecarts::HEIGHT as f32,
);
Some(self.entity_plan(entry, c.position, width, height, true))
Some(self.entity_plan(entry, at(c.position), width, height, true))
}
/// `NetherPortalBlock.entityInside` and `EndPortalBlock.entityInside`
/// for the items, mobs, empty boats and minecarts, and arrows that moved
/// this tick: the portal blocks their box swept through (an End portal's
/// transition time is 0 too).
pub(super) fn find_entity_portals(&mut self) {
self.find_portals(None);
}
/// The same for the vehicles server players ride, once `tick_guests` has
/// moved them: a ridden vehicle moves with its rider, after the other
/// entities were swept, so without this pass its portal is found a tick
/// late, from where it has already moved on to.
pub(super) fn find_ridden_vehicle_portals(&mut self, ridden: &[u32]) {
if !ridden.is_empty() {
self.find_portals(Some(ridden));
}
}
fn find_portals(&mut self, only: Option<&[u32]>) {
if self.remote.is_some() {
return;
}
let ridden = if only.is_none() {
self.guest_vehicles()
} else {
Vec::new()
};
// A trident or rocket in flight gets its id here, before the walk.
for t in self
.tridents
.iter_mut()
.filter(|t| !t.in_ground || t.previous != t.position)
.filter(|t| only.is_none() && (!t.in_ground || t.previous != t.position))
{
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut t.id);
}
for r in self
.rockets
.iter_mut()
.filter(|r| r.attached_to.is_none() && !r.attached)
.filter(|r| only.is_none() && r.attached_to.is_none() && !r.attached)
{
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut r.id);
}
@@ -783,6 +816,13 @@ impl Game<'_> {
continue;
}
let (id, to) = (entity.id(), entity.position());
let swept_later = match only {
Some(ids) => !ids.contains(&id),
None => ridden.contains(&id),
};
if swept_later {
continue;
}
let from = entity
.portal_from()
.unwrap_or_else(|| self.portals.before.get(&id).copied().unwrap_or(to));
@@ -88,14 +88,18 @@ impl Game<'_> {
return;
};
self.next_entity += 1;
let id = self.next_entity;
let saved = horizon_client::entity_nbt::uuid(&tag);
// Its saved UUID and the attachments saved under it (#7398).
self.remember_uuid(id, saved, &tag);
let uuid = self.uuid_for(id);
self.ominous_spawners.push(OminousItemSpawner {
id: self.next_entity,
id,
item,
position: [at.x, at.y, at.z],
spawn_item_after,
tick_count: 0,
uuid: horizon_client::entity_nbt::uuid(&tag)
.unwrap_or_else(horizon_client::save::random_uuid),
uuid,
raw: Some(Box::new(tag)),
});
}
+515 -334
View File
@@ -25,17 +25,18 @@ use std::{
/// What Horizon remembers about loaded entities to save them faithfully.
#[derive(Default)]
pub struct EntityMemory {
/// UUIDs of mobs and item entities by Horizon entity ID.
/// UUIDs of entities by Horizon entity ID: the one each was loaded with,
/// or made up once something needed it (a save, a native mod's attachment).
pub(super) uuids: HashMap<u32, [i32; 4]>,
/// The compound each mob or item entity was loaded with.
pub(super) raw: HashMap<u32, Tag>,
/// Entities of types Horizon does not simulate, kept per chunk and
/// written back unchanged.
unknown: HashMap<(i32, i32), Vec<Tag>>,
/// Entities without a Horizon entity ID (orbs, arrows, falling blocks, crystals,
/// the dragon): the UUID and compound they were loaded with, found again by
/// type and position (an entity that has moved since loading is saved afresh).
anon: HashMap<(String, [u64; 3]), (Vec3, Option<[i32; 4]>, Tag)>,
/// The compound of orbs, arrows, falling blocks, tridents, crystals and the
/// dragon as they were loaded, found again by type and position (one that
/// has moved since loading is saved afresh); their UUIDs are in `uuids`.
anon: HashMap<(String, [u64; 3]), (Vec3, Tag)>,
/// UUIDs of the vehicles `restore_saved_vehicle` brought in from the
/// player's file: their chunk copies must not spawn a second one. Cleared
/// with the rest when a world closes.
@@ -382,300 +383,18 @@ impl Game<'_> {
{
self.persist
.anon
.insert(anon_key(&kind, at), (at, uuid, tag.clone()));
.insert(anon_key(&kind, at), (at, tag.clone()));
}
match kind.as_str() {
"minecraft:item" => {
self.next_entity += 1;
let bob = self.world.random.next_float() * std::f32::consts::PI * 2.0;
if let Some(e) =
entity_nbt::item_entity_from_nbt(&tag, self.next_entity, bob)
{
self.remember(e.id, uuid, tag);
self.dropped.push(e);
}
}
"minecraft:experience_orb" => {
for mut orb in entity_nbt::orbs_from_nbt(&tag) {
self.next_entity += 1;
orb.id = self.next_entity;
let id = orb.id;
// An orb that came through a portal keeps its cooldown.
self.restore_portal_cooldown(id, &tag);
self.orbs.push(orb);
self.remember_tags(id, &tag);
}
}
"minecraft:arrow" | "minecraft:spectral_arrow" => {
if let Some((mut p, in_block)) = entity_nbt::arrow_from_nbt(
&tag,
player_uuid,
super::dispenser::tipped_effect,
) {
if let Some(a) = p.arrow.as_mut() {
a.last_state = in_block.and_then(|t| self.state_from_nbt(&t));
}
self.claim_owner(&mut p);
// An arrow that came through a portal keeps its
// cooldown: it does not go straight back through.
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.remember_tags(p.id, &tag);
self.add_projectile(p);
}
}
"minecraft:fireball"
| "minecraft:small_fireball"
| "minecraft:wind_charge"
| "minecraft:breeze_wind_charge" => {
if let Some(mut p) = entity_nbt::fireball_from_nbt(&tag, player_uuid) {
self.claim_owner(&mut p);
// A projectile that came through a portal keeps its cooldown.
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.add_projectile(p);
self.remember_last_projectile_tags(&tag);
}
}
"minecraft:llama_spit" | "minecraft:shulker_bullet" => {
if let Some(mut p) = entity_nbt::spit_or_bullet_from_nbt(&tag, player_uuid)
{
self.claim_owner(&mut p);
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.add_projectile(p);
self.remember_last_projectile_tags(&tag);
}
}
id if entity_nbt::throwable_kind(id).is_some() => {
if let Some(mut p) = entity_nbt::throwable_from_nbt(&tag, player_uuid) {
self.claim_owner(&mut p);
// A projectile that came through a portal keeps its cooldown.
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.add_projectile(p);
self.remember_last_projectile_tags(&tag);
}
}
"minecraft:falling_block" => {
if let Some((state, position, velocity, age, fall_distance)) =
entity_nbt::falling_block_from_nbt(&tag)
&& let Some(state) = self.state_from_nbt(&state)
{
let (no_gravity, drop_item) = entity_nbt::falling_block_flags(&tag);
self.next_entity += 1;
// A summoned one has no UUID yet: `Entity` makes a random one.
let uuid = uuid.unwrap_or_else(|| self.uuid_for(self.next_entity));
self.falling.push(FallingBlock {
id: self.next_entity,
state,
// DATA_START_POS is not persisted by vanilla.
start_pos: horizon_core::position::BlockPos::default(),
position,
previous: position,
velocity,
age,
no_gravity,
portal: horizon_client::nether_portal::PortalState {
cooldown: horizon_client::save::number(
tag.get("PortalCooldown"),
)
.unwrap_or(0)
as i32,
process: None,
},
drop_item,
fall_distance,
stuck: Vec3::ZERO,
damage: horizon_client::falling_block::Damage::from_nbt(
&tag,
self.res.facts().get(state).name.ends_with("anvil"),
),
saved: Some(Box::new((uuid, tag.clone()))),
});
}
}
"minecraft:trident" => {
if let Some(n) = entity_nbt::trident_from_nbt(&tag) {
let mut t = super::trident::ThrownTrident::from_nbt(n);
t.id = self.take_entity_id();
// One that came through a portal keeps its cooldown.
let id = t.id;
self.tridents.push(t);
self.remember(id, uuid, tag);
}
}
"minecraft:firework_rocket" => {
if let Some(r) = entity_nbt::firework_rocket_from_nbt(&tag, player_uuid) {
let id = self.take_entity_id();
let mut rocket = super::fireworks::Rocket::from_nbt(r);
rocket.id = id;
// One that came through a portal keeps its cooldown.
self.restore_portal_cooldown(id, &tag);
self.rockets.push(rocket);
self.remember(id, uuid, tag);
}
}
// PrimedTnt.readAdditionalSaveData: its fuse (80 when absent) and motion.
// Its Passengers ride it (EntityType.loadEntityRecursive forces them on).
"minecraft:tnt" => {
let mut tag = tag;
let riders = self.take_mob_passengers(&mut tag);
if let Some(position) = entity_nbt::position(&tag) {
self.next_entity += 1;
let id = self.next_entity;
let fuse = match tag.get("fuse") {
Some(Tag::Short(f)) => i32::from(*f),
_ => 80,
};
self.tnts.push(super::tnt::PrimedTnt {
no_gravity: entity_nbt::flag(&tag, "NoGravity"),
id,
position,
previous: position,
velocity: entity_nbt::motion(&tag),
fuse,
on_ground: matches!(tag.get("OnGround"), Some(Tag::Byte(1))),
owner: None,
guest: None,
// PrimedTnt.readAdditionalSaveData: 4 by default,
// kept to 0..128.
power: entity_nbt::tnt_power(&tag),
});
// `Entity.load`: the `PortalCooldown` of one that just came through.
self.remember(id, uuid, tag);
for rider in riders {
self.load_mob(rider, Some(id));
}
}
}
k if super::boats::any_boat_wood(k)
.is_some_and(|(wood, chest)| self.boat_models.has(wood, chest)) =>
{
if let Some((position, velocity, yaw)) = entity_nbt::boat_from_nbt(&tag) {
let mut tag = tag;
let riders = self.take_mob_passengers(&mut tag);
self.next_entity += 1;
let id = self.next_entity;
let (wood, chest) =
super::boats::any_boat_wood(k).unwrap_or(("oak", false));
let mut b = super::boats::Boat::new(
id,
std::sync::Arc::from(wood),
position,
yaw,
);
b.no_gravity = entity_nbt::flag(&tag, "NoGravity");
if chest {
b = b.with_chest();
b.items = horizon_client::save::decode_items(
tag.get("Items"),
super::boats::CHEST_SLOTS,
);
}
b.velocity = velocity;
// AbstractBoat.readAdditionalSaveData: the lead's holder.
b.leash_pending =
horizon_client::mobs::leash::pending_from_nbt(tag.get("leash"));
self.remember(id, uuid, tag);
self.boats.push(b);
for rider in riders {
self.load_mob(rider, Some(id));
}
}
}
k if super::minecarts::CartKind::from_id(k).is_some() => {
let kind = super::minecarts::CartKind::from_id(k).unwrap();
let mut tag = tag;
let riders = self.take_mob_passengers(&mut tag);
let id = self.next_entity + 1;
if let Some(mut c) = super::minecarts::Minecart::from_nbt(&tag, id, kind) {
self.roll_cart_loot(&mut c, &tag);
self.next_entity = id;
self.remember(id, uuid, tag);
self.carts.push(c);
for rider in riders {
self.load_mob(rider, Some(id));
}
}
}
"minecraft:ominous_item_spawner" => self.load_ominous_item_spawner(tag),
"minecraft:ender_dragon" => {
let seed = self.world.random.next_long();
if self.dragon.is_none()
&& let Some(mut d) = super::dragon::Dragon::from_nbt(&tag, seed)
{
// EnderDragon's constructor takes its id and its
// parts' from the entity counter.
d.id = self.next_entity + 1;
self.next_entity += 9;
self.remember_tags(d.id, &tag);
self.dragon = Some(d);
}
}
"minecraft:end_crystal" => {
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
if let Some(mut c) = super::dragon::Crystal::from_nbt(&tag, time) {
// EndCrystal's constructor takes an id from the
// entity counter.
self.next_entity += 1;
c.id = self.next_entity;
self.remember_tags(c.id, &tag);
self.crystals.push(c);
}
}
"minecraft:armor_stand" => {
self.next_entity += 1;
if let Some(a) = entity_nbt::armor_stand_from_nbt(&tag, self.next_entity) {
self.remember(a.id, uuid, tag);
self.armor_stands.push(a);
}
}
"minecraft:item_frame" | "minecraft:glow_item_frame" => {
self.next_entity += 1;
if let Some(f) = entity_nbt::item_frame_from_nbt(&tag, self.next_entity) {
self.remember(f.id, uuid, tag);
self.item_frames.push(f);
}
}
"minecraft:cushion" => {
self.next_entity += 1;
if let Some(c) = entity_nbt::cushion_from_nbt(&tag, self.next_entity) {
self.remember(c.id, uuid, tag);
self.cushions.push(c);
}
}
"minecraft:painting" => {
if let Some((pos, facing, variant)) = entity_nbt::painting_from_nbt(&tag)
&& let Some(v) = self.painting_variants.get(&variant).cloned()
{
self.next_entity += 1;
let id = self.next_entity;
self.remember(id, uuid, tag);
self.paintings.push(horizon_client::entities::Painting {
id,
pos,
facing,
variant: v.id,
width: v.width,
height: v.height,
check_interval: 0,
});
} else {
self.persist.unknown.entry(key).or_default().push(tag);
}
}
_ if self.res.mob_types().contains_key(&kind) => {
self.load_mob(tag, None);
}
_ if !kind.is_empty() && entity_nbt::position(&tag).is_some() => {
// Kinds outside `entity_lists!` first, then the listed kinds
// (`Entity::load_nbt`), then whatever else is a compound.
if let Err(tag) = self.load_unlisted(key, &kind, uuid, tag)
&& let Err(tag) = self.load_listed(&kind, uuid, player_uuid, tag)
{
if !kind.is_empty() && entity_nbt::position(&tag).is_some() {
self.load_other(tag, &kind);
} else {
self.persist.unknown.entry(key).or_default().push(tag);
}
_ => self.persist.unknown.entry(key).or_default().push(tag),
}
// PersistentEntitySectionManager.addEntity: every kind that
// just joined tells the mods; a moved one does not join.
@@ -688,6 +407,351 @@ impl Game<'_> {
self.native_entities_load(self.dim.id(), [key.0, key.1], count);
}
}
/// The kinds `entity_lists!` does not hold: `Err` hands the compound back
/// when `kind` is not one of them.
fn load_unlisted(
&mut self,
key: ChunkKey,
kind: &str,
uuid: Option<[i32; 4]>,
tag: Tag,
) -> std::result::Result<(), Tag> {
match kind {
"minecraft:ominous_item_spawner" => self.load_ominous_item_spawner(tag),
"minecraft:ender_dragon" => self.load_dragon(tag, uuid),
"minecraft:end_crystal" => self.load_crystal(tag, uuid),
"minecraft:item_frame" | "minecraft:glow_item_frame" => self.load_item_frame(tag, uuid),
"minecraft:cushion" => self.load_cushion(tag, uuid),
"minecraft:painting" => self.load_painting(tag, uuid, key),
_ => return Err(tag),
}
Ok(())
}
/// A saved dropped item.
pub(super) fn load_item(&mut self, tag: Tag, uuid: Option<[i32; 4]>) {
self.next_entity += 1;
let bob = self.world.random.next_float() * std::f32::consts::PI * 2.0;
if let Some(e) = entity_nbt::item_entity_from_nbt(&tag, self.next_entity, bob) {
self.remember(e.id, uuid, tag);
self.dropped.push(e);
}
}
/// A saved experience orb (or the orbs it stands for).
pub(super) fn load_orb(&mut self, tag: Tag, uuid: Option<[i32; 4]>) {
for (i, mut orb) in entity_nbt::orbs_from_nbt(&tag).into_iter().enumerate() {
self.next_entity += 1;
orb.id = self.next_entity;
let id = orb.id;
// Horizon splits a saved `count` into orbs: the first is the saved one.
if i == 0 {
self.remember_uuid(id, uuid, &tag);
}
// An orb that came through a portal keeps its cooldown.
self.restore_portal_cooldown(id, &tag);
self.orbs.push(orb);
self.remember_tags(id, &tag);
}
}
/// A saved arrow or spectral arrow.
pub(super) fn load_arrow(
&mut self,
tag: Tag,
uuid: Option<[i32; 4]>,
player_uuid: Option<[i32; 4]>,
) {
if let Some((mut p, in_block)) =
entity_nbt::arrow_from_nbt(&tag, player_uuid, super::dispenser::tipped_effect)
{
if let Some(a) = p.arrow.as_mut() {
a.last_state = in_block.and_then(|t| self.state_from_nbt(&t));
}
self.claim_owner(&mut p);
// An arrow that came through a portal keeps its
// cooldown: it does not go straight back through.
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.remember_tags(p.id, &tag);
self.remember_uuid(p.id, uuid, &tag);
self.add_projectile(p);
}
}
/// A saved llama spit or shulker bullet.
pub(super) fn load_spit_or_bullet(&mut self, tag: Tag, player_uuid: Option<[i32; 4]>) {
if let Some(mut p) = entity_nbt::spit_or_bullet_from_nbt(&tag, player_uuid) {
self.claim_owner(&mut p);
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.add_projectile(p);
self.remember_last_projectile_tags(&tag);
}
}
/// A saved fireball or wind charge.
pub(super) fn load_fireball(
&mut self,
tag: Tag,
uuid: Option<[i32; 4]>,
player_uuid: Option<[i32; 4]>,
) {
if let Some(mut p) = entity_nbt::fireball_from_nbt(&tag, player_uuid) {
self.claim_owner(&mut p);
// A projectile that came through a portal keeps its cooldown.
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.remember_uuid(p.id, uuid, &tag);
self.add_projectile(p);
self.remember_last_projectile_tags(&tag);
}
}
/// A saved thrown item.
pub(super) fn load_throwable(
&mut self,
tag: Tag,
uuid: Option<[i32; 4]>,
player_uuid: Option<[i32; 4]>,
) {
if let Some(mut p) = entity_nbt::throwable_from_nbt(&tag, player_uuid) {
self.claim_owner(&mut p);
// A projectile that came through a portal keeps its cooldown.
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.remember_uuid(p.id, uuid, &tag);
self.add_projectile(p);
self.remember_last_projectile_tags(&tag);
}
}
/// A saved falling block.
pub(super) fn load_falling(&mut self, tag: Tag, uuid: Option<[i32; 4]>) {
if let Some((state, position, velocity, age, fall_distance)) =
entity_nbt::falling_block_from_nbt(&tag)
&& let Some(state) = self.state_from_nbt(&state)
{
let (no_gravity, drop_item) = entity_nbt::falling_block_flags(&tag);
self.next_entity += 1;
// A summoned one has no UUID yet: `Entity` makes a random one.
self.remember_uuid(self.next_entity, uuid, &tag);
let uuid = self.uuid_for(self.next_entity);
self.falling.push(FallingBlock {
id: self.next_entity,
state,
// DATA_START_POS is not persisted by vanilla.
start_pos: horizon_core::position::BlockPos::default(),
position,
previous: position,
velocity,
age,
no_gravity,
portal: horizon_client::nether_portal::PortalState {
cooldown: horizon_client::save::number(tag.get("PortalCooldown")).unwrap_or(0)
as i32,
process: None,
},
drop_item,
fall_distance,
stuck: Vec3::ZERO,
damage: horizon_client::falling_block::Damage::from_nbt(
&tag,
self.res.facts().get(state).name.ends_with("anvil"),
),
saved: Some(Box::new((uuid, tag.clone()))),
});
}
}
/// A saved thrown trident.
pub(super) fn load_trident(&mut self, tag: Tag, uuid: Option<[i32; 4]>) {
if let Some(n) = entity_nbt::trident_from_nbt(&tag) {
let mut t = super::trident::ThrownTrident::from_nbt(n);
t.id = self.take_entity_id();
// One that came through a portal keeps its cooldown.
let id = t.id;
self.tridents.push(t);
self.remember(id, uuid, tag);
}
}
/// A saved firework rocket.
pub(super) fn load_rocket(
&mut self,
tag: Tag,
uuid: Option<[i32; 4]>,
player_uuid: Option<[i32; 4]>,
) {
if let Some(r) = entity_nbt::firework_rocket_from_nbt(&tag, player_uuid) {
let id = self.take_entity_id();
let mut rocket = super::fireworks::Rocket::from_nbt(r);
rocket.id = id;
// One that came through a portal keeps its cooldown.
self.restore_portal_cooldown(id, &tag);
self.rockets.push(rocket);
self.remember(id, uuid, tag);
}
}
/// A saved primed TNT.
// PrimedTnt.readAdditionalSaveData: its fuse (80 when absent) and motion.
// Its Passengers ride it (EntityType.loadEntityRecursive forces them on).
pub(super) fn load_tnt(&mut self, tag: Tag, uuid: Option<[i32; 4]>) {
let mut tag = tag;
let riders = self.take_mob_passengers(&mut tag);
if let Some(position) = entity_nbt::position(&tag) {
self.next_entity += 1;
let id = self.next_entity;
let fuse = match tag.get("fuse") {
Some(Tag::Short(f)) => i32::from(*f),
_ => 80,
};
self.tnts.push(super::tnt::PrimedTnt {
no_gravity: entity_nbt::flag(&tag, "NoGravity"),
id,
position,
previous: position,
velocity: entity_nbt::motion(&tag),
fuse,
on_ground: matches!(tag.get("OnGround"), Some(Tag::Byte(1))),
owner: None,
guest: None,
// PrimedTnt.readAdditionalSaveData: 4 by default,
// kept to 0..128.
power: entity_nbt::tnt_power(&tag),
});
// `Entity.load`: the `PortalCooldown` of one that just came through.
self.remember(id, uuid, tag);
for rider in riders {
self.load_mob(rider, Some(id));
}
}
}
/// A saved boat.
pub(super) fn load_boat(&mut self, tag: Tag, uuid: Option<[i32; 4]>, k: &str) {
if let Some((position, velocity, yaw)) = entity_nbt::boat_from_nbt(&tag) {
let mut tag = tag;
let riders = self.take_mob_passengers(&mut tag);
self.next_entity += 1;
let id = self.next_entity;
let (wood, chest) = super::boats::any_boat_wood(k).unwrap_or(("oak", false));
let mut b = super::boats::Boat::new(id, std::sync::Arc::from(wood), position, yaw);
b.no_gravity = entity_nbt::flag(&tag, "NoGravity");
if chest {
b = b.with_chest();
b.items =
horizon_client::save::decode_items(tag.get("Items"), super::boats::CHEST_SLOTS);
}
b.velocity = velocity;
// AbstractBoat.readAdditionalSaveData: the lead's holder.
b.leash_pending = horizon_client::mobs::leash::pending_from_nbt(tag.get("leash"));
self.remember(id, uuid, tag);
self.boats.push(b);
for rider in riders {
self.load_mob(rider, Some(id));
}
}
}
/// A saved minecart.
pub(super) fn load_cart(&mut self, tag: Tag, uuid: Option<[i32; 4]>, k: &str) {
let kind = super::minecarts::CartKind::from_id(k).unwrap();
let mut tag = tag;
let riders = self.take_mob_passengers(&mut tag);
let id = self.next_entity + 1;
if let Some(mut c) = super::minecarts::Minecart::from_nbt(&tag, id, kind) {
self.roll_cart_loot(&mut c, &tag);
self.next_entity = id;
self.remember(id, uuid, tag);
self.carts.push(c);
for rider in riders {
self.load_mob(rider, Some(id));
}
}
}
/// A saved armor stand.
pub(super) fn load_armor_stand(&mut self, tag: Tag, uuid: Option<[i32; 4]>) {
self.next_entity += 1;
if let Some(a) = entity_nbt::armor_stand_from_nbt(&tag, self.next_entity) {
self.remember(a.id, uuid, tag);
self.armor_stands.push(a);
}
}
/// The saved ender dragon.
pub(super) fn load_dragon(&mut self, tag: Tag, uuid: Option<[i32; 4]>) {
let seed = self.world.random.next_long();
if self.dragon.is_none()
&& let Some(mut d) = super::dragon::Dragon::from_nbt(&tag, seed)
{
// EnderDragon's constructor takes its id and its
// parts' from the entity counter.
d.id = self.next_entity + 1;
self.next_entity += 9;
self.remember_tags(d.id, &tag);
self.remember_uuid(d.id, uuid, &tag);
self.dragon = Some(d);
}
}
/// A saved end crystal.
pub(super) fn load_crystal(&mut self, tag: Tag, uuid: Option<[i32; 4]>) {
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
if let Some(mut c) = super::dragon::Crystal::from_nbt(&tag, time) {
// EndCrystal's constructor takes an id from the
// entity counter.
self.next_entity += 1;
c.id = self.next_entity;
self.remember_tags(c.id, &tag);
self.remember_uuid(c.id, uuid, &tag);
self.crystals.push(c);
}
}
/// A saved item frame.
pub(super) fn load_item_frame(&mut self, tag: Tag, uuid: Option<[i32; 4]>) {
self.next_entity += 1;
if let Some(f) = entity_nbt::item_frame_from_nbt(&tag, self.next_entity) {
self.remember(f.id, uuid, tag);
self.item_frames.push(f);
}
}
/// A saved cushion.
pub(super) fn load_cushion(&mut self, tag: Tag, uuid: Option<[i32; 4]>) {
self.next_entity += 1;
if let Some(c) = entity_nbt::cushion_from_nbt(&tag, self.next_entity) {
self.remember(c.id, uuid, tag);
self.cushions.push(c);
}
}
/// A saved painting, or its compound when its variant is unknown.
pub(super) fn load_painting(&mut self, tag: Tag, uuid: Option<[i32; 4]>, key: ChunkKey) {
if let Some((pos, facing, variant)) = entity_nbt::painting_from_nbt(&tag)
&& let Some(v) = self.painting_variants.get(&variant).cloned()
{
self.next_entity += 1;
let id = self.next_entity;
self.remember(id, uuid, tag);
self.paintings.push(horizon_client::entities::Painting {
id,
pos,
facing,
variant: v.id,
width: v.width,
height: v.height,
check_interval: 0,
});
} else {
self.persist.unknown.entry(key).or_default().push(tag);
}
}
/// A saved mob and, under `Passengers`, the mobs riding it.
pub(super) fn load_mob(&mut self, tag: Tag, mount: Option<u32>) -> Option<u32> {
let uuid = entity_nbt::uuid(&tag);
@@ -1068,13 +1132,13 @@ impl Game<'_> {
self.persist.uuids.remove(&id);
self.persist.raw.remove(&id);
}
/// The compound an entity without a Horizon entity ID was loaded with
/// (the crystals and the dragon), found again by type and position.
pub(super) fn persist_anon(&self, kind: &str, p: Vec3) -> (Option<[i32; 4]>, Option<&Tag>) {
/// The compound a crystal or the dragon was loaded with, found again by type
/// and position.
pub(super) fn persist_anon(&self, kind: &str, p: Vec3) -> Option<&Tag> {
self.persist
.anon
.get(&anon_key(kind, p))
.map_or((None, None), |(_, uuid, raw)| (*uuid, Some(raw)))
.map(|(_, raw)| raw)
}
/// The compound an entity was loaded with (and commands have edited).
pub(super) fn persist_raw(&self, id: u32) -> Option<&Tag> {
@@ -1131,11 +1195,15 @@ impl Game<'_> {
}
fn remember(&mut self, id: u32, uuid: Option<[i32; 4]>, tag: Tag) {
self.restore_portal_cooldown(id, &tag);
self.remember_uuid(id, uuid, &tag);
self.persist.raw.insert(id, tag);
}
/// The UUID entity `id` was loaded with, and the attachments `tag` saved under it.
pub(super) fn remember_uuid(&mut self, id: u32, uuid: Option<[i32; 4]>, tag: &Tag) {
if let Some(u) = uuid {
self.persist.uuids.insert(id, u);
read_attachments(&tag, u, &mut self.entity_attachments);
read_attachments(tag, u, &mut self.entity_attachments);
}
self.persist.raw.insert(id, tag);
}
/// The mob that was loaded or saved with a UUID.
pub(super) fn mob_by_uuid(&self, uuid: [i32; 4]) -> Option<u32> {
@@ -1174,14 +1242,16 @@ impl Game<'_> {
if let Some(d) = &self.dragon
&& keys.contains(&chunk_of(d.position))
{
let (uuid, raw) = anon_uuid_raw(&self.persist, "minecraft:ender_dragon", d.position);
let raw = anon_raw(&self.persist, "minecraft:ender_dragon", d.position);
let uuid = saved_uuid(&self.persist, d.id);
out.entry(chunk_of(d.position))
.or_default()
.push(d.to_nbt(uuid, raw.as_ref()));
}
for c in &self.crystals {
if keys.contains(&chunk_of(c.position)) {
let (uuid, raw) = anon_uuid_raw(&self.persist, "minecraft:end_crystal", c.position);
let raw = anon_raw(&self.persist, "minecraft:end_crystal", c.position);
let uuid = saved_uuid(&self.persist, c.id);
out.entry(chunk_of(c.position))
.or_default()
.push(c.to_nbt(uuid, raw.as_ref()));
@@ -1194,7 +1264,7 @@ impl Game<'_> {
&s.item,
at,
s.spawn_item_after,
s.uuid,
self.persist.uuids.get(&s.id).copied().unwrap_or(s.uuid),
s.raw.as_deref(),
);
out.entry(chunk_of(at)).or_default().push(tag);
@@ -1374,7 +1444,7 @@ impl Game<'_> {
self.tnts.retain(|t| !gone(t.position));
self.persist
.anon
.retain(|_, (at, _, _)| !keys.contains(&chunk_of(*at)));
.retain(|_, (at, _)| !keys.contains(&chunk_of(*at)));
for id in removed {
self.forget_entity(id);
}
@@ -1397,25 +1467,12 @@ impl Game<'_> {
}
}
pub(super) fn prune_entity_memory(&mut self) {
use super::entity::{Entity as _, entity_lists_of, for_each_entity};
// Forget entities that no longer exist.
let alive: HashSet<u32> = self
.mobs
.iter()
.map(|m| m.id)
.chain(self.dropped.iter().map(|e| e.id))
.chain(self.boats.iter().map(|e| e.id))
.chain(self.carts.iter().map(|e| e.id))
.chain(self.armor_stands.iter().map(|e| e.id))
.chain(self.cushions.iter().map(|e| e.id))
.chain(self.item_frames.iter().map(|e| e.id))
.chain(self.paintings.iter().map(|e| e.id))
.chain(self.others.iter().map(|e| e.id))
.chain(self.orbs.iter().map(|e| e.id))
.chain(self.creatures.projectiles.iter().map(|e| e.id))
.chain(self.crystals.iter().map(|e| e.id))
.chain(self.dragon.iter().map(|e| e.id))
.chain(self.tnts.iter().map(|e| e.id))
.collect();
let mut alive: HashSet<u32> = self.entity_ids_off_walk().collect();
for_each_entity!(entity_lists_of!(self), entity => {
alive.insert(entity.id());
});
for (id, uuid) in &self.persist.uuids {
if !alive.contains(id) {
self.entity_attachments.remove(uuid);
@@ -1502,7 +1559,8 @@ impl Game<'_> {
) -> Option<(ChunkKey, Tag)> {
let o = &self.orbs[i];
keys.contains(&chunk_of(o.position)).then(|| {
let (uuid, raw) = anon_uuid_raw(&self.persist, "minecraft:experience_orb", o.position);
let raw = anon_raw(&self.persist, "minecraft:experience_orb", o.position);
let uuid = saved_uuid(&self.persist, o.id);
let tag = with_raw(
with_raw(entity_nbt::orb_to_nbt(o, uuid), raw.as_ref()),
self.persist.raw.get(&o.id),
@@ -1520,7 +1578,8 @@ impl Game<'_> {
return None;
}
let owner = p.owner_uuid;
let (uuid, raw) = anon_uuid_raw(&self.persist, p.kind.id(), p.position);
let raw = anon_raw(&self.persist, p.kind.id(), p.position);
let uuid = saved_uuid(&self.persist, p.id);
let in_block = p
.arrow
.as_ref()
@@ -1548,10 +1607,11 @@ impl Game<'_> {
if !keys.contains(&chunk_of(f.position)) {
return None;
}
let (uuid, raw) = match &f.saved {
Some(saved) => (saved.0, Some(saved.1.clone())),
None => anon_uuid_raw(&self.persist, "minecraft:falling_block", f.position),
let raw = match &f.saved {
Some(saved) => Some(saved.1.clone()),
None => anon_raw(&self.persist, "minecraft:falling_block", f.position),
};
let uuid = saved_uuid(&self.persist, f.id);
let tag = entity_nbt::falling_block_to_nbt(
self.state_to_nbt(f.state),
f.position,
@@ -1679,14 +1739,16 @@ impl Game<'_> {
/// A chunk's key, as `chunk_of` gives it.
pub(super) type ChunkKey = (i32, i32);
/// The saved UUID and raw compound kept for an entity without an id, found by
/// kind and position; a new UUID when none was kept.
fn anon_uuid_raw(persist: &EntityMemory, kind: &str, at: Vec3) -> ([i32; 4], Option<Tag>) {
/// The raw compound kept for an entity loaded at `at`, found by kind and position.
fn anon_raw(persist: &EntityMemory, kind: &str, at: Vec3) -> Option<Tag> {
persist
.anon
.get(&anon_key(kind, at))
.map(|(_, uuid, raw)| (uuid.unwrap_or_else(random_uuid), Some(raw.clone())))
.unwrap_or_else(|| (random_uuid(), None))
.map(|(_, raw)| raw.clone())
}
/// The UUID entity `id` was loaded with or given, else a new one for this save.
fn saved_uuid(persist: &EntityMemory, id: u32) -> [i32; 4] {
persist.uuids.get(&id).copied().unwrap_or_else(random_uuid)
}
/// Only a mob is finalized; another entity drops the request.
@@ -1818,6 +1880,125 @@ mod tests {
assert_eq!(loaded.get_mut(&child_id).unwrap().get(&key), Ok(29));
}
/// Entities as a save before every entity held attachments wrote them (a vanilla compound
/// with its `UUID`, and `horizon:attachments` where the mod set one), loaded and saved again:
/// each comes back on the entity with its UUID, and its attachments with it.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn an_older_save_loads_its_entities_attachments_back_onto_them() {
use super::super::{Audio, Game, Options, Progress, Settings, load_resources};
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!("no menu handoff"),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
use horizon_world::attachments::{AttachmentCodec, AttachmentError, AttachmentType};
static MARK: AttachmentType<i32> = AttachmentType {
namespace: "holdproof",
name: "mark",
default: || 0,
codec: Some(AttachmentCodec {
encode: |n: &i32| Ok([("n".into(), Tag::Int(*n))].into()),
decode: |c| match c.get(&"n".into()) {
Some(Tag::Int(n)) => Ok(*n),
_ => Err(AttachmentError::Decode),
},
}),
copy_on_death: false,
sync: false,
};
let mark = |n: i32| {
Tag::compound([(
"holdproof",
Tag::compound([(
"mark",
Tag::compound([("value", Tag::compound([("n", Tag::Int(n))]))]),
)]),
)])
};
let pos = |x: f64| Tag::List([x, 70.0, 3.0].map(|v| Tag::Double(v.to_bits())).to_vec());
let kinds = [
("minecraft:zombie", vec![]),
("minecraft:arrow", vec![("NoGravity", Tag::Byte(1))]),
("minecraft:experience_orb", vec![("Value", Tag::Short(7))]),
(
"minecraft:falling_block",
vec![
(
"BlockState",
Tag::compound([("Name", Tag::String("minecraft:sand".into()))]),
),
("NoGravity", Tag::Byte(1)),
("Time", Tag::Int(1)),
],
),
("minecraft:snowball", vec![("NoGravity", Tag::Byte(1))]),
];
let mut tags = Vec::new();
let mut uuids = Vec::new();
for (i, (kind, extra)) in kinds.iter().enumerate() {
let n = i as i32 + 1;
let uuid = [n, 2 * n, 3 * n, 4 * n];
uuids.push((*kind, uuid, 1000 + n));
let mut fields: Vec<(&str, Tag)> = vec![
("id", Tag::String((*kind).into())),
("Pos", pos(1.0 + i as f64)),
("UUID", Tag::IntArray(uuid.to_vec())),
(ATTACHMENTS_KEY, mark(1000 + n)),
];
fields.extend(extra.iter().cloned());
tags.push(Tag::compound(fields));
}
// The level is a server's, whose attachments a mod reaches.
game.options.dedicated = Some(Vec::new());
game.world.loaded_entities.push(((0, 0), tags));
game.spawn_loaded_entities();
let saved = game.entity_chunks(&HashSet::from([(0, 0)]), false);
let saved: Vec<&Tag> = saved.iter().flat_map(|(_, tags)| tags).collect();
for (kind, uuid, n) in uuids {
let id = game
.persist
.uuids
.iter()
.find(|(_, u)| **u == uuid)
.map(|(id, _)| *id);
let id = id.unwrap_or_else(|| panic!("{kind}: loaded without its UUID"));
let map = game.attachment_map_quiet(horizon_client::mod_api::Holder::Entity(id));
let map = map.unwrap_or_else(|e| panic!("{kind}: not a holder: {e:?}"));
assert_eq!(map.get(&MARK), Ok(n), "{kind}: attachments not loaded");
let tag = saved.iter().find(|t| entity_nbt::uuid(t) == Some(uuid));
let tag = tag.unwrap_or_else(|| panic!("{kind}: saved without its UUID"));
assert_eq!(
tag.get(ATTACHMENTS_KEY),
Some(&mark(n)),
"{kind}: attachments not saved back"
);
println!("{kind}: id {id}, UUID kept, attachments loaded and saved back");
}
}
#[test]
fn a_lone_rider_saves_the_horse() {
let horse = 7;
+11 -1
View File
@@ -1324,7 +1324,17 @@ impl Game<'_> {
center[1],
center[2] + (angle.sin() * 32.0).floor() as i32,
];
if self.find_siege_spawn_pos(&centers, spot).is_some() {
if let Some(attempted_pos) = self.find_siege_spawn_pos(&centers, spot) {
let mut canceled = false;
self.native_village_siege(
self.dim.id(),
player.uuid,
attempted_pos,
&mut canceled,
);
if canceled {
return false;
}
self.raiding.siege.spot = spot;
self.raiding.siege.next_spawn_time = 0;
self.raiding.siege.zombies_to_spawn = 20;
@@ -3,8 +3,11 @@
use super::Game;
use horizon_client::ram_limit::{self, Step};
use horizon_client::text::Text;
use horizon_threads::capped;
use std::time::Instant;
const MIB: u64 = 1024 * 1024;
impl Game<'_> {
/// The render distance in use: the player's, unless memory holds it lower.
pub(super) fn render_distance_in_use(&self) -> i32 {
@@ -25,6 +28,17 @@ impl Game<'_> {
};
let has_far = self.settings.horizon.lod_distance > 0;
let pressure = ram_limit::pressure(limit, self.fps);
// Every ten seconds, what the limit sees: a report can show the trace.
if self.ticks.is_multiple_of(200) {
let (commit, _) = horizon_threads::allocator_commit();
log::info!(
"Horizon: memory: allocated {} MB of {} MB, commit {} MB, distance {}, {pressure:?}",
capped::live_bytes() / MIB,
limit / MIB,
commit as u64 / MIB,
self.render_distance_in_use()
);
}
let step = self
.ram_limit
.step(Instant::now(), pressure, chosen, has_far);
+78 -6
View File
@@ -615,6 +615,10 @@ pub(super) struct Remote {
/// The player's own shared flag 5 (`Entity.isInvisible`) as the server
/// last set it.
pub(super) own_invisible: bool,
/// The player's own shared flag 0 (`Entity.isOnFire`) as the server last
/// set it: its fire (blaze fireballs, fire aspect, lightning) never
/// reaches the window's own `fire_ticks` otherwise.
own_on_fire: bool,
/// `LivingEntity.DATA_ARROW_COUNT_ID` and `DATA_STINGER_COUNT_ID` of the
/// own player, as the server synced them.
pub(super) own_arrows: i32,
@@ -1069,6 +1073,7 @@ impl Game<'_> {
level_player_textures: HashMap::new(),
glowing: Default::default(),
own_invisible: false,
own_on_fire: false,
own_arrows: 0,
own_stingers: 0,
own_shoulders: [None; 2],
@@ -2091,6 +2096,7 @@ impl Game<'_> {
dimension.height,
dimension.logical_height,
dimension.has_skylight,
dimension.has_ceiling,
)
.unwrap_or_else(|_| super::Dim::from_id(&dimension.name).shape());
// handleRespawn keeps the ClientLevel only for the same level key:
@@ -3054,6 +3060,7 @@ impl Game<'_> {
if let Some(r) = self.remote.as_mut() {
r.carried = Some(0);
r.own_invisible = false;
r.own_on_fire = false;
r.online_mode = p.online_mode;
r.enforces_secure_chat = p.enforces_secure_chat;
r.entity_id = p.player_id;
@@ -3101,6 +3108,7 @@ impl Game<'_> {
// (its shared flags) to the new one; without it they reset.
if let Some(r) = self.remote.as_mut() {
r.own_invisible = false;
r.own_on_fire = false;
}
}
let dimension_changed = self.dim != Dim::from_id(&p.spawn.dimension);
@@ -3618,8 +3626,19 @@ impl Game<'_> {
// keep its glide travel (#7616).
pl.fall_flying = flags & -128 != 0;
}
let on_fire = flags & 0x01 != 0;
if let Some(r) = self.remote.as_mut() {
r.own_invisible = flags & 0x20 != 0;
let was_on_fire =
std::mem::replace(&mut r.own_on_fire, on_fire);
// The server put the fire out: the
// window's countdown ends with it.
if was_on_fire
&& !on_fire
&& let Some(pl) = self.player.as_mut()
{
pl.fire_ticks = pl.fire_ticks.min(0);
}
}
}
// Entity.DATA_AIR_SUPPLY_ID and
@@ -5924,6 +5943,13 @@ impl Game<'_> {
.passenger_pose_tick()?;
}
}
// The server decides fire the window cannot predict (blaze fireballs,
// fire aspect, lightning): Entity.isOnFire reads the synched flag, so
// the predicted tick's countdown must not end it while it is set.
player.fire_ticks = hold_server_fire(
player.fire_ticks,
self.remote.as_ref().is_some_and(|r| r.own_on_fire),
);
// LocalPlayer.onUpdateAbilities: flight turned on or off (a double
// jump, a spectator, landing) is sent to the server.
let flying = player.flying;
@@ -6210,7 +6236,7 @@ impl Game<'_> {
// tropical fish lie on their side without `in_water`, #1591).
// A frozen level ticks only the players, as below.
// Entity.isInRain for the wolves, which dry off by their own `Wolf.tick`.
let (fluids, rain, thundering): (Vec<_>, Vec<_>, bool) = {
let (fluids, rain, standings, thundering): (Vec<_>, Vec<_>, Vec<_>, bool) = {
let w = self.mob_world_base(&[]);
let players = self.remote.as_ref().map(|r| &r.level_players);
let fluids = self
@@ -6229,12 +6255,32 @@ impl Game<'_> {
&& horizon_client::mobs::tame::in_rain(m, &w)
})
.collect();
(fluids, rain, w.thundering)
// The block under each mob the packs' random-model rules read (a grazing
// cow), one hair below the feet like `Entity.getBlockStateOn`; nothing
// without rules.
let standings = self
.mobs
.iter()
.map(|m| {
horizon_client::mobs::cem::tracks_standing(&m.kind.id).then(|| {
let (x, y, z) = (
m.position.x.floor() as i32,
(m.position.y - 0.0001).floor() as i32,
m.position.z.floor() as i32,
);
w.block_name(x, y, z).to_owned()
})
})
.collect();
(fluids, rain, standings, w.thundering)
};
for (m, fluids) in self.mobs.iter_mut().zip(fluids) {
for ((m, fluids), standing) in self.mobs.iter_mut().zip(fluids).zip(standings) {
if let Some(fluids) = fluids {
m.fluids = fluids;
}
if let Some(name) = standing {
m.standing_on = Some(name);
}
}
let game_time = self.world.game_time;
// `AbstractPiglin.isConverting`: the dimension is not piglin safe.
@@ -6628,13 +6674,14 @@ impl Game<'_> {
Some(self.border.geometry()),
);
for d in self.dropped.iter_mut().filter(|_| running) {
d.tick(
super::entity::tick_item(
d,
world,
registry,
facts,
collision,
false,
&self.res.motion().fluids,
false,
// The server decides what an item rests on; this copy only moves between updates.
&[],
border,
@@ -10350,9 +10397,19 @@ fn client_millis() -> u64 {
.as_millis() as u64
}
/// `Entity.isOnFire` for the window's player: while the server's flag is set
/// the predicted tick's countdown never takes `fire_ticks` below 1.
fn hold_server_fire(fire_ticks: i32, server_on_fire: bool) -> i32 {
if server_on_fire {
fire_ticks.max(1)
} else {
fire_ticks
}
}
#[cfg(test)]
mod tests {
use super::{keep_client_state, remote_use_swings, synced_local_use};
use super::{hold_server_fire, keep_client_state, remote_use_swings, synced_local_use};
use horizon_client::{
containers::{Beacon, BlockEntity},
items::ItemStack,
@@ -10397,6 +10454,21 @@ mod tests {
assert!(!remote_use_swings(&stack("minecraft:apple"), true));
}
/// The server's on-fire flag keeps the predicted countdown (which
/// `fluid_base_tick` runs down) from ending the fire; a clear flag leaves
/// the window's own contact fire alone.
#[test]
fn the_servers_fire_flag_holds_the_burning_state_through_the_countdown() {
let mut ticks = 1;
for _ in 0..5 {
ticks = hold_server_fire(ticks - 1, true);
assert!(ticks > 0, "still burning");
}
assert_eq!(hold_server_fire(300, true), 300, "a longer fire is not cut");
assert_eq!(hold_server_fire(7, false), 7, "predicted lava fire stays");
assert_eq!(hold_server_fire(0, false), 0);
}
#[test]
fn a_beacon_update_tag_keeps_the_beam_the_client_walked() {
let old = BlockEntity::Beacon(Box::new(Beacon {
@@ -14,6 +14,7 @@ pub const HELP: &[&str] = &[
"section-draws [flat|indirect] how terrain sections are drawn (A/B checks); replies the mode",
"section-cull [on|off] occlusion culling of solid sections (A/B checks); replies the mode",
"section-sides [on|off] leave out solid quads facing away from the eye (A/B checks); replies the mode",
"gpu-culling [on|off] list and draw the near solid sections on the GPU where the device can (A/B checks); replies the mode",
];
impl Game<'_> {
+4 -1
View File
@@ -193,7 +193,10 @@ impl Game<'_> {
}
Ok(false)
}
Stage::Writes => self.save.writes_finished(),
Stage::Writes => {
let players = self.finish_player_autosaves(false)?;
Ok(self.save.writes_finished()? && players)
}
}
})();
match result {
+4 -2
View File
@@ -183,7 +183,8 @@ impl Game<'_> {
| Target::Falling(id)
| Target::Rocket(id)
| Target::Trident(id)
| Target::Other(id) => Some(uuid_text(self.uuid_for(id))),
| Target::Other(id)
| Target::Cloud(id) => Some(uuid_text(self.uuid_for(id))),
// It keeps its UUID with itself.
Target::Spawner(id) => self
.ominous_spawners
@@ -228,7 +229,8 @@ impl Game<'_> {
| Target::Dragon(id)
| Target::Rocket(id)
| Target::Trident(id)
| Target::Other(id) => self.persist.raw.get(&id),
| Target::Other(id)
| Target::Cloud(id) => self.persist.raw.get(&id),
Target::Player => self.level.player_extra.as_ref(),
// A server's player: the saved compound the guest's file is written from.
Target::Guest(conn) => self.command_guest(conn).and_then(|g| g.saved.as_ref()),
+51 -14
View File
@@ -55,15 +55,16 @@ pub(super) struct Trip {
/// dimension).
trait Rideable {
fn id(&self) -> u32;
/// The vehicle stands where its rider arrives, with no motion.
fn arrive(&mut self, at: Vec3, yaw: f32);
/// The vehicle stands where its rider arrives, with the motion it crossed
/// with (none for the kinds that keep none).
fn arrive(&mut self, at: Vec3, yaw: f32, motion: Vec3);
}
impl Rideable for Mob {
fn id(&self) -> u32 {
self.id
}
fn arrive(&mut self, at: Vec3, yaw: f32) {
fn arrive(&mut self, at: Vec3, yaw: f32, _motion: Vec3) {
self.position = at;
self.previous = at;
self.velocity = Vec3::ZERO;
@@ -77,7 +78,7 @@ impl Rideable for boats::Boat {
fn id(&self) -> u32 {
self.id
}
fn arrive(&mut self, at: Vec3, yaw: f32) {
fn arrive(&mut self, at: Vec3, yaw: f32, _motion: Vec3) {
self.position = at;
self.previous = at;
self.velocity = Vec3::ZERO;
@@ -91,10 +92,17 @@ impl Rideable for minecarts::Minecart {
fn id(&self) -> u32 {
self.id
}
fn arrive(&mut self, at: Vec3, yaw: f32) {
fn arrive(&mut self, at: Vec3, yaw: f32, motion: Vec3) {
self.position = at;
self.previous = at;
self.velocity = Vec3::ZERO;
// `PositionMoveRotation.of(this)` takes the cart's `getKnownMovement`
// (`OldMinecartBehavior` clamps it to 0.4 either way along x and z):
// it rolls on at that speed instead of arriving at rest.
self.velocity = Vec3::new(
motion.x.clamp(-0.4, 0.4),
motion.y,
motion.z.clamp(-0.4, 0.4),
);
self.yaw = yaw;
self.previous_yaw = yaw;
}
@@ -112,10 +120,16 @@ fn drop_hook(host: &mut LanHost, conn: u32) {
}
}
fn take_vehicle<T: Rideable>(list: &mut Vec<T>, id: u32, at: Vec3, yaw: f32) -> Option<T> {
fn take_vehicle<T: Rideable>(
list: &mut Vec<T>,
id: u32,
at: Vec3,
yaw: f32,
motion: Vec3,
) -> Option<T> {
let i = list.iter().position(|v| v.id() == id)?;
let mut v = list.remove(i);
v.arrive(at, yaw);
v.arrive(at, yaw, motion);
Some(v)
}
@@ -773,14 +787,19 @@ impl Game<'_> {
/// (`Entity.teleportCrossDimension` recreates the vehicle in the other
/// dimension, and its passengers ride it again).
fn arrive_vehicle(&mut self, source: Dim, id: u32, at: Vec3, yaw: f32) {
let motion = self
.portals
.carried_motion
.remove(&id)
.unwrap_or(Vec3::ZERO);
let Some(parked) = self.levels.parked_mut(source) else {
return;
};
if let Some(b) = take_vehicle(&mut parked.boats, id, at, yaw) {
if let Some(b) = take_vehicle(&mut parked.boats, id, at, yaw, motion) {
self.boats.push(b);
} else if let Some(c) = take_vehicle(&mut parked.carts, id, at, yaw) {
} else if let Some(c) = take_vehicle(&mut parked.carts, id, at, yaw, motion) {
self.carts.push(c);
} else if let Some(m) = take_vehicle(&mut parked.mobs, id, at, yaw) {
} else if let Some(m) = take_vehicle(&mut parked.mobs, id, at, yaw, motion) {
self.mobs.push(m);
} else {
return;
@@ -923,6 +942,24 @@ mod tests {
use super::*;
use std::sync::Arc;
/// A minecart a player rides through a portal rolls on at the motion it
/// crossed with, clamped to 0.4 along x and z (`PositionMoveRotation.of`
/// takes `OldMinecartBehavior.getKnownMovement`), not at rest. Vanilla
/// 26.3 sent the client a cart arriving with motion (0.4, 0, 0).
#[test]
fn a_minecart_a_player_rides_keeps_its_clamped_motion_through_a_portal() {
let mut carts = vec![minecarts::Minecart::new(
3,
minecarts::CartKind::Rideable,
Vec3::new(9.9, 100.0, 4.5),
)];
let at = Vec3::new(1.5, 100.0, 0.5);
let cart = take_vehicle(&mut carts, 3, at, 0.0, Vec3::new(1.6, -0.1, -0.7))
.expect("the cart travels");
assert_eq!(cart.position, at);
assert_eq!(cart.velocity, Vec3::new(0.4, -0.1, -0.4));
}
/// The vehicle a player rides travels with it through a portal: it
/// leaves the level the player came from, keeps its entity id (so the
/// ride is never broken) and stands where its rider arrives
@@ -939,7 +976,7 @@ mod tests {
let mut carts: Vec<minecarts::Minecart> = Vec::new();
let mut mobs: Vec<Mob> = Vec::new();
let at = Vec3::new(1.5, 65.0, -2.5);
let boat = take_vehicle(&mut boats, 7, at, 90.0)
let boat = take_vehicle(&mut boats, 7, at, 90.0, Vec3::ZERO)
.expect("the vehicle the player rides travels with it");
assert!(
boats.is_empty(),
@@ -950,9 +987,9 @@ mod tests {
assert_eq!(boat.yaw, 90.0, "it looks where its rider looks");
assert_eq!(boat.velocity, Vec3::ZERO, "it comes with no motion");
// A vehicle that is not in the level does not travel.
assert!(take_vehicle(&mut carts, 9, at, 0.0).is_none());
assert!(take_vehicle(&mut carts, 9, at, 0.0, Vec3::ZERO).is_none());
assert!(carts.is_empty());
assert!(take_vehicle(&mut mobs, 9, at, 0.0).is_none());
assert!(take_vehicle(&mut mobs, 9, at, 0.0, Vec3::ZERO).is_none());
assert!(mobs.is_empty());
}
}
+22 -1
View File
@@ -348,7 +348,7 @@ impl Game<'_> {
height: self.world.shape.height,
logical_height: self.world.shape.logical_height,
has_skylight: self.world.shape.sky_light,
has_ceiling: self.dim == Dim::Nether,
has_ceiling: self.world.shape.has_ceiling,
elytra_flying: self.player.as_ref().is_some_and(|p| p.fall_flying),
cloud_height: horizon_client::gfx::pack_uniforms::cloud_height(
sky.cloud_color[3],
@@ -496,6 +496,9 @@ impl Game<'_> {
self.push_span(start, "ENTITIES", id);
if let Some(last) = self.entity_spans.borrow_mut().last_mut() {
last.translucent = translucent;
if let Some(gpu) = self.skin_frame.borrow_mut().spans.last_mut() {
gpu.translucent = translucent;
}
}
}
@@ -529,6 +532,9 @@ impl Game<'_> {
.emission;
if let Some(last) = self.entity_spans.borrow_mut().last_mut() {
last.emissive = emission > 0;
if let Some(gpu) = self.skin_frame.borrow_mut().spans.last_mut() {
gpu.emissive = emission > 0;
}
}
}
@@ -541,6 +547,10 @@ impl Game<'_> {
{
last.player = true;
last.hidden = hidden;
if let Some(gpu) = self.skin_frame.borrow_mut().spans.last_mut() {
gpu.player = true;
gpu.hidden = hidden;
}
}
}
@@ -565,6 +575,17 @@ impl Game<'_> {
id,
..Default::default()
};
if self.pack_ids.is_some() && self.skinning() {
let mut skin = self.skin_frame.borrow_mut();
let gpu = EntitySpan {
start: skin.vertices,
..span
};
match skin.spans.last_mut() {
Some(last) if last.start == gpu.start => *last = gpu,
_ => skin.spans.push(gpu),
}
}
// A span that drew nothing gives way to the next.
match spans.last_mut() {
Some(last) if last.start == span.start => *last = span,
+1
View File
@@ -201,6 +201,7 @@ impl Game<'_> {
let Some(p) = self.player.as_ref() else {
return false;
};
let _attacker = self.knockback_scope(p.entity_id);
let from = [p.position.x, p.position.y, p.position.z];
// The enchantments' share, scaled by the attack strength unless the
// weapon is in use.
+3 -9
View File
@@ -97,15 +97,9 @@ impl PrimedTnt {
// Entity.applyEffectsFromBlocks: a bubble column lifts or drags it, and the
// columns it is at the top of send their surface particles.
let mut tops = Vec::new();
horizon_client::motion::bubble_column_push_over(
world,
assets.registry,
facts,
collision,
self.bounds(),
&mut self.velocity,
|pos| tops.push(pos),
);
self.push_by_bubble_column_over(world, assets.registry, facts, collision, |pos| {
tops.push(pos)
});
if self.on_ground {
// Entity.applyEffectsFromBlocks -> SlimeBlock.stepOn.
let under = world.block(
@@ -633,7 +633,14 @@ pub(super) fn level_shapes(
"minecraft:the_end",
] {
let shape = overrides.level_height(level).and_then(|h| {
LevelShape::new(h.min_y, h.height, h.logical_height, h.has_skylight).ok()
LevelShape::new(
h.min_y,
h.height,
h.logical_height,
h.has_skylight,
h.has_ceiling,
)
.ok()
});
match shape {
Some(shape) => *shapes.get_mut(level) = shape,
@@ -700,13 +707,15 @@ pub(super) fn make_streaming(
shape,
)
});
// `FlatLevelSource.createState`: a superflat world whose settings let structure sets
// generate runs them on its flat ground.
// PlaceCommand.placeJigsaw/placeStructure generate directly, independently
// of WorldOptions.generateStructures. Keep their flat locator available
// even when automatic structure starts are disabled.
let automatic_structures =
horizon_client::save::WorldGen::read(world_root).is_none_or(|w| w.structures);
let flat_structures = (dim == Dim::Overworld)
.then(|| horizon_client::save::world_gen_settings(world_root))
.flatten()
.and_then(|s| horizon_client::flatgen::FlatTerrain::structure_settings(&s))
.filter(|_| horizon_client::save::WorldGen::read(world_root).is_none_or(|w| w.structures));
.and_then(|s| horizon_client::flatgen::FlatTerrain::structure_settings(&s));
// A generator that cannot be built (a layer block the registry lacks, a level shorter
// than the noise) leaves the world on its flat ground, as it was before.
let flat_vanilla = flat_structures.and_then(|settings| {
@@ -719,7 +728,7 @@ pub(super) fn make_streaming(
&biomes,
seed,
&settings,
true,
automatic_structures,
) {
Ok(mut terrain) => {
terrain.add_mod_signs(res.facts());
@@ -742,7 +751,11 @@ pub(super) fn make_streaming(
pack.context_with(biomes.clone()),
res.threads(),
)
} else if let Some(inner) = flat_vanilla {
} else if let Some(inner) = flat_vanilla
.as_ref()
.filter(|_| automatic_structures)
.cloned()
{
inner.search_rings_behind();
inner.set_reference_file(world_root.join("data").join(format!(
"horizon_structure_references_{}.json",
@@ -766,6 +779,9 @@ pub(super) fn make_streaming(
res.threads(),
)
} else if let Some(inner) = flat {
// Keep the existing flat terrain/decorations path when automatic
// structures are off; only manual commands use the locator.
locator = flat_vanilla;
Streaming::new(
Arc::new(Persistent {
inner: Arc::new(inner),
@@ -795,7 +811,10 @@ pub(super) fn make_streaming(
let preset = horizon_client::save::WorldGen::preset(world_root);
let overrides =
world_worldgen_overrides(world_root, &options.resources, &biomes, enabled_features);
let mut inner = VanillaTerrain::for_world_with(&options.resources.join("worldgen"), &overrides, pack.assets.registry, &biomes, seed, preset, dim.id(), structures).map_err(|e| {
let saved_stem = horizon_client::save::world_gen_settings(world_root)
.and_then(|s| s.get("dimensions")?.get(dim.id()).cloned())
.map(|stem| horizon_client::scoreboard::tag_to_json(&stem));
let mut inner = VanillaTerrain::for_world_with_settings(&options.resources.join("worldgen"), &overrides, pack.assets.registry, &biomes, seed, preset, dim.id(), structures, saved_stem.as_ref()).map_err(|e| {
if overrides.is_empty() {
format!("{e}\nPrepare the worldgen bundle with the launcher, or: horizon-prepare --prepare")
} else {
+67 -5
View File
@@ -21,7 +21,7 @@ use horizon_world::{
server_player_damage::ServerDamageEnvironment,
synced_floats::{FloatValue, Observer, SyncedFloats},
};
use std::{fs, io, path::Path, sync::OnceLock};
use std::{cell::Cell, fs, io, path::Path, sync::OnceLock};
pub(crate) type Attributes = LiveAttributes<Box<dyn horizon_world::attributes::live::Identities>>;
pub(crate) type State = HurtState<DamageTypeId>;
@@ -45,6 +45,8 @@ pub enum Hurting<'a> {
Mob(&'a crate::mobs::Mob, &'a crate::mobs::Damage),
/// The ender dragon, by its entity id.
Dragon(i32, HurtSource<'a>),
/// An armor stand, by its entity id, for sweep knockback.
ArmorStand(i32, HurtSource<'a>),
}
/// The game's native damage hook (`native_living_incoming_damage`), set once by the
/// game: `LivingEntity.hurtServer` once its guards passed. It may change the
@@ -55,7 +57,7 @@ pub fn hurt_server_canceled(hurting: Hurting<'_>, amount: &mut f32) -> bool {
HURT_SERVER.get().is_some_and(|hook| hook(hurting, amount))
}
/// A knockback's strength and direction, as `LivingEntity.knockback` has them
/// once the knockback resistance is applied. The entity is pushed away from
/// before knockback resistance is applied. The entity is pushed away from
/// the direction (`x`, `z`), which vanilla points at the source of the hit.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Knock {
@@ -68,6 +70,44 @@ pub struct Knock {
/// and before the knockback resistance and the motion change. It may change the [`Knock`]; true
/// cancels it.
pub static KNOCKBACK: OnceLock<fn(Hurting<'_>, &mut Knock) -> bool> = OnceLock::new();
thread_local! {
static KNOCKBACK_ATTACKER: Cell<i32> = const { Cell::new(0) };
static KNOCKBACK_HELD: std::cell::RefCell<[Option<crate::items::ItemStack>; 2]> =
const { std::cell::RefCell::new([None, None]) };
}
/// The entity behind the synchronous attack, or 0 outside an attack scope.
pub fn knockback_attacker() -> i32 {
KNOCKBACK_ATTACKER.get()
}
/// What the attacker holds in the main and off hand, or none when it is not known (or no
/// native mod listens: the game only records it then).
pub fn knockback_held() -> [Option<crate::items::ItemStack>; 2] {
KNOCKBACK_HELD.with(|held| held.borrow().clone())
}
/// Restore the previous attacker on return or unwind, including nested guest actions.
pub fn knockback_from(entity: i32) -> impl Drop {
knockback_from_holding(entity, [None, None])
}
/// [`knockback_from`] that also records the attacker's held stacks (main hand, off hand).
pub fn knockback_from_holding(
entity: i32,
held: [Option<crate::items::ItemStack>; 2],
) -> impl Drop {
struct Restore(i32, [Option<crate::items::ItemStack>; 2]);
impl Drop for Restore {
fn drop(&mut self) {
KNOCKBACK_ATTACKER.set(self.0);
KNOCKBACK_HELD.with(|held| *held.borrow_mut() = std::mem::take(&mut self.1));
}
}
let held = KNOCKBACK_HELD.with(|slot| std::mem::replace(&mut *slot.borrow_mut(), held));
Restore(KNOCKBACK_ATTACKER.replace(entity), held)
}
/// Whether [`KNOCKBACK`] canceled the knockback (never while it is unset). A
/// value it leaves that is not a finite number is put back as it was.
pub fn knockback_canceled(pushed: Hurting<'_>, knock: &mut Knock) -> bool {
@@ -948,6 +988,27 @@ impl ServerPlayerHurtWorld for Environment<'_> {
#[cfg(test)]
mod tests {
#[test]
fn knockback_attacker_scopes_restore_on_return_and_unwind() {
assert_eq!(super::knockback_attacker(), 0);
{
let _outer = super::knockback_from(7);
let panic = std::panic::catch_unwind(|| {
let _inner = super::knockback_from(9);
assert_eq!(super::knockback_attacker(), 9);
panic!("test unwind");
});
assert!(panic.is_err());
assert_eq!(super::knockback_attacker(), 7);
assert_eq!(
std::thread::spawn(super::knockback_attacker)
.join()
.unwrap(),
0
);
}
assert_eq!(super::knockback_attacker(), 0);
}
#[test]
fn a_hit_in_the_hurt_cooldown_knocks_nobody_back() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -1064,7 +1125,7 @@ mod tests {
use super::Hurting;
fn hook(hurting: Hurting<'_>, amount: &mut f32) -> bool {
let (entity, seen) = match hurting {
Hurting::Player(e, s) | Hurting::Dragon(e, s) => {
Hurting::Player(e, s) | Hurting::Dragon(e, s) | Hurting::ArmorStand(e, s) => {
(e, format!("{} {}", s.damage_type, s.causing))
}
Hurting::Mob(mob, source) => (mob.id as i32, format!("{:?}", source.kind)),
@@ -1236,8 +1297,8 @@ mod tests {
// The gear's value is the whole attribute: it replaces the mob's own.
assert!((pushed(Some(0.4), 0.5) - -0.4 * 0.6).abs() < 1e-12);
}
/// `LivingEntity.knockback`: the hook runs once the knockback resistance
/// took its share and before the motion changes; a mod's strength or
/// `LivingEntity.knockback`: the hook runs before knockback resistance
/// takes its share and before motion changes; a mod's strength or
/// direction is what pushes, a cancel pushes nothing, and with no mod in
/// the way the push is vanilla's. Expected values are worked out by hand
/// from vanilla's formula: the horizontal speed halves and the push is
@@ -1283,6 +1344,7 @@ mod tests {
// A stronger push the other way: twice the strength, x turned around.
let turned = push(9102, 0.0);
assert_eq!(velocity(&turned), (0.8, 0.4, 0.0));
assert_eq!(velocity(&push(9102, 0.5)), (0.4, 0.4, 0.0));
// Numbers that are not finite are put back as the game made them.
assert_eq!(velocity(&push(9103, 0.0)), (-0.4, 0.4, 0.0));
// Before the resistance (`LivingKnockBackEvent`): the hook is handed the raw 0.4, and
+131 -15
View File
@@ -299,15 +299,20 @@ impl SectionCoder {
/// The section's biome container (`PalettedContainerRO.write`'s), in
/// network biome ids.
fn biomes(&self, section: &Section) -> Paletted {
let values: Vec<i32> = (0..BIOMES)
.map(|j| {
let internal = section.biomes.get(j) as u16;
self.biome_ids
.get(usize::from(internal))
.copied()
.unwrap_or(0)
})
.collect();
let net = |v: u32| {
self.biome_ids
.get(usize::from(v as u16))
.copied()
.unwrap_or(0)
};
if let Some(v) = section.biomes.uniform() {
return Paletted {
bits: 0,
palette: vec![net(v)],
data: Vec::new(),
};
}
let values: Vec<i32> = (0..BIOMES).map(|j| net(section.biomes.get(j))).collect();
Paletted::from_values(&values, Strategy::Biomes, self.biome_bits)
}
}
@@ -315,7 +320,7 @@ impl SectionCoder {
/// Sections kept before the ones no longer resident are dropped.
const CACHE_SWEEP: usize = 1 << 15;
/// Uncached sections below which a batch is encoded on the tick's thread
/// Listed uncached section occurrences below which a batch is encoded on the tick's thread
/// alone: waking the warm-up threads costs more than a few sections.
const WARM_MIN: usize = 16;
@@ -634,7 +639,12 @@ impl ChunkEncoder {
// One lock for the column: body_in asks for nine columns' sections.
let mut cache = self.cache.lock().unwrap();
for (i, s) in chunk.sections[self.sections.clone()].iter().enumerate() {
if self.entry(&mut cache, s).non_air {
let non_air = if s.blocks.bits() == 0 {
!self.is_air(s.blocks.get(0))
} else {
self.entry(&mut cache, s).non_air
};
if non_air {
bits.insert(i);
}
}
@@ -758,7 +768,7 @@ impl ChunkEncoder {
}
/// The work `warm` shares out: the heightmaps of the columns and the
/// sections not cached yet; none when there is too little to wake the
/// distinct sections not cached yet; none when there is too little to wake the
/// warm-up threads for.
fn job(&self, columns: &[&Chunk]) -> Option<Warm> {
let stale: Vec<((i32, i32), Vec<Arc<Section>>)> = match &self.tables {
@@ -773,7 +783,7 @@ impl ChunkEncoder {
}
None => Vec::new(),
};
let sections: Vec<Arc<Section>> = {
let mut sections: Vec<Arc<Section>> = {
let cache = self.cache.lock().unwrap();
columns
.iter()
@@ -789,6 +799,8 @@ impl ChunkEncoder {
if sections.len() + stale.len() < WARM_MIN {
return None;
}
let mut seen = crate::fast_hash::FxHashSet::default();
sections.retain(|s| seen.insert(Arc::as_ptr(s)));
let (min_y, height) = self.level();
Some(Warm {
coder: self.coder.clone(),
@@ -1591,6 +1603,110 @@ mod tests {
w.data
}
#[test]
fn uniform_and_mixed_biomes_keep_the_network_palette() {
let names = ["minecraft:plains".to_owned(), "minecraft:desert".to_owned()];
let encoder = ChunkEncoder::new(
[(Arc::<str>::from("minecraft:air"), false)],
&Builtin::default(),
&names,
&[names[1].clone(), names[0].clone()],
);
let mut section = Section::default();
assert_eq!(
encoder.coder.biomes(&section),
Paletted {
bits: 0,
palette: vec![1],
data: Vec::new()
}
);
section.biomes.set(13, 1);
let mut expected = [1; BIOMES];
expected[13] = 0;
assert_eq!(
encoder.coder.biomes(&section),
Paletted::from_values(&expected, Strategy::Biomes, 1)
);
section.biomes.set(13, 0);
assert_eq!(
encoder.coder.biomes(&section),
Paletted {
bits: 0,
palette: vec![1],
data: Vec::new()
}
);
section.biomes =
horizon_core::paletted::PalettedContainer::new(section.biomes.strategy(), 99);
assert_eq!(
encoder.coder.biomes(&section),
Paletted {
bits: 0,
palette: vec![0],
data: Vec::new()
}
);
}
#[test]
fn a_batch_encodes_each_shared_section_once() {
let encoder = encoder();
let air = Arc::new(Section::default());
let mut stone = Section::default();
stone.blocks = horizon_core::paletted::PalettedContainer::new(stone.blocks.strategy(), 1);
let stone = Arc::new(stone);
let mut edited = Chunk::empty(9, 9);
edited.set(3, MIN_Y + 40, 5, 2);
let mixed = edited.sections[2].clone();
let (mut a, mut b) = (Chunk::empty(0, 0), Chunk::empty(1, 0));
for (i, s) in a.sections.iter_mut().enumerate() {
*s = if i % 2 == 0 {
air.clone()
} else {
stone.clone()
};
}
for (i, s) in b.sections.iter_mut().enumerate() {
*s = if i % 3 == 0 {
stone.clone()
} else {
air.clone()
};
}
a.sections[7] = mixed.clone();
b.sections[5] = mixed.clone();
let job = encoder.job(&[&a, &b]).unwrap();
assert_eq!(job.sections.len(), 3);
job.run();
encoder.keep(&job);
for s in [&air, &stone, &mixed] {
let kept = encoder
.cached(s, |e| e.wire.clone())
.expect("kept by the job");
assert_eq!(&*kept.0, fresh(&encoder, s));
}
for c in [&a, &b] {
let expected: Vec<u8> = c.sections.iter().flat_map(|s| fresh(&encoder, s)).collect();
assert_eq!(encoder.encode(c, &HashMap::new()).chunk.buffer, expected);
}
}
#[test]
fn uniform_and_mixed_sections_report_the_same_occupancy() {
let encoder = encoder();
let mut chunk = Chunk::empty(0, 0);
for (i, state) in [(1, 1), (4, 2), (10, 99)] {
let section = Arc::make_mut(&mut chunk.sections[i]);
section.blocks =
horizon_core::paletted::PalettedContainer::new(section.blocks.strategy(), state);
}
chunk.set(1, MIN_Y + 7 * 16, 1, 1);
assert_eq!(encoder.occupied(&chunk), SectionMask::from_iter([1, 4, 7]));
chunk.set(1, MIN_Y + 7 * 16, 1, 0);
assert_eq!(encoder.occupied(&chunk), SectionMask::from_iter([1, 4]));
}
#[test]
fn cached_sections_follow_edits() {
let encoder = encoder();
@@ -1910,7 +2026,7 @@ mod tests {
let mut cold = ChunkEncoder::new(states, &builtin, &[], &[]);
cold.tables = alone.tables.clone();
let job = cold.job(&chunks).unwrap();
assert_eq!((job.columns.len(), job.sections.len()), (16, 17 * SECTIONS));
assert_eq!((job.columns.len(), job.sections.len()), (16, 16 * SECTIONS));
job.run();
cold.keep(&job);
assert_eq!(cold.heights.lock().unwrap().len(), 16);
@@ -1963,7 +2079,7 @@ mod tests {
#[test]
fn a_tall_column_sends_its_whole_height() {
let shape = LevelShape::new(-64, 2096, 2096, true).unwrap();
let shape = LevelShape::new(-64, 2096, 2096, true, false).unwrap();
let mut chunk = Chunk::with_layout(0, 0, shape.column());
chunk.set(3, 2000, 4, 1);
let mut encoder = encoder();
Loaded 100 of 224 files, more files were not shown because too many files have changed in this diff. Show more