Merge remote-tracking branch 'upstream/main' into fluid

This commit is contained in:
MSedgeMC committed 2026-11-02 07:11:42 +00:00
commit 81569ba550
281 files changed
+23369 -2228

No files matched your search

Generated
+1
View File
@@ -1733,6 +1733,7 @@ dependencies = [
"flate2",
"horizon-core",
"libm",
"log",
"lz4_flex",
"postcard",
"serde",
+24
View File
@@ -91,3 +91,27 @@ licensed under the MIT license:
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## FusionExamples
The tile tables in `crates/horizon-optifine/src/fusion/layout.rs` were read off the connecting
texture templates (`templates/template <layout>.png`) of
[FusionExamples](https://github.com/SuperMartijn642/FusionExamples) (at `8c9c56e`), which is
licensed under the MIT license:
> Copyright (c) 2024 Martijn van den Brand
>
> Permission is hereby granted, free of charge, to any person obtaining a copy of this software
> and associated documentation files (the "Software"), to deal in the Software without
> restriction, including without limitation the rights to use, copy, modify, merge, publish,
> distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
> Software is furnished to do so, subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in all copies or
> substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
> BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+60 -2
View File
@@ -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, |_| {}));
+106 -2
View File
@@ -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"
);
}
}
+24 -18
View File
@@ -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") {
+12 -6
View File
@@ -564,14 +564,20 @@ impl Chunk {
}
#[inline]
pub fn light(&self, x: i32, y: i32, z: i32) -> u8 {
let s = (y - self.min_y) >> 4;
if s < 0 {
return 0;
match self.section_at(y) {
Ok(section) => section.light(index(x, y, z)),
Err(light) => light,
}
if s >= self.sections.len() as i32 {
return 0xF0;
}
/// The section holding block height `y`, or the light of the air outside the
/// column's sections there: none below them, full sky above.
#[inline]
pub fn section_at(&self, y: i32) -> Result<&Section, u8> {
match self.section_index(y) {
Some(s) => Ok(&self.sections[s]),
None if y < self.min_y => Err(0),
None => Err(0xF0),
}
self.sections[s as usize].light(index(x, y, z))
}
/// Heap bytes held by the sections (benchmark accounting).
pub fn heap_bytes(&self) -> usize {
+193 -9
View File
@@ -387,7 +387,8 @@ impl ConnectedTextures {
/// What the `face` of the block `state` at `pos` draws, for a quad with sprite `layer`.
/// `neighbour` answers the state at an offset from the block; `biome` its biome, asked only
/// by rules that test one.
/// by rules that test one; `texture` the quad's own texture directions, asked only by rules
/// with `orient=texture`.
pub fn pick<'b>(
&self,
state: u32,
@@ -396,6 +397,7 @@ impl ConnectedTextures {
pos: [i32; 3],
neighbour: impl Fn([i32; 3]) -> u32,
biome: impl Fn() -> Option<&'b str>,
texture: impl Fn() -> Option<Axes>,
mut overlay: impl FnMut(Overlay),
) -> Pick {
let (mut by_state, mut by_layer) = (
@@ -427,9 +429,58 @@ impl ConnectedTextures {
let r = &self.rules[usize::from(i)];
if (block || r.rule.match_blocks.is_empty())
&& (tile || r.rule.match_tiles.is_empty())
&& let Some(pick) =
self.apply(r, state, layer, face, pos, &neighbour, &biome, &mut overlay)
&& let Some(pick) = self.apply(
r,
state,
layer,
face,
pos,
&neighbour,
&biome,
&texture,
&mut overlay,
)
{
// "Tiles output by CTM rules can also be matched by another rule": once, by the
// tile rules naming the picked tile (and this block, if they name blocks).
// A tile on the face's own sprite (`tiles=stone`) is no new tile.
let Pick::Tile(TileLayer::Layer { layer: picked, .. }) = pick else {
return pick;
};
if picked == layer {
return pick;
}
let own = self.list(&self.by_state, state as usize);
// Rules tried on the face's sprite already (those before this one) are not tried
// again: an overlay would draw twice.
let first = self.list(&self.by_layer, layer as usize);
let again = self
.list(&self.by_layer, picked as usize)
.iter()
.map(|&j| (j, &self.rules[usize::from(j)]))
.filter(|&(j, r)| {
j != i
&& !(j < i && first.contains(&j))
&& (r.rule.match_blocks.is_empty() || own.contains(&j))
});
for (_, r) in again {
// Joins by tile still compare the face's own sprite with its neighbours'.
match self.apply(
r,
state,
layer,
face,
pos,
&neighbour,
&biome,
&texture,
&mut overlay,
) {
Some(Pick::Keep) => return pick,
Some(chained) => return chained,
None => {}
}
}
return pick;
}
}
@@ -447,6 +498,7 @@ impl ConnectedTextures {
pos: [i32; 3],
neighbour: &impl Fn([i32; 3]) -> u32,
biome: &impl Fn() -> Option<&'b str>,
texture: &impl Fn() -> Option<Axes>,
overlay: &mut impl FnMut(Overlay),
) -> Option<Pick> {
let rule = &r.rule;
@@ -487,7 +539,12 @@ impl ConnectedTextures {
};
let axes = |face| match (rule.orient, info.axis) {
(Orient::StateAxis, Some(axis)) => Axes::column(face, axis),
// `orient=texture` follows the quad's own UVs; vanilla cubes are what it is used on.
// The quad's own texture directions, whatever face `symmetry` stands it for.
// Not for `overlay`, which Continuity's `orient` leaves out: its 17 tiles always face
// the standard way.
(Orient::Texture, _) if rule.method != Method::Overlay => {
texture().unwrap_or(Axes::cube(face))
}
_ => Axes::cube(face),
};
let mask = || geometry::neighbours(axes(face), face.normal(), rule.inner_seams, joins);
@@ -649,6 +706,10 @@ mod tests {
|name| match name {
"minecraft:block/stone" => Some(7),
"minecraft:block/glass" => Some(3),
// The tile `b0` of a rule second in order (`a_picked_tile_can_be_picked_again`).
"assets/minecraft/optifine/ctm/b0.png" => Some(2000),
// And `a0` of the first rule.
"assets/minecraft/optifine/ctm/a0.png" => Some(1000),
_ => None,
},
)
@@ -671,7 +732,7 @@ mod tests {
blocks: &[([i32; 3], u32)],
) -> Pick {
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
ctm.pick(state, layer, face, [0, 64, 0], at, || None, |_| {})
ctm.pick(state, layer, face, [0, 64, 0], at, || None, || None, |_| {})
}
const GLASS: &str = "matchBlocks=glass\nmethod=ctm\ntiles=0-46";
@@ -794,8 +855,26 @@ mod tests {
)]);
let mut tiles = std::collections::HashSet::new();
for x in 0..20 {
let lower = ctm.pick(5, 3, Face::North, [x, 64, 0], |_| 0, || None, |_| {});
let upper = ctm.pick(6, 3, Face::North, [x, 65, 0], |_| 0, || None, |_| {});
let lower = ctm.pick(
5,
3,
Face::North,
[x, 64, 0],
|_| 0,
|| None,
|| None,
|_| {},
);
let upper = ctm.pick(
6,
3,
Face::North,
[x, 65, 0],
|_| 0,
|| None,
|| None,
|_| {},
);
assert_eq!(lower, upper, "{x}");
tiles.insert(format!("{lower:?}"));
}
@@ -809,7 +888,16 @@ mod tests {
"matchBlocks=glass\nmethod=fixed\ntiles=0\nheights=0-63\nbiomes=desert",
)]);
let at = |y, biome: &'static str| {
ctm.pick(1, 3, Face::Up, [0, y, 0], |_| 0, || Some(biome), |_| {})
ctm.pick(
1,
3,
Face::Up,
[0, y, 0],
|_| 0,
|| Some(biome),
|| None,
|_| {},
)
};
assert_eq!(at(10, "minecraft:desert"), tile(1000));
assert_eq!(at(64, "minecraft:desert"), Pick::Keep);
@@ -844,7 +932,7 @@ mod tests {
"glass.properties",
"matchBlocks=glass\nmethod=repeat\nwidth=2\nheight=2\ntiles=0-3",
)]);
let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None, |_| {});
let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None, || None, |_| {});
assert_eq!(at([0, 64, 0]), tile(1000));
// The north face's right is west; rows run down.
assert_eq!(at([-1, 64, 0]), tile(1001));
@@ -925,6 +1013,7 @@ mod tests {
[0, 64, 0],
at,
|| None,
|| None,
|o| out.push(o.layer),
);
(out, pick)
@@ -962,6 +1051,7 @@ mod tests {
[0, 64, 0],
|o| if o == [0, 0, -1] { 1 } else { 0 },
|| None,
|| None,
|o| {
tint = Some((o.tint, o.translucent));
},
@@ -999,6 +1089,7 @@ mod tests {
[0, 64, 0],
door_north,
|| None,
|| None,
|o| got.push(o),
);
assert_eq!(
@@ -1039,6 +1130,99 @@ mod tests {
assert_eq!((layers, pick), (vec![1001, 3000], Pick::Keep));
}
#[test]
fn a_picked_tile_can_be_picked_again() {
// b (rule 1) gives glass its tile b0, layer 2000; t (rule 0, a tile rule, tried first)
// names that tile, so it draws in b0's place.
let chain = |t: &str| {
world(&[
("b.properties", "matchBlocks=glass\nmethod=fixed\ntiles=b0"),
("t.properties", t),
])
};
let ctm = chain("matchTiles=optifine/ctm/b0.png\nmethod=fixed\ntiles=0");
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(1000));
// A chained rule keeping the tile (<default>) leaves b's.
let ctm = chain("matchTiles=optifine/ctm/b0.png\nmethod=fixed\ntiles=<default>");
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
// One that names another block doesn't chain on glass.
let ctm = chain("matchTiles=optifine/ctm/b0.png\nmatchBlocks=stone\nmethod=fixed\ntiles=0");
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
// An overlay tried on the face's own sprite and on the picked tile draws once.
let ctm = chain("matchTiles=glass optifine/ctm/b0.png\nmethod=overlay_fixed\ntiles=0");
let (overlays, pick) = overlays_in(&ctm, 1, Face::Up, &[]);
assert_eq!((overlays, pick), (vec![1000], tile(2000)));
// A rule after the one that picked, naming both the face's sprite and the tile, wasn't
// tried: it chains.
let ctm = world(&[
("a.properties", "matchTiles=glass\nmethod=fixed\ntiles=a0"),
(
"c.properties",
"matchTiles=glass optifine/ctm/a0.png\nmethod=fixed\ntiles=0",
),
]);
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
}
#[test]
fn orient_texture_follows_the_quads_uvs() {
let rule = |orient: &str| {
world(&[(
"glass.properties",
&*format!("matchBlocks=glass\nmethod=horizontal\ntiles=0-3\norient={orient}"),
)])
};
// A mirrored north face (a vanilla pane's): its texture's right is east.
let mirrored = || {
Some(Axes {
right: [1, 0, 0],
up: [0, 1, 0],
})
};
let east = |o: [i32; 3]| if o == [1, 0, 0] { 1 } else { 0 };
let at = |ctm: &ConnectedTextures| {
ctm.pick(
1,
3,
Face::North,
[0, 64, 0],
east,
|| None,
mirrored,
|_| {},
)
};
// Glass to the east is to the texture's right: the row's left end.
assert_eq!(at(&rule("texture")), tile(1000));
// By the cube's directions it is to the left: the row's right end.
assert_eq!(at(&rule("none")), tile(1002));
// A plain overlay keeps the standard directions: glass north of a top face is above it
// (tile 15) whatever the quad's texture says.
let overlay = world(&[(
"a.properties",
"matchBlocks=stone\nmethod=overlay\nconnectBlocks=glass\ntiles=0-16\norient=texture",
)]);
let turned = || {
Some(Axes {
right: [0, 0, 1],
up: [1, 0, 0],
})
};
let north = |o: [i32; 3]| if o == [0, 0, -1] { 1 } else { 0 };
let mut got = Vec::new();
overlay.pick(
2,
7,
Face::Up,
[0, 64, 0],
north,
|| None,
turned,
|o| got.push(o.layer),
);
assert_eq!(got, [1015]);
}
#[test]
fn stacked_panes_hide_the_strips_they_share() {
let props = |pairs: &[(&str, &str)]| -> BlockProperties {
+2 -1
View File
@@ -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 -5
View File
@@ -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
+10
View File
@@ -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
+160 -28
View File
@@ -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.
@@ -1522,37 +1529,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);
@@ -3442,10 +3476,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 +3709,7 @@ impl Renderer {
entity_buffer,
entity_capacity,
entity_batch: EntityBatch::default(),
entity_skin,
group,
globals,
pbr_maps,
@@ -4009,6 +4048,10 @@ impl Renderer {
pub fn section_count(&self) -> usize {
self.sections.len()
}
/// The running native pack's `[[particle_lights]]`.
pub fn particle_lights(&self) -> Option<&[horizon_shader_api::manifest::ParticleLight]> {
self.native.as_ref().map(|n| n.particle_lights.as_slice())
}
/// Whether one of Horizon's own packs with `[shadow]` draws, whose shadow pass takes the
/// frame's `shadow_only` quads.
pub fn native_shadow(&self) -> bool {
@@ -4836,6 +4879,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
}
@@ -4883,6 +4943,11 @@ impl Renderer {
let world = pack_post::World {
atlas: self.pack_atlas.as_ref().map(|a| (a.view.clone(), a.size())),
lightmap: self.lightmap_view.clone(),
gui_size: self
.hud
.atlas_texture
.as_ref()
.map_or([1, 1], |t| [t.width(), t.height()]),
terrain: self.pack_atlas.as_ref().map(|_| pack_post::Geometry {
buffers: if flat { &buffers[..1] } else { &buffers },
table: self.flat.as_ref().filter(|_| flat).map(|f| &f.draw_layout),
@@ -5679,6 +5744,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));
@@ -6354,12 +6430,19 @@ impl Renderer {
&self.queue,
frame.entities,
frame.entity_spans,
frame.crumbling_sprite,
&mut self.pack_entity_rest,
);
entities = &self.pack_entity_rest;
}
// 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())
@@ -6384,6 +6467,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
@@ -6439,7 +6529,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);
@@ -6456,24 +6546,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,
@@ -8494,8 +8594,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,
@@ -9284,6 +9385,37 @@ 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)]
);
}
}
/// 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);
}
}
@@ -39,6 +39,8 @@ struct HorizonMaterialOut {
@location(8) @interpolate(flat) face: u32,
@location(9) @interpolate(flat) fluid_quad: u32,
@location(10) @interpolate(flat) emission: f32,
// terrain.wgsl's `emission`, for HDR output's mark.
@location(11) @interpolate(flat) hdr_code: u32,
}
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
@@ -67,7 +69,9 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
out.fluid_quad = u32(fluid);
// The tier (0 none, 1 low, 2 incandescent, 3 bright, 4 very bright) as how strongly the
// face shines: a redstone torch 7/15, a torch 14/15, glowstone 1.
let tier = 15u - (flags & 15u);
out.hdr_code = 15u - (flags & 15u);
// Codes above 4 are the same tiers, shining all over rather than in their bright parts.
let tier = select(out.hdr_code, out.hdr_code - 4u, out.hdr_code > 4u);
out.emission = select(min(f32(tier) * 7.0 / 15.0, 1.0), 0.0, tier == 0u);
return out;
}
@@ -145,7 +149,8 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
in.face, fluid, in.emission, normal, tangent)).color;
in.face, fluid, in.emission, normal, tangent,
horizon_terrain_native_colored_light(in.position))).color;
}
var<private> horizon_material_face_normal: vec3<f32>;
struct HorizonMaterialSolid {
@@ -154,15 +159,33 @@ struct HorizonMaterialSolid {
// stage's `surface_at` compares with the final depth.
@location(1) surface: vec4<f32>,
}
// What `fs_solid` leaves in the frame's alpha for HDR output, from the sprite before the pack's
// material: the pack decides the colour, Horizon still knows which pixels are light sources.
fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4<f32>) -> f32 {
if in.hdr_code > 0u {
let whole = in.hdr_code > 4u;
let tier = select(in.hdr_code, in.hdr_code - 4u, whole);
let l = in.layer & 0x7fffu;
let t = textureLoad(horizon_terrain_emission_thresholds,
vec2(i32(l % horizon_terrain_SPRITE_SPLIT), i32(l / horizon_terrain_SPRITE_SPLIT)), 0);
let y = horizon_terrain_brightness(texel.rgb);
if whole || y >= t.r {
return horizon_terrain_hdr_mark(tier, y);
}
}
return 1.0;
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
let color = horizon_material_color(in, false);
let texel = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
if horizon_surface.w == 0.0 {
set_surface(horizon_material_face_normal, 0.0, 0.0);
}
var out: HorizonMaterialSolid;
out.color = horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
let mark = horizon_material_mark(in, texel);
out.color = horizon_material_fogged(vec4(color.rgb, mark), in.spherical, in.cylindrical);
out.surface = vec4(horizon_surface.xyz, in.clip.z);
return out;
}
@@ -21,6 +21,8 @@ pub struct NativePack {
pub weather: Option<wgpu::RenderPipeline>,
/// The draw of the shadow map the stages read, if the pack asks for one.
pub shadow: Option<NativeShadow>,
/// `[[particle_lights]]`.
pub particle_lights: Vec<horizon_shader_api::manifest::ParticleLight>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<PassTiming>,
}
@@ -133,6 +135,7 @@ impl NativePack {
labels.extend(post.labels().into_iter().map(str::to_owned));
}
let pack = Self {
particle_lights: report.manifest.particle_lights.clone(),
frame,
sky,
material,
@@ -319,6 +319,7 @@ impl PackEntities {
entities: &[Vertex],
spans: &[EntitySpan],
drawn: [bool; PROGRAMS],
crack: ([f32; 4], [u32; 2]),
shadow: Casters,
rest: &mut Vec<Vertex>,
) {
@@ -330,7 +331,9 @@ impl PackEntities {
rest.extend_from_slice(&entities[..first.min(entities.len())]);
for (span, range) in runs(entities.len(), spans) {
let run = &entities[range];
let program = program_of(&span).filter(|_| plain(run));
let program = program_of(&span).filter(|&p| {
plain(run) || (p == DESTROY && run.iter().all(|v| matches!(v.layer, 11 | 12)))
});
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
let casts = program.is_some_and(|p| shadow.cast(&span, p));
if !pack && !span.hidden {
@@ -341,6 +344,9 @@ impl PackEntities {
};
let start = self.scratch.len() as u32;
self.scratch.extend_from_slice(run);
if program == DESTROY {
project_cracks(&mut self.scratch[start as usize..], crack.0, crack.1);
}
let (entity_id, block_id) = match program {
BLOCK => (0, span.id),
_ => (span.id, 0),
@@ -419,7 +425,8 @@ pub const ENTITY: usize = 0;
pub const BLOCK: usize = 1;
pub const TRANSLUCENT: usize = 2;
pub const PARTICLE: usize = 3;
pub const PROGRAMS: usize = 4;
pub const DESTROY: usize = 4;
pub const PROGRAMS: usize = 5;
/// Which program draws `span`'s run, given which of them (or their fallbacks) the pack has;
/// `None`: Horizon draws it.
@@ -440,6 +447,8 @@ pub fn program_of(span: &EntitySpan) -> Option<usize> {
BLOCK
} else if span.stage == render_stage("PARTICLES") {
PARTICLE
} else if span.stage == render_stage("DESTROY") {
DESTROY
} else {
return None;
})
@@ -461,6 +470,16 @@ pub fn runs(
mod tests {
use super::*;
#[test]
fn destroy_stage_uses_the_pack_only_when_a_program_is_available() {
let destroy = EntitySpan {
stage: render_stage("DESTROY"),
..Default::default()
};
assert!(drawn_by_pack(&destroy, [true; PROGRAMS]).is_some());
assert_eq!(drawn_by_pack(&destroy, [false; PROGRAMS]), None);
}
#[test]
fn runs_split_the_list_between_pack_and_horizon() {
let v = |layer| Vertex {
@@ -483,15 +502,21 @@ mod tests {
let all = [true; PROGRAMS];
assert_eq!(drawn_by_pack(&spans[0], all), Some(ENTITY));
assert_eq!(drawn_by_pack(&spans[1], all), Some(PARTICLE));
assert_eq!(drawn_by_pack(&spans[1], [true, true, true, false]), None);
assert_eq!(drawn_by_pack(&spans[2], [true, false, true, true]), None);
assert_eq!(
drawn_by_pack(&spans[1], [true, true, true, false, true]),
None
);
assert_eq!(
drawn_by_pack(&spans[2], [true, false, true, true, true]),
None
);
assert_eq!(drawn_by_pack(&spans[2], all), Some(BLOCK));
let shadow = EntitySpan {
translucent: true,
..spans[0]
};
assert_eq!(drawn_by_pack(&shadow, all), Some(TRANSLUCENT));
assert_eq!(drawn_by_pack(&span(0, "DESTROY", 0), all), None);
assert_eq!(drawn_by_pack(&span(0, "DESTROY", 0), all), Some(DESTROY));
// A beacon beam's quads stay Horizon's.
assert!(plain(&[v(0), v(0), v(0), v(0), v(1)]));
assert!(!plain(&[v(0), v(0), v(0), v(0), v(6)]));
@@ -523,3 +548,53 @@ mod tests {
assert!(!some.cast(&spans[2], BLOCK) && some.cast(&lit, BLOCK));
}
}
/// Decode the destroy-stage origin stored in the light bytes and project one continuous
/// sprite over each model face. Pack geometry uses ordinary atlas UVs, not Horizon's overlay codes.
fn project_cracks(vertices: &mut [Vertex], sprite: [f32; 4], atlas: [u32; 2]) {
let dims = [atlas[0].max(1) as f32, atlas[1].max(1) as f32];
for quad in vertices.as_chunks_mut::<4>().0 {
let origin = [
(quad[0].light & 65535) as f32 / 65535.0,
(quad[0].light >> 16) as f32 / 65535.0,
];
let origin = [0, 1].map(|i| (origin[i] * dims[i]).round() / dims[i]);
let min = [0, 1]
.map(|i| (quad.iter().map(|v| v.uv[i]).fold(f32::INFINITY, f32::min) + 0.0001).floor());
for v in quad {
v.uv =
[0, 1].map(|i| origin[i] + (v.uv[i] - min[i]).clamp(0.001, 0.999) * sprite[i + 2]);
v.layer = 0;
v.light = 0x0000_f0f0;
}
}
}
#[cfg(test)]
mod crack_projection_tests {
use super::*;
#[test]
fn crack_uvs_keep_a_full_sprite_and_decode_each_stage() {
let mut vertices: Vec<Vertex> = [[-1.0, -1.0], [-1.0, 0.0], [0.0, 0.0], [0.0, -1.0]]
.into_iter()
.map(|uv| Vertex {
uv,
layer: 11,
light: crate::breaking::stage_light([0.25, 0.5]),
..bytemuck::Zeroable::zeroed()
})
.collect();
project_cracks(
&mut vertices,
[0.0, 0.0, 16.0 / 4096.0, 16.0 / 9216.0],
[4096, 9216],
);
assert!(
vertices
.iter()
.all(|v| v.layer == 0 && v.uv[0] >= 0.25 && v.uv[0] < 0.25 + 16.0 / 4096.0)
);
assert!((vertices[2].uv[0] - vertices[0].uv[0]) > 15.0 / 4096.0);
assert!((vertices[1].uv[1] - vertices[0].uv[1]) > 15.0 / 9216.0);
}
}
@@ -408,6 +408,7 @@ pub(super) struct World<'a> {
pub block_ids: &'a [i32],
/// The atlas of the sun and moon's sprites.
pub celestial: Option<wgpu::TextureView>,
pub gui_size: [u32; 2],
/// Set 1 of the clouds' draw: their offset and palette.
pub clouds: Option<&'a wgpu::BindGroupLayout>,
/// The game can hand its rain and snow to the pack.
@@ -510,6 +511,7 @@ pub struct PackPost {
/// with the page buffer it was made for.
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer)>,
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
gui_size: [u32; 2],
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<super::pack_timing::PassTiming>,
/// `depthtex0` at the screen's centre, read back for `centerDepthSmooth`.
@@ -1341,11 +1343,16 @@ fn build_pass(
} else if let Some(&buffer) = p.buffers.get(l) {
let format = formats[buffer as usize];
let mut state = target(format);
if let (Some(t), Some(b)) = (&mut state, p.blend)
&& format
.guaranteed_format_features(features)
.flags
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
if let (Some(t), Some(b)) = (
&mut state,
p.buffer_blends
.iter()
.find(|(n, _)| *n == buffer)
.map_or(p.blend, |(_, b)| *b),
) && format
.guaranteed_format_features(features)
.flags
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
{
t.blend = Some(blend_state(b));
}
@@ -1433,7 +1440,15 @@ fn build_pass(
depth_write_enabled: Some(g.depth_write),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: Default::default(),
bias: if p.name == "gbuffers_damagedblock" {
wgpu::DepthBiasState {
constant: -10,
slope_scale: -1.0,
clamp: 0.0,
}
} else {
Default::default()
},
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
@@ -1893,8 +1908,15 @@ impl PackPost {
post.block,
post.entities_translucent,
post.particles,
post.damaged_block,
];
let late = [
false,
false,
post.translucent_late,
post.particles_late,
false,
];
let late = [false, false, post.translucent_late, post.particles_late];
let shadow_only = (post.shadow_only_player, post.shadow_only_lit_blocks);
let shadow_programs = [post.shadow_entities, post.shadow_block];
let entity_vertices = (programs.iter().chain(&shadow_programs))
@@ -1910,8 +1932,21 @@ impl PackPost {
cull: false,
});
let entity_passes = programs.map(|p| {
p.zip(entity_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)))
p.zip(entity_geometry.as_ref()).and_then(|(p, g)| {
if p.name == "gbuffers_damagedblock" {
let decal = Geometry {
buffers: g.buffers,
table: g.table,
depth_write: false,
atlas: g.atlas.clone(),
ids: g.ids,
cull: false,
};
build(p, Some(&decal))
} else {
build(p, Some(g))
}
})
});
let shadow_entities = shadow_programs.map(|p| {
p.zip(entity_geometry.as_ref())
@@ -2243,6 +2278,7 @@ impl PackPost {
draws,
block_ids,
page_groups: HashMap::new(),
gui_size: world.gui_size,
timing: None,
center_depth: None,
custom,
@@ -2868,6 +2904,7 @@ impl PackPost {
queue: &wgpu::Queue,
entities: &[super::Vertex],
spans: &[super::pack_draw::EntitySpan],
crumbling: [f32; 4],
rest: &mut Vec<super::Vertex>,
) {
let shadow = self.shadow_casters();
@@ -2884,6 +2921,7 @@ impl PackPost {
entities,
spans,
drawn,
(crumbling, self.gui_size),
shadow,
rest,
);
@@ -404,9 +404,9 @@ impl FrameUniforms {
frame.depth_far,
);
let camera = frame.camera.as_vec3();
let first_frame = self.previous.is_none();
let (previous_exact, previous_view, previous_projection) =
self.previous
.unwrap_or((glam::DVec3::ZERO, Mat4::ZERO, Mat4::ZERO));
self.previous.unwrap_or((frame.camera, view, projection));
let previous_camera = previous_exact.as_vec3();
self.previous = Some((frame.camera, view, projection));
// The camera's whole blocks and the rest, exact however far from the origin.
@@ -436,6 +436,10 @@ impl FrameUniforms {
};
self.wetness = smooth(self.wetness, inputs.rain, half_life / 20.0, dt);
let eye = inputs.eye_light.map(|l| (l * 16) as f32);
if first_frame {
self.wetness = inputs.rain;
self.eye_smooth = eye;
}
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
*smoothed = smooth(*smoothed, target, 10.0 / 20.0, dt);
}
@@ -849,3 +853,148 @@ mod tests {
);
}
}
#[cfg(test)]
mod initial_history_tests {
use super::*;
use crate::hud::HudBatch;
fn float(u: &FrameUniforms, name: &str) -> [f32; 16] {
match u.values[index(name).unwrap()] {
Value::F(v) => v,
_ => panic!("float uniform expected"),
}
}
#[test]
fn first_history_is_current_then_tracks_the_actual_previous_frame() {
let hud = HudBatch::new([800, 450]);
let mut f = frame(&hud);
let mut u = FrameUniforms::default();
f.camera = DVec3::new(1024.25, -3.5, 11.75);
f.yaw = 21.0;
f.pitch = -16.0;
u.update(&f, &PackInputs::default(), [800, 450]);
assert_eq!(
float(&u, "previousCameraPosition"),
float(&u, "cameraPosition")
);
for (prior, current) in [
("gbufferPreviousProjection", "gbufferProjection"),
("gbufferPreviousModelView", "gbufferModelView"),
] {
assert_eq!(float(&u, prior), float(&u, current));
assert!(float(&u, prior).iter().any(|v| *v != 0.0));
}
let camera = float(&u, "cameraPosition");
let view = float(&u, "gbufferModelView");
let projection = float(&u, "gbufferProjection");
f.camera = DVec3::new(-100.0, 20.0, 40.0);
f.yaw = 90.0;
f.pitch = 50.0;
f.fov_degrees = 100.0;
u.update(&f, &PackInputs::default(), [1000, 500]);
assert_eq!(float(&u, "previousCameraPosition"), camera);
assert_eq!(float(&u, "gbufferPreviousModelView"), view);
assert_eq!(float(&u, "gbufferPreviousProjection"), projection);
assert_ne!(
float(&u, "previousCameraPosition"),
float(&u, "cameraPosition")
);
}
#[test]
fn environmental_smoothing_starts_at_the_first_inputs_without_resetting_each_frame() {
let hud = HudBatch::new([800, 450]);
let f = frame(&hud);
let mut u = FrameUniforms::default();
let mut input = PackInputs {
rain: 0.75,
eye_light: [3, 14],
..Default::default()
};
u.update(&f, &input, [800, 450]);
assert_eq!(float(&u, "wetness")[0], 0.75);
match u.values[index("eyeBrightnessSmooth").unwrap()] {
Value::I(v) => assert_eq!(&v[..2], &[48, 224]),
_ => panic!("integer uniform expected"),
}
input.rain = 0.0;
input.eye_light = [0, 0];
u.update(&f, &input, [800, 450]);
assert!(float(&u, "wetness")[0] > 0.0);
assert!(u.eye_smooth.iter().all(|v| *v > 0.0));
}
fn frame(hud: &HudBatch) -> Frame<'_> {
Frame {
eye: glam::DVec3::ZERO,
eye_rotation: [0.0; 2],
first_person: true,
shadow_scene_revision: (0, 0),
shadow_cache: false,
water_plane: None,
weather: &[],
weather_time: crate::weather::Time::new(0, 0.0),
camera: glam::DVec3::new(0.5, 100.0, 0.5),
yaw: 0.0,
pitch: 10.0,
fov_degrees: 85.0,
bob: glam::Mat4::IDENTITY,
spin: glam::Mat4::IDENTITY,
sky: crate::environment::Sky::default(),
distance: 12.0,
detail_distance: 12.0,
depth_far: 1000.0,
underwater: false,
cave_culling: false,
title: true,
sky_blocked: false,
skybox: false,
end_sky: false,
end_sky_sprite: None,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
dark_disc: false,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
hud,
glint_alpha: 0.75,
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
entity_motion: &[],
colored_lights: &[],
emissive_layers: &[],
shadow_only: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
clouds: None,
lightning: Default::default(),
additive: &[],
see_through: &[],
celestial: &[],
stars: None,
hand: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
border: None,
game_time: 0.0,
shader_game_time: 0.0,
end_portal_sprites: [[0.0; 4]; 2],
crumbling_sprite: [0.0; 4],
ticks: 0,
darkness: (0.0, 0.0),
night_vision: 0.0,
block_flicker: 0.0,
boss_darken: 0.0,
menu_blur: None,
chunk_fade: 0.0,
post_effects: crate::post_chain::Request::none(),
}
}
}
@@ -10,6 +10,8 @@
use glam::{DMat4, DVec3, Mat4, Vec2};
/// The least a world is scaled to when memory runs out.
const MIN_SCALE: f32 = 0.25;
/// The TAA history format: 8 bits would band as frames accumulate.
const HISTORY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
@@ -322,12 +324,65 @@ impl Post {
self.target = None;
return None;
}
let (size, output) = self.sizes(window);
let history_size = self.taa_on.then_some(output);
if self
.target
// The old target goes first: a slider drag would otherwise hold the old and the new
// targets together, and the sum is what runs a small GPU out of memory.
loop {
let (size, output) = self.sizes(window);
let history_size = self.taa_on.then_some(output);
if self
.target
.as_ref()
.is_some_and(|t| t.size == size && t.taa.as_ref().map(|h| h.size) == history_size)
{
break;
}
self.target = None;
// Errors raised are fatal, so catch an out-of-memory one and scale down. A texture
// that failed is invalid, so its view fails validation too: catch that as well.
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let target = self.build_target(device, size, history_size);
let error = pollster::block_on(memory.pop()).or(pollster::block_on(validation.pop()));
if error.is_none() {
// New textures: no history yet.
self.taa_this_frame.jitter[2] = 1.0;
self.target = Some(target);
break;
}
drop(target);
if self.scale <= MIN_SCALE {
log::warn!(
"Horizon renderer: no video memory for the scaled world at {size:?} ({}); drawing at the window's size",
error.map_or_else(String::new, |e| e.to_string())
);
self.scale = 1.0;
self.target = None;
return None;
}
let reduced = (self.scale * 0.75).max(MIN_SCALE);
log::warn!(
"Horizon renderer: out of video memory for the scaled world at {size:?}; resolution scale {:.2} -> {reduced:.2} ({})",
self.scale,
error.map_or_else(String::new, |e| e.to_string())
);
self.scale = reduced;
if self.scale == 1.0 && !self.antialias && !self.taa_on && !always {
return None;
}
}
self.target
.as_ref()
.is_none_or(|t| t.size != size || t.taa.as_ref().map(|h| h.size) != history_size)
.map(|t| (t.color.clone(), t.depth.clone()))
}
/// The world's colour and depth targets at `size`, and TAA's history when `history_size`
/// is set.
fn build_target(
&self,
device: &wgpu::Device,
size: [u32; 2],
history_size: Option<[u32; 2]>,
) -> WorldTarget {
{
let make_tex = |label, size: [u32; 2], format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
@@ -403,19 +458,14 @@ impl Post {
show,
})
});
// New textures: no history yet.
self.taa_this_frame.jitter[2] = 1.0;
self.target = Some(WorldTarget {
WorldTarget {
size,
color,
depth,
group,
taa,
});
}
}
self.target
.as_ref()
.map(|t| (t.color.clone(), t.depth.clone()))
}
/// The size of the world's own target, when it has one.
@@ -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 -3
View File
@@ -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);
+4
View File
@@ -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 use horizon_world::json_folder;
pub mod level_loading;
pub mod light;
pub mod lod;
@@ -89,7 +91,9 @@ pub mod sound_events;
pub mod sound_preview;
pub mod spans;
pub mod split_layout;
pub mod split_map;
pub mod split_profiles;
pub mod split_ui;
mod sprite_cache;
pub mod sprites;
pub mod streaming;
+157 -47
View File
@@ -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();
+365 -124
View File
@@ -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,
};
@@ -20,7 +20,7 @@ use horizon_assets::model::Direction as ModelDirection;
use horizon_core::{
biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed,
};
use horizon_optifine::ctm::connect::Face as CtmFace;
use horizon_optifine::ctm::connect::{Axes as CtmAxes, Face as CtmFace};
use horizon_world::{
block_render::FaceVisibility,
fluid::{FluidKind, FluidState},
@@ -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();
solid_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 cells that are not `isSolidRender`, by row (`y << 4 | z`, bit
/// x), from the padded rows of `MeshScratch::solid_rows`.
fn open_rows(solid: &[u32]) -> [u16; 256] {
std::array::from_fn(|r| {
let (y, z) = ((r >> 4) as i32, (r & 15) as i32);
!(solid[((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,103 @@ 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;
// 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,
Some(c) => match c.section_at(wy) {
Ok(section) => Source::Section(section),
Err(light) => Source::Outside(light),
},
})
});
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;
}
}
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 &section.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 +1009,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` (`MeshTables::solid`).
solid_rows: Vec<u32>,
}
/// Fills `rows` with the padded section's `isSolidRender` blocks, one bit each (`MeshScratch::solid_rows`).
fn solid_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 +1130,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 +1151,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 +1170,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 +1190,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 +1209,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 +1270,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 +1320,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 +1392,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);
solid_rows(pad, &self.tables.solid, &mut scratch.solid_rows);
let opaque = &scratch.solid_rows;
out.visibility = visibility_rows(&open_rows(opaque));
let base_y = center.min_y() + sy * 16;
if self.leaf_culling.is_interior() {
scratch
@@ -1177,6 +1409,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 +1428,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 +1467,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;
@@ -1379,6 +1612,14 @@ impl Mesher<'_> {
[world.x, world.y, world.z],
|[dx, dy, dz]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)],
|| Some(tints.biome(pos[0], pos[1], pos[2])),
// A pane's tiles are drawn with the cube's UVs (below), so that is its texture.
|| {
if state.pane {
Some(CtmAxes::cube(CtmFace::ALL[q.facing as usize]))
} else {
CtmAxes::texture(&q.positions, &q.uv)
}
},
|overlay| {
if let Some(slot) = overlays.get_mut(count) {
*slot = Some(overlay);
@@ -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);
@@ -45,6 +45,65 @@ fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts(
}
}
#[test]
fn padded_fill_reads_every_neighbour_and_its_light_like_chunk_reads() {
let mut state = 0x2545_F491_4F6C_DD1Du64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
let mut pad = Padded::default();
let mut own = Box::new([0; 4096]);
for min_y in [MIN_Y, 0] {
let layout = crate::chunk::ColumnLayout::new(min_y, 32 * 16).unwrap();
// Nine chunks with their own blocks and per-block light, near the
// bottom and top sections too, where the padding leaves the column.
let chunks: Vec<Chunk> = (0..9)
.map(|i| {
let mut c = Chunk::with_layout(i % 3 - 1, i / 3 - 1, layout);
for s in [0, 1, 30, 31] {
for _ in 0..300 {
let (x, y, z) = (next() % 16, next() % 16, next() % 16);
let y = min_y + s * 16 + y as i32;
c.set(x as i32, y, z as i32, 1 + (next() % 40) as u32);
}
let mut light = Box::new([0u8; 4096]);
light.iter_mut().for_each(|l| *l = next() as u8);
std::sync::Arc::make_mut(&mut c.sections[s as usize]).light =
Light::Full(light);
}
c
})
.collect();
let region: [[Option<&Chunk>; 3]; 3] =
std::array::from_fn(|rz| std::array::from_fn(|rx| Some(&chunks[rz * 3 + rx])));
for sy in [0, 1, 30, 31] {
pad.fill(&region, sy, &mut own);
let base_y = min_y + sy * 16;
for y in -B..16 + B {
for z in -B..16 + B {
for x in -B..16 + B {
let c = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize].unwrap();
let (cx, cy, cz) = (x & 15, base_y + y, z & 15);
assert_eq!(
pad.blocks[pi(x, y, z)],
c.get(cx, cy, cz),
"{min_y} {sy} {x} {y} {z}"
);
assert_eq!(
pad.light[pi(x, y, z)],
c.light(cx, cy, cz),
"{min_y} {sy} {x} {y} {z}"
);
}
}
}
}
}
}
/// Run explicitly with prepared assets; never silently pass without exercising geometry.
/// With a shader pack lighting the faces, every face of a lone stone block has one colour; the
/// game's face shading (up 1.0, down 0.5, north/south 0.8, east/west 0.6) is left to the pack.
@@ -181,7 +240,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.solid_rows.capacity(), 0);
}
fresh.sort_solid();
recycled.sort_solid();
+124 -32
View File
@@ -203,13 +203,37 @@ fn bucket(quad: &[TerrainVertex; 4]) -> usize {
/// Reorders `solid`'s quads by bucket, opaque ones first in each. Solid
/// cutout quads are also ordered front to back within each axis-facing
/// bucket; stable sorting preserves the mesher's order for coplanar overlays.
/// bucket; quads that tie keep the mesher's order (the quad's index ends its
/// sort key), so coplanar overlays still draw over their base.
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
sort_parts(solid, GROUPED.load(Ordering::Relaxed))
}
/// `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 so sorting a mesh allocates
/// nothing: each quad's part, its sort key, and the sorted quads, which are
/// copied back into 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 +242,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 +269,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 +310,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)];
@@ -28,10 +28,18 @@ pub fn load(root: &Path, sprites: &mut Sprites) {
}
}
/// A connected-texture tile's sprite name without `.png`: tiles outside `textures/` are named by
/// their resource path (`Sprites::insert_file`, which only they use), block sprites by an id.
fn tile_file(name: &str) -> Option<&str> {
name.strip_prefix("assets/")?;
name.strip_suffix(".png")
}
/// OptiFine's emissive overlays of the block sprites already loaded (what the terrain draws;
/// items draw from the HUD atlas): for each
/// `<ns>:block/<name>` whose `<name><suffix>.png` sits beside it, the overlay is loaded too and
/// paired with it, first by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of
/// `<ns>:block/<name>`, or connected-texture tile `assets/<ns>/.../<name>.png`, whose
/// `<name><suffix>.png` sits beside it, the overlay is loaded too and paired with it, blocks
/// first, then tiles, each by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of
/// the table). A pack without such files adds nothing.
pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)> {
let suffix = horizon_optifine::emissive::suffix(root);
@@ -41,21 +49,34 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
for layer in sprites.layers.values() {
*sharing.entry(*layer).or_default() += 1;
}
let bases: Vec<String> = sprites
let mut bases: Vec<String> = sprites
.layers
.iter()
.filter(|(name, layer)| {
sharing[layer] == 1
&& name
&& (name
.split_once(':')
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(&suffix))
|| tile_file(name).is_some_and(|stem| !stem.ends_with(&suffix)))
})
.map(|(name, _)| name.clone())
.collect();
// Blocks before tiles: a pack's many tiles must not crowd vanilla blocks' overlays out.
bases.sort_by_key(|name| (tile_file(name).is_some(), name.clone()));
let mut pairs = Vec::new();
for base in bases {
let overlay = format!("{base}{suffix}");
let file = crate::atlas_sources::texture_path(root, &overlay, "textures");
// A tile is named by its file, a block sprite by its id.
let (overlay, file) = match tile_file(&base) {
Some(stem) => {
let overlay = format!("{stem}{suffix}.png");
(overlay.clone(), root.join(overlay))
}
None => {
let overlay = format!("{base}{suffix}");
let file = crate::atlas_sources::texture_path(root, &overlay, "textures");
(overlay, file)
}
};
if !std::fs::metadata(&file).is_ok_and(|m| m.is_file()) {
continue;
}
@@ -71,7 +92,12 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
continue;
}
// A file that doesn't load would glow as the missing texture: no pair instead.
match sprites.try_insert(root, &overlay) {
let loaded = if overlay.starts_with("assets/") {
sprites.insert_file(root, &overlay)
} else {
sprites.try_insert(root, &overlay)
};
match loaded {
Ok(_) => pairs.push((base, overlay)),
Err(e) => log::warn!("Horizon: emissive overlay {overlay} skipped: {e}"),
}
@@ -192,13 +218,40 @@ mod tests {
let props = root.join("assets/minecraft/optifine/emissive.properties");
std::fs::create_dir_all(props.parent().unwrap()).unwrap();
std::fs::write(&props, "suffix.emissive=_glow").unwrap();
// Connected-texture tiles pair with the overlay beside them.
let tiles = root.join("assets/minecraft/optifine/ctm/lamp");
png(&tiles.join("0.png"), 230);
png(&tiles.join("0_glow.png"), 240);
png(&tiles.join("1.png"), 250);
// A tile outside `ctm/` (`tiles=~/glass/2`) is a tile all the same.
png(&root.join("assets/minecraft/optifine/glass/2.png"), 245);
png(
&root.join("assets/minecraft/optifine/glass/2_glow.png"),
246,
);
for tile in ["ctm/lamp/0", "ctm/lamp/1", "glass/2"] {
sprites
.insert_file(&root, &format!("assets/minecraft/optifine/{tile}.png"))
.unwrap();
}
let pairs = load_optifine(&root, &mut sprites);
assert_eq!(
pairs,
[(
"minecraft:block/deepslate".into(),
"minecraft:block/deepslate_glow".into()
)]
// Blocks first, then tiles.
[
(
"minecraft:block/deepslate".into(),
"minecraft:block/deepslate_glow".into()
),
(
"assets/minecraft/optifine/ctm/lamp/0.png".into(),
"assets/minecraft/optifine/ctm/lamp/0_glow.png".into()
),
(
"assets/minecraft/optifine/glass/2.png".into(),
"assets/minecraft/optifine/glass/2_glow.png".into()
),
]
);
std::fs::remove_dir_all(&root).unwrap();
}
+87 -12
View File
@@ -1264,7 +1264,7 @@ pub enum Page {
Online,
/// Splitscreen: the profile another player joins as (`split_profiles`).
SplitProfiles,
/// Splitscreen: enable joining on this computer.
/// Splitscreen: the monitor map and draft window arrangement.
SplitSetup,
}
#[derive(Clone, Debug, PartialEq)]
@@ -1512,6 +1512,7 @@ pub struct OptionsUi {
pub calibration: Option<crate::hdr_calibration::Calibration>,
/// Whether the window was fullscreen before calibration made it so (the game keeps it).
pub calibration_fullscreen: Option<bool>,
pub split_setup: crate::split_ui::SplitUi,
/// The AI Agent page's status lines (set by the game while it shows).
pub agent_status: Vec<String>,
/// Keyboard focus: the interactive target currently focused.
@@ -1528,8 +1529,6 @@ pub struct OptionsUi {
/// The list's rows as last laid out: each row's top and bottom (GUI
/// pixels), for the narration's row under the mouse.
row_bands: Vec<[f32; 2]>,
/// Other local players are joined: leave before turning splitscreen off.
pub split_joined: bool,
}
impl Default for OptionsUi {
fn default() -> Self {
@@ -1568,6 +1567,7 @@ impl Default for OptionsUi {
split_profiles: Default::default(),
calibration: None,
calibration_fullscreen: None,
split_setup: Default::default(),
agent_status: Vec::new(),
focus: None,
can_change_value: false,
@@ -1575,7 +1575,6 @@ impl Default for OptionsUi {
framebuffer: [1280, 720],
split_guest: false,
row_bands: Vec::new(),
split_joined: false,
}
}
}
@@ -2122,6 +2121,9 @@ impl OptionsUi {
) {
self.graphics.reset();
}
if page == Page::SplitSetup {
self.split_setup.reset();
}
if page == Page::SplitProfiles {
self.split_profiles.reset();
}
@@ -2262,14 +2264,7 @@ impl OptionsUi {
fn list_rows(&self, s: &Settings) -> Vec<Row> {
match self.page() {
Page::SplitSetup => vec![Row::Buttons(
if self.split_guest || self.split_joined {
inactive(self.extra_button(s, crate::extras::Item::Splitscreen))
} else {
self.extra_button(s, crate::extras::Item::Splitscreen)
},
None,
)],
Page::SplitSetup => Vec::new(),
Page::Extras => {
// Each section's switches two to a row, as vanilla's
// OptionsList.addSmall lays out an options screen.
@@ -3346,6 +3341,11 @@ impl OptionsUi {
.draw(level, s, batch, atlas, cursor, self.in_world);
return;
}
if self.page() == Page::SplitSetup {
self.split_setup.on = s.extras.splitscreen;
self.split_setup.draw(batch, atlas, cursor, self.in_world);
return;
}
if self.page() == Page::SplitProfiles {
self.split_profiles
.draw(batch, atlas, cursor, self.in_world, self.pad_style);
@@ -3646,6 +3646,26 @@ impl OptionsUi {
}
return Vec::new();
}
if self.page() == Page::SplitSetup {
use crate::split_ui::Click;
if right {
return Vec::new();
}
self.split_setup.on = s.extras.splitscreen;
return match self.split_setup.click((x, y), size, drag) {
Click::Nothing => Vec::new(),
Click::Pressed => vec![Event::ButtonClick],
Click::Switch => {
crate::extras::Item::Splitscreen.cycle(&mut s.extras, false);
vec![Event::ButtonClick, Event::Changed]
}
Click::Done => {
let mut events = vec![Event::ButtonClick];
events.extend(self.back(s));
events
}
};
}
if self.page() == Page::SplitProfiles {
use crate::split_profiles::Click;
if right || drag {
@@ -5345,6 +5365,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();
@@ -4093,6 +4093,47 @@ impl ParticleEngine {
}
}
/// The particles that `entries` name, as lights: where each is this frame, its entry's colour
/// as RGB and reach, and its alpha as the intensity.
pub fn lights(
&self,
entries: &[horizon_shader_api::manifest::ParticleLight],
partial: f32,
) -> Vec<([f64; 3], u32, f32, f32)> {
let shining: Vec<(ParticleType, u32, f32)> = entries
.iter()
.flat_map(|e| {
let [r, g, b] = e.color.map(|c| (c.clamp(0.0, 1.0) * 255.0) as u32);
e.particles.iter().filter_map(move |name| {
let name = if name.contains(':') {
name.clone()
} else {
format!("minecraft:{name}")
};
Some((
ParticleType::from_name(&name)?,
r << 16 | g << 8 | b,
e.reach,
))
})
})
.collect();
if shining.is_empty() {
return Vec::new();
}
let f = f64::from(partial);
self.particles
.iter()
.filter(|p| p.rgba[3] > 0.0)
.filter_map(|p| {
let (_, rgb, reach) = shining.iter().find(|(ty, ..)| *ty == p.ty)?;
let at =
std::array::from_fn(|i| p.previous[i] + (p.position[i] - p.previous[i]) * f);
Some((at, *rgb, *reach, p.rgba[3].clamp(0.0, 1.0)))
})
.collect()
}
/// Camera-facing quads (`QuadParticleRenderState.renderRotatedQuad`).
pub fn render(
&self,
@@ -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()
+487 -25
View File
@@ -1,5 +1,6 @@
//! Splitscreen's layout, pure data tested without a window: where each player's view goes in
//! its window (`place`).
//! its window (`place`), and the setup screen's draft (`Draft`): the windows, where each is, who
//! plays in each, before any of it is applied.
use winit::dpi::PhysicalSize;
/// Players at most: P1 and fifteen more.
@@ -25,30 +26,6 @@ const ROWS: [&[u32]; MAX_PLAYERS - 4] = [
&[4, 4, 4, 4],
];
/// Where a window is.
#[derive(Clone, Debug, PartialEq)]
pub enum Placement {
/// A normal window: its corner on the desktop and its size in pixels (`None`: a new one,
/// where the system opens it).
Free(Option<([i32; 2], [u32; 2])>),
/// Borderless over the monitor of this name.
Fill(String),
}
/// How far window `number` opens right and down of the centre, in logical pixels: Window 2
/// centred, each next one a step further, as systems cascade new windows (back to the centre
/// after six), so new windows never open exactly on one another.
pub fn cascade(number: u8) -> f64 {
f64::from(number.saturating_sub(2) % 6) * 32.0
}
/// A window: its number (1 is the game's own) and where it is.
#[derive(Clone, Debug, PartialEq)]
pub struct Window {
pub number: u8,
pub placement: Placement,
}
/// `order` made a whole order of `n` players: those it names (once, if there are), then the
/// others, P1 first.
pub fn ordered(order: &[usize], n: usize) -> Vec<usize> {
@@ -152,10 +129,310 @@ fn rows(n: usize, slot: u32, w: u32, h: u32) -> [u32; 4] {
}
}
/// A screen as the system reports it: its name, where it is on the desktop and its size (both
/// physical pixels), and its scale.
#[derive(Clone, Debug, PartialEq)]
pub struct Monitor {
pub name: String,
pub at: [i32; 2],
pub size: [u32; 2],
pub scale: f64,
}
impl Monitor {
/// Where it is on the desktop and its size, as windows are placed: macOS places them in
/// points, each monitor at its own scale; Windows and X11 in desktop pixels.
pub fn desktop(&self) -> [f64; 4] {
let k = if cfg!(target_os = "macos") {
self.scale
} else {
1.0
};
[
f64::from(self.at[0]) / k,
f64::from(self.at[1]) / k,
f64::from(self.size[0]) / k,
f64::from(self.size[1]) / k,
]
}
}
/// Where a window is.
#[derive(Clone, Debug, PartialEq)]
pub enum Placement {
/// A normal window: its corner on the desktop (`Monitor::desktop`'s units) and its size in
/// pixels (`None`: a new one, where the system opens it, `NEW_WINDOW` big).
Free(Option<([i32; 2], [u32; 2])>),
/// Borderless over the monitor of this name.
Fill(String),
}
/// A new free window's size, in logical pixels.
pub const NEW_WINDOW: [f64; 2] = [854.0, 480.0];
/// How far window `number` opens right and down of the centre, in logical pixels: Window 2
/// centred, each next one a step further, as systems cascade new windows (back to the centre
/// after six), so new windows never open exactly on one another.
pub fn cascade(number: u8) -> f64 {
f64::from(number.saturating_sub(2) % 6) * 32.0
}
/// A window: its number (1 is the game's own) and where it is.
#[derive(Clone, Debug, PartialEq)]
pub struct Window {
pub number: u8,
pub placement: Placement,
}
/// Where a player can go: a window there is (by number), a new free one, or a new one filling a
/// monitor (by name).
#[derive(Clone, Debug, PartialEq)]
pub enum Spot {
Window(u8),
New,
Fill(String),
}
/// Something to tell before Apply.
#[derive(Clone, Debug, PartialEq)]
pub enum Warning {
/// Nobody plays in this window: it won't open (Window 1 stays, blank).
Empty(u8),
/// This window fills a monitor that isn't there: its players play in Window 1.
Unplugged(u8, String),
/// This window fills the monitor Window 1 is on: its players play in Window 1.
OnWindow1(u8),
}
/// The setup screen's arrangement until Apply: the windows, each player's window (by number, P1
/// first) and the shapes two or three players sharing a window take (`place`'s `two`, `three`).
#[derive(Clone, Debug, PartialEq)]
pub struct Draft {
pub windows: Vec<Window>,
pub players: Vec<u8>,
pub two: u8,
pub three: u8,
/// The players (indices) in the order their views fill a window shared with others: first
/// left or on top. Empty, or missing some: P1, P2... (`ordered`).
pub order: Vec<usize>,
}
impl Draft {
/// Everyone in Window 1 (where it is now).
pub fn everyone_here(players: usize, window1: Placement, two: u8, three: u8) -> Self {
Self {
windows: vec![Window {
number: 1,
placement: window1,
}],
players: vec![1; players],
two,
three,
order: Vec::new(),
}
}
/// Players `i` and `j` trade places: their windows and their places in them.
pub fn swap(&mut self, i: usize, j: usize) {
let n = self.players.len();
if i >= n || j >= n || i == j {
return;
}
self.players.swap(i, j);
let mut order = ordered(&self.order, n);
let (a, b) = (
order.iter().position(|&p| p == i),
order.iter().position(|&p| p == j),
);
if let (Some(a), Some(b)) = (a, b) {
order.swap(a, b);
}
self.order = order;
}
/// Player `i` to place `k` among the others in its window (0: before them all; past the
/// last: after them), the others keeping their order: the bumpers' "Window 1 (2 of 3)".
pub fn put_at(&mut self, i: usize, k: usize) {
let n = self.players.len();
if i >= n {
return;
}
let mine = self.players[i];
let mut order: Vec<usize> = ordered(&self.order, n)
.into_iter()
.filter(|&p| p != i)
.collect();
let others: Vec<usize> = order
.iter()
.copied()
.filter(|&p| self.players[p] == mine)
.collect();
let at = match others.get(k) {
Some(j) => order.iter().position(|p| p == j).unwrap_or(order.len()),
None => others
.last()
.and_then(|j| order.iter().position(|p| p == j))
.map_or(order.len(), |p| p + 1),
};
order.insert(at, i);
self.order = order;
}
/// The players but `i` in window `number`, in their order.
pub fn sharing(&self, i: usize, number: u8) -> Vec<usize> {
ordered(&self.order, self.players.len())
.into_iter()
.filter(|&p| p != i && self.players[p] == number)
.collect()
}
/// A new free window, numbered with the lowest number free; its number.
pub fn add_window(&mut self) -> u8 {
self.open(Placement::Free(None))
}
fn open(&mut self, placement: Placement) -> u8 {
let number = (2..=u8::MAX)
.find(|n| self.windows.iter().all(|w| w.number != *n))
.unwrap_or(u8::MAX);
self.windows.push(Window { number, placement });
number
}
/// Player `i` to `spot`: a new window opens for it. A window it leaves empty stays in the
/// draft until `drop_empty` (at Apply), so numbers hold still while players move. A window
/// number the draft doesn't have is ignored.
pub fn place_player(&mut self, i: usize, spot: Spot) {
if i >= self.players.len() {
return;
}
let number = match spot {
Spot::Window(n) if self.windows.iter().any(|w| w.number == n) => n,
Spot::Window(_) => return,
Spot::New => self.add_window(),
Spot::Fill(name) => self.open(Placement::Fill(name)),
};
self.players[i] = number;
}
/// The windows nobody plays in, gone, but Window 1 and those in `keep`. At Apply, all of
/// them; while a draft is edited, the ones it added (the windows open now stay until Apply,
/// so their numbers hold still as players move).
pub fn drop_empty(&mut self, keep: &[u8]) {
let players = &self.players;
self.windows
.retain(|w| w.number == 1 || players.contains(&w.number) || keep.contains(&w.number));
}
/// Whether nobody plays in window `number`.
pub fn empty(&self, number: u8) -> bool {
!self.players.contains(&number)
}
/// What to tell before Apply, in window order.
pub fn warnings(&self, monitors: &[Monitor], main: &str) -> Vec<Warning> {
let mut out = Vec::new();
for w in &self.windows {
if let Placement::Fill(name) = &w.placement
&& !monitors.iter().any(|m| &m.name == name)
{
out.push(Warning::Unplugged(w.number, name.clone()));
} else if w.number != 1 && w.placement == Placement::Fill(main.to_owned()) {
out.push(Warning::OnWindow1(w.number));
} else if !self.players.contains(&w.number) {
out.push(Warning::Empty(w.number));
}
}
out
}
/// Where each window would be: its corner on the desktop (`Monitor::desktop`'s units) and its
/// size in pixels, in `windows` order: free ones where they were (new ones at `main`'s
/// corner), the others over their monitor; a window over a missing monitor or Window 1's
/// (its players play in Window 1, as the game does): `None`. Window 1 always has one: give
/// it its real rect (`Free(Some)`); else it is taken as a new window would open.
pub fn rects(&self, monitors: &[Monitor], main: &str) -> Vec<Option<([i32; 2], [u32; 2])>> {
let home = monitors
.iter()
.find(|m| m.name == main)
.or(monitors.first());
// Centred on Window 1's monitor, as the game opens a new window.
let new = |number: u8| {
let (at, scale, k) = home.map_or(([0.0; 4], 1.0, 1.0), |m| {
let at = m.desktop();
(at, m.scale, at[2] / f64::from(m.size[0].max(1)))
});
let size = NEW_WINDOW.map(|v| (v * scale).round() as u32);
let step = cascade(number) * scale * k;
let x = at[0] + (at[2] - f64::from(size[0]) * k) / 2.0 + step;
let y = at[1] + (at[3] - f64::from(size[1]) * k) / 2.0 + step;
([x as i32, y as i32], size)
};
self.windows
.iter()
.map(|w| {
let rect = match &w.placement {
Placement::Free(Some(rect)) => Some(*rect),
Placement::Free(None) => Some(new(w.number)),
Placement::Fill(name) if w.number != 1 && name == main => None,
Placement::Fill(name) => monitors.iter().find(|m| &m.name == name).map(|m| {
let at = m.desktop();
([at[0] as i32, at[1] as i32], m.size)
}),
};
// Everyone with nowhere else plays in Window 1: it never has no size.
if w.number == 1 {
rect.or_else(|| Some(new(1)))
} else {
rect
}
})
.collect()
}
/// Every player's view as Apply would make it (`place`, as the game tiles them), with
/// `View`'s window an index into `windows`; players of a window over a missing monitor in
/// Window 1's.
pub fn preview(&self, monitors: &[Monitor], main: &str) -> Vec<View> {
let rects = self.rects(monitors, main);
let index = |number: u8| self.windows.iter().position(|w| w.number == number);
let window1 = index(1).unwrap_or(0);
let windows: Vec<usize> = self
.players
.iter()
.map(|&n| index(n).filter(|&i| rects[i].is_some()).unwrap_or(window1))
.collect();
let sizes: Vec<PhysicalSize<u32>> = rects
.iter()
.map(|r| {
r.map_or(PhysicalSize::new(1, 1), |(_, [w, h])| {
PhysicalSize::new(w, h)
})
})
.collect();
place_ordered(&windows, &self.order, &sizes, self.two, self.three)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn window_1_always_has_a_size_and_unknown_windows_take_nobody() {
let monitors = [Monitor {
name: "Main".into(),
at: [0, 0],
size: [1920, 1080],
scale: 1.0,
}];
let mut d = Draft::everyone_here(2, Placement::Fill("Gone".into()), 0, 0);
assert!(d.rects(&monitors, "Main")[0].is_some());
assert!(d.preview(&monitors, "Main").iter().all(|v| v.2.width > 1));
d.place_player(1, Spot::Window(9));
assert_eq!(d.players, [1, 1], "no Window 9 in the draft");
}
fn size(w: u32, h: u32) -> PhysicalSize<u32> {
PhysicalSize::new(w, h)
}
@@ -272,6 +549,97 @@ mod tests {
)
);
}
let mut draft = here(16);
draft.place_player(15, Spot::New);
let views = draft.preview(&monitors(), "Built-in");
assert_eq!(views.len(), 16);
assert_ne!(views[0].0, views[15].0);
}
fn monitors() -> Vec<Monitor> {
vec![
Monitor {
name: "Built-in".into(),
at: [0, 0],
size: [3024, 1964],
scale: 2.0,
},
Monitor {
name: "TV".into(),
at: [3024, 0],
size: [3840, 2160],
scale: 1.0,
},
]
}
/// P1 in Window 1, everyone else in Window 2 filling the TV.
trait DeskAndTv {
fn desk_and_tv(self, monitors: &[Monitor], main: &str) -> Option<Draft>;
}
impl DeskAndTv for Draft {
fn desk_and_tv(mut self, monitors: &[Monitor], main: &str) -> Option<Draft> {
let tv = monitors.iter().find(|m| m.name != main)?;
self.windows.push(Window {
number: 2,
placement: Placement::Fill(tv.name.clone()),
});
for p in self.players.iter_mut().skip(1) {
*p = 2;
}
Some(self)
}
}
fn here(players: usize) -> Draft {
Draft::everyone_here(
players,
Placement::Free(Some(([100, 100], [2000, 1200]))),
0,
0,
)
}
#[test]
fn presets() {
let m = monitors();
// Everyone here: four quarters of Window 1.
let views = here(4).preview(&m, "Built-in");
assert_eq!(views[3], (0, [1000, 600], size(1000, 600)));
// P1 alone, the others share the TV in rows.
let tv = here(4).desk_and_tv(&m, "Built-in").unwrap();
assert_eq!(tv.players, [1, 2, 2, 2]);
assert_eq!(tv.windows[1].placement, Placement::Fill("TV".into()));
let views = tv.preview(&m, "Built-in");
assert_eq!(views[0], (0, [0, 0], size(2000, 1200)));
assert_eq!(views[1], (1, [0, 0], size(3840, 1080)));
}
#[test]
fn new_windows_cascade_instead_of_stacking() {
let m = monitors();
let mut d = here(3);
d.place_player(1, Spot::New);
d.place_player(2, Spot::New);
let rects = d.rects(&m, "Built-in");
let ((a, _), (b, _)) = (rects[1].unwrap(), rects[2].unwrap());
// Window 3 a step right and down of Window 2, on the same diagonal.
assert!(b[0] > a[0] && b[0] - a[0] == b[1] - a[1]);
assert_eq!((cascade(2), cascade(3), cascade(8)), (0.0, 32.0, 0.0));
}
#[test]
fn put_at_a_place_among_a_window_s_players() {
let mut d = here(3);
d.put_at(2, 0);
assert_eq!(d.order, [2, 0, 1]);
d.put_at(2, 5); // past the last: after them
assert_eq!(d.order, [0, 1, 2]);
// Players in another window keep their places.
d.players = vec![1, 2, 1];
d.put_at(2, 0);
assert_eq!(d.sharing(2, 1), [0]);
assert_eq!(d.order, [2, 0, 1]);
}
#[test]
@@ -293,4 +661,98 @@ mod tests {
let views = place_ordered(&[0, 0, 0], &[2], &wide, 0, 0);
assert_eq!(views[2].2, size(2000, 500));
}
#[test]
fn two_players_swap_in_a_window_and_between_windows() {
let m = monitors();
// In one window: P1 and P2 trade sides.
let mut d = here(2);
d.swap(0, 1);
assert_eq!(d.order, [1, 0]);
let views = d.preview(&m, "Built-in");
assert!(views[1].1[0] < views[0].1[0], "P2 left of P1");
// Between windows: P2 on the TV, P3 in Window 1; they trade windows.
let mut d = here(3);
d.place_player(1, Spot::Fill("TV".into()));
d.swap(1, 2);
assert_eq!(d.players, [1, 1, 2]);
}
#[test]
fn players_placed() {
let m = monitors();
let mut d = here(3);
d.place_player(1, Spot::Fill("TV".into()));
d.place_player(2, Spot::Window(2));
assert_eq!(d.players, [1, 2, 2]);
d.place_player(2, Spot::New);
assert_eq!(d.players, [1, 2, 3]);
assert!(d.warnings(&m, "Built-in").is_empty());
// The window left behind stays while the draft is edited (numbers hold still), and
// closes at Apply; Window 1 never.
d.place_player(1, Spot::Window(1));
let numbers = |d: &Draft| d.windows.iter().map(|w| w.number).collect::<Vec<_>>();
assert_eq!(numbers(&d), [1, 2, 3]);
assert!(d.empty(2));
d.drop_empty(&[]);
assert_eq!(numbers(&d), [1, 3]);
assert!(d.warnings(&m, "Built-in").is_empty());
d.place_player(0, Spot::Window(3));
d.place_player(1, Spot::Window(3));
assert_eq!(d.warnings(&m, "Built-in"), [Warning::Empty(1)]);
}
#[test]
fn windows_numbered_lowest_free() {
let mut d = here(3);
assert_eq!(d.add_window(), 2);
assert_eq!(d.add_window(), 3);
d.windows.retain(|w| w.number != 2);
assert_eq!(d.add_window(), 2);
// Nobody to place: no window opens for it.
d.place_player(9, Spot::New);
assert_eq!(d.windows.len(), 3);
}
#[test]
fn a_window_over_window_1s_monitor() {
let m = monitors();
let mut d = here(2);
d.place_player(1, Spot::Fill("Built-in".into()));
assert_eq!(d.warnings(&m, "Built-in"), [Warning::OnWindow1(2)]);
// As the game does: P2 beside P1 in Window 1.
assert_eq!(d.preview(&m, "Built-in")[1].0, 0);
}
#[test]
fn warnings_and_an_unplugged_monitor() {
let m = monitors();
let mut d = here(2).desk_and_tv(&m, "Built-in").unwrap();
let three = d.add_window();
assert_eq!(d.warnings(&m, "Built-in"), [Warning::Empty(three)]);
// The TV unplugged: its player plays in Window 1 beside P1.
assert_eq!(
d.warnings(&m[..1], "Built-in"),
[Warning::Unplugged(2, "TV".into()), Warning::Empty(three)]
);
let views = d.preview(&m[..1], "Built-in");
assert_eq!(views[1], (0, [1000, 0], size(1000, 1200)));
// P1 elsewhere: Window 1 is blank.
d.players = vec![2, 2];
assert_eq!(d.warnings(&m, "Built-in")[0], Warning::Empty(1));
}
#[test]
fn eight_players_two_windows() {
let m = monitors();
let d = here(8).desk_and_tv(&m, "Built-in").unwrap();
let views = d.preview(&m, "Built-in");
// Seven on the TV: rows of 3, 2 and 2, the whole TV covered.
let area: u64 = views[1..]
.iter()
.map(|v| u64::from(v.2.width) * u64::from(v.2.height))
.sum();
assert_eq!(area, 3840 * 2160);
assert!(views[1..].iter().all(|v| v.0 == 1));
}
}
@@ -0,0 +1,166 @@
//! Project a draft arrangement onto the Setup screen, without opening a window.
use crate::split_layout::{Draft, Monitor};
/// Map area: below the title and above the move row and footer.
pub const TOP: f32 = 30.0;
pub const BOTTOM: f32 = 80.0;
pub type Rect = [f32; 4];
pub type Map = (Vec<Rect>, Vec<(u8, Rect)>, Vec<(u8, Rect)>);
/// Monitor, window and player rectangles in UI coordinates, in their drawing order.
pub fn project(draft: &Draft, monitors: &[Monitor], main: &str, w: f32, h: f32) -> Map {
let desk: Vec<[f64; 4]> = monitors.iter().map(Monitor::desktop).collect();
// A window's size on the desktop: its pixels at the scale of the monitor it is on.
let rects = draft.rects(monitors, main);
let on_desk: Vec<(u8, [f64; 4])> = draft
.windows
.iter()
.zip(&rects)
.filter_map(|(win, rect)| {
let ([x, y], [pw, ph]) = (*rect)?;
let (x, y) = (f64::from(x), f64::from(y));
let scale = monitors
.iter()
.find(|m| {
let [mx, my, mw, mh] = m.desktop();
x >= mx && x < mx + mw && y >= my && y < my + mh
})
.or_else(|| monitors.iter().find(|m| m.name == main))
.map_or(1.0, |m| m.desktop()[2] / f64::from(m.size[0]));
Some((
win.number,
[x, y, f64::from(pw) * scale, f64::from(ph) * scale],
))
})
.collect();
// The monitors, and windows past them (Wayland's, whose place is unknown) still shown.
let all = || desk.iter().chain(on_desk.iter().map(|(_, d)| d));
let x0 = all().map(|d| d[0]).fold(f64::MAX, f64::min);
let y0 = all().map(|d| d[1]).fold(f64::MAX, f64::min);
let x1 = all().map(|d| d[0] + d[2]).fold(f64::MIN, f64::max);
let y1 = all().map(|d| d[1] + d[3]).fold(f64::MIN, f64::max);
if desk.is_empty() || x1 <= x0 || y1 <= y0 {
return Default::default();
}
let (aw, ah) = (
f64::from(w) - 40.0,
// Room under the monitors for their names, and always for the move row: the map
// doesn't jump when a player is picked (a drop back to where its tile was misses).
(f64::from(h - TOP - BOTTOM) - 18.0).max(10.0),
);
let k = (aw / (x1 - x0)).min(ah / (y1 - y0));
let (ox, oy) = (
(f64::from(w) - (x1 - x0) * k) / 2.0,
f64::from(TOP) + 4.0 + (ah - (y1 - y0) * k) / 2.0,
);
let to_map = |[x, y, dw, dh]: [f64; 4]| {
[
(ox + (x - x0) * k) as f32,
(oy + (y - y0) * k) as f32,
(dw * k) as f32,
(dh * k) as f32,
]
};
let mapped_monitors = desk.iter().copied().map(to_map).collect();
let windows: Vec<(u8, [f32; 4])> = on_desk.iter().map(|&(n, d)| (n, to_map(d))).collect();
// In their own order, emptied ones too: a window a player was just dragged out of stays
// where it was until Apply closes it, so the player can be dragged back into it.
let tiles = draft
.preview(monitors, main)
.iter()
.map(|&(i, [vx, vy], size)| {
let number = draft.windows.get(i).map_or(1, |w| w.number);
let Some((_, [wx, wy, ww, wh])) = windows.iter().find(|(n, _)| *n == number) else {
return (number, [0.0; 4]);
};
let [pw, ph] = rects[i].map_or([1, 1], |r| r.1).map(|v| v.max(1) as f32);
(
number,
[
wx + vx as f32 / pw * ww,
wy + vy as f32 / ph * wh,
size.width as f32 / pw * ww,
size.height as f32 / ph * wh,
],
)
})
.collect();
(mapped_monitors, windows, tiles)
}
/// The largest visible side of a tile after later windows cover it.
pub fn uncovered(mut r: [f32; 4], covers: impl Iterator<Item = [f32; 4]>) -> [f32; 4] {
for [cx, cy, cw, ch] in covers {
let [x, y, w, h] = r;
if cx >= x + w || cx + cw <= x || cy >= y + h || cy + ch <= y {
continue;
}
let sides = [
[x, y, cx - x, h],
[cx + cw, y, x + w - (cx + cw), h],
[x, y, w, cy - y],
[x, cy + ch, w, y + h - (cy + ch)],
];
r = sides
.into_iter()
.map(|[sx, sy, sw, sh]| [sx, sy, sw.max(0.0), sh.max(0.0)])
.max_by(|a, b| (a[2] * a[3]).total_cmp(&(b[2] * b[3])))
.unwrap_or([x, y, 0.0, 0.0]);
}
r
}
#[cfg(test)]
mod tests {
use super::*;
use crate::split_layout::{Placement, Spot};
#[test]
fn preview_tiles_share_a_window_and_fallback_when_a_monitor_disappears() {
let monitors = vec![
Monitor {
name: "Desk".into(),
at: [0, 0],
size: [2000, 1200],
scale: 1.0,
},
Monitor {
name: "TV".into(),
at: [-1920, 0],
size: [1920, 1080],
scale: 1.0,
},
];
let mut draft =
Draft::everyone_here(2, Placement::Free(Some(([0, 0], [2000, 1200]))), 0, 0);
let (_, windows, tiles) = project(&draft, &monitors, "Desk", 427.0, 300.0);
assert_eq!(windows.len(), 1);
let (a, b) = (tiles[0].1, tiles[1].1);
assert!((a[2] - b[2]).abs() < 0.01);
assert!((a[0] + a[2] - b[0]).abs() < 0.01);
draft.place_player(1, Spot::Fill("TV".into()));
let (screens, windows, tiles) = project(&draft, &monitors, "Desk", 427.0, 300.0);
assert!(screens[1][0] < screens[0][0]);
assert_eq!(windows.len(), 2);
assert_eq!((tiles[0].0, tiles[1].0), (1, 2));
let (_, windows, tiles) = project(&draft, &monitors[..1], "Desk", 427.0, 300.0);
assert_eq!(windows.len(), 1);
assert_eq!((tiles[0].0, tiles[1].0), (1, 1));
assert!(project(&draft, &[], "Desk", 427.0, 300.0).0.is_empty());
}
#[test]
fn covered_tiles_only_expose_a_visible_side_for_clicks() {
let tile = [0.0, 0.0, 100.0, 100.0];
assert_eq!(
uncovered(tile, [[0.0, 0.0, 60.0, 100.0]].into_iter()),
[60.0, 0.0, 40.0, 100.0]
);
let hidden = uncovered(tile, [tile].into_iter());
assert_eq!(hidden[2] * hidden[3], 0.0);
assert_eq!(
uncovered(tile, [[110.0, 0.0, 50.0, 50.0]].into_iter()),
tile
);
}
}
@@ -0,0 +1,716 @@
//! Setup's monitor map and draft window choices. Apply is consumed by the game adapter.
use crate::{
graphics_style::STYLE,
hud::{HudAtlas, HudBatch, rgba},
lang,
split_layout::{Draft, Monitor, Placement, Spot, Warning, ordered},
split_map::{self, BOTTOM, uncovered},
text::Text,
ui,
};
/// Resolve against the selected language on every draw, with bundled English
/// defaults even in a resource-free UI test. Arguments use Minecraft's formatter.
fn tr(key: &str, args: &[&dyn std::fmt::Display]) -> String {
static ENGLISH: std::sync::OnceLock<lang::Language> = std::sync::OnceLock::new();
let key = if key.starts_with("horizon.") {
key.to_owned()
} else {
format!("horizon.splitscreen.setup.{key}")
};
let template = lang::current()
.and_then(|language| language.get(&key).map(str::to_owned))
.unwrap_or_else(|| {
ENGLISH
.get_or_init(|| lang::Language::load(&[], "en_us"))
.get(&key)
.unwrap_or(&key)
.to_owned()
});
let args: Vec<_> = args
.iter()
.map(|arg| Text::literal(arg.to_string()))
.collect();
crate::text::format_translation(&template, &args).plain()
}
#[derive(Default)]
pub struct SplitUi {
pub on: bool,
pub idle: bool,
pub live: Option<Draft>,
pub monitors: Vec<Monitor>,
pub main: String,
pub names: Vec<String>,
pub apply: Option<Draft>,
edit: Option<Draft>,
selected: Option<usize>,
}
#[derive(Clone)]
enum Target {
Player(usize),
Move(Spot),
Revert,
Apply,
Done,
Switch,
}
type Button = ([f32; 4], String, Target, bool);
pub enum Click {
Nothing,
Pressed,
Done,
Switch,
}
impl SplitUi {
pub fn reset(&mut self) {
self.edit = None;
self.selected = None;
self.apply = None;
}
fn draft(&self) -> Option<&Draft> {
self.edit.as_ref().or(self.live.as_ref())
}
fn map(&self, w: f32, h: f32) -> split_map::Map {
if self.idle {
return Default::default();
}
self.draft().map_or_else(Default::default, |draft| {
split_map::project(draft, &self.monitors, &self.main, w, h)
})
}
fn editing(&mut self) -> Option<&mut Draft> {
if self.edit.is_none() {
self.edit = self.live.clone();
}
self.edit.as_mut()
}
fn monitor_name(&self, name: &str) -> String {
match self.monitors.iter().position(|m| m.name == name) {
Some(n) => tr("monitor", &[&(n + 1)]),
None => tr("monitor_unknown", &[]),
}
}
/// Where the selected player can go: the other windows, a new one, a monitor no window fills.
fn spots(&self) -> Vec<(String, Spot)> {
let (Some(draft), Some(i)) = (self.draft(), self.selected) else {
return Vec::new();
};
let mine = draft.players.get(i).copied();
let mut spots: Vec<(String, Spot)> = draft
.windows
.iter()
.filter(|w| Some(w.number) != mine)
.map(|w| (tr("window", &[&w.number]), Spot::Window(w.number)))
.collect();
spots.push((tr("new_window", &[]), Spot::New));
for m in &self.monitors {
let filled = draft
.windows
.iter()
.any(|w| w.placement == Placement::Fill(m.name.clone()));
if m.name != self.main && !filled {
spots.push((
tr("fill_monitor", &[&self.monitor_name(&m.name)]),
Spot::Fill(m.name.clone()),
));
}
}
spots
}
fn buttons(&self, w: f32, h: f32) -> Vec<Button> {
let row = |out: &mut Vec<Button>, y: f32, items: Vec<(f32, String, Target, bool)>| {
let total: f32 = items.iter().map(|i| i.0 + 4.0).sum::<f32>() - 4.0;
let mut x = ((w - total) / 2.0).floor();
for (bw, label, target, active) in items {
out.push(([x, y, bw, 20.0], label, target, active));
x += bw + 4.0;
}
};
let done = (
64.0,
lang::get_or("gui.done", &tr("done", &[])),
Target::Done,
true,
);
// The switch, top right (P1's): off only once P1 plays alone (turning it off with others
// in drops them all at once).
let switch = (
[w - 110.0, 6.0, 104.0, 20.0],
tr(
"switch",
&[
&tr("horizon.extras.splitscreen", &[]),
&tr(if self.on { "on" } else { "off" }, &[]),
],
),
Target::Switch,
!self.on || self.names.len() <= 1,
);
if !self.on || self.idle {
let mut out = vec![switch];
row(&mut out, h - 27.0, vec![done]);
return out;
}
let (_, windows, tiles) = self.map(w, h);
let mut order: Vec<usize> = (0..tiles.len()).collect();
order.sort_by_key(|&i| windows.iter().position(|(n, _)| *n == tiles[i].0));
let mut out: Vec<Button> = order
.into_iter()
.map(|i| {
let name = self
.names
.get(i)
.cloned()
.unwrap_or_else(|| tr("player", &[&(i + 1)]));
// The part no later window covers: what can be seen and pressed.
let at = windows.iter().position(|(n, _)| *n == tiles[i].0);
let covers = at.map_or(&[][..], |k| &windows[k + 1..]);
let rect = uncovered(tiles[i].1, covers.iter().map(|(_, r)| *r));
(rect, name, Target::Player(i), true)
})
.collect();
// The move row: one button for the windows, which cycles (the next window after the
// player's, by number, wrapping), then a new window and the monitors.
let mut spots = self.spots();
let mine = self
.selected
.and_then(|i| self.draft().and_then(|d| d.players.get(i).copied()))
.unwrap_or(0);
let next = spots
.iter()
.filter_map(|(_, s)| match s {
Spot::Window(n) => Some(*n),
_ => None,
})
.min_by_key(|&n| (n <= mine, n));
spots.retain(|(_, s)| !matches!(s, Spot::Window(n) if Some(*n) != next));
if !spots.is_empty() {
let items = spots
.into_iter()
.map(|(label, spot)| (86.0, label, Target::Move(spot), true))
.collect();
row(&mut out, h - BOTTOM + 4.0, items);
}
out.push(switch);
let mut footer = Vec::new();
let editing = self.changed();
footer.extend([
(64.0, tr("revert", &[]), Target::Revert, editing),
(64.0, tr("apply", &[]), Target::Apply, editing),
done,
]);
row(&mut out, h - 27.0, footer);
out
}
fn warnings(&self) -> Vec<String> {
let Some(draft) = self.draft() else {
return Vec::new();
};
draft
.warnings(&self.monitors, &self.main)
.into_iter()
.map(|w| match w {
Warning::Empty(1) => tr("warning.empty_main", &[]),
// One open now closes; one only in the draft never opens.
Warning::Empty(n) if self.live.as_ref().is_some_and(|l| !l.empty(n)) => {
tr("warning.empty_close", &[&n])
}
Warning::Empty(n) => tr("warning.empty_new", &[&n]),
Warning::OnWindow1(n) => tr("warning.on_main", &[&n]),
Warning::Unplugged(n, _) => tr("warning.unplugged", &[&n]),
})
.collect()
}
/// The state represented by the preview area, also available to agent controls.
pub fn status(&self) -> &'static str {
if !self.on {
"off"
} else if self.idle {
"idle"
} else {
"active"
}
}
fn status_lines(&self) -> Option<[String; 3]> {
if !self.on {
Some([
tr("status.off", &[]),
tr("status.enable_hint", &[]),
tr("status.join_hint", &[]),
])
} else if self.idle {
Some([
tr("status.idle", &[]),
tr("status.alone", &[]),
tr("status.solo_controls", &[]),
])
} else {
None
}
}
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32), in_world: bool) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
STYLE.title(
batch,
atlas,
&Text::literal(tr("horizon.pages.split_setup", &[])),
);
if let Some(lines) = self.status_lines() {
let grey = rgba(170, 170, 170, 255);
let lines: Vec<_> = lines
.iter()
.flat_map(|line| atlas.font.split(&Text::literal(line), (w - 40.0).max(10.0)))
.collect();
let top = h / 2.0 - 6.0 * lines.len() as f32;
for (k, line) in lines.into_iter().enumerate() {
let y = (top + 12.0 * k as f32).floor();
ui::centered_text(batch, atlas, &line, 0.0, y, w, grey);
}
for b in &self.buttons(w, h) {
self.draw_button(batch, atlas, cursor, b);
}
return;
}
let (monitors, windows, tiles) = self.map(w, h);
let grey = rgba(160, 160, 160, 255);
for &[x, y, mw, mh] in &monitors {
batch.fill(atlas, x, y, mw, mh, rgba(255, 255, 255, 60));
batch.fill(
atlas,
x + 1.0,
y + 1.0,
mw - 2.0,
mh - 2.0,
rgba(0, 0, 0, 170),
);
}
let buttons = self.buttons(w, h);
let yellow = rgba(255, 255, 85, 255);
// Each window, then its players: a window over another covers it.
let draft = self.draft();
// A window nobody plays in any more stays until Apply: grey, under the others.
let emptied = |n: u8| n != 1 && draft.is_some_and(|d| d.empty(n));
for &(number, [x, y, ww, wh]) in &windows {
let edge = match emptied(number) {
true => rgba(160, 160, 160, 120),
false => rgba(255, 255, 85, 200),
};
batch.fill(atlas, x, y, ww, wh, edge);
if emptied(number) {
let label = Text::literal(tr("window_empty", &[&number]));
batch.text_styled(atlas, &label, x + 3.0, y - 10.0, grey, true);
}
batch.fill(
atlas,
x + 1.0,
y + 1.0,
ww - 2.0,
wh - 2.0,
rgba(40, 40, 40, 230),
);
for b in buttons
.iter()
.filter(|b| matches!(b.2, Target::Player(i) if tiles[i].0 == number))
{
self.draw_button(batch, atlas, cursor, b);
}
}
// The windows' names over everything, so another window's tiles never hide them.
for (number, [x, y, _, _]) in windows.iter().copied().filter(|w| !emptied(w.0)) {
let (label, color) = (tr("window", &[&number]), yellow);
// A title above this window can land on another player's visible tile.
let covered = windows.iter().any(|(n, r)| {
*n != number && ui::hovered((x + 3.0, y - 10.0), r[0], r[1], r[2], r[3])
});
let title_y = if covered { y + 3.0 } else { y - 10.0 };
batch.text_styled(atlas, &Text::literal(label), x + 3.0, title_y, color, true);
}
for b in buttons.iter().filter(|b| !matches!(b.2, Target::Player(_))) {
self.draw_button(batch, atlas, cursor, b);
}
// The monitors' names under them, where no window covers them.
for (n, [x, y, _, mh]) in monitors.into_iter().enumerate() {
let label = Text::literal(tr("monitor", &[&(n + 1)]));
batch.text_styled(atlas, &label, x + 1.0, y + mh + 2.0, grey, true);
}
self.draw_notes(batch, atlas, cursor, &buttons);
}
fn draw_button(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
([x, y, bw, bh], label, target, active): &Button,
) {
let (mut x, mut y, mut bw, mut bh) = (*x, *y, *bw, *bh);
let hover = *active && ui::hovered(cursor, x, y, bw, bh);
let selected = matches!(target, Target::Player(i) if Some(*i) == self.selected);
if matches!(target, Target::Player(_)) {
(x, y, bw, bh) = (x + 1.0, y + 1.0, (bw - 2.0).max(4.0), (bh - 2.0).max(4.0));
}
// The selected player's name in yellow: picked, not just under the pointer.
let text = if selected {
Text::colored(label.as_str(), crate::text::YELLOW)
} else {
Text::literal(label.as_str())
};
ui::button(
batch,
atlas,
x,
y,
bw,
bh,
&text,
*active,
hover || selected,
);
}
fn draw_notes(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
buttons: &[Button],
) {
let (w, h) = (batch.width, batch.height);
let note = match self.selected {
None => tr("note.select", &[]),
Some(_) if self.edit.is_some() => tr("note.apply", &[]),
Some(i) => tr(
"note.place",
&[&self
.names
.get(i)
.cloned()
.unwrap_or_else(|| tr("this_player", &[]))],
),
};
for (k, (line, color)) in std::iter::once((note, rgba(170, 170, 170, 255)))
.chain(
self.warnings()
.into_iter()
.map(|w| (w, rgba(255, 170, 85, 255))),
)
.take(2)
.enumerate()
{
ui::centered_text(
batch,
atlas,
&Text::literal(line),
0.0,
h - BOTTOM + 28.0 + k as f32 * 11.0,
w,
color,
);
}
if buttons.iter().any(|(r, _, t, active)| {
!active && matches!(t, Target::Switch) && ui::hovered(cursor, r[0], r[1], r[2], r[3])
}) {
ui::tooltip(
batch,
atlas,
&atlas
.font
.split(&Text::literal(tr("switch_disabled", &[])), 170.0),
cursor,
);
}
}
/// A press at `(x, y)` on a screen `w` x `h`, or the mouse moving with it held (`drag`).
pub fn click(&mut self, (x, y): (f32, f32), (w, h): (f32, f32), drag: bool) -> Click {
if drag {
return Click::Nothing;
}
// The topmost: the last drawn.
let hit = self
.buttons(w, h)
.into_iter()
.rev()
.find(|([bx, by, bw, bh], _, _, active)| {
*active && ui::hovered((x, y), *bx, *by, *bw, *bh)
});
let Some((_, _, target, _)) = hit else {
return Click::Nothing;
};
match target {
Target::Player(i) => {
self.selected = Some(i);
}
Target::Move(spot) => {
if let Some(i) = self.selected {
self.place(i, spot);
}
}
Target::Revert => self.edit = None,
Target::Apply => {
// Windows emptied in the draft close now, not as players moved.
self.apply = self.edit.take().map(|mut d| {
d.drop_empty(&[]);
d
});
}
Target::Done => {
self.reset();
return Click::Done;
}
Target::Switch => {
self.reset();
return Click::Switch;
}
}
Click::Pressed
}
fn changed(&self) -> bool {
// A free window's spot moves under the draft as it's dragged around: not a change.
let windows = |d: &Draft| -> Vec<(u8, Option<String>)> {
let fill = |p: &Placement| match p {
Placement::Fill(name) => Some(name.clone()),
Placement::Free(_) => None,
};
d.windows
.iter()
.map(|w| (w.number, fill(&w.placement)))
.collect()
};
// The order only counts among players sharing a window (who is first in it).
let order = |d: &Draft| -> Vec<(u8, usize)> {
let mut by_window: Vec<(u8, usize)> = ordered(&d.order, d.players.len())
.into_iter()
.filter_map(|i| d.players.get(i).map(|&n| (n, i)))
.collect();
by_window.sort_by_key(|&(n, _)| n);
by_window
};
let same = |a: &Draft, b: &Draft| {
windows(a) == windows(b)
&& a.players == b.players
&& (a.two, a.three) == (b.two, b.three)
&& order(a) == order(b)
};
match (&self.edit, &self.live) {
(Some(edit), Some(live)) => !same(edit, live),
(edit, _) => edit.is_some(),
}
}
/// Player `i` to `spot` in the draft.
fn place(&mut self, i: usize, spot: Spot) {
let open = self.open_now();
if let Some(draft) = self.editing() {
draft.place_player(i, spot);
draft.drop_empty(&open);
}
}
/// The windows open now: emptied in a draft, they stay until Apply (a window the draft
/// added goes as soon as nobody is in it).
fn open_now(&self) -> Vec<u8> {
self.live
.as_ref()
.map_or_else(Vec::new, |l| l.windows.iter().map(|w| w.number).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
const SIZE: (f32, f32) = (427.0, 300.0);
fn screen(players: usize) -> SplitUi {
SplitUi {
live: Some(Draft::everyone_here(
players,
Placement::Free(Some(([0, 0], [2000, 1200]))),
0,
0,
)),
monitors: vec![
Monitor {
name: "Built-in".into(),
at: [0, 0],
size: [3024, 1964],
scale: 2.0,
},
Monitor {
name: "TV".into(),
at: [3024, 0],
size: [1920, 1080],
scale: 1.0,
},
],
main: "Built-in".into(),
names: (1..=players).map(|n| format!("P{n}")).collect(),
on: true,
..Default::default()
}
}
fn labels(ui: &SplitUi) -> Vec<String> {
ui.buttons(SIZE.0, SIZE.1)
.into_iter()
.map(|b| b.1)
.collect()
}
#[test]
fn off_only_the_switch_and_done() {
let mut ui = screen(2);
ui.on = false;
assert_eq!(labels(&ui), ["Splitscreen: Off", "Done"]);
let r = rect(&ui, "Splitscreen: Off");
assert!(matches!(
ui.click((r[0] + 1.0, r[1] + 1.0), SIZE, false),
Click::Switch
));
}
fn rect(ui: &SplitUi, label: &str) -> [f32; 4] {
ui.buttons(SIZE.0, SIZE.1)
.into_iter()
.find(|(_, l, _, _)| l == label)
.unwrap_or_else(|| panic!("no {label}"))
.0
}
fn press(ui: &mut SplitUi, label: &str) {
let r = rect(ui, label);
ui.click((r[0] + 1.0, r[1] + 1.0), SIZE, false);
}
#[test]
fn move_a_player_then_apply() {
let mut ui = screen(2);
press(&mut ui, "P2");
press(&mut ui, "Fill Monitor 2");
// Nothing applied yet: the draft only.
assert_eq!(ui.apply, None);
assert_eq!(ui.live.as_ref().unwrap().players, [1, 1]);
press(&mut ui, "Apply");
let applied = ui.apply.take().unwrap();
assert_eq!(applied.players, [1, 2]);
assert_eq!(applied.windows[1].placement, Placement::Fill("TV".into()));
}
#[test]
fn revert_drops_the_draft() {
let mut ui = screen(2);
press(&mut ui, "P2");
press(&mut ui, "New window");
assert!(ui.edit.is_some());
press(&mut ui, "Revert");
assert!(ui.edit.is_none());
// Done closes and forgets the selection.
press(&mut ui, "Done");
assert_eq!(ui.selected, None);
}
#[test]
fn locale_switch_and_pack_overrides_keep_the_same_draft() {
// The language table is process-wide. Run this test alone in a child so
// other UI tests cannot observe its temporary French language.
const CHILD: &str = "HORIZON_TEST_SPLIT_SETUP_LOCALE";
if std::env::var_os(CHILD).is_none() {
let module = module_path!().split_once("::").unwrap().1;
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
&format!("{module}::locale_switch_and_pack_overrides_keep_the_same_draft"),
"--nocapture",
])
.env(CHILD, "1")
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&output.stdout).contains("1 passed;"),
"locale child test did not run: {}",
String::from_utf8_lossy(&output.stdout)
);
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
}
struct Pack;
impl lang::ResourceSource for Pack {
fn namespaces(&self) -> Vec<String> {
vec!["horizon".into()]
}
fn read(&self, path: &str) -> Option<Vec<u8>> {
(path == "assets/horizon/lang/fr_fr.json").then(|| {
serde_json::to_vec(&serde_json::json!({
"horizon.splitscreen.setup.new_window": "Créer une fenêtre",
"horizon.splitscreen.setup.monitor": "Écran personnalisé %s",
"horizon.splitscreen.setup.fill_monitor": "Occuper %s",
"horizon.splitscreen.setup.warning.empty_main": "Fenêtre principale masquée"
}))
.unwrap()
})
}
}
let mut ui = screen(2);
ui.names[1] = "Élodie %s".into();
lang::set(lang::Language::load(&[], "en_us"));
press(&mut ui, "Élodie %s");
assert!(labels(&ui).contains(&"New window".to_owned()));
lang::set(lang::Language::load(&[], "fr_fr"));
assert!(labels(&ui).contains(&"Nouvelle fenêtre".to_owned()));
assert!(labels(&ui).contains(&"Appliquer".to_owned()));
assert!(
labels(&ui).contains(&"Élodie %s".to_owned()),
"player names remain literal"
);
press(&mut ui, "Nouvelle fenêtre");
let draft = ui.edit.clone().unwrap();
assert_eq!(draft.players, [1, 2]);
lang::set(lang::Language::load(&[&Pack], "fr_fr"));
let translated = labels(&ui);
assert!(translated.contains(&"Créer une fenêtre".to_owned()));
assert!(translated.contains(&"Occuper Écran personnalisé 2".to_owned()));
assert_eq!(
ui.edit,
Some(draft),
"a language change must not reset the arrangement"
);
ui.place(0, Spot::Window(2));
assert!(
ui.warnings()
.contains(&"Fenêtre principale masquée".to_owned())
);
press(&mut ui, "Appliquer");
assert_eq!(ui.apply.take().unwrap().players, [2, 2]);
ui.on = false;
assert_eq!(
ui.status(),
"off",
"agent protocol states stay language independent"
);
assert_eq!(
ui.status_lines().unwrap()[0],
"L'écran partagé est désactivé."
);
assert!(labels(&ui).contains(&"Écran partagé : Désactivé".to_owned()));
lang::set(lang::Language::load(&[], "de_de"));
assert_eq!(
ui.status_lines().unwrap()[0],
"Splitscreen is off.",
"untranslated new keys use bundled English"
);
}
}
+182
View File
@@ -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");
+358 -221
View File
@@ -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, &region[*i]);
writes
.block_entities
.extend(generator.final_block_entities(&region[*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(&region), 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(&region), 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, &region[*i]);
writes.block_entities.extend(
generator.final_block_entities(&region[*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(&region), &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(&region),
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
+38 -4
View File
@@ -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.
+107 -8
View File
@@ -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."
}
@@ -35,16 +35,16 @@
"horizon.pages.settings": "Horizon Settings",
"horizon.pages.agent": "AI Agent",
"horizon.pages.split_settings": "Splitscreen Settings",
"horizon.pages.split_setup": "Splitscreen",
"horizon.pages.split_profiles": "Who's playing?",
"horizon.pages.split_setup": "Splitscreen Setup",
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
"horizon.pages.graphics_advanced.tooltip": "Every graphics setting, Horizon's included, with what each costs.",
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
"horizon.pages.split_setup.tooltip": "Players on this computer: controllers can join your world.",
"horizon.pages.split_profiles.tooltip": "Who plays: a profile kept in this game folder.",
"horizon.pages.split_setup.tooltip": "The windows and monitors, and who plays where.",
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
"horizon.pages.open.graphics": "Graphics...",
@@ -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,65 @@
"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.",
"horizon.splitscreen.setup.monitor": "Monitor %s",
"horizon.splitscreen.setup.monitor_unknown": "a monitor",
"horizon.splitscreen.setup.window": "Window %s",
"horizon.splitscreen.setup.new_window": "New window",
"horizon.splitscreen.setup.fill_monitor": "Fill %s",
"horizon.splitscreen.setup.done": "Done",
"horizon.splitscreen.setup.switch": "%s: %s",
"horizon.splitscreen.setup.on": "On",
"horizon.splitscreen.setup.off": "Off",
"horizon.splitscreen.setup.player": "Player %s",
"horizon.splitscreen.setup.revert": "Revert",
"horizon.splitscreen.setup.apply": "Apply",
"horizon.splitscreen.setup.warning.empty_main": "Nobody plays in Window 1: it hides",
"horizon.splitscreen.setup.warning.empty_close": "Nobody plays in Window %s: it closes on Apply",
"horizon.splitscreen.setup.warning.empty_new": "Nobody plays in Window %s: it won't open",
"horizon.splitscreen.setup.warning.on_main": "Window %s fills Window 1's monitor: its players play in Window 1",
"horizon.splitscreen.setup.warning.unplugged": "Window %s's monitor is not connected: its players play in Window 1",
"horizon.splitscreen.setup.status.off": "Splitscreen is off.",
"horizon.splitscreen.setup.status.enable_hint": "Turn it on, then press a button on another controller in a world.",
"horizon.splitscreen.setup.status.join_hint": "Press A to join and pick a profile.",
"horizon.splitscreen.setup.status.idle": "Splitscreen idle",
"horizon.splitscreen.setup.status.alone": "No other local players.",
"horizon.splitscreen.setup.status.solo_controls": "Solo controls and controller feedback are unchanged.",
"horizon.splitscreen.setup.window_empty": "Window %s: empty",
"horizon.splitscreen.setup.note.select": "Click a player to choose its window.",
"horizon.splitscreen.setup.note.apply": "Apply to play this way, Revert to undo.",
"horizon.splitscreen.setup.note.place": "Where should %s play?",
"horizon.splitscreen.setup.this_player": "this player",
"horizon.splitscreen.setup.switch_disabled": "Splitscreen turns off once the others have left (their pause menu).",
"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,82 @@
"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...",
"horizon.splitscreen.setup.monitor": "Écran %s",
"horizon.splitscreen.setup.monitor_unknown": "un écran",
"horizon.splitscreen.setup.window": "Fenêtre %s",
"horizon.splitscreen.setup.new_window": "Nouvelle fenêtre",
"horizon.splitscreen.setup.fill_monitor": "Remplir %s",
"horizon.splitscreen.setup.done": "Terminé",
"horizon.splitscreen.setup.switch": "%s : %s",
"horizon.splitscreen.setup.on": "Activé",
"horizon.splitscreen.setup.off": "Désactivé",
"horizon.splitscreen.setup.player": "Joueur %s",
"horizon.splitscreen.setup.revert": "Rétablir",
"horizon.splitscreen.setup.apply": "Appliquer",
"horizon.splitscreen.setup.warning.empty_main": "La fenêtre 1 se masque si personne n'y joue.",
"horizon.splitscreen.setup.warning.empty_close": "Personne ne joue dans la fenêtre %s : elle se fermera après application.",
"horizon.splitscreen.setup.warning.empty_new": "Personne ne joue dans la fenêtre %s : elle ne s'ouvrira pas.",
"horizon.splitscreen.setup.warning.on_main": "La fenêtre %s occupe l'écran de la fenêtre 1 : ses joueurs restent dans la fenêtre 1.",
"horizon.splitscreen.setup.warning.unplugged": "L'écran de la fenêtre %s est débranché : ses joueurs restent dans la fenêtre 1.",
"horizon.splitscreen.setup.status.off": "L'écran partagé est désactivé.",
"horizon.splitscreen.setup.status.enable_hint": "Activez-le, puis appuyez sur un bouton d'une autre manette dans un monde.",
"horizon.splitscreen.setup.status.join_hint": "Appuyez sur A pour rejoindre et choisir un profil.",
"horizon.splitscreen.setup.status.idle": "Écran partagé en attente",
"horizon.splitscreen.setup.status.alone": "Aucun autre joueur local.",
"horizon.splitscreen.setup.status.solo_controls": "Les commandes et les vibrations en solo restent inchangées.",
"horizon.splitscreen.setup.window_empty": "Fenêtre %s : vide",
"horizon.splitscreen.setup.note.select": "Cliquez sur un joueur pour choisir sa fenêtre.",
"horizon.splitscreen.setup.note.apply": "Appliquez les modifications ou annulez-les.",
"horizon.splitscreen.setup.note.place": "Où %s doit-il jouer ?",
"horizon.splitscreen.setup.this_player": "ce joueur",
"horizon.splitscreen.setup.switch_disabled": "L'écran partagé peut être désactivé après le départ des autres joueurs depuis leur menu pause.",
"horizon.pages.split_setup": "Configuration de l'écran partagé",
"horizon.pages.split_setup.tooltip": "Les fenêtres, les écrans et la place de chaque joueur.",
"horizon.pages.open.split_setup": "Écran partagé...",
"horizon.pages.split_settings": "Options de l'écran partagé",
"horizon.extras.splitscreen": "Écran partagé",
"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.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."
}
@@ -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": "仅限本机:此电脑上的代理可用,网络不可用。"
}
+9 -1
View File
@@ -24,7 +24,15 @@ fn main() {
resource.set_manifest_file(&manifest);
}
if let Err(e) = resource.compile() {
panic!("embedding Windows resources: {e}");
// The GNU toolchain's `windres` (the launcher's mod builds use it) can't take a path
// with a space, and a Windows user name often has one: such a build goes on without
// the icon. Release builds run in CI, where the icon stays required.
if std::env::var_os("CI").is_some() {
panic!("embedding Windows resources: {e}");
}
println!(
"cargo:warning=building without Horizon's icon: embedding Windows resources failed: {e}"
);
}
}
}
@@ -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,
+90 -1
View File
@@ -116,6 +116,39 @@ impl Control {
}
}
/// The wait before a scene's flight, spent timing Horizon's own render passes on the GPU:
/// `gpu_pass.<scene>.<pass>_ms`, where a slow GPU spends its frame. The timing is off again for
/// the flight, whose frames it would slow. False, having waited for nothing, on Metal: its passes
/// overlap on the GPU, so their times add up to more than the frame.
fn time_passes(control: &Control, scene: &str, report: &mut Report) -> Result<bool, String> {
if report
.hardware
.gpu
.as_ref()
.is_some_and(|g| g.backend == "metal")
{
return Ok(false);
}
control.call("gpu-passes on")?;
let held = control.call(&format!("bench {}", SETTLE.as_secs_f64()));
control.call("gpu-passes off")?;
for (pass, ms) in held?["gpu_passes"].as_object().into_iter().flatten() {
let pass: String = pass
.trim()
.chars()
.map(|c| match c {
'a'..='z' | '0'..='9' => c,
'A'..='Z' => c.to_ascii_lowercase(),
_ => '_',
})
.collect();
if let Some(ms) = ms.as_f64() {
report.metric(&format!("gpu_pass.{scene}.{pass}_ms"), &[ms]);
}
}
Ok(true)
}
fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
let scenes =
scenes(&setup.scenario).ok_or_else(|| format!("unknown scenario {}", setup.scenario))?;
@@ -204,7 +237,9 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
&[chunks / load.as_secs_f64()],
);
}
thread::sleep(SETTLE);
if !time_passes(control, name, &mut report)? {
thread::sleep(SETTLE);
}
let [ex, ey, ez] = end;
let begin = Instant::now();
let flight = control.call(&format!(
@@ -499,6 +534,60 @@ mod tests {
let _ = server.join();
}
/// The wait before a flight times the passes and turns the timing off again before the
/// flight; the report gets one metric per pass. Not on Metal.
#[test]
fn the_wait_times_the_passes_but_not_the_flight() {
let time_on = |backend: &str| {
let (tx, rx) = mpsc::channel::<Request>();
let server = thread::spawn(move || {
let mut lines = Vec::new();
while let Ok(request) = rx.recv() {
let reply = if request.line.starts_with("bench ") {
serde_json::json!({
"frames": 120,
"gpu_passes": {"World": 1.5, "World after culling": 0.25, "HUD": 0.01},
})
} else {
serde_json::json!({ "ok": true })
};
let _ = request.reply.send(reply.to_string());
lines.push(request.line);
}
lines
});
let hardware = horizon_bench::Hardware {
gpu: Some(horizon_bench::Gpu {
backend: backend.into(),
..Default::default()
}),
..Default::default()
};
let mut report = Report::with_hardware(Kind::Game, App::default(), hardware);
let control = Control(tx);
let timed = time_passes(&control, "forest", &mut report).expect("the wait runs");
drop(control);
(timed, server.join().unwrap(), report.metrics)
};
let (timed, lines, metrics) = time_on("vulkan");
assert!(timed);
assert_eq!(lines, ["gpu-passes on", "bench 2", "gpu-passes off"]);
let mean = |name: &str| metrics.get(name).map(|s| s.mean);
assert_eq!(mean("gpu_pass.forest.world_ms"), Some(1.5));
assert_eq!(mean("gpu_pass.forest.world_after_culling_ms"), Some(0.25));
assert_eq!(mean("gpu_pass.forest.hud_ms"), Some(0.01));
assert_eq!(metrics.len(), 3);
let (timed, lines, metrics) = time_on("metal");
assert!(!timed);
assert!(
lines.is_empty(),
"Metal's overlapping passes are not timed: {lines:?}"
);
assert!(metrics.is_empty());
}
#[test]
fn yaw_matches_vanilla() {
// South (+Z) is 0, west (-X) 90, east (+X) -90.
+1 -1
View File
@@ -36,7 +36,7 @@ pub fn native_living_breathe(
consume: &mut i32,
refill: &mut i32,
) {
let _ = (entity, kind, can_breathe, consume, refill);
crate::native_client_hooks::living_breathe(entity, kind, can_breathe, consume, refill);
}
/// `vanilla`'s breathing after the mods have had their say.
+9 -8
View File
@@ -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_map, 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;
@@ -89,6 +89,7 @@ pub mod mod_content;
pub mod motion;
pub mod multiplayer;
pub mod music;
pub mod native_client_hooks;
pub mod native_data;
pub mod net;
pub mod nether_portal;
+55 -48
View File
@@ -14,11 +14,34 @@ use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{
collections::{HashMap, HashSet},
fs,
path::{Path, PathBuf},
path::Path,
sync::Arc,
};
/// A recipe ingredient of a mod's own kind: the entry named a type in a
/// namespace outside `minecraft:`, and the loader kept it for this call.
/// The kind and the JSON the file wrote are here; fill `items` with the
/// item ids the ingredient matches (item ids only: components are not
/// checked here; a match-time hook follows separately). Duplicates are
/// removed keeping first-seen order. The hook may be called more than
/// once per recipe; the answer must be the same. An unclaimed kind
/// leaves the list empty and the recipe fails, as an unknown kind does.
/// API @API:ingredient@.
#[inline(never)]
pub fn native_ingredient(
kind: &str,
data: &serde_json::Map<String, Value>,
items: &mut Vec<String>,
) {
crate::native_client_hooks::ingredient(kind, data, items);
}
/// Keep first-seen order, drop the rest.
fn dedup_keep_order(items: &mut Vec<String>) {
let mut seen = std::collections::HashSet::new();
items.retain(|i| seen.insert(i.clone()));
}
/// A set of accepted items (expanded from an item, list or tag), and the
/// items in its `HolderSet` order (`Ingredient.display`'s `TagSlotDisplay`).
#[derive(Clone, Debug)]
@@ -107,6 +130,14 @@ impl Ingredient {
.cloned()
.collect()
}
// A mod's own kind: the hook answers with the item list it
// matches; a bare type name is minecraft's own, not a mod's.
k if k.contains(':') && !k.starts_with("minecraft:") => {
let mut list = Vec::new();
native_ingredient(k, o, &mut list);
dedup_keep_order(&mut list);
return (!list.is_empty()).then(|| Self::of(list));
}
_ => return None,
};
(!kept.is_empty()).then(|| Self::of(kept))
@@ -1176,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`).
@@ -1340,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()));
}
}
}
@@ -1855,6 +1844,7 @@ impl Recipes {
mod tests {
use super::*;
use crate::banner::BannerLayer;
use std::fs;
#[test]
fn result_count_is_bounded_like_vanilla() {
@@ -1913,6 +1903,23 @@ mod tests {
/// NeoForge's standard ingredient shapes resolve to their flat item
/// lists; a kind the loader doesn't know still fails the file.
#[test]
fn an_unclaimed_ingredient_kind_fails_and_dedup_keeps_order() {
let items = Items::for_tests(HashMap::new());
let json = |s: &str| serde_json::from_str::<Value>(s).unwrap();
assert!(
Ingredient::parse(&json(r#"{"type":"mymod:aspect","aspect":"fire"}"#), &items,)
.is_none()
);
assert!(
Ingredient::parse(&json(r#"{"type":"minecraft:future_shape"}"#), &items,).is_none()
);
assert!(Ingredient::parse(&json(r#"{"type":"aspect"}"#), &items,).is_none());
let mut list = vec!["b".to_string(), "a".to_string(), "b".to_string()];
dedup_keep_order(&mut list);
assert_eq!(list, vec!["b".to_string(), "a".to_string()]);
}
#[test]
fn neoforge_ingredient_shapes_parse() {
let mut tags = HashMap::new();
+69
View File
@@ -2343,6 +2343,20 @@ pub fn fireball_to_nbt(
if let Some(power) = power {
put(&mut tag, "ExplosionPower", Tag::Byte(power as i8));
}
// Fireball.addAdditionalSaveData: the item it is drawn as, one of the loaded
// item or else a fire charge (`setItem` refuses an empty stack and keeps one).
if matches!(
p.kind,
ProjectileKind::LargeFireball { .. } | ProjectileKind::SmallFireball
) {
let mut item = tag
.get("Item")
.and_then(item_from_nbt)
.filter(|i| i.count > 0 && &*i.item != "minecraft:air")
.unwrap_or_else(|| crate::items::ItemStack::new("minecraft:fire_charge", 1));
item.count = 1;
put(&mut tag, "Item", item_to_nbt(&item));
}
Some(tag)
}
@@ -4673,6 +4687,61 @@ mod fireball_nbt_tests {
}
}
/// `Fireball.addAdditionalSaveData` / `readAdditionalSaveData`: both fireballs save
/// `Item`, a fire charge by default, and a loaded one stays, as one item.
#[test]
fn a_fireball_saves_its_item() {
let item_of = |tag: &Tag| {
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
(item.item.to_string(), item.count)
};
for id in ["minecraft:fireball", "minecraft:small_fireball"] {
let plain = Tag::compound([
("id", Tag::String(id.into())),
("Pos", doubles(&[0.0, 0.0, 0.0])),
]);
let p = fireball_from_nbt(&plain, None).unwrap();
let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, Some(&plain)).unwrap();
assert_eq!(
item_of(&back),
("minecraft:fire_charge".to_owned(), 1),
"{id}"
);
// A loaded item stays, as a single one.
let mut loaded = plain.clone();
put(
&mut loaded,
"Item",
item_to_nbt(&crate::items::ItemStack::new("minecraft:diamond", 5)),
);
let p = fireball_from_nbt(&loaded, None).unwrap();
let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, Some(&loaded)).unwrap();
assert_eq!(item_of(&back), ("minecraft:diamond".to_owned(), 1), "{id}");
// An empty one falls back to the fire charge.
let mut empty = plain.clone();
put(
&mut empty,
"Item",
item_to_nbt(&crate::items::ItemStack::new("minecraft:air", 1)),
);
let p = fireball_from_nbt(&empty, None).unwrap();
let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, Some(&empty)).unwrap();
assert_eq!(
item_of(&back),
("minecraft:fire_charge".to_owned(), 1),
"{id}"
);
}
// The wind charges are not `Fireball`s: they save no item.
let wind = Tag::compound([
("id", Tag::String("minecraft:wind_charge".into())),
("Pos", doubles(&[0.0, 0.0, 0.0])),
]);
let p = fireball_from_nbt(&wind, None).unwrap();
let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, Some(&wind)).unwrap();
assert!(back.get("Item").is_none());
}
#[test]
fn thrown_item_projectiles_round_trip_their_flight_item_and_owner() {
let player = Some([5, 6, 7, 8]);
+47
View File
@@ -74,12 +74,59 @@ pub fn main_with(mods: crate::mod_api::Mods) {
components,
);
},
loot_function: |kind, data, ctx, stack| {
crate::mod_api::dispatch_loot_function(
crate::mod_api::mods(),
kind,
data,
ctx,
stack,
);
},
loot_condition: |kind, data, ctx, holds| {
crate::mod_api::dispatch_loot_condition(
crate::mod_api::mods(),
kind,
data,
ctx,
holds,
);
},
},
)
.is_err()
{
log::warn!("Native data-load hooks already installed; keeping the first callbacks");
}
if crate::native_client_hooks::install(crate::native_client_hooks::NativeClientHooks {
menu_slot_rules: |menu, slot, stack, may_place, max_stack| {
crate::mod_api::dispatch_menu_slot_rules(
crate::mod_api::mods(),
menu,
slot,
stack,
may_place,
max_stack,
);
},
living_breathe: |entity, kind, can_breathe, consume, refill| {
crate::mod_api::dispatch_living_breathe(
crate::mod_api::mods(),
entity,
kind,
can_breathe,
consume,
refill,
);
},
ingredient: |kind, data, items| {
crate::mod_api::dispatch_ingredient(crate::mod_api::mods(), kind, data, items);
},
})
.is_err()
{
log::warn!("Native client hooks already installed; keeping the first callbacks");
}
raise_open_file_limit();
if let Err(error) = game::run_with() {
// A bad command line is the caller's mistake, not a crash: no report.
+140 -15
View File
@@ -115,6 +115,7 @@ mod dragon;
mod dynamic_lights;
mod enchant_effects;
mod end;
mod entity;
mod entity_culling;
mod event_loop;
mod events;
@@ -182,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;
@@ -384,6 +386,16 @@ use horizon_world::{
},
registry::BlockStateId,
};
pub use maps::{NativeMapDecoration, NativeMapInfo};
pub use native_block_entity_draw::BlockEntityDraw;
pub use native_claimed_items::{NativeHeldItem, NativeHit, NativeModEvent};
pub use native_data_maps::NativeDataMapType;
pub use native_falling::NativeFalling;
pub use native_hooks::{NativeClock, NativeShake};
pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
pub use native_server::NativeExperienceOrb;
pub use native_world_columns::NativeColumnBlocks;
pub use native_world_edit::{NativeBlockEdit, NativeBlockRun, NativeHeightmap};
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
@@ -2288,6 +2300,8 @@ struct Game<'a> {
regrab_on_refocus: bool,
/// Only the local player owning keyboard and mouse changes the native pointer.
keyboard_mouse: bool,
/// Offscreen rendering into a visible splitscreen window still accepts native input.
split_window: bool,
/// This view's corner in its shared window; cursor warps include the offset.
view_at: [u32; 2],
/// Splitscreen: what it draws over this player's view (its badge, questions, notices).
@@ -2342,6 +2356,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.
@@ -2438,6 +2456,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,
@@ -2576,6 +2596,9 @@ struct Game<'a> {
server_trips: Vec<server_travel::Trip>,
/// Players a command sends to another level, once it has run.
level_teleports: Vec<server_travel::LevelTeleport>,
/// Teleports a command made inside a level, for `native_player_teleported`
/// once the player's move has reached their client (`leave_guest`).
teleport_notes: Vec<server_travel::TeleportNote>,
/// A server's levels other than the one in place (game/levels.rs).
levels: levels::Levels,
/// Which pass of a server tick runs (`tick_server`): the first one reads
@@ -2641,6 +2664,8 @@ struct Game<'a> {
/// Models drawn only once such an entity shows up (lead knots, wind charges...).
entity_models: EntityModels,
carts: Vec<minecarts::Minecart>,
/// The boats and minecarts `native_vehicle_moved` follows (lan_vehicles.rs).
watched_vehicles: Vec<lan_vehicles::WatchedVehicle>,
/// Songs playing from jukeboxes (tracked sound handles).
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
bolts: Vec<lightning::Bolt>,
@@ -2675,6 +2700,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]>,
@@ -2847,6 +2876,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.
@@ -2901,6 +2937,9 @@ struct Game<'a> {
lan_acting_name: Option<String>,
/// Their UUID (`Player.getUUID`).
lan_acting_uuid: Option<u128>,
/// Where the mods refused to prime TNT: the sound and game event the level
/// queued right after the prime are dropped (`prime_tnt_event`).
tnt_refused: Option<BlockPos>,
/// Their permission level: an op's above 0 (`PlayerList.isOp`).
lan_acting_permission: Option<u8>,
/// Their entity id, which the hub (often taken out of `lan` while they
@@ -5000,6 +5039,15 @@ impl Game<'_> {
_ => {}
}
}
// A prime the mods refused is silent: no fuse sound, no game event.
LevelEvent::Sound { pos, ref sound, .. }
if self.tnt_refused == Some(pos)
&& &**sound == "minecraft:entity.tnt.primed" => {}
LevelEvent::GameEvent {
pos,
event: "minecraft:prime_fuse",
..
} if self.tnt_refused == Some(pos) => self.tnt_refused = None,
LevelEvent::Sound {
pos,
sound,
@@ -5089,7 +5137,7 @@ impl Game<'_> {
}
PlayerEffect::InteractLoot(_) => {}
},
LevelEvent::PrimeTnt { pos, rot } => self.add_primed_tnt(pos, rot, 80, None, None),
LevelEvent::PrimeTnt { pos, rot } => self.prime_tnt_event(pos, rot),
LevelEvent::BlockEvent {
pos,
state,
@@ -6405,6 +6453,22 @@ impl Game<'_> {
}
Ok(true)
}
/// Asks the mods whether the acting guest may empty `stack` at `pos`
/// (`native_bucket_empty`): true when one cancelled it. A game's own
/// player is not asked.
fn bucket_empty_cancelled(&mut self, stack: &ItemStack, pos: [i32; 3]) -> bool {
let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) else {
return false;
};
let fluid = match &*stack.item {
"minecraft:lava_bucket" => "minecraft:lava",
"minecraft:powder_snow_bucket" => "minecraft:powder_snow",
_ => "minecraft:water",
};
let mut cancel = false;
self.native_bucket_empty(uuid, &name, pos, fluid, &mut cancel);
cancel
}
/// The player as a block's `useItemOn` / `useWithoutItem` sees them.
fn use_context(&self) -> PlayerContext {
PlayerContext {
@@ -7148,6 +7212,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;
@@ -7525,6 +7594,7 @@ impl Game<'_> {
horizon_client::spans::time("tick_cushions", || self.tick_cushions());
horizon_client::spans::time("tick_boats", || self.tick_boats());
horizon_client::spans::time("tick_carts", || self.tick_carts());
self.tick_vehicle_watch();
horizon_client::spans::time("tick_thunder", || self.tick_thunder());
horizon_client::spans::time("tick_lecterns", || self.tick_lecterns());
horizon_client::spans::time("tick_maps", || self.tick_maps());
@@ -7947,9 +8017,9 @@ impl Game<'_> {
};
let scale = HudBatch::new(size).scale;
let was_grabbed = self.captured;
if self.options.offscreen {
// `--offscreen` shows no window: the system pointer stays where the
// person at the computer has it, and the grab is the game's own (an
if self.options.offscreen && !self.split_window {
// A genuinely hidden `--offscreen` run leaves the system pointer where
// the person at the computer has it. Its grab is internal (an
// agent turns the view with `mouse-move`).
if capture {
self.mouse.set_ignore_first_move();
@@ -8010,7 +8080,7 @@ impl Game<'_> {
) {
self.cursor = pos;
}
if !self.options.offscreen {
if !self.options.offscreen || self.split_window {
window.set_cursor_visible(!self.captured);
}
}
@@ -8552,7 +8622,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
@@ -8625,7 +8695,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.
@@ -11183,6 +11253,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;
@@ -11406,7 +11479,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);
@@ -13044,7 +13117,7 @@ impl Game<'_> {
let target = self.bucket_target(content, &hit);
let mut canceled = false;
self.native_fill_bucket(stack, target, &mut canceled);
if canceled {
if canceled || self.bucket_empty_cancelled(stack, target) {
return Ok(UseResult::Fail);
}
let pos = BlockPos::new(target[0], target[1], target[2]);
@@ -15528,6 +15601,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),
@@ -15611,6 +15685,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,
@@ -15691,6 +15766,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
}
@@ -15965,6 +16052,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() {
@@ -16005,6 +16094,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]);
@@ -16016,6 +16107,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);
}
@@ -16053,7 +16146,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 => {
@@ -16104,6 +16197,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`).
@@ -16522,11 +16617,13 @@ impl ApplicationHandler for Game<'_> {
}
}
fn device_event(&mut self, _: &ActiveEventLoop, _: DeviceId, event: DeviceEvent) {
// The real mouse never steers an offscreen game: its pointer is not grabbed.
// A composited view renders offscreen but its keyboard/mouse owner still
// controls a visible native window. A headless offscreen run never does.
if self.pending_save.is_none()
&& self.captured
&& self.focused
&& !self.options.offscreen
&& self.keyboard_mouse
&& (!self.options.offscreen || self.split_window)
&& self.screen == Screen::Playing
&& let DeviceEvent::MouseMotion { delta: (x, y) } = event
{
@@ -18205,6 +18302,7 @@ impl<'a> Game<'a> {
captured: false,
regrab_on_refocus: false,
keyboard_mouse: true,
split_window: false,
view_at: [0, 0],
split_hud: Default::default(),
focused: true,
@@ -18233,6 +18331,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(),
@@ -18294,6 +18394,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(),
@@ -18366,6 +18467,7 @@ impl<'a> Game<'a> {
travelling: None,
server_trips: Vec::new(),
level_teleports: Vec::new(),
teleport_notes: Vec::new(),
levels: Default::default(),
level_pass: Default::default(),
portals: Default::default(),
@@ -18406,6 +18508,7 @@ impl<'a> Game<'a> {
cushion_model: prepared.cushion_model,
entity_models: prepared.entity_models,
carts: Vec::new(),
watched_vehicles: Vec::new(),
jukebox_sounds: HashMap::new(),
bolts: Vec::new(),
maps: maps::Maps::default(),
@@ -18435,6 +18538,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,
@@ -18530,6 +18634,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,
@@ -18557,6 +18663,7 @@ impl<'a> Game<'a> {
guest_player_sounds: Vec::new(),
lan_acting_name: None,
lan_acting_uuid: None,
tnt_refused: None,
lan_acting_permission: None,
lan_acting_entity: None,
chat_capture: None,
@@ -18960,6 +19067,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) => {
@@ -19337,6 +19447,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
@@ -19591,6 +19704,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)
@@ -19916,6 +20032,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
entities: &[],
entity_motion: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
colored_lights: &[],
@@ -20652,9 +20769,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();
}
@@ -20662,6 +20778,15 @@ pub fn run_with() -> Result<()> {
horizon_threads::catch_stop_signals();
}
let started = Instant::now();
// A dedicated server writes its slow ticks to the log with the spans
// they spent their time in (`HORIZON_SPIKES`'s line; the variable still
// sets another threshold), so a lag spike players felt can be traced
// from the log alone. Timing the spans costs microseconds a tick.
if options.spikes.is_none()
&& options.execution_role == execution_role::ExecutionRole::DedicatedServer
{
options.spikes = Some(dedicated::SLOW_TICK_MS);
}
horizon_client::spans::enable(options.spikes.is_some());
let vanilla_root = options.resources.clone();
if let Some(error) = missing_resources(&vanilla_root) {
+10 -3
View File
@@ -500,7 +500,7 @@ impl Game<'_> {
json!({
"dialog": self.dialog_status(),
"screen": self.screen_label(),
"menu": (self.title.is_some() || self.split_profiles_open()).then(|| self.title_value()),
"menu": (self.title.is_some() || self.split_profiles_open() || self.split_setup_open()).then(|| self.title_value()),
"world": (self.title.is_none()).then(|| match &self.integrated {
Some(server) => server.world_name.clone(),
None => self.level.name.clone(),
@@ -535,11 +535,18 @@ impl Game<'_> {
self.screen == Screen::Options
&& self.ui.options.page() == horizon_client::options_ui::Page::SplitProfiles
}
fn split_setup_open(&self) -> bool {
self.screen == Screen::Options
&& self.ui.options.page() == horizon_client::options_ui::Page::SplitSetup
}
fn title_value(&self) -> Value {
use horizon_client::title::Mode;
if self.split_profiles_open() {
return self.ui.options.split_profiles.state();
}
if self.split_setup_open() {
return json!({ "mode": "splitscreen-setup", "status": self.ui.options.split_setup.status() });
}
let Some(title) = &self.title else {
return Value::Null;
};
@@ -742,7 +749,7 @@ impl Game<'_> {
v["chat_input"] = json!(chat.input.value);
v["chat_suggestions"] = json!(chat.suggestions_visible());
}
if self.title.is_some() || self.split_profiles_open() {
if self.title.is_some() || self.split_profiles_open() || self.split_setup_open() {
v["menu"] = self.title_value();
}
if self.screen == Screen::Inventory && self.title.is_none() {
@@ -1705,7 +1712,7 @@ impl Game<'_> {
if let Some(server) = &self.integrated {
let chat = server
.call(move |g| g.owner_console_command(&command))
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
return ok(json!({ "ok": true, "chat": chat }));
}
if self.remote.is_some() {
+139 -28
View File
@@ -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.");
}
}
@@ -600,6 +600,23 @@ impl Game<'_> {
if held.is_none() && current.is_none() {
return false;
}
// A server's mods hear the swap before it happens, and may stop it.
// A stack of several on an occupied slot goes nowhere (below).
let moves = held
.as_ref()
.is_none_or(|h| h.count <= 1 || current.is_none());
if moves
&& target.is_some()
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
let stand = self.armor_stands[i].id as i32;
let slot_name = ["mainhand", "offhand", "feet", "legs", "chest", "head"][slot];
let mut cancel = false;
self.native_armor_stand_manipulate(player, &name, stand, slot_name, &mut cancel);
if cancel {
return true;
}
}
// ArmorStand.swapItem.
match held {
// A player with infinite materials puts a copy of one on an empty
+1 -1
View File
@@ -76,7 +76,7 @@ impl Game<'_> {
let words: Vec<&str> = owned.iter().map(String::as_str).collect();
g.in_owner_level(|g| g.block_command(&words).map_err(|e| e.to_string()))
})
.ok_or("the integrated server stopped")?
.ok_or(super::integrated::NO_ANSWER)?
.map_err(Into::into);
}
// Another server's world changes only through its own commands, which
+1 -1
View File
@@ -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
+2
View File
@@ -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
@@ -1483,6 +1483,10 @@ impl Game<'_> {
if let Some(conn) = self.lan_acting {
self.server_trips.retain(|trip| !trip.is(conn));
}
let from = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
if let Some(p) = self.player.as_mut() {
p.position = v;
p.velocity = Vec3::new(0.0, 0.0, 0.0);
@@ -1494,6 +1498,24 @@ impl Game<'_> {
self.yaw = yaw;
self.pitch = pitch.clamp(-90.0, 90.0);
}
// A server player's teleport is told to the mods once their
// move is in the hub (`leave_guest`).
if let (Some(conn), Some(uuid), Some(name), Some(from)) = (
self.lan_acting,
self.lan_acting_uuid,
self.lan_acting_name.clone(),
from,
) {
self.teleport_notes
.push(super::server_travel::TeleportNote {
conn,
uuid,
name,
from,
to: pos,
dimension: self.dim.id().to_owned(),
});
}
}
Target::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
+7 -7
View File
@@ -394,7 +394,7 @@ impl Game<'_> {
json!({ "game_time": g.world.game_time, "projectiles": g.projectiles_status() })
})
})
.ok_or("the integrated server stopped")?
.ok_or(super::integrated::NO_ANSWER)?
} else {
json!({ "game_time": self.world.game_time, "projectiles": self.projectiles_status() })
};
@@ -410,7 +410,7 @@ impl Game<'_> {
if let Some(server) = &self.integrated {
server
.call(move |g| g.agent.hold_server = held)
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
} else if self.remote.is_some() {
return Err("server-hold requires the world's host".into());
} else {
@@ -674,7 +674,7 @@ impl Game<'_> {
let command = format!("give @s {item} {count}");
server
.call(move |g| g.owner_console_command(&command))
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
return Ok(Some(json!({ "ok": true, "given": count })));
}
let max = info.max_stack;
@@ -727,7 +727,7 @@ impl Game<'_> {
if let Some(server) = &self.integrated {
server
.call(move |g| g.world.day_time = time)
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
}
ok()
}
@@ -746,7 +746,7 @@ impl Game<'_> {
let center = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
let value = server
.call(move |g| g.in_owner_level(|g| g.control_mobs_at(radius, center)))
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
return Ok(Some(value));
}
Ok(Some(self.control_mobs(radius)))
@@ -756,7 +756,7 @@ impl Game<'_> {
if let Some(server) = &self.integrated {
let value = server
.call(|g| g.control_hurt_log())
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
return Ok(Some(value));
}
Ok(Some(self.control_hurt_log()))
@@ -986,7 +986,7 @@ impl Game<'_> {
if let Some(server) = &self.integrated {
server
.call(move |g| g.control_save(all).map_err(|e| e.to_string()))
.ok_or("the integrated server stopped")??;
.ok_or(super::integrated::NO_ANSWER)??;
} else if self.remote.is_none() {
self.begin_control_save(all, reply.clone())?;
return Ok(None);
+124 -80
View File
@@ -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);
}
}
@@ -3918,9 +3897,11 @@ impl Game<'_> {
ArrowEvent::BubbleColumnParticles { pos } => self.bubble_column_particles(pos),
ArrowEvent::Block { pos, reaction } => match reaction {
BlockReaction::PrimeTnt => {
self.spawn_primed_tnt(pos, false, None, None);
self.with_level(|b, level, ticks| b.remove_block(level, ticks, pos, false));
let _ = self.process_level_events();
if self.tnt_prime_allowed(pos) {
self.spawn_primed_tnt(pos, false, None, None);
self.with_level(|b, level, ticks| b.remove_block(level, ticks, pos, false));
let _ = self.process_level_events();
}
}
BlockReaction::Light(lit) => {
let _ = self.level_set([pos.x, pos.y, pos.z], lit);
@@ -6119,6 +6100,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 +6255,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 +6351,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)
+44 -3
View File
@@ -257,6 +257,16 @@ fn forwarding_secret(online: bool) -> Result<Option<Vec<u8>>> {
Ok(Some(secret))
}
/// `MinecraftServer.runServer`'s overload warning for a server `behind` its
/// schedule, in milliseconds and in ticks of `tick`.
fn overload_warning(behind: Duration, tick: Duration) -> String {
format!(
"Can't keep up! Is the server overloaded? Running {}ms or {} ticks behind",
behind.as_millis(),
behind.as_nanos() / tick.as_nanos().max(1)
)
}
/// Log4j's console pattern: `[HH:mm:ss] [thread/LEVEL]: message`.
pub(super) fn log(level: &str, message: &str) {
log_from("Server thread", level, message);
@@ -502,6 +512,13 @@ const LEGACY_PRESET_NAMES: [(&str, horizon_client::save::WorldPreset); 2] = [
/// server.properties' line for `server.toml`'s `max_view_distance` (horizon-server writes it).
const FAR_PROPERTY: &str = "horizon-max-view-distance";
/// A dedicated server logs a tick slower than this, in milliseconds, with
/// its spans, unless `HORIZON_SPIKES` names another threshold: five ticks'
/// worth, a stall players feel as rubber-banding.
pub(super) const SLOW_TICK_MS: f32 = 250.0;
/// `MinecraftServer.OVERLOADED_WARNING_INTERVAL_NANOS` plus 100 ticks: the
/// "Can't keep up!" warning comes at most this often.
const OVERLOAD_WARNING_INTERVAL: Duration = Duration::from_secs(15);
/// server.properties' line for `server.toml`'s `admin_feedback_to_ops` (horizon-server writes it).
const ADMIN_FEEDBACK_PROPERTY: &str = "horizon-admin-feedback-to-ops";
@@ -618,6 +635,8 @@ struct ServerState {
/// View and simulation distance, and the player limit, for the report.
view: (u32, u32),
max_players: usize,
/// The mutable `gamemode` server property (`DedicatedServer.gameMode`).
game_mode: i32,
}
/// The dedicated-only commands' replies vanilla sends with `broadcast`
@@ -985,6 +1004,7 @@ impl Game<'_> {
broadcast_console: props.broadcast_console_to_ops,
broadcast_rcon: props.broadcast_rcon_to_ops,
op_level: props.op_permission_level,
game_mode: props.game_mode,
report: None,
view: (
props.view_distance.clamp(2, 32) as u32,
@@ -1000,6 +1020,7 @@ impl Game<'_> {
self.fns.permission = Some(props.function_permission_level.clamp(0, 4) as u8);
let watchdog = Watchdog::start(props.max_tick_time);
let mut braked = Instant::now();
let mut overload_warned: Option<Instant> = None;
while !state.stop {
let tick_start = Instant::now();
if let Some(watchdog) = &watchdog {
@@ -1089,7 +1110,17 @@ impl Game<'_> {
far.brake(mspt, host.hub.config.view_distance);
}
state.ticked(Instant::now());
self.wait_for_next_server_tick(&mut next, paused);
// MinecraftServer.runServer: a server more than a second's ticks
// behind skips them and says so, at most every 15 seconds.
if let Some(behind) = self.wait_for_next_server_tick(&mut next, paused)
&& overload_warned.is_none_or(|at| at.elapsed() >= OVERLOAD_WARNING_INTERVAL)
{
overload_warned = Some(Instant::now());
log(
"WARN",
&overload_warning(behind, self.clock.tick_duration()),
);
}
}
if let Some(watchdog) = &watchdog {
watchdog.running.store(false, Ordering::Relaxed);
@@ -1205,7 +1236,7 @@ impl Game<'_> {
.iter()
.map(|e| (e.user.id, e.level as u8))
.collect();
host.hub.resend_commands();
self.resend_commands_filtered();
}
/// A player on the server, by name.
@@ -1975,7 +2006,7 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
#[cfg(test)]
mod tests {
use super::{capture_log, log, log_from, panic_message};
use super::{capture_log, log, log_from, overload_warning, panic_message};
use std::{
fs,
io::{self, BufRead, BufReader, Read, Write},
@@ -1983,6 +2014,16 @@ mod tests {
time::{SystemTime, UNIX_EPOCH},
};
/// Vanilla's words, with how far behind the server is in milliseconds and in whole ticks.
#[test]
fn the_overload_warning_reads_as_vanilla_s() {
let tick = std::time::Duration::from_millis(50);
assert_eq!(
overload_warning(std::time::Duration::from_millis(13_027), tick),
"Can't keep up! Is the server overloaded? Running 13027ms or 260 ticks behind"
);
}
/// A `panic!("literal")` carries a `&str`, a formatted one a `String`; anything else is
/// named as unknown, so the crash line is never empty.
#[test]
@@ -3,7 +3,7 @@
//! `AllowlistService`, `BanlistService`, `IpBanlistService` and `OperatorService` on the running server's lists. Players are found as
//! `MinecraftPlayerListService.getUser` finds them, each change is logged as `JsonRpcLogger` logs it, and the
//! players a change shuts out are disconnected. The supervisor answers with the lists, from their files.
use super::{ServerState, log};
use super::{GAME_MODES, ServerState, log};
use crate::game::Game;
use horizon_client::{
command::vanilla::DIFFICULTIES,
@@ -70,6 +70,25 @@ impl Game<'_> {
self.store_property("motd", value.to_owned());
return json!({ "value": value });
}
"serversettings/game_mode" => {
return json!({ "value": GAME_MODES[state.game_mode as usize] });
}
"serversettings/game_mode/set" => {
let Some(value) = body["value"].as_str() else {
return json!({ "error": "game mode must be a string" });
};
let Some(index) = GAME_MODES.iter().position(|name| *name == value) else {
return json!({ "error": format!("unknown game mode {value:?}") });
};
rpc(format!(
"Update game mode from '{}' to '{}'",
GAME_MODES[state.game_mode as usize], value
));
self.store_property("gamemode", value.to_owned());
state.game_mode = index as i32;
self.set_default_game_mode(state.game_mode);
return json!({ "value": GAME_MODES[state.game_mode as usize] });
}
"serversettings/difficulty" => {
return json!({ "value": DIFFICULTIES[self.level.difficulty_id() as usize] });
}
+6 -2
View File
@@ -1374,7 +1374,7 @@ impl Game<'_> {
None,
);
} else if &*name == "minecraft:tnt" {
if self.ui.rules.bool("tnt_explodes") {
if self.ui.rules.bool("tnt_explodes") && self.tnt_prime_allowed(target_pos) {
self.spawn_primed_tnt(target_pos, false, None, None);
self.with_level(|b, level, ticks| b.remove_block(level, ticks, target_pos, false));
self.process_level_events()?;
@@ -1435,9 +1435,13 @@ impl Game<'_> {
if self.sulfur_cube_swallow(front, &stack) {
return (shrink(stack), optional_event("tnt"));
}
let target = Self::pos_of(front);
if !self.tnt_prime_allowed(target) {
set_success("tnt", false);
return (Some(stack), FAIL);
}
set_success("tnt", true);
// new PrimedTnt(level, x + 0.5, y, z + 0.5, null), its sound and ENTITY_PLACE.
let target = Self::pos_of(front);
let rot = self.world.random.next_double() * f64::from(std::f32::consts::TAU);
self.add_primed_tnt(target, rot, 80, None, None);
self.audio.play(
+308
View File
@@ -0,0 +1,308 @@
//! `Entity`: what every kind in the game's entity lists answers to, so a system
//! can treat them alike and only write down where vanilla's kinds differ.
//!
//! Default methods follow vanilla's `Entity`; a kind overrides one only where
//! its vanilla class does. [`EntityHandle`] names one entity by list and index,
//! so a system can walk handles and reach each entity after other borrows of
//! `Game` end. The walk allocates nothing and dispatch is static: hot paths pay
//! no vtable. A new kind is one line in `entity_lists!` and one `impl Entity`.
use super::{FallingBlock, Game};
use super::{
boats::Boat, fireworks::Rocket, minecarts::Minecart, tnt::PrimedTnt, trident::ThrownTrident,
};
use horizon_client::entities::{ArmorStand, ExperienceOrb, ItemEntity};
use horizon_client::mobs::Mob;
use horizon_client::projectiles::Projectile;
use horizon_core::geometry::{Aabb, Direction, Vec3};
use horizon_core::position::BlockPos;
use horizon_world::{level::data::PushReaction, piston_push};
pub(super) trait Entity {
fn position(&self) -> Vec3;
fn position_mut(&mut self) -> &mut Vec3;
fn velocity_mut(&mut self) -> &mut Vec3;
fn bounds(&self) -> Aabb;
fn on_ground(&self) -> bool {
false
}
/// `Entity.maxUpStep`.
fn get_step_height(&self) -> f32 {
0.0
}
fn is_alive(&self) -> bool {
true
}
/// `Entity.isSupportedBy`.
fn is_supported_by(&self, _pos: BlockPos) -> bool {
false
}
/// `Entity.getPistonPushReaction`.
fn get_piston_push_reaction(&self) -> PushReaction {
PushReaction::PushPull
}
/// `Entity.move(MoverType.PISTON, …)` once the movement is resolved.
fn move_by_piston(&mut self, movement: Vec3) {
let position = self.position_mut();
*position = position.add_vector(movement);
}
/// `PistonMovingBlockEntity.causeBounce`: a slime block sets the velocity
/// along the movement axis.
fn piston_slime_bounce(&mut self, movement: Direction) {
let velocity = self.velocity_mut();
*velocity = piston_push::bounce_velocity(movement, *velocity);
}
}
/// The fields every kind stores under the same names.
macro_rules! stored_motion {
() => {
fn position(&self) -> Vec3 {
self.position
}
fn position_mut(&mut self) -> &mut Vec3 {
&mut self.position
}
fn velocity_mut(&mut self) -> &mut Vec3 {
&mut self.velocity
}
};
}
/// The game's lists of entities, in the order systems walk them. Hands them to
/// `$then`, so one list drives the handles and every typed dispatch.
macro_rules! entity_lists {
($then:ident, $($args:tt)*) => {
$crate::game::entity::$then! {
($($args)*)
Mob: mobs,
Item: dropped,
Boat: boats,
Cart: carts,
ArmorStand: armor_stands,
Tnt: tnts,
Falling: falling,
Orb: orbs,
Projectile: creatures.projectiles,
Rocket: rockets,
Trident: tridents,
}
};
}
pub(crate) use entity_lists;
macro_rules! define_entity_handles {
(() $($kind:ident: $($list:ident).+,)*) => {
/// One entity, by list and index.
#[derive(Clone, Copy, Hash, PartialEq, Eq)]
pub(super) enum EntityHandle {
$($kind(usize),)*
}
impl Game<'_> {
/// Every entity's handle: lengths read now, nothing allocated, and
/// no borrow of `Game` held, so the caller can change it mid-walk.
pub(super) fn entity_handles(&self) -> impl Iterator<Item = EntityHandle> + use<> {
std::iter::empty()
$(.chain((0..self$(.$list)+.len()).map(EntityHandle::$kind)))*
}
}
};
}
pub(crate) use define_entity_handles;
entity_lists!(define_entity_handles,);
macro_rules! entity_borrow {
(shared, $place:expr) => {
&$place
};
(exclusive, $place:expr) => {
&mut $place
};
}
pub(crate) use entity_borrow;
macro_rules! match_entity {
(($borrow:ident, $entity:ident, $game:expr, $handle:expr, $body:expr)
$($kind:ident: $($list:ident).+,)*) => {
match $handle {
$($crate::game::entity::EntityHandle::$kind(i) => {
let $entity = $crate::game::entity::entity_borrow!($borrow, $game$(.$list)+[i]);
$body
})*
}
};
}
pub(crate) use match_entity;
/// Runs `$body` with `$entity` bound to the handle's entity, as its own type:
/// one match, each arm compiled for its kind, so nothing goes through a vtable.
macro_rules! with_entity {
($game:expr, $handle:expr, $entity:ident => $body:expr) => {
$crate::game::entity::entity_lists!(match_entity, shared, $entity, $game, $handle, $body)
};
}
pub(super) use with_entity;
/// [`with_entity`], with the entity mutable.
macro_rules! with_entity_mut {
($game:expr, $handle:expr, $entity:ident => $body:expr) => {
$crate::game::entity::entity_lists!(match_entity, exclusive, $entity, $game, $handle, $body)
};
}
pub(super) use with_entity_mut;
impl Entity for Mob {
stored_motion!();
fn bounds(&self) -> Aabb {
Mob::bounds(self)
}
fn on_ground(&self) -> bool {
self.on_ground
}
fn get_step_height(&self) -> f32 {
self.kind.step_height as f32
}
fn is_alive(&self) -> bool {
!self.removed && self.alive()
}
fn is_supported_by(&self, pos: BlockPos) -> bool {
self.supporting == Some([pos.x, pos.y, pos.z])
}
}
impl Entity for ItemEntity {
stored_motion!();
fn bounds(&self) -> Aabb {
ItemEntity::bounds(self)
}
fn on_ground(&self) -> bool {
self.on_ground
}
fn move_by_piston(&mut self, movement: Vec3) {
self.position = self.position.add_vector(movement);
// Entity.move(MoverType.PISTON): onGround only if the push was down.
if movement.y != 0.0 {
self.on_ground = movement.y < 0.0;
}
}
fn piston_slime_bounce(&mut self, movement: Direction) {
ItemEntity::piston_slime_bounce(self, movement);
}
}
impl Entity for ExperienceOrb {
stored_motion!();
fn bounds(&self) -> Aabb {
ExperienceOrb::bounds(self)
}
fn on_ground(&self) -> bool {
self.on_ground
}
fn move_by_piston(&mut self, movement: Vec3) {
self.position = self.position.add_vector(movement);
if movement.y != 0.0 {
self.on_ground = movement.y < 0.0;
}
}
fn piston_slime_bounce(&mut self, movement: Direction) {
self.velocity = piston_push::bounce_velocity(movement, self.velocity);
self.on_ground = false;
}
}
impl Entity for Boat {
stored_motion!();
fn bounds(&self) -> Aabb {
Boat::bounds(self)
}
fn on_ground(&self) -> bool {
self.on_ground
}
}
impl Entity for Minecart {
stored_motion!();
fn bounds(&self) -> Aabb {
Minecart::bounds(self)
}
fn on_ground(&self) -> bool {
self.on_ground
}
}
impl Entity for ArmorStand {
stored_motion!();
fn bounds(&self) -> Aabb {
ArmorStand::bounds(self)
}
fn on_ground(&self) -> bool {
self.on_ground
}
/// `ArmorStand.getPistonPushReaction`: a marker is ignored.
fn get_piston_push_reaction(&self) -> PushReaction {
if self.marker {
PushReaction::IgnoreEntity
} else {
PushReaction::PushPull
}
}
}
impl Entity for PrimedTnt {
stored_motion!();
fn bounds(&self) -> Aabb {
PrimedTnt::bounds(self)
}
fn on_ground(&self) -> bool {
self.on_ground
}
fn piston_slime_bounce(&mut self, movement: Direction) {
self.velocity = piston_push::bounce_velocity(movement, self.velocity);
// Leave the ground so the next tick does not apply the on-ground
// Y * -0.5 friction that cancels the launch.
self.on_ground = false;
}
}
impl Entity for FallingBlock {
stored_motion!();
fn bounds(&self) -> Aabb {
horizon_client::falling_block::bounds(self.position)
}
}
impl Entity for Projectile {
stored_motion!();
fn bounds(&self) -> Aabb {
Projectile::bounds(self)
}
fn move_by_piston(&mut self, movement: Vec3) {
self.position = self.position.add_vector(movement);
self.needs_sync = true;
}
fn piston_slime_bounce(&mut self, movement: Direction) {
self.velocity = piston_push::bounce_velocity(movement, self.velocity);
self.needs_sync = true;
}
}
impl Entity for Rocket {
stored_motion!();
fn bounds(&self) -> Aabb {
Rocket::bounds(self)
}
}
impl Entity for ThrownTrident {
stored_motion!();
fn bounds(&self) -> Aabb {
let p = self.position;
Aabb::new(
Vec3::new(p.x - 0.25, p.y, p.z - 0.25),
Vec3::new(p.x + 0.25, p.y + 0.5, p.z + 0.25),
)
}
}
@@ -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]
+19
View File
@@ -125,6 +125,17 @@ impl Game<'_> {
.iter()
.any(|o| o.id != f.id && o.facing == f.facing && overlaps(o.bounds()))
}
/// Asks the mods whether the acting guest may place a hanging entity
/// of type `kind` at `pos` (`native_hanging_place`): true when one
/// cancelled it. A game's own player is not asked.
pub(super) fn hanging_place_cancelled(&mut self, kind: &str, pos: [i32; 3]) -> bool {
let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) else {
return false;
};
let mut cancel = false;
self.native_hanging_place(uuid, &name, kind, pos, &mut cancel);
cancel
}
/// `ItemFrameItem.useOn`.
pub(super) fn place_item_frame(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
let glow = match &*stack.item {
@@ -162,6 +173,14 @@ impl Game<'_> {
if !self.frame_survives(&frame) {
return false;
}
let kind = if glow {
"minecraft:glow_item_frame"
} else {
"minecraft:item_frame"
};
if self.hanging_place_cancelled(kind, pos) {
return true;
}
self.next_entity += 1;
self.audio
.play(&sound(&frame, "place"), Some(center(&frame)), 1.0, 1.0);
+97 -8
View File
@@ -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 {
+26 -3
View File
@@ -32,12 +32,19 @@ use std::{
sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicI32, AtomicU16, AtomicU32, Ordering},
mpsc::{Receiver, Sender, channel, sync_channel},
mpsc::{Receiver, RecvTimeoutError, Sender, channel, sync_channel},
},
thread::JoinHandle,
time::{Duration, Instant},
};
/// How long the window waits for the server thread to answer a `call`.
const CALL_TIMEOUT: Duration = Duration::from_secs(10);
/// The error of a `call` without an answer: the server stopped, or it was
/// busy past `CALL_TIMEOUT` (the log says which).
pub(super) const NO_ANSWER: &str =
"the integrated server stopped or did not answer within 10 s (the game log says which)";
/// `LevelLoadProgressTracker.EXPECTED_PLAYER_CHUNKS`: the 7x7 chunks around the spawn.
const PLAYER_CHUNKS: usize = 49;
@@ -177,7 +184,9 @@ impl Integrated {
Some(self.flags.published.load(Ordering::Relaxed)).filter(|&p| p != 0)
}
/// `executeBlocking`: run `f` on the server thread between two ticks and
/// wait for its answer; `None` once the server has stopped.
/// wait for its answer; `None` once the server has stopped, or when it has
/// not answered within `CALL_TIMEOUT` (`f` still runs there later). The log
/// tells the two apart.
pub fn call<T: Send + 'static>(
&self,
f: impl FnOnce(&mut Game<'_>) -> T + Send + 'static,
@@ -188,7 +197,21 @@ impl Integrated {
let _ = tx.send(f(g));
}))
.ok()?;
rx.recv_timeout(Duration::from_secs(10)).ok()
match rx.recv_timeout(CALL_TIMEOUT) {
Ok(answer) => Some(answer),
Err(RecvTimeoutError::Timeout) => {
log::warn!(
"Horizon: the server thread did not answer within {} s (running: {}, \
autosaving: {}, smoothed tick {:.1} ms)",
CALL_TIMEOUT.as_secs(),
!self.stopped(),
self.is_saving(),
self.smoothed_tick_ms()
);
None
}
Err(RecvTimeoutError::Disconnected) => None,
}
}
/// `IntegratedServer.halt(true)`: returns once the world is saved and let go.
pub fn stop(mut self) -> Result<()> {
+11 -1
View File
@@ -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,
+26 -3
View File
@@ -1133,7 +1133,7 @@ impl Game<'_> {
// The hub is the server thread's.
let status = server
.call(|g| g.lan_status())
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
return Ok(Some(status));
}
match words.get(1).copied() {
@@ -1225,7 +1225,7 @@ impl Game<'_> {
// ShareToLanScreen: IntegratedServer.publishServer.
server
.call(move |g| g.publish(settings))
.ok_or("the integrated server stopped")??
.ok_or(super::integrated::NO_ANSWER)??
} else if self.serving() {
return self.publish(settings).map(drop).map_err(Into::into);
} else {
@@ -1708,8 +1708,8 @@ impl Game<'_> {
host.hub.config.guest_commands = effective;
host.hub.config.force_game_mode = force;
enforce_guest_game_mode(host, &self.world, self.collision);
host.hub.resend_commands();
}
self.resend_commands_filtered();
}
pub(super) fn lan_port(&self) -> Option<u16> {
@@ -1884,6 +1884,27 @@ impl Game<'_> {
}
}
HostEvent::Joined(conn) => {
// The mods may refuse the login before the player is placed.
if let Some((uuid, name)) =
host.hub.guest(conn).map(|g| (g.uuid, g.name.clone()))
{
let mut cancel = false;
let mut reason = None;
self.native_player_login(uuid, &name, &mut cancel, &mut reason);
if cancel {
let reason = match reason {
Some(text) => {
horizon_client::lan::data::component_nbt(&text.component())
}
None => horizon_protocol::text::translatable(
"multiplayer.disconnect.generic",
&[],
),
};
host.hub.kick(conn, reason);
continue;
}
}
self.lan_guest_joined(&mut host, conn, state, entities, spawn);
if self.options.dedicated.is_some()
&& let Some(g) = host.hub.guest(conn)
@@ -2381,6 +2402,8 @@ impl Game<'_> {
g.dimension = in_place;
}
self.load_guest_records(host, conn);
// The mods say which commands the newcomer's tree keeps.
self.native_filter_commands(host, Some(conn));
let mut theirs = States::default();
let entities = if elsewhere {
if let Err(e) = self.enter_level(Some(host), level) {
+150 -4
View File
@@ -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();
@@ -654,6 +663,28 @@ impl Game<'_> {
}
// A totem that saved the guest this turn (its hurt set it off).
let totem = self.apply_totem();
// The mods hear the food level the turn changed (eating, hunger,
// starving...), once for the change it made as a whole, with the
// guest's state still swapped in; they may change or undo it before it
// reaches the guest.
if let Some(now) = self.player.as_ref().map(|p| p.health.food.food)
&& let Some(g) = host.hub.guests.get(swap.i)
&& g.food != now
{
let (uuid, name, from, saturation) = (g.uuid, g.name.clone(), g.food, g.saturation);
let (mut to, mut cancel) = (now, false);
self.native_food_level_changed(uuid, &name, from, &mut to, &mut cancel);
if let Some(p) = self.player.as_mut() {
if cancel {
p.health.food.food = from;
p.health.food.saturation = saturation;
} else if to != now {
p.health.food.food = to.clamp(0, 20);
p.health.food.saturation =
p.health.food.saturation.min(p.health.food.food as f32);
}
}
}
let GuestSwap {
conn,
i,
@@ -1016,6 +1047,8 @@ impl Game<'_> {
if let Some(mob) = guest_mount {
self.lan_open_mount(host, conn, mob);
}
// The turn's teleports are in the hub now: the mods hear them.
self.flush_teleport_notes(host);
}
/// `ServerLevel.broadcastDamageEvent` for a guest that took an unblocked hit.
@@ -1089,6 +1122,31 @@ impl Game<'_> {
self.creative = self.mode == GameMode::Creative;
}
/// Whether the mods let the guest open the menu `kind` (`native_container_opened`),
/// heard with the guest's state swapped in. Asked before the menu that
/// was open closes.
pub(super) fn guest_may_open(
&mut self,
host: &mut LanHost,
conn: u32,
kind: &str,
pos: Option<[i32; 3]>,
) -> bool {
let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) else {
return true;
};
let mut cancel = false;
let mut hook =
|game: &mut Self| game.native_container_opened(uuid, &name, kind, pos, &mut cancel);
// The guest whose turn it is is in place already.
if self.lan_acting == Some(conn) {
hook(self);
} else {
self.as_guest(host, conn, hook);
}
!cancel
}
/// Hand a container the guest opened over (ServerPlayer.openMenu);
/// false when it is not one a guest can open.
fn lan_open_container(&mut self, host: &mut LanHost, conn: u32, pos: (i32, i32, i32)) -> bool {
@@ -1237,6 +1295,10 @@ impl Game<'_> {
if !horizon_client::containers::menu_reaches(brand, menu) {
return false;
}
// The mods may stop the menu; the click is spent.
if !self.guest_may_open(host, conn, menu, Some([pos.0, pos.1, pos.2])) {
return true;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let hidden = self.native_hidden_slots(pos, items.len());
@@ -1285,6 +1347,10 @@ impl Game<'_> {
else {
return false;
};
// The mods may stop the menu; the click is spent.
if !self.guest_may_open(host, conn, menu, None) {
return true;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
host.hub
@@ -1775,6 +1841,25 @@ impl Game<'_> {
// SWAP_ITEM_WITH_OFFHAND: the hands swap and the item in use stops
// (ServerGamePacketListenerImpl.handlePlayerAction), not for spectators.
7 if !spectator => {
// The mods hear the swap first, and may stop it: the
// client, which swapped at once, gets its slots back.
let (uuid, name) = {
let g = &host.hub.guests[gi];
(g.uuid, g.name.clone())
};
// With the player's state swapped in, so that the helpers
// reach them.
let cancel = self
.as_guest(host, conn, |g| {
let mut cancel = false;
g.native_player_swap_hands(uuid, &name, &mut cancel);
cancel
})
.unwrap_or(false);
if cancel {
host.hub.send_inventory(gi);
return;
}
let g = &mut host.hub.guests[gi];
let slot = g.selected;
std::mem::swap(&mut g.inventory[slot], &mut g.offhand);
@@ -2025,6 +2110,9 @@ impl Game<'_> {
if !self.creative_stack_valid(host, gi, &stack) {
return;
}
if !self.guest_may_set_creative_slot(host, conn, i32::from(p.slot), Some(&stack)) {
return;
}
let play = host.play.entry(conn).or_default();
if play.creative_drops >= 1480 {
eprintln!(
@@ -2049,6 +2137,12 @@ impl Game<'_> {
{
return;
}
if !self.guest_may_set_creative_slot(host, conn, i32::from(p.slot), item.as_ref()) {
// The creative client already holds the stack it sent: treat that as
// what it was told, so the next broadcast sends the real slot back.
host.hub.guests[gi].menu_sent[p.slot as usize] = item;
return;
}
let slot = p.slot as usize;
let g = &mut host.hub.guests[gi];
let old = g.menu_slot(slot);
@@ -2087,6 +2181,22 @@ impl Game<'_> {
});
}
S::PlayerCommand(p) if p.action == 6 => self.lan_start_gliding(host, conn),
// ServerboundResourcePackPacket in play: the server has nothing to
// do with it (a declined required pack is handled on the connection).
S::ResourcePack(r) => {
let (uuid, name) = (host.hub.guests[gi].uuid, host.hub.guests[gi].name.clone());
self.as_guest(host, conn, |g| {
g.native_resource_pack_status(uuid, &name, r.action);
});
}
// START_SPRINTING and STOP_SPRINTING: the hub has set the sprint
// and sends the packet on when it changed it.
S::PlayerCommand(p) if matches!(p.action, 1 | 2) => {
let (uuid, name) = (host.hub.guests[gi].uuid, host.hub.guests[gi].name.clone());
self.as_guest(host, conn, |g| {
g.native_player_sprint(uuid, &name, p.action == 1);
});
}
S::ContainerButtonClick(p) => {
self.lan_station_button(host, conn, p.container, p.button);
}
@@ -2443,6 +2553,32 @@ impl Game<'_> {
}
}
/// Whether the mods let a creative player set `slot` to `stack`
/// (`native_creative_slot`), heard with the guest's state swapped in.
fn guest_may_set_creative_slot(
&mut self,
host: &mut LanHost,
conn: u32,
slot: i32,
stack: Option<&ItemStack>,
) -> bool {
let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) else {
return true;
};
let (item, count) = stack.map_or(("minecraft:air", 0), |s| (&*s.item, s.count as i32));
let mut cancel = false;
let mut hook = |game: &mut Self| {
game.native_creative_slot(uuid, &name, slot, item, count, &mut cancel)
};
// The guest whose turn it is is in place already.
if self.lan_acting == Some(conn) {
hook(self);
} else {
self.as_guest(host, conn, hook);
}
!cancel
}
/// What ServerboundSetCreativeModeSlotPacket asks of a stack. Its codec
/// (`ItemStack.validatedStreamCodec`) encodes the stack with `ItemStack.CODEC`, whose count is
/// 1 to 99: more fails the packet and disconnects the player, as vanilla's decoder error does.
@@ -2516,8 +2652,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 +3385,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 +3986,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 +4102,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,
@@ -469,6 +469,9 @@ impl Game<'_> {
/// The guest opened a crafting table (`CraftingTableBlock.getMenuProvider`).
pub(super) fn lan_open_crafting(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
if !self.guest_may_open(host, conn, "minecraft:crafting", Some(pos)) {
return;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let title = horizon_protocol::text::translatable("container.crafting", &[]);
@@ -31,6 +31,9 @@ impl Game<'_> {
return;
};
let columns = super::mount_menu::mount_columns(&self.mobs[index]);
if !self.guest_may_open(host, conn, "minecraft:horse_inventory", None) {
return;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let Some(window) = host.hub.open_mount_window(conn, columns as i32, mob as i32) else {
@@ -69,6 +69,10 @@ impl Game<'_> {
let Some((menu_type, kind, _)) = menu_of(station.kind) else {
return false;
};
// The mods may stop the menu; the click is spent.
if !self.guest_may_open(host, conn, menu_type, Some(station.pos)) {
return true;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let title = horizon_protocol::text::translatable(station.kind.title_key(), &[]);
@@ -149,6 +153,9 @@ impl Game<'_> {
/// `LecternBlock.openScreen` for a guest: `minecraft:lectern` over the
/// block entity, with its book and page.
pub(super) fn lan_open_lectern(&mut self, host: &mut LanHost, conn: u32, key: (i32, i32, i32)) {
if !self.guest_may_open(host, conn, "minecraft:lectern", Some([key.0, key.1, key.2])) {
return;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let title = horizon_protocol::text::translatable("container.lectern", &[]);
@@ -57,6 +57,10 @@ impl Game<'_> {
state.session.update_special_prices();
}
let title = self.merchant_display_name(&state);
if !self.guest_may_open(host, conn, "minecraft:merchant", None) {
self.merchant_closed(&mut state);
return;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let Some(window) = host.hub.open_merchant_window(conn, title) else {
+110 -1
View File
@@ -9,7 +9,7 @@
//! here with the rider's input (`ServerPlayer.getLastClientMoveIntent`).
//! Sneaking gets out (`Player.rideTick`: `wantsToStopRiding`), and the client
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
use super::{Game, Keys, lan::LanHost, minecarts::SEAT};
use super::{Dim, Game, Keys, lan::LanHost, minecarts::SEAT};
use horizon_client::collisions::SceneCollisions;
use horizon_client::lan::hub::no_blocks_in;
use horizon_client::mobs::equine;
@@ -38,7 +38,116 @@ fn corners(b: Aabb) -> ([f64; 3], [f64; 3]) {
([lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
}
/// A boat or minecart a server player rode, followed for
/// `native_vehicle_moved`.
pub(super) struct WatchedVehicle {
id: u32,
dim: Dim,
/// The server player it carries now.
rider: Option<u128>,
/// Where the hook last heard of it.
at: [f64; 3],
/// Ticks it has stood still.
still: u32,
}
impl Game<'_> {
/// Where the boat or minecart `id` of this level is.
fn watched_vehicle_position(&self, id: u32) -> Option<[f64; 3]> {
let at = |v: Vec3| [v.x, v.y, v.z];
self.boats
.iter()
.find(|b| b.id == id)
.map(|b| at(b.position))
.or_else(|| {
self.carts
.iter()
.find(|c| c.id == id)
.map(|c| at(c.position))
})
}
/// `VehicleMoveEvent` for the boats and minecarts server players ride
/// (and left less than ten seconds ago): once a tick, after they moved.
pub(super) fn tick_vehicle_watch(&mut self) {
let riders: Vec<(u32, u32, u128)> = self.lan.as_ref().map_or_else(Vec::new, |h| {
h.hub
.guests
.iter()
.filter_map(|g| Some((g.vehicle?, g.conn, g.uuid)))
.collect()
});
if riders.is_empty() && self.watched_vehicles.is_empty() {
return;
}
let dim = self.dim;
for &(id, _, uuid) in &riders {
let Some(at) = self.watched_vehicle_position(id) else {
continue;
};
match self.watched_vehicles.iter_mut().find(|w| w.id == id) {
Some(w) => (w.dim, w.rider) = (dim, Some(uuid)),
None => self.watched_vehicles.push(WatchedVehicle {
id,
dim,
rider: Some(uuid),
at,
still: 0,
}),
}
}
let mut kept = Vec::new();
// (rider's connection and UUID, vehicle type, from, to)
let mut moved = Vec::new();
for mut w in std::mem::take(&mut self.watched_vehicles) {
// Another level's vehicle is told of on that level's tick.
if w.dim != dim {
kept.push(w);
continue;
}
// Broken, or gone from this level.
let Some(now) = self.watched_vehicle_position(w.id) else {
continue;
};
let rider = riders.iter().find(|r| r.0 == w.id);
w.rider = rider.map(|r| r.2);
if now != w.at {
let from = std::mem::replace(&mut w.at, now);
w.still = 0;
let kind = self.target_type(
self.level_entity(w.id as i32)
.unwrap_or(super::commands::Target::Boat(w.id)),
);
moved.push((rider.map(|r| (r.1, r.2)), kind, from, now));
} else {
w.still += 1;
if w.rider.is_none() && w.still > 200 {
continue;
}
}
kept.push(w);
}
self.watched_vehicles = kept;
// The rider's state is swapped in for the call, so the helpers reach
// them; a vehicle nobody rides is told of with nobody swapped in.
for (rider, kind, from, to) in moved {
let Some((conn, uuid)) = rider else {
self.native_vehicle_moved(None, &kind, from, to);
continue;
};
let Some(mut host) = self.lan.take() else {
continue;
};
let hook = |game: &mut Self| game.native_vehicle_moved(Some(uuid), &kind, from, to);
if self.lan_acting == Some(conn) {
hook(self);
} else {
self.as_guest(&mut host, conn, hook);
}
self.lan = Some(host);
}
}
/// The box of a boat, minecart, armor stand, item frame or painting, and
/// the point an interact packet's location is measured from (the
/// client's `remote_object_hit` uses the same points).
+8
View File
@@ -401,6 +401,14 @@ impl Game<'_> {
{
return false;
}
// A knot is made when a lead is tied to a fence that has none.
if existing.is_empty()
&& existing_boats.is_empty()
&& (!in_hand.is_empty() || !boats_in_hand.is_empty())
&& self.hanging_place_cancelled("minecraft:leash_knot", knot_pos)
{
return true;
}
let mut attached = self.tie_player_boats(center, Holder::Knot(knot_pos));
for j in in_hand {
if self.can_attach_lead(j, center) {
+12 -3
View File
@@ -520,8 +520,14 @@ impl Game<'_> {
/// The end of `MinecraftServer.runServer`'s loop for a server thread: a
/// finished `/tick sprint` reports to the operators as the server, then
/// the next tick starts one tick length after this one, or straight away
/// while the sprint runs (`checkShouldSprintThisTick`).
pub(super) fn wait_for_next_server_tick(&mut self, next: &mut Instant, paused: bool) {
/// while the sprint runs (`checkShouldSprintThisTick`). A server more
/// than two seconds behind skips the ticks it missed: that returns how far
/// behind it was, for the overload warning.
pub(super) fn wait_for_next_server_tick(
&mut self,
next: &mut Instant,
paused: bool,
) -> Option<Duration> {
if let Some(report) = self.clock.take_sprint_report() {
// ServerTickRateManager.finishTickSprint: the server's sendSuccess.
self.console_broadcast = Some(true);
@@ -536,7 +542,7 @@ impl Game<'_> {
let now = Instant::now();
if !paused && self.clock.sprinting() {
*next = now;
return;
return None;
}
*next += self.clock.tick_duration();
if *next > now {
@@ -548,8 +554,11 @@ impl Game<'_> {
std::thread::sleep(*next - now);
}
} else if now - *next > Duration::from_secs(2) {
let behind = now - *next;
*next = now;
return Some(behind);
}
None
}
/// The levels this server tick runs, in `MinecraftServer.levels` order
-1
View File
@@ -40,7 +40,6 @@ pub(super) struct Decoration {
}
/// A map as a native mod sees it when `native_map_decorations` is asked.
// Native mods read it; nothing in Horizon does.
#[allow(dead_code)]
#[derive(Clone, Copy, Debug)]
pub struct NativeMapInfo<'a> {
/// The map's id (`map_<id>`).
@@ -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);
}
}
+109
View File
@@ -159,6 +159,115 @@ impl Host for super::Game<'_> {
) -> bool {
self.native_set_block(dimension, pos, state, flags)
}
fn edit_blocks(&mut self, dimension: &str, edit: &NativeBlockEdit) -> Result<u64, String> {
self.native_edit_blocks(dimension, edit)
}
fn surface_heights(
&self,
dimension: &str,
columns: &[(i32, i32)],
heightmap: NativeHeightmap,
) -> Result<Vec<Option<i32>>, String> {
self.native_surface_heights(dimension, columns, heightmap)
}
fn read_columns(
&self,
dimension: &str,
columns: &[(i32, i32)],
heightmap: NativeHeightmap,
range: (u32, u32),
) -> Result<NativeColumnBlocks, String> {
self.native_read_columns(dimension, columns, heightmap, range)
}
fn refresh_biome_colors(&mut self) {
self.native_refresh_biome_colors()
}
fn neighbor_signal(&mut self, dimension: &str, pos: [i32; 3]) -> i32 {
self.native_neighbor_signal(dimension, pos)
}
fn light(&self, pos: [i32; 3]) -> (u8, u8) {
self.native_light(pos)
}
fn weather(&self) -> (f32, f32) {
self.native_weather()
}
fn day_time(&self) -> i64 {
self.native_day_time()
}
fn world_height(&self) -> (i32, i32) {
self.native_world_height()
}
fn max_stack_size(&self, item: &str) -> Option<u32> {
self.native_max_stack_size(item)
}
fn item_model(
&self,
batch: &mut HudBatch,
item: &str,
model: &str,
at: [f32; 2],
size: f32,
) -> bool {
self.native_item_model(batch, item, model, at, size)
}
fn quad(&self, batch: &mut HudBatch, points: [[f32; 2]; 4], argb: u32) {
self.native_quad(batch, points, argb)
}
fn mod_screen_open(&mut self, mod_id: &str) -> bool {
self.native_mod_screen_open(mod_id)
}
fn mod_screen_close(&mut self, mod_id: &str) {
self.native_mod_screen_close(mod_id)
}
fn mod_screen(&self) -> Option<&str> {
self.native_mod_screen()
}
fn set_experience_orb(
&mut self,
dimension: &str,
id: u32,
value: Option<u32>,
count: Option<u32>,
age: Option<u32>,
) -> bool {
self.native_set_experience_orb(dimension, id, value, count, age)
}
fn experience_orbs(
&self,
dimension: &str,
min: [f64; 3],
max: [f64; 3],
) -> Vec<NativeExperienceOrb> {
self.native_experience_orbs(dimension, min, max)
}
fn clock(&self) -> Option<NativeClock> {
self.native_clock()
}
fn spawn_falling_block(
&mut self,
dimension: &str,
state: BlockStateId,
pos: [f64; 3],
velocity: [f64; 3],
falling: NativeFalling,
) -> bool {
self.native_spawn_falling_block(dimension, state, pos, velocity, falling)
}
fn block_state_property(&self, state: BlockStateId, key: &str) -> Option<&str> {
self.native_block_state_property(state, key)
}
fn raycast(&self, distance: f32) -> Option<NativeHit> {
self.native_raycast(distance)
}
fn view_hit(&self) -> Option<(f32, NativeHit)> {
self.native_view_hit()
}
fn claim_items(&mut self, mod_id: &str, ids: &[String]) {
self.native_claim_items(mod_id, ids)
}
fn poll(&mut self, mod_id: &str) -> Vec<NativeModEvent> {
self.native_poll(mod_id)
}
fn set_item_entity(&mut self, dimension: &str, id: u32, stack: ItemStack) -> bool {
self.native_set_item_entity(dimension, id, stack)
}
+7 -1
View File
@@ -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;
@@ -43,7 +43,6 @@ fn in_frustum(frustum: Option<&impl Cull>, origin: Vec3, bounds: [f32; 6]) -> bo
/// partial tick, game time and light, and the pose stack the helpers draw
/// under (`native_draw_quad`, `native_draw_cube`, `native_draw_block`).
// Native mods read and drive it; nothing in Horizon does.
#[allow(dead_code)]
pub struct BlockEntityDraw {
/// The frame's partial tick, 0 to 1 (`BlockEntityRenderState`).
pub partial: f32,
@@ -174,7 +173,6 @@ impl BlockEntityDraw {
/// One face of a `native_draw_cube` box: its sprite (`mymod:entity/cog`,
/// `mymod:block/casing`) and its UVs in texels of 16 per side, as a model
/// face's `uv` (none takes the box's own, as a model's default).
#[allow(dead_code)]
#[derive(Clone, Copy, Debug)]
pub struct CubeFace<'a> {
pub sprite: &'a str,
@@ -5,36 +5,33 @@ use super::Game;
use std::collections::BTreeMap;
/// A stack as a client mod's use events carry it.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct NativeHeldItem {
pub(crate) id: String,
pub(crate) count: u32,
pub struct NativeHeldItem {
pub id: String,
pub count: u32,
/// The inventory slot: 0 to 8, the hotbar (a mod use is the main hand's).
pub(crate) slot: u32,
pub slot: u32,
/// `minecraft:custom_name` as SNBT, when the stack has one.
pub(crate) custom_name: Option<String>,
pub custom_name: Option<String>,
/// `minecraft:custom_data` as SNBT, when the stack has some.
pub(crate) custom_data: Option<String>,
pub custom_data: Option<String>,
}
/// The block a ray along the view meets.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct NativeHit {
pub(crate) block_x: i32,
pub(crate) block_y: i32,
pub(crate) block_z: i32,
pub struct NativeHit {
pub block_x: i32,
pub block_y: i32,
pub block_z: i32,
/// Where the ray meets the block.
pub(crate) x: f64,
pub(crate) y: f64,
pub(crate) z: f64,
pub x: f64,
pub y: f64,
pub z: f64,
/// The block's id (`minecraft:stone`).
pub(crate) block: String,
pub block: String,
}
/// What happened to a mod's claimed item, oldest first.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum NativeModEvent {
pub enum NativeModEvent {
/// A right-click with the item began its use: the stack, and 0 ticks.
UseStart(NativeHeldItem, u32),
/// The use ended: the stack it began with, and the ticks it was held. It
@@ -15,12 +15,12 @@ use std::collections::{BTreeMap, HashMap, HashSet};
/// A data map type a mod registers (`RegisterDataMapTypesEvent`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct NativeDataMapType {
pub struct NativeDataMapType {
/// `mod_id:name`: not in the `minecraft` namespace. Its files are
/// `data/<mod_id>/data_maps/<registry path>/<name>.json`.
pub(crate) id: String,
pub id: String,
/// The registry it is for: `minecraft:item` or `minecraft:block`.
pub(crate) registry: String,
pub registry: String,
}
/// The registries data maps can be attached to so far, with their folder
@@ -16,7 +16,7 @@ const NATIVE_FALLING_MAX_SPEED: f64 = 3.9;
/// What a mod's falling block does when it lands and when it hits.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct NativeFalling {
pub struct NativeFalling {
/// `FallingBlockEntity.dropItem`: whether it drops as an item where it
/// can't be placed.
pub drop_item: bool,
File diff suppressed because it is too large. Load diff
@@ -20,7 +20,6 @@ use horizon_client::{
/// `LivingEntityRenderState`: what a model reads to pose and animate itself.
/// Angles are in degrees, interpolated to the frame's partial tick.
// Native mods read it; nothing in Horizon does.
#[allow(dead_code)]
#[derive(Clone, Debug)]
pub struct NativeMobRender<'a> {
/// The mob's network id, as the mob helpers and mod messages name it.
@@ -83,7 +82,6 @@ impl<'a> NativeMobRender<'a> {
/// Which player a [`NativeMobRender`] is, for `native_player_submit` and
/// `native_mob_layer`.
// Native mods read it; nothing in Horizon does.
#[allow(dead_code)]
#[derive(Clone, Copy, Debug)]
pub struct NativePlayerRender<'a> {
/// The player's profile name.
Loaded 100 of 281 files, more files were not shown because too many files have changed in this diff. Show more