mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 22:43:47 -10:00
Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
029de76f05 | ||
|
|
5e5a2c74d2 | ||
|
|
2556a20c74 | ||
|
|
cc2bc8fb84 | ||
|
|
e9eb640ac0 | ||
|
|
0bb31c7d42 | ||
|
|
bf1fe4344d | ||
|
|
57cc476ac8 | ||
|
|
e058605ee6 | ||
|
|
bc807a6da1 | ||
|
|
b8f73142f9 | ||
|
|
00c25b5398 | ||
|
|
3e382789c5 | ||
|
|
68a348299e | ||
|
|
3e20d5c7e6 | ||
|
|
86fb2c8f82 | ||
|
|
f970ed281d | ||
|
|
ebc5806996 | ||
|
|
f6a8608beb | ||
|
|
6cafca9435 | ||
|
|
0caab26bc4 | ||
|
|
31d5f62db1 | ||
|
|
35a64d25c2 | ||
|
|
d8db492dca | ||
|
|
b2bda56ed3 | ||
|
|
e6f5a1ae7c | ||
|
|
cb14a7a1fb | ||
|
|
dd74d6b563 | ||
|
|
e3c831ab0d | ||
|
|
00fb064655 | ||
|
|
0d75e2e519 | ||
|
|
dfaa2864d5 | ||
|
|
d4f736d2bf | ||
|
|
0d8198d62d | ||
|
|
2de5ff4ef1 | ||
|
|
57b8346713 | ||
|
|
cbed0c07bd | ||
|
|
8a3f5f6939 | ||
|
|
f5be69df70 | ||
|
|
fc111a8fc0 | ||
|
|
5651a6af8d | ||
|
|
d073e1dc7e | ||
|
|
351c82aded | ||
|
|
6d3917100d | ||
|
|
78792c54ea | ||
|
|
dea101aafb | ||
|
|
4aa12eb27f | ||
|
|
d3c7a961e8 | ||
|
|
64688576ee | ||
|
|
194163c333 | ||
|
|
f579f440f4 | ||
|
|
779ab7dd4c | ||
|
|
e4b2778edb | ||
|
|
0d95220d49 | ||
|
|
b440c53cee | ||
|
|
c33c3acf9b | ||
|
|
44b03616c1 | ||
|
|
1d20a9db1d | ||
|
|
6219424298 | ||
|
|
55487cb931 | ||
|
|
a2f0a5baa8 | ||
|
|
dd428288d1 | ||
|
|
78a634a191 | ||
|
|
3c0a4db876 | ||
|
|
c3911b7608 | ||
|
|
0cfd1d821c | ||
|
|
5b47e1f45b | ||
|
|
0a0c2fd5f1 | ||
|
|
7a39319074 | ||
|
|
873ccc1ecf | ||
|
|
5f75afdbd0 | ||
|
|
d02c07cde6 | ||
|
|
3711915577 | ||
|
|
89b6008f8b | ||
|
|
53ba4b5a0a | ||
|
|
835f2b5222 |
No files matched your search
@@ -3,6 +3,7 @@
|
||||
//! from `pack.mcmeta` `language` sections, and a process-wide current
|
||||
//! language used by every screen.
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{BTreeMap, HashMap},
|
||||
sync::{
|
||||
Arc, RwLock,
|
||||
@@ -34,6 +35,15 @@ pub struct Language {
|
||||
pub const DEFAULT: &str = "en_us";
|
||||
|
||||
static CURRENT: RwLock<Option<Arc<Language>>> = RwLock::new(None);
|
||||
/// A language that only the calling thread sees, and the keys it was asked for and did not have
|
||||
/// (`scoped`).
|
||||
struct Scope {
|
||||
language: Arc<Language>,
|
||||
missed: Vec<String>,
|
||||
}
|
||||
thread_local! {
|
||||
static SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
|
||||
}
|
||||
/// The current language's `bidirectional`, read for every drawn string.
|
||||
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
@@ -254,8 +264,50 @@ pub fn set(language: Language) {
|
||||
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
|
||||
}
|
||||
pub fn current() -> Option<Arc<Language>> {
|
||||
if let Some(language) = SCOPE.with(|s| s.borrow().as_ref().map(|s| s.language.clone())) {
|
||||
return Some(language);
|
||||
}
|
||||
CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
}
|
||||
/// Runs `f` with `language` as the current language of this thread only, and returns what it
|
||||
/// returned with the keys `get` and `get_or` looked up and `language` did not have, in the order
|
||||
/// asked and without repeats. Nothing is set process-wide, so a test can draw a screen in another
|
||||
/// language while the tests beside it read theirs. Scopes nest: the inner one wins, and the outer
|
||||
/// one's misses do not include the inner one's. For tests: `bidirectional` stays the process-wide
|
||||
/// language's.
|
||||
pub fn scoped<R>(language: Language, f: impl FnOnce() -> R) -> (R, Vec<String>) {
|
||||
/// Puts the outer scope back, also when `f` panics.
|
||||
struct Restore(Option<Scope>);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
SCOPE.with(|s| *s.borrow_mut() = self.0.take());
|
||||
}
|
||||
}
|
||||
let outer = SCOPE.with(|s| {
|
||||
s.borrow_mut().replace(Scope {
|
||||
language: Arc::new(language),
|
||||
missed: Vec::new(),
|
||||
})
|
||||
});
|
||||
let _restore = Restore(outer);
|
||||
let result = f();
|
||||
let missed = SCOPE.with(|s| {
|
||||
s.borrow_mut()
|
||||
.as_mut()
|
||||
.map(|s| std::mem::take(&mut s.missed))
|
||||
});
|
||||
(result, missed.unwrap_or_default())
|
||||
}
|
||||
/// `key` was asked for in a `scoped` language that has no text for it.
|
||||
fn missed(key: &str) {
|
||||
SCOPE.with(|s| {
|
||||
if let Some(scope) = s.borrow_mut().as_mut()
|
||||
&& !scope.missed.iter().any(|m| m == key)
|
||||
{
|
||||
scope.missed.push(key.to_owned());
|
||||
}
|
||||
});
|
||||
}
|
||||
/// `ClientLanguage.defaultRightToLeft` of the current language.
|
||||
pub fn bidirectional() -> bool {
|
||||
BIDIRECTIONAL.load(Ordering::Relaxed)
|
||||
@@ -267,13 +319,19 @@ pub fn code() -> String {
|
||||
pub fn get(key: &str) -> String {
|
||||
current()
|
||||
.and_then(|l| l.get(key).map(str::to_owned))
|
||||
.unwrap_or_else(|| key.to_owned())
|
||||
.unwrap_or_else(|| {
|
||||
missed(key);
|
||||
key.to_owned()
|
||||
})
|
||||
}
|
||||
/// The translation, or `fallback` when the key is missing.
|
||||
pub fn get_or(key: &str, fallback: &str) -> String {
|
||||
current()
|
||||
.and_then(|l| l.get(key).map(str::to_owned))
|
||||
.unwrap_or_else(|| fallback.to_owned())
|
||||
.unwrap_or_else(|| {
|
||||
missed(key);
|
||||
fallback.to_owned()
|
||||
})
|
||||
}
|
||||
pub fn has(key: &str) -> bool {
|
||||
current().is_some_and(|l| l.get(key).is_some())
|
||||
@@ -350,6 +408,52 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scoped_language_is_the_calling_threads_and_reports_what_it_lacks() {
|
||||
let empty = Mem(HashMap::new());
|
||||
let outside = code();
|
||||
let ((german, english_only, fallback, elsewhere), missed) =
|
||||
scoped(Language::load(&[&empty], "de_de"), || {
|
||||
(
|
||||
get("horizon.osk.close"),
|
||||
get("horizon.no_such_key"),
|
||||
get_or("horizon.no_fallback", "English"),
|
||||
// Another thread keeps the process-wide language.
|
||||
std::thread::spawn(code).join().unwrap(),
|
||||
)
|
||||
});
|
||||
assert_eq!(german, "Schließen");
|
||||
assert_eq!(english_only, "horizon.no_such_key");
|
||||
assert_eq!(fallback, "English");
|
||||
assert_eq!(elsewhere, outside);
|
||||
assert_eq!(missed, ["horizon.no_such_key", "horizon.no_fallback"]);
|
||||
// The scope ends with its closure.
|
||||
assert_eq!(code(), outside);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scopes_nest_and_end_when_their_closure_panics() {
|
||||
let empty = Mem(HashMap::new());
|
||||
let outside = code();
|
||||
let (inner, missed) = scoped(Language::load(&[&empty], "fr_fr"), || {
|
||||
let (inner, inner_missed) = scoped(Language::load(&[&empty], "pl_pl"), || {
|
||||
(code(), get("horizon.inner_miss"))
|
||||
});
|
||||
assert_eq!(inner_missed, ["horizon.inner_miss"]);
|
||||
assert_eq!(code(), "fr_fr");
|
||||
inner.0
|
||||
});
|
||||
assert_eq!(inner, "pl_pl");
|
||||
assert!(missed.is_empty());
|
||||
let panicked = std::panic::catch_unwind(|| {
|
||||
scoped(Language::load(&[&Mem(HashMap::new())], "de_de"), || {
|
||||
panic!("a test failed inside a scope")
|
||||
})
|
||||
});
|
||||
assert!(panicked.is_err());
|
||||
assert_eq!(code(), outside);
|
||||
}
|
||||
|
||||
/// `crates/horizon-client/assets/horizon/lang`, where Horizon's own strings live.
|
||||
fn bundled_dir() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -51,6 +51,10 @@ impl InteractionInput {
|
||||
pub fn press(&mut self, button: Button) {
|
||||
self.clicks[button as usize] += 1;
|
||||
}
|
||||
/// Whether a click waits for the next tick.
|
||||
pub fn has_clicks(&self) -> bool {
|
||||
self.clicks.iter().any(|&c| c > 0)
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, world: &mut impl InteractionWorld, attack_down: bool, use_down: bool) {
|
||||
let [attack, use_item, pick] = std::mem::take(&mut self.clicks);
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
//! OptiFine custom entity models over the block entities' models (`block_entity_render`'s
|
||||
//! `MODELS`): `chest.jem`, `shulker_box.jem`, `banner.jem`, `bell.jem`, `head_zombie.jem`... (the
|
||||
//! "Block entity name" table of OptiFine's `cem_model.txt`, and Entity Model Features' one-file
|
||||
//! `banner`), found and read as the mobs' are ([`horizon_optifine::cem::files`]). Each entry
|
||||
//! replaces or joins the boxes of the vanilla part it names and takes its `scale`; animations
|
||||
//! don't run yet. A `.jem`'s texture paints the models it reshapes instead of vanilla's when the
|
||||
//! atlas has it.
|
||||
//!
|
||||
//! An entry's part is OptiFine's name for it, else the vanilla part's own name. Trapped and
|
||||
//! ender chests take only their own file, and copper chests (no CEM name) keep vanilla's chest.
|
||||
//! Lectern and enchanting-table books take theirs, else `book`. Signs, hanging signs and beds are
|
||||
//! block models here, so their files are not read; copper golem statues have no CEM name.
|
||||
|
||||
use crate::block_entity_render::ModelPart;
|
||||
use horizon_optifine::cem::{
|
||||
files::{atlas_name, jem_files, load_jem},
|
||||
model::{Bone, EntityModel},
|
||||
};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
/// One model the renderer draws, the CEM names that reshape it, best first, and OptiFine's
|
||||
/// names for its vanilla parts.
|
||||
struct Target {
|
||||
key: &'static str,
|
||||
/// The vanilla model it starts from: `key` itself, or the one a block the renderer tells
|
||||
/// apart shares (a trapped chest's is the chest's).
|
||||
base: &'static str,
|
||||
names: &'static [&'static str],
|
||||
parts: &'static [(&'static str, &'static str)],
|
||||
}
|
||||
|
||||
const CHEST: &[(&str, &str)] = &[
|
||||
("root", "root"),
|
||||
("base", "bottom"),
|
||||
("lid", "lid"),
|
||||
("knob", "lock"),
|
||||
];
|
||||
const SHULKER: &[(&str, &str)] = &[("root", "root"), ("base", "base"), ("lid", "lid")];
|
||||
const BANNER: &[(&str, &str)] = &[("stand", "pole"), ("top", "bar")];
|
||||
const FLAG: &[(&str, &str)] = &[("slate", "flag")];
|
||||
const HEAD: &[(&str, &str)] = &[("root", "root"), ("head", "head")];
|
||||
const DRAGON: &[(&str, &str)] = &[("root", "root"), ("head", "head"), ("jaw", "jaw")];
|
||||
const PIGLIN: &[(&str, &str)] = &[
|
||||
("root", "root"),
|
||||
("head", "head"),
|
||||
("left_ear", "left_ear"),
|
||||
("right_ear", "right_ear"),
|
||||
];
|
||||
const BELL: &[(&str, &str)] = &[
|
||||
("root", "root"),
|
||||
("body", "bell_body"),
|
||||
("base", "bell_base"),
|
||||
];
|
||||
const BOOK: &[(&str, &str)] = &[
|
||||
("root", "root"),
|
||||
("cover_right", "right_lid"),
|
||||
("cover_left", "left_lid"),
|
||||
("pages_right", "right_pages"),
|
||||
("pages_left", "left_pages"),
|
||||
("flipping_page_right", "flip_page1"),
|
||||
("flipping_page_left", "flip_page2"),
|
||||
("book_spine", "seam"),
|
||||
];
|
||||
const POT_BASE: &[(&str, &str)] = &[("neck", "neck"), ("top", "top"), ("bottom", "bottom")];
|
||||
const POT_SIDES: &[(&str, &str)] = &[
|
||||
("front", "front"),
|
||||
("back", "back"),
|
||||
("left", "left"),
|
||||
("right", "right"),
|
||||
];
|
||||
|
||||
const fn model(
|
||||
key: &'static str,
|
||||
names: &'static [&'static str],
|
||||
parts: &'static [(&'static str, &'static str)],
|
||||
) -> Target {
|
||||
Target {
|
||||
key,
|
||||
base: key,
|
||||
names,
|
||||
parts,
|
||||
}
|
||||
}
|
||||
const fn copy(
|
||||
key: &'static str,
|
||||
base: &'static str,
|
||||
names: &'static [&'static str],
|
||||
parts: &'static [(&'static str, &'static str)],
|
||||
) -> Target {
|
||||
Target {
|
||||
key,
|
||||
base,
|
||||
names,
|
||||
parts,
|
||||
}
|
||||
}
|
||||
|
||||
/// In order: a copy comes after its base.
|
||||
const TARGETS: &[Target] = &[
|
||||
model("chest", &["chest"], CHEST),
|
||||
model("double_chest_left", &["chest_left"], CHEST),
|
||||
model("double_chest_right", &["chest_right"], CHEST),
|
||||
copy("trapped_chest", "chest", &["trapped_chest"], CHEST),
|
||||
copy(
|
||||
"trapped_chest_left",
|
||||
"double_chest_left",
|
||||
&["trapped_chest_left"],
|
||||
CHEST,
|
||||
),
|
||||
copy(
|
||||
"trapped_chest_right",
|
||||
"double_chest_right",
|
||||
&["trapped_chest_right"],
|
||||
CHEST,
|
||||
),
|
||||
copy("ender_chest", "chest", &["ender_chest"], CHEST),
|
||||
model("shulker_box", &["shulker_box"], SHULKER),
|
||||
model("standing_banner", &["banner_base", "banner"], BANNER),
|
||||
model("wall_banner", &["banner_base", "banner"], BANNER),
|
||||
model("standing_banner_flag", &["banner_flag", "banner"], FLAG),
|
||||
model("wall_banner_flag", &["banner_flag", "banner"], FLAG),
|
||||
model("skeleton_skull", &["head_skeleton"], HEAD),
|
||||
model("wither_skeleton_skull", &["head_wither_skeleton"], HEAD),
|
||||
model("zombie_head", &["head_zombie"], HEAD),
|
||||
model("creeper_head", &["head_creeper"], HEAD),
|
||||
model("dragon_skull", &["head_dragon"], DRAGON),
|
||||
model("piglin_head", &["head_piglin"], PIGLIN),
|
||||
model("player_head", &["head_player"], HEAD),
|
||||
model("bell", &["bell"], BELL),
|
||||
model("book", &["enchanting_book", "book"], BOOK),
|
||||
copy("lectern_book", "book", &["lectern_book", "book"], BOOK),
|
||||
model("conduit_shell", &["conduit"], &[("base", "shell")]),
|
||||
model("conduit_cage", &["conduit"], &[("cage", "shell")]),
|
||||
model("conduit_wind", &["conduit"], &[("wind", "wind")]),
|
||||
model("conduit_eye", &["conduit"], &[("eye", "eye")]),
|
||||
model("decorated_pot_base", &["decorated_pot"], POT_BASE),
|
||||
model("decorated_pot_sides", &["decorated_pot"], POT_SIDES),
|
||||
];
|
||||
|
||||
/// Every model, reshaped or vanilla's, and the atlas names of the `.jem` textures by model.
|
||||
pub type Reshaped = (
|
||||
HashMap<&'static str, ModelPart>,
|
||||
HashMap<&'static str, String>,
|
||||
);
|
||||
|
||||
/// The models the packs under `root` reshape, over `vanilla`'s; None when no block entity has
|
||||
/// a `.jem` there.
|
||||
pub fn reshape(root: &Path, vanilla: &HashMap<&'static str, ModelPart>) -> Option<Reshaped> {
|
||||
let (reshaped, unused) = reshape_all(root, vanilla)?;
|
||||
for (path, part) in unused {
|
||||
log::warn!("Horizon: custom entity model {path}: no part {part}");
|
||||
}
|
||||
Some(reshaped)
|
||||
}
|
||||
|
||||
/// [`reshape`]'s models, and each file's entries whose part none of the models it reshapes has.
|
||||
fn reshape_all(
|
||||
root: &Path,
|
||||
vanilla: &HashMap<&'static str, ModelPart>,
|
||||
) -> Option<(Reshaped, Vec<(String, String)>)> {
|
||||
let files = jem_files(root);
|
||||
let named = |t: &Target| t.names.iter().any(|n| files.contains_key(*n));
|
||||
if !TARGETS.iter().any(named) {
|
||||
return None;
|
||||
}
|
||||
let mut models = vanilla.clone();
|
||||
let mut textures = HashMap::new();
|
||||
let mut reshaped = HashSet::new();
|
||||
// Each file parsed once; which of its entries found a part in some model.
|
||||
let mut parsed: HashMap<&str, Option<EntityModel>> = HashMap::new();
|
||||
let mut used: BTreeMap<&str, Vec<bool>> = BTreeMap::new();
|
||||
for t in TARGETS {
|
||||
let Some(model) = vanilla.get(t.base) else {
|
||||
continue;
|
||||
};
|
||||
let found = t
|
||||
.names
|
||||
.iter()
|
||||
.flat_map(|name| files.get(*name).into_iter().flatten())
|
||||
.map(String::as_str)
|
||||
.find(|&path| {
|
||||
parsed
|
||||
.entry(path)
|
||||
.or_insert_with(|| load_jem(root, path))
|
||||
.is_some()
|
||||
});
|
||||
let Some(path) = found else {
|
||||
// A copy of a reshaped base that has no file of its own stays vanilla.
|
||||
if t.key != t.base && reshaped.contains(t.base) {
|
||||
models.insert(t.key, model.clone());
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let Some(Some(jem)) = parsed.get(path) else {
|
||||
continue;
|
||||
};
|
||||
let mut out = model.clone();
|
||||
let hits = apply(&mut out, jem, t.parts);
|
||||
// A file shared by several models (the conduit's, the pot's) paints only the models
|
||||
// it reshapes: vanilla geometry keeps its own texture's layout.
|
||||
let reshapes = hits.iter().any(|hit| *hit);
|
||||
let marks = used
|
||||
.entry(path)
|
||||
.or_insert_with(|| vec![false; jem.parts.len()]);
|
||||
for (mark, hit) in marks.iter_mut().zip(hits) {
|
||||
*mark |= hit;
|
||||
}
|
||||
models.insert(t.key, out);
|
||||
reshaped.insert(t.key);
|
||||
if reshapes && let Some(texture) = jem.texture.as_deref().and_then(atlas_name) {
|
||||
textures.insert(t.key, texture);
|
||||
}
|
||||
}
|
||||
let mut unused = Vec::new();
|
||||
for (path, marks) in &used {
|
||||
let Some(Some(jem)) = parsed.get(path) else {
|
||||
continue;
|
||||
};
|
||||
for (p, _) in jem.parts.iter().zip(marks).filter(|(_, hit)| !**hit) {
|
||||
unused.push(((*path).to_owned(), p.part.clone()));
|
||||
}
|
||||
}
|
||||
Some(((models, textures), unused))
|
||||
}
|
||||
|
||||
/// `jem`'s entries on `model`, OptiFine's part names read through `parts`, else as the vanilla
|
||||
/// part's own name: a replacing entry clears its part's boxes, and each joins the part as a child
|
||||
/// `cem#<n>`. Which entries found their part.
|
||||
fn apply(model: &mut ModelPart, jem: &EntityModel, parts: &[(&str, &str)]) -> Vec<bool> {
|
||||
let mut count = 0;
|
||||
let mut hits = Vec::with_capacity(jem.parts.len());
|
||||
for p in &jem.parts {
|
||||
let mapped = parts.iter().find(|(of, _)| *of == p.part).map(|&(_, v)| v);
|
||||
let target = match mapped {
|
||||
Some(vanilla) if find_mut(model, vanilla).is_some() => vanilla,
|
||||
_ => p.part.as_str(),
|
||||
};
|
||||
let Some(node) = find_mut(model, target) else {
|
||||
hits.push(false);
|
||||
continue;
|
||||
};
|
||||
if !p.attach {
|
||||
node.polygons.clear();
|
||||
}
|
||||
let (name, mut bone) = part(&p.bone, &mut count);
|
||||
bone.pose[6..9].fill(p.scale);
|
||||
node.children.push((name, bone));
|
||||
hits.push(true);
|
||||
}
|
||||
hits
|
||||
}
|
||||
|
||||
/// The model itself for `root`, else its first part named `name`, depth first.
|
||||
fn find_mut<'a>(model: &'a mut ModelPart, name: &str) -> Option<&'a mut ModelPart> {
|
||||
fn child<'a>(part: &'a mut ModelPart, name: &str) -> Option<&'a mut ModelPart> {
|
||||
part.children.iter_mut().find_map(|(n, c)| {
|
||||
if n.as_str() == name {
|
||||
Some(c)
|
||||
} else {
|
||||
child(c, name)
|
||||
}
|
||||
})
|
||||
}
|
||||
if name == "root" {
|
||||
return Some(model);
|
||||
}
|
||||
child(model, name)
|
||||
}
|
||||
|
||||
/// A baked bone as a model part, named `cem#<n>`: unique in the model, so no vanilla pose
|
||||
/// change by name reaches it.
|
||||
fn part(bone: &Bone, count: &mut usize) -> (String, ModelPart) {
|
||||
let name = format!("cem#{count}");
|
||||
*count += 1;
|
||||
let [x, y, z] = bone.pivot;
|
||||
let [rx, ry, rz] = bone.rotation;
|
||||
let out = ModelPart {
|
||||
pose: [x, y, z, rx, ry, rz, 1.0, 1.0, 1.0],
|
||||
polygons: bone.quads.iter().map(|q| q.vertices).collect(),
|
||||
children: bone.children.iter().map(|c| part(c, count)).collect(),
|
||||
};
|
||||
(name, out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use std::{collections::BTreeSet, fs};
|
||||
|
||||
/// A part with one face and `children`.
|
||||
pub(crate) fn plane(children: Vec<(&str, ModelPart)>) -> ModelPart {
|
||||
ModelPart {
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons: vec![[[0.0; 5]; 4]],
|
||||
children: children
|
||||
.into_iter()
|
||||
.map(|(n, c)| (n.to_owned(), c))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
pub(crate) fn chest() -> ModelPart {
|
||||
let mut root = plane(vec![
|
||||
("bottom", plane(vec![])),
|
||||
("lid", plane(vec![])),
|
||||
("lock", plane(vec![])),
|
||||
]);
|
||||
root.polygons.clear();
|
||||
root
|
||||
}
|
||||
pub(crate) const BOX: &str =
|
||||
r#""boxes": [{"coordinates": [0, 0, 0, 1, 1, 1], "textureOffset": [0, 0]}]"#;
|
||||
/// A fresh pack folder named for `test`, holding `files` under `assets/minecraft/`.
|
||||
pub(crate) fn pack(test: &str, files: &[(&str, String)]) -> std::path::PathBuf {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-be-cem-{test}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
for (path, text) in files {
|
||||
let path = root.join("assets/minecraft").join(path);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, text).unwrap();
|
||||
}
|
||||
root
|
||||
}
|
||||
/// A `.jem` replacing each of `parts` with one box.
|
||||
fn jem(parts: &[&str]) -> String {
|
||||
let entries: Vec<_> = parts
|
||||
.iter()
|
||||
.map(|p| format!(r#"{{"part": "{p}", {BOX}}}"#))
|
||||
.collect();
|
||||
format!(r#"{{"models": [{}]}}"#, entries.join(", "))
|
||||
}
|
||||
/// A `.jem` replacing each of `parts` with a box of its own: the `i`th is `i + 1` pixels
|
||||
/// deep, so a part that took another entry's box shows.
|
||||
fn jem_sized(parts: &[&str]) -> String {
|
||||
let entries: Vec<_> = parts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
let depth = i + 1;
|
||||
format!(
|
||||
r#"{{"part": "{p}", "boxes": [{{"coordinates": [0, 0, 0, 1, 1, {depth}], "textureOffset": [0, 0]}}]}}"#
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
format!(r#"{{"models": [{}]}}"#, entries.join(", "))
|
||||
}
|
||||
/// How deep (z) the custom box under `model`'s part `name` is.
|
||||
fn depth(model: &ModelPart, name: &str) -> f32 {
|
||||
let part = model.child(name).unwrap();
|
||||
let zs = part
|
||||
.children
|
||||
.iter()
|
||||
.flat_map(|(_, c)| c.polygons.iter().flatten().map(|v| v[2]));
|
||||
let (lo, hi) = zs.fold((f32::MAX, f32::MIN), |(lo, hi), z| (lo.min(z), hi.max(z)));
|
||||
hi - lo
|
||||
}
|
||||
/// Whether `model`'s part `name` was replaced by one custom box: its own face gone, and one
|
||||
/// child of a box's six faces.
|
||||
fn replaced(model: &ModelPart, name: &str) -> bool {
|
||||
let part = model.child(name).unwrap();
|
||||
match part.children.as_slice() {
|
||||
[(_, only)] => part.polygons.is_empty() && only.polygons.len() == 6,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_optifine_part_name_lands_on_its_vanilla_part() {
|
||||
// File, OptiFine's part, the model it reshapes, vanilla's part there.
|
||||
let cases = [
|
||||
("chest", "base", "chest", "bottom"),
|
||||
("chest", "lid", "chest", "lid"),
|
||||
("chest", "knob", "chest", "lock"),
|
||||
("chest_left", "base", "double_chest_left", "bottom"),
|
||||
("chest_left", "lid", "double_chest_left", "lid"),
|
||||
("chest_left", "knob", "double_chest_left", "lock"),
|
||||
("chest_right", "base", "double_chest_right", "bottom"),
|
||||
("chest_right", "lid", "double_chest_right", "lid"),
|
||||
("chest_right", "knob", "double_chest_right", "lock"),
|
||||
("trapped_chest", "base", "trapped_chest", "bottom"),
|
||||
("trapped_chest", "knob", "trapped_chest", "lock"),
|
||||
("trapped_chest_left", "base", "trapped_chest_left", "bottom"),
|
||||
("trapped_chest_left", "knob", "trapped_chest_left", "lock"),
|
||||
(
|
||||
"trapped_chest_right",
|
||||
"base",
|
||||
"trapped_chest_right",
|
||||
"bottom",
|
||||
),
|
||||
("trapped_chest_right", "knob", "trapped_chest_right", "lock"),
|
||||
("ender_chest", "base", "ender_chest", "bottom"),
|
||||
("ender_chest", "knob", "ender_chest", "lock"),
|
||||
("shulker_box", "base", "shulker_box", "base"),
|
||||
("shulker_box", "lid", "shulker_box", "lid"),
|
||||
("banner", "stand", "standing_banner", "pole"),
|
||||
("banner", "top", "standing_banner", "bar"),
|
||||
("banner", "top", "wall_banner", "bar"),
|
||||
("banner", "slate", "standing_banner_flag", "flag"),
|
||||
("banner", "slate", "wall_banner_flag", "flag"),
|
||||
("head_skeleton", "head", "skeleton_skull", "head"),
|
||||
(
|
||||
"head_wither_skeleton",
|
||||
"head",
|
||||
"wither_skeleton_skull",
|
||||
"head",
|
||||
),
|
||||
("head_zombie", "head", "zombie_head", "head"),
|
||||
("head_creeper", "head", "creeper_head", "head"),
|
||||
("head_dragon", "head", "dragon_skull", "head"),
|
||||
("head_dragon", "jaw", "dragon_skull", "jaw"),
|
||||
("head_piglin", "head", "piglin_head", "head"),
|
||||
("head_piglin", "left_ear", "piglin_head", "left_ear"),
|
||||
("head_piglin", "right_ear", "piglin_head", "right_ear"),
|
||||
("head_player", "head", "player_head", "head"),
|
||||
("bell", "body", "bell", "bell_body"),
|
||||
("bell", "base", "bell", "bell_base"),
|
||||
("enchanting_book", "cover_right", "book", "right_lid"),
|
||||
("enchanting_book", "cover_left", "book", "left_lid"),
|
||||
("enchanting_book", "pages_right", "book", "right_pages"),
|
||||
("enchanting_book", "pages_left", "book", "left_pages"),
|
||||
(
|
||||
"enchanting_book",
|
||||
"flipping_page_right",
|
||||
"book",
|
||||
"flip_page1",
|
||||
),
|
||||
(
|
||||
"enchanting_book",
|
||||
"flipping_page_left",
|
||||
"book",
|
||||
"flip_page2",
|
||||
),
|
||||
("enchanting_book", "book_spine", "book", "seam"),
|
||||
("lectern_book", "cover_right", "lectern_book", "right_lid"),
|
||||
("lectern_book", "pages_left", "lectern_book", "left_pages"),
|
||||
(
|
||||
"lectern_book",
|
||||
"flipping_page_right",
|
||||
"lectern_book",
|
||||
"flip_page1",
|
||||
),
|
||||
("lectern_book", "book_spine", "lectern_book", "seam"),
|
||||
("conduit", "base", "conduit_shell", "shell"),
|
||||
("conduit", "cage", "conduit_cage", "shell"),
|
||||
("conduit", "wind", "conduit_wind", "wind"),
|
||||
("conduit", "eye", "conduit_eye", "eye"),
|
||||
("decorated_pot", "neck", "decorated_pot_base", "neck"),
|
||||
("decorated_pot", "top", "decorated_pot_base", "top"),
|
||||
("decorated_pot", "bottom", "decorated_pot_base", "bottom"),
|
||||
("decorated_pot", "front", "decorated_pot_sides", "front"),
|
||||
("decorated_pot", "back", "decorated_pot_sides", "back"),
|
||||
("decorated_pot", "left", "decorated_pot_sides", "left"),
|
||||
("decorated_pot", "right", "decorated_pot_sides", "right"),
|
||||
];
|
||||
// One file per name with each of its parts, and a stub of each model holding its parts
|
||||
// (chest models all of theirs, so a misplaced entry shows).
|
||||
let mut files: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
|
||||
let mut trees: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
|
||||
for (file, part, key, vanilla) in cases {
|
||||
files.entry(file).or_default().insert(part);
|
||||
trees.entry(key).or_default().insert(vanilla);
|
||||
}
|
||||
for key in ["chest", "double_chest_left", "double_chest_right"] {
|
||||
trees
|
||||
.entry(key)
|
||||
.or_default()
|
||||
.extend(["bottom", "lid", "lock"]);
|
||||
}
|
||||
let files_by_name = files.clone();
|
||||
let files: Vec<_> = files
|
||||
.iter()
|
||||
.map(|(file, parts)| {
|
||||
let parts: Vec<_> = parts.iter().copied().collect();
|
||||
(format!("optifine/cem/{file}.jem"), jem_sized(&parts))
|
||||
})
|
||||
.collect();
|
||||
let files: Vec<_> = files.iter().map(|(p, t)| (p.as_str(), t.clone())).collect();
|
||||
let root = pack("parts", &files);
|
||||
let vanilla: HashMap<&'static str, ModelPart> = trees
|
||||
.iter()
|
||||
.map(|(key, parts)| {
|
||||
let parts = parts.iter().map(|p| (*p, plane(vec![]))).collect();
|
||||
(*key, plane(parts))
|
||||
})
|
||||
.collect();
|
||||
let result = reshape_all(&root, &vanilla);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let ((models, _), unused) = result.unwrap();
|
||||
for (file, part, key, vanilla) in cases {
|
||||
assert!(
|
||||
replaced(&models[key], vanilla),
|
||||
"{file}.jem {part}: {key} {vanilla}"
|
||||
);
|
||||
// The box is this entry's own: swapped names (left and right, body and base) show.
|
||||
let own = files_by_name[file].iter().position(|p| *p == part).unwrap() + 1;
|
||||
assert_eq!(
|
||||
depth(&models[key], vanilla),
|
||||
own as f32,
|
||||
"{file}.jem {part}: {key} {vanilla}"
|
||||
);
|
||||
}
|
||||
assert_eq!(unused, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chest_jem_reshapes_the_chest_alone_and_warns_of_entries_without_a_part() {
|
||||
let root = pack(
|
||||
"chest",
|
||||
&[(
|
||||
"optifine/cem/chest.jem",
|
||||
format!(
|
||||
r#"{{"texture": "textures/entity/chest/fancy.png", "models": [
|
||||
{{"part": "lid", {BOX}}},
|
||||
{{"part": "knob", "attach": true, "scale": 2, {BOX}}},
|
||||
{{"part": "bottom", {BOX}}},
|
||||
{{"part": "tail", {BOX}}}]}}"#
|
||||
),
|
||||
)],
|
||||
);
|
||||
let vanilla: HashMap<_, _> = [("chest", chest()), ("double_chest_left", chest())].into();
|
||||
let result = reshape_all(&root, &vanilla);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let ((models, textures), unused) = result.unwrap();
|
||||
let chest = &models["chest"];
|
||||
// Replaced: the lid's own face goes and the custom box (6 faces) joins it.
|
||||
assert!(replaced(chest, "lid"));
|
||||
// `bottom` is no OptiFine name but vanilla's own: it reaches the part as well.
|
||||
assert!(replaced(chest, "bottom"));
|
||||
// Attached: the lock keeps its face beside the scaled box.
|
||||
let lock = chest.child("lock").unwrap();
|
||||
assert_eq!((lock.polygons.len(), lock.children.len()), (1, 1));
|
||||
assert_eq!(lock.children[0].1.pose[6..], [2.0, 2.0, 2.0]);
|
||||
// `tail` names no chest part: the one warning.
|
||||
assert_eq!(
|
||||
unused,
|
||||
[(
|
||||
"assets/minecraft/optifine/cem/chest.jem".to_owned(),
|
||||
"tail".to_owned()
|
||||
)]
|
||||
);
|
||||
// The double chest has no file, nor do trapped and ender chests: vanilla's own, and
|
||||
// only the chest takes the texture.
|
||||
for key in ["double_chest_left", "trapped_chest", "ender_chest"] {
|
||||
let lid = models[key].child("lid").unwrap();
|
||||
assert_eq!((lid.polygons.len(), lid.children.len()), (1, 0), "{key}");
|
||||
}
|
||||
assert_eq!(
|
||||
textures,
|
||||
HashMap::from([("chest", "entity/chest/fancy".to_owned())])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banners_split_one_file_and_books_fall_back() {
|
||||
let root = pack(
|
||||
"banner",
|
||||
&[
|
||||
("optifine/cem/banner.jem", jem(&["stand", "top", "slate"])),
|
||||
("emf/cem/enchanting_book.jem", jem(&["cover_left"])),
|
||||
],
|
||||
);
|
||||
let banner = plane(vec![("pole", plane(vec![])), ("bar", plane(vec![]))]);
|
||||
let wall = plane(vec![("bar", plane(vec![]))]);
|
||||
let flag = plane(vec![("flag", plane(vec![]))]);
|
||||
let book = plane(vec![("left_lid", plane(vec![]))]);
|
||||
let vanilla: HashMap<_, _> = [
|
||||
("standing_banner", banner),
|
||||
("wall_banner", wall),
|
||||
("wall_banner_flag", flag),
|
||||
("book", book),
|
||||
]
|
||||
.into();
|
||||
let result = reshape_all(&root, &vanilla);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let ((models, textures), unused) = result.unwrap();
|
||||
assert!(replaced(&models["standing_banner"], "pole"));
|
||||
assert!(replaced(&models["standing_banner"], "bar"));
|
||||
assert!(replaced(&models["wall_banner"], "bar"));
|
||||
assert!(replaced(&models["wall_banner_flag"], "flag"));
|
||||
assert!(replaced(&models["book"], "left_lid"));
|
||||
// The lectern's book has no file of its own nor `book.jem`: vanilla's.
|
||||
let lectern = models["lectern_book"].child("left_lid").unwrap();
|
||||
assert_eq!((lectern.polygons.len(), lectern.children.len()), (1, 0));
|
||||
assert!(textures.is_empty());
|
||||
// Each entry found its part in one of the models: no warning.
|
||||
assert_eq!(unused, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn book_jem_reshapes_the_lectern_book_without_its_own_file() {
|
||||
let root = pack("book", &[("optifine/cem/book.jem", jem(&["cover_left"]))]);
|
||||
let vanilla: HashMap<_, _> = [("book", plane(vec![("left_lid", plane(vec![]))]))].into();
|
||||
let result = reshape(&root, &vanilla);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let (models, _) = result.unwrap();
|
||||
assert!(replaced(&models["book"], "left_lid"));
|
||||
assert!(replaced(&models["lectern_book"], "left_lid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_file_paints_only_the_models_it_reshapes() {
|
||||
// The conduit's file reshapes its eye alone: the shell, cage and wind keep their
|
||||
// vanilla geometry and so their own textures.
|
||||
let file = format!(
|
||||
r#"{{"texture": "textures/entity/conduit/custom.png", "models": [{{"part": "eye", {BOX}}}]}}"#
|
||||
);
|
||||
let root = pack("conduit", &[("optifine/cem/conduit.jem", file)]);
|
||||
let vanilla: HashMap<_, _> = [
|
||||
"conduit_shell",
|
||||
"conduit_cage",
|
||||
"conduit_wind",
|
||||
"conduit_eye",
|
||||
]
|
||||
.map(|key| {
|
||||
let part = if key == "conduit_eye" { "eye" } else { "shell" };
|
||||
(key, plane(vec![(part, plane(vec![]))]))
|
||||
})
|
||||
.into();
|
||||
let result = reshape(&root, &vanilla);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let (models, textures) = result.unwrap();
|
||||
assert!(replaced(&models["conduit_eye"], "eye"));
|
||||
let painted: BTreeSet<_> = textures.keys().copied().collect();
|
||||
assert_eq!(painted, BTreeSet::from(["conduit_eye"]));
|
||||
assert_eq!(textures["conduit_eye"], "entity/conduit/custom");
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,12 @@ use horizon_world::{
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, f32::consts::PI, path::Path, sync::Mutex};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
f32::consts::PI,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex, PoisonError, RwLock},
|
||||
};
|
||||
|
||||
/// One baked `ModelPart`: initial pose, textured polygons (pixel units,
|
||||
/// normalized UVs) and named children.
|
||||
@@ -802,16 +807,102 @@ pub struct Scene<'a> {
|
||||
/// Empty by default (a dedicated server): no state has a block entity model.
|
||||
#[derive(Default)]
|
||||
pub struct BlockEntityRenderer {
|
||||
models: HashMap<&'static str, ModelPart>,
|
||||
vanilla: Arc<BlockEntityShapes>,
|
||||
/// What draws: `vanilla`, or the packs' custom entity models over it.
|
||||
shapes: RwLock<Arc<BlockEntityShapes>>,
|
||||
kinds: Vec<Kind>,
|
||||
// Closed chest geometry: dimension lighting, chest part, horizontal facing.
|
||||
closed_chests: [[[Vec<Vertex>; 4]; 3]; 2],
|
||||
/// Item id to its `PROVIDES_POTTERY_PATTERN` side texture (`Sheets.DECORATED_POT_MAPPER`).
|
||||
pot_patterns: HashMap<String, String>,
|
||||
/// `Block.shouldRenderFace` for End gateway faces (its occlusion pair cache needs a lock).
|
||||
visibility: Option<Mutex<FaceVisibility>>,
|
||||
sign_texts: Mutex<SignTexts>,
|
||||
}
|
||||
/// The block-entity models and what is baked from them: vanilla's, or the packs' custom entity
|
||||
/// models over them ([`crate::block_entity_cem`]).
|
||||
#[derive(Default)]
|
||||
pub struct BlockEntityShapes {
|
||||
models: HashMap<&'static str, ModelPart>,
|
||||
/// Closed chest geometry by chest model: dimension lighting, horizontal facing.
|
||||
closed_chests: HashMap<&'static str, [[Vec<Vertex>; 4]; 2]>,
|
||||
/// A `.jem`'s texture (atlas name) by model, drawn instead of vanilla's.
|
||||
textures: HashMap<&'static str, String>,
|
||||
}
|
||||
/// The chest models, each baked with its lid closed.
|
||||
const CHEST_MODELS: [&str; 7] = [
|
||||
"chest",
|
||||
"double_chest_left",
|
||||
"double_chest_right",
|
||||
"trapped_chest",
|
||||
"trapped_chest_left",
|
||||
"trapped_chest_right",
|
||||
"ender_chest",
|
||||
];
|
||||
impl BlockEntityShapes {
|
||||
fn new(
|
||||
models: HashMap<&'static str, ModelPart>,
|
||||
textures: HashMap<&'static str, String>,
|
||||
) -> Self {
|
||||
let closed_chests = CHEST_MODELS
|
||||
.iter()
|
||||
.filter_map(|key| Some((*key, closed_chest(models.get(key)?))))
|
||||
.collect();
|
||||
Self {
|
||||
models,
|
||||
closed_chests,
|
||||
textures,
|
||||
}
|
||||
}
|
||||
/// The texture model `key` draws with: its `.jem`'s when the atlas has it, else `vanilla`.
|
||||
fn texture<'a>(
|
||||
&'a self,
|
||||
key: &str,
|
||||
vanilla: &'a str,
|
||||
sprites: &HashMap<String, Rect>,
|
||||
) -> &'a str {
|
||||
match self.textures.get(key) {
|
||||
Some(texture) if sprites.contains_key(texture) => texture,
|
||||
_ => vanilla,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A chest model with its lid closed, for each dimension lighting and horizontal facing, its UVs
|
||||
/// over the whole texture.
|
||||
fn closed_chest(model: &ModelPart) -> [[Vec<Vertex>; 4]; 2] {
|
||||
[false, true].map(|nether| {
|
||||
[0.0f32, 90.0, 180.0, 270.0].map(|y_rot| {
|
||||
let mut vertices = Vec::new();
|
||||
draw_part(
|
||||
model,
|
||||
"root",
|
||||
around(
|
||||
Mat4::from_rotation_y((-y_rot).to_radians()),
|
||||
Vec3::new(0.5, 0.0, 0.5),
|
||||
),
|
||||
&|name, p| {
|
||||
if name == "lid" || name == "lock" {
|
||||
p[3] = crate::block_entity_anim::chest_lid_x_rot(0.0);
|
||||
}
|
||||
},
|
||||
Paint {
|
||||
sprite: Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
tint: 0xFFFFFF,
|
||||
light: 0,
|
||||
lights: diffuse_lights(nether),
|
||||
glow: None,
|
||||
},
|
||||
&mut vertices,
|
||||
);
|
||||
vertices
|
||||
})
|
||||
})
|
||||
}
|
||||
/// Laid-out sign text by position. `AbstractSignRenderer` lays the text out
|
||||
/// again each frame, glyph by glyph; its vertices only change with what is
|
||||
/// in the key, so they are kept relative to the block and moved to the camera.
|
||||
@@ -894,6 +985,8 @@ impl BlockEntityRenderer {
|
||||
.collect();
|
||||
let mut renderer = Self::new(models, registry);
|
||||
renderer.visibility = Some(Mutex::new(visibility));
|
||||
// The packs' custom entity models (`.jem` files), before anything draws.
|
||||
renderer.set_shapes(renderer.shapes_for(root));
|
||||
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
|
||||
let items = match horizon_world::item_defaults::read(root) {
|
||||
Ok(items) => items,
|
||||
@@ -920,44 +1013,7 @@ impl BlockEntityRenderer {
|
||||
Ok(renderer)
|
||||
}
|
||||
fn new(models: HashMap<&'static str, ModelPart>, registry: &BlockRegistry) -> Self {
|
||||
let closed_chests = [false, true].map(|nether| {
|
||||
["chest", "double_chest_left", "double_chest_right"].map(|name| {
|
||||
[0.0f32, 90.0, 180.0, 270.0].map(|y_rot| {
|
||||
let mut vertices = Vec::new();
|
||||
if let Some(model) = models.get(name) {
|
||||
draw_part(
|
||||
model,
|
||||
"root",
|
||||
around(
|
||||
Mat4::from_rotation_y((-y_rot).to_radians()),
|
||||
Vec3::new(0.5, 0.0, 0.5),
|
||||
),
|
||||
&|name, p| {
|
||||
if name == "lid" || name == "lock" {
|
||||
p[3] = crate::block_entity_anim::chest_lid_x_rot(0.0);
|
||||
}
|
||||
},
|
||||
Paint {
|
||||
sprite: Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
tint: 0xFFFFFF,
|
||||
light: 0,
|
||||
lights: diffuse_lights(nether),
|
||||
glow: None,
|
||||
},
|
||||
&mut vertices,
|
||||
);
|
||||
}
|
||||
vertices
|
||||
})
|
||||
})
|
||||
});
|
||||
let vanilla = Arc::new(BlockEntityShapes::new(models, HashMap::new()));
|
||||
let kinds = (0..registry.len() as u32)
|
||||
.map(|id| {
|
||||
registry.state(BlockStateId(id)).map_or(Kind::None, |s| {
|
||||
@@ -970,14 +1026,31 @@ impl BlockEntityRenderer {
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
models,
|
||||
shapes: RwLock::new(vanilla.clone()),
|
||||
vanilla,
|
||||
kinds,
|
||||
closed_chests,
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
sign_texts: Mutex::default(),
|
||||
}
|
||||
}
|
||||
/// The models the packs under `root` give, their `.jem` files over vanilla's, for
|
||||
/// [`Self::set_shapes`] (a pack reload): vanilla's own, shared, without any.
|
||||
pub fn shapes_for(&self, root: &Path) -> Arc<BlockEntityShapes> {
|
||||
match crate::block_entity_cem::reshape(root, &self.vanilla.models) {
|
||||
Some((models, textures)) => Arc::new(BlockEntityShapes::new(models, textures)),
|
||||
None => self.vanilla.clone(),
|
||||
}
|
||||
}
|
||||
pub fn set_shapes(&self, shapes: Arc<BlockEntityShapes>) {
|
||||
*self.shapes.write().unwrap_or_else(PoisonError::into_inner) = shapes;
|
||||
}
|
||||
fn shapes(&self) -> Arc<BlockEntityShapes> {
|
||||
self.shapes
|
||||
.read()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.clone()
|
||||
}
|
||||
fn kind(&self, state: u32) -> &Kind {
|
||||
self.kinds.get(state as usize).unwrap_or(&Kind::None)
|
||||
}
|
||||
@@ -1271,6 +1344,7 @@ impl BlockEntityRenderer {
|
||||
world_chests: bool,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let shapes = self.shapes();
|
||||
let sprite = |name: &str| scene.sprites.get(name).copied();
|
||||
let paint = |name: &str, tint: u32, packed: u8| {
|
||||
let rect = if crumbling {
|
||||
@@ -1363,12 +1437,34 @@ impl BlockEntityRenderer {
|
||||
} else {
|
||||
*texture
|
||||
};
|
||||
let model = match part {
|
||||
ChestPart::Single => "chest",
|
||||
ChestPart::Left => "double_chest_left",
|
||||
ChestPart::Right => "double_chest_right",
|
||||
// Trapped and ender chests have models of their own when a `.jem` reshapes
|
||||
// them; copper chests have no CEM name and draw vanilla's chest.
|
||||
let shapes: &BlockEntityShapes =
|
||||
if matches!(block.as_str(), "chest" | "trapped_chest" | "ender_chest") {
|
||||
&shapes
|
||||
} else {
|
||||
&self.vanilla
|
||||
};
|
||||
let (own, chest) = match (block.as_str(), part) {
|
||||
("trapped_chest", ChestPart::Single) => ("trapped_chest", "chest"),
|
||||
("trapped_chest", ChestPart::Left) => {
|
||||
("trapped_chest_left", "double_chest_left")
|
||||
}
|
||||
("trapped_chest", ChestPart::Right) => {
|
||||
("trapped_chest_right", "double_chest_right")
|
||||
}
|
||||
("ender_chest", _) => ("ender_chest", "chest"),
|
||||
(_, ChestPart::Single) => ("chest", "chest"),
|
||||
(_, ChestPart::Left) => ("double_chest_left", "double_chest_left"),
|
||||
(_, ChestPart::Right) => ("double_chest_right", "double_chest_right"),
|
||||
};
|
||||
let Some(model) = self.models.get(model) else {
|
||||
let key = if shapes.models.contains_key(own) {
|
||||
own
|
||||
} else {
|
||||
chest
|
||||
};
|
||||
let texture = shapes.texture(key, texture, scene.sprites);
|
||||
let Some(model) = shapes.models.get(key) else {
|
||||
return;
|
||||
};
|
||||
let (open, other) = self.chest_openness(
|
||||
@@ -1384,9 +1480,11 @@ impl BlockEntityRenderer {
|
||||
packed = (packed >> 4).max(l >> 4) << 4 | (packed & 15).max(l & 15);
|
||||
}
|
||||
if world_chests && open == 0.0 {
|
||||
if let Some(paint) = paint(texture, 0xFFFFFF, packed) {
|
||||
let vertices = &self.closed_chests[usize::from(scene.nether)]
|
||||
[*part as usize][*y_rot as usize / 90];
|
||||
if let (Some(paint), Some(closed)) = (
|
||||
paint(texture, 0xFFFFFF, packed),
|
||||
shapes.closed_chests.get(key),
|
||||
) {
|
||||
let vertices = &closed[usize::from(scene.nether)][*y_rot as usize / 90];
|
||||
let origin = base.w_axis.truncate();
|
||||
let passes = std::iter::once((paint.sprite, paint.light))
|
||||
.chain(paint.glow.map(|g| (g, FULL_BRIGHT)));
|
||||
@@ -1421,8 +1519,12 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
Kind::Shulker { texture, facing } => {
|
||||
let (Some(model), Some(paint)) = (
|
||||
self.models.get("shulker_box"),
|
||||
paint(texture, 0xFFFFFF, packed),
|
||||
shapes.models.get("shulker_box"),
|
||||
paint(
|
||||
shapes.texture("shulker_box", texture, scene.sprites),
|
||||
0xFFFFFF,
|
||||
packed,
|
||||
),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
@@ -1446,12 +1548,13 @@ impl BlockEntityRenderer {
|
||||
draw_part(model, "root", m, &lid, paint, out);
|
||||
}
|
||||
Kind::Banner { color, wall, angle } => {
|
||||
let (main, flag) = if *wall {
|
||||
let (main_key, flag_key) = if *wall {
|
||||
("wall_banner", "wall_banner_flag")
|
||||
} else {
|
||||
("standing_banner", "standing_banner_flag")
|
||||
};
|
||||
let (Some(main), Some(flag)) = (self.models.get(main), self.models.get(flag))
|
||||
let (Some(main), Some(flag)) =
|
||||
(shapes.models.get(main_key), shapes.models.get(flag_key))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -1472,8 +1575,13 @@ impl BlockEntityRenderer {
|
||||
p[3] = (-0.0125 + 0.01 * (2.0 * PI * phase).mcos()) * PI;
|
||||
}
|
||||
};
|
||||
if let Some(paint) = paint("entity/banner/banner_base", 0xFFFFFF, packed) {
|
||||
let banner_base = "entity/banner/banner_base";
|
||||
let main_texture = shapes.texture(main_key, banner_base, scene.sprites);
|
||||
if let Some(paint) = paint(main_texture, 0xFFFFFF, packed) {
|
||||
draw_part(main, "root", m, &none, paint, out);
|
||||
}
|
||||
let flag_texture = shapes.texture(flag_key, banner_base, scene.sprites);
|
||||
if let Some(paint) = paint(flag_texture, 0xFFFFFF, packed) {
|
||||
draw_part(flag, "root", m, &wave, paint, out);
|
||||
}
|
||||
// Pattern passes must not multiply the crumbling decal.
|
||||
@@ -1515,9 +1623,12 @@ impl BlockEntityRenderer {
|
||||
None
|
||||
};
|
||||
let has_profile = skin.is_some();
|
||||
let texture = skin.as_deref().unwrap_or(texture);
|
||||
// A `.jem` texture stands in for the default skin, not a profile's.
|
||||
let texture = skin
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| shapes.texture(model, texture, scene.sprites));
|
||||
let (Some(model), Some(paint)) =
|
||||
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
|
||||
(shapes.models.get(model), paint(texture, 0xFFFFFF, packed))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -1559,8 +1670,12 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
Kind::Bell => {
|
||||
let (Some(model), Some(paint)) = (
|
||||
self.models.get("bell"),
|
||||
paint("entity/bell/bell_body", 0xFFFFFF, packed),
|
||||
shapes.models.get("bell"),
|
||||
paint(
|
||||
shapes.texture("bell", "entity/bell/bell_body", scene.sprites),
|
||||
0xFFFFFF,
|
||||
packed,
|
||||
),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
@@ -1588,8 +1703,8 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
Kind::DecoratedPot { y_rot } => {
|
||||
let (Some(base_model), Some(sides)) = (
|
||||
self.models.get("decorated_pot_base"),
|
||||
self.models.get("decorated_pot_sides"),
|
||||
shapes.models.get("decorated_pot_base"),
|
||||
shapes.models.get("decorated_pot_sides"),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
@@ -1617,17 +1732,20 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
}
|
||||
let first = out.len();
|
||||
if let Some(paint) =
|
||||
paint("entity/decorated_pot/decorated_pot_base", 0xFFFFFF, packed)
|
||||
{
|
||||
let pot_base = "entity/decorated_pot/decorated_pot_base";
|
||||
let pot_base = shapes.texture("decorated_pot_base", pot_base, scene.sprites);
|
||||
if let Some(paint) = paint(pot_base, 0xFFFFFF, packed) {
|
||||
draw_part(base_model, "root", m, &none, paint, out);
|
||||
}
|
||||
// PotDecorations (back, left, right, front); items without a pattern use the blank side.
|
||||
// PotDecorations (back, left, right, front); items without a pattern use the blank
|
||||
// side (a `.jem`'s texture, if any).
|
||||
let blank = "entity/decorated_pot/decorated_pot_side";
|
||||
let blank = shapes.texture("decorated_pot_sides", blank, scene.sprites);
|
||||
for (side, item) in ["back", "left", "right", "front"].iter().zip(&a.sherds) {
|
||||
let texture = item
|
||||
.as_ref()
|
||||
.and_then(|i| self.pot_patterns.get(i))
|
||||
.map_or("entity/decorated_pot/decorated_pot_side", String::as_str);
|
||||
.map_or(blank, String::as_str);
|
||||
if let (Some(part), Some(paint)) =
|
||||
(sides.child(side), paint(texture, 0xFFFFFF, packed))
|
||||
{
|
||||
@@ -1677,11 +1795,14 @@ impl BlockEntityRenderer {
|
||||
let (ticks, active, hunting, raw_rotation) = (scene.anim)(pos).conduit;
|
||||
// ConduitRenderer.extractRenderState.
|
||||
let rotation = (raw_rotation + if active { scene.partial } else { 0.0 }) * -0.0375;
|
||||
let tex = |name: &str| paint(name, 0xFFFFFF, packed);
|
||||
let tex = |key: &str, name: &str| {
|
||||
paint(shapes.texture(key, name, scene.sprites), 0xFFFFFF, packed)
|
||||
};
|
||||
if !active {
|
||||
let (Some(model), Some(paint)) =
|
||||
(self.models.get("conduit_shell"), tex("entity/conduit/base"))
|
||||
else {
|
||||
let (Some(model), Some(paint)) = (
|
||||
shapes.models.get("conduit_shell"),
|
||||
tex("conduit_shell", "entity/conduit/base"),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let m = base
|
||||
@@ -1696,9 +1817,10 @@ impl BlockEntityRenderer {
|
||||
let hh = (anim_time * 0.1).msin() / 2.0 + 0.5;
|
||||
let hh = hh * hh + hh;
|
||||
let lift = Vec3::new(0.5, 0.3 + hh * 0.2, 0.5);
|
||||
if let (Some(model), Some(paint)) =
|
||||
(self.models.get("conduit_cage"), tex("entity/conduit/cage"))
|
||||
{
|
||||
if let (Some(model), Some(paint)) = (
|
||||
shapes.models.get("conduit_cage"),
|
||||
tex("conduit_cage", "entity/conduit/cage"),
|
||||
) {
|
||||
let axis = Vec3::new(0.5, 1.0, 0.5).normalize();
|
||||
let m = base
|
||||
* Mat4::from_translation(lift)
|
||||
@@ -1714,7 +1836,9 @@ impl BlockEntityRenderer {
|
||||
} else {
|
||||
"entity/conduit/wind"
|
||||
};
|
||||
if let (Some(model), Some(paint)) = (self.models.get("conduit_wind"), tex(wind)) {
|
||||
if let (Some(model), Some(paint)) =
|
||||
(shapes.models.get("conduit_wind"), tex("conduit_wind", wind))
|
||||
{
|
||||
let turn = match phase {
|
||||
1 => Mat4::from_rotation_x(PI / 2.0),
|
||||
2 => Mat4::from_rotation_z(PI / 2.0),
|
||||
@@ -1732,7 +1856,9 @@ impl BlockEntityRenderer {
|
||||
} else {
|
||||
"entity/conduit/closed_eye"
|
||||
};
|
||||
if let (Some(model), Some(paint)) = (self.models.get("conduit_eye"), tex(eye)) {
|
||||
if let (Some(model), Some(paint)) =
|
||||
(shapes.models.get("conduit_eye"), tex("conduit_eye", eye))
|
||||
{
|
||||
let m = base
|
||||
* Mat4::from_translation(lift)
|
||||
* Mat4::from_scale(Vec3::splat(0.5))
|
||||
@@ -1743,9 +1869,14 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
}
|
||||
Kind::EnchantingTable => {
|
||||
let book = "entity/enchantment/enchanting_table_book";
|
||||
let (Some(model), Some(paint)) = (
|
||||
self.models.get("book"),
|
||||
paint("entity/enchantment/enchanting_table_book", 0xFFFFFF, packed),
|
||||
shapes.models.get("book"),
|
||||
paint(
|
||||
shapes.texture("book", book, scene.sprites),
|
||||
0xFFFFFF,
|
||||
packed,
|
||||
),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
@@ -1764,9 +1895,16 @@ impl BlockEntityRenderer {
|
||||
draw_part(model, "root", m, &book_pose(openness, ff1, ff2), paint, out);
|
||||
}
|
||||
Kind::Lectern { y_rot } => {
|
||||
// Its own `.jem`'s book, else the enchanting table's.
|
||||
let key = if shapes.models.contains_key("lectern_book") {
|
||||
"lectern_book"
|
||||
} else {
|
||||
"book"
|
||||
};
|
||||
let book = "entity/enchantment/enchanting_table_book";
|
||||
let (Some(model), Some(paint)) = (
|
||||
self.models.get("book"),
|
||||
paint("entity/enchantment/enchanting_table_book", 0xFFFFFF, packed),
|
||||
shapes.models.get(key),
|
||||
paint(shapes.texture(key, book, scene.sprites), 0xFFFFFF, packed),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
@@ -1797,7 +1935,7 @@ impl BlockEntityRenderer {
|
||||
_ => "copper_golem",
|
||||
};
|
||||
let (Some(model), Some(paint)) =
|
||||
(self.models.get(key), paint(texture, 0xFFFFFF, packed))
|
||||
(shapes.models.get(key), paint(texture, 0xFFFFFF, packed))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -2449,7 +2587,8 @@ mod tests {
|
||||
#[test]
|
||||
fn cached_sign_text_matches_and_follows_changes() {
|
||||
let renderer = BlockEntityRenderer {
|
||||
models: HashMap::new(),
|
||||
vanilla: Default::default(),
|
||||
shapes: Default::default(),
|
||||
kinds: vec![
|
||||
Kind::Sign {
|
||||
hanging: false,
|
||||
@@ -2462,7 +2601,6 @@ mod tests {
|
||||
angle: 90.0,
|
||||
},
|
||||
],
|
||||
closed_chests: Default::default(),
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
sign_texts: Mutex::default(),
|
||||
@@ -2646,4 +2784,131 @@ mod tests {
|
||||
"entitySolid culls the pot's back faces"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chest_jem_reshapes_the_closed_chest_and_paints_its_texture() {
|
||||
use crate::block_entity_cem::tests::{BOX, chest, pack};
|
||||
let vanilla = Arc::new(BlockEntityShapes::new(
|
||||
HashMap::from([("chest", chest())]),
|
||||
HashMap::new(),
|
||||
));
|
||||
let renderer = BlockEntityRenderer {
|
||||
shapes: RwLock::new(vanilla.clone()),
|
||||
vanilla,
|
||||
kinds: Vec::new(),
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
sign_texts: Mutex::default(),
|
||||
};
|
||||
// No block entity's `.jem` (a mob's only): vanilla's shapes, not a copy.
|
||||
let empty = pack("render-none", &[("optifine/cem/zombie.jem", "{}".into())]);
|
||||
assert!(Arc::ptr_eq(&renderer.shapes_for(&empty), &renderer.vanilla));
|
||||
let root = pack(
|
||||
"render-chest",
|
||||
&[(
|
||||
"optifine/cem/chest.jem",
|
||||
format!(
|
||||
r#"{{"texture": "textures/entity/chest/fancy.png",
|
||||
"models": [{{"part": "lid", {BOX}}}]}}"#
|
||||
),
|
||||
)],
|
||||
);
|
||||
renderer.set_shapes(renderer.shapes_for(&root));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&empty);
|
||||
let rect = |u0: f32| Rect {
|
||||
u0,
|
||||
v0: 0.0,
|
||||
u1: u0 + 0.2,
|
||||
v1: 0.2,
|
||||
w: 16,
|
||||
h: 16,
|
||||
};
|
||||
let sprites: HashMap<String, Rect> = [
|
||||
("entity/chest/normal", rect(0.0)),
|
||||
("entity/chest/ender", rect(0.2)),
|
||||
("entity/chest/fancy", rect(0.4)),
|
||||
("entity/chest/copper", rect(0.6)),
|
||||
]
|
||||
.map(|(name, r)| (name.to_owned(), r))
|
||||
.into();
|
||||
let world = World::new(BlockStateId(0), 1);
|
||||
let scene = Scene {
|
||||
camera: DVec3::ZERO,
|
||||
orientation: Quat::IDENTITY,
|
||||
anim: &|_| BlockAnim::default(),
|
||||
texts: None,
|
||||
partial: 0.0,
|
||||
game_time: 0,
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
emissive: &Default::default(),
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
// A closed single chest of `block` drawn by `renderer`: positions and UVs.
|
||||
let draw = |renderer: &BlockEntityRenderer, block: &str, texture: &'static str| {
|
||||
let kind = Kind::Chest {
|
||||
block: block.into(),
|
||||
texture,
|
||||
part: ChestPart::Single,
|
||||
y_rot: 0.0,
|
||||
double_with: None,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
renderer.render_one(
|
||||
&scene,
|
||||
[0; 3],
|
||||
&kind,
|
||||
Mat4::IDENTITY,
|
||||
0xF0,
|
||||
diffuse_lights(false),
|
||||
false,
|
||||
true,
|
||||
&mut out,
|
||||
);
|
||||
out.iter().map(|v| (v.pos, v.uv)).collect::<Vec<_>>()
|
||||
};
|
||||
// The chest's three faces become the bottom's, the lock's and the custom lid's six, in
|
||||
// the `.jem`'s texture. Ender and copper chests have no file of their own: vanilla's
|
||||
// three faces in their own textures. (Trapped chests: `block_entity_cem`'s tests; their
|
||||
// texture turns festive in late December.)
|
||||
for (block, texture, u0, faces) in [
|
||||
("chest", "entity/chest/normal", 0.4, 8),
|
||||
("ender_chest", "entity/chest/ender", 0.2, 3),
|
||||
("copper_chest", "entity/chest/copper", 0.6, 3),
|
||||
] {
|
||||
let out = draw(&renderer, block, texture);
|
||||
assert_eq!(out.len(), faces * 4, "{block}");
|
||||
assert!(
|
||||
out.iter().all(|(_, uv)| (u0..=u0 + 0.2).contains(&uv[0])),
|
||||
"{block}"
|
||||
);
|
||||
}
|
||||
// A copper chest draws exactly what it draws without any pack.
|
||||
let plain = BlockEntityRenderer {
|
||||
shapes: RwLock::new(renderer.vanilla.clone()),
|
||||
vanilla: renderer.vanilla.clone(),
|
||||
kinds: Vec::new(),
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
sign_texts: Mutex::default(),
|
||||
};
|
||||
assert_eq!(
|
||||
draw(&renderer, "copper_chest", "entity/chest/copper"),
|
||||
draw(&plain, "copper_chest", "entity/chest/copper")
|
||||
);
|
||||
// The atlas has no such texture: vanilla's.
|
||||
let shapes = renderer.shapes();
|
||||
assert_eq!(
|
||||
shapes.texture("chest", "entity/chest/normal", &HashMap::new()),
|
||||
"entity/chest/normal"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use crate::{
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashSet, fs, path::Path};
|
||||
use std::{collections::HashSet, path::Path};
|
||||
|
||||
pub use horizon_client_core::potions::{
|
||||
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, all_effects,
|
||||
@@ -158,6 +158,28 @@ pub struct Brewing {
|
||||
/// Items accepted in the bottle slots (`PotionIngredient.isPotionInput`).
|
||||
pub inputs: HashSet<String>,
|
||||
}
|
||||
/// The files right in `recipe/brewing/`, parsed, by id: taken from the whole
|
||||
/// recipe folder's read when another table holds it (the start loads them
|
||||
/// together), else read alone. A file that doesn't parse is left out.
|
||||
fn brewing_sources(root: &Path) -> Vec<(String, Value)> {
|
||||
let recipe = root.join("data/minecraft/recipe");
|
||||
let (folder, prefix) = match crate::json_folder::held(&recipe) {
|
||||
Some(folder) => (folder, "brewing/"),
|
||||
None => match crate::json_folder::read(&recipe.join("brewing")) {
|
||||
Ok(folder) => (folder, ""),
|
||||
Err(_) => return Vec::new(),
|
||||
},
|
||||
};
|
||||
folder
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
let name = f.name.strip_prefix(prefix).filter(|n| !n.contains('/'))?;
|
||||
let v = serde_json::from_slice::<Value>(f.bytes().ok()?).ok()?;
|
||||
Some((format!("minecraft:brewing/{name}"), v))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
impl Brewing {
|
||||
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self {
|
||||
// NeoForge's load conditions: a file whose don't hold is silently
|
||||
@@ -172,23 +194,7 @@ impl Brewing {
|
||||
let conditions_hold = |v: &Value| {
|
||||
horizon_world::conditions::conditions_hold(v, &|id: &str| mods.iter().any(|m| m == id))
|
||||
};
|
||||
let mut sources: Vec<(String, Value)> =
|
||||
fs::read_dir(root.join("data/minecraft/recipe/brewing"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let bytes = fs::read(e.path()).ok()?;
|
||||
let v = serde_json::from_slice::<Value>(&bytes).ok()?;
|
||||
Some((
|
||||
format!(
|
||||
"minecraft:brewing/{}",
|
||||
e.file_name().to_string_lossy().strip_suffix(".json")?
|
||||
),
|
||||
v,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let mut sources: Vec<(String, Value)> = brewing_sources(root);
|
||||
// The world's packs' brewing recipes over the game's own: the top
|
||||
// pack's file for each id replaces it (`SimpleJsonResourceReloadListener`).
|
||||
for (id, files) in overlay.kind("recipe") {
|
||||
|
||||
@@ -31,6 +31,9 @@ struct Globals {
|
||||
// HDR output's tier windows (hdr.rs `Hdr::windows`): x..w low to very bright,
|
||||
// [0] where the lift starts, [1] where it is full.
|
||||
hdr_windows: array<vec4<f32>, 2>,
|
||||
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
|
||||
// y what the sky and fog get: the same, but none under water.
|
||||
hdr_light: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@@ -69,6 +72,8 @@ struct Out {
|
||||
// HDR tier of a light-giving particle (mesher::hdr_tier), from the overlay's
|
||||
// high bits (particles.rs): fs_hdr_mark marks the frame with it.
|
||||
@location(13) @interpolate(flat) tier: u32,
|
||||
// HDR lighting (`hdr_scene`), 0 for what isn't lit.
|
||||
@location(14) scene: f32,
|
||||
}
|
||||
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
|
||||
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
|
||||
@@ -107,6 +112,16 @@ fn native_colored_light(pos: vec3<f32>) -> vec3<f32> {
|
||||
}
|
||||
return min(rgb, vec3(1.0));
|
||||
}
|
||||
// HDR lighting: how far an entity's light carries it above paper white, 0 to 1, as terrain's.
|
||||
fn hdr_scene(block: f32, sky: f32) -> f32 {
|
||||
let b = smoothstep(11.0, 15.0, block);
|
||||
let s = smoothstep(11.0, 15.0, sky) * globals.hdr_light.x;
|
||||
return max(b, s);
|
||||
}
|
||||
// The frame's alpha for it, between 1 (none) and the light sources' marks below 0.75.
|
||||
fn hdr_scene_alpha(scene: f32) -> f32 {
|
||||
return 1.0 - 0.24 * scene;
|
||||
}
|
||||
fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
overlay: u32, color: vec4<f32>, light: vec4<u32>, normal: vec4<f32>) -> Out {
|
||||
var out: Out;
|
||||
@@ -120,6 +135,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
let unlit = overlay == 2u || overlay == 4u || overlay == 6u || overlay == 9u || overlay == 10u
|
||||
|| overlay == 11u || overlay == 12u;
|
||||
out.light_color = select(lm, vec4(1.0), unlit);
|
||||
out.scene = select(hdr_scene(l.x / 16.0, l.y / 16.0), 0.0, unlit);
|
||||
if globals.colored_count.x > 0u && !unlit {
|
||||
out.light_color = vec4(min(out.light_color.rgb + native_colored_light(position), vec3(1.0)), out.light_color.a);
|
||||
}
|
||||
@@ -328,8 +344,14 @@ fn shadow_color(in: Out) -> vec4<f32> {
|
||||
var uv = in.uv;
|
||||
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
|
||||
let texel = textureSample(atlas, atlas_sampler, uv);
|
||||
if in.tier == 0u || texel.a < 0.1 { discard; }
|
||||
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, brightness(texel.rgb * in.color.rgb)));
|
||||
if texel.a < 0.1 { discard; }
|
||||
// Fog hides a light source and the light on a surface as it hides everything else.
|
||||
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
|
||||
if in.tier == 0u {
|
||||
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(mix(in.scene, globals.hdr_light.y, fogged)));
|
||||
}
|
||||
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, brightness(texel.rgb * in.color.rgb) * (1.0 - fogged)));
|
||||
}
|
||||
fn brightness(c: vec3<f32>) -> f32 {
|
||||
let l = pow(max(c, vec3(0.0)), vec3(2.2));
|
||||
@@ -348,7 +370,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
|
||||
// no longer shines; fs_hdr_mark then marks the light sources it holds.
|
||||
@fragment fn fs_hdr_hand_unmark(in: Out) -> @location(0) vec4<f32> {
|
||||
if textureSample(atlas, atlas_sampler, in.uv).a < 0.1 { discard; }
|
||||
return vec4(0.0, 0.0, 0.0, 1.0);
|
||||
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(in.scene));
|
||||
}
|
||||
|
||||
fn celestial_color(in: Out) -> vec4<f32> {
|
||||
@@ -375,8 +397,12 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
let added = color.rgb * color.a;
|
||||
// The sun at the brightest tier, the moon at its own (in.tier); geometry that sets
|
||||
// none (the End sky, the End flash) stays brightest.
|
||||
let keep = 1.0 - hdr_mark(select(in.tier, 4u, in.tier == 0u), brightness(added));
|
||||
return vec4(added, keep * smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
|
||||
let tier = select(in.tier, 4u, in.tier == 0u);
|
||||
let y = brightness(added);
|
||||
// Only what's past the tier's window takes the mark: a soft edge keeps the sky's light,
|
||||
// or it would come out darker than the sky around it.
|
||||
if y < globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u] { return vec4(added, 0.0); }
|
||||
return vec4(added, 1.0 - hdr_mark(tier, y));
|
||||
}
|
||||
|
||||
// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by
|
||||
|
||||
@@ -947,6 +947,16 @@ impl Font {
|
||||
.sum::<f32>()
|
||||
.ceil()
|
||||
}
|
||||
/// `Font.width(FormattedCharSequence)` of a sequence a component built
|
||||
/// with `FormattedCharSequence.forward`: every character is a glyph, a
|
||||
/// `§` included (`StringDecomposer.iterate`, not `iterateFormatted`).
|
||||
pub fn width_drawn(&self, t: &Text) -> f32 {
|
||||
crate::bidi::visual(t)
|
||||
.chars()
|
||||
.map(|(c, s)| self.advance(c, s))
|
||||
.sum::<f32>()
|
||||
.ceil()
|
||||
}
|
||||
/// `StringSplitter.splitLines`: each line and whether it continues a
|
||||
/// wrapped one. A line breaks at a `\n`, or once a character overflows
|
||||
/// `max_width` (never the first visible one) at the last space so far, or
|
||||
|
||||
@@ -112,6 +112,7 @@ mod cull;
|
||||
mod end_sky;
|
||||
mod flat;
|
||||
mod frame_gen;
|
||||
mod gpu_lists;
|
||||
mod hdr;
|
||||
mod hdr_icc;
|
||||
mod hdr_stats;
|
||||
@@ -329,6 +330,7 @@ struct Globals {
|
||||
/// HDR output's tier windows, low to very bright: where the lift starts, where it is
|
||||
/// full (`hdr::Hdr::windows`); zero without HDR.
|
||||
hdr_windows: [[f32; 4]; 2],
|
||||
hdr_light: [f32; 4],
|
||||
}
|
||||
pub type SectionKey = (i32, i32, i32);
|
||||
/// The water mirror's targets: the world as a camera mirrored about the water plane sees
|
||||
@@ -356,6 +358,8 @@ struct SectionGpu {
|
||||
sorted_indices: Option<(u32, u64)>,
|
||||
/// Its id for flat draws (`flat::Flat::add`), when they are on.
|
||||
flat_id: Option<u32>,
|
||||
/// Its row for phase 1's lists made on the GPU (`gpu_lists::GpuLists::add`).
|
||||
list_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.
|
||||
@@ -446,12 +450,19 @@ 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` and, in the section arena, the ranges' flat draw ids and
|
||||
/// GPU list rows (`ids`) along. False when the other pages have no room left.
|
||||
/// The flat draws and GPU lists that name a mesh's range, with what writes to them.
|
||||
type MeshIds<'a> = (
|
||||
Option<&'a mut flat::Flat>,
|
||||
Option<&'a mut gpu_lists::GpuLists>,
|
||||
&'a wgpu::Device,
|
||||
&'a mut upload::Uploads,
|
||||
);
|
||||
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 ids: Option<MeshIds<'_>>,
|
||||
meshes: impl Iterator<Item = &'a mut SectionGpu>,
|
||||
page: u32,
|
||||
part: Part,
|
||||
@@ -473,9 +484,14 @@ fn move_off_page<'a>(
|
||||
};
|
||||
arena.copy(encoder, (page, start), to, vertices);
|
||||
arena.release(page, start, vertices);
|
||||
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
|
||||
flat.remove(id);
|
||||
s.flat_id = Some(flat.add(device, encoder, uploads, to.0, to.1, vertices));
|
||||
if let Some((flat, lists, device, uploads)) = &mut ids {
|
||||
if let (Some(flat), Some(id)) = (flat, s.flat_id) {
|
||||
flat.remove(id);
|
||||
s.flat_id = Some(flat.add(device, encoder, uploads, to.0, to.1, vertices));
|
||||
}
|
||||
if let (Some(lists), Some(id)) = (lists, s.list_id) {
|
||||
lists.set(device, encoder, uploads, id, to.1 as u32, &s.sides);
|
||||
}
|
||||
}
|
||||
part.set(s, to);
|
||||
*budget = budget.saturating_sub(bytes);
|
||||
@@ -776,6 +792,9 @@ pub struct Renderer {
|
||||
flat: Option<flat::Flat>,
|
||||
/// `flat` draws this frame's sections (off: the per-section draws).
|
||||
flat_on: bool,
|
||||
/// Phase 1's solid lists made on the GPU: in flat frames (indices), or as
|
||||
/// draws counted on the GPU (`MULTI_DRAW_INDIRECT_COUNT`: Vulkan, DX12).
|
||||
gpu_lists: Option<gpu_lists::GpuLists>,
|
||||
/// Occlusion culling of solid section draws, where the GPU's instance
|
||||
/// counts reach a multi-draw's entries (Vulkan, Metal).
|
||||
cull: Option<cull::Cull>,
|
||||
@@ -1671,6 +1690,7 @@ async fn open_backend(
|
||||
| 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
|
||||
@@ -3496,6 +3516,18 @@ impl Renderer {
|
||||
.get_downlevel_capabilities()
|
||||
.flags
|
||||
.contains(wgpu::DownlevelFlags::INDIRECT_EXECUTION);
|
||||
let gpu_lists = if flat.is_some() {
|
||||
Some(gpu_lists::Out::Indices)
|
||||
} else if multi_draw
|
||||
&& device
|
||||
.features()
|
||||
.contains(wgpu::Features::MULTI_DRAW_INDIRECT_COUNT)
|
||||
{
|
||||
Some(gpu_lists::Out::Draws)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.map(|out| gpu_lists::GpuLists::new(&device, out));
|
||||
let batchable = translucent_batchable(
|
||||
&adapter.get_info(),
|
||||
multi_draw,
|
||||
@@ -3610,6 +3642,7 @@ impl Renderer {
|
||||
solid_cutout,
|
||||
translucent,
|
||||
flat_on: flat.is_some(),
|
||||
gpu_lists,
|
||||
cull_on: cull.is_some(),
|
||||
sides_on: true,
|
||||
cull,
|
||||
@@ -4156,6 +4189,11 @@ impl Renderer {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let sides = if mesh.sides.quads() == solid {
|
||||
mesh.sides
|
||||
} else {
|
||||
Sides::unsorted(solid)
|
||||
};
|
||||
// Flat draws name the blocks of its range in `arena`: split, its solid
|
||||
// quads alone (no frame draws flat then: `translucent_split`).
|
||||
let flat_id = self.flat.as_mut().filter(|_| main > 0).map(|f| {
|
||||
@@ -4168,6 +4206,15 @@ impl Renderer {
|
||||
main,
|
||||
)
|
||||
});
|
||||
let list_id = self.gpu_lists.as_mut().filter(|_| main > 0).map(|l| {
|
||||
l.add(
|
||||
&self.device,
|
||||
encoder,
|
||||
&mut self.section_uploads,
|
||||
start as u32,
|
||||
&sides,
|
||||
)
|
||||
});
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let translucent_arena = self.translucent_arena.as_ref().unwrap_or(&self.arena);
|
||||
for (part, arena, (part_page, first)) in [
|
||||
@@ -4198,11 +4245,7 @@ impl Renderer {
|
||||
start,
|
||||
translucent_at,
|
||||
solid,
|
||||
sides: if mesh.sides.quads() == solid {
|
||||
mesh.sides
|
||||
} else {
|
||||
Sides::unsorted(solid)
|
||||
},
|
||||
sides,
|
||||
translucent,
|
||||
sort: translucency::SortState::new(
|
||||
mesh.translucent
|
||||
@@ -4214,6 +4257,7 @@ impl Renderer {
|
||||
),
|
||||
sorted_indices,
|
||||
flat_id,
|
||||
list_id,
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers,
|
||||
@@ -4286,6 +4330,9 @@ impl Renderer {
|
||||
if let (Some(flat), Some(id)) = (&mut self.flat, s.flat_id) {
|
||||
flat.remove(id);
|
||||
}
|
||||
if let (Some(lists), Some(id)) = (&mut self.gpu_lists, s.list_id) {
|
||||
lists.remove(id);
|
||||
}
|
||||
if let Some((page, start)) = s.sorted_indices {
|
||||
self.sorted_indices
|
||||
.release(page, start, u64::from(s.translucent) * 6);
|
||||
@@ -4328,13 +4375,15 @@ impl Renderer {
|
||||
.sections
|
||||
.values_mut()
|
||||
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()));
|
||||
let flat = self
|
||||
.flat
|
||||
.as_mut()
|
||||
.map(|f| (f, &self.device, &mut self.section_uploads));
|
||||
let ids = Some((
|
||||
self.flat.as_mut(),
|
||||
self.gpu_lists.as_mut(),
|
||||
&self.device,
|
||||
&mut self.section_uploads,
|
||||
));
|
||||
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, ids, meshes, page, part, &mut budget) {
|
||||
// The other pages have no room after all.
|
||||
arena.reopen();
|
||||
}
|
||||
@@ -4640,6 +4689,11 @@ impl Renderer {
|
||||
flat.remove(id);
|
||||
}
|
||||
}
|
||||
if let Some(lists) = &mut self.gpu_lists {
|
||||
for id in released.iter().filter_map(|s| s.list_id) {
|
||||
lists.remove(id);
|
||||
}
|
||||
}
|
||||
self.sorted_indices
|
||||
.release_all(released.iter().filter_map(|s| {
|
||||
let (page, start) = s.sorted_indices?;
|
||||
@@ -5688,6 +5742,17 @@ impl Renderer {
|
||||
0.0,
|
||||
],
|
||||
hdr_windows: self.hdr.as_ref().map_or([[0.0; 4]; 2], hdr::Hdr::windows),
|
||||
hdr_light: [
|
||||
frame.sky.sky_light_factor,
|
||||
// The underwater overlay: the sky and fog are the water's own colour there.
|
||||
if frame.screen.iter().any(|q| q.world_tint) {
|
||||
0.0
|
||||
} else {
|
||||
frame.sky.sky_light_factor
|
||||
},
|
||||
0.0,
|
||||
0.0,
|
||||
],
|
||||
};
|
||||
self.uploads
|
||||
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
|
||||
@@ -5987,10 +6052,44 @@ impl Renderer {
|
||||
(true, true) => &[(1, Quads::All)],
|
||||
}
|
||||
};
|
||||
// Phase 1's sections can leave finding their facing quads to the GPU
|
||||
// (`gpu_lists`) in frames with nothing but the camera's terrain (no
|
||||
// mirror, shader pack terrain or shadow, nor tiles): drawn flat, or
|
||||
// where multi-draws take their count from the GPU.
|
||||
let use_lists = gpu_lists_on()
|
||||
&& !pack_terrain
|
||||
&& shadow_view.is_none()
|
||||
&& mirror.is_none()
|
||||
&& visible_lods.is_empty()
|
||||
&& match &self.flat {
|
||||
Some(flat) => self.flat_on && self.drawn_sections >= flat.min_sections,
|
||||
None => self.multi_draw,
|
||||
};
|
||||
if let Some(lists) = &mut self.gpu_lists {
|
||||
lists.clear(&self.device);
|
||||
}
|
||||
let mut lists = self.gpu_lists.as_mut().filter(|_| use_lists);
|
||||
let mut opaque_draws = 0;
|
||||
for (i, v) in visible.iter().enumerate() {
|
||||
let s = self.sections.get(v.slot);
|
||||
let first_phase = cull.as_ref().is_none_or(|c| c.first(v.slot));
|
||||
if first_phase
|
||||
&& let (Some(lists), Some(id)) = (&mut lists, s.list_id)
|
||||
&& lists.list(
|
||||
id,
|
||||
s.page,
|
||||
v.distance,
|
||||
&s.sides,
|
||||
parts(fade_code(s, &v.key) != 0),
|
||||
v.origin,
|
||||
i as u32,
|
||||
)
|
||||
{
|
||||
if let Some(c) = &mut cull {
|
||||
c.drawn(i, v.slot, s.sides.quads() * 6);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (draws, pages) = if first_phase {
|
||||
(&mut self.draws, &mut self.draw_pages)
|
||||
} else {
|
||||
@@ -6370,6 +6469,12 @@ impl Renderer {
|
||||
}
|
||||
// GameRenderer.useImprovedTransparency. A shader pack keeps its own
|
||||
// path.
|
||||
let hdr_lighting = self.native.is_none()
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
&& std::env::var_os("HORIZON_HDR_LIGHTING").is_none_or(|v| v != "0");
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.lighting = hdr_lighting;
|
||||
}
|
||||
let oit_on = self.improved_transparency
|
||||
&& !frame.title
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
@@ -6594,7 +6699,8 @@ impl Renderer {
|
||||
let flat_frame = match &mut self.flat {
|
||||
Some(flat)
|
||||
if self.flat_on
|
||||
&& !self.draws.is_empty()
|
||||
&& (!self.draws.is_empty()
|
||||
|| self.gpu_lists.as_ref().is_some_and(|l| l.listed() > 0))
|
||||
&& (self.drawn_sections >= flat.min_sections
|
||||
|| pack_terrain
|
||||
|| shadow_view.is_some()) =>
|
||||
@@ -6633,6 +6739,8 @@ impl Renderer {
|
||||
Some((id, lod::instance(v.origin, v.fade)))
|
||||
})),
|
||||
|page| sorted.buffer(page).clone(),
|
||||
self.gpu_lists.as_mut().filter(|l| l.listed() > 0),
|
||||
eye.map(|e| e.to_array()),
|
||||
self.own_timing.as_mut(),
|
||||
)
|
||||
})
|
||||
@@ -6640,7 +6748,8 @@ impl Renderer {
|
||||
_ => false,
|
||||
};
|
||||
if flat_frame {
|
||||
self.section_draw_calls = self.flat.as_ref().map_or(0, |f| f.shadow_calls[0]);
|
||||
self.section_draw_calls = self.flat.as_ref().map_or(0, |f| f.shadow_calls[0])
|
||||
+ self.gpu_lists.as_ref().map_or(0, |l| l.calls().len());
|
||||
} else if self.multi_draw && !self.draws.is_empty() {
|
||||
if self.draws.len() as u64 > self.indirect_capacity {
|
||||
self.indirect_capacity = (self.draws.len() as u64).next_power_of_two();
|
||||
@@ -6658,6 +6767,27 @@ impl Renderer {
|
||||
bytemuck::cast_slice(&self.draws),
|
||||
);
|
||||
}
|
||||
// Without flat draws, the GPU writes the lists' draws and counts them.
|
||||
if let Some(lists) = self
|
||||
.gpu_lists
|
||||
.as_mut()
|
||||
.filter(|l| l.out() == gpu_lists::Out::Draws && l.listed() > 0)
|
||||
{
|
||||
lists.rooms();
|
||||
lists.prepare(
|
||||
&self.device,
|
||||
&mut encoder,
|
||||
&mut self.uploads,
|
||||
None,
|
||||
eye.map(|e| e.to_array()),
|
||||
);
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Section lists"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
lists.dispatch(&mut pass);
|
||||
self.section_draw_calls += lists.calls().len();
|
||||
}
|
||||
let view_target = target_texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
format: Some(self.config.format.remove_srgb_suffix()),
|
||||
..Default::default()
|
||||
@@ -7241,19 +7371,26 @@ impl Renderer {
|
||||
// Without a pack, on Metal, the opaque pass, then the cutout one
|
||||
// (terrain.wgsl's `opaque_quad`), each over phase 1's lists with
|
||||
// quads for it, and over all of phase 2's.
|
||||
// Each pass's GPU-made lists by group, too (`flat::Flat::list`).
|
||||
let [opaque, cutout] = flat.pass_calls;
|
||||
let passes: &[(&wgpu::RenderPipeline, std::ops::Range<usize>)] = match pack_flat
|
||||
{
|
||||
Some(p) => &[(&p.0, 0..flat.phase1_calls)],
|
||||
type Passes<'p> =
|
||||
&'p [(&'p wgpu::RenderPipeline, std::ops::Range<usize>, &'p [u8])];
|
||||
let passes: Passes = match pack_flat {
|
||||
Some(p) => &[(&p.0, 0..flat.phase1_calls, &[0, 1])],
|
||||
None if split_solid => &[
|
||||
(&flat.solid_opaque, 0..opaque),
|
||||
(&flat.solid_cutout, cutout..flat.phase1_calls),
|
||||
(&flat.solid_opaque, 0..opaque, &[0]),
|
||||
(&flat.solid_cutout, cutout..flat.phase1_calls, &[1]),
|
||||
],
|
||||
None => &[(&flat.solid, 0..flat.phase1_calls)],
|
||||
None => &[(&flat.solid, 0..flat.phase1_calls, &[0, 1])],
|
||||
};
|
||||
let draw = |pass: &mut wgpu::RenderPass<'_>| {
|
||||
for (pipeline, calls) in passes {
|
||||
for (pipeline, calls, groups) in passes {
|
||||
pass.set_pipeline(pipeline);
|
||||
if let Some(lists) = &self.gpu_lists {
|
||||
for &group in *groups {
|
||||
flat.draw_lists(pass, lists, group, vertices);
|
||||
}
|
||||
}
|
||||
flat.draw(pass, &flat.calls[calls.clone()], vertices);
|
||||
}
|
||||
};
|
||||
@@ -7283,7 +7420,7 @@ impl Renderer {
|
||||
};
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
for (pipeline, _) in passes {
|
||||
for (pipeline, ..) in passes {
|
||||
pass.set_pipeline(pipeline);
|
||||
flat.draw_phase2(&mut pass, vertices);
|
||||
}
|
||||
@@ -7298,13 +7435,31 @@ impl Renderer {
|
||||
// Without a native material, on Metal, the opaque pass, then the
|
||||
// cutout one (terrain.wgsl's `opaque_quad`), each over phase 1's
|
||||
// lists with quads for it, and over all of phase 2's.
|
||||
// Each pass's GPU-made draws by group, too (`gpu_lists`).
|
||||
let (opaque, cutout) = (opaque_end as u32, cutout_start as u32);
|
||||
let passes: &[(&wgpu::RenderPipeline, std::ops::Range<u32>)] = match material {
|
||||
type Passes<'p> =
|
||||
&'p [(&'p wgpu::RenderPipeline, std::ops::Range<u32>, &'p [u8])];
|
||||
let passes: Passes = match material {
|
||||
None if split_solid => &[
|
||||
(&self.solid_opaque, 0..opaque),
|
||||
(&self.solid_cutout, cutout..phase1_draws),
|
||||
(&self.solid_opaque, 0..opaque, &[0]),
|
||||
(&self.solid_cutout, cutout..phase1_draws, &[1]),
|
||||
],
|
||||
_ => &[(solid_pipeline, 0..phase1_draws)],
|
||||
_ => &[(solid_pipeline, 0..phase1_draws, &[0, 1])],
|
||||
};
|
||||
let lists = self
|
||||
.gpu_lists
|
||||
.as_ref()
|
||||
.filter(|l| l.out() == gpu_lists::Out::Draws && l.prepared());
|
||||
let draw_lists = |pass: &mut wgpu::RenderPass<'_>, groups: &[u8]| {
|
||||
let Some(lists) = lists else {
|
||||
return;
|
||||
};
|
||||
for (k, call) in lists.calls().iter().enumerate() {
|
||||
if groups.contains(&call.group) {
|
||||
pass.set_vertex_buffer(0, self.arena.buffer(call.page).slice(..));
|
||||
lists.multi_draw(pass, k);
|
||||
}
|
||||
}
|
||||
};
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(1, self.instances.slice(..));
|
||||
@@ -7314,8 +7469,9 @@ impl Renderer {
|
||||
// its pass, or one of their own on Metal), then
|
||||
// what they found in sight, in a pass that loads
|
||||
// both targets (it reads the test's results).
|
||||
for (pipeline, draws) in passes {
|
||||
for (pipeline, draws, groups) in passes {
|
||||
pass.set_pipeline(pipeline);
|
||||
draw_lists(&mut pass, groups);
|
||||
self.draw_sections(
|
||||
&mut pass,
|
||||
&self.indirect,
|
||||
@@ -7349,7 +7505,7 @@ impl Renderer {
|
||||
wgpu::IndexFormat::Uint32,
|
||||
);
|
||||
pass.set_vertex_buffer(1, self.instances.slice(..));
|
||||
for (pipeline, _) in passes {
|
||||
for (pipeline, ..) in passes {
|
||||
pass.set_pipeline(pipeline);
|
||||
self.draw_sections(
|
||||
&mut pass,
|
||||
@@ -7362,8 +7518,9 @@ impl Renderer {
|
||||
}
|
||||
// Without culling, phase 1 is all the solid draws.
|
||||
None => {
|
||||
for (pipeline, draws) in passes {
|
||||
for (pipeline, draws, groups) in passes {
|
||||
pass.set_pipeline(pipeline);
|
||||
draw_lists(&mut pass, groups);
|
||||
self.draw_sections(
|
||||
&mut pass,
|
||||
&self.indirect,
|
||||
@@ -8935,6 +9092,12 @@ fn projection_center(view_proj: Mat4) -> Option<Vec3> {
|
||||
fn facing_runs(sides: &Sides, eye: Option<Vec3>, origin: [f32; 3], draw: impl FnMut(u32, u32)) {
|
||||
facing_runs_from(sides, eye, None, origin, Quads::All, draw)
|
||||
}
|
||||
/// Whether phase 1's solid lists may be made on the GPU (`flat::Flat::list`);
|
||||
/// `HORIZON_GPU_LISTS=0` keeps them on the CPU.
|
||||
fn gpu_lists_on() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var("HORIZON_GPU_LISTS").as_deref() != Ok("0"))
|
||||
}
|
||||
/// `facing_runs` for the eye and, with `low`, every eye straight below it down to that
|
||||
/// camera-relative height (the water mirror's eye).
|
||||
fn facing_runs_from(
|
||||
|
||||
@@ -836,6 +836,7 @@ mod tests {
|
||||
sort: super::super::translucency::SortState::new(Vec::new()),
|
||||
sorted_indices: None,
|
||||
flat_id: None,
|
||||
list_id: None,
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers: Box::new([]),
|
||||
@@ -1119,6 +1120,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,
|
||||
list_id: None,
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers: Box::new([]),
|
||||
|
||||
@@ -223,6 +223,7 @@ fn buffer(
|
||||
/// from `shadow[0]` (solid) and `shadow[1]` (translucent) on are a shader
|
||||
/// pack's shadow camera's, with calls of their own. Phase 1's draws before
|
||||
/// `passes[0]` and from `passes[1]` on, the two solid passes' on Metal, too.
|
||||
/// The list starts at index `base` (the GPU-made lists' rooms come first: `gpu_lists`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn plan(
|
||||
draws: &[[u32; 5]],
|
||||
@@ -235,6 +236,7 @@ fn plan(
|
||||
calls: &mut Vec<Call>,
|
||||
ranges: &mut Vec<Range>,
|
||||
segments: &mut Vec<(Source, u64, u64)>,
|
||||
base: u64,
|
||||
) -> Plan {
|
||||
calls.clear();
|
||||
ranges.clear();
|
||||
@@ -242,7 +244,7 @@ fn plan(
|
||||
let (mut phase1_calls, mut solid_calls) = (None, None);
|
||||
let mut shadow_calls = [None; 2];
|
||||
let mut pass_calls = [None; 2];
|
||||
let mut total = 0u64;
|
||||
let mut total = base;
|
||||
let mut by_source: Vec<(Source, Vec<Range>)> = Vec::new();
|
||||
for (i, (d, &(page, sorted))) in draws.iter().zip(pages).enumerate() {
|
||||
if i == phase1_draws {
|
||||
@@ -540,6 +542,8 @@ impl Flat {
|
||||
phase2: Option<&wgpu::Buffer>,
|
||||
origins: impl Iterator<Item = (u32, [f32; 4])>,
|
||||
sorted_page: impl Fn(u32) -> wgpu::Buffer,
|
||||
mut lists: Option<&mut super::gpu_lists::GpuLists>,
|
||||
eye: Option<[f32; 3]>,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> bool {
|
||||
self.phase2_group = None;
|
||||
@@ -577,6 +581,8 @@ impl Flat {
|
||||
bytemuck::cast_slice(&self.origin_data),
|
||||
);
|
||||
}
|
||||
// The GPU-made lists' rooms come first in the index list.
|
||||
let room = lists.as_mut().map_or(0, |l| l.rooms());
|
||||
let Plan {
|
||||
phase1_calls,
|
||||
solid_calls,
|
||||
@@ -594,6 +600,7 @@ impl Flat {
|
||||
&mut self.calls,
|
||||
&mut self.range_data,
|
||||
&mut self.segments,
|
||||
room,
|
||||
);
|
||||
self.phase1_calls = phase1_calls;
|
||||
self.solid_calls = solid_calls;
|
||||
@@ -656,6 +663,9 @@ impl Flat {
|
||||
bytemuck::cast_slice(&self.phase2_call_data),
|
||||
);
|
||||
}
|
||||
if let Some(lists) = &mut lists {
|
||||
lists.prepare(device, encoder, uploads, Some(&self.indices), eye);
|
||||
}
|
||||
let ranges_at = |first: u64, count: u64| {
|
||||
wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &self.ranges,
|
||||
@@ -725,13 +735,17 @@ impl Flat {
|
||||
label: Some("Flatten section draws"),
|
||||
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten")),
|
||||
});
|
||||
if let Some(lists) = &lists {
|
||||
lists.dispatch(&mut pass);
|
||||
}
|
||||
pass.set_pipeline(&self.flatten);
|
||||
for (group, count) in &groups {
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
drop(pass);
|
||||
for page in self.calls.iter().map(|c| c.page) {
|
||||
let list_pages = lists.iter().flat_map(|l| l.calls().iter().map(|c| c.page));
|
||||
for page in self.calls.iter().map(|c| c.page).chain(list_pages) {
|
||||
let table = self.pages[page as usize]
|
||||
.as_mut()
|
||||
.expect("a drawn page has its block table");
|
||||
@@ -772,6 +786,27 @@ impl Flat {
|
||||
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
|
||||
/// Draws pass `group`'s calls of `lists` (`gpu_lists::Out::Indices`, prepared with
|
||||
/// this frame's `prepare`) with a flat pipeline set.
|
||||
pub fn draw_lists<'a>(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
lists: &super::gpu_lists::GpuLists,
|
||||
group: u8,
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
) {
|
||||
if !lists.prepared() {
|
||||
return;
|
||||
}
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for (k, call) in lists.calls().iter().enumerate() {
|
||||
if call.group == group {
|
||||
self.bind_page(pass, call.page, &vertices);
|
||||
lists.draw_indexed(pass, k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The camera's translucent calls.
|
||||
pub fn translucent_calls(&self) -> &[Call] {
|
||||
&self.calls[self.solid_calls..self.shadow_calls[0]]
|
||||
@@ -897,6 +932,7 @@ mod tests {
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
0,
|
||||
);
|
||||
assert_eq!(total, 6 * 13);
|
||||
assert_eq!(phase1_calls, solid_calls);
|
||||
@@ -945,6 +981,7 @@ mod tests {
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
0,
|
||||
);
|
||||
assert_eq!((plan.pass_calls, plan.solid_calls), ([3, 1], 4));
|
||||
let call = |page, first, count| Call { page, first, count };
|
||||
@@ -980,6 +1017,7 @@ mod tests {
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
0,
|
||||
);
|
||||
assert_eq!((plan.solid_calls, plan.shadow_calls), (3, [4, 5]));
|
||||
let call = |page, first, count| Call { page, first, count };
|
||||
@@ -1008,6 +1046,7 @@ mod tests {
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
0,
|
||||
);
|
||||
assert_eq!(
|
||||
plan,
|
||||
@@ -1125,6 +1164,7 @@ mod tests {
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
0,
|
||||
);
|
||||
// Phase 2's draws as the test leaves them: instance counts of 0,
|
||||
// 1 where it saw the section.
|
||||
|
||||
@@ -0,0 +1,764 @@
|
||||
//! Phase 1's solid section draws made on the GPU (gpu_lists.wgsl).
|
||||
//!
|
||||
//! Each frame the render thread walked every section phase 1 draws through
|
||||
//! `mesher::Sides::visible_from` to find the runs of its quads that face the eye,
|
||||
//! and pushed one draw per run. Here it pushes one record per section instead:
|
||||
//! its row (where its quads start and how `Sides` sorted them, uploaded with its
|
||||
//! mesh), the call each of its parts draws in and its camera-relative origin. The
|
||||
//! GPU finds the same runs and writes, per call (one per pass group and arena
|
||||
//! page), either the runs' indices into the flat draws' index list
|
||||
//! (`Out::Indices`, drawn with `draw_indexed_indirect`), or one indirect draw per
|
||||
//! run (`Out::Draws`, drawn with `multi_draw_indexed_indirect_count`). Calls take
|
||||
//! room for the most their sections may write; the GPU counts what they wrote.
|
||||
//!
|
||||
//! Solid draws write depth and are tested against it, so their order within a
|
||||
//! call does not change the picture.
|
||||
use crate::mesher::{Part, Sides};
|
||||
|
||||
/// Words per row: first vertex, `Sides::gpu_row`, padding.
|
||||
const ROW: usize = 24;
|
||||
/// Records each workgroup of `sides_draws` takes.
|
||||
const GROUP: u32 = 64;
|
||||
/// Most workgroups along one dimension of a dispatch.
|
||||
const GROUP_LIMIT: u32 = 65535;
|
||||
|
||||
/// What the GPU writes for a frame's calls.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Out {
|
||||
/// Indices into the flat draws' list (`flat::Flat`), one indirect draw per call.
|
||||
Indices,
|
||||
/// `DrawIndexedIndirectArgs` per run, over the quad index pattern, with the
|
||||
/// section's instance; per call a count for `multi_draw_indexed_indirect_count`.
|
||||
Draws,
|
||||
}
|
||||
|
||||
/// A part of a section in a record: `Part` as gpu_lists.wgsl names it.
|
||||
fn part_code(part: Part) -> u32 {
|
||||
match part {
|
||||
Part::All => 0,
|
||||
Part::Opaque => 1,
|
||||
Part::Cutout => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// One of the frame's calls: what pass `group` draws of arena `page`, from
|
||||
/// `first` (an index, or a draw) with room for `count`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) struct ListCall {
|
||||
pub group: u8,
|
||||
pub page: u32,
|
||||
pub first: u32,
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
pub(super) struct GpuLists {
|
||||
out: Out,
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
/// Per id: its first vertex and `Sides::gpu_row` (`ROW` words).
|
||||
rows: wgpu::Buffer,
|
||||
row_capacity: u64,
|
||||
free_ids: Vec<u32>,
|
||||
next_id: u32,
|
||||
/// The frame's sections: id, the call and part of each of its parts, its
|
||||
/// number of parts plus its instance times 4; then its origin.
|
||||
records: Vec<[u32; 8]>,
|
||||
record_buffer: wgpu::Buffer,
|
||||
record_capacity: u64,
|
||||
calls: Vec<ListCall>,
|
||||
/// `calls`' index by page and group in the band of distance the last
|
||||
/// section listed is in (`u32::MAX`: none yet).
|
||||
call_of: Vec<u32>,
|
||||
band: u32,
|
||||
call_data: Vec<[u32; 5]>,
|
||||
/// Per call `DrawIndexedIndirectArgs` (`Out::Indices`), or its count of
|
||||
/// draws first (`Out::Draws`); the GPU adds what it writes to the first word.
|
||||
call_buffer: wgpu::Buffer,
|
||||
call_capacity: u64,
|
||||
view: wgpu::Buffer,
|
||||
/// `Out::Draws`' draws.
|
||||
draws: wgpu::Buffer,
|
||||
draw_capacity: u64,
|
||||
group: Option<(wgpu::BindGroup, u32)>,
|
||||
/// The room the frame's calls take (indices, or draws), and the most they may.
|
||||
room: u64,
|
||||
cap: u64,
|
||||
}
|
||||
|
||||
fn buffer(
|
||||
device: &wgpu::Device,
|
||||
label: &str,
|
||||
size: u64,
|
||||
usage: wgpu::BufferUsages,
|
||||
) -> wgpu::Buffer {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(label),
|
||||
size,
|
||||
// COPY_SRC: rows move to a larger buffer; tests read calls and draws back.
|
||||
usage: usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn entry(binding: u32, ty: wgpu::BufferBindingType) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}
|
||||
}
|
||||
|
||||
impl GpuLists {
|
||||
pub fn new(device: &wgpu::Device, out: Out) -> Self {
|
||||
let read = wgpu::BufferBindingType::Storage { read_only: true };
|
||||
let write = wgpu::BufferBindingType::Storage { read_only: false };
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Section lists"),
|
||||
entries: &[
|
||||
entry(0, read),
|
||||
entry(1, read),
|
||||
entry(2, wgpu::BufferBindingType::Uniform),
|
||||
entry(3, write),
|
||||
entry(4, write),
|
||||
],
|
||||
});
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Section lists"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("gpu_lists.wgsl").into()),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Section lists"),
|
||||
layout: Some(
|
||||
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Section lists"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
module: &module,
|
||||
entry_point: Some(match out {
|
||||
Out::Indices => "flatten_sides",
|
||||
Out::Draws => "sides_draws",
|
||||
}),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let storage = wgpu::BufferUsages::STORAGE;
|
||||
let indirect = wgpu::BufferUsages::INDIRECT | storage;
|
||||
Self {
|
||||
out,
|
||||
pipeline,
|
||||
layout,
|
||||
rows: buffer(device, "Section rows", ROW as u64 * 4, storage),
|
||||
row_capacity: 1,
|
||||
free_ids: Vec::new(),
|
||||
next_id: 0,
|
||||
records: Vec::new(),
|
||||
record_buffer: buffer(device, "Sections listed", 32, storage),
|
||||
record_capacity: 1,
|
||||
calls: Vec::new(),
|
||||
call_of: Vec::new(),
|
||||
band: 0,
|
||||
call_data: Vec::new(),
|
||||
call_buffer: buffer(device, "Section list calls", 20, indirect),
|
||||
call_capacity: 1,
|
||||
view: buffer(
|
||||
device,
|
||||
"Section lists' eye",
|
||||
16,
|
||||
wgpu::BufferUsages::UNIFORM,
|
||||
),
|
||||
draws: buffer(device, "Section list draws", 20, indirect),
|
||||
draw_capacity: 1,
|
||||
group: None,
|
||||
room: 0,
|
||||
cap: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A mesh uploaded from vertex `start` of its page, its solid quads sorted as
|
||||
/// `sides` says: its id, whose row is written through `uploads` in `encoder`.
|
||||
pub fn add(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
start: u32,
|
||||
sides: &Sides,
|
||||
) -> u32 {
|
||||
let id = self.free_ids.pop().unwrap_or_else(|| {
|
||||
self.next_id += 1;
|
||||
self.next_id - 1
|
||||
});
|
||||
self.set(device, encoder, uploads, id, start, sides);
|
||||
id
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: u32) {
|
||||
self.free_ids.push(id);
|
||||
}
|
||||
|
||||
/// Mesh `id` now starts at vertex `start` (moved to another page).
|
||||
pub fn set(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
id: u32,
|
||||
start: u32,
|
||||
sides: &Sides,
|
||||
) {
|
||||
let need = u64::from(id) + 1;
|
||||
if need > self.row_capacity {
|
||||
let capacity = need.next_power_of_two().max(1024);
|
||||
let rows = buffer(
|
||||
device,
|
||||
"Section rows",
|
||||
capacity * ROW as u64 * 4,
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
);
|
||||
// Earlier rows: uploads record their copies in `encoder`, so this one follows them.
|
||||
encoder.copy_buffer_to_buffer(
|
||||
&self.rows,
|
||||
0,
|
||||
&rows,
|
||||
0,
|
||||
self.row_capacity * ROW as u64 * 4,
|
||||
);
|
||||
self.rows = rows;
|
||||
self.row_capacity = capacity;
|
||||
}
|
||||
let mut row = [0u32; ROW];
|
||||
row[0] = start;
|
||||
row[1..23].copy_from_slice(&sides.gpu_row());
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
&self.rows,
|
||||
u64::from(id) * ROW as u64 * 4,
|
||||
bytemuck::cast_slice(&row),
|
||||
);
|
||||
}
|
||||
|
||||
/// Starts the frame's lists.
|
||||
pub fn clear(&mut self, device: &wgpu::Device) {
|
||||
self.records.clear();
|
||||
self.calls.clear();
|
||||
self.call_of.fill(u32::MAX);
|
||||
self.band = 0;
|
||||
self.room = 0;
|
||||
self.group = None;
|
||||
// A quarter of a binding: indices share theirs with the CPU's draws.
|
||||
let binding = device.limits().max_storage_buffer_binding_size;
|
||||
self.cap = match self.out {
|
||||
Out::Indices => binding / 16,
|
||||
Out::Draws => binding / 20 / 4,
|
||||
};
|
||||
}
|
||||
|
||||
/// Section `id`, in `page`, at `origin` from the camera and `distance`
|
||||
/// (squared, as bits) from it, instance `instance` of the frame's list:
|
||||
/// each of `parts` (pass group, part) draws the quads of the part that face
|
||||
/// the eye. False, listing nothing, when the frame's lists hold all the room
|
||||
/// they may: the caller draws it.
|
||||
///
|
||||
/// Sections come nearest first. Each band of distance gets calls of its own,
|
||||
/// drawn in that order, as the CPU's lists keep them (`page_runs`): within
|
||||
/// a call the GPU writes sections in no set order, and cutout quads, which
|
||||
/// tile GPUs can't hide before shading, then draw mostly front to back.
|
||||
pub fn list(
|
||||
&mut self,
|
||||
id: u32,
|
||||
page: u32,
|
||||
distance: u64,
|
||||
sides: &Sides,
|
||||
parts: &[(u8, Part)],
|
||||
origin: [f32; 3],
|
||||
instance: u32,
|
||||
) -> bool {
|
||||
let parts = &parts[..parts.len().min(2)];
|
||||
let mut counts = [0; 2];
|
||||
for (count, &(_, part)) in counts.iter_mut().zip(parts) {
|
||||
*count = match self.out {
|
||||
Out::Indices => sides.part_quads(part) * 6,
|
||||
Out::Draws => sides.part_runs(part),
|
||||
};
|
||||
}
|
||||
let room = u64::from(counts[0]) + u64::from(counts[1]);
|
||||
if self.room + room > self.cap || instance >= 1 << 30 {
|
||||
return false;
|
||||
}
|
||||
self.room += room;
|
||||
let band = super::page_runs::band(distance) as u32;
|
||||
if band != self.band {
|
||||
self.band = band;
|
||||
self.call_of.fill(u32::MAX);
|
||||
}
|
||||
let mut record = [id, 0, 0, parts.len() as u32 | instance << 2, 0, 0, 0, 0];
|
||||
for (k, &(group, part)) in parts.iter().enumerate() {
|
||||
let key = page as usize * 2 + usize::from(group.min(1));
|
||||
if self.call_of.len() <= key {
|
||||
self.call_of.resize(key + 1, u32::MAX);
|
||||
}
|
||||
if self.call_of[key] == u32::MAX {
|
||||
self.call_of[key] = self.calls.len() as u32;
|
||||
self.calls.push(ListCall {
|
||||
group,
|
||||
page,
|
||||
first: 0,
|
||||
count: 0,
|
||||
});
|
||||
}
|
||||
let call = self.call_of[key];
|
||||
self.calls[call as usize].count += counts[k];
|
||||
record[1 + k] = call | part_code(part) << 28;
|
||||
}
|
||||
for (to, o) in record[4..7].iter_mut().zip(origin) {
|
||||
*to = o.to_bits();
|
||||
}
|
||||
self.records.push(record);
|
||||
true
|
||||
}
|
||||
|
||||
/// The sections listed this frame.
|
||||
pub fn listed(&self) -> usize {
|
||||
self.records.len()
|
||||
}
|
||||
|
||||
/// The frame's calls, with their rooms once laid out (`rooms`).
|
||||
pub fn calls(&self) -> &[ListCall] {
|
||||
&self.calls
|
||||
}
|
||||
|
||||
/// Lays the calls' rooms out one after the other from the start: the
|
||||
/// room they take (indices, or draws).
|
||||
pub fn rooms(&mut self) -> u64 {
|
||||
let mut room = 0u64;
|
||||
for call in &mut self.calls {
|
||||
call.first = room as u32;
|
||||
room += u64::from(call.count);
|
||||
}
|
||||
room
|
||||
}
|
||||
|
||||
/// Stages the frame's sections, calls and eye (camera-relative; none: every
|
||||
/// quad faces it), and binds them for `dispatch` writing into `indices`
|
||||
/// (`Out::Indices`) or into the lists' own draws. After `rooms`.
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
indices: Option<&wgpu::Buffer>,
|
||||
eye: Option<[f32; 3]>,
|
||||
) {
|
||||
self.group = None;
|
||||
let records = self.records.len() as u64;
|
||||
if records == 0 {
|
||||
return;
|
||||
}
|
||||
if records > self.record_capacity {
|
||||
self.record_capacity = records.next_power_of_two();
|
||||
self.record_buffer = buffer(
|
||||
device,
|
||||
"Sections listed",
|
||||
self.record_capacity * 32,
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
);
|
||||
}
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
&self.record_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.records),
|
||||
);
|
||||
self.call_data.clear();
|
||||
self.call_data
|
||||
.extend(self.calls.iter().map(|c| [0, 1, c.first, 0, 0]));
|
||||
let calls = self.call_data.len() as u64;
|
||||
let indirect = wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::STORAGE;
|
||||
if calls > self.call_capacity {
|
||||
self.call_capacity = calls.next_power_of_two();
|
||||
self.call_buffer = buffer(
|
||||
device,
|
||||
"Section list calls",
|
||||
self.call_capacity * 20,
|
||||
indirect,
|
||||
);
|
||||
}
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
&self.call_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.call_data),
|
||||
);
|
||||
let room = self
|
||||
.calls
|
||||
.last()
|
||||
.map_or(0, |c| u64::from(c.first + c.count));
|
||||
if self.out == Out::Draws && room > self.draw_capacity {
|
||||
self.draw_capacity = room.next_power_of_two();
|
||||
self.draws = buffer(
|
||||
device,
|
||||
"Section list draws",
|
||||
self.draw_capacity * 20,
|
||||
indirect,
|
||||
);
|
||||
}
|
||||
let [x, y, z] = eye.unwrap_or_default();
|
||||
let view = [x, y, z, if eye.is_some() { 1.0 } else { 0.0 }];
|
||||
uploads.buffer(encoder, &self.view, 0, bytemuck::cast_slice(&view));
|
||||
let out = match self.out {
|
||||
Out::Indices => indices.expect("flat draws' index list"),
|
||||
Out::Draws => &self.draws,
|
||||
};
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Section lists"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &self.record_buffer,
|
||||
offset: 0,
|
||||
size: wgpu::BufferSize::new(records * 32),
|
||||
}),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: self.rows.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.view.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: self.call_buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: out.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.group = Some((group, records as u32));
|
||||
}
|
||||
|
||||
/// Writes the frame's lists (after `prepare`).
|
||||
pub fn dispatch(&self, pass: &mut wgpu::ComputePass<'_>) {
|
||||
let Some((group, records)) = &self.group else {
|
||||
return;
|
||||
};
|
||||
let groups = match self.out {
|
||||
Out::Indices => *records,
|
||||
Out::Draws => records.div_ceil(GROUP),
|
||||
};
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups(groups.min(GROUP_LIMIT), groups.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
|
||||
pub fn out(&self) -> Out {
|
||||
self.out
|
||||
}
|
||||
|
||||
/// Whether `dispatch` writes this frame's lists.
|
||||
pub fn prepared(&self) -> bool {
|
||||
self.group.is_some()
|
||||
}
|
||||
|
||||
/// Draws call `k` (`Out::Indices`) with a pipeline, its page's vertices and
|
||||
/// the index list bound.
|
||||
pub fn draw_indexed(&self, pass: &mut wgpu::RenderPass<'_>, k: usize) {
|
||||
pass.draw_indexed_indirect(&self.call_buffer, k as u64 * 20);
|
||||
}
|
||||
|
||||
/// Draws call `k` (`Out::Draws`) with a pipeline, its page's vertices, the
|
||||
/// quad indices and the frame's instances bound.
|
||||
pub fn multi_draw(&self, pass: &mut wgpu::RenderPass<'_>, k: usize) {
|
||||
let call = self.calls[k];
|
||||
pass.multi_draw_indexed_indirect_count(
|
||||
&self.draws,
|
||||
u64::from(call.first) * 20,
|
||||
&self.call_buffer,
|
||||
k as u64 * 20,
|
||||
call.count,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mesher::{Part, Sides};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
struct Section {
|
||||
sides: Sides,
|
||||
origin: [f32; 3],
|
||||
start: u32,
|
||||
page: u32,
|
||||
parts: &'static [(u8, Part)],
|
||||
}
|
||||
|
||||
/// Random sorts: some parts empty, grouped or not, split or not, planes in
|
||||
/// and out of a section, each listed in one of the three ways it can be.
|
||||
fn sections(count: usize) -> Vec<Section> {
|
||||
let mut seed = 0x2545_f491_4f6c_dd1du64;
|
||||
let mut next = |n: u32| {
|
||||
seed = seed
|
||||
.wrapping_mul(6_364_136_223_846_793_005)
|
||||
.wrapping_add(1_442_695_040_888_963_407);
|
||||
((seed >> 33) % u64::from(n)) as u32
|
||||
};
|
||||
(0..count)
|
||||
.map(|_| {
|
||||
let mut ends = [0; 14];
|
||||
let mut end = 0;
|
||||
for e in &mut ends {
|
||||
if next(3) > 0 {
|
||||
end += next(40);
|
||||
}
|
||||
*e = end;
|
||||
}
|
||||
let planes = std::array::from_fn(|_| next(17) as f32 + next(16) as f32 / 16.0);
|
||||
Section {
|
||||
sides: Sides::from_parts(ends, next(2) == 0, next(4) > 0, planes),
|
||||
origin: [0, 1, 2].map(|_| next(129) as f32 - 64.0),
|
||||
start: next(10_000) * 4,
|
||||
page: next(3),
|
||||
parts: match next(3) {
|
||||
0 => &[(0, Part::All)],
|
||||
1 => &[(0, Part::Opaque), (1, Part::Cutout)],
|
||||
_ => &[(1, Part::All)],
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The runs the CPU finds for a section's part, as `Gfx` drew them.
|
||||
fn cpu_runs(s: &Section, part: Part, eye: Option<[f32; 3]>) -> Vec<(u32, u32)> {
|
||||
let mut runs = Vec::new();
|
||||
let draw = |first, quads| runs.push((first, quads));
|
||||
match eye {
|
||||
Some(eye) => {
|
||||
let eye = [0, 1, 2].map(|a| eye[a] - s.origin[a]);
|
||||
s.sides.visible_from(eye, eye[1], part, draw);
|
||||
}
|
||||
None => s.sides.every(part, draw),
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
/// Lists `sections` for `out` from each of a few eyes; per eye, the
|
||||
/// lists, the calls' words and the written words.
|
||||
fn run(
|
||||
out: Out,
|
||||
sections: &[Section],
|
||||
) -> Option<Vec<(Option<[f32; 3]>, GpuLists, Vec<u32>, Vec<u32>)>> {
|
||||
let (device, queue) = device()?;
|
||||
let mut results = Vec::new();
|
||||
for eye in [
|
||||
Some([3.5f32, 70.25, -12.0]),
|
||||
Some([-40.0, -3.0, 50.5]),
|
||||
None,
|
||||
] {
|
||||
let mut lists = GpuLists::new(&device, out);
|
||||
lists.clear(&device);
|
||||
let mut rows = vec![0u32; sections.len() * ROW];
|
||||
for (id, s) in sections.iter().enumerate() {
|
||||
rows[id * ROW] = s.start;
|
||||
rows[id * ROW + 1..id * ROW + 23].copy_from_slice(&s.sides.gpu_row());
|
||||
// Nearest first, over several bands.
|
||||
let distance = (id as f64 * 2.0).powi(2).to_bits();
|
||||
let instance = id as u32 + 7;
|
||||
assert!(lists.list(
|
||||
id as u32, s.page, distance, &s.sides, s.parts, s.origin, instance
|
||||
));
|
||||
}
|
||||
let room = lists.rooms();
|
||||
let init = |contents: &[u8], usage| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents,
|
||||
usage,
|
||||
})
|
||||
};
|
||||
let storage = wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC;
|
||||
lists.rows = init(bytemuck::cast_slice(&rows), storage);
|
||||
let words = match out {
|
||||
Out::Indices => room,
|
||||
Out::Draws => room * 5,
|
||||
};
|
||||
let indices = init(&vec![0xffu8; words as usize * 4], storage);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let mut uploads = super::super::upload::Uploads::new(&device);
|
||||
lists.prepare(&device, &mut encoder, &mut uploads, Some(&indices), eye);
|
||||
uploads.finish(&encoder);
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&Default::default());
|
||||
lists.dispatch(&mut pass);
|
||||
}
|
||||
queue.submit([encoder.finish()]);
|
||||
let written = match out {
|
||||
Out::Indices => &indices,
|
||||
Out::Draws => &lists.draws,
|
||||
};
|
||||
let read_back = |buffer: &wgpu::Buffer, words: u64| -> Vec<u32> {
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: words * 4,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_buffer_to_buffer(buffer, 0, &read, 0, words * 4);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
bytemuck::cast_slice(&read.slice(..).get_mapped_range().unwrap()).to_vec()
|
||||
};
|
||||
let calls = read_back(&lists.call_buffer, lists.calls.len() as u64 * 5);
|
||||
let words = read_back(written, words);
|
||||
results.push((eye, lists, calls, words));
|
||||
}
|
||||
Some(results)
|
||||
}
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return None;
|
||||
};
|
||||
let device = pollster::block_on(adapter.request_device(&Default::default())).ok();
|
||||
if device.is_none() {
|
||||
eprintln!("no GPU device: skipped");
|
||||
}
|
||||
device
|
||||
}
|
||||
|
||||
/// The call section `i`'s `k`th part draws in, as listed.
|
||||
fn call_of(lists: &GpuLists, i: usize, k: usize) -> usize {
|
||||
(lists.records[i][1 + k] & 0x0fff_ffff) as usize
|
||||
}
|
||||
|
||||
/// Each band of distance and page gets a pass's calls of its own, in the
|
||||
/// order sections come (nearest first), and sections of one share them.
|
||||
#[test]
|
||||
fn calls_go_by_band_then_page_nearest_first() {
|
||||
let Some((device, _)) = device() else {
|
||||
return;
|
||||
};
|
||||
let mut lists = GpuLists::new(&device, Out::Indices);
|
||||
lists.clear(&device);
|
||||
let sides = Sides::unsorted(4);
|
||||
let parts = &[(0, Part::Opaque), (1, Part::Cutout)];
|
||||
for (page, blocks) in [(1, 10.0), (0, 20.0), (1, 30.0), (1, 100.0), (0, 130.0)] {
|
||||
let distance = f64::powi(blocks, 2).to_bits();
|
||||
assert!(lists.list(0, page, distance, &sides, parts, [0.0; 3], 0));
|
||||
}
|
||||
let calls: Vec<_> = lists
|
||||
.calls()
|
||||
.iter()
|
||||
.map(|c| (c.group, c.page, c.count))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
calls,
|
||||
[
|
||||
(0, 1, 48),
|
||||
(1, 1, 48),
|
||||
(0, 0, 24),
|
||||
(1, 0, 24),
|
||||
(0, 1, 24),
|
||||
(1, 1, 24),
|
||||
(0, 0, 24),
|
||||
(1, 0, 24)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// `flatten_sides` writes, in each call's room, the indices of exactly the
|
||||
/// runs `Sides::visible_from` finds for each listed section's parts (in
|
||||
/// some order: workgroups take room as they come), and counts them.
|
||||
#[test]
|
||||
fn flatten_sides_writes_the_runs_the_cpu_finds() {
|
||||
let sections = sections(200);
|
||||
let Some(results) = run(Out::Indices, §ions) else {
|
||||
return;
|
||||
};
|
||||
for (eye, lists, calls, out) in results {
|
||||
let mut expected = vec![Vec::new(); lists.calls.len()];
|
||||
for (i, s) in sections.iter().enumerate() {
|
||||
for (k, &(_, part)) in s.parts.iter().enumerate() {
|
||||
let call = call_of(&lists, i, k);
|
||||
for (first, quads) in cpu_runs(s, part, eye) {
|
||||
for k in 0..quads * 6 {
|
||||
let corner = [0, 1, 2, 0, 2, 3][(k % 6) as usize];
|
||||
expected[call].push(s.start + first * 4 + (k / 6) * 4 + corner);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (k, call) in lists.calls.iter().enumerate() {
|
||||
let count = calls[k * 5] as usize;
|
||||
assert_eq!(
|
||||
count,
|
||||
expected[k].len(),
|
||||
"call {k}'s index count, eye {eye:?}"
|
||||
);
|
||||
assert_eq!(calls[k * 5 + 2], call.first);
|
||||
let mut got = out[call.first as usize..call.first as usize + count].to_vec();
|
||||
got.sort_unstable();
|
||||
expected[k].sort_unstable();
|
||||
assert_eq!(got, expected[k], "call {k}'s indices, eye {eye:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `sides_draws` writes, in each call's room, one draw per run the CPU finds
|
||||
/// (as the CPU lists them: the run's indices over the quad pattern, from its
|
||||
/// first vertex, with the section's instance), and counts them; the room
|
||||
/// holds them all.
|
||||
#[test]
|
||||
fn sides_draws_writes_a_draw_per_run_the_cpu_finds() {
|
||||
let sections = sections(200);
|
||||
let Some(results) = run(Out::Draws, §ions) else {
|
||||
return;
|
||||
};
|
||||
for (eye, lists, calls, out) in results {
|
||||
let mut expected = vec![Vec::new(); lists.calls.len()];
|
||||
for (i, s) in sections.iter().enumerate() {
|
||||
for (k, &(_, part)) in s.parts.iter().enumerate() {
|
||||
let call = call_of(&lists, i, k);
|
||||
for (first, quads) in cpu_runs(s, part, eye) {
|
||||
expected[call].push([quads * 6, 1, 0, s.start + first * 4, i as u32 + 7]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (k, call) in lists.calls.iter().enumerate() {
|
||||
let count = calls[k * 5] as usize;
|
||||
assert_eq!(
|
||||
count,
|
||||
expected[k].len(),
|
||||
"call {k}'s draw count, eye {eye:?}"
|
||||
);
|
||||
assert!(count as u32 <= call.count, "call {k} wrote past its room");
|
||||
let first = call.first as usize * 5;
|
||||
let mut got: Vec<[u32; 5]> = out[first..first + count * 5]
|
||||
.chunks(5)
|
||||
.map(|d| d.try_into().unwrap())
|
||||
.collect();
|
||||
got.sort_unstable();
|
||||
expected[k].sort_unstable();
|
||||
assert_eq!(got, expected[k], "call {k}'s draws, eye {eye:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// Phase 1's solid section draws made on the GPU (gfx/gpu_lists.rs): per section
|
||||
// listed, the runs of its quads that face the eye, found as
|
||||
// `mesher::Sides::visible_from` finds them on the CPU, written into the call
|
||||
// each of its parts draws in (one per pass group and arena page) after taking
|
||||
// room there: their indices (`flatten_sides`, for the flat draws' index list,
|
||||
// as `flatten_seen` does for occlusion culling's phase 2), or one indirect draw
|
||||
// per run (`sides_draws`, for `multi_draw_indexed_indirect_count`). Solid draws
|
||||
// write depth and are tested against it, so their order within a call does not
|
||||
// change the picture.
|
||||
struct View {
|
||||
// The eye relative to the camera, and 1.0 when there is one (`sides_on`);
|
||||
// without, every bucket is drawn (`Sides::every`).
|
||||
eye: vec3<f32>,
|
||||
has_eye: f32,
|
||||
}
|
||||
// Per section listed: its row's id; for each of its parts (up to two) the call
|
||||
// it draws in, with the part (`PART_*`) in the top bits; its number of parts
|
||||
// plus 4 times its instance. Then its origin relative to the camera.
|
||||
struct Record {
|
||||
words: vec4<u32>,
|
||||
origin: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<storage, read> records: array<Record>;
|
||||
// Per id, `ROW` words: its first vertex in its page, then `Sides::gpu_row`
|
||||
// (14 ends, 7 planes' bits, flags).
|
||||
@group(0) @binding(1) var<storage, read> rows: array<u32>;
|
||||
@group(0) @binding(2) var<uniform> view: View;
|
||||
// Per call five words: its count (indices, or draws), which starts at 0, then
|
||||
// for `flatten_sides` the rest of its `DrawIndexedIndirectArgs`; word 2 is
|
||||
// where its room starts.
|
||||
@group(0) @binding(3) var<storage, read_write> calls: array<atomic<u32>>;
|
||||
// Indices (`flatten_sides`) or `DrawIndexedIndirectArgs` (`sides_draws`).
|
||||
@group(0) @binding(4) var<storage, read_write> out: array<u32>;
|
||||
|
||||
const ROW: u32 = 24u;
|
||||
const LANES: u32 = 64u;
|
||||
const PART_ALL: u32 = 0u;
|
||||
const PART_OPAQUE: u32 = 1u;
|
||||
const PART_CUTOUT: u32 = 2u;
|
||||
const CALL_MASK: u32 = 0x0fffffffu;
|
||||
// `sides::MARGIN`.
|
||||
const MARGIN: f32 = 0.0625;
|
||||
|
||||
// Index `k` of a solid draw: quad k / 6's vertices 0 1 2 0 2 3.
|
||||
fn quad(k: u32) -> u32 {
|
||||
let c = k % 6u;
|
||||
return (k / 6u) * 4u + select(select(c - 2u, 0u, c == 3u), c, c < 3u);
|
||||
}
|
||||
|
||||
fn plane(row: u32, bucket: u32) -> f32 {
|
||||
return bitcast<f32>(rows[row + 15u + bucket]);
|
||||
}
|
||||
|
||||
// `Sides::visible_from`'s `seen`, buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y.
|
||||
fn seen(row: u32, bucket: u32, eye: vec3<f32>) -> bool {
|
||||
var faces = true;
|
||||
if view.has_eye != 0.0 {
|
||||
switch bucket {
|
||||
case 0u: { faces = eye.x > plane(row, 0u) - MARGIN; }
|
||||
case 1u: { faces = eye.z > plane(row, 1u) - MARGIN; }
|
||||
case 3u: { faces = eye.y > plane(row, 3u) - MARGIN; }
|
||||
case 4u: { faces = eye.z < plane(row, 4u) + MARGIN; }
|
||||
case 5u: { faces = eye.x < plane(row, 5u) + MARGIN; }
|
||||
case 6u: { faces = eye.y < plane(row, 6u) + MARGIN; }
|
||||
default: {}
|
||||
}
|
||||
}
|
||||
return faces;
|
||||
}
|
||||
|
||||
// A part's runs (first quad, quads): at most 7, as a run ends at a part left
|
||||
// out and there are 14.
|
||||
struct Runs {
|
||||
n: u32,
|
||||
quads: u32,
|
||||
runs: array<vec2<u32>, 7>,
|
||||
}
|
||||
|
||||
// `Sides::runs` for `part` of the mesh at `row`, seen from `eye` (relative to
|
||||
// the section): empty parts do not break a run.
|
||||
fn part_runs(row: u32, part: u32, eye: vec3<f32>) -> Runs {
|
||||
var r: Runs;
|
||||
let grouped = (rows[row + 22u] & 1u) != 0u;
|
||||
let split = (rows[row + 22u] & 2u) != 0u;
|
||||
var start = 0u;
|
||||
var open = false;
|
||||
var first = 0u;
|
||||
for (var n = 0u; n < 14u; n++) {
|
||||
let end = rows[row + 1u + n];
|
||||
if end == start {
|
||||
continue;
|
||||
}
|
||||
var bucket: u32;
|
||||
var cutout: bool;
|
||||
if grouped {
|
||||
bucket = n % 7u;
|
||||
cutout = n >= 7u;
|
||||
} else {
|
||||
bucket = n / 2u;
|
||||
cutout = n % 2u == 1u;
|
||||
}
|
||||
let wanted = part == PART_ALL || !split
|
||||
|| (part == PART_OPAQUE && !cutout) || (part == PART_CUTOUT && cutout);
|
||||
let take = wanted && seen(row, bucket, eye);
|
||||
if take && !open {
|
||||
open = true;
|
||||
first = start;
|
||||
} else if !take && open {
|
||||
r.runs[r.n] = vec2(first, start - first);
|
||||
r.quads += start - first;
|
||||
r.n++;
|
||||
open = false;
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
if open {
|
||||
r.runs[r.n] = vec2(first, start - first);
|
||||
r.quads += start - first;
|
||||
r.n++;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
fn record_index(group: vec3<u32>) -> u32 {
|
||||
return group.y * 65535u + group.x;
|
||||
}
|
||||
|
||||
// The eye relative to `record`'s section.
|
||||
fn eye_of(record: Record) -> vec3<f32> {
|
||||
return view.eye - record.origin.xyz;
|
||||
}
|
||||
|
||||
fn part_word(record: Record, p: u32) -> u32 {
|
||||
return select(record.words.z, record.words.y, p == 0u);
|
||||
}
|
||||
|
||||
// Lane 0's runs per part, and where each part's indices start.
|
||||
var<workgroup> found: array<Runs, 2>;
|
||||
var<workgroup> at: array<u32, 2>;
|
||||
|
||||
// One workgroup per record: lane 0 finds the runs and takes room for their
|
||||
// indices, then every lane writes them.
|
||||
@compute @workgroup_size(64)
|
||||
fn flatten_sides(@builtin(workgroup_id) group: vec3<u32>,
|
||||
@builtin(local_invocation_index) lane: u32) {
|
||||
let r = record_index(group);
|
||||
if r >= arrayLength(&records) {
|
||||
return;
|
||||
}
|
||||
let record = records[r];
|
||||
let row = record.words.x * ROW;
|
||||
let parts = record.words.w & 3u;
|
||||
if lane == 0u {
|
||||
let eye = eye_of(record);
|
||||
for (var p = 0u; p < parts; p++) {
|
||||
let word = part_word(record, p);
|
||||
let runs = part_runs(row, word >> 28u, eye);
|
||||
let call = (word & CALL_MASK) * 5u;
|
||||
var begin = 0u;
|
||||
if runs.quads > 0u {
|
||||
begin = atomicLoad(&calls[call + 2u]) + atomicAdd(&calls[call], runs.quads * 6u);
|
||||
}
|
||||
found[p] = runs;
|
||||
at[p] = begin;
|
||||
}
|
||||
}
|
||||
workgroupBarrier();
|
||||
let base = rows[row];
|
||||
for (var p = 0u; p < parts; p++) {
|
||||
var to = at[p];
|
||||
for (var k = 0u; k < found[p].n; k++) {
|
||||
let run = found[p].runs[k];
|
||||
let first = base + run.x * 4u;
|
||||
let count = run.y * 6u;
|
||||
for (var i = lane; i < count; i += LANES) {
|
||||
out[to + i] = first + quad(i);
|
||||
}
|
||||
to += count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One record per invocation: a draw per run, as the CPU lists them
|
||||
// (`[quads * 6, 1, 0, first vertex, instance]` over the quad index pattern).
|
||||
@compute @workgroup_size(64)
|
||||
fn sides_draws(@builtin(workgroup_id) group: vec3<u32>,
|
||||
@builtin(local_invocation_index) lane: u32) {
|
||||
let r = record_index(group) * LANES + lane;
|
||||
if r >= arrayLength(&records) {
|
||||
return;
|
||||
}
|
||||
let record = records[r];
|
||||
let row = record.words.x * ROW;
|
||||
let parts = record.words.w & 3u;
|
||||
let instance = record.words.w >> 2u;
|
||||
let eye = eye_of(record);
|
||||
let base = rows[row];
|
||||
for (var p = 0u; p < parts; p++) {
|
||||
let word = part_word(record, p);
|
||||
let runs = part_runs(row, word >> 28u, eye);
|
||||
if runs.n == 0u {
|
||||
continue;
|
||||
}
|
||||
let call = (word & CALL_MASK) * 5u;
|
||||
var to = (atomicLoad(&calls[call + 2u]) + atomicAdd(&calls[call], runs.n)) * 5u;
|
||||
for (var k = 0u; k < runs.n; k++) {
|
||||
let run = runs.runs[k];
|
||||
out[to] = run.y * 6u;
|
||||
out[to + 1u] = 1u;
|
||||
out[to + 2u] = 0u;
|
||||
out[to + 3u] = base + run.x * 4u;
|
||||
out[to + 4u] = instance;
|
||||
to += 5u;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,9 @@ pub struct Hdr {
|
||||
pub ui: f32,
|
||||
/// Which pixels of a light source shine (`Masks`).
|
||||
pub masks: Masks,
|
||||
/// HDR lighting: off while a shader pack's post or material stage runs, the pack's own, and
|
||||
/// with `HORIZON_HDR_LIGHTING=0`, to compare.
|
||||
pub lighting: bool,
|
||||
/// Calibration's test picture while it runs.
|
||||
pub calibration: Option<Calibration>,
|
||||
}
|
||||
@@ -293,6 +296,7 @@ impl Hdr {
|
||||
tiers: TIERS,
|
||||
ui: 1.0,
|
||||
masks: MASKS,
|
||||
lighting: true,
|
||||
calibration: None,
|
||||
}
|
||||
}
|
||||
@@ -539,6 +543,11 @@ impl Hdr {
|
||||
]);
|
||||
params[5] = self.black.clamp(0.0, paper);
|
||||
params[6] = self.ui.clamp(0.25, 1.0);
|
||||
params[7] = if self.lighting {
|
||||
scene_gain(self.peak, self.paper_white)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
for (i, t) in self.tiers.iter().enumerate() {
|
||||
params[8 + i] = t.share;
|
||||
}
|
||||
@@ -618,6 +627,12 @@ fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
.create_view(&Default::default())
|
||||
}
|
||||
|
||||
/// What the strongest light multiplies a surface by, in HDR lighting: 4, which looked right
|
||||
/// across biomes; less only where there is little headroom to ease into (`hdr.wgsl`).
|
||||
fn scene_gain(peak: f32, paper_white: f32) -> f32 {
|
||||
(peak / paper_white.max(1.0) * 1.75).clamp(1.0, 4.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Output, pick};
|
||||
@@ -670,3 +685,18 @@ mod tests {
|
||||
assert_eq!(pick(&caps(&[(F::Bgra8UnormSrgb, S::SRGB)]), None), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod lighting_tests {
|
||||
use super::scene_gain;
|
||||
|
||||
#[test]
|
||||
fn the_lighting_scales_with_the_headroom() {
|
||||
// Enough headroom: the full strength, whatever the paper white.
|
||||
assert_eq!(scene_gain(1000.0, 430.0), 4.0);
|
||||
assert_eq!(scene_gain(1060.0, 203.0), 4.0);
|
||||
// Little headroom, and none.
|
||||
assert_eq!(scene_gain(600.0, 400.0), 2.625);
|
||||
assert_eq!(scene_gain(200.0, 480.0), 1.0);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,11 @@ pub(super) struct Run {
|
||||
pub tag: [u32; 3],
|
||||
}
|
||||
|
||||
/// The band of a section `distance` from the camera (squared, as bits).
|
||||
pub(super) fn band(distance: u64) -> u64 {
|
||||
(f64::from_bits(distance).sqrt() / BAND) as u64
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct PageRuns {
|
||||
runs: Vec<Run>,
|
||||
@@ -58,7 +63,7 @@ impl PageRuns {
|
||||
tag: [u32; 3],
|
||||
) {
|
||||
if len > 0 {
|
||||
let band = (f64::from_bits(distance).sqrt() / BAND) as u64;
|
||||
let band = band(distance);
|
||||
self.runs.push(Run {
|
||||
key: u64::from(group) << GROUP_SHIFT | band << 32 | u64::from(page),
|
||||
start: start as u32,
|
||||
|
||||
@@ -2226,4 +2226,112 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Horizon's graphics captions are English sentences that the language table keys by
|
||||
/// themselves, so a caption, description or value that is not in `en_us.json` shows in
|
||||
/// English in every language. Every text the three pages show, with each setting cycled
|
||||
/// through its values (#1927).
|
||||
#[test]
|
||||
fn every_graphics_text_is_in_the_language_table() {
|
||||
let (_, missed) = lang::scoped(lang::Language::load(&[], "de_de"), || {
|
||||
for hdr in [false, true] {
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = hdr;
|
||||
s.horizon.taa = true;
|
||||
for level in [Level::Simple, Level::Advanced, Level::Hdr] {
|
||||
for _ in 0..8 {
|
||||
for row in extra_rows(level, &s) {
|
||||
match row {
|
||||
Row::Header(name) => {
|
||||
lang::get(name);
|
||||
}
|
||||
Row::Setting(row) => {
|
||||
row.item.label(&s);
|
||||
let text = description(row.item, &s, row.text);
|
||||
lang::get(text);
|
||||
lang::get(row.cost.label());
|
||||
if let Some(why) = row.item.blocked(&s) {
|
||||
lang::get(why);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for row in extra_rows(level, &s) {
|
||||
if let Row::Setting(Setting {
|
||||
item: Item::Horizon(h),
|
||||
..
|
||||
}) = row
|
||||
{
|
||||
h.cycle(&mut s, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for button in ["Advanced...", "Simple...", "Done"] {
|
||||
lang::get(button);
|
||||
}
|
||||
});
|
||||
// `labelled` also looks up values that are already words in the language ("An") or
|
||||
// numbers with a unit ("12 chunks", "Auto (8)"); those are not keys, and "FSR 3.1" is
|
||||
// a name.
|
||||
let missed: Vec<_> = missed
|
||||
.into_iter()
|
||||
.filter(|m| !m.starts_with(|c: char| c.is_ascii_digit()) && !m.starts_with("Auto ("))
|
||||
.filter(|m| !["An", "Aus", "FSR 3.1", "key.keyboard.unknown"].contains(&m.as_str()))
|
||||
.collect();
|
||||
assert_eq!(missed, Vec::<String>::new());
|
||||
}
|
||||
/// Leaf Culling, Frame Generation and Shadow Cache came with English captions only (#1927).
|
||||
#[test]
|
||||
fn newer_graphics_settings_are_translated() {
|
||||
let mut s = Settings::default();
|
||||
s.horizon.leaf_culling = LeafCullingMode::Hollow;
|
||||
s.horizon.entity_culling = EntityCullingMode::Asynchronous;
|
||||
for (code, leaf, entity, leaf_text) in [
|
||||
(
|
||||
"de_de",
|
||||
"Laub-Culling: Hohl",
|
||||
"Objekt-Culling: Asynchron",
|
||||
"Lässt Flächen zwischen Blättern weg: schneller, aber Lücken sind durchsichtig.",
|
||||
),
|
||||
(
|
||||
"pl_pl",
|
||||
"Pomijanie liści: Puste",
|
||||
"Pomijanie bytów: Asynchroniczne",
|
||||
"Pomija ściany między liśćmi: szybciej, ale luki są przezroczyste.",
|
||||
),
|
||||
(
|
||||
"uk_ua",
|
||||
"Відсікання листя: Порожнє",
|
||||
"Відсік. сутностей: Асинхронно",
|
||||
"Прибирає грані між листям: швидше, але прогалини прозорі.",
|
||||
),
|
||||
(
|
||||
"zh_cn",
|
||||
"树叶剔除: 镂空",
|
||||
"实体剔除: 异步",
|
||||
"省略树叶之间的面:更快,但缝隙会透视。",
|
||||
),
|
||||
(
|
||||
"fr_fr",
|
||||
"Occlusion feuillage: Creux",
|
||||
"Occlusion des entités: Asynchrone",
|
||||
"Supprime les faces entre feuilles : plus rapide, mais on voit à travers les trous.",
|
||||
),
|
||||
] {
|
||||
let (shown, _) = lang::scoped(lang::Language::load(&[], code), || {
|
||||
let item = |h| Item::Horizon(h);
|
||||
(
|
||||
item(H::LeafCulling).label(&s).plain(),
|
||||
item(H::EntityCulling).label(&s).plain(),
|
||||
lang::get(description(item(H::LeafCulling), &s, "")),
|
||||
)
|
||||
});
|
||||
assert_eq!(
|
||||
shown,
|
||||
(leaf.into(), entity.into(), leaf_text.into()),
|
||||
"{code}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ struct Params {
|
||||
black: f32,
|
||||
// The HUD and menus' brightness as a share of paper white.
|
||||
ui: f32,
|
||||
// HDR lighting: what the strongest light multiplies a surface by, 1 for none.
|
||||
scene: f32,
|
||||
// Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom
|
||||
// its fully lifted pixels get.
|
||||
share: vec4<f32>,
|
||||
@@ -53,7 +55,7 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
|
||||
return true;
|
||||
}
|
||||
let f = (c.r + c.g + c.b) / max(w.r + w.g + w.b, 1.0 / 255.0);
|
||||
return f <= 1.01 && f > 0.5 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
|
||||
return f <= 1.01 && f > 0.02 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
|
||||
}
|
||||
|
||||
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
|
||||
@@ -168,8 +170,23 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
// lower the brighter, and where in the band how much (hdr_mark). The hand
|
||||
// writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
|
||||
var lifted = false;
|
||||
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75
|
||||
&& (hand || (!covered && shows_world(c, w.rgb))) {
|
||||
let shows = params.world > 0.5 && params.peak > 1.0
|
||||
&& (hand || (!covered && shows_world(c, w.rgb)));
|
||||
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
|
||||
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
|
||||
// peak from 60% of it, so bright textures keep their detail.
|
||||
if shows && w.a >= 0.75 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
let gain = 1.0 + (params.scene - 1.0) * (1.0 - w.a) / 0.24;
|
||||
let v = top * gain;
|
||||
let knee = max(0.6 * params.peak, 1.0);
|
||||
var out = v;
|
||||
if v > knee && params.peak > knee {
|
||||
out = knee + (params.peak - knee) * (1.0 - exp(-(v - knee) / (params.peak - knee)));
|
||||
}
|
||||
light *= select(gain, out / max(top, 1e-4), v > knee);
|
||||
}
|
||||
if shows && w.a < 0.75 {
|
||||
let band = u32(clamp(floor((w.a + 0.005) / 0.175), 0.0, 3.0));
|
||||
let tier = 3u - band;
|
||||
let lift = 1.0 - clamp((w.a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! A data folder's files read from the disk once while a load holds them.
|
||||
//! Several tables are built from the same folder (the recipes: the crafting
|
||||
//! tables, the stations, the recipe book, brewing), and each used to list and
|
||||
//! read every file again: on a hard disk or under a virus scanner, each open
|
||||
//! costs. The first reader lists and reads the folder; later readers share
|
||||
//! those bytes for as long as one of them holds the [`Folder`], then it is
|
||||
//! gone (nothing is kept past the load).
|
||||
use std::{
|
||||
fs, io,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex, Weak},
|
||||
};
|
||||
|
||||
/// One file: its path inside the folder without `.json` (`a/b`, as an id has
|
||||
/// it), the file, and its bytes or the error reading it gave.
|
||||
pub struct File {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
bytes: Result<Box<[u8]>, (io::ErrorKind, String)>,
|
||||
}
|
||||
impl File {
|
||||
pub fn bytes(&self) -> io::Result<&[u8]> {
|
||||
self.bytes
|
||||
.as_deref()
|
||||
.map_err(|(kind, message)| io::Error::new(*kind, message.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Every `.json` file under a folder, its subfolders' too, in name order.
|
||||
pub struct Folder {
|
||||
pub files: Vec<File>,
|
||||
}
|
||||
|
||||
static OPEN: Mutex<Vec<(PathBuf, Weak<Folder>)>> = Mutex::new(Vec::new());
|
||||
|
||||
fn find(open: &[(PathBuf, Weak<Folder>)], dir: &Path) -> Option<Arc<Folder>> {
|
||||
open.iter()
|
||||
.find(|(d, _)| d == dir)
|
||||
.and_then(|(_, f)| f.upgrade())
|
||||
}
|
||||
|
||||
/// `dir`'s files if another reader holds them, without reading anything.
|
||||
pub fn held(dir: &Path) -> Option<Arc<Folder>> {
|
||||
find(&OPEN.lock().unwrap_or_else(|e| e.into_inner()), dir)
|
||||
}
|
||||
|
||||
/// The `.json` files under `dir`: shared with any other reader holding them,
|
||||
/// else listed and read now. An error when `dir` itself can't be listed.
|
||||
pub fn read(dir: &Path) -> io::Result<Arc<Folder>> {
|
||||
if let Some(folder) = held(dir) {
|
||||
return Ok(folder);
|
||||
}
|
||||
let files = list(dir)?
|
||||
.into_iter()
|
||||
.map(|(name, path)| {
|
||||
let bytes = fs::read(&path)
|
||||
.map(Vec::into_boxed_slice)
|
||||
.map_err(|e| (e.kind(), e.to_string()));
|
||||
File { name, path, bytes }
|
||||
})
|
||||
.collect();
|
||||
let folder = Arc::new(Folder { files });
|
||||
let mut open = OPEN.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Another reader may have read it meanwhile: share one copy.
|
||||
if let Some(theirs) = find(&open, dir) {
|
||||
return Ok(theirs);
|
||||
}
|
||||
open.retain(|(_, f)| f.strong_count() > 0);
|
||||
open.push((dir.to_owned(), Arc::downgrade(&folder)));
|
||||
Ok(folder)
|
||||
}
|
||||
|
||||
/// The `.json` files under `dir` and its subfolders, in name order: each
|
||||
/// one's path inside the folder without the extension (`a/b`) and its file.
|
||||
/// An error when `dir` itself can't be listed; a subfolder or an entry that
|
||||
/// can't be is warned and skipped, as vanilla's `PathPackResources` keeps
|
||||
/// what it found.
|
||||
pub fn list(dir: &Path) -> io::Result<Vec<(String, PathBuf)>> {
|
||||
let mut files = Vec::new();
|
||||
let mut stack = vec![fs::read_dir(dir)?];
|
||||
while let Some(entries) = stack.pop() {
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: failed to list an entry of {}: {e}", dir.display());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
match fs::read_dir(&path) {
|
||||
Ok(list) => stack.push(list),
|
||||
Err(e) => log::warn!("Horizon: failed to list {}: {e}", path.display()),
|
||||
}
|
||||
} else if path.extension().is_some_and(|e| e == "json") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut named: Vec<(String, PathBuf)> = files
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
let inside = path.strip_prefix(dir).unwrap_or(&path).with_extension("");
|
||||
let name: Vec<_> = inside.iter().map(|c| c.to_string_lossy()).collect();
|
||||
(name.join("/"), path)
|
||||
})
|
||||
.collect();
|
||||
// Vanilla lists resources by id, in a map keyed by it
|
||||
// (`FallbackResourceManager`): `crushing` before `crushing/iron`, where
|
||||
// a path's components put the subfolder's file first.
|
||||
named.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
Ok(named)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn folder(tag: &str) -> PathBuf {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-json-folder-{tag}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(dir.join("brewing")).unwrap();
|
||||
fs::write(dir.join("stick.json"), b"{\"a\":1}").unwrap();
|
||||
fs::write(dir.join("brewing/awkward.json"), b"{}").unwrap();
|
||||
fs::write(dir.join("notes.txt"), b"not json").unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readers_share_the_bytes_while_one_holds_them_and_read_again_after() {
|
||||
let dir = folder("share");
|
||||
let first = read(&dir).unwrap();
|
||||
let names: Vec<_> = first.files.iter().map(|f| f.name.as_str()).collect();
|
||||
assert_eq!(names, ["brewing/awkward", "stick"]);
|
||||
assert_eq!(first.files[1].bytes().unwrap(), b"{\"a\":1}");
|
||||
// Held: a change on the disk isn't seen, the copy read is shared.
|
||||
fs::write(dir.join("stick.json"), b"{\"a\":2}").unwrap();
|
||||
let second = read(&dir).unwrap();
|
||||
assert!(Arc::ptr_eq(&first, &second));
|
||||
drop((first, second));
|
||||
// Let go: the next reader reads the folder again.
|
||||
assert_eq!(read(&dir).unwrap().files[1].bytes().unwrap(), b"{\"a\":2}");
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_that_cannot_be_listed_is_an_error() {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-json-folder-none-{}", std::process::id()));
|
||||
assert!(read(&dir).is_err());
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ pub mod biome;
|
||||
pub mod block_edit;
|
||||
pub mod block_entities;
|
||||
pub mod block_entity_anim;
|
||||
pub mod block_entity_cem;
|
||||
pub mod block_entity_render;
|
||||
pub mod border_render;
|
||||
pub mod breaking;
|
||||
@@ -48,6 +49,7 @@ pub mod hud_overlays;
|
||||
pub mod inventory_effects;
|
||||
pub mod java_text;
|
||||
pub mod jigsaw_block;
|
||||
pub mod json_folder;
|
||||
pub mod level_loading;
|
||||
pub mod light;
|
||||
pub mod lod;
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{
|
||||
assets::{Assets, RenderAssets},
|
||||
biome::Biomes,
|
||||
blocks::{BlockFacts, RenderShape},
|
||||
chunk::{Chunk, MIN_Y},
|
||||
chunk::{Chunk, Light, MIN_Y},
|
||||
graphics::LeafCullingMode,
|
||||
models::catalog::BakedFace,
|
||||
};
|
||||
@@ -505,6 +505,10 @@ pub struct MeshFacts {
|
||||
}
|
||||
pub struct MeshTables {
|
||||
info: Vec<Info>,
|
||||
/// `Info::solid` by state, packed apart: the opacity mask of a section
|
||||
/// reads 8,000 states, and a few bytes each stay in cache where `Info`
|
||||
/// does not.
|
||||
solid: Vec<bool>,
|
||||
pub visibility: FaceVisibility,
|
||||
water_still: u16,
|
||||
water_flow: u16,
|
||||
@@ -673,6 +677,7 @@ impl MeshTables {
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
solid: info.iter().map(|i| i.solid).collect(),
|
||||
info,
|
||||
visibility,
|
||||
water_still: layer("minecraft:block/water_still")?,
|
||||
@@ -842,43 +847,66 @@ impl Padded {
|
||||
}
|
||||
fn fill(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32, own: &mut [u32; 4096]) {
|
||||
self.blocks.resize(P * P * P, 0);
|
||||
self.blocks.fill(0);
|
||||
self.light.resize(P * P * P, 0);
|
||||
let (blocks, light) = (&mut self.blocks, &mut self.light);
|
||||
let base_y = region[1][1].map_or(MIN_Y, Chunk::min_y) + sy * 16;
|
||||
// The section itself is unpacked in one pass; the border cells of the
|
||||
// neighbouring sections are read one by one.
|
||||
// The section itself is unpacked in one pass and copied row by row;
|
||||
// the border cells of the neighbouring sections are read one by one,
|
||||
// each run of them from a section found once.
|
||||
own.fill(0);
|
||||
let center = region[1][1].map(|c| &c.sections[sy as usize]);
|
||||
if let Some(section) = center {
|
||||
section.unpack(own);
|
||||
}
|
||||
// The x runs of a padded row by the chunk column they fall in.
|
||||
const RUNS: [(usize, i32, i32); 3] = [(0, -B, 0), (1, 0, 16), (2, 16, 16 + B)];
|
||||
for y in -B..16 + B {
|
||||
let wy = base_y + y;
|
||||
for z in -B..16 + B {
|
||||
for x in -B..16 + B {
|
||||
let chunk = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize];
|
||||
let i = pi(x, y, z);
|
||||
if let Some(c) = chunk {
|
||||
blocks[i] =
|
||||
if (0..16).contains(&x) && (0..16).contains(&y) && (0..16).contains(&z)
|
||||
{
|
||||
own[(y << 8 | z << 4 | x) as usize]
|
||||
} else {
|
||||
c.get(x & 15, wy, z & 15)
|
||||
};
|
||||
light[i] = c.light(x & 15, wy, z & 15);
|
||||
} else if let Some(c) = region[1][1] {
|
||||
// A neighbour not loaded continues this chunk's edge
|
||||
// column. As lit air, the outermost chunks' water drew
|
||||
// a wall toward it (fluids show both sides), a light
|
||||
// sheet in the water where far terrain carries on.
|
||||
// Vanilla draws no chunk without its neighbours.
|
||||
let (ex, ez) = (x.clamp(0, 15), z.clamp(0, 15));
|
||||
blocks[i] = c.get(ex, wy, ez);
|
||||
light[i] = c.light(ex, wy, ez);
|
||||
let row = pi(-B, y, z);
|
||||
let rz = ((z + 16) >> 4) as usize;
|
||||
for (rx, x0, x1) in RUNS {
|
||||
let (start, end) = (row + (x0 + B) as usize, row + (x1 + B) as usize);
|
||||
let (blocks, light) = (&mut blocks[start..end], &mut light[start..end]);
|
||||
let Some(c) = region[rz][rx] else {
|
||||
match region[1][1] {
|
||||
// A neighbour not loaded continues this chunk's edge
|
||||
// column. As lit air, the outermost chunks' water drew
|
||||
// a wall toward it (fluids show both sides), a light
|
||||
// sheet in the water where far terrain carries on.
|
||||
// Vanilla draws no chunk without its neighbours.
|
||||
Some(c) => {
|
||||
let ez = z.clamp(0, 15);
|
||||
for (k, x) in (x0..x1).enumerate() {
|
||||
let ex = x.clamp(0, 15);
|
||||
blocks[k] = c.get(ex, wy, ez);
|
||||
light[k] = c.light(ex, wy, ez);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
blocks.fill(0);
|
||||
light.fill(0xF0);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
};
|
||||
// `Chunk::get` and `Chunk::light` with the section found once.
|
||||
let s = (wy - c.min_y()) >> 4;
|
||||
let Some(section) = c.sections.get(s as usize).filter(|_| s >= 0) else {
|
||||
blocks.fill(0);
|
||||
light.fill(if s < 0 { 0 } else { 0xF0 });
|
||||
continue;
|
||||
};
|
||||
let at = crate::chunk::index(x0, wy, z);
|
||||
if rx == 1 && rz == 1 && (0..16).contains(&y) {
|
||||
let own_row = (y << 8 | z << 4) as usize;
|
||||
blocks.copy_from_slice(&own[own_row..own_row + 16]);
|
||||
} else {
|
||||
light[i] = 0xF0;
|
||||
section.blocks.get_run(at, blocks);
|
||||
}
|
||||
match §ion.light {
|
||||
Light::Uniform(v) => light.fill(*v),
|
||||
Light::Full(all) => light.copy_from_slice(&all[at..at + light.len()]),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -894,6 +922,28 @@ pub struct MeshScratch {
|
||||
own: Option<Box<[u32; 4096]>>,
|
||||
visibility: Vec<usize>,
|
||||
leaf: leaf_culling::LeafMask,
|
||||
/// Per padded row (`pi(-B, y, z) / P`), bit `x + B` set where the block
|
||||
/// is `isSolidRender`.
|
||||
opaque: Vec<u32>,
|
||||
}
|
||||
/// Fills `rows` with the padded section's opacity, one bit per block (`MeshScratch::opaque`).
|
||||
fn opaque_rows(pad: &Padded, solid: &[bool], rows: &mut Vec<u32>) {
|
||||
rows.clear();
|
||||
rows.extend(pad.blocks.as_chunks::<P>().0.iter().map(|row| {
|
||||
row.iter().enumerate().fold(0u32, |bits, (x, &id)| {
|
||||
bits | u32::from(solid[id as usize]) << x
|
||||
})
|
||||
}));
|
||||
}
|
||||
/// Whether the block at `(x, y, z)` (section-local) and its six neighbours
|
||||
/// are all `isSolidRender`: none of its faces can show.
|
||||
#[inline]
|
||||
fn hidden(rows: &[u32], x: i32, y: i32, z: i32) -> bool {
|
||||
let row = |y: i32, z: i32| rows[((y + B) as usize) * P + (z + B) as usize];
|
||||
let r = row(y, z);
|
||||
let around =
|
||||
r & r << 1 & r >> 1 & row(y - 1, z) & row(y + 1, z) & row(y, z - 1) & row(y, z + 1);
|
||||
around >> (x + B) & 1 != 0
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod leaf_culling_tests;
|
||||
@@ -990,6 +1040,12 @@ enum Resolver {
|
||||
/// layer plus the blend radius around it, and the blended colours per column.
|
||||
struct Layer {
|
||||
zoomed: Vec<u16>,
|
||||
/// Each resolver's colour of every cell of `zoomed`, filled on first use:
|
||||
/// a cell is in the blend square of up to `(2r+1)^2` columns.
|
||||
cells: [Vec<u32>; 4],
|
||||
/// `BiomeZoom::corner_jitter` of the quart cells `zoomed` reaches, by
|
||||
/// their offset from the first: each is shared by up to 16 cells of the layer.
|
||||
jitter: Vec<Option<[[f64; 3]; 8]>>,
|
||||
colors: [[u32; 256]; 4],
|
||||
}
|
||||
/// `ClientLevel.calculateBlockTint` behind a per-layer `BlockTintCache`: each
|
||||
@@ -1041,6 +1097,16 @@ impl<'a> Tints<'a> {
|
||||
[x, y, z]: [i32; 3],
|
||||
) -> u16 {
|
||||
let q = zoom.quart_at_block(BlockPos::new(cx * 16 + x, base_y + y, cz * 16 + z));
|
||||
Self::quart_biome(region, plains, [cx, cz], q)
|
||||
}
|
||||
/// The biome of quart cell `q` in the loaded chunks around chunk `(cx, cz)`, plains where
|
||||
/// there is none.
|
||||
fn quart_biome(
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
plains: u16,
|
||||
[cx, cz]: [i32; 2],
|
||||
q: [i32; 3],
|
||||
) -> u16 {
|
||||
let (rx, rz) = ((q[0] >> 2) - cx + 1, (q[2] >> 2) - cz + 1);
|
||||
if (0..3).contains(&rx) && (0..3).contains(&rz) {
|
||||
region[rz as usize][rx as usize].map_or(plains, |c| {
|
||||
@@ -1057,6 +1123,8 @@ impl<'a> Tints<'a> {
|
||||
let layer = self.layers[y as usize].get_or_insert_with(|| {
|
||||
Box::new(Layer {
|
||||
zoomed: vec![NO_BIOME; dim * dim],
|
||||
cells: Default::default(),
|
||||
jitter: Vec::new(),
|
||||
colors: [[UNSET; 256]; 4],
|
||||
})
|
||||
});
|
||||
@@ -1065,21 +1133,45 @@ impl<'a> Tints<'a> {
|
||||
if cached != UNSET {
|
||||
return cached;
|
||||
}
|
||||
let [cx, cz, _] = self.origin;
|
||||
let (bx, bz) = (cx * 16, cz * 16);
|
||||
let [cx, cz, base_y] = self.origin;
|
||||
let (bx, by, bz) = (cx * 16, base_y + y, cz * 16);
|
||||
let (mut red, mut green, mut blue) = (0, 0, 0);
|
||||
let cells = &mut layer.cells[resolver as usize];
|
||||
if cells.is_empty() {
|
||||
cells.resize(dim * dim, UNSET);
|
||||
}
|
||||
for dz in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let cell = ((z + dz + r) as usize) * dim + (x + dx + r) as usize;
|
||||
let known = cells[cell];
|
||||
if known != UNSET {
|
||||
red += known >> 16 & 255;
|
||||
green += known >> 8 & 255;
|
||||
blue += known & 255;
|
||||
continue;
|
||||
}
|
||||
let mut biome = layer.zoomed[cell];
|
||||
if biome == NO_BIOME {
|
||||
biome = Self::biome_id(
|
||||
&self.zoom,
|
||||
self.region,
|
||||
self.plains,
|
||||
self.origin,
|
||||
[x + dx, y, z + dz],
|
||||
);
|
||||
// BiomeManager.getBiome over the loaded chunks (plains
|
||||
// where there is none): `quart_at_block`, its cell's
|
||||
// corner jitter mixed once per layer.
|
||||
let block = BlockPos::new(bx + x + dx, by, bz + z + dz);
|
||||
let base = [block.x, block.y, block.z].map(|v| v.wrapping_sub(2) >> 2);
|
||||
let first = [bx - r, bz - r].map(|v| v.wrapping_sub(2) >> 2);
|
||||
let span = dim / 4 + 2;
|
||||
let at = (base[2] - first[1]) as usize * span + (base[0] - first[0]) as usize;
|
||||
if layer.jitter.is_empty() {
|
||||
layer.jitter.resize(span * span, None);
|
||||
}
|
||||
let jitter =
|
||||
layer.jitter[at].get_or_insert_with(|| self.zoom.corner_jitter(base));
|
||||
let corner = BiomeZoom::select_corner(block, jitter);
|
||||
let q = [
|
||||
base[0].wrapping_add((corner >> 2 & 1) as i32),
|
||||
base[1].wrapping_add((corner >> 1 & 1) as i32),
|
||||
base[2].wrapping_add((corner & 1) as i32),
|
||||
];
|
||||
biome = Self::quart_biome(self.region, self.plains, [cx, cz], q);
|
||||
layer.zoomed[cell] = biome;
|
||||
}
|
||||
let c = match resolver {
|
||||
@@ -1091,6 +1183,7 @@ impl<'a> Tints<'a> {
|
||||
Resolver::Dry => self.biomes.get(biome).dry_foliage,
|
||||
Resolver::Water => self.biomes.get(biome).water,
|
||||
};
|
||||
cells[cell] = c;
|
||||
red += c >> 16 & 255;
|
||||
green += c >> 8 & 255;
|
||||
blue += c & 255;
|
||||
@@ -1163,6 +1256,8 @@ impl Mesher<'_> {
|
||||
scratch.padded.fill(region, sy, own);
|
||||
let pad = &scratch.padded;
|
||||
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
|
||||
opaque_rows(pad, &self.tables.solid, &mut scratch.opaque);
|
||||
let opaque = &scratch.opaque;
|
||||
let base_y = center.min_y() + sy * 16;
|
||||
if self.leaf_culling.is_interior() {
|
||||
scratch
|
||||
@@ -1195,12 +1290,7 @@ impl Mesher<'_> {
|
||||
out.lights
|
||||
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
|
||||
}
|
||||
if state.solid
|
||||
&& Direction::ALL.iter().all(|d| {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
info[pad.blocks[pi(x + dx, y + dy, z + dz)] as usize].solid
|
||||
})
|
||||
{
|
||||
if hidden(opaque, x, y, z) {
|
||||
continue;
|
||||
}
|
||||
if state.fluid.kind() != FluidKind::Empty {
|
||||
|
||||
@@ -103,6 +103,22 @@ impl Sides {
|
||||
}
|
||||
}
|
||||
|
||||
/// A sort with these parts' `ends` and `planes`, for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_parts(
|
||||
ends: [u32; 14],
|
||||
grouped: bool,
|
||||
split: bool,
|
||||
planes: [f32; 7],
|
||||
) -> Self {
|
||||
Self {
|
||||
ends,
|
||||
grouped,
|
||||
split,
|
||||
planes,
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of solid quads the buckets hold.
|
||||
pub fn quads(&self) -> u32 {
|
||||
self.ends[13]
|
||||
@@ -128,11 +144,56 @@ impl Sides {
|
||||
self.runs(seen, part, draw);
|
||||
}
|
||||
|
||||
/// How many quads `part` draws facing every way (`every`).
|
||||
pub fn part_quads(&self, part: Part) -> u32 {
|
||||
let mut quads = 0;
|
||||
self.every(part, |_, count| quads += count);
|
||||
quads
|
||||
}
|
||||
|
||||
/// The facts `visible_from` reads, for the GPU to find the same runs
|
||||
/// (gpu_lists.wgsl): the parts' ends, the planes' bits,
|
||||
/// then 1 when grouped plus 2 when split.
|
||||
pub fn gpu_row(&self) -> [u32; 22] {
|
||||
let mut row = [0; 22];
|
||||
row[..14].copy_from_slice(&self.ends);
|
||||
for (to, plane) in row[14..21].iter_mut().zip(self.planes) {
|
||||
*to = plane.to_bits();
|
||||
}
|
||||
row[21] = u32::from(self.grouped) | u32::from(self.split) << 1;
|
||||
row
|
||||
}
|
||||
|
||||
/// The runs of `part`'s quads facing every way.
|
||||
pub fn every(&self, part: Part, draw: impl FnMut(u32, u32)) {
|
||||
self.runs(|_| true, part, draw);
|
||||
}
|
||||
|
||||
/// Whether `part` draws the cutout (or opaque) quads of a bucket.
|
||||
fn wants(&self, part: Part, cutout: bool) -> bool {
|
||||
match part {
|
||||
Part::All => true,
|
||||
_ if !self.split => true,
|
||||
Part::Opaque => !cutout,
|
||||
Part::Cutout => cutout,
|
||||
}
|
||||
}
|
||||
|
||||
/// The most runs `visible_from` finds for `part`, whatever the eye: one per
|
||||
/// part of a bucket it draws that holds quads, and at most 7, as runs are
|
||||
/// apart (gpu_lists.wgsl's room for its draws).
|
||||
pub fn part_runs(&self, part: Part) -> u32 {
|
||||
let mut start = 0;
|
||||
let mut runs = 0;
|
||||
for (n, &end) in self.ends.iter().enumerate() {
|
||||
if end != start && self.wants(part, slot(self.grouped, n).1) {
|
||||
runs += 1;
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
runs.min(7)
|
||||
}
|
||||
|
||||
/// Calls `draw` for each run of `part`'s quads in the buckets `seen` keeps.
|
||||
fn runs(&self, seen: impl Fn(usize) -> bool, part: Part, mut draw: impl FnMut(u32, u32)) {
|
||||
let mut start = 0;
|
||||
@@ -142,13 +203,7 @@ impl Sides {
|
||||
continue;
|
||||
}
|
||||
let (bucket, cutout) = slot(self.grouped, n);
|
||||
let wanted = match part {
|
||||
Part::All => true,
|
||||
_ if !self.split => true,
|
||||
Part::Opaque => !cutout,
|
||||
Part::Cutout => cutout,
|
||||
};
|
||||
match (wanted && seen(bucket), run) {
|
||||
match (self.wants(part, cutout) && seen(bucket), run) {
|
||||
(true, None) => run = Some(start),
|
||||
(false, Some(first)) => {
|
||||
draw(first, start - first);
|
||||
@@ -210,6 +265,29 @@ pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
|
||||
|
||||
/// `sort`, with all the opaque parts first when `grouped`.
|
||||
fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
|
||||
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, grouped, scratch))
|
||||
}
|
||||
|
||||
/// `sort`'s buffers, kept by each meshing thread: a section's quads went
|
||||
/// through fresh vectors, and the sorted copy replaced the mesh's own
|
||||
/// (recycled) buffer.
|
||||
#[derive(Default)]
|
||||
struct SortScratch {
|
||||
slots: Vec<u8>,
|
||||
keys: Vec<u64>,
|
||||
sorted: Vec<[TerrainVertex; 4]>,
|
||||
}
|
||||
thread_local! {
|
||||
static SORT_SCRATCH: std::cell::RefCell<SortScratch> = Default::default();
|
||||
}
|
||||
|
||||
/// `f32::total_cmp`'s order as an unsigned integer's.
|
||||
fn total_order_key(v: f32) -> u32 {
|
||||
let bits = v.to_bits() as i32;
|
||||
(bits ^ (((bits >> 31) as u32) >> 1) as i32) as u32 ^ 0x8000_0000
|
||||
}
|
||||
|
||||
fn sort_with(solid: &mut Vec<TerrainVertex>, grouped: bool, scratch: &mut SortScratch) -> Sides {
|
||||
let (quads, rest) = solid.as_chunks::<4>();
|
||||
debug_assert!(rest.is_empty(), "solid vertices come in quads");
|
||||
let cutout = |q: &[TerrainVertex; 4]| q[0].layer & OPAQUE_LAYER == 0;
|
||||
@@ -218,15 +296,14 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
|
||||
true => b + 7 * usize::from(cutout),
|
||||
false => b * 2 + usize::from(cutout),
|
||||
};
|
||||
let slots: Vec<u8> = quads
|
||||
.iter()
|
||||
.map(|q| position(bucket(q), cutout(q)) as u8)
|
||||
.collect();
|
||||
let slots = &mut scratch.slots;
|
||||
slots.clear();
|
||||
slots.extend(quads.iter().map(|q| position(bucket(q), cutout(q)) as u8));
|
||||
let has_cutout = quads.iter().any(cutout);
|
||||
let mut counts = [0u32; 7];
|
||||
let mut slot_counts = [0u32; 14];
|
||||
let mut planes = [0f32; 7];
|
||||
for (quad, &part) in quads.iter().zip(&slots) {
|
||||
for (quad, &part) in quads.iter().zip(slots.iter()) {
|
||||
slot_counts[usize::from(part)] += 1;
|
||||
let b = slot(grouped, usize::from(part)).0;
|
||||
if let Bucket::Side(axis, positive) = BUCKETS[b] {
|
||||
@@ -246,33 +323,34 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
|
||||
*end = total;
|
||||
}
|
||||
if has_cutout || counts.iter().filter(|&&c| c > 0).count() > 1 {
|
||||
let mut next: [u32; 14] = std::array::from_fn(|s| slot_ends[s] - slot_counts[s]);
|
||||
let mut sorted = vec![quads[0]; quads.len()];
|
||||
for (quad, &part) in quads.iter().zip(&slots) {
|
||||
let at = &mut next[usize::from(part)];
|
||||
sorted[*at as usize] = *quad;
|
||||
*at += 1;
|
||||
}
|
||||
if has_cutout {
|
||||
let mut start = 0;
|
||||
for (s, &end) in slot_ends.iter().enumerate() {
|
||||
let end = end as usize;
|
||||
let slice = &mut sorted[start..end];
|
||||
if let Bucket::Side(axis, positive) = BUCKETS[slot(grouped, s).0] {
|
||||
slice.sort_by(|a, b| {
|
||||
let a = a[0].pos[axis];
|
||||
let b = b[0].pos[axis];
|
||||
if positive {
|
||||
b.total_cmp(&a)
|
||||
} else {
|
||||
a.total_cmp(&b)
|
||||
// One integer key per quad: its part, then (cutout meshes, side
|
||||
// buckets) its plane from the outside in, then its index, which keeps
|
||||
// the order of equal quads as a stable sort does.
|
||||
let keys = &mut scratch.keys;
|
||||
keys.clear();
|
||||
keys.extend(
|
||||
quads
|
||||
.iter()
|
||||
.zip(slots.iter())
|
||||
.enumerate()
|
||||
.map(|(i, (quad, &part))| {
|
||||
let plane = match BUCKETS[slot(grouped, usize::from(part)).0] {
|
||||
Bucket::Side(axis, positive) if has_cutout => {
|
||||
let key = total_order_key(quad[0].pos[axis]);
|
||||
if positive { !key } else { key }
|
||||
}
|
||||
});
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
*solid = sorted.into_flattened();
|
||||
_ => 0,
|
||||
};
|
||||
u64::from(part) << 56 | u64::from(plane) << 24 | i as u64
|
||||
}),
|
||||
);
|
||||
debug_assert!(quads.len() < 1 << 24);
|
||||
keys.sort_unstable();
|
||||
let sorted = &mut scratch.sorted;
|
||||
sorted.clear();
|
||||
sorted.extend(keys.iter().map(|&k| quads[(k & 0xFF_FFFF) as usize]));
|
||||
solid.clear();
|
||||
solid.extend_from_slice(sorted.as_flattened());
|
||||
}
|
||||
Sides {
|
||||
ends: slot_ends,
|
||||
@@ -286,6 +364,74 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The sort as it was before the integer keys: quads moved to their
|
||||
/// parts in order, then a stable sort by plane in each side bucket.
|
||||
fn reference(solid: &[TerrainVertex], grouped: bool) -> Vec<TerrainVertex> {
|
||||
let quads = solid.as_chunks::<4>().0;
|
||||
let has_cutout = quads.iter().any(|q| q[0].layer & OPAQUE_LAYER == 0);
|
||||
let mut sorted = Vec::new();
|
||||
for n in 0..14 {
|
||||
let (b, cutout) = slot(grouped, n);
|
||||
let mut part: Vec<[TerrainVertex; 4]> = quads
|
||||
.iter()
|
||||
.filter(|q| bucket(q) == b && (q[0].layer & OPAQUE_LAYER == 0) == cutout)
|
||||
.copied()
|
||||
.collect();
|
||||
if let (Bucket::Side(axis, positive), true) = (BUCKETS[b], has_cutout) {
|
||||
part.sort_by(|a, b| {
|
||||
let (a, b) = (a[0].pos[axis], b[0].pos[axis]);
|
||||
if positive {
|
||||
b.total_cmp(&a)
|
||||
} else {
|
||||
a.total_cmp(&b)
|
||||
}
|
||||
});
|
||||
}
|
||||
sorted.extend(part);
|
||||
}
|
||||
sorted.into_flattened()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_keys_sort_as_the_stable_sort_did() {
|
||||
let mut state = 0x2545_F491_4F6C_DD1Du64;
|
||||
let mut next = move || {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state
|
||||
};
|
||||
for round in 0..100 {
|
||||
let grouped = round % 2 == 1;
|
||||
let mut solid = Vec::new();
|
||||
for _ in 0..(next() % 300) {
|
||||
let origin = [0, 1, 2].map(|_| (next() % 34) as f32 / 2.0 - 0.5);
|
||||
// Some negative and signed-zero planes too.
|
||||
let origin = if next() % 9 == 0 {
|
||||
origin.map(|v| -v)
|
||||
} else {
|
||||
origin
|
||||
};
|
||||
let layer = if round % 3 == 0 {
|
||||
OPAQUE_LAYER
|
||||
} else {
|
||||
(next() % 2) as u16 * OPAQUE_LAYER
|
||||
};
|
||||
let faces = cube(origin, layer);
|
||||
let face = (next() % 6) as usize;
|
||||
solid.extend_from_slice(&faces[face * 4..face * 4 + 4]);
|
||||
}
|
||||
let expected = reference(&solid, grouped);
|
||||
let sides = sort_parts(&mut solid, grouped);
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&solid),
|
||||
bytemuck::cast_slice::<_, u8>(&expected),
|
||||
"round {round}"
|
||||
);
|
||||
assert_eq!(sides.quads() as usize * 4, solid.len());
|
||||
}
|
||||
}
|
||||
|
||||
fn quad(corners: [[f32; 3]; 4], layer: u16) -> [TerrainVertex; 4] {
|
||||
corners.map(|p| TerrainVertex::new(p, [0.0; 2], layer, 0, 0))
|
||||
}
|
||||
|
||||
@@ -5345,6 +5345,61 @@ mod tests {
|
||||
assert!(ui.resource_error.is_none());
|
||||
}
|
||||
|
||||
/// The texts of an options page, as laid out in `code`'s language, and the keys it lacked.
|
||||
fn page_texts(code: &str, pages: &[Page]) -> (Vec<String>, Vec<String>) {
|
||||
let s = Settings::default();
|
||||
lang::scoped(lang::Language::load(&[], code), || {
|
||||
let mut texts = Vec::new();
|
||||
for page in pages {
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(*page);
|
||||
for w in ui.layout(&s, 480.0, 4000.0).0 {
|
||||
match w.kind {
|
||||
WKind::Button { label: t, .. }
|
||||
| WKind::Slider { label: t, .. }
|
||||
| WKind::Text { text: t, .. } => texts.push(t.plain()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
texts
|
||||
})
|
||||
}
|
||||
|
||||
/// Horizon's own options pages said "Splitscreen...", "Hide block-break particles" and
|
||||
/// "Hotbar: Previous Slot" in every language (#1927).
|
||||
#[test]
|
||||
fn horizon_option_pages_follow_the_language() {
|
||||
let (texts, missed) = page_texts("de_de", &[Page::Horizon, Page::Extras, Page::PadBinds]);
|
||||
for want in [
|
||||
"Splitscreen …",
|
||||
"Partikel",
|
||||
"Blockpartikel weg: Aus",
|
||||
"Weicher Chat: Aus",
|
||||
"Nachrichtenmarkierung: An",
|
||||
"Kanäle für UI-Töne: Aus",
|
||||
"Schnellzugriffsleiste: Vorheriger Platz",
|
||||
"Schnellzugriffsleiste: Nächster Platz",
|
||||
] {
|
||||
assert!(texts.iter().any(|t| t == want), "{want:?} not in {texts:?}");
|
||||
}
|
||||
// Every key these pages ask for is in the table; vanilla's own keys (`options.*`,
|
||||
// `key.*`: dotted ids without spaces) come from the prepared resources, which this
|
||||
// language does not have.
|
||||
let missed: Vec<_> = missed
|
||||
.into_iter()
|
||||
.filter(|k| k.starts_with("horizon.") || k.contains(' ') || !k.contains('.'))
|
||||
.collect();
|
||||
assert_eq!(missed, Vec::<String>::new());
|
||||
let (texts, _) = page_texts("pl_pl", &[Page::Extras, Page::PadBinds]);
|
||||
for want in [
|
||||
"Ukryj cząstki bloków: Wył.",
|
||||
"Pasek szybkiego wyboru: poprzednie pole",
|
||||
] {
|
||||
assert!(texts.iter().any(|t| t == want), "{want:?} not in {texts:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_mods_page_toggles_mods_and_keeps_dev_and_invalid_entries_inactive() {
|
||||
let atlas = atlas();
|
||||
|
||||
@@ -133,6 +133,21 @@ pub fn text(out: &mut Vec<Draw>, text: Text, x: i32, y: i32, color: i32, shadow:
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `graphics.text` on text the caller already styled, drawn as it is: a `§`
|
||||
/// in it stays a character, as `FormattedCharSequence.forward`
|
||||
/// (`StringDecomposer.iterate`, not `iterateFormatted`) draws it. An edit box
|
||||
/// builds its own sequence that way, so a `§` in its value shows.
|
||||
pub fn text_as_typed(out: &mut Vec<Draw>, text: Text, x: i32, y: i32, color: i32, shadow: bool) {
|
||||
if (color as u32) >> 24 != 0 {
|
||||
out.push(Draw::Text {
|
||||
text,
|
||||
x,
|
||||
y,
|
||||
color,
|
||||
shadow,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// `StringDecomposer.iterateFormatted`: the drawn characters of `contents`,
|
||||
/// with their index in it and their style; legacy `§` codes restyle what
|
||||
@@ -186,6 +201,11 @@ impl Measure<'_> {
|
||||
pub fn width(&self, t: &Text) -> i32 {
|
||||
self.0.width_text(&t.resolve_legacy()) as i32
|
||||
}
|
||||
/// `Font.width(FormattedCharSequence)` of a sequence drawn as it is: a
|
||||
/// `§` in the text counts as the glyph it is drawn as.
|
||||
pub fn width_drawn(&self, t: &Text) -> i32 {
|
||||
self.0.width_drawn(t) as i32
|
||||
}
|
||||
pub fn width_str(&self, s: &str) -> i32 {
|
||||
self.0.width(s) as i32
|
||||
}
|
||||
@@ -864,8 +884,8 @@ impl TextField {
|
||||
displayed.clone()
|
||||
};
|
||||
let t = styled(half, self.display);
|
||||
let w = m.width(&t);
|
||||
text(out, t, draw_x, ty, color, self.text_shadow);
|
||||
let w = m.width_drawn(&t);
|
||||
text_as_typed(out, t, draw_x, ty, color, self.text_shadow);
|
||||
draw_x += w + 1;
|
||||
}
|
||||
let insert = self.cursor < len || len >= self.max_length;
|
||||
@@ -878,7 +898,7 @@ impl TextField {
|
||||
}
|
||||
if !displayed.is_empty() && on_screen && (rel_cursor as usize) < shown_len {
|
||||
let t = styled(sub(&displayed, rel_cursor as usize, shown_len), self.cursor);
|
||||
text(out, t, draw_x, ty, color, self.text_shadow);
|
||||
text_as_typed(out, t, draw_x, ty, color, self.text_shadow);
|
||||
}
|
||||
if let Some(hint) = &self.hint
|
||||
&& displayed.is_empty()
|
||||
|
||||
@@ -413,6 +413,119 @@ impl Sprites {
|
||||
};
|
||||
self.register(root, resource, decoded)
|
||||
}
|
||||
/// The tiles of the texture `name` (`ns:path`) cut into a `columns` x `rows` grid, row by
|
||||
/// row from the top left: a Fusion connecting texture's sheet. Each tile is its own layer
|
||||
/// (named `fusion/<ns>/<path>#<tile>`); a fully transparent one is `None`, as Fusion skips
|
||||
/// empty tiles. Tiles are square, as wide as a column. An animated sheet's frames are whole
|
||||
/// grids one below the other, or with `per_tile` each cell is a column of its tile's frames.
|
||||
/// The sheet's own layer, if models already show it, shows its first tile from then on.
|
||||
pub fn insert_tiles(
|
||||
&mut self,
|
||||
root: &Path,
|
||||
name: &str,
|
||||
(columns, rows): (u32, u32),
|
||||
per_tile: bool,
|
||||
) -> Result<Vec<Option<u32>>> {
|
||||
let path = crate::atlas_sources::texture_path(root, name, "textures");
|
||||
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
|
||||
Ok(text) => Some(serde_json::from_str::<
|
||||
BTreeMap<String, Box<serde_json::value::RawValue>>,
|
||||
>(&text)?),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let animation = metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("animation"))
|
||||
.map(|v| v.get().to_owned());
|
||||
// The sheet's `texture` section is each tile's.
|
||||
let texture = texture_section(metadata.as_ref());
|
||||
let strategy = texture
|
||||
.as_ref()
|
||||
.and_then(|t| t["mipmap_strategy"].as_str())
|
||||
.and_then(crate::mipmap::Strategy::parse)
|
||||
.unwrap_or_default();
|
||||
let alpha_cutoff_bias = texture
|
||||
.as_ref()
|
||||
.and_then(|t| t["alpha_cutoff_bias"].as_f64())
|
||||
.unwrap_or(0.0) as f32;
|
||||
let (width, height, pixels) = read_rgba(&path)?;
|
||||
let side = width / columns.max(1);
|
||||
// How far down the next frame of a tile is, and the next row of tiles.
|
||||
let (frame_step, row_step) = match per_tile {
|
||||
true => (side, height / rows.max(1)),
|
||||
false => (rows * side, side),
|
||||
};
|
||||
let frames = match per_tile {
|
||||
true => row_step / side.max(1),
|
||||
false => height / frame_step.max(1),
|
||||
};
|
||||
let whole = match per_tile {
|
||||
true => height % rows.max(1) == 0 && row_step % side.max(1) == 0,
|
||||
false => height % frame_step.max(1) == 0,
|
||||
};
|
||||
if side == 0 || frames == 0 || width % columns.max(1) != 0 || !whole {
|
||||
return Err(format!("{name} isn't a {columns} x {rows} grid of square tiles").into());
|
||||
}
|
||||
let tiles = (0..columns * rows)
|
||||
.map(|tile| {
|
||||
let frames: Vec<Vec<u8>> = (0..frames)
|
||||
.map(|f| {
|
||||
let (x, y) = (
|
||||
tile % columns * side,
|
||||
tile / columns * row_step + f * frame_step,
|
||||
);
|
||||
(y..y + side)
|
||||
.flat_map(|row| {
|
||||
let start = ((row * width + x) * 4) as usize;
|
||||
pixels[start..start + side as usize * 4].iter().copied()
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let alphas = || frames.iter().flat_map(|f| f.iter().skip(3).step_by(4));
|
||||
if alphas().all(|&a| a == 0) {
|
||||
return Ok(None);
|
||||
}
|
||||
let decoded = crate::sprite_cache::Decoded {
|
||||
side,
|
||||
animation: animation.clone().filter(|_| frames.len() > 1),
|
||||
strategy,
|
||||
alpha_cutoff_bias,
|
||||
has_transparent: alphas().any(|&a| a == 0),
|
||||
opaque: alphas().all(|&a| a == 255),
|
||||
translucent: alphas().any(|&a| a != 0 && a != 255),
|
||||
frames,
|
||||
};
|
||||
let tile_name = format!("fusion/{}#{tile}", name.replace(':', "/"));
|
||||
self.register(root, &tile_name, decoded).map(Some)
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
// Where Fusion doesn't draw the sheet (items, other models), the lone tile rather than
|
||||
// the whole sheet squeezed square, whose size would set every layer's.
|
||||
if let (Some(&own), Some(&first)) = (self.layers.get(name), tiles.iter().flatten().next()) {
|
||||
self.show_as(own, first);
|
||||
}
|
||||
Ok(tiles)
|
||||
}
|
||||
/// Layer `layer` shows what `source` does, and is no longer the layer of its old pixels.
|
||||
fn show_as(&mut self, layer: u32, source: u32) {
|
||||
self.still.retain(|_, l| *l != layer);
|
||||
let (l, s) = (layer as usize, source as usize);
|
||||
self.native[l] = self.native[s].clone();
|
||||
self.opaque[l] = self.opaque[s];
|
||||
self.translucent[l] = self.translucent[s];
|
||||
self.mips[l] = self.mips[s];
|
||||
self.pbr[l] = self.pbr[s].clone();
|
||||
self.animations.retain(|a| a.layer != layer);
|
||||
if let Some(animation) = self.animations.iter().find(|a| a.layer == source) {
|
||||
let animation = SpriteAnimation {
|
||||
layer,
|
||||
..animation.clone()
|
||||
};
|
||||
self.animations.push(animation);
|
||||
}
|
||||
}
|
||||
/// Read and decode one texture and its `.mcmeta`.
|
||||
fn decode(root: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
|
||||
if !name.contains(':') {
|
||||
@@ -762,6 +875,75 @@ mod tests {
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn tile_sheets_cut_into_a_layer_per_tile() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-sprite-tiles-{}", std::process::id()));
|
||||
let file = root.join("assets/minecraft/textures/block/sheet.png");
|
||||
fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
// 3 columns of 2 x 2 tiles over 4 rows of pixels: the pixel at (x, y) has red x, green
|
||||
// y; the middle column is transparent, and the top-right tile half see-through.
|
||||
let (w, h) = (6u32, 4u32);
|
||||
let pixels: Vec<u8> = (0..w * h)
|
||||
.flat_map(|i| {
|
||||
let (x, y) = (i % w, i / w);
|
||||
let alpha = match x / 2 {
|
||||
1 => 0,
|
||||
2 if y < 2 => 128,
|
||||
_ => 255,
|
||||
};
|
||||
[x as u8, y as u8, 0, alpha]
|
||||
})
|
||||
.collect();
|
||||
let mut encoder = png::Encoder::new(File::create(&file).unwrap(), w, h);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
// Red and green of each pixel in a layer's frame.
|
||||
let texels = |sprites: &Sprites, layer: u32, frame: usize| -> Vec<(u8, u8)> {
|
||||
sprites.native[layer as usize].1[frame]
|
||||
.chunks(4)
|
||||
.map(|p| (p[0], p[1]))
|
||||
.collect()
|
||||
};
|
||||
let mut sprites = Sprites::default();
|
||||
let name = "minecraft:block/sheet";
|
||||
let own = sprites.insert(&root, name).unwrap();
|
||||
// Still, as a 3 x 2 grid: the empty middle column draws nothing.
|
||||
let tiles = sprites.insert_tiles(&root, name, (3, 2), false).unwrap();
|
||||
// The sheet's own layer now shows the top-left tile, at a tile's size.
|
||||
assert_eq!(texels(&sprites, own, 0), [(0, 0), (1, 0), (0, 1), (1, 1)]);
|
||||
assert_eq!(
|
||||
tiles.iter().map(Option::is_some).collect::<Vec<_>>(),
|
||||
[true, false, true, true, false, true]
|
||||
);
|
||||
let bottom_left = tiles[3].unwrap();
|
||||
assert_eq!(
|
||||
texels(&sprites, bottom_left, 0),
|
||||
[(0, 2), (1, 2), (0, 3), (1, 3)]
|
||||
);
|
||||
assert!(sprites.translucent[tiles[2].unwrap() as usize]);
|
||||
assert!(sprites.opaque[bottom_left as usize]);
|
||||
// Animated, as one row of tiles: two frames, the grid's top and then its bottom half;
|
||||
// per tile, each cell is the column of a tile's frames, which here is the same.
|
||||
fs::write(file.with_extension("png.mcmeta"), r#"{"animation":{}}"#).unwrap();
|
||||
for per_tile in [false, true] {
|
||||
let mut sprites = Sprites::default();
|
||||
let tiles = sprites.insert_tiles(&root, name, (3, 1), per_tile).unwrap();
|
||||
let first = tiles[0].unwrap();
|
||||
assert_eq!(texels(&sprites, first, 0), [(0, 0), (1, 0), (0, 1), (1, 1)]);
|
||||
assert_eq!(texels(&sprites, first, 1), [(0, 2), (1, 2), (0, 3), (1, 3)]);
|
||||
assert_eq!(sprites.animations.len(), 2, "two non-empty tiles");
|
||||
}
|
||||
// Not a grid of square tiles.
|
||||
assert!(sprites.insert_tiles(&root, name, (4, 1), false).is_err());
|
||||
assert!(sprites.insert_tiles(&root, name, (3, 3), false).is_err());
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn custom_item_texture_ids_load_with_their_animation() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-sprite-cit-{}", std::process::id()));
|
||||
let file = root.join("assets/minecraft/optifine/cit/a.png");
|
||||
|
||||
@@ -37,6 +37,9 @@ struct Globals {
|
||||
// HDR output's tier windows (hdr.rs `Hdr::windows`): x..w low to very bright,
|
||||
// [0] where the lift starts, [1] where it is full.
|
||||
hdr_windows: array<vec4<f32>, 2>,
|
||||
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
|
||||
// y what the sky and fog get: the same, but none under water.
|
||||
hdr_light: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var sprites: texture_2d_array<f32>;
|
||||
@@ -171,6 +174,8 @@ struct TerrainOut {
|
||||
// How the face shines in HDR output (mesher.rs's `EMISSION_BITS`): 0 not
|
||||
// at all, 1 to 4 the tier with its bright parts, 5 to 8 the tier all over.
|
||||
@location(5) @interpolate(flat) emission: u32,
|
||||
// HDR lighting: block and sky light, as levels 0 to 15.
|
||||
@location(11) scene: vec2<f32>,
|
||||
@location(6) @interpolate(flat) fade: u32,
|
||||
// A distant tile's tile-relative position, for its fade (`faded`).
|
||||
@location(7) local: vec3<f32>,
|
||||
@@ -361,6 +366,22 @@ fn left_out() -> TerrainOut {
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w);
|
||||
}
|
||||
// HDR lighting: how far a surface's light carries it above paper white, 0 to 1. Only the
|
||||
// strongest light does, so dark stays as dark as in SDR; sky light by how the face meets the sun.
|
||||
fn hdr_scene(block: f32, sky: f32, facing: f32) -> f32 {
|
||||
let b = smoothstep(11.0, 15.0, block);
|
||||
let s = smoothstep(11.0, 15.0, sky) * globals.hdr_light.x * facing;
|
||||
return max(b, s);
|
||||
}
|
||||
// The frame's alpha for it, between 1 (none) and the light sources' marks below 0.75.
|
||||
fn hdr_scene_alpha(scene: f32) -> f32 {
|
||||
return 1.0 - 0.24 * scene;
|
||||
}
|
||||
// The face's normal, from the fragment's derivatives, for how it faces the sun.
|
||||
var<private> hdr_normal: vec3<f32>;
|
||||
fn hdr_facing() -> f32 {
|
||||
return mix(0.45, 1.0, max(dot(hdr_normal, globals.sun.xyz), 0.0));
|
||||
}
|
||||
fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>, origin: vec3<f32>, w: f32) -> TerrainOut {
|
||||
let pos = position + origin;
|
||||
@@ -384,6 +405,7 @@ fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f3
|
||||
}
|
||||
let block = max(f32(light.y), dynamic_block_light(pos));
|
||||
out.color = vec4(color.rgb, 1.0) * sample_lightmap(f32(light.x) / 16.0, block / 16.0);
|
||||
out.scene = vec2(block / 16.0, f32(light.x) / 16.0);
|
||||
if globals.colored_count.x > 0u {
|
||||
out.color = vec4(min(out.color.rgb + color.rgb * native_colored_light(pos), vec3(1.0)), out.color.a);
|
||||
}
|
||||
@@ -426,6 +448,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
|
||||
}
|
||||
// `fs_solid` for the opaque pass (`opaque_quad`): no fade, no cutout, no discard.
|
||||
@fragment fn fs_opaque(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
|
||||
let texel = sprite(in.uv, in.layer & 0x7fffu);
|
||||
var color = texel * in.color;
|
||||
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
|
||||
@@ -440,6 +463,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
|
||||
fn solid_color(in: TerrainOut) -> vec4<f32> {
|
||||
let dx = dpdx(in.local);
|
||||
let dy = dpdy(in.local);
|
||||
hdr_normal = normalize(cross(dy, dx));
|
||||
if faded(in.fade, in.local, dx, dy) { discard; }
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
let texel = sprite(in.uv, in.layer & 0x7fffu);
|
||||
@@ -456,15 +480,24 @@ fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32>
|
||||
// texel's own brightness, so its light level and face shading don't change
|
||||
// it. Only pixels as bright as their sprite's threshold may: its bright
|
||||
// parts, or all of it. Nothing else reads the frame's alpha.
|
||||
var emissive = 1.0;
|
||||
// Fog turns a far surface into the fog's colour: it takes the fog's light with it, or a
|
||||
// fully fogged mountain would stand out from the fog behind it.
|
||||
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
|
||||
var emissive = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()),
|
||||
globals.hdr_light.y, fogged));
|
||||
if in.emission > 0u {
|
||||
// A light source's own face shines as one or stays as drawn, without the lighting.
|
||||
emissive = 1.0;
|
||||
let whole = in.emission > 4u;
|
||||
let tier = select(in.emission, in.emission - 4u, whole);
|
||||
let l = in.layer & 0x7fffu;
|
||||
let t = textureLoad(emission_thresholds, vec2(i32(l % SPRITE_SPLIT), i32(l / SPRITE_SPLIT)), 0);
|
||||
let y = brightness(texel.rgb);
|
||||
if y >= select(t.r, t.g, whole) {
|
||||
emissive = hdr_mark(tier, y);
|
||||
// All over means all over: the sprite's range is one frame's, and an animated sprite's
|
||||
// other frames dip below it. Fog hides a light source as it hides everything else.
|
||||
if whole || y >= t.r {
|
||||
emissive = hdr_mark(tier, y * (1.0 - fogged));
|
||||
}
|
||||
}
|
||||
if in.emissive_layer != 0xffffffffu {
|
||||
@@ -612,7 +645,8 @@ fn sky_along_ray(d: vec3<f32>) -> vec3<f32> {
|
||||
return color;
|
||||
}
|
||||
@fragment fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
|
||||
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0);
|
||||
// `hdr_scene_alpha`, spelled out: this part of the file also goes into shader packs' skies.
|
||||
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0 - 0.24 * globals.hdr_light.y);
|
||||
}
|
||||
// The first-person hand's depth clear inside the world pass: `vs_sky`'s triangle lies at the
|
||||
// far plane, written over the whole depth target with no colour.
|
||||
|
||||
@@ -384,19 +384,42 @@ pub fn scrolling_text_at(
|
||||
y: f32,
|
||||
now_ms: i64,
|
||||
color: u32,
|
||||
) {
|
||||
// `acceptScrollingWithDefaultCenter`: `centerX = (left + right) / 2`.
|
||||
let center_x = left + ((right - left) / 2.0).floor();
|
||||
scrolling_text_around_at(
|
||||
batch, atlas, text, center_x, left, right, top, bottom, y, now_ms, color,
|
||||
);
|
||||
}
|
||||
|
||||
/// [`scrolling_text_at`] around an explicit `center_x`
|
||||
/// (`ActiveTextCollector.acceptScrolling`, which `SpriteIconButton.TextAndIcon`
|
||||
/// calls with the button's middle): a label that fits is centred on `center_x`
|
||||
/// moved as far as it must to stay between `left` and `right`
|
||||
/// (`Mth.clamp(centerX, left + lineWidth / 2, right - lineWidth / 2)`), so
|
||||
/// beside an icon it ends where the icon's room begins. A wider one is drawn
|
||||
/// from the left and slid inside the box.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn scrolling_text_around_at(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
text: &Text,
|
||||
center_x: f32,
|
||||
left: f32,
|
||||
right: f32,
|
||||
top: f32,
|
||||
bottom: f32,
|
||||
y: f32,
|
||||
now_ms: i64,
|
||||
color: u32,
|
||||
) {
|
||||
let text = text.resolved();
|
||||
let width = atlas.font.width_text(&text);
|
||||
let room = right - left;
|
||||
if width <= room {
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&text,
|
||||
centered_left(left, room, width),
|
||||
y,
|
||||
color,
|
||||
true,
|
||||
);
|
||||
let half = (width / 2.0).floor();
|
||||
let text_x = center_x.max(left + half).min(right - half);
|
||||
batch.text_styled(atlas, &text, text_x - half, y, color, true);
|
||||
return;
|
||||
}
|
||||
// Scissored to the widget: what does not fit is read as the label
|
||||
@@ -440,6 +463,37 @@ pub fn scrolling_text(
|
||||
);
|
||||
}
|
||||
|
||||
/// `MenuTabBar.MenuTabButton.renderLabel` for the tab at `x, y`: its label
|
||||
/// inside the tab less 1 px on each side, from the tab's top when selected
|
||||
/// and 3 px lower when not (`acceptScrollingWithDefaultCenter`, so one wider
|
||||
/// than the tab slides instead of running into the next tab). White, gray
|
||||
/// for an inactive tab (`WithInactiveMessage`).
|
||||
pub fn menu_tab_label(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
label: &Text,
|
||||
(x, y, width, height): (f32, f32, f32, f32),
|
||||
selected: bool,
|
||||
active: bool,
|
||||
now_ms: i64,
|
||||
) {
|
||||
let top = y + if selected { 0.0 } else { 3.0 };
|
||||
let bottom = y + height;
|
||||
scrolling_text_at(
|
||||
batch,
|
||||
atlas,
|
||||
label,
|
||||
x + 1.0,
|
||||
x + width - 1.0,
|
||||
top,
|
||||
bottom,
|
||||
// defaultScrollingHelper: `(top + bottom - lineHeight) / 2 + 1`.
|
||||
((top + bottom - 9.0) / 2.0).floor() + 1.0,
|
||||
now_ms,
|
||||
if active { u32::MAX } else { 0xFFA0_A0A0 },
|
||||
);
|
||||
}
|
||||
|
||||
/// Button (AbstractButton): nine-slice sprite plus the centred label.
|
||||
pub fn button(
|
||||
batch: &mut HudBatch,
|
||||
@@ -1642,6 +1696,51 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `MenuTabButton.renderLabel` on a 24 px tab: centred on the tab, its
|
||||
/// text top at `(0 + 24 - 9) / 2 + 1 = 8` when selected and at
|
||||
/// `(3 + 24 - 9) / 2 + 1 = 10` when not; white on an active tab, selected
|
||||
/// or not, `0xA0A0A0` on an inactive one (`WithInactiveMessage`). The
|
||||
/// Statistics tabs drew every label at 8 and an active unselected one gray.
|
||||
#[test]
|
||||
fn a_tab_label_sits_lower_on_an_unselected_tab_and_is_gray_only_when_inactive() {
|
||||
let atlas = atlas();
|
||||
let label = Text::literal("Items");
|
||||
let half = (atlas.font.width_text(&label.resolved()) / 2.0).floor();
|
||||
// A 124 px tab at x = 100: `(left + right) / 2 = (101 + 223) / 2 = 162`.
|
||||
let left = 162.0 - half;
|
||||
for (selected, active, y, color) in [
|
||||
(true, true, 8.0, u32::MAX),
|
||||
(false, true, 10.0, u32::MAX),
|
||||
(false, false, 10.0, 0xFFA0_A0A0),
|
||||
] {
|
||||
let mut tab = HudBatch::new([640, 480]);
|
||||
menu_tab_label(
|
||||
&mut tab,
|
||||
&atlas,
|
||||
&label,
|
||||
(100.0, 0.0, 124.0, 24.0),
|
||||
selected,
|
||||
active,
|
||||
0,
|
||||
);
|
||||
let mut want = HudBatch::new([640, 480]);
|
||||
want.text_styled(&atlas, &label.resolved(), left, y, color, true);
|
||||
let glyphs = |b: &HudBatch| -> Vec<([u32; 2], u32)> {
|
||||
b.vertices
|
||||
.iter()
|
||||
.filter(|v| v.text == 1)
|
||||
.map(|v| ([v.pos[0].to_bits(), v.pos[1].to_bits()], v.color))
|
||||
.collect()
|
||||
};
|
||||
assert!(!glyphs(&want).is_empty());
|
||||
assert_eq!(
|
||||
glyphs(&tab),
|
||||
glyphs(&want),
|
||||
"selected {selected}, active {active}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slider_track_is_outlined_only_while_dragged() {
|
||||
let (track, handle) = slider_sprites(true, true, false);
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
"UI Brightness": "GUI-Helligkeit",
|
||||
"Battery Mode": "Akkumodus",
|
||||
"Graphics API": "Grafik-API",
|
||||
"Translucent Batching": "Transparenz-Batching",
|
||||
"Performance Overlay": "Leistungsanzeige",
|
||||
"Full Detail Distance": "Detailweite",
|
||||
"On": "An",
|
||||
@@ -142,6 +143,7 @@
|
||||
"This feature is not yet available in Horizon.": "Diese Funktion ist in Horizon noch nicht verfügbar.",
|
||||
"Your display or system doesn't offer HDR output.": "Dein Bildschirm oder System unterstützt keine HDR-Ausgabe.",
|
||||
"Turn HDR on to use it.": "Schalte HDR ein, um es zu nutzen.",
|
||||
"Your GPU or graphics API doesn't support it.": "Deine GPU oder Grafik-API unterstützt das nicht.",
|
||||
"%s chunks": "%s Chunks",
|
||||
"%s per frame": "%s pro Bild",
|
||||
"%s MiB/frame": "%s MiB/Bild",
|
||||
@@ -164,7 +166,6 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Wie weit Kreaturen, Pflanzen und Maschinen weiterarbeiten. Kostet CPU-Zeit.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Zeichenweite für Kreaturen und Gegenstände, anteilig zur Norm.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Überspringt Gelände hinter geschlossenen Abschnitten. Aus kostet viel.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Versteckt Modelle hinter Blöcken. Bei eigenen Paketen ausschalten.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Mesh-Abschnitte pro Bild. Weniger: flüssigeres Laden.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Weiche Schatten in Ecken. Kostet nur Zeit beim Chunk-Aufbau.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Kleinere Textur-Kopien für fernes Gelände: weniger Flimmern.",
|
||||
@@ -198,6 +199,7 @@
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS (30 im Menü/ohne Fokus), halb so viele Worker: spart Strom.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Threads für Erzeugung, Licht und Mesh der Chunks. Nach Neustart.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, sonst DirectX 12 (Windows) oder OpenGL (Linux). Nach Neustart.",
|
||||
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Wasser und Glas in einem Puffer: weniger Draw-Calls, etwas mehr VRAM. Nach Neustart.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Pro Bild an die GPU gesendete Mesh-Daten. Weniger ist flüssiger beim Laden.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Bildrate und Bildzeiten über dem Spiel, mit Diagramm.",
|
||||
"horizon.multiplayer.finding_friend": "Die Welt deines Freundes wird gesucht …",
|
||||
@@ -286,5 +288,53 @@
|
||||
"horizon.multiplayer.not_signed_in": "Dieser Server läuft im Online-Modus. Melde dich im Horizon-Launcher mit deinem Microsoft-Konto an, um ihm beizutreten.",
|
||||
"horizon.multiplayer.invalid_address": "Ungültige Serveradresse: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Direkt mit deinem Freund verbunden …",
|
||||
"horizon.multiplayer.connected_friend_relay": "Über das Relay mit deinem Freund verbunden …"
|
||||
"horizon.multiplayer.connected_friend_relay": "Über das Relay mit deinem Freund verbunden …",
|
||||
"Culling": "Culling",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Versteckt Modelle hinter Blöcken. Prüfung im Hintergrund; Objekte laufen weiter.",
|
||||
"Leaf Culling": "Laub-Culling",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "Jede Blattfläche, wie Minecraft sie bei schönen Blättern zeichnet.",
|
||||
"Distant Plant Dither": "Pflanzen-Dithering",
|
||||
"Keeps fine plants in high-res packs from fading far away.": "Verhindert, dass feine Pflanzen in hochauflösenden Paketen in der Ferne verblassen.",
|
||||
"Screenshot Quality": "Screenshot-Qualität",
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2-Screenshots in diesem Vielfachen der Spielauflösung. Höher dauert länger.",
|
||||
"Frame Generation": "Bildgenerierung",
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Zeigt zwischen zwei echten Bildern ein errechnetes: flüssiger, etwas mehr Verzögerung.",
|
||||
"Shadow Cache": "Schatten-Cache",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Verwendet die Schattenkarte des Pakets erneut, wenn Sonne, Blick und Welt gleich bleiben. Standardmäßig aus.",
|
||||
"Synchronous": "Synchron",
|
||||
"Asynchronous": "Asynchron",
|
||||
"Solid": "Massiv",
|
||||
"Solid Aggressive": "Massiv, aggressiv",
|
||||
"Hollow": "Hohl",
|
||||
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Blätter mitten im Blätterdach werden massiv gezeichnet: weniger durchsichtige Schichten.",
|
||||
"Leaves with one open side draw solid too: faster, chunkier edges.": "Auch Blätter mit einer offenen Seite werden massiv gezeichnet: schneller, gröbere Kanten.",
|
||||
"Drops faces between leaves: faster, but gaps look through.": "Lässt Flächen zwischen Blättern weg: schneller, aber Lücken sind durchsichtig.",
|
||||
"Hotbar: Previous Slot": "Schnellzugriffsleiste: Vorheriger Platz",
|
||||
"Hotbar: Next Slot": "Schnellzugriffsleiste: Nächster Platz",
|
||||
"horizon.agent.connected.one": "Verbunden: 1 Agentenverbindung",
|
||||
"horizon.agent.connected.many": "Verbunden: %s Agentenverbindungen",
|
||||
"horizon.agent.connected.one_ago": "Verbunden: 1 Agentenverbindung, letzter Befehl vor %s s",
|
||||
"horizon.agent.connected.many_ago": "Verbunden: %s Agentenverbindungen, letzter Befehl vor %s s",
|
||||
"horizon.agent.waiting": "Wartet auf einen Agenten.",
|
||||
"horizon.agent.off": "Aus: Agenten können dieses Spiel nicht erreichen.",
|
||||
"horizon.agent.session_opened": "Für diese Sitzung von dem Programm geöffnet, das das Spiel gestartet hat.",
|
||||
"horizon.agent.socket": "Socket: %s (Verbindungsdaten kopieren für den vollständigen Pfad)",
|
||||
"horizon.agent.local_only": "Nur lokal: Agenten auf diesem Computer, nie über das Netzwerk.",
|
||||
"horizon.extras.chat_animation": "Weicher Chat",
|
||||
"horizon.extras.chat_animation.tooltip": "Eine neue Chatzeile gleitet von links herein und blendet dabei ein, statt sofort ganz zu erscheinen.",
|
||||
"horizon.extras.chat_indicator": "Nachrichtenmarkierung",
|
||||
"horizon.extras.chat_indicator.tooltip": "Der farbige Balken links neben einer Chatzeile.",
|
||||
"horizon.extras.header.chat": "Chat",
|
||||
"horizon.extras.header.particles": "Partikel",
|
||||
"horizon.extras.header.sound": "Ton",
|
||||
"horizon.extras.hide_block_particles": "Blockpartikel weg",
|
||||
"horizon.extras.hide_block_particles.tooltip": "Blendet die Trümmerpartikel aus, die aus einem zerstörten Block sprühen und von dem abbröckeln, den du abbaust. Andere Partikel bleiben.",
|
||||
"horizon.extras.splitscreen": "Splitscreen",
|
||||
"horizon.extras.splitscreen.tooltip": "In einer Welt kann ein weiterer Controller als neuer Spieler beitreten: A drücken, wenn gefragt, dann Profil und Spielort wählen.",
|
||||
"horizon.extras.ui_sound_reserve": "Kanäle für UI-Töne",
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "Weltgeräusche lassen 16 der 247 Tonkanäle frei, damit eine Flut davon (eine große Explosion) Menü-Klicks nicht verstummen lässt.",
|
||||
"horizon.pages.open.split_setup": "Splitscreen …",
|
||||
"horizon.pages.split_settings": "Splitscreen-Einstellungen",
|
||||
"horizon.pages.split_setup": "Splitscreen",
|
||||
"horizon.pages.split_setup.tooltip": "Spieler an diesem Computer: Controller können deiner Welt beitreten."
|
||||
}
|
||||
@@ -170,7 +170,6 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "How far mobs, crops and machines keep working. CPU time.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "How far away mobs and items are drawn, as a share of normal.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Skips terrain hidden behind closed sections. Off costs a lot.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Hides models behind opaque blocks. Entities update. Off with custom packs.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Meshed sections uploaded per frame. Lower is smoother while loading.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Soft shadows in corners. Costs chunk building time only.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Smaller copies of block textures for distant terrain: less shimmer.",
|
||||
@@ -306,5 +305,36 @@
|
||||
"horizon.osk.close": "Close",
|
||||
"horizon.extras.chat_animation.tooltip": "A new chat line slides in from the left while it fades in, instead of appearing whole.",
|
||||
"horizon.extras.chat_indicator.tooltip": "The coloured bar at the left of a chat line.",
|
||||
"horizon.extras.splitscreen.tooltip": "In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
|
||||
"horizon.extras.splitscreen.tooltip": "In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play.",
|
||||
"Culling": "Culling",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Hides models behind opaque blocks. Async checks off-thread; entities tick.",
|
||||
"Leaf Culling": "Leaf Culling",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "Every leaf face. Vanilla's fancy leaves.",
|
||||
"Distant Plant Dither": "Distant Plant Dither",
|
||||
"Keeps fine plants in high-res packs from fading far away.": "Keeps fine plants in high-res packs from fading far away.",
|
||||
"Screenshot Quality": "Screenshot Quality",
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2 screenshots at this many times the game's resolution. Higher takes longer.",
|
||||
"Frame Generation": "Frame Generation",
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Shows a made-up frame between each two real ones: smoother, a little more lag.",
|
||||
"Shadow Cache": "Shadow Cache",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Reuses the pack shadow map when sun, view and world match. Off by default.",
|
||||
"Synchronous": "Synchronous",
|
||||
"Asynchronous": "Asynchronous",
|
||||
"Solid": "Solid",
|
||||
"Solid Aggressive": "Solid Aggressive",
|
||||
"Hollow": "Hollow",
|
||||
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Leaves fully inside a canopy draw solid: fewer see-through layers.",
|
||||
"Leaves with one open side draw solid too: faster, chunkier edges.": "Leaves with one open side draw solid too: faster, chunkier edges.",
|
||||
"Drops faces between leaves: faster, but gaps look through.": "Drops faces between leaves: faster, but gaps look through.",
|
||||
"Hotbar: Previous Slot": "Hotbar: Previous Slot",
|
||||
"Hotbar: Next Slot": "Hotbar: Next Slot",
|
||||
"horizon.agent.connected.one": "Connected: 1 agent connection",
|
||||
"horizon.agent.connected.many": "Connected: %s agent connections",
|
||||
"horizon.agent.connected.one_ago": "Connected: 1 agent connection, last command %s s ago",
|
||||
"horizon.agent.connected.many_ago": "Connected: %s agent connections, last command %s s ago",
|
||||
"horizon.agent.waiting": "Waiting for an agent.",
|
||||
"horizon.agent.off": "Off: agents can't reach this game.",
|
||||
"horizon.agent.session_opened": "Opened for this session by the program that started the game.",
|
||||
"horizon.agent.socket": "Socket: %s (copy the details for its full path)",
|
||||
"horizon.agent.local_only": "Local only: agents on this computer, never the network."
|
||||
}
|
||||
@@ -158,7 +158,6 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Hasta dónde siguen funcionando mobs, cultivos y máquinas. Tiempo de CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "A qué distancia se dibujan mobs y objetos, como proporción de lo normal.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Omite el terreno oculto tras secciones cerradas. Desactivarlo cuesta mucho.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Oculta modelos tras bloques opacos. Las entidades se actualizan. Desactivado con paquetes personalizados.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Secciones malladas subidas por fotograma. Menos es más fluido durante la carga.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Sombras suaves en las esquinas. Solo cuesta tiempo de construcción de chunks.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Copias más pequeñas de las texturas de bloques para el terreno lejano: menos parpadeo.",
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
"UI Brightness": "Luminosité UI",
|
||||
"Battery Mode": "Mode batterie",
|
||||
"Graphics API": "API graphique",
|
||||
"Translucent Batching": "Regroupement translucide",
|
||||
"Performance Overlay": "Infos de perfs",
|
||||
"Full Detail Distance": "Dist. détail",
|
||||
"On": "Oui",
|
||||
@@ -142,6 +143,7 @@
|
||||
"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",
|
||||
@@ -164,7 +166,6 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Portée d'activité des créatures, cultures, machines. Coût : CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Distance des créatures et objets, en part de la normale.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Ignore le terrain caché. Désactiver coûte cher.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Masque les modèles derrière les blocs. Éteindre avec packs.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Sections maillées par image. Moins : chargement plus fluide.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Ombres douces dans les coins. Coût : construction des chunks.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Textures réduites au loin : moins de scintillement.",
|
||||
@@ -198,6 +199,7 @@
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS max (30 en menu/sans focus), moitié de threads : économie.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Threads de génération, lumière et maillage. Au redémarrage.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, sinon DirectX 12 (Windows) ou OpenGL (Linux). Au redémarrage.",
|
||||
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Eau et verre dans un seul tampon : moins d'appels de dessin, un peu plus de VRAM. Au redémarrage.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Données de maillage vers le GPU par image. Moins : plus fluide.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Fréquence et temps d'image par-dessus le jeu, avec un graphique.",
|
||||
"horizon.multiplayer.finding_friend": "Recherche du monde de votre ami...",
|
||||
@@ -286,5 +288,53 @@
|
||||
"horizon.multiplayer.not_signed_in": "Ce serveur est en mode en ligne. Connectez-vous avec votre compte Microsoft dans le lanceur Horizon pour le rejoindre.",
|
||||
"horizon.multiplayer.invalid_address": "Adresse de serveur invalide : %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Connecté directement à votre ami...",
|
||||
"horizon.multiplayer.connected_friend_relay": "Connecté à votre ami via le relais..."
|
||||
"horizon.multiplayer.connected_friend_relay": "Connecté à votre ami via le relais...",
|
||||
"Culling": "Occlusion",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Masque les modèles derrière les blocs opaques. Vérification en arrière-plan ; les entités continuent.",
|
||||
"Leaf Culling": "Occlusion feuillage",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "Toutes les faces de feuillage, comme les feuilles détaillées de Minecraft.",
|
||||
"Distant Plant Dither": "Tramage des plantes",
|
||||
"Keeps fine plants in high-res packs from fading far away.": "Empêche les plantes fines des packs haute résolution de s'estomper au loin.",
|
||||
"Screenshot Quality": "Qualité des captures",
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Captures F2 à ce multiple de la résolution du jeu. Plus c'est haut, plus c'est long.",
|
||||
"Frame Generation": "Génération d'images",
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Insère une image calculée entre deux vraies : plus fluide, un peu plus de latence.",
|
||||
"Shadow Cache": "Cache des ombres",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Réutilise la carte d'ombres du pack si soleil, vue et monde sont identiques. Désactivé par défaut.",
|
||||
"Synchronous": "Synchrone",
|
||||
"Asynchronous": "Asynchrone",
|
||||
"Solid": "Opaque",
|
||||
"Solid Aggressive": "Opaque agressif",
|
||||
"Hollow": "Creux",
|
||||
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Les feuilles au cœur d'un feuillage sont opaques : moins de couches transparentes.",
|
||||
"Leaves with one open side draw solid too: faster, chunkier edges.": "Les feuilles avec un côté ouvert sont aussi opaques : plus rapide, bords plus grossiers.",
|
||||
"Drops faces between leaves: faster, but gaps look through.": "Supprime les faces entre feuilles : plus rapide, mais on voit à travers les trous.",
|
||||
"Hotbar: Previous Slot": "Barre d'action : case précédente",
|
||||
"Hotbar: Next Slot": "Barre d'action : case suivante",
|
||||
"horizon.agent.connected.one": "Connecté : 1 connexion d'agent",
|
||||
"horizon.agent.connected.many": "Connecté : %s connexions d'agent",
|
||||
"horizon.agent.connected.one_ago": "Connecté : 1 connexion d'agent, dernière commande il y a %s s",
|
||||
"horizon.agent.connected.many_ago": "Connecté : %s connexions d'agent, dernière commande il y a %s s",
|
||||
"horizon.agent.waiting": "En attente d'un agent.",
|
||||
"horizon.agent.off": "Désactivé : les agents ne peuvent pas joindre ce jeu.",
|
||||
"horizon.agent.session_opened": "Ouvert pour cette session par le programme qui a lancé le jeu.",
|
||||
"horizon.agent.socket": "Socket : %s (copiez les détails pour le chemin complet)",
|
||||
"horizon.agent.local_only": "Local uniquement : agents sur cet ordinateur, jamais via le réseau.",
|
||||
"horizon.extras.chat_animation": "Chat fluide",
|
||||
"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",
|
||||
"horizon.extras.hide_block_particles.tooltip": "Masque les débris qui jaillissent d'un bloc cassé et s'effritent de celui que vous minez. Les autres particules restent.",
|
||||
"horizon.extras.splitscreen": "Écran partagé",
|
||||
"horizon.extras.splitscreen.tooltip": "Dans un monde, une autre manette peut rejoindre en tant que nouveau joueur : appuyez sur A quand c'est demandé, puis choisissez un profil et où jouer.",
|
||||
"horizon.extras.ui_sound_reserve": "Canaux pour sons UI",
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "Les sons du monde laissent 16 des 247 canaux sonores libres, pour qu'un déluge de sons (une grosse explosion) ne couvre pas les clics de menu.",
|
||||
"horizon.pages.open.split_setup": "Écran partagé...",
|
||||
"horizon.pages.split_settings": "Options de l'écran partagé",
|
||||
"horizon.pages.split_setup": "Écran partagé",
|
||||
"horizon.pages.split_setup.tooltip": "Joueurs sur cet ordinateur : des manettes peuvent rejoindre votre monde."
|
||||
}
|
||||
@@ -95,6 +95,7 @@
|
||||
"UI Brightness": "Jasność interfejsu",
|
||||
"Battery Mode": "Tryb baterii",
|
||||
"Graphics API": "API grafiki",
|
||||
"Translucent Batching": "Grupowanie półprzezroczystych",
|
||||
"Performance Overlay": "Nakładka wydajności",
|
||||
"Full Detail Distance": "Zasięg detali",
|
||||
"On": "Wł.",
|
||||
@@ -142,6 +143,7 @@
|
||||
"This feature is not yet available in Horizon.": "Ta funkcja nie jest jeszcze dostępna w Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Twój wyświetlacz lub system nie obsługuje wyjścia HDR.",
|
||||
"Turn HDR on to use it.": "Włącz HDR, aby tego użyć.",
|
||||
"Your GPU or graphics API doesn't support it.": "Twoje GPU lub API graficzne tego nie obsługuje.",
|
||||
"%s chunks": "%s chunków",
|
||||
"%s per frame": "%s na klatkę",
|
||||
"%s MiB/frame": "%s MiB/klatka",
|
||||
@@ -164,7 +166,6 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Jak daleko stworzenia, uprawy i maszyny nadal działają. Czas procesora.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Odległość rysowania stworzeń i przedmiotów, jako ułamek normy.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Pomija teren ukryty za zamkniętymi sekcjami. Wyłączone dużo kosztuje.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Ukrywa modele za pełnymi blokami. Wył. przy własnych paczkach.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Liczba sekcji siatki wysyłanych na klatkę. Mniej to płynniej podczas ładowania.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Miękkie cienie w narożnikach. Kosztuje tylko czas budowania chunków.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Mniejsze kopie tekstur bloków dla odległego terenu: mniej migotania.",
|
||||
@@ -198,6 +199,7 @@
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "Limit 60 FPS (30 w menu/bez fokusu), połowa wątków: mniej energii.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Wątki generujące, oświetlające i meshujące chunki. Po restarcie.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, awaryjnie DirectX 12 (Windows) / OpenGL (Linux). Po restarcie.",
|
||||
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Woda i szkło w jednym buforze: mniej wywołań rysowania, trochę więcej VRAM. Po restarcie.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Dane siatki wysyłane do GPU na klatkę. Mniej to płynniej podczas ładowania.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Liczba klatek i czasy klatek nad grą, z wykresem.",
|
||||
"horizon.multiplayer.finding_friend": "Szukanie świata znajomego…",
|
||||
@@ -286,5 +288,53 @@
|
||||
"horizon.osk.delete": "Usuń",
|
||||
"horizon.osk.type": "Wpisz",
|
||||
"horizon.osk.symbols": "Symbole",
|
||||
"horizon.osk.close": "Zamknij"
|
||||
"horizon.osk.close": "Zamknij",
|
||||
"Culling": "Pomijanie",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Ukrywa modele za pełnymi blokami. Sprawdzanie w tle; byty nadal się aktualizują.",
|
||||
"Leaf Culling": "Pomijanie liści",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "Każda ściana liści, tak jak rysuje je Minecraft.",
|
||||
"Distant Plant Dither": "Dithering roślin",
|
||||
"Keeps fine plants in high-res packs from fading far away.": "Zapobiega zanikaniu drobnych roślin w paczkach wysokiej rozdzielczości w oddali.",
|
||||
"Screenshot Quality": "Jakość zrzutów ekranu",
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Zrzuty F2 w tylokrotności rozdzielczości gry. Więcej trwa dłużej.",
|
||||
"Frame Generation": "Generowanie klatek",
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Pokazuje wygenerowaną klatkę między każdymi dwiema prawdziwymi: płynniej, trochę większe opóźnienie.",
|
||||
"Shadow Cache": "Pamięć cieni",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Używa ponownie mapy cieni paczki, gdy słońce, widok i świat się zgadzają. Domyślnie wyłączone.",
|
||||
"Synchronous": "Synchroniczne",
|
||||
"Asynchronous": "Asynchroniczne",
|
||||
"Solid": "Pełne",
|
||||
"Solid Aggressive": "Pełne, agresywnie",
|
||||
"Hollow": "Puste",
|
||||
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Liście w środku korony rysują się jako pełne: mniej przezroczystych warstw.",
|
||||
"Leaves with one open side draw solid too: faster, chunkier edges.": "Liście z jedną otwartą stroną też rysują się jako pełne: szybciej, grubsze krawędzie.",
|
||||
"Drops faces between leaves: faster, but gaps look through.": "Pomija ściany między liśćmi: szybciej, ale luki są przezroczyste.",
|
||||
"Hotbar: Previous Slot": "Pasek szybkiego wyboru: poprzednie pole",
|
||||
"Hotbar: Next Slot": "Pasek szybkiego wyboru: następne pole",
|
||||
"horizon.agent.connected.one": "Połączono: 1 połączenie agenta",
|
||||
"horizon.agent.connected.many": "Połączenia agenta: %s",
|
||||
"horizon.agent.connected.one_ago": "Połączono: 1 połączenie agenta, ostatnie polecenie %s s temu",
|
||||
"horizon.agent.connected.many_ago": "Połączenia agenta: %s, ostatnie polecenie %s s temu",
|
||||
"horizon.agent.waiting": "Czeka na agenta.",
|
||||
"horizon.agent.off": "Wyłączone: agenci nie mają dostępu do tej gry.",
|
||||
"horizon.agent.session_opened": "Otwarte na tę sesję przez program, który uruchomił grę.",
|
||||
"horizon.agent.socket": "Gniazdo: %s (skopiuj dane, aby poznać pełną ścieżkę)",
|
||||
"horizon.agent.local_only": "Tylko lokalnie: agenci na tym komputerze, nigdy przez sieć.",
|
||||
"horizon.extras.chat_animation": "Płynny czat",
|
||||
"horizon.extras.chat_animation.tooltip": "Nowa linia czatu wsuwa się z lewej i pojawia stopniowo, zamiast wyświetlać się od razu w całości.",
|
||||
"horizon.extras.chat_indicator": "Znacznik wiadomości",
|
||||
"horizon.extras.chat_indicator.tooltip": "Kolorowy pasek po lewej stronie linii czatu.",
|
||||
"horizon.extras.header.chat": "Czat",
|
||||
"horizon.extras.header.particles": "Cząsteczki",
|
||||
"horizon.extras.header.sound": "Dźwięk",
|
||||
"horizon.extras.hide_block_particles": "Ukryj cząstki bloków",
|
||||
"horizon.extras.hide_block_particles.tooltip": "Ukrywa cząsteczki odłamków, które tryskają ze zniszczonego bloku i kruszą się z tego, który kopiesz. Inne cząsteczki zostają.",
|
||||
"horizon.extras.splitscreen": "Podzielony ekran",
|
||||
"horizon.extras.splitscreen.tooltip": "W świecie może dołączyć kolejny kontroler jako nowy gracz: naciśnij A, gdy zapyta, potem wybierz profil i miejsce gry.",
|
||||
"horizon.extras.ui_sound_reserve": "Kanały dla dźwięków UI",
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "Dźwięki świata zostawiają wolnych 16 z 247 kanałów, by ich nawał (duży wybuch) nie wyciszył kliknięć w menu.",
|
||||
"horizon.pages.open.split_setup": "Podzielony ekran…",
|
||||
"horizon.pages.split_settings": "Ustawienia podzielonego ekranu",
|
||||
"horizon.pages.split_setup": "Podzielony ekran",
|
||||
"horizon.pages.split_setup.tooltip": "Gracze na tym komputerze: kontrolery mogą dołączyć do twojego świata."
|
||||
}
|
||||
@@ -157,7 +157,6 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Как далеко мобы, посевы и механизмы продолжают работать. Нагрузка на процессор.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "С какой дистанции рисуются мобы и предметы, в долях от обычной.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Пропускает рельеф, скрытый за закрытыми секциями. Выключено — дорого.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Прячет модели за непрозрачными блоками. Сущности тикают. Выключать со своими наборами.",
|
||||
"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.": "Уменьшенные копии текстур блоков для дали: меньше мерцания.",
|
||||
|
||||
@@ -158,7 +158,6 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Yaratıkların, ekinlerin ve makinelerin ne kadar uzaktan çalışmaya devam edeceğini belirler. İşlemci gücü kullanır.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Yaratıkların ve eşyaların ne kadar uzaktan görüntüleneceğini, normal mesafeye oranla belirler.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Kapalı alanların arkasında kalan arazilerin çizilmesini önler. Kapalı olması ciddi performans kaybına neden olur.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Opak blokların arkasında kalan modelleri gizler. Varlıkların güncellemeleri devam eder. Özel paketler kullanırken kapatın.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Her karede GPU'ya yüklenen oluşturulmuş bölüm sayısı. Daha düşük değerler yükleme sırasında daha akıcı bir deneyim sağlar.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Köşelerde yumuşak gölgeler oluşturur. Yalnızca parça oluşturma süresini etkiler.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Uzak arazilerde blok dokularının daha küçük kopyalarını kullanır. Titreşimli görüntüyü azaltır.",
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
"UI Brightness": "Яскравість UI",
|
||||
"Battery Mode": "Режим батареї",
|
||||
"Graphics API": "API графіки",
|
||||
"Translucent Batching": "Групування напівпрозорого",
|
||||
"Performance Overlay": "Накладка швидкодії",
|
||||
"Full Detail Distance": "Дальність деталей",
|
||||
"On": "Увімк.",
|
||||
@@ -142,6 +143,7 @@
|
||||
"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/кадр",
|
||||
@@ -164,7 +166,6 @@
|
||||
"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 models behind opaque blocks. Entities update. Off with custom packs.": "Ховає моделі за блоками. Вимкніть із власними пакетами.",
|
||||
"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.": "Менші копії текстур блоків для віддаленої місцевості: менше мерехтіння.",
|
||||
@@ -198,6 +199,7 @@
|
||||
"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": "Шукаємо світ друга…",
|
||||
@@ -286,5 +288,53 @@
|
||||
"horizon.osk.delete": "Видалити",
|
||||
"horizon.osk.type": "Ввести",
|
||||
"horizon.osk.symbols": "Символи",
|
||||
"horizon.osk.close": "Закрити"
|
||||
"horizon.osk.close": "Закрити",
|
||||
"Culling": "Відсікання",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Ховає моделі за непрозорими блоками. Перевірка у фоні; сутності оновлюються.",
|
||||
"Leaf Culling": "Відсікання листя",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "Кожна грань листя, як це робить Minecraft у гарній графіці.",
|
||||
"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.": "Повторно використовує карту тіней пакета, коли сонце, вигляд і світ збігаються. Типово вимкнено.",
|
||||
"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": "Лише локально: агенти на цьому комп’ютері, ніколи через мережу.",
|
||||
"horizon.extras.chat_animation": "Плавний чат",
|
||||
"horizon.extras.chat_animation.tooltip": "Новий рядок чату виїжджає зліва, поступово з’являючись, замість того щоб з’явитися одразу.",
|
||||
"horizon.extras.chat_indicator": "Позначка повідомлення",
|
||||
"horizon.extras.chat_indicator.tooltip": "Кольорова смужка ліворуч від рядка чату.",
|
||||
"horizon.extras.header.chat": "Чат",
|
||||
"horizon.extras.header.particles": "Частинки",
|
||||
"horizon.extras.header.sound": "Звук",
|
||||
"horizon.extras.hide_block_particles": "Без частинок блоків",
|
||||
"horizon.extras.hide_block_particles.tooltip": "Ховає частинки уламків, що розлітаються зі зламаного блока та осипаються з того, який ви ламаєте. Інші частинки залишаються.",
|
||||
"horizon.extras.splitscreen": "Розділений екран",
|
||||
"horizon.extras.splitscreen.tooltip": "У світі може долучитися ще один геймпад як новий гравець: натисніть A, коли запитає, потім виберіть профіль і де грати.",
|
||||
"horizon.extras.ui_sound_reserve": "Канали для звуків UI",
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "Звуки у світі залишають вільними 16 із 247 звукових каналів, щоб їхній потік (великий вибух) не заглушив кліки в меню.",
|
||||
"horizon.pages.open.split_setup": "Розділений екран…",
|
||||
"horizon.pages.split_settings": "Налаштування розділеного екрана",
|
||||
"horizon.pages.split_setup": "Розділений екран",
|
||||
"horizon.pages.split_setup.tooltip": "Гравці на цьому комп’ютері: геймпади можуть долучитися до вашого світу."
|
||||
}
|
||||
@@ -89,6 +89,7 @@
|
||||
"UI Brightness": "界面亮度",
|
||||
"Battery Mode": "电池模式",
|
||||
"Graphics API": "图形 API",
|
||||
"Translucent Batching": "半透明批处理",
|
||||
"Performance Overlay": "性能叠加层",
|
||||
"Full Detail Distance": "全细节距离",
|
||||
"On": "开",
|
||||
@@ -136,6 +137,7 @@
|
||||
"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",
|
||||
@@ -158,7 +160,6 @@
|
||||
"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 models behind opaque blocks. Entities update. Off with custom packs.": "隐藏不透明方块后面的模型。实体仍会更新。自定义资源包时应关闭。",
|
||||
"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.": "为远处地形生成更小的方块纹理副本:减少闪烁。",
|
||||
@@ -192,6 +193,7 @@
|
||||
"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.": "水和玻璃放在同一个缓冲区:绘制调用更少,显存略多。下次启动生效。",
|
||||
"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": "正在查找好友的世界…",
|
||||
@@ -303,5 +305,36 @@
|
||||
"horizon.osk.close": "关闭",
|
||||
"horizon.extras.chat_animation.tooltip": "新聊天行从左侧滑入并淡入,而不是整行直接出现。",
|
||||
"horizon.extras.chat_indicator.tooltip": "聊天行左侧的彩色竖条。",
|
||||
"horizon.extras.splitscreen.tooltip": "在世界中,另一个手柄可以以新玩家身份加入:提示时按 A,然后选择档案和游玩位置。"
|
||||
"horizon.extras.splitscreen.tooltip": "在世界中,另一个手柄可以以新玩家身份加入:提示时按 A,然后选择档案和游玩位置。",
|
||||
"Culling": "剔除",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "隐藏不透明方块后面的模型。检测在后台线程进行,实体仍会更新。",
|
||||
"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.": "太阳、视角和世界不变时重复使用资源包的阴影贴图。默认关闭。",
|
||||
"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": "仅限本机:此电脑上的代理可用,网络不可用。"
|
||||
}
|
||||
@@ -536,6 +536,9 @@ pub fn main() -> Result<()> {
|
||||
let flying = op["value"].as_bool().unwrap();
|
||||
world.player.may_fly = op["may_fly"].as_bool().unwrap_or(flying);
|
||||
world.player.flying = flying;
|
||||
if let Some(speed) = op.get("speed") {
|
||||
world.player.flying_speed = single(speed);
|
||||
}
|
||||
}
|
||||
"trigger" => world.player.jump_trigger_time = op["value"].as_i64().unwrap() as i32,
|
||||
"delay" => world.player.no_jump_delay = op["value"].as_i64().unwrap() as i32,
|
||||
|
||||
@@ -13,14 +13,14 @@ pub use horizon_client_render::{
|
||||
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, 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, settings, shader_options, shader_pack_list, signs, skins, sound_engine,
|
||||
sound_events, sound_preview, spans, split_layout, split_profiles, sprites, streaming,
|
||||
structure_block, tab_list, telemetry, test_block, test_instance_block, testgen, title_edition,
|
||||
trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
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, settings, shader_options, shader_pack_list, signs, skins,
|
||||
sound_engine, sound_events, sound_preview, spans, split_layout, split_profiles, sprites,
|
||||
streaming, structure_block, tab_list, telemetry, test_block, test_instance_block, testgen,
|
||||
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
|
||||
@@ -14,8 +14,7 @@ use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
@@ -1208,48 +1207,6 @@ pub fn recipe_result(v: &Value) -> Option<ItemStack> {
|
||||
let template = Template::parse(v)?;
|
||||
template.item.is_some().then(|| template.create())
|
||||
}
|
||||
/// The `.json` files under a `recipe` folder, its subfolders' too, in id
|
||||
/// order: each one's path inside the folder without the extension (`a/b`, as a
|
||||
/// recipe id has it) and its file. An error when `dir` itself can't be read; a
|
||||
/// subfolder or an entry that can't be listed is warned and skipped, as
|
||||
/// vanilla's `PathPackResources` keeps what it found.
|
||||
pub(crate) fn recipe_files(dir: &Path) -> Result<Vec<(String, PathBuf)>> {
|
||||
let mut files = Vec::new();
|
||||
let mut stack = vec![fs::read_dir(dir)?];
|
||||
while let Some(entries) = stack.pop() {
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: failed to list an entry of {}: {e}", dir.display());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
match fs::read_dir(&path) {
|
||||
Ok(list) => stack.push(list),
|
||||
Err(e) => log::warn!("Horizon: failed to list {}: {e}", path.display()),
|
||||
}
|
||||
} else if path.extension().is_some_and(|e| e == "json") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut named: Vec<(String, PathBuf)> = files
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
let inside = path.strip_prefix(dir).unwrap_or(&path).with_extension("");
|
||||
let name: Vec<_> = inside.iter().map(|c| c.to_string_lossy()).collect();
|
||||
(name.join("/"), path)
|
||||
})
|
||||
.collect();
|
||||
// Vanilla lists resources by id, in a map keyed by it
|
||||
// (`FallbackResourceManager`): `crushing` before `crushing/iron`, where
|
||||
// a path's components put the subfolder's file first.
|
||||
named.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
Ok(named)
|
||||
}
|
||||
/// The type of a recipe the game doesn't craft itself: one outside `minecraft`.
|
||||
/// A recipe's whole id from its name in the tables: vanilla's are kept bare
|
||||
/// (`oak_planks`), everyone else's whole (`mymod:crusher/iron`).
|
||||
@@ -1372,14 +1329,14 @@ impl Recipes {
|
||||
}
|
||||
mods
|
||||
};
|
||||
for (name, p) in recipe_files(&root.join("data/minecraft/recipe"))? {
|
||||
sources.push((name, fs::read(&p)?));
|
||||
for file in &crate::json_folder::read(&root.join("data/minecraft/recipe"))?.files {
|
||||
sources.push((file.name.clone(), file.bytes()?.to_vec()));
|
||||
}
|
||||
for namespace in crate::mod_content::namespaces(root) {
|
||||
let dir = root.join(format!("data/{namespace}/recipe"));
|
||||
if dir.is_dir() {
|
||||
for (name, p) in recipe_files(&dir)? {
|
||||
sources.push((format!("{namespace}:{name}"), fs::read(&p)?));
|
||||
for file in &crate::json_folder::read(&dir)?.files {
|
||||
sources.push((format!("{namespace}:{}", file.name), file.bytes()?.to_vec()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1887,6 +1844,7 @@ impl Recipes {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::banner::BannerLayer;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn result_count_is_bounded_like_vanilla() {
|
||||
|
||||
@@ -2676,6 +2676,10 @@ struct Game<'a> {
|
||||
cart_models: minecarts::CartModels,
|
||||
/// The boat the player rides.
|
||||
riding: Option<u32>,
|
||||
/// The mount the last `position_rider` put the player on, to tell the tick
|
||||
/// a seat is taken (`Entity.moveTo`, no camera sweep) from the ones that
|
||||
/// follow it (`positionRider`, interpolated as usual).
|
||||
posed_rider: Option<u32>,
|
||||
/// The vehicle the player's file names, waiting for it to be in the world
|
||||
/// (`ServerPlayer.loadAndSpawnParentVehicle`).
|
||||
pending_vehicle: Option<[i32; 4]>,
|
||||
@@ -2848,6 +2852,13 @@ struct Game<'a> {
|
||||
/// `remote_handle_input`): picked once, so a creative break does not
|
||||
/// move the use that follows it past the broken block.
|
||||
tick_hit: Option<Option<Hit>>,
|
||||
/// The mob of that `hitResult`, picked with the item held at the pick
|
||||
/// (`LocalPlayer.raycastHitResult`: a spear's `attack_range`), before the
|
||||
/// tick's number keys change it.
|
||||
tick_mob: Option<Option<(usize, f64)>>,
|
||||
/// Number keys since the last tick (`keyHotbarSlots[i].consumeClick`):
|
||||
/// `Minecraft.handleKeybinds` selects them after the tick's pick.
|
||||
hotbar_presses: Vec<usize>,
|
||||
/// `Player.entityInteractionRange` and `blockInteractionRange`, refreshed
|
||||
/// each tick from the attribute (a server's reach modifier, creative's
|
||||
/// +2): the pick helpers' `&self` reach.
|
||||
@@ -7149,6 +7160,11 @@ impl Game<'_> {
|
||||
self.tick_riding(keys)?;
|
||||
}
|
||||
let riding = self.riding.is_some();
|
||||
// A seat that is given up is taken again as a new one: the next mount
|
||||
// snaps the camera even when it is the same vehicle.
|
||||
if !riding {
|
||||
self.posed_rider = None;
|
||||
}
|
||||
let entity_collisions = self.player_entity_collisions();
|
||||
let spin_targets = self.spin_targets();
|
||||
let sprint_window = self.settings.sprint_window;
|
||||
@@ -8553,7 +8569,7 @@ impl Game<'_> {
|
||||
batch.blur_at = batch.vertices.len() as u32;
|
||||
// PackConfirmScreen takes the open screen's place (it comes back once
|
||||
// the prompt is answered): the screen under the prompt isn't drawn.
|
||||
let screen = if self.server_packs.prompt.is_some() {
|
||||
let screen = if self.server_packs.prompt.is_some() || self.book.is_some() {
|
||||
Screen::Playing
|
||||
} else {
|
||||
self.screen
|
||||
@@ -8626,7 +8642,7 @@ impl Game<'_> {
|
||||
/// read-only: what a mod draws is its own.
|
||||
fn fire_screen_render_post(&self, batch: &mut HudBatch) {
|
||||
let screen = self.screen;
|
||||
if screen == Screen::Playing || self.server_packs.prompt.is_some() {
|
||||
if screen == Screen::Playing || self.server_packs.prompt.is_some() || self.book.is_some() {
|
||||
return;
|
||||
}
|
||||
// slot_count mirrors slots() without allocating; keep them in step.
|
||||
@@ -11184,6 +11200,9 @@ impl Game<'_> {
|
||||
darken
|
||||
}
|
||||
fn mob_hit(&self) -> Option<(usize, f64)> {
|
||||
if let Some(hit) = self.tick_mob {
|
||||
return hit;
|
||||
}
|
||||
let (eye, dir) = self.aim()?;
|
||||
let reach: f64 = self.entity_reach;
|
||||
let mut best: Option<(usize, f64)> = None;
|
||||
@@ -11407,7 +11426,7 @@ impl Game<'_> {
|
||||
let Some((index, distance)) = self.mob_hit() else {
|
||||
return false;
|
||||
};
|
||||
if !self.before_block(distance) {
|
||||
if !self.before_block(distance) || !self.held_range_allows(distance) {
|
||||
return false;
|
||||
}
|
||||
self.attack_mob(index);
|
||||
@@ -15966,6 +15985,8 @@ impl Game<'_> {
|
||||
KeyCode::KeyE => match self.screen {
|
||||
Screen::Playing if self.spectator() => {}
|
||||
Screen::Playing => {
|
||||
// handleKeybinds: the number keys select before key.inventory.
|
||||
self.select_queued_hotbar();
|
||||
// key.inventory while riding a tamed mount opens its inventory.
|
||||
if !self.open_ridden_mount_menu() {
|
||||
if self.tutorial.open_inventory() {
|
||||
@@ -16006,6 +16027,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
KeyCode::KeyF if self.screen == Screen::Playing => {
|
||||
// handleKeybinds: the number keys select before key.swapOffhand.
|
||||
self.select_queued_hotbar();
|
||||
// handlePlayerAction SWAP_ITEM_WITH_OFFHAND: setItemInHand for
|
||||
// the off hand, then the main hand.
|
||||
std::mem::swap(&mut self.offhand, &mut self.inventory[self.selected]);
|
||||
@@ -16017,6 +16040,8 @@ impl Game<'_> {
|
||||
self.using = None;
|
||||
}
|
||||
KeyCode::KeyQ if self.screen == Screen::Playing => {
|
||||
// handleKeybinds: the number keys select before key.drop.
|
||||
self.select_queued_hotbar();
|
||||
let all = drop_whole_stack(self.modifiers);
|
||||
self.drop_held(all);
|
||||
}
|
||||
@@ -16054,7 +16079,7 @@ impl Game<'_> {
|
||||
{
|
||||
self.hotbar_load_or_save(slot, load);
|
||||
} else {
|
||||
self.selected = slot;
|
||||
self.hotbar_presses.push(slot);
|
||||
}
|
||||
}
|
||||
_ if self.screen == Screen::Paused && self.pause_menu => {
|
||||
@@ -16105,6 +16130,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
MouseButton::Middle if pressed => {
|
||||
// handleKeybinds: the number keys select before key.pickItem.
|
||||
self.select_queued_hotbar();
|
||||
// Minecraft.pickBlockOrEntity -> the server's handlePickItemFromBlock:
|
||||
// the block's clone item, with its block entity's data on Ctrl in
|
||||
// creative, picked from the inventory (`pick_item`).
|
||||
@@ -18436,6 +18463,7 @@ impl<'a> Game<'a> {
|
||||
trim_slots: Vec::new(),
|
||||
cart_models: prepared.cart_models,
|
||||
riding: None,
|
||||
posed_rider: None,
|
||||
pending_vehicle: None,
|
||||
ender_items: vec![None; 27],
|
||||
ender_open: None,
|
||||
@@ -18531,6 +18559,8 @@ impl<'a> Game<'a> {
|
||||
world_icon: None,
|
||||
forced_hit: None,
|
||||
tick_hit: None,
|
||||
tick_mob: None,
|
||||
hotbar_presses: Vec::new(),
|
||||
entity_reach: 3.0,
|
||||
block_reach: 4.5,
|
||||
guest_use: None,
|
||||
@@ -18961,6 +18991,9 @@ impl Game<'_> {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// The recipe tables, the stations and the recipe book read one folder:
|
||||
// hold its files so it is read once.
|
||||
let _recipe_files = horizon_client::json_folder::read(&root.join("data/minecraft/recipe"));
|
||||
let recipes = match Recipes::load(root, &items, &overlay) {
|
||||
Ok(recipes) => recipes,
|
||||
Err(e) => {
|
||||
@@ -19592,6 +19625,9 @@ fn load_world_rest(
|
||||
let items = startup::time("rest.items", || Items::load(root, registry, facts, overlay))?;
|
||||
log::info!("load items: {:.2}s", started.elapsed().as_secs_f32());
|
||||
progress.set(7);
|
||||
// The recipe tables, the stations and the recipe book read one folder:
|
||||
// hold its files so it is read once.
|
||||
let _recipe_files = horizon_client::json_folder::read(&root.join("data/minecraft/recipe"));
|
||||
let recipes = startup::time("rest.recipes", || Recipes::load(root, &items, overlay))?;
|
||||
let stations = startup::time("rest.stations", || {
|
||||
horizon_client::stations::StationData::load(root, &items, overlay)
|
||||
|
||||
@@ -86,34 +86,21 @@ impl Game<'_> {
|
||||
let server = self
|
||||
.control
|
||||
.as_ref()
|
||||
.filter(|c| !c.path().as_os_str().is_empty());
|
||||
let mut lines = vec![match server {
|
||||
Some(s) if s.clients() > 0 => {
|
||||
let ago = self
|
||||
.agent_last
|
||||
.map(|t| format!(", last command {} s ago", t.elapsed().as_secs()))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"Connected: {} agent connection{}{ago}",
|
||||
s.clients(),
|
||||
if s.clients() == 1 { "" } else { "s" }
|
||||
)
|
||||
}
|
||||
Some(_) => "Waiting for an agent.".into(),
|
||||
None => "Off: agents can't reach this game.".into(),
|
||||
}];
|
||||
if let Some(s) = server {
|
||||
if self.options.control.is_some() && !self.agent_control_on {
|
||||
lines.push("Opened for this session by the program that started the game.".into());
|
||||
}
|
||||
// The full path is too wide for the page: Copy Connection Details has it.
|
||||
let name = s.path().file_name().unwrap_or_default().to_string_lossy();
|
||||
lines.push(format!(
|
||||
"Socket: {name} (copy the details for its full path)"
|
||||
));
|
||||
}
|
||||
lines.push("Local only: agents on this computer, never the network.".into());
|
||||
lines
|
||||
.filter(|c| !c.path().as_os_str().is_empty())
|
||||
.map(|s| Socket {
|
||||
clients: s.clients(),
|
||||
name: s
|
||||
.path()
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
});
|
||||
status_lines(
|
||||
server,
|
||||
self.agent_last.map(|t| t.elapsed().as_secs()),
|
||||
self.options.control.is_some() && !self.agent_control_on,
|
||||
)
|
||||
}
|
||||
|
||||
/// What Copy Connection Details puts on the clipboard: the socket, a horizon-ctl line to try,
|
||||
@@ -138,3 +125,127 @@ impl Game<'_> {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The control socket as the AI Agent page tells of it.
|
||||
struct Socket {
|
||||
/// Agents connected now.
|
||||
clients: usize,
|
||||
/// The socket's file name; the full path is too wide for the page (Copy Connection Details
|
||||
/// has it).
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// The AI Agent page's lines in the selected language: the state, then where agents connect.
|
||||
/// `last_command` is the seconds since an agent's last command, `session_only` whether the
|
||||
/// program that started the game opened the socket and the player's switch did not.
|
||||
fn status_lines(
|
||||
server: Option<Socket>,
|
||||
last_command: Option<u64>,
|
||||
session_only: bool,
|
||||
) -> Vec<String> {
|
||||
use horizon_client::lang::{get, tr};
|
||||
let mut lines = vec![match &server {
|
||||
Some(s) if s.clients > 0 => {
|
||||
let (one, many) = match last_command {
|
||||
Some(_) => ("one_ago", "many_ago"),
|
||||
None => ("one", "many"),
|
||||
};
|
||||
let key = format!(
|
||||
"horizon.agent.connected.{}",
|
||||
if s.clients == 1 { one } else { many }
|
||||
);
|
||||
let mut args: Vec<&dyn std::fmt::Display> = Vec::new();
|
||||
if s.clients != 1 {
|
||||
args.push(&s.clients);
|
||||
}
|
||||
if let Some(ago) = &last_command {
|
||||
args.push(ago);
|
||||
}
|
||||
tr(&key, &args)
|
||||
}
|
||||
Some(_) => get("horizon.agent.waiting"),
|
||||
None => get("horizon.agent.off"),
|
||||
}];
|
||||
if let Some(s) = &server {
|
||||
if session_only {
|
||||
lines.push(get("horizon.agent.session_opened"));
|
||||
}
|
||||
lines.push(tr("horizon.agent.socket", &[&s.name]));
|
||||
}
|
||||
lines.push(get("horizon.agent.local_only"));
|
||||
lines
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Socket, status_lines};
|
||||
use horizon_client::lang::{Language, scoped};
|
||||
|
||||
fn lines(code: &str, clients: usize, ago: Option<u64>, session_only: bool) -> Vec<String> {
|
||||
let socket = Socket {
|
||||
clients,
|
||||
name: "ctl.sock".into(),
|
||||
};
|
||||
let (lines, missed) = scoped(Language::load(&[], code), || {
|
||||
status_lines(Some(socket), ago, session_only)
|
||||
});
|
||||
assert!(missed.is_empty(), "{code}: {missed:?}");
|
||||
lines
|
||||
}
|
||||
|
||||
/// The AI Agent page told its state in English in every language (#1927).
|
||||
#[test]
|
||||
fn agent_page_lines_follow_the_language() {
|
||||
assert_eq!(
|
||||
lines("de_de", 2, Some(5), false),
|
||||
[
|
||||
"Verbunden: 2 Agentenverbindungen, letzter Befehl vor 5 s",
|
||||
"Socket: ctl.sock (Verbindungsdaten kopieren für den vollständigen Pfad)",
|
||||
"Nur lokal: Agenten auf diesem Computer, nie über das Netzwerk.",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
lines("pl_pl", 1, None, true),
|
||||
[
|
||||
"Połączono: 1 połączenie agenta",
|
||||
"Otwarte na tę sesję przez program, który uruchomił grę.",
|
||||
"Gniazdo: ctl.sock (skopiuj dane, aby poznać pełną ścieżkę)",
|
||||
"Tylko lokalnie: agenci na tym komputerze, nigdy przez sieć.",
|
||||
]
|
||||
);
|
||||
let (off, missed) = scoped(Language::load(&[], "fr_fr"), || {
|
||||
status_lines(None, None, false)
|
||||
});
|
||||
assert!(missed.is_empty(), "{missed:?}");
|
||||
assert_eq!(
|
||||
off,
|
||||
[
|
||||
"Désactivé : les agents ne peuvent pas joindre ce jeu.",
|
||||
"Local uniquement : agents sur cet ordinateur, jamais via le réseau.",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// English keeps the sentences it always had: the count, its plural, the time since the last
|
||||
/// command, and the wait for a first agent.
|
||||
#[test]
|
||||
fn agent_page_lines_in_english() {
|
||||
assert_eq!(
|
||||
lines("en_us", 2, Some(5), false)[0],
|
||||
"Connected: 2 agent connections, last command 5 s ago"
|
||||
);
|
||||
assert_eq!(
|
||||
lines("en_us", 1, None, false)[0],
|
||||
"Connected: 1 agent connection"
|
||||
);
|
||||
assert_eq!(
|
||||
lines("en_us", 1, Some(0), false)[0],
|
||||
"Connected: 1 agent connection, last command 0 s ago"
|
||||
);
|
||||
assert_eq!(
|
||||
lines("en_us", 3, None, false)[0],
|
||||
"Connected: 3 agent connections"
|
||||
);
|
||||
assert_eq!(lines("en_us", 0, None, true)[0], "Waiting for an agent.");
|
||||
}
|
||||
}
|
||||
@@ -258,7 +258,7 @@ impl Game<'_> {
|
||||
self.remote_close_container();
|
||||
}
|
||||
self.book = None;
|
||||
self.capture(true);
|
||||
self.capture(self.screen == super::Screen::Playing && !self.screen_over_playing());
|
||||
}
|
||||
/// `BookEditScreen.saveChanges` and `BookSignScreen`'s Sign and Close:
|
||||
/// trailing empty pages dropped, the book in hand updated here and, on a
|
||||
|
||||
@@ -574,6 +574,8 @@ impl Game<'_> {
|
||||
/// last time, when this method can restore it (a command draft only for
|
||||
/// /), else with its prefix.
|
||||
pub(super) fn open_chat_method(&mut self, prefix: &str) {
|
||||
// handleKeybinds: the number keys select before key.chat and key.command.
|
||||
self.select_queued_hotbar();
|
||||
let command = prefix == "/";
|
||||
let draft = self
|
||||
.ui
|
||||
|
||||
@@ -488,14 +488,15 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.hurtArmor` -> `LivingEntity.doHurtEquipment`, from the hit
|
||||
/// `MobEvent::ArmorHurt` reported: every worn piece that wears when its
|
||||
/// wearer is hurt loses `max(1, damage / 4)` durability, Unbreaking
|
||||
/// applied, and at its maximum the slot empties and the equipment-break
|
||||
/// event goes out (`LivingEntity.entityEventForEquipmentBreak`: 49 to 52
|
||||
/// for the armour, 65 for the body). A fire hit leaves a fire-resistant
|
||||
/// piece alone (`ItemStack.canBeHurtBy`).
|
||||
/// `MobEvent::ArmorHurt` reported. Only `Horse` overrides `hurtArmor` for a
|
||||
/// mob, and its override asks for the BODY slot alone, so the body piece is
|
||||
/// the only one this wears: it loses `max(1, damage / 4)` durability,
|
||||
/// Unbreaking applied, and at its maximum the slot empties and the break
|
||||
/// event goes out (`LivingEntity.entityEventForEquipmentBreak`: 65 for the
|
||||
/// body — 49 to 52 are the four armour slots, which only a player's own
|
||||
/// pieces reach). A fire hit leaves a fire-resistant piece alone
|
||||
/// (`ItemStack.canBeHurtBy`).
|
||||
fn hurt_mob_armor(&mut self, id: u32, damage: f32, kind: horizon_client::mobs::DamageKind) {
|
||||
use horizon_client::mobs::drops;
|
||||
use horizon_world::player_inventory::EquipmentSlot;
|
||||
if damage <= 0.0 {
|
||||
return;
|
||||
@@ -504,7 +505,6 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
let body_armour = drops::head_is_body(m.species());
|
||||
let amount = ((damage / 4.0) as u32).max(1);
|
||||
let name = damage_type(kind, false);
|
||||
let fire = self
|
||||
@@ -514,9 +514,15 @@ impl Game<'_> {
|
||||
.id(name)
|
||||
.is_some_and(|d| self.motion.health.damage.in_tag(d, "minecraft:is_fire"));
|
||||
// A piece's maximum, or none when it may not lose durability at all.
|
||||
// `doHurtEquipment` starts from the item's `equippable` component
|
||||
// (`Equippable equippable = item.get(DataComponents.EQUIPPABLE)`), so a
|
||||
// damageable item with no such component — a sword put in a horse's body
|
||||
// slot by `/item replace` — never wears; `equip_damage_on_hurt` alone
|
||||
// cannot say that, since it defaults to true when the component is absent.
|
||||
fn wearable(me: &Game<'_>, stack: &ItemStack, fire: bool, name: &str) -> Option<u32> {
|
||||
let info = me.res.items().get(&stack.item)?;
|
||||
if !info.equip_damage_on_hurt
|
||||
if info.equip_slot.is_none()
|
||||
|| !info.equip_damage_on_hurt
|
||||
|| info.max_damage == 0
|
||||
|| !horizon_client::stations::is_damageable(me.res.items(), stack)
|
||||
|| (fire && me.res.items().resists_fire(stack, name))
|
||||
@@ -525,66 +531,39 @@ impl Game<'_> {
|
||||
}
|
||||
Some(info.max_damage)
|
||||
}
|
||||
let mut broke: Vec<i8> = Vec::new();
|
||||
// The four armour slots, then the body slot (`extra.head`).
|
||||
let event_of = horizon_client::mobs::item_break::equipment_break_event;
|
||||
for (index, slot) in [
|
||||
(0usize, EquipmentSlot::Head),
|
||||
(1, EquipmentSlot::Chest),
|
||||
(2, EquipmentSlot::Legs),
|
||||
(3, EquipmentSlot::Feet),
|
||||
] {
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
let Some(stack) = m.extra.armor[index].clone() else {
|
||||
continue;
|
||||
};
|
||||
let Some(max) = wearable(self, &stack, fire, name) else {
|
||||
continue;
|
||||
};
|
||||
let loss = self.durability_loss(&stack, amount);
|
||||
if loss == 0 {
|
||||
continue;
|
||||
}
|
||||
let lost = stack.damage + loss;
|
||||
let Some(body) = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.and_then(|m| m.item_by_slot(EquipmentSlot::Body))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(max) = wearable(self, &body, fire, name) else {
|
||||
return;
|
||||
};
|
||||
let loss = self.durability_loss(&body, amount);
|
||||
if loss == 0 {
|
||||
return;
|
||||
}
|
||||
let lost = body.damage + loss;
|
||||
let broke = {
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
if lost >= max {
|
||||
m.extra.armor[index] = None;
|
||||
broke.push(event_of(slot));
|
||||
} else if let Some(worn) = m.extra.armor[index].as_mut() {
|
||||
worn.damage = lost;
|
||||
m.extra.head = None;
|
||||
m.extra.body_damage = 0;
|
||||
true
|
||||
} else {
|
||||
m.extra.body_damage = lost as i32;
|
||||
false
|
||||
}
|
||||
}
|
||||
if body_armour {
|
||||
let body = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.and_then(|m| m.item_by_slot(EquipmentSlot::Body));
|
||||
if let Some(body) = body
|
||||
&& let Some(max) = wearable(self, &body, fire, name)
|
||||
{
|
||||
let loss = self.durability_loss(&body, amount);
|
||||
if loss > 0 {
|
||||
let lost = body.damage + loss;
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
if lost >= max {
|
||||
m.extra.head = None;
|
||||
m.extra.body_damage = 0;
|
||||
broke.push(event_of(EquipmentSlot::Body));
|
||||
} else {
|
||||
m.extra.body_damage = lost as i32;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for event in broke {
|
||||
};
|
||||
if broke {
|
||||
// LivingEntity.onEquippedItemBroken through the level's broadcast.
|
||||
let event =
|
||||
horizon_client::mobs::item_break::equipment_break_event(EquipmentSlot::Body);
|
||||
self.lan_entity_event(id as i32, event, at);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +205,94 @@ impl Game<'_> {
|
||||
self.attack_ticks = 0;
|
||||
self.hand_strength.reset();
|
||||
}
|
||||
/// `Minecraft.tick`'s `pick`, then `handleKeybinds`' number keys: the
|
||||
/// tick's clicks act on what the item held before the keys picked (a
|
||||
/// spear's 4.5 blocks), with the item the keys select (attribute
|
||||
/// swapping a spear's reach onto a mace).
|
||||
pub(super) fn pick_tick_targets(&mut self) {
|
||||
self.tick_hit = Some(self.pick());
|
||||
self.tick_mob = None;
|
||||
let mob = match self.active_attack_range() {
|
||||
Some(range) => self.attack_range_pick(&range),
|
||||
None => None,
|
||||
};
|
||||
self.tick_mob = Some(mob.unwrap_or_else(|| self.mob_hit()));
|
||||
self.select_queued_hotbar();
|
||||
}
|
||||
/// `handleKeybinds`' number keys (`keyHotbarSlots[i].consumeClick`).
|
||||
pub(super) fn select_queued_hotbar(&mut self) {
|
||||
for slot in std::mem::take(&mut self.hotbar_presses) {
|
||||
self.selected = slot;
|
||||
}
|
||||
}
|
||||
/// The tick's clicks are done: later picks follow the crosshair again.
|
||||
pub(super) fn release_tick_targets(&mut self) {
|
||||
self.tick_hit = None;
|
||||
self.tick_mob = None;
|
||||
}
|
||||
/// `Minecraft.startAttack` on an entity: a main-hand item with an
|
||||
/// `attack_range` attacks only a hit inside it.
|
||||
pub(super) fn held_range_allows(&self, distance: f64) -> bool {
|
||||
let range = self.held().and_then(|stack| {
|
||||
self.pack
|
||||
.item_renderer
|
||||
.models
|
||||
.components(&horizon_client::item_render::stack_view(stack))
|
||||
.get("minecraft:attack_range")
|
||||
.cloned()
|
||||
});
|
||||
range.is_none_or(|r| attack_range_contains(&r, distance, self.creative))
|
||||
}
|
||||
/// `LocalPlayer.raycastHitResult`'s item (`getActiveItem`: the one in
|
||||
/// use, else the main hand's) `attack_range` component.
|
||||
fn active_attack_range(&self) -> Option<serde_json::Value> {
|
||||
let active = self
|
||||
.using
|
||||
.and_then(|(slot, _)| self.inventory.get(slot).and_then(Option::as_ref))
|
||||
.or_else(|| self.held())?;
|
||||
self.pack
|
||||
.item_renderer
|
||||
.models
|
||||
.components(&horizon_client::item_render::stack_view(active))
|
||||
.get("minecraft:attack_range")
|
||||
.cloned()
|
||||
}
|
||||
/// `AttackRange.getClosesetHit` against the mobs `mob_hit` can pick
|
||||
/// (`EntitySelector.CAN_BE_PICKED`), as `raycastHitResult` reads it: the
|
||||
/// nearest mob hit within the item's reach; no mob when the clip met a
|
||||
/// block within the block reach (`getHitEntitiesAlong`'s block result,
|
||||
/// kept by `filterHitResult`); `None` on a miss, where the plain pick
|
||||
/// decides.
|
||||
fn attack_range_pick(&self, range: &serde_json::Value) -> Option<Option<(usize, f64)>> {
|
||||
let eye = v3(self.eye_position()?);
|
||||
let look = v3(super::view_vector(self.yaw, self.pitch));
|
||||
let reach = attack_reach(range, self.creative);
|
||||
let camera = self.remote_camera_id();
|
||||
let boxes: Vec<(usize, Aabb)> = (self.mobs.iter().enumerate())
|
||||
.filter(|(_, m)| !m.removed && Some(m.id) != self.riding && Some(m.id) != camera)
|
||||
.map(|(i, m)| (i, m.bounds()))
|
||||
.collect();
|
||||
let clip = |from: Vec3, to: Vec3| {
|
||||
let length = from.distance_squared(to).sqrt();
|
||||
let d = to.subtract(from).scale(1.0 / length);
|
||||
(length > 0.0)
|
||||
.then(|| self.clip_ray([from.x, from.y, from.z], [d.x, d.y, d.z], length))
|
||||
.flatten()
|
||||
.map(|hit| Vec3::new(hit.point[0], hit.point[1], hit.point[2]))
|
||||
};
|
||||
let movement = self.known_player_movement();
|
||||
let nearest = super::spear::hits_in_reach(&boxes, eye, look, movement, reach, clip)
|
||||
.into_iter()
|
||||
.map(|(i, at)| (i, eye.distance_squared(at).sqrt()))
|
||||
.reduce(|a, b| if b.1 < a.1 { b } else { a });
|
||||
if nearest.is_some() {
|
||||
return Some(nearest);
|
||||
}
|
||||
let to = eye.add_vector(look.scale(f64::from(reach.1) + movement.dot(look).max(0.0)));
|
||||
clip(eye, to)
|
||||
.is_some_and(|block| eye.distance_squared(block) <= self.block_reach.powi(2))
|
||||
.then_some(None)
|
||||
}
|
||||
/// The attack and use part of `Minecraft.tick` for the local player:
|
||||
/// attack clicks since the last tick and the held attack button.
|
||||
pub(super) fn tick_local_hand(&mut self) -> Result<()> {
|
||||
@@ -216,11 +304,10 @@ impl Game<'_> {
|
||||
// (scripts under --control hold buttons without grabbing it).
|
||||
let down = self.left;
|
||||
let ticks = self.clicks.destroying.ticks;
|
||||
// Minecraft.hitResult is picked once per frame: a creative break
|
||||
// does not move the use that follows it past the broken block.
|
||||
if use_clicks > 0 && attack_clicks > 0 {
|
||||
self.tick_hit = Some(self.pick());
|
||||
}
|
||||
// Minecraft.hitResult is picked once per tick, before the number
|
||||
// keys: a creative break does not move the use that follows it
|
||||
// past the broken block.
|
||||
self.pick_tick_targets();
|
||||
let mut world = GameHands {
|
||||
game: self,
|
||||
error: None,
|
||||
@@ -229,7 +316,7 @@ impl Game<'_> {
|
||||
let error = world.error.take();
|
||||
self.clicks = clicks;
|
||||
if let Some(e) = error {
|
||||
self.tick_hit = None;
|
||||
self.release_tick_targets();
|
||||
return Err(e);
|
||||
}
|
||||
// Use clicks follow the attack clicks within the tick, against
|
||||
@@ -245,7 +332,7 @@ impl Game<'_> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.tick_hit = None;
|
||||
self.release_tick_targets();
|
||||
used?;
|
||||
// A tick of breaking: the hit sound every fourth tick and the
|
||||
// cracking particles.
|
||||
@@ -267,8 +354,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Minecraft.tick: a screen keeps the attack from starting.
|
||||
// Minecraft.tick: a screen keeps the attack from starting, and
|
||||
// opening it released the keys (`KeyMapping.releaseAll`).
|
||||
self.clicks.miss_time = 10000;
|
||||
self.hotbar_presses.clear();
|
||||
}
|
||||
let d = &self.clicks.destroying;
|
||||
self.mining = d.active.then_some(Mining {
|
||||
|
||||
@@ -383,6 +383,15 @@ impl Game<'_> {
|
||||
let _ =
|
||||
m.apply_saved_attributes(g.saved.as_ref().and_then(|t| t.get("attributes")));
|
||||
}
|
||||
// ServerPlayer.restoreFrom: a body that follows another takes its
|
||||
// attributes (a scale of 0.1 stays 0.1 after a respawn or a trip
|
||||
// to another level).
|
||||
let g = &mut host.hub.guests[i];
|
||||
if let Some(carried) = g.carried_attributes.take()
|
||||
&& let Some(m) = g.motion.as_mut()
|
||||
{
|
||||
let _ = m.apply_saved_attributes(Some(&carried));
|
||||
}
|
||||
// The saved golden hearts go on the first body of a player who
|
||||
// has been here before.
|
||||
let saved = host.hub.guests[i].saved_absorption.take();
|
||||
@@ -2516,8 +2525,9 @@ impl Game<'_> {
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
(g.yaw, g.pitch) = (yaw, pitch);
|
||||
// A new body there: no fall distance or motion carried over.
|
||||
g.motion = None;
|
||||
// A new body there: no fall distance or motion carried over, but the
|
||||
// player's attributes (ServerPlayer.teleport keeps the entity).
|
||||
g.new_body(true);
|
||||
host.hub.teleport(gi, Some(at));
|
||||
}
|
||||
fn guest_tick_death(&mut self, host: &mut LanHost, gi: usize) {
|
||||
@@ -3248,14 +3258,16 @@ impl Game<'_> {
|
||||
g.load_timeout = horizon_client::lan::guest::CLIENT_LOADED_TIMEOUT;
|
||||
g.loading = true;
|
||||
g.death_time = 0;
|
||||
g.health = 20.0;
|
||||
g.food = 20;
|
||||
g.saturation = 5.0;
|
||||
g.sent_health = None;
|
||||
if let Some(m) = g.motion.take() {
|
||||
// ServerPlayer.restoreFrom(old, false): the base values come back,
|
||||
// and the new player has the max health they give.
|
||||
if let Some(m) = g.new_body(false) {
|
||||
let m = *m;
|
||||
g.logs_kept = Some((m.hurt_log, m.attack_log));
|
||||
}
|
||||
g.health = g.respawn_health(self.motion);
|
||||
let mut xp = Experience {
|
||||
level: g.xp_level,
|
||||
progress: g.xp_progress,
|
||||
@@ -3847,6 +3859,12 @@ impl Game<'_> {
|
||||
w.player.fall_distance = 0.0;
|
||||
}
|
||||
let travelled: Vec<[f64; 3]> = moves.iter().map(|m| m.0).collect();
|
||||
// The tick's whole movement, for the delta movement a glide's wall
|
||||
// hit reads (`handleMovePlayer` gives the server copy the client's
|
||||
// own movement).
|
||||
let tick_movement = travelled.iter().fold(Vec3::ZERO, |sum, d| {
|
||||
sum.add_vector(Vec3::new(d[0], d[1], d[2]))
|
||||
});
|
||||
// Entity.move's movementThisTick: each move, from where the
|
||||
// tick's first one started.
|
||||
let end = w.player.position;
|
||||
@@ -3957,6 +3975,7 @@ impl Game<'_> {
|
||||
let _ = player.peaceful_regeneration(rules);
|
||||
let _ = player.food_tick(health, rules);
|
||||
events.append(&mut player.events);
|
||||
player.carry_client_movement(tick_movement);
|
||||
let _ = MotionWorld {
|
||||
entities: &[],
|
||||
player,
|
||||
|
||||
@@ -536,8 +536,14 @@ impl Game<'_> {
|
||||
let rear = Vec3::new(rear[0], rear[1], rear[2])
|
||||
.rotate_y(-m.yaw * (std::f64::consts::PI / 180.0) as f32);
|
||||
at = Vec3::new(at.x + rear.x, at.y + rear.y, at.z + rear.z);
|
||||
// The tick a rider takes its seat is a teleport (`Entity.moveTo`), which
|
||||
// sets the old position too: the camera must not sweep from where the
|
||||
// player stood to the seat. The ticks after it move with the mount and
|
||||
// are interpolated as usual.
|
||||
let first = self.posed_rider != self.riding;
|
||||
self.posed_rider = self.riding;
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
self.previous_position = if first { at } else { p.position };
|
||||
p.position = at;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
|
||||
@@ -1288,6 +1288,21 @@ impl Game<'_> {
|
||||
pub(super) fn native_frame(&mut self, elapsed: Duration) {
|
||||
mod_api::dispatch_frame(self, mod_api::mods(), elapsed);
|
||||
}
|
||||
/// 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
|
||||
/// is open. A mod may lower `limit`, as Dynamic FPS does for a window in the background:
|
||||
/// Horizon keeps the lower of its own limit and the mod's, and ignores values below 1.
|
||||
/// API @API:framerate_limit@.
|
||||
pub(super) fn native_framerate_limit(
|
||||
&self,
|
||||
focused: bool,
|
||||
minimized: bool,
|
||||
in_world: bool,
|
||||
limit: &mut i32,
|
||||
) {
|
||||
let _ = (focused, minimized, in_world, limit);
|
||||
}
|
||||
/// Render-thread notification after a resource-pack reload has applied successfully.
|
||||
/// Atlas, models, renderer and client caches already refer to the new resources.
|
||||
/// Selection/F3+T and server-pack reloads share this point; initial startup does not.
|
||||
|
||||
@@ -734,6 +734,16 @@ impl Game<'_> {
|
||||
ids.push((o.id, true));
|
||||
}
|
||||
}
|
||||
for t in &self.tridents {
|
||||
if tracked.contains_key(&t.id) {
|
||||
ids.push((t.id, true));
|
||||
}
|
||||
}
|
||||
for r in &self.rockets {
|
||||
if tracked.contains_key(&r.id) && r.attached_to.is_none() && !r.attached {
|
||||
ids.push((r.id, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
|
||||
self.portals.entities.retain(|id, _| present.contains(id));
|
||||
@@ -921,6 +931,30 @@ impl Game<'_> {
|
||||
inside.push((p.id, pos));
|
||||
}
|
||||
}
|
||||
// A trident (0.5 across) or rocket (0.25) in flight, or one that moved
|
||||
// this tick; a rocket that carries its owner stays.
|
||||
let mut flyers: Vec<(u32, Vec3, Vec3, f32)> = Vec::new();
|
||||
for t in self
|
||||
.tridents
|
||||
.iter_mut()
|
||||
.filter(|t| !t.in_ground || t.previous != t.position)
|
||||
{
|
||||
let id = super::lan_entities::assign_entity_id(&mut self.next_entity, &mut t.id);
|
||||
flyers.push((id, t.previous, t.position, 0.5));
|
||||
}
|
||||
for r in self
|
||||
.rockets
|
||||
.iter_mut()
|
||||
.filter(|r| r.attached_to.is_none() && !r.attached)
|
||||
{
|
||||
let id = super::lan_entities::assign_entity_id(&mut self.next_entity, &mut r.id);
|
||||
flyers.push((id, r.previous, r.position, 0.25));
|
||||
}
|
||||
for (id, from, to, size) in flyers {
|
||||
if let Some(pos) = self.swept_portal(from, to, size, size, &mut portal_sections) {
|
||||
inside.push((id, pos));
|
||||
}
|
||||
}
|
||||
self.portals.sections = portal_sections;
|
||||
for (id, pos) in inside {
|
||||
self.portals
|
||||
@@ -1058,6 +1092,28 @@ impl Game<'_> {
|
||||
self.forget_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self.tridents.iter().position(|t| t.id == id) {
|
||||
let plan = self.entity_plan(entry, self.tridents[i].position, 0.5, 0.5, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag = self.tridents[i].to_nbt(uuid, self.persist_raw(id));
|
||||
self.tridents.remove(i);
|
||||
self.forget_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self.rockets.iter().position(|r| r.id == id) {
|
||||
let plan = self.entity_plan(entry, self.rockets[i].position, 0.25, 0.25, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag = self.rockets[i].to_nbt(uuid, self.level.player_uuid, self.persist_raw(id));
|
||||
self.rockets.remove(i);
|
||||
self.forget_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self.orbs.iter().position(|o| o.id == id) {
|
||||
let plan = self.entity_plan(entry, self.orbs[i].position, 0.5, 0.5, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
|
||||
@@ -574,9 +574,23 @@ impl Game<'_> {
|
||||
has_overlay: false,
|
||||
};
|
||||
let limit = framerate_limit(self.settings.max_fps as i32, context);
|
||||
self.settings
|
||||
let limit = self
|
||||
.settings
|
||||
.horizon
|
||||
.battery_limit(limit, self.title.is_none(), self.focused)
|
||||
.battery_limit(limit, self.title.is_none(), self.focused);
|
||||
// A native mod may only lower it (`native_framerate_limit`).
|
||||
let mut hooked = limit;
|
||||
self.native_framerate_limit(
|
||||
self.focused,
|
||||
context.iconified,
|
||||
self.title.is_none(),
|
||||
&mut hooked,
|
||||
);
|
||||
if hooked >= 1 {
|
||||
hooked.min(limit)
|
||||
} else {
|
||||
limit
|
||||
}
|
||||
}
|
||||
/// `Minecraft.runTick`'s frame limiter: `FramerateLimiter.limitDisplayFPS`
|
||||
/// below 260 frames per second, sleeping then spin-waiting until the
|
||||
|
||||
@@ -299,6 +299,7 @@ impl Game<'_> {
|
||||
pack,
|
||||
textures,
|
||||
cem,
|
||||
block_entity_shapes,
|
||||
} = staged;
|
||||
// Keep the working GPU resources, and hand its window surface over.
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
@@ -372,6 +373,9 @@ impl Game<'_> {
|
||||
.cem
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = cem;
|
||||
self.res
|
||||
.block_entity_render()
|
||||
.set_shapes(block_entity_shapes);
|
||||
// The random entity textures and emissive overlays the new atlas packs. A new scan is
|
||||
// a new generation:
|
||||
// every mob (every splitscreen player's too) chooses again on its next tick.
|
||||
@@ -479,6 +483,7 @@ pub(super) fn stage_pack(
|
||||
&root,
|
||||
res.mob_types(),
|
||||
));
|
||||
let block_entity_shapes = res.block_entity_render().shapes_for(&root);
|
||||
progress.set(3);
|
||||
Ok(StagedPack {
|
||||
root,
|
||||
@@ -486,6 +491,7 @@ pub(super) fn stage_pack(
|
||||
pack,
|
||||
textures,
|
||||
cem,
|
||||
block_entity_shapes,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -498,6 +504,8 @@ pub(super) struct StagedPack {
|
||||
pub textures: PreparedTextures,
|
||||
/// The packs' custom entity models (OptiFine CEM).
|
||||
pub cem: Arc<horizon_client::mobs::cem::CemModels>,
|
||||
/// The block entities' models, the packs' custom entity models over vanilla's.
|
||||
pub block_entity_shapes: Arc<horizon_client::block_entity_render::BlockEntityShapes>,
|
||||
}
|
||||
|
||||
/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`.
|
||||
|
||||
@@ -493,8 +493,11 @@ impl Game<'_> {
|
||||
}
|
||||
"minecraft:trident" => {
|
||||
if let Some(n) = entity_nbt::trident_from_nbt(&tag) {
|
||||
self.tridents
|
||||
.push(super::trident::ThrownTrident::from_nbt(n));
|
||||
let mut t = super::trident::ThrownTrident::from_nbt(n);
|
||||
// One that came through a portal keeps its cooldown.
|
||||
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut t.id);
|
||||
self.restore_portal_cooldown(t.id, &tag);
|
||||
self.tridents.push(t);
|
||||
}
|
||||
}
|
||||
"minecraft:firework_rocket" => {
|
||||
@@ -503,6 +506,8 @@ impl Game<'_> {
|
||||
let id = self.next_entity;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -427,6 +427,7 @@ impl Game<'_> {
|
||||
let visually_swimming =
|
||||
p.pose == Pose::Swimming || (!p.fall_flying && p.pose == Pose::FallFlying);
|
||||
let in_water = p.fluids.was_touching_water;
|
||||
let in_lava = p.is_in_lava();
|
||||
let (yaw, pitch) = (self.yaw, self.pitch);
|
||||
let main = self.inventory[self.selected].clone();
|
||||
let off = self.offhand.as_ref().map(|s| s.item.clone());
|
||||
@@ -497,6 +498,9 @@ impl Game<'_> {
|
||||
m.extra.crouching = crouching;
|
||||
// Guests and server players take their pose from DATA_POSE, not here.
|
||||
m.extra.avatar_pose = pose;
|
||||
// What custom entity model animations read of the fluids (`is_in_water`, crawling).
|
||||
m.fluids.in_water = in_water;
|
||||
m.fluids.in_lava = in_lava;
|
||||
m.extra.passenger = riding;
|
||||
m.extra.previous_velocity = m.velocity;
|
||||
m.velocity = velocity;
|
||||
|
||||
@@ -8181,9 +8181,26 @@ impl Game<'_> {
|
||||
self.stop_remote_mining();
|
||||
}
|
||||
if !ready {
|
||||
// A screen's opening released the keys (`KeyMapping.releaseAll`).
|
||||
self.hotbar_presses.clear();
|
||||
return;
|
||||
}
|
||||
// MultiPlayerGameMode.tick syncs the carried slot before keybinds.
|
||||
self.remote_send_carried();
|
||||
let (attack, use_item) = (self.left, self.right);
|
||||
// A tick that clicks, attacks or uses acts with the slot its number
|
||||
// keys select (`ensureHasSentCarriedItem` before the packets).
|
||||
let acts = input.has_clicks() || attack || use_item;
|
||||
input.tick(&mut RemoteInputWorld(self, acts), attack, use_item);
|
||||
self.remote_queued_keys();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.input = input;
|
||||
}
|
||||
}
|
||||
|
||||
/// `MultiPlayerGameMode.ensureHasSentCarriedItem`: the server hears of a
|
||||
/// new selected slot before anything the slot acts with.
|
||||
fn remote_send_carried(&mut self) {
|
||||
let carried_changed = self.remote.as_mut().is_some_and(|r| {
|
||||
if r.carried == Some(self.selected) {
|
||||
false
|
||||
@@ -8197,8 +8214,9 @@ impl Game<'_> {
|
||||
slot: self.selected as i16,
|
||||
}));
|
||||
}
|
||||
let (attack, use_item) = (self.left, self.right);
|
||||
input.tick(&mut RemoteInputWorld(self), attack, use_item);
|
||||
}
|
||||
|
||||
fn remote_queued_keys(&mut self) {
|
||||
// handleKeybinds' keybind polls: the queued presses act now, in tick
|
||||
// order, so no packet goes out between CLIENT_TICK_END and a pong.
|
||||
let pending = self
|
||||
@@ -8229,9 +8247,6 @@ impl Game<'_> {
|
||||
if self.clicks.miss_time > 0 {
|
||||
self.clicks.miss_time -= 1;
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.input = input;
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_start_attack(&mut self) -> bool {
|
||||
@@ -8300,11 +8315,14 @@ impl Game<'_> {
|
||||
} else if let Some((i, distance)) = self.mob_hit()
|
||||
&& self.before_block(distance)
|
||||
{
|
||||
self.send(S::Attack(play::Attack {
|
||||
id: self.mobs[i].id as i32,
|
||||
}));
|
||||
self.remote_player_attack(i);
|
||||
self.reset_attack_strength();
|
||||
// Minecraft.startAttack: an item's attack_range must hold the hit.
|
||||
if self.held_range_allows(distance) {
|
||||
self.send(S::Attack(play::Attack {
|
||||
id: self.mobs[i].id as i32,
|
||||
}));
|
||||
self.remote_player_attack(i);
|
||||
self.reset_attack_strength();
|
||||
}
|
||||
} else if !self.entity_targeted()
|
||||
&& let Some(hit) = self.pick()
|
||||
{
|
||||
@@ -8519,7 +8537,9 @@ impl Game<'_> {
|
||||
// `KeyMapping.releaseAll` clears key.use; without this the button still
|
||||
// reads held and a use would start again on the first tick back.
|
||||
self.right = false;
|
||||
horizon_client::interaction_input::release_on_screen_open(&mut RemoteInputWorld(self));
|
||||
horizon_client::interaction_input::release_on_screen_open(&mut RemoteInputWorld(
|
||||
self, false,
|
||||
));
|
||||
}
|
||||
/// `MultiPlayerGameMode.releaseUsingItem`: sends `RELEASE_USE_ITEM` and
|
||||
/// runs the client half of `Item.releaseUsing` (a Riptide launch moves the
|
||||
@@ -9818,7 +9838,8 @@ fn synced_local_use(
|
||||
}
|
||||
}
|
||||
|
||||
struct RemoteInputWorld<'g, 'a>(&'g mut Game<'a>);
|
||||
/// The game as the tick's input sees it, and whether the tick acts.
|
||||
struct RemoteInputWorld<'g, 'a>(&'g mut Game<'a>, bool);
|
||||
|
||||
struct RemoteUseWorld<'g, 'a>(&'g mut Game<'a>);
|
||||
|
||||
@@ -9836,10 +9857,16 @@ impl horizon_client::interaction_input::TargetUseWorld for RemoteUseWorld<'_, '_
|
||||
|
||||
impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_, '_> {
|
||||
fn pick_hit_result(&mut self) {
|
||||
self.0.tick_hit = Some(self.0.pick());
|
||||
self.0.pick_tick_targets();
|
||||
// MultiPlayerGameMode.ensureHasSentCarriedItem: an action this tick
|
||||
// sends the slot the number keys chose first; otherwise the next
|
||||
// tick's MultiPlayerGameMode.tick does.
|
||||
if self.1 {
|
||||
self.0.remote_send_carried();
|
||||
}
|
||||
}
|
||||
fn release_hit_result(&mut self) {
|
||||
self.0.tick_hit = None;
|
||||
self.0.release_tick_targets();
|
||||
}
|
||||
fn using_item(&self) -> bool {
|
||||
self.0.using.is_some()
|
||||
|
||||
@@ -795,7 +795,10 @@ impl Game<'_> {
|
||||
out.push(json!({"id": c.id, "kind": "minecraft:end_crystal", "position": at(c.position), "show_bottom": c.show_bottom}));
|
||||
}
|
||||
for t in &self.tridents {
|
||||
out.push(json!({"id": t.id, "kind": "minecraft:trident", "position": at(t.position)}));
|
||||
out.push(json!({"id": t.id, "kind": "minecraft:trident", "position": at(t.position), "velocity": at(t.velocity)}));
|
||||
}
|
||||
for r in &self.rockets {
|
||||
out.push(json!({"id": r.id, "kind": "minecraft:firework_rocket", "position": at(r.position), "velocity": at(r.velocity)}));
|
||||
}
|
||||
for p in &self.creatures.projectiles {
|
||||
out.push(json!({"id": p.id, "kind": format!("{:?}", p.kind).split([' ', '{']).next().unwrap_or(""), "position": at(p.position), "velocity": at(p.velocity),
|
||||
|
||||
@@ -237,9 +237,17 @@ impl Game<'_> {
|
||||
cart
|
||||
};
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
// The tick a rider takes its seat is a teleport (`Entity.moveTo`,
|
||||
// which sets the old position too): the camera must not sweep from
|
||||
// where the player stood to the cushion, boat or cart. The ticks
|
||||
// after it move with the vehicle and interpolate as usual.
|
||||
let first = self.posed_rider != self.riding;
|
||||
self.posed_rider = self.riding;
|
||||
if let Some(v) = seat {
|
||||
self.previous_position = if first { v } else { p.position };
|
||||
p.position = v;
|
||||
} else {
|
||||
self.previous_position = p.position;
|
||||
}
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
|
||||
@@ -670,8 +670,9 @@ impl Game<'_> {
|
||||
host.play.entry(trip.conn).or_default().portal.cooldown = 10;
|
||||
let g = &mut host.hub.guests[i];
|
||||
g.yaw = yaw;
|
||||
// A new body at the exit: no fall distance or motion carried over.
|
||||
g.motion = None;
|
||||
// A new body at the exit: no fall distance or motion carried over,
|
||||
// but the player's attributes (ServerPlayer.teleport keeps the entity).
|
||||
g.new_body(true);
|
||||
let at = [position.x, position.y, position.z];
|
||||
if portal {
|
||||
host.hub.land(i, at);
|
||||
@@ -727,8 +728,9 @@ impl Game<'_> {
|
||||
let g = &mut host.hub.guests[i];
|
||||
let [yaw, pitch] = t.rotation.unwrap_or([g.yaw, g.pitch]);
|
||||
let origin = g.position;
|
||||
// A new body there: no fall distance or motion carried over.
|
||||
g.motion = None;
|
||||
// A new body there: no fall distance or motion carried over, but
|
||||
// the player's attributes (ServerPlayer.teleport keeps the entity).
|
||||
g.new_body(true);
|
||||
let cooldown = host.play.get(&t.conn).map_or(0, |p| p.portal.cooldown);
|
||||
let ctx = self.server_ctx();
|
||||
host.hub
|
||||
|
||||
@@ -38,6 +38,10 @@ fn sign_box() -> EditBox {
|
||||
edit
|
||||
}
|
||||
|
||||
/// The Done button's text, `CommonComponents.GUI_DONE`, in the selected language.
|
||||
fn done_label() -> String {
|
||||
super::screen_info::tr("gui.done", &[])
|
||||
}
|
||||
/// AbstractSignEditScreen.init's Done button: `width / 2 - 100`,
|
||||
/// `height / 4 + 144`, int GUI pixels.
|
||||
fn done_button(w: f32, h: f32) -> (f32, f32) {
|
||||
@@ -841,11 +845,7 @@ impl Game<'_> {
|
||||
}
|
||||
let (bx, by) = done_button(w, h);
|
||||
horizon_client::widgets::narratable(|| {
|
||||
horizon_client::widgets::Narratable::button(
|
||||
super::screen_info::tr("gui.done", &[]),
|
||||
[bx, by, 200.0, 20.0],
|
||||
true,
|
||||
)
|
||||
horizon_client::widgets::Narratable::button(done_label(), [bx, by, 200.0, 20.0], true)
|
||||
});
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
@@ -856,10 +856,11 @@ impl Game<'_> {
|
||||
20.0,
|
||||
u32::MAX,
|
||||
);
|
||||
let tw = HudBatch::text_width(atlas, "Done");
|
||||
let done = done_label();
|
||||
let tw = HudBatch::text_width(atlas, &done);
|
||||
batch.text(
|
||||
atlas,
|
||||
"Done",
|
||||
&done,
|
||||
bx + 100.0 - (tw / 2.0).floor(),
|
||||
by + 6.0,
|
||||
u32::MAX,
|
||||
@@ -876,4 +877,27 @@ mod tests {
|
||||
assert_eq!(super::done_button(427.0, 334.0), (113.0, 227.0));
|
||||
assert_eq!(super::done_button(320.0, 240.0), (60.0, 204.0));
|
||||
}
|
||||
|
||||
/// The Done button said "Done" in every language (#1927).
|
||||
#[test]
|
||||
fn done_button_reads_the_selected_language() {
|
||||
use horizon_client::lang::{Language, ResourceSource, scoped};
|
||||
struct Vanilla;
|
||||
impl ResourceSource for Vanilla {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
match path {
|
||||
"assets/minecraft/lang/en_us.json" => Some(br#"{"gui.done": "Done"}"#.to_vec()),
|
||||
"assets/minecraft/lang/de_de.json" => {
|
||||
Some(br#"{"gui.done": "Fertig"}"#.to_vec())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
for (code, want) in [("de_de", "Fertig"), ("en_us", "Done")] {
|
||||
let (label, missed) = scoped(Language::load(&[&Vanilla], code), super::done_label);
|
||||
assert_eq!(label, want, "{code}");
|
||||
assert!(missed.is_empty(), "{code}: {missed:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -326,20 +326,14 @@ impl Game<'_> {
|
||||
(false, false) => "gui/sprites/widget/tab",
|
||||
};
|
||||
ui::nine_slice(batch, atlas, sprite, x, 0.0, tw, TAB_H, u32::MAX);
|
||||
let label = Text::translate(key, &[]);
|
||||
let lw = atlas.font.width_text(&label);
|
||||
let color = if selected && active {
|
||||
u32::MAX
|
||||
} else {
|
||||
0xFFA0_A0A0
|
||||
};
|
||||
batch.text_styled(
|
||||
ui::menu_tab_label(
|
||||
batch,
|
||||
atlas,
|
||||
&label,
|
||||
(x + (tw - lw) / 2.0).floor(),
|
||||
8.0,
|
||||
color,
|
||||
true,
|
||||
&Text::translate(key, &[]),
|
||||
(x, 0.0, tw, TAB_H),
|
||||
selected,
|
||||
active,
|
||||
horizon_client::hud_overlays::now_ms(),
|
||||
);
|
||||
if selected {
|
||||
batch.fill(atlas, x + 2.0, TAB_H - 3.0, tw - 4.0, 1.0, u32::MAX);
|
||||
|
||||
@@ -45,7 +45,7 @@ impl PrimedTnt {
|
||||
collision: &horizon_world::block_collision::BlockCollisionRegistry<'_>,
|
||||
assets: &horizon_client::assets::Assets,
|
||||
facts: &horizon_client::blocks::BlockFacts,
|
||||
) -> Result<()> {
|
||||
) -> Result<Vec<BlockPos>> {
|
||||
self.previous = self.position;
|
||||
if !self.no_gravity {
|
||||
self.velocity.y -= 0.04;
|
||||
@@ -74,14 +74,17 @@ impl PrimedTnt {
|
||||
if moved.y != wanted.y {
|
||||
self.velocity.y = 0.0;
|
||||
}
|
||||
// Entity.applyEffectsFromBlocks: a bubble column lifts or drags it.
|
||||
horizon_client::motion::bubble_column_push(
|
||||
// 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.velocity = Vec3::new(
|
||||
self.velocity.x * 0.98,
|
||||
@@ -120,7 +123,7 @@ impl PrimedTnt {
|
||||
},
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
Ok(tops)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,12 +202,16 @@ impl Game<'_> {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
t.tick(
|
||||
let tops = t.tick(
|
||||
&self.world,
|
||||
self.collision,
|
||||
&self.pack.assets,
|
||||
self.res.facts(),
|
||||
)?;
|
||||
for pos in tops {
|
||||
self.bubble_column_particles(pos);
|
||||
}
|
||||
let t = &mut self.tnts[i];
|
||||
if t.fuse <= 0 {
|
||||
let center = [
|
||||
t.position.x,
|
||||
@@ -766,4 +773,70 @@ mod tests {
|
||||
tnt.position.y
|
||||
);
|
||||
}
|
||||
|
||||
/// `Entity.onAboveBubbleColumn` sends the surface particles only from the top of
|
||||
/// a column: `tick` names the column block it is at the top of, and none while
|
||||
/// the column has water above it.
|
||||
#[test]
|
||||
fn primed_tnt_reports_the_surface_of_a_bubble_column() {
|
||||
let root = 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 = horizon_client::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = horizon_client::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let collision = horizon_client::collisions::load(&root, assets.registry).unwrap();
|
||||
let mut world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap();
|
||||
let water = assets.registry.default_state("minecraft:water").unwrap();
|
||||
let soul_sand = assets
|
||||
.registry
|
||||
.default_state("minecraft:soul_sand")
|
||||
.unwrap();
|
||||
let default = assets
|
||||
.registry
|
||||
.default_state("minecraft:bubble_column")
|
||||
.unwrap();
|
||||
let mut up = assets.registry.state(default).unwrap().clone();
|
||||
up.properties.insert("drag".into(), "false".into());
|
||||
let column = assets.registry.resolve(&up).unwrap();
|
||||
for y in 3..=8 {
|
||||
world.set(20, y, 20, column).unwrap();
|
||||
}
|
||||
world.set(20, 2, 20, soul_sand).unwrap();
|
||||
let at = Vec3::new(20.5, 6.0, 20.5);
|
||||
let primed = || PrimedTnt {
|
||||
no_gravity: false,
|
||||
id: 0,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
fuse: 80,
|
||||
on_ground: false,
|
||||
owner: None,
|
||||
guest: None,
|
||||
};
|
||||
let mut tops = Vec::new();
|
||||
let mut tnt = primed();
|
||||
for _ in 0..30 {
|
||||
tops.extend(tnt.tick(&world, &collision, &assets, &facts).unwrap());
|
||||
}
|
||||
assert!(!tops.is_empty(), "it reaches the top of the column");
|
||||
assert!(
|
||||
tops.iter().all(|p| *p == BlockPos::new(20, 8, 20)),
|
||||
"only the surface block, got {tops:?}"
|
||||
);
|
||||
// Water above the top block: it is no longer the surface.
|
||||
world.set(20, 9, 20, water).unwrap();
|
||||
let mut tnt = primed();
|
||||
for _ in 0..10 {
|
||||
assert!(
|
||||
tnt.tick(&world, &collision, &assets, &facts)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ impl Game<'_> {
|
||||
let mut nodes = widgets::tree(&widgets::last_labelled(), &widgets::last_targets(), size);
|
||||
if matches!(self.screen, Screen::Inventory | Screen::Creative)
|
||||
&& self.title.is_none()
|
||||
&& self.book.is_none()
|
||||
&& let Ok((ox, oy)) = self.gui_origin()
|
||||
{
|
||||
nodes.extend(self.slots().into_iter().map(|i| {
|
||||
|
||||
@@ -426,6 +426,35 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
self.falling = falling;
|
||||
// Firework rockets (0.25 wide, eyes at 0.85 of it), tridents in flight
|
||||
// (0.5 wide, eyes at 0.13; like any arrow not pushed in the ground)
|
||||
// take `Entity.pushFromExplosion` alike.
|
||||
let mut rockets = std::mem::take(&mut self.rockets);
|
||||
for r in &mut rockets {
|
||||
let p = r.position;
|
||||
push_box(
|
||||
self,
|
||||
p,
|
||||
[p.x - 0.125, p.y, p.z - 0.125],
|
||||
[p.x + 0.125, p.y + 0.25, p.z + 0.125],
|
||||
0.2125,
|
||||
&mut r.velocity,
|
||||
);
|
||||
}
|
||||
self.rockets = rockets;
|
||||
let mut tridents = std::mem::take(&mut self.tridents);
|
||||
for t in tridents.iter_mut().filter(|t| !t.in_ground) {
|
||||
let p = t.position;
|
||||
push_box(
|
||||
self,
|
||||
p,
|
||||
[p.x - 0.25, p.y, p.z - 0.25],
|
||||
[p.x + 0.25, p.y + 0.5, p.z + 0.25],
|
||||
f64::from(0.13_f32),
|
||||
&mut t.velocity,
|
||||
);
|
||||
}
|
||||
self.tridents = tridents;
|
||||
// VehicleEntity.ignoreExplosion: a mob's burst (a breeze's) leaves
|
||||
// boats and minecarts alone with mob griefing off.
|
||||
if !(breeze && !self.ui.rules.bool("mob_griefing")) {
|
||||
|
||||
@@ -826,6 +826,57 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A recipe filter's `replace_input` (#5910) reaches the book swapped:
|
||||
/// the book writes from the network copy, so the swap must have run
|
||||
/// there, once, before the ingredient normalization. A world's pack
|
||||
/// carries the filter (the way the game reads them).
|
||||
#[test]
|
||||
fn a_recipe_filters_swapped_input_shows_swapped_in_the_book() {
|
||||
let root = resources(
|
||||
"filter_swap",
|
||||
&[(
|
||||
"copper_ingot_from_smelting_raw_copper",
|
||||
r#"{"type":"minecraft:smelting","cookingtime":200,"experience":0.7,
|
||||
"ingredient":"minecraft:raw_copper",
|
||||
"result":{"id":"minecraft:copper_ingot"}}"#,
|
||||
)],
|
||||
);
|
||||
let packs = vec![(
|
||||
"recipe_filter/minecraft:swap".to_owned(),
|
||||
vec![
|
||||
br#"{"filters":[{"id":"minecraft:copper_ingot_from_smelting_raw_copper",
|
||||
"replace_input":{"from":"minecraft:raw_copper","to":"minecraft:redstone"}}]}"#
|
||||
.to_vec(),
|
||||
],
|
||||
)];
|
||||
let overlay = horizon_world::loot::PackOverlay { files: &packs };
|
||||
let sync = SyncData::load(&root, &overlay).unwrap();
|
||||
let entries = sync.advancements.book_entries(
|
||||
"minecraft:copper_ingot_from_smelting_raw_copper",
|
||||
&sync,
|
||||
NOTIFY_AND_HIGHLIGHT,
|
||||
);
|
||||
assert_eq!(entries.len(), 1, "the recipe has one display");
|
||||
let mut r = Reader::new(&entries[0]);
|
||||
let e = RecipeBookEntry::read(&mut r).unwrap();
|
||||
assert_eq!(r.remaining(), 0, "the entry ends with its flags");
|
||||
let item = |n: &str| sync.id("minecraft:item", n).unwrap();
|
||||
assert_eq!(
|
||||
e.requirements,
|
||||
Some(vec![HolderSet::Items(vec![item("minecraft:redstone")])]),
|
||||
"the filter's swapped input, not the file's"
|
||||
);
|
||||
let RecipeDisplay::Furnace { ingredient, .. } = e.display else {
|
||||
panic!("a smelting recipe shows a furnace display");
|
||||
};
|
||||
assert_eq!(
|
||||
ingredient,
|
||||
SlotDisplay::Tag(HolderSet::Items(vec![item("minecraft:redstone")])),
|
||||
"the display shows the swapped input too"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blasting_reaches_the_blast_furnace_book() {
|
||||
let root = resources(
|
||||
|
||||
@@ -271,8 +271,7 @@ impl SyncData {
|
||||
super::advancements::ServerAdvancements::load(resources, overlay, recipes, groups)
|
||||
},
|
||||
};
|
||||
sync.recipes =
|
||||
super::recipes::update_recipes(&sync.advancements.recipes, resources, &sync, overlay);
|
||||
sync.recipes = super::recipes::update_recipes(&sync.advancements.recipes, &sync);
|
||||
Ok(sync)
|
||||
}
|
||||
|
||||
|
||||
@@ -214,6 +214,11 @@ pub struct Guest {
|
||||
/// What the player's file says of the body (`Entity.load`: the fire
|
||||
/// and air ticks and the fall distance), for the first body it gets.
|
||||
pub loaded_body: Option<SavedBody>,
|
||||
/// The attribute list the player's last body hands to its next one
|
||||
/// (`ServerPlayer.restoreFrom`, `PlayerMotion::carried_attributes`),
|
||||
/// until that body exists: the scale a player has set stays through a
|
||||
/// respawn and a change of level (#3684).
|
||||
pub carried_attributes: Option<horizon_world::nbt::Tag>,
|
||||
/// The vehicle the player's file names (`ServerPlayer.saveParentVehicle`'s
|
||||
/// "RootVehicle"), for the ride to come back on the next login.
|
||||
pub saved_vehicle: Option<[i32; 4]>,
|
||||
@@ -410,6 +415,7 @@ impl Guest {
|
||||
tracker: Tracker::default(),
|
||||
motion: None,
|
||||
loaded_body: None,
|
||||
carried_attributes: None,
|
||||
saved_vehicle: None,
|
||||
logs_kept: None,
|
||||
inventory: vec![None; 36],
|
||||
@@ -600,14 +606,39 @@ impl Guest {
|
||||
Pose::Standing
|
||||
}
|
||||
}
|
||||
/// `Avatar.POSES` for the guest's pose, with its scale attribute.
|
||||
/// `Avatar.POSES` for the guest's pose, with its scale attribute: also for
|
||||
/// a pose the client has announced (gliding) before the body's own tick
|
||||
/// has taken it.
|
||||
pub fn dimensions(&self) -> Dimensions {
|
||||
let pose = self.pose();
|
||||
match &self.motion {
|
||||
Some(m) if m.pose == pose => m.dimensions,
|
||||
_ => pose.dimensions(1.0),
|
||||
Some(m) => pose.dimensions(m.scale),
|
||||
None => pose.dimensions(1.0),
|
||||
}
|
||||
}
|
||||
/// The player's body is replaced (`ServerPlayer.restoreFrom`): the next
|
||||
/// one starts from the old one's attributes, all of them when it is the
|
||||
/// same player in another level (`restore_all`), only their base values
|
||||
/// after a death. The old body, for what else it hands over.
|
||||
pub fn new_body(&mut self, restore_all: bool) -> Option<Box<PlayerMotion>> {
|
||||
let m = self.motion.take()?;
|
||||
self.carried_attributes = Some(m.carried_attributes(restore_all));
|
||||
Some(m)
|
||||
}
|
||||
/// `ServerPlayer.restoreFrom(old, false)`'s `setHealth(getMaxHealth())`:
|
||||
/// the health a player comes back from a death with, the max health of
|
||||
/// the base values their next body takes over, before its equipment
|
||||
/// modifies it (40 after `/attribute @s minecraft:max_health base set
|
||||
/// 40`, not 20).
|
||||
pub fn respawn_health(&self, resources: &crate::motion::MotionResources) -> f32 {
|
||||
let Ok(mut body) = PlayerMotion::new(resources, Vec3::ZERO) else {
|
||||
return 20.0;
|
||||
};
|
||||
let _ = body.apply_saved_attributes(self.carried_attributes.as_ref());
|
||||
body.attribute("minecraft:max_health")
|
||||
.map_or(20.0, |max| max as f32)
|
||||
}
|
||||
/// `Entity.getBoundingBox`.
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
let [x, y, z] = self.position;
|
||||
@@ -1384,4 +1415,102 @@ mod tests {
|
||||
Some(Component::Text(_))
|
||||
));
|
||||
}
|
||||
|
||||
/// The prepared game data a body needs, or `None` without it.
|
||||
fn resources() -> Option<(crate::assets::Assets, crate::motion::MotionResources)> {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
eprintln!("skipped: prepared resources unavailable");
|
||||
return None;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).ok()?;
|
||||
let resources = crate::motion::MotionResources::load(&root, &assets).ok()?;
|
||||
Some((assets, resources))
|
||||
}
|
||||
|
||||
/// `/attribute @s <attribute> base set`, as `AttributeCommand` runs it,
|
||||
/// and the next tick's `refreshDirtyAttributes` (the box of a new scale).
|
||||
fn set_base(m: &mut PlayerMotion, attribute: &str, value: f64) {
|
||||
let key = m.attributes.key(attribute).unwrap();
|
||||
let handle = m.attributes.get_instance(Some(key)).unwrap().unwrap();
|
||||
m.attributes
|
||||
.edit(&handle, |attribute, observer| {
|
||||
attribute.set_base_value(value, observer)
|
||||
})
|
||||
.unwrap();
|
||||
m.refresh_attributes().unwrap();
|
||||
}
|
||||
|
||||
fn assert_dims(got: Dimensions, want: (f32, f32, f32), what: &str) {
|
||||
let close = |a: f32, b: f32| (a - b).abs() <= 1e-5 * b.abs().max(1.0);
|
||||
assert!(
|
||||
close(got.width, want.0) && close(got.height, want.1) && close(got.eye_height, want.2),
|
||||
"{what}: {got:?}, want {want:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `ServerPlayer.restoreFrom(old, false)` takes the base values
|
||||
/// (`assignBaseValues`), then `setHealth(getMaxHealth())`: a player whose
|
||||
/// max health base was set comes back with that much, not 20, and a
|
||||
/// permanent modifier (`/attribute ... modifier add`) does not count, as
|
||||
/// it stays with the dead body. The attribute's range is 1..1024.
|
||||
#[test]
|
||||
fn a_respawned_player_has_the_max_health_of_their_base_values() {
|
||||
use horizon_world::attributes::{Modifier, Operation};
|
||||
let Some((_assets, resources)) = resources() else {
|
||||
return;
|
||||
};
|
||||
for (base, bonus, want) in [
|
||||
(40.0, 0.0, 40.0),
|
||||
(10.0, 0.0, 10.0),
|
||||
(20.0, 20.0, 20.0),
|
||||
(0.0, 0.0, 1.0),
|
||||
(2048.0, 0.0, 1024.0),
|
||||
] {
|
||||
let mut g = guest_at([0.5, 64.0, 0.5]);
|
||||
let mut m = PlayerMotion::new(&resources, Vec3::new(0.5, 64.0, 0.5)).unwrap();
|
||||
set_base(&mut m, "minecraft:max_health", base);
|
||||
let key = m.attributes.key("minecraft:max_health").unwrap();
|
||||
let handle = m.attributes.get_instance(Some(key)).unwrap().unwrap();
|
||||
let bonus = Modifier::new("test:bonus", bonus, Operation::AddValue).unwrap();
|
||||
m.attributes
|
||||
.edit(&handle, |a, o| a.add_permanent(bonus, o))
|
||||
.unwrap();
|
||||
g.motion = Some(Box::new(m));
|
||||
assert!(g.new_body(false).is_some());
|
||||
assert_eq!(g.respawn_health(&resources), want, "base {base}");
|
||||
}
|
||||
// No body to take anything from: the default.
|
||||
let g = guest_at([0.5, 64.0, 0.5]);
|
||||
assert_eq!(g.respawn_health(&resources), 20.0);
|
||||
}
|
||||
|
||||
/// #3684: a glide the client announced (`START_FALL_FLYING`) is the
|
||||
/// player's pose before its body's own tick has taken it: the box in
|
||||
/// between is the gliding one at the player's scale, not at scale 1 (a
|
||||
/// 0.6 box on a player 0.1 or 2 times as big sent them back from a wall).
|
||||
#[test]
|
||||
fn a_glide_announced_before_the_body_ticks_has_the_scaled_box() {
|
||||
let Some((_assets, resources)) = resources() else {
|
||||
return;
|
||||
};
|
||||
for (scale, side) in [(0.1_f64, 0.06_f32), (2.0, 1.2)] {
|
||||
let mut g = guest_at([0.5, 64.0, 0.5]);
|
||||
let mut m = PlayerMotion::new(&resources, Vec3::new(0.5, 64.0, 0.5)).unwrap();
|
||||
set_base(&mut m, "minecraft:scale", scale);
|
||||
m.set_pose(Pose::Standing).unwrap();
|
||||
g.motion = Some(Box::new(m));
|
||||
g.fall_flying = true;
|
||||
assert_eq!(g.pose(), Pose::FallFlying);
|
||||
assert_ne!(g.motion.as_ref().unwrap().pose, Pose::FallFlying);
|
||||
let d = g.dimensions();
|
||||
assert_dims(
|
||||
d,
|
||||
(side, side, side * (0.4 / 0.6)),
|
||||
&format!("scale {scale}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,12 +275,19 @@ pub fn store(guest: &Guest, level: &mut Level, vehicle: Option<Tag>) {
|
||||
}
|
||||
// LivingEntity.addAdditionalSaveData: the live body's AttributeMap.pack,
|
||||
// or the list the file had while the player has no body yet.
|
||||
if let Some(m) = guest.motion.as_deref() {
|
||||
// A player between two bodies (a respawn, a change of level) saves what
|
||||
// the next one takes over.
|
||||
let attributes = guest
|
||||
.motion
|
||||
.as_deref()
|
||||
.map(crate::motion::PlayerMotion::saved_attributes)
|
||||
.or_else(|| guest.carried_attributes.clone());
|
||||
if let Some(attributes) = attributes {
|
||||
let extra = level
|
||||
.player_extra
|
||||
.get_or_insert_with(|| horizon_world::nbt::Tag::Compound(Default::default()));
|
||||
if let horizon_world::nbt::Tag::Compound(map) = extra {
|
||||
map.insert("attributes".into(), m.saved_attributes());
|
||||
map.insert("attributes".into(), attributes);
|
||||
}
|
||||
}
|
||||
level.recipe_book = guest.recipe_book.clone();
|
||||
@@ -543,6 +550,89 @@ mod tests {
|
||||
Some((assets, resources))
|
||||
}
|
||||
|
||||
/// A body at scale 0.1 with a permanent +0.4 on its step height, such as
|
||||
/// a command or a datapack gives.
|
||||
fn small_body(resources: &crate::motion::MotionResources) -> crate::motion::PlayerMotion {
|
||||
use horizon_world::attributes::{Modifier, Operation};
|
||||
let mut m = crate::motion::PlayerMotion::new(resources, horizon_core::geometry::Vec3::ZERO)
|
||||
.expect("a body");
|
||||
let key = m.attributes.key("minecraft:scale").unwrap();
|
||||
let handle = m.attributes.get_instance(Some(key)).unwrap().unwrap();
|
||||
m.attributes
|
||||
.edit(&handle, |a, o| a.set_base_value(0.1, o))
|
||||
.unwrap();
|
||||
let key = m.attributes.key("minecraft:step_height").unwrap();
|
||||
let handle = m.attributes.get_instance(Some(key)).unwrap().unwrap();
|
||||
let step = Modifier::new("test:step", 0.4, Operation::AddValue).unwrap();
|
||||
m.attributes
|
||||
.edit(&handle, |a, o| a.add_permanent(step, o))
|
||||
.unwrap();
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_body_takes_the_scale_of_the_old_one() {
|
||||
// #3684: ServerPlayer.restoreFrom copies the base values to the body
|
||||
// that follows a death (assignBaseValues) and, for the same player in
|
||||
// another level, the permanent modifiers too (assignPermanentModifiers).
|
||||
// The body Horizon made on a respawn or a level change started from
|
||||
// the defaults: a player at /attribute scale 0.1 was 1.0 again.
|
||||
let Some((_assets, resources)) = resources() else {
|
||||
eprintln!("skipped: prepared resources unavailable");
|
||||
return;
|
||||
};
|
||||
let next = |g: &Guest| {
|
||||
let mut m =
|
||||
crate::motion::PlayerMotion::new(&resources, horizon_core::geometry::Vec3::ZERO)
|
||||
.expect("a body");
|
||||
m.apply_saved_attributes(g.carried_attributes.as_ref())
|
||||
.unwrap();
|
||||
m
|
||||
};
|
||||
for (restore_all, step) in [(false, 0.6), (true, 1.0)] {
|
||||
let mut g = guest();
|
||||
g.motion = Some(Box::new(small_body(&resources)));
|
||||
g.new_body(restore_all);
|
||||
assert!(g.motion.is_none());
|
||||
let mut m = next(&g);
|
||||
assert_eq!(m.attribute("minecraft:scale").unwrap(), 0.1);
|
||||
assert!((m.attribute("minecraft:step_height").unwrap() - step).abs() < 1e-9);
|
||||
// The tick that follows rebuilds the box at that scale.
|
||||
m.refresh_attributes().unwrap();
|
||||
assert_eq!(m.dimensions.width, 0.6 * 0.1);
|
||||
assert_eq!(m.dimensions.height, 1.8 * 0.1);
|
||||
}
|
||||
// No body before: nothing to hand over.
|
||||
let mut g = guest();
|
||||
g.new_body(true);
|
||||
assert!(g.carried_attributes.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_player_between_two_bodies_saves_the_attributes_the_next_one_takes() {
|
||||
// #3684: logging out after a respawn, before the new body has ticked,
|
||||
// must not write the file's older list over the scale.
|
||||
let Some((_assets, resources)) = resources() else {
|
||||
eprintln!("skipped: prepared resources unavailable");
|
||||
return;
|
||||
};
|
||||
let mut g = guest();
|
||||
g.motion = Some(Box::new(small_body(&resources)));
|
||||
g.new_body(false);
|
||||
let mut level = Level::default();
|
||||
store(&g, &mut level, None);
|
||||
let tag = level.player_tag();
|
||||
let mut read = Level::default();
|
||||
read.read_player(&tag);
|
||||
read.player_extra = Some(tag);
|
||||
let mut back =
|
||||
crate::motion::PlayerMotion::new(&resources, horizon_core::geometry::Vec3::ZERO)
|
||||
.expect("a body");
|
||||
back.apply_saved_attributes(read.player_extra.as_ref().and_then(|t| t.get("attributes")))
|
||||
.unwrap();
|
||||
assert_eq!(back.attribute("minecraft:scale").unwrap(), 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_air_comes_back() {
|
||||
// A player saved underwater (#1321): the file keeps the remaining air
|
||||
|
||||
@@ -96,6 +96,12 @@ pub(super) fn network_recipes(
|
||||
let mut all = BTreeMap::new();
|
||||
recipes(&resources.join("data/minecraft/recipe"), "", &mut all);
|
||||
overlay_recipes(overlay, &mut all);
|
||||
// The data's recipe filters, as the server's own tables apply them
|
||||
// (#5910), once, before the ingredient normalization, so a swapped
|
||||
// name resolves and every copy — the book's displays, the property
|
||||
// sets — agrees on the swapped recipe.
|
||||
let filters = horizon_items::recipe_filters::RecipeFilters::load(resources, overlay);
|
||||
all.retain(|id, r| filters.apply(id, r));
|
||||
match horizon_client::items::Items::catalog(resources, overlay) {
|
||||
Ok(items) => all.retain(|_, r| normalize_ingredients(r, &items)),
|
||||
Err(e) => log::warn!("recipes: NeoForge's ingredient shapes stay unresolved: {e}"),
|
||||
@@ -190,26 +196,14 @@ fn normalize_ingredients(r: &mut Value, items: &horizon_client::items::Items) ->
|
||||
}
|
||||
|
||||
/// The packet's payload for the network copy's recipes (`network_recipes`).
|
||||
pub fn update_recipes(
|
||||
all: &BTreeMap<String, Box<str>>,
|
||||
resources: &Path,
|
||||
sync: &SyncData,
|
||||
overlay: &horizon_world::loot::PackOverlay,
|
||||
) -> Vec<u8> {
|
||||
// The data's recipe filters, as the server's own tables apply them. A
|
||||
// filter can also swap a recipe's inputs and outputs, so it runs on
|
||||
// this packet's own parsed values, the book's copy keeping its files
|
||||
// as loaded. Parsing every recipe takes about a millisecond.
|
||||
let filters = horizon_items::recipe_filters::RecipeFilters::load(resources, overlay);
|
||||
let mut chosen: Vec<Value> = Vec::new();
|
||||
for (id, text) in all {
|
||||
let Ok(mut own) = serde_json::from_str::<Value>(text) else {
|
||||
continue;
|
||||
};
|
||||
if filters.apply(id, &mut own) {
|
||||
chosen.push(own);
|
||||
}
|
||||
}
|
||||
pub fn update_recipes(all: &BTreeMap<String, Box<str>>, sync: &SyncData) -> Vec<u8> {
|
||||
// The copy is already filtered and swapped (`network_recipes`); the
|
||||
// filters never run twice, so one swap cannot feed a later one again.
|
||||
// Parsing every recipe takes about a millisecond.
|
||||
let chosen: Vec<Value> = all
|
||||
.values()
|
||||
.filter_map(|text| serde_json::from_str::<Value>(text).ok())
|
||||
.collect();
|
||||
let tags: BTreeMap<&str, &[i32]> = sync
|
||||
.tags
|
||||
.registries
|
||||
|
||||
@@ -2799,6 +2799,11 @@ impl Mob {
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// LivingEntity.getDamageAfterArmorAbsorb asks `hurtArmor` *before*
|
||||
// `CombatRules.getDamageAfterAbsorb`, so a worn piece wears by the
|
||||
// damage the armour, toughness and enchantment reductions have not
|
||||
// touched yet, and only a hit outside `#bypasses_armor` asks at all.
|
||||
let armor_hit = (!source.bypasses_armor() && amount > 0.0).then_some(amount);
|
||||
if !source.bypasses_armor() && self.kind.armor + worn + covered > 0.0 {
|
||||
// CombatRules.getDamageAfterAbsorb
|
||||
let armor = self.kind.armor + worn + covered;
|
||||
@@ -2881,17 +2886,19 @@ impl Mob {
|
||||
if !absorbed && dealt != 0.0 {
|
||||
self.record_damage(source, dealt);
|
||||
}
|
||||
// LivingEntity.hurtArmor: the body armour a mob wears (a horse's) wears
|
||||
// on a hit that reached it. A wolf's armour has its own rule
|
||||
// (`Wolf.actuallyHurt`, `hurt_wolf_armor`).
|
||||
if dealt > 0.0
|
||||
// LivingEntity.hurtArmor: `Horse` is the only mob that overrides it, and
|
||||
// it hands `doHurtEquipment` the body slot alone. A wolf's armour has
|
||||
// its own rule (`Wolf.actuallyHurt`, `hurt_wolf_armor`) and a player's
|
||||
// four pieces theirs, so the other "body slot" species — donkey, mule,
|
||||
// llama, camel, the skeleton and zombie horses — wear nothing, whatever
|
||||
// `drops::head_is_body` says about where the item lives.
|
||||
if let Some(damage) = armor_hit
|
||||
&& self.species() == Species::Horse
|
||||
&& self.extra.head.is_some()
|
||||
&& self.species() != Species::Wolf
|
||||
&& drops::head_is_body(self.species())
|
||||
{
|
||||
self.outbox.push(MobEvent::ArmorHurt {
|
||||
mob: self.id,
|
||||
amount: dealt,
|
||||
amount: damage,
|
||||
kind: source.kind,
|
||||
});
|
||||
}
|
||||
@@ -4325,10 +4332,14 @@ mod armor_hurt_tests {
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
fn horse() -> Mob {
|
||||
mob("minecraft:horse", Species::Horse)
|
||||
}
|
||||
|
||||
fn mob(id: &str, species: Species) -> Mob {
|
||||
let mut kind = kind();
|
||||
let k = Arc::make_mut(&mut kind);
|
||||
k.id = "minecraft:horse".into();
|
||||
k.species = Species::Horse;
|
||||
k.id = id.into();
|
||||
k.species = species;
|
||||
Mob::new(1, kind, Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
@@ -4363,6 +4374,72 @@ mod armor_hurt_tests {
|
||||
.any(|e| matches!(e, MobEvent::ArmorHurt { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
/// `Horse` is the only mob that overrides `LivingEntity.hurtArmor` (the
|
||||
/// base one is empty, `LivingEntity.java:1887`), so a donkey wearing a body
|
||||
/// item — `/item replace entity @e[type=minecraft:donkey,limit=1]
|
||||
/// armor.body with minecraft:wolf_armor` — wears nothing, even though
|
||||
/// `drops::head_is_body` stores a donkey's body item in the slot a horse's
|
||||
/// goes in.
|
||||
#[test]
|
||||
fn a_donkey_wearing_a_body_item_wears_nothing() {
|
||||
let mut donkey = mob("minecraft:donkey", Species::Donkey);
|
||||
donkey.extra.head = Some("minecraft:wolf_armor".into());
|
||||
assert!(
|
||||
donkey.damage(6.0, Damage::of(DamageKind::MobAttack)),
|
||||
"the hit landed"
|
||||
);
|
||||
assert!(
|
||||
!donkey
|
||||
.outbox
|
||||
.iter()
|
||||
.any(|e| matches!(e, MobEvent::ArmorHurt { .. })),
|
||||
"only a horse's own override wears its body piece"
|
||||
);
|
||||
}
|
||||
|
||||
/// `getDamageAfterArmorAbsorb` asks `hurtArmor` only when the damage type
|
||||
/// is not in `#bypasses_armor` (`LivingEntity.java:1907-1909`), so a horse
|
||||
/// that falls keeps its body piece whole.
|
||||
#[test]
|
||||
fn a_fall_does_not_wear_the_body_piece() {
|
||||
let mut m = horse();
|
||||
m.extra.head = Some("minecraft:diamond_horse_armor".into());
|
||||
assert!(
|
||||
m.damage(6.0, Damage::of(DamageKind::Fall)),
|
||||
"the fall landed"
|
||||
);
|
||||
assert!(
|
||||
!m.outbox
|
||||
.iter()
|
||||
.any(|e| matches!(e, MobEvent::ArmorHurt { .. })),
|
||||
"a #bypasses_armor hit asks for no wear"
|
||||
);
|
||||
}
|
||||
|
||||
/// The wear is measured by the damage `hurtArmor` is handed, which is the
|
||||
/// one the armour, toughness and enchantment reductions have not touched
|
||||
/// yet: 8 raw damage is `8 / 4 = 2` durability even though plate leaves far
|
||||
/// less than 8 through to the health.
|
||||
#[test]
|
||||
fn the_wear_is_the_damage_before_the_armour_reduction() {
|
||||
let mut m = horse();
|
||||
let k = Arc::make_mut(&mut m.kind);
|
||||
k.armor = 20.0;
|
||||
m.extra.head = Some("minecraft:diamond_horse_armor".into());
|
||||
let before = m.health;
|
||||
assert!(m.damage(8.0, Damage::of(DamageKind::MobAttack)));
|
||||
assert!(m.health < before, "the hit reached the health");
|
||||
let owed = m.outbox.iter().find_map(|e| match e {
|
||||
MobEvent::ArmorHurt { amount, .. } => Some(*amount),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(
|
||||
owed,
|
||||
Some(8.0),
|
||||
"the pre-reduction damage, not the damage the health took"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -18,10 +18,12 @@
|
||||
//! new geometry.
|
||||
|
||||
use super::{MobType, Part, Polygon};
|
||||
use horizon_optifine::cem::model::{Bone, EntityModel, MAX_BYTES, parse_jem};
|
||||
use horizon_optifine::cem::{
|
||||
files::{atlas_name, jem_files, load_jem},
|
||||
model::{Bone, EntityModel},
|
||||
};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, HashMap},
|
||||
fs,
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
@@ -130,39 +132,6 @@ impl CemModels {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `.jem` at `path`, its warnings logged; None, with a warning, when it can't be read or
|
||||
/// parsed.
|
||||
fn load_jem(root: &Path, path: &str) -> Option<EntityModel> {
|
||||
let Some(text) = read_small(&root.join(path)) else {
|
||||
log::warn!("Horizon: custom entity model {path}: unreadable or over {MAX_BYTES} bytes");
|
||||
return None;
|
||||
};
|
||||
let jem = match parse_jem(path, &text, &mut |p: &str| read_small(&root.join(p))) {
|
||||
Ok(jem) => jem,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: custom entity model {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
for warning in &jem.warnings {
|
||||
log::warn!("Horizon: custom entity model {warning}");
|
||||
}
|
||||
if let Some(texture) = &jem.texture {
|
||||
if atlas_name(texture).is_none() {
|
||||
log::warn!(
|
||||
"Horizon: custom entity model {path}: its texture {texture} is outside \
|
||||
assets/minecraft/textures/, so not in the atlas: the vanilla one draws"
|
||||
);
|
||||
} else if !root.join(texture).is_file() {
|
||||
log::warn!(
|
||||
"Horizon: custom entity model {path}: its texture {texture} doesn't exist, \
|
||||
so not in the atlas: the vanilla one draws"
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(jem)
|
||||
}
|
||||
|
||||
/// Every model layer of `types` by its key.
|
||||
fn vanilla_layers(types: &HashMap<String, Arc<MobType>>) -> HashMap<&str, &Arc<Part>> {
|
||||
types
|
||||
@@ -171,47 +140,6 @@ fn vanilla_layers(types: &HashMap<String, Arc<MobType>>) -> HashMap<&str, &Arc<P
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A pack file's text, None when it's missing or larger than a model may read: its size is
|
||||
/// checked before anything is read.
|
||||
fn read_small(path: &Path) -> Option<String> {
|
||||
let len = fs::metadata(path).ok()?.len();
|
||||
if len > MAX_BYTES as u64 {
|
||||
return None;
|
||||
}
|
||||
fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
/// Every `.jem` of the CEM folders by model name, sorted, each name's files best first:
|
||||
/// `emf/cem` before `optifine/cem` whichever form either uses, and within one folder the flat
|
||||
/// `<name>.jem` before `<name>/<name>.jem`.
|
||||
fn jem_files(root: &Path) -> BTreeMap<String, Vec<String>> {
|
||||
let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
for base in ["assets/minecraft/emf/cem", "assets/minecraft/optifine/cem"] {
|
||||
let Ok(entries) = fs::read_dir(root.join(base)) else {
|
||||
continue;
|
||||
};
|
||||
let (mut flat, mut nested) = (Vec::new(), Vec::new());
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if let Some(model) = name.strip_suffix(".jem") {
|
||||
// A directory named `x.jem` isn't a model.
|
||||
if e.path().is_file() {
|
||||
flat.push((model.to_owned(), format!("{base}/{name}")));
|
||||
}
|
||||
} else {
|
||||
let path = format!("{base}/{name}/{name}.jem");
|
||||
if root.join(&path).is_file() {
|
||||
nested.push((name, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (model, path) in flat.into_iter().chain(nested) {
|
||||
out.entry(model).or_default().push(path);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The CEM model names with a layer key of their own; any other name `n` is `n#main`.
|
||||
const LAYERS: &[(&str, &str)] = &[
|
||||
("sheep_wool", "sheep#wool"),
|
||||
@@ -500,13 +428,6 @@ impl CemLayer {
|
||||
}
|
||||
}
|
||||
|
||||
/// The atlas name of a texture resource path: `assets/minecraft/textures/entity/x.png` is
|
||||
/// `entity/x`. Textures outside a `textures/` folder are not in the atlas.
|
||||
fn atlas_name(path: &str) -> Option<String> {
|
||||
let rest = path.strip_prefix("assets/minecraft/textures/")?;
|
||||
Some(rest.strip_suffix(".png").unwrap_or(rest).to_owned())
|
||||
}
|
||||
|
||||
/// The first part named `name`, depth first (`ModelPart.getChild` through the tree).
|
||||
fn find_mut<'a>(part: &'a mut Part, name: &str) -> Option<&'a mut Part> {
|
||||
if part.name == name {
|
||||
@@ -544,6 +465,7 @@ fn part(bone: &Bone, count: &mut usize, ids: &mut HashMap<String, String>) -> Pa
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn plane(name: &str, children: Vec<Part>) -> Part {
|
||||
Part {
|
||||
@@ -705,35 +627,6 @@ mod tests {
|
||||
Some(part.polygons.len() == 1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emf_files_win_and_a_flat_file_beats_its_folder() {
|
||||
let (replace, attach) = (jem("leg1", false), jem("leg1", true));
|
||||
let files = [
|
||||
// EMF's folder form over OptiFine's flat file.
|
||||
("optifine/cem/cow.jem", attach.as_str()),
|
||||
("emf/cem/cow/cow.jem", replace.as_str()),
|
||||
// One folder: the flat file over the folder form.
|
||||
("optifine/cem/pig.jem", replace.as_str()),
|
||||
("optifine/cem/pig/pig.jem", attach.as_str()),
|
||||
// A directory named `sheep.jem` is no file: the folder form stands.
|
||||
("optifine/cem/sheep.jem/readme.txt", ""),
|
||||
("optifine/cem/sheep/sheep.jem", attach.as_str()),
|
||||
];
|
||||
let tree = plane("root", vec![plane("right_hind_leg", vec![])]);
|
||||
let models = models(
|
||||
"precedence",
|
||||
&files,
|
||||
&["cow#main", "pig#main", "sheep#main"],
|
||||
&tree,
|
||||
);
|
||||
assert_eq!(attached(&models, "cow#main", "right_hind_leg"), Some(false));
|
||||
assert_eq!(attached(&models, "pig#main", "right_hind_leg"), Some(false));
|
||||
assert_eq!(
|
||||
attached(&models, "sheep#main", "right_hind_leg"),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baby_with_its_own_file_takes_it_not_the_adults() {
|
||||
let (adult, baby) = (jem("head", false), jem("head", true));
|
||||
|
||||
@@ -549,6 +549,24 @@ fn params(m: &Mob, s: &State, frame: &Frame, slots: &mut [f32]) {
|
||||
((vx * fx + vz * fz) / len, (vx * -fz + vz * fx) / len)
|
||||
}
|
||||
};
|
||||
// `Entity.isVisuallySwimming` (`Pose.SWIMMING`, id 3): a player's or mannequin's pose comes
|
||||
// in `avatar_pose`, another mob's in `pose`. A drowned has no such pose, only the shared
|
||||
// swimming flag (`extra.flag`).
|
||||
let swim_pose = match &*m.kind.id {
|
||||
"minecraft:player" | "minecraft:mannequin" => {
|
||||
m.extra.avatar_pose == crate::pose::Pose::Swimming
|
||||
}
|
||||
_ => m.extra.pose == 3 || (m.species() == super::Species::Drowned && m.extra.flag),
|
||||
};
|
||||
// The arm the current swing moves: the main arm unless the swing named its hand.
|
||||
let swinging = m.ai.swinging || m.extra.swing_right_arm.is_some();
|
||||
let swing_right = m.extra.swing_right_arm.unwrap_or(!m.main_arm_left());
|
||||
// `HumanoidArm`'s items: the main hand is on the main arm.
|
||||
let (right_hand, left_hand) = if m.main_arm_left() {
|
||||
(&m.extra.off_hand, &m.extra.main_hand)
|
||||
} else {
|
||||
(&m.extra.main_hand, &m.extra.off_hand)
|
||||
};
|
||||
for (i, (_, p)) in PARAMS.iter().enumerate() {
|
||||
use Param::*;
|
||||
slots[i] = match p {
|
||||
@@ -582,6 +600,8 @@ fn params(m: &Mob, s: &State, frame: &Frame, slots: &mut [f32]) {
|
||||
PlayerRotY => frame.player_rot[1],
|
||||
// Random models' rule (a later change); the model's own rule is 0 until then.
|
||||
RuleIndex => 0.0,
|
||||
// A client knows only a wolf's anger end time (as a remaining time taken when it
|
||||
// was synced, which doesn't count down) and never the start: 0 for now.
|
||||
AngerTime | AngerTimeStart => 0.0,
|
||||
Distance => (dx * dx + dy * dy + dz * dz).sqrt() as f32,
|
||||
MoveForward => forward,
|
||||
@@ -589,6 +609,8 @@ fn params(m: &Mob, s: &State, frame: &Frame, slots: &mut [f32]) {
|
||||
// How deep the entity is in its fluid, which is what `fluid_depth_down` measures
|
||||
// once it touches the bottom.
|
||||
FluidDepth | FluidDepthDown => m.fluids.water.max(m.fluids.lava) as f32,
|
||||
// Both need the blocks around the mob (the ground below, the fluid's surface
|
||||
// above), which the draw doesn't have: 0 for now.
|
||||
HeightAboveGround | FluidDepthUp => 0.0,
|
||||
// The synced `Mob.isAggressive`, which a client's mobs have too.
|
||||
Aggressive => flag(m.ai.aggressive),
|
||||
@@ -599,10 +621,14 @@ fn params(m: &Mob, s: &State, frame: &Frame, slots: &mut [f32]) {
|
||||
Hurt => flag(m.hurt_time > 0),
|
||||
InWater => flag(m.fluids.in_water),
|
||||
InLava => flag(m.fluids.in_lava),
|
||||
// `isInWaterOrRain` without the rain: whether rain reaches the mob needs the
|
||||
// weather and the sky above it, which the draw doesn't have.
|
||||
Wet => flag(m.fluids.in_water),
|
||||
Invisible => flag(m.invisible),
|
||||
OnGround => flag(m.on_ground),
|
||||
Riding => flag(m.extra.passenger),
|
||||
// `Entity.isVehicle`: SetPassengers names its first rider.
|
||||
Ridden => flag(m.extra.rider.is_some() || m.rider.is_some()),
|
||||
Climbing => flag(m.climbing),
|
||||
Jumping => flag(!m.on_ground && m.velocity.y > 0.0),
|
||||
Paused => flag(frame.paused),
|
||||
@@ -611,14 +637,32 @@ fn params(m: &Mob, s: &State, frame: &Frame, slots: &mut [f32]) {
|
||||
ThirdPerson => flag(!frame.first_person && !frame.third_person_reversed),
|
||||
ThirdPersonReversed => flag(frame.third_person_reversed),
|
||||
RightHanded => flag(!m.main_arm_left()),
|
||||
Sitting => flag(m.extra.sitting),
|
||||
// The tamed animals' DATA_FLAGS_ID, and the species that keep their own.
|
||||
Sitting => flag(match m.species() {
|
||||
super::Species::Fox => super::fox::flag(m, super::fox::SITTING),
|
||||
super::Species::Panda => super::panda::is_sitting(m),
|
||||
super::Species::Camel | super::Species::CamelHusk => super::camel::sitting(m),
|
||||
_ => m.extra.sitting,
|
||||
}),
|
||||
Tamed => flag(m.extra.tame),
|
||||
Sprinting => flag(m.extra.sprinting),
|
||||
Sneaking => flag(m.extra.crouching),
|
||||
// Not known to the draw yet: 0 until something reads them.
|
||||
InHand | InItemFrame | InGround | OnHead | OnShoulder | Ridden | Blocking
|
||||
| Crawling | Swimming | SwingingRight | SwingingLeft | FirstPersonHand | UsingItem
|
||||
| HoldingRight | HoldingLeft | Hovered => 0.0,
|
||||
// A player's `isBlocking`: the item in use blocks attacks.
|
||||
Blocking => flag(m.extra.blocking),
|
||||
// `isVisuallyCrawling`: the swimming pose out of water.
|
||||
Crawling => flag(swim_pose && !m.fluids.in_water),
|
||||
Swimming => flag(swim_pose),
|
||||
// DATA_LIVING_ENTITY_FLAGS' using bit (the client counts the ticks).
|
||||
UsingItem => flag(m.extra.using_item > 0),
|
||||
SwingingRight => flag(swinging && swing_right),
|
||||
SwingingLeft => flag(swinging && !swing_right),
|
||||
HoldingRight => flag(right_hand.is_some()),
|
||||
HoldingLeft => flag(left_hand.is_some()),
|
||||
// Where the model is drawn (a held, framed or worn item, a shoulder): the draw
|
||||
// doesn't pass it yet, so 0 until something reads them.
|
||||
InHand | InItemFrame | InGround | OnHead | OnShoulder | FirstPersonHand | Hovered => {
|
||||
0.0
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -882,18 +926,68 @@ mod tests {
|
||||
m.extra.crouching = true;
|
||||
// The player's main hand option set to left.
|
||||
m.extra.main_hand_left = true;
|
||||
m.extra.blocking = true;
|
||||
// The swimming pose out of water: crawling.
|
||||
m.extra.pose = 3;
|
||||
m.extra.using_item = 3;
|
||||
// Only the main hand holds something, and it is the left one.
|
||||
m.extra.set_main_hand(Some("minecraft:stick".into()));
|
||||
// A swing that names no hand moves the main arm: the left.
|
||||
m.ai.swinging = true;
|
||||
m.extra.rider = Some(7);
|
||||
let model = bone("root", vec![]);
|
||||
let animation = animate(
|
||||
&model,
|
||||
&[(
|
||||
"root.tx",
|
||||
"is_aggressive + is_sitting * 2 + is_tamed * 4 + is_sprinting * 8 \
|
||||
+ is_sneaking * 16 + is_right_handed * 32",
|
||||
+ is_sneaking * 16 + is_right_handed * 32 + is_blocking * 64 \
|
||||
+ is_crawling * 128 + is_swimming * 256 + is_using_item * 512 \
|
||||
+ is_holding_item_right * 1024 + is_holding_item_left * 2048 \
|
||||
+ is_swinging_right_arm * 4096 + is_swinging_left_arm * 8192 \
|
||||
+ is_ridden * 16384",
|
||||
)],
|
||||
);
|
||||
let mut poses = Poses::new(&model);
|
||||
animation.run_in(&Frame::default(), &m, &still(), &mut poses);
|
||||
assert_eq!(poses.get("root")[0], 31.0);
|
||||
assert_eq!(
|
||||
poses.get("root")[0],
|
||||
(31 + 64 + 128 + 256 + 512 + 2048 + 8192 + 16384) as f32
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_swimming_pose_in_water_swims_without_crawling() {
|
||||
let mut m = mob();
|
||||
m.extra.pose = 3;
|
||||
let model = bone("root", vec![]);
|
||||
let animation = animate(&model, &[("root.tx", "is_swimming + is_crawling * 2")]);
|
||||
let mut read = |in_water: bool| {
|
||||
m.fluids.in_water = in_water;
|
||||
let mut poses = Poses::new(&model);
|
||||
animation.run_in(&Frame::default(), &m, &still(), &mut poses);
|
||||
poses.get("root")[0]
|
||||
};
|
||||
assert_eq!([read(true), read(false)], [1.0, 3.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fox_sits_by_its_own_flags() {
|
||||
let mut m = mob();
|
||||
m.kind = std::sync::Arc::new(crate::mobs::MobType {
|
||||
species: crate::mobs::Species::Fox,
|
||||
..(*m.kind).clone()
|
||||
});
|
||||
let model = bone("root", vec![]);
|
||||
let animation = animate(&model, &[("root.tx", "is_sitting")]);
|
||||
let mut sitting = |flags: i32| {
|
||||
m.extra.ints[0] = flags;
|
||||
let mut poses = Poses::new(&model);
|
||||
animation.run_in(&Frame::default(), &m, &still(), &mut poses);
|
||||
poses.get("root")[0]
|
||||
};
|
||||
assert_eq!(sitting(crate::mobs::fox::SITTING), 1.0);
|
||||
assert_eq!(sitting(crate::mobs::fox::CROUCHING), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -345,25 +345,28 @@ pub fn work_sound(profession: u8) -> Option<&'static str> {
|
||||
}
|
||||
/// A `CropBlock` at `p`.
|
||||
pub fn crop(w: &MobWorld, p: [i32; 3]) -> bool {
|
||||
matches!(
|
||||
w.block_name(p[0], p[1], p[2]),
|
||||
"minecraft:wheat"
|
||||
| "minecraft:carrots"
|
||||
| "minecraft:potatoes"
|
||||
| "minecraft:beetroots"
|
||||
| "minecraft:torchflower_crop"
|
||||
)
|
||||
w.facts
|
||||
.get(w.world.block(p[0], p[1], p[2]))
|
||||
.has_class("CropBlock")
|
||||
}
|
||||
/// `CropBlock.isMaxAge` for the block at `p`.
|
||||
pub fn ripe_crop(w: &MobWorld, p: [i32; 3]) -> bool {
|
||||
let max = match w.block_name(p[0], p[1], p[2]) {
|
||||
"minecraft:wheat" | "minecraft:carrots" | "minecraft:potatoes" => "7",
|
||||
"minecraft:beetroots" => "3",
|
||||
// TorchflowerCropBlock.getMaxAge is 2, past its age property (0-1):
|
||||
// the crop is never at max age (age 2 is the torchflower itself).
|
||||
_ => return false,
|
||||
let facts = w.facts.get(w.world.block(p[0], p[1], p[2]));
|
||||
if !facts.has_class("CropBlock") {
|
||||
return false;
|
||||
}
|
||||
// The class chain survives content registration, including the two
|
||||
// getMaxAge overrides. PitcherCropBlock is not a CropBlock.
|
||||
let max = if facts.has_class("BeetrootBlock") {
|
||||
3
|
||||
} else if facts.has_class("TorchflowerCropBlock") {
|
||||
2
|
||||
} else {
|
||||
7
|
||||
};
|
||||
w.prop(p[0], p[1], p[2], "age") == Some(max)
|
||||
w.prop(p[0], p[1], p[2], "age")
|
||||
.and_then(|age| age.parse::<i32>().ok())
|
||||
.is_some_and(|age| age >= max)
|
||||
}
|
||||
|
||||
/// The gift loot table of a villager (`GiveGiftToHero.getLootTableToThrow`).
|
||||
|
||||
@@ -775,7 +775,7 @@ fn sense_babies(m: &mut Mob, w: &MobWorld) {
|
||||
}
|
||||
mem(m).visible_villager_babies.set_list(babies);
|
||||
}
|
||||
/// `SecondaryPoiSensor`: a farmer's farmland within four blocks.
|
||||
/// `SecondaryPoiSensor.doTick`: a farmer's farmland within four blocks.
|
||||
fn sense_secondary(m: &mut Mob, w: &MobWorld) {
|
||||
let mut sites = Vec::new();
|
||||
if super::villager::is_farmer(m) {
|
||||
@@ -784,7 +784,7 @@ fn sense_secondary(m: &mut Mob, w: &MobWorld) {
|
||||
for y in -2..=2 {
|
||||
for z in -4..=4 {
|
||||
let p = [c[0] + x, c[1] + y, c[2] + z];
|
||||
if w.block_name(p[0], p[1], p[2]) == "minecraft:farmland" {
|
||||
if farmland(w, p) {
|
||||
sites.push(p);
|
||||
}
|
||||
}
|
||||
@@ -3494,6 +3494,10 @@ fn restock_if_due(m: &mut Mob, w: &MobWorld) {
|
||||
t.restock(w.game_time);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[path = "villager_farming_tests.rs"]
|
||||
mod farming_tests;
|
||||
|
||||
/// `HarvestFarmland.validFarmlandAroundVillager`: ripe crops and bare
|
||||
/// farmland around the villager.
|
||||
fn farmland_around(m: &Mob, w: &MobWorld) -> Vec<[i32; 3]> {
|
||||
@@ -3514,10 +3518,16 @@ fn farmland_around(m: &Mob, w: &MobWorld) -> Vec<[i32; 3]> {
|
||||
}
|
||||
valid
|
||||
}
|
||||
/// Farmland subclasses include mod templates.
|
||||
fn farmland(w: &MobWorld, p: [i32; 3]) -> bool {
|
||||
w.facts
|
||||
.get(w.world.block(p[0], p[1], p[2]))
|
||||
.has_class("FarmlandBlock")
|
||||
}
|
||||
/// `HarvestFarmland.validPos`: a ripe CropBlock or air above farmland.
|
||||
fn farmland_valid(w: &MobWorld, p: [i32; 3]) -> bool {
|
||||
super::villager::ripe_crop(w, p)
|
||||
|| w.facts.is_air(w.world.block(p[0], p[1], p[2]))
|
||||
&& w.block_name(p[0], p[1] - 1, p[2]) == "minecraft:farmland"
|
||||
|| w.facts.is_air(w.world.block(p[0], p[1], p[2])) && farmland(w, [p[0], p[1] - 1, p[2]])
|
||||
}
|
||||
/// `HarvestFarmland.getValidFarmland`: one picked by the level random.
|
||||
fn pick_farmland(valid: &[[i32; 3]], cx: &mut Bx) -> Option<[i32; 3]> {
|
||||
@@ -3527,17 +3537,13 @@ fn pick_farmland(valid: &[[i32; 3]], cx: &mut Bx) -> Option<[i32; 3]> {
|
||||
let i = cx.lr.next_int_bound(valid.len() as i32).unwrap_or(0) as usize;
|
||||
Some(valid[i])
|
||||
}
|
||||
/// The seeds a villager plants (`VILLAGER_PLANTABLE_SEEDS`) and their crops.
|
||||
fn seed_crop(item: &str) -> Option<&'static str> {
|
||||
Some(match item {
|
||||
"minecraft:wheat_seeds" => "minecraft:wheat",
|
||||
"minecraft:potato" => "minecraft:potatoes",
|
||||
"minecraft:carrot" => "minecraft:carrots",
|
||||
"minecraft:beetroot_seeds" => "minecraft:beetroots",
|
||||
"minecraft:torchflower_seeds" => "minecraft:torchflower_crop",
|
||||
"minecraft:pitcher_pod" => "minecraft:pitcher_crop",
|
||||
_ => return None,
|
||||
})
|
||||
/// A tagged BlockItem plants its block's default state.
|
||||
fn seed_crop(w: &MobWorld, item: &str) -> Option<horizon_world::registry::BlockStateId> {
|
||||
let items = w.items?;
|
||||
if !items.item_in_tag("minecraft:villager_plantable_seeds", item) {
|
||||
return None;
|
||||
}
|
||||
items.get(item)?.block
|
||||
}
|
||||
/// `HarvestFarmland.tick` at the farmland: harvest a ripe crop, sow bare
|
||||
/// farmland, and move on from a growing crop.
|
||||
@@ -3559,23 +3565,22 @@ fn harvest_tick(
|
||||
if ripe {
|
||||
cx.events.push(MobEvent::BreakBlock { pos: p, drop: true });
|
||||
}
|
||||
if air && w.block_name(p[0], p[1] - 1, p[2]) == "minecraft:farmland" {
|
||||
if air && farmland(w, [p[0], p[1] - 1, p[2]]) {
|
||||
let e = super::villager::ext_mut(m);
|
||||
if let Some(slot) = e
|
||||
if let Some((slot, crop)) = e
|
||||
.inventory
|
||||
.iter()
|
||||
.position(|s| s.count > 0 && seed_crop(&s.item).is_some())
|
||||
.enumerate()
|
||||
.filter(|(_, s)| s.count > 0)
|
||||
.find_map(|(i, s)| seed_crop(w, &s.item).map(|crop| (i, crop)))
|
||||
{
|
||||
let crop = seed_crop(&e.inventory[slot].item).unwrap_or("minecraft:wheat");
|
||||
e.inventory[slot].count -= 1;
|
||||
if e.inventory[slot].count == 0 {
|
||||
e.inventory.remove(slot);
|
||||
}
|
||||
cx.events.push(MobEvent::SetBlockName {
|
||||
cx.events.push(MobEvent::SetBlock {
|
||||
pos: p,
|
||||
name: crop,
|
||||
effect: false,
|
||||
flags: horizon_world::level::flags::UPDATE_ALL,
|
||||
state: crop,
|
||||
});
|
||||
cx.events.push(MobEvent::Sound(
|
||||
"item.crop.plant",
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
use super::*;
|
||||
use crate::{items::ItemStack, mod_content::ContentMod};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{Arc, OnceLock},
|
||||
};
|
||||
|
||||
fn resources() -> &'static std::path::Path {
|
||||
static ROOT: OnceLock<PathBuf> = OnceLock::new();
|
||||
ROOT.get_or_init(|| {
|
||||
let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
let scratch = std::env::temp_dir().join(format!("fresh-farming-{}", std::process::id()));
|
||||
let dir = scratch.join("mod");
|
||||
let mut files = vec![(
|
||||
"horizon-mod.toml".to_owned(),
|
||||
"id = \"fieldtest\"\nname = \"Field test\"\nversion = \"1.0\"\napi = \"0.2\"\nside = \"both\"\ncapabilities = [\"registries\"]\n".to_owned(),
|
||||
)];
|
||||
for (id, template) in [
|
||||
("soil", "farmland"),
|
||||
("grain", "wheat"),
|
||||
("roots", "beetroots"),
|
||||
("flower", "torchflower_crop"),
|
||||
("pod", "pitcher_crop"),
|
||||
("fake_crop", "stone"),
|
||||
] {
|
||||
files.push((
|
||||
format!("data/fieldtest/horizon/block/{id}.json"),
|
||||
format!(r#"{{"template":"minecraft:{template}"}}"#),
|
||||
));
|
||||
}
|
||||
files.push((
|
||||
"data/minecraft/tags/item/villager_plantable_seeds.json".into(),
|
||||
r#"{"replace":true,"values":["fieldtest:grain","fieldtest:roots","minecraft:stick"]}"#.into(),
|
||||
));
|
||||
for (name, text) in files {
|
||||
let path = dir.join(name);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, text).unwrap();
|
||||
}
|
||||
let package = horizon_mods::Package::read(&dir).unwrap();
|
||||
let content = horizon_mods::Content::read(&dir, &package.manifest).unwrap();
|
||||
let out = scratch.join("resources");
|
||||
crate::mod_content::build(&base, &[ContentMod { id: "fieldtest".into(), content }], &out)
|
||||
.unwrap();
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
fn farmer(types: &super::super::MobTypes) -> Mob {
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
Arc::new(types.types["minecraft:villager"].clone()),
|
||||
Vec3::new(0.5, 1.0, 0.5),
|
||||
0.0,
|
||||
);
|
||||
super::super::villager::set_profession(&mut m, 5);
|
||||
m
|
||||
}
|
||||
|
||||
fn harvest() -> B {
|
||||
B::HarvestFarmland {
|
||||
t: timed(60, 60),
|
||||
next_ok: 0,
|
||||
target: None,
|
||||
valid: Vec::new(),
|
||||
worked: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn put(
|
||||
world: &mut crate::world::World,
|
||||
x: i32,
|
||||
y: i32,
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
) {
|
||||
if !world.chunks.contains_key(&(x >> 4, 0)) {
|
||||
world
|
||||
.chunks
|
||||
.insert((x >> 4, 0), Arc::new(crate::chunk::Chunk::empty(x >> 4, 0)));
|
||||
}
|
||||
world.set(x, y, 0, state).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "full content root: tools/test_villager_farming_parity.py runs it"]
|
||||
fn mod_farmland_is_a_secondary_site_and_farming_target() {
|
||||
assert!(super::super::world::with_resource_test_world(
|
||||
resources(),
|
||||
&[],
|
||||
true,
|
||||
|world, reg| {
|
||||
put(world, 0, 0, reg.default_state("fieldtest:soil").unwrap());
|
||||
},
|
||||
|w, types| {
|
||||
let mut m = farmer(types);
|
||||
sense_secondary(&mut m, w);
|
||||
assert_eq!(
|
||||
mem(&mut m).secondary_job_site.value(),
|
||||
Some(vec![[0, 0, 0]])
|
||||
);
|
||||
let mut lr = LegacyRandom::new(11);
|
||||
let mut events = Vec::new();
|
||||
let mut cx = Bx {
|
||||
w,
|
||||
lr: &mut lr,
|
||||
events: &mut events,
|
||||
now: 1000,
|
||||
refresh: false,
|
||||
};
|
||||
assert!(harvest().try_start(&mut m, &mut cx));
|
||||
let mut denied = w.clone();
|
||||
denied.mob_griefing = false;
|
||||
mem(&mut m).look_target.clear();
|
||||
mem(&mut m).walk_target.clear();
|
||||
cx.w = &denied;
|
||||
assert!(!harvest().try_start(&mut m, &mut cx));
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "full content root: tools/test_villager_farming_parity.py runs it"]
|
||||
fn mod_crops_are_harvested_only_at_their_vanilla_templates_max_age() {
|
||||
assert!(super::super::world::with_resource_test_world(
|
||||
resources(),
|
||||
&[],
|
||||
true,
|
||||
|world, reg| {
|
||||
for (x, block, age) in [
|
||||
(0, "grain", "6"),
|
||||
(1, "grain", "7"),
|
||||
(2, "roots", "3"),
|
||||
(3, "flower", "1"),
|
||||
(4, "pod", "4"),
|
||||
] {
|
||||
let mut state = reg
|
||||
.state(reg.default_state(&format!("fieldtest:{block}")).unwrap())
|
||||
.unwrap()
|
||||
.clone();
|
||||
state.properties.insert("age".into(), age.into());
|
||||
put(world, x, 1, reg.resolve(&state).unwrap());
|
||||
}
|
||||
put(
|
||||
world,
|
||||
5,
|
||||
1,
|
||||
reg.default_state("fieldtest:fake_crop").unwrap(),
|
||||
);
|
||||
},
|
||||
|w, types| {
|
||||
let mut m = farmer(types);
|
||||
let mut lr = LegacyRandom::new(12);
|
||||
let mut events = Vec::new();
|
||||
for (x, ripe) in [
|
||||
(0, false),
|
||||
(1, true),
|
||||
(2, true),
|
||||
(3, false),
|
||||
(4, false),
|
||||
(5, false),
|
||||
] {
|
||||
events.clear();
|
||||
harvest_tick(
|
||||
&mut m,
|
||||
&mut Bx {
|
||||
w,
|
||||
lr: &mut lr,
|
||||
events: &mut events,
|
||||
now: 1000,
|
||||
refresh: false,
|
||||
},
|
||||
&mut 0,
|
||||
&mut Some([x, 1, 0]),
|
||||
&mut Vec::new(),
|
||||
);
|
||||
assert_eq!(events.iter().any(|e| matches!(e, MobEvent::BreakBlock { pos, drop: true } if *pos == [x, 1, 0])), ripe, "x={x}");
|
||||
}
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "full content root: tools/test_villager_farming_parity.py runs it"]
|
||||
fn farming_uses_the_first_tagged_block_item_and_consumes_one_seed() {
|
||||
assert!(super::super::world::with_resource_test_world(
|
||||
resources(),
|
||||
&[],
|
||||
true,
|
||||
|world, reg| {
|
||||
for x in 0..=1 {
|
||||
put(world, x, 0, reg.default_state("fieldtest:soil").unwrap());
|
||||
}
|
||||
},
|
||||
|w, types| {
|
||||
let mut m = farmer(types);
|
||||
let inventory = &mut super::super::villager::ext_mut(&mut m).inventory;
|
||||
*inventory = vec![
|
||||
ItemStack::new("minecraft:wheat_seeds", 2),
|
||||
ItemStack::new("minecraft:stick", 2),
|
||||
ItemStack::new("fieldtest:flower", 2),
|
||||
ItemStack::new("fieldtest:roots", 1),
|
||||
ItemStack::new("fieldtest:grain", 2),
|
||||
];
|
||||
let mut lr = LegacyRandom::new(13);
|
||||
let mut events = Vec::new();
|
||||
harvest_tick(
|
||||
&mut m,
|
||||
&mut Bx {
|
||||
w,
|
||||
lr: &mut lr,
|
||||
events: &mut events,
|
||||
now: 1000,
|
||||
refresh: false,
|
||||
},
|
||||
&mut 0,
|
||||
&mut Some([0, 1, 0]),
|
||||
&mut Vec::new(),
|
||||
);
|
||||
let planted = events.iter().find_map(|e| match e {
|
||||
MobEvent::SetBlock { state, .. } => {
|
||||
Some(w.registry.state(*state).unwrap().name.as_str())
|
||||
}
|
||||
MobEvent::SetBlockName { name, .. } => Some(*name),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(planted, Some("fieldtest:roots"));
|
||||
let inventory = &super::super::villager::ext(&m).unwrap().inventory;
|
||||
assert_eq!(
|
||||
inventory
|
||||
.iter()
|
||||
.map(|s| (&*s.item, s.count))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
("minecraft:wheat_seeds", 2),
|
||||
("minecraft:stick", 2),
|
||||
("fieldtest:flower", 2),
|
||||
("fieldtest:grain", 2)
|
||||
]
|
||||
);
|
||||
events.clear();
|
||||
harvest_tick(
|
||||
&mut m,
|
||||
&mut Bx {
|
||||
w,
|
||||
lr: &mut lr,
|
||||
events: &mut events,
|
||||
now: 1001,
|
||||
refresh: false,
|
||||
},
|
||||
&mut 0,
|
||||
&mut Some([1, 1, 0]),
|
||||
&mut Vec::new(),
|
||||
);
|
||||
let expected = w.registry.default_state("fieldtest:grain").unwrap();
|
||||
assert!(events.iter().any(
|
||||
|e| matches!(e, MobEvent::SetBlock { pos: [1, 1, 0], state } if *state == expected)
|
||||
));
|
||||
assert_eq!(
|
||||
super::super::villager::ext(&m)
|
||||
.unwrap()
|
||||
.inventory
|
||||
.last()
|
||||
.unwrap()
|
||||
.count,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, MobEvent::Sound("item.crop.plant", _, 1.0, 1.0)))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "tools/test_villager_farming_parity.py supplies the vanilla oracle"]
|
||||
fn vanilla_farming_classification_matches_the_oracle() {
|
||||
let path = std::env::var_os("HORIZON_FARMING_ORACLE").expect("vanilla oracle output");
|
||||
let rows: Vec<(u32, Vec<bool>)> = std::fs::read_to_string(path)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(|line| {
|
||||
let mut fields = line.split_whitespace();
|
||||
(
|
||||
fields.next().unwrap().parse().unwrap(),
|
||||
fields.map(|f| f.parse().unwrap()).collect(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert!(!rows.is_empty());
|
||||
assert!(super::super::world::with_built_test_world(
|
||||
&[],
|
||||
|world, _| {
|
||||
for (x, (state, _)) in rows.iter().enumerate() {
|
||||
put(
|
||||
world,
|
||||
x as i32,
|
||||
0,
|
||||
horizon_world::registry::BlockStateId(*state),
|
||||
);
|
||||
}
|
||||
},
|
||||
|w, _| {
|
||||
for (x, (state, expected)) in rows.iter().enumerate() {
|
||||
let p = [x as i32, 0, 0];
|
||||
assert_eq!(
|
||||
vec![
|
||||
super::super::villager::crop(w, p),
|
||||
super::super::villager::ripe_crop(w, p),
|
||||
farmland_valid(w, [p[0], 1, 0])
|
||||
],
|
||||
*expected,
|
||||
"state={state}"
|
||||
);
|
||||
}
|
||||
}
|
||||
));
|
||||
}
|
||||
@@ -417,24 +417,35 @@ pub(crate) fn with_built_test_world_rules(
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
with_resource_test_world(&root, others, mob_griefing, build, f)
|
||||
}
|
||||
/// The same mob world loaded from a content mod's resource root.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_resource_test_world(
|
||||
root: &std::path::Path,
|
||||
others: &[Seen],
|
||||
mob_griefing: bool,
|
||||
build: impl FnOnce(&mut crate::world::World, &horizon_world::registry::BlockRegistry),
|
||||
f: impl FnOnce(&MobWorld<'_>, &super::MobTypes),
|
||||
) -> bool {
|
||||
if !root.join("entity-types.json").exists() || !root.join("movement-properties.nbt").exists() {
|
||||
return false;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let assets = crate::assets::Assets::load(root).unwrap();
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(&root, registry).unwrap();
|
||||
let motion = crate::motion::MotionResources::load(&root, &assets).unwrap();
|
||||
let data = super::MobData::load(&root, &facts);
|
||||
let types = super::MobTypes::load(&root).unwrap();
|
||||
let biomes = crate::biome::Biomes::load(&root).unwrap();
|
||||
let facts = BlockFacts::load(root, registry).unwrap();
|
||||
let motion = crate::motion::MotionResources::load(root, &assets).unwrap();
|
||||
let data = super::MobData::load(root, &facts);
|
||||
let types = super::MobTypes::load(root).unwrap();
|
||||
let biomes = crate::biome::Biomes::load(root).unwrap();
|
||||
let items = crate::items::Items::load(
|
||||
&root,
|
||||
root,
|
||||
registry,
|
||||
&facts,
|
||||
&horizon_world::loot::PackOverlay::NONE,
|
||||
)
|
||||
.unwrap();
|
||||
let collision = crate::collisions::load(&root, registry).unwrap();
|
||||
let collision = crate::collisions::load(root, registry).unwrap();
|
||||
let air = registry.default_state("minecraft:air").unwrap();
|
||||
let mut world = crate::world::World::new(air, 0);
|
||||
build(&mut world, registry);
|
||||
|
||||
+2
-1
@@ -1144,7 +1144,8 @@ pub trait Hooks: Send + Sync {
|
||||
/// the clicked position, whether the clicked block is being replaced, the
|
||||
/// item in hand) and `fluid` the fluid in the position the block would go
|
||||
/// in, which a waterlogged block reads. Build the state from `state` with
|
||||
/// `native_state_with_property`. It is asked for every placer that goes
|
||||
/// `native_state_with_property`, and know whether `state` is yours with
|
||||
/// `native_state_block`. It is asked for every placer that goes
|
||||
/// through vanilla's placement (a player's `BlockItem.place`, a
|
||||
/// dispenser's `DirectionalPlaceContext`), and the answer still takes its
|
||||
/// survival and entity obstruction checks. A trait mod returns
|
||||
|
||||
@@ -344,6 +344,10 @@ pub struct PlayerMotion {
|
||||
pub local_crouching: bool,
|
||||
pub pose: crate::pose::Pose,
|
||||
pub dimensions: crate::pose::Dimensions,
|
||||
/// The `minecraft:scale` the dimensions were last built with
|
||||
/// (`Entity.refreshDimensions`), for the poses the body takes before it
|
||||
/// next refreshes.
|
||||
pub scale: f32,
|
||||
pub speed: f32,
|
||||
/// `LocalPlayer.itemUseSpeedMultiplier` while using an item (not riding).
|
||||
pub item_use_speed: Option<f32>,
|
||||
@@ -428,6 +432,16 @@ fn attribute_error(error: impl std::fmt::Debug) -> io::Error {
|
||||
io::Error::other(format!("movement attribute: {error:?}"))
|
||||
}
|
||||
impl PlayerMotion {
|
||||
/// `ServerGamePacketListenerImpl.handleMovePlayer`: the movement the client
|
||||
/// sent is the server copy's own delta movement. A glide's wall hit reads it
|
||||
/// as the speed the glide had (`LivingEntity.handleFallFlyingCollisions`'s
|
||||
/// moveHorLength); a copy whose travels start from its own last velocity
|
||||
/// otherwise keeps a few hundredths there, and no hit reaches the damage.
|
||||
pub fn carry_client_movement(&mut self, movement: Vec3) {
|
||||
if self.fall_flying {
|
||||
self.velocity = movement;
|
||||
}
|
||||
}
|
||||
/// `Player.isSweepAttack`, using the resident player's known horizontal velocity.
|
||||
pub fn is_sweep_attack(&self, strength: f32, critical: bool, sword: bool) -> bool {
|
||||
let movement = self.velocity;
|
||||
@@ -559,6 +573,7 @@ impl PlayerMotion {
|
||||
local_crouching: false,
|
||||
pose: crate::pose::Pose::Standing,
|
||||
dimensions: crate::pose::Pose::Standing.dimensions(1.0),
|
||||
scale: 1.0,
|
||||
swimming: false,
|
||||
is_passenger: false,
|
||||
fire_ticks: -20,
|
||||
@@ -717,6 +732,18 @@ impl PlayerMotion {
|
||||
/// player's `attributes` list (LivingEntity.addAdditionalSaveData), each
|
||||
/// instance's id, base and permanent modifiers.
|
||||
pub fn saved_attributes(&self) -> horizon_world::nbt::Tag {
|
||||
self.packed_attributes(true)
|
||||
}
|
||||
/// `ServerPlayer.restoreFrom`: the attributes a player's new body takes
|
||||
/// over from the old one. A body that comes back from a death takes the
|
||||
/// base values only (`assignBaseValues`); the same player in another
|
||||
/// level (`ServerPlayer.teleport`) is the same entity, with its permanent
|
||||
/// modifiers too (`restoreAll`, `assignPermanentModifiers`). A scale of
|
||||
/// 0.1 is still 0.1 after a respawn or a trip through a portal (#3684).
|
||||
pub fn carried_attributes(&self, restore_all: bool) -> horizon_world::nbt::Tag {
|
||||
self.packed_attributes(restore_all)
|
||||
}
|
||||
fn packed_attributes(&self, modifiers: bool) -> horizon_world::nbt::Tag {
|
||||
use horizon_client_core::nbt_value::string;
|
||||
use horizon_world::nbt::Tag;
|
||||
let list = self
|
||||
@@ -729,7 +756,7 @@ impl PlayerMotion {
|
||||
("base", Tag::Double(packed.base.to_bits())),
|
||||
];
|
||||
// `modifiers` is an optional field whose default is the empty list.
|
||||
if !packed.modifiers.is_empty() {
|
||||
if modifiers && !packed.modifiers.is_empty() {
|
||||
let modifiers = packed
|
||||
.modifiers
|
||||
.iter()
|
||||
@@ -1035,9 +1062,9 @@ impl MotionWorld<'_, '_, '_> {
|
||||
if self.player.spectator {
|
||||
self.player.flying = true;
|
||||
self.player.ground.on_ground = false;
|
||||
} else if !self.player.may_fly {
|
||||
self.player.flying = false;
|
||||
} else if !was_jumping && jumping && !self.player.was_auto_jump {
|
||||
} else if self.player.may_fly && !was_jumping && jumping && !self.player.was_auto_jump {
|
||||
// `LocalPlayer.aiStep`: mayfly gates the flight toggle, not the
|
||||
// flying state independently supplied by the server's abilities.
|
||||
if self.player.jump_trigger_time == 0 {
|
||||
self.player.jump_trigger_time = 7;
|
||||
} else if !self.player.swimming {
|
||||
@@ -3430,6 +3457,60 @@ mod tests {
|
||||
assert!(player.events.is_empty());
|
||||
}
|
||||
|
||||
/// `LivingEntity.handleFallFlyingCollisions`: a glide that meets a wall loses
|
||||
/// the horizontal speed it had, and the server's copy hurts by that loss,
|
||||
/// `(moveHorLength - newMoveHorLength) * 10 - 3`, as `minecraft:fly_into_wall`.
|
||||
/// The speed it starts the tick with is the client's own movement
|
||||
/// (`handleMovePlayer` gives the server copy its delta movement), so the
|
||||
/// loss is the speed the glide actually flew at.
|
||||
#[test]
|
||||
fn a_glide_into_a_wall_hurts_by_the_speed_it_lost() {
|
||||
let root = 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 = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
let mut scene = World::test_scene(&assets, &render, 1).unwrap();
|
||||
// A wall in the way of a glide along +x, at feet height.
|
||||
let stone = assets.registry.default_state("minecraft:stone").unwrap();
|
||||
for y in 4..=5 {
|
||||
for z in 19..=21 {
|
||||
scene.set(23, y, z, stone).unwrap();
|
||||
}
|
||||
}
|
||||
let mut player = PlayerMotion::new(&resources, Vec3::new(22.0, 4.0, 20.5)).unwrap();
|
||||
player.fall_flying = true;
|
||||
// Looking along +x (the `getLookAngle` of a flat -90 yaw) keeps the
|
||||
// glide on its course through `updateFallFlyingMovement`'s pull.
|
||||
player.yaw = -90.0;
|
||||
player.pitch = 0.0;
|
||||
player.carry_client_movement(Vec3::new(1.2, 0.0, 0.0));
|
||||
MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
}
|
||||
.server_travel()
|
||||
.unwrap();
|
||||
assert!(
|
||||
player
|
||||
.hurt_log
|
||||
.last()
|
||||
.is_some_and(|hurt| hurt.source == "minecraft:fly_into_wall"),
|
||||
"{:?}",
|
||||
player.hurt_log
|
||||
);
|
||||
assert!(player.health.health() < 20.0, "{}", player.health.health());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spectator_scroll_speed_controls_horizontal_and_vertical_flight() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -5166,6 +5247,60 @@ mod tests {
|
||||
assert!(w.player.sprinting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_forced_flight_survives_without_permission_to_toggle_it() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
eprintln!("skipped forced flight: prepared resources unavailable");
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
let mut scene = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let start = Vec3::new(20.5, 20.0, 20.5);
|
||||
let mut player = PlayerMotion::new(&resources, start).unwrap();
|
||||
player.may_fly = false;
|
||||
player.flying = true;
|
||||
player.flying_speed = 0.0;
|
||||
let mut w = MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
};
|
||||
// Idle, movement in all directions, and repeated jump presses must
|
||||
// neither cancel server-forced flight nor move a zero-speed flyer.
|
||||
for tick in 0..60 {
|
||||
w.controlled_simulation_tick(crate::sprint::Keys {
|
||||
forward: tick % 4 == 0,
|
||||
backward: tick % 4 == 1,
|
||||
left: tick % 4 == 2,
|
||||
right: tick % 4 == 3,
|
||||
jump: tick % 2 == 0,
|
||||
shift: tick % 3 == 0,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert!(w.player.flying, "server-forced flight ended on tick {tick}");
|
||||
assert_eq!(w.player.position, start);
|
||||
assert_eq!(w.player.velocity, Vec3::ZERO);
|
||||
}
|
||||
// A later abilities update from the server can still end flight.
|
||||
w.player.flying = false;
|
||||
for _ in 0..3 {
|
||||
w.controlled_simulation_tick(crate::sprint::Keys::default())
|
||||
.unwrap();
|
||||
}
|
||||
assert!(!w.player.flying);
|
||||
assert!(w.player.position.y < start.y);
|
||||
}
|
||||
|
||||
/// Vanilla `Player.getFlyingSpeed`: a flying player moves at the ability
|
||||
/// speed, doubled while the sprint state holds. The sprint key starts the
|
||||
/// state; releasing it does not change the speed.
|
||||
|
||||
@@ -94,7 +94,8 @@ impl PlayerMotion {
|
||||
Ok(pose.dimensions(self.attribute("minecraft:scale")? as f32))
|
||||
}
|
||||
pub fn refresh_dimensions(&mut self) -> Result<()> {
|
||||
self.dimensions = self.dimensions_for_pose(self.pose)?;
|
||||
self.scale = self.attribute("minecraft:scale")? as f32;
|
||||
self.dimensions = self.pose.dimensions(self.scale);
|
||||
Ok(())
|
||||
}
|
||||
pub fn set_pose(&mut self, pose: Pose) -> Result<()> {
|
||||
|
||||
@@ -717,14 +717,13 @@ impl RecipeIndex {
|
||||
// The packs' files replace first and the gate answers the merged
|
||||
// file, as the crafting loader's does: a false-conditioned pack
|
||||
// file leaves the lower one gone too.
|
||||
let mut sources: Vec<(String, serde_json::Value)> = crate::crafting::recipe_files(&dir)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|(name, path)| {
|
||||
let v = std::fs::read(&path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())?;
|
||||
Some((format!("minecraft:{name}"), v))
|
||||
let folder = crate::json_folder::read(&dir).ok();
|
||||
let mut sources: Vec<(String, serde_json::Value)> = folder
|
||||
.iter()
|
||||
.flat_map(|folder| &folder.files)
|
||||
.filter_map(|file| {
|
||||
let v = serde_json::from_slice::<serde_json::Value>(file.bytes().ok()?).ok()?;
|
||||
Some((format!("minecraft:{}", file.name), v))
|
||||
})
|
||||
.collect();
|
||||
for (id, files) in overlay.kind("recipe") {
|
||||
|
||||
@@ -503,18 +503,16 @@ impl StationRecipes {
|
||||
};
|
||||
for namespace in namespaces {
|
||||
let dir = root.join(format!("data/{namespace}/recipe"));
|
||||
let more = crate::crafting::recipe_files(&dir).unwrap_or_default();
|
||||
files.extend(
|
||||
more.into_iter()
|
||||
.map(|(name, p)| (namespace.clone(), name, p)),
|
||||
);
|
||||
if let Ok(folder) = crate::json_folder::read(&dir) {
|
||||
files.push((namespace, folder));
|
||||
}
|
||||
}
|
||||
let mut sources: Vec<(String, Value)> = files
|
||||
.iter()
|
||||
.filter_map(|(namespace, name, p)| {
|
||||
let bytes = fs::read(p).ok()?;
|
||||
let v = serde_json::from_slice::<Value>(&bytes).ok()?;
|
||||
Some((format!("{namespace}:{name}"), v))
|
||||
.flat_map(|(namespace, folder)| folder.files.iter().map(move |f| (namespace, f)))
|
||||
.filter_map(|(namespace, file)| {
|
||||
let v = serde_json::from_slice::<Value>(file.bytes().ok()?).ok()?;
|
||||
Some((format!("{namespace}:{}", file.name), v))
|
||||
})
|
||||
.collect();
|
||||
// The world's packs' recipes over the game's own: the top pack's
|
||||
|
||||
@@ -470,7 +470,8 @@ pub(crate) struct Button {
|
||||
enabled: bool,
|
||||
id: u8,
|
||||
icon: Option<(&'static str, f32)>,
|
||||
/// `SpriteIconButton.TextAndIcon`: the icon at the right end, the text centred.
|
||||
/// `SpriteIconButton.TextAndIcon`: the icon at the right end, the text
|
||||
/// centred in the room left of it.
|
||||
trailing: Option<(&'static str, f32)>,
|
||||
}
|
||||
impl Button {
|
||||
@@ -479,17 +480,36 @@ impl Button {
|
||||
/// (`AbstractWidget.extractScrollingStringOverContents`), instead of being
|
||||
/// drawn past it over the buttons beside it (so_so's 590 px World Type
|
||||
/// label on Create World, hi_in's Join Server in the Multiplayer footer).
|
||||
/// A button with a `trailing` icon is `SpriteIconButton.TextAndIcon`, whose
|
||||
/// label keeps clear of the icon: see [`Button::draw_label_at`].
|
||||
fn draw_label(&self, batch: &mut HudBatch, atlas: &HudAtlas, color: u32) {
|
||||
let label = Text::literal(&self.label);
|
||||
crate::ui::scrolling_text(
|
||||
self.draw_label_at(batch, atlas, color, crate::hud_overlays::now_ms());
|
||||
}
|
||||
|
||||
/// [`Button::draw_label`] at `now_ms`. `SpriteIconButton.TextAndIcon`
|
||||
/// (`extractContents`) hands the label to `acceptScrolling` with `left`
|
||||
/// at the button's x plus 2, `right` at its x plus width minus the
|
||||
/// sprite's width minus 4, and `centerX` at its x plus half its width: the
|
||||
/// label slides inside that box when wider, and one that fits is moved
|
||||
/// left until it ends where the icon's room begins. A button without an
|
||||
/// icon has `right` 2 short of its edge, where `centerX` is the middle of
|
||||
/// the box and nothing moves.
|
||||
fn draw_label_at(&self, batch: &mut HudBatch, atlas: &HudAtlas, color: u32, now_ms: i64) {
|
||||
let right = match self.trailing {
|
||||
Some((_, size)) => self.x + self.w - size - 4.0,
|
||||
None => self.x + self.w - 2.0,
|
||||
};
|
||||
crate::ui::scrolling_text_around_at(
|
||||
batch,
|
||||
atlas,
|
||||
&label,
|
||||
&Text::literal(&self.label),
|
||||
self.x + (self.w / 2.0).floor(),
|
||||
self.x + 2.0,
|
||||
self.x + self.w - 2.0,
|
||||
right,
|
||||
self.y,
|
||||
self.y + 20.0,
|
||||
self.y + 6.0,
|
||||
now_ms,
|
||||
color,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -535,15 +535,14 @@ impl Title {
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
// The label sits 3 pixels lower on unselected tabs.
|
||||
let top = if selected { 0.0 } else { 3.0 };
|
||||
batch.text_styled(
|
||||
ui::menu_tab_label(
|
||||
batch,
|
||||
atlas,
|
||||
&label,
|
||||
x + (tw / 2.0).floor() - (lw / 2.0).floor(),
|
||||
((top + TAB_BAR - 9.0) / 2.0).floor() + 1.0,
|
||||
u32::MAX,
|
||||
(x, 0.0, tw, TAB_BAR),
|
||||
selected,
|
||||
true,
|
||||
crate::hud_overlays::now_ms(),
|
||||
);
|
||||
}
|
||||
let (gx, gy) = grid(w, h, o.tab);
|
||||
|
||||
@@ -2074,3 +2074,148 @@ fn labels_at(f: &Fixture) -> (f32, f32, (f32, f32)) {
|
||||
(customize.x + 10.0, customize.y + 10.0),
|
||||
)
|
||||
}
|
||||
|
||||
/// A button with an icon at its right end (`SpriteIconButton.TextAndIcon`, the
|
||||
/// Music & Sounds button of the Accessibility screen): `extractContents` gives
|
||||
/// the label `left = x + 2` and `right = x + width - spriteWidth - 4`.
|
||||
mod text_and_icon {
|
||||
use super::*;
|
||||
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
fn button(label: &str, icon: Option<(&'static str, f32)>) -> Button {
|
||||
Button {
|
||||
x: 30.0,
|
||||
y: 40.0,
|
||||
w: 150.0,
|
||||
label: label.into(),
|
||||
enabled: true,
|
||||
id: 0,
|
||||
icon: None,
|
||||
trailing: icon,
|
||||
}
|
||||
}
|
||||
/// The x of every vertex of the label's text.
|
||||
fn xs(batch: &HudBatch) -> Vec<f32> {
|
||||
batch
|
||||
.vertices
|
||||
.iter()
|
||||
.filter(|v| v.text == 1)
|
||||
.map(|v| v.pos[0])
|
||||
.collect()
|
||||
}
|
||||
/// The first `n` letters of a text with no repeating pattern.
|
||||
fn label(n: usize) -> String {
|
||||
"Bezpostrzydnie połōnczynie Direct Connection Join Server"
|
||||
.chars()
|
||||
.take(n)
|
||||
.collect()
|
||||
}
|
||||
fn width(atlas: &HudAtlas, label: &str) -> i32 {
|
||||
HudBatch::text_width(atlas, label).ceil() as i32
|
||||
}
|
||||
|
||||
/// A label that fits beside the icon but, centred on the button, would end
|
||||
/// under it (`Settings sa Aksesibilidad...` in `fil_ph`, and about 40 other
|
||||
/// languages) is moved left until it ends where the icon's room begins:
|
||||
/// `Mth.clamp(centerX, left + lineWidth / 2, right - lineWidth / 2)` in
|
||||
/// `defaultScrollingHelper`. It does not scroll. The same label in a plain
|
||||
/// button stays centred.
|
||||
#[test]
|
||||
fn a_label_that_fits_beside_the_icon_is_moved_clear_of_it() {
|
||||
let atlas = atlas();
|
||||
// A 16 px icon: `left = x + 2`, `right = x + 130`, `centerX = x + 75`.
|
||||
let (left, right, center) = (32, 30 + 130, 30 + 75);
|
||||
// The widest label that fits; `right - width / 2 < centerX` by most.
|
||||
let text = (1..60)
|
||||
.map(label)
|
||||
.rfind(|t| width(&atlas, t) <= right - left)
|
||||
.expect("a label that fits");
|
||||
let half = width(&atlas, &text) / 2;
|
||||
assert!(right - half < center, "{text}: {}", width(&atlas, &text));
|
||||
let drawn_from = |from: i32| {
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
batch.text(&atlas, &text, from as f32, 46.0, u32::MAX, true);
|
||||
xs(&batch)
|
||||
};
|
||||
let draw = |b: &Button, now_ms: i64| {
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
b.draw_label_at(&mut batch, &atlas, u32::MAX, now_ms);
|
||||
xs(&batch)
|
||||
};
|
||||
let b = button(&text, Some(("icon/music_notes", 16.0)));
|
||||
// Vanilla: `textX = right - width / 2`, drawn from `textX - width / 2`.
|
||||
assert!(draw(&b, 0) == drawn_from(right - half - half), "{text}");
|
||||
for now_ms in [700, 1500, 31_337] {
|
||||
assert!(draw(&b, now_ms) == draw(&b, 0), "it scrolls at {now_ms}");
|
||||
}
|
||||
let plain = button(&text, None);
|
||||
assert!(draw(&plain, 0) == drawn_from(center - half), "plain {text}");
|
||||
}
|
||||
|
||||
/// A label wider than the room beside the icon scrolls inside
|
||||
/// `x + 2 ..= x + width - spriteWidth - 4`, never under the icon.
|
||||
#[test]
|
||||
fn a_label_wider_than_the_room_beside_the_icon_scrolls_clear_of_it() {
|
||||
let atlas = atlas();
|
||||
let text = label(40);
|
||||
let b = button(&text, Some(("icon/music_notes", 16.0)));
|
||||
assert!(width(&atlas, &text) > 130 - 2);
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for now_ms in (0..40_000).step_by(250) {
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
b.draw_label_at(&mut batch, &atlas, u32::MAX, now_ms);
|
||||
let xs = xs(&batch);
|
||||
assert!(!xs.is_empty());
|
||||
for x in &xs {
|
||||
assert!(
|
||||
(32.0..=30.0 + 150.0 - 16.0 - 4.0).contains(x),
|
||||
"{x} at {now_ms} ms"
|
||||
);
|
||||
}
|
||||
seen.insert(xs.iter().map(|x| x.to_bits()).collect::<Vec<_>>());
|
||||
}
|
||||
assert!(seen.len() > 3, "the label hardly moves");
|
||||
}
|
||||
}
|
||||
|
||||
/// `MenuTabBar.MenuTabButton.renderLabel`: a tab's label slides inside the
|
||||
/// tab's box with a 1 px margin when it is wider than the tab (here a window
|
||||
/// too narrow for the English labels), instead of running into the next tab.
|
||||
#[test]
|
||||
fn a_create_world_tab_label_wider_than_its_tab_stays_inside_it() {
|
||||
let mut f = Fixture::new();
|
||||
f.click("Singleplayer");
|
||||
let atlas = HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
|
||||
// 90 px wide: three 20 px tabs from x = 14, too narrow for "Game".
|
||||
let mut batch = HudBatch::new([90, 262]);
|
||||
f.title.draw(&atlas, &mut batch, (0.0, 100.0), true);
|
||||
let labels: Vec<f32> = batch
|
||||
.vertices
|
||||
.iter()
|
||||
.filter(|v| v.text == 1 && v.pos[1] < 24.0)
|
||||
.map(|v| v.pos[0])
|
||||
.collect();
|
||||
assert!(!labels.is_empty());
|
||||
let boxes: Vec<(f32, f32)> = (0..3)
|
||||
.map(|tab| {
|
||||
(
|
||||
14.0 + 20.0 * tab as f32 + 1.0,
|
||||
14.0 + 20.0 * (tab + 1) as f32 - 1.0,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for x in &labels {
|
||||
assert!(
|
||||
boxes.iter().any(|(lo, hi)| (*lo..=*hi).contains(x)),
|
||||
"label vertex at {x} is outside every tab's box"
|
||||
);
|
||||
}
|
||||
for (tab, (lo, hi)) in boxes.iter().enumerate() {
|
||||
assert!(
|
||||
labels.iter().any(|x| (*lo..=*hi).contains(x)),
|
||||
"tab {tab} draws no label"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,23 @@ impl PalettedContainer {
|
||||
Palette::Global => self.raw(i) as u32,
|
||||
}
|
||||
}
|
||||
/// `get` of the entries `start..start + out.len()`, the palette matched once.
|
||||
#[inline]
|
||||
pub fn get_run(&self, start: usize, out: &mut [u32]) {
|
||||
match &self.palette {
|
||||
Palette::Single(value) => out.fill(*value),
|
||||
Palette::List(list) => {
|
||||
for (k, cell) in out.iter_mut().enumerate() {
|
||||
*cell = list[self.raw(start + k)];
|
||||
}
|
||||
}
|
||||
Palette::Global => {
|
||||
for (k, cell) in out.iter_mut().enumerate() {
|
||||
*cell = self.raw(start + k) as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The value when every entry is equal, without the palette having to be
|
||||
/// minimal.
|
||||
pub fn uniform(&self) -> Option<u32> {
|
||||
@@ -736,6 +753,26 @@ mod tests {
|
||||
*state
|
||||
}
|
||||
#[test]
|
||||
fn runs_read_what_get_reads() {
|
||||
let mut state = 0x9E37_79B9_7F4A_7C15;
|
||||
// One value, a list palette, and the global palette.
|
||||
for distinct in [1u64, 5, 3000] {
|
||||
let values: Vec<u32> = (0..4096)
|
||||
.map(|_| (rng(&mut state) % distinct) as u32)
|
||||
.collect();
|
||||
let container = PalettedContainer::from_values(BLOCKS, &values);
|
||||
for _ in 0..200 {
|
||||
let start = (rng(&mut state) % 4096) as usize;
|
||||
let len = (rng(&mut state) % 20) as usize;
|
||||
let len = len.min(4096 - start);
|
||||
let mut run = vec![u32::MAX; len];
|
||||
container.get_run(start, &mut run);
|
||||
let expected: Vec<u32> = (start..start + len).map(|i| container.get(i)).collect();
|
||||
assert_eq!(run, expected, "{distinct} values, {start}+{len}");
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn containers_stored_alike_are_the_same() {
|
||||
let values: Vec<u32> = (0..4096).map(|i| (i % 7) as u32).collect();
|
||||
let a = PalettedContainer::from_values(BLOCKS, &values);
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Where packs keep their custom entity models and how a game reads one: the `.jem` files of
|
||||
//! `emf/cem/` and `optifine/cem/` by model name, each read under [`MAX_BYTES`], parsed, its
|
||||
//! warnings logged; and the atlas name of a model's texture. Shared by the mobs' and the block
|
||||
//! entities' models.
|
||||
|
||||
use super::model::{EntityModel, MAX_BYTES, parse_jem};
|
||||
use std::{collections::BTreeMap, fs, path::Path};
|
||||
|
||||
/// Every `.jem` of the CEM folders by model name, sorted, each name's files best first:
|
||||
/// `emf/cem` before `optifine/cem` whichever form either uses, and within one folder the flat
|
||||
/// `<name>.jem` before `<name>/<name>.jem`.
|
||||
pub fn jem_files(root: &Path) -> BTreeMap<String, Vec<String>> {
|
||||
let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
for base in ["assets/minecraft/emf/cem", "assets/minecraft/optifine/cem"] {
|
||||
let Ok(entries) = fs::read_dir(root.join(base)) else {
|
||||
continue;
|
||||
};
|
||||
let (mut flat, mut nested) = (Vec::new(), Vec::new());
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if let Some(model) = name.strip_suffix(".jem") {
|
||||
// A directory named `x.jem` isn't a model.
|
||||
if e.path().is_file() {
|
||||
flat.push((model.to_owned(), format!("{base}/{name}")));
|
||||
}
|
||||
} else {
|
||||
let path = format!("{base}/{name}/{name}.jem");
|
||||
if root.join(&path).is_file() {
|
||||
nested.push((name, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (model, path) in flat.into_iter().chain(nested) {
|
||||
out.entry(model).or_default().push(path);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The `.jem` at `path`, its warnings logged; None, with a warning, when it can't be read or
|
||||
/// parsed.
|
||||
pub fn load_jem(root: &Path, path: &str) -> Option<EntityModel> {
|
||||
let Some(text) = read_small(&root.join(path)) else {
|
||||
log::warn!("Horizon: custom entity model {path}: unreadable or over {MAX_BYTES} bytes");
|
||||
return None;
|
||||
};
|
||||
let jem = match parse_jem(path, &text, &mut |p: &str| read_small(&root.join(p))) {
|
||||
Ok(jem) => jem,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: custom entity model {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
for warning in &jem.warnings {
|
||||
log::warn!("Horizon: custom entity model {warning}");
|
||||
}
|
||||
if let Some(texture) = &jem.texture {
|
||||
if atlas_name(texture).is_none() {
|
||||
log::warn!(
|
||||
"Horizon: custom entity model {path}: its texture {texture} is outside \
|
||||
assets/minecraft/textures/, so not in the atlas: the vanilla one draws"
|
||||
);
|
||||
} else if !root.join(texture).is_file() {
|
||||
log::warn!(
|
||||
"Horizon: custom entity model {path}: its texture {texture} doesn't exist, \
|
||||
so not in the atlas: the vanilla one draws"
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(jem)
|
||||
}
|
||||
|
||||
/// A pack file's text, None when it's missing or larger than a model may read: its size is
|
||||
/// checked before anything is read.
|
||||
fn read_small(path: &Path) -> Option<String> {
|
||||
let len = fs::metadata(path).ok()?.len();
|
||||
if len > MAX_BYTES as u64 {
|
||||
return None;
|
||||
}
|
||||
fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
/// The atlas name of a texture resource path: `assets/minecraft/textures/entity/x.png` is
|
||||
/// `entity/x`. Textures outside a `textures/` folder are not in the atlas.
|
||||
pub fn atlas_name(path: &str) -> Option<String> {
|
||||
let rest = path.strip_prefix("assets/minecraft/textures/")?;
|
||||
Some(rest.strip_suffix(".png").unwrap_or(rest).to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn emf_files_win_and_a_flat_file_beats_its_folder() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-jem-files-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
for path in [
|
||||
// EMF's folder form over OptiFine's flat file.
|
||||
"optifine/cem/cow.jem",
|
||||
"emf/cem/cow/cow.jem",
|
||||
// One folder: the flat file over the folder form.
|
||||
"optifine/cem/pig.jem",
|
||||
"optifine/cem/pig/pig.jem",
|
||||
// A directory named `sheep.jem` is no file: the folder form stands.
|
||||
"optifine/cem/sheep.jem/readme.txt",
|
||||
"optifine/cem/sheep/sheep.jem",
|
||||
] {
|
||||
let path = root.join("assets/minecraft").join(path);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, "{}").unwrap();
|
||||
}
|
||||
let files = jem_files(&root);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let paths = |paths: &[&str]| {
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| format!("assets/minecraft/{p}"))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(
|
||||
files,
|
||||
BTreeMap::from([
|
||||
(
|
||||
"cow".to_owned(),
|
||||
paths(&["emf/cem/cow/cow.jem", "optifine/cem/cow.jem"])
|
||||
),
|
||||
(
|
||||
"pig".to_owned(),
|
||||
paths(&["optifine/cem/pig.jem", "optifine/cem/pig/pig.jem"])
|
||||
),
|
||||
("sheep".to_owned(), paths(&["optifine/cem/sheep/sheep.jem"])),
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,6 @@
|
||||
|
||||
mod curves;
|
||||
pub mod expr;
|
||||
pub mod files;
|
||||
pub mod json;
|
||||
pub mod model;
|
||||
@@ -79,6 +79,48 @@ pub struct BlockCollisionHit {
|
||||
pub shape: VoxelShape,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum CellError {
|
||||
InvalidBounds,
|
||||
TooLarge,
|
||||
}
|
||||
|
||||
/// The cursor a scan of `bounds` walks: its padded start, the box's extents in
|
||||
/// cells and the cell count. `new` and [`scan_cells`] share it, so a caller
|
||||
/// that sizes a budget from a box counts exactly what the scan will visit.
|
||||
fn cells(bounds: Aabb) -> Result<(BlockPos, [i32; 3], usize), CellError> {
|
||||
let min = bounds.min_position();
|
||||
let max = bounds.max_position();
|
||||
if !min.is_finite() || !max.is_finite() {
|
||||
return Err(CellError::InvalidBounds);
|
||||
}
|
||||
let start = BlockPos::containing(Vec3::new(min.x - 1.0e-7, min.y - 1.0e-7, min.z - 1.0e-7))
|
||||
.offset(-1, -1, -1);
|
||||
let end = BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
|
||||
.offset(1, 1, 1);
|
||||
let sizes = [
|
||||
end.x.wrapping_sub(start.x).wrapping_add(1),
|
||||
end.y.wrapping_sub(start.y).wrapping_add(1),
|
||||
end.z.wrapping_sub(start.z).wrapping_add(1),
|
||||
];
|
||||
if sizes.iter().any(|size| *size <= 0) {
|
||||
return Err(CellError::TooLarge);
|
||||
}
|
||||
let count = sizes
|
||||
.into_iter()
|
||||
.try_fold(1u64, |n, size| {
|
||||
n.checked_mul(size as u64).filter(|n| *n <= i32::MAX as u64)
|
||||
})
|
||||
.ok_or(CellError::TooLarge)? as usize;
|
||||
Ok((start, sizes, count))
|
||||
}
|
||||
|
||||
/// The cells a scan of `bounds` visits, or `None` for a box `new` refuses
|
||||
/// (`InvalidBounds`, `TooLarge`): the most a budget can usefully be.
|
||||
pub fn scan_cells(bounds: Aabb) -> Option<usize> {
|
||||
cells(bounds).ok().map(|(_, _, count)| count)
|
||||
}
|
||||
|
||||
pub struct BlockCollisionScan<'w, 'c, W: BlockCollisionWorld> {
|
||||
world: &'w mut W,
|
||||
context: CollisionContext<'c>,
|
||||
@@ -110,29 +152,10 @@ impl<'w, 'c, W: BlockCollisionWorld> BlockCollisionScan<'w, 'c, W> {
|
||||
only_suffocating: bool,
|
||||
candidate_budget: usize,
|
||||
) -> Result<Self, CollisionScanError<W::Error>> {
|
||||
let min = bounds.min_position();
|
||||
let max = bounds.max_position();
|
||||
if !min.is_finite() || !max.is_finite() {
|
||||
return Err(CollisionScanError::InvalidBounds);
|
||||
}
|
||||
let start = BlockPos::containing(Vec3::new(min.x - 1.0e-7, min.y - 1.0e-7, min.z - 1.0e-7))
|
||||
.offset(-1, -1, -1);
|
||||
let end = BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
|
||||
.offset(1, 1, 1);
|
||||
let sizes = [
|
||||
end.x.wrapping_sub(start.x).wrapping_add(1),
|
||||
end.y.wrapping_sub(start.y).wrapping_add(1),
|
||||
end.z.wrapping_sub(start.z).wrapping_add(1),
|
||||
];
|
||||
if sizes.iter().any(|size| *size <= 0) {
|
||||
return Err(CollisionScanError::TooLarge);
|
||||
}
|
||||
let count = sizes
|
||||
.into_iter()
|
||||
.try_fold(1u64, |n, size| {
|
||||
n.checked_mul(size as u64).filter(|n| *n <= i32::MAX as u64)
|
||||
})
|
||||
.ok_or(CollisionScanError::TooLarge)? as usize;
|
||||
let (start, sizes, count) = cells(bounds).map_err(|error| match error {
|
||||
CellError::InvalidBounds => CollisionScanError::InvalidBounds,
|
||||
CellError::TooLarge => CollisionScanError::TooLarge,
|
||||
})?;
|
||||
Ok(Self {
|
||||
world,
|
||||
context,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Entity.collide collection order and step resolution over a collision world.
|
||||
//! Travel, velocity, grounded flags and inside-block effects remain caller work.
|
||||
use crate::{
|
||||
block_collision_scan::{BlockCollisionScan, BlockCollisionWorld, CollisionScanError},
|
||||
block_collision_scan::{
|
||||
BlockCollisionScan, BlockCollisionWorld, CollisionScanError, scan_cells,
|
||||
},
|
||||
collision_context::CollisionContext,
|
||||
entity_obstruction::EntityIdentity,
|
||||
};
|
||||
@@ -56,6 +58,19 @@ where
|
||||
entity_collision_bounds(request.movement, request.bounds, request.max_step),
|
||||
)
|
||||
.map_err(CollisionScanError::World)?;
|
||||
// Vanilla walks the whole box a movement expands to with no candidate
|
||||
// counter, so a movement big enough to pass the caller's budget is slow
|
||||
// there, never fatal (#6124: a legal movement_speed of 400 crossed more
|
||||
// than a million cells on a diagonal and aborted the game). The budget
|
||||
// only has to cover the cells both queries of a step can visit: the
|
||||
// sweep, and the step's own box raised by max_step.
|
||||
let sweep = request
|
||||
.bounds
|
||||
.expand_towards(request.movement)
|
||||
.expand_towards(Vec3::new(0.0, f64::from(request.max_step.max(0.0)), 0.0));
|
||||
let candidate_budget = scan_cells(sweep).map_or(request.candidate_budget, |cells| {
|
||||
cells.max(request.candidate_budget)
|
||||
});
|
||||
try_resolve_step_movement(
|
||||
request.movement,
|
||||
request.bounds,
|
||||
@@ -69,13 +84,9 @@ where
|
||||
{
|
||||
shapes.push(border);
|
||||
}
|
||||
for hit in BlockCollisionScan::new(
|
||||
world,
|
||||
request.context,
|
||||
bounds,
|
||||
false,
|
||||
request.candidate_budget,
|
||||
)? {
|
||||
for hit in
|
||||
BlockCollisionScan::new(world, request.context, bounds, false, candidate_budget)?
|
||||
{
|
||||
shapes.push(hit?.shape);
|
||||
}
|
||||
Ok(shapes)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! The candidate budget bounds a scan, never a movement: a movement big enough
|
||||
//! to pass the caller's budget is walked in full, as vanilla's uncounted
|
||||
//! collision walk does (#6124).
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3, VoxelShape},
|
||||
position::{BlockPos, ChunkPos},
|
||||
};
|
||||
use horizon_world::{
|
||||
block_collision_scan::{BlockCollisionWorld, CollisionBlock, scan_cells},
|
||||
collision_context::CollisionContext,
|
||||
entity_obstruction::EntityIdentity,
|
||||
registry::BlockStateId,
|
||||
world_movement::{EntityMovementWorld, MovementRequest, resolve_world_movement},
|
||||
};
|
||||
use std::io;
|
||||
|
||||
/// Open air: every cell is reachable and has no shape, so a scan visits all of
|
||||
/// them and the element count is the walk it performed.
|
||||
struct OpenAir {
|
||||
cells: usize,
|
||||
}
|
||||
impl BlockCollisionWorld for OpenAir {
|
||||
type Error = io::Error;
|
||||
type Chunk = ();
|
||||
fn collision_chunk(&mut self, _: ChunkPos) -> io::Result<Option<()>> {
|
||||
Ok(Some(()))
|
||||
}
|
||||
fn collision_block(&mut self, _: (), _: BlockPos) -> io::Result<CollisionBlock> {
|
||||
self.cells += 1;
|
||||
Ok(CollisionBlock {
|
||||
state: BlockStateId(0),
|
||||
large_collision_shape: false,
|
||||
moving_piston: false,
|
||||
empty_shape: true,
|
||||
})
|
||||
}
|
||||
fn is_suffocating(&mut self, _: (), _: CollisionBlock, _: BlockPos) -> io::Result<bool> {
|
||||
unreachable!()
|
||||
}
|
||||
fn context_shape(
|
||||
&mut self,
|
||||
_: CollisionBlock,
|
||||
_: BlockPos,
|
||||
_: &CollisionContext<'_>,
|
||||
) -> io::Result<VoxelShape> {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
impl EntityMovementWorld for OpenAir {
|
||||
type Error = io::Error;
|
||||
fn entity_collisions(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Vec<VoxelShape>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn movement_border(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Option<VoxelShape>> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn player() -> Aabb {
|
||||
Aabb::new(Vec3::new(0.5, 64.0, 0.5), Vec3::new(1.1, 65.8, 1.1))
|
||||
}
|
||||
|
||||
fn resolve(movement: Vec3, candidate_budget: usize) -> (Vec3, OpenAir) {
|
||||
let mut world = OpenAir { cells: 0 };
|
||||
let resolved = resolve_world_movement(
|
||||
&mut world,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context: CollisionContext::empty(false),
|
||||
bounds: player(),
|
||||
movement,
|
||||
max_step: 0.6,
|
||||
on_ground: true,
|
||||
candidate_budget,
|
||||
},
|
||||
)
|
||||
.expect("a movement is never aborted by the candidate budget");
|
||||
(resolved, world)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_diagonal_sweep_past_the_budget_is_walked_in_full() {
|
||||
// 40 blocks each way pads to about 42 x 4 x 42 cells, far past the budget.
|
||||
let movement = Vec3::new(40.0, 0.0, 40.0);
|
||||
let sweep = scan_cells(player().expand_towards(movement)).expect("a scannable box");
|
||||
assert!(sweep > 1_000, "the sweep is {sweep} cells");
|
||||
let (resolved, world) = resolve(movement, 10);
|
||||
assert_eq!(resolved, movement);
|
||||
assert!(
|
||||
world.cells > 0,
|
||||
"the whole sweep was walked: {} cells",
|
||||
world.cells
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_cells_covers_what_the_scan_visits() {
|
||||
// The count includes the corners the cursor skips, so it is an upper bound
|
||||
// on the walk that is never short of it by more than the eight corners.
|
||||
for movement in [
|
||||
Vec3::new(0.0, 0.0, 0.0),
|
||||
Vec3::new(1.0, -0.5, 2.0),
|
||||
Vec3::new(40.0, 3.0, 40.0),
|
||||
] {
|
||||
let (_, world) = resolve(movement, 0);
|
||||
let sweep = scan_cells(player().expand_towards(movement)).expect("a scannable box");
|
||||
if movement.length_squared() == 0.0 {
|
||||
// A zero movement is never scanned.
|
||||
assert_eq!(world.cells, 0);
|
||||
} else {
|
||||
assert!(world.cells <= sweep, "movement {movement:?}");
|
||||
assert!(sweep - world.cells <= 8, "movement {movement:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_movements_are_unchanged() {
|
||||
// Resolved movement does not depend on the budget once it covers the sweep.
|
||||
for movement in [
|
||||
Vec3::new(0.2, -0.0784, 0.1),
|
||||
Vec3::new(1.0, 0.0, -3.0),
|
||||
Vec3::new(0.0, -0.5, 0.0),
|
||||
] {
|
||||
assert_eq!(
|
||||
resolve(movement, 1_000_000).0,
|
||||
resolve(movement, 1).0,
|
||||
"movement {movement:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ command above to inspect the selection rather than relying on historical counts.
|
||||
| 20 | Mob models and animation (78) | `L/mobs/render.rs`, `L/mobs/anim.rs`, `L/mobs/body_anim.rs`, `L/mobs/keyframes.rs`, `R/entity.wgsl` | entity_model |
|
||||
| 21 | Block behaviour, server side (78) | `W/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `data.rs`, `redstone.rs`, `place.rs`), `G/block_entity_ticks.rs`, `G/moving_pistons.rs` | block_update, redstone, random_tick, simple_block, piston_movement, block_entity_tickers |
|
||||
| 22 | Player movement and collision (75) | `L/motion.rs` + `L/motion/`, `R/collisions.rs`, `W/entity_move.rs`, `W/player_travel.rs`, `W/collision.rs` | collision, entity_move, player_travel, living_motion, climbing, swimming; `python3 tools/test_native_scene_motion.py` |
|
||||
| 23 | Resource packs and models (65) | `R/packs.rs` + `R/packs/`, `R/pack_screen.rs`, `R/atlas_sources.rs`, `R/sprites.rs`, `R/model_library.rs`, `G/packs.rs`, `L/mobs/cem.rs`, `L/mobs/cem_anim.rs`, `crates/horizon-assets/`, `crates/horizon-optifine/` | resource_pack, pack_selection, pack_screen, model_resolution, item_model, mipmap |
|
||||
| 23 | Resource packs and models (65) | `R/packs.rs` + `R/packs/`, `R/pack_screen.rs`, `R/atlas_sources.rs`, `R/sprites.rs`, `R/model_library.rs`, `G/packs.rs`, `L/mobs/cem.rs`, `L/mobs/cem_anim.rs`, `R/block_entity_cem.rs`, `crates/horizon-assets/`, `crates/horizon-optifine/` | resource_pack, pack_selection, pack_screen, model_resolution, item_model, mipmap |
|
||||
| 24 | Container menus and inventory (61) | `L/menu.rs`, `L/container_screen.rs`, `R/containers.rs`, `L/creative.rs`; game.rs `fn menu_ops`, `fn click_inventory` | menu_click, container_screen, creative, lan_e2e containers clicks |
|
||||
| 25 | Workstations (60) | `L/stations.rs`, `G/stations.rs`, `I/enchant.rs`, `R/brewing.rs` | enchanting, screen_layout, lan_e2e brewing loom stonecutter grindstone anvil smithing |
|
||||
| 26 | Crafting and the recipe book (59) | `L/crafting.rs`, `L/recipe_book.rs`, `L/recipe_book_screen.rs`, `G/recipe_book.rs`, `G/machines.rs`, `G/machines/crafter.rs` | recipe, recipe_book, recipe_book_view, place_recipe, ghost_recipe, crafter |
|
||||
|
||||
@@ -37,6 +37,8 @@ Every row has a one-line description and its cost in frame time (none, low, medi
|
||||
|
||||
**HDR** (off by default) lives on its own screen, Graphics: HDR, behind **HDR Settings...** under Display, which is greyed where the display path offers no HDR output (so players without an HDR display don't take its settings for their display's); there the switch comes first and the sliders below it only while it is on. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one. The frame is drawn exactly as in SDR, in vanilla's gamma space, and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), asked and then watched while HDR output is on (with HDR off the game doesn't ask), so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; these often come from the display's EDID and may run optimistic, and this is untested so far. **Calibrate...** (`hdr_calibration.rs`) sets Black Level, Peak Brightness and Paper White full screen, one test picture at a time, drawn by the last pass straight in nits above a band of controls: Horizon's logo at the slider on black, lowered until it disappears; the logo at the slider inside a tenth of the screen at 10 000 nits (small, so the display doesn't dim), raised until it disappears; a white card, set to a white that's comfortable in the light the player plays in. Save writes the three, Cancel or Escape keeps what was set, and a step left untouched keeps Auto. The Peak step needs a path that doesn't tone-map: on KWin the logo never disappears, so where the system reports its values the button reads **Calibrate Anyway...** and says the display is already set up (**Use Automatic Setup** once they were changed by hand). Where nothing is reported, Auto is 203 and 1000 nits, and the Graphics: HDR screen asks the player to calibrate. **Black Level** (0 is Auto: what the system reports, else an OLED's true black) is the display's darkest black, in nits: the last pass lifts the shadows onto it with ITU-R BT.2390's black level lift in PQ, which leaves the peak where it is and fades out by the fourth power above black, and the metadata gives it as the minimum luminance; at 0 the picture is unchanged. **UI Brightness** (100% by default) sets the HUD and menus against paper white: once the world is kept for the last pass, the frame's alpha is zeroed, everything drawn after raises it where it covers the world, the first-person hand's is then scaled down to 1/255 (it stays at the world's brightness), and the last pass scales the rest by UI Brightness and lifts none of it (so text over the sun stays text). With HDR on, F2 writes vanilla's screenshot as always and an HDR10 copy beside it (`…-hdr.png`): the HDR pass drawn once more into a 10-bit PQ target, whatever the swapchain is, saved as a 16-bit PNG tagged BT.2020 with PQ (`cICP`, PNG's third edition) and the peak and paper white as content light levels (`cLLI`); viewers that read `cICP` show it in HDR, and others that honour its ICC profile (reference white at the profile's white) a correct SDR picture with clipped highlights. Glowing items shine by their block's tier when dropped, framed, or held by anyone, your own hand included; the hand itself stays at the world's brightness. Not lifted yet: translucent blocks (the nether portal), distant LOD terrain. The screen shows these as plain numbers: they are nits onlLine truncated
|
||||
|
||||
HDR also lights the world past paper white: surfaces at block or sky light levels 11 to 15 rise above it, up to 4 times for the strongest light, sky light by how a face meets the sun, easing into the peak from 60% of it so bright textures keep their detail. The frame's alpha between 0.76 and 1 carries it to the last pass, where it amounts to the lightmap going past 1. Darker light stays as in SDR. It's off while a shader pack's post or material stage runs, and `HORIZON_HDR_LIGHTING=0` turns it off to compare.
|
||||
|
||||
HDR's light sources shine by the kind of light rather than its level (`mesher::hdr_tier`): the sun most, then glowstone, campfires and the soul fires, then flames and lava, torch and candle flames among them (particles too), then faint glowers such as a redstone torch or magma; glow lichen stays as in SDR. Each tier has its share of the headroom and the brightness its lift starts and ends at, the texel's own before lighting, so a light source shines alike at any light level and on every face (`gfx/hdr.rs` `TIERS`); `HORIZON_HDR_DEBUG=1` greys what isn't lifted and tints what is magenta.
|
||||
|
||||
Which settings each screen shows, their descriptions and costs are in `crates/horizon-client-render/src/graphics_ui.rs` (`rows`); `graphics_style.rs` draws them.
|
||||
|
||||
@@ -11,6 +11,16 @@ switch. The first install shows a trust notice, and every build waits behind a r
|
||||
after a review (its later releases are checked by machine); and they play in a game of their own, built for the
|
||||
instance they are installed in.
|
||||
|
||||
## Villager farming
|
||||
|
||||
Farmers recognize mod blocks made from farmland and `CropBlock` templates, including declarations from
|
||||
`native_blocks`. A crop block's generated item is its seed: add that item to
|
||||
`minecraft:villager_plantable_seeds` to let farmers plant its default block state from their inventory.
|
||||
Untagged items and tagged items without a block are skipped. Crop maturity follows the template's class:
|
||||
beetroots mature at age 3, ordinary crops at 7; torchflower crops never reach their maximum age 2 while
|
||||
still crops. Pitcher crops are not `CropBlock`s and are not harvested by this behavior. Farming still
|
||||
requires a farmer at work and `mob_griefing` enabled.
|
||||
|
||||
## Pinned to a Nightly
|
||||
|
||||
A native mod builds on one exact Horizon commit: `[compatibility] horizon_revision` in its `horizon-mixin.toml`,
|
||||
|
||||
@@ -38,7 +38,7 @@ harness named checks that loader against vanilla).
|
||||
| Block states: variants, multipart, weights, rotations, uvlock | `blockstates/` | works | `test_blockstate_model_parity`, `test_model_choice_parity`, `test_blockstate_transform_parity` |
|
||||
| Block models: parents, elements, rotations, faces, cullface, tint, shade, light emission, ambient occlusion | `models/block/` | works | `test_model_resolution_parity`, `test_model_baking_parity`, `test_section_mesh_parity` |
|
||||
| Item definitions and item models (all model types, display transforms, tints) | `items/`, `models/item/` | works | `test_item_model_parity` |
|
||||
| Custom entity models (OptiFine/EMF): a `.jem` reshapes a mob's model layer, with its animations and texture; Entity Model Features' files win over OptiFine's | `optifine/cem/`, `emf/cem/` | partly | `mobs::cem` and `mobs::cem_anim` unit tests; no per-bone textures, no random models, no textures under `optifine/` (vanilla's own entity models are code in 26.3, which `test_entity_model_parity` checks) |
|
||||
| Custom entity models (OptiFine/EMF): a `.jem` reshapes a mob's model layer, with its animations and texture, or a block entity's model (chests, shulker boxes, banners, heads, bells, books, conduits, decorated pots) with its texture but no animations yet; Entity Model Features' files win over OptiFine's | `optifine/cem/`, `emf/cem/` | partly | `mobs::cem`, `mobs::cem_anim`, `block_entity_cem` and `block_entity_render` unit tests, `horizon-optifine` `cem::files` (which file wins); trapped and ender chests read only their own files and copper chests keep vanilla's model; no sign, hanging sign or bed models, no per-bone textures, no random models, no textures under `optifine/` (vanilla's own entity models are code in 26.3, which `test_entity_model_parity` checks) |
|
||||
| Random entity textures (OptiFine/ETF): numbered variants and their rules (biome, height, name, profession, colour, baby, health, moon, time, weather, size, blocks), chosen per entity from its UUID and rechecked once a second | `optifine/random/`, `optifine/mob/`, `mcpatcher/mob/`, `etf/random/`, `textures/entity/` with a `.properties` | partly | `horizon-optifine` `random` and `random_textures` tests, `mobs::random_textures` tests; no `nbt.*` or ETF's extra conditions (teams, items...), no block entities |
|
||||
| Connected textures (OptiFine/Continuity): every method (`ctm`, `ctm_compact` with `ctm.N`, `horizontal`, `vertical`, `horizontal+vertical`, `vertical+horizontal`, `top`, `random`, `repeat`, `fixed`, `overlay`, `overlay_ctm`/`_random`/`_repeat`/`_fixed`, and Continuity's `overlay_horizontal`/`_vertical`/`_h+v`/`_v+h`), `matchTiles`/`matchBlocks` and the file-name forms, `tiles` (ranges, names, paths, `<skip>`, `<default>`), `weight`, `faces`, `biomes`, `heights` (and the old `minHeight`/`maxHeight`), `connect`, `connectTiles`/`connectBlocks`, `innerSeams`, `weights`, `randomLoops`, `symmetry`, `linked`, `width`/`height`, `tintIndex`/`tintBlock`, `layer`; Continuity's `orient`, `prioritize`, `optifineOnly` and its stacked-pane culling; tiles a rule picked matched again by another; panes drawn with OptiFine's unmirrored UVs | `optifine/ctm/**/*.properties` (and `mcpatcher/ctm/`) in any namespace, with their tiles | partly | `horizon-optifine` `ctm` and `ctm::connect` tests (the tile tables read off OptiFine's template images), `connected` and `mesher::quarter_tests` tests; `name=` rules are not loaded (the client meshing chunks never receives block entities' custom names, so they could never match); `random` picks from its own fixed hash, so random tiles land in other places than in OptiFine (same weights); tiles glow with their `_e` overlays, sharing the emissive row's table and limits (block overlays come first) |
|
||||
| Emissive textures (OptiFine/ETF): `<texture>_e.png` (or the pack's `suffix.emissive`) drawn full bright over mobs, their layers and armour, armour stands, the elytra, the dragon, thrown tridents, block entities, placed blocks and connected-texture tiles | `<texture>_e.png` beside its texture (also random variants' and blink frames'); the suffix from `optifine/emissive.properties`, `etf/emissive.properties` or `textures/emissive.properties` | partly | `horizon-optifine` `emissive` tests, `mobs::render::emissive_tests`, `block_entity_render` and `native_emissive` tests; at most 96 block overlays (native mods keep 32 more), cutout; none on translucent faces, nor on blocks whose texture is pixel-identical to another's (they share a layer); not over items, armour trims, other projectiles, minecarts or end crystals |
|
||||
|
||||
@@ -461,7 +461,7 @@ public final class VanillaSceneMotionOracle {
|
||||
case "frozen"->p.frozen=op.get("value").getAsInt();
|
||||
case "portal_tick"->p.portalTick(op.get("creative").getAsBoolean());
|
||||
case "weaving"->p.weaving=op.get("value").getAsBoolean();
|
||||
case "flying"->{var a=p.getAbilities();a.flying=op.get("value").getAsBoolean();a.mayfly=op.has("may_fly")?op.get("may_fly").getAsBoolean():a.flying;}
|
||||
case "flying"->{var a=p.getAbilities();a.flying=op.get("value").getAsBoolean();a.mayfly=op.has("may_fly")?op.get("may_fly").getAsBoolean():a.flying;if(op.has("speed"))a.setFlyingSpeed(single(op.get("speed")));}
|
||||
case "trigger"->trigger.setInt(p,op.get("value").getAsInt());
|
||||
case "delay"->delay.setInt(p,op.get("value").getAsInt());
|
||||
case "ack_attributes"->{p.attrs.getAttributesToUpdate().clear();p.attrs.getAttributesToSync().clear();}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package dev.horizonlab.parity;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.CropBlock;
|
||||
import net.minecraft.world.level.block.FarmlandBlock;
|
||||
|
||||
/** HarvestFarmland's class and maturity checks, including non-CropBlock age properties. */
|
||||
public final class VanillaVillagerFarmingOracle {
|
||||
public static void main(String[] args) throws Exception {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
try (var out = Files.newBufferedWriter(Path.of(args[0]))) {
|
||||
for (String name : new String[] {"wheat", "carrots", "potatoes", "beetroots",
|
||||
"torchflower_crop", "pitcher_crop", "cocoa", "farmland", "stone"}) {
|
||||
var block = BuiltInRegistries.BLOCK.getValue(Identifier.parse("minecraft:" + name));
|
||||
for (var state : block.getStateDefinition().getPossibleStates()) {
|
||||
boolean crop = block instanceof CropBlock;
|
||||
boolean ripe = block instanceof CropBlock c && c.isMaxAge(state);
|
||||
out.write(Block.getId(state) + " " + crop + " " + ripe + " " + (block instanceof FarmlandBlock));
|
||||
out.newLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Attribute swapping from a spear to a mace against an actual vanilla 26.3 server.
|
||||
|
||||
`Minecraft.tick` picks the crosshair target (`pick`, with the held item's
|
||||
`attack_range`: a spear reaches 4.5 blocks) before `handleKeybinds` takes a
|
||||
number key and then the attack click. A number key and a click in the same
|
||||
tick therefore swing the new item at the target the spear picked, and the
|
||||
server's `handleAttack` checks that item's range with a 3 block buffer. The
|
||||
server is slowed to one tick a second so both inputs land in one client tick.
|
||||
|
||||
Cases: the swap and the click in one tick hit a pig 3.75 blocks away; the
|
||||
swap a tick before the click misses it (the mace's own pick reaches 3); a
|
||||
mace click at 2.25 blocks hits (the setup lands attacks at all). A pig
|
||||
hovering at eye height inside the spear's 2 block minimum (its box, with the
|
||||
0.125 margin, ends 1.475 along the ray): with a wall 3.5 away the spear's pick
|
||||
ends on the wall (`getHitEntitiesAlong`'s block result), so the mace misses
|
||||
the pig; with nothing behind it the spear's pick misses and the plain pick
|
||||
finds the pig. A stick given an `attack_range` does not attack that pig,
|
||||
inside its minimum (`startAttack`).
|
||||
Requires prepared resources and built horizon-client/horizon-ctl binaries.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import lab
|
||||
import test_rust_parity as base
|
||||
from integration_env import game_environment
|
||||
from test_dedicated_server_parity import Server, game_time
|
||||
from test_locator_bar_parity import server_dir
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
EXE = '.exe' if os.name == 'nt' else ''
|
||||
NAME = 'SwapTester'
|
||||
# Tagged: a generated world has pigs of its own.
|
||||
PIG = '@e[type=minecraft:pig,tag=swap,limit=1]'
|
||||
# Player at 0.5 80 0.5 looking east; the pig's near face is `distance` - 0.45
|
||||
# from the eyes' column.
|
||||
OPEN = ['fill 2 80 -3 8 84 3 minecraft:air']
|
||||
WALL = [*OPEN, 'fill 4 80 -3 4 84 3 minecraft:stone']
|
||||
SWAP = ['key 2 tap', 'mouse left click']
|
||||
APART = ['key 2 tap', 'wait 1', 'mouse left click']
|
||||
EYE = 81.62
|
||||
CASES = [
|
||||
# (label, terrain, pig distance, pig feet y, look height, inputs, expected hit)
|
||||
('same-tick', OPEN, 4.2, 80, 80.45, SWAP, True),
|
||||
('tick-apart', OPEN, 4.2, 80, 80.45, APART, False),
|
||||
('mace-near', OPEN, 2.7, 80, 80.45, APART, True),
|
||||
('min-reach-wall', WALL, 0.9, 80.8, EYE, SWAP, False),
|
||||
('min-reach-open', OPEN, 0.9, 80.8, EYE, SWAP, True),
|
||||
('ranged-stick', OPEN, 0.9, 80.8, EYE, ['key 3 tap', 'wait 1', 'mouse left click'], False),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--game', type=Path, default=ROOT / f'target/fast/horizon-client{EXE}')
|
||||
parser.add_argument('--ctl', type=Path, default=ROOT / f'target/fast/horizon-ctl{EXE}')
|
||||
parser.add_argument('--resources', type=Path, default=ROOT / '.local/horizon-client/26.3')
|
||||
parser.add_argument('--output', type=Path)
|
||||
args = parser.parse_args()
|
||||
output = (args.output or Path(tempfile.mkdtemp(prefix='attack-swap-'))).resolve()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
_, jars, libraries = base.verify_artifacts()
|
||||
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
|
||||
command = ['java', '-Xmx2G', '-Duser.timezone=UTC', '-cp', dependencies,
|
||||
'net.minecraft.server.Main', 'nogui']
|
||||
port = server_dir(output / 'server')
|
||||
server = Server(command, output / 'server', 'vanilla')
|
||||
env = game_environment(os.environ, output)
|
||||
results = []
|
||||
# Keep AF_UNIX paths short even in nested worktrees.
|
||||
with tempfile.TemporaryDirectory(prefix='aswap-', dir=None if os.name == 'nt' else '/tmp') as socket_home:
|
||||
socket = Path(socket_home) / 'ctl.sock'
|
||||
|
||||
def ctl(*commands, timeout=500):
|
||||
r = subprocess.run([str(args.ctl.resolve()), '--socket', str(socket), *map(str, commands)],
|
||||
cwd=output, env=env, capture_output=True, text=True, timeout=timeout)
|
||||
data = json.loads(r.stdout)
|
||||
if r.returncode or data.get('error'):
|
||||
raise RuntimeError(f'{commands}: {data}; {r.stderr}')
|
||||
return data
|
||||
|
||||
def pig_health():
|
||||
start = len(server.lines)
|
||||
server.console(f'data get entity {PIG} Health')
|
||||
game_time(server)
|
||||
values = [float(m.group(1)) for line in server.lines[start:]
|
||||
if (m := re.search(r'entity data: (-?\d+(?:\.\d+)?)f', line))]
|
||||
assert len(values) == 1, server.lines[start:]
|
||||
return values[0]
|
||||
|
||||
try:
|
||||
server.wait_for(r'Done \(')
|
||||
ctl('launch', '--game', args.game.resolve(), '--offscreen', '--no-pause', '--name', NAME,
|
||||
'--resources', args.resources.resolve(), '--render-distance', 2, '--width', 960, '--height', 720)
|
||||
ctl('connect', f'127.0.0.1:{port}', NAME)
|
||||
ctl('wait-ready', 400)
|
||||
for line in (f'op {NAME}', 'gamerule spawn_mobs false', f'gamemode survival {NAME}',
|
||||
'kill @e[type=!minecraft:player]',
|
||||
'fill -3 79 -3 8 79 3 minecraft:stone', 'fill -3 80 -3 8 84 3 minecraft:air',
|
||||
f'tp {NAME} 0.5 80 0.5 -90 0',
|
||||
f'item replace entity {NAME} hotbar.0 with minecraft:iron_spear',
|
||||
f'item replace entity {NAME} hotbar.1 with minecraft:mace',
|
||||
f'item replace entity {NAME} hotbar.2 with minecraft:stick'
|
||||
'[minecraft:attack_range={min_reach:2.0f,max_reach:4.5f}]'):
|
||||
server.console(line)
|
||||
game_time(server)
|
||||
ctl('wait', 20)
|
||||
for label, terrain, distance, feet, look, inputs, expected in CASES:
|
||||
server.console(f'kill {PIG}')
|
||||
for line in terrain:
|
||||
server.console(line)
|
||||
server.console(f'summon minecraft:pig {0.5 + distance} {feet} 0.5 '
|
||||
'{NoAI:1b,Silent:1b,NoGravity:1b,Tags:["swap"]}')
|
||||
server.console(f'tp {NAME} 0.5 80 0.5 -90 0')
|
||||
game_time(server)
|
||||
ctl('key', '1', 'tap')
|
||||
# A full attack charge with the spear in hand.
|
||||
ctl('wait', 40)
|
||||
ctl('look-at', 0.5 + distance, look, 0.5)
|
||||
before = pig_health()
|
||||
server.console('tick rate 1')
|
||||
game_time(server)
|
||||
ctl('wait', 2)
|
||||
ctl('wait', 1)
|
||||
for action in inputs:
|
||||
ctl(*action.split())
|
||||
server.console('tick rate 20')
|
||||
game_time(server)
|
||||
ctl('wait', 10)
|
||||
after = pig_health()
|
||||
hit = after < before
|
||||
result = dict(label=label, distance=distance, health_before=before, health_after=after,
|
||||
hit=hit, expected=expected, pass_=hit == expected)
|
||||
results.append(result)
|
||||
(output / 'results.json').write_text(json.dumps(results, indent=2))
|
||||
print(json.dumps(result), flush=True)
|
||||
finally:
|
||||
try:
|
||||
ctl('quit')
|
||||
except (RuntimeError, subprocess.TimeoutExpired, json.JSONDecodeError):
|
||||
pass
|
||||
server.stop()
|
||||
passed = sum(r['pass_'] for r in results)
|
||||
print(f'{passed}/{len(results)} attack swaps match vanilla: {output}')
|
||||
assert len(results) == len(CASES) and passed == len(results), results
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -11654,6 +11654,54 @@ def check_blast_bystanders(host, guest, out):
|
||||
|
||||
CHECKS += ['blast-bystanders']
|
||||
CHECK_FNS['blast-bystanders'] = check_blast_bystanders
|
||||
def check_wind_remaining(host, guest, out):
|
||||
"""A wind burst pushes a firework rocket and a thrown trident (ServerExplosion.hurtEntities ->
|
||||
Entity.pushFromExplosion; neither overrides ignoreExplosion). Real 26.3 server, wind charge into the floor,
|
||||
tick freeze, tick step 1 after the burst: the rocket's Motion x -0.3174, -0.3650, -0.4197 and y 0.5971, 0.6371,
|
||||
0.6771 on the next three ticks (it starts at (0, 0.08, 0), 0.8 west of the burst, 1.0 above the floor); a
|
||||
trident 0.8 east gets (+0.446, +0.490) on the first tick when NoGravity holds. A summoned trident here
|
||||
ignores NoGravity and falls first, so only its direction is asserted."""
|
||||
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
||||
x, y, z = hx + 6, hy, hz
|
||||
host('command', command='gamemode spectator')
|
||||
|
||||
def read():
|
||||
found = {}
|
||||
for p in host.raw('simulation')['projectiles']:
|
||||
if p['kind'] in ('minecraft:trident', 'minecraft:firework_rocket') and abs(p['position'][0] - x) < 4:
|
||||
found[p['kind'].split(':')[1]] = p['velocity']
|
||||
return found
|
||||
detail = {}
|
||||
try:
|
||||
host('command', command='tick unfreeze')
|
||||
for c in (f'fill {x - 8} {y - 1} {z - 8} {x + 8} {y - 1} {z + 8} minecraft:bedrock',
|
||||
f'fill {x - 8} {y} {z - 8} {x + 8} {y + 5} {z + 8} minecraft:air', 'kill @e[type=!player]', 'tick freeze',
|
||||
f'summon minecraft:trident {x + 1.3} {y + 1.0} {z + 0.5} {{NoGravity:1b}}',
|
||||
f'summon minecraft:firework_rocket {x - 0.3} {y + 1.0} {z + 0.5} {{LifeTime:200}}', 'tick step 2'):
|
||||
host('command', command=c)
|
||||
host.wait(8)
|
||||
detail['before'] = read()
|
||||
host('command', command=f'summon minecraft:wind_charge {x + 0.5} {y + 0.6} {z + 0.5} {{Motion:[0.0,-1.0,0.0]}}')
|
||||
detail['ticks'] = []
|
||||
for _ in range(3):
|
||||
host('command', command='tick step 1')
|
||||
host.wait(4)
|
||||
detail['ticks'].append(read())
|
||||
finally:
|
||||
host('command', command='tick unfreeze')
|
||||
host('command', command='kill @e[type=!player]')
|
||||
host('command', command='gamemode creative')
|
||||
first = detail['ticks'][0]
|
||||
assert 'firework_rocket' in first and 'trident' in first, f'no rocket listed: {detail}'
|
||||
rocket = first['firework_rocket']
|
||||
assert abs(rocket[0] + 0.3174) < 0.01 and abs(rocket[1] - 0.5971) < 0.01, f'the rocket was not pushed like vanilla: {detail}'
|
||||
trident = first['trident']
|
||||
assert trident[0] > 0.2 and trident[1] > 0.1, f'the trident was not pushed away: {detail}'
|
||||
return detail
|
||||
|
||||
|
||||
CHECKS += ['wind-remaining']
|
||||
CHECK_FNS['wind-remaining'] = check_wind_remaining
|
||||
|
||||
|
||||
def check_selector_tags(host, guest, out):
|
||||
@@ -11763,5 +11811,55 @@ CHECKS += ['mining-drop-latency']
|
||||
CHECK_FNS['mining-drop-latency'] = check_mining_drop_latency
|
||||
|
||||
|
||||
def check_scale_body(host, guest, out):
|
||||
"""#3684: a player's `minecraft:scale` is the same entity's attribute through a change of level and
|
||||
a death. ServerPlayer.teleport keeps the player; ServerPlayer.restoreFrom copies the base values to
|
||||
the body after a death (assignBaseValues), then sets its health to the max they give. The eye the
|
||||
client sees stays at 0.162 (1.62 * 0.1), not back at 1.62, and a max health base of 40 respawns the
|
||||
player with 40."""
|
||||
seen = {}
|
||||
|
||||
def scale_of(player):
|
||||
# The eye height the player's client has: the server's scale reaches it as an attribute update.
|
||||
state = player('state')['player']
|
||||
return round(state['eye'][1] - state['position'][1], 3)
|
||||
|
||||
for label, player in (('host', host), ('guest', guest)):
|
||||
s = seen[label] = {}
|
||||
player('command', command='gamemode creative')
|
||||
player('command', command='attribute @s minecraft:scale base set 0.1')
|
||||
player('command', command='attribute @s minecraft:max_health base set 40')
|
||||
player.wait(20)
|
||||
s['set'] = scale_of(player)
|
||||
x, z = (int(v // 1) for v in pos_of(player)[::2])
|
||||
into_level(player, NETHER, x, 100, z)
|
||||
s['nether'] = scale_of(player)
|
||||
player('command', command=f'execute in {OVERWORLD} run tp @s {x + 0.5} 200 {z + 0.5}')
|
||||
wait_dimension(player, OVERWORLD, 400)
|
||||
player.wait(40)
|
||||
s['overworld'] = scale_of(player)
|
||||
player('command', command='kill @s')
|
||||
player.wait(30)
|
||||
player.raw('respawn')
|
||||
player.wait(60)
|
||||
back_to_game(player)
|
||||
s['respawned'] = scale_of(player)
|
||||
s['health'] = player('state')['player']['health']
|
||||
player('command', command='attribute @s minecraft:scale base set 1')
|
||||
player('command', command='attribute @s minecraft:max_health base set 20')
|
||||
player.wait(20)
|
||||
s['reset'] = scale_of(player)
|
||||
for label, s in seen.items():
|
||||
for step in ('set', 'nether', 'overworld', 'respawned'):
|
||||
assert s[step] == 0.162, f'{label} after {step}: {s}'
|
||||
assert s['reset'] == 1.62, f'{label} reset: {s}'
|
||||
assert s['health'] == 40, f'{label} respawned health: {s}'
|
||||
return seen
|
||||
|
||||
|
||||
CHECKS += ['scale-body']
|
||||
CHECK_FNS['scale-body'] = check_scale_body
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"name": "framerate_limit",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_framerate_limit",
|
||||
"neoforge": "none (vanilla FramerateLimitTracker.getFramerateLimit fires no event)",
|
||||
"side": "client",
|
||||
"cancellable": false
|
||||
}
|
||||
],
|
||||
"rust_summary": "lowering the frame rate limit, as Dynamic FPS does (`native_framerate_limit`).",
|
||||
"doc_summary": "lowering the frame rate limit: `native_framerate_limit(focused, minimized, in_world, limit)` is asked each frame with the limit Horizon is about to use (Max Framerate after the inactivity throttle and Battery Mode, 260 for unlimited), whether the window has focus, whether it is minimised and whether a world is open. A mod may lower `limit`, for example to throttle a window in the background as Dynamic FPS does; Horizon keeps the lower of its own limit and the mod's and ignores values below 1. Bundles that use it need `\"hooks\": @API:framerate_limit@`."
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
ActiveTextCollector.accept inline crates/horizon-client-render/src/screen_draw.rs::text default overload: left-aligned text at x,y with default parameters
|
||||
ActiveTextCollector.acceptScrolling port crates/horizon-client-render/src/screen_draw.rs::scrolling_text gap: minor: only the screen_draw widgets (Button) scroll; ui.rs centered_text clips
|
||||
ActiveTextCollector.acceptScrollingWithDefaultCenter port crates/horizon-client-render/src/ui.rs::centered_text gap: minor: centres the message; wider text is clipped instead of scrolled
|
||||
ActiveTextCollector.defaultScrollingHelper port crates/horizon-client-render/src/screen_draw.rs::scrolling_text gap: minor: only the screen_draw widgets (Button) call it; ui.rs centered_text still clips wider text. The offset is screen_draw.rs::scroll_offset
|
||||
ActiveTextCollector.acceptScrolling port crates/horizon-client-render/src/ui.rs::scrolling_text_around_at the HudBatch widgets; a label that fits is clamped between left and right around centerX (title.rs::Button::draw_label_at passes the middle of a TextAndIcon button); screen_draw.rs::scrolling_text is the same for the screen_draw widgets
|
||||
ActiveTextCollector.acceptScrollingWithDefaultCenter port crates/horizon-client-render/src/ui.rs::scrolling_text_at centre (left + right) / 2; ui::button, ui::slider and the tab labels (ui::menu_tab_label) draw through it
|
||||
ActiveTextCollector.defaultScrollingHelper port crates/horizon-client-render/src/screen_draw.rs::scrolling_text the offset is ui.rs::scroll_offset (screen_draw.rs::scroll_offset for the screen_draw widgets); ui.rs::centered_text, for titles and text lines, still clips because vanilla does not scroll them
|
||||
ActiveTextCollector.findElementUnderCursor port crates/horizon-client-render/src/chat.rs::Chat::style_at gap: minor: hit test covers chat lines only; book links use game/books.rs book_link_at
|
||||
ActiveTextCollector.isPointInRectangle port crates/horizon-client-render/src/ui.rs::hovered half-open rectangle test with the same bounds
|
||||
ActiveTextCollector.ClickableStyleFinder.accept port crates/horizon-client-render/src/chat.rs::Chat::style_at gap: minor: only chat lines are searched for click and hover styles
|
||||
@@ -62,7 +62,7 @@ GuiGraphicsExtractor.textRendererForWidget skip:ui builds a text collector for
|
||||
GuiGraphicsExtractor.textRenderer skip:ui builds a text collector; Horizon draws text directly
|
||||
GuiGraphicsExtractor.createDefaultTextParameters skip:ui default pose, opacity and scissor for text; Horizon passes colour and rectangle directly
|
||||
GuiGraphicsExtractor.RenderingTextCollector.accept port crates/horizon-client-render/src/screen_draw.rs::text gap: minor: no hover or click registration here (see Chat::style_at)
|
||||
GuiGraphicsExtractor.RenderingTextCollector.acceptScrolling port crates/horizon-client-render/src/screen_draw.rs::scrolling_text gap: minor: only the screen_draw widgets (Button) scroll; ui.rs centered_text clips
|
||||
GuiGraphicsExtractor.RenderingTextCollector.acceptScrolling port crates/horizon-client-render/src/screen_draw.rs::scrolling_text the font's width and a 9 px line into defaultScrollingHelper, for the screen_draw widgets; ui.rs::scrolling_text_around_at is the same for the HudBatch widgets
|
||||
GuiGraphicsExtractor.ScissorStack.push inline crates/horizon-client-render/src/screen_draw.rs::paint gap: minor: the pushed rectangle replaces the current one; it is not intersected
|
||||
GuiGraphicsExtractor.ScissorStack.pop inline crates/horizon-client-render/src/screen_draw.rs::paint gap: minor: Draw::NoScissor clears the scissor instead of restoring the enclosing one
|
||||
GuiGraphicsExtractor.ScissorStack.containsPoint missing minor: hover ignores the scissor, so a widget clipped out of view can still highlight
|
||||
@@ -121,7 +121,7 @@ AbstractSelectionList.TrackedList.set skip:java delegate list wrapper
|
||||
AbstractSliderButton.getSprite inline crates/horizon-client-render/src/ui.rs::slider gap: minor: the track outline is drawn while dragged only; keyboard focus does not highlight it; hover highlights only the handle
|
||||
AbstractSliderButton.getHandleSprite inline crates/horizon-client-render/src/ui.rs::slider gap: minor: handle highlight follows hover only
|
||||
AbstractSliderButton.updateWidgetNarration skip:other narration only
|
||||
AbstractSliderButton.extractWidgetRenderState port crates/horizon-client-render/src/ui.rs::slider gap: minor: the label is clipped rather than scrolled
|
||||
AbstractSliderButton.extractWidgetRenderState port crates/horizon-client-render/src/ui.rs::slider_ext the whole message, value included, scrolls inside the slider (margin 2)
|
||||
AbstractSliderButton.handleCursor port crates/horizon-client-render/src/widgets.rs::widget_cursor gap: minor: no resize cursor while dragging
|
||||
AbstractSliderButton.setFocused missing minor: keyboard focus does not enable value changes; there is no change-value state
|
||||
AbstractSliderButton.keyPressed missing minor: Left and Right do not move a focused slider; Enter does not toggle it
|
||||
@@ -139,7 +139,7 @@ AbstractTextAreaWidget.extractBorder skip:ui frame sprite; no behaviour
|
||||
AbstractTextAreaWidget.withinContentAreaTopBottom skip:ui visibility cull while drawing lines
|
||||
AbstractWidget.extractRenderState port crates/horizon-client-render/src/ui.rs::hovered gap: minor: the scissor test is not part of the hover check
|
||||
AbstractWidget.extractTooltipForNextRenderPass port crates/horizon-client-render/src/ui.rs::tooltip gap: minor: unsure: widget hover delay not checked
|
||||
AbstractWidget.extractScrollingStringOverContents port crates/horizon-client-render/src/ui.rs::centered_text gap: minor: text is clipped, not scrolled
|
||||
AbstractWidget.extractScrollingStringOverContents port crates/horizon-client-render/src/ui.rs::scrolling_text ui::button and ui::slider (margin 2) draw it for the HudBatch widgets; screen_draw.rs::scrolling_string_over_contents for the screen_draw ones
|
||||
AbstractWidget.mouseClicked port crates/horizon-client/src/title.rs::Title::click gap: minor: click dispatch is per screen; the press sound is audio.rs Audio::play_button_click
|
||||
AbstractWidget.mouseReleased port crates/horizon-client-render/src/ui.rs::Scroll::release gap: minor: only the scroll release; widget onRelease is per screen
|
||||
AbstractWidget.mouseDragged port crates/horizon-client-render/src/ui.rs::Scroll::drag gap: minor: only scroll drag; widget onDrag is per screen
|
||||
|
||||
|
@@ -72,7 +72,7 @@ SpriteIconButton.extractLoadingStateIfLoading skip:service loading spinner only
|
||||
SpriteIconButton.Builder.sprite skip:java builder setter; Horizon draws its icon buttons with fixed sprites
|
||||
SpriteIconButton.Builder.build skip:java builder; no object is needed
|
||||
SpriteIconButton.CenteredIcon.extractContents skip:service FriendsButton (social)
|
||||
SpriteIconButton.TextAndIcon.extractContents skip:other no caller in 26.3
|
||||
SpriteIconButton.TextAndIcon.extractContents port crates/horizon-client/src/title.rs::Button::draw_label_at the Accessibility screen's Music & Sounds, Accessibility Settings and Language buttons (AccessibilityOnboardingScreen): the label in x + 2 .. x + width - icon - 4 centred on the button's middle, the icon drawn in Title::draw
|
||||
StringWidget.setMaxWidth port crates/horizon-client-render/src/screen_draw.rs::string_widget clips at max_width (CLAMPED)
|
||||
StringWidget.getWidth port crates/horizon-client-render/src/screen_draw.rs::label_width the smaller of max width and text width
|
||||
StringWidget.visitLines port crates/horizon-client-render/src/screen_draw.rs::string_widget gap: minor: SCROLLING overflow (scrolling text) is not ported; CLAMPED draws "..."
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 35 and column 134.
|
@@ -20,7 +20,7 @@ MenuTabBar.Builder.addTabs skip:java builder
|
||||
MenuTabBar.Builder.build skip:java builder
|
||||
MenuTabBar.MenuTabButton.extractWidgetRenderState inline crates/horizon-client/src/title/create_world.rs::Title::draw_create tab loop: sprite by selected and hover state, underline, label
|
||||
MenuTabBar.MenuTabButton.renderMenuBackground inline crates/horizon-client/src/title/create_world.rs::Title::draw_create menu background inside the selected tab
|
||||
MenuTabBar.MenuTabButton.renderLabel inline crates/horizon-client/src/title/create_world.rs::Title::draw_create label centred, 3 px lower on unselected tabs
|
||||
MenuTabBar.MenuTabButton.renderLabel port crates/horizon-client-render/src/ui.rs::menu_tab_label the Create World and Statistics tabs: centred, 3 px lower on unselected tabs, gray on inactive ones; a label wider than the tab (margin 1) scrolls
|
||||
MenuTabBar.MenuTabButton.renderFocusUnderline inline crates/horizon-client/src/title/create_world.rs::Title::draw_create 1 px underline under the selected tab label
|
||||
TabManager.setTabArea skip:ui tab area layout; create world computes the tab row on every draw (tabs())
|
||||
TabManager.setCurrentTab port crates/horizon-client/src/title.rs::Title::click a click on a tab sets options.tab
|
||||
|
||||
|
@@ -32,7 +32,7 @@ LinearInterpolationHandler.doInterpolate inline crates/horizon-client/src/game/r
|
||||
LivingEntity.<init> port crates/horizon-client/src/mobs.rs::Mob::new box, health, random yaw
|
||||
LivingEntity.defineSynchedData port crates/horizon-client/src/game/lan.rs::mob_meta gap: player: arrow count (12) and stinger count (13) are never kept or sent, so arrows and bee stingers do not stay stuck in a body
|
||||
LivingEntity.hasLandedInLiquid port crates/horizon-world/src/living_fall.rs::ImpulseContext::landed_in_liquid water or lava with getDeltaMovement().y below 9.9999997E-6, from the guest move loop; the grace time still defers it
|
||||
LivingEntity.getScale missing minor: no scale attribute, so /attribute scale and datapack scaling do nothing; baby and slime sizes come from tables in Mob::dims
|
||||
LivingEntity.getScale port crates/horizon-client/src/mobs/riders.rs::scale the minecraft:scale a client has for any living entity; the server's player reads its own in PlayerMotion::refresh_dimensions (PlayerMotion.scale keeps it for Guest::dimensions)
|
||||
LivingEntity.shouldDropLoot inline crates/horizon-client/src/mobs/drops.rs::die mob_drops rule and not a baby (monsters excepted)
|
||||
LivingEntity.decreaseAirSupply port crates/horizon-client/src/motion/environment.rs::MotionWorld::tick_environment_damage gap: minor: only the player reads oxygen_bonus (Respiration); a mob loses one air per tick in ai.rs::base_tick
|
||||
LivingEntity.setLastHurtByPlayer inline crates/horizon-client/src/mobs.rs::Mob::damage_with ai.last_hurt_by_player = 100 on a player's hit; Game::last_hurt_by_player records which guest
|
||||
|
||||
|
+126
-4
@@ -15,6 +15,8 @@ Checks:
|
||||
payload (the server's "Received custom click action" line), `wait_for_response` leaves the
|
||||
waiting screen, and `/dialog clear` closes a dialog (servers used to see nothing: the game
|
||||
dropped the packets).
|
||||
- book_menu: an OpenBook packet over a retained container shows only the book; closing it restores
|
||||
the menu with an available pointer.
|
||||
- titles: a chest named with styles (`CustomName`, as a plugin's menu is): its screen's title keeps
|
||||
the colours and bold (it used to be flattened to plain grey).
|
||||
|
||||
@@ -25,6 +27,8 @@ import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import threading
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -75,6 +79,84 @@ def snbt(value):
|
||||
return str(value)
|
||||
|
||||
|
||||
class BookPacketProxy:
|
||||
"""Forward whole frames; inject the pinned protocol's OpenBook packet.
|
||||
|
||||
Offline vanilla uses compression threshold 256. The injected packet is
|
||||
below that threshold (uncompressed length 0). Forwarded frames stay opaque.
|
||||
"""
|
||||
def __init__(self, upstream):
|
||||
self.upstream = upstream
|
||||
self.listener = socket.socket()
|
||||
self.listener.bind(('127.0.0.1', 0))
|
||||
self.listener.listen(1)
|
||||
self.port = self.listener.getsockname()[1]
|
||||
self.client = None
|
||||
self.server = None
|
||||
self.lock = threading.Lock()
|
||||
self.ready = threading.Event()
|
||||
threading.Thread(target=self.run, daemon=True).start()
|
||||
|
||||
@staticmethod
|
||||
def read(sock, count):
|
||||
data = bytearray()
|
||||
while len(data) < count:
|
||||
chunk = sock.recv(count - len(data))
|
||||
if not chunk:
|
||||
raise EOFError()
|
||||
data.extend(chunk)
|
||||
return bytes(data)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
self.client, _ = self.listener.accept()
|
||||
self.server = socket.create_connection(('127.0.0.1', self.upstream))
|
||||
self.ready.set()
|
||||
threading.Thread(target=self.upload, daemon=True).start()
|
||||
while True:
|
||||
prefix = bytearray()
|
||||
length = 0
|
||||
for shift in range(0, 35, 7):
|
||||
byte = self.read(self.server, 1)[0]
|
||||
prefix.append(byte)
|
||||
length |= (byte & 127) << shift
|
||||
if byte < 128:
|
||||
break
|
||||
else:
|
||||
raise ValueError('invalid packet length')
|
||||
frame = bytes(prefix) + self.read(self.server, length)
|
||||
with self.lock:
|
||||
self.client.sendall(frame)
|
||||
except (EOFError, OSError):
|
||||
pass
|
||||
|
||||
def upload(self):
|
||||
try:
|
||||
while data := self.client.recv(65536):
|
||||
self.server.sendall(data)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def open_book(self, hand=0):
|
||||
# ClientboundOpenBookPacket: protocol 777, play packet 0x3b.
|
||||
self.send_packet(0x3b, lab.varint(hand))
|
||||
|
||||
def send_packet(self, packet, data):
|
||||
assert self.ready.wait(10), 'client did not connect through the proxy'
|
||||
body = b'\0' + lab.varint(packet) + data
|
||||
with self.lock:
|
||||
self.client.sendall(lab.varint(len(body)) + body)
|
||||
|
||||
def close(self):
|
||||
for sock in (self.client, self.server, self.listener):
|
||||
if sock is not None:
|
||||
try:
|
||||
sock.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
sock.close()
|
||||
|
||||
|
||||
def check_dialog(server, game, out):
|
||||
server.console('scoreboard objectives add answer trigger')
|
||||
server.console(f'scoreboard players enable {PLAYER} answer')
|
||||
@@ -139,6 +221,40 @@ def check_dialog(server, game, out):
|
||||
return shown
|
||||
|
||||
|
||||
def check_book_menu(server, game, out, proxy):
|
||||
"""An active book is the only rendered/interactive screen over a menu."""
|
||||
server.console(f'item replace entity {PLAYER} weapon.mainhand with minecraft:written_book'
|
||||
'[minecraft:written_book_content={title:"Overlay",author:"Test",pages:["First","Second"]}]')
|
||||
game.until(lambda: game('state')['held'],
|
||||
lambda held: held is not None and held['item'] == 'minecraft:written_book')
|
||||
x, y, z = (int(v // 1) for v in game('state')['player']['position'])
|
||||
at = (x + 2, y, z)
|
||||
server.console(f'setblock {at[0]} {at[1]} {at[2]} minecraft:chest')
|
||||
game.wait(10)
|
||||
game.raw('interact', *at)
|
||||
game.raw('wait-for', 'screen', 'container', '--timeout', 30)
|
||||
assert any(w['kind'] == 'slot' for w in game.raw('ui')['widgets'])
|
||||
proxy.open_book()
|
||||
game.raw('wait-for', 'screen', 'book', '--timeout', 10)
|
||||
game.wait(3)
|
||||
game.shot(out, 'book-over-menu')
|
||||
ui = game.raw('ui')
|
||||
assert not any(w['kind'] == 'slot' for w in ui['widgets']), ui
|
||||
game.raw('book', 'next')
|
||||
assert game.raw('book')['page'] == 1
|
||||
game.raw('book', 'done')
|
||||
game.raw('wait-for', 'screen', 'container', '--timeout', 10)
|
||||
state = game('state')
|
||||
assert not state['pointer_grabbed'], 'book close grabbed the mouse behind the menu'
|
||||
assert any(w['kind'] == 'slot' for w in game.raw('ui')['widgets'])
|
||||
game.shot(out, 'menu-after-book')
|
||||
game.raw('key', 'esc', 'tap')
|
||||
game.raw('wait-for', 'screen', 'playing', '--timeout', 10)
|
||||
server.console(f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
|
||||
return {'book_excludes_menu': True, 'pages_work': True,
|
||||
'menu_restored': True, 'menu_keeps_mouse': True}
|
||||
|
||||
|
||||
def check_titles(server, game, out):
|
||||
# AbstractContainerScreen draws the title the server sent, its styles included (#1645). A named
|
||||
# chest is what a plugin's menu looks like: its CustomName is the open_screen title.
|
||||
@@ -160,7 +276,7 @@ def check_titles(server, game, out):
|
||||
return {'title': title}
|
||||
|
||||
|
||||
CHECKS = {'dialog': check_dialog, 'titles': check_titles}
|
||||
CHECKS = {'dialog': check_dialog, 'titles': check_titles, 'book_menu': check_book_menu}
|
||||
|
||||
|
||||
def run(bins, out, checks):
|
||||
@@ -170,12 +286,15 @@ def run(bins, out, checks):
|
||||
directory = work / 'server'
|
||||
directory.mkdir()
|
||||
props = dict(PROPERTIES, **{'server-port': str(port)})
|
||||
if 'book_menu' in checks:
|
||||
props['network-compression-threshold'] = '256'
|
||||
(directory / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()))
|
||||
(directory / 'eula.txt').write_text('eula=true\n')
|
||||
(work / 'log4j2.xml').write_text(LOG4J)
|
||||
classpath = os.pathsep.join(map(str, [jars['server'], *libraries]))
|
||||
server = Server(['java', '-Xmx2g', f'-Dlog4j.configurationFile={work / "log4j2.xml"}', '-cp', classpath,
|
||||
'net.minecraft.server.Main', 'nogui'], directory, 'vanilla')
|
||||
proxy = BookPacketProxy(port) if 'book_menu' in checks else None
|
||||
game = Game('client', bins / 'horizon-ctl', lab.RESOURCES.resolve())
|
||||
for key in ('XDG_DATA_HOME', 'XDG_CONFIG_HOME', 'HORIZON_LAUNCHER_HOME'):
|
||||
game.d.env[key] = str(game.work / key.lower())
|
||||
@@ -184,20 +303,23 @@ def run(bins, out, checks):
|
||||
try:
|
||||
server.wait_for(r'Done \(')
|
||||
server.console('gamerule spawn_mobs false')
|
||||
game('launch', wait=False, offscreen=sys.platform == 'darwin')
|
||||
game.raw('connect', f'127.0.0.1:{port}', PLAYER)
|
||||
game('launch', wait=False, offscreen=True)
|
||||
game.raw('connect', f'127.0.0.1:{proxy.port if proxy else port}', PLAYER)
|
||||
game.raw('wait-for', 'loaded', '--timeout', 300)
|
||||
server.wait_for(rf'{PLAYER} joined the game')
|
||||
server.console(f'op {PLAYER}')
|
||||
game.wait(20)
|
||||
for name in checks:
|
||||
try:
|
||||
results[name] = {'pass': True, 'detail': CHECKS[name](server, game, out)}
|
||||
detail = CHECKS[name](server, game, out, proxy) if name == 'book_menu' else CHECKS[name](server, game, out)
|
||||
results[name] = {'pass': True, 'detail': detail}
|
||||
except (Failed, AssertionError, StopIteration, KeyError) as e:
|
||||
results[name] = {'pass': False, 'detail': repr(e)}
|
||||
print(f'[server] {name}: {"PASS" if results[name]["pass"] else "FAIL"} {results[name]["detail"]}')
|
||||
finally:
|
||||
(out / 'client.log').write_text(game.close())
|
||||
if proxy:
|
||||
proxy.close()
|
||||
server.stop()
|
||||
shutil.copy(server.log_path, out / 'server.log')
|
||||
shutil.rmtree(game.work, ignore_errors=True)
|
||||
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user