mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 17:19:10 -10:00
Compare commits
119
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7bb2259d6 | ||
|
|
a7df9ec019 | ||
|
|
76aa871c1e | ||
|
|
3a264943c7 | ||
|
|
5afa1de5f7 | ||
|
|
40e044da77 | ||
|
|
e7275ddee3 | ||
|
|
f7b73ed5f6 | ||
|
|
1c2ed6b613 | ||
|
|
c2d352e0a3 | ||
|
|
64c0291da2 | ||
|
|
4f1ebf7deb | ||
|
|
f4ee603baf | ||
|
|
daf749bbcd | ||
|
|
27877c639e | ||
|
|
dbb78b8a4c | ||
|
|
c4f9b6fc7f | ||
|
|
03d815532e | ||
|
|
e12185820a | ||
|
|
8f91c1534e | ||
|
|
00e0ecbdaf | ||
|
|
3e642d0acd | ||
|
|
f31198cbf7 | ||
|
|
b82e2a4b35 | ||
|
|
c9434646de | ||
|
|
9784cf63fd | ||
|
|
84ad92068f | ||
|
|
79a60dea88 | ||
|
|
38ce16338c | ||
|
|
2eb2bc1538 | ||
|
|
2556a20c74 | ||
|
|
cc2bc8fb84 | ||
|
|
e9eb640ac0 | ||
|
|
d5be28d945 | ||
|
|
8a6024f8e1 | ||
|
|
2b874aea1c | ||
|
|
3011a79600 | ||
|
|
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 | ||
|
|
50fdee8614 | ||
|
|
351c82aded | ||
|
|
6d3917100d | ||
|
|
78792c54ea | ||
|
|
dea101aafb | ||
|
|
4aa12eb27f | ||
|
|
d3c7a961e8 | ||
|
|
64688576ee | ||
|
|
194163c333 | ||
|
|
f579f440f4 | ||
|
|
779ab7dd4c | ||
|
|
e4b2778edb | ||
|
|
0d95220d49 | ||
|
|
b440c53cee | ||
|
|
9762e4f3db | ||
|
|
21832e9390 | ||
|
|
c33c3acf9b | ||
|
|
44b03616c1 | ||
|
|
1d20a9db1d | ||
|
|
2b78d2638e | ||
|
|
6219424298 | ||
|
|
55487cb931 | ||
|
|
20e429c3f8 | ||
|
|
c481f9c68a | ||
|
|
a2f0a5baa8 | ||
|
|
dd428288d1 | ||
|
|
78a634a191 | ||
|
|
3c0a4db876 | ||
|
|
c3911b7608 | ||
|
|
0cfd1d821c | ||
|
|
5b47e1f45b | ||
|
|
912941a8f7 | ||
|
|
0a0c2fd5f1 | ||
|
|
7a39319074 | ||
|
|
873ccc1ecf | ||
|
|
5f75afdbd0 | ||
|
|
d02c07cde6 | ||
|
|
b73e5b7c98 | ||
|
|
7ec58998d6 | ||
|
|
7a52fde28f | ||
|
|
3711915577 | ||
|
|
89b6008f8b | ||
|
|
53ba4b5a0a | ||
|
|
af3a264af1 | ||
|
|
835f2b5222 |
No files matched your search
@@ -14,7 +14,8 @@
|
||||
//!
|
||||
//! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json
|
||||
//!
|
||||
//! It prints one JSON line a second (bots online, chunks received) and writes a summary with `--report`.
|
||||
//! It prints one JSON line a second (bots online, chunks received), one with the first bot's spawn position, and
|
||||
//! writes a summary with `--report`.
|
||||
//! A bot moves only while it holds the chunk it stands in: vanilla's client stops ticking its player in a chunk
|
||||
//! it hasn't received, so a player can't outrun the server's chunk sending, on any server.
|
||||
//! Each bot also keeps the chunks it holds, as a client's chunk cache does, so the report tells chunks that
|
||||
@@ -102,6 +103,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
to test what a server takes
|
||||
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
|
||||
--report FILE a JSON summary at the end (- prints it after the per-second lines)
|
||||
--capture FILE record the first bot's play packets (milliseconds since it joined, id, body)
|
||||
for crates/horizon-protocol/examples/net_replay.rs
|
||||
";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -177,6 +180,8 @@ struct Options {
|
||||
/// The `minecraft:brand` to send, if any.
|
||||
brand: Option<String>,
|
||||
report: Option<String>,
|
||||
/// --capture: where the first bot's play packets go.
|
||||
capture: Option<String>,
|
||||
/// How many times as fast as vanilla's 20 Hz the bots tick (a timer cheat above 1).
|
||||
timer: f64,
|
||||
}
|
||||
@@ -207,6 +212,7 @@ fn options() -> Result<Options, String> {
|
||||
click: None,
|
||||
name: "Bot".into(),
|
||||
report: None,
|
||||
capture: None,
|
||||
brand: None,
|
||||
timer: 1.0,
|
||||
};
|
||||
@@ -256,6 +262,7 @@ fn options() -> Result<Options, String> {
|
||||
"--attack-rounds" => o.attack_rounds = value.parse().map_err(|_| number("a number"))?,
|
||||
"--name" => o.name = value,
|
||||
"--report" => o.report = Some(value),
|
||||
"--capture" => o.capture = Some(value),
|
||||
"--brand" => o.brand = Some(value),
|
||||
"--give" => {
|
||||
for part in value.split(',') {
|
||||
@@ -819,12 +826,53 @@ impl BatchSize {
|
||||
}
|
||||
}
|
||||
|
||||
/// `--capture`: one record per play packet, little-endian: u32 milliseconds since the
|
||||
/// bot joined, i32 packet id, u32 body length, then the body (without the id).
|
||||
struct Capture {
|
||||
out: std::io::BufWriter<std::fs::File>,
|
||||
start: Instant,
|
||||
/// The second last flushed: the process may end before the reader thread does.
|
||||
flushed: u32,
|
||||
}
|
||||
impl Capture {
|
||||
fn create(path: &str) -> Option<Self> {
|
||||
match std::fs::File::create(path) {
|
||||
Ok(file) => Some(Self {
|
||||
out: std::io::BufWriter::new(file),
|
||||
start: Instant::now(),
|
||||
flushed: 0,
|
||||
}),
|
||||
Err(e) => {
|
||||
eprintln!("--capture {path}: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
fn record(&mut self, id: i32, body: &[u8]) {
|
||||
use std::io::Write;
|
||||
let ms = self.start.elapsed().as_millis() as u32;
|
||||
let mut head = [0u8; 12];
|
||||
head[..4].copy_from_slice(&ms.to_le_bytes());
|
||||
head[4..8].copy_from_slice(&id.to_le_bytes());
|
||||
head[8..].copy_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
let _ = self
|
||||
.out
|
||||
.write_all(&head)
|
||||
.and_then(|()| self.out.write_all(body));
|
||||
if ms / 1000 != self.flushed {
|
||||
self.flushed = ms / 1000;
|
||||
let _ = self.out.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the play state: answers what a client must answer, counts chunks, records teleports.
|
||||
fn read(
|
||||
mut reader: horizon_protocol::connection::ConnReader,
|
||||
writer: Arc<Mutex<ConnWriter>>,
|
||||
bot: Arc<Bot>,
|
||||
track: bool,
|
||||
mut capture: Option<Capture>,
|
||||
) {
|
||||
use ids::play::clientbound as cb;
|
||||
use ids::play::serverbound as sb;
|
||||
@@ -848,6 +896,9 @@ fn read(
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(c) = &mut capture {
|
||||
c.record(id, &body);
|
||||
}
|
||||
let mut r = Reader::new(&body);
|
||||
let sent = match id {
|
||||
cb::KEEP_ALIVE => match r.i64() {
|
||||
@@ -1007,7 +1058,10 @@ fn run_bot(
|
||||
{
|
||||
let (writer, bot) = (writer.clone(), bot.clone());
|
||||
let track = behaviour == Behaviour::Fight;
|
||||
thread::spawn(move || read(reader, writer, bot, track));
|
||||
let capture = (index == 0)
|
||||
.then(|| o.capture.as_deref().and_then(Capture::create))
|
||||
.flatten();
|
||||
thread::spawn(move || read(reader, writer, bot, track, capture));
|
||||
}
|
||||
use ids::play::serverbound as sb;
|
||||
// Wait for the spawn position.
|
||||
@@ -1025,6 +1079,10 @@ fn run_bot(
|
||||
};
|
||||
bot.joined_after_ms
|
||||
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
|
||||
if index == 0 {
|
||||
// Where the bots gather, for a client that comes to watch them (tools/perf_bench.py --server-bots).
|
||||
println!("{}", json!({ "spawn": spawn }));
|
||||
}
|
||||
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
|
||||
if !o.no_loaded {
|
||||
send(raw(sb::PLAYER_LOADED, |_| {}));
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -518,7 +518,8 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `name` is one of the streaming pool's threads, vanilla's `Util` worker threads.
|
||||
/// Whether `name` is one of the shared worker pool's threads (`horizon_threads::jobs`), vanilla's
|
||||
/// `Util` worker threads.
|
||||
fn is_worker_thread(name: Option<&str>) -> bool {
|
||||
name.is_some_and(|n| n.starts_with("horizon-worker-"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -110,6 +110,7 @@ mod arena;
|
||||
pub mod compositor;
|
||||
mod cull;
|
||||
mod end_sky;
|
||||
pub mod entity_skin;
|
||||
mod flat;
|
||||
mod frame_gen;
|
||||
mod hdr;
|
||||
@@ -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
|
||||
@@ -626,6 +628,9 @@ pub struct Frame<'a> {
|
||||
pub glowing: &'a [Vertex],
|
||||
/// Runs of `entities` by entity, while a shader pack draws them (`pack_draw::EntitySpan`).
|
||||
pub entity_spans: &'a [pack_draw::EntitySpan],
|
||||
/// Ordinary entity models posed on the GPU (`entity_skin`), drawn before `entities`'
|
||||
/// ordinary ones; only when `Renderer::skins_entities` said so.
|
||||
pub skin: Option<&'a entity_skin::SkinFrame>,
|
||||
pub weather: &'a [crate::weather::Vertex],
|
||||
pub weather_time: crate::weather::Time,
|
||||
/// Horizon Extras' dynamic lights, nearest first (`dynamic_light::select`); empty when off.
|
||||
@@ -855,6 +860,8 @@ pub struct Renderer {
|
||||
entity_buffer: wgpu::Buffer,
|
||||
entity_capacity: u64,
|
||||
entity_batch: EntityBatch,
|
||||
/// Ordinary entity models posed on the GPU; `None` without compute shaders.
|
||||
entity_skin: Option<entity_skin::EntitySkin>,
|
||||
group: wgpu::BindGroup,
|
||||
globals: wgpu::Buffer,
|
||||
/// The resource packs' LabPBR maps (normals, specular), for native shader packs.
|
||||
@@ -1451,6 +1458,9 @@ struct Opened {
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
device_lost: Arc<std::sync::atomic::AtomicBool>,
|
||||
/// The window surface's configuration, set on `device` during the search, and the
|
||||
/// surface's capabilities on `adapter`. None offscreen.
|
||||
configured: Option<(wgpu::SurfaceConfiguration, wgpu::SurfaceCapabilities)>,
|
||||
}
|
||||
/// Logs the adapter and records it for telemetry and crash reports.
|
||||
fn describe_adapter(adapter: &wgpu::Adapter) {
|
||||
@@ -1513,6 +1523,88 @@ fn surface_format(
|
||||
.or_else(|| formats.iter().copied().find(|f| !f.is_srgb()))
|
||||
.unwrap_or(default)
|
||||
}
|
||||
/// Sets the window surface up on `device`: the swapchain format (`surface_format`), the
|
||||
/// copies screenshots and frame generation make, and Mailbox when the surface offers it
|
||||
/// (`keep_mode`, a replaced renderer's mode, first). `caps` are the surface's capabilities
|
||||
/// on `adapter`. Errs, instead of wgpu's panic, when the surface can't be configured on
|
||||
/// this device: a hybrid laptop's discrete GPU under Wayland accepts the surface in
|
||||
/// `request_adapter` and fails only here (#13493).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn configure_surface(
|
||||
surface: &wgpu::Surface<'_>,
|
||||
adapter: &wgpu::Adapter,
|
||||
device: &wgpu::Device,
|
||||
device_lost: &std::sync::atomic::AtomicBool,
|
||||
caps: wgpu::SurfaceCapabilities,
|
||||
size: winit::dpi::PhysicalSize<u32>,
|
||||
keep_mode: Option<wgpu::PresentMode>,
|
||||
log_step: bool,
|
||||
) -> std::result::Result<(wgpu::SurfaceConfiguration, wgpu::SurfaceCapabilities), String> {
|
||||
let max_side = device.limits().max_texture_dimension_2d;
|
||||
let mut config = surface
|
||||
.get_default_config(
|
||||
adapter,
|
||||
size.width.clamp(1, max_side),
|
||||
size.height.clamp(1, max_side),
|
||||
)
|
||||
.ok_or("surface has no supported configuration")?;
|
||||
config.format = surface_format(&caps.formats, config.format);
|
||||
config.view_formats = vec![config.format.remove_srgb_suffix()];
|
||||
// Screenshots copy the presented frame back from the swapchain, frame
|
||||
// generation's present thread copies its frames in.
|
||||
if caps.usages.contains(wgpu::TextureUsages::COPY_SRC) {
|
||||
config.usage |= wgpu::TextureUsages::COPY_SRC;
|
||||
}
|
||||
if caps.usages.contains(wgpu::TextureUsages::COPY_DST) {
|
||||
config.usage |= wgpu::TextureUsages::COPY_DST;
|
||||
}
|
||||
config.present_mode = keep_mode
|
||||
.filter(|mode| caps.present_modes.contains(mode))
|
||||
.unwrap_or_else(|| {
|
||||
if caps.present_modes.contains(&wgpu::PresentMode::Mailbox) {
|
||||
wgpu::PresentMode::Mailbox
|
||||
} else {
|
||||
wgpu::PresentMode::Fifo
|
||||
}
|
||||
});
|
||||
if log_step {
|
||||
startup_step(format_args!(
|
||||
"device ready; configuring the surface ({}x{} {:?}, {:?})",
|
||||
config.width, config.height, config.format, config.present_mode
|
||||
));
|
||||
}
|
||||
Renderer::configure(device, surface, &config, None, device_lost)?;
|
||||
Ok((config, caps))
|
||||
}
|
||||
/// What `pick_adapter` knows of one of a backend's adapters.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct AdapterChoice {
|
||||
kind: wgpu::DeviceType,
|
||||
/// The window surface offers it formats (`Surface::get_capabilities`).
|
||||
presents: bool,
|
||||
/// Already tried, and it could not present.
|
||||
tried: bool,
|
||||
}
|
||||
/// The adapter to try after one that could not present to the window: one not tried yet
|
||||
/// that the surface offers formats on, a software one only when `allow_cpu`, ranked as
|
||||
/// `request_adapter` ranks for `HighPerformance` (discrete, integrated, other, virtual,
|
||||
/// CPU), the first listed among equals. A hybrid laptop's integrated GPU drives the
|
||||
/// Wayland compositor and presents when its discrete GPU can't (#13493).
|
||||
fn pick_adapter(adapters: &[AdapterChoice], allow_cpu: bool) -> Option<usize> {
|
||||
let rank = |kind| match kind {
|
||||
wgpu::DeviceType::DiscreteGpu => 0,
|
||||
wgpu::DeviceType::IntegratedGpu => 1,
|
||||
wgpu::DeviceType::Other => 2,
|
||||
wgpu::DeviceType::VirtualGpu => 3,
|
||||
wgpu::DeviceType::Cpu => 4,
|
||||
};
|
||||
adapters
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, a)| a.presents && !a.tried && (allow_cpu || !is_software(a.kind)))
|
||||
.min_by_key(|(_, a)| rank(a.kind))
|
||||
.map(|(i, _)| i)
|
||||
}
|
||||
/// Opens a device on the first of `backend_order`'s backends that gives one.
|
||||
/// Offscreen rendering needs no surface, so it can use a GPU the window's
|
||||
/// display cannot present from (a headless compositor).
|
||||
@@ -1522,37 +1614,64 @@ async fn open_device(
|
||||
api: GraphicsApi,
|
||||
layers: u32,
|
||||
) -> Result<Opened> {
|
||||
// A software adapter (WARP, llvmpipe) is a last resort: a later backend may
|
||||
// have the real GPU (#3978: a Fermi GPU with no Vulkan and no DX12 driver
|
||||
// still has OpenGL).
|
||||
match open_device_with(window, offscreen, api, layers, false).await {
|
||||
Ok(opened) => Ok(opened),
|
||||
Err(first) => match open_device_with(window, offscreen, api, layers, true).await {
|
||||
let wgpu_backend = std::env::var_os("WGPU_BACKEND").is_some();
|
||||
let mut first = None;
|
||||
for (order, allow_cpu) in device_search(api.with_env(), std::env::consts::OS, wgpu_backend) {
|
||||
match open_device_with(window, offscreen, &order, layers, allow_cpu).await {
|
||||
Ok(opened) => {
|
||||
log::warn!(
|
||||
"Horizon renderer: no GPU adapter could be used ({first}); \
|
||||
rendering on the CPU, which is slow"
|
||||
);
|
||||
Ok(opened)
|
||||
if let Some(first) = &first {
|
||||
log::warn!(
|
||||
"Horizon renderer: the chosen graphics API gave no GPU ({first}); {}",
|
||||
if allow_cpu {
|
||||
"rendering on the CPU, which is slow"
|
||||
} else {
|
||||
"using the GPU through another one"
|
||||
}
|
||||
);
|
||||
}
|
||||
return Ok(opened);
|
||||
}
|
||||
Err(_) => Err(first),
|
||||
},
|
||||
Err(e) => {
|
||||
first.get_or_insert(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(first.unwrap_or_else(|| NO_GPU_DRIVER.into()))
|
||||
}
|
||||
/// `open_device`, taking an adapter of the CPU type (software rendering) only
|
||||
/// when `allow_cpu`.
|
||||
/// The tries `open_device` makes, in turn: the chosen API's backends (`backend_order`) on a
|
||||
/// real GPU; when the player chose one API, the other backends `Auto` would try, on a real
|
||||
/// GPU; and last the chosen backends with a software adapter (WARP, llvmpipe) allowed.
|
||||
/// Software rendering takes most of a weak CPU even on the title screen: an Intel HD 2500 has
|
||||
/// DirectX 12 only through WARP and its GPU through OpenGL (#5185), and a Fermi GPU has no
|
||||
/// Vulkan or DirectX 12 driver but OpenGL (#3978). DirectX 12 never falls back to Vulkan: the
|
||||
/// launcher chooses it after Vulkan crashed in the driver (#746), which the game can't catch.
|
||||
fn device_search(
|
||||
api: GraphicsApi,
|
||||
os: &str,
|
||||
wgpu_backend: bool,
|
||||
) -> Vec<(Vec<wgpu::Backends>, bool)> {
|
||||
let chosen = backend_order(api, os, wgpu_backend);
|
||||
let others: Vec<_> = backend_order(GraphicsApi::Auto, os, wgpu_backend)
|
||||
.into_iter()
|
||||
.filter(|b| !chosen.contains(b))
|
||||
.filter(|&b| !(api == GraphicsApi::Dx12 && b == wgpu::Backends::VULKAN))
|
||||
.collect();
|
||||
let mut tries = vec![(chosen.clone(), false)];
|
||||
if !others.is_empty() {
|
||||
tries.push((others, false));
|
||||
}
|
||||
tries.push((chosen, true));
|
||||
tries
|
||||
}
|
||||
/// `open_device` on `order`'s backends in turn, taking an adapter of the CPU type
|
||||
/// (software rendering) only when `allow_cpu`.
|
||||
async fn open_device_with(
|
||||
window: &Arc<Window>,
|
||||
offscreen: bool,
|
||||
api: GraphicsApi,
|
||||
order: &[wgpu::Backends],
|
||||
layers: u32,
|
||||
allow_cpu: bool,
|
||||
) -> Result<Opened> {
|
||||
let order = backend_order(
|
||||
api.with_env(),
|
||||
std::env::consts::OS,
|
||||
std::env::var_os("WGPU_BACKEND").is_some(),
|
||||
);
|
||||
let mut failures = Vec::new();
|
||||
for (attempt, backends) in order.iter().copied().enumerate() {
|
||||
let name = backends_name(backends);
|
||||
@@ -1654,11 +1773,92 @@ async fn open_backend(
|
||||
adapter.get_info().name
|
||||
)));
|
||||
}
|
||||
describe_adapter(&adapter);
|
||||
// Adapters that could not present to the window: the next one of this backend
|
||||
// that can is tried before the next backend (#13493).
|
||||
let mut tried = Vec::new();
|
||||
let mut adapter = adapter;
|
||||
loop {
|
||||
let why = match open_adapter(name, window, surface.as_ref(), &adapter, layers).await {
|
||||
Ok(adapted) => {
|
||||
return Ok(Opened {
|
||||
instance,
|
||||
surface,
|
||||
adapter,
|
||||
device: adapted.device,
|
||||
queue: adapted.queue,
|
||||
device_lost: adapted.device_lost,
|
||||
configured: adapted.configured,
|
||||
});
|
||||
}
|
||||
Err(AdapterFailure::Failed(e)) => return Err(NoDevice::Failed(e)),
|
||||
Err(AdapterFailure::CannotPresent(why)) => why,
|
||||
};
|
||||
let info = adapter.get_info();
|
||||
tried.push(info.clone());
|
||||
let others = instance.enumerate_adapters(wgpu::Backends::all()).await;
|
||||
let choices: Vec<_> = others
|
||||
.iter()
|
||||
.map(|other| AdapterChoice {
|
||||
kind: other.get_info().device_type,
|
||||
presents: surface
|
||||
.as_ref()
|
||||
.is_some_and(|s| !s.get_capabilities(other).formats.is_empty()),
|
||||
tried: tried.contains(&other.get_info()),
|
||||
})
|
||||
.collect();
|
||||
let Some(next) = pick_adapter(&choices, allow_cpu) else {
|
||||
log::warn!(
|
||||
"Horizon renderer: skipping {} on {name}: it can't present to the window \
|
||||
({why}), and no other {name} adapter can",
|
||||
info.name
|
||||
);
|
||||
return Err(NoDevice::Failed(format!(
|
||||
"{name}: {} can't present to the window ({why})",
|
||||
info.name
|
||||
)));
|
||||
};
|
||||
adapter = others[next].clone();
|
||||
log::warn!(
|
||||
"Horizon renderer: skipping {} on {name}: it can't present to the window ({why}); \
|
||||
trying {}",
|
||||
info.name,
|
||||
adapter.get_info().name
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Why an adapter gave no device: it can't present to the window, so another adapter of
|
||||
/// the backend may, or another failure.
|
||||
enum AdapterFailure {
|
||||
CannotPresent(String),
|
||||
Failed(String),
|
||||
}
|
||||
/// A device opened on one adapter, with the window surface set up on it.
|
||||
struct Adapted {
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
device_lost: Arc<std::sync::atomic::AtomicBool>,
|
||||
configured: Option<(wgpu::SurfaceConfiguration, wgpu::SurfaceCapabilities)>,
|
||||
}
|
||||
/// Opens a device on `adapter` and configures the window's `surface` on it (none
|
||||
/// offscreen). An adapter the surface offers no format on fails before its device opens.
|
||||
async fn open_adapter(
|
||||
name: &str,
|
||||
window: &Arc<Window>,
|
||||
surface: Option<&wgpu::Surface<'static>>,
|
||||
adapter: &wgpu::Adapter,
|
||||
layers: u32,
|
||||
) -> std::result::Result<Adapted, AdapterFailure> {
|
||||
let caps = surface.map(|surface| surface.get_capabilities(adapter));
|
||||
if caps.as_ref().is_some_and(|caps| caps.formats.is_empty()) {
|
||||
return Err(AdapterFailure::CannotPresent(
|
||||
"the window surface offers it no format".into(),
|
||||
));
|
||||
}
|
||||
describe_adapter(adapter);
|
||||
let info = adapter.get_info();
|
||||
let limits = adapter.limits();
|
||||
if let Err(e) = check_layers(&adapter, layers) {
|
||||
return Err(NoDevice::Failed(format!("{name}: {e}")));
|
||||
if let Err(e) = check_layers(adapter, layers) {
|
||||
return Err(AdapterFailure::Failed(format!("{name}: {e}")));
|
||||
}
|
||||
// Mappable primary buffers: section pages that no longer fit in
|
||||
// video memory go to system memory. Shader packs' 16-bit normalized buffers (render
|
||||
@@ -1666,9 +1866,9 @@ async fn open_backend(
|
||||
// stand-in neither clamps nor drops NaN. Subgroups: shader packs' compute programs
|
||||
// (`MC_GL_KHR_shader_subgroup_*`); storage buffers in vertex shaders, clearing and
|
||||
// atomics on images: their buffers and images.
|
||||
let features = timing::GpuTimer::features(&adapter)
|
||||
| cull::Cull::features(&adapter)
|
||||
| oit::features(&adapter)
|
||||
let features = timing::GpuTimer::features(adapter)
|
||||
| cull::Cull::features(adapter)
|
||||
| oit::features(adapter)
|
||||
| (adapter.features()
|
||||
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
|
||||
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
|
||||
@@ -1703,7 +1903,7 @@ async fn open_backend(
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let opened = match info.backend {
|
||||
wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(&adapter, &desc),
|
||||
wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(adapter, &desc),
|
||||
_ => None,
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -1712,31 +1912,42 @@ async fn open_backend(
|
||||
Some(opened) => opened,
|
||||
None => adapter.request_device(&desc).await,
|
||||
};
|
||||
match opened {
|
||||
Ok((device, queue)) => {
|
||||
let device_lost = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let lost = device_lost.clone();
|
||||
device.set_device_lost_callback(move |reason, message| {
|
||||
// A device destroyed on purpose was not lost.
|
||||
if reason == wgpu::DeviceLostReason::Destroyed {
|
||||
return;
|
||||
}
|
||||
log::warn!(
|
||||
"Horizon renderer: the graphics device was lost ({reason:?}: {message})"
|
||||
);
|
||||
lost.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
});
|
||||
Ok(Opened {
|
||||
instance,
|
||||
let (device, queue) = match opened {
|
||||
Ok(opened) => opened,
|
||||
Err(e) => return Err(AdapterFailure::Failed(format!("{name} device: {e}"))),
|
||||
};
|
||||
let device_lost = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let lost = device_lost.clone();
|
||||
device.set_device_lost_callback(move |reason, message| {
|
||||
// A device destroyed on purpose was not lost.
|
||||
if reason == wgpu::DeviceLostReason::Destroyed {
|
||||
return;
|
||||
}
|
||||
log::warn!("Horizon renderer: the graphics device was lost ({reason:?}: {message})");
|
||||
lost.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
});
|
||||
let configured = match (surface, caps) {
|
||||
(Some(surface), Some(caps)) => Some(
|
||||
configure_surface(
|
||||
surface,
|
||||
adapter,
|
||||
device,
|
||||
queue,
|
||||
device_lost,
|
||||
})
|
||||
}
|
||||
Err(e) => Err(NoDevice::Failed(format!("{name} device: {e}"))),
|
||||
}
|
||||
&device,
|
||||
&device_lost,
|
||||
caps,
|
||||
window.inner_size(),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.map_err(AdapterFailure::CannotPresent)?,
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
Ok(Adapted {
|
||||
device,
|
||||
queue,
|
||||
device_lost,
|
||||
configured,
|
||||
})
|
||||
}
|
||||
/// What to tell a player whose system offers no usable graphics adapter.
|
||||
/// Vulkan is loaded at run time (the game links no graphics library), so a
|
||||
@@ -1836,6 +2047,7 @@ impl Renderer {
|
||||
device,
|
||||
queue,
|
||||
device_lost,
|
||||
configured,
|
||||
} = match previous {
|
||||
// Reload resources on the same device; replacing live Vulkan
|
||||
// devices can invalidate the driver's swapchain state on the same
|
||||
@@ -1862,6 +2074,7 @@ impl Renderer {
|
||||
device: renderer.device.clone(),
|
||||
queue: renderer.queue.clone(),
|
||||
device_lost: renderer.device_lost.clone(),
|
||||
configured: None,
|
||||
}
|
||||
}
|
||||
None => {
|
||||
@@ -1879,42 +2092,21 @@ impl Renderer {
|
||||
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
|
||||
let size = window.inner_size();
|
||||
let max_side = device.limits().max_texture_dimension_2d;
|
||||
let (config, present_modes, hdr_output) = if let Some(surface) = &surface {
|
||||
let mut config = surface
|
||||
.get_default_config(
|
||||
&adapter,
|
||||
size.width.clamp(1, max_side),
|
||||
size.height.clamp(1, max_side),
|
||||
)
|
||||
.ok_or("surface has no supported configuration")?;
|
||||
let caps = surface.get_capabilities(&adapter);
|
||||
config.format = surface_format(&caps.formats, config.format);
|
||||
config.view_formats = vec![config.format.remove_srgb_suffix()];
|
||||
// Screenshots copy the presented frame back from the swapchain, frame
|
||||
// generation's present thread copies its frames in.
|
||||
if caps.usages.contains(wgpu::TextureUsages::COPY_SRC) {
|
||||
config.usage |= wgpu::TextureUsages::COPY_SRC;
|
||||
}
|
||||
if caps.usages.contains(wgpu::TextureUsages::COPY_DST) {
|
||||
config.usage |= wgpu::TextureUsages::COPY_DST;
|
||||
}
|
||||
config.present_mode = previous
|
||||
.map(|renderer| renderer.config.present_mode)
|
||||
.filter(|mode| caps.present_modes.contains(mode))
|
||||
.unwrap_or_else(|| {
|
||||
if caps.present_modes.contains(&wgpu::PresentMode::Mailbox) {
|
||||
wgpu::PresentMode::Mailbox
|
||||
} else {
|
||||
wgpu::PresentMode::Fifo
|
||||
}
|
||||
});
|
||||
if previous.is_none() {
|
||||
startup_step(format_args!(
|
||||
"device ready; configuring the surface ({}x{} {:?}, {:?})",
|
||||
config.width, config.height, config.format, config.present_mode
|
||||
));
|
||||
}
|
||||
surface.configure(&device, &config);
|
||||
let configured = match (&surface, configured) {
|
||||
(Some(_), Some(configured)) => Some(configured),
|
||||
(Some(surface), None) => Some(configure_surface(
|
||||
surface,
|
||||
&adapter,
|
||||
&device,
|
||||
&device_lost,
|
||||
surface.get_capabilities(&adapter),
|
||||
size,
|
||||
previous.map(|renderer| renderer.config.present_mode),
|
||||
previous.is_none(),
|
||||
)?),
|
||||
(None, _) => None,
|
||||
};
|
||||
let (config, present_modes, hdr_output) = if let Some((config, caps)) = configured {
|
||||
let hdr_output = hdr::output(&caps);
|
||||
crate::graphics::offer_hdr(hdr_output.is_some());
|
||||
if previous.is_none() {
|
||||
@@ -1923,7 +2115,7 @@ impl Renderer {
|
||||
hdr_output.map_or("not offered by this display", hdr::Output::name)
|
||||
);
|
||||
}
|
||||
(config, caps.present_modes.clone(), hdr_output)
|
||||
(config, caps.present_modes, hdr_output)
|
||||
} else {
|
||||
let format = wgpu::TextureFormat::Bgra8Unorm;
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
@@ -3442,10 +3634,14 @@ impl Renderer {
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let entity_capacity = 1 << 14;
|
||||
let entity_skin = entity_skin::EntitySkin::new(
|
||||
&device,
|
||||
animate::SpriteAnimator::compute(&adapter, &device),
|
||||
);
|
||||
let entity_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Entity vertices"),
|
||||
size: entity_capacity * std::mem::size_of::<Vertex>() as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
usage: entity_skin::EntitySkin::usage(entity_skin.is_some()),
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let flat_wanted = flat::wanted(&adapter, &device);
|
||||
@@ -3671,6 +3867,7 @@ impl Renderer {
|
||||
entity_buffer,
|
||||
entity_capacity,
|
||||
entity_batch: EntityBatch::default(),
|
||||
entity_skin,
|
||||
group,
|
||||
globals,
|
||||
pbr_maps,
|
||||
@@ -4840,6 +5037,23 @@ impl Renderer {
|
||||
}
|
||||
/// The share of real frames frame generation shows a made frame before (1 while it
|
||||
/// keeps up), `None` while it isn't running.
|
||||
/// Whether the game may pose ordinary entity models on the GPU (`Frame::skin`): not
|
||||
/// while a shader pack or a native pack draws entities from their vertices, nor while
|
||||
/// frame generation follows them.
|
||||
/// The posed models' store size (`SkinFrame::base`), with entity skinning.
|
||||
pub fn skinned_models(&self) -> Option<u32> {
|
||||
self.entity_skin
|
||||
.as_ref()
|
||||
.map(entity_skin::EntitySkin::stored)
|
||||
}
|
||||
|
||||
pub fn skins_entities(&self) -> bool {
|
||||
self.entity_skin.is_some()
|
||||
&& !self.frame_gen.on
|
||||
&& self.pack_post.is_none()
|
||||
&& self.native.is_none()
|
||||
}
|
||||
|
||||
pub fn frame_generation_share(&self) -> Option<f32> {
|
||||
self.frame_gen.made_share
|
||||
}
|
||||
@@ -5688,6 +5902,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));
|
||||
@@ -6370,6 +6595,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())
|
||||
@@ -6394,6 +6625,13 @@ impl Renderer {
|
||||
oit_on,
|
||||
);
|
||||
self.entity_batch.append_shadow_only(frame.shadow_only);
|
||||
// The posed models' vertices come first: the ordinary entities' draw takes them in.
|
||||
let skin = frame.skin.filter(|skin| match &mut self.entity_skin {
|
||||
Some(skinner) => skinner.register(&self.device, &mut self.uploads, &mut encoder, skin),
|
||||
None => false,
|
||||
});
|
||||
let posed = skin.map_or(0, |s| s.vertices);
|
||||
let entity_quads = entity_quads + posed / 4;
|
||||
let weather_quads = frame.weather.len() as u32 / 4;
|
||||
let world_quads = entity_quads
|
||||
+ text_quads
|
||||
@@ -6449,7 +6687,7 @@ impl Renderer {
|
||||
camera,
|
||||
);
|
||||
// Frame generation's made frame draws its own hand, halfway, after the others.
|
||||
let made_hand_vertex = self.entity_batch.combined.len() as i32;
|
||||
let made_hand_vertex = (posed as usize + self.entity_batch.combined.len()) as i32;
|
||||
if fg {
|
||||
self.frame_gen
|
||||
.halfway_hand(frame.hand, hand_view, &mut self.entity_batch.combined);
|
||||
@@ -6466,24 +6704,34 @@ impl Renderer {
|
||||
let additive_quads = (frame.additive.len() / 4) as u32 - self.entity_batch.moved_additive;
|
||||
let see_through_quads = (frame.see_through.len() / 4) as u32;
|
||||
let shadow_only_quads = (frame.shadow_only.len() / 4) as u32;
|
||||
let entity_vertices = all_entities.len() as u64;
|
||||
let entity_vertices = u64::from(posed) + all_entities.len() as u64;
|
||||
if entity_vertices > self.entity_capacity {
|
||||
self.entity_capacity = entity_vertices.next_power_of_two();
|
||||
self.entity_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Entity vertices"),
|
||||
size: self.entity_capacity * std::mem::size_of::<Vertex>() as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
usage: entity_skin::EntitySkin::usage(self.entity_skin.is_some()),
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
}
|
||||
if entity_vertices > 0 {
|
||||
if !all_entities.is_empty() {
|
||||
self.uploads.buffer(
|
||||
&mut encoder,
|
||||
&self.entity_buffer,
|
||||
0,
|
||||
u64::from(posed) * std::mem::size_of::<Vertex>() as u64,
|
||||
bytemuck::cast_slice(all_entities),
|
||||
);
|
||||
}
|
||||
if let (Some(skinner), Some(skin)) = (&mut self.entity_skin, skin) {
|
||||
skinner.dispatch(
|
||||
&self.device,
|
||||
&mut self.uploads,
|
||||
&mut encoder,
|
||||
skin,
|
||||
&self.entity_buffer,
|
||||
0,
|
||||
);
|
||||
}
|
||||
self.entity_outline.prepare(
|
||||
&self.device,
|
||||
&mut self.uploads,
|
||||
@@ -8504,8 +8752,9 @@ impl Renderer {
|
||||
if let Some(radius) = menu_blur {
|
||||
let format = self.config.format;
|
||||
let device = &self.device;
|
||||
let software = is_software(self.info.device_type);
|
||||
self.menu_blur
|
||||
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format))
|
||||
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format, software))
|
||||
.apply(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
@@ -9294,6 +9543,73 @@ mod tests {
|
||||
assert_eq!(order(api, "macos"), [Backends::default()]);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn a_chosen_api_without_a_gpu_tries_the_others_before_software() {
|
||||
use super::{GraphicsApi, device_search};
|
||||
use wgpu::Backends;
|
||||
let (v, d, g) = (Backends::VULKAN, Backends::DX12, Backends::GL);
|
||||
// #5185: DirectX 12 chosen on an Intel HD 2500 (WARP only): OpenGL's GPU before WARP.
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::Dx12, "windows", false),
|
||||
[(vec![d], false), (vec![g], false), (vec![d], true)]
|
||||
);
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::Vulkan, "windows", false),
|
||||
[(vec![v], false), (vec![d, g], false), (vec![v], true)]
|
||||
);
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::Auto, "windows", false),
|
||||
[(vec![v, d, g], false), (vec![v, d, g], true)]
|
||||
);
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::OpenGl, "linux", false),
|
||||
[(vec![g], false), (vec![v], false), (vec![g], true)]
|
||||
);
|
||||
// WGPU_BACKEND and macOS: one set of backends, nothing else to try.
|
||||
let all = Backends::default();
|
||||
for (os, env) in [("windows", true), ("macos", false)] {
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::Vulkan, os, env),
|
||||
[(vec![all], false), (vec![all], true)]
|
||||
);
|
||||
}
|
||||
}
|
||||
/// #13493: a hybrid laptop under Wayland. The NVIDIA GPU can't present to the window,
|
||||
/// so the integrated GPU that drives the compositor is next, never the one that failed.
|
||||
#[test]
|
||||
fn an_adapter_that_cannot_present_gives_way_to_one_that_can() {
|
||||
use super::{AdapterChoice, pick_adapter};
|
||||
use wgpu::DeviceType::{Cpu, DiscreteGpu, IntegratedGpu, Other, VirtualGpu};
|
||||
let a = |kind, presents, tried| AdapterChoice {
|
||||
kind,
|
||||
presents,
|
||||
tried,
|
||||
};
|
||||
// The NVIDIA GPU failed to configure, the Intel one presents: the Intel one.
|
||||
let hybrid = [a(DiscreteGpu, true, true), a(IntegratedGpu, true, false)];
|
||||
assert_eq!(pick_adapter(&hybrid, false), Some(1));
|
||||
// Among adapters that present, HighPerformance's order: discrete first, then
|
||||
// integrated, other, virtual; the first listed among equals.
|
||||
let many = [
|
||||
a(VirtualGpu, true, false),
|
||||
a(Other, true, false),
|
||||
a(IntegratedGpu, true, false),
|
||||
a(DiscreteGpu, false, false),
|
||||
a(DiscreteGpu, true, false),
|
||||
a(DiscreteGpu, true, false),
|
||||
];
|
||||
assert_eq!(pick_adapter(&many, false), Some(4));
|
||||
assert_eq!(pick_adapter(&many[..4], false), Some(2));
|
||||
assert_eq!(pick_adapter(&many[..2], false), Some(1));
|
||||
// Nothing left that presents: the next backend (OpenGL) is tried.
|
||||
let none = [a(DiscreteGpu, true, true), a(IntegratedGpu, false, false)];
|
||||
assert_eq!(pick_adapter(&none, false), None);
|
||||
assert_eq!(pick_adapter(&[], false), None);
|
||||
// A software adapter only on the search's last, software-allowed try.
|
||||
let llvmpipe = [a(DiscreteGpu, true, true), a(Cpu, true, false)];
|
||||
assert_eq!(pick_adapter(&llvmpipe, false), None);
|
||||
assert_eq!(pick_adapter(&llvmpipe, true), Some(1));
|
||||
}
|
||||
/// The second try at a backend shows the adapters wgpu hides for reporting
|
||||
/// no Vulkan conformance version (Mesa's hasvk, which drives Ivy Bridge and
|
||||
/// Haswell, reports 0.0.0.0 and works).
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
//! Entity models posed on the GPU. The CPU path (`mobs::render::draw`) walks a
|
||||
//! model's parts and pushes every vertex of every polygon, transformed by its
|
||||
//! part's matrix: ~280 vertices of 44 bytes per mob, then copied by material
|
||||
//! and uploaded each frame. Here each model's vertices are uploaded once, in
|
||||
//! model space, and a frame only sends each drawn part's matrices and one job
|
||||
//! per run of drawn parts; `entity_skin.wgsl` writes the same vertices, in the
|
||||
//! same order, into the entity vertex buffer, after the CPU's ordinary entities
|
||||
//! (`EntityBatch` leaves the gap). Every pass that draws ordinary entities
|
||||
//! then draws them as before.
|
||||
//!
|
||||
//! The game decides which draws come here (`SkinFrame`): models whose
|
||||
//! polygons all face an axis, drawn as ordinary opaque or cutout entities.
|
||||
//! The rest stays on the CPU path.
|
||||
|
||||
use super::upload::Uploads;
|
||||
use crate::mesher::Vertex;
|
||||
|
||||
/// A model vertex: position / 16, model UV, the face's normal as an axis (0..2
|
||||
/// = +x, +y, +z; 3..5 = -x, -y, -z), the part's index in its model.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct SkinVertex {
|
||||
pub pos: [f32; 3],
|
||||
pub uv: [f32; 2],
|
||||
pub axis: u32,
|
||||
pub part: u32,
|
||||
}
|
||||
|
||||
/// A drawn part: its geometry matrix and its normal matrix, by columns, the
|
||||
/// normal matrix's columns normalized when the part's scale is not uniform.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct SkinPart {
|
||||
pub matrix: [f32; 16],
|
||||
pub normal: [f32; 9],
|
||||
}
|
||||
|
||||
/// One run of a model's vertices drawn with one texture rect, colour, light
|
||||
/// and overlay. `dst` counts from the frame's first posed vertex.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct SkinJob {
|
||||
pub first: u32,
|
||||
pub count: u32,
|
||||
pub part: u32,
|
||||
pub dst: u32,
|
||||
pub rect: [f32; 4],
|
||||
pub scroll: f32,
|
||||
pub color: u32,
|
||||
pub light: u32,
|
||||
pub overlay: u32,
|
||||
/// 1 when the overlay is the armour glint: the model UV rides in `glint_uv`.
|
||||
pub glint: u32,
|
||||
pub spare: [u32; 3],
|
||||
}
|
||||
|
||||
const _: () = assert!(std::mem::size_of::<Vertex>() == 44);
|
||||
const _: () = assert!(std::mem::size_of::<SkinVertex>() == 7 * 4);
|
||||
const _: () = assert!(std::mem::size_of::<SkinPart>() == 25 * 4);
|
||||
const _: () = assert!(std::mem::size_of::<SkinJob>() == 16 * 4);
|
||||
|
||||
/// What the game hands the renderer for one frame.
|
||||
#[derive(Default)]
|
||||
pub struct SkinFrame {
|
||||
/// The model store restarted (models are registered again from 0).
|
||||
pub reset: bool,
|
||||
/// The store's size before `new_model`: the renderer takes the frame only
|
||||
/// when its own store is that large (it missed none of the earlier ones).
|
||||
pub base: u32,
|
||||
/// Model vertices registered since the last frame, at the store's end.
|
||||
pub new_model: Vec<SkinVertex>,
|
||||
pub parts: Vec<SkinPart>,
|
||||
pub jobs: Vec<SkinJob>,
|
||||
/// Vertices the jobs write: a whole number of quads.
|
||||
pub vertices: u32,
|
||||
}
|
||||
|
||||
impl SkinFrame {
|
||||
/// Starts a frame's draws; models not yet handed to the renderer stay.
|
||||
pub fn clear_draws(&mut self) {
|
||||
self.parts.clear();
|
||||
self.jobs.clear();
|
||||
self.vertices = 0;
|
||||
}
|
||||
|
||||
/// The renderer took the new models.
|
||||
pub fn handed(&mut self) {
|
||||
self.base += self.new_model.len() as u32;
|
||||
self.new_model.clear();
|
||||
self.reset = false;
|
||||
}
|
||||
|
||||
/// Where the draws stand, to take back a mob's (`truncate`).
|
||||
pub fn mark(&self) -> (usize, usize, u32) {
|
||||
(self.jobs.len(), self.parts.len(), self.vertices)
|
||||
}
|
||||
|
||||
/// Takes back the draws since `mark` (a mob culled after drawing).
|
||||
pub fn truncate(&mut self, (jobs, parts, vertices): (usize, usize, u32)) {
|
||||
self.jobs.truncate(jobs);
|
||||
self.parts.truncate(parts);
|
||||
self.vertices = vertices;
|
||||
}
|
||||
|
||||
/// Whether the renderer has anything to take.
|
||||
pub fn pending(&self) -> bool {
|
||||
self.reset || !self.new_model.is_empty() || !self.jobs.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Jobs per row of workgroups (the dispatch limit is 65535 per dimension).
|
||||
const ROW: u32 = 32_768;
|
||||
|
||||
pub(super) struct EntitySkin {
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
model: Vec<SkinVertex>,
|
||||
model_buffer: Option<wgpu::Buffer>,
|
||||
parts: Grow,
|
||||
jobs: Grow,
|
||||
/// The jobs with `dst` moved to the frame's gap, as uploaded.
|
||||
staged: Vec<SkinJob>,
|
||||
/// The bind group and the buffers it binds (buffers compare by identity).
|
||||
group: Option<(wgpu::BindGroup, [wgpu::Buffer; 4])>,
|
||||
}
|
||||
|
||||
/// A storage buffer that grows to the next power of two.
|
||||
struct Grow {
|
||||
label: &'static str,
|
||||
buffer: Option<wgpu::Buffer>,
|
||||
}
|
||||
|
||||
impl Grow {
|
||||
fn ensure(&mut self, device: &wgpu::Device, bytes: u64) -> bool {
|
||||
let bytes = bytes.max(256).next_power_of_two();
|
||||
if self.buffer.as_ref().is_some_and(|b| b.size() >= bytes) {
|
||||
return false;
|
||||
}
|
||||
self.buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(self.label),
|
||||
size: bytes,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}));
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl EntitySkin {
|
||||
/// The entity vertex buffer's usage: the jobs write into it.
|
||||
pub(super) fn usage(skinning: bool) -> wgpu::BufferUsages {
|
||||
let usage = wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST;
|
||||
if skinning {
|
||||
usage | wgpu::BufferUsages::STORAGE
|
||||
} else {
|
||||
usage
|
||||
}
|
||||
}
|
||||
|
||||
/// The compute pipeline, or `None` without compute shaders (the CPU path
|
||||
/// then draws every model).
|
||||
pub(super) fn new(device: &wgpu::Device, compute: bool) -> Option<Self> {
|
||||
// HORIZON_NO_ENTITY_SKIN: every model on the CPU path (A/B runs, a driver's bug).
|
||||
if !compute
|
||||
|| device.limits().max_storage_buffers_per_shader_stage < 4
|
||||
|| std::env::var_os("HORIZON_NO_ENTITY_SKIN").is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Entity skinning"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("entity_skin.wgsl").into()),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Entity skinning"),
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("skin"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let layout = pipeline.get_bind_group_layout(0);
|
||||
let internal = pollster::block_on(internal.pop());
|
||||
if let Some(e) = internal.or(pollster::block_on(validation.pop())) {
|
||||
log::warn!("Horizon renderer: entity models posed on the CPU ({e})");
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
pipeline,
|
||||
layout,
|
||||
model: Vec::new(),
|
||||
model_buffer: None,
|
||||
parts: Grow {
|
||||
label: "Entity skinning parts",
|
||||
buffer: None,
|
||||
},
|
||||
jobs: Grow {
|
||||
label: "Entity skinning jobs",
|
||||
buffer: None,
|
||||
},
|
||||
staged: Vec::new(),
|
||||
group: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The model vertices stored (`SkinFrame::base` of the next frame).
|
||||
pub(super) fn stored(&self) -> u32 {
|
||||
self.model.len() as u32
|
||||
}
|
||||
|
||||
/// Takes the frame's new models; whether its jobs can run (the store
|
||||
/// holds every model they read).
|
||||
pub(super) fn register(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
uploads: &mut Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
frame: &SkinFrame,
|
||||
) -> bool {
|
||||
if frame.reset {
|
||||
self.model.clear();
|
||||
self.model_buffer = None;
|
||||
}
|
||||
if self.model.len() as u32 != frame.base {
|
||||
// Out of step with the game's store: it restarts it.
|
||||
return false;
|
||||
}
|
||||
if frame.new_model.is_empty() {
|
||||
return self.model_buffer.is_some() || frame.jobs.is_empty();
|
||||
}
|
||||
let old = self.model.len();
|
||||
self.model.extend_from_slice(&frame.new_model);
|
||||
let bytes = (self.model.len() * std::mem::size_of::<SkinVertex>()) as u64;
|
||||
if self
|
||||
.model_buffer
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.size() >= bytes)
|
||||
{
|
||||
let buffer = self.model_buffer.as_ref().expect("checked");
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
buffer,
|
||||
(old * std::mem::size_of::<SkinVertex>()) as u64,
|
||||
bytemuck::cast_slice(&frame.new_model),
|
||||
);
|
||||
} else {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Entity skinning models"),
|
||||
size: bytes.max(256).next_power_of_two(),
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
uploads.buffer(encoder, &buffer, 0, bytemuck::cast_slice(&self.model));
|
||||
self.model_buffer = Some(buffer);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Records the frame's jobs: they write `frame.vertices` vertices into
|
||||
/// `out` from vertex `base` on. Call after the CPU vertices' upload.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn dispatch(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
uploads: &mut Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
frame: &SkinFrame,
|
||||
out: &wgpu::Buffer,
|
||||
base: u32,
|
||||
) {
|
||||
if frame.jobs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(model) = &self.model_buffer else {
|
||||
return;
|
||||
};
|
||||
// Whole rows of workgroups past `ROW` jobs: the padding jobs write nothing.
|
||||
let count = frame.jobs.len() as u32;
|
||||
let rows = count.div_ceil(ROW);
|
||||
let padded = if rows > 1 {
|
||||
(rows * ROW) as usize
|
||||
} else {
|
||||
frame.jobs.len()
|
||||
};
|
||||
self.staged.clear();
|
||||
self.staged.extend(frame.jobs.iter().map(|job| SkinJob {
|
||||
dst: job.dst + base,
|
||||
..*job
|
||||
}));
|
||||
self.staged.resize(padded, SkinJob::default());
|
||||
self.parts.ensure(
|
||||
device,
|
||||
(frame.parts.len() * std::mem::size_of::<SkinPart>()) as u64,
|
||||
);
|
||||
self.jobs
|
||||
.ensure(device, (padded * std::mem::size_of::<SkinJob>()) as u64);
|
||||
let (Some(parts), Some(jobs)) = (&self.parts.buffer, &self.jobs.buffer) else {
|
||||
return;
|
||||
};
|
||||
uploads.buffer(encoder, parts, 0, bytemuck::cast_slice(&frame.parts));
|
||||
uploads.buffer(encoder, jobs, 0, bytemuck::cast_slice(&self.staged));
|
||||
let key = [model.clone(), parts.clone(), jobs.clone(), out.clone()];
|
||||
if self.group.as_ref().is_none_or(|(_, k)| *k != key) {
|
||||
let entries: Vec<_> = key
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(binding, buffer)| wgpu::BindGroupEntry {
|
||||
binding: binding as u32,
|
||||
resource: buffer.as_entire_binding(),
|
||||
})
|
||||
.collect();
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Entity skinning"),
|
||||
layout: &self.layout,
|
||||
entries: &entries,
|
||||
});
|
||||
self.group = Some((group, key));
|
||||
}
|
||||
let Some((group, _)) = &self.group else {
|
||||
return;
|
||||
};
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Entity skinning"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups(count.min(ROW), rows, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// What `entity_skin.wgsl` writes for `frame`'s jobs, computed on the CPU
|
||||
/// the same way: the reference its tests hold the shader and the game's walk
|
||||
/// (`mobs::skin`) to. `model` is the whole store.
|
||||
pub fn expand(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
|
||||
let mut out = vec![bytemuck::Zeroable::zeroed(); frame.vertices as usize];
|
||||
for job in &frame.jobs {
|
||||
for k in 0..job.count {
|
||||
let v = model[(job.first + k) as usize];
|
||||
let part = &frame.parts[(job.part + v.part) as usize];
|
||||
let m = glam::Mat4::from_cols_array(&part.matrix);
|
||||
let pos = m.transform_point3(glam::Vec3::from_array(v.pos));
|
||||
let c = (v.axis % 3) as usize * 3;
|
||||
let column = glam::Vec3::from_slice(&part.normal[c..c + 3]);
|
||||
let n = if v.axis >= 3 { -column } else { column };
|
||||
let packed = n.to_array().map(|v| (v.clamp(-1.0, 1.0) * 127.0) as i8);
|
||||
let u = if job.scroll == 0.0 {
|
||||
v.uv[0]
|
||||
} else {
|
||||
(v.uv[0] + job.scroll) % 1.0
|
||||
};
|
||||
let r = job.rect;
|
||||
out[(job.dst + k) as usize] = Vertex {
|
||||
pos: pos.to_array(),
|
||||
uv: [r[0] + (r[2] - r[0]) * u, r[1] + (r[3] - r[1]) * v.uv[1]],
|
||||
layer: job.overlay,
|
||||
color: job.color,
|
||||
light: job.light,
|
||||
normal: [packed[0], packed[1], packed[2], -127],
|
||||
glint_uv: if job.glint != 0 { v.uv } else { [0.0, 0.0] },
|
||||
};
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
/// A cube's 24 vertices for part `part`, its faces on every axis.
|
||||
fn cube(part: u32) -> Vec<SkinVertex> {
|
||||
let mut out = Vec::new();
|
||||
for axis in 0..6u32 {
|
||||
for corner in 0..4u32 {
|
||||
let a = (axis % 3) as usize;
|
||||
let sign = if axis >= 3 { -0.25 } else { 0.25 };
|
||||
let mut pos = [0.0f32; 3];
|
||||
pos[a] = sign;
|
||||
pos[(a + 1) % 3] = if corner & 1 == 0 { -0.25 } else { 0.25 } + 0.1 * part as f32;
|
||||
pos[(a + 2) % 3] = if corner & 2 == 0 { -0.3 } else { 0.3 };
|
||||
out.push(SkinVertex {
|
||||
pos,
|
||||
uv: [corner as f32 / 3.0, 0.125 * axis as f32 + 0.0625],
|
||||
axis,
|
||||
part,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn part(m: glam::Mat4, normal: glam::Mat3) -> SkinPart {
|
||||
SkinPart {
|
||||
matrix: m.to_cols_array(),
|
||||
normal: normal.to_cols_array(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Two draws of a two-part model with turned, scaled parts, a scrolled
|
||||
/// texture and the armour glint: the shader writes what `expand` does,
|
||||
/// to the bit, after the vertices before them.
|
||||
#[test]
|
||||
fn the_shader_writes_what_the_cpu_reference_computes() {
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let Some(mut skin) = EntitySkin::new(&device, true) else {
|
||||
eprintln!("no compute shaders: skipped");
|
||||
return;
|
||||
};
|
||||
let model: Vec<SkinVertex> = cube(0).into_iter().chain(cube(1)).collect();
|
||||
let turn =
|
||||
glam::Mat4::from_quat(glam::Quat::from_euler(glam::EulerRot::ZYX, 0.3, -1.1, 2.0));
|
||||
let base = glam::Mat4::from_translation(glam::Vec3::new(-3.7, 1.25, 9.1)) * turn;
|
||||
let child = base
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.2, -0.4, 0.05))
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(1.0, 2.5, 0.5));
|
||||
let normals = glam::Mat3::from_mat4(child).inverse().transpose();
|
||||
let normals = glam::Mat3::from_cols(
|
||||
normals.x_axis.normalize_or_zero(),
|
||||
normals.y_axis.normalize_or_zero(),
|
||||
normals.z_axis.normalize_or_zero(),
|
||||
);
|
||||
let frame = SkinFrame {
|
||||
reset: false,
|
||||
base: 0,
|
||||
new_model: model.clone(),
|
||||
parts: vec![
|
||||
part(base, glam::Mat3::from_mat4(turn)),
|
||||
part(child, normals),
|
||||
part(base * turn, glam::Mat3::from_mat4(turn * turn)),
|
||||
part(child * turn, glam::Mat3::IDENTITY),
|
||||
],
|
||||
jobs: vec![
|
||||
SkinJob {
|
||||
first: 0,
|
||||
count: 48,
|
||||
part: 0,
|
||||
dst: 0,
|
||||
rect: [0.25, 0.5, 0.375, 0.5625],
|
||||
scroll: 0.0,
|
||||
color: 0xff80_40ff,
|
||||
light: 0xf0_70,
|
||||
overlay: 1,
|
||||
..Default::default()
|
||||
},
|
||||
SkinJob {
|
||||
first: 24,
|
||||
count: 24,
|
||||
part: 2,
|
||||
dst: 48,
|
||||
rect: [0.0, 0.0, 0.0625, 0.0625],
|
||||
scroll: 0.73,
|
||||
color: u32::MAX,
|
||||
light: 0xf0f0,
|
||||
overlay: 36,
|
||||
glint: 1,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
vertices: 72,
|
||||
};
|
||||
let expected = expand(&model, &frame);
|
||||
// The posed vertices land after 5 others, which they leave alone.
|
||||
let before = 5u32;
|
||||
let out = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(before + frame.vertices) * 44,
|
||||
usage: EntitySkin::usage(true) | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let sentinel = vec![0xabu8; before as usize * 44];
|
||||
queue.write_buffer(&out, 0, &sentinel);
|
||||
let mut uploads = Uploads::new(&device);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
assert!(skin.register(&device, &mut uploads, &mut encoder, &frame));
|
||||
skin.dispatch(&device, &mut uploads, &mut encoder, &frame, &out, before);
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: out.size(),
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_buffer_to_buffer(&out, 0, &read, 0, out.size());
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let data = read.slice(..).get_mapped_range().unwrap();
|
||||
assert_eq!(&data[..sentinel.len()], &sentinel[..]);
|
||||
let got: &[Vertex] = bytemuck::cast_slice(&data[sentinel.len()..]);
|
||||
for (i, (got, want)) in got.iter().zip(&expected).enumerate() {
|
||||
assert_eq!(
|
||||
bytemuck::bytes_of(got),
|
||||
bytemuck::bytes_of(want),
|
||||
"vertex {i}: {got:?} != {want:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Entity models posed on the GPU (gfx/entity_skin.rs): each job is one draw of
|
||||
// a model (`mobs::render::draw`), and writes the same vertices the CPU path
|
||||
// would push, in the same order, into the entity vertex buffer. One workgroup
|
||||
// per job; its invocations walk the model's vertices.
|
||||
//
|
||||
// Words, not structs: every record is read and written as u32 words, so the
|
||||
// layouts are the Rust ones byte for byte.
|
||||
|
||||
// A model vertex (7 words): position / 16, model UV, the face's normal as an
|
||||
// axis (0..2 = +x, +y, +z; 3..5 = -x, -y, -z), the part's index in the model.
|
||||
const MODEL_WORDS: u32 = 7u;
|
||||
// A posed part (25 words): the geometry matrix (16, by columns), then the
|
||||
// normal matrix (9, by columns), its columns already normalized when the
|
||||
// part's scale is not uniform (`ModelNormals.trusted` false).
|
||||
const PART_WORDS: u32 = 25u;
|
||||
// A job (16 words): first model vertex, vertex count, first part, first output
|
||||
// vertex, the texture rect (4 floats), scroll, colour, light, overlay,
|
||||
// whether the overlay is the armour glint (its model UV in `glint_uv`), 3 spare.
|
||||
const JOB_WORDS: u32 = 16u;
|
||||
// `mesher::Vertex` (11 words): pos (3), uv (2), layer, color, light, normal
|
||||
// (4 x i8), glint_uv (2).
|
||||
const VERTEX_WORDS: u32 = 11u;
|
||||
|
||||
@group(0) @binding(0) var<storage, read> model: array<u32>;
|
||||
@group(0) @binding(1) var<storage, read> parts: array<u32>;
|
||||
@group(0) @binding(2) var<storage, read> jobs: array<u32>;
|
||||
@group(0) @binding(3) var<storage, read_write> out: array<u32>;
|
||||
|
||||
fn f(words: u32) -> f32 {
|
||||
return bitcast<f32>(words);
|
||||
}
|
||||
|
||||
fn part_f(part: u32, i: u32) -> f32 {
|
||||
return f(parts[part * PART_WORDS + i]);
|
||||
}
|
||||
|
||||
// `mobs::render::scroll_u`.
|
||||
fn scroll_u(u: f32, scroll: f32) -> f32 {
|
||||
if scroll == 0.0 {
|
||||
return u;
|
||||
}
|
||||
// Rust's `%` on floats: the remainder with the dividend's sign.
|
||||
let s = u + scroll;
|
||||
return s - trunc(s);
|
||||
}
|
||||
|
||||
// `Polygon::vertex_normal`'s packing: clamped, times 127, cut toward zero.
|
||||
fn pack(n: vec3<f32>) -> u32 {
|
||||
let b = vec3<i32>(clamp(n, vec3(-1.0), vec3(1.0)) * 127.0);
|
||||
return (u32(b.x) & 0xffu) | ((u32(b.y) & 0xffu) << 8u) | ((u32(b.z) & 0xffu) << 16u)
|
||||
| (u32(-127 & 0xff) << 24u);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn skin(
|
||||
@builtin(workgroup_id) group: vec3<u32>,
|
||||
@builtin(num_workgroups) groups: vec3<u32>,
|
||||
@builtin(local_invocation_index) lane: u32,
|
||||
) {
|
||||
let job = group.y * groups.x + group.x;
|
||||
if job >= arrayLength(&jobs) / JOB_WORDS {
|
||||
return;
|
||||
}
|
||||
let j = job * JOB_WORDS;
|
||||
let first = jobs[j];
|
||||
let count = jobs[j + 1u];
|
||||
let first_part = jobs[j + 2u];
|
||||
let dst = jobs[j + 3u];
|
||||
let rect = vec4(f(jobs[j + 4u]), f(jobs[j + 5u]), f(jobs[j + 6u]), f(jobs[j + 7u]));
|
||||
let scroll = f(jobs[j + 8u]);
|
||||
let color = jobs[j + 9u];
|
||||
let light = jobs[j + 10u];
|
||||
let overlay = jobs[j + 11u];
|
||||
let glint = jobs[j + 12u] != 0u;
|
||||
for (var k = lane; k < count; k += 64u) {
|
||||
let v = (first + k) * MODEL_WORDS;
|
||||
let p = vec3(f(model[v]), f(model[v + 1u]), f(model[v + 2u]));
|
||||
let uv = vec2(f(model[v + 3u]), f(model[v + 4u]));
|
||||
let axis = model[v + 5u];
|
||||
let part = first_part + model[v + 6u];
|
||||
// glam's Mat4::transform_point3, in its order.
|
||||
var pos = vec3(part_f(part, 0u), part_f(part, 1u), part_f(part, 2u)) * p.x;
|
||||
pos = vec3(part_f(part, 4u), part_f(part, 5u), part_f(part, 6u)) * p.y + pos;
|
||||
pos = vec3(part_f(part, 8u), part_f(part, 9u), part_f(part, 10u)) * p.z + pos;
|
||||
pos = vec3(part_f(part, 12u), part_f(part, 13u), part_f(part, 14u)) + pos;
|
||||
// An axis normal times the normal matrix is that column, exactly.
|
||||
let c = 16u + (axis % 3u) * 3u;
|
||||
var n = vec3(part_f(part, c), part_f(part, c + 1u), part_f(part, c + 2u));
|
||||
if axis >= 3u {
|
||||
n = -n;
|
||||
}
|
||||
let o = (dst + k) * VERTEX_WORDS;
|
||||
out[o] = bitcast<u32>(pos.x);
|
||||
out[o + 1u] = bitcast<u32>(pos.y);
|
||||
out[o + 2u] = bitcast<u32>(pos.z);
|
||||
out[o + 3u] = bitcast<u32>(rect.x + (rect.z - rect.x) * scroll_u(uv.x, scroll));
|
||||
out[o + 4u] = bitcast<u32>(rect.y + (rect.w - rect.y) * uv.y);
|
||||
out[o + 5u] = overlay;
|
||||
out[o + 6u] = color;
|
||||
out[o + 7u] = light;
|
||||
out[o + 8u] = pack(n);
|
||||
if glint {
|
||||
out[o + 9u] = bitcast<u32>(uv.x);
|
||||
out[o + 10u] = bitcast<u32>(uv.y);
|
||||
} else {
|
||||
out[o + 9u] = 0u;
|
||||
out[o + 10u] = 0u;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -969,6 +969,7 @@ mod initial_history_tests {
|
||||
emissive_layers: &[],
|
||||
shadow_only: &[],
|
||||
entity_spans: &[],
|
||||
skin: None,
|
||||
glowing: &[],
|
||||
dynamic_lights: &[],
|
||||
clouds: None,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
//! framebuffer pixels wide, before the screen is drawn over it. Works in the
|
||||
//! frame's own (gamma) values, as vanilla's main target does, and needs a
|
||||
//! swapchain that can be copied from.
|
||||
//!
|
||||
//! On a software adapter (WARP, llvmpipe) the rounds run at half the frame's
|
||||
//! width and height with half the radius: the same blur, a quarter of the
|
||||
//! pixels. At full size they took 250 ms of a CPU per frame at 1440x900, and
|
||||
//! every blurred menu ran at 3 FPS (#5185).
|
||||
|
||||
/// `blur.json`: the passes' directions, ping-ponging between the copied
|
||||
/// frame and a swap texture; the last one writes the frame.
|
||||
@@ -27,15 +32,20 @@ pub struct MenuBlur {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
sampler: wgpu::Sampler,
|
||||
/// One per pass, then the half-size copy's.
|
||||
uniforms: Vec<wgpu::Buffer>,
|
||||
/// The copied frame (`minecraft:main`) and `swap`.
|
||||
/// The copied frame (`minecraft:main`) and `swap`; at half size, then the
|
||||
/// frame copied at full size.
|
||||
targets: Vec<(wgpu::Texture, wgpu::TextureView)>,
|
||||
size: [u32; 2],
|
||||
format: wgpu::TextureFormat,
|
||||
/// Blur at half size (`new`'s `software`).
|
||||
half: bool,
|
||||
}
|
||||
impl MenuBlur {
|
||||
/// `format` is the swapchain's; the blur works in its non-sRGB view.
|
||||
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
|
||||
/// `format` is the swapchain's; the blur works in its non-sRGB view. `software`:
|
||||
/// the adapter renders on the CPU, so the blur runs at half size.
|
||||
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, software: bool) -> Self {
|
||||
let format = format.remove_srgb_suffix();
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Menu blur"),
|
||||
@@ -111,8 +121,7 @@ impl MenuBlur {
|
||||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||
..Default::default()
|
||||
});
|
||||
let uniforms = PASSES
|
||||
.iter()
|
||||
let uniforms = (0..=PASSES.len())
|
||||
.map(|_| {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Menu blur pass"),
|
||||
@@ -130,6 +139,15 @@ impl MenuBlur {
|
||||
targets: Vec::new(),
|
||||
size: [0, 0],
|
||||
format,
|
||||
half: software,
|
||||
}
|
||||
}
|
||||
/// The size the blur's rounds run at for a frame of `size`.
|
||||
fn work_size(&self, size: [u32; 2]) -> [u32; 2] {
|
||||
if self.half {
|
||||
size.map(|s| s.div_ceil(2))
|
||||
} else {
|
||||
size
|
||||
}
|
||||
}
|
||||
fn ensure(&mut self, device: &wgpu::Device, size: [u32; 2]) {
|
||||
@@ -137,8 +155,15 @@ impl MenuBlur {
|
||||
return;
|
||||
}
|
||||
self.size = size;
|
||||
self.targets = (0..2)
|
||||
.map(|_| {
|
||||
let work = self.work_size(size);
|
||||
let sizes = if self.half {
|
||||
vec![work, work, size]
|
||||
} else {
|
||||
vec![work, work]
|
||||
};
|
||||
self.targets = sizes
|
||||
.into_iter()
|
||||
.map(|size| {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Menu blur target"),
|
||||
size: wgpu::Extent3d {
|
||||
@@ -180,65 +205,112 @@ impl MenuBlur {
|
||||
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
|
||||
) {
|
||||
self.ensure(device, size);
|
||||
let work = self.work_size(size);
|
||||
let copy = if self.half { 2 } else { 0 };
|
||||
encoder.copy_texture_to_texture(
|
||||
frame.as_image_copy(),
|
||||
self.targets[0].0.as_image_copy(),
|
||||
self.targets[copy].0.as_image_copy(),
|
||||
wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
let radius = if self.half { radius / 2.0 } else { radius };
|
||||
if self.half {
|
||||
// A radius of 0 samples each half-size pixel's 2x2 block at its shared
|
||||
// corner: their mean.
|
||||
let data = Uniform {
|
||||
size_dir: [size[0] as f32, size[1] as f32, 0.0, 0.0],
|
||||
radius: [0.0; 4],
|
||||
};
|
||||
let (source, output) = (&self.targets[2].1, &self.targets[0].1);
|
||||
self.pass(
|
||||
device,
|
||||
queue,
|
||||
encoder,
|
||||
PASSES.len(),
|
||||
data,
|
||||
source,
|
||||
output,
|
||||
None,
|
||||
);
|
||||
}
|
||||
for (i, dir) in PASSES.iter().enumerate() {
|
||||
let data = Uniform {
|
||||
size_dir: [size[0] as f32, size[1] as f32, dir[0], dir[1]],
|
||||
size_dir: [work[0] as f32, work[1] as f32, dir[0], dir[1]],
|
||||
radius: [radius, 0.0, 0.0, 0.0],
|
||||
};
|
||||
queue.write_buffer(&self.uniforms[i], 0, bytemuck::bytes_of(&data));
|
||||
let source = &self.targets[i % 2].1;
|
||||
// The last pass writes the frame; from half size it samples up as it blurs.
|
||||
let output = if i + 1 == PASSES.len() {
|
||||
target
|
||||
} else {
|
||||
&self.targets[(i + 1) % 2].1
|
||||
};
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Menu blur pass"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(source),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.uniforms[i].as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: output,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Menu blur pass"),
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Menu blur")),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
self.pass(
|
||||
device,
|
||||
queue,
|
||||
encoder,
|
||||
i,
|
||||
data,
|
||||
source,
|
||||
output,
|
||||
timing.as_deref_mut(),
|
||||
);
|
||||
}
|
||||
}
|
||||
/// One `box_blur` pass from `source` into `output` with uniform buffer `slot`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn pass(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
slot: usize,
|
||||
data: Uniform,
|
||||
source: &wgpu::TextureView,
|
||||
output: &wgpu::TextureView,
|
||||
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
|
||||
) {
|
||||
queue.write_buffer(&self.uniforms[slot], 0, bytemuck::bytes_of(&data));
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Menu blur pass"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(source),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.uniforms[slot].as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: output,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Menu blur pass"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("Menu blur")),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -318,7 +390,7 @@ mod tests {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb);
|
||||
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, false);
|
||||
let size = [64, 8];
|
||||
let first = blurred(&device, &queue, &mut blur, size);
|
||||
// The stripes are blurred into greys.
|
||||
@@ -327,6 +399,44 @@ mod tests {
|
||||
assert_eq!(blurred(&device, &queue, &mut blur, size), first);
|
||||
}
|
||||
|
||||
/// On a software adapter the blur runs at half size and still blurs the
|
||||
/// frame much as at full size: stripes two pixels wide turn to an even grey.
|
||||
#[test]
|
||||
fn the_half_size_blur_blurs_like_the_full_size_one() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let format = wgpu::TextureFormat::Rgba8UnormSrgb;
|
||||
let size = [64, 8];
|
||||
let full = blurred(
|
||||
&device,
|
||||
&queue,
|
||||
&mut MenuBlur::new(&device, format, false),
|
||||
size,
|
||||
);
|
||||
let half = blurred(
|
||||
&device,
|
||||
&queue,
|
||||
&mut MenuBlur::new(&device, format, true),
|
||||
size,
|
||||
);
|
||||
// Away from the edges, both are within a few levels of the stripes' mean grey.
|
||||
for row in 0..size[1] as usize {
|
||||
for x in 8..56 {
|
||||
let at = (row * size[0] as usize + x) * 4;
|
||||
for (name, frame) in [("full", &full), ("half", &half)] {
|
||||
let v = frame[at];
|
||||
assert!((120..=136).contains(&v), "{name} size: {v} at ({x}, {row})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_blur_shader_validates() {
|
||||
let module = naga::front::wgsl::parse_str(include_str!("menu_blur.wgsl")).unwrap();
|
||||
|
||||
@@ -10,9 +10,9 @@ mod sides;
|
||||
use crate::connected::{Pick, TileLayer};
|
||||
use crate::{
|
||||
assets::{Assets, RenderAssets},
|
||||
biome::Biomes,
|
||||
biome::{Biomes, GrassModifier},
|
||||
blocks::{BlockFacts, RenderShape},
|
||||
chunk::{Chunk, MIN_Y},
|
||||
chunk::{Chunk, Light, MIN_Y},
|
||||
graphics::LeafCullingMode,
|
||||
models::catalog::BakedFace,
|
||||
};
|
||||
@@ -73,6 +73,15 @@ pub struct TerrainVertex {
|
||||
/// RGBA8: tint multiplied by directional shade and ambient occlusion.
|
||||
pub color: u32,
|
||||
}
|
||||
/// `v.round().clamp(0.0, 65535.0) as u16`, without the `roundf` call x86-64's
|
||||
/// baseline instruction set makes of `f32::round`: clamped first (the same
|
||||
/// result), the fraction is exact below 2^23, and every float above is whole.
|
||||
#[inline]
|
||||
fn round_to_u16(v: f32) -> u16 {
|
||||
let v = v.clamp(0.0, f32::from(u16::MAX));
|
||||
let whole = v as u32;
|
||||
(whole + u32::from(v - whole as f32 >= 0.5)) as u16
|
||||
}
|
||||
/// Fixed-point steps per unit of `TerrainVertex` UVs, which span `[0, 2)`.
|
||||
pub const UV_STEPS: f32 = 32768.0;
|
||||
/// Fixed-point steps per block of distant-terrain UVs (`TerrainVertex::tiled`),
|
||||
@@ -82,7 +91,7 @@ impl TerrainVertex {
|
||||
pub fn new(pos: [f32; 3], uv: [f32; 2], layer: u16, color: u32, light: u16) -> Self {
|
||||
Self {
|
||||
pos,
|
||||
uv: uv.map(|v| (v * UV_STEPS).round().clamp(0.0, f32::from(u16::MAX)) as u16),
|
||||
uv: uv.map(|v| round_to_u16(v * UV_STEPS)),
|
||||
layer,
|
||||
light,
|
||||
color,
|
||||
@@ -93,7 +102,7 @@ impl TerrainVertex {
|
||||
pub fn tiled(pos: [f32; 3], uv: [f32; 2], layer: u16, color: u32, light: u16) -> Self {
|
||||
Self {
|
||||
pos,
|
||||
uv: uv.map(|v| (v * TILED_UV_STEPS).round().clamp(0.0, f32::from(u16::MAX)) as u16),
|
||||
uv: uv.map(|v| round_to_u16(v * TILED_UV_STEPS)),
|
||||
layer,
|
||||
light,
|
||||
color,
|
||||
@@ -116,6 +125,30 @@ mod vertex_tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn uv_rounding_matches_f32_round() {
|
||||
let reference = |v: f32| v.round().clamp(0.0, f32::from(u16::MAX)) as u16;
|
||||
let mut values = vec![
|
||||
f32::NAN,
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
-0.0,
|
||||
0.5,
|
||||
1.5,
|
||||
65534.5,
|
||||
1e9,
|
||||
];
|
||||
for bits in (0..u32::MAX).step_by(9973) {
|
||||
values.push(f32::from_bits(bits));
|
||||
}
|
||||
for i in 0..=140_000 {
|
||||
values.push(i as f32 * 0.5 - 2.0);
|
||||
values.push(i as f32 * 0.5 - 2.0 + f32::EPSILON);
|
||||
}
|
||||
for v in values {
|
||||
assert_eq!(super::round_to_u16(v), reference(v), "{v:e}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn other_uvs_round_within_a_step() {
|
||||
// A flowing fluid's rotated UV.
|
||||
let uv = [0.5 + 0.25 * 0.3f32.sin(), 0.5 - 0.25 * 0.3f32.cos()];
|
||||
@@ -333,94 +366,112 @@ pub const ALL_VISIBLE: u64 = (1 << 36) - 1;
|
||||
/// VisGraph.resolve: flood fill non-opaque cells and connect the faces each region touches.
|
||||
#[cfg(test)]
|
||||
fn visibility(pad: &Padded, info: &[Info]) -> u64 {
|
||||
visibility_reusing(pad, info, &mut Vec::new())
|
||||
let solid: Vec<bool> = info.iter().map(|i| i.solid).collect();
|
||||
let mut rows = Vec::new();
|
||||
opaque_rows(pad, &solid, &mut rows);
|
||||
visibility_rows(&open_rows(&rows))
|
||||
}
|
||||
fn visibility_reusing(pad: &Padded, info: &[Info], stack: &mut Vec<usize>) -> u64 {
|
||||
// The all-visible shortcut can leave a partially filled stack behind.
|
||||
stack.clear();
|
||||
// Opaque cells are already visited: resolve each state's opacity once,
|
||||
// then reuse this map for the flood fill and the uniform-section shortcuts.
|
||||
let mut seen = [false; 4096];
|
||||
let mut opaque_count = 0;
|
||||
for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
let row = pi(0, y, z);
|
||||
for x in 0..16 {
|
||||
let opaque = info[pad.blocks[row + x] as usize].solid;
|
||||
seen[(y as usize) << 8 | (z as usize) << 4 | x] = opaque;
|
||||
opaque_count += usize::from(opaque);
|
||||
}
|
||||
}
|
||||
/// The section's non-opaque cells by row (`y << 4 | z`, bit x) from the padded
|
||||
/// opacity rows (`MeshScratch::opaque`).
|
||||
fn open_rows(opaque: &[u32]) -> [u16; 256] {
|
||||
std::array::from_fn(|r| {
|
||||
let (y, z) = ((r >> 4) as i32, (r & 15) as i32);
|
||||
!(opaque[((y + B) as usize) * P + (z + B) as usize] >> B) as u16
|
||||
})
|
||||
}
|
||||
/// The cells of `open` connected to `seeds` (a subset) within the row: an
|
||||
/// occluded fill each way, doubling its reach at each step.
|
||||
#[inline]
|
||||
fn fill_row(seeds: u16, open: u16) -> u16 {
|
||||
let (mut up, mut through_up) = (seeds, open);
|
||||
let (mut down, mut through_down) = (seeds, open);
|
||||
for step in [1, 2, 4, 8] {
|
||||
up |= through_up & up << step;
|
||||
through_up &= through_up << step;
|
||||
down |= through_down & down >> step;
|
||||
through_down &= through_down >> step;
|
||||
}
|
||||
if opaque_count == 0 {
|
||||
up | down
|
||||
}
|
||||
/// `visibility` on bit rows, one region at a time: a region grows a row at
|
||||
/// a time (each row filled along its open runs at once) by sweeps over the
|
||||
/// rows it can reach, until a sweep adds nothing.
|
||||
fn visibility_rows(open: &[u16; 256]) -> u64 {
|
||||
let open_count: u32 = open.iter().map(|r| r.count_ones()).sum();
|
||||
if open_count == 4096 {
|
||||
return ALL_VISIBLE;
|
||||
}
|
||||
if opaque_count == 4096 {
|
||||
if open_count == 0 {
|
||||
return 0;
|
||||
}
|
||||
let mut unvisited = *open;
|
||||
let mut region = [0u16; 256];
|
||||
let mut result = 0u64;
|
||||
for start in 0..4096usize {
|
||||
if seen[start] {
|
||||
continue;
|
||||
}
|
||||
let mut faces = 0u8;
|
||||
seen[start] = true;
|
||||
stack.push(start);
|
||||
while let Some(c) = stack.pop() {
|
||||
let (x, y, z) = ((c & 15) as i32, (c >> 8) as i32, ((c >> 4) & 15) as i32);
|
||||
if y == 0 {
|
||||
faces |= 1 << 0;
|
||||
for start in 0..256 {
|
||||
while unvisited[start] != 0 {
|
||||
let seed = unvisited[start] & unvisited[start].wrapping_neg();
|
||||
region[start] = fill_row(seed, unvisited[start]);
|
||||
let (mut lo, mut hi) = (start, start);
|
||||
loop {
|
||||
let mut grown = false;
|
||||
let (first, last) = (lo.saturating_sub(16), (hi + 16).min(255));
|
||||
for r in (first..=last).chain((first..=last).rev()) {
|
||||
let mut near = region[r];
|
||||
if r >= 16 {
|
||||
near |= region[r - 16];
|
||||
}
|
||||
if r + 16 < 256 {
|
||||
near |= region[r + 16];
|
||||
}
|
||||
if r & 15 != 0 {
|
||||
near |= region[r - 1];
|
||||
}
|
||||
if r & 15 != 15 {
|
||||
near |= region[r + 1];
|
||||
}
|
||||
let reached = near & unvisited[r];
|
||||
if reached & !region[r] != 0 {
|
||||
region[r] = fill_row(reached, unvisited[r]);
|
||||
(lo, hi) = (lo.min(r), hi.max(r));
|
||||
grown = true;
|
||||
}
|
||||
}
|
||||
if !grown {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if y == 15 {
|
||||
faces |= 1 << 1;
|
||||
}
|
||||
if z == 0 {
|
||||
faces |= 1 << 2;
|
||||
}
|
||||
if z == 15 {
|
||||
faces |= 1 << 3;
|
||||
}
|
||||
if x == 0 {
|
||||
faces |= 1 << 4;
|
||||
}
|
||||
if x == 15 {
|
||||
faces |= 1 << 5;
|
||||
let mut faces = 0u8;
|
||||
for r in lo..=hi {
|
||||
let row = std::mem::take(&mut region[r]);
|
||||
if row == 0 {
|
||||
continue;
|
||||
}
|
||||
unvisited[r] &= !row;
|
||||
let (y, z) = (r >> 4, r & 15);
|
||||
faces |= u8::from(y == 0)
|
||||
| u8::from(y == 15) << 1
|
||||
| u8::from(z == 0) << 2
|
||||
| u8::from(z == 15) << 3
|
||||
| u8::from(row & 1 != 0) << 4
|
||||
| u8::from(row & 0x8000 != 0) << 5;
|
||||
}
|
||||
// A single connected region touching all six faces sets every
|
||||
// pair (including the diagonal); unvisited cavities cannot add more.
|
||||
if faces == 0b11_1111 {
|
||||
return ALL_VISIBLE;
|
||||
}
|
||||
for (dx, dy, dz) in [
|
||||
(1, 0, 0),
|
||||
(-1, 0, 0),
|
||||
(0, 1, 0),
|
||||
(0, -1, 0),
|
||||
(0, 0, 1),
|
||||
(0, 0, -1),
|
||||
] {
|
||||
let (nx, ny, nz) = (x + dx, y + dy, z + dz);
|
||||
if !(0..16).contains(&nx) || !(0..16).contains(&ny) || !(0..16).contains(&nz) {
|
||||
continue;
|
||||
}
|
||||
let n = (ny << 8 | nz << 4 | nx) as usize;
|
||||
if !seen[n] {
|
||||
seen[n] = true;
|
||||
stack.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
for a in 0..6 {
|
||||
if faces & (1 << a) != 0 {
|
||||
for b in 0..6 {
|
||||
if faces & (1 << b) != 0 {
|
||||
result |= 1 << (a * 6 + b);
|
||||
for a in 0..6 {
|
||||
if faces & (1 << a) != 0 {
|
||||
for b in 0..6 {
|
||||
if faces & (1 << b) != 0 {
|
||||
result |= 1 << (a * 6 + b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if result == ALL_VISIBLE {
|
||||
return result;
|
||||
if result == ALL_VISIBLE {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
@@ -505,6 +556,13 @@ 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>,
|
||||
/// By state, bit `d` set where its occlusion face toward direction `d` is the
|
||||
/// full block (`Info::face` 1): a face looking at it never shows.
|
||||
full_faces: Vec<u8>,
|
||||
pub visibility: FaceVisibility,
|
||||
water_still: u16,
|
||||
water_flow: u16,
|
||||
@@ -673,6 +731,11 @@ impl MeshTables {
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
solid: info.iter().map(|i| i.solid).collect(),
|
||||
full_faces: info
|
||||
.iter()
|
||||
.map(|i| (0..6).fold(0, |bits, d| bits | u8::from(i.face[d] == 1) << d))
|
||||
.collect(),
|
||||
info,
|
||||
visibility,
|
||||
water_still: layer("minecraft:block/water_still")?,
|
||||
@@ -842,49 +905,107 @@ 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 in runs, from
|
||||
// sections found once per layer.
|
||||
own.fill(0);
|
||||
let center = region[1][1].map(|c| &c.sections[sy as usize]);
|
||||
if let Some(section) = center {
|
||||
section.unpack(own);
|
||||
let center = region[1][1];
|
||||
if let Some(c) = center {
|
||||
c.sections[sy as usize].unpack(own);
|
||||
}
|
||||
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);
|
||||
} else {
|
||||
light[i] = 0xF0;
|
||||
// Where each chunk column's cells of this layer come from, found once.
|
||||
let sources: [[Source; 3]; 3] = std::array::from_fn(|rz| {
|
||||
std::array::from_fn(|rx| match region[rz][rx] {
|
||||
None => Source::Missing,
|
||||
// `Chunk::get` and `Chunk::light` outside the column's sections.
|
||||
Some(c) => {
|
||||
let s = (wy - c.min_y()) >> 4;
|
||||
match c.sections.get(s as usize).filter(|_| s >= 0) {
|
||||
Some(section) => Source::Section(section),
|
||||
None => Source::Outside(if s < 0 { 0 } else { 0xF0 }),
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
for z in -B..16 + B {
|
||||
let row = pi(-B, y, z);
|
||||
let rz = ((z + 16) >> 4) as usize;
|
||||
let (west, rest) = blocks[row..row + P].split_at_mut(B as usize);
|
||||
let (middle, east) = rest.split_at_mut(16);
|
||||
let (west_light, rest) = light[row..row + P].split_at_mut(B as usize);
|
||||
let (middle_light, east_light) = rest.split_at_mut(16);
|
||||
let own_row = (rz == 1 && (0..16).contains(&y))
|
||||
.then(|| &own[(y << 8 | z << 4) as usize..][..16]);
|
||||
let at = |x: i32| crate::chunk::index(x, wy, z);
|
||||
let [w, m, e] = &sources[rz];
|
||||
let edge = (center, wy, z);
|
||||
fill_run(west, west_light, w, None, at(-B), -B, edge);
|
||||
fill_run(middle, middle_light, m, own_row, at(0), 0, edge);
|
||||
fill_run(east, east_light, e, None, at(16), 16, edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Where the cells of one chunk column at one height come from (`Padded::fill`).
|
||||
enum Source<'a> {
|
||||
/// A neighbour not loaded.
|
||||
Missing,
|
||||
/// Above or below the column's sections: air with this light.
|
||||
Outside(u8),
|
||||
Section(&'a crate::chunk::Section),
|
||||
}
|
||||
/// Fills one run of a padded row, `blocks.len()` cells from x = `x0`, starting
|
||||
/// at section index `at`; `own` is the centre section's row, already unpacked.
|
||||
#[inline(always)]
|
||||
fn fill_run(
|
||||
blocks: &mut [u32],
|
||||
light: &mut [u8],
|
||||
source: &Source,
|
||||
own: Option<&[u32]>,
|
||||
at: usize,
|
||||
x0: i32,
|
||||
(center, wy, z): (Option<&Chunk>, i32, i32),
|
||||
) {
|
||||
match source {
|
||||
Source::Section(section) => {
|
||||
match own {
|
||||
Some(own) => blocks.copy_from_slice(own),
|
||||
None => 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()]),
|
||||
}
|
||||
}
|
||||
Source::Outside(v) => {
|
||||
blocks.fill(0);
|
||||
light.fill(*v);
|
||||
}
|
||||
// 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.
|
||||
Source::Missing => match center {
|
||||
Some(c) => {
|
||||
let ez = z.clamp(0, 15);
|
||||
for (k, (block, light)) in blocks.iter_mut().zip(light.iter_mut()).enumerate() {
|
||||
let ex = (x0 + k as i32).clamp(0, 15);
|
||||
*block = c.get(ex, wy, ez);
|
||||
*light = c.light(ex, wy, ez);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
blocks.fill(0);
|
||||
light.fill(0xF0);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
/// Per-worker temporary arrays. They never hold resource-pack references or
|
||||
/// geometry still owned by the renderer, and total about 150 KiB per worker
|
||||
/// (the leaf mask adds 8,000 cells, depths and a queue).
|
||||
@@ -892,13 +1013,36 @@ impl Padded {
|
||||
pub struct MeshScratch {
|
||||
padded: Padded,
|
||||
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;
|
||||
#[cfg(test)]
|
||||
mod reuse_tests;
|
||||
#[cfg(test)]
|
||||
mod tint_tests;
|
||||
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
|
||||
pub const OPAQUE_LAYER: u16 = 1 << 15;
|
||||
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
|
||||
@@ -990,6 +1134,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
|
||||
@@ -1005,6 +1155,8 @@ struct Tints<'a> {
|
||||
/// Chunk x, chunk z and the section's lowest block y.
|
||||
origin: [i32; 3],
|
||||
layers: [Option<Box<Layer>>; 16],
|
||||
/// `uniform_biome`, once computed.
|
||||
uniform: Option<Option<u16>>,
|
||||
}
|
||||
impl<'a> Tints<'a> {
|
||||
fn new(
|
||||
@@ -1022,6 +1174,7 @@ impl<'a> Tints<'a> {
|
||||
plains: biomes.id("minecraft:plains"),
|
||||
origin,
|
||||
layers: Default::default(),
|
||||
uniform: None,
|
||||
}
|
||||
}
|
||||
/// The biome of block `(x, y, z)` (section-local x/z, y in 0..16), as `BiomeManager.getBiome`
|
||||
@@ -1041,6 +1194,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| {
|
||||
@@ -1050,13 +1213,59 @@ impl<'a> Tints<'a> {
|
||||
plains
|
||||
}
|
||||
}
|
||||
/// The biome every block of the section's blend squares zooms to, when they
|
||||
/// all do: `quart_at_block` picks one of the eight quart cells at its base
|
||||
/// cell plus 0 or 1 on each axis, so if every such cell the section reaches
|
||||
/// holds one biome, every zoomed cell is that biome.
|
||||
fn uniform_biome(&mut self) -> Option<u16> {
|
||||
if let Some(uniform) = self.uniform {
|
||||
return uniform;
|
||||
}
|
||||
let (r, [cx, cz, base_y]) = (self.radius, self.origin);
|
||||
// The base cells of blocks lo..=hi, and the cell past the last.
|
||||
let span = |lo: i32, hi: i32| (lo.wrapping_sub(2) >> 2)..=(hi.wrapping_sub(2) >> 2) + 1;
|
||||
let (xs, ys, zs) = (
|
||||
span(cx * 16 - r, cx * 16 + 15 + r),
|
||||
span(base_y, base_y + 15),
|
||||
span(cz * 16 - r, cz * 16 + 15 + r),
|
||||
);
|
||||
let first = Self::quart_biome(
|
||||
self.region,
|
||||
self.plains,
|
||||
[cx, cz],
|
||||
[*xs.start(), *ys.start(), *zs.start()],
|
||||
);
|
||||
let uniform = ys
|
||||
.flat_map(|y| zs.clone().map(move |z| (y, z)))
|
||||
.flat_map(|(y, z)| xs.clone().map(move |x| [x, y, z]))
|
||||
.all(|q| Self::quart_biome(self.region, self.plains, [cx, cz], q) == first)
|
||||
.then_some(first);
|
||||
self.uniform = Some(uniform);
|
||||
uniform
|
||||
}
|
||||
/// The blended tint of block `(x, y, z)` (section-local x/z, y in 0..16).
|
||||
fn get(&mut self, resolver: Resolver, x: i32, y: i32, z: i32) -> u32 {
|
||||
// One biome over every blend square: the mean of (2r+1)^2 equal
|
||||
// colours is that colour, unless it varies by position (swamp grass).
|
||||
if let Some(biome) = self.uniform_biome() {
|
||||
let info = self.biomes.get(biome);
|
||||
match resolver {
|
||||
Resolver::Grass if !matches!(info.modifier, GrassModifier::Swamp) => {
|
||||
return self.biomes.grass(biome, 0.0, 0.0) & 0xFF_FFFF;
|
||||
}
|
||||
Resolver::Grass => {}
|
||||
Resolver::Foliage => return info.foliage & 0xFF_FFFF,
|
||||
Resolver::Dry => return info.dry_foliage & 0xFF_FFFF,
|
||||
Resolver::Water => return info.water & 0xFF_FFFF,
|
||||
}
|
||||
}
|
||||
let r = self.radius;
|
||||
let dim = (16 + 2 * r) as usize;
|
||||
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 +1274,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 +1324,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;
|
||||
@@ -1162,7 +1396,9 @@ impl Mesher<'_> {
|
||||
let own = scratch.own.get_or_insert_with(|| Box::new([0; 4096]));
|
||||
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;
|
||||
out.visibility = visibility_rows(&open_rows(opaque));
|
||||
let base_y = center.min_y() + sy * 16;
|
||||
if self.leaf_culling.is_interior() {
|
||||
scratch
|
||||
@@ -1177,6 +1413,7 @@ impl Mesher<'_> {
|
||||
[center.x, center.z, base_y],
|
||||
);
|
||||
let info = &self.tables.info;
|
||||
let full_faces = &self.tables.full_faces;
|
||||
let catalog = &self.render.model_catalog;
|
||||
let mut models = Vec::new();
|
||||
for y in 0..16 {
|
||||
@@ -1195,12 +1432,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 {
|
||||
@@ -1239,6 +1471,11 @@ impl Mesher<'_> {
|
||||
for d in Direction::ALL {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
|
||||
// `should_render_face`'s first test, without its lookups: a
|
||||
// full occlusion face toward this block hides the face.
|
||||
if full_faces[neighbor as usize] >> d.opposite() as u8 & 1 != 0 {
|
||||
continue;
|
||||
}
|
||||
let other = &info[neighbor as usize];
|
||||
// LeavesBlock.skipRendering with opaque leaves.
|
||||
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
|
||||
|
||||
@@ -83,9 +83,17 @@ fn weighted_blend(c: [u32; 4], w: [f32; 4]) -> u32 {
|
||||
};
|
||||
channel(0) | channel(16) << 16
|
||||
}
|
||||
/// `ARGB.gray` channel: `Mth.floor(brightness * 255)`.
|
||||
/// `ARGB.gray` channel: `Mth.floor(brightness * 255)`. Without the `floorf`
|
||||
/// call x86-64's baseline instruction set makes of `f32::floor`: truncation,
|
||||
/// one lower where that rounded a negative value up (saturating as `as` does).
|
||||
pub(super) fn gray(brightness: f32) -> i32 {
|
||||
(brightness * 255.0).floor() as i32
|
||||
let v = brightness * 255.0;
|
||||
let whole = v as i32;
|
||||
if whole as f32 > v {
|
||||
whole.saturating_sub(1)
|
||||
} else {
|
||||
whole
|
||||
}
|
||||
}
|
||||
/// One channel of `ARGB.scaleRGB(color, float)`.
|
||||
pub(super) fn scale(channel: i32, by: f32) -> i32 {
|
||||
@@ -406,6 +414,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn gray_floors_like_f32_floor() {
|
||||
let mut values = vec![
|
||||
f32::NAN,
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
-0.0,
|
||||
1.0,
|
||||
-1.0,
|
||||
1e10,
|
||||
-1e10,
|
||||
];
|
||||
for bits in (0..u32::MAX).step_by(7919) {
|
||||
values.push(f32::from_bits(bits));
|
||||
}
|
||||
for i in -3000..3000 {
|
||||
values.push(i as f32 / 1024.0);
|
||||
}
|
||||
for v in values {
|
||||
assert_eq!(gray(v), (v * 255.0).floor() as i32, "{v:e}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn colour_arithmetic_matches_argb_helpers() {
|
||||
assert_eq!(gray(1.0), 255);
|
||||
assert_eq!(gray(0.8), 204);
|
||||
|
||||
@@ -181,7 +181,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
|
||||
assert!(scratch.own.is_none());
|
||||
assert_eq!(scratch.padded.blocks.capacity(), 0);
|
||||
assert_eq!(scratch.padded.light.capacity(), 0);
|
||||
assert_eq!(scratch.visibility.capacity(), 0);
|
||||
assert_eq!(scratch.opaque.capacity(), 0);
|
||||
}
|
||||
fresh.sort_solid();
|
||||
recycled.sort_solid();
|
||||
|
||||
@@ -210,6 +210,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 +241,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 +268,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 +309,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))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! The one-biome shortcut of `Tints::get` against the full blend. Needs the
|
||||
//! prepared biome data, like the other mesher tests that load resources.
|
||||
use super::*;
|
||||
|
||||
fn biomes() -> Biomes {
|
||||
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3")
|
||||
});
|
||||
Biomes::load(&root).unwrap()
|
||||
}
|
||||
|
||||
/// A chunk whose every quart cell holds `biome`.
|
||||
fn filled(cx: i32, cz: i32, biome: u16) -> Chunk {
|
||||
let mut chunk = Chunk::empty(cx, cz);
|
||||
for qy in 0..(384 / 4) {
|
||||
for qz in 0..4 {
|
||||
for qx in 0..4 {
|
||||
chunk.set_biome_cell(qx, qy, qz, biome);
|
||||
}
|
||||
}
|
||||
}
|
||||
chunk
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn one_biome_shortcut_blends_like_the_full_square() {
|
||||
let biomes = biomes();
|
||||
let desert = biomes.id("minecraft:desert");
|
||||
let (cx, cz) = (3, -2);
|
||||
// Grass without and with the dark forest modifier, swamp noise, and
|
||||
// biomes whose foliage, dry foliage and water differ from the defaults.
|
||||
for name in [
|
||||
"minecraft:plains",
|
||||
"minecraft:dark_forest",
|
||||
"minecraft:swamp",
|
||||
"minecraft:mangrove_swamp",
|
||||
"minecraft:cherry_grove",
|
||||
"minecraft:warm_ocean",
|
||||
"minecraft:badlands",
|
||||
] {
|
||||
let biome = biomes.id(name);
|
||||
let chunks: Vec<Vec<Chunk>> = (0..3)
|
||||
.map(|rz| {
|
||||
(0..3)
|
||||
.map(|rx| filled(cx + rx - 1, cz + rz - 1, biome))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
// One desert column in the east neighbour, next to the section's edge,
|
||||
// where every blend radius reaches it.
|
||||
let mut east = filled(cx + 1, cz, biome);
|
||||
for qy in 0..(384 / 4) {
|
||||
east.set_biome_cell(0, qy, 2, desert);
|
||||
}
|
||||
let all = |rz: usize, rx: usize| Some(&chunks[rz][rx]);
|
||||
let uniform: [[Option<&Chunk>; 3]; 3] =
|
||||
std::array::from_fn(|rz| std::array::from_fn(|rx| all(rz, rx)));
|
||||
let mut mixed = uniform;
|
||||
mixed[1][2] = Some(&east);
|
||||
// A missing neighbour reads as plains.
|
||||
let mut missing = uniform;
|
||||
missing[0][0] = None;
|
||||
for (region, one_biome) in [
|
||||
(&uniform, true),
|
||||
(&mixed, false),
|
||||
(&missing, name == "minecraft:plains"),
|
||||
] {
|
||||
for base_y in [MIN_Y, 0, 304] {
|
||||
for (radius, seed) in [(0, 0), (1, 42), (2, -7), (5, 1234), (7, i64::MAX)] {
|
||||
let new = || {
|
||||
Tints::new(
|
||||
region,
|
||||
&biomes,
|
||||
BiomeZoom::new(seed),
|
||||
radius,
|
||||
[cx, cz, base_y],
|
||||
)
|
||||
};
|
||||
let mut fast = new();
|
||||
let mut full = new();
|
||||
full.uniform = Some(None);
|
||||
assert_eq!(
|
||||
fast.uniform_biome().is_some(),
|
||||
one_biome,
|
||||
"{name} r{radius} y{base_y}"
|
||||
);
|
||||
for resolver in [
|
||||
Resolver::Grass,
|
||||
Resolver::Foliage,
|
||||
Resolver::Dry,
|
||||
Resolver::Water,
|
||||
] {
|
||||
for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
assert_eq!(
|
||||
fast.get(resolver, x, y, z),
|
||||
full.get(resolver, x, y, z),
|
||||
"{name} resolver {} r{radius} at {x} {y} {z} of y{base_y}",
|
||||
resolver as usize
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,20 +190,74 @@ fn visibility_matches_reference() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_scratch_reuse_keeps_shortcuts_and_subsequent_fills_exact() {
|
||||
let info = [info(false), info(true), info(false), info(true)];
|
||||
let mut stack = vec![usize::MAX];
|
||||
for _ in 0..3 {
|
||||
for (name, pad) in cases() {
|
||||
assert_eq!(
|
||||
visibility_reusing(&pad, &info, &mut stack),
|
||||
reference(&pad, &info),
|
||||
"{name}"
|
||||
);
|
||||
fn row_fill_matches_a_cell_by_cell_fill() {
|
||||
let naive = |seeds: u16, open: u16| {
|
||||
let mut fill = seeds;
|
||||
loop {
|
||||
let next = (fill | fill << 1 | fill >> 1) & open;
|
||||
if next == fill {
|
||||
return fill;
|
||||
}
|
||||
fill = next;
|
||||
}
|
||||
};
|
||||
let mut state = 0x9E37_79B9u32;
|
||||
for _ in 0..200_000 {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 17;
|
||||
state ^= state << 5;
|
||||
let open = state as u16;
|
||||
let seeds = (state >> 16) as u16 & open;
|
||||
assert_eq!(
|
||||
fill_row(seeds, open),
|
||||
naive(seeds, open),
|
||||
"{open:016b} {seeds:016b}"
|
||||
);
|
||||
}
|
||||
// Several seeds in one run, the case a carry-based fill gets wrong.
|
||||
assert_eq!(fill_row(0b0110, 0b1111), 0b1111);
|
||||
assert_eq!(fill_row(0xC000, 0xF000), 0xF000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_fill_follows_open_runs() {
|
||||
for open in [0u16, 0xFFFF, 0b0111_0110, 0x8001, 0xF0F0, 0x5555] {
|
||||
for bit in 0..16 {
|
||||
let seed = 1u16 << bit;
|
||||
if open & seed == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut expected = seed;
|
||||
for x in (0..bit).rev().take_while(|&x| open & 1 << x != 0) {
|
||||
expected |= 1 << x;
|
||||
}
|
||||
for x in (bit + 1..16).take_while(|&x| open & 1 << x != 0) {
|
||||
expected |= 1 << x;
|
||||
}
|
||||
assert_eq!(fill_row(seed, open), expected, "{open:016b} from {bit}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mazes: one long winding cavity, and many small ones, where the row
|
||||
/// sweeps are slowest.
|
||||
#[test]
|
||||
fn winding_and_scattered_cavities_match_reference() {
|
||||
let info = [info(false), info(true), info(false), info(true)];
|
||||
let serpentine = fixture(|x, y, z| {
|
||||
let lane = z % 4 == 0 && y % 4 == 0;
|
||||
let turn = (x == 0 && z % 8 == 4) || (x == 15 && z % 8 == 0);
|
||||
let climb = x == 0 && z == 0 && y % 4 != 0;
|
||||
!(lane || (turn && y % 4 == 0) || climb)
|
||||
});
|
||||
assert_eq!(
|
||||
visibility(&serpentine, &info),
|
||||
reference(&serpentine, &info)
|
||||
);
|
||||
let pockets = fixture(|x, y, z| !(x % 3 == 1 && y % 3 == 1 && z % 3 == 1));
|
||||
assert_eq!(visibility(&pockets, &info), reference(&pockets, &info));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_preserves_every_face_pair() {
|
||||
let info = [info(false), info(true), info(false), info(true)];
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -6,21 +6,19 @@ use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use crate::section_mask::SectionMask;
|
||||
use crate::{
|
||||
chunk::Chunk,
|
||||
chunk_map::ChunkMembershipDelta,
|
||||
graphics::LeafCullingMode,
|
||||
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
|
||||
mesher::{MeshScratch, MeshTables, Mesher, Region, SectionMesh, borrow},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::biome::BiomeZoom;
|
||||
use horizon_threads::jobs::{Class, PerWorker, Rank, Registration, Source};
|
||||
use horizon_world::block_render::FaceVisibility;
|
||||
use horizon_world::worldgen::decoration_order::DecorationOrder;
|
||||
use std::{
|
||||
collections::{BinaryHeap, HashSet, VecDeque},
|
||||
sync::{
|
||||
Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
},
|
||||
thread,
|
||||
sync::{Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError},
|
||||
};
|
||||
|
||||
/// How long a worker waits with nothing to do before it gives back its
|
||||
@@ -33,7 +31,8 @@ pub type Proto = Box<dyn std::any::Any + Send>;
|
||||
/// (biomes, noise, surface, carvers), decoration of a 3x3 neighbourhood
|
||||
/// (FEATURES, which may write into neighbours), then conversion of a chunk
|
||||
/// whose eight neighbours are decorated into a final column. Implementations
|
||||
/// must be thread-safe; each worker thread owns one `Worker`.
|
||||
/// must be thread-safe; each worker of the shared pool
|
||||
/// (`horizon_threads::jobs`) keeps one `Worker` per streamer.
|
||||
pub trait ChunkGenerator: Send + Sync + 'static {
|
||||
type Worker: Send + 'static;
|
||||
fn worker(&self) -> Self::Worker;
|
||||
@@ -276,8 +275,6 @@ impl Ord for Queued {
|
||||
}
|
||||
struct Shared {
|
||||
queue: Mutex<BinaryHeap<Queued>>,
|
||||
ready: Condvar,
|
||||
stop: AtomicBool,
|
||||
/// What meshing reads (block models and sprites), swapped when resource
|
||||
/// packs reload; each mesh job takes the current one.
|
||||
context: RwLock<Arc<WorkerContext>>,
|
||||
@@ -285,11 +282,6 @@ struct Shared {
|
||||
/// Only workers use this pool; frames never wait for its lock. Retaining
|
||||
/// cleared buffers avoids racing the renderer for the process heap.
|
||||
meshes: Mutex<MeshPool>,
|
||||
/// Workers that have freed their buffers ([`IDLE_RELEASE`]) and taken no
|
||||
/// job since: the last of them gives the freed memory back to the OS.
|
||||
released: AtomicUsize,
|
||||
/// The workers that started.
|
||||
workers: AtomicUsize,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct MeshPool {
|
||||
@@ -364,7 +356,11 @@ pub struct Streaming {
|
||||
/// next arrive, though read from the save (`World::loaded_chunks`).
|
||||
dropped_new: FxHashSet<(i32, i32)>,
|
||||
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
|
||||
threads: Vec<thread::JoinHandle<()>>,
|
||||
/// This streamer's place in the shared pool (`horizon_threads::jobs`);
|
||||
/// dropping it waits for its running jobs.
|
||||
jobs: Registration,
|
||||
/// The workers its in-flight work is sized for.
|
||||
workers: usize,
|
||||
order: u64,
|
||||
/// Ticks since the columns and protos that fell out of range were swept.
|
||||
scan_age: u8,
|
||||
@@ -476,6 +472,14 @@ pub struct Streaming {
|
||||
/// Snapshot only after a scan found no missing candidates. Public range fields
|
||||
/// and `World::chunks` can change without going through Streaming, so compare
|
||||
/// their values and structural membership token rather than a frame timeout.
|
||||
///
|
||||
/// A scan also keeps every candidate it found, nearest first (`plan`): while
|
||||
/// the ranges stay the same and no column leaves (`World::chunks` removals, or
|
||||
/// `invalidate` for proto chunks dropped), no column becomes wanted, so the
|
||||
/// next batches are the plan's entries still missing. A full scan looks at
|
||||
/// every column of the areas (thousands at render distance 32), and one ran
|
||||
/// whenever a worker finished: it took a quarter of the thread that runs
|
||||
/// `Streaming::update` while a world loaded.
|
||||
#[derive(Default)]
|
||||
struct GenerationScan {
|
||||
exhausted: bool,
|
||||
@@ -485,17 +489,52 @@ struct GenerationScan {
|
||||
forced: HashSet<(i32, i32)>,
|
||||
band: Option<Band>,
|
||||
chunk_revision: u64,
|
||||
plan: Vec<(i64, i32, i32)>,
|
||||
/// Entries before it are no longer missing.
|
||||
next: usize,
|
||||
/// What `plan` was made for: the ranges, the loading screen and
|
||||
/// decoration; the chunk map's revision is `chunk_revision`.
|
||||
plan_basis: Option<(bool, bool)>,
|
||||
}
|
||||
impl GenerationScan {
|
||||
fn unchanged(&self, ranges: &Ranges<'_>, world: &World) -> bool {
|
||||
self.exhausted
|
||||
&& self.center == ranges.center
|
||||
fn same_ranges(&self, ranges: &Ranges<'_>) -> bool {
|
||||
self.center == ranges.center
|
||||
&& self.radius == ranges.radius
|
||||
&& self.extra == ranges.extra
|
||||
&& self.forced == *ranges.forced
|
||||
&& self.band.as_ref() == ranges.band
|
||||
}
|
||||
fn unchanged(&self, ranges: &Ranges<'_>, world: &World) -> bool {
|
||||
self.exhausted
|
||||
&& self.same_ranges(ranges)
|
||||
&& self.chunk_revision == world.chunks.membership_revision()
|
||||
}
|
||||
/// A column may be wanted again (a proto chunk was dropped): the next
|
||||
/// call scans the areas again.
|
||||
fn invalidate(&mut self) {
|
||||
self.exhausted = false;
|
||||
self.plan_basis = None;
|
||||
}
|
||||
/// Whether `plan` still holds every candidate: same ranges, and no
|
||||
/// column left the chunk map since it was made.
|
||||
fn plan_holds(&mut self, input: &GenerationInputs<'_>) -> bool {
|
||||
if self.plan_basis != Some((input.loading, input.decorates))
|
||||
|| !self.same_ranges(&input.ranges)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let revision = input.world.chunks.membership_revision();
|
||||
if revision != self.chunk_revision {
|
||||
let Some(mut changes) = input.world.chunks.deltas_since(self.chunk_revision) else {
|
||||
return false;
|
||||
};
|
||||
if !changes.all(|(_, change)| matches!(change, ChunkMembershipDelta::Added(_))) {
|
||||
return false;
|
||||
}
|
||||
self.chunk_revision = revision;
|
||||
}
|
||||
true
|
||||
}
|
||||
fn remember(&mut self, ranges: Ranges<'_>, world: &World) {
|
||||
self.exhausted = true;
|
||||
self.center = ranges.center;
|
||||
@@ -533,6 +572,30 @@ impl GenerationScan {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let missing = |&(_, x, z): &(i64, i32, i32)| {
|
||||
!input.world.chunks.contains_key(&(x, z))
|
||||
&& !input.generating.contains(&(x, z))
|
||||
&& !input.protos.contains_key(&(x, z))
|
||||
&& !input.finishing.contains(&(x, z))
|
||||
};
|
||||
if self.plan_holds(&input) {
|
||||
while self.next < self.plan.len() && !missing(&self.plan[self.next]) {
|
||||
self.next += 1;
|
||||
}
|
||||
let room = capacity - input.generating.len();
|
||||
let batch: Vec<_> = self.plan[self.next..]
|
||||
.iter()
|
||||
.filter(|c| missing(c))
|
||||
.take(room)
|
||||
.copied()
|
||||
.collect();
|
||||
// Past the plan's end, the full scan below decides whether
|
||||
// anything is left (and remembers when nothing is).
|
||||
if !batch.is_empty() {
|
||||
self.exhausted = false;
|
||||
return Some(batch);
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut wanted = Vec::new();
|
||||
// Each area's square of columns, as one run per row. Players'
|
||||
@@ -592,28 +655,196 @@ impl GenerationScan {
|
||||
}
|
||||
}
|
||||
}
|
||||
let ranges = Ranges {
|
||||
center: input.ranges.center,
|
||||
radius: input.ranges.radius,
|
||||
extra: input.ranges.extra,
|
||||
forced: input.ranges.forced,
|
||||
cover: input.ranges.cover,
|
||||
band: input.ranges.band,
|
||||
};
|
||||
self.remember(ranges, input.world);
|
||||
if wanted.is_empty() {
|
||||
let ranges = Ranges {
|
||||
center: input.ranges.center,
|
||||
radius: input.ranges.radius,
|
||||
extra: input.ranges.extra,
|
||||
forced: input.ranges.forced,
|
||||
cover: input.ranges.cover,
|
||||
band: input.ranges.band,
|
||||
};
|
||||
self.remember(ranges, input.world);
|
||||
self.plan.clear();
|
||||
self.plan_basis = None;
|
||||
} else {
|
||||
// A bounded batch may leave candidates behind. Keep scanning
|
||||
// as capacity opens, even if no range or membership changed.
|
||||
// A bounded batch may leave candidates behind: the next calls
|
||||
// take them from the plan as capacity opens.
|
||||
self.exhausted = false;
|
||||
self.plan_basis = Some((input.loading, input.decorates));
|
||||
}
|
||||
wanted.sort_unstable();
|
||||
wanted.dedup();
|
||||
self.plan.clear();
|
||||
self.plan.extend_from_slice(&wanted);
|
||||
self.next = 0;
|
||||
wanted.truncate(capacity - input.generating.len());
|
||||
Some(wanted)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What a pool worker keeps for one streamer between its jobs: the
|
||||
/// generator's worker, and the face visibility table and its occlusion
|
||||
/// cache (like vanilla's per-thread cache: cloning it per job copied every
|
||||
/// state and face shape) and the mesher's scratch.
|
||||
struct WorkerLocal<W> {
|
||||
worker: W,
|
||||
faces: Option<(Arc<MeshTables>, FaceVisibility)>,
|
||||
mesh_scratch: MeshScratch,
|
||||
}
|
||||
/// A streamer's jobs, run by the process's shared pool (`horizon_threads::jobs`).
|
||||
struct Jobs<G: ChunkGenerator> {
|
||||
shared: Arc<Shared>,
|
||||
generator: Arc<G>,
|
||||
light: Arc<LightTables>,
|
||||
locals: PerWorker<WorkerLocal<G::Worker>>,
|
||||
}
|
||||
impl<G: ChunkGenerator> Source for Jobs<G> {
|
||||
fn next(&self) -> Option<Rank> {
|
||||
let queue = self.shared.queue.lock().unwrap();
|
||||
queue.peek().map(|q| {
|
||||
// Jobs without a column (saves, meshes to free) go first.
|
||||
let class = if q.at.is_none() {
|
||||
Class::Urgent
|
||||
} else {
|
||||
Class::Near
|
||||
};
|
||||
Rank::new(class, q.priority)
|
||||
})
|
||||
}
|
||||
fn run(&self, worker: usize) -> bool {
|
||||
let Some(job) = self.shared.queue.lock().unwrap().pop() else {
|
||||
return false;
|
||||
};
|
||||
let done = self.locals.with(
|
||||
worker,
|
||||
|| WorkerLocal {
|
||||
worker: self.generator.worker(),
|
||||
faces: None,
|
||||
mesh_scratch: MeshScratch::default(),
|
||||
},
|
||||
|local| self.run_job(job.job, local),
|
||||
);
|
||||
if let Some(done) = done {
|
||||
self.shared.done.send(done);
|
||||
}
|
||||
true
|
||||
}
|
||||
fn idle(&self, worker: usize) {
|
||||
// A server nobody is on, a client standing still: free the
|
||||
// generator's buffers (and the mesher's face cache and scratch)
|
||||
// until the next job needs them.
|
||||
self.locals.with_existing(worker, |local| {
|
||||
self.generator.idle(&mut local.worker);
|
||||
local.faces = None;
|
||||
local.mesh_scratch = MeshScratch::default();
|
||||
});
|
||||
}
|
||||
fn all_idle(&self) {
|
||||
// No worker needs retained mesh capacity now. Drop it outside the
|
||||
// pool lock; the pool then asks the allocator to return freed pages.
|
||||
let meshes = std::mem::take(&mut *self.shared.meshes.lock().unwrap());
|
||||
drop(meshes);
|
||||
}
|
||||
}
|
||||
impl<G: ChunkGenerator> Jobs<G> {
|
||||
fn run_job(&self, job: Job, local: &mut WorkerLocal<G::Worker>) -> Option<Done> {
|
||||
let (generator, shared, light) = (&self.generator, &self.shared, &self.light);
|
||||
let WorkerLocal {
|
||||
worker,
|
||||
faces,
|
||||
mesh_scratch,
|
||||
} = local;
|
||||
let done = match job {
|
||||
Job::Generate(x, z) => match generator.load(x, z) {
|
||||
Some(chunk) => {
|
||||
generator.prepare_loaded(&mut *worker, &chunk);
|
||||
Done::Generated(chunk)
|
||||
}
|
||||
None => {
|
||||
let proto = generator.proto(&mut *worker, x, z);
|
||||
let decorated = generator.is_decorated(&proto);
|
||||
Done::Proto(x, z, proto, decorated)
|
||||
}
|
||||
},
|
||||
Job::Decorate(x, z, mut region, finals) => {
|
||||
// The final chunks' proto chunks, made here: a
|
||||
// generated proto chunk holding their blocks.
|
||||
for (i, snapshot) in &finals {
|
||||
if let Some(proto) = generator.proto_beside_final(&mut *worker, snapshot) {
|
||||
region[*i] = proto;
|
||||
}
|
||||
}
|
||||
generator.decorate(&mut *worker, &mut region);
|
||||
let mut writes = FinalWrites::default();
|
||||
for (i, snapshot) in &finals {
|
||||
let blocks = generator.final_writes(snapshot, ®ion[*i]);
|
||||
writes
|
||||
.block_entities
|
||||
.extend(generator.final_block_entities(®ion[*i], &blocks));
|
||||
writes.blocks.extend(blocks);
|
||||
region[*i] = Box::new(());
|
||||
}
|
||||
let finals = finals.into_iter().map(|(i, _)| i).collect();
|
||||
Done::Decorated(x, z, region, finals, writes)
|
||||
}
|
||||
Job::Finish(proto) => {
|
||||
let mut chunk = generator.finish(proto);
|
||||
chunk.generated = true;
|
||||
Done::Generated(chunk)
|
||||
}
|
||||
Job::Save(protos) => {
|
||||
let refs: Vec<&Proto> = protos.iter().map(|(_, p)| p).collect();
|
||||
generator.save_protos(&refs);
|
||||
Done::Saved(protos)
|
||||
}
|
||||
Job::LightView(x, z, proto) => {
|
||||
let view = generator.light_view(&proto);
|
||||
Done::LightView(x, z, proto, view)
|
||||
}
|
||||
Job::Free(meshes) => {
|
||||
for (_, mesh) in meshes {
|
||||
let rejected = shared.meshes.lock().unwrap().put(mesh);
|
||||
drop(rejected);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
Job::Light(x, z, region, seq, sky) => {
|
||||
Done::Lit(x, z, light_chunk(&borrow(®ion), light, sky), seq)
|
||||
}
|
||||
Job::Mesh(x, sy, z, region, seq, settings) => {
|
||||
let context = shared.context.read().unwrap().clone();
|
||||
let visibility = match faces.take() {
|
||||
Some((tables, visibility)) if Arc::ptr_eq(&tables, &context.tables) => {
|
||||
visibility
|
||||
}
|
||||
_ => context.tables.visibility.clone(),
|
||||
};
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: settings.nether,
|
||||
shader_pack_lighting: settings.shader_pack_lighting,
|
||||
zoom: settings.zoom,
|
||||
blend_radius: settings.blend_radius,
|
||||
smooth_lighting: settings.smooth_lighting,
|
||||
cutout_leaves: settings.cutout_leaves,
|
||||
translucent_layers: &settings.translucent_layers,
|
||||
leaf_culling: settings.leaf_culling,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
biomes: &context.biomes,
|
||||
visibility,
|
||||
};
|
||||
let recycled = shared.meshes.lock().unwrap().take();
|
||||
let mut mesh = mesher.mesh_reusing(&borrow(®ion), sy, recycled, mesh_scratch);
|
||||
mesh.sort_solid();
|
||||
*faces = Some((context.tables.clone(), mesher.visibility));
|
||||
Done::Meshed(x, sy, z, mesh, seq)
|
||||
}
|
||||
};
|
||||
Some(done)
|
||||
}
|
||||
}
|
||||
pub struct WorkerContext {
|
||||
pub assets: Arc<crate::assets::Assets>,
|
||||
pub render: Arc<crate::assets::RenderAssets>,
|
||||
@@ -629,190 +860,23 @@ impl Streaming {
|
||||
) -> Self {
|
||||
let shared = Arc::new(Shared {
|
||||
queue: Mutex::new(BinaryHeap::new()),
|
||||
ready: Condvar::new(),
|
||||
stop: AtomicBool::new(false),
|
||||
context: RwLock::new(context.clone()),
|
||||
done: Mailbox::default(),
|
||||
meshes: Mutex::new(MeshPool::default()),
|
||||
released: AtomicUsize::new(0),
|
||||
workers: AtomicUsize::new(0),
|
||||
});
|
||||
let light_tables = context.light.clone();
|
||||
let saves: Arc<dyn Saves> = generator.clone();
|
||||
let threads = (0..threads.max(1))
|
||||
.map(|i| {
|
||||
let shared = shared.clone();
|
||||
let generator = generator.clone();
|
||||
let light = context.light.clone();
|
||||
thread::Builder::new()
|
||||
.name(format!("horizon-worker-{i}"))
|
||||
.spawn(move || {
|
||||
// Ticks and frames come first (horizon_threads).
|
||||
horizon_threads::behind_server();
|
||||
let mut worker = generator.worker();
|
||||
// The face visibility table and its occlusion cache stay with
|
||||
// the worker across mesh jobs, like vanilla's per-thread cache:
|
||||
// cloning it per job copied every state and face shape.
|
||||
let mut faces: Option<(Arc<MeshTables>, FaceVisibility)> = None;
|
||||
let mut mesh_scratch = MeshScratch::default();
|
||||
loop {
|
||||
let job = {
|
||||
let mut queue = shared.queue.lock().unwrap();
|
||||
let mut released = false;
|
||||
loop {
|
||||
if shared.stop.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if let Some(job) = queue.pop() {
|
||||
if released {
|
||||
shared.released.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
break job.job;
|
||||
}
|
||||
if released {
|
||||
queue = shared.ready.wait(queue).unwrap();
|
||||
continue;
|
||||
}
|
||||
let (woken, wait) =
|
||||
shared.ready.wait_timeout(queue, IDLE_RELEASE).unwrap();
|
||||
queue = woken;
|
||||
if wait.timed_out() && queue.is_empty() {
|
||||
// A server nobody is on, a client standing still:
|
||||
// free the generator's buffers (and the mesher's
|
||||
// face cache and scratch) until the next job
|
||||
// needs them.
|
||||
drop(queue);
|
||||
generator.idle(&mut worker);
|
||||
faces = None;
|
||||
mesh_scratch = MeshScratch::default();
|
||||
released = true;
|
||||
// The allocator keeps freed pages until memory
|
||||
// runs short: once every worker has freed its own,
|
||||
// hand them back.
|
||||
if shared.released.fetch_add(1, Ordering::Relaxed) + 1
|
||||
== shared.workers.load(Ordering::Relaxed)
|
||||
{
|
||||
// No worker needs retained mesh capacity now.
|
||||
// Drop it outside the pool lock before asking
|
||||
// the allocator to return freed pages.
|
||||
let meshes =
|
||||
std::mem::take(&mut *shared.meshes.lock().unwrap());
|
||||
drop(meshes);
|
||||
horizon_threads::release_free_memory();
|
||||
}
|
||||
queue = shared.queue.lock().unwrap();
|
||||
}
|
||||
}
|
||||
};
|
||||
let done = match job {
|
||||
Job::Generate(x, z) => match generator.load(x, z) {
|
||||
Some(chunk) => {
|
||||
generator.prepare_loaded(&mut worker, &chunk);
|
||||
Done::Generated(chunk)
|
||||
}
|
||||
None => {
|
||||
let proto = generator.proto(&mut worker, x, z);
|
||||
let decorated = generator.is_decorated(&proto);
|
||||
Done::Proto(x, z, proto, decorated)
|
||||
}
|
||||
},
|
||||
Job::Decorate(x, z, mut region, finals) => {
|
||||
// The final chunks' proto chunks, made here: a
|
||||
// generated proto chunk holding their blocks.
|
||||
for (i, snapshot) in &finals {
|
||||
if let Some(proto) =
|
||||
generator.proto_beside_final(&mut worker, snapshot)
|
||||
{
|
||||
region[*i] = proto;
|
||||
}
|
||||
}
|
||||
generator.decorate(&mut worker, &mut region);
|
||||
let mut writes = FinalWrites::default();
|
||||
for (i, snapshot) in &finals {
|
||||
let blocks = generator.final_writes(snapshot, ®ion[*i]);
|
||||
writes.block_entities.extend(
|
||||
generator.final_block_entities(®ion[*i], &blocks),
|
||||
);
|
||||
writes.blocks.extend(blocks);
|
||||
region[*i] = Box::new(());
|
||||
}
|
||||
let finals = finals.into_iter().map(|(i, _)| i).collect();
|
||||
Done::Decorated(x, z, region, finals, writes)
|
||||
}
|
||||
Job::Finish(proto) => {
|
||||
let mut chunk = generator.finish(proto);
|
||||
chunk.generated = true;
|
||||
Done::Generated(chunk)
|
||||
}
|
||||
Job::Save(protos) => {
|
||||
let refs: Vec<&Proto> = protos.iter().map(|(_, p)| p).collect();
|
||||
generator.save_protos(&refs);
|
||||
Done::Saved(protos)
|
||||
}
|
||||
Job::LightView(x, z, proto) => {
|
||||
let view = generator.light_view(&proto);
|
||||
Done::LightView(x, z, proto, view)
|
||||
}
|
||||
Job::Free(meshes) => {
|
||||
for (_, mesh) in meshes {
|
||||
let rejected = shared.meshes.lock().unwrap().put(mesh);
|
||||
drop(rejected);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Job::Light(x, z, region, seq, sky) => {
|
||||
Done::Lit(x, z, light_chunk(&borrow(®ion), &light, sky), seq)
|
||||
}
|
||||
Job::Mesh(x, sy, z, region, seq, settings) => {
|
||||
let context = shared.context.read().unwrap().clone();
|
||||
let visibility = match faces.take() {
|
||||
Some((tables, visibility))
|
||||
if Arc::ptr_eq(&tables, &context.tables) =>
|
||||
{
|
||||
visibility
|
||||
}
|
||||
_ => context.tables.visibility.clone(),
|
||||
};
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: settings.nether,
|
||||
shader_pack_lighting: settings.shader_pack_lighting,
|
||||
zoom: settings.zoom,
|
||||
blend_radius: settings.blend_radius,
|
||||
smooth_lighting: settings.smooth_lighting,
|
||||
cutout_leaves: settings.cutout_leaves,
|
||||
translucent_layers: &settings.translucent_layers,
|
||||
leaf_culling: settings.leaf_culling,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
biomes: &context.biomes,
|
||||
visibility,
|
||||
};
|
||||
let recycled = shared.meshes.lock().unwrap().take();
|
||||
let mut mesh = mesher.mesh_reusing(
|
||||
&borrow(®ion),
|
||||
sy,
|
||||
recycled,
|
||||
&mut mesh_scratch,
|
||||
);
|
||||
mesh.sort_solid();
|
||||
faces = Some((context.tables.clone(), mesher.visibility));
|
||||
Done::Meshed(x, sy, z, mesh, seq)
|
||||
}
|
||||
};
|
||||
shared.done.send(done);
|
||||
}
|
||||
})
|
||||
})
|
||||
// A thread the system refuses (a container's process limit reached) leaves the pool a
|
||||
// worker short: a server that panicked here went down with its players' moves unsaved.
|
||||
.filter_map(|spawned| {
|
||||
spawned
|
||||
.inspect_err(|e| log::error!("Horizon: chunk worker not started: {e}"))
|
||||
.ok()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
shared.workers.store(threads.len(), Ordering::Relaxed);
|
||||
let pool = horizon_threads::jobs::pool();
|
||||
let source = Arc::new(Jobs {
|
||||
shared: shared.clone(),
|
||||
generator: generator.clone(),
|
||||
light: context.light.clone(),
|
||||
locals: horizon_threads::jobs::PerWorker::new(pool.threads()),
|
||||
});
|
||||
let jobs = pool.register(source);
|
||||
// The work kept in flight is sized for the workers this streamer may
|
||||
// use: the pool's, or fewer when asked (tests, a placeholder).
|
||||
let workers = threads.clamp(1, pool.threads());
|
||||
let non_air_sections = {
|
||||
let generator = generator.clone();
|
||||
Arc::new(move |p: &Proto| generator.non_air_sections(p))
|
||||
@@ -820,7 +884,8 @@ impl Streaming {
|
||||
Self {
|
||||
shared,
|
||||
non_air_sections,
|
||||
threads,
|
||||
jobs,
|
||||
workers,
|
||||
order: 0,
|
||||
scan_age: 0,
|
||||
scan_area: ((i32::MIN, i32::MIN), i32::MIN),
|
||||
@@ -1019,7 +1084,7 @@ impl Streaming {
|
||||
order: self.order,
|
||||
job,
|
||||
});
|
||||
self.shared.ready.notify_one();
|
||||
self.jobs.notify();
|
||||
}
|
||||
/// Rank the queued jobs again when what their priorities were computed
|
||||
/// from changed: the centre, the extra areas, the band, or the loading
|
||||
@@ -1489,7 +1554,7 @@ impl Streaming {
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
if !far.is_empty() {
|
||||
self.generation_scan.exhausted = false;
|
||||
self.generation_scan.invalidate();
|
||||
}
|
||||
// Those decoration wrote to are saved first (see
|
||||
// `ChunkGenerator::save_protos`), and dropped once back.
|
||||
@@ -1538,7 +1603,7 @@ impl Streaming {
|
||||
// Decorate chunks whose 3x3 proto neighbourhood is available, in
|
||||
// vanilla's order: decoration is order dependent where write regions
|
||||
// overlap (see `DecorationOrder`).
|
||||
let workers = self.threads.len();
|
||||
let workers = self.workers;
|
||||
if self.decorates && self.order_area != Some((self.center, self.radius)) {
|
||||
let (decorated, chunks) = (&self.decorated, &world.chunks);
|
||||
// The render distance is the server view distance in singleplayer.
|
||||
@@ -1600,14 +1665,24 @@ impl Streaming {
|
||||
w.0 = self.decoration_order.rank_or_append(w.2, w.3);
|
||||
}
|
||||
wanted.sort_unstable();
|
||||
// Most wanted chunks are blocked, and the same neighbours are
|
||||
// asked about for each of them: what `waits_for` says is kept for
|
||||
// the pass (nothing it reads changes in it but `decorated`, which
|
||||
// updates the entry). Without it, scheduling at render distance 32
|
||||
// took most of the thread that runs it while the world loaded.
|
||||
let waiting: std::cell::RefCell<FxHashMap<(i32, i32), bool>> = Default::default();
|
||||
for (_, _, x, z) in wanted {
|
||||
if self.decorating >= workers * 2 {
|
||||
break;
|
||||
}
|
||||
if self
|
||||
.decoration_order
|
||||
.blocked(x, z, |nx, nz| waits_for(self, nx, nz))
|
||||
{
|
||||
if self.decoration_order.blocked(x, z, |nx, nz| {
|
||||
if let Some(&known) = waiting.borrow().get(&(nx, nz)) {
|
||||
return known;
|
||||
}
|
||||
let known = waits_for(self, nx, nz);
|
||||
waiting.borrow_mut().insert((nx, nz), known);
|
||||
known
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let keys: Vec<(i32, i32)> =
|
||||
@@ -1627,6 +1702,7 @@ impl Streaming {
|
||||
.collect();
|
||||
if !finals.is_empty() && !self.saves_beside_final {
|
||||
self.decorated.insert((x, z));
|
||||
waiting.borrow_mut().insert((x, z), false);
|
||||
continue;
|
||||
}
|
||||
if !keys.iter().enumerate().all(|(i, k)| {
|
||||
@@ -1680,7 +1756,7 @@ impl Streaming {
|
||||
for key in gone {
|
||||
world.dirty_light.remove(&key);
|
||||
}
|
||||
let room = (self.threads.len() * 4).saturating_sub(self.lighting.len());
|
||||
let room = (self.workers * 4).saturating_sub(self.lighting.len());
|
||||
nearest_first(&mut light, room);
|
||||
for (_, x, z) in light {
|
||||
let Some(region) = self.light_region(world, x, z) else {
|
||||
@@ -1766,7 +1842,7 @@ impl Streaming {
|
||||
// jobs went stale long before a worker reached them (#3629). What
|
||||
// stays dirty is asked for again, from the current centre, as
|
||||
// capacity frees; `requeue` then has a small queue to keep close.
|
||||
let room = (self.threads.len() * 32).saturating_sub(self.meshing.len());
|
||||
let room = (self.workers * 32).saturating_sub(self.meshing.len());
|
||||
nearest_first(&mut candidates, room);
|
||||
for (_, x, z, sections) in candidates {
|
||||
let Some(region) = Self::region(world, x, z) else {
|
||||
@@ -2438,11 +2514,8 @@ impl Drop for Streaming {
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
self.shared.stop.store(true, Ordering::Relaxed);
|
||||
self.shared.ready.notify_all();
|
||||
for t in self.threads.drain(..) {
|
||||
let _ = t.join();
|
||||
}
|
||||
// `jobs` leaves the pool after this, once its running jobs are
|
||||
// done; the jobs still queued go with the queue.
|
||||
}
|
||||
}
|
||||
/// The queued jobs' priorities computed again from each one's column
|
||||
@@ -3143,6 +3216,70 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Batches taken from the plan are the full scan's, as jobs finish,
|
||||
/// columns and proto chunks leave and the centre moves.
|
||||
#[test]
|
||||
fn generation_plan_matches_full_scans_as_columns_arrive() {
|
||||
for decorates in [false, true] {
|
||||
let mut f = GenerationFixture::new(6, decorates);
|
||||
let mut gate = GenerationScan::default();
|
||||
let chunk = Arc::new(Chunk::empty(0, 0));
|
||||
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
||||
let mut rand = |n: u64| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
seed % n
|
||||
};
|
||||
let mut scheduled = 0;
|
||||
for step in 0..600 {
|
||||
let capacity = 3 + rand(6) as usize;
|
||||
let expected = baseline_candidates(f.input(), capacity);
|
||||
let batch = gate.candidates(f.input(), capacity).unwrap_or_default();
|
||||
assert_eq!(batch, expected, "step {step}");
|
||||
for (_, x, z) in batch {
|
||||
f.generating.insert((x, z));
|
||||
scheduled += 1;
|
||||
}
|
||||
// Some jobs finish: as a proto chunk, or a final column.
|
||||
let mut done: Vec<_> = f.generating.iter().copied().collect();
|
||||
done.sort_unstable();
|
||||
for key in done {
|
||||
if rand(3) != 0 {
|
||||
continue;
|
||||
}
|
||||
f.generating.remove(&key);
|
||||
if rand(2) == 0 {
|
||||
f.protos.insert(key, None);
|
||||
} else {
|
||||
f.world.chunks.insert(key, chunk.clone());
|
||||
}
|
||||
gate.exhausted = false;
|
||||
}
|
||||
// Now and then a column or a proto chunk leaves.
|
||||
if rand(20) == 0 {
|
||||
let mut keys: Vec<_> = f.world.chunks.keys().copied().collect();
|
||||
keys.sort_unstable();
|
||||
if let Some(key) = keys.get(rand(keys.len().max(1) as u64) as usize) {
|
||||
f.world.chunks.remove(key);
|
||||
}
|
||||
}
|
||||
if rand(20) == 0 {
|
||||
let mut keys: Vec<_> = f.protos.keys().copied().collect();
|
||||
keys.sort_unstable();
|
||||
if let Some(key) = keys.get(rand(keys.len().max(1) as u64) as usize) {
|
||||
f.protos.remove(key);
|
||||
gate.invalidate();
|
||||
}
|
||||
}
|
||||
if step == 300 {
|
||||
f.center = (3, -2);
|
||||
}
|
||||
}
|
||||
assert!(scheduled > 200);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_scan_looks_at_overlapping_areas_once() {
|
||||
// A server's players: many areas around one spot, some apart, one
|
||||
|
||||
@@ -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": "仅限本机:此电脑上的代理可用,网络不可用。"
|
||||
}
|
||||
@@ -89,8 +89,9 @@ fn main() -> Result<(), Error> {
|
||||
});
|
||||
let terrain = VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed)?;
|
||||
|
||||
// Generation and light, on the game's worker pool. Meshing is left out
|
||||
// here and measured on its own below.
|
||||
// Generation and light, on the game's worker pool (`threads` workers).
|
||||
// Meshing is left out here and measured on its own below.
|
||||
horizon_threads::jobs::configure(threads);
|
||||
let mut streaming = Streaming::new(Arc::new(terrain), context.clone(), threads);
|
||||
streaming.mesh = false;
|
||||
streaming.center = center;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -183,6 +183,7 @@ mod maps;
|
||||
mod minecarts;
|
||||
mod mob_blocks;
|
||||
mod mob_gear;
|
||||
mod mob_index;
|
||||
mod mob_looting;
|
||||
mod mob_sounds;
|
||||
mod mod_api_host;
|
||||
@@ -2343,6 +2344,10 @@ struct Game<'a> {
|
||||
entity_spans: std::cell::RefCell<Vec<horizon_client::gfx::pack_draw::EntitySpan>>,
|
||||
/// Which entity this frame's entity vertices belong to (`Frame::entity_motion`).
|
||||
entity_motion: std::cell::RefCell<Vec<(u32, u64)>>,
|
||||
/// Mob models posed on the GPU (`Frame::skin`, `mobs::skin`): the models stored there,
|
||||
/// and this frame's draws.
|
||||
skin_models: std::cell::RefCell<horizon_client::mobs::skin::SkinModels>,
|
||||
skin_frame: std::cell::RefCell<horizon_client::gfx::entity_skin::SkinFrame>,
|
||||
/// The glowing entities' silhouettes this frame (game/glowing.rs).
|
||||
glowing_vertices: std::cell::RefCell<Vec<Vertex>>,
|
||||
/// The shader pack's entity and block entity ids, while one draws entities.
|
||||
@@ -2439,6 +2444,8 @@ struct Game<'a> {
|
||||
/// player's own (taken by `update_world`).
|
||||
spawn_wait: Vec<(i32, i32)>,
|
||||
mobs: Vec<Mob>,
|
||||
/// `mobs` by network id, for the packets that name one (`game/mob_index.rs`).
|
||||
mob_index: mob_index::MobIndex,
|
||||
creatures: creatures::Creatures,
|
||||
sculk: sculk::SculkState,
|
||||
raiding: raids::Raiding,
|
||||
@@ -2676,6 +2683,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 +2859,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 +7167,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 +8576,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 +8649,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 +11207,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 +11433,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);
|
||||
@@ -15529,6 +15555,7 @@ impl Game<'_> {
|
||||
let entity_motion = std::mem::take(self.entity_motion.get_mut());
|
||||
let glowing = std::mem::take(self.glowing_vertices.get_mut());
|
||||
let shadow_only = std::mem::take(self.shadow_only_vertices.get_mut());
|
||||
let skin = std::mem::take(self.skin_frame.get_mut());
|
||||
let frame = Frame {
|
||||
weather: &weather,
|
||||
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
|
||||
@@ -15612,6 +15639,7 @@ impl Game<'_> {
|
||||
entities: &entities,
|
||||
entity_motion: &entity_motion,
|
||||
entity_spans: &entity_spans,
|
||||
skin: skin.pending().then_some(&skin),
|
||||
glowing: &glowing,
|
||||
dynamic_lights: &self.dynamic_lights,
|
||||
colored_lights: &colored_lights,
|
||||
@@ -15692,6 +15720,18 @@ impl Game<'_> {
|
||||
*self.entity_motion.get_mut() = entity_motion;
|
||||
*self.glowing_vertices.get_mut() = glowing;
|
||||
*self.shadow_only_vertices.get_mut() = shadow_only;
|
||||
let mut skin = skin;
|
||||
// The renderer took the new models when its store grew by them; otherwise they stay
|
||||
// pending, or the stores restart (`SkinModels::begin`).
|
||||
if self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.and_then(|r| r.skinned_models())
|
||||
.is_some_and(|stored| stored == skin.base + skin.new_model.len() as u32)
|
||||
{
|
||||
skin.handed();
|
||||
}
|
||||
*self.skin_frame.get_mut() = skin;
|
||||
self.see_through_vertices = see_through;
|
||||
result
|
||||
}
|
||||
@@ -15966,6 +16006,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 +16048,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 +16061,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 +16100,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 +16151,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`).
|
||||
@@ -18234,6 +18282,8 @@ impl<'a> Game<'a> {
|
||||
shadow_only_vertices: Default::default(),
|
||||
entity_spans: Default::default(),
|
||||
entity_motion: Default::default(),
|
||||
skin_models: Default::default(),
|
||||
skin_frame: Default::default(),
|
||||
glowing_vertices: Default::default(),
|
||||
pack_ids: None,
|
||||
health_hud: hud_layout::HealthHud::default(),
|
||||
@@ -18295,6 +18345,7 @@ impl<'a> Game<'a> {
|
||||
player_spawn: None,
|
||||
spawn_wait: Vec::new(),
|
||||
mobs: Vec::new(),
|
||||
mob_index: Default::default(),
|
||||
creatures: Default::default(),
|
||||
sculk: Default::default(),
|
||||
raiding: Default::default(),
|
||||
@@ -18436,6 +18487,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 +18583,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 +19015,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) => {
|
||||
@@ -19338,6 +19395,9 @@ fn load_resources(
|
||||
publish: &mut dyn FnMut(UiResources, &std::path::Path),
|
||||
) -> Result<Loaded> {
|
||||
let threads = settings.horizon.worker_threads();
|
||||
// One pool of workers for every level's streaming, the client's and the
|
||||
// server thread's (horizon_threads::jobs), sized before any world opens.
|
||||
horizon_threads::jobs::configure(threads);
|
||||
horizon_client::sprites::set_texture_budget(settings.horizon.texture_budget_mib);
|
||||
horizon_client::sprites::set_labpbr_maps(shader_packs::picks_native_pack(settings));
|
||||
// Stage A: the menu bundle (atlas and panorama). With a loading screen it is
|
||||
@@ -19592,6 +19652,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)
|
||||
@@ -19917,6 +19980,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
entities: &[],
|
||||
entity_motion: &[],
|
||||
entity_spans: &[],
|
||||
skin: None,
|
||||
glowing: &[],
|
||||
dynamic_lights: &[],
|
||||
colored_lights: &[],
|
||||
@@ -20653,9 +20717,8 @@ pub fn run_with() -> Result<()> {
|
||||
horizon_threads::put_helpers_behind();
|
||||
if options.execution_role.is_client() {
|
||||
// The render thread, like the server thread, ahead of the workers on
|
||||
// macOS and Windows. While terrain loads, the worker pools (two with
|
||||
// single player's server thread) and other programs outnumber the
|
||||
// cores, and a frame that gave its core up (in a GPU submit) waited
|
||||
// macOS and Windows. While terrain loads, the workers
|
||||
// (horizon_threads::jobs) and other programs outnumber the cores, and a frame that gave its core up (in a GPU submit) waited
|
||||
// 10 to 200 ms to get one back.
|
||||
horizon_threads::favour_current();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -6119,6 +6098,19 @@ impl Game<'_> {
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
let scale = self.view_scale();
|
||||
self.rendered_mobs.set(0);
|
||||
// Mob models posed on the GPU (mobs::skin), when the renderer takes them this frame.
|
||||
let skinning = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.filter(|r| r.skins_entities())
|
||||
.and_then(|r| r.skinned_models());
|
||||
match skinning {
|
||||
Some(stored) => self
|
||||
.skin_models
|
||||
.borrow_mut()
|
||||
.begin(&mut self.skin_frame.borrow_mut(), stored),
|
||||
None => self.skin_frame.borrow_mut().clear_draws(),
|
||||
}
|
||||
// What custom entity model animations read of the frame.
|
||||
horizon_client::mobs::cem_anim::set_frame(horizon_client::mobs::cem_anim::Frame {
|
||||
time: self.world.game_time as f64 + alpha,
|
||||
@@ -6261,21 +6253,55 @@ impl Game<'_> {
|
||||
Vec::new()
|
||||
};
|
||||
let body = out.len();
|
||||
let render = if layers_only {
|
||||
horizon_client::mobs::render::render_layers
|
||||
// The GPU path: an ordinary mob, no outline, no layers the CPU path changes after
|
||||
// drawing them (a player's, the HDR tiers below, the sulfur cube's shell).
|
||||
let posable = skinning.is_some()
|
||||
&& !is_player
|
||||
&& !layers_only
|
||||
&& outline.is_none()
|
||||
&& armor.is_empty()
|
||||
&& !matches!(
|
||||
mob.species(),
|
||||
Species::Blaze | Species::MagmaCube | Species::GlowSquid | Species::SulfurCube
|
||||
);
|
||||
let mut skinned = None;
|
||||
if posable {
|
||||
let mut models = self.skin_models.borrow_mut();
|
||||
let mut frame = self.skin_frame.borrow_mut();
|
||||
let mark = frame.mark();
|
||||
let mut skin = horizon_client::mobs::skin::Skin {
|
||||
models: &mut models,
|
||||
frame: &mut frame,
|
||||
bounds: None,
|
||||
};
|
||||
horizon_client::mobs::render::render_skinned(
|
||||
mob,
|
||||
alpha as f32,
|
||||
camera,
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
out,
|
||||
&mut skin,
|
||||
);
|
||||
skinned = Some((mark, skin.bounds));
|
||||
} else {
|
||||
horizon_client::mobs::render::render_with
|
||||
};
|
||||
render(
|
||||
mob,
|
||||
alpha as f32,
|
||||
camera,
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
&armor,
|
||||
out,
|
||||
);
|
||||
let render = if layers_only {
|
||||
horizon_client::mobs::render::render_layers
|
||||
} else {
|
||||
horizon_client::mobs::render::render_with
|
||||
};
|
||||
render(
|
||||
mob,
|
||||
alpha as f32,
|
||||
camera,
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
&armor,
|
||||
out,
|
||||
);
|
||||
}
|
||||
// HDR output: mobs that are light shine by their kind, as blocks do (entity.wgsl
|
||||
// fs_hdr_mark); a tier's window lifts only their bright texels.
|
||||
if let Some(tier) = match mob.species() {
|
||||
@@ -6323,7 +6349,23 @@ impl Game<'_> {
|
||||
if outline.is_none()
|
||||
&& horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none()
|
||||
{
|
||||
// What the GPU draws of the mob counts by its bounds' corners.
|
||||
let corners = skinned.and_then(|(_, bounds)| bounds);
|
||||
if let Some((lo, hi)) = corners {
|
||||
out.extend([lo, hi].map(|pos| Vertex {
|
||||
pos: pos.to_array(),
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
}));
|
||||
}
|
||||
let len = out.len();
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
if out.len() < len {
|
||||
if let Some((mark, _)) = skinned {
|
||||
self.skin_frame.borrow_mut().truncate(mark);
|
||||
}
|
||||
} else if corners.is_some() {
|
||||
out.truncate(len - 2);
|
||||
}
|
||||
}
|
||||
// GuardianRenderer: the laser to the locked target.
|
||||
if let Some((time, scale)) = horizon_client::mobs::guardian::beam(mob, alpha as f32)
|
||||
|
||||
@@ -712,6 +712,17 @@ mod tests {
|
||||
assert_eq!(range, 48.0);
|
||||
}
|
||||
|
||||
/// `Entity.shouldRenderAtSqrDistance` for a dropped item (`ItemEntity`
|
||||
/// keeps the default and its box is 0.25 on each side): 24 blocks at
|
||||
/// render distance 12, 32 at 16, 40 from 20 on; never 64.
|
||||
#[test]
|
||||
fn a_dropped_item_is_drawn_sixteen_blocks_times_the_view_scale() {
|
||||
let item = glam::DVec3::splat(0.25);
|
||||
assert_eq!(default_range(item, entity_view_scale(12, 1.0)), 24.0);
|
||||
assert_eq!(default_range(item, entity_view_scale(16, 1.0)), 32.0);
|
||||
assert_eq!(default_range(item, entity_view_scale(32, 1.0)), 40.0);
|
||||
}
|
||||
|
||||
/// `Entity.shouldRenderAtSqrDistance` on a broken box: the size falls
|
||||
/// back to one block.
|
||||
#[test]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -483,12 +483,22 @@ impl Game<'_> {
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
// Entity.shouldRenderAtSqrDistance for the item's 0.25 box: 16 blocks
|
||||
// times the view scale (32 at render distance 16), from its current
|
||||
// position. A pickup particle is not an entity and keeps 64 blocks.
|
||||
let range =
|
||||
super::entity_culling::default_range(glam::DVec3::splat(0.25), self.view_scale());
|
||||
for (item, at, partial, entity) in live.chain(taken) {
|
||||
let entity_start = out.len();
|
||||
self.mark_motion(entity_start, (2 << 32) | u64::from(item.id));
|
||||
let q = item.position;
|
||||
let pos = glam::DVec3::from_array(at) - camera;
|
||||
if pos.length_squared() > 64.0 * 64.0 {
|
||||
let far = if entity {
|
||||
(glam::DVec3::new(q.x, q.y, q.z) - camera).length_squared() >= range * range
|
||||
} else {
|
||||
pos.length_squared() > 64.0 * 64.0
|
||||
};
|
||||
if far {
|
||||
continue;
|
||||
}
|
||||
// EntityRenderer.shouldRender: the item's 0.25 box, grown by 0.5,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Finding a mob by its network id without walking the whole list: a
|
||||
//! multiplayer client looks one up for every movement, data, motion and
|
||||
//! event packet. A `Mob` is ~3 KB, so a linear search touches a cache line
|
||||
//! per mob: 1000 movement packets in a 3000-mob farm cost ~5 ms a tick that
|
||||
//! way, against ~0.01 ms through the index.
|
||||
//!
|
||||
//! The index is a cache: the mob list changes in many places (spawns,
|
||||
//! removals, dimension swaps), and none of them has to tell it. A hit is
|
||||
//! checked against the list, and a miss falls back to the linear search,
|
||||
//! which rebuilds the index when it finds the mob. Ids are unique in the list
|
||||
//! (`spawn_entity` drops a mob with the same id before adding one), so the
|
||||
//! answer is always the linear search's.
|
||||
|
||||
use horizon_client::fast_hash::FxHashMap;
|
||||
use horizon_client::mobs::Mob;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct MobIndex {
|
||||
slots: FxHashMap<u32, u32>,
|
||||
}
|
||||
|
||||
impl MobIndex {
|
||||
/// The position of the mob with this id in `mobs`, as
|
||||
/// `mobs.iter().position(|m| m.id == id)`.
|
||||
pub(super) fn find(&mut self, mobs: &[Mob], id: u32) -> Option<usize> {
|
||||
self.position(mobs, |m| m.id, id)
|
||||
}
|
||||
|
||||
fn position<T>(&mut self, items: &[T], id_of: impl Fn(&T) -> u32, id: u32) -> Option<usize> {
|
||||
if let Some(&i) = self.slots.get(&id)
|
||||
&& items.get(i as usize).is_some_and(|m| id_of(m) == id)
|
||||
{
|
||||
return Some(i as usize);
|
||||
}
|
||||
let i = items.iter().position(|m| id_of(m) == id)?;
|
||||
self.slots.clear();
|
||||
for (i, m) in items.iter().enumerate() {
|
||||
self.slots.entry(id_of(m)).or_insert(i as u32);
|
||||
}
|
||||
Some(i)
|
||||
}
|
||||
|
||||
/// The mob with this id, as `mobs.iter_mut().find(|m| m.id == id)`.
|
||||
pub(super) fn get_mut<'a>(&mut self, mobs: &'a mut [Mob], id: u32) -> Option<&'a mut Mob> {
|
||||
let i = self.find(mobs, id)?;
|
||||
Some(&mut mobs[i])
|
||||
}
|
||||
|
||||
/// The mob with this id, as `mobs.iter().find(|m| m.id == id)`.
|
||||
pub(super) fn get<'a>(&mut self, mobs: &'a [Mob], id: u32) -> Option<&'a Mob> {
|
||||
let i = self.find(mobs, id)?;
|
||||
Some(&mobs[i])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Whatever the list went through since the last lookup, the index
|
||||
/// answers as the linear search does.
|
||||
#[test]
|
||||
fn finds_what_a_linear_search_finds_after_any_change() {
|
||||
let mut index = MobIndex::default();
|
||||
let check = |index: &mut MobIndex, ids: &[u32]| {
|
||||
for id in 0..20 {
|
||||
let found = index.position(ids, |&m| m, id);
|
||||
assert_eq!(
|
||||
found,
|
||||
ids.iter().position(|&m| m == id),
|
||||
"id {id} in {ids:?}"
|
||||
);
|
||||
}
|
||||
};
|
||||
let mut ids = vec![7, 3, 9, 12];
|
||||
check(&mut index, &ids);
|
||||
ids.remove(1);
|
||||
check(&mut index, &ids);
|
||||
ids.push(3);
|
||||
ids.swap(0, 2);
|
||||
check(&mut index, &ids);
|
||||
ids.retain(|&m| m != 9);
|
||||
ids.insert(0, 15);
|
||||
check(&mut index, &ids);
|
||||
// Another list altogether: a dimension swapped in.
|
||||
check(&mut index, &[12, 15, 1]);
|
||||
ids.clear();
|
||||
check(&mut index, &ids);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -2230,7 +2230,7 @@ impl Game<'_> {
|
||||
)
|
||||
})
|
||||
} else {
|
||||
self.mobs.iter().find(|m| m.id == id as u32).map(|m| {
|
||||
self.mob_index.get(&self.mobs, id as u32).map(|m| {
|
||||
(
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
m.kind.width,
|
||||
@@ -2448,9 +2448,8 @@ impl Game<'_> {
|
||||
let dragon = self.dragon.as_ref().is_some_and(|d| d.id as i32 == id);
|
||||
let driven = self.driven_mount().is_some_and(|m| m.id as i32 == id);
|
||||
let mob = self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == id as u32)
|
||||
.mob_index
|
||||
.get_mut(&mut self.mobs, id as u32)
|
||||
.filter(|_| !dragon);
|
||||
let tracked = self
|
||||
.remote
|
||||
@@ -2852,7 +2851,7 @@ impl Game<'_> {
|
||||
value("minecraft:movement_speed"),
|
||||
value("minecraft:jump_strength"),
|
||||
);
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) else {
|
||||
let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) else {
|
||||
return;
|
||||
};
|
||||
// A horse its rider's client moves goes at its own speed and jumps
|
||||
@@ -3284,7 +3283,7 @@ impl Game<'_> {
|
||||
None => Some(Holder::Mob(d as u32)),
|
||||
},
|
||||
};
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.source as u32) {
|
||||
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.source as u32) {
|
||||
m.extra.leash = holder;
|
||||
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id == p.source as u32) {
|
||||
// A leashed boat or raft (AbstractBoat is Leashable).
|
||||
@@ -3304,7 +3303,7 @@ impl Game<'_> {
|
||||
// Entity.DATA_SHARED_FLAGS_ID, DATA_CUSTOM_NAME and
|
||||
// DATA_CUSTOM_NAME_VISIBLE: server NPCs and holograms rely on
|
||||
// these instead of saved NBT.
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
|
||||
for (index, _, value) in &p.values {
|
||||
match (index, value) {
|
||||
(0, Value::Byte(flags)) => {
|
||||
@@ -3337,7 +3336,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.remote_mob_data(p.id, &p.values);
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
|
||||
&& m.species() == horizon_client::mobs::Species::Creeper
|
||||
{
|
||||
for (index, _, value) in &p.values {
|
||||
@@ -3350,7 +3349,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
|
||||
for (index, _, value) in &p.values {
|
||||
match (index, value) {
|
||||
// Entity.DATA_TICKS_FROZEN: isFullyFrozen shakes it.
|
||||
@@ -3382,7 +3381,7 @@ impl Game<'_> {
|
||||
for (index, _, value) in &p.values {
|
||||
if let (14, Value::OptPos(bed)) = (index, value) {
|
||||
let code = bed.map_or(0, |b| self.bed_facing(b));
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
|
||||
m.extra.ints[1] = code;
|
||||
if let Some(head) = *bed {
|
||||
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): setPosToBed.
|
||||
@@ -3401,14 +3400,14 @@ impl Game<'_> {
|
||||
// Avatar.DATA_PLAYER_MAIN_HAND (HumanoidArm: 0 left, 1
|
||||
// right): the arm a swing of the main hand moves.
|
||||
if let (15, Value::Id(arm)) = (index, value)
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
|
||||
{
|
||||
m.extra.main_hand_left = *arm == 0;
|
||||
}
|
||||
// Avatar.DATA_PLAYER_MODE_CUSTOMISATION: the skin layers
|
||||
// the player shows (PlayerModelPart bits).
|
||||
if let (16, Value::Byte(bits)) = (index, value)
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
|
||||
{
|
||||
m.extra.hidden_model_parts = !(*bits as u8) & 0x7F;
|
||||
}
|
||||
@@ -3518,7 +3517,7 @@ impl Game<'_> {
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.level_players.contains_key(&p.id)))
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
|
||||
{
|
||||
for (index, _, value) in &p.values {
|
||||
if let (0, Value::Byte(flags)) = (index, value) {
|
||||
@@ -3571,7 +3570,7 @@ impl Game<'_> {
|
||||
)
|
||||
})
|
||||
});
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
|
||||
if let Some(charged) = charged
|
||||
&& &*m.kind.id == "minecraft:player"
|
||||
{
|
||||
@@ -3753,7 +3752,7 @@ impl Game<'_> {
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
} else if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
|
||||
m.limb_speed = 1.5;
|
||||
m.hurt_time = 10;
|
||||
} else {
|
||||
@@ -3766,7 +3765,7 @@ impl Game<'_> {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.health.animate_hurt_dir(p.yaw);
|
||||
}
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
} else if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
|
||||
m.hurt_time = 10;
|
||||
} else {
|
||||
self.remote_dragon_hurt(p.id);
|
||||
@@ -3810,20 +3809,20 @@ impl Game<'_> {
|
||||
self.fox_eat_particles(p.id as u32);
|
||||
}
|
||||
if p.event == 1
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
|
||||
&& m.species() == horizon_client::mobs::Species::Rabbit
|
||||
{
|
||||
horizon_client::mobs::rabbit::hop_event(m);
|
||||
}
|
||||
if p.event == 19
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
|
||||
{
|
||||
horizon_client::mobs::aquatic::squid_cycle_event(m);
|
||||
}
|
||||
// LivingEntity.handleEntityEvent 55: another player swapped
|
||||
// its main and off hand (`swapHandItems`).
|
||||
if p.event == 55
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
|
||||
{
|
||||
std::mem::swap(&mut m.extra.main_hand, &mut m.extra.off_hand);
|
||||
}
|
||||
@@ -3835,7 +3834,7 @@ impl Game<'_> {
|
||||
}
|
||||
// EntityEvent.DEATH: start the death animation.
|
||||
if p.event == 3
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
|
||||
{
|
||||
m.health = 0.0;
|
||||
}
|
||||
@@ -3859,12 +3858,12 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
// The events that start a mob's animation (the warden's).
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
|
||||
horizon_client::mobs::sync::entity_event(m, p.event as u8);
|
||||
}
|
||||
// LivingEntity.handleEntityEvent 60: the poof as the dead body goes.
|
||||
if p.event == 60
|
||||
&& let Some(m) = self.mobs.iter().find(|m| m.id == p.id as u32)
|
||||
&& let Some(m) = self.mob_index.get(&self.mobs, p.id as u32)
|
||||
{
|
||||
let (at, w, h) = (m.position, f64::from(m.width()), f64::from(m.height()));
|
||||
self.death_poof([at.x, at.y, at.z], w, h);
|
||||
@@ -3941,7 +3940,7 @@ impl Game<'_> {
|
||||
};
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
self.swing_hand(hand, animation);
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
} else if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
|
||||
m.swing_hand_modified(hand == Hand::Off, animation);
|
||||
}
|
||||
}
|
||||
@@ -5219,7 +5218,7 @@ impl Game<'_> {
|
||||
fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) {
|
||||
self.remote_dragon_motion(id, velocity);
|
||||
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
|
||||
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) {
|
||||
// Squid.aiStep derives its body pitch and roll from network motion.
|
||||
m.velocity = v;
|
||||
} else if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
|
||||
@@ -8181,9 +8180,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 +8213,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 +8246,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 +8314,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 +8536,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 +9837,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 +9856,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()
|
||||
|
||||
@@ -487,7 +487,7 @@ impl Game<'_> {
|
||||
(14, _, Value::OptPos(bed)) => Some(bed.map_or(0, |b| self.bed_facing(b))),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
|
||||
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) {
|
||||
apply(m, values, &r.builtin, r.entity_id);
|
||||
apply_registry_data(m, values, &r.registries, self.world.game_time);
|
||||
if let Some(code) = bed
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -170,7 +170,7 @@ impl Game<'_> {
|
||||
} else if self.riding == Some(vehicle_id) {
|
||||
// The server takes the player out, then teleports it.
|
||||
self.riding = None;
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == vehicle_id) {
|
||||
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, vehicle_id) {
|
||||
m.rider = None;
|
||||
}
|
||||
// HappyGhast.removePassenger: a still timeout of 10 (the server's
|
||||
@@ -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;
|
||||
@@ -278,7 +286,7 @@ impl Game<'_> {
|
||||
pub(super) fn remote_move_vehicle(&mut self, position: [f64; 3], yaw: f32) {
|
||||
let at = Vec3::new(position[0], position[1], position[2]);
|
||||
if let Some(id) = self.driven_mount().map(|m| m.id)
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, id)
|
||||
{
|
||||
m.previous = at;
|
||||
m.position = at;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -73,6 +73,7 @@ pub mod render;
|
||||
pub mod riders;
|
||||
pub mod seen_grid;
|
||||
pub mod shulker;
|
||||
pub mod skin;
|
||||
pub mod sniffer;
|
||||
pub mod spawn_rules;
|
||||
pub mod spawn_selection;
|
||||
@@ -2799,6 +2800,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 +2887,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 +4333,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 +4375,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)]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! (quadrupeds, humanoids, creepers, spiders, chickens...) applied to a map of
|
||||
//! part poses before the model is drawn.
|
||||
use super::{Mob, Part, keyframes::AnimationState, living::wrap};
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use horizon_core::geometry::trig::Mth;
|
||||
use std::collections::HashMap;
|
||||
use std::f32::consts::{FRAC_PI_6, PI};
|
||||
@@ -101,15 +102,27 @@ impl AnimState {
|
||||
}
|
||||
pub fn end_tick(&mut self, _swinging: bool) {}
|
||||
}
|
||||
/// A part name as `Poses` keys it: its Fx hash, so filling a pose map allocates
|
||||
/// nothing (a mob farm resets one per layer per mob each frame). Names are short
|
||||
/// and few per model; `no_two_part_names_share_a_key` checks every vanilla model.
|
||||
#[inline]
|
||||
pub(crate) fn key(name: &str) -> u64 {
|
||||
use std::hash::Hasher;
|
||||
let mut h = crate::fast_hash::FxHasher::default();
|
||||
h.write(name.as_bytes());
|
||||
// The length too, so "a" and "a\0" differ (str's own Hash adds a terminator).
|
||||
h.write_usize(name.len());
|
||||
h.finish()
|
||||
}
|
||||
/// Part poses for one draw, keyed by part name.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Poses {
|
||||
map: HashMap<String, [f32; 9]>,
|
||||
map: FxHashMap<u64, [f32; 9]>,
|
||||
dummy: [f32; 9],
|
||||
/// Parts with `skipDraw` set: their own cubes are not drawn, children are.
|
||||
skipped: std::collections::HashSet<String>,
|
||||
skipped: FxHashSet<u64>,
|
||||
/// Parts with `visible` cleared: neither they nor their children draw.
|
||||
hidden: std::collections::HashSet<String>,
|
||||
hidden: FxHashSet<u64>,
|
||||
}
|
||||
impl Poses {
|
||||
pub fn new(model: &Part) -> Self {
|
||||
@@ -120,9 +133,9 @@ impl Poses {
|
||||
/// Refills this for another model, keeping the map and the sets' capacity:
|
||||
/// a mob draws its body and every equipment layer each frame.
|
||||
pub fn reset(&mut self, model: &Part) {
|
||||
fn walk(p: &Part, map: &mut HashMap<String, [f32; 9]>) {
|
||||
fn walk(p: &Part, map: &mut FxHashMap<u64, [f32; 9]>) {
|
||||
// ModelPart.createPartLookup fills its map with putIfAbsent: the first part keeps a name.
|
||||
map.entry(p.name.clone()).or_insert(p.pose);
|
||||
map.entry(key(&p.name)).or_insert(p.pose);
|
||||
for c in &p.children {
|
||||
walk(c, map);
|
||||
}
|
||||
@@ -134,26 +147,26 @@ impl Poses {
|
||||
walk(model, &mut self.map);
|
||||
}
|
||||
pub fn get(&mut self, name: &str) -> &mut [f32; 9] {
|
||||
match self.map.get_mut(name) {
|
||||
match self.map.get_mut(&key(name)) {
|
||||
Some(p) => p,
|
||||
None => &mut self.dummy,
|
||||
}
|
||||
}
|
||||
pub fn pose(&self, part: &Part) -> [f32; 9] {
|
||||
self.map.get(&part.name).copied().unwrap_or(part.pose)
|
||||
self.map.get(&key(&part.name)).copied().unwrap_or(part.pose)
|
||||
}
|
||||
pub fn skip_draw(&mut self, name: &str) {
|
||||
self.skipped.insert(name.to_owned());
|
||||
self.skipped.insert(key(name));
|
||||
}
|
||||
pub fn skips(&self, part: &Part) -> bool {
|
||||
self.skips_named(&part.name)
|
||||
}
|
||||
/// `skips` by the part's name.
|
||||
pub fn skips_named(&self, name: &str) -> bool {
|
||||
self.skipped.contains(name)
|
||||
!self.skipped.is_empty() && self.skipped.contains(&key(name))
|
||||
}
|
||||
pub fn has(&self, name: &str) -> bool {
|
||||
self.map.contains_key(name)
|
||||
self.map.contains_key(&key(name))
|
||||
}
|
||||
pub fn xrot(&mut self, n: &str, v: f32) {
|
||||
self.get(n)[3] = v;
|
||||
@@ -167,9 +180,9 @@ impl Poses {
|
||||
/// `ModelPart.visible = v`; a hidden part and its children do not draw.
|
||||
pub fn visible(&mut self, n: &str, v: bool) {
|
||||
if v {
|
||||
self.hidden.remove(n);
|
||||
} else if self.map.contains_key(n) {
|
||||
self.hidden.insert(n.to_owned());
|
||||
self.hidden.remove(&key(n));
|
||||
} else if self.map.contains_key(&key(n)) {
|
||||
self.hidden.insert(key(n));
|
||||
}
|
||||
}
|
||||
pub fn is_hidden(&self, part: &Part) -> bool {
|
||||
@@ -177,7 +190,7 @@ impl Poses {
|
||||
}
|
||||
/// `is_hidden` by the part's name.
|
||||
pub fn is_hidden_named(&self, name: &str) -> bool {
|
||||
self.hidden.contains(name)
|
||||
!self.hidden.is_empty() && self.hidden.contains(&key(name))
|
||||
}
|
||||
}
|
||||
/// The LivingEntityRenderState values setupAnim reads.
|
||||
@@ -1003,7 +1016,7 @@ mod flight_pose_tests {
|
||||
"left_leg",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|n| (n.to_string(), [0.0; 9]))
|
||||
.map(|n| (key(n), [0.0; 9]))
|
||||
.collect(),
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
@@ -1011,13 +1024,13 @@ mod flight_pose_tests {
|
||||
};
|
||||
player_model_visibility(&mut p, 0, true);
|
||||
for part in ["body", "right_arm", "left_arm", "right_leg", "left_leg"] {
|
||||
assert!(p.hidden.contains(part), "{part}");
|
||||
assert!(p.hidden.contains(&key(part)), "{part}");
|
||||
}
|
||||
assert!(!p.hidden.contains("head") && !p.hidden.contains("hat"));
|
||||
assert!(!p.hidden.contains(&key("head")) && !p.hidden.contains(&key("hat")));
|
||||
// Back in another mode they all show, but for the layers the player hid.
|
||||
player_model_visibility(&mut p, 1 << 6, false);
|
||||
assert!(!p.hidden.contains("body") && !p.hidden.contains("left_leg"));
|
||||
assert!(p.hidden.contains("hat"));
|
||||
assert!(!p.hidden.contains(&key("body")) && !p.hidden.contains(&key("left_leg")));
|
||||
assert!(p.hidden.contains(&key("hat")));
|
||||
}
|
||||
#[test]
|
||||
fn blocking_arm_follows_the_head_with_limits_in_both_hands() {
|
||||
@@ -1030,7 +1043,7 @@ mod flight_pose_tests {
|
||||
let mut p = Poses {
|
||||
map: ["head", "right_arm", "left_arm"]
|
||||
.into_iter()
|
||||
.map(|n| (n.to_string(), [0.0; 9]))
|
||||
.map(|n| (key(n), [0.0; 9]))
|
||||
.collect(),
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
@@ -1087,7 +1100,7 @@ mod flight_pose_tests {
|
||||
"left_leg",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|n| (n.to_string(), [0.0; 9]))
|
||||
.map(|n| (key(n), [0.0; 9]))
|
||||
.collect(),
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
@@ -1142,7 +1155,7 @@ mod flight_pose_tests {
|
||||
"body",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|n| (n.to_string(), [0.0; 9]))
|
||||
.map(|n| (key(n), [0.0; 9]))
|
||||
.collect(),
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
@@ -1183,7 +1196,7 @@ mod flight_pose_tests {
|
||||
let make = || Poses {
|
||||
map: ["head", "right_arm", "left_arm", "right_leg", "left_leg"]
|
||||
.into_iter()
|
||||
.map(|n| (n.to_string(), [0.0; 9]))
|
||||
.map(|n| (key(n), [0.0; 9]))
|
||||
.collect(),
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
@@ -1289,7 +1302,7 @@ mod flight_pose_tests {
|
||||
let mut p = Poses {
|
||||
map: ["head", "right_arm", "left_arm"]
|
||||
.into_iter()
|
||||
.map(|n| (n.to_string(), [0.0; 9]))
|
||||
.map(|n| (key(n), [0.0; 9]))
|
||||
.collect(),
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
@@ -1310,7 +1323,7 @@ mod flight_pose_tests {
|
||||
let mut p = Poses {
|
||||
map: ["head", "right_arm", "left_arm"]
|
||||
.into_iter()
|
||||
.map(|n| (n.to_string(), [0.0; 9]))
|
||||
.map(|n| (key(n), [0.0; 9]))
|
||||
.collect(),
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
@@ -1338,7 +1351,7 @@ mod flight_pose_tests {
|
||||
let make = || Poses {
|
||||
map: ["head", "right_arm", "left_arm"]
|
||||
.into_iter()
|
||||
.map(|n| (n.to_string(), [0.0; 9]))
|
||||
.map(|n| (key(n), [0.0; 9]))
|
||||
.collect(),
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1278,15 +1278,7 @@ fn draw_normals(
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let p = poses.pose(part);
|
||||
let m = base
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(p[0], p[1], p[2]) / 16.0)
|
||||
* glam::Mat4::from_quat(glam::Quat::from_euler(
|
||||
glam::EulerRot::ZYX,
|
||||
p[5],
|
||||
p[4],
|
||||
p[3],
|
||||
))
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(p[6], p[7], p[8]));
|
||||
let m = posed(base, p);
|
||||
if poses.is_hidden(part) || p[6] == 0.0 && p[7] == 0.0 && p[8] == 0.0 {
|
||||
return;
|
||||
}
|
||||
@@ -1324,6 +1316,46 @@ fn draw_normals(
|
||||
draw_normals(c, m, normals, poses, uv, scroll, color, light, overlay, out);
|
||||
}
|
||||
}
|
||||
/// A part's matrix under `base` for its pose (`ModelPart.translateAndRotate`
|
||||
/// and its scale), as `draw` and the GPU path (`super::skin`) both use it.
|
||||
pub(super) fn posed(base: glam::Mat4, p: [f32; 9]) -> glam::Mat4 {
|
||||
base * glam::Mat4::from_translation(glam::Vec3::new(p[0], p[1], p[2]) / 16.0)
|
||||
* glam::Mat4::from_quat(glam::Quat::from_euler(
|
||||
glam::EulerRot::ZYX,
|
||||
p[5],
|
||||
p[4],
|
||||
p[3],
|
||||
))
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(p[6], p[7], p[8]))
|
||||
}
|
||||
/// `draw_scrolled` through the GPU path when the mob takes it (`skin`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn put(
|
||||
skin: &mut Option<&mut super::skin::Skin>,
|
||||
part: &std::sync::Arc<Part>,
|
||||
base: glam::Mat4,
|
||||
poses: &Poses,
|
||||
uv: [f32; 4],
|
||||
scroll: f32,
|
||||
color: u32,
|
||||
light: u32,
|
||||
overlay: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let paint = super::skin::Paint {
|
||||
uv,
|
||||
scroll,
|
||||
color,
|
||||
light,
|
||||
overlay,
|
||||
};
|
||||
if let Some(skin) = skin
|
||||
&& skin.draw(part, base, poses, paint)
|
||||
{
|
||||
return;
|
||||
}
|
||||
draw_scrolled(part, base, poses, uv, scroll, color, light, overlay, out);
|
||||
}
|
||||
/// Render a mob with its variant model, texture and layers.
|
||||
pub fn render(
|
||||
m: &Mob,
|
||||
@@ -1360,7 +1392,7 @@ pub fn render_with(
|
||||
let shifted =
|
||||
camera - glam::DVec3::new(off[0] + w[0], off[1] + w[1], off[2] + w[2]);
|
||||
let base = base_transform(m, alpha, shifted);
|
||||
render_visible(m, alpha, base, atlas, light, data, extra, true, out);
|
||||
render_visible(m, alpha, base, atlas, light, data, extra, true, out, None);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -1375,6 +1407,37 @@ pub fn render_with(
|
||||
extra,
|
||||
true,
|
||||
out,
|
||||
None,
|
||||
);
|
||||
}
|
||||
/// `render` with the model's ordinary draws posed on the GPU when the mob
|
||||
/// allows it (`super::skin`): the same picture, no vertices on the CPU.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render_skinned(
|
||||
m: &Mob,
|
||||
alpha: f32,
|
||||
camera: glam::DVec3,
|
||||
atlas: Atlas,
|
||||
light: u32,
|
||||
data: &MobData,
|
||||
out: &mut Vec<Vertex>,
|
||||
skin: &mut super::skin::Skin,
|
||||
) {
|
||||
if super::trader::is_invisible(m) {
|
||||
render_with(m, alpha, camera, atlas, light, data, &[], out);
|
||||
return;
|
||||
}
|
||||
render_visible(
|
||||
m,
|
||||
alpha,
|
||||
base_transform(m, alpha, camera),
|
||||
atlas,
|
||||
light,
|
||||
data,
|
||||
&[],
|
||||
true,
|
||||
out,
|
||||
Some(skin),
|
||||
);
|
||||
}
|
||||
/// `CapeLayer.submit` with `PlayerCapeModel`: the player model's body posed
|
||||
@@ -1632,7 +1695,7 @@ pub fn render_layers(
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let base = base_transform(m, alpha, camera);
|
||||
render_visible(m, alpha, base, atlas, light, data, extra, false, out);
|
||||
render_visible(m, alpha, base, atlas, light, data, extra, false, out, None);
|
||||
}
|
||||
/// A mob drawn under `origin` instead of at its position (the spinning mob
|
||||
/// of `SpawnerRenderer.submitEntityInSpawner`).
|
||||
@@ -1646,7 +1709,7 @@ pub fn render_posed(
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let base = base_transform_at(m, alpha, origin);
|
||||
render_visible(m, alpha, base, atlas, light, data, &[], true, out);
|
||||
render_visible(m, alpha, base, atlas, light, data, &[], true, out, None);
|
||||
}
|
||||
/// The custom model's key of a body model: `main` is the wide player model, `kind.model`.
|
||||
fn cem_key(model_key: &str) -> &str {
|
||||
@@ -1730,6 +1793,7 @@ fn render_visible(
|
||||
extra: &[(&Part, [f32; 4], u32, u32, bool)],
|
||||
body_visible: bool,
|
||||
out: &mut Vec<Vertex>,
|
||||
skin: Option<&mut super::skin::Skin>,
|
||||
) {
|
||||
let overlay = u32::from(red_overlay(m));
|
||||
let s = anim::state(m, alpha);
|
||||
@@ -1799,56 +1863,6 @@ fn render_visible(
|
||||
if let Some(animation) = body_animation {
|
||||
animation.run(m, &s, &mut poses);
|
||||
}
|
||||
let draw_body = |out: &mut Vec<Vertex>| {
|
||||
// A player or mannequin whose skin has no atlas sprite (an evicted or
|
||||
// never stored download) draws with its default skin, not invisibly.
|
||||
let drawn = sprite(texture).or_else(|| Some((texture, atlas(texture)?)));
|
||||
let uv = drawn.map(|(_, uv)| uv).or_else(|| {
|
||||
if avatar(m) {
|
||||
atlas(&m.kind.texture)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if body_visible && let Some(uv) = uv {
|
||||
let start = out.len();
|
||||
draw(
|
||||
&model,
|
||||
base,
|
||||
&poses,
|
||||
uv,
|
||||
body_color(m, alpha),
|
||||
light,
|
||||
body_overlay(m, alpha),
|
||||
out,
|
||||
);
|
||||
if let Some(glow) = drawn.and_then(|(name, _)| emissive_uv(name)) {
|
||||
draw(
|
||||
&model,
|
||||
base,
|
||||
&poses,
|
||||
glow,
|
||||
body_color(m, alpha),
|
||||
FULL_BRIGHT,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
}
|
||||
if translucent_body(m) {
|
||||
mark_oit(&mut out[start..]);
|
||||
}
|
||||
}
|
||||
};
|
||||
// The translucent shell writes depth: draw the opaque core before it.
|
||||
let shell_last = m.species() == Species::SulfurCube;
|
||||
if !shell_last {
|
||||
draw_body(out);
|
||||
}
|
||||
// The worn armour's layers (and their emissive overlays, full bright).
|
||||
for (part, uv, color, layer_overlay, glows) in extra {
|
||||
let light = if *glows { FULL_BRIGHT } else { light };
|
||||
draw(part, base, &poses, *uv, *color, light, *layer_overlay, out);
|
||||
}
|
||||
let mut all_layers = layers(m, s.age);
|
||||
// WardenRenderer: the translucent emissive layers fade with the animations.
|
||||
if m.species() == Species::Warden {
|
||||
@@ -1864,6 +1878,87 @@ fn render_visible(
|
||||
}
|
||||
}
|
||||
}
|
||||
// The GPU path takes the whole mob or none of it, so its draws keep their
|
||||
// order: only plain opaque and cutout draws of models it can pose (no
|
||||
// custom models, translucency, eyes or energy swirl, which the CPU path
|
||||
// changes after drawing).
|
||||
let mut skin = skin;
|
||||
let posable = match skin.as_deref_mut() {
|
||||
Some(skin) => {
|
||||
custom_body.is_none()
|
||||
&& extra.is_empty()
|
||||
&& m.species() != Species::SulfurCube
|
||||
&& !translucent_body(m)
|
||||
&& energy_swirl(m, s.age).is_none()
|
||||
&& skin.supports(&model)
|
||||
&& all_layers.iter().all(|layer| {
|
||||
!layer.full_bright
|
||||
&& !translucent_layer(m, layer)
|
||||
&& cem.layer(&layer.model).is_none()
|
||||
&& m.kind
|
||||
.layer(&layer.model)
|
||||
.is_none_or(|part| skin.supports(part))
|
||||
})
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if !posable {
|
||||
skin = None;
|
||||
}
|
||||
let draw_body = |out: &mut Vec<Vertex>, skin: &mut Option<&mut super::skin::Skin>| {
|
||||
// A player or mannequin whose skin has no atlas sprite (an evicted or
|
||||
// never stored download) draws with its default skin, not invisibly.
|
||||
let drawn = sprite(texture).or_else(|| Some((texture, atlas(texture)?)));
|
||||
let uv = drawn.map(|(_, uv)| uv).or_else(|| {
|
||||
if avatar(m) {
|
||||
atlas(&m.kind.texture)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if body_visible && let Some(uv) = uv {
|
||||
let start = out.len();
|
||||
put(
|
||||
skin,
|
||||
&model,
|
||||
base,
|
||||
&poses,
|
||||
uv,
|
||||
0.0,
|
||||
body_color(m, alpha),
|
||||
light,
|
||||
body_overlay(m, alpha),
|
||||
out,
|
||||
);
|
||||
if let Some(glow) = drawn.and_then(|(name, _)| emissive_uv(name)) {
|
||||
put(
|
||||
skin,
|
||||
&model,
|
||||
base,
|
||||
&poses,
|
||||
glow,
|
||||
0.0,
|
||||
body_color(m, alpha),
|
||||
FULL_BRIGHT,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
}
|
||||
if translucent_body(m) {
|
||||
mark_oit(&mut out[start..]);
|
||||
}
|
||||
}
|
||||
};
|
||||
// The translucent shell writes depth: draw the opaque core before it.
|
||||
let shell_last = m.species() == Species::SulfurCube;
|
||||
if !shell_last {
|
||||
draw_body(out, &mut skin);
|
||||
}
|
||||
// The worn armour's layers (and their emissive overlays, full bright).
|
||||
for (part, uv, color, layer_overlay, glows) in extra {
|
||||
let light = if *glows { FULL_BRIGHT } else { light };
|
||||
draw(part, base, &poses, *uv, *color, light, *layer_overlay, out);
|
||||
}
|
||||
// One Poses refilled for every layer (armour draws up to seven): it keeps
|
||||
// its maps' capacity.
|
||||
let mut layer_poses = Poses::default();
|
||||
@@ -1917,7 +2012,8 @@ fn render_visible(
|
||||
None => overlay,
|
||||
};
|
||||
let start = out.len();
|
||||
draw_scrolled(
|
||||
put(
|
||||
&mut skin,
|
||||
&part,
|
||||
base,
|
||||
&layer_poses,
|
||||
@@ -1929,7 +2025,8 @@ fn render_visible(
|
||||
out,
|
||||
);
|
||||
if let Some(glow) = emissive_uv(drawn) {
|
||||
draw_scrolled(
|
||||
put(
|
||||
&mut skin,
|
||||
&part,
|
||||
base,
|
||||
&layer_poses,
|
||||
@@ -1979,7 +2076,7 @@ fn render_visible(
|
||||
}
|
||||
}
|
||||
if shell_last {
|
||||
draw_body(out);
|
||||
draw_body(out, &mut skin);
|
||||
}
|
||||
}
|
||||
/// `EnergySwirlLayer`'s model, texture and `xOffset(t)` for a charged creeper
|
||||
@@ -3084,6 +3181,170 @@ mod scroll_tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// `anim::Poses` keys parts by a hash of their name: within any vanilla model, two
|
||||
/// different names never share one (they would share a pose).
|
||||
#[test]
|
||||
fn no_two_part_names_share_a_key() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
if !root.join("entity-models.json").exists() {
|
||||
return;
|
||||
}
|
||||
fn names<'a>(p: &'a Part, out: &mut Vec<&'a str>) {
|
||||
out.push(&p.name);
|
||||
for c in &p.children {
|
||||
names(c, out);
|
||||
}
|
||||
}
|
||||
let types = super::super::MobTypes::load(&root).unwrap();
|
||||
let mut models = 0;
|
||||
for kind in types.types.values() {
|
||||
for model in std::iter::once(&kind.model).chain(kind.layers.values()) {
|
||||
let mut all = Vec::new();
|
||||
names(model, &mut all);
|
||||
let mut keys = std::collections::HashMap::new();
|
||||
for name in all {
|
||||
let other = *keys.entry(super::super::anim::key(name)).or_insert(name);
|
||||
assert_eq!(other, name, "{} shares a key", kind.id);
|
||||
}
|
||||
models += 1;
|
||||
}
|
||||
}
|
||||
assert!(models > 100);
|
||||
}
|
||||
|
||||
/// The CPU cost of posing a mob farm (tools/stress_scenes.py `stress_mobs`): 1000 mobs of
|
||||
/// 8 kinds through `render_with`, as `mob_geometry` draws them each frame. Prints the median
|
||||
/// thread CPU time per frame (busy machines stretch wall time), the vertices and a hash of them (the same hash before and after a speed-up
|
||||
/// means the same picture). `cargo test --profile fast -p horizon-client --lib
|
||||
/// benchmark_mob_farm_geometry -- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore = "manual benchmark; needs the prepared resources"]
|
||||
fn benchmark_mob_farm_geometry() {
|
||||
use std::{hash::Hasher, hint::black_box, time::Instant};
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
if !root.join("entity-models.json").exists() {
|
||||
return;
|
||||
}
|
||||
let types = super::super::MobTypes::load(&root).unwrap();
|
||||
let kinds = [
|
||||
"husk", "creeper", "cow", "pig", "sheep", "chicken", "villager", "spider",
|
||||
]
|
||||
.map(|k| std::sync::Arc::new(types.types[&format!("minecraft:{k}")].clone()));
|
||||
let mobs: Vec<Mob> = (0..1000u32)
|
||||
.map(|i| {
|
||||
let (layer, row, col) = (i / 200, i / 20 % 10, i % 20);
|
||||
let at = horizon_core::geometry::Vec3::new(
|
||||
-14.25 + 1.5 * f64::from(col),
|
||||
1.0 + 2.5 * f64::from(layer),
|
||||
8.0 + 2.0 * f64::from(row),
|
||||
);
|
||||
let mut m = Mob::new(i + 1, kinds[i as usize % 8].clone(), at, 180.0);
|
||||
m.previous = at;
|
||||
m.age = i;
|
||||
m
|
||||
})
|
||||
.collect();
|
||||
let data = MobData {
|
||||
states: Vec::new(),
|
||||
placements: Default::default(),
|
||||
heightmaps: Default::default(),
|
||||
animations: Default::default(),
|
||||
biome_tags: Default::default(),
|
||||
entity_tags: Default::default(),
|
||||
villager_hats: Default::default(),
|
||||
cem: Default::default(),
|
||||
random_textures: Default::default(),
|
||||
emissive: Default::default(),
|
||||
blinks: Default::default(),
|
||||
pois: Default::default(),
|
||||
species_placements: Default::default(),
|
||||
};
|
||||
let atlas = |name: &str| {
|
||||
let h = name.len() as f32 / 64.0;
|
||||
Some([h, h, h + 0.25, h + 0.25])
|
||||
};
|
||||
let camera = glam::DVec3::new(0.5, 9.6, 0.5);
|
||||
let mut out = Vec::new();
|
||||
let frame = |out: &mut Vec<Vertex>| {
|
||||
out.clear();
|
||||
for m in &mobs {
|
||||
render_with(m, 0.5, camera, &atlas, 0xf0f0, &data, &[], out);
|
||||
}
|
||||
};
|
||||
frame(&mut out);
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
hash.write(bytemuck::cast_slice(&out));
|
||||
// The thread's own CPU time where the platform gives it, else wall time.
|
||||
let timed = |f: &mut dyn FnMut()| {
|
||||
let (wall, cpu) = (Instant::now(), horizon_client_render::spans::thread_cpu());
|
||||
f();
|
||||
match (horizon_client_render::spans::thread_cpu(), cpu) {
|
||||
(Some(end), Some(start)) => (end - start).as_secs_f64() * 1000.0,
|
||||
_ => wall.elapsed().as_secs_f64() * 1000.0,
|
||||
}
|
||||
};
|
||||
let mut times = Vec::new();
|
||||
for _ in 0..30 {
|
||||
times.push(timed(&mut || frame(black_box(&mut out))));
|
||||
}
|
||||
times.sort_by(f64::total_cmp);
|
||||
println!(
|
||||
"mob farm: 1000 mobs, {} vertices, {:.3} ms/frame (median of 30, min {:.3}), hash {:016x}",
|
||||
out.len(),
|
||||
times[15],
|
||||
times[0],
|
||||
hash.finish()
|
||||
);
|
||||
// Where it goes: the body's poses alone (state, pose map, setupAnim), then the
|
||||
// body's vertices alone from those poses.
|
||||
let median = |mut f: Box<dyn FnMut() + '_>| {
|
||||
let mut t: Vec<f64> = (0..30).map(|_| timed(&mut f)).collect();
|
||||
t.sort_by(f64::total_cmp);
|
||||
t[15]
|
||||
};
|
||||
let posed: Vec<(std::sync::Arc<Part>, Poses)> = mobs
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let model = m.kind.model.clone();
|
||||
let mut poses = Poses::new(&model);
|
||||
anim::setup(m, &anim::state(m, 0.5), &mut poses, &data.animations);
|
||||
(model, poses)
|
||||
})
|
||||
.collect();
|
||||
let poses_ms = median(Box::new(|| {
|
||||
for m in &mobs {
|
||||
let model = m.kind.model.clone();
|
||||
let mut poses = Poses::new(&model);
|
||||
anim::setup(m, &anim::state(m, 0.5), &mut poses, &data.animations);
|
||||
black_box(&poses);
|
||||
}
|
||||
}));
|
||||
let mut body = Vec::new();
|
||||
let draw_ms = median(Box::new(|| {
|
||||
body.clear();
|
||||
for (m, (model, poses)) in mobs.iter().zip(&posed) {
|
||||
let base = base_transform(m, 0.5, camera);
|
||||
draw(
|
||||
model,
|
||||
base,
|
||||
poses,
|
||||
[0.0, 0.0, 0.25, 0.25],
|
||||
u32::MAX,
|
||||
0xf0f0,
|
||||
0,
|
||||
&mut body,
|
||||
);
|
||||
}
|
||||
black_box(&body);
|
||||
}));
|
||||
println!(
|
||||
" body poses only {poses_ms:.3} ms; body vertices only {draw_ms:.3} ms ({} vertices)",
|
||||
body.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
//! Mob models posed on the GPU (`gfx::entity_skin`): the store of models
|
||||
//! uploaded once, and the walk that turns one `render::draw` into the parts'
|
||||
//! matrices and jobs the shader expands into the very vertices `draw` would
|
||||
//! push, in its order.
|
||||
//!
|
||||
//! A model takes this path only when every polygon faces an axis: the
|
||||
//! normal matrix times an axis is one of its columns, exactly, so the shader's
|
||||
//! packed normals are the CPU's to the bit. Positions and UVs are the same
|
||||
//! sums in the same order (`Mat4::transform_point3`).
|
||||
|
||||
use super::{ModelNormals, Part, anim::Poses};
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use crate::gfx::entity_skin::{SkinFrame, SkinJob, SkinPart, SkinVertex};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A stored model: where its vertices start, and per part (pre-order) its
|
||||
/// own vertices and the end of its subtree, for the walk.
|
||||
struct Stored {
|
||||
first: u32,
|
||||
parts: Vec<StoredPart>,
|
||||
/// The model's bounds in its parts' own space, per part: min and max of
|
||||
/// its own vertices (none: no polygons).
|
||||
bounds: Vec<Option<([f32; 3], [f32; 3])>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct StoredPart {
|
||||
/// The part's first vertex, from the model's.
|
||||
first: u32,
|
||||
count: u32,
|
||||
/// One past the last part of its subtree.
|
||||
end: u32,
|
||||
}
|
||||
|
||||
/// The models on the GPU, by the `Arc` they are drawn from. An entry holds
|
||||
/// its `Arc`, so the address is never reused while stored; models no one
|
||||
/// else holds any more are dropped by `begin`.
|
||||
#[derive(Default)]
|
||||
pub struct SkinModels {
|
||||
stored: FxHashMap<usize, (Arc<Part>, Option<Stored>)>,
|
||||
vertices: u32,
|
||||
}
|
||||
|
||||
/// Models whose store would grow past this restart it (a pack reload's
|
||||
/// models pile up otherwise).
|
||||
const STORE_LIMIT: u32 = 4 << 20;
|
||||
|
||||
impl SkinModels {
|
||||
/// Starts a frame: drops the models nothing else holds (their vertices
|
||||
/// stay in the store until it restarts), restarts the store when it grew
|
||||
/// too large or the renderer's (`stored`, its size) is out of step.
|
||||
pub fn begin(&mut self, frame: &mut SkinFrame, stored: u32) {
|
||||
if self.vertices > STORE_LIMIT || stored + frame.new_model.len() as u32 != self.vertices {
|
||||
self.restart(frame);
|
||||
}
|
||||
self.stored
|
||||
.retain(|_, (model, _)| Arc::strong_count(model) > 1);
|
||||
frame.clear_draws();
|
||||
}
|
||||
|
||||
fn restart(&mut self, frame: &mut SkinFrame) {
|
||||
self.stored.clear();
|
||||
self.vertices = 0;
|
||||
frame.new_model.clear();
|
||||
frame.base = 0;
|
||||
frame.reset = true;
|
||||
}
|
||||
|
||||
fn entry(&mut self, model: &Arc<Part>, frame: &mut SkinFrame) -> Option<&Stored> {
|
||||
let key = Arc::as_ptr(model) as usize;
|
||||
let vertices = &mut self.vertices;
|
||||
let (_, stored) = self.stored.entry(key).or_insert_with(|| {
|
||||
// The renderer appends `new_model` to its store in order, so a
|
||||
// model's first vertex is the store's size when it is added.
|
||||
let before = frame.new_model.len();
|
||||
let stored = flatten(model, *vertices, &mut frame.new_model);
|
||||
*vertices += (frame.new_model.len() - before) as u32;
|
||||
(model.clone(), stored)
|
||||
});
|
||||
stored.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
/// The model's vertices in pre-order (a part's polygons, then its children),
|
||||
/// appended to `out`; `None` when a polygon does not face an axis.
|
||||
fn flatten(model: &Part, first: u32, out: &mut Vec<SkinVertex>) -> Option<Stored> {
|
||||
let start = out.len();
|
||||
let mut parts = Vec::new();
|
||||
let mut bounds = Vec::new();
|
||||
fn walk(
|
||||
part: &Part,
|
||||
start: usize,
|
||||
out: &mut Vec<SkinVertex>,
|
||||
parts: &mut Vec<StoredPart>,
|
||||
bounds: &mut Vec<Option<([f32; 3], [f32; 3])>>,
|
||||
) -> bool {
|
||||
let index = parts.len();
|
||||
let own = (out.len() - start) as u32;
|
||||
parts.push(StoredPart {
|
||||
first: own,
|
||||
count: 0,
|
||||
end: 0,
|
||||
});
|
||||
bounds.push(None);
|
||||
let mut lo = [f32::INFINITY; 3];
|
||||
let mut hi = [f32::NEG_INFINITY; 3];
|
||||
for poly in &part.polygons {
|
||||
let Some(axis) = axis(poly.normal) else {
|
||||
return false;
|
||||
};
|
||||
for v in poly.vertices {
|
||||
let pos = (glam::Vec3::new(v[0], v[1], v[2]) / 16.0).to_array();
|
||||
for a in 0..3 {
|
||||
lo[a] = lo[a].min(pos[a]);
|
||||
hi[a] = hi[a].max(pos[a]);
|
||||
}
|
||||
out.push(SkinVertex {
|
||||
pos,
|
||||
uv: [v[3], v[4]],
|
||||
axis,
|
||||
part: index as u32,
|
||||
});
|
||||
}
|
||||
}
|
||||
parts[index].count = (out.len() - start) as u32 - own;
|
||||
if !part.polygons.is_empty() {
|
||||
bounds[index] = Some((lo, hi));
|
||||
}
|
||||
for child in &part.children {
|
||||
if !walk(child, start, out, parts, bounds) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
parts[index].end = parts.len() as u32;
|
||||
true
|
||||
}
|
||||
if !walk(model, start, out, &mut parts, &mut bounds) {
|
||||
out.truncate(start);
|
||||
return None;
|
||||
}
|
||||
Some(Stored {
|
||||
first,
|
||||
parts,
|
||||
bounds,
|
||||
})
|
||||
}
|
||||
|
||||
/// 0..2 for +x, +y, +z, 3..5 for -x, -y, -z; `None` for any other normal.
|
||||
fn axis(n: [f32; 3]) -> Option<u32> {
|
||||
let mut found = None;
|
||||
for (a, &c) in n.iter().enumerate() {
|
||||
if c == 1.0 || c == -1.0 {
|
||||
if found.is_some() {
|
||||
return None;
|
||||
}
|
||||
found = Some(a as u32 + if c < 0.0 { 3 } else { 0 });
|
||||
} else if c != 0.0 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// One draw's parameters (`render::draw_scrolled`'s).
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Paint {
|
||||
pub uv: [f32; 4],
|
||||
pub scroll: f32,
|
||||
pub color: u32,
|
||||
pub light: u32,
|
||||
pub overlay: u32,
|
||||
}
|
||||
|
||||
/// Where a frame's posed draws go, and the bounds of the current mob's.
|
||||
pub struct Skin<'a> {
|
||||
pub models: &'a mut SkinModels,
|
||||
pub frame: &'a mut SkinFrame,
|
||||
/// Camera-relative bounds of what this mob drew here (for culling).
|
||||
pub bounds: Option<(glam::Vec3, glam::Vec3)>,
|
||||
}
|
||||
|
||||
impl Skin<'_> {
|
||||
/// Whether this model can be posed here (stored on first sight).
|
||||
pub fn supports(&mut self, model: &Arc<Part>) -> bool {
|
||||
self.models.entry(model, self.frame).is_some()
|
||||
}
|
||||
|
||||
/// `render::draw_scrolled` on the GPU: false (nothing done) when the
|
||||
/// model cannot take this path.
|
||||
pub fn draw(
|
||||
&mut self,
|
||||
model: &Arc<Part>,
|
||||
base: glam::Mat4,
|
||||
poses: &Poses,
|
||||
paint: Paint,
|
||||
) -> bool {
|
||||
let Some(stored) = self.models.entry(model, self.frame) else {
|
||||
return false;
|
||||
};
|
||||
let first_part = self.frame.parts.len() as u32;
|
||||
self.frame.parts.resize(
|
||||
self.frame.parts.len() + stored.parts.len(),
|
||||
bytemuck::Zeroable::zeroed(),
|
||||
);
|
||||
let mut walk = Walk {
|
||||
stored,
|
||||
poses,
|
||||
parts: &mut self.frame.parts[first_part as usize..],
|
||||
jobs: &mut self.frame.jobs,
|
||||
vertices: &mut self.frame.vertices,
|
||||
bounds: &mut self.bounds,
|
||||
first_part,
|
||||
paint,
|
||||
index: 0,
|
||||
};
|
||||
walk.part(model, base, ModelNormals::new(base));
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
struct Walk<'a> {
|
||||
stored: &'a Stored,
|
||||
poses: &'a Poses,
|
||||
parts: &'a mut [SkinPart],
|
||||
jobs: &'a mut Vec<SkinJob>,
|
||||
vertices: &'a mut u32,
|
||||
bounds: &'a mut Option<(glam::Vec3, glam::Vec3)>,
|
||||
first_part: u32,
|
||||
paint: Paint,
|
||||
/// The pre-order index of the part being walked.
|
||||
index: u32,
|
||||
}
|
||||
|
||||
impl Walk<'_> {
|
||||
/// `render::draw_normals` without the vertices: the part's matrices, and
|
||||
/// its polygons as (part of) a job.
|
||||
fn part(&mut self, part: &Part, base: glam::Mat4, normals: ModelNormals) {
|
||||
let index = self.index;
|
||||
let stored = self.stored.parts[index as usize];
|
||||
let p = self.poses.pose(part);
|
||||
let m = super::render::posed(base, p);
|
||||
if self.poses.is_hidden(part) || p[6] == 0.0 && p[7] == 0.0 && p[8] == 0.0 {
|
||||
// The whole subtree is left out.
|
||||
self.index = stored.end;
|
||||
return;
|
||||
}
|
||||
let normals = normals.part(p);
|
||||
let n = if normals.trusted {
|
||||
normals.matrix
|
||||
} else {
|
||||
glam::Mat3::from_cols(
|
||||
normals.matrix.x_axis.normalize_or_zero(),
|
||||
normals.matrix.y_axis.normalize_or_zero(),
|
||||
normals.matrix.z_axis.normalize_or_zero(),
|
||||
)
|
||||
};
|
||||
self.parts[index as usize] = SkinPart {
|
||||
matrix: m.to_cols_array(),
|
||||
normal: n.to_cols_array(),
|
||||
};
|
||||
if !self.poses.skips(part) && stored.count > 0 {
|
||||
self.emit(stored);
|
||||
if let Some((lo, hi)) = self.stored.bounds[index as usize] {
|
||||
let (lo, hi) = (glam::Vec3::from_array(lo), glam::Vec3::from_array(hi));
|
||||
let center = m.transform_point3((lo + hi) * 0.5);
|
||||
let half = (hi - lo) * 0.5;
|
||||
let r = glam::Mat3::from_mat4(m);
|
||||
let extent =
|
||||
r.x_axis.abs() * half.x + r.y_axis.abs() * half.y + r.z_axis.abs() * half.z;
|
||||
let (a, b) = (center - extent, center + extent);
|
||||
*self.bounds = Some(match *self.bounds {
|
||||
Some((l, h)) => (l.min(a), h.max(b)),
|
||||
None => (a, b),
|
||||
});
|
||||
}
|
||||
}
|
||||
self.index += 1;
|
||||
for child in &part.children {
|
||||
self.part(child, m, normals);
|
||||
}
|
||||
}
|
||||
|
||||
/// The part's vertices: extends the last job when they follow its own.
|
||||
fn emit(&mut self, stored: StoredPart) {
|
||||
let first = self.stored.first + stored.first;
|
||||
let paint = self.paint;
|
||||
if let Some(job) = self.jobs.last_mut()
|
||||
&& job.first + job.count == first
|
||||
&& job.part == self.first_part
|
||||
&& job.dst + job.count == *self.vertices
|
||||
{
|
||||
job.count += stored.count;
|
||||
} else {
|
||||
self.jobs.push(SkinJob {
|
||||
first,
|
||||
count: stored.count,
|
||||
part: self.first_part,
|
||||
dst: *self.vertices,
|
||||
rect: paint.uv,
|
||||
scroll: paint.scroll,
|
||||
color: paint.color,
|
||||
light: paint.light,
|
||||
overlay: paint.overlay,
|
||||
glint: u32::from(paint.overlay == super::render::ARMOR_GLINT),
|
||||
spare: [0; 3],
|
||||
});
|
||||
}
|
||||
*self.vertices += stored.count;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::gfx::entity_skin::expand;
|
||||
use crate::mobs::{Mob, MobData, MobTypes, render};
|
||||
|
||||
/// Every vanilla mob, at a few ages and turns: the GPU path's vertices
|
||||
/// (its jobs expanded as `entity_skin.wgsl` does) are the CPU path's, to
|
||||
/// the bit and in the same order, for each mob it takes; the mobs it
|
||||
/// leaves to the CPU draw exactly as before. Needs the prepared resources.
|
||||
#[test]
|
||||
fn posed_on_the_gpu_every_mob_draws_what_the_cpu_draws() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
if !root.join("entity-models.json").exists() {
|
||||
eprintln!("no prepared resources: skipped");
|
||||
return;
|
||||
}
|
||||
let types = MobTypes::load(&root).unwrap();
|
||||
let data = MobData {
|
||||
states: Vec::new(),
|
||||
placements: Default::default(),
|
||||
heightmaps: Default::default(),
|
||||
animations: MobData::load_animations(&root),
|
||||
biome_tags: Default::default(),
|
||||
entity_tags: Default::default(),
|
||||
villager_hats: Default::default(),
|
||||
cem: Default::default(),
|
||||
random_textures: Default::default(),
|
||||
emissive: Default::default(),
|
||||
blinks: Default::default(),
|
||||
pois: Default::default(),
|
||||
species_placements: Default::default(),
|
||||
};
|
||||
// Every texture its own rect, so a swapped texture shows.
|
||||
let atlas = |name: &str| {
|
||||
let h = name
|
||||
.bytes()
|
||||
.fold(7u32, |h, b| h.wrapping_mul(31).wrapping_add(u32::from(b)));
|
||||
let u = (h % 61) as f32 / 64.0;
|
||||
let v = (h / 61 % 61) as f32 / 64.0;
|
||||
Some([u, v, u + 1.0 / 64.0, v + 1.0 / 32.0])
|
||||
};
|
||||
let camera = glam::DVec3::new(0.3, 62.0, -4.0);
|
||||
let mut models = SkinModels::default();
|
||||
let mut frame = SkinFrame::default();
|
||||
let mut store: Vec<crate::gfx::entity_skin::SkinVertex> = Vec::new();
|
||||
let (mut posed, mut kept) = (0, 0);
|
||||
let mut ids: Vec<_> = types.types.keys().cloned().collect();
|
||||
ids.sort();
|
||||
for (n, id) in ids.iter().enumerate() {
|
||||
let kind = Arc::new(types.types[id].clone());
|
||||
for (age, yaw) in [(0u32, 0.0f32), (37, 123.0), (412, -71.5)] {
|
||||
let at = horizon_core::geometry::Vec3::new(1.5 + n as f64 % 5.0, 64.0, 3.25);
|
||||
let mut m = Mob::new(n as u32 + 1, kind.clone(), at, yaw);
|
||||
m.previous = horizon_core::geometry::Vec3::new(at.x - 0.1, at.y, at.z + 0.05);
|
||||
m.age = age;
|
||||
m.limb_speed = 0.6;
|
||||
m.limb_position = age as f32 * 0.37;
|
||||
let mut cpu = Vec::new();
|
||||
render::render_with(&m, 0.4, camera, &atlas, 0x50a0, &data, &[], &mut cpu);
|
||||
models.begin(&mut frame, store.len() as u32);
|
||||
let mut gpu = Vec::new();
|
||||
let mut skin = Skin {
|
||||
models: &mut models,
|
||||
frame: &mut frame,
|
||||
bounds: None,
|
||||
};
|
||||
render::render_skinned(&m, 0.4, camera, &atlas, 0x50a0, &data, &mut gpu, &mut skin);
|
||||
let bounds = skin.bounds;
|
||||
if frame.reset {
|
||||
store.clear();
|
||||
}
|
||||
assert_eq!(store.len() as u32, frame.base);
|
||||
store.extend_from_slice(&frame.new_model);
|
||||
frame.handed();
|
||||
if frame.jobs.is_empty() {
|
||||
kept += 1;
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&gpu),
|
||||
bytemuck::cast_slice::<_, u8>(&cpu),
|
||||
"{id} age {age}: the CPU path changed"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
posed += 1;
|
||||
assert!(gpu.is_empty(), "{id}: a posed mob left vertices on the CPU");
|
||||
let expanded = expand(&store, &frame);
|
||||
assert_eq!(expanded.len(), cpu.len(), "{id} age {age}: vertex count");
|
||||
for (i, (a, b)) in expanded.iter().zip(&cpu).enumerate() {
|
||||
assert_eq!(
|
||||
bytemuck::bytes_of(a),
|
||||
bytemuck::bytes_of(b),
|
||||
"{id} age {age} vertex {i}: {a:?} != {b:?}"
|
||||
);
|
||||
}
|
||||
// The culling bounds hold every vertex.
|
||||
let (lo, hi) = bounds.expect("a posed mob has bounds");
|
||||
for v in &cpu {
|
||||
let p = glam::Vec3::from_array(v.pos);
|
||||
assert!(
|
||||
p.cmpge(lo - 1e-4).all() && p.cmple(hi + 1e-4).all(),
|
||||
"{id}: {p} outside {lo}..{hi}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("posed on the GPU: {posed} mob draws; kept on the CPU: {kept}");
|
||||
assert!(posed > 3 * 40, "only {posed} draws took the GPU path");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -156,7 +156,7 @@ impl Session {
|
||||
let before = (self.streaming.generated_total, self.world.chunks.len());
|
||||
self.update();
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
let idle = self.streaming.pending() == 0
|
||||
let idle = !self.streaming.busy()
|
||||
&& before == (self.streaming.generated_total, self.world.chunks.len());
|
||||
quiet = if idle { quiet + 1 } else { 0 };
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ fn columns_loaded_past_the_render_distance_mesh_when_they_come_in_range() {
|
||||
// and nothing comes back (`pending` empties).
|
||||
streaming.free(uploads);
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
let idle = streaming.pending() == 0 && before == (world.chunks.len(), meshed.len());
|
||||
let idle = !streaming.busy() && before == (world.chunks.len(), meshed.len());
|
||||
quiet = if idle { quiet + 1 } else { 0 };
|
||||
}
|
||||
};
|
||||
@@ -181,7 +181,7 @@ fn relit_columns_keep_every_mesh_current() {
|
||||
while quiet < 40 {
|
||||
assert!(Instant::now() < deadline, "streaming never settled");
|
||||
let (uploads, _) = streaming.update(world);
|
||||
let idle = uploads.is_empty() && streaming.pending() == 0;
|
||||
let idle = uploads.is_empty() && !streaming.busy();
|
||||
meshes.extend(uploads);
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
quiet = if idle { quiet + 1 } else { 0 };
|
||||
|
||||
@@ -90,7 +90,7 @@ fn a_tall_column_streams_lights_and_meshes_to_its_top() {
|
||||
while quiet < 40 {
|
||||
assert!(Instant::now() < deadline, "streaming never settled");
|
||||
let (uploads, _) = streaming.update(world);
|
||||
let idle = uploads.is_empty() && streaming.pending() == 0;
|
||||
let idle = uploads.is_empty() && !streaming.busy();
|
||||
meshes.extend(uploads);
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
quiet = if idle { quiet + 1 } else { 0 };
|
||||
|
||||
@@ -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;
|
||||
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user