Merge #5644 (main merged) into #5645: main's Polish and Ukrainian shader-option strings kept

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-01 17:09:14 +01:00
commit 87266190f4
208 files changed
+16430 -585

No files matched your search

Generated
+2
View File
@@ -1395,6 +1395,7 @@ dependencies = [
"horizon-hdr",
"horizon-items",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
"horizon-shader-api",
"horizon-shaders",
@@ -1516,6 +1517,7 @@ name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"log",
"regex",
]
+25
View File
@@ -66,3 +66,28 @@ with Microsoft accounts
The four-square Microsoft symbol in `launcher/src/microsoft.rs` reproduces the squares, positions and
colours of Microsoft's official `ms-symbollockup_mssymbol_19.svg` from that page, unmodified. Microsoft
and its logo are trademarks of the Microsoft group of companies; Horizon is not endorsed by Microsoft.
## CIT Resewn
`crates/horizon-optifine/src/cit.rs` and `crates/horizon-client-render/src/cit.rs` follow the
condition semantics of [CIT Resewn Continuation](https://github.com/EthanVisagie/CIT-Resewn-Continuation-1.21.11)
(at `b792f9a`), a continuation of [CIT Resewn](https://github.com/SHsuperCM/CITResewn) by SHsuperCM: the
1.21 component paths, enchantment levels, percent damage, text matching and rule ordering. It is
licensed under the MIT license:
> Copyright (c) 2021 SHsuperCM
>
> 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.
+7
View File
@@ -281,6 +281,13 @@ pub struct Player {
pub ground: Option<(u64, f64)>,
/// The client ticks the prediction plays again (only while it runs).
pub record: Option<Box<prediction::Record>>,
/// Sprinting as the client's own commands left it (ServerboundPlayerCommandPacket's
/// START_SPRINTING and STOP_SPRINTING: LocalPlayer.wasSprinting), which the record's ticks take.
/// Not the server's player: a hit with extra knockback stops its sprint
/// (Player.causeExtraKnockback), the client's own `Player.attack` doesn't (a mob's `hurtClient`
/// is false), and once the server's flag reaches it LocalPlayer.aiStep starts the sprint again
/// while the sprint key is held, with nothing to say: wasSprinting never changed.
pub sprinting: bool,
/// The attacks of the client tick being heard.
pub attacks: combat::Attacks,
}
+3
View File
@@ -17,6 +17,9 @@ horizon-hdr = { path = "../horizon-hdr" }
horizon-items = { path = "../horizon-items" }
# The narrator option's status and whether the narrator is there (Options.narrator).
horizon-narrator = { path = "../horizon-narrator" }
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
# (connected.rs) and custom item texture rules (cit.rs).
horizon-optifine = { path = "../horizon-optifine" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
+30 -2
View File
@@ -101,6 +101,8 @@ pub struct RenderAssets {
pub pbr_normals: Vec<u8>,
pub pbr_specular: Vec<u8>,
pub pbr_size: u32,
/// OptiFine connected textures (empty without `optifine/ctm` files).
pub connected: crate::connected::ConnectedTextures,
}
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
@@ -240,9 +242,34 @@ impl RenderAssets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
if draws {
let connected = if draws {
crate::native_emissive::load(root, &mut sprites);
}
// A side's sprite, for rules that connect by tile: the first quad facing it.
let catalog = &models.catalog;
let mut found = Vec::new();
crate::connected::ConnectedTextures::load(
root,
registry,
&mut sprites,
|state, face| {
let side = horizon_assets::model::Direction::ALL[face as usize];
catalog
.models_at(
state,
horizon_core::position::BlockPos::new(0, 0, 0),
&mut found,
)
.ok()?;
found
.iter()
.flat_map(|m| &m.faces)
.find(|f| f.quad.facing == side)
.map(|f| f.layer)
},
)
} else {
Default::default()
};
sprites.save_disk_cache();
if draws {
sprites.finish();
@@ -267,6 +294,7 @@ impl RenderAssets {
pbr_normals: sprites.normals,
pbr_specular: sprites.specular,
pbr_size: sprites.pbr_size,
connected,
})
}
}
+50 -17
View File
@@ -29,6 +29,10 @@ pub type Signature = horizon_protocol::secure_chat::Signature;
/// How long a message shows before a deletion replaces it
/// (`ChatComponent.deleteMessageOrDelay`: `addedTime + 60`).
const DELETION_DELAY: u64 = 60;
/// The smooth chat extra: the ticks a new line takes to slide in and fade in,
/// and how far left of its place it starts (GUI pixels).
const INTRO_TICKS: f32 = 5.0;
const INTRO_WIDTH: f32 = 12.0;
/// `ChatVisiblity`: the Chat option's three values.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -233,14 +237,10 @@ impl Tag {
/// `ChatComponent.AlphaCalculator.timeBased`: opaque for 180 ticks, then
/// fading out quadratically over 20.
fn time_alpha(ticks: u64, added: u64) -> f32 {
let delta = ticks as i64 - added as i64;
let mut t = delta as f64 / 200.0;
t = 1.0 - t;
t *= 10.0;
t = t.clamp(0.0, 1.0);
t *= t;
t as f32
fn time_alpha(ticks: f32, added: u64) -> f32 {
let mut t = 1.0 - (ticks - added as f32) / 200.0;
t = (t * 10.0).clamp(0.0, 1.0);
t * t
}
#[derive(Default)]
pub struct Chat {
@@ -256,6 +256,11 @@ pub struct Chat {
scroll: usize,
new_since_scroll: bool,
pub options: ChatOptions,
/// The smooth chat extra: a new line slides in from the left while it
/// fades in, instead of appearing whole.
pub smooth: bool,
/// The smooth chat extra: no indicator bar at the left of a line.
pub hide_indicator: bool,
/// Messages printed to stdout as `[CHAT]` (vanilla logs them).
pub log: bool,
/// Messages added since the start, for the control socket's events.
@@ -555,7 +560,7 @@ impl Chat {
}
/// `ChatComponent.extractRenderState` (background mode unless `focused`)
/// on a screen `height` GUI pixels tall at `Hud.tickCount` `ticks`.
pub fn layout(&self, height: i32, ticks: u64, focused: bool) -> Vec<GuiDraw> {
pub fn layout(&self, height: i32, ticks: f32, focused: bool) -> Vec<GuiDraw> {
let total = self.lines.len();
if total == 0 {
return Vec::new();
@@ -569,28 +574,47 @@ impl Chat {
let entry_height = o.line_height();
let bottom_to_text =
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
// Vanilla's 200-tick fade reads the whole GUI ticks
// (`Gui.getGuiTicks`); only the smooth chat extra eases it per frame.
let fade_ticks = if self.smooth { ticks } else { ticks.floor() };
let alpha_of = |line: &Line| {
if focused {
1.0
} else {
time_alpha(ticks, line.added)
time_alpha(fade_ticks, line.added)
}
};
// The smooth chat extra: how far into its intro a line is, 0 at the
// tick it was added and 1 once it has arrived, as (alpha, offset x).
let intro_of = |line: &Line| {
if focused || !self.smooth {
return (1.0, 0.0);
}
let t = ((ticks - line.added as f32) / INTRO_TICKS).clamp(0.0, 1.0);
let eased = 1.0 - (1.0 - t) * (1.0 - t);
(eased, (eased - 1.0) * INTRO_WIDTH)
};
let visible = total
.saturating_sub(self.scroll)
.min(self.lines_per_page(focused));
let lines: Vec<(usize, &Line, f32)> = (0..visible)
let lines: Vec<(usize, &Line, f32, f32)> = (0..visible)
.rev()
.map(|i| (i, &self.lines[i + self.scroll]))
.map(|(i, line)| (i, line, alpha_of(line)))
.filter(|(_, _, alpha)| *alpha > 1.0e-5)
.map(|(i, line)| {
let (eased, slide) = intro_of(line);
(i, line, alpha_of(line) * eased, slide)
})
.filter(|(_, _, alpha, _)| *alpha > 1.0e-5)
.collect();
let mut out = vec![GuiDraw::Push(vec![
PoseOp::Scale(scale, scale),
PoseOp::Translate(4.0, 0.0),
])];
for &(i, _, alpha) in &lines {
for &(i, _, alpha, slide) in &lines {
let bottom = chat_bottom - i as i32 * entry_height;
if slide != 0.0 {
out.push(GuiDraw::Push(vec![PoseOp::Translate(slide, 0.0)]));
}
out.push(hud_layout::fill(
-4,
bottom - entry_height,
@@ -598,9 +622,15 @@ impl Chat {
bottom,
hud_layout::as8(alpha * background_opacity) << 24,
));
if slide != 0.0 {
out.push(GuiDraw::Pop);
}
}
for &(i, line, alpha) in &lines {
for &(i, line, alpha, slide) in &lines {
let bottom = chat_bottom - i as i32 * entry_height;
if slide != 0.0 {
out.push(GuiDraw::Push(vec![PoseOp::Translate(slide, 0.0)]));
}
let opacity = alpha * text_opacity;
let color = hud_layout::white(opacity);
if color >> 24 != 0 {
@@ -612,7 +642,7 @@ impl Chat {
shadow: true,
});
}
if let Some(tag) = &line.tag {
if let Some(tag) = line.tag.as_ref().filter(|_| !self.hide_indicator) {
let color = hud_layout::as8(opacity) << 24 | tag.color & 0xFF_FFFF;
out.push(hud_layout::fill(
-4,
@@ -622,6 +652,9 @@ impl Chat {
color,
));
}
if slide != 0.0 {
out.push(GuiDraw::Pop);
}
}
if focused {
let count = lines.len() as i32;
@@ -658,7 +691,7 @@ impl Chat {
}
/// Draws [`Chat::layout`] into the HUD batch, and with the chat screen
/// open the restricted prompt above the lines.
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: u64, focused: bool) {
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: f32, focused: bool) {
let draws = self.layout(batch.height as i32, ticks, focused);
hud_layout::paint(batch, atlas, &draws);
if focused && self.abilities.any_restriction() {
+56 -1
View File
@@ -39,6 +39,61 @@ fn add(chat: &mut Chat, font: &Font, content: Text) {
}
}
#[test]
fn smooth_chat_slides_a_line_in_and_can_hide_the_indicator() {
let mut chat = Chat {
smooth: true,
..Chat::default()
};
chat.lines.push(Line {
added: 0,
content: Text::literal("hi"),
tag: Some(std::sync::Arc::new(Tag::system_single_player(
SYSTEM_INDICATOR,
))),
});
let text_alpha = |draws: &[GuiDraw]| {
draws.iter().find_map(|d| match d {
GuiDraw::Text { color, .. } => Some(color >> 24),
_ => None,
})
};
// The slide is the negative translate the line carries while it moves.
let slide = |draws: &[GuiDraw]| {
draws.iter().find_map(|d| match d {
GuiDraw::Push(ops) => ops.iter().find_map(|op| match op {
PoseOp::Translate(x, _) if *x < 0.0 => Some(*x),
_ => None,
}),
_ => None,
})
};
// The tick it arrives: fully faded out, so nothing is drawn.
assert_eq!(text_alpha(&chat.layout(361, 0.0, false)), None);
// Halfway through the intro: left of its place and still faint.
let halfway = chat.layout(361, INTRO_TICKS / 2.0, false);
assert!(slide(&halfway).is_some_and(|x| (-INTRO_WIDTH..0.0).contains(&x)));
let faded = text_alpha(&halfway).expect("the line shows halfway through");
// Settled: in place and as opaque as vanilla's line.
let settled = chat.layout(361, INTRO_TICKS, false);
assert_eq!(slide(&settled), None);
assert!(text_alpha(&settled).expect("settled") > faded);
// The extra off draws the line whole on the tick it arrives, as vanilla.
chat.smooth = false;
let plain = chat.layout(361, 0.0, false);
assert_eq!(slide(&plain), None);
assert_eq!(text_alpha(&plain), text_alpha(&settled));
// The indicator bar is the -4 to -2 fill at the left of the line.
let bar = |draws: &[GuiDraw]| {
draws
.iter()
.any(|d| matches!(d, GuiDraw::Fill { x0: -4, x1: -2, .. }))
};
assert!(bar(&plain));
chat.hide_indicator = true;
assert!(!bar(&chat.layout(361, 0.0, false)));
}
#[test]
fn clicking_seed_copies_the_raw_value_only_inside_the_link() {
let font = font();
@@ -95,7 +150,7 @@ fn click_targets_match_rendered_spans_when_scaled_wrapped_and_scrolled() {
for scroll in [0, 3, 12] {
chat.scroll = scroll;
let mut links = 0;
for draw in chat.layout(361, 0, true) {
for draw in chat.layout(361, 0.0, true) {
if let GuiDraw::Text { text, x, y, .. } = draw {
let mut at = x as f64;
for (c, style) in text.chars() {
+662
View File
@@ -0,0 +1,662 @@
//! OptiFine's Custom Item Textures (and CIT Resewn's): the rules of the enabled packs'
//! `optifine/cit/**/*.properties`, read from the overlay resource root. The rules themselves are
//! `horizon_optifine::cit`'s.
pub use horizon_optifine::cit::{Hand, Kind, Rule};
use horizon_client_core::component::name_text;
use horizon_client_core::scoreboard::comp_text;
use horizon_items::item_data::{canon_tag, encode_components};
use horizon_items::items::ItemStack;
use horizon_optifine::cit::{ComponentTest, Source, ValueTest};
use horizon_optifine::{Properties, properties_files_in};
use horizon_world::nbt::{JavaString, Tag, parse_snbt};
use std::borrow::Cow;
use std::collections::HashMap;
use std::fs;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::Path;
use std::sync::Mutex;
/// Every rule under the root's `optifine/cit/`, `mcpatcher/cit/` and CIT Resewn's
/// `citresewn/cit/` folders, the one chosen first when several match first (`Rule::priority`).
/// The pack's global `cit.properties` is not a rule.
pub fn load_rules(root: &Path) -> Vec<Rule> {
let tops = ["optifine", "mcpatcher", "citresewn"];
let mut rules: Vec<Rule> = properties_files_in(root, &tops, "cit")
.into_iter()
.filter(|path| !path.ends_with("/cit.properties"))
.filter_map(|path| {
// Java reads `.properties` as Latin-1: a pack saved that way still loads.
let bytes = fs::read(root.join(&path)).ok()?;
let text = String::from_utf8(bytes)
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
let rule = Rule::parse(&path, &Properties::parse_equals_first(&text), |p| {
root.join(p).is_file()
});
if rule.is_none() {
log::warn!("custom item textures: {path} is not a rule Horizon can read");
}
rule
})
.collect();
rules.sort_by(Rule::priority);
rules
}
/// The rules with an index by item and a cache of what each stack matched.
#[derive(Debug, Default)]
pub struct CitRules {
pub rules: Vec<Rule>,
/// Per kind (`Kind as usize`), the rules naming each item, in priority order.
by_item: [HashMap<String, Vec<u32>>; 4],
/// Enchantment rules without `items`, in priority order.
any_item: Vec<u32>,
/// Per rule, per component test: its literal read as SNBT and as a JSON text component.
literals: Vec<Vec<Prepared>>,
selected: Mutex<HashMap<u64, Option<u32>>>,
enchanted: Mutex<HashMap<u64, Vec<u32>>>,
}
#[derive(Debug, Default)]
struct Prepared {
snbt: Option<Tag>,
json: Option<String>,
}
/// Cache entries kept before the cache starts over.
const CACHE_LIMIT: usize = 4096;
impl CitRules {
/// The enabled packs' rules under the overlay resource root.
pub fn load(root: &Path) -> Self {
Self::new(load_rules(root))
}
/// `rules` in priority order, the winner first.
pub fn new(rules: Vec<Rule>) -> Self {
let mut by_item: [HashMap<String, Vec<u32>>; 4] = Default::default();
let mut any_item = Vec::new();
for (i, rule) in rules.iter().enumerate() {
let i = i as u32;
if rule.kind == Kind::Enchantment && rule.items.is_empty() {
any_item.push(i);
}
for item in &rule.items {
let list = by_item[rule.kind as usize].entry(item.clone()).or_default();
if list.last() != Some(&i) {
list.push(i);
}
}
}
let literals = rules
.iter()
.map(|r| r.components.iter().map(prepare).collect())
.collect();
Self {
rules,
by_item,
any_item,
literals,
..Default::default()
}
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
/// The winning rule of `kind` (Item, Armor or Elytra) for this stack, as an index into `rules`.
pub fn select(
&self,
kind: Kind,
stack: &ItemStack,
hand: Hand,
max_damage: u32,
) -> Option<usize> {
let candidates = self.by_item[kind as usize].get(&*stack.item)?;
let comps = encode_components(stack);
let key = cache_key((0, kind, hand), stack, max_damage, comps.as_ref());
if let Some(hit) = lock(&self.selected).get(&key) {
return hit.map(|i| i as usize);
}
let found = candidates
.iter()
.copied()
.find(|&i| self.matches(i, stack, Some(hand), max_damage, comps.as_ref()));
remember(&self.selected, key, found);
found.map(|i| i as usize)
}
/// Every matching Enchantment rule, in priority order (layers are the glint PR's business).
pub fn enchantments(&self, stack: &ItemStack, max_damage: u32) -> Vec<usize> {
let named = self.by_item[Kind::Enchantment as usize]
.get(&*stack.item)
.map_or(&[][..], Vec::as_slice);
if named.is_empty() && self.any_item.is_empty() {
return Vec::new();
}
let comps = encode_components(stack);
let key = cache_key(
(1, Kind::Enchantment, Hand::Any),
stack,
max_damage,
comps.as_ref(),
);
let hit = lock(&self.enchanted).get(&key).cloned();
let found = hit.unwrap_or_else(|| {
let mut all: Vec<u32> = named.iter().chain(&self.any_item).copied().collect();
all.sort_unstable();
all.retain(|&i| self.matches(i, stack, None, max_damage, comps.as_ref()));
remember(&self.enchanted, key, all.clone());
all
});
found.into_iter().map(|i| i as usize).collect()
}
/// Whether rule `i` matches; `hand` None skips the hand test (enchantment glints).
fn matches(
&self,
i: u32,
stack: &ItemStack,
hand: Option<Hand>,
max_damage: u32,
comps: Option<&Tag>,
) -> bool {
let rule = &self.rules[i as usize];
if hand.is_some_and(|h| rule.hand != Hand::Any && rule.hand != h) {
return false;
}
let count = i32::try_from(stack.count).unwrap_or(i32::MAX);
if rule.stack_size.as_ref().is_some_and(|r| !r.contains(count)) {
return false;
}
// CIT Resewn: only damageable items have damage.
let damage = if max_damage > 0 { stack.damage } else { 0 };
if rule
.damage
.as_ref()
.is_some_and(|d| !d.matches(damage, max_damage))
{
return false;
}
let enchantments = stack.crafting_enchantments();
let listed = |id: &str| rule.enchantments.iter().any(|e| e == id);
if !rule.enchantments.is_empty() && !enchantments.iter().any(|(id, _)| listed(id)) {
return false;
}
if let Some(levels) = &rule.enchantment_levels {
let level_in_range = enchantments.iter().any(|(id, level)| {
(rule.enchantments.is_empty() || listed(id))
&& levels.contains(i32::try_from(*level).unwrap_or(i32::MAX))
});
if !level_in_range {
return false;
}
}
let literals = &self.literals[i as usize];
rule.components
.iter()
.zip(literals)
.all(|(test, lit)| component_test(test, lit, stack, comps) != test.negate)
}
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
fn remember<V>(cache: &Mutex<HashMap<u64, V>>, key: u64, value: V) {
let mut cache = lock(cache);
if cache.len() >= CACHE_LIMIT {
cache.clear();
}
cache.insert(key, value);
}
/// What a cached answer depends on; `query` tells `select` from `enchantments`.
fn cache_key(query: (u8, Kind, Hand), stack: &ItemStack, max: u32, comps: Option<&Tag>) -> u64 {
let mut h = DefaultHasher::new();
(query.0, query.1 as u8, query.2 as u8, &*stack.item).hash(&mut h);
(stack.count, stack.damage, max, comps).hash(&mut h);
h.finish()
}
fn prepare(test: &ComponentTest) -> Prepared {
let ValueTest::Literal(lit) = &test.value else {
return Prepared::default();
};
let text = lit.text.trim();
let snbt = match parse_snbt(text) {
Ok((tag, read)) if read == text.len() && text.starts_with(['{', '[']) => Some(tag),
_ => None,
};
let json = json_plain(text);
Prepared { snbt, json }
}
/// The plain text of `s` read as a JSON text component.
fn json_plain(s: &str) -> Option<String> {
if !s.starts_with(['{', '[', '"']) {
return None;
}
let value: serde_json::Value = serde_json::from_str(s).ok()?;
Some(comp_text(&value).plain())
}
/// Whether one test passes, before its `negate`.
fn component_test(
test: &ComponentTest,
lit: &Prepared,
stack: &ItemStack,
comps: Option<&Tag>,
) -> bool {
let legacy;
let (root, text_depth) = match &test.source {
Source::Component(id) => {
let depth = match id.as_str() {
"minecraft:custom_name" | "minecraft:item_name" => Some(0),
"minecraft:lore" => Some(1),
_ => None,
};
(comps.and_then(|c| c.get(id)), depth)
}
Source::LegacyEnchantments { stored } => {
let levels = if *stored {
stack.stored_enchantments()
} else {
stack.enchantments()
};
legacy = Tag::List(
levels
.iter()
.map(|(id, lvl)| {
let lvl = i16::try_from(*lvl).unwrap_or(i16::MAX);
Tag::compound([
("id", Tag::String((&**id).into())),
("lvl", Tag::Short(lvl)),
])
})
.collect(),
);
(Some(&legacy), None)
}
};
// Fields the component leaves out when 0, which pre-1.20.5 items always wrote.
let defaulted;
let root = match (&test.source, root, test.path.first()) {
(Source::Component(id), Some(Tag::Compound(map)), Some(field))
if matches!(
(id.as_str(), field.as_str()),
("minecraft:written_book_content", "generation")
| ("minecraft:fireworks", "flight_duration")
) && !map.contains_key(&JavaString::from(field.as_str())) =>
{
let mut map = map.clone();
map.insert(JavaString::from(field.as_str()), Tag::Int(0));
defaulted = Tag::Compound(map);
Some(&defaulted)
}
(_, root, _) => root,
};
let Some(root) = root else {
return matches!(test.value, ValueTest::Exists(false));
};
if let ValueTest::Exists(want) = test.value {
return walk(root, &test.path, 0, &mut |_, _| true) == want;
}
walk(root, &test.path, 0, &mut |tag, depth| {
value_test(&test.value, lit, tag, text_depth == Some(depth))
})
}
/// Visits what `path` reaches from `tag`, true once `visit` is.
fn walk(
tag: &Tag,
path: &[String],
depth: usize,
visit: &mut dyn FnMut(&Tag, usize) -> bool,
) -> bool {
let Some((seg, rest)) = path.split_first() else {
return visit(tag, depth);
};
let mut next = |t: &Tag| walk(t, rest, depth + 1, visit);
let len = match tag {
Tag::Compound(map) if seg == "*" => return map.values().any(&mut next),
Tag::Compound(map) => return map.get(&JavaString::from(seg.as_str())).is_some_and(next),
Tag::List(v) => v.len(),
Tag::ByteArray(v) => v.len(),
Tag::IntArray(v) => v.len(),
Tag::LongArray(v) => v.len(),
_ => return false,
};
let element = |i: usize| match tag {
Tag::List(v) => Cow::Borrowed(&v[i]),
Tag::ByteArray(v) => Cow::Owned(Tag::Byte(v[i] as i8)),
Tag::IntArray(v) => Cow::Owned(Tag::Int(v[i])),
Tag::LongArray(v) => Cow::Owned(Tag::Long(v[i])),
_ => Cow::Owned(Tag::End),
};
if seg == "count" {
return next(&Tag::Int(i32::try_from(len).unwrap_or(i32::MAX)));
}
if seg == "*" {
return (0..len).any(|i| next(&element(i)));
}
seg.parse::<usize>()
.ok()
.filter(|&i| i < len)
.is_some_and(|i| next(&element(i)))
}
fn int_value(tag: &Tag) -> Option<i64> {
match *tag {
Tag::Byte(v) => Some(v.into()),
Tag::Short(v) => Some(v.into()),
Tag::Int(v) => Some(v.into()),
Tag::Long(v) => Some(v),
_ => None,
}
}
/// A number as Java's `String.valueOf` writes it (`3`, `3.0`).
fn decimal(tag: &Tag) -> Option<String> {
let s = match *tag {
Tag::Float(b) => f32::from_bits(b).to_string(),
Tag::Double(b) => f64::from_bits(b).to_string(),
_ => return int_value(tag).map(|v| v.to_string()),
};
Some(if s.contains(['.', 'e', 'N', 'i']) {
s
} else {
s + ".0"
})
}
/// `text` against a string, its JSON text, or the plain text of a text component.
fn text_matches(tag: &Tag, is_text: bool, text: impl Fn(&str) -> bool) -> bool {
match tag {
Tag::String(s) => {
let s = s.to_string_lossy();
text(&s)
|| (is_text && text(&name_text(tag).plain()))
|| json_plain(&s).is_some_and(|p| text(&p))
}
Tag::Compound(_) | Tag::List(_) => is_text && text(&name_text(tag).plain()),
_ => false,
}
}
fn value_test(test: &ValueTest, lit: &Prepared, tag: &Tag, is_text: bool) -> bool {
match test {
ValueTest::Exists(_) => true,
ValueTest::Range(r) => {
int_value(tag).is_some_and(|v| i32::try_from(v).is_ok_and(|v| r.contains(v)))
}
ValueTest::Raw(name) => name.matches(&canon_tag(tag)),
ValueTest::Text(name) => match decimal(tag) {
Some(n) => name.matches(&n),
None => text_matches(tag, is_text, |s| name.matches(s)),
},
ValueTest::Literal(l) => match tag {
Tag::Float(b) => l.text.trim().parse::<f32>().ok() == Some(f32::from_bits(*b)),
Tag::Double(b) => l.float == Some(f64::from_bits(*b)),
Tag::Compound(_) | Tag::List(_)
if lit.snbt.as_ref().is_some_and(|e| contains(e, tag)) =>
{
true
}
_ => match int_value(tag) {
Some(v) => l.int == Some(v),
None => text_matches(tag, is_text, |s| {
s == l.text || lit.json.as_deref() == Some(s)
}),
},
},
}
}
/// `NbtUtils.compareNbt(expected, actual, true)`: every key and element of `expected` found in
/// `actual`.
fn contains(expected: &Tag, actual: &Tag) -> bool {
match (expected, actual) {
(Tag::Compound(e), Tag::Compound(a)) => e
.iter()
.all(|(k, v)| a.get(k).is_some_and(|a| contains(v, a))),
(Tag::List(e), Tag::List(a)) if e.is_empty() => a.is_empty(),
(Tag::List(e), Tag::List(a)) => e.iter().all(|e| a.iter().any(|a| contains(e, a))),
_ => expected == actual,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
/// Rules from (file stem, properties), sorted as `load_rules` sorts them.
fn rules(files: &[(&str, &str)]) -> CitRules {
let mut rules: Vec<Rule> = files
.iter()
.map(|(stem, text)| {
let path = format!("assets/minecraft/optifine/cit/{stem}.properties");
let text = format!("texture=x\n{text}");
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
})
.collect();
rules.sort_by(Rule::priority);
CitRules::new(rules)
}
fn stem(cit: &CitRules, i: Option<usize>) -> Option<&str> {
let path = &cit.rules[i?].path;
path.rsplit('/').next()?.strip_suffix(".properties")
}
fn item(cit: &CitRules, stack: ItemStack) -> Option<&str> {
stem(cit, cit.select(Kind::Item, &stack, Hand::Main, 0))
}
fn new(id: &str) -> ItemStack {
ItemStack::new(&format!("minecraft:{id}"), 1)
}
fn string(s: &str) -> Tag {
Tag::String(s.into())
}
fn with(id: &str, component: &str, tag: Tag) -> ItemStack {
let mut stack = new(id);
let component = format!("minecraft:{component}");
stack.data_mut().other.push((component, tag));
stack
}
fn enchanted(id: &str, levels: &[(&str, u32)]) -> ItemStack {
let mut stack = new(id);
let levels = levels
.iter()
.map(|(e, l)| (Arc::from(format!("minecraft:{e}")), *l));
stack.data_mut().enchantments = levels.collect();
stack
}
#[test]
fn heavier_rules_win_and_kinds_stay_apart() {
let cit = rules(&[
("plain", "items=stick"),
("heavy", "items=stick\nweight=2\nstackSize=2-"),
("offhand", "items=stick\nweight=5\nhand=off"),
("armor", "type=armor\nitems=stick"),
("wings", "type=elytra"),
]);
assert_eq!(item(&cit, new("stick")), Some("plain"));
assert_eq!(
item(&cit, ItemStack::new("minecraft:stick", 3)),
Some("heavy")
);
let pick = |kind, id, hand, max| stem(&cit, cit.select(kind, &new(id), hand, max));
assert_eq!(pick(Kind::Item, "stick", Hand::Off, 0), Some("offhand"));
assert_eq!(item(&cit, new("stone")), None);
assert_eq!(item(&cit, new("elytra")), None);
assert_eq!(pick(Kind::Elytra, "elytra", Hand::Main, 432), Some("wings"));
assert_eq!(pick(Kind::Armor, "stick", Hand::Main, 0), Some("armor"));
}
#[test]
fn names_match_by_their_plain_text() {
let cit = rules(&[
("glob", "items=stick\nnbt.display.Name=ipattern:*sword"),
("exact", "items=stone\ncomponents.custom_name=My Sword"),
("json", "items=dirt\nnbt.display.Name=[\"My \",\"Sword\"]"),
]);
let named = |id: &str, name: Tag| {
let mut stack = new(id);
stack.data_mut().custom_name = Some(name);
stack
};
let styled = Tag::compound([("text", string("My Sword")), ("color", string("red"))]);
assert_eq!(item(&cit, named("stick", styled.clone())), Some("glob"));
assert_eq!(item(&cit, named("stick", string("Blade"))), None);
assert_eq!(item(&cit, new("stick")), None);
assert_eq!(item(&cit, named("stone", styled.clone())), Some("exact"));
assert_eq!(item(&cit, named("dirt", styled)), Some("json"));
}
#[test]
fn lore_lines_and_list_counts() {
let cit = rules(&[
(
"any_line",
"items=stick\nnbt.display.Lore.*=ipattern:*legendary*",
),
("two_lines", "items=stone\ncomponents.lore.count=2"),
]);
let bold = Tag::compound([("text", string("A LEGENDARY item")), ("bold", Tag::Byte(1))]);
let lore = Tag::List(vec![string("Common"), bold]);
assert_eq!(
item(&cit, with("stick", "lore", lore.clone())),
Some("any_line")
);
assert_eq!(item(&cit, with("stone", "lore", lore)), Some("two_lines"));
let one = Tag::List(vec![string("A legendary item")]);
assert_eq!(item(&cit, with("stone", "lore", one)), None);
}
#[test]
fn custom_data_existence_and_negation() {
let cit = rules(&[
(
"aote",
"items=stick\nweight=2\nnbt.ExtraAttributes.id=ASPECT_OF_THE_END",
),
(
"not_hyp",
"items=stick\nweight=1\ncomponent.custom_data.ExtraAttributes.id=!HYP",
),
("unnamed", "items=stick\nnbt.display.Name=exists:false"),
]);
let data = |id: &str| {
let attributes =
Tag::compound([("ExtraAttributes", Tag::compound([("id", string(id))]))]);
with("stick", "custom_data", attributes)
};
assert_eq!(item(&cit, data("ASPECT_OF_THE_END")), Some("aote"));
assert_eq!(item(&cit, data("MIDAS")), Some("not_hyp"));
assert_eq!(item(&cit, data("HYP")), Some("unnamed"));
let mut named = data("HYP");
named.data_mut().custom_name = Some(string("Hyperion"));
assert_eq!(item(&cit, named), None);
}
#[test]
fn legacy_enchantment_lists_and_model_data() {
let cit = rules(&[
(
"sharp",
"items=stick\nnbt.Enchantments.*.id=minecraft:sharpness\nnbt.Enchantments.0.lvl=5",
),
("cmd", "items=stone\nnbt.CustomModelData=3"),
(
"cmd_snbt",
"items=dirt\ncomponents.custom_model_data={floats:[3.0f]}",
),
]);
assert_eq!(
item(&cit, enchanted("stick", &[("sharpness", 5)])),
Some("sharp")
);
assert_eq!(item(&cit, enchanted("stick", &[("sharpness", 4)])), None);
let model = |id: &str, f: f32| {
let floats = Tag::List(vec![Tag::Float(f.to_bits()), Tag::Float(7f32.to_bits())]);
with(id, "custom_model_data", Tag::compound([("floats", floats)]))
};
assert_eq!(item(&cit, model("stone", 3.0)), Some("cmd"));
assert_eq!(item(&cit, model("stone", 3.5)), None);
assert_eq!(item(&cit, model("dirt", 3.0)), Some("cmd_snbt"));
assert_eq!(item(&cit, model("dirt", 2.0)), None);
}
#[test]
fn fields_left_out_at_zero_read_as_zero() {
let cit = rules(&[("zero", "items=firework_rocket\nnbt.Fireworks.Flight=0")]);
let mut rocket = new("firework_rocket");
rocket.data_mut().fireworks = Some((0, Vec::new()));
assert_eq!(item(&cit, rocket.clone()), Some("zero"));
rocket.data_mut().fireworks = Some((2, Vec::new()));
assert_eq!(item(&cit, rocket), None);
}
#[test]
fn damage_counts_only_for_damageable_items() {
let cit = rules(&[
("worn", "items=diamond_sword\ndamage=50-100%"),
("new", "items=stick\ndamage=0"),
]);
let damaged = |id: &str, damage: u32, max: u32| {
let mut stack = new(id);
stack.damage = damage;
stem(&cit, cit.select(Kind::Item, &stack, Hand::Main, max))
};
// Half of 1561 is 780.5.
assert_eq!(damaged("diamond_sword", 780, 1561), None);
assert_eq!(damaged("diamond_sword", 781, 1561), Some("worn"));
assert_eq!(damaged("stick", 5, 0), Some("new"));
}
#[test]
fn enchantment_levels_and_rules_for_any_item() {
let cit = rules(&[
(
"sharp3",
"items=stick\nenchantments=sharpness\nenchantmentLevels=3-",
),
("any3", "items=stone\nenchantmentLevels=3-"),
("glint", "type=enchantment\nenchantments=sharpness"),
(
"sword_glint",
"type=enchantment\nitems=diamond_sword\nweight=2",
),
]);
let mixed = [("sharpness", 2), ("unbreaking", 3)];
assert_eq!(item(&cit, enchanted("stick", &mixed)), None);
assert_eq!(
item(&cit, enchanted("stick", &[("sharpness", 4)])),
Some("sharp3")
);
assert_eq!(item(&cit, enchanted("stone", &mixed)), Some("any3"));
let glints = |stack: ItemStack| -> Vec<&str> {
let found = cit.enchantments(&stack, 0).into_iter();
found.map(|i| stem(&cit, Some(i)).unwrap()).collect()
};
let sharp = [("sharpness", 1)];
assert_eq!(
glints(enchanted("diamond_sword", &sharp)),
["sword_glint", "glint"]
);
assert_eq!(glints(enchanted("stick", &sharp)), ["glint"]);
assert!(glints(new("stick")).is_empty());
// Glints are never an item's texture.
assert_eq!(item(&cit, enchanted("diamond_sword", &sharp)), None);
}
}
@@ -0,0 +1,724 @@
//! OptiFine connected textures (`optifine/ctm`) at mesh time. The active packs' rules are read
//! once at load, their tiles become sprite layers, and each block state and sprite layer gets
//! the short list of rules that can touch it; meshing a face then walks only those lists
//! ([`ConnectedTextures::pick`]). Without CTM files there are no rules and nothing changes.
//!
//! The rules and the tile geometry are `horizon_optifine::ctm`'s.
use crate::sprites::Sprites;
use horizon_optifine::ctm::{
self, Connect, Method, Orient, Rule, Tile,
connect::{self as geometry, Axes, Axis, Face},
};
use horizon_world::block_state_properties::BlockProperties;
use horizon_world::registry::{BlockRegistry, BlockStateId};
use std::{collections::HashMap, path::Path};
/// A tile as the mesher draws it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TileLayer {
Layer {
layer: u16,
/// The sprite has fractional alpha (drawn in the translucent pass).
translucent: bool,
/// Every texel is opaque (drawn without an alpha test).
opaque: bool,
},
/// Try the next rule.
Skip,
/// The face's own sprite.
Default,
}
/// What a face draws.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Pick {
/// Its own sprite.
Keep,
Tile(TileLayer),
/// `ctm_compact`: a tile per quarter of the sprite (top-left, top-right, bottom-left,
/// bottom-right).
Quarters([TileLayer; 4]),
}
struct LoadedRule {
rule: Rule,
tiles: Vec<TileLayer>,
}
#[derive(Clone, Copy, Default)]
struct StateInfo {
/// Index of the state's block (`connect=block`).
block: u32,
axis: Option<Axis>,
/// `half=upper`: `linked` random tiles follow the lower half.
upper: bool,
}
#[derive(Default)]
pub struct ConnectedTextures {
rules: Vec<LoadedRule>,
/// By block state, then by sprite layer: ranges of `lists` (rule indices, in rule order).
by_state: Vec<(u32, u32)>,
by_layer: Vec<(u32, u32)>,
lists: Vec<u16>,
states: Vec<StateInfo>,
/// By block state: the sprite of its model's face on each side (`connect=tile`); empty
/// when no rule connects by tile.
face_layers: Vec<[u16; 6]>,
}
/// `list` appended to `lists`, as its range there.
fn push_list(lists: &mut Vec<u16>, list: &[u16]) -> (u32, u32) {
let start = lists.len() as u32;
lists.extend_from_slice(list);
(start, list.len() as u32)
}
/// The sprite name of a texture resource path: `ns:path` under `textures/`, else the path
/// itself (as [`Sprites::insert_file`] names a tile).
fn sprite_name(resource: &str) -> String {
let parts = resource
.strip_prefix("assets/")
.and_then(|p| p.split_once('/'))
.and_then(|(ns, p)| Some((ns, p.strip_prefix("textures/")?.strip_suffix(".png")?)));
match parts {
Some((ns, path)) => format!("{ns}:{path}"),
None => resource.to_owned(),
}
}
impl ConnectedTextures {
/// The rules under `root`, with their tiles inserted into `sprites` (before it finishes).
/// `face_layer` answers a block state's sprite on a side, for rules that connect by tile.
pub fn load(
root: &Path,
registry: &BlockRegistry,
sprites: &mut Sprites,
mut face_layer: impl FnMut(BlockStateId, Face) -> Option<u32>,
) -> Self {
let mut rules = ctm::load(root);
// Rules no face can use load no tiles: overlays (not drawn yet), and custom names (section
// meshes see no block entity).
rules.retain(|r| !r.method.is_overlay() && r.name.is_none());
// Rule indices are `u16`.
rules.truncate(usize::from(u16::MAX));
if rules.is_empty() {
return Self::default();
}
let loaded = rules
.into_iter()
.filter_map(|rule| {
let tiles = rule
.tiles
.iter()
.map(|tile| match tile {
Tile::Skip => Ok(TileLayer::Skip),
Tile::Default => Ok(TileLayer::Default),
Tile::Texture(path) => {
// A tile among the textures is that sprite, under its own name.
let layer = match sprite_name(path) {
name if name != *path => sprites.insert(root, &name)?,
_ => sprites.insert_file(root, path)?,
};
Ok(TileLayer::Layer {
layer: u16::try_from(layer)?,
translucent: sprites.translucent[layer as usize],
opaque: sprites.opaque[layer as usize],
})
}
})
.collect::<crate::Result<Vec<_>>>();
match tiles {
Ok(tiles) => Some(LoadedRule { rule, tiles }),
Err(e) => {
log::warn!("Horizon: connected textures {}: a tile: {e}", rule.path);
None
}
}
})
.collect();
let states = (0..registry.len() as u32).map(|id| {
registry
.state(BlockStateId(id))
.map(|s| (s.name.as_str(), &s.properties))
});
let mut out = Self::build(loaded, states, |name| sprites.layers.get(name).copied());
if out.rules.iter().any(|r| r.rule.connect == Connect::Tile) {
out.face_layers = (0..registry.len() as u32)
.map(|id| {
Face::ALL
.map(|f| face_layer(BlockStateId(id), f).map_or(u16::MAX, |l| l as u16))
})
.collect();
}
out
}
/// The lookups for `rules` (already ordered) over every block state (`None` for an id the
/// registry skips), `layer` answering a sprite name's layer.
fn build<'a>(
rules: Vec<LoadedRule>,
states: impl Iterator<Item = Option<(&'a str, &'a BlockProperties)>>,
layer: impl Fn(&str) -> Option<u32>,
) -> Self {
let mut out = Self {
rules,
..Self::default()
};
let mut blocks = HashMap::new();
let mut by_layer: Vec<Vec<u16>> = Vec::new();
// Block rules by the block each entry names, so a state tests only its own.
let mut by_block: HashMap<&str, Vec<u16>> = HashMap::new();
for (i, r) in out.rules.iter().enumerate() {
let i = i as u16;
for tile in &r.rule.match_tiles {
if let Some(l) = layer(&sprite_name(tile)) {
let l = l as usize;
if by_layer.len() <= l {
by_layer.resize(l + 1, Vec::new());
}
if by_layer[l].last() != Some(&i) {
by_layer[l].push(i);
}
}
}
for m in &r.rule.match_blocks {
let list = by_block.entry(m.id.as_str()).or_default();
if list.last() != Some(&i) {
list.push(i);
}
}
}
for list in by_layer {
let range = push_list(&mut out.lists, &list);
out.by_layer.push(range);
}
let mut list = Vec::new();
for state in states {
let Some((name, props)) = state else {
out.by_state.push((0, 0));
out.states.push(StateInfo::default());
continue;
};
let next = blocks.len() as u32;
let block = *blocks.entry(name).or_insert(next);
let prop = |k: &str| props.get(k).map(String::as_str);
out.states.push(StateInfo {
block,
axis: match prop("axis") {
Some("x") => Some(Axis::X),
Some("y") => Some(Axis::Y),
Some("z") => Some(Axis::Z),
_ => None,
},
upper: prop("half") == Some("upper"),
});
list.clear();
if let Some(candidates) = by_block.get(name) {
list.extend(
candidates
.iter()
.filter(|&&i| out.rules[usize::from(i)].rule.matches_block(name, prop)),
);
}
let range = push_list(&mut out.lists, &list);
out.by_state.push(range);
}
out
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
fn list(&self, ranges: &[(u32, u32)], i: usize) -> &[u16] {
ranges.get(i).map_or(&[], |&(start, len)| {
&self.lists[start as usize..(start + len) as usize]
})
}
/// 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.
pub fn pick<'b>(
&self,
state: u32,
layer: u16,
face: Face,
pos: [i32; 3],
neighbour: impl Fn([i32; 3]) -> u32,
biome: impl Fn() -> Option<&'b str>,
) -> Pick {
let (mut by_state, mut by_layer) = (
self.list(&self.by_state, state as usize),
self.list(&self.by_layer, layer as usize),
);
// Both lists are in rule order: walk them as one. A rule naming blocks and tiles needs
// both (it is in both lists); one naming either needs that one.
loop {
let (i, block, tile) = match (by_state.split_first(), by_layer.split_first()) {
(None, None) => return Pick::Keep,
(Some((&x, rest)), Some((&y, other))) if x == y => {
(by_state, by_layer) = (rest, other);
(x, true, true)
}
(Some((&x, rest)), Some((&y, _))) if x < y => {
by_state = rest;
(x, true, false)
}
(Some((&x, rest)), None) => {
by_state = rest;
(x, true, false)
}
(_, Some((&y, rest))) => {
by_layer = rest;
(y, false, true)
}
};
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)
{
return pick;
}
}
}
/// One rule on a face: `None` when it doesn't apply or picks `<skip>`.
#[allow(clippy::too_many_arguments)]
fn apply<'b>(
&self,
r: &LoadedRule,
state: u32,
layer: u16,
face: Face,
pos: [i32; 3],
neighbour: &impl Fn([i32; 3]) -> u32,
biome: &impl Fn() -> Option<&'b str>,
) -> Option<Pick> {
let rule = &r.rule;
// `Rule::applies_at`, with the biome looked up only for rules that test one. Rules with
// a custom name are not loaded.
if rule.faces & face.bit() == 0
|| rule.heights.as_ref().is_some_and(|h| !h.contains(pos[1]))
{
return None;
}
if let Some(biomes) = &rule.biomes
&& !biome().is_some_and(|b| biomes.matches(b))
{
return None;
}
let info = self.states.get(state as usize).copied().unwrap_or_default();
let joins = |offset: [i32; 3]| {
let other = neighbour(offset);
match rule.connect {
Connect::Block => {
self.states.get(other as usize).map(|s| s.block) == Some(info.block)
}
Connect::State => other == state,
Connect::Tile => self
.face_layers
.get(other as usize)
.is_some_and(|f| f[face as usize] == layer),
}
};
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.
_ => Axes::cube(face),
};
let mask = || geometry::neighbours(axes(face), face.normal(), rule.inner_seams, joins);
let tile = |i: usize| r.tiles.get(i).copied().unwrap_or(TileLayer::Default);
let index = match rule.method {
Method::Ctm => geometry::ctm_index(mask()),
Method::CtmCompact => {
let mask = mask();
let case = geometry::ctm_index(mask) as u8;
match rule.compact_overrides.iter().find(|&&(c, _)| c == case) {
Some(&(_, t)) => t,
None => {
let quarters = geometry::compact_quarters(mask).map(|q| tile(q.into()));
return if quarters.contains(&TileLayer::Skip) {
None
} else {
Some(Pick::Quarters(quarters))
};
}
}
}
Method::Horizontal => geometry::horizontal(mask()),
Method::Vertical => geometry::vertical(mask()),
Method::HorizontalVertical => geometry::horizontal_vertical(mask()),
Method::VerticalHorizontal => geometry::vertical_horizontal(mask()),
Method::Top => {
// Only sides join the block above: the top and bottom go on to the next rule.
if matches!(face, Face::Up | Face::Down) {
return None;
}
if !joins([0, 1, 0]) {
return Some(Pick::Keep);
}
0
}
Method::Fixed => 0,
Method::Random => {
let face = geometry::symmetric_face(face, rule.symmetry);
let pos = if rule.linked && info.upper {
[pos[0], pos[1] - 1, pos[2]]
} else {
pos
};
geometry::random_index(pos, face, rule.random_loops, &rule.weights, r.tiles.len())
}
Method::Repeat => {
let face = geometry::symmetric_face(face, rule.symmetry);
geometry::repeat_index(pos, axes(face), rule.width, rule.height)
}
// Overlays are not loaded yet.
_ => return None,
};
match tile(index) {
TileLayer::Skip => None,
TileLayer::Default => Some(Pick::Keep),
t => Some(Pick::Tile(t)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::Properties;
/// A tile whose layer says which tile of its rule it is: rule `r`'s tile `i` is layer
/// `1000 * (r + 1) + i`.
fn rules(files: &[(&str, &str)]) -> Vec<LoadedRule> {
let mut rules: Vec<Rule> = files
.iter()
.map(|(path, text)| {
Rule::parse(
&format!("assets/minecraft/optifine/ctm/{path}"),
&Properties::parse(text),
)
.unwrap()
.unwrap()
})
.collect();
ctm::sort(&mut rules);
rules
.into_iter()
.enumerate()
.map(|(n, rule)| LoadedRule {
tiles: rule
.tiles
.iter()
.enumerate()
.map(|(i, t)| match t {
Tile::Skip => TileLayer::Skip,
Tile::Default => TileLayer::Default,
Tile::Texture(_) => TileLayer::Layer {
layer: (1000 * (n + 1) + i) as u16,
translucent: false,
opaque: false,
},
})
.collect(),
rule,
})
.collect()
}
/// State 0 air, 1 glass, 2 stone, 3 and 4 oak logs (axis y, x), 5 and 6 a door (lower,
/// upper); the stone sprite is layer 7.
fn world(files: &[(&str, &str)]) -> ConnectedTextures {
let props = |pairs: &[(&str, &str)]| -> BlockProperties {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
};
let states = [
("minecraft:air", props(&[])),
("minecraft:glass", props(&[])),
("minecraft:stone", props(&[])),
("minecraft:oak_log", props(&[("axis", "y")])),
("minecraft:oak_log", props(&[("axis", "x")])),
("minecraft:oak_door", props(&[("half", "lower")])),
("minecraft:oak_door", props(&[("half", "upper")])),
];
ConnectedTextures::build(
rules(files),
states.iter().map(|(n, p)| Some((*n, p))),
|name| (name == "minecraft:block/stone").then_some(7),
)
}
fn tile(layer: u16) -> Pick {
Pick::Tile(TileLayer::Layer {
layer,
translucent: false,
opaque: false,
})
}
/// `pick` on `face` of the block at the origin among `blocks` (offset, state).
fn pick_in(
ctm: &ConnectedTextures,
state: u32,
layer: u16,
face: Face,
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)
}
const GLASS: &str = "matchBlocks=glass\nmethod=ctm\ntiles=0-46";
#[test]
fn glass_joins_its_neighbours() {
let ctm = world(&[("glass.properties", GLASS)]);
// Alone: tile 0. A north face's right is west: glass there is the left end of a row.
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &[]), tile(1000));
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
tile(1001)
);
// Glass all around in the face's plane: the middle tile, 26.
let ring: Vec<_> = [-1, 0, 1]
.into_iter()
.flat_map(|x| [-1, 0, 1].map(|y| ([x, y, 0], 1)))
.collect();
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &ring), tile(1026));
// Stone beside it doesn't join; stone has no rule.
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 2)]),
tile(1000)
);
assert_eq!(pick_in(&ctm, 2, 7, Face::North, &[]), Pick::Keep);
}
#[test]
fn rule_order_faces_and_skips() {
let ctm = world(&[
(
"a_sides.properties",
"matchBlocks=stone\nmethod=fixed\ntiles=<skip>\nfaces=sides",
),
("b_glass.properties", GLASS),
("stone.properties", "method=fixed\ntiles=x\nfaces=top"),
("z.properties", "matchBlocks=stone\nmethod=fixed\ntiles=y"),
]);
// The tile rule (stone.properties, by its file name) comes first, on the top face only.
assert_eq!(pick_in(&ctm, 2, 7, Face::Up, &[]), tile(1000));
// Sides: a_sides skips, so z draws.
assert_eq!(pick_in(&ctm, 2, 7, Face::North, &[]), tile(4000));
// The tile rule follows the sprite, whatever the block: glass with the stone sprite.
assert_eq!(pick_in(&ctm, 1, 7, Face::Up, &[]), tile(1000));
}
#[test]
fn compact_quarters_and_overrides() {
let ctm = world(&[(
"glass.properties",
"matchBlocks=glass\nmethod=ctm_compact\ntiles=0-5\nctm.1=5",
)]);
let layer = |i: u16| TileLayer::Layer {
layer: 1000 + i,
translucent: false,
opaque: false,
};
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[]),
Pick::Quarters([layer(0); 4])
);
// Joined right only is case 1, replaced whole by tile 5.
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
tile(1005)
);
// Joined left only (east of a north face): quarters [3, 0, 3, 0].
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([1, 0, 0], 1)]),
Pick::Quarters([layer(3), layer(0), layer(3), layer(0)])
);
}
#[test]
fn logs_turn_with_their_axis() {
let ctm = world(&[(
"log.properties",
"matchBlocks=oak_log\nmethod=vertical\ntiles=0-3",
)]);
// An upright log with a log above: the bottom of a column.
assert_eq!(
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 3)]),
tile(1000)
);
// A log lying along x: on its north face the texture's up points east, so a log to the
// east (same block, other axis state) is above it.
assert_eq!(
pick_in(&ctm, 4, 3, Face::North, &[([1, 0, 0], 4)]),
tile(1000)
);
assert_eq!(
pick_in(&ctm, 4, 3, Face::North, &[([0, 1, 0], 4)]),
tile(1003)
);
}
#[test]
fn top_and_connect_state() {
let ctm = world(&[(
"block_oak_log.properties",
"method=top\ntiles=0\nconnect=state",
)]);
assert_eq!(
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 3)]),
tile(1000)
);
// The other axis is another state.
assert_eq!(
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 4)]),
Pick::Keep
);
assert_eq!(pick_in(&ctm, 3, 3, Face::Up, &[([0, 1, 0], 3)]), Pick::Keep);
}
#[test]
fn linked_random_follows_the_lower_half() {
let ctm = world(&[(
"door.properties",
"matchBlocks=oak_door\nmethod=random\ntiles=0-7\nlinked=true",
)]);
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);
assert_eq!(lower, upper, "{x}");
tiles.insert(format!("{lower:?}"));
}
assert!(tiles.len() > 3, "the doors are not all alike");
}
#[test]
fn heights_and_biomes() {
let ctm = world(&[(
"glass.properties",
"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));
assert_eq!(at(10, "minecraft:desert"), tile(1000));
assert_eq!(at(64, "minecraft:desert"), Pick::Keep);
assert_eq!(at(10, "minecraft:plains"), Pick::Keep);
}
#[test]
fn connect_by_tile_reads_the_neighbours_same_side() {
let mut ctm = world(&[(
"glass.properties",
"matchBlocks=glass\nmethod=horizontal\nconnect=tile\ntiles=0-3",
)]);
// Stone shows the glass sprite (3) on its north side; the door only on its south.
let mut faces = vec![[9u16; 6]; 7];
faces[1][Face::North as usize] = 3;
faces[2][Face::North as usize] = 3;
faces[5][Face::South as usize] = 3;
ctm.face_layers = faces;
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 2)]),
tile(1000)
);
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 5)]),
tile(1003)
);
}
#[test]
fn repeat_and_rows_then_columns() {
let ctm = world(&[(
"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);
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));
assert_eq!(at([0, 63, 0]), tile(1002));
let hv = world(&[(
"glass.properties",
"matchBlocks=glass\nmethod=horizontal+vertical\ntiles=0-6",
)]);
// Alone in its row, glass above: the column's bottom, tile 4.
assert_eq!(
pick_in(&hv, 1, 3, Face::North, &[([0, 1, 0], 1)]),
tile(1004)
);
assert_eq!(
pick_in(&hv, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
tile(1000)
);
}
#[test]
fn skips_defaults_and_narrowed_rules() {
let ctm = world(&[
(
"a.properties",
"matchBlocks=glass\nmethod=ctm_compact\ntiles=<skip> 1-4",
),
(
"b.properties",
"matchBlocks=glass\nmethod=fixed\ntiles=0\nfaces=sides",
),
(
"c.properties",
"matchBlocks=glass\nmethod=fixed\ntiles=<default>",
),
("d.properties", "matchBlocks=glass\nmethod=fixed\ntiles=0"),
(
"e.properties",
"matchBlocks=stone\nmatchTiles=glass_pane_top\nmethod=fixed\ntiles=0",
),
]);
// e names a tile, so it is tried first (tile 1000s), but needs a stone with the pane
// sprite. a's skipped quarter drops it; b (3000s) draws the sides; on top c draws the
// glass itself, which stops d.
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &[]), tile(3000));
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), Pick::Keep);
assert_eq!(pick_in(&ctm, 2, 7, Face::Up, &[]), Pick::Keep);
let both = world(&[(
"e.properties",
"matchBlocks=glass\nmatchTiles=stone\nmethod=fixed\ntiles=0",
)]);
assert_eq!(pick_in(&both, 1, 7, Face::Up, &[]), tile(1000));
assert_eq!(pick_in(&both, 1, 3, Face::Up, &[]), Pick::Keep);
assert_eq!(pick_in(&both, 2, 7, Face::Up, &[]), Pick::Keep);
// `top` passes the top face on.
let top = world(&[
("block_glass.properties", "method=top\ntiles=0"),
(
"z.properties",
"matchBlocks=glass\nmethod=fixed\ntiles=0\nfaces=top",
),
]);
assert_eq!(pick_in(&top, 1, 3, Face::Up, &[]), tile(2000));
}
#[test]
fn sprite_names() {
assert_eq!(
sprite_name("assets/minecraft/textures/block/dirt.png"),
"minecraft:block/dirt"
);
assert_eq!(
sprite_name("assets/minecraft/optifine/ctm/glass/0.png"),
"assets/minecraft/optifine/ctm/glass/0.png"
);
}
}
+38 -2
View File
@@ -111,6 +111,10 @@ pub struct Extras {
pub sort: bool,
/// A vanilla key name: sorts the part under the cursor.
pub sort_key: String,
/// A new chat line slides in from the left while it fades in (`Chat::layout`).
pub chat_animation: bool,
/// The indicator bar at the left of a chat line (on by default, as vanilla).
pub chat_indicator: bool,
/// No AI: every AI feature hidden and off (`horizon_core::no_ai`; the launcher writes it too).
pub no_ai: bool,
/// Agents may control the game the player starts: it opens its control socket for
@@ -152,6 +156,8 @@ impl Default for Extras {
mouse_tweaks_wheel: true,
sort: false,
sort_key: "key.keyboard.unknown".into(),
chat_animation: false,
chat_indicator: true,
no_ai: false,
agent_control: false,
discord_presence: true,
@@ -230,6 +236,8 @@ impl Extras {
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
"sort" => e.sort = flag(e.sort),
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
"chatAnimation" => e.chat_animation = flag(e.chat_animation),
"chatIndicator" => e.chat_indicator = flag(e.chat_indicator),
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
@@ -241,7 +249,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -274,6 +282,8 @@ impl Extras {
bool_text(self.mouse_tweaks_wheel),
bool_text(self.sort),
self.sort_key,
bool_text(self.chat_animation),
bool_text(self.chat_indicator),
horizon_core::no_ai::KEY,
bool_text(self.no_ai),
bool_text(self.agent_control),
@@ -406,6 +416,10 @@ pub enum Item {
MouseTweaksWheel,
Sort,
SortKey,
/// A new chat line slides in and fades in (`Chat::layout`).
ChatAnimation,
/// The coloured bar at the left of a chat line.
ChatIndicator,
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
NoAi,
AgentControl,
@@ -430,6 +444,9 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Ok(MouseTweaksWheel),
Ok(Sort),
Ok(SortKey),
Err("horizon.extras.header.chat"),
Ok(ChatAnimation),
Ok(ChatIndicator),
Err("horizon.extras.header.multiplayer"),
Ok(AutoReconnect),
Err("horizon.extras.header.sound"),
@@ -454,6 +471,8 @@ impl Item {
Item::AgentControl => "horizon.extras.agent_control",
Item::DiscordPresence => "horizon.extras.discord_presence",
Item::Splitscreen => "horizon.extras.splitscreen",
Item::ChatAnimation => "horizon.extras.chat_animation",
Item::ChatIndicator => "horizon.extras.chat_indicator",
}
}
/// The language key of the description shown as the row's tooltip.
@@ -473,6 +492,8 @@ impl Item {
Item::AgentControl => "horizon.extras.agent_control.tooltip",
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
}
}
/// The caption in the current language (English when the key is missing).
@@ -497,6 +518,8 @@ impl Item {
Item::MouseTweaksWheel => "Scroll wheel moves items",
Item::Sort => "Sort buttons",
Item::SortKey => "Sort key",
Item::ChatAnimation => "Smooth chat",
Item::ChatIndicator => "Message indicator",
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::DiscordPresence => "Discord Rich Presence",
@@ -510,8 +533,10 @@ impl Item {
UiSoundReserve => return None,
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
ChatIndicator => ChatAnimation,
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen => {
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen
| ChatAnimation => {
return None;
}
})
@@ -547,6 +572,10 @@ impl Item {
Item::SortKey => {
"Sorts the part under the cursor. Click, then press a key; Escape unbinds it."
}
Item::ChatAnimation => {
"A new chat line slides in from the left while it fades in, instead of appearing whole."
}
Item::ChatIndicator => "The coloured bar at the left of a chat line.",
Item::NoAi => {
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
}
@@ -568,6 +597,7 @@ impl Item {
Item::Fullbright => Some(Cost::None),
Item::UiSoundReserve => Some(Cost::None),
Item::DynamicLights => Some(Cost::Low),
Item::ChatAnimation => Some(Cost::None),
Item::OwnNameTag
| Item::HideBlockParticles
| Item::AutoReconnect
@@ -589,6 +619,8 @@ impl Item {
Item::MouseTweaks => e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
Item::Sort => e.sort,
Item::ChatAnimation => e.chat_animation,
Item::ChatIndicator => e.chat_indicator,
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::DiscordPresence => e.discord_presence,
@@ -619,6 +651,8 @@ impl Item {
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
Item::Sort => e.sort = !e.sort,
Item::ChatAnimation => e.chat_animation = !e.chat_animation,
Item::ChatIndicator => e.chat_indicator = !e.chat_indicator,
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
@@ -681,6 +715,8 @@ mod tests {
e.mouse_tweaks_wheel = false;
e.sort = true;
e.sort_key = "key.keyboard.r".into();
e.chat_animation = true;
e.chat_indicator = false;
e.discord_presence = false;
assert!(!e.splitscreen);
Item::Splitscreen.cycle(&mut e, false);
+265 -32
View File
@@ -142,6 +142,7 @@ pub mod panorama;
mod readback;
mod resident;
mod stars;
pub mod submit;
mod sunrise_fan;
mod timing;
pub mod translucency;
@@ -164,6 +165,50 @@ fn sky_visible(frame: &Frame) -> bool {
!frame.title && frame.skybox && sky_open(frame)
}
/// The world pass's part that draws a shader pack's solid terrain: the world's colour and
/// depth loaded, and its `surface` target, cleared on the first of these passes.
fn surface_pass<'e>(
encoder: &'e mut wgpu::CommandEncoder,
view: &wgpu::TextureView,
surface: &wgpu::TextureView,
depth: &wgpu::TextureView,
clear: bool,
) -> wgpu::RenderPass<'e> {
let keep = |load| wgpu::Operations {
load,
store: wgpu::StoreOp::Store,
};
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon world, pack terrain"),
color_attachments: &[
Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: keep(wgpu::LoadOp::Load),
}),
Some(wgpu::RenderPassColorAttachment {
view: surface,
resolve_target: None,
depth_slice: None,
ops: keep(if clear {
wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT)
} else {
wgpu::LoadOp::Load
}),
}),
],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
})
}
fn world_pass_after_culling<'e>(
encoder: &'e mut wgpu::CommandEncoder,
view: &wgpu::TextureView,
@@ -618,7 +663,11 @@ pub struct Renderer {
display_fresh: bool,
window: Arc<Window>,
pub device: wgpu::Device,
pub queue: wgpu::Queue,
/// Frames are finished and submitted on its submit thread ([`submit`]).
pub queue: submit::Queue,
/// The last frame went to the submit thread; whether it had no window surface.
/// The next frame takes it in ([`Self::land_frame`]).
deferred: Option<bool>,
/// Set by the device's lost callback. wgpu reports nothing else when an
/// operation fails on a lost device (no error scope sees it).
device_lost: Arc<std::sync::atomic::AtomicBool>,
@@ -640,6 +689,9 @@ pub struct Renderer {
sides_on: bool,
sky: wgpu::RenderPipeline,
dark_disc: wgpu::RenderPipeline,
/// Clears the world pass's depth to the far plane so the first-person hand draws in the
/// same pass (`hand_in_world`), as the hand's own pass would clear it.
depth_reset: wgpu::RenderPipeline,
lines: wgpu::RenderPipeline,
lines_secondary: wgpu::RenderPipeline,
/// Gizmo lines (`RenderTypes.linesTranslucentNoDepthWrite`) and the
@@ -2039,6 +2091,40 @@ impl Renderer {
false,
Some(alpha_blend),
);
let depth_reset = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Hand depth reset"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_sky"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_depth_reset"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &sprite_constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: config.format.remove_srgb_suffix(),
blend: None,
write_mask: wgpu::ColorWrites::empty(),
})],
}),
multiview_mask: None,
cache: None,
});
let lines = pipeline(
"Block outline",
"vs_line",
@@ -3358,7 +3444,8 @@ impl Renderer {
last_encode: std::time::Duration::ZERO,
window,
device,
queue,
queue: submit::Queue::new(queue, submit::wanted()),
deferred: None,
device_lost,
config,
solid,
@@ -3372,6 +3459,7 @@ impl Renderer {
flat,
sky,
dark_disc,
depth_reset,
lines,
lines_secondary,
debug_lines,
@@ -3531,6 +3619,14 @@ impl Renderer {
present_modes,
})
}
/// The first-person hand's quads, which follow `first` quads of the entity buffer.
fn draw_hand(&self, pass: &mut wgpu::RenderPass<'_>, quads: u32, first: u32) {
pass.set_pipeline(&self.entity_pipeline);
pass.set_bind_group(0, &self.hand_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
}
/// LevelRenderer.submitBlockOutline: the buffer holds `edges` per stroke, the black
/// secondary stroke first when high contrast is on.
fn draw_outline(&self, pass: &mut wgpu::RenderPass<'_>, edges: u32, high_contrast: bool) {
@@ -3578,6 +3674,7 @@ impl Renderer {
/// Hand the window surface over to the renderer that replaces this one on
/// the same device (it takes `spare_surface`).
pub fn release_surface(&mut self) {
self.queue.wait();
*self
.spare_surface
.get_mut()
@@ -3585,6 +3682,7 @@ impl Renderer {
}
/// Reattach a window after surface loss or a failed renderer replacement.
pub fn recreate_surface(&mut self) -> Result<()> {
self.queue.wait();
if self.offscreen.is_some() {
return Ok(());
}
@@ -4305,6 +4403,8 @@ impl Renderer {
/// Configures the window surface again. One that fails is still the one
/// wgpu configured before, which acquisition reports as a validation error.
fn configure_surface(&self) {
// The submit thread may still present to the old swapchain.
self.queue.wait();
if let Some(surface) = &self.surface
&& let Err(e) = Self::configure(
&self.device,
@@ -4944,6 +5044,7 @@ impl Renderer {
}
/// `checks`: the error scopes of a scaled screenshot's frame, checked before its PNG is written.
fn draw_frame(&mut self, frame: &Frame, checks: Option<GpuChecks>) -> Result<bool> {
self.land_frame();
// World frames alone follow the shader pack: the game loads the picked one
// before it draws one. Title frames draw no terrain and keep the layout
// through a pack dropped (Shader Options) or not loaded yet (`Renderer::new`),
@@ -6191,6 +6292,14 @@ impl Renderer {
if culling && let Some(cull) = &mut self.cull {
cull.bind_depth(&self.device, world_depth);
}
// The hand draws at the end of the world pass, after a depth reset, when nothing runs
// between them (a scaled world's post pass, HDR's world copy, a native mod's
// `native_world_drawn`): one render pass fewer, ~10 µs of wgpu's encoding a frame.
let hand_in_world = (frame.hand.len() / 4) > 0
&& !pack_draws_hand
&& scaled.is_none()
&& self.hdr.is_none()
&& !native_render::world_drawn_hooked();
if oit_on {
let size = self
.world_post
@@ -6519,6 +6628,16 @@ impl Renderer {
.frame
.fit_opaque(&self.device, world_depth.texture(), color);
}
if let Some(native) = self.native.as_mut().filter(|_| !frame.title) {
let writes = native.material.as_ref().is_some_and(|m| m.writes_surface);
let depth = world_depth.texture();
let size = if writes {
[depth.width(), depth.height()]
} else {
[1, 1]
};
native.frame.fit_surface(&self.device, size);
}
// One of Horizon's own packs with a material stage: terrain draws with its pipelines,
// which read the pack's frame at group 3.
let material = self
@@ -6541,6 +6660,17 @@ impl Renderer {
pass.set_bind_group(horizon_shader_api::stages::GROUP, group, &[]);
}
};
// A pack's solid terrain that calls `set_surface` also writes the surface target, in
// passes of its own between the sky and everything else.
let surface = self
.native
.as_ref()
.filter(|n| {
!frame.title
&& !pack_draws_terrain
&& n.material.as_ref().is_some_and(|m| m.writes_surface)
})
.map(|n| n.frame.surface_view.clone());
{
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: world_view,
@@ -6626,6 +6756,28 @@ impl Renderer {
pass.draw(0..3, 0..1);
}
}
let terrain_pass =
|encoder: &mut wgpu::CommandEncoder, surface: &wgpu::TextureView, clear: bool| {
// Its lifetime forgotten: a closure cannot tie it to `encoder`'s.
let mut pass = surface_pass(encoder, world_view, surface, world_depth, clear)
.forget_lifetime();
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
pass
};
// The occlusion test's pipeline draws into the world's colour alone.
let plain_pass = |encoder: &mut wgpu::CommandEncoder| {
let mut pass =
world_pass_after_culling(encoder, world_view, world_depth, true, None, "")
.forget_lifetime();
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
pass
};
if let Some(surface) = &surface {
drop(pass);
pass = terrain_pass(&mut encoder, surface, true);
}
match self
.flat
.as_ref()
@@ -6654,18 +6806,25 @@ impl Renderer {
// that the test saw, copied after it.
Some(cull) => {
draw(&mut pass, &flat.calls[..flat.phase1_calls]);
if surface.is_some() {
drop(pass);
pass = plain_pass(&mut encoder);
}
cull.test(&mut pass);
drop(pass);
cull.test_pass(&mut encoder, self.own_timing.as_mut());
flat.flatten_phase2(&mut encoder, self.own_timing.as_mut());
pass = world_pass_after_culling(
&mut encoder,
world_view,
world_depth,
keep_depth,
self.own_timing.as_mut(),
"World after culling",
);
pass = match &surface {
Some(surface) => terrain_pass(&mut encoder, surface, false),
None => world_pass_after_culling(
&mut encoder,
world_view,
world_depth,
keep_depth,
self.own_timing.as_mut(),
"World after culling",
),
};
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
for &pipeline in passes {
@@ -6697,17 +6856,24 @@ impl Renderer {
pass.set_pipeline(pipeline);
self.draw_sections(&mut pass, &self.indirect, 0, 0, phase1_draws);
}
if surface.is_some() {
drop(pass);
pass = plain_pass(&mut encoder);
}
cull.test(&mut pass);
drop(pass);
cull.test_pass(&mut encoder, self.own_timing.as_mut());
pass = world_pass_after_culling(
&mut encoder,
world_view,
world_depth,
keep_depth,
self.own_timing.as_mut(),
"World after culling",
);
pass = match &surface {
Some(surface) => terrain_pass(&mut encoder, surface, false),
None => world_pass_after_culling(
&mut encoder,
world_view,
world_depth,
keep_depth,
self.own_timing.as_mut(),
"World after culling",
),
};
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
pass.set_index_buffer(
@@ -6735,6 +6901,11 @@ impl Renderer {
}
}
}
if surface.is_some() {
drop(pass);
pass = plain_pass(&mut encoder);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
}
if entity_quads > 0 {
pass.set_pipeline(&self.entity_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
@@ -7197,6 +7368,12 @@ impl Renderer {
0..1,
);
}
if hand_in_world {
pass.set_pipeline(&self.depth_reset);
pass.set_bind_group(0, &self.group, &[]);
pass.draw(0..3, 0..1);
self.draw_hand(&mut pass, hand_quads, world_quads + additive_quads);
}
}
if let Some((color, depth)) = &scaled
&& pack_post
@@ -7317,7 +7494,7 @@ impl Renderer {
post.run(
&self.device,
&mut encoder,
(color, depth),
(color, depth, &native.frame.surface_view),
&view_target,
size,
history_reset,
@@ -7358,7 +7535,7 @@ impl Renderer {
if let Some(hdr) = &self.hdr {
hdr.keep_world(&mut encoder, self.own_timing.as_mut());
}
if hand_quads > 0 && !pack_draws_hand {
if hand_quads > 0 && !pack_draws_hand && !hand_in_world {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view_target,
resolve_target: None,
@@ -7382,15 +7559,7 @@ impl Renderer {
}),
..Default::default()
});
pass.set_pipeline(&self.entity_pipeline);
pass.set_bind_group(0, &self.hand_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..hand_quads * 6,
((world_quads + additive_quads) * 4) as i32,
0..1,
);
self.draw_hand(&mut pass, hand_quads, world_quads + additive_quads);
drop(pass);
if let Some(hdr) = &self.hdr {
hdr.mark_hand(&mut encoder, self.own_timing.as_mut());
@@ -7578,15 +7747,54 @@ impl Renderer {
let size = [self.config.width, self.config.height];
Some(copy_frame(&self.device, &mut encoder, &texture, size))
});
let commands = crate::spans::time("encode", || encoder.finish());
let first_frame = self.first_frame == FirstFrame::Submit;
// Finished, submitted and presented on the submit thread when nothing after the
// submission needs this one: screenshots' readbacks, the error scopes (one per
// thread), the first frame's startup log, VSync's frame callback, a reconfigure.
let (encoder, surface_frame, after) = if readback.is_none()
&& hdr_readback.is_none()
&& checks.is_none()
&& !first_frame
&& !reconfigure
&& self.hdr_marker.is_none()
&& (surface_frame.is_none() || self.config.present_mode != wgpu::PresentMode::Fifo)
{
let offscreen = surface_frame.is_none();
// Culling reads its results back as soon as the frame is submitted, as here.
let after = self
.cull
.as_mut()
.and_then(|c| c.submitted())
.map(|f| Box::new(f) as submit::AfterSubmit);
let job = submit::Job {
encoder,
surface: surface_frame,
after,
};
match self.queue.submit_later(job) {
Ok(()) => {
self.deferred = Some(offscreen);
self.last_encode = started.elapsed().saturating_sub(self.last_wait);
self.visible = visible;
self.visible_lods = visible_lods;
self.shadow_visible = shadow_visible;
return Ok(true);
}
Err(back) => (back.encoder, back.surface, back.after),
}
} else {
(encoder, surface_frame, None)
};
let commands = crate::spans::time("encode", || encoder.finish());
if first_frame {
startup_step(format_args!("submitting the first frame"));
self.first_frame = FirstFrame::Drawn;
}
let submission = crate::spans::time("submit", || self.queue.submit([commands]));
if let Some(cull) = &mut self.cull {
cull.submitted();
if let Some(read) = after {
read();
} else if let Some(read) = self.cull.as_mut().and_then(|c| c.submitted()) {
read();
}
if let Some(timer) = &mut self.gpu_timer {
timer.submitted(&self.queue);
@@ -7734,8 +7942,33 @@ pub(crate) fn copy_frame(
buffer
}
impl Renderer {
/// Take in the frame the submit thread submitted: start its readbacks and, offscreen,
/// keep at most two frames ahead of the GPU, as a frame submitted in place does.
fn land_frame(&mut self) {
let Some(offscreen) = self.deferred.take() else {
return;
};
let submission = self.queue.landed();
if let Some(timer) = &mut self.gpu_timer {
timer.submitted(&self.queue);
timer.collect(&self.device, &mut self.gpu_times);
}
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
timer.submitted(&self.queue);
}
if offscreen && let Some(submission) = submission {
self.in_flight.push_back(submission);
if !self.paced_elsewhere {
self.wait_frames();
}
}
}
/// No swapchain paces offscreen frames: keep two on the GPU at most.
pub fn wait_in_flight(&mut self) {
self.land_frame();
self.wait_frames();
}
fn wait_frames(&mut self) {
while self.in_flight.len() > 2 {
let index = self.in_flight.pop_front();
let _ = self.device.poll(wgpu::PollType::Wait {
+15 -13
View File
@@ -585,23 +585,25 @@ impl Cull {
self.current = Some(self.next);
}
/// After the frame's submission: start reading its results back.
pub fn submitted(&mut self) {
let Some(i) = self.current.take() else {
return;
};
/// The frame's results to read back, started by calling it right after the frame's
/// submission (on the submit thread, when the frame goes there).
pub fn submitted(&mut self) -> Option<impl FnOnce() + Send + 'static> {
let i = self.current.take()?;
let r = &self.readbacks[i];
let state = r.state.clone();
state.store(PENDING, Ordering::Release);
r.buffer
.slice(..r.slots.len() as u64 * 4)
.map_async(wgpu::MapMode::Read, move |result| {
state.store(
if result.is_ok() { MAPPED } else { FREE },
Ordering::Release,
);
});
let (buffer, bytes) = (r.buffer.clone(), r.slots.len() as u64 * 4);
self.next = (i + 1) % READBACKS;
Some(move || {
buffer
.slice(..bytes)
.map_async(wgpu::MapMode::Read, move |result| {
state.store(
if result.is_ok() { MAPPED } else { FREE },
Ordering::Release,
);
});
})
}
fn make_group(
@@ -139,17 +139,32 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
// Water draws translucent and lava solid, so the pass resolves the fluid kind.
let fluid = select(0u, select(2u, 1u, translucent), in.fluid_quad != 0u);
let normal = horizon_material_normal(in.position);
horizon_material_face_normal = normal;
horizon_surface = vec4<f32>(0.0);
let tangent = horizon_material_tangent(in.position, in.uv, normal);
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;
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
var<private> horizon_material_face_normal: vec3<f32>;
struct HorizonMaterialSolid {
@location(0) color: vec4<f32>,
// The surface target's data: `set_surface`'s, then the fragment's depth, which the post
// stage's `surface_at` compares with the final depth.
@location(1) surface: vec4<f32>,
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
let color = horizon_material_color(in, false);
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
return horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
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);
out.surface = vec4(horizon_surface.xyz, in.clip.z);
return out;
}
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, true);
@@ -189,6 +204,8 @@ pub struct NativeMaterial {
pub solid: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
pub flat: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>,
/// The stage calls `set_surface`: its solid pipelines also write the surface target.
pub writes_surface: bool,
}
/// terrain.wgsl's terrain section (its globals, bindings, sampling, lighting and fog), with
@@ -220,6 +237,10 @@ fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
/// `materials`.
/// The surface target (the post stage's `scene_surface`): x the normal (two snorm16 in its
/// bits), y smoothness, z the pack's data, w the fragment's depth.
pub const SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
fn shader_source(source: &str, tags: &[(u32, u32)], materials: &[(u32, u32)]) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{ENTRIES}",
@@ -250,6 +271,7 @@ impl NativeMaterial {
constants: &[(String, f64)],
fog: bool,
) -> Result<Self, String> {
let writes_surface = horizon_shader_api::stages::calls_set_surface(&stage.source);
let module = module(stage, shape.sprite_tags, shape.sprite_materials)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
@@ -296,6 +318,18 @@ impl NativeMaterial {
constants: &constants,
..Default::default()
};
let solid_targets = [
Some(wgpu::ColorTargetState {
format: shape.format,
blend,
write_mask: wgpu::ColorWrites::ALL,
}),
Some(wgpu::ColorTargetState {
format: SURFACE_FORMAT,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
}),
];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&stage.label),
layout: Some(layout),
@@ -321,11 +355,11 @@ impl NativeMaterial {
module: &module,
entry_point: Some(fragment),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format: shape.format,
blend,
write_mask: wgpu::ColorWrites::ALL,
})],
targets: if fragment == "horizon_material_fs_solid" && writes_surface {
&solid_targets
} else {
&solid_targets[..1]
},
}),
multiview_mask: None,
cache: None,
@@ -370,6 +404,7 @@ impl NativeMaterial {
solid,
translucent,
flat,
writes_surface,
})
}
}
@@ -14,7 +14,7 @@ use super::Frame;
const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// The world's colour and depth views a set of bind groups was made for.
type WorldViews = (wgpu::TextureView, wgpu::TextureView);
type WorldViews = (wgpu::TextureView, wgpu::TextureView, wgpu::TextureView);
/// Where a post pass draws.
#[derive(Clone, Copy, PartialEq)]
@@ -103,6 +103,9 @@ pub struct NativePost {
/// The `frame` every stage of a pack reads (group 3, binding 0), written once a frame, and
/// what the material stage reads besides.
pub struct NativeFrame {
/// The solid terrain's second target, the post stage's `scene_surface`.
surface: wgpu::Texture,
pub surface_view: wgpu::TextureView,
pub(super) buffer: wgpu::Buffer,
/// Group 3 of the sky and material stages.
pub layout: wgpu::BindGroupLayout,
@@ -270,6 +273,8 @@ impl NativeFrame {
label: Some("Native shader pack frame"),
entries: &entries,
});
let surface = surface_target(device, [1, 1]);
let surface_view = surface.create_view(&Default::default());
let opaque_depth = depth_copy(device, [1, 1]);
let opaque_color = color_copy(device, [1, 1], wgpu::TextureFormat::Rgba8Unorm);
let blank = copy_target(
@@ -332,6 +337,8 @@ impl NativeFrame {
buffer,
layout,
group,
surface,
surface_view,
opaque_depth,
opaque_color,
reflection: blank.clone(),
@@ -468,6 +475,14 @@ impl NativeFrame {
self.regroup(device);
}
/// Sizes the surface target: the world's while a material stage writes it, else a blank.
pub fn fit_surface(&mut self, device: &wgpu::Device, size: [u32; 2]) {
if [self.surface.width(), self.surface.height()] != size {
self.surface = surface_target(device, size);
self.surface_view = self.surface.create_view(&Default::default());
}
}
/// Binds `view` as this frame's `reflection` (`None`: the blank). The mirror is drawn
/// before the stages read it.
pub fn set_reflection(&mut self, device: &wgpu::Device, view: Option<&wgpu::TextureView>) {
@@ -573,6 +588,24 @@ pub fn present_constants<'a>(
.collect()
}
/// The solid terrain's surface target of `size`, drawn and read.
fn surface_target(device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("Native shader pack surface"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: super::native_material::SURFACE_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
}
fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
@@ -652,6 +685,7 @@ impl NativePost {
texture_entry(8, float),
texture_entry(9, wgpu::TextureSampleType::Depth),
texture_entry(10, float),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
];
entries.extend(pack_texture_entries(frame.textures.len()));
entries
@@ -757,7 +791,7 @@ impl NativePost {
fn prepare(
&mut self,
device: &wgpu::Device,
world: (&wgpu::TextureView, &wgpu::TextureView),
world: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView),
size: [u32; 2],
history_reset: bool,
) {
@@ -830,8 +864,11 @@ impl NativePost {
if self
.groups
.as_ref()
.is_some_and(|((color, depth), blank, _)| {
color == world.0 && depth == world.1 && *blank == blank_history
.is_some_and(|((color, depth, surface), blank, _)| {
color == world.0
&& depth == world.1
&& surface == world.2
&& *blank == blank_history
})
{
return;
@@ -893,6 +930,10 @@ impl NativePost {
binding: 9,
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(world.2),
},
wgpu::BindGroupEntry {
binding: 10,
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
@@ -920,7 +961,11 @@ impl NativePost {
})
})
.collect();
self.groups = Some(((world.0.clone(), world.1.clone()), blank_history, groups));
self.groups = Some((
(world.0.clone(), world.1.clone(), world.2.clone()),
blank_history,
groups,
));
}
/// Runs the passes over the world target `world` (colour, depth) of `size`, the last onto
@@ -929,7 +974,7 @@ impl NativePost {
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
world: (&wgpu::TextureView, &wgpu::TextureView),
world: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView),
screen: &wgpu::TextureView,
size: [u32; 2],
history_reset: bool,
@@ -1349,7 +1394,7 @@ mod tests {
post.run(
&device,
&mut encoder,
(&color, &depth),
(&color, &depth, &frame.surface_view),
&view,
[4, 4],
true,
@@ -1456,7 +1501,7 @@ file = \"b.wgsl\"
post.run(
&device,
&mut encoder,
(&color, &depth),
(&color, &depth, &frame.surface_view),
&view,
[4, 4],
history_reset,
@@ -6,6 +6,19 @@
use super::Vertex;
use super::pack_draw::EntitySpan;
use glam::{DVec3, Mat4};
use std::sync::atomic::{AtomicBool, Ordering};
static MODS_BUILT_IN: AtomicBool = AtomicBool::new(false);
/// The game was built with native mods (the game sets it at start): one of them may draw in
/// [`native_world_drawn`], so the first-person hand keeps its own pass after it.
pub fn set_mods_built_in(on: bool) {
MODS_BUILT_IN.store(on, Ordering::Relaxed);
}
/// Whether [`native_world_drawn`] may draw ([`set_mods_built_in`]).
pub fn world_drawn_hooked() -> bool {
MODS_BUILT_IN.load(Ordering::Relaxed)
}
/// What a mod can draw with once the world is drawn: after the sky, terrain, entities,
/// particles, weather and the world post pass, before the first-person hand, the HUD and the
@@ -0,0 +1,228 @@
//! The frame's commands finished, submitted and presented on a thread of their own.
//!
//! wgpu records a pass's commands lazily: `CommandEncoder::finish` validates and
//! encodes the whole frame (about 0.1 ms with the submit), work the window's thread
//! would otherwise wait for before building the next frame. The renderer hands the
//! frame's encoder to [`Queue::submit_later`] and goes on; every use of the queue
//! ([`Deref`]) first waits for that frame to be submitted, so writes, copies and
//! submissions keep the order they had on one thread.
use std::ops::Deref;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, mpsc};
/// Whether frames go to the submit thread, unless `HORIZON_SUBMIT_THREAD` says (`0` off, `1` on):
/// on Linux, where it was measured faster (+32% FPS in a CPU-bound view on Vulkan, #5779). On
/// macOS's Metal it stalled: offscreen at render distance 12, a tenth to a half of the frame rate
/// with 20-180 ms p99 frames, against the same binary without it. Windows is not measured yet.
pub fn wanted() -> bool {
match std::env::var("HORIZON_SUBMIT_THREAD").as_deref() {
Ok("0") => false,
Ok("1") => true,
_ => cfg!(target_os = "linux"),
}
}
/// Work to start right after a frame's submission (a readback's `map_async`).
pub type AfterSubmit = Box<dyn FnOnce() + Send>;
/// A frame for the submit thread: its commands and, in a window, the surface to present.
pub struct Job {
pub encoder: wgpu::CommandEncoder,
pub surface: Option<wgpu::SurfaceTexture>,
pub after: Option<AfterSubmit>,
}
struct Thread {
jobs: Option<mpsc::Sender<Job>>,
handle: Option<std::thread::JoinHandle<()>>,
}
/// What the submit thread sent back, once it is taken in by [`Queue::wait`].
struct Landing {
done: mpsc::Receiver<wgpu::SubmissionIndex>,
landed: Option<wgpu::SubmissionIndex>,
}
/// The device's queue, with frames submitted on the submit thread when it runs.
pub struct Queue {
queue: wgpu::Queue,
thread: Option<Thread>,
/// A frame went to the submit thread and has not been waited for.
pending: AtomicBool,
landing: Mutex<Option<Landing>>,
}
impl Queue {
/// `threaded`: frames may go to a submit thread (the renderer asks [`wanted`]).
pub fn new(queue: wgpu::Queue, threaded: bool) -> Self {
let mut this = Self {
queue,
thread: None,
pending: AtomicBool::new(false),
landing: Mutex::new(None),
};
if !threaded {
return this;
}
let (jobs, job_rx) = mpsc::channel::<Job>();
let (done_tx, done) = mpsc::channel();
let queue = this.queue.clone();
let spawned = std::thread::Builder::new()
.name("Render submit".into())
.spawn(move || {
for job in job_rx {
let commands = job.encoder.finish();
let submission = queue.submit([commands]);
if let Some(after) = job.after {
after();
}
if let Some(surface) = job.surface {
queue.present(surface);
}
if done_tx.send(submission).is_err() {
break;
}
}
});
match spawned {
Ok(handle) => {
this.thread = Some(Thread {
jobs: Some(jobs),
handle: Some(handle),
});
*this.lock() = Some(Landing { done, landed: None });
}
Err(e) => log::warn!("Horizon renderer: no submit thread ({e}); submitting in place"),
}
this
}
fn lock(&self) -> std::sync::MutexGuard<'_, Option<Landing>> {
self.landing
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Whether frames can go to the submit thread.
pub fn threaded(&self) -> bool {
self.thread.is_some()
}
/// Finish, submit and present the frame on the submit thread, then run its `after`;
/// given back, with nothing done, when there is none.
pub fn submit_later(&self, job: Job) -> Result<(), Box<Job>> {
let Some(jobs) = self.thread.as_ref().and_then(|t| t.jobs.as_ref()) else {
return Err(Box::new(job));
};
self.wait();
match jobs.send(job) {
Ok(()) => {
self.pending.store(true, Ordering::Release);
Ok(())
}
Err(mpsc::SendError(job)) => Err(Box::new(job)),
}
}
/// Wait until the frame on the submit thread is submitted and presented.
pub fn wait(&self) {
if !self.pending.load(Ordering::Acquire) {
return;
}
let mut landing = self.lock();
if !self.pending.load(Ordering::Acquire) {
return;
}
if let Some(l) = landing.as_mut() {
// A thread that died (a wgpu panic on it) panics here, as it would have in place.
let submission = l
.done
.recv()
.expect("Horizon renderer: the submit thread stopped");
l.landed = Some(submission);
}
self.pending.store(false, Ordering::Release);
}
/// The submission of the frame [`submit_later`](Self::submit_later) took, once it is
/// made; `None` if it was taken already.
pub fn landed(&self) -> Option<wgpu::SubmissionIndex> {
self.wait();
self.lock().as_mut().and_then(|l| l.landed.take())
}
}
impl Deref for Queue {
type Target = wgpu::Queue;
fn deref(&self) -> &wgpu::Queue {
self.wait();
&self.queue
}
}
impl Drop for Queue {
fn drop(&mut self) {
self.wait();
if let Some(thread) = &mut self.thread {
thread.jobs.take();
if let Some(handle) = thread.handle.take() {
let _ = handle.join();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
fn buffer(device: &wgpu::Device, usage: wgpu::BufferUsages) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 4,
usage,
mapped_at_creation: false,
})
}
#[test]
fn writes_after_a_frame_on_the_thread_land_after_it() {
let Some((device, queue)) = device() else {
return;
};
let queue = Queue::new(queue, true);
assert!(queue.threaded());
use wgpu::BufferUsages as U;
let source = buffer(&device, U::COPY_SRC | U::COPY_DST);
let copy = buffer(&device, U::COPY_SRC | U::COPY_DST);
let read = buffer(&device, U::MAP_READ | U::COPY_DST);
queue.write_buffer(&source, 0, &[1, 2, 3, 4]);
for frame in 0..3u8 {
// The frame copies `source`; the next frame's write must not reach that copy.
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(&source, 0, &copy, 0, 4);
let job = Job {
encoder,
surface: None,
after: None,
};
assert!(queue.submit_later(job).is_ok());
queue.write_buffer(&source, 0, &[frame + 10; 4]);
assert!(queue.landed().is_some(), "frame {frame} submitted");
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(&copy, 0, &read, 0, 4);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let expected = if frame == 0 {
[1, 2, 3, 4]
} else {
[frame + 9; 4]
};
assert_eq!(*read.slice(..).get_mapped_range().unwrap(), expected);
read.unmap();
}
}
}
+190 -2
View File
@@ -246,6 +246,61 @@ impl FarDetail {
}
}
/// What the section mesher does with leaf faces inside a canopy. Terrain
/// geometry only: entities, particles and block entities are untouched.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LeafCullingMode {
/// Every leaf face, as vanilla's fancy leaves draw them.
#[default]
Off,
/// Leaves surrounded on all six sides by leaves or full opaque cubes draw
/// in the opaque layer, so their cutouts no longer show the layers behind.
Solid,
/// Like `Solid`, but a leaf with one open side draws opaque too: the
/// exposed shell thins by a layer and the canopy looks chunkier.
SolidAggressive,
/// Drops the faces two leaves share. Faces toward air, cavities, partial
/// models, unknown neighbours and section boundaries stay.
Hollow,
}
impl LeafCullingMode {
pub const ALL: [LeafCullingMode; 4] = [
LeafCullingMode::Off,
LeafCullingMode::Solid,
LeafCullingMode::SolidAggressive,
LeafCullingMode::Hollow,
];
pub fn name(self) -> &'static str {
match self {
LeafCullingMode::Off => "off",
LeafCullingMode::Solid => "solid",
LeafCullingMode::SolidAggressive => "solid_aggressive",
LeafCullingMode::Hollow => "hollow",
}
}
pub fn label(self) -> &'static str {
match self {
LeafCullingMode::Off => "Off",
LeafCullingMode::Solid => "Solid",
LeafCullingMode::SolidAggressive => "Solid Aggressive",
LeafCullingMode::Hollow => "Hollow",
}
}
/// Whether this mode draws interior leaf faces in the solid layer.
pub fn is_interior(self) -> bool {
matches!(
self,
LeafCullingMode::Solid | LeafCullingMode::SolidAggressive
)
}
fn parse(s: &str) -> Self {
Self::ALL
.into_iter()
.find(|mode| mode.name() == s)
.unwrap_or_default()
}
}
/// TAA upscaling: the world renders this much smaller than the resolution scale's size,
/// and TAA builds the full-size picture from the jittered frames (FSR's and DLSS's ratios).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
@@ -361,6 +416,46 @@ impl Graphics {
pub fn lod_distance_on(&self, info: &wgpu::AdapterInfo) -> u32 {
lod_distance_capped(self.lod_distance, info)
}
/// Whether the selected packs leave vanilla's block models in place.
/// Horizon's occlusion assumptions (entity and leaf culling) hold only
/// for those: a pack can replace an opaque cube's model or a leaf's.
pub fn vanilla_models(&self, resource_packs: &[String]) -> bool {
resource_packs.iter().all(|id| id == "vanilla")
}
/// The same check including packs overlaid by the connected server.
/// Their ids are namespaced as `server/...`, so an active server pack
/// disables model-dependent occlusion assumptions.
pub fn vanilla_models_with_server_packs<'a>(
&self,
resource_packs: impl IntoIterator<Item = &'a str>,
server_packs: impl IntoIterator<Item = &'a str>,
) -> bool {
resource_packs
.into_iter()
.chain(server_packs)
.all(|id| id == "vanilla")
}
/// The leaf culling the mesher may apply. A pack that can replace leaf
/// models falls back to `Off`, which draws exactly vanilla's leaves.
pub fn leaf_culling_effective(&self, resource_packs: &[String]) -> LeafCullingMode {
if self.vanilla_models(resource_packs) {
self.leaf_culling
} else {
LeafCullingMode::Off
}
}
/// The effective mode when local and server resource packs are active.
pub fn leaf_culling_effective_with_server_packs<'a>(
&self,
resource_packs: impl IntoIterator<Item = &'a str>,
server_packs: impl IntoIterator<Item = &'a str>,
) -> LeafCullingMode {
if self.vanilla_models_with_server_packs(resource_packs, server_packs) {
self.leaf_culling
} else {
LeafCullingMode::Off
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Graphics {
@@ -373,6 +468,10 @@ pub struct Graphics {
pub cave_culling: bool,
/// Skip drawing entities hidden behind opaque terrain.
pub entity_culling: bool,
/// Terrain leaf faces inside a canopy (see [`LeafCullingMode`]); `Off` draws
/// vanilla's fancy leaves. Ignored with a resource pack that may replace
/// the vanilla leaf models ([`Graphics::leaf_culling_effective`]).
pub leaf_culling: LeafCullingMode,
/// Meshed sections uploaded per frame; 0 uploads all that are ready.
pub chunk_updates: u32,
/// Section mesh bytes uploaded per frame, in KiB; 0 is unlimited.
@@ -446,6 +545,7 @@ impl Default for Graphics {
far_detail: FarDetail::Full,
cave_culling: true,
entity_culling: false,
leaf_culling: LeafCullingMode::Off,
chunk_updates: 0,
upload_budget_kib: 0,
texture_budget_mib: 0,
@@ -515,6 +615,7 @@ impl Graphics {
"farDetail" => g.far_detail = FarDetail::parse(value),
"caveCulling" => g.cave_culling = flag(g.cave_culling),
"entityCulling" => g.entity_culling = flag(g.entity_culling),
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
"textureBudgetMiB" => {
@@ -580,12 +681,13 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
bool_text(self.cave_culling),
bool_text(self.entity_culling),
self.leaf_culling.name(),
self.chunk_updates,
self.upload_budget_kib,
self.texture_budget_mib,
@@ -742,6 +844,7 @@ mod tests {
far_detail: FarDetail::Reduced,
cave_culling: false,
entity_culling: true,
leaf_culling: LeafCullingMode::SolidAggressive,
chunk_updates: 12,
upload_budget_kib: 4096,
texture_budget_mib: 1536,
@@ -801,11 +904,96 @@ mod tests {
Graphics::default()
);
assert_eq!(
Graphics::parse("workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\n"),
Graphics::parse(
"workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\nleafCulling:maybe\n"
),
Graphics::default()
);
}
#[test]
fn leaf_culling_round_trips_its_stable_names() {
assert_eq!(Graphics::default().leaf_culling, LeafCullingMode::Off);
for mode in LeafCullingMode::ALL {
let g = Graphics {
leaf_culling: mode,
..Graphics::default()
};
assert_eq!(Graphics::parse(&g.serialize()), g, "{mode:?}");
assert_eq!(
Graphics::parse(&format!("leafCulling:{}\n", mode.name())).leaf_culling,
mode
);
}
for text in [
"leafCulling:other\n",
"leafCulling:\n",
"leafCulling:SOLID\n",
] {
assert_eq!(
Graphics::parse(text).leaf_culling,
LeafCullingMode::Off,
"{text:?}"
);
}
}
#[test]
fn presets_keep_the_leaf_culling_choice() {
for mode in LeafCullingMode::ALL {
for preset in Preset::ALL {
let mut s = crate::settings::Settings::default();
s.horizon.leaf_culling = mode;
apply_preset(preset, &mut s);
assert_eq!(s.horizon.leaf_culling, mode, "{preset:?}");
assert_eq!(Graphics::parse(&s.horizon.serialize()), s.horizon);
}
}
}
#[test]
fn custom_packs_turn_leaf_culling_off() {
let g = Graphics {
leaf_culling: LeafCullingMode::Hollow,
..Graphics::default()
};
assert!(g.vanilla_models(&["vanilla".into()]));
assert_eq!(
g.leaf_culling_effective(&["vanilla".into()]),
LeafCullingMode::Hollow
);
assert!(!g.vanilla_models(&["vanilla".into(), "My Pack".into()]));
assert_eq!(
g.leaf_culling_effective(&["vanilla".into(), "My Pack".into()]),
LeafCullingMode::Off
);
assert_eq!(g.leaf_culling_effective(&[]), LeafCullingMode::Hollow);
}
#[test]
fn server_pack_apply_disables_leaf_culling_and_removal_restores_it() {
let g = Graphics {
leaf_culling: LeafCullingMode::Solid,
..Graphics::default()
};
let selected = ["vanilla"];
let applied_server_pack = ["server/0123456789abcdef/model-pack.zip"];
assert_eq!(
g.leaf_culling_effective_with_server_packs(selected, applied_server_pack),
LeafCullingMode::Off,
"an applied server pack can replace block models"
);
assert!(!g.vanilla_models_with_server_packs(selected, applied_server_pack));
assert_eq!(
g.leaf_culling_effective_with_server_packs(selected, []),
LeafCullingMode::Solid,
"removing the server pack restores the selected local mode"
);
assert!(g.vanilla_models_with_server_packs(selected, []));
}
#[test]
fn integrated_gpus_other_than_apple_cap_the_lod_distance() {
let adapter = |name: &str, device_type| wgpu::AdapterInfo {
+103 -4
View File
@@ -2,7 +2,9 @@
//! they show, in what groups, with what description and cost, and their
//! input. How they look comes from `graphics_style`.
use crate::{
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset, Upscaling},
graphics::{
FarDetail, GraphicsApi, LeafCullingMode, Overlay, Pacing, PresentMode, Preset, Upscaling,
},
graphics_style::{Cost, STYLE},
hud::{HudAtlas, HudBatch},
lang,
@@ -27,6 +29,7 @@ pub enum H {
FarDetail,
CaveCulling,
EntityCulling,
LeafCulling,
ChunkUpdates,
UploadBudget,
Sky,
@@ -187,6 +190,12 @@ pub fn rows(level: Level) -> Vec<Row> {
Medium,
"How far away mobs and items are drawn, as a share of normal.",
),
h(
H::ChunkUpdates,
Low,
"Meshed sections uploaded per frame. Lower is smoother while loading.",
),
Row::Header("Culling"),
h(
H::CaveCulling,
Saves,
@@ -198,9 +207,9 @@ pub fn rows(level: Level) -> Vec<Row> {
"Hides models behind opaque blocks. Entities update. Off with custom packs.",
),
h(
H::ChunkUpdates,
Low,
"Meshed sections uploaded per frame. Lower is smoother while loading.",
H::LeafCulling,
Saves,
"Draws or drops leaf faces inside canopies. Off keeps vanilla's fancy leaves.",
),
Row::Header("Quality"),
v(
@@ -446,6 +455,16 @@ fn description(item: Item, s: &Settings, text: &'static str) -> &'static str {
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
"Set up for your display. Leave on Auto unless it looks wrong."
}
(Item::Horizon(H::LeafCulling), _) => match s.horizon.leaf_culling {
LeafCullingMode::Off => "Every leaf face. Vanilla's fancy leaves.",
LeafCullingMode::Solid => {
"Leaves fully inside a canopy draw solid: fewer see-through layers."
}
LeafCullingMode::SolidAggressive => {
"Leaves with one open side draw solid too: faster, chunkier edges."
}
LeafCullingMode::Hollow => "Drops faces between leaves: faster, but gaps look through.",
},
_ => text,
}
}
@@ -516,6 +535,7 @@ impl H {
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)),
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
H::ChunkUpdates => match g.chunk_updates {
0 => labelled("Chunk Updates", "Unlimited"),
n => labelled("Chunk Updates", lang::tr("%s per frame", &[&n])),
@@ -649,6 +669,9 @@ impl H {
}
H::CaveCulling => g.cave_culling = !g.cave_culling,
H::EntityCulling => g.entity_culling = !g.entity_culling,
H::LeafCulling => {
g.leaf_culling = step(&LeafCullingMode::ALL, g.leaf_culling, back);
}
H::Sky => g.sky = !g.sky,
H::Fog => g.fog = !g.fog,
H::Weather => g.weather = !g.weather,
@@ -1828,6 +1851,82 @@ mod tests {
assert!(shown(&s));
}
#[test]
fn leaf_culling_cycles_modes_in_the_advanced_culling_group() {
let mut s = Settings::default();
let item = Item::Horizon(H::LeafCulling);
assert_eq!(item.switch(&s), None);
assert_eq!(item.label(&s).plain(), "Leaf Culling: Off");
for label in ["Solid", "Solid Aggressive", "Hollow", "Off"] {
item.cycle(&mut s, false, [854, 480]);
assert_eq!(item.label(&s).plain(), format!("Leaf Culling: {label}"));
assert_eq!(
crate::graphics::Graphics::parse(&s.horizon.serialize()).leaf_culling,
s.horizon.leaf_culling
);
}
item.cycle(&mut s, true, [854, 480]);
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Hollow);
// The three culling settings share one group, below Rendering's chunk
// updates and above Quality.
let rows = rows(Level::Advanced);
let culling = rows
.iter()
.position(|row| matches!(row, Row::Header("Culling")))
.unwrap();
assert!(matches!(
rows.get(culling + 1),
Some(Row::Setting(setting)) if setting.item == Item::Horizon(H::CaveCulling)
));
assert!(matches!(
rows.get(culling + 2),
Some(Row::Setting(setting)) if setting.item == Item::Horizon(H::EntityCulling)
));
assert!(matches!(
rows.get(culling + 3),
Some(Row::Setting(setting)) if setting.item == item
));
assert!(matches!(
rows.get(culling + 4),
Some(Row::Header("Quality"))
));
// The description names what the picked mode does.
for mode in LeafCullingMode::ALL {
s.horizon.leaf_culling = mode;
let text = description(item, &s, "fallback");
assert_ne!(text, "fallback", "{mode:?}");
assert!(text.len() < 80, "{mode:?}: {text}");
}
// A click cycles it and leaves the preset, as the other quality
// settings do.
let mut ui = GraphicsUi::default();
let size = (480.0, 4000.0);
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
let control = widgets
.iter()
.find(|w| matches!(w.target, Target::Item(i) if i == item))
.unwrap();
s.horizon.preset = Preset::Balanced;
s.horizon.leaf_culling = LeafCullingMode::Off;
assert_eq!(
ui.click(
Level::Advanced,
&mut s,
(control.x + 1.0, control.y + 1.0),
size,
[854, 480],
false,
false
),
vec![Action::ButtonClick, Action::Changed]
);
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Solid);
assert_eq!(s.horizon.preset, Preset::Custom);
}
#[test]
fn entity_culling_is_opt_in_and_toggles_from_advanced_graphics() {
let mut s = Settings::default();
+37 -7
View File
@@ -83,6 +83,8 @@ pub struct HudAtlas {
/// A resource pack's own `core/text` shader for the GUI's text, translated;
/// none draws the text natively.
pub text_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
/// The packs' OptiFine random entity textures, whose variants this atlas packs.
pub random_textures: horizon_optifine::random_textures::RandomTextures,
white: Rect,
}
/// Atlas rows reserved for glyphs rasterized at run time.
@@ -479,6 +481,7 @@ impl HudAtlas {
glint_armor: (vec![0; 4], 1, 1),
white,
text_shader: None,
random_textures: Default::default(),
}
}
/// Shelf placements of `images` (tallest first) in the narrowest atlas
@@ -567,15 +570,41 @@ impl HudAtlas {
let s = p.to_string_lossy();
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
});
// OptiFine random entity variants, under the names their scan gives them (those
// outside `textures/` are `<ns>:optifine/random/...`).
let random_textures = if folders.contains(&"entity") {
horizon_optifine::random_textures::RandomTextures::scan(root)
} else {
Default::default()
};
let mut named: HashMap<PathBuf, String> = HashMap::new();
for (file, name) in random_textures.files() {
named.insert(root.join(file), name.to_owned());
}
if !named.is_empty() {
// Those beside their texture are walked already.
let walked: HashSet<&PathBuf> = files.iter().collect();
let mut extra: Vec<PathBuf> = named
.keys()
.filter(|p| !walked.contains(p))
.cloned()
.collect();
extra.sort();
files.extend(extra);
}
type Decoded = (PackedImage, Option<TextureFlags>, Option<(u32, u32)>, bool);
let decode_one = |path: &PathBuf| -> Option<Decoded> {
let name = match path.strip_prefix(&textures) {
Ok(rel) => rel.with_extension("").to_string_lossy().replace('\\', "/"),
Err(_) => {
let rel = path.strip_prefix(&assets).ok()?.with_extension("");
let rel = rel.to_string_lossy().replace('\\', "/");
let (namespace, rest) = rel.split_once("/textures/")?;
format!("{namespace}:{rest}")
let name = if let Some(name) = named.get(path) {
name.clone()
} else {
match path.strip_prefix(&textures) {
Ok(rel) => rel.with_extension("").to_string_lossy().replace('\\', "/"),
Err(_) => {
let rel = path.strip_prefix(&assets).ok()?.with_extension("");
let rel = rel.to_string_lossy().replace('\\', "/");
let (namespace, rest) = rel.split_once("/textures/")?;
format!("{namespace}:{rest}")
}
}
};
// LabPBR's maps beside a texture are the world's (sprites.rs), not images to draw.
@@ -777,6 +806,7 @@ impl HudAtlas {
glint,
glint_armor,
text_shader: None,
random_textures,
font,
white,
})
+2
View File
@@ -18,9 +18,11 @@ pub mod camera;
pub mod chat;
pub mod chunk;
pub mod chunk_map;
pub mod cit;
pub mod clouds;
pub mod collisions;
pub mod command_block;
pub mod connected;
pub mod containers;
pub mod crash;
pub mod datapack;
+39 -4
View File
@@ -47,6 +47,9 @@ fn meshing_threads(cores: usize) -> usize {
/// terrain starts one sample job at most this often and no detail: it would
/// otherwise take the cores the near chunks and the frame need.
const HELD_SAMPLE_STEP: std::time::Duration = std::time::Duration::from_millis(500);
/// How long a tile whose sampling failed (`LodSampler::grid` gave no
/// columns: a server refused or lost it) waits before it is asked again.
const RETRY: std::time::Duration = std::time::Duration::from_secs(2);
/// The coarsest tile level: `Plan` roots cover at most `MAX_CHUNKS` chunks
/// around (a level-22 tile is 67 million blocks wide: the whole world).
pub(crate) const MAX_LEVEL: u8 = 22;
@@ -922,6 +925,8 @@ pub struct Lod {
near_loading: bool,
/// When `request_samples` last started a job while held.
held_sample_at: std::time::Instant,
/// Tiles whose sampling failed, and when they may be asked again.
retry: FxHashMap<TileKey, std::time::Instant>,
/// Each tile's samples, shared with mesh jobs in flight.
ready: FxHashMap<TileKey, Arc<Vec<Column>>>,
/// Fully covered subtrees, by tile.
@@ -1258,6 +1263,7 @@ impl Lod {
sample_jobs: SAMPLE_JOBS.max(sampling_threads(cores) + 1),
near_loading: false,
held_sample_at: std::time::Instant::now(),
retry: FxHashMap::default(),
ready: FxHashMap::default(),
complete: FxHashMap::default(),
active: FxHashSet::default(),
@@ -1689,7 +1695,16 @@ impl Lod {
}
fn finish_sample(&mut self, key: TileKey, samples: Vec<Column>) {
self.pending.remove(&key);
if self.plan.nodes.contains(&key) && samples.len() == CELLS * CELLS {
if samples.len() != CELLS * CELLS {
let now = std::time::Instant::now();
if self.retry.len() > 4096 {
self.retry.retain(|_, at| *at > now);
}
self.retry.insert(key, now + RETRY);
return;
}
self.retry.remove(&key);
if self.plan.nodes.contains(&key) {
let mut samples = samples;
if let (Some(old), Some(mask)) = (self.ready.get(&key), self.detail_masks.get(&key)) {
for i in 0..256 {
@@ -2241,8 +2256,16 @@ impl Lod {
if free == 0 {
return;
}
let now = std::time::Instant::now();
if self.held() {
self.held_sample_at = std::time::Instant::now();
self.held_sample_at = now;
}
// A tile whose sampling failed waits out `RETRY`; once it has, the
// queue is rebuilt so the tile is asked again.
let retrying = self.retry.len();
self.retry.retain(|_, at| *at > now);
if self.retry.len() != retrying {
self.wanted_stale = true;
}
// The queue is rebuilt when the view moves a chunk, not scanned and
// sorted on every frame.
@@ -2268,6 +2291,7 @@ impl Lod {
|| self.ready.contains_key(&key)
|| (self.plan.split.contains(&key) && wanted.class != BACKDROP)
|| !self.plan.nodes.contains(&key)
|| self.retry.contains_key(&key)
{
continue;
}
@@ -2282,8 +2306,19 @@ impl Lod {
}
fn rebuild_wanted(&mut self, camera: [i32; 2]) {
self.wanted.clear();
self.wanted
.extend(wanted_tiles(&self.plan, &self.ready, camera).map(std::cmp::Reverse));
// A failed tile waits out its retry delay (`retry`) before it is asked again.
let retry = &self.retry;
self.wanted.extend(
wanted_tiles(&self.plan, &self.ready, camera)
.filter(|w| {
!retry.contains_key(&TileKey {
x: w.x,
z: w.z,
level: w.level,
})
})
.map(std::cmp::Reverse),
);
}
#[cfg(test)]
+53 -8
View File
@@ -4,6 +4,7 @@
//! cardinal shading, tint sources, block offsets; see `lighting`). Fluids
//! follow `FluidRenderer` (see `fluid`).
mod fluid;
mod leaf_culling;
mod lighting;
mod sides;
use crate::{
@@ -11,6 +12,7 @@ use crate::{
biome::Biomes,
blocks::{BlockFacts, RenderShape},
chunk::{Chunk, MIN_Y},
graphics::LeafCullingMode,
models::catalog::BakedFace,
};
use horizon_core::{
@@ -470,6 +472,9 @@ struct Info {
overlay: bool,
/// `LeavesBlock`: with opaque leaves the faces between two leaf blocks are skipped.
leaves: bool,
/// A full opaque cube: `RenderShape::Model`, `can_occlude`, `solid_render`
/// and a full collision shape. The only block leaf culling treats as solid.
opaque_cube: bool,
blocks_flow: bool,
ice: bool,
sturdy: u8,
@@ -647,6 +652,12 @@ impl MeshTables {
legacy_solid: s.legacy_solid,
overlay: s.has_class("HalfTransparentBlock") || s.has_class("LeavesBlock"),
leaves: s.has_class("LeavesBlock"),
opaque_cube: leaf_culling::opaque_cube(
s.render_shape,
s.can_occlude,
s.solid_render,
s.full_collision,
),
blocks_flow: assets.blocks_fluid_flow.contains(&*s.name),
ice: s.has_class("IceBlock"),
sturdy: s.sturdy_full,
@@ -686,6 +697,14 @@ impl MeshTables {
tint_sources: i.tint_count,
})
}
/// Whether `state` is a full opaque cube: the only block leaf culling
/// treats as a solid neighbour. Glass, fluids, slabs, stairs and models
/// that only partly fill their block are not.
pub fn opaque_cube(&self, state: BlockStateId) -> bool {
self.info
.get(state.0 as usize)
.is_some_and(|i| i.opaque_cube)
}
/// `BlockTintSource.color(state)`: block-model colour without a level.
/// `grass` is `GrassColor.getDefaultColor()` from the active pack's colormap.
/// Untinted faces, blank sources and invalid tint indices return `None`.
@@ -860,14 +879,18 @@ impl Padded {
}
}
/// Per-worker temporary arrays. They never hold resource-pack references or
/// geometry still owned by the renderer, and total about 88 KiB per worker.
/// geometry still owned by the renderer, and total about 150 KiB per worker
/// (the leaf mask adds 8,000 cells, depths and a queue).
#[derive(Default)]
pub(crate) struct MeshScratch {
padded: Padded,
own: Option<Box<[u32; 4096]>>,
visibility: Vec<usize>,
leaf: leaf_culling::LeafMask,
}
#[cfg(test)]
mod leaf_culling_tests;
#[cfg(test)]
mod reuse_tests;
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
pub const OPAQUE_LAYER: u16 = 1 << 15;
@@ -1079,6 +1102,8 @@ pub struct Mesher<'a> {
pub smooth_lighting: bool,
/// The `cutoutLeaves` option (fancy graphics); off makes leaves opaque.
pub cutout_leaves: bool,
/// What the worker does with leaf faces inside a canopy.
pub leaf_culling: LeafCullingMode,
}
impl Mesher<'_> {
/// `region[dz][dx]` are the chunks around the meshed column (centre 1,1).
@@ -1108,6 +1133,11 @@ impl Mesher<'_> {
let pad = &scratch.padded;
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
let base_y = center.min_y() + sy * 16;
if self.leaf_culling.is_interior() {
scratch
.leaf
.prepare(self.leaf_culling, pad, &self.tables.info, region, base_y);
}
let mut tints = Tints::new(
region,
self.biomes,
@@ -1172,13 +1202,21 @@ impl Mesher<'_> {
None => [x as f32, y as f32, z as f32],
};
let mut visible = [false; 6];
let interior_leaf = state.leaves
&& self.leaf_culling.is_interior()
&& scratch.leaf.interior(self.leaf_culling, x, y, z);
for d in Direction::ALL {
let [dx, dy, dz] = d.normal();
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
let other = &info[neighbor as usize];
// LeavesBlock.skipRendering with opaque leaves.
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
let hollow_leaf = self.leaf_culling == LeafCullingMode::Hollow
&& state.leaves
&& other.leaves
&& leaf_culling::in_section(x + dx, y + dy, z + dz);
visible[d as usize] = !opaque_leaves
&& !hollow_leaf
&& self
.visibility
.should_render_face(
@@ -1203,6 +1241,7 @@ impl Mesher<'_> {
face,
ao,
state,
interior_leaf,
);
}
}
@@ -1241,6 +1280,7 @@ impl Mesher<'_> {
face: &BakedFace,
ao: bool,
state: &Info,
interior_leaf: bool,
) {
let q = &face.quad;
// A quad's own light (a model's glowing part) may raise the tier; whether it
@@ -1275,6 +1315,17 @@ impl Mesher<'_> {
} else {
&mut out.solid
};
// Opaque leaves (forceOpaque), quads over opaque texels and leaves a
// leaf-culling mode proved internal draw in the solid layer: no alpha
// cutout. All four vertices of the quad share the layer.
let layer = if face.opaque && !face.translucent
|| !self.cutout_leaves && state.leaves
|| interior_leaf
{
face.layer as u16 | OPAQUE_LAYER
} else {
face.layer as u16
};
for i in 0..4 {
let g = gray[i] as u32;
let rgb = match tint {
@@ -1285,13 +1336,7 @@ impl Mesher<'_> {
target.push(TerrainVertex::new(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
q.uv[i],
// Opaque leaves (forceOpaque) and quads over opaque texels draw in the
// solid layer: no alpha cutout (a dirt path's side skips its clear top row).
if face.opaque && !face.translucent || !self.cutout_leaves && state.leaves {
face.layer as u16 | OPAQUE_LAYER
} else {
face.layer as u16
},
layer,
emitting(pack_rgb(rgb), tier),
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
));
@@ -0,0 +1,656 @@
//! Leaf culling: decides which leaf faces a section may draw solid or drop.
//!
//! The mask is built on the mesh worker from the same padded block snapshot the
//! rest of the mesher reads, so a decision never needs a chunk lookup on the
//! render thread. It classifies each padded cell as a leaf, a full opaque cube,
//! open (air, fluids, partial models, anything else) or unknown (a missing
//! chunk, a Y outside the column, or the mask's outer shell). Only leaves can
//! hide or solidify anything; unknown and open cells always expose a face.
//!
//! `Solid` performs an immediate 6-neighbour occlusion check, solidifying fully
//! enclosed leaves into the opaque layer without cutout testing. `Solid
//! Aggressive` runs a multi-source breadth-first search from leaves touching
//! openings, solidifying leaves with a single open face to thin the canopy shell
//! by one layer. `Hollow` directly drops faces shared between two leaves within
//! the section, preserving boundaries and cavity faces.
use super::{B, Info, P, Padded, pi};
use crate::{blocks::RenderShape, chunk::Chunk, graphics::LeafCullingMode};
/// A cell of the padded snapshot, as far as leaf culling cares.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Cell {
/// Air, a fluid, a partial model, glass or any other non-occluding block.
Open,
/// `LeavesBlock`.
Leaf,
/// A full opaque cube (see [`opaque_cube`]).
Opaque,
/// Missing chunk, missing section or the mask's outer shell.
Unknown,
}
/// Whether a state is a full opaque cube: `RenderShape::Model`, `can_occlude`,
/// `solid_render` and a full collision shape. Glass, fluids, slabs, stairs,
/// chests and models that only partly fill their block are not.
pub(super) fn opaque_cube(
shape: RenderShape,
can_occlude: bool,
solid_render: bool,
full_collision: bool,
) -> bool {
shape == RenderShape::Model && can_occlude && solid_render && full_collision
}
const UNVISITED: u8 = u8::MAX;
const STRIDE_X: isize = 1;
const STRIDE_Z: isize = P as isize;
const STRIDE_Y: isize = (P * P) as isize;
const ADJACENT_STRIDES: [isize; 6] = [
STRIDE_X, -STRIDE_X, STRIDE_Z, -STRIDE_Z, STRIDE_Y, -STRIDE_Y,
];
/// Per-worker leaf mask and BFS scratch. The vectors keep their capacity
/// between sections and modes; `Off` does no work at all.
#[derive(Default)]
pub(super) struct LeafMask {
cells: Vec<Cell>,
depth: Vec<u8>,
queue: Vec<u16>,
active: bool,
}
impl LeafMask {
/// Classify the padded snapshot and, for the interior modes, measure how
/// deep each leaf sits below the canopy surface.
pub(super) fn prepare(
&mut self,
mode: LeafCullingMode,
pad: &Padded,
info: &[Info],
region: &[[Option<&Chunk>; 3]; 3],
base_y: i32,
) {
self.active = mode != LeafCullingMode::Off;
if !self.active {
return;
}
// Most sections hold no leaves at all; do not classify 8,000 cells to
// learn that. The centre is the only part this mesh can change.
let mut leaves = false;
'scan: for y in 0..16 {
for z in 0..16 {
for x in 0..16 {
if info
.get(pad.blocks[pi(x, y, z)] as usize)
.is_some_and(|state| state.leaves)
{
leaves = true;
break 'scan;
}
}
}
}
if !leaves {
self.active = false;
return;
}
if self.cells.len() != P * P * P {
self.cells.resize(P * P * P, Cell::Open);
}
for y in -B..16 + B {
for z in -B..16 + B {
for x in -B..16 + B {
let i = pi(x, y, z);
self.cells[i] = if !known(region, base_y, x, y, z) {
Cell::Unknown
} else {
match info.get(pad.blocks[i] as usize) {
None => Cell::Unknown,
Some(state) if state.leaves => Cell::Leaf,
Some(state) if state.opaque_cube => Cell::Opaque,
Some(_) => Cell::Open,
}
};
}
}
}
// Solid is a purely local six-neighbour test. Reserve the breadth-first
// search for Solid Aggressive, whose one-open-side rule needs to know
// whether a leaf is connected to the canopy surface through other
// leaves.
if mode == LeafCullingMode::SolidAggressive {
self.depths();
}
}
/// Multi-source BFS from leaves that touch an opening. Opaque neighbours do
/// not expose a leaf: a leaf fully enclosed by stone is interior too.
fn depths(&mut self) {
if self.depth.len() != self.cells.len() {
self.depth.resize(self.cells.len(), UNVISITED);
} else {
self.depth.fill(UNVISITED);
}
self.queue.clear();
for y in -B..16 + B {
for z in -B..16 + B {
for x in -B..16 + B {
let i = pi(x, y, z);
if self.cells[i] != Cell::Leaf {
continue;
}
let touches_open = ADJACENT_STRIDES.iter().any(|&stride| {
let j = (i as isize + stride) as usize;
matches!(self.cells[j], Cell::Open | Cell::Unknown)
});
if touches_open {
self.depth[i] = 0;
self.queue.push(i as u16);
}
}
}
}
let mut head = 0;
while head < self.queue.len() {
let i = self.queue[head] as usize;
head += 1;
let next_depth = self.depth[i].saturating_add(1);
for stride in ADJACENT_STRIDES {
let j = (i as isize + stride) as usize;
if self.cells[j] == Cell::Leaf && self.depth[j] == UNVISITED {
self.depth[j] = next_depth;
self.queue.push(j as u16);
}
}
}
}
/// Whether the leaf at `(x, y, z)` may draw in the opaque layer.
///
/// `Solid` wants a leaf with no open neighbour, which is exactly depth 1 or
/// more. `Solid Aggressive` also takes the next layer of the shell: a leaf
/// with a single open side joins the opaque core, so the cutout shell is
/// one layer thinner. Unknown neighbours never qualify, however many sides
/// they leave open. A leaf the search never reached sits inside a closed
/// volume: interior for both.
pub(super) fn interior(&self, mode: LeafCullingMode, x: i32, y: i32, z: i32) -> bool {
if !self.active {
return false;
}
let aggressive = match mode {
LeafCullingMode::Solid => false,
LeafCullingMode::SolidAggressive => true,
_ => return false,
};
// A section boundary is not proof of a closed volume: the neighbouring
// section may not be drawn yet, and its data is not ours to assume.
if !(1..15).contains(&x) || !(1..15).contains(&y) || !(1..15).contains(&z) {
return false;
}
let i = pi(x, y, z);
if self.cells.get(i) != Some(&Cell::Leaf) {
return false;
}
if !aggressive {
return ADJACENT_STRIDES.iter().all(|&stride| {
let j = (i as isize + stride) as usize;
matches!(self.cells[j], Cell::Leaf | Cell::Opaque)
});
}
let depth = self.depth[i];
if depth == UNVISITED || depth >= 1 {
return true;
}
let mut open = 0;
for stride in ADJACENT_STRIDES {
let j = (i as isize + stride) as usize;
match self.cells[j] {
Cell::Unknown => return false,
Cell::Open => {
open += 1;
if open > 1 {
return false;
}
}
Cell::Leaf | Cell::Opaque => {}
}
}
open == 1
}
}
/// The cells this section's mesh owns. A face on this boundary belongs to the
/// neighbouring section as much as to this one; leaf culling leaves it alone.
#[inline]
pub(super) fn in_section(x: i32, y: i32, z: i32) -> bool {
(0..16).contains(&x) && (0..16).contains(&y) && (0..16).contains(&z)
}
/// Whether the padded cell `(x, y, z)` has real block data. A missing chunk
/// does not count: `Padded::fill` copies the centre's edge column into it, which
/// would make a canopy look enclosed. A Y outside the column counts as unknown
/// for the same reason. The mask's outer shell is unknown too, so nothing is
/// inferred past the data the worker snapshotted.
fn known(region: &[[Option<&Chunk>; 3]; 3], base_y: i32, x: i32, y: i32, z: i32) -> bool {
if x <= -B || x >= 16 + B - 1 || y <= -B || y >= 16 + B - 1 || z <= -B || z >= 16 + B - 1 {
return false;
}
let chunk = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize];
chunk.is_some_and(|chunk| chunk.section_index(base_y + y).is_some())
}
#[cfg(test)]
impl LeafMask {
/// Whether `Hollow` drops the face of the leaf at `(x, y, z)` toward `d`.
/// Only a known leaf on the other side counts: open cells, partial models,
/// missing neighbours and the mask's edge stay visible.
pub(super) fn hides_face(
&self,
mode: LeafCullingMode,
x: i32,
y: i32,
z: i32,
d: horizon_core::geometry::Direction,
) -> bool {
if !self.active || mode != LeafCullingMode::Hollow {
return false;
}
let [dx, dy, dz] = d.normal();
let (nx, ny, nz) = (x + dx, y + dy, z + dz);
if !in_section(x, y, z) || !in_section(nx, ny, nz) {
return false;
}
let i = pi(x, y, z);
let ni = pi(nx, ny, nz);
self.cells.get(i) == Some(&Cell::Leaf) && self.cells.get(ni) == Some(&Cell::Leaf)
}
#[inline]
fn cell(&self, x: i32, y: i32, z: i32) -> Cell {
checked(x, y, z)
.and_then(|i| self.cells.get(i))
.copied()
.unwrap_or(Cell::Unknown)
}
}
#[cfg(test)]
#[inline]
fn checked(x: i32, y: i32, z: i32) -> Option<usize> {
if (-B..16 + B).contains(&x) && (-B..16 + B).contains(&y) && (-B..16 + B).contains(&z) {
Some(pi(x, y, z))
} else {
None
}
}
#[cfg(test)]
#[inline]
fn unpi(i: usize) -> (i32, i32, i32) {
let x = (i % P) as i32 - B;
let z = (i / P % P) as i32 - B;
let y = (i / (P * P)) as i32 - B;
(x, y, z)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{blocks::RenderShape, graphics::LeafCullingMode};
use horizon_core::geometry::Direction;
use horizon_world::fluid::FluidState;
use std::time::Instant;
fn info(leaves: bool, opaque: bool) -> Info {
Info {
solid: opaque,
full: opaque,
permeable: false,
shade: 1.0,
emission: 0,
tier: 0,
emissive: false,
fluid: FluidState::EMPTY,
model: true,
block_entity: false,
tints: [super::super::Tint::None; 2],
tint_count: 0,
offset: None,
legacy_solid: false,
overlay: leaves,
leaves,
opaque_cube: opaque,
blocks_flow: false,
ice: false,
sturdy: 0,
face: [0; 6],
cover: [0.0; 6],
}
}
/// State 0 is air, 1 leaves, 2 a full opaque cube, 3 a partial model.
fn states() -> Vec<Info> {
vec![
info(false, false),
info(true, false),
info(false, true),
info(false, false),
]
}
fn padded(fill: impl Fn(i32, i32, i32) -> u32) -> Padded {
Padded {
blocks: (0..P * P * P)
.map(|i| {
let (x, y, z) = unpi(i);
fill(x, y, z)
})
.collect(),
light: vec![0; P * P * P],
}
}
fn cube(pad: &mut Padded, min: i32, max: i32, state: u32) {
for y in min..=max {
for z in min..=max {
for x in min..=max {
pad.blocks[pi(x, y, z)] = state;
}
}
}
}
fn loaded() -> Chunk {
Chunk::empty(0, 0)
}
fn region(chunk: &Chunk) -> [[Option<&Chunk>; 3]; 3] {
[[Some(chunk); 3]; 3]
}
fn built(mode: LeafCullingMode, pad: &Padded, states: &[Info], chunk: &Chunk) -> LeafMask {
let mut mask = LeafMask::default();
mask.prepare(mode, pad, states, &region(chunk), 0);
mask
}
#[test]
fn only_a_full_model_cube_is_opaque() {
let combinations = [
(RenderShape::Model, true, true, true),
(RenderShape::Model, true, true, false),
(RenderShape::Model, true, false, true),
(RenderShape::Model, false, true, true),
(RenderShape::Model, false, false, false),
(RenderShape::Invisible, true, true, true),
(RenderShape::Animated, true, true, true),
];
let (shape, can_occlude, solid_render, full_collision) = combinations[0];
assert!(opaque_cube(
shape,
can_occlude,
solid_render,
full_collision
));
for &(shape, can_occlude, solid_render, full_collision) in &combinations[1..] {
assert!(
!opaque_cube(shape, can_occlude, solid_render, full_collision),
"{shape:?} {can_occlude} {solid_render} {full_collision}"
);
}
}
#[test]
fn solid_marks_the_cell_inside_a_three_cube() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 7, 9, 1);
let chunk = loaded();
let solid = built(LeafCullingMode::Solid, &pad, &states, &chunk);
assert!(!solid.interior(LeafCullingMode::Solid, 7, 8, 8));
assert!(solid.interior(LeafCullingMode::Solid, 8, 8, 8));
assert!(!solid.interior(LeafCullingMode::Solid, 8, 9, 8));
// Aggressive also takes the six face-centre leaves, whose only open
// side faces out of the cube. Corners and edges keep their cutout.
let aggressive = built(LeafCullingMode::SolidAggressive, &pad, &states, &chunk);
assert!(aggressive.interior(LeafCullingMode::SolidAggressive, 8, 8, 8));
assert!(aggressive.interior(LeafCullingMode::SolidAggressive, 7, 8, 8));
assert!(aggressive.interior(LeafCullingMode::SolidAggressive, 8, 8, 9));
assert!(!aggressive.interior(LeafCullingMode::SolidAggressive, 7, 7, 8));
assert!(!aggressive.interior(LeafCullingMode::SolidAggressive, 7, 7, 7));
}
#[test]
fn aggressive_takes_the_next_shell_layer() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 6, 10, 1);
let chunk = loaded();
let solid = built(LeafCullingMode::Solid, &pad, &states, &chunk);
let aggressive = built(LeafCullingMode::SolidAggressive, &pad, &states, &chunk);
let surface = |v: i32| v == 6 || v == 10;
for [x, y, z] in [
[6, 8, 8],
[10, 8, 8],
[6, 6, 8],
[6, 6, 6],
[7, 8, 8],
[8, 8, 8],
[7, 7, 7],
[8, 8, 7],
] {
let expected = !surface(x) && !surface(y) && !surface(z);
assert_eq!(
solid.interior(LeafCullingMode::Solid, x, y, z),
expected,
"solid {x} {y} {z}"
);
let open = [x, y, z].iter().filter(|v| surface(**v)).count();
let expected = open <= 1;
assert_eq!(
aggressive.interior(LeafCullingMode::SolidAggressive, x, y, z),
expected,
"aggressive {x} {y} {z}"
);
}
}
#[test]
fn hollow_hides_only_leaf_to_leaf_faces() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 7, 9, 1);
// Glass (3, a partial model), water and a slab are open: their leaves
// must keep the face.
pad.blocks[pi(10, 8, 8)] = 3;
let chunk = loaded();
let hollow = built(LeafCullingMode::Hollow, &pad, &states, &chunk);
for d in Direction::ALL {
assert!(
hollow.hides_face(LeafCullingMode::Hollow, 8, 8, 8, d),
"{d:?}"
);
}
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 9, 8, 8, Direction::East));
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 7, 8, 8, Direction::West));
// The other modes never hide a quad.
for mode in [
LeafCullingMode::Off,
LeafCullingMode::Solid,
LeafCullingMode::SolidAggressive,
] {
let mask = built(mode, &pad, &states, &chunk);
assert!(!mask.hides_face(mode, 8, 8, 8, Direction::Up));
}
}
#[test]
fn a_cavity_keeps_its_faces_and_its_rim_visible() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 6, 10, 1);
pad.blocks[pi(8, 8, 8)] = 0;
let chunk = loaded();
let hollow = built(LeafCullingMode::Hollow, &pad, &states, &chunk);
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 7, 8, 8, Direction::East));
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 8, 8, 7, Direction::South));
let solid = built(LeafCullingMode::Solid, &pad, &states, &chunk);
assert!(!solid.interior(LeafCullingMode::Solid, 7, 8, 8));
assert!(!solid.interior(LeafCullingMode::Solid, 6, 8, 8));
// The corner away from the cavity is still two layers in.
assert!(solid.interior(LeafCullingMode::Solid, 9, 9, 9));
}
#[test]
fn an_opaque_neighbour_does_not_expose_a_leaf() {
let states = states();
let mut pad = padded(|_, _, _| 0);
for (dx, dy, dz) in [
(1, 0, 0),
(-1, 0, 0),
(0, 1, 0),
(0, -1, 0),
(0, 0, 1),
(0, 0, -1),
] {
pad.blocks[pi(8 + dx, 8 + dy, 8 + dz)] = 2;
}
pad.blocks[pi(8, 8, 8)] = 1;
let chunk = loaded();
for mode in [LeafCullingMode::Solid, LeafCullingMode::SolidAggressive] {
let mask = built(mode, &pad, &states, &chunk);
assert!(mask.interior(mode, 8, 8, 8), "{mode:?}");
}
let hollow = built(LeafCullingMode::Hollow, &pad, &states, &chunk);
for d in Direction::ALL {
assert!(
!hollow.hides_face(LeafCullingMode::Hollow, 8, 8, 8, d),
"{d:?}"
);
}
}
#[test]
fn a_leaf_inside_a_closed_volume_is_interior() {
let states = states();
// A leaf cube inside opaque rock: the search has no opening to start
// from, so every leaf stays unvisited and counts as interior.
let mut pad = padded(|_, _, _| 2);
cube(&mut pad, 7, 9, 1);
let chunk = loaded();
for mode in [LeafCullingMode::Solid, LeafCullingMode::SolidAggressive] {
let mask = built(mode, &pad, &states, &chunk);
if mode == LeafCullingMode::SolidAggressive {
assert_eq!(mask.depth[pi(8, 8, 8)], UNVISITED);
}
assert!(mask.interior(mode, 8, 8, 8), "{mode:?}");
}
}
#[test]
fn missing_chunks_and_sections_are_unknown() {
let states = states();
let pad = padded(|_, _, _| 1);
let chunk = loaded();
let mut around = [[Some(&chunk); 3]; 3];
around[1][2] = None; // east chunk
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Hollow, &pad, &states, &around, 0);
// The leaf at the edge keeps its face toward the missing chunk, even
// though `Padded::fill` copied the centre's edge leaf there.
assert!(!mask.hides_face(LeafCullingMode::Hollow, 15, 8, 8, Direction::East));
// A leaf one block inside is still between two known leaves.
assert!(mask.hides_face(LeafCullingMode::Hollow, 14, 8, 8, Direction::East));
// A section above the column is unknown too: the same leaf one
// section up has no data, so it can never be interior.
let short = Chunk::with_layout(0, 0, crate::chunk::ColumnLayout::new(0, 16).unwrap());
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Solid, &pad, &states, &region(&short), 16);
assert!(!mask.interior(LeafCullingMode::Solid, 8, 8, 8));
// The outer shell has no data beyond it.
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Hollow, &pad, &states, &region(&chunk), 0);
assert_eq!(mask.cell(-B, 0, 0), Cell::Unknown);
assert_eq!(mask.cell(16 + B - 1, 0, 0), Cell::Unknown);
assert_eq!(mask.cell(0, 0, 0), Cell::Leaf);
// A section boundary is never enclosed, even with every chunk loaded:
// the neighbouring section may not have been meshed yet.
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Solid, &pad, &states, &region(&chunk), 0);
assert!(!mask.interior(LeafCullingMode::Solid, 15, 8, 8));
assert!(mask.interior(LeafCullingMode::Solid, 14, 8, 8));
}
#[test]
fn off_does_no_work_and_hides_nothing() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 7, 9, 1);
let chunk = loaded();
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Off, &pad, &states, &region(&chunk), 0);
assert!(!mask.active);
assert!(mask.cells.is_empty());
assert!(!mask.interior(LeafCullingMode::Off, 8, 8, 8));
assert!(!mask.hides_face(LeafCullingMode::Off, 8, 8, 8, Direction::Up));
}
#[test]
fn a_reused_mask_reclassifies_every_cell() {
let states = states();
let chunk = loaded();
let mut mask = LeafMask::default();
let mut leaves = padded(|_, _, _| 0);
cube(&mut leaves, 7, 9, 1);
mask.prepare(
LeafCullingMode::Hollow,
&leaves,
&states,
&region(&chunk),
0,
);
assert_eq!(mask.cell(8, 8, 8), Cell::Leaf);
let mut other = padded(|_, _, _| 0);
cube(&mut other, 10, 12, 1);
mask.prepare(LeafCullingMode::Hollow, &other, &states, &region(&chunk), 0);
assert_eq!(mask.cell(8, 8, 8), Cell::Open);
assert_eq!(mask.cell(11, 11, 11), Cell::Leaf);
}
#[test]
#[ignore = "microbenchmark; run with --ignored --nocapture"]
fn mask_build_benchmark() {
let states = states();
let mut pad = padded(|x, y, z| {
// Alternating leaf slabs make the search visit most of the volume.
if (x / 2 + y / 2 + z / 2) % 3 == 0 {
1
} else {
0
}
});
cube(&mut pad, 6, 10, 1);
let chunk = loaded();
let region = region(&chunk);
let iterations = 1000;
for mode in [LeafCullingMode::Solid, LeafCullingMode::SolidAggressive] {
let mut mask = LeafMask::default();
let started = Instant::now();
for _ in 0..iterations {
mask.prepare(mode, &pad, &states, &region, 0);
}
let per = started.elapsed() / iterations;
println!("leaf mask {mode:?}: {per:?} per section ({iterations} iterations)");
assert!(mask.active);
if mode == LeafCullingMode::SolidAggressive {
assert_ne!(mask.depth[pi(8, 8, 8)], UNVISITED);
}
}
}
}
@@ -0,0 +1,293 @@
//! Leaf culling through the real mesher: face counts, the opaque layer and the
//! conservative cases (cavities, partial neighbours, missing chunks, column
//! ends). Needs prepared resources, like the other mesher geometry tests.
use super::*;
use crate::{
assets::{Assets, RenderAssets},
biome::Biomes,
blocks::BlockFacts,
chunk::{Chunk, ColumnLayout},
graphics::LeafCullingMode,
models,
};
struct Fixture {
assets: Assets,
biomes: Biomes,
render: RenderAssets,
visibility: horizon_world::block_render::FaceVisibility,
tables: MeshTables,
}
fn fixture() -> Fixture {
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")
});
let assets = Assets::load(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let biomes = Biomes::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let visibility = models::load_visibility(&root, assets.registry).unwrap();
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
Fixture {
assets,
biomes,
render,
visibility,
tables,
}
}
fn state(f: &Fixture, name: &str) -> u32 {
f.assets.registry.default_state(name).unwrap().0
}
fn leaves_cube(chunk: &mut Chunk, min: i32, max: i32, y: i32, state: u32) {
for z in min..=max {
for x in min..=max {
chunk.set(x, y, z, state);
}
}
}
fn leaf_cube(f: &Fixture, min: i32, max: i32, base: i32, name: &str) -> Chunk {
let mut chunk = Chunk::empty(0, 0);
let state = state(f, name);
for y in base..base + (max - min + 1) {
leaves_cube(&mut chunk, min, max, y, state);
}
chunk
}
fn mesh(
f: &Fixture,
_chunk: &Chunk,
region: [[Option<&Chunk>; 3]; 3],
sy: i32,
mode: LeafCullingMode,
) -> SectionMesh {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
assets: &f.assets,
render: &f.render,
tables: &f.tables,
biomes: &f.biomes,
visibility: f.visibility.clone(),
zoom: BiomeZoom::new(0),
blend_radius: 0,
smooth_lighting: false,
cutout_leaves: true,
leaf_culling: mode,
};
mesher.mesh(&region, sy)
}
fn around(chunk: &Chunk) -> [[Option<&Chunk>; 3]; 3] {
[[Some(chunk); 3]; 3]
}
/// (opaque, cutout) leaf quads, rejecting a quad whose four vertices disagree.
fn quads(mesh: &SectionMesh) -> (usize, usize) {
let mut opaque = 0;
let mut cutout = 0;
for quad in mesh.solid.as_chunks::<4>().0 {
let flags = quad
.iter()
.map(|v| v.layer & OPAQUE_LAYER != 0)
.collect::<Vec<_>>();
assert!(
flags.iter().all(|flag| *flag == flags[0]),
"a quad mixed opaque and cutout vertices: {flags:?}"
);
if flags[0] {
opaque += 1;
} else {
cutout += 1;
}
}
assert_eq!(mesh.solid.len() % 4, 0);
(opaque, cutout)
}
const BASE: i32 = MIN_Y + 4;
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn off_draws_every_fancy_leaf_face() {
let f = fixture();
let chunk = leaf_cube(&f, 6, 8, BASE, "minecraft:oak_leaves");
let mesh = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Off);
// 27 blocks, six quads each: vanilla's fancy leaves keep the inside.
assert_eq!(quads(&mesh), (0, 162));
assert_eq!(mesh.translucent.len(), 0);
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn solid_draws_the_fully_enclosed_leaf_opaque() {
let f = fixture();
let chunk = leaf_cube(&f, 6, 8, BASE, "minecraft:oak_leaves");
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
// Only the centre has all six sides covered: its six quads go opaque.
assert_eq!(quads(&solid), (6, 156));
let aggressive = mesh(
&f,
&chunk,
around(&chunk),
0,
LeafCullingMode::SolidAggressive,
);
// Aggressive also takes the six face-centre leaves: seven blocks opaque.
assert_eq!(quads(&aggressive), (42, 120));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn aggressive_solidifies_the_next_shell_layer_of_a_five_cube() {
let f = fixture();
let chunk = leaf_cube(&f, 5, 9, BASE, "minecraft:oak_leaves");
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
// The 3x3x3 inside the surface: 27 blocks, six quads each.
assert_eq!(quads(&solid), (162, 588));
let aggressive = mesh(
&f,
&chunk,
around(&chunk),
0,
LeafCullingMode::SolidAggressive,
);
// The 3x3x3 inside plus the 3x3 face centre of each of the six sides.
assert_eq!(quads(&aggressive), (486, 264));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn hollow_drops_only_the_faces_two_leaves_share() {
let f = fixture();
let chunk = leaf_cube(&f, 6, 8, BASE, "minecraft:oak_leaves");
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
// 162 quads minus the 54 shared pairs' 108 faces: the 3x3x3 shell.
assert_eq!(quads(&hollow), (0, 54));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn mixed_species_count_as_one_leaf_volume() {
let f = fixture();
let oak = state(&f, "minecraft:oak_leaves");
let birch = state(&f, "minecraft:birch_leaves");
let mut chunk = Chunk::empty(0, 0);
for y in BASE..BASE + 3 {
for z in 6..=8 {
for x in 6..=8 {
chunk.set(x, y, z, if (x + y + z) % 2 == 0 { oak } else { birch });
}
}
}
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
assert_eq!(quads(&solid), (6, 156));
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
assert_eq!(quads(&hollow), (0, 54));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn a_cavity_keeps_its_six_faces_and_its_rim_stays_cutout() {
let f = fixture();
let mut chunk = leaf_cube(&f, 5, 9, BASE, "minecraft:oak_leaves");
chunk.set(7, BASE + 2, 7, 0);
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
// Five-cube shell (150) plus the six faces around the one-block cavity.
assert_eq!(quads(&hollow), (0, 156));
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
// The six leaves around the cavity are exposed, so they stay cutout; the
// other 20 inner leaves draw solid. The cavity block itself has no quads.
assert_eq!(quads(&solid), (120, 624));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn section_boundary_faces_stay_with_a_loaded_neighbour() {
let f = fixture();
let leaf = state(&f, "minecraft:oak_leaves");
let chunk = leaf_cube(&f, 12, 15, BASE, "minecraft:oak_leaves");
// A loaded east chunk whose x=0 column continues the canopy.
let mut east = Chunk::empty(1, 0);
for y in BASE..BASE + 4 {
for z in 12..=15 {
east.set(0, y, z, leaf);
}
}
let mut region = around(&chunk);
region[1][2] = Some(&east);
let joined = mesh(&f, &chunk, region, 0, LeafCullingMode::Hollow);
let mut region = around(&chunk);
region[1][2] = None;
let missing = mesh(&f, &chunk, region, 0, LeafCullingMode::Hollow);
// The east faces of the x=15 leaves are on the section boundary: a loaded
// neighbour does not let Hollow drop them, and a missing one does not
// change the mesh either.
assert_eq!(joined.solid.len(), missing.solid.len());
let off = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Off);
assert!(joined.solid.len() < off.solid.len());
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn the_top_of_a_one_section_column_stays_visible() {
let f = fixture();
let leaf = state(&f, "minecraft:oak_leaves");
let layout = ColumnLayout::new(0, 16).unwrap();
let mut chunk = Chunk::with_layout(0, 0, layout);
for y in 13..16 {
leaves_cube(&mut chunk, 5, 7, y, leaf);
}
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
// y=15 is the column's top: the faces toward y=16 are unknown, so they
// stay, and the y=15 leaves are not enclosed.
assert!(!hollow.solid.is_empty());
assert!(solid.solid.iter().any(|v| v.pos[1] == 16.0));
assert!(
solid
.solid
.iter()
.filter(|v| v.pos[1] == 16.0)
.all(|v| v.layer & OPAQUE_LAYER == 0)
);
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn a_world_without_leaves_meshes_identically_in_every_mode() {
let f = fixture();
let mut chunk = Chunk::empty(0, 0);
for y in BASE..BASE + 3 {
leaves_cube(&mut chunk, 6, 8, y, state(&f, "minecraft:stone"));
}
let off = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Off);
for mode in LeafCullingMode::ALL {
let other = mesh(&f, &chunk, around(&chunk), 0, mode);
assert_eq!(off.solid.len(), other.solid.len(), "{mode:?}");
assert_eq!(off.translucent.len(), other.translucent.len(), "{mode:?}");
for (a, b) in off.solid.iter().zip(&other.solid) {
assert_eq!(
(a.pos, a.uv, a.layer, a.light, a.color),
(b.pos, b.uv, b.layer, b.light, b.color),
"{mode:?}"
);
}
for (a, b) in off.translucent.iter().zip(&other.translucent) {
assert_eq!(
(a.pos, a.uv, a.layer, a.light, a.color),
(b.pos, b.uv, b.layer, b.light, b.color),
"{mode:?}"
);
}
}
}
@@ -81,6 +81,7 @@ fn a_shader_pack_lights_the_faces_itself() {
blend_radius: 0,
smooth_lighting: false,
cutout_leaves: true,
leaf_culling: LeafCullingMode::Off,
};
let mut colours: Vec<u32> = mesher
.mesh(&region, 0)
@@ -156,6 +157,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
blend_radius: 2,
smooth_lighting: reload == 0,
cutout_leaves: reload == 0,
leaf_culling: LeafCullingMode::Off,
};
for (case, (center, east)) in [
(&empty, None),
@@ -88,6 +88,7 @@ fn info(solid: bool) -> Info {
legacy_solid: false,
overlay: false,
leaves: false,
opaque_cube: solid,
blocks_flow: false,
ice: false,
sturdy: 0,
@@ -68,6 +68,9 @@ pub enum Ticking {
/// `RidingEntitySoundInstance` for the player riding a happy ghast
/// (`underwater_sound` false) or a nautilus (true).
RidingEntity { entity: u32, underwater_sound: bool },
/// A native mod's music layer (`MusicLayers`), whose volume its owner
/// sets each tick.
MusicLayer,
}
/// What a minecart's sound instances read about it.
#[derive(Clone, Copy, Debug)]
@@ -233,6 +236,25 @@ impl SoundInstance {
true,
)
}
/// A native mod's music layer: a looping, unattenuated track on the music
/// source that starts silent (`MusicLayers` fades it in).
pub fn for_music_layer(id: &str, random: LegacyRandom) -> Self {
let mut s = Self::new(
id,
MUSIC,
0.0,
1.0,
random,
true,
0,
Attenuation::None,
[0.0; 3],
true,
);
s.can_start_silent = true;
s.ticking = Ticking::MusicLayer;
s
}
/// `SimpleSoundInstance.forJukeboxSong`.
pub fn for_jukebox_song(id: &str, at: [f64; 3], random: LegacyRandom) -> Self {
Self::new(
@@ -522,7 +544,7 @@ impl SoundInstance {
/// `TickableSoundInstance.tick`.
pub fn tick(&mut self, ctx: &TickContext) {
match &mut self.ticking {
Ticking::No => {}
Ticking::No | Ticking::MusicLayer => {}
Ticking::BiomeLoop { fade, direction } => {
let stop = *fade < 0;
*fade += *direction;
+45 -2
View File
@@ -349,10 +349,26 @@ impl Sprites {
};
self.register(root, name, decoded)
}
/// The layer of a texture outside `textures/` (an OptiFine pack's connected-texture tile),
/// named by its resource path and added on first use. Unlike [`Sprites::insert`], a texture
/// that does not load is an error: the caller drops what needed it. Such files bypass the
/// disk cache, whose fingerprint covers only `textures/`.
pub fn insert_file(&mut self, root: &Path, resource: &str) -> Result<u32> {
if let Some(layer) = self.layers.get(resource) {
return Ok(*layer);
}
let decoded = Self::decode_file(&root.join(resource), resource)?;
self.register(root, resource, decoded)
}
/// Read and decode one texture and its `.mcmeta`.
fn decode(root: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
let (namespace, path) = name.split_once(':').ok_or("invalid sprite identifier")?;
let path = root.join(format!("assets/{namespace}/textures/{path}.png"));
Self::decode_file(
&root.join(format!("assets/{namespace}/textures/{path}.png")),
name,
)
}
fn decode_file(path: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
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>>,
@@ -368,7 +384,7 @@ impl Sprites {
.as_deref()
.map(AnimationMetadata::from_json)
.transpose()?;
let (png_width, png_height, pixels) = read_rgba(&path)?;
let (png_width, png_height, pixels) = read_rgba(path)?;
let (width, height) = if let Some(animation) = &animation {
let [width, height] =
animation.frame_size(i32::try_from(png_width)?, i32::try_from(png_height)?);
@@ -662,6 +678,33 @@ mod tests {
}
}
#[test]
fn files_outside_textures_load_by_path() {
let root = std::env::temp_dir().join(format!("horizon-sprite-file-{}", std::process::id()));
let tile = "assets/minecraft/optifine/ctm/glass/0.png";
fs::create_dir_all(root.join(tile).parent().unwrap()).unwrap();
let mut encoder = png::Encoder::new(File::create(root.join(tile)).unwrap(), 16, 16);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let pixels: Vec<u8> = (0..256).flat_map(|i| [i as u8, 0, 0, 128]).collect();
encoder
.write_header()
.unwrap()
.write_image_data(&pixels)
.unwrap();
let mut sprites = Sprites::default();
let layer = sprites.insert_file(&root, tile).unwrap();
assert_eq!(sprites.insert_file(&root, tile).unwrap(), layer);
assert!(sprites.translucent[layer as usize]);
// A missing tile is an error, not the missing texture.
assert!(
sprites
.insert_file(&root, "assets/minecraft/optifine/ctm/glass/1.png")
.is_err()
);
assert_eq!(sprites.count(), 1);
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn identical_still_textures_share_a_layer() {
let root =
std::env::temp_dir().join(format!("horizon-sprite-share-{}", std::process::id()));
@@ -6,6 +6,7 @@ use crate::fast_hash::{FxHashMap, FxHashSet};
use crate::section_mask::SectionMask;
use crate::{
chunk::Chunk,
graphics::LeafCullingMode,
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
mesher::{MeshScratch, MeshTables, Mesher, Region, SectionMesh, borrow},
world::World,
@@ -149,6 +150,8 @@ pub trait LodSampler: Send {
fn reduced_column(&mut self, x: i32, z: i32, _width: i32) -> crate::lod::Column {
self.column(x, z)
}
/// `side` x `side` columns from (`x`, `z`), `stride` blocks apart; none
/// when sampling failed and should be tried again later.
fn grid(&mut self, x: i32, z: i32, stride: i32, side: usize) -> Vec<crate::lod::Column> {
let mut columns = Vec::with_capacity(side * side);
for dz in 0..side {
@@ -202,6 +205,7 @@ struct MeshSettings {
blend_radius: i32,
smooth_lighting: bool,
cutout_leaves: bool,
leaf_culling: LeafCullingMode,
}
enum Job {
Generate(i32, i32),
@@ -436,6 +440,8 @@ pub struct Streaming {
/// The `ao` and `cutoutLeaves` options.
pub smooth_lighting: bool,
pub cutout_leaves: bool,
/// What the mesher does with leaf faces inside a canopy (`LeafCullingMode`).
pub leaf_culling: LeafCullingMode,
/// Incremental relighting of block edits on lit chunks (see `light::relight`).
light_tables: Arc<LightTables>,
/// Applied edits by sequence, replayed onto light jobs snapshotted before them.
@@ -770,6 +776,7 @@ impl Streaming {
blend_radius: settings.blend_radius,
smooth_lighting: settings.smooth_lighting,
cutout_leaves: settings.cutout_leaves,
leaf_culling: settings.leaf_culling,
assets: &context.assets,
render: &context.render,
tables: &context.tables,
@@ -851,6 +858,7 @@ impl Streaming {
biome_blend: 2,
smooth_lighting: true,
cutout_leaves: true,
leaf_culling: LeafCullingMode::Off,
light_tables,
light_log: Vec::new(),
light_edit_sequence: 0,
@@ -1710,6 +1718,7 @@ impl Streaming {
blend_radius: self.biome_blend,
smooth_lighting: self.smooth_lighting,
cutout_leaves: self.cutout_leaves,
leaf_culling: self.leaf_culling,
},
),
);
@@ -591,6 +591,11 @@ fn sky_along_ray(d: vec3<f32>) -> vec3<f32> {
@fragment fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0);
}
// 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.
@fragment fn fs_depth_reset(in: SkyOut) -> @location(0) vec4<f32> {
return vec4(0.0);
}
@fragment fn fs_dark_disc(in: SkyOut) -> @location(0) vec4<f32> {
let d = view_ray(in.ndc);
let dist = disc_distance(d, -4.0);
+1 -1
View File
@@ -16,7 +16,6 @@ horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-world = { path = "../horizon-world" }
@@ -37,6 +36,7 @@ horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
# Their runtime is the `mods` feature.
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
horizon-optifine = { path = "../horizon-optifine" }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
pollster = "0.4"
@@ -219,6 +219,7 @@
"horizon.extras.header.third_person": "Third person",
"horizon.extras.header.particles": "Particles",
"horizon.extras.header.inventory": "Inventory",
"horizon.extras.header.chat": "Chat",
"horizon.extras.header.multiplayer": "Multiplayer",
"horizon.extras.header.sound": "Sound",
"horizon.extras.fullbright": "Fullbright",
@@ -235,6 +236,8 @@
"horizon.extras.agent_control": "Agent Control",
"horizon.extras.discord_presence": "Discord Rich Presence",
"horizon.extras.splitscreen": "Splitscreen",
"horizon.extras.chat_animation": "Smooth chat",
"horizon.extras.chat_indicator": "Message indicator",
"horizon.extras.fullbright.tooltip": "Show blocks and entities at full light, even in dark areas.",
"horizon.extras.dynamic_lights.tooltip": "Torches and other light sources held, dropped or on fire light the blocks around them.",
"horizon.extras.own_name_tag.tooltip": "Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible.",
@@ -301,5 +304,7 @@
"horizon.osk.type": "Type",
"horizon.osk.symbols": "Symbols",
"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."
}
@@ -203,5 +203,11 @@
"horizon.multiplayer.not_signed_in": "Este servidor está en modo en línea. Inicia sesión con tu cuenta de Microsoft en el launcher de Horizon para unirte.",
"horizon.multiplayer.invalid_address": "Dirección de servidor no válida: %s",
"horizon.multiplayer.connected_friend_direct": "Conectado directamente con tu amigo…",
"horizon.multiplayer.connected_friend_relay": "Conectado con tu amigo a través del relé…"
"horizon.multiplayer.connected_friend_relay": "Conectado con tu amigo a través del relé…",
"horizon.pages.shader_options": "Opciones de sombreadores",
"horizon.pages.split_profiles": "¿Quién juega?",
"horizon.pages.split_profiles.tooltip": "Quién juega: un perfil guardado en esta carpeta del juego.",
"horizon.shader_options.open": "Opciones de sombreadores…",
"horizon.shader_options.profile": "Perfil",
"horizon.shader_options.loading": "Leyendo las opciones del paquete de sombreadores…"
}
@@ -28,7 +28,7 @@
"horizon.options.search": "Szukaj ustawień…",
"horizon.pages.mods": "Mody",
"horizon.pages.shader_packs": "Paczki shaderów",
"horizon.pages.shader_options": "Opcje shaderów",
"horizon.pages.shader_options": "Ustawienia shaderów",
"horizon.pages.controller": "Ustawienia kontrolera",
"horizon.pages.controller_binds": "Przypisane przyciski kontrolera",
"horizon.pages.extras": "Dodatki Horizon",
@@ -41,7 +41,7 @@
"horizon.pages.controller.tooltip": "Kontrolery: ich przyciski, drążki, wibracje i nawigacja po menu.",
"horizon.pages.extras.tooltip": "Opcjonalne dodatki, wszystkie domyślnie wyłączone: gra pozostaje vanilla, dopóki nie włączysz któregoś.",
"horizon.pages.agent.tooltip": "Pozwól własnemu agentowi AI grać: horizon-ctl i klienci MCP na tym komputerze.",
"horizon.pages.split_profiles.tooltip": "Kto gra: profil zapisany w tym folderze gry.",
"horizon.pages.split_profiles.tooltip": "Kto gra: profil przechowywany w tym folderze gry.",
"horizon.pages.shader_packs.tooltip": "Paczki shaderów z folderu shaderpacks: własne paczki Horizon i te w formacie shaders/. Naraz działa jedna.",
"horizon.pages.mods.tooltip": "Mody stworzone dla Horizon, z folderu horizon-mods. Każdy działa w piaskownicy i pozostaje wyłączony, dopóki go nie włączysz.",
"horizon.pages.open.graphics": "Grafika…",
@@ -62,9 +62,9 @@
"horizon.mods.open_folder": "Otwórz folder z modami",
"horizon.shader_packs.empty": "Brak paczek shaderów: umieść je w folderze shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.shader_options.open": "Opcje shaderów…",
"horizon.shader_options.open": "Ustawienia shaderów…",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Odczytywanie opcji paczki shaderów…",
"horizon.shader_options.loading": "Wczytywanie ustawień paczki shaderów…",
"horizon.agent.copy_details": "Kopiuj dane połączenia",
"horizon.multiplayer.join": "Dołącz",
"horizon.multiplayer.cancel_reconnecting": "Anuluj ponowne łączenie",
@@ -202,5 +202,11 @@
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…"
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…",
"horizon.pages.shader_options": "Настройки шейдеров",
"horizon.pages.split_profiles": "Кто играет?",
"horizon.pages.split_profiles.tooltip": "Кто играет: профиль, хранимый в этой папке игры.",
"horizon.shader_options.open": "Настройки шейдеров…",
"horizon.shader_options.profile": "Профиль",
"horizon.shader_options.loading": "Чтение настроек набора шейдеров…"
}
@@ -196,5 +196,11 @@
"Threads that generate, light and mesh chunks. Next start.": "Parçaları oluşturan, aydınlatan ve ağ verilerini hazırlayan iş parçacıklarının sayısı. Değişiklikler sonraki başlatmada uygulanır.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Önce Vulkan kullanılır. Çalışmazsa Windows'ta DirectX 12, Linux'ta OpenGL denenir. Değişiklikler sonraki başlatmada uygulanır.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Her karede GPU'ya gönderilen ağ verisi miktarı. Daha düşük değerler yükleme sırasında daha akıcı bir deneyim sağlar.",
"Frame rate and frame times over the game, with a graph.": "Oyun ekranında FPS değerini ve kare sürelerini grafikle birlikte gösterir."
"Frame rate and frame times over the game, with a graph.": "Oyun ekranında FPS değerini ve kare sürelerini grafikle birlikte gösterir.",
"horizon.pages.shader_options": "Gölgelendirici Seçenekleri",
"horizon.pages.split_profiles": "Kim oynuyor?",
"horizon.pages.split_profiles.tooltip": "Kim oynuyor: bu oyun klasöründe tutulan bir profil.",
"horizon.shader_options.open": "Gölgelendirici Seçenekleri…",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Gölgelendirici paketinin seçenekleri okunuyor…"
}
@@ -64,7 +64,7 @@
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.shader_options.open": "Налаштування шейдерів…",
"horizon.shader_options.profile": "Профіль",
"horizon.shader_options.loading": "Читання налаштувань пакета шейдерів…",
"horizon.shader_options.loading": "Читання налаштувань набору шейдерів…",
"horizon.agent.copy_details": "Копіювати дані з’єднання",
"horizon.multiplayer.join": "Приєднатися",
"horizon.multiplayer.cancel_reconnecting": "Скасувати перепідключення",
@@ -203,5 +203,11 @@
"horizon.multiplayer.not_signed_in": "该服务器为在线模式服务器。请先在 Horizon 启动器中登录 Microsoft 账户再加入。",
"horizon.multiplayer.invalid_address": "无效的服务器地址:%s",
"horizon.multiplayer.connected_friend_direct": "已直连好友的世界…",
"horizon.multiplayer.connected_friend_relay": "已通过中继连接好友的世界…"
"horizon.multiplayer.connected_friend_relay": "已通过中继连接好友的世界…",
"horizon.pages.shader_options": "光影选项",
"horizon.pages.split_profiles": "谁在玩?",
"horizon.pages.split_profiles.tooltip": "谁在玩:保存在此游戏文件夹中的一份档案。",
"horizon.shader_options.open": "光影选项…",
"horizon.shader_options.profile": "档案",
"horizon.shader_options.loading": "正在读取光影包的选项…"
}
@@ -17,6 +17,7 @@ use horizon_client::{
biome::Biomes,
blocks::BlockFacts,
chunk::HEIGHT,
graphics::LeafCullingMode,
light::LightTables,
mesher::{MeshTables, Mesher, Region, borrow},
models,
@@ -139,6 +140,7 @@ fn main() -> Result<(), Error> {
blend_radius: 2,
smooth_lighting: true,
cutout_leaves: true,
leaf_culling: LeafCullingMode::Off,
assets: &context.assets,
render: &context.render,
tables: &context.tables,
@@ -328,5 +328,5 @@ fn chat(c: &Value, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
if let Some(n) = c["scroll"].as_i64() {
chat.scroll_by(n as i32, focused);
}
chat.layout(h, c["now"].as_u64().unwrap_or(0), focused)
chat.layout(h, c["now"].as_f64().unwrap_or(0.0) as f32, focused)
}
@@ -1182,6 +1182,7 @@ pub fn main() -> horizon_client::Result<()> {
thundering: s.thunder,
nether: false,
block_tags: &items.block_tags,
landing: &motion.landing,
biomes: &biomes,
mob_griefing: true,
universal_anger: false,
@@ -7,6 +7,7 @@ use horizon_client::{
biome::{BiomeInfo, Biomes, GrassModifier},
blocks::BlockFacts,
chunk::{Chunk, Light, MIN_Y, index},
graphics::LeafCullingMode,
mesher::{EMISSION_BITS, FLUID_FACE_BITS, MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
models,
};
@@ -137,6 +138,7 @@ fn scene(
blend_radius,
smooth_lighting,
cutout_leaves,
leaf_culling: LeafCullingMode::Off,
};
let section_y = int(&row["section_y"])?;
let started = Instant::now();
+11 -6
View File
@@ -2,6 +2,7 @@
//! progress (`PlayerAdvancements`) and the predicates criteria test
//! (`EntityPredicate`, `ItemPredicate`, `LocationPredicate`, the loot
//! conditions advancements use).
use horizon_world::lazy_json::LazyJson;
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet, HashMap},
@@ -30,7 +31,9 @@ pub struct Display {
pub struct Criterion {
pub name: String,
pub trigger: String,
pub conditions: Value,
/// Read on first test: ~1,300 recipe advancements' criteria, most never
/// tested in a game, took ~4 MB as `Value`s.
pub conditions: LazyJson,
}
#[derive(Clone, Debug, Default)]
pub struct Rewards {
@@ -154,7 +157,8 @@ impl Tree {
.into_iter()
.flatten()
{
match self.list[ai].criteria[ci].conditions["recipes"].as_str() {
// `read`: the index alone keeps none of the criteria read.
match self.list[ai].criteria[ci].conditions.read()["recipes"].as_str() {
Some(r) => self
.by_recipe
.entry(r.to_owned())
@@ -208,7 +212,8 @@ impl InventoryIndex {
return index;
};
for &(ai, ci) in list {
let items = &tree.list[ai].criteria[ci].conditions["items"];
let conditions = tree.list[ai].criteria[ci].conditions.read();
let items = &conditions["items"];
// `id_matches`: only a string or a list limits the items.
let limited = items.as_array().and_then(|a| {
a.iter()
@@ -266,7 +271,7 @@ fn parse(id: &str, v: &Value) -> Option<Advancement> {
.map(|(name, c)| Criterion {
name: name.clone(),
trigger: c["trigger"].as_str().unwrap_or("").to_owned(),
conditions: c["conditions"].clone(),
conditions: LazyJson::new(&c["conditions"]),
})
.collect();
let requirements = match v["requirements"].as_array() {
@@ -1606,7 +1611,7 @@ mod tests {
criteria: vec![Criterion {
name: "c".into(),
trigger: "minecraft:inventory_changed".into(),
conditions,
conditions: LazyJson::new(&conditions),
}],
requirements: vec![vec!["c".into()]],
rewards: Rewards::default(),
@@ -1655,7 +1660,7 @@ mod tests {
criteria: vec![Criterion {
name: "c".into(),
trigger: "minecraft:recipe_unlocked".into(),
conditions,
conditions: LazyJson::new(&conditions),
}],
requirements: vec![vec!["c".into()]],
rewards: Rewards::default(),
+36 -9
View File
@@ -1219,6 +1219,15 @@ pub(crate) fn recipe_files(dir: &Path) -> Result<Vec<(String, PathBuf)>> {
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`).
pub fn recipe_id(name: &str) -> String {
if name.contains(':') {
name.to_owned()
} else {
format!("minecraft:{name}")
}
}
fn custom_type(v: &Value) -> Option<&str> {
v["type"]
.as_str()
@@ -1309,10 +1318,14 @@ fn matches_all(ingredients: &[Ingredient], stacks: &[&ItemStack]) -> bool {
}
impl Recipes {
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Result<Self> {
let mut crafting = Vec::new();
let mut cooking = Vec::new();
let mut custom: HashMap<String, Vec<CustomRecipe>> = HashMap::new();
let mut meta = HashMap::new();
let sources = Self::sources(root, overlay)?;
Self::from_sources(root, items, overlay, sources)
}
/// The recipe files the tables are built from, each id once: vanilla's by
/// bare name, then the content mods' as `<ns>:<name>`, the world's packs'
/// over them (`overlay`), parsed, and only those whose load conditions
/// hold. A server sends some of them to its clients (`recipe_content`).
pub fn sources(root: &Path, overlay: &PackOverlay) -> Result<Vec<(String, Value)>> {
// Vanilla's recipes by bare name, then content mods' as `<ns>:<name>`.
let mut sources: Vec<(String, Vec<u8>)> = Vec::new();
// NeoForge's load conditions: `mod_loaded` asks which mods' data is
@@ -1357,6 +1370,7 @@ impl Recipes {
}
}
let filters = horizon_items::recipe_filters::RecipeFilters::load(root, overlay);
let mut parsed = Vec::with_capacity(sources.len());
for (name, bytes) in sources {
// Vanilla's RecipeManager logs a file that does not parse and
// goes on ("Couldn't parse data file").
@@ -1375,13 +1389,26 @@ impl Recipes {
continue;
}
// The data's recipe filters, over the final set.
let full_id = match name.contains(':') {
true => name.clone(),
false => format!("minecraft:{name}"),
};
if !filters.apply(&full_id, &mut v) {
if !filters.apply(&recipe_id(&name), &mut v) {
continue;
}
parsed.push((name, v));
}
Ok(parsed)
}
/// The tables from `sources` (`Recipes::sources`' shape); `root` and
/// `overlay` give the enchantment tags the tables keep.
pub fn from_sources(
root: &Path,
items: &Items,
overlay: &PackOverlay,
sources: Vec<(String, Value)>,
) -> Result<Self> {
let mut crafting = Vec::new();
let mut cooking = Vec::new();
let mut custom: HashMap<String, Vec<CustomRecipe>> = HashMap::new();
let mut meta = HashMap::new();
for (name, v) in sources {
if let Some(kind) = custom_type(&v) {
let id = if name.contains(':') {
name
+237 -6
View File
@@ -201,6 +201,7 @@ impl ItemEntity {
collision: &BlockCollisionRegistry<'_>,
no_physics: bool,
fluids: &crate::motion::FluidResources,
entity_boxes: &[Aabb],
) -> bool {
self.ticks += 1;
self.previous = self.position;
@@ -241,10 +242,20 @@ impl ItemEntity {
// Entity.move with noPhysics: straight through, the ground as it was.
let moved = if no_physics {
wanted
} else {
} else if entity_boxes.is_empty() {
scene
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
.unwrap_or(Vec3::ZERO)
} else {
// `Entity.collide`: the entities that `canBeCollidedWith` the item stop it
// too (a boat, a minecart, a shulker, a happy ghast held still).
crate::collisions::EntityCollisions {
blocks: scene,
entities: entity_boxes,
border: None,
}
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
.unwrap_or(Vec3::ZERO)
};
self.position = Vec3::new(
self.position.x + moved.x,
@@ -405,6 +416,71 @@ impl ItemEntity {
self.pickup_delay != u32::MAX && age < 6000 && self.stack.count < max_stack
}
}
/// The boxes that stop a dropped item (boats, minecarts, shulkers, a held-still happy ghast),
/// binned by 8-block cell, so an item looks only at the ones near it: a farm with many drops and
/// many vehicles checks a handful of boxes per item, not every box in the level.
#[derive(Default)]
pub struct ObstacleGrid {
cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>>,
boxes: Vec<Aabb>,
}
impl ObstacleGrid {
const CELL: f64 = 8.0;
pub fn new(boxes: Vec<Aabb>) -> Self {
let mut cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>> = Default::default();
for (i, b) in boxes.iter().enumerate() {
let (lo, hi) = Self::span(*b);
for x in lo.0..=hi.0 {
for y in lo.1..=hi.1 {
for z in lo.2..=hi.2 {
cells.entry((x, y, z)).or_default().push(i as u32);
}
}
}
}
Self { cells, boxes }
}
fn span(b: Aabb) -> ((i32, i32, i32), (i32, i32, i32)) {
let cell = |v: Vec3| {
(
(v.x / Self::CELL).floor() as i32,
(v.y / Self::CELL).floor() as i32,
(v.z / Self::CELL).floor() as i32,
)
};
(cell(b.min_position()), cell(b.max_position()))
}
pub fn is_empty(&self) -> bool {
self.boxes.is_empty()
}
/// The boxes meeting `area` (`getEntityCollisions`'s query box), in the order they were given.
pub fn near(&self, area: Aabb, out: &mut Vec<Aabb>) {
out.clear();
if self.boxes.is_empty() {
return;
}
let (lo, hi) = Self::span(area);
let mut found: Vec<u32> = Vec::new();
for x in lo.0..=hi.0 {
for y in lo.1..=hi.1 {
for z in lo.2..=hi.2 {
if let Some(list) = self.cells.get(&(x, y, z)) {
found.extend(list);
}
}
}
}
found.sort_unstable();
found.dedup();
out.extend(
found
.into_iter()
.map(|i| self.boxes[i as usize])
.filter(|b| b.intersects(area)),
);
}
}
/// Dropped items by block cell, for `ItemEntity.mergeWithNeighbours`'s
/// search around an item. Holds indexes into the tick's item list.
#[derive(Default)]
@@ -1081,6 +1157,12 @@ pub struct ArmorStand {
/// `ArmorStand.disabledSlots`: bit `filterFlag` locks a slot, bit
/// `filterFlag + 8` takes from it and bit `filterFlag + 16` puts into it.
pub disabled_slots: i32,
/// Server-only `LivingEntity.lastPos` for location-based enchantments.
pub enchantment_block: Option<BlockPos>,
/// Travel accumulated for `LivingEntity.checkFallDamage`'s landing callback.
pub enchantment_fall_distance: f64,
/// Last equipment seen by `collectEquipmentChanges`; not saved to NBT.
pub enchantment_equipment: [Option<ItemStack>; 6],
}
impl ArmorStand {
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
@@ -1152,6 +1234,9 @@ impl ArmorStand {
pose: Self::DEFAULT_POSE,
vehicle: None,
disabled_slots: 0,
enchantment_block: None,
enchantment_fall_distance: 0.0,
enchantment_equipment: Default::default(),
}
}
/// `EquipmentSlot.getFilterFlag` of an `equipment` index (main hand, off
@@ -1721,7 +1806,15 @@ mod honey_slide_tests {
item.id = 1;
let mut ys = Vec::new();
for _ in 0..8 {
item.tick(&world, assets.registry, &facts, &collision, false, &fluids);
item.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
&[],
);
ys.push(item.velocity.y);
}
// Idle ticks keep gravity; the 4th tick's move hits the floor and
@@ -1777,7 +1870,15 @@ mod honey_slide_tests {
item.id = 1;
let before = item.position.y;
for _ in 0..16 {
item.tick(&world, assets.registry, &facts, &collision, false, &fluids);
item.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
&[],
);
}
assert!(
item.position.y > before + 0.5,
@@ -1787,7 +1888,7 @@ mod honey_slide_tests {
);
}
fn falling_item(x: f64, y: f64, z: f64, vx: f64, vy: f64, vz: f64) -> ItemEntity {
pub(super) fn falling_item(x: f64, y: f64, z: f64, vx: f64, vy: f64, vz: f64) -> ItemEntity {
let at = Vec3::new(x, y, z);
ItemEntity {
id: 1,
@@ -1995,7 +2096,15 @@ mod slime_bounce_tests {
falling.on_ground = false;
falling.age = 0;
falling.id = 1;
falling.tick(&world, assets.registry, &facts, &collision, false, &fluids);
falling.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
&[],
);
assert_eq!(falling.position.y, 4.0);
assert!(falling.velocity.y > 0.0);
assert!(falling.on_ground);
@@ -2005,7 +2114,15 @@ mod slime_bounce_tests {
// does not set needs_sync.
falling.velocity = Vec3::ZERO;
falling.needs_sync = false;
falling.tick(&world, assets.registry, &facts, &collision, false, &fluids);
falling.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
&[],
);
assert!(!falling.needs_sync);
}
}
@@ -2060,3 +2177,117 @@ mod unstuck_tests {
assert_eq!(ExperienceOrb::unstuck(at, &BORDER, shapes(&solid)), None);
}
}
#[cfg(test)]
mod entity_collision_tests {
use super::honey_slide_tests::falling_item;
use super::*;
/// `Entity.collide` stops an item at the entities that `canBeCollidedWith` it: dropped
/// above a boat's box (or a still happy ghast's) it lands on the box's top and rests,
/// where with no box it falls to the floor of the scene.
#[test]
fn an_item_lands_on_an_entity_box_under_it() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let fluids = crate::motion::FluidResources::new(&assets, &facts).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let mut world = World::test_scene(&assets, &render, 3).unwrap();
let stone = assets.registry.default_state("minecraft:stone").unwrap();
world.set(20, 3, 20, stone).unwrap();
let rest = |boxes: &[Aabb]| {
let mut item = falling_item(20.5, 12.0, 20.5, 0.0, 0.0, 0.0);
item.id = 1;
for _ in 0..80 {
item.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
boxes,
);
}
item.position.y
};
// The stone's top is at 4.0; a box from 5.0 to 9.0 is in the way.
let platform = Aabb::new(Vec3::new(19.0, 5.0, 19.0), Vec3::new(22.0, 9.0, 22.0));
assert!((rest(&[]) - 4.0).abs() < 1.0e-6, "on the stone with no box");
assert!(
(rest(&[platform]) - 9.0).abs() < 1.0e-6,
"on top of the box, at its top"
);
}
#[test]
fn obstacle_grid_returns_only_the_boxes_near_an_item() {
// A boat every 100 blocks along a line, 1000 of them.
let boat = |x: f64| {
Aabb::new(
Vec3::new(x, 64.0, 0.0),
Vec3::new(x + 1.375, 64.5625, 1.375),
)
};
let grid = ObstacleGrid::new((0..1000).map(|i| boat(f64::from(i) * 100.0)).collect());
let mut out = Vec::new();
// Around the 7th boat: only it.
let area = Aabb::new(Vec3::new(699.0, 63.0, -1.0), Vec3::new(701.0, 66.0, 1.0));
grid.near(area, &mut out);
assert_eq!(out, vec![boat(700.0)]);
// Between two boats: none.
let area = Aabb::new(Vec3::new(750.0, 63.0, 0.0), Vec3::new(751.0, 66.0, 1.0));
grid.near(area, &mut out);
assert!(out.is_empty());
// A box spanning cells is found from any of them, once.
let wide = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(20.0, 1.0, 1.0));
let grid = ObstacleGrid::new(vec![wide]);
let area = Aabb::new(Vec3::new(17.0, 0.0, 0.0), Vec3::new(18.0, 1.0, 1.0));
grid.near(area, &mut out);
assert_eq!(out, vec![wide]);
}
/// A farm: 5000 drops and 1000 separated vehicles. Run with `-- --ignored --nocapture`.
#[test]
#[ignore = "timing"]
fn obstacle_grid_beats_scanning_every_box_for_a_big_farm() {
let boat = |i: u32| {
let (x, z) = (f64::from(i % 40) * 6.0, f64::from(i / 40) * 6.0);
Aabb::new(
Vec3::new(x, 64.0, z),
Vec3::new(x + 1.375, 64.5625, z + 1.375),
)
};
let boxes: Vec<Aabb> = (0..1000).map(boat).collect();
let items: Vec<Aabb> = (0..5000)
.map(|i| {
let (x, z) = (f64::from(i % 70) * 3.4, f64::from(i / 70) * 3.4);
Aabb::new(Vec3::new(x, 64.0, z), Vec3::new(x + 0.25, 64.25, z + 0.25))
})
.collect();
let start = std::time::Instant::now();
let mut hits = 0;
for item in &items {
let area = item.inflate(Vec3::new(1.0, 1.0, 1.0));
hits += boxes.iter().filter(|b| b.intersects(area)).count();
}
let scan = start.elapsed();
let grid = ObstacleGrid::new(boxes);
let start = std::time::Instant::now();
let (mut out, mut found) = (Vec::new(), 0);
for item in &items {
grid.near(item.inflate(Vec3::new(1.0, 1.0, 1.0)), &mut out);
found += out.len();
}
let binned = start.elapsed();
assert_eq!(hits, found);
println!("scan {scan:?}, grid {binned:?}");
}
}
+27 -3
View File
@@ -246,17 +246,41 @@ mod tests {
needs_sync: false,
};
let mut still = item.clone();
assert!(still.tick(&world, assets.registry, &facts, &collision, false, &fluids));
assert!(still.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
&[]
));
assert_eq!(still.position.x, at.x);
assert_eq!(still.velocity.x, 0.0);
world.set(21, 4, 20, flowing).unwrap();
let mut carried = item;
assert!(carried.tick(&world, assets.registry, &facts, &collision, false, &fluids));
assert!(carried.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
&[]
));
// First push is damped by buoyancy before motion, second follows drag.
assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12);
assert!((carried.velocity.x - (0.014 * 0.99 * 0.98 + 0.014)).abs() < 1e-12);
for _ in 0..20 {
carried.tick(&world, assets.registry, &facts, &collision, false, &fluids);
carried.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
&[],
);
}
assert!(carried.position.x > at.x + 0.5);
}
+204 -62
View File
@@ -190,6 +190,7 @@ mod names;
mod narrator;
mod native_biome_colors;
mod native_block_entity_draw;
mod native_claimed_items;
mod native_components;
mod native_criteria;
mod native_entities;
@@ -237,6 +238,7 @@ mod prediction;
mod pumpkin;
mod quick_actions;
mod raids;
mod random_textures;
mod recipe_book;
mod remote;
mod remote_camera;
@@ -268,6 +270,7 @@ mod shield;
mod shoulders;
mod sign_edit;
mod sniffers;
mod social;
mod spawner;
mod spear;
mod spectator;
@@ -760,6 +763,8 @@ enum Screen {
/// Play with Friends, from the pause menu's Friends button
/// (`friends_screen`, game/friends.rs).
Friends,
/// `SocialInteractionsScreen` (`social_screen`, game/social.rs).
Social,
/// CommandBlockEditScreen (game/command_blocks.rs).
CommandBlock,
/// A game master block's edit screen (game/block_editor.rs).
@@ -796,6 +801,7 @@ impl Screen {
Screen::Advancements => "advancements",
Screen::WorldOptions => "world_options",
Screen::Friends => "friends",
Screen::Social => "social",
Screen::CommandBlock => "command_block",
Screen::BlockEditor => "block_editor",
Screen::ServerLinks => "server_links",
@@ -2198,6 +2204,7 @@ struct Game<'a> {
// Physical input is separate from remapped and toggled movement keys.
physical_keys: HashSet<KeyCode>,
physical_buttons: HashSet<MouseButton>,
drag_button: Option<MouseButton>,
/// Control + left click as a right click (macOS).
right_click_emulation: horizon_client_input::mouse::RightClickEmulation,
left: bool,
@@ -2301,6 +2308,8 @@ struct Game<'a> {
session_lock: Option<horizon_client::save::SessionLock>,
audio: Audio,
music: horizon_client::music::MusicManager,
/// The tracks native mods play over the music (`native_music_layers`).
music_layers: horizon_client::music::MusicLayers,
/// `ToastManager.nowPlayingToast`: there while Music Toast is not Never.
now_playing: Option<horizon_client::toasts::NowPlayingToast>,
/// The Music Toast option the toast was last set up for.
@@ -2618,6 +2627,8 @@ struct Game<'a> {
hoppers: HashMap<(i32, i32, i32), machines::HopperState>,
/// The screen of a mod block's menu open on this client (`native_screen`).
mod_screen: Option<native_hooks::OpenNativeScreen>,
/// The players with each mod container open, by level (`tick_mod_container_viewers`).
mod_container_viewers: HashMap<(&'static str, (i32, i32, i32)), u32>,
/// Where item entities are, while the block entities tick (hoppers).
item_columns: Option<machines::ItemColumns>,
creative_screen: Creative,
@@ -2644,6 +2655,8 @@ struct Game<'a> {
panel: native_panel::Panel,
/// Horizon's own shader pack, while one is picked (`game/native_shaders.rs`).
native_shaders: native_shaders::NativeShaders,
/// The items native client mods claim and the events they haven't polled (game/native_claimed_items.rs).
native_input: native_claimed_items::ModInput,
/// The native lifecycle bookkeeping.
native_lifecycle: native_lifecycle::NativeLifecycle,
/// Native mods' messages waiting (`game/native_net.rs`).
@@ -2685,6 +2698,8 @@ struct Game<'a> {
serving: Option<integrated::Serving>,
/// The open world is shared behind an invite code (game/friends.rs).
friends: Option<friends::Sharing>,
/// The Social Interactions screen and the players whose chat is hidden (game/social.rs).
social: social::Social,
world_options: Option<lan_screen::WorldOptions>,
/// Chat Settings opened from RestrictionsScreen: Done returns to World Options.
options_to_world_options: bool,
@@ -3687,6 +3702,8 @@ impl Game<'_> {
|stack: &ItemStack| catalog.get(&stack.item).map_or(64, |i| i.max_stack).max(1);
// A LAN or server guest only shows the host's items.
let merging = self.remote.is_none();
let obstacles = horizon_client::entities::ObstacleGrid::new(self.item_entity_collisions());
let mut nearby = Vec::new();
for i in 0..items.len() {
if removed[i] {
continue;
@@ -3711,6 +3728,14 @@ impl Game<'_> {
let speed = self.world.random.next_float() * 0.2 + 0.1;
item.move_towards_closest_space(&self.world, self.res.facts(), speed);
}
// `getEntityCollisions(entity, bounds.expandTowards(movement))`: the boxes around
// where the item can reach this tick (a block of slack for the next velocity).
let reach = Vec3::new(
item.velocity.x.abs() + 1.0,
item.velocity.y.abs() + 1.0,
item.velocity.z.abs() + 1.0,
);
obstacles.near(item.bounds().inflate(reach), &mut nearby);
let alive = item.tick(
&self.world,
self.pack.assets.registry,
@@ -3718,6 +3743,7 @@ impl Game<'_> {
self.collision,
no_physics,
&self.res.motion().fluids,
&nearby,
);
cells.moved(i, item.previous, item.position);
// ItemEntity.tick: merge every 2 ticks while moving between
@@ -5576,10 +5602,11 @@ impl Game<'_> {
return Ok(Some(Hand::Main));
}
// An EntityHitResult may fall through to Item.use, never to useItemOn.
let block_hit = if matches!(guest, Some(GuestUse::Item(_))) || self.entity_targeted() {
None
} else {
self.pick()
let block_hit = match guest {
Some(GuestUse::OnBlock(_)) => self.pick(),
Some(GuestUse::Item(_)) => None,
_ if self.entity_targeted() => None,
_ => self.pick(),
};
let hands = match guest {
Some(GuestUse::OnBlock(hand) | GuestUse::Item(hand) | GuestUse::Entity(hand, _)) => {
@@ -5858,7 +5885,9 @@ impl Game<'_> {
self.trigger_default_block_use(hit.pos);
}
}
self.swing_arm();
if result.should_swing() {
self.swing_arm();
}
return Ok(true);
}
}
@@ -5956,9 +5985,7 @@ impl Game<'_> {
return Ok(true);
}
if (!on_block && self.use_potion_item(&stack)?)
|| ((block_hit.is_some() || !spawn_egg)
&& (!on_block || spawn_egg)
&& self.throw_item(&stack)?)
|| ((!on_block || (spawn_egg && block_hit.is_some())) && self.throw_item(&stack)?)
{
return Ok(true);
}
@@ -7374,6 +7401,36 @@ impl Game<'_> {
}
}
}
/// Apply a physical key to every mapping bound to it.
fn mapped_key_event(&mut self, key: KeyCode, pressed: bool, repeat: bool) -> Result<()> {
for mapped in self.settings.keys.translate_all(key) {
match mapped {
Ok(k) => {
self.mapped_key(k, pressed, repeat)?;
}
Err(button) => {
let action = match (pressed, repeat) {
(false, _) => KeyAction::Release,
(_, false) => KeyAction::Press,
(_, true) => KeyAction::Repeat,
};
if let Some(pressed) = keyboard_button(
gameplay_has_keyboard(
self.screen,
self.title.is_some(),
self.dialog_open(),
),
action,
) {
self.play_mouse(button, pressed)?;
} else if container_key_clones(button, action) {
self.inventory_pick_key();
}
}
}
}
Ok(())
}
/// A key event after rebinding, as the default key of its mapping (the
/// keyboard and controllers both end here).
fn mapped_key(&mut self, key: KeyCode, pressed: bool, repeat: bool) -> Result<()> {
@@ -7479,6 +7536,7 @@ impl Game<'_> {
| Screen::Advancements
| Screen::WorldOptions
| Screen::Friends
| Screen::Social
| Screen::ServerLinks
| Screen::ConfirmLink
| Screen::DialogWarning
@@ -8178,6 +8236,7 @@ impl Game<'_> {
Screen::Mod => {}
Screen::WorldOptions => self.draw_world_options(&mut batch),
Screen::Friends => self.draw_friends_screen(&mut batch),
Screen::Social => self.draw_social_screen(&mut batch),
Screen::ServerLinks => self.draw_server_links(&mut batch),
Screen::ConfirmLink => self.draw_confirm_link(&mut batch),
Screen::DialogWarning => self.draw_dialog_warning(&mut batch),
@@ -11464,6 +11523,7 @@ impl Game<'_> {
// TagsUpdatedEvent (SERVER_DATA_LOAD): the world's data is in, before
// its chunks and players are.
self.native_server_data_loaded(false);
self.sync_recipe_content();
self.bosses = Default::default();
reset_world_progress(&mut self.adv, &mut self.stats);
self.prefetch_advancement_tree();
@@ -13339,14 +13399,25 @@ impl Game<'_> {
let abilities = self.chat_abilities();
self.ui.chat.set_abilities(&self.gui.atlas.font, abilities);
self.streaming.radius = self.settings.render_distance;
// Options.biomeBlendRadius, ambientOcclusion and cutoutLeaves:
// LevelExtractor.allChanged when they change.
// Options.biomeBlendRadius, ambientOcclusion, cutoutLeaves and the
// leaf-culling mode: LevelExtractor.allChanged when they change.
let leaf_culling = self.effective_leaf_culling();
let (s, st) = (&self.settings, &self.streaming);
let remesh = (s.biome_blend, s.smooth_lighting, s.cutout_leaves)
!= (st.biome_blend, st.smooth_lighting, st.cutout_leaves);
let remesh = (
s.biome_blend,
s.smooth_lighting,
s.cutout_leaves,
leaf_culling,
) != (
st.biome_blend,
st.smooth_lighting,
st.cutout_leaves,
st.leaf_culling,
);
self.streaming.biome_blend = self.settings.biome_blend;
self.streaming.smooth_lighting = self.settings.smooth_lighting;
self.streaming.cutout_leaves = self.settings.cutout_leaves;
self.streaming.leaf_culling = leaf_culling;
if remesh {
self.all_sections_changed();
}
@@ -13799,6 +13870,8 @@ impl Game<'_> {
}
lines
}
/// `CreativeModeInventoryScreen.extractRenderState`: item slots share the
/// container screen's hover target for their highlight and tooltip.
fn draw_creative(&mut self, batch: &mut HudBatch) {
// CreativeModeInventoryScreen: `CommonComponents.EMPTY` for a title.
screen_info::begin_container("creative");
@@ -13894,6 +13967,9 @@ impl Game<'_> {
}
// The hovered stack, and whether it is a grid slot (`CustomCreativeSlot`).
let mut hovered: Option<(ItemStack, bool)> = None;
// Use the same padded slot bounds as clicks and the other menus, so
// highlights and tooltips remain visible between adjacent items.
let hovered_slot = self.slot_at(batch, self.cursor.0, self.cursor.1);
if tab.inventory {
self.draw_player_preview(
batch,
@@ -13924,10 +14000,7 @@ impl Game<'_> {
continue;
};
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 18.0 + r as f32 * 18.0);
let over = self.cursor.0 >= x
&& self.cursor.0 < x + 16.0
&& self.cursor.1 >= y
&& self.cursor.1 < y + 16.0;
let over = hovered_slot == Some(Creative::DISPLAY + r * 9 + c);
if over {
self.draw_slot_highlight(batch, false, x, y);
}
@@ -13941,10 +14014,7 @@ impl Game<'_> {
let drag = self.drag_view();
for c in 0..9 {
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 112.0);
let over = self.cursor.0 >= x
&& self.cursor.0 < x + 16.0
&& self.cursor.1 >= y
&& self.cursor.1 < y + 16.0;
let over = hovered_slot == Some(c);
if over {
self.draw_slot_highlight(batch, false, x, y);
}
@@ -15243,6 +15313,7 @@ impl Game<'_> {
}
}
Screen::Stats | Screen::Friends => self.screen = Screen::Paused,
Screen::Social => self.close_social(),
Screen::ServerLinks => self.screen = Screen::Paused,
// ConfirmScreen.keyPressed: Escape answers no; both return to
// the Server Links screen they came from.
@@ -15285,6 +15356,11 @@ impl Game<'_> {
// an active use in the same tick.
self.release_use_for_keybind_screen();
}
// key.socialInteractions.
KeyCode::KeyP if self.screen == Screen::Playing => {
self.open_social(false);
self.release_use_for_keybind_screen();
}
KeyCode::KeyL if self.screen == Screen::Advancements => self.close_advancements(),
// key.quickActions: only open if a datapack provides dialog actions.
KeyCode::KeyG if self.screen == Screen::Playing => {
@@ -15472,11 +15548,25 @@ impl Game<'_> {
}
Ok(())
}
/// LOD Distance and Far Detail Reduced as the player chose them.
/// LOD Distance and Far Detail Reduced as the player chose them, the
/// distance no farther than a server sampling far terrain for us allows.
fn far_terrain_choice(&self) -> (u32, bool) {
let g = &self.settings.horizon;
let reduced = g.far_detail == horizon_client::graphics::FarDetail::Reduced;
(g.lod_distance, reduced)
let distance = match self.server_far_terrain() {
Some(offer) => g.lod_distance.min(offer.distance),
None => g.lod_distance,
};
(distance, reduced)
}
/// The far terrain another machine's server samples for us
/// (`lod_net`): a client of a dedicated server or of a LAN world, not of
/// its own server thread, which hands its generator over directly.
fn server_far_terrain(&self) -> Option<horizon_client::lod_net::Offer> {
if self.integrated.is_some() {
return None;
}
self.remote.as_ref()?.far_terrain
}
/// LOD Distance as far terrain uses it (`graphics::lod_distance_capped`).
fn lod_distance(&self) -> u32 {
@@ -15497,6 +15587,11 @@ impl Game<'_> {
}
}
fn clear_lod(&mut self) {
// The samplers waiting on the server stop first: dropping the far
// terrain joins them.
if let Some(remote) = &self.remote {
remote.session.cancel_far_terrain();
}
self.lod = None;
self.lod_source_attempted = false;
self.lod_observe.clear();
@@ -15511,7 +15606,9 @@ impl Game<'_> {
&& self.renderer.is_some()
&& self.player.is_some()
&& self.title.is_none()
&& (self.remote.is_none() || self.integrated.is_some());
&& (self.remote.is_none()
|| self.integrated.is_some()
|| self.server_far_terrain().is_some());
if !enabled {
if self.lod.is_some() || self.lod_source_attempted {
self.clear_lod();
@@ -15523,9 +15620,16 @@ impl Game<'_> {
}
self.lod_source_attempted = true;
let dim = self.dim;
let source = match &self.integrated {
Some(server) => server.call(move |game| game.lod_source_in(dim)).flatten(),
None => Some(self.streaming.lod_source()),
let source = match (&self.integrated, self.server_far_terrain(), &self.remote) {
(Some(server), ..) => server.call(move |game| game.lod_source_in(dim)).flatten(),
(None, Some(offer), Some(remote)) => {
let states = self.res.facts().states.len() as u32;
let biomes = self.res.biomes().index.clone();
let source: Arc<dyn horizon_client::streaming::LodSource> =
remote.session.far_terrain(offer, biomes, states);
Some(source)
}
_ => Some(self.streaming.lod_source()),
};
if let Some(source) = source {
self.lod = Some(horizon_client::lod::Lod::new(
@@ -15791,7 +15895,21 @@ impl ApplicationHandler for Game<'_> {
self.previous = Instant::now();
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
let key = if self.window.as_ref().is_some_and(|w| w.id() == id)
&& let WindowEvent::KeyboardInput {
event,
is_synthetic: false,
..
} = &event
{
Some((event.clone(), glfw_modifiers(self.modifiers)))
} else {
None
};
self.handle_window_event(event_loop, id, event);
if let Some((event, modifiers)) = key {
self.native_input_key(&event, modifiers);
}
}
fn device_event(&mut self, _: &ActiveEventLoop, _: DeviceId, event: DeviceEvent) {
// The real mouse never steers an offscreen game: its pointer is not grabbed.
@@ -15894,6 +16012,7 @@ impl Game<'_> {
if let WindowEvent::Focused(false) = &event {
self.physical_keys.clear();
self.physical_buttons.clear();
self.drag_button = None;
}
if let WindowEvent::ModifiersChanged(m) = &event {
self.modifiers = m.state();
@@ -16160,6 +16279,21 @@ impl Game<'_> {
return;
}
let result: Result<()> = (|| {
if let WindowEvent::MouseInput { state, button, .. } = &event
&& !self.pad_synthetic_input()
{
self.track_drag_button(*button, *state == ElementState::Pressed);
}
if let WindowEvent::KeyboardInput { event, .. } = &event
&& event.state == ElementState::Released
&& let PhysicalKey::Code(key) = event.physical_key
&& self.screen_key_released(key)
{
// Screen cancellation must not leave a held keyboard mapping down.
self.keys.remove(&key);
self.mapped_key_event(key, false, false)?;
return Ok(());
}
// ScreenEvent.MouseButtonPressed.Pre / MouseButtonReleased.Pre:
// the open screen hears of the press before anything reads it, and
// a cancelled one goes no further — no hotbar key, no slot, no
@@ -16252,7 +16386,11 @@ impl Game<'_> {
WindowEvent::CursorMoved { position, .. } => {
if let Some(r) = &self.renderer {
let b = HudBatch::new([r.config.width, r.config.height]);
let previous = self.cursor;
self.cursor = (position.x as f32 / b.scale, position.y as f32 / b.scale);
if self.screen_mouse_dragged(previous) {
return Ok(());
}
if self.dialog_open() {
self.dialog_drag();
}
@@ -16352,10 +16490,28 @@ impl Game<'_> {
&& self.sleeping.is_none()
&& self.player.is_some()
&& !self.native_wheel_event(d)
&& let Some((wheel, (_, vertical_steps))) =
self.mouse
.on_scroll(raw_x, raw_y, discrete, wheel_sensitivity)
&& let Some((wheel, (horizontal_steps, vertical_steps))) = self
.mouse
.on_scroll(raw_x, raw_y, discrete, wheel_sensitivity)
{
if !self.pad_synthetic_input() {
let mut canceled = false;
self.native_mouse_scrolling(
[scaled(raw_x), scaled(raw_y)],
[horizontal_steps, vertical_steps],
[
self.physical_buttons.contains(&MouseButton::Left),
self.physical_buttons.contains(&MouseButton::Middle),
self.physical_buttons.contains(&MouseButton::Right),
],
[self.cursor.0, self.cursor.1],
glfw_modifiers(self.modifiers),
&mut canceled,
);
if canceled {
return Ok(());
}
}
if self.spectator() && self.spectator_gui.is_menu_active() {
self.spectator_scroll(-wheel);
} else if self.spectator() {
@@ -16377,6 +16533,9 @@ impl Game<'_> {
if self.screen == Screen::Chat && d != 0.0 {
self.chat_scroll(d);
}
if self.screen == Screen::Social && d != 0.0 {
self.social_scroll(d);
}
if self.screen == Screen::CommandBlock && d != 0.0 {
self.command_block_scroll(d);
}
@@ -16626,6 +16785,9 @@ impl Game<'_> {
Screen::Friends if pressed && button == MouseButton::Left => {
self.friends_screen_click()
}
Screen::Social if pressed && button == MouseButton::Left => {
self.social_click()
}
Screen::CommandBlock if pressed && button == MouseButton::Left => {
self.command_block_click()
}
@@ -16783,6 +16945,9 @@ impl Game<'_> {
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::Win => {
self.win_key(&event)?
}
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::Social => {
self.social_key(&event)
}
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::CommandBlock => {
self.command_block_key(&event)
}
@@ -16923,38 +17088,7 @@ impl Game<'_> {
{
self.smooth_camera = !self.smooth_camera;
}
// Key binds: act as the default key of every mapping bound
// to it (KeyMapping.set drives them all).
for mapped in self.settings.keys.translate_all(key) {
match mapped {
Ok(k) => {
self.mapped_key(
k,
event.state == ElementState::Pressed,
event.repeat,
)?;
}
Err(button) => {
let action = match (event.state, event.repeat) {
(ElementState::Released, _) => KeyAction::Release,
(_, false) => KeyAction::Press,
(_, true) => KeyAction::Repeat,
};
if let Some(pressed) = keyboard_button(
gameplay_has_keyboard(
self.screen,
self.title.is_some(),
self.dialog_open(),
),
action,
) {
self.play_mouse(button, pressed)?;
} else if container_key_clones(button, action) {
self.inventory_pick_key();
}
}
}
}
self.mapped_key_event(key, event.state == ElementState::Pressed, event.repeat)?;
}
WindowEvent::RedrawRequested => {
self.observe_fullscreen();
@@ -17393,6 +17527,7 @@ impl<'a> Game<'a> {
keys: HashSet::new(),
physical_keys: HashSet::new(),
physical_buttons: HashSet::new(),
drag_button: None,
right_click_emulation: Default::default(),
left: false,
right: false,
@@ -17458,6 +17593,7 @@ impl<'a> Game<'a> {
);
music
},
music_layers: Default::default(),
// ToastManager.initializeMusicToast: Pause and Pause and Toast make the toast.
now_playing: (settings.file.int("musicToast").clamp(0, 2) != 0).then(Default::default),
music_toast_state: settings.file.int("musicToast").clamp(0, 2) as u8,
@@ -17646,6 +17782,7 @@ impl<'a> Game<'a> {
tnts: Vec::new(),
hoppers: HashMap::new(),
mod_screen: None,
mod_container_viewers: HashMap::new(),
item_columns: None,
creative_screen: prepared.creative_screen,
control: None,
@@ -17669,6 +17806,7 @@ impl<'a> Game<'a> {
recipe_tables: Default::default(),
panel: Default::default(),
native_shaders: Default::default(),
native_input: Default::default(),
native_lifecycle: Default::default(),
native_net: Default::default(),
native_mod_screen: None,
@@ -17691,6 +17829,7 @@ impl<'a> Game<'a> {
test_highlights: HashMap::new(),
game_tests: game_tests::GameTests::default(),
friends: None,
social: Default::default(),
debug_keys: Default::default(),
debug_charts: Default::default(),
debug_entries: horizon_client::debug_entries::DebugEntries::load(
@@ -18055,7 +18194,7 @@ impl Game<'_> {
fn recipes(&self) -> &Recipes {
match &self.world_tables {
Some(t) => &t.recipes,
None => self.res.recipes(),
None => self.synced_recipes().unwrap_or_else(|| self.res.recipes()),
}
}
fn stations(&self) -> &horizon_client::stations::StationData {
@@ -18128,6 +18267,8 @@ impl Game<'_> {
}
self.world_tables = self.build_world_tables();
horizon_client::structure_blocks::forget_loot_data();
// Before the players are sent the reloaded data below.
self.sync_recipe_content();
// PlayerList.reloadResources: the tags and recipes go to the players
// and their advancements reload. The synchronized registries (the
// dialogs, and the commands' ids of them) stay the hub's.
@@ -20097,6 +20238,7 @@ mod tests {
Screen::Advancements,
Screen::WorldOptions,
Screen::Friends,
Screen::Social,
Screen::CommandBlock,
Screen::BlockEditor,
Screen::ServerLinks,
@@ -2006,7 +2006,7 @@ impl Game<'_> {
criteria.push(adv::Criterion {
name: name.clone(),
trigger: String::new(),
conditions: Value::Null,
conditions: Value::Null.into(),
});
}
}
+1
View File
@@ -417,6 +417,7 @@ impl Game<'_> {
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::Social => "social",
Screen::ServerLinks => "server-links",
Screen::ConfirmLink => "confirm-link",
Screen::DialogWarning => "dialog-warning",
+26 -9
View File
@@ -727,20 +727,20 @@ impl Game<'_> {
true
}
pub(super) fn tick_armor_stands(&mut self) {
let mut scene = horizon_client::collisions::SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
let mut dead = Vec::new();
// ServerLevel.tick: only entities in entity-ticking chunks tick.
let ticking = self.entity_ticking_area();
for (i, a) in self.armor_stands.iter_mut().enumerate() {
a.previous = a.position;
a.previous_yaw = a.yaw;
if !ticking(a.position.x.floor() as i32, a.position.z.floor() as i32) {
for i in 0..self.armor_stands.len() {
let at = self.armor_stands[i].position;
if !ticking(at.x.floor() as i32, at.z.floor() as i32) {
self.armor_stands[i].previous = at;
self.armor_stands[i].previous_yaw = self.armor_stands[i].yaw;
continue;
}
self.armor_stand_location_effects(i, false);
let a = &mut self.armor_stands[i];
a.previous = a.position;
a.previous_yaw = a.yaw;
let in_water = a.update_fluids(&self.world, &self.res.motion().fluids);
let (world, registry, facts, collision) = (
&self.world,
@@ -748,14 +748,31 @@ impl Game<'_> {
self.res.facts(),
self.collision,
);
let mut scene = horizon_client::collisions::SceneCollisions {
world,
binding: collision,
subject: None,
};
a.tick(&mut scene, facts, in_water, &mut |bounds, velocity| {
horizon_client::motion::bubble_column_push(
world, registry, facts, collision, bounds, velocity,
);
});
if in_water {
a.enchantment_fall_distance = 0.0;
} else {
a.enchantment_fall_distance += (a.previous.y - a.position.y).max(0.0);
}
let landed = a.on_ground && a.enchantment_fall_distance > 0.0;
if a.on_ground {
a.enchantment_fall_distance = 0.0;
}
if !a.environment_tick(&self.world, self.res.facts(), self.pack.assets.registry) {
dead.push(i);
}
if landed {
self.armor_stand_location_effects(i, true);
}
}
for i in dead.into_iter().rev() {
self.break_armor_stand_by_anything(i);
+23 -14
View File
@@ -57,8 +57,8 @@ pub(super) struct Boat {
above_bubble_column: bool,
/// `AbstractBoat.bubbleColumnDirectionIsDown`.
bubble_down: bool,
/// A bubble column's drag asked a rider the server can't move itself (a
/// player) to get out: `guest_vehicle_tick` takes it (`ejectPassengers`).
/// The server asked a rider whose client moves it (a player) to get out:
/// `guest_vehicle_tick` honors it (`Entity.ejectPassengers`).
pub eject_riders: bool,
/// `Entity.tickCount`, counted where the wobble is (a client's tick).
tick_count: i32,
@@ -101,6 +101,17 @@ pub(super) const BUBBLE_UP_EVENT: i8 = 72;
pub(super) const WIDTH: f64 = 1.375;
pub(super) const HEIGHT: f64 = 0.5625;
impl Boat {
/// `AbstractBoat.tick`: passengers lose control after 60 consecutive
/// underwater ticks; any other status resets the counter.
fn tick_submersion(&mut self) {
self.out_of_control =
if matches!(self.status, Status::UnderWater | Status::UnderFlowingWater) {
self.out_of_control + 1.0
} else {
0.0
};
}
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
Self {
id,
@@ -559,11 +570,7 @@ impl Game<'_> {
}
b.old_status = b.status;
b.status = self.boat_status(&mut b);
b.out_of_control = if matches!(b.status, Status::UnderWater | Status::UnderFlowingWater) {
b.out_of_control + 1.0
} else {
0.0
};
b.tick_submersion();
b.hurt_time = (b.hurt_time - 1).max(0);
b.damage = (b.damage - 1.0).max(0.0);
if !controlled {
@@ -751,8 +758,13 @@ impl Game<'_> {
if !ticking(p.x.floor() as i32, p.z.floor() as i32) {
continue;
}
// `AbstractBoat.tick` counts submersion on the server even when
// its controlling client owns the boat's movement.
if driven.contains(&self.boats[i].id) {
self.refresh_boat_status(i);
}
if self.boats[i].out_of_control >= 60.0 {
self.eject_boat_mobs(i, false);
self.eject_boat_riders(i);
}
self.boat_bubble_tick(i);
self.boat_contacts(i);
@@ -776,6 +788,7 @@ impl Game<'_> {
let mut probe = Boat::new(b.id, b.wood.clone(), b.position, b.yaw);
let status = self.boat_status(&mut probe);
self.boats[i].status = status;
self.boats[i].tick_submersion();
}
/// `BubbleColumnBlock.entityInside`: the bubble columns boat `i` touches
/// (its box deflated by 1e-5 as `Entity.checkInsideBlocks` does), in x, y,
@@ -882,8 +895,8 @@ impl Game<'_> {
v.y = 0.6;
}
}
/// `Entity.ejectPassengers`: for a down column the mobs get out here, this
/// window's player gets out, and a server's player is asked to
/// `Entity.ejectPassengers`: the mobs get out here, this window's player
/// gets out, and a server's player is asked to
/// (`guest_vehicle_tick` lets it out through its client).
fn eject_boat_riders(&mut self, i: usize) {
self.eject_boat_mobs(i, false);
@@ -918,10 +931,6 @@ impl Game<'_> {
/// window is its own client.
fn boat_bubble_tick(&mut self, i: usize) {
let id = self.boats[i].id;
// The boat a server's player drives is not ticked here: its status is.
if self.guest_vehicles().contains(&id) {
self.refresh_boat_status(i);
}
// An ejection nobody took (its player left the boat first) is void.
if !self.guest_rides(id) {
self.boats[i].eject_riders = false;
+6 -3
View File
@@ -1092,9 +1092,12 @@ impl Game<'_> {
if self.ui.hide_gui && !focused {
return;
}
self.ui
.chat
.draw(batch, &self.gui.atlas, self.ticks, focused);
self.ui.chat.smooth = self.settings.extras.chat_animation;
self.ui.chat.hide_indicator = !self.settings.extras.chat_indicator;
// The fractional tick makes the smooth chat extra's slide move every
// frame; vanilla's fade reads it too.
let now = self.ticks as f32 + self.clock.fraction() as f32;
self.ui.chat.draw(batch, &self.gui.atlas, now, focused);
}
/// `HoverEvent.EntityTooltipInfo.getTooltipLines`: the name when the
/// event has one, the entity type and the UUID.
+7 -1
View File
@@ -183,6 +183,10 @@ impl Game<'_> {
self.mod_browser_text(None, true);
return Ok(());
}
Screen::Social => {
self.social_backspace();
return Ok(());
}
_ => {}
}
}
@@ -205,6 +209,7 @@ impl Game<'_> {
match self.screen {
Screen::Chat => self.chat_type(text),
Screen::CommandBlock => self.command_block_type(text),
Screen::Social => self.social_type(text),
Screen::BlockEditor => self.block_editor_type(text),
Screen::DebugOptions => self.debug_options_type(text),
Screen::Options => self.ui.options.type_text(&self.gui.atlas, text),
@@ -1052,6 +1057,7 @@ impl Game<'_> {
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::Social => "social",
Screen::ServerLinks => "server-links",
Screen::ConfirmLink => "confirm-link",
Screen::DialogWarning => "dialog-warning",
@@ -1172,7 +1178,7 @@ impl Game<'_> {
"distance": self.settings.horizon.lod_distance,
"effective_distance": self.lod_distance(),
"terrain_distance": self.terrain_distance(),
"source": if self.lod.is_none() { "none" } else if self.integrated.is_some() { "integrated" } else { "local" },
"source": if self.lod.is_none() { "none" } else if self.integrated.is_some() { "integrated" } else if self.server_far_terrain().is_some() { "server" } else { "local" },
"source_attempted": self.lod_source_attempted,
"active_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().0),
"cached_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().1),
+118 -13
View File
@@ -771,6 +771,7 @@ impl Game<'_> {
thundering: weather && thunder > 0.9,
nether: self.dim == Dim::Nether,
block_tags: &self.items().block_tags,
landing: &self.motion.landing,
biomes: world_biomes!(self),
mob_griefing: self.ui.rules.bool("mob_griefing"),
universal_anger: self.ui.rules.bool("universal_anger"),
@@ -1774,6 +1775,40 @@ impl Game<'_> {
self.mob_hits_player(amount, from, kind);
}
MobEvent::ElderCurse { at } => self.elder_curse(at),
MobEvent::SetVelocity { target, velocity } => {
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
match target {
Who::Player => {
if let Some(p) = self.player.as_mut() {
p.velocity = v;
}
}
Who::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.velocity = v;
}
}
}
}
MobEvent::RemoveKilled { target } => {
// The bite that killed it ran `LivingEntity.die` (loot, such
// as a magma cube's froglight, and experience) before
// `eatEntity` removes it: a removed mob never reaches the
// next tick's deaths, so it dies here first.
if let Some(m) = self
.mobs
.iter()
.find(|m| m.id == target && !m.alive() && !m.removed)
.cloned()
{
if self.died.insert(target) {
self.mob_died(&m);
}
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == target) {
m.removed = true;
}
}
}
MobEvent::Push { target, velocity } => {
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
match target {
@@ -5730,13 +5765,29 @@ impl Game<'_> {
);
let (species, variant, color) =
(parent.species(), parent.extra.variant, parent.extra.color);
let mob_type = parent.kind.clone();
let target = self.mob_entity(&self.mobs[index]);
let Some(id) = self.spawn_mob_spawned(&kind, at) else {
return false;
let id = if species == Species::Piglin {
// SpawnEggItem.spawnOffspringFromSpawnEgg: a non-AgeableMob is
// constructed, then setBaby(true), without finalizeSpawn. Giving
// it adult spawn gear first leaves the baby holding a weapon.
self.next_entity += 1;
let id = self.next_entity;
self.mobs
.push(Mob::new(id, mob_type, Vec3::new(at[0], at[1], at[2]), 0.0));
id
} else {
let Some(id) = self.spawn_mob_spawned(&kind, at) else {
return false;
};
id
};
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
}
if species == Species::Piglin {
self.native_mobs_joined(self.mobs.len() - 1, false);
}
// `Mob.checkAndHandleImportantInteractions` then calls
// `onOffspringSpawnedFromEgg`: `Fox`'s makes the baby trust the player
// who used the egg (a fox egg used on a block trusts nobody).
@@ -5750,17 +5801,49 @@ impl Game<'_> {
self.consume_held();
true
}
/// `SpawnEggItem.use`: the SOURCE_ONLY ray must hit a liquid block.
pub(super) fn spawn_egg_fluid_hit(&self) -> Option<super::Hit> {
let hit = self.bucket_hit()?;
matches!(
&*self.block_name(hit.pos),
"minecraft:water" | "minecraft:lava"
)
.then_some(hit)
}
/// Shared peaceful-mode eligibility for spawn eggs.
pub(super) fn spawn_egg_allowed(&self, item: &str) -> bool {
let kind = item.trim_end_matches("_spawn_egg");
self.ui.difficulty != 0
|| self
.res
.mob_types()
.get(kind)
.is_none_or(|t| horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species))
}
/// `SpawnEggItem.useOn` for a block, or `use` for a fluid source.
fn use_spawn_egg(&mut self, stack: &ItemStack) -> Result<bool> {
let Some(hit) = self.pick() else {
let fluid = matches!(self.guest_use, Some(super::GuestUse::Item(_)));
let Some(hit) = (if fluid {
self.spawn_egg_fluid_hit()
} else {
self.pick()
}) else {
return Ok(false);
};
let kind = stack.item.trim_end_matches("_spawn_egg").to_owned();
// EntityType.canSpawn: no monsters in peaceful (FAIL ends the click).
let id = format!("minecraft:{}", kind.trim_start_matches("minecraft:"));
if self.ui.difficulty == 0
&& let Some(t) = self.res.mob_types().get(&id)
&& !horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species)
if !self.spawn_egg_allowed(&stack.item) {
self.use_failed = true;
return Ok(true);
}
if fluid
&& !self.may_build()
&& !self.can_place_on_in_adventure([
hit.pos[0] - hit.face[0],
hit.pos[1] - hit.face[1],
hit.pos[2] - hit.face[2],
])
{
self.use_failed = true;
return Ok(true);
}
// SpawnEggItem.useOn on a spawner sets its mob (and an op's egg data).
@@ -5776,11 +5859,15 @@ impl Game<'_> {
}
return Ok(true);
}
let p = [
hit.pos[0] + hit.face[0],
hit.pos[1] + hit.face[1],
hit.pos[2] + hit.face[2],
];
let p = if fluid {
hit.pos
} else {
[
hit.pos[0] + hit.face[0],
hit.pos[1] + hit.face[1],
hit.pos[2] + hit.face[2],
]
};
let at = [
f64::from(p[0]) + 0.5,
f64::from(p[1]),
@@ -5795,6 +5882,8 @@ impl Game<'_> {
horizon_client::entity_nbt::apply_item_components(m, stack);
}
self.apply_spawn_egg_entity_data(id, stack);
self.post_block_game_event("minecraft:entity_place", p, Some(Who::Player), None);
self.stats.add("used", &stack.item, 1);
self.swing_arm();
if !self.creative
&& let Some(s) = self.held_mut().as_mut()
@@ -5909,6 +5998,21 @@ 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);
// 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,
day_time: self.world.day_time as f64 + alpha,
dimension: match self.dim {
Dim::Overworld => 0.0,
Dim::Nether => -1.0,
Dim::End => 1.0,
},
player: self.player.as_ref().map_or(camera.to_array(), |p| {
[p.position.x, p.position.y, p.position.z]
}),
player_rot: [self.pitch, self.yaw],
..Default::default()
});
for mob in &self.mobs {
let entity_start = out.len();
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
@@ -6793,6 +6897,7 @@ mod pearl_tests {
thundering: false,
nether: false,
block_tags: &items.block_tags,
landing: &motion.landing,
biomes: &biomes,
mob_griefing: true,
universal_anger: false,
+106 -10
View File
@@ -990,6 +990,8 @@ impl Game<'_> {
let pause_after = props.pause_when_empty_seconds * 20;
let idle_timeout = props.player_idle_timeout;
let mut next = Instant::now();
// The panic that ended the tick loop, when one did.
let mut crashed: Option<String> = None;
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();
@@ -998,10 +1000,18 @@ impl Game<'_> {
if let Some(watchdog) = &watchdog {
watchdog.tick_due(next, self.clock.tick_duration());
}
while let Ok(line) = state.console.try_recv() {
self.console_line(&mut state, &line);
// The console's lines and the control socket run in the tick too
// (`handleConsoleInputs`): a panic there is the same crash.
let prelude = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
while let Ok(line) = state.console.try_recv() {
self.console_line(&mut state, &line);
}
self.poll_server_control(&mut state);
}));
if let Err(payload) = prelude {
crashed = Some(panic_message(payload.as_ref()));
break;
}
self.poll_server_control(&mut state);
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
if pause_after > 0 {
// A `/pregen` task keeps the server busy as a player would:
@@ -1029,11 +1039,23 @@ impl Game<'_> {
self.ticks = self.ticks.max(1);
}
// MinecraftServer.tickServer: while paused only the connections run.
match &mut state.report {
Some(recording) => self.tick_server_spans(paused, |wall, cpu, spans| {
recording.add(wall, cpu, spans)
})?,
None => self.tick_server_timed(paused)?,
// `MinecraftServer.runServer` catches what the tick throws, writes the crash
// report and still stops the server (saves). A panic here does the same.
let ticked =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
match &mut state.report {
Some(recording) => self.tick_server_spans(paused, |wall, cpu, spans| {
recording.add(wall, cpu, spans)
}),
None => self.tick_server_timed(paused),
}
}));
match ticked {
Ok(result) => result?,
Err(payload) => {
crashed = Some(panic_message(payload.as_ref()));
break;
}
}
if state.report.as_ref().is_some_and(report::Recording::done)
&& let Some(recording) = state.report.take()
@@ -1067,7 +1089,10 @@ impl Game<'_> {
if let Some(watchdog) = &watchdog {
watchdog.running.store(false, Ordering::Relaxed);
}
self.stop_dedicated()
match crashed {
Some(message) => self.stop_after_crash(&message),
None => self.stop_dedicated(),
}
}
/// A dedicated server of its own, not single player's server thread
@@ -1237,6 +1262,7 @@ impl Game<'_> {
self.console_command(state, line);
self.console_broadcast = None;
if let Some(mut host) = self.lan.take() {
crash_fixture("hub", || host.hub.guests.iter().any(|g| g.placed));
self.run_level_teleports(&mut host);
self.send_admin_feedback(&mut host, None);
self.lan = Some(host);
@@ -1818,6 +1844,41 @@ impl Game<'_> {
}
}
/// `MinecraftServer.runServer`'s `finally`: a tick that panicked still saves. What the panic
/// interrupted is not trusted: a guest's turn (`as_guest`) holds the game's player state
/// swapped in, so its record is older than what the game holds, and writing it would put
/// back older data than the last autosave; that guest keeps the file it has. A hub that the
/// panic took with it (`self.lan.take()` was in progress) saves no players at all. The
/// levels, the scoreboard and the other state are saved as in a normal stop.
fn stop_after_crash(&mut self, message: &str) -> Result<()> {
log("ERROR", &format!("The server thread panicked: {message}"));
let acting = self.lan_acting;
let hub_lost = self.lan.is_none();
if let (Some(conn), Some(host)) = (acting, self.lan.as_mut())
&& let Some(guest) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
{
guest.placed = false;
}
if hub_lost {
log(
"WARN",
"The panic interrupted the network tick: players are not saved, only the world",
);
} else if acting.is_some() {
log(
"WARN",
"The panic interrupted a player's turn: that player keeps the last saved file",
);
}
self.native_lifecycle.begin_close();
log("INFO", "Saving worlds");
self.save_game()?;
self.log_saved_levels();
self.native_lifecycle_world_closed();
self.lan_close(false);
Err(format!("the server thread panicked: {message}").into())
}
fn stop_dedicated(&mut self) -> Result<()> {
// The close request starts before player and world saves.
self.native_lifecycle.begin_close();
@@ -1855,6 +1916,28 @@ impl Game<'_> {
}
}
/// `tools/test_dedicated_crash_save.py`'s fixture: with `HORIZON_CRASH_FIXTURE` naming `point`
/// (`guest`: during a placed guest's turn, `hub`: while a console line has the hub out and a
/// player is in), the tick panics there, to see what `stop_after_crash` keeps.
/// `armed` is asked only when the variable names `point`, so the tick pays one
/// load of a `OnceLock` without it.
pub(super) fn crash_fixture(point: &str, armed: impl FnOnce() -> bool) {
static FIXTURE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
let fixture = FIXTURE.get_or_init(|| std::env::var("HORIZON_CRASH_FIXTURE").ok());
if fixture.as_deref() == Some(point) && armed() {
panic!("crash fixture at {point}");
}
}
/// The text of a panic payload (`panic!("..")` is a `&str`, a formatted one a `String`).
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<&str>()
.map(|s| (*s).to_owned())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic".to_owned())
}
/// The width and height of a PNG (its IHDR chunk).
fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
if bytes.len() < 24 || &bytes[..8] != b"\x89PNG\r\n\x1a\n" || &bytes[12..16] != b"IHDR" {
@@ -1867,7 +1950,7 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
#[cfg(test)]
mod tests {
use super::{capture_log, log, log_from};
use super::{capture_log, log, log_from, panic_message};
use std::{
fs,
io::{self, BufRead, BufReader, Read, Write},
@@ -1875,6 +1958,19 @@ mod tests {
time::{SystemTime, UNIX_EPOCH},
};
/// 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]
fn a_panic_payload_is_read_as_text() {
let literal = std::panic::catch_unwind(|| panic!("a literal")).unwrap_err();
assert_eq!(panic_message(literal.as_ref()), "a literal");
let n = 3;
let formatted = std::panic::catch_unwind(|| panic!("tick {n}")).unwrap_err();
assert_eq!(panic_message(formatted.as_ref()), "tick 3");
let other = std::panic::catch_unwind(|| std::panic::panic_any(7_u8)).unwrap_err();
assert_eq!(panic_message(other.as_ref()), "unknown panic");
}
const LOGIN: &str = "Player[/127.0.0.1:0] logged in with entity id 2 at (0.5, 115.0, 0.5)";
const WARNING: &str = "Authentication warning";
const READY: &str = "horizon-console-log-ready\n";
+101 -30
View File
@@ -790,40 +790,73 @@ impl Game<'_> {
return;
}
let riding = self.riding.is_some();
let mut disks = Vec::new();
for (stack, slot) in self.armor.iter().zip(["head", "chest", "legs", "feet"]) {
let Some(stack) = stack else { continue };
for (id, level) in stack.enchantments() {
let Some(def) = self.stations().enchantments.get(id) else {
continue;
};
if !def
.slots
.iter()
.any(|s| s == slot || s == "armor" || s == "any")
{
continue;
}
for effect in def.effects["minecraft:location_changed"]
.as_array()
.into_iter()
.flatten()
{
if effect["effect"]["type"] == "minecraft:replace_disk"
&& location_requirements(&effect["requirements"], on_ground, riding)
{
disks.push((effect["effect"].clone(), *level));
}
}
}
}
let disks = location_disks(
&self.stations().enchantments,
self.armor.iter().zip(["head", "chest", "legs", "feet"]),
on_ground,
riding,
);
for (effect, level) in disks {
self.replace_disk(&effect, level, at);
self.replace_disk(&effect, level, at, Who::Player);
}
}
/// `LivingEntity.onChangedBlock`, landing and `collectEquipmentChanges`
/// for an armor stand: run location effects before travel (including a
/// piston push), on landing, and for newly equipped items.
pub(super) fn armor_stand_location_effects(&mut self, index: usize, landed: bool) {
let stand = &mut self.armor_stands[index];
let block = BlockPos::containing(stand.position);
let moved = !landed && stand.enchantment_block.replace(block) != Some(block);
let changed: [bool; 6] =
std::array::from_fn(|i| stand.equipment[i] != stand.enchantment_equipment[i]);
if !moved && !landed && !changed.iter().any(|c| *c) {
return;
}
stand.enchantment_equipment.clone_from(&stand.equipment);
let stand = &self.armor_stands[index];
let at = [stand.position.x, stand.position.y, stand.position.z];
let who = Who::Mob(stand.id);
let equipment = stand
.equipment
.iter()
.zip(horizon_client::entities::ArmorStand::SLOTS)
.enumerate()
.filter(|(i, _)| moved || landed || changed[*i])
.filter(|(_, (stack, _))| {
moved
|| landed
|| !stack.as_ref().is_some_and(|stack| {
let data = stack.data();
let component = |id| data.and_then(|data| data.other(id));
let max_damage = match component("minecraft:max_damage") {
Some(horizon_world::nbt::Tag::Int(value)) => *value as u32,
_ => self
.res
.items()
.get(&stack.item)
.map_or(0, |i| i.max_damage),
};
max_damage > 0
&& component("minecraft:unbreakable").is_none()
&& component("!minecraft:max_damage").is_none()
&& component("!minecraft:damage").is_none()
&& stack.damage >= max_damage
})
})
.map(|(_, item)| item);
let disks = location_disks(
&self.stations().enchantments,
equipment,
stand.on_ground,
stand.vehicle.is_some(),
);
for (effect, level) in disks {
self.replace_disk(&effect, level, at, who);
}
}
/// `ReplaceDisk.apply`: the blocks of the disk around `at` that pass the
/// predicate become the effect's state, and each placement is a game event.
fn replace_disk(&mut self, effect: &Value, level: u32, at: [f64; 3]) {
fn replace_disk(&mut self, effect: &Value, level: u32, at: [f64; 3], who: Who) {
let registry = self.pack.assets.registry;
let spec = &effect["block_state"];
let Some(default) = spec["id"].as_str().and_then(|n| registry.default_state(n)) else {
@@ -861,7 +894,7 @@ impl Game<'_> {
self.post_block_game_event(
"minecraft:block_place",
pos,
Some(Who::Player),
Some(who),
Some(new),
);
}
@@ -966,6 +999,44 @@ impl Game<'_> {
}
}
/// `EnchantmentHelper.runLocationChangedEffects`: the replace-disk effects of
/// active equipment slots, with the wearer's ground/passenger requirements.
fn location_disks<'a>(
enchantments: &Enchantments,
equipment: impl IntoIterator<Item = (&'a Option<ItemStack>, &'static str)>,
on_ground: bool,
riding: bool,
) -> Vec<(Value, u32)> {
let mut disks = Vec::new();
for (stack, slot) in equipment {
let Some(stack) = stack else { continue };
for (id, level) in stack.enchantments() {
let Some(def) = enchantments.get(id) else {
continue;
};
let armor = matches!(slot, "head" | "chest" | "legs" | "feet");
let hand = matches!(slot, "mainhand" | "offhand");
if !def.slots.iter().any(|s| {
s == slot || s == "any" || (s == "armor" && armor) || (s == "hand" && hand)
}) {
continue;
}
for effect in def.effects["minecraft:location_changed"]
.as_array()
.into_iter()
.flatten()
{
if effect["effect"]["type"] == "minecraft:replace_disk"
&& location_requirements(&effect["requirements"], on_ground, riding)
{
disks.push((effect["effect"].clone(), *level));
}
}
}
}
disks
}
/// Soul Speed's `attribute` effects at `level` (attribute, id, amount, operation),
/// the id taking the boots slot like `EnchantmentAttributeEffect.idForSlot`.
fn soul_speed_modifiers(
@@ -25,7 +25,7 @@ impl Game<'_> {
let at = camera.to_array().map(|v| v.floor() as i32);
self.entity_culler = (self.settings.horizon.entity_culling
// Packs can replace an opaque block's model or texture.
&& self.settings.resource_packs.iter().all(|id| id == "vanilla")
&& self.model_packs_are_vanilla()
&& self.player.is_some()
&& !self.spectator()
&& !opaque(self.res.facts().get(self.world.block(at[0], at[1], at[2]))))
@@ -698,6 +698,74 @@ impl Game<'_> {
c.velocity.z + dir[2] * power,
);
}
// Arrows, fire charges, snowballs and the rest: `Entity.pushFromExplosion`
// (`push`) from their eyes (0.85 of their height). `AbstractArrow.push`
// does nothing while it is in the ground; `hurtServer` never hurts them.
let mut projectiles = std::mem::take(&mut self.creatures.projectiles);
for p in projectiles.iter_mut().filter(|p| !p.stuck) {
let (lo, hi) = (p.bounds().min_position(), p.bounds().max_position());
let dist = distance([p.position.x, p.position.y, p.position.z]) / diameter;
if dist > 1.0 {
continue;
}
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
let eye_height = p.kind.eye_height();
let dir = direction([p.position.x, p.position.y + eye_height, p.position.z]);
let power = (1.0 - dist) * f64::from(exposure);
p.velocity = Vec3::new(
p.velocity.x + dir[0] * power,
p.velocity.y + dir[1] * power,
p.velocity.z + dir[2] * power,
);
p.needs_sync = true;
}
self.creatures.projectiles = projectiles;
// Firework rockets (0.25 wide, so 0.2125 to the eyes).
let mut rockets = std::mem::take(&mut self.rockets);
for r in &mut rockets {
let feet = r.position;
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
if dist > 1.0 {
continue;
}
let exposure = self.seen_percent(
center,
[feet.x - 0.125, feet.y, feet.z - 0.125],
[feet.x + 0.125, feet.y + 0.25, feet.z + 0.125],
);
let dir = direction([feet.x, feet.y + 0.2125, feet.z]);
let power = (1.0 - dist) * f64::from(exposure);
r.velocity = Vec3::new(
r.velocity.x + dir[0] * power,
r.velocity.y + dir[1] * power,
r.velocity.z + dir[2] * power,
);
}
self.rockets = rockets;
// Thrown tridents live apart from the other projectiles (`game/trident.rs`): 0.5
// wide and high, eyes at 0.13 (`EntityType.TRIDENT`), and like any arrow
// (`AbstractArrow.push`) not pushed while in the ground.
let mut tridents = std::mem::take(&mut self.tridents);
for t in tridents.iter_mut().filter(|t| !t.in_ground) {
let feet = t.position;
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
if dist > 1.0 {
continue;
}
let exposure = self.seen_percent(
center,
[feet.x - 0.25, feet.y, feet.z - 0.25],
[feet.x + 0.25, feet.y + 0.5, feet.z + 0.25],
);
let dir = direction([feet.x, feet.y + f64::from(0.13_f32), feet.z]);
let power = (1.0 - dist) * f64::from(exposure);
t.velocity = Vec3::new(
t.velocity.x + dir[0] * power,
t.velocity.y + dir[1] * power,
t.velocity.z + dir[2] * power,
);
}
self.tridents = tridents;
// Frames, paintings, cushions and armour stands last: vanilla lists
// the entities in reach before hurting any, so the items they drop
// here are neither destroyed nor pushed by the same blast.
+1
View File
@@ -62,6 +62,7 @@ impl Game<'_> {
let now = self.extras_now();
self.extras.fps.record(now, elapsed.as_secs_f32() * 1000.0);
self.native_frame(elapsed);
self.recipes_received_now();
}
/// The frame counter for mods: fps, min, 1% low.
pub(super) fn frame_reading(&self) -> (u32, u32, u32) {
+12
View File
@@ -580,6 +580,12 @@ impl Game<'_> {
.map(|i| i.id)
});
if let Some(id) = hit {
// Projectile.onHit on an entity: PROJECTILE_LAND.
self.projectile_event(
"minecraft:projectile_land",
Some(horizon_client::mobs::Who::Player),
[h.position.x, h.position.y, h.position.z],
);
h.state = State::Hooked(id);
h.velocity = Vec3::ZERO;
self.hook = Some(h);
@@ -605,6 +611,12 @@ impl Game<'_> {
shape.clip(h.position, to, p)
});
if let Some(hit) = hit.ok().flatten() {
// Projectile.onHit on a block: PROJECTILE_LAND.
self.projectile_event(
"minecraft:projectile_land",
Some(horizon_client::mobs::Who::Player),
[hit.location.x, hit.location.y, hit.location.z],
);
h.velocity = rebound(h.velocity, hit.location, h.position);
}
}
+19
View File
@@ -35,6 +35,25 @@ impl Game<'_> {
)
.collect()
}
/// `EntityGetter.getEntityCollisions` for a dropped item: the entities whose
/// `canBeCollidedWith(item)` is true. A boat and a minecart always, a living shulker,
/// and a happy ghast only while it is held still (`isOnStillTimeout`): a player on its
/// back keeps it still, so an item dropped there stays on top.
pub(super) fn item_entity_collisions(&self) -> Vec<Aabb> {
self.mobs
.iter()
.filter(|m| {
(m.species() == horizon_client::mobs::Species::HappyGhast
&& m.alive()
&& !m.is_baby()
&& happy_ghast::on_still_timeout(m))
|| (m.species() == horizon_client::mobs::Species::Shulker && m.alive())
})
.map(|m| m.bounds())
.chain(self.boats.iter().map(super::boats::Boat::bounds))
.chain(self.carts.iter().map(|c| c.bounds()))
.collect()
}
/// `HappyGhast.mobInteract`. Returns whether the click was used.
pub(super) fn interact_happy_ghast(&mut self, index: usize, item: &str) -> bool {
let m = &self.mobs[index];
+2 -1
View File
@@ -21,7 +21,7 @@ impl Game<'_> {
return true;
}
match self.screen {
Screen::Chat | Screen::CommandBlock => true,
Screen::Chat | Screen::CommandBlock | Screen::Social => true,
Screen::DebugOptions => self
.debug_options
.as_ref()
@@ -117,6 +117,7 @@ impl Game<'_> {
match self.screen {
Screen::Chat => self.chat_type(&text),
Screen::CommandBlock => self.command_block_type(&text),
Screen::Social => self.social_type(&text),
Screen::DebugOptions => self.debug_options_type(&text),
Screen::WorldOptions => self.world_options_text(Some(&text)),
Screen::Creative => self.creative_search_edit(Some(&text)),
@@ -32,8 +32,37 @@ use horizon_protocol::{
text::literal,
};
use horizon_world::entity_move::EntityMoveWorld;
use std::collections::HashMap;
use winit::keyboard::KeyCode;
/// Sets the viewer counts `known` holds for `dim` to `now` and returns the
/// changes as `(pos, previous, viewers)`, in position order. A container that
/// is no longer counted goes to 0 and leaves `known`.
pub(super) fn viewer_changes(
known: &mut HashMap<(&'static str, (i32, i32, i32)), u32>,
dim: &'static str,
mut now: HashMap<(i32, i32, i32), u32>,
) -> Vec<((i32, i32, i32), u32, u32)> {
let mut changes = Vec::new();
known.retain(|&(d, pos), previous| {
if d != dim {
return true;
}
let viewers = now.remove(&pos).unwrap_or(0);
if viewers != *previous {
changes.push((pos, *previous, viewers));
*previous = viewers;
}
viewers > 0
});
for (pos, viewers) in now {
known.insert((dim, pos), viewers);
changes.push((pos, 0, viewers));
}
changes.sort_unstable();
changes
}
/// Per-guest state of the gameplay code that lives outside `Guest`.
#[derive(Default)]
pub(super) struct GuestPlay {
@@ -297,6 +326,9 @@ impl Game<'_> {
f: impl FnOnce(&mut Self) -> R,
) -> Option<R> {
let swap = self.enter_guest(host, conn)?;
super::dedicated::crash_fixture("guest", || {
host.hub.guests.iter().any(|g| g.conn == conn && g.placed)
});
let dimension =
(self.serving.is_some() || self.options.dedicated.is_some()).then_some(self.dim.id());
let result = horizon_client::block_contact::in_level(dimension, || f(self));
@@ -1363,6 +1395,27 @@ impl Game<'_> {
}
}
/// `ContainerOpenersCounter` for the mod containers of the level in place:
/// the players with each one open, recounted from their menus, so a
/// change is heard whichever way a player left (`native_mod_container_viewers`).
fn tick_mod_container_viewers(&mut self, host: &LanHost) {
let mut now: HashMap<(i32, i32, i32), u32> = HashMap::new();
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
// `BarrelBlockEntity.startOpen`: a spectator does not open it.
if !g.spectator()
&& let Some(m) = &g.menu
&& let Slots::Block(pos) = m.slots
&& self.mod_menu_at(pos).is_some()
{
*now.entry(pos).or_default() += 1;
}
}
let dim = self.dim.id();
for (pos, previous, viewers) in viewer_changes(&mut self.mod_container_viewers, dim, now) {
self.native_mod_container_viewers(dim, [pos.0, pos.1, pos.2], viewers, previous);
}
}
/// `stillValid` of the guest's open container: its blocks are still there
/// and in reach, or its vehicle exists and is in reach. True with none open.
fn container_menu_valid(&self, g: &horizon_client::lan::guest::Guest) -> bool {
@@ -1869,6 +1922,23 @@ impl Game<'_> {
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
let g = &mut host.hub.guests[gi];
(g.yaw, g.pitch) = (p.yaw, p.pitch);
// SpawnEggItem.use calls ServerLevel.mayInteract at its own
// fluid ray's position, unlike useOn's packet-supplied block.
let held = if hand == Hand::Off {
g.offhand.as_ref()
} else {
g.inventory.get(g.selected).and_then(Option::as_ref)
};
if held.is_some_and(|s| s.item.ends_with("_spawn_egg")) {
let hit = self
.as_guest(host, conn, |g| {
g.with_hand(hand, |g| g.spawn_egg_fluid_hit())
})
.flatten();
if hit.is_some_and(|h| self.under_spawn_protection(host, gi, h.pos)) {
return;
}
}
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
let used = self.as_guest(host, conn, |g| {
// A horn's sound, inline instruments included.
@@ -3235,6 +3305,7 @@ impl Game<'_> {
self.lan_stations_tick(host);
self.lan_tables_tick(host);
self.lan_containers_tick(host);
self.tick_mod_container_viewers(host);
self.tick_lan_mount_menus(host);
self.announce_guest_advancements(host);
let conns: Vec<u32> = host
@@ -133,8 +133,9 @@ impl Game<'_> {
let b = &mut self.boats[i];
b.hurt_time = (b.hurt_time - 1).max(0);
b.damage = (b.damage - 1.0).max(0.0);
// AbstractBoat.tickBubbleColumn: a down column ejects the rider.
ejected = std::mem::take(&mut b.eject_riders);
// `Entity.ejectPassengers`: every player in the boat must see
// the request; the common boat tick clears it once nobody rides.
ejected = b.eject_riders;
}
// ServerGamePacketListenerImpl.tick: the vehicle's last good position
// is where it is now, so a move the server made (a piston, a push)
+2
View File
@@ -549,6 +549,8 @@ impl Game<'_> {
.is_some_and(|p| !p.streaming.forced.is_empty())
};
guests.iter().any(|g| g.dimension == d.id())
// A viewer count not yet told to 0 (`tick_mod_container_viewers`).
|| self.mod_container_viewers.keys().any(|(k, _)| *k == d.id())
|| self.player_bed_levels().any(|b| b == *d)
|| forced
// A game test runs in its own level, players or not.
+67 -12
View File
@@ -7,7 +7,11 @@
use super::{Game, commands::Target};
use horizon_client::advancements::EntityView;
use horizon_client::mobs::{Damage, DamageKind, MobEvent, SpawnSetup, Species};
use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_core::{
geometry::{Aabb, Vec3},
position::BlockPos,
random::RandomSource,
};
pub(super) struct Bolt {
/// The entity id a server's players know it by (0 until it has one).
@@ -452,10 +456,26 @@ impl Game<'_> {
}
});
}
/// `thunderHit` for entities within 3 blocks (up to 9 above). Returns the
/// mobs in that box before the hit, as `CHANNELED_LIGHTNING` counts them.
/// `thunderHit` for entities whose box meets the strike box, 3 blocks
/// around and up to 9 above (`getEntities(this, AABB)` tests boxes, not
/// positions). Returns the mobs in that box before the hit, as
/// `CHANNELED_LIGHTNING` counts them. Boats, minecarts and experience
/// orbs are hit too (`VehicleEntity.hurtServer` adds 5 x 10 > 40 and
/// destroys them; an orb has 5 health).
fn thunder_hit(&mut self, b: &mut Bolt) -> Vec<EntityView> {
let at = b.at;
let strike = Aabb::new(
Vec3::new(at[0] - 3.0, at[1] - 3.0, at[2] - 3.0),
Vec3::new(at[0] + 3.0, at[1] + 9.0, at[2] + 3.0),
);
// Whether the box of an entity standing at `p` (half width `w`,
// height `h`) meets the strike box.
let meets = move |p: [f64; 3], w: f64, h: f64| {
strike.intersects(Aabb::new(
Vec3::new(p[0] - w, p[1], p[2] - w),
Vec3::new(p[0] + w, p[1] + h, p[2] + w),
))
};
let inside = move |p: [f64; 3]| {
(p[0] - at[0]).abs() <= 3.0
&& (p[2] - at[2]).abs() <= 3.0
@@ -465,7 +485,14 @@ impl Game<'_> {
let struck: Vec<(u32, EntityView)> = self
.mobs
.iter()
.filter(|m| m.alive() && inside([m.position.x, m.position.y, m.position.z]))
.filter(|m| {
m.alive()
&& meets(
[m.position.x, m.position.y, m.position.z],
m.kind.width / 2.0,
m.kind.height,
)
})
.map(|m| (m.id, self.mob_entity(m)))
.collect();
b.hit.extend(struck.iter().map(|(id, _)| *id));
@@ -477,7 +504,11 @@ impl Game<'_> {
let mut events = Vec::new();
let peaceful = self.ui.difficulty == 0;
for m in self.mobs.iter_mut().filter(|m| m.alive()) {
if !inside([m.position.x, m.position.y, m.position.z]) {
if !meets(
[m.position.x, m.position.y, m.position.z],
m.kind.width / 2.0,
m.kind.height,
) {
continue;
}
let setup = |m: &horizon_client::mobs::Mob| SpawnSetup {
@@ -554,10 +585,34 @@ impl Game<'_> {
b.spared.extend(self.dropped[before..].iter().map(|e| e.id));
}
}
// Every player in the box, the server's players too.
let strike = move |g: &mut Self| {
// Boats, then minecarts: `VehicleEntity.hurtServer` for 5 damage
// (x 10 over the 40 a vehicle takes) destroys them. Lightning is no
// fire source, so a TNT cart does not prime.
let mut i = self.boats.len();
while i > 0 {
i -= 1;
let bb = self.boats[i].bounds();
if strike.intersects(bb) {
self.hurt_boat(i, 5.0, false);
}
}
let mut i = self.carts.len();
while i > 0 {
i -= 1;
let bb = self.carts[i].bounds();
if strike.intersects(bb) {
self.hurt_cart(i, 5.0, false, false);
}
}
// `ExperienceOrb.hurtServer`: 5 damage off 5 health.
let orbs_before = self.orb_ids();
self.orbs
.retain_mut(|o| !(strike.intersects(o.bounds()) && o.hurt(5.0)));
self.native_orbs_left_since(&orbs_before);
// Every player in the box, the server's players too (0.6 x 1.8).
let strike_players = move |g: &mut Self| {
if let Some(p) = g.player.as_ref().map(|p| p.position)
&& inside([p.x, p.y, p.z])
&& meets([p.x, p.y, p.z], 0.3, 1.8)
{
if let Some(p) = g.player.as_mut() {
p.fire_ticks += 1;
@@ -570,18 +625,18 @@ impl Game<'_> {
g.hurt_player_by("minecraft:lightning_bolt", 5.0);
}
};
strike(self);
self.each_guest_hit(strike);
strike_players(self);
self.each_guest_hit(strike_players);
b.hit_items.extend(
self.dropped
.iter()
.filter(|e| inside([e.position.x, e.position.y, e.position.z]))
.filter(|e| meets([e.position.x, e.position.y, e.position.z], 0.125, 0.25))
.map(|e| e.id),
);
// Dropped items burn up (ItemEntity health 5), save fire-resistant ones.
let before = self.dropped_ids();
self.dropped.retain(|e| {
!inside([e.position.x, e.position.y, e.position.z])
!meets([e.position.x, e.position.y, e.position.z], 0.125, 0.25)
|| e.stack.item.contains("netherite")
|| (e.age < 20 && b.spared.contains(&e.id))
});
+56 -1
View File
@@ -414,13 +414,68 @@ impl Game<'_> {
}
self.rail_pos(x, y, z)
}
/// `AbstractMinecart.canCollideWith`, for `Entity.collide`'s `getEntityCollisions`:
/// `AbstractBoat.canVehicleCollide` takes any other cart, boat, shulker or pushable mob and
/// player, except one riding this cart. A cart that runs into one has a horizontal
/// collision, which sets a TNT minecart off.
fn cart_entity_collisions(&self, c: &Minecart) -> Vec<Aabb> {
let near = c.bounds().inflate(Vec3::new(2.0, 2.0, 2.0));
let mut boxes: Vec<Aabb> = self
.carts
.iter()
.filter(|o| o.id != c.id)
.map(Minecart::bounds)
.chain(self.boats.iter().map(super::boats::Boat::bounds))
.chain(
self.mobs
.iter()
.filter(|m| {
!m.removed
&& m.extra.mount != Some(c.id)
&& (m.pushable()
|| m.species() == horizon_client::mobs::Species::Shulker
&& m.alive())
})
.map(|m| m.bounds()),
)
.collect();
if self.riding != Some(c.id)
&& let Some(p) = self.player.as_ref()
&& !self.spectator()
{
boxes.push(p.bounds());
}
if let Some(host) = self.lan.as_ref() {
boxes.extend(
host.hub
.guests
.iter()
.filter(|g| {
host.hub.here(g)
&& g.loaded
&& !g.dead
&& !g.spectator()
&& g.vehicle != Some(c.id)
})
.map(|g| g.bounds()),
);
}
boxes.retain(|b| b.intersects(near));
boxes
}
/// Entity.move for a cart: collisions zero the blocked components.
fn move_cart(&self, c: &mut Minecart, delta: Vec3) {
let mut scene = SceneCollisions {
let scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
let entities = self.cart_entity_collisions(c);
let mut scene = horizon_client::collisions::EntityCollisions {
blocks: scene,
entities: &entities,
border: None,
};
if let Ok(moved) = scene.resolve(c.bounds(), delta, 0.0, c.on_ground) {
c.position = Vec3::new(
c.position.x + moved.x,
+15
View File
@@ -185,6 +185,12 @@ fn register_menu_types(mods: &[horizon_client::mod_content::ContentMod]) {
/// What the launcher writes beside a game it built with native mods: their ids under `mods`.
const BUILD_INFO: &str = "native-mods.json";
/// Whether the launcher built this game with native mods (`built`: its [`BUILD_INFO`]).
fn built_with_mods(built: &str) -> bool {
let info: serde_json::Value = serde_json::from_str(built).unwrap_or_default();
info["mods"].as_array().is_some_and(|mods| !mods.is_empty())
}
/// A row for each native mod the launcher installed in `dir` (`<id>.hnmod`) that this game wasn't
/// built with (`built`: its [`BUILD_INFO`], empty for a release or Nightly) and that has no row
/// yet. The page said "No mods" before, and players copied the files into `horizon-mods` (#4770).
@@ -272,6 +278,15 @@ impl Game<'_> {
.ok()
.and_then(|exe| std::fs::read_to_string(exe.with_file_name(BUILD_INFO)).ok())
.unwrap_or_default();
horizon_client::gfx::native_render::set_mods_built_in(
built_with_mods(&built)
|| self
.settings
.mods
.installed
.iter()
.any(|m| m.native.is_some()),
);
let native_dir = Self::game_dir().join(mod_list::NATIVE_DIR);
let unbuilt = not_built(&native_dir, &built, &self.settings.mods.installed);
self.settings.mods.installed.extend(unbuilt);
+6 -1
View File
@@ -14,7 +14,12 @@ impl Game<'_> {
if self.options.split_guest {
return;
}
let (music, volume) = self.situational_music();
let (mut music, mut volume) = self.situational_music();
// A native mod's own choice of track and volume, then its layers.
self.native_music(&mut music, &mut volume);
let mut layers = Vec::new();
self.native_music_layers(&mut layers);
self.music_layers.tick(&layers, &mut self.audio);
// Options.musicFrequency's listener: MusicManager.setMinutesBetweenSongs.
let frequency = MusicFrequency::parse(&self.settings.file.name("musicFrequency"));
if frequency != self.music.frequency {
+1 -1
View File
@@ -260,7 +260,7 @@ impl Game<'_> {
Screen::Chat if self.title.is_none() => true,
Screen::Options => self.ui.options.typing(),
Screen::WorldOptions => self.world_options_typing(),
Screen::CommandBlock => true,
Screen::CommandBlock | Screen::Social => true,
Screen::Inventory => self.station_typing() || self.mod_browser_typing(),
_ => self
.title
@@ -0,0 +1,346 @@
//! A client mod's view and its own items (docs/native-mods.md, "A client mod's view and items"): the
//! first block along the view, across the distant terrain past the loaded chunks, and items whose
//! right-click is the mod's alone, heard as use events the mod polls.
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,
/// The inventory slot: 0 to 8, the hotbar (a mod use is the main hand's).
pub(crate) slot: u32,
/// `minecraft:custom_name` as SNBT, when the stack has one.
pub(crate) custom_name: Option<String>,
/// `minecraft:custom_data` as SNBT, when the stack has some.
pub(crate) 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,
/// Where the ray meets the block.
pub(crate) x: f64,
pub(crate) y: f64,
pub(crate) z: f64,
/// The block's id (`minecraft:stone`).
pub(crate) block: String,
}
/// What happened to a mod's claimed item, oldest first.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) 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
/// ends when the button comes up, the hand's item changes or leaves the
/// claim, the player respawns, or the game joins a server.
UseStop(NativeHeldItem, u32),
}
/// The farthest a ray along the view reaches, in blocks.
pub(crate) const RAYCAST_MAX: f32 = 1024.0;
/// The ids a mod may claim.
const CLAIMS_MAX: usize = 64;
/// Events kept for a mod between two polls.
const EVENTS_MAX: usize = 256;
/// A claimed item's use under way.
struct ModUse {
mod_id: String,
item: NativeHeldItem,
/// The player's `using` while it is this use: its slot and ticks.
slot: usize,
ticks: u32,
}
/// The items client mods claim, their events waiting for a poll, and the use under way.
#[derive(Default)]
pub(super) struct ModInput {
claims: BTreeMap<String, Vec<String>>,
events: BTreeMap<String, Vec<NativeModEvent>>,
using: Option<ModUse>,
}
impl ModInput {
fn claim(&mut self, mod_id: &str, ids: &[String]) {
let ids = ids.iter().take(CLAIMS_MAX).cloned().collect();
self.claims.insert(mod_id.to_owned(), ids);
}
/// The mod that claims `item`, the first in id order.
pub(super) fn claimant(&self, item: &str) -> Option<&str> {
self.claims
.iter()
.find(|(_, ids)| ids.iter().any(|i| i == item))
.map(|(id, _)| id.as_str())
}
fn push(&mut self, mod_id: &str, event: NativeModEvent) {
let events = self.events.entry(mod_id.to_owned()).or_default();
if events.len() < EVENTS_MAX {
events.push(event);
}
}
fn poll(&mut self, mod_id: &str) -> Vec<NativeModEvent> {
self.events.remove(mod_id).unwrap_or_default()
}
/// Mod `mod_id`'s use of `item` in `slot` begins, ending any other first.
fn start(&mut self, mod_id: &str, item: NativeHeldItem, slot: usize) {
self.end();
self.push(mod_id, NativeModEvent::UseStart(item.clone(), 0));
self.using = Some(ModUse {
mod_id: mod_id.to_owned(),
item,
slot,
ticks: 0,
});
}
/// Whether the player's use (`Game.using`) is the mod use under way. A
/// use the game reset and another item's begun since are not.
fn owns(&self, using: Option<(usize, u32)>) -> bool {
self.using
.as_ref()
.is_some_and(|u| using == Some((u.slot, u.ticks)))
}
fn tick(&mut self) {
if let Some(u) = &mut self.using {
u.ticks += 1;
}
}
/// The use under way ends: its mod hears `UseStop`.
fn end(&mut self) {
if let Some(u) = self.using.take() {
self.push(&u.mod_id, NativeModEvent::UseStop(u.item, u.ticks));
}
}
}
#[allow(dead_code)]
impl Game<'_> {
/// The stack in inventory slot `slot` as a mod's events carry it.
fn mod_held(&self, slot: usize) -> Option<NativeHeldItem> {
let stack = self.stack_in(slot)?;
let extra = stack.extra.as_deref();
Some(NativeHeldItem {
id: stack.item.to_string(),
count: stack.count,
slot: slot as u32,
custom_name: extra
.and_then(|e| e.custom_name.as_ref())
.map(horizon_client::snbt::to_snbt),
custom_data: extra
.and_then(|e| e.other("minecraft:custom_data"))
.map(horizon_client::snbt::to_snbt),
})
}
/// The first block along the view within `distance` (at most 1024), past the loaded chunks over the distant terrain. API @API:native_claimed_items@.
pub(super) fn native_raycast(&self, distance: f32) -> Option<NativeHit> {
let (d, hit) = self.native_view_hit()?;
(d <= distance.clamp(0.0, RAYCAST_MAX)).then_some(hit)
}
/// The first block along the view within `RAYCAST_MAX`, and its distance. API @API:native_claimed_items@.
pub(super) fn native_view_hit(&self) -> Option<(f32, NativeHit)> {
self.player.as_ref()?;
let eye = self.eye_position()?;
let dir = super::view_vector(self.yaw, self.pitch);
let reach = f64::from(RAYCAST_MAX);
let (pos, point, state) = match self.clip_ray(eye, dir, reach) {
Some(hit) => (
hit.pos,
hit.point,
self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]),
),
None => self.lod_ray(eye, dir, reach)?,
};
let distance = (0..3)
.map(|i| (point[i] - eye[i]).powi(2))
.sum::<f64>()
.sqrt();
Some((
distance as f32,
NativeHit {
block_x: pos[0],
block_y: pos[1],
block_z: pos[2],
x: point[0],
y: point[1],
z: point[2],
block: self.res.facts().get(state).name.to_string(),
},
))
}
/// A ray over the distant terrain (`Lod::ground`) past the loaded chunks, a block at a time.
fn lod_ray(
&self,
eye: [f64; 3],
dir: [f64; 3],
reach: f64,
) -> Option<([i32; 3], [f64; 3], horizon_world::registry::BlockStateId)> {
let lod = self.lod.as_ref()?;
let mut t = 0.0;
while t <= reach {
let p = [
eye[0] + dir[0] * t,
eye[1] + dir[1] * t,
eye[2] + dir[2] * t,
];
let (x, z) = (p[0].floor() as i32, p[2].floor() as i32);
if !self.world.loaded(x, z)
&& let Some((height, state)) = lod.ground(x, z)
&& p[1] <= f64::from(height)
{
return Some(([x, height - 1, z], [p[0], f64::from(height), p[2]], state));
}
t += 1.0;
}
None
}
/// Right-clicks with these items (at most 64 ids) are mod `mod_id`'s; a later call replaces the list. API @API:native_claimed_items@.
pub(super) fn native_claim_items(&mut self, mod_id: &str, ids: &[String]) {
self.native_input.claim(mod_id, ids);
}
/// Mod `mod_id`'s events since its last poll (at most 256 wait). API @API:native_claimed_items@.
pub(super) fn native_poll(&mut self, mod_id: &str) -> Vec<NativeModEvent> {
self.native_input.poll(mod_id)
}
/// `use_hand`: a right-click with an item a mod claims starts its use for
/// that mod and nothing else happens. True when a mod took it.
pub(super) fn mod_use_start(&mut self) -> bool {
if self.guest_use.is_some() || self.using.is_some() {
return false;
}
// With no use, a mod use the game reset without telling it is over.
self.native_input.end();
let slot = self.held_slot();
let Some(item) = self.mod_held(slot) else {
return false;
};
let Some(id) = self.native_input.claimant(&item.id).map(str::to_owned) else {
return false;
};
self.native_input.start(&id, item, slot);
self.using = Some((slot, 0));
true
}
/// `tick_used_item` for a claimed item: counts the ticks held and ends
/// the use when the button comes up. True when the player's use is a
/// mod's; a mod use the game reset ends, and the player's own use goes on.
pub(super) fn mod_use_tick(&mut self, slot: usize, ticks: u32, released: bool) -> bool {
if !self.native_input.owns(Some((slot, ticks))) {
self.native_input.end();
return false;
}
let still_claimed = self.native_input.using.as_ref().is_some_and(|u| {
self.stack_in(slot)
.is_some_and(|s| self.native_input.claimant(&s.item) == Some(u.mod_id.as_str()))
});
if released || !still_claimed {
self.native_input.end();
self.using = None;
} else {
self.native_input.tick();
self.using = Some((slot, ticks + 1));
}
true
}
/// The player's use was reset (a respawn, a join, the hand's item
/// changed): a mod use under way ends and its mod hears `UseStop`.
pub(super) fn end_mod_use(&mut self) {
self.native_input.end();
}
}
#[cfg(test)]
mod input_tests {
use super::*;
fn stick(slot: u32) -> NativeHeldItem {
NativeHeldItem {
id: "minecraft:stick".into(),
count: 1,
slot,
custom_name: None,
custom_data: Some("{orbital:1b}".into()),
}
}
#[test]
fn items_are_claimed_by_the_first_mod_in_id_order() {
let mut input = ModInput::default();
let ids = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
input.claim("zeta", &ids(&["orbital:remote", "minecraft:stick"]));
input.claim("alpha", &ids(&["minecraft:stick"]));
assert_eq!(input.claimant("minecraft:stick"), Some("alpha"));
assert_eq!(input.claimant("orbital:remote"), Some("zeta"));
assert_eq!(input.claimant("minecraft:dirt"), None);
input.claim("alpha", &[]);
assert_eq!(input.claimant("minecraft:stick"), Some("zeta"));
let many: Vec<String> = (0..100).map(|i| format!("m:item{i}")).collect();
input.claim("alpha", &many);
assert_eq!(input.claimant("m:item63"), Some("alpha"));
assert_eq!(input.claimant("m:item64"), None, "64 at most");
}
#[test]
fn events_wait_for_their_mod_up_to_a_limit() {
let mut input = ModInput::default();
for _ in 0..EVENTS_MAX + 10 {
input.push("a", NativeModEvent::UseStart(stick(0), 0));
}
input.push("b", NativeModEvent::UseStop(stick(0), 3));
assert_eq!(input.poll("a").len(), EVENTS_MAX);
assert!(input.poll("a").is_empty());
assert_eq!(input.poll("b"), vec![NativeModEvent::UseStop(stick(0), 3)]);
}
#[test]
fn a_claimed_use_is_the_mods_while_it_runs() {
let mut input = ModInput::default();
input.start("orbital", stick(2), 2);
assert!(input.owns(Some((2, 0))));
input.tick();
input.tick();
assert!(input.owns(Some((2, 2))));
input.end();
assert!(!input.owns(Some((2, 2))));
assert_eq!(
input.poll("orbital"),
vec![
NativeModEvent::UseStart(stick(2), 0),
NativeModEvent::UseStop(stick(2), 2),
]
);
}
#[test]
fn an_interrupted_claimed_use_leaves_the_next_use_alone() {
// A respawn ends the use: the mod hears it stop, and a vanilla use
// begun next in the same slot is not taken for the mod's.
let mut input = ModInput::default();
input.start("orbital", stick(0), 0);
input.tick();
input.end();
assert!(!input.owns(Some((0, 0))));
assert_eq!(
input.poll("orbital").last(),
Some(&NativeModEvent::UseStop(stick(0), 1))
);
// A reset the mod use never heard of (`Game.using` cleared alone):
// the player's next use, from 0 ticks or in another slot, is still
// not the mod's, and ending it then tells the mod once.
input.start("orbital", stick(0), 0);
input.tick();
input.tick();
assert!(!input.owns(None));
assert!(!input.owns(Some((0, 0))), "a vanilla use from 0 ticks");
assert!(!input.owns(Some((1, 2))), "another slot");
input.end();
input.end();
let events = input.poll("orbital");
assert_eq!(events.len(), 2);
assert_eq!(events[1], NativeModEvent::UseStop(stick(0), 2));
}
}
+185 -11
View File
@@ -298,6 +298,7 @@ use horizon_client::{
keybinds,
mobs::{Damage, Who},
mod_list::{self, Installed, NativePage},
music::{Music, MusicLayer},
particles::{ParticleOptions, ParticleType},
text::Text,
};
@@ -1185,6 +1186,35 @@ impl Game<'_> {
) {
let _ = (context, phase, error_text);
}
/// The server is about to send its data to the players (NeoForge's
/// `OnDatapackSyncEvent`, its `sendRecipes`): push the recipe types
/// (`minecraft:smelting`, `mymod:crusher`) whose recipes your client half
/// needs. Horizon sends their final recipes — the world's data packs
/// merged, load conditions held — to every Horizon client when it joins
/// and again after each `/reload`; a vanilla client is sent nothing new.
/// Asked once per load, when the server's data tables are built, and the
/// same types go to every player: unlike NeoForge, which asks again for
/// each joining player, it can't differ between players. Push nothing and
/// nothing is sent; push nothing after a load that pushed some and the
/// clients go back to their own recipes. Server-side (the single
/// player's integrated server included). API @API:recipe_sync@.
pub(super) fn native_datapack_sync(&mut self, recipe_types: &mut Vec<String>) {
let _ = recipe_types;
}
/// The server's recipes of `recipe_types` arrived (NeoForge's
/// `RecipesReceivedEvent`): from now on the recipe helpers
/// (`native_recipes`, `native_recipes_of_type`) give the server's recipes
/// of those types in place of the window's own, so rebuild what you made
/// of them. It comes after a join, once the window has its player, and
/// after every `/reload`, on a server whose mods asked for them
/// (`native_datapack_sync`), single player included; with no types when
/// the server stopped sending them and the window is back on its own
/// recipes. A recipe the
/// window's own recipe book doesn't know has `book_id` -1. Client-side.
/// API @API:recipe_sync@.
pub(super) fn native_recipes_received(&mut self, recipe_types: &[String]) {
let _ = recipe_types;
}
/// The server's data tables are built (NeoForge's `TagsUpdatedEvent`
/// with `SERVER_DATA_LOAD`): the recipes, tags, loot and advancements of
/// the world's data packs, and the files mods added through
@@ -1323,6 +1353,12 @@ impl Game<'_> {
pub(super) fn native_key(&mut self, key: &str, pressed: bool) {
let _ = (key, pressed);
}
/// A real window key event after handling, including repeats and consumed shortcuts.
/// Receives the original physical/logical key, text, location, state and repeat flag.
/// Modifier bits use the screen hooks' GLFW encoding. Not cancellable.
pub(super) fn native_input_key(&mut self, event: &winit::event::KeyEvent, modifiers: u32) {
let _ = (event, modifiers);
}
/// Raw mouse button input on any screen, before dispatch; cancel skips dispatch and Post.
/// Button/modifiers use the same GLFW encoding as screen hooks. Synthetic clicks skip it.
pub(super) fn native_mouse_button_pre(
@@ -1342,6 +1378,20 @@ impl Game<'_> {
pub(super) fn native_wheel(&mut self, steps: f32, claimed: &mut bool) {
let _ = (steps, claimed);
}
/// Gameplay scrolling after whole steps accumulate, before hotbar/spectator handling.
/// `scroll` is scaled x/y, `accumulated` whole x/y steps, `buttons` left/middle/right,
/// and `cursor` the GUI position. Cancel skips gameplay scrolling; synthetic input skips it.
pub(super) fn native_mouse_scrolling(
&mut self,
scroll: [f64; 2],
accumulated: [i32; 2],
buttons: [bool; 3],
cursor: [f32; 2],
modifiers: u32,
canceled: &mut bool,
) {
let _ = (scroll, accumulated, buttons, cursor, modifiers, canceled);
}
/// A command typed or clicked in chat, without its slash, before the
/// server and Horizon's commands see it: set `ran` when the mod ran it.
pub(super) fn native_command(&mut self, command: &str, ran: &mut bool) {
@@ -1377,6 +1427,18 @@ impl Game<'_> {
) {
let _ = (partial, camera, fog_type, rgb);
}
/// Final world fog, after the computed color hook, ordinary calculations and Horizon's fog
/// option, before upload. Title panoramas skip it. Invalid color or distance values retain the
/// original whole fog.
pub(super) fn native_render_fog(
&self,
partial: f32,
camera: [f64; 3],
fog_type: horizon_client::fog::FogType,
fog: &mut horizon_client::fog::FogData,
) {
let _ = (partial, camera, fog_type, fog);
}
/// The camera shake native mods ask for in this frame, `partial` ticks
/// past the last tick (0 to 1): add your pitch, yaw and roll in degrees
/// and your offset in blocks, along the camera's right, up and back, to
@@ -1414,13 +1476,6 @@ impl Game<'_> {
) {
let _ = (partial, mirrored, entity, distance);
}
/// The values native mods hand their shader packs in this frame,
/// `partial` ticks past the last tick (0 to 1): a pack's WGSL reads
/// them as `frame.mods` (eight `vec4<f32>`). Write the slots your own
/// pack reads; a mod that bundles its pack as `native-<mod id>` is the
/// one that pack is for. Asked once a frame while a pack is on; a value
/// that isn't finite reads as 0 (`native_shader_params_now`). API
/// @API:native_shader_params@.
/// Change the colours of `biome` (`minecraft:plains`) on this client: the
/// grass, foliage (leaves, vines), dry foliage (leaf litter) and water
/// tints, 0xRRGGBB, as its biome file and the colormaps set them. Asked
@@ -1432,6 +1487,36 @@ impl Game<'_> {
pub(super) fn native_biome_colors(&self, biome: &str, colors: &mut BiomeColors) {
let _ = (biome, colors);
}
/// Choose this client's music, once a client tick: `music` and `volume`
/// hold what vanilla chose (`Minecraft.getSituationalMusic` and
/// `getMusicVolume`: the biome's track, the creative or underwater one, a
/// boss's, the menu's; None for silence). Set `music` to a track of your
/// own, a sound event of your content mod's `sounds.json` such as
/// `Music::new("mymod:music.spring", 1200, 6000, false)` (the delays in
/// ticks between songs, and whether it cuts off a different song playing),
/// None to keep vanilla's quiet, or change `volume` (0 to 1) to fade it. API
/// @API:music@.
pub(super) fn native_music(&self, music: &mut Option<Music>, volume: &mut f32) {
let _ = (music, volume);
}
/// The tracks to play over (or instead of) the music on this client,
/// asked once a client tick: push a `MusicLayer` (a sound event, looped,
/// and its volume, 0 to 1) for each. A layer eases to a new volume over up
/// to two seconds, and one left out fades out and stops; layers that
/// first appear in the same tick start together, so list every stem of a
/// piece from the start, the silent ones at volume 0, and raise them (a
/// fuller arrangement as a village grows). Their sound events should be
/// `stream: true` in `sounds.json`. API @API:music@.
pub(super) fn native_music_layers(&self, layers: &mut Vec<MusicLayer>) {
let _ = layers;
}
/// The values native mods hand their shader packs in this frame,
/// `partial` ticks past the last tick (0 to 1): a pack's WGSL reads
/// them as `frame.mods` (eight `vec4<f32>`). Write the slots your own
/// pack reads; a mod that bundles its pack as `native-<mod id>` is the
/// one that pack is for. Asked once a frame while a pack is on; a value
/// that isn't finite reads as 0 (`native_shader_params_now`). API
/// @API:native_shader_params@.
pub(super) fn native_shader_params(&self, partial: f32, params: &mut [[f32; 4]; 8]) {
let _ = (partial, params);
}
@@ -1725,6 +1810,19 @@ impl Game<'_> {
) {
let _ = (screen, button, modifiers, canceled);
}
/// Before a real held-button movement reaches screen drag handlers.
/// `screen.mouse` is the current GUI position; `delta` is its GUI-space movement.
/// Cancel keeps the new cursor position but skips all screen drag handlers.
pub(super) fn native_screen_mouse_dragged_pre(
&self,
screen: &NativeScreenView<'_>,
button: i32,
delta: [f32; 2],
modifiers: u32,
canceled: &mut bool,
) {
let _ = (screen, button, delta, modifiers, canceled);
}
/// The wheel turned over the open screen (`ScreenEvent.MouseScrolled.Pre`,
/// vanilla's `Screen.mouseScrolled`): `screen` is the screen it is,
/// `screen.mouse` where the pointer is (so there is no position of its
@@ -1792,6 +1890,17 @@ impl Game<'_> {
) {
let _ = (screen, key, repeat, modifiers, canceled);
}
/// Before an open screen handles a named key's release (same screen gate as key presses).
/// Cancel bypasses screen handling; physical keys and mapped keyboard hold/toggle state release.
pub(super) fn native_screen_key_released_pre(
&self,
screen: &NativeScreenView<'_>,
key: &str,
modifiers: u32,
canceled: &mut bool,
) {
let _ = (screen, key, modifiers, canceled);
}
/// A character was typed at the open screen
/// (`ScreenEvent.CharacterTyped.Pre`, vanilla's `Screen.charTyped`):
/// `screen` is the screen it is, `c` the character, and `modifiers` GLFW's
@@ -1857,6 +1966,34 @@ impl Game<'_> {
image.height as f32,
]
}
pub(super) fn track_drag_button(&mut self, button: MouseButton, pressed: bool) {
// Any release ends the drag, as vanilla's `MouseHandler.onButton` clears `activeButton`:
// left down, right down, left up sends no drag while right is still held.
self.drag_button = pressed.then_some(button);
}
pub(super) fn screen_mouse_dragged(&self, previous: (f32, f32)) -> bool {
if self.pad_synthetic_input() || self.cursor == previous {
return false;
}
let Some(button) = self
.drag_button
.filter(|b| self.physical_buttons.contains(b))
else {
return false;
};
let Some(screen) = self.screen_view_now() else {
return false;
};
let mut canceled = false;
self.native_screen_mouse_dragged_pre(
&screen,
super::glfw_button(button),
[self.cursor.0 - previous.0, self.cursor.1 - previous.1],
super::glfw_modifiers(self.modifiers),
&mut canceled,
);
canceled
}
/// A screen the native mods' input hooks are for is open: the window has a
/// renderer, `Screen::Playing` is behind it (so not the title screen, nor a
/// book, a sign editor, a server dialog or a prompt drawn over gameplay)
@@ -1904,6 +2041,22 @@ impl Game<'_> {
);
canceled
}
pub(super) fn screen_key_released(&self, key: KeyCode) -> bool {
let Some(name) = keybinds::key_name(key) else {
return false;
};
let Some(screen) = self.screen_view_now() else {
return false;
};
let mut canceled = false;
self.native_screen_key_released_pre(
&screen,
name,
super::glfw_modifiers(self.modifiers),
&mut canceled,
);
canceled
}
/// Text on its way to a text box after the open screen's character hooks
/// (`native_screen_char_typed_pre` per printable character): the cancelled
/// characters are gone and every other one stays, control characters
@@ -2386,6 +2539,25 @@ impl Game<'_> {
pub(super) fn native_player_respawned(&mut self, player: u128, name: &str) {
let _ = (player, name);
}
/// The number of players with the mod container at `pos` of `dimension`
/// open went from `previous` to `viewers` (vanilla's
/// `ContainerOpenersCounter`, as a barrel keeps it): 0 to more than 0 is
/// `onOpen`, back to 0 is `onClose`, any change `openerCountChanged`, so a
/// block can show itself open while `viewers` is not 0. Counted every
/// tick from the players' open menus, so a player who leaves without
/// closing (a lost connection, death, another dimension, out of reach, the
/// block gone) is heard too (`recheckOpeners`). Once for each change, with
/// no player's state swapped in. Server-side.
/// @API:mod_container_viewers@.
pub(super) fn native_mod_container_viewers(
&mut self,
dimension: &str,
pos: [i32; 3],
viewers: u32,
previous: u32,
) {
let _ = (dimension, pos, viewers, previous);
}
/// `player` died: where `ServerPlayer.die` starts, with the death
/// message's key when the game knows it (`death.attack.lava`; none for a
/// message it builds from the combat). It only hears. Server-side, for
@@ -3684,7 +3856,8 @@ impl Game<'_> {
/// nobody uses and outside a world. These are the server's table on the
/// server thread, a dedicated server or a LAN host; a client, the
/// single-player window included, sees only the content mods' recipes, not
/// the world's data packs'. API 36.
/// the world's data packs', unless a mod had the server send it the type
/// (`native_datapack_sync`). API 36.
pub(super) fn native_recipes_of_type(&self, kind: &str) -> &[CustomRecipe] {
if self.outside_world() {
return &[];
@@ -3743,9 +3916,10 @@ impl Game<'_> {
self.player.is_none() && self.options.execution_role.is_client()
}
/// Every item and recipe the game knows, made once (the same for every
/// call); none outside a world. They are the window's own: the world's
/// in single player, vanilla's on a server, which sends a client no
/// others.
/// call); none outside a world. They are the window's own (the content
/// mods' and vanilla's), with the server's recipes of the types a mod had
/// it send (`native_datapack_sync`) in their place; made again when those
/// arrive (`native_recipes_received`).
pub(super) fn native_recipes(&mut self) -> Option<Arc<NativeRecipes>> {
self.player.as_ref()?;
self.made_recipes()
@@ -1,10 +1,10 @@
//! Every item and recipe the game knows, made once for the mods that read
//! them (native mods' `native_recipes`, capability `recipes` of WebAssembly
//! mods, docs/mods.md), and the recipe book's known displays; placing a
//! recipe and creative gifts. The recipes are the window's own data: the
//! world's in single player, vanilla's on a server (a client is sent no
//! others), while the known displays follow the server's book as the recipe
//! book does.
//! recipe and creative gifts. The recipes are the window's own data — the
//! content root's — with the server's recipes of the types its native mods
//! had it send (`horizon:recipe_content`) in their place, while the known
//! displays follow the server's book as the recipe book does.
use super::{
Game,
native_hooks::{
@@ -13,7 +13,7 @@ use super::{
},
};
use horizon_client::{
crafting::Ingredient,
crafting::{Ingredient, recipe_id},
items::ItemStack,
recipe_book::{Category, Display},
stations::Smithing,
@@ -22,10 +22,22 @@ use std::{collections::HashMap, sync::Arc};
/// The tables, made once, and the recipe book's known displays with the
/// book's version they are of.
/// The most recipes a server's recipe content may carry.
const MAX_SYNCED_RECIPES: usize = 1_000_000;
#[derive(Default)]
pub(super) struct RecipeTables {
tables: Option<Arc<Recipes>>,
known: (Option<u64>, Arc<[i32]>),
/// The server's recipes of the types its native mods sent (`horizon:
/// recipe_content`) over the window's own: what `Game::recipes` reads on
/// a client once they came.
synced: Option<horizon_client::crafting::Recipes>,
/// The recipe content's parts so far, until its last.
incoming: Vec<(String, serde_json::Value)>,
/// The types that came before the player did: the mods hear of them
/// once the helpers can answer (`recipes_received_now`).
received: Option<Vec<String>>,
}
/// Item id -> its index in [`Recipes::items`].
@@ -187,11 +199,10 @@ impl Game<'_> {
);
let mut out = Vec::new();
let cooking: HashMap<String, (u32, f32)> = self
.res
.recipes()
.cooking
.iter()
.map(|c| (format!("minecraft:{}", c.id), (c.time, c.experience)))
.map(|c| (recipe_id(&c.id), (c.time, c.experience)))
.collect();
for e in self.recipe_book_entries() {
let furnace = |c: Category| match c.name().split('_').next() {
@@ -243,7 +254,7 @@ impl Game<'_> {
}
for c in &self.recipes().cooking {
if c.kind == "minecraft:campfire_cooking" {
let mut r = recipe(format!("minecraft:{}", c.id), RecipeKind::Campfire);
let mut r = recipe(recipe_id(&c.id), RecipeKind::Campfire);
r.group = c.group.clone();
r.slots = vec![index.any(&c.ingredient)];
r.results = index.of(&c.result).into_iter().collect();
@@ -320,3 +331,107 @@ impl Game<'_> {
}
}
}
impl Game<'_> {
/// The server's recipes a client was sent, over its own; None on a
/// server and until a server sends any.
pub(super) fn synced_recipes(&self) -> Option<&horizon_client::crafting::Recipes> {
self.recipe_tables.synced.as_ref()
}
/// A new play session (`Login`): what the last server sent is gone.
pub(super) fn forget_synced_recipes(&mut self) {
self.recipe_tables.synced = None;
self.recipe_tables.incoming.clear();
self.recipe_tables.received = None;
self.recipe_tables.tables = None;
self.forget_book();
}
/// `native_recipes_received` for the types that came, once the window
/// has its player: the recipes of a join come before it, and the helpers
/// answer nothing until then. Once a frame.
pub(super) fn recipes_received_now(&mut self) {
if self.player.is_some()
&& let Some(types) = self.recipe_tables.received.take()
{
self.native_recipes_received(&types);
}
}
/// `OnDatapackSyncEvent.sendRecipes`: the recipe types the native mods
/// want their clients to have, from this load's tables, become what the
/// server sends its Horizon clients at a join and after `/reload`. Called
/// once a load's tables are built, before any player hears of them.
pub(super) fn sync_recipe_content(&mut self) {
use horizon_client::lan::recipes;
let mut types = Vec::new();
self.native_datapack_sync(&mut types);
types.sort();
types.dedup();
let sources = if types.is_empty() {
Vec::new()
} else {
horizon_client::crafting::Recipes::sources(&self.options.resources, &self.pack_files())
.unwrap_or_else(|e| {
log::warn!("Horizon: the recipes for the clients were not read: {e}");
Vec::new()
})
};
recipes::set_content(recipes::encode_content(
&types,
&sources,
recipes::sent_before(),
));
}
/// A `horizon:recipe_content` part the server sent the window. On the
/// last, the window's tables take the server's recipes of those types in
/// place of its own, and the mods hear of it (`RecipesReceivedEvent`).
pub(super) fn remote_recipe_content(&mut self, data: &[u8]) {
let part: horizon_client::lan::recipes::ContentPart = match serde_json::from_slice(data) {
Ok(part) => part,
Err(e) => {
log::warn!("Horizon: bad recipe content from the server: {e}");
return;
}
};
self.recipe_tables.incoming.extend(part.recipes);
// The server is trusted to end its parts; one that never does is
// dropped, not kept growing.
if self.recipe_tables.incoming.len() > MAX_SYNCED_RECIPES {
log::warn!("Horizon: the server sent more than {MAX_SYNCED_RECIPES} recipes; dropped");
self.recipe_tables.incoming = Vec::new();
return;
}
let Some(types) = part.last else {
return;
};
let received = std::mem::take(&mut self.recipe_tables.incoming);
// No type any more: back on the window's own recipes.
if types.is_empty() {
if self.recipe_tables.synced.take().is_some() {
self.recipe_tables.tables = None;
self.forget_book();
self.recipe_tables.received = Some(types);
self.recipes_received_now();
}
return;
}
let root = &self.options.resources;
let overlay = horizon_world::loot::PackOverlay::NONE;
let built = horizon_client::crafting::Recipes::sources(root, &overlay).and_then(|own| {
let of = |v: &serde_json::Value| types.iter().any(|t| v["type"].as_str() == Some(t));
let mut all: Vec<_> = own.into_iter().filter(|(_, v)| !of(v)).collect();
all.extend(received);
horizon_client::crafting::Recipes::from_sources(root, self.items(), &overlay, all)
});
match built {
Ok(recipes) => {
self.recipe_tables.synced = Some(recipes);
// What was made of the window's own recipes is made again.
self.recipe_tables.tables = None;
self.forget_book();
self.recipe_tables.received = Some(types);
self.recipes_received_now();
}
Err(e) => log::warn!("Horizon: the server's recipes were not taken: {e}"),
}
}
}
@@ -628,7 +628,8 @@ fn add_to(
/// screen as any change to their inventory does.
#[allow(dead_code)]
impl Game<'_> {
/// The main hand's stack: its item, count, and whether it is plain.
/// The main hand's stack: its item, count, and whether it is plain. On a
/// client, the player's own.
pub(super) fn native_held(&self) -> Option<(String, u32, bool)> {
let s = self.inventory.get(self.selected)?.as_ref()?;
Some((s.item.to_string(), s.count, plain(s)))
+31
View File
@@ -148,6 +148,23 @@ impl Game<'_> {
self.ui.vanilla_root.clone()
}
}
/// Whether all active packs preserve vanilla's block models. The server
/// overlay sits above the local selection and must be included too.
pub(super) fn model_packs_are_vanilla(&self) -> bool {
self.settings.horizon.vanilla_models_with_server_packs(
self.settings.resource_packs.iter().map(String::as_str),
self.server_packs.active.iter().map(|pack| pack.id.as_str()),
)
}
/// The leaf mode that is safe for the current local and server packs.
pub(super) fn effective_leaf_culling(&self) -> horizon_client::graphics::LeafCullingMode {
self.settings
.horizon
.leaf_culling_effective_with_server_packs(
self.settings.resource_packs.iter().map(String::as_str),
self.server_packs.active.iter().map(|pack| pack.id.as_str()),
)
}
/// The packs the pack screen offers and the current selection
/// (`PackSelectionScreen` makes its `PackSelectionModel` from them).
pub(super) fn refresh_pack_lists(&mut self) {
@@ -355,11 +372,25 @@ impl Game<'_> {
.cem
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = cem;
// The random entity textures the new atlas packs: every mob chooses again.
*self
.res
.mob_data()
.random_textures
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
std::mem::take(&mut self.gui.atlas.random_textures);
for m in &mut self.mobs {
m.random_textures.forget_picks();
}
horizon_client::ui::load_metadata(&root);
lang::set(lang::Language::load(&[&root], &lang::code()));
self.refresh_remote_language();
self.audio.reload(&root);
self.clear_lod();
// A pack that may replace leaf models falls back to `Off` before the
// reload marks every section dirty.
self.streaming.leaf_culling = self.effective_leaf_culling();
self.streaming.reload_resources(&mut self.world, context);
self.pending_uploads.clear();
// New GPU textures: a renderer built from the reloaded resources; the
@@ -1416,6 +1416,7 @@ mod preview_tests {
entity_tags: Default::default(),
villager_hats: Default::default(),
cem: Default::default(),
random_textures: Default::default(),
pois: Default::default(),
species_placements: Default::default(),
};
+42 -22
View File
@@ -7,8 +7,8 @@ use horizon_client::{
mobs::{
Mob, Species, Who,
finalize::{
SLOT_NAMES, set_drop_chance, set_item, set_slot_enchantments, slot_enchantments,
stored_drop_chance,
SLOT_NAMES, item_stack, set_drop_chance, set_item, set_item_stack,
set_slot_enchantments, slot_enchantments, stored_drop_chance,
},
piglin::{ADMIRE, ATE_RECENTLY},
},
@@ -27,8 +27,9 @@ impl Game<'_> {
self.item_in_tag("minecraft:piglin_loved", item)
}
/// `PiglinAi.isLovedItem` or a preferred weapon (`Piglin.canReplaceCurrentItem`).
fn piglin_wanted(&self, item: &str) -> bool {
self.piglin_loved(item) || self.item_in_tag("minecraft:piglin_preferred_weapons", item)
fn piglin_wanted(&self, m: &Mob, item: &str) -> bool {
self.piglin_loved(item)
|| !m.is_baby() && self.item_in_tag("minecraft:piglin_preferred_weapons", item)
}
/// The `SLOT_NAMES` index `getEquipmentSlotForItem` picks: armour goes
/// to its slot, anything else to the main hand.
@@ -50,7 +51,7 @@ impl Game<'_> {
let Some(current) = horizon_client::mobs::finalize::item(m, slot) else {
return true;
};
self.piglin_wanted(item) && !self.piglin_wanted(&current)
self.piglin_wanted(m, item) && !self.piglin_wanted(m, &current)
}
/// `SimpleContainer.canAddItem` on the piglin's eight slots.
fn piglin_can_add(&self, m: &Mob, stack: &ItemStack) -> bool {
@@ -62,11 +63,13 @@ impl Game<'_> {
fn piglin_max_stack(&self, item: &str) -> u32 {
self.items().get(item).map_or(64, |i| i.max_stack)
}
/// `PiglinAi.wantsToPickup` (an adult that is not hurt-shy), without
/// the baby rule, which babies skip entirely here.
/// `PiglinAi.wantsToPickup`: babies ignore their own item tag, but can
/// pick up currency and other loved items just like adults.
fn piglin_wants(&self, m: &Mob, stack: &ItemStack) -> bool {
let item = &*stack.item;
if self.item_in_tag("minecraft:piglin_repellents", item) {
if m.is_baby() && self.item_in_tag("minecraft:ignored_by_piglin_babies", item)
|| self.item_in_tag("minecraft:piglin_repellents", item)
{
return false;
}
let holds_loved = m
@@ -191,8 +194,7 @@ impl Game<'_> {
}
for i in 0..self.mobs.len() {
let m = &self.mobs[i];
if m.species() != Species::Piglin || !m.alive() || m.is_baby() || m.extra.using_item > 0
{
if m.species() != Species::Piglin || !m.alive() || m.extra.using_item > 0 {
continue;
}
let pos = m.position;
@@ -302,8 +304,8 @@ impl Game<'_> {
true
}
/// `PiglinAi.stopHoldingOffHandItem` after admiring: gold ingots buy a
/// roll of the bartering table, thrown toward the player; any other loved
/// item it picked up is kept (worn if it can be, else stored).
/// roll of the bartering table for adults. Babies equip the admired item,
/// or hold it in the main hand after stashing or throwing the old item.
pub(super) fn finish_admiring(&mut self, mob: u32) {
let Some(i) = self.mobs.iter().position(|m| m.id == mob) else {
return;
@@ -311,22 +313,40 @@ impl Game<'_> {
let Some(item) = self.mobs[i].extra.off_hand.take() else {
return;
};
if self.mobs[i].is_baby() {
let stack = ItemStack::new(&item, 1);
if !self.piglin_equip(i, &stack) {
if let Some(old) = item_stack(&self.mobs[i], 0) {
if self.piglin_loved(&old.item) {
self.piglin_stash(i, old);
} else {
self.piglin_throw_items(i, vec![old]);
}
}
let m = &mut self.mobs[i];
set_item_stack(m, 0, stack);
set_drop_chance(m, 0, 2.0);
m.persistent = true;
}
return;
}
if &*item != "minecraft:gold_ingot" {
self.piglin_keep(i, ItemStack::new(&item, 1));
return;
}
let items = world_items!(self).loot_table(
"minecraft:gameplay/piglin_bartering",
false,
None,
&mut self.world.random,
);
self.piglin_throw_items(i, items);
}
/// `PiglinAi.throwItems`: barters and a baby's unwanted main-hand item
/// use the same player-directed throw.
fn piglin_throw_items(&mut self, i: usize, items: Vec<ItemStack>) {
let m = &self.mobs[i];
let from = [m.position.x, m.position.y + 1.3, m.position.z];
let items = if !m.is_baby() {
world_items!(self).loot_table(
"minecraft:gameplay/piglin_bartering",
false,
None,
&mut self.world.random,
)
} else {
vec![ItemStack::new(&item, 1)]
};
// BehaviorUtils.throwItem toward the nearest visible player
// (NEAREST_VISIBLE_PLAYER: within 16, alive, no spectators; the
// guests count as much as the host), else nearby.
@@ -507,6 +507,12 @@ impl Game<'_> {
)
.unwrap_or(Vec3::ZERO)
});
// Entity.move updates an authoritative living entity's ground flag
// even for a horizontal piston push. Keeping the old support flag
// would run Frost Walker while the stand is moving over open water.
if let Target::ArmorStand(i) = target {
self.armor_stands[i].on_ground = limited.y < 0.0 && limited.y != resolved.y;
}
if resolved.length_squared() <= 1.0e-7
&& limited.length_squared() - resolved.length_squared() >= 1.0e-7
{
@@ -1140,6 +1140,15 @@ impl Game<'_> {
});
self.potions.thrown = thrown;
for (stack, at, mob, block, age, owner) in landed {
// Projectile.onHit (super.onHit of AbstractThrownPotion): a block
// is heard at its centre, an entity at the hit.
let heard = if mob.is_some() {
[at.x, at.y, at.z]
} else {
[at.x.floor() + 0.5, at.y.floor() + 0.5, at.z.floor() + 0.5]
};
let thrower = owner.is_some().then_some(Who::Player);
self.projectile_event("minecraft:projectile_land", thrower, heard);
self.splash(&stack, at, mob, block, age, owner);
}
}
@@ -0,0 +1,64 @@
//! OptiFine random entity textures: each mob's variants, chosen when this window first sees it
//! and checked again once a second (`horizon_client::mobs::random_textures`).
use super::Game;
use horizon_client::mobs::random_textures::{Seen, choose, seed};
use horizon_core::geometry::Vec3;
use horizon_optifine::random::Weather;
impl Game<'_> {
pub(super) fn tick_random_textures(&mut self) {
let res = self.res.clone();
let textures = res
.mob_data()
.random_textures
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if textures.is_empty() {
return;
}
let tick = self.world.game_time;
for i in 0..self.mobs.len() {
let m = &self.mobs[i];
if !m.random_textures.due(tick, m.id) {
continue;
}
let seed = seed(m, self.persist.uuids.get(&m.id).copied());
let seen = (!m.random_textures.has_seen()).then(|| self.seen_at(m.position));
choose(&mut self.mobs[i], &textures, seen, seed);
}
}
/// What OptiFine reads of a mob's surroundings when it first appears.
fn seen_at(&self, at: Vec3) -> Seen {
let (x, y, z) = (
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
);
let block = |y| {
let state = self.pack.assets.registry.state(self.world.block(x, y, z))?;
let properties = state
.properties
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
Some((state.name.clone(), properties))
};
let weather = &self.world.weather;
Seen {
biome: self.biome_name_at(at),
y,
blocks: [block(y - 1), block(y)].into_iter().flatten().collect(),
moon_phase: self.world.day_time.div_euclid(24_000).rem_euclid(8) as i32,
day_time: self.world.day_time.rem_euclid(24_000) as i32,
weather: if weather.thundering {
Weather::Thunder
} else if weather.raining {
Weather::Rain
} else {
Weather::Clear
},
}
}
}
@@ -369,6 +369,11 @@ impl Game<'_> {
self.recipe_ui.client.displays(),
)
}
/// The book's model is made again from `Game::recipes` when next needed:
/// the server's recipes arrived, or a new session began.
pub(super) fn forget_book(&mut self) {
self.recipe_ui.book = None;
}
fn ensure_book(&mut self) {
if self.recipe_ui.book.is_none() {
let mut book = Book::build(self.recipes(), self.items());
+67 -1
View File
@@ -431,6 +431,8 @@ pub(super) struct Remote {
pub quiet_commands: bool,
/// The server announced it carries mod messages.
pub mod_net: bool,
/// The server samples far terrain for us, this far at most (`lod_net`).
pub far_terrain: Option<horizon_client::lod_net::Offer>,
pub(super) builtin: Arc<Builtin>,
lang: Arc<Language>,
pub(super) registries: Arc<Dynamic>,
@@ -880,6 +882,7 @@ impl Game<'_> {
self.container = None;
self.carried = None;
self.using = None;
self.end_mod_use();
self.clicks.miss_time = 0;
// A new LocalPlayer has no effects: the server sends what it has
// (ClientboundUpdateMobEffectPacket on placement).
@@ -934,6 +937,7 @@ impl Game<'_> {
brand: "unknown".into(),
quiet_commands: false,
mod_net: false,
far_terrain: None,
builtin: Arc::new(builtin),
status: lang
.get("connect.connecting")
@@ -1768,12 +1772,26 @@ impl Game<'_> {
remote.mod_net = true;
}
}
Event::FarTerrain(offer) => {
if let Some(remote) = &mut self.remote {
remote.far_terrain = Some(offer);
}
}
Event::Reconfiguring => {
// handleConfigurationStart: the held-back chat shows, then
// clearClientLevel opens ServerReconfigScreen over no level.
self.flush_chat_queue();
self.send_chat_acknowledgement();
self.close_dialogs();
// The server configuring us now (a proxy's next one)
// offers its own far terrain, or none.
if let Some(remote) = &mut self.remote {
remote.far_terrain = None;
}
self.clear_lod();
if let Some(remote) = &self.remote {
remote.session.reset_far_terrain();
}
self.player = None;
self.screen = Screen::Playing;
self.capture(false);
@@ -2856,6 +2874,10 @@ impl Game<'_> {
C::CustomPayload(p) if p.channel == horizon_protocol::mod_net::CHANNEL => {
self.remote_native_net(&p.data);
}
// The server's recipes its native mods asked to send.
C::CustomPayload(p) if p.channel == horizon_client::lan::recipes::CONTENT_CHANNEL => {
self.remote_recipe_content(&p.data);
}
// PingDebugMonitor.onPongReceived.
C::PongResponse(p) => {
let delay = self.debug_millis() - p.time;
@@ -2863,6 +2885,7 @@ impl Game<'_> {
}
C::Login(p) => {
self.test_highlights.clear();
self.forget_synced_recipes();
self.level.hardcore = p.hardcore;
// handleLogin: startWaitingForNewLevel with Reason.OTHER, which
// replaces ServerReconfigScreen.
@@ -2910,6 +2933,7 @@ impl Game<'_> {
// again) and with no item in use.
self.potions.effects.clear();
self.using = None;
self.end_mod_use();
// After a death (no KEEP_ENTITY_DATA) the new LocalPlayer
// rides nothing: the server pairs a vehicle again with its
// passengers. A dimension change keeps the vehicle it travels in.
@@ -4346,6 +4370,15 @@ impl Game<'_> {
p.unsigned_content.as_ref(),
&line,
);
// ChatListener.showMessageToPlayer: `Minecraft.isBlocked` hides the
// message of a player hidden in the Social Interactions screen; it
// counts as processed, not shown.
if self.social_hides(p.sender) {
if let Some(signature) = &signature {
self.mark_message_processed(signature, false);
}
return Ok(());
}
self.handle_player_chat(line, signature, trust, narration);
}
C::DisguisedChat(p) => {
@@ -5374,6 +5407,10 @@ impl Game<'_> {
self.native_net.to_server = 0;
self.native_tick();
self.tell_native_now();
// Far tiles the samplers asked for (`lod_net`).
if let Some(r) = &self.remote {
r.session.send_far_terrain();
}
self.tick_chat_session();
// Minecraft.tick sends this even when the player cannot tick yet,
// including while waiting for chunks. Keep it outside early returns.
@@ -5440,6 +5477,7 @@ impl Game<'_> {
self.border.tick();
self.player_border();
self.tick_remote_entities(running);
self.tick_random_textures();
// Breeze.tick's block dust: drawn by each client for the breezes it sees.
if running {
self.tick_breeze_effects();
@@ -6313,6 +6351,8 @@ impl Game<'_> {
collision,
false,
&self.res.motion().fluids,
// The server decides what an item rests on; this copy only moves between updates.
&[],
);
}
head_lerps.retain(|_, (_, steps)| *steps > 0);
@@ -6837,6 +6877,10 @@ impl Game<'_> {
if self.hands_busy() {
return;
}
// An item a mod claims: its use is the mod's, and the server never hears of it.
if self.mod_use_start() {
return;
}
// Resolve the crosshair once. A passing entity interaction keeps
// that target and may only fall back to using the item itself.
let entity = self.entity_targeted();
@@ -7057,7 +7101,7 @@ impl Game<'_> {
self.open_block_editor_if_editable((hit.pos[0], hit.pos[1], hit.pos[2]));
// MultiPlayerGameMode.useItemOn: SUCCESS SwingSource.CLIENT
// (a chest, barrel, furnace, …). CONSUME does not swing.
if result == InteractionResult::Success {
if result.should_swing() {
self.with_hand(hand, Game::swing_arm);
}
} else if self.predict_campfire_douse(hit.pos, held.as_ref(), seq) {
@@ -7448,6 +7492,11 @@ impl Game<'_> {
let Some(held) = self.with_hand(hand, |g| g.held().cloned()) else {
return false;
};
// A mod's claimed item is never used on the server; its own use
// starts from the main hand only (`mod_use_start`).
if self.native_input.claimant(&held.item).is_some() {
return false;
}
let seq = self.next_sequence();
self.send(S::UseItem(play::UseItem {
hand: hand_id(hand),
@@ -7501,6 +7550,11 @@ impl Game<'_> {
}
return succeeded;
}
// SpawnEggItem.use on the client succeeds after a SOURCE_ONLY block
// hit; the server alone tests LiquidBlock and the placement permission.
if held.item.ends_with("_spawn_egg") {
return self.spawn_egg_allowed(&held.item) && self.bucket_hit().is_some();
}
// Food starts being eaten (Consumable.startConsuming). A food that
// can't be eaten (full and not always-edible) fails the use
// (InteractionResult.FAIL): startUseItem tries the other hand, and
@@ -7713,6 +7767,8 @@ impl Game<'_> {
/// stack, its hunger and the sounds.
fn remote_tick_using(&mut self) {
let Some((slot, ticks)) = self.using else {
// A mod's use the game reset (the server stopping the use...).
self.end_mod_use();
return;
};
let stack = self.stack_in(slot).cloned();
@@ -7724,12 +7780,17 @@ impl Game<'_> {
r.use_item = item;
} else if r.use_item != item {
self.using = None;
self.end_mod_use();
return;
}
}
// Another hotbar slot picked: LivingEntity.updatingUsingItem sees a
// different stack in the hand. The off hand keeps its item.
let in_hand = slot == self.selected || slot == super::shield::OFFHAND_SLOT;
// A mod's item: held until the button lets go, counted for the mod.
if self.mod_use_tick(slot, ticks, !self.right || !in_hand) {
return;
}
// BrushItem.onUseTick: with no block aimed the use ends (the server
// ends it too); a stroke shows its dust. Its sounds are the server's.
if stack
@@ -9476,6 +9537,11 @@ impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_
self.0.using.is_some()
}
fn release_using(&mut self) {
if let Some((slot, ticks)) = self.0.using
&& self.0.mod_use_tick(slot, ticks, true)
{
return;
}
self.0.release_remote_use();
}
fn start_attack(&mut self) -> bool {
+34 -1
View File
@@ -143,6 +143,7 @@ impl Game<'_> {
if self.title.is_none() {
fog.color =
self.native_fog_color_now(self.clock.fraction() as f32, at, fog_type, fog.color);
fog = self.native_fog_now(self.clock.fraction() as f32, at, fog_type, fog);
}
fog
}
@@ -161,6 +162,38 @@ impl Game<'_> {
color
}
}
fn native_fog_now(
&self,
partial: f32,
camera: [f64; 3],
fog_type: FogType,
original: FogData,
) -> FogData {
let mut fog = original;
self.native_render_fog(partial, camera, fog_type, &mut fog);
let distances = [
fog.environmental_start,
fog.environmental_end,
fog.render_distance_start,
fog.render_distance_end,
fog.sky_end,
fog.cloud_end,
];
if fog
.color
.iter()
.all(|v| v.is_finite() && (0.0..=1.0).contains(v))
&& distances.iter().all(|v| v.is_finite())
&& fog.environmental_end > fog.environmental_start
&& fog.render_distance_end > fog.render_distance_start
&& fog.sky_end >= 0.0
&& fog.cloud_end >= 0.0
{
fog
} else {
original
}
}
/// `FogRenderer.computeFogColor`'s night vision brightenFactor:
/// `GameRenderer.nightVisionScale`, but not while Darkness is on.
fn fog_night_vision(&self) -> f32 {
@@ -286,7 +319,7 @@ impl Game<'_> {
// drawn on the HUD by draw_spyglass.
let fx = &self.screen_fx;
let overlays = hud_overlays::CameraOverlays {
vignette: self.settings.vignette.then_some((
vignette: self.vignette_shown().then_some((
fx.vignette_brightness,
self.border.warning_strength(p.position.x, p.position.z),
)),
+1 -1
View File
@@ -821,7 +821,7 @@ impl Game<'_> {
}
/// The host and guests that have entered the world, rebuilt from live state.
fn local_tab_players(me: TabPlayer, guests: &[Guest]) -> Vec<TabPlayer> {
pub(super) fn local_tab_players(me: TabPlayer, guests: &[Guest]) -> Vec<TabPlayer> {
let mut players = vec![me];
players.extend(guests.iter().filter(|g| g.placed).map(|g| {
let mut player = TabPlayer::new(g.uuid, &g.name);
+12 -10
View File
@@ -24,6 +24,7 @@ impl Screen {
| Self::Advancements
| Self::WorldOptions
| Self::Friends
| Self::Social
| Self::ServerLinks
| Self::ConfirmLink
| Self::DialogWarning
@@ -118,8 +119,7 @@ fn pause_buttons(
/// The icon row: report bugs, feedback, friends, player reporting.
/// `FRIENDS_ICON` (vanilla's `CommonButtons.friends`) opens Horizon's
/// Play with Friends; the first two open their link through
/// `ConfirmLinkScreen`; player reporting (`SocialInteractionsScreen`, not
/// ported) stays inactive.
/// `ConfirmLinkScreen`; player reporting opens `SocialInteractionsScreen`.
const FRIENDS_ICON: usize = 2;
/// `CommonLinks.SNAPSHOT_BUGS_FEEDBACK`, behind `PauseScreen.REPORT_BUGS`.
const BUGS_LINK: &str = "https://aka.ms/snapshotbugs?ref=game";
@@ -143,7 +143,7 @@ const ICON_LABELS: [&str; 4] = [
/// button always, friends when the friends list is shown.
fn icon_active(icon: usize, friends: bool) -> bool {
match icon {
0 | 1 => true,
0 | 1 | 3 => true,
FRIENDS_ICON => friends,
_ => false,
}
@@ -362,6 +362,8 @@ impl Game<'_> {
// ConfirmLinkScreen.confirmLink(this, CommonLinks...) on the pause screen.
12 => self.confirm_trusted_link(BUGS_LINK, Screen::Paused),
13 => self.confirm_trusted_link(feedback_link(), Screen::Paused),
// PauseScreen: `new SocialInteractionsScreen(this)`.
15 => self.open_social(true),
30 => self.open_split_settings(),
// addCustomDialogButtons: DialogConnectionAccess.openDialog of the
// additions dialog, from the pause menu.
@@ -403,8 +405,9 @@ impl Game<'_> {
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
.map(|b| b.4)
.or_else(|| {
// The report bugs and feedback icons (ids 12 and 13).
(0..2)
// The report bugs, feedback and player reporting icons (ids 12, 13 and 15).
[0, 1, 3]
.into_iter()
.find(|&i| ui::hovered(self.cursor, icons[i].0, icons[i].1, 20.0, 20.0))
.map(|i| 12 + i as u8)
})
@@ -916,16 +919,15 @@ mod tests {
fn pause_icons_report_bugs_and_feedback_are_active_like_vanilla() {
use super::{FRIENDS_ICON, icon_active};
// PauseScreen.createPauseMenu: the bug and feedback buttons are live,
// friends follows its list, player reporting waits for its screen.
assert!(icon_active(0, false) && icon_active(1, false));
// friends follows its list, player reporting opens the social screen.
assert!(icon_active(0, false) && icon_active(1, false) && icon_active(3, false));
assert!(icon_active(FRIENDS_ICON, true) && !icon_active(FRIENDS_ICON, false));
assert!(!icon_active(3, true));
}
#[test]
fn pause_keyboard_focus_includes_the_active_icon_buttons() {
assert_eq!(pause_icon_focus_ids(false), [12, 13]);
assert_eq!(pause_icon_focus_ids(true), [12, 13, 10]);
assert_eq!(pause_icon_focus_ids(false), [12, 13, 15]);
assert_eq!(pause_icon_focus_ids(true), [12, 13, 10, 15]);
}
#[test]
@@ -136,6 +136,8 @@ pub(super) struct PackIds {
missing_item: i32,
/// `oldHandLight` (3.2): the first held item and light are those of the brighter hand.
old_hand_light: bool,
/// `vignette`: `false` hides the game's vignette over the pack's image.
vignette: bool,
/// `oldLighting`: `true` keeps the game's face shading in the block colours.
old_lighting: bool,
}
@@ -235,6 +237,10 @@ impl Game<'_> {
old_hand_light: properties
.get("oldHandLight")
.is_none_or(|v| v.trim() != "false"),
// On unless the pack turns it off (`vignette=false`).
vignette: properties
.get("vignette")
.is_none_or(|v| v.trim() != "false"),
// Off unless the pack turns it on (Iris shades no faces by default).
old_lighting: properties
.get("oldLighting")
@@ -369,6 +375,11 @@ impl Game<'_> {
));
}
/// Whether the game's vignette draws: the option, unless the loaded pack turns it off.
pub(super) fn vignette_shown(&self) -> bool {
self.settings.vignette && self.pack_ids.as_ref().is_none_or(|ids| ids.vignette)
}
/// `heldItemId(2)` and `heldBlockLightValue(2)`: main hand, then off hand (5.2, 5.3).
fn held_for_pack(&mut self) -> ([i32; 2], [i32; 2]) {
if self.pack_ids.is_none() {
+244
View File
@@ -0,0 +1,244 @@
//! `SocialInteractionsScreen` and `PlayerSocialManager` on the game side: the players Horizon
//! knows (the tab list's), the ones whose chat the player hid, the screen's page, search and
//! scroll, and what hiding does to chat (`Minecraft.isBlocked`). The screen is
//! `social_screen`. Blocking is Mojang's service (`UserApiService.isBlockedPlayer`), which
//! Horizon does not offer: nobody is blocked, as in vanilla's offline mode.
use super::{Game, Screen};
use horizon_client::{
social_screen::{self, Action, Entry, Page, SocialView},
tab_list::TabPlayer,
text::Text,
ui::EditBox,
};
use std::collections::{HashMap, HashSet};
use winit::{
event::{ElementState, KeyEvent},
keyboard::{KeyCode, PhysicalKey},
};
/// `PlayerSocialManager.hiddenPlayers` and the screen's state.
pub(super) struct Social {
pub hidden: HashSet<u128>,
/// `discoveredNamesToUUID`, the other way: the names of the players seen, so a hidden player
/// who left is still listed by name.
names: HashMap<u128, String>,
page: Page,
search: EditBox,
scroll: f32,
/// Opened from the pause menu: Done goes back there.
from_pause: bool,
}
impl Default for Social {
fn default() -> Self {
let mut search = EditBox::new(0.0, 0.0, 200.0, 15.0);
search.max_length = 16;
Self {
hidden: HashSet::new(),
names: HashMap::new(),
page: Page::All,
search,
scroll: 0.0,
from_pause: false,
}
}
}
impl Game<'_> {
/// The players of the level, as the tab list shows them, and the local player's id.
fn social_players(&mut self) -> (Vec<TabPlayer>, u128) {
match self.remote_tab_players() {
Some((players, _, _)) => {
let me = self.remote.as_ref().map_or(0, |r| r.profile());
(players, me)
}
None => {
let uuid = self.level.player_uuid.map_or(0, |u| {
u.iter()
.fold(0u128, |a, &w| (a << 32) | u128::from(w as u32))
});
let mut me = TabPlayer::new(uuid, super::chat::PLAYER_NAME);
me.skin = "entity/player/wide/steve".into();
let guests = self.lan.as_ref().map_or(&[][..], |h| &h.hub.guests);
(super::scoreboard::local_tab_players(me, guests), uuid)
}
}
}
/// `Minecraft.isBlocked`: whether a player's chat is hidden (never the player's own).
pub(super) fn social_hides(&mut self, id: u128) -> bool {
if !self.social.hidden.contains(&id) {
return false;
}
let (_, me) = self.social_players();
id != me
}
/// `new SocialInteractionsScreen(lastScreen)`: it opens on All, with an empty search.
pub(super) fn open_social(&mut self, from_pause: bool) {
self.social.page = Page::All;
self.social.search.set_value("");
self.social.scroll = 0.0;
self.social.from_pause = from_pause;
self.screen = Screen::Social;
self.capture(false);
}
pub(super) fn close_social(&mut self) {
if self.social.from_pause {
self.screen = Screen::Paused;
} else {
self.screen = Screen::Playing;
self.capture(true);
}
}
/// The page's rows, sorted and filtered as `SocialInteractionsPlayerList` does.
fn social_view(&mut self) -> SocialView {
let (players, me) = self.social_players();
for p in &players {
self.social.names.insert(p.uuid, p.name.clone());
}
let hidden = self.social.hidden.clone();
let search = self.social.search.value.to_lowercase();
let mut entries: Vec<Entry> = Vec::new();
let online = |id: u128| players.iter().find(|p| p.uuid == id);
let page = self.social.page;
let ids: Vec<u128> = match page {
Page::All => players.iter().map(|p| p.uuid).collect(),
Page::Hidden => hidden.iter().copied().collect(),
// `UserApiService.isBlockedPlayer`: nobody, without the service.
Page::Blocked => Vec::new(),
};
for id in ids {
let (name, skin, removed) = match online(id) {
Some(p) => (p.name.clone(), p.skin.clone(), false),
None => (
self.social.names.get(&id).cloned().unwrap_or_default(),
horizon_client::tab_list::default_skin(id).to_owned(),
true,
),
};
if !search.is_empty() && !name.to_lowercase().contains(&search) {
continue;
}
let skin = self.face_skin(id, &skin);
entries.push(Entry {
id,
name,
skin,
hidden: hidden.contains(&id),
removed,
local: id == me,
});
}
social_screen::sort_entries(&mut entries);
let count = players.len();
let server = self.social_server_name();
let key = if count > 1 {
"gui.socialInteractions.server_label.multiple"
} else {
"gui.socialInteractions.server_label.single"
};
let args = [Text::literal(server), Text::literal(count.to_string())];
let max = social_screen::max_scroll(self.gui_size().1, entries.len());
self.social.scroll = self.social.scroll.clamp(0.0, max);
SocialView {
page,
search: self.social.search.value.clone(),
entries,
server_label: Text::translate(key, &args),
scroll: self.social.scroll,
}
}
/// The server's name: a local world's name, a server's entry in the list.
fn social_server_name(&self) -> String {
match (&self.integrated, self.server_address()) {
(Some(server), _) => server.world_name.clone(),
(None, Some(address)) if self.remote.is_some() => address.to_owned(),
_ => self.level.name.clone(),
}
}
pub(super) fn draw_social_screen(&mut self, batch: &mut horizon_client::hud::HudBatch) {
self.flush_skin_slots();
let view = self.social_view();
horizon_client::widgets::begin_screen(|| {
horizon_client::widgets::ScreenInfo::new("social", social_screen::TITLE)
});
social_screen::draw(
batch,
&self.gui.atlas,
self.cursor,
&view,
&mut self.social.search,
true,
);
}
pub(super) fn social_click(&mut self) {
let view = self.social_view();
let Some(action) = social_screen::click(self.gui_size(), self.cursor, &view) else {
return;
};
self.audio.play_button_click();
match action {
Action::Page(page) => {
self.social.page = page;
self.social.scroll = 0.0;
}
Action::Hide(id) | Action::Show(id) => {
let hiding = matches!(action, Action::Hide(_));
let name = self.social.names.get(&id).cloned().unwrap_or_default();
if hiding {
self.social.hidden.insert(id);
} else {
self.social.hidden.remove(&id);
}
// `PlayerEntry.onHiddenOrShown`: a line in the chat, and the narrator says it.
let key = if hiding {
"gui.socialInteractions.hidden_in_chat"
} else {
"gui.socialInteractions.shown_in_chat"
};
let line = Text::translate(key, &[Text::literal(name)]);
self.say_system_now(&line);
self.system_message(line);
}
Action::Done => self.close_social(),
}
}
/// `AbstractSelectionList.mouseScrolled`: a row's height and a bit a notch.
pub(super) fn social_scroll(&mut self, delta: f32) {
self.social.scroll -= delta * 18.0;
// `social_view` clamps it to the list.
let _ = self.social_view();
}
/// The search box has the focus from the start (`setInitialFocus`): typing fills it.
pub(super) fn social_key(&mut self, event: &KeyEvent) {
if event.state != ElementState::Pressed {
return;
}
let PhysicalKey::Code(key) = event.physical_key else {
return;
};
match key {
KeyCode::Escape => self.close_social(),
_ => {
// `EditBox.keyPressed`: Backspace, Delete, the arrows, Home and End.
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
if !self.social.search.key(key, ctrl, shift)
&& self.types_text()
&& let Some(text) = event.text.as_deref()
{
self.social_type(text);
}
self.social.scroll = 0.0;
}
}
}
pub(super) fn social_backspace(&mut self) {
self.social.search.key(KeyCode::Backspace, false, false);
self.social.scroll = 0.0;
}
/// Characters into the search box (`searchBox.setMaxLength(16)`).
pub(super) fn social_type(&mut self, text: &str) {
let text: String = text.chars().filter(|c| !c.is_control()).collect();
self.social.search.insert_text(&text);
self.social.scroll = 0.0;
}
}
+1
View File
@@ -284,6 +284,7 @@ impl<'a> Split<'a> {
for button in std::mem::take(&mut game.physical_buttons) {
game.extras_mouse(button, false);
}
game.drag_button = None;
game.modifiers = Default::default();
game.left = false;
game.right = false;
+18 -1
View File
@@ -629,6 +629,12 @@ impl Game<'_> {
};
self.thrower_hit(t.guest, &victim, direct, damage);
}
// Projectile.onHit: the landing is heard, at the hit.
self.projectile_event(
"minecraft:projectile_land",
Some(Who::Player),
[t.position.x, t.position.y, t.position.z],
);
self.audio.play(
"minecraft:item.trident.hit",
Some([t.position.x, t.position.y, t.position.z]),
@@ -647,9 +653,20 @@ impl Game<'_> {
// `AbstractArrow.onHitBlock` on a hit.
let from = t.position;
let flight = t.velocity;
if let Some((stop, _)) =
if let Some((stop, block)) =
clip_block_stop(&self.world, self.res.facts(), self.collision, from, flight)
{
// Projectile.onHit on a block: PROJECTILE_LAND at the
// block's centre.
self.projectile_event(
"minecraft:projectile_land",
Some(Who::Player),
[
f64::from(block.x) + 0.5,
f64::from(block.y) + 0.5,
f64::from(block.z) + 0.5,
],
);
if t.stack.enchantment_level("minecraft:channeling") > 0 {
self.channel_rod_hit(from, flight, t.guest);
}
@@ -52,10 +52,43 @@ impl WorldRequest {
// Before any chunk is read: a loaded container with a loot table is rolled as its chunk
// is decoded (`save::roll_loaded_loot`), and the spawn chunks are read while the world
// opens. Every world opens here: in-frame, on the server thread, on a dedicated server.
horizon_client::structure_blocks::loot_data(
&options.resources,
&horizon_world::loot::PackOverlay::NONE,
);
let loot = || {
horizon_client::structure_blocks::loot_data(
&options.resources,
&horizon_world::loot::PackOverlay::NONE,
);
};
// Bone meal's and saplings' features, over the plain worldgen bundle.
let live_features = || growth::install_live_features(res, pack, options);
if res.threads() <= 1 {
loot();
let prepared = self.prepare_world(res, pack, options, settings, upgrade)?;
live_features();
return Ok(prepared);
}
// Neither needs the world: they load beside it (the generator's decoration keeps one
// core busy) and are done before it opens. A bundle both read loads once.
std::thread::scope(|scope| {
let loot = scope.spawn(loot);
let live_features = scope.spawn(live_features);
let prepared = self.prepare_world(res, pack, options, settings, upgrade);
for helper in [loot, live_features] {
helper
.join()
.unwrap_or_else(|panic| std::panic::resume_unwind(panic));
}
prepared
})
}
fn prepare_world(
self,
res: &Resources,
pack: &PackResources,
options: &Options,
settings: &Settings,
upgrade: &horizon_world::datafix::progress::UpgradeProgress,
) -> Result<PreparedWorld> {
let Self {
save_root,
folder,
@@ -227,8 +260,6 @@ impl WorldRequest {
};
}
growth::install_live_features(res, pack, options);
// Persist new metadata on the worker too, before publishing the world.
if !save_root.join("level.dat").exists() {
level.write(&save_root)?;
@@ -788,6 +819,9 @@ pub(super) fn make_streaming(
streaming.biome_blend = settings.biome_blend;
streaming.smooth_lighting = settings.smooth_lighting;
streaming.cutout_leaves = settings.cutout_leaves;
streaming.leaf_culling = settings
.horizon
.leaf_culling_effective(&settings.resource_packs);
streaming.biome_zoom = BiomeZoom::new(obfuscate_seed(seed));
streaming.sky_light = dim != Dim::Nether;
streaming.nether_shading = dim == Dim::Nether;
+1 -1
View File
@@ -157,7 +157,7 @@ impl Hub {
};
if ac.config.runs(Check::Prediction) {
let g = &mut self.guests[i];
let (input, sprinting) = (g.input, g.sprinting);
let (input, sprinting) = (g.input, g.anticheat.sprinting);
g.anticheat
.record
.get_or_insert_default()
+26
View File
@@ -939,6 +939,11 @@ impl Hub {
}));
// PlayerList.placeNewPlayer: ClientboundUpdateRecipesPacket.
g.send(&raw(ids::UPDATE_RECIPES, self.sync.recipes.clone()));
// OnDatapackSyncEvent.sendRecipes: the recipes the native mods asked
// for, to a Horizon client only.
if g.brand == "horizon" {
send_recipe_content(g);
}
// PlayerList.sendPlayerPermissionLevel, then the recipe book.
self.send_permission_level(i);
self.send_initial_recipe_book(i);
@@ -2553,6 +2558,9 @@ impl Hub {
continue;
}
self.guests[i].send(&raw(ids::UPDATE_RECIPES, sync.recipes.clone()));
if self.guests[i].brand == "horizon" {
send_recipe_content(&self.guests[i]);
}
self.send_initial_recipe_book(i);
}
}
@@ -2651,6 +2659,7 @@ impl Hub {
fn set_sprinting(&mut self, i: usize, sprinting: bool) {
let g = &mut self.guests[i];
g.sprinting = sprinting;
g.anticheat.sprinting = sprinting;
if let Some(m) = g.motion.as_mut() {
let _ = m.set_sprint_modifier(sprinting);
}
@@ -3419,6 +3428,10 @@ impl Hub {
spawn: info,
keep: 0,
}));
// ClientPacketListener.handleRespawn without KEEP_ENTITY_DATA: the client's new player
// starts with wasSprinting false. (A level change keeps it: the new player sprints on, or
// says STOP.)
g.anticheat.sprinting = false;
// The respawned player is a new one, without effects; so is the
// client's (ClientPacketListener.handleRespawn).
g.effects = Default::default();
@@ -3635,6 +3648,19 @@ fn invalid_movement() -> Tag {
translatable("multiplayer.disconnect.invalid_player_movement", &[])
}
/// The recipe content the native mods asked for (`lan::recipes::content`),
/// to one Horizon client: none when no mod asked.
fn send_recipe_content(g: &Guest) {
for data in super::recipes::content().iter() {
g.send(&C::CustomPayload(
horizon_protocol::packets::configuration::CustomPayload {
channel: super::recipes::CONTENT_CHANNEL.into(),
data: data.clone(),
},
));
}
}
fn raw(id: i32, payload: Vec<u8>) -> C {
C::Unknown(id, payload)
}
+1 -1
View File
@@ -25,7 +25,7 @@ pub mod hub;
pub mod menu;
pub mod playerdata;
pub mod prediction;
mod recipes;
pub mod recipes;
pub mod scoreboard;
pub mod tracking;
pub mod waypoints;
+122 -5
View File
@@ -196,6 +196,10 @@ pub struct Replay {
/// Ticks the client's jump delay is unknown: under an excuse the replay may have jumped where the
/// client couldn't (on a block it hadn't received), or the other way round.
jump_unsure: u8,
/// The last tick played: whether the client said it sprinted, and whether its keys pushed it
/// forward (for [`Replay::sprints`]).
claimed: bool,
forward: bool,
/// Ticks in a row within the tolerance, at most [`STEADY`].
steady: u8,
/// The velocity the client's last tick left it with, less the replay's.
@@ -230,6 +234,8 @@ impl Replay {
used: server.item_use_speed,
use_window: 0,
jump_unsure: 0,
claimed: false,
forward: false,
steady: STEADY,
missed: Vec3::ZERO,
meeting: None,
@@ -419,7 +425,7 @@ impl Replay {
}
// ClientPacketListener.handleSetEntityData: the flag alone. A sprint the tick goes on
// with is then started again (`MotionWorld::replayed_tick`), its modifier with it, as
// LocalPlayer.aiStep does when the sprint key is held.
// LocalPlayer.aiStep does when the sprint key is held ([`Replay::sprints`]).
Change::Sprinting(sprinting) => body.sprinting = sprinting,
Change::Effects => body.status = server.status.clone(),
// `sync` takes them every tick.
@@ -427,6 +433,18 @@ impl Replay {
}
}
/// Whether the client sprints in `tick`: as it said (ServerboundPlayerCommandPacket), but for a
/// sprint the server stopped (its flag, [`Change::Sprinting`]) that the client says goes on. That
/// client never said STOP: its LocalPlayer.aiStep started the sprint again, which takes the sprint
/// key held, or forward pressed again (a double tap). Without either it would have stopped and
/// said so: one that sprints on is played walking, until it holds the key or says STOP.
fn sprints(&mut self, tick: &ClientTick, keys: Keys) -> bool {
let restarted = tick.sprinting && self.claimed && !self.body.sprinting;
self.claimed = tick.sprinting;
let forward = std::mem::replace(&mut self.forward, keys.direction()[1] > 1.0e-5);
tick.sprinting && (!restarted || keys.sprint || !forward)
}
/// Plays `tick` again. `server` is the server's copy of the player, for what the server decides
/// (abilities, effects, attributes); `run` moves a body one client tick with these keys and
/// sprinting (`MotionWorld::replayed_tick`). None when no candidate could be played.
@@ -458,6 +476,7 @@ impl Replay {
|| self.use_window > 0 && server.item_use_speed.is_none() && used.is_some();
let said = tick.position.map(|p| Vec3::new(p[0], p[1], p[2]));
let keys = keys(tick.input);
let sprinting = self.sprints(tick, keys);
let last = self.said;
let offset = |body: &PlayerMotion| match said {
Some(p) => body.position.distance(p),
@@ -475,7 +494,7 @@ impl Replay {
};
let (offset, candidate) = if ![1, 2, 4, 8].into_iter().any(unknown) {
// Nothing to try (most ticks): the body itself plays the tick.
run(&mut self.body, keys, tick.sprinting).ok()?;
run(&mut self.body, keys, sprinting).ok()?;
(offset(&self.body), 0)
} else {
let mut best: Option<(f64, u8, Box<PlayerMotion>)> = None;
@@ -495,7 +514,7 @@ impl Replay {
if let Some(met) = meeting.filter(|_| candidate & 8 != 0) {
body.velocity = body.velocity.add_vector(met);
}
if run(&mut body, keys, tick.sprinting).is_err() {
if run(&mut body, keys, sprinting).is_err() {
continue;
}
let off = offset(&body);
@@ -704,6 +723,9 @@ mod tests {
/// sprint `.1`: the shared flags with that sprinting bit, and the movement speed with the
/// sprint modifier or without ([`Change::Sprinting`], [`Change::Attributes`]).
server_sprint: Vec<(usize, bool)>,
/// The client ignores the sprinting flag and movement speed the server sends (a cheat that
/// keeps its sprint through a hit).
keep_sprint: bool,
/// The first ticks the client is on its loading screen: it ends them without moving or
/// sending a move.
frozen: usize,
@@ -724,6 +746,7 @@ mod tests {
blocks: Vec::new(),
attributes: None,
server_sprint: Vec::new(),
keep_sprint: false,
frozen: 0,
item_use: None,
}
@@ -832,8 +855,10 @@ mod tests {
sprint: Some(sprint),
},
);
client.sprinting = sprint;
client.set_sprint_modifier(sprint).unwrap();
if !run.keep_sprint {
client.sprinting = sprint;
client.set_sprint_modifier(sprint).unwrap();
}
}
record.pong(tick, tick);
let before = client.position;
@@ -1273,6 +1298,98 @@ mod tests {
assert!(counted.len() >= 10, "{} of 18: {counted:?}", counted.len());
}
/// A sprint the server stopped after a hit (Player.causeExtraKnockback), which the client's own
/// attack on a mob doesn't: the server's flag and movement speed reach the client on tick 14.
/// Forward is held throughout, the sprint key for the first 10 ticks, and for the rest if `key`.
/// The client's own commands say what it does: with the key it starts the sprint again in its
/// next aiStep and says nothing (the live server's `ground, keys forward sprint` flags, where
/// the record took the server's player and played the rest of the sprint as a walk); without it
/// it stops and says STOP. `water`: the same swimming across a pool, toward its surface.
fn sprint_hit(key: bool, water: bool) -> Run {
let sprint = if key { 64 } else { 0 };
let mut script = vec![(1 | 64, 0.0); 10];
script.extend(vec![(1 | sprint, 0.0); 40]);
let mut blocks = Vec::new();
if water {
for x in 14..=26 {
for y in 5..=7 {
for z in 14..=40 {
blocks.push(([x, y, z], "minecraft:water"));
}
}
}
}
Run {
script,
pitch: if water { -8.0 } else { 0.0 },
blocks,
server_sprint: vec![(14, false)],
..Run::walk()
}
}
#[test]
fn a_sprint_the_server_stopped_after_a_hit_is_the_clients() {
for (key, water) in [(true, false), (false, false), (true, true), (false, true)] {
let Some(steps) = steps_in(sprint_hit(key, water), |_, _, honest| honest, None) else {
return;
};
for (i, s) in steps.iter().enumerate() {
assert!(
s.played.offset <= EXACT && s.over == Over::default(),
"key {key}, water {water}, tick {i}: {:?}",
s.played
);
}
// The client sprints on after the hit only with the key.
let last = steps.last().unwrap().played.state;
assert_eq!(
last & Played::SPRINTING != 0,
key,
"water {water}: {last:b}"
);
}
}
/// A client that sprints on through the server's stop without the sprint key, which vanilla's
/// starts again only with it, is played walking from the tick the stop reaches it; one that
/// holds the key and moves a twentieth of a block a tick too far counts from there too.
#[test]
fn a_sprint_kept_without_the_key_or_sped_up_after_a_hit_counts() {
for water in [false, true] {
let kept = Run {
keep_sprint: true,
..sprint_hit(false, water)
};
let fast = |i: usize, _, honest: Vec3| {
honest.add_vector(Vec3::new(0.0, 0.0, if i >= 14 { 0.05 } else { 0.0 }))
};
for (name, steps) in [
("kept", steps_in(kept, |_, _, honest| honest, None)),
("fast", steps_in(sprint_hit(true, water), fast, None)),
] {
let Some(steps) = steps else {
return;
};
let counted: Vec<usize> = steps
.iter()
.enumerate()
.filter(|(_, s)| s.over.ticks > 0)
.map(|(i, _)| i)
.collect();
assert!(
counted.first().is_some_and(|&i| (14..=16).contains(&i)),
"{name}, water {water}: {counted:?}"
);
assert!(
counted.len() >= 25,
"{name}, water {water}: {} of 36: {counted:?}",
counted.len()
);
}
}
}
/// An elytra: a jump while falling starts a glide, played again exactly.
#[test]
fn a_glide_is_played_again_exactly() {
+183
View File
@@ -8,6 +8,7 @@ use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet},
path::Path,
sync::{Arc, Mutex},
};
/// RecipeManager.RECIPE_PROPERTY_SETS: each set and the ingredients it takes
@@ -170,3 +171,185 @@ pub fn update_recipes(
}
w.data
}
/// The Horizon channel a server sends its clients the recipes its native mods
/// asked for (`native_datapack_sync`), NeoForge's
/// `OnDatapackSyncEvent.sendRecipes`. Horizon clients only.
pub const CONTENT_CHANNEL: &str = "horizon:recipe_content";
/// `ClientboundCustomPayloadPacket`'s cap on a payload, less room for the
/// envelope.
const CONTENT_MAX: usize = 1_048_576 - 1024;
/// One payload of the recipe content: some recipes as `(id, json)`, and on the
/// last one the recipe types they replace on the client (a type the server has
/// no recipe of still empties the client's).
#[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ContentPart {
pub recipes: Vec<(String, Value)>,
pub last: Option<Vec<String>>,
}
/// The payloads for `types`' recipes among `sources` (`Recipes::sources`,
/// vanilla's ids bare), ids made whole (`minecraft:<name>`). With no type
/// asked for: nothing, or with `reset` (this server sent recipes before) one
/// last part with no types, which puts its clients back on their own. A
/// recipe too big for a payload of its own is left out.
pub fn encode_content(types: &[String], sources: &[(String, Value)], reset: bool) -> Vec<Vec<u8>> {
// The last part carries the types: they count against its cap too.
let types_len = serde_json::to_string(types).map_or(0, |t| t.len()) + 32;
if types_len > CONTENT_MAX {
log::warn!("Horizon: too many recipe types to send to clients");
return Vec::new();
}
if types.is_empty() && !reset {
return Vec::new();
}
let mut out = Vec::new();
let mut part: Vec<(String, Value)> = Vec::new();
let mut size = 0;
for (name, v) in sources {
if !types.iter().any(|t| v["type"].as_str() == Some(t)) {
continue;
}
let id = if name.contains(':') {
name.clone()
} else {
format!("minecraft:{name}")
};
let len = id.len() + v.to_string().len() + 8;
if len > CONTENT_MAX {
log::warn!("Horizon: recipe {id} is too big to send to clients");
continue;
}
if size + len > CONTENT_MAX {
let recipes = std::mem::take(&mut part);
out.push(
serde_json::to_vec(&ContentPart {
recipes,
last: None,
})
.unwrap_or_default(),
);
size = 0;
}
size += len;
part.push((id, v.clone()));
}
if size + types_len > CONTENT_MAX {
let recipes = std::mem::take(&mut part);
out.push(
serde_json::to_vec(&ContentPart {
recipes,
last: None,
})
.unwrap_or_default(),
);
}
let last = ContentPart {
recipes: part,
last: Some(types.to_vec()),
};
out.push(serde_json::to_vec(&last).unwrap_or_default());
out
}
/// What the server sends its Horizon clients now (`set_content`).
static CONTENT: Mutex<Option<Arc<Vec<Vec<u8>>>>> = Mutex::new(None);
/// The recipe content the server sends from now on, at a join and after a
/// `/reload` (one server per process, as `lan::sync_data`).
pub fn set_content(payloads: Vec<Vec<u8>>) {
if !payloads.is_empty() {
SENT.store(true, std::sync::atomic::Ordering::Relaxed);
}
*CONTENT.lock().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(payloads));
}
/// Whether this server sent its clients recipes before: a later load that
/// asks for none must put them back on their own (`encode_content`'s reset).
static SENT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub fn sent_before() -> bool {
SENT.load(std::sync::atomic::Ordering::Relaxed)
}
/// The payloads to send a Horizon client; none when no mod asked.
pub fn content() -> Arc<Vec<Vec<u8>>> {
CONTENT
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
.unwrap_or_default()
}
#[cfg(test)]
mod content_tests {
use super::*;
use serde_json::json;
#[test]
fn content_keeps_the_asked_types_and_ends_with_them() {
let sources = vec![
("furnace".to_owned(), json!({"type": "minecraft:smelting"})),
(
"stick".to_owned(),
json!({"type": "minecraft:crafting_shaped"}),
),
("mymod:crush".to_owned(), json!({"type": "mymod:crusher"})),
];
assert!(encode_content(&[], &sources, false).is_empty());
// After a sync that sent recipes, asking for none sends an empty last part.
let reset = encode_content(&[], &sources, true);
assert_eq!(reset.len(), 1);
let part: ContentPart = serde_json::from_slice(&reset[0]).unwrap();
assert!(part.recipes.is_empty());
assert_eq!(part.last.as_deref(), Some(&[][..]));
let types = vec![
"minecraft:smelting".to_owned(),
"mymod:crusher".to_owned(),
"x:none".to_owned(),
];
let parts = encode_content(&types, &sources, false);
assert_eq!(parts.len(), 1);
let part: ContentPart = serde_json::from_slice(&parts[0]).unwrap();
let ids: Vec<&str> = part.recipes.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(ids, ["minecraft:furnace", "mymod:crush"]);
assert_eq!(part.last.as_deref(), Some(types.as_slice()));
}
#[test]
fn content_splits_under_the_payload_cap() {
let big = "x".repeat(300_000);
let sources: Vec<_> = (0..10)
.map(|i| (format!("m:r{i}"), json!({"type": "m:t", "pad": big})))
.collect();
let parts = encode_content(&["m:t".to_owned()], &sources, false);
assert!(parts.len() >= 3);
let mut n = 0;
for (i, p) in parts.iter().enumerate() {
assert!(p.len() <= 1_048_576);
let part: ContentPart = serde_json::from_slice(p).unwrap();
assert_eq!(part.last.is_some(), i + 1 == parts.len());
n += part.recipes.len();
}
assert_eq!(n, 10);
}
#[test]
fn a_long_type_list_keeps_the_last_part_under_the_cap() {
let types: Vec<String> = (0..5000)
.map(|i| format!("mod{i}:type_with_a_long_name"))
.collect();
let big = "x".repeat(1_000_000);
let sources = vec![(
"m:r".to_owned(),
json!({"type": "mod0:type_with_a_long_name", "pad": big}),
)];
let parts = encode_content(&types, &sources, false);
assert_eq!(parts.len(), 2);
assert!(parts.iter().all(|p| p.len() <= 1_048_576));
let last: ContentPart = serde_json::from_slice(&parts[1]).unwrap();
assert!(last.recipes.is_empty());
assert_eq!(last.last.map(|t| t.len()), Some(5000));
}
}
+13 -11
View File
@@ -8,17 +8,18 @@ pub mod arrows;
pub mod block_contact;
pub use horizon_client_render::{
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_entities, block_entity_anim,
block_entity_render, border_render, breaking, brewing, camera, chat, chunk, chunk_map, 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,
block_entity_render, border_render, breaking, brewing, camera, chat, chunk, chunk_map, cit,
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,
};
pub mod attack_strength;
pub mod attribute_updates;
@@ -110,6 +111,7 @@ pub mod save;
pub mod scoreboard_cmd;
pub mod screen_layout;
pub mod screen_narrator;
pub mod social_screen;
pub use horizon_server_files as server;
pub mod server_packs;
pub mod shadows;
+7 -3
View File
@@ -1,7 +1,9 @@
//! Far terrain from a server (`horizon:lod`): a Horizon client playing on
//! another machine's world (a dedicated server, a LAN world) has neither the
//! seed nor the generator its far terrain samples, so the server samples the
//! tiles for it, up to the distance its owner allows (`horizon-lod-distance`).
//! another machine's world has neither the seed nor the generator its far
//! terrain samples, so the server samples the tiles for it, up to the distance
//! its owner allows (`horizon-lod-distance`). Only a dedicated server can offer
//! it for now: an open-to-LAN world has no setting for that distance yet, so
//! its `LanConfig::lod_distance` stays 0.
//!
//! The server offers it with an [`Offer`] payload in the configuration phase,
//! to Horizon clients only (a vanilla client never hears of it). The client
@@ -23,7 +25,9 @@ use horizon_protocol::{
use horizon_world::registry::BlockStateId;
use std::{collections::HashMap, time::Instant};
mod client;
mod server;
pub use client::{Inbox, RemoteSource};
pub use server::{Delivery, Job, Service, Verdict};
/// The custom payload channel, both ways.
+285
View File
@@ -0,0 +1,285 @@
//! The client side of `horizon:lod`: far terrain a server samples, as the
//! `LodSource` of a client of another machine's world. Each sampling thread
//! queues one tile request, which the game thread sends with its own packets
//! ([`Inbox::requests`]), and waits for its reply, which the network thread
//! hands over ([`Inbox::deliver`]), within the server's [`Offer`]. A tile
//! refused, lost or unreadable comes back as no columns: `Lod` asks again.
use super::{Bucket, Offer, Request, SIDE, decode_reply, reply_id};
use crate::lod::{Column, Surface};
use crate::streaming::{LodSampler, LodSource};
use std::{
collections::HashMap,
sync::{
Arc, Condvar, Mutex,
atomic::{AtomicUsize, Ordering},
mpsc,
},
time::{Duration, Instant},
};
/// How long a client waits for a tile before asking again.
const TIMEOUT: Duration = Duration::from_secs(30);
/// A connection's far terrain requests: their replies come in on the network
/// thread ([`Self::deliver`]) and go to the sampler waiting for each. One per
/// connection, so the server's limits hold across the far terrains (one per
/// dimension) a connection has in turn.
#[derive(Default)]
pub struct Inbox {
state: Mutex<InboxState>,
changed: Condvar,
}
#[derive(Default)]
struct InboxState {
/// Requests sent and not answered: when each went, and the sampler
/// waiting for it (None once given up).
waiting: HashMap<u32, (Instant, Option<mpsc::Sender<Vec<u8>>>)>,
/// Requests for the game thread to send, by id.
outbox: Vec<(u32, Vec<u8>)>,
next: u32,
bucket: Option<Bucket>,
/// Bumped by [`Inbox::cancel`]: samplers of an older one stop.
epoch: u64,
}
impl Inbox {
/// A reply from the server: to its sampler, and its slot freed.
pub fn deliver(&self, data: Vec<u8>) {
let Some(id) = reply_id(&data) else {
return;
};
let mut state = self.state.lock().unwrap();
if let Some((_, Some(tx))) = state.waiting.remove(&id) {
let _ = tx.send(data);
}
drop(state);
self.changed.notify_all();
}
/// The requests to send now, from the game thread.
pub fn requests(&self) -> Vec<Vec<u8>> {
let mut state = self.state.lock().unwrap();
state.outbox.drain(..).map(|(_, data)| data).collect()
}
/// The far terrain ends (another dimension, a disconnect): its samplers
/// stop waiting at once. Requests still at the server keep their slot
/// until it answers; those not sent yet never go.
pub fn cancel(&self) {
let mut state = self.state.lock().unwrap();
state.epoch += 1;
for (id, _) in std::mem::take(&mut state.outbox) {
state.waiting.remove(&id);
}
for (_, tx) in state.waiting.values_mut() {
*tx = None;
}
drop(state);
self.changed.notify_all();
}
/// The connection now talks to another server (a proxy's reconfiguration):
/// what the old one was asked can never come back, so its slots and its
/// rate go too. Ids keep increasing, so a late reply matches nothing.
pub fn reset(&self) {
self.cancel();
let mut state = self.state.lock().unwrap();
state.waiting.clear();
state.bucket = None;
drop(state);
self.changed.notify_all();
}
fn epoch(&self) -> u64 {
self.state.lock().unwrap().epoch
}
/// A slot for `request` (its id set), within the offer's limits, once
/// there is one, and the request queued; None once the far terrain of
/// `epoch` was cancelled.
fn send(
&self,
epoch: u64,
offer: Offer,
mut request: Request,
) -> Option<mpsc::Receiver<Vec<u8>>> {
let mut state = self.state.lock().unwrap();
loop {
if state.epoch != epoch {
return None;
}
let now = Instant::now();
// A request the server never answered (a reconfiguration lost
// it) frees its slot in time.
state
.waiting
.retain(|_, (sent, _)| now.duration_since(*sent) < TIMEOUT);
let rate = f64::from(offer.per_second);
let free = state.waiting.len() < offer.in_flight as usize;
let bucket = state
.bucket
.get_or_insert_with(|| Bucket::new(rate, rate, now));
if free && bucket.take(now) {
request.id = state.next;
state.next = state.next.wrapping_add(1);
let (tx, rx) = mpsc::channel();
state.waiting.insert(request.id, (now, Some(tx)));
state.outbox.push((request.id, request.encode()));
return Some(rx);
}
state = self
.changed
.wait_timeout(state, Duration::from_millis(20))
.unwrap()
.0;
}
}
}
/// Far terrain sampled by the server: the `LodSource` of a client of
/// another machine's world.
pub struct RemoteSource {
inbox: Arc<Inbox>,
epoch: u64,
offer: Offer,
/// This client's biome indices by name.
biomes: HashMap<String, u16>,
/// Block states this client knows.
states: u32,
/// Samplers made: no more than requests may wait.
samplers: AtomicUsize,
}
impl RemoteSource {
pub fn new(
inbox: Arc<Inbox>,
offer: Offer,
biomes: HashMap<String, u16>,
states: u32,
) -> Arc<Self> {
Arc::new(Self {
epoch: inbox.epoch(),
inbox,
offer,
biomes,
states,
samplers: AtomicUsize::new(0),
})
}
/// One tile's columns; none when the server refused it, it was lost,
/// or the far terrain ended first: `Lod` asks again later.
fn tile(&self, x: i32, z: i32, stride: i32, approximate: bool) -> Vec<Column> {
let request = Request {
id: 0,
x,
z,
stride,
approximate,
};
let Some(reply) = self.inbox.send(self.epoch, self.offer, request) else {
return Vec::new();
};
let Ok(data) = reply.recv_timeout(TIMEOUT) else {
return Vec::new();
};
match decode_reply(&data, &self.biomes, self.states) {
Ok(columns) => columns.unwrap_or_default(),
Err(e) => {
log::warn!("Horizon: far terrain from the server: {e}");
Vec::new()
}
}
}
}
impl LodSource for RemoteSource {
fn sampler(self: Arc<Self>) -> Box<dyn LodSampler> {
self.samplers.fetch_add(1, Ordering::Relaxed);
Box::new(RemoteSampler {
source: self,
approximate: false,
})
}
}
struct RemoteSampler {
source: Arc<RemoteSource>,
approximate: bool,
}
impl LodSampler for RemoteSampler {
/// Each sampling thread waits for one request: no more threads than the
/// server lets requests wait.
fn fork(&self) -> Option<Box<dyn LodSampler>> {
let samplers = &self.source.samplers;
if samplers.load(Ordering::Relaxed) >= self.source.offer.in_flight as usize {
return None;
}
Some(self.source.clone().sampler())
}
fn set_approximate(&mut self, approximate: bool) {
self.approximate = approximate;
}
fn sample(&mut self, _x: i32, _z: i32) -> Option<Surface> {
None
}
fn grid(&mut self, x: i32, z: i32, stride: i32, side: usize) -> Vec<Column> {
if side != SIDE {
return vec![Column::default(); side * side];
}
self.source.tile(x, z, stride, self.approximate)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lod_net::refusal;
fn request(x: i32) -> Request {
Request {
id: 0,
x,
z: 0,
stride: 1,
approximate: false,
}
}
#[test]
fn a_cancelled_inbox_frees_its_samplers() {
let inbox = Arc::new(Inbox::default());
let offer = Offer::new(64);
let epoch = inbox.epoch();
let sent = inbox.send(epoch, offer, request(0)).unwrap();
let id = Request::decode(&inbox.requests()[0]).unwrap().id;
// Queued, not sent: cancelling forgets it.
let _queued = inbox.send(epoch, offer, request(16)).unwrap();
inbox.cancel();
assert!(sent.recv_timeout(Duration::from_secs(1)).is_err());
assert!(inbox.send(epoch, offer, request(32)).is_none());
assert!(inbox.requests().is_empty());
// The late reply still frees the slot the sent one held.
assert_eq!(inbox.state.lock().unwrap().waiting.len(), 1);
inbox.deliver(refusal(id));
assert!(inbox.state.lock().unwrap().waiting.is_empty());
// A reconfiguration frees the slots still at the old server.
let epoch = inbox.epoch();
assert!(inbox.send(epoch, offer, request(48)).is_some());
assert_eq!(inbox.requests().len(), 1);
inbox.reset();
let state = inbox.state.lock().unwrap();
assert!(state.waiting.is_empty() && state.outbox.is_empty() && state.bucket.is_none());
drop(state);
}
#[test]
fn a_refused_tile_is_asked_again() {
let inbox = Arc::new(Inbox::default());
let source = RemoteSource::new(inbox.clone(), Offer::new(64), HashMap::new(), 10);
let mut sampler = source.clone().sampler();
let server = std::thread::spawn(move || {
loop {
if let Some(data) = inbox.requests().pop() {
let id = Request::decode(&data).unwrap().id;
inbox.deliver(refusal(id));
return;
}
std::thread::sleep(Duration::from_millis(1));
}
});
// No columns, not 256 empty ones: Lod doesn't keep it.
assert!(sampler.grid(0, 0, 1, SIDE).is_empty());
server.join().unwrap();
}
}
+18
View File
@@ -21,6 +21,7 @@ pub mod breeze;
pub mod camel;
pub mod caravan;
pub mod cem;
pub mod cem_anim;
pub mod combat;
pub mod control;
pub mod conversion;
@@ -50,6 +51,7 @@ pub mod illager;
pub mod illusioner;
pub mod item_break;
pub mod keyframes;
pub mod landing;
pub mod leash;
pub mod living;
pub mod llama;
@@ -65,6 +67,7 @@ pub mod piglin;
pub mod polar_bear;
pub mod rabbit;
pub mod raider;
pub mod random_textures;
pub mod ravager;
pub mod render;
pub mod riders;
@@ -1188,6 +1191,8 @@ pub struct Mob {
/// A mannequin's `DATA_PROFILE` (ResolvableProfile): the profile's
/// `textures` property resolves to `skin` like a player's.
pub profile: Option<horizon_protocol::entity_data::Profile>,
/// The packs' random texture variants it wears (`random_textures::choose`).
pub random_textures: random_textures::Choice,
}
/// A mannequin's `description` and `hide_description` (`Mannequin.updateDescription`):
/// the default `entity.minecraft.mannequin.label`, none at all, or a component.
@@ -1394,6 +1399,16 @@ pub enum MobEvent {
ElderCurse {
at: [f64; 3],
},
/// `Entity.setDeltaMovement`: replace a target's velocity.
SetVelocity {
target: Who,
velocity: [f64; 3],
},
/// `Entity.remove(RemovalReason.KILLED)` of a mob that is no longer alive: it vanishes at once,
/// with no death animation (`ShootTongue.eatEntity`).
RemoveKilled {
target: u32,
},
/// `Entity.push`: add to a target's velocity.
Push {
target: Who,
@@ -2344,6 +2359,8 @@ pub struct MobWorld<'a> {
pub thundering: bool,
pub nether: bool,
pub block_tags: &'a HashMap<String, std::collections::HashSet<String>>,
/// Each block's `fallOn` rule (the player's landing registry).
pub landing: &'a horizon_world::block_landing::BlockLandingRegistry,
pub biomes: &'a crate::biome::Biomes,
/// The `mob_griefing` game rule.
pub mob_griefing: bool,
@@ -2466,6 +2483,7 @@ impl Mob {
slim: false,
cape: None,
profile: None,
random_textures: Default::default(),
attrs: Attrs {
speed: kind.speed,
attack_damage: kind.attack_damage,
+14
View File
@@ -765,6 +765,20 @@ fn effects_from_blocks(
m.damage(1.0, Damage::of(DamageKind::SweetBerryBush));
}
}
// PowderSnowBlock.entityInside: a living entity is stuck
// (and forgets its fall) only while the block at its
// own feet is powder snow.
"minecraft:powder_snow"
if w.block_name(
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
) == "minecraft:powder_snow" =>
{
m.fall_distance = 0.0;
m.stuck_multiplier =
Vec3::new(f64::from(0.9_f32), 1.5, f64::from(0.9_f32));
}
"minecraft:honey_block" => honey_slide(m, p, level_random, events),
_ => continue,
}
Loaded 100 of 208 files, more files were not shown because too many files have changed in this diff. Show more