mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
commit
54cbbadbb7
130 files changed
+8480
-351
No files matched your search
@@ -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.
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ description = "The client's world data, rendering and screens: chunks, meshing,
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-client-input = { path = "../horizon-client-input" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
@@ -18,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" }
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -142,6 +142,7 @@ pub mod panorama;
|
||||
mod readback;
|
||||
mod resident;
|
||||
mod stars;
|
||||
pub mod submit;
|
||||
mod sunrise_fan;
|
||||
mod timing;
|
||||
pub mod translucency;
|
||||
@@ -662,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>,
|
||||
@@ -684,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
|
||||
@@ -2083,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",
|
||||
@@ -3402,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,
|
||||
@@ -3416,6 +3459,7 @@ impl Renderer {
|
||||
flat,
|
||||
sky,
|
||||
dark_disc,
|
||||
depth_reset,
|
||||
lines,
|
||||
lines_secondary,
|
||||
debug_lines,
|
||||
@@ -3575,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) {
|
||||
@@ -3622,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()
|
||||
@@ -3629,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(());
|
||||
}
|
||||
@@ -4349,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,
|
||||
@@ -4988,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`),
|
||||
@@ -6235,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
|
||||
@@ -7303,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
|
||||
@@ -7464,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,
|
||||
@@ -7488,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());
|
||||
@@ -7684,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);
|
||||
@@ -7840,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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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, ©, 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(©, 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
@@ -18,5 +18,11 @@
|
||||
"horizon.multiplayer.not_signed_in": "Ten serwer działa w trybie online. Zaloguj się kontem Microsoft w launcherze Horizon, aby dołączyć.",
|
||||
"horizon.multiplayer.invalid_address": "Nieprawidłowy adres serwera: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Połączono bezpośrednio ze znajomym…",
|
||||
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…"
|
||||
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…",
|
||||
"horizon.pages.shader_options": "Ustawienia shaderów",
|
||||
"horizon.pages.split_profiles": "Kto gra?",
|
||||
"horizon.pages.split_profiles.tooltip": "Kto gra: profil przechowywany w tym folderze gry.",
|
||||
"horizon.shader_options.open": "Ustawienia shaderów…",
|
||||
"horizon.shader_options.profile": "Profil",
|
||||
"horizon.shader_options.loading": "Wczytywanie ustawień paczki shaderów…"
|
||||
}
|
||||
@@ -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…"
|
||||
}
|
||||
@@ -18,5 +18,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": "Читання налаштувань набору шейдерів…"
|
||||
}
|
||||
@@ -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": "正在读取光影包的选项…"
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -270,6 +270,7 @@ mod shield;
|
||||
mod shoulders;
|
||||
mod sign_edit;
|
||||
mod sniffers;
|
||||
mod social;
|
||||
mod spawner;
|
||||
mod spear;
|
||||
mod spectator;
|
||||
@@ -762,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).
|
||||
@@ -798,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",
|
||||
@@ -2200,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,
|
||||
@@ -2622,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,
|
||||
@@ -2691,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,
|
||||
@@ -3693,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;
|
||||
@@ -3717,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,
|
||||
@@ -3724,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
|
||||
@@ -5865,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);
|
||||
}
|
||||
}
|
||||
@@ -7514,6 +7536,7 @@ impl Game<'_> {
|
||||
| Screen::Advancements
|
||||
| Screen::WorldOptions
|
||||
| Screen::Friends
|
||||
| Screen::Social
|
||||
| Screen::ServerLinks
|
||||
| Screen::ConfirmLink
|
||||
| Screen::DialogWarning
|
||||
@@ -8213,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),
|
||||
@@ -11499,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();
|
||||
@@ -13853,6 +13878,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");
|
||||
@@ -13948,6 +13975,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,
|
||||
@@ -13978,10 +14008,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);
|
||||
}
|
||||
@@ -13995,10 +14022,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);
|
||||
}
|
||||
@@ -15297,6 +15321,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.
|
||||
@@ -15339,6 +15364,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 => {
|
||||
@@ -15873,7 +15903,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.
|
||||
@@ -15976,6 +16020,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();
|
||||
@@ -16242,6 +16287,11 @@ 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
|
||||
@@ -16344,7 +16394,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();
|
||||
}
|
||||
@@ -16444,10 +16498,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() {
|
||||
@@ -16469,6 +16541,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);
|
||||
}
|
||||
@@ -16718,6 +16793,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()
|
||||
}
|
||||
@@ -16875,6 +16953,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)
|
||||
}
|
||||
@@ -17454,6 +17535,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,
|
||||
@@ -17708,6 +17790,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,
|
||||
@@ -17754,6 +17837,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(
|
||||
@@ -18118,7 +18202,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 {
|
||||
@@ -18191,6 +18275,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.
|
||||
@@ -20160,6 +20246,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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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).
|
||||
@@ -6846,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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 {
|
||||
@@ -3252,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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1186,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
|
||||
@@ -1324,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(
|
||||
@@ -1343,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) {
|
||||
@@ -1761,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
|
||||
@@ -1904,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)
|
||||
@@ -2449,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
|
||||
@@ -3747,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 &[];
|
||||
@@ -3806,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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(¤t)
|
||||
self.piglin_wanted(m, item) && !self.piglin_wanted(m, ¤t)
|
||||
}
|
||||
/// `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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -2874,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;
|
||||
@@ -2881,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.
|
||||
@@ -4365,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) => {
|
||||
@@ -6337,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);
|
||||
@@ -7085,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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -51,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;
|
||||
@@ -1398,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,
|
||||
@@ -2348,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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -549,9 +549,10 @@ impl Goal for ShootTongue {
|
||||
cx.events
|
||||
.push(MobEvent::Sound("entity.frog.tongue", here, 2.0, 1.0));
|
||||
set_pose(m, sync::USING_TONGUE);
|
||||
// The target is drawn in at 0.75 a tick.
|
||||
// `target.setDeltaMovement(vectorTo(frog).normalize().scale(0.75))`: the
|
||||
// target's own motion is replaced, and it is drawn in.
|
||||
let pull = m.position.subtract(pos).normalize().scale(0.75);
|
||||
cx.events.push(MobEvent::Push {
|
||||
cx.events.push(MobEvent::SetVelocity {
|
||||
target: Who::Mob(id),
|
||||
velocity: [pull.x, pull.y, pull.z],
|
||||
});
|
||||
@@ -574,6 +575,8 @@ impl Goal for ShootTongue {
|
||||
cx.events
|
||||
.push(MobEvent::Sound("entity.frog.eat", here, 2.0, 1.0));
|
||||
super::ai::do_hurt_target(m, Who::Mob(id), cx);
|
||||
// `if (!target.isAlive()) target.remove(KILLED)`: no death animation.
|
||||
cx.events.push(MobEvent::RemoveKilled { target: id });
|
||||
}
|
||||
}
|
||||
Phase::EatAnimation => {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
//! PURPOSE: a mob landing on a block runs that block's registered `Block.fallOn` rule (hay, honey, slime, powder snow, bed, stalagmite, farmland, turtle egg), as the player already does in landing.rs.
|
||||
//! WHY: mobs used to take the whole fall whatever block they landed on (a pig falling 8 blocks onto powder snow lost 6 health; vanilla loses none).
|
||||
//! REFS: Block.fallOn and its overrides (HayBlock, HoneyBlock, SlimeBlock, PowderSnowBlock, BedBlock, PointedDripstoneBlock, FarmlandBlock, TurtleEggBlock); crate::landing (the player's adapter); horizon_world::block_landing.
|
||||
//! LOGS: none; the block writes and sounds go out as MobEvents.
|
||||
//! READS-BY: mobs::living::Mob::fall_on_block.
|
||||
//! ENDS-WHEN: the landing registry covers a mob's whole fall and this adapter is merged into crate::landing.
|
||||
use super::{DamageKind, Mob, MobEvent, MobWorld};
|
||||
use crate::collisions::SceneCollisions;
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3, VoxelShape},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_world::{
|
||||
block_entity_push::BlockPushWorld,
|
||||
block_landing::{
|
||||
BlockLandingWorld, DamageSourceOwner, LandingDamage, LandingEvent, LandingSound,
|
||||
},
|
||||
entity_obstruction::EntityIdentity,
|
||||
registry::BlockStateId,
|
||||
};
|
||||
|
||||
/// A mob mid-landing: the block under it is at `pos`, the fall was `distance`.
|
||||
pub(super) struct MobLanding<'a, 'w> {
|
||||
pub mob: &'a mut Mob,
|
||||
pub w: &'a MobWorld<'w>,
|
||||
pub events: &'a mut Vec<MobEvent>,
|
||||
pub pos: [i32; 3],
|
||||
}
|
||||
impl MobLanding<'_, '_> {
|
||||
fn at(&self) -> [f64; 3] {
|
||||
let p = self.mob.position;
|
||||
[p.x, p.y, p.z]
|
||||
}
|
||||
}
|
||||
impl BlockPushWorld for MobLanding<'_, '_> {
|
||||
type Error = String;
|
||||
fn collision_shape(&mut self, state: BlockStateId, p: BlockPos) -> Result<VoxelShape, String> {
|
||||
let mut scene = SceneCollisions {
|
||||
world: self.w.world,
|
||||
binding: self.w.collision,
|
||||
subject: None,
|
||||
};
|
||||
self.w
|
||||
.collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
// Only the landing mob is lifted off a block that grows under it.
|
||||
fn entities(&mut self, bounds: Aabb) -> Result<Vec<EntityIdentity>, String> {
|
||||
Ok(if bounds.intersects(self.mob.bounds()) {
|
||||
vec![EntityIdentity(0)]
|
||||
} else {
|
||||
vec![]
|
||||
})
|
||||
}
|
||||
fn entity_bounds(&self, _: EntityIdentity) -> Aabb {
|
||||
self.mob.bounds()
|
||||
}
|
||||
fn teleport_relative(&mut self, _: EntityIdentity, m: Vec3) -> Result<(), String> {
|
||||
self.mob.position = Vec3::new(
|
||||
self.mob.position.x + m.x,
|
||||
self.mob.position.y + m.y,
|
||||
self.mob.position.z + m.z,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl BlockLandingWorld for MobLanding<'_, '_> {
|
||||
type DamageSource = DamageKind;
|
||||
/// The sound event, or none for a sound a mob's landing never makes.
|
||||
type Sound = Option<&'static str>;
|
||||
fn is_server_level(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn is_client_side(&mut self) -> Result<bool, String> {
|
||||
Ok(false)
|
||||
}
|
||||
fn random_float(&mut self) -> Result<f32, String> {
|
||||
Ok(self.mob.random.next_float())
|
||||
}
|
||||
fn random_int(&mut self, bound: i32) -> Result<i32, String> {
|
||||
self.mob
|
||||
.random
|
||||
.next_int_bound(bound)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
fn under_spawn_protection(&mut self, _: BlockPos) -> Result<bool, String> {
|
||||
Ok(false)
|
||||
}
|
||||
fn mob_griefing(&mut self) -> Result<bool, String> {
|
||||
Ok(self.w.mob_griefing)
|
||||
}
|
||||
fn entity_dimensions(&self) -> (f32, f32) {
|
||||
(self.mob.width(), self.mob.height())
|
||||
}
|
||||
// A mob never sneaks, so nothing suppresses a slime block's bounce.
|
||||
fn suppressing_bounce(&mut self) -> Result<bool, String> {
|
||||
Ok(false)
|
||||
}
|
||||
fn damage_source(
|
||||
&mut self,
|
||||
_: DamageSourceOwner,
|
||||
kind: LandingDamage,
|
||||
) -> Result<DamageKind, String> {
|
||||
Ok(match kind {
|
||||
LandingDamage::Fall => DamageKind::Fall,
|
||||
LandingDamage::Stalagmite => DamageKind::Stalagmite,
|
||||
})
|
||||
}
|
||||
fn cause_fall_damage(
|
||||
&mut self,
|
||||
distance: f64,
|
||||
multiplier: f32,
|
||||
source: DamageKind,
|
||||
) -> Result<bool, String> {
|
||||
let contact = Some((self.w, self.pos));
|
||||
Ok(self
|
||||
.mob
|
||||
.cause_fall_damage_at(distance, multiplier, self.events, contact, source))
|
||||
}
|
||||
fn known_sound(&self, sound: LandingSound) -> Option<&'static str> {
|
||||
match sound {
|
||||
LandingSound::HoneySlide => Some("block.honey_block.slide"),
|
||||
LandingSound::HoneyFall => Some("block.honey_block.fall"),
|
||||
LandingSound::TurtleEggBreak => Some("block.turtle_egg.break"),
|
||||
LandingSound::ShelfMushroomBounce => None,
|
||||
}
|
||||
}
|
||||
// LivingEntity.getFallDamageSound: Monster.getFallSounds is the hostile pair.
|
||||
fn living_fall_sound(&mut self, big: bool) -> Result<Option<&'static str>, String> {
|
||||
let monster = super::ai::is_monster(self.mob.species());
|
||||
Ok(Some(match (monster, big) {
|
||||
(true, true) => "entity.hostile.big_fall",
|
||||
(true, false) => "entity.hostile.small_fall",
|
||||
(false, true) => "entity.generic.big_fall",
|
||||
(false, false) => "entity.generic.small_fall",
|
||||
}))
|
||||
}
|
||||
fn entity_sound(
|
||||
&mut self,
|
||||
sound: Option<&'static str>,
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
) -> Result<(), String> {
|
||||
if let Some(name) = sound
|
||||
&& !self.mob.silent
|
||||
{
|
||||
let at = self.at();
|
||||
self.events.push(MobEvent::Sound(name, at, volume, pitch));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn block_sound(
|
||||
&mut self,
|
||||
position: BlockPos,
|
||||
sound: Option<&'static str>,
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
) -> Result<(), String> {
|
||||
if let Some(name) = sound {
|
||||
let at = [
|
||||
f64::from(position.x) + 0.5,
|
||||
f64::from(position.y) + 0.5,
|
||||
f64::from(position.z) + 0.5,
|
||||
];
|
||||
self.events.push(MobEvent::Sound(name, at, volume, pitch));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn broadcast_entity_event(&mut self, event: u8) -> Result<(), String> {
|
||||
let at = self.at();
|
||||
self.events.push(MobEvent::EntityEvent {
|
||||
mob: self.mob.id,
|
||||
event: event as i8,
|
||||
at,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
fn destroy_block(&mut self, position: BlockPos, _drops: bool) -> Result<bool, String> {
|
||||
let air = self
|
||||
.w
|
||||
.registry
|
||||
.default_state("minecraft:air")
|
||||
.ok_or("missing air")?;
|
||||
self.set_block(position, air, 3)
|
||||
}
|
||||
fn set_block(
|
||||
&mut self,
|
||||
position: BlockPos,
|
||||
state: BlockStateId,
|
||||
_: u32,
|
||||
) -> Result<bool, String> {
|
||||
self.events.push(MobEvent::SetBlock {
|
||||
pos: [position.x, position.y, position.z],
|
||||
state,
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
// Neighbour updates and the break particles ride on the host's SetBlock.
|
||||
fn block_event(
|
||||
&mut self,
|
||||
_: LandingEvent,
|
||||
_: BlockPos,
|
||||
_: BlockStateId,
|
||||
_: bool,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
fn level_event(&mut self, _: i32, _: BlockPos, _: i32) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use horizon_core::{
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_world::{
|
||||
block_landing::LandingEntityKind,
|
||||
entity_fluid::{CurrentAccumulator, CurrentTarget},
|
||||
fluid::{FluidKind, FluidState},
|
||||
fluid_flow::{FluidFlowWorld, flow_at},
|
||||
@@ -652,36 +653,11 @@ impl Mob {
|
||||
}
|
||||
if self.on_ground {
|
||||
if self.fall_distance > 0.0 {
|
||||
self.trample_farmland(w, events);
|
||||
self.cause_fall_damage(w, events);
|
||||
self.fall_on_block(w, events);
|
||||
}
|
||||
self.fall_distance = 0.0;
|
||||
}
|
||||
}
|
||||
/// `FarmlandBlock.fallOn`: a landing on farmland turns it to dirt with
|
||||
/// chance `fallDistance - 0.5`, if mobs may grief and the mob's
|
||||
/// `width * width * height` is over 0.512. `Block.pushEntitiesUp` lifts
|
||||
/// the mob onto the dirt, which is a full block.
|
||||
fn trample_farmland(&mut self, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
let p = self.block_on_legacy(w);
|
||||
if w.block_name(p[0], p[1], p[2]) != "minecraft:farmland" {
|
||||
return;
|
||||
}
|
||||
let roll = f64::from(self.random.next_float());
|
||||
let Some(dirt) = w.registry.default_state("minecraft:dirt") else {
|
||||
return;
|
||||
};
|
||||
if roll < self.fall_distance - 0.5
|
||||
&& w.mob_griefing
|
||||
&& self.width() * self.width() * self.height() > 0.512
|
||||
{
|
||||
events.push(MobEvent::SetBlock {
|
||||
pos: p,
|
||||
state: dirt,
|
||||
});
|
||||
self.position.y = self.position.y.max(f64::from(p[1]) + 1.0);
|
||||
}
|
||||
}
|
||||
/// Entity.updateFluidInteraction: the fluid state, then touching water
|
||||
/// resets the fall and entering it (after the first tick) splashes.
|
||||
pub(super) fn update_fluids(&mut self, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
@@ -894,11 +870,45 @@ impl Mob {
|
||||
self.position.z.floor() as i32,
|
||||
]
|
||||
}
|
||||
/// The landing of `check_fall_damage`: the whole fall, with no block
|
||||
/// softening it.
|
||||
fn cause_fall_damage(&mut self, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
/// `Block.fallOn` of the block under the mob (`Entity.checkFallDamage`):
|
||||
/// the block's own rule (hay, honey, slime, powder snow, a bed, a
|
||||
/// stalagmite, farmland, a turtle egg) decides how much of the fall hurts.
|
||||
fn fall_on_block(&mut self, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
let pos = self.block_on_legacy(w);
|
||||
self.cause_fall_damage_at(self.fall_distance, 1.0, events, Some((w, pos)));
|
||||
let state = w.world.block(pos[0], pos[1], pos[2]);
|
||||
let species = self.species();
|
||||
let entity = if super::doors::is_zombie(species) {
|
||||
LandingEntityKind::Zombie
|
||||
} else if species == super::Species::Turtle {
|
||||
LandingEntityKind::Turtle
|
||||
} else if species == super::Species::Bat {
|
||||
LandingEntityKind::Bat
|
||||
} else {
|
||||
LandingEntityKind::Living
|
||||
};
|
||||
let distance = self.fall_distance;
|
||||
let result = w.landing.fall_on(
|
||||
&mut super::landing::MobLanding {
|
||||
mob: self,
|
||||
w,
|
||||
events,
|
||||
pos,
|
||||
},
|
||||
state,
|
||||
BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
entity,
|
||||
distance,
|
||||
);
|
||||
// A block outside the landing registry falls back to the plain fall.
|
||||
if result.is_err() {
|
||||
self.cause_fall_damage_at(
|
||||
distance,
|
||||
1.0,
|
||||
events,
|
||||
Some((w, pos)),
|
||||
super::DamageKind::Fall,
|
||||
);
|
||||
}
|
||||
}
|
||||
fn mod_block_fall(
|
||||
&self,
|
||||
@@ -931,15 +941,17 @@ impl Mob {
|
||||
modifier: f32,
|
||||
events: &mut Vec<MobEvent>,
|
||||
) {
|
||||
self.cause_fall_damage_at(distance, modifier, events, None);
|
||||
self.cause_fall_damage_at(distance, modifier, events, None, super::DamageKind::Fall);
|
||||
}
|
||||
fn cause_fall_damage_at(
|
||||
/// True when the fall hurt (`causeFallDamage`'s result).
|
||||
pub(super) fn cause_fall_damage_at(
|
||||
&mut self,
|
||||
distance: f64,
|
||||
modifier: f32,
|
||||
events: &mut Vec<MobEvent>,
|
||||
contact: Option<(&MobWorld, [i32; 3])>,
|
||||
) {
|
||||
source: super::DamageKind,
|
||||
) -> bool {
|
||||
// Breeze.causeFallDamage: a landing from higher than three blocks.
|
||||
if self.species() == super::Species::Breeze && distance > 3.0 {
|
||||
let at = [self.position.x, self.position.y, self.position.z];
|
||||
@@ -970,7 +982,7 @@ impl Mob {
|
||||
// to those on it.
|
||||
if self.kind.flags.fall_immune {
|
||||
self.mod_block_fall(contact, distance, 0.0);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let power = distance + 1.0e-6 - self.kind.safe_fall_distance;
|
||||
let mut damage = (power * f64::from(modifier) * self.kind.fall_damage_multiplier).floor();
|
||||
@@ -990,7 +1002,7 @@ impl Mob {
|
||||
// Llama.causeFallDamage: only a fall of six blocks or more hurts the
|
||||
// llama and its riders.
|
||||
if super::llama::is_llama(self.species()) && distance < 6.0 {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let at = [self.position.x, self.position.y, self.position.z];
|
||||
// Monster.getFallSounds: the hostile pair.
|
||||
@@ -1005,11 +1017,13 @@ impl Mob {
|
||||
if !horse {
|
||||
events.push(MobEvent::Sound(sound, at, 1.0, 1.0));
|
||||
}
|
||||
self.damage(damage as f32, super::Damage::fall());
|
||||
self.damage(damage as f32, super::Damage::of(source));
|
||||
if horse {
|
||||
self.fall_to_passengers(distance, modifier, events);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `Entity.propagateFallToPassengers`: the game gives the fall to each
|
||||
/// passenger (`causeFallDamage`), a mob or a player, for this mob is
|
||||
@@ -1632,7 +1646,9 @@ mod tests {
|
||||
use super::*;
|
||||
/// A pig dropped hard onto a block bounces by that block's restitution
|
||||
/// (`Entity.getBlockBounciness`): landing on a slime block sends it back
|
||||
/// up, landing on stone stops it dead.
|
||||
/// up, landing on stone stops it dead. The same 8-block fall then costs the
|
||||
/// pig what the block's own `fallOn` allows (`Block.fallOn`): all 5 on
|
||||
/// stone, 1 on hay or honey, 0 on slime, 1 on a bed, and a stalagmite kills.
|
||||
#[test]
|
||||
fn a_mob_bounces_off_a_bouncy_block_and_not_off_stone() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -1659,7 +1675,14 @@ mod tests {
|
||||
.unwrap();
|
||||
let collision = crate::collisions::load(&root, registry).unwrap();
|
||||
let lid_openers = std::collections::HashMap::new();
|
||||
for (ground, bounces) in [("minecraft:slime_block", true), ("minecraft:stone", false)] {
|
||||
for (ground, bounces, health) in [
|
||||
("minecraft:slime_block", true, 10.0),
|
||||
("minecraft:stone", false, 5.0),
|
||||
("minecraft:hay_block", false, 9.0),
|
||||
("minecraft:honey_block", false, 9.0),
|
||||
("minecraft:red_bed", true, 9.0),
|
||||
("minecraft:pointed_dripstone", false, 0.0),
|
||||
] {
|
||||
let air = registry.default_state("minecraft:air").unwrap();
|
||||
let mut world = crate::world::World::new(air, 0);
|
||||
world.chunks.insert(
|
||||
@@ -1695,6 +1718,7 @@ mod tests {
|
||||
thundering: false,
|
||||
nether: false,
|
||||
block_tags: &items.block_tags,
|
||||
landing: &motion.landing,
|
||||
biomes: &biomes,
|
||||
universal_anger: false,
|
||||
mob_griefing: true,
|
||||
@@ -1712,7 +1736,9 @@ mod tests {
|
||||
let mut m = Mob::new(1, kind, Vec3::new(0.5, 1.0, 0.5), 0.0);
|
||||
// Falling faster than gravity, so the landing is hard enough.
|
||||
m.velocity = Vec3::new(0.0, -1.0, 0.0);
|
||||
m.fall_distance = 8.0;
|
||||
m.do_move(&w, &mut Vec::new());
|
||||
assert_eq!(m.health, health, "{ground}: health after an 8 block fall");
|
||||
assert!(
|
||||
m.vertical_collision_below,
|
||||
"{ground}: the pig lands on the block"
|
||||
|
||||
@@ -2259,6 +2259,17 @@ mod tests {
|
||||
.custom_name()
|
||||
.is_none()
|
||||
);
|
||||
// An admiring piglin holds gold beside its crossbow. Both the item
|
||||
// id and the model components must come from the appropriate hand.
|
||||
extra.off_hand = Some("minecraft:gold_ingot".into());
|
||||
assert_eq!(
|
||||
&*held_item_stack(&extra, true).unwrap().item,
|
||||
"minecraft:gold_ingot"
|
||||
);
|
||||
assert_eq!(
|
||||
&*held_item_stack(&extra, false).unwrap().item,
|
||||
"minecraft:crossbow"
|
||||
);
|
||||
extra.off_hand = None;
|
||||
assert!(held_item_stack(&extra, true).is_none());
|
||||
}
|
||||
|
||||
@@ -452,6 +452,7 @@ pub(crate) fn with_built_test_world(
|
||||
thundering: false,
|
||||
nether: false,
|
||||
block_tags: &items.block_tags,
|
||||
landing: &motion.landing,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
universal_anger: false,
|
||||
|
||||
@@ -215,6 +215,7 @@ mod tests {
|
||||
thundering: false,
|
||||
nether: false,
|
||||
block_tags: &items.block_tags,
|
||||
landing: &motion.landing,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
universal_anger: false,
|
||||
|
||||
@@ -324,7 +324,7 @@ impl Book {
|
||||
Recipe::Special(special) => {
|
||||
for shown in special.book_displays() {
|
||||
entries.push(Entry {
|
||||
id: format!("minecraft:{name}"),
|
||||
id: crate::crafting::recipe_id(name),
|
||||
display_id: -1,
|
||||
category: crafting_category(&category),
|
||||
group: group(&grp),
|
||||
@@ -338,7 +338,7 @@ impl Book {
|
||||
}
|
||||
};
|
||||
entries.push(Entry {
|
||||
id: format!("minecraft:{name}"),
|
||||
id: crate::crafting::recipe_id(name),
|
||||
display_id: -1,
|
||||
category: crafting_category(&category),
|
||||
group: group(&grp),
|
||||
@@ -359,7 +359,7 @@ impl Book {
|
||||
_ => continue,
|
||||
};
|
||||
entries.push(Entry {
|
||||
id: format!("minecraft:{}", c.id),
|
||||
id: crate::crafting::recipe_id(&c.id),
|
||||
display_id: -1,
|
||||
category,
|
||||
group: group(&c.group),
|
||||
|
||||
@@ -184,7 +184,8 @@ impl SessionLock {
|
||||
/// (`FileFixerUpper`), then the DataFixers upgrades of every chunk, entity chunk,
|
||||
/// POI chunk, player, saved data, advancements and statistics file. Vanilla runs
|
||||
/// the latter as it reads each file; Horizon runs them all on opening, which reads
|
||||
/// the same data. Worlds older than the ported fixes (1.17) are refused.
|
||||
/// the same data, and region records still older in a world whose level.dat is
|
||||
/// current are upgraded as they are read (`upgrade_record`).
|
||||
pub fn upgrade_older_world(world: &Path) -> Result<()> {
|
||||
upgrade_older_world_with(
|
||||
world,
|
||||
@@ -2154,10 +2155,18 @@ fn resolve_poi(
|
||||
let Record::Poi(chunk, tables) = record else {
|
||||
return Some(record.clone());
|
||||
};
|
||||
let old = read_region_record(root, cache, "poi", x, z).unwrap_or_else(|e| {
|
||||
log::warn!("Horizon: could not read the poi record of chunk {x} {z}: {e}");
|
||||
None
|
||||
});
|
||||
let old = read_region_record(root, cache, "poi", x, z)
|
||||
.and_then(|old| {
|
||||
old.map(|mut old| {
|
||||
upgrade_record(Folder::Poi, &mut old, || None)?;
|
||||
Ok(old)
|
||||
})
|
||||
.transpose()
|
||||
})
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("Horizon: could not read the poi record of chunk {x} {z}: {e}");
|
||||
None
|
||||
});
|
||||
let tag = match (encode_poi(chunk, tables), old) {
|
||||
(Some(mut tag), Some(old)) => {
|
||||
keep_claims(&mut tag, &old);
|
||||
@@ -2190,6 +2199,73 @@ fn read_region_record(
|
||||
None => None,
|
||||
})
|
||||
}
|
||||
/// The `__context` the chunk fixes read: the dimension and its generator type
|
||||
/// (`ChunkMap.getChunkDataFixContextTag`).
|
||||
struct DatafixContext {
|
||||
dimension: String,
|
||||
generator: Option<String>,
|
||||
}
|
||||
impl DatafixContext {
|
||||
/// From a dimension folder made by [`dimension_dir`] and the world's
|
||||
/// `world_gen_settings.dat`; None for a folder outside a world's `dimensions/`.
|
||||
fn of(root: &Path) -> Option<Self> {
|
||||
let mut parts = Vec::new();
|
||||
let mut at = root;
|
||||
while at.file_name()? != "dimensions" {
|
||||
parts.push(at.file_name()?.to_str()?);
|
||||
at = at.parent()?;
|
||||
}
|
||||
let world = at.parent()?;
|
||||
let namespace = parts.pop()?;
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
parts.reverse();
|
||||
let dimension = format!("{namespace}:{}", parts.join("/"));
|
||||
let generator = horizon_world::datafix::files::generators(world)
|
||||
.ok()
|
||||
.and_then(|g| g.into_iter().find(|(d, _)| *d == dimension))
|
||||
.map(|(_, g)| g);
|
||||
Some(Self {
|
||||
dimension,
|
||||
generator,
|
||||
})
|
||||
}
|
||||
}
|
||||
/// `SimpleRegionStorage.upgradeChunkTag`, as `ChunkMap.upgradeChunkTag` (chunks),
|
||||
/// `EntityStorage` and `SectionStorage.PackedChunk.parse` (POI) call it on every
|
||||
/// record they read: a world 26.3 has opened keeps the chunks nobody loaded since
|
||||
/// an older version in that version's format, and `upgrade_older_world` only runs
|
||||
/// for an older level.dat. A record without a DataVersion counts as -1 (POI: 1945).
|
||||
/// `context` is needed only for an older chunk; without one it stays as saved.
|
||||
fn upgrade_record<'a>(
|
||||
folder: Folder,
|
||||
record: &mut NamedTag,
|
||||
context: impl FnOnce() -> Option<&'a DatafixContext>,
|
||||
) -> Result<()> {
|
||||
use horizon_world::datafix::{self, schema::Ty};
|
||||
let default = if folder == Folder::Poi { 1945 } else { -1 };
|
||||
if number(record.tag.get("DataVersion")).unwrap_or(default) >= i64::from(DATA_VERSION) {
|
||||
return Ok(());
|
||||
}
|
||||
let context = match folder {
|
||||
Folder::Region => match context() {
|
||||
Some(c) => Some(c),
|
||||
None => return Ok(()),
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
let tag = &mut record.tag;
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match (folder, context) {
|
||||
(Folder::Region, Some(c)) => {
|
||||
datafix::update_chunk(tag, &c.dimension, c.generator.as_deref());
|
||||
}
|
||||
(Folder::Entities, _) => datafix::update_region_tag(Ty::EntityChunk, tag),
|
||||
(Folder::Poi, _) => datafix::update_region_tag(Ty::PoiChunk, tag),
|
||||
(Folder::Region, None) => {}
|
||||
}))
|
||||
.map_err(|_| "upgrading an older record failed".into())
|
||||
}
|
||||
/// Chunk and entity records waiting for the writer thread, by (entities?,
|
||||
/// x, z): reads see them before the region files do.
|
||||
type PendingWrites = Mutex<HashMap<(Folder, i32, i32), (u64, Record)>>;
|
||||
@@ -2252,6 +2328,8 @@ pub struct SaveDir {
|
||||
/// Saved chunks decoded for far terrain, shared by its sampling threads
|
||||
/// (`lod_surfaces_shared`), oldest first.
|
||||
lod_decoded: Mutex<LodDecoded>,
|
||||
/// What older chunks are upgraded with, found on the first one read.
|
||||
datafix_context: OnceLock<Option<DatafixContext>>,
|
||||
}
|
||||
fn is_forced_ticket(ticket: &Tag) -> bool {
|
||||
matches!(ticket.get("type"), Some(Tag::String(t)) if *t == JavaString::from("minecraft:forced"))
|
||||
@@ -2290,6 +2368,7 @@ impl SaveDir {
|
||||
shape,
|
||||
lod_regions: Default::default(),
|
||||
lod_decoded: Default::default(),
|
||||
datafix_context: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
/// 26.3's TicketStorage: persistent tickets live in each dimension's
|
||||
@@ -2384,7 +2463,8 @@ impl SaveDir {
|
||||
let present: Vec<ChunkPos> = region.chunks().map(|(pos, _)| pos).collect();
|
||||
drop(region);
|
||||
for pos in present {
|
||||
let Some(root) = self.read_record("region", pos.x, pos.z) else {
|
||||
// `IOWorker` reads the record as saved, before any upgrade.
|
||||
let Some(root) = self.read_record_raw("region", pos.x, pos.z) else {
|
||||
continue;
|
||||
};
|
||||
let old = match root.tag.get("DataVersion") {
|
||||
@@ -2398,7 +2478,7 @@ impl SaveDir {
|
||||
}
|
||||
bits
|
||||
}
|
||||
/// A saved chunk as the region file keeps it, for the readers of its
|
||||
/// A saved chunk brought to the current version, for the readers of its
|
||||
/// fields that `load` does not decode (`blending_data`).
|
||||
pub fn raw_chunk(&self, x: i32, z: i32) -> Option<NamedTag> {
|
||||
self.read_record("region", x, z)
|
||||
@@ -2421,7 +2501,8 @@ impl SaveDir {
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
fn read_record(&self, folder: &str, x: i32, z: i32) -> Option<NamedTag> {
|
||||
/// A record as saved: the pending write first, then the region file.
|
||||
fn read_record_raw(&self, folder: &str, x: i32, z: i32) -> Option<NamedTag> {
|
||||
let pending = self
|
||||
.pending
|
||||
.lock()
|
||||
@@ -2444,6 +2525,24 @@ impl SaveDir {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A record brought to the current version (`upgrade_record`).
|
||||
fn read_record(&self, folder: &str, x: i32, z: i32) -> Option<NamedTag> {
|
||||
let mut record = self.read_record_raw(folder, x, z)?;
|
||||
match self.upgrade_record(Folder::of(folder), &mut record) {
|
||||
Ok(()) => Some(record),
|
||||
Err(e) => {
|
||||
crate::chunk_io::report_load_failure(&self.root, x, z, &e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
fn upgrade_record(&self, folder: Folder, record: &mut NamedTag) -> Result<()> {
|
||||
upgrade_record(folder, record, || {
|
||||
self.datafix_context
|
||||
.get_or_init(|| DatafixContext::of(&self.root))
|
||||
.as_ref()
|
||||
})
|
||||
}
|
||||
pub fn load(&self, x: i32, z: i32, registry: &BlockRegistry, biomes: &Biomes) -> Option<Chunk> {
|
||||
let root = self.read_record("region", x, z)?;
|
||||
let tables = self.tables.get().map(|t| &**t);
|
||||
@@ -2473,11 +2572,13 @@ impl SaveDir {
|
||||
return None;
|
||||
}
|
||||
let mut region = RegionFile::open(self.region_path("region", x >> 5, z >> 5)).ok()?;
|
||||
region
|
||||
let mut root = region
|
||||
.read(ChunkPos::new(x, z))
|
||||
.ok()??
|
||||
.nbt(Limits::default())
|
||||
.ok()?
|
||||
.ok()?;
|
||||
self.upgrade_record(Folder::Region, &mut root).ok()?;
|
||||
root
|
||||
};
|
||||
let tables = self.tables.get().map(|t| &**t);
|
||||
let mut chunk = decode_with(&root, registry, biomes, tables, self.shape.column())?;
|
||||
@@ -4664,11 +4765,13 @@ impl SaveDir {
|
||||
}
|
||||
// Far-only reads must not populate the gameplay region cache across the whole view.
|
||||
let mut region = RegionFile::open(self.region_path("region", x >> 5, z >> 5)).ok()?;
|
||||
region
|
||||
let mut root = region
|
||||
.read(ChunkPos::new(x, z))
|
||||
.ok()??
|
||||
.nbt(Limits::default())
|
||||
.ok()?
|
||||
.ok()?;
|
||||
self.upgrade_record(Folder::Region, &mut root).ok()?;
|
||||
root
|
||||
};
|
||||
// Proto saves may still be missing decoration from their neighbors.
|
||||
if text(root.tag.get("Status"))
|
||||
@@ -6946,4 +7049,216 @@ mod tests {
|
||||
assert_eq!(preset.prev().next(), preset);
|
||||
}
|
||||
}
|
||||
/// A region record of the dimension folder `save` at chunk (0, 0), written
|
||||
/// as an older version left it.
|
||||
fn write_old_record(save: &SaveDir, folder: &str, tag: Tag) {
|
||||
let record = Record::Tag(Arc::new(NamedTag {
|
||||
name: "".into(),
|
||||
tag,
|
||||
}));
|
||||
let cache = match Folder::of(folder) {
|
||||
Folder::Region => &save.regions,
|
||||
Folder::Entities => &save.entity_regions,
|
||||
Folder::Poi => &save.poi_regions,
|
||||
};
|
||||
super::write_region_records(&save.root, cache, folder, &[((0, 0), &record)]).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_records_of_a_current_world_upgrade_as_they_are_read() {
|
||||
let world = std::env::temp_dir().join(format!("horizon-lazy-dv-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&world);
|
||||
let save = SaveDir::new(
|
||||
super::dimension_dir(&world, "minecraft:overworld"),
|
||||
LevelShape::OVERWORLD,
|
||||
);
|
||||
// A 1.20.1 chunk (DataVersion 3465): 1.20.3 renamed its grass to short_grass.
|
||||
let section = Tag::compound([
|
||||
("Y", Tag::Byte(-4)),
|
||||
(
|
||||
"block_states",
|
||||
Tag::compound([(
|
||||
"palette",
|
||||
Tag::List(vec![Tag::compound([("Name", string("minecraft:grass"))])]),
|
||||
)]),
|
||||
),
|
||||
(
|
||||
"biomes",
|
||||
Tag::compound([("palette", Tag::List(vec![string("minecraft:plains")]))]),
|
||||
),
|
||||
]);
|
||||
write_old_record(
|
||||
&save,
|
||||
"region",
|
||||
Tag::compound([
|
||||
("DataVersion", Tag::Int(3465)),
|
||||
("xPos", Tag::Int(0)),
|
||||
("zPos", Tag::Int(0)),
|
||||
("yPos", Tag::Int(-4)),
|
||||
("Status", string("minecraft:full")),
|
||||
("sections", Tag::List(vec![section])),
|
||||
]),
|
||||
);
|
||||
let chunk = save.raw_chunk(0, 0).expect("the chunk reads");
|
||||
assert_eq!(
|
||||
number(chunk.tag.get("DataVersion")),
|
||||
Some(i64::from(DATA_VERSION))
|
||||
);
|
||||
let Some(Tag::List(sections)) = chunk.tag.get("sections") else {
|
||||
panic!("no sections: {:?}", chunk.tag);
|
||||
};
|
||||
let Some(Tag::List(palette)) = sections[0]
|
||||
.get("block_states")
|
||||
.and_then(|b| b.get("palette"))
|
||||
else {
|
||||
panic!("no palette");
|
||||
};
|
||||
assert_eq!(
|
||||
text(palette[0].get("id")).as_deref(),
|
||||
Some("minecraft:short_grass")
|
||||
);
|
||||
// Decoded, it keeps its blocks: read raw, the 26.x decoder finds no `id` in a
|
||||
// 1.20 palette (`Name`) and the whole column is air.
|
||||
if let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") {
|
||||
let root = PathBuf::from(root);
|
||||
let assets = crate::assets::Assets::load(&root).expect("assets");
|
||||
let biomes = Biomes::load(&root).expect("biomes");
|
||||
let grass = assets
|
||||
.registry
|
||||
.default_state("minecraft:short_grass")
|
||||
.unwrap();
|
||||
let loaded = save
|
||||
.load(0, 0, assets.registry, &biomes)
|
||||
.expect("the chunk loads");
|
||||
assert_eq!(loaded.get(3, -60, 7), grass.0);
|
||||
}
|
||||
// A 1.20.1 item entity: 1.20.5 made its stack `count` (an int) from `Count`.
|
||||
write_old_record(
|
||||
&save,
|
||||
"entities",
|
||||
Tag::compound([
|
||||
("DataVersion", Tag::Int(3465)),
|
||||
("Position", Tag::IntArray(vec![0, 0])),
|
||||
(
|
||||
"Entities",
|
||||
Tag::List(vec![Tag::compound([
|
||||
("id", string("minecraft:item")),
|
||||
(
|
||||
"Item",
|
||||
Tag::compound([
|
||||
("id", string("minecraft:stone")),
|
||||
("Count", Tag::Byte(3)),
|
||||
]),
|
||||
),
|
||||
])]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
let entities = save.load_entities(0, 0);
|
||||
let item = entities[0].get("Item").expect("the item stack");
|
||||
assert_eq!(item.get("count"), Some(&Tag::Int(3)));
|
||||
assert!(item.get("Count").is_none());
|
||||
let _ = std::fs::remove_dir_all(&world);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_chunks_are_found_in_the_saved_records_and_old_poi_upgrade() {
|
||||
let world = std::env::temp_dir().join(format!("horizon-lazy-old-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&world);
|
||||
let nether = SaveDir::new(
|
||||
super::dimension_dir(&world, "minecraft:the_nether"),
|
||||
LevelShape::NETHER,
|
||||
);
|
||||
// A 1.20.1 Nether chunk: upgraded it has no `blending_data` (only Overworld
|
||||
// chunks blend) and the current DataVersion, so only the saved record says
|
||||
// it is older than blending (4997), as `IOWorker.isOldChunk` reads it.
|
||||
write_old_record(
|
||||
&nether,
|
||||
"region",
|
||||
Tag::compound([
|
||||
("DataVersion", Tag::Int(3465)),
|
||||
("xPos", Tag::Int(0)),
|
||||
("zPos", Tag::Int(0)),
|
||||
("yPos", Tag::Int(0)),
|
||||
("Status", string("minecraft:full")),
|
||||
("sections", Tag::List(Vec::new())),
|
||||
]),
|
||||
);
|
||||
let upgraded = nether.raw_chunk(0, 0).expect("the chunk reads");
|
||||
assert!(upgraded.tag.get("blending_data").is_none());
|
||||
assert_ne!(nether.old_chunk_bits(0, 0)[0] & 1, 0);
|
||||
// A POI record of before 1.14.4 (no DataVersion, read as 1945): its
|
||||
// sections at the root, which 1946 moved under `Sections`.
|
||||
let overworld = SaveDir::new(
|
||||
super::dimension_dir(&world, "minecraft:overworld"),
|
||||
LevelShape::OVERWORLD,
|
||||
);
|
||||
write_old_record(
|
||||
&overworld,
|
||||
"poi",
|
||||
Tag::compound([(
|
||||
"0",
|
||||
Tag::compound([("Valid", Tag::Byte(1)), ("Records", Tag::List(Vec::new()))]),
|
||||
)]),
|
||||
);
|
||||
let poi = overworld.read_record("poi", 0, 0).expect("the POI reads");
|
||||
assert!(poi.tag.get("Sections").and_then(|s| s.get("0")).is_some());
|
||||
assert_eq!(
|
||||
number(poi.tag.get("DataVersion")),
|
||||
Some(i64::from(DATA_VERSION))
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&world);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chunk_from_before_1_18_is_brought_to_the_current_layout() {
|
||||
let world = std::env::temp_dir().join(format!("horizon-lazy-117-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&world);
|
||||
let save = SaveDir::new(
|
||||
super::dimension_dir(&world, "minecraft:overworld"),
|
||||
LevelShape::OVERWORLD,
|
||||
);
|
||||
// A 1.17.1 chunk (DataVersion 2730), its fields under `Level`: section 0 all stone.
|
||||
let section = Tag::compound([
|
||||
("Y", Tag::Byte(0)),
|
||||
(
|
||||
"Palette",
|
||||
Tag::List(vec![
|
||||
Tag::compound([("Name", string("minecraft:air"))]),
|
||||
Tag::compound([("Name", string("minecraft:stone"))]),
|
||||
]),
|
||||
),
|
||||
(
|
||||
"BlockStates",
|
||||
Tag::LongArray(vec![0x1111_1111_1111_1111; 256]),
|
||||
),
|
||||
]);
|
||||
write_old_record(
|
||||
&save,
|
||||
"region",
|
||||
Tag::compound([
|
||||
("DataVersion", Tag::Int(2730)),
|
||||
(
|
||||
"Level",
|
||||
Tag::compound([
|
||||
("xPos", Tag::Int(0)),
|
||||
("zPos", Tag::Int(0)),
|
||||
("Status", string("full")),
|
||||
("Biomes", Tag::IntArray(vec![1; 1024])),
|
||||
("Sections", Tag::List(vec![section])),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
let chunk = save.raw_chunk(0, 0).expect("the chunk reads");
|
||||
assert!(
|
||||
chunk.tag.get("Level").is_none(),
|
||||
"still wrapped: {:?}",
|
||||
chunk.tag
|
||||
);
|
||||
assert_eq!(number(chunk.tag.get("xPos")), Some(0));
|
||||
// 1.18's height fix: an Overworld chunk older than the new heights blends with them.
|
||||
assert!(chunk.tag.get("blending_data").is_some());
|
||||
let _ = std::fs::remove_dir_all(&world);
|
||||
}
|
||||
}
|
||||
@@ -45,11 +45,12 @@ impl SaveDir {
|
||||
std::collections::btree_map::Entry::Occupied(e) => e.into_mut(),
|
||||
std::collections::btree_map::Entry::Vacant(e) => {
|
||||
// Propagate damaged/unreadable regions; never discard their legacy exits.
|
||||
let Some(root) =
|
||||
let Some(mut root) =
|
||||
read_region_record(&self.root, &self.regions, "region", key.0, key.1)?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
self.upgrade_record(Folder::Region, &mut root)?;
|
||||
e.insert(root)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
//! `SocialInteractionsScreen`: the players of the server in three tabs (All, Hidden, Blocked),
|
||||
//! a search box, and a row for each with the button that hides or shows its chat. The
|
||||
//! Blocked tab and each row's report button belong to Mojang's services, which Horizon does
|
||||
//! not offer: the first lists no one, the second is the disabled button vanilla shows when
|
||||
//! reporting is off.
|
||||
use crate::{
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
hud_layout, tab_list,
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
|
||||
pub const TITLE: &str = "gui.socialInteractions.title";
|
||||
/// `SocialInteractionsScreen`'s page.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Page {
|
||||
#[default]
|
||||
All,
|
||||
Hidden,
|
||||
Blocked,
|
||||
}
|
||||
/// One `PlayerEntry`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
pub id: u128,
|
||||
pub name: String,
|
||||
pub skin: String,
|
||||
pub hidden: bool,
|
||||
/// Not in the level any more (`PlayerEntry.isRemoved`): a hidden player who left.
|
||||
pub removed: bool,
|
||||
pub local: bool,
|
||||
}
|
||||
/// What the screen shows (game/social.rs fills it).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SocialView {
|
||||
pub page: Page,
|
||||
pub search: String,
|
||||
/// The rows of the page, sorted and filtered as `SocialInteractionsPlayerList` does.
|
||||
pub entries: Vec<Entry>,
|
||||
/// `serverLabel`: the server's name and the players online.
|
||||
pub server_label: Text,
|
||||
pub scroll: f32,
|
||||
}
|
||||
/// What a click does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
Page(Page),
|
||||
Hide(u128),
|
||||
Show(u128),
|
||||
Done,
|
||||
}
|
||||
|
||||
const IMAGE_WIDTH: f32 = 238.0;
|
||||
const ROW_WIDTH: f32 = 220.0;
|
||||
const ITEM_HEIGHT: f32 = 36.0;
|
||||
const LIST_START: f32 = 88.0;
|
||||
const BG_FILL: u32 = 0xFF4A4A4A;
|
||||
const BG_FILL_REMOVED: u32 = 0xFF303030;
|
||||
const STATUS_COLOR: u32 = 0x8CFFFFFF;
|
||||
const WHITE: u32 = u32::MAX;
|
||||
|
||||
fn margin_x(w: f32) -> f32 {
|
||||
((w - IMAGE_WIDTH) / 2.0).floor()
|
||||
}
|
||||
/// `windowHeight`.
|
||||
fn window_height(h: f32) -> f32 {
|
||||
(h - 128.0 - 16.0).max(52.0)
|
||||
}
|
||||
/// `listEnd`.
|
||||
fn list_end(h: f32) -> f32 {
|
||||
80.0 + window_height(h) - 8.0
|
||||
}
|
||||
fn row_left(w: f32) -> f32 {
|
||||
((w - ROW_WIDTH) / 2.0).floor()
|
||||
}
|
||||
/// `SocialInteractionsPlayerList.sortPlayerEntries`: the local player first, then names that
|
||||
/// start with a letter, a digit or `_`, then the rest, each by name ignoring case.
|
||||
pub fn sort_entries(entries: &mut [Entry]) {
|
||||
entries.sort_by_key(|e| {
|
||||
let plain = e
|
||||
.name
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|c| c == '_' || c.is_ascii_alphanumeric());
|
||||
(!e.local, !plain, e.name.to_lowercase())
|
||||
});
|
||||
}
|
||||
/// The most the list scrolls: its rows less its height.
|
||||
pub fn max_scroll(h: f32, rows: usize) -> f32 {
|
||||
// `AbstractSelectionList.contentHeight`: the rows and 4px.
|
||||
(rows as f32 * ITEM_HEIGHT + 4.0 - (list_end(h) - LIST_START)).max(0.0)
|
||||
}
|
||||
fn tabs(w: f32) -> [(f32, Page); 3] {
|
||||
let width = (ROW_WIDTH / 3.0).floor();
|
||||
let (left, right) = (row_left(w), row_left(w) + ROW_WIDTH);
|
||||
[
|
||||
(left, Page::All),
|
||||
(((left + right - width) / 2.0 + 1.0).floor(), Page::Hidden),
|
||||
(right - width + 1.0, Page::Blocked),
|
||||
]
|
||||
}
|
||||
fn tab_label(page: Page, selected: bool) -> Text {
|
||||
let key = match page {
|
||||
Page::All => "gui.socialInteractions.tab_all",
|
||||
Page::Hidden => "gui.socialInteractions.tab_hidden",
|
||||
Page::Blocked => "gui.socialInteractions.tab_blocked",
|
||||
};
|
||||
let label = Text::translate(key, &[]);
|
||||
if selected { label.underlined() } else { label }
|
||||
}
|
||||
/// A row's content rectangle (`Entry.getContentX/Y/Width/Height`: 2px in from the row), `y` the
|
||||
/// row's top.
|
||||
fn row_rect(w: f32, y: f32) -> [f32; 4] {
|
||||
[
|
||||
row_left(w) + 2.0,
|
||||
y + 2.0,
|
||||
ROW_WIDTH - 4.0,
|
||||
ITEM_HEIGHT - 4.0,
|
||||
]
|
||||
}
|
||||
/// `PlayerEntry.extractContent`: the hide or show button, and the report button after it.
|
||||
fn row_buttons(w: f32, y: f32) -> ([f32; 2], [f32; 2]) {
|
||||
let [x, y, width, height] = row_rect(w, y);
|
||||
let hide = [
|
||||
x + (width - 20.0 - 4.0) - 20.0 - 4.0,
|
||||
y + (height - 20.0) / 2.0,
|
||||
];
|
||||
let report = [x + (width - 20.0 - 4.0), y + (height - 20.0) / 2.0];
|
||||
(hide, report)
|
||||
}
|
||||
fn status(e: &Entry) -> Option<Text> {
|
||||
let key = match (e.hidden, e.removed) {
|
||||
(true, true) => "gui.socialInteractions.status_hidden_offline",
|
||||
(true, false) => "gui.socialInteractions.status_hidden",
|
||||
(false, true) => "gui.socialInteractions.status_offline",
|
||||
(false, false) => return None,
|
||||
};
|
||||
Some(Text::translate(key, &[]).italic())
|
||||
}
|
||||
/// The rows that show: the first one at `getY() + 2 - scroll` (`getFirstEntryY`).
|
||||
fn visible(view: &SocialView, h: f32) -> impl Iterator<Item = (usize, f32)> + '_ {
|
||||
let end = list_end(h);
|
||||
view.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(move |(i, _)| (i, LIST_START + 2.0 - view.scroll + i as f32 * ITEM_HEIGHT))
|
||||
.filter(move |&(_, y)| y + ITEM_HEIGHT > LIST_START + 4.0 && y < end)
|
||||
}
|
||||
/// The search box's rectangle.
|
||||
pub fn search_box(w: f32) -> [f32; 4] {
|
||||
[margin_x(w) + 28.0, 74.0, 200.0, 15.0]
|
||||
}
|
||||
/// Done's rectangle (`HeaderAndFooterLayout`'s footer).
|
||||
fn done(w: f32, h: f32) -> [f32; 4] {
|
||||
[(w / 2.0).floor() - 100.0, h - 27.0, 200.0, 20.0]
|
||||
}
|
||||
/// What is under `cursor`, if a click would do something.
|
||||
pub fn click(size: (f32, f32), cursor: (f32, f32), view: &SocialView) -> Option<Action> {
|
||||
let (w, h) = size;
|
||||
for (x, page) in tabs(w) {
|
||||
if ui::hovered(cursor, x, 45.0, (ROW_WIDTH / 3.0).floor(), 20.0) {
|
||||
return Some(Action::Page(page));
|
||||
}
|
||||
}
|
||||
let [dx, dy, dw, dh] = done(w, h);
|
||||
if ui::hovered(cursor, dx, dy, dw, dh) {
|
||||
return Some(Action::Done);
|
||||
}
|
||||
if cursor.1 < LIST_START || cursor.1 >= list_end(h) {
|
||||
return None;
|
||||
}
|
||||
for (i, y) in visible(view, h) {
|
||||
let e = &view.entries[i];
|
||||
if e.local {
|
||||
continue;
|
||||
}
|
||||
let (hide, _) = row_buttons(w, y);
|
||||
if ui::hovered(cursor, hide[0], hide[1], 20.0, 20.0) {
|
||||
return Some(if e.hidden {
|
||||
Action::Show(e.id)
|
||||
} else {
|
||||
Action::Hide(e.id)
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
/// The tooltip of what the cursor is over.
|
||||
fn tooltip(size: (f32, f32), cursor: (f32, f32), view: &SocialView) -> Option<Text> {
|
||||
let (w, h) = size;
|
||||
if cursor.1 < LIST_START || cursor.1 >= list_end(h) {
|
||||
return None;
|
||||
}
|
||||
for (i, y) in visible(view, h) {
|
||||
let e = &view.entries[i];
|
||||
if e.local {
|
||||
continue;
|
||||
}
|
||||
let (hide, report) = row_buttons(w, y);
|
||||
if ui::hovered(cursor, hide[0], hide[1], 20.0, 20.0) {
|
||||
let key = if e.hidden {
|
||||
"gui.socialInteractions.tooltip.show"
|
||||
} else {
|
||||
"gui.socialInteractions.tooltip.hide"
|
||||
};
|
||||
return Some(Text::translate(key, &[]));
|
||||
}
|
||||
if ui::hovered(cursor, report[0], report[1], 20.0, 20.0) {
|
||||
return Some(Text::translate(
|
||||
"gui.socialInteractions.tooltip.report.disabled",
|
||||
&[],
|
||||
));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
pub fn draw(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
view: &SocialView,
|
||||
search: &mut ui::EditBox,
|
||||
in_world: bool,
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
|
||||
let title = Text::translate(TITLE, &[]);
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(atlas, &title, ((w - tw) / 2.0).floor(), 15.0, WHITE, true);
|
||||
// `extractBackground`: the panel and the search icon.
|
||||
let mx = margin_x(w) + 3.0;
|
||||
ui::blit_sprite(
|
||||
batch,
|
||||
atlas,
|
||||
"gui/sprites/social_interactions/background",
|
||||
mx,
|
||||
64.0,
|
||||
236.0,
|
||||
window_height(h) + 16.0,
|
||||
WHITE,
|
||||
);
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
"gui/sprites/icon/search",
|
||||
mx + 10.0,
|
||||
76.0,
|
||||
12.0,
|
||||
12.0,
|
||||
WHITE,
|
||||
);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&view.server_label,
|
||||
margin_x(w) + 8.0,
|
||||
35.0,
|
||||
WHITE,
|
||||
true,
|
||||
);
|
||||
for (x, page) in tabs(w) {
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
x,
|
||||
45.0,
|
||||
(ROW_WIDTH / 3.0).floor(),
|
||||
20.0,
|
||||
&tab_label(page, view.page == page),
|
||||
true,
|
||||
ui::hovered(cursor, x, 45.0, (ROW_WIDTH / 3.0).floor(), 20.0),
|
||||
);
|
||||
}
|
||||
// The search box: an `EditBox` with the focus from the start (`setInitialFocus`), so its
|
||||
// hint, shown only while unfocused, never shows.
|
||||
let [sx, sy, sw, sh] = search_box(w);
|
||||
(search.x, search.y, search.width, search.height) = (sx, sy, sw, sh);
|
||||
search.max_length = 16;
|
||||
search.text_color = WHITE;
|
||||
search.focused = true;
|
||||
search.draw(batch, atlas, None);
|
||||
// The rows, clipped to the list.
|
||||
let start = batch.vertices.len();
|
||||
let mut faces = Vec::new();
|
||||
for (i, y) in visible(view, h) {
|
||||
let e = &view.entries[i];
|
||||
let [x, _, width, height] = row_rect(w, y);
|
||||
let (fill, text_y) = match status(e) {
|
||||
None => (BG_FILL, y + (height - 9.0) / 2.0),
|
||||
Some(_) => (BG_FILL_REMOVED, y + (height - 18.0) / 2.0),
|
||||
};
|
||||
batch.fill(atlas, x, y, width, height, fill);
|
||||
let (skin_x, skin_y) = (x + 4.0, y + ((height - 24.0) / 2.0).floor());
|
||||
tab_list::face(
|
||||
&mut faces,
|
||||
&e.skin,
|
||||
skin_x as i32,
|
||||
skin_y as i32,
|
||||
24,
|
||||
true,
|
||||
false,
|
||||
WHITE,
|
||||
);
|
||||
if e.removed {
|
||||
faces.push(hud_layout::fill(
|
||||
skin_x as i32,
|
||||
skin_y as i32,
|
||||
skin_x as i32 + 24,
|
||||
skin_y as i32 + 24,
|
||||
0xBE000000,
|
||||
));
|
||||
}
|
||||
let text_x = skin_x + 24.0 + 4.0;
|
||||
batch.text(atlas, &e.name, text_x, text_y.floor(), WHITE, false);
|
||||
if let Some(s) = status(e) {
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&s,
|
||||
text_x,
|
||||
(text_y + 12.0).floor(),
|
||||
STATUS_COLOR,
|
||||
false,
|
||||
);
|
||||
}
|
||||
if !e.local {
|
||||
let (hide, report) = row_buttons(w, y);
|
||||
let sprite = |base: &str, hover: bool| {
|
||||
format!(
|
||||
"gui/sprites/social_interactions/{base}{}",
|
||||
if hover { "_highlighted" } else { "" }
|
||||
)
|
||||
};
|
||||
let hovered = |p: [f32; 2]| ui::hovered(cursor, p[0], p[1], 20.0, 20.0);
|
||||
let base = if e.hidden {
|
||||
"unmute_button"
|
||||
} else {
|
||||
"mute_button"
|
||||
};
|
||||
// The button's narration (`PlayerEntry`: `narration.hide` / `narration.show`).
|
||||
let key = if e.hidden {
|
||||
"gui.socialInteractions.narration.show"
|
||||
} else {
|
||||
"gui.socialInteractions.narration.hide"
|
||||
};
|
||||
crate::widgets::record(
|
||||
&Text::translate(key, &[Text::literal(e.name.clone())]).plain(),
|
||||
[hide[0], hide[1], 20.0, 20.0],
|
||||
true,
|
||||
);
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
&sprite(base, hovered(hide)),
|
||||
hide[0],
|
||||
hide[1],
|
||||
20.0,
|
||||
20.0,
|
||||
WHITE,
|
||||
);
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
"gui/sprites/social_interactions/report_button_disabled",
|
||||
report[0],
|
||||
report[1],
|
||||
20.0,
|
||||
20.0,
|
||||
WHITE,
|
||||
);
|
||||
}
|
||||
}
|
||||
hud_layout::paint(batch, atlas, &faces);
|
||||
// `enableScissor`: from 4px below the list's top.
|
||||
batch.clip_since(start, 0.0, LIST_START + 4.0, w, list_end(h));
|
||||
// `Done`.
|
||||
let [dx, dy, dw, dh] = done(w, h);
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
dx,
|
||||
dy,
|
||||
dw,
|
||||
dh,
|
||||
&Text::translate("gui.done", &[]),
|
||||
true,
|
||||
ui::hovered(cursor, dx, dy, dw, dh),
|
||||
);
|
||||
// The empty page's note, in place of the list.
|
||||
if view.entries.is_empty() {
|
||||
let key = if !view.search.is_empty() {
|
||||
Some("gui.socialInteractions.search_empty")
|
||||
} else {
|
||||
match view.page {
|
||||
Page::Hidden => Some("gui.socialInteractions.empty_hidden"),
|
||||
Page::Blocked => Some("gui.socialInteractions.empty_blocked"),
|
||||
Page::All => None,
|
||||
}
|
||||
};
|
||||
if let Some(key) = key {
|
||||
let note = Text::translate(key, &[]);
|
||||
let nw = atlas.font.width_text(¬e);
|
||||
let y = ((72.0 + list_end(h)) / 2.0).floor();
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
¬e,
|
||||
((w - nw) / 2.0).floor(),
|
||||
y,
|
||||
rgba(170, 170, 170, 255),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(text) = tooltip((w, h), cursor, view) {
|
||||
ui::tooltip(batch, atlas, &[text], cursor);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(id: u128, name: &str) -> Entry {
|
||||
Entry {
|
||||
id,
|
||||
name: name.into(),
|
||||
skin: tab_list::default_skin(id).into(),
|
||||
hidden: false,
|
||||
removed: false,
|
||||
local: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A click on a row's button hides a shown player and shows a hidden one, a click on
|
||||
/// one's own row does nothing, and the tabs and Done answer where vanilla puts them.
|
||||
#[test]
|
||||
fn the_buttons_answer_their_clicks() {
|
||||
let size = (427.0, 360.0);
|
||||
let mut me = entry(1, "Me");
|
||||
me.local = true;
|
||||
let mut hidden = entry(3, "Hidden");
|
||||
hidden.hidden = true;
|
||||
let view = SocialView {
|
||||
entries: vec![me, entry(2, "Other"), hidden],
|
||||
..SocialView::default()
|
||||
};
|
||||
let rows: Vec<_> = visible(&view, size.1).collect();
|
||||
assert_eq!(rows.len(), 3);
|
||||
let at = |i: usize| {
|
||||
let (hide, _) = row_buttons(size.0, rows[i].1);
|
||||
(hide[0] + 2.0, hide[1] + 2.0)
|
||||
};
|
||||
assert_eq!(
|
||||
click(size, at(0), &view),
|
||||
None,
|
||||
"no button on one's own row"
|
||||
);
|
||||
assert_eq!(click(size, at(1), &view), Some(Action::Hide(2)));
|
||||
assert_eq!(click(size, at(2), &view), Some(Action::Show(3)));
|
||||
let tab = tabs(size.0)[1];
|
||||
assert_eq!(
|
||||
click(size, (tab.0 + 2.0, 50.0), &view),
|
||||
Some(Action::Page(Page::Hidden))
|
||||
);
|
||||
let [dx, dy, ..] = done(size.0, size.1);
|
||||
assert_eq!(click(size, (dx + 2.0, dy + 2.0), &view), Some(Action::Done));
|
||||
}
|
||||
|
||||
/// The list scrolls by whole rows less its height, and a row scrolled out is not clickable.
|
||||
#[test]
|
||||
fn the_list_scrolls_and_clips() {
|
||||
let h = 240.0;
|
||||
let entries: Vec<_> = (0..10).map(|i| entry(i + 1, &format!("P{i}"))).collect();
|
||||
let mut view = SocialView {
|
||||
entries,
|
||||
..SocialView::default()
|
||||
};
|
||||
let shown = visible(&view, h).count();
|
||||
assert!(shown < 10, "ten rows do not fit {h}");
|
||||
let max = max_scroll(h, 10);
|
||||
assert!(max > 0.0 && max < 10.0 * ITEM_HEIGHT);
|
||||
view.scroll = max;
|
||||
let last = visible(&view, h).last().unwrap().0;
|
||||
assert_eq!(last, 9, "scrolled to the end shows the last row");
|
||||
let first = visible(&view, h).next().unwrap();
|
||||
let (hide, _) = row_buttons(427.0, first.1);
|
||||
// A cursor above the list's top does not reach a row's button.
|
||||
assert_eq!(click((427.0, h), (hide[0] + 2.0, 30.0), &view), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_sort_as_the_list_does() {
|
||||
let entry = |name: &str, local: bool| Entry {
|
||||
id: 0,
|
||||
name: name.into(),
|
||||
skin: String::new(),
|
||||
hidden: false,
|
||||
removed: false,
|
||||
local,
|
||||
};
|
||||
let mut rows = vec![
|
||||
entry("zed", false),
|
||||
entry("Alpha", false),
|
||||
entry("§odd", false),
|
||||
entry("me", true),
|
||||
entry("_x", false),
|
||||
];
|
||||
sort_entries(&mut rows);
|
||||
let names: Vec<_> = rows.iter().map(|e| e.name.as_str()).collect();
|
||||
assert_eq!(names, ["me", "_x", "Alpha", "zed", "§odd"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,957 @@
|
||||
//! Custom Item Textures: the rules of `optifine/cit/**/*.properties` and the pack's global
|
||||
//! `cit.properties`, parsed into what they test on a stack and what they change. Matching stacks
|
||||
//! against them is the client's (`horizon_client::cit`): it knows the items and their components.
|
||||
//!
|
||||
//! Written from OptiFine's `cit.properties` and `cit_single.properties` docs, CIT Resewn's wiki
|
||||
//! (citresewn.shcm.io) and CIT Resewn Continuation's MIT-licensed source (its conditions, read
|
||||
//! for the 1.21 component rules). Where OptiFine and CIT Resewn differ, CIT Resewn is followed,
|
||||
//! but for keys neither reads: those are ignored, as OptiFine does, whereas CIT Resewn would
|
||||
//! never match the rule.
|
||||
|
||||
use crate::matchers::{IdList, Name, RangeList, namespaced};
|
||||
use crate::paths::{resolve, valid};
|
||||
use crate::properties::Properties;
|
||||
|
||||
/// What a rule changes (`type`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Item,
|
||||
Armor,
|
||||
Elytra,
|
||||
Enchantment,
|
||||
}
|
||||
|
||||
/// `hand`: the hand the item is drawn in; GUI items count as the main hand.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Hand {
|
||||
Any,
|
||||
Main,
|
||||
Off,
|
||||
}
|
||||
|
||||
/// `damage` (with `%` against the item's max damage) and `damageMask`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Damage {
|
||||
pub ranges: RangeList,
|
||||
pub percent: bool,
|
||||
pub mask: Option<i32>,
|
||||
}
|
||||
|
||||
impl Damage {
|
||||
pub fn matches(&self, damage: u32, max_damage: u32) -> bool {
|
||||
let mut value = damage as i32;
|
||||
if let Some(mask) = self.mask {
|
||||
value &= mask;
|
||||
}
|
||||
if !self.percent {
|
||||
return self.ranges.contains(value);
|
||||
}
|
||||
if max_damage == 0 {
|
||||
return false;
|
||||
}
|
||||
let percent = 100.0 * f64::from(value) / f64::from(max_damage);
|
||||
self.ranges
|
||||
.0
|
||||
.iter()
|
||||
.any(|&(lo, hi)| f64::from(lo) <= percent && percent <= f64::from(hi))
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a component test reads its value.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Source {
|
||||
/// A data component by id (`minecraft:custom_name`), then a path inside its NBT.
|
||||
Component(String),
|
||||
/// Pre-1.20.5 `Enchantments` / `StoredEnchantments`: a list of `{id, lvl}` compounds, which
|
||||
/// the client builds from the stack's enchantments.
|
||||
LegacyEnchantments { stored: bool },
|
||||
}
|
||||
|
||||
/// One `components.<id>.<path>=<value>` or `nbt.<path>=<value>` test.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ComponentTest {
|
||||
pub source: Source,
|
||||
/// Compound keys and list indexes; `*` is any key or element, `count` a list's length. The
|
||||
/// tag walked decides which a segment is: `0` is an index in a list, a key in a compound.
|
||||
pub path: Vec<String>,
|
||||
pub value: ValueTest,
|
||||
/// A leading `!`: the test passes when the value does not match.
|
||||
pub negate: bool,
|
||||
}
|
||||
|
||||
/// The right-hand side of a component test.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ValueTest {
|
||||
/// `exists:true` / `exists:false`.
|
||||
Exists(bool),
|
||||
/// `range:1 5 7-9`: an integer value.
|
||||
Range(RangeList),
|
||||
/// `raw:<name>`: the value written as SNBT.
|
||||
Raw(Name),
|
||||
/// `pattern:`, `regex:` (and their `i` forms) against a string, a text component's plain
|
||||
/// text, or a number's decimal form.
|
||||
Text(Name),
|
||||
/// A bare value: the same string, the same number (`#rrggbb` and `0x` hex are integers), or
|
||||
/// for a compound or list, SNBT that the value contains.
|
||||
Literal(Literal),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Literal {
|
||||
pub text: String,
|
||||
pub int: Option<i64>,
|
||||
pub float: Option<f64>,
|
||||
}
|
||||
|
||||
impl Literal {
|
||||
fn parse(text: &str) -> Self {
|
||||
let hex = text
|
||||
.strip_prefix('#')
|
||||
.or_else(|| text.strip_prefix("0x"))
|
||||
.and_then(|h| i64::from_str_radix(h, 16).ok());
|
||||
Self {
|
||||
text: text.to_owned(),
|
||||
int: hex.or_else(|| text.parse().ok()),
|
||||
float: text.parse().ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ValueTest {
|
||||
fn parse(value: &str) -> Option<(Self, bool)> {
|
||||
let (negate, value) = match value.strip_prefix('!') {
|
||||
Some(rest) => (true, rest),
|
||||
None => (false, value),
|
||||
};
|
||||
let test = if let Some(v) = value.strip_prefix("exists:") {
|
||||
ValueTest::Exists(v.trim().parse().ok()?)
|
||||
} else if let Some(v) = value.strip_prefix("range:") {
|
||||
ValueTest::Range(RangeList::parse(v)?)
|
||||
} else if let Some(v) = value.strip_prefix("raw:") {
|
||||
ValueTest::Raw(Name::parse(v)?)
|
||||
} else if ["pattern:", "ipattern:", "regex:", "iregex:"]
|
||||
.iter()
|
||||
.any(|p| value.starts_with(p))
|
||||
{
|
||||
ValueTest::Text(Name::parse(value)?)
|
||||
} else {
|
||||
ValueTest::Literal(Literal::parse(value))
|
||||
};
|
||||
Some((test, negate))
|
||||
}
|
||||
}
|
||||
|
||||
impl ComponentTest {
|
||||
/// `key` is the part after `nbt.` or `components.`.
|
||||
fn parse(legacy: bool, key: &str, value: &str) -> Option<Self> {
|
||||
// `~` is the minecraft namespace where it starts a segment (`levels.~thorns`).
|
||||
let mut segments: Vec<String> = key
|
||||
.split('.')
|
||||
.map(|s| match s.strip_prefix('~') {
|
||||
Some(rest) => format!("minecraft:{rest}"),
|
||||
None => s.to_owned(),
|
||||
})
|
||||
.collect();
|
||||
if segments.iter().any(String::is_empty) {
|
||||
return None;
|
||||
}
|
||||
let (source, mut path) = if legacy {
|
||||
legacy_source(segments)?
|
||||
} else {
|
||||
let id = namespaced(&segments.remove(0));
|
||||
(Source::Component(id), segments)
|
||||
};
|
||||
// Enchantments were `{levels: {id: level}}` until 1.21.5 and are the bare map since, as
|
||||
// OptiFine's doc still writes them (`components.enchantments.levels.~thorns`).
|
||||
if let Source::Component(id) = &source {
|
||||
if matches!(
|
||||
&**id,
|
||||
"minecraft:enchantments" | "minecraft:stored_enchantments"
|
||||
) && path.first().is_some_and(|s| s == "levels")
|
||||
{
|
||||
path.remove(0);
|
||||
}
|
||||
}
|
||||
let (value, negate) = ValueTest::parse(value)?;
|
||||
Some(Self {
|
||||
source,
|
||||
path,
|
||||
value,
|
||||
negate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a pre-1.20.5 `nbt.` path lives since items moved to data components. Tags that became
|
||||
/// no component of their own were moved into `minecraft:custom_data` by the game's data fixer,
|
||||
/// so paths outside the tags 1.20.5 knew are read there. `None` for a tag that became a component
|
||||
/// whose shape no longer matches the old one (`AttributeModifiers`, `pages`...): such a test
|
||||
/// would never pass, or always pass when negated.
|
||||
fn legacy_source(path: Vec<String>) -> Option<(Source, Vec<String>)> {
|
||||
let component = |id: &str, prefix: &[&str], rest: &[String]| {
|
||||
let path = prefix.iter().map(|s| (*s).to_owned());
|
||||
Some((
|
||||
Source::Component(format!("minecraft:{id}")),
|
||||
path.chain(rest.iter().cloned()).collect(),
|
||||
))
|
||||
};
|
||||
let strs: Vec<&str> = path.iter().map(String::as_str).collect();
|
||||
match strs[..] {
|
||||
["display", "Name", ..] => component("custom_name", &[], &path[2..]),
|
||||
["display", "Lore", ..] => component("lore", &[], &path[2..]),
|
||||
["display", "color", ..] => component("dyed_color", &[], &path[2..]),
|
||||
// `map_color` is gone in 26.x; `LocName` became a translated `item_name`.
|
||||
["display", "MapColor" | "LocName", ..] => None,
|
||||
["CustomModelData", ..] => component("custom_model_data", &["floats", "0"], &path[1..]),
|
||||
["Potion", ..] => component("potion_contents", &["potion"], &path[1..]),
|
||||
["CustomPotionColor", ..] => component("potion_contents", &["custom_color"], &path[1..]),
|
||||
["Damage", ..] => component("damage", &[], &path[1..]),
|
||||
["RepairCost", ..] => component("repair_cost", &[], &path[1..]),
|
||||
["Trim", ..] => component("trim", &[], &path[1..]),
|
||||
["title", ..] => component("written_book_content", &["title", "raw"], &path[1..]),
|
||||
["author", ..] => component("written_book_content", &["author"], &path[1..]),
|
||||
// `generation` and `flight_duration` are left out when 0: the matcher reads that default.
|
||||
["generation", ..] => component("written_book_content", &["generation"], &path[1..]),
|
||||
["SkullOwner"] | ["SkullOwner", "Name", ..] => {
|
||||
component("profile", &["name"], path.get(2..).unwrap_or_default())
|
||||
}
|
||||
["SkullOwner", "Id", ..] => component("profile", &["id"], &path[2..]),
|
||||
// `Properties.textures` became `profile.properties`, a list of `{name, value}`.
|
||||
["SkullOwner", ..] => None,
|
||||
["Fireworks", "Flight", ..] => component("fireworks", &["flight_duration"], &path[2..]),
|
||||
["map", ..] => component("map_id", &[], &path[1..]),
|
||||
["EntityTag", "variant", ..] => component("painting/variant", &[], &path[2..]),
|
||||
["EntityTag", ..] => component("entity_data", &[], &path[1..]),
|
||||
// The 1.20.2 name of `CustomPotionEffects`, whose fields kept their names.
|
||||
["custom_potion_effects", ..] => {
|
||||
component("potion_contents", &["custom_effects"], &path[1..])
|
||||
}
|
||||
// The data fixer took these out of `BlockEntityTag` into components of their own shape.
|
||||
[
|
||||
"BlockEntityTag",
|
||||
"Base" | "Patterns" | "Items" | "Lock" | "LootTable" | "LootTableSeed" | "sherds"
|
||||
| "item" | "Bees" | "bees" | "note_block_sound",
|
||||
..,
|
||||
] => None,
|
||||
["BlockEntityTag", ..] => component("block_entity_data", &[], &path[1..]),
|
||||
["instrument", ..] => component("instrument", &[], &path[1..]),
|
||||
["Recipes", ..] => component("recipes", &[], &path[1..]),
|
||||
["DebugProperty", ..] => component("debug_stick_state", &[], &path[1..]),
|
||||
["BlockStateTag", ..] => component("block_state", &[], &path[1..]),
|
||||
["Enchantments", ..] => Some((
|
||||
Source::LegacyEnchantments { stored: false },
|
||||
path[1..].to_vec(),
|
||||
)),
|
||||
["StoredEnchantments", ..] => Some((
|
||||
Source::LegacyEnchantments { stored: true },
|
||||
path[1..].to_vec(),
|
||||
)),
|
||||
[
|
||||
"Unbreakable"
|
||||
| "CanDestroy"
|
||||
| "CanPlaceOn"
|
||||
| "HideFlags"
|
||||
| "AttributeModifiers"
|
||||
| "CustomPotionEffects"
|
||||
| "effects"
|
||||
| "Decorations"
|
||||
| "LodestoneDimension"
|
||||
| "LodestonePos"
|
||||
| "LodestoneTracked"
|
||||
| "pages"
|
||||
| "filtered_pages"
|
||||
| "filtered_title"
|
||||
| "resolved"
|
||||
| "Fireworks"
|
||||
| "Explosion"
|
||||
| "BucketVariantTag"
|
||||
| "Items"
|
||||
| "ChargedProjectiles"
|
||||
| "Charged"
|
||||
| "CustomCreativeLock"
|
||||
// Bucket mobs': into `bucket_entity_data`, and `Variant` became a name.
|
||||
| "Variant"
|
||||
| "NoAI"
|
||||
| "Silent"
|
||||
| "NoGravity"
|
||||
| "Glowing"
|
||||
| "Invulnerable"
|
||||
| "Health"
|
||||
| "Age"
|
||||
| "HuntingCooldown",
|
||||
..,
|
||||
] => None,
|
||||
_ => component("custom_data", &[], &path),
|
||||
}
|
||||
}
|
||||
|
||||
/// One `.properties` file under `optifine/cit/`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Rule {
|
||||
/// The file's resource path: the tie-breaker between rules of equal weight.
|
||||
pub path: String,
|
||||
pub kind: Kind,
|
||||
pub weight: i32,
|
||||
/// Namespaced item ids; empty (enchantment rules only) is any item.
|
||||
pub items: Vec<String>,
|
||||
pub stack_size: Option<RangeList>,
|
||||
pub damage: Option<Damage>,
|
||||
pub hand: Hand,
|
||||
/// Namespaced enchantment ids, any of which the stack must have.
|
||||
pub enchantments: Vec<String>,
|
||||
/// Levels one of the stack's enchantments (of `enchantments`, when given) must have.
|
||||
pub enchantment_levels: Option<RangeList>,
|
||||
pub components: Vec<ComponentTest>,
|
||||
/// `texture`, as a resource path to a `.png`.
|
||||
pub texture: Option<String>,
|
||||
/// `texture.<name>`: (name, resource path).
|
||||
pub textures: Vec<(String, String)>,
|
||||
/// `model`, as a resource path to a `.json`.
|
||||
pub model: Option<String>,
|
||||
/// `model.<name>`.
|
||||
pub models: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Rule {
|
||||
/// The rule `props` (at resource path `path`, read with `Properties::parse_equals_first` as
|
||||
/// CIT Resewn reads them) describes. `exists` answers whether a resource path names a file,
|
||||
/// to find textures and models the way OptiFine and CIT Resewn look for them. `None` for an
|
||||
/// unknown type, a malformed condition, or a rule that changes nothing.
|
||||
pub fn parse(path: &str, props: &Properties, exists: impl Fn(&str) -> bool) -> Option<Self> {
|
||||
let kind = match props.get("type").unwrap_or("item") {
|
||||
"item" => Kind::Item,
|
||||
"armor" => Kind::Armor,
|
||||
"elytra" => Kind::Elytra,
|
||||
"enchantment" => Kind::Enchantment,
|
||||
_ => return None,
|
||||
};
|
||||
let stem = path
|
||||
.rsplit('/')
|
||||
.next()?
|
||||
.strip_suffix(".properties")
|
||||
.unwrap_or_default();
|
||||
let mut items: Vec<String> = props
|
||||
.get("items")
|
||||
.or_else(|| props.get("matchItems"))
|
||||
.map(|v| IdList::parse(v).include)
|
||||
.unwrap_or_default();
|
||||
if kind == Kind::Elytra {
|
||||
items = vec!["minecraft:elytra".to_owned()];
|
||||
} else if items.is_empty() && kind != Kind::Enchantment {
|
||||
// CIT Resewn: a rule named after an item is for that item (a bare id, so a
|
||||
// `minecraft:` one whatever the rule's namespace).
|
||||
items.push(namespaced(stem));
|
||||
}
|
||||
let int = |v: &str| v.parse::<i32>().ok();
|
||||
let damage = match props.get("damage") {
|
||||
Some(v) => Some(Damage {
|
||||
ranges: RangeList::parse(&v.replace('%', ""))?,
|
||||
percent: v.contains('%'),
|
||||
mask: optional(props, &["damageMask", "damage_mask"], int)?,
|
||||
}),
|
||||
None => None,
|
||||
};
|
||||
let enchantments = props
|
||||
.get("enchantments")
|
||||
.or_else(|| props.get("enchantmentIDs"))
|
||||
.map(|v| v.split_whitespace().map(enchantment_id).collect())
|
||||
.unwrap_or_default();
|
||||
let mut components = Vec::new();
|
||||
// `component.` is CIT Resewn's alias of `components.`.
|
||||
for (legacy, prefix) in [
|
||||
(true, "nbt."),
|
||||
(false, "components."),
|
||||
(false, "component."),
|
||||
] {
|
||||
for (key, value) in props.with_prefix(prefix) {
|
||||
components.push(ComponentTest::parse(legacy, key, value)?);
|
||||
}
|
||||
}
|
||||
let tex = |v: &str| asset(path, v, "png", "textures", &exists);
|
||||
let model_at = |v: &str| asset(path, v, "json", "models", &exists);
|
||||
// A named asset that is not there drops the rule, as in CIT Resewn: half a bow's textures
|
||||
// would draw a vanilla frame among custom ones.
|
||||
let mut texture = optional(props, &["texture"], tex)?;
|
||||
let mut model = optional(props, &["model"], model_at)?;
|
||||
let textures = props
|
||||
.with_prefix("texture.")
|
||||
.map(|(name, v)| Some((name.to_owned(), tex(v)?)))
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
let models = props
|
||||
.with_prefix("model.")
|
||||
.map(|(name, v)| Some((name.to_owned(), model_at(v)?)))
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
if texture.is_none() && model.is_none() && textures.is_empty() && models.is_empty() {
|
||||
// Neither named: a model, then a texture, named after the file beside it.
|
||||
let dir = path.rsplit_once('/').map_or("", |(d, _)| d);
|
||||
let beside = |ext: &str| {
|
||||
let p = format!("{dir}/{stem}.{ext}");
|
||||
exists(&p).then_some(p)
|
||||
};
|
||||
if kind == Kind::Item {
|
||||
model = beside("json");
|
||||
}
|
||||
if model.is_none() {
|
||||
texture = beside("png");
|
||||
}
|
||||
}
|
||||
if texture.is_none() && model.is_none() && textures.is_empty() && models.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
path: path.to_owned(),
|
||||
kind,
|
||||
weight: optional(props, &["weight", "cit_weight", "citWeight"], int)?.unwrap_or(0),
|
||||
items,
|
||||
stack_size: optional(
|
||||
props,
|
||||
&["stackSize", "stack_size", "amount"],
|
||||
RangeList::parse,
|
||||
)?,
|
||||
damage,
|
||||
hand: match props.get("hand").map(str::to_ascii_lowercase).as_deref() {
|
||||
// CIT Resewn's spellings too.
|
||||
None | Some("any" | "either" | "*") => Hand::Any,
|
||||
Some("main" | "mainhand" | "main_hand") => Hand::Main,
|
||||
Some("off" | "offhand" | "off_hand") => Hand::Off,
|
||||
Some(_) => return None,
|
||||
},
|
||||
enchantments,
|
||||
enchantment_levels: optional(
|
||||
props,
|
||||
&["enchantmentLevels", "enchantment_levels"],
|
||||
RangeList::parse,
|
||||
)?,
|
||||
components,
|
||||
texture,
|
||||
textures,
|
||||
model,
|
||||
models,
|
||||
})
|
||||
}
|
||||
|
||||
/// Which of two matching rules is chosen: `Less` for the one that wins, the higher weight,
|
||||
/// then the path that sorts first.
|
||||
pub fn priority(&self, other: &Self) -> std::cmp::Ordering {
|
||||
(other.weight, &self.path).cmp(&(self.weight, &other.path))
|
||||
}
|
||||
}
|
||||
|
||||
/// An optional key, under any of its names (CIT Resewn's aliases): `Some(None)` when absent,
|
||||
/// `None` (dropping the rule) when malformed.
|
||||
fn optional<T>(
|
||||
props: &Properties,
|
||||
keys: &[&str],
|
||||
parse: impl Fn(&str) -> Option<T>,
|
||||
) -> Option<Option<T>> {
|
||||
match keys.iter().find_map(|k| props.get(k)) {
|
||||
Some(v) => parse(v).map(Some),
|
||||
None => Some(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// A `texture`/`model` value as a resource path to an existing `.ext` file, trying what CIT
|
||||
/// Resewn tries in its order: as written (OptiFine's paths, see `paths::resolve`, or a full
|
||||
/// `assets/...` path), in the namespace's typed folder (`item/x` → `textures/item/x.png`), then
|
||||
/// beside the rule (`sub/x`, and `./../x` up from its folder).
|
||||
fn asset(
|
||||
props: &str,
|
||||
value: &str,
|
||||
ext: &str,
|
||||
typed: &str,
|
||||
exists: &impl Fn(&str) -> bool,
|
||||
) -> Option<String> {
|
||||
let last = value.rsplit('/').next().unwrap_or(value);
|
||||
let value = if last.ends_with(&format!(".{ext}")) {
|
||||
value.to_owned()
|
||||
} else {
|
||||
format!("{value}.{ext}")
|
||||
};
|
||||
let dir = props.rsplit_once('/').map_or("", |(d, _)| d);
|
||||
let mut candidates = Vec::new();
|
||||
if value.split('/').any(|s| s == "..") {
|
||||
// Only up from the rule's folder: `~/` and `ns:` paths name their folder outright.
|
||||
if !value.starts_with("~/") && !value.contains(':') {
|
||||
candidates.extend(up_from(dir, &value));
|
||||
}
|
||||
} else if value.starts_with("assets/") {
|
||||
candidates.extend(valid(&value).then(|| value.clone()));
|
||||
} else if let Some(resolved) = resolve(props, &value) {
|
||||
let plain = !value.starts_with("~/") && !value.starts_with("./");
|
||||
if plain && value.contains(['/', ':']) {
|
||||
if let Some((ns, rest)) = resolved
|
||||
.strip_prefix("assets/")
|
||||
.and_then(|p| p.split_once('/'))
|
||||
{
|
||||
if !rest.starts_with(&format!("{typed}/")) {
|
||||
candidates.push(format!("assets/{ns}/{typed}/{rest}"));
|
||||
}
|
||||
}
|
||||
if !value.contains(':') {
|
||||
candidates.push(format!("{dir}/{value}"));
|
||||
}
|
||||
}
|
||||
candidates.insert(0, resolved);
|
||||
}
|
||||
candidates.into_iter().find(|p| exists(p))
|
||||
}
|
||||
|
||||
/// `value` (`./../x`, `../x`) in the folder `dir`, each `..` taking a folder off; `None` when it
|
||||
/// would climb out of `assets/<namespace>/`.
|
||||
fn up_from(dir: &str, value: &str) -> Option<String> {
|
||||
let mut out: Vec<&str> = dir.split('/').collect();
|
||||
for segment in value.strip_prefix("./").unwrap_or(value).split('/') {
|
||||
match segment {
|
||||
".." if out.len() > 2 => {
|
||||
out.pop();
|
||||
}
|
||||
".." => return None,
|
||||
s => out.push(s),
|
||||
}
|
||||
}
|
||||
let path = out.join("/");
|
||||
(out.len() > 2 && out[0] == "assets" && valid(&path)).then_some(path)
|
||||
}
|
||||
|
||||
/// An `enchantments` entry: an id (`flame`, `minecraft:flame`) or a pre-1.13 numeric id. An
|
||||
/// unknown number stays an id no enchantment has, as in CIT Resewn, so its rule never matches.
|
||||
fn enchantment_id(token: &str) -> String {
|
||||
if token.eq_ignore_ascii_case("sweeping") || token.eq_ignore_ascii_case("minecraft:sweeping") {
|
||||
return "minecraft:sweeping_edge".to_owned();
|
||||
}
|
||||
let Ok(n) = token.parse::<u32>() else {
|
||||
return namespaced(token);
|
||||
};
|
||||
let name = match n {
|
||||
0 => "protection",
|
||||
1 => "fire_protection",
|
||||
2 => "feather_falling",
|
||||
3 => "blast_protection",
|
||||
4 => "projectile_protection",
|
||||
5 => "respiration",
|
||||
6 => "aqua_affinity",
|
||||
7 => "thorns",
|
||||
8 => "depth_strider",
|
||||
9 => "frost_walker",
|
||||
10 => "binding_curse",
|
||||
16 => "sharpness",
|
||||
17 => "smite",
|
||||
18 => "bane_of_arthropods",
|
||||
19 => "knockback",
|
||||
20 => "fire_aspect",
|
||||
21 => "looting",
|
||||
22 => "sweeping_edge",
|
||||
32 => "efficiency",
|
||||
33 => "silk_touch",
|
||||
34 => "unbreaking",
|
||||
35 => "fortune",
|
||||
48 => "power",
|
||||
49 => "punch",
|
||||
50 => "flame",
|
||||
51 => "infinity",
|
||||
61 => "luck_of_the_sea",
|
||||
62 => "lure",
|
||||
70 => "mending",
|
||||
71 => "vanishing_curse",
|
||||
_ => return namespaced(token),
|
||||
};
|
||||
format!("minecraft:{name}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rule(path: &str, text: &str, files: &[&str]) -> Option<Rule> {
|
||||
Rule::parse(path, &Properties::parse_equals_first(text), |p| {
|
||||
files.contains(&p)
|
||||
})
|
||||
}
|
||||
|
||||
const SWORD: &str = "assets/minecraft/optifine/cit/swords/flame.properties";
|
||||
|
||||
#[test]
|
||||
fn an_item_rule_with_every_condition() {
|
||||
let r = rule(
|
||||
SWORD,
|
||||
"items=diamond_sword minecraft:iron_sword\ntexture=flame_sword\nweight=5\n\
|
||||
stackSize=1\ndamage=0-50%\nhand=off\nenchantmentIDs=20 sharpness\n\
|
||||
enchantmentLevels=3-\nnbt.display.Name=ipattern:*flame*\n",
|
||||
&["assets/minecraft/optifine/cit/swords/flame_sword.png"],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r.kind, Kind::Item);
|
||||
assert_eq!(r.items, ["minecraft:diamond_sword", "minecraft:iron_sword"]);
|
||||
assert_eq!(
|
||||
r.texture.as_deref(),
|
||||
Some("assets/minecraft/optifine/cit/swords/flame_sword.png")
|
||||
);
|
||||
assert_eq!(r.weight, 5);
|
||||
assert_eq!(r.hand, Hand::Off);
|
||||
let files = ["assets/minecraft/optifine/cit/swords/flame.png"];
|
||||
for (v, hand) in [
|
||||
("Offhand", Hand::Off),
|
||||
("main_hand", Hand::Main),
|
||||
("*", Hand::Any),
|
||||
] {
|
||||
let spelled = rule(SWORD, &format!("items=stick\nhand={v}"), &files).unwrap();
|
||||
assert_eq!(spelled.hand, hand, "{v}");
|
||||
}
|
||||
let stack = r.stack_size.as_ref().unwrap();
|
||||
assert!(stack.contains(1) && !stack.contains(2));
|
||||
assert_eq!(
|
||||
r.enchantments,
|
||||
["minecraft:fire_aspect", "minecraft:sharpness"]
|
||||
);
|
||||
assert!(r.enchantment_levels.as_ref().unwrap().contains(7));
|
||||
assert!(!r.enchantment_levels.as_ref().unwrap().contains(2));
|
||||
// A diamond sword lasts 1561: half of it is 780.5 used.
|
||||
let damage = r.damage.as_ref().unwrap();
|
||||
assert!(damage.matches(780, 1561));
|
||||
assert!(!damage.matches(781, 1561));
|
||||
assert!(!damage.matches(0, 0), "no max damage, no percentage");
|
||||
let [name] = &r.components[..] else { panic!() };
|
||||
assert_eq!(
|
||||
name.source,
|
||||
Source::Component("minecraft:custom_name".into())
|
||||
);
|
||||
assert!(name.path.is_empty());
|
||||
let ValueTest::Text(n) = &name.value else {
|
||||
panic!()
|
||||
};
|
||||
assert!(n.matches("The FLAME Blade"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damage_masks_apply_before_the_ranges() {
|
||||
let r = rule(
|
||||
SWORD,
|
||||
"items=potion\ndamage=3\ndamageMask=15\n",
|
||||
&["assets/minecraft/optifine/cit/swords/flame.png"],
|
||||
)
|
||||
.unwrap();
|
||||
let d = r.damage.unwrap();
|
||||
assert!(
|
||||
d.matches(16_387, 0),
|
||||
"splash fire resistance: 16387 & 15 = 3"
|
||||
);
|
||||
assert!(!d.matches(16_388, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_items_and_assets_come_from_the_file_name() {
|
||||
let dir = "assets/minecraft/optifine/cit/sticks";
|
||||
let path = format!("{dir}/stick.properties");
|
||||
// No texture or model named: the model beside it wins over the texture.
|
||||
let both = [&*format!("{dir}/stick.json"), &*format!("{dir}/stick.png")];
|
||||
let r = rule(&path, "nbt.CustomModelData=1\n", &both).unwrap();
|
||||
assert_eq!(r.items, ["minecraft:stick"]);
|
||||
assert_eq!(r.model.as_deref(), Some(&*format!("{dir}/stick.json")));
|
||||
assert_eq!(r.texture, None);
|
||||
let r = rule(&path, "", &both[1..]).unwrap();
|
||||
assert_eq!(r.texture.as_deref(), Some(&*format!("{dir}/stick.png")));
|
||||
// The file name is a bare id: a minecraft one even in another namespace's folder.
|
||||
let other = rule(
|
||||
"assets/mymod/optifine/cit/stick.properties",
|
||||
"",
|
||||
&["assets/mymod/optifine/cit/stick.png"],
|
||||
);
|
||||
assert_eq!(other.unwrap().items, ["minecraft:stick"]);
|
||||
// Nothing to draw: no rule.
|
||||
assert!(rule(&path, "", &[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_paths_try_the_typed_folders() {
|
||||
let files = [
|
||||
"assets/minecraft/textures/item/excalibur.png",
|
||||
"assets/minecraft/models/item/excalibur.json",
|
||||
"assets/minecraft/optifine/cit/shared/glow.png",
|
||||
"assets/minecraft/optifine/cit/swords/sub/x.png",
|
||||
];
|
||||
let tex = |v| rule(SWORD, &format!("items=stick\ntexture={v}"), &files)?.texture;
|
||||
let at = |s: &str| Some(s.to_owned());
|
||||
assert_eq!(tex("item/excalibur"), at(files[0]));
|
||||
assert_eq!(tex("minecraft:item/excalibur.png"), at(files[0]));
|
||||
assert_eq!(tex("textures/item/excalibur"), at(files[0]));
|
||||
assert_eq!(
|
||||
tex("assets/minecraft/textures/item/excalibur.png"),
|
||||
at(files[0])
|
||||
);
|
||||
assert_eq!(tex("~/cit/shared/glow"), at(files[2]));
|
||||
assert_eq!(tex("./sub/x"), at(files[3]));
|
||||
assert_eq!(tex("./../swords/sub/x"), at(files[3]));
|
||||
assert_eq!(tex("sub/x"), at(files[3]), "beside the rule, last");
|
||||
// `..` climbs only from the rule's folder: `~/` and `ns:` name theirs outright.
|
||||
let beside = ["assets/minecraft/optifine/cit/swords/x.png"];
|
||||
let up = |v| rule(SWORD, &format!("items=stick\ntexture={v}"), &beside);
|
||||
assert!(up("./a/../x").is_some());
|
||||
assert!(up("~/../x").is_none());
|
||||
assert!(up("minecraft:a/../x").is_none());
|
||||
let model = rule(SWORD, "items=stick\nmodel=item/excalibur", &files).unwrap();
|
||||
assert_eq!(model.model.as_deref(), Some(files[1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn climbing_stops_at_the_namespace() {
|
||||
// Files at every folder a climb could reach: only the namespace's own are found.
|
||||
let files = ["assets/x.png", "x.png", "assets/minecraft/x.png"];
|
||||
let tex = |v: &str| rule(SWORD, &format!("items=stick\ntexture={v}"), &files)?.texture;
|
||||
assert_eq!(tex("../../../x").as_deref(), Some("assets/minecraft/x.png"));
|
||||
assert_eq!(tex("../../../../x"), None);
|
||||
assert_eq!(tex("../../../../../x"), None);
|
||||
// A Windows `\` is refused by `paths::valid`, whose tests cover it.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_asset_that_is_missing_drops_the_rule() {
|
||||
// flame.png sits beside flame.properties, but the rule names another texture.
|
||||
let beside = ["assets/minecraft/optifine/cit/swords/flame.png"];
|
||||
assert!(rule(SWORD, "items=stick\ntexture=typo", &beside).is_none());
|
||||
assert!(rule(SWORD, "items=stick", &beside).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_textures_and_armor_layers() {
|
||||
let dir = "assets/minecraft/optifine/cit/bow";
|
||||
let files: Vec<String> = ["a0", "a1", "diamond1", "diamond2"]
|
||||
.iter()
|
||||
.map(|n| format!("{dir}/{n}.png"))
|
||||
.collect();
|
||||
let refs: Vec<&str> = files.iter().map(String::as_str).collect();
|
||||
let r = rule(
|
||||
&format!("{dir}/bow.properties"),
|
||||
"items=bow\ntexture.bow_pulling_0=a0\ntexture.bow_pulling_1=a1\n",
|
||||
&refs,
|
||||
)
|
||||
.unwrap();
|
||||
let names: Vec<_> = r.textures.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert_eq!(names, ["bow_pulling_0", "bow_pulling_1"]);
|
||||
// One frame missing: no rule, rather than a bow that is half vanilla.
|
||||
let missing = "items=bow\ntexture.bow_pulling_0=a0\ntexture.bow_pulling_2=missing\n";
|
||||
assert!(rule(&format!("{dir}/bow.properties"), missing, &refs).is_none());
|
||||
let armor = rule(
|
||||
&format!("{dir}/armor.properties"),
|
||||
"type=armor\nitems=diamond_chestplate\ntexture.diamond_layer_1=diamond1\ntexture.diamond_layer_2=diamond2\n",
|
||||
&refs,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(armor.kind, Kind::Armor);
|
||||
assert_eq!(
|
||||
armor.textures[1],
|
||||
("diamond_layer_2".into(), files[3].clone())
|
||||
);
|
||||
let glint = rule(
|
||||
&format!("{dir}/glint.properties"),
|
||||
"type=enchantment\ntexture=a0",
|
||||
&refs,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
glint.items.is_empty(),
|
||||
"an enchantment rule without items is for every item"
|
||||
);
|
||||
// Elytra rules are always for the elytra.
|
||||
let elytra = rule(
|
||||
&format!("{dir}/x.properties"),
|
||||
"type=elytra\nitems=stick\ntexture=a0",
|
||||
&refs,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(elytra.items, ["minecraft:elytra"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_nbt_paths_land_on_components() {
|
||||
let source = |key: &str| {
|
||||
let t = ComponentTest::parse(true, key, "x").unwrap();
|
||||
let Source::Component(id) = t.source else {
|
||||
return (format!("{:?}", t.source), t.path);
|
||||
};
|
||||
(id, t.path)
|
||||
};
|
||||
let s = |v: &[&str]| v.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
source("display.Lore.*"),
|
||||
("minecraft:lore".into(), s(&["*"]))
|
||||
);
|
||||
assert_eq!(
|
||||
source("CustomModelData"),
|
||||
("minecraft:custom_model_data".into(), s(&["floats", "0"]))
|
||||
);
|
||||
assert_eq!(
|
||||
source("Potion"),
|
||||
("minecraft:potion_contents".into(), s(&["potion"]))
|
||||
);
|
||||
assert_eq!(
|
||||
source("ExtraAttributes.id"),
|
||||
(
|
||||
"minecraft:custom_data".into(),
|
||||
s(&["ExtraAttributes", "id"])
|
||||
)
|
||||
);
|
||||
let ench = ComponentTest::parse(true, "StoredEnchantments.0.id", "x").unwrap();
|
||||
assert_eq!(ench.source, Source::LegacyEnchantments { stored: true });
|
||||
assert_eq!(ench.path, s(&["0", "id"]));
|
||||
// `~` is the minecraft namespace; a bare component id is in it too.
|
||||
// Since 1.21.5 the enchantments component has no `levels` level.
|
||||
let c = ComponentTest::parse(false, "enchantments.levels.~thorns", "3").unwrap();
|
||||
assert_eq!(c.source, Source::Component("minecraft:enchantments".into()));
|
||||
assert_eq!(c.path, s(&["minecraft:thorns"]));
|
||||
let c = ComponentTest::parse(false, "custom_data.my~key.~x", "3").unwrap();
|
||||
assert_eq!(
|
||||
c.path,
|
||||
s(&["my~key", "minecraft:x"]),
|
||||
"~ only opens a segment"
|
||||
);
|
||||
let c = ComponentTest::parse(false, "custom_data.levels", "3").unwrap();
|
||||
assert_eq!(c.path, s(&["levels"]));
|
||||
assert!(ComponentTest::parse(false, "lore..0", "x").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_values() {
|
||||
let v = |s: &str| ValueTest::parse(s).unwrap();
|
||||
let (ValueTest::Literal(l), false) = v("#ff99cc") else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(l.int, Some(0xff99cc));
|
||||
let (ValueTest::Literal(l), false) = v("0x1F") else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(l.int, Some(31));
|
||||
let (ValueTest::Literal(l), false) = v("1.5") else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!((l.int, l.float), (None, Some(1.5)));
|
||||
let (ValueTest::Literal(l), false) = v("53") else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!((l.int, l.float), (Some(53), Some(53.0)));
|
||||
let (ValueTest::Literal(l), true) = v("!Bob") else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!((l.text.as_str(), l.int), ("Bob", None));
|
||||
let (ValueTest::Range(r), false) = v("range:(-25) (-10)-10 15") else {
|
||||
panic!()
|
||||
};
|
||||
assert!(r.contains(-25) && r.contains(0) && !r.contains(11));
|
||||
assert!(matches!(
|
||||
v("exists:false"),
|
||||
(ValueTest::Exists(false), false)
|
||||
));
|
||||
let (ValueTest::Raw(n), false) = v("raw:pattern:*Chopper*") else {
|
||||
panic!()
|
||||
};
|
||||
assert!(n.matches("{text:\"Chopper\"}"));
|
||||
assert!(ValueTest::parse("regex:(").is_none());
|
||||
assert!(ValueTest::parse("exists:maybe").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_enchantment_ids_never_match_instead_of_matching_all() {
|
||||
let files = ["assets/minecraft/optifine/cit/swords/flame.png"];
|
||||
let r = rule(SWORD, "items=stick\nenchantmentIDs=99 sweeping", &files).unwrap();
|
||||
assert_eq!(r.enchantments, ["minecraft:99", "minecraft:sweeping_edge"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resewn_key_spellings() {
|
||||
let files = ["assets/minecraft/optifine/cit/swords/flame.png"];
|
||||
let text = "items=arrow\namount=64\ncitWeight=5\ndamage=3\ndamage_mask=15\n\
|
||||
enchantment_levels=2\ncomponents.minecraft:custom_name=Quiver\n";
|
||||
let r = rule(SWORD, text, &files).unwrap();
|
||||
assert!(r.stack_size.unwrap().contains(64));
|
||||
assert_eq!(r.weight, 5);
|
||||
assert_eq!(r.damage.unwrap().mask, Some(15));
|
||||
assert!(r.enchantment_levels.unwrap().contains(2));
|
||||
let [name] = &r.components[..] else { panic!() };
|
||||
assert_eq!(
|
||||
name.source,
|
||||
Source::Component("minecraft:custom_name".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_that_became_components_are_read_there_or_drop_the_rule() {
|
||||
let at = |key: &str| {
|
||||
let t = ComponentTest::parse(true, key, "x")?;
|
||||
let Source::Component(id) = t.source else {
|
||||
return None;
|
||||
};
|
||||
Some((id, t.path.join(".")))
|
||||
};
|
||||
let is = |id: &str, path: &str| Some((format!("minecraft:{id}"), path.to_owned()));
|
||||
assert_eq!(at("SkullOwner"), is("profile", "name"));
|
||||
assert_eq!(at("SkullOwner.Name"), is("profile", "name"));
|
||||
assert_eq!(at("generation"), is("written_book_content", "generation"));
|
||||
assert_eq!(at("EntityTag.CustomName"), is("entity_data", "CustomName"));
|
||||
assert_eq!(at("Fireworks.Flight"), is("fireworks", "flight_duration"));
|
||||
// Moved to a component of another shape: no test, and so no rule.
|
||||
assert_eq!(at("AttributeModifiers.0.Amount"), None);
|
||||
assert_eq!(at("HideFlags"), None);
|
||||
assert_eq!(at("SkullOwner.Properties.textures.0.Value"), None);
|
||||
assert_eq!(at("BlockEntityTag.Patterns.0.Pattern"), None);
|
||||
assert_eq!(at("display.MapColor"), None);
|
||||
assert_eq!(
|
||||
at("BlockEntityTag.CustomName"),
|
||||
is("block_entity_data", "CustomName")
|
||||
);
|
||||
assert_eq!(at("instrument"), is("instrument", ""));
|
||||
assert_eq!(
|
||||
at("DebugProperty.minecraft:stone"),
|
||||
is("debug_stick_state", "minecraft:stone")
|
||||
);
|
||||
assert_eq!(at("EntityTag.variant"), is("painting/variant", ""));
|
||||
assert_eq!(at("display.LocName"), None);
|
||||
// Bucket mobs' tags went into `bucket_entity_data`, and `Variant` became a name.
|
||||
assert_eq!(at("Variant"), None);
|
||||
// What the fixer left in `display` stayed in custom data.
|
||||
assert_eq!(at("display.Foo"), is("custom_data", "display.Foo"));
|
||||
let files = ["assets/minecraft/optifine/cit/swords/flame.png"];
|
||||
assert!(rule(SWORD, "items=stick\nnbt.Unbreakable=!1", &files).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_conditions_drop_the_rule() {
|
||||
let files = ["assets/minecraft/optifine/cit/swords/flame.png"];
|
||||
for bad in [
|
||||
"damage=lots",
|
||||
"damage=3\ndamageMask=abc",
|
||||
"stackSize=1-x",
|
||||
"hand=left",
|
||||
"texture=flame\nmodel=typo",
|
||||
"weight=heavy",
|
||||
"type=hat",
|
||||
"nbt.display.Name=regex:(",
|
||||
] {
|
||||
assert!(
|
||||
rule(SWORD, &format!("items=stick\n{bad}\n"), &files).is_none(),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavier_rules_then_earlier_paths_win() {
|
||||
let files = ["assets/minecraft/optifine/cit/a.png"];
|
||||
let at = |path: &str, weight: i32| {
|
||||
rule(
|
||||
path,
|
||||
&format!("items=stick\ntexture=~/cit/a\nweight={weight}"),
|
||||
&files,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let a = at("assets/minecraft/optifine/cit/a.properties", 0);
|
||||
let b = at("assets/minecraft/optifine/cit/b.properties", 0);
|
||||
let heavy = at("assets/minecraft/optifine/cit/c.properties", 3);
|
||||
let mut rules = [b, heavy, a];
|
||||
rules.sort_by(Rule::priority);
|
||||
let order: Vec<_> = rules
|
||||
.iter()
|
||||
.map(|r| r.path.rsplit('/').next().unwrap())
|
||||
.collect();
|
||||
assert_eq!(order, ["c.properties", "a.properties", "b.properties"]);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
//! Connected textures (`optifine/ctm/**/*.properties`): the rules a pack defines, which block
|
||||
//! faces each one applies to, and its replacement tiles.
|
||||
//! faces each one applies to, and its replacement tiles. Choosing a tile from the neighbours is
|
||||
//! [`connect`]'s.
|
||||
//!
|
||||
//! Written from OptiFine's `ctm.properties` documentation and template images, and Continuity's
|
||||
//! wiki (its "Continuity Connected Textures Specification" page) for what OptiFine does where the
|
||||
//! documentation is silent; no mod's source was read.
|
||||
|
||||
pub mod connect;
|
||||
|
||||
use crate::{BlockMatch, IdList, Name, Properties, RangeList};
|
||||
use std::path::Path;
|
||||
|
||||
@@ -216,7 +219,8 @@ pub fn load(root: &Path) -> Vec<Rule> {
|
||||
rules
|
||||
}
|
||||
|
||||
fn sort(rules: &mut [Rule]) {
|
||||
/// [`load`]'s order, for rules gathered elsewhere.
|
||||
pub fn sort(rules: &mut [Rule]) {
|
||||
rules.sort_by(|a, b| (b.prioritize, b.weight, &a.path).cmp(&(a.prioritize, a.weight, &b.path)));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
//! Which tile a connected face gets. A face's texture directions ([`Axes`]) say where its left,
|
||||
//! right, up and down neighbours are; [`neighbours`] turns the ones that join into a bit mask;
|
||||
//! each method then reads its tile from the mask ([`ctm_index`], [`compact_quarters`],
|
||||
//! [`horizontal`], [`overlay_tiles`]...) or from the position ([`repeat_index`],
|
||||
//! [`random_index`]).
|
||||
//!
|
||||
//! Nothing here knows OptiFine's files, so other connected-texture formats (Fusion's) share it:
|
||||
//! they build the same mask and read the same tables.
|
||||
//!
|
||||
//! The tile tables are OptiFine's documented templates (`ctm_template.png`,
|
||||
//! `ctm_compact_template.png`, `horizontal_template.png`, `vertical_template.png`,
|
||||
//! `overlay_template.png`), read off the images.
|
||||
|
||||
use super::Symmetry;
|
||||
|
||||
pub type Vec3 = [i32; 3];
|
||||
|
||||
const fn add(a: Vec3, b: Vec3) -> Vec3 {
|
||||
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
|
||||
}
|
||||
|
||||
const fn neg(a: Vec3) -> Vec3 {
|
||||
[-a[0], -a[1], -a[2]]
|
||||
}
|
||||
|
||||
/// In `i64`: a coordinate times -1 can leave `i32`.
|
||||
const fn dot(a: Vec3, b: Vec3) -> i64 {
|
||||
a[0] as i64 * b[0] as i64 + a[1] as i64 * b[1] as i64 + a[2] as i64 * b[2] as i64
|
||||
}
|
||||
|
||||
/// Block faces in `Direction` order, the bit order of [`super::faces`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Face {
|
||||
Down,
|
||||
Up,
|
||||
North,
|
||||
South,
|
||||
West,
|
||||
East,
|
||||
}
|
||||
|
||||
const EAST: Vec3 = [1, 0, 0];
|
||||
const WEST: Vec3 = [-1, 0, 0];
|
||||
const UP: Vec3 = [0, 1, 0];
|
||||
const SOUTH: Vec3 = [0, 0, 1];
|
||||
const NORTH: Vec3 = [0, 0, -1];
|
||||
|
||||
impl Face {
|
||||
pub const ALL: [Face; 6] = [
|
||||
Face::Down,
|
||||
Face::Up,
|
||||
Face::North,
|
||||
Face::South,
|
||||
Face::West,
|
||||
Face::East,
|
||||
];
|
||||
|
||||
pub const fn normal(self) -> Vec3 {
|
||||
match self {
|
||||
Face::Down => [0, -1, 0],
|
||||
Face::Up => UP,
|
||||
Face::North => NORTH,
|
||||
Face::South => SOUTH,
|
||||
Face::West => WEST,
|
||||
Face::East => EAST,
|
||||
}
|
||||
}
|
||||
|
||||
/// The face's [`super::faces`] bit.
|
||||
pub const fn bit(self) -> u8 {
|
||||
1 << self as u8
|
||||
}
|
||||
}
|
||||
|
||||
/// A block state's `axis`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Axis {
|
||||
X,
|
||||
Y,
|
||||
Z,
|
||||
}
|
||||
|
||||
/// Where a face's texture points in the world: its right (+u) and its up (-v).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Axes {
|
||||
pub right: Vec3,
|
||||
pub up: Vec3,
|
||||
}
|
||||
|
||||
impl Axes {
|
||||
/// On a vanilla cube: `BlockElement`'s default UVs (OptiFine's `ctm`, Continuity's
|
||||
/// `orient=none`).
|
||||
pub const fn cube(face: Face) -> Self {
|
||||
let (right, up) = match face {
|
||||
Face::Down => (EAST, SOUTH),
|
||||
Face::Up => (EAST, NORTH),
|
||||
Face::North => (WEST, UP),
|
||||
Face::South => (EAST, UP),
|
||||
Face::West => (SOUTH, UP),
|
||||
Face::East => (NORTH, UP),
|
||||
};
|
||||
Self { right, up }
|
||||
}
|
||||
|
||||
/// On a cube column turned to `axis` as vanilla's blockstates turn one (`x=90, y=90` for
|
||||
/// `x`, `x=90` for `z`): OptiFine's horizontal and vertical methods, Continuity's
|
||||
/// `orient=state_axis`.
|
||||
pub fn column(face: Face, axis: Axis) -> Self {
|
||||
let turn = |[x, y, z]: Vec3| match axis {
|
||||
Axis::Y => [x, y, z],
|
||||
// x=90 takes up to north.
|
||||
Axis::Z => [x, z, -y],
|
||||
// Then y=90 takes north to east.
|
||||
Axis::X => [y, z, x],
|
||||
};
|
||||
let model = Face::ALL
|
||||
.into_iter()
|
||||
.find(|f| turn(f.normal()) == face.normal())
|
||||
.expect("a quarter turn maps faces onto faces");
|
||||
let cube = Self::cube(model);
|
||||
Self {
|
||||
right: turn(cube.right),
|
||||
up: turn(cube.up),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Neighbour bits of a face's mask, in texture directions.
|
||||
pub const U: u8 = 1;
|
||||
pub const UR: u8 = 2;
|
||||
pub const R: u8 = 4;
|
||||
pub const DR: u8 = 8;
|
||||
pub const D: u8 = 16;
|
||||
pub const DL: u8 = 32;
|
||||
pub const L: u8 = 64;
|
||||
pub const UL: u8 = 128;
|
||||
|
||||
/// The neighbours of a face that join it, by `joins(offset from the block)`: all eight, so
|
||||
/// overlays see the corners whatever the sides. With `inner_seams`, a neighbour whose own face
|
||||
/// is covered (the block in front of it joins too) doesn't count, which draws a seam on inner
|
||||
/// corners.
|
||||
pub fn neighbours(
|
||||
axes: Axes,
|
||||
normal: Vec3,
|
||||
inner_seams: bool,
|
||||
mut joins: impl FnMut(Vec3) -> bool,
|
||||
) -> u8 {
|
||||
let (r, u) = (axes.right, axes.up);
|
||||
let (l, d) = (neg(r), neg(u));
|
||||
let offsets = [
|
||||
(U, u),
|
||||
(UR, add(u, r)),
|
||||
(R, r),
|
||||
(DR, add(d, r)),
|
||||
(D, d),
|
||||
(DL, add(d, l)),
|
||||
(L, l),
|
||||
(UL, add(u, l)),
|
||||
];
|
||||
let mut mask = 0;
|
||||
for (bit, offset) in offsets {
|
||||
if joins(offset) && !(inner_seams && joins(add(offset, normal))) {
|
||||
mask |= bit;
|
||||
}
|
||||
}
|
||||
mask
|
||||
}
|
||||
|
||||
/// The 47 tiles of `ctm_template.png`, as the neighbours each one joins: its sides, and a
|
||||
/// corner only where both sides beside it join (a lone corner draws nothing).
|
||||
const TEMPLATE: [u8; 47] = [
|
||||
0,
|
||||
R,
|
||||
R | L,
|
||||
L,
|
||||
R | D,
|
||||
D | L,
|
||||
U | R | D,
|
||||
R | D | L,
|
||||
U | UR | R | D | L,
|
||||
U | R | DR | D | L,
|
||||
U | R | D | DL | L | UL,
|
||||
U | UR | R | D | L | UL,
|
||||
D,
|
||||
R | DR | D,
|
||||
R | DR | D | DL | L,
|
||||
D | DL | L,
|
||||
U | R,
|
||||
U | L,
|
||||
U | R | L,
|
||||
U | D | L,
|
||||
U | R | D | L | UL,
|
||||
U | R | D | DL | L,
|
||||
U | R | DR | D | DL | L,
|
||||
U | UR | R | DR | D | L,
|
||||
U | D,
|
||||
U | UR | R | DR | D,
|
||||
U | UR | R | DR | D | DL | L | UL,
|
||||
U | D | DL | L | UL,
|
||||
U | R | DR | D,
|
||||
R | D | DL | L,
|
||||
U | UR | R | D,
|
||||
R | DR | D | L,
|
||||
U | UR | R | D | DL | L | UL,
|
||||
U | UR | R | DR | D | L | UL,
|
||||
U | UR | R | D | DL | L,
|
||||
U | R | DR | D | L | UL,
|
||||
U,
|
||||
U | UR | R,
|
||||
U | UR | R | L | UL,
|
||||
U | L | UL,
|
||||
U | UR | R | L,
|
||||
U | D | L | UL,
|
||||
U | R | L | UL,
|
||||
U | D | DL | L,
|
||||
U | R | DR | D | DL | L | UL,
|
||||
U | UR | R | DR | D | DL | L,
|
||||
U | R | D | L,
|
||||
];
|
||||
|
||||
/// A mask without the corners whose sides don't both join.
|
||||
const fn reduce(mask: u8) -> u8 {
|
||||
let mut out = mask & (U | R | D | L);
|
||||
let corners = [(UR, U | R), (DR, D | R), (DL, D | L), (UL, U | L)];
|
||||
let mut i = 0;
|
||||
while i < corners.len() {
|
||||
let (corner, sides) = corners[i];
|
||||
if mask & corner != 0 && mask & sides == sides {
|
||||
out |= corner;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
const CTM_INDEX: [u8; 256] = {
|
||||
let mut table = [0; 256];
|
||||
let mut mask = 0;
|
||||
while mask < 256 {
|
||||
let reduced = reduce(mask as u8);
|
||||
let mut tile = 0;
|
||||
while TEMPLATE[tile] != reduced {
|
||||
tile += 1;
|
||||
}
|
||||
table[mask] = tile as u8;
|
||||
mask += 1;
|
||||
}
|
||||
table
|
||||
};
|
||||
|
||||
/// The `ctm` tile (0-46) for a neighbour mask.
|
||||
pub fn ctm_index(mask: u8) -> usize {
|
||||
CTM_INDEX[mask as usize] as usize
|
||||
}
|
||||
|
||||
/// `ctm_compact`'s tile (0-4) for each quarter of the face: top-left, top-right, bottom-left,
|
||||
/// bottom-right. Tile 0 has every border, 1 none, 2 its left and right ones (joined up and
|
||||
/// down), 3 its top and bottom ones, 4 only the inner corners.
|
||||
pub fn compact_quarters(mask: u8) -> [u8; 4] {
|
||||
let quarter = |vertical: u8, horizontal: u8, corner: u8| match (
|
||||
mask & vertical != 0,
|
||||
mask & horizontal != 0,
|
||||
) {
|
||||
(false, false) => 0,
|
||||
(true, false) => 2,
|
||||
(false, true) => 3,
|
||||
(true, true) if mask & corner != 0 => 1,
|
||||
(true, true) => 4,
|
||||
};
|
||||
[
|
||||
quarter(U, L, UL),
|
||||
quarter(U, R, UR),
|
||||
quarter(D, L, DL),
|
||||
quarter(D, R, DR),
|
||||
]
|
||||
}
|
||||
|
||||
/// `horizontal`'s tile: 0 the left end of a row, 1 its middle, 2 its right end, 3 alone.
|
||||
pub fn horizontal(mask: u8) -> usize {
|
||||
match (mask & L != 0, mask & R != 0) {
|
||||
(false, true) => 0,
|
||||
(true, true) => 1,
|
||||
(true, false) => 2,
|
||||
(false, false) => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// `vertical`'s tile: 0 the bottom of a column, 1 its middle, 2 its top, 3 alone.
|
||||
pub fn vertical(mask: u8) -> usize {
|
||||
match (mask & U != 0, mask & D != 0) {
|
||||
(true, false) => 0,
|
||||
(true, true) => 1,
|
||||
(false, true) => 2,
|
||||
(false, false) => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// `horizontal+vertical`'s 7 tiles: a face alone in its row takes `vertical`'s first three as
|
||||
/// tiles 4-6.
|
||||
pub fn horizontal_vertical(mask: u8) -> usize {
|
||||
match (horizontal(mask), vertical(mask)) {
|
||||
(3, 3) => 3,
|
||||
(3, v) => 4 + v,
|
||||
(h, _) => h,
|
||||
}
|
||||
}
|
||||
|
||||
/// `vertical+horizontal`'s 7 tiles: a face alone in its column takes `horizontal`'s first three
|
||||
/// as tiles 4-6.
|
||||
pub fn vertical_horizontal(mask: u8) -> usize {
|
||||
match (vertical(mask), horizontal(mask)) {
|
||||
(3, 3) => 3,
|
||||
(3, h) => 4 + h,
|
||||
(v, _) => v,
|
||||
}
|
||||
}
|
||||
|
||||
/// `overlay`'s tiles (0-16) for a face whose neighbours in `mask` hold the block drawn over it:
|
||||
/// one tile for the sides (two when they face each other: the template has no tile for those),
|
||||
/// and one for each corner with neither side beside it.
|
||||
pub fn overlay_tiles(mask: u8) -> impl Iterator<Item = usize> {
|
||||
let sides: &[usize] = match (mask & U != 0, mask & R != 0, mask & D != 0, mask & L != 0) {
|
||||
(false, false, false, false) => &[],
|
||||
(true, false, false, false) => &[15],
|
||||
(false, true, false, false) => &[7],
|
||||
(false, false, true, false) => &[1],
|
||||
(false, false, false, true) => &[9],
|
||||
(true, true, false, false) => &[10],
|
||||
(true, false, false, true) => &[11],
|
||||
(false, true, true, false) => &[3],
|
||||
(false, false, true, true) => &[4],
|
||||
(true, false, true, false) => &[15, 1],
|
||||
(false, true, false, true) => &[9, 7],
|
||||
(true, true, true, false) => &[12],
|
||||
(true, false, true, true) => &[6],
|
||||
(true, true, false, true) => &[13],
|
||||
(false, true, true, true) => &[5],
|
||||
(true, true, true, true) => &[8],
|
||||
};
|
||||
let corners = [
|
||||
(UR, U | R, 14),
|
||||
(DR, D | R, 0),
|
||||
(DL, D | L, 2),
|
||||
(UL, U | L, 16),
|
||||
];
|
||||
let corners = corners
|
||||
.into_iter()
|
||||
.filter(move |&(corner, beside, _)| mask & corner != 0 && mask & beside == 0)
|
||||
.map(|(_, _, tile)| tile);
|
||||
sides.iter().copied().chain(corners)
|
||||
}
|
||||
|
||||
/// `repeat`'s tile: the block's place in a `width` x `height` pattern laid along the face's
|
||||
/// texture directions, row by row from the top.
|
||||
pub fn repeat_index(pos: Vec3, axes: Axes, width: u32, height: u32) -> usize {
|
||||
let (w, h) = (width.max(1) as i64, height.max(1) as i64);
|
||||
let u = dot(pos, axes.right).rem_euclid(w);
|
||||
let v = (-dot(pos, axes.up)).rem_euclid(h);
|
||||
(v * w + u) as usize
|
||||
}
|
||||
|
||||
/// The face whose choice `face` shares under `symmetry`: itself, the first of its opposite
|
||||
/// pair, or one face for all six.
|
||||
pub fn symmetric_face(face: Face, symmetry: Symmetry) -> Face {
|
||||
match symmetry {
|
||||
Symmetry::None => face,
|
||||
Symmetry::Opposite => Face::ALL[face as usize & !1],
|
||||
Symmetry::All => Face::Down,
|
||||
}
|
||||
}
|
||||
|
||||
/// `random`'s tile (below `count`): a fixed choice for the block at `pos` and its (symmetric)
|
||||
/// face, so a world looks the same every time it loads. `weights` is one per tile; any other
|
||||
/// length (none given) picks every tile alike. `loops` rehashes the choice (`randomLoops`).
|
||||
pub fn random_index(pos: Vec3, face: Face, loops: u32, weights: &[u32], count: usize) -> usize {
|
||||
// The golden-ratio step keeps a zero input (the origin's down face) off `mix`'s fixed point.
|
||||
let step = |h: u32, v: u32| mix((h ^ v).wrapping_add(0x9e37_79b9));
|
||||
let mut h = step(0, pos[0] as u32);
|
||||
h = step(h, pos[1] as u32);
|
||||
h = step(h, pos[2] as u32);
|
||||
h = step(h, face as u32);
|
||||
for _ in 0..loops {
|
||||
h = step(h, 0);
|
||||
}
|
||||
let total: u64 = weights.iter().map(|&w| u64::from(w)).sum();
|
||||
if weights.len() != count || total == 0 {
|
||||
return h as usize % count.max(1);
|
||||
}
|
||||
let mut pick = u64::from(h) % total;
|
||||
weights
|
||||
.iter()
|
||||
.position(|&w| match pick.checked_sub(u64::from(w)) {
|
||||
Some(rest) => {
|
||||
pick = rest;
|
||||
false
|
||||
}
|
||||
None => true,
|
||||
})
|
||||
.expect("pick is below the weights' total")
|
||||
}
|
||||
|
||||
/// A 32-bit finalizer (Chris Wellons' `lowbias32`): every input bit moves every output bit.
|
||||
fn mix(mut x: u32) -> u32 {
|
||||
x ^= x >> 16;
|
||||
x = x.wrapping_mul(0x7feb_352d);
|
||||
x ^= x >> 15;
|
||||
x = x.wrapping_mul(0x846c_a68b);
|
||||
x ^ (x >> 16)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ctm_tiles_from_the_template() {
|
||||
// Read off ctm_template.png by hand: the tile's borders are the sides that don't join.
|
||||
assert_eq!(ctm_index(0), 0);
|
||||
assert_eq!(ctm_index(R), 1);
|
||||
assert_eq!(ctm_index(R | L), 2);
|
||||
assert_eq!(ctm_index(L), 3);
|
||||
assert_eq!(ctm_index(D), 12);
|
||||
assert_eq!(ctm_index(U | D), 24);
|
||||
assert_eq!(ctm_index(U), 36);
|
||||
assert_eq!(ctm_index(255), 26);
|
||||
assert_eq!(ctm_index(U | R | D | L), 46);
|
||||
// The top-left of a 3x3 square, and a corner whose sides don't both join.
|
||||
assert_eq!(ctm_index(R | DR | D), 13);
|
||||
assert_eq!(ctm_index(U | UR | DR | DL | UL), 36);
|
||||
// One missing corner of a full square.
|
||||
assert_eq!(ctm_index(!UR), 44);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_quarters_from_the_template() {
|
||||
// ctm_compact_template.png: 0 every border, 1 none, 2 left and right borders, 3 top and
|
||||
// bottom ones, 4 the inner corners only.
|
||||
assert_eq!(compact_quarters(0), [0; 4]);
|
||||
assert_eq!(compact_quarters(255), [1; 4]);
|
||||
assert_eq!(compact_quarters(U | D), [2; 4]);
|
||||
assert_eq!(compact_quarters(L | R), [3; 4]);
|
||||
assert_eq!(compact_quarters(U | R | D | L), [4; 4]);
|
||||
// The left end of a row, and a bottom-left corner of a square.
|
||||
assert_eq!(compact_quarters(R), [0, 3, 0, 3]);
|
||||
assert_eq!(compact_quarters(U | UR | R), [2, 1, 0, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_and_columns() {
|
||||
assert_eq!([R, R | L, L, 0].map(horizontal), [0, 1, 2, 3]);
|
||||
assert_eq!([U, U | D, D, 0].map(vertical), [0, 1, 2, 3]);
|
||||
// Joined left: horizontal wins. Alone in the row: the column's tiles, from 4.
|
||||
assert_eq!(horizontal_vertical(L | U), 2);
|
||||
assert_eq!([U, U | D, D, 0].map(horizontal_vertical), [4, 5, 6, 3]);
|
||||
assert_eq!([R, R | L, L, 0].map(vertical_horizontal), [4, 5, 6, 3]);
|
||||
assert_eq!(vertical_horizontal(U | R), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_tiles_from_the_template() {
|
||||
let tiles = |mask| overlay_tiles(mask).collect::<Vec<_>>();
|
||||
assert_eq!(tiles(0), [0usize; 0]);
|
||||
assert_eq!(tiles(D), [1]);
|
||||
assert_eq!(tiles(DR), [0]);
|
||||
assert_eq!(tiles(D | DR), [1], "a corner beside a side is the side's");
|
||||
assert_eq!(tiles(U | D), [15, 1]);
|
||||
assert_eq!(tiles(U | L | DR), [11, 0]);
|
||||
assert_eq!(tiles(255), [8]);
|
||||
assert_eq!(tiles(UR | UL), [14, 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_directions() {
|
||||
// A north face seen from the north: right is west.
|
||||
assert_eq!(Axes::cube(Face::North).right, WEST);
|
||||
assert_eq!(Axes::column(Face::North, Axis::Y), Axes::cube(Face::North));
|
||||
// An x log: its grain runs east-west on the top face, its end turned on the east one.
|
||||
let top = Axes::column(Face::Up, Axis::X);
|
||||
assert_eq!((top.right, top.up), (SOUTH, EAST));
|
||||
let end = Axes::column(Face::East, Axis::X);
|
||||
assert_eq!((end.right, end.up), (SOUTH, [0, -1, 0]));
|
||||
// A z log: north face is the end.
|
||||
let end = Axes::column(Face::North, Axis::Z);
|
||||
assert_eq!((end.right, end.up), (EAST, [0, -1, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_turned_face_keeps_its_texture_facing_out() {
|
||||
// A texture's right x up is the face's normal on a cube, and turning keeps it.
|
||||
let cross = |a: Vec3, b: Vec3| {
|
||||
[
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
]
|
||||
};
|
||||
for face in Face::ALL {
|
||||
for axis in [Axis::X, Axis::Y, Axis::Z] {
|
||||
let a = Axes::column(face, axis);
|
||||
assert_eq!(cross(a.right, a.up), face.normal(), "{face:?} {axis:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neighbour_masks() {
|
||||
let axes = Axes::cube(Face::North);
|
||||
let normal = Face::North.normal();
|
||||
// Glass at the block's west (its north face's right) and above it.
|
||||
let glass = |o: Vec3| o == WEST || o == UP || o == add(WEST, UP);
|
||||
assert_eq!(neighbours(axes, normal, false, glass), U | UR | R);
|
||||
// A block in front of the west neighbour covers its face: an inner seam.
|
||||
let covered = |o: Vec3| glass(o) || o == add(WEST, NORTH);
|
||||
assert_eq!(neighbours(axes, normal, false, covered), U | UR | R);
|
||||
assert_eq!(neighbours(axes, normal, true, covered), U | UR);
|
||||
// A covered corner counts as covered too.
|
||||
let corner = |o: Vec3| glass(o) || o == add(add(WEST, UP), NORTH);
|
||||
assert_eq!(neighbours(axes, normal, true, corner), U | R);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetry() {
|
||||
assert_eq!(symmetric_face(Face::South, Symmetry::None), Face::South);
|
||||
assert_eq!(symmetric_face(Face::South, Symmetry::Opposite), Face::North);
|
||||
assert_eq!(symmetric_face(Face::East, Symmetry::Opposite), Face::West);
|
||||
assert_eq!(symmetric_face(Face::Up, Symmetry::Opposite), Face::Down);
|
||||
assert_eq!(symmetric_face(Face::East, Symmetry::All), Face::Down);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_patterns() {
|
||||
let north = Axes::cube(Face::North);
|
||||
// Along the north face the pattern runs west (its right), rows downward.
|
||||
assert_eq!(repeat_index([0, 0, 0], north, 2, 2), 0);
|
||||
assert_eq!(repeat_index([-1, 0, 0], north, 2, 2), 1);
|
||||
assert_eq!(repeat_index([0, -1, 0], north, 2, 2), 2);
|
||||
assert_eq!(repeat_index([-3, 5, 7], north, 3, 2), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_choices_are_fixed_and_weighted() {
|
||||
// Pinned, so a world keeps its tiles across versions (lowbias32 worked by hand in Python).
|
||||
assert_eq!(random_index([12, 64, -7], Face::Up, 0, &[], 5), 3);
|
||||
assert_eq!(random_index([0, 0, 0], Face::Down, 0, &[], 5), 4);
|
||||
assert_eq!(random_index([0, 0, 0], Face::Down, 1, &[], 4), 2);
|
||||
// Weights that don't fit the tiles pick every tile alike, and never past them.
|
||||
for x in 0..50 {
|
||||
assert!(random_index([x, 0, 0], Face::Up, 0, &[1, 1, 1], 2) < 2);
|
||||
}
|
||||
// Weights 1:3 over a 100x100 floor: about a quarter take tile 0.
|
||||
let mut zeros = 0;
|
||||
for x in 0..100 {
|
||||
for z in 0..100 {
|
||||
zeros += (random_index([x, 0, z], Face::Up, 0, &[1, 3], 2) == 0) as u32;
|
||||
}
|
||||
}
|
||||
assert!((2200..2800).contains(&zeros), "{zeros}");
|
||||
// A zero weight is never picked.
|
||||
for x in 0..200 {
|
||||
assert_ne!(random_index([x, 1, 2], Face::Up, 3, &[2, 0, 1], 3), 1);
|
||||
}
|
||||
// Loops pick another pattern.
|
||||
let differs = (0..50)
|
||||
.filter(|&x| {
|
||||
random_index([x, 0, 0], Face::Up, 0, &[], 4)
|
||||
!= random_index([x, 0, 0], Face::Up, 1, &[], 4)
|
||||
})
|
||||
.count();
|
||||
assert!(differs > 20, "{differs}");
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,15 @@
|
||||
//! colours): `.properties` files, the matchers their values hold, their texture paths, and the
|
||||
//! `optifine/` (or legacy `mcpatcher/`) folders that hold them. Each feature's own rules sit in
|
||||
//! a module of their own, free of the game's types (`random`: random entity textures' rules;
|
||||
//! `random_textures`: where a pack keeps their variants; [`ctm`]: connected textures).
|
||||
//! `random_textures`: where a pack keeps their variants; [`ctm`]: connected textures; `cit`:
|
||||
//! custom item textures, which the client matches against its stacks).
|
||||
//!
|
||||
//! Written from OptiFine's public documentation of the file formats (and ETF's, for its additions
|
||||
//! to random entities); no mod's source was read.
|
||||
//! to random entities); `cit` also from CIT Resewn's wiki and the conditions of its MIT-licensed
|
||||
//! source (THIRD_PARTY_NOTICES.md). No OptiFine code was read.
|
||||
|
||||
pub mod cem;
|
||||
pub mod cit;
|
||||
pub mod ctm;
|
||||
pub mod matchers;
|
||||
pub mod paths;
|
||||
@@ -17,5 +20,5 @@ pub mod random;
|
||||
pub mod random_textures;
|
||||
|
||||
pub use matchers::{BlockMatch, IdList, Name, RangeList};
|
||||
pub use paths::{properties_files, resolve};
|
||||
pub use paths::{properties_files, properties_files_in, resolve, valid};
|
||||
pub use properties::Properties;
|
||||
@@ -25,6 +25,8 @@ impl Name {
|
||||
ignore_case,
|
||||
};
|
||||
let regex = |re: &str, ignore_case| {
|
||||
// On its own first: a stray `)` would otherwise close the anchoring group early.
|
||||
Regex::new(re).ok()?;
|
||||
RegexBuilder::new(&format!("^(?:{re})$"))
|
||||
.case_insensitive(ignore_case)
|
||||
.build()
|
||||
@@ -242,6 +244,10 @@ mod tests {
|
||||
assert!(!m("regex:Bob|Alice", "Alice2"));
|
||||
assert!(m("iregex:j(eb)_", "JEB_"));
|
||||
assert!(Name::parse("regex:(").is_none());
|
||||
assert!(
|
||||
Name::parse("regex:Excalibur)|(.*").is_none(),
|
||||
"would match every name"
|
||||
);
|
||||
assert!(m("pattern:a*b*c", "aXbYc"));
|
||||
assert!(!m("pattern:a*b*c", "aXbY"));
|
||||
assert!(m("pattern:**", ""));
|
||||
|
||||
@@ -15,20 +15,23 @@ use std::path::{Path, PathBuf};
|
||||
/// The extension is the caller's: OptiFine adds `.png` to textures that have none.
|
||||
///
|
||||
/// `None` when the path would leave the resource root or is malformed: a `..` or `.` segment, an
|
||||
/// empty one (`a//b`, a leading `/`), as vanilla's resource paths refuse them, or a backslash.
|
||||
/// Callers join the result onto the overlay root, so a pack must never name a file outside it.
|
||||
/// empty one (`a//b`, a leading `/`), as vanilla's resource paths refuse them, or a `\` (see
|
||||
/// [`valid`]). Callers join the result onto the overlay root, so a pack must never name a file
|
||||
/// outside it.
|
||||
pub fn resolve(props: &str, value: &str) -> Option<String> {
|
||||
in_root(resolve_unchecked(props, value))
|
||||
}
|
||||
|
||||
/// `path` when it stays inside the resource root ([`resolve`]'s test). A `\` is refused too:
|
||||
/// Windows would read it as a separator (vanilla's resource paths never hold one).
|
||||
/// Whether a resource path stays in the root: no `.`, `..` or empty segment, and no `\` (a
|
||||
/// separator on Windows, where `..\..` would climb).
|
||||
pub fn valid(path: &str) -> bool {
|
||||
path.split('/')
|
||||
.all(|segment| !matches!(segment, "" | "." | "..") && !segment.contains('\\'))
|
||||
}
|
||||
|
||||
/// `path` when it stays inside the resource root ([`valid`]).
|
||||
pub(crate) fn in_root(path: String) -> Option<String> {
|
||||
(!path.contains('\\')
|
||||
&& path
|
||||
.split('/')
|
||||
.all(|segment| !matches!(segment, "" | "." | "..")))
|
||||
.then_some(path)
|
||||
valid(&path).then_some(path)
|
||||
}
|
||||
|
||||
fn resolve_unchecked(props: &str, value: &str) -> String {
|
||||
@@ -57,6 +60,12 @@ fn resolve_unchecked(props: &str, value: &str) -> String {
|
||||
/// `assets/*/mcpatcher/<feature>/` of `root`, as sorted resource paths. `feature` is a folder
|
||||
/// (`ctm`, `cit`, `random/entity`, `sky/world0`).
|
||||
pub fn properties_files(root: &Path, feature: &str) -> Vec<String> {
|
||||
properties_files_in(root, &["optifine", "mcpatcher"], feature)
|
||||
}
|
||||
|
||||
/// `properties_files` under other top folders too, for a feature a mod reads from its own
|
||||
/// (CIT Resewn's `citresewn/cit/`).
|
||||
pub fn properties_files_in(root: &Path, tops: &[&str], feature: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
|
||||
@@ -64,7 +73,7 @@ pub fn properties_files(root: &Path, feature: &str) -> Vec<String> {
|
||||
};
|
||||
for ns in namespaces.flatten() {
|
||||
let ns = ns.file_name().to_string_lossy().into_owned();
|
||||
for top in ["optifine", "mcpatcher"] {
|
||||
for top in tops {
|
||||
let base = format!("assets/{ns}/{top}/{feature}");
|
||||
walk(
|
||||
&root.join(&base),
|
||||
@@ -169,6 +178,7 @@ mod tests {
|
||||
"a/./b",
|
||||
"..\\..\\x.jpm",
|
||||
"~/..\\..\\x",
|
||||
"..\\..\\x.png",
|
||||
] {
|
||||
assert_eq!(resolve(props, bad), None, "{bad}");
|
||||
}
|
||||
|
||||
@@ -14,7 +14,25 @@ pub struct Properties {
|
||||
|
||||
impl Properties {
|
||||
pub fn parse(text: &str) -> Self {
|
||||
Self::read(text, split)
|
||||
}
|
||||
|
||||
/// As CIT Resewn reads its files: a line with an unescaped `=` splits there, so a key may
|
||||
/// hold an unescaped `:` (`components.minecraft:custom_name=Bob`); other lines as `parse`.
|
||||
pub fn parse_equals_first(text: &str) -> Self {
|
||||
Self::read(text, |line| match unescaped_equals(line) {
|
||||
Some(i) => (
|
||||
line[..i].trim_end_matches([' ', '\t', '\x0c']),
|
||||
&line[i + 1..],
|
||||
),
|
||||
None => split(line),
|
||||
})
|
||||
}
|
||||
|
||||
fn read(text: &str, split: impl Fn(&str) -> (&str, &str)) -> Self {
|
||||
let mut out = Self::default();
|
||||
// Each key's place in `entries`, so a file of many keys reads in linear time.
|
||||
let mut index = std::collections::HashMap::<String, usize>::new();
|
||||
let mut lines = text.lines();
|
||||
while let Some(first) = lines.next() {
|
||||
let first = first.trim_start();
|
||||
@@ -30,18 +48,18 @@ impl Properties {
|
||||
}
|
||||
}
|
||||
let (key, value) = split(&line);
|
||||
out.insert(unescape(key), unescape(value).trim().to_owned());
|
||||
let (key, value) = (unescape(key), unescape(value).trim().to_owned());
|
||||
match index.get(&key) {
|
||||
Some(&i) => out.entries[i].1 = value,
|
||||
None => {
|
||||
index.insert(key.clone(), out.entries.len());
|
||||
out.entries.push((key, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn insert(&mut self, key: String, value: String) {
|
||||
match self.entries.iter_mut().find(|(k, _)| *k == key) {
|
||||
Some(entry) => entry.1 = value,
|
||||
None => self.entries.push((key, value)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&str> {
|
||||
self.entries
|
||||
.iter()
|
||||
@@ -62,6 +80,19 @@ fn ends_in_continuation(line: &str) -> bool {
|
||||
line.bytes().rev().take_while(|&b| b == b'\\').count() % 2 == 1
|
||||
}
|
||||
|
||||
fn unescaped_equals(line: &str) -> Option<usize> {
|
||||
let bytes = line.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
match bytes[i] {
|
||||
b'\\' => i += 2,
|
||||
b'=' => return Some(i),
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The key ends at the first unescaped `=`, `:` or whitespace; then whitespace and at most one
|
||||
/// `=` or `:` are skipped.
|
||||
fn split(line: &str) -> (&str, &str) {
|
||||
@@ -96,8 +127,24 @@ fn unescape(s: &str) -> String {
|
||||
Some('r') => out.push('\r'),
|
||||
Some('f') => out.push('\x0c'),
|
||||
Some('u') => {
|
||||
let hex: String = chars.by_ref().take(4).collect();
|
||||
if let Some(c) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
|
||||
let mut unit = || {
|
||||
let hex: String = chars.by_ref().take(4).collect();
|
||||
u32::from_str_radix(&hex, 16).ok()
|
||||
};
|
||||
let Some(mut code) = unit() else { continue };
|
||||
// A character past U+FFFF is two escapes, as Java writes it: `\uD83D\uDE00`.
|
||||
if (0xD800..0xDC00).contains(&code) {
|
||||
let rest = chars.as_str();
|
||||
if let Some(low) = rest
|
||||
.strip_prefix("\\u")
|
||||
.and_then(|r| u32::from_str_radix(r.get(..4)?, 16).ok())
|
||||
.filter(|low| (0xDC00..0xE000).contains(low))
|
||||
{
|
||||
chars.nth(5);
|
||||
code = 0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00);
|
||||
}
|
||||
}
|
||||
if let Some(c) = char::from_u32(code) {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
@@ -136,6 +183,21 @@ mod tests {
|
||||
assert_eq!(p.get("key=with:colon"), Some("a\tb"));
|
||||
// An even number of backslashes is an escaped backslash, not a continuation.
|
||||
assert_eq!(p.get("last\\"), Some("x"));
|
||||
let emoji = Properties::parse("nbt.display.Name=\\uD83D\\uDE00 Sword\n");
|
||||
assert_eq!(emoji.get("nbt.display.Name"), Some("\u{1F600} Sword"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equals_first_keeps_colons_in_keys() {
|
||||
let text = "components.minecraft:custom_name = Bob\nnbt.a\\=b=c\ntype:item\n";
|
||||
let p = Properties::parse_equals_first(text);
|
||||
assert_eq!(p.get("components.minecraft:custom_name"), Some("Bob"));
|
||||
assert_eq!(p.get("nbt.a=b"), Some("c"), "an escaped = is the key's");
|
||||
assert_eq!(p.get("type"), Some("item"), "no = at all: Java's rules");
|
||||
assert_eq!(
|
||||
Properties::parse(text).get("components.minecraft"),
|
||||
Some("custom_name = Bob")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -700,8 +700,9 @@ pub fn current_world_gen_settings(base: &Path) -> io::Result<Option<Tag>> {
|
||||
|
||||
// ------------------------------------------------------------------ the read-time upgrades
|
||||
|
||||
/// Chunk generator types per dimension from `world_gen_settings.dat`.
|
||||
pub(super) fn generators(base: &Path) -> io::Result<Vec<(String, String)>> {
|
||||
/// Chunk generator types per dimension from `world_gen_settings.dat`, the
|
||||
/// `generator` of the context the chunk fixes read.
|
||||
pub fn generators(base: &Path) -> io::Result<Vec<(String, String)>> {
|
||||
let Some(tag) = read_dat(&base.join("data/minecraft/world_gen_settings.dat"))? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
@@ -20,6 +20,14 @@ impl InteractionResult {
|
||||
pub fn consumes_action(self) -> bool {
|
||||
!matches!(self, Self::Pass)
|
||||
}
|
||||
|
||||
/// Whether the interaction swings the player's arm.
|
||||
///
|
||||
/// In vanilla, InteractionResult.SUCCESS swings (swing source CLIENT),
|
||||
/// while CONSUME consumes the action without swinging (swing source SERVER).
|
||||
pub fn should_swing(self) -> bool {
|
||||
matches!(self, Self::Success)
|
||||
}
|
||||
}
|
||||
/// Which part of `BlockState` answered a use on a block.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -2758,16 +2758,19 @@ fn flowers_and_saplings_can_be_placed_in_flower_pots() {
|
||||
assert_eq!(w.get(pos), empty_pot);
|
||||
}
|
||||
|
||||
// Right-clicking an empty flower pot with an empty hand does nothing (CONSUME).
|
||||
// Right-clicking an empty flower pot with an empty hand does nothing (CONSUME, no swing).
|
||||
w.put(pos, empty_pot);
|
||||
assert_eq!(w.use_block(pos), InteractionResult::Consume);
|
||||
let empty_click = w.use_block(pos);
|
||||
assert_eq!(empty_click, InteractionResult::Consume);
|
||||
assert!(empty_click.consumes_action());
|
||||
assert!(!empty_click.should_swing());
|
||||
assert_eq!(w.get(pos), empty_pot);
|
||||
|
||||
// Right-clicking an empty flower pot with a non-plant item does nothing (CONSUME).
|
||||
assert_eq!(
|
||||
w.use_item(pos, "minecraft:stone"),
|
||||
InteractionResult::Consume
|
||||
);
|
||||
// Right-clicking an empty flower pot with a non-plant item does nothing (CONSUME, no swing).
|
||||
let non_plant_click = w.use_item(pos, "minecraft:stone");
|
||||
assert_eq!(non_plant_click, InteractionResult::Consume);
|
||||
assert!(non_plant_click.consumes_action());
|
||||
assert!(!non_plant_click.should_swing());
|
||||
assert_eq!(w.get(pos), empty_pot);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ pub type Compound = BTreeMap<JavaString, Tag>;
|
||||
/// canonicalizes NaNs (`DataOutputStream.writeFloat`); its network output
|
||||
/// keeps their bits (Netty's `ByteBuf.writeFloat`). Loading, like vanilla's
|
||||
/// valueOf factories, normalizes negative zero to positive zero.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Tag {
|
||||
End,
|
||||
Byte(i8),
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ there.
|
||||
|---|---|---|
|
||||
| `timer` | a sped-up game (more ticks a second than the server's clock) | vanilla's client ends each tick with ServerboundClientTickEndPacket, at the server's tick rate. Each one spends one tick of a balance that wall-clock time fills. A client more than `tolerance_ms` (300) ahead is flagged; a quiet connection banks at most `max_lag_ms` (1000), so a lag spike's backlog isn't flagged and a pause can't be saved for a burst. A slow server tick adds its own time before its backlog is counted. |
|
||||
| `ground` | no-fall (claiming the ground to skip fall damage) and walking on water | a move that says it ends on the ground is judged only when every block around the feet (0.6 below them, 1/16 wider than the body) is air or a fluid, nothing collides a tenth of a block down, and no boat, minecart, shulker or happy ghast is near. Anything else (powder snow, scaffolding, a waterlogged slab...) isn't judged. A false claim is dropped, so vanilla's fall damage follows (not while the check only logs); the player is never sent back, and a move whose claim was dropped never counts toward vanilla's floating kick (sent back into the air it claimed to stand in, an honest player on a block the server doesn't have claimed it again until that kick: only the anticheat's own kick, logged and told to the operators, ends a false claim); creative, spectators and gliders are skipped (no fall damage to dodge), and so is a claim standing still (no fall to skip; hovering is vanilla's floating kick). |
|
||||
| `prediction` | speed, step, fly, jesus: any move vanilla's movement can't make from the player's own keys | each client tick (the packets between two ServerboundClientTickEndPackets: keys, sprinting, rotation, the move) is recorded, and every server tick ends with a ping so the server knows from which client tick on what it changed (a push, a teleport, an effect) was in effect. Each tick is then played again with the client's own movement code, from where the last one ended (it starts at a teleport the client accepted), and compared with where the client said it went; the replay then goes on from the client's position. What the server sent the player that changes its movement (a velocity, an explosion's knockback, attributes, effects, the sprinting flag) is applied on the first client tick that had seen it, so a client that ignores a push (anti-knockback) is off too. The keys and sprinting are the client's own, so they are never guessed; what the server can't know is tried (the ground on the first tick after a teleport, the item use on the tick the server's changed). Not played: riding, sleeping, dead, spectators. A move that lands farther than `tolerance` (0.03 blocks) from the replay flags, weighing its offset in tolerances (at most 5 a tick); the setback puts the player back where its moves last agreed. What the replay can't play yet excuses the tick, and the ticks after it until the moves agree again (2 seconds at most): another entity or player within 3 blocks (a push, a boat or shulker to stand on, a firework to fly with), an entity within 4 blocks that the server removed and the client hadn't heard of yet, a block change within 4 blocks the client hadn't seen or the server hasn't sent yet, abilities (a game mode, flight, a glider put on) the client hadn't seen, a moving piston, a chunk around the player the client doesn't have, a riptide launch, a teleport that keeps the player's motion (an End gateway's). Around a fast player (an elytra) these distances grow by two ticks of its speed, as what the server has around it is a tick or so behind. Under an excuse the replay takes the client's velocity, so it agrees again within a few ticks. It costs one ping and one replayed tick a tick per player. |
|
||||
| `prediction` | speed, step, fly, jesus: any move vanilla's movement can't make from the player's own keys | each client tick (the packets between two ServerboundClientTickEndPackets: keys, sprinting, rotation, the move) is recorded, and every server tick ends with a ping so the server knows from which client tick on what it changed (a push, a teleport, an effect) was in effect. Each tick is then played again with the client's own movement code, from where the last one ended (it starts at a teleport the client accepted), and compared with where the client said it went; the replay then goes on from the client's position. What the server sent the player that changes its movement (a velocity, an explosion's knockback, attributes, effects, the sprinting flag) is applied on the first client tick that had seen it, so a client that ignores a push (anti-knockback) is off too. The keys and sprinting are the client's own (its sprint commands, not the server's player, whose sprint a hit stops), so they are never guessed; a sprint the server stopped that the client says goes on is played only if vanilla's client starts it again (the sprint key held, or forward pressed again); what the server can't know is tried (the ground on the first tick after a teleport, the item use on the tick the server's changed). Not played: riding, sleeping, dead, spectators. A move that lands farther than `tolerance` (0.03 blocks) from the replay flags, weighing its offset in tolerances (at most 5 a tick); the setback puts the player back where its moves last agreed. What the replay can't play yet excuses the tick, and the ticks after it until the moves agree again (2 seconds at most): another entity or player within 3 blocks (a push, a boat or shulker to stand on, a firework to fly with), an entity within 4 blocks that the server removed and the client hadn't heard of yet, a block change within 4 blocks the client hadn't seen or the server hasn't sent yet, abilities (a game mode, flight, a glider put on) the client hadn't seen, a moving piston, a chunk around the player the client doesn't have, a riptide launch, a teleport that keeps the player's motion (an End gateway's). Around a fast player (an elytra) these distances grow by two ticks of its speed, as what the server has around it is a tick or so behind. Under an excuse the replay takes the client's velocity, so it agrees again within a few ticks. It costs one ping and one replayed tick a tick per player. |
|
||||
| `combat` | a kill aura: attacks a vanilla client can't send | vanilla's client attacks from one place only (`Minecraft.startAttack`, once a client tick, with no screen open): the entity under the crosshair, picked once a tick, then it swings (ServerboundPunchPacket) at once. An attack with no swing before the next attack or the tick's end (a kill aura hiding its swing), or a second entity attacked in the same client tick (a multi-target aura), flags once. From `setback` (2) on the player's attacks are dropped until the level decays below it (`decay` 0.5 a second); `kick` is 20. It costs nothing until a player attacks. |
|
||||
|
||||
More checks (aim, world, anti-xray) come in their own pull requests; this table lists the
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 740 KiB |
@@ -309,7 +309,15 @@ To chat on such servers the game (`auth.rs`, `game/remote_chat.rs`, `horizon_pro
|
||||
`tools/secure_chat_client_e2e.py` checks it against vanilla 26.3's server with a mock of the services
|
||||
that issues certificates: online mode with `enforce-secure-profile=true` (signed chat logged as secure,
|
||||
a `/msg` whisper delivered, a message flushed from Chat Delay still counted as seen, nothing refused) and
|
||||
offline mode (unsigned chat, as before). Other players' signatures are not verified yet.
|
||||
offline mode (unsigned chat, as before).
|
||||
|
||||
Chat from other players is checked as vanilla's client does (`ClientPacketListener.handlePlayerChat`,
|
||||
`game/remote.rs`, `secure_chat::MessageValidator`): a wrong global chat index disconnects, and a sender
|
||||
with no list entry or a message its validator refuses shows the red, italic `chat.validation_error`. A
|
||||
chat session the services' profile-key keys vouch for makes a keyed validator (the key not past its
|
||||
expiry plus 8 hours, the signature, the chain); anything else makes the fallback, which accepts unsigned
|
||||
messages, or none where the server enforces secure chat. The unit tests in `secure_chat.rs` cover the
|
||||
validators; the script above doesn't send another player's chat.
|
||||
|
||||
## Harnesses
|
||||
|
||||
|
||||
Generated
+1
@@ -2711,6 +2711,7 @@ dependencies = [
|
||||
"toml 1.1.8+spec-1.1.0",
|
||||
"trash",
|
||||
"ureq",
|
||||
"windows-sys 0.61.2",
|
||||
"winresource",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -78,6 +78,8 @@ objc2-app-kit = { version = "0.3", default-features = false, features = ["NSAcce
|
||||
raw-window-handle = "0.6"
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
keyring = { version = "3", features = ["windows-native"] }
|
||||
# The computer's memory, the end of the Launch panel's memory slider; already in the lockfile.
|
||||
windows-sys = { version = "0.61", features = ["Win32_System_SystemInformation"] }
|
||||
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
# The icon resource in the .exe (build.rs).
|
||||
|
||||
@@ -114,6 +114,9 @@ The opening, then Play ([clip](docs/opening.webm)):
|
||||
pack into the selected instance. A data pack is pointed at the world rows, and the Packs section
|
||||
and the world rows keep taking their own drops. A note in the corner says what happened.
|
||||
`horizon-launcher instance import <file>`.
|
||||
- **Maximum memory**: a row on Home, per instance: a slider and a box that takes `4096`, `4gb`, or nothing
|
||||
for no limit (the default), saved at once. For Horizon it goes to the game as
|
||||
`HORIZON_MEMORY_LIMIT_MB` ([what it does](../docs/native-client.md#ram-limit)); a Java instance's sets `-Xmx`.
|
||||
- **Launch options**: Home's Instance row has **Launch…**: per instance, a shell command run in its
|
||||
folder before the game (the game starts only if it succeeds), a wrapper the game runs through
|
||||
(`nice -n 5`), and environment variables (`NAME=value OTHER="two words"`), kept in
|
||||
|
||||
@@ -316,6 +316,14 @@
|
||||
"needed by another pack": "von einem anderen Paket benötigt",
|
||||
"Older →": "Ältere →",
|
||||
"Saved: nothing runs around the game.": "Gespeichert: Rund um das Spiel wird nichts ausgeführt.",
|
||||
"Maximum memory": "Maximaler Arbeitsspeicher",
|
||||
"No limit": "Keine Begrenzung",
|
||||
"limited to {mb} MB": "auf {mb} MB begrenzt",
|
||||
"Saved: the next Play uses at most {mb} MB.": "Gespeichert: Der nächste Start nutzt höchstens {mb} MB.",
|
||||
"Saved: no memory limit on the next Play.": "Gespeichert: keine Speichergrenze beim nächsten Start.",
|
||||
"Type a number of MB like 4096, or 4gb, or leave it empty for no limit.": "Gib eine Zahl in MB wie 4096 oder 4gb ein, oder lass das Feld leer für keine Begrenzung.",
|
||||
"The game stays under this. Very low values make it slower, and it crashes only if it can't run at all. Change it while playing with /ramlimit.": "Das Spiel bleibt unter diesem Wert. Sehr niedrige Werte machen es langsamer, und es stürzt nur ab, wenn es gar nicht laufen kann. Im Spiel änderst du den Wert mit /ramlimit.",
|
||||
"How much memory Java may use (-Xmx). Set it too low and the game can crash.": "Wie viel Speicher Java nutzen darf (-Xmx). Ist der Wert zu niedrig, kann das Spiel abstürzen.",
|
||||
"Could not run {program}: {error}": "Could not run {program}: {error}",
|
||||
"Restart": "Neu starten",
|
||||
"Copied to the clipboard.": "In die Zwischenablage kopiert.",
|
||||
|
||||
@@ -316,6 +316,14 @@
|
||||
"needed by another pack": "necesario para otro paquete",
|
||||
"Older →": "Anteriores →",
|
||||
"Saved: nothing runs around the game.": "Guardado: no se ejecuta nada alrededor del juego.",
|
||||
"Maximum memory": "Memoria máxima",
|
||||
"No limit": "Sin límite",
|
||||
"limited to {mb} MB": "limitado a {mb} MB",
|
||||
"Saved: the next Play uses at most {mb} MB.": "Guardado: la próxima partida usa como máximo {mb} MB.",
|
||||
"Saved: no memory limit on the next Play.": "Guardado: sin límite de memoria en la próxima partida.",
|
||||
"Type a number of MB like 4096, or 4gb, or leave it empty for no limit.": "Escribe un número de MB como 4096, o 4gb, o déjalo vacío para no poner límite.",
|
||||
"The game stays under this. Very low values make it slower, and it crashes only if it can't run at all. Change it while playing with /ramlimit.": "El juego se mantiene por debajo de este valor. Los valores muy bajos lo ralentizan, y solo falla si no puede funcionar en absoluto. Cámbialo durante la partida con /ramlimit.",
|
||||
"How much memory Java may use (-Xmx). Set it too low and the game can crash.": "La memoria que puede usar Java (-Xmx). Un valor demasiado bajo puede hacer que el juego falle.",
|
||||
"Could not run {program}: {error}": "No se pudo ejecutar {program}: {error}",
|
||||
"Restart": "Reiniciar",
|
||||
"Copied to the clipboard.": "Copiado al portapapeles.",
|
||||
|
||||
@@ -316,6 +316,14 @@
|
||||
"needed by another pack": "requis par un autre pack",
|
||||
"Older →": "Plus anciennes →",
|
||||
"Saved: nothing runs around the game.": "Enregistré : rien ne s'exécute autour du jeu.",
|
||||
"Maximum memory": "Mémoire maximale",
|
||||
"No limit": "Aucune limite",
|
||||
"limited to {mb} MB": "limité à {mb} Mo",
|
||||
"Saved: the next Play uses at most {mb} MB.": "Enregistré : le prochain lancement utilise au plus {mb} Mo.",
|
||||
"Saved: no memory limit on the next Play.": "Enregistré : aucune limite de mémoire au prochain lancement.",
|
||||
"Type a number of MB like 4096, or 4gb, or leave it empty for no limit.": "Saisissez un nombre de Mo comme 4096, ou 4gb, ou laissez vide pour aucune limite.",
|
||||
"The game stays under this. Very low values make it slower, and it crashes only if it can't run at all. Change it while playing with /ramlimit.": "Le jeu reste en dessous de cette valeur. Une valeur très basse le ralentit, et il ne plante que s'il ne peut plus du tout fonctionner. Modifiez-la en jeu avec /ramlimit.",
|
||||
"How much memory Java may use (-Xmx). Set it too low and the game can crash.": "La mémoire que Java peut utiliser (-Xmx). Une valeur trop basse peut faire planter le jeu.",
|
||||
"Could not run {program}: {error}": "Could not run {program}: {error}",
|
||||
"Restart": "Redémarrer",
|
||||
"Copied to the clipboard.": "Copié dans le presse-papiers.",
|
||||
|
||||
@@ -552,6 +552,14 @@
|
||||
"Run the game through": "Run the game through",
|
||||
"Older →": "Older →",
|
||||
"Saved: nothing runs around the game.": "Saved: nothing runs around the game.",
|
||||
"Maximum memory": "最大メモリ",
|
||||
"No limit": "制限なし",
|
||||
"limited to {mb} MB": "{mb} MB に制限",
|
||||
"Saved: the next Play uses at most {mb} MB.": "保存しました: 次回のプレイでは最大 {mb} MB を使用します。",
|
||||
"Saved: no memory limit on the next Play.": "保存しました: 次回のプレイではメモリを制限しません。",
|
||||
"Type a number of MB like 4096, or 4gb, or leave it empty for no limit.": "4096 のような MB の数値か 4gb を入力してください。制限しない場合は空欄にします。",
|
||||
"The game stays under this. Very low values make it slower, and it crashes only if it can't run at all. Change it while playing with /ramlimit.": "ゲームはこの値を超えません。値が非常に低いと遅くなり、まったく動作できない場合にのみクラッシュします。プレイ中は /ramlimit で変更できます。",
|
||||
"How much memory Java may use (-Xmx). Set it too low and the game can crash.": "Java が使用できるメモリ量 (-Xmx) です。低すぎるとゲームがクラッシュすることがあります。",
|
||||
"Copied to the clipboard.": "Copied to the clipboard.",
|
||||
"Start from": "Start from",
|
||||
"Export": "Export",
|
||||
|
||||
@@ -316,6 +316,14 @@
|
||||
"needed by another pack": "wymagane przez inną paczkę",
|
||||
"Older →": "Starsze →",
|
||||
"Saved: nothing runs around the game.": "Zapisano: nic nie jest uruchamiane wokół gry.",
|
||||
"Maximum memory": "Maksymalna pamięć",
|
||||
"No limit": "Bez limitu",
|
||||
"limited to {mb} MB": "limit {mb} MB",
|
||||
"Saved: the next Play uses at most {mb} MB.": "Zapisano: następne uruchomienie użyje najwyżej {mb} MB.",
|
||||
"Saved: no memory limit on the next Play.": "Zapisano: bez limitu pamięci przy następnym uruchomieniu.",
|
||||
"Type a number of MB like 4096, or 4gb, or leave it empty for no limit.": "Wpisz liczbę MB, np. 4096 lub 4gb, albo zostaw puste, aby nie ustawiać limitu.",
|
||||
"The game stays under this. Very low values make it slower, and it crashes only if it can't run at all. Change it while playing with /ramlimit.": "Gra nie przekracza tej wartości. Bardzo niskie wartości ją spowalniają, a awaria nastąpi tylko wtedy, gdy gra w ogóle nie może działać. Zmienisz ją w trakcie gry poleceniem /ramlimit.",
|
||||
"How much memory Java may use (-Xmx). Set it too low and the game can crash.": "Ile pamięci może użyć Java (-Xmx). Zbyt niska wartość może spowodować awarię gry.",
|
||||
"Could not run {program}: {error}": "Could not run {program}: {error}",
|
||||
"Restart": "Uruchom ponownie",
|
||||
"Copied to the clipboard.": "Skopiowano do schowka.",
|
||||
|
||||
@@ -552,6 +552,14 @@
|
||||
"Run the game through": "Rodar o jogo através de",
|
||||
"Older →": "Mais antigos →",
|
||||
"Saved: nothing runs around the game.": "Salvo: nada roda junto com o jogo.",
|
||||
"Maximum memory": "Memória máxima",
|
||||
"No limit": "Sem limite",
|
||||
"limited to {mb} MB": "limitado a {mb} MB",
|
||||
"Saved: the next Play uses at most {mb} MB.": "Salvo: a próxima partida usa no máximo {mb} MB.",
|
||||
"Saved: no memory limit on the next Play.": "Salvo: sem limite de memória na próxima partida.",
|
||||
"Type a number of MB like 4096, or 4gb, or leave it empty for no limit.": "Digite um número de MB como 4096, ou 4gb, ou deixe vazio para não ter limite.",
|
||||
"The game stays under this. Very low values make it slower, and it crashes only if it can't run at all. Change it while playing with /ramlimit.": "O jogo fica abaixo deste valor. Valores muito baixos o deixam mais lento, e ele só falha se não conseguir rodar de jeito nenhum. Altere durante a partida com /ramlimit.",
|
||||
"How much memory Java may use (-Xmx). Set it too low and the game can crash.": "Quanta memória o Java pode usar (-Xmx). Um valor baixo demais pode fazer o jogo falhar.",
|
||||
"Copied to the clipboard.": "Copiado para a área de transferência.",
|
||||
"Start from": "Começar de",
|
||||
"Export": "Exportar",
|
||||
|
||||
@@ -316,6 +316,14 @@
|
||||
"needed by another pack": "требуется для другого пакета",
|
||||
"Older →": "Старее →",
|
||||
"Saved: nothing runs around the game.": "Сохранено: вокруг игры ничего дополнительно не запускается.",
|
||||
"Maximum memory": "Максимум памяти",
|
||||
"No limit": "Без ограничения",
|
||||
"limited to {mb} MB": "ограничено {mb} МБ",
|
||||
"Saved: the next Play uses at most {mb} MB.": "Сохранено: следующий запуск использует не более {mb} МБ.",
|
||||
"Saved: no memory limit on the next Play.": "Сохранено: при следующем запуске память не ограничена.",
|
||||
"Type a number of MB like 4096, or 4gb, or leave it empty for no limit.": "Введите число МБ, например 4096 или 4gb, или оставьте пустым, чтобы не ограничивать.",
|
||||
"The game stays under this. Very low values make it slower, and it crashes only if it can't run at all. Change it while playing with /ramlimit.": "Игра не превышает это значение. При очень низких значениях она работает медленнее и вылетает, только если совсем не может работать. Изменить его во время игры можно командой /ramlimit.",
|
||||
"How much memory Java may use (-Xmx). Set it too low and the game can crash.": "Сколько памяти может использовать Java (-Xmx). При слишком низком значении игра может вылететь.",
|
||||
"Could not run {program}: {error}": "Could not run {program}: {error}",
|
||||
"Restart": "Перезапустить",
|
||||
"Copied to the clipboard.": "Скопировано в буфер обмена.",
|
||||
|
||||
+13
-5
@@ -42,7 +42,7 @@
|
||||
"Restart": "Yeniden Başlat",
|
||||
"Check now": "Şimdi kontrol et",
|
||||
"Open": "Aç",
|
||||
"Data folder": "Klasör",
|
||||
"Data folder": "Veri klasörü",
|
||||
"Logs": "Günlükler",
|
||||
"crash reports beside it": "yanındaki çökme raporları",
|
||||
"Applications menu": "Uygulamalar menüsü",
|
||||
@@ -105,7 +105,7 @@
|
||||
"Playing offline": "Çevrimdışı oynanıyor",
|
||||
"Online servers need a Microsoft account.": "Çevrimiçi sunucular bir Microsoft hesabı gerektirir.",
|
||||
"Accounts…": "Hesaplar…",
|
||||
"Horizon crashed": "Horizon kilitlendi",
|
||||
"Horizon crashed": "Horizon çöktü",
|
||||
"When you report it on Discord, attach the crash report file (or paste it with Copy report) and say what you were doing.": "Bunu Discord'da bildirdiğinizde, çökme raporu dosyasını ekleyin (veya \"Raporu kopyala\" ile yapıştırın) ve ne yaptığınızı belirtin.",
|
||||
"The game ended without a crash report. When you report it on Discord, attach latest.log from the logs folder and say what you were doing.": "Oyun bir çökme raporu olmaksızın sonlandı. Bunu Discord'da bildirdiğinizde, logs klasöründen latest.log dosyasını ekleyin ve ne yaptığınızı belirtin.",
|
||||
"Open report": "Raporu aç",
|
||||
@@ -162,7 +162,7 @@
|
||||
"Your worlds, offline names and settings stay where they are.": "Dünyalarınız, çevrimdışı adlarınız ve ayarlarınız olduğu yerde kalır.",
|
||||
"Open {link} in your browser and enter this code:": "Tarayıcınızda {link} adresini açın ve şu kodu girin:",
|
||||
"From my install": "Kurulumumdan",
|
||||
"Download and prepare": "İndir ve hazirla",
|
||||
"Download and prepare": "İndir ve hazırla",
|
||||
"Not prepared. Play downloads Minecraft {version} from Mojang for your account and prepares it.": "Hazırlanmadı. Oyna düğmesi, hesabınız için Minecraft {version} sürümünü Mojang'dan indirir ve hazırlar.",
|
||||
"Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.": "Hazırlanmadı. Horizon, hesabınız için Minecraft {version} sürümünü Mojang'dan indirebilir veya kendi kurulumunuzu kullanabilir.",
|
||||
"Not prepared. Sign in with the Microsoft account that owns Minecraft {version} first.": "Hazırlanmadı. Önce Minecraft {version} sürümüne sahip olan Microsoft hesabıyla oturum açın.",
|
||||
@@ -335,7 +335,7 @@
|
||||
"The MCP server did not answer: {error}": "MCP sunucusu yanıt vermedi: {error}",
|
||||
"What an agent can do, and the commands": "Bir ajanın yapabilecekleri ve komutlar",
|
||||
"Added. Restart {program} if it is open, then ask it: \"Say hello from Horizon\".": "Eklendi. Açıksa {program} programını yeniden başlatın, ardından ona sorun: \"Say hello from Horizon\".",
|
||||
"{program} said: {output}": "{program} şოვu söyledi: {output}",
|
||||
"{program} said: {output}": "{program} şunu söyledi: {output}",
|
||||
"{program} is not installed here (or not on the PATH): copy the config instead.": "{program} buraya kurulu değil (veya PATH üzerinde değil): bunun yerine yapılandırmayı kopyalayın.",
|
||||
"Could not run {program}: {error}": "{program} çalıştırılamadı: {error}",
|
||||
"Always on": "Her zaman açık",
|
||||
@@ -552,6 +552,14 @@
|
||||
"Run the game through": "Run the game through",
|
||||
"Older →": "Older →",
|
||||
"Saved: nothing runs around the game.": "Saved: nothing runs around the game.",
|
||||
"Maximum memory": "En fazla bellek",
|
||||
"No limit": "Sınır yok",
|
||||
"limited to {mb} MB": "{mb} MB ile sınırlı",
|
||||
"Saved: the next Play uses at most {mb} MB.": "Kaydedildi: sonraki oyun en fazla {mb} MB kullanır.",
|
||||
"Saved: no memory limit on the next Play.": "Kaydedildi: sonraki oyunda bellek sınırı yok.",
|
||||
"Type a number of MB like 4096, or 4gb, or leave it empty for no limit.": "4096 gibi bir MB değeri veya 4gb yaz ya da sınır olmaması için boş bırak.",
|
||||
"The game stays under this. Very low values make it slower, and it crashes only if it can't run at all. Change it while playing with /ramlimit.": "Oyun bu değerin altında kalır. Çok düşük değerler oyunu yavaşlatır ve oyun yalnızca hiç çalışamazsa çöker. Oyun sırasında /ramlimit ile değiştirebilirsin.",
|
||||
"How much memory Java may use (-Xmx). Set it too low and the game can crash.": "Java'nın kullanabileceği bellek (-Xmx). Değer çok düşükse oyun çökebilir.",
|
||||
"Copied to the clipboard.": "Copied to the clipboard.",
|
||||
"Start from": "Start from",
|
||||
"Export": "Export",
|
||||
@@ -642,7 +650,7 @@
|
||||
"Needs refresh": "Yenilenmesi gerekiyor",
|
||||
"Sign in again": "Tekrar oturum aç",
|
||||
"Could not check sign-in": "Oturum kontrol edilemedi",
|
||||
"Minecraft account needs attention": "Minecraft hesabı ilgi gerektiriyor",
|
||||
"Minecraft account needs attention": "Minecraft hesabı dikkat gerektiriyor",
|
||||
"Sign-in not checked": "Oturum kontrol edilmedi",
|
||||
"Refresh": "Yenile",
|
||||
"Sign in again in your browser.": "Tarayıcınızda tekrar oturum açın.",
|
||||
|
||||
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user